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