@stim-cli/server 1.17.2 → 1.18.0

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/README.md CHANGED
@@ -112,8 +112,9 @@ GUI login session.
112
112
 
113
113
  Running setup on the worker is the worker-side approval. It pre-approves at
114
114
  most one request per chosen capability, from this node, carrying this ticket,
115
- until this expiry. A terminal asks y/N for each grant (default No); use `--yes`
116
- to approve without those questions, including without a terminal. Without
115
+ until this expiry. A terminal asks Y/n for each grant (default Yes: Enter, `y` and `yes` approve;
116
+ `n`, `no` and any other answer decline); use `--yes` to approve without those
117
+ questions, including without a terminal. Without
117
118
  a terminal or `--yes`, setup refuses before installing anything unless every
118
119
  chosen capability already has a matching approval. A person on
119
120
  the worker still approves; an SSH-driven run is not offered. Build and
@@ -155,7 +156,7 @@ nothing. Android and CocoaPods checks are not needed for hosting alone.
155
156
  In a terminal, setup prints a banner, then one line per step with a green check,
156
157
  a yellow arrow while a step runs, or a red cross; `NO_COLOR`, `TERM=dumb` and a
157
158
  non-terminal give plain `[ok]`, `[failed]` and `[pending]` marks, no colors and no
158
- in-place line. The y/N questions stay, one line each. The run ends with a
159
+ in-place line. The Y/n questions stay, one line each. The run ends with a
159
160
  headline such as "janics-mac-mini is ready to build for Janic's MacBook Pro", one
160
161
  line per approved capability, a `Fix` line for each skipped permission or missing
161
162
  tool, and one `To undo` line. `--verbose` prints every step with its detail, the
@@ -1162,7 +1163,11 @@ The server re-reads the build clients on every call, and revoking a client
1162
1163
  closes its connections and cancels its builds.
1163
1164
 
1164
1165
  - `build.offer` takes `repo` (the client's name for its repository: letters,
1165
- digits, `.`, `_` and `-`, at most 80) and an optional `lockfile` sha256. It
1166
+ digits, `.`, `_` and `-`, at most 80), an optional `lockfile` sha256, and an
1167
+ optional `rubyVersion` (the project's normalized `.ruby-version`, a nonempty
1168
+ installation name without path separators, NUL or line breaks). The CocoaPods probe uses the
1169
+ same Ruby selection and UTF-8 locale defaults as `pod install`; omitting
1170
+ `rubyVersion` uses the worker's default Ruby. It
1166
1171
  returns `toolchain` (`stimBuild`, a digest of the bundled Stim's built code;
1167
1172
  `arch`; `xcode`; `simulatorSdk`; `macosSdk` (from
1168
1173
  `xcrun --sdk macosx --show-sdk-version`); `cocoapods`; `runtimes`, the simulator
@@ -1178,11 +1183,12 @@ closes its connections and cancels its builds.
1178
1183
  average per CPU), `builds` (this Mac's own Stim runs in prebuild, pods or
1179
1184
  compile that it did not offload, plus the offloaded builds it runs), `maxBuilds` (its
1180
1185
  `concurrency.maxBuilds`, 0 when unlimited), `maxLoadPerCore` (its
1181
- `server.maxLoadPerCore`, default 2), and `declined`: why it would refuse a
1186
+ `server.maxLoadPerCore`, default 2), `memoryUsedBytes` (Activity Monitor's
1187
+ Memory Used) and `memoryTotalBytes`, and `declined`: why it would refuse a
1182
1188
  build now, or null. It declines while it runs its limit of offloaded builds,
1183
1189
  while the worker root's volume has less than 10 GiB free, while `builds`
1184
1190
  reaches a non-zero `maxBuilds`, or while `loadPerCore` is at or above
1185
- `maxLoadPerCore`. The toolchain is read at most once a minute; capacity on
1191
+ `maxLoadPerCore`. The toolchain is read at most once a minute per Ruby context; capacity on
1186
1192
  every offer.
1187
1193
  - `build.sync` takes `repo`, `files` and `done`. `files` is one page of the
1188
1194
  manifest, each `{ "path", "kind": "file"|"exec"|"link", "size", "sha256" }`,
@@ -1890,8 +1896,10 @@ after. With a `stim` whose status has no `activity.recent`, every app log
1890
1896
  record counts as activity, so an app that keeps logging never looks stuck or
1891
1897
  finished.
1892
1898
 
1893
- Each workspace notifies once per episode: a stuck agent notifies again only
1894
- after new activity and a new quiet stretch, a loop only after a success or a
1899
+ Each workspace notifies once per episode: an agent stop pushes `finished` once
1900
+ per run, which ends when the workspace goes idle, and a workspace pushes
1901
+ `attention` once per run, which starts over when the workspace stops being idle
1902
+ or has no item left; a stuck agent notifies again only after new activity and a new quiet stretch, a loop only after a success or a
1895
1903
  different failure. A push carries a collapse id for its workspace and
1896
1904
  category, so a later one replaces the earlier notification on the phone
1897
1905
  instead of stacking. What is already true when the server starts or a device
@@ -1,5 +1,5 @@
1
1
  import "node:module";
