agent-embassy 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/CONTRIBUTING.md +122 -0
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/README.zh-CN.md +185 -0
- package/SECURITY.md +224 -0
- package/assets/live-dashboard/app.css +1610 -0
- package/assets/mark-seal.svg +7 -0
- package/assets/mark.svg +7 -0
- package/assets/social-preview.png +0 -0
- package/assets/vendor/react/LICENSE +21 -0
- package/assets/vendor/react/react-dom.production.min.js +267 -0
- package/assets/vendor/react/react.production.min.js +31 -0
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +11 -0
- package/dist/src/errors.js.map +1 -0
- package/dist/src/gateway/claude-compatibility-scratch.d.ts +18 -0
- package/dist/src/gateway/claude-compatibility-scratch.js +158 -0
- package/dist/src/gateway/claude-compatibility-scratch.js.map +1 -0
- package/dist/src/gateway/claude-helper-client.d.ts +43 -0
- package/dist/src/gateway/claude-helper-client.js +212 -0
- package/dist/src/gateway/claude-helper-client.js.map +1 -0
- package/dist/src/gateway/claude-helper-protocol.d.ts +172 -0
- package/dist/src/gateway/claude-helper-protocol.js +373 -0
- package/dist/src/gateway/claude-helper-protocol.js.map +1 -0
- package/dist/src/gateway/claude-helper-supervisor.d.ts +74 -0
- package/dist/src/gateway/claude-helper-supervisor.js +497 -0
- package/dist/src/gateway/claude-helper-supervisor.js.map +1 -0
- package/dist/src/gateway/claude-helper.d.ts +1 -0
- package/dist/src/gateway/claude-helper.js +269 -0
- package/dist/src/gateway/claude-helper.js.map +1 -0
- package/dist/src/gateway/claude-peer.d.ts +273 -0
- package/dist/src/gateway/claude-peer.js +2359 -0
- package/dist/src/gateway/claude-peer.js.map +1 -0
- package/dist/src/gateway/claude-runtime.d.ts +40 -0
- package/dist/src/gateway/claude-runtime.js +302 -0
- package/dist/src/gateway/claude-runtime.js.map +1 -0
- package/dist/src/gateway/cli-copy.d.ts +8 -0
- package/dist/src/gateway/cli-copy.en.d.ts +13 -0
- package/dist/src/gateway/cli-copy.en.js +43 -0
- package/dist/src/gateway/cli-copy.en.js.map +1 -0
- package/dist/src/gateway/cli-copy.js +19 -0
- package/dist/src/gateway/cli-copy.js.map +1 -0
- package/dist/src/gateway/cli-copy.zh-CN.d.ts +13 -0
- package/dist/src/gateway/cli-copy.zh-CN.js +43 -0
- package/dist/src/gateway/cli-copy.zh-CN.js.map +1 -0
- package/dist/src/gateway/cli.d.ts +44 -0
- package/dist/src/gateway/cli.js +969 -0
- package/dist/src/gateway/cli.js.map +1 -0
- package/dist/src/gateway/codex-app-server.d.ts +310 -0
- package/dist/src/gateway/codex-app-server.js +1746 -0
- package/dist/src/gateway/codex-app-server.js.map +1 -0
- package/dist/src/gateway/codex-local-transport.d.ts +81 -0
- package/dist/src/gateway/codex-local-transport.js +617 -0
- package/dist/src/gateway/codex-local-transport.js.map +1 -0
- package/dist/src/gateway/codex-registration-generation.d.ts +5 -0
- package/dist/src/gateway/codex-registration-generation.js +19 -0
- package/dist/src/gateway/codex-registration-generation.js.map +1 -0
- package/dist/src/gateway/codex-registration-succession.d.ts +209 -0
- package/dist/src/gateway/codex-registration-succession.js +588 -0
- package/dist/src/gateway/codex-registration-succession.js.map +1 -0
- package/dist/src/gateway/compatibility.d.ts +70 -0
- package/dist/src/gateway/compatibility.js +252 -0
- package/dist/src/gateway/compatibility.js.map +1 -0
- package/dist/src/gateway/config.d.ts +24 -0
- package/dist/src/gateway/config.js +130 -0
- package/dist/src/gateway/config.js.map +1 -0
- package/dist/src/gateway/control.d.ts +308 -0
- package/dist/src/gateway/control.js +1573 -0
- package/dist/src/gateway/control.js.map +1 -0
- package/dist/src/gateway/dashboard-copy.d.ts +7 -0
- package/dist/src/gateway/dashboard-copy.en.d.ts +506 -0
- package/dist/src/gateway/dashboard-copy.en.js +507 -0
- package/dist/src/gateway/dashboard-copy.en.js.map +1 -0
- package/dist/src/gateway/dashboard-copy.js +516 -0
- package/dist/src/gateway/dashboard-copy.js.map +1 -0
- package/dist/src/gateway/dashboard-copy.zh-CN.d.ts +506 -0
- package/dist/src/gateway/dashboard-copy.zh-CN.js +507 -0
- package/dist/src/gateway/dashboard-copy.zh-CN.js.map +1 -0
- package/dist/src/gateway/dashboard-model.d.ts +207 -0
- package/dist/src/gateway/dashboard-model.js +720 -0
- package/dist/src/gateway/dashboard-model.js.map +1 -0
- package/dist/src/gateway/dashboard.d.ts +26 -0
- package/dist/src/gateway/dashboard.js +941 -0
- package/dist/src/gateway/dashboard.js.map +1 -0
- package/dist/src/gateway/delivery-machine.d.ts +235 -0
- package/dist/src/gateway/delivery-machine.js +540 -0
- package/dist/src/gateway/delivery-machine.js.map +1 -0
- package/dist/src/gateway/instance-lease.d.ts +21 -0
- package/dist/src/gateway/instance-lease.js +549 -0
- package/dist/src/gateway/instance-lease.js.map +1 -0
- package/dist/src/gateway/live-dashboard-app/app.js +2914 -0
- package/dist/src/gateway/live-dashboard-assets.d.ts +10 -0
- package/dist/src/gateway/live-dashboard-assets.js +73 -0
- package/dist/src/gateway/live-dashboard-assets.js.map +1 -0
- package/dist/src/gateway/live-dashboard-bootstrap.d.ts +36 -0
- package/dist/src/gateway/live-dashboard-bootstrap.js +176 -0
- package/dist/src/gateway/live-dashboard-bootstrap.js.map +1 -0
- package/dist/src/gateway/live-dashboard-command.d.ts +54 -0
- package/dist/src/gateway/live-dashboard-command.js +324 -0
- package/dist/src/gateway/live-dashboard-command.js.map +1 -0
- package/dist/src/gateway/live-dashboard-http.d.ts +41 -0
- package/dist/src/gateway/live-dashboard-http.js +445 -0
- package/dist/src/gateway/live-dashboard-http.js.map +1 -0
- package/dist/src/gateway/live-dashboard-protocol.d.ts +38 -0
- package/dist/src/gateway/live-dashboard-protocol.js +156 -0
- package/dist/src/gateway/live-dashboard-protocol.js.map +1 -0
- package/dist/src/gateway/live-dashboard-server.d.ts +33 -0
- package/dist/src/gateway/live-dashboard-server.js +130 -0
- package/dist/src/gateway/live-dashboard-server.js.map +1 -0
- package/dist/src/gateway/live-dashboard-stream.d.ts +46 -0
- package/dist/src/gateway/live-dashboard-stream.js +233 -0
- package/dist/src/gateway/live-dashboard-stream.js.map +1 -0
- package/dist/src/gateway/live-dashboard.d.ts +31 -0
- package/dist/src/gateway/live-dashboard.js +190 -0
- package/dist/src/gateway/live-dashboard.js.map +1 -0
- package/dist/src/gateway/locale.d.ts +4 -0
- package/dist/src/gateway/locale.js +10 -0
- package/dist/src/gateway/locale.js.map +1 -0
- package/dist/src/gateway/progress-watch-machine.d.ts +97 -0
- package/dist/src/gateway/progress-watch-machine.js +172 -0
- package/dist/src/gateway/progress-watch-machine.js.map +1 -0
- package/dist/src/gateway/providers.d.ts +287 -0
- package/dist/src/gateway/providers.js +2451 -0
- package/dist/src/gateway/providers.js.map +1 -0
- package/dist/src/gateway/server.d.ts +57 -0
- package/dist/src/gateway/server.js +264 -0
- package/dist/src/gateway/server.js.map +1 -0
- package/dist/src/gateway/service.d.ts +449 -0
- package/dist/src/gateway/service.js +4831 -0
- package/dist/src/gateway/service.js.map +1 -0
- package/dist/src/gateway/store.d.ts +340 -0
- package/dist/src/gateway/store.js +4047 -0
- package/dist/src/gateway/store.js.map +1 -0
- package/dist/src/gateway/types.d.ts +514 -0
- package/dist/src/gateway/types.js +414 -0
- package/dist/src/gateway/types.js.map +1 -0
- package/dist/src/mutex.d.ts +4 -0
- package/dist/src/mutex.js +23 -0
- package/dist/src/mutex.js.map +1 -0
- package/docs/DESIGN.md +134 -0
- package/docs/GATEWAY-ARCHITECTURE.md +858 -0
- package/package.json +80 -0
- package/skills/embassy-peer/SKILL.md +198 -0
- package/skills/embassy-peer/agents/openai.yaml +4 -0
|
@@ -0,0 +1,4047 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { chmod, lstat, mkdir, open, readdir, realpath, rename, unlink, } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { BridgeError } from "../errors.js";
|
|
7
|
+
import { KeyedMutex } from "../mutex.js";
|
|
8
|
+
import { isCodexRegistrationGeneration } from "./codex-registration-generation.js";
|
|
9
|
+
import { compatibilityCacheKey, isCompatibilityAttestation, } from "./compatibility.js";
|
|
10
|
+
import { PROGRESS_WATCH_DEFAULT_CAPACITY, PROGRESS_WATCH_MAX_IDLE_MS, PROGRESS_WATCH_MIN_IDLE_MS, createProgressWatchMachine, progressWatchCapabilities, progressWatchJournalKinds, progressWatchPhases, transitionProgressWatch, } from "./progress-watch-machine.js";
|
|
11
|
+
import { compatibilityStates, connectorHealthStates, deliveryStates, gatewayProviders, messageDirections, projectGatewayPublicSnapshot, routeRegistrationModes, routeStates, } from "./types.js";
|
|
12
|
+
const STATE_MARKER = ".agent-embassy-state";
|
|
13
|
+
const STATE_MARKER_CONTENT = "agent-embassy-state-v1\n";
|
|
14
|
+
const STATE_FILE = "gateway-state.json";
|
|
15
|
+
const CONTROLLER_LOCK = ".gateway-controller.lock";
|
|
16
|
+
const MAX_MARKER_FILE_BYTES = 128;
|
|
17
|
+
const MAX_LOCK_FILE_BYTES = 4 * 1024;
|
|
18
|
+
export const GATEWAY_MAX_STATE_FILE_BYTES = 8 * 1024 * 1024;
|
|
19
|
+
const COMPATIBILITY_ATTESTATION_CAPACITY = 16;
|
|
20
|
+
const ALIAS_PATTERN = /^[a-z][a-z0-9_-]{0,31}@[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
|
|
21
|
+
const HOST_PATTERN = /^[a-z0-9](?:[a-z0-9.-]{0,61}[a-z0-9])?$/;
|
|
22
|
+
const PRIVATE_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/;
|
|
23
|
+
const SAFE_CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/;
|
|
24
|
+
const PROTOCOL_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
25
|
+
const PROTOCOL_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:+-]{0,63}$/;
|
|
26
|
+
const MESSAGE_ID_PATTERN = /^msg_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
const MESSAGE_SUFFIX_PATTERN = /^[0-9a-f]{8}$/;
|
|
28
|
+
const CONVERSATION_ID_PATTERN = /^conv_[A-Za-z0-9_-]{16,64}$/;
|
|
29
|
+
const CONVERSATION_SUFFIX_PATTERN = /^[A-Za-z0-9_-]{8}$/;
|
|
30
|
+
const FINGERPRINT_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
31
|
+
class PostRenamePersistenceError extends BridgeError {
|
|
32
|
+
constructor(verified) {
|
|
33
|
+
super("GATEWAY_STATE_COMMIT_OUTCOME_UNKNOWN", verified
|
|
34
|
+
? "The gateway state rename was installed, but directory durability could not be confirmed. The exact installed state was retained; retry is forbidden until recovery is reconciled."
|
|
35
|
+
: "The gateway state rename was installed, but the exact installed state could not be verified. The controller was disabled and must be recovered before reuse.");
|
|
36
|
+
this.name = "PostRenamePersistenceError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const PROVIDERS = new Set(gatewayProviders);
|
|
40
|
+
const CONNECTOR_HEALTH = new Set(connectorHealthStates);
|
|
41
|
+
const COMPATIBILITY = new Set(compatibilityStates);
|
|
42
|
+
const ROUTE_STATES = new Set(routeStates);
|
|
43
|
+
const REGISTRATION_MODES = new Set(routeRegistrationModes);
|
|
44
|
+
const DIRECTIONS = new Set(messageDirections);
|
|
45
|
+
const DELIVERY_STATES = new Set(deliveryStates);
|
|
46
|
+
const PROGRESS_WATCH_PHASES = new Set(progressWatchPhases);
|
|
47
|
+
const PROGRESS_WATCH_CAPABILITIES = new Set(progressWatchCapabilities);
|
|
48
|
+
const PROGRESS_WATCH_JOURNAL_KINDS = new Set(progressWatchJournalKinds);
|
|
49
|
+
export const codexSuccessionJournalStages = [
|
|
50
|
+
"prepared",
|
|
51
|
+
"publication_armed",
|
|
52
|
+
"published",
|
|
53
|
+
"activated",
|
|
54
|
+
"recovery_forbidden",
|
|
55
|
+
];
|
|
56
|
+
const CODEX_SUCCESSION_STAGES = new Set(codexSuccessionJournalStages);
|
|
57
|
+
function isObject(value) {
|
|
58
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
59
|
+
}
|
|
60
|
+
function hasOnlyKeys(value, required, optional = []) {
|
|
61
|
+
const keys = Object.keys(value);
|
|
62
|
+
return (required.every((key) => Object.hasOwn(value, key)) &&
|
|
63
|
+
keys.every((key) => required.includes(key) || optional.includes(key)));
|
|
64
|
+
}
|
|
65
|
+
function isNonNegativeInteger(value) {
|
|
66
|
+
return Number.isSafeInteger(value) && value >= 0;
|
|
67
|
+
}
|
|
68
|
+
function isPositiveInteger(value) {
|
|
69
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
70
|
+
}
|
|
71
|
+
function isIsoTimestamp(value) {
|
|
72
|
+
return (typeof value === "string" &&
|
|
73
|
+
value.length >= 20 &&
|
|
74
|
+
value.length <= 35 &&
|
|
75
|
+
Number.isFinite(Date.parse(value)));
|
|
76
|
+
}
|
|
77
|
+
function isSafeCode(value) {
|
|
78
|
+
return typeof value === "string" && SAFE_CODE_PATTERN.test(value);
|
|
79
|
+
}
|
|
80
|
+
function isPrivateToken(value) {
|
|
81
|
+
// Intentionally excludes whitespace and path separators. Socket/reply paths
|
|
82
|
+
// must remain connector-memory-only and can never become route handles.
|
|
83
|
+
return typeof value === "string" && PRIVATE_TOKEN_PATTERN.test(value);
|
|
84
|
+
}
|
|
85
|
+
function isCodexSuccessionIdentity(value) {
|
|
86
|
+
return (isObject(value) &&
|
|
87
|
+
hasOnlyKeys(value, [
|
|
88
|
+
"alias",
|
|
89
|
+
"threadId",
|
|
90
|
+
"hostId",
|
|
91
|
+
"generation",
|
|
92
|
+
"binding",
|
|
93
|
+
]) &&
|
|
94
|
+
typeof value.alias === "string" &&
|
|
95
|
+
ALIAS_PATTERN.test(value.alias) &&
|
|
96
|
+
value.alias.startsWith("codex-") &&
|
|
97
|
+
typeof value.hostId === "string" &&
|
|
98
|
+
HOST_PATTERN.test(value.hostId) &&
|
|
99
|
+
value.alias.endsWith(`@${value.hostId}`) &&
|
|
100
|
+
isPrivateToken(value.threadId) &&
|
|
101
|
+
isCodexRegistrationGeneration(value.generation) &&
|
|
102
|
+
isPrivateRouteBinding(value.binding) &&
|
|
103
|
+
value.binding.provider === "codex" &&
|
|
104
|
+
value.binding.hostId === value.hostId &&
|
|
105
|
+
value.binding.routeHandle === value.threadId);
|
|
106
|
+
}
|
|
107
|
+
function isCodexSuccessionJournal(value) {
|
|
108
|
+
if (!isObject(value) ||
|
|
109
|
+
!hasOnlyKeys(value, ["schemaVersion", "stage", "old", "new"], ["safeErrorCode"]) ||
|
|
110
|
+
value.schemaVersion !== 1 ||
|
|
111
|
+
typeof value.stage !== "string" ||
|
|
112
|
+
!CODEX_SUCCESSION_STAGES.has(value.stage) ||
|
|
113
|
+
!isCodexSuccessionIdentity(value.old) ||
|
|
114
|
+
!isCodexSuccessionIdentity(value.new)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
const oldIdentity = value.old;
|
|
118
|
+
const newIdentity = value.new;
|
|
119
|
+
if (oldIdentity.hostId !== newIdentity.hostId ||
|
|
120
|
+
oldIdentity.alias === newIdentity.alias ||
|
|
121
|
+
oldIdentity.threadId === newIdentity.threadId ||
|
|
122
|
+
oldIdentity.generation === newIdentity.generation ||
|
|
123
|
+
oldIdentity.binding.ownerLease === newIdentity.binding.ownerLease) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return value.stage === "recovery_forbidden"
|
|
127
|
+
? isSafeCode(value.safeErrorCode)
|
|
128
|
+
: value.safeErrorCode === undefined;
|
|
129
|
+
}
|
|
130
|
+
function isPrivateEndpointIdentity(value) {
|
|
131
|
+
return (isObject(value) &&
|
|
132
|
+
hasOnlyKeys(value, ["provider", "hostId", "endpointGeneration"]) &&
|
|
133
|
+
typeof value.provider === "string" &&
|
|
134
|
+
PROVIDERS.has(value.provider) &&
|
|
135
|
+
typeof value.hostId === "string" &&
|
|
136
|
+
HOST_PATTERN.test(value.hostId) &&
|
|
137
|
+
isPrivateToken(value.endpointGeneration));
|
|
138
|
+
}
|
|
139
|
+
function isPrivateRouteBinding(value) {
|
|
140
|
+
return (isObject(value) &&
|
|
141
|
+
hasOnlyKeys(value, [
|
|
142
|
+
"provider",
|
|
143
|
+
"hostId",
|
|
144
|
+
"endpointGeneration",
|
|
145
|
+
"routeHandle",
|
|
146
|
+
"ownerLease",
|
|
147
|
+
]) &&
|
|
148
|
+
isPrivateEndpointIdentity({
|
|
149
|
+
provider: value.provider,
|
|
150
|
+
hostId: value.hostId,
|
|
151
|
+
endpointGeneration: value.endpointGeneration,
|
|
152
|
+
}) &&
|
|
153
|
+
isPrivateToken(value.routeHandle) &&
|
|
154
|
+
isPrivateToken(value.ownerLease));
|
|
155
|
+
}
|
|
156
|
+
function isRouteCounters(value) {
|
|
157
|
+
if (!isObject(value) ||
|
|
158
|
+
!hasOnlyKeys(value, [
|
|
159
|
+
"accepted",
|
|
160
|
+
"delivered",
|
|
161
|
+
"unconfirmed",
|
|
162
|
+
"failed",
|
|
163
|
+
"ambiguous",
|
|
164
|
+
"expired",
|
|
165
|
+
"cancelled",
|
|
166
|
+
"abandoned",
|
|
167
|
+
"rejected",
|
|
168
|
+
"bytesAccepted",
|
|
169
|
+
])) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
return Object.values(value).every(isNonNegativeInteger);
|
|
173
|
+
}
|
|
174
|
+
function isRouteRecord(value) {
|
|
175
|
+
if (!isObject(value) ||
|
|
176
|
+
!hasOnlyKeys(value, [
|
|
177
|
+
"alias",
|
|
178
|
+
"binding",
|
|
179
|
+
"registrationMode",
|
|
180
|
+
"enabled",
|
|
181
|
+
"state",
|
|
182
|
+
"compatibility",
|
|
183
|
+
"busyPolicy",
|
|
184
|
+
"registeredAt",
|
|
185
|
+
"updatedAt",
|
|
186
|
+
"queueDepth",
|
|
187
|
+
"counters",
|
|
188
|
+
], ["lastSeenAt", "safeErrorCode"])) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return (typeof value.alias === "string" &&
|
|
192
|
+
ALIAS_PATTERN.test(value.alias) &&
|
|
193
|
+
isPrivateRouteBinding(value.binding) &&
|
|
194
|
+
typeof value.registrationMode === "string" &&
|
|
195
|
+
REGISTRATION_MODES.has(value.registrationMode) &&
|
|
196
|
+
typeof value.enabled === "boolean" &&
|
|
197
|
+
typeof value.state === "string" &&
|
|
198
|
+
ROUTE_STATES.has(value.state) &&
|
|
199
|
+
typeof value.compatibility === "string" &&
|
|
200
|
+
COMPATIBILITY.has(value.compatibility) &&
|
|
201
|
+
value.busyPolicy === "queue" &&
|
|
202
|
+
isIsoTimestamp(value.registeredAt) &&
|
|
203
|
+
isIsoTimestamp(value.updatedAt) &&
|
|
204
|
+
(value.lastSeenAt === undefined || isIsoTimestamp(value.lastSeenAt)) &&
|
|
205
|
+
isNonNegativeInteger(value.queueDepth) &&
|
|
206
|
+
isRouteCounters(value.counters) &&
|
|
207
|
+
(value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
|
|
208
|
+
}
|
|
209
|
+
function isPairRecord(value) {
|
|
210
|
+
return (isObject(value) &&
|
|
211
|
+
hasOnlyKeys(value, [
|
|
212
|
+
"claudeAlias",
|
|
213
|
+
"codexAlias",
|
|
214
|
+
"claudeOwnerLease",
|
|
215
|
+
"codexOwnerLease",
|
|
216
|
+
"createdAt",
|
|
217
|
+
"updatedAt",
|
|
218
|
+
"counters",
|
|
219
|
+
]) &&
|
|
220
|
+
typeof value.claudeAlias === "string" &&
|
|
221
|
+
ALIAS_PATTERN.test(value.claudeAlias) &&
|
|
222
|
+
typeof value.codexAlias === "string" &&
|
|
223
|
+
ALIAS_PATTERN.test(value.codexAlias) &&
|
|
224
|
+
isPrivateToken(value.claudeOwnerLease) &&
|
|
225
|
+
isPrivateToken(value.codexOwnerLease) &&
|
|
226
|
+
isIsoTimestamp(value.createdAt) &&
|
|
227
|
+
isIsoTimestamp(value.updatedAt) &&
|
|
228
|
+
isRouteCounters(value.counters));
|
|
229
|
+
}
|
|
230
|
+
function isProgressWatchMachine(value) {
|
|
231
|
+
if (!isObject(value) ||
|
|
232
|
+
!hasOnlyKeys(value, [
|
|
233
|
+
"conversationId",
|
|
234
|
+
"ownerAlias",
|
|
235
|
+
"workerAlias",
|
|
236
|
+
"ownerLease",
|
|
237
|
+
"workerLease",
|
|
238
|
+
"createdAt",
|
|
239
|
+
"updatedAt",
|
|
240
|
+
"lastActivityAt",
|
|
241
|
+
"idleMs",
|
|
242
|
+
"phase",
|
|
243
|
+
"nudgeCount",
|
|
244
|
+
"nextActionAt",
|
|
245
|
+
"capability",
|
|
246
|
+
"degradedNoticeSent",
|
|
247
|
+
], ["workerReportedCompleteAt"])) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
return (typeof value.conversationId === "string" &&
|
|
251
|
+
CONVERSATION_ID_PATTERN.test(value.conversationId) &&
|
|
252
|
+
typeof value.ownerAlias === "string" &&
|
|
253
|
+
ALIAS_PATTERN.test(value.ownerAlias) &&
|
|
254
|
+
typeof value.workerAlias === "string" &&
|
|
255
|
+
ALIAS_PATTERN.test(value.workerAlias) &&
|
|
256
|
+
value.ownerAlias !== value.workerAlias &&
|
|
257
|
+
isPrivateToken(value.ownerLease) &&
|
|
258
|
+
isPrivateToken(value.workerLease) &&
|
|
259
|
+
value.ownerLease !== value.workerLease &&
|
|
260
|
+
isIsoTimestamp(value.createdAt) &&
|
|
261
|
+
isIsoTimestamp(value.updatedAt) &&
|
|
262
|
+
isIsoTimestamp(value.lastActivityAt) &&
|
|
263
|
+
isPositiveInteger(value.idleMs) &&
|
|
264
|
+
value.idleMs >= PROGRESS_WATCH_MIN_IDLE_MS &&
|
|
265
|
+
value.idleMs <= PROGRESS_WATCH_MAX_IDLE_MS &&
|
|
266
|
+
typeof value.phase === "string" &&
|
|
267
|
+
PROGRESS_WATCH_PHASES.has(value.phase) &&
|
|
268
|
+
(value.nudgeCount === 0 ||
|
|
269
|
+
value.nudgeCount === 1 ||
|
|
270
|
+
value.nudgeCount === 2) &&
|
|
271
|
+
(value.phase === "quiet"
|
|
272
|
+
? value.nudgeCount === 0
|
|
273
|
+
: value.nudgeCount === 1 || value.nudgeCount === 2) &&
|
|
274
|
+
isIsoTimestamp(value.nextActionAt) &&
|
|
275
|
+
typeof value.capability === "string" &&
|
|
276
|
+
PROGRESS_WATCH_CAPABILITIES.has(value.capability) &&
|
|
277
|
+
typeof value.degradedNoticeSent === "boolean" &&
|
|
278
|
+
(value.workerReportedCompleteAt === undefined ||
|
|
279
|
+
isIsoTimestamp(value.workerReportedCompleteAt)));
|
|
280
|
+
}
|
|
281
|
+
function isProgressWatchJournalEvent(value) {
|
|
282
|
+
return (isObject(value) &&
|
|
283
|
+
hasOnlyKeys(value, [
|
|
284
|
+
"sequence",
|
|
285
|
+
"timestamp",
|
|
286
|
+
"conversationId",
|
|
287
|
+
"ownerAlias",
|
|
288
|
+
"workerAlias",
|
|
289
|
+
"kind",
|
|
290
|
+
], ["nudgeNumber"]) &&
|
|
291
|
+
isPositiveInteger(value.sequence) &&
|
|
292
|
+
isIsoTimestamp(value.timestamp) &&
|
|
293
|
+
typeof value.conversationId === "string" &&
|
|
294
|
+
CONVERSATION_ID_PATTERN.test(value.conversationId) &&
|
|
295
|
+
typeof value.ownerAlias === "string" &&
|
|
296
|
+
ALIAS_PATTERN.test(value.ownerAlias) &&
|
|
297
|
+
typeof value.workerAlias === "string" &&
|
|
298
|
+
ALIAS_PATTERN.test(value.workerAlias) &&
|
|
299
|
+
value.ownerAlias !== value.workerAlias &&
|
|
300
|
+
typeof value.kind === "string" &&
|
|
301
|
+
PROGRESS_WATCH_JOURNAL_KINDS.has(value.kind) &&
|
|
302
|
+
(value.nudgeNumber === undefined ||
|
|
303
|
+
value.nudgeNumber === 1 ||
|
|
304
|
+
value.nudgeNumber === 2) &&
|
|
305
|
+
(value.kind === "nudge"
|
|
306
|
+
? value.nudgeNumber === 1 || value.nudgeNumber === 2
|
|
307
|
+
: value.nudgeNumber === undefined));
|
|
308
|
+
}
|
|
309
|
+
function isConnectorRecord(value) {
|
|
310
|
+
if (!isObject(value) ||
|
|
311
|
+
!hasOnlyKeys(value, [
|
|
312
|
+
"provider",
|
|
313
|
+
"hostId",
|
|
314
|
+
"endpointGeneration",
|
|
315
|
+
"health",
|
|
316
|
+
"compatibility",
|
|
317
|
+
"protocol",
|
|
318
|
+
"protocolVersion",
|
|
319
|
+
"updatedAt",
|
|
320
|
+
], ["lastSeenAt", "safeErrorCode"])) {
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
return (isPrivateEndpointIdentity({
|
|
324
|
+
provider: value.provider,
|
|
325
|
+
hostId: value.hostId,
|
|
326
|
+
endpointGeneration: value.endpointGeneration,
|
|
327
|
+
}) &&
|
|
328
|
+
typeof value.health === "string" &&
|
|
329
|
+
CONNECTOR_HEALTH.has(value.health) &&
|
|
330
|
+
typeof value.compatibility === "string" &&
|
|
331
|
+
COMPATIBILITY.has(value.compatibility) &&
|
|
332
|
+
typeof value.protocol === "string" &&
|
|
333
|
+
PROTOCOL_PATTERN.test(value.protocol) &&
|
|
334
|
+
typeof value.protocolVersion === "string" &&
|
|
335
|
+
PROTOCOL_VERSION_PATTERN.test(value.protocolVersion) &&
|
|
336
|
+
isIsoTimestamp(value.updatedAt) &&
|
|
337
|
+
(value.lastSeenAt === undefined || isIsoTimestamp(value.lastSeenAt)) &&
|
|
338
|
+
(value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)));
|
|
339
|
+
}
|
|
340
|
+
function isQueuedMetadata(value) {
|
|
341
|
+
if (!isObject(value) ||
|
|
342
|
+
!hasOnlyKeys(value, [
|
|
343
|
+
"messageId",
|
|
344
|
+
"messageIdSuffix",
|
|
345
|
+
"direction",
|
|
346
|
+
"sourceAlias",
|
|
347
|
+
"targetAlias",
|
|
348
|
+
"enqueuedAt",
|
|
349
|
+
"deadlineAt",
|
|
350
|
+
"bytes",
|
|
351
|
+
"hopCount",
|
|
352
|
+
], ["conversationIdSuffix", "pair", "steer"])) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
return (typeof value.messageId === "string" &&
|
|
356
|
+
MESSAGE_ID_PATTERN.test(value.messageId) &&
|
|
357
|
+
typeof value.messageIdSuffix === "string" &&
|
|
358
|
+
MESSAGE_SUFFIX_PATTERN.test(value.messageIdSuffix) &&
|
|
359
|
+
(value.conversationIdSuffix === undefined ||
|
|
360
|
+
(typeof value.conversationIdSuffix === "string" &&
|
|
361
|
+
CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))) &&
|
|
362
|
+
typeof value.direction === "string" &&
|
|
363
|
+
DIRECTIONS.has(value.direction) &&
|
|
364
|
+
typeof value.sourceAlias === "string" &&
|
|
365
|
+
ALIAS_PATTERN.test(value.sourceAlias) &&
|
|
366
|
+
typeof value.targetAlias === "string" &&
|
|
367
|
+
ALIAS_PATTERN.test(value.targetAlias) &&
|
|
368
|
+
isIsoTimestamp(value.enqueuedAt) &&
|
|
369
|
+
isIsoTimestamp(value.deadlineAt) &&
|
|
370
|
+
isPositiveInteger(value.bytes) &&
|
|
371
|
+
isNonNegativeInteger(value.hopCount) &&
|
|
372
|
+
(value.pair === undefined || value.pair === true) &&
|
|
373
|
+
(value.steer === undefined || value.steer === true));
|
|
374
|
+
}
|
|
375
|
+
function isInFlightMetadata(value) {
|
|
376
|
+
if (!isObject(value))
|
|
377
|
+
return false;
|
|
378
|
+
const { dispatchedAt, ...metadata } = value;
|
|
379
|
+
return isQueuedMetadata(metadata) && isIsoTimestamp(dispatchedAt);
|
|
380
|
+
}
|
|
381
|
+
function isEvent(value) {
|
|
382
|
+
if (!isObject(value) ||
|
|
383
|
+
!hasOnlyKeys(value, [
|
|
384
|
+
"sequence",
|
|
385
|
+
"timestamp",
|
|
386
|
+
"messageIdSuffix",
|
|
387
|
+
"direction",
|
|
388
|
+
"sourceAlias",
|
|
389
|
+
"targetAlias",
|
|
390
|
+
"state",
|
|
391
|
+
"bytes",
|
|
392
|
+
"hopCount",
|
|
393
|
+
], ["conversationIdSuffix", "latencyMs", "safeErrorCode", "steer"])) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
return (isPositiveInteger(value.sequence) &&
|
|
397
|
+
isIsoTimestamp(value.timestamp) &&
|
|
398
|
+
typeof value.messageIdSuffix === "string" &&
|
|
399
|
+
MESSAGE_SUFFIX_PATTERN.test(value.messageIdSuffix) &&
|
|
400
|
+
(value.conversationIdSuffix === undefined ||
|
|
401
|
+
(typeof value.conversationIdSuffix === "string" &&
|
|
402
|
+
CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))) &&
|
|
403
|
+
typeof value.direction === "string" &&
|
|
404
|
+
DIRECTIONS.has(value.direction) &&
|
|
405
|
+
typeof value.sourceAlias === "string" &&
|
|
406
|
+
ALIAS_PATTERN.test(value.sourceAlias) &&
|
|
407
|
+
typeof value.targetAlias === "string" &&
|
|
408
|
+
ALIAS_PATTERN.test(value.targetAlias) &&
|
|
409
|
+
typeof value.state === "string" &&
|
|
410
|
+
DELIVERY_STATES.has(value.state) &&
|
|
411
|
+
isPositiveInteger(value.bytes) &&
|
|
412
|
+
isNonNegativeInteger(value.hopCount) &&
|
|
413
|
+
(value.latencyMs === undefined || isNonNegativeInteger(value.latencyMs)) &&
|
|
414
|
+
(value.safeErrorCode === undefined || isSafeCode(value.safeErrorCode)) &&
|
|
415
|
+
(value.steer === undefined || value.steer === true));
|
|
416
|
+
}
|
|
417
|
+
function isDedupeRecord(value) {
|
|
418
|
+
return (isObject(value) &&
|
|
419
|
+
hasOnlyKeys(value, [
|
|
420
|
+
"fingerprint",
|
|
421
|
+
"messageIdSuffix",
|
|
422
|
+
"sourceAlias",
|
|
423
|
+
"targetAlias",
|
|
424
|
+
"direction",
|
|
425
|
+
"firstSeenAt",
|
|
426
|
+
"expiresAt",
|
|
427
|
+
], ["conversationIdSuffix", "pair"]) &&
|
|
428
|
+
typeof value.fingerprint === "string" &&
|
|
429
|
+
FINGERPRINT_PATTERN.test(value.fingerprint) &&
|
|
430
|
+
typeof value.messageIdSuffix === "string" &&
|
|
431
|
+
MESSAGE_SUFFIX_PATTERN.test(value.messageIdSuffix) &&
|
|
432
|
+
(value.conversationIdSuffix === undefined ||
|
|
433
|
+
(typeof value.conversationIdSuffix === "string" &&
|
|
434
|
+
CONVERSATION_SUFFIX_PATTERN.test(value.conversationIdSuffix))) &&
|
|
435
|
+
typeof value.sourceAlias === "string" &&
|
|
436
|
+
ALIAS_PATTERN.test(value.sourceAlias) &&
|
|
437
|
+
typeof value.targetAlias === "string" &&
|
|
438
|
+
ALIAS_PATTERN.test(value.targetAlias) &&
|
|
439
|
+
typeof value.direction === "string" &&
|
|
440
|
+
DIRECTIONS.has(value.direction) &&
|
|
441
|
+
(value.pair === undefined || value.pair === true) &&
|
|
442
|
+
isIsoTimestamp(value.firstSeenAt) &&
|
|
443
|
+
isIsoTimestamp(value.expiresAt));
|
|
444
|
+
}
|
|
445
|
+
function isAccounting(value) {
|
|
446
|
+
if (!isObject(value) ||
|
|
447
|
+
!hasOnlyKeys(value, [
|
|
448
|
+
"accepted",
|
|
449
|
+
"duplicates",
|
|
450
|
+
"delivered",
|
|
451
|
+
"unconfirmed",
|
|
452
|
+
"failed",
|
|
453
|
+
"ambiguous",
|
|
454
|
+
"expired",
|
|
455
|
+
"cancelled",
|
|
456
|
+
"abandoned",
|
|
457
|
+
"rejected",
|
|
458
|
+
"bytesAccepted",
|
|
459
|
+
"queuedBytes",
|
|
460
|
+
])) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
return Object.values(value).every(isNonNegativeInteger);
|
|
464
|
+
}
|
|
465
|
+
const PRE_UNCONFIRMED_ROUTE_COUNTER_KEYS = [
|
|
466
|
+
"accepted",
|
|
467
|
+
"delivered",
|
|
468
|
+
"failed",
|
|
469
|
+
"ambiguous",
|
|
470
|
+
"expired",
|
|
471
|
+
"cancelled",
|
|
472
|
+
"abandoned",
|
|
473
|
+
"rejected",
|
|
474
|
+
"bytesAccepted",
|
|
475
|
+
];
|
|
476
|
+
const PRE_UNCONFIRMED_ACCOUNTING_KEYS = [
|
|
477
|
+
"accepted",
|
|
478
|
+
"duplicates",
|
|
479
|
+
"delivered",
|
|
480
|
+
"failed",
|
|
481
|
+
"ambiguous",
|
|
482
|
+
"expired",
|
|
483
|
+
"cancelled",
|
|
484
|
+
"abandoned",
|
|
485
|
+
"rejected",
|
|
486
|
+
"bytesAccepted",
|
|
487
|
+
"queuedBytes",
|
|
488
|
+
];
|
|
489
|
+
/**
|
|
490
|
+
* One narrow migration for unpublished dogfood state created before the
|
|
491
|
+
* `unconfirmed` terminal outcome existed. Atomic state replacement means a
|
|
492
|
+
* valid old file has either every old counter shape or none of them; mixed or
|
|
493
|
+
* otherwise unfamiliar shapes continue to fail strict validation.
|
|
494
|
+
*/
|
|
495
|
+
function migratePreUnconfirmedCounters(value) {
|
|
496
|
+
if (!isObject(value) ||
|
|
497
|
+
value.schemaVersion !== 1 ||
|
|
498
|
+
!isObject(value.accounting) ||
|
|
499
|
+
!hasOnlyKeys(value.accounting, PRE_UNCONFIRMED_ACCOUNTING_KEYS) ||
|
|
500
|
+
Object.hasOwn(value.accounting, "unconfirmed") ||
|
|
501
|
+
!Array.isArray(value.routes) ||
|
|
502
|
+
!value.routes.every((route) => isObject(route) &&
|
|
503
|
+
isObject(route.counters) &&
|
|
504
|
+
hasOnlyKeys(route.counters, PRE_UNCONFIRMED_ROUTE_COUNTER_KEYS) &&
|
|
505
|
+
!Object.hasOwn(route.counters, "unconfirmed"))) {
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
return {
|
|
509
|
+
...value,
|
|
510
|
+
accounting: { ...value.accounting, unconfirmed: 0 },
|
|
511
|
+
routes: value.routes.map((route) => {
|
|
512
|
+
const record = route;
|
|
513
|
+
return {
|
|
514
|
+
...record,
|
|
515
|
+
counters: {
|
|
516
|
+
...record.counters,
|
|
517
|
+
unconfirmed: 0,
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
}),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
const PRE_SUCCESSION_JOURNAL_STATE_KEYS = [
|
|
524
|
+
"schemaVersion",
|
|
525
|
+
"createdAt",
|
|
526
|
+
"updatedAt",
|
|
527
|
+
"eventSequence",
|
|
528
|
+
"routes",
|
|
529
|
+
"connectors",
|
|
530
|
+
"queue",
|
|
531
|
+
"inFlight",
|
|
532
|
+
"events",
|
|
533
|
+
"dedupe",
|
|
534
|
+
"rateBuckets",
|
|
535
|
+
"accounting",
|
|
536
|
+
];
|
|
537
|
+
/** Add the nullable internal journal to exact legacy v1 state only. */
|
|
538
|
+
function migratePreSuccessionJournal(value) {
|
|
539
|
+
if (!isObject(value) ||
|
|
540
|
+
value.schemaVersion !== 1 ||
|
|
541
|
+
!hasOnlyKeys(value, PRE_SUCCESSION_JOURNAL_STATE_KEYS)) {
|
|
542
|
+
return value;
|
|
543
|
+
}
|
|
544
|
+
return { ...value, codexSuccession: null };
|
|
545
|
+
}
|
|
546
|
+
const PRE_PAIR_GRAPH_STATE_KEYS = [
|
|
547
|
+
...PRE_SUCCESSION_JOURNAL_STATE_KEYS,
|
|
548
|
+
"codexSuccession",
|
|
549
|
+
];
|
|
550
|
+
/**
|
|
551
|
+
* Preserve the old explicit singleton-selection consent when adding the pair
|
|
552
|
+
* graph. This migration accepts only the exact prior v1 top-level shape; final
|
|
553
|
+
* state validation still proves every route, lease, host, and edge.
|
|
554
|
+
*/
|
|
555
|
+
function migratePrePairGraph(value) {
|
|
556
|
+
if (!isObject(value) ||
|
|
557
|
+
value.schemaVersion !== 1 ||
|
|
558
|
+
!hasOnlyKeys(value, PRE_PAIR_GRAPH_STATE_KEYS) ||
|
|
559
|
+
!Array.isArray(value.routes) ||
|
|
560
|
+
!isIsoTimestamp(value.updatedAt)) {
|
|
561
|
+
return value;
|
|
562
|
+
}
|
|
563
|
+
const routes = value.routes.filter(isObject);
|
|
564
|
+
const claudeRoutes = routes.filter((route) => route.registrationMode === "selected_live_peer" &&
|
|
565
|
+
isObject(route.binding) &&
|
|
566
|
+
route.binding.provider === "claude");
|
|
567
|
+
const codexRoutes = routes.filter((route) => route.registrationMode === "explicit_opt_in" &&
|
|
568
|
+
isObject(route.binding) &&
|
|
569
|
+
route.binding.provider === "codex");
|
|
570
|
+
const pairs = [];
|
|
571
|
+
for (const claude of claudeRoutes) {
|
|
572
|
+
for (const codex of codexRoutes) {
|
|
573
|
+
const claudeBinding = claude.binding;
|
|
574
|
+
const codexBinding = codex.binding;
|
|
575
|
+
if (claudeBinding.hostId !== codexBinding.hostId ||
|
|
576
|
+
typeof claude.alias !== "string" ||
|
|
577
|
+
typeof codex.alias !== "string" ||
|
|
578
|
+
typeof claudeBinding.ownerLease !== "string" ||
|
|
579
|
+
typeof codexBinding.ownerLease !== "string") {
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
pairs.push({
|
|
583
|
+
claudeAlias: claude.alias,
|
|
584
|
+
codexAlias: codex.alias,
|
|
585
|
+
claudeOwnerLease: claudeBinding.ownerLease,
|
|
586
|
+
codexOwnerLease: codexBinding.ownerLease,
|
|
587
|
+
createdAt: value.updatedAt,
|
|
588
|
+
updatedAt: value.updatedAt,
|
|
589
|
+
counters: emptyCounters(),
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
if (pairs.length > 256)
|
|
594
|
+
return value;
|
|
595
|
+
return { ...value, pairs };
|
|
596
|
+
}
|
|
597
|
+
const PRE_PROGRESS_WATCH_STATE_KEYS = [
|
|
598
|
+
...PRE_PAIR_GRAPH_STATE_KEYS,
|
|
599
|
+
"pairs",
|
|
600
|
+
];
|
|
601
|
+
/** Add empty opt-in watch state to the exact preceding v1 schema only. */
|
|
602
|
+
function migratePreProgressWatches(value) {
|
|
603
|
+
if (!isObject(value) ||
|
|
604
|
+
value.schemaVersion !== 1 ||
|
|
605
|
+
!hasOnlyKeys(value, PRE_PROGRESS_WATCH_STATE_KEYS)) {
|
|
606
|
+
return value;
|
|
607
|
+
}
|
|
608
|
+
return {
|
|
609
|
+
...value,
|
|
610
|
+
watchSequence: 0,
|
|
611
|
+
progressWatches: [],
|
|
612
|
+
progressWatchEvents: [],
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
const PRE_COMPATIBILITY_ATTESTATION_STATE_KEYS = [
|
|
616
|
+
...PRE_PROGRESS_WATCH_STATE_KEYS,
|
|
617
|
+
"watchSequence",
|
|
618
|
+
"progressWatches",
|
|
619
|
+
"progressWatchEvents",
|
|
620
|
+
];
|
|
621
|
+
/** Add an empty compatibility cache to the exact preceding v1 schema only. */
|
|
622
|
+
function migratePreCompatibilityAttestations(value) {
|
|
623
|
+
if (!isObject(value) ||
|
|
624
|
+
value.schemaVersion !== 1 ||
|
|
625
|
+
!hasOnlyKeys(value, PRE_COMPATIBILITY_ATTESTATION_STATE_KEYS)) {
|
|
626
|
+
return value;
|
|
627
|
+
}
|
|
628
|
+
return { ...value, compatibilityAttestations: [] };
|
|
629
|
+
}
|
|
630
|
+
function isPersistedState(value) {
|
|
631
|
+
if (!isObject(value) ||
|
|
632
|
+
!hasOnlyKeys(value, [
|
|
633
|
+
"schemaVersion",
|
|
634
|
+
"createdAt",
|
|
635
|
+
"updatedAt",
|
|
636
|
+
"eventSequence",
|
|
637
|
+
"routes",
|
|
638
|
+
"pairs",
|
|
639
|
+
"connectors",
|
|
640
|
+
"queue",
|
|
641
|
+
"inFlight",
|
|
642
|
+
"events",
|
|
643
|
+
"dedupe",
|
|
644
|
+
"rateBuckets",
|
|
645
|
+
"accounting",
|
|
646
|
+
"watchSequence",
|
|
647
|
+
"progressWatches",
|
|
648
|
+
"progressWatchEvents",
|
|
649
|
+
"compatibilityAttestations",
|
|
650
|
+
"codexSuccession",
|
|
651
|
+
]) ||
|
|
652
|
+
value.schemaVersion !== 1 ||
|
|
653
|
+
!isIsoTimestamp(value.createdAt) ||
|
|
654
|
+
!isIsoTimestamp(value.updatedAt) ||
|
|
655
|
+
!isNonNegativeInteger(value.eventSequence) ||
|
|
656
|
+
!isNonNegativeInteger(value.watchSequence) ||
|
|
657
|
+
!Array.isArray(value.routes) ||
|
|
658
|
+
!value.routes.every(isRouteRecord) ||
|
|
659
|
+
!Array.isArray(value.pairs) ||
|
|
660
|
+
value.pairs.length > 256 ||
|
|
661
|
+
!value.pairs.every(isPairRecord) ||
|
|
662
|
+
!Array.isArray(value.progressWatches) ||
|
|
663
|
+
value.progressWatches.length > 256 ||
|
|
664
|
+
!value.progressWatches.every(isProgressWatchMachine) ||
|
|
665
|
+
!Array.isArray(value.progressWatchEvents) ||
|
|
666
|
+
!value.progressWatchEvents.every(isProgressWatchJournalEvent) ||
|
|
667
|
+
!Array.isArray(value.compatibilityAttestations) ||
|
|
668
|
+
value.compatibilityAttestations.length >
|
|
669
|
+
COMPATIBILITY_ATTESTATION_CAPACITY ||
|
|
670
|
+
!value.compatibilityAttestations.every(isCompatibilityAttestation) ||
|
|
671
|
+
!Array.isArray(value.connectors) ||
|
|
672
|
+
!value.connectors.every(isConnectorRecord) ||
|
|
673
|
+
!Array.isArray(value.queue) ||
|
|
674
|
+
!value.queue.every(isQueuedMetadata) ||
|
|
675
|
+
!Array.isArray(value.inFlight) ||
|
|
676
|
+
!value.inFlight.every(isInFlightMetadata) ||
|
|
677
|
+
!Array.isArray(value.events) ||
|
|
678
|
+
!value.events.every(isEvent) ||
|
|
679
|
+
!Array.isArray(value.dedupe) ||
|
|
680
|
+
!value.dedupe.every(isDedupeRecord) ||
|
|
681
|
+
!Array.isArray(value.rateBuckets) ||
|
|
682
|
+
!value.rateBuckets.every((bucket) => isObject(bucket) &&
|
|
683
|
+
hasOnlyKeys(bucket, ["sourceAlias", "windowStartedAt", "count"]) &&
|
|
684
|
+
typeof bucket.sourceAlias === "string" &&
|
|
685
|
+
ALIAS_PATTERN.test(bucket.sourceAlias) &&
|
|
686
|
+
isIsoTimestamp(bucket.windowStartedAt) &&
|
|
687
|
+
isNonNegativeInteger(bucket.count)) ||
|
|
688
|
+
!isAccounting(value.accounting) ||
|
|
689
|
+
(value.codexSuccession !== null &&
|
|
690
|
+
!isCodexSuccessionJournal(value.codexSuccession))) {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
const candidate = value;
|
|
694
|
+
const aliases = new Set(candidate.routes.map((route) => route.alias));
|
|
695
|
+
const routeTargets = new Set(candidate.routes.map((route) => `${route.binding.provider}\0${route.binding.hostId}\0${route.binding.endpointGeneration}\0${route.binding.routeHandle}`));
|
|
696
|
+
const ownerLeases = new Set(candidate.routes.map((route) => route.binding.ownerLease));
|
|
697
|
+
const watchConversationIds = new Set(candidate.progressWatches.map((watch) => watch.conversationId));
|
|
698
|
+
if (watchConversationIds.size !== candidate.progressWatches.length) {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
const compatibilityKeys = candidate.compatibilityAttestations.map(compatibilityCacheKey);
|
|
702
|
+
if (new Set(compatibilityKeys).size !== compatibilityKeys.length) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
for (const watch of candidate.progressWatches) {
|
|
706
|
+
const owner = candidate.routes.find((route) => route.alias === watch.ownerAlias &&
|
|
707
|
+
route.binding.ownerLease === watch.ownerLease);
|
|
708
|
+
const worker = candidate.routes.find((route) => route.alias === watch.workerAlias &&
|
|
709
|
+
route.binding.ownerLease === watch.workerLease);
|
|
710
|
+
if (owner === undefined ||
|
|
711
|
+
worker === undefined ||
|
|
712
|
+
owner.binding.provider === worker.binding.provider ||
|
|
713
|
+
owner.binding.hostId !== worker.binding.hostId) {
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
const pairAliases = pairAliasesForRoutes(owner, worker);
|
|
717
|
+
if (!candidate.pairs.some((pair) => pair.claudeAlias === pairAliases.claudeAlias &&
|
|
718
|
+
pair.codexAlias === pairAliases.codexAlias &&
|
|
719
|
+
pair.claudeOwnerLease ===
|
|
720
|
+
(owner.binding.provider === "claude"
|
|
721
|
+
? owner.binding.ownerLease
|
|
722
|
+
: worker.binding.ownerLease) &&
|
|
723
|
+
pair.codexOwnerLease ===
|
|
724
|
+
(owner.binding.provider === "codex"
|
|
725
|
+
? owner.binding.ownerLease
|
|
726
|
+
: worker.binding.ownerLease))) {
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const connectorKeys = new Set(candidate.connectors.map((connector) => `${connector.provider}\0${connector.hostId}`));
|
|
731
|
+
const messageIds = [
|
|
732
|
+
...candidate.queue.map((item) => item.messageId),
|
|
733
|
+
...candidate.inFlight.map((item) => item.messageId),
|
|
734
|
+
];
|
|
735
|
+
const expectedQueuedBytes = candidate.queue.reduce((total, item) => total + item.bytes, 0);
|
|
736
|
+
const routeByAlias = new Map(candidate.routes.map((route) => [route.alias, route]));
|
|
737
|
+
const pairKeys = candidate.pairs.map((pair) => `${pair.claudeAlias}\0${pair.codexAlias}`);
|
|
738
|
+
const pairsValid = candidate.pairs.every((pair) => {
|
|
739
|
+
const claude = routeByAlias.get(pair.claudeAlias);
|
|
740
|
+
const codex = routeByAlias.get(pair.codexAlias);
|
|
741
|
+
return (claude?.binding.provider === "claude" &&
|
|
742
|
+
codex?.binding.provider === "codex" &&
|
|
743
|
+
claude.binding.hostId === codex.binding.hostId &&
|
|
744
|
+
claude.binding.ownerLease === pair.claudeOwnerLease &&
|
|
745
|
+
codex.binding.ownerLease === pair.codexOwnerLease);
|
|
746
|
+
});
|
|
747
|
+
const claudeConnectorHosts = new Set(candidate.connectors
|
|
748
|
+
.filter((connector) => connector.provider === "claude")
|
|
749
|
+
.map((connector) => connector.hostId));
|
|
750
|
+
const aliasHost = (alias) => alias.slice(alias.lastIndexOf("@") + 1);
|
|
751
|
+
const isValidPersistedMessagePair = (message) => {
|
|
752
|
+
const source = routeByAlias.get(message.sourceAlias);
|
|
753
|
+
const target = routeByAlias.get(message.targetAlias);
|
|
754
|
+
const routeShapeValid = message.direction === "codex_to_claude"
|
|
755
|
+
? source?.binding.provider === "codex" &&
|
|
756
|
+
((target?.binding.provider === "claude" &&
|
|
757
|
+
target.binding.hostId === source.binding.hostId) ||
|
|
758
|
+
(target === undefined &&
|
|
759
|
+
claudeConnectorHosts.has(source.binding.hostId) &&
|
|
760
|
+
aliasHost(message.targetAlias) === source.binding.hostId))
|
|
761
|
+
: target?.binding.provider === "codex" &&
|
|
762
|
+
((source?.binding.provider === "claude" &&
|
|
763
|
+
source.binding.hostId === target.binding.hostId) ||
|
|
764
|
+
(source === undefined &&
|
|
765
|
+
claudeConnectorHosts.has(target.binding.hostId) &&
|
|
766
|
+
aliasHost(message.sourceAlias) === target.binding.hostId));
|
|
767
|
+
return (routeShapeValid &&
|
|
768
|
+
(message.pair !== true ||
|
|
769
|
+
candidate.pairs.some((pair) => pairMatchesMessage(pair, message))));
|
|
770
|
+
};
|
|
771
|
+
const sequencesStrictlyIncrease = candidate.events.every((event, index) => index === 0 ||
|
|
772
|
+
event.sequence > (candidate.events[index - 1]?.sequence ?? 0));
|
|
773
|
+
return (aliases.size === candidate.routes.length &&
|
|
774
|
+
new Set(pairKeys).size === pairKeys.length &&
|
|
775
|
+
pairsValid &&
|
|
776
|
+
routeTargets.size === candidate.routes.length &&
|
|
777
|
+
ownerLeases.size === candidate.routes.length &&
|
|
778
|
+
connectorKeys.size === candidate.connectors.length &&
|
|
779
|
+
new Set(messageIds).size === messageIds.length &&
|
|
780
|
+
new Set(candidate.dedupe.map((record) => record.fingerprint)).size ===
|
|
781
|
+
candidate.dedupe.length &&
|
|
782
|
+
new Set(candidate.rateBuckets.map((bucket) => bucket.sourceAlias)).size ===
|
|
783
|
+
candidate.rateBuckets.length &&
|
|
784
|
+
candidate.accounting.queuedBytes === expectedQueuedBytes &&
|
|
785
|
+
sequencesStrictlyIncrease &&
|
|
786
|
+
candidate.events.every((event) => event.sequence <= candidate.eventSequence) &&
|
|
787
|
+
candidate.routes.every((route) => route.alias.endsWith(`@${route.binding.hostId}`) &&
|
|
788
|
+
((route.binding.provider === "codex" &&
|
|
789
|
+
route.registrationMode === "explicit_opt_in") ||
|
|
790
|
+
(route.binding.provider === "claude" &&
|
|
791
|
+
route.registrationMode === "selected_live_peer")) &&
|
|
792
|
+
route.queueDepth ===
|
|
793
|
+
candidate.queue.filter((item) => item.targetAlias === route.alias)
|
|
794
|
+
.length) &&
|
|
795
|
+
[...candidate.queue, ...candidate.inFlight].every((item) => {
|
|
796
|
+
return (item.messageIdSuffix ===
|
|
797
|
+
item.messageId.replaceAll("-", "").slice(-8).toLowerCase() &&
|
|
798
|
+
isValidPersistedMessagePair(item));
|
|
799
|
+
}) &&
|
|
800
|
+
candidate.dedupe.every(isValidPersistedMessagePair) &&
|
|
801
|
+
candidate.rateBuckets.every((bucket) => aliases.has(bucket.sourceAlias) ||
|
|
802
|
+
candidate.routes.some((route) => route.binding.provider === "codex" &&
|
|
803
|
+
claudeConnectorHosts.has(route.binding.hostId) &&
|
|
804
|
+
route.binding.hostId === aliasHost(bucket.sourceAlias))) &&
|
|
805
|
+
isPersistedSuccessionConsistent(candidate));
|
|
806
|
+
}
|
|
807
|
+
function routeMatchesSuccessionIdentity(route, identity) {
|
|
808
|
+
return (route.alias === identity.alias &&
|
|
809
|
+
route.registrationMode === "explicit_opt_in" &&
|
|
810
|
+
route.binding.provider === "codex" &&
|
|
811
|
+
route.binding.hostId === identity.hostId &&
|
|
812
|
+
route.binding.routeHandle === identity.threadId &&
|
|
813
|
+
sameBinding(route.binding, identity.binding));
|
|
814
|
+
}
|
|
815
|
+
function isPersistedSuccessionConsistent(state) {
|
|
816
|
+
if (state.codexSuccession === null)
|
|
817
|
+
return true;
|
|
818
|
+
if (!isCodexSuccessionJournal(state.codexSuccession))
|
|
819
|
+
return false;
|
|
820
|
+
const journal = state.codexSuccession;
|
|
821
|
+
const matchingRoutes = state.routes.filter((candidate) => routeMatchesSuccessionIdentity(candidate, journal.old) ||
|
|
822
|
+
routeMatchesSuccessionIdentity(candidate, journal.new));
|
|
823
|
+
if (matchingRoutes.length !== 1 || matchingRoutes[0] === undefined) {
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
const route = matchingRoutes[0];
|
|
827
|
+
const successionAliases = new Set([journal.old.alias, journal.new.alias]);
|
|
828
|
+
if (state.queue.some((item) => successionAliases.has(item.sourceAlias) ||
|
|
829
|
+
successionAliases.has(item.targetAlias)) ||
|
|
830
|
+
state.inFlight.some((item) => successionAliases.has(item.sourceAlias) ||
|
|
831
|
+
successionAliases.has(item.targetAlias)) ||
|
|
832
|
+
route.queueDepth !== 0) {
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
const oldMatches = routeMatchesSuccessionIdentity(route, journal.old);
|
|
836
|
+
const newMatches = routeMatchesSuccessionIdentity(route, journal.new);
|
|
837
|
+
const newConnectorGenerationExists = state.connectors.some((connector) => sameEndpoint(connector, journal.new.binding));
|
|
838
|
+
if (!newConnectorGenerationExists)
|
|
839
|
+
return false;
|
|
840
|
+
const nonCodexCollision = state.routes.some((candidate) => candidate !== route &&
|
|
841
|
+
(candidate.alias === journal.new.alias ||
|
|
842
|
+
sameRouteTarget(candidate.binding, journal.new.binding) ||
|
|
843
|
+
candidate.binding.ownerLease === journal.new.binding.ownerLease));
|
|
844
|
+
if (nonCodexCollision)
|
|
845
|
+
return false;
|
|
846
|
+
if (journal.stage === "activated") {
|
|
847
|
+
return newMatches && isZeroRouteCounters(route.counters);
|
|
848
|
+
}
|
|
849
|
+
if (journal.stage === "recovery_forbidden") {
|
|
850
|
+
return oldMatches || (newMatches && isZeroRouteCounters(route.counters));
|
|
851
|
+
}
|
|
852
|
+
return oldMatches;
|
|
853
|
+
}
|
|
854
|
+
function isZeroRouteCounters(counters) {
|
|
855
|
+
return Object.values(counters).every((value) => value === 0);
|
|
856
|
+
}
|
|
857
|
+
function emptyCounters() {
|
|
858
|
+
return {
|
|
859
|
+
accepted: 0,
|
|
860
|
+
delivered: 0,
|
|
861
|
+
unconfirmed: 0,
|
|
862
|
+
failed: 0,
|
|
863
|
+
ambiguous: 0,
|
|
864
|
+
expired: 0,
|
|
865
|
+
cancelled: 0,
|
|
866
|
+
abandoned: 0,
|
|
867
|
+
rejected: 0,
|
|
868
|
+
bytesAccepted: 0,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
function emptyAccounting() {
|
|
872
|
+
return {
|
|
873
|
+
accepted: 0,
|
|
874
|
+
duplicates: 0,
|
|
875
|
+
delivered: 0,
|
|
876
|
+
unconfirmed: 0,
|
|
877
|
+
failed: 0,
|
|
878
|
+
ambiguous: 0,
|
|
879
|
+
expired: 0,
|
|
880
|
+
cancelled: 0,
|
|
881
|
+
abandoned: 0,
|
|
882
|
+
rejected: 0,
|
|
883
|
+
bytesAccepted: 0,
|
|
884
|
+
queuedBytes: 0,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
async function canonicalFuturePath(candidate) {
|
|
888
|
+
const suffix = [];
|
|
889
|
+
let cursor = path.resolve(candidate);
|
|
890
|
+
while (true) {
|
|
891
|
+
try {
|
|
892
|
+
return path.join(await realpath(cursor), ...suffix);
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
if (!(error instanceof Error) ||
|
|
896
|
+
!("code" in error) ||
|
|
897
|
+
error.code !== "ENOENT") {
|
|
898
|
+
throw error;
|
|
899
|
+
}
|
|
900
|
+
const parent = path.dirname(cursor);
|
|
901
|
+
if (parent === cursor)
|
|
902
|
+
throw error;
|
|
903
|
+
suffix.unshift(path.basename(cursor));
|
|
904
|
+
cursor = parent;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
async function assertNoSymlinkComponents(candidate) {
|
|
909
|
+
const absolute = path.resolve(candidate);
|
|
910
|
+
const parsed = path.parse(absolute);
|
|
911
|
+
const relative = absolute.slice(parsed.root.length);
|
|
912
|
+
let cursor = parsed.root;
|
|
913
|
+
for (const component of relative.split(path.sep).filter(Boolean)) {
|
|
914
|
+
cursor = path.join(cursor, component);
|
|
915
|
+
try {
|
|
916
|
+
const info = await lstat(cursor);
|
|
917
|
+
if (info.isSymbolicLink()) {
|
|
918
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The gateway state path must not contain symbolic links.");
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
catch (error) {
|
|
922
|
+
if (error instanceof Error &&
|
|
923
|
+
"code" in error &&
|
|
924
|
+
error.code === "ENOENT") {
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
throw error;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
function sameEndpoint(left, right) {
|
|
932
|
+
return (left.provider === right.provider &&
|
|
933
|
+
left.hostId === right.hostId &&
|
|
934
|
+
left.endpointGeneration === right.endpointGeneration);
|
|
935
|
+
}
|
|
936
|
+
function endpointOf(binding) {
|
|
937
|
+
return {
|
|
938
|
+
provider: binding.provider,
|
|
939
|
+
hostId: binding.hostId,
|
|
940
|
+
endpointGeneration: binding.endpointGeneration,
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
function cloneSuccessionIdentity(identity) {
|
|
944
|
+
return {
|
|
945
|
+
alias: identity.alias,
|
|
946
|
+
threadId: identity.threadId,
|
|
947
|
+
hostId: identity.hostId,
|
|
948
|
+
generation: identity.generation,
|
|
949
|
+
binding: { ...identity.binding },
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
function cloneSuccessionJournal(journal) {
|
|
953
|
+
return {
|
|
954
|
+
schemaVersion: 1,
|
|
955
|
+
stage: journal.stage,
|
|
956
|
+
old: cloneSuccessionIdentity(journal.old),
|
|
957
|
+
new: cloneSuccessionIdentity(journal.new),
|
|
958
|
+
...(journal.safeErrorCode === undefined
|
|
959
|
+
? {}
|
|
960
|
+
: { safeErrorCode: journal.safeErrorCode }),
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
function sameBinding(left, right) {
|
|
964
|
+
return (sameEndpoint(left, right) &&
|
|
965
|
+
left.routeHandle === right.routeHandle &&
|
|
966
|
+
left.ownerLease === right.ownerLease);
|
|
967
|
+
}
|
|
968
|
+
function sameRouteTarget(left, right) {
|
|
969
|
+
return sameEndpoint(left, right) && left.routeHandle === right.routeHandle;
|
|
970
|
+
}
|
|
971
|
+
function renameRateBucket(state, previousAlias, newAlias) {
|
|
972
|
+
const previous = state.rateBuckets.find((bucket) => bucket.sourceAlias === previousAlias);
|
|
973
|
+
if (previous === undefined)
|
|
974
|
+
return;
|
|
975
|
+
const existing = state.rateBuckets.find((bucket) => bucket.sourceAlias === newAlias);
|
|
976
|
+
if (existing === undefined) {
|
|
977
|
+
previous.sourceAlias = newAlias;
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
existing.windowStartedAt =
|
|
981
|
+
existing.windowStartedAt < previous.windowStartedAt
|
|
982
|
+
? existing.windowStartedAt
|
|
983
|
+
: previous.windowStartedAt;
|
|
984
|
+
existing.count = Math.min(Number.MAX_SAFE_INTEGER, existing.count + previous.count);
|
|
985
|
+
state.rateBuckets = state.rateBuckets.filter((bucket) => bucket !== previous);
|
|
986
|
+
}
|
|
987
|
+
function pairKey(claudeAlias, codexAlias) {
|
|
988
|
+
return `${claudeAlias}\0${codexAlias}`;
|
|
989
|
+
}
|
|
990
|
+
function pairAliasesForRoutes(source, target) {
|
|
991
|
+
if (source.binding.provider === "claude" &&
|
|
992
|
+
target.binding.provider === "codex") {
|
|
993
|
+
return { claudeAlias: source.alias, codexAlias: target.alias };
|
|
994
|
+
}
|
|
995
|
+
if (source.binding.provider === "codex" &&
|
|
996
|
+
target.binding.provider === "claude") {
|
|
997
|
+
return { claudeAlias: target.alias, codexAlias: source.alias };
|
|
998
|
+
}
|
|
999
|
+
throw new BridgeError("INVALID_GATEWAY_ROUTE_PAIR", "Gateway pairs must connect one Claude route and one Codex route.");
|
|
1000
|
+
}
|
|
1001
|
+
function pairMatchesMessage(pair, message) {
|
|
1002
|
+
return (message.pair === true &&
|
|
1003
|
+
((message.sourceAlias === pair.claudeAlias &&
|
|
1004
|
+
message.targetAlias === pair.codexAlias) ||
|
|
1005
|
+
(message.sourceAlias === pair.codexAlias &&
|
|
1006
|
+
message.targetAlias === pair.claudeAlias)));
|
|
1007
|
+
}
|
|
1008
|
+
function findPairForMessage(state, message) {
|
|
1009
|
+
return state.pairs.find((pair) => pairMatchesMessage(pair, message));
|
|
1010
|
+
}
|
|
1011
|
+
function renamePairAlias(state, previousAlias, newAlias, now) {
|
|
1012
|
+
if (previousAlias === newAlias)
|
|
1013
|
+
return;
|
|
1014
|
+
for (const pair of state.pairs) {
|
|
1015
|
+
if (pair.claudeAlias === previousAlias)
|
|
1016
|
+
pair.claudeAlias = newAlias;
|
|
1017
|
+
if (pair.codexAlias === previousAlias)
|
|
1018
|
+
pair.codexAlias = newAlias;
|
|
1019
|
+
if (pair.claudeAlias === newAlias ||
|
|
1020
|
+
pair.codexAlias === newAlias) {
|
|
1021
|
+
pair.updatedAt = now.toISOString();
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
function removePairsForAliases(state, aliases) {
|
|
1026
|
+
state.pairs = state.pairs.filter((pair) => !aliases.has(pair.claudeAlias) && !aliases.has(pair.codexAlias));
|
|
1027
|
+
}
|
|
1028
|
+
function routeTerminationMatches(state, aliases, message) {
|
|
1029
|
+
for (const alias of [message.sourceAlias, message.targetAlias]) {
|
|
1030
|
+
if (!aliases.has(alias))
|
|
1031
|
+
continue;
|
|
1032
|
+
const route = state.routes.find((candidate) => candidate.alias === alias);
|
|
1033
|
+
if (route?.binding.provider !== "claude" || message.pair === true) {
|
|
1034
|
+
return true;
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
return false;
|
|
1038
|
+
}
|
|
1039
|
+
function directionFor(source, target) {
|
|
1040
|
+
if (source.binding.provider === "codex" &&
|
|
1041
|
+
target.binding.provider === "claude") {
|
|
1042
|
+
return "codex_to_claude";
|
|
1043
|
+
}
|
|
1044
|
+
if (source.binding.provider === "claude" &&
|
|
1045
|
+
target.binding.provider === "codex") {
|
|
1046
|
+
return "claude_to_codex";
|
|
1047
|
+
}
|
|
1048
|
+
throw new BridgeError("INVALID_GATEWAY_ROUTE_PAIR", "Gateway messages must cross from one provider to the other.");
|
|
1049
|
+
}
|
|
1050
|
+
export class GatewayStore {
|
|
1051
|
+
config;
|
|
1052
|
+
rootDir;
|
|
1053
|
+
now;
|
|
1054
|
+
randomId;
|
|
1055
|
+
afterStateFileRename;
|
|
1056
|
+
mutex = new KeyedMutex();
|
|
1057
|
+
transientBodies = new Map();
|
|
1058
|
+
state;
|
|
1059
|
+
lockHandle;
|
|
1060
|
+
lockToken;
|
|
1061
|
+
/**
|
|
1062
|
+
* Startup admission may exercise migrated state in memory, but it must not
|
|
1063
|
+
* strand an older binary by rewriting the durable schema before every
|
|
1064
|
+
* provider and control-socket readiness check has passed.
|
|
1065
|
+
*/
|
|
1066
|
+
persistenceDeferred = false;
|
|
1067
|
+
constructor(config, dependencies = {}) {
|
|
1068
|
+
this.config = config;
|
|
1069
|
+
this.rootDir = path.resolve(config.stateDir);
|
|
1070
|
+
this.now = dependencies.now ?? (() => new Date());
|
|
1071
|
+
this.randomId = dependencies.randomId ?? randomUUID;
|
|
1072
|
+
this.afterStateFileRename = dependencies.afterStateFileRename;
|
|
1073
|
+
}
|
|
1074
|
+
get stateFilePath() {
|
|
1075
|
+
return path.join(this.rootDir, STATE_FILE);
|
|
1076
|
+
}
|
|
1077
|
+
async initialize(options = {}) {
|
|
1078
|
+
await this.mutex.run("gateway", async () => {
|
|
1079
|
+
if (this.state)
|
|
1080
|
+
return;
|
|
1081
|
+
this.rootDir = await this.prepareOwnedDirectory();
|
|
1082
|
+
const rootMetadata = await lstat(this.rootDir);
|
|
1083
|
+
if (rootMetadata.isSymbolicLink() || !rootMetadata.isDirectory()) {
|
|
1084
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The prepared gateway controller root is no longer a real directory.");
|
|
1085
|
+
}
|
|
1086
|
+
await this.acquireLock();
|
|
1087
|
+
try {
|
|
1088
|
+
const loaded = await this.loadStateFile();
|
|
1089
|
+
const now = this.now();
|
|
1090
|
+
this.state = loaded ?? {
|
|
1091
|
+
schemaVersion: 1,
|
|
1092
|
+
createdAt: now.toISOString(),
|
|
1093
|
+
updatedAt: now.toISOString(),
|
|
1094
|
+
eventSequence: 0,
|
|
1095
|
+
routes: [],
|
|
1096
|
+
pairs: [],
|
|
1097
|
+
connectors: [],
|
|
1098
|
+
queue: [],
|
|
1099
|
+
inFlight: [],
|
|
1100
|
+
events: [],
|
|
1101
|
+
dedupe: [],
|
|
1102
|
+
rateBuckets: [],
|
|
1103
|
+
accounting: emptyAccounting(),
|
|
1104
|
+
watchSequence: 0,
|
|
1105
|
+
progressWatches: [],
|
|
1106
|
+
progressWatchEvents: [],
|
|
1107
|
+
compatibilityAttestations: [],
|
|
1108
|
+
codexSuccession: null,
|
|
1109
|
+
};
|
|
1110
|
+
if (this.state.pairs.length > this.config.limits.maxPairs) {
|
|
1111
|
+
throw new BridgeError("PAIR_CAPACITY_REACHED", "The durable pair inventory exceeds the configured bound.");
|
|
1112
|
+
}
|
|
1113
|
+
if (this.state.progressWatches.length >
|
|
1114
|
+
(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
|
|
1115
|
+
throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The durable progress-watch inventory exceeds the configured bound.");
|
|
1116
|
+
}
|
|
1117
|
+
this.recoverAfterRestart(now);
|
|
1118
|
+
this.prune(now);
|
|
1119
|
+
this.persistenceDeferred = options.deferPersistence === true;
|
|
1120
|
+
if (!this.persistenceDeferred)
|
|
1121
|
+
await this.persist();
|
|
1122
|
+
}
|
|
1123
|
+
catch (error) {
|
|
1124
|
+
this.state = undefined;
|
|
1125
|
+
this.persistenceDeferred = false;
|
|
1126
|
+
await this.releaseControllerLock();
|
|
1127
|
+
throw error;
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
/** Commit a successfully admitted startup exactly once. */
|
|
1132
|
+
async commitInitialization() {
|
|
1133
|
+
await this.mutex.run("gateway", async () => {
|
|
1134
|
+
this.requireState();
|
|
1135
|
+
if (!this.persistenceDeferred)
|
|
1136
|
+
return;
|
|
1137
|
+
await this.persist(true);
|
|
1138
|
+
this.persistenceDeferred = false;
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
async close() {
|
|
1142
|
+
await this.mutex.run("gateway", async () => {
|
|
1143
|
+
this.transientBodies.clear();
|
|
1144
|
+
this.state = undefined;
|
|
1145
|
+
this.persistenceDeferred = false;
|
|
1146
|
+
await this.releaseControllerLock();
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
async observeConnector(input) {
|
|
1150
|
+
await this.mutate(async (state, now) => {
|
|
1151
|
+
this.assertAllowedIdentity(input.identity);
|
|
1152
|
+
if (!PROTOCOL_PATTERN.test(input.protocol) ||
|
|
1153
|
+
!PROTOCOL_VERSION_PATTERN.test(input.protocolVersion) ||
|
|
1154
|
+
(input.safeErrorCode !== undefined &&
|
|
1155
|
+
!SAFE_CODE_PATTERN.test(input.safeErrorCode))) {
|
|
1156
|
+
throw new BridgeError("INVALID_CONNECTOR_OBSERVATION", "The connector observation contains an unsupported normalized field.");
|
|
1157
|
+
}
|
|
1158
|
+
const existingIndex = state.connectors.findIndex((connector) => connector.provider === input.identity.provider &&
|
|
1159
|
+
connector.hostId === input.identity.hostId);
|
|
1160
|
+
const existing = state.connectors[existingIndex];
|
|
1161
|
+
if (existing &&
|
|
1162
|
+
existing.endpointGeneration !== input.identity.endpointGeneration &&
|
|
1163
|
+
existing.health !== "offline") {
|
|
1164
|
+
throw new BridgeError("ENDPOINT_GENERATION_MISMATCH", "A live connector already owns this provider and host. It must be invalidated before a new endpoint generation is observed.");
|
|
1165
|
+
}
|
|
1166
|
+
const observed = {
|
|
1167
|
+
...input.identity,
|
|
1168
|
+
health: input.health,
|
|
1169
|
+
compatibility: input.compatibility,
|
|
1170
|
+
protocol: input.protocol,
|
|
1171
|
+
protocolVersion: input.protocolVersion,
|
|
1172
|
+
updatedAt: now.toISOString(),
|
|
1173
|
+
lastSeenAt: now.toISOString(),
|
|
1174
|
+
...(input.safeErrorCode ? { safeErrorCode: input.safeErrorCode } : {}),
|
|
1175
|
+
};
|
|
1176
|
+
if (existingIndex >= 0)
|
|
1177
|
+
state.connectors[existingIndex] = observed;
|
|
1178
|
+
else {
|
|
1179
|
+
if (state.connectors.length >= this.config.allowedHosts.length * 2) {
|
|
1180
|
+
throw new BridgeError("CONNECTOR_CAPACITY_REACHED", "The bounded provider and host connector registry is full.");
|
|
1181
|
+
}
|
|
1182
|
+
state.connectors.push(observed);
|
|
1183
|
+
}
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
async markConnectorOffline(identity, safeErrorCode = "CONNECTOR_OFFLINE", inFlightSettlements = []) {
|
|
1187
|
+
return await this.mutate(async (state, now) => {
|
|
1188
|
+
this.assertAllowedIdentity(identity);
|
|
1189
|
+
if (!SAFE_CODE_PATTERN.test(safeErrorCode)) {
|
|
1190
|
+
throw new BridgeError("INVALID_SAFE_ERROR_CODE", "Connector error codes must use the normalized safe-code grammar.");
|
|
1191
|
+
}
|
|
1192
|
+
const connector = state.connectors.find((candidate) => sameEndpoint(candidate, identity));
|
|
1193
|
+
if (!connector) {
|
|
1194
|
+
throw new BridgeError("CONNECTOR_NOT_FOUND", "No connector matches the exact private endpoint generation.");
|
|
1195
|
+
}
|
|
1196
|
+
const affectedAliases = new Set();
|
|
1197
|
+
for (const route of state.routes) {
|
|
1198
|
+
if (!sameEndpoint(route.binding, identity))
|
|
1199
|
+
continue;
|
|
1200
|
+
affectedAliases.add(route.alias);
|
|
1201
|
+
}
|
|
1202
|
+
const settlementPlan = this.validateAffectedInFlightSettlements(state, affectedAliases, inFlightSettlements);
|
|
1203
|
+
connector.health = "offline";
|
|
1204
|
+
connector.updatedAt = now.toISOString();
|
|
1205
|
+
connector.safeErrorCode = safeErrorCode;
|
|
1206
|
+
for (const route of state.routes) {
|
|
1207
|
+
if (!sameEndpoint(route.binding, identity))
|
|
1208
|
+
continue;
|
|
1209
|
+
route.state = route.enabled ? "stale" : "disabled";
|
|
1210
|
+
route.compatibility = "expired";
|
|
1211
|
+
route.updatedAt = now.toISOString();
|
|
1212
|
+
route.safeErrorCode = safeErrorCode;
|
|
1213
|
+
}
|
|
1214
|
+
return this.terminateAffectedMessages(state, affectedAliases, now, safeErrorCode, settlementPlan);
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
async registerRoute(input) {
|
|
1218
|
+
await this.mutate(async (state, now) => {
|
|
1219
|
+
this.validateRouteInput(input);
|
|
1220
|
+
this.assertAllowedIdentity(endpointOf(input.binding));
|
|
1221
|
+
const connector = state.connectors.find((candidate) => sameEndpoint(candidate, input.binding));
|
|
1222
|
+
if (!connector ||
|
|
1223
|
+
!["healthy", "degraded"].includes(connector.health) ||
|
|
1224
|
+
connector.compatibility !== "compatible") {
|
|
1225
|
+
throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact compatible endpoint generation must be observed before a route can be registered.");
|
|
1226
|
+
}
|
|
1227
|
+
const byAlias = state.routes.find((route) => route.alias === input.alias);
|
|
1228
|
+
const byBinding = state.routes.find((route) => sameRouteTarget(route.binding, input.binding));
|
|
1229
|
+
if (byAlias) {
|
|
1230
|
+
if (!sameBinding(byAlias.binding, input.binding)) {
|
|
1231
|
+
throw new BridgeError("ROUTE_ALIAS_COLLISION", "The public alias is already bound to another private route.");
|
|
1232
|
+
}
|
|
1233
|
+
if (byAlias.registrationMode !== input.registrationMode) {
|
|
1234
|
+
throw new BridgeError("ROUTE_REGISTRATION_MISMATCH", "The route registration mode cannot change for an existing binding.");
|
|
1235
|
+
}
|
|
1236
|
+
byAlias.enabled = true;
|
|
1237
|
+
byAlias.state = input.state ?? "idle";
|
|
1238
|
+
byAlias.compatibility = "compatible";
|
|
1239
|
+
byAlias.updatedAt = now.toISOString();
|
|
1240
|
+
byAlias.lastSeenAt = now.toISOString();
|
|
1241
|
+
delete byAlias.safeErrorCode;
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (byBinding) {
|
|
1245
|
+
throw new BridgeError("ROUTE_BINDING_COLLISION", "The private route is already registered under another public alias.");
|
|
1246
|
+
}
|
|
1247
|
+
if (state.routes.some((route) => route.binding.ownerLease === input.binding.ownerLease)) {
|
|
1248
|
+
throw new BridgeError("ROUTE_LEASE_COLLISION", "The private ownership lease already belongs to another route.");
|
|
1249
|
+
}
|
|
1250
|
+
if (state.routes.length >= this.config.limits.maxRoutes) {
|
|
1251
|
+
throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded gateway route registry is full.", true);
|
|
1252
|
+
}
|
|
1253
|
+
state.routes.push({
|
|
1254
|
+
alias: input.alias,
|
|
1255
|
+
binding: { ...input.binding },
|
|
1256
|
+
registrationMode: input.registrationMode,
|
|
1257
|
+
enabled: true,
|
|
1258
|
+
state: input.state ?? "idle",
|
|
1259
|
+
compatibility: "compatible",
|
|
1260
|
+
busyPolicy: "queue",
|
|
1261
|
+
registeredAt: now.toISOString(),
|
|
1262
|
+
updatedAt: now.toISOString(),
|
|
1263
|
+
lastSeenAt: now.toISOString(),
|
|
1264
|
+
queueDepth: 0,
|
|
1265
|
+
counters: emptyCounters(),
|
|
1266
|
+
});
|
|
1267
|
+
});
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Replace the complete selected-Claude set with one exact live session in a
|
|
1271
|
+
* single durable mutation. Work owned by every retired selection settles by
|
|
1272
|
+
* the same evidence-aware rules as explicit unselection; no public snapshot
|
|
1273
|
+
* can observe a two-selection intermediate state.
|
|
1274
|
+
*/
|
|
1275
|
+
async replaceClaudeSelection(input) {
|
|
1276
|
+
return await this.mutate(async (state, now) => {
|
|
1277
|
+
const replacement = input.replacement;
|
|
1278
|
+
this.validateRouteInput(replacement);
|
|
1279
|
+
this.assertAllowedIdentity(endpointOf(replacement.binding));
|
|
1280
|
+
if (replacement.binding.provider !== "claude" ||
|
|
1281
|
+
replacement.registrationMode !== "selected_live_peer") {
|
|
1282
|
+
throw new BridgeError("INVALID_CLAUDE_SELECTION_REPLACEMENT", "A Claude selection replacement must name one exact selected live peer.");
|
|
1283
|
+
}
|
|
1284
|
+
const connector = state.connectors.find((candidate) => sameEndpoint(candidate, replacement.binding));
|
|
1285
|
+
if (!connector ||
|
|
1286
|
+
!["healthy", "degraded"].includes(connector.health) ||
|
|
1287
|
+
connector.compatibility !== "compatible") {
|
|
1288
|
+
throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact compatible endpoint generation must be observed before a Claude selection can replace another.");
|
|
1289
|
+
}
|
|
1290
|
+
const selectedClaudeRoutes = state.routes.filter((route) => route.binding.provider === "claude" &&
|
|
1291
|
+
route.registrationMode === "selected_live_peer");
|
|
1292
|
+
const logicalMatches = selectedClaudeRoutes.filter((route) => route.binding.hostId === replacement.binding.hostId &&
|
|
1293
|
+
route.binding.routeHandle === replacement.binding.routeHandle &&
|
|
1294
|
+
route.binding.ownerLease === replacement.binding.ownerLease);
|
|
1295
|
+
if (logicalMatches.length > 1) {
|
|
1296
|
+
throw new BridgeError("CLAUDE_SELECTION_STATE_CORRUPT", "More than one durable selection claims the same exact Claude session authority.");
|
|
1297
|
+
}
|
|
1298
|
+
const retained = logicalMatches[0];
|
|
1299
|
+
if (retained !== undefined &&
|
|
1300
|
+
!sameBinding(retained.binding, replacement.binding) &&
|
|
1301
|
+
(!retained.enabled ||
|
|
1302
|
+
retained.state !== "stale" ||
|
|
1303
|
+
retained.compatibility !== "expired")) {
|
|
1304
|
+
throw new BridgeError("ROUTE_REBIND_IDENTITY_MISMATCH", "Only a stale exact Claude session may adopt a newly observed endpoint generation during replacement.");
|
|
1305
|
+
}
|
|
1306
|
+
const aliasOwner = state.routes.find((route) => route.alias === replacement.alias);
|
|
1307
|
+
if (aliasOwner !== undefined &&
|
|
1308
|
+
aliasOwner !== retained &&
|
|
1309
|
+
!selectedClaudeRoutes.includes(aliasOwner)) {
|
|
1310
|
+
throw new BridgeError("ROUTE_ALIAS_COLLISION", "The replacement alias belongs to a different non-retired route.");
|
|
1311
|
+
}
|
|
1312
|
+
// Replacing an endpoint is scoped to the replacement alias. Other
|
|
1313
|
+
// selected Claude routes and their independent consent edges survive.
|
|
1314
|
+
const retired = selectedClaudeRoutes.filter((route) => route !== retained && route === aliasOwner);
|
|
1315
|
+
const retiredAliases = new Set(retired.map((route) => route.alias));
|
|
1316
|
+
const settlementPlan = this.validateAffectedInFlightSettlements(state, retiredAliases, input.inFlightSettlements ?? []);
|
|
1317
|
+
const settlements = this.terminateAffectedMessages(state, retiredAliases, now, "ROUTE_UNREGISTERED", settlementPlan);
|
|
1318
|
+
this.settleProgressWatchesForAliases(state, retiredAliases, now);
|
|
1319
|
+
state.routes = state.routes.filter((route) => !retired.includes(route));
|
|
1320
|
+
removePairsForAliases(state, retiredAliases);
|
|
1321
|
+
state.rateBuckets = state.rateBuckets.filter((bucket) => !retiredAliases.has(bucket.sourceAlias));
|
|
1322
|
+
state.dedupe = state.dedupe.filter((record) => !retiredAliases.has(record.sourceAlias) &&
|
|
1323
|
+
!retiredAliases.has(record.targetAlias));
|
|
1324
|
+
if (state.routes.some((route) => route !== retained &&
|
|
1325
|
+
(sameRouteTarget(route.binding, replacement.binding) ||
|
|
1326
|
+
route.binding.ownerLease === replacement.binding.ownerLease))) {
|
|
1327
|
+
throw new BridgeError("ROUTE_BINDING_COLLISION", "The replacement Claude authority collides with a non-retired route.");
|
|
1328
|
+
}
|
|
1329
|
+
if (retained !== undefined) {
|
|
1330
|
+
const previousAlias = retained.alias;
|
|
1331
|
+
retained.alias = replacement.alias;
|
|
1332
|
+
retained.binding = { ...replacement.binding };
|
|
1333
|
+
retained.enabled = true;
|
|
1334
|
+
retained.state = replacement.state ?? "idle";
|
|
1335
|
+
retained.compatibility = "compatible";
|
|
1336
|
+
retained.updatedAt = now.toISOString();
|
|
1337
|
+
retained.lastSeenAt = now.toISOString();
|
|
1338
|
+
delete retained.safeErrorCode;
|
|
1339
|
+
if (previousAlias !== replacement.alias) {
|
|
1340
|
+
for (const item of [...state.queue, ...state.inFlight]) {
|
|
1341
|
+
if (item.sourceAlias === previousAlias) {
|
|
1342
|
+
item.sourceAlias = replacement.alias;
|
|
1343
|
+
}
|
|
1344
|
+
if (item.targetAlias === previousAlias) {
|
|
1345
|
+
item.targetAlias = replacement.alias;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
for (const record of state.dedupe) {
|
|
1349
|
+
if (record.sourceAlias === previousAlias) {
|
|
1350
|
+
record.sourceAlias = replacement.alias;
|
|
1351
|
+
}
|
|
1352
|
+
if (record.targetAlias === previousAlias) {
|
|
1353
|
+
record.targetAlias = replacement.alias;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
renameRateBucket(state, previousAlias, replacement.alias);
|
|
1357
|
+
renamePairAlias(state, previousAlias, replacement.alias, now);
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
if (state.routes.length >= this.config.limits.maxRoutes) {
|
|
1362
|
+
throw new BridgeError("ROUTE_CAPACITY_REACHED", "The bounded gateway route registry is full.", true);
|
|
1363
|
+
}
|
|
1364
|
+
state.routes.push({
|
|
1365
|
+
alias: replacement.alias,
|
|
1366
|
+
binding: { ...replacement.binding },
|
|
1367
|
+
registrationMode: replacement.registrationMode,
|
|
1368
|
+
enabled: true,
|
|
1369
|
+
state: replacement.state ?? "idle",
|
|
1370
|
+
compatibility: "compatible",
|
|
1371
|
+
busyPolicy: "queue",
|
|
1372
|
+
registeredAt: now.toISOString(),
|
|
1373
|
+
updatedAt: now.toISOString(),
|
|
1374
|
+
lastSeenAt: now.toISOString(),
|
|
1375
|
+
queueDepth: 0,
|
|
1376
|
+
counters: emptyCounters(),
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
if (state.routes.filter((route) => route.alias === replacement.alias &&
|
|
1380
|
+
route.binding.provider === "claude" &&
|
|
1381
|
+
route.registrationMode === "selected_live_peer" &&
|
|
1382
|
+
sameBinding(route.binding, replacement.binding)).length !== 1) {
|
|
1383
|
+
throw new BridgeError("CLAUDE_SELECTION_STATE_CORRUPT", "A successful endpoint replacement must leave exactly one matching Claude route.");
|
|
1384
|
+
}
|
|
1385
|
+
return settlements;
|
|
1386
|
+
});
|
|
1387
|
+
}
|
|
1388
|
+
async observeRoute(input) {
|
|
1389
|
+
await this.mutate(async (state, now) => {
|
|
1390
|
+
if (!isPrivateRouteBinding(input.binding) ||
|
|
1391
|
+
(input.safeErrorCode !== undefined &&
|
|
1392
|
+
!SAFE_CODE_PATTERN.test(input.safeErrorCode))) {
|
|
1393
|
+
throw new BridgeError("INVALID_ROUTE_OBSERVATION", "The exact route observation contains an unsupported private or normalized field.");
|
|
1394
|
+
}
|
|
1395
|
+
this.assertAllowedIdentity(endpointOf(input.binding));
|
|
1396
|
+
const route = state.routes.find((candidate) => sameBinding(candidate.binding, input.binding));
|
|
1397
|
+
const connector = state.connectors.find((candidate) => sameEndpoint(candidate, input.binding));
|
|
1398
|
+
if (!route || !route.enabled) {
|
|
1399
|
+
throw new BridgeError("ROUTE_OWNERSHIP_MISMATCH", "No enabled route matches the exact private binding and ownership lease.");
|
|
1400
|
+
}
|
|
1401
|
+
const endpointObservationValid = input.state === "stale"
|
|
1402
|
+
? connector?.health === "degraded" &&
|
|
1403
|
+
connector.compatibility === "expired"
|
|
1404
|
+
: connector !== undefined &&
|
|
1405
|
+
["healthy", "degraded"].includes(connector.health) &&
|
|
1406
|
+
connector.compatibility === "compatible";
|
|
1407
|
+
if (!endpointObservationValid) {
|
|
1408
|
+
throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The route observation must match the exact endpoint generation's current health and compatibility.");
|
|
1409
|
+
}
|
|
1410
|
+
route.state = input.state;
|
|
1411
|
+
route.compatibility = input.compatibility;
|
|
1412
|
+
route.lastSeenAt = now.toISOString();
|
|
1413
|
+
route.updatedAt = now.toISOString();
|
|
1414
|
+
if (input.safeErrorCode)
|
|
1415
|
+
route.safeErrorCode = input.safeErrorCode;
|
|
1416
|
+
else
|
|
1417
|
+
delete route.safeErrorCode;
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
async invalidateRoute(binding, safeErrorCode = "ROUTE_STALE", inFlightSettlements = []) {
|
|
1421
|
+
return await this.mutate(async (state, now) => {
|
|
1422
|
+
if (!isPrivateRouteBinding(binding) ||
|
|
1423
|
+
!SAFE_CODE_PATTERN.test(safeErrorCode)) {
|
|
1424
|
+
throw new BridgeError("INVALID_ROUTE_INVALIDATION", "Route invalidation requires an exact private binding and normalized safe code.");
|
|
1425
|
+
}
|
|
1426
|
+
this.assertAllowedIdentity(endpointOf(binding));
|
|
1427
|
+
const route = state.routes.find((candidate) => sameBinding(candidate.binding, binding));
|
|
1428
|
+
if (!route || !route.enabled) {
|
|
1429
|
+
throw new BridgeError("ROUTE_OWNERSHIP_MISMATCH", "No enabled route matches the exact private binding and ownership lease.");
|
|
1430
|
+
}
|
|
1431
|
+
const aliases = new Set([route.alias]);
|
|
1432
|
+
const settlementPlan = this.validateAffectedInFlightSettlements(state, aliases, inFlightSettlements);
|
|
1433
|
+
route.state = "stale";
|
|
1434
|
+
route.compatibility = "expired";
|
|
1435
|
+
route.updatedAt = now.toISOString();
|
|
1436
|
+
route.safeErrorCode = safeErrorCode;
|
|
1437
|
+
return this.terminateAffectedMessages(state, aliases, now, safeErrorCode, settlementPlan);
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Replaces only an already-invalidated binding. Codex must retain the exact
|
|
1442
|
+
* task handle. Claude must retain its exact session UUID and ownership lease;
|
|
1443
|
+
* an authorized discovery may also atomically adopt that UUID's latest live
|
|
1444
|
+
* display name. Names alone are never rebind authority.
|
|
1445
|
+
*/
|
|
1446
|
+
async rebindStaleRoute(input) {
|
|
1447
|
+
await this.mutate(async (state, now) => {
|
|
1448
|
+
const newAlias = input.newAlias ?? input.alias;
|
|
1449
|
+
if (!ALIAS_PATTERN.test(input.alias) ||
|
|
1450
|
+
!ALIAS_PATTERN.test(newAlias) ||
|
|
1451
|
+
!isPrivateRouteBinding(input.newBinding) ||
|
|
1452
|
+
!newAlias.endsWith(`@${input.newBinding.hostId}`)) {
|
|
1453
|
+
throw new BridgeError("INVALID_ROUTE_REBIND", "The stale-route rebind request is malformed.");
|
|
1454
|
+
}
|
|
1455
|
+
this.assertAllowedIdentity(endpointOf(input.newBinding));
|
|
1456
|
+
const route = this.requireOwnedRoute(state, input.alias, input.currentOwnerLease);
|
|
1457
|
+
if (!route.enabled ||
|
|
1458
|
+
route.state !== "stale" ||
|
|
1459
|
+
route.compatibility !== "expired" ||
|
|
1460
|
+
route.queueDepth !== 0 ||
|
|
1461
|
+
state.queue.some((item) => item.sourceAlias === route.alias || item.targetAlias === route.alias) ||
|
|
1462
|
+
state.inFlight.some((item) => item.sourceAlias === route.alias || item.targetAlias === route.alias)) {
|
|
1463
|
+
throw new BridgeError("ROUTE_REBIND_NOT_SAFE", "Only an enabled, expired, empty stale route can be rebound.");
|
|
1464
|
+
}
|
|
1465
|
+
if (route.binding.provider !== input.newBinding.provider ||
|
|
1466
|
+
route.binding.hostId !== input.newBinding.hostId) {
|
|
1467
|
+
throw new BridgeError("ROUTE_REBIND_SCOPE_MISMATCH", "A route rebind cannot change provider or allowlisted host.");
|
|
1468
|
+
}
|
|
1469
|
+
const sameLogicalRoute = route.binding.routeHandle === input.newBinding.routeHandle &&
|
|
1470
|
+
route.binding.ownerLease === input.newBinding.ownerLease;
|
|
1471
|
+
const claudeReasonAllowed = input.reason === "peer_explicitly_reselected" ||
|
|
1472
|
+
input.reason === "peer_identity_reobserved";
|
|
1473
|
+
if ((route.binding.provider === "codex" &&
|
|
1474
|
+
(input.reason !== "endpoint_reobserved" || !sameLogicalRoute)) ||
|
|
1475
|
+
(route.binding.provider === "claude" &&
|
|
1476
|
+
(!claudeReasonAllowed || !sameLogicalRoute))) {
|
|
1477
|
+
throw new BridgeError("ROUTE_RESELECTION_REQUIRED", "A stale route may rebind only the same provider-native logical identity and ownership lease.");
|
|
1478
|
+
}
|
|
1479
|
+
const connector = state.connectors.find((candidate) => sameEndpoint(candidate, input.newBinding));
|
|
1480
|
+
if (!connector ||
|
|
1481
|
+
!["healthy", "degraded"].includes(connector.health) ||
|
|
1482
|
+
connector.compatibility !== "compatible") {
|
|
1483
|
+
throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The replacement endpoint generation must be positively observed and compatible.");
|
|
1484
|
+
}
|
|
1485
|
+
if (state.routes.some((candidate) => candidate !== route &&
|
|
1486
|
+
(candidate.alias === newAlias ||
|
|
1487
|
+
sameRouteTarget(candidate.binding, input.newBinding) ||
|
|
1488
|
+
candidate.binding.ownerLease === input.newBinding.ownerLease))) {
|
|
1489
|
+
throw new BridgeError("ROUTE_BINDING_COLLISION", "The replacement private binding already belongs to another alias.");
|
|
1490
|
+
}
|
|
1491
|
+
const previousAlias = route.alias;
|
|
1492
|
+
route.binding = { ...input.newBinding };
|
|
1493
|
+
route.alias = newAlias;
|
|
1494
|
+
route.state = input.state ?? "idle";
|
|
1495
|
+
route.compatibility = "compatible";
|
|
1496
|
+
route.updatedAt = now.toISOString();
|
|
1497
|
+
route.lastSeenAt = now.toISOString();
|
|
1498
|
+
delete route.safeErrorCode;
|
|
1499
|
+
if (previousAlias !== newAlias) {
|
|
1500
|
+
// Restart recovery has already emptied queue/in-flight state. Keep the
|
|
1501
|
+
// metadata rewrite complete anyway so this mutation stays atomic if a
|
|
1502
|
+
// future caller uses the same primitive after another invalidation.
|
|
1503
|
+
for (const item of [...state.queue, ...state.inFlight]) {
|
|
1504
|
+
if (item.sourceAlias === previousAlias)
|
|
1505
|
+
item.sourceAlias = newAlias;
|
|
1506
|
+
if (item.targetAlias === previousAlias)
|
|
1507
|
+
item.targetAlias = newAlias;
|
|
1508
|
+
}
|
|
1509
|
+
for (const record of state.dedupe) {
|
|
1510
|
+
if (record.sourceAlias === previousAlias) {
|
|
1511
|
+
record.sourceAlias = newAlias;
|
|
1512
|
+
}
|
|
1513
|
+
if (record.targetAlias === previousAlias) {
|
|
1514
|
+
record.targetAlias = newAlias;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
renameRateBucket(state, previousAlias, newAlias);
|
|
1518
|
+
renamePairAlias(state, previousAlias, newAlias, now);
|
|
1519
|
+
}
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
async disableRoute(alias, ownerLease, inFlightSettlements = []) {
|
|
1523
|
+
return await this.mutate(async (state, now) => {
|
|
1524
|
+
const route = this.requireOwnedRoute(state, alias, ownerLease);
|
|
1525
|
+
const aliases = new Set([route.alias]);
|
|
1526
|
+
const settlementPlan = this.validateAffectedInFlightSettlements(state, aliases, inFlightSettlements);
|
|
1527
|
+
route.enabled = false;
|
|
1528
|
+
route.state = "disabled";
|
|
1529
|
+
route.updatedAt = now.toISOString();
|
|
1530
|
+
return this.terminateAffectedMessages(state, aliases, now, "ROUTE_DISABLED", settlementPlan);
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
async unregisterRoute(alias, ownerLease, inFlightSettlements = []) {
|
|
1534
|
+
return await this.mutate(async (state, now) => {
|
|
1535
|
+
const route = this.requireOwnedRoute(state, alias, ownerLease);
|
|
1536
|
+
const aliases = new Set([route.alias]);
|
|
1537
|
+
const settlementPlan = this.validateAffectedInFlightSettlements(state, aliases, inFlightSettlements);
|
|
1538
|
+
const settlements = this.terminateAffectedMessages(state, aliases, now, "ROUTE_UNREGISTERED", settlementPlan);
|
|
1539
|
+
this.settleProgressWatchesForAliases(state, aliases, now);
|
|
1540
|
+
state.routes = state.routes.filter((candidate) => candidate !== route);
|
|
1541
|
+
removePairsForAliases(state, aliases);
|
|
1542
|
+
const remainingCodexHosts = new Set(state.routes
|
|
1543
|
+
.filter((candidate) => candidate.binding.provider === "codex")
|
|
1544
|
+
.map((candidate) => candidate.binding.hostId));
|
|
1545
|
+
state.rateBuckets = state.rateBuckets.filter((bucket) => bucket.sourceAlias !== route.alias &&
|
|
1546
|
+
(state.routes.some((candidate) => candidate.alias === bucket.sourceAlias) ||
|
|
1547
|
+
remainingCodexHosts.has(bucket.sourceAlias.slice(bucket.sourceAlias.lastIndexOf("@") + 1))));
|
|
1548
|
+
state.dedupe = state.dedupe.filter((record) => record.sourceAlias !== route.alias &&
|
|
1549
|
+
record.targetAlias !== route.alias);
|
|
1550
|
+
return settlements;
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Move one owned route to its provider's latest live display name without
|
|
1555
|
+
* changing the logical provider route binding. Historical normalized events
|
|
1556
|
+
* retain the name that was true when they were emitted; active metadata is
|
|
1557
|
+
* rewritten so subsequent settlement and dispatch use only the latest name.
|
|
1558
|
+
*/
|
|
1559
|
+
async renameRoute(alias, newAlias, ownerLease) {
|
|
1560
|
+
await this.mutate(async (state, now) => {
|
|
1561
|
+
if (!ALIAS_PATTERN.test(newAlias)) {
|
|
1562
|
+
throw new BridgeError("INVALID_ROUTE_ALIAS", "The replacement route alias is invalid.");
|
|
1563
|
+
}
|
|
1564
|
+
const route = this.requireOwnedRoute(state, alias, ownerLease);
|
|
1565
|
+
if (alias === newAlias)
|
|
1566
|
+
return;
|
|
1567
|
+
if (state.routes.some((candidate) => candidate.alias === newAlias)) {
|
|
1568
|
+
throw new BridgeError("ROUTE_ALIAS_COLLISION", "The replacement alias already belongs to another route.");
|
|
1569
|
+
}
|
|
1570
|
+
route.alias = newAlias;
|
|
1571
|
+
route.updatedAt = now.toISOString();
|
|
1572
|
+
route.lastSeenAt = now.toISOString();
|
|
1573
|
+
for (const item of [...state.queue, ...state.inFlight]) {
|
|
1574
|
+
if (item.sourceAlias === alias)
|
|
1575
|
+
item.sourceAlias = newAlias;
|
|
1576
|
+
if (item.targetAlias === alias)
|
|
1577
|
+
item.targetAlias = newAlias;
|
|
1578
|
+
}
|
|
1579
|
+
for (const record of state.dedupe) {
|
|
1580
|
+
if (record.sourceAlias === alias)
|
|
1581
|
+
record.sourceAlias = newAlias;
|
|
1582
|
+
if (record.targetAlias === alias)
|
|
1583
|
+
record.targetAlias = newAlias;
|
|
1584
|
+
}
|
|
1585
|
+
renameRateBucket(state, alias, newAlias);
|
|
1586
|
+
renamePairAlias(state, alias, newAlias, now);
|
|
1587
|
+
for (let index = 0; index < state.progressWatches.length; index += 1) {
|
|
1588
|
+
const watch = state.progressWatches[index];
|
|
1589
|
+
if (watch === undefined)
|
|
1590
|
+
continue;
|
|
1591
|
+
if (watch.ownerAlias === alias) {
|
|
1592
|
+
state.progressWatches[index] = {
|
|
1593
|
+
...watch,
|
|
1594
|
+
ownerAlias: newAlias,
|
|
1595
|
+
updatedAt: now.toISOString(),
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
else if (watch.workerAlias === alias) {
|
|
1599
|
+
state.progressWatches[index] = {
|
|
1600
|
+
...watch,
|
|
1601
|
+
workerAlias: newAlias,
|
|
1602
|
+
updatedAt: now.toISOString(),
|
|
1603
|
+
};
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
async resolveRoute(alias) {
|
|
1609
|
+
return this.mutex.run("gateway", async () => {
|
|
1610
|
+
const state = this.requireState();
|
|
1611
|
+
const route = state.routes.find((candidate) => candidate.alias === alias);
|
|
1612
|
+
if (route?.enabled === true &&
|
|
1613
|
+
route.binding.provider === "codex" &&
|
|
1614
|
+
route.state === "stale" &&
|
|
1615
|
+
route.safeErrorCode === "CODEX_ROUTE_STALE") {
|
|
1616
|
+
throw new BridgeError("CODEX_ROUTE_STALE", "The selected Codex route exists but its connector is stale.", true);
|
|
1617
|
+
}
|
|
1618
|
+
if (!route ||
|
|
1619
|
+
!route.enabled ||
|
|
1620
|
+
!["idle", "busy", "awaiting_approval"].includes(route.state) ||
|
|
1621
|
+
route.compatibility !== "compatible") {
|
|
1622
|
+
throw new BridgeError("ROUTE_UNAVAILABLE", "The selected route is not currently enabled and positively observed.", true);
|
|
1623
|
+
}
|
|
1624
|
+
return { ...route.binding };
|
|
1625
|
+
});
|
|
1626
|
+
}
|
|
1627
|
+
/**
|
|
1628
|
+
* Controller-internal ownership lookup used for explicit reactivation and
|
|
1629
|
+
* unregister after restart. Callers must never return this value through a
|
|
1630
|
+
* control response, dashboard projection, log, or normalized event.
|
|
1631
|
+
*/
|
|
1632
|
+
async inspectPrivateRoute(alias) {
|
|
1633
|
+
return this.mutex.run("gateway", async () => {
|
|
1634
|
+
const state = this.requireState();
|
|
1635
|
+
if (!ALIAS_PATTERN.test(alias)) {
|
|
1636
|
+
throw new BridgeError("INVALID_GATEWAY_ALIAS", "The selected alias does not use the required lowercase ASCII grammar.");
|
|
1637
|
+
}
|
|
1638
|
+
const route = state.routes.find((candidate) => candidate.alias === alias);
|
|
1639
|
+
if (!route)
|
|
1640
|
+
return undefined;
|
|
1641
|
+
return {
|
|
1642
|
+
alias: route.alias,
|
|
1643
|
+
binding: { ...route.binding },
|
|
1644
|
+
registrationMode: route.registrationMode,
|
|
1645
|
+
enabled: route.enabled,
|
|
1646
|
+
state: route.state,
|
|
1647
|
+
compatibility: route.compatibility,
|
|
1648
|
+
...(route.safeErrorCode === undefined
|
|
1649
|
+
? {}
|
|
1650
|
+
: { safeErrorCode: route.safeErrorCode }),
|
|
1651
|
+
};
|
|
1652
|
+
});
|
|
1653
|
+
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Controller-internal inventory used to preserve exact Codex registration
|
|
1656
|
+
* identities across restart. Native task handles never cross the control
|
|
1657
|
+
* protocol or enter a public projection.
|
|
1658
|
+
*/
|
|
1659
|
+
async inspectPrivateCodexRoutes() {
|
|
1660
|
+
return this.mutex.run("gateway", async () => {
|
|
1661
|
+
const state = this.requireState();
|
|
1662
|
+
return state.routes
|
|
1663
|
+
.filter((route) => route.binding.provider === "codex" &&
|
|
1664
|
+
route.registrationMode === "explicit_opt_in")
|
|
1665
|
+
.slice(0, this.config.limits.maxRoutes)
|
|
1666
|
+
.map((route) => ({
|
|
1667
|
+
alias: route.alias,
|
|
1668
|
+
binding: { ...route.binding },
|
|
1669
|
+
registrationMode: route.registrationMode,
|
|
1670
|
+
enabled: route.enabled,
|
|
1671
|
+
state: route.state,
|
|
1672
|
+
compatibility: route.compatibility,
|
|
1673
|
+
}));
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* Bounded, metadata-only barrier observation for the succession controller.
|
|
1678
|
+
* No route, message, conversation, or listener identifier is returned.
|
|
1679
|
+
*/
|
|
1680
|
+
async inspectCodexSuccessionBarrier(alias) {
|
|
1681
|
+
return this.mutex.run("gateway", async () => {
|
|
1682
|
+
const state = this.requireState();
|
|
1683
|
+
const codexRoutes = state.routes.filter((route) => route.binding.provider === "codex" && route.alias === alias);
|
|
1684
|
+
const codexQueueDepth = codexRoutes.reduce((total, route) => total + route.queueDepth, 0);
|
|
1685
|
+
const queued = state.queue.filter((item) => item.sourceAlias === alias || item.targetAlias === alias);
|
|
1686
|
+
const inFlight = state.inFlight.filter((item) => item.sourceAlias === alias || item.targetAlias === alias);
|
|
1687
|
+
const ownedMessageIds = new Set([
|
|
1688
|
+
...queued.map((item) => item.messageId),
|
|
1689
|
+
...inFlight.map((item) => item.messageId),
|
|
1690
|
+
]);
|
|
1691
|
+
const transientBodyCount = [...this.transientBodies.keys()].filter((messageId) => ownedMessageIds.has(messageId)).length;
|
|
1692
|
+
const inspection = {
|
|
1693
|
+
codexRouteCount: codexRoutes.length,
|
|
1694
|
+
queueCount: queued.length,
|
|
1695
|
+
inFlightCount: inFlight.length,
|
|
1696
|
+
transientBodyCount,
|
|
1697
|
+
codexQueueDepth,
|
|
1698
|
+
clean: codexRoutes.length === 1 &&
|
|
1699
|
+
queued.length === 0 &&
|
|
1700
|
+
inFlight.length === 0 &&
|
|
1701
|
+
transientBodyCount === 0 &&
|
|
1702
|
+
codexQueueDepth === 0,
|
|
1703
|
+
};
|
|
1704
|
+
return inspection;
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
/** Begin one exact, same-host Codex registration succession. */
|
|
1708
|
+
async prepareCodexSuccession(input) {
|
|
1709
|
+
await this.mutate(async (state) => {
|
|
1710
|
+
if (!isObject(input) ||
|
|
1711
|
+
!hasOnlyKeys(input, ["old", "new"]) ||
|
|
1712
|
+
!isCodexSuccessionIdentity(input.old) ||
|
|
1713
|
+
!isCodexSuccessionIdentity(input.new) ||
|
|
1714
|
+
!isCodexSuccessionJournal({
|
|
1715
|
+
schemaVersion: 1,
|
|
1716
|
+
stage: "prepared",
|
|
1717
|
+
old: input.old,
|
|
1718
|
+
new: input.new,
|
|
1719
|
+
})) {
|
|
1720
|
+
throw new BridgeError("INVALID_CODEX_SUCCESSION", "The Codex succession identities are malformed, non-distinct, or cross-host.");
|
|
1721
|
+
}
|
|
1722
|
+
this.assertAllowedIdentity(endpointOf(input.old.binding));
|
|
1723
|
+
this.assertAllowedIdentity(endpointOf(input.new.binding));
|
|
1724
|
+
if (state.codexSuccession !== null) {
|
|
1725
|
+
throw new BridgeError("CODEX_SUCCESSION_ALREADY_ACTIVE", "A durable Codex succession journal already exists.");
|
|
1726
|
+
}
|
|
1727
|
+
this.assertSuccessionLedgerEmpty(state, input.old.alias);
|
|
1728
|
+
const route = this.requireCodexRouteForIdentity(state, input.old);
|
|
1729
|
+
if (!routeMatchesSuccessionIdentity(route, input.old)) {
|
|
1730
|
+
throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The old succession identity does not exactly own its Codex route.");
|
|
1731
|
+
}
|
|
1732
|
+
this.requireCompatibleObservedEndpoint(state, input.new.binding);
|
|
1733
|
+
this.assertNewSuccessionIdentityAvailable(state, route, input.new);
|
|
1734
|
+
state.codexSuccession = {
|
|
1735
|
+
schemaVersion: 1,
|
|
1736
|
+
stage: "prepared",
|
|
1737
|
+
old: cloneSuccessionIdentity(input.old),
|
|
1738
|
+
new: cloneSuccessionIdentity(input.new),
|
|
1739
|
+
};
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
/** Durably closes the rollback-to-old boundary before registry publication. */
|
|
1743
|
+
async armCodexSuccessionPublication(input) {
|
|
1744
|
+
await this.mutate(async (state) => {
|
|
1745
|
+
const journal = this.requireExactSuccession(state, input, ["prepared"]);
|
|
1746
|
+
this.assertSuccessionLedgerEmpty(state, journal.old.alias);
|
|
1747
|
+
this.requireJournalRoute(state, journal, "old");
|
|
1748
|
+
state.codexSuccession = { ...journal, stage: "publication_armed" };
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
/** Record that the exact new registry generation is externally visible. */
|
|
1752
|
+
async markCodexSuccessionPublished(input) {
|
|
1753
|
+
await this.mutate(async (state) => {
|
|
1754
|
+
const journal = this.requireExactSuccession(state, input, ["publication_armed"]);
|
|
1755
|
+
this.assertSuccessionLedgerEmpty(state, journal.old.alias);
|
|
1756
|
+
this.requireJournalRoute(state, journal, "old");
|
|
1757
|
+
state.codexSuccession = { ...journal, stage: "published" };
|
|
1758
|
+
});
|
|
1759
|
+
}
|
|
1760
|
+
/**
|
|
1761
|
+
* Atomically replace the sole old Codex route with the exact journaled new
|
|
1762
|
+
* private binding. No route gap is ever persisted.
|
|
1763
|
+
*/
|
|
1764
|
+
async activateCodexSuccession(input) {
|
|
1765
|
+
await this.mutate(async (state, now) => {
|
|
1766
|
+
if (!isObject(input) ||
|
|
1767
|
+
!hasOnlyKeys(input, ["oldGeneration", "newGeneration", "state"]) ||
|
|
1768
|
+
!["idle", "busy", "awaiting_approval"].includes(input.state)) {
|
|
1769
|
+
throw new BridgeError("INVALID_CODEX_SUCCESSION", "The Codex succession activation request is malformed.");
|
|
1770
|
+
}
|
|
1771
|
+
const journal = this.requireExactSuccession(state, {
|
|
1772
|
+
oldGeneration: input.oldGeneration,
|
|
1773
|
+
newGeneration: input.newGeneration,
|
|
1774
|
+
}, ["published"]);
|
|
1775
|
+
this.assertSuccessionLedgerEmpty(state, journal.old.alias);
|
|
1776
|
+
const route = this.requireJournalRoute(state, journal, "old");
|
|
1777
|
+
this.requireCompatibleObservedEndpoint(state, journal.new.binding);
|
|
1778
|
+
this.assertNewSuccessionIdentityAvailable(state, route, journal.new);
|
|
1779
|
+
this.replaceCodexRouteForSuccession(state, route, journal.new, now, input.state, "compatible");
|
|
1780
|
+
state.codexSuccession = { ...journal, stage: "activated" };
|
|
1781
|
+
});
|
|
1782
|
+
}
|
|
1783
|
+
/** Record an armed-or-later failure for fail-closed restart authority. */
|
|
1784
|
+
async forbidCodexSuccessionRecovery(input) {
|
|
1785
|
+
await this.mutate(async (state) => {
|
|
1786
|
+
if (!isObject(input) ||
|
|
1787
|
+
!hasOnlyKeys(input, [
|
|
1788
|
+
"oldGeneration",
|
|
1789
|
+
"newGeneration",
|
|
1790
|
+
"safeErrorCode",
|
|
1791
|
+
]) ||
|
|
1792
|
+
!isSafeCode(input.safeErrorCode)) {
|
|
1793
|
+
throw new BridgeError("INVALID_CODEX_SUCCESSION", "The Codex recovery-forbidden request is malformed.");
|
|
1794
|
+
}
|
|
1795
|
+
const journal = this.requireExactSuccession(state, {
|
|
1796
|
+
oldGeneration: input.oldGeneration,
|
|
1797
|
+
newGeneration: input.newGeneration,
|
|
1798
|
+
}, [
|
|
1799
|
+
"publication_armed",
|
|
1800
|
+
"published",
|
|
1801
|
+
"activated",
|
|
1802
|
+
"recovery_forbidden",
|
|
1803
|
+
]);
|
|
1804
|
+
this.assertSuccessionLedgerEmpty(state, journal.old.alias);
|
|
1805
|
+
const expected = journal.stage === "activated" ? "new" : undefined;
|
|
1806
|
+
if (expected !== undefined)
|
|
1807
|
+
this.requireJournalRoute(state, journal, expected);
|
|
1808
|
+
else {
|
|
1809
|
+
this.requireAnyJournalRoute(state, journal);
|
|
1810
|
+
}
|
|
1811
|
+
state.codexSuccession = {
|
|
1812
|
+
...journal,
|
|
1813
|
+
stage: "recovery_forbidden",
|
|
1814
|
+
safeErrorCode: input.safeErrorCode,
|
|
1815
|
+
};
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
/** Clear before arming, or after positive proof that an arm was unpublished. */
|
|
1819
|
+
async clearCodexSuccession(input) {
|
|
1820
|
+
await this.mutate(async (state) => {
|
|
1821
|
+
if (!isObject(input) ||
|
|
1822
|
+
!hasOnlyKeys(input, ["oldGeneration", "newGeneration"], ["publicationAbsenceConfirmed"]) ||
|
|
1823
|
+
(input.publicationAbsenceConfirmed !== undefined &&
|
|
1824
|
+
input.publicationAbsenceConfirmed !== true)) {
|
|
1825
|
+
throw new BridgeError("INVALID_CODEX_SUCCESSION", "The Codex succession clear request is malformed.");
|
|
1826
|
+
}
|
|
1827
|
+
const journal = this.requireExactSuccession(state, {
|
|
1828
|
+
oldGeneration: input.oldGeneration,
|
|
1829
|
+
newGeneration: input.newGeneration,
|
|
1830
|
+
}, ["prepared", "publication_armed"]);
|
|
1831
|
+
if (journal.stage === "publication_armed" &&
|
|
1832
|
+
input.publicationAbsenceConfirmed !== true) {
|
|
1833
|
+
throw new BridgeError("CODEX_SUCCESSION_PUBLICATION_PROOF_REQUIRED", "Clearing an armed succession requires positive proof that publication is absent.");
|
|
1834
|
+
}
|
|
1835
|
+
this.assertSuccessionLedgerEmpty(state, journal.old.alias);
|
|
1836
|
+
this.requireJournalRoute(state, journal, "old");
|
|
1837
|
+
state.codexSuccession = null;
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
/** Complete an activated or restart-canonicalized new generation. */
|
|
1841
|
+
async completeCodexSuccession(input) {
|
|
1842
|
+
await this.mutate(async (state) => {
|
|
1843
|
+
const journal = this.requireExactSuccession(state, input, [
|
|
1844
|
+
"activated",
|
|
1845
|
+
"recovery_forbidden",
|
|
1846
|
+
]);
|
|
1847
|
+
this.assertSuccessionLedgerEmpty(state, journal.new.alias);
|
|
1848
|
+
this.requireJournalRoute(state, journal, "new");
|
|
1849
|
+
state.codexSuccession = null;
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
/** Controller-private restart authority; omitted from every public view. */
|
|
1853
|
+
async inspectCodexSuccessionRecoveryAuthority() {
|
|
1854
|
+
return this.mutex.run("gateway", async () => {
|
|
1855
|
+
const state = this.requireState();
|
|
1856
|
+
const journal = this.journal(state);
|
|
1857
|
+
if (journal === null)
|
|
1858
|
+
return { authority: "none" };
|
|
1859
|
+
return {
|
|
1860
|
+
authority: journal.stage === "prepared" ? "old" : "new",
|
|
1861
|
+
journal: cloneSuccessionJournal(journal),
|
|
1862
|
+
};
|
|
1863
|
+
});
|
|
1864
|
+
}
|
|
1865
|
+
/**
|
|
1866
|
+
* Controller-internal inventory used only for exact-UUID Claude restoration
|
|
1867
|
+
* and explicit offline unselection. The result is bounded by maxRoutes and
|
|
1868
|
+
* must never cross the control protocol or enter a public projection.
|
|
1869
|
+
*/
|
|
1870
|
+
async inspectPrivateClaudeRoutes() {
|
|
1871
|
+
return this.mutex.run("gateway", async () => {
|
|
1872
|
+
const state = this.requireState();
|
|
1873
|
+
return state.routes
|
|
1874
|
+
.filter((route) => route.binding.provider === "claude" &&
|
|
1875
|
+
route.registrationMode === "selected_live_peer")
|
|
1876
|
+
.slice(0, this.config.limits.maxRoutes)
|
|
1877
|
+
.map((route) => ({
|
|
1878
|
+
alias: route.alias,
|
|
1879
|
+
binding: { ...route.binding },
|
|
1880
|
+
registrationMode: route.registrationMode,
|
|
1881
|
+
enabled: route.enabled,
|
|
1882
|
+
state: route.state,
|
|
1883
|
+
compatibility: route.compatibility,
|
|
1884
|
+
}));
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
/** Create one explicit, durable Claude↔Codex consent edge. */
|
|
1888
|
+
async pairRoutes(input) {
|
|
1889
|
+
return this.mutate(async (state, now) => {
|
|
1890
|
+
const { claude, codex } = this.requirePairRoutes(state, input);
|
|
1891
|
+
const existing = state.pairs.find((pair) => pair.claudeAlias === claude.alias && pair.codexAlias === codex.alias);
|
|
1892
|
+
if (existing !== undefined) {
|
|
1893
|
+
if (existing.claudeOwnerLease !== claude.binding.ownerLease ||
|
|
1894
|
+
existing.codexOwnerLease !== codex.binding.ownerLease) {
|
|
1895
|
+
throw new BridgeError("PAIR_AUTHORITY_MISMATCH", "The pair aliases no longer match their exact route authorities.");
|
|
1896
|
+
}
|
|
1897
|
+
return { created: false };
|
|
1898
|
+
}
|
|
1899
|
+
if (state.pairs.length >= this.config.limits.maxPairs) {
|
|
1900
|
+
throw new BridgeError("PAIR_CAPACITY_REACHED", "The bounded permission graph cannot accept another pair.", true);
|
|
1901
|
+
}
|
|
1902
|
+
state.pairs.push({
|
|
1903
|
+
claudeAlias: claude.alias,
|
|
1904
|
+
codexAlias: codex.alias,
|
|
1905
|
+
claudeOwnerLease: claude.binding.ownerLease,
|
|
1906
|
+
codexOwnerLease: codex.binding.ownerLease,
|
|
1907
|
+
createdAt: now.toISOString(),
|
|
1908
|
+
updatedAt: now.toISOString(),
|
|
1909
|
+
counters: emptyCounters(),
|
|
1910
|
+
});
|
|
1911
|
+
return { created: true };
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
/** Bounded metadata-only graph inventory for controller inference. */
|
|
1915
|
+
async inspectPairs() {
|
|
1916
|
+
return this.mutex.run("gateway", async () => {
|
|
1917
|
+
const state = this.requireState();
|
|
1918
|
+
return state.pairs
|
|
1919
|
+
.map(({ claudeAlias, codexAlias }) => ({ claudeAlias, codexAlias }))
|
|
1920
|
+
.sort((left, right) => pairKey(left.claudeAlias, left.codexAlias).localeCompare(pairKey(right.claudeAlias, right.codexAlias)));
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
/** Open one durable owner-ended watch on an exact consent edge. */
|
|
1924
|
+
async openProgressWatch(input) {
|
|
1925
|
+
return this.mutate(async (state, now) => {
|
|
1926
|
+
if (this.config.trackingEnabled === false) {
|
|
1927
|
+
throw new BridgeError("PROGRESS_TRACKING_DISABLED", "Progress tracking is disabled by the controller configuration.");
|
|
1928
|
+
}
|
|
1929
|
+
if (!CONVERSATION_ID_PATTERN.test(input.conversationId) ||
|
|
1930
|
+
!ALIAS_PATTERN.test(input.ownerAlias) ||
|
|
1931
|
+
!ALIAS_PATTERN.test(input.workerAlias) ||
|
|
1932
|
+
input.ownerAlias === input.workerAlias ||
|
|
1933
|
+
!Number.isSafeInteger(input.idleMs) ||
|
|
1934
|
+
input.idleMs < PROGRESS_WATCH_MIN_IDLE_MS ||
|
|
1935
|
+
input.idleMs > PROGRESS_WATCH_MAX_IDLE_MS) {
|
|
1936
|
+
throw new BridgeError("INVALID_PROGRESS_WATCH", "The progress-watch request is malformed or outside its idle bound.");
|
|
1937
|
+
}
|
|
1938
|
+
const existing = state.progressWatches.find((watch) => watch.conversationId === input.conversationId);
|
|
1939
|
+
if (existing !== undefined) {
|
|
1940
|
+
if (existing.ownerAlias !== input.ownerAlias ||
|
|
1941
|
+
existing.workerAlias !== input.workerAlias) {
|
|
1942
|
+
throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The conversation watch belongs to different exact endpoints.");
|
|
1943
|
+
}
|
|
1944
|
+
return { created: false, watch: { ...existing } };
|
|
1945
|
+
}
|
|
1946
|
+
if (state.progressWatches.length >=
|
|
1947
|
+
(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
|
|
1948
|
+
throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The bounded progress-watch inventory is full.", true);
|
|
1949
|
+
}
|
|
1950
|
+
const owner = this.requireAvailableRoute(state, input.ownerAlias);
|
|
1951
|
+
const worker = this.requireAvailableRoute(state, input.workerAlias);
|
|
1952
|
+
const pairAliases = pairAliasesForRoutes(owner, worker);
|
|
1953
|
+
this.requireExactPair(state, owner.binding.provider === "claude" ? owner : worker, owner.binding.provider === "codex" ? owner : worker);
|
|
1954
|
+
if (pairAliases.claudeAlias !==
|
|
1955
|
+
(owner.binding.provider === "claude"
|
|
1956
|
+
? owner.alias
|
|
1957
|
+
: worker.alias) ||
|
|
1958
|
+
pairAliases.codexAlias !==
|
|
1959
|
+
(owner.binding.provider === "codex" ? owner.alias : worker.alias)) {
|
|
1960
|
+
throw new BridgeError("PROGRESS_WATCH_EDGE_MISMATCH", "The watch endpoints do not form one exact consent edge.");
|
|
1961
|
+
}
|
|
1962
|
+
const watch = createProgressWatchMachine({
|
|
1963
|
+
conversationId: input.conversationId,
|
|
1964
|
+
ownerAlias: owner.alias,
|
|
1965
|
+
workerAlias: worker.alias,
|
|
1966
|
+
ownerLease: owner.binding.ownerLease,
|
|
1967
|
+
workerLease: worker.binding.ownerLease,
|
|
1968
|
+
idleMs: input.idleMs,
|
|
1969
|
+
at: now.getTime(),
|
|
1970
|
+
});
|
|
1971
|
+
state.progressWatches.push(watch);
|
|
1972
|
+
this.appendProgressWatchEvent(state, watch, {
|
|
1973
|
+
kind: "opened",
|
|
1974
|
+
timestamp: now.toISOString(),
|
|
1975
|
+
});
|
|
1976
|
+
return { created: true, watch: { ...watch } };
|
|
1977
|
+
});
|
|
1978
|
+
}
|
|
1979
|
+
async inspectProgressWatches() {
|
|
1980
|
+
return this.mutex.run("gateway", async () => this.requireState().progressWatches.map((watch) => ({ ...watch })));
|
|
1981
|
+
}
|
|
1982
|
+
async inspectCompatibilityAttestation(surface, version) {
|
|
1983
|
+
return this.mutex.run("gateway", async () => {
|
|
1984
|
+
const attestation = this.requireState().compatibilityAttestations.find((candidate) => candidate.surface === surface && candidate.version === version);
|
|
1985
|
+
return attestation === undefined
|
|
1986
|
+
? undefined
|
|
1987
|
+
: structuredClone(attestation);
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
async inspectCompatibilityAttestations() {
|
|
1991
|
+
return this.mutex.run("gateway", async () => this.requireState().compatibilityAttestations
|
|
1992
|
+
.map((attestation) => structuredClone(attestation))
|
|
1993
|
+
.sort((left, right) => compatibilityCacheKey(left).localeCompare(compatibilityCacheKey(right))));
|
|
1994
|
+
}
|
|
1995
|
+
async recordCompatibilityAttestation(attestation) {
|
|
1996
|
+
if (!isCompatibilityAttestation(attestation)) {
|
|
1997
|
+
throw new BridgeError("COMPAT_ATTESTATION_INVALID", "Compatibility evidence failed strict schema validation.");
|
|
1998
|
+
}
|
|
1999
|
+
await this.mutate(async (state) => {
|
|
2000
|
+
const key = compatibilityCacheKey(attestation);
|
|
2001
|
+
const index = state.compatibilityAttestations.findIndex((candidate) => compatibilityCacheKey(candidate) === key);
|
|
2002
|
+
if (index >= 0) {
|
|
2003
|
+
state.compatibilityAttestations[index] = structuredClone(attestation);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (state.compatibilityAttestations.length >=
|
|
2007
|
+
COMPATIBILITY_ATTESTATION_CAPACITY) {
|
|
2008
|
+
state.compatibilityAttestations.sort((left, right) => left.checkedAt.localeCompare(right.checkedAt));
|
|
2009
|
+
state.compatibilityAttestations.shift();
|
|
2010
|
+
}
|
|
2011
|
+
state.compatibilityAttestations.push(structuredClone(attestation));
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
async touchProgressWatch(input) {
|
|
2015
|
+
return this.mutate(async (state, now) => {
|
|
2016
|
+
const index = state.progressWatches.findIndex((watch) => watch.conversationId === input.conversationId);
|
|
2017
|
+
const watch = state.progressWatches[index];
|
|
2018
|
+
if (watch === undefined)
|
|
2019
|
+
return false;
|
|
2020
|
+
if (input.actorAlias !== watch.ownerAlias &&
|
|
2021
|
+
input.actorAlias !== watch.workerAlias) {
|
|
2022
|
+
throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The watch activity did not come from an endpoint on its exact edge.");
|
|
2023
|
+
}
|
|
2024
|
+
const workerReportedComplete = input.workerReportedComplete === true &&
|
|
2025
|
+
input.actorAlias === watch.workerAlias;
|
|
2026
|
+
const transition = transitionProgressWatch(watch, {
|
|
2027
|
+
type: "activity",
|
|
2028
|
+
at: now.getTime(),
|
|
2029
|
+
...(workerReportedComplete
|
|
2030
|
+
? { workerReportedComplete: true }
|
|
2031
|
+
: {}),
|
|
2032
|
+
});
|
|
2033
|
+
if (transition.state === null)
|
|
2034
|
+
return false;
|
|
2035
|
+
state.progressWatches[index] = transition.state;
|
|
2036
|
+
if (workerReportedComplete) {
|
|
2037
|
+
this.appendProgressWatchEvent(state, transition.state, {
|
|
2038
|
+
kind: "worker_reported_complete",
|
|
2039
|
+
timestamp: now.toISOString(),
|
|
2040
|
+
});
|
|
2041
|
+
}
|
|
2042
|
+
return true;
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
async touchProgressWatchesForAlias(alias) {
|
|
2046
|
+
return this.mutate(async (state, now) => {
|
|
2047
|
+
if (!ALIAS_PATTERN.test(alias))
|
|
2048
|
+
return 0;
|
|
2049
|
+
let changed = 0;
|
|
2050
|
+
for (let index = 0; index < state.progressWatches.length; index += 1) {
|
|
2051
|
+
const watch = state.progressWatches[index];
|
|
2052
|
+
if (watch === undefined ||
|
|
2053
|
+
(watch.ownerAlias !== alias && watch.workerAlias !== alias)) {
|
|
2054
|
+
continue;
|
|
2055
|
+
}
|
|
2056
|
+
const transition = transitionProgressWatch(watch, {
|
|
2057
|
+
type: "route_activity",
|
|
2058
|
+
at: now.getTime(),
|
|
2059
|
+
});
|
|
2060
|
+
if (transition.state !== null) {
|
|
2061
|
+
state.progressWatches[index] = transition.state;
|
|
2062
|
+
changed += 1;
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
return changed;
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
async settleProgressWatch(input) {
|
|
2069
|
+
return this.mutate(async (state, now) => {
|
|
2070
|
+
const index = state.progressWatches.findIndex((watch) => watch.conversationId === input.conversationId);
|
|
2071
|
+
const watch = state.progressWatches[index];
|
|
2072
|
+
if (watch === undefined)
|
|
2073
|
+
return false;
|
|
2074
|
+
if (input.ownerAlias !== undefined &&
|
|
2075
|
+
input.ownerAlias !== watch.ownerAlias) {
|
|
2076
|
+
throw new BridgeError("PROGRESS_WATCH_OWNER_REQUIRED", "Only the exact watch owner may end this watch.");
|
|
2077
|
+
}
|
|
2078
|
+
const event = input.outcome === "done"
|
|
2079
|
+
? { type: "owner_done", at: now.getTime() }
|
|
2080
|
+
: input.outcome === "endpoint_retired"
|
|
2081
|
+
? { type: "endpoint_retired", at: now.getTime() }
|
|
2082
|
+
: input.outcome === "disabled"
|
|
2083
|
+
? { type: "disabled", at: now.getTime() }
|
|
2084
|
+
: undefined;
|
|
2085
|
+
if (event === undefined) {
|
|
2086
|
+
throw new BridgeError("INVALID_PROGRESS_WATCH_SETTLEMENT", "Unresponsive settlement is owned only by the watch deadline machine.");
|
|
2087
|
+
}
|
|
2088
|
+
const transition = transitionProgressWatch(watch, event);
|
|
2089
|
+
state.progressWatches.splice(index, 1);
|
|
2090
|
+
this.appendProgressWatchEvent(state, watch, {
|
|
2091
|
+
kind: input.outcome,
|
|
2092
|
+
timestamp: now.toISOString(),
|
|
2093
|
+
});
|
|
2094
|
+
return transition.state === null;
|
|
2095
|
+
});
|
|
2096
|
+
}
|
|
2097
|
+
/** Advance every due watch once under one durable store mutation. */
|
|
2098
|
+
async advanceDueProgressWatches() {
|
|
2099
|
+
return this.mutate(async (state, now) => {
|
|
2100
|
+
const actions = [];
|
|
2101
|
+
const retained = [];
|
|
2102
|
+
for (const watch of state.progressWatches) {
|
|
2103
|
+
if (Date.parse(watch.nextActionAt) > now.getTime()) {
|
|
2104
|
+
retained.push(watch);
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
const transition = this.progressWatchDueTransition(state, watch, now);
|
|
2108
|
+
const nudge = transition.effects.find((effect) => effect.type === "send_nudge");
|
|
2109
|
+
// A nudge is only reserved here. Its state transition and journal row
|
|
2110
|
+
// commit atomically with the ordinary queued body.
|
|
2111
|
+
if (nudge !== undefined)
|
|
2112
|
+
retained.push(watch);
|
|
2113
|
+
else if (transition.state !== null)
|
|
2114
|
+
retained.push(transition.state);
|
|
2115
|
+
for (const effect of transition.effects) {
|
|
2116
|
+
if (effect.type === "send_nudge") {
|
|
2117
|
+
actions.push({
|
|
2118
|
+
type: "send_nudge",
|
|
2119
|
+
conversationId: watch.conversationId,
|
|
2120
|
+
ownerAlias: watch.ownerAlias,
|
|
2121
|
+
workerAlias: watch.workerAlias,
|
|
2122
|
+
nudgeNumber: effect.nudgeNumber,
|
|
2123
|
+
});
|
|
2124
|
+
}
|
|
2125
|
+
else if (effect.type === "settled") {
|
|
2126
|
+
this.appendProgressWatchEvent(state, watch, {
|
|
2127
|
+
kind: effect.outcome,
|
|
2128
|
+
timestamp: now.toISOString(),
|
|
2129
|
+
});
|
|
2130
|
+
actions.push({
|
|
2131
|
+
type: "settled",
|
|
2132
|
+
conversationId: watch.conversationId,
|
|
2133
|
+
ownerAlias: watch.ownerAlias,
|
|
2134
|
+
workerAlias: watch.workerAlias,
|
|
2135
|
+
outcome: effect.outcome,
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
state.progressWatches = retained;
|
|
2141
|
+
return actions;
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
async nextProgressWatchActionAt() {
|
|
2145
|
+
return this.mutex.run("gateway", async () => this.requireState().progressWatches.reduce((earliest, watch) => earliest === undefined || watch.nextActionAt < earliest
|
|
2146
|
+
? watch.nextActionAt
|
|
2147
|
+
: earliest, undefined));
|
|
2148
|
+
}
|
|
2149
|
+
async deferProgressWatchNudge(conversationId, retryDelayMs = 1_000) {
|
|
2150
|
+
return this.mutate(async (state, now) => {
|
|
2151
|
+
const index = state.progressWatches.findIndex((watch) => watch.conversationId === conversationId);
|
|
2152
|
+
const watch = state.progressWatches[index];
|
|
2153
|
+
if (watch === undefined)
|
|
2154
|
+
return false;
|
|
2155
|
+
const boundedDelay = Math.max(1, Math.min(retryDelayMs, watch.idleMs, 60_000));
|
|
2156
|
+
const transition = transitionProgressWatch(watch, {
|
|
2157
|
+
type: "nudge_deferred",
|
|
2158
|
+
at: now.getTime(),
|
|
2159
|
+
retryAt: now.getTime() + boundedDelay,
|
|
2160
|
+
});
|
|
2161
|
+
if (transition.state === null)
|
|
2162
|
+
return false;
|
|
2163
|
+
state.progressWatches[index] = transition.state;
|
|
2164
|
+
return true;
|
|
2165
|
+
});
|
|
2166
|
+
}
|
|
2167
|
+
async hasPair(input) {
|
|
2168
|
+
return this.mutex.run("gateway", async () => {
|
|
2169
|
+
const state = this.requireState();
|
|
2170
|
+
try {
|
|
2171
|
+
const { claude, codex } = this.requirePairRoutes(state, input, false);
|
|
2172
|
+
return state.pairs.some((pair) => pair.claudeAlias === claude.alias &&
|
|
2173
|
+
pair.codexAlias === codex.alias &&
|
|
2174
|
+
pair.claudeOwnerLease === claude.binding.ownerLease &&
|
|
2175
|
+
pair.codexOwnerLease === codex.binding.ownerLease);
|
|
2176
|
+
}
|
|
2177
|
+
catch (error) {
|
|
2178
|
+
if (error instanceof BridgeError)
|
|
2179
|
+
return false;
|
|
2180
|
+
throw error;
|
|
2181
|
+
}
|
|
2182
|
+
});
|
|
2183
|
+
}
|
|
2184
|
+
async inspectAffectedPairInFlightMessages(input) {
|
|
2185
|
+
return this.mutex.run("gateway", async () => {
|
|
2186
|
+
const state = this.requireState();
|
|
2187
|
+
const { claude, codex } = this.requirePairRoutes(state, input, false);
|
|
2188
|
+
this.requireExactPair(state, claude, codex);
|
|
2189
|
+
return state.inFlight
|
|
2190
|
+
.filter((item) => pairMatchesMessage(input, item))
|
|
2191
|
+
.map(({ messageId, deadlineAt }) => ({ messageId, deadlineAt }));
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* Remove exactly one consent edge and settle only work owned by that edge.
|
|
2196
|
+
* Adjacent pairs, terminal token metadata, and unrelated messages survive.
|
|
2197
|
+
*/
|
|
2198
|
+
async unpairRoutes(input) {
|
|
2199
|
+
return this.mutate(async (state, now) => {
|
|
2200
|
+
const { claude, codex } = this.requirePairRoutes(state, input, false);
|
|
2201
|
+
const pair = this.requireExactPair(state, claude, codex);
|
|
2202
|
+
const plan = this.validateAffectedPairInFlightSettlements(state, pair, input.inFlightSettlements ?? []);
|
|
2203
|
+
const settlements = this.terminateAffectedPairMessages(state, pair, now, "PAIR_REMOVED", plan);
|
|
2204
|
+
this.settleProgressWatchesForPair(state, pair, now);
|
|
2205
|
+
state.pairs = state.pairs.filter((candidate) => candidate !== pair);
|
|
2206
|
+
state.dedupe = state.dedupe.filter((record) => !pairMatchesMessage(pair, record));
|
|
2207
|
+
return {
|
|
2208
|
+
settlements,
|
|
2209
|
+
claudeRouteUnreferenced: !state.pairs.some((candidate) => candidate.claudeAlias === pair.claudeAlias),
|
|
2210
|
+
};
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
/**
|
|
2214
|
+
* Controller-internal inventory used to prepare an exact, evidence-aware
|
|
2215
|
+
* terminal plan before a route mutation. Message bodies and route handles
|
|
2216
|
+
* never cross this boundary. The route mutation validates the returned set
|
|
2217
|
+
* again so a stale or incomplete plan cannot partially settle the ledger.
|
|
2218
|
+
*/
|
|
2219
|
+
async inspectAffectedInFlightMessages(aliases) {
|
|
2220
|
+
if (aliases.length < 1 ||
|
|
2221
|
+
aliases.length > this.config.limits.maxRoutes ||
|
|
2222
|
+
aliases.some((alias) => !ALIAS_PATTERN.test(alias))) {
|
|
2223
|
+
throw new BridgeError("INVALID_ROUTE_TERMINATION_SCOPE", "Affected in-flight inspection requires a bounded list of valid route aliases.");
|
|
2224
|
+
}
|
|
2225
|
+
const scope = new Set(aliases);
|
|
2226
|
+
return this.mutex.run("gateway", async () => {
|
|
2227
|
+
const state = this.requireState();
|
|
2228
|
+
return state.inFlight
|
|
2229
|
+
.filter((item) => routeTerminationMatches(state, scope, item))
|
|
2230
|
+
.map(({ messageId, deadlineAt }) => ({ messageId, deadlineAt }));
|
|
2231
|
+
});
|
|
2232
|
+
}
|
|
2233
|
+
async enqueueMessage(input) {
|
|
2234
|
+
return this.mutate(async (state, now) => {
|
|
2235
|
+
const source = this.requireAvailableRoute(state, input.sourceAlias);
|
|
2236
|
+
const target = this.requireAvailableRoute(state, input.targetAlias);
|
|
2237
|
+
const sides = {
|
|
2238
|
+
sourceAlias: source.alias,
|
|
2239
|
+
targetAlias: target.alias,
|
|
2240
|
+
direction: directionFor(source, target),
|
|
2241
|
+
sourceRoute: source,
|
|
2242
|
+
targetRoute: target,
|
|
2243
|
+
};
|
|
2244
|
+
try {
|
|
2245
|
+
const pairAliases = pairAliasesForRoutes(source, target);
|
|
2246
|
+
this.requireExactPair(state, pairAliases.claudeAlias === source.alias ? source : target, pairAliases.codexAlias === source.alias ? source : target);
|
|
2247
|
+
sides.pair = true;
|
|
2248
|
+
}
|
|
2249
|
+
catch (error) {
|
|
2250
|
+
if (error instanceof BridgeError && error.code === "SENDER_NOT_PAIRED") {
|
|
2251
|
+
this.recordRejection(state, sides, typeof input.body === "string"
|
|
2252
|
+
? Math.max(1, Buffer.byteLength(input.body, "utf8"))
|
|
2253
|
+
: 1, now, "SENDER_NOT_PAIRED", input.steer, input.conversationIdSuffix);
|
|
2254
|
+
}
|
|
2255
|
+
throw error;
|
|
2256
|
+
}
|
|
2257
|
+
return this.enqueueResolvedMessage(state, now, input, sides);
|
|
2258
|
+
});
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Accept one message from a caller-attested native Claude peer without
|
|
2262
|
+
* registering or persisting that peer as a route. The exact private binding
|
|
2263
|
+
* is used only to prove a live same-host connector generation for this call.
|
|
2264
|
+
*/
|
|
2265
|
+
async enqueueNativeIngress(input) {
|
|
2266
|
+
return this.mutate(async (state, now) => {
|
|
2267
|
+
const target = this.requireAvailableRoute(state, input.targetAlias);
|
|
2268
|
+
if (target.binding.provider !== "codex") {
|
|
2269
|
+
throw new BridgeError("INVALID_NATIVE_INGRESS_TARGET", "Native Claude ingress requires an explicitly registered Codex target.");
|
|
2270
|
+
}
|
|
2271
|
+
this.validateTransientNativeClaudePeer(state, input.source, target);
|
|
2272
|
+
const selectedClaude = state.routes.find((route) => route.alias === input.source.alias &&
|
|
2273
|
+
route.binding.provider === "claude" &&
|
|
2274
|
+
route.registrationMode === "selected_live_peer" &&
|
|
2275
|
+
route.enabled &&
|
|
2276
|
+
route.compatibility === "compatible" &&
|
|
2277
|
+
["idle", "busy", "awaiting_approval"].includes(route.state) &&
|
|
2278
|
+
sameBinding(route.binding, input.source.binding));
|
|
2279
|
+
const pair = selectedClaude === undefined
|
|
2280
|
+
? undefined
|
|
2281
|
+
: state.pairs.find((candidate) => candidate.claudeAlias === selectedClaude.alias &&
|
|
2282
|
+
candidate.codexAlias === target.alias &&
|
|
2283
|
+
candidate.claudeOwnerLease ===
|
|
2284
|
+
selectedClaude.binding.ownerLease &&
|
|
2285
|
+
candidate.codexOwnerLease === target.binding.ownerLease);
|
|
2286
|
+
if (input.authorizedPairTeardownReply === true &&
|
|
2287
|
+
selectedClaude === undefined) {
|
|
2288
|
+
throw new BridgeError("SENDER_NOT_PAIRED", "The retained reply no longer matches the exact selected Claude route.");
|
|
2289
|
+
}
|
|
2290
|
+
if (this.config.inboundMode === "paired" &&
|
|
2291
|
+
pair === undefined &&
|
|
2292
|
+
input.authorizedPairTeardownReply !== true) {
|
|
2293
|
+
const bytes = typeof input.body === "string"
|
|
2294
|
+
? Math.max(1, Buffer.byteLength(input.body, "utf8"))
|
|
2295
|
+
: 1;
|
|
2296
|
+
this.recordRejection(state, {
|
|
2297
|
+
sourceAlias: input.source.alias,
|
|
2298
|
+
targetAlias: target.alias,
|
|
2299
|
+
direction: "claude_to_codex",
|
|
2300
|
+
targetRoute: target,
|
|
2301
|
+
}, bytes, now, "SENDER_NOT_PAIRED", input.steer, input.conversationIdSuffix);
|
|
2302
|
+
throw new BridgeError("SENDER_NOT_PAIRED", "The native Claude sender is not the exact session paired with this Codex task.");
|
|
2303
|
+
}
|
|
2304
|
+
return this.enqueueResolvedMessage(state, now, input, {
|
|
2305
|
+
sourceAlias: input.source.alias,
|
|
2306
|
+
targetAlias: target.alias,
|
|
2307
|
+
direction: "claude_to_codex",
|
|
2308
|
+
targetRoute: target,
|
|
2309
|
+
...(pair === undefined ? {} : { pair: true }),
|
|
2310
|
+
});
|
|
2311
|
+
});
|
|
2312
|
+
}
|
|
2313
|
+
/**
|
|
2314
|
+
* Queue a correlated Codex reply for a caller-attested transient Claude
|
|
2315
|
+
* peer. The service owns conversation correlation and the live dispatch
|
|
2316
|
+
* capability; the store retains only bounded public-alias metadata.
|
|
2317
|
+
*/
|
|
2318
|
+
async enqueueNativeReply(input) {
|
|
2319
|
+
return this.mutate(async (state, now) => {
|
|
2320
|
+
const source = this.requireAvailableRoute(state, input.sourceAlias);
|
|
2321
|
+
if (source.binding.provider !== "codex") {
|
|
2322
|
+
throw new BridgeError("INVALID_NATIVE_REPLY_SOURCE", "A native Claude reply requires an explicitly registered Codex source.");
|
|
2323
|
+
}
|
|
2324
|
+
this.validateTransientNativeClaudePeer(state, input.target, source);
|
|
2325
|
+
if (input.pair === true) {
|
|
2326
|
+
const target = state.routes.find((route) => route.alias === input.target.alias &&
|
|
2327
|
+
route.binding.provider === "claude" &&
|
|
2328
|
+
sameBinding(route.binding, input.target.binding));
|
|
2329
|
+
if (target === undefined) {
|
|
2330
|
+
throw new BridgeError("SENDER_NOT_PAIRED", "The correlated reply no longer has its exact paired Claude route.");
|
|
2331
|
+
}
|
|
2332
|
+
this.requireExactPair(state, target, source);
|
|
2333
|
+
}
|
|
2334
|
+
return this.enqueueResolvedMessage(state, now, input, {
|
|
2335
|
+
sourceAlias: source.alias,
|
|
2336
|
+
targetAlias: input.target.alias,
|
|
2337
|
+
direction: "codex_to_claude",
|
|
2338
|
+
sourceRoute: source,
|
|
2339
|
+
...(input.pair === true ? { pair: true } : {}),
|
|
2340
|
+
});
|
|
2341
|
+
});
|
|
2342
|
+
}
|
|
2343
|
+
async dequeueMessage(targetAlias, mode = "any") {
|
|
2344
|
+
return this.mutate(async (state, now) => {
|
|
2345
|
+
if (targetAlias !== undefined && !ALIAS_PATTERN.test(targetAlias)) {
|
|
2346
|
+
throw new BridgeError("INVALID_GATEWAY_ALIAS", "The target alias does not use the required lowercase ASCII grammar.");
|
|
2347
|
+
}
|
|
2348
|
+
if (mode !== "any" && mode !== "steer_only") {
|
|
2349
|
+
throw new BridgeError("INVALID_GATEWAY_DISPATCH_MODE", "The gateway dispatch selector is not recognized.");
|
|
2350
|
+
}
|
|
2351
|
+
const index = state.queue.findIndex((item) => (targetAlias === undefined || item.targetAlias === targetAlias) &&
|
|
2352
|
+
(mode === "any" || item.steer === true));
|
|
2353
|
+
if (index < 0)
|
|
2354
|
+
return undefined;
|
|
2355
|
+
if (state.inFlight.length >= this.config.limits.maxInFlightMessages) {
|
|
2356
|
+
throw new BridgeError("GATEWAY_IN_FLIGHT_FULL", "The bounded gateway dispatch set is at capacity.", true);
|
|
2357
|
+
}
|
|
2358
|
+
const metadata = state.queue[index];
|
|
2359
|
+
if (!metadata)
|
|
2360
|
+
return undefined;
|
|
2361
|
+
state.queue.splice(index, 1);
|
|
2362
|
+
state.accounting.queuedBytes -= metadata.bytes;
|
|
2363
|
+
const target = state.routes.find((route) => route.alias === metadata.targetAlias);
|
|
2364
|
+
if (target)
|
|
2365
|
+
target.queueDepth = Math.max(0, target.queueDepth - 1);
|
|
2366
|
+
const body = this.transientBodies.get(metadata.messageId);
|
|
2367
|
+
this.transientBodies.delete(metadata.messageId);
|
|
2368
|
+
if (body === undefined) {
|
|
2369
|
+
this.finishMetadata(state, metadata, "abandoned", now, "TRANSIENT_BODY_UNAVAILABLE");
|
|
2370
|
+
return undefined;
|
|
2371
|
+
}
|
|
2372
|
+
state.inFlight.push({ ...metadata, dispatchedAt: now.toISOString() });
|
|
2373
|
+
this.appendEvent(state, {
|
|
2374
|
+
timestamp: now.toISOString(),
|
|
2375
|
+
messageIdSuffix: metadata.messageIdSuffix,
|
|
2376
|
+
...(metadata.conversationIdSuffix === undefined
|
|
2377
|
+
? {}
|
|
2378
|
+
: { conversationIdSuffix: metadata.conversationIdSuffix }),
|
|
2379
|
+
direction: metadata.direction,
|
|
2380
|
+
sourceAlias: metadata.sourceAlias,
|
|
2381
|
+
targetAlias: metadata.targetAlias,
|
|
2382
|
+
state: "dispatching",
|
|
2383
|
+
bytes: metadata.bytes,
|
|
2384
|
+
hopCount: metadata.hopCount,
|
|
2385
|
+
...(metadata.steer === true ? { steer: true } : {}),
|
|
2386
|
+
latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
|
|
2387
|
+
});
|
|
2388
|
+
return { ...metadata, body };
|
|
2389
|
+
});
|
|
2390
|
+
}
|
|
2391
|
+
/**
|
|
2392
|
+
* Atomically terminalizes every message whose delivery deadline is due.
|
|
2393
|
+
* Returned settlements are emitted only by the mutation that removed the
|
|
2394
|
+
* message, so callers can release service-owned capabilities exactly once.
|
|
2395
|
+
*
|
|
2396
|
+
* @deprecated GatewayService must arbitrate deadlines through the delivery
|
|
2397
|
+
* reducer because only the service owns transport-write evidence. Retained
|
|
2398
|
+
* temporarily as a store-level recovery/test primitive; production service
|
|
2399
|
+
* code must not call it.
|
|
2400
|
+
*/
|
|
2401
|
+
async expireDueMessages(now) {
|
|
2402
|
+
const requestedTime = now instanceof Date ? now.getTime() : undefined;
|
|
2403
|
+
if (now !== undefined &&
|
|
2404
|
+
(!(now instanceof Date) || !Number.isFinite(requestedTime))) {
|
|
2405
|
+
throw new BridgeError("INVALID_GATEWAY_TIMESTAMP", "The gateway expiry time must be a valid Date.");
|
|
2406
|
+
}
|
|
2407
|
+
return this.mutate(async (state, mutationNow) => {
|
|
2408
|
+
const effectiveNow = requestedTime === undefined ? mutationNow : new Date(requestedTime);
|
|
2409
|
+
const settlements = [];
|
|
2410
|
+
const retainedQueue = [];
|
|
2411
|
+
for (const item of state.queue) {
|
|
2412
|
+
if (Date.parse(item.deadlineAt) > effectiveNow.getTime()) {
|
|
2413
|
+
retainedQueue.push(item);
|
|
2414
|
+
continue;
|
|
2415
|
+
}
|
|
2416
|
+
this.transientBodies.delete(item.messageId);
|
|
2417
|
+
state.accounting.queuedBytes -= item.bytes;
|
|
2418
|
+
const target = state.routes.find((route) => route.alias === item.targetAlias);
|
|
2419
|
+
if (target)
|
|
2420
|
+
target.queueDepth = Math.max(0, target.queueDepth - 1);
|
|
2421
|
+
settlements.push(this.finishMetadata(state, item, "expired", effectiveNow, "MESSAGE_EXPIRED"));
|
|
2422
|
+
}
|
|
2423
|
+
state.queue = retainedQueue;
|
|
2424
|
+
const retainedInFlight = [];
|
|
2425
|
+
for (const item of state.inFlight) {
|
|
2426
|
+
if (Date.parse(item.deadlineAt) > effectiveNow.getTime()) {
|
|
2427
|
+
retainedInFlight.push(item);
|
|
2428
|
+
continue;
|
|
2429
|
+
}
|
|
2430
|
+
settlements.push(this.finishMetadata(state, item, "ambiguous", effectiveNow, "DELIVERY_DEADLINE_EXPIRED"));
|
|
2431
|
+
}
|
|
2432
|
+
state.inFlight = retainedInFlight;
|
|
2433
|
+
return settlements;
|
|
2434
|
+
});
|
|
2435
|
+
}
|
|
2436
|
+
async settleMessage(input) {
|
|
2437
|
+
return this.mutate(async (state, now) => {
|
|
2438
|
+
if (!MESSAGE_ID_PATTERN.test(input.messageId)) {
|
|
2439
|
+
throw new BridgeError("INVALID_GATEWAY_MESSAGE_ID", "The gateway message identifier is invalid.");
|
|
2440
|
+
}
|
|
2441
|
+
if (input.safeErrorCode !== undefined &&
|
|
2442
|
+
!SAFE_CODE_PATTERN.test(input.safeErrorCode)) {
|
|
2443
|
+
throw new BridgeError("INVALID_SAFE_ERROR_CODE", "Delivery error codes must use the normalized safe-code grammar.");
|
|
2444
|
+
}
|
|
2445
|
+
if (input.state !== "delivered" &&
|
|
2446
|
+
input.state !== "unconfirmed" &&
|
|
2447
|
+
input.state !== "failed" &&
|
|
2448
|
+
input.state !== "ambiguous" &&
|
|
2449
|
+
input.state !== "expired" &&
|
|
2450
|
+
input.state !== "cancelled") {
|
|
2451
|
+
throw new BridgeError("INVALID_DELIVERY_SETTLEMENT", "Gateway delivery settlement must use a fixed terminal state.");
|
|
2452
|
+
}
|
|
2453
|
+
const index = state.inFlight.findIndex((item) => item.messageId === input.messageId);
|
|
2454
|
+
const metadata = state.inFlight[index];
|
|
2455
|
+
if (!metadata || index < 0) {
|
|
2456
|
+
return { status: "not_in_flight" };
|
|
2457
|
+
}
|
|
2458
|
+
state.inFlight.splice(index, 1);
|
|
2459
|
+
return {
|
|
2460
|
+
status: "settled",
|
|
2461
|
+
settlement: this.finishMetadata(state, metadata, input.state, now, input.safeErrorCode),
|
|
2462
|
+
};
|
|
2463
|
+
});
|
|
2464
|
+
}
|
|
2465
|
+
async requeueInFlightMessage(messageId, body) {
|
|
2466
|
+
return this.mutate(async (state, now) => {
|
|
2467
|
+
if (!MESSAGE_ID_PATTERN.test(messageId) || typeof body !== "string") {
|
|
2468
|
+
throw new BridgeError("INVALID_GATEWAY_MESSAGE", "Only an exact in-flight message can be returned to the queue.");
|
|
2469
|
+
}
|
|
2470
|
+
const index = state.inFlight.findIndex((item) => item.messageId === messageId);
|
|
2471
|
+
const metadata = state.inFlight[index];
|
|
2472
|
+
if (metadata === undefined || index < 0) {
|
|
2473
|
+
return { status: "not_in_flight" };
|
|
2474
|
+
}
|
|
2475
|
+
state.inFlight.splice(index, 1);
|
|
2476
|
+
if (Date.parse(metadata.deadlineAt) <= now.getTime()) {
|
|
2477
|
+
return {
|
|
2478
|
+
status: "settled",
|
|
2479
|
+
settlement: this.finishMetadata(state, metadata, "expired", now, "MESSAGE_EXPIRED"),
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
const { dispatchedAt: _dispatchedAt, ...queuedMetadata } = metadata;
|
|
2483
|
+
state.queue.unshift(queuedMetadata);
|
|
2484
|
+
this.transientBodies.set(messageId, body);
|
|
2485
|
+
state.accounting.queuedBytes += metadata.bytes;
|
|
2486
|
+
const target = state.routes.find((route) => route.alias === metadata.targetAlias);
|
|
2487
|
+
if (target !== undefined)
|
|
2488
|
+
target.queueDepth += 1;
|
|
2489
|
+
this.appendEvent(state, {
|
|
2490
|
+
timestamp: now.toISOString(),
|
|
2491
|
+
messageIdSuffix: metadata.messageIdSuffix,
|
|
2492
|
+
...(metadata.conversationIdSuffix === undefined
|
|
2493
|
+
? {}
|
|
2494
|
+
: { conversationIdSuffix: metadata.conversationIdSuffix }),
|
|
2495
|
+
direction: metadata.direction,
|
|
2496
|
+
sourceAlias: metadata.sourceAlias,
|
|
2497
|
+
targetAlias: metadata.targetAlias,
|
|
2498
|
+
state: "held",
|
|
2499
|
+
bytes: metadata.bytes,
|
|
2500
|
+
hopCount: metadata.hopCount,
|
|
2501
|
+
...(metadata.steer === true ? { steer: true } : {}),
|
|
2502
|
+
latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
|
|
2503
|
+
});
|
|
2504
|
+
return { status: "requeued" };
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
2507
|
+
async markMessageProgress(messageId, progress) {
|
|
2508
|
+
await this.mutate(async (state, now) => {
|
|
2509
|
+
if (!MESSAGE_ID_PATTERN.test(messageId)) {
|
|
2510
|
+
throw new BridgeError("INVALID_GATEWAY_MESSAGE_ID", "The gateway message identifier is invalid.");
|
|
2511
|
+
}
|
|
2512
|
+
if (progress !== "transport_written" && progress !== "held") {
|
|
2513
|
+
throw new BridgeError("INVALID_DELIVERY_PROGRESS", "Gateway delivery progress must use a fixed nonterminal state.");
|
|
2514
|
+
}
|
|
2515
|
+
const metadata = state.inFlight.find((item) => item.messageId === messageId);
|
|
2516
|
+
if (!metadata) {
|
|
2517
|
+
throw new BridgeError("MESSAGE_NOT_IN_FLIGHT", "The gateway message is not owned by an active dispatch.");
|
|
2518
|
+
}
|
|
2519
|
+
this.appendEvent(state, {
|
|
2520
|
+
timestamp: now.toISOString(),
|
|
2521
|
+
messageIdSuffix: metadata.messageIdSuffix,
|
|
2522
|
+
...(metadata.conversationIdSuffix === undefined
|
|
2523
|
+
? {}
|
|
2524
|
+
: { conversationIdSuffix: metadata.conversationIdSuffix }),
|
|
2525
|
+
direction: metadata.direction,
|
|
2526
|
+
sourceAlias: metadata.sourceAlias,
|
|
2527
|
+
targetAlias: metadata.targetAlias,
|
|
2528
|
+
state: progress,
|
|
2529
|
+
bytes: metadata.bytes,
|
|
2530
|
+
hopCount: metadata.hopCount,
|
|
2531
|
+
...(metadata.steer === true ? { steer: true } : {}),
|
|
2532
|
+
latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
|
|
2533
|
+
});
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
/**
|
|
2537
|
+
* Atomically terminalize one queued message and return the authoritative
|
|
2538
|
+
* settlement produced by the winning mutation. A later contender observes
|
|
2539
|
+
* `not_queued` and must not infer or publish another terminal outcome. At
|
|
2540
|
+
* or beyond the delivery deadline, expiry is authoritative regardless of
|
|
2541
|
+
* the caller's requested terminal state.
|
|
2542
|
+
*/
|
|
2543
|
+
async settleQueuedMessage(input) {
|
|
2544
|
+
return this.mutate(async (state, now) => {
|
|
2545
|
+
if (!MESSAGE_ID_PATTERN.test(input.messageId)) {
|
|
2546
|
+
throw new BridgeError("INVALID_GATEWAY_MESSAGE_ID", "The gateway message identifier is invalid.");
|
|
2547
|
+
}
|
|
2548
|
+
if (input.safeErrorCode !== undefined &&
|
|
2549
|
+
!SAFE_CODE_PATTERN.test(input.safeErrorCode)) {
|
|
2550
|
+
throw new BridgeError("INVALID_SAFE_ERROR_CODE", "Delivery error codes must use the normalized safe-code grammar.");
|
|
2551
|
+
}
|
|
2552
|
+
if (input.state !== "failed" &&
|
|
2553
|
+
input.state !== "expired" &&
|
|
2554
|
+
input.state !== "cancelled" &&
|
|
2555
|
+
input.state !== "abandoned") {
|
|
2556
|
+
throw new BridgeError("INVALID_DELIVERY_SETTLEMENT", "Queued delivery settlement must use an applicable terminal state.");
|
|
2557
|
+
}
|
|
2558
|
+
const index = state.queue.findIndex((item) => item.messageId === input.messageId);
|
|
2559
|
+
const metadata = state.queue[index];
|
|
2560
|
+
if (!metadata || index < 0)
|
|
2561
|
+
return { status: "not_queued" };
|
|
2562
|
+
state.queue.splice(index, 1);
|
|
2563
|
+
this.transientBodies.delete(input.messageId);
|
|
2564
|
+
state.accounting.queuedBytes -= metadata.bytes;
|
|
2565
|
+
const target = state.routes.find((route) => route.alias === metadata.targetAlias);
|
|
2566
|
+
if (target)
|
|
2567
|
+
target.queueDepth = Math.max(0, target.queueDepth - 1);
|
|
2568
|
+
const deadlineReached = Date.parse(metadata.deadlineAt) <= now.getTime();
|
|
2569
|
+
return {
|
|
2570
|
+
status: "settled",
|
|
2571
|
+
settlement: this.finishMetadata(state, metadata, deadlineReached ? "expired" : input.state, now, deadlineReached ? "MESSAGE_EXPIRED" : input.safeErrorCode),
|
|
2572
|
+
};
|
|
2573
|
+
});
|
|
2574
|
+
}
|
|
2575
|
+
/** @deprecated Prefer settleQueuedMessage so callers retain terminal proof. */
|
|
2576
|
+
async cancelQueuedMessage(messageId) {
|
|
2577
|
+
const result = await this.settleQueuedMessage({
|
|
2578
|
+
messageId,
|
|
2579
|
+
state: "cancelled",
|
|
2580
|
+
safeErrorCode: "MESSAGE_CANCELLED",
|
|
2581
|
+
});
|
|
2582
|
+
return result.status === "settled";
|
|
2583
|
+
}
|
|
2584
|
+
async publicSnapshot() {
|
|
2585
|
+
return this.mutate(async (state, now) => {
|
|
2586
|
+
const connectors = state.connectors
|
|
2587
|
+
.map((connector) => ({
|
|
2588
|
+
provider: connector.provider,
|
|
2589
|
+
host: connector.hostId,
|
|
2590
|
+
health: connector.health,
|
|
2591
|
+
compatibility: connector.compatibility,
|
|
2592
|
+
protocol: connector.protocol,
|
|
2593
|
+
protocolVersion: connector.protocolVersion,
|
|
2594
|
+
...(connector.lastSeenAt ? { lastSeenAt: connector.lastSeenAt } : {}),
|
|
2595
|
+
...(connector.safeErrorCode
|
|
2596
|
+
? { safeErrorCode: connector.safeErrorCode }
|
|
2597
|
+
: {}),
|
|
2598
|
+
}))
|
|
2599
|
+
.sort((left, right) => `${left.provider}:${left.host}`.localeCompare(`${right.provider}:${right.host}`));
|
|
2600
|
+
const oldestQueuedAtByTarget = new Map();
|
|
2601
|
+
for (const item of state.queue) {
|
|
2602
|
+
const oldest = oldestQueuedAtByTarget.get(item.targetAlias);
|
|
2603
|
+
if (oldest === undefined ||
|
|
2604
|
+
Date.parse(item.enqueuedAt) < Date.parse(oldest)) {
|
|
2605
|
+
oldestQueuedAtByTarget.set(item.targetAlias, item.enqueuedAt);
|
|
2606
|
+
}
|
|
2607
|
+
}
|
|
2608
|
+
const routes = state.routes
|
|
2609
|
+
.map((route) => {
|
|
2610
|
+
const oldestQueuedAt = oldestQueuedAtByTarget.get(route.alias);
|
|
2611
|
+
return {
|
|
2612
|
+
alias: route.alias,
|
|
2613
|
+
provider: route.binding.provider,
|
|
2614
|
+
host: route.binding.hostId,
|
|
2615
|
+
enabled: route.enabled,
|
|
2616
|
+
state: route.state,
|
|
2617
|
+
compatibility: route.compatibility,
|
|
2618
|
+
busyPolicy: route.busyPolicy,
|
|
2619
|
+
...(route.lastSeenAt ? { lastSeenAt: route.lastSeenAt } : {}),
|
|
2620
|
+
queueDepth: route.queueDepth,
|
|
2621
|
+
...(oldestQueuedAt === undefined ? {} : { oldestQueuedAt }),
|
|
2622
|
+
counters: { ...route.counters },
|
|
2623
|
+
...(route.safeErrorCode
|
|
2624
|
+
? { safeErrorCode: route.safeErrorCode }
|
|
2625
|
+
: {}),
|
|
2626
|
+
};
|
|
2627
|
+
})
|
|
2628
|
+
.sort((left, right) => left.alias.localeCompare(right.alias));
|
|
2629
|
+
const pairs = state.pairs
|
|
2630
|
+
.map((pair) => ({
|
|
2631
|
+
claudeAlias: pair.claudeAlias,
|
|
2632
|
+
codexAlias: pair.codexAlias,
|
|
2633
|
+
host: pair.claudeAlias.slice(pair.claudeAlias.lastIndexOf("@") + 1),
|
|
2634
|
+
counters: { ...pair.counters },
|
|
2635
|
+
}))
|
|
2636
|
+
.sort((left, right) => `${left.claudeAlias}\0${left.codexAlias}`.localeCompare(`${right.claudeAlias}\0${right.codexAlias}`));
|
|
2637
|
+
const progressWatches = state.progressWatches
|
|
2638
|
+
.map((watch) => ({
|
|
2639
|
+
conversationIdSuffix: watch.conversationId.slice(-8),
|
|
2640
|
+
ownerAlias: watch.ownerAlias,
|
|
2641
|
+
workerAlias: watch.workerAlias,
|
|
2642
|
+
phase: watch.phase,
|
|
2643
|
+
capability: watch.capability,
|
|
2644
|
+
lastActivityAt: watch.lastActivityAt,
|
|
2645
|
+
nextActionAt: watch.nextActionAt,
|
|
2646
|
+
idleMs: watch.idleMs,
|
|
2647
|
+
nudgeCount: watch.nudgeCount,
|
|
2648
|
+
workerReportedComplete: watch.workerReportedCompleteAt !== undefined,
|
|
2649
|
+
}))
|
|
2650
|
+
.sort((left, right) => `${left.nextActionAt}\0${left.ownerAlias}\0${left.workerAlias}`.localeCompare(`${right.nextActionAt}\0${right.ownerAlias}\0${right.workerAlias}`));
|
|
2651
|
+
const progressWatchEvents = state.progressWatchEvents
|
|
2652
|
+
.filter((event) => event.kind !== "activity")
|
|
2653
|
+
.map((event) => ({
|
|
2654
|
+
sequence: event.sequence,
|
|
2655
|
+
timestamp: event.timestamp,
|
|
2656
|
+
conversationIdSuffix: event.conversationId.slice(-8),
|
|
2657
|
+
ownerAlias: event.ownerAlias,
|
|
2658
|
+
workerAlias: event.workerAlias,
|
|
2659
|
+
kind: event.kind,
|
|
2660
|
+
...(event.nudgeNumber === undefined
|
|
2661
|
+
? {}
|
|
2662
|
+
: { nudgeNumber: event.nudgeNumber }),
|
|
2663
|
+
}));
|
|
2664
|
+
const pressureBuckets = [
|
|
2665
|
+
{ bucket: "under_1m", settled: 0, expired: 0 },
|
|
2666
|
+
{ bucket: "1m_to_5m", settled: 0, expired: 0 },
|
|
2667
|
+
{ bucket: "5m_to_15m", settled: 0, expired: 0 },
|
|
2668
|
+
{ bucket: "15m_to_60m", settled: 0, expired: 0 },
|
|
2669
|
+
{ bucket: "over_60m", settled: 0, expired: 0 },
|
|
2670
|
+
];
|
|
2671
|
+
const terminalEvidence = state.events.filter((event) => event.latencyMs !== undefined &&
|
|
2672
|
+
(event.state === "delivered" ||
|
|
2673
|
+
event.state === "unconfirmed" ||
|
|
2674
|
+
event.state === "failed" ||
|
|
2675
|
+
event.state === "ambiguous" ||
|
|
2676
|
+
event.state === "expired" ||
|
|
2677
|
+
event.state === "cancelled" ||
|
|
2678
|
+
event.state === "abandoned"));
|
|
2679
|
+
for (const event of terminalEvidence) {
|
|
2680
|
+
const latency = event.latencyMs ?? 0;
|
|
2681
|
+
const index = latency < 60_000
|
|
2682
|
+
? 0
|
|
2683
|
+
: latency < 300_000
|
|
2684
|
+
? 1
|
|
2685
|
+
: latency < 900_000
|
|
2686
|
+
? 2
|
|
2687
|
+
: latency < 3_600_000
|
|
2688
|
+
? 3
|
|
2689
|
+
: 4;
|
|
2690
|
+
const bucket = pressureBuckets[index];
|
|
2691
|
+
if (bucket === undefined)
|
|
2692
|
+
continue;
|
|
2693
|
+
bucket.settled += 1;
|
|
2694
|
+
if (event.state === "expired")
|
|
2695
|
+
bucket.expired += 1;
|
|
2696
|
+
}
|
|
2697
|
+
const health = this.aggregateHealth(connectors);
|
|
2698
|
+
const unsortedAlerts = [
|
|
2699
|
+
...connectors.flatMap((connector) => connector.safeErrorCode
|
|
2700
|
+
? [
|
|
2701
|
+
{
|
|
2702
|
+
code: connector.safeErrorCode,
|
|
2703
|
+
severity: connector.health === "offline" ? "error" : "warning",
|
|
2704
|
+
timestamp: connector.lastSeenAt ?? now.toISOString(),
|
|
2705
|
+
provider: connector.provider,
|
|
2706
|
+
host: connector.host,
|
|
2707
|
+
},
|
|
2708
|
+
]
|
|
2709
|
+
: []),
|
|
2710
|
+
...routes.flatMap((route) => route.safeErrorCode
|
|
2711
|
+
? [
|
|
2712
|
+
{
|
|
2713
|
+
code: route.safeErrorCode,
|
|
2714
|
+
severity: "warning",
|
|
2715
|
+
timestamp: route.lastSeenAt ?? now.toISOString(),
|
|
2716
|
+
provider: route.provider,
|
|
2717
|
+
host: route.host,
|
|
2718
|
+
alias: route.alias,
|
|
2719
|
+
},
|
|
2720
|
+
]
|
|
2721
|
+
: []),
|
|
2722
|
+
];
|
|
2723
|
+
const alerts = unsortedAlerts
|
|
2724
|
+
.sort((left, right) => {
|
|
2725
|
+
const byTime = left.timestamp.localeCompare(right.timestamp);
|
|
2726
|
+
if (byTime !== 0)
|
|
2727
|
+
return byTime;
|
|
2728
|
+
return `${left.code}\0${left.provider ?? ""}\0${left.host ?? ""}\0${left.alias ?? ""}`.localeCompare(`${right.code}\0${right.provider ?? ""}\0${right.host ?? ""}\0${right.alias ?? ""}`);
|
|
2729
|
+
});
|
|
2730
|
+
return projectGatewayPublicSnapshot({
|
|
2731
|
+
schemaVersion: 1,
|
|
2732
|
+
generatedAt: now.toISOString(),
|
|
2733
|
+
inboundMode: this.config.inboundMode,
|
|
2734
|
+
health,
|
|
2735
|
+
connectors,
|
|
2736
|
+
availablePeers: [],
|
|
2737
|
+
routes,
|
|
2738
|
+
pairs,
|
|
2739
|
+
progressWatches,
|
|
2740
|
+
progressWatchEvents,
|
|
2741
|
+
deadlinePressure: {
|
|
2742
|
+
configuredDeadlineMs: this.config.limits.messageDeadlineMs,
|
|
2743
|
+
...(state.events[0]?.timestamp === undefined
|
|
2744
|
+
? {}
|
|
2745
|
+
: { retainedSince: state.events[0].timestamp }),
|
|
2746
|
+
terminalEvents: terminalEvidence.length,
|
|
2747
|
+
expiredEvents: terminalEvidence.filter((event) => event.state === "expired").length,
|
|
2748
|
+
buckets: pressureBuckets,
|
|
2749
|
+
},
|
|
2750
|
+
messages: state.events.map((event) => ({ ...event })),
|
|
2751
|
+
accounting: { ...state.accounting },
|
|
2752
|
+
alerts,
|
|
2753
|
+
truncation: {
|
|
2754
|
+
connectors: 0,
|
|
2755
|
+
availablePeers: 0,
|
|
2756
|
+
routes: 0,
|
|
2757
|
+
pairs: 0,
|
|
2758
|
+
progressWatches: 0,
|
|
2759
|
+
progressWatchEvents: 0,
|
|
2760
|
+
messages: 0,
|
|
2761
|
+
alerts: 0,
|
|
2762
|
+
},
|
|
2763
|
+
});
|
|
2764
|
+
});
|
|
2765
|
+
}
|
|
2766
|
+
async mutate(operation) {
|
|
2767
|
+
return this.mutex.run("gateway", async () => {
|
|
2768
|
+
const state = this.requireState();
|
|
2769
|
+
const stateBefore = structuredClone(state);
|
|
2770
|
+
const bodiesBefore = new Map(this.transientBodies);
|
|
2771
|
+
const now = this.now();
|
|
2772
|
+
this.prune(now);
|
|
2773
|
+
let outcome;
|
|
2774
|
+
try {
|
|
2775
|
+
outcome = { ok: true, value: await operation(state, now) };
|
|
2776
|
+
}
|
|
2777
|
+
catch (error) {
|
|
2778
|
+
outcome = { ok: false, error };
|
|
2779
|
+
}
|
|
2780
|
+
state.updatedAt = now.toISOString();
|
|
2781
|
+
try {
|
|
2782
|
+
await this.persist();
|
|
2783
|
+
}
|
|
2784
|
+
catch (persistError) {
|
|
2785
|
+
if (persistError instanceof PostRenamePersistenceError) {
|
|
2786
|
+
// Rename is the commit point. `persist` reloaded and verified the
|
|
2787
|
+
// installed state, so restoring `stateBefore` would create two
|
|
2788
|
+
// conflicting authorities inside one controller.
|
|
2789
|
+
throw persistError;
|
|
2790
|
+
}
|
|
2791
|
+
// A failed durable commit must not leave a newer in-memory authority.
|
|
2792
|
+
this.state = stateBefore;
|
|
2793
|
+
this.transientBodies.clear();
|
|
2794
|
+
for (const [messageId, body] of bodiesBefore) {
|
|
2795
|
+
this.transientBodies.set(messageId, body);
|
|
2796
|
+
}
|
|
2797
|
+
throw persistError;
|
|
2798
|
+
}
|
|
2799
|
+
// Rejection accounting/events deliberately survive operation errors that
|
|
2800
|
+
// occur after both selected routes have been safely resolved.
|
|
2801
|
+
if (!outcome.ok)
|
|
2802
|
+
throw outcome.error;
|
|
2803
|
+
return outcome.value;
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
requireState() {
|
|
2807
|
+
if (!this.state || !this.lockHandle) {
|
|
2808
|
+
throw new BridgeError("GATEWAY_NOT_INITIALIZED", "The gateway store must hold its controller lock before use.");
|
|
2809
|
+
}
|
|
2810
|
+
return this.state;
|
|
2811
|
+
}
|
|
2812
|
+
journal(state) {
|
|
2813
|
+
if (state.codexSuccession === null)
|
|
2814
|
+
return null;
|
|
2815
|
+
if (!isCodexSuccessionJournal(state.codexSuccession)) {
|
|
2816
|
+
throw new BridgeError("CORRUPT_GATEWAY_STATE", "The in-memory Codex succession journal is invalid.");
|
|
2817
|
+
}
|
|
2818
|
+
return state.codexSuccession;
|
|
2819
|
+
}
|
|
2820
|
+
requireExactSuccession(state, input, allowedStages) {
|
|
2821
|
+
if (!isObject(input) ||
|
|
2822
|
+
!hasOnlyKeys(input, ["oldGeneration", "newGeneration"]) ||
|
|
2823
|
+
!isCodexRegistrationGeneration(input.oldGeneration) ||
|
|
2824
|
+
!isCodexRegistrationGeneration(input.newGeneration)) {
|
|
2825
|
+
throw new BridgeError("INVALID_CODEX_SUCCESSION", "The exact Codex succession generations are malformed.");
|
|
2826
|
+
}
|
|
2827
|
+
const journal = this.journal(state);
|
|
2828
|
+
if (journal === null) {
|
|
2829
|
+
throw new BridgeError("CODEX_SUCCESSION_NOT_FOUND", "No durable Codex succession journal exists.");
|
|
2830
|
+
}
|
|
2831
|
+
if (journal.old.generation !== input.oldGeneration ||
|
|
2832
|
+
journal.new.generation !== input.newGeneration ||
|
|
2833
|
+
!allowedStages.includes(journal.stage)) {
|
|
2834
|
+
throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The succession stage or exact generation ownership does not match.");
|
|
2835
|
+
}
|
|
2836
|
+
return journal;
|
|
2837
|
+
}
|
|
2838
|
+
assertSuccessionLedgerEmpty(state, alias) {
|
|
2839
|
+
const queued = state.queue.filter((item) => item.sourceAlias === alias || item.targetAlias === alias);
|
|
2840
|
+
const inFlight = state.inFlight.filter((item) => item.sourceAlias === alias || item.targetAlias === alias);
|
|
2841
|
+
const ownedMessageIds = new Set([
|
|
2842
|
+
...queued.map((item) => item.messageId),
|
|
2843
|
+
...inFlight.map((item) => item.messageId),
|
|
2844
|
+
]);
|
|
2845
|
+
if (queued.length !== 0 ||
|
|
2846
|
+
inFlight.length !== 0 ||
|
|
2847
|
+
[...this.transientBodies.keys()].some((messageId) => ownedMessageIds.has(messageId)) ||
|
|
2848
|
+
state.routes.some((route) => route.binding.provider === "codex" &&
|
|
2849
|
+
route.alias === alias &&
|
|
2850
|
+
route.queueDepth !== 0)) {
|
|
2851
|
+
throw new BridgeError("CODEX_SUCCESSION_LEDGER_NOT_EMPTY", "Codex succession requires the replaced route's queue and in-flight ledger to be drained.", true);
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
requireCodexRouteForIdentity(state, identity) {
|
|
2855
|
+
const route = state.routes.find((candidate) => routeMatchesSuccessionIdentity(candidate, identity));
|
|
2856
|
+
if (route === undefined) {
|
|
2857
|
+
throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "Codex succession requires the exact persisted Codex route.");
|
|
2858
|
+
}
|
|
2859
|
+
return route;
|
|
2860
|
+
}
|
|
2861
|
+
requireJournalRoute(state, journal, side) {
|
|
2862
|
+
return this.requireCodexRouteForIdentity(state, journal[side]);
|
|
2863
|
+
}
|
|
2864
|
+
requireAnyJournalRoute(state, journal) {
|
|
2865
|
+
const routes = state.routes.filter((candidate) => routeMatchesSuccessionIdentity(candidate, journal.old) ||
|
|
2866
|
+
routeMatchesSuccessionIdentity(candidate, journal.new));
|
|
2867
|
+
if (routes.length !== 1 || routes[0] === undefined) {
|
|
2868
|
+
throw new BridgeError("CODEX_SUCCESSION_OWNER_MISMATCH", "The succession journal must own exactly one persisted Codex route.");
|
|
2869
|
+
}
|
|
2870
|
+
return routes[0];
|
|
2871
|
+
}
|
|
2872
|
+
assertNewSuccessionIdentityAvailable(state, oldRoute, identity) {
|
|
2873
|
+
if (state.routes.some((candidate) => candidate !== oldRoute &&
|
|
2874
|
+
(candidate.alias === identity.alias ||
|
|
2875
|
+
sameRouteTarget(candidate.binding, identity.binding) ||
|
|
2876
|
+
candidate.binding.ownerLease === identity.binding.ownerLease))) {
|
|
2877
|
+
throw new BridgeError("CODEX_SUCCESSION_BINDING_COLLISION", "The new Codex identity collides with another persisted route.");
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
replaceCodexRouteForSuccession(state, route, identity, now, routeState, compatibility) {
|
|
2881
|
+
const oldAlias = route.alias;
|
|
2882
|
+
route.alias = identity.alias;
|
|
2883
|
+
route.binding = { ...identity.binding };
|
|
2884
|
+
route.registrationMode = "explicit_opt_in";
|
|
2885
|
+
route.enabled = true;
|
|
2886
|
+
route.state = routeState;
|
|
2887
|
+
route.compatibility = compatibility;
|
|
2888
|
+
route.registeredAt = now.toISOString();
|
|
2889
|
+
route.updatedAt = now.toISOString();
|
|
2890
|
+
route.queueDepth = 0;
|
|
2891
|
+
route.counters = emptyCounters();
|
|
2892
|
+
if (routeState === "stale") {
|
|
2893
|
+
delete route.lastSeenAt;
|
|
2894
|
+
route.safeErrorCode = "REOBSERVATION_REQUIRED";
|
|
2895
|
+
}
|
|
2896
|
+
else {
|
|
2897
|
+
route.lastSeenAt = now.toISOString();
|
|
2898
|
+
delete route.safeErrorCode;
|
|
2899
|
+
}
|
|
2900
|
+
state.dedupe = state.dedupe.filter((record) => record.sourceAlias !== oldAlias &&
|
|
2901
|
+
record.targetAlias !== oldAlias &&
|
|
2902
|
+
record.sourceAlias !== identity.alias &&
|
|
2903
|
+
record.targetAlias !== identity.alias);
|
|
2904
|
+
state.rateBuckets = state.rateBuckets.filter((bucket) => bucket.sourceAlias !== oldAlias &&
|
|
2905
|
+
bucket.sourceAlias !== identity.alias);
|
|
2906
|
+
this.settleProgressWatchesForAliases(state, new Set([oldAlias, identity.alias]), now);
|
|
2907
|
+
removePairsForAliases(state, new Set([oldAlias, identity.alias]));
|
|
2908
|
+
}
|
|
2909
|
+
assertAllowedIdentity(identity) {
|
|
2910
|
+
if (!isPrivateEndpointIdentity(identity)) {
|
|
2911
|
+
throw new BridgeError("INVALID_PRIVATE_ROUTE_IDENTITY", "The private route identity is malformed or contains a path-like value.");
|
|
2912
|
+
}
|
|
2913
|
+
if (!this.config.allowedHosts.includes(identity.hostId)) {
|
|
2914
|
+
throw new BridgeError("GATEWAY_HOST_NOT_ALLOWED", "The route host is not in the fixed gateway allowlist.");
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2917
|
+
requireCompatibleObservedEndpoint(state, binding) {
|
|
2918
|
+
const connector = state.connectors.find((candidate) => sameEndpoint(candidate, binding));
|
|
2919
|
+
if (!connector ||
|
|
2920
|
+
!["healthy", "degraded"].includes(connector.health) ||
|
|
2921
|
+
connector.compatibility !== "compatible") {
|
|
2922
|
+
throw new BridgeError("ROUTE_ENDPOINT_NOT_OBSERVED", "The exact compatible endpoint generation must be observed before Codex succession can publish or activate it.");
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
validateRouteInput(input) {
|
|
2926
|
+
if (!ALIAS_PATTERN.test(input.alias)) {
|
|
2927
|
+
throw new BridgeError("INVALID_GATEWAY_ALIAS", "Gateway aliases must use lowercase ASCII name@host syntax.");
|
|
2928
|
+
}
|
|
2929
|
+
if (!isPrivateRouteBinding(input.binding)) {
|
|
2930
|
+
throw new BridgeError("INVALID_PRIVATE_ROUTE_IDENTITY", "The private route binding is malformed or contains a path-like value.");
|
|
2931
|
+
}
|
|
2932
|
+
const aliasHost = input.alias.slice(input.alias.lastIndexOf("@") + 1);
|
|
2933
|
+
if (aliasHost !== input.binding.hostId) {
|
|
2934
|
+
throw new BridgeError("ROUTE_HOST_MISMATCH", "The public alias host must match the allowlisted private route host.");
|
|
2935
|
+
}
|
|
2936
|
+
if ((input.binding.provider === "codex" &&
|
|
2937
|
+
input.registrationMode !== "explicit_opt_in") ||
|
|
2938
|
+
(input.binding.provider === "claude" &&
|
|
2939
|
+
input.registrationMode !== "selected_live_peer")) {
|
|
2940
|
+
throw new BridgeError("ROUTE_OPT_IN_REQUIRED", "Codex routes require explicit self opt-in and Claude routes require a selected live peer.");
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
requireOwnedRoute(state, alias, ownerLease) {
|
|
2944
|
+
const route = state.routes.find((candidate) => candidate.alias === alias);
|
|
2945
|
+
if (!route || route.binding.ownerLease !== ownerLease) {
|
|
2946
|
+
throw new BridgeError("ROUTE_OWNERSHIP_MISMATCH", "The route does not exist or the private ownership lease does not match.");
|
|
2947
|
+
}
|
|
2948
|
+
return route;
|
|
2949
|
+
}
|
|
2950
|
+
requireAvailableRoute(state, alias) {
|
|
2951
|
+
if (!ALIAS_PATTERN.test(alias)) {
|
|
2952
|
+
throw new BridgeError("INVALID_GATEWAY_ALIAS", "The selected alias does not use the required lowercase ASCII grammar.");
|
|
2953
|
+
}
|
|
2954
|
+
const route = state.routes.find((candidate) => candidate.alias === alias);
|
|
2955
|
+
if (!route ||
|
|
2956
|
+
!route.enabled ||
|
|
2957
|
+
!["idle", "busy", "awaiting_approval"].includes(route.state) ||
|
|
2958
|
+
route.compatibility !== "compatible") {
|
|
2959
|
+
throw new BridgeError("ROUTE_UNAVAILABLE", "The selected route is not currently enabled and positively observed.", true);
|
|
2960
|
+
}
|
|
2961
|
+
return route;
|
|
2962
|
+
}
|
|
2963
|
+
requirePairRoutes(state, input, requireAvailable = true) {
|
|
2964
|
+
if (!isObject(input) ||
|
|
2965
|
+
typeof input.claudeAlias !== "string" ||
|
|
2966
|
+
typeof input.codexAlias !== "string" ||
|
|
2967
|
+
!ALIAS_PATTERN.test(input.claudeAlias) ||
|
|
2968
|
+
!ALIAS_PATTERN.test(input.codexAlias) ||
|
|
2969
|
+
input.claudeAlias === input.codexAlias) {
|
|
2970
|
+
throw new BridgeError("INVALID_GATEWAY_PAIR", "A pair requires distinct normalized Claude and Codex aliases.");
|
|
2971
|
+
}
|
|
2972
|
+
const resolve = (alias) => {
|
|
2973
|
+
if (requireAvailable)
|
|
2974
|
+
return this.requireAvailableRoute(state, alias);
|
|
2975
|
+
const route = state.routes.find((candidate) => candidate.alias === alias);
|
|
2976
|
+
if (route === undefined) {
|
|
2977
|
+
throw new BridgeError("PAIR_ROUTE_NOT_FOUND", "The pair references a route that is not registered.");
|
|
2978
|
+
}
|
|
2979
|
+
return route;
|
|
2980
|
+
};
|
|
2981
|
+
const claude = resolve(input.claudeAlias);
|
|
2982
|
+
const codex = resolve(input.codexAlias);
|
|
2983
|
+
if (claude.binding.provider !== "claude" ||
|
|
2984
|
+
claude.registrationMode !== "selected_live_peer" ||
|
|
2985
|
+
codex.binding.provider !== "codex" ||
|
|
2986
|
+
codex.registrationMode !== "explicit_opt_in" ||
|
|
2987
|
+
claude.binding.hostId !== codex.binding.hostId) {
|
|
2988
|
+
throw new BridgeError("INVALID_GATEWAY_PAIR", "A pair must connect one selected Claude route and one registered Codex route on the same host.");
|
|
2989
|
+
}
|
|
2990
|
+
return { claude, codex };
|
|
2991
|
+
}
|
|
2992
|
+
requireExactPair(state, claude, codex) {
|
|
2993
|
+
const pair = state.pairs.find((candidate) => candidate.claudeAlias === claude.alias &&
|
|
2994
|
+
candidate.codexAlias === codex.alias);
|
|
2995
|
+
if (pair === undefined ||
|
|
2996
|
+
pair.claudeOwnerLease !== claude.binding.ownerLease ||
|
|
2997
|
+
pair.codexOwnerLease !== codex.binding.ownerLease) {
|
|
2998
|
+
throw new BridgeError("SENDER_NOT_PAIRED", "The two exact route authorities do not share a consent edge.");
|
|
2999
|
+
}
|
|
3000
|
+
return pair;
|
|
3001
|
+
}
|
|
3002
|
+
validateTransientNativeClaudePeer(state, peer, codexRoute) {
|
|
3003
|
+
if (!isObject(peer) ||
|
|
3004
|
+
!hasOnlyKeys(peer, ["alias", "binding"]) ||
|
|
3005
|
+
typeof peer.alias !== "string" ||
|
|
3006
|
+
!ALIAS_PATTERN.test(peer.alias) ||
|
|
3007
|
+
!isPrivateRouteBinding(peer.binding) ||
|
|
3008
|
+
peer.binding.provider !== "claude") {
|
|
3009
|
+
throw new BridgeError("INVALID_NATIVE_CLAUDE_PEER", "Native ingress requires a normalized alias and a private Claude binding.");
|
|
3010
|
+
}
|
|
3011
|
+
this.assertAllowedIdentity(endpointOf(peer.binding));
|
|
3012
|
+
const aliasHost = peer.alias.slice(peer.alias.lastIndexOf("@") + 1);
|
|
3013
|
+
if (codexRoute.binding.provider !== "codex" ||
|
|
3014
|
+
aliasHost !== peer.binding.hostId ||
|
|
3015
|
+
peer.binding.hostId !== codexRoute.binding.hostId ||
|
|
3016
|
+
peer.alias === codexRoute.alias) {
|
|
3017
|
+
throw new BridgeError("NATIVE_PEER_SCOPE_MISMATCH", "The transient Claude peer and registered Codex route must have distinct aliases on the same allowlisted host.");
|
|
3018
|
+
}
|
|
3019
|
+
const connector = state.connectors.find((candidate) => sameEndpoint(candidate, peer.binding));
|
|
3020
|
+
if (!connector ||
|
|
3021
|
+
!["healthy", "degraded"].includes(connector.health) ||
|
|
3022
|
+
connector.compatibility !== "compatible") {
|
|
3023
|
+
throw new BridgeError("NATIVE_PEER_ENDPOINT_NOT_OBSERVED", "The transient Claude peer's exact compatible connector generation is not live.", true);
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
enqueueResolvedMessage(state, now, input, sides) {
|
|
3027
|
+
if (input.conversationIdSuffix !== undefined &&
|
|
3028
|
+
!CONVERSATION_SUFFIX_PATTERN.test(input.conversationIdSuffix)) {
|
|
3029
|
+
throw new BridgeError("INVALID_CONVERSATION_SUFFIX", "Conversation correlation requires an eight-character opaque suffix.");
|
|
3030
|
+
}
|
|
3031
|
+
if (typeof input.body !== "string" ||
|
|
3032
|
+
input.body.length === 0 ||
|
|
3033
|
+
input.body.includes("\u0000")) {
|
|
3034
|
+
throw new BridgeError("INVALID_GATEWAY_MESSAGE", "Gateway messages must contain a non-empty text body without NUL bytes.");
|
|
3035
|
+
}
|
|
3036
|
+
const bytes = Buffer.byteLength(input.body, "utf8");
|
|
3037
|
+
if (bytes > this.config.limits.maxMessageBytes) {
|
|
3038
|
+
this.recordRejection(state, sides, bytes, now, "MESSAGE_TOO_LARGE", input.steer, input.conversationIdSuffix);
|
|
3039
|
+
throw new BridgeError("MESSAGE_TOO_LARGE", "The transient message exceeds the configured byte limit.");
|
|
3040
|
+
}
|
|
3041
|
+
const hopCount = input.hopCount ?? 0;
|
|
3042
|
+
if (!isNonNegativeInteger(hopCount) ||
|
|
3043
|
+
hopCount > this.config.limits.maxHopCount) {
|
|
3044
|
+
this.recordRejection(state, sides, bytes, now, "HOP_LIMIT_EXCEEDED", input.steer, input.conversationIdSuffix);
|
|
3045
|
+
throw new BridgeError("HOP_LIMIT_EXCEEDED", "The message exceeds the configured gateway hop limit.");
|
|
3046
|
+
}
|
|
3047
|
+
if (typeof input.dedupeKey !== "string" ||
|
|
3048
|
+
input.dedupeKey.length === 0 ||
|
|
3049
|
+
Buffer.byteLength(input.dedupeKey) > 512) {
|
|
3050
|
+
throw new BridgeError("INVALID_DEDUPE_KEY", "A bounded, non-empty deduplication key is required.");
|
|
3051
|
+
}
|
|
3052
|
+
const fingerprint = createHash("sha256")
|
|
3053
|
+
.update(sides.sourceAlias)
|
|
3054
|
+
.update("\0")
|
|
3055
|
+
.update(sides.targetAlias)
|
|
3056
|
+
.update("\0")
|
|
3057
|
+
.update(input.dedupeKey)
|
|
3058
|
+
.digest("base64url");
|
|
3059
|
+
const duplicate = state.dedupe.find((record) => record.fingerprint === fingerprint &&
|
|
3060
|
+
Date.parse(record.expiresAt) > now.getTime());
|
|
3061
|
+
if (duplicate) {
|
|
3062
|
+
state.accounting.duplicates += 1;
|
|
3063
|
+
this.appendEvent(state, {
|
|
3064
|
+
timestamp: now.toISOString(),
|
|
3065
|
+
messageIdSuffix: duplicate.messageIdSuffix,
|
|
3066
|
+
direction: sides.direction,
|
|
3067
|
+
sourceAlias: sides.sourceAlias,
|
|
3068
|
+
targetAlias: sides.targetAlias,
|
|
3069
|
+
state: "duplicate",
|
|
3070
|
+
...(duplicate.conversationIdSuffix === undefined
|
|
3071
|
+
? {}
|
|
3072
|
+
: { conversationIdSuffix: duplicate.conversationIdSuffix }),
|
|
3073
|
+
bytes,
|
|
3074
|
+
hopCount,
|
|
3075
|
+
...(input.steer === true ? { steer: true } : {}),
|
|
3076
|
+
});
|
|
3077
|
+
return {
|
|
3078
|
+
accepted: false,
|
|
3079
|
+
duplicate: true,
|
|
3080
|
+
messageIdSuffix: duplicate.messageIdSuffix,
|
|
3081
|
+
};
|
|
3082
|
+
}
|
|
3083
|
+
this.consumeRateLimit(state, sides.sourceAlias, now);
|
|
3084
|
+
const deadline = input.deadlineAt
|
|
3085
|
+
? new Date(input.deadlineAt)
|
|
3086
|
+
: new Date(now.getTime() + this.config.limits.messageDeadlineMs);
|
|
3087
|
+
if (!Number.isFinite(deadline.getTime()) ||
|
|
3088
|
+
deadline.getTime() <= now.getTime() ||
|
|
3089
|
+
deadline.getTime() > now.getTime() + this.config.limits.messageDeadlineMs) {
|
|
3090
|
+
this.recordRejection(state, sides, bytes, now, "INVALID_DEADLINE", input.steer, input.conversationIdSuffix);
|
|
3091
|
+
throw new BridgeError("INVALID_DEADLINE", "The message deadline must be in the future and within the configured maximum.");
|
|
3092
|
+
}
|
|
3093
|
+
let oldestSteerIndex = -1;
|
|
3094
|
+
if (input.steer === true) {
|
|
3095
|
+
let oldestSteerAt = Number.POSITIVE_INFINITY;
|
|
3096
|
+
for (const [index, item] of state.queue.entries()) {
|
|
3097
|
+
if (item.sourceAlias !== sides.sourceAlias ||
|
|
3098
|
+
item.targetAlias !== sides.targetAlias ||
|
|
3099
|
+
item.steer !== true) {
|
|
3100
|
+
continue;
|
|
3101
|
+
}
|
|
3102
|
+
const enqueuedAt = Date.parse(item.enqueuedAt);
|
|
3103
|
+
if (enqueuedAt < oldestSteerAt) {
|
|
3104
|
+
oldestSteerAt = enqueuedAt;
|
|
3105
|
+
oldestSteerIndex = index;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
const steerDepth = state.queue.filter((item) => item.sourceAlias === sides.sourceAlias &&
|
|
3109
|
+
item.targetAlias === sides.targetAlias &&
|
|
3110
|
+
item.steer === true).length;
|
|
3111
|
+
if (steerDepth < 3)
|
|
3112
|
+
oldestSteerIndex = -1;
|
|
3113
|
+
}
|
|
3114
|
+
const superseded = oldestSteerIndex < 0 ? undefined : state.queue[oldestSteerIndex];
|
|
3115
|
+
const targetDepthBefore = state.queue.filter((item) => item.targetAlias === sides.targetAlias).length;
|
|
3116
|
+
const queueLengthAfterSupersession = state.queue.length - (superseded === undefined ? 0 : 1);
|
|
3117
|
+
const targetDepthAfterSupersession = targetDepthBefore - (superseded === undefined ? 0 : 1);
|
|
3118
|
+
const queuedBytesAfterSupersession = state.accounting.queuedBytes - (superseded?.bytes ?? 0);
|
|
3119
|
+
if (queueLengthAfterSupersession + state.inFlight.length >=
|
|
3120
|
+
this.config.limits.maxQueueMessages ||
|
|
3121
|
+
targetDepthAfterSupersession >=
|
|
3122
|
+
this.config.limits.maxQueueMessagesPerRoute ||
|
|
3123
|
+
queuedBytesAfterSupersession + bytes >
|
|
3124
|
+
this.config.limits.maxQueueBytes) {
|
|
3125
|
+
this.recordRejection(state, sides, bytes, now, "QUEUE_FULL", input.steer, input.conversationIdSuffix);
|
|
3126
|
+
throw new BridgeError("GATEWAY_QUEUE_FULL", "The bounded gateway queue cannot accept another message.", true);
|
|
3127
|
+
}
|
|
3128
|
+
if (input.progressWatch !== undefined &&
|
|
3129
|
+
input.progressWatchNudge !== undefined) {
|
|
3130
|
+
throw new BridgeError("INVALID_PROGRESS_WATCH", "A queued message cannot be both endpoint activity and a controller nudge.");
|
|
3131
|
+
}
|
|
3132
|
+
this.applyProgressWatchMessageActivity(state, now, input.progressWatch, sides);
|
|
3133
|
+
this.commitProgressWatchNudge(state, now, input.progressWatchNudge, sides);
|
|
3134
|
+
let supersededSettlement;
|
|
3135
|
+
if (superseded !== undefined) {
|
|
3136
|
+
state.queue.splice(oldestSteerIndex, 1);
|
|
3137
|
+
this.transientBodies.delete(superseded.messageId);
|
|
3138
|
+
state.accounting.queuedBytes -= superseded.bytes;
|
|
3139
|
+
const oldestTarget = state.routes.find((route) => route.alias === superseded.targetAlias);
|
|
3140
|
+
if (oldestTarget) {
|
|
3141
|
+
oldestTarget.queueDepth = Math.max(0, oldestTarget.queueDepth - 1);
|
|
3142
|
+
}
|
|
3143
|
+
supersededSettlement = this.finishMetadata(state, superseded, "cancelled", now, "STEER_QUEUE_SUPERSEDED");
|
|
3144
|
+
}
|
|
3145
|
+
const opaque = this.randomId();
|
|
3146
|
+
const messageId = `msg_${opaque}`;
|
|
3147
|
+
if (!MESSAGE_ID_PATTERN.test(messageId)) {
|
|
3148
|
+
throw new BridgeError("INVALID_RANDOM_ID_SOURCE", "The gateway random identifier source did not return a UUID.");
|
|
3149
|
+
}
|
|
3150
|
+
const messageIdSuffix = opaque.replaceAll("-", "").slice(-8).toLowerCase();
|
|
3151
|
+
const metadata = {
|
|
3152
|
+
messageId,
|
|
3153
|
+
messageIdSuffix,
|
|
3154
|
+
...(input.conversationIdSuffix === undefined
|
|
3155
|
+
? {}
|
|
3156
|
+
: { conversationIdSuffix: input.conversationIdSuffix }),
|
|
3157
|
+
direction: sides.direction,
|
|
3158
|
+
sourceAlias: sides.sourceAlias,
|
|
3159
|
+
targetAlias: sides.targetAlias,
|
|
3160
|
+
enqueuedAt: now.toISOString(),
|
|
3161
|
+
deadlineAt: deadline.toISOString(),
|
|
3162
|
+
bytes,
|
|
3163
|
+
hopCount,
|
|
3164
|
+
...(sides.pair === true ? { pair: true } : {}),
|
|
3165
|
+
...(input.steer === true ? { steer: true } : {}),
|
|
3166
|
+
};
|
|
3167
|
+
state.queue.push(metadata);
|
|
3168
|
+
this.transientBodies.set(messageId, input.body);
|
|
3169
|
+
state.accounting.accepted += 1;
|
|
3170
|
+
state.accounting.bytesAccepted += bytes;
|
|
3171
|
+
const pair = findPairForMessage(state, metadata);
|
|
3172
|
+
if (pair !== undefined) {
|
|
3173
|
+
pair.counters.accepted += 1;
|
|
3174
|
+
pair.counters.bytesAccepted += bytes;
|
|
3175
|
+
pair.updatedAt = now.toISOString();
|
|
3176
|
+
}
|
|
3177
|
+
state.accounting.queuedBytes += bytes;
|
|
3178
|
+
if (sides.sourceRoute) {
|
|
3179
|
+
sides.sourceRoute.counters.accepted += 1;
|
|
3180
|
+
sides.sourceRoute.counters.bytesAccepted += bytes;
|
|
3181
|
+
}
|
|
3182
|
+
if (sides.targetRoute)
|
|
3183
|
+
sides.targetRoute.queueDepth += 1;
|
|
3184
|
+
state.dedupe.push({
|
|
3185
|
+
fingerprint,
|
|
3186
|
+
messageIdSuffix,
|
|
3187
|
+
...(input.conversationIdSuffix === undefined
|
|
3188
|
+
? {}
|
|
3189
|
+
: { conversationIdSuffix: input.conversationIdSuffix }),
|
|
3190
|
+
sourceAlias: sides.sourceAlias,
|
|
3191
|
+
targetAlias: sides.targetAlias,
|
|
3192
|
+
direction: sides.direction,
|
|
3193
|
+
...(sides.pair === true ? { pair: true } : {}),
|
|
3194
|
+
firstSeenAt: now.toISOString(),
|
|
3195
|
+
expiresAt: new Date(now.getTime() + this.config.limits.dedupeTtlMs).toISOString(),
|
|
3196
|
+
});
|
|
3197
|
+
while (state.dedupe.length > this.config.limits.dedupeCapacity) {
|
|
3198
|
+
state.dedupe.shift();
|
|
3199
|
+
}
|
|
3200
|
+
this.appendEvent(state, {
|
|
3201
|
+
timestamp: now.toISOString(),
|
|
3202
|
+
messageIdSuffix,
|
|
3203
|
+
...(input.conversationIdSuffix === undefined
|
|
3204
|
+
? {}
|
|
3205
|
+
: { conversationIdSuffix: input.conversationIdSuffix }),
|
|
3206
|
+
direction: sides.direction,
|
|
3207
|
+
sourceAlias: sides.sourceAlias,
|
|
3208
|
+
targetAlias: sides.targetAlias,
|
|
3209
|
+
state: "queued",
|
|
3210
|
+
bytes,
|
|
3211
|
+
hopCount,
|
|
3212
|
+
...(input.steer === true ? { steer: true } : {}),
|
|
3213
|
+
});
|
|
3214
|
+
return {
|
|
3215
|
+
accepted: true,
|
|
3216
|
+
duplicate: false,
|
|
3217
|
+
messageId,
|
|
3218
|
+
messageIdSuffix,
|
|
3219
|
+
...(sides.pair === true ? { pair: true } : {}),
|
|
3220
|
+
...(supersededSettlement === undefined
|
|
3221
|
+
? {}
|
|
3222
|
+
: { supersededSettlement }),
|
|
3223
|
+
};
|
|
3224
|
+
}
|
|
3225
|
+
consumeRateLimit(state, sourceAlias, now) {
|
|
3226
|
+
let bucket = state.rateBuckets.find((candidate) => candidate.sourceAlias === sourceAlias);
|
|
3227
|
+
if (!bucket ||
|
|
3228
|
+
now.getTime() - Date.parse(bucket.windowStartedAt) >=
|
|
3229
|
+
this.config.limits.rateWindowMs) {
|
|
3230
|
+
state.rateBuckets = state.rateBuckets.filter((candidate) => candidate.sourceAlias !== sourceAlias);
|
|
3231
|
+
if (state.rateBuckets.length >= this.config.limits.maxRoutes) {
|
|
3232
|
+
state.accounting.rejected += 1;
|
|
3233
|
+
const route = state.routes.find((candidate) => candidate.alias === sourceAlias);
|
|
3234
|
+
if (route)
|
|
3235
|
+
route.counters.rejected += 1;
|
|
3236
|
+
throw new BridgeError("GATEWAY_RATE_LIMITED", "The bounded gateway rate window cannot admit another source.", true);
|
|
3237
|
+
}
|
|
3238
|
+
bucket = {
|
|
3239
|
+
sourceAlias,
|
|
3240
|
+
windowStartedAt: now.toISOString(),
|
|
3241
|
+
count: 0,
|
|
3242
|
+
};
|
|
3243
|
+
state.rateBuckets.push(bucket);
|
|
3244
|
+
}
|
|
3245
|
+
if (bucket.count >= this.config.limits.rateLimitPerRoute) {
|
|
3246
|
+
state.accounting.rejected += 1;
|
|
3247
|
+
const route = state.routes.find((candidate) => candidate.alias === sourceAlias);
|
|
3248
|
+
if (route)
|
|
3249
|
+
route.counters.rejected += 1;
|
|
3250
|
+
throw new BridgeError("GATEWAY_RATE_LIMITED", "The source route exceeded its bounded gateway rate window.", true);
|
|
3251
|
+
}
|
|
3252
|
+
bucket.count += 1;
|
|
3253
|
+
}
|
|
3254
|
+
recordRejection(state, sides, bytes, now, safeErrorCode, steer, conversationIdSuffix) {
|
|
3255
|
+
const suffix = this.randomId().replaceAll("-", "").slice(-8).toLowerCase();
|
|
3256
|
+
if (!MESSAGE_SUFFIX_PATTERN.test(suffix))
|
|
3257
|
+
return;
|
|
3258
|
+
state.accounting.rejected += 1;
|
|
3259
|
+
if (sides.sourceRoute)
|
|
3260
|
+
sides.sourceRoute.counters.rejected += 1;
|
|
3261
|
+
const pair = findPairForMessage(state, sides);
|
|
3262
|
+
if (pair !== undefined) {
|
|
3263
|
+
pair.counters.rejected += 1;
|
|
3264
|
+
pair.updatedAt = now.toISOString();
|
|
3265
|
+
}
|
|
3266
|
+
this.appendEvent(state, {
|
|
3267
|
+
timestamp: now.toISOString(),
|
|
3268
|
+
messageIdSuffix: suffix,
|
|
3269
|
+
...(conversationIdSuffix === undefined
|
|
3270
|
+
? {}
|
|
3271
|
+
: { conversationIdSuffix }),
|
|
3272
|
+
direction: sides.direction,
|
|
3273
|
+
sourceAlias: sides.sourceAlias,
|
|
3274
|
+
targetAlias: sides.targetAlias,
|
|
3275
|
+
state: "rejected",
|
|
3276
|
+
bytes: Math.max(1, bytes),
|
|
3277
|
+
hopCount: 0,
|
|
3278
|
+
safeErrorCode,
|
|
3279
|
+
...(steer === true ? { steer: true } : {}),
|
|
3280
|
+
});
|
|
3281
|
+
}
|
|
3282
|
+
appendEvent(state, event) {
|
|
3283
|
+
state.eventSequence += 1;
|
|
3284
|
+
state.events.push({ sequence: state.eventSequence, ...event });
|
|
3285
|
+
while (state.events.length > this.config.limits.eventCapacity) {
|
|
3286
|
+
state.events.shift();
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
/** Commit watch activity only after every message-admission check succeeds. */
|
|
3290
|
+
applyProgressWatchMessageActivity(state, now, activity, sides) {
|
|
3291
|
+
if (activity === undefined)
|
|
3292
|
+
return;
|
|
3293
|
+
if (!CONVERSATION_ID_PATTERN.test(activity.conversationId) ||
|
|
3294
|
+
activity.actorAlias !== sides.sourceAlias ||
|
|
3295
|
+
(activity.openIdleMs !== undefined &&
|
|
3296
|
+
(!Number.isSafeInteger(activity.openIdleMs) ||
|
|
3297
|
+
activity.openIdleMs < PROGRESS_WATCH_MIN_IDLE_MS ||
|
|
3298
|
+
activity.openIdleMs > PROGRESS_WATCH_MAX_IDLE_MS)) ||
|
|
3299
|
+
(activity.completionSignal === true &&
|
|
3300
|
+
activity.openIdleMs !== undefined)) {
|
|
3301
|
+
throw new BridgeError("INVALID_PROGRESS_WATCH", "The message watch activity is malformed or contradictory.");
|
|
3302
|
+
}
|
|
3303
|
+
const index = state.progressWatches.findIndex((watch) => watch.conversationId === activity.conversationId);
|
|
3304
|
+
const existing = state.progressWatches[index];
|
|
3305
|
+
if (existing !== undefined) {
|
|
3306
|
+
if (activity.actorAlias !== existing.ownerAlias &&
|
|
3307
|
+
activity.actorAlias !== existing.workerAlias) {
|
|
3308
|
+
throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The watched conversation belongs to different exact endpoints.");
|
|
3309
|
+
}
|
|
3310
|
+
if (activity.openIdleMs !== undefined &&
|
|
3311
|
+
activity.actorAlias !== existing.ownerAlias) {
|
|
3312
|
+
throw new BridgeError("PROGRESS_WATCH_OWNER_REQUIRED", "Only the exact watch owner may reassert tracking options.");
|
|
3313
|
+
}
|
|
3314
|
+
if (activity.completionSignal === true &&
|
|
3315
|
+
activity.actorAlias === existing.ownerAlias) {
|
|
3316
|
+
state.progressWatches.splice(index, 1);
|
|
3317
|
+
this.appendProgressWatchEvent(state, existing, {
|
|
3318
|
+
kind: "done",
|
|
3319
|
+
timestamp: now.toISOString(),
|
|
3320
|
+
});
|
|
3321
|
+
return;
|
|
3322
|
+
}
|
|
3323
|
+
const workerReportedComplete = activity.completionSignal === true &&
|
|
3324
|
+
activity.actorAlias === existing.workerAlias;
|
|
3325
|
+
const transition = transitionProgressWatch(existing, {
|
|
3326
|
+
type: "activity",
|
|
3327
|
+
at: now.getTime(),
|
|
3328
|
+
...(workerReportedComplete
|
|
3329
|
+
? { workerReportedComplete: true }
|
|
3330
|
+
: {}),
|
|
3331
|
+
});
|
|
3332
|
+
if (transition.state !== null) {
|
|
3333
|
+
state.progressWatches[index] = transition.state;
|
|
3334
|
+
if (workerReportedComplete) {
|
|
3335
|
+
this.appendProgressWatchEvent(state, transition.state, {
|
|
3336
|
+
kind: "worker_reported_complete",
|
|
3337
|
+
timestamp: now.toISOString(),
|
|
3338
|
+
});
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
return;
|
|
3342
|
+
}
|
|
3343
|
+
if (activity.openIdleMs === undefined)
|
|
3344
|
+
return;
|
|
3345
|
+
if (this.config.trackingEnabled === false) {
|
|
3346
|
+
throw new BridgeError("PROGRESS_TRACKING_DISABLED", "Progress tracking is disabled by the controller configuration.");
|
|
3347
|
+
}
|
|
3348
|
+
if (sides.pair !== true) {
|
|
3349
|
+
throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "Progress tracking requires one exact paired consent edge.");
|
|
3350
|
+
}
|
|
3351
|
+
if (state.progressWatches.length >=
|
|
3352
|
+
(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY)) {
|
|
3353
|
+
throw new BridgeError("PROGRESS_WATCH_CAPACITY_REACHED", "The bounded progress-watch inventory is full.", true);
|
|
3354
|
+
}
|
|
3355
|
+
const owner = state.routes.find((route) => route.alias === sides.sourceAlias);
|
|
3356
|
+
const worker = state.routes.find((route) => route.alias === sides.targetAlias);
|
|
3357
|
+
if (owner === undefined || worker === undefined) {
|
|
3358
|
+
throw new BridgeError("PROGRESS_WATCH_EDGE_REQUIRED", "Progress tracking requires both exact paired routes to be registered.");
|
|
3359
|
+
}
|
|
3360
|
+
const watch = createProgressWatchMachine({
|
|
3361
|
+
conversationId: activity.conversationId,
|
|
3362
|
+
ownerAlias: owner.alias,
|
|
3363
|
+
workerAlias: worker.alias,
|
|
3364
|
+
ownerLease: owner.binding.ownerLease,
|
|
3365
|
+
workerLease: worker.binding.ownerLease,
|
|
3366
|
+
idleMs: activity.openIdleMs,
|
|
3367
|
+
at: now.getTime(),
|
|
3368
|
+
});
|
|
3369
|
+
state.progressWatches.push(watch);
|
|
3370
|
+
this.appendProgressWatchEvent(state, watch, {
|
|
3371
|
+
kind: "opened",
|
|
3372
|
+
timestamp: now.toISOString(),
|
|
3373
|
+
});
|
|
3374
|
+
}
|
|
3375
|
+
progressWatchDueTransition(state, watch, now) {
|
|
3376
|
+
const owner = state.routes.find((route) => route.alias === watch.ownerAlias &&
|
|
3377
|
+
route.binding.ownerLease === watch.ownerLease);
|
|
3378
|
+
const worker = state.routes.find((route) => route.alias === watch.workerAlias &&
|
|
3379
|
+
route.binding.ownerLease === watch.workerLease);
|
|
3380
|
+
const pairPresent = owner !== undefined &&
|
|
3381
|
+
worker !== undefined &&
|
|
3382
|
+
state.pairs.some((pair) => ((pair.claudeAlias === owner.alias &&
|
|
3383
|
+
pair.codexAlias === worker.alias) ||
|
|
3384
|
+
(pair.claudeAlias === worker.alias &&
|
|
3385
|
+
pair.codexAlias === owner.alias)) &&
|
|
3386
|
+
pair.claudeOwnerLease ===
|
|
3387
|
+
(owner.binding.provider === "claude"
|
|
3388
|
+
? owner.binding.ownerLease
|
|
3389
|
+
: worker.binding.ownerLease) &&
|
|
3390
|
+
pair.codexOwnerLease ===
|
|
3391
|
+
(owner.binding.provider === "codex"
|
|
3392
|
+
? owner.binding.ownerLease
|
|
3393
|
+
: worker.binding.ownerLease));
|
|
3394
|
+
return transitionProgressWatch(watch, {
|
|
3395
|
+
type: "due",
|
|
3396
|
+
at: now.getTime(),
|
|
3397
|
+
bothIdle: owner?.state === "idle" && worker?.state === "idle",
|
|
3398
|
+
endpointsPresent: pairPresent,
|
|
3399
|
+
});
|
|
3400
|
+
}
|
|
3401
|
+
commitProgressWatchNudge(state, now, nudge, sides) {
|
|
3402
|
+
if (nudge === undefined)
|
|
3403
|
+
return;
|
|
3404
|
+
const index = state.progressWatches.findIndex((watch) => watch.conversationId === nudge.conversationId);
|
|
3405
|
+
const watch = state.progressWatches[index];
|
|
3406
|
+
if (watch === undefined ||
|
|
3407
|
+
sides.sourceAlias !== watch.ownerAlias ||
|
|
3408
|
+
sides.targetAlias !== watch.workerAlias) {
|
|
3409
|
+
throw new BridgeError("PROGRESS_WATCH_OWNERSHIP_MISMATCH", "The controller nudge no longer matches its exact watch edge.");
|
|
3410
|
+
}
|
|
3411
|
+
const transition = this.progressWatchDueTransition(state, watch, now);
|
|
3412
|
+
if (transition.state === null ||
|
|
3413
|
+
!transition.effects.some((effect) => effect.type === "send_nudge" &&
|
|
3414
|
+
effect.nudgeNumber === nudge.nudgeNumber)) {
|
|
3415
|
+
throw new BridgeError("PROGRESS_WATCH_NUDGE_NOT_DUE", "The requested progress-watch nudge is not currently due.", true);
|
|
3416
|
+
}
|
|
3417
|
+
state.progressWatches[index] = transition.state;
|
|
3418
|
+
this.appendProgressWatchEvent(state, transition.state, {
|
|
3419
|
+
kind: "nudge",
|
|
3420
|
+
timestamp: now.toISOString(),
|
|
3421
|
+
nudgeNumber: nudge.nudgeNumber,
|
|
3422
|
+
});
|
|
3423
|
+
}
|
|
3424
|
+
appendProgressWatchEvent(state, watch, event) {
|
|
3425
|
+
state.watchSequence += 1;
|
|
3426
|
+
state.progressWatchEvents.push({
|
|
3427
|
+
sequence: state.watchSequence,
|
|
3428
|
+
timestamp: event.timestamp,
|
|
3429
|
+
conversationId: watch.conversationId,
|
|
3430
|
+
ownerAlias: watch.ownerAlias,
|
|
3431
|
+
workerAlias: watch.workerAlias,
|
|
3432
|
+
kind: event.kind,
|
|
3433
|
+
...(event.nudgeNumber === undefined
|
|
3434
|
+
? {}
|
|
3435
|
+
: { nudgeNumber: event.nudgeNumber }),
|
|
3436
|
+
});
|
|
3437
|
+
while (state.progressWatchEvents.length > this.config.limits.eventCapacity) {
|
|
3438
|
+
state.progressWatchEvents.shift();
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
settleProgressWatchesForAliases(state, aliases, now) {
|
|
3442
|
+
const retained = [];
|
|
3443
|
+
for (const watch of state.progressWatches) {
|
|
3444
|
+
if (!aliases.has(watch.ownerAlias) &&
|
|
3445
|
+
!aliases.has(watch.workerAlias)) {
|
|
3446
|
+
retained.push(watch);
|
|
3447
|
+
continue;
|
|
3448
|
+
}
|
|
3449
|
+
this.appendProgressWatchEvent(state, watch, {
|
|
3450
|
+
kind: "endpoint_retired",
|
|
3451
|
+
timestamp: now.toISOString(),
|
|
3452
|
+
});
|
|
3453
|
+
}
|
|
3454
|
+
state.progressWatches = retained;
|
|
3455
|
+
}
|
|
3456
|
+
settleProgressWatchesForPair(state, pair, now) {
|
|
3457
|
+
const retained = [];
|
|
3458
|
+
for (const watch of state.progressWatches) {
|
|
3459
|
+
const matches = (watch.ownerAlias === pair.claudeAlias &&
|
|
3460
|
+
watch.workerAlias === pair.codexAlias) ||
|
|
3461
|
+
(watch.ownerAlias === pair.codexAlias &&
|
|
3462
|
+
watch.workerAlias === pair.claudeAlias);
|
|
3463
|
+
if (!matches) {
|
|
3464
|
+
retained.push(watch);
|
|
3465
|
+
continue;
|
|
3466
|
+
}
|
|
3467
|
+
this.appendProgressWatchEvent(state, watch, {
|
|
3468
|
+
kind: "endpoint_retired",
|
|
3469
|
+
timestamp: now.toISOString(),
|
|
3470
|
+
});
|
|
3471
|
+
}
|
|
3472
|
+
state.progressWatches = retained;
|
|
3473
|
+
}
|
|
3474
|
+
finishMetadata(state, metadata, deliveryState, now, safeErrorCode) {
|
|
3475
|
+
const counterKey = deliveryState;
|
|
3476
|
+
state.accounting[counterKey] += 1;
|
|
3477
|
+
const pair = findPairForMessage(state, metadata);
|
|
3478
|
+
if (pair !== undefined) {
|
|
3479
|
+
pair.counters[counterKey] += 1;
|
|
3480
|
+
pair.updatedAt = now.toISOString();
|
|
3481
|
+
}
|
|
3482
|
+
const target = state.routes.find((route) => route.alias === metadata.targetAlias);
|
|
3483
|
+
if (target)
|
|
3484
|
+
target.counters[counterKey] += 1;
|
|
3485
|
+
this.appendEvent(state, {
|
|
3486
|
+
timestamp: now.toISOString(),
|
|
3487
|
+
messageIdSuffix: metadata.messageIdSuffix,
|
|
3488
|
+
...(metadata.conversationIdSuffix === undefined
|
|
3489
|
+
? {}
|
|
3490
|
+
: { conversationIdSuffix: metadata.conversationIdSuffix }),
|
|
3491
|
+
direction: metadata.direction,
|
|
3492
|
+
sourceAlias: metadata.sourceAlias,
|
|
3493
|
+
targetAlias: metadata.targetAlias,
|
|
3494
|
+
state: deliveryState,
|
|
3495
|
+
bytes: metadata.bytes,
|
|
3496
|
+
hopCount: metadata.hopCount,
|
|
3497
|
+
...(metadata.steer === true ? { steer: true } : {}),
|
|
3498
|
+
latencyMs: Math.max(0, now.getTime() - Date.parse(metadata.enqueuedAt)),
|
|
3499
|
+
...(safeErrorCode ? { safeErrorCode } : {}),
|
|
3500
|
+
});
|
|
3501
|
+
return {
|
|
3502
|
+
messageId: metadata.messageId,
|
|
3503
|
+
state: deliveryState,
|
|
3504
|
+
...(safeErrorCode ? { safeErrorCode } : {}),
|
|
3505
|
+
};
|
|
3506
|
+
}
|
|
3507
|
+
validateAffectedInFlightSettlements(state, aliases, requested) {
|
|
3508
|
+
if (requested.length > this.config.limits.maxInFlightMessages) {
|
|
3509
|
+
throw new BridgeError("INVALID_ROUTE_TERMINATION_PLAN", "Route termination requires one normalized terminal settlement per affected in-flight message.");
|
|
3510
|
+
}
|
|
3511
|
+
const affectedIds = new Set(state.inFlight
|
|
3512
|
+
.filter((item) => routeTerminationMatches(state, aliases, item))
|
|
3513
|
+
.map((item) => item.messageId));
|
|
3514
|
+
const byMessageId = new Map();
|
|
3515
|
+
for (const settlement of requested) {
|
|
3516
|
+
if (!MESSAGE_ID_PATTERN.test(settlement.messageId) ||
|
|
3517
|
+
(settlement.state !== "delivered" &&
|
|
3518
|
+
settlement.state !== "unconfirmed" &&
|
|
3519
|
+
settlement.state !== "failed" &&
|
|
3520
|
+
settlement.state !== "ambiguous" &&
|
|
3521
|
+
settlement.state !== "expired" &&
|
|
3522
|
+
settlement.state !== "cancelled") ||
|
|
3523
|
+
(settlement.safeErrorCode !== undefined &&
|
|
3524
|
+
!SAFE_CODE_PATTERN.test(settlement.safeErrorCode)) ||
|
|
3525
|
+
byMessageId.has(settlement.messageId)) {
|
|
3526
|
+
throw new BridgeError("INVALID_ROUTE_TERMINATION_PLAN", "Route termination requires one normalized terminal settlement per affected in-flight message.");
|
|
3527
|
+
}
|
|
3528
|
+
byMessageId.set(settlement.messageId, settlement);
|
|
3529
|
+
}
|
|
3530
|
+
if (byMessageId.size !== affectedIds.size ||
|
|
3531
|
+
[...affectedIds].some((messageId) => !byMessageId.has(messageId)) ||
|
|
3532
|
+
[...byMessageId].some(([messageId]) => !affectedIds.has(messageId))) {
|
|
3533
|
+
throw new BridgeError("ROUTE_TERMINATION_PLAN_MISMATCH", "The exact in-flight route termination plan no longer matches the affected message set.", true);
|
|
3534
|
+
}
|
|
3535
|
+
return byMessageId;
|
|
3536
|
+
}
|
|
3537
|
+
validateAffectedPairInFlightSettlements(state, pair, requested) {
|
|
3538
|
+
if (requested.length > this.config.limits.maxInFlightMessages) {
|
|
3539
|
+
throw new BridgeError("INVALID_PAIR_TERMINATION_PLAN", "Unpairing requires one normalized terminal settlement per affected in-flight message.");
|
|
3540
|
+
}
|
|
3541
|
+
const affectedIds = new Set(state.inFlight
|
|
3542
|
+
.filter((item) => pairMatchesMessage(pair, item))
|
|
3543
|
+
.map((item) => item.messageId));
|
|
3544
|
+
const byMessageId = new Map();
|
|
3545
|
+
for (const settlement of requested) {
|
|
3546
|
+
if (!MESSAGE_ID_PATTERN.test(settlement.messageId) ||
|
|
3547
|
+
(settlement.state !== "delivered" &&
|
|
3548
|
+
settlement.state !== "unconfirmed" &&
|
|
3549
|
+
settlement.state !== "failed" &&
|
|
3550
|
+
settlement.state !== "ambiguous" &&
|
|
3551
|
+
settlement.state !== "expired" &&
|
|
3552
|
+
settlement.state !== "cancelled") ||
|
|
3553
|
+
(settlement.safeErrorCode !== undefined &&
|
|
3554
|
+
!SAFE_CODE_PATTERN.test(settlement.safeErrorCode)) ||
|
|
3555
|
+
byMessageId.has(settlement.messageId)) {
|
|
3556
|
+
throw new BridgeError("INVALID_PAIR_TERMINATION_PLAN", "Unpairing requires one normalized terminal settlement per affected in-flight message.");
|
|
3557
|
+
}
|
|
3558
|
+
byMessageId.set(settlement.messageId, settlement);
|
|
3559
|
+
}
|
|
3560
|
+
if (byMessageId.size !== affectedIds.size ||
|
|
3561
|
+
[...affectedIds].some((messageId) => !byMessageId.has(messageId)) ||
|
|
3562
|
+
[...byMessageId].some(([messageId]) => !affectedIds.has(messageId))) {
|
|
3563
|
+
throw new BridgeError("PAIR_TERMINATION_PLAN_MISMATCH", "The exact pair termination plan no longer matches its in-flight messages.", true);
|
|
3564
|
+
}
|
|
3565
|
+
return byMessageId;
|
|
3566
|
+
}
|
|
3567
|
+
terminateAffectedMessages(state, aliases, now, safeErrorCode, inFlightSettlements) {
|
|
3568
|
+
const settlements = [];
|
|
3569
|
+
const queued = state.queue.filter((item) => routeTerminationMatches(state, aliases, item));
|
|
3570
|
+
state.queue = state.queue.filter((item) => !routeTerminationMatches(state, aliases, item));
|
|
3571
|
+
for (const item of queued) {
|
|
3572
|
+
this.transientBodies.delete(item.messageId);
|
|
3573
|
+
state.accounting.queuedBytes -= item.bytes;
|
|
3574
|
+
const target = state.routes.find((route) => route.alias === item.targetAlias);
|
|
3575
|
+
if (target)
|
|
3576
|
+
target.queueDepth = Math.max(0, target.queueDepth - 1);
|
|
3577
|
+
const deadlineReached = Date.parse(item.deadlineAt) <= now.getTime();
|
|
3578
|
+
settlements.push(this.finishMetadata(state, item, deadlineReached ? "expired" : "abandoned", now, deadlineReached ? "MESSAGE_EXPIRED" : safeErrorCode));
|
|
3579
|
+
}
|
|
3580
|
+
const inFlight = state.inFlight.filter((item) => routeTerminationMatches(state, aliases, item));
|
|
3581
|
+
state.inFlight = state.inFlight.filter((item) => !routeTerminationMatches(state, aliases, item));
|
|
3582
|
+
for (const item of inFlight) {
|
|
3583
|
+
const requested = inFlightSettlements.get(item.messageId);
|
|
3584
|
+
if (requested === undefined) {
|
|
3585
|
+
throw new BridgeError("ROUTE_TERMINATION_PLAN_MISMATCH", "The exact in-flight route termination plan no longer matches the affected message set.", true);
|
|
3586
|
+
}
|
|
3587
|
+
settlements.push(this.finishMetadata(state, item, requested.state, now, requested.safeErrorCode));
|
|
3588
|
+
}
|
|
3589
|
+
return settlements;
|
|
3590
|
+
}
|
|
3591
|
+
terminateAffectedPairMessages(state, pair, now, safeErrorCode, inFlightSettlements) {
|
|
3592
|
+
const settlements = [];
|
|
3593
|
+
const queued = state.queue.filter((item) => pairMatchesMessage(pair, item));
|
|
3594
|
+
state.queue = state.queue.filter((item) => !pairMatchesMessage(pair, item));
|
|
3595
|
+
for (const item of queued) {
|
|
3596
|
+
this.transientBodies.delete(item.messageId);
|
|
3597
|
+
state.accounting.queuedBytes -= item.bytes;
|
|
3598
|
+
const target = state.routes.find((route) => route.alias === item.targetAlias);
|
|
3599
|
+
if (target)
|
|
3600
|
+
target.queueDepth = Math.max(0, target.queueDepth - 1);
|
|
3601
|
+
const deadlineReached = Date.parse(item.deadlineAt) <= now.getTime();
|
|
3602
|
+
settlements.push(this.finishMetadata(state, item, deadlineReached ? "expired" : "abandoned", now, deadlineReached ? "MESSAGE_EXPIRED" : safeErrorCode));
|
|
3603
|
+
}
|
|
3604
|
+
const inFlight = state.inFlight.filter((item) => pairMatchesMessage(pair, item));
|
|
3605
|
+
state.inFlight = state.inFlight.filter((item) => !pairMatchesMessage(pair, item));
|
|
3606
|
+
for (const item of inFlight) {
|
|
3607
|
+
const requested = inFlightSettlements.get(item.messageId);
|
|
3608
|
+
if (requested === undefined) {
|
|
3609
|
+
throw new BridgeError("PAIR_TERMINATION_PLAN_MISMATCH", "The exact pair termination plan no longer matches its in-flight messages.", true);
|
|
3610
|
+
}
|
|
3611
|
+
settlements.push(this.finishMetadata(state, item, requested.state, now, requested.safeErrorCode));
|
|
3612
|
+
}
|
|
3613
|
+
return settlements;
|
|
3614
|
+
}
|
|
3615
|
+
prune(now) {
|
|
3616
|
+
const state = this.requireState();
|
|
3617
|
+
const eventCutoff = now.getTime() - this.config.limits.eventTtlMs;
|
|
3618
|
+
state.events = state.events
|
|
3619
|
+
.filter((event) => Date.parse(event.timestamp) > eventCutoff)
|
|
3620
|
+
.slice(-this.config.limits.eventCapacity);
|
|
3621
|
+
state.progressWatchEvents = state.progressWatchEvents
|
|
3622
|
+
.filter((event) => Date.parse(event.timestamp) > eventCutoff)
|
|
3623
|
+
.slice(-this.config.limits.eventCapacity);
|
|
3624
|
+
state.dedupe = state.dedupe
|
|
3625
|
+
.filter((record) => Date.parse(record.expiresAt) > now.getTime())
|
|
3626
|
+
.slice(-this.config.limits.dedupeCapacity);
|
|
3627
|
+
state.rateBuckets = state.rateBuckets.filter((bucket) => now.getTime() - Date.parse(bucket.windowStartedAt) <
|
|
3628
|
+
this.config.limits.rateWindowMs);
|
|
3629
|
+
}
|
|
3630
|
+
recoverAfterRestart(now) {
|
|
3631
|
+
const state = this.requireState();
|
|
3632
|
+
this.recoverCodexSuccessionAfterRestart(state, now);
|
|
3633
|
+
if (this.config.trackingEnabled === false) {
|
|
3634
|
+
for (const watch of state.progressWatches) {
|
|
3635
|
+
this.appendProgressWatchEvent(state, watch, {
|
|
3636
|
+
kind: "disabled",
|
|
3637
|
+
timestamp: now.toISOString(),
|
|
3638
|
+
});
|
|
3639
|
+
}
|
|
3640
|
+
state.progressWatches = [];
|
|
3641
|
+
}
|
|
3642
|
+
for (let index = 0; index < state.progressWatches.length; index += 1) {
|
|
3643
|
+
const watch = state.progressWatches[index];
|
|
3644
|
+
if (watch === undefined)
|
|
3645
|
+
continue;
|
|
3646
|
+
const transition = transitionProgressWatch(watch, {
|
|
3647
|
+
type: "restart",
|
|
3648
|
+
at: now.getTime(),
|
|
3649
|
+
conversationCapabilityRestored: false,
|
|
3650
|
+
});
|
|
3651
|
+
if (transition.state !== null) {
|
|
3652
|
+
state.progressWatches[index] = transition.state;
|
|
3653
|
+
}
|
|
3654
|
+
if (transition.effects.some((effect) => effect.type === "notify_capability_degraded")) {
|
|
3655
|
+
this.appendProgressWatchEvent(state, transition.state ?? watch, {
|
|
3656
|
+
kind: "capability_degraded",
|
|
3657
|
+
timestamp: now.toISOString(),
|
|
3658
|
+
});
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
for (const connector of state.connectors) {
|
|
3662
|
+
connector.health = "offline";
|
|
3663
|
+
connector.compatibility = "expired";
|
|
3664
|
+
connector.updatedAt = now.toISOString();
|
|
3665
|
+
connector.safeErrorCode = "REOBSERVATION_REQUIRED";
|
|
3666
|
+
}
|
|
3667
|
+
for (const route of state.routes) {
|
|
3668
|
+
route.state = route.enabled ? "stale" : "disabled";
|
|
3669
|
+
route.compatibility = "expired";
|
|
3670
|
+
route.updatedAt = now.toISOString();
|
|
3671
|
+
route.safeErrorCode = "REOBSERVATION_REQUIRED";
|
|
3672
|
+
route.queueDepth = 0;
|
|
3673
|
+
}
|
|
3674
|
+
for (const item of state.queue) {
|
|
3675
|
+
state.accounting.queuedBytes -= item.bytes;
|
|
3676
|
+
this.finishMetadata(state, item, "abandoned", now, "CONTROLLER_RESTARTED");
|
|
3677
|
+
}
|
|
3678
|
+
for (const item of state.inFlight) {
|
|
3679
|
+
this.finishMetadata(state, item, "ambiguous", now, "CONTROLLER_RESTARTED");
|
|
3680
|
+
}
|
|
3681
|
+
state.queue = [];
|
|
3682
|
+
state.inFlight = [];
|
|
3683
|
+
state.accounting.queuedBytes = 0;
|
|
3684
|
+
this.transientBodies.clear();
|
|
3685
|
+
}
|
|
3686
|
+
recoverCodexSuccessionAfterRestart(state, now) {
|
|
3687
|
+
const journal = this.journal(state);
|
|
3688
|
+
if (journal === null || journal.stage === "prepared")
|
|
3689
|
+
return;
|
|
3690
|
+
const route = this.requireAnyJournalRoute(state, journal);
|
|
3691
|
+
this.assertSuccessionLedgerEmpty(state, route.alias);
|
|
3692
|
+
this.assertNewSuccessionIdentityAvailable(state, route, journal.new);
|
|
3693
|
+
this.replaceCodexRouteForSuccession(state, route, journal.new, now, "stale", "expired");
|
|
3694
|
+
state.codexSuccession = {
|
|
3695
|
+
...journal,
|
|
3696
|
+
stage: "recovery_forbidden",
|
|
3697
|
+
safeErrorCode: "CODEX_SUCCESSION_RESTART_RECOVERY_REQUIRED",
|
|
3698
|
+
};
|
|
3699
|
+
}
|
|
3700
|
+
aggregateHealth(connectors) {
|
|
3701
|
+
if (connectors.length === 0)
|
|
3702
|
+
return "offline";
|
|
3703
|
+
if (connectors.some((connector) => connector.health === "incompatible")) {
|
|
3704
|
+
return "incompatible";
|
|
3705
|
+
}
|
|
3706
|
+
if (connectors.some((connector) => connector.health === "degraded")) {
|
|
3707
|
+
return "degraded";
|
|
3708
|
+
}
|
|
3709
|
+
if (connectors.some((connector) => connector.health === "connecting")) {
|
|
3710
|
+
return "connecting";
|
|
3711
|
+
}
|
|
3712
|
+
if (connectors.every((connector) => connector.health === "healthy")) {
|
|
3713
|
+
return "healthy";
|
|
3714
|
+
}
|
|
3715
|
+
return "offline";
|
|
3716
|
+
}
|
|
3717
|
+
async prepareOwnedDirectory() {
|
|
3718
|
+
const requested = path.resolve(this.rootDir);
|
|
3719
|
+
await assertNoSymlinkComponents(requested);
|
|
3720
|
+
const canonical = await canonicalFuturePath(requested);
|
|
3721
|
+
const home = await realpath(os.homedir()).catch(() => path.resolve(os.homedir()));
|
|
3722
|
+
const temporaryRoot = await realpath(os.tmpdir()).catch(() => path.resolve(os.tmpdir()));
|
|
3723
|
+
if (canonical === path.parse(canonical).root ||
|
|
3724
|
+
canonical === home ||
|
|
3725
|
+
canonical === temporaryRoot) {
|
|
3726
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The gateway state directory must be a dedicated private leaf.");
|
|
3727
|
+
}
|
|
3728
|
+
let existed = true;
|
|
3729
|
+
try {
|
|
3730
|
+
const info = await lstat(canonical);
|
|
3731
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
3732
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The gateway state path must be a real directory.");
|
|
3733
|
+
}
|
|
3734
|
+
this.assertOwnedPrivate(info.uid, info.mode, "directory");
|
|
3735
|
+
}
|
|
3736
|
+
catch (error) {
|
|
3737
|
+
if (error instanceof Error &&
|
|
3738
|
+
"code" in error &&
|
|
3739
|
+
error.code === "ENOENT") {
|
|
3740
|
+
existed = false;
|
|
3741
|
+
}
|
|
3742
|
+
else {
|
|
3743
|
+
throw error;
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
if (!existed) {
|
|
3747
|
+
await mkdir(canonical, { recursive: true, mode: 0o700 });
|
|
3748
|
+
await chmod(canonical, 0o700);
|
|
3749
|
+
}
|
|
3750
|
+
const root = await realpath(canonical);
|
|
3751
|
+
if (root !== canonical) {
|
|
3752
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", "The gateway state path changed while it was being prepared.");
|
|
3753
|
+
}
|
|
3754
|
+
const markerPath = path.join(root, STATE_MARKER);
|
|
3755
|
+
let markerExists = true;
|
|
3756
|
+
try {
|
|
3757
|
+
await this.readPrivateFile(markerPath, MAX_MARKER_FILE_BYTES, STATE_MARKER_CONTENT);
|
|
3758
|
+
}
|
|
3759
|
+
catch (error) {
|
|
3760
|
+
if (error instanceof Error &&
|
|
3761
|
+
"code" in error &&
|
|
3762
|
+
error.code === "ENOENT") {
|
|
3763
|
+
markerExists = false;
|
|
3764
|
+
}
|
|
3765
|
+
else {
|
|
3766
|
+
throw error;
|
|
3767
|
+
}
|
|
3768
|
+
}
|
|
3769
|
+
if (!markerExists) {
|
|
3770
|
+
const entries = await readdir(root);
|
|
3771
|
+
if (existed && entries.length > 0) {
|
|
3772
|
+
throw new BridgeError("GATEWAY_STATE_DIRECTORY_NOT_OWNED", "The existing gateway state directory is non-empty and has no gateway ownership marker.");
|
|
3773
|
+
}
|
|
3774
|
+
const marker = await open(markerPath, constants.O_CREAT |
|
|
3775
|
+
constants.O_EXCL |
|
|
3776
|
+
constants.O_WRONLY |
|
|
3777
|
+
(constants.O_NOFOLLOW ?? 0), 0o600);
|
|
3778
|
+
try {
|
|
3779
|
+
await marker.writeFile(STATE_MARKER_CONTENT, "utf8");
|
|
3780
|
+
await marker.sync();
|
|
3781
|
+
}
|
|
3782
|
+
finally {
|
|
3783
|
+
await marker.close();
|
|
3784
|
+
}
|
|
3785
|
+
await chmod(markerPath, 0o600);
|
|
3786
|
+
}
|
|
3787
|
+
return root;
|
|
3788
|
+
}
|
|
3789
|
+
assertOwnedPrivate(uid, mode, kind) {
|
|
3790
|
+
if (typeof process.getuid === "function" && uid !== process.getuid()) {
|
|
3791
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", `The gateway ${kind} is not owned by the current process user.`);
|
|
3792
|
+
}
|
|
3793
|
+
if ((mode & 0o077) !== 0) {
|
|
3794
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_DIRECTORY", `The gateway ${kind} must not grant group or other permissions.`);
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
async readPrivateFile(filePath, maximumBytes, expectedBody) {
|
|
3798
|
+
const info = await lstat(filePath);
|
|
3799
|
+
if (info.isSymbolicLink() || !info.isFile()) {
|
|
3800
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_FILE", "A gateway controller file is not a regular file.");
|
|
3801
|
+
}
|
|
3802
|
+
this.assertOwnedPrivate(info.uid, info.mode, "state file");
|
|
3803
|
+
if (!Number.isSafeInteger(maximumBytes) ||
|
|
3804
|
+
maximumBytes < 1 ||
|
|
3805
|
+
info.size > maximumBytes) {
|
|
3806
|
+
throw new BridgeError("GATEWAY_STATE_FILE_TOO_LARGE", "A gateway controller file exceeds its strict byte limit.");
|
|
3807
|
+
}
|
|
3808
|
+
const handle = await open(filePath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
3809
|
+
try {
|
|
3810
|
+
const opened = await handle.stat();
|
|
3811
|
+
if (!opened.isFile() ||
|
|
3812
|
+
opened.dev !== info.dev ||
|
|
3813
|
+
opened.ino !== info.ino ||
|
|
3814
|
+
opened.size > maximumBytes) {
|
|
3815
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_FILE", "A gateway controller file changed during its bounded read.");
|
|
3816
|
+
}
|
|
3817
|
+
this.assertOwnedPrivate(opened.uid, opened.mode, "state file");
|
|
3818
|
+
const buffer = Buffer.alloc(maximumBytes + 1);
|
|
3819
|
+
let offset = 0;
|
|
3820
|
+
while (offset < buffer.length) {
|
|
3821
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset);
|
|
3822
|
+
if (bytesRead === 0)
|
|
3823
|
+
break;
|
|
3824
|
+
offset += bytesRead;
|
|
3825
|
+
}
|
|
3826
|
+
if (offset > maximumBytes) {
|
|
3827
|
+
throw new BridgeError("GATEWAY_STATE_FILE_TOO_LARGE", "A gateway controller file exceeds its strict byte limit.");
|
|
3828
|
+
}
|
|
3829
|
+
const body = buffer.subarray(0, offset).toString("utf8");
|
|
3830
|
+
if (expectedBody !== undefined && body !== expectedBody) {
|
|
3831
|
+
throw new BridgeError("GATEWAY_STATE_DIRECTORY_NOT_OWNED", "The gateway ownership marker is not recognized.");
|
|
3832
|
+
}
|
|
3833
|
+
return body;
|
|
3834
|
+
}
|
|
3835
|
+
finally {
|
|
3836
|
+
await handle.close();
|
|
3837
|
+
}
|
|
3838
|
+
}
|
|
3839
|
+
async acquireLock() {
|
|
3840
|
+
const lockPath = path.join(this.rootDir, CONTROLLER_LOCK);
|
|
3841
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
3842
|
+
const token = randomUUID();
|
|
3843
|
+
try {
|
|
3844
|
+
const handle = await open(lockPath, constants.O_CREAT |
|
|
3845
|
+
constants.O_EXCL |
|
|
3846
|
+
constants.O_RDWR |
|
|
3847
|
+
(constants.O_NOFOLLOW ?? 0), 0o600);
|
|
3848
|
+
try {
|
|
3849
|
+
await handle.writeFile(`${JSON.stringify({
|
|
3850
|
+
schemaVersion: 1,
|
|
3851
|
+
pid: process.pid,
|
|
3852
|
+
hostname: os.hostname(),
|
|
3853
|
+
token,
|
|
3854
|
+
})}\n`, "utf8");
|
|
3855
|
+
await handle.sync();
|
|
3856
|
+
}
|
|
3857
|
+
catch (error) {
|
|
3858
|
+
await handle.close().catch(() => undefined);
|
|
3859
|
+
await unlink(lockPath).catch(() => undefined);
|
|
3860
|
+
throw error;
|
|
3861
|
+
}
|
|
3862
|
+
this.lockHandle = handle;
|
|
3863
|
+
this.lockToken = token;
|
|
3864
|
+
return;
|
|
3865
|
+
}
|
|
3866
|
+
catch (error) {
|
|
3867
|
+
if (!(error instanceof Error) ||
|
|
3868
|
+
!("code" in error) ||
|
|
3869
|
+
error.code !== "EEXIST") {
|
|
3870
|
+
throw error;
|
|
3871
|
+
}
|
|
3872
|
+
}
|
|
3873
|
+
let owner;
|
|
3874
|
+
try {
|
|
3875
|
+
owner = JSON.parse(await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES));
|
|
3876
|
+
}
|
|
3877
|
+
catch {
|
|
3878
|
+
throw new BridgeError("GATEWAY_STATE_LOCK_UNVERIFIED", "The gateway state lock exists but cannot be safely verified.");
|
|
3879
|
+
}
|
|
3880
|
+
if (owner.hostname !== os.hostname() ||
|
|
3881
|
+
!isPositiveInteger(owner.pid)) {
|
|
3882
|
+
throw new BridgeError("GATEWAY_STATE_IN_USE", "The gateway state directory is locked by another or unverifiable host process.", true);
|
|
3883
|
+
}
|
|
3884
|
+
let alive = true;
|
|
3885
|
+
try {
|
|
3886
|
+
process.kill(owner.pid, 0);
|
|
3887
|
+
}
|
|
3888
|
+
catch (error) {
|
|
3889
|
+
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
|
|
3890
|
+
alive = false;
|
|
3891
|
+
}
|
|
3892
|
+
}
|
|
3893
|
+
if (alive) {
|
|
3894
|
+
throw new BridgeError("GATEWAY_STATE_IN_USE", "Another live gateway controller owns this state directory.", true);
|
|
3895
|
+
}
|
|
3896
|
+
await rename(lockPath, path.join(this.rootDir, `.gateway-controller.lock.stale-${randomUUID()}`)).catch((error) => {
|
|
3897
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
3898
|
+
throw error;
|
|
3899
|
+
}
|
|
3900
|
+
});
|
|
3901
|
+
}
|
|
3902
|
+
throw new BridgeError("GATEWAY_STATE_IN_USE", "Could not acquire exclusive gateway controller ownership.", true);
|
|
3903
|
+
}
|
|
3904
|
+
async releaseControllerLock() {
|
|
3905
|
+
const handle = this.lockHandle;
|
|
3906
|
+
const token = this.lockToken;
|
|
3907
|
+
this.lockHandle = undefined;
|
|
3908
|
+
this.lockToken = undefined;
|
|
3909
|
+
if (!handle || !token)
|
|
3910
|
+
return;
|
|
3911
|
+
await handle.close().catch(() => undefined);
|
|
3912
|
+
const lockPath = path.join(this.rootDir, CONTROLLER_LOCK);
|
|
3913
|
+
try {
|
|
3914
|
+
const parsed = JSON.parse(await this.readPrivateFile(lockPath, MAX_LOCK_FILE_BYTES));
|
|
3915
|
+
if (parsed.token === token)
|
|
3916
|
+
await unlink(lockPath);
|
|
3917
|
+
}
|
|
3918
|
+
catch {
|
|
3919
|
+
// Never remove a lock that cannot be proven to belong to this instance.
|
|
3920
|
+
}
|
|
3921
|
+
}
|
|
3922
|
+
async loadStateFile() {
|
|
3923
|
+
let body;
|
|
3924
|
+
try {
|
|
3925
|
+
body = await this.readPrivateFile(this.stateFilePath, GATEWAY_MAX_STATE_FILE_BYTES);
|
|
3926
|
+
}
|
|
3927
|
+
catch (error) {
|
|
3928
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
3929
|
+
return undefined;
|
|
3930
|
+
}
|
|
3931
|
+
throw error;
|
|
3932
|
+
}
|
|
3933
|
+
let parsed;
|
|
3934
|
+
try {
|
|
3935
|
+
parsed = JSON.parse(body);
|
|
3936
|
+
}
|
|
3937
|
+
catch {
|
|
3938
|
+
throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state is not valid JSON.");
|
|
3939
|
+
}
|
|
3940
|
+
parsed = migratePreUnconfirmedCounters(parsed);
|
|
3941
|
+
parsed = migratePreSuccessionJournal(parsed);
|
|
3942
|
+
parsed = migratePrePairGraph(parsed);
|
|
3943
|
+
parsed = migratePreProgressWatches(parsed);
|
|
3944
|
+
parsed = migratePreCompatibilityAttestations(parsed);
|
|
3945
|
+
if (!isPersistedState(parsed)) {
|
|
3946
|
+
throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway controller state failed strict schema validation.");
|
|
3947
|
+
}
|
|
3948
|
+
if (parsed.routes.some((route) => !this.config.allowedHosts.includes(route.binding.hostId)) ||
|
|
3949
|
+
parsed.connectors.some((connector) => !this.config.allowedHosts.includes(connector.hostId)) ||
|
|
3950
|
+
parsed.routes.length > this.config.limits.maxRoutes ||
|
|
3951
|
+
parsed.connectors.length > this.config.allowedHosts.length * 2 ||
|
|
3952
|
+
parsed.rateBuckets.length > this.config.limits.maxRoutes ||
|
|
3953
|
+
parsed.progressWatches.length >
|
|
3954
|
+
(this.config.limits.maxWatches ?? PROGRESS_WATCH_DEFAULT_CAPACITY) ||
|
|
3955
|
+
parsed.progressWatchEvents.length > this.config.limits.eventCapacity ||
|
|
3956
|
+
parsed.events.length > this.config.limits.eventCapacity ||
|
|
3957
|
+
parsed.dedupe.length > this.config.limits.dedupeCapacity ||
|
|
3958
|
+
parsed.queue.length > this.config.limits.maxQueueMessages ||
|
|
3959
|
+
parsed.inFlight.length > this.config.limits.maxInFlightMessages ||
|
|
3960
|
+
parsed.queue.length + parsed.inFlight.length >
|
|
3961
|
+
this.config.limits.maxQueueMessages ||
|
|
3962
|
+
parsed.queue.some((item) => item.bytes > this.config.limits.maxMessageBytes) ||
|
|
3963
|
+
parsed.accounting.queuedBytes > this.config.limits.maxQueueBytes) {
|
|
3964
|
+
throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway state exceeds its current allowlist or configured bounds.");
|
|
3965
|
+
}
|
|
3966
|
+
return parsed;
|
|
3967
|
+
}
|
|
3968
|
+
async persist(force = false) {
|
|
3969
|
+
if (this.persistenceDeferred && !force)
|
|
3970
|
+
return;
|
|
3971
|
+
const state = this.requireState();
|
|
3972
|
+
if (!isPersistedState(state)) {
|
|
3973
|
+
throw new BridgeError("CORRUPT_GATEWAY_STATE", "The gateway refused to persist internally inconsistent state.");
|
|
3974
|
+
}
|
|
3975
|
+
const temporary = path.join(this.rootDir, `.gateway-state-${randomUUID()}.tmp`);
|
|
3976
|
+
const body = `${JSON.stringify(state, null, 2)}\n`;
|
|
3977
|
+
if (Buffer.byteLength(body, "utf8") > GATEWAY_MAX_STATE_FILE_BYTES) {
|
|
3978
|
+
throw new BridgeError("GATEWAY_STATE_FILE_TOO_LARGE", "The bounded gateway controller state exceeds its durable byte limit.");
|
|
3979
|
+
}
|
|
3980
|
+
let handle;
|
|
3981
|
+
let renamed = false;
|
|
3982
|
+
try {
|
|
3983
|
+
try {
|
|
3984
|
+
handle = await open(temporary, constants.O_CREAT |
|
|
3985
|
+
constants.O_EXCL |
|
|
3986
|
+
constants.O_WRONLY |
|
|
3987
|
+
(constants.O_NOFOLLOW ?? 0), 0o600);
|
|
3988
|
+
await handle.writeFile(body, "utf8");
|
|
3989
|
+
await handle.chmod(0o600);
|
|
3990
|
+
await handle.sync();
|
|
3991
|
+
await handle.close();
|
|
3992
|
+
handle = undefined;
|
|
3993
|
+
try {
|
|
3994
|
+
const existing = await lstat(this.stateFilePath);
|
|
3995
|
+
if (existing.isSymbolicLink() || !existing.isFile()) {
|
|
3996
|
+
throw new BridgeError("UNSAFE_GATEWAY_STATE_FILE", "The gateway state target is not a regular file.");
|
|
3997
|
+
}
|
|
3998
|
+
this.assertOwnedPrivate(existing.uid, existing.mode, "state file");
|
|
3999
|
+
}
|
|
4000
|
+
catch (error) {
|
|
4001
|
+
if (!(error instanceof Error) ||
|
|
4002
|
+
!("code" in error) ||
|
|
4003
|
+
error.code !== "ENOENT") {
|
|
4004
|
+
throw error;
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
await rename(temporary, this.stateFilePath);
|
|
4008
|
+
renamed = true;
|
|
4009
|
+
await this.afterStateFileRename?.();
|
|
4010
|
+
const directory = await open(this.rootDir, constants.O_RDONLY);
|
|
4011
|
+
try {
|
|
4012
|
+
await directory.sync();
|
|
4013
|
+
}
|
|
4014
|
+
finally {
|
|
4015
|
+
await directory.close();
|
|
4016
|
+
}
|
|
4017
|
+
}
|
|
4018
|
+
catch (error) {
|
|
4019
|
+
if (!renamed)
|
|
4020
|
+
throw error;
|
|
4021
|
+
let installed;
|
|
4022
|
+
try {
|
|
4023
|
+
installed = await this.loadStateFile();
|
|
4024
|
+
}
|
|
4025
|
+
catch {
|
|
4026
|
+
// Rename crossed the commit point. Keep the intended in-memory
|
|
4027
|
+
// authority even if verification itself is unavailable; never
|
|
4028
|
+
// manufacture an old-state rollback after an installed rename.
|
|
4029
|
+
}
|
|
4030
|
+
const verified = installed !== undefined &&
|
|
4031
|
+
JSON.stringify(installed) === JSON.stringify(state);
|
|
4032
|
+
if (verified && installed !== undefined) {
|
|
4033
|
+
this.state = installed;
|
|
4034
|
+
}
|
|
4035
|
+
else {
|
|
4036
|
+
this.state = undefined;
|
|
4037
|
+
}
|
|
4038
|
+
throw new PostRenamePersistenceError(verified);
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
finally {
|
|
4042
|
+
await handle?.close().catch(() => undefined);
|
|
4043
|
+
await unlink(temporary).catch(() => undefined);
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
//# sourceMappingURL=store.js.map
|