@aloud/runner 0.3.0 → 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/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,
@@ -31,7 +32,14 @@ import {
31
32
  } from "./config/credentials";
32
33
  import { policyFrom, type LocalPolicy } from "./config/policy";
33
34
  import { clearRunning, readRunning, runningPath, writeRunning, type RunningState } from "./config/running";
34
- import { RUNNER_VERSION } from "./version";
35
+ import {
36
+ RUNNER_VERSION,
37
+ compareRunnerVersions,
38
+ runnerUpdateFor,
39
+ runnerVersionPolicyFrom,
40
+ type RunnerUpdate,
41
+ type RunnerVersionPolicy,
42
+ } from "./version";
35
43
  import { RunnerClient } from "./protocol/client";
36
44
  import { installChromium, preflight } from "./preflight";
37
45
  import { TerminalReporter } from "./ui/output";
@@ -57,6 +65,11 @@ export async function main(argv: readonly string[] = process.argv.slice(2)): Pro
57
65
  return allow(rest);
58
66
  case "setup":
59
67
  return setup();
68
+ case "version":
69
+ case "--version":
70
+ case "-v":
71
+ process.stdout.write(RUNNER_VERSION + "\n");
72
+ return 0;
60
73
  case "mcp":
61
74
  // `aloud mcp` is the server an MCP host launches; `aloud mcp connect` is how it gets a
62
75
  // credential in the first place. Same word, because from the outside they are one feature.
