rocketride 1.0.5 → 1.1.0

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.
Files changed (159) hide show
  1. package/README.md +4 -4
  2. package/dist/cjs/account.js +284 -0
  3. package/dist/cjs/account.js.map +1 -0
  4. package/dist/cjs/billing.js +171 -0
  5. package/dist/cjs/billing.js.map +1 -0
  6. package/dist/cjs/client.js +1208 -756
  7. package/dist/cjs/client.js.map +1 -1
  8. package/dist/cjs/constants.js +10 -1
  9. package/dist/cjs/constants.js.map +1 -1
  10. package/dist/cjs/core/DAPBase.js +4 -1
  11. package/dist/cjs/core/DAPBase.js.map +1 -1
  12. package/dist/cjs/core/DAPClient.js +121 -50
  13. package/dist/cjs/core/DAPClient.js.map +1 -1
  14. package/dist/cjs/core/TransportBase.js +0 -10
  15. package/dist/cjs/core/TransportBase.js.map +1 -1
  16. package/dist/cjs/core/TransportWebSocket.js +30 -19
  17. package/dist/cjs/core/TransportWebSocket.js.map +1 -1
  18. package/dist/cjs/database.js +121 -0
  19. package/dist/cjs/database.js.map +1 -0
  20. package/dist/cjs/index.js +4 -0
  21. package/dist/cjs/index.js.map +1 -1
  22. package/dist/cjs/schema/Question.js +2 -0
  23. package/dist/cjs/schema/Question.js.map +1 -1
  24. package/dist/cjs/types/account.js +26 -0
  25. package/dist/cjs/types/account.js.map +1 -0
  26. package/dist/cjs/types/billing.js +26 -0
  27. package/dist/cjs/types/billing.js.map +1 -0
  28. package/dist/cjs/types/client.js +14 -0
  29. package/dist/cjs/types/client.js.map +1 -1
  30. package/dist/cjs/types/cprofile.js +26 -0
  31. package/dist/cjs/types/cprofile.js.map +1 -0
  32. package/dist/cjs/types/dashboard.js +26 -0
  33. package/dist/cjs/types/dashboard.js.map +1 -0
  34. package/dist/cjs/types/events.js +5 -1
  35. package/dist/cjs/types/events.js.map +1 -1
  36. package/dist/cjs/types/index.js +5 -0
  37. package/dist/cjs/types/index.js.map +1 -1
  38. package/dist/cjs/types/service.js +85 -0
  39. package/dist/cjs/types/service.js.map +1 -0
  40. package/dist/cli/cli/rocketride.js +335 -113
  41. package/dist/cli/cli/rocketride.js.map +1 -1
  42. package/dist/cli/client/account.js +284 -0
  43. package/dist/cli/client/account.js.map +1 -0
  44. package/dist/cli/client/billing.js +171 -0
  45. package/dist/cli/client/billing.js.map +1 -0
  46. package/dist/cli/client/client.js +1208 -756
  47. package/dist/cli/client/client.js.map +1 -1
  48. package/dist/cli/client/constants.js +10 -1
  49. package/dist/cli/client/constants.js.map +1 -1
  50. package/dist/cli/client/core/DAPBase.js +4 -1
  51. package/dist/cli/client/core/DAPBase.js.map +1 -1
  52. package/dist/cli/client/core/DAPClient.js +121 -50
  53. package/dist/cli/client/core/DAPClient.js.map +1 -1
  54. package/dist/cli/client/core/TransportBase.js +0 -10
  55. package/dist/cli/client/core/TransportBase.js.map +1 -1
  56. package/dist/cli/client/core/TransportWebSocket.js +30 -19
  57. package/dist/cli/client/core/TransportWebSocket.js.map +1 -1
  58. package/dist/cli/client/database.js +121 -0
  59. package/dist/cli/client/database.js.map +1 -0
  60. package/dist/cli/client/index.js +4 -0
  61. package/dist/cli/client/index.js.map +1 -1
  62. package/dist/cli/client/schema/Question.js +2 -0
  63. package/dist/cli/client/schema/Question.js.map +1 -1
  64. package/dist/cli/client/types/account.js +26 -0
  65. package/dist/cli/client/types/account.js.map +1 -0
  66. package/dist/cli/client/types/billing.js +26 -0
  67. package/dist/cli/client/types/billing.js.map +1 -0
  68. package/dist/cli/client/types/client.js +14 -0
  69. package/dist/cli/client/types/client.js.map +1 -1
  70. package/dist/cli/client/types/cprofile.js +26 -0
  71. package/dist/cli/client/types/cprofile.js.map +1 -0
  72. package/dist/cli/client/types/dashboard.js +26 -0
  73. package/dist/cli/client/types/dashboard.js.map +1 -0
  74. package/dist/cli/client/types/events.js +5 -1
  75. package/dist/cli/client/types/events.js.map +1 -1
  76. package/dist/cli/client/types/index.js +5 -0
  77. package/dist/cli/client/types/index.js.map +1 -1
  78. package/dist/cli/client/types/service.js +85 -0
  79. package/dist/cli/client/types/service.js.map +1 -0
  80. package/dist/esm/account.js +280 -0
  81. package/dist/esm/account.js.map +1 -0
  82. package/dist/esm/billing.js +167 -0
  83. package/dist/esm/billing.js.map +1 -0
  84. package/dist/esm/client.js +1208 -756
  85. package/dist/esm/client.js.map +1 -1
  86. package/dist/esm/constants.js +9 -0
  87. package/dist/esm/constants.js.map +1 -1
  88. package/dist/esm/core/DAPBase.js +4 -1
  89. package/dist/esm/core/DAPBase.js.map +1 -1
  90. package/dist/esm/core/DAPClient.js +121 -50
  91. package/dist/esm/core/DAPClient.js.map +1 -1
  92. package/dist/esm/core/TransportBase.js +0 -10
  93. package/dist/esm/core/TransportBase.js.map +1 -1
  94. package/dist/esm/core/TransportWebSocket.js +30 -19
  95. package/dist/esm/core/TransportWebSocket.js.map +1 -1
  96. package/dist/esm/database.js +117 -0
  97. package/dist/esm/database.js.map +1 -0
  98. package/dist/esm/index.js +4 -0
  99. package/dist/esm/index.js.map +1 -1
  100. package/dist/esm/schema/Question.js +2 -0
  101. package/dist/esm/schema/Question.js.map +1 -1
  102. package/dist/esm/types/account.js +25 -0
  103. package/dist/esm/types/account.js.map +1 -0
  104. package/dist/esm/types/billing.js +25 -0
  105. package/dist/esm/types/billing.js.map +1 -0
  106. package/dist/esm/types/client.js +13 -1
  107. package/dist/esm/types/client.js.map +1 -1
  108. package/dist/esm/types/cprofile.js +25 -0
  109. package/dist/esm/types/cprofile.js.map +1 -0
  110. package/dist/esm/types/dashboard.js +25 -0
  111. package/dist/esm/types/dashboard.js.map +1 -0
  112. package/dist/esm/types/events.js +5 -1
  113. package/dist/esm/types/events.js.map +1 -1
  114. package/dist/esm/types/index.js +5 -0
  115. package/dist/esm/types/index.js.map +1 -1
  116. package/dist/esm/types/service.js +82 -0
  117. package/dist/esm/types/service.js.map +1 -0
  118. package/dist/types/account.d.ts +209 -0
  119. package/dist/types/account.d.ts.map +1 -0
  120. package/dist/types/billing.d.ts +135 -0
  121. package/dist/types/billing.d.ts.map +1 -0
  122. package/dist/types/client.d.ts +553 -285
  123. package/dist/types/client.d.ts.map +1 -1
  124. package/dist/types/constants.d.ts +9 -0
  125. package/dist/types/constants.d.ts.map +1 -1
  126. package/dist/types/core/DAPBase.d.ts.map +1 -1
  127. package/dist/types/core/DAPClient.d.ts +89 -7
  128. package/dist/types/core/DAPClient.d.ts.map +1 -1
  129. package/dist/types/core/TransportBase.d.ts +1 -11
  130. package/dist/types/core/TransportBase.d.ts.map +1 -1
  131. package/dist/types/core/TransportWebSocket.d.ts +14 -11
  132. package/dist/types/core/TransportWebSocket.d.ts.map +1 -1
  133. package/dist/types/database.d.ts +90 -0
  134. package/dist/types/database.d.ts.map +1 -0
  135. package/dist/types/index.d.ts +2 -0
  136. package/dist/types/index.d.ts.map +1 -1
  137. package/dist/types/schema/Question.d.ts +3 -1
  138. package/dist/types/schema/Question.d.ts.map +1 -1
  139. package/dist/types/types/account.d.ts +171 -0
  140. package/dist/types/types/account.d.ts.map +1 -0
  141. package/dist/types/types/billing.d.ts +115 -0
  142. package/dist/types/types/billing.d.ts.map +1 -0
  143. package/dist/types/types/client.d.ts +241 -1
  144. package/dist/types/types/client.d.ts.map +1 -1
  145. package/dist/types/types/cprofile.d.ts +67 -0
  146. package/dist/types/types/cprofile.d.ts.map +1 -0
  147. package/dist/types/types/dashboard.d.ts +198 -0
  148. package/dist/types/types/dashboard.d.ts.map +1 -0
  149. package/dist/types/types/events.d.ts +90 -95
  150. package/dist/types/types/events.d.ts.map +1 -1
  151. package/dist/types/types/index.d.ts +5 -0
  152. package/dist/types/types/index.d.ts.map +1 -1
  153. package/dist/types/types/pipeline.d.ts +10 -2
  154. package/dist/types/types/pipeline.d.ts.map +1 -1
  155. package/dist/types/types/service.d.ts +189 -0
  156. package/dist/types/types/service.d.ts.map +1 -0
  157. package/dist/types/types/task.d.ts +5 -1
  158. package/dist/types/types/task.d.ts.map +1 -1
  159. package/package.json +12 -7
@@ -23,8 +23,12 @@
23
23
  */
24
24
  import { TransportWebSocket } from './core/TransportWebSocket.js';
25
25
  import { DAPClient } from './core/DAPClient.js';
26
+ import { TraceType } from './types/index.js';
26
27
  import { CONST_DEFAULT_WEB_CLOUD, CONST_DEFAULT_WEB_PROTOCOL, CONST_DEFAULT_WEB_PORT } from './constants.js';
27
- import { AuthenticationException } from './exceptions/index.js';
28
+ import { AccountApi } from './account.js';
29
+ import { BillingApi } from './billing.js';
30
+ import { DatabaseApi } from './database.js';
31
+ import { AuthenticationException, ConnectionException, PipeException } from './exceptions/index.js';
28
32
  // Global counter for generating unique client IDs
29
33
  let clientId = 0;
30
34
  /**
@@ -97,7 +101,8 @@ export class DataPipe {
97
101
  * unique pipe ID that is used for subsequent operations.
98
102
  *
99
103
  * @returns This DataPipe instance (for method chaining)
100
- * @throws Error if the pipe is already opened or if the pipeline is not running
104
+ * @throws Error if the pipe is already opened
105
+ * @throws PipeException if the server rejects the open request
101
106
  */
102
107
  async open() {
103
108
  if (this._opened) {
@@ -114,15 +119,32 @@ export class DataPipe {
114
119
  });
115
120
  const response = await this._client.request(request);
116
121
  if (this._client.didFail(response)) {
117
- throw new Error(response.message || 'Your pipeline is not currently running.');
122
+ const base = response.message || 'Failed to open a data pipe.';
123
+ const msg = `${base}\n\n` + 'Common causes:\n' + "- Pipeline isn't running (wrong token or task terminated)\n" + "- Pipeline source is 'chat' (use client.chat()), not webhook/dropper\n" + "- MIME type doesn't match the source lane (try mimeType='text/plain')\n";
124
+ throw new PipeException({ ...response, message: msg });
118
125
  }
119
126
  this._pipeId = response.body?.pipe_id;
120
- this._opened = true;
121
127
  // If an SSE callback was provided, subscribe and register for this pipe
122
128
  if (this._onSSE !== undefined && this._pipeId !== undefined) {
123
- await this._client.setEvents(this._token, ['SSE'], this._pipeId);
129
+ try {
130
+ await this._client.setEvents(this._token, ['SSE'], this._pipeId);
131
+ }
132
+ catch (err) {
133
+ // Roll back: don't leave the pipe half-open on the server.
134
+ try {
135
+ await this.close();
136
+ }
137
+ catch {
138
+ // Best-effort cleanup
139
+ }
140
+ const errMsg = err instanceof Error ? err.message : String(err);
141
+ const msg = `Failed to subscribe to SSE events for this data pipe.\n\n${errMsg}`;
142
+ throw new PipeException({ message: msg });
143
+ }
124
144
  this._client._ssePipeCallbacks.set(this._pipeId, this._onSSE);
125
145
  }
146
+ // Only mark opened after the server-side pipe is fully consistent (including SSE setup).
147
+ this._opened = true;
126
148
  return this;
127
149
  }
128
150
  /**
@@ -132,7 +154,8 @@ export class DataPipe {
132
154
  * multiple times to stream large datasets. The pipe must be opened first.
133
155
  *
134
156
  * @param buffer - Data to write, must be a Uint8Array
135
- * @throws Error if the pipe is not opened, buffer is invalid, or write fails
157
+ * @throws Error if the pipe is not opened or buffer is invalid
158
+ * @throws PipeException if the server reports a write failure
136
159
  */
137
160
  async write(buffer) {
138
161
  if (!this._opened) {
@@ -151,7 +174,8 @@ export class DataPipe {
151
174
  });
152
175
  const response = await this._client.request(request);
153
176
  if (this._client.didFail(response)) {
154
- throw new Error(response.message || 'Failed to write to pipe');
177
+ const msg = response.message || 'Failed to write to a data pipe.';
178
+ throw new PipeException({ ...response, message: msg });
155
179
  }
156
180
  }