2
- import { ACTIONS, BUILD_REPO_PATTERN, CAPABILITIES, CONTROL_END_REASONS, DEVICE_POSTURES, ERROR_CODES, FEATURES, FRAME_EDGE, FRAME_FPS, INPUT_BUTTONS, INPUT_KEYS, KEY_MODIFIERS, LEGACY_PUSH_EVENTS, LOG_LEVELS, LOG_SOURCES, MAX_INPUT_TEXT, MAX_LOG_TAIL, NOTIFICATION_LEVELS, NOTIFICATION_SUPPRESSIONS, PLATFORMS, PROTOCOL_VERSION, PUSH_EVENTS, PUSH_TOKEN_PATTERN, RELOAD_PLATFORMS, REPLAY_MARKER_KINDS, REPLAY_RATES, ROTATE_DIRECTIONS, TOUCH_PHASES, VIDEO_CODECS } from "@stim-cli/core/protocol";
2
+ import { ACTIONS, BUILD_REPO_PATTERN, BUILD_RUBY_VERSION_PATTERN, CAPABILITIES, CONTROL_END_REASONS, DEVICE_POSTURES, ERROR_CODES, FEATURES, FRAME_EDGE, FRAME_FPS, INPUT_BUTTONS, INPUT_KEYS, KEY_MODIFIERS, LEGACY_PUSH_EVENTS, LOG_LEVELS, LOG_SOURCES, MAX_INPUT_TEXT, MAX_LOG_TAIL, NOTIFICATION_LEVELS, NOTIFICATION_SUPPRESSIONS, PLATFORMS, PROTOCOL_VERSION, PUSH_EVENTS, PUSH_TOKEN_PATTERN, RELOAD_PLATFORMS, REPLAY_MARKER_KINDS, REPLAY_RATES, ROTATE_DIRECTIONS, TOUCH_PHASES, VIDEO_CODECS } from "@stim-cli/core/protocol";
3
3
  //#region \0rolldown/runtime.js
4
4
  var __defProp = Object.defineProperty;
5
5
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -785,7 +785,11 @@ function protocolJsonSchema() {
785
785
  required: ["name", "version"],
786
786
  properties: {
787
787
  name: { type: "string" },
788
- version: { type: "string" }
788
+ version: { type: "string" },
789
+ runId: {
790
+ type: "string",
791
+ pattern: "^[A-Za-z0-9._-]{1,64}$"
792
+ }
789
793
  }
790
794
  },
791
795
  auth: { oneOf: [
@@ -2209,7 +2213,11 @@ function protocolJsonSchema() {
2209
2213
  additionalProperties: false,
2210
2214
  properties: {
2211
2215
  repo: buildRepo,
2212
- lockfile: sha256
2216
+ lockfile: sha256,
2217
+ rubyVersion: {
2218
+ type: "string",
2219
+ pattern: BUILD_RUBY_VERSION_PATTERN
2220
+ }
2213
2221
  }
2214
2222
  }),
2215
2223
  request("build.sync", {
package/dist/protocol.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { t as protocolJsonSchema } from "./protocol-Btu-PwQS.mjs";
1
+ import { t as protocolJsonSchema } from "./protocol-qo5TTavl.mjs";
2
2
  export * from "@stim-cli/core/protocol";
3
3
  export { protocolJsonSchema };
@@ -1145,6 +1145,10 @@
1145
1145
  },
1146
1146
  "version": {
1147
1147
  "type": "string"
1148
+ },
1149
+ "runId": {
1150
+ "type": "string",
1151
+ "pattern": "^[A-Za-z0-9._-]{1,64}$"
1148
1152
  }
1149
1153
  }
1150
1154
  },
@@ -1420,7 +1424,8 @@
1420
1424
  "device",
1421
1425
  "build",
1422
1426
  "agent",
1423
- "maintenance"
1427
+ "maintenance",
1428
+ "placement"
1424
1429
  ]
1425
1430
  }
1426
1431
  },
@@ -5215,6 +5220,10 @@
5215
5220
  "lockfile": {
5216
5221
  "type": "string",
5217
5222
  "pattern": "^[0-9a-f]{64}$"
5223
+ },
5224
+ "rubyVersion": {
5225
+ "type": "string",
5226
+ "pattern": "^(?!\\.{1,2}$)[^/\\\\\\u0000\\r\\n]+$"
5218
5227
  }
5219
5228
  }
5220
5229
  }
@@ -1,8 +1,8 @@
1
- import { n as protocol_exports } from "./protocol-Btu-PwQS.mjs";
1
+ import { n as protocol_exports } from "./protocol-qo5TTavl.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
4
4
  import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
