handmux 0.24.0 → 0.25.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/README.md +29 -6
- package/README.zh-CN.md +29 -6
- package/bin/handmux.js +15 -3
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +200 -35
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +45 -3
- package/dist/hooks/handmux-write.cjs +175 -10
- package/dist/package.json +10 -9
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +22 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +52 -7
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +27 -9
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +17 -3
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +11 -2
- package/dist/src/claudeEvents.js +35 -9
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +49 -3
- package/dist/src/cli/codexManaged.js +6 -3
- package/dist/src/cli/hookScaffold.js +4 -1
- package/dist/src/cli/i18n/en.js +30 -4
- package/dist/src/cli/i18n/zh.js +30 -4
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/service.js +19 -9
- package/dist/src/cli/state.js +3 -0
- package/dist/src/cli/statusLine.js +19 -11
- package/dist/src/codexAppServer.js +1296 -159
- package/dist/src/codexQueueProtocol.js +41 -0
- package/dist/src/codexTranscriptParse.js +256 -145
- package/dist/src/codexUsageSnapshot.js +29 -6
- package/dist/src/git.js +13 -1
- package/dist/src/httpApi.js +35 -7
- package/dist/src/orphans.js +13 -3
- package/dist/src/paneInput.js +42 -0
- package/dist/src/privateStateStore.js +35 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/git.js +11 -0
- package/dist/src/routes/sessions.js +16 -4
- package/dist/src/routes/system.js +69 -5
- package/dist/src/routes/terminal.js +22 -16
- package/dist/src/server.js +109 -6
- package/dist/src/tmux/commands.js +29 -1
- package/dist/src/transcriptParse.js +22 -1
- package/dist/src/transcriptReader.js +100 -4
- package/dist/src/uploadTypes.js +2 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +103 -6
- package/dist/src/workspace/restore.js +51 -2
- package/dist/src/workspace/tmuxAdapter.js +10 -0
- package/package.json +6 -5
- package/dist/public/assets/index-CicdY9E9.css +0 -32
- package/dist/public/assets/index-D4VP5t8h.js +0 -337
- package/dist/src/routes/codex.js +0 -606
- package/dist/src/routes/transcript.js +0 -197
- package/dist/src/usage.js +0 -260
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PrivateStateStore } from '../src/privateStateStore.js';
|
|
4
|
+
import { connectBridgeTransport } from '../src/agent-runtime/bridgeTransport.js';
|
|
5
|
+
const NAME_RE = /^[a-z][a-z0-9._-]{0,63}$/;
|
|
6
|
+
const EVENT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,255}$/;
|
|
7
|
+
function isRecord(value) {
|
|
8
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function parseState(value) {
|
|
11
|
+
if (!isRecord(value) || value.version !== 1 || !isRecord(value.snapshots)
|
|
12
|
+
|| !Array.isArray(value.durable)) {
|
|
13
|
+
return { version: 1, snapshots: {}, durable: [] };
|
|
14
|
+
}
|
|
15
|
+
const durable = [];
|
|
16
|
+
const ids = new Set();
|
|
17
|
+
for (const item of value.durable) {
|
|
18
|
+
if (!isRecord(item) || typeof item.channel !== 'string' || !NAME_RE.test(item.channel)
|
|
19
|
+
|| typeof item.eventId !== 'string' || !EVENT_ID_RE.test(item.eventId)
|
|
20
|
+
|| ids.has(`${item.channel}\0${item.eventId}`))
|
|
21
|
+
continue;
|
|
22
|
+
ids.add(`${item.channel}\0${item.eventId}`);
|
|
23
|
+
durable.push({ channel: item.channel, eventId: item.eventId, payload: item.payload });
|
|
24
|
+
}
|
|
25
|
+
return { version: 1, snapshots: structuredClone(value.snapshots), durable };
|
|
26
|
+
}
|
|
27
|
+
function credential(file) {
|
|
28
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
29
|
+
if (!isRecord(value) || value.version !== 1 || typeof value.authToken !== 'string'
|
|
30
|
+
|| value.authToken.length < 32 || value.authToken.length > 1024) {
|
|
31
|
+
throw new Error('Handmux Agent Runtime credential is unavailable or invalid');
|
|
32
|
+
}
|
|
33
|
+
return value.authToken;
|
|
34
|
+
}
|
|
35
|
+
function samePayload(first, second) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.stringify(first) === JSON.stringify(second);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function durableKey(channel, eventId) {
|
|
44
|
+
return `${channel}\0${eventId}`;
|
|
45
|
+
}
|
|
46
|
+
// Shared only by Handmux-owned built-in Connectors. Provider callbacks enqueue local state synchronously;
|
|
47
|
+
// socket retries and ordered flushing stay here so every Connector gets the same offline semantics.
|
|
48
|
+
export class LocalConnectorBridgeClient {
|
|
49
|
+
#adapterId;
|
|
50
|
+
#socketPath;
|
|
51
|
+
#credentialFile;
|
|
52
|
+
#candidate;
|
|
53
|
+
#connect;
|
|
54
|
+
#retryDelayMs;
|
|
55
|
+
#maxRetryDelayMs;
|
|
56
|
+
#maxEphemeralPerChannel;
|
|
57
|
+
#logger;
|
|
58
|
+
#store;
|
|
59
|
+
#snapshots = new Map();
|
|
60
|
+
#dirtySnapshots = new Set();
|
|
61
|
+
#unpersistedSnapshots = new Set();
|
|
62
|
+
#ephemeral = new Map();
|
|
63
|
+
#dropped = new Set();
|
|
64
|
+
#handlers = new Map();
|
|
65
|
+
#durableWaiters = new Map();
|
|
66
|
+
#state;
|
|
67
|
+
#generation;
|
|
68
|
+
#connection;
|
|
69
|
+
#retryTimer;
|
|
70
|
+
#retryAttempt = 0;
|
|
71
|
+
#flushTail = Promise.resolve();
|
|
72
|
+
#started = false;
|
|
73
|
+
#closed = false;
|
|
74
|
+
constructor({ adapterId, socketPath, credentialFile, stateFile, candidate, generation, connect = connectBridgeTransport, retryDelayMs = 250, maxRetryDelayMs = 5_000, maxEphemeralPerChannel = 256, logger = () => { }, }) {
|
|
75
|
+
if (!NAME_RE.test(adapterId) || !path.isAbsolute(socketPath)
|
|
76
|
+
|| !path.isAbsolute(credentialFile) || !path.isAbsolute(stateFile)
|
|
77
|
+
|| !candidate || !Number.isSafeInteger(retryDelayMs) || retryDelayMs <= 0
|
|
78
|
+
|| !Number.isSafeInteger(maxRetryDelayMs) || maxRetryDelayMs < retryDelayMs
|
|
79
|
+
|| !Number.isSafeInteger(maxEphemeralPerChannel) || maxEphemeralPerChannel <= 0) {
|
|
80
|
+
throw new TypeError('Connector Bridge client requires an adapter, private paths, and bounded retries');
|
|
81
|
+
}
|
|
82
|
+
this.#adapterId = adapterId;
|
|
83
|
+
this.#socketPath = socketPath;
|
|
84
|
+
this.#credentialFile = credentialFile;
|
|
85
|
+
this.#candidate = structuredClone(candidate);
|
|
86
|
+
this.#generation = generation;
|
|
87
|
+
this.#connect = connect;
|
|
88
|
+
this.#retryDelayMs = retryDelayMs;
|
|
89
|
+
this.#maxRetryDelayMs = maxRetryDelayMs;
|
|
90
|
+
this.#maxEphemeralPerChannel = maxEphemeralPerChannel;
|
|
91
|
+
this.#logger = logger;
|
|
92
|
+
this.#store = new PrivateStateStore(stateFile);
|
|
93
|
+
this.#state = parseState(this.#store.read());
|
|
94
|
+
for (const [channel, value] of Object.entries(this.#state.snapshots)) {
|
|
95
|
+
if (!NAME_RE.test(channel))
|
|
96
|
+
continue;
|
|
97
|
+
this.#snapshots.set(channel, { revision: 1, value });
|
|
98
|
+
this.#dirtySnapshots.add(channel);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
start() {
|
|
102
|
+
if (this.#closed)
|
|
103
|
+
throw new Error('Connector Bridge client is closed');
|
|
104
|
+
if (this.#started)
|
|
105
|
+
return;
|
|
106
|
+
this.#started = true;
|
|
107
|
+
this.#scheduleConnect(0);
|
|
108
|
+
}
|
|
109
|
+
setSnapshot(channel, value) {
|
|
110
|
+
this.#assertName(channel);
|
|
111
|
+
if (this.#closed)
|
|
112
|
+
return false;
|
|
113
|
+
const previous = this.#snapshots.get(channel);
|
|
114
|
+
if (previous && samePayload(previous.value, value)) {
|
|
115
|
+
const persisted = !this.#unpersistedSnapshots.has(channel) || this.#persist();
|
|
116
|
+
if (persisted)
|
|
117
|
+
this.#unpersistedSnapshots.delete(channel);
|
|
118
|
+
this.#kickFlush();
|
|
119
|
+
return persisted;
|
|
120
|
+
}
|
|
121
|
+
this.#snapshots.set(channel, { revision: (previous?.revision ?? 0) + 1, value: structuredClone(value) });
|
|
122
|
+
this.#dirtySnapshots.add(channel);
|
|
123
|
+
this.#state.snapshots[channel] = structuredClone(value);
|
|
124
|
+
const persisted = this.#persist();
|
|
125
|
+
if (persisted)
|
|
126
|
+
this.#unpersistedSnapshots.delete(channel);
|
|
127
|
+
else
|
|
128
|
+
this.#unpersistedSnapshots.add(channel);
|
|
129
|
+
this.#kickFlush();
|
|
130
|
+
return persisted;
|
|
131
|
+
}
|
|
132
|
+
publishDurable(channel, eventId, payload) {
|
|
133
|
+
this.#assertName(channel);
|
|
134
|
+
if (!EVENT_ID_RE.test(eventId))
|
|
135
|
+
throw new Error('Durable event requires a stable bounded eventId');
|
|
136
|
+
if (this.#closed)
|
|
137
|
+
return false;
|
|
138
|
+
const existing = this.#state.durable.find((item) => (item.channel === channel && item.eventId === eventId));
|
|
139
|
+
if (existing) {
|
|
140
|
+
if (!samePayload(existing.payload, payload)) {
|
|
141
|
+
throw new Error('Durable eventId was reused with a different payload');
|
|
142
|
+
}
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
const item = { channel, eventId, payload: structuredClone(payload) };
|
|
146
|
+
this.#state.durable.push(item);
|
|
147
|
+
if (!this.#persist()) {
|
|
148
|
+
const index = this.#state.durable.indexOf(item);
|
|
149
|
+
if (index !== -1)
|
|
150
|
+
this.#state.durable.splice(index, 1);
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
this.#kickFlush();
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
waitForDurableAck(channel, eventId) {
|
|
157
|
+
this.#assertName(channel);
|
|
158
|
+
if (!EVENT_ID_RE.test(eventId))
|
|
159
|
+
return Promise.reject(new Error('Invalid durable eventId'));
|
|
160
|
+
if (!this.#state.durable.some((item) => item.channel === channel && item.eventId === eventId)) {
|
|
161
|
+
return Promise.resolve();
|
|
162
|
+
}
|
|
163
|
+
if (this.#closed)
|
|
164
|
+
return Promise.reject(new Error('Connector Bridge client is closed'));
|
|
165
|
+
const key = durableKey(channel, eventId);
|
|
166
|
+
return new Promise((resolve, reject) => {
|
|
167
|
+
const waiters = this.#durableWaiters.get(key) ?? new Set();
|
|
168
|
+
if (!this.#durableWaiters.has(key))
|
|
169
|
+
this.#durableWaiters.set(key, waiters);
|
|
170
|
+
waiters.add({ resolve, reject });
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
waitForDurableDrain() {
|
|
174
|
+
if (this.#closed)
|
|
175
|
+
return Promise.reject(new Error('Connector Bridge client is closed'));
|
|
176
|
+
const pending = this.#state.durable.map(({ channel, eventId }) => (this.waitForDurableAck(channel, eventId)));
|
|
177
|
+
return Promise.all(pending).then(() => undefined);
|
|
178
|
+
}
|
|
179
|
+
closeAndRemoveIfDrained() {
|
|
180
|
+
if (this.#state.durable.length !== 0)
|
|
181
|
+
return false;
|
|
182
|
+
this.close();
|
|
183
|
+
try {
|
|
184
|
+
this.#store.remove();
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
this.#logger('Drained Connector Bridge state could not be removed', error);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
publishEphemeral(channel, payload) {
|
|
193
|
+
this.#assertName(channel);
|
|
194
|
+
if (this.#closed)
|
|
195
|
+
return;
|
|
196
|
+
const queue = this.#ephemeral.get(channel) ?? [];
|
|
197
|
+
if (!this.#ephemeral.has(channel))
|
|
198
|
+
this.#ephemeral.set(channel, queue);
|
|
199
|
+
if (queue.length >= this.#maxEphemeralPerChannel) {
|
|
200
|
+
queue.shift();
|
|
201
|
+
this.#dropped.add(channel);
|
|
202
|
+
}
|
|
203
|
+
queue.push({ payload: structuredClone(payload) });
|
|
204
|
+
this.#kickFlush();
|
|
205
|
+
}
|
|
206
|
+
handle(channel, method, handler) {
|
|
207
|
+
this.#assertName(channel);
|
|
208
|
+
this.#assertName(method);
|
|
209
|
+
if (typeof handler !== 'function' || this.#closed)
|
|
210
|
+
throw new Error('Invalid Connector Bridge handler');
|
|
211
|
+
const key = `${channel}\0${method}`;
|
|
212
|
+
if (this.#handlers.has(key))
|
|
213
|
+
throw new Error('Connector Bridge handler is already registered');
|
|
214
|
+
const wrapped = async (payload, context) => handler(payload, context);
|
|
215
|
+
const entry = { channel, method, handler: wrapped };
|
|
216
|
+
this.#handlers.set(key, entry);
|
|
217
|
+
if (this.#connection) {
|
|
218
|
+
void this.#connection.channel(channel).handle(method, wrapped).catch((error) => {
|
|
219
|
+
this.#logger(`Failed to register Bridge handler ${channel}.${method}`, error);
|
|
220
|
+
this.#connection?.close();
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return () => { if (this.#handlers.get(key) === entry)
|
|
224
|
+
this.#handlers.delete(key); };
|
|
225
|
+
}
|
|
226
|
+
close() {
|
|
227
|
+
if (this.#closed)
|
|
228
|
+
return;
|
|
229
|
+
this.#closed = true;
|
|
230
|
+
if (this.#retryTimer)
|
|
231
|
+
clearTimeout(this.#retryTimer);
|
|
232
|
+
this.#retryTimer = undefined;
|
|
233
|
+
this.#connection?.close();
|
|
234
|
+
this.#connection = undefined;
|
|
235
|
+
const error = new Error('Connector Bridge client is closed');
|
|
236
|
+
for (const waiters of this.#durableWaiters.values()) {
|
|
237
|
+
for (const waiter of waiters)
|
|
238
|
+
waiter.reject(error);
|
|
239
|
+
}
|
|
240
|
+
this.#durableWaiters.clear();
|
|
241
|
+
}
|
|
242
|
+
#assertName(value) {
|
|
243
|
+
if (!NAME_RE.test(value))
|
|
244
|
+
throw new Error('Invalid Bridge channel or method name');
|
|
245
|
+
}
|
|
246
|
+
#persist() {
|
|
247
|
+
try {
|
|
248
|
+
this.#store.write(this.#state);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
this.#logger('Connector Bridge local state could not be persisted', error);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
#resolveDurable(channel, eventId) {
|
|
257
|
+
const key = durableKey(channel, eventId);
|
|
258
|
+
const waiters = this.#durableWaiters.get(key);
|
|
259
|
+
if (!waiters)
|
|
260
|
+
return;
|
|
261
|
+
this.#durableWaiters.delete(key);
|
|
262
|
+
for (const waiter of waiters)
|
|
263
|
+
waiter.resolve();
|
|
264
|
+
}
|
|
265
|
+
#invalidateEphemeral() {
|
|
266
|
+
for (const [channel, queue] of this.#ephemeral) {
|
|
267
|
+
if (!queue.length)
|
|
268
|
+
continue;
|
|
269
|
+
queue.splice(0);
|
|
270
|
+
this.#dropped.add(channel);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
#scheduleConnect(delay) {
|
|
274
|
+
if (!this.#started || this.#closed || this.#connection || this.#retryTimer)
|
|
275
|
+
return;
|
|
276
|
+
this.#retryTimer = setTimeout(() => {
|
|
277
|
+
this.#retryTimer = undefined;
|
|
278
|
+
void this.#open();
|
|
279
|
+
}, delay);
|
|
280
|
+
this.#retryTimer.unref?.();
|
|
281
|
+
}
|
|
282
|
+
async #open() {
|
|
283
|
+
if (this.#closed || this.#connection)
|
|
284
|
+
return;
|
|
285
|
+
try {
|
|
286
|
+
const connection = await this.#connect({
|
|
287
|
+
socketPath: this.#socketPath,
|
|
288
|
+
authToken: credential(this.#credentialFile),
|
|
289
|
+
adapterId: this.#adapterId,
|
|
290
|
+
candidate: this.#candidate,
|
|
291
|
+
...(this.#generation === undefined ? {} : { generation: this.#generation }),
|
|
292
|
+
});
|
|
293
|
+
if (this.#closed) {
|
|
294
|
+
connection.close();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
this.#connection = connection;
|
|
298
|
+
this.#generation = undefined;
|
|
299
|
+
this.#retryAttempt = 0;
|
|
300
|
+
for (const channel of this.#snapshots.keys())
|
|
301
|
+
this.#dirtySnapshots.add(channel);
|
|
302
|
+
for (const entry of this.#handlers.values()) {
|
|
303
|
+
await connection.channel(entry.channel).handle(entry.method, entry.handler);
|
|
304
|
+
}
|
|
305
|
+
connection.signal.addEventListener('abort', () => {
|
|
306
|
+
if (this.#connection === connection)
|
|
307
|
+
this.#connection = undefined;
|
|
308
|
+
this.#scheduleRetry();
|
|
309
|
+
}, { once: true });
|
|
310
|
+
this.#kickFlush();
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
this.#connection?.close();
|
|
314
|
+
this.#connection = undefined;
|
|
315
|
+
this.#invalidateEphemeral();
|
|
316
|
+
this.#logger('Connector Bridge connection failed; retrying in background', error);
|
|
317
|
+
this.#scheduleRetry();
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
#scheduleRetry() {
|
|
321
|
+
if (this.#closed)
|
|
322
|
+
return;
|
|
323
|
+
const delay = Math.min(this.#maxRetryDelayMs, this.#retryDelayMs * (2 ** this.#retryAttempt));
|
|
324
|
+
this.#retryAttempt = Math.min(this.#retryAttempt + 1, 20);
|
|
325
|
+
this.#scheduleConnect(delay);
|
|
326
|
+
}
|
|
327
|
+
#kickFlush() {
|
|
328
|
+
if (!this.#connection || this.#closed)
|
|
329
|
+
return;
|
|
330
|
+
const pending = this.#flushTail.then(() => this.#flush());
|
|
331
|
+
this.#flushTail = pending.catch((error) => {
|
|
332
|
+
this.#invalidateEphemeral();
|
|
333
|
+
this.#logger('Connector Bridge flush failed; state remains queued', error);
|
|
334
|
+
this.#connection?.close();
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
async #flush() {
|
|
338
|
+
const connection = this.#connection;
|
|
339
|
+
if (!connection || connection.signal.aborted || this.#closed)
|
|
340
|
+
return;
|
|
341
|
+
while (this.#state.durable.length) {
|
|
342
|
+
const item = this.#state.durable[0];
|
|
343
|
+
const receipt = await connection.channel(item.channel).publish({
|
|
344
|
+
eventId: item.eventId, payload: item.payload,
|
|
345
|
+
}, { delivery: 'durable' });
|
|
346
|
+
if (!receipt.accepted && receipt.reason !== 'duplicate') {
|
|
347
|
+
throw new Error(`Durable Bridge write rejected: ${receipt.reason ?? 'unknown'}`);
|
|
348
|
+
}
|
|
349
|
+
this.#state.durable.shift();
|
|
350
|
+
if (!this.#persist()) {
|
|
351
|
+
this.#state.durable.unshift(item);
|
|
352
|
+
throw new Error('Durable Bridge acknowledgement could not be persisted locally');
|
|
353
|
+
}
|
|
354
|
+
this.#resolveDurable(item.channel, item.eventId);
|
|
355
|
+
}
|
|
356
|
+
for (const channel of [...this.#dirtySnapshots]) {
|
|
357
|
+
const entry = this.#snapshots.get(channel);
|
|
358
|
+
if (!entry) {
|
|
359
|
+
this.#dirtySnapshots.delete(channel);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
const revision = entry.revision;
|
|
363
|
+
const receipt = await connection.channel(channel).setSnapshot(entry.value);
|
|
364
|
+
if (!receipt.accepted)
|
|
365
|
+
throw new Error(`Bridge snapshot rejected: ${receipt.reason ?? 'unknown'}`);
|
|
366
|
+
if (this.#snapshots.get(channel)?.revision === revision)
|
|
367
|
+
this.#dirtySnapshots.delete(channel);
|
|
368
|
+
}
|
|
369
|
+
for (const channel of [...this.#dropped]) {
|
|
370
|
+
const receipt = await connection.channel(channel).publish({ payload: { type: 'stream.gap' } });
|
|
371
|
+
if (!receipt.accepted)
|
|
372
|
+
throw new Error(`Bridge gap rejected: ${receipt.reason ?? 'unknown'}`);
|
|
373
|
+
this.#dropped.delete(channel);
|
|
374
|
+
}
|
|
375
|
+
for (const [channel, queue] of this.#ephemeral) {
|
|
376
|
+
while (queue.length) {
|
|
377
|
+
const event = queue[0];
|
|
378
|
+
const receipt = await connection.channel(channel).publish(event);
|
|
379
|
+
if (!receipt.accepted)
|
|
380
|
+
throw new Error(`Bridge event rejected: ${receipt.reason ?? 'unknown'}`);
|
|
381
|
+
queue.shift();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|