crewly 1.11.4 → 1.11.6
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/dist/backend/backend/src/constants.d.ts +22 -1
- package/dist/backend/backend/src/constants.d.ts.map +1 -1
- package/dist/backend/backend/src/constants.js +22 -1
- package/dist/backend/backend/src/constants.js.map +1 -1
- package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts +90 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.js +309 -0
- package/dist/backend/backend/src/services/backup/backup-archive.service.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.d.ts +75 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.js +134 -0
- package/dist/backend/backend/src/services/backup/backup-cloud.client.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.d.ts +78 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.js +358 -0
- package/dist/backend/backend/src/services/backup/backup-restore.service.js.map +1 -0
- package/dist/backend/backend/src/services/backup/backup.types.d.ts +163 -0
- package/dist/backend/backend/src/services/backup/backup.types.d.ts.map +1 -0
- package/dist/backend/backend/src/services/backup/backup.types.js +13 -0
- package/dist/backend/backend/src/services/backup/backup.types.js.map +1 -0
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts +29 -2
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -1
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.js +97 -13
- package/dist/backend/backend/src/services/cloud/cloud-sync.service.js.map +1 -1
- package/dist/cli/backend/src/constants.d.ts +22 -1
- package/dist/cli/backend/src/constants.d.ts.map +1 -1
- package/dist/cli/backend/src/constants.js +22 -1
- package/dist/cli/backend/src/constants.js.map +1 -1
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.d.ts +70 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.d.ts.map +1 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.js +427 -0
- package/dist/cli/backend/src/controllers/cloud/cloud-google-auth.controller.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts +90 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.js +309 -0
- package/dist/cli/backend/src/services/backup/backup-archive.service.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.d.ts +75 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.js +134 -0
- package/dist/cli/backend/src/services/backup/backup-cloud.client.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.d.ts +78 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.js +358 -0
- package/dist/cli/backend/src/services/backup/backup-restore.service.js.map +1 -0
- package/dist/cli/backend/src/services/backup/backup.types.d.ts +163 -0
- package/dist/cli/backend/src/services/backup/backup.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/backup/backup.types.js +13 -0
- package/dist/cli/backend/src/services/backup/backup.types.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts +410 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.js +863 -0
- package/dist/cli/backend/src/services/cloud/cloud-client.service.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts +292 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.js +1093 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.service.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts +328 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.js +171 -0
- package/dist/cli/backend/src/services/cloud/cloud-sync.types.js.map +1 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.d.ts +89 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.js +148 -0
- package/dist/cli/backend/src/services/cloud/device-identity.service.js.map +1 -0
- package/dist/cli/backend/src/services/user/user-identity.service.d.ts +86 -0
- package/dist/cli/backend/src/services/user/user-identity.service.d.ts.map +1 -0
- package/dist/cli/backend/src/services/user/user-identity.service.js +190 -0
- package/dist/cli/backend/src/services/user/user-identity.service.js.map +1 -0
- package/dist/cli/cli/src/commands/backup.d.ts +31 -0
- package/dist/cli/cli/src/commands/backup.d.ts.map +1 -0
- package/dist/cli/cli/src/commands/backup.js +280 -0
- package/dist/cli/cli/src/commands/backup.js.map +1 -0
- package/dist/cli/cli/src/index.js +10 -0
- package/dist/cli/cli/src/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1093 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloud Sync Service
|
|
3
|
+
*
|
|
4
|
+
* Singleton service that replaces the WebSocket Relay pairing model with a
|
|
5
|
+
* simpler heartbeat + polling architecture. All devices under the same Cloud
|
|
6
|
+
* account are automatically visible and can exchange messages.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Heartbeat: periodically uploads device status to Cloud
|
|
10
|
+
* - Device poll: periodically fetches the device list for the account
|
|
11
|
+
* - Message poll: periodically fetches pending messages for this device
|
|
12
|
+
* - Send: posts messages to specific devices via Cloud
|
|
13
|
+
*
|
|
14
|
+
* @see docs/cloud-sync-design.md
|
|
15
|
+
* @module services/cloud/cloud-sync.service
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from 'events';
|
|
18
|
+
import { LoggerService } from '../core/logger.service.js';
|
|
19
|
+
import { StorageService } from '../core/storage.service.js';
|
|
20
|
+
import { CLOUD_SYNC_CONSTANTS } from '../../constants.js';
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Helpers
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
/**
|
|
25
|
+
* Read the Crewly version from package.json (best-effort).
|
|
26
|
+
*
|
|
27
|
+
* @returns Version string or 'unknown'
|
|
28
|
+
*/
|
|
29
|
+
async function readVersion() {
|
|
30
|
+
try {
|
|
31
|
+
const { readFile } = await import('fs/promises');
|
|
32
|
+
const { join } = await import('path');
|
|
33
|
+
const pkg = JSON.parse(await readFile(join(process.cwd(), 'package.json'), 'utf-8'));
|
|
34
|
+
return pkg.version || 'unknown';
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return 'unknown';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Gather active team summaries from StorageService.
|
|
42
|
+
* Includes agent session details for cross-device routing.
|
|
43
|
+
*
|
|
44
|
+
* @returns Array of team summaries for heartbeat payload
|
|
45
|
+
*/
|
|
46
|
+
async function gatherTeamSummaries() {
|
|
47
|
+
try {
|
|
48
|
+
const storage = StorageService.getInstance();
|
|
49
|
+
const teams = await storage.getTeams();
|
|
50
|
+
return teams.map((team) => {
|
|
51
|
+
const members = team.members ?? [];
|
|
52
|
+
const activeMembers = members.filter((m) => m.agentStatus === 'active');
|
|
53
|
+
// Include session details for active agents so remote devices can route messages
|
|
54
|
+
const agents = activeMembers
|
|
55
|
+
.filter((m) => m.sessionName)
|
|
56
|
+
.map((m) => ({
|
|
57
|
+
sessionName: m.sessionName,
|
|
58
|
+
role: m.role || 'agent',
|
|
59
|
+
workingStatus: m.workingStatus || 'idle',
|
|
60
|
+
}));
|
|
61
|
+
return {
|
|
62
|
+
id: team.id,
|
|
63
|
+
name: team.name,
|
|
64
|
+
memberCount: members.length,
|
|
65
|
+
activeAgents: activeMembers.length,
|
|
66
|
+
agents,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Service
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
/**
|
|
78
|
+
* CloudSyncService singleton.
|
|
79
|
+
*
|
|
80
|
+
* Manages heartbeat, device discovery, and message polling for the
|
|
81
|
+
* Cloud Sync system. Extends EventEmitter to notify consumers of
|
|
82
|
+
* device and message changes.
|
|
83
|
+
*
|
|
84
|
+
* Events:
|
|
85
|
+
* - `devices_updated` — Fired when the device list changes. Payload: SyncDevice[]
|
|
86
|
+
* - `message` — Fired for each incoming message. Payload: IncomingMessage
|
|
87
|
+
* - `device_online` — Fired when a device comes online. Payload: SyncDevice
|
|
88
|
+
* - `device_offline` — Fired when a device goes offline. Payload: SyncDevice
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```typescript
|
|
92
|
+
* const sync = CloudSyncService.getInstance();
|
|
93
|
+
* sync.start({ cloudUrl, token, deviceId, deviceName });
|
|
94
|
+
* sync.on('message', (msg) => console.log('Received:', msg));
|
|
95
|
+
* sync.on('devices_updated', (devices) => console.log('Devices:', devices));
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export class CloudSyncService extends EventEmitter {
|
|
99
|
+
static instance = null;
|
|
100
|
+
logger;
|
|
101
|
+
/** Current service state */
|
|
102
|
+
state = 'stopped';
|
|
103
|
+
/** Configuration (set on start) */
|
|
104
|
+
config = null;
|
|
105
|
+
/** Cached device list from last poll */
|
|
106
|
+
devices = [];
|
|
107
|
+
/** Cached Crewly version */
|
|
108
|
+
version = 'unknown';
|
|
109
|
+
/** Heartbeat timer handle */
|
|
110
|
+
heartbeatTimer = null;
|
|
111
|
+
/** Device poll timer handle */
|
|
112
|
+
devicePollTimer = null;
|
|
113
|
+
/** Message poll timer handle (self-rescheduling long-poll loop). */
|
|
114
|
+
messagePollTimer = null;
|
|
115
|
+
/** Guards against overlapping self-scheduled message-poll cycles. */
|
|
116
|
+
messagePollRunning = false;
|
|
117
|
+
/** Queue re-register timer handle (lets relay evict stale Portal pairs). */
|
|
118
|
+
registerTimer = null;
|
|
119
|
+
/** Consecutive heartbeat failure count */
|
|
120
|
+
heartbeatFailures = 0;
|
|
121
|
+
/** Consecutive device poll failure count */
|
|
122
|
+
devicePollFailures = 0;
|
|
123
|
+
/** Consecutive message poll failure count */
|
|
124
|
+
messagePollFailures = 0;
|
|
125
|
+
/** Error recovery timer handle */
|
|
126
|
+
errorRecoveryTimer = null;
|
|
127
|
+
/** Consecutive error recovery attempts (reset on success or stop) */
|
|
128
|
+
errorRecoveryAttempts = 0;
|
|
129
|
+
/** Our assigned queue ID from Cloud relay queue registration (for message polling) */
|
|
130
|
+
queueId = null;
|
|
131
|
+
/**
|
|
132
|
+
* Recently processed message IDs to prevent re-delivery when ackMessages fails.
|
|
133
|
+
* Keeps the last 500 IDs; pruned on overflow.
|
|
134
|
+
*/
|
|
135
|
+
processedMessageIds = new Set();
|
|
136
|
+
/** Maximum size of the processedMessageIds dedup set */
|
|
137
|
+
static MAX_DEDUP_IDS = 500;
|
|
138
|
+
constructor() {
|
|
139
|
+
super();
|
|
140
|
+
this.logger = LoggerService.getInstance().createComponentLogger('CloudSyncService');
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Get the singleton instance.
|
|
144
|
+
*
|
|
145
|
+
* @returns CloudSyncService instance
|
|
146
|
+
*/
|
|
147
|
+
static getInstance() {
|
|
148
|
+
if (!CloudSyncService.instance) {
|
|
149
|
+
CloudSyncService.instance = new CloudSyncService();
|
|
150
|
+
}
|
|
151
|
+
return CloudSyncService.instance;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Reset the singleton (for testing).
|
|
155
|
+
*/
|
|
156
|
+
static resetInstance() {
|
|
157
|
+
if (CloudSyncService.instance) {
|
|
158
|
+
CloudSyncService.instance.stop();
|
|
159
|
+
}
|
|
160
|
+
CloudSyncService.instance = null;
|
|
161
|
+
}
|
|
162
|
+
// -------------------------------------------------------------------------
|
|
163
|
+
// Public API
|
|
164
|
+
// -------------------------------------------------------------------------
|
|
165
|
+
/**
|
|
166
|
+
* Start the Cloud Sync service.
|
|
167
|
+
*
|
|
168
|
+
* Begins heartbeat, device polling, and message polling timers.
|
|
169
|
+
* Performs an immediate heartbeat and device poll on start.
|
|
170
|
+
*
|
|
171
|
+
* @param config - Cloud connection configuration
|
|
172
|
+
*/
|
|
173
|
+
start(config) {
|
|
174
|
+
if (this.state === 'syncing') {
|
|
175
|
+
this.logger.warn('CloudSyncService already running, ignoring start()');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.config = config;
|
|
179
|
+
this.state = 'syncing';
|
|
180
|
+
this.heartbeatFailures = 0;
|
|
181
|
+
this.devicePollFailures = 0;
|
|
182
|
+
this.messagePollFailures = 0;
|
|
183
|
+
this.logger.info('Starting Cloud Sync', {
|
|
184
|
+
cloudUrl: config.cloudUrl,
|
|
185
|
+
deviceId: config.deviceId,
|
|
186
|
+
deviceName: config.deviceName,
|
|
187
|
+
});
|
|
188
|
+
// Read version once at startup
|
|
189
|
+
readVersion().then((v) => { this.version = v; }).catch(() => { });
|
|
190
|
+
// Register with Cloud message queue for inter-device messaging.
|
|
191
|
+
// This gets us a queueId used for message polling and makes us
|
|
192
|
+
// discoverable by peer devices with the same pairing code.
|
|
193
|
+
this.registerQueue().catch((err) => {
|
|
194
|
+
this.logger.warn('Queue registration failed (non-fatal, messaging may not work)', {
|
|
195
|
+
error: err instanceof Error ? err.message : String(err),
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
// Perform initial sync immediately
|
|
199
|
+
this.sendHeartbeat().catch(() => { });
|
|
200
|
+
this.pollDevices().catch(() => { });
|
|
201
|
+
// Start periodic timers
|
|
202
|
+
this.heartbeatTimer = setInterval(() => { this.sendHeartbeat().catch(() => { }); }, CLOUD_SYNC_CONSTANTS.HEARTBEAT_INTERVAL_MS);
|
|
203
|
+
this.devicePollTimer = setInterval(() => { this.pollDevices().catch(() => { }); }, CLOUD_SYNC_CONSTANTS.DEVICE_POLL_INTERVAL_MS);
|
|
204
|
+
// Message polling is a self-rescheduling loop (not a fixed interval) so a
|
|
205
|
+
// held long-poll never overlaps the next request. Kicks off immediately;
|
|
206
|
+
// each cycle picks its own next gap (see runMessagePollCycle).
|
|
207
|
+
this.scheduleNextMessagePoll(0);
|
|
208
|
+
// Periodic re-register lets the relay's stale-pair eviction kick in
|
|
209
|
+
// when a previously-paired Portal closes uncleanly. Without this,
|
|
210
|
+
// OSS would stay wedged against a dead Portal session until restart.
|
|
211
|
+
this.registerTimer = setInterval(() => { this.registerQueue().catch(() => { }); }, CLOUD_SYNC_CONSTANTS.REGISTER_INTERVAL_MS);
|
|
212
|
+
// Unref timers so they don't keep the process alive (messagePollTimer is
|
|
213
|
+
// unref'd inside scheduleNextMessagePoll on each cycle).
|
|
214
|
+
if (this.heartbeatTimer.unref)
|
|
215
|
+
this.heartbeatTimer.unref();
|
|
216
|
+
if (this.devicePollTimer.unref)
|
|
217
|
+
this.devicePollTimer.unref();
|
|
218
|
+
if (this.registerTimer.unref)
|
|
219
|
+
this.registerTimer.unref();
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Stop the Cloud Sync service.
|
|
223
|
+
*
|
|
224
|
+
* Clears all timers and resets state. Safe to call multiple times.
|
|
225
|
+
*/
|
|
226
|
+
stop() {
|
|
227
|
+
if (this.state === 'stopped')
|
|
228
|
+
return;
|
|
229
|
+
this.logger.info('Stopping Cloud Sync');
|
|
230
|
+
if (this.heartbeatTimer) {
|
|
231
|
+
clearInterval(this.heartbeatTimer);
|
|
232
|
+
this.heartbeatTimer = null;
|
|
233
|
+
}
|
|
234
|
+
if (this.devicePollTimer) {
|
|
235
|
+
clearInterval(this.devicePollTimer);
|
|
236
|
+
this.devicePollTimer = null;
|
|
237
|
+
}
|
|
238
|
+
if (this.messagePollTimer) {
|
|
239
|
+
clearTimeout(this.messagePollTimer);
|
|
240
|
+
this.messagePollTimer = null;
|
|
241
|
+
}
|
|
242
|
+
if (this.registerTimer) {
|
|
243
|
+
clearInterval(this.registerTimer);
|
|
244
|
+
this.registerTimer = null;
|
|
245
|
+
}
|
|
246
|
+
if (this.errorRecoveryTimer) {
|
|
247
|
+
clearInterval(this.errorRecoveryTimer);
|
|
248
|
+
this.errorRecoveryTimer = null;
|
|
249
|
+
}
|
|
250
|
+
this.state = 'stopped';
|
|
251
|
+
this.config = null;
|
|
252
|
+
this.devices = [];
|
|
253
|
+
this.heartbeatFailures = 0;
|
|
254
|
+
this.devicePollFailures = 0;
|
|
255
|
+
this.messagePollFailures = 0;
|
|
256
|
+
this.errorRecoveryAttempts = 0;
|
|
257
|
+
this.processedMessageIds.clear();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Check if the sync service has been started.
|
|
261
|
+
*
|
|
262
|
+
* @returns True if the service is currently syncing
|
|
263
|
+
*/
|
|
264
|
+
isStarted() {
|
|
265
|
+
return this.state === 'syncing';
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Get the current service state.
|
|
269
|
+
*
|
|
270
|
+
* @returns Current CloudSyncState
|
|
271
|
+
*/
|
|
272
|
+
getState() {
|
|
273
|
+
return this.state;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Get all cached devices for this Cloud account.
|
|
277
|
+
*
|
|
278
|
+
* @returns Array of SyncDevice objects (may include offline devices)
|
|
279
|
+
*/
|
|
280
|
+
getDevices() {
|
|
281
|
+
return [...this.devices];
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Update the authentication token used for Cloud API requests.
|
|
285
|
+
*
|
|
286
|
+
* Called by CloudClientService after a successful token refresh
|
|
287
|
+
* so that heartbeat, device poll, and message poll use the new token.
|
|
288
|
+
*
|
|
289
|
+
* @param newToken - New JWT access token
|
|
290
|
+
*/
|
|
291
|
+
updateToken(newToken) {
|
|
292
|
+
if (this.config) {
|
|
293
|
+
this.config = { ...this.config, token: newToken };
|
|
294
|
+
this.logger.info('CloudSyncService token updated');
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Get only online devices (excluding this device).
|
|
299
|
+
*
|
|
300
|
+
* @returns Array of online SyncDevice objects
|
|
301
|
+
*/
|
|
302
|
+
getOnlineDevices() {
|
|
303
|
+
return this.devices.filter((d) => d.status === 'online' && !d.isLocal);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Send a message to another device via the Cloud message queue.
|
|
307
|
+
*
|
|
308
|
+
* @param toDeviceId - Target device identifier
|
|
309
|
+
* @param type - Message type
|
|
310
|
+
* @param payload - Message payload (must be JSON-serializable)
|
|
311
|
+
* @throws Error when not started or API call fails
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* ```typescript
|
|
315
|
+
* await sync.sendMessage('dev-456', 'command', {
|
|
316
|
+
* action: 'delegate-task',
|
|
317
|
+
* content: 'Implement feature X'
|
|
318
|
+
* });
|
|
319
|
+
* ```
|
|
320
|
+
*/
|
|
321
|
+
async sendMessage(toDeviceId, type, payload) {
|
|
322
|
+
if (!this.config) {
|
|
323
|
+
throw new Error('CloudSyncService not started. Call start() first.');
|
|
324
|
+
}
|
|
325
|
+
// Resolve the target device's sessionId (peerQueueId) from the cached
|
|
326
|
+
// device list. The Cloud API queue/send endpoint routes by sessionId,
|
|
327
|
+
// not deviceId.
|
|
328
|
+
const targetDevice = this.devices.find(d => d.deviceId === toDeviceId);
|
|
329
|
+
if (!targetDevice) {
|
|
330
|
+
throw new Error(`Device not found in cache: ${toDeviceId}. Available: ${this.devices.map(d => d.deviceName).join(', ')}`);
|
|
331
|
+
}
|
|
332
|
+
if (!targetDevice.sessionId) {
|
|
333
|
+
throw new Error(`Device ${targetDevice.deviceName} has no sessionId — cannot route message`);
|
|
334
|
+
}
|
|
335
|
+
const url = `${this.config.cloudUrl}${CLOUD_SYNC_CONSTANTS.ENDPOINTS.MESSAGES}`;
|
|
336
|
+
const wrappedPayload = JSON.stringify({
|
|
337
|
+
type,
|
|
338
|
+
data: payload,
|
|
339
|
+
encrypted: false,
|
|
340
|
+
fromDeviceName: this.config.deviceName,
|
|
341
|
+
});
|
|
342
|
+
const response = await fetch(url, {
|
|
343
|
+
method: 'POST',
|
|
344
|
+
headers: this.authHeaders(),
|
|
345
|
+
body: JSON.stringify({
|
|
346
|
+
peerQueueId: targetDevice.sessionId,
|
|
347
|
+
payload: wrappedPayload,
|
|
348
|
+
}),
|
|
349
|
+
signal: AbortSignal.timeout(CLOUD_SYNC_CONSTANTS.REQUEST_TIMEOUT_MS),
|
|
350
|
+
});
|
|
351
|
+
if (!response.ok) {
|
|
352
|
+
const errorText = await response.text().catch(() => '');
|
|
353
|
+
throw new Error(`Failed to send message: ${response.status} ${errorText}`);
|
|
354
|
+
}
|
|
355
|
+
this.logger.info('Message sent via Cloud Sync', { to: toDeviceId, type, peerSessionId: targetDevice.sessionId });
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Send a message directly to a peer queueId, bypassing the device cache.
|
|
359
|
+
*
|
|
360
|
+
* Used by `ChatV2RelayAdapter` for routing `chat_response` back to a
|
|
361
|
+
* Portal browser whose deviceId may not yet have propagated to the
|
|
362
|
+
* 30s-interval device poll. The production relay's `/queue/send`
|
|
363
|
+
* endpoint accepts a raw `peerQueueId`, so when the target identifier
|
|
364
|
+
* IS already a queueId (which Portal's wrapper.fromDeviceName field
|
|
365
|
+
* carries — see `chat-v2.relay-adapter.service.ts:handleRequest`),
|
|
366
|
+
* we can skip the cache lookup entirely and avoid the race.
|
|
367
|
+
*
|
|
368
|
+
* Falls back to `sendMessage(...)` semantics on the wire (same JSON
|
|
369
|
+
* wrapper) so the receiver's CloudSyncService normalizes it identically.
|
|
370
|
+
*
|
|
371
|
+
* @param peerQueueId - The relay queueId to route to (no cache lookup)
|
|
372
|
+
* @param type - MessageType for the wrapper
|
|
373
|
+
* @param payload - JSON-serializable inner data
|
|
374
|
+
* @throws Error when not started or the HTTP send fails
|
|
375
|
+
*/
|
|
376
|
+
async sendToPeerQueueId(peerQueueId, type, payload) {
|
|
377
|
+
if (!this.config) {
|
|
378
|
+
throw new Error('CloudSyncService not started. Call start() first.');
|
|
379
|
+
}
|
|
380
|
+
if (!peerQueueId) {
|
|
381
|
+
throw new Error('sendToPeerQueueId requires a non-empty peerQueueId');
|
|
382
|
+
}
|
|
383
|
+
const url = `${this.config.cloudUrl}${CLOUD_SYNC_CONSTANTS.ENDPOINTS.MESSAGES}`;
|
|
384
|
+
const wrappedPayload = JSON.stringify({
|
|
385
|
+
type,
|
|
386
|
+
data: payload,
|
|
387
|
+
encrypted: false,
|
|
388
|
+
fromDeviceName: this.config.deviceName,
|
|
389
|
+
});
|
|
390
|
+
const response = await fetch(url, {
|
|
391
|
+
method: 'POST',
|
|
392
|
+
headers: this.authHeaders(),
|
|
393
|
+
body: JSON.stringify({ peerQueueId, payload: wrappedPayload }),
|
|
394
|
+
signal: AbortSignal.timeout(CLOUD_SYNC_CONSTANTS.REQUEST_TIMEOUT_MS),
|
|
395
|
+
});
|
|
396
|
+
if (!response.ok) {
|
|
397
|
+
const errorText = await response.text().catch(() => '');
|
|
398
|
+
throw new Error(`Failed to send message (queueId path): ${response.status} ${errorText}`);
|
|
399
|
+
}
|
|
400
|
+
this.logger.info('Message sent via Cloud Sync (queueId path)', { peerQueueId, type });
|
|
401
|
+
}
|
|
402
|
+
// -------------------------------------------------------------------------
|
|
403
|
+
// Internal: Queue Registration
|
|
404
|
+
// -------------------------------------------------------------------------
|
|
405
|
+
/**
|
|
406
|
+
* Register with the Cloud relay message queue.
|
|
407
|
+
*
|
|
408
|
+
* Calls POST /api/v1/relay/queue/register with a deterministic pairing code
|
|
409
|
+
* derived from the JWT user ID. This allows both devices of the same user
|
|
410
|
+
* to auto-discover each other's queueId for message routing.
|
|
411
|
+
*
|
|
412
|
+
* The assigned queueId is used for message polling. The peerQueueId
|
|
413
|
+
* (other device's queue) is used for sending.
|
|
414
|
+
*/
|
|
415
|
+
async registerQueue() {
|
|
416
|
+
if (!this.config)
|
|
417
|
+
return;
|
|
418
|
+
const url = `${this.config.cloudUrl}/api/v1/relay/queue/register`;
|
|
419
|
+
// Derive deterministic pairing code from JWT user ID
|
|
420
|
+
const pairingCode = await this.derivePairingCode();
|
|
421
|
+
if (!pairingCode) {
|
|
422
|
+
this.logger.warn('Cannot register queue: unable to derive pairing code from token');
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const response = await fetch(url, {
|
|
426
|
+
method: 'POST',
|
|
427
|
+
headers: this.authHeaders(),
|
|
428
|
+
body: JSON.stringify({
|
|
429
|
+
deviceId: this.config.deviceId,
|
|
430
|
+
deviceName: this.config.deviceName,
|
|
431
|
+
role: 'orchestrator',
|
|
432
|
+
pairingCode,
|
|
433
|
+
}),
|
|
434
|
+
signal: AbortSignal.timeout(CLOUD_SYNC_CONSTANTS.REQUEST_TIMEOUT_MS),
|
|
435
|
+
});
|
|
436
|
+
if (!response.ok) {
|
|
437
|
+
const errorText = await response.text().catch(() => '');
|
|
438
|
+
throw new Error(`Queue registration failed: ${response.status} ${errorText}`);
|
|
439
|
+
}
|
|
440
|
+
const data = await response.json();
|
|
441
|
+
if (data.queueId) {
|
|
442
|
+
this.queueId = data.queueId;
|
|
443
|
+
this.logger.info('Registered with Cloud message queue', {
|
|
444
|
+
queueId: this.queueId,
|
|
445
|
+
peerQueueId: data.peerQueueId ?? 'none (waiting for peer)',
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Derive a deterministic pairing code from the JWT token's user ID.
|
|
451
|
+
* Both devices of the same user will produce the same code.
|
|
452
|
+
*/
|
|
453
|
+
async derivePairingCode() {
|
|
454
|
+
if (!this.config?.token)
|
|
455
|
+
return null;
|
|
456
|
+
try {
|
|
457
|
+
// Decode JWT payload without verification (we just need the sub claim)
|
|
458
|
+
const parts = this.config.token.split('.');
|
|
459
|
+
if (parts.length < 2)
|
|
460
|
+
return null;
|
|
461
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
462
|
+
const userId = payload.sub;
|
|
463
|
+
if (!userId)
|
|
464
|
+
return null;
|
|
465
|
+
const crypto = await import('crypto');
|
|
466
|
+
return crypto.createHash('sha256').update(`crewly-pair-${userId}`).digest('hex').slice(0, 12);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Get the queue ID assigned by Cloud registration.
|
|
474
|
+
* Returns null if not yet registered.
|
|
475
|
+
*/
|
|
476
|
+
getQueueId() {
|
|
477
|
+
return this.queueId;
|
|
478
|
+
}
|
|
479
|
+
// -------------------------------------------------------------------------
|
|
480
|
+
// Internal: Heartbeat
|
|
481
|
+
// -------------------------------------------------------------------------
|
|
482
|
+
/**
|
|
483
|
+
* Send a heartbeat to the Cloud server with current device state.
|
|
484
|
+
* Called periodically by the heartbeat timer.
|
|
485
|
+
*
|
|
486
|
+
* Posts to the Cloud Relay handshake endpoint (/api/v1/relay/handshake)
|
|
487
|
+
* which registers/updates the device with enriched metadata.
|
|
488
|
+
*/
|
|
489
|
+
async sendHeartbeat() {
|
|
490
|
+
if (!this.config || this.state === 'error')
|
|
491
|
+
return;
|
|
492
|
+
try {
|
|
493
|
+
const teams = await gatherTeamSummaries();
|
|
494
|
+
// Cloud Relay handshake expects: { deviceId, deviceName, teams, version, timestamp }
|
|
495
|
+
const payload = {
|
|
496
|
+
deviceId: this.config.deviceId,
|
|
497
|
+
deviceName: this.config.deviceName,
|
|
498
|
+
status: 'online',
|
|
499
|
+
version: this.version,
|
|
500
|
+
capabilities: ['orchestrator', 'agent-runner'],
|
|
501
|
+
teams,
|
|
502
|
+
timestamp: new Date().toISOString(),
|
|
503
|
+
};
|
|
504
|
+
const url = `${this.config.cloudUrl}${CLOUD_SYNC_CONSTANTS.ENDPOINTS.HEARTBEAT}`;
|
|
505
|
+
const response = await fetch(url, {
|
|
506
|
+
method: 'POST',
|
|
507
|
+
headers: this.authHeaders(),
|
|
508
|
+
body: JSON.stringify(payload),
|
|
509
|
+
signal: AbortSignal.timeout(CLOUD_SYNC_CONSTANTS.REQUEST_TIMEOUT_MS),
|
|
510
|
+
});
|
|
511
|
+
if (!response.ok) {
|
|
512
|
+
// On 401/403, attempt token refresh before counting as failure
|
|
513
|
+
if (response.status === 401 || response.status === 403) {
|
|
514
|
+
const refreshed = await this.handleAuthError(response.status);
|
|
515
|
+
if (refreshed) {
|
|
516
|
+
this.logger.info('Heartbeat will retry with refreshed token on next cycle');
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
throw new Error(`Heartbeat failed: ${response.status}`);
|
|
521
|
+
}
|
|
522
|
+
this.heartbeatFailures = 0;
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
this.heartbeatFailures++;
|
|
526
|
+
this.logger.warn('Heartbeat failed', {
|
|
527
|
+
error: error instanceof Error ? error.message : String(error),
|
|
528
|
+
failures: this.heartbeatFailures,
|
|
529
|
+
});
|
|
530
|
+
this.checkErrorThreshold();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
// -------------------------------------------------------------------------
|
|
534
|
+
// Internal: Device Polling
|
|
535
|
+
// -------------------------------------------------------------------------
|
|
536
|
+
/**
|
|
537
|
+
* Poll the Cloud server for the current device list.
|
|
538
|
+
* Updates the cached device list and emits events on changes.
|
|
539
|
+
*
|
|
540
|
+
* The Cloud server returns devices in relay format:
|
|
541
|
+
* `{ sessionId, role, state, pairedWith, registeredAt, lastHeartbeatAt, name }`
|
|
542
|
+
* which we normalize to the SyncDevice shape used by the frontend.
|
|
543
|
+
*/
|
|
544
|
+
async pollDevices() {
|
|
545
|
+
if (!this.config || this.state === 'error')
|
|
546
|
+
return;
|
|
547
|
+
try {
|
|
548
|
+
const url = `${this.config.cloudUrl}${CLOUD_SYNC_CONSTANTS.ENDPOINTS.DEVICES}`;
|
|
549
|
+
const response = await fetch(url, {
|
|
550
|
+
method: 'GET',
|
|
551
|
+
headers: {
|
|
552
|
+
...this.authHeaders(),
|
|
553
|
+
// Send the real local deviceId so the Cloud auto-register uses
|
|
554
|
+
// THIS machine's identity, not the (potentially stale) JWT deviceId.
|
|
555
|
+
'X-Device-Id': this.config.deviceId,
|
|
556
|
+
},
|
|
557
|
+
signal: AbortSignal.timeout(CLOUD_SYNC_CONSTANTS.REQUEST_TIMEOUT_MS),
|
|
558
|
+
});
|
|
559
|
+
if (!response.ok) {
|
|
560
|
+
// On 401/403, attempt token refresh before counting as failure
|
|
561
|
+
if (response.status === 401 || response.status === 403) {
|
|
562
|
+
const refreshed = await this.handleAuthError(response.status);
|
|
563
|
+
if (refreshed) {
|
|
564
|
+
this.logger.info('Device poll will retry with refreshed token on next cycle');
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
throw new Error(`Device poll failed: ${response.status}`);
|
|
569
|
+
}
|
|
570
|
+
const data = await response.json();
|
|
571
|
+
// Cloud /api/v1/relay/devices returns { success, devices: Device[] }
|
|
572
|
+
// Legacy endpoints returned { success, data: Device[] }
|
|
573
|
+
// Support both formats for forward/backward compatibility
|
|
574
|
+
const rawDevices = data.data || data.devices;
|
|
575
|
+
if (!rawDevices) {
|
|
576
|
+
// Empty response is valid when no other devices are online
|
|
577
|
+
this.devices = [];
|
|
578
|
+
this.devicePollFailures = 0;
|
|
579
|
+
this.emit('devices_updated', this.getDevices());
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
const previousOnlineIds = new Set(this.devices.filter((d) => d.status === 'online').map((d) => d.deviceId));
|
|
583
|
+
// Normalize Cloud device records to SyncDevice format.
|
|
584
|
+
// Cloud /api/devices returns: { deviceId, deviceName, email, teams, lastSeenAt }
|
|
585
|
+
// Legacy relay returns: { sessionId, role, state, pairedWith, registeredAt, lastHeartbeatAt, name }
|
|
586
|
+
// We need: { deviceId, deviceName, status, lastHeartbeatAt, isLocal, ... }
|
|
587
|
+
const offlineThreshold = CLOUD_SYNC_CONSTANTS.OFFLINE_THRESHOLD_MS;
|
|
588
|
+
const now = Date.now();
|
|
589
|
+
const updatedDevices = rawDevices.map((d) => {
|
|
590
|
+
const deviceId = d.deviceId || d.sessionId || '';
|
|
591
|
+
const lastSeen = d.lastSeenAt || d.lastHeartbeatAt || d.registeredAt || new Date().toISOString();
|
|
592
|
+
// Determine online/offline from heartbeat recency, explicit status, or relay state
|
|
593
|
+
const isOnline = d.status === 'online' ||
|
|
594
|
+
d.online === true ||
|
|
595
|
+
d.state === 'paired' ||
|
|
596
|
+
d.state === 'waiting' ||
|
|
597
|
+
(now - new Date(lastSeen).getTime() <= offlineThreshold);
|
|
598
|
+
// IMPORTANT: don't synthesize a fake `deviceName` like
|
|
599
|
+
// `Device ${prefix}` when the upstream is missing one. The
|
|
600
|
+
// frontend's Connected-Devices filter keys off whether
|
|
601
|
+
// `deviceName` is a real hostname to distinguish Crewly OSS
|
|
602
|
+
// installations (which always set it) from Portal browser
|
|
603
|
+
// sessions (which can't — browsers don't have a hostname).
|
|
604
|
+
// A synthesized placeholder defeats that filter and surfaces
|
|
605
|
+
// Portal sessions as fake "Device 85b41885" rows. The device
|
|
606
|
+
// card UI has its own display-time fallback for missing names.
|
|
607
|
+
return {
|
|
608
|
+
deviceId,
|
|
609
|
+
deviceName: d.deviceName || d.name || undefined,
|
|
610
|
+
status: isOnline ? 'online' : 'offline',
|
|
611
|
+
lastHeartbeatAt: lastSeen,
|
|
612
|
+
isLocal: deviceId === this.config.deviceId,
|
|
613
|
+
version: d.version,
|
|
614
|
+
capabilities: d.capabilities,
|
|
615
|
+
// Preserve extra fields for the frontend
|
|
616
|
+
...(d.role && { role: d.role }),
|
|
617
|
+
...(d.state && { state: d.state }),
|
|
618
|
+
...(d.registeredAt && { registeredAt: d.registeredAt }),
|
|
619
|
+
...(d.sessionId && { sessionId: d.sessionId }),
|
|
620
|
+
...(d.email && { email: d.email }),
|
|
621
|
+
...(d.teams && { teams: d.teams }),
|
|
622
|
+
};
|
|
623
|
+
});
|
|
624
|
+
// Detect online/offline transitions
|
|
625
|
+
const newOnlineIds = new Set(updatedDevices.filter((d) => d.status === 'online').map((d) => d.deviceId));
|
|
626
|
+
for (const device of updatedDevices) {
|
|
627
|
+
if (device.status === 'online' && !previousOnlineIds.has(device.deviceId) && !device.isLocal) {
|
|
628
|
+
this.emit('device_online', device);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
for (const prevId of previousOnlineIds) {
|
|
632
|
+
if (!newOnlineIds.has(prevId) && prevId !== this.config.deviceId) {
|
|
633
|
+
const offlineDevice = this.devices.find((d) => d.deviceId === prevId);
|
|
634
|
+
if (offlineDevice) {
|
|
635
|
+
this.emit('device_offline', { ...offlineDevice, status: 'offline' });
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
// Deduplicate by deviceId — the Cloud API may return multiple records
|
|
640
|
+
// for the same physical device (e.g., from relay handshake and device
|
|
641
|
+
// heartbeat endpoints). Keep the entry with the most recent heartbeat.
|
|
642
|
+
const deduped = new Map();
|
|
643
|
+
for (const device of updatedDevices) {
|
|
644
|
+
if (!device.deviceId) {
|
|
645
|
+
// No deviceId — cannot deduplicate, keep as-is with a generated key
|
|
646
|
+
deduped.set(`__no_id_${deduped.size}`, device);
|
|
647
|
+
continue;
|
|
648
|
+
}
|
|
649
|
+
const existing = deduped.get(device.deviceId);
|
|
650
|
+
if (!existing) {
|
|
651
|
+
deduped.set(device.deviceId, device);
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
// Keep the one with the more recent heartbeat, or prefer online status
|
|
655
|
+
const existingTime = new Date(existing.lastHeartbeatAt).getTime();
|
|
656
|
+
const newTime = new Date(device.lastHeartbeatAt).getTime();
|
|
657
|
+
if (newTime > existingTime || (device.status === 'online' && existing.status !== 'online')) {
|
|
658
|
+
deduped.set(device.deviceId, device);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
this.devices = Array.from(deduped.values());
|
|
663
|
+
this.devicePollFailures = 0;
|
|
664
|
+
this.emit('devices_updated', this.getDevices());
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
this.devicePollFailures++;
|
|
668
|
+
this.logger.warn('Device poll failed', {
|
|
669
|
+
error: error instanceof Error ? error.message : String(error),
|
|
670
|
+
failures: this.devicePollFailures,
|
|
671
|
+
});
|
|
672
|
+
this.checkErrorThreshold();
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// -------------------------------------------------------------------------
|
|
676
|
+
// Internal: Message Polling
|
|
677
|
+
// -------------------------------------------------------------------------
|
|
678
|
+
/**
|
|
679
|
+
* Poll the Cloud server for pending messages addressed to this device.
|
|
680
|
+
* Emits 'message' event for each received message, then acknowledges.
|
|
681
|
+
*
|
|
682
|
+
* The Cloud Sync message poll endpoint uses `deviceId` query param.
|
|
683
|
+
* Cloud returns messages as: `{ id, from, fromDeviceName, type, payload, encrypted, sentAt }`.
|
|
684
|
+
*/
|
|
685
|
+
async pollMessages() {
|
|
686
|
+
if (!this.config || this.state === 'error')
|
|
687
|
+
return 0;
|
|
688
|
+
try {
|
|
689
|
+
// Cloud Relay queue/poll requires the queueId assigned during queue registration.
|
|
690
|
+
// If we haven't registered yet, skip polling.
|
|
691
|
+
const pollQueueId = this.queueId || this.config.deviceId;
|
|
692
|
+
if (!this.queueId) {
|
|
693
|
+
// Not yet registered — skip silently (registration is async)
|
|
694
|
+
return 0;
|
|
695
|
+
}
|
|
696
|
+
// Long-poll: ask the relay to hold the connection until a message lands
|
|
697
|
+
// (or its deadline) so we pick up Portal chat_requests near-instantly.
|
|
698
|
+
// The relay falls back to an immediate empty return if it's older; the
|
|
699
|
+
// poll loop auto-degrades to the fixed interval in that case.
|
|
700
|
+
const longPollMs = CLOUD_SYNC_CONSTANTS.MESSAGE_LONGPOLL_WAIT_MS;
|
|
701
|
+
const waitQuery = longPollMs > 0 ? `&wait=${longPollMs}` : '';
|
|
702
|
+
const url = `${this.config.cloudUrl}${CLOUD_SYNC_CONSTANTS.ENDPOINTS.MESSAGES_POLL}?queueId=${encodeURIComponent(pollQueueId)}${waitQuery}`;
|
|
703
|
+
// The request timeout MUST exceed the long-poll hold so we don't abort
|
|
704
|
+
// the held connection before the relay returns it.
|
|
705
|
+
const timeoutMs = longPollMs > 0
|
|
706
|
+
? CLOUD_SYNC_CONSTANTS.MESSAGE_LONGPOLL_TIMEOUT_MS
|
|
707
|
+
: CLOUD_SYNC_CONSTANTS.REQUEST_TIMEOUT_MS;
|
|
708
|
+
const response = await fetch(url, {
|
|
709
|
+
method: 'GET',
|
|
710
|
+
headers: this.authHeaders(),
|
|
711
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
712
|
+
});
|
|
713
|
+
if (!response.ok) {
|
|
714
|
+
// On 401/403, attempt token refresh before counting as failure
|
|
715
|
+
if (response.status === 401 || response.status === 403) {
|
|
716
|
+
const refreshed = await this.handleAuthError(response.status);
|
|
717
|
+
if (refreshed) {
|
|
718
|
+
this.logger.info('Message poll will retry with refreshed token on next cycle');
|
|
719
|
+
return 0;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
throw new Error(`Message poll failed: ${response.status}`);
|
|
723
|
+
}
|
|
724
|
+
const data = await response.json();
|
|
725
|
+
// Support both { messages: [...] } and { data: [...] } response formats
|
|
726
|
+
const rawMessages = data.messages || data.data;
|
|
727
|
+
if (!rawMessages || rawMessages.length === 0) {
|
|
728
|
+
this.messagePollFailures = 0;
|
|
729
|
+
return 0;
|
|
730
|
+
}
|
|
731
|
+
// Normalize Cloud Relay message format to IncomingMessage shape.
|
|
732
|
+
// Cloud returns: { id, fromDeviceId, payload (string), createdAt }.
|
|
733
|
+
// The payload may be a JSON string wrapping { type, data, encrypted, fromDeviceName }
|
|
734
|
+
// (sent by our sendMessage), or a raw string from legacy senders.
|
|
735
|
+
const messageIds = [];
|
|
736
|
+
for (const raw of rawMessages) {
|
|
737
|
+
let msgType = 'relay';
|
|
738
|
+
let msgPayload = raw.payload;
|
|
739
|
+
let msgEncrypted = false;
|
|
740
|
+
let msgFromDeviceName = '';
|
|
741
|
+
// Try to unwrap our wrapped payload format
|
|
742
|
+
try {
|
|
743
|
+
const parsed = typeof raw.payload === 'string' ? JSON.parse(raw.payload) : raw.payload;
|
|
744
|
+
if (parsed && typeof parsed === 'object' && 'type' in parsed && 'data' in parsed) {
|
|
745
|
+
msgType = parsed.type || 'relay';
|
|
746
|
+
msgPayload = parsed.data;
|
|
747
|
+
msgEncrypted = parsed.encrypted ?? false;
|
|
748
|
+
msgFromDeviceName = parsed.fromDeviceName || '';
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch {
|
|
752
|
+
// Not JSON or not our format — use raw payload as-is
|
|
753
|
+
}
|
|
754
|
+
const msgId = raw.id;
|
|
755
|
+
// Dedup: skip messages we already processed (guards against ack failure re-delivery)
|
|
756
|
+
if (this.processedMessageIds.has(msgId)) {
|
|
757
|
+
this.logger.debug('Skipping already-processed message', { messageId: msgId });
|
|
758
|
+
messageIds.push(msgId); // Still ack so Cloud removes it
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
const msg = {
|
|
762
|
+
id: msgId,
|
|
763
|
+
from: raw.fromDeviceId || raw.from || '',
|
|
764
|
+
fromDeviceName: msgFromDeviceName,
|
|
765
|
+
type: msgType,
|
|
766
|
+
payload: msgPayload,
|
|
767
|
+
encrypted: msgEncrypted,
|
|
768
|
+
sentAt: raw.createdAt || raw.sentAt || new Date().toISOString(),
|
|
769
|
+
};
|
|
770
|
+
this.emit('message', msg);
|
|
771
|
+
messageIds.push(msg.id);
|
|
772
|
+
// Track processed ID for dedup
|
|
773
|
+
this.processedMessageIds.add(msgId);
|
|
774
|
+
if (this.processedMessageIds.size > CloudSyncService.MAX_DEDUP_IDS) {
|
|
775
|
+
// Prune oldest half (Set preserves insertion order)
|
|
776
|
+
const iter = this.processedMessageIds.values();
|
|
777
|
+
const pruneCount = Math.floor(CloudSyncService.MAX_DEDUP_IDS / 2);
|
|
778
|
+
for (let i = 0; i < pruneCount; i++) {
|
|
779
|
+
const val = iter.next().value;
|
|
780
|
+
if (val !== undefined)
|
|
781
|
+
this.processedMessageIds.delete(val);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
// Acknowledge processed messages
|
|
786
|
+
await this.ackMessages(messageIds);
|
|
787
|
+
this.messagePollFailures = 0;
|
|
788
|
+
this.logger.debug('Polled and processed messages', { count: rawMessages.length });
|
|
789
|
+
return rawMessages.length;
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
this.messagePollFailures++;
|
|
793
|
+
this.logger.warn('Message poll failed', {
|
|
794
|
+
error: error instanceof Error ? error.message : String(error),
|
|
795
|
+
failures: this.messagePollFailures,
|
|
796
|
+
});
|
|
797
|
+
this.checkErrorThreshold();
|
|
798
|
+
// Signal an error to the loop via a sentinel so it backs off to the
|
|
799
|
+
// fixed interval rather than re-opening immediately.
|
|
800
|
+
return -1;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* One iteration of the self-rescheduling message-poll loop. Runs a
|
|
805
|
+
* (possibly long-held) {@link pollMessages}, then schedules the next cycle
|
|
806
|
+
* with a gap chosen from the outcome:
|
|
807
|
+
*
|
|
808
|
+
* - **error** (`count < 0`) → back off to {@link CLOUD_SYNC_CONSTANTS.MESSAGE_POLL_INTERVAL_MS}.
|
|
809
|
+
* - **long-poll disabled** → fixed interval (legacy cadence).
|
|
810
|
+
* - **message received** (`count > 0`) → re-open right away to drain more.
|
|
811
|
+
* - **held then empty** (elapsed ≥ half the wait) → short gap; this is the
|
|
812
|
+
* normal continuous long-poll.
|
|
813
|
+
* - **empty too fast** (elapsed < half the wait) → the relay likely ignores
|
|
814
|
+
* `wait=`; degrade to the fixed interval so we don't hot-loop against an
|
|
815
|
+
* un-upgraded relay.
|
|
816
|
+
*
|
|
817
|
+
* Re-entrancy is guarded so a delayed timer can never overlap an in-flight
|
|
818
|
+
* cycle. No-op once stopped.
|
|
819
|
+
*/
|
|
820
|
+
async runMessagePollCycle() {
|
|
821
|
+
if (this.state === 'stopped' || this.messagePollRunning)
|
|
822
|
+
return;
|
|
823
|
+
this.messagePollRunning = true;
|
|
824
|
+
const longPollMs = CLOUD_SYNC_CONSTANTS.MESSAGE_LONGPOLL_WAIT_MS;
|
|
825
|
+
const startedAt = Date.now();
|
|
826
|
+
let count = 0;
|
|
827
|
+
try {
|
|
828
|
+
count = await this.pollMessages();
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
// pollMessages handles its own errors and returns -1; this guards any
|
|
832
|
+
// unexpected throw so the loop always reschedules.
|
|
833
|
+
count = -1;
|
|
834
|
+
}
|
|
835
|
+
finally {
|
|
836
|
+
this.messagePollRunning = false;
|
|
837
|
+
}
|
|
838
|
+
// `stop()` may have run during the await — re-read the (widened) state.
|
|
839
|
+
if (this.state === 'stopped' || !this.config)
|
|
840
|
+
return;
|
|
841
|
+
let gap;
|
|
842
|
+
if (count < 0 || longPollMs <= 0) {
|
|
843
|
+
gap = CLOUD_SYNC_CONSTANTS.MESSAGE_POLL_INTERVAL_MS;
|
|
844
|
+
}
|
|
845
|
+
else {
|
|
846
|
+
const heldLongEnough = Date.now() - startedAt >= longPollMs / 2;
|
|
847
|
+
gap = count > 0 || heldLongEnough
|
|
848
|
+
? CLOUD_SYNC_CONSTANTS.MESSAGE_LONGPOLL_GAP_MS
|
|
849
|
+
: CLOUD_SYNC_CONSTANTS.MESSAGE_POLL_INTERVAL_MS;
|
|
850
|
+
}
|
|
851
|
+
this.scheduleNextMessagePoll(gap);
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Schedule the next message-poll cycle. Stores the (unref'd) timer handle so
|
|
855
|
+
* `stop()` can cancel a pending cycle. No-op once stopped.
|
|
856
|
+
*
|
|
857
|
+
* @param delayMs - Delay before the next {@link runMessagePollCycle}
|
|
858
|
+
*/
|
|
859
|
+
scheduleNextMessagePoll(delayMs) {
|
|
860
|
+
if (this.state === 'stopped')
|
|
861
|
+
return;
|
|
862
|
+
this.messagePollTimer = setTimeout(() => {
|
|
863
|
+
void this.runMessagePollCycle();
|
|
864
|
+
}, delayMs);
|
|
865
|
+
if (this.messagePollTimer.unref)
|
|
866
|
+
this.messagePollTimer.unref();
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Acknowledge processed messages so Cloud can remove them from the queue.
|
|
870
|
+
* Uses `queueId` in body to identify the target message queue (fallback
|
|
871
|
+
* when JWT has no deviceId claim).
|
|
872
|
+
*
|
|
873
|
+
* @param messageIds - Array of message IDs to acknowledge
|
|
874
|
+
*/
|
|
875
|
+
async ackMessages(messageIds) {
|
|
876
|
+
if (!this.config || messageIds.length === 0)
|
|
877
|
+
return;
|
|
878
|
+
try {
|
|
879
|
+
const url = `${this.config.cloudUrl}${CLOUD_SYNC_CONSTANTS.ENDPOINTS.MESSAGES_ACK}`;
|
|
880
|
+
await fetch(url, {
|
|
881
|
+
method: 'POST',
|
|
882
|
+
headers: this.authHeaders(),
|
|
883
|
+
body: JSON.stringify({
|
|
884
|
+
queueId: this.queueId || this.config.deviceId,
|
|
885
|
+
messageIds,
|
|
886
|
+
}),
|
|
887
|
+
signal: AbortSignal.timeout(CLOUD_SYNC_CONSTANTS.REQUEST_TIMEOUT_MS),
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
this.logger.warn('Message ACK failed (non-fatal)', {
|
|
892
|
+
error: error instanceof Error ? error.message : String(error),
|
|
893
|
+
messageIds,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// -------------------------------------------------------------------------
|
|
898
|
+
// Internal: Error Handling
|
|
899
|
+
// -------------------------------------------------------------------------
|
|
900
|
+
/**
|
|
901
|
+
* Check if any failure counter has exceeded the threshold.
|
|
902
|
+
* If so, transition to error state and schedule a recovery attempt.
|
|
903
|
+
*/
|
|
904
|
+
checkErrorThreshold() {
|
|
905
|
+
if (this.state === 'error')
|
|
906
|
+
return; // Already in error state, don't log repeatedly
|
|
907
|
+
const max = CLOUD_SYNC_CONSTANTS.MAX_CONSECUTIVE_FAILURES;
|
|
908
|
+
if (this.heartbeatFailures >= max ||
|
|
909
|
+
this.devicePollFailures >= max ||
|
|
910
|
+
this.messagePollFailures >= max) {
|
|
911
|
+
this.logger.error('Cloud Sync entering error state after repeated failures — scheduling recovery', {
|
|
912
|
+
heartbeatFailures: this.heartbeatFailures,
|
|
913
|
+
devicePollFailures: this.devicePollFailures,
|
|
914
|
+
messagePollFailures: this.messagePollFailures,
|
|
915
|
+
});
|
|
916
|
+
this.state = 'error';
|
|
917
|
+
this.scheduleErrorRecovery();
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Schedule periodic recovery attempts when in error state.
|
|
922
|
+
*
|
|
923
|
+
* Tries a single heartbeat every ERROR_RECOVERY_INTERVAL_MS (60s by default).
|
|
924
|
+
* If the heartbeat succeeds, resets all failure counters and resumes normal
|
|
925
|
+
* polling. After MAX_ERROR_RECOVERY_ATTEMPTS consecutive auth failures (401/403),
|
|
926
|
+
* transitions to 'auth_expired' terminal state and emits 'auth_expired'.
|
|
927
|
+
*/
|
|
928
|
+
scheduleErrorRecovery() {
|
|
929
|
+
if (this.errorRecoveryTimer)
|
|
930
|
+
return; // Already scheduled
|
|
931
|
+
this.errorRecoveryAttempts = 0;
|
|
932
|
+
const recoveryInterval = CLOUD_SYNC_CONSTANTS.ERROR_RECOVERY_INTERVAL_MS ?? 60_000;
|
|
933
|
+
const maxAttempts = CLOUD_SYNC_CONSTANTS.MAX_ERROR_RECOVERY_ATTEMPTS ?? 5;
|
|
934
|
+
this.errorRecoveryTimer = setInterval(async () => {
|
|
935
|
+
if (this.state !== 'error' || !this.config) {
|
|
936
|
+
if (this.errorRecoveryTimer) {
|
|
937
|
+
clearInterval(this.errorRecoveryTimer);
|
|
938
|
+
this.errorRecoveryTimer = null;
|
|
939
|
+
}
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
this.errorRecoveryAttempts++;
|
|
943
|
+
this.logger.info('Attempting Cloud Sync error recovery...', {
|
|
944
|
+
attempt: this.errorRecoveryAttempts,
|
|
945
|
+
maxAttempts,
|
|
946
|
+
});
|
|
947
|
+
try {
|
|
948
|
+
const url = `${this.config.cloudUrl}${CLOUD_SYNC_CONSTANTS.ENDPOINTS.HEARTBEAT}`;
|
|
949
|
+
const teams = await gatherTeamSummaries();
|
|
950
|
+
const payload = {
|
|
951
|
+
deviceId: this.config.deviceId,
|
|
952
|
+
deviceName: this.config.deviceName,
|
|
953
|
+
status: 'online',
|
|
954
|
+
version: this.version,
|
|
955
|
+
capabilities: ['orchestrator', 'agent-runner'],
|
|
956
|
+
teams,
|
|
957
|
+
timestamp: new Date().toISOString(),
|
|
958
|
+
};
|
|
959
|
+
const response = await fetch(url, {
|
|
960
|
+
method: 'POST',
|
|
961
|
+
headers: this.authHeaders(),
|
|
962
|
+
body: JSON.stringify(payload),
|
|
963
|
+
signal: AbortSignal.timeout(CLOUD_SYNC_CONSTANTS.REQUEST_TIMEOUT_MS),
|
|
964
|
+
});
|
|
965
|
+
if (response.ok) {
|
|
966
|
+
this.logger.info('Cloud Sync recovery succeeded — resuming normal polling');
|
|
967
|
+
this.heartbeatFailures = 0;
|
|
968
|
+
this.devicePollFailures = 0;
|
|
969
|
+
this.messagePollFailures = 0;
|
|
970
|
+
this.errorRecoveryAttempts = 0;
|
|
971
|
+
this.state = 'syncing';
|
|
972
|
+
if (this.errorRecoveryTimer) {
|
|
973
|
+
clearInterval(this.errorRecoveryTimer);
|
|
974
|
+
this.errorRecoveryTimer = null;
|
|
975
|
+
}
|
|
976
|
+
this.pollDevices().catch(() => { });
|
|
977
|
+
}
|
|
978
|
+
else if (response.status === 401 || response.status === 403) {
|
|
979
|
+
const refreshed = await this.handleAuthError(response.status);
|
|
980
|
+
if (refreshed) {
|
|
981
|
+
this.logger.info('Token refreshed during recovery — will retry next cycle');
|
|
982
|
+
this.errorRecoveryAttempts--;
|
|
983
|
+
}
|
|
984
|
+
else if (this.errorRecoveryAttempts >= maxAttempts) {
|
|
985
|
+
this.enterAuthExpiredState();
|
|
986
|
+
}
|
|
987
|
+
else {
|
|
988
|
+
this.logger.warn('Recovery heartbeat auth failed, will retry', {
|
|
989
|
+
status: response.status,
|
|
990
|
+
attempt: this.errorRecoveryAttempts,
|
|
991
|
+
remaining: maxAttempts - this.errorRecoveryAttempts,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
else {
|
|
996
|
+
this.logger.warn('Recovery heartbeat failed, will retry', { status: response.status });
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
catch (error) {
|
|
1000
|
+
this.logger.warn('Recovery attempt failed, will retry', {
|
|
1001
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
}, recoveryInterval);
|
|
1005
|
+
if (this.errorRecoveryTimer.unref)
|
|
1006
|
+
this.errorRecoveryTimer.unref();
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Transition to the terminal auth_expired state.
|
|
1010
|
+
* Clears all timers and emits 'auth_expired' for frontend notification.
|
|
1011
|
+
*/
|
|
1012
|
+
enterAuthExpiredState() {
|
|
1013
|
+
this.logger.error('Cloud Sync authentication permanently failed — user must re-login', { attempts: this.errorRecoveryAttempts });
|
|
1014
|
+
if (this.errorRecoveryTimer) {
|
|
1015
|
+
clearInterval(this.errorRecoveryTimer);
|
|
1016
|
+
this.errorRecoveryTimer = null;
|
|
1017
|
+
}
|
|
1018
|
+
if (this.heartbeatTimer) {
|
|
1019
|
+
clearInterval(this.heartbeatTimer);
|
|
1020
|
+
this.heartbeatTimer = null;
|
|
1021
|
+
}
|
|
1022
|
+
if (this.devicePollTimer) {
|
|
1023
|
+
clearInterval(this.devicePollTimer);
|
|
1024
|
+
this.devicePollTimer = null;
|
|
1025
|
+
}
|
|
1026
|
+
if (this.messagePollTimer) {
|
|
1027
|
+
clearTimeout(this.messagePollTimer);
|
|
1028
|
+
this.messagePollTimer = null;
|
|
1029
|
+
}
|
|
1030
|
+
if (this.registerTimer) {
|
|
1031
|
+
clearInterval(this.registerTimer);
|
|
1032
|
+
this.registerTimer = null;
|
|
1033
|
+
}
|
|
1034
|
+
this.state = 'auth_expired';
|
|
1035
|
+
this.emit('auth_expired');
|
|
1036
|
+
}
|
|
1037
|
+
/** Guard to prevent concurrent token refresh attempts */
|
|
1038
|
+
tokenRefreshInProgress = false;
|
|
1039
|
+
/**
|
|
1040
|
+
* Check if an HTTP status code indicates an authentication failure (401/403).
|
|
1041
|
+
* If so, attempt to refresh the token via CloudClientService.
|
|
1042
|
+
* On success, updates this service's config with the new token.
|
|
1043
|
+
*
|
|
1044
|
+
* @param status - HTTP status code from a Cloud API response
|
|
1045
|
+
* @returns true if the token was refreshed successfully
|
|
1046
|
+
*/
|
|
1047
|
+
async handleAuthError(status) {
|
|
1048
|
+
if (status !== 401 && status !== 403)
|
|
1049
|
+
return false;
|
|
1050
|
+
if (this.tokenRefreshInProgress)
|
|
1051
|
+
return false;
|
|
1052
|
+
this.tokenRefreshInProgress = true;
|
|
1053
|
+
try {
|
|
1054
|
+
const { CloudClientService } = await import('./cloud-client.service.js');
|
|
1055
|
+
const client = CloudClientService.getInstance();
|
|
1056
|
+
const refreshed = await client.tryRefreshToken();
|
|
1057
|
+
if (refreshed) {
|
|
1058
|
+
const newToken = client.getToken();
|
|
1059
|
+
if (newToken && this.config) {
|
|
1060
|
+
this.config = { ...this.config, token: newToken };
|
|
1061
|
+
this.logger.info('CloudSyncService token refreshed after auth error');
|
|
1062
|
+
}
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
this.logger.warn('CloudSyncService token refresh failed — API returned auth error', { status });
|
|
1066
|
+
return false;
|
|
1067
|
+
}
|
|
1068
|
+
catch (err) {
|
|
1069
|
+
this.logger.warn('CloudSyncService token refresh attempt threw', {
|
|
1070
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1071
|
+
});
|
|
1072
|
+
return false;
|
|
1073
|
+
}
|
|
1074
|
+
finally {
|
|
1075
|
+
this.tokenRefreshInProgress = false;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
// -------------------------------------------------------------------------
|
|
1079
|
+
// Internal: Helpers
|
|
1080
|
+
// -------------------------------------------------------------------------
|
|
1081
|
+
/**
|
|
1082
|
+
* Build authorization headers for Cloud API requests.
|
|
1083
|
+
*
|
|
1084
|
+
* @returns Headers with Bearer token and Content-Type
|
|
1085
|
+
*/
|
|
1086
|
+
authHeaders() {
|
|
1087
|
+
return {
|
|
1088
|
+
Authorization: `Bearer ${this.config?.token ?? ''}`,
|
|
1089
|
+
'Content-Type': 'application/json',
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
//# sourceMappingURL=cloud-sync.service.js.map
|