157
181
  /**
@@ -162,10 +186,12 @@ export class DataPipe {
162
186
  * the pipe cannot be reopened or written to again.
163
187
  *
164
188
  * @returns The processing result from the server, or undefined if already closed
165
- * @throws Error if closing the pipe fails
189
+ * @throws PipeException if the server reports a failure while finalizing the pipe
166
190
  */
167
191
  async close() {
168
- if (!this._opened || this._closed) {
192
+ // Allow closing after a failed open() path where the server assigned a pipe_id
193
+ // but we never flipped _opened=true (e.g., SSE subscription failure).
194
+ if (this._closed || (this._pipeId === undefined && !this._opened)) {
169
195
  return;
170
196
  }
171
197
  try {
@@ -178,12 +204,14 @@ export class DataPipe {
178
204
  });
179
205
  const response = await this._client.request(request);
180
206
  if (this._client.didFail(response)) {
181
- throw new Error(response.message || 'Failed to close pipe');
207
+ const msg = response.message || 'Failed to close a data pipe.';
208
+ throw new PipeException({ ...response, message: msg });
182
209
  }
183
210
  return response.body;
184
211
  }
185
212
  finally {
186
213
  this._closed = true;
214
+ this._opened = false;
187
215
  // Unregister SSE callback and scoped monitor subscription
188
216
  if (this._onSSE !== undefined && this._pipeId !== undefined) {
189
217
  this._client._ssePipeCallbacks.delete(this._pipeId);
@@ -197,24 +225,6 @@ export class DataPipe {
197
225
  }
198
226
  }
199
227
  }
200
- /**
201
- * Main RocketRide client for connecting to RocketRide servers and services.
202
- *
203
- * This client provides a comprehensive API for interacting with RocketRide services,
204
- * including connection management, pipeline execution, data operations, AI chat,
205
- * event handling, and server connectivity testing.
206
- *
207
- * Key features:
208
- * - Single shared WebSocket connection for all operations
209
- * - Connection management (connect/disconnect) with optional persistence
210
- * - Automatic reconnection when persist mode is enabled
211
- * - Pipeline execution (use, terminate, getTaskStatus)
212
- * - Data operations (send, sendFiles, pipe)
213
- * - AI chat functionality (chat)
214
- * - Event handling (setEvents, event callbacks)
215
- * - Server connectivity testing (ping)
216
- * - Full TypeScript type safety
217
- */
218
228
  export class RocketRideClient extends DAPClient {
219
229
  /**
220
230
  * Creates a new RocketRideClient instance.
@@ -277,24 +287,26 @@ export class RocketRideClient extends DAPClient {
277
287
  }
278
288
  }
279
289
  }
280
- const { auth = config.auth || clientEnv.ROCKETRIDE_APIKEY, uri = config.uri || clientEnv.ROCKETRIDE_URI || CONST_DEFAULT_WEB_CLOUD, onEvent, onConnected, onDisconnected, onConnectError, persist, maxRetryTime, module, } = config;
290
+ const { auth = config.auth, uri = config.uri || clientEnv.ROCKETRIDE_URI || CONST_DEFAULT_WEB_CLOUD, onEvent, onConnected, onDisconnected, onConnectError, persist, module } = config;
281
291
  // Create unique client identifier
282
292
  const clientName = module || `CLIENT-${clientId++}`;
283
- // Initialize the DAPClient without a transport; transport is created in _internalConnect (CONNECTION_LOGIC.md §3)
293
+ // Initialize the DAPClient without a transport; transport is created in _internalAttach
284
294
  super(clientName, undefined, config);
285
295
  this._dapAttempted = false;
286
296
  this._nextChatId = 1;
287
297
  /** Maps pipe_id → SSE callback for pipe-scoped real-time event dispatch. */
288
298
  this._ssePipeCallbacks = new Map();
289
- // Persistence properties for automatic reconnection
299
+ // Desired state model replaces old flag soup
300
+ this._desiredState = 'detached';
301
+ this._authenticated = false;
290
302
  this._persist = false;
291
- this._manualDisconnect = false;
292
303
  this._currentReconnectDelay = 250;
293
- /** True after onConnected has been invoked; used to only invoke onDisconnected when we had a connection. */
294
- this._didNotifyConnected = false;
304
+ /** Reference-counted monitor subscriptions: keyString Map<eventType, refCount> */
305
+ this._monitorKeys = new Map();
295
306
  // Store connection details and environment
307
+ this._wsPath = config.wsPath;
296
308
  this._setUri(uri);
297
- this._setAuth(auth);
309
+ this._setAuth(auth ?? '');
298
310
  this._env = clientEnv;
299
311
  // Set up callbacks if provided
300
312
  if (onEvent)
@@ -305,9 +317,11 @@ export class RocketRideClient extends DAPClient {
305
317
  this._callerOnDisconnected = onDisconnected;
306
318
  if (onConnectError)
307
319
  this._callerOnConnectError = onConnectError;
320
+ if (config.onTrace)
321
+ this._onTrace = config.onTrace;
308
322
  // Set up persistence options
309
323
  this._persist = persist ?? false;
310
- this._maxRetryTime = maxRetryTime;
324
+ // maxRetryTime accepted for backward compat but ignored (linear backoff never gives up)
311
325
  }
312
326
  /**
313
327
  * Normalize a user-provided URI into a fully-formed HTTP/HTTPS URL.
@@ -325,7 +339,16 @@ export class RocketRideClient extends DAPClient {
325
339
  }
326
340
  try {
327
341
  const url = new URL(normalized);
328
- if (!url.port && !url.hostname.includes('rocketride.ai')) {
342
+ // The URL API silently strips ports that are default-for-scheme
343
+ // (e.g. :443 on https, :80 on http), so url.port alone cannot
344
+ // distinguish "no port given" from "scheme-default port given".
345
+ // Check the raw input for an explicit `:digits` after the scheme.
346
+ const withoutScheme = normalized.replace(/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//, '');
347
+ const authority = withoutScheme.split(/[/?#]/, 1)[0] ?? '';
348
+ const hasExplicitPort = authority.startsWith('[')
349
+ ? /\]:\d+$/.test(authority) // IPv6 literal with explicit port
350
+ : /:\d+$/.test(authority); // hostname/IPv4 with explicit port
351
+ if (!url.port && !hasExplicitPort && !url.hostname.includes('rocketride.ai')) {
329
352
  url.port = CONST_DEFAULT_WEB_PORT;
330
353
  }
331
354
  return `${url.protocol}//${url.host}`;
@@ -334,19 +357,57 @@ export class RocketRideClient extends DAPClient {
334
357
  return normalized;
335
358
  }
336
359
  }
360
+ /**
361
+ * Probe a server for its capabilities without authenticating.
362
+ *
363
+ * Creates a temporary public connection and sends an
364
+ * ``rrext_public_probe`` command. The server responds with version,
365
+ * capabilities, platform, and public apps without requiring credentials.
366
+ *
367
+ * @param uri - Server URI (e.g. ``"localhost:5565"``, ``"https://cloud.rocketride.ai"``)
368
+ * @param timeout - Optional timeout in ms for the entire operation
369
+ * @returns Server info including version and capability tags
370
+ * @throws Error if the server is unreachable or does not support probes
371
+ *
372
+ * @example
373
+ * ```typescript
374
+ * const info = await RocketRideClient.getServerInfo('localhost:5565');
375
+ * if (info.capabilities.includes('saas')) {
376
+ * // Show cloud sign-in options
377
+ * }
378
+ * ```
379
+ */
380
+ static async getServerInfo(uri, timeout) {
381
+ const client = new RocketRideClient({ uri, persist: false });
382
+ try {
383
+ // Open a public connection (no auth handshake)
384
+ await client.attach(uri, { timeout });
385
+ // Send rrext_public_probe — allowed on unauthenticated connections
386
+ const message = client.buildRequest('rrext_public_probe', {});
387
+ const response = await client.request(message, timeout);
388
+ if (response.success === false) {
389
+ throw new Error(response.message || 'Server info request failed');
390
+ }
391
+ return (response.body ?? {});
392
+ }
393
+ finally {
394
+ await client.disconnect();
395
+ }
396
+ }
337
397
  /**
338
398
  * Normalize a user-provided URI into a fully-formed WebSocket address.
339
399
  * Builds on normalizeUri, then converts to ws/wss and appends /task/service.
340
400
  */
341
401
  _getWebsocketUri(uri) {
342
402
  const httpUrl = RocketRideClient.normalizeUri(uri);
403
+ const path = this._wsPath ?? '/task/service';
343
404
  try {
344
405
  const url = new URL(httpUrl);
345
- const wsScheme = (url.protocol === 'https:' || url.protocol === 'wss:') ? 'wss:' : 'ws:';
346
- return `${wsScheme}//${url.host}/task/service`;
406
+ const wsScheme = url.protocol === 'https:' || url.protocol === 'wss:' ? 'wss:' : 'ws:';
407
+ return `${wsScheme}//${url.host}${path}`;
347
408
  }
348
409
  catch {
349
- return `${httpUrl}/task/service`;
410
+ return `${httpUrl}${path}`;
350
411
  }
351
412
  }
352
413
  /**
@@ -361,182 +422,318 @@ export class RocketRideClient extends DAPClient {
361
422
  _setAuth(auth) {
362
423
  this._apikey = auth;
363
424
  }
364
- /**
365
- * Clear any pending reconnection timeout.
366
- */
367
- _clearReconnectTimeout() {
368
- if (this._reconnectTimeout) {
369
- clearTimeout(this._reconnectTimeout);
370
- this._reconnectTimeout = undefined;
371
- }
372
- }
373
425
  // ============================================================================
374
- // CONNECTION METHODS
426
+ // INTERNAL CONNECTION HELPERS
375
427
  // ============================================================================
376
428
  /**
377
- * Single place for physical connection. Creates transport if needed, then
378
- * calls DAPClient.connect (transport connect + auth handshake + onConnected).
429
+ * Create transport if needed and open the WebSocket. No auth.
379
430
  */
380
- async _internalConnect(timeout) {
431
+ async _internalAttach(timeout) {
381
432
  if (!this._transport) {
382
- const transport = new TransportWebSocket(this._uri, this._apikey);
433
+ const transport = new TransportWebSocket(this._uri);
383
434
  this._bindTransport(transport);
384
435
  }
385
- await super.connect(timeout);
436
+ await super._dapConnect(timeout);
437
+ }
438
+ /**
439
+ * Send the ``auth`` DAP command over the open transport.
440
+ * Sets ``_authenticated`` and ``_connectResult`` on success.
441
+ * Throws ``AuthenticationException`` on failure (transport stays open).
442
+ */
443
+ async _internalLogin(timeout) {
444
+ // Build auth args with credential + client identification
445
+ const authArgs = { auth: this._apikey ?? '' };
446
+ if (this._clientDisplayName)
447
+ authArgs.clientName = this._clientDisplayName;
448
+ if (this._clientDisplayVersion)
449
+ authArgs.clientVersion = this._clientDisplayVersion;
450
+ const resp = await this.request({ type: 'request', command: 'auth', seq: 0, arguments: authArgs }, timeout);
451
+ const success = resp.success;
452
+ if (!success) {
453
+ throw new AuthenticationException(resp);
454
+ }
455
+ this._connectResult = resp.body;
456
+ this._authenticated = true;
457
+ // Store userToken for future reconnects
458
+ if (this._connectResult?.userToken) {
459
+ this._apikey = this._connectResult.userToken;
460
+ }
461
+ // Resubscribe monitors and notify
462
+ await this._resubscribeAllMonitors();
463
+ const connectionInfo = this._transport?.getConnectionInfo() ?? '';
464
+ if (this._callerOnConnected) {
465
+ try {
466
+ await this._callerOnConnected(connectionInfo);
467
+ }
468
+ catch (e) {
469
+ this.debugMessage(`Error in user onConnected handler: ${e}`);
470
+ }
471
+ }
472
+ await super.onConnected(connectionInfo);
473
+ return this._connectResult;
474
+ }
475
+ /**
476
+ * Send the ``deauth`` DAP command to revert to unauthenticated.
477
+ */
478
+ async _internalLogout() {
479
+ if (!this._authenticated || !this._transport?.isConnected())
480
+ return;
481
+ try {
482
+ await this.request({ type: 'request', command: 'deauth', seq: 0, arguments: {} });
483
+ }
484
+ catch {
485
+ // Best-effort — server may have already disconnected
486
+ }
487
+ this._connectResult = undefined;
488
+ this._authenticated = false;
386
489
  }
387
490
  /**
388
- * Single place for physical disconnect. Closes the transport directly,
389
- * which triggers onDisconnected via the transport callback.
491
+ * Close the transport. Triggers onDisconnected via the transport callback.
390
492
  */
391
- async _internalDisconnect(reason, hasError) {
493
+ async _internalDisconnect() {
392
494
  if (!this._transport)
393
495
  return;
394
- await this._transport.disconnect(reason, hasError);
496
+ await this._transport.disconnect();
395
497
  }
396
498
  /**
397
- * Try to connect; on auth error notify and stop; on other error notify and
398
- * reschedule with exponential backoff. Used by persist-mode connect() and
399
- * by the reconnect timer.
499
+ * Clear the reconnect timer if active.
400
500
  */
401
- async _attemptConnection(timeout) {
402
- try {
403
- await this._internalConnect(timeout);
404
- this._reconnectTimeout = undefined;
405
- this.debugMessage('Connection successful');
501
+ _clearReconnectTimer() {
502
+ if (this._reconnectTimer) {
503
+ clearTimeout(this._reconnectTimer);
504
+ this._reconnectTimer = undefined;
406
505
  }
407
- catch (error) {
408
- const err = error instanceof Error ? error : new Error(String(error));
409
- this.debugMessage(`Connection failed: ${err}`);
410
- await this.onConnectError(err);
411
- if (error instanceof AuthenticationException) {
412
- return;
413
- }
414
- if (this._retryStartTime === undefined) {
415
- this._retryStartTime = Date.now();
506
+ }
507
+ /**
508
+ * Reconnect engine driven by ``_desiredState``.
509
+ *
510
+ * Schedules a timer that re-attaches (and re-logins if the user had
511
+ * been authenticated). Checks ``_desiredState`` after every await so
512
+ * user actions mid-reconnect are respected immediately.
513
+ *
514
+ * Linear backoff: 250ms → 500ms → ... → 15 000ms cap.
515
+ */
516
+ _scheduleReconnect() {
517
+ this.debugMessage(`Scheduling reconnect in ${this._currentReconnectDelay}ms`);
518
+ this._reconnectTimer = setTimeout(async () => {
519
+ try {
520
+ // Re-attach transport
521
+ await this._internalAttach();
522
+ if (this._desiredState === 'detached') {
523
+ this._reconnectTimer = undefined;
524
+ return;
525
+ }
526
+ // Re-login if the user was authenticated
527
+ if (this._desiredState === 'authenticated') {
528
+ await this._internalLogin();
529
+ if (this._desiredState === 'detached') {
530
+ this._reconnectTimer = undefined;
531
+ return;
532
+ }
533
+ }
534
+ // Success — reset backoff
535
+ this._reconnectTimer = undefined;
536
+ this._currentReconnectDelay = 250;
537
+ this.debugMessage('Reconnect successful');
416
538
  }
417
- if (this._maxRetryTime !== undefined) {
418
- if (Date.now() - this._retryStartTime >= this._maxRetryTime) {
539
+ catch (err) {
540
+ // User changed intent — stop (desiredState may have been changed by detach() during await)
541
+ if (this._desiredState === 'detached') {
542
+ this._reconnectTimer = undefined;
419
543
  return;
420
544
  }
545
+ // Auth rejected — downgrade to attached, stop retrying auth
546
+ if (err instanceof AuthenticationException) {
547
+ this._desiredState = 'attached';
548
+ this._reconnectTimer = undefined;
549
+ await this.onConnectError(err);
550
+ return;
551
+ }
552
+ // Transient failure — linear backoff, cap at 15s
553
+ this._currentReconnectDelay = Math.min(this._currentReconnectDelay + 250, 15000);
554
+ const error = err instanceof Error ? err : new Error(String(err));
555
+ await this.onConnectError(error);
556
+ this._scheduleReconnect(); // replaces timer with new delay
421
557
  }
422
- this._currentReconnectDelay = Math.min(this._currentReconnectDelay * 2, 2500);
423
- this._scheduleReconnect();
424
- }
558
+ }, this._currentReconnectDelay);
425
559
  }
560
+ // ============================================================================
561
+ // PUBLIC API — TRANSPORT
562
+ // ============================================================================
426
563
  /**
427
- * Schedule a reconnection attempt with exponential backoff.
564
+ * Attach to a RocketRide server (open WebSocket, no auth).
565
+ *
566
+ * If ``uri`` is provided and differs from the current URI, detaches
567
+ * first. If already attached to the same URI, this is a no-op.
568
+ *
569
+ * After attach, public APIs (``rrext_public_*``) are available.
570
+ *
571
+ * @param uri - Server URI override. Updates the stored URI if provided.
572
+ * @param options - Optional timeout for the WebSocket handshake.
428
573
  */
429
- _scheduleReconnect() {
430
- this._clearReconnectTimeout();
431
- if (this._maxRetryTime !== undefined && this._retryStartTime !== undefined) {
432
- if (Date.now() - this._retryStartTime >= this._maxRetryTime) {
433
- this.onConnectError(new Error('Max retry time exceeded'));
434
- return;
574
+ async attach(uri, options) {
575
+ // URI change → detach first, then update
576
+ if (uri) {
577
+ const normalised = this._getWebsocketUri(uri);
578
+ if (normalised !== this._uri) {
579
+ if (this.isAttached())
580
+ await this.detach();
581
+ this._setUri(uri);
435
582
  }
436
583
  }
437
- this.debugMessage(`Scheduling reconnection in ${this._currentReconnectDelay}ms`);
438
- this._reconnectTimeout = setTimeout(async () => {
439
- if (this._persist && !this._manualDisconnect) {
440
- this.debugMessage('Attempting to reconnect...');
441
- await this._attemptConnection();
442
- }
443
- }, this._currentReconnectDelay);
584
+ // Already attached → no-op
585
+ if (this.isAttached()) {
586
+ this._desiredState = this._desiredState === 'detached' ? 'attached' : this._desiredState;
587
+ return;
588
+ }
589
+ this._desiredState = 'attached';
590
+ await this._internalAttach(options?.timeout);
444
591
  }
445
592
  /**
446
- * Check if the client is currently connected to the RocketRide server.
593
+ * Detach from the server (close WebSocket, cancel reconnection).
594
+ *
595
+ * Sets ``_desiredState`` to ``'detached'`` so the reconnect engine
596
+ * stops and ``onDisconnected`` does not restart it.
447
597
  */
448
- isConnected() {
598
+ async detach() {
599
+ this._desiredState = 'detached';
600
+ this._clearReconnectTimer();
601
+ this._authenticated = false;
602
+ this._connectResult = undefined;
603
+ if (this._transport?.isConnected()) {
604
+ await this._internalDisconnect();
605
+ }
606
+ }
607
+ /**
608
+ * True when the WebSocket transport is connected (regardless of auth).
609
+ */
610
+ isAttached() {
449
611
  return this._transport?.isConnected() || false;
450
612
  }
613
+ // ============================================================================
614
+ // PUBLIC API — AUTH
615
+ // ============================================================================
451
616
  /**
452
- * Connect to the RocketRide server.
617
+ * Authenticate over an attached transport.
453
618
  *
454
- * Must be called before executing pipelines or other operations.
455
- * In persist mode, enables automatic reconnection on disconnect and on initial failure
456
- * (calls onConnectError on each failed attempt and keeps retrying).
457
- * @param options - Optional timeout (number) or connection parameters object with uri, auth, and timeout.
619
+ * If ``uri`` is provided and differs, detaches and re-attaches first.
620
+ * If ``auth`` is provided and differs from the current credential,
621
+ * logs out (best-effort) before logging in with the new credential.
622
+ * If already authenticated with the same credential, this is a no-op.
623
+ *
624
+ * @param credential - API key, rr_ token, or PKCE code object.
625
+ * @param options - Optional URI override and/or timeout.
626
+ * @returns ConnectResult with user identity on success.
627
+ * @throws AuthenticationException on auth failure (transport stays attached).
458
628
  */
459
- async connect(options) {
460
- let uri;
461
- let auth;
462
- let timeout;
463
- if (typeof options === 'number') {
464
- timeout = options;
465
- }
466
- else if (options) {
467
- ({ uri, auth, timeout } = options);
629
+ async login(credential, options) {
630
+ // Resolve credential
631
+ let resolvedCredential;
632
+ if (credential && typeof credential === 'object') {
633
+ resolvedCredential = 'cd_' + btoa(JSON.stringify(credential));
468
634
  }
469
- // Apply optional overrides so they're used for this connect
470
- if (uri !== undefined) {
471
- this._setUri(uri);
635
+ else {
636
+ const envKey = this._env['ROCKETRIDE_APIKEY'];
637
+ const envCredential = typeof envKey === 'string' && envKey.trim() !== '' ? envKey : undefined;
638
+ resolvedCredential = credential ?? envCredential ?? this._apikey ?? '';
639
+ }
640
+ // URI change → detach + re-attach
641
+ if (options?.uri) {
642
+ const normalised = this._getWebsocketUri(options.uri);
643
+ if (normalised !== this._uri) {
644
+ await this.detach();
645
+ this._setUri(options.uri);
646
+ await this._internalAttach(options.timeout);
647
+ }
472
648
  }
473
- if (auth !== undefined) {
474
- this._setAuth(auth);
649
+ // Ensure attached
650
+ if (!this.isAttached()) {
651
+ await this._internalAttach(options?.timeout);
475
652
  }
476
- this._manualDisconnect = false;
477
- this._currentReconnectDelay = 250;
478
- this._retryStartTime = undefined;
479
- // If already connected, disconnect first without setting _manualDisconnect
480
- if (this.isConnected()) {
481
- await this._internalDisconnect();
482
- }
483
- if (this._persist) {
484
- this._clearReconnectTimeout();
485
- await this._attemptConnection(timeout);
653
+ // Auth change → logout first (best-effort)
654
+ if (resolvedCredential !== this._apikey && this._authenticated) {
655
+ try {
656
+ await this._internalLogout();
657
+ }
658
+ catch { }
486
659
  }
487
- else {
488
- await this._internalConnect(timeout);
660
+ this._setAuth(resolvedCredential);
661
+ // Already authenticated with same credential → no-op
662
+ if (this._authenticated) {
663
+ this._desiredState = 'authenticated';
664
+ return this._connectResult ?? {};
489
665
  }
666
+ this._desiredState = 'authenticated';
667
+ return this._internalLogin(options?.timeout);
490
668
  }
491
669
  /**
492
- * Disconnect from the RocketRide server and stop automatic reconnection.
670
+ * Deauthenticate: sends ``deauth`` to the server, clears client auth state.
671
+ * The transport stays attached — public APIs continue to work.
672
+ */
673
+ async logout() {
674
+ await this._internalLogout();
675
+ this._desiredState = 'attached';
676
+ }
677
+ /**
678
+ * True when the auth handshake has succeeded on the current connection.
679
+ */
680
+ isAuthenticated() {
681
+ return this._authenticated;
682
+ }
683
+ // ============================================================================
684
+ // COMPAT API — connect() / disconnect()
685
+ // ============================================================================
686
+ /**
687
+ * Check if the client is currently connected to the RocketRide server.
688
+ * Equivalent to ``isAttached()`` — kept for backward compatibility.
689
+ */
690
+ isConnected() {
691
+ return this.isAttached();
692
+ }
693
+ /**
694
+ * Connect to the RocketRide server and authenticate in a single call.
695
+ *
696
+ * Backward-compatible wrapper around ``attach()`` + ``login()``.
697
+ * Sends the credential as the first DAP message and returns the full
698
+ * ConnectResult (user identity + organizations + teams) on success.
493
699
  *
494
- * Should be called when finished with the client to clean up resources.
700
+ * @param credential - API key / Zitadel access_token / rr_ user token / PKCE code object.
701
+ * @param options - Optional overrides: uri and/or timeout.
495
702
  */
496
- async disconnect() {
497
- this._manualDisconnect = true;
498
- this._clearReconnectTimeout();
499
- if (this._transport && this.isConnected()) {
500
- await this._internalDisconnect();
501
- }
703
+ async connect(credential, options) {
704
+ this._currentReconnectDelay = 250;
705
+ await this.attach(options?.uri, { timeout: options?.timeout });
706
+ return this.login(credential, options);
502
707
  }
503
708
  /**
504
- * Update server URI and/or auth at runtime. If currently connected,
505
- * disconnects and reconnects with the new params. In persist mode,
506
- * reconnection is scheduled only if we were connected.
709
+ * Get the ConnectResult from the last successful connect().
710
+ * Returns undefined if not connected or not yet authenticated.
507
711
  */
508
- async setConnectionParams(options) {
509
- if (options.uri !== undefined) {
510
- this._setUri(options.uri);
511
- }
512
- if (options.auth !== undefined) {
513
- this._setAuth(options.auth);
514
- }
515
- const wasAlreadyConnected = this.isConnected();
516
- this._manualDisconnect = true;
517
- this._clearReconnectTimeout();
518
- if (wasAlreadyConnected) {
519
- await this._internalDisconnect();
520
- }
521
- // Destroy transport so next connect() creates a new one with updated uri/auth (CONNECTION_LOGIC.md §2c)
522
- if (options.uri !== undefined || options.auth !== undefined) {
523
- this._transport = undefined;
524
- }
525
- if (this._persist && wasAlreadyConnected) {
526
- this._manualDisconnect = false;
527
- this._scheduleReconnect();
528
- }
529
- else if (wasAlreadyConnected) {
530
- this._manualDisconnect = false;
531
- await this._internalConnect();
532
- }
533
- else {
534
- this._manualDisconnect = false;
535
- }
712
+ getAccountInfo() {
713
+ return this._connectResult;
714
+ }
715
+ /**
716
+ * Returns the ID of the user's primary organization.
717
+ */
718
+ getOrgId() {
719
+ return this._connectResult?.organizations?.[0]?.id;
536
720
  }
721
+ /**
722
+ * Disconnect from the RocketRide server and stop automatic reconnection.
723
+ * Backward-compatible wrapper around ``logout()`` + ``detach()``.
724
+ */
725
+ async disconnect() {
726
+ await this.logout();
727
+ await this.detach();
728
+ }
729
+ /**
730
+ * Update the environment variables used for pipeline substitution.
731
+ *
732
+ * The env dictionary is used by {@link use} and {@link validate} to replace
537
733
  // ============================================================================
538
734
  // PING METHODS
539
735
  // ============================================================================
736
+
540
737
  /**
541
738
  * Test connectivity to the RocketRide server.
542
739
  *
@@ -545,59 +742,16 @@ export class RocketRideClient extends DAPClient {
545
742
  * and measuring response times.
546
743
  */
547
744
  async ping(token) {
548
- // Build ping request
549
- const request = this.buildRequest('rrext_ping', { token });
550
- // Send to server and wait for response
551
- const response = await this.request(request);
552
- // Check if ping failed
553
- if (this.didFail(response)) {
554
- const errorMsg = response.message || 'Ping failed';
555
- throw new Error(`Ping failed: ${errorMsg}`);
745
+ try {
746
+ await this.call('rrext_ping', undefined, { token });
747
+ }
748
+ catch (err) {
749
+ throw new Error(`Ping failed: ${err instanceof Error ? err.message : err}`);
556
750
  }
557
751
  }
558
752
  // ============================================================================
559
753
  // EXECUTION METHODS
560
754
  // ============================================================================
561
- /**
562
- * Substitute environment variables in a string.
563
- * Replaces ${ROCKETRIDE_*} patterns with values from client's env dictionary.
564
- * If variable is not found, leaves it unchanged.
565
- */
566
- substituteEnvVars(value) {
567
- // Match ${ROCKETRIDE_*} patterns
568
- return value.replace(/\$\{(ROCKETRIDE_[^}]+)\}/g, (match, varName) => {
569
- // Check if variable exists in client's env
570
- if (varName in this._env) {
571
- return String(this._env[varName]);
572
- }
573
- // If not found, leave as is
574
- return match;
575
- });
576
- }
577
- /**
578
- * Recursively process an object/array to substitute environment variables.
579
- * Only processes string values, leaving other types unchanged.
580
- */
581
- processEnvSubstitution(obj) {
582
- if (typeof obj === 'string') {
583
- // If it's a string, perform substitution
584
- return this.substituteEnvVars(obj);
585
- }
586
- else if (Array.isArray(obj)) {
587
- // If it's an array, process each element
588
- return obj.map(item => this.processEnvSubstitution(item));
589
- }
590
- else if (obj !== null && typeof obj === 'object') {
591
- // If it's an object, process each property
592
- const result = {};
593
- for (const [key, value] of Object.entries(obj)) {
594
- result[key] = this.processEnvSubstitution(value);
595
- }
596
- return result;
597
- }
598
- // For other types (number, boolean, null), return as is
599
- return obj;
600
- }
601
755
  /**
602
756
  * Load Node.js fs/promises at runtime without static imports.
603
757
  * This keeps browser bundles free of Node built-ins while preserving Node features.
@@ -657,19 +811,16 @@ export class RocketRideClient extends DAPClient {
657
811
  */
658
812
  async validate(options) {
659
813
  const { pipeline, source } = options;
660
- const arguments_ = { pipeline };
814
+ const args = { pipeline };
661
815
  if (source !== undefined) {
662
- arguments_.source = source;
816
+ args.source = source;
663
817
  }
664
- const request = this.buildRequest('rrext_validate', {
665
- arguments: arguments_
666
- });
667
- const response = await this.request(request);
668
- if (this.didFail(response)) {
669
- const errorMsg = response.message || 'Validation failed';
670
- throw new Error(`Pipeline validation failed: ${errorMsg}`);
818
+ try {
819
+ return await this.call('rrext_validate', args);
820
+ }
821
+ catch (err) {
822
+ throw new Error(`Pipeline validation failed: ${err instanceof Error ? err.message : err}`);
671
823
  }
672
- return response.body || {};
673
824
  }
674
825
  // ============================================================================
675
826
  // PIPELINE EXECUTION METHODS
@@ -718,7 +869,7 @@ export class RocketRideClient extends DAPClient {
718
869
  * ```
719
870
  */
720
871
  async use(options = {}) {
721
- const { token, filepath, pipeline, source, threads, useExisting, args, ttl, pipelineTraceLevel } = options;
872
+ const { token, filepath, pipeline, source, threads, useExisting, args, ttl, pipelineTraceLevel, name, env, teamId } = options;
722
873
  // Validate required parameters
723
874
  if (!pipeline && !filepath) {
724
875
  throw new Error('Pipeline configuration or file path is required and must be specified');
@@ -741,9 +892,7 @@ export class RocketRideClient extends DAPClient {
741
892
  pipelineConfig = pipeline;
742
893
  }
743
894
  // Create a deep copy of the pipeline config to avoid modifying the original
744
- let processedConfig = JSON.parse(JSON.stringify(pipelineConfig));
745
- // Perform environment variable substitution on the pipeline configuration
746
- processedConfig = this.processEnvSubstitution(processedConfig);
895
+ const processedConfig = JSON.parse(JSON.stringify(pipelineConfig));
747
896
  // Override source if specified (after substitution)
748
897
  if (source !== undefined) {
749
898
  processedConfig.source = source;
@@ -770,54 +919,135 @@ export class RocketRideClient extends DAPClient {
770
919
  if (pipelineTraceLevel !== undefined) {
771
920
  arguments_.pipelineTraceLevel = pipelineTraceLevel;
772
921
  }
922
+ // Build ROCKETRIDE_* env from client's .env + caller overrides
923
+ const rocketEnv = {};
924
+ for (const [k, v] of Object.entries(this._env)) {
925
+ if (k.startsWith('ROCKETRIDE_'))
926
+ rocketEnv[k] = v;
927
+ }
928
+ if (env)
929
+ Object.assign(rocketEnv, env);
930
+ if (Object.keys(rocketEnv).length > 0) {
931
+ arguments_.env = rocketEnv;
932
+ }
933
+ // Derive display name from filepath if not explicitly provided
934
+ const effectiveName = name ?? (filepath ? filepath.replace(/^.*[\\/]/, '').replace(/\.pipe(?:\.json)?$/, '') : undefined);
935
+ if (effectiveName !== undefined) {
936
+ arguments_.name = effectiveName;
937
+ }
938
+ if (teamId !== undefined) {
939
+ arguments_.teamId = teamId;
940
+ }
773
941
  // Send execution request to server
774
- const request = this.buildRequest('execute', { arguments: arguments_ });
775
- const response = await this.request(request);
776
- // Check for execution errors
777
- if (this.didFail(response)) {
778
- const errorMsg = response.message || 'Unknown execution error';
779
- this.debugMessage(`Pipeline execution failed: ${errorMsg}`);
780
- throw new Error(errorMsg);
942
+ try {
943
+ const body = await this.call('execute', arguments_);
944
+ // Extract and validate response
945
+ const responseBody = body || {};
946
+ const taskToken = responseBody.token;
947
+ if (!taskToken) {
948
+ throw new Error('Server did not return a task token in successful response');
949
+ }
950
+ this.debugMessage(`Pipeline execution started successfully, task token: ${taskToken}`);
951
+ // Type assertion to ensure token is present
952
+ return responseBody;
781
953
  }
782
- // Extract and validate response
783
- const responseBody = response.body || {};
784
- const taskToken = responseBody.token;
785
- if (!taskToken) {
786
- throw new Error('Server did not return a task token in successful response');
954
+ catch (err) {
955
+ const errorMsg = err instanceof Error ? err.message : String(err);
956
+ this.debugMessage(`Pipeline execution failed: ${errorMsg}`);
957
+ throw err;
787
958
  }
788
- this.debugMessage(`Pipeline execution started successfully, task token: ${taskToken}`);
789
- // Type assertion to ensure token is present
790
- return responseBody;
791
959
  }
792
960
  /**
793
961
  * Terminate a running pipeline.
794
962
  */
795
963
  async terminate(token) {
796
- // Send termination request
797
- const request = this.buildRequest('terminate', { token });
798
- const response = await this.request(request);
799
- // Check for termination errors
800
- if (this.didFail(response)) {
801
- const errorMsg = response.message || 'Unknown termination error';
964
+ try {
965
+ await this.call('terminate', undefined, { token });
966
+ }
967
+ catch (err) {
968
+ const errorMsg = err instanceof Error ? err.message : String(err);
802
969
  this.debugMessage(`Pipeline termination failed: ${errorMsg}`);
803
970
  throw new Error(errorMsg);
804
971
  }
805
972
  }
973
+ /**
974
+ * Restart a running pipeline with a new configuration.
975
+ *
976
+ * Looks up the existing task by project/source, terminates it, and
977
+ * starts a new execution in one server round-trip.
978
+ *
979
+ * @param options.token - Existing task token (optional, resolved server-side if omitted).
980
+ * @param options.projectId - The project identifier.
981
+ * @param options.source - The source component identifier.
982
+ * @param options.pipeline - The pipeline configuration to restart with.
983
+ */
984
+ async restart(options) {
985
+ try {
986
+ await this.call('restart', {
987
+ token: options.token,
988
+ projectId: options.projectId,
989
+ source: options.source,
990
+ pipeline: options.pipeline,
991
+ }, { token: '*' });
992
+ }
993
+ catch (err) {
994
+ const errorMsg = err instanceof Error ? err.message : String(err);
995
+ this.debugMessage(`Pipeline restart failed: ${errorMsg}`);
996
+ throw new Error(errorMsg);
997
+ }
998
+ }
806
999
  /**
807
1000
  * Get the current status of a running pipeline.
1001
+ *
1002
+ * By default this call is bounded to 15s so callers/tests don't hang forever if the engine
1003
+ * stops responding mid-request (especially important in CI). Pass `{ timeout: false }` to
1004
+ * restore the previous behavior of using only the client-level request timeout (if any).
808
1005
  */
809
- async getTaskStatus(token) {
810
- // Send status request
811
- const request = this.buildRequest('rrext_get_task_status', { token });
812
- const response = await this.request(request);
813
- // Check for status retrieval errors
814
- if (this.didFail(response)) {
815
- const errorMsg = response.message || 'Unknown status retrieval error';
1006
+ async getTaskStatus(token, options) {
1007
+ try {
1008
+ const callOptions = { token };
1009
+ if (options?.timeout === false) {
1010
+ // Intentionally omit per-call timeout override.
1011
+ }
1012
+ else {
1013
+ callOptions.timeout = options?.timeout ?? 15000;
1014
+ }
1015
+ return await this.call('rrext_get_task_status', undefined, callOptions);
1016
+ }
1017
+ catch (err) {
1018
+ const errorMsg = err instanceof Error ? err.message : String(err);
816
1019
  this.debugMessage(`Pipeline status retrieval failed: ${errorMsg}`);
817
1020
  throw new Error(errorMsg);
818
1021
  }
819
- // Return status information
820
- return response.body || {};
1022
+ }
1023
+ /**
1024
+ * Resolve a running task's token from its project ID and source component.
1025
+ *
1026
+ * The token is required for operations like terminate and restart.
1027
+ * Returns undefined if no task is currently running for the given project/source.
1028
+ *
1029
+ * @param options.projectId - The project identifier.
1030
+ * @param options.source - The source component identifier.
1031
+ */
1032
+ async getTaskToken(options) {
1033
+ const body = await this.call('rrext_get_token', {
1034
+ projectId: options.projectId,
1035
+ source: options.source,
1036
+ });
1037
+ return body?.token;
1038
+ }
1039
+ /**
1040
+ * Returns the unresolved pipeline for a running task.
1041
+ *
1042
+ * The pipeline is returned exactly as stored — ${ROCKETRIDE_*} placeholders are
1043
+ * NOT substituted, so no secrets are included in the response.
1044
+ *
1045
+ * @param token - Task token returned by {@link getTaskToken}.
1046
+ * @returns The unresolved pipeline dict, or undefined if the task is not found.
1047
+ */
1048
+ async getTaskPipeline(token) {
1049
+ const body = await this.call('rrext_get_pipeline', undefined, { token });
1050
+ return body?.pipeline;
821
1051
  }
822
1052
  // ============================================================================
823
1053
  // DATA METHODS
@@ -912,7 +1142,7 @@ export class RocketRideClient extends DAPClient {
912
1142
  event: 'apaevt_status_upload',
913
1143
  body: body,
914
1144
  seq: 0,
915
- type: 'event'
1145
+ type: 'event',
916
1146
  };
917
1147
  this.onEvent(eventMessage);
918
1148
  };
@@ -1004,7 +1234,7 @@ export class RocketRideClient extends DAPClient {
1004
1234
  results[index] = finalResult;
1005
1235
  };
1006
1236
  // Create a promise for every file - let server handle queuing
1007
- const uploadPromises = files.map((fileData, index) => uploadFile(fileData, index).catch(err => {
1237
+ const uploadPromises = files.map((fileData, index) => uploadFile(fileData, index).catch((err) => {
1008
1238
  // Ensure errors don't kill the whole batch
1009
1239
  console.error(`Upload failed for ${fileData.file.name}:`, err);
1010
1240
  }));
@@ -1106,6 +1336,13 @@ export class RocketRideClient extends DAPClient {
1106
1336
  const seqNum = message.seq || 0;
1107
1337
  // Forward to debugging interface if available
1108
1338
  this._sendVSCodeEvent(eventType, eventBody);
1339
+ // Update cached ConnectResult when the server pushes a full account refresh
1340
+ if (eventType === 'apaext_account') {
1341
+ this._connectResult = eventBody;
1342
+ if (this._connectResult?.userToken) {
1343
+ this._apikey = this._connectResult.userToken;
1344
+ }
1345
+ }
1109
1346
  // Dispatch pipe-scoped SSE events to the registered DataPipe callback
1110
1347
  if (eventType === 'apaevt_sse') {
1111
1348
  const pipeId = eventBody?.pipe_id;
@@ -1142,7 +1379,8 @@ export class RocketRideClient extends DAPClient {
1142
1379
  async onConnectError(error) {
1143
1380
  if (this._callerOnConnectError) {
1144
1381
  try {
1145
- await this._callerOnConnectError(error instanceof Error ? error.message : String(error));
1382
+ const connectionError = error instanceof ConnectionException ? error : new ConnectionException({ message: String(error) });
1383
+ await this._callerOnConnectError(connectionError);
1146
1384
  }
1147
1385
  catch (e) {
1148
1386
  this.debugMessage(`Error in user onConnectError handler: ${e}`);
@@ -1151,564 +1389,697 @@ export class RocketRideClient extends DAPClient {
1151
1389
  await super.onConnectError(error);
1152
1390
  }
1153
1391
  /**
1154
- * Handle connected events from the RocketRide server.
1392
+ * Handle transport-level connected event.
1393
+ *
1394
+ * With the attach/login split, this fires when the WebSocket opens
1395
+ * (before auth). The ``_internalLogin`` method handles the auth
1396
+ * notification separately, so this is intentionally minimal.
1155
1397
  */
1156
1398
  async onConnected(connectionInfo) {
1157
- this._manualDisconnect = false;
1158
- this._didNotifyConnected = true;
1159
- this._clearReconnectTimeout();
1160
- this._currentReconnectDelay = 250;
1161
- this._retryStartTime = undefined;
1162
- // Call user-provided event handler if available
1163
- if (this._callerOnConnected) {
1164
- try {
1165
- await this._callerOnConnected(connectionInfo);
1166
- }
1167
- catch (error) {
1168
- // Log errors but don't let user code break the connection
1169
- this.debugMessage(`Error in user onConnected handler for ${connectionInfo}: ${error}`);
1170
- }
1171
- }
1172
1399
  await super.onConnected(connectionInfo);
1173
1400
  }
1174
1401
  /**
1175
- * Handle disconnected events from the RocketRide server.
1176
- * Only invokes the user's onDisconnected if onConnected had previously been called
1177
- * (so "disconnect without ever connecting" does not fire the user callback).
1402
+ * Handle transport disconnection.
1403
+ *
1404
+ * Clears transport and auth state, notifies the user callback,
1405
+ * then consults ``_desiredState`` to decide whether to reconnect.
1178
1406
  */
1179
1407
  async onDisconnected(reason, hasError) {
1180
- if (this._didNotifyConnected) {
1181
- this._didNotifyConnected = false;
1182
- if (this._callerOnDisconnected) {
1183
- try {
1184
- await this._callerOnDisconnected(reason, hasError);
1185
- }
1186
- catch (error) {
1187
- // Log errors but don't let user code break the connection
1188
- this.debugMessage(`Error in user onDisconnected handler for ${reason}: ${error}`);
1189
- }
1408
+ // Transport is gone — clear so next attach creates a fresh one
1409
+ this._transport = undefined;
1410
+ this._connectResult = undefined;
1411
+ this._authenticated = false;
1412
+ // Notify user callback
1413
+ if (this._callerOnDisconnected) {
1414
+ try {
1415
+ await this._callerOnDisconnected(reason, hasError);
1416
+ }
1417
+ catch (error) {
1418
+ this.debugMessage(`Error in user onDisconnected handler for ${reason}: ${error}`);
1190
1419
  }
1191
- // Chain to parent to clear pending requests
1192
- await super.onDisconnected(reason, hasError);
1193
1420
  }
1194
- // Schedule reconnection if persist is enabled and not a manual disconnect
1195
- if (this._persist && !this._manualDisconnect) {
1196
- this._scheduleReconnect();
1421
+ // Chain to parent to clear pending requests
1422
+ await super.onDisconnected(reason, hasError);
1423
+ // Reconnect engine: honour _desiredState
1424
+ if (this._desiredState === 'detached')
1425
+ return;
1426
+ if (!this._persist) {
1427
+ this._desiredState = 'detached';
1428
+ return;
1197
1429
  }
1430
+ if (this._reconnectTimer)
1431
+ return; // engine already active
1432
+ this._currentReconnectDelay = 250;
1433
+ this._scheduleReconnect();
1198
1434
  }
1199
1435
  /**
1200
1436
  * Subscribe to specific types of events from the server.
1437
+ * @deprecated Use {@link addMonitor} / {@link removeMonitor} instead.
1201
1438
  */
1202
1439
  async setEvents(token, eventTypes, pipeId) {
1203
- // Build event subscription request
1440
+ // Build event subscription args
1204
1441
  const args = { types: eventTypes };
1205
1442
  if (pipeId !== undefined)
1206
1443
  args.pipeId = pipeId;
1207
- const request = this.buildRequest('rrext_monitor', {
1208
- arguments: args,
1209
- token,
1210
- });
1211
- // Send to server
1212
- const response = await this.request(request);
1213
- // Check for errors
1214
- if (this.didFail(response)) {
1215
- const errorMsg = response.message || 'Event subscription failed';
1216
- throw new Error(errorMsg);
1444
+ try {
1445
+ await this.call('rrext_monitor', args, { token });
1446
+ }
1447
+ catch (err) {
1448
+ throw new Error(`Event subscription failed: ${err instanceof Error ? err.message : err}`);
1217
1449
  }
1218
1450
  }
1219
1451
  // ============================================================================
1220
- // PROJECT STORAGE MANAGEMENT
1452
+ // MONITOR SUBSCRIPTION MANAGEMENT
1221
1453
  // ============================================================================
1222
1454
  /**
1223
- * Save or update a project pipeline.
1455
+ * Add a monitor subscription. If the key already exists, the new types are
1456
+ * merged via reference counting and the merged set is sent to the server.
1224
1457
  *
1225
- * Stores a project pipeline configuration on the server. If the project
1226
- * already exists, it will be updated. Use expectedVersion to ensure
1227
- * you're updating the version you expect (prevents conflicts).
1228
- *
1229
- * @param options - Save project options
1230
- * @param options.projectId - Unique identifier for the project
1231
- * @param options.pipeline - Pipeline configuration object
1232
- * @param options.expectedVersion - Expected current version for atomic updates (optional)
1233
- * @returns Promise resolving to save result with success status, projectId, and new version
1234
- * @throws Error if save fails due to version mismatch, storage error, or invalid input
1235
- *
1236
- * @example
1237
- * ```typescript
1238
- * // Save a new project
1239
- * const result = await client.saveProject({
1240
- * projectId: 'proj-123',
1241
- * pipeline: {
1242
- * name: 'Data Processor',
1243
- * source: 'source_1',
1244
- * components: [...]
1245
- * }
1246
- * });
1247
- * console.log(`Saved version: ${result.version}`);
1248
- *
1249
- * // Update existing project with version check
1250
- * const existing = await client.getProject({ projectId: 'proj-123' });
1251
- * existing.name = 'Updated Name';
1252
- * const updated = await client.saveProject({
1253
- * projectId: 'proj-123',
1254
- * pipeline: existing,
1255
- * expectedVersion: existing.version
1256
- * });
1257
- * ```
1458
+ * @param key - Monitor key: `{ token }` for a running task, or `{ projectId, source }` for a project.
1459
+ * @param types - Event types to subscribe to (e.g. `['summary', 'flow']`).
1258
1460
  */
1259
- async saveProject(options) {
1260
- const { projectId, pipeline, expectedVersion } = options;
1261
- // Validate inputs
1262
- if (!projectId) {
1263
- throw new Error('projectId is required');
1264
- }
1265
- if (!pipeline || typeof pipeline !== 'object') {
1266
- throw new Error('pipeline must be a non-empty object');
1461
+ async addMonitor(key, types) {
1462
+ const keyStr = this._monitorKeyToString(key);
1463
+ let refCounts = this._monitorKeys.get(keyStr);
1464
+ if (!refCounts) {
1465
+ refCounts = new Map();
1466
+ this._monitorKeys.set(keyStr, refCounts);
1467
+ }
1468
+ // Increment reference counts
1469
+ for (const t of types) {
1470
+ refCounts.set(t, (refCounts.get(t) ?? 0) + 1);
1471
+ }
1472
+ // Send merged types to server — rollback on failure
1473
+ try {
1474
+ await this._syncMonitor(key, refCounts);
1267
1475
  }
1268
- // Build request arguments
1269
- const args = {
1270
- subcommand: 'save_project',
1271
- projectId,
1272
- pipeline,
1273
- };
1274
- // Add optional version for atomic updates
1275
- if (expectedVersion !== undefined) {
1276
- args.expectedVersion = expectedVersion;
1277
- }
1278
- // Send request to server
1279
- const request = this.buildRequest('rrext_store', { arguments: args });
1280
- const response = await this.request(request);
1281
- // Check for errors
1282
- if (this.didFail(response)) {
1283
- const errorMsg = response.message || 'Unknown error saving project';
1284
- this.debugMessage(`Project save failed: ${errorMsg}`);
1285
- throw new Error(errorMsg);
1476
+ catch (error) {
1477
+ for (const t of types) {
1478
+ const current = refCounts.get(t) ?? 0;
1479
+ if (current <= 1) {
1480
+ refCounts.delete(t);
1481
+ }
1482
+ else {
1483
+ refCounts.set(t, current - 1);
1484
+ }
1485
+ }
1486
+ if (refCounts.size === 0) {
1487
+ this._monitorKeys.delete(keyStr);
1488
+ }
1489
+ throw error;
1286
1490
  }
1287
- // Extract and return response
1288
- this.debugMessage(`Project saved successfully: ${projectId}, version: ${response.body?.version}`);
1289
- return response.body;
1290
1491
  }
1291
1492
  /**
1292
- * Retrieve a project by its ID.
1293
- *
1294
- * Fetches the complete pipeline configuration and current version for
1295
- * the specified project. Use this before updating to get the current
1296
- * version for atomic updates.
1297
- *
1298
- * @param options - Get project options
1299
- * @param options.projectId - Unique identifier of the project to retrieve
1300
- * @returns Promise resolving to project data with success status, pipeline, and version
1301
- * @throws Error if project doesn't exist or retrieval fails
1302
- *
1303
- * @example
1304
- * ```typescript
1305
- * // Get a project
1306
- * try {
1307
- * const project = await client.getProject({ projectId: 'proj-123' });
1308
- * console.log(`Project: ${project.name}`);
1309
- * console.log(`Version: ${project.version}`);
1310
- * } catch (error) {
1311
- * if (error.message.includes('NOT_FOUND')) {
1312
- * console.log("Project doesn't exist");
1313
- * }
1314
- * }
1493
+ * Remove a monitor subscription. Decrements reference counts for the given
1494
+ * types. Only unsubscribes a type from the server when its count reaches 0.
1315
1495
  *
1316
- * // Before updating - get current version
1317
- * const project = await client.getProject({ projectId: 'proj-123' });
1318
- * project.name = 'Updated';
1319
- * await client.saveProject({
1320
- * projectId: 'proj-123',
1321
- * pipeline: project,
1322
- * expectedVersion: project.version
1323
- * });
1324
- * ```
1496
+ * @param key - Monitor key (must match the key used in addMonitor).
1497
+ * @param types - Event types to unsubscribe from.
1325
1498
  */
1326
- async getProject(options) {
1327
- const { projectId } = options;
1328
- // Validate inputs
1329
- if (!projectId) {
1330
- throw new Error('projectId is required');
1499
+ async removeMonitor(key, types) {
1500
+ const keyStr = this._monitorKeyToString(key);
1501
+ const refCounts = this._monitorKeys.get(keyStr);
1502
+ if (!refCounts)
1503
+ return;
1504
+ // Decrement reference counts
1505
+ for (const t of types) {
1506
+ const current = refCounts.get(t) ?? 0;
1507
+ if (current <= 1) {
1508
+ refCounts.delete(t);
1509
+ }
1510
+ else {
1511
+ refCounts.set(t, current - 1);
1512
+ }
1331
1513
  }
1332
- // Build request
1333
- const args = {
1334
- subcommand: 'get_project',
1335
- projectId,
1336
- };
1337
- // Send request to server
1338
- const request = this.buildRequest('rrext_store', { arguments: args });
1339
- const response = await this.request(request);
1340
- // Check for errors
1341
- if (this.didFail(response)) {
1342
- const errorMsg = response.message || 'Unknown error retrieving project';
1343
- this.debugMessage(`Project retrieval failed: ${errorMsg}`);
1344
- throw new Error(errorMsg);
1514
+ // Send merged types (or unsubscribe if empty)
1515
+ await this._syncMonitor(key, refCounts);
1516
+ // Clean up empty keys
1517
+ if (refCounts.size === 0) {
1518
+ this._monitorKeys.delete(keyStr);
1345
1519
  }
1346
- // Extract and return response
1347
- this.debugMessage(`Project retrieved successfully: ${projectId}`);
1348
- return response.body;
1349
1520
  }
1350
1521
  /**
1351
- * Delete a project by its ID.
1352
- *
1353
- * Permanently removes a project from storage. Optionally verify the
1354
- * version before deletion to ensure you're deleting the version you
1355
- * expect (prevents accidental deletion of modified projects).
1356
- *
1357
- * @param options - Delete project options
1358
- * @param options.projectId - Unique identifier of the project to delete
1359
- * @param options.expectedVersion - Expected current version for atomic deletion (required)
1360
- * @returns Promise resolving to deletion result with success status and message
1361
- * @throws Error if project doesn't exist, version mismatch, or deletion fails
1362
- *
1363
- * @example
1364
- * ```typescript
1365
- * // Safe deletion with version check
1366
- * const project = await client.getProject({ projectId: 'proj-123' });
1367
- * try {
1368
- * const result = await client.deleteProject({
1369
- * projectId: 'proj-123',
1370
- * expectedVersion: project.version
1371
- * });
1372
- * console.log('Project deleted successfully');
1373
- * } catch (error) {
1374
- * if (error.message.includes('CONFLICT')) {
1375
- * console.log('Project was modified, deletion cancelled');
1376
- * }
1377
- * }
1378
- * ```
1522
+ * Send the merged type list for a monitor key to the server.
1379
1523
  */
1380
- async deleteProject(options) {
1381
- const { projectId, expectedVersion } = options;
1382
- // Validate inputs
1383
- if (!projectId) {
1384
- throw new Error('projectId is required');
1524
+ async _syncMonitor(key, refCounts) {
1525
+ if (!this.isConnected())
1526
+ return;
1527
+ const mergedTypes = Array.from(refCounts.keys());
1528
+ if ('token' in key) {
1529
+ await this.call('rrext_monitor', { types: mergedTypes }, { token: key.token });
1385
1530
  }
1386
- // Build request
1387
- const args = {
1388
- subcommand: 'delete_project',
1389
- projectId,
1390
- };
1391
- // Add optional version for atomic deletion
1392
- if (expectedVersion !== undefined) {
1393
- args.expectedVersion = expectedVersion;
1394
- }
1395
- // Send request to server
1396
- const request = this.buildRequest('rrext_store', { arguments: args });
1397
- const response = await this.request(request);
1398
- // Check for errors
1399
- if (this.didFail(response)) {
1400
- const errorMsg = response.message || 'Unknown error deleting project';
1401
- this.debugMessage(`Project deletion failed: ${errorMsg}`);
1402
- throw new Error(errorMsg);
1531
+ else {
1532
+ const args = {
1533
+ projectId: key.projectId,
1534
+ source: key.source,
1535
+ types: mergedTypes,
1536
+ };
1537
+ if (key.pipeId !== undefined) {
1538
+ args.pipeId = key.pipeId;
1539
+ }
1540
+ await this.call('rrext_monitor', args);
1403
1541
  }
1404
- // Extract and return response
1405
- this.debugMessage(`Project deleted successfully: ${projectId}`);
1406
- return response.body;
1407
1542
  }
1408
1543
  /**
1409
- * List all projects for the current user.
1410
- *
1411
- * Retrieves a summary of all projects stored for the authenticated user.
1412
- * Each project summary includes the ID, name, list of data sources, and total component count.
1413
- *
1414
- * @returns Promise resolving to list result with success status, projects array, and count
1415
- * @throws Error if retrieval fails
1416
- *
1417
- * @example
1418
- * ```typescript
1419
- * // List all projects
1420
- * const result = await client.getAllProjects();
1421
- * console.log(`Found ${result.count} projects:`);
1422
- * for (const project of result.projects) {
1423
- * console.log(`- ${project.id}: ${project.name} (${project.totalComponents} components)`);
1424
- * for (const source of project.sources) {
1425
- * console.log(` * ${source.name} (${source.provider})`);
1426
- * }
1427
- * }
1428
- *
1429
- * // Find specific project
1430
- * const result = await client.getAllProjects();
1431
- * const myProject = result.projects.find(p => p.id === 'proj-123');
1432
- * ```
1544
+ * Replay all active monitor subscriptions to the server.
1545
+ * Called automatically after reconnection.
1433
1546
  */
1434
- async getAllProjects() {
1435
- // Build request
1436
- const args = {
1437
- subcommand: 'get_all_projects',
1438
- };
1439
- // Send request to server
1440
- const request = this.buildRequest('rrext_store', { arguments: args });
1441
- const response = await this.request(request);
1442
- // Check for errors
1443
- if (this.didFail(response)) {
1444
- const errorMsg = response.message || 'Unknown error listing projects';
1445
- this.debugMessage(`Project list retrieval failed: ${errorMsg}`);
1446
- throw new Error(errorMsg);
1547
+ async _resubscribeAllMonitors() {
1548
+ for (const [keyStr, refCounts] of this._monitorKeys) {
1549
+ if (refCounts.size === 0)
1550
+ continue;
1551
+ const key = this._monitorStringToKey(keyStr);
1552
+ if (key) {
1553
+ try {
1554
+ await this._syncMonitor(key, refCounts);
1555
+ }
1556
+ catch (error) {
1557
+ this.debugMessage(`Failed to resubscribe monitor ${keyStr}: ${error}`);
1558
+ }
1559
+ }
1560
+ }
1561
+ }
1562
+ /**
1563
+ * Convert a MonitorKey to a stable string for map lookup.
1564
+ */
1565
+ _monitorKeyToString(key) {
1566
+ if ('token' in key) {
1567
+ return `t:${key.token}`;
1568
+ }
1569
+ let s = `p:${key.projectId}.${key.source}`;
1570
+ if (key.pipeId !== undefined) {
1571
+ s += `.${key.pipeId}`;
1447
1572
  }
1448
- // Extract and return response
1449
- const projectCount = response.body?.count || 0;
1450
- this.debugMessage(`Projects retrieved successfully: ${projectCount} projects`);
1451
- return response.body;
1573
+ return s;
1574
+ }
1575
+ /**
1576
+ * Reverse a key-string back to a MonitorKey (for resubscribeAll).
1577
+ */
1578
+ _monitorStringToKey(keyStr) {
1579
+ if (keyStr.startsWith('t:')) {
1580
+ return { token: keyStr.slice(2) };
1581
+ }
1582
+ if (keyStr.startsWith('p:')) {
1583
+ const rest = keyStr.slice(2);
1584
+ const dotIdx = rest.indexOf('.');
1585
+ if (dotIdx === -1)
1586
+ return null;
1587
+ const projectId = rest.slice(0, dotIdx);
1588
+ const remaining = rest.slice(dotIdx + 1);
1589
+ const parts = remaining.split('.');
1590
+ if (parts.length === 2 && !isNaN(Number(parts[1]))) {
1591
+ return { projectId, source: parts[0], pipeId: Number(parts[1]) };
1592
+ }
1593
+ return { projectId, source: remaining };
1594
+ }
1595
+ return null;
1452
1596
  }
1453
1597
  // ============================================================================
1454
- // TEMPLATE STORAGE MANAGEMENT (System-wide templates)
1598
+ // TEMPLATE STORAGE MANAGEMENT (convenience wrappers using fsReadJson/fsWriteJson)
1455
1599
  // ============================================================================
1456
1600
  /**
1457
- * Save or update a template pipeline.
1601
+ * Persist a pipeline configuration as a named template in the account store.
1458
1602
  *
1459
- * Stores a template pipeline configuration on the server. Templates are system-wide
1460
- * and accessible to all users. If the template already exists, it will be updated.
1461
- * Use expectedVersion to ensure you're updating the version you expect.
1603
+ * Templates are stored as JSON files under `.templates/<templateId>.json`.
1604
+ * Saving a template with an existing ID overwrites the previous version.
1462
1605
  *
1463
- * @param options - Save template options
1464
- * @param options.templateId - Unique identifier for the template
1465
- * @param options.pipeline - Pipeline configuration object
1466
- * @param options.expectedVersion - Expected current version for atomic updates (optional)
1467
- * @returns Promise resolving to save result with success status, templateId, and new version
1468
- * @throws Error if save fails due to version mismatch, storage error, or invalid input
1606
+ * @param options.templateId - Unique identifier for the template (no path separators)
1607
+ * @param options.pipeline - Pipeline configuration object to save
1608
+ * @throws Error if templateId is invalid or pipeline is not a non-empty object
1469
1609
  */
1470
1610
  async saveTemplate(options) {
1471
- const { templateId, pipeline, expectedVersion } = options;
1472
- // Validate inputs
1473
- if (!templateId) {
1474
- throw new Error('templateId is required');
1475
- }
1476
- if (!pipeline || typeof pipeline !== 'object') {
1611
+ // Validate the template ID to prevent path traversal or invalid filenames
1612
+ this.validateId(options.templateId, 'templateId');
1613
+ // Ensure the pipeline payload is a non-null object before writing
1614
+ if (!options.pipeline || typeof options.pipeline !== 'object')
1477
1615
  throw new Error('pipeline must be a non-empty object');
1478
- }
1479
- // Build request arguments
1480
- const args = {
1481
- subcommand: 'save_template',
1482
- templateId,
1483
- pipeline,
1484
- };
1485
- // Add optional version for atomic updates
1486
- if (expectedVersion !== undefined) {
1487
- args.expectedVersion = expectedVersion;
1488
- }
1489
- // Send request to server
1490
- const request = this.buildRequest('rrext_store', { arguments: args });
1491
- const response = await this.request(request);
1492
- // Check for errors
1493
- if (this.didFail(response)) {
1494
- const errorMsg = response.message || 'Unknown error saving template';
1495
- this.debugMessage(`Template save failed: ${errorMsg}`);
1496
- throw new Error(errorMsg);
1497
- }
1498
- // Extract and return response
1499
- this.debugMessage(`Template saved successfully: ${templateId}, version: ${response.body?.version}`);
1500
- return response.body;
1616
+ // Serialise and write the pipeline under the .templates virtual directory
1617
+ await this.fsWriteJson(`.templates/${options.templateId}.json`, options.pipeline);
1501
1618
  }
1502
1619
  /**
1503
- * Retrieve a template by its ID.
1620
+ * Retrieve a previously saved pipeline template from the account store.
1621
+ *
1622
+ * @param options.templateId - Unique identifier of the template to retrieve
1623
+ * @returns The pipeline configuration object that was saved
1624
+ * @throws Error if the template does not exist or templateId is invalid
1504
1625
  */
1505
1626
  async getTemplate(options) {
1506
- const { templateId } = options;
1507
- // Validate inputs
1508
- if (!templateId) {
1509
- throw new Error('templateId is required');
1510
- }
1511
- // Build request
1512
- const args = {
1513
- subcommand: 'get_template',
1514
- templateId,
1515
- };
1516
- // Send request to server
1517
- const request = this.buildRequest('rrext_store', { arguments: args });
1518
- const response = await this.request(request);
1519
- // Check for errors
1520
- if (this.didFail(response)) {
1521
- const errorMsg = response.message || 'Unknown error retrieving template';
1522
- this.debugMessage(`Template retrieval failed: ${errorMsg}`);
1523
- throw new Error(errorMsg);
1524
- }
1525
- // Extract and return response
1526
- this.debugMessage(`Template retrieved successfully: ${templateId}`);
1527
- return response.body;
1627
+ // Validate the ID before constructing the storage path
1628
+ this.validateId(options.templateId, 'templateId');
1629
+ // Read and parse the JSON file from the .templates virtual directory
1630
+ return this.fsReadJson(`.templates/${options.templateId}.json`);
1528
1631
  }
1529
1632
  /**
1530
- * Delete a template by its ID.
1633
+ * Delete a pipeline template from the account store.
1634
+ *
1635
+ * @param options.templateId - Unique identifier of the template to delete
1636
+ * @throws Error if the template does not exist or templateId is invalid
1531
1637
  */
1532
1638
  async deleteTemplate(options) {
1533
- const { templateId, expectedVersion } = options;
1534
- // Validate inputs
1535
- if (!templateId) {
1536
- throw new Error('templateId is required');
1537
- }
1538
- // Build request
1539
- const args = {
1540
- subcommand: 'delete_template',
1541
- templateId,
1542
- };
1543
- // Add optional version for atomic deletion
1544
- if (expectedVersion !== undefined) {
1545
- args.expectedVersion = expectedVersion;
1546
- }
1547
- // Send request to server
1548
- const request = this.buildRequest('rrext_store', { arguments: args });
1549
- const response = await this.request(request);
1550
- // Check for errors
1551
- if (this.didFail(response)) {
1552
- const errorMsg = response.message || 'Unknown error deleting template';
1553
- this.debugMessage(`Template deletion failed: ${errorMsg}`);
1554
- throw new Error(errorMsg);
1555
- }
1556
- // Extract and return response
1557
- this.debugMessage(`Template deleted successfully: ${templateId}`);
1558
- return response.body;
1639
+ // Validate the ID before constructing the storage path
1640
+ this.validateId(options.templateId, 'templateId');
1641
+ // Delete the JSON file from the .templates virtual directory
1642
+ await this.fsDelete(`.templates/${options.templateId}.json`);
1559
1643
  }
1560
1644
  /**
1561
- * List all templates.
1645
+ * List all pipeline templates stored in the account store.
1646
+ *
1647
+ * Reads the `.templates` directory, parses each `.json` file, and extracts
1648
+ * a summary for each template. Files that cannot be parsed are silently
1649
+ * skipped so a single corrupt template does not break the entire listing.
1650
+ *
1651
+ * @returns Array of template summaries sorted in directory-listing order.
1652
+ * Each entry contains the template ID, display name, source components,
1653
+ * and total component count.
1562
1654
  */
1563
1655
  async getAllTemplates() {
1564
- // Build request
1565
- const args = {
1566
- subcommand: 'get_all_templates',
1567
- };
1568
- // Send request to server
1569
- const request = this.buildRequest('rrext_store', { arguments: args });
1570
- const response = await this.request(request);
1571
- // Check for errors
1572
- if (this.didFail(response)) {
1573
- const errorMsg = response.message || 'Unknown error listing templates';
1574
- this.debugMessage(`Template list retrieval failed: ${errorMsg}`);
1575
- throw new Error(errorMsg);
1656
+ // Fetch the list of entries under the .templates virtual directory
1657
+ const dir = await this.fsListDir('.templates');
1658
+ const templates = [];
1659
+ for (const entry of dir.entries) {
1660
+ // Skip directories and any non-JSON files (e.g. temp files)
1661
+ if (entry.type !== 'file' || !entry.name.endsWith('.json'))
1662
+ continue;
1663
+ try {
1664
+ // Derive the template ID by stripping the .json extension
1665
+ const id = entry.name.slice(0, -5);
1666
+ // Load and parse the template JSON
1667
+ const pipeline = await this.fsReadJson(`.templates/${entry.name}`);
1668
+ // Extract Source-mode components to populate the sources summary list
1669
+ const sources = (pipeline.components || []).filter((c) => c.config?.mode === 'Source').map((c) => ({ id: c.id, provider: c.provider, name: c.config?.name || c.id }));
1670
+ // Push the summary (use template ID as display name)
1671
+ templates.push({ id, name: id, sources, totalComponents: (pipeline.components || []).length });
1672
+ }
1673
+ catch (err) {
1674
+ // Log the failure but continue so one bad file doesn't block others
1675
+ console.debug(`[RocketRideClient] Failed to read .templates/${entry.name}:`, err);
1676
+ continue;
1677
+ }
1576
1678
  }
1577
- // Extract and return response
1578
- const templateCount = response.body?.count || 0;
1579
- this.debugMessage(`Templates retrieved successfully: ${templateCount} templates`);
1580
- return response.body;
1679
+ return templates;
1581
1680
  }
1582
1681
  // ============================================================================
1583
- // LOG STORAGE MANAGEMENT (Per-project log files for historical tracking)
1682
+ // LOG STORAGE MANAGEMENT (convenience wrappers using fsReadJson/fsWriteJson)
1584
1683
  // ============================================================================
1585
1684
  /**
1586
- * Save a log file for a source run.
1685
+ * Persist a pipeline execution log to the account store.
1686
+ *
1687
+ * Logs are stored under `.logs/<projectId>/<source>-<startTime>.log`.
1688
+ * The filename is derived from `contents.body.startTime` so logs are
1689
+ * naturally sortable by execution start time.
1690
+ *
1691
+ * @param options.projectId - Project identifier that owns this log
1692
+ * @param options.source - Source component identifier the log is associated with
1693
+ * @param options.contents - Log payload; must contain `body.startTime`
1694
+ * @returns The generated filename (e.g. `"ingest-1714000000000.log"`)
1695
+ * @throws Error if any ID is invalid, contents is not an object, or startTime is missing
1587
1696
  */
1588
1697
  async saveLog(options) {
1589
- const { projectId, source, contents } = options;
1590
- // Validate inputs
1591
- if (!projectId) {
1592
- throw new Error('projectId is required');
1593
- }
1594
- if (!source) {
1595
- throw new Error('source is required');
1596
- }
1597
- if (!contents || typeof contents !== 'object') {
1698
+ // Validate identifiers to prevent path traversal
1699
+ this.validateId(options.projectId, 'projectId');
1700
+ this.validateId(options.source, 'source');
1701
+ // Ensure the contents payload is a non-null object
1702
+ if (!options.contents || typeof options.contents !== 'object')
1598
1703
  throw new Error('contents must be a non-empty object');
1599
- }
1600
- // Build request arguments
1601
- const args = {
1602
- subcommand: 'save_log',
1603
- projectId,
1604
- source,
1605
- contents,
1606
- };
1607
- // Send request to server
1608
- const request = this.buildRequest('rrext_store', { arguments: args });
1609
- const response = await this.request(request);
1610
- // Check for errors
1611
- if (this.didFail(response)) {
1612
- const errorMsg = response.message || 'Unknown error saving log';
1613
- this.debugMessage(`Log save failed: ${errorMsg}`);
1614
- throw new Error(errorMsg);
1615
- }
1616
- // Extract and return response
1617
- this.debugMessage(`Log saved successfully: ${response.body?.filename}`);
1618
- return response.body;
1704
+ // startTime is required; it forms part of the filename for chronological ordering.
1705
+ // Reject anything other than a non-empty number or numeric-looking string to
1706
+ // prevent path-separator chars from slipping into the generated filename.
1707
+ const startTime = options.contents?.body?.startTime;
1708
+ if (startTime === undefined || startTime === null)
1709
+ throw new Error('contents must contain body.startTime');
1710
+ if (typeof startTime !== 'number' && typeof startTime !== 'string') {
1711
+ throw new Error('contents.body.startTime must be a number or string');
1712
+ }
1713
+ const startTimeStr = String(startTime);
1714
+ if (!startTimeStr || /[\\/]/.test(startTimeStr)) {
1715
+ throw new Error('contents.body.startTime must not be empty or contain path separators');
1716
+ }
1717
+ // Construct a deterministic filename from source and start time
1718
+ const filename = `${options.source}-${startTimeStr}.log`;
1719
+ // Write the log JSON to the per-project logs directory
1720
+ await this.fsWriteJson(`.logs/${options.projectId}/${filename}`, options.contents);
1721
+ return filename;
1619
1722
  }
1620
1723
  /**
1621
- * Get a log file by source name and start time.
1724
+ * Retrieve a previously saved pipeline execution log from the account store.
1725
+ *
1726
+ * @param options.projectId - Project identifier that owns the log
1727
+ * @param options.name - Filename of the log (as returned by saveLog)
1728
+ * @returns The log payload that was saved
1729
+ * @throws Error if the log does not exist or projectId is invalid
1622
1730
  */
1623
1731
  async getLog(options) {
1624
- const { projectId, source, startTime } = options;
1625
- // Validate inputs
1626
- if (!projectId) {
1627
- throw new Error('projectId is required');
1628
- }
1629
- if (!source) {
1630
- throw new Error('source is required');
1631
- }
1632
- if (startTime === undefined || startTime === null) {
1633
- throw new Error('startTime is required');
1634
- }
1635
- // Build request
1636
- const args = {
1637
- subcommand: 'get_log',
1638
- projectId,
1639
- source,
1640
- startTime,
1641
- };
1642
- // Send request to server
1643
- const request = this.buildRequest('rrext_store', { arguments: args });
1644
- const response = await this.request(request);
1645
- // Check for errors
1646
- if (this.didFail(response)) {
1647
- const errorMsg = response.message || 'Unknown error retrieving log';
1648
- this.debugMessage(`Log retrieval failed: ${errorMsg}`);
1649
- throw new Error(errorMsg);
1650
- }
1651
- // Extract and return response
1652
- this.debugMessage(`Log retrieved successfully: ${projectId}/${source}`);
1653
- return response.body;
1732
+ // Validate the project ID before constructing the storage path
1733
+ this.validateId(options.projectId, 'projectId');
1734
+ if (!options.name)
1735
+ throw new Error('name is required');
1736
+ // Read and parse the log JSON from the per-project logs directory
1737
+ return this.fsReadJson(`.logs/${options.projectId}/${options.name}`);
1738
+ }
1739
+ /**
1740
+ * Delete a pipeline execution log from the account store.
1741
+ *
1742
+ * @param options.projectId - Project identifier that owns the log
1743
+ * @param options.name - Filename of the log to delete
1744
+ * @throws Error if the log does not exist or projectId is invalid
1745
+ */
1746
+ async deleteLog(options) {
1747
+ // Validate the project ID before constructing the storage path
1748
+ this.validateId(options.projectId, 'projectId');
1749
+ if (!options.name)
1750
+ throw new Error('name is required');
1751
+ // Delete the log file from the per-project logs directory
1752
+ await this.fsDelete(`.logs/${options.projectId}/${options.name}`);
1654
1753
  }
1655
1754
  /**
1656
- * List log files for a project.
1755
+ * List pipeline execution logs stored for a project, optionally filtered by source.
1756
+ *
1757
+ * Results are sorted ascending by `modified` timestamp so the oldest log
1758
+ * appears first. The caller can page through or slice the array as needed.
1759
+ *
1760
+ * @param options.projectId - Project identifier whose logs to list
1761
+ * @param options.source - Optional source component filter; when set, only logs
1762
+ * whose filename starts with `<source>-` are returned
1763
+ * @returns Array of log name and optional modified timestamp, sorted oldest-first
1764
+ * @throws Error if projectId (or source when provided) is invalid
1657
1765
  */
1658
1766
  async listLogs(options) {
1659
- const { projectId, source, page } = options;
1660
- // Validate inputs
1661
- if (!projectId) {
1662
- throw new Error('projectId is required');
1663
- }
1664
- // Build request
1665
- const args = {
1666
- subcommand: 'list_logs',
1667
- projectId,
1668
- };
1669
- // Add optional parameters
1670
- if (source !== undefined) {
1671
- args.source = source;
1767
+ // Validate identifiers before constructing the storage path
1768
+ this.validateId(options.projectId, 'projectId');
1769
+ if (options.source)
1770
+ this.validateId(options.source, 'source');
1771
+ // List all entries in the per-project logs directory
1772
+ const dir = await this.fsListDir(`.logs/${options.projectId}`);
1773
+ // Keep only .log files and map to the public shape (name + modified)
1774
+ let logs = dir.entries.filter((e) => e.type === 'file' && e.name.endsWith('.log')).map((e) => ({ name: e.name, modified: e.modified }));
1775
+ // Apply optional source prefix filter when a source was specified
1776
+ if (options.source) {
1777
+ logs = logs.filter((l) => l.name.startsWith(`${options.source}-`));
1778
+ }
1779
+ // Sort ascending by modified timestamp; treat missing timestamps as epoch 0
1780
+ logs.sort((a, b) => (a.modified || 0) - (b.modified || 0));
1781
+ return logs;
1782
+ }
1783
+ // ============================================================================
1784
+ // HANDLE-BASED FILE STORE OPERATIONS
1785
+ // ============================================================================
1786
+ /**
1787
+ * Open a file handle for reading or writing.
1788
+ *
1789
+ * @param path - Relative path within the account store
1790
+ * @param mode - 'r' for read, 'w' for write (default: 'r')
1791
+ * @param offset - Initial byte offset (read mode only)
1792
+ * @returns Object with 'handle' (string). Read mode also includes 'size' (number).
1793
+ */
1794
+ async fsOpen(path, mode = 'r') {
1795
+ this.validateStorePath(path);
1796
+ return this.call('rrext_store', { subcommand: 'fs_open', path, mode });
1797
+ }
1798
+ /**
1799
+ * Read data from an open read handle.
1800
+ *
1801
+ * @param handle - Handle ID returned by fsOpen
1802
+ * @param offset - Byte offset to read from
1803
+ * @param length - Max bytes to read (default 4 MB). Empty Uint8Array indicates EOF.
1804
+ * @returns The bytes read
1805
+ */
1806
+ async fsRead(handle, offset = 0, length = 4194304) {
1807
+ // Bypass call() which unwraps response.body, losing response.arguments
1808
+ // where the server places the binary data payload.
1809
+ const message = this.buildRequest('rrext_store', {
1810
+ arguments: { subcommand: 'fs_read', handle, offset, length },
1811
+ });
1812
+ this._onTrace?.(TraceType.Request, message);
1813
+ const response = await this.request(message);
1814
+ if (response.success === false) {
1815
+ this._onTrace?.(TraceType.Error, response);
1816
+ throw new Error(response.message ?? 'fs_read failed');
1817
+ }
1818
+ this._onTrace?.(TraceType.Success, response);
1819
+ return response.arguments?.data || new Uint8Array(0);
1820
+ }
1821
+ /**
1822
+ * Write data to an open write handle.
1823
+ *
1824
+ * @param handle - Handle ID returned by fsOpen
1825
+ * @param data - Raw bytes to write
1826
+ * @returns Number of bytes written
1827
+ */
1828
+ async fsWrite(handle, data) {
1829
+ const body = await this.call('rrext_store', { subcommand: 'fs_write', handle, data });
1830
+ return body?.bytesWritten ?? 0;
1831
+ }
1832
+ /**
1833
+ * Close a file handle.
1834
+ *
1835
+ * @param handle - Handle ID returned by fsOpen
1836
+ * @param mode - 'r' or 'w' (must match the mode used in fsOpen)
1837
+ */
1838
+ async fsClose(handle, mode) {
1839
+ await this.call('rrext_store', { subcommand: 'fs_close', handle, mode });
1840
+ }
1841
+ /**
1842
+ * Delete a file.
1843
+ *
1844
+ * @param path - Relative path within the account store
1845
+ * @throws Error if file does not exist or delete fails
1846
+ */
1847
+ async fsDelete(path) {
1848
+ this.validateStorePath(path);
1849
+ await this.call('rrext_store', { subcommand: 'fs_delete', path });
1850
+ }
1851
+ /**
1852
+ * List immediate children of a directory.
1853
+ *
1854
+ * @param path - Relative directory path (default: account root)
1855
+ * @returns Directory entries with name and type (file or dir)
1856
+ */
1857
+ async fsListDir(path = '') {
1858
+ if (path)
1859
+ this.validateStorePath(path);
1860
+ return this.call('rrext_store', { subcommand: 'fs_list_dir', path });
1861
+ }
1862
+ /**
1863
+ * Create a directory.
1864
+ *
1865
+ * @param path - Relative directory path
1866
+ */
1867
+ async fsMkdir(path) {
1868
+ this.validateStorePath(path);
1869
+ await this.call('rrext_store', { subcommand: 'fs_mkdir', path });
1870
+ }
1871
+ /**
1872
+ * Remove a directory.
1873
+ *
1874
+ * @param path - Relative directory path
1875
+ * @param recursive - If true, delete contents recursively (default: false)
1876
+ * @throws Error if directory is not empty (when recursive is false) or delete fails
1877
+ */
1878
+ async fsRmdir(path, recursive = false) {
1879
+ this.validateStorePath(path);
1880
+ await this.call('rrext_store', { subcommand: 'fs_rmdir', path, recursive });
1881
+ }
1882
+ /**
1883
+ * Get file or directory metadata.
1884
+ *
1885
+ * @param path - Relative path within the account store
1886
+ * @returns Metadata including existence, type, size (bytes), and modified epoch timestamp (for files)
1887
+ */
1888
+ async fsStat(path) {
1889
+ this.validateStorePath(path);
1890
+ return this.call('rrext_store', { subcommand: 'fs_stat', path });
1891
+ }
1892
+ /**
1893
+ * Rename a file or directory.
1894
+ *
1895
+ * On object stores this is implemented as copy + delete. For directories,
1896
+ * all contents are moved recursively.
1897
+ *
1898
+ * @param oldPath - Current relative path within the account store
1899
+ * @param newPath - New relative path within the account store
1900
+ * @throws Error if oldPath does not exist or rename fails
1901
+ */
1902
+ async fsRename(oldPath, newPath) {
1903
+ this.validateStorePath(oldPath);
1904
+ this.validateStorePath(newPath);
1905
+ await this.call('rrext_store', { subcommand: 'fs_rename', old_path: oldPath, new_path: newPath });
1906
+ }
1907
+ // ============================================================================
1908
+ // CONVENIENCE WRAPPERS (text/JSON over binary, handle open/close internally)
1909
+ // ============================================================================
1910
+ /** Read a file as a UTF-8 string. */
1911
+ async fsReadString(path) {
1912
+ const { handle } = await this.fsOpen(path, 'r');
1913
+ try {
1914
+ const chunks = [];
1915
+ let offset = 0;
1916
+ while (true) {
1917
+ const chunk = await this.fsRead(handle, offset);
1918
+ if (chunk.length === 0)
1919
+ break;
1920
+ chunks.push(chunk);
1921
+ offset += chunk.length;
1922
+ }
1923
+ const total = new Uint8Array(offset);
1924
+ let pos = 0;
1925
+ for (const chunk of chunks) {
1926
+ total.set(chunk, pos);
1927
+ pos += chunk.length;
1928
+ }
1929
+ return new TextDecoder().decode(total);
1672
1930
  }
1673
- if (page !== undefined) {
1674
- args.page = page;
1931
+ finally {
1932
+ await this.fsClose(handle, 'r');
1675
1933
  }
1676
- // Send request to server
1677
- const request = this.buildRequest('rrext_store', { arguments: args });
1678
- const response = await this.request(request);
1679
- // Check for errors
1680
- if (this.didFail(response)) {
1681
- const errorMsg = response.message || 'Unknown error listing logs';
1682
- this.debugMessage(`Log list retrieval failed: ${errorMsg}`);
1683
- throw new Error(errorMsg);
1934
+ }
1935
+ /** Write a UTF-8 string to a file. */
1936
+ async fsWriteString(path, text) {
1937
+ const { handle } = await this.fsOpen(path, 'w');
1938
+ try {
1939
+ await this.fsWrite(handle, new TextEncoder().encode(text));
1940
+ await this.fsClose(handle, 'w');
1941
+ }
1942
+ catch (err) {
1943
+ try {
1944
+ await this.fsClose(handle, 'w');
1945
+ }
1946
+ catch {
1947
+ /* best-effort */
1948
+ }
1949
+ throw err;
1950
+ }
1951
+ }
1952
+ /** Read a JSON file. */
1953
+ async fsReadJson(path) {
1954
+ const text = await this.fsReadString(path);
1955
+ return JSON.parse(text);
1956
+ }
1957
+ /** Write an object as JSON. */
1958
+ async fsWriteJson(path, obj) {
1959
+ await this.fsWriteString(path, JSON.stringify(obj, null, 2));
1960
+ }
1961
+ /**
1962
+ * Validate a relative path intended for the account file store.
1963
+ *
1964
+ * Splits the path on `/` (after normalising backslashes) and checks every
1965
+ * segment for path-traversal attempts (`..`) and forbidden characters.
1966
+ * Empty segments (from leading/trailing/double slashes) are skipped because
1967
+ * they carry no security risk on the server side.
1968
+ *
1969
+ * @param path - Relative path to validate (e.g. `.templates/my-pipe.json`)
1970
+ * @throws Error if any segment is `..` or contains illegal characters
1971
+ */
1972
+ validateStorePath(path) {
1973
+ // Normalise Windows-style backslashes to forward slashes before splitting
1974
+ for (const segment of path.replace(/\\/g, '/').split('/')) {
1975
+ // Reject parent-directory traversal attempts in any position of the path
1976
+ if (segment === '..')
1977
+ throw new Error(`Path traversal not allowed: ${path}`);
1978
+ // Only validate non-empty segments (empty ones arise from leading/trailing slashes)
1979
+ if (segment) {
1980
+ for (const ch of segment) {
1981
+ // Reject forbidden metacharacters and ASCII control characters (< 0x20)
1982
+ if (RocketRideClient.INVALID_PATH_CHARS.has(ch) || ch.charCodeAt(0) < 0x20) {
1983
+ throw new Error(`Path contains invalid characters: ${path}`);
1984
+ }
1985
+ }
1986
+ }
1987
+ }
1988
+ }
1989
+ /**
1990
+ * Validate a single identifier (projectId, source, templateId, etc.) used
1991
+ * to construct store paths.
1992
+ *
1993
+ * IDs must be non-empty strings that contain no path separators and no
1994
+ * characters from the forbidden set. This prevents an ID from escaping its
1995
+ * intended directory when interpolated into a path.
1996
+ *
1997
+ * @param value - The identifier string to validate
1998
+ * @param name - Human-readable field name used in error messages (e.g. `"projectId"`)
1999
+ * @throws Error if value is empty, contains path separators, or contains illegal characters
2000
+ */
2001
+ validateId(value, name) {
2002
+ // Require a non-empty value
2003
+ if (!value)
2004
+ throw new Error(`${name} is required`);
2005
+ // Reject forward and backward slashes to prevent path injection
2006
+ if (value.includes('/') || value.includes('\\'))
2007
+ throw new Error(`${name} must not contain path separators`);
2008
+ // Reject any forbidden metacharacter or ASCII control character
2009
+ for (const ch of value) {
2010
+ if (RocketRideClient.INVALID_PATH_CHARS.has(ch) || ch.charCodeAt(0) < 0x20) {
2011
+ throw new Error(`${name} contains invalid characters: ${value}`);
2012
+ }
1684
2013
  }
1685
- // Extract and return response
1686
- const logCount = response.body?.total_count || 0;
1687
- this.debugMessage(`Logs retrieved successfully: ${logCount} logs`);
1688
- return response.body;
1689
2014
  }
1690
2015
  // ============================================================================
1691
- // RAW REQUEST METHOD
2016
+ // DASHBOARD METHODS
1692
2017
  // ============================================================================
1693
2018
  /**
1694
- * Send an arbitrary DAP command with command name, arguments, and optional token.
2019
+ * Retrieve a server dashboard snapshot.
1695
2020
  *
1696
- * This is a convenience method for callers that don't want to construct
1697
- * full DAPMessage objects. It builds the request internally and delegates
1698
- * to the underlying request() method.
2021
+ * Returns the current state of all connections, tasks, and aggregate
2022
+ * metrics from the server. Requires 'task.monitor' permission.
1699
2023
  *
1700
- * @param command - The DAP command name (e.g., 'rrext_services', 'rrext_monitor')
1701
- * @param args - Optional arguments for the command
1702
- * @param token - Optional task/session token
1703
- * @param timeout - Optional per-request timeout in ms
1704
- * @returns The response DAPMessage from the server
2024
+ * @returns DashboardResponse containing overview, connections, and tasks
1705
2025
  */
1706
- async dapRequest(command, args, token, timeout) {
1707
- const message = this.buildRequest(command, {
1708
- arguments: args,
1709
- token,
1710
- });
1711
- return await this.request(message, timeout);
2026
+ async getDashboard() {
2027
+ return this.call('rrext_dashboard', {});
2028
+ }
2029
+ // ============================================================================
2030
+ // CPROFILE PROFILING
2031
+ // ============================================================================
2032
+ /**
2033
+ * Start a cProfile profiling session on the server process or a pipeline.
2034
+ *
2035
+ * @param target - Task token to profile a pipeline subprocess, or
2036
+ * undefined/null to profile the server process itself.
2037
+ * @param session - Optional human-readable session name.
2038
+ * @returns Status object with session info and start time.
2039
+ */
2040
+ async cprofileStart(target, session) {
2041
+ const args = {};
2042
+ if (target)
2043
+ args.target = target;
2044
+ if (session)
2045
+ args.session = session;
2046
+ return this.call('rrext_cprofile_start', args);
2047
+ }
2048
+ /**
2049
+ * Stop the active cProfile profiling session.
2050
+ *
2051
+ * @param target - Task token if profiling a pipeline, or undefined for server.
2052
+ * @returns Result with session name and runtime.
2053
+ */
2054
+ async cprofileStop(target) {
2055
+ const args = {};
2056
+ if (target)
2057
+ args.target = target;
2058
+ return this.call('rrext_cprofile_stop', args);
2059
+ }
2060
+ /**
2061
+ * Get the current cProfile profiling status.
2062
+ *
2063
+ * @param target - Task token if querying a pipeline, or undefined for server.
2064
+ * @returns Status indicating active/inactive, owner, runtime.
2065
+ */
2066
+ async cprofileStatus(target) {
2067
+ const args = {};
2068
+ if (target)
2069
+ args.target = target;
2070
+ return this.call('rrext_cprofile_status', args);
2071
+ }
2072
+ /**
2073
+ * Get the full cProfile report from the last completed session.
2074
+ *
2075
+ * @param target - Task token if querying a pipeline, or undefined for server.
2076
+ * @returns Object containing the full pstats text report.
2077
+ */
2078
+ async cprofileReport(target) {
2079
+ const args = {};
2080
+ if (target)
2081
+ args.target = target;
2082
+ return this.call('rrext_cprofile_report', args);
1712
2083
  }
1713
2084
  // ============================================================================
1714
2085
  // CONTEXT MANAGER SUPPORT - Python-style async context manager
@@ -1727,7 +2098,7 @@ export class RocketRideClient extends DAPClient {
1727
2098
  static async withConnection(config, callback) {
1728
2099
  const client = new RocketRideClient(config);
1729
2100
  try {
1730
- await client.connect();
2101
+ await client.connect(config.auth);
1731
2102
  return await callback(client);
1732
2103
  }
1733
2104
  finally {
@@ -1764,17 +2135,7 @@ export class RocketRideClient extends DAPClient {
1764
2135
  * ```
1765
2136
  */
1766
2137
  async getServices() {
1767
- // Build services request (no service argument = get all)
1768
- const request = this.buildRequest('rrext_services', {});
1769
- // Send to server and wait for response
1770
- const response = await this.request(request);
1771
- // Check if request failed
1772
- if (this.didFail(response)) {
1773
- const errorMsg = response.message || 'Failed to retrieve services';
1774
- throw new Error(`Failed to retrieve services: ${errorMsg}`);
1775
- }
1776
- // Return the body containing all service definitions
1777
- return response.body || {};
2138
+ return this.call('rrext_services', {});
1778
2139
  }
1779
2140
  /**
1780
2141
  * Retrieve a specific service definition from the server.
@@ -1802,19 +2163,7 @@ export class RocketRideClient extends DAPClient {
1802
2163
  if (!service) {
1803
2164
  throw new Error('Service name is required');
1804
2165
  }
1805
- // Build services request with specific service name
1806
- const request = this.buildRequest('rrext_services', {
1807
- arguments: { service }
1808
- });
1809
- // Send to server and wait for response
1810
- const response = await this.request(request);
1811
- // Check if request failed
1812
- if (this.didFail(response)) {
1813
- const errorMsg = response.message || `Service '${service}' not found`;
1814
- throw new Error(`Failed to retrieve service '${service}': ${errorMsg}`);
1815
- }
1816
- // Return the body containing the service definition
1817
- return response.body;
2166
+ return this.call('rrext_services', { service });
1818
2167
  }
1819
2168
  // ============================================================================
1820
2169
  // ADDITIONAL CONVENIENCE METHODS
@@ -1835,6 +2184,109 @@ export class RocketRideClient extends DAPClient {
1835
2184
  getApiKey() {
1836
2185
  return this._apikey;
1837
2186
  }
2187
+ // ============================================================================
2188
+ // ACCOUNT & BILLING NAMESPACES
2189
+ // ============================================================================
2190
+ /**
2191
+ * Lazily-initialised account API namespace.
2192
+ *
2193
+ * Provides typed methods for managing the authenticated user's profile,
2194
+ * API keys, organization, members, and teams.
2195
+ *
2196
+ * @example
2197
+ * ```typescript
2198
+ * const profile = await client.account.getProfile();
2199
+ * ```
2200
+ */
2201
+ get account() {
2202
+ if (!this._account) {
2203
+ this._account = new AccountApi(this);
2204
+ }
2205
+ return this._account;
2206
+ }
2207
+ /**
2208
+ * Lazily-initialised billing API namespace.
2209
+ *
2210
+ * Provides typed methods for managing subscriptions, Stripe checkout
2211
+ * sessions, billing portal access, and compute credit wallets.
2212
+ *
2213
+ * @example
2214
+ * ```typescript
2215
+ * const details = await client.billing.getDetails(orgId);
2216
+ * ```
2217
+ */
2218
+ get billing() {
2219
+ if (!this._billing) {
2220
+ this._billing = new BillingApi(this);
2221
+ }
2222
+ return this._billing;
2223
+ }
2224
+ /**
2225
+ * Lazily-initialised database API namespace.
2226
+ *
2227
+ * Provides direct SQL/Cypher execution against database pipelines, bypassing
2228
+ * the LLM translation layer that {@link RocketRideClient.chat} uses.
2229
+ *
2230
+ * @example
2231
+ * ```typescript
2232
+ * const result = await client.database.query({ token, sql: 'SELECT 1' });
2233
+ * ```
2234
+ */
2235
+ get database() {
2236
+ if (!this._database) {
2237
+ this._database = new DatabaseApi(this);
2238
+ }
2239
+ return this._database;
2240
+ }
2241
+ // ============================================================================
2242
+ // CALL — PUBLIC DAP COMMAND INTERFACE
2243
+ // ============================================================================
2244
+ /**
2245
+ * Sends a DAP command, unwraps the response body, and throws on failure.
2246
+ *
2247
+ * This is the single public entry point for all typed DAP operations.
2248
+ * The {@link AccountApi} and {@link BillingApi} namespaces delegate here.
2249
+ *
2250
+ * If an `onTrace` callback was provided in the constructor config, it is
2251
+ * invoked before the request (TraceType.Request) and after completion
2252
+ * (TraceType.Success or TraceType.Error).
2253
+ *
2254
+ * @param command - DAP command name (e.g. "rrext_account_me").
2255
+ * @param args - Key/value arguments forwarded in the request.
2256
+ * @param options - Optional token (for task-scoped calls) and timeout in ms.
2257
+ * @returns The `body` field of a successful DAP response.
2258
+ * @throws Error if the server signals failure.
2259
+ */
2260
+ async call(command, args, options) {
2261
+ // Build the raw DAP request
2262
+ const message = this.buildRequest(command, {
2263
+ arguments: args,
2264
+ token: options?.token,
2265
+ });
2266
+ // Trace: outbound request
2267
+ this._onTrace?.(TraceType.Request, message);
2268
+ const response = await this.request(message, options?.timeout);
2269
+ // Throw on server-reported failure
2270
+ if (response.success === false) {
2271
+ this._onTrace?.(TraceType.Error, response);
2272
+ throw new Error(response.message ?? `${command} failed`);
2273
+ }
2274
+ // Trace: success response
2275
+ this._onTrace?.(TraceType.Success, response);
2276
+ // Unwrap the body envelope
2277
+ return (response.body ?? response);
2278
+ }
1838
2279
  }
2280
+ // ============================================================================
2281
+ // PATH AND ID VALIDATION
2282
+ // ============================================================================
2283
+ /**
2284
+ * Characters that are illegal in store paths and IDs on all supported
2285
+ * platforms (Windows, Linux, macOS, and object-storage back-ends).
2286
+ *
2287
+ * `\x00` is the null byte; the rest are shell/filesystem metacharacters
2288
+ * that would cause ambiguous or dangerous behaviour in path construction.
2289
+ */
2290
+ RocketRideClient.INVALID_PATH_CHARS = new Set(['*', '?', '<', '>', '|', '"', '\x00']);
1839
2291
  export { RocketRideClient as default };
1840
2292
  //# sourceMappingURL=client.js.map