- import { HOSTED_APP_CHUNK_BYTES, HOSTED_MACOS_APP_SLOTS, RECORDING_CAP_BYTES, RECORDING_FOOTAGE_MS, RECORDING_PLATFORMS, archiveDir, assertHostedDeviceLedger, buildWorkerRoot, capDrops, closedSegmentName, compileGrep, createAgentActionReader, deviceHostArea, deviceHostRoot, deviceViewersDir, footageDrops, getConcurrencyLimits, getParkedMax, hostedAndroidPlacements, hostedAppArea, hostedAppAttempt, hostedAppBlobs, hostedDeviceId, hostedIosPlacements, hostedMacosBundleId, hostedMacosLogsDir, hostedNativeLogsDir, isJournalExpired, isJsonObject, isSetupJournalPrunable, listRecordedDevices, listSegments, loadConfig, machineCapacity, macosAppState, openSegmentName, parseHostedAppOffer, parseHostedLocalDeviceCount, parseHostedLogsCursor, parseHostedNativeOffer, parseHostedOfferRequest, parseHostedPlatformDevice, parseHostedRequest, parseNdjsonLine, pinnedEndpoint, queryJsonLogs, readArchive, readArchiveState, readBuildHistory, readBuildMachines, readDeviceHostMachines, readHostedApp, readHostedAppMetadata, readHostedDevice, readHostedDeviceLedger, readHostedMacosApp, readHostedNativeLogsCheckpoint, readHostedSessions, readJsonObject, readLogTimeline, readLogsSince, readMacosRecord, readSetupJournal, readWorkspaceState, recordingDeviceName, rememberHostedAppBlobRename, saturation, serverSetupDir, settingDefinition, settingValueError, setupJournalFile, sortByTs, stimBuildDigest, tryAcquireBuildSlotClaim, validHostedAppBlob, workspaceRecordingsDir } from "@stim-cli/core/state";
5
+ import { HOSTED_APP_CHUNK_BYTES, HOSTED_MACOS_APP_SLOTS, RECORDING_CAP_BYTES, RECORDING_FOOTAGE_MS, RECORDING_PLATFORMS, archiveDir, assertHostedDeviceLedger, buildWorkerRoot, capDrops, closedSegmentName, compileGrep, createAgentActionReader, createDebugLog, deviceHostArea, deviceHostRoot, deviceViewersDir, footageDrops, getConcurrencyLimits, getParkedMax, hostedAndroidPlacements, hostedAppArea, hostedAppAttempt, hostedAppBlobs, hostedDeviceId, hostedIosPlacements, hostedMacosBundleId, hostedMacosLogsDir, hostedNativeLogsDir, isJournalExpired, isJsonObject, isSetupJournalPrunable, listRecordedDevices, listSegments, loadConfig, machineCapacity, macosAppState, openSegmentName, parseHostedAppOffer, parseHostedLocalDeviceCount, parseHostedLogsCursor, parseHostedNativeOffer, parseHostedOfferRequest, parseHostedPlatformDevice, parseHostedRequest, parseNdjsonLine, pinnedEndpoint, queryJsonLogs, readArchive, readArchiveState, readBuildHistory, readBuildMachines, readDeviceHostMachines, readHostedApp, readHostedAppMetadata, readHostedDevice, readHostedDeviceLedger, readHostedMacosApp, readHostedNativeLogsCheckpoint, readHostedSessions, readJsonObject, readLogTimeline, readLogsSince, readMacosRecord, readSetupJournal, readWorkspaceState, recordingDeviceName, rememberHostedAppBlobRename, saturation, serverSetupDir, settingDefinition, settingValueError, setupJournalFile, sortByTs, stimBuildDigest, tryAcquireBuildSlotClaim, validHostedAppBlob, validRunId, workspaceRecordingsDir } from "@stim-cli/core/state";
6
6
  import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, cpSync, createReadStream, existsSync, futimesSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, statfsSync, watch, writeFileSync, writeSync } from "node:fs";
7
7
  import { availableParallelism, cpus, freemem, homedir, hostname, loadavg, tmpdir, totalmem } from "node:os";
8
8
  import { parseArgs } from "node:util";
@@ -1186,30 +1186,6 @@ function readTailscaleStatus(binary, env, timeoutMs = TIMEOUT_MS$1) {
1186
1186
  });
1187
1187
  });
1188
1188
  }
