@limrun/api 0.49.3 → 0.50.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 (104) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/exec-client.d.mts +25 -1
  3. package/exec-client.d.mts.map +1 -1
  4. package/exec-client.d.ts +25 -1
  5. package/exec-client.d.ts.map +1 -1
  6. package/exec-client.js +362 -197
  7. package/exec-client.js.map +1 -1
  8. package/exec-client.mjs +361 -197
  9. package/exec-client.mjs.map +1 -1
  10. package/folder-sync-ignore.d.mts +15 -12
  11. package/folder-sync-ignore.d.mts.map +1 -1
  12. package/folder-sync-ignore.d.ts +15 -12
  13. package/folder-sync-ignore.d.ts.map +1 -1
  14. package/folder-sync-ignore.js +16 -19
  15. package/folder-sync-ignore.js.map +1 -1
  16. package/folder-sync-ignore.mjs +16 -19
  17. package/folder-sync-ignore.mjs.map +1 -1
  18. package/index.d.mts +1 -1
  19. package/index.d.mts.map +1 -1
  20. package/index.d.ts +1 -1
  21. package/index.d.ts.map +1 -1
  22. package/index.js.map +1 -1
  23. package/index.mjs.map +1 -1
  24. package/instance-client.d.mts +11 -0
  25. package/instance-client.d.mts.map +1 -1
  26. package/instance-client.d.ts +11 -0
  27. package/instance-client.d.ts.map +1 -1
  28. package/instance-client.js +10 -1
  29. package/instance-client.js.map +1 -1
  30. package/instance-client.mjs +7 -0
  31. package/instance-client.mjs.map +1 -1
  32. package/ios-client.d.mts +2 -33
  33. package/ios-client.d.mts.map +1 -1
  34. package/ios-client.d.ts +2 -33
  35. package/ios-client.d.ts.map +1 -1
  36. package/ios-client.js +8 -99
  37. package/ios-client.js.map +1 -1
  38. package/ios-client.mjs +4 -95
  39. package/ios-client.mjs.map +1 -1
  40. package/log-stream.d.mts +35 -0
  41. package/log-stream.d.mts.map +1 -0
  42. package/log-stream.d.ts +35 -0
  43. package/log-stream.d.ts.map +1 -0
  44. package/log-stream.js +103 -0
  45. package/log-stream.js.map +1 -0
  46. package/log-stream.mjs +99 -0
  47. package/log-stream.mjs.map +1 -0
  48. package/logcat-process.d.mts +40 -0
  49. package/logcat-process.d.mts.map +1 -0
  50. package/logcat-process.d.ts +40 -0
  51. package/logcat-process.d.ts.map +1 -0
  52. package/logcat-process.js +122 -0
  53. package/logcat-process.js.map +1 -0
  54. package/logcat-process.mjs +118 -0
  55. package/logcat-process.mjs.map +1 -0
  56. package/mise-tools.d.mts +2 -0
  57. package/mise-tools.d.mts.map +1 -0
  58. package/mise-tools.d.ts +2 -0
  59. package/mise-tools.d.ts.map +1 -0
  60. package/mise-tools.js +17 -0
  61. package/mise-tools.js.map +1 -0
  62. package/mise-tools.mjs +14 -0
  63. package/mise-tools.mjs.map +1 -0
  64. package/package.json +31 -1
  65. package/resources/daemon-client-shared.d.mts +11 -0
  66. package/resources/daemon-client-shared.d.mts.map +1 -1
  67. package/resources/daemon-client-shared.d.ts +11 -0
  68. package/resources/daemon-client-shared.d.ts.map +1 -1
  69. package/resources/daemon-client-shared.js +23 -0
  70. package/resources/daemon-client-shared.js.map +1 -1
  71. package/resources/daemon-client-shared.mjs +22 -0
  72. package/resources/daemon-client-shared.mjs.map +1 -1
  73. package/resources/gradle-instances-helpers.d.mts +12 -2
  74. package/resources/gradle-instances-helpers.d.mts.map +1 -1
  75. package/resources/gradle-instances-helpers.d.ts +12 -2
  76. package/resources/gradle-instances-helpers.d.ts.map +1 -1
  77. package/resources/gradle-instances-helpers.js +6 -0
  78. package/resources/gradle-instances-helpers.js.map +1 -1
  79. package/resources/gradle-instances-helpers.mjs +8 -2
  80. package/resources/gradle-instances-helpers.mjs.map +1 -1
  81. package/resources/xcode-instances-helpers.d.mts +6 -15
  82. package/resources/xcode-instances-helpers.d.mts.map +1 -1
  83. package/resources/xcode-instances-helpers.d.ts +6 -15
  84. package/resources/xcode-instances-helpers.d.ts.map +1 -1
  85. package/resources/xcode-instances-helpers.js +3 -18
  86. package/resources/xcode-instances-helpers.js.map +1 -1
  87. package/resources/xcode-instances-helpers.mjs +5 -20
  88. package/resources/xcode-instances-helpers.mjs.map +1 -1
  89. package/src/exec-client.ts +404 -198
  90. package/src/folder-sync-ignore.ts +21 -21
  91. package/src/index.ts +1 -0
  92. package/src/instance-client.ts +16 -0
  93. package/src/ios-client.ts +4 -129
  94. package/src/log-stream.ts +132 -0
  95. package/src/logcat-process.ts +151 -0
  96. package/src/mise-tools.ts +13 -0
  97. package/src/resources/daemon-client-shared.ts +33 -0
  98. package/src/resources/gradle-instances-helpers.ts +23 -0
  99. package/src/resources/xcode-instances-helpers.ts +13 -33
  100. package/src/version.ts +1 -1
  101. package/version.d.mts +1 -1
  102. package/version.d.ts +1 -1
  103. package/version.js +1 -1
  104. package/version.mjs +1 -1
