@stim-cli/server 1.21.0 → 1.22.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.
@@ -1,4 +1,4 @@
1
- import { n as protocol_exports } from "./protocol-D21Ab4EI.mjs";
1
+ import { n as protocol_exports } from "./protocol-ZY9j2h32.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, execFileSync, spawn } from "node:child_process";
4
4
  import { 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";
@@ -6,15 +6,15 @@ import { basename, delimiter, dirname, isAbsolute, join, posix, relative, resolv
6
6
  import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
7
  import { DEVICE_HOST_LIMITS, HOSTED_APP_CHUNK_BYTES, HOSTED_MACOS_APP_SLOTS, HOSTED_PARKED_CANDIDATES, KeyedEntities, RECORDING_CAP_BYTES, RECORDING_FOOTAGE_MS, RECORDING_PLATFORMS, SETTING_SCOPES, archiveDir, archivedUsage, assertHostedDeviceLedger, buildWorkerRoot, capDrops, clientIdentity, closedSegmentName, compileGrep, createDebugLog, deviceHostArea, deviceHostRoot, deviceViewersDir, footageDrops, getConcurrencyLimits, getParkedMax, hostedAndroidPlacements, hostedAppArea, hostedAppAttempt, hostedAppBlobs, hostedDeviceId, hostedIosPlacements, hostedMacosBundleId, hostedMacosLogsDir, hostedNativeLogsDir, hostedWorkerSettleMs, isJournalExpired, isJsonObject, isSetupJournalPrunable, listRecordedDevices, listSegments, loadConfig, machineCapacity, machineEndpoint, macosAppState, openSegmentName, parseHostedAppOffer, parseHostedLocalDeviceCount, parseHostedLogsCursor, parseHostedNativeOffer, parseHostedOfferRequest, parseHostedPlatformDevice, parseHostedRequest, queryJsonLogs, readArchive, readArchiveState, readBuildHistory, readBuildMachines, readDeviceHostMachines, readHostedApp, readHostedAppMetadata, readHostedDevice, readHostedDeviceLedger, readHostedMacosApp, readHostedNativeLogsCheckpoint, readHostedSessions, readJsonObject, readLogTimeline, readLogsSince, readMacosRecord, readSetupJournal, readWorkspaceState, recordingDeviceName, rememberHostedAppBlobRename, saturation, serverLocalSocket, serverSetupDir, settingDefinition, settingValueError, setupJournalFile, sortByTs, statusViews, stimBuildDigest, tryAcquireBuildSlotClaim, validHostedAppBlob, validRunId, windowsShortDir, windowsShortDirKey, workspaceRecordingsDir } from "@stim-cli/core/state";
8
8
  import { availableParallelism, cpus, freemem, homedir, hostname, loadavg, tmpdir, totalmem, userInfo } from "node:os";
9
- import { parseArgs } from "node:util";
9
+ import { isDeepStrictEqual, parseArgs } from "node:util";
10
10
  import { discoverToolchain } from "@stim-cli/core/toolchain";
11
11
  import { copyFile, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, writeFile } from "node:fs/promises";
12
12
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
13
- import { compiledHelper, configDir, createMetroBridge, quoteUnitWord, unitLines, unitWords, validMacosResourceDestination, withDirLock, workspaceStateDir } from "@stim-cli/core";
13
+ import { compiledHelper, configDir, createMetroBridge, quoteUnitWord, reactNativeArtifactIdentity, reactNativeArtifactQuery, reusableDependencies, swiftpmArtifactQuery, unitLines, unitWords, validMacosResourceDestination, withDirLock, workspaceStateDir } from "@stim-cli/core";
14
14
  import { CLOSE_REFUSED, HandshakeRefusal, MAX_CLIENT_MESSAGE_BYTES, MAX_FIRST_MESSAGE_BYTES, MAX_SERVER_MESSAGE_BYTES, MODE_FIRST_CONTACT, TRANSPORT_OVERHEAD_BYTES, acceptNoise, decodeSecret, initiateNoise, keyFingerprint, newSecret, readOrCreateKeyFile, secretValues } from "@stim-cli/core/noise-session";
15
15
  import { DEFAULT_STUCK_MINUTES, OVERSIGHT_CATEGORIES, inQuietHours, oversee, oversightTitle } from "@stim-cli/core/oversight";
16
16
  import { connect, createServer, isIP } from "node:net";
17
- import { ClaimRefusedError, claimRemoveCommand, clearClaimChild, clearFreeClaimSet, exclusiveClaimDir, markClaimChildPending, processGroupAlive, releaseClaim, setClaimChild, sharedClaimDir, tryAcquireClaim } from "@stim-cli/core/ownership-claim";
17
+ import { ClaimRefusedError, claimRemoveCommand, clearClaimChild, clearFreeClaimSet, exclusiveClaimDir, markClaimChildPending, processGroupAlive, readClaimSet, releaseClaim, setClaimChild, sharedClaimDir, tryAcquireClaim } from "@stim-cli/core/ownership-claim";
18
18
  import { findTailscale, isTailscaleTransport, planServe, readRawTailscaleStatus, readTailscaleStatus, serveCommand, serveRoute, tailnetEndpoint, tailscaleInstall, tailscaleTransport } from "@stim-cli/transport-tailscale";
19
19
  import { fileURLToPath } from "node:url";
20
20
  import { createInterface } from "node:readline/promises";
@@ -127,6 +127,54 @@ function devicesFile() {
127
127
  function clientsFile(capability) {
128
128
  return join(serverDir(), `${capability}-clients.json`);
129
129
  }
130
+ /** Registry events are hints; callers must reread grants and retain missed-event repair. */
131
+ function watchClientRegistries(changed) {
132
+ const files = [clientsFile("build"), clientsFile("device-host")];
133
+ const names = new Set(files.map((file) => basename(file)));
134
+ const watchers = /* @__PURE__ */ new Map();
135
+ let stopped = false;
136
+ const clearFiles = () => {
137
+ const previous = [...watchers.values()];
138
+ watchers.clear();
139
+ for (const observer of previous) observer.close();
140
+ };
141
+ const refresh = () => {
142
+ clearFiles();
143
+ for (const file of files) try {
144
+ const observer = watch(file, { persistent: false }, (event) => {
145
+ if (stopped || watchers.get(file) !== observer) return;
146
+ if (event === "rename") refresh();
147
+ changed();
148
+ });
149
+ watchers.set(file, observer);
150
+ observer.once("error", () => {
151
+ if (watchers.get(file) !== observer) return;
152
+ watchers.delete(file);
153
+ observer.close();
154
+ changed();
155
+ });
156
+ } catch {}
157
+ };
158
+ refresh();
159
+ let directory;
160
+ try {
161
+ directory = watch(serverDir(), { persistent: false }, (_event, filename) => {
162
+ if (stopped || filename !== null && !names.has(filename.toString())) return;
163
+ refresh();
164
+ changed();
165
+ });
166
+ directory.once("error", () => {
167
+ directory?.close();
168
+ directory = void 0;
169
+ if (!stopped) changed();
170
+ });
171
+ } catch {}
172
+ return () => {
173
+ stopped = true;
174
+ directory?.close();
175
+ clearFiles();
176
+ };
177
+ }
130
178
  function secretsFile() {
131
179
  return join(serverDir(), "secrets.json");
132
180
  }
@@ -1602,15 +1650,48 @@ const taskScheduler = {
1602
1650
  ]);
1603
1651
  if (!started.ok) return started;
1604
1652
  const deadline = Date.now() + SUPERVISOR_WAIT_MS;
1605
- while (!await runningSupervisor(label)) {
1606
- if (Date.now() >= deadline) return {
1607
- ok: false,
1608
- stdout: "",
1609
- stderr: `${taskName(label)} did not start its supervisor within ${SUPERVISOR_WAIT_MS / 1e3} s; see ${join(taskScheduler.root(label), "supervisor.log")} and \`schtasks /Query /TN ${taskName(label)} /V /FO LIST\`.`
1610
- };
1611
- await new Promise((resolve) => setTimeout(resolve, 250));
1653
+ let observer;
1654
+ let changed = false;
1655
+ let timer;
1656
+ let wake;
1657
+ const notify = () => {
1658
+ changed = true;
1659
+ wake?.();
1660
+ };
1661
+ try {
1662
+ try {
1663
+ observer = watch(taskScheduler.root(label), { persistent: false }, (_event, filename) => {
1664
+ if (filename === null || filename.toString() === "supervisor.json") notify();
1665
+ });
1666
+ observer.once("error", () => {
1667
+ observer?.close();
1668
+ observer = void 0;
1669
+ notify();
1670
+ });
1671
+ } catch {}
1672
+ for (;;) {
1673
+ changed = false;
1674
+ if (await runningSupervisor(label)) return started;
1675
+ const remaining = deadline - Date.now();
1676
+ if (remaining <= 0) return {
1677
+ ok: false,
1678
+ stdout: "",
1679
+ stderr: `${taskName(label)} did not start its supervisor within ${SUPERVISOR_WAIT_MS / 1e3} s; see ${join(taskScheduler.root(label), "supervisor.log")} and \`schtasks /Query /TN ${taskName(label)} /V /FO LIST\`.`
1680
+ };
1681
+ if (changed) continue;
1682
+ await new Promise((resolve) => {
1683
+ wake = () => {
1684
+ clearTimeout(timer);
1685
+ wake = void 0;
1686
+ resolve();
1687
+ };
1688
+ timer = setTimeout(wake, Math.min(remaining, observer ? 1e3 : 250));
1689
+ });
1690
+ }
1691
+ } finally {
1692
+ clearTimeout(timer);
1693
+ observer?.close();
1612
1694
  }
1613
- return started;
1614
1695
  },
1615
1696
  async stop(label) {
1616
1697
  const state = await runningSupervisor(label);
@@ -2466,16 +2547,17 @@ function withUpdateClaim(label, work) {
2466
2547
  /**
2467
2548
  * Installs `source` beside the server the job runs, waits for offloaded builds and hosted sessions to finish,
2468
2549
  * switches the job to it and restarts it, and switches back when it does not answer as the installed build. Never
2469
- * touches pairings, approvals, the host app, `$STIM_HOME` or the serve route.
2550
+ * touches pairings, approvals, the host app, `$STIM_HOME` or the serve route. With `waitForWork` false,
2551
+ * restart interrupts active work instead of waiting for idle; shutdown and health checks still apply.
2470
2552
  */
2471
- async function updateService(label, source, log) {
2553
+ async function updateService(label, source, log, waitForWork = true) {
2472
2554
  if (!existsSync(plistPath(label))) throw new ServiceError(`${label} is not installed.`);
2473
- return withUpdateClaim(label, (installed) => updateInstalledService(label, installed, source, log));
2555
+ return withUpdateClaim(label, (installed) => updateInstalledService(label, installed, source, log, waitForWork));
2474
2556
  }
2475
- async function updateInstalledService(label, installed, source, log) {
2557
+ async function updateInstalledService(label, installed, source, log, waitForWork = true) {
2476
2558
  const at = (/* @__PURE__ */ new Date()).toISOString();
2477
2559
  try {
2478
- const notes = await switchToSource(label, installed, source, log);
2560
+ const notes = await switchToSource(label, installed, source, log, waitForWork);
2479
2561
  recordOutcome(label, {
2480
2562
  at,
2481
2563
  target: describeSource(source),
@@ -2493,11 +2575,12 @@ async function updateInstalledService(label, installed, source, log) {
2493
2575
  throw error;
2494
2576
  }
2495
2577
  }
2496
- async function switchToSource(label, installed, source, log) {
2578
+ async function switchToSource(label, installed, source, log, waitForWork) {
2497
2579
  const versions = join(serviceRoot(label), "versions");
2498
2580
  const target = await installServer(versions, source, installed.node, log);
2499
2581
  if (sameFile(installed.script, target.script)) return [`${label} already runs ${describeBuild(target.build)}.`];
2500
- await waitForIdle(installed.port, log);
2582
+ if (waitForWork) await waitForIdle(installed.port, log);
2583
+ else log("Restarting without waiting for active builds or hosted sessions; they will be interrupted.");
2501
2584
  log(`Switching ${label} to ${describeBuild(target.build)}.`);
2502
2585
  const script = realpathSync(target.script);
2503
2586
  await switchTo(installed, script, target.build);
@@ -2769,6 +2852,25 @@ function listCommandLines() {
2769
2852
  }
2770
2853
  //#endregion
2771
2854
  //#region src/build-artifact-cache.ts
2855
+ const androidPackage = (value) => typeof value === "string" && /^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+$/.test(value);
2856
+ function gradleQueryKey(query) {
2857
+ const { android, inputs } = query;
2858
+ return createHash("sha256").update(JSON.stringify([
2859
+ query.project,
2860
+ query.sourceDigest,
2861
+ inputs.ignored,
2862
+ inputs.outputs,
2863
+ android.variant,
2864
+ android.abi === "all" ? null : android.abi,
2865
+ android.compilerCache,
2866
+ android.gradleBuildCache,
2867
+ android.pch,
2868
+ android.gradleRoot ?? null,
2869
+ android.module ?? null
2870
+ ])).digest("hex");
2871
+ }
2872
+ const completedKind = (query) => "build" in query ? "swiftpm" : "sourceDigest" in query ? "gradle" : "react-native";
2873
+ const completedKey = (query) => "sourceDigest" in query && !("build" in query) ? gradleQueryKey(query) : createHash("sha256").update(JSON.stringify(query)).digest("hex");
2772
2874
  function validArtifactIdentity(value) {
2773
2875
  return isJsonObject(value) && typeof value.fingerprint === "string" && value.fingerprint.length > 0 && value.fingerprint.length <= 256 && typeof value.cacheKey === "string" && value.cacheKey.length > 0 && value.cacheKey.length <= 1024;
2774
2876
  }
@@ -2800,7 +2902,7 @@ var BuildArtifactCache = class {
2800
2902
  encoding: "utf8",
2801
2903
  signal
2802
2904
  }));
2803
- if (!isJsonObject(record) || record.fingerprint !== identity.fingerprint || record.cacheKey !== identity.cacheKey || typeof record.name !== "string" || basename(record.name) !== record.name || !record.name.endsWith(".app") || typeof record.size !== "number" || !Number.isSafeInteger(record.size) || record.size <= 0 || typeof record.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.sha256)) return null;
2905
+ if (!isJsonObject(record) || record.fingerprint !== identity.fingerprint || record.cacheKey !== identity.cacheKey || typeof record.name !== "string" || basename(record.name) !== record.name || !(record.name.endsWith(".app") || record.name.endsWith(".apk") && androidPackage(record.androidPackage)) || typeof record.size !== "number" || !Number.isSafeInteger(record.size) || record.size <= 0 || typeof record.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(record.sha256)) return null;
2804
2906
  if ((await this.files.stat({
2805
2907
  key: record.sha256,
2806
2908
  signal
@@ -2809,7 +2911,8 @@ var BuildArtifactCache = class {
2809
2911
  ...identity,
2810
2912
  name: record.name,
2811
2913
  size: record.size,
2812
- sha256: record.sha256
2914
+ sha256: record.sha256,
2915
+ ...record.name.endsWith(".apk") && androidPackage(record.androidPackage) ? { androidPackage: record.androidPackage } : {}
2813
2916
  };
2814
2917
  } catch {
2815
2918
  signal.throwIfAborted();
@@ -2817,7 +2920,7 @@ var BuildArtifactCache = class {
2817
2920
  }
2818
2921
  }
2819
2922
  async store(identity, parameters, archive, artifact, signal) {
2820
- if (!validArtifactIdentity(identity) || !await verified(archive, artifact, signal)) return;
2923
+ if (!validArtifactIdentity(identity) || !await verified(archive, artifact, signal)) return false;
2821
2924
  await this.files.store({
2822
2925
  key: artifact.sha256,
2823
2926
  sourcePath: archive,
@@ -2840,10 +2943,57 @@ var BuildArtifactCache = class {
2840
2943
  });
2841
2944
  signal.throwIfAborted();
2842
2945
  await rename(temporary, this.record(identity));
2946
+ return true;
2947
+ } finally {
2948
+ await rm(temporary, { force: true });
2949
+ }
2950
+ }
2951
+ async storeCompleted(query, identity, receipt, archive, artifact, signal) {
2952
+ if (!("build" in query) && ("sourceDigest" in query || query.platform === "android")) {
2953
+ if (!artifact.name.endsWith(".apk") || !androidPackage(artifact.androidPackage)) return;
2954
+ } else if (!artifact.name.endsWith(".app")) return;
2955
+ if (!await this.store(identity, { ...query }, archive, artifact, signal)) return;
2956
+ const key = completedKey(query);
2957
+ const temporary = join(this.directory, `${randomUUID()}.partial`);
2958
+ try {
2959
+ await writeFile(temporary, JSON.stringify({
2960
+ query: key,
2961
+ receipt
2962
+ }), {
2963
+ mode: 384,
2964
+ signal
2965
+ });
2966
+ signal.throwIfAborted();
2967
+ await rename(temporary, join(this.directory, `${key}.${completedKind(query)}.json`));
2843
2968
  } finally {
2844
2969
  await rm(temporary, { force: true });
2845
2970
  }
2846
2971
  }
2972
+ async completedRecord(query, signal) {
2973
+ signal.throwIfAborted();
2974
+ try {
2975
+ const key = completedKey(query);
2976
+ const record = JSON.parse(await readFile(join(this.directory, `${key}.${completedKind(query)}.json`), {
2977
+ encoding: "utf8",
2978
+ signal
2979
+ }));
2980
+ return isJsonObject(record) && record.query === key && isJsonObject(record.receipt) ? record : null;
2981
+ } catch {
2982
+ signal.throwIfAborted();
2983
+ return null;
2984
+ }
2985
+ }
2986
+ async receipt(query, signal) {
2987
+ return (await this.completedRecord(query, signal))?.receipt ?? null;
2988
+ }
2989
+ async lookupReactNative(query, signal) {
2990
+ const record = await this.completedRecord(query, signal);
2991
+ if (!record || !isJsonObject(record.receipt) || !isJsonObject(record.receipt.toolchain) || !isDeepStrictEqual(record.receipt.query, query)) return null;
2992
+ return this.lookup(reactNativeArtifactIdentity({
2993
+ query,
2994
+ toolchain: record.receipt.toolchain
2995
+ }), signal);
2996
+ }
2847
2997
  async fetch(identity, destinationDir, signal) {
2848
2998
  const artifact = await this.lookup(identity, signal);
2849
2999
  if (!artifact) return null;
@@ -2866,6 +3016,175 @@ var BuildArtifactCache = class {
2866
3016
  }
2867
3017
  };
2868
3018
  //#endregion
3019
+ //#region src/artifact-query.ts
3020
+ /** Revalidates only a completed build's private inputs, holding its area until the owned query has stopped. */
3021
+ function runArtifactQuery({ worker, area, env, query, receipt, signal, timeoutMs = 1e4, killGraceMs = 1e3 }) {
3022
+ signal.throwIfAborted();
3023
+ const attempt = tryAcquireClaim({
3024
+ root: `${area}.claims`,
3025
+ mode: "shared",
3026
+ label: "Completed artifact query"
3027
+ });
3028
+ if (!attempt.acquired) {
3029
+ if (attempt.pending) releaseClaim(attempt.pending);
3030
+ return {
3031
+ done: Promise.resolve({
3032
+ identity: null,
3033
+ settled: true
3034
+ }),
3035
+ cancel: () => {}
3036
+ };
3037
+ }
3038
+ const claim = attempt.acquired;
3039
+ markClaimChildPending(claim);
3040
+ let child;
3041
+ try {
3042
+ child = spawn(process.execPath, [worker, "build" in query ? "swiftpm-artifact" : "native-gradle-artifact"], {
3043
+ cwd: area,
3044
+ env,
3045
+ detached: true,
3046
+ stdio: [
3047
+ "pipe",
3048
+ "pipe",
3049
+ "pipe"
3050
+ ]
3051
+ });
3052
+ } catch (error) {
3053
+ clearClaimChild(claim);
3054
+ releaseClaim(claim);
3055
+ throw error;
3056
+ }
3057
+ let identity = null;
3058
+ let closed = false;
3059
+ let ended = false;
3060
+ let stopped = false;
3061
+ let output = "";
3062
+ let treeStopped = null;
3063
+ let killTimer;
3064
+ let settleTimer;
3065
+ let groupTimer;
3066
+ let finish;
3067
+ const done = new Promise((resolve) => {
3068
+ finish = resolve;
3069
+ });
3070
+ const end = (settled) => {
3071
+ if (ended) return;
3072
+ ended = true;
3073
+ clearTimeout(deadline);
3074
+ clearTimeout(killTimer);
3075
+ clearTimeout(settleTimer);
3076
+ clearTimeout(groupTimer);
3077
+ signal.removeEventListener("abort", cancel);
3078
+ let result = null;
3079
+ if (settled) {
3080
+ try {
3081
+ clearClaimChild(claim);
3082
+ releaseClaim(claim);
3083
+ } catch (error) {
3084
+ settled = false;
3085
+ console.error(`stim-server: Completed artifact query could not release ${claim.path}: ${String(error)}`);
3086
+ }
3087
+ if (settled && !stopped) try {
3088
+ const value = JSON.parse(output);
3089
+ if (isJsonObject(value) && isJsonObject(value.identity) && typeof value.identity.hash === "string" && /^[a-f0-9]{64}$/.test(value.identity.hash) && typeof value.identity.key === "string" && value.identity.key.length > 0 && value.identity.key.length <= 1024) result = {
3090
+ fingerprint: value.identity.hash,
3091
+ cacheKey: value.identity.key
3092
+ };
3093
+ } catch {}
3094
+ }
3095
+ if (!settled) {
3096
+ child.stdin?.destroy();
3097
+ child.stdout?.destroy();
3098
+ child.stderr?.destroy();
3099
+ child.unref();
3100
+ console.error(`stim-server: Completed artifact query cleanup is unresolved at ${claim.path}; inspect before ${claimRemoveCommand(claim.root)}`);
3101
+ }
3102
+ finish({
3103
+ identity: result,
3104
+ settled
3105
+ });
3106
+ };
3107
+ const signalGroup = (name) => {
3108
+ if (!identity || !child.pid) return;
3109
+ const status = inspectProcessIdentity(identity);
3110
+ if (status !== "same" && status !== "gone") return;
3111
+ if (process.platform === "win32") treeStopped ??= new Promise((resolve) => {
3112
+ execFile(join(process.env.SystemRoot || "C:\\Windows", "System32", "taskkill.exe"), [
3113
+ "/PID",
3114
+ String(child.pid),
3115
+ "/T",
3116
+ "/F"
3117
+ ], { timeout: 4e3 }, (error) => resolve(!error));
3118
+ });
3119
+ else try {
3120
+ process.kill(-child.pid, name);
3121
+ } catch {}
3122
+ };
3123
+ const cancel = () => {
3124
+ if (ended || stopped) return;
3125
+ stopped = true;
3126
+ signalGroup("SIGTERM");
3127
+ killTimer = setTimeout(() => signalGroup("SIGKILL"), killGraceMs);
3128
+ settleTimer = setTimeout(() => end(false), killGraceMs + 5e3);
3129
+ };
3130
+ const drained = async () => {
3131
+ if (ended || !closed) return;
3132
+ if (treeStopped && !await treeStopped) return end(false);
3133
+ if (!child.pid || !processGroupAlive(child.pid)) return end(true);
3134
+ cancel();
3135
+ groupTimer = setTimeout(() => void drained(), 25);
3136
+ };
3137
+ const deadline = setTimeout(cancel, timeoutMs);
3138
+ child.stdout?.setEncoding("utf8");
3139
+ child.stdout?.on("data", (chunk) => {
3140
+ if (Buffer.byteLength(output) + Buffer.byteLength(chunk) > 16384) cancel();
3141
+ else output += chunk;
3142
+ });
3143
+ child.stderr?.resume();
3144
+ child.stdin?.on("error", cancel);
3145
+ child.once("error", () => {
3146
+ cancel();
3147
+ closed = true;
3148
+ drained();
3149
+ });
3150
+ child.once("close", (code) => {
3151
+ if (code !== 0) cancel();
3152
+ closed = true;
3153
+ drained();
3154
+ });
3155
+ signal.addEventListener("abort", cancel, { once: true });
3156
+ const captured = child.pid === void 0 ? null : captureProcessIdentity(child.pid);
3157
+ if (!captured?.ok || child.pid === void 0) {
3158
+ child.kill("SIGKILL");
3159
+ cancel();
3160
+ } else {
3161
+ identity = {
3162
+ pid: child.pid,
3163
+ processToken: captured.token
3164
+ };
3165
+ try {
3166
+ setClaimChild(claim, identity);
3167
+ if (signal.aborted) cancel();
3168
+ else child.stdin?.end(JSON.stringify({
3169
+ root: join(area, "src", query.project),
3170
+ receipt,
3171
+ ..."build" in query ? { query } : {
3172
+ sourceDigest: query.sourceDigest,
3173
+ inputs: query.inputs,
3174
+ android: query.android
3175
+ }
3176
+ }));
3177
+ } catch (error) {
3178
+ console.error(`stim-server: Completed artifact query could not record its child: ${String(error)}`);
3179
+ cancel();
3180
+ }
3181
+ }
3182
+ return {
3183
+ done,
3184
+ cancel
3185
+ };
3186
+ }
3187
+ //#endregion
2869
3188
  //#region src/build.ts
2870
3189
  const DEFAULT_BUILD_LIMITS = {
2871
3190
  maxJobs: 1,
@@ -2931,6 +3250,61 @@ function androidOptions(value) {
2931
3250
  module
2932
3251
  };
2933
3252
  }
3253
+ function macosBuildOptions(params) {
3254
+ const options = params.macos;
3255
+ if (!isJsonObject(options) || typeof options.product !== "string" || !/^[A-Za-z0-9_.-]{1,100}$/.test(options.product) || !validBuildPath(options.infoPlist) || typeof options.bundleId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,199}$/.test(options.bundleId)) return refusal("bad-request", "A macos build needs a valid product, relative infoPlist path and bundleId in params.macos.");
3256
+ if (options.resources !== void 0 && (!isJsonObject(options.resources) || Object.keys(options.resources).length > 256 || Object.entries(options.resources).some(([destination, source]) => !validMacosResourceDestination(destination) || !validBuildPath(source))) || options.assetCatalog !== void 0 && options.assetCatalog !== null && !validBuildPath(options.assetCatalog)) return refusal("bad-request", "params.macos needs contained resource destinations and repository-relative sources.");
3257
+ const artifactQuery = options.artifactInputs === void 0 ? null : swiftpmArtifactQuery({
3258
+ project: params.project,
3259
+ sourceDigest: params.fingerprint,
3260
+ stimBuild: params.stimBuild,
3261
+ inputs: options.artifactInputs,
3262
+ build: {
3263
+ product: options.product,
3264
+ infoPlist: options.infoPlist,
3265
+ resources: options.resources ?? {},
3266
+ assetCatalog: options.assetCatalog ?? null
3267
+ }
3268
+ });
3269
+ if (options.artifactInputs !== void 0 && !artifactQuery) return refusal("bad-request", "SwiftPM artifact retention needs a complete repeatable input declaration.");
3270
+ return { options: {
3271
+ product: options.product,
3272
+ infoPlist: options.infoPlist,
3273
+ bundleId: options.bundleId,
3274
+ ...artifactQuery ? { artifactInputs: artifactQuery.inputs } : {},
3275
+ ...options.resources !== void 0 ? { resources: options.resources } : {},
3276
+ ...options.assetCatalog !== void 0 ? { assetCatalog: options.assetCatalog } : {}
3277
+ } };
3278
+ }
3279
+ function gradleArtifactQuery(value) {
3280
+ if (!isJsonObject(value) || Object.keys(value).some((key) => ![
3281
+ "project",
3282
+ "sourceDigest",
3283
+ "inputs",
3284
+ "android"
3285
+ ].includes(key))) return null;
3286
+ const { project, sourceDigest, inputs } = value;
3287
+ const path = (candidate) => validBuildPath(candidate) && !/[\\:]/.test(candidate);
3288
+ if (typeof project !== "string" || project !== "" && !path(project) || typeof sourceDigest !== "string" || !/^[a-f0-9]{64}$/.test(sourceDigest) || !isJsonObject(inputs) || inputs.complete !== true || Object.keys(inputs).some((key) => ![
3289
+ "complete",
3290
+ "ignored",
3291
+ "outputs"
3292
+ ].includes(key)) || !Array.isArray(inputs.ignored) || !inputs.ignored.every(path) || !Array.isArray(inputs.outputs) || !inputs.outputs.every(path)) return null;
3293
+ const android = androidOptions(value.android);
3294
+ return android && android.variant !== null ? {
3295
+ project,
3296
+ sourceDigest,
3297
+ inputs: {
3298
+ complete: true,
3299
+ ignored: inputs.ignored,
3300
+ outputs: inputs.outputs
3301
+ },
3302
+ android: {
3303
+ ...android,
3304
+ variant: android.variant
3305
+ }
3306
+ } : null;
3307
+ }
2934
3308
  function freeBytes(path) {
2935
3309
  try {
2936
3310
  const stats = statfsSync(path);
@@ -2944,13 +3318,6 @@ function workerFreeBytes(root) {
2944
3318
  const free = [root, ...process.platform === "win32" ? [dirname(dirname(windowsShortDir(root)))] : []].map(freeBytes).filter((bytes) => bytes !== null);
2945
3319
  return free.length ? Math.min(...free) : null;
2946
3320
  }
2947
- function nonEmptyDir(path) {
2948
- try {
2949
- return readdirSync(path).length > 0;
2950
- } catch {
2951
- return false;
2952
- }
2953
- }
2954
3321
  function gradleDaemonIdleMs() {
2955
3322
  const definition = settingDefinition("server.gradleDaemonIdleMinutes");
2956
3323
  const value = loadConfig()?.server?.gradleDaemonIdleMinutes;
@@ -3007,6 +3374,8 @@ var BuildHost = class {
3007
3374
  owned = /* @__PURE__ */ new Map();
3008
3375
  sessions = /* @__PURE__ */ new Map();
3009
3376
  served = /* @__PURE__ */ new Set();
3377
+ removals = /* @__PURE__ */ new Map();
3378
+ queries = /* @__PURE__ */ new Set();
3010
3379
  retained = /* @__PURE__ */ new Map();
3011
3380
  closed = false;
3012
3381
  draining = null;
@@ -3108,13 +3477,30 @@ var BuildHost = class {
3108
3477
  });
3109
3478
  return value;
3110
3479
  }
3111
- async offer(client, params) {
3480
+ async offer(client, params, signal = new AbortController().signal) {
3112
3481
  if (!isJsonObject(params) || !validRepo(params.repo)) return refusal("bad-request", "build.offer needs params.repo.");
3482
+ let dependencies;
3483
+ if (params.dependencies !== void 0) {
3484
+ const value = params.dependencies;
3485
+ if (!isJsonObject(value) || typeof value.project !== "string" || value.project !== "" && (!validBuildPath(value.project) || /[\\:]/.test(value.project)) || value.packageName !== null && typeof value.packageName !== "string") return refusal("bad-request", "Dependency warmth needs a repository project and package name or null.");
3486
+ dependencies = {
3487
+ project: value.project,
3488
+ packageName: value.packageName
3489
+ };
3490
+ }
3113
3491
  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.");
3114
3492
  if (params.native !== void 0 && params.native !== "xcode" && params.native !== "gradle") return refusal("bad-request", "build.offer supports native Xcode or Gradle toolchains.");
3115
3493
  if (params.artifact !== void 0 && (params.native !== "xcode" || !validArtifactIdentity(params.artifact))) return refusal("bad-request", "An artifact lookup needs a complete native Xcode identity.");
3116
- const artifact = params.artifact === void 0 ? void 0 : await this.cache(client, params.repo).lookup(params.artifact, new AbortController().signal);
3494
+ const query = params.gradleArtifact === void 0 ? void 0 : gradleArtifactQuery(params.gradleArtifact);
3495
+ if (query === null || query !== void 0 && (params.native !== "gradle" || params.artifact !== void 0)) return refusal("bad-request", "A native Gradle artifact lookup needs its completed source and build choices.");
3496
+ const reactNative = params.reactNativeArtifact === void 0 ? void 0 : reactNativeArtifactQuery(params.reactNativeArtifact);
3497
+ if (reactNative === null || reactNative !== void 0 && (params.native !== void 0 || params.artifact !== void 0 || query !== void 0)) return refusal("bad-request", "A React Native artifact lookup needs its development fingerprint and build choices.");
3498
+ const swiftpm = params.swiftpmArtifact === void 0 ? void 0 : swiftpmArtifactQuery(params.swiftpmArtifact);
3499
+ if (swiftpm === null || swiftpm !== void 0 && (params.native !== void 0 || params.artifact !== void 0 || query !== void 0 || reactNative !== void 0)) return refusal("bad-request", "A SwiftPM artifact lookup needs its completed source and declared build choices.");
3500
+ const artifact = swiftpm !== void 0 ? await this.privateArtifact(client, params.repo, swiftpm, signal) : reactNative !== void 0 ? await this.cache(client, params.repo).lookupReactNative(reactNative, signal) : query !== void 0 ? await this.privateArtifact(client, params.repo, query, signal) : params.artifact === void 0 ? void 0 : await this.cache(client, params.repo).lookup(params.artifact, signal);
3501
+ if (this.closed || this.removals.has(client) || signal.aborted || !(this.options.allowed?.(client) ?? true)) return refusal("build-refused", "This build connection closed or lost access during the offer.");
3117
3502
  const toolchain = await this.toolchain(params.rubyVersion ?? null, params.native);
3503
+ if (this.closed || this.removals.has(client) || signal.aborted || !(this.options.allowed?.(client) ?? true)) return refusal("build-refused", "This build connection closed or lost access during the offer.");
3118
3504
  if (!toolchain) return refusal("build-refused", "This machine could not read its build toolchain.");
3119
3505
  const root = this.root();
3120
3506
  mkdirSync(root, {
@@ -3122,10 +3508,6 @@ var BuildHost = class {
3122
3508
  mode: 448
3123
3509
  });
3124
3510
  const area = join(this.clientDir(client), "repos", params.repo);
3125
- let lockfile = null;
3126
- try {
3127
- lockfile = readFileSync(join(area, "lockfile"), "utf8");
3128
- } catch {}
3129
3511
  const available = await readAvailableMemory();
3130
3512
  const total = totalmem();
3131
3513
  const memory = available === null ? {} : {
@@ -3140,8 +3522,8 @@ var BuildHost = class {
3140
3522
  },
3141
3523
  warm: {
3142
3524
  checkout: existsSync(join(area, "src")),
3143
- dependencies: typeof params.lockfile === "string" && lockfile === params.lockfile,
3144
- build: nonEmptyDir(join(area, "home", "workspaces")) || nonEmptyDir(join(area, "macos"))
3525
+ dependencies: typeof params.lockfile === "string" && dependencies !== void 0 && reusableDependencies(area, dependencies.project, dependencies.packageName, params.lockfile),
3526
+ build: false
3145
3527
  },
3146
3528
  ...artifact !== void 0 ? { artifact } : {}
3147
3529
  } };
@@ -3149,6 +3531,101 @@ var BuildHost = class {
3149
3531
  cache(client, repo) {
3150
3532
  return new BuildArtifactCache(join(this.clientDir(client), "repos", repo, "artifact-cache"));
3151
3533
  }
3534
+ workerEnvironment(clientDir, area) {
3535
+ return {
3536
+ ...this.options.env,
3537
+ STIM_HOME: join(area, "home"),
3538
+ CP_HOME_DIR: join(clientDir, "cache", "cocoapods-home"),
3539
+ CP_CACHE_DIR: join(clientDir, "cache", "cocoapods"),
3540
+ npm_config_store_dir: join(clientDir, "cache", "pnpm-store"),
3541
+ GRADLE_USER_HOME: gradleUserHome(clientDir)
3542
+ };
3543
+ }
3544
+ async privateArtifact(client, repo, query, signal) {
3545
+ try {
3546
+ if (this.closed || this.removals.has(client) || !(this.options.allowed?.(client) ?? true) || this.queries.size >= 4) return null;
3547
+ const cache = this.cache(client, repo);
3548
+ const receipt = await cache.receipt(query, signal);
3549
+ if (!receipt || this.closed || this.removals.has(client) || signal.aborted || !(this.options.allowed?.(client) ?? true) || this.queries.size >= 4) return null;
3550
+ const clientDir = this.clientDir(client), area = join(clientDir, "repos", repo);
3551
+ const run = runArtifactQuery({
3552
+ worker: this.options.worker,
3553
+ area,
3554
+ env: this.workerEnvironment(clientDir, area),
3555
+ query,
3556
+ receipt,
3557
+ signal,
3558
+ killGraceMs: this.limits.killGraceMs
3559
+ });
3560
+ const entry = {
3561
+ client,
3562
+ run,
3563
+ settled: false
3564
+ };
3565
+ this.queries.add(entry);
3566
+ this.served.add(client);
3567
+ try {
3568
+ const result = await run.done;
3569
+ entry.settled = result.settled;
3570
+ if (!result.settled) throw new Error("Native artifact lookup cleanup is unresolved; inspect this machine's stim-server logs before retrying.");
3571
+ if (!result.identity || signal.aborted || this.closed || !(this.options.allowed?.(client) ?? true)) return null;
3572
+ const artifact = await cache.lookup(result.identity, signal);
3573
+ return "build" in query ? artifact?.name.endsWith(".app") ? artifact : null : artifact?.androidPackage ? artifact : null;
3574
+ } finally {
3575
+ if (entry.settled) this.queries.delete(entry);
3576
+ }
3577
+ } catch (error) {
3578
+ console.error(`stim-server: Completed artifact lookup failed for ${client}/${repo}: ${String(error)}`);
3579
+ throw new Error("This machine could not verify the completed artifact inputs or their cleanup; inspect its stim-server logs before retrying.", { cause: error });
3580
+ }
3581
+ }
3582
+ async stopQueries(client) {
3583
+ const entries = [...this.queries].filter((entry) => client === void 0 || entry.client === client);
3584
+ for (const entry of entries) entry.run.cancel();
3585
+ return (await Promise.all(entries.map((entry) => entry.run.done))).every((result) => result.settled);
3586
+ }
3587
+ async storeCompletedGradle(client, repo, job, entry, signal) {
3588
+ const outcome = entry.outcome, metadata = entry.cacheArtifact, native = job.native;
3589
+ if (!outcome?.ok || !isJsonObject(native) || native.provider !== "gradle" || !isJsonObject(native.cache) || native.cache.write !== true || native.cache.remote !== true || outcome.fingerprint !== null || outcome.sourceDigest !== native.sourceDigest || typeof outcome.androidPackage !== "string" || !metadata || !isJsonObject(metadata.identity) || !isJsonObject(metadata.receipt)) return;
3590
+ const identity = {
3591
+ fingerprint: metadata.identity.hash,
3592
+ cacheKey: metadata.identity.key
3593
+ };
3594
+ const query = gradleArtifactQuery({
3595
+ project: job.project,
3596
+ sourceDigest: native.sourceDigest,
3597
+ inputs: native.inputs,
3598
+ android: job.android
3599
+ });
3600
+ if (!query || !validArtifactIdentity(identity)) return;
3601
+ await this.cache(client, repo).storeCompleted(query, identity, metadata.receipt, entry.archive, {
3602
+ ...outcome.artifact,
3603
+ androidPackage: outcome.androidPackage
3604
+ }, signal);
3605
+ }
3606
+ async storeCompletedSwiftpm(client, repo, job, entry, signal) {
3607
+ const metadata = entry.swiftpmArtifact;
3608
+ if (!entry.outcome?.ok || job.platform !== "macos" || job.cacheArtifact !== true || !isJsonObject(job.macos) || !metadata || !isJsonObject(metadata.identity) || !isJsonObject(metadata.receipt)) return;
3609
+ const options = job.macos;
3610
+ const query = swiftpmArtifactQuery({
3611
+ project: job.project,
3612
+ sourceDigest: job.expectedFingerprint,
3613
+ stimBuild: job.stimBuild,
3614
+ inputs: options.artifactInputs,
3615
+ build: {
3616
+ product: options.product,
3617
+ infoPlist: options.infoPlist,
3618
+ resources: options.resources ?? {},
3619
+ assetCatalog: options.assetCatalog ?? null
3620
+ }
3621
+ });
3622
+ const identity = {
3623
+ fingerprint: metadata.identity.hash,
3624
+ cacheKey: metadata.identity.key
3625
+ };
3626
+ if (!query || !validArtifactIdentity(identity) || identity.cacheKey !== `swiftpm-peer-${identity.fingerprint}` || entry.outcome.fingerprint !== query.sourceDigest || !isDeepStrictEqual(query, metadata.receipt.query) || !isDeepStrictEqual(metadata.identity, metadata.receipt.identity)) return;
3627
+ await this.cache(client, repo).storeCompleted(query, identity, metadata.receipt, entry.archive, entry.outcome.artifact, signal);
3628
+ }
3152
3629
  running() {
3153
3630
  return this.jobs.size;
3154
3631
  }
@@ -3219,12 +3696,45 @@ var BuildHost = class {
3219
3696
  } catch {}
3220
3697
  }
3221
3698
  /** Cancels the builds of `client`, a client that lost `build`, and deletes its area once they end. */
3222
- async remove(client) {
3223
- if (!client || client === "." || client === ".." || basename(client) !== client) return;
3699
+ remove(client) {
3700
+ if (!client || client === "." || client === ".." || basename(client) !== client) return Promise.resolve();
3701
+ const pending = this.removals.get(client);
3702
+ if (pending) return pending;
3703
+ const removal = this.removeClient(client).finally(() => this.removals.delete(client));
3704
+ this.removals.set(client, removal);
3705
+ return removal;
3706
+ }
3707
+ async removeClient(client) {
3224
3708
  for (const [token, entry] of this.retained) if (entry.client === client) this.drop(token);
3225
3709
  const jobs = [...this.jobs].filter((job) => job.client === client);
3226
3710
  for (const job of jobs) job.cancel();
3711
+ const queriesSettled = await this.stopQueries(client);
3227
3712
  await Promise.all(jobs.map((job) => job.done));
3713
+ if (!queriesSettled) {
3714
+ console.error(`stim-server: retaining the build area of ${client} because artifact query cleanup is unresolved.`);
3715
+ return;
3716
+ }
3717
+ let areas;
3718
+ try {
3719
+ areas = await readdir(join(this.clientDir(client), "repos"));
3720
+ } catch (error) {
3721
+ if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
3722
+ console.error(`stim-server: retaining unreadable build areas of ${client}: ${String(error)}`);
3723
+ return;
3724
+ }
3725
+ areas = [];
3726
+ }
3727
+ for (const area of areas.filter((name) => name.endsWith(".claims"))) {
3728
+ const root = join(this.clientDir(client), "repos", area);
3729
+ const clearance = clearFreeClaimSet({
3730
+ root,
3731
+ label: "build worker"
3732
+ });
3733
+ if (clearance.status !== "cleared") {
3734
+ console.error(`stim-server: retaining the build area of ${client}; ownership at ${root} is ${clearance.status}.`);
3735
+ return;
3736
+ }
3737
+ }
3228
3738
  if (process.platform === "win32") await this.sweepDaemons(client);
3229
3739
  const removeDir = async (dir) => {
3230
3740
  try {
@@ -3263,7 +3773,7 @@ var BuildHost = class {
3263
3773
  }
3264
3774
  /** Starts the build of one synced manifest; the job belongs to the caller's connection. */
3265
3775
  launch({ client, repo, job, session, send }) {
3266
- if (this.closed) return refusal("build-refused", "This machine is shutting down stim-server.");
3776
+ if (this.closed || this.removals.has(client)) return refusal("build-refused", "This machine is shutting down or removing this build client.");
3267
3777
  const { declined } = this.capacity();
3268
3778
  if (declined) return refusal("build-busy", `This machine declines the build: ${declined}.`);
3269
3779
  const clientDir = this.clientDir(client);
@@ -3335,14 +3845,7 @@ var BuildHost = class {
3335
3845
  "pipe",
3336
3846
  "ignore"
3337
3847
  ],
3338
- env: {
3339
- ...this.options.env,
3340
- STIM_HOME: join(area, "home"),
3341
- CP_HOME_DIR: join(clientDir, "cache", "cocoapods-home"),
3342
- CP_CACHE_DIR: join(clientDir, "cache", "cocoapods"),
3343
- npm_config_store_dir: join(clientDir, "cache", "pnpm-store"),
3344
- GRADLE_USER_HOME: gradleUserHome(clientDir)
3345
- }
3848
+ env: this.workerEnvironment(clientDir, area)
3346
3849
  });
3347
3850
  } catch (error) {
3348
3851
  releaseClaims();
@@ -3432,7 +3935,12 @@ var BuildHost = class {
3432
3935
  job: id,
3433
3936
  record: value.record
3434
3937
  });
3435
- else if (value.type === "result") entry.outcome = workerOutcome(value, isJsonObject(job.native) && job.native.provider === "gradle" ? "gradle" : void 0);
3938
+ else if (value.type === "result") {
3939
+ if (isJsonObject(value.cacheArtifact)) entry.cacheArtifact = value.cacheArtifact;
3940
+ if (isJsonObject(value.reactNativeArtifact)) entry.reactNativeArtifact = value.reactNativeArtifact;
3941
+ if (isJsonObject(value.swiftpmArtifact)) entry.swiftpmArtifact = value.swiftpmArtifact;
3942
+ entry.outcome = workerOutcome(value, isJsonObject(job.native) && job.native.provider === "gradle" ? "gradle" : void 0);
3943
+ }
3436
3944
  });
3437
3945
  this.jobs.add(entry);
3438
3946
  this.owned.set(id, entry);
@@ -3477,6 +3985,24 @@ var BuildHost = class {
3477
3985
  }
3478
3986
  });
3479
3987
  }
3988
+ try {
3989
+ await this.storeCompletedGradle(client, repo, job, entry, caching.signal);
3990
+ await this.storeCompletedSwiftpm(client, repo, job, entry, caching.signal);
3991
+ const metadata = entry.reactNativeArtifact;
3992
+ const query = metadata && isJsonObject(metadata.receipt) ? reactNativeArtifactQuery(metadata.receipt.query) : null;
3993
+ if (entry.outcome?.ok && job.cacheArtifact === true && !job.native && query && metadata && isJsonObject(metadata.identity) && query.fingerprint === job.expectedFingerprint && entry.outcome.fingerprint === job.expectedFingerprint && query.project === job.project && query.platform === job.platform) {
3994
+ const identity = {
3995
+ fingerprint: metadata.identity.hash,
3996
+ cacheKey: metadata.identity.key
3997
+ };
3998
+ if (validArtifactIdentity(identity)) await this.cache(client, repo).storeCompleted(query, identity, metadata.receipt, entry.archive, {
3999
+ ...entry.outcome.artifact,
4000
+ ...entry.outcome.androidPackage ? { androidPackage: entry.outcome.androidPackage } : {}
4001
+ }, caching.signal);
4002
+ }
4003
+ } catch (error) {
4004
+ console.error(`stim-server: could not retain the completed artifact for ${client}/${repo}: ${String(error)}`);
4005
+ }
3480
4006
  entry.settled = true;
3481
4007
  this.jobs.delete(entry);
3482
4008
  if (cancelled) this.sweepDaemons(client);
@@ -3600,6 +4126,7 @@ var BuildHost = class {
3600
4126
  */
3601
4127
  abandonDetached(allowed, deleteAreas = false) {
3602
4128
  for (const job of this.owned.values()) if (!job.session && !allowed(job.client)) this.abandon(job);
4129
+ for (const entry of this.queries) if (!allowed(entry.client)) entry.run.cancel();
3603
4130
  for (const [token, entry] of this.retained) if (!allowed(entry.client)) this.drop(token);
3604
4131
  for (const client of this.served) {
3605
4132
  if (!deleteAreas || allowed(client)) continue;
@@ -3612,15 +4139,17 @@ var BuildHost = class {
3612
4139
  clearInterval(this.sweeper);
3613
4140
  for (const job of this.owned.values()) this.abandon(job);
3614
4141
  for (const token of this.retained.keys()) this.drop(token);
4142
+ await this.stopQueries();
3615
4143
  await Promise.all([...this.jobs].map((job) => job.done));
3616
4144
  await this.sweeping;
3617
4145
  }
3618
4146
  };
3619
4147
  function workerOutcome(value, native) {
3620
4148
  const timings = isJsonObject(value.timings) ? value.timings : {};
4149
+ const androidPackage = typeof value.androidPackage === "string" && /^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+$/.test(value.androidPackage) ? value.androidPackage : void 0;
3621
4150
  if (value.ok === true && isJsonObject(value.artifact)) {
3622
4151
  const { name, size, sha256 } = value.artifact;
3623
- if (typeof name === "string" && typeof size === "number" && typeof sha256 === "string" && (native !== "gradle" || value.fingerprint === null && typeof value.androidPackage === "string" && /^[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+$/.test(value.androidPackage))) return {
4152
+ if (typeof name === "string" && typeof size === "number" && typeof sha256 === "string" && (native !== "gradle" || value.fingerprint === null && androidPackage !== void 0)) return {
3624
4153
  ok: true,
3625
4154
  artifact: {
3626
4155
  name,
@@ -3628,7 +4157,7 @@ function workerOutcome(value, native) {
3628
4157
  sha256
3629
4158
  },
3630
4159
  fingerprint: native === "gradle" ? null : String(value.fingerprint),
3631
- ...native === "gradle" ? { androidPackage: value.androidPackage } : {},
4160
+ ...androidPackage ? { androidPackage } : {},
3632
4161
  ...typeof value.sourceDigest === "string" ? { sourceDigest: value.sourceDigest } : {},
3633
4162
  compilationCache: isJsonObject(value.compilationCache) ? value.compilationCache : {},
3634
4163
  timings
@@ -3655,6 +4184,7 @@ var BuildSession = class {
3655
4184
  jobs = /* @__PURE__ */ new Map();
3656
4185
  closed = false;
3657
4186
  transfers = new AbortController();
4187
+ signal = this.transfers.signal;
3658
4188
  host;
3659
4189
  client;
3660
4190
  socket;
@@ -3753,6 +4283,12 @@ var BuildSession = class {
3753
4283
  if (!isJsonObject(input) || typeof input.sourceDigest !== "string" || !/^[0-9a-f]{64}$/.test(input.sourceDigest)) return refusal("bad-request", "A native build needs its source transfer digest.");
3754
4284
  if (input.provider === "gradle") {
3755
4285
  const inputs = input.inputs;
4286
+ const cache = input.cache;
4287
+ if (cache !== void 0 && (!isJsonObject(cache) || [
4288
+ "read",
4289
+ "write",
4290
+ "remote"
4291
+ ].some((key) => typeof cache[key] !== "boolean"))) return refusal("bad-request", "A native Gradle artifact cache policy needs boolean read, write and remote values.");
3756
4292
  if (platform !== "android" || params.fingerprint !== null || !isJsonObject(inputs) || inputs.complete !== true || Object.keys(inputs).some((key) => ![
3757
4293
  "complete",
3758
4294
  "ignored",
@@ -3774,16 +4310,9 @@ var BuildSession = class {
3774
4310
  if (platform === "android" && !android) return refusal("bad-request", "An android build needs params.android: variant and abi (string or null), gradleBuildCache, pch and compilerCache.");
3775
4311
  let macos = null;
3776
4312
  if (platform === "macos") {
3777
- const options = params.macos;
3778
- if (!isJsonObject(options) || typeof options.product !== "string" || !/^[A-Za-z0-9_.-]{1,100}$/.test(options.product) || !validBuildPath(options.infoPlist) || typeof options.bundleId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9.-]{0,199}$/.test(options.bundleId)) return refusal("bad-request", "A macos build needs a valid product, relative infoPlist path and bundleId in params.macos.");
3779
- if (options.resources !== void 0 && (!isJsonObject(options.resources) || Object.keys(options.resources).length > 256 || Object.entries(options.resources).some(([destination, source]) => !validMacosResourceDestination(destination) || !validBuildPath(source))) || options.assetCatalog !== void 0 && options.assetCatalog !== null && !validBuildPath(options.assetCatalog)) return refusal("bad-request", "params.macos needs contained resource destinations and repository-relative sources.");
3780
- macos = {
3781
- product: options.product,
3782
- infoPlist: options.infoPlist,
3783
- bundleId: options.bundleId,
3784
- ...options.resources !== void 0 ? { resources: options.resources } : {},
3785
- ...options.assetCatalog !== void 0 ? { assetCatalog: options.assetCatalog } : {}
3786
- };
4313
+ const parsed = macosBuildOptions(params);
4314
+ if ("error" in parsed) return parsed;
4315
+ macos = parsed.options;
3787
4316
  }
3788
4317
  if (params.project !== "" && !validBuildPath(params.project)) return refusal("bad-request", "params.project must be a relative path inside the repository.");
3789
4318
  if (!this.done || this.repo !== params.repo) return refusal("bad-request", "Send the whole manifest of this repo with build.sync first.");
@@ -3806,6 +4335,8 @@ var BuildSession = class {
3806
4335
  project: params.project,
3807
4336
  packageName: optional(params.packageName),
3808
4337
  isExpo: params.isExpo === true,
4338
+ cacheArtifact: params.cacheArtifact === true,
4339
+ stimBuild: params.stimBuild,
3809
4340
  configuration: optional(params.configuration),
3810
4341
  scheme: optional(params.scheme),
3811
4342
  iosProjectPath: optional(params.iosProjectPath),
@@ -3862,6 +4393,17 @@ var BuildSession = class {
3862
4393
  async cachedArtifact(params) {
3863
4394
  if (!isJsonObject(params) || !validRepo(params.repo) || !validArtifactIdentity(params.artifact)) return refusal("bad-request", "A cached artifact needs its repository and complete identity.");
3864
4395
  if (this.closed) return refusal("build-refused", "The connection closed.");
4396
+ if (params.gradleArtifact !== void 0 || params.reactNativeArtifact !== void 0 || params.swiftpmArtifact !== void 0) {
4397
+ if ([
4398
+ params.gradleArtifact,
4399
+ params.reactNativeArtifact,
4400
+ params.swiftpmArtifact
4401
+ ].filter((value) => value !== void 0).length > 1) return refusal("bad-request", "Choose one completed artifact query.");
4402
+ const query = params.gradleArtifact !== void 0 ? gradleArtifactQuery(params.gradleArtifact) : params.swiftpmArtifact !== void 0 ? swiftpmArtifactQuery(params.swiftpmArtifact) : reactNativeArtifactQuery(params.reactNativeArtifact);
4403
+ if (!query) return refusal("bad-request", "Choose the completed source and build options.");
4404
+ const current = "sourceDigest" in query ? await this.host.privateArtifact(this.client, params.repo, query, this.transfers.signal) : await this.host.cache(this.client, params.repo).lookupReactNative(query, this.transfers.signal);
4405
+ if (!current || current.fingerprint !== params.artifact.fingerprint || current.cacheKey !== params.artifact.cacheKey) return { result: null };
4406
+ } else if (params.artifact.cacheKey.startsWith("react-native-") || params.artifact.cacheKey.startsWith("swiftpm-peer-") || (await this.host.cache(this.client, params.repo).lookup(params.artifact, this.transfers.signal))?.androidPackage) return refusal("bad-request", "This cached artifact needs a current build query.");
3865
4407
  const area = join(this.host.clientDir(this.client), "repos", params.repo);
3866
4408
  mkdirSync(area, {
3867
4409
  recursive: true,
@@ -4079,7 +4621,8 @@ function parseSetupArgs(args, now, ticket) {
4079
4621
  },
4080
4622
  yes: { type: "boolean" },
4081
4623
  json: { type: "boolean" },
4082
- verbose: { type: "boolean" }
4624
+ verbose: { type: "boolean" },
4625
+ "no-wait": { type: "boolean" }
4083
4626
  }
4084
4627
  });
4085
4628
  if (values.ticket !== void 0) throw new SetupRefusal("setup no longer takes --ticket, because other users on this machine can read command-line arguments. Set STIM_SETUP_TICKET=<ticket> before stim-server setup, or copy a new command from Stim Desktop.");
@@ -4111,7 +4654,8 @@ function parseSetupArgs(args, now, ticket) {
4111
4654
  pathPrepend,
4112
4655
  yes: values.yes === true,
4113
4656
  json: values.json === true,
4114
- verbose: values.verbose === true
4657
+ verbose: values.verbose === true,
4658
+ noWait: values["no-wait"] === true
4115
4659
  };
4116
4660
  }
4117
4661
  /** The record a client made for each capability by spending this run's ticket. */
@@ -4160,7 +4704,16 @@ function setupClaim() {
4160
4704
  releaseClaim(attempt.acquired);
4161
4705
  };
4162
4706
  }
4163
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4707
+ const sleep = (ms, signal) => new Promise((resolve) => {
4708
+ const finish = () => {
4709
+ clearTimeout(timer);
4710
+ signal?.removeEventListener("abort", finish);
4711
+ resolve();
4712
+ };
4713
+ const timer = setTimeout(finish, ms);
4714
+ signal?.addEventListener("abort", finish, { once: true });
4715
+ if (signal?.aborted) finish();
4716
+ });
4164
4717
  const binary = () => findTailscale(process.env);
4165
4718
  async function interruptible(work, signal) {
4166
4719
  if (!signal) return work;
@@ -4276,6 +4829,7 @@ function defaultSetupDeps() {
4276
4829
  if (!result.ok) throw new ServiceError(result.stderr.trim());
4277
4830
  },
4278
4831
  records: () => [...readBuildClients(), ...readDeviceHostClients()],
4832
+ watchRecords: watchClientRegistries,
4279
4833
  storeSecret: storeSetupSecret,
4280
4834
  dropSecret: dropSetupSecret,
4281
4835
  panes: hostPermissionPanes,
@@ -4580,7 +5134,7 @@ async function runSetup(args, version, deps) {
4580
5134
  protocol: health?.protocol,
4581
5135
  running: health !== null
4582
5136
  }, version, !desktop);
4583
- const fix = desktop ? `If Stim Desktop runs this server, update Stim Desktop with Check for Updates in its app menu. Otherwise run npm install --global @stim-cli/server@${version} and restart the server.` : `stim-server service update --label ${opts.label} --release ${version}`;
5137
+ const fix = desktop ? `If Stim Desktop runs this server, update Stim Desktop with Check for Updates in its app menu. Otherwise run npm install --global @stim-cli/server@${version} and restart the server.` : `stim-server service update --label ${opts.label} --release ${version}${opts.noWait ? " --no-wait" : ""}`;
4584
5138
  if (decision === "too-old") throw new SetupRefusal(`This server predates setup support (${current?.version} speaks protocol ${health?.protocol ?? 1}; setup needs protocol ${SETUP_PROTOCOL}). ${desktop ? "Update and restart the app's server, then rerun setup." : "Update the service, then rerun setup."}`, false, fix);
4585
5139
  if (decision === "update") {
4586
5140
  if (!installed?.script || !installed.node || installed.port === null) throw new SetupRefusal("The managed service has no usable server invocation.");
@@ -4589,7 +5143,7 @@ async function runSetup(args, version, deps) {
4589
5143
  script: installed.script,
4590
5144
  node: installed.node,
4591
5145
  port: installed.port
4592
- }, { release: version }, chatter))) chatter(note);
5146
+ }, { release: version }, chatter, !opts.noWait))) chatter(note);
4593
5147
  installed = await wait(deps.installed(opts.label));
4594
5148
  const updated = await wait(deps.health(opts.port));
4595
5149
  if (!updated) throw new SetupRefusal("The updated server is not answering.");
@@ -4712,12 +5266,28 @@ async function runSetup(args, version, deps) {
4712
5266
  approved.push(capability);
4713
5267
  }
4714
5268
  if (approved.length && deps.now() < Date.parse(opts.expiresAt)) {
4715
- deps.storeSecret(opts.ticket, approved, opts.expiresAt, deps.now());
4716
- secretStored = true;
4717
- while (deps.now() < Date.parse(opts.expiresAt)) {
4718
- for (const grant of recorded(approved)) note(grant, false);
4719
- if (approved.every((c) => journal.granted.some((g) => g.capability === c))) break;
4720
- await wait(deps.sleep(Math.min(1e3, Math.max(0, Date.parse(opts.expiresAt) - deps.now()))));
5269
+ let changed = false;
5270
+ let sleeping;
5271
+ const stopWatching = deps.watchRecords(() => {
5272
+ changed = true;
5273
+ sleeping?.abort();
5274
+ });
5275
+ try {
5276
+ deps.storeSecret(opts.ticket, approved, opts.expiresAt, deps.now());
5277
+ secretStored = true;
5278
+ if (deps.fromDoctor) printer.line("Waiting for the requesting machine to reconnect. Run `stim doctor` on that machine to finish.");
5279
+ while (deps.now() < Date.parse(opts.expiresAt)) {
5280
+ changed = false;
5281
+ for (const grant of recorded(approved)) note(grant, false);
5282
+ if (approved.every((c) => journal.granted.some((g) => g.capability === c))) break;
5283
+ if (changed) continue;
5284
+ sleeping = new AbortController();
5285
+ await wait(deps.sleep(Math.min(1e3, Math.max(0, Date.parse(opts.expiresAt) - deps.now())), sleeping.signal));
5286
+ sleeping = void 0;
5287
+ }
5288
+ } finally {
5289
+ sleeping?.abort();
5290
+ stopWatching();
4721
5291
  }
4722
5292
  }
4723
5293
  step("approve", journal.granted.length === opts.capabilities.length ? "ok" : "pending", "Access approval", journal.granted.length === opts.capabilities.length ? "Chosen capabilities approved." : "Missing approval: builds or hosted simulators will not work. Generate a new command after expiry.", void 0, approvalView(journal.granted.length === opts.capabilities.length));
@@ -4798,7 +5368,7 @@ const ERROR_REPEAT_MS = 6e4;
4798
5368
  const MAX_REMEMBERED = 256;
4799
5369
  /** One token of a service-log line: no whitespace or control characters, so a client cannot forge another field or line. */
4800
5370
  const printable = (value) => String(value).replace(/[^\x21-\x7e]+/g, "_").slice(0, 80);
4801
- const text = (fields) => Object.entries(fields).filter(([, value]) => value !== void 0 && value !== null).map(([key, value]) => `${key}=${printable(value)}`).join(" ");
5371
+ const text$1 = (fields) => Object.entries(fields).filter(([, value]) => value !== void 0 && value !== null).map(([key, value]) => `${key}=${printable(value)}`).join(" ");
4802
5372
  /**
4803
5373
  * Request timing for stim-server. An error reply always goes to the service log (stderr), once a minute per
4804
5374
  * client, method and code. With debug logging on, every request goes to the service log and to
@@ -4859,21 +5429,165 @@ function createRequestLog({ service = (line) => console.error(line), debug = cre
4859
5429
  error: code
4860
5430
  };
4861
5431
  if (debug.enabled()) {
4862
- service(`stim-server: debug request ${text(fields)}`);
5432
+ service(`stim-server: debug request ${text$1(fields)}`);
4863
5433
  const { run, ...rest } = fields;
4864
5434
  debug.log("request", {
4865
5435
  ...rest,
4866
5436
  runId: run
4867
5437
  });
4868
- } else if (code !== void 0 && errorDue(`${client.id}|${method}|${code}`)) service(`stim-server: request failed ${text(fields)}`);
5438
+ } else if (code !== void 0 && errorDue(`${client.id}|${method}|${code}`)) service(`stim-server: request failed ${text$1(fields)}`);
4869
5439
  }
4870
5440
  };
4871
5441
  },
4872
5442
  host(event, fields, failed = false) {
4873
5443
  if (debug.enabled()) {
4874
- service(`stim-server: debug ${event} ${text(fields)}`);
5444
+ service(`stim-server: debug ${event} ${text$1(fields)}`);
4875
5445
  debug.log(event, fields);
4876
- } else if (failed && errorDue(`host|${event}|${String(fields.host)}`)) service(`stim-server: ${event} ${text(fields)}`);
5446
+ } else if (failed && errorDue(`host|${event}|${String(fields.host)}`)) service(`stim-server: ${event} ${text$1(fields)}`);
5447
+ }
5448
+ };
5449
+ }
5450
+ //#endregion
5451
+ //#region src/platform-run.ts
5452
+ const nativeText = [
5453
+ "slot",
5454
+ "remoteBuild",
5455
+ "remote",
5456
+ "easProfile"
5457
+ ];
5458
+ const nativeBoolean = ["metroCheck", "buildCache"];
5459
+ const iosText = [
5460
+ "configuration",
5461
+ "scheme",
5462
+ "deviceType",
5463
+ "runtime",
5464
+ "simulatorApp"
5465
+ ];
5466
+ const androidText = [
5467
+ "variant",
5468
+ "systemImage",
5469
+ "deviceProfile"
5470
+ ];
5471
+ function text(value) {
5472
+ return typeof value === "string" && value.length > 0 && value.length <= 1024 && !value.includes("\0");
5473
+ }
5474
+ function optionalText(value) {
5475
+ return typeof value === "string" ? value : void 0;
5476
+ }
5477
+ function optionalBoolean(value) {
5478
+ return typeof value === "boolean" ? value : void 0;
5479
+ }
5480
+ function selector(value) {
5481
+ return typeof value === "string" || typeof value === "boolean" ? value : void 0;
5482
+ }
5483
+ function parsePlatformRun(params) {
5484
+ if (!isJsonObject(params) || Object.keys(params).some((key) => key !== "cwd" && key !== "options") || typeof params.cwd !== "string" || params.cwd.length > 4096 || params.cwd.includes("\0") || !isAbsolute(params.cwd) || !isJsonObject(params.options)) return null;
5485
+ const { cwd, options } = params;
5486
+ const { platform } = options;
5487
+ if (platform !== "ios" && platform !== "android" && platform !== "macos" && platform !== "web") return null;
5488
+ const texts = platform === "ios" ? [...nativeText, ...iosText] : platform === "android" ? [...nativeText, ...androidText] : platform === "macos" ? ["remote", "remoteBuild"] : [];
5489
+ const booleans = platform === "ios" || platform === "android" ? nativeBoolean : platform === "web" ? ["headed"] : [];
5490
+ for (const [key, value] of Object.entries(options)) {
5491
+ if (key === "platform") continue;
5492
+ if (texts.includes(key) ? !text(value) : booleans.includes(key) ? typeof value !== "boolean" : (platform === "ios" || platform === "android") && (key === "device" || key === "wait") ? typeof value !== "boolean" && !text(value) : true) return null;
5493
+ }
5494
+ if (platform === "web") return {
5495
+ cwd,
5496
+ options: {
5497
+ platform,
5498
+ headed: optionalBoolean(options.headed)
5499
+ }
5500
+ };
5501
+ if (platform === "macos") return {
5502
+ cwd,
5503
+ options: {
5504
+ platform,
5505
+ remote: optionalText(options.remote),
5506
+ remoteBuild: optionalText(options.remoteBuild)
5507
+ }
5508
+ };
5509
+ const native = {
5510
+ slot: optionalText(options.slot),
5511
+ metroCheck: optionalBoolean(options.metroCheck),
5512
+ buildCache: optionalBoolean(options.buildCache),
5513
+ remoteBuild: optionalText(options.remoteBuild),
5514
+ remote: optionalText(options.remote),
5515
+ easProfile: optionalText(options.easProfile),
5516
+ device: selector(options.device),
5517
+ wait: selector(options.wait)
5518
+ };
5519
+ return platform === "ios" ? {
5520
+ cwd,
5521
+ options: {
5522
+ ...native,
5523
+ platform,
5524
+ configuration: optionalText(options.configuration),
5525
+ scheme: optionalText(options.scheme),
5526
+ deviceType: optionalText(options.deviceType),
5527
+ runtime: optionalText(options.runtime),
5528
+ simulatorApp: optionalText(options.simulatorApp)
5529
+ }
5530
+ } : {
5531
+ cwd,
5532
+ options: {
5533
+ ...native,
5534
+ platform,
5535
+ variant: optionalText(options.variant),
5536
+ systemImage: optionalText(options.systemImage),
5537
+ deviceProfile: optionalText(options.deviceProfile)
5538
+ }
5539
+ };
5540
+ }
5541
+ //#endregion
5542
+ //#region src/platform-build.ts
5543
+ function parsePlatformBuild(params) {
5544
+ if (!isJsonObject(params) || Object.keys(params).some((key) => key !== "cwd" && key !== "options") || typeof params.cwd !== "string" || params.cwd.length > 4096 || params.cwd.includes("\0") || !isAbsolute(params.cwd) || !isJsonObject(params.options)) return null;
5545
+ const { cwd, options } = params;
5546
+ const { platform } = options;
5547
+ if (platform !== "ios" && platform !== "android" && platform !== "macos") return null;
5548
+ const texts = platform === "ios" ? [
5549
+ "configuration",
5550
+ "scheme",
5551
+ "remoteBuild"
5552
+ ] : platform === "android" ? ["variant", "remoteBuild"] : ["remoteBuild"];
5553
+ for (const [key, value] of Object.entries(options)) {
5554
+ if (key === "platform" || platform === "ios" && key === "arch" || platform === "android" && key === "abi") continue;
5555
+ if (texts.includes(key) ? typeof value !== "string" || value.length === 0 || value.length > 1024 || value.includes("\0") : platform !== "macos" && key === "buildCache" ? typeof value !== "boolean" : true) return null;
5556
+ }
5557
+ const remoteBuild = typeof options.remoteBuild === "string" ? options.remoteBuild : void 0;
5558
+ if (platform === "macos") return {
5559
+ cwd,
5560
+ options: {
5561
+ platform,
5562
+ remoteBuild
5563
+ }
5564
+ };
5565
+ const buildCache = typeof options.buildCache === "boolean" ? options.buildCache : void 0;
5566
+ if (platform === "ios") {
5567
+ const { arch } = options;
5568
+ if (arch !== void 0 && arch !== "arm64" && arch !== "x86_64" && arch !== "all") return null;
5569
+ return {
5570
+ cwd,
5571
+ options: {
5572
+ platform,
5573
+ remoteBuild,
5574
+ buildCache,
5575
+ arch,
5576
+ configuration: typeof options.configuration === "string" ? options.configuration : void 0,
5577
+ scheme: typeof options.scheme === "string" ? options.scheme : void 0
5578
+ }
5579
+ };
5580
+ }
5581
+ const { abi } = options;
5582
+ if (abi !== void 0 && abi !== "arm64-v8a" && abi !== "armeabi-v7a" && abi !== "x86" && abi !== "x86_64" && abi !== "all") return null;
5583
+ return {
5584
+ cwd,
5585
+ options: {
5586
+ platform,
5587
+ remoteBuild,
5588
+ buildCache,
5589
+ abi,
5590
+ variant: typeof options.variant === "string" ? options.variant : void 0
4877
5591
  }
4878
5592
  };
4879
5593
  }
@@ -13037,7 +13751,7 @@ function readFailure(cause) {
13037
13751
  return `${cause.code}: ${cause.message}${cause.remedy ? ` ${cause.remedy}` : ""}`;
13038
13752
  }
13039
13753
  function stopAction(cwd, env, limits, options = {}) {
13040
- const root = realpathSync(cwd);
13754
+ let root = realpathSync(cwd);
13041
13755
  if (!lstatSync(root).isDirectory()) throw new Error("The workspace is not a directory.");
13042
13756
  let stderr = "";
13043
13757
  const client = createStim({
@@ -13064,7 +13778,11 @@ function stopAction(cwd, env, limits, options = {}) {
13064
13778
  });
13065
13779
  const drained = Promise.resolve().then(() => client.stop({
13066
13780
  slot: options.slot,
13067
- signal: abort.signal
13781
+ signal: abort.signal,
13782
+ beforeStop: (workspace) => {
13783
+ options.beforeStop?.(workspace);
13784
+ root = workspace;
13785
+ }
13068
13786
  })).then((result) => {
13069
13787
  const stdout = JSON.stringify({
13070
13788
  root,
@@ -13086,7 +13804,8 @@ function stopAction(cwd, env, limits, options = {}) {
13086
13804
  };
13087
13805
  }, (cause) => ({
13088
13806
  ok: false,
13089
- message: readFailure(cause)
13807
+ message: readFailure(cause),
13808
+ ...cause instanceof StimError ? { code: cause.code } : {}
13090
13809
  })).finally(() => clearTimeout(timer));
13091
13810
  return {
13092
13811
  outcome: Promise.race([drained, deadline]),
@@ -13160,34 +13879,199 @@ function reloadAction(cwd, env, limits, options = {}) {
13160
13879
  }
13161
13880
  };
13162
13881
  }
13163
- function isLoopback(address) {
13164
- return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
13165
- }
13166
- /**
13167
- * The tailnet address of the peer. `tailscale serve` proxies from loopback and names the peer in
13168
- * X-Forwarded-For; a loopback connection without that header comes from this machine.
13169
- */
13170
- function peerAddress(request) {
13171
- const remote = request.socket.remoteAddress;
13172
- if (!isLoopback(remote)) return remote ?? null;
13173
- const forwarded = request.headers["x-forwarded-for"];
13174
- return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
13175
- }
13176
- function peerLabel(identity) {
13177
- return identity ? `${identity.name || identity.id}${identity.user ? ` (${identity.user})` : ""}` : null;
13178
- }
13179
- const NO_TAILSCALE = {
13180
- state: "unavailable",
13181
- reason: "the tailscale command was not found"
13182
- };
13183
- function serverTransports(options) {
13184
- const tailscale = options.transports?.find(isTailscaleTransport) ?? tailscaleTransport({
13185
- env: options.env,
13186
- snapshot: {
13187
- binary: options.tailscale ?? null,
13188
- state: options.tailscaleState ?? NO_TAILSCALE
13189
- },
13190
- monitor: options.tailscaleMonitor ?? null
13882
+ function lifecycleAction(cwd, env, limits, label, timeoutCode, execute, onProgress) {
13883
+ const abort = new AbortController();
13884
+ let bytes = 0;
13885
+ let timedOut = false;
13886
+ const timer = setTimeout(() => {
13887
+ timedOut = true;
13888
+ abort.abort();
13889
+ }, limits.timeoutMs);
13890
+ const drained = Promise.resolve().then(() => execute(createStim({
13891
+ projectRoot: cwd,
13892
+ env,
13893
+ onProgress: (progress) => {
13894
+ bytes += Buffer.byteLength(progress.message);
13895
+ if (bytes > limits.maxOutputBytes) throw new StimError({
13896
+ code: "STIM_OUTPUT_LIMIT",
13897
+ message: `${label} printed more than ${limits.maxOutputBytes} bytes.`
13898
+ });
13899
+ onProgress?.(progress);
13900
+ }
13901
+ }), abort.signal)).then((result) => result, (cause) => {
13902
+ const details = cause instanceof StimError && isJsonObject(cause.details) ? cause.details.cause : void 0;
13903
+ return {
13904
+ ok: false,
13905
+ message: readFailure(cause),
13906
+ ...cause instanceof StimError ? {
13907
+ code: cause.code,
13908
+ stdout: JSON.stringify({
13909
+ code: cause.code,
13910
+ message: cause.message,
13911
+ remedy: cause.remedy
13912
+ }),
13913
+ unresolved: cause.code === "STIM_CLAIM_UNAVAILABLE",
13914
+ ...isJsonObject(details) && Array.isArray(details.retainedClaimRoots) && details.retainedClaimRoots.every((root) => typeof root === "string" && isAbsolute(root)) ? { retainedClaimRoots: details.retainedClaimRoots } : {}
13915
+ } : {}
13916
+ };
13917
+ }).then((outcome) => !outcome.unresolved && bytes + Buffer.byteLength(outcome.stdout ?? "") > limits.maxOutputBytes ? {
13918
+ ok: false,
13919
+ code: "STIM_OUTPUT_LIMIT",
13920
+ unresolved: outcome.unresolved,
13921
+ retainedClaimRoots: outcome.retainedClaimRoots,
13922
+ message: `${label} printed more than ${limits.maxOutputBytes} bytes.`
13923
+ } : outcome).then((outcome) => timedOut && !outcome.unresolved ? {
13924
+ ok: false,
13925
+ code: timeoutCode,
13926
+ message: `${label} did not finish within ${limits.timeoutMs / 1e3} s.`
13927
+ } : outcome).finally(() => clearTimeout(timer));
13928
+ return {
13929
+ outcome: drained,
13930
+ drained,
13931
+ cancel: async () => {
13932
+ abort.abort();
13933
+ return await drained;
13934
+ }
13935
+ };
13936
+ }
13937
+ function startAction(cwd, env, limits, options = {}) {
13938
+ return lifecycleAction(cwd, env, limits, "stim start", "STIM_METRO_TIMEOUT", async (client, signal) => {
13939
+ const result = await client.start({
13940
+ remote: options.remote,
13941
+ resetCache: options.resetCache,
13942
+ waitSeconds: options.waitSeconds,
13943
+ beforeStart: options.beforeStart,
13944
+ signal
13945
+ });
13946
+ return {
13947
+ ok: true,
13948
+ stdout: JSON.stringify(result),
13949
+ summary: `Dev server ${result.alreadyRunning ? "already running" : "ready"} on port ${result.port}.`
13950
+ };
13951
+ }, options.onProgress);
13952
+ }
13953
+ function platformRunAction(cwd, env, limits, options, onProgress) {
13954
+ return lifecycleAction(cwd, env, limits, `stim ${options.platform}`, "STIM_RUN_FAILED", async (client, signal) => {
13955
+ const result = await client.run({
13956
+ ...options,
13957
+ signal
13958
+ });
13959
+ const summary = result.platform === "web" ? `${result.facts.url} ${result.facts.launched === true ? "loaded" : result.facts.launched}. ${result.facts.running ? "Logs: stim logs --errors." : "The owned Chrome is no longer running. Run stim logs --errors, then stim web again."}` : `${options.platform} run completed.`;
13960
+ return {
13961
+ ok: true,
13962
+ stdout: JSON.stringify(result),
13963
+ summary
13964
+ };
13965
+ }, onProgress);
13966
+ }
13967
+ function platformBuildAction(cwd, env, limits, options, onProgress) {
13968
+ return lifecycleAction(cwd, env, limits, `${options.platform} build`, "STIM_BUILD_FAILED", async (client, signal) => {
13969
+ const result = await client.build({
13970
+ ...options,
13971
+ signal
13972
+ });
13973
+ return {
13974
+ ok: true,
13975
+ stdout: JSON.stringify(result),
13976
+ summary: `${options.platform} build completed.`
13977
+ };
13978
+ }, onProgress);
13979
+ }
13980
+ function gcAction(cwd, env, limits, options, onProgress) {
13981
+ return lifecycleAction(cwd, env, limits, "stim gc", "STIM_GC_TIMEOUT", async (client, signal) => {
13982
+ const { report, exitCode } = await client.collectGarbage({
13983
+ ...options,
13984
+ signal
13985
+ });
13986
+ const stdout = JSON.stringify(report);
13987
+ return exitCode === 0 ? {
13988
+ ok: true,
13989
+ stdout,
13990
+ summary: "Garbage collection completed."
13991
+ } : {
13992
+ ok: false,
13993
+ stdout,
13994
+ code: "STIM_GC_FAILED",
13995
+ message: "Garbage collection reported failures."
13996
+ };
13997
+ }, onProgress);
13998
+ }
13999
+ function machineAccessAction(cwd, env, limits, ticket, onProgress) {
14000
+ return lifecycleAction(cwd, env, limits, "Machine access request", "STIM_REPORT_TIMEOUT", async (client, signal) => ({
14001
+ ok: true,
14002
+ stdout: JSON.stringify(await client.requestMachineAccess({
14003
+ ticket,
14004
+ signal
14005
+ })),
14006
+ summary: "Machine access report completed."
14007
+ }), onProgress);
14008
+ }
14009
+ function worktreeWarmAction(cwd, env, limits, options, onProgress) {
14010
+ return lifecycleAction(cwd, env, limits, "stim worktree warm", "STIM_WORKTREE_WARM_TIMEOUT", async (client, signal) => {
14011
+ const result = await client.warmWorktree({
14012
+ ...options,
14013
+ signal
14014
+ });
14015
+ const stdout = JSON.stringify(result);
14016
+ return result.exitCode === 0 ? {
14017
+ ok: true,
14018
+ stdout,
14019
+ summary: "Worktree warming completed."
14020
+ } : {
14021
+ ok: false,
14022
+ stdout,
14023
+ code: "STIM_WORKTREE_WARM_FAILED",
14024
+ message: "Worktree warming reported failures."
14025
+ };
14026
+ }, onProgress);
14027
+ }
14028
+ function worktreeRemoveAction(cwd, env, limits, options, onProgress) {
14029
+ return lifecycleAction(cwd, env, limits, "stim worktree remove", "STIM_WORKTREE_REMOVE_TIMEOUT", async (client, signal) => {
14030
+ const result = await client.removeWorktree({
14031
+ ...options,
14032
+ signal
14033
+ });
14034
+ const stdout = JSON.stringify(result);
14035
+ return result.exitCode === 0 ? {
14036
+ ok: true,
14037
+ stdout,
14038
+ summary: "Worktree cleanup completed."
14039
+ } : {
14040
+ ok: false,
14041
+ stdout,
14042
+ code: "STIM_WORKTREE_REMOVE_FAILED",
14043
+ message: "Worktree cleanup reported failures."
14044
+ };
14045
+ }, onProgress);
14046
+ }
14047
+ function isLoopback(address) {
14048
+ return address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1";
14049
+ }
14050
+ /**
14051
+ * The tailnet address of the peer. `tailscale serve` proxies from loopback and names the peer in
14052
+ * X-Forwarded-For; a loopback connection without that header comes from this machine.
14053
+ */
14054
+ function peerAddress(request) {
14055
+ const remote = request.socket.remoteAddress;
14056
+ if (!isLoopback(remote)) return remote ?? null;
14057
+ const forwarded = request.headers["x-forwarded-for"];
14058
+ return (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(",")[0]?.trim() || null;
14059
+ }
14060
+ function peerLabel(identity) {
14061
+ return identity ? `${identity.name || identity.id}${identity.user ? ` (${identity.user})` : ""}` : null;
14062
+ }
14063
+ const NO_TAILSCALE = {
14064
+ state: "unavailable",
14065
+ reason: "the tailscale command was not found"
14066
+ };
14067
+ function serverTransports(options) {
14068
+ const tailscale = options.transports?.find(isTailscaleTransport) ?? tailscaleTransport({
14069
+ env: options.env,
14070
+ snapshot: {
14071
+ binary: options.tailscale ?? null,
14072
+ state: options.tailscaleState ?? NO_TAILSCALE
14073
+ },
14074
+ monitor: options.tailscaleMonitor ?? null
13191
14075
  });
13192
14076
  return {
13193
14077
  transports: options.transports ?? [tailscale],
@@ -13263,6 +14147,9 @@ function buildAllowed(session) {
13263
14147
  function requestId(value) {
13264
14148
  return typeof value === "number" || typeof value === "string" ? value : null;
13265
14149
  }
14150
+ function validPlanRequest(value) {
14151
+ return typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value);
14152
+ }
13266
14153
  function registeredWorkspace(workspace) {
13267
14154
  if (typeof workspace !== "string") return {
13268
14155
  code: "bad-request",
@@ -13481,6 +14368,7 @@ async function startServer(options) {
13481
14368
  ...options.actionLimits
13482
14369
  };
13483
14370
  const busyWorkspaces = /* @__PURE__ */ new Set();
14371
+ const retainedLifecycles = /* @__PURE__ */ new Set();
13484
14372
  const planQueues = /* @__PURE__ */ new Map();
13485
14373
  const readDetailsReport = (method, cwd = homedir(), platform) => {
13486
14374
  const abort = new AbortController();
@@ -13791,6 +14679,7 @@ async function startServer(options) {
13791
14679
  const replays = /* @__PURE__ */ new Map();
13792
14680
  let keyframeReads = 0;
13793
14681
  const commands = /* @__PURE__ */ new Set();
14682
+ const planRequests = /* @__PURE__ */ new Map();
13794
14683
  const persistentActions = /* @__PURE__ */ new Set();
13795
14684
  let nextSubscription = 1;
13796
14685
  let device = null;
@@ -13874,7 +14763,19 @@ async function startServer(options) {
13874
14763
  },
13875
14764
  ...options.host ? { host } : {},
13876
14765
  capabilities: device.capabilities,
13877
- features: protocol_exports.FEATURES.filter((feature) => (!["desktop-settings-write", "desktop-reload"].includes(feature) || options.desktopBootstrap !== void 0) && (local || ![
14766
+ features: protocol_exports.FEATURES.filter((feature) => (![
14767
+ "desktop-settings-write",
14768
+ "desktop-reload",
14769
+ "desktop-start",
14770
+ "desktop-run",
14771
+ "desktop-build",
14772
+ "desktop-gc",
14773
+ "desktop-worktree-remove",
14774
+ "desktop-worktree-warm",
14775
+ "desktop-machine-access",
14776
+ "desktop-plan"
14777
+ ].includes(feature) || options.desktopBootstrap !== void 0) && (local || ![
14778
+ "desktop-plan",
13878
14779
  "desktop-reports",
13879
14780
  "desktop-report-platform",
13880
14781
  "desktop-settings",
@@ -13882,7 +14783,14 @@ async function startServer(options) {
13882
14783
  "desktop-full-logs",
13883
14784
  "desktop-doctor-run",
13884
14785
  "desktop-stop",
13885
- "desktop-reload"
14786
+ "desktop-reload",
14787
+ "desktop-start",
14788
+ "desktop-run",
14789
+ "desktop-build",
14790
+ "desktop-gc",
14791
+ "desktop-worktree-remove",
14792
+ "desktop-worktree-warm",
14793
+ "desktop-machine-access"
13886
14794
  ].includes(feature))),
13887
14795
  actions: device.capabilities.includes("control") ? [...protocol_exports.ACTIONS] : [],
13888
14796
  device: {
@@ -14561,54 +15469,6 @@ async function startServer(options) {
14561
15469
  });
14562
15470
  subscriptions.set(subscription, cleanup);
14563
15471
  }
14564
- function command(id, args, cwd, result, limits = commandLimits, turn = null) {
14565
- if (commands.size >= MAX_COMMANDS) {
14566
- error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
14567
- return null;
14568
- }
14569
- let run = null;
14570
- let dropped = false;
14571
- let release;
14572
- const released = new Promise((resolve) => {
14573
- release = resolve;
14574
- });
14575
- const cancel = async () => {
14576
- dropped = true;
14577
- if (!run) return void (turn ?? Promise.resolve()).then(release);
14578
- await run.cancel();
14579
- release();
14580
- };
14581
- const start = async () => {
14582
- if (dropped) return;
14583
- run = runStim(options.stimCli, options.env, args, cwd, limits);
14584
- const outcome = await run.outcome;
14585
- commands.delete(cancel);
14586
- running.delete(cancel);
14587
- try {
14588
- if (!outcome.ok) {
14589
- const printed = actionOutcome(outcome);
14590
- return error(id, "stim-failed", printed.ok ? outcome.message : printed.error.message);
14591
- }
14592
- let value;
14593
- try {
14594
- value = result(outcome.stdout);
14595
- } catch {
14596
- return error(id, "stim-failed", `stim ${args[0]} printed output that is not JSON.`);
14597
- }
14598
- send(socket, {
14599
- id,
14600
- result: value
14601
- });
14602
- } finally {
14603
- release();
14604
- }
14605
- };
14606
- commands.add(cancel);
14607
- running.add(cancel);
14608
- if (turn) turn.then(start);
14609
- else start();
14610
- return released;
14611
- }
14612
15472
  function archiveRead(id, read) {
14613
15473
  if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
14614
15474
  let dropped = false;
@@ -14674,15 +15534,22 @@ async function startServer(options) {
14674
15534
  running.delete(cancel);
14675
15535
  });
14676
15536
  }
14677
- function inProcessRead(id, label, read, timeoutMs = commandLimits.timeoutMs) {
14678
- if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
15537
+ function inProcessRead(id, label, read, timeoutMs = commandLimits.timeoutMs, turn = null) {
15538
+ if (commands.size >= MAX_COMMANDS) {
15539
+ error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
15540
+ return null;
15541
+ }
14679
15542
  const abort = new AbortController();
14680
- const deadline = setTimeout(() => {
14681
- if (abort.signal.aborted) return;
14682
- abort.abort();
14683
- error(id, "stim-failed", `${label} did not finish within ${timeoutMs / 1e3} s.`);
14684
- }, timeoutMs);
14685
- const task = Promise.resolve(abort.signal).then(read).then((result) => {
15543
+ let deadline;
15544
+ const task = Promise.resolve(turn).then(() => {
15545
+ abort.signal.throwIfAborted();
15546
+ deadline = setTimeout(() => {
15547
+ if (abort.signal.aborted) return;
15548
+ abort.abort();
15549
+ error(id, "stim-failed", `${label} did not finish within ${timeoutMs / 1e3} s.`);
15550
+ }, timeoutMs);
15551
+ return read(abort.signal);
15552
+ }).then((result) => {
14686
15553
  if (abort.signal.aborted) return void 0;
14687
15554
  if (Buffer.byteLength(JSON.stringify(result)) > commandLimits.maxOutputBytes) error(id, "stim-failed", `${label} printed more than ${commandLimits.maxOutputBytes} bytes.`);
14688
15555
  else send(socket, {
@@ -14702,16 +15569,23 @@ async function startServer(options) {
14702
15569
  };
14703
15570
  commands.add(cancel);
14704
15571
  running.add(cancel);
15572
+ return {
15573
+ done: task,
15574
+ cancel
15575
+ };
14705
15576
  }
14706
15577
  function workspaceCommand(id, method, params) {
14707
15578
  if (params !== void 0 && !isJsonObject(params)) return error(id, "bad-request", "params must be an object.");
14708
15579
  const cwd = workspaceDir(id, params?.workspace, false);
14709
15580
  if (!cwd) return;
14710
- if (method === "settings.get") return inProcessRead(id, "stim settings", (signal) => createStim({
14711
- projectRoot: cwd,
14712
- env: options.env
14713
- }).settings({ signal }));
14714
- return inProcessRead(id, "stim stats", (signal) => createStim({
15581
+ if (method === "settings.get") {
15582
+ inProcessRead(id, "stim settings", (signal) => createStim({
15583
+ projectRoot: cwd,
15584
+ env: options.env
15585
+ }).settings({ signal }));
15586
+ return;
15587
+ }
15588
+ inProcessRead(id, "stim stats", (signal) => createStim({
14715
15589
  projectRoot: cwd,
14716
15590
  env: options.env
14717
15591
  }).stats({ signal }));
@@ -14767,7 +15641,16 @@ async function startServer(options) {
14767
15641
  let admittedCwd = resolved.dir;
14768
15642
  let run;
14769
15643
  try {
14770
- run = action.action === "stop" ? stopAction(resolved.dir, options.env, actionLimits) : reloadAction(resolved.dir, options.env, actionLimits, {
15644
+ run = action.action === "stop" ? stopAction(resolved.dir, options.env, actionLimits, { beforeStop: (workspace) => {
15645
+ if (workspace === resolved.dir) return;
15646
+ if (busyWorkspaces.has(workspace)) throw new StimError({
15647
+ code: "STIM_ACTION_BUSY",
15648
+ message: `An action is already running in ${workspace}.`
15649
+ });
15650
+ busyWorkspaces.add(workspace);
15651
+ admittedCwd = workspace;
15652
+ record.workspace = clip(workspace);
15653
+ } }) : reloadAction(resolved.dir, options.env, actionLimits, {
14771
15654
  platform: action.platform,
14772
15655
  beforeReload: (workspace) => {
14773
15656
  if (workspace === resolved.dir) return;
@@ -14854,6 +15737,7 @@ async function startServer(options) {
14854
15737
  const subscription = openSubscription(id);
14855
15738
  if (!subscription) return;
14856
15739
  const startedAt = Date.now();
15740
+ let admittedCwd = cwd;
14857
15741
  const slot = typeof params.slot === "string" ? params.slot : void 0;
14858
15742
  let observing = true;
14859
15743
  let terminal;
@@ -14875,6 +15759,15 @@ async function startServer(options) {
14875
15759
  try {
14876
15760
  run = stopAction(cwd, options.env, actionLimits, {
14877
15761
  slot,
15762
+ beforeStop: (workspace) => {
15763
+ if (workspace === cwd) return;
15764
+ if (busyWorkspaces.has(workspace)) throw new StimError({
15765
+ code: "STIM_ACTION_BUSY",
15766
+ message: `An action is already running in ${workspace}.`
15767
+ });
15768
+ busyWorkspaces.add(workspace);
15769
+ admittedCwd = workspace;
15770
+ },
14878
15771
  onProgress: (progress) => {
14879
15772
  if (!observing) return;
14880
15773
  outputBytes += Buffer.byteLength(progress.message);
@@ -14911,6 +15804,7 @@ async function startServer(options) {
14911
15804
  run.drained.then((outcome) => {
14912
15805
  running.delete(cancel);
14913
15806
  busyWorkspaces.delete(cwd);
15807
+ busyWorkspaces.delete(admittedCwd);
14914
15808
  const recorded = terminal ?? outcome;
14915
15809
  auditSafely({
14916
15810
  at: new Date(startedAt).toISOString(),
@@ -14919,7 +15813,7 @@ async function startServer(options) {
14919
15813
  name: owner.name
14920
15814
  },
14921
15815
  action: "stop",
14922
- workspace: cwd,
15816
+ workspace: admittedCwd,
14923
15817
  ...slot === void 0 ? {} : { slot },
14924
15818
  ok: recorded.ok,
14925
15819
  durationMs: Date.now() - startedAt,
@@ -15016,6 +15910,193 @@ async function startServer(options) {
15016
15910
  });
15017
15911
  });
15018
15912
  }
15913
+ function subscribeStart(id, params, owner) {
15914
+ if (!isJsonObject(params) || Object.keys(params).some((key) => ![
15915
+ "cwd",
15916
+ "remote",
15917
+ "resetCache",
15918
+ "waitSeconds"
15919
+ ].includes(key)) || typeof params.cwd !== "string" || params.cwd.length > 4096 || params.cwd.includes("\0") || !isAbsolute(params.cwd) || params.remote !== void 0 && typeof params.remote !== "boolean" || params.resetCache !== void 0 && typeof params.resetCache !== "boolean" || params.waitSeconds !== void 0 && (typeof params.waitSeconds !== "number" || !Number.isSafeInteger(params.waitSeconds) || params.waitSeconds <= 0)) return error(id, "bad-request", "Start requires an absolute cwd, optional remote/resetCache booleans, and a positive whole waitSeconds.");
15920
+ const remote = typeof params.remote === "boolean" ? params.remote : void 0;
15921
+ const resetCache = typeof params.resetCache === "boolean" ? params.resetCache : void 0;
15922
+ const waitSeconds = typeof params.waitSeconds === "number" ? params.waitSeconds : void 0;
15923
+ subscribeLifecycle(id, params.cwd, owner, "start", (cwd, beforeStart, onProgress) => startAction(cwd, options.env, actionLimits, {
15924
+ remote,
15925
+ resetCache,
15926
+ waitSeconds,
15927
+ beforeStart,
15928
+ onProgress
15929
+ }));
15930
+ }
15931
+ function subscribeRun(id, params, owner) {
15932
+ const parsed = parsePlatformRun(params);
15933
+ if (!parsed) return error(id, "bad-request", "Run requires an absolute cwd and valid platform options.");
15934
+ const limits = {
15935
+ ...actionLimits,
15936
+ timeoutMs: options.actionLimits?.timeoutMs ?? 36e5
15937
+ };
15938
+ subscribeLifecycle(id, parsed.cwd, owner, "run", (cwd, _beforeRun, onProgress) => platformRunAction(cwd, options.env, limits, parsed.options, onProgress), parsed.options.platform);
15939
+ }
15940
+ function subscribeBuild(id, params, owner) {
15941
+ const parsed = parsePlatformBuild(params);
15942
+ if (!parsed) return error(id, "bad-request", "Build requires an absolute cwd and valid artifact-only options.");
15943
+ const limits = {
15944
+ ...actionLimits,
15945
+ timeoutMs: options.actionLimits?.timeoutMs ?? 36e5
15946
+ };
15947
+ subscribeLifecycle(id, parsed.cwd, owner, "build", (cwd, _beforeBuild, onProgress) => platformBuildAction(cwd, options.env, limits, parsed.options, onProgress), parsed.options.platform);
15948
+ }
15949
+ function subscribeGc(id, params, owner) {
15950
+ if (!desktopSessions.has(socket)) return error(id, "forbidden", "Garbage collection requires the connection of the Stim Desktop that started this server.");
15951
+ if (!isJsonObject(params) || Object.keys(params).some((key) => ![
15952
+ "cwd",
15953
+ "cache",
15954
+ "olderThan",
15955
+ "worktrees",
15956
+ "delete",
15957
+ "idleMs"
15958
+ ].includes(key)) || typeof params.cwd !== "string" || params.cwd.length > 4096 || params.cwd.includes("\0") || !isAbsolute(params.cwd) || params.cache !== void 0 && (typeof params.cache !== "string" || !params.cache.trim() || params.cache.length > 1024) || params.olderThan !== void 0 && (typeof params.olderThan !== "number" || !Number.isSafeInteger(params.olderThan)) || params.worktrees !== void 0 && typeof params.worktrees !== "boolean" || params.delete !== void 0 && typeof params.delete !== "boolean" || params.idleMs !== void 0 && (typeof params.idleMs !== "number" || !Number.isSafeInteger(params.idleMs) || params.idleMs <= 0)) return error(id, "bad-request", "GC requires an absolute cwd and valid cache, age, worktree, delete and idle selectors.");
15959
+ const request = {
15960
+ cache: typeof params.cache === "string" ? params.cache : void 0,
15961
+ olderThan: typeof params.olderThan === "number" ? params.olderThan : void 0,
15962
+ worktrees: typeof params.worktrees === "boolean" ? params.worktrees : void 0,
15963
+ delete: typeof params.delete === "boolean" ? params.delete : void 0,
15964
+ idleMs: typeof params.idleMs === "number" ? params.idleMs : void 0
15965
+ };
15966
+ subscribeLifecycle(id, params.cwd, owner, "gc", (cwd, _admit, onProgress) => gcAction(cwd, options.env, actionLimits, request, onProgress));
15967
+ }
15968
+ function subscribeMachineAccess(id, params, owner) {
15969
+ if (!desktopSessions.has(socket)) return error(id, "forbidden", "Requesting machine access requires the connection of the Stim Desktop that started this server.");
15970
+ if (!isJsonObject(params) || Object.keys(params).some((key) => key !== "cwd" && key !== "ticket") || typeof params.cwd !== "string" || params.cwd.length > 4096 || params.cwd.includes("\0") || !isAbsolute(params.cwd) || params.ticket !== void 0 && (typeof params.ticket !== "string" || !decodeSecret(params.ticket))) return error(id, "bad-request", "Machine access requires an absolute cwd and an optional valid setup ticket.");
15971
+ const ticket = typeof params.ticket === "string" ? params.ticket : void 0;
15972
+ subscribeLifecycle(id, params.cwd, owner, "machine-access", (cwd, _admit, onProgress) => machineAccessAction(cwd, options.env, actionLimits, ticket, onProgress));
15973
+ }
15974
+ function subscribeWorktreeWarm(id, params, owner) {
15975
+ if (!desktopSessions.has(socket)) return error(id, "forbidden", "Warming worktrees requires the connection of the Stim Desktop that started this server.");
15976
+ if (!isJsonObject(params) || Object.keys(params).some((key) => key !== "cwd") || typeof params.cwd !== "string" || params.cwd.length > 4096 || params.cwd.includes("\0") || !isAbsolute(params.cwd)) return error(id, "bad-request", "Worktree warming requires only an absolute cwd.");
15977
+ const limits = {
15978
+ ...actionLimits,
15979
+ timeoutMs: options.actionLimits?.timeoutMs ?? 54e5
15980
+ };
15981
+ subscribeLifecycle(id, params.cwd, owner, "worktree-warm", (cwd, beforeWarm, onProgress) => worktreeWarmAction(cwd, options.env, limits, { beforeWarm }, onProgress));
15982
+ }
15983
+ function subscribeWorktreeRemove(id, params, owner) {
15984
+ if (!desktopSessions.has(socket)) return error(id, "forbidden", "Removing worktrees requires the connection of the Stim Desktop that started this server.");
15985
+ if (!isJsonObject(params) || Object.keys(params).some((key) => ![
15986
+ "cwd",
15987
+ "target",
15988
+ "force"
15989
+ ].includes(key)) || typeof params.cwd !== "string" || params.cwd.length > 4096 || params.cwd.includes("\0") || !isAbsolute(params.cwd) || params.target !== void 0 && (typeof params.target !== "string" || params.target.length > 4096 || params.target.includes("\0") || !isAbsolute(params.target)) || params.force !== void 0 && typeof params.force !== "boolean") return error(id, "bad-request", "Worktree removal requires an absolute cwd, optional absolute target and boolean force.");
15990
+ const request = {
15991
+ target: typeof params.target === "string" ? params.target : void 0,
15992
+ force: typeof params.force === "boolean" ? params.force : void 0
15993
+ };
15994
+ subscribeLifecycle(id, params.cwd, owner, "worktree-remove", (cwd, beforeRemove, onProgress) => worktreeRemoveAction(cwd, options.env, actionLimits, {
15995
+ ...request,
15996
+ beforeRemove
15997
+ }, onProgress));
15998
+ }
15999
+ function subscribeLifecycle(id, requestedCwd, owner, kind, begin, platform) {
16000
+ let cwd;
16001
+ try {
16002
+ cwd = realpathSync(requestedCwd);
16003
+ if (!lstatSync(cwd).isDirectory()) throw new Error("The workspace is not a directory.");
16004
+ } catch (cause) {
16005
+ return error(id, "bad-request", `Cannot ${kind} this workspace: ${readFailure(cause)}`);
16006
+ }
16007
+ if (commands.size >= MAX_COMMANDS) return error(id, "limit-exceeded", `A connection can run ${MAX_COMMANDS} requests at a time.`);
16008
+ if (busyWorkspaces.has(cwd)) return error(id, "action-busy", `An action is already running in ${cwd}.`);
16009
+ const subscription = openSubscription(id);
16010
+ if (!subscription) return;
16011
+ const startedAt = Date.now();
16012
+ let admittedCwd = cwd;
16013
+ const admittedWorkspaces = /* @__PURE__ */ new Set();
16014
+ let observing = true;
16015
+ let terminal;
16016
+ const finish = (outcome) => {
16017
+ terminal ??= outcome;
16018
+ if (!observing) return;
16019
+ observing = false;
16020
+ subscriptions.delete(subscription);
16021
+ send(socket, {
16022
+ event: `desktop-${kind}-ended`,
16023
+ subscription,
16024
+ exitCode: outcome.ok ? 0 : 1,
16025
+ stdout: outcome.stdout ?? "",
16026
+ message: outcome.ok ? outcome.summary : outcome.message
16027
+ });
16028
+ };
16029
+ const run = begin(cwd, (workspace) => {
16030
+ if (admittedWorkspaces.has(workspace)) return;
16031
+ if (workspace !== cwd && busyWorkspaces.has(workspace)) throw new StimError({
16032
+ code: "STIM_ACTION_BUSY",
16033
+ message: `An action is already running in ${workspace}.`
16034
+ });
16035
+ if (admittedWorkspaces.size === 0) admittedCwd = workspace;
16036
+ admittedWorkspaces.add(workspace);
16037
+ busyWorkspaces.add(workspace);
16038
+ }, (progress) => {
16039
+ if (observing) send(socket, {
16040
+ event: `desktop-${kind}-progress`,
16041
+ subscription,
16042
+ ...progress
16043
+ });
16044
+ });
16045
+ const cancel = async () => {
16046
+ await run.cancel();
16047
+ };
16048
+ commands.add(cancel);
16049
+ running.add(cancel);
16050
+ busyWorkspaces.add(cwd);
16051
+ subscriptions.set(subscription, async () => {
16052
+ observing = false;
16053
+ const outcome = await run.cancel();
16054
+ if (!outcome.ok && outcome.unresolved) return {
16055
+ code: "action-failed",
16056
+ message: outcome.message
16057
+ };
16058
+ });
16059
+ run.outcome.then(finish);
16060
+ run.drained.then((outcome) => {
16061
+ if (kind === "gc" || kind === "worktree-remove" || kind === "worktree-warm" || kind === "machine-access") desktopReports.invalidate();
16062
+ const release = () => {
16063
+ commands.delete(cancel);
16064
+ running.delete(cancel);
16065
+ busyWorkspaces.delete(cwd);
16066
+ for (const workspace of admittedWorkspaces) busyWorkspaces.delete(workspace);
16067
+ };
16068
+ const roots = outcome.retainedClaimRoots;
16069
+ if (outcome.unresolved) {
16070
+ const reconcile = () => {
16071
+ if (!roots?.length) return;
16072
+ if (roots.some((root) => {
16073
+ const claims = readClaimSet(root);
16074
+ return claims.live.length > 0 || claims.unresolved.length > 0;
16075
+ })) return;
16076
+ release();
16077
+ retainedLifecycles.delete(reconcile);
16078
+ };
16079
+ retainedLifecycles.add(reconcile);
16080
+ } else release();
16081
+ const recorded = terminal ?? outcome;
16082
+ auditSafely({
16083
+ at: new Date(startedAt).toISOString(),
16084
+ device: {
16085
+ id: owner.id,
16086
+ name: owner.name
16087
+ },
16088
+ action: kind,
16089
+ workspace: admittedCwd,
16090
+ ...platform ? { platform } : {},
16091
+ ok: recorded.ok,
16092
+ durationMs: Date.now() - startedAt,
16093
+ ...recorded.ok ? {} : { error: {
16094
+ code: "action-failed",
16095
+ message: outcome.unresolved ? "STIM_CLAIM_UNAVAILABLE" : recorded.code || (kind === "start" ? "STIM_METRO_START_FAILED" : kind === "gc" ? "STIM_GC_FAILED" : kind === "build" ? "STIM_BUILD_FAILED" : "STIM_RUN_FAILED")
16096
+ } }
16097
+ });
16098
+ });
16099
+ }
15019
16100
  function writeDesktopSetting(id, params, owner) {
15020
16101
  if (!isJsonObject(params) || typeof params.cwd !== "string" || !isAbsolute(params.cwd) || params.cwd.length > 4096 || params.cwd.includes("\0") || typeof params.key !== "string" || params.key.length === 0 || params.key.length > 256 || params.action !== "set" && params.action !== "unset" || (params.action === "set" ? typeof params.value !== "string" : "value" in params) || Object.keys(params).some((key) => ![
15021
16102
  "cwd",
@@ -15396,23 +16477,46 @@ async function startServer(options) {
15396
16477
  if (slot !== void 0 && (typeof slot !== "string" || !SLOT_NAME.test(slot))) return error(id, "bad-request", "params.slot must be 1-64 letters, digits, underscores or hyphens.");
15397
16478
  const cwd = workspaceDir(id, params.workspace, true);
15398
16479
  if (!cwd) return;
15399
- const finished = command(id, [
16480
+ queuePlan(id, cwd, {
15400
16481
  platform,
15401
- "--plan",
15402
- "--json",
15403
- ...slot === void 0 ? [] : [`--slot=${slot}`]
15404
- ], cwd, (stdout) => {
15405
- const value = JSON.parse(stdout);
15406
- if (!isJsonObject(value) || value.platform !== platform) throw new Error("not a plan");
15407
- return value;
15408
- }, {
15409
- ...commandLimits,
15410
- timeoutMs: options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS
15411
- }, planQueues.get(cwd) ?? null);
16482
+ ...slot === void 0 ? {} : { slot }
16483
+ }, "build.plan");
16484
+ }
16485
+ function queuePlan(id, cwd, plan, method, request) {
16486
+ if (request !== void 0 && planRequests.size >= MAX_COMMANDS) {
16487
+ error(id, "limit-exceeded", "Acknowledge retained plan refusals before requesting more previews.");
16488
+ return;
16489
+ }
16490
+ let unresolved;
16491
+ const finished = inProcessRead(id, `stim ${plan.platform}`, async (signal) => {
16492
+ try {
16493
+ return await createStim({
16494
+ projectRoot: cwd,
16495
+ env: options.env
16496
+ }).plan({
16497
+ ...plan,
16498
+ signal
16499
+ });
16500
+ } catch (cause) {
16501
+ if (method !== "desktop.plan.get" || !(cause instanceof StimError)) throw cause;
16502
+ const refusal = {
16503
+ code: cause.code,
16504
+ message: cause.message,
16505
+ remedy: cause.remedy
16506
+ };
16507
+ if (cause.code === "STIM_CLAIM_UNAVAILABLE") unresolved = refusal;
16508
+ return refusal;
16509
+ }
16510
+ }, options.commandLimits?.timeoutMs ?? PLAN_TIMEOUT_MS, planQueues.get(cwd) ?? null);
15412
16511
  if (!finished) return;
15413
- planQueues.set(cwd, finished);
15414
- finished.finally(() => {
15415
- if (planQueues.get(cwd) === finished) planQueues.delete(cwd);
16512
+ planQueues.set(cwd, finished.done);
16513
+ if (request !== void 0) planRequests.set(request, {
16514
+ cancel: finished.cancel,
16515
+ refusal: () => unresolved
16516
+ });
16517
+ finished.done.finally(() => {
16518
+ if (planQueues.get(cwd) === finished.done) planQueues.delete(cwd);
16519
+ if (request !== void 0 && planRequests.get(request)?.cancel === finished.cancel && !unresolved) planRequests.delete(request);
15416
16520
  });
15417
16521
  }
15418
16522
  function registerPush(id, params, session) {
@@ -15441,7 +16545,7 @@ async function startServer(options) {
15441
16545
  async function buildMethod(id, method, params, session) {
15442
16546
  if (!buildAllowed(session)) return error(id, "forbidden", `${method} needs build access, which this machine has not granted this device.`);
15443
16547
  buildSession ??= builds.session(session.id, socket, (event) => send(socket, event));
15444
- const answer = method === "build.offer" ? await builds.offer(session.id, params) : method === "build.sync" ? buildSession.sync(params) : method === "build.start" ? await buildSession.start(params) : method === "build.cancel" ? buildSession.cancel(params) : method === "build.attach" ? buildSession.attach(params) : method === "build.cached-artifact" ? await buildSession.cachedArtifact(params) : await buildSession.artifact(params);
16548
+ const answer = method === "build.offer" ? await builds.offer(session.id, params, buildSession.signal) : method === "build.sync" ? buildSession.sync(params) : method === "build.start" ? await buildSession.start(params) : method === "build.cancel" ? buildSession.cancel(params) : method === "build.attach" ? buildSession.attach(params) : method === "build.cached-artifact" ? await buildSession.cachedArtifact(params) : await buildSession.artifact(params);
15445
16549
  send(socket, "error" in answer ? {
15446
16550
  id,
15447
16551
  error: answer.error
@@ -15464,10 +16568,19 @@ async function startServer(options) {
15464
16568
  "desktop.settings.write",
15465
16569
  "desktop.logs.subscribe",
15466
16570
  "desktop.doctor.run",
16571
+ "desktop.machine-access.subscribe",
15467
16572
  "desktop.stop.subscribe",
15468
- "desktop.reload.subscribe"
16573
+ "desktop.reload.subscribe",
16574
+ "desktop.start.subscribe",
16575
+ "desktop.run.subscribe",
16576
+ "desktop.build.subscribe",
16577
+ "desktop.gc.subscribe",
16578
+ "desktop.worktree.remove.subscribe",
16579
+ "desktop.worktree.warm.subscribe",
16580
+ "desktop.plan.get",
16581
+ "desktop.plan.cancel"
15469
16582
  ].includes(method)) return null;
15470
- if (!local || options.desktopBootstrap !== void 0 && !desktopSessions.has(socket)) return Promise.resolve(error(id, "forbidden", method === "route.setup" ? "Phone connection setup requires the connection of the Stim Desktop that started this server." : method === "desktop.doctor.run" ? "Running project doctor requires the connection of the Stim Desktop that started this server." : method === "desktop.settings.write" ? "Changing settings requires the connection of the Stim Desktop that started this server." : method === "desktop.reload.subscribe" ? "Reloading apps requires the connection of the Stim Desktop that started this server." : method === "desktop.stop.subscribe" ? "Stopping workspaces requires the connection of the Stim Desktop that started this server." : method.startsWith("desktop.") ? "Desktop reads require the connection of the Stim Desktop that started this server." : method.startsWith("device-host.sessions") ? "Listing or stopping hosted sessions requires the connection of the Stim Desktop that started this server." : "Updating another machine requires the connection of the Stim Desktop that started this server."));
16583
+ if (!local || options.desktopBootstrap !== void 0 && !desktopSessions.has(socket)) return Promise.resolve(error(id, "forbidden", method === "route.setup" ? "Phone connection setup requires the connection of the Stim Desktop that started this server." : method === "desktop.doctor.run" ? "Running project doctor requires the connection of the Stim Desktop that started this server." : method === "desktop.settings.write" ? "Changing settings requires the connection of the Stim Desktop that started this server." : method === "desktop.reload.subscribe" ? "Reloading apps requires the connection of the Stim Desktop that started this server." : method === "desktop.build.subscribe" ? "Building artifacts requires the connection of the Stim Desktop that started this server." : method === "desktop.run.subscribe" ? "Running apps requires the connection of the Stim Desktop that started this server." : method === "desktop.start.subscribe" ? "Starting dev servers requires the connection of the Stim Desktop that started this server." : method === "desktop.stop.subscribe" ? "Stopping workspaces requires the connection of the Stim Desktop that started this server." : method.startsWith("desktop.") ? "Desktop operations require the connection of the Stim Desktop that started this server." : method.startsWith("device-host.sessions") ? "Listing or stopping hosted sessions requires the connection of the Stim Desktop that started this server." : "Updating another machine requires the connection of the Stim Desktop that started this server."));
15471
16584
  if (method === "desktop.settings.write") {
15472
16585
  if (!desktopSessions.has(socket)) {
15473
16586
  error(id, "forbidden", "Changing settings requires the connection of the Stim Desktop that started this server.");
@@ -15484,6 +16597,93 @@ async function startServer(options) {
15484
16597
  subscribeReload(id, params, owner);
15485
16598
  return Promise.resolve();
15486
16599
  }
16600
+ if (method === "desktop.plan.cancel") {
16601
+ if (!desktopSessions.has(socket)) {
16602
+ error(id, "forbidden", "Cancelling a plan requires the connection of the Stim Desktop that started this server.");
16603
+ return Promise.resolve();
16604
+ }
16605
+ if (!isJsonObject(params) || Object.keys(params).length !== 1 || !validPlanRequest(params.request)) {
16606
+ error(id, "bad-request", "Choose the request of this connection to cancel.");
16607
+ return Promise.resolve();
16608
+ }
16609
+ const request = params.request;
16610
+ const pending = planRequests.get(request);
16611
+ (async () => {
16612
+ await pending?.cancel();
16613
+ const refusal = pending?.refusal();
16614
+ if (planRequests.get(request) === pending) planRequests.delete(request);
16615
+ send(socket, {
16616
+ id,
16617
+ result: {
16618
+ cancelled: pending !== void 0,
16619
+ ...refusal ? { refusal } : {}
16620
+ }
16621
+ });
16622
+ })();
16623
+ return Promise.resolve();
16624
+ }
16625
+ if (method === "desktop.plan.get") {
16626
+ if (!desktopSessions.has(socket)) {
16627
+ error(id, "forbidden", "Planning builds requires the connection of the Stim Desktop that started this server.");
16628
+ return Promise.resolve();
16629
+ }
16630
+ if (!isJsonObject(params) || Object.keys(params).some((key) => key !== "cwd" && key !== "platform" && key !== "slot" && key !== "request") || typeof params.cwd !== "string" || params.cwd.length > 4096 || !isAbsolute(params.cwd) || params.request !== void 0 && !validPlanRequest(params.request) || params.platform !== "ios" && params.platform !== "android" && params.platform !== "macos" || params.slot !== void 0 && (params.platform === "macos" || typeof params.slot !== "string" || !SLOT_NAME.test(params.slot))) {
16631
+ error(id, "bad-request", "Choose an absolute cwd and ios/android/macos platform; slot applies only to ios/android.");
16632
+ return Promise.resolve();
16633
+ }
16634
+ if (typeof params.request === "string" && planRequests.has(params.request)) {
16635
+ error(id, "bad-request", "This connection already has a plan with that request.");
16636
+ return Promise.resolve();
16637
+ }
16638
+ let cwd;
16639
+ try {
16640
+ cwd = realpathSync(params.cwd);
16641
+ if (!lstatSync(cwd).isDirectory()) throw new Error("not a directory");
16642
+ } catch {
16643
+ error(id, "bad-request", "Choose an existing project directory.");
16644
+ return Promise.resolve();
16645
+ }
16646
+ const plan = params.platform === "macos" ? { platform: "macos" } : {
16647
+ platform: params.platform,
16648
+ ...params.slot === void 0 ? {} : { slot: params.slot }
16649
+ };
16650
+ queuePlan(id, cwd, plan, "desktop.plan.get", typeof params.request === "string" ? params.request : void 0);
16651
+ return Promise.resolve();
16652
+ }
16653
+ const subscribeCleanup = {
16654
+ "desktop.machine-access.subscribe": subscribeMachineAccess,
16655
+ "desktop.gc.subscribe": subscribeGc,
16656
+ "desktop.worktree.remove.subscribe": subscribeWorktreeRemove,
16657
+ "desktop.worktree.warm.subscribe": subscribeWorktreeWarm
16658
+ }[method];
16659
+ if (subscribeCleanup) {
16660
+ subscribeCleanup(id, params, owner);
16661
+ return Promise.resolve();
16662
+ }
16663
+ if (method === "desktop.build.subscribe") {
16664
+ if (!desktopSessions.has(socket)) {
16665
+ error(id, "forbidden", "Building artifacts requires the connection of the Stim Desktop that started this server.");
16666
+ return Promise.resolve();
16667
+ }
16668
+ subscribeBuild(id, params, owner);
16669
+ return Promise.resolve();
16670
+ }
16671
+ if (method === "desktop.run.subscribe") {
16672
+ if (!desktopSessions.has(socket)) {
16673
+ error(id, "forbidden", "Running apps requires the connection of the Stim Desktop that started this server.");
16674
+ return Promise.resolve();
16675
+ }
16676
+ subscribeRun(id, params, owner);
16677
+ return Promise.resolve();
16678
+ }
16679
+ if (method === "desktop.start.subscribe") {
16680
+ if (!desktopSessions.has(socket)) {
16681
+ error(id, "forbidden", "Starting dev servers requires the connection of the Stim Desktop that started this server.");
16682
+ return Promise.resolve();
16683
+ }
16684
+ subscribeStart(id, params, owner);
16685
+ return Promise.resolve();
16686
+ }
15487
16687
  if (method === "desktop.stop.subscribe") {
15488
16688
  subscribeStop(id, params, owner);
15489
16689
  return Promise.resolve();
@@ -15552,11 +16752,14 @@ async function startServer(options) {
15552
16752
  cwd: resolve(String(params.cwd)),
15553
16753
  ...params.platform === "ios" || params.platform === "android" ? { platform: params.platform } : {}
15554
16754
  };
15555
- if (method === "desktop.reports.get") return inProcessRead(id, `stim ${target.report}`, async (signal) => {
15556
- const snapshot = await desktopReports.get(target, signal);
15557
- if (snapshot.error) throw new Error(snapshot.error);
15558
- return snapshot;
15559
- }, options.commandLimits?.timeoutMs ?? DETAILS_TIMEOUT_MS);
16755
+ if (method === "desktop.reports.get") {
16756
+ inProcessRead(id, `stim ${target.report}`, async (signal) => {
16757
+ const snapshot = await desktopReports.get(target, signal);
16758
+ if (snapshot.error) throw new Error(snapshot.error);
16759
+ return snapshot;
16760
+ }, options.commandLimits?.timeoutMs ?? DETAILS_TIMEOUT_MS);
16761
+ return;
16762
+ }
15560
16763
  const subscription = openSubscription(id);
15561
16764
  if (!subscription) return;
15562
16765
  subscriptions.set(subscription, desktopReports.subscribe(target, (snapshot) => send(socket, {
@@ -15687,17 +16890,7 @@ async function startServer(options) {
15687
16890
  result: {}
15688
16891
  });
15689
16892
  }
15690
- if (method === "device-host.unsubscribe") {
15691
- const name = params.subscription;
15692
- const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
15693
- if (!unsubscribe) return error(id, "unknown-subscription", `No subscription ${String(name)}.`);
15694
- unsubscribe();
15695
- subscriptions.delete(name);
15696
- return send(socket, {
15697
- id,
15698
- result: {}
15699
- });
15700
- }
16893
+ if (method === "device-host.unsubscribe") return unsubscribeRequest(id, params.subscription);
15701
16894
  if (method === "device-host.control.end") {
15702
16895
  const name = params.session;
15703
16896
  if (typeof name !== "string" || !control.endById(controller(session), name)) return error(id, "unknown-session", `No control session ${String(name)} on this connection.`);
@@ -15730,6 +16923,20 @@ async function startServer(options) {
15730
16923
  result: { authorized: true }
15731
16924
  });
15732
16925
  }
16926
+ async function unsubscribeRequest(id, name) {
16927
+ if (typeof name !== "string") return error(id, "unknown-subscription", `No subscription ${String(name)}.`);
16928
+ const unsubscribe = subscriptions.get(name);
16929
+ if (!unsubscribe) return error(id, "unknown-subscription", `No subscription ${name}.`);
16930
+ subscriptions.delete(name);
16931
+ const failure = await unsubscribe();
16932
+ send(socket, failure ? {
16933
+ id,
16934
+ error: failure
16935
+ } : {
16936
+ id,
16937
+ result: {}
16938
+ });
16939
+ }
15733
16940
  async function handle(raw) {
15734
16941
  if (socket.readyState !== socket.OPEN) return;
15735
16942
  let message;
@@ -15753,6 +16960,7 @@ async function startServer(options) {
15753
16960
  if (message.method === "hello") return hello(id, message.params);
15754
16961
  if (!device) return refuse(id, "unauthorized", "Send hello first.", CLOSE_REFUSED);
15755
16962
  if (message.method === "desktop.authenticate") return authenticateDesktop(id, message.params);
16963
+ for (const reconcile of retainedLifecycles) reconcile();
15756
16964
  if (protocol_exports.DEVICE_HOST_METHODS.includes(message.method)) {
15757
16965
  if (!device.capabilities.includes("device-host")) return error(id, "forbidden", "Explicit device-host approval is required.");
15758
16966
  return hostedMethod(id, message.method, message.params, device);
@@ -15889,23 +17097,7 @@ async function startServer(options) {
15889
17097
  result: {}
15890
17098
  });
15891
17099
  }
15892
- if (message.method === "unsubscribe") {
15893
- const name = isJsonObject(message.params) ? message.params.subscription : void 0;
15894
- const unsubscribe = typeof name === "string" ? subscriptions.get(name) : void 0;
15895
- if (!unsubscribe) return send(socket, {
15896
- id,
15897
- error: {
15898
- code: "unknown-subscription",
15899
- message: `No subscription ${String(name)}.`
15900
- }
15901
- });
15902
- subscriptions.delete(name);
15903
- unsubscribe();
15904
- return send(socket, {
15905
- id,
15906
- result: {}
15907
- });
15908
- }
17100
+ if (message.method === "unsubscribe") return unsubscribeRequest(id, isJsonObject(message.params) ? message.params.subscription : void 0);
15909
17101
  send(socket, {
15910
17102
  id,
15911
17103
  error: {
@@ -16448,7 +17640,7 @@ const USAGE = `Usage:
16448
17640
  --env and --path-prepend apply after the login shell's environment
16449
17641
  STIM_SETUP_TICKET=<t> stim-server setup --expires <iso>
16450
17642
  [--build] [--device-host] [--port <n>] [--label <name>]
16451
- [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json] [--verbose]
17643
+ [--env KEY=VALUE]... [--path-prepend <dir>]... [--yes] [--json] [--verbose] [--no-wait]
16452
17644
  worker-side approval of the machine holding this ticket, until expiry
16453
17645
  stim-server service install [--port <n>] [--label <name>] [--serve]
16454
17646
  [--env KEY=VALUE]... [--path-prepend <dir>]...
@@ -16456,10 +17648,11 @@ const USAGE = `Usage:
16456
17648
  a Linux systemd user service, or a Windows scheduled task;
16457
17649
  --serve adds a tailnet-only \`tailscale serve\` route
16458
17650
  stim-server service status [--label <name>] [--json]
16459
- stim-server service update [--label <name>] --release <version>|--from <dir>
17651
+ stim-server service update [--label <name>] [--no-wait] --release <version>|--from <dir>
16460
17652
  install that stim-server release from npm, or the .tgz
16461
17653
  packages in <dir>, beside the current one, switch the
16462
17654
  service to it, and switch back if it does not answer
17655
+ --no-wait interrupts active builds and hosted sessions at restart
16463
17656
  stim-server service rollback [--label <name>]
16464
17657
  switch the service back to the server it ran before
16465
17658
  stim-server service uninstall [--label <name>]
@@ -16643,6 +17836,7 @@ async function runService(sub, extra, values, env, pathPrepend) {
16643
17836
  "uninstall"
16644
17837
  ].includes(sub ?? "")) fail(`unknown command.\n${USAGE}`);
16645
17838
  if (sub !== "update" && (values.release !== void 0 || values.from !== void 0)) fail(`--release and --from apply only to \`service update\`.\n${USAGE}`);
17839
+ if (sub !== "update" && values["no-wait"]) fail(`--no-wait applies only to \`service update\`.\n${USAGE}`);
16646
17840
  if (sub !== "install" && (values.serve || values.port !== void 0 || values.env || values["path-prepend"])) fail(`--port, --serve, --env and --path-prepend apply only to \`service install\`.\n${USAGE}`);
16647
17841
  if (sub !== "status" && values.json) fail(`--json applies only to \`service status\`.\n${USAGE}`);
16648
17842
  try {
@@ -16670,7 +17864,7 @@ async function runService(sub, extra, values, env, pathPrepend) {
16670
17864
  const from = values.from === void 0 ? null : resolve(values.from);
16671
17865
  if (from !== null && !statSync(from, { throwIfNoEntry: false })?.isDirectory()) fail(`--from takes a directory of .tgz packages, got ${values.from}.`);
16672
17866
  const source = from === null ? { release: values.release } : { from };
16673
- for (const line of await updateService(label, source, (progress) => console.log(progress))) console.log(line);
17867
+ for (const line of await updateService(label, source, (progress) => console.log(progress), !values["no-wait"])) console.log(line);
16674
17868
  } else if (sub === "rollback") for (const line of await rollbackService(label, (progress) => console.log(progress))) console.log(line);
16675
17869
  else for (const line of await uninstallService(label)) console.log(line);
16676
17870
  } catch (error) {
@@ -16685,11 +17879,14 @@ async function setup() {
16685
17879
  process.on("SIGINT", interrupt);
16686
17880
  process.on("SIGTERM", interrupt);
16687
17881
  const ticket = process.env.STIM_SETUP_TICKET;
17882
+ const fromDoctor = process.env.STIM_SETUP_ORIGIN === "doctor";
16688
17883
  delete process.env.STIM_SETUP_TICKET;
17884
+ delete process.env.STIM_SETUP_ORIGIN;
16689
17885
  try {
16690
17886
  process.exitCode = await runSetup(process.argv.slice(3), pkg.version, {
16691
17887
  ...defaultSetupDeps(),
16692
17888
  ticket,
17889
+ fromDoctor,
16693
17890
  signal: controller.signal
16694
17891
  });
16695
17892
  } finally {
@@ -16722,6 +17919,7 @@ async function main() {
16722
17919
  },
16723
17920
  release: { type: "string" },
16724
17921
  from: { type: "string" },
17922
+ "no-wait": { type: "boolean" },
16725
17923
  help: {
16726
17924
  type: "boolean",
16727
17925
  short: "h"
@@ -16747,6 +17945,7 @@ async function main() {
16747
17945
  }
16748
17946
  if ((values.label !== void 0 || values.serve) && !service) fail(`--label and --serve apply only to \`service\`.\n${USAGE}`);
16749
17947
  if ((values.release !== void 0 || values.from !== void 0) && !service) fail(`--release and --from apply only to \`service update\`.\n${USAGE}`);
17948
+ if (values["no-wait"] && !service) fail(`--no-wait applies only to \`service update\` or \`setup\`.\n${USAGE}`);
16750
17949
  if (values["loopback-only"] && command !== void 0) fail(`--loopback-only applies only to serving.\n${USAGE}`);
16751
17950
  if (command === void 0) return serve(port, extraEnv, pathPrepend, values["loopback-only"] === true);
16752
17951
  const grant = command === "devices" && sub === "grant";