@aloud/runner 0.3.1 → 0.3.2
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/cli.js +173 -54
- package/package.json +1 -1
- package/src/cli.ts +159 -24
- package/src/protocol/presence.ts +83 -0
- package/src/version.ts +1 -1
package/dist/cli.js
CHANGED
|
@@ -3152,6 +3152,83 @@ async function waitForApproval(start2, deps) {
|
|
|
3152
3152
|
}
|
|
3153
3153
|
}
|
|
3154
3154
|
|
|
3155
|
+
// src/version.ts
|
|
3156
|
+
var RUNNER_VERSION = "0.3.2";
|
|
3157
|
+
var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
|
|
3158
|
+
function versionParts(value) {
|
|
3159
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
3160
|
+
if (!match) return null;
|
|
3161
|
+
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
3162
|
+
return parts.every(Number.isSafeInteger) ? parts : null;
|
|
3163
|
+
}
|
|
3164
|
+
function compareRunnerVersions(left, right) {
|
|
3165
|
+
const a = versionParts(left);
|
|
3166
|
+
const b = versionParts(right);
|
|
3167
|
+
if (!a || !b) return null;
|
|
3168
|
+
for (let index = 0; index < a.length; index += 1) {
|
|
3169
|
+
if (a[index] > b[index]) return 1;
|
|
3170
|
+
if (a[index] < b[index]) return -1;
|
|
3171
|
+
}
|
|
3172
|
+
return 0;
|
|
3173
|
+
}
|
|
3174
|
+
function runnerVersionPolicyFrom(value) {
|
|
3175
|
+
if (!value || typeof value !== "object") return null;
|
|
3176
|
+
const policy = value;
|
|
3177
|
+
if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
|
|
3178
|
+
const order = compareRunnerVersions(policy.recommended, policy.minimum);
|
|
3179
|
+
if (order === null || order < 0) return null;
|
|
3180
|
+
return { minimum: policy.minimum, recommended: policy.recommended };
|
|
3181
|
+
}
|
|
3182
|
+
function runnerUpdateFor(policy, current = RUNNER_VERSION) {
|
|
3183
|
+
const recommendedOrder = compareRunnerVersions(policy.recommended, current);
|
|
3184
|
+
const minimumOrder = compareRunnerVersions(policy.minimum, current);
|
|
3185
|
+
if (recommendedOrder === null || minimumOrder === null) return null;
|
|
3186
|
+
if (recommendedOrder <= 0) return null;
|
|
3187
|
+
return { target: policy.recommended, required: minimumOrder > 0 };
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
// src/protocol/presence.ts
|
|
3191
|
+
async function readRunnerPresence(input) {
|
|
3192
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
3193
|
+
try {
|
|
3194
|
+
const response = await fetchImpl(new URL("api/runner/me", input.server + "/"), {
|
|
3195
|
+
headers: {
|
|
3196
|
+
authorization: `Bearer ${input.token}`,
|
|
3197
|
+
accept: "application/json",
|
|
3198
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION
|
|
3199
|
+
},
|
|
3200
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 5e3)
|
|
3201
|
+
});
|
|
3202
|
+
if (response.status === 401 || response.status === 403) return { state: "revoked" };
|
|
3203
|
+
if (!response.ok) return { state: "unreachable" };
|
|
3204
|
+
const body = await response.json().catch(() => ({}));
|
|
3205
|
+
return {
|
|
3206
|
+
state: "ok",
|
|
3207
|
+
presence: {
|
|
3208
|
+
online: body.online === true,
|
|
3209
|
+
lastSeenAt: typeof body.lastSeenAt === "string" ? body.lastSeenAt : null,
|
|
3210
|
+
lastVersion: typeof body.lastVersion === "string" ? body.lastVersion : null
|
|
3211
|
+
}
|
|
3212
|
+
};
|
|
3213
|
+
} catch {
|
|
3214
|
+
return { state: "unreachable" };
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
async function waitForRunnerCheckIn(input) {
|
|
3218
|
+
const now = input.now ?? Date.now;
|
|
3219
|
+
const sleep = input.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
3220
|
+
const deadline = now() + (input.timeoutMs ?? 15e3);
|
|
3221
|
+
for (; ; ) {
|
|
3222
|
+
const result = await readRunnerPresence(input);
|
|
3223
|
+
if (result.state === "revoked") return "revoked";
|
|
3224
|
+
if (result.state === "ok" && result.presence.online && result.presence.lastSeenAt !== null && result.presence.lastSeenAt !== input.previousLastSeenAt && result.presence.lastVersion === (input.expectedVersion ?? RUNNER_VERSION)) {
|
|
3225
|
+
return "confirmed";
|
|
3226
|
+
}
|
|
3227
|
+
if (now() >= deadline) return "timeout";
|
|
3228
|
+
await sleep(Math.min(input.pollMs ?? 750, Math.max(0, deadline - now())));
|
|
3229
|
+
}
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3155
3232
|
// src/config/mcp-credentials.ts
|
|
3156
3233
|
import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3157
3234
|
import { constants } from "node:fs";
|
|
@@ -3384,41 +3461,6 @@ function alive(pid) {
|
|
|
3384
3461
|
}
|
|
3385
3462
|
}
|
|
3386
3463
|
|
|
3387
|
-
// src/version.ts
|
|
3388
|
-
var RUNNER_VERSION = "0.3.1";
|
|
3389
|
-
var RUNNER_VERSION_HEADER = "x-aloud-runner-version";
|
|
3390
|
-
function versionParts(value) {
|
|
3391
|
-
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(value);
|
|
3392
|
-
if (!match) return null;
|
|
3393
|
-
const parts = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
3394
|
-
return parts.every(Number.isSafeInteger) ? parts : null;
|
|
3395
|
-
}
|
|
3396
|
-
function compareRunnerVersions(left, right) {
|
|
3397
|
-
const a = versionParts(left);
|
|
3398
|
-
const b = versionParts(right);
|
|
3399
|
-
if (!a || !b) return null;
|
|
3400
|
-
for (let index = 0; index < a.length; index += 1) {
|
|
3401
|
-
if (a[index] > b[index]) return 1;
|
|
3402
|
-
if (a[index] < b[index]) return -1;
|
|
3403
|
-
}
|
|
3404
|
-
return 0;
|
|
3405
|
-
}
|
|
3406
|
-
function runnerVersionPolicyFrom(value) {
|
|
3407
|
-
if (!value || typeof value !== "object") return null;
|
|
3408
|
-
const policy = value;
|
|
3409
|
-
if (typeof policy.minimum !== "string" || typeof policy.recommended !== "string") return null;
|
|
3410
|
-
const order = compareRunnerVersions(policy.recommended, policy.minimum);
|
|
3411
|
-
if (order === null || order < 0) return null;
|
|
3412
|
-
return { minimum: policy.minimum, recommended: policy.recommended };
|
|
3413
|
-
}
|
|
3414
|
-
function runnerUpdateFor(policy, current = RUNNER_VERSION) {
|
|
3415
|
-
const recommendedOrder = compareRunnerVersions(policy.recommended, current);
|
|
3416
|
-
const minimumOrder = compareRunnerVersions(policy.minimum, current);
|
|
3417
|
-
if (recommendedOrder === null || minimumOrder === null) return null;
|
|
3418
|
-
if (recommendedOrder <= 0) return null;
|
|
3419
|
-
return { target: policy.recommended, required: minimumOrder > 0 };
|
|
3420
|
-
}
|
|
3421
|
-
|
|
3422
3464
|
// src/protocol/client.ts
|
|
3423
3465
|
var LeaseLostError = class extends Error {
|
|
3424
3466
|
constructor(message) {
|
|
@@ -10802,8 +10844,21 @@ async function setup() {
|
|
|
10802
10844
|
);
|
|
10803
10845
|
out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
|
|
10804
10846
|
out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
|
|
10847
|
+
out(
|
|
10848
|
+
` Aloud sees ${signedIn.state === "ok" ? signedIn.online ? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""}` : running ? "not yet. The local process exists, but no current check-in is confirmed" : "no" : signedIn.state === "unreachable" ? "unknown, the server did not answer" : "no"}`
|
|
10849
|
+
);
|
|
10805
10850
|
out();
|
|
10806
|
-
if (process.stdin.isTTY)
|
|
10851
|
+
if (process.stdin.isTTY) {
|
|
10852
|
+
return interactiveSetup({
|
|
10853
|
+
installed,
|
|
10854
|
+
stale,
|
|
10855
|
+
latest,
|
|
10856
|
+
signedIn,
|
|
10857
|
+
running,
|
|
10858
|
+
credentials,
|
|
10859
|
+
chromiumInstalled: checks.chromiumInstalled
|
|
10860
|
+
});
|
|
10861
|
+
}
|
|
10807
10862
|
const steps = [];
|
|
10808
10863
|
if (!installed) {
|
|
10809
10864
|
steps.push(["npm install -g @aloud/runner"]);
|
|
@@ -10832,6 +10887,20 @@ async function setup() {
|
|
|
10832
10887
|
out();
|
|
10833
10888
|
return 1;
|
|
10834
10889
|
}
|
|
10890
|
+
if (steps.length === 0 && running && signedIn.state === "ok" && !signedIn.online) {
|
|
10891
|
+
out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
|
|
10892
|
+
out(`Read ${join6(dirname5(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
|
|
10893
|
+
out("Do not report this machine ready until `Aloud sees` says yes.");
|
|
10894
|
+
out();
|
|
10895
|
+
return 1;
|
|
10896
|
+
}
|
|
10897
|
+
if (steps.length === 0 && running && signedIn.state === "ok" && signedIn.online && signedIn.lastVersion !== RUNNER_VERSION) {
|
|
10898
|
+
out(`The live process is checking in as v${signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`);
|
|
10899
|
+
out(`Restart it with: kill ${running.pid} && aloud start`);
|
|
10900
|
+
out("Do not report the update complete until `aloud status` shows the expected version.");
|
|
10901
|
+
out();
|
|
10902
|
+
return 1;
|
|
10903
|
+
}
|
|
10835
10904
|
if (steps.length === 0) {
|
|
10836
10905
|
out("Nothing to do. This machine is set up and waiting for studies.");
|
|
10837
10906
|
out();
|
|
@@ -10905,12 +10974,28 @@ async function interactiveSetup(state) {
|
|
|
10905
10974
|
out("Start it when you are ready, and leave it running: aloud start");
|
|
10906
10975
|
return 1;
|
|
10907
10976
|
}
|
|
10908
|
-
return startDetached(out);
|
|
10977
|
+
return startDetached(out, { waitForConfirmation: state.chromiumInstalled });
|
|
10978
|
+
}
|
|
10979
|
+
if (state.signedIn.state === "ok" && state.signedIn.online && state.signedIn.lastVersion === RUNNER_VERSION) {
|
|
10980
|
+
out("");
|
|
10981
|
+
out("Set up and confirmed by Aloud. This machine is waiting for eligible studies.");
|
|
10982
|
+
out("");
|
|
10983
|
+
return 0;
|
|
10984
|
+
}
|
|
10985
|
+
if (state.signedIn.state === "ok" && state.signedIn.online) {
|
|
10986
|
+
out("");
|
|
10987
|
+
out(
|
|
10988
|
+
`The live process is checking in as v${state.signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`
|
|
10989
|
+
);
|
|
10990
|
+
out(`Restart it with: kill ${state.running.pid} && aloud start`);
|
|
10991
|
+
out("");
|
|
10992
|
+
return 1;
|
|
10909
10993
|
}
|
|
10910
10994
|
out("");
|
|
10911
|
-
out("
|
|
10995
|
+
out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
|
|
10996
|
+
out(`Read ${join6(dirname5(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
|
|
10912
10997
|
out("");
|
|
10913
|
-
return
|
|
10998
|
+
return 1;
|
|
10914
10999
|
} finally {
|
|
10915
11000
|
rl.close();
|
|
10916
11001
|
}
|
|
@@ -10930,9 +11015,11 @@ async function run(command, args, out) {
|
|
|
10930
11015
|
child.on("close", (code) => resolve(code === 0));
|
|
10931
11016
|
});
|
|
10932
11017
|
}
|
|
10933
|
-
async function startDetached(out) {
|
|
11018
|
+
async function startDetached(out, options) {
|
|
10934
11019
|
const log = join6(dirname5(credentialsPath()), "runner.log");
|
|
10935
11020
|
await mkdir5(dirname5(log), { recursive: true, mode: 448 });
|
|
11021
|
+
const credentials = await readCredentials();
|
|
11022
|
+
const before = credentials ? await readRunnerPresence({ server: credentials.server, token: credentials.token }) : null;
|
|
10936
11023
|
const handle = openSync(log, "a");
|
|
10937
11024
|
const child = spawn2(process.execPath, [process.argv[1] ?? "", "start"], {
|
|
10938
11025
|
detached: true,
|
|
@@ -10945,23 +11032,50 @@ async function startDetached(out) {
|
|
|
10945
11032
|
out(" Check it aloud status");
|
|
10946
11033
|
out(` Stop it kill ${child.pid}`);
|
|
10947
11034
|
out("");
|
|
10948
|
-
|
|
11035
|
+
if (!options.waitForConfirmation) {
|
|
11036
|
+
out("The runner is preparing Chromium in the background. It is started locally, but not yet");
|
|
11037
|
+
out("confirmed by Aloud. The Machines page updates automatically after its first check-in.");
|
|
11038
|
+
out("");
|
|
11039
|
+
return 0;
|
|
11040
|
+
}
|
|
11041
|
+
if (!credentials || before?.state !== "ok") {
|
|
11042
|
+
out("Started locally, but Aloud could not establish a before-start status to confirm this launch.");
|
|
11043
|
+
out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
|
|
11044
|
+
out("");
|
|
11045
|
+
return 1;
|
|
11046
|
+
}
|
|
11047
|
+
out("Waiting for Aloud to confirm the first check-in\u2026");
|
|
11048
|
+
const confirmation = await waitForRunnerCheckIn({
|
|
11049
|
+
server: credentials.server,
|
|
11050
|
+
token: credentials.token,
|
|
11051
|
+
previousLastSeenAt: before.presence.lastSeenAt
|
|
11052
|
+
});
|
|
11053
|
+
if (confirmation === "confirmed") {
|
|
11054
|
+
out("Confirmed by Aloud. This machine is online and waiting for eligible studies.");
|
|
11055
|
+
out("");
|
|
11056
|
+
return 0;
|
|
11057
|
+
}
|
|
11058
|
+
if (confirmation === "revoked") {
|
|
11059
|
+
out("Aloud refused the saved connection. Run `aloud login` to approve this machine again.");
|
|
11060
|
+
} else {
|
|
11061
|
+
out("The process started, but Aloud did not confirm a check-in within 15 seconds.");
|
|
11062
|
+
out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
|
|
11063
|
+
}
|
|
10949
11064
|
out("");
|
|
10950
|
-
return
|
|
11065
|
+
return 1;
|
|
10951
11066
|
}
|
|
10952
11067
|
async function signedInState(credentials) {
|
|
10953
11068
|
if (!credentials) return { state: "none" };
|
|
10954
|
-
|
|
10955
|
-
|
|
10956
|
-
|
|
10957
|
-
|
|
10958
|
-
|
|
10959
|
-
|
|
10960
|
-
|
|
10961
|
-
|
|
10962
|
-
|
|
10963
|
-
|
|
10964
|
-
}
|
|
11069
|
+
const result = await readRunnerPresence({ server: credentials.server, token: credentials.token });
|
|
11070
|
+
if (result.state === "revoked") return { state: "revoked" };
|
|
11071
|
+
if (result.state === "unreachable") return { state: "unreachable", server: credentials.server };
|
|
11072
|
+
return {
|
|
11073
|
+
state: "ok",
|
|
11074
|
+
name: credentials.runnerName,
|
|
11075
|
+
online: result.presence.online,
|
|
11076
|
+
lastSeenAt: result.presence.lastSeenAt,
|
|
11077
|
+
lastVersion: result.presence.lastVersion
|
|
11078
|
+
};
|
|
10965
11079
|
}
|
|
10966
11080
|
async function npmPrefix() {
|
|
10967
11081
|
const path = await new Promise((resolve) => {
|
|
@@ -11136,6 +11250,7 @@ async function status() {
|
|
|
11136
11250
|
});
|
|
11137
11251
|
const checks = await preflight();
|
|
11138
11252
|
const running = await readRunning();
|
|
11253
|
+
const signedIn = await signedInState(credentials);
|
|
11139
11254
|
process.stdout.write("\n");
|
|
11140
11255
|
if (!credentials) {
|
|
11141
11256
|
process.stdout.write("Signed in no. Run `aloud login`.\n");
|
|
@@ -11153,10 +11268,14 @@ async function status() {
|
|
|
11153
11268
|
);
|
|
11154
11269
|
process.stdout.write(
|
|
11155
11270
|
`Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}
|
|
11271
|
+
`
|
|
11272
|
+
);
|
|
11273
|
+
process.stdout.write(
|
|
11274
|
+
`Aloud sees ${signedIn.state === "ok" ? signedIn.online ? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""}` : "no current check-in" : signedIn.state === "unreachable" ? "unknown, server did not answer" : "no"}
|
|
11156
11275
|
`
|
|
11157
11276
|
);
|
|
11158
11277
|
process.stdout.write("\n");
|
|
11159
|
-
return credentials && checks.chromiumInstalled && running ? 0 : 1;
|
|
11278
|
+
return credentials && checks.chromiumInstalled && running && signedIn.state === "ok" && signedIn.online ? 0 : 1;
|
|
11160
11279
|
}
|
|
11161
11280
|
async function allow(argv) {
|
|
11162
11281
|
const host = argv.find((arg) => !arg.startsWith("-"));
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { createInterface } from "node:readline/promises";
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { startApproval, waitForApproval, type ApprovalStart } from "./protocol/approval";
|
|
12
|
+
import { readRunnerPresence, waitForRunnerCheckIn } from "./protocol/presence";
|
|
12
13
|
import {
|
|
13
14
|
clearMcpCredentials,
|
|
14
15
|
mcpCredentialsPath,
|
|
@@ -366,6 +367,21 @@ async function setup(): Promise<number> {
|
|
|
366
367
|
);
|
|
367
368
|
out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
|
|
368
369
|
out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
|
|
370
|
+
out(
|
|
371
|
+
` Aloud sees ${
|
|
372
|
+
signedIn.state === "ok"
|
|
373
|
+
? signedIn.online
|
|
374
|
+
? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${
|
|
375
|
+
signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""
|
|
376
|
+
}`
|
|
377
|
+
: running
|
|
378
|
+
? "not yet. The local process exists, but no current check-in is confirmed"
|
|
379
|
+
: "no"
|
|
380
|
+
: signedIn.state === "unreachable"
|
|
381
|
+
? "unknown, the server did not answer"
|
|
382
|
+
: "no"
|
|
383
|
+
}`,
|
|
384
|
+
);
|
|
369
385
|
out();
|
|
370
386
|
|
|
371
387
|
// Each step is a command plus the lines that qualify it. Only the command gets a number, or a
|
|
@@ -373,7 +389,17 @@ async function setup(): Promise<number> {
|
|
|
373
389
|
// into a shell.
|
|
374
390
|
// A person in a terminal gets setup done, not a list of things to go and do. An agent, which has
|
|
375
391
|
// no terminal, gets the list. Same command, and the difference is who can answer a prompt.
|
|
376
|
-
if (process.stdin.isTTY)
|
|
392
|
+
if (process.stdin.isTTY) {
|
|
393
|
+
return interactiveSetup({
|
|
394
|
+
installed,
|
|
395
|
+
stale,
|
|
396
|
+
latest,
|
|
397
|
+
signedIn,
|
|
398
|
+
running,
|
|
399
|
+
credentials,
|
|
400
|
+
chromiumInstalled: checks.chromiumInstalled,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
377
403
|
|
|
378
404
|
const steps: string[][] = [];
|
|
379
405
|
if (!installed) {
|
|
@@ -405,6 +431,28 @@ async function setup(): Promise<number> {
|
|
|
405
431
|
return 1;
|
|
406
432
|
}
|
|
407
433
|
|
|
434
|
+
if (steps.length === 0 && running && signedIn.state === "ok" && !signedIn.online) {
|
|
435
|
+
out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
|
|
436
|
+
out(`Read ${join(dirname(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
|
|
437
|
+
out("Do not report this machine ready until `Aloud sees` says yes.");
|
|
438
|
+
out();
|
|
439
|
+
return 1;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (
|
|
443
|
+
steps.length === 0 &&
|
|
444
|
+
running &&
|
|
445
|
+
signedIn.state === "ok" &&
|
|
446
|
+
signedIn.online &&
|
|
447
|
+
signedIn.lastVersion !== RUNNER_VERSION
|
|
448
|
+
) {
|
|
449
|
+
out(`The live process is checking in as v${signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`);
|
|
450
|
+
out(`Restart it with: kill ${running.pid} && aloud start`);
|
|
451
|
+
out("Do not report the update complete until `aloud status` shows the expected version.");
|
|
452
|
+
out();
|
|
453
|
+
return 1;
|
|
454
|
+
}
|
|
455
|
+
|
|
408
456
|
if (steps.length === 0) {
|
|
409
457
|
out("Nothing to do. This machine is set up and waiting for studies.");
|
|
410
458
|
out();
|
|
@@ -458,9 +506,10 @@ async function interactiveSetup(state: {
|
|
|
458
506
|
installed: boolean;
|
|
459
507
|
stale: boolean;
|
|
460
508
|
latest: string | null;
|
|
461
|
-
signedIn:
|
|
509
|
+
signedIn: SignedInState;
|
|
462
510
|
running: RunningState | null;
|
|
463
511
|
credentials: Credentials | null;
|
|
512
|
+
chromiumInstalled: boolean;
|
|
464
513
|
}): Promise<number> {
|
|
465
514
|
const out = (line = "") => process.stdout.write(line + "\n");
|
|
466
515
|
const server = state.credentials?.server ?? DEFAULT_SERVER;
|
|
@@ -503,13 +552,35 @@ async function interactiveSetup(state: {
|
|
|
503
552
|
out("Start it when you are ready, and leave it running: aloud start");
|
|
504
553
|
return 1;
|
|
505
554
|
}
|
|
506
|
-
return startDetached(out);
|
|
555
|
+
return startDetached(out, { waitForConfirmation: state.chromiumInstalled });
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (
|
|
559
|
+
state.signedIn.state === "ok" &&
|
|
560
|
+
state.signedIn.online &&
|
|
561
|
+
state.signedIn.lastVersion === RUNNER_VERSION
|
|
562
|
+
) {
|
|
563
|
+
out("");
|
|
564
|
+
out("Set up and confirmed by Aloud. This machine is waiting for eligible studies.");
|
|
565
|
+
out("");
|
|
566
|
+
return 0;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (state.signedIn.state === "ok" && state.signedIn.online) {
|
|
570
|
+
out("");
|
|
571
|
+
out(
|
|
572
|
+
`The live process is checking in as v${state.signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`,
|
|
573
|
+
);
|
|
574
|
+
out(`Restart it with: kill ${state.running.pid} && aloud start`);
|
|
575
|
+
out("");
|
|
576
|
+
return 1;
|
|
507
577
|
}
|
|
508
578
|
|
|
509
579
|
out("");
|
|
510
|
-
out("
|
|
580
|
+
out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
|
|
581
|
+
out(`Read ${join(dirname(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
|
|
511
582
|
out("");
|
|
512
|
-
return
|
|
583
|
+
return 1;
|
|
513
584
|
} finally {
|
|
514
585
|
rl.close();
|
|
515
586
|
}
|
|
@@ -540,9 +611,16 @@ async function run(command: string, args: readonly string[], out: (line?: string
|
|
|
540
611
|
* Output goes to a file rather than nowhere, because the first start downloads Chromium and a
|
|
541
612
|
* silent five minutes is indistinguishable from a hang.
|
|
542
613
|
*/
|
|
543
|
-
async function startDetached(
|
|
614
|
+
async function startDetached(
|
|
615
|
+
out: (line?: string) => void,
|
|
616
|
+
options: { waitForConfirmation: boolean },
|
|
617
|
+
): Promise<number> {
|
|
544
618
|
const log = join(dirname(credentialsPath()), "runner.log");
|
|
545
619
|
await mkdir(dirname(log), { recursive: true, mode: 0o700 });
|
|
620
|
+
const credentials = await readCredentials();
|
|
621
|
+
const before = credentials
|
|
622
|
+
? await readRunnerPresence({ server: credentials.server, token: credentials.token })
|
|
623
|
+
: null;
|
|
546
624
|
const handle = openSync(log, "a");
|
|
547
625
|
const child = spawn(process.execPath, [process.argv[1] ?? "", "start"], {
|
|
548
626
|
detached: true,
|
|
@@ -556,9 +634,40 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
|
|
|
556
634
|
out(" Check it aloud status");
|
|
557
635
|
out(` Stop it kill ${child.pid}`);
|
|
558
636
|
out("");
|
|
559
|
-
|
|
637
|
+
if (!options.waitForConfirmation) {
|
|
638
|
+
out("The runner is preparing Chromium in the background. It is started locally, but not yet");
|
|
639
|
+
out("confirmed by Aloud. The Machines page updates automatically after its first check-in.");
|
|
640
|
+
out("");
|
|
641
|
+
return 0;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (!credentials || before?.state !== "ok") {
|
|
645
|
+
out("Started locally, but Aloud could not establish a before-start status to confirm this launch.");
|
|
646
|
+
out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
|
|
647
|
+
out("");
|
|
648
|
+
return 1;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
out("Waiting for Aloud to confirm the first check-in…");
|
|
652
|
+
const confirmation = await waitForRunnerCheckIn({
|
|
653
|
+
server: credentials.server,
|
|
654
|
+
token: credentials.token,
|
|
655
|
+
previousLastSeenAt: before.presence.lastSeenAt,
|
|
656
|
+
});
|
|
657
|
+
if (confirmation === "confirmed") {
|
|
658
|
+
out("Confirmed by Aloud. This machine is online and waiting for eligible studies.");
|
|
659
|
+
out("");
|
|
660
|
+
return 0;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
if (confirmation === "revoked") {
|
|
664
|
+
out("Aloud refused the saved connection. Run `aloud login` to approve this machine again.");
|
|
665
|
+
} else {
|
|
666
|
+
out("The process started, but Aloud did not confirm a check-in within 15 seconds.");
|
|
667
|
+
out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
|
|
668
|
+
}
|
|
560
669
|
out("");
|
|
561
|
-
return
|
|
670
|
+
return 1;
|
|
562
671
|
}
|
|
563
672
|
|
|
564
673
|
/**
|
|
@@ -569,21 +678,30 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
|
|
|
569
678
|
* presence sent an agent off to `aloud start`, which died on its first poll with a 401 and no
|
|
570
679
|
* explanation of what to do. This is the same endpoint `login` checks a token against.
|
|
571
680
|
*/
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
681
|
+
type SignedInState =
|
|
682
|
+
| { state: "none" }
|
|
683
|
+
| {
|
|
684
|
+
state: "ok";
|
|
685
|
+
name: string;
|
|
686
|
+
online: boolean;
|
|
687
|
+
lastSeenAt: string | null;
|
|
688
|
+
lastVersion: string | null;
|
|
689
|
+
}
|
|
690
|
+
| { state: "revoked" }
|
|
691
|
+
| { state: "unreachable"; server: string };
|
|
692
|
+
|
|
693
|
+
async function signedInState(credentials: Credentials | null): Promise<SignedInState> {
|
|
575
694
|
if (!credentials) return { state: "none" };
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
}
|
|
695
|
+
const result = await readRunnerPresence({ server: credentials.server, token: credentials.token });
|
|
696
|
+
if (result.state === "revoked") return { state: "revoked" };
|
|
697
|
+
if (result.state === "unreachable") return { state: "unreachable", server: credentials.server };
|
|
698
|
+
return {
|
|
699
|
+
state: "ok",
|
|
700
|
+
name: credentials.runnerName,
|
|
701
|
+
online: result.presence.online,
|
|
702
|
+
lastSeenAt: result.presence.lastSeenAt,
|
|
703
|
+
lastVersion: result.presence.lastVersion,
|
|
704
|
+
};
|
|
587
705
|
}
|
|
588
706
|
|
|
589
707
|
/**
|
|
@@ -823,6 +941,7 @@ async function status(): Promise<number> {
|
|
|
823
941
|
});
|
|
824
942
|
const checks = await preflight();
|
|
825
943
|
const running = await readRunning();
|
|
944
|
+
const signedIn = await signedInState(credentials);
|
|
826
945
|
|
|
827
946
|
process.stdout.write("\n");
|
|
828
947
|
if (!credentials) {
|
|
@@ -838,11 +957,27 @@ async function status(): Promise<number> {
|
|
|
838
957
|
process.stdout.write(
|
|
839
958
|
`Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}\n`,
|
|
840
959
|
);
|
|
960
|
+
process.stdout.write(
|
|
961
|
+
`Aloud sees ${
|
|
962
|
+
signedIn.state === "ok"
|
|
963
|
+
? signedIn.online
|
|
964
|
+
? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${
|
|
965
|
+
signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""
|
|
966
|
+
}`
|
|
967
|
+
: "no current check-in"
|
|
968
|
+
: signedIn.state === "unreachable"
|
|
969
|
+
? "unknown, server did not answer"
|
|
970
|
+
: "no"
|
|
971
|
+
}\n`,
|
|
972
|
+
);
|
|
841
973
|
process.stdout.write("\n");
|
|
842
974
|
|
|
843
975
|
// Zero means "a study started now would run here", which is the only question worth asking of
|
|
844
|
-
// this command.
|
|
845
|
-
|
|
976
|
+
// this command. A local PID without a server-confirmed claim poll is not ready either: that exact
|
|
977
|
+
// disagreement is what previously made setup and the Machines page contradict one another.
|
|
978
|
+
return credentials && checks.chromiumInstalled && running && signedIn.state === "ok" && signedIn.online
|
|
979
|
+
? 0
|
|
980
|
+
: 1;
|
|
846
981
|
}
|
|
847
982
|
|
|
848
983
|
/* --------------------------------- allow --------------------------------- */
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { RUNNER_VERSION, RUNNER_VERSION_HEADER } from "../version";
|
|
2
|
+
|
|
3
|
+
export interface RunnerPresence {
|
|
4
|
+
online: boolean;
|
|
5
|
+
lastSeenAt: string | null;
|
|
6
|
+
lastVersion: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type PresenceRead =
|
|
10
|
+
| { state: "ok"; presence: RunnerPresence }
|
|
11
|
+
| { state: "revoked" }
|
|
12
|
+
| { state: "unreachable" };
|
|
13
|
+
|
|
14
|
+
/** Reads server-confirmed presence without itself updating the runner's last-seen clock. */
|
|
15
|
+
export async function readRunnerPresence(input: {
|
|
16
|
+
server: string;
|
|
17
|
+
token: string;
|
|
18
|
+
fetchImpl?: typeof fetch;
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
}): Promise<PresenceRead> {
|
|
21
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
22
|
+
try {
|
|
23
|
+
const response = await fetchImpl(new URL("api/runner/me", input.server + "/"), {
|
|
24
|
+
headers: {
|
|
25
|
+
authorization: `Bearer ${input.token}`,
|
|
26
|
+
accept: "application/json",
|
|
27
|
+
[RUNNER_VERSION_HEADER]: RUNNER_VERSION,
|
|
28
|
+
},
|
|
29
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 5_000),
|
|
30
|
+
});
|
|
31
|
+
if (response.status === 401 || response.status === 403) return { state: "revoked" };
|
|
32
|
+
if (!response.ok) return { state: "unreachable" };
|
|
33
|
+
const body = (await response.json().catch(() => ({}))) as Partial<RunnerPresence>;
|
|
34
|
+
return {
|
|
35
|
+
state: "ok",
|
|
36
|
+
presence: {
|
|
37
|
+
online: body.online === true,
|
|
38
|
+
lastSeenAt: typeof body.lastSeenAt === "string" ? body.lastSeenAt : null,
|
|
39
|
+
lastVersion: typeof body.lastVersion === "string" ? body.lastVersion : null,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
} catch {
|
|
43
|
+
return { state: "unreachable" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Waits for a newly launched process to make a claim poll the control plane has actually recorded.
|
|
49
|
+
*
|
|
50
|
+
* A PID is local evidence only. Requiring `lastSeenAt` to change prevents a recent heartbeat from a
|
|
51
|
+
* previous process being mistaken for confirmation of this launch.
|
|
52
|
+
*/
|
|
53
|
+
export async function waitForRunnerCheckIn(input: {
|
|
54
|
+
server: string;
|
|
55
|
+
token: string;
|
|
56
|
+
previousLastSeenAt: string | null;
|
|
57
|
+
expectedVersion?: string;
|
|
58
|
+
fetchImpl?: typeof fetch;
|
|
59
|
+
sleep?: (ms: number) => Promise<void>;
|
|
60
|
+
now?: () => number;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
pollMs?: number;
|
|
63
|
+
}): Promise<"confirmed" | "revoked" | "timeout"> {
|
|
64
|
+
const now = input.now ?? Date.now;
|
|
65
|
+
const sleep = input.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
66
|
+
const deadline = now() + (input.timeoutMs ?? 15_000);
|
|
67
|
+
|
|
68
|
+
for (;;) {
|
|
69
|
+
const result = await readRunnerPresence(input);
|
|
70
|
+
if (result.state === "revoked") return "revoked";
|
|
71
|
+
if (
|
|
72
|
+
result.state === "ok" &&
|
|
73
|
+
result.presence.online &&
|
|
74
|
+
result.presence.lastSeenAt !== null &&
|
|
75
|
+
result.presence.lastSeenAt !== input.previousLastSeenAt &&
|
|
76
|
+
result.presence.lastVersion === (input.expectedVersion ?? RUNNER_VERSION)
|
|
77
|
+
) {
|
|
78
|
+
return "confirmed";
|
|
79
|
+
}
|
|
80
|
+
if (now() >= deadline) return "timeout";
|
|
81
|
+
await sleep(Math.min(input.pollMs ?? 750, Math.max(0, deadline - now())));
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/version.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* package.json beside it to read, and importing one into the source trips the composite build's
|
|
11
11
|
* rootDir. `version.test.ts` asserts this matches, so the drift this invites cannot survive CI.
|
|
12
12
|
*/
|
|
13
|
-
export const RUNNER_VERSION = "0.3.
|
|
13
|
+
export const RUNNER_VERSION = "0.3.2";
|
|
14
14
|
|
|
15
15
|
/** The header the server reads it from. */
|
|
16
16
|
export const RUNNER_VERSION_HEADER = "x-aloud-runner-version";
|