teleportation-cli 1.0.0 → 1.0.2

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.
@@ -0,0 +1,480 @@
1
+ /**
2
+ * RemoteOrchestrator
3
+ *
4
+ * Manages remote session lifecycle:
5
+ * - Created → Provisioning → Syncing → Running → Completed/Failed
6
+ * - Integrates VaultClient, LivePortClient, Provider, and StateCapturer
7
+ * - Handles cleanup on errors and completion
8
+ */
9
+
10
+ import { EventEmitter } from 'events';
11
+ import { StateCapturer } from './state-capture.js';
12
+
13
+ /**
14
+ * Session states
15
+ */
16
+ export const SessionState = {
17
+ CREATED: 'created',
18
+ CAPTURING: 'capturing',
19
+ PROVISIONING: 'provisioning',
20
+ SYNCING: 'syncing',
21
+ RUNNING: 'running',
22
+ PAUSED: 'paused',
23
+ COMPLETED: 'completed',
24
+ FAILED: 'failed',
25
+ CLEANUP: 'cleanup',
26
+ };
27
+
28
+ /**
29
+ * RemoteOrchestrator class
30
+ */
31
+ export class RemoteOrchestrator extends EventEmitter {
32
+ /**
33
+ * @param {Object} options
34
+ * @param {string} options.sessionId - Session identifier
35
+ * @param {string} options.repoPath - Path to git repository
36
+ * @param {Object} options.vaultClient - Mech Vault client instance
37
+ * @param {Object} options.livePortClient - LivePort client instance
38
+ * @param {Object} options.provider - Cloud provider instance (Fly/Daytona)
39
+ * @param {boolean} [options.verbose] - Enable verbose progress logging
40
+ */
41
+ constructor(options) {
42
+ super();
43
+
44
+ const { sessionId, repoPath, vaultClient, livePortClient, provider, verbose } = options;
45
+
46
+ if (!sessionId) {
47
+ throw new Error('sessionId is required');
48
+ }
49
+
50
+ if (!repoPath) {
51
+ throw new Error('repoPath is required');
52
+ }
53
+
54
+ if (!vaultClient || !livePortClient || !provider) {
55
+ throw new Error('All dependencies (vaultClient, livePortClient, provider) are required');
56
+ }
57
+
58
+ this.sessionId = sessionId;
59
+ this.repoPath = repoPath;
60
+ this.vaultClient = vaultClient;
61
+ this.livePortClient = livePortClient;
62
+ this.provider = provider;
63
+ this.verbose = verbose || false;
64
+
65
+ // Create StateCapturer for local state capture
66
+ this.stateCapturer = options.stateCapturer || new StateCapturer({
67
+ repoPath: this.repoPath,
68
+ sessionId: this.sessionId,
69
+ verbose: this.verbose,
70
+ });
71
+
72
+ // Session state
73
+ this.state = SessionState.CREATED;
74
+ this.stateHistory = [{ state: SessionState.CREATED, timestamp: Date.now() }];
75
+
76
+ // Resources created during session
77
+ this.machineId = null;
78
+ this.machineDetails = null;
79
+ this.tunnelUrl = null;
80
+ this.bridgeKeyId = null;
81
+ this.sshKeyId = null;
82
+ this.vaultNamespace = null;
83
+ this.secretIds = [];
84
+
85
+ // Captured state
86
+ this.capturedState = null;
87
+
88
+ // Metadata
89
+ this.metadata = {};
90
+ this.error = null;
91
+ this.cleanupErrors = [];
92
+
93
+ // Flags
94
+ this.started = false;
95
+ this.destroyed = false;
96
+
97
+ // Progress tracking
98
+ this.progress = {
99
+ current: 0,
100
+ total: 6, // Total number of steps in provisioning flow
101
+ step: null,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Transition to a new state
107
+ * @private
108
+ */
109
+ _transitionState(newState) {
110
+ const oldState = this.state;
111
+ this.state = newState;
112
+ this.stateHistory.push({ state: newState, timestamp: Date.now() });
113
+ this.emit('stateChange', newState, oldState);
114
+
115
+ if (this.verbose) {
116
+ console.log(`[orchestrator] State: ${oldState} → ${newState}`);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Update progress
122
+ * @private
123
+ * @param {number} step - Current step number (1-based)
124
+ * @param {string} description - Step description
125
+ */
126
+ _updateProgress(step, description) {
127
+ this.progress = {
128
+ current: step,
129
+ total: 6,
130
+ step: description,
131
+ };
132
+
133
+ this.emit('progress', this.progress);
134
+
135
+ if (this.verbose) {
136
+ console.log(`[orchestrator] Progress: ${step}/6 - ${description}`);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Start remote session
142
+ *
143
+ * Provisioning flow:
144
+ * 1. Capture local state (git, env, uncommitted changes)
145
+ * 2. Store secrets in Vault
146
+ * 3. Provision remote machine
147
+ * 4. Sync code to remote
148
+ * 5. Initialize machine (install deps, start daemon)
149
+ * 6. Mark as running
150
+ *
151
+ * @param {Object} options
152
+ * @param {string} options.task - Task description
153
+ * @param {string} [options.branch] - Git branch to checkout (defaults to current)
154
+ * @param {string} options.repoUrl - Git repository URL
155
+ * @param {string} options.relayApiUrl - Relay API endpoint
156
+ * @param {string} options.relayApiKey - Relay API key
157
+ * @param {string} options.githubToken - GitHub personal access token
158
+ * @param {string} [options.mechApiKey] - Mech API key for router
159
+ * @param {Object} [options.additionalSecrets] - Additional secrets to store
160
+ * @param {Object} [options.envOptions] - Options for env capture (filterPattern, exclude)
161
+ * @returns {Promise<Object>} Machine details and session info
162
+ * @throws {Error} If provisioning fails
163
+ */
164
+ async startSession(options) {
165
+ const {
166
+ task,
167
+ branch,
168
+ repoUrl,
169
+ relayApiUrl,
170
+ relayApiKey,
171
+ githubToken,
172
+ mechApiKey,
173
+ additionalSecrets = {},
174
+ envOptions = {},
175
+ } = options;
176
+
177
+ if (this.started) {
178
+ throw new Error('Session already started');
179
+ }
180
+
181
+ if (!repoUrl || !relayApiUrl || !relayApiKey || !githubToken) {
182
+ throw new Error('Required fields: repoUrl, relayApiUrl, relayApiKey, githubToken');
183
+ }
184
+
185
+ this.started = true;
186
+ this.metadata.task = task;
187
+ this.metadata.branch = branch;
188
+ this.metadata.startedAt = Date.now();
189
+
190
+ try {
191
+ // Step 1: Capture local state
192
+ this._transitionState(SessionState.CAPTURING);
193
+ this._updateProgress(1, 'Capturing local state...');
194
+
195
+ this.capturedState = await this.stateCapturer.captureAll({ envOptions });
196
+
197
+ // Re-check uncommitted changes immediately before committing
198
+ // (prevents race condition where repo state changes between capture and commit)
199
+ const currentChanges = await this.stateCapturer.captureUncommittedChanges();
200
+ if (currentChanges.hasChanges) {
201
+ const commitResult = await this.stateCapturer.commitAndPushChanges(task);
202
+ this.metadata.wipCommit = commitResult.commit;
203
+ this.metadata.wipBranch = commitResult.branch;
204
+ }
205
+
206
+ // Step 2: Store secrets in Vault
207
+ this._transitionState(SessionState.PROVISIONING);
208
+ this._updateProgress(2, 'Storing secrets in Vault...');
209
+
210
+ this.vaultNamespace = `teleportation.remote.${this.sessionId}`;
211
+
212
+ // Merge captured env vars with explicit secrets
213
+ const allSecrets = {
214
+ RELAY_API_URL: relayApiUrl,
215
+ RELAY_API_KEY: relayApiKey,
216
+ GITHUB_TOKEN: githubToken,
217
+ ...additionalSecrets,
218
+ };
219
+
220
+ if (mechApiKey) {
221
+ allSecrets.MECH_API_KEY = mechApiKey;
222
+ }
223
+
224
+ // Step 3: Provision remote machine (which handles SSH, tunnel, and secrets internally)
225
+ this._updateProgress(3, 'Provisioning remote machine...');
226
+
227
+ const sessionConfig = {
228
+ sessionId: this.sessionId,
229
+ machineId: this.sessionId, // Use sessionId as machineId
230
+ repoUrl,
231
+ branch: this.metadata.wipBranch || branch || this.capturedState.git.branch || 'main',
232
+ relayApiUrl,
233
+ relayApiKey,
234
+ githubToken,
235
+ mechApiKey,
236
+ task,
237
+ additionalSecrets: allSecrets,
238
+ };
239
+
240
+ this.machineDetails = await this.provider.createMachine(sessionConfig);
241
+ this.machineId = this.machineDetails.machineId;
242
+ this.tunnelUrl = this.machineDetails.tunnelUrl;
243
+ this.bridgeKeyId = this.machineDetails.bridgeKeyId;
244
+ this.sshKeyId = this.machineDetails.sshKeyId;
245
+ this.secretIds = this.machineDetails.secretIds || [];
246
+
247
+ // Step 4: Wait for machine initialization
248
+ this._transitionState(SessionState.SYNCING);
249
+ this._updateProgress(4, 'Waiting for machine initialization...');
250
+
251
+ // Give machine time to start and run init script
252
+ await this._waitForMachineReady();
253
+
254
+ // Step 5: Verify machine is running
255
+ this._updateProgress(5, 'Verifying machine status...');
256
+
257
+ const status = await this.provider.getMachineStatus(this.machineId);
258
+ if (status.status !== 'started' && status.status !== 'running') {
259
+ throw new Error(`Machine failed to start. Status: ${status.status}`);
260
+ }
261
+
262
+ // Step 6: Mark as running
263
+ this._updateProgress(6, 'Session ready!');
264
+ this._transitionState(SessionState.RUNNING);
265
+
266
+ return {
267
+ sessionId: this.sessionId,
268
+ machineId: this.machineId,
269
+ tunnelUrl: this.tunnelUrl,
270
+ state: this.state,
271
+ machineDetails: this.machineDetails,
272
+ };
273
+ } catch (error) {
274
+ this.error = error;
275
+ this._transitionState(SessionState.FAILED);
276
+
277
+ // Cleanup on failure
278
+ await this._cleanupOnFailure();
279
+
280
+ throw error;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Wait for machine to be ready
286
+ * @private
287
+ * @param {number} [maxWaitMs=120000] - Max wait time (default: 2 minutes)
288
+ * @param {number} [pollIntervalMs=5000] - Poll interval (default: 5 seconds)
289
+ * @returns {Promise<void>}
290
+ */
291
+ async _waitForMachineReady(maxWaitMs = 120000, pollIntervalMs = 5000) {
292
+ const startTime = Date.now();
293
+
294
+ while (Date.now() - startTime < maxWaitMs) {
295
+ try {
296
+ const status = await this.provider.getMachineStatus(this.machineId);
297
+
298
+ if (status.status === 'started' || status.status === 'running') {
299
+ return; // Machine is ready
300
+ }
301
+
302
+ if (status.status === 'error' || status.status === 'failed') {
303
+ throw new Error(`Machine failed during initialization: ${status.status}`);
304
+ }
305
+
306
+ // Wait before next poll
307
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
308
+ } catch (error) {
309
+ // If it's a "machine not found" error, machine hasn't started yet
310
+ if (error.message.includes('not found')) {
311
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
312
+ continue;
313
+ }
314
+
315
+ throw error;
316
+ }
317
+ }
318
+
319
+ throw new Error(`Machine failed to become ready within ${maxWaitMs}ms`);
320
+ }
321
+
322
+ /**
323
+ * Cleanup resources on failure
324
+ *
325
+ * Destroys machine and verifies cleanup succeeded.
326
+ * Secrets and tunnel cleanup handled by provider internally.
327
+ *
328
+ * @private
329
+ * @returns {Promise<void>}
330
+ */
331
+ async _cleanupOnFailure() {
332
+ // Destroy machine if created
333
+ if (this.machineId) {
334
+ try {
335
+ await this.provider.destroyMachine(this.machineId);
336
+
337
+ // Verify machine is actually destroyed
338
+ try {
339
+ await this.provider.getMachineStatus(this.machineId);
340
+ // If we get here, machine still exists
341
+ this.cleanupErrors.push({
342
+ resource: 'machine',
343
+ error: 'Machine still exists after destroy attempt',
344
+ });
345
+ } catch (notFoundErr) {
346
+ // Expected: machine not found means successfully destroyed
347
+ if (this.verbose) {
348
+ console.log('[orchestrator] Machine successfully destroyed and verified');
349
+ }
350
+ }
351
+ } catch (err) {
352
+ this.cleanupErrors.push({
353
+ resource: 'machine',
354
+ error: err?.message || String(err),
355
+ });
356
+ }
357
+ }
358
+
359
+ // Note: Secrets and tunnel are cleaned up by provider's internal cleanup
360
+
361
+ if (this.verbose && this.cleanupErrors.length > 0) {
362
+ console.warn('[orchestrator] Cleanup errors:', this.cleanupErrors);
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Stop remote session (pause)
368
+ * @returns {Promise<void>}
369
+ */
370
+ async stopSession() {
371
+ if (this.state !== SessionState.RUNNING) {
372
+ throw new Error(`Cannot stop session in state: ${this.state}`);
373
+ }
374
+
375
+ try {
376
+ await this.provider.stopMachine(this.machineId);
377
+ this._transitionState(SessionState.PAUSED);
378
+ } catch (error) {
379
+ this.error = error;
380
+ this._transitionState(SessionState.FAILED);
381
+ throw error;
382
+ }
383
+ }
384
+
385
+ /**
386
+ * Resume paused session
387
+ * @returns {Promise<void>}
388
+ */
389
+ async resumeSession() {
390
+ if (this.state !== SessionState.PAUSED) {
391
+ throw new Error(`Cannot resume session in state: ${this.state}`);
392
+ }
393
+
394
+ try {
395
+ await this.provider.startMachine(this.machineId);
396
+ this._transitionState(SessionState.RUNNING);
397
+ } catch (error) {
398
+ this.error = error;
399
+ this._transitionState(SessionState.FAILED);
400
+ throw error;
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Get session status
406
+ * @returns {Promise<Object>}
407
+ */
408
+ async getStatus() {
409
+ const status = {
410
+ sessionId: this.sessionId,
411
+ state: this.state,
412
+ machineId: this.machineId,
413
+ tunnelUrl: this.tunnelUrl,
414
+ metadata: this.metadata,
415
+ progress: this.progress,
416
+ error: this.error ? this.error.message : null,
417
+ capturedState: this.capturedState ? {
418
+ git: this.capturedState.git,
419
+ uncommittedChanges: this.capturedState.uncommittedChanges,
420
+ } : null,
421
+ };
422
+
423
+ // Query provider for machine status if machine exists
424
+ if (this.machineId && (this.state === SessionState.RUNNING || this.state === SessionState.PAUSED)) {
425
+ try {
426
+ const machineStatus = await this.provider.getMachineStatus(this.machineId);
427
+ status.machine = machineStatus;
428
+ } catch (err) {
429
+ status.machine = { error: err.message };
430
+ }
431
+ }
432
+
433
+ return status;
434
+ }
435
+
436
+ /**
437
+ * Get machine logs
438
+ * @param {Object} [options] - Log options
439
+ * @param {number} [options.tail] - Number of lines to retrieve
440
+ * @returns {Promise<Object>} Log data
441
+ */
442
+ async getLogs(options = {}) {
443
+ if (!this.machineId) {
444
+ throw new Error('No machine provisioned');
445
+ }
446
+
447
+ return this.provider.getLogs(this.machineId, options);
448
+ }
449
+
450
+ /**
451
+ * Destroy session and cleanup all resources
452
+ * @returns {Promise<Object>} Cleanup result with errors if any
453
+ */
454
+ async destroySession() {
455
+ if (this.destroyed) {
456
+ return { success: true, errors: [] }; // Idempotent
457
+ }
458
+
459
+ this.destroyed = true;
460
+ this._transitionState(SessionState.CLEANUP);
461
+
462
+ const errors = [];
463
+
464
+ // Destroy machine (provider handles internal cleanup of secrets/tunnels)
465
+ if (this.machineId) {
466
+ try {
467
+ await this.provider.destroyMachine(this.machineId);
468
+ } catch (err) {
469
+ errors.push({ resource: 'machine', error: err?.message || String(err) });
470
+ }
471
+ }
472
+
473
+ return {
474
+ success: errors.length === 0,
475
+ errors,
476
+ };
477
+ }
478
+ }
479
+
480
+ export default RemoteOrchestrator;