@aloud/runner 0.3.1 → 0.3.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloud/runner",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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,
@@ -101,7 +102,7 @@ function printHelp(): void {
101
102
  " aloud start [--once] [--quiet] [--no-update]",
102
103
  " Update, then wait for studies and run them here",
103
104
  " aloud status What is set up, and whether it is running",
104
- " aloud allow <host> Let studies open this host from this machine",
105
+ " aloud allow <host> Approve a private or local host on this machine",
105
106
  " aloud mcp Serve MCP to an editor, using the saved credential",
106
107
  " aloud mcp connect Connect an editor, approving it in your browser",
107
108
  " aloud logout Forget the token on this machine",
@@ -224,7 +225,8 @@ async function connectWith(server: string, token: string): Promise<number> {
224
225
 
225
226
  const reporter = new TerminalReporter(process.stdout, token);
226
227
  process.stdout.write(`\nConnected as ${credentials.runnerName}.\n`);
227
- process.stdout.write(`Allowed here: ${credentials.allowedHosts.join(", ")}\n`);
228
+ process.stdout.write("Public sites: automatic for the study that names them.\n");
229
+ process.stdout.write(`Private/local: ${credentials.allowedHosts.join(", ") || "nothing approved"}\n`);
228
230
  reporter.privacyNote();
229
231
  process.stdout.write("Run `aloud start` and leave it running.\n\n");
230
232
  return 0;
@@ -366,6 +368,21 @@ async function setup(): Promise<number> {
366
368
  );
367
369
  out(` chromium ${checks.chromiumInstalled ? "ready" : "downloads on first start, about 350 MB"}`);
368
370
  out(` running ${running ? `yes (pid ${running.pid})` : "no"}`);
371
+ out(
372
+ ` Aloud sees ${
373
+ signedIn.state === "ok"
374
+ ? signedIn.online
375
+ ? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${
376
+ signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""
377
+ }`
378
+ : running
379
+ ? "not yet. The local process exists, but no current check-in is confirmed"
380
+ : "no"
381
+ : signedIn.state === "unreachable"
382
+ ? "unknown, the server did not answer"
383
+ : "no"
384
+ }`,
385
+ );
369
386
  out();
370
387
 
371
388
  // Each step is a command plus the lines that qualify it. Only the command gets a number, or a
@@ -373,7 +390,17 @@ async function setup(): Promise<number> {
373
390
  // into a shell.
374
391
  // A person in a terminal gets setup done, not a list of things to go and do. An agent, which has
375
392
  // 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 });
393
+ if (process.stdin.isTTY) {
394
+ return interactiveSetup({
395
+ installed,
396
+ stale,
397
+ latest,
398
+ signedIn,
399
+ running,
400
+ credentials,
401
+ chromiumInstalled: checks.chromiumInstalled,
402
+ });
403
+ }
377
404
 
378
405
  const steps: string[][] = [];
379
406
  if (!installed) {
@@ -405,6 +432,28 @@ async function setup(): Promise<number> {
405
432
  return 1;
406
433
  }
407
434
 
435
+ if (steps.length === 0 && running && signedIn.state === "ok" && !signedIn.online) {
436
+ out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
437
+ out(`Read ${join(dirname(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
438
+ out("Do not report this machine ready until `Aloud sees` says yes.");
439
+ out();
440
+ return 1;
441
+ }
442
+
443
+ if (
444
+ steps.length === 0 &&
445
+ running &&
446
+ signedIn.state === "ok" &&
447
+ signedIn.online &&
448
+ signedIn.lastVersion !== RUNNER_VERSION
449
+ ) {
450
+ out(`The live process is checking in as v${signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`);
451
+ out(`Restart it with: kill ${running.pid} && aloud start`);
452
+ out("Do not report the update complete until `aloud status` shows the expected version.");
453
+ out();
454
+ return 1;
455
+ }
456
+
408
457
  if (steps.length === 0) {
409
458
  out("Nothing to do. This machine is set up and waiting for studies.");
410
459
  out();
@@ -458,9 +507,10 @@ async function interactiveSetup(state: {
458
507
  installed: boolean;
459
508
  stale: boolean;
460
509
  latest: string | null;
461
- signedIn: { state: string };
510
+ signedIn: SignedInState;
462
511
  running: RunningState | null;
463
512
  credentials: Credentials | null;
513
+ chromiumInstalled: boolean;
464
514
  }): Promise<number> {
465
515
  const out = (line = "") => process.stdout.write(line + "\n");
466
516
  const server = state.credentials?.server ?? DEFAULT_SERVER;
@@ -503,13 +553,35 @@ async function interactiveSetup(state: {
503
553
  out("Start it when you are ready, and leave it running: aloud start");
504
554
  return 1;
505
555
  }
506
- return startDetached(out);
556
+ return startDetached(out, { waitForConfirmation: state.chromiumInstalled });
557
+ }
558
+
559
+ if (
560
+ state.signedIn.state === "ok" &&
561
+ state.signedIn.online &&
562
+ state.signedIn.lastVersion === RUNNER_VERSION
563
+ ) {
564
+ out("");
565
+ out("Set up and confirmed by Aloud. This machine is waiting for eligible studies.");
566
+ out("");
567
+ return 0;
568
+ }
569
+
570
+ if (state.signedIn.state === "ok" && state.signedIn.online) {
571
+ out("");
572
+ out(
573
+ `The live process is checking in as v${state.signedIn.lastVersion ?? "unknown"}, not v${RUNNER_VERSION}.`,
574
+ );
575
+ out(`Restart it with: kill ${state.running.pid} && aloud start`);
576
+ out("");
577
+ return 1;
507
578
  }
508
579
 
509
580
  out("");
510
- out("Set up. This machine is waiting for studies.");
581
+ out("The runner process exists locally, but Aloud has not confirmed a current check-in.");
582
+ out(`Read ${join(dirname(credentialsPath()), "runner.log")} and run \`aloud status\` again.`);
511
583
  out("");
512
- return 0;
584
+ return 1;
513
585
  } finally {
514
586
  rl.close();
515
587
  }
@@ -540,9 +612,16 @@ async function run(command: string, args: readonly string[], out: (line?: string
540
612
  * Output goes to a file rather than nowhere, because the first start downloads Chromium and a
541
613
  * silent five minutes is indistinguishable from a hang.
542
614
  */
543
- async function startDetached(out: (line?: string) => void): Promise<number> {
615
+ async function startDetached(
616
+ out: (line?: string) => void,
617
+ options: { waitForConfirmation: boolean },
618
+ ): Promise<number> {
544
619
  const log = join(dirname(credentialsPath()), "runner.log");
545
620
  await mkdir(dirname(log), { recursive: true, mode: 0o700 });
621
+ const credentials = await readCredentials();
622
+ const before = credentials
623
+ ? await readRunnerPresence({ server: credentials.server, token: credentials.token })
624
+ : null;
546
625
  const handle = openSync(log, "a");
547
626
  const child = spawn(process.execPath, [process.argv[1] ?? "", "start"], {
548
627
  detached: true,
@@ -556,9 +635,40 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
556
635
  out(" Check it aloud status");
557
636
  out(` Stop it kill ${child.pid}`);
558
637
  out("");
559
- out("The first start downloads Chromium, about 350 MB, once. Studies will wait until it is done.");
638
+ if (!options.waitForConfirmation) {
639
+ out("The runner is preparing Chromium in the background. It is started locally, but not yet");
640
+ out("confirmed by Aloud. The Machines page updates automatically after its first check-in.");
641
+ out("");
642
+ return 0;
643
+ }
644
+
645
+ if (!credentials || before?.state !== "ok") {
646
+ out("Started locally, but Aloud could not establish a before-start status to confirm this launch.");
647
+ out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
648
+ out("");
649
+ return 1;
650
+ }
651
+
652
+ out("Waiting for Aloud to confirm the first check-in…");
653
+ const confirmation = await waitForRunnerCheckIn({
654
+ server: credentials.server,
655
+ token: credentials.token,
656
+ previousLastSeenAt: before.presence.lastSeenAt,
657
+ });
658
+ if (confirmation === "confirmed") {
659
+ out("Confirmed by Aloud. This machine is online and waiting for eligible studies.");
660
+ out("");
661
+ return 0;
662
+ }
663
+
664
+ if (confirmation === "revoked") {
665
+ out("Aloud refused the saved connection. Run `aloud login` to approve this machine again.");
666
+ } else {
667
+ out("The process started, but Aloud did not confirm a check-in within 15 seconds.");
668
+ out(`Read ${log} and run \`aloud status\`; do not call it ready until \`Aloud sees\` says yes.`);
669
+ }
560
670
  out("");
561
- return 0;
671
+ return 1;
562
672
  }
563
673
 
564
674
  /**
@@ -569,21 +679,30 @@ async function startDetached(out: (line?: string) => void): Promise<number> {
569
679
  * presence sent an agent off to `aloud start`, which died on its first poll with a 401 and no
570
680
  * explanation of what to do. This is the same endpoint `login` checks a token against.
571
681
  */
572
- async function signedInState(
573
- credentials: Credentials | null,
574
- ): Promise<{ state: "none" } | { state: "ok"; name: string } | { state: "revoked" } | { state: "unreachable"; server: string }> {
682
+ type SignedInState =
683
+ | { state: "none" }
684
+ | {
685
+ state: "ok";
686
+ name: string;
687
+ online: boolean;
688
+ lastSeenAt: string | null;
689
+ lastVersion: string | null;
690
+ }
691
+ | { state: "revoked" }
692
+ | { state: "unreachable"; server: string };
693
+
694
+ async function signedInState(credentials: Credentials | null): Promise<SignedInState> {
575
695
  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
- }
696
+ const result = await readRunnerPresence({ server: credentials.server, token: credentials.token });
697
+ if (result.state === "revoked") return { state: "revoked" };
698
+ if (result.state === "unreachable") return { state: "unreachable", server: credentials.server };
699
+ return {
700
+ state: "ok",
701
+ name: credentials.runnerName,
702
+ online: result.presence.online,
703
+ lastSeenAt: result.presence.lastSeenAt,
704
+ lastVersion: result.presence.lastVersion,
705
+ };
587
706
  }
588
707
 
589
708
  /**
@@ -823,6 +942,7 @@ async function status(): Promise<number> {
823
942
  });
824
943
  const checks = await preflight();
825
944
  const running = await readRunning();
945
+ const signedIn = await signedInState(credentials);
826
946
 
827
947
  process.stdout.write("\n");
828
948
  if (!credentials) {
@@ -830,7 +950,10 @@ async function status(): Promise<number> {
830
950
  } else {
831
951
  process.stdout.write(`Signed in ${credentials.runnerName}\n`);
832
952
  process.stdout.write(`Server ${credentials.server}\n`);
833
- process.stdout.write(`Allowed here ${credentials.allowedHosts.join(", ") || "nothing yet"}\n`);
953
+ process.stdout.write("Public sites automatic per study\n");
954
+ process.stdout.write(
955
+ `Private/local ${credentials.allowedHosts.join(", ") || "nothing approved"}\n`,
956
+ );
834
957
  }
835
958
  process.stdout.write(
836
959
  `Chromium ${checks.chromiumInstalled ? `ready (${checks.chromiumPath})` : "not installed yet"}\n`,
@@ -838,11 +961,27 @@ async function status(): Promise<number> {
838
961
  process.stdout.write(
839
962
  `Running ${running ? `yes, since ${when(running.startedAt)} (pid ${running.pid})` : "no. Run `aloud start`."}\n`,
840
963
  );
964
+ process.stdout.write(
965
+ `Aloud sees ${
966
+ signedIn.state === "ok"
967
+ ? signedIn.online
968
+ ? `yes${signedIn.lastVersion ? ` (v${signedIn.lastVersion})` : ""}${
969
+ signedIn.lastSeenAt ? `, checked in ${when(signedIn.lastSeenAt)}` : ""
970
+ }`
971
+ : "no current check-in"
972
+ : signedIn.state === "unreachable"
973
+ ? "unknown, server did not answer"
974
+ : "no"
975
+ }\n`,
976
+ );
841
977
  process.stdout.write("\n");
842
978
 
843
979
  // 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;
980
+ // this command. A local PID without a server-confirmed claim poll is not ready either: that exact
981
+ // disagreement is what previously made setup and the Machines page contradict one another.
982
+ return credentials && checks.chromiumInstalled && running && signedIn.state === "ok" && signedIn.online
983
+ ? 0
984
+ : 1;
846
985
  }
847
986
 
848
987
  /* --------------------------------- allow --------------------------------- */
@@ -850,7 +989,7 @@ async function status(): Promise<number> {
850
989
  async function allow(argv: readonly string[]): Promise<number> {
851
990
  const host = argv.find((arg) => !arg.startsWith("-"));
852
991
  if (!host) {
853
- process.stderr.write("Which host? For example: aloud allow staging.acme.com\n");
992
+ process.stderr.write("Which private or local host? For example: aloud allow internal.acme.test\n");
854
993
  return 1;
855
994
  }
856
995
 
@@ -862,8 +1001,9 @@ async function allow(argv: readonly string[]): Promise<number> {
862
1001
 
863
1002
  const next = normaliseHosts([...credentials.allowedHosts, host]);
864
1003
  await writeCredentials({ ...credentials, allowedHosts: next });
865
- process.stdout.write(`\nStudies on this machine may now open: ${next.join(", ")}\n`);
866
- process.stdout.write("Nothing the server sends can change that list. Only this command can.\n\n");
1004
+ process.stdout.write(`\nPrivate/local access approved for: ${next.join(", ")}\n`);
1005
+ process.stdout.write("A running runner syncs this on its next poll; otherwise the next start does.\n");
1006
+ process.stdout.write("Public sites work automatically per study. Nothing the server sends can widen this private list.\n\n");
867
1007
  return 0;
868
1008
  }
869
1009
 
@@ -909,11 +1049,6 @@ async function start(argv: readonly string[]): Promise<number> {
909
1049
  }
910
1050
 
911
1051
  const local = policyOf(credentials, argv);
912
- if (local.allowedHosts.length === 0) {
913
- process.stderr.write("This machine is not allowed to open anything. Try `aloud allow localhost`.\n");
914
- return 1;
915
- }
916
-
917
1052
  const client = new RunnerClient({
918
1053
  server: credentials.server,
919
1054
  token: credentials.token,
@@ -935,15 +1070,44 @@ async function start(argv: readonly string[]): Promise<number> {
935
1070
  // The `finally` below covers every ordinary ending. This covers the second Ctrl-C, which calls
936
1071
  // `process.exit` and unwinds nothing. Synchronous, because an exit handler cannot await.
937
1072
  process.on("exit", () => clearRunningSync());
1073
+ let attemptedIdleUpdate: string | null = null;
938
1074
 
939
1075
  try {
940
1076
  await runLoop({
941
1077
  client,
942
1078
  local,
1079
+ loadLocalPolicy: async () => {
1080
+ const current = await readCredentials();
1081
+ return current && current.runnerId === credentials.runnerId
1082
+ ? policyOf(current, argv)
1083
+ : policyFrom({ allowedHosts: [], allowPrivateNetwork: false });
1084
+ },
943
1085
  ui: reporter,
944
1086
  webUrl: `${credentials.server}/app`,
945
1087
  once: argv.includes("--once"),
946
1088
  signal: controller.signal,
1089
+ onUpdateRequested: async (policy) => {
1090
+ if (attemptedIdleUpdate === policy.recommended) return false;
1091
+ attemptedIdleUpdate = policy.recommended;
1092
+ reporter.note(`Runner ${policy.recommended} is ready. Updating now while this machine is idle.`);
1093
+ // The replacement must be able to write its own PID before it starts polling. Clearing this
1094
+ // process's marker is the handoff; the exit cleanup checks the PID and cannot erase the
1095
+ // replacement's record afterwards.
1096
+ await clearRunning();
1097
+ const result = await updateBeforeStart(credentials, argv, {
1098
+ versionPolicy: async () => policy,
1099
+ });
1100
+ if (result !== null) return true;
1101
+
1102
+ // An optional update can fail while this release remains compatible. Resume this exact
1103
+ // process and make status truthful again rather than silently leaving no runner behind.
1104
+ await writeRunning({
1105
+ pid: process.pid,
1106
+ startedAt: new Date().toISOString(),
1107
+ server: credentials.server,
1108
+ });
1109
+ return false;
1110
+ },
947
1111
  });
948
1112
  return 0;
949
1113
  } catch (error) {
@@ -13,7 +13,7 @@ import { normaliseHosts } from "@aloud/core";
13
13
  * ever edits this file.
14
14
  */
15
15
  export interface LocalPolicy {
16
- /** Hosts this machine will open. Written at login, changed only by `aloud allow`. */
16
+ /** Private/local hosts this machine will open. Public access is ephemeral and lease-scoped. */
17
17
  allowedHosts: string[];
18
18
  /**
19
19
  * Whether private and loopback addresses may be opened at all.
package/src/discovery.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  evaluateTargetUrl,
6
6
  hostPermitted,
7
7
  intersectHosts,
8
+ targetNetworkAccess,
8
9
  type DeviceContext,
9
10
  type DiscoveryLink,
10
11
  type DiscoveryPage,
@@ -26,14 +27,22 @@ export async function discoverProduct(
26
27
  job: StudySetupJob,
27
28
  local: LocalPolicy,
28
29
  ): Promise<ProductDiscoveryEvidence> {
29
- const effectiveHosts = intersectHosts(job.allowedHosts, local.allowedHosts);
30
+ const access = await targetNetworkAccess(job.url);
31
+ if (access.kind === "blocked") throw new Error(`Refusing product discovery: ${access.reason}`);
32
+ const effectiveHosts =
33
+ access.kind === "public"
34
+ ? job.allowedHosts.some((host) => hostPermitted(access.hostname, [host]))
35
+ ? [access.hostname]
36
+ : []
37
+ : intersectHosts(job.allowedHosts, local.allowedHosts);
30
38
  if (effectiveHosts.length === 0) {
31
39
  throw new Error(
32
- `Product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ` +
40
+ `Private product discovery wants ${job.allowedHosts.join(", ")}, but this machine allows ` +
33
41
  `${local.allowedHosts.join(", ") || "nothing"}.`,
34
42
  );
35
43
  }
36
- await assertTarget(job.url, effectiveHosts, local);
44
+ const allowPrivateNetwork = access.kind === "private" && local.allowPrivateNetwork;
45
+ await assertTarget(job.url, effectiveHosts, allowPrivateNetwork);
37
46
 
38
47
  const browser = await chromium.launch({
39
48
  args: ["--disable-dev-shm-usage"],
@@ -51,7 +60,7 @@ export async function discoverProduct(
51
60
  });
52
61
 
53
62
  try {
54
- await guardRequests(context, effectiveHosts, local);
63
+ await guardRequests(context, effectiveHosts, allowPrivateNetwork);
55
64
  const start = canonicalUrl(job.url);
56
65
  const origin = new URL(start).origin;
57
66
  const queued = [start];
@@ -63,7 +72,7 @@ export async function discoverProduct(
63
72
  if (seen.has(url)) continue;
64
73
  seen.add(url);
65
74
  try {
66
- await assertTarget(url, effectiveHosts, local);
75
+ await assertTarget(url, effectiveHosts, allowPrivateNetwork);
67
76
  const page = await context.newPage();
68
77
  try {
69
78
  page.setDefaultTimeout(PAGE_TIMEOUT_MS);
@@ -73,7 +82,7 @@ export async function discoverProduct(
73
82
  } catch {
74
83
  // Some products poll forever. The DOM is still useful after DOMContentLoaded.
75
84
  }
76
- await assertTarget(page.url(), effectiveHosts, local);
85
+ await assertTarget(page.url(), effectiveHosts, allowPrivateNetwork);
77
86
  const captured = await extractPage(page);
78
87
  pages.push(captured);
79
88
 
@@ -102,10 +111,10 @@ export async function discoverProduct(
102
111
  }
103
112
  }
104
113
 
105
- async function assertTarget(url: string, allowedDomains: string[], local: LocalPolicy): Promise<void> {
114
+ async function assertTarget(url: string, allowedDomains: string[], allowPrivateNetwork: boolean): Promise<void> {
106
115
  const verdict = await evaluateTargetUrl(url, {
107
116
  allowedDomains,
108
- allowPrivateNetwork: local.allowPrivateNetwork,
117
+ allowPrivateNetwork,
109
118
  });
110
119
  if (!verdict.allowed) throw new Error(`Refusing product discovery: ${verdict.reason}`);
111
120
  }
@@ -113,7 +122,7 @@ async function assertTarget(url: string, allowedDomains: string[], local: LocalP
113
122
  async function guardRequests(
114
123
  context: BrowserContext,
115
124
  allowedDomains: string[],
116
- local: LocalPolicy,
125
+ allowPrivateNetwork: boolean,
117
126
  ): Promise<void> {
118
127
  await context.route("**/*", async (route) => {
119
128
  const request = route.request();
@@ -135,15 +144,16 @@ async function guardRequests(
135
144
  await route.abort("blockedbyclient");
136
145
  return;
137
146
  }
138
- if (request.resourceType() === "document") {
139
- const verdict = await evaluateTargetUrl(url.toString(), {
140
- allowedDomains,
141
- allowPrivateNetwork: local.allowPrivateNetwork,
142
- });
143
- if (!verdict.allowed) {
144
- await route.abort("blockedbyclient");
145
- return;
146
- }
147
+ // Discovery is narrower than a participant browser and keeps every request on the product
148
+ // domain. Resolve all of them, not only documents: a same-host private subresource or DNS
149
+ // rebind must not turn automatic public discovery into local-network access.
150
+ const verdict = await evaluateTargetUrl(url.toString(), {
151
+ allowedDomains,
152
+ allowPrivateNetwork,
153
+ });
154
+ if (!verdict.allowed) {
155
+ await route.abort("blockedbyclient");
156
+ return;
147
157
  }
148
158
  await route.continue();
149
159
  });
package/src/loop.ts CHANGED
@@ -5,6 +5,7 @@ import { executeLease, type ExecuteResult, type RunReporter } from "./run/execut
5
5
  import type { StageRouting } from "./model/proxy-adapter";
6
6
  import type { LocalPolicy } from "./config/policy";
7
7
  import { discoverProduct } from "./discovery";
8
+ import { runnerVersionPolicyFrom, type RunnerVersionPolicy } from "./version";
8
9
 
9
10
  /**
10
11
  * Claim, run, repeat.
@@ -22,17 +23,20 @@ const FAST_WINDOW_MS = 30_000;
22
23
  const IDLE_AFTER_MS = 5 * 60_000;
23
24
 
24
25
  interface ClaimResponse {
25
- kind?: "study_run" | "product_discovery";
26
+ kind?: "study_run" | "product_discovery" | "runner_update";
26
27
  lease?: JobLease;
27
28
  run?: StudyRun;
28
29
  setupJob?: StudySetupJob;
29
30
  productId?: string;
30
31
  routing?: Partial<Record<string, StageRouting>>;
32
+ runnerVersionPolicy?: unknown;
31
33
  }
32
34
 
33
35
  export interface LoopDeps {
34
36
  client: RunnerClient;
35
37
  local: LocalPolicy;
38
+ /** Reloads durable private routes before a job, so `aloud allow` needs no process restart. */
39
+ loadLocalPolicy?: () => Promise<LocalPolicy>;
36
40
  ui: RunReporter & { waiting?(url: string): void };
37
41
  webUrl: string;
38
42
  /** Claim one lease, run it, and stop. For CI, and for `aloud start --once`. */
@@ -42,6 +46,8 @@ export interface LoopDeps {
42
46
  now?: () => number;
43
47
  signal?: AbortSignal;
44
48
  workers?: Parameters<typeof executeLease>[0]["workers"];
49
+ /** Install and relaunch an update only while no study is claimed. True stops this old loop. */
50
+ onUpdateRequested?: (policy: RunnerVersionPolicy) => Promise<boolean>;
45
51
  }
46
52
 
47
53
  export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
@@ -56,9 +62,13 @@ export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
56
62
 
57
63
  while (!deps.signal?.aborted) {
58
64
  let claim: ClaimResponse | null = null;
65
+ const pollLocal = deps.loadLocalPolicy ? await deps.loadLocalPolicy() : deps.local;
59
66
  try {
60
67
  const response = await deps.client.request<ClaimResponse>("api/runner/claim", {
61
68
  method: "POST",
69
+ // The machine owns private-network policy. Mirroring it on every outbound poll makes one
70
+ // `aloud allow` command sufficient and repairs a missed sync without human intervention.
71
+ body: { localAllowedHosts: pollLocal.allowedHosts },
62
72
  // A poll should fail fast and come back, not block for a minute holding the loop.
63
73
  retry: false,
64
74
  ...(deps.signal ? { signal: deps.signal } : {}),
@@ -78,11 +88,18 @@ export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
78
88
  throw error;
79
89
  }
80
90
 
91
+ if (claim?.kind === "runner_update") {
92
+ const policy = runnerVersionPolicyFrom(claim.runnerVersionPolicy);
93
+ if (policy && deps.onUpdateRequested && (await deps.onUpdateRequested(policy))) break;
94
+ await sleep(POLL_NORMAL_MS);
95
+ continue;
96
+ }
97
+
81
98
  if (claim?.kind === "product_discovery" && claim.setupJob) {
82
99
  lastActivityAt = now();
83
100
  deps.ui.note(`Learning what ${new URL(claim.setupJob.url).hostname} does from its public pages.`);
84
101
  try {
85
- const evidence = await discoverProduct(claim.setupJob, deps.local);
102
+ const evidence = await discoverProduct(claim.setupJob, pollLocal);
86
103
  await deps.client.request("api/runner/discovery", {
87
104
  method: "POST",
88
105
  body: { setupJobId: claim.setupJob.id, evidence },
@@ -132,7 +149,7 @@ export async function runLoop(deps: LoopDeps): Promise<ExecuteResult[]> {
132
149
  run: claim.run,
133
150
  productId: claim.productId ?? "",
134
151
  routing: (claim.routing ?? {}) as never,
135
- local: deps.local,
152
+ local: pollLocal,
136
153
  spool,
137
154
  ui: deps.ui,
138
155
  ...(deps.workers ? { workers: deps.workers } : {}),
@@ -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
+ }