package/exec-client.js CHANGED
@@ -7,10 +7,12 @@
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.ExecChildProcess = exports.ReadableStream = exports.ExecStreamClosedError = exports.ExecStreamLostError = exports.sseStreamPolicy = void 0;
10
+ exports.observeExecLogs = observeExecLogs;
10
11
  exports.exec = exec;
11
12
  const eventsource_client_1 = require("eventsource-client");
12
13
  const proxy_transport_1 = require("./internal/proxy-transport.js");
13
14
  const sse_fetch_1 = require("./internal/sse-fetch.js");
15
+ const direct_instance_errors_1 = require("./internal/direct-instance-errors.js");
14
16
  /**
15
17
  * Give-up policy for the exec event stream. The EventSource reconnects
16
18
  * forever on its own, so a dead exec (deleted instance, expired record)
@@ -70,6 +72,177 @@ class ReadableStream {
70
72
  }
71
73
  }
72
74
  exports.ReadableStream = ReadableStream;
75
+ /** Follow execution events until exitCode, with bounded retries and replay deduplication. */
76
+ function followExecEventStream(options) {
77
+ let connection = null;
78
+ const { eventsUrl, signal } = options;
79
+ const result = new Promise((resolve, reject) => {
80
+ if (signal?.aborted) {
81
+ reject(options.abortError());
82
+ return;
83
+ }
84
+ // Settle-once pair. Both paths close the source: the EventSource
85
+ // reconnects on its own otherwise, even after the promise is done.
86
+ let settled = false;
87
+ let lastSeenEventId = -1;
88
+ let streamDeadSince = 0;
89
+ let eventSource = null;
90
+ let connectedAt = 0;
91
+ let lastCycleAt = 0;
92
+ let lastCycleMono = 0;
93
+ let proofOfLifeThisCycle = false;
94
+ let cleanResponseThisCycle = false;
95
+ let lastStreamError;
96
+ const cleanup = () => {
97
+ eventSource?.close();
98
+ signal?.removeEventListener('abort', onAbort);
99
+ };
100
+ const succeed = (exitCode) => {
101
+ if (settled)
102
+ return;
103
+ settled = true;
104
+ cleanup();
105
+ resolve(exitCode);
106
+ };
107
+ const fail = (error) => {
108
+ if (settled)
109
+ return;
110
+ settled = true;
111
+ cleanup();
112
+ reject(error);
113
+ };
114
+ const onAbort = () => fail(options.abortError());
115
+ // Stream health is judged at the fetch surface, where the response
116
+ // status is visible; the library fires onConnect for error responses
117
+ // too, so an error page held open must not read as a healthy
118
+ // connection. An HTTP 204 makes the EventSource close for good
119
+ // without reporting anything, so it must fail here. A rejected fetch
120
+ // never reaches onDisconnect (the hazard sseFetch exists for):
121
+ // capture it for the give-up message and let the clock decide, so
122
+ // one transient refusal does not kill a live build.
123
+ const fetchWithStreamPolicy = async (input, init) => {
124
+ const response = await proxy_transport_1.nodeProxyTransport.fetch(input, init);
125
+ if (response.status === 204) {
126
+ fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
127
+ }
128
+ else if (options.failOnClientError &&
129
+ response.status >= 400 &&
130
+ response.status < 500 &&
131
+ response.status !== 408 &&
132
+ response.status !== 429) {
133
+ fail((0, direct_instance_errors_1.directInstanceHttpError)('GET exec logs', response.status, await response.text(), response.headers));
134
+ }
135
+ else if (!response.ok) {
136
+ lastStreamError = new Error(`server answered HTTP ${response.status}`);
137
+ }
138
+ cleanResponseThisCycle = response.ok;
139
+ return response;
140
+ };
141
+ eventSource = (0, eventsource_client_1.createEventSource)({
142
+ url: eventsUrl,
143
+ fetch: (0, sse_fetch_1.sseFetch)(fetchWithStreamPolicy, (err) => {
144
+ lastStreamError = err instanceof Error ? err : new Error(String(err));
145
+ }),
146
+ headers: { Authorization: `Bearer ${options.token}` },
147
+ onConnect: () => {
148
+ connectedAt = Date.now();
149
+ },
150
+ // Comments count as proof of life, so a server-side keepalive
151
+ // works without a client change (onMessage never sees them).
152
+ onComment: () => {
153
+ proofOfLifeThisCycle = true;
154
+ },
155
+ // Fires once per broken cycle, before the retry timer is armed, on
156
+ // both failure paths (request rejected, stream ended). This is
157
+ // where the give-up clock runs.
158
+ onScheduleReconnect: () => {
159
+ if (settled || signal?.aborted) {
160
+ return;
161
+ }
162
+ const now = Date.now();
163
+ const mono = performance.now();
164
+ const livedMs = connectedAt > 0 ? now - connectedAt : 0;
165
+ connectedAt = 0;
166
+ // Date.now() is wall clock: a laptop waking from sleep (or a
167
+ // clock step) would arrive with the whole window already
168
+ // "elapsed" and fail on its first attempt. Sleep is the wall
169
+ // clock advancing while the monotonic clock stands still; a
170
+ // large drift between the two restarts the streak. Long server
171
+ // retry delays and hanging connects advance both clocks equally
172
+ // and keep counting.
173
+ if (streamDeadSince > 0 && lastCycleAt > 0) {
174
+ const wallGapMs = now - lastCycleAt;
175
+ const monoGapMs = mono - lastCycleMono;
176
+ if (wallGapMs - monoGapMs > 30000) {
177
+ streamDeadSince = now;
178
+ options.onStreamDeadSince?.(streamDeadSince);
179
+ }
180
+ }
181
+ lastCycleAt = now;
182
+ lastCycleMono = mono;
183
+ const healthy = proofOfLifeThisCycle || (cleanResponseThisCycle && livedMs >= exports.sseStreamPolicy.healthyConnectionMs);
184
+ proofOfLifeThisCycle = false;
185
+ cleanResponseThisCycle = false;
186
+ if (healthy) {
187
+ streamDeadSince = 0;
188
+ options.onStreamDeadSince?.(0);
189
+ lastStreamError = undefined;
190
+ return;
191
+ }
192
+ if (streamDeadSince === 0) {
193
+ streamDeadSince = now;
194
+ options.onStreamDeadSince?.(streamDeadSince);
195
+ options.log?.('warn', 'SSE disconnected; reconnecting');
196
+ return;
197
+ }
198
+ if (now - streamDeadSince >= exports.sseStreamPolicy.giveUpAfterMs) {
199
+ const seconds = Math.round((now - streamDeadSince) / 1000);
200
+ const cause = lastStreamError ? `; last error: ${lastStreamError.message}` : '';
201
+ fail(new ExecStreamLostError(`event stream to ${eventsUrl} kept failing for ${seconds}s without delivering events${cause}; ` +
202
+ 'the execution may no longer exist (instance deleted or record expired)'));
203
+ }
204
+ },
205
+ onMessage: (message) => {
206
+ if (settled)
207
+ return;
208
+ streamDeadSince = 0;
209
+ options.onStreamDeadSince?.(0);
210
+ proofOfLifeThisCycle = true;
211
+ lastStreamError = undefined;
212
+ const eventId = message.id !== undefined ? Number.parseInt(message.id, 10) : NaN;
213
+ if (!Number.isNaN(eventId)) {
214
+ if (eventId <= lastSeenEventId)
215
+ return;
216
+ lastSeenEventId = eventId;
217
+ }
218
+ try {
219
+ const event = {
220
+ ...(message.id !== undefined && { id: message.id }),
221
+ type: message.event || 'message',
222
+ data: typeof message.data === 'string' ? message.data : String(message.data ?? ''),
223
+ };
224
+ options.onEvent(event);
225
+ if (event.type === 'exitCode') {
226
+ const exitCode = parseExitCode(event.data);
227
+ options.log?.('debug', `Execution completed via SSE: exitCode=${exitCode}`);
228
+ succeed(exitCode);
229
+ }
230
+ }
231
+ catch (error) {
232
+ fail(error);
233
+ }
234
+ },
235
+ });
236
+ connection = eventSource;
237
+ if (settled) {
238
+ eventSource.close();
239
+ }
240
+ else {
241
+ signal?.addEventListener('abort', onAbort, { once: true });
242
+ }
243
+ });
244
+ return { connection, result };
245
+ }
73
246
  /**
74
247
  * A ChildProcess-like object similar to Node.js's ChildProcess.
75
248
  *
@@ -107,11 +280,6 @@ class ExecChildProcess {
107
280
  this.playstoreEvent = null;
108
281
  this.xctestCases = [];
109
282
  this.xctestSummary = null;
110
- // Highest server-assigned event id already dispatched. The server numbers
111
- // every frame and replays the stream from the start on reconnect; skipping
112
- // ids at or below this mark drops the replayed prefix for every event type
113
- // at once, and stays correct if the server ever honors Last-Event-ID.
114
- this.lastSeenEventId = -1;
115
283
  this.options = options;
116
284
  this.log = options.log ?? (() => { });
117
285
  this.startedPromise = new Promise((resolve, reject) => {
@@ -363,217 +531,214 @@ class ExecChildProcess {
363
531
  * closes the stream for good, or when the give-up clock runs out.
364
532
  */
