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