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.
- package/.claude/hooks/heartbeat.mjs +67 -2
- package/.claude/hooks/permission_request.mjs +55 -26
- package/.claude/hooks/pre_tool_use.mjs +29 -2
- package/.claude/hooks/session-register.mjs +64 -5
- package/.claude/hooks/stop.mjs +205 -1
- package/.claude/hooks/user_prompt_submit.mjs +111 -0
- package/README.md +36 -12
- package/lib/auth/claude-key-extractor.js +196 -0
- package/lib/auth/credentials.js +7 -2
- package/lib/cli/remote-commands.js +649 -0
- package/lib/daemon/teleportation-daemon.js +131 -41
- package/lib/install/installer.js +22 -7
- package/lib/machine-coders/claude-code-adapter.js +191 -37
- package/lib/remote/code-sync.js +213 -0
- package/lib/remote/init-script-robust.js +187 -0
- package/lib/remote/liveport-client.js +417 -0
- package/lib/remote/orchestrator.js +480 -0
- package/lib/remote/pr-creator.js +382 -0
- package/lib/remote/providers/base-provider.js +407 -0
- package/lib/remote/providers/daytona-provider.js +506 -0
- package/lib/remote/providers/fly-provider.js +611 -0
- package/lib/remote/providers/provider-factory.js +228 -0
- package/lib/remote/results-delivery.js +333 -0
- package/lib/remote/session-manager.js +273 -0
- package/lib/remote/state-capture.js +324 -0
- package/lib/remote/vault-client.js +478 -0
- package/lib/session/metadata.js +80 -49
- package/lib/session/mute-checker.js +2 -1
- package/lib/utils/vault-errors.js +353 -0
- package/package.json +5 -5
- package/teleportation-cli.cjs +417 -7
|
@@ -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;
|