@mindexec/cli 0.2.136 → 0.2.137
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/package.json +1 -1
- package/scripts/remote-registry-follower-smoke.mjs +123 -6
- package/server.js +471 -3
package/package.json
CHANGED
|
@@ -8,6 +8,7 @@ import net from 'node:net';
|
|
|
8
8
|
import os from 'node:os';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { WebSocketServer } from 'ws';
|
|
11
12
|
|
|
12
13
|
const BRIDGE_TOKEN = 'remote-registry-follower-smoke-token';
|
|
13
14
|
const PAIR_TOKEN = 'remote-registry-follower-pair-token';
|
|
@@ -84,6 +85,7 @@ function createRegistryTarget({ endpoint, endpointCandidates, leaseId, active =
|
|
|
84
85
|
|
|
85
86
|
function startFakeSupabase(getTarget) {
|
|
86
87
|
const requests = [];
|
|
88
|
+
const realtimeClients = new Set();
|
|
87
89
|
const server = createServer((req, res) => {
|
|
88
90
|
const parsed = new URL(req.url || '/', 'http://127.0.0.1');
|
|
89
91
|
requests.push({
|
|
@@ -108,6 +110,77 @@ function startFakeSupabase(getTarget) {
|
|
|
108
110
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
109
111
|
res.end(JSON.stringify({ error: 'not-found' }));
|
|
110
112
|
});
|
|
113
|
+
const realtime = new WebSocketServer({ noServer: true });
|
|
114
|
+
|
|
115
|
+
realtime.on('connection', (ws, req) => {
|
|
116
|
+
realtimeClients.add(ws);
|
|
117
|
+
ws.on('message', data => {
|
|
118
|
+
const text = data.toString();
|
|
119
|
+
let message = null;
|
|
120
|
+
try {
|
|
121
|
+
message = JSON.parse(text);
|
|
122
|
+
} catch {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (message.event === 'phx_join') {
|
|
127
|
+
assert.equal(String(req.headers.apikey || ''), SUPABASE_KEY);
|
|
128
|
+
assert.equal(String(req.headers.authorization || ''), `Bearer ${ACCESS_TOKEN}`);
|
|
129
|
+
assert.equal(message.payload?.access_token, ACCESS_TOKEN);
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
message.payload?.config?.postgres_changes?.[0],
|
|
132
|
+
{
|
|
133
|
+
event: '*',
|
|
134
|
+
schema: 'public',
|
|
135
|
+
table: 'remote_host_targets',
|
|
136
|
+
filter: `user_id=eq.${USER_ID}`
|
|
137
|
+
});
|
|
138
|
+
ws.remoteRegistryTopic = message.topic;
|
|
139
|
+
ws.send(JSON.stringify({
|
|
140
|
+
topic: message.topic,
|
|
141
|
+
event: 'phx_reply',
|
|
142
|
+
payload: {
|
|
143
|
+
status: 'ok',
|
|
144
|
+
response: {
|
|
145
|
+
postgres_changes: [
|
|
146
|
+
{
|
|
147
|
+
id: 1,
|
|
148
|
+
event: '*',
|
|
149
|
+
schema: 'public',
|
|
150
|
+
table: 'remote_host_targets'
|
|
151
|
+
}
|
|
152
|
+
]
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
ref: message.ref,
|
|
156
|
+
join_ref: message.join_ref
|
|
157
|
+
}));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (message.event === 'heartbeat') {
|
|
162
|
+
ws.send(JSON.stringify({
|
|
163
|
+
topic: message.topic,
|
|
164
|
+
event: 'phx_reply',
|
|
165
|
+
payload: { status: 'ok', response: {} },
|
|
166
|
+
ref: message.ref
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
ws.on('close', () => realtimeClients.delete(ws));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
server.on('upgrade', (req, socket, head) => {
|
|
174
|
+
const parsed = new URL(req.url || '/', 'http://127.0.0.1');
|
|
175
|
+
if (parsed.pathname !== '/realtime/v1/websocket') {
|
|
176
|
+
socket.destroy();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
realtime.handleUpgrade(req, socket, head, ws => {
|
|
181
|
+
realtime.emit('connection', ws, req);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
111
184
|
|
|
112
185
|
return new Promise((resolve, reject) => {
|
|
113
186
|
server.once('error', reject);
|
|
@@ -117,7 +190,41 @@ function startFakeSupabase(getTarget) {
|
|
|
117
190
|
resolve({
|
|
118
191
|
url: `http://127.0.0.1:${port}`,
|
|
119
192
|
requests,
|
|
120
|
-
|
|
193
|
+
realtimeClients,
|
|
194
|
+
broadcastChange: (type = 'UPDATE') => {
|
|
195
|
+
for (const client of realtimeClients) {
|
|
196
|
+
if (client.readyState !== 1 || !client.remoteRegistryTopic) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
client.send(JSON.stringify({
|
|
201
|
+
topic: client.remoteRegistryTopic,
|
|
202
|
+
event: 'postgres_changes',
|
|
203
|
+
payload: {
|
|
204
|
+
ids: [1],
|
|
205
|
+
data: {
|
|
206
|
+
schema: 'public',
|
|
207
|
+
table: 'remote_host_targets',
|
|
208
|
+
commit_timestamp: new Date().toISOString(),
|
|
209
|
+
type,
|
|
210
|
+
record: getTarget(),
|
|
211
|
+
old_record: null
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
ref: null
|
|
215
|
+
}));
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
stop: () => new Promise(done => {
|
|
219
|
+
for (const client of realtimeClients) {
|
|
220
|
+
try {
|
|
221
|
+
client.close();
|
|
222
|
+
} catch {
|
|
223
|
+
// best effort only
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
realtime.close(() => server.close(done));
|
|
227
|
+
})
|
|
121
228
|
});
|
|
122
229
|
});
|
|
123
230
|
});
|
|
@@ -140,8 +247,10 @@ function spawnBridge({ bridgePort, remoteHubPort, workspacePath, authRoot, label
|
|
|
140
247
|
WORKSPACE_PATH: workspacePath,
|
|
141
248
|
MINDEXEC_AUTH_DATA_ROOT: authRoot,
|
|
142
249
|
MINDEXEC_REMOTE_REGISTRY_FOLLOWER: follower ? '1' : '0',
|
|
143
|
-
MINDEXEC_REMOTE_REGISTRY_POLL_MS: '
|
|
250
|
+
MINDEXEC_REMOTE_REGISTRY_POLL_MS: '10000',
|
|
144
251
|
MINDEXEC_REMOTE_REGISTRY_FAST_RETRY_MS: '250',
|
|
252
|
+
MINDEXEC_REMOTE_REGISTRY_REALTIME_RECONNECT_MS: '250',
|
|
253
|
+
MINDEXEC_REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS: '100',
|
|
145
254
|
SUPABASE_URL: supabaseUrl,
|
|
146
255
|
SUPABASE_KEY,
|
|
147
256
|
NO_COLOR: '1'
|
|
@@ -196,7 +305,7 @@ async function writeSession(authRoot) {
|
|
|
196
305
|
}), 'utf8');
|
|
197
306
|
}
|
|
198
307
|
|
|
199
|
-
async function waitForManagedAgent(bridge, managerEndpoint, label) {
|
|
308
|
+
async function waitForManagedAgent(bridge, managerEndpoint, label, timeoutMs = 20000) {
|
|
200
309
|
return await waitFor(async () => {
|
|
201
310
|
const result = await fetchJson(`${bridge.baseUrl}/api/remote/agent/status`);
|
|
202
311
|
const agent = result.payload;
|
|
@@ -205,7 +314,7 @@ async function waitForManagedAgent(bridge, managerEndpoint, label) {
|
|
|
205
314
|
&& String(agent.manager || '') === managerEndpoint
|
|
206
315
|
? agent
|
|
207
316
|
: null;
|
|
208
|
-
},
|
|
317
|
+
}, timeoutMs, `${label} managed agent\n${bridge.details()}`);
|
|
209
318
|
}
|
|
210
319
|
|
|
211
320
|
async function waitForConnectedDevice(bridge, label) {
|
|
@@ -280,14 +389,18 @@ async function main() {
|
|
|
280
389
|
assert.equal(firstAgent.manager, hostAEndpoint);
|
|
281
390
|
assert.equal(firstAgent.managerCandidates?.[0], staleEndpoint, JSON.stringify(firstAgent.managerCandidates));
|
|
282
391
|
await waitForConnectedDevice(hostA, 'host-a');
|
|
392
|
+
const firstStatus = await fetchJson(`${client.baseUrl}/api/status`);
|
|
393
|
+
assert.equal(firstStatus.payload?.remoteRegistryRealtime?.subscribed, true, JSON.stringify(firstStatus.payload?.remoteRegistryRealtime));
|
|
394
|
+
assert.ok(fakeSupabase.realtimeClients.size >= 1, 'expected LocalBridge realtime watcher to connect');
|
|
283
395
|
|
|
284
396
|
currentTarget = createRegistryTarget({
|
|
285
397
|
endpoint: hostBEndpoint,
|
|
286
398
|
endpointCandidates: [hostBEndpoint],
|
|
287
399
|
leaseId: 'lease-b'
|
|
288
400
|
});
|
|
401
|
+
fakeSupabase.broadcastChange('UPDATE');
|
|
289
402
|
|
|
290
|
-
const secondAgent = await waitForManagedAgent(client, hostBEndpoint, 'host-b');
|
|
403
|
+
const secondAgent = await waitForManagedAgent(client, hostBEndpoint, 'host-b', 9000);
|
|
291
404
|
assert.equal(secondAgent.usingNpx, false, JSON.stringify(secondAgent));
|
|
292
405
|
assert.match(String(secondAgent.launcher || ''), /mindexec-remote-fast/i);
|
|
293
406
|
assert.equal(secondAgent.manager, hostBEndpoint);
|
|
@@ -299,6 +412,7 @@ async function main() {
|
|
|
299
412
|
active: false,
|
|
300
413
|
expires_at: new Date(Date.now() - 1000).toISOString()
|
|
301
414
|
};
|
|
415
|
+
fakeSupabase.broadcastChange('UPDATE');
|
|
302
416
|
|
|
303
417
|
await waitFor(async () => {
|
|
304
418
|
const result = await fetchJson(`${client.baseUrl}/api/remote/agent/status`);
|
|
@@ -306,7 +420,10 @@ async function main() {
|
|
|
306
420
|
&& String(result.payload?.lastError || '') === 'registry-inactive'
|
|
307
421
|
? result.payload
|
|
308
422
|
: null;
|
|
309
|
-
},
|
|
423
|
+
}, 9000, `registry inactive stop\n${client.details()}`);
|
|
424
|
+
const finalStatus = await fetchJson(`${client.baseUrl}/api/status`);
|
|
425
|
+
assert.ok(finalStatus.payload?.remoteRegistryRealtime?.changes >= 2, JSON.stringify(finalStatus.payload?.remoteRegistryRealtime));
|
|
426
|
+
assert.ok(finalStatus.payload?.remoteRegistryRealtime?.wakeups >= 2, JSON.stringify(finalStatus.payload?.remoteRegistryRealtime));
|
|
310
427
|
|
|
311
428
|
assert.ok(fakeSupabase.requests.length >= 2, JSON.stringify(fakeSupabase.requests));
|
|
312
429
|
console.log('Remote registry follower smoke OK');
|
package/server.js
CHANGED
|
@@ -17,7 +17,7 @@ import multer from 'multer';
|
|
|
17
17
|
import crypto from 'crypto';
|
|
18
18
|
import sharp from 'sharp';
|
|
19
19
|
import { createServer } from 'http';
|
|
20
|
-
import { WebSocketServer } from 'ws';
|
|
20
|
+
import { WebSocket, WebSocketServer } from 'ws';
|
|
21
21
|
import chokidar from 'chokidar';
|
|
22
22
|
import Parser from 'web-tree-sitter';
|
|
23
23
|
import { fileURLToPath } from 'url';
|
|
@@ -3141,6 +3141,11 @@ const REMOTE_REGISTRY_FOLLOWER_ENABLED = !/^(0|false|no|off)$/i.test(String(proc
|
|
|
3141
3141
|
const REMOTE_REGISTRY_FOLLOWER_POLL_MS = Math.max(1500, Number(process.env.MINDEXEC_REMOTE_REGISTRY_POLL_MS || 5000) || 5000);
|
|
3142
3142
|
const REMOTE_REGISTRY_FOLLOWER_FAST_RETRY_MS = Math.max(500, Number(process.env.MINDEXEC_REMOTE_REGISTRY_FAST_RETRY_MS || 1200) || 1200);
|
|
3143
3143
|
const REMOTE_REGISTRY_FOLLOWER_MISSING_SESSION_STOP_COUNT = 3;
|
|
3144
|
+
const REMOTE_REGISTRY_REALTIME_ENABLED = REMOTE_REGISTRY_FOLLOWER_ENABLED
|
|
3145
|
+
&& !/^(0|false|no|off)$/i.test(String(process.env.MINDEXEC_REMOTE_REGISTRY_REALTIME || 'true'));
|
|
3146
|
+
const REMOTE_REGISTRY_REALTIME_HEARTBEAT_MS = Math.max(10000, Number(process.env.MINDEXEC_REMOTE_REGISTRY_REALTIME_HEARTBEAT_MS || 25000) || 25000);
|
|
3147
|
+
const REMOTE_REGISTRY_REALTIME_RECONNECT_MS = Math.max(1000, Number(process.env.MINDEXEC_REMOTE_REGISTRY_REALTIME_RECONNECT_MS || 2500) || 2500);
|
|
3148
|
+
const REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS = Math.max(100, Number(process.env.MINDEXEC_REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS || 250) || 250);
|
|
3144
3149
|
let remoteAgentState = createRemoteAgentIdleState();
|
|
3145
3150
|
let remoteAgentSyncReportState = null;
|
|
3146
3151
|
let remoteAgentSyncReportLogKey = '';
|
|
@@ -3153,6 +3158,15 @@ let remoteRegistryFollowerTimer = null;
|
|
|
3153
3158
|
let remoteRegistryFollowerInFlight = false;
|
|
3154
3159
|
let remoteRegistryFollowerStarted = false;
|
|
3155
3160
|
let remoteRegistryFollowerConsecutiveMissingSession = 0;
|
|
3161
|
+
let remoteRegistryRealtimeSocket = null;
|
|
3162
|
+
let remoteRegistryRealtimeSessionKey = '';
|
|
3163
|
+
let remoteRegistryRealtimeTopic = '';
|
|
3164
|
+
let remoteRegistryRealtimeJoinRef = '';
|
|
3165
|
+
let remoteRegistryRealtimeRef = 0;
|
|
3166
|
+
let remoteRegistryRealtimeHeartbeatTimer = null;
|
|
3167
|
+
let remoteRegistryRealtimeReconnectTimer = null;
|
|
3168
|
+
let remoteRegistryRealtimeReconnectContext = null;
|
|
3169
|
+
let remoteRegistryRealtimeLastWakeAt = 0;
|
|
3156
3170
|
let remoteRegistryFollowerState = {
|
|
3157
3171
|
enabled: REMOTE_REGISTRY_FOLLOWER_ENABLED,
|
|
3158
3172
|
status: REMOTE_REGISTRY_FOLLOWER_ENABLED ? 'idle' : 'disabled',
|
|
@@ -3166,6 +3180,26 @@ let remoteRegistryFollowerState = {
|
|
|
3166
3180
|
targetNodeId: '',
|
|
3167
3181
|
authenticated: false
|
|
3168
3182
|
};
|
|
3183
|
+
let remoteRegistryRealtimeState = {
|
|
3184
|
+
enabled: REMOTE_REGISTRY_REALTIME_ENABLED,
|
|
3185
|
+
status: REMOTE_REGISTRY_REALTIME_ENABLED ? 'idle' : 'disabled',
|
|
3186
|
+
reason: '',
|
|
3187
|
+
connected: false,
|
|
3188
|
+
subscribed: false,
|
|
3189
|
+
userId: '',
|
|
3190
|
+
topic: '',
|
|
3191
|
+
lastOpenAt: '',
|
|
3192
|
+
lastJoinAt: '',
|
|
3193
|
+
lastMessageAt: '',
|
|
3194
|
+
lastChangeAt: '',
|
|
3195
|
+
lastCloseAt: '',
|
|
3196
|
+
lastErrorAt: '',
|
|
3197
|
+
lastError: '',
|
|
3198
|
+
reconnects: 0,
|
|
3199
|
+
messages: 0,
|
|
3200
|
+
changes: 0,
|
|
3201
|
+
wakeups: 0
|
|
3202
|
+
};
|
|
3169
3203
|
|
|
3170
3204
|
function createRemoteAgentIdleState(overrides = {}) {
|
|
3171
3205
|
return {
|
|
@@ -3617,6 +3651,11 @@ function isRemoteAgentProcessRunning() {
|
|
|
3617
3651
|
&& !proc.killed;
|
|
3618
3652
|
}
|
|
3619
3653
|
|
|
3654
|
+
function isRemoteAgentRegistryOwned() {
|
|
3655
|
+
return isRemoteAgentProcessRunning()
|
|
3656
|
+
&& /registry/i.test(String(remoteAgentState.source || ''));
|
|
3657
|
+
}
|
|
3658
|
+
|
|
3620
3659
|
function isLocalRemoteHostTargetActive() {
|
|
3621
3660
|
const status = remoteHub.getStatus({ includeSecrets: false });
|
|
3622
3661
|
return status?.hostTargetActive === true
|
|
@@ -4417,6 +4456,424 @@ function updateRemoteRegistryFollowerState(patch = {}) {
|
|
|
4417
4456
|
emitBridgeEvent('RemoteRegistryFollowerUpdated', serializeRemoteRegistryFollowerState());
|
|
4418
4457
|
}
|
|
4419
4458
|
|
|
4459
|
+
function serializeRemoteRegistryRealtimeState() {
|
|
4460
|
+
return {
|
|
4461
|
+
...remoteRegistryRealtimeState,
|
|
4462
|
+
heartbeatMs: REMOTE_REGISTRY_REALTIME_HEARTBEAT_MS,
|
|
4463
|
+
reconnectMs: REMOTE_REGISTRY_REALTIME_RECONNECT_MS,
|
|
4464
|
+
debounceMs: REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS
|
|
4465
|
+
};
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
function updateRemoteRegistryRealtimeState(patch = {}) {
|
|
4469
|
+
remoteRegistryRealtimeState = {
|
|
4470
|
+
...remoteRegistryRealtimeState,
|
|
4471
|
+
...patch,
|
|
4472
|
+
enabled: REMOTE_REGISTRY_REALTIME_ENABLED
|
|
4473
|
+
};
|
|
4474
|
+
emitBridgeEvent('RemoteRegistryRealtimeUpdated', serializeRemoteRegistryRealtimeState());
|
|
4475
|
+
}
|
|
4476
|
+
|
|
4477
|
+
function clearRemoteRegistryRealtimeTimers() {
|
|
4478
|
+
if (remoteRegistryRealtimeHeartbeatTimer) {
|
|
4479
|
+
clearInterval(remoteRegistryRealtimeHeartbeatTimer);
|
|
4480
|
+
remoteRegistryRealtimeHeartbeatTimer = null;
|
|
4481
|
+
}
|
|
4482
|
+
if (remoteRegistryRealtimeReconnectTimer) {
|
|
4483
|
+
clearTimeout(remoteRegistryRealtimeReconnectTimer);
|
|
4484
|
+
remoteRegistryRealtimeReconnectTimer = null;
|
|
4485
|
+
}
|
|
4486
|
+
}
|
|
4487
|
+
|
|
4488
|
+
function closeRemoteRegistryRealtime(reason = 'stopped') {
|
|
4489
|
+
clearRemoteRegistryRealtimeTimers();
|
|
4490
|
+
remoteRegistryRealtimeReconnectContext = null;
|
|
4491
|
+
const socket = remoteRegistryRealtimeSocket;
|
|
4492
|
+
remoteRegistryRealtimeSocket = null;
|
|
4493
|
+
remoteRegistryRealtimeSessionKey = '';
|
|
4494
|
+
remoteRegistryRealtimeTopic = '';
|
|
4495
|
+
remoteRegistryRealtimeJoinRef = '';
|
|
4496
|
+
|
|
4497
|
+
if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
|
|
4498
|
+
try {
|
|
4499
|
+
socket.close(1000, String(reason || 'stopped').slice(0, 120));
|
|
4500
|
+
} catch {
|
|
4501
|
+
// Best-effort close only.
|
|
4502
|
+
}
|
|
4503
|
+
}
|
|
4504
|
+
|
|
4505
|
+
updateRemoteRegistryRealtimeState({
|
|
4506
|
+
status: REMOTE_REGISTRY_REALTIME_ENABLED ? 'stopped' : 'disabled',
|
|
4507
|
+
reason,
|
|
4508
|
+
connected: false,
|
|
4509
|
+
subscribed: false,
|
|
4510
|
+
userId: '',
|
|
4511
|
+
topic: ''
|
|
4512
|
+
});
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
function buildRemoteRegistryRealtimeUrl(config) {
|
|
4516
|
+
try {
|
|
4517
|
+
const url = new URL('/realtime/v1/websocket', config.url);
|
|
4518
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
4519
|
+
url.searchParams.set('apikey', config.key);
|
|
4520
|
+
url.searchParams.set('vsn', '1.0.0');
|
|
4521
|
+
return url.toString();
|
|
4522
|
+
} catch {
|
|
4523
|
+
return '';
|
|
4524
|
+
}
|
|
4525
|
+
}
|
|
4526
|
+
|
|
4527
|
+
function getRemoteRegistryRealtimeSessionKey(config, session) {
|
|
4528
|
+
const tokenHash = crypto
|
|
4529
|
+
.createHash('sha256')
|
|
4530
|
+
.update(String(session?.accessToken || ''))
|
|
4531
|
+
.digest('hex')
|
|
4532
|
+
.slice(0, 16);
|
|
4533
|
+
return [
|
|
4534
|
+
String(config?.url || '').replace(/\/+$/, ''),
|
|
4535
|
+
String(session?.userId || ''),
|
|
4536
|
+
tokenHash
|
|
4537
|
+
].join('|');
|
|
4538
|
+
}
|
|
4539
|
+
|
|
4540
|
+
function sendRemoteRegistryRealtimeMessage(socket, topic, event, payload = {}, options = {}) {
|
|
4541
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
4542
|
+
return '';
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
const ref = String(++remoteRegistryRealtimeRef);
|
|
4546
|
+
const message = {
|
|
4547
|
+
topic,
|
|
4548
|
+
event,
|
|
4549
|
+
payload,
|
|
4550
|
+
ref
|
|
4551
|
+
};
|
|
4552
|
+
if (options.joinRef) {
|
|
4553
|
+
message.join_ref = options.joinRef;
|
|
4554
|
+
}
|
|
4555
|
+
socket.send(JSON.stringify(message));
|
|
4556
|
+
return ref;
|
|
4557
|
+
}
|
|
4558
|
+
|
|
4559
|
+
function sendRemoteRegistryRealtimeHeartbeat(socket) {
|
|
4560
|
+
sendRemoteRegistryRealtimeMessage(socket, 'phoenix', 'heartbeat', {});
|
|
4561
|
+
}
|
|
4562
|
+
|
|
4563
|
+
function parseRemoteRegistryRealtimeMessage(data) {
|
|
4564
|
+
let raw = data;
|
|
4565
|
+
if (Buffer.isBuffer(raw)) {
|
|
4566
|
+
raw = raw.toString('utf8');
|
|
4567
|
+
}
|
|
4568
|
+
if (ArrayBuffer.isView(raw)) {
|
|
4569
|
+
raw = Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength).toString('utf8');
|
|
4570
|
+
}
|
|
4571
|
+
|
|
4572
|
+
const parsed = JSON.parse(String(raw || '{}'));
|
|
4573
|
+
if (Array.isArray(parsed)) {
|
|
4574
|
+
return {
|
|
4575
|
+
joinRef: parsed[0] ?? '',
|
|
4576
|
+
ref: parsed[1] ?? '',
|
|
4577
|
+
topic: parsed[2] ?? '',
|
|
4578
|
+
event: parsed[3] ?? '',
|
|
4579
|
+
payload: parsed[4] ?? {}
|
|
4580
|
+
};
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
return {
|
|
4584
|
+
joinRef: parsed.join_ref ?? parsed.joinRef ?? '',
|
|
4585
|
+
ref: parsed.ref ?? '',
|
|
4586
|
+
topic: parsed.topic ?? '',
|
|
4587
|
+
event: parsed.event ?? '',
|
|
4588
|
+
payload: parsed.payload ?? {}
|
|
4589
|
+
};
|
|
4590
|
+
}
|
|
4591
|
+
|
|
4592
|
+
function isRemoteRegistryRealtimeChange(message) {
|
|
4593
|
+
if (!message || message.event !== 'postgres_changes') {
|
|
4594
|
+
return false;
|
|
4595
|
+
}
|
|
4596
|
+
|
|
4597
|
+
const data = message.payload?.data || message.payload;
|
|
4598
|
+
const table = String(data?.table || data?.record?.table || '').trim();
|
|
4599
|
+
const schema = String(data?.schema || data?.record?.schema || 'public').trim();
|
|
4600
|
+
return table === 'remote_host_targets' && (!schema || schema === 'public');
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
function wakeRemoteRegistryFollowerFromRealtime(reason = 'realtime-change') {
|
|
4604
|
+
const now = Date.now();
|
|
4605
|
+
if (now - remoteRegistryRealtimeLastWakeAt < REMOTE_REGISTRY_REALTIME_DEBOUNCE_MS) {
|
|
4606
|
+
return;
|
|
4607
|
+
}
|
|
4608
|
+
|
|
4609
|
+
remoteRegistryRealtimeLastWakeAt = now;
|
|
4610
|
+
updateRemoteRegistryRealtimeState({
|
|
4611
|
+
wakeups: remoteRegistryRealtimeState.wakeups + 1,
|
|
4612
|
+
lastChangeAt: new Date().toISOString(),
|
|
4613
|
+
reason
|
|
4614
|
+
});
|
|
4615
|
+
wakeRemoteRegistryFollower(reason).catch(error => {
|
|
4616
|
+
updateRemoteRegistryRealtimeState({
|
|
4617
|
+
status: 'error',
|
|
4618
|
+
reason: 'wake-failed',
|
|
4619
|
+
lastError: error?.message || String(error || ''),
|
|
4620
|
+
lastErrorAt: new Date().toISOString()
|
|
4621
|
+
});
|
|
4622
|
+
});
|
|
4623
|
+
}
|
|
4624
|
+
|
|
4625
|
+
function scheduleRemoteRegistryRealtimeReconnect(reason = 'reconnect') {
|
|
4626
|
+
if (!REMOTE_REGISTRY_REALTIME_ENABLED || isShuttingDown || !remoteRegistryRealtimeReconnectContext) {
|
|
4627
|
+
return;
|
|
4628
|
+
}
|
|
4629
|
+
|
|
4630
|
+
if (remoteRegistryRealtimeReconnectTimer) {
|
|
4631
|
+
return;
|
|
4632
|
+
}
|
|
4633
|
+
|
|
4634
|
+
updateRemoteRegistryRealtimeState({
|
|
4635
|
+
status: 'reconnecting',
|
|
4636
|
+
reason,
|
|
4637
|
+
connected: false,
|
|
4638
|
+
subscribed: false,
|
|
4639
|
+
reconnects: remoteRegistryRealtimeState.reconnects + 1
|
|
4640
|
+
});
|
|
4641
|
+
remoteRegistryRealtimeReconnectTimer = setTimeout(() => {
|
|
4642
|
+
remoteRegistryRealtimeReconnectTimer = null;
|
|
4643
|
+
const context = remoteRegistryRealtimeReconnectContext;
|
|
4644
|
+
if (!context || isShuttingDown) {
|
|
4645
|
+
return;
|
|
4646
|
+
}
|
|
4647
|
+
ensureRemoteRegistryRealtimeSubscription(context.config, context.session, 'realtime-reconnect')
|
|
4648
|
+
.catch(error => {
|
|
4649
|
+
updateRemoteRegistryRealtimeState({
|
|
4650
|
+
status: 'error',
|
|
4651
|
+
reason: 'reconnect-failed',
|
|
4652
|
+
lastError: error?.message || String(error || ''),
|
|
4653
|
+
lastErrorAt: new Date().toISOString()
|
|
4654
|
+
});
|
|
4655
|
+
scheduleRemoteRegistryRealtimeReconnect('reconnect-failed');
|
|
4656
|
+
});
|
|
4657
|
+
}, REMOTE_REGISTRY_REALTIME_RECONNECT_MS);
|
|
4658
|
+
remoteRegistryRealtimeReconnectTimer?.unref?.();
|
|
4659
|
+
}
|
|
4660
|
+
|
|
4661
|
+
async function ensureRemoteRegistryRealtimeSubscription(config, session, trigger = 'sync') {
|
|
4662
|
+
if (!REMOTE_REGISTRY_REALTIME_ENABLED) {
|
|
4663
|
+
closeRemoteRegistryRealtime('disabled');
|
|
4664
|
+
return serializeRemoteRegistryRealtimeState();
|
|
4665
|
+
}
|
|
4666
|
+
|
|
4667
|
+
if (!config?.url || !config?.key || !session?.accessToken || !session?.userId) {
|
|
4668
|
+
closeRemoteRegistryRealtime('not-authenticated');
|
|
4669
|
+
return serializeRemoteRegistryRealtimeState();
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
const sessionKey = getRemoteRegistryRealtimeSessionKey(config, session);
|
|
4673
|
+
if (remoteRegistryRealtimeSocket
|
|
4674
|
+
&& remoteRegistryRealtimeSessionKey === sessionKey
|
|
4675
|
+
&& (remoteRegistryRealtimeSocket.readyState === WebSocket.OPEN
|
|
4676
|
+
|| remoteRegistryRealtimeSocket.readyState === WebSocket.CONNECTING)) {
|
|
4677
|
+
return serializeRemoteRegistryRealtimeState();
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4680
|
+
closeRemoteRegistryRealtime('replaced');
|
|
4681
|
+
const wsUrl = buildRemoteRegistryRealtimeUrl(config);
|
|
4682
|
+
if (!wsUrl) {
|
|
4683
|
+
updateRemoteRegistryRealtimeState({
|
|
4684
|
+
status: 'error',
|
|
4685
|
+
reason: 'invalid-realtime-url',
|
|
4686
|
+
lastError: 'invalid-realtime-url',
|
|
4687
|
+
lastErrorAt: new Date().toISOString()
|
|
4688
|
+
});
|
|
4689
|
+
return serializeRemoteRegistryRealtimeState();
|
|
4690
|
+
}
|
|
4691
|
+
|
|
4692
|
+
const topic = `realtime:remote-host-targets-${String(session.userId).replace(/[^a-zA-Z0-9]/g, '') || 'user'}`;
|
|
4693
|
+
const socket = new WebSocket(wsUrl, {
|
|
4694
|
+
headers: {
|
|
4695
|
+
apikey: config.key,
|
|
4696
|
+
Authorization: `Bearer ${session.accessToken}`
|
|
4697
|
+
}
|
|
4698
|
+
});
|
|
4699
|
+
|
|
4700
|
+
remoteRegistryRealtimeSocket = socket;
|
|
4701
|
+
remoteRegistryRealtimeSessionKey = sessionKey;
|
|
4702
|
+
remoteRegistryRealtimeTopic = topic;
|
|
4703
|
+
remoteRegistryRealtimeReconnectContext = {
|
|
4704
|
+
config: { url: config.url, key: config.key },
|
|
4705
|
+
session: {
|
|
4706
|
+
accessToken: session.accessToken,
|
|
4707
|
+
userId: session.userId,
|
|
4708
|
+
expiresAtMs: session.expiresAtMs
|
|
4709
|
+
}
|
|
4710
|
+
};
|
|
4711
|
+
|
|
4712
|
+
updateRemoteRegistryRealtimeState({
|
|
4713
|
+
status: 'connecting',
|
|
4714
|
+
reason: trigger,
|
|
4715
|
+
connected: false,
|
|
4716
|
+
subscribed: false,
|
|
4717
|
+
userId: session.userId,
|
|
4718
|
+
topic,
|
|
4719
|
+
lastError: ''
|
|
4720
|
+
});
|
|
4721
|
+
|
|
4722
|
+
socket.on('open', () => {
|
|
4723
|
+
if (remoteRegistryRealtimeSocket !== socket) {
|
|
4724
|
+
return;
|
|
4725
|
+
}
|
|
4726
|
+
|
|
4727
|
+
const now = new Date().toISOString();
|
|
4728
|
+
remoteRegistryRealtimeJoinRef = String(++remoteRegistryRealtimeRef);
|
|
4729
|
+
updateRemoteRegistryRealtimeState({
|
|
4730
|
+
status: 'joining',
|
|
4731
|
+
reason: 'socket-open',
|
|
4732
|
+
connected: true,
|
|
4733
|
+
subscribed: false,
|
|
4734
|
+
lastOpenAt: now,
|
|
4735
|
+
lastJoinAt: now
|
|
4736
|
+
});
|
|
4737
|
+
socket.send(JSON.stringify({
|
|
4738
|
+
topic,
|
|
4739
|
+
event: 'phx_join',
|
|
4740
|
+
payload: {
|
|
4741
|
+
config: {
|
|
4742
|
+
broadcast: { ack: false, self: false },
|
|
4743
|
+
presence: { enabled: false },
|
|
4744
|
+
postgres_changes: [
|
|
4745
|
+
{
|
|
4746
|
+
event: '*',
|
|
4747
|
+
schema: 'public',
|
|
4748
|
+
table: 'remote_host_targets',
|
|
4749
|
+
filter: `user_id=eq.${session.userId}`
|
|
4750
|
+
}
|
|
4751
|
+
],
|
|
4752
|
+
private: false
|
|
4753
|
+
},
|
|
4754
|
+
access_token: session.accessToken
|
|
4755
|
+
},
|
|
4756
|
+
ref: remoteRegistryRealtimeJoinRef,
|
|
4757
|
+
join_ref: remoteRegistryRealtimeJoinRef
|
|
4758
|
+
}));
|
|
4759
|
+
|
|
4760
|
+
if (remoteRegistryRealtimeHeartbeatTimer) {
|
|
4761
|
+
clearInterval(remoteRegistryRealtimeHeartbeatTimer);
|
|
4762
|
+
}
|
|
4763
|
+
remoteRegistryRealtimeHeartbeatTimer = setInterval(
|
|
4764
|
+
() => sendRemoteRegistryRealtimeHeartbeat(socket),
|
|
4765
|
+
REMOTE_REGISTRY_REALTIME_HEARTBEAT_MS);
|
|
4766
|
+
remoteRegistryRealtimeHeartbeatTimer?.unref?.();
|
|
4767
|
+
});
|
|
4768
|
+
|
|
4769
|
+
socket.on('message', data => {
|
|
4770
|
+
if (remoteRegistryRealtimeSocket !== socket) {
|
|
4771
|
+
return;
|
|
4772
|
+
}
|
|
4773
|
+
|
|
4774
|
+
let message = null;
|
|
4775
|
+
try {
|
|
4776
|
+
message = parseRemoteRegistryRealtimeMessage(data);
|
|
4777
|
+
} catch (error) {
|
|
4778
|
+
updateRemoteRegistryRealtimeState({
|
|
4779
|
+
status: 'error',
|
|
4780
|
+
reason: 'message-parse-failed',
|
|
4781
|
+
lastError: error?.message || String(error || ''),
|
|
4782
|
+
lastErrorAt: new Date().toISOString()
|
|
4783
|
+
});
|
|
4784
|
+
return;
|
|
4785
|
+
}
|
|
4786
|
+
|
|
4787
|
+
updateRemoteRegistryRealtimeState({
|
|
4788
|
+
messages: remoteRegistryRealtimeState.messages + 1,
|
|
4789
|
+
lastMessageAt: new Date().toISOString()
|
|
4790
|
+
});
|
|
4791
|
+
|
|
4792
|
+
if (message.event === 'phx_reply' && message.ref === remoteRegistryRealtimeJoinRef) {
|
|
4793
|
+
const ok = String(message.payload?.status || '').toLowerCase() === 'ok';
|
|
4794
|
+
updateRemoteRegistryRealtimeState({
|
|
4795
|
+
status: ok ? 'subscribed' : 'error',
|
|
4796
|
+
reason: ok ? 'joined' : 'join-failed',
|
|
4797
|
+
subscribed: ok,
|
|
4798
|
+
lastError: ok ? '' : JSON.stringify(message.payload || {}).slice(0, 240),
|
|
4799
|
+
lastErrorAt: ok ? remoteRegistryRealtimeState.lastErrorAt : new Date().toISOString()
|
|
4800
|
+
});
|
|
4801
|
+
return;
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
if (message.event === 'system') {
|
|
4805
|
+
const status = String(message.payload?.status || '').toLowerCase();
|
|
4806
|
+
if (status === 'ok') {
|
|
4807
|
+
updateRemoteRegistryRealtimeState({
|
|
4808
|
+
status: 'subscribed',
|
|
4809
|
+
reason: 'system-ok',
|
|
4810
|
+
subscribed: true
|
|
4811
|
+
});
|
|
4812
|
+
} else if (status === 'error' || status === 'timeout') {
|
|
4813
|
+
updateRemoteRegistryRealtimeState({
|
|
4814
|
+
status: 'error',
|
|
4815
|
+
reason: 'system-error',
|
|
4816
|
+
subscribed: false,
|
|
4817
|
+
lastError: String(message.payload?.message || status).slice(0, 240),
|
|
4818
|
+
lastErrorAt: new Date().toISOString()
|
|
4819
|
+
});
|
|
4820
|
+
}
|
|
4821
|
+
return;
|
|
4822
|
+
}
|
|
4823
|
+
|
|
4824
|
+
if (message.event === 'phx_error' || message.event === 'phx_close') {
|
|
4825
|
+
updateRemoteRegistryRealtimeState({
|
|
4826
|
+
status: 'error',
|
|
4827
|
+
reason: message.event,
|
|
4828
|
+
subscribed: false,
|
|
4829
|
+
lastError: message.event,
|
|
4830
|
+
lastErrorAt: new Date().toISOString()
|
|
4831
|
+
});
|
|
4832
|
+
scheduleRemoteRegistryRealtimeReconnect(message.event);
|
|
4833
|
+
return;
|
|
4834
|
+
}
|
|
4835
|
+
|
|
4836
|
+
if (isRemoteRegistryRealtimeChange(message)) {
|
|
4837
|
+
updateRemoteRegistryRealtimeState({
|
|
4838
|
+
changes: remoteRegistryRealtimeState.changes + 1
|
|
4839
|
+
});
|
|
4840
|
+
wakeRemoteRegistryFollowerFromRealtime('realtime-change');
|
|
4841
|
+
}
|
|
4842
|
+
});
|
|
4843
|
+
|
|
4844
|
+
socket.on('close', (code, reasonBuffer) => {
|
|
4845
|
+
if (remoteRegistryRealtimeSocket !== socket) {
|
|
4846
|
+
return;
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
clearRemoteRegistryRealtimeTimers();
|
|
4850
|
+
remoteRegistryRealtimeSocket = null;
|
|
4851
|
+
updateRemoteRegistryRealtimeState({
|
|
4852
|
+
status: 'closed',
|
|
4853
|
+
reason: reasonBuffer?.toString?.() || `close:${code}`,
|
|
4854
|
+
connected: false,
|
|
4855
|
+
subscribed: false,
|
|
4856
|
+
lastCloseAt: new Date().toISOString()
|
|
4857
|
+
});
|
|
4858
|
+
scheduleRemoteRegistryRealtimeReconnect(`close:${code}`);
|
|
4859
|
+
});
|
|
4860
|
+
|
|
4861
|
+
socket.on('error', error => {
|
|
4862
|
+
if (remoteRegistryRealtimeSocket !== socket) {
|
|
4863
|
+
return;
|
|
4864
|
+
}
|
|
4865
|
+
|
|
4866
|
+
updateRemoteRegistryRealtimeState({
|
|
4867
|
+
status: 'error',
|
|
4868
|
+
reason: 'socket-error',
|
|
4869
|
+
lastError: error?.message || String(error || ''),
|
|
4870
|
+
lastErrorAt: new Date().toISOString()
|
|
4871
|
+
});
|
|
4872
|
+
});
|
|
4873
|
+
|
|
4874
|
+
return serializeRemoteRegistryRealtimeState();
|
|
4875
|
+
}
|
|
4876
|
+
|
|
4420
4877
|
function readSupabaseRuntimeConfig() {
|
|
4421
4878
|
const envUrl = String(process.env.SUPABASE_URL || process.env.MINDEXEC_SUPABASE_URL || '').trim();
|
|
4422
4879
|
const envKey = String(
|
|
@@ -4643,6 +5100,7 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
|
|
|
4643
5100
|
try {
|
|
4644
5101
|
const config = readSupabaseRuntimeConfig();
|
|
4645
5102
|
if (!config.url || !config.key) {
|
|
5103
|
+
closeRemoteRegistryRealtime('supabase-config-missing');
|
|
4646
5104
|
updateRemoteRegistryFollowerState({
|
|
4647
5105
|
status: 'skipped',
|
|
4648
5106
|
reason: 'supabase-config-missing',
|
|
@@ -4664,9 +5122,10 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
|
|
|
4664
5122
|
const sessionPayload = await readStableAuthSessionPayload();
|
|
4665
5123
|
const session = sessionPayload.found ? parseSupabaseSessionForRegistry(sessionPayload.content) : null;
|
|
4666
5124
|
if (!session) {
|
|
5125
|
+
closeRemoteRegistryRealtime('registry-not-authenticated');
|
|
4667
5126
|
remoteRegistryFollowerConsecutiveMissingSession += 1;
|
|
4668
5127
|
if (remoteRegistryFollowerConsecutiveMissingSession >= REMOTE_REGISTRY_FOLLOWER_MISSING_SESSION_STOP_COUNT
|
|
4669
|
-
&&
|
|
5128
|
+
&& isRemoteAgentRegistryOwned()) {
|
|
4670
5129
|
await stopRemoteAgentConnection('registry-not-authenticated');
|
|
4671
5130
|
}
|
|
4672
5131
|
|
|
@@ -4694,6 +5153,7 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
|
|
|
4694
5153
|
|
|
4695
5154
|
remoteRegistryFollowerConsecutiveMissingSession = 0;
|
|
4696
5155
|
if (session.expiresAtMs && session.expiresAtMs <= Date.now()) {
|
|
5156
|
+
closeRemoteRegistryRealtime('session-expired');
|
|
4697
5157
|
updateRemoteRegistryFollowerState({
|
|
4698
5158
|
status: 'skipped',
|
|
4699
5159
|
reason: 'session-expired',
|
|
@@ -4713,6 +5173,7 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
|
|
|
4713
5173
|
}
|
|
4714
5174
|
|
|
4715
5175
|
const localHub = remoteHub.getStatus({ includeSecrets: false });
|
|
5176
|
+
await ensureRemoteRegistryRealtimeSubscription(config, session, trigger);
|
|
4716
5177
|
const target = await fetchRemoteRegistryTarget(config, session);
|
|
4717
5178
|
|
|
4718
5179
|
if (localHub?.hostTargetActive === true) {
|
|
@@ -4779,7 +5240,7 @@ async function runRemoteRegistryFollowerOnce(trigger = 'timer') {
|
|
|
4779
5240
|
}
|
|
4780
5241
|
|
|
4781
5242
|
if (!target?.active || isRemoteRegistryTargetExpired(target)) {
|
|
4782
|
-
if (
|
|
5243
|
+
if (isRemoteAgentRegistryOwned()) {
|
|
4783
5244
|
await stopRemoteAgentConnection('registry-inactive');
|
|
4784
5245
|
}
|
|
4785
5246
|
updateRemoteRegistryFollowerState({
|
|
@@ -9416,6 +9877,7 @@ app.get('/api/status', async (req, res) => {
|
|
|
9416
9877
|
remoteFrameWs: serializeRemoteFrameWsDiagnostics(),
|
|
9417
9878
|
remoteAgent: serializeRemoteAgentState(),
|
|
9418
9879
|
remoteRegistryFollower: serializeRemoteRegistryFollowerState(),
|
|
9880
|
+
remoteRegistryRealtime: serializeRemoteRegistryRealtimeState(),
|
|
9419
9881
|
shellJobsPath: '/api/shell/jobs',
|
|
9420
9882
|
companyCore: {
|
|
9421
9883
|
baseUrl: companyCoreBaseUrl,
|
|
@@ -11246,6 +11708,12 @@ async function shutdownBridge(signal) {
|
|
|
11246
11708
|
// Ignore registry follower shutdown errors
|
|
11247
11709
|
}
|
|
11248
11710
|
|
|
11711
|
+
try {
|
|
11712
|
+
closeRemoteRegistryRealtime('bridge-shutdown');
|
|
11713
|
+
} catch {
|
|
11714
|
+
// Ignore registry realtime shutdown errors
|
|
11715
|
+
}
|
|
11716
|
+
|
|
11249
11717
|
try {
|
|
11250
11718
|
await stopRemoteAgentConnection('bridge-shutdown');
|
|
11251
11719
|
} catch {
|