@@ -86,12 +99,14 @@ function printHelp(): void {
86
99
  "",
87
100
  " aloud setup What to do next, for a person or an agent",
88
101
  " aloud login [--token <token>] Connect this machine, approving it in your browser",
89
- " aloud start [--once] [--quiet] Wait for studies and run them here",
102
+ " aloud start [--once] [--quiet] [--no-update]",
103
+ " Update, then wait for studies and run them here",
90
104
  " aloud status What is set up, and whether it is running",
91
105
  " aloud allow <host> Let studies open this host from this machine",
92
106
  " aloud mcp Serve MCP to an editor, using the saved credential",
93
107
  " aloud mcp connect Connect an editor, approving it in your browser",
94
108
  " aloud logout Forget the token on this machine",
109
+ " aloud --version Print the installed runner version",
95
110
  "",
96
111
  `Server: ${DEFAULT_SERVER} (override with ALOUD_SERVER)`,
97
112
  "",
@@ -329,7 +344,7 @@ async function setup(): Promise<number> {
329
344
  const running = await readRunning();
330
345
  const installed = onPath("aloud");
331
346
  const latest = await latestVersion();
332
- const stale = latest !== null && latest !== RUNNER_VERSION;
347
+ const stale = latest !== null && compareRunnerVersions(latest, RUNNER_VERSION) === 1;
333
348
  const signedIn = await signedInState(credentials);
334
349
 
335
350
  const out = (line = "") => process.stdout.write(line + "\n");
@@ -352,6 +367,21 @@ async function setup(): Promise<number> {
352
367
  );
353
368
  out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
354
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
+ );
355
385
  out();
356
386
 
357
387
  // Each step is a command plus the lines that qualify it. Only the command gets a number, or a
@@ -359,7 +389,17 @@ async function setup(): Promise<number> {
359
389
  // into a shell.
360
390
  // A person in a terminal gets setup done, not a list of things to go and do. An agent, which has
361
391
  // no terminal, gets the list. Same command, and the difference is who can answer a prompt.
362
- 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
+ }
363
403
 
364
404
  const steps: string[][] = [];
365
405
  if (!installed) {
@@ -369,11 +409,11 @@ async function setup(): Promise<number> {
369
409
  }
370
410
  if (signedIn.state === "revoked") {
371
411
  steps.push([
372
- `Create a new token at ${credentials?.server ?? DEFAULT_SERVER}/app/settings/runners`,
373
- "The saved one was revoked and cannot be reused.",
412
+ "aloud login",
413
+ "The saved connection was revoked. This prints a new browser-approval link and code.",
374
414
  ]);
375
415
  }
376
- if (signedIn.state === "none" || signedIn.state === "revoked") {
416
+ if (signedIn.state === "none") {
377
417
  steps.push([
378
418
  "aloud login",
379
419
  "Prints a link and a short code, then waits. Give both to the person; they approve in",
@@ -391,6 +431,28 @@ async function setup(): Promise<number> {
391
431
  return 1;
392
432
  }
393
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
+
394
456
  if (steps.length === 0) {
395
457
  out("Nothing to do. This machine is set up and waiting for studies.");
396
458
  out();
@@ -418,7 +480,7 @@ async function setup(): Promise<number> {
418
480
  );
419
481
  }
420
482
  out(" - The package is `@aloud/runner`, exactly. If that name does not resolve, stop and say so.");
421
- out(" Do not install a similarly named package, and do not hand the token to anything else.");
483
+ out(" Do not install a similarly named package.");
422
484
  out(" - There is nothing secret for you to handle. `aloud login` prints a link and a code,");
423
485
  out(" and the credential goes straight to this machine. Never ask anyone for a token.");
424
486
  out(" - `aloud start` does not exit. Run it in a background shell and do not block on it.");
@@ -444,9 +506,10 @@ async function interactiveSetup(state: {
444
506
  installed: boolean;
445
507
  stale: boolean;
446
508
  latest: string | null;
447
- signedIn: { state: string };
509
+ signedIn: SignedInState;
448
510
  running: RunningState | null;
449
511
  credentials: Credentials | null;
512
+ chromiumInstalled: boolean;
450
513
  }): Promise<number> {
451
514
  const out = (line = "") => process.stdout.write(line + "\n");
452
515
  const server = state.credentials?.server ?? DEFAULT_SERVER;
@@ -489,13 +552,35 @@ async function interactiveSetup(state: {
489
552
  out("Start it when you are ready, and leave it running: aloud start");
490
553
  return 1;
491
554
  }
492
- 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;
493
577
  }
494
578
 
495
579
  out("");
496
- 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.`);
497
582
  out("");
498
- return 0;
583
+ return 1;
499
584
  } finally {
500
585
  rl.close();
501
586
  }
@@ -526,9 +611,16 @@ async function run(command: string, args: readonly string[], out: (line?: string
526
611
  * Output goes to a file rather than nowhere, because the first start downloads Chromium and a
527
612
  * silent five minutes is indistinguishable from a hang.
528
613
  */
529
- 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> {
530
618
  const log = join(dirname(credentialsPath()), "runner.log");
531
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;
532
624
  const handle = openSync(log, "a");
533
625
  const child = spawn(process.execPath, [process.argv[1] ?? "", "start"], {
534
626
  detached: true,
@@ -542,9 +634,40 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
542
634
  out(" Check it aloud status");
543
635
  out(` Stop it kill ${child.pid}`);
544
636
  out("");
545
- 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
+ }
546
669
  out("");
547
- return 0;
670
+ return 1;
548
671
  }
549
672
 
550
673
  /**
@@ -555,21 +678,30 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
555
678
  * presence sent an agent off to `aloud start`, which died on its first poll with a 401 and no
556
679
  * explanation of what to do. This is the same endpoint `login` checks a token against.
557
680
  */
558
- async function signedInState(
559
- credentials: Credentials | null,
560
- ): 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> {
561
694
  if (!credentials) return { state: "none" };
562
- try {
563
- const response = await fetch(new URL("api/runner/me", credentials.server + "/"), {
564
- headers: { authorization: `Bearer ${credentials.token}` },
565
- signal: AbortSignal.timeout(5_000),
566
- });
567
- if (response.status === 401 || response.status === 403) return { state: "revoked" };
568
- if (!response.ok) return { state: "unreachable", server: credentials.server };
569
- return { state: "ok", name: credentials.runnerName };
570
- } catch {
571
- return { state: "unreachable", server: credentials.server };
572
- }
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
+ };
573
705
  }
574
706
 
575
707
  /**
@@ -624,6 +756,182 @@ async function latestVersion(): Promise<string | null> {
624
756
  }
625
757
  }
626
758
 
759
+ /**
760
+ * The release policy belongs to the control plane this machine is connected to, not to npm.
761
+ *
762
+ * That distinction lets a self-hosted deployment deliberately lag the hosted service and lets the
763
+ * hosted service publish a runner before recommending it. The response is validated before any
764
+ * part of it reaches an install command; even a compromised control plane can select only an exact
765
+ * numeric release of the official scoped package.
766
+ */
767
+ async function serverVersionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null> {
768
+ try {
769
+ const client = new RunnerClient({ server: credentials.server, token: credentials.token });
770
+ const response = await client.request<{ runnerVersionPolicy?: unknown }>("api/runner/me", {
771
+ retry: false,
772
+ });
773
+ return runnerVersionPolicyFrom(response.body?.runnerVersionPolicy);
774
+ } catch {
775
+ // Startup still reaches the ordinary authenticated claim below. A revoked token, unreachable
776
+ // server, or old self-hosted control plane will be explained there; an update convenience must
777
+ // not replace the runner's established connection behavior.
778
+ return null;
779
+ }
780
+ }
781
+
782
+ /**
783
+ * Updates before any browser or lease exists, then starts the newly installed bundle.
784
+ *
785
+ * `null` means the current process should continue. A number means startup has been handed to the
786
+ * replacement process, or could not safely continue because the server requires that replacement.
787
+ */
788
+ export interface StartUpdateDependencies {
789
+ versionPolicy(credentials: Credentials): Promise<RunnerVersionPolicy | null>;
790
+ npmPrefix(): Promise<{ path: string; writable: boolean | null } | null>;
791
+ install(target: string, write: (text: string) => void): Promise<boolean>;
792
+ installedEntry(target: string): Promise<string | null>;
793
+ relaunch(entry: string, argv: readonly string[]): Promise<number>;
794
+ stdout(text: string): void;
795
+ stderr(text: string): void;
796
+ }
797
+
798
+ export async function updateBeforeStart(
799
+ credentials: Credentials,
800
+ argv: readonly string[],
801
+ overrides: Partial<StartUpdateDependencies> = {},
802
+ ): Promise<number | null> {
803
+ const stdout = overrides.stdout ?? ((text: string) => process.stdout.write(text));
804
+ const stderr = overrides.stderr ?? ((text: string) => process.stderr.write(text));
805
+ const policy = await (overrides.versionPolicy ?? serverVersionPolicy)(credentials);
806
+ if (!policy) return null;
807
+ const update = runnerUpdateFor(policy);
808
+ if (!update) return null;
809
+
810
+ if (argv.includes("--no-update")) {
811
+ if (!update.required) return null;
812
+ stderr(
813
+ `Runner ${RUNNER_VERSION} cannot start studies on this server; ${policy.minimum} or newer is required.\n` +
814
+ `Automatic updates were disabled. Remove --no-update or run npm install -g @aloud/runner@${update.target}.\n`,
815
+ );
816
+ return 1;
817
+ }
818
+
819
+ stdout(`\nUpdating Aloud runner ${RUNNER_VERSION} → ${update.target} before it starts.\n`);
820
+ const prefix = await (overrides.npmPrefix ?? npmPrefix)();
821
+ if (prefix?.writable === false) {
822
+ return failedAutomaticUpdate(
823
+ update,
824
+ policy,
825
+ `npm's global install directory (${prefix.path}) is not writable by this user.`,
826
+ stderr,
827
+ );
828
+ }
829
+
830
+ const installed = overrides.install
831
+ ? await overrides.install(update.target, stdout)
832
+ : await run(
833
+ "npm",
834
+ ["install", "-g", `@aloud/runner@${update.target}`],
835
+ (line = "") => stdout(line + "\n"),
836
+ );
837
+ if (!installed) {
838
+ return failedAutomaticUpdate(update, policy, "npm did not complete the update.", stderr);
839
+ }
840
+
841
+ // Do not relaunch `process.argv[1]`: a runner started through npx or a project-local shim can
842
+ // live somewhere entirely different from the global package npm just replaced. Resolve npm's
843
+ // canonical global root and verify the exact installed version before handing it any work.
844
+ const entry = await (overrides.installedEntry ?? installedRunnerEntry)(update.target);
845
+ if (!entry) {
846
+ return failedAutomaticUpdate(
847
+ update,
848
+ policy,
849
+ `npm completed, but the installed @aloud/runner@${update.target} could not be verified.`,
850
+ stderr,
851
+ );
852
+ }
853
+
854
+ stdout(`\nUpdated to ${update.target}. Starting it now.\n\n`);
855
+ return (overrides.relaunch ?? relaunchUpdatedRunner)(entry, argv);
856
+ }
857
+
858
+ function failedAutomaticUpdate(
859
+ update: RunnerUpdate,
860
+ policy: RunnerVersionPolicy,
861
+ reason: string,
862
+ stderr: (text: string) => void,
863
+ ): number | null {
864
+ const command = `npm install -g @aloud/runner@${update.target}`;
865
+ if (update.required) {
866
+ stderr(
867
+ `\n${reason}\nRunner ${RUNNER_VERSION} is below this server's minimum ${policy.minimum}, so nothing was started.\n` +
868
+ `Do not use sudo. Fix npm's global prefix, then run: ${command}\n`,
869
+ );
870
+ return 1;
871
+ }
872
+ stderr(
873
+ `\n${reason}\nRunner ${RUNNER_VERSION} is still compatible, so it will start without updating.\n` +
874
+ `To update it later, run: ${command}\n\n`,
875
+ );
876
+ return null;
877
+ }
878
+
879
+ /** Finds the exact global bundle npm just installed, independently of how this process was run. */
880
+ async function installedRunnerEntry(target: string): Promise<string | null> {
881
+ const root = await commandOutput("npm", ["root", "--global"]);
882
+ if (!root) return null;
883
+
884
+ const directory = join(root, "@aloud", "runner");
885
+ const entry = join(directory, "dist", "cli.js");
886
+ try {
887
+ const manifest = JSON.parse(readFileSync(join(directory, "package.json"), "utf8")) as {
888
+ version?: unknown;
889
+ };
890
+ return manifest.version === target && existsSync(entry) ? entry : null;
891
+ } catch {
892
+ return null;
893
+ }
894
+ }
895
+
896
+ /** Captures one short command result without involving a shell. */
897
+ async function commandOutput(command: string, args: readonly string[]): Promise<string | null> {
898
+ return new Promise((resolve) => {
899
+ const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "ignore"] });
900
+ let output = "";
901
+ let settled = false;
902
+ const finish = (value: string | null) => {
903
+ if (settled) return;
904
+ settled = true;
905
+ resolve(value);
906
+ };
907
+ child.stdout?.on("data", (chunk: Buffer) => (output += chunk.toString("utf8")));
908
+ child.on("error", () => finish(null));
909
+ child.on("close", (code: number | null) => finish(code === 0 && output.trim() ? output.trim() : null));
910
+ });
911
+ }
912
+
913
+ /** The old bundle stays only as a transparent parent while the verified new bundle runs. */
914
+ async function relaunchUpdatedRunner(entry: string, argv: readonly string[]): Promise<number> {
915
+ return new Promise((resolve) => {
916
+ let settled = false;
917
+ const finish = (code: number) => {
918
+ if (settled) return;
919
+ settled = true;
920
+ resolve(code);
921
+ };
922
+ const child = spawn(process.execPath, [entry, "start", ...argv, "--no-update"], {
923
+ stdio: "inherit",
924
+ });
925
+ child.on("error", (error: Error) => {
926
+ process.stderr.write(`The runner updated but could not relaunch: ${error.message}\nRun \`aloud start\` again.\n`);
927
+ finish(1);
928
+ });
929
+ child.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
930
+ finish(code ?? (signal ? 130 : 1));
931
+ });
932
+ });
933
+ }
934
+
627
935
  /* --------------------------------- status --------------------------------- */