365
533
  connectSSE(eventsUrl) {
366
- return new Promise((resolve, reject) => {
367
- const signal = this.abortController.signal;
368
- if (signal.aborted) {
369
- reject(new Error('killed'));
370
- return;
371
- }
372
- // Settle-once pair. Both paths close the source: the EventSource
373
- // reconnects on its own otherwise, even after the promise is done.
374
- let settled = false;
375
- let connectedAt = 0;
376
- let lastCycleAt = 0;
377
- let lastCycleMono = 0;
378
- let proofOfLifeThisCycle = false;
379
- let cleanResponseThisCycle = false;
380
- let lastStreamError;
381
- const cleanup = () => {
382
- eventSource.close();
383
- signal.removeEventListener('abort', onAbort);
384
- };
385
- const succeed = (exitCode) => {
386
- if (settled)
387
- return;
388
- settled = true;
389
- cleanup();
390
- resolve(exitCode);
391
- };
392
- const fail = (error) => {
393
- if (settled)
394
- return;
395
- settled = true;
396
- cleanup();
397
- reject(error);
398
- };
399
- const onAbort = () => fail(new Error('killed'));
400
- // Stream health is judged at the fetch surface, where the response
401
- // status is visible; the library fires onConnect for error responses
402
- // too, so an error page held open must not read as a healthy
403
- // connection. An HTTP 204 makes the EventSource close for good
404
- // without reporting anything, so it must fail here. A rejected fetch
405
- // never reaches onDisconnect (the hazard sseFetch exists for):
406
- // capture it for the give-up message and let the clock decide, so
407
- // one transient refusal does not kill a live build.
408
- const fetchWithStreamPolicy = async (input, init) => {
409
- const response = await proxy_transport_1.nodeProxyTransport.fetch(input, init);
410
- if (response.status === 204) {
411
- fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
534
+ const stream = followExecEventStream({
535
+ eventsUrl,
536
+ token: this.options.token,
537
+ signal: this.abortController.signal,
538
+ abortError: () => new Error('killed'),
539
+ log: this.log,
540
+ onStreamDeadSince: (since) => {
541
+ this.streamDeadSince = since;
542
+ },
543
+ onEvent: ({ type: eventType, data }) => {
544
+ if (eventType === 'command') {
545
+ this.command.emit('data', data);
412
546
  }
413
- else if (!response.ok) {
414
- lastStreamError = new Error(`server answered HTTP ${response.status}`);
547
+ else if (eventType === 'stdout') {
548
+ this.stdout.emit('data', data);
415
549
  }
416
- cleanResponseThisCycle = response.ok;
417
- return response;
418
- };
419
- const eventSource = (0, eventsource_client_1.createEventSource)({
420
- url: eventsUrl,
421
- fetch: (0, sse_fetch_1.sseFetch)(fetchWithStreamPolicy, (err) => {
422
- lastStreamError = err instanceof Error ? err : new Error(String(err));
423
- }),
424
- headers: { Authorization: `Bearer ${this.options.token}` },
425
- onConnect: () => {
426
- connectedAt = Date.now();
427
- },
428
- // Comments count as proof of life, so a server-side keepalive
429
- // works without a client change (onMessage never sees them).
430
- onComment: () => {
431
- proofOfLifeThisCycle = true;
432
- },
433
- // Fires once per broken cycle, before the retry timer is armed, on
434
- // both failure paths (request rejected, stream ended). This is
435
- // where the give-up clock runs.
436
- onScheduleReconnect: () => {
437
- if (settled || this.killed) {
438
- return;
439
- }
440
- const now = Date.now();
441
- const mono = performance.now();
442
- const livedMs = connectedAt > 0 ? now - connectedAt : 0;
443
- connectedAt = 0;
444
- // Date.now() is wall clock: a laptop waking from sleep (or a
445
- // clock step) would arrive with the whole window already
446
- // "elapsed" and fail on its first attempt. Sleep is the wall
447
- // clock advancing while the monotonic clock stands still; a
448
- // large drift between the two restarts the streak. Long server
449
- // retry delays and hanging connects advance both clocks equally
450
- // and keep counting.
451
- if (this.streamDeadSince > 0 && lastCycleAt > 0) {
452
- const wallGapMs = now - lastCycleAt;
453
- const monoGapMs = mono - lastCycleMono;
454
- if (wallGapMs - monoGapMs > 30000) {
455
- this.streamDeadSince = now;
456
- }
550
+ else if (eventType === 'stderr') {
551
+ this.stderr.emit('data', data);
552
+ }
553
+ else if (eventType === 'testflight') {
554
+ try {
555
+ this.appStoreEvent = JSON.parse(data);
457
556
  }
458
- lastCycleAt = now;
459
- lastCycleMono = mono;
460
- const healthy = proofOfLifeThisCycle ||
461
- (cleanResponseThisCycle && livedMs >= exports.sseStreamPolicy.healthyConnectionMs);
462
- proofOfLifeThisCycle = false;
463
- cleanResponseThisCycle = false;
464
- if (healthy) {
465
- this.streamDeadSince = 0;
466
- lastStreamError = undefined;
467
- return;
557
+ catch {
558
+ // The wire event itself proves the server ran the App Store upload,
559
+ // so never let a payload glitch look like a missing feature.
560
+ this.appStoreEvent = { state: 'unknown' };
561
+ this.log('warn', `SSE testflight event has invalid data: ${data}`);
468
562
  }
469
- if (this.streamDeadSince === 0) {
470
- this.streamDeadSince = now;
471
- this.log('warn', 'SSE disconnected; reconnecting');
472
- return;
563
+ }
564
+ else if (eventType === 'playstore') {
565
+ try {
566
+ this.playstoreEvent = JSON.parse(data);
473
567
  }
474
- if (now - this.streamDeadSince >= exports.sseStreamPolicy.giveUpAfterMs) {
475
- const seconds = Math.round((now - this.streamDeadSince) / 1000);
476
- const cause = lastStreamError ? `; last error: ${lastStreamError.message}` : '';
477
- fail(new ExecStreamLostError(`event stream to ${eventsUrl} kept failing for ${seconds}s without delivering events${cause}; ` +
478
- 'the execution may no longer exist (instance deleted or record expired)'));
568
+ catch {
569
+ // Same contract as the App Store upload event: its presence proves the server
570
+ // ran the Play Store step, so a payload glitch must not
571
+ // read as a missing feature.
572
+ this.playstoreEvent = { state: 'unknown' };
573
+ this.log('warn', `SSE playstore event has invalid data: ${data}`);
479
574
  }
480
- },
481
- onMessage: (message) => {
482
- this.streamDeadSince = 0;
483
- proofOfLifeThisCycle = true;
484
- lastStreamError = undefined;
485
- const eventId = message.id !== undefined ? parseInt(message.id, 10) : NaN;
486
- if (!Number.isNaN(eventId)) {
487
- if (eventId <= this.lastSeenEventId) {
488
- // A reconnect replays the stream from the start; this frame was
489
- // already dispatched on an earlier connection.
490
- return;
575
+ }
576
+ else if (eventType === 'xctest') {
577
+ let event;
578
+ try {
579
+ const parsed = JSON.parse(data);
580
+ if (parsed?.type === 'case') {
581
+ this.xctestCases.push(parsed);
582
+ event = parsed;
491
583
  }
492
- this.lastSeenEventId = eventId;
493
- }
494
- const data = typeof message.data === 'string' ? message.data : String(message.data ?? '');
495
- const eventType = message.event;
496
- if (eventType === 'command') {
497
- this.command.emit('data', data);
498
- }
499
- else if (eventType === 'stdout') {
500
- this.stdout.emit('data', data);
501
- }
502
- else if (eventType === 'stderr') {
503
- this.stderr.emit('data', data);
504
- }
505
- else if (eventType === 'testflight') {
506
- try {
507
- this.appStoreEvent = JSON.parse(data);
584
+ else if (parsed?.type === 'summary') {
585
+ this.xctestSummary = parsed;
586
+ event = parsed;
508
587
  }
509
- catch {
510
- // The wire event itself proves the server ran the App Store upload,
511
- // so never let a payload glitch look like a missing feature.
512
- this.appStoreEvent = { state: 'unknown' };
513
- this.log('warn', `SSE testflight event has invalid data: ${data}`);
588
+ else {
589
+ // The simulator owns the frame vocabulary and may grow it; the
590
+ // callback's contract is cases and summaries only.
591
+ this.log('debug', `SSE xctest event of unknown type ignored: ${data}`);
514
592
  }
515
593
  }
516
- else if (eventType === 'playstore') {
517
- try {
518
- this.playstoreEvent = JSON.parse(data);
519
- }
520
- catch {
521
- // Same contract as the App Store upload event: its presence proves the server
522
- // ran the Play Store step, so a payload glitch must not
523
- // read as a missing feature.
524
- this.playstoreEvent = { state: 'unknown' };
525
- this.log('warn', `SSE playstore event has invalid data: ${data}`);
526
- }
594
+ catch {
595
+ this.log('warn', `SSE xctest event has invalid data: ${data}`);
527
596
  }
528
- else if (eventType === 'xctest') {
529
- let event;
597
+ if (event) {
530
598
  try {
531
- const parsed = JSON.parse(data);
532
- if (parsed?.type === 'case') {
533
- this.xctestCases.push(parsed);
534
- event = parsed;
535
- }
536
- else if (parsed?.type === 'summary') {
537
- this.xctestSummary = parsed;
538
- event = parsed;
539
- }
540
- else {
541
- // The simulator owns the frame vocabulary and may grow it; the
542
- // callback's contract is cases and summaries only.
543
- this.log('debug', `SSE xctest event of unknown type ignored: ${data}`);
544
- }
599
+ this.options.onXctestEvent?.(event);
545
600
  }
546
- catch {
547
- this.log('warn', `SSE xctest event has invalid data: ${data}`);
601
+ catch (err) {
602
+ // The consumer's callback, not the frame: the run must not be
603
+ // derailed and the log must not blame the payload.
604
+ this.log('warn', `onXctestEvent callback threw: ${String(err)}`);
548
605
  }
549
- if (event) {
550
- try {
551
- this.options.onXctestEvent?.(event);
552
- }
553
- catch (err) {
554
- // The consumer's callback, not the frame: the run must not be
555
- // derailed and the log must not blame the payload.
556
- this.log('warn', `onXctestEvent callback threw: ${String(err)}`);
557
- }
558
- }
559
- }
560
- else if (eventType === 'exitCode') {
561
- const exitCode = parseInt(data, 10);
562
- if (Number.isNaN(exitCode)) {
563
- this.log('warn', `SSE exitCode event has invalid data: ${data}`);
564
- return;
565
- }
566
- this.log('debug', `Execution completed via SSE: exitCode=${exitCode}`);
567
- succeed(exitCode);
568
606
  }
569
- },
570
- });
571
- this.sseConnection = eventSource;
572
- signal.addEventListener('abort', onAbort, { once: true });
607
+ }
608
+ },
573
609
  });
610
+ this.sseConnection = stream.connection;
611
+ return stream.result;
574
612
  }
575
613
  }
576
614
  exports.ExecChildProcess = ExecChildProcess;
615
+ /**
616
+ * Replay logs for an existing execution, optionally following it to completion.
617
+ * Snapshot mode uses a finite SSE response; follow mode consumes the same
618
+ * replayable stream until its terminal exitCode event.
619
+ */
620
+ async function observeExecLogs(execId, options) {
621
+ if (!execId.trim()) {
622
+ throw new Error('execId must not be empty');
623
+ }
624
+ const follow = options.follow ?? false;
625
+ // Resolve mutable aliases once so a reconnect cannot switch to another build.
626
+ if (follow && (execId === 'active' || execId === 'latest')) {
627
+ const snapshotEvents = [];
628
+ const snapshot = await observeExecLogs(execId, {
629
+ ...options,
630
+ follow: false,
631
+ onEvent: (event) => snapshotEvents.push(event),
632
+ });
633
+ if (snapshot.status !== 'RUNNING') {
634
+ for (const event of snapshotEvents)
635
+ options.onEvent?.(event);
636
+ return snapshot;
637
+ }
638
+ if (snapshot.execId === execId) {
639
+ throw new Error(`The server did not resolve the ${execId} build alias.`);
640
+ }
641
+ return observeExecLogs(snapshot.execId, options);
642
+ }
643
+ let apiUrlEnd = options.apiUrl.length;
644
+ while (options.apiUrl[apiUrlEnd - 1] === '/')
645
+ apiUrlEnd--;
646
+ const eventsUrl = new URL(`${options.apiUrl.slice(0, apiUrlEnd)}/exec/${encodeURIComponent(execId)}/events`);
647
+ eventsUrl.searchParams.set('follow', String(follow));
648
+ if (!follow) {
649
+ const response = await proxy_transport_1.nodeProxyTransport.fetch(eventsUrl, {
650
+ method: 'GET',
651
+ headers: { Authorization: `Bearer ${options.token}` },
652
+ ...(options.signal && { signal: options.signal }),
653
+ });
654
+ if (!response.ok) {
655
+ const text = await response.text();
656
+ throw (0, direct_instance_errors_1.directInstanceHttpError)('GET exec logs', response.status, text, response.headers);
657
+ }
658
+ const events = parseSSE(await response.text());
659
+ let exitCode;
660
+ let resolvedExecId = execId;
661
+ for (const event of events) {
662
+ resolvedExecId = execIdFromMeta(event, resolvedExecId);
663
+ options.onEvent?.(event);
664
+ if (event.type === 'exitCode') {
665
+ exitCode = parseExitCode(event.data);
666
+ }
667
+ }
668
+ return buildExecLogResult(resolvedExecId, exitCode);
669
+ }
670
+ let resolvedExecId = execId;
671
+ const stream = followExecEventStream({
672
+ eventsUrl: eventsUrl.toString(),
673
+ token: options.token,
674
+ ...(options.signal && { signal: options.signal }),
675
+ failOnClientError: true,
676
+ log: options.log,
677
+ abortError: () => new Error(`exec log stream for ${execId} was aborted`),
678
+ onEvent: (event) => {
679
+ resolvedExecId = execIdFromMeta(event, resolvedExecId);
680
+ options.onEvent?.(event);
681
+ },
682
+ });
683
+ const exitCode = await stream.result;
684
+ return buildExecLogResult(resolvedExecId, exitCode);
685
+ }
686
+ function parseSSE(body) {
687
+ const normalized = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
688
+ const events = [];
689
+ for (const block of normalized.split('\n\n')) {
690
+ if (!block.trim())
691
+ continue;
692
+ let id;
693
+ let type = 'message';
694
+ const data = [];
695
+ for (const line of block.split('\n')) {
696
+ if (line.startsWith('id:')) {
697
+ id = line.slice(3).trimStart();
698
+ }
699
+ else if (line.startsWith('event:')) {
700
+ type = line.slice(6).trimStart();
701
+ }
702
+ else if (line.startsWith('data:')) {
703
+ data.push(line.slice(5).replace(/^ /, ''));
704
+ }
705
+ }
706
+ if (data.length > 0 || type !== 'message' || id !== undefined) {
707
+ events.push({ ...(id !== undefined && { id }), type, data: data.join('\n') });
708
+ }
709
+ }
710
+ return events;
711
+ }
712
+ function parseExitCode(data) {
713
+ const exitCode = Number.parseInt(data, 10);
714
+ if (Number.isNaN(exitCode)) {
715
+ throw new Error(`invalid exec exit code: ${data}`);
716
+ }
717
+ return exitCode;
718
+ }
719
+ function execIdFromMeta(event, fallback) {
720
+ if (event.type !== 'meta')
721
+ return fallback;
722
+ try {
723
+ const meta = JSON.parse(event.data);
724
+ return typeof meta.id === 'string' && meta.id ? meta.id : fallback;
725
+ }
726
+ catch {
727
+ return fallback;
728
+ }
729
+ }
730
+ function buildExecLogResult(execId, exitCode) {
731
+ if (exitCode === undefined) {
732
+ return { execId, status: 'RUNNING' };
733
+ }
734
+ return {
735
+ execId,
736
+ exitCode,
737
+ status: exitCode === 0 ? 'SUCCEEDED'
738
+ : exitCode === -1 ? 'CANCELLED'
739
+ : 'FAILED',
740
+ };
741
+ }
577
742
  /**
578
743
  * Execute a command on the limbuild server.
579
744
  * Returns a ChildProcess-like object with stdout/stderr streams.