@limrun/api 0.49.2 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +49 -0
- package/exec-client.d.mts +25 -1
- package/exec-client.d.mts.map +1 -1
- package/exec-client.d.ts +25 -1
- package/exec-client.d.ts.map +1 -1
- package/exec-client.js +362 -197
- package/exec-client.js.map +1 -1
- package/exec-client.mjs +361 -197
- package/exec-client.mjs.map +1 -1
- package/folder-sync-ignore.d.mts +15 -12
- package/folder-sync-ignore.d.mts.map +1 -1
- package/folder-sync-ignore.d.ts +15 -12
- package/folder-sync-ignore.d.ts.map +1 -1
- package/folder-sync-ignore.js +16 -19
- package/folder-sync-ignore.js.map +1 -1
- package/folder-sync-ignore.mjs +16 -19
- package/folder-sync-ignore.mjs.map +1 -1
- package/index.d.mts +1 -1
- package/index.d.mts.map +1 -1
- package/index.d.ts +1 -1
- package/index.d.ts.map +1 -1
- package/index.js.map +1 -1
- package/index.mjs.map +1 -1
- package/instance-client.d.mts +11 -0
- package/instance-client.d.mts.map +1 -1
- package/instance-client.d.ts +11 -0
- package/instance-client.d.ts.map +1 -1
- package/instance-client.js +10 -1
- package/instance-client.js.map +1 -1
- package/instance-client.mjs +7 -0
- package/instance-client.mjs.map +1 -1
- package/ios-client.d.mts +2 -33
- package/ios-client.d.mts.map +1 -1
- package/ios-client.d.ts +2 -33
- package/ios-client.d.ts.map +1 -1
- package/ios-client.js +8 -99
- package/ios-client.js.map +1 -1
- package/ios-client.mjs +4 -95
- package/ios-client.mjs.map +1 -1
- package/log-stream.d.mts +35 -0
- package/log-stream.d.mts.map +1 -0
- package/log-stream.d.ts +35 -0
- package/log-stream.d.ts.map +1 -0
- package/log-stream.js +103 -0
- package/log-stream.js.map +1 -0
- package/log-stream.mjs +99 -0
- package/log-stream.mjs.map +1 -0
- package/logcat-process.d.mts +40 -0
- package/logcat-process.d.mts.map +1 -0
- package/logcat-process.d.ts +40 -0
- package/logcat-process.d.ts.map +1 -0
- package/logcat-process.js +122 -0
- package/logcat-process.js.map +1 -0
- package/logcat-process.mjs +118 -0
- package/logcat-process.mjs.map +1 -0
- package/mise-tools.d.mts +4 -0
- package/mise-tools.d.mts.map +1 -0
- package/mise-tools.d.ts +4 -0
- package/mise-tools.d.ts.map +1 -0
- package/mise-tools.js +63 -0
- package/mise-tools.js.map +1 -0
- package/mise-tools.mjs +58 -0
- package/mise-tools.mjs.map +1 -0
- package/package.json +32 -1
- package/resources/android-instances.d.mts +4 -0
- package/resources/android-instances.d.mts.map +1 -1
- package/resources/android-instances.d.ts +4 -0
- package/resources/android-instances.d.ts.map +1 -1
- package/resources/daemon-client-shared.d.mts +11 -0
- package/resources/daemon-client-shared.d.mts.map +1 -1
- package/resources/daemon-client-shared.d.ts +11 -0
- package/resources/daemon-client-shared.d.ts.map +1 -1
- package/resources/daemon-client-shared.js +23 -0
- package/resources/daemon-client-shared.js.map +1 -1
- package/resources/daemon-client-shared.mjs +22 -0
- package/resources/daemon-client-shared.mjs.map +1 -1
- package/resources/gradle-instances-helpers.d.mts +12 -2
- package/resources/gradle-instances-helpers.d.mts.map +1 -1
- package/resources/gradle-instances-helpers.d.ts +12 -2
- package/resources/gradle-instances-helpers.d.ts.map +1 -1
- package/resources/gradle-instances-helpers.js +6 -0
- package/resources/gradle-instances-helpers.js.map +1 -1
- package/resources/gradle-instances-helpers.mjs +8 -2
- package/resources/gradle-instances-helpers.mjs.map +1 -1
- package/resources/xcode-instances-helpers.d.mts +6 -15
- package/resources/xcode-instances-helpers.d.mts.map +1 -1
- package/resources/xcode-instances-helpers.d.ts +6 -15
- package/resources/xcode-instances-helpers.d.ts.map +1 -1
- package/resources/xcode-instances-helpers.js +3 -18
- package/resources/xcode-instances-helpers.js.map +1 -1
- package/resources/xcode-instances-helpers.mjs +5 -20
- package/resources/xcode-instances-helpers.mjs.map +1 -1
- package/src/exec-client.ts +404 -198
- package/src/folder-sync-ignore.ts +21 -21
- package/src/index.ts +1 -0
- package/src/instance-client.ts +16 -0
- package/src/ios-client.ts +4 -129
- package/src/log-stream.ts +132 -0
- package/src/logcat-process.ts +151 -0
- package/src/mise-tools.ts +55 -0
- package/src/resources/android-instances.ts +5 -0
- package/src/resources/daemon-client-shared.ts +33 -0
- package/src/resources/gradle-instances-helpers.ts +23 -0
- package/src/resources/xcode-instances-helpers.ts +13 -33
- package/src/version.ts +1 -1
- package/version.d.mts +1 -1
- package/version.d.ts +1 -1
- package/version.js +1 -1
- 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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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 (
|
|
414
|
-
|
|
547
|
+
else if (eventType === 'stdout') {
|
|
548
|
+
this.stdout.emit('data', data);
|
|
415
549
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
563
|
+
}
|
|
564
|
+
else if (eventType === 'playstore') {
|
|
565
|
+
try {
|
|
566
|
+
this.playstoreEvent = JSON.parse(data);
|
|
473
567
|
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
|
|
510
|
-
// The
|
|
511
|
-
//
|
|
512
|
-
this.
|
|
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
|
-
|
|
517
|
-
|
|
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
|
-
|
|
529
|
-
let event;
|
|
597
|
+
if (event) {
|
|
530
598
|
try {
|
|
531
|
-
|
|
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
|
-
|
|
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.
|