628
936
 
629
937
  async function status(): Promise<number> {
@@ -633,6 +941,7 @@ async function status(): Promise<number> {
633
941
  });
634
942
  const checks = await preflight();
635
943
  const running = await readRunning();
944
+ const signedIn = await signedInState(credentials);
636
945
 
637
946
  process.stdout.write("\n");
638
947
  if (!credentials) {
@@ -648,11 +957,27 @@ async function status(): Promise<number> {
648
957
  process.stdout.write(
649
958
  `Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}\n`,
650
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
+ );
651
973
  process.stdout.write("\n");
652
974
 
653
975
  // Zero means "a study started now would run here", which is the only question worth asking of
654
- // this command. Signed in and installed but stopped is not ready, and used to exit 0 anyway.
655
- 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;
656
981
  }
657
982
 
658
983
  /* --------------------------------- allow --------------------------------- */
@@ -689,6 +1014,21 @@ async function start(argv: readonly string[]): Promise<number> {
689
1014
  return 1;
690
1015
  }
691
1016
 
1017
+ // Two starts using one runner identity can claim two studies while status reports only the last
1018
+ // pid written. Refuse before updating or polling; a restart has to stop the existing process
1019
+ // first, which also guarantees we never replace its package while it is inside an active study.
1020
+ const existing = await readRunning();
1021
+ if (existing && existing.pid !== process.pid) {
1022
+ process.stderr.write(
1023
+ `Aloud runner is already running as pid ${existing.pid}. Stop that process before starting another.\n` +
1024
+ `To restart it: kill ${existing.pid} && aloud start\n`,
1025
+ );
1026
+ return 1;
1027
+ }
1028
+
1029
+ const updated = await updateBeforeStart(credentials, argv);
1030
+ if (updated !== null) return updated;
1031
+
692
1032
  const reporter = new TerminalReporter(process.stdout, credentials.token, !argv.includes("--quiet"));
693
1033
 
694
1034
  // Preflight runs before the first claim, deliberately. A missing Chromium makes
@@ -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
+ }