@minionry/minion 0.7.36 → 0.7.39
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/bin/commands/minions.js +19 -0
- package/bin/minion.js +33 -4
- package/dist/server/cli/headless/claude-invoker-process.js +23 -7
- package/dist/server/cli/headless/claude-invoker-stream.js +8 -3
- package/dist/server/cli/headless/haiku-assessments.js +2 -1
- package/dist/server/cli/headless/mcp-config.js +2 -1
- package/dist/server/engines/claude/claude-command.js +1 -0
- package/dist/server/engines/factory.js +2 -0
- package/dist/server/engines/hermes/HermesEngine.js +112 -219
- package/dist/server/engines/hermes/hermes-gateway-registry.js +467 -0
- package/dist/server/index.js +10 -4
- package/dist/server/mcp/bouncer-integration.js +3 -2
- package/dist/server/mcp/classifier/ClaudeBouncerClassifier.js +35 -8
- package/dist/server/mcp/classifier/shadow-eval-scheduler.js +42 -0
- package/dist/server/mcp/classifier/telemetry.js +0 -0
- package/dist/server/mcp/permission-channels.js +176 -0
- package/dist/server/mcp/security-analysis.js +9 -1
- package/dist/server/mcp/security-audit.js +22 -2
- package/dist/server/mcp/security-patterns.js +73 -1
- package/dist/server/mcp/server.js +80 -50
- package/dist/server/routes/internal.js +6 -1
- package/dist/server/routes/notifications.js +6 -18
- package/dist/server/server-setup.js +24 -48
- package/dist/server/services/analytics.js +4 -2
- package/dist/server/services/browser/host-mock.js +6 -1
- package/dist/server/services/browser/host.js +27 -3
- package/dist/server/services/browser/reasoning-agent.js +3 -1
- package/dist/server/services/chain/chain-engine.js +494 -0
- package/dist/server/services/chain/chain-store.js +542 -0
- package/dist/server/services/git/haiku.js +2 -2
- package/dist/server/services/plan/agents/check-injection.md +43 -2
- package/dist/server/services/plan/agents/review-code.md +62 -3
- package/dist/server/services/plan/agents/review-quality.md +52 -2
- package/dist/server/services/plan/board-export.js +4 -0
- package/dist/server/services/plan/composer-prompt.js +27 -8
- package/dist/server/services/plan/composer.js +10 -4
- package/dist/server/services/plan/config-installer.js +4 -83
- package/dist/server/services/plan/executor.js +155 -73
- package/dist/server/services/plan/issue-effort.js +9 -21
- package/dist/server/services/plan/issue-prompt-builder.js +72 -9
- package/dist/server/services/plan/parser-core.js +6 -1
- package/dist/server/services/plan/quality-delta.js +6 -24
- package/dist/server/services/plan/readiness-planner.js +12 -4
- package/dist/server/services/plan/record-file.js +52 -0
- package/dist/server/services/plan/review-approval.js +64 -0
- package/dist/server/services/plan/review-gate.js +186 -77
- package/dist/server/services/plan/review-outcome.js +146 -0
- package/dist/server/services/plan/review-report.js +28 -1
- package/dist/server/services/plan/review-target.js +57 -0
- package/dist/server/services/plan/state-reconciler.js +3 -3
- package/dist/server/services/plan/template-diff.js +2 -0
- package/dist/server/services/plan/template-instantiator.js +4 -0
- package/dist/server/services/plan/watcher.js +11 -1
- package/dist/server/services/platform-reconnect.js +7 -0
- package/dist/server/services/platform-token-lifecycle.js +2 -1
- package/dist/server/services/platform.js +293 -50
- package/dist/server/services/relay-http-client.js +107 -0
- package/dist/server/services/schedule/agent-schedule-store.js +69 -8
- package/dist/server/services/schedule/agent-scheduler.js +48 -7
- package/dist/server/services/schedule/daily-timing.js +52 -0
- package/dist/server/services/schedule/prompt-file.js +55 -0
- package/dist/server/services/schedule/run-bootstrap.js +20 -5
- package/dist/server/services/schedule/schedule-store.js +54 -4
- package/dist/server/services/schedule/schedule-wire.js +17 -0
- package/dist/server/services/schedule/scheduler.js +43 -9
- package/dist/server/services/sdk/agent-schedule.js +294 -0
- package/dist/server/services/sdk/agents.js +95 -13
- package/dist/server/services/sdk/app-tools.js +44 -7
- package/dist/server/services/sdk/atomic-write.js +75 -0
- package/dist/server/services/sdk/browser.js +5 -1
- package/dist/server/services/sdk/composer.js +2 -0
- package/dist/server/services/sdk/file-change-notify.js +60 -13
- package/dist/server/services/sdk/files-app.js +12 -7
- package/dist/server/services/sdk/files-search.js +14 -0
- package/dist/server/services/sdk/files-transfer.js +1 -1
- package/dist/server/services/sdk/files-watch.js +721 -0
- package/dist/server/services/sdk/files-workspace.js +37 -11
- package/dist/server/services/sdk/inference-claude.js +4 -2
- package/dist/server/services/sdk/inference.js +16 -2
- package/dist/server/services/sdk/pm-chain.js +0 -0
- package/dist/server/services/sdk/pm-schedule.js +72 -15
- package/dist/server/services/sdk/pm.js +22 -0
- package/dist/server/services/sdk/rate-limits.js +32 -0
- package/dist/server/services/sdk/registry.js +40 -2
- package/dist/server/services/sdk/terminal.js +1 -1
- package/dist/server/services/sentry.js +5 -1
- package/dist/server/services/terminal/pty-manager.js +26 -11
- package/dist/server/services/terminal/pty-utils.js +12 -1
- package/dist/server/services/terminal/terminal-modes.js +87 -0
- package/dist/server/services/timeline/assembler.js +2 -2
- package/dist/server/services/timeline/index.js +1 -1
- package/dist/server/services/websocket/agent-schedule-handlers.js +62 -4
- package/dist/server/services/websocket/ask-user-question-bridge.js +2 -2
- package/dist/server/services/websocket/browser-agent-runtime.js +3 -1
- package/dist/server/services/websocket/browser-handlers.js +5 -2
- package/dist/server/services/websocket/browser-viewer-lifecycle.js +8 -0
- package/dist/server/services/websocket/file-explorer-handlers.js +17 -0
- package/dist/server/services/websocket/file-transfer-http.js +12 -6
- package/dist/server/services/websocket/handler.js +185 -33
- package/dist/server/services/websocket/msg-id-tracker.js +94 -19
- package/dist/server/services/websocket/plan-execution-handlers.js +33 -5
- package/dist/server/services/websocket/plan-handlers.js +10 -5
- package/dist/server/services/websocket/plan-helpers.js +23 -0
- package/dist/server/services/websocket/plan-issue-handlers.js +161 -4
- package/dist/server/services/websocket/plan-sprint-handlers.js +3 -2
- package/dist/server/services/websocket/schedule-handlers.js +8 -2
- package/dist/server/services/websocket/sdk-agent-host.js +5 -1
- package/dist/server/services/websocket/sdk-agent-schedule-host.js +30 -0
- package/dist/server/services/websocket/sdk-browser-host.js +11 -4
- package/dist/server/services/websocket/sdk-handlers.js +6 -1
- package/dist/server/services/websocket/sdk-pm-chain-host.js +20 -0
- package/dist/server/services/websocket/sdk-pm-execution-host.js +21 -14
- package/dist/server/services/websocket/sdk-pm-schedule-host.js +9 -0
- package/dist/server/services/websocket/sdk-terminal-host.js +7 -0
- package/dist/server/services/websocket/settings-handlers.js +6 -18
- package/dist/server/services/websocket/tab-broadcast.js +5 -1
- package/dist/server/services/websocket/terminal-handlers.js +51 -6
- package/dist/server/services/websocket/types.js +6 -5
- package/dist/server/services/websocket/viewer-interest.js +30 -0
- package/package.json +6 -6
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Minionry, Inc.
|
|
2
2
|
// SPDX-License-Identifier: LicenseRef-Minionry-Software
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import { arch, hostname, type } from 'node:os';
|
|
4
5
|
import { basename } from 'node:path';
|
|
5
6
|
import { AnalyticsEvents, trackEvent } from './analytics.js';
|
|
@@ -75,13 +76,40 @@ const CONNECT_TIMEOUT_MS = 10_000;
|
|
|
75
76
|
const HANDSHAKE_ERROR_CLOSE_GRACE_MS = 500;
|
|
76
77
|
const HEARTBEAT_TIMEOUT_CLOSE_CODE = 4006;
|
|
77
78
|
const DIAL_TIMEOUT_CLOSE_CODE = 4011;
|
|
79
|
+
const HANDOFF_DIAL_TIMEOUT_MS = 10_000;
|
|
80
|
+
const MIGRATE_OLD_CLOSE_DELAY_MS = 2_000;
|
|
81
|
+
const SOCKET_SCOPED_TYPES = new Set(['paired', 'relayHandoff', 'ping', 'pong']);
|
|
82
|
+
function handoffDeadlineMs(delayMs, handoffBy, now) {
|
|
83
|
+
const budget = delayMs + HANDOFF_DIAL_TIMEOUT_MS;
|
|
84
|
+
const untilHandoffBy = handoffBy - now;
|
|
85
|
+
return untilHandoffBy > delayMs ? Math.min(untilHandoffBy, budget) : budget;
|
|
86
|
+
}
|
|
87
|
+
function retireSocket(ws) {
|
|
88
|
+
try {
|
|
89
|
+
ws.onopen = null;
|
|
90
|
+
ws.onmessage = null;
|
|
91
|
+
ws.onclose = null;
|
|
92
|
+
ws.onerror = null;
|
|
93
|
+
}
|
|
94
|
+
catch { }
|
|
95
|
+
try {
|
|
96
|
+
if (ws.readyState === WebSocketImpl.CONNECTING || ws.readyState === WebSocketImpl.OPEN) {
|
|
97
|
+
ws.close();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch { }
|
|
101
|
+
}
|
|
78
102
|
const CREDENTIALS_POLL_MS = 15_000;
|
|
79
103
|
const OUTBOUND_QUEUE_LIMIT = 100;
|
|
104
|
+
const USAGE_RETRY_QUEUE_LIMIT = 1_000;
|
|
80
105
|
const MAX_OUTBOUND_FRAME_BYTES = 16 * 1024 * 1024;
|
|
81
106
|
const NEVER_QUEUED_TYPES = new Set(['pong', 'terminalOutput', 'sdkGrantState', 'shuttingDown']);
|
|
82
107
|
export class PlatformConnection {
|
|
83
108
|
ws = null;
|
|
84
109
|
socketEpoch = 0;
|
|
110
|
+
epochSeq = 0;
|
|
111
|
+
handoff = null;
|
|
112
|
+
leaving = new Map();
|
|
85
113
|
reconnectTimeout = null;
|
|
86
114
|
reconnectPolicy = new ReconnectPolicy();
|
|
87
115
|
isIntentionallyClosed = false;
|
|
@@ -99,9 +127,13 @@ export class PlatformConnection {
|
|
|
99
127
|
spaceVisibility;
|
|
100
128
|
e2ee;
|
|
101
129
|
startedAt;
|
|
130
|
+
epochNonce;
|
|
102
131
|
tokens;
|
|
103
132
|
outboundQueue = [];
|
|
104
133
|
outboundDropped = 0;
|
|
134
|
+
usageRetryQueue = new Map();
|
|
135
|
+
usageDropped = 0;
|
|
136
|
+
usageKeySeq = 0;
|
|
105
137
|
browserFrameRefusalWarned = false;
|
|
106
138
|
browserFrameRefusals = 0;
|
|
107
139
|
constructor(workingDirectory, callbacks = {}, platformUrl) {
|
|
@@ -109,6 +141,7 @@ export class PlatformConnection {
|
|
|
109
141
|
this.platformUrl = platformUrl || DEFAULT_PLATFORM_URL;
|
|
110
142
|
this.callbacks = callbacks;
|
|
111
143
|
this.startedAt = new Date().toISOString();
|
|
144
|
+
this.epochNonce = randomBytes(16).toString('base64url');
|
|
112
145
|
this.tokens = new TokenLifecycle({
|
|
113
146
|
platformUrl: this.platformUrl,
|
|
114
147
|
send: (msg) => this.send(msg),
|
|
@@ -163,8 +196,9 @@ export class PlatformConnection {
|
|
|
163
196
|
}
|
|
164
197
|
this.stopCredentialsWatch();
|
|
165
198
|
this.presentedToken = authToken;
|
|
199
|
+
this.dropHandoff();
|
|
166
200
|
this.detachSocket();
|
|
167
|
-
const epoch =
|
|
201
|
+
const epoch = this.nextLiveEpoch();
|
|
168
202
|
const wsUrl = this.buildConnectionUrl();
|
|
169
203
|
let ws;
|
|
170
204
|
try {
|
|
@@ -197,6 +231,7 @@ export class PlatformConnection {
|
|
|
197
231
|
...E2EE_CAPABILITIES,
|
|
198
232
|
fileTransfer: FILE_TRANSFER_CAPABILITY,
|
|
199
233
|
maxUploadBytes: getMaxUploadBytes(),
|
|
234
|
+
relayHandoff: true,
|
|
200
235
|
}),
|
|
201
236
|
startedAt: this.startedAt,
|
|
202
237
|
protocolVersion: String(PROTOCOL_VERSION),
|
|
@@ -217,18 +252,7 @@ export class PlatformConnection {
|
|
|
217
252
|
}, CONNECT_TIMEOUT_MS);
|
|
218
253
|
}
|
|
219
254
|
attachSocketHandlers(ws, authToken, connectionTimeout, epoch) {
|
|
220
|
-
|
|
221
|
-
const settleClose = (event) => {
|
|
222
|
-
if (closeSettled)
|
|
223
|
-
return;
|
|
224
|
-
closeSettled = true;
|
|
225
|
-
clearTimeout(connectionTimeout);
|
|
226
|
-
if (epoch !== this.socketEpoch) {
|
|
227
|
-
console.log(`[Platform] Ignoring close on a retired socket (code ${event.code})`);
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
this.handleSocketClose(event);
|
|
231
|
-
};
|
|
255
|
+
this.bindLiveHandlers(ws, epoch, () => clearTimeout(connectionTimeout));
|
|
232
256
|
ws.onopen = () => {
|
|
233
257
|
if (epoch !== this.socketEpoch) {
|
|
234
258
|
try {
|
|
@@ -238,11 +262,36 @@ export class PlatformConnection {
|
|
|
238
262
|
return;
|
|
239
263
|
}
|
|
240
264
|
clearTimeout(connectionTimeout);
|
|
241
|
-
ws.send(JSON.stringify(
|
|
265
|
+
ws.send(JSON.stringify(this.authFrame(authToken)));
|
|
242
266
|
this.tokens.maybeRefresh();
|
|
243
267
|
this.tokens.startPeriodicCheck();
|
|
244
268
|
trackEvent(AnalyticsEvents.PLATFORM_CONNECTED);
|
|
245
269
|
};
|
|
270
|
+
const wsWithEmitter = ws;
|
|
271
|
+
if (typeof wsWithEmitter.on === 'function') {
|
|
272
|
+
wsWithEmitter.on('unexpected-response', (...args) => {
|
|
273
|
+
if (epoch !== this.socketEpoch)
|
|
274
|
+
return;
|
|
275
|
+
const response = args[1];
|
|
276
|
+
if (response?.statusCode === 426) {
|
|
277
|
+
this.protocolUpgradeRequired = true;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
bindLiveHandlers(ws, epoch, onSettle = () => { }) {
|
|
283
|
+
let closeSettled = false;
|
|
284
|
+
const settleClose = (event) => {
|
|
285
|
+
if (closeSettled)
|
|
286
|
+
return;
|
|
287
|
+
closeSettled = true;
|
|
288
|
+
onSettle();
|
|
289
|
+
if (epoch !== this.socketEpoch) {
|
|
290
|
+
console.log(`[Platform] Ignoring close on a retired socket (code ${event.code})`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.handleSocketClose(event);
|
|
294
|
+
};
|
|
246
295
|
ws.onmessage = (event) => {
|
|
247
296
|
if (epoch !== this.socketEpoch)
|
|
248
297
|
return;
|
|
@@ -255,46 +304,36 @@ export class PlatformConnection {
|
|
|
255
304
|
};
|
|
256
305
|
ws.onclose = (event) => settleClose(event);
|
|
257
306
|
ws.onerror = () => {
|
|
258
|
-
|
|
307
|
+
onSettle();
|
|
259
308
|
setTimeout(() => {
|
|
260
309
|
settleClose({ code: 1012, reason: 'handshake failed' });
|
|
261
310
|
}, HANDSHAKE_ERROR_CLOSE_GRACE_MS);
|
|
262
311
|
};
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
312
|
+
}
|
|
313
|
+
authFrame(token, migrateFrom) {
|
|
314
|
+
return {
|
|
315
|
+
type: 'auth',
|
|
316
|
+
token,
|
|
317
|
+
startedAt: this.startedAt,
|
|
318
|
+
epochNonce: this.epochNonce,
|
|
319
|
+
...(migrateFrom ? { migrateFrom } : {}),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
nextLiveEpoch() {
|
|
323
|
+
this.socketEpoch = ++this.epochSeq;
|
|
324
|
+
return this.socketEpoch;
|
|
274
325
|
}
|
|
275
326
|
detachSocket() {
|
|
276
327
|
const ws = this.ws;
|
|
277
328
|
if (!ws)
|
|
278
329
|
return;
|
|
279
330
|
this.ws = null;
|
|
280
|
-
|
|
281
|
-
ws.onopen = null;
|
|
282
|
-
ws.onmessage = null;
|
|
283
|
-
ws.onclose = null;
|
|
284
|
-
ws.onerror = null;
|
|
285
|
-
}
|
|
286
|
-
catch { }
|
|
287
|
-
try {
|
|
288
|
-
if (ws.readyState === WebSocketImpl.CONNECTING || ws.readyState === WebSocketImpl.OPEN) {
|
|
289
|
-
ws.close();
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
catch { }
|
|
331
|
+
retireSocket(ws);
|
|
293
332
|
}
|
|
294
333
|
forceClose(ws, epoch, code, reason) {
|
|
295
334
|
if (epoch !== this.socketEpoch)
|
|
296
335
|
return;
|
|
297
|
-
this.
|
|
336
|
+
this.nextLiveEpoch();
|
|
298
337
|
this.ws = null;
|
|
299
338
|
try {
|
|
300
339
|
ws.onopen = null;
|
|
@@ -320,9 +359,24 @@ export class PlatformConnection {
|
|
|
320
359
|
this.reconnectPolicy.noteDisconnected();
|
|
321
360
|
if (this.isIntentionallyClosed)
|
|
322
361
|
return;
|
|
323
|
-
const
|
|
362
|
+
const handoff = this.handoff;
|
|
363
|
+
if (handoff?.ws) {
|
|
364
|
+
handoff.fromClosed = event;
|
|
365
|
+
console.log(`[Platform] Socket closed mid-handoff (code ${event.code}) — waiting on the new one`);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (handoff)
|
|
369
|
+
this.clearHandoff(handoff);
|
|
370
|
+
this.recoverFromClose(event, handoff !== null);
|
|
371
|
+
}
|
|
372
|
+
recoverFromClose(event, migratingAway) {
|
|
373
|
+
let disposition = classifyClose(event.code, event.reason, {
|
|
324
374
|
protocolUpgradeRequired: this.protocolUpgradeRequired,
|
|
325
375
|
});
|
|
376
|
+
if (disposition === 'superseded' && migratingAway) {
|
|
377
|
+
console.log('[Platform] Ignoring 4015 on the socket this process is migrating away from — reconnecting');
|
|
378
|
+
disposition = 'reconnect';
|
|
379
|
+
}
|
|
326
380
|
switch (disposition) {
|
|
327
381
|
case 'protocol_too_old':
|
|
328
382
|
this.handleProtocolUpgradeRequired();
|
|
@@ -330,11 +384,18 @@ export class PlatformConnection {
|
|
|
330
384
|
case 'superseded':
|
|
331
385
|
this.handleSuperseded(event);
|
|
332
386
|
return;
|
|
387
|
+
case 'handoff':
|
|
388
|
+
console.log(`Relay handed this socket off (code ${event.code}${event.reason ? `: ${event.reason}` : ''}), reconnecting...`);
|
|
389
|
+
this.callbacks.onDisconnected?.();
|
|
390
|
+
trackEvent(AnalyticsEvents.PLATFORM_DISCONNECTED);
|
|
391
|
+
this.scheduleReconnect(this.reconnectPolicy.handoffDelayMs());
|
|
392
|
+
return;
|
|
333
393
|
case 'auth_failed':
|
|
334
394
|
console.error('\n❌ Authentication failed. Your device token may be invalid or expired.');
|
|
335
395
|
console.error(' Run `minion login --force` to re-authenticate — this server reconnects as soon as you do.\n');
|
|
336
396
|
this.tokens.notifyAuthExpired();
|
|
337
397
|
this.callbacks.onError?.('Authentication failed - run `minion login --force`');
|
|
398
|
+
this.callbacks.onDisconnected?.();
|
|
338
399
|
this.awaitCredentials(this.presentedToken);
|
|
339
400
|
return;
|
|
340
401
|
default:
|
|
@@ -367,8 +428,151 @@ export class PlatformConnection {
|
|
|
367
428
|
this.connect();
|
|
368
429
|
}, PROTOCOL_UPGRADE_RECONNECT_DELAY_MS);
|
|
369
430
|
}
|
|
370
|
-
|
|
431
|
+
beginHandoff(message) {
|
|
432
|
+
const { v, delayMs, handoffBy } = message;
|
|
433
|
+
if (v !== 1 || typeof delayMs !== 'number' || typeof handoffBy !== 'number')
|
|
434
|
+
return;
|
|
435
|
+
if (!Number.isFinite(delayMs) || !Number.isFinite(handoffBy))
|
|
436
|
+
return;
|
|
437
|
+
const from = this.ws;
|
|
438
|
+
if (this.handoff || !from || !this.isConnected || !this.connectionId)
|
|
439
|
+
return;
|
|
440
|
+
const delay = Math.max(0, delayMs);
|
|
441
|
+
const handoff = { from, migrateFrom: this.connectionId, ws: null, fromClosed: null, timers: [] };
|
|
442
|
+
this.handoff = handoff;
|
|
443
|
+
const reason = typeof message.reason === 'string' ? message.reason : 'unspecified';
|
|
444
|
+
console.log(`[Platform] Relay handoff (${reason}) — moving to a new socket in ${delay}ms`);
|
|
445
|
+
handoff.timers.push(setTimeout(() => this.dialHandoff(handoff), delay), setTimeout(() => this.abandonHandoff(handoff, 'handoffBy passed'), handoffDeadlineMs(delay, handoffBy, Date.now())));
|
|
446
|
+
}
|
|
447
|
+
dialHandoff(handoff) {
|
|
448
|
+
if (this.handoff !== handoff)
|
|
449
|
+
return;
|
|
450
|
+
const token = getCredentials()?.token;
|
|
451
|
+
if (!token) {
|
|
452
|
+
this.abandonHandoff(handoff, 'not logged in');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
let ws;
|
|
456
|
+
try {
|
|
457
|
+
ws = new WebSocketImpl(this.buildConnectionUrl());
|
|
458
|
+
}
|
|
459
|
+
catch (err) {
|
|
460
|
+
this.abandonHandoff(handoff, err instanceof Error ? err.message : String(err));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
handoff.ws = ws;
|
|
464
|
+
ws.onopen = () => {
|
|
465
|
+
if (this.handoff !== handoff)
|
|
466
|
+
return;
|
|
467
|
+
ws.send(JSON.stringify(this.authFrame(token, handoff.migrateFrom)));
|
|
468
|
+
};
|
|
469
|
+
ws.onmessage = (event) => {
|
|
470
|
+
if (this.handoff !== handoff)
|
|
471
|
+
return;
|
|
472
|
+
try {
|
|
473
|
+
const message = JSON.parse(event.data.toString());
|
|
474
|
+
if (message.type === 'paired')
|
|
475
|
+
this.completeHandoff(handoff, ws, message);
|
|
476
|
+
else
|
|
477
|
+
this.handleMessage(message, false);
|
|
478
|
+
}
|
|
479
|
+
catch (err) {
|
|
480
|
+
console.error('Failed to parse platform message:', err);
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
ws.onclose = (event) => this.abandonHandoff(handoff, `new socket closed with code ${event.code}`);
|
|
484
|
+
ws.onerror = () => this.abandonHandoff(handoff, 'new socket failed');
|
|
485
|
+
}
|
|
486
|
+
completeHandoff(handoff, ws, paired) {
|
|
487
|
+
this.clearHandoff(handoff);
|
|
488
|
+
const old = handoff.from;
|
|
489
|
+
if (old.readyState === WebSocketImpl.OPEN) {
|
|
490
|
+
try {
|
|
491
|
+
old.send(JSON.stringify({ type: 'handoffMark' }));
|
|
492
|
+
}
|
|
493
|
+
catch { }
|
|
494
|
+
}
|
|
495
|
+
this.ws = ws;
|
|
496
|
+
ws.onopen = null;
|
|
497
|
+
this.bindLiveHandlers(ws, this.nextLiveEpoch());
|
|
498
|
+
this.retireLeaving(old);
|
|
499
|
+
if (handoff.fromClosed) {
|
|
500
|
+
this.tokens.maybeRefresh();
|
|
501
|
+
this.tokens.startPeriodicCheck();
|
|
502
|
+
}
|
|
503
|
+
console.log('[Platform] Relay handoff complete — moved to the new socket');
|
|
504
|
+
this.handleMessage(paired);
|
|
505
|
+
}
|
|
506
|
+
retireLeaving(old) {
|
|
507
|
+
if (old.readyState !== WebSocketImpl.OPEN && old.readyState !== WebSocketImpl.CONNECTING) {
|
|
508
|
+
retireSocket(old);
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
old.onopen = null;
|
|
512
|
+
old.onerror = null;
|
|
513
|
+
old.onmessage = (event) => {
|
|
514
|
+
try {
|
|
515
|
+
this.handleMessage(JSON.parse(event.data.toString()), false);
|
|
516
|
+
}
|
|
517
|
+
catch (err) {
|
|
518
|
+
console.error('Failed to parse platform message:', err);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
old.onclose = (event) => {
|
|
522
|
+
console.log(`[Platform] Previous socket closed after the handoff (code ${event.code})`);
|
|
523
|
+
this.dropLeaving(old);
|
|
524
|
+
};
|
|
525
|
+
this.leaving.set(old, setTimeout(() => {
|
|
526
|
+
this.dropLeaving(old);
|
|
527
|
+
retireSocket(old);
|
|
528
|
+
}, MIGRATE_OLD_CLOSE_DELAY_MS));
|
|
529
|
+
}
|
|
530
|
+
dropLeaving(old) {
|
|
531
|
+
const timer = this.leaving.get(old);
|
|
532
|
+
if (timer === undefined)
|
|
533
|
+
return;
|
|
534
|
+
clearTimeout(timer);
|
|
535
|
+
this.leaving.delete(old);
|
|
536
|
+
old.onmessage = null;
|
|
537
|
+
old.onclose = null;
|
|
538
|
+
}
|
|
539
|
+
abandonHandoff(handoff, why) {
|
|
540
|
+
if (this.handoff !== handoff)
|
|
541
|
+
return;
|
|
542
|
+
this.clearHandoff(handoff);
|
|
543
|
+
if (handoff.ws)
|
|
544
|
+
retireSocket(handoff.ws);
|
|
545
|
+
const closed = handoff.fromClosed;
|
|
546
|
+
console.warn(`[Platform] Relay handoff abandoned (${why}) — ${closed ? 'reconnecting' : 'staying on the current socket'}`);
|
|
547
|
+
if (closed && !this.isIntentionallyClosed)
|
|
548
|
+
this.recoverFromClose(closed, true);
|
|
549
|
+
}
|
|
550
|
+
clearHandoff(handoff) {
|
|
551
|
+
for (const timer of handoff.timers)
|
|
552
|
+
clearTimeout(timer);
|
|
553
|
+
if (this.handoff === handoff)
|
|
554
|
+
this.handoff = null;
|
|
555
|
+
}
|
|
556
|
+
dropHandoff() {
|
|
557
|
+
const handoff = this.handoff;
|
|
558
|
+
if (!handoff)
|
|
559
|
+
return;
|
|
560
|
+
this.clearHandoff(handoff);
|
|
561
|
+
if (handoff.ws)
|
|
562
|
+
retireSocket(handoff.ws);
|
|
563
|
+
}
|
|
564
|
+
settleBeforeEnvelope(message, fromLive) {
|
|
565
|
+
if (!fromLive)
|
|
566
|
+
return SOCKET_SCOPED_TYPES.has(message.type);
|
|
371
567
|
this.heartbeat.noteInbound();
|
|
568
|
+
if (message.type !== 'relayHandoff')
|
|
569
|
+
return false;
|
|
570
|
+
this.beginHandoff(message);
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
handleMessage(message, fromLive = true) {
|
|
574
|
+
if (this.settleBeforeEnvelope(message, fromLive))
|
|
575
|
+
return;
|
|
372
576
|
if (isV1Envelope(message)) {
|
|
373
577
|
if (message.enc !== false) {
|
|
374
578
|
this.e2ee.handleSealedInbound(message, (legacy) => {
|
|
@@ -390,6 +594,7 @@ export class PlatformConnection {
|
|
|
390
594
|
replaceFileShareRegistry(message.fileShares, this.workingDirectory);
|
|
391
595
|
this.heartbeat.start();
|
|
392
596
|
this.callbacks.onConnected?.(message.connectionId);
|
|
597
|
+
this.flushOutboundQueue();
|
|
393
598
|
break;
|
|
394
599
|
}
|
|
395
600
|
case 'web_connected':
|
|
@@ -488,12 +693,12 @@ export class PlatformConnection {
|
|
|
488
693
|
this.credentialsWatch = null;
|
|
489
694
|
}
|
|
490
695
|
}
|
|
491
|
-
scheduleReconnect() {
|
|
696
|
+
scheduleReconnect(delayMs) {
|
|
492
697
|
if (this.reconnectTimeout)
|
|
493
698
|
return;
|
|
494
699
|
if (this.superseded)
|
|
495
700
|
return;
|
|
496
|
-
const delay = this.reconnectPolicy.nextDelayMs();
|
|
701
|
+
const delay = delayMs ?? this.reconnectPolicy.nextDelayMs();
|
|
497
702
|
this.reconnectTimeout = setTimeout(() => {
|
|
498
703
|
this.reconnectTimeout = null;
|
|
499
704
|
this.connect();
|
|
@@ -502,6 +707,10 @@ export class PlatformConnection {
|
|
|
502
707
|
send(message) {
|
|
503
708
|
if (this.refuseCleartextBrowserFrame(message))
|
|
504
709
|
return;
|
|
710
|
+
if (message?.type === 'reportUsage' && !this.isConnectedToPlatform()) {
|
|
711
|
+
this.holdUsageReport(message);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
505
714
|
if (!this.ws || this.ws.readyState !== WebSocketImpl.OPEN) {
|
|
506
715
|
this.queueWhileDisconnected(message);
|
|
507
716
|
return;
|
|
@@ -601,17 +810,44 @@ export class PlatformConnection {
|
|
|
601
810
|
}
|
|
602
811
|
this.outboundQueue.push(message);
|
|
603
812
|
}
|
|
813
|
+
holdUsageReport(message) {
|
|
814
|
+
const reportId = message.data?.reportId;
|
|
815
|
+
const key = typeof reportId === 'string' && reportId !== '' ? reportId : `unkeyed-${++this.usageKeySeq}`;
|
|
816
|
+
if (this.usageRetryQueue.has(key))
|
|
817
|
+
return;
|
|
818
|
+
if (this.usageRetryQueue.size >= USAGE_RETRY_QUEUE_LIMIT) {
|
|
819
|
+
const oldest = this.usageRetryQueue.keys().next().value;
|
|
820
|
+
this.usageRetryQueue.delete(oldest);
|
|
821
|
+
this.usageDropped++;
|
|
822
|
+
}
|
|
823
|
+
this.usageRetryQueue.set(key, message);
|
|
824
|
+
}
|
|
604
825
|
flushOutboundQueue() {
|
|
605
826
|
if (this.outboundDropped > 0) {
|
|
606
827
|
console.log(`[Platform] ${this.outboundDropped} queued outbound frame${this.outboundDropped === 1 ? '' : 's'} dropped over the ${OUTBOUND_QUEUE_LIMIT}-frame cap`);
|
|
607
828
|
this.outboundDropped = 0;
|
|
608
829
|
}
|
|
609
|
-
if (this.outboundQueue.length
|
|
830
|
+
if (this.outboundQueue.length > 0) {
|
|
831
|
+
const queued = this.outboundQueue;
|
|
832
|
+
this.outboundQueue = [];
|
|
833
|
+
console.log(`[Platform] Flushing ${queued.length} outbound message${queued.length === 1 ? '' : 's'} queued during the outage`);
|
|
834
|
+
for (const message of queued) {
|
|
835
|
+
this.send(message);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
this.flushUsageRetryQueue();
|
|
839
|
+
}
|
|
840
|
+
flushUsageRetryQueue() {
|
|
841
|
+
if (this.usageDropped > 0) {
|
|
842
|
+
console.log(`[Platform] ${this.usageDropped} held usage report${this.usageDropped === 1 ? '' : 's'} dropped over the ${USAGE_RETRY_QUEUE_LIMIT}-report cap`);
|
|
843
|
+
this.usageDropped = 0;
|
|
844
|
+
}
|
|
845
|
+
if (this.usageRetryQueue.size === 0 || !this.isConnectedToPlatform())
|
|
610
846
|
return;
|
|
611
|
-
const
|
|
612
|
-
this.
|
|
613
|
-
console.log(`[Platform]
|
|
614
|
-
for (const message of
|
|
847
|
+
const held = [...this.usageRetryQueue.values()];
|
|
848
|
+
this.usageRetryQueue.clear();
|
|
849
|
+
console.log(`[Platform] Sending ${held.length} usage report${held.length === 1 ? '' : 's'} held while unpaired`);
|
|
850
|
+
for (const message of held) {
|
|
615
851
|
this.send(message);
|
|
616
852
|
}
|
|
617
853
|
}
|
|
@@ -643,12 +879,19 @@ export class PlatformConnection {
|
|
|
643
879
|
this.stopCredentialsWatch();
|
|
644
880
|
this.outboundQueue = [];
|
|
645
881
|
this.outboundDropped = 0;
|
|
882
|
+
this.usageRetryQueue.clear();
|
|
883
|
+
this.usageDropped = 0;
|
|
646
884
|
if (this.reconnectTimeout) {
|
|
647
885
|
clearTimeout(this.reconnectTimeout);
|
|
648
886
|
this.reconnectTimeout = null;
|
|
649
887
|
}
|
|
650
|
-
this.
|
|
888
|
+
this.nextLiveEpoch();
|
|
889
|
+
this.dropHandoff();
|
|
651
890
|
this.detachSocket();
|
|
891
|
+
for (const old of [...this.leaving.keys()]) {
|
|
892
|
+
this.dropLeaving(old);
|
|
893
|
+
retireSocket(old);
|
|
894
|
+
}
|
|
652
895
|
this.isConnected = false;
|
|
653
896
|
this.connectionId = null;
|
|
654
897
|
this.envelopeNegotiated = false;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Minionry, Inc.
|
|
2
|
+
// SPDX-License-Identifier: LicenseRef-Minionry-Software
|
|
3
|
+
import { ReconnectPolicy } from './platform-reconnect.js';
|
|
4
|
+
export const RELAY_HTTP_RETRY_BUDGET_MS = 60_000;
|
|
5
|
+
const RETRYABLE_STATUSES = new Set([502, 503, 504]);
|
|
6
|
+
const RETRYABLE_ERROR_CODES = new Set(['ECONNRESET', 'ECONNREFUSED', 'UND_ERR_SOCKET']);
|
|
7
|
+
export class RelayHttpClient {
|
|
8
|
+
options;
|
|
9
|
+
constructor(options = {}) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
}
|
|
12
|
+
async fetch(input, init, request = {}) {
|
|
13
|
+
const now = this.options.now ?? Date.now;
|
|
14
|
+
const startedAt = now();
|
|
15
|
+
const budgetMs = request.budgetMs ?? this.options.budgetMs ?? RELAY_HTTP_RETRY_BUDGET_MS;
|
|
16
|
+
const backoff = new ReconnectPolicy({
|
|
17
|
+
initialMs: this.options.initialDelayMs ?? 500,
|
|
18
|
+
maxMs: this.options.maxDelayMs ?? 8_000,
|
|
19
|
+
random: this.options.random,
|
|
20
|
+
});
|
|
21
|
+
const replayable = isReplayableBody(init?.body);
|
|
22
|
+
for (;;) {
|
|
23
|
+
const attempt = await this.attempt(input, init);
|
|
24
|
+
const waitMs = replayable ? retryWaitMs(attempt, backoff, now()) : null;
|
|
25
|
+
if (waitMs === null || now() - startedAt + waitMs > budgetMs)
|
|
26
|
+
return settle(attempt);
|
|
27
|
+
if (attempt.ok)
|
|
28
|
+
await discardBody(attempt.response);
|
|
29
|
+
await sleep(waitMs, init?.signal ?? undefined);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async attempt(input, init) {
|
|
33
|
+
const fetchImpl = this.options.fetchImpl ?? globalThis.fetch;
|
|
34
|
+
try {
|
|
35
|
+
return { ok: true, response: await fetchImpl(input, init) };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
return { ok: false, error };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function retryWaitMs(attempt, backoff, nowMs) {
|
|
43
|
+
if (!attempt.ok)
|
|
44
|
+
return isRetryableNetworkError(attempt.error) ? backoff.nextDelayMs() : null;
|
|
45
|
+
if (!RETRYABLE_STATUSES.has(attempt.response.status))
|
|
46
|
+
return null;
|
|
47
|
+
return Math.max(retryAfterMs(attempt.response, nowMs) ?? 0, backoff.nextDelayMs());
|
|
48
|
+
}
|
|
49
|
+
function settle(attempt) {
|
|
50
|
+
if (attempt.ok)
|
|
51
|
+
return attempt.response;
|
|
52
|
+
throw attempt.error;
|
|
53
|
+
}
|
|
54
|
+
export const relayHttp = new RelayHttpClient();
|
|
55
|
+
function retryAfterMs(response, nowMs) {
|
|
56
|
+
const raw = response.headers?.get?.('retry-after')?.trim();
|
|
57
|
+
if (!raw)
|
|
58
|
+
return null;
|
|
59
|
+
if (/^\d+$/.test(raw))
|
|
60
|
+
return Number(raw) * 1_000;
|
|
61
|
+
const at = Date.parse(raw);
|
|
62
|
+
return Number.isNaN(at) ? null : Math.max(0, at - nowMs);
|
|
63
|
+
}
|
|
64
|
+
function isRetryableNetworkError(err, depth = 0) {
|
|
65
|
+
if (depth > 4 || typeof err !== 'object' || err === null)
|
|
66
|
+
return false;
|
|
67
|
+
const { code, cause, errors } = err;
|
|
68
|
+
if (typeof code === 'string' && RETRYABLE_ERROR_CODES.has(code))
|
|
69
|
+
return true;
|
|
70
|
+
if (Array.isArray(errors) && errors.some((inner) => isRetryableNetworkError(inner, depth + 1)))
|
|
71
|
+
return true;
|
|
72
|
+
return isRetryableNetworkError(cause, depth + 1);
|
|
73
|
+
}
|
|
74
|
+
function isReplayableBody(body) {
|
|
75
|
+
return (body === undefined ||
|
|
76
|
+
body === null ||
|
|
77
|
+
typeof body === 'string' ||
|
|
78
|
+
body instanceof ArrayBuffer ||
|
|
79
|
+
ArrayBuffer.isView(body) ||
|
|
80
|
+
body instanceof URLSearchParams ||
|
|
81
|
+
body instanceof Blob ||
|
|
82
|
+
body instanceof FormData);
|
|
83
|
+
}
|
|
84
|
+
async function discardBody(response) {
|
|
85
|
+
try {
|
|
86
|
+
await response.body?.cancel();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function sleep(ms, signal) {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
if (signal?.aborted) {
|
|
94
|
+
reject(signal.reason);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const onAbort = () => {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
reject(signal?.reason);
|
|
100
|
+
};
|
|
101
|
+
const timer = setTimeout(() => {
|
|
102
|
+
signal?.removeEventListener('abort', onAbort);
|
|
103
|
+
resolve();
|
|
104
|
+
}, ms);
|
|
105
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
106
|
+
});
|
|
107
|
+
}
|