rocketride 1.0.6 → 1.1.1

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