@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/src/exec-client.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { createEventSource, type EventSourceClient, type EventSourceMessage } from 'eventsource-client';
|
|
9
9
|
import { nodeProxyTransport } from './internal/proxy-transport';
|
|
10
10
|
import { sseFetch } from './internal/sse-fetch';
|
|
11
|
+
import { directInstanceHttpError } from './internal/direct-instance-errors';
|
|
11
12
|
import type { Fetch } from './internal/builtin-types';
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -196,6 +197,7 @@ export type GradleSigningConfig = {
|
|
|
196
197
|
|
|
197
198
|
export type GradleBuildExecRequest = {
|
|
198
199
|
command: 'gradlebuild';
|
|
200
|
+
env?: string[];
|
|
199
201
|
/**
|
|
200
202
|
* Gradle tasks to run. Omit for the server default (assembleDebug, or
|
|
201
203
|
* bundleRelease when signing is set).
|
|
@@ -383,6 +385,27 @@ export type AppStoreUploadConfig = {
|
|
|
383
385
|
|
|
384
386
|
type DataListener = (chunk: string) => void;
|
|
385
387
|
type CloseListener = () => void;
|
|
388
|
+
export type ExecLogEvent = {
|
|
389
|
+
id?: string;
|
|
390
|
+
type: string;
|
|
391
|
+
data: string;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
export type ExecLogOptions = {
|
|
395
|
+
/** Keep streaming after replaying buffered events. Defaults to false. */
|
|
396
|
+
follow?: boolean;
|
|
397
|
+
/** Called for each replayed or live event in wire order. */
|
|
398
|
+
onEvent?: (event: ExecLogEvent) => void;
|
|
399
|
+
signal?: AbortSignal;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
export type ExecLogResult = {
|
|
403
|
+
/** Concrete exec ID returned by the daemon, including when an alias was requested. */
|
|
404
|
+
execId: string;
|
|
405
|
+
status: 'RUNNING' | 'SUCCEEDED' | 'FAILED' | 'CANCELLED';
|
|
406
|
+
exitCode?: number;
|
|
407
|
+
};
|
|
408
|
+
|
|
386
409
|
type ExitListener = (code: number) => void;
|
|
387
410
|
|
|
388
411
|
/**
|
|
@@ -418,6 +441,195 @@ export class ReadableStream {
|
|
|
418
441
|
}
|
|
419
442
|
}
|
|
420
443
|
|
|
444
|
+
type FollowExecEventStreamOptions = {
|
|
445
|
+
eventsUrl: string;
|
|
446
|
+
token: string;
|
|
447
|
+
signal?: AbortSignal;
|
|
448
|
+
abortError: () => Error;
|
|
449
|
+
onEvent: (event: ExecLogEvent) => void;
|
|
450
|
+
onStreamDeadSince?: (since: number) => void;
|
|
451
|
+
failOnClientError?: boolean;
|
|
452
|
+
log?: ExecOptions['log'];
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
/** Follow execution events until exitCode, with bounded retries and replay deduplication. */
|
|
456
|
+
function followExecEventStream(options: FollowExecEventStreamOptions): {
|
|
457
|
+
connection: EventSourceClient | null;
|
|
458
|
+
result: Promise<number>;
|
|
459
|
+
} {
|
|
460
|
+
let connection: EventSourceClient | null = null;
|
|
461
|
+
const { eventsUrl, signal } = options;
|
|
462
|
+
const result = new Promise<number>((resolve, reject) => {
|
|
463
|
+
if (signal?.aborted) {
|
|
464
|
+
reject(options.abortError());
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
// Settle-once pair. Both paths close the source: the EventSource
|
|
468
|
+
// reconnects on its own otherwise, even after the promise is done.
|
|
469
|
+
let settled = false;
|
|
470
|
+
let lastSeenEventId = -1;
|
|
471
|
+
let streamDeadSince = 0;
|
|
472
|
+
let eventSource: EventSourceClient | null = null;
|
|
473
|
+
let connectedAt = 0;
|
|
474
|
+
let lastCycleAt = 0;
|
|
475
|
+
let lastCycleMono = 0;
|
|
476
|
+
let proofOfLifeThisCycle = false;
|
|
477
|
+
let cleanResponseThisCycle = false;
|
|
478
|
+
let lastStreamError: Error | undefined;
|
|
479
|
+
const cleanup = () => {
|
|
480
|
+
eventSource?.close();
|
|
481
|
+
signal?.removeEventListener('abort', onAbort);
|
|
482
|
+
};
|
|
483
|
+
const succeed = (exitCode: number) => {
|
|
484
|
+
if (settled) return;
|
|
485
|
+
settled = true;
|
|
486
|
+
cleanup();
|
|
487
|
+
resolve(exitCode);
|
|
488
|
+
};
|
|
489
|
+
const fail = (error: unknown) => {
|
|
490
|
+
if (settled) return;
|
|
491
|
+
settled = true;
|
|
492
|
+
cleanup();
|
|
493
|
+
reject(error);
|
|
494
|
+
};
|
|
495
|
+
const onAbort = () => fail(options.abortError());
|
|
496
|
+
|
|
497
|
+
// Stream health is judged at the fetch surface, where the response
|
|
498
|
+
// status is visible; the library fires onConnect for error responses
|
|
499
|
+
// too, so an error page held open must not read as a healthy
|
|
500
|
+
// connection. An HTTP 204 makes the EventSource close for good
|
|
501
|
+
// without reporting anything, so it must fail here. A rejected fetch
|
|
502
|
+
// never reaches onDisconnect (the hazard sseFetch exists for):
|
|
503
|
+
// capture it for the give-up message and let the clock decide, so
|
|
504
|
+
// one transient refusal does not kill a live build.
|
|
505
|
+
const fetchWithStreamPolicy: Fetch = async (input, init) => {
|
|
506
|
+
const response = await nodeProxyTransport.fetch(input, init);
|
|
507
|
+
if (response.status === 204) {
|
|
508
|
+
fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
|
|
509
|
+
} else if (
|
|
510
|
+
options.failOnClientError &&
|
|
511
|
+
response.status >= 400 &&
|
|
512
|
+
response.status < 500 &&
|
|
513
|
+
response.status !== 408 &&
|
|
514
|
+
response.status !== 429
|
|
515
|
+
) {
|
|
516
|
+
fail(
|
|
517
|
+
directInstanceHttpError('GET exec logs', response.status, await response.text(), response.headers),
|
|
518
|
+
);
|
|
519
|
+
} else if (!response.ok) {
|
|
520
|
+
lastStreamError = new Error(`server answered HTTP ${response.status}`);
|
|
521
|
+
}
|
|
522
|
+
cleanResponseThisCycle = response.ok;
|
|
523
|
+
return response;
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
eventSource = createEventSource({
|
|
527
|
+
url: eventsUrl,
|
|
528
|
+
fetch: sseFetch(fetchWithStreamPolicy, (err) => {
|
|
529
|
+
lastStreamError = err instanceof Error ? err : new Error(String(err));
|
|
530
|
+
}),
|
|
531
|
+
headers: { Authorization: `Bearer ${options.token}` },
|
|
532
|
+
onConnect: () => {
|
|
533
|
+
connectedAt = Date.now();
|
|
534
|
+
},
|
|
535
|
+
// Comments count as proof of life, so a server-side keepalive
|
|
536
|
+
// works without a client change (onMessage never sees them).
|
|
537
|
+
onComment: () => {
|
|
538
|
+
proofOfLifeThisCycle = true;
|
|
539
|
+
},
|
|
540
|
+
// Fires once per broken cycle, before the retry timer is armed, on
|
|
541
|
+
// both failure paths (request rejected, stream ended). This is
|
|
542
|
+
// where the give-up clock runs.
|
|
543
|
+
onScheduleReconnect: () => {
|
|
544
|
+
if (settled || signal?.aborted) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const now = Date.now();
|
|
548
|
+
const mono = performance.now();
|
|
549
|
+
const livedMs = connectedAt > 0 ? now - connectedAt : 0;
|
|
550
|
+
connectedAt = 0;
|
|
551
|
+
// Date.now() is wall clock: a laptop waking from sleep (or a
|
|
552
|
+
// clock step) would arrive with the whole window already
|
|
553
|
+
// "elapsed" and fail on its first attempt. Sleep is the wall
|
|
554
|
+
// clock advancing while the monotonic clock stands still; a
|
|
555
|
+
// large drift between the two restarts the streak. Long server
|
|
556
|
+
// retry delays and hanging connects advance both clocks equally
|
|
557
|
+
// and keep counting.
|
|
558
|
+
if (streamDeadSince > 0 && lastCycleAt > 0) {
|
|
559
|
+
const wallGapMs = now - lastCycleAt;
|
|
560
|
+
const monoGapMs = mono - lastCycleMono;
|
|
561
|
+
if (wallGapMs - monoGapMs > 30_000) {
|
|
562
|
+
streamDeadSince = now;
|
|
563
|
+
options.onStreamDeadSince?.(streamDeadSince);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
lastCycleAt = now;
|
|
567
|
+
lastCycleMono = mono;
|
|
568
|
+
const healthy =
|
|
569
|
+
proofOfLifeThisCycle || (cleanResponseThisCycle && livedMs >= sseStreamPolicy.healthyConnectionMs);
|
|
570
|
+
proofOfLifeThisCycle = false;
|
|
571
|
+
cleanResponseThisCycle = false;
|
|
572
|
+
if (healthy) {
|
|
573
|
+
streamDeadSince = 0;
|
|
574
|
+
options.onStreamDeadSince?.(0);
|
|
575
|
+
lastStreamError = undefined;
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (streamDeadSince === 0) {
|
|
579
|
+
streamDeadSince = now;
|
|
580
|
+
options.onStreamDeadSince?.(streamDeadSince);
|
|
581
|
+
options.log?.('warn', 'SSE disconnected; reconnecting');
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
if (now - streamDeadSince >= sseStreamPolicy.giveUpAfterMs) {
|
|
585
|
+
const seconds = Math.round((now - streamDeadSince) / 1000);
|
|
586
|
+
const cause = lastStreamError ? `; last error: ${lastStreamError.message}` : '';
|
|
587
|
+
fail(
|
|
588
|
+
new ExecStreamLostError(
|
|
589
|
+
`event stream to ${eventsUrl} kept failing for ${seconds}s without delivering events${cause}; ` +
|
|
590
|
+
'the execution may no longer exist (instance deleted or record expired)',
|
|
591
|
+
),
|
|
592
|
+
);
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
onMessage: (message: EventSourceMessage) => {
|
|
596
|
+
if (settled) return;
|
|
597
|
+
streamDeadSince = 0;
|
|
598
|
+
options.onStreamDeadSince?.(0);
|
|
599
|
+
proofOfLifeThisCycle = true;
|
|
600
|
+
lastStreamError = undefined;
|
|
601
|
+
const eventId = message.id !== undefined ? Number.parseInt(message.id, 10) : NaN;
|
|
602
|
+
if (!Number.isNaN(eventId)) {
|
|
603
|
+
if (eventId <= lastSeenEventId) return;
|
|
604
|
+
lastSeenEventId = eventId;
|
|
605
|
+
}
|
|
606
|
+
try {
|
|
607
|
+
const event: ExecLogEvent = {
|
|
608
|
+
...(message.id !== undefined && { id: message.id }),
|
|
609
|
+
type: message.event || 'message',
|
|
610
|
+
data: typeof message.data === 'string' ? message.data : String(message.data ?? ''),
|
|
611
|
+
};
|
|
612
|
+
options.onEvent(event);
|
|
613
|
+
if (event.type === 'exitCode') {
|
|
614
|
+
const exitCode = parseExitCode(event.data);
|
|
615
|
+
options.log?.('debug', `Execution completed via SSE: exitCode=${exitCode}`);
|
|
616
|
+
succeed(exitCode);
|
|
617
|
+
}
|
|
618
|
+
} catch (error) {
|
|
619
|
+
fail(error);
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
connection = eventSource;
|
|
624
|
+
if (settled) {
|
|
625
|
+
eventSource.close();
|
|
626
|
+
} else {
|
|
627
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
return { connection, result };
|
|
631
|
+
}
|
|
632
|
+
|
|
421
633
|
/**
|
|
422
634
|
* A ChildProcess-like object similar to Node.js's ChildProcess.
|
|
423
635
|
*
|
|
@@ -464,11 +676,6 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
|
|
|
464
676
|
private playstoreEvent: PlaystoreEvent | null = null;
|
|
465
677
|
private xctestCases: XctestCaseEvent[] = [];
|
|
466
678
|
private xctestSummary: XctestSummaryEvent | null = null;
|
|
467
|
-
// Highest server-assigned event id already dispatched. The server numbers
|
|
468
|
-
// every frame and replays the stream from the start on reconnect; skipping
|
|
469
|
-
// ids at or below this mark drops the replayed prefix for every event type
|
|
470
|
-
// at once, and stays correct if the server ever honors Last-Event-ID.
|
|
471
|
-
private lastSeenEventId = -1;
|
|
472
679
|
private readonly options: ExecOptions;
|
|
473
680
|
private readonly log: (level: 'debug' | 'info' | 'warn' | 'error', msg: string) => void;
|
|
474
681
|
|
|
@@ -743,208 +950,207 @@ export class ExecChildProcess implements PromiseLike<ExecResult> {
|
|
|
743
950
|
* closes the stream for good, or when the give-up clock runs out.
|
|
744
951
|
*/
|
|
745
952
|
private connectSSE(eventsUrl: string): Promise<number> {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
resolve(exitCode);
|
|
771
|
-
};
|
|
772
|
-
const fail = (error: Error) => {
|
|
773
|
-
if (settled) return;
|
|
774
|
-
settled = true;
|
|
775
|
-
cleanup();
|
|
776
|
-
reject(error);
|
|
777
|
-
};
|
|
778
|
-
const onAbort = () => fail(new Error('killed'));
|
|
779
|
-
|
|
780
|
-
// Stream health is judged at the fetch surface, where the response
|
|
781
|
-
// status is visible; the library fires onConnect for error responses
|
|
782
|
-
// too, so an error page held open must not read as a healthy
|
|
783
|
-
// connection. An HTTP 204 makes the EventSource close for good
|
|
784
|
-
// without reporting anything, so it must fail here. A rejected fetch
|
|
785
|
-
// never reaches onDisconnect (the hazard sseFetch exists for):
|
|
786
|
-
// capture it for the give-up message and let the clock decide, so
|
|
787
|
-
// one transient refusal does not kill a live build.
|
|
788
|
-
const fetchWithStreamPolicy: Fetch = async (input, init) => {
|
|
789
|
-
const response = await nodeProxyTransport.fetch(input, init);
|
|
790
|
-
if (response.status === 204) {
|
|
791
|
-
fail(new ExecStreamClosedError(`event stream to ${eventsUrl} was closed by the server (HTTP 204)`));
|
|
792
|
-
} else if (!response.ok) {
|
|
793
|
-
lastStreamError = new Error(`server answered HTTP ${response.status}`);
|
|
794
|
-
}
|
|
795
|
-
cleanResponseThisCycle = response.ok;
|
|
796
|
-
return response;
|
|
797
|
-
};
|
|
798
|
-
|
|
799
|
-
const eventSource = createEventSource({
|
|
800
|
-
url: eventsUrl,
|
|
801
|
-
fetch: sseFetch(fetchWithStreamPolicy, (err) => {
|
|
802
|
-
lastStreamError = err instanceof Error ? err : new Error(String(err));
|
|
803
|
-
}),
|
|
804
|
-
headers: { Authorization: `Bearer ${this.options.token}` },
|
|
805
|
-
onConnect: () => {
|
|
806
|
-
connectedAt = Date.now();
|
|
807
|
-
},
|
|
808
|
-
// Comments count as proof of life, so a server-side keepalive
|
|
809
|
-
// works without a client change (onMessage never sees them).
|
|
810
|
-
onComment: () => {
|
|
811
|
-
proofOfLifeThisCycle = true;
|
|
812
|
-
},
|
|
813
|
-
// Fires once per broken cycle, before the retry timer is armed, on
|
|
814
|
-
// both failure paths (request rejected, stream ended). This is
|
|
815
|
-
// where the give-up clock runs.
|
|
816
|
-
onScheduleReconnect: () => {
|
|
817
|
-
if (settled || this.killed) {
|
|
818
|
-
return;
|
|
819
|
-
}
|
|
820
|
-
const now = Date.now();
|
|
821
|
-
const mono = performance.now();
|
|
822
|
-
const livedMs = connectedAt > 0 ? now - connectedAt : 0;
|
|
823
|
-
connectedAt = 0;
|
|
824
|
-
// Date.now() is wall clock: a laptop waking from sleep (or a
|
|
825
|
-
// clock step) would arrive with the whole window already
|
|
826
|
-
// "elapsed" and fail on its first attempt. Sleep is the wall
|
|
827
|
-
// clock advancing while the monotonic clock stands still; a
|
|
828
|
-
// large drift between the two restarts the streak. Long server
|
|
829
|
-
// retry delays and hanging connects advance both clocks equally
|
|
830
|
-
// and keep counting.
|
|
831
|
-
if (this.streamDeadSince > 0 && lastCycleAt > 0) {
|
|
832
|
-
const wallGapMs = now - lastCycleAt;
|
|
833
|
-
const monoGapMs = mono - lastCycleMono;
|
|
834
|
-
if (wallGapMs - monoGapMs > 30_000) {
|
|
835
|
-
this.streamDeadSince = now;
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
lastCycleAt = now;
|
|
839
|
-
lastCycleMono = mono;
|
|
840
|
-
const healthy =
|
|
841
|
-
proofOfLifeThisCycle ||
|
|
842
|
-
(cleanResponseThisCycle && livedMs >= sseStreamPolicy.healthyConnectionMs);
|
|
843
|
-
proofOfLifeThisCycle = false;
|
|
844
|
-
cleanResponseThisCycle = false;
|
|
845
|
-
if (healthy) {
|
|
846
|
-
this.streamDeadSince = 0;
|
|
847
|
-
lastStreamError = undefined;
|
|
848
|
-
return;
|
|
953
|
+
const stream = followExecEventStream({
|
|
954
|
+
eventsUrl,
|
|
955
|
+
token: this.options.token,
|
|
956
|
+
signal: this.abortController.signal,
|
|
957
|
+
abortError: () => new Error('killed'),
|
|
958
|
+
log: this.log,
|
|
959
|
+
onStreamDeadSince: (since) => {
|
|
960
|
+
this.streamDeadSince = since;
|
|
961
|
+
},
|
|
962
|
+
onEvent: ({ type: eventType, data }) => {
|
|
963
|
+
if (eventType === 'command') {
|
|
964
|
+
this.command.emit('data', data);
|
|
965
|
+
} else if (eventType === 'stdout') {
|
|
966
|
+
this.stdout.emit('data', data);
|
|
967
|
+
} else if (eventType === 'stderr') {
|
|
968
|
+
this.stderr.emit('data', data);
|
|
969
|
+
} else if (eventType === 'testflight') {
|
|
970
|
+
try {
|
|
971
|
+
this.appStoreEvent = JSON.parse(data) as AppStoreEvent;
|
|
972
|
+
} catch {
|
|
973
|
+
// The wire event itself proves the server ran the App Store upload,
|
|
974
|
+
// so never let a payload glitch look like a missing feature.
|
|
975
|
+
this.appStoreEvent = { state: 'unknown' };
|
|
976
|
+
this.log('warn', `SSE testflight event has invalid data: ${data}`);
|
|
849
977
|
}
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
this.
|
|
853
|
-
|
|
978
|
+
} else if (eventType === 'playstore') {
|
|
979
|
+
try {
|
|
980
|
+
this.playstoreEvent = JSON.parse(data) as PlaystoreEvent;
|
|
981
|
+
} catch {
|
|
982
|
+
// Same contract as the App Store upload event: its presence proves the server
|
|
983
|
+
// ran the Play Store step, so a payload glitch must not
|
|
984
|
+
// read as a missing feature.
|
|
985
|
+
this.playstoreEvent = { state: 'unknown' };
|
|
986
|
+
this.log('warn', `SSE playstore event has invalid data: ${data}`);
|
|
854
987
|
}
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
lastStreamError = undefined;
|
|
870
|
-
const eventId = message.id !== undefined ? parseInt(message.id, 10) : NaN;
|
|
871
|
-
if (!Number.isNaN(eventId)) {
|
|
872
|
-
if (eventId <= this.lastSeenEventId) {
|
|
873
|
-
// A reconnect replays the stream from the start; this frame was
|
|
874
|
-
// already dispatched on an earlier connection.
|
|
875
|
-
return;
|
|
988
|
+
} else if (eventType === 'xctest') {
|
|
989
|
+
let event: XctestEvent | undefined;
|
|
990
|
+
try {
|
|
991
|
+
const parsed = JSON.parse(data) as XctestEvent;
|
|
992
|
+
if (parsed?.type === 'case') {
|
|
993
|
+
this.xctestCases.push(parsed);
|
|
994
|
+
event = parsed;
|
|
995
|
+
} else if (parsed?.type === 'summary') {
|
|
996
|
+
this.xctestSummary = parsed;
|
|
997
|
+
event = parsed;
|
|
998
|
+
} else {
|
|
999
|
+
// The simulator owns the frame vocabulary and may grow it; the
|
|
1000
|
+
// callback's contract is cases and summaries only.
|
|
1001
|
+
this.log('debug', `SSE xctest event of unknown type ignored: ${data}`);
|
|
876
1002
|
}
|
|
877
|
-
|
|
1003
|
+
} catch {
|
|
1004
|
+
this.log('warn', `SSE xctest event has invalid data: ${data}`);
|
|
878
1005
|
}
|
|
879
|
-
|
|
880
|
-
const eventType = message.event;
|
|
881
|
-
if (eventType === 'command') {
|
|
882
|
-
this.command.emit('data', data);
|
|
883
|
-
} else if (eventType === 'stdout') {
|
|
884
|
-
this.stdout.emit('data', data);
|
|
885
|
-
} else if (eventType === 'stderr') {
|
|
886
|
-
this.stderr.emit('data', data);
|
|
887
|
-
} else if (eventType === 'testflight') {
|
|
888
|
-
try {
|
|
889
|
-
this.appStoreEvent = JSON.parse(data) as AppStoreEvent;
|
|
890
|
-
} catch {
|
|
891
|
-
// The wire event itself proves the server ran the App Store upload,
|
|
892
|
-
// so never let a payload glitch look like a missing feature.
|
|
893
|
-
this.appStoreEvent = { state: 'unknown' };
|
|
894
|
-
this.log('warn', `SSE testflight event has invalid data: ${data}`);
|
|
895
|
-
}
|
|
896
|
-
} else if (eventType === 'playstore') {
|
|
897
|
-
try {
|
|
898
|
-
this.playstoreEvent = JSON.parse(data) as PlaystoreEvent;
|
|
899
|
-
} catch {
|
|
900
|
-
// Same contract as the App Store upload event: its presence proves the server
|
|
901
|
-
// ran the Play Store step, so a payload glitch must not
|
|
902
|
-
// read as a missing feature.
|
|
903
|
-
this.playstoreEvent = { state: 'unknown' };
|
|
904
|
-
this.log('warn', `SSE playstore event has invalid data: ${data}`);
|
|
905
|
-
}
|
|
906
|
-
} else if (eventType === 'xctest') {
|
|
907
|
-
let event: XctestEvent | undefined;
|
|
1006
|
+
if (event) {
|
|
908
1007
|
try {
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
this.xctestSummary = parsed;
|
|
915
|
-
event = parsed;
|
|
916
|
-
} else {
|
|
917
|
-
// The simulator owns the frame vocabulary and may grow it; the
|
|
918
|
-
// callback's contract is cases and summaries only.
|
|
919
|
-
this.log('debug', `SSE xctest event of unknown type ignored: ${data}`);
|
|
920
|
-
}
|
|
921
|
-
} catch {
|
|
922
|
-
this.log('warn', `SSE xctest event has invalid data: ${data}`);
|
|
923
|
-
}
|
|
924
|
-
if (event) {
|
|
925
|
-
try {
|
|
926
|
-
this.options.onXctestEvent?.(event);
|
|
927
|
-
} catch (err) {
|
|
928
|
-
// The consumer's callback, not the frame: the run must not be
|
|
929
|
-
// derailed and the log must not blame the payload.
|
|
930
|
-
this.log('warn', `onXctestEvent callback threw: ${String(err)}`);
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
} else if (eventType === 'exitCode') {
|
|
934
|
-
const exitCode = parseInt(data, 10);
|
|
935
|
-
if (Number.isNaN(exitCode)) {
|
|
936
|
-
this.log('warn', `SSE exitCode event has invalid data: ${data}`);
|
|
937
|
-
return;
|
|
1008
|
+
this.options.onXctestEvent?.(event);
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
// The consumer's callback, not the frame: the run must not be
|
|
1011
|
+
// derailed and the log must not blame the payload.
|
|
1012
|
+
this.log('warn', `onXctestEvent callback threw: ${String(err)}`);
|
|
938
1013
|
}
|
|
939
|
-
this.log('debug', `Execution completed via SSE: exitCode=${exitCode}`);
|
|
940
|
-
succeed(exitCode);
|
|
941
1014
|
}
|
|
942
|
-
}
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
|
|
1015
|
+
}
|
|
1016
|
+
},
|
|
1017
|
+
});
|
|
1018
|
+
this.sseConnection = stream.connection;
|
|
1019
|
+
return stream.result;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/**
|
|
1024
|
+
* Replay logs for an existing execution, optionally following it to completion.
|
|
1025
|
+
* Snapshot mode uses a finite SSE response; follow mode consumes the same
|
|
1026
|
+
* replayable stream until its terminal exitCode event.
|
|
1027
|
+
*/
|
|
1028
|
+
export async function observeExecLogs(
|
|
1029
|
+
execId: string,
|
|
1030
|
+
options: ExecOptions & ExecLogOptions,
|
|
1031
|
+
): Promise<ExecLogResult> {
|
|
1032
|
+
if (!execId.trim()) {
|
|
1033
|
+
throw new Error('execId must not be empty');
|
|
1034
|
+
}
|
|
1035
|
+
const follow = options.follow ?? false;
|
|
1036
|
+
// Resolve mutable aliases once so a reconnect cannot switch to another build.
|
|
1037
|
+
if (follow && (execId === 'active' || execId === 'latest')) {
|
|
1038
|
+
const snapshotEvents: ExecLogEvent[] = [];
|
|
1039
|
+
const snapshot = await observeExecLogs(execId, {
|
|
1040
|
+
...options,
|
|
1041
|
+
follow: false,
|
|
1042
|
+
onEvent: (event) => snapshotEvents.push(event),
|
|
946
1043
|
});
|
|
1044
|
+
if (snapshot.status !== 'RUNNING') {
|
|
1045
|
+
for (const event of snapshotEvents) options.onEvent?.(event);
|
|
1046
|
+
return snapshot;
|
|
1047
|
+
}
|
|
1048
|
+
if (snapshot.execId === execId) {
|
|
1049
|
+
throw new Error(`The server did not resolve the ${execId} build alias.`);
|
|
1050
|
+
}
|
|
1051
|
+
return observeExecLogs(snapshot.execId, options);
|
|
947
1052
|
}
|
|
1053
|
+
let apiUrlEnd = options.apiUrl.length;
|
|
1054
|
+
while (options.apiUrl[apiUrlEnd - 1] === '/') apiUrlEnd--;
|
|
1055
|
+
const eventsUrl = new URL(
|
|
1056
|
+
`${options.apiUrl.slice(0, apiUrlEnd)}/exec/${encodeURIComponent(execId)}/events`,
|
|
1057
|
+
);
|
|
1058
|
+
eventsUrl.searchParams.set('follow', String(follow));
|
|
1059
|
+
|
|
1060
|
+
if (!follow) {
|
|
1061
|
+
const response = await nodeProxyTransport.fetch(eventsUrl, {
|
|
1062
|
+
method: 'GET',
|
|
1063
|
+
headers: { Authorization: `Bearer ${options.token}` },
|
|
1064
|
+
...(options.signal && { signal: options.signal }),
|
|
1065
|
+
});
|
|
1066
|
+
if (!response.ok) {
|
|
1067
|
+
const text = await response.text();
|
|
1068
|
+
throw directInstanceHttpError('GET exec logs', response.status, text, response.headers);
|
|
1069
|
+
}
|
|
1070
|
+
const events = parseSSE(await response.text());
|
|
1071
|
+
let exitCode: number | undefined;
|
|
1072
|
+
let resolvedExecId = execId;
|
|
1073
|
+
for (const event of events) {
|
|
1074
|
+
resolvedExecId = execIdFromMeta(event, resolvedExecId);
|
|
1075
|
+
options.onEvent?.(event);
|
|
1076
|
+
if (event.type === 'exitCode') {
|
|
1077
|
+
exitCode = parseExitCode(event.data);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
return buildExecLogResult(resolvedExecId, exitCode);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
let resolvedExecId = execId;
|
|
1084
|
+
const stream = followExecEventStream({
|
|
1085
|
+
eventsUrl: eventsUrl.toString(),
|
|
1086
|
+
token: options.token,
|
|
1087
|
+
...(options.signal && { signal: options.signal }),
|
|
1088
|
+
failOnClientError: true,
|
|
1089
|
+
log: options.log,
|
|
1090
|
+
abortError: () => new Error(`exec log stream for ${execId} was aborted`),
|
|
1091
|
+
onEvent: (event) => {
|
|
1092
|
+
resolvedExecId = execIdFromMeta(event, resolvedExecId);
|
|
1093
|
+
options.onEvent?.(event);
|
|
1094
|
+
},
|
|
1095
|
+
});
|
|
1096
|
+
const exitCode = await stream.result;
|
|
1097
|
+
return buildExecLogResult(resolvedExecId, exitCode);
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function parseSSE(body: string): ExecLogEvent[] {
|
|
1101
|
+
const normalized = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
1102
|
+
const events: ExecLogEvent[] = [];
|
|
1103
|
+
for (const block of normalized.split('\n\n')) {
|
|
1104
|
+
if (!block.trim()) continue;
|
|
1105
|
+
let id: string | undefined;
|
|
1106
|
+
let type = 'message';
|
|
1107
|
+
const data: string[] = [];
|
|
1108
|
+
for (const line of block.split('\n')) {
|
|
1109
|
+
if (line.startsWith('id:')) {
|
|
1110
|
+
id = line.slice(3).trimStart();
|
|
1111
|
+
} else if (line.startsWith('event:')) {
|
|
1112
|
+
type = line.slice(6).trimStart();
|
|
1113
|
+
} else if (line.startsWith('data:')) {
|
|
1114
|
+
data.push(line.slice(5).replace(/^ /, ''));
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (data.length > 0 || type !== 'message' || id !== undefined) {
|
|
1118
|
+
events.push({ ...(id !== undefined && { id }), type, data: data.join('\n') });
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return events;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function parseExitCode(data: string): number {
|
|
1125
|
+
const exitCode = Number.parseInt(data, 10);
|
|
1126
|
+
if (Number.isNaN(exitCode)) {
|
|
1127
|
+
throw new Error(`invalid exec exit code: ${data}`);
|
|
1128
|
+
}
|
|
1129
|
+
return exitCode;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
function execIdFromMeta(event: ExecLogEvent, fallback: string): string {
|
|
1133
|
+
if (event.type !== 'meta') return fallback;
|
|
1134
|
+
try {
|
|
1135
|
+
const meta = JSON.parse(event.data) as { id?: unknown };
|
|
1136
|
+
return typeof meta.id === 'string' && meta.id ? meta.id : fallback;
|
|
1137
|
+
} catch {
|
|
1138
|
+
return fallback;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
function buildExecLogResult(execId: string, exitCode: number | undefined): ExecLogResult {
|
|
1143
|
+
if (exitCode === undefined) {
|
|
1144
|
+
return { execId, status: 'RUNNING' };
|
|
1145
|
+
}
|
|
1146
|
+
return {
|
|
1147
|
+
execId,
|
|
1148
|
+
exitCode,
|
|
1149
|
+
status:
|
|
1150
|
+
exitCode === 0 ? 'SUCCEEDED'
|
|
1151
|
+
: exitCode === -1 ? 'CANCELLED'
|
|
1152
|
+
: 'FAILED',
|
|
1153
|
+
};
|
|
948
1154
|
}
|
|
949
1155
|
|
|
950
1156
|
/**
|