1189
- function tailscaleStatus(binary, env) {
1190
- if (!binary) return {
1191
- state: "unavailable",
1192
- reason: "the tailscale command was not found"
1193
- };
1194
- try {
1195
- const output = execFileSync(binary, ["status", "--json"], {
1196
- env,
1197
- timeout: TIMEOUT_MS$1,
1198
- encoding: "utf8",
1199
- stdio: [
1200
- "ignore",
1201
- "pipe",
1202
- "ignore"
1203
- ]
1204
- });
1205
- return parseTailscaleStatus(JSON.parse(output));
1206
- } catch (error) {
1207
- return {
1208
- state: "unavailable",
1209
- reason: `\`tailscale status --json\` failed: ${error.message}`
1210
- };
1211
- }
1212
- }
1213
1189
  function reaches(address, target, hosts) {
1214
1190
  if (typeof address !== "string") return null;
1215
1191
  let url;
@@ -1556,7 +1532,7 @@ async function waitForHealth(port, label) {
1556
1532
  }
1557
1533
  async function prepareRoute(port, previous) {
1558
1534
  const binary = findTailscale(process.env);
1559
- const status = tailscaleStatus(binary, process.env);
1535
+ const status = await readTailscaleStatus(binary, process.env);
1560
1536
  if (!binary || status.state !== "running") throw new ServiceError("--serve needs Tailscale running on this Mac, and the tailscale command on PATH.");
1561
1537
  const plan = planServe(await serveRoute(binary, process.env, port, status.ips), port, previous?.serve ?? null);
1562
1538
  if ("refusal" in plan) throw new ServiceError(plan.refusal);
@@ -1834,7 +1810,7 @@ async function uninstallJob(label) {
1834
1810
  let routeOff = null;
1835
1811
  const tailscale = findTailscale(process.env);
1836
1812
  if (installed.serve?.created && installed.port !== null) {
1837
- const status = tailscaleStatus(tailscale, process.env);
1813
+ const status = await readTailscaleStatus(tailscale, process.env);
1838
1814
  if (!tailscale || status.state !== "running") throw new ServiceError(`${label} created the tailscale serve route on https port ${installed.serve.port}, and Tailscale is not answering, so uninstall cannot check it. Start Tailscale and run this again.`);
1839
1815
  const route = await serveRoute(tailscale, process.env, installed.port, status.ips);
1840
1816
  if (route.state === "routed" && route.port === installed.serve.port) routeOff = installed.serve.port;
@@ -2619,8 +2595,7 @@ var BuildHost = class {
2619
2595
  retained = /* @__PURE__ */ new Map();
2620
2596
  closed = false;
2621
2597
  draining = null;
2622
- toolchainAt = 0;
2623
- toolchainValue = null;
2598
+ toolchains = /* @__PURE__ */ new Map();
2624
2599
  options;
2625
2600
  sweeping = Promise.resolve();
2626
2601
  queued = null;
@@ -2674,11 +2649,17 @@ var BuildHost = class {
2674
2649
  clientDir(client) {
2675
2650
  return join(this.root(), client);
2676
2651
  }
2677
- toolchain() {
2678
- if (this.toolchainValue && Date.now() - this.toolchainAt < this.limits.toolchainTtlMs) return this.toolchainValue;
2679
- this.toolchainAt = Date.now();
2680
- this.toolchainValue = new Promise((resolve) => {
2681
- const child = spawn(process.execPath, [this.options.worker, "offer"], {
2652
+ toolchain(rubyVersion = null) {
2653
+ const now = Date.now();
2654
+ for (const [context, entry] of this.toolchains) if (now - entry.at >= this.limits.toolchainTtlMs) this.toolchains.delete(context);
2655
+ const cached = this.toolchains.get(rubyVersion);
2656
+ if (cached) return cached.value;
2657
+ const value = new Promise((resolve) => {
2658
+ const child = spawn(process.execPath, [
2659
+ this.options.worker,
2660
+ "offer",
2661
+ ...rubyVersion ? [rubyVersion] : []
2662
+ ], {
2682
2663
  env: this.options.env,
2683
2664
  stdio: [
2684
2665
  "ignore",
@@ -2694,21 +2675,26 @@ var BuildHost = class {
2694
2675
  child.on("close", () => {
2695
2676
  clearTimeout(timer);
2696
2677
  try {
2697
- const value = JSON.parse(out);
2698
- resolve(isJsonObject(value) ? value : null);
2678
+ const report = JSON.parse(out);
2679
+ resolve(isJsonObject(report) ? report : null);
2699
2680
  } catch {
2700
2681
  resolve(null);
2701
2682
  }
2702
2683
  });
2703
2684
  });
2704
- this.toolchainValue.then((value) => {
2705
- if (!value) this.toolchainValue = null;
2685
+ this.toolchains.set(rubyVersion, {
2686
+ at: now,
2687
+ value
2688
+ });
2689
+ value.then((result) => {
2690
+ if (!result && this.toolchains.get(rubyVersion)?.value === value) this.toolchains.delete(rubyVersion);
2706
2691
  });
2707
- return this.toolchainValue;
2692
+ return value;
2708
2693
  }
2709
2694
  async offer(client, params) {
2710
2695
  if (!isJsonObject(params) || !validRepo(params.repo)) return refusal("bad-request", "build.offer needs params.repo.");
2711
- const toolchain = await this.toolchain();
2696
+ if (params.rubyVersion !== void 0 && (typeof params.rubyVersion !== "string" || !new RegExp(protocol_exports.BUILD_RUBY_VERSION_PATTERN).test(params.rubyVersion))) return refusal("bad-request", "build.offer needs a Ruby installation name without path separators.");
2697
+ const toolchain = await this.toolchain(params.rubyVersion ?? null);
2712
2698
  if (!toolchain) return refusal("build-refused", "This Mac could not read its build toolchain.");
2713
2699
  const root = this.root();
2714
2700
  mkdirSync(root, {
@@ -2720,9 +2706,18 @@ var BuildHost = class {
2720
2706
  try {
2721
2707
  lockfile = readFileSync(join(area, "lockfile"), "utf8");
2722
2708
  } catch {}
2709
+ const available = await readAvailableMemory();
2710
+ const total = totalmem();
2711
+ const memory = available === null ? {} : {
2712
+ memoryTotalBytes: total,
2713
+ memoryUsedBytes: total - available
2714
+ };
2723
2715
  return { result: {
2724
2716
  toolchain,
2725
- capacity: this.capacity(),
2717
+ capacity: {
2718
+ ...this.capacity(),
2719
+ ...memory
2720
+ },
2726
2721
  warm: {
2727
2722
  checkout: existsSync(join(area, "src")),
2728
2723
  dependencies: typeof params.lockfile === "string" && lockfile === params.lockfile,
@@ -3599,7 +3594,7 @@ async function confirmSetup(input, question, timeoutMs) {
3599
3594
  input.on("SIGINT", interrupt);
3600
3595
  input.on("close", interrupt);
3601
3596
  try {
3602
- return /^(y|yes)$/i.test((await input.question(question, { signal: controller.signal })).trim());
3597
+ return /^(|y|yes)$/i.test((await input.question(question, { signal: controller.signal })).trim());
3603
3598
  } catch (error) {
3604
3599
  if (controller.signal.reason instanceof SetupRefusal) throw controller.signal.reason;
3605
3600
  if (error.name === "AbortError") return false;
@@ -3611,6 +3606,15 @@ async function confirmSetup(input, question, timeoutMs) {
3611
3606
  input.close();
3612
3607
  }
3613
3608
  }
3609
+ const discardInput = () => void 0;
3610
+ async function discardTypedAhead(stream) {
3611
+ if (!stream.isTTY) return;
3612
+ stream.on("data", discardInput);
3613
+ stream.resume();
3614
+ await sleep(300);
3615
+ stream.off("data", discardInput);
3616
+ stream.pause();
3617
+ }
3614
3618
  function defaultSetupDeps() {
3615
3619
  return {
3616
3620
  now: Date.now,
@@ -3619,10 +3623,13 @@ function defaultSetupDeps() {
3619
3623
  display: setupDisplayFor(process.stdout.isTTY === true, process.env, (text) => void process.stdout.write(text)),
3620
3624
  stdout: (line) => console.log(line),
3621
3625
  stderr: (line) => console.error(line),
3622
- confirm: (question, timeoutMs) => confirmSetup(createInterface({
3623
- input: process.stdin,
3624
- output: process.stderr
3625
- }), question, timeoutMs),
3626
+ confirm: async (question, timeoutMs) => {
3627
+ await discardTypedAhead(process.stdin);
3628
+ return confirmSetup(createInterface({
3629
+ input: process.stdin,
3630
+ output: process.stderr
3631
+ }), question, timeoutMs);
3632
+ },
3626
3633
  permissionWait(ms) {
3627
3634
  if (!process.stdin.isTTY) return sleep(ms).then(() => false);
3628
3635
  return new Promise((resolve, reject) => {
@@ -3798,9 +3805,9 @@ const capabilityNoun = (capability) => capability === "build" ? "builds" : "host
3798
3805
  function approvalQuestion(concise, name, capability, nodeId) {
3799
3806
  if (!concise) {
3800
3807
  const verb = capability === "build" ? "build on this Mac" : "host simulators on this Mac";
3801
- return `${name} (node ${nodeId.slice(0, 4)}...) asks to ${verb}. Approve? [y/N] `;
3808
+ return `${name} (node ${nodeId.slice(0, 4)}...) asks to ${verb}. Approve? [Y/n] `;
3802
3809
  }
3803
- return `Approve ${name} to ${capability === "build" ? "build here (runs its project code" : "host simulators here (runs its app code"} on this Mac)? [y/N] `;
3810
+ return `Approve ${name} to ${capability === "build" ? "build here (runs its project code" : "host simulators here (runs its app code"} on this Mac)? [Y/n] `;
3804
3811
  }
3805
3812
  function serverText(desktop, decision, version) {
3806
3813
  if (desktop) return `using the app's stim-server ${version}`;
@@ -4180,6 +4187,93 @@ async function runSetup(args, version, deps) {
4180
4187
  return journal.exit;
4181
4188
  }
4182
4189
  //#endregion
4190
+ //#region src/request-log.ts
4191
+ const SLOW_MS = 1e3;
4192
+ const METHOD = /^[a-z][a-z0-9.-]{0,47}$/;
4193
+ const ERROR_REPEAT_MS = 6e4;
4194
+ const MAX_REMEMBERED = 256;
4195
+ /** One token of a service-log line: no whitespace or control characters, so a client cannot forge another field or line. */
4196
+ const printable = (value) => String(value).replace(/[^\x21-\x7e]+/g, "_").slice(0, 80);
4197
+ const text = (fields) => Object.entries(fields).filter(([, value]) => value !== void 0 && value !== null).map(([key, value]) => `${key}=${printable(value)}`).join(" ");
4198
+ /**
4199
+ * Request timing for stim-server. An error reply always goes to the service log (stderr), once a minute per
4200
+ * client, method and code. With debug logging on, every request goes to the service log and to
4201
+ * `logs/debug/server.ndjson` with its duration and the slow steps. Lines carry the method, the client's device id,
4202
+ * the CLI run id, durations and the error code: never params, messages, tokens or tickets.
4203
+ */
4204
+ function createRequestLog({ service = (line) => console.error(line), debug = createDebugLog("server"), now = Date.now } = {}) {
4205
+ const lastError = /* @__PURE__ */ new Map();
4206
+ const errorDue = (key) => {
4207
+ const at = now();
4208
+ const last = lastError.get(key);
4209
+ if (last !== void 0 && at - last < ERROR_REPEAT_MS) return false;
4210
+ lastError.delete(key);
4211
+ lastError.set(key, at);
4212
+ if (lastError.size > MAX_REMEMBERED) lastError.delete(lastError.keys().next().value);
4213
+ return true;
4214
+ };
4215
+ return {
4216
+ track() {
4217
+ let client = {
4218
+ id: null,
4219
+ runId: null
4220
+ };
4221
+ const inflight = /* @__PURE__ */ new Map();
4222
+ return {
4223
+ identify(next) {
4224
+ client = next;
4225
+ },
4226
+ begin(id, method) {
4227
+ if (id === null) return;
4228
+ if (inflight.size >= MAX_REMEMBERED) inflight.delete(inflight.keys().next().value);
4229
+ inflight.set(id, {
4230
+ method: METHOD.test(method) ? method : "unknown",
4231
+ start: now(),
4232
+ steps: {}
4233
+ });
4234
+ },
4235
+ step(id, name, ms) {
4236
+ const entry = inflight.get(id);
4237
+ if (entry) entry.steps[name] = Math.round(ms);
4238
+ },
4239
+ reply(message) {
4240
+ if (!message || typeof message !== "object" || !("id" in message)) return;
4241
+ const { id, error } = message;
4242
+ const entry = inflight.get(id);
4243
+ if (entry) inflight.delete(id);
4244
+ if (!entry && !error) return;
4245
+ const code = error ? String(error.code ?? "error").slice(0, 64) : void 0;
4246
+ const method = entry?.method ?? "unknown";
4247
+ const ms = entry ? now() - entry.start : void 0;
4248
+ const fields = {
4249
+ method,
4250
+ client: client.id ?? void 0,
4251
+ run: client.runId ?? void 0,
4252
+ ms,
4253
+ ...ms !== void 0 && ms >= SLOW_MS ? { slow: true } : {},
4254
+ ...entry?.steps,
4255
+ error: code
4256
+ };
4257
+ if (debug.enabled()) {
4258
+ service(`stim-server: debug request ${text(fields)}`);
4259
+ const { run, ...rest } = fields;
4260
+ debug.log("request", {
4261
+ ...rest,
4262
+ runId: run
4263
+ });
4264
+ } else if (code !== void 0 && errorDue(`${client.id}|${method}|${code}`)) service(`stim-server: request failed ${text(fields)}`);
4265
+ }
4266
+ };
4267
+ },
4268
+ host(event, fields, failed = false) {
4269
+ if (debug.enabled()) {
4270
+ service(`stim-server: debug ${event} ${text(fields)}`);
4271
+ debug.log(event, fields);
4272
+ } else if (failed && errorDue(`host|${event}|${String(fields.host)}`)) service(`stim-server: ${event} ${text(fields)}`);
4273
+ }
4274
+ };
4275
+ }
4276
+ //#endregion
4183
4277
  //#region src/agent-driver.ts
4184
4278
  /** The driver cannot run or cannot scope an app; the message is safe to show a client. */
4185
4279
  var AgentDriverUnavailable = class extends Error {};
@@ -8835,6 +8929,7 @@ var LatestFrames = class {
8835
8929
  //#endregion
8836
8930
  //#region src/hosted-relay.ts
8837
8931
  const TIMEOUT_MS = 1e4;
8932
+ const REQUEST_TIMEOUT_MS = 2e4;
8838
8933
  const KEYFRAME_RETRY_MS = 1e3;
8839
8934
  const CONGESTION_NOTICE_MS = 250;
8840
8935
  function routeOf(message) {
@@ -8857,6 +8952,8 @@ var Upstream = class {
8857
8952
  users = 0;
8858
8953
  features = [];
8859
8954
  onClose = null;
8955
+ connectMs = 0;
8956
+ helloMs = 0;
8860
8957
  token;
8861
8958
  constructor(endpoint, token) {
8862
8959
  this.token = token;
@@ -8923,6 +9020,7 @@ var Upstream = class {
8923
9020
  }
8924
9021
  /** Connects and says hello with the token; the host must grant `capability` to this Mac. */
8925
9022
  async open(version, capability = "device-host") {
9023
+ const started = Date.now();
8926
9024
  await new Promise((resolve, reject) => {
8927
9025
  const done = (error) => {
8928
9026
  clearTimeout(timer);
@@ -8943,6 +9041,7 @@ var Upstream = class {
8943
9041
  this.socket.once("close", closed);
8944
9042
  this.socket.once("error", failed);
8945
9043
  });
9044
+ this.connectMs = Date.now() - started;
8946
9045
  const reply = await this.request("hello", {
8947
9046
  protocol: 1,
8948
9047
  client: {
@@ -8951,6 +9050,7 @@ var Upstream = class {
8951
9050
  },
8952
9051
  auth: { deviceToken: this.token }
8953
9052
  });
9053
+ this.helloMs = Date.now() - started - this.connectMs;
8954
9054
  if ("error" in reply) throw new Error(reply.error.message);
8955
9055
  if (!isJsonObject(reply.result) || !Array.isArray(reply.result.capabilities) || !reply.result.capabilities.includes(capability)) throw new Error(`the host did not grant ${capability} access`);
8956
9056
  if (Array.isArray(reply.result.features)) this.features = reply.result.features.filter((feature) => typeof feature === "string");
@@ -8959,7 +9059,7 @@ var Upstream = class {
8959
9059
  if (this.ended) return Promise.reject(this.ended);
8960
9060
  return new Promise((resolve, reject) => {
8961
9061
  const id = this.nextId++;
8962
- const timer = setTimeout(() => this.fail(`the host did not answer ${method} in time`), TIMEOUT_MS);
9062
+ const timer = setTimeout(() => this.fail(`the host was busy or slow: it did not answer ${method} within ${REQUEST_TIMEOUT_MS / 1e3} seconds`), REQUEST_TIMEOUT_MS);
8963
9063
  this.pending.set(id, {
8964
9064
  resolve: (reply) => {
8965
9065
  clearTimeout(timer);
@@ -9032,6 +9132,7 @@ var HostConnections = class {
9032
9132
  /** Checks the credential and pinned node on every call, then joins or opens the connection to that endpoint. */
9033
9133
  async acquire(host) {
9034
9134
  let token = "";
9135
+ const started = Date.now();
9035
9136
  try {
9036
9137
  const credential = readDeviceHostMachines().find((entry) => entry.machine === host.machine);
9037
9138
  if (!credential || credential.state !== "approved") throw new Error("no approved device-host credential");
@@ -9047,6 +9148,7 @@ var HostConnections = class {
9047
9148
  endpoint.host
9048
9149
  ]);
9049
9150
  let entry = this.open.get(key);
9151
+ const created = !entry;
9050
9152
  if (!entry) {
9051
9153
  const connection = new Upstream(endpoint, token);
9052
9154
  const opened = {
@@ -9067,10 +9169,24 @@ var HostConnections = class {
9067
9169
  lease.release();
9068
9170
  throw cause;
9069
9171
  }
9172
+ this.options.log?.("host_connect", {
9173
+ host: host.machine,
9174
+ ms: Date.now() - started,
9175
+ ...created ? {
9176
+ connectMs: entry.connection.connectMs,
9177
+ helloMs: entry.connection.helloMs
9178
+ } : { reused: true }
9179
+ });
9070
9180
  return lease;
9071
9181
  } catch (cause) {
9072
9182
  const reason = cause.message;
9073
- throw new Error(token ? reason.replaceAll(token, "[redacted]") : reason, { cause });
9183
+ const safe = token ? reason.replaceAll(token, "[redacted]") : reason;
9184
+ this.options.log?.("host_connect", {
9185
+ host: host.machine,
9186
+ ms: Date.now() - started,
9187
+ error: safe.slice(0, 200)
9188
+ }, true);
9189
+ throw new Error(safe, { cause });
9074
9190
  }
9075
9191
  }
9076
9192
  close() {
@@ -13064,7 +13180,9 @@ function take(bucket, cost, perSecond, capacity) {
13064
13180
  bucket.tokens -= cost;
13065
13181
  return true;
13066
13182
  }
13183
+ const trackers = /* @__PURE__ */ new WeakMap();
13067
13184
  function send(socket, message) {
13185
+ if (typeof message !== "string") trackers.get(socket)?.reply(message);
13068
13186
  if (socket.readyState === socket.OPEN) socket.send(typeof message === "string" ? message : JSON.stringify(message));
13069
13187
  }
13070
13188
  function buildAllowed(session) {
@@ -13166,6 +13284,7 @@ async function startServer(options) {
13166
13284
  }
13167
13285
  return hostProbe;
13168
13286
  };
13287
+ const requestLog = options.requestLog ?? createRequestLog();
13169
13288
  const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
13170
13289
  const authTimeoutMs = options.authTimeoutMs ?? 5e3;
13171
13290
  const feeds = new FeedPool(options.stimCli, options.env);
@@ -13480,7 +13599,10 @@ async function startServer(options) {
13480
13599
  limits: options.pushLimits
13481
13600
  });
13482
13601
  const relays = /* @__PURE__ */ new Map();
13483
- const hostConnections = new HostConnections(options.hostedRelay ?? { status: () => readRawTailscaleStatus(tailscaleNow().binary, options.env) }, options.serverVersion);
13602
+ const hostConnections = new HostConnections({
13603
+ ...options.hostedRelay ?? { status: () => readRawTailscaleStatus(tailscaleNow().binary, options.env) },
13604
+ log: requestLog.host
13605
+ }, options.serverVersion);
13484
13606
  let revocationCheck = null;
13485
13607
  let checkedRegistry = null;
13486
13608
  const checkRevocations = () => {
@@ -13544,6 +13666,9 @@ async function startServer(options) {
13544
13666
  const commands = /* @__PURE__ */ new Set();
13545
13667
  let nextSubscription = 1;
13546
13668
  let device = null;
13669
+ const tracker = requestLog.track();
13670
+ trackers.set(socket, tracker);
13671
+ let runId = null;
13547
13672
  let buildSession = null;
13548
13673
  let queue = Promise.resolve();
13549
13674
  let archiveReads = Promise.resolve();
@@ -13584,7 +13709,15 @@ async function startServer(options) {
13584
13709
  if (!isJsonObject(params) || !auth || !isJsonObject(params.client)) return refuse(id, "bad-request", "hello needs params.client and params.auth.", CLOSE_BAD_REQUEST);
13585
13710
  if (params.protocol !== protocol_exports.PROTOCOL_VERSION) return refuse(id, "protocol-unsupported", `This server speaks protocol ${protocol_exports.PROTOCOL_VERSION}.`, CLOSE_BAD_REQUEST);
13586
13711
  clearTimeout(timer);
13712
+ runId = validRunId(params.client.runId);
13713
+ tracker.identify({
13714
+ id: null,
13715
+ runId
13716
+ });
13717
+ const probing = hostHealth();
13718
+ const whoisStarted = Date.now();
13587
13719
  const identity = await identify();
13720
+ tracker.step(id, "whois", Date.now() - whoisStarted);
13588
13721
  if (socket.readyState !== socket.OPEN) return;
13589
13722
  if (!identity) return refuse(id, "identity-unavailable", `tailscale whois did not identify ${peer}.`, CLOSE_UNAUTHORIZED);
13590
13723
  let outcome;
@@ -13638,8 +13771,15 @@ async function startServer(options) {
13638
13771
  return;
13639
13772
  }
13640
13773
  device = outcome.device;
13774
+ tracker.identify({
13775
+ id: device.id,
13776
+ runId
13777
+ });
13641
13778
  sessions.set(socket, device);
13642
13779
  sampler.start();
13780
+ const probeWaited = Date.now();
13781
+ const host = options.host ? await probing : void 0;
13782
+ if (options.host) tracker.step(id, "probe", Date.now() - probeWaited);
13643
13783
  send(socket, {
13644
13784
  id,
13645
13785
  result: {
@@ -13650,7 +13790,7 @@ async function startServer(options) {
13650
13790
  stim: options.stimVersion,
13651
13791
  home: homedir()
13652
13792
  },
13653
- ...options.host ? { host: await hostHealth() } : {},
13793
+ ...options.host ? { host } : {},
13654
13794
  capabilities: device.capabilities,
13655
13795
  features: [...protocol_exports.FEATURES],
13656
13796
  actions: device.capabilities.includes("control") ? [...protocol_exports.ACTIONS] : [],
@@ -14798,6 +14938,7 @@ async function startServer(options) {
14798
14938
  });
14799
14939
  return refuse(id, "bad-request", "Expected {id, method, params}.", CLOSE_BAD_REQUEST);
14800
14940
  }
14941
+ tracker.begin(id, message.method);
14801
14942
  if (message.method === "hello") return hello(id, message.params);
14802
14943
  if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_UNAUTHORIZED);
14803
14944
  if (protocol_exports.DEVICE_HOST_METHODS.includes(message.method)) {
@@ -15374,7 +15515,7 @@ async function serve(port, extraEnv, pathPrepend, loopbackOnly) {
15374
15515
  if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
15375
15516
  const launchdLabel = process.env.XPC_SERVICE_NAME;
15376
15517
  const tailscaleBinary = findTailscale(env);
15377
- const tailscale = tailscaleStatus(tailscaleBinary, env);
15518
+ const tailscale = await readTailscaleStatus(tailscaleBinary, env);
15378
15519
  const stim = bundledStim();
15379
15520
  const monitor = watchTailscale({
15380
15521
  env,
@@ -15450,7 +15591,7 @@ const SCOPE_TEXT = {
15450
15591
  };
15451
15592
  async function pair(port, json, control) {
15452
15593
  const binary = findTailscale(process.env);
15453
- const tailscale = tailscaleStatus(binary, process.env);
15594
+ const tailscale = await readTailscaleStatus(binary, process.env);
15454
15595
  let endpoint = `ws://127.0.0.1:${port}`;
15455
15596
  let note = tailscaleNote(tailscale);
15456
15597
  if (note) note = `${note} The endpoint above only works on this Mac.`;
@@ -4,6 +4,6 @@ const encode = (version) => version.split(".", 3).reduce((sum, part) => sum * 1e
4
4
  if (encode(process.versions.node) < encode("22.12.0")) {
5
5
  process.stderr.write(`STIM_NODE_UNSUPPORTED: stim-server needs Node 22.12.0 or later; this is Node ${process.versions.node} at ${process.execPath}. To run stim-server where a project pins an older Node, see https://stim.appandflow.com/docs/requirements#older-node-pins\n`);
6
6
  process.exitCode = 1;
7
- } else import("./stim-server-CJIlV28Y.mjs");
7
+ } else import("./stim-server-BR9FPA63.mjs");
8
8
  //#endregion
9
9
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stim-cli/server",
3
- "version": "1.17.2",
3
+ "version": "1.18.0",
4
4
  "description": "Serves Stim status to paired clients over Tailscale, and runs reload and stop for clients granted control.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -28,8 +28,8 @@
28
28
  "./protocol.schema.json": "./dist/protocol.schema.json"
29
29
  },
30
30
  "dependencies": {
31
- "@stim-cli/core": "^1.17.2",
32
- "stim": "1.17.2",
31
+ "@stim-cli/core": "^1.18.0",
32
+ "stim": "1.18.0",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {