@agentshouse/kit 0.2.1-c23b83622c698 → 0.2.1-c30728be20485
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/dist/agents.js +4 -0
- package/dist/api.js +30 -2
- package/dist/bootstrap.json +4 -4
- package/dist/bridge.js +25 -17
- package/dist/clis.js +2 -0
- package/dist/copies.js +3 -5
- package/dist/end.js +8 -0
- package/dist/refusals.js +12 -0
- package/dist/secret-input.js +3 -2
- package/dist/stream.js +29 -4
- package/dist/transcription.js +2 -0
- package/dist/update.js +2 -0
- package/package.json +1 -1
package/dist/agents.js
CHANGED
|
@@ -48,12 +48,14 @@ async function models(kind, adapter, known) {
|
|
|
48
48
|
}
|
|
49
49
|
async function signedIn(kind, cli, known) {
|
|
50
50
|
const probe = CLIS[kind].signedIn;
|
|
51
|
+
// A sign-in check that has not answered in half a minute counts as signed out rather than stalling the report.
|
|
51
52
|
if ('command' in probe)
|
|
52
53
|
return (await run(cli, probe.command, 30_000)).status === 0;
|
|
53
54
|
return (await offer(kind, cli, known)).signed_in;
|
|
54
55
|
}
|
|
55
56
|
async function offer(kind, cli, known) {
|
|
56
57
|
const probe = CLIS[kind].signedIn;
|
|
58
|
+
// A sign-in check that has not answered in half a minute counts as signed out rather than stalling the report.
|
|
57
59
|
if ('command' in probe && (await run(cli, probe.command, 30_000)).status !== 0)
|
|
58
60
|
return SIGNED_OUT;
|
|
59
61
|
let adapter;
|
|
@@ -96,10 +98,12 @@ function tooOld(kind, release) {
|
|
|
96
98
|
return `${cli.bin} ${release} is older than ${cli.minimum}, the oldest this Kit runs`;
|
|
97
99
|
}
|
|
98
100
|
async function sshHostKey() {
|
|
101
|
+
// Listing a local host key takes a moment, so ten seconds only bounds a stuck ssh-keygen.
|
|
99
102
|
const listed = await run('ssh-keygen', ['-l', '-E', 'sha256', '-f', '/etc/ssh/ssh_host_ed25519_key.pub'], 10_000);
|
|
100
103
|
return listed.status === 0 ? (/^\d+ (SHA256:\S+)/.exec(listed.output)?.[1] ?? null) : null;
|
|
101
104
|
}
|
|
102
105
|
async function operatingSystem() {
|
|
106
|
+
// sw_vers answers at once, so ten seconds only bounds a stuck call.
|
|
103
107
|
if (process.platform === 'darwin')
|
|
104
108
|
return `macOS ${(await run('sw_vers', ['-productVersion'], 10_000)).output.trim()}`;
|
|
105
109
|
const release = await readFile('/etc/os-release', 'utf8');
|
package/dist/api.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
import { end, SIGN_IN_AGAIN } from './end.js';
|
|
1
3
|
import { readEnrolment } from './home.js';
|
|
4
|
+
import { retryable } from './refusals.js';
|
|
2
5
|
import { relayed } from './relay.js';
|
|
3
6
|
export class HouseRefusal extends Error {
|
|
4
7
|
status;
|
|
@@ -9,9 +12,26 @@ export class HouseRefusal extends Error {
|
|
|
9
12
|
this.text = text;
|
|
10
13
|
}
|
|
11
14
|
}
|
|
15
|
+
export function finalRefusal(error) {
|
|
16
|
+
return error instanceof HouseRefusal && retryable(error.text) === false;
|
|
17
|
+
}
|
|
12
18
|
export function retryDelay(attempt) {
|
|
19
|
+
// Full jitter, doubling from one second to a 30-second cap, spreads the Kits one outage dropped and retries within half a minute of House's return.
|
|
13
20
|
return Math.random() * Math.min(30_000, 1000 * 2 ** attempt);
|
|
14
21
|
}
|
|
22
|
+
export async function retrying(sent, signal) {
|
|
23
|
+
for (let attempt = 0;; attempt++) {
|
|
24
|
+
try {
|
|
25
|
+
return await sent();
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (!(error instanceof HouseRefusal) || retryable(error.text) !== true)
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
// A request House refused as retryable is sent again on the jittered curve.
|
|
32
|
+
await delay(retryDelay(attempt), undefined, { signal });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
15
35
|
export async function post(house, path, body, credential, signal) {
|
|
16
36
|
const answer = await fetch(relayed(new URL(path, house)), {
|
|
17
37
|
method: 'POST',
|
|
@@ -29,7 +49,14 @@ export async function post(house, path, body, credential, signal) {
|
|
|
29
49
|
}
|
|
30
50
|
async function enrolledPost(path, body, signal) {
|
|
31
51
|
const { house, credential } = (await readEnrolment());
|
|
32
|
-
|
|
52
|
+
try {
|
|
53
|
+
return await post(house, path, body, credential, signal);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof HouseRefusal && error.status === 401)
|
|
57
|
+
end(SIGN_IN_AGAIN);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
33
60
|
}
|
|
34
61
|
export const house = {
|
|
35
62
|
post: enrolledPost,
|
|
@@ -39,9 +66,10 @@ export const house = {
|
|
|
39
66
|
return await enrolledPost(path, body);
|
|
40
67
|
}
|
|
41
68
|
catch (error) {
|
|
42
|
-
if (error
|
|
69
|
+
if (finalRefusal(error))
|
|
43
70
|
throw error;
|
|
44
71
|
process.stderr.write(`kit: ${path} did not reach House: ${error.message}\n`);
|
|
72
|
+
// A delivery House did not take is sent again on the jittered curve for as long as it is still current.
|
|
45
73
|
await new Promise((resolve) => setTimeout(resolve, retryDelay(attempt)));
|
|
46
74
|
if (!current())
|
|
47
75
|
throw error;
|
package/dist/bootstrap.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
|
-
"version": "0.2.1-
|
|
3
|
+
"version": "0.2.1-c30728be20485",
|
|
4
4
|
"hosts": {
|
|
5
5
|
"macos": {
|
|
6
6
|
"asset": {
|
|
7
7
|
"name": "connect-linux.sh",
|
|
8
|
-
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-
|
|
8
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-c30728be20485/connect-linux.sh"
|
|
9
9
|
}
|
|
10
10
|
},
|
|
11
11
|
"linux": {
|
|
12
12
|
"asset": {
|
|
13
13
|
"name": "connect-linux.sh",
|
|
14
|
-
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-
|
|
14
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-c30728be20485/connect-linux.sh"
|
|
15
15
|
}
|
|
16
16
|
},
|
|
17
17
|
"windows": {
|
|
18
18
|
"asset": {
|
|
19
19
|
"name": "connect-windows.ps1",
|
|
20
|
-
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-
|
|
20
|
+
"url": "https://github.com/agentshouse/kit/releases/download/v0.2.1-c30728be20485/connect-windows.ps1"
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
23
|
}
|
package/dist/bridge.js
CHANGED
|
@@ -6,13 +6,13 @@ import { createServer, request as httpRequest } from 'node:http';
|
|
|
6
6
|
import { request as httpsRequest } from 'node:https';
|
|
7
7
|
import { delimiter, dirname, join } from 'node:path';
|
|
8
8
|
import { parse } from 'yaml';
|
|
9
|
-
import { HouseRefusal, retryDelay } from './api.js';
|
|
9
|
+
import { finalRefusal, HouseRefusal, retryDelay, retrying } from './api.js';
|
|
10
10
|
import { loginPath } from './clis.js';
|
|
11
11
|
import { changedPaths, } from './copies.js';
|
|
12
12
|
import { kitHome, readEnrolment } from './home.js';
|
|
13
13
|
import { callHouse, mcpBody, rpc, UNREACHABLE } from './mcp.js';
|
|
14
14
|
import { operationId, uuidOf } from './operation.js';
|
|
15
|
-
import { refusalOf } from './refusals.js';
|
|
15
|
+
import { refusalOf, retryable } from './refusals.js';
|
|
16
16
|
import { relayed } from './relay.js';
|
|
17
17
|
const MCP_REQUEST_BYTES = 4 * 1024 * 1024;
|
|
18
18
|
const ATTACHMENTS = '/kit/attachments/upload';
|
|
@@ -40,8 +40,6 @@ function refusalIn(text, status) {
|
|
|
40
40
|
return refusalOf(text) ?? { code: '', text: text === '' ? `House refused it with ${status}` : text, conflicts: [] };
|
|
41
41
|
}
|
|
42
42
|
function answered(forwarded) {
|
|
43
|
-
if (forwarded.status >= 500)
|
|
44
|
-
throw new HouseRefusal('/', forwarded.status, forwarded.text);
|
|
45
43
|
if (forwarded.status !== 200)
|
|
46
44
|
return { refused: refusalIn(forwarded.text, forwarded.status) };
|
|
47
45
|
const answer = JSON.parse(forwarded.text);
|
|
@@ -65,8 +63,8 @@ function pendingSeconds(forwarded) {
|
|
|
65
63
|
function resendAfter(forwarded, attempt) {
|
|
66
64
|
if (forwarded === null)
|
|
67
65
|
return retryDelay(attempt);
|
|
68
|
-
if (forwarded.status
|
|
69
|
-
return
|
|
66
|
+
if (forwarded.status !== 200)
|
|
67
|
+
return retryable(forwarded.text) === false ? null : retryDelay(attempt);
|
|
70
68
|
const seconds = pendingSeconds(forwarded);
|
|
71
69
|
return seconds === null ? null : seconds * 1000;
|
|
72
70
|
}
|
|
@@ -105,6 +103,7 @@ export async function openBridge(house, copies, conversationId, signal) {
|
|
|
105
103
|
const after = resendAfter(forwarded, attempt);
|
|
106
104
|
if (after === null)
|
|
107
105
|
return forwarded;
|
|
106
|
+
// A write House did not settle is sent again on the jittered curve, or after the wait House names for a pending one.
|
|
108
107
|
await delay(after, undefined, { signal: closed.signal });
|
|
109
108
|
}
|
|
110
109
|
};
|
|
@@ -119,17 +118,26 @@ export async function openBridge(house, copies, conversationId, signal) {
|
|
|
119
118
|
if ('refused' in prepared)
|
|
120
119
|
return prepared;
|
|
121
120
|
const { transfer } = parse(prepared.text);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
121
|
+
let text;
|
|
122
|
+
try {
|
|
123
|
+
text = await retrying(async () => {
|
|
124
|
+
const posted = await fetch(relayed(transfer.url), {
|
|
125
|
+
method: 'POST',
|
|
126
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' },
|
|
127
|
+
body: Buffer.concat([Buffer.from(`${transfer.operation}\n`), bytes]),
|
|
128
|
+
signal: closed.signal,
|
|
129
|
+
});
|
|
130
|
+
const answer = await posted.text();
|
|
131
|
+
if (!posted.ok)
|
|
132
|
+
throw new HouseRefusal('/', posted.status, answer);
|
|
133
|
+
return answer;
|
|
134
|
+
}, closed.signal);
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (!finalRefusal(error))
|
|
138
|
+
throw error;
|
|
139
|
+
return { refused: refusalIn(error.text, error.status) };
|
|
140
|
+
}
|
|
133
141
|
const held = JSON.parse(text).transfer;
|
|
134
142
|
return { url: held.url, operation: held.operation, bytes: bytes.length, sha256 };
|
|
135
143
|
};
|
package/dist/clis.js
CHANGED
|
@@ -105,7 +105,9 @@ const KIT_PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.me
|
|
|
105
105
|
export const KIT_VERSION = KIT_PACKAGE.version;
|
|
106
106
|
const PROXY = /^https?_proxy$/i;
|
|
107
107
|
const VERSION = /\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?/;
|
|
108
|
+
// An install still running after fifteen minutes is ended and reported as failed, so a hung download cannot hold its CLI's report.
|
|
108
109
|
const INSTALL_MS = 15 * 60_000;
|
|
110
|
+
// A login shell or a version read is ended after half a minute, so a profile that waits for input cannot stall the report.
|
|
109
111
|
const READ_MS = 30_000;
|
|
110
112
|
const CAUSE_CHARACTERS = 4000;
|
|
111
113
|
function prefix(kind) {
|
package/dist/copies.js
CHANGED
|
@@ -2,7 +2,7 @@ import { parse } from '@agentshouse/mdmodel';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { mkdir, readFile, readdir, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
|
-
import { HouseRefusal } from './api.js';
|
|
5
|
+
import { finalRefusal, HouseRefusal, retrying } from './api.js';
|
|
6
6
|
import { blobId, blobText, git, HOUSE_REF, importCommits, localCopies, revision, run, succeeded, tree, } from './git.js';
|
|
7
7
|
import { kitHome } from './home.js';
|
|
8
8
|
import { operationId } from './operation.js';
|
|
@@ -11,7 +11,6 @@ import { changed } from './translate.js';
|
|
|
11
11
|
import { relayed } from './relay.js';
|
|
12
12
|
const BRANCH = 'main';
|
|
13
13
|
const PROVENANCE = '_provenance';
|
|
14
|
-
const REPLICA_RETRY_MS = 30_000;
|
|
15
14
|
const IDENTITY = { GIT_COMMITTER_NAME: 'House Kit', GIT_COMMITTER_EMAIL: 'house-kit@localhost' };
|
|
16
15
|
function causeOf(error) {
|
|
17
16
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -161,11 +160,11 @@ export class LocalCopies {
|
|
|
161
160
|
key: 'owner',
|
|
162
161
|
submit: async (operation, changes) => {
|
|
163
162
|
try {
|
|
164
|
-
const outcome = await house.post('/kit/door/batch', { operation_id: operation, changes });
|
|
163
|
+
const outcome = await retrying(() => house.post('/kit/door/batch', { operation_id: operation, changes }));
|
|
165
164
|
return { accepted: outcome.contents };
|
|
166
165
|
}
|
|
167
166
|
catch (error) {
|
|
168
|
-
if (!(error
|
|
167
|
+
if (!finalRefusal(error))
|
|
169
168
|
throw error;
|
|
170
169
|
return {
|
|
171
170
|
refused: refusalOf(error.text) ?? { code: '', text: `House refused it with ${error.status}`, conflicts: [] },
|
|
@@ -439,7 +438,6 @@ export class LocalCopies {
|
|
|
439
438
|
return this.received(copy);
|
|
440
439
|
if (code !== 'replica_behind')
|
|
441
440
|
throw error;
|
|
442
|
-
setTimeout(() => this.deliver(copy.key), REPLICA_RETRY_MS).unref();
|
|
443
441
|
return copy;
|
|
444
442
|
}
|
|
445
443
|
const view = answer.authorities.find((entry) => entry.authority === copy.room_ref);
|
package/dist/end.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { killDescendants } from './acp.js';
|
|
2
|
+
export const REPLACED = 'another Kit started for this Environment';
|
|
3
|
+
export const SIGN_IN_AGAIN = 'sign in again';
|
|
4
|
+
export function end(reason) {
|
|
5
|
+
process.stderr.write(`kit: ${reason}\n`);
|
|
6
|
+
killDescendants();
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
package/dist/refusals.js
CHANGED
|
@@ -21,6 +21,18 @@ function spoken(text) {
|
|
|
21
21
|
const detail = said.indexOf('\n');
|
|
22
22
|
return { code: head[1], text: said, conflicts: detail === -1 ? [] : conflictCodes(said.slice(detail + 1)) };
|
|
23
23
|
}
|
|
24
|
+
export function retryable(text) {
|
|
25
|
+
let body;
|
|
26
|
+
try {
|
|
27
|
+
body = JSON.parse(text);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const error = body?.error;
|
|
33
|
+
const verdict = error?.retryable ?? error?.data?.retryable;
|
|
34
|
+
return typeof verdict === 'boolean' ? verdict : null;
|
|
35
|
+
}
|
|
24
36
|
export function refusalOf(text) {
|
|
25
37
|
let body;
|
|
26
38
|
try {
|
package/dist/secret-input.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { finalRefusal, retryDelay } from './api.js';
|
|
2
2
|
export async function holdSecretInput(house, subject, steps, waiting) {
|
|
3
3
|
for (let failures = 0; waiting();) {
|
|
4
4
|
try {
|
|
@@ -10,9 +10,10 @@ export async function holdSecretInput(house, subject, steps, waiting) {
|
|
|
10
10
|
failures = 0;
|
|
11
11
|
}
|
|
12
12
|
catch (error) {
|
|
13
|
-
if (error
|
|
13
|
+
if (finalRefusal(error))
|
|
14
14
|
return null;
|
|
15
15
|
process.stderr.write(`kit: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
16
|
+
// A wait that did not reach House is held again on the jittered curve for as long as its asker still waits.
|
|
16
17
|
await new Promise((resolve) => setTimeout(resolve, retryDelay(failures++)));
|
|
17
18
|
}
|
|
18
19
|
}
|
package/dist/stream.js
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { subscribe } from 'node:diagnostics_channel';
|
|
2
|
+
import { finalRefusal, HouseRefusal, retryDelay } from './api.js';
|
|
3
|
+
import { end, REPLACED, SIGN_IN_AGAIN } from './end.js';
|
|
2
4
|
import { readEnrolment } from './home.js';
|
|
3
5
|
import { relayed } from './relay.js';
|
|
6
|
+
const PATH = '/kit/stream';
|
|
4
7
|
const SUBPROTOCOL = 'house.kit.stream.1';
|
|
8
|
+
const REFUSAL_HEADER = 'x-house-refusal';
|
|
5
9
|
function streamUrl(house) {
|
|
6
|
-
const url = new URL(
|
|
10
|
+
const url = new URL(PATH, house);
|
|
7
11
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
8
12
|
return url;
|
|
9
13
|
}
|
|
14
|
+
function header(headers, name) {
|
|
15
|
+
for (let index = 0; index < headers.length; index += 2) {
|
|
16
|
+
if (headers[index].toString('latin1').toLowerCase() === name)
|
|
17
|
+
return headers[index + 1].toString('latin1');
|
|
18
|
+
}
|
|
19
|
+
return '';
|
|
20
|
+
}
|
|
21
|
+
let refused = null;
|
|
22
|
+
subscribe('undici:request:headers', (message) => {
|
|
23
|
+
const { request, response } = message;
|
|
24
|
+
if (!request.path.endsWith(PATH))
|
|
25
|
+
return;
|
|
26
|
+
refused = new HouseRefusal(PATH, response.statusCode, header(response.headers, REFUSAL_HEADER) || response.statusText);
|
|
27
|
+
});
|
|
10
28
|
export function holdStream(handlers) {
|
|
11
29
|
let socket = null;
|
|
12
30
|
let failures = 0;
|
|
13
31
|
const connect = async () => {
|
|
14
32
|
const { house, credential } = (await readEnrolment());
|
|
33
|
+
refused = null;
|
|
15
34
|
const opening = new WebSocket(relayed(streamUrl(house)), {
|
|
16
35
|
protocols: [SUBPROTOCOL],
|
|
17
36
|
headers: { authorization: `Bearer ${credential}` },
|
|
@@ -31,8 +50,14 @@ export function holdStream(handlers) {
|
|
|
31
50
|
if (socket === opening)
|
|
32
51
|
socket = null;
|
|
33
52
|
process.stderr.write(`kit: control stream closed ${event.code} ${event.reason}\n`);
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
if (event.reason === 'replaced')
|
|
54
|
+
end(REPLACED);
|
|
55
|
+
if (event.reason === 'credential_rejected' || refused?.status === 401)
|
|
56
|
+
end(SIGN_IN_AGAIN);
|
|
57
|
+
if (finalRefusal(refused))
|
|
58
|
+
end(refused.message);
|
|
59
|
+
// A closed socket reopens in one to three seconds, spread so Kits one restart closed return apart; an opening House refused as retryable or left unanswered backs off on the jittered curve.
|
|
60
|
+
setTimeout(() => void connect(), opened ? 1000 + Math.random() * 2000 : retryDelay(failures++));
|
|
36
61
|
};
|
|
37
62
|
};
|
|
38
63
|
void connect();
|
package/dist/transcription.js
CHANGED
|
@@ -36,6 +36,7 @@ function installed(artifact) {
|
|
|
36
36
|
return join(directory(), artifact.sha256);
|
|
37
37
|
}
|
|
38
38
|
async function executed(command, args, signal) {
|
|
39
|
+
// A download or a transcription runs as long as its size needs; the conversation's interrupt or end aborts it instead.
|
|
39
40
|
const ran = await run(command, args, 0, false, signal);
|
|
40
41
|
if (ran.status !== 0)
|
|
41
42
|
throw new Error(failureOf(ran, basename(command)));
|
|
@@ -68,6 +69,7 @@ async function install(artifacts, signal) {
|
|
|
68
69
|
try {
|
|
69
70
|
for (const artifact of missing) {
|
|
70
71
|
const part = `${installed(artifact)}.part`;
|
|
72
|
+
// Three retries carry a large download past a dropped connection before the transcription fails.
|
|
71
73
|
await executed('curl', ['-fsSL', '--retry', '3', '-o', part, artifact.url], signal);
|
|
72
74
|
if ((await sha256(part)) !== artifact.sha256)
|
|
73
75
|
throw new Error(`${artifact.url} does not match its sha256`);
|
package/dist/update.js
CHANGED
|
@@ -40,9 +40,11 @@ async function finished(inputId) {
|
|
|
40
40
|
const ack = await recorded();
|
|
41
41
|
if (ack !== null)
|
|
42
42
|
return ack;
|
|
43
|
+
// systemctl answers at once, so ten seconds only bounds a stuck service manager.
|
|
43
44
|
if ((await run('systemctl', ['is-active', '--quiet', unit(inputId)], 10_000)).status !== 0) {
|
|
44
45
|
return (await recorded()) ?? { refused: 'the Kit bootstrap did not finish' };
|
|
45
46
|
}
|
|
47
|
+
// The bootstrap runs for minutes, so checking each second sees its outcome within a second at little cost.
|
|
46
48
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
47
49
|
}
|
|
48
50
|
}
|