@stim-cli/server 1.17.2 → 1.19.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
  }
@@ -66,7 +66,10 @@ public enum CoreSimulator {
66
66
  }
67
67
 
68
68
  static func simulatorKitPath(_ developerDir: String) -> String {
69
- "\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit"
69
+ let shared = "\(developerDir)/../SharedFrameworks/SimulatorKit.framework/SimulatorKit"
70
+ let legacy = "\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"
71
+ return FileManager.default.fileExists(atPath: shared) || !FileManager.default.fileExists(atPath: legacy)
72
+ ? shared : legacy
70
73
  }
71
74
 
72
75
  static let deviceSet: NSObject? = {
@@ -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
2706
2688
  });
2707
- return this.toolchainValue;
2689
+ value.then((result) => {
2690
+ if (!result && this.toolchains.get(rubyVersion)?.value === value) this.toolchains.delete(rubyVersion);
2691
+ });
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 {};
@@ -6441,9 +6535,11 @@ var DeviceHost = class {
6441
6535
  reconciling;
6442
6536
  draining = null;
6443
6537
  limits;
6538
+ debug;
6444
6539
  options;
6445
6540
  constructor(options) {
6446
6541
  this.options = options;
6542
+ this.debug = createDebugLog("server", { env: options.env });
6447
6543
  this.limits = {
6448
6544
  prepareMs: 3e5,
6449
6545
  offerMs: 3e4,
@@ -7593,6 +7689,15 @@ var DeviceHost = class {
7593
7689
  if (options.claim) clearClaimChild(options.claim);
7594
7690
  throw error;
7595
7691
  }
7692
+ const started = performance.now();
7693
+ const mode = "mode" in options.input && typeof options.input.mode === "string" ? options.input.mode : void 0;
7694
+ const diagnostic = (event, fields = {}) => this.debug.log(`device-host.worker.${event}`, {
7695
+ mode,
7696
+ workerPid: child.pid,
7697
+ ms: Math.round(performance.now() - started),
7698
+ ...fields
7699
+ });
7700
+ diagnostic("start", { timeoutMs: options.timeoutMs });
7596
7701
  let identity = null;
7597
7702
  const output = [];
7598
7703
  let outputBytes = 0;
@@ -7615,7 +7720,14 @@ var DeviceHost = class {
7615
7720
  clearTimeout(killTimer);
7616
7721
  clearTimeout(finishTimer);
7617
7722
  clearTimeout(groupTimer);
7618
- const settled = closed && (!child.pid || !processGroupAlive(child.pid));
7723
+ const groupAlive = closed && child.pid ? processGroupAlive(child.pid) : null;
7724
+ const settled = closed && groupAlive !== true;
7725
+ diagnostic("settled", {
7726
+ closed,
7727
+ groupAlive,
7728
+ settled,
7729
+ cancelling
7730
+ });
7619
7731
  if (settled && options.claim) clearClaimChild(options.claim);
7620
7732
  let value = null;
7621
7733
  try {
@@ -7643,9 +7755,10 @@ var DeviceHost = class {
7643
7755
  process.kill(-child.pid, name);
7644
7756
  } catch {}
7645
7757
  };
7646
- const cancel = () => {
7758
+ const cancel = (reason = "requested") => {
7647
7759
  if (finished || cancelling) return;
7648
7760
  cancelling = true;
7761
+ diagnostic("cancel", { reason });
7649
7762
  notice ??= "Hosted worker was cancelled or exceeded its deadline.";
7650
7763
  signal("SIGTERM");
7651
7764
  killTimer = setTimeout(() => signal("SIGKILL"), this.limits.killGraceMs);
@@ -7655,15 +7768,15 @@ var DeviceHost = class {
7655
7768
  if (finished) return;
7656
7769
  if (!child.pid || !processGroupAlive(child.pid)) finish();
7657
7770
  else {
7658
- cancel();
7771
+ cancel("group-after-close");
7659
7772
  groupTimer = setTimeout(finishGroup, 25);
7660
7773
  }
7661
7774
  };
7662
- const timer = setTimeout(cancel, options.timeoutMs);
7775
+ const timer = setTimeout(() => cancel("deadline"), options.timeoutMs);
7663
7776
  child.stdout?.on("data", (chunk) => {
7664
7777
  if (outputBytes + chunk.length > options.maxOutputBytes) {
7665
7778
  notice = "Hosted worker output exceeded its bound.";
7666
- cancel();
7779
+ cancel("output-bound");
7667
7780
  } else {
7668
7781
  output.push(chunk);
7669
7782
  outputBytes += chunk.length;
@@ -7672,13 +7785,18 @@ var DeviceHost = class {
7672
7785
  child.stderr?.on("data", (chunk) => {
7673
7786
  stderr = (stderr + chunk.toString()).slice(-8192);
7674
7787
  });
7675
- child.stdin?.on("error", () => cancel());
7788
+ child.stdin?.on("error", () => cancel("stdin-error"));
7676
7789
  child.once("error", (error) => {
7790
+ diagnostic("error", { code: error.code });
7677
7791
  notice = error.message;
7678
7792
  closed = true;
7679
7793
  finishGroup();
7680
7794
  });
7681
- child.once("close", (code) => {
7795
+ child.once("close", (code, closeSignal) => {
7796
+ diagnostic("close", {
7797
+ code,
7798
+ signal: closeSignal
7799
+ });
7682
7800
  if (code !== 0) notice ??= stderr.trim() || `Hosted worker exited ${code}.`;
7683
7801
  closed = true;
7684
7802
  finishGroup();
@@ -7687,7 +7805,7 @@ var DeviceHost = class {
7687
7805
  if (!captured?.ok || child.pid === void 0) {
7688
7806
  notice = "The worker process identity could not be captured; no native request was sent.";
7689
7807
  child.kill("SIGKILL");
7690
- cancel();
7808
+ cancel("identity-unavailable");
7691
7809
  } else {
7692
7810
  identity = {
7693
7811
  pid: child.pid,
@@ -7698,7 +7816,7 @@ var DeviceHost = class {
7698
7816
  child.stdin?.end(JSON.stringify(options.input));
7699
7817
  } catch (error) {
7700
7818
  notice = error.message;
7701
- cancel();
7819
+ cancel("request-write");
7702
7820
  }
7703
7821
  }
7704
7822
  return {
@@ -8835,6 +8953,7 @@ var LatestFrames = class {
8835
8953
  //#endregion
8836
8954
  //#region src/hosted-relay.ts
8837
8955
  const TIMEOUT_MS = 1e4;
8956
+ const REQUEST_TIMEOUT_MS = 2e4;
8838
8957
  const KEYFRAME_RETRY_MS = 1e3;
8839
8958
  const CONGESTION_NOTICE_MS = 250;
8840
8959
  function routeOf(message) {
@@ -8857,6 +8976,8 @@ var Upstream = class {
8857
8976
  users = 0;
8858
8977
  features = [];
8859
8978
  onClose = null;
8979
+ connectMs = 0;
8980
+ helloMs = 0;
8860
8981
  token;
8861
8982
  constructor(endpoint, token) {
8862
8983
  this.token = token;
@@ -8923,6 +9044,7 @@ var Upstream = class {
8923
9044
  }
8924
9045
  /** Connects and says hello with the token; the host must grant `capability` to this Mac. */
8925
9046
  async open(version, capability = "device-host") {
9047
+ const started = Date.now();
8926
9048
  await new Promise((resolve, reject) => {
8927
9049
  const done = (error) => {
8928
9050
  clearTimeout(timer);
@@ -8943,6 +9065,7 @@ var Upstream = class {
8943
9065
  this.socket.once("close", closed);
8944
9066
  this.socket.once("error", failed);
8945
9067
  });
9068
+ this.connectMs = Date.now() - started;
8946
9069
  const reply = await this.request("hello", {
8947
9070
  protocol: 1,
8948
9071
  client: {
@@ -8951,6 +9074,7 @@ var Upstream = class {
8951
9074
  },
8952
9075
  auth: { deviceToken: this.token }
8953
9076
  });
9077
+ this.helloMs = Date.now() - started - this.connectMs;
8954
9078
  if ("error" in reply) throw new Error(reply.error.message);
8955
9079
  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
9080
  if (Array.isArray(reply.result.features)) this.features = reply.result.features.filter((feature) => typeof feature === "string");
@@ -8959,7 +9083,7 @@ var Upstream = class {
8959
9083
  if (this.ended) return Promise.reject(this.ended);
8960
9084
  return new Promise((resolve, reject) => {
8961
9085
  const id = this.nextId++;
8962
- const timer = setTimeout(() => this.fail(`the host did not answer ${method} in time`), TIMEOUT_MS);
9086
+ 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
9087
  this.pending.set(id, {
8964
9088
  resolve: (reply) => {
8965
9089
  clearTimeout(timer);
@@ -9032,6 +9156,7 @@ var HostConnections = class {
9032
9156
  /** Checks the credential and pinned node on every call, then joins or opens the connection to that endpoint. */
9033
9157
  async acquire(host) {
9034
9158
  let token = "";
9159
+ const started = Date.now();
9035
9160
  try {
9036
9161
  const credential = readDeviceHostMachines().find((entry) => entry.machine === host.machine);
9037
9162
  if (!credential || credential.state !== "approved") throw new Error("no approved device-host credential");
@@ -9047,6 +9172,7 @@ var HostConnections = class {
9047
9172
  endpoint.host
9048
9173
  ]);
9049
9174
  let entry = this.open.get(key);
9175
+ const created = !entry;
9050
9176
  if (!entry) {
9051
9177
  const connection = new Upstream(endpoint, token);
9052
9178
  const opened = {
@@ -9067,10 +9193,24 @@ var HostConnections = class {
9067
9193
  lease.release();
9068
9194
  throw cause;
9069
9195
  }
9196
+ this.options.log?.("host_connect", {
9197
+ host: host.machine,
9198
+ ms: Date.now() - started,
9199
+ ...created ? {
9200
+ connectMs: entry.connection.connectMs,
9201
+ helloMs: entry.connection.helloMs
9202
+ } : { reused: true }
9203
+ });
9070
9204
  return lease;
9071
9205
  } catch (cause) {
9072
9206
  const reason = cause.message;
9073
- throw new Error(token ? reason.replaceAll(token, "[redacted]") : reason, { cause });
9207
+ const safe = token ? reason.replaceAll(token, "[redacted]") : reason;
9208
+ this.options.log?.("host_connect", {
9209
+ host: host.machine,
9210
+ ms: Date.now() - started,
9211
+ error: safe.slice(0, 200)
9212
+ }, true);
9213
+ throw new Error(safe, { cause });
9074
9214
  }
9075
9215
  }
9076
9216
  close() {
@@ -13064,7 +13204,9 @@ function take(bucket, cost, perSecond, capacity) {
13064
13204
  bucket.tokens -= cost;
13065
13205
  return true;
13066
13206
  }
13207
+ const trackers = /* @__PURE__ */ new WeakMap();
13067
13208
  function send(socket, message) {
13209
+ if (typeof message !== "string") trackers.get(socket)?.reply(message);
13068
13210
  if (socket.readyState === socket.OPEN) socket.send(typeof message === "string" ? message : JSON.stringify(message));
13069
13211
  }
13070
13212
  function buildAllowed(session) {
@@ -13166,6 +13308,7 @@ async function startServer(options) {
13166
13308
  }
13167
13309
  return hostProbe;
13168
13310
  };
13311
+ const requestLog = options.requestLog ?? createRequestLog();
13169
13312
  const limiter = new FailureLimiter(options.maxAuthFailures ?? 5, options.failureWindowMs ?? 6e4);
13170
13313
  const authTimeoutMs = options.authTimeoutMs ?? 5e3;
13171
13314
  const feeds = new FeedPool(options.stimCli, options.env);
@@ -13480,7 +13623,10 @@ async function startServer(options) {
13480
13623
  limits: options.pushLimits
13481
13624
  });
13482
13625
  const relays = /* @__PURE__ */ new Map();
13483
- const hostConnections = new HostConnections(options.hostedRelay ?? { status: () => readRawTailscaleStatus(tailscaleNow().binary, options.env) }, options.serverVersion);
13626
+ const hostConnections = new HostConnections({
13627
+ ...options.hostedRelay ?? { status: () => readRawTailscaleStatus(tailscaleNow().binary, options.env) },
13628
+ log: requestLog.host
13629
+ }, options.serverVersion);
13484
13630
  let revocationCheck = null;
13485
13631
  let checkedRegistry = null;
13486
13632
  const checkRevocations = () => {
@@ -13544,6 +13690,9 @@ async function startServer(options) {
13544
13690
  const commands = /* @__PURE__ */ new Set();
13545
13691
  let nextSubscription = 1;
13546
13692
  let device = null;
13693
+ const tracker = requestLog.track();
13694
+ trackers.set(socket, tracker);
13695
+ let runId = null;
13547
13696
  let buildSession = null;
13548
13697
  let queue = Promise.resolve();
13549
13698
  let archiveReads = Promise.resolve();
@@ -13584,7 +13733,15 @@ async function startServer(options) {
13584
13733
  if (!isJsonObject(params) || !auth || !isJsonObject(params.client)) return refuse(id, "bad-request", "hello needs params.client and params.auth.", CLOSE_BAD_REQUEST);
13585
13734
  if (params.protocol !== protocol_exports.PROTOCOL_VERSION) return refuse(id, "protocol-unsupported", `This server speaks protocol ${protocol_exports.PROTOCOL_VERSION}.`, CLOSE_BAD_REQUEST);
13586
13735
  clearTimeout(timer);
13736
+ runId = validRunId(params.client.runId);
13737
+ tracker.identify({
13738
+ id: null,
13739
+ runId
13740
+ });
13741
+ const probing = hostHealth();
13742
+ const whoisStarted = Date.now();
13587
13743
  const identity = await identify();
13744
+ tracker.step(id, "whois", Date.now() - whoisStarted);
13588
13745
  if (socket.readyState !== socket.OPEN) return;
13589
13746
  if (!identity) return refuse(id, "identity-unavailable", `tailscale whois did not identify ${peer}.`, CLOSE_UNAUTHORIZED);
13590
13747
  let outcome;
@@ -13638,8 +13795,15 @@ async function startServer(options) {
13638
13795
  return;
13639
13796
  }
13640
13797
  device = outcome.device;
13798
+ tracker.identify({
13799
+ id: device.id,
13800
+ runId
13801
+ });
13641
13802
  sessions.set(socket, device);
13642
13803
  sampler.start();
13804
+ const probeWaited = Date.now();
13805
+ const host = options.host ? await probing : void 0;
13806
+ if (options.host) tracker.step(id, "probe", Date.now() - probeWaited);
13643
13807
  send(socket, {
13644
13808
  id,
13645
13809
  result: {
@@ -13650,7 +13814,7 @@ async function startServer(options) {
13650
13814
  stim: options.stimVersion,
13651
13815
  home: homedir()
13652
13816
  },
13653
- ...options.host ? { host: await hostHealth() } : {},
13817
+ ...options.host ? { host } : {},
13654
13818
  capabilities: device.capabilities,
13655
13819
  features: [...protocol_exports.FEATURES],
13656
13820
  actions: device.capabilities.includes("control") ? [...protocol_exports.ACTIONS] : [],
@@ -14798,6 +14962,7 @@ async function startServer(options) {
14798
14962
  });
14799
14963
  return refuse(id, "bad-request", "Expected {id, method, params}.", CLOSE_BAD_REQUEST);
14800
14964
  }
14965
+ tracker.begin(id, message.method);
14801
14966
  if (message.method === "hello") return hello(id, message.params);
14802
14967
  if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_UNAUTHORIZED);
14803
14968
  if (protocol_exports.DEVICE_HOST_METHODS.includes(message.method)) {
@@ -15374,7 +15539,7 @@ async function serve(port, extraEnv, pathPrepend, loopbackOnly) {
15374
15539
  if (process.env.STIM_HOME) env.STIM_HOME = process.env.STIM_HOME;
15375
15540
  const launchdLabel = process.env.XPC_SERVICE_NAME;
15376
15541
  const tailscaleBinary = findTailscale(env);
15377
- const tailscale = tailscaleStatus(tailscaleBinary, env);
15542
+ const tailscale = await readTailscaleStatus(tailscaleBinary, env);
15378
15543
  const stim = bundledStim();
15379
15544
  const monitor = watchTailscale({
15380
15545
  env,
@@ -15450,7 +15615,7 @@ const SCOPE_TEXT = {
15450
15615
  };
15451
15616
  async function pair(port, json, control) {
15452
15617
  const binary = findTailscale(process.env);
15453
- const tailscale = tailscaleStatus(binary, process.env);
15618
+ const tailscale = await readTailscaleStatus(binary, process.env);
15454
15619
  let endpoint = `ws://127.0.0.1:${port}`;
15455
15620
  let note = tailscaleNote(tailscale);
15456
15621
  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-DThEHaj6.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.19.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.19.0",
32
+ "stim": "1.19.0",
33
33
  "ws": "^8.21.3"
34
34
  },
35
35
  "devDependencies": {