@stim-cli/core 1.13.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/dist/{core-CmZJEFj_.mjs → core-BwaxUjLX.mjs} +133 -5
- package/dist/index-DgLuTAvh.d.mts +1826 -0
- package/dist/index.d.mts +41 -1
- package/dist/index.mjs +2 -2
- package/dist/oversight.d.mts +308 -0
- package/dist/oversight.mjs +666 -0
- package/dist/ownership-claim-Xxxr9MPp.d.mts +152 -0
- package/dist/ownership-claim.d.mts +1 -151
- package/dist/phone-protocol.d.mts +434 -0
- package/dist/phone-protocol.mjs +2 -0
- package/dist/process-identity-BTyww2Xj.d.mts +37 -0
- package/dist/process-identity.d.mts +2 -30
- package/dist/process-identity.mjs +10 -2
- package/dist/protocol-BjnE921M.d.mts +1442 -0
- package/dist/protocol.d.mts +2 -0
- package/dist/protocol.mjs +372 -0
- package/dist/receive-protocol.d.mts +12 -0
- package/dist/receive-protocol.mjs +16563 -0
- package/dist/state.d.mts +2 -737
- package/dist/state.mjs +2738 -680
- package/package.json +21 -1
package/dist/state.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
1
|
+
import { _ as releaseClaim, f as isClaimRefusal, g as readClaimSet, x as tryAcquireClaim } from "./ownership-claim-O31Y5iIb.mjs";
|
|
2
|
+
import { inspectProcessIdentity } from "./process-identity.mjs";
|
|
3
|
+
import { C as workspaceStateDir, h as rotatedLogPath, j as withDirLock, l as configDir, x as workspaceName } from "./core-BwaxUjLX.mjs";
|
|
4
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { availableParallelism, homedir, loadavg } from "node:os";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
5
8
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
9
|
import { StringDecoder } from "node:string_decoder";
|
|
7
10
|
//#region state/json-file.ts
|
|
@@ -59,6 +62,10 @@ function workspaceMetadataFile(projectRoot) {
|
|
|
59
62
|
function workspaceLogsDir(projectRoot) {
|
|
60
63
|
return join(workspaceStateDir(projectRoot), "logs");
|
|
61
64
|
}
|
|
65
|
+
/** Device screen footage stim-server records for the workspace, one directory per platform and slot. */
|
|
66
|
+
function workspaceRecordingsDir(projectRoot) {
|
|
67
|
+
return join(workspaceStateDir(projectRoot), "recordings");
|
|
68
|
+
}
|
|
62
69
|
function workspaceLogErrorIndex(projectRoot) {
|
|
63
70
|
return join(workspaceStateDir(projectRoot), "log-error-index.json");
|
|
64
71
|
}
|
|
@@ -68,6 +75,16 @@ function workspaceDerivedData(projectRoot) {
|
|
|
68
75
|
function workspaceGradleBuild(projectRoot) {
|
|
69
76
|
return join(workspaceStateDir(projectRoot), "gradle-build");
|
|
70
77
|
}
|
|
78
|
+
function workspaceBuildDetailFile(projectRoot) {
|
|
79
|
+
return join(workspaceStateDir(projectRoot), "build-detail.json");
|
|
80
|
+
}
|
|
81
|
+
/** The agent sessions that stopped running in the workspace in the last `ENDED_AGENT_RETENTION_MS`. */
|
|
82
|
+
function workspaceEndedAgentsFile(projectRoot) {
|
|
83
|
+
return join(workspaceStateDir(projectRoot), "ended-agents.json");
|
|
84
|
+
}
|
|
85
|
+
function workspaceEndedAgentsLock(projectRoot) {
|
|
86
|
+
return join(workspaceStateDir(projectRoot), "ended-agents.lock");
|
|
87
|
+
}
|
|
71
88
|
function supervisorPidFile(projectRoot) {
|
|
72
89
|
return join(workspaceStateDir(projectRoot), "supervisor.pid");
|
|
73
90
|
}
|
|
@@ -101,6 +118,32 @@ function createdDevicesFile() {
|
|
|
101
118
|
function gitMergeCacheDir() {
|
|
102
119
|
return join(configDir(), "git-merge");
|
|
103
120
|
}
|
|
121
|
+
function pullRequestCacheDir() {
|
|
122
|
+
return join(configDir(), "pull-requests");
|
|
123
|
+
}
|
|
124
|
+
function agentSessionsCacheFile() {
|
|
125
|
+
return join(configDir(), "agent-sessions.json");
|
|
126
|
+
}
|
|
127
|
+
function diskUsageCacheDir() {
|
|
128
|
+
return join(configDir(), "disk-usage");
|
|
129
|
+
}
|
|
130
|
+
/** The build machines this Mac paired with, each with its pinned tailnet node and device token. */
|
|
131
|
+
function buildMachinesFile() {
|
|
132
|
+
return join(configDir(), "build-machines.json");
|
|
133
|
+
}
|
|
134
|
+
function buildMachinesLock() {
|
|
135
|
+
return join(configDir(), "build-machines.lock");
|
|
136
|
+
}
|
|
137
|
+
/** The hosting machines this Mac asked for access, separate from build credentials. */
|
|
138
|
+
function deviceHostMachinesFile() {
|
|
139
|
+
return join(configDir(), "device-host-machines.json");
|
|
140
|
+
}
|
|
141
|
+
function deviceHostMachinesLock() {
|
|
142
|
+
return join(configDir(), "device-host-machines.lock");
|
|
143
|
+
}
|
|
144
|
+
function deviceHostMachinesClaims() {
|
|
145
|
+
return join(configDir(), "device-host-machines.claims");
|
|
146
|
+
}
|
|
104
147
|
function createdDevicesLock() {
|
|
105
148
|
return join(configDir(), "created-devices.lock");
|
|
106
149
|
}
|
|
@@ -114,6 +157,13 @@ function easSessionLedgerFile(root = easMachineStateRoot()) {
|
|
|
114
157
|
function easSessionLedgerLock(root = easMachineStateRoot()) {
|
|
115
158
|
return join(root, "ledger.lock");
|
|
116
159
|
}
|
|
160
|
+
/** Where stim-server records, one file per server process, the devices whose frames a client subscribes to. */
|
|
161
|
+
function deviceViewersDir() {
|
|
162
|
+
return join(configDir(), "viewers");
|
|
163
|
+
}
|
|
164
|
+
function statsFile() {
|
|
165
|
+
return join(configDir(), "stats.json");
|
|
166
|
+
}
|
|
117
167
|
//#endregion
|
|
118
168
|
//#region state/config.ts
|
|
119
169
|
function withConfigLock(fn) {
|
|
@@ -131,6 +181,11 @@ function configCorrupt(reason, path = getConfigPath()) {
|
|
|
131
181
|
corrupt.code = "STIM_CONFIG_CORRUPT";
|
|
132
182
|
return corrupt;
|
|
133
183
|
}
|
|
184
|
+
/** The build worker root: `offload.workerRoot` when it is absolute, else `$STIM_HOME/build-worker`. */
|
|
185
|
+
function buildWorkerRoot(config = loadConfig()) {
|
|
186
|
+
const configured = config?.offload?.workerRoot;
|
|
187
|
+
return typeof configured === "string" && configured.startsWith("/") ? configured : join(configDir(), "build-worker");
|
|
188
|
+
}
|
|
134
189
|
function loadConfig() {
|
|
135
190
|
const p = getConfigPath();
|
|
136
191
|
let parsed;
|
|
@@ -233,613 +288,178 @@ function findProjectByMetroPort(port) {
|
|
|
233
288
|
return null;
|
|
234
289
|
}
|
|
235
290
|
//#endregion
|
|
236
|
-
//#region state/
|
|
237
|
-
|
|
238
|
-
|
|
291
|
+
//#region state/hosted-macos.ts
|
|
292
|
+
/** App slots one host offers; a slot names the host-assigned bundle id `<bundleId>.hosted<slot>`. */
|
|
293
|
+
const HOSTED_MACOS_APP_SLOTS = 64;
|
|
294
|
+
const HOSTED_AGENT_DRIVERS = ["none", "agent-device"];
|
|
295
|
+
function hostedMacosAppSlot(value) {
|
|
296
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 64;
|
|
297
|
+
}
|
|
298
|
+
function hostedMacosBundleId(bundleId, appSlot) {
|
|
299
|
+
return `${bundleId}.hosted${appSlot}`;
|
|
300
|
+
}
|
|
301
|
+
function parseHostedMacosChoice(value) {
|
|
302
|
+
if (!isJsonObject(value) || value.architecture !== "arm64" && value.architecture !== "x86_64" || typeof value.macosVersion !== "string" || !/^\d+(\.\d+){0,2}$/.test(value.macosVersion)) return null;
|
|
303
|
+
return {
|
|
304
|
+
architecture: value.architecture,
|
|
305
|
+
macosVersion: value.macosVersion
|
|
306
|
+
};
|
|
239
307
|
}
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
};
|
|
248
|
-
} catch {
|
|
249
|
-
return {
|
|
250
|
-
ios: /* @__PURE__ */ new Set(),
|
|
251
|
-
android: /* @__PURE__ */ new Set(),
|
|
252
|
-
web: /* @__PURE__ */ new Set()
|
|
253
|
-
};
|
|
254
|
-
}
|
|
308
|
+
function parseHostedMacosDevice(value) {
|
|
309
|
+
if (!isJsonObject(value) || !parseHostedMacosChoice(value) || !hostedMacosAppSlot(value.appSlot) || Object.keys(value).some((key) => ![
|
|
310
|
+
"architecture",
|
|
311
|
+
"macosVersion",
|
|
312
|
+
"appSlot"
|
|
313
|
+
].includes(key))) return null;
|
|
314
|
+
return value;
|
|
255
315
|
}
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
if (
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (
|
|
271
|
-
if (
|
|
316
|
+
const AGENT_PATH = /^\/device-host\/agent\/[a-f0-9-]{36}\/$/;
|
|
317
|
+
const LEASE_NAME = /^[A-Za-z0-9._-]{1,128}$/;
|
|
318
|
+
const LEASE_DEVICE_KEY = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+@[1-9][0-9]{0,9}$/;
|
|
319
|
+
function parseHostedAgentLease(value) {
|
|
320
|
+
if (!isJsonObject(value) || Object.keys(value).length !== 4 || ![
|
|
321
|
+
value.tenant,
|
|
322
|
+
value.runId,
|
|
323
|
+
value.clientId
|
|
324
|
+
].every((name) => typeof name === "string" && LEASE_NAME.test(name)) || typeof value.deviceKey !== "string" || !LEASE_DEVICE_KEY.test(value.deviceKey)) return null;
|
|
325
|
+
return value;
|
|
326
|
+
}
|
|
327
|
+
/** A grant naming a driver this client does not know is not usable; it parses as null. */
|
|
328
|
+
function parseHostedAgentGrant(value) {
|
|
329
|
+
if (!isJsonObject(value)) return null;
|
|
330
|
+
if (value.driver === "none") return Object.keys(value).length === 1 ? { driver: "none" } : null;
|
|
331
|
+
if (!HOSTED_AGENT_DRIVERS.includes(value.driver) || typeof value.path !== "string" || !AGENT_PATH.test(value.path) || typeof value.token !== "string" || !/^[A-Za-z0-9_-]{32,256}$/.test(value.token) || typeof value.scope !== "string" || !/^[A-Za-z0-9._:-]{1,256}$/.test(value.scope) || Object.keys(value).length !== 5) return null;
|
|
332
|
+
const lease = parseHostedAgentLease(value.lease);
|
|
333
|
+
if (!lease) return null;
|
|
272
334
|
return {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
stateFile
|
|
335
|
+
driver: value.driver,
|
|
336
|
+
path: value.path,
|
|
337
|
+
token: value.token,
|
|
338
|
+
scope: value.scope,
|
|
339
|
+
lease: { ...lease }
|
|
279
340
|
};
|
|
280
341
|
}
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return {
|
|
293
|
-
claims: /* @__PURE__ */ new Map(),
|
|
294
|
-
notice: `EAS ownership ledger ${file} is unreadable: ${error.message}`,
|
|
295
|
-
safe: false
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
299
|
-
claims: /* @__PURE__ */ new Map(),
|
|
300
|
-
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
301
|
-
safe: false
|
|
302
|
-
};
|
|
303
|
-
const ledger = parsed;
|
|
304
|
-
if (ledger.version !== 1 || !ledger.claims || typeof ledger.claims !== "object" || Array.isArray(ledger.claims)) return {
|
|
305
|
-
claims: /* @__PURE__ */ new Map(),
|
|
306
|
-
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
307
|
-
safe: false
|
|
342
|
+
function parseHostedAgentAccess(value) {
|
|
343
|
+
if (!isJsonObject(value)) return null;
|
|
344
|
+
if (value.driver === "none") return value.setting === "hosting.agentDriver" && Object.keys(value).length === 2 ? {
|
|
345
|
+
driver: "none",
|
|
346
|
+
setting: "hosting.agentDriver"
|
|
347
|
+
} : null;
|
|
348
|
+
if (value.driver !== "agent-device" || typeof value.remoteConfig !== "string" || !value.remoteConfig.startsWith("/") || typeof value.command !== "string" || !value.command || Object.keys(value).length !== 3) return null;
|
|
349
|
+
return {
|
|
350
|
+
driver: "agent-device",
|
|
351
|
+
remoteConfig: value.remoteConfig,
|
|
352
|
+
command: value.command
|
|
308
353
|
};
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
315
|
-
safe: false
|
|
316
|
-
};
|
|
317
|
-
claims.set(id, claim);
|
|
318
|
-
}
|
|
354
|
+
}
|
|
355
|
+
function parseHostedMacosPlacement(value) {
|
|
356
|
+
if (!isJsonObject(value) || typeof value.machine !== "string" || !value.machine || typeof value.session !== "string" || !/^[a-f0-9-]{36}$/.test(value.session) || !hostedMacosAppSlot(value.appSlot) || typeof value.appAttempt !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(value.appAttempt) || typeof value.bundleId !== "string" || !value.bundleId.endsWith(`.hosted${value.appSlot}`)) return null;
|
|
357
|
+
const agent = parseHostedAgentAccess(value.agent);
|
|
358
|
+
if (!agent) return null;
|
|
319
359
|
return {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
360
|
+
machine: value.machine,
|
|
361
|
+
session: value.session,
|
|
362
|
+
appSlot: value.appSlot,
|
|
363
|
+
appAttempt: value.appAttempt,
|
|
364
|
+
bundleId: value.bundleId,
|
|
365
|
+
agent
|
|
323
366
|
};
|
|
324
367
|
}
|
|
325
368
|
//#endregion
|
|
326
|
-
//#region state/
|
|
327
|
-
const
|
|
328
|
-
"
|
|
329
|
-
"
|
|
330
|
-
"
|
|
331
|
-
"
|
|
332
|
-
"fatal"
|
|
369
|
+
//#region state/settings-registry.ts
|
|
370
|
+
const ANDROID_COMPILER_CACHE_CHOICES = [
|
|
371
|
+
"auto",
|
|
372
|
+
"ccache",
|
|
373
|
+
"cas",
|
|
374
|
+
"none"
|
|
333
375
|
];
|
|
334
|
-
const
|
|
335
|
-
"
|
|
336
|
-
"
|
|
337
|
-
"
|
|
338
|
-
"build",
|
|
339
|
-
"agent"
|
|
376
|
+
const ANDROID_PCH_CHOICES = [
|
|
377
|
+
"auto",
|
|
378
|
+
"on",
|
|
379
|
+
"off"
|
|
340
380
|
];
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
381
|
+
const TUNNEL_MODES = [
|
|
382
|
+
"auto",
|
|
383
|
+
"off",
|
|
384
|
+
"expo",
|
|
385
|
+
"cloudflared",
|
|
386
|
+
"ngrok",
|
|
387
|
+
"tailscale"
|
|
388
|
+
];
|
|
389
|
+
const SETTING_SCOPES = [
|
|
390
|
+
"machine",
|
|
391
|
+
"workspace",
|
|
392
|
+
"repo",
|
|
393
|
+
"committed"
|
|
394
|
+
];
|
|
395
|
+
const REMOTE_DEVICE_BACKENDS = ["proxy", "eas"];
|
|
396
|
+
const IOS_SIMULATOR_APPS = [
|
|
397
|
+
"xcode",
|
|
398
|
+
"siniulator",
|
|
399
|
+
"stim-desktop"
|
|
400
|
+
];
|
|
401
|
+
const ANDROID_EMULATOR_APPS = ["emulator", "stim-desktop"];
|
|
402
|
+
const OFFLOAD_MODES = [
|
|
403
|
+
"auto",
|
|
404
|
+
"force",
|
|
405
|
+
"off"
|
|
406
|
+
];
|
|
407
|
+
const WEB_VIEWPORTS = ["desktop", "phone"];
|
|
408
|
+
const PROJECT = [
|
|
409
|
+
"workspace",
|
|
410
|
+
"repo",
|
|
411
|
+
"committed"
|
|
412
|
+
];
|
|
413
|
+
const EVERY = [
|
|
414
|
+
"machine",
|
|
415
|
+
"workspace",
|
|
416
|
+
"repo",
|
|
417
|
+
"committed"
|
|
418
|
+
];
|
|
419
|
+
const MACHINE = ["machine"];
|
|
420
|
+
const RECORDING = [
|
|
421
|
+
"machine",
|
|
422
|
+
"workspace",
|
|
423
|
+
"repo"
|
|
424
|
+
];
|
|
425
|
+
const STRING = { kind: "string" };
|
|
426
|
+
const PATH = { kind: "path" };
|
|
427
|
+
const ABSOLUTE_PATH = {
|
|
428
|
+
kind: "path",
|
|
429
|
+
absolute: true
|
|
388
430
|
};
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (pageLoadTs === null || ts > pageLoadTs) pageLoadTs = ts;
|
|
422
|
-
} else if (launchTs === null || ts > launchTs) launchTs = ts;
|
|
423
|
-
}
|
|
424
|
-
return {
|
|
425
|
-
launchTs,
|
|
426
|
-
bundleTs,
|
|
427
|
-
pageLoadTs
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
function isWebPageRecord(record) {
|
|
431
|
-
return record.platform === "web" && record.src !== "metro";
|
|
432
|
-
}
|
|
433
|
-
function isAppDeviceRecord(record) {
|
|
434
|
-
return record.src === "device" && (record.event === "native_crash" || record.platform === "web");
|
|
435
|
-
}
|
|
436
|
-
function recordMatches(record, criteria = {}) {
|
|
437
|
-
if (!record) return false;
|
|
438
|
-
if (criteria.slot !== void 0 && (record.slot ?? "default") !== criteria.slot) return false;
|
|
439
|
-
const { sources, minLevel, grep, sinceTs, errorsOnly, markerTs, bundleMarkerTs } = criteria;
|
|
440
|
-
if (sources && sources.length > 0 && !sources.includes(record.src) && !(criteria.includeAppDeviceErrors && isAppDeviceRecord(record))) return false;
|
|
441
|
-
if (minLevel && levelRank(record.level) < levelRank(minLevel)) return false;
|
|
442
|
-
if (errorsOnly) {
|
|
443
|
-
if (record.level !== "error" && record.level !== "fatal") return false;
|
|
444
|
-
if (typeof markerTs === "number") {
|
|
445
|
-
const ts = tsOf(record);
|
|
446
|
-
if (ts === null || ts <= markerTs) return false;
|
|
447
|
-
}
|
|
448
|
-
if (typeof bundleMarkerTs === "number" && record.src === "metro") {
|
|
449
|
-
const ts = tsOf(record);
|
|
450
|
-
if (ts === null || ts < bundleMarkerTs) return false;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
if (typeof sinceTs === "number") {
|
|
454
|
-
const ts = tsOf(record);
|
|
455
|
-
if (ts === null || ts < sinceTs) return false;
|
|
456
|
-
}
|
|
457
|
-
if (grep) {
|
|
458
|
-
const re = grep instanceof RegExp ? grep : compileGrep(grep).re;
|
|
459
|
-
if (!re || String(record.msg ?? "").search(re) === -1) return false;
|
|
460
|
-
}
|
|
461
|
-
return true;
|
|
462
|
-
}
|
|
463
|
-
function buildCriteria({ slot, sources, minLevel, since, grep, errorsOnly, markerTs, bundleMarkerTs, now } = {}) {
|
|
464
|
-
const criteria = {
|
|
465
|
-
...slot === void 0 ? {} : { slot },
|
|
466
|
-
errorsOnly: Boolean(errorsOnly),
|
|
467
|
-
includeAppDeviceErrors: Boolean(errorsOnly && !sources?.length)
|
|
468
|
-
};
|
|
469
|
-
if (sources && sources.length > 0) criteria.sources = sources;
|
|
470
|
-
else if (criteria.errorsOnly) criteria.sources = ERROR_SOURCES;
|
|
471
|
-
if (minLevel) criteria.minLevel = minLevel;
|
|
472
|
-
if (typeof markerTs === "number") criteria.markerTs = markerTs;
|
|
473
|
-
if (typeof bundleMarkerTs === "number") criteria.bundleMarkerTs = bundleMarkerTs;
|
|
474
|
-
if (since !== void 0 && since !== null && since !== "") {
|
|
475
|
-
const parsed = parseSince(since);
|
|
476
|
-
if (parsed.error) throw new Error(parsed.error);
|
|
477
|
-
criteria.sinceTs = (typeof now === "number" ? now : Date.now()) - parsed.ms;
|
|
478
|
-
}
|
|
479
|
-
if (grep !== void 0 && grep !== null && grep !== "") {
|
|
480
|
-
const compiled = compileGrep(grep);
|
|
481
|
-
if (compiled.error) throw new Error(compiled.error);
|
|
482
|
-
criteria.grep = compiled.re;
|
|
483
|
-
}
|
|
484
|
-
return criteria;
|
|
485
|
-
}
|
|
486
|
-
function logFiles(dir) {
|
|
487
|
-
let entries;
|
|
488
|
-
try {
|
|
489
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
490
|
-
} catch {
|
|
491
|
-
return [];
|
|
492
|
-
}
|
|
493
|
-
return entries.filter((e) => e.isFile() && e.name.endsWith(".ndjson")).map((e) => e.name).toSorted();
|
|
494
|
-
}
|
|
495
|
-
function fileSizes(dir) {
|
|
496
|
-
const sizes = {};
|
|
497
|
-
for (const name of logFiles(dir)) try {
|
|
498
|
-
sizes[name] = statSync(join(dir, name)).size;
|
|
499
|
-
} catch {}
|
|
500
|
-
return sizes;
|
|
501
|
-
}
|
|
502
|
-
function readLogRecords(dir, include = () => true) {
|
|
503
|
-
const all = [];
|
|
504
|
-
for (const name of logFiles(dir).filter(include)) for (const record of readNdjsonGenerations(join(dir, name))) all.push(record);
|
|
505
|
-
return sortByTs(all);
|
|
506
|
-
}
|
|
507
|
-
function isExpoErrorContext(record, event) {
|
|
508
|
-
if (record.src !== "metro" || record.raw !== true || record.event !== event || typeof record.msg !== "string") return false;
|
|
509
|
-
return /^Code: \S/.test(record.msg) || /^\s*>?\s*\d+\s*\|/.test(record.msg) || /^\s*\|\s*\^/.test(record.msg) || /^Call Stack$/.test(record.msg) || /^\s+.+\([^()]+:\d+:\d+\)\s*$/.test(record.msg) || /^\s+at\s+\S+:\d+:\d+\s*$/.test(record.msg);
|
|
510
|
-
}
|
|
511
|
-
function isExpoError(record) {
|
|
512
|
-
return record.src === "metro" && record.raw === true && (record.level === "error" || record.level === "fatal") && typeof record.msg === "string";
|
|
513
|
-
}
|
|
514
|
-
function isSameExpoStream(record, error) {
|
|
515
|
-
return record.src === "metro" && record.raw === true && record.event === error.event;
|
|
516
|
-
}
|
|
517
|
-
function withExpoContext(record, context, into) {
|
|
518
|
-
if (context.length === 0) return record;
|
|
519
|
-
return into === "msg" ? {
|
|
520
|
-
...record,
|
|
521
|
-
msg: [record.msg, ...context].join("\n")
|
|
522
|
-
} : {
|
|
523
|
-
...record,
|
|
524
|
-
context
|
|
525
|
-
};
|
|
526
|
-
}
|
|
527
|
-
/**
|
|
528
|
-
* Adds the code frame and stack lines Expo printed after each Expo error record, either joined into
|
|
529
|
-
* `msg` or as a `context` string array.
|
|
530
|
-
*/
|
|
531
|
-
function attachExpoErrorContext(all, matched, into = "msg") {
|
|
532
|
-
return matched.map((record) => {
|
|
533
|
-
if (!isExpoError(record)) return record;
|
|
534
|
-
const index = all.findIndex((entry) => entry.ts === record.ts && entry.src === record.src && entry.msg === record.msg && entry.event === record.event);
|
|
535
|
-
if (index === -1) return record;
|
|
536
|
-
const context = [];
|
|
537
|
-
for (let i = index + 1; i < all.length; i += 1) {
|
|
538
|
-
const next = all[i];
|
|
539
|
-
if (!isSameExpoStream(next, record)) continue;
|
|
540
|
-
if (!isExpoErrorContext(next, record.event)) break;
|
|
541
|
-
context.push(next.msg);
|
|
542
|
-
}
|
|
543
|
-
return withExpoContext(record, context, into);
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
function expoContextFollower(onRecord, into) {
|
|
547
|
-
let pending = null;
|
|
548
|
-
let grew = false;
|
|
549
|
-
function flush() {
|
|
550
|
-
if (!pending) return;
|
|
551
|
-
const { record, context, queued } = pending;
|
|
552
|
-
pending = null;
|
|
553
|
-
onRecord(withExpoContext(record, context, into));
|
|
554
|
-
for (const next of queued) onRecord(next);
|
|
555
|
-
}
|
|
556
|
-
function push(record, matches) {
|
|
557
|
-
if (pending && isSameExpoStream(record, pending.record)) {
|
|
558
|
-
if (!isExpoErrorContext(record, pending.record.event)) flush();
|
|
559
|
-
else {
|
|
560
|
-
pending.context.push(record.msg);
|
|
561
|
-
if (matches) pending.queued.push(record);
|
|
562
|
-
grew = true;
|
|
563
|
-
return;
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
if (pending) {
|
|
567
|
-
if (matches) pending.queued.push(record);
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
if (matches && isExpoError(record)) {
|
|
571
|
-
pending = {
|
|
572
|
-
record,
|
|
573
|
-
context: [],
|
|
574
|
-
queued: []
|
|
575
|
-
};
|
|
576
|
-
grew = true;
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
if (matches) onRecord(record);
|
|
580
|
-
}
|
|
581
|
-
function settle() {
|
|
582
|
-
if (!grew) flush();
|
|
583
|
-
grew = false;
|
|
584
|
-
}
|
|
585
|
-
return {
|
|
586
|
-
push,
|
|
587
|
-
settle,
|
|
588
|
-
flush
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
function includeBareErrorContext(all, matched, launchTs, sinceTs) {
|
|
592
|
-
if (!matched.some((record) => record.src === "client" && (record.level === "error" || record.level === "fatal"))) return matched;
|
|
593
|
-
const renderedContext = [];
|
|
594
|
-
for (const record of all) {
|
|
595
|
-
if (record.src !== "client" || record.event !== "client_symbolication") continue;
|
|
596
|
-
const ts = tsOf(record);
|
|
597
|
-
if (launchTs !== null && (ts === null || ts <= launchTs)) continue;
|
|
598
|
-
if (sinceTs !== void 0 && (ts === null || ts < sinceTs)) continue;
|
|
599
|
-
renderedContext.push({
|
|
600
|
-
...record,
|
|
601
|
-
msg: `Metro symbolication context (not correlated to a specific client error)\n${String(record.msg ?? "")}`,
|
|
602
|
-
errorContext: true
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
return sortByTs([...matched, ...renderedContext]);
|
|
606
|
-
}
|
|
607
|
-
function launchMarkersBySlot(records) {
|
|
608
|
-
const markers = /* @__PURE__ */ new Map();
|
|
609
|
-
for (const record of records) {
|
|
610
|
-
if (record.marker !== true || record.src === "metro" || isWebPageRecord(record)) continue;
|
|
611
|
-
const ts = tsOf(record);
|
|
612
|
-
const slot = record.slot ?? "default";
|
|
613
|
-
if (ts !== null && ts > (markers.get(slot) ?? -Infinity)) markers.set(slot, ts);
|
|
614
|
-
}
|
|
615
|
-
return markers;
|
|
616
|
-
}
|
|
617
|
-
function queryLogs({ slot, dir, records, sources, minLevel, since, grep, tail, errorsOnly, errorContext, now } = {}) {
|
|
618
|
-
const all = (records ?? readLogRecords(dir)).filter((record) => slot === void 0 || (record.slot ?? "default") === slot);
|
|
619
|
-
if (all.length === 0) return [];
|
|
620
|
-
const { launchTs, bundleTs, pageLoadTs } = errorsOnly ? markerWindow(all) : {
|
|
621
|
-
launchTs: null,
|
|
622
|
-
bundleTs: null,
|
|
623
|
-
pageLoadTs: null
|
|
624
|
-
};
|
|
625
|
-
const criteria = buildCriteria({
|
|
626
|
-
slot,
|
|
627
|
-
sources,
|
|
628
|
-
minLevel,
|
|
629
|
-
since,
|
|
630
|
-
grep,
|
|
631
|
-
errorsOnly,
|
|
632
|
-
now,
|
|
633
|
-
markerTs: launchTs === null ? void 0 : launchTs,
|
|
634
|
-
bundleMarkerTs: bundleTs === null ? void 0 : bundleTs
|
|
635
|
-
});
|
|
636
|
-
const slotMarkers = launchMarkersBySlot(all);
|
|
637
|
-
const windowStart = (record) => {
|
|
638
|
-
if (pageLoadTs !== null && isWebPageRecord(record)) return pageLoadTs - 1;
|
|
639
|
-
if (record.src === "metro" || record.src === "client") return criteria.markerTs;
|
|
640
|
-
return slotMarkers.get(record.slot ?? "default");
|
|
641
|
-
};
|
|
642
|
-
let matched = all.filter((record) => recordMatches(record, {
|
|
643
|
-
...criteria,
|
|
644
|
-
markerTs: windowStart(record)
|
|
645
|
-
}));
|
|
646
|
-
if (typeof tail === "number" && tail >= 0 && matched.length > tail) matched = matched.slice(matched.length - tail);
|
|
647
|
-
return errorContext ? includeBareErrorContext(all, attachExpoErrorContext(all, matched), launchTs, criteria.sinceTs) : matched;
|
|
648
|
-
}
|
|
649
|
-
function tailRead(prev, size) {
|
|
650
|
-
const state = prev && typeof prev.offset === "number" ? prev : {
|
|
651
|
-
offset: 0,
|
|
652
|
-
partial: ""
|
|
653
|
-
};
|
|
654
|
-
if (size < state.offset) return {
|
|
655
|
-
start: 0,
|
|
656
|
-
prev: {
|
|
657
|
-
offset: 0,
|
|
658
|
-
partial: ""
|
|
659
|
-
}
|
|
660
|
-
};
|
|
661
|
-
return {
|
|
662
|
-
start: state.offset,
|
|
663
|
-
prev: state
|
|
664
|
-
};
|
|
665
|
-
}
|
|
666
|
-
function advanceTail(prev, chunk, size) {
|
|
667
|
-
const lines = ((prev && typeof prev.partial === "string" ? prev.partial : "") + (chunk || "")).split("\n");
|
|
668
|
-
const partial = lines.pop();
|
|
669
|
-
const records = [];
|
|
670
|
-
for (const line of lines) {
|
|
671
|
-
const record = parseNdjsonLine(line);
|
|
672
|
-
if (record) records.push(record);
|
|
673
|
-
}
|
|
674
|
-
return {
|
|
675
|
-
state: {
|
|
676
|
-
offset: size,
|
|
677
|
-
partial
|
|
678
|
-
},
|
|
679
|
-
records
|
|
680
|
-
};
|
|
681
|
-
}
|
|
682
|
-
function followLogs({ dir, onRecord, criteria = {}, intervalMs = 500, offsets = null, errorContext }) {
|
|
683
|
-
const follower = errorContext ? expoContextFollower(onRecord, errorContext) : null;
|
|
684
|
-
const state = /* @__PURE__ */ new Map();
|
|
685
|
-
const decoders = /* @__PURE__ */ new Map();
|
|
686
|
-
for (const [name, size] of Object.entries(offsets || fileSizes(dir))) {
|
|
687
|
-
state.set(name, {
|
|
688
|
-
offset: size,
|
|
689
|
-
partial: ""
|
|
690
|
-
});
|
|
691
|
-
decoders.set(name, new StringDecoder("utf8"));
|
|
692
|
-
}
|
|
693
|
-
function emit(records) {
|
|
694
|
-
for (const record of records) {
|
|
695
|
-
const matches = recordMatches(record, criteria);
|
|
696
|
-
if (follower) follower.push(record, matches);
|
|
697
|
-
else if (matches) onRecord(record);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
function drainRotated(name, entry) {
|
|
701
|
-
const path = rotatedLogPath(join(dir, name));
|
|
702
|
-
let size;
|
|
703
|
-
try {
|
|
704
|
-
size = statSync(path).size;
|
|
705
|
-
} catch {
|
|
706
|
-
return;
|
|
707
|
-
}
|
|
708
|
-
const decoder = decoders.get(name) ?? new StringDecoder("utf8");
|
|
709
|
-
emit(advanceTail(entry, readChunk(path, decoder, entry.offset, size) + decoder.end(), size).records);
|
|
710
|
-
}
|
|
711
|
-
function pollFile(name) {
|
|
712
|
-
const path = join(dir, name);
|
|
713
|
-
const size = statSync(path).size;
|
|
714
|
-
const entry = state.get(name);
|
|
715
|
-
if (entry && size < entry.offset) drainRotated(name, entry);
|
|
716
|
-
const { start, prev } = tailRead(entry, size);
|
|
717
|
-
if (prev !== entry) decoders.set(name, new StringDecoder("utf8"));
|
|
718
|
-
if (!decoders.has(name)) decoders.set(name, new StringDecoder("utf8"));
|
|
719
|
-
const next = advanceTail(prev, readChunk(path, decoders.get(name), start, size), size);
|
|
720
|
-
state.set(name, next.state);
|
|
721
|
-
emit(next.records);
|
|
722
|
-
}
|
|
723
|
-
function poll() {
|
|
724
|
-
for (const name of logFiles(dir)) try {
|
|
725
|
-
pollFile(name);
|
|
726
|
-
} catch {}
|
|
727
|
-
follower?.settle();
|
|
728
|
-
}
|
|
729
|
-
const timer = setInterval(poll, intervalMs);
|
|
730
|
-
return function stop() {
|
|
731
|
-
clearInterval(timer);
|
|
732
|
-
follower?.flush();
|
|
733
|
-
};
|
|
734
|
-
}
|
|
735
|
-
function readChunk(path, decoder, start, size) {
|
|
736
|
-
if (size <= start) return "";
|
|
737
|
-
const fd = openSync(path, "r");
|
|
738
|
-
try {
|
|
739
|
-
const buf = Buffer.allocUnsafe(size - start);
|
|
740
|
-
const read = readSync(fd, buf, 0, size - start, start);
|
|
741
|
-
return decoder.write(buf.subarray(0, read));
|
|
742
|
-
} finally {
|
|
743
|
-
closeSync(fd);
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
|
-
function tsOf(record) {
|
|
747
|
-
return typeof record?.ts === "number" && Number.isFinite(record.ts) ? record.ts : null;
|
|
748
|
-
}
|
|
749
|
-
function sortByTs(records) {
|
|
750
|
-
return records.toSorted((a, b) => {
|
|
751
|
-
const ta = tsOf(a);
|
|
752
|
-
const tb = tsOf(b);
|
|
753
|
-
if (ta === null && tb === null) return 0;
|
|
754
|
-
if (ta === null) return 1;
|
|
755
|
-
if (tb === null) return -1;
|
|
756
|
-
return ta - tb;
|
|
757
|
-
});
|
|
758
|
-
}
|
|
759
|
-
//#endregion
|
|
760
|
-
//#region state/settings-registry.ts
|
|
761
|
-
const ANDROID_COMPILER_CACHE_CHOICES = [
|
|
762
|
-
"auto",
|
|
763
|
-
"ccache",
|
|
764
|
-
"cas",
|
|
765
|
-
"none"
|
|
766
|
-
];
|
|
767
|
-
const ANDROID_PCH_CHOICES = [
|
|
768
|
-
"auto",
|
|
769
|
-
"on",
|
|
770
|
-
"off"
|
|
771
|
-
];
|
|
772
|
-
const TUNNEL_MODES = [
|
|
773
|
-
"auto",
|
|
774
|
-
"off",
|
|
775
|
-
"expo",
|
|
776
|
-
"cloudflared",
|
|
777
|
-
"ngrok"
|
|
778
|
-
];
|
|
779
|
-
const SETTING_SCOPES = [
|
|
780
|
-
"machine",
|
|
781
|
-
"workspace",
|
|
782
|
-
"repo",
|
|
783
|
-
"committed"
|
|
784
|
-
];
|
|
785
|
-
const REMOTE_DEVICE_BACKENDS = ["proxy", "eas"];
|
|
786
|
-
const IOS_SIMULATOR_APPS = [
|
|
787
|
-
"xcode",
|
|
788
|
-
"siniulator",
|
|
789
|
-
"stim-desktop"
|
|
790
|
-
];
|
|
791
|
-
const ANDROID_EMULATOR_APPS = ["emulator", "stim-desktop"];
|
|
792
|
-
const WEB_VIEWPORTS = ["desktop", "phone"];
|
|
793
|
-
const PROJECT = [
|
|
794
|
-
"workspace",
|
|
795
|
-
"repo",
|
|
796
|
-
"committed"
|
|
797
|
-
];
|
|
798
|
-
const EVERY = [
|
|
799
|
-
"machine",
|
|
800
|
-
"workspace",
|
|
801
|
-
"repo",
|
|
802
|
-
"committed"
|
|
803
|
-
];
|
|
804
|
-
const MACHINE = ["machine"];
|
|
805
|
-
const STRING = { kind: "string" };
|
|
806
|
-
const PATH = { kind: "path" };
|
|
807
|
-
const ABSOLUTE_PATH = {
|
|
808
|
-
kind: "path",
|
|
809
|
-
absolute: true
|
|
810
|
-
};
|
|
811
|
-
const RELATIVE_PATH = {
|
|
812
|
-
kind: "path",
|
|
813
|
-
relative: true
|
|
814
|
-
};
|
|
815
|
-
const BOOLEAN = { kind: "boolean" };
|
|
816
|
-
const OBJECT = { kind: "object" };
|
|
817
|
-
const REMOTE = {
|
|
818
|
-
kind: "choice",
|
|
819
|
-
choices: REMOTE_DEVICE_BACKENDS
|
|
820
|
-
};
|
|
821
|
-
const PARKED_MAX = {
|
|
822
|
-
kind: "number",
|
|
823
|
-
integer: true,
|
|
824
|
-
minimum: 0
|
|
825
|
-
};
|
|
826
|
-
const CAPACITY = {
|
|
827
|
-
kind: "number",
|
|
828
|
-
integer: true,
|
|
829
|
-
minimum: 0
|
|
830
|
-
};
|
|
831
|
-
const GIGABYTES = {
|
|
832
|
-
kind: "number",
|
|
833
|
-
minimum: 0
|
|
834
|
-
};
|
|
835
|
-
function optimization(key, description, type = BOOLEAN) {
|
|
836
|
-
return {
|
|
837
|
-
key: `optimizations.${key}`,
|
|
838
|
-
type,
|
|
839
|
-
scopes: EVERY,
|
|
840
|
-
description,
|
|
841
|
-
...type.kind === "boolean" ? { default: true } : {}
|
|
842
|
-
};
|
|
431
|
+
const RELATIVE_PATH = {
|
|
432
|
+
kind: "path",
|
|
433
|
+
relative: true
|
|
434
|
+
};
|
|
435
|
+
const BOOLEAN = { kind: "boolean" };
|
|
436
|
+
const OBJECT = { kind: "object" };
|
|
437
|
+
const REMOTE = {
|
|
438
|
+
kind: "choice",
|
|
439
|
+
choices: REMOTE_DEVICE_BACKENDS
|
|
440
|
+
};
|
|
441
|
+
const PARKED_MAX = {
|
|
442
|
+
kind: "number",
|
|
443
|
+
integer: true,
|
|
444
|
+
minimum: 0
|
|
445
|
+
};
|
|
446
|
+
const CAPACITY = {
|
|
447
|
+
kind: "number",
|
|
448
|
+
integer: true,
|
|
449
|
+
minimum: 0
|
|
450
|
+
};
|
|
451
|
+
const GIGABYTES = {
|
|
452
|
+
kind: "number",
|
|
453
|
+
minimum: 0
|
|
454
|
+
};
|
|
455
|
+
function optimization(key, description, type = BOOLEAN) {
|
|
456
|
+
return {
|
|
457
|
+
key: `optimizations.${key}`,
|
|
458
|
+
type,
|
|
459
|
+
scopes: EVERY,
|
|
460
|
+
description,
|
|
461
|
+
...type.kind === "boolean" ? { default: true } : {}
|
|
462
|
+
};
|
|
843
463
|
}
|
|
844
464
|
const SETTINGS = [
|
|
845
465
|
optimization("buildCache", "Read and write native build artifacts"),
|
|
@@ -878,13 +498,13 @@ const SETTINGS = [
|
|
|
878
498
|
{
|
|
879
499
|
key: "ios.deviceType",
|
|
880
500
|
type: STRING,
|
|
881
|
-
scopes:
|
|
501
|
+
scopes: EVERY,
|
|
882
502
|
description: "Simulator model for owned simulators"
|
|
883
503
|
},
|
|
884
504
|
{
|
|
885
505
|
key: "ios.runtime",
|
|
886
506
|
type: STRING,
|
|
887
|
-
scopes:
|
|
507
|
+
scopes: EVERY,
|
|
888
508
|
description: "iOS runtime owned simulators are created on"
|
|
889
509
|
},
|
|
890
510
|
{
|
|
@@ -935,7 +555,7 @@ const SETTINGS = [
|
|
|
935
555
|
{
|
|
936
556
|
key: "android.systemImage",
|
|
937
557
|
type: STRING,
|
|
938
|
-
scopes:
|
|
558
|
+
scopes: EVERY,
|
|
939
559
|
description: "SDK system image for owned AVDs"
|
|
940
560
|
},
|
|
941
561
|
{
|
|
@@ -1002,7 +622,7 @@ const SETTINGS = [
|
|
|
1002
622
|
},
|
|
1003
623
|
scopes: PROJECT,
|
|
1004
624
|
default: "auto",
|
|
1005
|
-
description: "How a remote device reaches Metro"
|
|
625
|
+
description: "How a remote device reaches Metro; tailscale is tailnet-only and requires explicit selection"
|
|
1006
626
|
},
|
|
1007
627
|
{
|
|
1008
628
|
key: "metro.ngrokUrl",
|
|
@@ -1016,6 +636,18 @@ const SETTINGS = [
|
|
|
1016
636
|
scopes: PROJECT,
|
|
1017
637
|
description: "Existing public Metro URL"
|
|
1018
638
|
},
|
|
639
|
+
{
|
|
640
|
+
key: "metro.port",
|
|
641
|
+
type: {
|
|
642
|
+
kind: "number",
|
|
643
|
+
integer: true,
|
|
644
|
+
minimum: 1024,
|
|
645
|
+
maximum: 65535
|
|
646
|
+
},
|
|
647
|
+
scopes: PROJECT,
|
|
648
|
+
env: "STIM_METRO_PORT",
|
|
649
|
+
description: "This workspace's Metro port, reserved instead of one Stim picks"
|
|
650
|
+
},
|
|
1019
651
|
{
|
|
1020
652
|
key: "metro.warmupUrl.ios",
|
|
1021
653
|
type: { kind: "bundle-url" },
|
|
@@ -1039,6 +671,51 @@ const SETTINGS = [
|
|
|
1039
671
|
default: 60,
|
|
1040
672
|
description: "Minutes without a bundle request, client log or Stim command before the dev server stops; 0 never"
|
|
1041
673
|
},
|
|
674
|
+
{
|
|
675
|
+
key: "devices.idleShutdownMinutes",
|
|
676
|
+
type: {
|
|
677
|
+
kind: "number",
|
|
678
|
+
integer: true,
|
|
679
|
+
minimum: 0
|
|
680
|
+
},
|
|
681
|
+
scopes: EVERY,
|
|
682
|
+
default: 0,
|
|
683
|
+
description: "Minutes an owned simulator or emulator stays idle before the workspace supervisor shuts it down; 0 never"
|
|
684
|
+
},
|
|
685
|
+
{
|
|
686
|
+
key: "macos.product",
|
|
687
|
+
type: {
|
|
688
|
+
kind: "string",
|
|
689
|
+
pattern: "^[A-Za-z0-9_-]+$",
|
|
690
|
+
patternHelp: "a Swift Package executable product name"
|
|
691
|
+
},
|
|
692
|
+
scopes: PROJECT,
|
|
693
|
+
description: "The Swift Package executable product stim macos builds in Debug."
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
key: "macos.infoPlist",
|
|
697
|
+
type: RELATIVE_PATH,
|
|
698
|
+
scopes: PROJECT,
|
|
699
|
+
description: "Development Info.plist relative to the Swift Package directory."
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
key: "macos.arguments",
|
|
703
|
+
type: { kind: "strings" },
|
|
704
|
+
scopes: PROJECT,
|
|
705
|
+
description: "Arguments passed directly to the owned macOS development executable."
|
|
706
|
+
},
|
|
707
|
+
{
|
|
708
|
+
key: "macos.assetCatalog",
|
|
709
|
+
type: RELATIVE_PATH,
|
|
710
|
+
scopes: PROJECT,
|
|
711
|
+
description: "Optional .xcassets directory relative to the Swift Package directory."
|
|
712
|
+
},
|
|
713
|
+
{
|
|
714
|
+
key: "macos.resources",
|
|
715
|
+
type: OBJECT,
|
|
716
|
+
scopes: PROJECT,
|
|
717
|
+
description: "Resource destinations under Contents/Resources mapped to sources relative to the Swift Package directory."
|
|
718
|
+
},
|
|
1042
719
|
{
|
|
1043
720
|
key: "web.url",
|
|
1044
721
|
type: {
|
|
@@ -1066,6 +743,14 @@ const SETTINGS = [
|
|
|
1066
743
|
default: "desktop",
|
|
1067
744
|
description: "Viewport of the owned Chrome page: desktop, or phone for a 390x844 touch screen"
|
|
1068
745
|
},
|
|
746
|
+
{
|
|
747
|
+
key: "recording.enabled",
|
|
748
|
+
type: BOOLEAN,
|
|
749
|
+
scopes: RECORDING,
|
|
750
|
+
default: true,
|
|
751
|
+
env: "STIM_RECORDING",
|
|
752
|
+
description: "Let stim-server record device screens for replay; false stops it and deletes the recordings"
|
|
753
|
+
},
|
|
1069
754
|
{
|
|
1070
755
|
key: "worktree.exclude",
|
|
1071
756
|
type: { kind: "strings" },
|
|
@@ -1190,6 +875,86 @@ const SETTINGS = [
|
|
|
1190
875
|
scopedHomeValue: 0,
|
|
1191
876
|
description: "Parked emulators kept for adoption; 0 turns parking off"
|
|
1192
877
|
},
|
|
878
|
+
{
|
|
879
|
+
key: "hosting.machines",
|
|
880
|
+
type: { kind: "strings" },
|
|
881
|
+
scopes: MACHINE,
|
|
882
|
+
description: "Tailscale names of the Macs that may host owned simulator sessions for this one, each optionally with :<port> of its tailscale serve route (default 7443); doctor --fix asks for separate device-host approval"
|
|
883
|
+
},
|
|
884
|
+
{
|
|
885
|
+
key: "hosting.agentDriver",
|
|
886
|
+
type: {
|
|
887
|
+
kind: "choice",
|
|
888
|
+
choices: HOSTED_AGENT_DRIVERS
|
|
889
|
+
},
|
|
890
|
+
scopes: MACHINE,
|
|
891
|
+
default: "none",
|
|
892
|
+
description: "Tool this Mac starts so a client's coding agent can drive the macOS apps it hosts for that client; none starts nothing"
|
|
893
|
+
},
|
|
894
|
+
{
|
|
895
|
+
key: "server.acceptClientBuilds",
|
|
896
|
+
type: { kind: "boolean" },
|
|
897
|
+
scopes: MACHINE,
|
|
898
|
+
default: false,
|
|
899
|
+
description: "Whether a Mac approved for builds or device hosting here may update this Mac's stim-server service to that Mac's own stim-server build; releases from npm need no setting"
|
|
900
|
+
},
|
|
901
|
+
{
|
|
902
|
+
key: "offload.machines",
|
|
903
|
+
type: { kind: "strings" },
|
|
904
|
+
scopes: MACHINE,
|
|
905
|
+
description: "Tailscale names of the Macs that may build for this one, each optionally with :<port> of its tailscale serve route (default 7443)"
|
|
906
|
+
},
|
|
907
|
+
{
|
|
908
|
+
key: "offload.machine",
|
|
909
|
+
type: {
|
|
910
|
+
kind: "string",
|
|
911
|
+
pattern: "^[A-Za-z0-9][A-Za-z0-9.-]*(?::(?!0+$)(?:[0-5]?[0-9]{1,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]))?$",
|
|
912
|
+
patternHelp: "auto, local, or a tailnet machine name"
|
|
913
|
+
},
|
|
914
|
+
scopes: MACHINE,
|
|
915
|
+
default: "auto",
|
|
916
|
+
env: "STIM_OFFLOAD_MACHINE",
|
|
917
|
+
description: "Build placement: auto follows offload.mode with local fallback; local always builds here; a name in offload.machines requires that machine without fallback. The ios, android and macos --build-machine flag overrides this setting and its environment override"
|
|
918
|
+
},
|
|
919
|
+
{
|
|
920
|
+
key: "offload.mode",
|
|
921
|
+
type: {
|
|
922
|
+
kind: "choice",
|
|
923
|
+
choices: OFFLOAD_MODES
|
|
924
|
+
},
|
|
925
|
+
scopes: MACHINE,
|
|
926
|
+
default: "auto",
|
|
927
|
+
env: "STIM_OFFLOAD_MODE",
|
|
928
|
+
description: "When offload.machine is auto, where iOS simulator Debug, Android emulator debug and macOS SwiftPM Debug builds run: auto builds here while this Mac has a free concurrency.maxBuilds slot and its load is under offload.maxLoadPerCore, and otherwise offloads to a less loaded machine in offload.machines; force offloads whenever a machine can take the build; off always builds here"
|
|
929
|
+
},
|
|
930
|
+
{
|
|
931
|
+
key: "offload.maxLoadPerCore",
|
|
932
|
+
type: {
|
|
933
|
+
kind: "number",
|
|
934
|
+
minimum: .1
|
|
935
|
+
},
|
|
936
|
+
scopes: MACHINE,
|
|
937
|
+
default: 2,
|
|
938
|
+
description: "Load per core (5-minute load average divided by the CPU count) at which a Mac counts as saturated: a build machine declines offloaded builds, and auto offload stops preferring this Mac"
|
|
939
|
+
},
|
|
940
|
+
{
|
|
941
|
+
key: "offload.workerRoot",
|
|
942
|
+
type: ABSOLUTE_PATH,
|
|
943
|
+
scopes: MACHINE,
|
|
944
|
+
description: "Directory where stim-server keeps the checkouts, dependencies and build state of Macs that build here; default $STIM_HOME/build-worker"
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
key: "offload.gradleDaemonIdleMinutes",
|
|
948
|
+
type: {
|
|
949
|
+
kind: "number",
|
|
950
|
+
integer: true,
|
|
951
|
+
minimum: 0,
|
|
952
|
+
maximum: 35791
|
|
953
|
+
},
|
|
954
|
+
scopes: MACHINE,
|
|
955
|
+
default: 30,
|
|
956
|
+
description: "Minutes the Gradle daemon of an offloaded Android build stays warm on this build machine after the build; 0 stops it when the build ends"
|
|
957
|
+
},
|
|
1193
958
|
{
|
|
1194
959
|
key: "caches.buildCache",
|
|
1195
960
|
type: ABSOLUTE_PATH,
|
|
@@ -1212,95 +977,998 @@ const SETTINGS = [
|
|
|
1212
977
|
description: "Absolute directory for large temporary copies"
|
|
1213
978
|
}
|
|
1214
979
|
];
|
|
1215
|
-
const BY_KEY = new Map(SETTINGS.map((setting) => [setting.key, setting]));
|
|
1216
|
-
function settingDefinition(key) {
|
|
1217
|
-
return BY_KEY.get(key) ?? null;
|
|
980
|
+
const BY_KEY = new Map(SETTINGS.map((setting) => [setting.key, setting]));
|
|
981
|
+
function settingDefinition(key) {
|
|
982
|
+
return BY_KEY.get(key) ?? null;
|
|
983
|
+
}
|
|
984
|
+
function isLayeredSetting(setting) {
|
|
985
|
+
return setting.scopes.some((scope) => scope !== "machine");
|
|
986
|
+
}
|
|
987
|
+
const SETTING_GROUPS = [...new Set(SETTINGS.filter(isLayeredSetting).flatMap((setting) => setting.key.split(".").slice(0, -1).map((_, index, parts) => parts.slice(0, index + 1).join("."))))];
|
|
988
|
+
function isPlainObject(value) {
|
|
989
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
990
|
+
}
|
|
991
|
+
function bundleUrlAccepts(value, key) {
|
|
992
|
+
if (typeof value !== "string" || !/^(https?:\/\/|\/(?!\/))/.test(value) || /[\s\\#]/.test(value)) return false;
|
|
993
|
+
try {
|
|
994
|
+
const url = new URL(value, "http://localhost");
|
|
995
|
+
return /\.bundle\/*$/.test(url.pathname) && url.searchParams.get("platform") === key.split(".").at(-1);
|
|
996
|
+
} catch {
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function expectedShape(type) {
|
|
1001
|
+
switch (type.kind) {
|
|
1002
|
+
case "boolean": return "true or false";
|
|
1003
|
+
case "string": return "a string";
|
|
1004
|
+
case "path": return "a string path";
|
|
1005
|
+
case "strings": return "an array of strings";
|
|
1006
|
+
case "number": return "a number";
|
|
1007
|
+
case "choice": return `one of: ${type.choices.join(", ")}`;
|
|
1008
|
+
case "object": return "an object";
|
|
1009
|
+
case "bundle-url": return "an HTTP(S) URL or /path ending in .bundle with a matching platform query and no fragment";
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
function acceptsShape(setting, value) {
|
|
1013
|
+
const { type } = setting;
|
|
1014
|
+
switch (type.kind) {
|
|
1015
|
+
case "boolean": return typeof value === "boolean";
|
|
1016
|
+
case "string":
|
|
1017
|
+
case "path": return typeof value === "string";
|
|
1018
|
+
case "strings": return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
1019
|
+
case "number": return typeof value === "number";
|
|
1020
|
+
case "choice": return typeof value === "string" && type.choices.includes(value);
|
|
1021
|
+
case "object": return isPlainObject(value);
|
|
1022
|
+
case "bundle-url": return bundleUrlAccepts(value, setting.key);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
function numberBounds(type) {
|
|
1026
|
+
const noun = type.integer ? "a whole number" : "a number";
|
|
1027
|
+
if (type.minimum !== void 0 && type.maximum !== void 0) return `${noun} from ${type.minimum} through ${type.maximum}`;
|
|
1028
|
+
if (type.minimum !== void 0) return `${noun}, ${type.minimum} or more`;
|
|
1029
|
+
return noun;
|
|
1030
|
+
}
|
|
1031
|
+
function settingValueError(setting, value) {
|
|
1032
|
+
const { type } = setting;
|
|
1033
|
+
if (!acceptsShape(setting, value)) return type.kind === "number" ? numberBounds(type) : expectedShape(type);
|
|
1034
|
+
if (type.kind === "number") {
|
|
1035
|
+
const number = value;
|
|
1036
|
+
if (!Number.isFinite(number) || type.integer && !Number.isSafeInteger(number) || type.minimum !== void 0 && number < type.minimum || type.maximum !== void 0 && number > type.maximum) return numberBounds(type);
|
|
1037
|
+
}
|
|
1038
|
+
if (type.kind === "path") {
|
|
1039
|
+
const path = value;
|
|
1040
|
+
if (type.absolute && !isAbsolute(path)) return "an absolute path";
|
|
1041
|
+
if (type.relative && (path.trim() === "" || isAbsolute(path))) return "a relative path inside the app directory";
|
|
1042
|
+
}
|
|
1043
|
+
if (type.kind === "string" && type.pattern && !new RegExp(type.pattern).test(value)) return type.patternHelp ?? `a string matching ${type.pattern}`;
|
|
1044
|
+
return null;
|
|
1045
|
+
}
|
|
1046
|
+
const SENSITIVE_REFERENCE = /^(env|file):\S/;
|
|
1047
|
+
function isSensitiveReference(value) {
|
|
1048
|
+
return typeof value === "string" && SENSITIVE_REFERENCE.test(value);
|
|
1049
|
+
}
|
|
1050
|
+
const JSON_ENCODED_KINDS = [
|
|
1051
|
+
"boolean",
|
|
1052
|
+
"number",
|
|
1053
|
+
"strings",
|
|
1054
|
+
"object"
|
|
1055
|
+
];
|
|
1056
|
+
/**
|
|
1057
|
+
* Coerces raw text (a CLI argument or an environment variable) through a setting's
|
|
1058
|
+
* registry type, the same way for both sources. Types whose values are not written
|
|
1059
|
+
* as bare text (boolean, number, strings, object) are read as JSON; a value that
|
|
1060
|
+
* fails to parse is left as the original string so `settingValueError` reports it. A boolean also reads `1` and `0`.
|
|
1061
|
+
*/
|
|
1062
|
+
function coerceSettingText(setting, raw) {
|
|
1063
|
+
if (!JSON_ENCODED_KINDS.includes(setting.type.kind)) return raw;
|
|
1064
|
+
if (setting.type.kind === "boolean" && (raw === "1" || raw === "0")) return raw === "1";
|
|
1065
|
+
try {
|
|
1066
|
+
return JSON.parse(raw);
|
|
1067
|
+
} catch {
|
|
1068
|
+
return raw;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
//#endregion
|
|
1072
|
+
//#region state/build-capacity.ts
|
|
1073
|
+
const NATIVE_PHASES = /* @__PURE__ */ new Set([
|
|
1074
|
+
"prebuild",
|
|
1075
|
+
"pods",
|
|
1076
|
+
"compile"
|
|
1077
|
+
]);
|
|
1078
|
+
const SLOT_PREFIX = "slot-";
|
|
1079
|
+
const SLOT_LABEL = "build slot";
|
|
1080
|
+
function buildSlotsDir() {
|
|
1081
|
+
return join(configDir(), "build-slots");
|
|
1082
|
+
}
|
|
1083
|
+
function buildSlotPath(index) {
|
|
1084
|
+
return join(buildSlotsDir(), `${SLOT_PREFIX}${index}`);
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Takes the first free one of `max` `concurrency.maxBuilds` slots, recording `details` and the slot `index`. Returns
|
|
1088
|
+
* null while every slot is held; throws the claim refusal when no slot is busy but one cannot be resolved.
|
|
1089
|
+
*/
|
|
1090
|
+
function tryAcquireBuildSlotClaim({ max, details }) {
|
|
1091
|
+
let refusal = null;
|
|
1092
|
+
let busy = false;
|
|
1093
|
+
for (let index = 0; index < max; index++) {
|
|
1094
|
+
const path = buildSlotPath(index);
|
|
1095
|
+
let attempt;
|
|
1096
|
+
try {
|
|
1097
|
+
attempt = tryAcquireClaim({
|
|
1098
|
+
root: path,
|
|
1099
|
+
mode: "exclusive",
|
|
1100
|
+
label: SLOT_LABEL,
|
|
1101
|
+
details: {
|
|
1102
|
+
...details,
|
|
1103
|
+
index
|
|
1104
|
+
}
|
|
1105
|
+
});
|
|
1106
|
+
} catch (err) {
|
|
1107
|
+
if (!isClaimRefusal(err)) throw err;
|
|
1108
|
+
refusal ??= err;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
if (attempt.pending) releaseClaim(attempt.pending);
|
|
1112
|
+
if (!attempt.acquired) {
|
|
1113
|
+
busy = true;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
return {
|
|
1117
|
+
claim: attempt.acquired,
|
|
1118
|
+
index,
|
|
1119
|
+
path
|
|
1120
|
+
};
|
|
1121
|
+
}
|
|
1122
|
+
if (refusal && !busy) throw refusal;
|
|
1123
|
+
return null;
|
|
1124
|
+
}
|
|
1125
|
+
/** Build slots held for a build machine's offloaded builds, which run under another Stim home. */
|
|
1126
|
+
function liveOffloadedSlots() {
|
|
1127
|
+
let names;
|
|
1128
|
+
try {
|
|
1129
|
+
names = readdirSync(buildSlotsDir());
|
|
1130
|
+
} catch {
|
|
1131
|
+
return 0;
|
|
1132
|
+
}
|
|
1133
|
+
let count = 0;
|
|
1134
|
+
for (const name of names) {
|
|
1135
|
+
if (!name.startsWith(SLOT_PREFIX)) continue;
|
|
1136
|
+
try {
|
|
1137
|
+
if (readClaimSet(join(buildSlotsDir(), name)).live.some((holder) => holder.details.offloaded === true)) count += 1;
|
|
1138
|
+
} catch {}
|
|
1139
|
+
}
|
|
1140
|
+
return count;
|
|
1141
|
+
}
|
|
1142
|
+
function maxLoadPerCore() {
|
|
1143
|
+
const value = loadConfig()?.offload?.maxLoadPerCore;
|
|
1144
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
1145
|
+
return settingDefinition("offload.maxLoadPerCore").default;
|
|
1146
|
+
}
|
|
1147
|
+
function liveNativeBuilds() {
|
|
1148
|
+
const dir = join(configDir(), "workspaces");
|
|
1149
|
+
let names;
|
|
1150
|
+
try {
|
|
1151
|
+
names = readdirSync(dir);
|
|
1152
|
+
} catch {
|
|
1153
|
+
return 0;
|
|
1154
|
+
}
|
|
1155
|
+
let count = 0;
|
|
1156
|
+
for (const name of names) {
|
|
1157
|
+
let active;
|
|
1158
|
+
try {
|
|
1159
|
+
active = JSON.parse(readFileSync(join(dir, name, "state.json"), "utf8")).activeBuild;
|
|
1160
|
+
} catch {
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
if (!active || typeof active !== "object") continue;
|
|
1164
|
+
const { phase, claim, placement } = active;
|
|
1165
|
+
if (typeof phase !== "string" || !NATIVE_PHASES.has(phase) || placement) continue;
|
|
1166
|
+
if (typeof claim?.root !== "string" || typeof claim.claimId !== "string") continue;
|
|
1167
|
+
try {
|
|
1168
|
+
if (readClaimSet(claim.root).live.some((holder) => holder.claimId === claim.claimId)) count += 1;
|
|
1169
|
+
} catch {}
|
|
1170
|
+
}
|
|
1171
|
+
return count;
|
|
1172
|
+
}
|
|
1173
|
+
function machineCapacity() {
|
|
1174
|
+
const cpus = Math.max(1, availableParallelism());
|
|
1175
|
+
return {
|
|
1176
|
+
cpus,
|
|
1177
|
+
loadPerCore: Math.round(loadavg()[1] / cpus * 10) / 10,
|
|
1178
|
+
builds: liveNativeBuilds() + liveOffloadedSlots(),
|
|
1179
|
+
maxBuilds: getConcurrencyLimits().maxBuilds,
|
|
1180
|
+
maxLoadPerCore: maxLoadPerCore()
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
/** Why a Mac with this capacity should not take another native build; null when it can. */
|
|
1184
|
+
function saturation(capacity) {
|
|
1185
|
+
if (capacity.maxBuilds > 0 && capacity.builds >= capacity.maxBuilds) return `all ${capacity.maxBuilds} build slots busy`;
|
|
1186
|
+
if (capacity.loadPerCore >= capacity.maxLoadPerCore) return `load at or above ${capacity.maxLoadPerCore}/core`;
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
//#endregion
|
|
1190
|
+
//#region state/build-machines.ts
|
|
1191
|
+
function parseCredential(value) {
|
|
1192
|
+
if (!isJsonObject(value)) return null;
|
|
1193
|
+
const { machine, nodeId, dnsName, deviceId, deviceToken, state, requestedAt } = value;
|
|
1194
|
+
if (![
|
|
1195
|
+
machine,
|
|
1196
|
+
nodeId,
|
|
1197
|
+
dnsName,
|
|
1198
|
+
deviceId,
|
|
1199
|
+
deviceToken,
|
|
1200
|
+
requestedAt
|
|
1201
|
+
].every((field) => typeof field === "string" && field)) return null;
|
|
1202
|
+
if (state !== "pending" && state !== "approved") return null;
|
|
1203
|
+
return {
|
|
1204
|
+
machine,
|
|
1205
|
+
nodeId,
|
|
1206
|
+
dnsName,
|
|
1207
|
+
deviceId,
|
|
1208
|
+
deviceToken,
|
|
1209
|
+
state,
|
|
1210
|
+
requestedAt
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function readBuildMachines() {
|
|
1214
|
+
try {
|
|
1215
|
+
const machines = readJsonFile(buildMachinesFile()).machines;
|
|
1216
|
+
return Array.isArray(machines) ? machines.flatMap((entry) => parseCredential(entry) ?? []) : [];
|
|
1217
|
+
} catch {
|
|
1218
|
+
return [];
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
//#endregion
|
|
1222
|
+
//#region state/ledgers.ts
|
|
1223
|
+
function ids(value) {
|
|
1224
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string" && entry !== "") : [];
|
|
1225
|
+
}
|
|
1226
|
+
function readCreatedDevices() {
|
|
1227
|
+
try {
|
|
1228
|
+
const parsed = JSON.parse(readStateFile(createdDevicesFile()));
|
|
1229
|
+
return {
|
|
1230
|
+
ios: new Set(ids(parsed?.ios)),
|
|
1231
|
+
android: new Set(ids(parsed?.android)),
|
|
1232
|
+
web: new Set(ids(parsed?.web))
|
|
1233
|
+
};
|
|
1234
|
+
} catch {
|
|
1235
|
+
return {
|
|
1236
|
+
ios: /* @__PURE__ */ new Set(),
|
|
1237
|
+
android: /* @__PURE__ */ new Set(),
|
|
1238
|
+
web: /* @__PURE__ */ new Set()
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
/** Returns the claim normalized to canonical paths, or null when it is not a valid claim for `id`. */
|
|
1243
|
+
function validEasSessionClaim(id, value) {
|
|
1244
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1245
|
+
const claim = value;
|
|
1246
|
+
if (claim.sessionId !== id) return null;
|
|
1247
|
+
if (typeof claim.name !== "string" || !claim.name.startsWith("stim-")) return null;
|
|
1248
|
+
if (claim.platform !== "ios" && claim.platform !== "android") return null;
|
|
1249
|
+
if (typeof claim.workspaceRoot !== "string" || !isAbsolute(claim.workspaceRoot)) return null;
|
|
1250
|
+
if (typeof claim.workspaceHome !== "string" || !isAbsolute(claim.workspaceHome)) return null;
|
|
1251
|
+
if (typeof claim.stateFile !== "string" || !isAbsolute(claim.stateFile)) return null;
|
|
1252
|
+
const workspaceRoot = resolve(claim.workspaceRoot);
|
|
1253
|
+
const workspaceHome = resolve(claim.workspaceHome);
|
|
1254
|
+
const stateFile = resolve(claim.stateFile);
|
|
1255
|
+
const stateRelative = relative(workspaceHome, stateFile);
|
|
1256
|
+
if (!stateRelative || stateRelative.startsWith("..") || isAbsolute(stateRelative)) return null;
|
|
1257
|
+
if (stateFile !== join(workspaceHome, "workspaces", workspaceName(workspaceRoot), "state.json")) return null;
|
|
1258
|
+
return {
|
|
1259
|
+
sessionId: id,
|
|
1260
|
+
name: claim.name,
|
|
1261
|
+
platform: claim.platform,
|
|
1262
|
+
workspaceRoot,
|
|
1263
|
+
workspaceHome,
|
|
1264
|
+
stateFile
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
function readEasSessionLedger(root = easMachineStateRoot()) {
|
|
1268
|
+
const file = easSessionLedgerFile(root);
|
|
1269
|
+
if (!existsSync(file)) return {
|
|
1270
|
+
claims: /* @__PURE__ */ new Map(),
|
|
1271
|
+
notice: null,
|
|
1272
|
+
safe: true
|
|
1273
|
+
};
|
|
1274
|
+
let parsed;
|
|
1275
|
+
try {
|
|
1276
|
+
parsed = JSON.parse(readFileSync(file, "utf-8"));
|
|
1277
|
+
} catch (error) {
|
|
1278
|
+
return {
|
|
1279
|
+
claims: /* @__PURE__ */ new Map(),
|
|
1280
|
+
notice: `EAS ownership ledger ${file} is unreadable: ${error.message}`,
|
|
1281
|
+
safe: false
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {
|
|
1285
|
+
claims: /* @__PURE__ */ new Map(),
|
|
1286
|
+
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
1287
|
+
safe: false
|
|
1288
|
+
};
|
|
1289
|
+
const ledger = parsed;
|
|
1290
|
+
if (ledger.version !== 1 || !ledger.claims || typeof ledger.claims !== "object" || Array.isArray(ledger.claims)) return {
|
|
1291
|
+
claims: /* @__PURE__ */ new Map(),
|
|
1292
|
+
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
1293
|
+
safe: false
|
|
1294
|
+
};
|
|
1295
|
+
const claims = /* @__PURE__ */ new Map();
|
|
1296
|
+
for (const [id, value] of Object.entries(ledger.claims)) {
|
|
1297
|
+
const claim = validEasSessionClaim(id, value);
|
|
1298
|
+
if (!claim) return {
|
|
1299
|
+
claims: /* @__PURE__ */ new Map(),
|
|
1300
|
+
notice: `EAS ownership ledger ${file} is malformed.`,
|
|
1301
|
+
safe: false
|
|
1302
|
+
};
|
|
1303
|
+
claims.set(id, claim);
|
|
1304
|
+
}
|
|
1305
|
+
return {
|
|
1306
|
+
claims,
|
|
1307
|
+
notice: null,
|
|
1308
|
+
safe: true
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
//#endregion
|
|
1312
|
+
//#region state/error-entries.ts
|
|
1313
|
+
const BUNDLE_LINE_WINDOW_MS = 1e3;
|
|
1314
|
+
const BUNDLE_RESPONSE_WINDOW_MS = 2e3;
|
|
1315
|
+
function isError(record) {
|
|
1316
|
+
return record.level === "error" || record.level === "fatal";
|
|
1317
|
+
}
|
|
1318
|
+
function isExpoLine(record) {
|
|
1319
|
+
return record.src === "metro" && record.raw === true && record.event === "expo_stdout";
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* The number of rows a log list shows for `records`, an error query's result in log order. Stim Desktop's
|
|
1323
|
+
* `LogEntryList` and the phone's `groupRecords` group the same way and replay the same vectors: in Expo's dev
|
|
1324
|
+
* server one failed bundle is a `Bundling failed` marker line, the error line and a failed bundle response,
|
|
1325
|
+
* which the apps list as one row, and every other record is its own row.
|
|
1326
|
+
*/
|
|
1327
|
+
function countErrorEntries(records) {
|
|
1328
|
+
const taken = /* @__PURE__ */ new Set();
|
|
1329
|
+
const groups = [];
|
|
1330
|
+
for (let i = 0; i < records.length; i += 1) {
|
|
1331
|
+
if (taken.has(i)) continue;
|
|
1332
|
+
const record = records[i];
|
|
1333
|
+
taken.add(i);
|
|
1334
|
+
const group = {
|
|
1335
|
+
indexes: [i],
|
|
1336
|
+
lead: i
|
|
1337
|
+
};
|
|
1338
|
+
groups.push(group);
|
|
1339
|
+
if (!(isExpoLine(record) && isError(record) && record.marker === true)) continue;
|
|
1340
|
+
const j = records.findIndex((next, at) => at > i && !taken.has(at) && isExpoLine(next));
|
|
1341
|
+
const next = j < 0 ? null : records[j];
|
|
1342
|
+
if (next && isError(next) && next.marker !== true && (next.ts ?? 0) - (record.ts ?? 0) <= BUNDLE_LINE_WINDOW_MS) {
|
|
1343
|
+
taken.add(j);
|
|
1344
|
+
group.indexes.push(j);
|
|
1345
|
+
group.lead = j;
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
const failures = groups.filter((g) => {
|
|
1349
|
+
const first = records[g.indexes[0]];
|
|
1350
|
+
return isExpoLine(first) && first.marker === true && isError(first);
|
|
1351
|
+
});
|
|
1352
|
+
const answered = /* @__PURE__ */ new Set();
|
|
1353
|
+
let merged = 0;
|
|
1354
|
+
for (const response of groups.filter((g) => records[g.lead].event === "bundle_response_failed")) {
|
|
1355
|
+
const { ts, platform } = records[response.lead];
|
|
1356
|
+
const distance = (g) => Math.abs((records[g.indexes[0]].ts ?? 0) - (ts ?? 0));
|
|
1357
|
+
const target = failures.filter((g) => !answered.has(g) && distance(g) <= BUNDLE_RESPONSE_WINDOW_MS && (typeof platform !== "string" || String(records[g.indexes[0]].msg ?? "").toLowerCase().startsWith(`${platform.toLowerCase()} `))).toSorted((a, b) => distance(a) - distance(b))[0];
|
|
1358
|
+
if (!target) continue;
|
|
1359
|
+
answered.add(target);
|
|
1360
|
+
merged += 1;
|
|
1361
|
+
}
|
|
1362
|
+
return groups.length - merged;
|
|
1363
|
+
}
|
|
1364
|
+
//#endregion
|
|
1365
|
+
//#region state/ndjson.ts
|
|
1366
|
+
const LEVELS = [
|
|
1367
|
+
"debug",
|
|
1368
|
+
"info",
|
|
1369
|
+
"warn",
|
|
1370
|
+
"error",
|
|
1371
|
+
"fatal"
|
|
1372
|
+
];
|
|
1373
|
+
const SOURCES = [
|
|
1374
|
+
"metro",
|
|
1375
|
+
"client",
|
|
1376
|
+
"device",
|
|
1377
|
+
"build",
|
|
1378
|
+
"agent"
|
|
1379
|
+
];
|
|
1380
|
+
function levelRank(level) {
|
|
1381
|
+
const i = LEVELS.indexOf(level);
|
|
1382
|
+
return i < 0 ? 0 : i;
|
|
1383
|
+
}
|
|
1384
|
+
function parseNdjsonLine(line) {
|
|
1385
|
+
if (typeof line !== "string") return null;
|
|
1386
|
+
const trimmed = line.trim();
|
|
1387
|
+
if (!trimmed) return null;
|
|
1388
|
+
let value;
|
|
1389
|
+
try {
|
|
1390
|
+
value = JSON.parse(trimmed);
|
|
1391
|
+
} catch {
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1395
|
+
return value;
|
|
1396
|
+
}
|
|
1397
|
+
function parseNdjsonText(text) {
|
|
1398
|
+
if (typeof text !== "string" || text === "") return [];
|
|
1399
|
+
const out = [];
|
|
1400
|
+
const lines = text.split("\n");
|
|
1401
|
+
lines.pop();
|
|
1402
|
+
for (const line of lines) {
|
|
1403
|
+
const record = parseNdjsonLine(line);
|
|
1404
|
+
if (record) out.push(record);
|
|
1405
|
+
}
|
|
1406
|
+
return out;
|
|
1407
|
+
}
|
|
1408
|
+
function readNdjsonGenerations(file) {
|
|
1409
|
+
const out = [];
|
|
1410
|
+
for (const path of [rotatedLogPath(file), file]) {
|
|
1411
|
+
let text;
|
|
1412
|
+
try {
|
|
1413
|
+
text = readFileSync(path, "utf-8");
|
|
1414
|
+
} catch {
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
for (const record of parseNdjsonText(text)) out.push(record);
|
|
1418
|
+
}
|
|
1419
|
+
return out;
|
|
1420
|
+
}
|
|
1421
|
+
//#endregion
|
|
1422
|
+
//#region state/logs-query.ts
|
|
1423
|
+
const SINCE_UNITS = {
|
|
1424
|
+
s: 1e3,
|
|
1425
|
+
m: 6e4,
|
|
1426
|
+
h: 36e5
|
|
1427
|
+
};
|
|
1428
|
+
const SINCE_FORMS = "30s, 5m, 2h";
|
|
1429
|
+
function parseSince(text) {
|
|
1430
|
+
if (typeof text !== "string") return { error: `Invalid --since value ${JSON.stringify(text)}. Use a count and a unit, e.g. ${SINCE_FORMS}.` };
|
|
1431
|
+
const m = /^(\d+)\s*([smh])$/i.exec(text.trim());
|
|
1432
|
+
if (!m) return { error: `Invalid --since value "${text}". Use a count and a unit, e.g. ${SINCE_FORMS}.` };
|
|
1433
|
+
const unit = (m[2] ?? "").toLowerCase();
|
|
1434
|
+
return { ms: parseInt(m[1] ?? "", 10) * SINCE_UNITS[unit] };
|
|
1435
|
+
}
|
|
1436
|
+
function compileGrep(pattern) {
|
|
1437
|
+
if (pattern instanceof RegExp) return { re: pattern };
|
|
1438
|
+
try {
|
|
1439
|
+
return { re: new RegExp(String(pattern)) };
|
|
1440
|
+
} catch (err) {
|
|
1441
|
+
return { error: `Invalid --grep pattern "${pattern}": ${err.message}` };
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
const ERROR_SOURCES = [
|
|
1445
|
+
"metro",
|
|
1446
|
+
"client",
|
|
1447
|
+
"build"
|
|
1448
|
+
];
|
|
1449
|
+
function markerWindow(records) {
|
|
1450
|
+
let launchTs = null;
|
|
1451
|
+
let bundleTs = null;
|
|
1452
|
+
let pageLoadTs = null;
|
|
1453
|
+
for (const r of records) {
|
|
1454
|
+
if (r?.marker !== true) continue;
|
|
1455
|
+
const ts = tsOf(r);
|
|
1456
|
+
if (ts === null) continue;
|
|
1457
|
+
if (r.src === "metro") {
|
|
1458
|
+
if (bundleTs === null || ts > bundleTs) bundleTs = ts;
|
|
1459
|
+
} else if (isWebPageRecord(r)) {
|
|
1460
|
+
if (pageLoadTs === null || ts > pageLoadTs) pageLoadTs = ts;
|
|
1461
|
+
} else if (launchTs === null || ts > launchTs) launchTs = ts;
|
|
1462
|
+
}
|
|
1463
|
+
return {
|
|
1464
|
+
launchTs,
|
|
1465
|
+
bundleTs,
|
|
1466
|
+
pageLoadTs
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function isWebPageRecord(record) {
|
|
1470
|
+
return record.platform === "web" && record.src !== "metro";
|
|
1471
|
+
}
|
|
1472
|
+
function isSharedRecord(record) {
|
|
1473
|
+
return record.slot === void 0 && (record.src === "metro" || record.src === "client") && !isWebPageRecord(record);
|
|
1474
|
+
}
|
|
1475
|
+
function inSlot(record, slot) {
|
|
1476
|
+
return isSharedRecord(record) || (record.slot ?? "default") === slot;
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* The platform a bundle marker speaks for, lowercased: its `platform` field, or the tag @expo/cli prints before
|
|
1480
|
+
* "Bundled" ("iOS", "Android", "Web", "RSC(iOS)" as ios, or an environment tag such as "DOM"). Null when unknown.
|
|
1481
|
+
*/
|
|
1482
|
+
function bundleMarkerPlatform(record) {
|
|
1483
|
+
if (typeof record.platform === "string") return record.platform;
|
|
1484
|
+
const tag = /^\s*(\S+)\s+Bundl/.exec(String(record.msg ?? ""))?.[1];
|
|
1485
|
+
if (!tag) return null;
|
|
1486
|
+
return (/^RSC\((.+)\)$/.exec(tag)?.[1] ?? tag).toLowerCase();
|
|
1487
|
+
}
|
|
1488
|
+
function isAppDeviceRecord(record) {
|
|
1489
|
+
return record.src === "device" && (record.event === "native_crash" || record.platform === "web");
|
|
1490
|
+
}
|
|
1491
|
+
function recordMatches(record, criteria = {}) {
|
|
1492
|
+
if (!record) return false;
|
|
1493
|
+
if (criteria.slot !== void 0 && !inSlot(record, criteria.slot)) return false;
|
|
1494
|
+
const { sources, minLevel, grep, sinceTs, errorsOnly, markerTs, bundleMarkerTs } = criteria;
|
|
1495
|
+
if (sources && sources.length > 0 && !sources.includes(record.src) && !(criteria.includeAppDeviceErrors && isAppDeviceRecord(record))) return false;
|
|
1496
|
+
if (minLevel && levelRank(record.level) < levelRank(minLevel)) return false;
|
|
1497
|
+
if (errorsOnly) {
|
|
1498
|
+
if (record.level !== "error" && record.level !== "fatal") return false;
|
|
1499
|
+
if (typeof markerTs === "number") {
|
|
1500
|
+
const ts = tsOf(record);
|
|
1501
|
+
if (ts === null || ts <= markerTs) return false;
|
|
1502
|
+
}
|
|
1503
|
+
if (typeof bundleMarkerTs === "number" && record.src === "metro") {
|
|
1504
|
+
const ts = tsOf(record);
|
|
1505
|
+
if (ts === null || ts < bundleMarkerTs) return false;
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
if (typeof sinceTs === "number") {
|
|
1509
|
+
const ts = tsOf(record);
|
|
1510
|
+
if (ts === null || ts < sinceTs) return false;
|
|
1511
|
+
}
|
|
1512
|
+
if (grep) {
|
|
1513
|
+
const re = grep instanceof RegExp ? grep : compileGrep(grep).re;
|
|
1514
|
+
if (!re || String(record.msg ?? "").search(re) === -1) return false;
|
|
1515
|
+
}
|
|
1516
|
+
return true;
|
|
1517
|
+
}
|
|
1518
|
+
function buildCriteria({ slot, sources, minLevel, since, grep, errorsOnly, markerTs, bundleMarkerTs, now } = {}) {
|
|
1519
|
+
const criteria = {
|
|
1520
|
+
...slot === void 0 ? {} : { slot },
|
|
1521
|
+
errorsOnly: Boolean(errorsOnly),
|
|
1522
|
+
includeAppDeviceErrors: Boolean(errorsOnly && !sources?.length)
|
|
1523
|
+
};
|
|
1524
|
+
if (sources && sources.length > 0) criteria.sources = sources;
|
|
1525
|
+
else if (criteria.errorsOnly) criteria.sources = ERROR_SOURCES;
|
|
1526
|
+
if (minLevel) criteria.minLevel = minLevel;
|
|
1527
|
+
if (typeof markerTs === "number") criteria.markerTs = markerTs;
|
|
1528
|
+
if (typeof bundleMarkerTs === "number") criteria.bundleMarkerTs = bundleMarkerTs;
|
|
1529
|
+
if (since !== void 0 && since !== null && since !== "") {
|
|
1530
|
+
const parsed = parseSince(since);
|
|
1531
|
+
if (parsed.error) throw new Error(parsed.error);
|
|
1532
|
+
criteria.sinceTs = (typeof now === "number" ? now : Date.now()) - parsed.ms;
|
|
1533
|
+
}
|
|
1534
|
+
if (grep !== void 0 && grep !== null && grep !== "") {
|
|
1535
|
+
const compiled = compileGrep(grep);
|
|
1536
|
+
if (compiled.error) throw new Error(compiled.error);
|
|
1537
|
+
criteria.grep = compiled.re;
|
|
1538
|
+
}
|
|
1539
|
+
return criteria;
|
|
1540
|
+
}
|
|
1541
|
+
function logFiles(dir) {
|
|
1542
|
+
let entries;
|
|
1543
|
+
try {
|
|
1544
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1545
|
+
} catch {
|
|
1546
|
+
return [];
|
|
1547
|
+
}
|
|
1548
|
+
return entries.filter((e) => e.isFile() && e.name.endsWith(".ndjson")).map((e) => e.name).toSorted();
|
|
1549
|
+
}
|
|
1550
|
+
function fileSizes(dir) {
|
|
1551
|
+
const sizes = {};
|
|
1552
|
+
for (const name of logFiles(dir)) try {
|
|
1553
|
+
sizes[name] = statSync(join(dir, name)).size;
|
|
1554
|
+
} catch {}
|
|
1555
|
+
return sizes;
|
|
1556
|
+
}
|
|
1557
|
+
function readLogRecords(dir, include = () => true) {
|
|
1558
|
+
const all = [];
|
|
1559
|
+
for (const name of logFiles(dir).filter(include)) for (const record of readNdjsonGenerations(join(dir, name))) all.push(record);
|
|
1560
|
+
return sortByTs(all);
|
|
1561
|
+
}
|
|
1562
|
+
function isExpoErrorContext(record, event) {
|
|
1563
|
+
if (record.src !== "metro" || record.raw !== true || record.event !== event || typeof record.msg !== "string") return false;
|
|
1564
|
+
return /^Code: \S/.test(record.msg) || /^\s*>?\s*\d+\s*\|/.test(record.msg) || /^\s*\|\s*\^/.test(record.msg) || /^Call Stack$/.test(record.msg) || /^\s+.+\([^()]+:\d+:\d+\)\s*$/.test(record.msg) || /^\s+at\s+\S+:\d+:\d+\s*$/.test(record.msg);
|
|
1565
|
+
}
|
|
1566
|
+
function isExpoError(record) {
|
|
1567
|
+
return record.src === "metro" && record.raw === true && (record.level === "error" || record.level === "fatal") && typeof record.msg === "string";
|
|
1568
|
+
}
|
|
1569
|
+
function isSameExpoStream(record, error) {
|
|
1570
|
+
return record.src === "metro" && record.raw === true && record.event === error.event;
|
|
1571
|
+
}
|
|
1572
|
+
function withExpoContext(record, context, into) {
|
|
1573
|
+
if (context.length === 0) return record;
|
|
1574
|
+
return into === "msg" ? {
|
|
1575
|
+
...record,
|
|
1576
|
+
msg: [record.msg, ...context].join("\n")
|
|
1577
|
+
} : {
|
|
1578
|
+
...record,
|
|
1579
|
+
context
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
/**
|
|
1583
|
+
* Adds the code frame and stack lines Expo printed after each Expo error record, either joined into
|
|
1584
|
+
* `msg` or as a `context` string array.
|
|
1585
|
+
*/
|
|
1586
|
+
function attachExpoErrorContext(all, matched, into = "msg") {
|
|
1587
|
+
return matched.map((record) => {
|
|
1588
|
+
if (!isExpoError(record)) return record;
|
|
1589
|
+
const index = all.findIndex((entry) => entry.ts === record.ts && entry.src === record.src && entry.msg === record.msg && entry.event === record.event);
|
|
1590
|
+
if (index === -1) return record;
|
|
1591
|
+
const context = [];
|
|
1592
|
+
for (let i = index + 1; i < all.length; i += 1) {
|
|
1593
|
+
const next = all[i];
|
|
1594
|
+
if (!isSameExpoStream(next, record)) continue;
|
|
1595
|
+
if (!isExpoErrorContext(next, record.event)) break;
|
|
1596
|
+
context.push(next.msg);
|
|
1597
|
+
}
|
|
1598
|
+
return withExpoContext(record, context, into);
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
function expoContextFollower(onRecord, into) {
|
|
1602
|
+
let pending = null;
|
|
1603
|
+
let grew = false;
|
|
1604
|
+
function flush() {
|
|
1605
|
+
if (!pending) return;
|
|
1606
|
+
const { record, context, queued } = pending;
|
|
1607
|
+
pending = null;
|
|
1608
|
+
onRecord(withExpoContext(record, context, into));
|
|
1609
|
+
for (const next of queued) onRecord(next);
|
|
1610
|
+
}
|
|
1611
|
+
function push(record, matches) {
|
|
1612
|
+
if (pending && isSameExpoStream(record, pending.record)) {
|
|
1613
|
+
if (!isExpoErrorContext(record, pending.record.event)) flush();
|
|
1614
|
+
else {
|
|
1615
|
+
pending.context.push(record.msg);
|
|
1616
|
+
if (matches) pending.queued.push(record);
|
|
1617
|
+
grew = true;
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
if (pending) {
|
|
1622
|
+
if (matches) pending.queued.push(record);
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
if (matches && isExpoError(record)) {
|
|
1626
|
+
pending = {
|
|
1627
|
+
record,
|
|
1628
|
+
context: [],
|
|
1629
|
+
queued: []
|
|
1630
|
+
};
|
|
1631
|
+
grew = true;
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
if (matches) onRecord(record);
|
|
1635
|
+
}
|
|
1636
|
+
function settle() {
|
|
1637
|
+
if (!grew) flush();
|
|
1638
|
+
grew = false;
|
|
1639
|
+
}
|
|
1640
|
+
return {
|
|
1641
|
+
push,
|
|
1642
|
+
settle,
|
|
1643
|
+
flush
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
function includeBareErrorContext(all, matched, launchTs, sinceTs) {
|
|
1647
|
+
if (!matched.some((record) => record.src === "client" && (record.level === "error" || record.level === "fatal"))) return matched;
|
|
1648
|
+
const renderedContext = [];
|
|
1649
|
+
for (const record of all) {
|
|
1650
|
+
if (record.src !== "client" || record.event !== "client_symbolication") continue;
|
|
1651
|
+
const ts = tsOf(record);
|
|
1652
|
+
if (launchTs !== null && (ts === null || ts <= launchTs)) continue;
|
|
1653
|
+
if (sinceTs !== void 0 && (ts === null || ts < sinceTs)) continue;
|
|
1654
|
+
renderedContext.push({
|
|
1655
|
+
...record,
|
|
1656
|
+
msg: `Metro symbolication context (not correlated to a specific client error)\n${String(record.msg ?? "")}`,
|
|
1657
|
+
errorContext: true
|
|
1658
|
+
});
|
|
1659
|
+
}
|
|
1660
|
+
return sortByTs([...matched, ...renderedContext]);
|
|
1661
|
+
}
|
|
1662
|
+
function nativePlatform(record) {
|
|
1663
|
+
const platform = record.src === "metro" && record.marker === true ? bundleMarkerPlatform(record) : record.platform;
|
|
1664
|
+
return platform === "ios" || platform === "android" ? platform : null;
|
|
1665
|
+
}
|
|
1666
|
+
function newestByPlatform(records, platformOf) {
|
|
1667
|
+
const newest = /* @__PURE__ */ new Map();
|
|
1668
|
+
for (const record of records) {
|
|
1669
|
+
const ts = tsOf(record);
|
|
1670
|
+
if (ts === null) continue;
|
|
1671
|
+
const platform = platformOf(record);
|
|
1672
|
+
if (ts > (newest.get(platform) ?? -Infinity)) newest.set(platform, ts);
|
|
1673
|
+
}
|
|
1674
|
+
return newest;
|
|
1675
|
+
}
|
|
1676
|
+
function newestWhere(newest, include) {
|
|
1677
|
+
let ts;
|
|
1678
|
+
for (const [platform, at] of newest) if (include(platform) && (ts === void 0 || at > ts)) ts = at;
|
|
1679
|
+
return ts;
|
|
1680
|
+
}
|
|
1681
|
+
function isLaunchMarker(record) {
|
|
1682
|
+
return record.marker === true && record.src !== "metro" && !isWebPageRecord(record);
|
|
1683
|
+
}
|
|
1684
|
+
function launchMarkersBySlot(records) {
|
|
1685
|
+
const markers = /* @__PURE__ */ new Map();
|
|
1686
|
+
for (const record of records) {
|
|
1687
|
+
if (record.marker !== true || record.src === "metro" || isWebPageRecord(record)) continue;
|
|
1688
|
+
const ts = tsOf(record);
|
|
1689
|
+
const slot = record.slot ?? "default";
|
|
1690
|
+
if (ts !== null && ts > (markers.get(slot) ?? -Infinity)) markers.set(slot, ts);
|
|
1691
|
+
}
|
|
1692
|
+
return markers;
|
|
1693
|
+
}
|
|
1694
|
+
function queryLogs({ slot, dir, records, sources, minLevel, since, grep, tail, errorsOnly, errorContext, now } = {}) {
|
|
1695
|
+
const source = records ?? readLogRecords(dir);
|
|
1696
|
+
const slotLaunched = slot !== void 0 && source.some((record) => isLaunchMarker(record) && (record.slot ?? "default") === slot);
|
|
1697
|
+
const all = source.filter((record) => slot === void 0 || (record.slot ?? "default") === slot || slotLaunched && isSharedRecord(record));
|
|
1698
|
+
if (all.length === 0) return [];
|
|
1699
|
+
const { launchTs, bundleTs, pageLoadTs } = errorsOnly ? markerWindow(all) : {
|
|
1700
|
+
launchTs: null,
|
|
1701
|
+
bundleTs: null,
|
|
1702
|
+
pageLoadTs: null
|
|
1703
|
+
};
|
|
1704
|
+
const criteria = buildCriteria({
|
|
1705
|
+
slot,
|
|
1706
|
+
sources,
|
|
1707
|
+
minLevel,
|
|
1708
|
+
since,
|
|
1709
|
+
grep,
|
|
1710
|
+
errorsOnly,
|
|
1711
|
+
now,
|
|
1712
|
+
markerTs: launchTs === null ? void 0 : launchTs,
|
|
1713
|
+
bundleMarkerTs: bundleTs === null ? void 0 : bundleTs
|
|
1714
|
+
});
|
|
1715
|
+
const slotMarkers = launchMarkersBySlot(all);
|
|
1716
|
+
const markers = all.filter((record) => record.marker === true);
|
|
1717
|
+
const launches = newestByPlatform(markers.filter((record) => record.src !== "metro" && !isWebPageRecord(record)), (record) => typeof record.platform === "string" ? record.platform : null);
|
|
1718
|
+
const bundles = newestByPlatform(markers.filter((record) => record.src === "metro"), bundleMarkerPlatform);
|
|
1719
|
+
const slotPlatforms = slot === void 0 ? [] : [...launches.keys()].filter((platform) => platform !== null);
|
|
1720
|
+
const windowStart = (record) => {
|
|
1721
|
+
if (pageLoadTs !== null && isWebPageRecord(record)) return pageLoadTs - 1;
|
|
1722
|
+
if (record.src !== "metro" && record.src !== "client") return slotMarkers.get(record.slot ?? "default");
|
|
1723
|
+
const platform = nativePlatform(record);
|
|
1724
|
+
return (platform === null ? void 0 : launches.get(platform)) ?? criteria.markerTs;
|
|
1725
|
+
};
|
|
1726
|
+
const otherPlatform = (record) => {
|
|
1727
|
+
const platform = nativePlatform(record);
|
|
1728
|
+
return slotPlatforms.length > 0 && isSharedRecord(record) && platform !== null && !slotPlatforms.includes(platform);
|
|
1729
|
+
};
|
|
1730
|
+
const bundleWindowStart = (record) => {
|
|
1731
|
+
const platform = nativePlatform(record);
|
|
1732
|
+
return newestWhere(bundles, (marker) => marker === null || (platform ? marker === platform : slotPlatforms.length === 0 || slotPlatforms.includes(marker)));
|
|
1733
|
+
};
|
|
1734
|
+
let matched = all.filter((record) => !otherPlatform(record) && recordMatches(record, {
|
|
1735
|
+
...criteria,
|
|
1736
|
+
markerTs: windowStart(record),
|
|
1737
|
+
bundleMarkerTs: bundleWindowStart(record)
|
|
1738
|
+
}));
|
|
1739
|
+
if (typeof tail === "number" && tail >= 0 && matched.length > tail) matched = matched.slice(matched.length - tail);
|
|
1740
|
+
return errorContext ? includeBareErrorContext(all, attachExpoErrorContext(all, matched), launchTs, criteria.sinceTs) : matched;
|
|
1741
|
+
}
|
|
1742
|
+
function tailRead(prev, size) {
|
|
1743
|
+
const state = prev && typeof prev.offset === "number" ? prev : {
|
|
1744
|
+
offset: 0,
|
|
1745
|
+
partial: ""
|
|
1746
|
+
};
|
|
1747
|
+
if (size < state.offset) return {
|
|
1748
|
+
start: 0,
|
|
1749
|
+
prev: {
|
|
1750
|
+
offset: 0,
|
|
1751
|
+
partial: ""
|
|
1752
|
+
}
|
|
1753
|
+
};
|
|
1754
|
+
return {
|
|
1755
|
+
start: state.offset,
|
|
1756
|
+
prev: state
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
function advanceTail(prev, chunk, size) {
|
|
1760
|
+
const lines = ((prev && typeof prev.partial === "string" ? prev.partial : "") + (chunk || "")).split("\n");
|
|
1761
|
+
const partial = lines.pop();
|
|
1762
|
+
const records = [];
|
|
1763
|
+
for (const line of lines) {
|
|
1764
|
+
const record = parseNdjsonLine(line);
|
|
1765
|
+
if (record) records.push(record);
|
|
1766
|
+
}
|
|
1767
|
+
return {
|
|
1768
|
+
state: {
|
|
1769
|
+
offset: size,
|
|
1770
|
+
partial
|
|
1771
|
+
},
|
|
1772
|
+
records
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
function followLogs({ dir, onRecord, criteria = {}, intervalMs = 500, offsets = null, errorContext }) {
|
|
1776
|
+
const follower = errorContext ? expoContextFollower(onRecord, errorContext) : null;
|
|
1777
|
+
const state = /* @__PURE__ */ new Map();
|
|
1778
|
+
const decoders = /* @__PURE__ */ new Map();
|
|
1779
|
+
for (const [name, size] of Object.entries(offsets || fileSizes(dir))) {
|
|
1780
|
+
state.set(name, {
|
|
1781
|
+
offset: size,
|
|
1782
|
+
partial: ""
|
|
1783
|
+
});
|
|
1784
|
+
decoders.set(name, new StringDecoder("utf8"));
|
|
1785
|
+
}
|
|
1786
|
+
function emit(records) {
|
|
1787
|
+
for (const record of records) {
|
|
1788
|
+
const matches = recordMatches(record, criteria);
|
|
1789
|
+
if (follower) follower.push(record, matches);
|
|
1790
|
+
else if (matches) onRecord(record);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
function drainRotated(name, entry) {
|
|
1794
|
+
const path = rotatedLogPath(join(dir, name));
|
|
1795
|
+
let size;
|
|
1796
|
+
try {
|
|
1797
|
+
size = statSync(path).size;
|
|
1798
|
+
} catch {
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
const decoder = decoders.get(name) ?? new StringDecoder("utf8");
|
|
1802
|
+
emit(advanceTail(entry, readChunk(path, decoder, entry.offset, size) + decoder.end(), size).records);
|
|
1803
|
+
}
|
|
1804
|
+
function pollFile(name) {
|
|
1805
|
+
const path = join(dir, name);
|
|
1806
|
+
const size = statSync(path).size;
|
|
1807
|
+
const entry = state.get(name);
|
|
1808
|
+
if (entry && size < entry.offset) drainRotated(name, entry);
|
|
1809
|
+
const { start, prev } = tailRead(entry, size);
|
|
1810
|
+
if (prev !== entry) decoders.set(name, new StringDecoder("utf8"));
|
|
1811
|
+
if (!decoders.has(name)) decoders.set(name, new StringDecoder("utf8"));
|
|
1812
|
+
const next = advanceTail(prev, readChunk(path, decoders.get(name), start, size), size);
|
|
1813
|
+
state.set(name, next.state);
|
|
1814
|
+
emit(next.records);
|
|
1815
|
+
}
|
|
1816
|
+
function poll() {
|
|
1817
|
+
for (const name of logFiles(dir)) try {
|
|
1818
|
+
pollFile(name);
|
|
1819
|
+
} catch {}
|
|
1820
|
+
follower?.settle();
|
|
1821
|
+
}
|
|
1822
|
+
const timer = setInterval(poll, intervalMs);
|
|
1823
|
+
return function stop() {
|
|
1824
|
+
clearInterval(timer);
|
|
1825
|
+
follower?.flush();
|
|
1826
|
+
};
|
|
1827
|
+
}
|
|
1828
|
+
function readChunk(path, decoder, start, size) {
|
|
1829
|
+
if (size <= start) return "";
|
|
1830
|
+
const fd = openSync(path, "r");
|
|
1831
|
+
try {
|
|
1832
|
+
const buf = Buffer.allocUnsafe(size - start);
|
|
1833
|
+
const read = readSync(fd, buf, 0, size - start, start);
|
|
1834
|
+
return decoder.write(buf.subarray(0, read));
|
|
1835
|
+
} finally {
|
|
1836
|
+
closeSync(fd);
|
|
1837
|
+
}
|
|
1838
|
+
}
|
|
1839
|
+
function tsOf(record) {
|
|
1840
|
+
return typeof record?.ts === "number" && Number.isFinite(record.ts) ? record.ts : null;
|
|
1841
|
+
}
|
|
1842
|
+
function sortByTs(records) {
|
|
1843
|
+
return records.toSorted((a, b) => {
|
|
1844
|
+
const ta = tsOf(a);
|
|
1845
|
+
const tb = tsOf(b);
|
|
1846
|
+
if (ta === null && tb === null) return 0;
|
|
1847
|
+
if (ta === null) return 1;
|
|
1848
|
+
if (tb === null) return -1;
|
|
1849
|
+
return ta - tb;
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
//#endregion
|
|
1853
|
+
//#region state/recordings.ts
|
|
1854
|
+
/** The footage stim-server keeps per device: the last 15 minutes recorded, however long ago. */
|
|
1855
|
+
const RECORDING_FOOTAGE_MS = 9e5;
|
|
1856
|
+
/** The recordings of every workspace of one Stim home together; past it the oldest footage goes first. */
|
|
1857
|
+
const RECORDING_CAP_BYTES = 1024 ** 3;
|
|
1858
|
+
/** The platforms stim-server records: owned simulators and emulators, and the Stim-owned Chrome page. */
|
|
1859
|
+
const RECORDING_PLATFORMS = [
|
|
1860
|
+
"ios",
|
|
1861
|
+
"android",
|
|
1862
|
+
"web"
|
|
1863
|
+
];
|
|
1864
|
+
const SEGMENT = /^(\d+)-(\d+)\.seg$/;
|
|
1865
|
+
const PART = /^(\d+)\.part$/;
|
|
1866
|
+
const DEVICE = /^(ios|android|web)-(.+)$/;
|
|
1867
|
+
/** The directory under a workspace's recordings that holds one device slot's footage. */
|
|
1868
|
+
function recordingDeviceName(platform, slot) {
|
|
1869
|
+
return `${platform}-${slot}`;
|
|
1218
1870
|
}
|
|
1219
|
-
|
|
1220
|
-
|
|
1871
|
+
/** Segment names hold whole milliseconds: the start rounded down and the end rounded up. */
|
|
1872
|
+
function closedSegmentName(start, end) {
|
|
1873
|
+
return `${Math.floor(start)}-${Math.ceil(end)}.seg`;
|
|
1221
1874
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1875
|
+
function openSegmentName(start) {
|
|
1876
|
+
return `${Math.floor(start)}.part`;
|
|
1225
1877
|
}
|
|
1226
|
-
function
|
|
1227
|
-
if (typeof value !== "string" || !/^(https?:\/\/|\/(?!\/))/.test(value) || /[\s\\#]/.test(value)) return false;
|
|
1878
|
+
function listDir(dir) {
|
|
1228
1879
|
try {
|
|
1229
|
-
|
|
1230
|
-
return /\.bundle\/*$/.test(url.pathname) && url.searchParams.get("platform") === key.split(".").at(-1);
|
|
1880
|
+
return readdirSync(dir);
|
|
1231
1881
|
} catch {
|
|
1232
|
-
return
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
function expectedShape(type) {
|
|
1236
|
-
switch (type.kind) {
|
|
1237
|
-
case "boolean": return "true or false";
|
|
1238
|
-
case "string": return "a string";
|
|
1239
|
-
case "path": return "a string path";
|
|
1240
|
-
case "strings": return "an array of strings";
|
|
1241
|
-
case "number": return "a number";
|
|
1242
|
-
case "choice": return `one of: ${type.choices.join(", ")}`;
|
|
1243
|
-
case "object": return "an object";
|
|
1244
|
-
case "bundle-url": return "an HTTP(S) URL or /path ending in .bundle with a matching platform query and no fragment";
|
|
1882
|
+
return [];
|
|
1245
1883
|
}
|
|
1246
1884
|
}
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1885
|
+
/** The segments in one device directory, oldest first. Files that are not segments are left out. */
|
|
1886
|
+
function listSegments(dir) {
|
|
1887
|
+
const segments = [];
|
|
1888
|
+
for (const name of listDir(dir)) {
|
|
1889
|
+
const closed = SEGMENT.exec(name);
|
|
1890
|
+
const part = closed ? null : PART.exec(name);
|
|
1891
|
+
if (!closed && !part) continue;
|
|
1892
|
+
let stat;
|
|
1893
|
+
try {
|
|
1894
|
+
stat = statSync(join(dir, name));
|
|
1895
|
+
} catch {
|
|
1896
|
+
continue;
|
|
1897
|
+
}
|
|
1898
|
+
const start = Number((closed ?? part)[1]);
|
|
1899
|
+
segments.push({
|
|
1900
|
+
file: join(dir, name),
|
|
1901
|
+
start,
|
|
1902
|
+
end: closed ? Math.max(start, Number(closed[2])) : Math.max(start, Math.floor(stat.mtimeMs)),
|
|
1903
|
+
bytes: stat.size,
|
|
1904
|
+
open: part !== null
|
|
1905
|
+
});
|
|
1258
1906
|
}
|
|
1907
|
+
return segments.toSorted((a, b) => a.start - b.start);
|
|
1908
|
+
}
|
|
1909
|
+
/** Every device slot recorded under a workspace's recordings directory. */
|
|
1910
|
+
function listRecordedDevices(recordingsDir) {
|
|
1911
|
+
return listDir(recordingsDir).flatMap((name) => {
|
|
1912
|
+
const match = DEVICE.exec(name);
|
|
1913
|
+
if (!match) return [];
|
|
1914
|
+
const dir = join(recordingsDir, name);
|
|
1915
|
+
return [{
|
|
1916
|
+
platform: match[1],
|
|
1917
|
+
slot: match[2],
|
|
1918
|
+
dir,
|
|
1919
|
+
segments: listSegments(dir)
|
|
1920
|
+
}];
|
|
1921
|
+
});
|
|
1259
1922
|
}
|
|
1260
|
-
function
|
|
1261
|
-
|
|
1262
|
-
if (type.minimum !== void 0 && type.maximum !== void 0) return `${noun} from ${type.minimum} through ${type.maximum}`;
|
|
1263
|
-
if (type.minimum !== void 0) return `${noun}, ${type.minimum} or more`;
|
|
1264
|
-
return noun;
|
|
1923
|
+
function recordedBytes(devices) {
|
|
1924
|
+
return devices.reduce((sum, device) => sum + device.segments.reduce((total, s) => total + s.bytes, 0), 0);
|
|
1265
1925
|
}
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1926
|
+
/**
|
|
1927
|
+
* The oldest closed segments to delete so that one device keeps at most `footageMs` of footage. Time between
|
|
1928
|
+
* segments, while nothing was recorded, does not count. The open segment is never chosen.
|
|
1929
|
+
*/
|
|
1930
|
+
function footageDrops(segments, footageMs) {
|
|
1931
|
+
let total = segments.reduce((sum, segment) => sum + (segment.end - segment.start), 0);
|
|
1932
|
+
const drops = [];
|
|
1933
|
+
for (const segment of segments) {
|
|
1934
|
+
if (total <= footageMs) break;
|
|
1935
|
+
if (segment.open) continue;
|
|
1936
|
+
drops.push(segment);
|
|
1937
|
+
total -= segment.end - segment.start;
|
|
1272
1938
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1939
|
+
return drops;
|
|
1940
|
+
}
|
|
1941
|
+
/** The oldest closed segments across all devices to delete so that together they hold at most `capBytes`. */
|
|
1942
|
+
function capDrops(devices, capBytes) {
|
|
1943
|
+
let total = recordedBytes(devices);
|
|
1944
|
+
const drops = [];
|
|
1945
|
+
const oldest = devices.flatMap((device) => device.segments.filter((s) => !s.open)).toSorted((a, b) => a.end - b.end);
|
|
1946
|
+
for (const segment of oldest) {
|
|
1947
|
+
if (total <= capBytes) break;
|
|
1948
|
+
drops.push(segment);
|
|
1949
|
+
total -= segment.bytes;
|
|
1277
1950
|
}
|
|
1278
|
-
|
|
1279
|
-
return null;
|
|
1951
|
+
return drops;
|
|
1280
1952
|
}
|
|
1281
|
-
|
|
1282
|
-
function
|
|
1283
|
-
|
|
1953
|
+
/** `STIM_RECORDING` as a boolean, read like `stim settings` reads it; any other value is not an override. */
|
|
1954
|
+
function recordingEnvValue(raw) {
|
|
1955
|
+
if (!raw) return null;
|
|
1956
|
+
const value = coerceSettingText(settingDefinition("recording.enabled"), raw.trim());
|
|
1957
|
+
return typeof value === "boolean" ? value : null;
|
|
1284
1958
|
}
|
|
1285
|
-
const JSON_ENCODED_KINDS = [
|
|
1286
|
-
"boolean",
|
|
1287
|
-
"number",
|
|
1288
|
-
"strings",
|
|
1289
|
-
"object"
|
|
1290
|
-
];
|
|
1291
1959
|
/**
|
|
1292
|
-
*
|
|
1293
|
-
*
|
|
1294
|
-
* as bare text (boolean, number, strings, object) are read as JSON; a value that
|
|
1295
|
-
* fails to parse is left as the original string so `settingValueError` reports it.
|
|
1960
|
+
* Whether stim-server records a workspace's devices: `STIM_RECORDING`, then `recording.enabled` in the workspace,
|
|
1961
|
+
* repo and machine layers, in that order, and true by default.
|
|
1296
1962
|
*/
|
|
1297
|
-
function
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1963
|
+
function recordingEnabled(env, layers) {
|
|
1964
|
+
const override = recordingEnvValue(env.STIM_RECORDING);
|
|
1965
|
+
if (override !== null) return override;
|
|
1966
|
+
for (const layer of layers) {
|
|
1967
|
+
const recording = isJsonObject(layer) ? layer.recording : void 0;
|
|
1968
|
+
const value = isJsonObject(recording) ? recording.enabled : void 0;
|
|
1969
|
+
if (typeof value === "boolean") return value;
|
|
1303
1970
|
}
|
|
1971
|
+
return true;
|
|
1304
1972
|
}
|
|
1305
1973
|
//#endregion
|
|
1306
1974
|
//#region state/settings-schema.ts
|
|
@@ -1399,9 +2067,21 @@ const BUILD_PHASES = [
|
|
|
1399
2067
|
"prebuild",
|
|
1400
2068
|
"pods",
|
|
1401
2069
|
"compile",
|
|
2070
|
+
"device",
|
|
1402
2071
|
"install",
|
|
1403
2072
|
"launch"
|
|
1404
2073
|
];
|
|
2074
|
+
/** The native build tool's step inside a build's `compile` phase. */
|
|
2075
|
+
const NATIVE_BUILD_STEPS = [
|
|
2076
|
+
"configure",
|
|
2077
|
+
"compile",
|
|
2078
|
+
"link",
|
|
2079
|
+
"resources",
|
|
2080
|
+
"script",
|
|
2081
|
+
"dex",
|
|
2082
|
+
"package",
|
|
2083
|
+
"sign"
|
|
2084
|
+
];
|
|
1405
2085
|
/** How a recorded build ended. `interrupted` is a run whose process ended without recording a result. */
|
|
1406
2086
|
const BUILD_RESULTS = [
|
|
1407
2087
|
"succeeded",
|
|
@@ -1409,12 +2089,38 @@ const BUILD_RESULTS = [
|
|
|
1409
2089
|
"cancelled",
|
|
1410
2090
|
"interrupted"
|
|
1411
2091
|
];
|
|
2092
|
+
/** The kinds of evidence that date a device's last activity, in the order `recent` lists them. */
|
|
2093
|
+
const ACTIVITY_RECENCY_BASES = [
|
|
2094
|
+
"agent-action",
|
|
2095
|
+
"device-log",
|
|
2096
|
+
"metro-bundle",
|
|
2097
|
+
"workspace-use",
|
|
2098
|
+
"supervisor-start",
|
|
2099
|
+
"page-log",
|
|
2100
|
+
"viewer"
|
|
2101
|
+
];
|
|
1412
2102
|
/** Every state a device's app process can report. */
|
|
1413
2103
|
const APP_PROCESS_STATES = [
|
|
1414
2104
|
"running",
|
|
1415
2105
|
"stopped",
|
|
1416
2106
|
"unknown"
|
|
1417
2107
|
];
|
|
2108
|
+
/** Every coding-agent tool whose sessions status can attribute to an environment. */
|
|
2109
|
+
const AGENT_TOOLS = ["claude-code", "codex"];
|
|
2110
|
+
/** Every connection state a physical device can report. */
|
|
2111
|
+
const PHYSICAL_DEVICE_CONNECTIONS = [
|
|
2112
|
+
"connected",
|
|
2113
|
+
"disconnected",
|
|
2114
|
+
"unknown"
|
|
2115
|
+
];
|
|
2116
|
+
const WORKSPACE_STAGE_KINDS = [
|
|
2117
|
+
"building",
|
|
2118
|
+
"warming",
|
|
2119
|
+
"ready",
|
|
2120
|
+
"build-failed",
|
|
2121
|
+
"running",
|
|
2122
|
+
"stopped"
|
|
2123
|
+
];
|
|
1418
2124
|
/** Every code a status issue can carry. */
|
|
1419
2125
|
const STATUS_ISSUE_CODES = [
|
|
1420
2126
|
"port-not-ours",
|
|
@@ -1424,10 +2130,39 @@ const STATUS_ISSUE_CODES = [
|
|
|
1424
2130
|
"avd-missing",
|
|
1425
2131
|
"avd-not-detected",
|
|
1426
2132
|
"avd-unchecked",
|
|
2133
|
+
"android-reverse-missing",
|
|
1427
2134
|
"supervisor-unverified",
|
|
1428
2135
|
"browser-unverified",
|
|
1429
2136
|
"browser-orphaned"
|
|
1430
2137
|
];
|
|
2138
|
+
/** Every state the owned page's latest load can report. */
|
|
2139
|
+
const WEB_PAGE_STATES = [
|
|
2140
|
+
"loading",
|
|
2141
|
+
"loaded",
|
|
2142
|
+
"failed"
|
|
2143
|
+
];
|
|
2144
|
+
/**
|
|
2145
|
+
* Where a workspace is in its lifecycle: `warming` while `stim worktree warm` runs in it, `ready` after a warm
|
|
2146
|
+
* succeeded and before any `start`, `ios`, `android`, `web` or `reload` there, for at most two hours; `live` when
|
|
2147
|
+
* anything it owns runs, else `idle`.
|
|
2148
|
+
*/
|
|
2149
|
+
const WORKSPACE_PHASES = [
|
|
2150
|
+
"warming",
|
|
2151
|
+
"ready",
|
|
2152
|
+
"live",
|
|
2153
|
+
"idle"
|
|
2154
|
+
];
|
|
2155
|
+
/**
|
|
2156
|
+
* Where a memory figure comes from. `footprint` is each process's physical footprint, which Activity Monitor's Memory
|
|
2157
|
+
* column shows. `rss` sums resident set sizes, which count pages shared between processes once per process, so a
|
|
2158
|
+
* simulator reports well above its footprint; the machine owners fall back to it when the footprint helper cannot be
|
|
2159
|
+
* built. `estimate` is a fixed amount per running thing, which an environment reports whenever it has no footprint.
|
|
2160
|
+
*/
|
|
2161
|
+
const MEMORY_SOURCES = [
|
|
2162
|
+
"footprint",
|
|
2163
|
+
"rss",
|
|
2164
|
+
"estimate"
|
|
2165
|
+
];
|
|
1431
2166
|
/** Every kind of process owner the status machine section reports. */
|
|
1432
2167
|
const MACHINE_OWNER_KINDS = [
|
|
1433
2168
|
"simulator",
|
|
@@ -1435,10 +2170,362 @@ const MACHINE_OWNER_KINDS = [
|
|
|
1435
2170
|
"metro",
|
|
1436
2171
|
"build",
|
|
1437
2172
|
"browser",
|
|
2173
|
+
"macos",
|
|
1438
2174
|
"server",
|
|
1439
2175
|
"shared"
|
|
1440
2176
|
];
|
|
1441
2177
|
//#endregion
|
|
2178
|
+
//#region state/status-derived.ts
|
|
2179
|
+
const PLATFORMS$1 = ["ios", "android"];
|
|
2180
|
+
function isRunning(platform, device) {
|
|
2181
|
+
if (platform === "ios") return device.state === "Booted";
|
|
2182
|
+
return device.state === "detected" && !device.physical;
|
|
2183
|
+
}
|
|
2184
|
+
function appPresence(env, platform, device) {
|
|
2185
|
+
if (!isRunning(platform, device) || device.app?.state === "running") return null;
|
|
2186
|
+
const everBuilt = env.builds?.[platform]?.some((entry) => entry.result === "succeeded") ?? true;
|
|
2187
|
+
if (env.lastBuilds?.[platform]?.status === "failed" && !everBuilt) return "none";
|
|
2188
|
+
return device.app?.state === "stopped" ? "closed" : null;
|
|
2189
|
+
}
|
|
2190
|
+
function deviceSlots(env) {
|
|
2191
|
+
return [{
|
|
2192
|
+
slot: "default",
|
|
2193
|
+
ios: env.ios,
|
|
2194
|
+
android: env.android
|
|
2195
|
+
}, ...env.slots ?? []];
|
|
2196
|
+
}
|
|
2197
|
+
function latestBuild(env) {
|
|
2198
|
+
let latest = null;
|
|
2199
|
+
for (const platform of PLATFORMS$1) {
|
|
2200
|
+
const build = env.lastBuilds?.[platform];
|
|
2201
|
+
if (build && (latest === null || Date.parse(build.startedAt) > Date.parse(latest.startedAt))) latest = build;
|
|
2202
|
+
}
|
|
2203
|
+
return latest;
|
|
2204
|
+
}
|
|
2205
|
+
function stage(kind, since, platform = null) {
|
|
2206
|
+
return {
|
|
2207
|
+
kind,
|
|
2208
|
+
since: since ?? null,
|
|
2209
|
+
platform,
|
|
2210
|
+
closedApps: []
|
|
2211
|
+
};
|
|
2212
|
+
}
|
|
2213
|
+
function workspaceStage(env) {
|
|
2214
|
+
if (env.macos?.build.state === "running") return stage("building", env.macos.build.startedAt, "macos");
|
|
2215
|
+
if (env.macos?.build.state === "failed") return stage("build-failed", env.macos.build.finishedAt ?? env.macos.build.startedAt, "macos");
|
|
2216
|
+
if (env.build?.state === "running") return stage("building", env.build.startedAt, env.build.platform);
|
|
2217
|
+
if (!env.live && env.phase === "warming") return stage("warming", env.phaseSince);
|
|
2218
|
+
if (!env.live && env.phase === "ready") return stage("ready", env.phaseSince);
|
|
2219
|
+
const latest = latestBuild(env);
|
|
2220
|
+
if (latest?.status === "failed") return stage("build-failed", latest.finishedAt ?? latest.startedAt, latest.platform);
|
|
2221
|
+
if (env.live || (env.remoteDevices?.length ?? 0) > 0) {
|
|
2222
|
+
const closedApps = deviceSlots(env).flatMap(({ slot, ...devices }) => PLATFORMS$1.flatMap((platform) => {
|
|
2223
|
+
const device = devices[platform];
|
|
2224
|
+
return device && appPresence(env, platform, device) === "closed" ? [{
|
|
2225
|
+
platform,
|
|
2226
|
+
slot
|
|
2227
|
+
}] : [];
|
|
2228
|
+
}));
|
|
2229
|
+
return {
|
|
2230
|
+
...stage("running", env.supervisor?.startedAt),
|
|
2231
|
+
closedApps
|
|
2232
|
+
};
|
|
2233
|
+
}
|
|
2234
|
+
const lastStop = env.metro?.lastStop;
|
|
2235
|
+
return stage("stopped", lastStop && "at" in lastStop ? lastStop.at : null);
|
|
2236
|
+
}
|
|
2237
|
+
function checksState(checks) {
|
|
2238
|
+
if (!checks) return null;
|
|
2239
|
+
if (checks.failing > 0) return "failing";
|
|
2240
|
+
if (checks.pending > 0) return "pending";
|
|
2241
|
+
return checks.passing > 0 ? "passing" : null;
|
|
2242
|
+
}
|
|
2243
|
+
function gitChip(worktree) {
|
|
2244
|
+
const git = worktree.git;
|
|
2245
|
+
if (!git) return null;
|
|
2246
|
+
const pull = worktree.pullRequest;
|
|
2247
|
+
const parts = [];
|
|
2248
|
+
const ahead = git.ahead ?? 0;
|
|
2249
|
+
const behind = git.behind ?? 0;
|
|
2250
|
+
if (ahead || behind) parts.push({
|
|
2251
|
+
kind: "arrows",
|
|
2252
|
+
ahead,
|
|
2253
|
+
behind
|
|
2254
|
+
});
|
|
2255
|
+
const uncommitted = git.changed + git.untracked;
|
|
2256
|
+
if (uncommitted) parts.push({
|
|
2257
|
+
kind: "changed",
|
|
2258
|
+
count: uncommitted
|
|
2259
|
+
});
|
|
2260
|
+
if (git.mergedInto !== null) {
|
|
2261
|
+
if (pull?.state !== "merged") parts.push({
|
|
2262
|
+
kind: "merged",
|
|
2263
|
+
into: git.mergedInto
|
|
2264
|
+
});
|
|
2265
|
+
} else if (git.upstream === null) parts.push({ kind: "no-upstream" });
|
|
2266
|
+
return {
|
|
2267
|
+
parts,
|
|
2268
|
+
ci: pull ? checksState(pull.checks) : null
|
|
2269
|
+
};
|
|
2270
|
+
}
|
|
2271
|
+
function buildCause(build) {
|
|
2272
|
+
if (("result" in build ? build.result : build.status) !== "failed") return null;
|
|
2273
|
+
const at = build.diagnostics?.find((d) => d.file && d.line !== null && d.line !== void 0);
|
|
2274
|
+
if (at) return {
|
|
2275
|
+
key: `${at.file}:${at.line}`,
|
|
2276
|
+
file: at.file,
|
|
2277
|
+
line: at.line
|
|
2278
|
+
};
|
|
2279
|
+
return {
|
|
2280
|
+
key: build.errorCode ?? "failed",
|
|
2281
|
+
file: null,
|
|
2282
|
+
line: null
|
|
2283
|
+
};
|
|
2284
|
+
}
|
|
2285
|
+
function withCauses(builds) {
|
|
2286
|
+
if (!builds) return builds;
|
|
2287
|
+
return Object.fromEntries(Object.entries(builds).map(([platform, list]) => [platform, list?.map((build) => withCause(build))]));
|
|
2288
|
+
}
|
|
2289
|
+
function withCause(build) {
|
|
2290
|
+
const cause = buildCause(build);
|
|
2291
|
+
return cause ? {
|
|
2292
|
+
...build,
|
|
2293
|
+
cause
|
|
2294
|
+
} : build;
|
|
2295
|
+
}
|
|
2296
|
+
function withGitChip(worktree) {
|
|
2297
|
+
const chip = gitChip(worktree);
|
|
2298
|
+
return chip ? {
|
|
2299
|
+
...worktree,
|
|
2300
|
+
gitChip: chip
|
|
2301
|
+
} : worktree;
|
|
2302
|
+
}
|
|
2303
|
+
/** The environment with the conclusions clients draw from it: `stage`, `appPresence`, `gitChip` and `cause`. */
|
|
2304
|
+
function withDerivedFacts(env) {
|
|
2305
|
+
const device = (platform, record) => record ? {
|
|
2306
|
+
...record,
|
|
2307
|
+
appPresence: appPresence(env, platform, record)
|
|
2308
|
+
} : record;
|
|
2309
|
+
const derived = {
|
|
2310
|
+
...env,
|
|
2311
|
+
stage: workspaceStage(env)
|
|
2312
|
+
};
|
|
2313
|
+
if (env.ios) derived.ios = device("ios", env.ios);
|
|
2314
|
+
if (env.android) derived.android = device("android", env.android);
|
|
2315
|
+
if (env.slots) derived.slots = env.slots.map((slot) => ({
|
|
2316
|
+
...slot,
|
|
2317
|
+
ios: device("ios", slot.ios),
|
|
2318
|
+
android: device("android", slot.android)
|
|
2319
|
+
}));
|
|
2320
|
+
if (env.worktree) derived.worktree = withGitChip(env.worktree);
|
|
2321
|
+
if (env.lastBuilds) derived.lastBuilds = Object.fromEntries(Object.entries(env.lastBuilds).map(([platform, build]) => [platform, withCause(build)]));
|
|
2322
|
+
if (env.builds) derived.builds = withCauses(env.builds);
|
|
2323
|
+
return derived;
|
|
2324
|
+
}
|
|
2325
|
+
//#endregion
|
|
2326
|
+
//#region state/status-measures.ts
|
|
2327
|
+
function cacheName(path) {
|
|
2328
|
+
return `${createHash("sha256").update(path).digest("hex").slice(0, 32)}.json`;
|
|
2329
|
+
}
|
|
2330
|
+
/** The file that caches `path`'s measured size, one file per folder. */
|
|
2331
|
+
function diskUsageCacheFile(path) {
|
|
2332
|
+
return join(diskUsageCacheDir(), cacheName(path));
|
|
2333
|
+
}
|
|
2334
|
+
/** The file that caches the pull request of the worktree at `path`. */
|
|
2335
|
+
function pullRequestCacheFile(path) {
|
|
2336
|
+
return join(pullRequestCacheDir(), cacheName(path));
|
|
2337
|
+
}
|
|
2338
|
+
/** Every finished file in the disk-usage and pull-request caches; a file being written is not listed. */
|
|
2339
|
+
function listStatusCacheEntries() {
|
|
2340
|
+
const entries = [];
|
|
2341
|
+
for (const [kind, dir] of [["disk-usage", diskUsageCacheDir()], ["pull-request", pullRequestCacheDir()]]) {
|
|
2342
|
+
let names;
|
|
2343
|
+
try {
|
|
2344
|
+
names = readdirSync(dir).toSorted();
|
|
2345
|
+
} catch {
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
for (const name of names) {
|
|
2349
|
+
if (!/^[0-9a-f]{32}\.json$/.test(name)) continue;
|
|
2350
|
+
const file = join(dir, name);
|
|
2351
|
+
let bytes;
|
|
2352
|
+
try {
|
|
2353
|
+
bytes = statSync(file).size;
|
|
2354
|
+
} catch {
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
const recorded = readJsonObject(file)?.path;
|
|
2358
|
+
const path = typeof recorded === "string" && isAbsolute(recorded) && cacheName(recorded) === name ? recorded : null;
|
|
2359
|
+
entries.push({
|
|
2360
|
+
kind,
|
|
2361
|
+
file,
|
|
2362
|
+
path,
|
|
2363
|
+
bytes
|
|
2364
|
+
});
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
return entries;
|
|
2368
|
+
}
|
|
2369
|
+
const PR_STATES = /* @__PURE__ */ new Set([
|
|
2370
|
+
"open",
|
|
2371
|
+
"draft",
|
|
2372
|
+
"merged",
|
|
2373
|
+
"closed"
|
|
2374
|
+
]);
|
|
2375
|
+
const REVIEW_DECISIONS = /* @__PURE__ */ new Set([
|
|
2376
|
+
"approved",
|
|
2377
|
+
"changes-requested",
|
|
2378
|
+
"review-required"
|
|
2379
|
+
]);
|
|
2380
|
+
function pullRequestOf(value) {
|
|
2381
|
+
if (value === null) return null;
|
|
2382
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
2383
|
+
const pr = value;
|
|
2384
|
+
if (!Number.isInteger(pr.number) || typeof pr.url !== "string" || typeof pr.title !== "string") return void 0;
|
|
2385
|
+
if (!PR_STATES.has(pr.state) || typeof pr.checkedAt !== "string") return void 0;
|
|
2386
|
+
const checks = pr.checks;
|
|
2387
|
+
const counts = checks && typeof checks === "object" ? [
|
|
2388
|
+
checks.passing,
|
|
2389
|
+
checks.failing,
|
|
2390
|
+
checks.pending
|
|
2391
|
+
].map((count) => countOrNull(count)) : null;
|
|
2392
|
+
return {
|
|
2393
|
+
number: pr.number,
|
|
2394
|
+
url: pr.url,
|
|
2395
|
+
title: pr.title,
|
|
2396
|
+
state: pr.state,
|
|
2397
|
+
checks: counts && counts.every((count) => count !== null) ? {
|
|
2398
|
+
passing: counts[0],
|
|
2399
|
+
failing: counts[1],
|
|
2400
|
+
pending: counts[2]
|
|
2401
|
+
} : null,
|
|
2402
|
+
reviewDecision: REVIEW_DECISIONS.has(pr.reviewDecision) ? pr.reviewDecision : null,
|
|
2403
|
+
checkedAt: pr.checkedAt
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
/** The cached pull request lookup of the worktree at `path`, or null before one succeeded. */
|
|
2407
|
+
function readPullRequestCache(path) {
|
|
2408
|
+
const entry = readJsonObject(pullRequestCacheFile(path));
|
|
2409
|
+
if (!entry || entry.path !== path || typeof entry.branch !== "string" || typeof entry.head !== "string") return null;
|
|
2410
|
+
if (typeof entry.checkedAt !== "string") return null;
|
|
2411
|
+
const pullRequest = pullRequestOf(entry.pullRequest);
|
|
2412
|
+
if (pullRequest === void 0) return null;
|
|
2413
|
+
return {
|
|
2414
|
+
path,
|
|
2415
|
+
branch: entry.branch,
|
|
2416
|
+
head: entry.head,
|
|
2417
|
+
checkedAt: entry.checkedAt,
|
|
2418
|
+
pullRequest
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
/** The cached size of the folder at `path`, or null before it was measured. */
|
|
2422
|
+
function readDiskUsage(path) {
|
|
2423
|
+
const entry = readJsonObject(diskUsageCacheFile(path));
|
|
2424
|
+
if (!entry || entry.path !== path) return null;
|
|
2425
|
+
const { bytes, measuredAt } = entry;
|
|
2426
|
+
if (typeof bytes !== "number" || !Number.isFinite(bytes) || typeof measuredAt !== "string") return null;
|
|
2427
|
+
if (!Number.isFinite(Date.parse(measuredAt))) return null;
|
|
2428
|
+
return {
|
|
2429
|
+
bytes,
|
|
2430
|
+
measuredAt
|
|
2431
|
+
};
|
|
2432
|
+
}
|
|
2433
|
+
function countOrNull(value) {
|
|
2434
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
|
|
2435
|
+
}
|
|
2436
|
+
/** The running build's tool detail its process last wrote, when the file belongs to the build with `claimId`. */
|
|
2437
|
+
function readBuildDetail(root, claimId) {
|
|
2438
|
+
const file = readJsonObject(workspaceBuildDetailFile(root));
|
|
2439
|
+
if (!file || file.claimId !== claimId) return null;
|
|
2440
|
+
const detail = file.detail;
|
|
2441
|
+
if (!detail || typeof detail !== "object" || Array.isArray(detail)) return null;
|
|
2442
|
+
const raw = detail;
|
|
2443
|
+
if (typeof raw.updatedAt !== "string") return null;
|
|
2444
|
+
return {
|
|
2445
|
+
step: NATIVE_BUILD_STEPS.includes(raw.step) ? raw.step : null,
|
|
2446
|
+
unit: raw.unit === "targets" || raw.unit === "tasks" ? raw.unit : null,
|
|
2447
|
+
done: countOrNull(raw.done),
|
|
2448
|
+
total: countOrNull(raw.total),
|
|
2449
|
+
line: typeof raw.line === "string" ? raw.line : null,
|
|
2450
|
+
updatedAt: raw.updatedAt
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
function optionalString(value) {
|
|
2454
|
+
return typeof value === "string" && value ? value : void 0;
|
|
2455
|
+
}
|
|
2456
|
+
/** An agent session as Stim wrote it to its cache or workspace state, or null when it is not one. */
|
|
2457
|
+
function agentSessionOf(value) {
|
|
2458
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
2459
|
+
const raw = value;
|
|
2460
|
+
if (!AGENT_TOOLS.includes(raw.tool)) return null;
|
|
2461
|
+
if (typeof raw.sessionId !== "string" || !raw.sessionId || typeof raw.cwd !== "string" || !raw.cwd) return null;
|
|
2462
|
+
const session = {
|
|
2463
|
+
tool: raw.tool,
|
|
2464
|
+
sessionId: raw.sessionId,
|
|
2465
|
+
cwd: raw.cwd
|
|
2466
|
+
};
|
|
2467
|
+
const title = optionalString(raw.title);
|
|
2468
|
+
const startedAt = optionalString(raw.startedAt);
|
|
2469
|
+
const lastActiveAt = optionalString(raw.lastActiveAt);
|
|
2470
|
+
const openUrl = optionalString(raw.openUrl);
|
|
2471
|
+
const webUrl = optionalString(raw.webUrl);
|
|
2472
|
+
if (title) session.title = title;
|
|
2473
|
+
if (startedAt) session.startedAt = startedAt;
|
|
2474
|
+
if (lastActiveAt) session.lastActiveAt = lastActiveAt;
|
|
2475
|
+
if (Number.isInteger(raw.pid) && raw.pid > 0) session.pid = raw.pid;
|
|
2476
|
+
if (openUrl) session.openUrl = openUrl;
|
|
2477
|
+
if (webUrl) session.webUrl = webUrl;
|
|
2478
|
+
return session;
|
|
2479
|
+
}
|
|
2480
|
+
/** How long a workspace keeps an agent session after it stopped running. */
|
|
2481
|
+
const ENDED_AGENT_RETENTION_MS = 2592e5;
|
|
2482
|
+
/** An ended agent session as Stim wrote it, or null when it is not one. */
|
|
2483
|
+
function endedAgentSessionOf(value) {
|
|
2484
|
+
const session = agentSessionOf(value);
|
|
2485
|
+
const endedAt = optionalString(value?.endedAt);
|
|
2486
|
+
if (!session || !endedAt || !Number.isFinite(Date.parse(endedAt))) return null;
|
|
2487
|
+
const { pid: _pid, ...ended } = session;
|
|
2488
|
+
return {
|
|
2489
|
+
...ended,
|
|
2490
|
+
endedAt
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
/** The workspace's agent sessions that ended within `ENDED_AGENT_RETENTION_MS` of `now`, most recently ended first. */
|
|
2494
|
+
function readEndedAgentSessions(root, now) {
|
|
2495
|
+
const entry = readJsonObject(workspaceEndedAgentsFile(root));
|
|
2496
|
+
if (!entry || !Array.isArray(entry.sessions)) return [];
|
|
2497
|
+
return entry.sessions.map(endedAgentSessionOf).filter((session) => session !== null && now - Date.parse(session.endedAt) <= 2592e5).toSorted((a, b) => Date.parse(b.endedAt) - Date.parse(a.endedAt));
|
|
2498
|
+
}
|
|
2499
|
+
/** The agent sessions `status --watch` last cached, or null before it cached any. */
|
|
2500
|
+
function readAgentSessionsCache() {
|
|
2501
|
+
const entry = readJsonObject(agentSessionsCacheFile());
|
|
2502
|
+
if (!entry || typeof entry.discoveredAt !== "string" || !Array.isArray(entry.sessions)) return null;
|
|
2503
|
+
return {
|
|
2504
|
+
discoveredAt: entry.discoveredAt,
|
|
2505
|
+
sessions: entry.sessions.map(agentSessionOf).filter((session) => session !== null)
|
|
2506
|
+
};
|
|
2507
|
+
}
|
|
2508
|
+
//#endregion
|
|
2509
|
+
//#region state/stim-build.ts
|
|
2510
|
+
/**
|
|
2511
|
+
* A digest of Stim's built code: the first 16 hex characters of the sha256 over the sorted `*.mjs` names and
|
|
2512
|
+
* bytes of a `stim` `dist` directory. A client and a build machine offload only when both compute the same one.
|
|
2513
|
+
*/
|
|
2514
|
+
function stimBuildDigest(dir) {
|
|
2515
|
+
try {
|
|
2516
|
+
const hash = createHash("sha256");
|
|
2517
|
+
const names = readdirSync(dir).filter((entry) => entry.endsWith(".mjs")).toSorted();
|
|
2518
|
+
if (names.length === 0) return null;
|
|
2519
|
+
for (const name of names) {
|
|
2520
|
+
hash.update(name);
|
|
2521
|
+
hash.update(readFileSync(join(dir, name)));
|
|
2522
|
+
}
|
|
2523
|
+
return hash.digest("hex").slice(0, 16);
|
|
2524
|
+
} catch {
|
|
2525
|
+
return null;
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
//#endregion
|
|
1442
2529
|
//#region state/workspace-state.ts
|
|
1443
2530
|
const IDLE_STOP_KEY = "devServerStop";
|
|
1444
2531
|
function readIdleStop(state) {
|
|
@@ -1450,6 +2537,60 @@ function readIdleStop(state) {
|
|
|
1450
2537
|
idleMinutes: record.idleMinutes
|
|
1451
2538
|
};
|
|
1452
2539
|
}
|
|
2540
|
+
const DEVICE_IDLE_SHUTDOWN_KEY = "deviceIdleShutdowns";
|
|
2541
|
+
/** The recorded idle shutdowns by device slot key (`ios`, `android`, `ios:<slot>`). */
|
|
2542
|
+
function readDeviceIdleShutdowns(state) {
|
|
2543
|
+
const records = state?.[DEVICE_IDLE_SHUTDOWN_KEY];
|
|
2544
|
+
if (!records || typeof records !== "object" || Array.isArray(records)) return {};
|
|
2545
|
+
const found = {};
|
|
2546
|
+
for (const [key, value] of Object.entries(records)) {
|
|
2547
|
+
const record = value;
|
|
2548
|
+
if (typeof record?.at === "string" && typeof record.idleMinutes === "number") found[key] = {
|
|
2549
|
+
at: record.at,
|
|
2550
|
+
idleMinutes: record.idleMinutes
|
|
2551
|
+
};
|
|
2552
|
+
}
|
|
2553
|
+
return found;
|
|
2554
|
+
}
|
|
2555
|
+
const DEV_SERVER_STOP_REQUEST_KEY = "devServerStopRequest";
|
|
2556
|
+
function readDevServerStopRequest(state) {
|
|
2557
|
+
const record = state?.[DEV_SERVER_STOP_REQUEST_KEY];
|
|
2558
|
+
if (typeof record?.processToken !== "string" || typeof record.by !== "string" || typeof record.pid !== "number" || typeof record.at !== "string") return null;
|
|
2559
|
+
return {
|
|
2560
|
+
processToken: record.processToken,
|
|
2561
|
+
by: record.by,
|
|
2562
|
+
pid: record.pid,
|
|
2563
|
+
at: record.at,
|
|
2564
|
+
...typeof record.workspace === "string" ? { workspace: record.workspace } : {}
|
|
2565
|
+
};
|
|
2566
|
+
}
|
|
2567
|
+
function readDevServerStop(state) {
|
|
2568
|
+
const idle = readIdleStop(state);
|
|
2569
|
+
if (idle) return idle;
|
|
2570
|
+
const record = state?.[IDLE_STOP_KEY];
|
|
2571
|
+
if (typeof record?.at !== "string") return null;
|
|
2572
|
+
const at = record.at;
|
|
2573
|
+
if (record.reason === "requested" && typeof record.by === "string" && typeof record.byPid === "number") return {
|
|
2574
|
+
reason: "requested",
|
|
2575
|
+
at,
|
|
2576
|
+
by: record.by,
|
|
2577
|
+
byPid: record.byPid,
|
|
2578
|
+
...typeof record.byWorkspace === "string" ? { byWorkspace: record.byWorkspace } : {}
|
|
2579
|
+
};
|
|
2580
|
+
if (record.reason === "signal" && typeof record.signal === "string") return {
|
|
2581
|
+
reason: "signal",
|
|
2582
|
+
at,
|
|
2583
|
+
signal: record.signal
|
|
2584
|
+
};
|
|
2585
|
+
if (record.reason === "server-exited") return {
|
|
2586
|
+
reason: "server-exited",
|
|
2587
|
+
at,
|
|
2588
|
+
mode: typeof record.mode === "string" ? record.mode : "dev",
|
|
2589
|
+
code: typeof record.code === "number" ? record.code : null,
|
|
2590
|
+
signal: typeof record.signal === "string" ? record.signal : null
|
|
2591
|
+
};
|
|
2592
|
+
return null;
|
|
2593
|
+
}
|
|
1453
2594
|
/** Each platform's latest run; `lastBuild` holds whichever platform ran last. */
|
|
1454
2595
|
const LAST_BUILD_KEYS = {
|
|
1455
2596
|
ios: "lastIosBuild",
|
|
@@ -1487,7 +2628,8 @@ function missChange(value) {
|
|
|
1487
2628
|
category: typeof category === "string" && MISS_CATEGORIES.has(category) ? category : "other"
|
|
1488
2629
|
};
|
|
1489
2630
|
}
|
|
1490
|
-
|
|
2631
|
+
/** A recorded miss reason in the shape status reports, without `baseline.cacheKey`; null when malformed. */
|
|
2632
|
+
function parseMissReason(value) {
|
|
1491
2633
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1492
2634
|
const record = value;
|
|
1493
2635
|
if (typeof record.kind !== "string" || !MISS_KINDS.has(record.kind) || typeof record.summary !== "string") return null;
|
|
@@ -1506,7 +2648,7 @@ function missReason(value) {
|
|
|
1506
2648
|
};
|
|
1507
2649
|
}
|
|
1508
2650
|
const DIAGNOSTIC_CAP = 5;
|
|
1509
|
-
const positive = (value) => typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
2651
|
+
const positive$1 = (value) => typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
|
|
1510
2652
|
/**
|
|
1511
2653
|
* The first diagnostics of a failed build in the shape a last-build record stores and `status` reports, the
|
|
1512
2654
|
* ones with a source position first.
|
|
@@ -1518,8 +2660,8 @@ function buildDiagnostics(value) {
|
|
|
1518
2660
|
if (typeof message !== "string" || message === "") return [];
|
|
1519
2661
|
return [{
|
|
1520
2662
|
file: typeof file === "string" && file ? file : null,
|
|
1521
|
-
line: positive(line),
|
|
1522
|
-
column: positive(column),
|
|
2663
|
+
line: positive$1(line),
|
|
2664
|
+
column: positive$1(column),
|
|
1523
2665
|
message
|
|
1524
2666
|
}];
|
|
1525
2667
|
}).toSorted((a, b) => Number(b.file !== null) - Number(a.file !== null)).slice(0, DIAGNOSTIC_CAP);
|
|
@@ -1531,7 +2673,7 @@ function lastBuildReport(platform, value) {
|
|
|
1531
2673
|
if (typeof record.startedAt !== "string") return null;
|
|
1532
2674
|
const durationMs = typeof record.durationMs === "number" && record.durationMs >= 0 ? record.durationMs : null;
|
|
1533
2675
|
const finished = new Date(Date.parse(record.startedAt) + (durationMs ?? NaN));
|
|
1534
|
-
const reason = record.cacheHit === "local" || record.cacheHit === "remote" ? null :
|
|
2676
|
+
const reason = record.cacheHit === "local" || record.cacheHit === "remote" ? null : parseMissReason(record.missReason);
|
|
1535
2677
|
const diagnostics = record.status === "failed" ? buildDiagnostics(record.diagnostics) : [];
|
|
1536
2678
|
return {
|
|
1537
2679
|
platform,
|
|
@@ -1544,6 +2686,10 @@ function lastBuildReport(platform, value) {
|
|
|
1544
2686
|
finishedAt: Number.isNaN(finished.getTime()) ? null : finished.toISOString(),
|
|
1545
2687
|
...typeof record.errorCode === "string" ? { errorCode: record.errorCode } : {},
|
|
1546
2688
|
...reason ? { missReason: reason } : {},
|
|
2689
|
+
...typeof record.buildMachine === "string" ? { buildMachine: record.buildMachine } : {},
|
|
2690
|
+
...typeof record.builtOn === "string" ? { builtOn: record.builtOn } : {},
|
|
2691
|
+
...typeof record.offloadedTo === "string" ? { offloadedTo: record.offloadedTo } : {},
|
|
2692
|
+
...typeof record.offloadFallback === "string" ? { offloadFallback: record.offloadFallback } : {},
|
|
1547
2693
|
...diagnostics.length ? { diagnostics } : {}
|
|
1548
2694
|
};
|
|
1549
2695
|
}
|
|
@@ -1616,5 +2762,917 @@ function workspaceLastUsed(root) {
|
|
|
1616
2762
|
} catch {}
|
|
1617
2763
|
return lastUseFrom(readWorkspaceState(root), mtimes);
|
|
1618
2764
|
}
|
|
2765
|
+
const WARM_KEY = "warm";
|
|
2766
|
+
/** How long a successful warm keeps a workspace `ready` when nothing runs in it. */
|
|
2767
|
+
const READY_PHASE_MS = 72e5;
|
|
2768
|
+
function readWarmRecord(state) {
|
|
2769
|
+
const record = state?.[WARM_KEY];
|
|
2770
|
+
if (!record || typeof record !== "object") return null;
|
|
2771
|
+
if (record.phase === "ready") return typeof record.at === "string" ? {
|
|
2772
|
+
phase: "ready",
|
|
2773
|
+
at: record.at
|
|
2774
|
+
} : null;
|
|
2775
|
+
const claim = record.claim;
|
|
2776
|
+
if (record.phase !== "warming" || record.step !== "refresh" && record.step !== "copy" || typeof record.startedAt !== "string" || typeof claim?.root !== "string" || typeof claim.claimId !== "string") return null;
|
|
2777
|
+
return {
|
|
2778
|
+
phase: "warming",
|
|
2779
|
+
step: record.step,
|
|
2780
|
+
startedAt: record.startedAt,
|
|
2781
|
+
claim: {
|
|
2782
|
+
root: claim.root,
|
|
2783
|
+
claimId: claim.claimId
|
|
2784
|
+
}
|
|
2785
|
+
};
|
|
2786
|
+
}
|
|
2787
|
+
const WORKSPACE_AGENT_KEY = "agentSession";
|
|
2788
|
+
/**
|
|
2789
|
+
* The coding-agent session whose shell ran the Stim command that last recorded `lastUsedAt`, with that command's
|
|
2790
|
+
* working directory as `cwd` and its time as `lastActiveAt`.
|
|
2791
|
+
*/
|
|
2792
|
+
function readWorkspaceAgent(state) {
|
|
2793
|
+
const session = agentSessionOf(state?.[WORKSPACE_AGENT_KEY]);
|
|
2794
|
+
return session?.lastActiveAt ? session : null;
|
|
2795
|
+
}
|
|
2796
|
+
//#endregion
|
|
2797
|
+
//#region state/viewers.ts
|
|
2798
|
+
function parseViewedDevices(value) {
|
|
2799
|
+
if (!Array.isArray(value)) return [];
|
|
2800
|
+
return value.flatMap((entry) => {
|
|
2801
|
+
const { platform, id } = entry ?? {};
|
|
2802
|
+
return (platform === "ios" || platform === "android") && typeof id === "string" && id ? [{
|
|
2803
|
+
platform,
|
|
2804
|
+
id
|
|
2805
|
+
}] : [];
|
|
2806
|
+
});
|
|
2807
|
+
}
|
|
2808
|
+
/**
|
|
2809
|
+
* The devices a running stim-server streams frames of. A record whose server is not proven to be the same live
|
|
2810
|
+
* process counts for nothing.
|
|
2811
|
+
*/
|
|
2812
|
+
function readViewedDevices(dir = deviceViewersDir()) {
|
|
2813
|
+
let names;
|
|
2814
|
+
try {
|
|
2815
|
+
names = readdirSync(dir).filter((name) => name.endsWith(".json"));
|
|
2816
|
+
} catch {
|
|
2817
|
+
return [];
|
|
2818
|
+
}
|
|
2819
|
+
return names.flatMap((name) => {
|
|
2820
|
+
const record = readJsonObject(join(dir, name));
|
|
2821
|
+
if (!record || inspectProcessIdentity(record) !== "same") return [];
|
|
2822
|
+
return parseViewedDevices(record.devices);
|
|
2823
|
+
});
|
|
2824
|
+
}
|
|
2825
|
+
//#endregion
|
|
2826
|
+
//#region state/stats.ts
|
|
2827
|
+
const STATS_VERSION = 1;
|
|
2828
|
+
/** Where a compiling build ran: here, on a build machine, or here after offloading it failed. */
|
|
2829
|
+
const PLACEMENT_DECISIONS = [
|
|
2830
|
+
"here",
|
|
2831
|
+
"offloaded",
|
|
2832
|
+
"fell-back"
|
|
2833
|
+
];
|
|
2834
|
+
const PLATFORMS = ["ios", "android"];
|
|
2835
|
+
const OUTCOMES = ["hit", "cold"];
|
|
2836
|
+
const HISTORY_LIMIT = 10;
|
|
2837
|
+
function statsProjectKey({ root, commonDir, repoRoot }) {
|
|
2838
|
+
if (commonDir && repoRoot && basename(commonDir) === ".git") return canonical(join(canonical(dirname(commonDir)), relative(canonical(repoRoot), canonical(root))));
|
|
2839
|
+
return canonical(root);
|
|
2840
|
+
}
|
|
2841
|
+
/** Placement counts for the local calendar day of `now`, per-machine totals, and the retained placements. */
|
|
2842
|
+
function offloadSummary(record, now) {
|
|
2843
|
+
const day = new Date(now).toDateString();
|
|
2844
|
+
const placements = record?.placements ?? [];
|
|
2845
|
+
const today = {
|
|
2846
|
+
here: 0,
|
|
2847
|
+
offloaded: 0,
|
|
2848
|
+
fellBack: 0
|
|
2849
|
+
};
|
|
2850
|
+
const machines = {};
|
|
2851
|
+
const machine = (name) => machines[name] ??= {
|
|
2852
|
+
today: {
|
|
2853
|
+
offloaded: 0,
|
|
2854
|
+
offloadedMs: 0,
|
|
2855
|
+
savedMs: 0,
|
|
2856
|
+
fallbacks: 0
|
|
2857
|
+
},
|
|
2858
|
+
total: record?.buildMachines?.[name] ?? {
|
|
2859
|
+
offloaded: 0,
|
|
2860
|
+
offloadedMs: 0,
|
|
2861
|
+
savedMs: 0,
|
|
2862
|
+
fallbacks: 0
|
|
2863
|
+
}
|
|
2864
|
+
};
|
|
2865
|
+
for (const name of Object.keys(record?.buildMachines ?? {})) machine(name);
|
|
2866
|
+
for (const placement of placements) {
|
|
2867
|
+
if (new Date(placement.at).toDateString() !== day) continue;
|
|
2868
|
+
if (placement.decision === "here") {
|
|
2869
|
+
today.here += 1;
|
|
2870
|
+
continue;
|
|
2871
|
+
}
|
|
2872
|
+
if (placement.decision === "fell-back") today.fellBack += 1;
|
|
2873
|
+
else today.offloaded += 1;
|
|
2874
|
+
if (!placement.machine) continue;
|
|
2875
|
+
const entry = machine(placement.machine).today;
|
|
2876
|
+
if (placement.decision === "fell-back") {
|
|
2877
|
+
entry.fallbacks += 1;
|
|
2878
|
+
continue;
|
|
2879
|
+
}
|
|
2880
|
+
entry.offloaded += 1;
|
|
2881
|
+
entry.offloadedMs += placement.buildMs ?? 0;
|
|
2882
|
+
if (placement.localEstimateMs && placement.buildMs) entry.savedMs += placement.localEstimateMs - placement.buildMs;
|
|
2883
|
+
}
|
|
2884
|
+
return {
|
|
2885
|
+
today,
|
|
2886
|
+
machines,
|
|
2887
|
+
placements: placements.toReversed()
|
|
2888
|
+
};
|
|
2889
|
+
}
|
|
2890
|
+
function trimSamples(samples) {
|
|
2891
|
+
const kept = {
|
|
2892
|
+
setup: 0,
|
|
2893
|
+
other: 0
|
|
2894
|
+
};
|
|
2895
|
+
const out = [];
|
|
2896
|
+
for (const sample of samples.toReversed()) {
|
|
2897
|
+
const kind = sample.deviceSetup === true ? "setup" : "other";
|
|
2898
|
+
if (kept[kind] >= 10) continue;
|
|
2899
|
+
kept[kind] += 1;
|
|
2900
|
+
out.push(sample);
|
|
2901
|
+
}
|
|
2902
|
+
return out.toReversed();
|
|
2903
|
+
}
|
|
2904
|
+
function readStats() {
|
|
2905
|
+
const path = statsFile();
|
|
2906
|
+
let text;
|
|
2907
|
+
try {
|
|
2908
|
+
text = readStateFile(path);
|
|
2909
|
+
} catch {
|
|
2910
|
+
return {
|
|
2911
|
+
record: null,
|
|
2912
|
+
note: null
|
|
2913
|
+
};
|
|
2914
|
+
}
|
|
2915
|
+
const parsed = decodeStats(text);
|
|
2916
|
+
if (!parsed.record && parsed.newerVersion === null) return {
|
|
2917
|
+
record: null,
|
|
2918
|
+
note: `Run statistics at ${path} could not be read; the next ios or android run moves them aside and starts a new file.`
|
|
2919
|
+
};
|
|
2920
|
+
if (parsed.newerVersion !== null) return {
|
|
2921
|
+
record: null,
|
|
2922
|
+
note: `Run statistics at ${path} are version ${parsed.newerVersion}, which this Stim does not understand, so none are shown.`
|
|
2923
|
+
};
|
|
2924
|
+
return {
|
|
2925
|
+
record: parsed.record,
|
|
2926
|
+
note: null
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2929
|
+
function projectEstimates(record, projectKey, platform) {
|
|
2930
|
+
const bucket = record?.projects?.[projectKey]?.[platform] ?? null;
|
|
2931
|
+
return {
|
|
2932
|
+
coldBuildMs: positive(bucket?.lastColdBuildMs),
|
|
2933
|
+
podsMs: positive(bucket?.lastPodsMs)
|
|
2934
|
+
};
|
|
2935
|
+
}
|
|
2936
|
+
function readRunEstimates({ projectKey, platform, read = readStats }) {
|
|
2937
|
+
if (!projectKey) return {
|
|
2938
|
+
coldBuildMs: null,
|
|
2939
|
+
podsMs: null
|
|
2940
|
+
};
|
|
2941
|
+
try {
|
|
2942
|
+
return projectEstimates(read().record, projectKey, platform);
|
|
2943
|
+
} catch {
|
|
2944
|
+
return {
|
|
2945
|
+
coldBuildMs: null,
|
|
2946
|
+
podsMs: null
|
|
2947
|
+
};
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
function parseRecord(text) {
|
|
2951
|
+
let parsed;
|
|
2952
|
+
try {
|
|
2953
|
+
parsed = JSON.parse(text);
|
|
2954
|
+
} catch {
|
|
2955
|
+
return null;
|
|
2956
|
+
}
|
|
2957
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
2958
|
+
const version = parsed.version;
|
|
2959
|
+
if (typeof version !== "number" || !Number.isInteger(version)) return null;
|
|
2960
|
+
return parsed;
|
|
2961
|
+
}
|
|
2962
|
+
function normalize(record) {
|
|
2963
|
+
const projects = {};
|
|
2964
|
+
const source = record.projects;
|
|
2965
|
+
if (source && typeof source === "object" && !Array.isArray(source)) for (const [key, scope] of Object.entries(source)) projects[key] = normalizeScope(scope);
|
|
2966
|
+
const history = {};
|
|
2967
|
+
if (isObject(record.history)) for (const [key, scope] of Object.entries(record.history)) {
|
|
2968
|
+
const normalized = normalizeHistory(scope);
|
|
2969
|
+
if (Object.keys(normalized).length) history[key] = normalized;
|
|
2970
|
+
}
|
|
2971
|
+
const placements = Array.isArray(record.placements) ? record.placements.flatMap(normalizePlacement) : [];
|
|
2972
|
+
const buildMachines = {};
|
|
2973
|
+
if (isObject(record.buildMachines)) {
|
|
2974
|
+
for (const [name, totals] of Object.entries(record.buildMachines)) if (isObject(totals)) buildMachines[name] = normalizeMachineTotals(totals);
|
|
2975
|
+
}
|
|
2976
|
+
return {
|
|
2977
|
+
version: 1,
|
|
2978
|
+
machine: normalizeScope(record.machine),
|
|
2979
|
+
projects,
|
|
2980
|
+
...Object.keys(history).length ? { history } : {},
|
|
2981
|
+
...placements.length ? { placements } : {},
|
|
2982
|
+
...Object.keys(buildMachines).length ? { buildMachines } : {}
|
|
2983
|
+
};
|
|
2984
|
+
}
|
|
2985
|
+
function normalizePlacement(value) {
|
|
2986
|
+
if (!isObject(value)) return [];
|
|
2987
|
+
const { decision, platform, reason, project, machine } = value;
|
|
2988
|
+
if (!PLACEMENT_DECISIONS.includes(decision) || !PLATFORMS.includes(platform)) return [];
|
|
2989
|
+
if (typeof reason !== "string" || typeof project !== "string") return [];
|
|
2990
|
+
const buildMs = wholeMs(value.buildMs);
|
|
2991
|
+
const localEstimateMs = wholeMs(value.localEstimateMs);
|
|
2992
|
+
return [{
|
|
2993
|
+
at: timestamp(value.at),
|
|
2994
|
+
project,
|
|
2995
|
+
platform,
|
|
2996
|
+
decision,
|
|
2997
|
+
reason,
|
|
2998
|
+
...typeof machine === "string" && machine !== "" ? { machine } : {},
|
|
2999
|
+
...buildMs > 0 ? { buildMs } : {},
|
|
3000
|
+
...localEstimateMs > 0 ? { localEstimateMs } : {},
|
|
3001
|
+
...value.failed === true ? { failed: true } : {}
|
|
3002
|
+
}];
|
|
3003
|
+
}
|
|
3004
|
+
function normalizeMachineTotals(totals) {
|
|
3005
|
+
const savedMs = Number(totals.savedMs);
|
|
3006
|
+
return {
|
|
3007
|
+
offloaded: count(totals.offloaded),
|
|
3008
|
+
offloadedMs: count(totals.offloadedMs),
|
|
3009
|
+
savedMs: Number.isFinite(savedMs) ? Math.round(savedMs) : 0,
|
|
3010
|
+
fallbacks: count(totals.fallbacks),
|
|
3011
|
+
...typeof totals.lastOffloadAt === "string" ? { lastOffloadAt: totals.lastOffloadAt } : {},
|
|
3012
|
+
...typeof totals.lastFallbackAt === "string" ? { lastFallbackAt: totals.lastFallbackAt } : {}
|
|
3013
|
+
};
|
|
3014
|
+
}
|
|
3015
|
+
function isObject(value) {
|
|
3016
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
3017
|
+
}
|
|
3018
|
+
function normalizeHistory(scope) {
|
|
3019
|
+
const history = {};
|
|
3020
|
+
if (!isObject(scope)) return history;
|
|
3021
|
+
for (const platform of PLATFORMS) {
|
|
3022
|
+
const lists = scope[platform];
|
|
3023
|
+
if (!isObject(lists)) continue;
|
|
3024
|
+
const normalized = {};
|
|
3025
|
+
for (const outcome of OUTCOMES) {
|
|
3026
|
+
const samples = lists[outcome];
|
|
3027
|
+
if (!Array.isArray(samples)) continue;
|
|
3028
|
+
const kept = samples.filter(isObject).flatMap((sample) => {
|
|
3029
|
+
const durationMs = wholeMs(sample.durationMs);
|
|
3030
|
+
if (durationMs <= 0) return [];
|
|
3031
|
+
return [{
|
|
3032
|
+
at: timestamp(sample.at),
|
|
3033
|
+
durationMs,
|
|
3034
|
+
phases: wholePhases(sample.phases),
|
|
3035
|
+
...typeof sample.deviceSetup === "boolean" ? { deviceSetup: sample.deviceSetup } : {}
|
|
3036
|
+
}];
|
|
3037
|
+
});
|
|
3038
|
+
if (kept.length) normalized[outcome] = trimSamples(kept);
|
|
3039
|
+
}
|
|
3040
|
+
if (Object.keys(normalized).length) history[platform] = normalized;
|
|
3041
|
+
}
|
|
3042
|
+
return history;
|
|
3043
|
+
}
|
|
3044
|
+
function wholePhases(phases) {
|
|
3045
|
+
const out = {};
|
|
3046
|
+
if (!isObject(phases)) return out;
|
|
3047
|
+
for (const [name, ms] of Object.entries(phases)) {
|
|
3048
|
+
const value = Number(ms);
|
|
3049
|
+
if (Number.isFinite(value) && value >= 0) out[name] = Math.round(value);
|
|
3050
|
+
}
|
|
3051
|
+
return out;
|
|
3052
|
+
}
|
|
3053
|
+
function normalizeScope(scope) {
|
|
3054
|
+
const normalized = {};
|
|
3055
|
+
if (!scope || typeof scope !== "object" || Array.isArray(scope)) return normalized;
|
|
3056
|
+
for (const platform of PLATFORMS) {
|
|
3057
|
+
const bucket = scope[platform];
|
|
3058
|
+
if (bucket && typeof bucket === "object" && !Array.isArray(bucket)) normalized[platform] = normalizeBucket(bucket);
|
|
3059
|
+
}
|
|
3060
|
+
return normalized;
|
|
3061
|
+
}
|
|
3062
|
+
function normalizeBucket(bucket) {
|
|
3063
|
+
return {
|
|
3064
|
+
runs: count(bucket.runs),
|
|
3065
|
+
failed: count(bucket.failed),
|
|
3066
|
+
hits: count(bucket.hits),
|
|
3067
|
+
misses: count(bucket.misses),
|
|
3068
|
+
coldRuns: count(bucket.coldRuns),
|
|
3069
|
+
coldRunMs: count(bucket.coldRunMs),
|
|
3070
|
+
hitRuns: count(bucket.hitRuns),
|
|
3071
|
+
hitRunMs: count(bucket.hitRunMs),
|
|
3072
|
+
timeSavedMs: count(bucket.timeSavedMs),
|
|
3073
|
+
firstRunAt: timestamp(bucket.firstRunAt),
|
|
3074
|
+
lastRunAt: timestamp(bucket.lastRunAt),
|
|
3075
|
+
...count(bucket.lastColdBuildMs) > 0 ? { lastColdBuildMs: count(bucket.lastColdBuildMs) } : {},
|
|
3076
|
+
...count(bucket.lastPodsMs) > 0 ? { lastPodsMs: count(bucket.lastPodsMs) } : {},
|
|
3077
|
+
...count(bucket.offloadedRuns) > 0 ? {
|
|
3078
|
+
offloadedRuns: count(bucket.offloadedRuns),
|
|
3079
|
+
offloadedRunMs: count(bucket.offloadedRunMs)
|
|
3080
|
+
} : {},
|
|
3081
|
+
...typeof bucket.lastOffloadHost === "string" ? { lastOffloadHost: bucket.lastOffloadHost } : {}
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
function positive(value) {
|
|
3085
|
+
const ms = wholeMs(value);
|
|
3086
|
+
return ms > 0 ? ms : null;
|
|
3087
|
+
}
|
|
3088
|
+
function wholeMs(value) {
|
|
3089
|
+
const ms = Number(value);
|
|
3090
|
+
return Number.isFinite(ms) && ms > 0 ? Math.round(ms) : 0;
|
|
3091
|
+
}
|
|
3092
|
+
function count(value) {
|
|
3093
|
+
return wholeMs(value);
|
|
3094
|
+
}
|
|
3095
|
+
function timestamp(value) {
|
|
3096
|
+
return typeof value === "string" && value !== "" ? value : (/* @__PURE__ */ new Date(0)).toISOString();
|
|
3097
|
+
}
|
|
3098
|
+
function canonical(path) {
|
|
3099
|
+
try {
|
|
3100
|
+
return realpathSync.native(path);
|
|
3101
|
+
} catch {
|
|
3102
|
+
return resolve(path);
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
function decodeStats(text) {
|
|
3106
|
+
const parsed = parseRecord(text);
|
|
3107
|
+
if (!parsed) return {
|
|
3108
|
+
record: null,
|
|
3109
|
+
newerVersion: null
|
|
3110
|
+
};
|
|
3111
|
+
if (parsed.version > 1) return {
|
|
3112
|
+
record: null,
|
|
3113
|
+
newerVersion: parsed.version
|
|
3114
|
+
};
|
|
3115
|
+
return {
|
|
3116
|
+
record: normalize(parsed),
|
|
3117
|
+
newerVersion: null
|
|
3118
|
+
};
|
|
3119
|
+
}
|
|
3120
|
+
function readStatsReport(key, now) {
|
|
3121
|
+
const { record, note } = readStats();
|
|
3122
|
+
const project = key ? record?.projects[key] : null;
|
|
3123
|
+
return {
|
|
3124
|
+
report: {
|
|
3125
|
+
version: 1,
|
|
3126
|
+
project: key ? {
|
|
3127
|
+
key,
|
|
3128
|
+
ios: project?.ios ?? null,
|
|
3129
|
+
android: project?.android ?? null
|
|
3130
|
+
} : null,
|
|
3131
|
+
machine: {
|
|
3132
|
+
ios: record?.machine.ios ?? null,
|
|
3133
|
+
android: record?.machine.android ?? null
|
|
3134
|
+
},
|
|
3135
|
+
offload: offloadSummary(record, now)
|
|
3136
|
+
},
|
|
3137
|
+
note
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
3140
|
+
function findProjectRoot(startDir) {
|
|
3141
|
+
let dir;
|
|
3142
|
+
try {
|
|
3143
|
+
dir = realpathSync(resolve(startDir));
|
|
3144
|
+
} catch {
|
|
3145
|
+
dir = resolve(startDir);
|
|
3146
|
+
}
|
|
3147
|
+
while (true) {
|
|
3148
|
+
if (existsSync(join(dir, "package.json")) || existsSync(join(dir, "Package.swift"))) return dir;
|
|
3149
|
+
const parent = dirname(dir);
|
|
3150
|
+
if (parent === dir) return null;
|
|
3151
|
+
dir = parent;
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
//#endregion
|
|
3155
|
+
//#region state/device-host.ts
|
|
3156
|
+
function deviceHostRoot() {
|
|
3157
|
+
return join(configDir(), "server", "device-host-sessions");
|
|
3158
|
+
}
|
|
3159
|
+
function deviceHostArea(id) {
|
|
3160
|
+
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error("Invalid hosted session id.");
|
|
3161
|
+
return join(configDir(), "device-host", "sessions", id);
|
|
3162
|
+
}
|
|
3163
|
+
function parseHostedChoice(value) {
|
|
3164
|
+
if (!isJsonObject(value)) return null;
|
|
3165
|
+
if (![
|
|
3166
|
+
"deviceTypeId",
|
|
3167
|
+
"runtimeId",
|
|
3168
|
+
"deviceType",
|
|
3169
|
+
"runtime"
|
|
3170
|
+
].every((key) => typeof value[key] === "string" && value[key])) return null;
|
|
3171
|
+
if (value.architecture !== "arm64" && value.architecture !== "x86_64") return null;
|
|
3172
|
+
return value;
|
|
3173
|
+
}
|
|
3174
|
+
function parseHostedDevice(value) {
|
|
3175
|
+
if (!isJsonObject(value) || value.avdName !== void 0 || !parseHostedChoice(value)) return null;
|
|
3176
|
+
if (typeof value.udid !== "string" || !/^[a-fA-F0-9-]{36}$/.test(value.udid) || typeof value.name !== "string") return null;
|
|
3177
|
+
return value;
|
|
3178
|
+
}
|
|
3179
|
+
function parseHostedAndroidDevice(value) {
|
|
3180
|
+
if (!isJsonObject(value)) return null;
|
|
3181
|
+
if (value.udid !== void 0 || typeof value.avdName !== "string" || !/^stim-[A-Za-z0-9._-]+$/.test(value.avdName) || !hostedConsolePort(value.consolePort) || value.serial !== `emulator-${value.consolePort}` || typeof value.systemImage !== "string" || !/^system-images;android-\d+;[^;\s]+;(arm64-v8a|x86_64)$/.test(value.systemImage) || typeof value.deviceProfile !== "string" || !value.deviceProfile || value.architecture !== "arm64-v8a" && value.architecture !== "x86_64" || !value.systemImage.endsWith(`;${value.architecture}`)) return null;
|
|
3182
|
+
return value;
|
|
3183
|
+
}
|
|
3184
|
+
/** Android Emulator -help-port defines the supported even console ports as 5554 through 5584. */
|
|
3185
|
+
function hostedConsolePort(value) {
|
|
3186
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 5554 && value <= 5584 && value % 2 === 0;
|
|
3187
|
+
}
|
|
3188
|
+
function parseHostedPlatformDevice(value, platform) {
|
|
3189
|
+
return platform === "ios" ? parseHostedDevice(value) : platform === "android" ? parseHostedAndroidDevice(value) : parseHostedMacosDevice(value);
|
|
3190
|
+
}
|
|
3191
|
+
function hostedDeviceId(device) {
|
|
3192
|
+
return "udid" in device ? device.udid : "avdName" in device ? device.avdName : `macos-${device.appSlot}`;
|
|
3193
|
+
}
|
|
3194
|
+
function parseHostedRequest(value) {
|
|
3195
|
+
if (!isJsonObject(value) || value.platform !== "ios" && value.platform !== "android" && value.platform !== "macos") return null;
|
|
3196
|
+
if (typeof value.workspace !== "string" || !value.workspace || value.workspace.length > 4096 || /[\0\r\n]/.test(value.workspace)) return null;
|
|
3197
|
+
if (typeof value.slot !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(value.slot) || [
|
|
3198
|
+
"constructor",
|
|
3199
|
+
"prototype",
|
|
3200
|
+
"__proto__",
|
|
3201
|
+
"web"
|
|
3202
|
+
].includes(value.slot)) return null;
|
|
3203
|
+
if (typeof value.attempt !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(value.attempt)) return null;
|
|
3204
|
+
const selectors = parseHostedOfferRequest(value);
|
|
3205
|
+
return selectors ? {
|
|
3206
|
+
workspace: value.workspace,
|
|
3207
|
+
slot: value.slot,
|
|
3208
|
+
attempt: value.attempt,
|
|
3209
|
+
...selectors
|
|
3210
|
+
} : null;
|
|
3211
|
+
}
|
|
3212
|
+
function parseHostedOfferRequest(value) {
|
|
3213
|
+
if (!isJsonObject(value) || value.platform !== "ios" && value.platform !== "android" && value.platform !== "macos") return null;
|
|
3214
|
+
if ([
|
|
3215
|
+
"deviceType",
|
|
3216
|
+
"runtime",
|
|
3217
|
+
"systemImage",
|
|
3218
|
+
"deviceProfile"
|
|
3219
|
+
].some((key) => value[key] !== void 0 && (typeof value[key] !== "string" || !value[key] || value[key].length > 256 || /[\0\r\n]/.test(value[key])))) return null;
|
|
3220
|
+
if ((value.platform === "ios" ? ["systemImage", "deviceProfile"] : value.platform === "android" ? ["deviceType", "runtime"] : [
|
|
3221
|
+
"deviceType",
|
|
3222
|
+
"runtime",
|
|
3223
|
+
"systemImage",
|
|
3224
|
+
"deviceProfile"
|
|
3225
|
+
]).some((key) => value[key] !== void 0)) return null;
|
|
3226
|
+
return {
|
|
3227
|
+
platform: value.platform,
|
|
3228
|
+
...typeof value.deviceType === "string" ? { deviceType: value.deviceType } : {},
|
|
3229
|
+
...typeof value.runtime === "string" ? { runtime: value.runtime } : {},
|
|
3230
|
+
...typeof value.systemImage === "string" ? { systemImage: value.systemImage } : {},
|
|
3231
|
+
...typeof value.deviceProfile === "string" ? { deviceProfile: value.deviceProfile } : {}
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
function parseHostedNativeOffer(value) {
|
|
3235
|
+
if (!isJsonObject(value) || !isJsonObject(value.resources)) return null;
|
|
3236
|
+
const { resources } = value;
|
|
3237
|
+
if (value.platform !== "ios" && value.platform !== "android" && value.platform !== "macos" || value.declined !== null && (typeof value.declined !== "string" || !value.declined || value.declined.length > 4e3) || !Number.isInteger(resources.cpus) || Number(resources.cpus) < 1 || ["loadPerCore", "memoryFreeBytes"].some((key) => typeof resources[key] !== "number" || !Number.isFinite(resources[key]) || resources[key] < 0) || resources.workerDiskFreeBytes !== null && (typeof resources.workerDiskFreeBytes !== "number" || !Number.isFinite(resources.workerDiskFreeBytes) || resources.workerDiskFreeBytes < 0) || resources.memoryPressure !== null && resources.memoryPressure !== "normal" && resources.memoryPressure !== "warning" && resources.memoryPressure !== "critical") return null;
|
|
3238
|
+
let choice = null;
|
|
3239
|
+
if (value.choice !== null) {
|
|
3240
|
+
if (value.platform === "ios") {
|
|
3241
|
+
const parsed = parseHostedChoice(value.choice);
|
|
3242
|
+
if (!parsed) return null;
|
|
3243
|
+
choice = {
|
|
3244
|
+
deviceType: parsed.deviceType,
|
|
3245
|
+
runtime: parsed.runtime,
|
|
3246
|
+
deviceTypeId: parsed.deviceTypeId,
|
|
3247
|
+
runtimeId: parsed.runtimeId,
|
|
3248
|
+
architecture: parsed.architecture
|
|
3249
|
+
};
|
|
3250
|
+
} else if (value.platform === "macos") {
|
|
3251
|
+
choice = parseHostedMacosChoice(value.choice);
|
|
3252
|
+
if (!choice) return null;
|
|
3253
|
+
} else {
|
|
3254
|
+
const selected = value.choice;
|
|
3255
|
+
if (!isJsonObject(selected) || typeof selected.systemImage !== "string" || !/^system-images;android-\d+;[^;\s]+;(arm64-v8a|x86_64)$/.test(selected.systemImage) || typeof selected.deviceProfile !== "string" || !selected.deviceProfile || selected.architecture !== "arm64-v8a" && selected.architecture !== "x86_64" || !selected.systemImage.endsWith(`;${selected.architecture}`)) return null;
|
|
3256
|
+
choice = {
|
|
3257
|
+
systemImage: selected.systemImage,
|
|
3258
|
+
deviceProfile: selected.deviceProfile,
|
|
3259
|
+
architecture: selected.architecture
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
} else if (!value.declined) return null;
|
|
3263
|
+
return {
|
|
3264
|
+
platform: value.platform,
|
|
3265
|
+
choice,
|
|
3266
|
+
declined: value.declined,
|
|
3267
|
+
resources: {
|
|
3268
|
+
cpus: resources.cpus,
|
|
3269
|
+
loadPerCore: resources.loadPerCore,
|
|
3270
|
+
memoryFreeBytes: resources.memoryFreeBytes,
|
|
3271
|
+
memoryPressure: resources.memoryPressure,
|
|
3272
|
+
workerDiskFreeBytes: resources.workerDiskFreeBytes
|
|
3273
|
+
}
|
|
3274
|
+
};
|
|
3275
|
+
}
|
|
3276
|
+
/** A missing established journal or malformed record is a refusal, never an empty reservation set. */
|
|
3277
|
+
function readHostedSessions() {
|
|
3278
|
+
const root = deviceHostRoot();
|
|
3279
|
+
try {
|
|
3280
|
+
readdirSync(root);
|
|
3281
|
+
} catch (error) {
|
|
3282
|
+
if (error.code === "ENOENT") {
|
|
3283
|
+
try {
|
|
3284
|
+
if (readdirSync(join(configDir(), "device-host", "sessions")).length) throw new Error(`Hosted session journal is missing while worker areas remain: ${root}`, { cause: error });
|
|
3285
|
+
} catch (areaError) {
|
|
3286
|
+
if (areaError.code !== "ENOENT") throw areaError;
|
|
3287
|
+
}
|
|
3288
|
+
return [];
|
|
3289
|
+
}
|
|
3290
|
+
throw error;
|
|
3291
|
+
}
|
|
3292
|
+
const value = JSON.parse(readFileSync(join(root, "sessions.json"), "utf8"));
|
|
3293
|
+
if (!isJsonObject(value) || value.version !== 1 || !Array.isArray(value.sessions)) throw new Error(`Malformed hosted session journal: ${root}`);
|
|
3294
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3295
|
+
return value.sessions.map((entry) => {
|
|
3296
|
+
if (!isJsonObject(entry) || !parseHostedRequest(entry) || typeof entry.id !== "string" || !/^[a-f0-9-]{36}$/.test(entry.id) || ids.has(entry.id) || typeof entry.client !== "string" || !entry.client || typeof entry.createdAt !== "string" || ![
|
|
3297
|
+
"preparing",
|
|
3298
|
+
"ready",
|
|
3299
|
+
"stopping",
|
|
3300
|
+
"stopped",
|
|
3301
|
+
"unknown"
|
|
3302
|
+
].includes(String(entry.state)) || entry.platform === "android" && !hostedConsolePort(entry.consolePort) || (entry.platform === "macos" ? !hostedMacosAppSlot(entry.appSlot) : entry.appSlot !== void 0) || entry.device !== null && !parseHostedPlatformDevice(entry.device, entry.platform) || entry.platform === "macos" && entry.device !== null && entry.device.appSlot !== entry.appSlot || entry.platform === "android" && entry.device !== null && entry.device.consolePort !== entry.consolePort || entry.state === "ready" && entry.device === null || entry.notice !== void 0 && typeof entry.notice !== "string" || entry.appAttempt !== void 0 && (typeof entry.appAttempt !== "string" || !/^[a-zA-Z0-9_-]{1,128}$/.test(entry.appAttempt)) || entry.metroPort !== void 0 && (typeof entry.metroPort !== "number" || !Number.isInteger(entry.metroPort) || entry.metroPort < 1 || entry.metroPort > 65535)) throw new Error(`Malformed hosted session record: ${root}`);
|
|
3303
|
+
ids.add(entry.id);
|
|
3304
|
+
return entry;
|
|
3305
|
+
});
|
|
3306
|
+
}
|
|
3307
|
+
function readHostedDevice(home, platform = "ios") {
|
|
3308
|
+
const device = parseHostedPlatformDevice(JSON.parse(readFileSync(join(home, "hosted-device.json"), "utf8")), platform);
|
|
3309
|
+
if (!device) throw new Error("The hosted device record is malformed.");
|
|
3310
|
+
return device;
|
|
3311
|
+
}
|
|
3312
|
+
/** Hosted sessions use isolated homes: only one exact device of their platform may appear in this ledger. */
|
|
3313
|
+
function assertHostedDeviceLedger(home, id, platform = "ios") {
|
|
3314
|
+
let value;
|
|
3315
|
+
try {
|
|
3316
|
+
value = JSON.parse(readFileSync(join(home, "created-devices.json"), "utf8"));
|
|
3317
|
+
} catch (error) {
|
|
3318
|
+
if (platform === "macos" && error.code === "ENOENT") return;
|
|
3319
|
+
throw error;
|
|
3320
|
+
}
|
|
3321
|
+
if (!isJsonObject(value) || value.version !== 1 || !Array.isArray(value.ios) || value.ios.length !== (platform === "ios" ? 1 : 0) || platform === "ios" && value.ios[0] !== id || !Array.isArray(value.android) || value.android.length !== (platform === "android" ? 1 : 0) || platform === "android" && value.android[0] !== id || !Array.isArray(value.web) || value.web.length !== 0) throw new Error("The hosted device ownership ledger is missing, malformed or names another device.");
|
|
3322
|
+
}
|
|
3323
|
+
//#endregion
|
|
3324
|
+
//#region state/hosted-app.ts
|
|
3325
|
+
const HOSTED_APP_CHUNK_BYTES = 32768;
|
|
3326
|
+
function hostedAppAttempt(value) {
|
|
3327
|
+
return typeof value === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(value);
|
|
3328
|
+
}
|
|
3329
|
+
function hostedAppArea(session, attempt, workerHome) {
|
|
3330
|
+
if (!hostedAppAttempt(attempt)) throw new Error("Invalid hosted app attempt.");
|
|
3331
|
+
if (workerHome) return join(workerHome, "..", "apps", attempt);
|
|
3332
|
+
return join(deviceHostArea(session), "apps", attempt);
|
|
3333
|
+
}
|
|
3334
|
+
/** At most 32 plain arguments of 1024 characters (8192 in total), with no NUL, CR or LF. */
|
|
3335
|
+
function validHostedAppArguments(value) {
|
|
3336
|
+
return Array.isArray(value) && value.length <= 32 && value.every((argument) => typeof argument === "string" && argument.length <= 1024 && !/[\0\r\n]/.test(argument)) && value.reduce((total, argument) => total + argument.length, 0) <= 8192;
|
|
3337
|
+
}
|
|
3338
|
+
function parseHostedAppOffer(value) {
|
|
3339
|
+
if (!isJsonObject(value) || typeof value.session !== "string" || !/^[a-f0-9-]{36}$/.test(value.session) || !hostedAppAttempt(value.attempt) || typeof value.bundleId !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,254}$/.test(value.bundleId) || value.mode !== "development" && value.mode !== "release" || value.devClientScheme !== void 0 && (value.mode !== "development" || typeof value.devClientScheme !== "string" || !/^[a-zA-Z][a-zA-Z0-9+.-]{0,127}$/.test(value.devClientScheme)) || !isJsonObject(value.manifest) || typeof value.manifest.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(value.manifest.sha256) || typeof value.manifest.size !== "number" || !Number.isSafeInteger(value.manifest.size) || value.manifest.size < 1 || value.manifest.size > 8 * 1024 ** 2) return null;
|
|
3340
|
+
if (value.arguments !== void 0 && !validHostedAppArguments(value.arguments)) return null;
|
|
3341
|
+
return {
|
|
3342
|
+
session: value.session,
|
|
3343
|
+
attempt: value.attempt,
|
|
3344
|
+
bundleId: value.bundleId,
|
|
3345
|
+
mode: value.mode,
|
|
3346
|
+
...typeof value.devClientScheme === "string" ? { devClientScheme: value.devClientScheme } : {},
|
|
3347
|
+
...Array.isArray(value.arguments) && value.arguments.length ? { arguments: value.arguments } : {},
|
|
3348
|
+
manifest: {
|
|
3349
|
+
sha256: value.manifest.sha256,
|
|
3350
|
+
size: value.manifest.size
|
|
3351
|
+
}
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
3354
|
+
/** Relative normalized paths, with no file or link as another entry's ancestor. */
|
|
3355
|
+
function parseHostedAppManifest(value) {
|
|
3356
|
+
if (!Array.isArray(value) || !value.length || value.length > 2e4) return null;
|
|
3357
|
+
const files = [];
|
|
3358
|
+
const paths = /* @__PURE__ */ new Set();
|
|
3359
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
3360
|
+
let total = 0;
|
|
3361
|
+
for (const file of value) {
|
|
3362
|
+
if (!isJsonObject(file) || typeof file.path !== "string" || !file.path || file.path.length > 1024 || /[\\\0\r\n]/.test(file.path) || file.path.split("/").some((part) => !part || part === "." || part === "..") || paths.has(file.path.normalize("NFC").toLowerCase()) || ![
|
|
3363
|
+
"file",
|
|
3364
|
+
"exec",
|
|
3365
|
+
"link"
|
|
3366
|
+
].includes(String(file.kind)) || typeof file.size !== "number" || !Number.isSafeInteger(file.size) || file.size < 0 || file.size > (file.kind === "link" ? 1024 : 1024 ** 3) || typeof file.sha256 !== "string" || !/^[0-9a-f]{64}$/.test(file.sha256) || sizes.has(file.sha256) && sizes.get(file.sha256) !== file.size) return null;
|
|
3367
|
+
total += file.size;
|
|
3368
|
+
if (total > 4 * 1024 ** 3) return null;
|
|
3369
|
+
paths.add(file.path.normalize("NFC").toLowerCase());
|
|
3370
|
+
sizes.set(file.sha256, file.size);
|
|
3371
|
+
files.push({
|
|
3372
|
+
path: file.path,
|
|
3373
|
+
kind: file.kind,
|
|
3374
|
+
size: file.size,
|
|
3375
|
+
sha256: file.sha256
|
|
3376
|
+
});
|
|
3377
|
+
}
|
|
3378
|
+
for (const path of paths) {
|
|
3379
|
+
let ancestor = posix.dirname(path);
|
|
3380
|
+
while (ancestor !== ".") {
|
|
3381
|
+
if (paths.has(ancestor)) return null;
|
|
3382
|
+
ancestor = posix.dirname(ancestor);
|
|
3383
|
+
}
|
|
3384
|
+
}
|
|
3385
|
+
if (!files.some((file) => file.path === "Info.plist" && file.kind === "file") && !(files.some((file) => file.path === "Contents/Info.plist" && file.kind === "file") && files.some((file) => file.path.startsWith("Contents/MacOS/") && file.kind === "exec")) && !(files.length === 1 && files[0]?.path === "App.apk" && files[0].kind === "file")) return null;
|
|
3386
|
+
files.sort((a, b) => a.path.localeCompare(b.path));
|
|
3387
|
+
return files;
|
|
3388
|
+
}
|
|
3389
|
+
/** Reads a server-written receipt; malformed or missing established state remains unresolved. */
|
|
3390
|
+
function readHostedAppMetadata(session, attempt, workerHome) {
|
|
3391
|
+
const value = JSON.parse(readFileSync(join(hostedAppArea(session, attempt, workerHome), "receipt.json"), "utf8"));
|
|
3392
|
+
const offer = parseHostedAppOffer(value);
|
|
3393
|
+
if (!offer || offer.session !== session || offer.attempt !== attempt || !isJsonObject(value) || ![
|
|
3394
|
+
"receiving",
|
|
3395
|
+
"installing",
|
|
3396
|
+
"installed",
|
|
3397
|
+
"unknown"
|
|
3398
|
+
].includes(String(value.state)) || value.launched !== null && value.launched !== true && value.launched !== "unverified" || value.notice !== void 0 && typeof value.notice !== "string") throw new Error("The hosted app receipt is malformed.");
|
|
3399
|
+
return {
|
|
3400
|
+
...offer,
|
|
3401
|
+
state: value.state,
|
|
3402
|
+
launched: value.launched,
|
|
3403
|
+
...typeof value.notice === "string" ? { notice: value.notice } : {}
|
|
3404
|
+
};
|
|
3405
|
+
}
|
|
3406
|
+
function readHostedApp(session, attempt, workerHome) {
|
|
3407
|
+
const record = readHostedAppMetadata(session, attempt, workerHome);
|
|
3408
|
+
const manifest = join(hostedAppArea(session, attempt, workerHome), "blobs", record.manifest.sha256);
|
|
3409
|
+
const files = existsSync(manifest) ? parseHostedAppManifest(JSON.parse(readFileSync(manifest, "utf8"))) : [];
|
|
3410
|
+
if (!files || !files.length && record.state !== "receiving") throw new Error("The hosted app manifest is missing or malformed.");
|
|
3411
|
+
return {
|
|
3412
|
+
...record,
|
|
3413
|
+
files
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
//#endregion
|
|
3417
|
+
//#region state/device-host-machines.ts
|
|
3418
|
+
/** An absent store is empty; unreadable or malformed credentials refuse access without discarding pins. */
|
|
3419
|
+
function readDeviceHostMachines() {
|
|
3420
|
+
let value;
|
|
3421
|
+
try {
|
|
3422
|
+
value = readJsonFile(deviceHostMachinesFile());
|
|
3423
|
+
} catch (error) {
|
|
3424
|
+
if (error.code === "ENOENT") return [];
|
|
3425
|
+
throw error;
|
|
3426
|
+
}
|
|
3427
|
+
if (!isJsonObject(value) || value.version !== 1 || !Array.isArray(value.machines)) throw new Error("Invalid device-host machine credential store");
|
|
3428
|
+
const machines = [];
|
|
3429
|
+
for (const entry of value.machines) {
|
|
3430
|
+
if (!isJsonObject(entry) || ![
|
|
3431
|
+
"machine",
|
|
3432
|
+
"nodeId",
|
|
3433
|
+
"dnsName",
|
|
3434
|
+
"deviceId",
|
|
3435
|
+
"deviceToken",
|
|
3436
|
+
"requestedAt"
|
|
3437
|
+
].every((key) => typeof entry[key] === "string" && entry[key]) || entry.state !== "pending" && entry.state !== "approved" || machines.some((each) => each.machine === entry.machine)) throw new Error("Invalid device-host machine credential store");
|
|
3438
|
+
machines.push(entry);
|
|
3439
|
+
}
|
|
3440
|
+
return machines;
|
|
3441
|
+
}
|
|
3442
|
+
//#endregion
|
|
3443
|
+
//#region state/macos.ts
|
|
3444
|
+
function processRecord(value) {
|
|
3445
|
+
if (!value || typeof value !== "object") return void 0;
|
|
3446
|
+
const { pid, processToken, startedAtMicros } = value;
|
|
3447
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || typeof processToken !== "string") return void 0;
|
|
3448
|
+
if (!Number.isSafeInteger(startedAtMicros) || startedAtMicros <= 0) return void 0;
|
|
3449
|
+
return {
|
|
3450
|
+
pid,
|
|
3451
|
+
processToken,
|
|
3452
|
+
startedAtMicros
|
|
3453
|
+
};
|
|
3454
|
+
}
|
|
3455
|
+
function parseMacosRecord(value) {
|
|
3456
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
3457
|
+
const { launchId, arguments: args, product, bundle, bundleId, executable, build, supervisor, app, host, hostLaunched } = value;
|
|
3458
|
+
if (typeof launchId !== "string" || !Array.isArray(args) || args.some((arg) => typeof arg !== "string")) return null;
|
|
3459
|
+
if ([
|
|
3460
|
+
product,
|
|
3461
|
+
bundle,
|
|
3462
|
+
bundleId,
|
|
3463
|
+
executable
|
|
3464
|
+
].some((field) => typeof field !== "string")) return null;
|
|
3465
|
+
if (!build || typeof build !== "object") return null;
|
|
3466
|
+
const b = build;
|
|
3467
|
+
if (![
|
|
3468
|
+
"running",
|
|
3469
|
+
"ok",
|
|
3470
|
+
"failed"
|
|
3471
|
+
].includes(String(b.state)) || typeof b.startedAt !== "string") return null;
|
|
3472
|
+
if (app !== void 0 && !processRecord(app) || supervisor !== void 0 && !processRecord(supervisor)) return null;
|
|
3473
|
+
const placement = host === void 0 ? void 0 : parseHostedMacosPlacement(host);
|
|
3474
|
+
if (placement === null || hostLaunched !== void 0 && typeof hostLaunched !== "boolean" && hostLaunched !== "unverified") return null;
|
|
3475
|
+
return {
|
|
3476
|
+
launchId,
|
|
3477
|
+
arguments: args,
|
|
3478
|
+
product,
|
|
3479
|
+
bundle,
|
|
3480
|
+
bundleId,
|
|
3481
|
+
executable,
|
|
3482
|
+
build: {
|
|
3483
|
+
state: b.state,
|
|
3484
|
+
startedAt: b.startedAt,
|
|
3485
|
+
...typeof b.finishedAt === "string" ? { finishedAt: b.finishedAt } : {},
|
|
3486
|
+
...typeof b.durationMs === "number" ? { durationMs: b.durationMs } : {},
|
|
3487
|
+
...typeof b.error === "string" ? { error: b.error } : {},
|
|
3488
|
+
...typeof b.buildMachine === "string" ? { buildMachine: b.buildMachine } : {},
|
|
3489
|
+
...typeof b.builtOn === "string" ? { builtOn: b.builtOn } : {},
|
|
3490
|
+
...typeof b.errorCode === "string" ? { errorCode: b.errorCode } : {},
|
|
3491
|
+
...typeof b.offloadedTo === "string" ? { offloadedTo: b.offloadedTo } : {},
|
|
3492
|
+
...typeof b.offloadFallback === "string" ? { offloadFallback: b.offloadFallback } : {}
|
|
3493
|
+
},
|
|
3494
|
+
...processRecord(supervisor) ? { supervisor: processRecord(supervisor) } : {},
|
|
3495
|
+
...processRecord(app) ? { app: processRecord(app) } : {},
|
|
3496
|
+
...placement ? { host: placement } : {},
|
|
3497
|
+
...hostLaunched !== void 0 ? { hostLaunched } : {}
|
|
3498
|
+
};
|
|
3499
|
+
}
|
|
3500
|
+
function readMacosRecord(root) {
|
|
3501
|
+
return parseMacosRecord(readWorkspaceState(root)?.macos);
|
|
3502
|
+
}
|
|
3503
|
+
function readHostedMacosApp(home) {
|
|
3504
|
+
try {
|
|
3505
|
+
const root = realpathSync(join(home, "macos-app"));
|
|
3506
|
+
return parseMacosRecord(readJsonObject(join(home, "workspaces", workspaceName(root), "state.json"))?.macos);
|
|
3507
|
+
} catch {
|
|
3508
|
+
return null;
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
function macosAppState(record) {
|
|
3512
|
+
if (!record) return null;
|
|
3513
|
+
const app = record.app ? inspectProcessIdentity(record.app) : "gone";
|
|
3514
|
+
const supervisor = record.supervisor ? inspectProcessIdentity(record.supervisor) : "gone";
|
|
3515
|
+
const interrupted = record.build.state === "running" && (supervisor === "gone" || supervisor === "different");
|
|
3516
|
+
return {
|
|
3517
|
+
...record,
|
|
3518
|
+
...interrupted ? { build: {
|
|
3519
|
+
...record.build,
|
|
3520
|
+
state: "failed",
|
|
3521
|
+
error: "The build process exited before reporting a result."
|
|
3522
|
+
} } : {},
|
|
3523
|
+
state: record.host ? record.supervisor || !record.hostLaunched ? "stopped" : record.hostLaunched === true ? "running" : "unverified" : app === "unknown" || supervisor === "unknown" ? "unverified" : app === "same" ? supervisor === "same" ? "running" : "orphaned" : "stopped"
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
//#endregion
|
|
3527
|
+
//#region state/tailnet.ts
|
|
3528
|
+
const DEFAULT_SERVE_PORT = 7443;
|
|
3529
|
+
function parseMachine(entry) {
|
|
3530
|
+
const match = /^([A-Za-z0-9][A-Za-z0-9.-]*?)(?::(\d{1,5}))?$/.exec(entry.trim());
|
|
3531
|
+
if (!match) return null;
|
|
3532
|
+
const port = match[2] === void 0 ? DEFAULT_SERVE_PORT : Number(match[2]);
|
|
3533
|
+
return port >= 1 && port <= 65535 ? {
|
|
3534
|
+
name: match[1].toLowerCase(),
|
|
3535
|
+
port
|
|
3536
|
+
} : null;
|
|
3537
|
+
}
|
|
3538
|
+
/**
|
|
3539
|
+
* The one peer in `tailscale status --json` whose MagicDNS name is `name` or starts with `name.`. `ID` there is
|
|
3540
|
+
* the node's StableID, the same value `tailscale whois` reports as `Node.StableID`.
|
|
3541
|
+
*/
|
|
3542
|
+
function findPeer(status, name) {
|
|
3543
|
+
const found = (isJsonObject(status) && isJsonObject(status.Peer) ? Object.values(status.Peer) : []).flatMap((peer) => {
|
|
3544
|
+
if (!isJsonObject(peer) || typeof peer.ID !== "string" || typeof peer.DNSName !== "string") return [];
|
|
3545
|
+
const dnsName = peer.DNSName.replace(/\.$/, "").toLowerCase();
|
|
3546
|
+
const addresses = Array.isArray(peer.TailscaleIPs) ? peer.TailscaleIPs.filter((ip) => typeof ip === "string") : [];
|
|
3547
|
+
const address = addresses.find((ip) => !ip.includes(":")) ?? addresses[0];
|
|
3548
|
+
if (!address || !(dnsName === name || dnsName.startsWith(`${name}.`))) return [];
|
|
3549
|
+
return [{
|
|
3550
|
+
nodeId: peer.ID,
|
|
3551
|
+
dnsName,
|
|
3552
|
+
address
|
|
3553
|
+
}];
|
|
3554
|
+
});
|
|
3555
|
+
if (found.length === 0) return "missing";
|
|
3556
|
+
return found.length === 1 ? found[0] : "ambiguous";
|
|
3557
|
+
}
|
|
3558
|
+
/**
|
|
3559
|
+
* Connects to the pinned node's own tailnet address rather than resolving its name, so the socket reaches the
|
|
3560
|
+
* node whose ID was just checked. `tailscale serve` still needs the MagicDNS name for TLS (SNI and certificate)
|
|
3561
|
+
* and for its Host routing.
|
|
3562
|
+
*/
|
|
3563
|
+
function endpoint(peer, port) {
|
|
3564
|
+
const address = peer.address.includes(":") ? `[${peer.address}]` : peer.address;
|
|
3565
|
+
const suffix = port === 443 ? "" : `:${port}`;
|
|
3566
|
+
return {
|
|
3567
|
+
url: `wss://${address}${suffix}`,
|
|
3568
|
+
servername: peer.dnsName,
|
|
3569
|
+
host: `${peer.dnsName}${suffix}`
|
|
3570
|
+
};
|
|
3571
|
+
}
|
|
3572
|
+
/**
|
|
3573
|
+
* The endpoint of a paired machine's pinned node, or why Stim does not connect to it. The name must still
|
|
3574
|
+
* resolve to exactly the pinned node; the token is never sent anywhere else.
|
|
3575
|
+
*/
|
|
3576
|
+
function pinnedEndpoint(credential, current) {
|
|
3577
|
+
const parsed = parseMachine(credential.machine);
|
|
3578
|
+
if (!parsed) return `${credential.machine} is not a tailnet name`;
|
|
3579
|
+
if (!isJsonObject(current)) return "Tailscale is not running";
|
|
3580
|
+
const peer = findPeer(current, parsed.name);
|
|
3581
|
+
if (peer === "missing") return `no peer on this tailnet is named ${parsed.name}`;
|
|
3582
|
+
if (peer === "ambiguous") return `several tailnet peers match ${parsed.name}`;
|
|
3583
|
+
if (peer.nodeId !== credential.nodeId) return `${peer.dnsName} is now node ${peer.nodeId}, not the pinned ${credential.nodeId}; run stim doctor`;
|
|
3584
|
+
return endpoint(peer, parsed.port);
|
|
3585
|
+
}
|
|
3586
|
+
//#endregion
|
|
3587
|
+
//#region state/hosted-logs.ts
|
|
3588
|
+
const HOSTED_LOGS_PAGE_BYTES = 1024 ** 2;
|
|
3589
|
+
const HOSTED_LOGS_FIRST_BYTES = 4 * 1024 ** 2;
|
|
3590
|
+
const LOG_FILE = /^[A-Za-z0-9._-]{1,128}\.ndjson$/;
|
|
3591
|
+
function parseHostedLogsCursor(value) {
|
|
3592
|
+
if (!isJsonObject(value)) return null;
|
|
3593
|
+
const entries = Object.entries(value);
|
|
3594
|
+
if (entries.length > 16) return null;
|
|
3595
|
+
const cursor = {};
|
|
3596
|
+
for (const [name, offset] of entries) {
|
|
3597
|
+
if (!LOG_FILE.test(name) || typeof offset !== "number" || !Number.isSafeInteger(offset) || offset < 0) return null;
|
|
3598
|
+
cursor[name] = offset;
|
|
3599
|
+
}
|
|
3600
|
+
return cursor;
|
|
3601
|
+
}
|
|
3602
|
+
function hostedMacosLogsDir(home) {
|
|
3603
|
+
try {
|
|
3604
|
+
return join(home, "workspaces", workspaceName(realpathSync(join(home, "macos-app"))), "logs");
|
|
3605
|
+
} catch {
|
|
3606
|
+
return null;
|
|
3607
|
+
}
|
|
3608
|
+
}
|
|
3609
|
+
function readSpan(path, from, to) {
|
|
3610
|
+
const fd = openSync(path, "r");
|
|
3611
|
+
try {
|
|
3612
|
+
const buffer = Buffer.alloc(to - from);
|
|
3613
|
+
const read = readSync(fd, buffer, 0, buffer.length, from);
|
|
3614
|
+
return buffer.subarray(0, read);
|
|
3615
|
+
} finally {
|
|
3616
|
+
closeSync(fd);
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
function readLines(path, from, size, limit, aligned) {
|
|
3620
|
+
const bytes = readSpan(path, from, Math.min(size, from + limit));
|
|
3621
|
+
let start = 0;
|
|
3622
|
+
if (!aligned) {
|
|
3623
|
+
const first = bytes.indexOf(10);
|
|
3624
|
+
start = first < 0 ? bytes.length : first + 1;
|
|
3625
|
+
}
|
|
3626
|
+
const end = bytes.lastIndexOf(10) + 1;
|
|
3627
|
+
return {
|
|
3628
|
+
records: end > start ? parseNdjsonText(bytes.subarray(start, end).toString("utf8")) : [],
|
|
3629
|
+
offset: from + Math.max(start, end)
|
|
3630
|
+
};
|
|
3631
|
+
}
|
|
3632
|
+
/**
|
|
3633
|
+
* The records in `dir` after `cursor`, in file order. A file without a cursor entry starts at most
|
|
3634
|
+
* {@link HOSTED_LOGS_FIRST_BYTES} before its end. A file that shrank since the cursor was taken was rotated: the end of
|
|
3635
|
+
* its previous generation is read first, and only as much of it as the page budget allows.
|
|
3636
|
+
*/
|
|
3637
|
+
function readLogsSince(dir, cursor) {
|
|
3638
|
+
const records = [];
|
|
3639
|
+
const next = {};
|
|
3640
|
+
let budget = HOSTED_LOGS_PAGE_BYTES;
|
|
3641
|
+
let more = false;
|
|
3642
|
+
for (const name of logFiles(dir)) {
|
|
3643
|
+
const path = join(dir, name);
|
|
3644
|
+
let size;
|
|
3645
|
+
try {
|
|
3646
|
+
size = statSync(path).size;
|
|
3647
|
+
} catch {
|
|
3648
|
+
continue;
|
|
3649
|
+
}
|
|
3650
|
+
let offset = cursor[name];
|
|
3651
|
+
if (offset !== void 0 && offset > size) {
|
|
3652
|
+
const previous = `${path}.1`;
|
|
3653
|
+
try {
|
|
3654
|
+
const previousSize = statSync(previous).size;
|
|
3655
|
+
if (previousSize > offset) {
|
|
3656
|
+
const from = Math.max(offset, previousSize - budget);
|
|
3657
|
+
records.push(...readLines(previous, from, previousSize, budget, from === offset).records);
|
|
3658
|
+
budget -= previousSize - from;
|
|
3659
|
+
}
|
|
3660
|
+
} catch {}
|
|
3661
|
+
offset = 0;
|
|
3662
|
+
}
|
|
3663
|
+
const from = offset ?? Math.max(0, size - HOSTED_LOGS_FIRST_BYTES);
|
|
3664
|
+
const reading = Math.min(budget, size - from);
|
|
3665
|
+
const page = reading > 0 ? readLines(path, from, size, reading, offset !== void 0 || from === 0) : null;
|
|
3666
|
+
if (page) records.push(...page.records);
|
|
3667
|
+
budget -= reading;
|
|
3668
|
+
next[name] = page ? page.offset : from;
|
|
3669
|
+
if (from + reading < size) more = true;
|
|
3670
|
+
}
|
|
3671
|
+
return {
|
|
3672
|
+
records,
|
|
3673
|
+
cursor: next,
|
|
3674
|
+
more
|
|
3675
|
+
};
|
|
3676
|
+
}
|
|
1619
3677
|
//#endregion
|
|
1620
|
-
export { ANDROID_COMPILER_CACHE_CHOICES, ANDROID_EMULATOR_APPS, ANDROID_PCH_CHOICES, APP_PROCESS_STATES, BUILD_HISTORY_KEY, BUILD_HISTORY_LIMIT, BUILD_PHASES, BUILD_RESULTS, ERROR_SOURCES, IDLE_STOP_KEY, IOS_SIMULATOR_APPS, LAST_BUILD_KEYS, LEVELS, MACHINE_OWNER_KINDS, REMOTE_DEVICE_BACKENDS, SETTINGS, SETTINGS_SCHEMA_FILE, SETTINGS_SCHEMA_URL, SETTING_GROUPS, SETTING_SCOPES, SOURCES, STATUS_ISSUE_CODES, TUNNEL_MODES, WEB_VIEWPORTS, acceptsShape, advanceTail, allMetroPorts, attachExpoErrorContext, buildCriteria, buildDiagnostics, coerceSettingText, compileGrep, configCorruptRepair, configLockPath, createdDevicesFile, createdDevicesLock, easMachineStateRoot, easSessionLedgerFile, easSessionLedgerLock, emulatorLogFile, expectedShape, fileSizes, findEnclosingWorktreeRoot, findProjectByMetroPort, followLogs, getConcurrencyLimits, getConfigPath, getProject, getProjectSetting, getProjectSettings, getRepoSettings, gitMergeCacheDir, isJsonObject, isLayeredSetting, isPathPrefix, isSensitiveReference, lastUseFrom, levelRank, loadConfig, logFiles, markerWindow, parseNdjsonLine, parseNdjsonText, parseSince, queryLogs, readBuildHistory, readCreatedDevices, readEasSessionLedger, readIdleStop, readJsonFile, readJsonObject, readLastBuilds, readLogRecords, readNdjsonGenerations, readStateFile, readWorkspaceState, recordMatches, settingDefinition, settingValueError, settingsJsonSchema, sharedCcache, sharedCompilationCache, sharedGradle, sharedPods, sortByTs, supervisorLogFile, supervisorPidFile, tailRead, validEasSessionClaim, withConfigLock, withStateReadCache, workspaceDerivedData, workspaceGradleBuild, workspaceLastUsed, workspaceLogErrorIndex, workspaceLogsDir, workspaceMetadataFile, workspaceStateFile, workspaceStateLock };
|
|
3678
|
+
export { ACTIVITY_RECENCY_BASES, AGENT_TOOLS, ANDROID_COMPILER_CACHE_CHOICES, ANDROID_EMULATOR_APPS, ANDROID_PCH_CHOICES, APP_PROCESS_STATES, BUILD_HISTORY_KEY, BUILD_HISTORY_LIMIT, BUILD_PHASES, BUILD_RESULTS, DEVICE_IDLE_SHUTDOWN_KEY, DEV_SERVER_STOP_REQUEST_KEY, ENDED_AGENT_RETENTION_MS, ERROR_SOURCES, HISTORY_LIMIT, HOSTED_AGENT_DRIVERS, HOSTED_APP_CHUNK_BYTES, HOSTED_LOGS_FIRST_BYTES, HOSTED_LOGS_PAGE_BYTES, HOSTED_MACOS_APP_SLOTS, IDLE_STOP_KEY, IOS_SIMULATOR_APPS, LAST_BUILD_KEYS, LEVELS, MACHINE_OWNER_KINDS, MEMORY_SOURCES, NATIVE_BUILD_STEPS, OFFLOAD_MODES, PHYSICAL_DEVICE_CONNECTIONS, PLACEMENT_DECISIONS, READY_PHASE_MS, RECORDING_CAP_BYTES, RECORDING_FOOTAGE_MS, RECORDING_PLATFORMS, REMOTE_DEVICE_BACKENDS, SETTINGS, SETTINGS_SCHEMA_FILE, SETTINGS_SCHEMA_URL, SETTING_GROUPS, SETTING_SCOPES, SOURCES, STATS_VERSION, STATUS_ISSUE_CODES, TUNNEL_MODES, WARM_KEY, WEB_PAGE_STATES, WEB_VIEWPORTS, WORKSPACE_AGENT_KEY, WORKSPACE_PHASES, WORKSPACE_STAGE_KINDS, acceptsShape, advanceTail, agentSessionOf, agentSessionsCacheFile, allMetroPorts, assertHostedDeviceLedger, attachExpoErrorContext, buildCriteria, buildDiagnostics, buildMachinesFile, buildMachinesLock, buildSlotPath, buildSlotsDir, buildWorkerRoot, bundleMarkerPlatform, capDrops, closedSegmentName, coerceSettingText, compileGrep, configCorruptRepair, configLockPath, countErrorEntries, createdDevicesFile, createdDevicesLock, decodeStats, deviceHostArea, deviceHostMachinesClaims, deviceHostMachinesFile, deviceHostMachinesLock, deviceHostRoot, deviceViewersDir, diskUsageCacheDir, diskUsageCacheFile, easMachineStateRoot, easSessionLedgerFile, easSessionLedgerLock, emulatorLogFile, endedAgentSessionOf, endpoint, expectedShape, fileSizes, findEnclosingWorktreeRoot, findPeer, findProjectByMetroPort, findProjectRoot, followLogs, footageDrops, getConcurrencyLimits, getConfigPath, getProject, getProjectSetting, getProjectSettings, getRepoSettings, gitMergeCacheDir, hostedAppArea, hostedAppAttempt, hostedConsolePort, hostedDeviceId, hostedMacosAppSlot, hostedMacosBundleId, hostedMacosLogsDir, isJsonObject, isLayeredSetting, isPathPrefix, isSensitiveReference, lastUseFrom, levelRank, listRecordedDevices, listSegments, listStatusCacheEntries, loadConfig, logFiles, machineCapacity, macosAppState, markerWindow, offloadSummary, openSegmentName, parseHostedAgentGrant, parseHostedAndroidDevice, parseHostedAppManifest, parseHostedAppOffer, parseHostedChoice, parseHostedDevice, parseHostedLogsCursor, parseHostedMacosChoice, parseHostedMacosDevice, parseHostedMacosPlacement, parseHostedNativeOffer, parseHostedOfferRequest, parseHostedPlatformDevice, parseHostedRequest, parseMachine, parseMacosRecord, parseMissReason, parseNdjsonLine, parseNdjsonText, parseSince, pinnedEndpoint, pullRequestCacheDir, pullRequestCacheFile, queryLogs, readAgentSessionsCache, readBuildDetail, readBuildHistory, readBuildMachines, readCreatedDevices, readDevServerStop, readDevServerStopRequest, readDeviceHostMachines, readDeviceIdleShutdowns, readDiskUsage, readEasSessionLedger, readEndedAgentSessions, readHostedApp, readHostedAppMetadata, readHostedDevice, readHostedMacosApp, readHostedSessions, readIdleStop, readJsonFile, readJsonObject, readLastBuilds, readLogRecords, readLogsSince, readMacosRecord, readNdjsonGenerations, readPullRequestCache, readRunEstimates, readStateFile, readStats, readStatsReport, readViewedDevices, readWarmRecord, readWorkspaceAgent, readWorkspaceState, recordMatches, recordedBytes, recordingDeviceName, recordingEnabled, saturation, settingDefinition, settingValueError, settingsJsonSchema, sharedCcache, sharedCompilationCache, sharedGradle, sharedPods, sortByTs, statsFile, statsProjectKey, stimBuildDigest, supervisorLogFile, supervisorPidFile, tailRead, trimSamples, tryAcquireBuildSlotClaim, validEasSessionClaim, validHostedAppArguments, wholePhases, withConfigLock, withDerivedFacts, withGitChip, withStateReadCache, workspaceBuildDetailFile, workspaceDerivedData, workspaceEndedAgentsFile, workspaceEndedAgentsLock, workspaceGradleBuild, workspaceLastUsed, workspaceLogErrorIndex, workspaceLogsDir, workspaceMetadataFile, workspaceRecordingsDir, workspaceStateFile, workspaceStateLock };
|