@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 +16 -8
- package/dist/{protocol-Btu-PwQS.mjs → protocol-qo5TTavl.mjs} +11 -3
- package/dist/protocol.mjs +1 -1
- package/dist/protocol.schema.json +10 -1
- package/dist/{stim-server-CJIlV28Y.mjs → stim-server-BR9FPA63.mjs} +196 -55
- package/dist/stim-server.mjs +1 -1
- package/package.json +3 -3
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
|
|
116
|
-
|
|
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
|
|
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)
|
|
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),
|
|
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:
|
|
1894
|
-
|
|
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
|
@@ -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-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
2679
|
-
this.
|
|
2680
|
-
|
|
2681
|
-
|
|
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
|
|
2698
|
-
resolve(isJsonObject(
|
|
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.
|
|
2705
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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) =>
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
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? [
|
|
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)? [
|
|
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}
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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 =
|
|
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.`;
|
package/dist/stim-server.mjs
CHANGED
|
@@ -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-
|
|
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.
|
|
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.
|
|
32
|
-
"stim": "1.
|
|
31
|
+
"@stim-cli/core": "^1.18.0",
|
|
32
|
+
"stim": "1.18.0",
|
|
33
33
|
"ws": "^8.21.3"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|