@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 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) return interactiveSetup({ installed, stale, latest, signedIn, running, credentials });
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("Set up. This machine is waiting for studies.");
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 0;
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
- out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
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 0;
11065
+ return 1;
10951
11066
  }
10952
11067
  async function signedInState(credentials) {
10953
11068
  if (!credentials) return { state: "none" };
10954
- try {
10955
- const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
10956
- headers: { authorization: `Bearer ${credentials.token}` },
10957
- signal: AbortSignal.timeout(5e3)
10958
- });
10959
- if (response.status === 401 || response.status === 403) return { state: "revoked" };
10960
- if (!response.ok) return { state: "unreachable", server: credentials.server };
10961
- return { state: "ok", name: credentials.runnerName };
10962
- } catch {
10963
- return { state: "unreachable", server: credentials.server };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Run Aloud usability studies in a real browser on your own machine, so a study can reach localhost and anything else behind your network.",
5
5
  "license": "ISC",
6
6
  "repository": {
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) return interactiveSetup({ installed, stale, latest, signedIn, running, credentials });
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: { state: string };
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("Set up. This machine is waiting for studies.");
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 0;
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(out: (line?: string) => void): Promise<number> {
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
- out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
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 0;
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
- async function signedInState(
573
- credentials: Credentials | null,
574
- ): Promise<{ state: "none" } | { state: "ok"; name: string } | { state: "revoked" } | { state: "unreachable"; server: string }> {
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
- try {
577
- const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
578
- headers: { authorization: `Bearer ${credentials.token}` },
579
- signal: AbortSignal.timeout(5_000),
580
- });
581
- if (response.status === 401 || response.status === 403) return { state: "revoked" };
582
- if (!response.ok) return { state: "unreachable", server: credentials.server };
583
- return { state: "ok", name: credentials.runnerName };
584
- } catch {
585
- return { state: "unreachable", server: credentials.server };
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. Signed in and installed but stopped is not ready, and used to exit 0 anyway.
845
- return credentials && checks.chromiumInstalled && running ? 0 : 1;
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.1";
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";