@openscout/scout 0.2.70 → 0.2.73
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/LICENSE +202 -0
- package/README.md +59 -3
- package/bin/openscout-runtime.mjs +23 -2
- package/bin/scout +34 -0
- package/bin/scout.mjs +195 -18
- package/bin/scoutd +0 -0
- package/dist/client/apple-touch-icon.png +0 -0
- package/dist/client/assets/RepoDiffViewer-D8gg36W3.js +56 -0
- package/dist/client/assets/{addon-fit-BNV7JPhj.js → addon-fit-D4KdY-pj.js} +1 -1
- package/dist/client/assets/{addon-webgl-B-_Y5L3F.js → addon-webgl-Bo_tmKBp.js} +1 -1
- package/dist/client/assets/arc.es-D0gujIKy.js +188 -0
- package/dist/client/assets/embed-entry-CUVNwyB_.js +1 -0
- package/dist/client/assets/index-CRkjiso5.js +257 -0
- package/dist/client/assets/index-D_RX2cRb.css +1 -0
- package/dist/client/assets/{xterm-Cx1oABPt.js → xterm-RfflDFod.js} +1 -1
- package/dist/client/favicon-16.png +0 -0
- package/dist/client/favicon-32.png +0 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/favicon.svg +48 -0
- package/dist/client/index.html +42 -2
- package/dist/client/openscout-icon.png +0 -0
- package/dist/client/site.webmanifest +19 -0
- package/dist/client/web-app-icon-192.png +0 -0
- package/dist/client/web-app-icon-512.png +0 -0
- package/dist/drizzle/0000_curly_iron_monger.sql +592 -0
- package/dist/drizzle/0001_invocation-status-columns.sql +7 -0
- package/dist/drizzle/0002_invocation-flight-metadata.sql +21 -0
- package/dist/drizzle/README.md +81 -0
- package/dist/drizzle/meta/0000_snapshot.json +4575 -0
- package/dist/drizzle/meta/0001_snapshot.json +4624 -0
- package/dist/drizzle/meta/0002_snapshot.json +4631 -0
- package/dist/drizzle/meta/_journal.json +27 -0
- package/dist/main.mjs +77080 -60152
- package/dist/node/main.mjs +7607 -0
- package/dist/openscout-terminal-relay.mjs +3830 -152
- package/dist/{pair-supervisor.mjs → pairing-runtime-controller.mjs} +50837 -41212
- package/dist/runtime/base-daemon.mjs +2219 -488
- package/dist/runtime/broker-daemon.mjs +39082 -25118
- package/dist/runtime/broker-process-manager.mjs +2048 -392
- package/dist/runtime/mesh-discover.mjs +2012 -391
- package/dist/scout-control-plane-web.mjs +58128 -34736
- package/dist/scout-web-server.mjs +58128 -34736
- package/dist/statusline.mjs +287 -0
- package/package.json +8 -4
- package/dist/client/assets/index-BJri_z5a.css +0 -1
- package/dist/client/assets/index-Ccjo5BZz.js +0 -199
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// packages/runtime/src/base-daemon.ts
|
|
3
|
-
import { spawn, spawnSync as spawnSync2 } from "child_process";
|
|
4
|
-
import { existsSync as
|
|
5
|
-
import { homedir as
|
|
6
|
-
import { dirname as dirname4, join as
|
|
3
|
+
import { spawn as spawn3, spawnSync as spawnSync2 } from "child_process";
|
|
4
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, openSync, writeFileSync as writeFileSync2 } from "fs";
|
|
5
|
+
import { homedir as homedir6 } from "os";
|
|
6
|
+
import { dirname as dirname4, join as join6, resolve as resolve3 } from "path";
|
|
7
7
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
8
8
|
|
|
9
9
|
// packages/runtime/src/broker-process-manager.ts
|
|
10
|
-
import { spawnSync } from "child_process";
|
|
11
|
-
import {
|
|
12
|
-
import { homedir as
|
|
13
|
-
import { dirname as
|
|
10
|
+
import { spawn as spawn2, spawnSync } from "child_process";
|
|
11
|
+
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
12
|
+
import { homedir as homedir5 } from "os";
|
|
13
|
+
import { dirname as dirname3, join as join5, resolve as resolve2 } from "path";
|
|
14
14
|
import { fileURLToPath } from "url";
|
|
15
15
|
|
|
16
16
|
// packages/runtime/src/broker-api.ts
|
|
17
|
+
import { existsSync } from "fs";
|
|
17
18
|
import { request as httpRequest } from "http";
|
|
19
|
+
var DEFAULT_BROKER_JSON_REQUEST_TIMEOUT_MS = 30000;
|
|
18
20
|
function normalizeBaseUrl(baseUrl) {
|
|
19
21
|
const trimmed = baseUrl.trim();
|
|
20
22
|
if (!trimmed) {
|
|
@@ -61,13 +63,17 @@ async function requestBrokerOverHttp(baseUrl, path, options) {
|
|
|
61
63
|
}
|
|
62
64
|
function shouldFallbackFromUnixSocket(error) {
|
|
63
65
|
const code = error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
64
|
-
return code === "ENOENT" || code === "ECONNREFUSED" || code === "ENOTSOCK" || code === "EACCES" || code === "FailedToOpenSocket";
|
|
66
|
+
return code === "ENOENT" || code === "ECONNREFUSED" || code === "ENOTSOCK" || code === "EACCES" || code === "EINVAL" || code === "FailedToOpenSocket";
|
|
65
67
|
}
|
|
66
68
|
function errorMessage(error) {
|
|
67
69
|
return error instanceof Error ? error.message : String(error);
|
|
68
70
|
}
|
|
69
71
|
function requestBrokerOverUnixSocket(socketPath, baseUrl, path, options) {
|
|
70
72
|
return new Promise((resolve, reject) => {
|
|
73
|
+
if (!existsSync(socketPath)) {
|
|
74
|
+
reject(Object.assign(new Error(`Scout broker socket does not exist: ${socketPath}`), { code: "ENOENT" }));
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
71
77
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
|
72
78
|
const url = new URL(path, normalizedBaseUrl);
|
|
73
79
|
const body = requestBody(options);
|
|
@@ -115,12 +121,19 @@ function requestBrokerOverUnixSocket(socketPath, baseUrl, path, options) {
|
|
|
115
121
|
request.end();
|
|
116
122
|
});
|
|
117
123
|
}
|
|
124
|
+
function defaultBrokerRequestSignal(options) {
|
|
125
|
+
return options.signal ?? AbortSignal.timeout(DEFAULT_BROKER_JSON_REQUEST_TIMEOUT_MS);
|
|
126
|
+
}
|
|
118
127
|
async function requestBrokerWire(baseUrl, path, options) {
|
|
128
|
+
const requestOptions = {
|
|
129
|
+
...options,
|
|
130
|
+
signal: defaultBrokerRequestSignal(options)
|
|
131
|
+
};
|
|
119
132
|
const socketPath = options.socketPath?.trim();
|
|
120
133
|
if (socketPath) {
|
|
121
134
|
try {
|
|
122
135
|
return {
|
|
123
|
-
response: await requestBrokerOverUnixSocket(socketPath, baseUrl, path,
|
|
136
|
+
response: await requestBrokerOverUnixSocket(socketPath, baseUrl, path, requestOptions),
|
|
124
137
|
trace: {
|
|
125
138
|
transport: "unix_socket",
|
|
126
139
|
socketPath
|
|
@@ -131,7 +144,7 @@ async function requestBrokerWire(baseUrl, path, options) {
|
|
|
131
144
|
throw error;
|
|
132
145
|
}
|
|
133
146
|
return {
|
|
134
|
-
response: await requestBrokerOverHttp(baseUrl, path,
|
|
147
|
+
response: await requestBrokerOverHttp(baseUrl, path, requestOptions),
|
|
135
148
|
trace: {
|
|
136
149
|
transport: "http",
|
|
137
150
|
socketPath,
|
|
@@ -141,7 +154,7 @@ async function requestBrokerWire(baseUrl, path, options) {
|
|
|
141
154
|
}
|
|
142
155
|
}
|
|
143
156
|
return {
|
|
144
|
-
response: await requestBrokerOverHttp(baseUrl, path,
|
|
157
|
+
response: await requestBrokerOverHttp(baseUrl, path, requestOptions),
|
|
145
158
|
trace: {
|
|
146
159
|
transport: "http"
|
|
147
160
|
}
|
|
@@ -172,7 +185,6 @@ async function requestScoutBrokerJsonWithTrace(baseUrl, path, options = {}) {
|
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
// packages/runtime/src/support-paths.ts
|
|
175
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
|
176
188
|
import { homedir } from "os";
|
|
177
189
|
import { join } from "path";
|
|
178
190
|
var OPENSCOUT_RPC_CUTOVER_MARKER = "rpc-runtime-cutover-v1";
|
|
@@ -182,6 +194,8 @@ function resolveOpenScoutSupportPaths() {
|
|
|
182
194
|
const logsDirectory = join(supportDirectory, "logs");
|
|
183
195
|
const runtimeDirectory = join(supportDirectory, "runtime");
|
|
184
196
|
const catalogDirectory = join(supportDirectory, "catalog");
|
|
197
|
+
const controlHome = process.env.OPENSCOUT_CONTROL_HOME ?? join(home, ".openscout", "control-plane");
|
|
198
|
+
const knowledgeDirectory = join(controlHome, "knowledge");
|
|
185
199
|
return {
|
|
186
200
|
supportDirectory,
|
|
187
201
|
logsDirectory,
|
|
@@ -193,42 +207,1564 @@ function resolveOpenScoutSupportPaths() {
|
|
|
193
207
|
settingsPath: join(supportDirectory, "settings.json"),
|
|
194
208
|
harnessCatalogPath: join(catalogDirectory, "harness-catalog.json"),
|
|
195
209
|
relayAgentsRegistryPath: join(supportDirectory, "relay-agents.json"),
|
|
210
|
+
managedInstallsPath: join(supportDirectory, "managed-installs.json"),
|
|
211
|
+
hostInfoPath: join(supportDirectory, ".host-info"),
|
|
196
212
|
relayHubDirectory: process.env.OPENSCOUT_RELAY_HUB ?? join(home, ".openscout", "relay"),
|
|
197
|
-
controlHome
|
|
213
|
+
controlHome,
|
|
214
|
+
knowledgeDirectory,
|
|
215
|
+
knowledgeQmdDirectory: join(knowledgeDirectory, "qmd"),
|
|
216
|
+
knowledgeSqlitePath: join(knowledgeDirectory, "knowledge.sqlite"),
|
|
198
217
|
desktopStatusPath: join(supportDirectory, "agent-status.json"),
|
|
199
218
|
workspaceStatePath: join(supportDirectory, "workspace-state.json"),
|
|
200
219
|
cutoverMarkerPath: join(supportDirectory, OPENSCOUT_RPC_CUTOVER_MARKER)
|
|
201
220
|
};
|
|
202
221
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
222
|
+
|
|
223
|
+
// packages/runtime/src/open-scout-network.ts
|
|
224
|
+
import { readFileSync } from "fs";
|
|
225
|
+
var DEFAULT_OPENSCOUT_NETWORK_RENDEZVOUS_URL = "https://mesh.oscout.net";
|
|
226
|
+
var DEFAULT_OPENSCOUT_NETWORK_PAIRING_RELAY_URL = "wss://mesh.oscout.net/v1/relay";
|
|
227
|
+
function defaultOpenScoutNetworkSettings() {
|
|
228
|
+
return {
|
|
229
|
+
discoveryEnabled: false,
|
|
230
|
+
rendezvousUrl: DEFAULT_OPENSCOUT_NETWORK_RENDEZVOUS_URL,
|
|
231
|
+
pairingRelayUrl: DEFAULT_OPENSCOUT_NETWORK_PAIRING_RELAY_URL,
|
|
232
|
+
keepPairingRelayRunning: true
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function normalizeOpenScoutNetworkSettings(input) {
|
|
236
|
+
const base = defaultOpenScoutNetworkSettings();
|
|
237
|
+
const record = isRecord(input) ? input : {};
|
|
238
|
+
return {
|
|
239
|
+
discoveryEnabled: typeof record.discoveryEnabled === "boolean" ? record.discoveryEnabled : base.discoveryEnabled,
|
|
240
|
+
rendezvousUrl: normalizeUrlString(record.rendezvousUrl, base.rendezvousUrl),
|
|
241
|
+
pairingRelayUrl: normalizeUrlString(record.pairingRelayUrl, base.pairingRelayUrl),
|
|
242
|
+
keepPairingRelayRunning: typeof record.keepPairingRelayRunning === "boolean" ? record.keepPairingRelayRunning : base.keepPairingRelayRunning
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function readOpenScoutNetworkSettingsSync() {
|
|
246
|
+
try {
|
|
247
|
+
const raw = readFileSync(resolveOpenScoutSupportPaths().settingsPath, "utf8");
|
|
248
|
+
const parsed = JSON.parse(raw);
|
|
249
|
+
const network = isRecord(parsed) && isRecord(parsed.network) ? parsed.network : {};
|
|
250
|
+
return normalizeOpenScoutNetworkSettings(isRecord(network.openScoutNetwork) ? network.openScoutNetwork : {});
|
|
251
|
+
} catch {
|
|
252
|
+
return defaultOpenScoutNetworkSettings();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function openScoutNetworkDiscoveryEnabled(env = process.env) {
|
|
256
|
+
const explicit = readBooleanEnv(env.OPENSCOUT_NETWORK_DISCOVERY_ENABLED) ?? readBooleanEnv(env.OPENSCOUT_OSN_DISCOVERY_ENABLED);
|
|
257
|
+
if (explicit !== undefined) {
|
|
258
|
+
return explicit;
|
|
259
|
+
}
|
|
260
|
+
return readOpenScoutNetworkSettingsSync().discoveryEnabled;
|
|
261
|
+
}
|
|
262
|
+
function openScoutNetworkServiceEnvironment(env = process.env) {
|
|
263
|
+
const settings = readOpenScoutNetworkSettingsSync();
|
|
264
|
+
if (!settings.discoveryEnabled) {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
const next = {};
|
|
268
|
+
if (!hasEnv(env, "OPENSCOUT_MESH_RENDEZVOUS_URL")) {
|
|
269
|
+
next.OPENSCOUT_MESH_RENDEZVOUS_URL = settings.rendezvousUrl;
|
|
270
|
+
}
|
|
271
|
+
if (!hasEnv(env, "OPENSCOUT_PAIRING_RELAY_URL") && !hasEnv(env, "OPENSCOUT_MOBILE_PAIRING_RELAY_URL")) {
|
|
272
|
+
next.OPENSCOUT_PAIRING_RELAY_URL = settings.pairingRelayUrl;
|
|
273
|
+
}
|
|
274
|
+
return next;
|
|
275
|
+
}
|
|
276
|
+
function normalizeUrlString(value, fallback) {
|
|
277
|
+
if (typeof value !== "string") {
|
|
278
|
+
return fallback;
|
|
279
|
+
}
|
|
280
|
+
const trimmed = value.trim().replace(/\/$/, "");
|
|
281
|
+
return trimmed || fallback;
|
|
282
|
+
}
|
|
283
|
+
function readBooleanEnv(value) {
|
|
284
|
+
const normalized = value?.trim().toLowerCase();
|
|
285
|
+
if (!normalized)
|
|
286
|
+
return;
|
|
287
|
+
if (["1", "true", "yes", "on"].includes(normalized))
|
|
288
|
+
return true;
|
|
289
|
+
if (["0", "false", "no", "off"].includes(normalized))
|
|
290
|
+
return false;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
function hasEnv(env, key) {
|
|
294
|
+
return typeof env[key] === "string" && env[key].trim().length > 0;
|
|
295
|
+
}
|
|
296
|
+
function isRecord(value) {
|
|
297
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// packages/runtime/src/tailscale.ts
|
|
301
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
302
|
+
import { execFileSync } from "child_process";
|
|
303
|
+
|
|
304
|
+
// packages/runtime/src/system-probes/tailscale-status.ts
|
|
305
|
+
import { readFile } from "fs/promises";
|
|
306
|
+
|
|
307
|
+
// packages/runtime/src/system-probes/registry.ts
|
|
308
|
+
var DEFAULT_MIN_MAX_STALE_MS = 2 * 60000;
|
|
309
|
+
var PROBE_RUN_OUTPUT = Symbol("openscout.probeRunOutput");
|
|
310
|
+
var registeredProbes = [];
|
|
311
|
+
|
|
312
|
+
class ProbeBackendError extends Error {
|
|
313
|
+
backend;
|
|
314
|
+
fallbackSince;
|
|
315
|
+
fallbackReason;
|
|
316
|
+
code;
|
|
317
|
+
timedOut;
|
|
318
|
+
constructor(message, metadata, cause) {
|
|
319
|
+
super(message);
|
|
320
|
+
this.name = "ProbeBackendError";
|
|
321
|
+
this.backend = metadata.backend;
|
|
322
|
+
this.fallbackSince = metadata.fallbackSince;
|
|
323
|
+
this.fallbackReason = metadata.fallbackReason;
|
|
324
|
+
const details = probeErrorDetails(cause);
|
|
325
|
+
if (details.code) {
|
|
326
|
+
this.code = details.code;
|
|
327
|
+
}
|
|
328
|
+
if (details.timedOut !== undefined) {
|
|
329
|
+
this.timedOut = details.timedOut;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function probeRunOutput(value, metadata) {
|
|
334
|
+
return {
|
|
335
|
+
[PROBE_RUN_OUTPUT]: true,
|
|
336
|
+
value,
|
|
337
|
+
...metadata
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
function isProbeRunOutput(value) {
|
|
341
|
+
return typeof value === "object" && value !== null && value[PROBE_RUN_OUTPUT] === true;
|
|
342
|
+
}
|
|
343
|
+
function backendMetadataFromError(error) {
|
|
344
|
+
if (error instanceof ProbeBackendError) {
|
|
345
|
+
return {
|
|
346
|
+
backend: error.backend,
|
|
347
|
+
fallbackSince: error.fallbackSince,
|
|
348
|
+
fallbackReason: error.fallbackReason
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (typeof error === "object" && error !== null) {
|
|
352
|
+
const record = error;
|
|
353
|
+
if (record.backend === "local" || record.backend === "scoutd" || record.backend === "local-fallback") {
|
|
354
|
+
return {
|
|
355
|
+
backend: record.backend,
|
|
356
|
+
fallbackSince: typeof record.fallbackSince === "number" ? record.fallbackSince : undefined,
|
|
357
|
+
fallbackReason: typeof record.fallbackReason === "string" ? record.fallbackReason : undefined
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
function probeErrorDetails(error) {
|
|
364
|
+
if (typeof error !== "object" || error === null) {
|
|
365
|
+
return {};
|
|
366
|
+
}
|
|
367
|
+
const record = error;
|
|
368
|
+
const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : undefined;
|
|
369
|
+
const timedOut = record.timedOut === true || code === "timeout" ? true : record.timedOut === false ? false : undefined;
|
|
370
|
+
return { code, timedOut };
|
|
371
|
+
}
|
|
372
|
+
function assertProbeSpec(spec) {
|
|
373
|
+
if (!spec.id.trim()) {
|
|
374
|
+
throw new Error("Probe id is required");
|
|
375
|
+
}
|
|
376
|
+
if (!Number.isFinite(spec.ttlMs) || spec.ttlMs <= 0) {
|
|
377
|
+
throw new Error(`Probe ${spec.id} must declare a positive ttlMs`);
|
|
378
|
+
}
|
|
379
|
+
if (!Number.isFinite(spec.timeoutMs) || spec.timeoutMs <= 0) {
|
|
380
|
+
throw new Error(`Probe ${spec.id} must declare a positive timeoutMs`);
|
|
381
|
+
}
|
|
382
|
+
if (spec.maxStaleMs !== undefined && (!Number.isFinite(spec.maxStaleMs) || spec.maxStaleMs <= 0)) {
|
|
383
|
+
throw new Error(`Probe ${spec.id} maxStaleMs must be positive when set`);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
function maxStaleMsFor(spec) {
|
|
387
|
+
return spec.maxStaleMs ?? Math.max(DEFAULT_MIN_MAX_STALE_MS, spec.ttlMs * 10);
|
|
388
|
+
}
|
|
389
|
+
function failureBackoffMs(consecutiveFailures) {
|
|
390
|
+
if (consecutiveFailures <= 0) {
|
|
391
|
+
return 0;
|
|
392
|
+
}
|
|
393
|
+
return Math.min(30000, 1000 * 2 ** Math.min(consecutiveFailures - 1, 5));
|
|
394
|
+
}
|
|
395
|
+
function probeErrorFromUnknown(error, at, timedOut = false) {
|
|
396
|
+
if (typeof error === "object" && error !== null) {
|
|
397
|
+
const record = error;
|
|
398
|
+
const code = typeof record.code === "string" && record.code.trim() ? record.code.trim() : timedOut ? "timeout" : typeof record.name === "string" && record.name.trim() ? record.name.trim() : "error";
|
|
399
|
+
const message = typeof record.message === "string" && record.message.trim() ? record.message : String(error);
|
|
400
|
+
return { code, message, at, ...timedOut ? { timedOut: true } : {} };
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
code: timedOut ? "timeout" : "error",
|
|
404
|
+
message: String(error),
|
|
405
|
+
at,
|
|
406
|
+
...timedOut ? { timedOut: true } : {}
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function timeoutError(probeId, timeoutMs) {
|
|
410
|
+
return {
|
|
411
|
+
code: "timeout",
|
|
412
|
+
message: `Probe ${probeId} timed out after ${timeoutMs}ms`,
|
|
413
|
+
at: Date.now(),
|
|
414
|
+
timedOut: true
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
function staleTooLongError(probeId, ageMs, maxStaleMs) {
|
|
418
|
+
return {
|
|
419
|
+
code: "max_stale_exceeded",
|
|
420
|
+
message: `Probe ${probeId} last good snapshot is ${ageMs}ms old, exceeding maxStaleMs ${maxStaleMs}`,
|
|
421
|
+
at: Date.now()
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
function isFreshEnough(state, maxAgeMs) {
|
|
425
|
+
if (state.at === null || state.invalidated) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
return Date.now() - state.at <= maxAgeMs;
|
|
429
|
+
}
|
|
430
|
+
function logBackendTransition(input) {
|
|
431
|
+
if (input.from === input.to) {
|
|
206
432
|
return;
|
|
207
433
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
mkdirSync(paths.supportDirectory, { recursive: true });
|
|
212
|
-
writeFileSync(paths.cutoverMarkerPath, `${Date.now()}
|
|
213
|
-
`, "utf8");
|
|
434
|
+
const keySuffix = input.key === undefined ? "" : ` key=${JSON.stringify(input.key)}`;
|
|
435
|
+
const reasonSuffix = input.reason ? ` (${input.reason})` : "";
|
|
436
|
+
console.warn(`[openscout] system probe ${input.id}${keySuffix} backend ${input.from} -> ${input.to}${reasonSuffix}`);
|
|
214
437
|
}
|
|
215
438
|
|
|
216
|
-
|
|
217
|
-
|
|
439
|
+
class ProbeInstance {
|
|
440
|
+
spec;
|
|
441
|
+
key;
|
|
442
|
+
runProbe;
|
|
443
|
+
scheduleRun;
|
|
444
|
+
state = {
|
|
445
|
+
value: null,
|
|
446
|
+
at: null,
|
|
447
|
+
error: null,
|
|
448
|
+
consecutiveFailures: 0,
|
|
449
|
+
inFlight: null,
|
|
450
|
+
invalidated: false,
|
|
451
|
+
invalidationReason: null,
|
|
452
|
+
invalidationSerial: 0,
|
|
453
|
+
nextRetryAt: 0,
|
|
454
|
+
backend: "local",
|
|
455
|
+
fallbackSince: null,
|
|
456
|
+
fallbackReason: null
|
|
457
|
+
};
|
|
458
|
+
metricState;
|
|
459
|
+
lastAccessAt = Date.now();
|
|
460
|
+
constructor(options) {
|
|
461
|
+
this.spec = options.spec;
|
|
462
|
+
this.key = options.key;
|
|
463
|
+
this.runProbe = options.run;
|
|
464
|
+
this.scheduleRun = options.scheduleRun ?? ((task) => task());
|
|
465
|
+
this.metricState = {
|
|
466
|
+
id: options.spec.id,
|
|
467
|
+
...options.key !== undefined ? { key: options.key } : {},
|
|
468
|
+
backend: "local",
|
|
469
|
+
runCount: 0,
|
|
470
|
+
failureCount: 0,
|
|
471
|
+
timeoutCount: 0,
|
|
472
|
+
staleServedCount: 0,
|
|
473
|
+
lastRunAt: null,
|
|
474
|
+
lastDurationMs: null,
|
|
475
|
+
lastSuccessAt: null
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
read() {
|
|
479
|
+
this.touch();
|
|
480
|
+
if (this.shouldRefreshForRead(Date.now())) {
|
|
481
|
+
this.ensureRefresh(false, this.spec.ttlMs);
|
|
482
|
+
}
|
|
483
|
+
const snap = this.snapshot();
|
|
484
|
+
if (snap.status === "stale" || snap.status === "failed" && snap.at !== null) {
|
|
485
|
+
this.metricState.staleServedCount += 1;
|
|
486
|
+
}
|
|
487
|
+
return snap;
|
|
488
|
+
}
|
|
489
|
+
async fresh(options = {}) {
|
|
490
|
+
this.touch();
|
|
491
|
+
const maxAgeMs = options.maxAgeMs ?? this.spec.ttlMs;
|
|
492
|
+
for (let attempt = 0;attempt < 3; attempt += 1) {
|
|
493
|
+
if (isFreshEnough(this.state, maxAgeMs)) {
|
|
494
|
+
return this.snapshot();
|
|
495
|
+
}
|
|
496
|
+
await this.ensureRefresh(true, maxAgeMs);
|
|
497
|
+
if (!this.state.invalidated) {
|
|
498
|
+
return this.snapshot();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return this.snapshot();
|
|
502
|
+
}
|
|
503
|
+
snapshot() {
|
|
504
|
+
const now = Date.now();
|
|
505
|
+
const ageMs = this.state.at === null ? null : Math.max(0, now - this.state.at);
|
|
506
|
+
const maxStaleMs = maxStaleMsFor(this.spec);
|
|
507
|
+
const exceededMaxStale = ageMs !== null && ageMs > maxStaleMs;
|
|
508
|
+
const fresh = ageMs !== null && !this.state.invalidated && ageMs <= this.spec.ttlMs;
|
|
509
|
+
const status = this.state.at === null ? this.state.error ? "failed" : "empty" : exceededMaxStale ? "failed" : fresh ? "fresh" : "stale";
|
|
510
|
+
const error = exceededMaxStale ? this.state.error ?? staleTooLongError(this.spec.id, ageMs ?? 0, maxStaleMs) : this.state.error;
|
|
511
|
+
return {
|
|
512
|
+
id: this.spec.id,
|
|
513
|
+
...this.key !== undefined ? { key: this.key } : {},
|
|
514
|
+
value: status === "failed" && exceededMaxStale ? null : this.state.value,
|
|
515
|
+
at: this.state.at,
|
|
516
|
+
ageMs,
|
|
517
|
+
stale: status === "stale" || status === "failed" && this.state.at !== null,
|
|
518
|
+
refreshing: this.state.inFlight !== null,
|
|
519
|
+
status,
|
|
520
|
+
error,
|
|
521
|
+
consecutiveFailures: this.state.consecutiveFailures,
|
|
522
|
+
backend: this.state.backend,
|
|
523
|
+
...this.state.fallbackSince !== null ? { fallbackSince: this.state.fallbackSince } : {},
|
|
524
|
+
...this.state.fallbackReason !== null ? { fallbackReason: this.state.fallbackReason } : {}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
invalidate(reason) {
|
|
528
|
+
this.touch();
|
|
529
|
+
this.state.invalidated = true;
|
|
530
|
+
this.state.invalidationReason = reason ?? null;
|
|
531
|
+
this.state.invalidationSerial += 1;
|
|
532
|
+
this.state.nextRetryAt = 0;
|
|
533
|
+
}
|
|
534
|
+
metrics() {
|
|
535
|
+
return {
|
|
536
|
+
...this.metricState,
|
|
537
|
+
consecutiveFailures: this.state.consecutiveFailures,
|
|
538
|
+
inFlight: this.state.inFlight !== null,
|
|
539
|
+
...this.state.fallbackSince !== null ? { fallbackSince: this.state.fallbackSince } : {},
|
|
540
|
+
...this.state.fallbackReason !== null ? { fallbackReason: this.state.fallbackReason } : {}
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
isRefreshing() {
|
|
544
|
+
return this.state.inFlight !== null;
|
|
545
|
+
}
|
|
546
|
+
touch() {
|
|
547
|
+
this.lastAccessAt = Date.now();
|
|
548
|
+
}
|
|
549
|
+
shouldRefreshForRead(now) {
|
|
550
|
+
if (this.state.inFlight !== null) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
if (this.state.nextRetryAt > now) {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
if (this.state.at === null) {
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
if (this.state.invalidated) {
|
|
560
|
+
return true;
|
|
561
|
+
}
|
|
562
|
+
return now - this.state.at > this.spec.ttlMs;
|
|
563
|
+
}
|
|
564
|
+
ensureRefresh(force, maxAgeMs) {
|
|
565
|
+
if (this.state.inFlight) {
|
|
566
|
+
return this.state.inFlight;
|
|
567
|
+
}
|
|
568
|
+
const now = Date.now();
|
|
569
|
+
if (!force && this.state.nextRetryAt > now) {
|
|
570
|
+
return Promise.resolve();
|
|
571
|
+
}
|
|
572
|
+
const inFlight = this.scheduleRun(() => this.executeRun(maxAgeMs)).finally(() => {
|
|
573
|
+
if (this.state.inFlight === inFlight) {
|
|
574
|
+
this.state.inFlight = null;
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
this.state.inFlight = inFlight;
|
|
578
|
+
return inFlight;
|
|
579
|
+
}
|
|
580
|
+
async executeRun(maxAgeMs) {
|
|
581
|
+
const startedAt = Date.now();
|
|
582
|
+
const previousBackend = this.state.backend;
|
|
583
|
+
const invalidationSerialAtStart = this.state.invalidationSerial;
|
|
584
|
+
const controller = new AbortController;
|
|
585
|
+
let timeout = null;
|
|
586
|
+
this.metricState.runCount += 1;
|
|
587
|
+
this.metricState.lastRunAt = startedAt;
|
|
588
|
+
const ctx = {
|
|
589
|
+
probeId: this.spec.id,
|
|
590
|
+
...this.key !== undefined ? { key: this.key } : {},
|
|
591
|
+
signal: controller.signal,
|
|
592
|
+
timeoutMs: this.spec.timeoutMs,
|
|
593
|
+
maxAgeMs,
|
|
594
|
+
startedAt
|
|
595
|
+
};
|
|
596
|
+
const timeoutProbeError = timeoutError(this.spec.id, this.spec.timeoutMs);
|
|
597
|
+
const runPromise = this.runProbe(ctx);
|
|
598
|
+
runPromise.catch(() => {
|
|
599
|
+
return;
|
|
600
|
+
});
|
|
601
|
+
try {
|
|
602
|
+
const result = await new Promise((resolve, reject) => {
|
|
603
|
+
timeout = setTimeout(() => {
|
|
604
|
+
controller.abort(timeoutProbeError);
|
|
605
|
+
reject(timeoutProbeError);
|
|
606
|
+
}, this.spec.timeoutMs);
|
|
607
|
+
runPromise.then(resolve, reject);
|
|
608
|
+
});
|
|
609
|
+
const output = isProbeRunOutput(result) ? result : probeRunOutput(result, { backend: "local" });
|
|
610
|
+
this.state.value = output.value;
|
|
611
|
+
this.state.at = output.generatedAt ?? Date.now();
|
|
612
|
+
this.state.error = null;
|
|
613
|
+
this.state.consecutiveFailures = 0;
|
|
614
|
+
if (this.state.invalidationSerial === invalidationSerialAtStart) {
|
|
615
|
+
this.state.invalidated = false;
|
|
616
|
+
this.state.invalidationReason = null;
|
|
617
|
+
}
|
|
618
|
+
this.state.nextRetryAt = 0;
|
|
619
|
+
this.state.backend = output.backend;
|
|
620
|
+
this.state.fallbackSince = output.fallbackSince ?? null;
|
|
621
|
+
this.state.fallbackReason = output.fallbackReason ?? null;
|
|
622
|
+
this.metricState.backend = output.backend;
|
|
623
|
+
this.metricState.lastSuccessAt = this.state.at;
|
|
624
|
+
logBackendTransition({
|
|
625
|
+
id: this.spec.id,
|
|
626
|
+
key: this.key,
|
|
627
|
+
from: previousBackend,
|
|
628
|
+
to: output.backend,
|
|
629
|
+
reason: output.fallbackReason
|
|
630
|
+
});
|
|
631
|
+
} catch (error) {
|
|
632
|
+
const timedOut = error === timeoutProbeError || typeof error === "object" && error !== null && (error.code === "timeout" || error.timedOut === true);
|
|
633
|
+
const at = Date.now();
|
|
634
|
+
this.state.error = error === timeoutProbeError ? timeoutProbeError : probeErrorFromUnknown(error, at, timedOut);
|
|
635
|
+
this.state.consecutiveFailures += 1;
|
|
636
|
+
this.state.nextRetryAt = at + failureBackoffMs(this.state.consecutiveFailures);
|
|
637
|
+
const backendMetadata = backendMetadataFromError(error);
|
|
638
|
+
if (backendMetadata) {
|
|
639
|
+
this.state.backend = backendMetadata.backend;
|
|
640
|
+
this.state.fallbackSince = backendMetadata.fallbackSince ?? null;
|
|
641
|
+
this.state.fallbackReason = backendMetadata.fallbackReason ?? null;
|
|
642
|
+
this.metricState.backend = backendMetadata.backend;
|
|
643
|
+
logBackendTransition({
|
|
644
|
+
id: this.spec.id,
|
|
645
|
+
key: this.key,
|
|
646
|
+
from: previousBackend,
|
|
647
|
+
to: backendMetadata.backend,
|
|
648
|
+
reason: backendMetadata.fallbackReason
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
this.metricState.failureCount += 1;
|
|
652
|
+
if (this.state.error.timedOut || this.state.error.code === "timeout") {
|
|
653
|
+
this.metricState.timeoutCount += 1;
|
|
654
|
+
}
|
|
655
|
+
} finally {
|
|
656
|
+
if (timeout) {
|
|
657
|
+
clearTimeout(timeout);
|
|
658
|
+
}
|
|
659
|
+
this.metricState.lastDurationMs = Date.now() - startedAt;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
class FamilyLimiter {
|
|
665
|
+
maxConcurrent;
|
|
666
|
+
active = 0;
|
|
667
|
+
queue = [];
|
|
668
|
+
constructor(maxConcurrent) {
|
|
669
|
+
this.maxConcurrent = maxConcurrent;
|
|
670
|
+
}
|
|
671
|
+
queued() {
|
|
672
|
+
return this.queue.length;
|
|
673
|
+
}
|
|
674
|
+
async run(task) {
|
|
675
|
+
if (this.active >= this.maxConcurrent) {
|
|
676
|
+
await new Promise((resolve) => this.queue.push(resolve));
|
|
677
|
+
}
|
|
678
|
+
this.active += 1;
|
|
679
|
+
try {
|
|
680
|
+
return await task();
|
|
681
|
+
} finally {
|
|
682
|
+
this.active -= 1;
|
|
683
|
+
const next = this.queue.shift();
|
|
684
|
+
if (next) {
|
|
685
|
+
next();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
class ProbeFamily {
|
|
692
|
+
spec;
|
|
693
|
+
entries = new Map;
|
|
694
|
+
limiter;
|
|
695
|
+
constructor(spec) {
|
|
696
|
+
this.spec = spec;
|
|
697
|
+
assertProbeSpec(spec);
|
|
698
|
+
if (!Number.isInteger(spec.maxKeys) || spec.maxKeys <= 0) {
|
|
699
|
+
throw new Error(`Probe family ${spec.id} must declare a positive maxKeys`);
|
|
700
|
+
}
|
|
701
|
+
if (!Number.isFinite(spec.idleKeyTtlMs) || spec.idleKeyTtlMs <= 0) {
|
|
702
|
+
throw new Error(`Probe family ${spec.id} must declare a positive idleKeyTtlMs`);
|
|
703
|
+
}
|
|
704
|
+
if (!Number.isInteger(spec.maxConcurrentKeys) || spec.maxConcurrentKeys <= 0) {
|
|
705
|
+
throw new Error(`Probe family ${spec.id} must declare a positive maxConcurrentKeys`);
|
|
706
|
+
}
|
|
707
|
+
this.limiter = new FamilyLimiter(spec.maxConcurrentKeys);
|
|
708
|
+
}
|
|
709
|
+
for(rawKey) {
|
|
710
|
+
const key = this.normalize(rawKey);
|
|
711
|
+
this.cleanupIdle(Date.now());
|
|
712
|
+
let entry = this.entries.get(key);
|
|
713
|
+
if (!entry) {
|
|
714
|
+
entry = new ProbeInstance({
|
|
715
|
+
spec: this.spec,
|
|
716
|
+
key,
|
|
717
|
+
run: (ctx) => this.spec.run(key, ctx),
|
|
718
|
+
scheduleRun: (task) => this.limiter.run(task)
|
|
719
|
+
});
|
|
720
|
+
this.entries.set(key, entry);
|
|
721
|
+
}
|
|
722
|
+
entry.lastAccessAt = Date.now();
|
|
723
|
+
this.evictLruIfNeeded();
|
|
724
|
+
return entry;
|
|
725
|
+
}
|
|
726
|
+
snapshot(key) {
|
|
727
|
+
return this.for(key).snapshot();
|
|
728
|
+
}
|
|
729
|
+
invalidate(key, reason) {
|
|
730
|
+
this.for(key).invalidate(reason);
|
|
731
|
+
}
|
|
732
|
+
metrics() {
|
|
733
|
+
this.cleanupIdle(Date.now());
|
|
734
|
+
return {
|
|
735
|
+
id: this.spec.id,
|
|
736
|
+
keyCount: this.entries.size,
|
|
737
|
+
maxKeys: this.spec.maxKeys,
|
|
738
|
+
activeRuns: this.limiter.active,
|
|
739
|
+
queuedRuns: this.limiter.queued(),
|
|
740
|
+
keys: Array.from(this.entries.values(), (entry) => entry.metrics())
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
keys() {
|
|
744
|
+
this.cleanupIdle(Date.now());
|
|
745
|
+
return Array.from(this.entries.keys());
|
|
746
|
+
}
|
|
747
|
+
normalize(rawKey) {
|
|
748
|
+
const key = this.spec.normalizeKey(rawKey).trim();
|
|
749
|
+
if (!key) {
|
|
750
|
+
throw new Error(`Probe family ${this.spec.id} normalized an empty key`);
|
|
751
|
+
}
|
|
752
|
+
return key;
|
|
753
|
+
}
|
|
754
|
+
cleanupIdle(now) {
|
|
755
|
+
for (const [key, entry] of this.entries) {
|
|
756
|
+
if (!entry.isRefreshing() && now - entry.lastAccessAt > this.spec.idleKeyTtlMs) {
|
|
757
|
+
this.entries.delete(key);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
evictLruIfNeeded() {
|
|
762
|
+
while (this.entries.size > this.spec.maxKeys) {
|
|
763
|
+
let oldestKey = null;
|
|
764
|
+
let oldestAccess = Number.POSITIVE_INFINITY;
|
|
765
|
+
for (const [key, entry] of this.entries) {
|
|
766
|
+
if (entry.isRefreshing()) {
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (entry.lastAccessAt < oldestAccess) {
|
|
770
|
+
oldestAccess = entry.lastAccessAt;
|
|
771
|
+
oldestKey = key;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
if (!oldestKey) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
this.entries.delete(oldestKey);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
function defineProbe(spec) {
|
|
782
|
+
assertProbeSpec(spec);
|
|
783
|
+
const handle = new ProbeInstance({
|
|
784
|
+
spec,
|
|
785
|
+
run: spec.run
|
|
786
|
+
});
|
|
787
|
+
registeredProbes.push({
|
|
788
|
+
kind: "probe",
|
|
789
|
+
id: spec.id,
|
|
790
|
+
handle
|
|
791
|
+
});
|
|
792
|
+
return handle;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// packages/runtime/src/system-probes/exec.ts
|
|
796
|
+
import { spawn } from "child_process";
|
|
797
|
+
|
|
798
|
+
// packages/runtime/src/system-probes/scoutd-client.ts
|
|
799
|
+
import { existsSync as existsSync2 } from "fs";
|
|
218
800
|
import { homedir as homedir2 } from "os";
|
|
219
|
-
import {
|
|
801
|
+
import { join as join2 } from "path";
|
|
802
|
+
import { Socket } from "net";
|
|
803
|
+
|
|
804
|
+
// packages/runtime/src/system-probes/scout-host-catalog.ts
|
|
805
|
+
var SCOUT_HOST_PROBE_SCHEMA_VERSIONS = {
|
|
806
|
+
"tailscale.status": 1,
|
|
807
|
+
"git.buildInfo": 1,
|
|
808
|
+
"git.revParse": 1,
|
|
809
|
+
"git.diffShortstat": 1,
|
|
810
|
+
"git.statusPorcelain": 1,
|
|
811
|
+
"git.mergeBase": 1,
|
|
812
|
+
"git.logLastCommitUnix": 1,
|
|
813
|
+
"git.worktreeListPorcelain": 1,
|
|
814
|
+
"tmux.sessions": 1,
|
|
815
|
+
"tmux.panes": 1,
|
|
816
|
+
"zellij.sessions": 1,
|
|
817
|
+
"ps.runtime": 1,
|
|
818
|
+
"ps.discovery": 1,
|
|
819
|
+
"ps.cwd": 1,
|
|
820
|
+
"net.listeners": 1,
|
|
821
|
+
"repo.scan": 1,
|
|
822
|
+
"repo.diff": 1
|
|
823
|
+
};
|
|
824
|
+
var SCOUT_HOST_EXEC_VERB_SCHEMA_VERSIONS = {
|
|
825
|
+
"tmux.sendKeys": 1,
|
|
826
|
+
"tmux.sendKeysLiteral": 1,
|
|
827
|
+
"tmux.loadBuffer": 1,
|
|
828
|
+
"tmux.pasteBuffer": 1,
|
|
829
|
+
"tmux.deleteBuffer": 1,
|
|
830
|
+
"tmux.killSession": 1,
|
|
831
|
+
"tmux.newSession": 1,
|
|
832
|
+
"tmux.detachClient": 1,
|
|
833
|
+
"tailscale.cert": 1,
|
|
834
|
+
"reveal.open": 1
|
|
835
|
+
};
|
|
836
|
+
function expectedScoutHostProbeSchemaVersion(probeId) {
|
|
837
|
+
return Object.hasOwn(SCOUT_HOST_PROBE_SCHEMA_VERSIONS, probeId) ? SCOUT_HOST_PROBE_SCHEMA_VERSIONS[probeId] : null;
|
|
838
|
+
}
|
|
839
|
+
function expectedScoutHostExecVerbSchemaVersion(verb) {
|
|
840
|
+
return Object.hasOwn(SCOUT_HOST_EXEC_VERB_SCHEMA_VERSIONS, verb) ? SCOUT_HOST_EXEC_VERB_SCHEMA_VERSIONS[verb] : null;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// packages/runtime/src/system-probes/scoutd-client.ts
|
|
844
|
+
var CAPABILITIES_SCHEMA = "openscout.probe.capabilities/v1";
|
|
845
|
+
var REQUEST_SCHEMA = "openscout.probe.request/v1";
|
|
846
|
+
var SNAPSHOT_SCHEMA = "openscout.probe.snapshot/v1";
|
|
847
|
+
var ERROR_SCHEMA = "openscout.probe.error/v1";
|
|
848
|
+
var EXEC_REQUEST_SCHEMA = "openscout.exec.request/v1";
|
|
849
|
+
var EXEC_RESPONSE_SCHEMA = "openscout.exec.response/v1";
|
|
850
|
+
var CAPABILITY_RECHECK_MS = 1e4;
|
|
851
|
+
var SOCKET_TIMEOUT_MS = 900;
|
|
852
|
+
var MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
853
|
+
function readString(value) {
|
|
854
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
855
|
+
}
|
|
856
|
+
function readNumber(value) {
|
|
857
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
858
|
+
}
|
|
859
|
+
function isRecord2(value) {
|
|
860
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
861
|
+
}
|
|
862
|
+
function fallbackMessage(error) {
|
|
863
|
+
if (error instanceof Error && error.message.trim()) {
|
|
864
|
+
return error.message;
|
|
865
|
+
}
|
|
866
|
+
return String(error);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
class ScoutdExecResponseError extends Error {
|
|
870
|
+
code;
|
|
871
|
+
timedOut;
|
|
872
|
+
constructor(message, options = {}) {
|
|
873
|
+
super(message);
|
|
874
|
+
this.name = "ScoutdExecResponseError";
|
|
875
|
+
this.code = options.code ?? "exec_error";
|
|
876
|
+
this.timedOut = options.timedOut ?? false;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
class ScoutdProbeResponseError extends Error {
|
|
881
|
+
code;
|
|
882
|
+
timedOut;
|
|
883
|
+
constructor(message, options = {}) {
|
|
884
|
+
super(message);
|
|
885
|
+
this.name = "ScoutdProbeResponseError";
|
|
886
|
+
this.code = options.code ?? "probe_error";
|
|
887
|
+
this.timedOut = options.timedOut ?? false;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
function resolveScoutdProbesSocketPath(env = process.env) {
|
|
891
|
+
const explicit = env.OPENSCOUT_PROBES_SOCKET?.trim();
|
|
892
|
+
if (explicit) {
|
|
893
|
+
return explicit;
|
|
894
|
+
}
|
|
895
|
+
const openScoutHome = env.OPENSCOUT_HOME?.trim() || join2(homedir2(), ".openscout");
|
|
896
|
+
return join2(openScoutHome, "run", "scoutd-probes.sock");
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
class ScoutdProbeClient {
|
|
900
|
+
env;
|
|
901
|
+
capabilities = null;
|
|
902
|
+
lastCapabilityCheckAt = null;
|
|
903
|
+
lastError = null;
|
|
904
|
+
daemonObserved = false;
|
|
905
|
+
fallbackByProbe = new Map;
|
|
906
|
+
fallbackByExec = new Map;
|
|
907
|
+
constructor(env = process.env) {
|
|
908
|
+
this.env = env;
|
|
909
|
+
}
|
|
910
|
+
async requestProbe(input) {
|
|
911
|
+
const socketPath = resolveScoutdProbesSocketPath(this.env);
|
|
912
|
+
const socketTimeoutMs = probeSocketTimeoutMs(input.timeoutMs);
|
|
913
|
+
const socketExists = existsSync2(socketPath);
|
|
914
|
+
if (!socketExists) {
|
|
915
|
+
this.capabilities = null;
|
|
916
|
+
this.lastError = null;
|
|
917
|
+
return this.daemonObserved ? this.fallback(input.probeId, input.key, `probe socket is missing: ${socketPath}`) : { state: "local" };
|
|
918
|
+
}
|
|
919
|
+
this.daemonObserved = true;
|
|
920
|
+
const capabilities = await this.ensureCapabilities(socketPath, socketTimeoutMs);
|
|
921
|
+
if (!capabilities) {
|
|
922
|
+
return this.fallback(input.probeId, input.key, this.lastError ?? "probe capabilities unavailable");
|
|
923
|
+
}
|
|
924
|
+
const capability = capabilities.families.get(input.probeId);
|
|
925
|
+
if (!capability) {
|
|
926
|
+
return this.fallback(input.probeId, input.key, `scoutd does not serve ${input.probeId}`);
|
|
927
|
+
}
|
|
928
|
+
const expectedSchemaVersion = expectedScoutHostProbeSchemaVersion(input.probeId);
|
|
929
|
+
if (expectedSchemaVersion === null) {
|
|
930
|
+
return this.fallback(input.probeId, input.key, `client has no compiled schema version for ${input.probeId}`);
|
|
931
|
+
}
|
|
932
|
+
if (capability.schemaVersion !== expectedSchemaVersion) {
|
|
933
|
+
return this.fallback(input.probeId, input.key, `scoutd serves ${input.probeId} schema v${capability.schemaVersion}, expected v${expectedSchemaVersion}`);
|
|
934
|
+
}
|
|
935
|
+
try {
|
|
936
|
+
const response = await requestJson(socketPath, {
|
|
937
|
+
schema: REQUEST_SCHEMA,
|
|
938
|
+
schemaVersion: expectedSchemaVersion,
|
|
939
|
+
probeId: input.probeId,
|
|
940
|
+
key: input.key ?? null,
|
|
941
|
+
maxAgeMs: input.maxAgeMs,
|
|
942
|
+
opTimeoutMs: requestOpTimeoutMs(input.timeoutMs)
|
|
943
|
+
}, { timeoutMs: socketTimeoutMs });
|
|
944
|
+
if (!isRecord2(response) || response.schema !== SNAPSHOT_SCHEMA) {
|
|
945
|
+
throw new Error("scoutd returned an invalid probe snapshot envelope");
|
|
946
|
+
}
|
|
947
|
+
if (response.error) {
|
|
948
|
+
const error = isRecord2(response.error) ? response.error : {};
|
|
949
|
+
const code = readString(error.code) ?? "probe_error";
|
|
950
|
+
const message = readString(error.message) ?? code;
|
|
951
|
+
const timedOut = error.timedOut === true || error.timed_out === true;
|
|
952
|
+
throw new ScoutdProbeResponseError(message, { code, timedOut });
|
|
953
|
+
}
|
|
954
|
+
this.fallbackByProbe.delete(fallbackKey(input.probeId, input.key));
|
|
955
|
+
this.lastError = null;
|
|
956
|
+
return {
|
|
957
|
+
state: "scoutd",
|
|
958
|
+
value: response.value,
|
|
959
|
+
generatedAt: readNumber(response.generatedAt) ?? Date.now(),
|
|
960
|
+
daemonVersion: readString(response.daemonVersion) ?? capabilities.daemonVersion
|
|
961
|
+
};
|
|
962
|
+
} catch (error) {
|
|
963
|
+
if (!(error instanceof ScoutdProbeResponseError)) {
|
|
964
|
+
this.capabilities = null;
|
|
965
|
+
this.lastCapabilityCheckAt = null;
|
|
966
|
+
}
|
|
967
|
+
this.lastError = fallbackMessage(error);
|
|
968
|
+
return this.fallback(input.probeId, input.key, this.lastError);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
async requestExecVerb(input) {
|
|
972
|
+
const socketPath = resolveScoutdProbesSocketPath(this.env);
|
|
973
|
+
const socketTimeoutMs = execSocketTimeoutMs(input.args);
|
|
974
|
+
const socketExists = existsSync2(socketPath);
|
|
975
|
+
if (!socketExists) {
|
|
976
|
+
this.capabilities = null;
|
|
977
|
+
this.lastError = null;
|
|
978
|
+
return this.daemonObserved ? this.execFallback(input.verb, `probe socket is missing: ${socketPath}`) : { state: "local" };
|
|
979
|
+
}
|
|
980
|
+
this.daemonObserved = true;
|
|
981
|
+
const capabilities = await this.ensureCapabilities(socketPath, socketTimeoutMs);
|
|
982
|
+
if (!capabilities) {
|
|
983
|
+
return this.execFallback(input.verb, this.lastError ?? "probe capabilities unavailable");
|
|
984
|
+
}
|
|
985
|
+
const capability = capabilities.verbs.get(input.verb);
|
|
986
|
+
if (!capability) {
|
|
987
|
+
return this.execFallback(input.verb, `scoutd does not serve ${input.verb}`);
|
|
988
|
+
}
|
|
989
|
+
const expectedSchemaVersion = expectedScoutHostExecVerbSchemaVersion(input.verb);
|
|
990
|
+
if (expectedSchemaVersion === null) {
|
|
991
|
+
return this.execFallback(input.verb, `client has no compiled schema version for ${input.verb}`);
|
|
992
|
+
}
|
|
993
|
+
if (capability.schemaVersion !== expectedSchemaVersion) {
|
|
994
|
+
return this.execFallback(input.verb, `scoutd serves ${input.verb} schema v${capability.schemaVersion}, expected v${expectedSchemaVersion}`);
|
|
995
|
+
}
|
|
996
|
+
try {
|
|
997
|
+
const response = await requestJson(socketPath, {
|
|
998
|
+
schema: EXEC_REQUEST_SCHEMA,
|
|
999
|
+
schemaVersion: expectedSchemaVersion,
|
|
1000
|
+
verb: input.verb,
|
|
1001
|
+
args: input.args
|
|
1002
|
+
}, { timeoutMs: socketTimeoutMs });
|
|
1003
|
+
if (!isRecord2(response) || response.schema !== EXEC_RESPONSE_SCHEMA) {
|
|
1004
|
+
throw new Error("scoutd returned an invalid exec response envelope");
|
|
1005
|
+
}
|
|
1006
|
+
if (!response.ok) {
|
|
1007
|
+
const error = isRecord2(response.error) ? response.error : {};
|
|
1008
|
+
const code = readString(error.code) ?? "exec_error";
|
|
1009
|
+
const message = readString(error.message) ?? code;
|
|
1010
|
+
const timedOut = error.timedOut === true || error.timed_out === true;
|
|
1011
|
+
throw new ScoutdExecResponseError(message, { code, timedOut });
|
|
1012
|
+
}
|
|
1013
|
+
this.fallbackByExec.delete(input.verb);
|
|
1014
|
+
this.lastError = null;
|
|
1015
|
+
return {
|
|
1016
|
+
state: "scoutd",
|
|
1017
|
+
value: response.value,
|
|
1018
|
+
daemonVersion: readString(response.daemonVersion) ?? capabilities.daemonVersion
|
|
1019
|
+
};
|
|
1020
|
+
} catch (error) {
|
|
1021
|
+
if (error instanceof ScoutdExecResponseError) {
|
|
1022
|
+
throw error;
|
|
1023
|
+
}
|
|
1024
|
+
this.capabilities = null;
|
|
1025
|
+
this.lastCapabilityCheckAt = null;
|
|
1026
|
+
this.lastError = fallbackMessage(error);
|
|
1027
|
+
return this.execFallback(input.verb, this.lastError);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
diagnostics() {
|
|
1031
|
+
const socketPath = resolveScoutdProbesSocketPath(this.env);
|
|
1032
|
+
return {
|
|
1033
|
+
socketPath,
|
|
1034
|
+
socketExists: existsSync2(socketPath),
|
|
1035
|
+
daemonObserved: this.daemonObserved,
|
|
1036
|
+
daemonVersion: this.capabilities?.daemonVersion ?? null,
|
|
1037
|
+
supportedProbeIds: this.capabilities ? [...this.capabilities.families.keys()].sort() : [],
|
|
1038
|
+
supportedExecVerbs: this.capabilities ? [...this.capabilities.verbs.keys()].sort() : [],
|
|
1039
|
+
lastCapabilityCheckAt: this.lastCapabilityCheckAt,
|
|
1040
|
+
lastError: this.lastError
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
resetForTests() {
|
|
1044
|
+
this.capabilities = null;
|
|
1045
|
+
this.lastCapabilityCheckAt = null;
|
|
1046
|
+
this.lastError = null;
|
|
1047
|
+
this.daemonObserved = false;
|
|
1048
|
+
this.fallbackByProbe.clear();
|
|
1049
|
+
this.fallbackByExec.clear();
|
|
1050
|
+
}
|
|
1051
|
+
async ensureCapabilities(socketPath, timeoutMs = SOCKET_TIMEOUT_MS) {
|
|
1052
|
+
const now = Date.now();
|
|
1053
|
+
if (this.capabilities && this.lastCapabilityCheckAt !== null && now - this.lastCapabilityCheckAt < CAPABILITY_RECHECK_MS) {
|
|
1054
|
+
return this.capabilities;
|
|
1055
|
+
}
|
|
1056
|
+
this.lastCapabilityCheckAt = now;
|
|
1057
|
+
try {
|
|
1058
|
+
const response = await requestJson(socketPath, { schema: CAPABILITIES_SCHEMA }, { timeoutMs });
|
|
1059
|
+
if (!isRecord2(response) || response.schema !== CAPABILITIES_SCHEMA) {
|
|
1060
|
+
throw new Error("scoutd returned an invalid capabilities envelope");
|
|
1061
|
+
}
|
|
1062
|
+
const daemonVersion = readString(response.daemonVersion) ?? "unknown";
|
|
1063
|
+
const families = new Map;
|
|
1064
|
+
if (Array.isArray(response.families)) {
|
|
1065
|
+
for (const entry of response.families) {
|
|
1066
|
+
if (!isRecord2(entry))
|
|
1067
|
+
continue;
|
|
1068
|
+
const probeId = readString(entry.probeId);
|
|
1069
|
+
const schemaVersion = readNumber(entry.schemaVersion);
|
|
1070
|
+
const ttlMs = readNumber(entry.ttlMs);
|
|
1071
|
+
if (probeId && schemaVersion !== null && ttlMs !== null) {
|
|
1072
|
+
families.set(probeId, { probeId, schemaVersion, ttlMs });
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
const verbs = new Map;
|
|
1077
|
+
if (Array.isArray(response.verbs)) {
|
|
1078
|
+
for (const entry of response.verbs) {
|
|
1079
|
+
if (!isRecord2(entry))
|
|
1080
|
+
continue;
|
|
1081
|
+
const verb = readString(entry.verb);
|
|
1082
|
+
const schemaVersion = readNumber(entry.schemaVersion);
|
|
1083
|
+
if (verb && schemaVersion !== null) {
|
|
1084
|
+
verbs.set(verb, { verb, schemaVersion });
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
this.capabilities = { daemonVersion, families, verbs };
|
|
1089
|
+
this.daemonObserved = true;
|
|
1090
|
+
this.lastError = null;
|
|
1091
|
+
return this.capabilities;
|
|
1092
|
+
} catch (error) {
|
|
1093
|
+
this.capabilities = null;
|
|
1094
|
+
this.lastError = fallbackMessage(error);
|
|
1095
|
+
return null;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
fallback(probeId, key, reason) {
|
|
1099
|
+
const id = fallbackKey(probeId, key);
|
|
1100
|
+
let state = this.fallbackByProbe.get(id);
|
|
1101
|
+
if (!state || state.reason !== reason) {
|
|
1102
|
+
state = { since: Date.now(), reason };
|
|
1103
|
+
this.fallbackByProbe.set(id, state);
|
|
1104
|
+
}
|
|
1105
|
+
return {
|
|
1106
|
+
state: "local",
|
|
1107
|
+
fallbackSince: state.since,
|
|
1108
|
+
fallbackReason: state.reason
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
execFallback(verb, reason) {
|
|
1112
|
+
let state = this.fallbackByExec.get(verb);
|
|
1113
|
+
if (!state || state.reason !== reason) {
|
|
1114
|
+
state = { since: Date.now(), reason };
|
|
1115
|
+
this.fallbackByExec.set(verb, state);
|
|
1116
|
+
}
|
|
1117
|
+
return {
|
|
1118
|
+
state: "local",
|
|
1119
|
+
fallbackSince: state.since,
|
|
1120
|
+
fallbackReason: state.reason
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
var singletonClient = null;
|
|
1125
|
+
function getScoutdProbeClient() {
|
|
1126
|
+
singletonClient ??= new ScoutdProbeClient;
|
|
1127
|
+
return singletonClient;
|
|
1128
|
+
}
|
|
1129
|
+
async function runWithScoutdFallback(input) {
|
|
1130
|
+
const client = getScoutdProbeClient();
|
|
1131
|
+
const scoutd = await client.requestProbe({
|
|
1132
|
+
probeId: input.probeId,
|
|
1133
|
+
key: input.key,
|
|
1134
|
+
maxAgeMs: input.ctx.maxAgeMs,
|
|
1135
|
+
timeoutMs: input.ctx.timeoutMs
|
|
1136
|
+
});
|
|
1137
|
+
if (scoutd.state === "scoutd") {
|
|
1138
|
+
return probeRunOutput(scoutd.value, {
|
|
1139
|
+
backend: "scoutd",
|
|
1140
|
+
generatedAt: scoutd.generatedAt
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
const metadata = scoutd.fallbackSince ? {
|
|
1144
|
+
backend: "local-fallback",
|
|
1145
|
+
fallbackSince: scoutd.fallbackSince,
|
|
1146
|
+
fallbackReason: scoutd.fallbackReason ?? "scoutd unavailable"
|
|
1147
|
+
} : { backend: "local" };
|
|
1148
|
+
try {
|
|
1149
|
+
const local = await input.local();
|
|
1150
|
+
return probeRunOutput(local, metadata);
|
|
1151
|
+
} catch (error) {
|
|
1152
|
+
throw new ProbeBackendError(error instanceof Error ? error.message : String(error), metadata, error);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
function probeSocketTimeoutMs(timeoutMs) {
|
|
1156
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) {
|
|
1157
|
+
return SOCKET_TIMEOUT_MS;
|
|
1158
|
+
}
|
|
1159
|
+
return Math.max(SOCKET_TIMEOUT_MS, Math.min(Math.max(0, timeoutMs) + 1000, 31000));
|
|
1160
|
+
}
|
|
1161
|
+
function requestOpTimeoutMs(timeoutMs) {
|
|
1162
|
+
if (timeoutMs === undefined || !Number.isFinite(timeoutMs)) {
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
return Math.max(0, Math.floor(timeoutMs));
|
|
1166
|
+
}
|
|
1167
|
+
function execSocketTimeoutMs(args) {
|
|
1168
|
+
const timeoutMs = typeof args.timeoutMs === "number" && Number.isFinite(args.timeoutMs) ? Math.max(0, args.timeoutMs) : 5000;
|
|
1169
|
+
return Math.max(SOCKET_TIMEOUT_MS, Math.min(timeoutMs + 1000, 31000));
|
|
1170
|
+
}
|
|
1171
|
+
async function requestJson(socketPath, payload, options = {}) {
|
|
1172
|
+
const bun = globalThis.Bun;
|
|
1173
|
+
if (bun?.connect) {
|
|
1174
|
+
return await requestJsonWithBun(bun.connect, socketPath, payload, options);
|
|
1175
|
+
}
|
|
1176
|
+
return await new Promise((resolve, reject) => {
|
|
1177
|
+
const socket = new Socket;
|
|
1178
|
+
let response = "";
|
|
1179
|
+
let settled = false;
|
|
1180
|
+
const finish = (error, value) => {
|
|
1181
|
+
if (settled)
|
|
1182
|
+
return;
|
|
1183
|
+
settled = true;
|
|
1184
|
+
clearTimeout(timer);
|
|
1185
|
+
socket.removeAllListeners();
|
|
1186
|
+
socket.destroy();
|
|
1187
|
+
if (error) {
|
|
1188
|
+
reject(error);
|
|
1189
|
+
} else {
|
|
1190
|
+
resolve(value);
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
const timeoutMs = options.timeoutMs ?? SOCKET_TIMEOUT_MS;
|
|
1194
|
+
const timer = setTimeout(() => {
|
|
1195
|
+
finish(new Error(`scoutd probe socket timed out after ${timeoutMs}ms`));
|
|
1196
|
+
}, timeoutMs);
|
|
1197
|
+
timer.unref?.();
|
|
1198
|
+
const finishFromResponse = () => {
|
|
1199
|
+
if (settled)
|
|
1200
|
+
return;
|
|
1201
|
+
try {
|
|
1202
|
+
const parsed = JSON.parse(response);
|
|
1203
|
+
if (isRecord2(parsed) && parsed.schema === ERROR_SCHEMA) {
|
|
1204
|
+
const error = isRecord2(parsed.error) ? parsed.error : {};
|
|
1205
|
+
const message = readString(error.message) ?? readString(error.code) ?? "scoutd probe request failed";
|
|
1206
|
+
finish(new Error(message));
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
finish(null, parsed);
|
|
1210
|
+
} catch (error) {
|
|
1211
|
+
finish(new Error(`scoutd probe response was not JSON: ${fallbackMessage(error)}`));
|
|
1212
|
+
}
|
|
1213
|
+
};
|
|
1214
|
+
socket.setEncoding("utf8");
|
|
1215
|
+
socket.on("connect", () => {
|
|
1216
|
+
socket.write(`${JSON.stringify(payload)}
|
|
1217
|
+
`);
|
|
1218
|
+
});
|
|
1219
|
+
socket.on("data", (chunk) => {
|
|
1220
|
+
response += chunk;
|
|
1221
|
+
if (Buffer.byteLength(response, "utf8") > MAX_RESPONSE_BYTES) {
|
|
1222
|
+
finish(new Error("scoutd probe response exceeded output limit"));
|
|
1223
|
+
}
|
|
1224
|
+
});
|
|
1225
|
+
socket.on("error", (error) => finish(error));
|
|
1226
|
+
socket.on("end", finishFromResponse);
|
|
1227
|
+
socket.on("close", () => {
|
|
1228
|
+
if (!settled) {
|
|
1229
|
+
if (response.length === 0) {
|
|
1230
|
+
finish(new Error("scoutd probe socket closed without a response"));
|
|
1231
|
+
} else {
|
|
1232
|
+
finishFromResponse();
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
});
|
|
1236
|
+
socket.connect(socketPath);
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
async function requestJsonWithBun(connect, socketPath, payload, options = {}) {
|
|
1240
|
+
return await new Promise((resolve, reject) => {
|
|
1241
|
+
let response = "";
|
|
1242
|
+
let settled = false;
|
|
1243
|
+
let socketRef = null;
|
|
1244
|
+
const decoder = new TextDecoder;
|
|
1245
|
+
const finish = (error, value) => {
|
|
1246
|
+
if (settled)
|
|
1247
|
+
return;
|
|
1248
|
+
settled = true;
|
|
1249
|
+
clearTimeout(timer);
|
|
1250
|
+
try {
|
|
1251
|
+
socketRef?.end?.();
|
|
1252
|
+
} catch {}
|
|
1253
|
+
if (error) {
|
|
1254
|
+
reject(error);
|
|
1255
|
+
} else {
|
|
1256
|
+
resolve(value);
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
const finishFromResponse = () => {
|
|
1260
|
+
if (settled)
|
|
1261
|
+
return;
|
|
1262
|
+
try {
|
|
1263
|
+
const parsed = JSON.parse(response);
|
|
1264
|
+
if (isRecord2(parsed) && parsed.schema === ERROR_SCHEMA) {
|
|
1265
|
+
const error = isRecord2(parsed.error) ? parsed.error : {};
|
|
1266
|
+
const message = readString(error.message) ?? readString(error.code) ?? "scoutd probe request failed";
|
|
1267
|
+
finish(new Error(message));
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
finish(null, parsed);
|
|
1271
|
+
} catch (error) {
|
|
1272
|
+
finish(new Error(`scoutd probe response was not JSON: ${fallbackMessage(error)}`));
|
|
1273
|
+
}
|
|
1274
|
+
};
|
|
1275
|
+
const timeoutMs = options.timeoutMs ?? SOCKET_TIMEOUT_MS;
|
|
1276
|
+
const timer = setTimeout(() => {
|
|
1277
|
+
finish(new Error(`scoutd probe socket timed out after ${timeoutMs}ms`));
|
|
1278
|
+
}, timeoutMs);
|
|
1279
|
+
timer.unref?.();
|
|
1280
|
+
connect({
|
|
1281
|
+
unix: socketPath,
|
|
1282
|
+
socket: {
|
|
1283
|
+
open(socket) {
|
|
1284
|
+
socketRef = socket;
|
|
1285
|
+
socket.write(`${JSON.stringify(payload)}
|
|
1286
|
+
`);
|
|
1287
|
+
},
|
|
1288
|
+
data(_socket, data) {
|
|
1289
|
+
response += decoder.decode(data, { stream: true });
|
|
1290
|
+
if (Buffer.byteLength(response, "utf8") > MAX_RESPONSE_BYTES) {
|
|
1291
|
+
finish(new Error("scoutd probe response exceeded output limit"));
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
close() {
|
|
1295
|
+
if (response.length === 0) {
|
|
1296
|
+
finish(new Error("scoutd probe socket closed without a response"));
|
|
1297
|
+
} else {
|
|
1298
|
+
response += decoder.decode();
|
|
1299
|
+
finishFromResponse();
|
|
1300
|
+
}
|
|
1301
|
+
},
|
|
1302
|
+
error(_socket, error) {
|
|
1303
|
+
finish(error);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}).then((socket) => {
|
|
1307
|
+
socketRef = socket;
|
|
1308
|
+
}, (error) => {
|
|
1309
|
+
finish(error instanceof Error ? error : new Error(String(error)));
|
|
1310
|
+
});
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
function fallbackKey(probeId, key) {
|
|
1314
|
+
return `${probeId}\x00${key ?? ""}`;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// packages/runtime/src/system-probes/exec.ts
|
|
1318
|
+
class ProbeCommandError extends Error {
|
|
1319
|
+
code;
|
|
1320
|
+
exitCode;
|
|
1321
|
+
signal;
|
|
1322
|
+
constructor(message, options) {
|
|
1323
|
+
super(message);
|
|
1324
|
+
this.name = "ProbeCommandError";
|
|
1325
|
+
this.code = options.code;
|
|
1326
|
+
this.exitCode = options.exitCode;
|
|
1327
|
+
this.signal = options.signal;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
var DEFAULT_STDOUT_CAP_BYTES = 1024 * 1024;
|
|
1331
|
+
var DEFAULT_STDERR_CAP_BYTES = 128 * 1024;
|
|
1332
|
+
var SIGKILL_DELAY_MS = 500;
|
|
1333
|
+
var EXEC_SYSTEM_TRANSPORT_METADATA = Symbol.for("openscout.execSystem.transport");
|
|
1334
|
+
var spawnProcess = spawn;
|
|
1335
|
+
function bufferByteLength(chunks) {
|
|
1336
|
+
return chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
|
1337
|
+
}
|
|
1338
|
+
function abortMessage(ctx) {
|
|
1339
|
+
const reason = ctx.signal.reason;
|
|
1340
|
+
if (typeof reason === "object" && reason !== null && "message" in reason) {
|
|
1341
|
+
const message = reason.message;
|
|
1342
|
+
if (typeof message === "string" && message.trim()) {
|
|
1343
|
+
return message;
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
return `Probe ${ctx.probeId} aborted`;
|
|
1347
|
+
}
|
|
1348
|
+
async function execProbeFile(ctx, file, args, options = {}) {
|
|
1349
|
+
const maxStdoutBytes = options.maxStdoutBytes ?? DEFAULT_STDOUT_CAP_BYTES;
|
|
1350
|
+
const maxStderrBytes = options.maxStderrBytes ?? DEFAULT_STDERR_CAP_BYTES;
|
|
1351
|
+
return await new Promise((resolve, reject) => {
|
|
1352
|
+
if (ctx.signal.aborted) {
|
|
1353
|
+
reject(new ProbeCommandError(abortMessage(ctx), { code: "aborted" }));
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
const stdoutChunks = [];
|
|
1357
|
+
const stderrChunks = [];
|
|
1358
|
+
let settled = false;
|
|
1359
|
+
let killTimer = null;
|
|
1360
|
+
const child = spawnProcess(file, [...args], {
|
|
1361
|
+
cwd: options.cwd,
|
|
1362
|
+
env: options.env,
|
|
1363
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
1364
|
+
windowsHide: true
|
|
1365
|
+
});
|
|
1366
|
+
const cleanup = () => {
|
|
1367
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
1368
|
+
if (killTimer) {
|
|
1369
|
+
clearTimeout(killTimer);
|
|
1370
|
+
killTimer = null;
|
|
1371
|
+
}
|
|
1372
|
+
};
|
|
1373
|
+
const fail = (error) => {
|
|
1374
|
+
if (settled) {
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
settled = true;
|
|
1378
|
+
cleanup();
|
|
1379
|
+
if (!child.killed) {
|
|
1380
|
+
child.kill("SIGTERM");
|
|
1381
|
+
}
|
|
1382
|
+
reject(error);
|
|
1383
|
+
};
|
|
1384
|
+
const onAbort = () => {
|
|
1385
|
+
if (settled) {
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
settled = true;
|
|
1389
|
+
ctx.signal.removeEventListener("abort", onAbort);
|
|
1390
|
+
child.kill("SIGTERM");
|
|
1391
|
+
killTimer = setTimeout(() => {
|
|
1392
|
+
child.kill("SIGKILL");
|
|
1393
|
+
}, SIGKILL_DELAY_MS);
|
|
1394
|
+
reject(new ProbeCommandError(abortMessage(ctx), { code: "timeout" }));
|
|
1395
|
+
};
|
|
1396
|
+
ctx.signal.addEventListener("abort", onAbort, { once: true });
|
|
1397
|
+
if (options.input !== undefined) {
|
|
1398
|
+
child.stdin?.end(options.input);
|
|
1399
|
+
}
|
|
1400
|
+
child.stdout?.on("data", (chunk) => {
|
|
1401
|
+
if (settled) {
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
stdoutChunks.push(chunk);
|
|
1405
|
+
if (bufferByteLength(stdoutChunks) > maxStdoutBytes) {
|
|
1406
|
+
fail(new ProbeCommandError(`Probe ${ctx.probeId} stdout exceeded ${maxStdoutBytes} bytes`, { code: "output_cap" }));
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
child.stderr?.on("data", (chunk) => {
|
|
1410
|
+
if (settled) {
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
stderrChunks.push(chunk);
|
|
1414
|
+
if (bufferByteLength(stderrChunks) > maxStderrBytes) {
|
|
1415
|
+
fail(new ProbeCommandError(`Probe ${ctx.probeId} stderr exceeded ${maxStderrBytes} bytes`, { code: "output_cap" }));
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
child.once("error", (error) => {
|
|
1419
|
+
if (settled) {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
settled = true;
|
|
1423
|
+
cleanup();
|
|
1424
|
+
const code = typeof error.code === "string" ? String(error.code) : "spawn";
|
|
1425
|
+
reject(new ProbeCommandError(error.message, { code }));
|
|
1426
|
+
});
|
|
1427
|
+
child.once("close", (exitCode, signal) => {
|
|
1428
|
+
if (settled) {
|
|
1429
|
+
cleanup();
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
settled = true;
|
|
1433
|
+
cleanup();
|
|
1434
|
+
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
1435
|
+
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
1436
|
+
if (exitCode === 0) {
|
|
1437
|
+
resolve({ stdout, stderr, exitCode });
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
reject(new ProbeCommandError(`${file} exited with ${exitCode ?? signal ?? "unknown status"}`, { code: "exit", exitCode, signal }));
|
|
1441
|
+
});
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// packages/runtime/src/system-probes/tailscale-status.ts
|
|
1446
|
+
var DEFAULT_TAILSCALE_STATUS_TIMEOUT_MS = 1500;
|
|
1447
|
+
function parseTailscaleStatusJson(raw) {
|
|
1448
|
+
return JSON.parse(raw);
|
|
1449
|
+
}
|
|
1450
|
+
function parsePeers(status) {
|
|
1451
|
+
const peers = Object.entries(status.Peer ?? {});
|
|
1452
|
+
return peers.map(([fallbackId, peer]) => ({
|
|
1453
|
+
id: peer.ID ?? fallbackId,
|
|
1454
|
+
name: peer.HostName ?? peer.DNSName ?? fallbackId,
|
|
1455
|
+
dnsName: peer.DNSName,
|
|
1456
|
+
addresses: peer.TailscaleIPs ?? [],
|
|
1457
|
+
online: peer.Online ?? false,
|
|
1458
|
+
hostName: peer.HostName,
|
|
1459
|
+
os: peer.OS,
|
|
1460
|
+
tags: peer.Tags ?? []
|
|
1461
|
+
}));
|
|
1462
|
+
}
|
|
1463
|
+
function parseSelf(status) {
|
|
1464
|
+
const self = status.Self;
|
|
1465
|
+
if (!self) {
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
return {
|
|
1469
|
+
id: self.ID ?? self.DNSName ?? self.HostName ?? "self",
|
|
1470
|
+
name: self.HostName ?? self.DNSName ?? "self",
|
|
1471
|
+
dnsName: self.DNSName,
|
|
1472
|
+
addresses: self.TailscaleIPs ?? [],
|
|
1473
|
+
online: self.Online ?? true,
|
|
1474
|
+
hostName: self.HostName,
|
|
1475
|
+
os: self.OS,
|
|
1476
|
+
tailnetName: status.CurrentTailnet?.Name,
|
|
1477
|
+
magicDnsSuffix: status.CurrentTailnet?.MagicDNSSuffix
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function isTailscaleBackendRunning(status) {
|
|
1481
|
+
return (status.BackendState ?? "").trim().toLowerCase() === "running";
|
|
1482
|
+
}
|
|
1483
|
+
function normalizeHost(value) {
|
|
1484
|
+
const normalized = value?.trim().replace(/\.$/, "").toLowerCase();
|
|
1485
|
+
if (!normalized || normalized.includes("/") || /\s/.test(normalized)) {
|
|
1486
|
+
return null;
|
|
1487
|
+
}
|
|
1488
|
+
return normalized;
|
|
1489
|
+
}
|
|
1490
|
+
function uniq(values) {
|
|
1491
|
+
const seen = new Set;
|
|
1492
|
+
const out = [];
|
|
1493
|
+
for (const value of values) {
|
|
1494
|
+
const normalized = normalizeHost(value ?? undefined);
|
|
1495
|
+
if (!normalized || seen.has(normalized)) {
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
seen.add(normalized);
|
|
1499
|
+
out.push(normalized);
|
|
1500
|
+
}
|
|
1501
|
+
return out;
|
|
1502
|
+
}
|
|
1503
|
+
function tailscaleSelfWebHosts(self) {
|
|
1504
|
+
if (!self) {
|
|
1505
|
+
return [];
|
|
1506
|
+
}
|
|
1507
|
+
return uniq([
|
|
1508
|
+
self.dnsName,
|
|
1509
|
+
self.hostName && self.magicDnsSuffix ? `${self.hostName}.${self.magicDnsSuffix}` : undefined,
|
|
1510
|
+
...self.addresses
|
|
1511
|
+
]);
|
|
1512
|
+
}
|
|
1513
|
+
function summarizeTailscaleStatus(status) {
|
|
1514
|
+
return {
|
|
1515
|
+
backendState: status.BackendState ?? null,
|
|
1516
|
+
running: isTailscaleBackendRunning(status),
|
|
1517
|
+
health: status.Health ?? [],
|
|
1518
|
+
peers: parsePeers(status),
|
|
1519
|
+
self: parseSelf(status)
|
|
1520
|
+
};
|
|
1521
|
+
}
|
|
1522
|
+
function statusTimeoutMs(env) {
|
|
1523
|
+
const parsed = Number.parseInt(env.OPENSCOUT_TAILSCALE_STATUS_TIMEOUT_MS ?? "", 10);
|
|
1524
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TAILSCALE_STATUS_TIMEOUT_MS;
|
|
1525
|
+
}
|
|
1526
|
+
async function readStatusJsonFromFile(filePath) {
|
|
1527
|
+
try {
|
|
1528
|
+
const raw = await readFile(filePath, "utf8");
|
|
1529
|
+
return parseTailscaleStatusJson(raw);
|
|
1530
|
+
} catch {
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
function isDomainUnavailableError(error) {
|
|
1535
|
+
if (!(error instanceof ProbeCommandError)) {
|
|
1536
|
+
return false;
|
|
1537
|
+
}
|
|
1538
|
+
return error.code === "ENOENT" || error.code === "spawn" || error.code === "exit";
|
|
1539
|
+
}
|
|
1540
|
+
async function readTailscaleStatusSummaryLocal(ctx) {
|
|
1541
|
+
const fixturePath = process.env.OPENSCOUT_TAILSCALE_STATUS_JSON;
|
|
1542
|
+
if (fixturePath) {
|
|
1543
|
+
const status = await readStatusJsonFromFile(fixturePath);
|
|
1544
|
+
return status ? summarizeTailscaleStatus(status) : null;
|
|
1545
|
+
}
|
|
1546
|
+
const tailscaleBin = process.env.OPENSCOUT_TAILSCALE_BIN ?? "tailscale";
|
|
1547
|
+
try {
|
|
1548
|
+
const { stdout } = await execProbeFile(ctx, tailscaleBin, ["status", "--json"], {
|
|
1549
|
+
maxStdoutBytes: 4 * 1024 * 1024,
|
|
1550
|
+
maxStderrBytes: 256 * 1024
|
|
1551
|
+
});
|
|
1552
|
+
return summarizeTailscaleStatus(parseTailscaleStatusJson(stdout));
|
|
1553
|
+
} catch (error) {
|
|
1554
|
+
if (isDomainUnavailableError(error)) {
|
|
1555
|
+
return null;
|
|
1556
|
+
}
|
|
1557
|
+
throw error;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
var tailscaleStatusProbe = defineProbe({
|
|
1561
|
+
id: "tailscale.status",
|
|
1562
|
+
ttlMs: 30000,
|
|
1563
|
+
timeoutMs: statusTimeoutMs(process.env),
|
|
1564
|
+
run: (ctx) => runWithScoutdFallback({
|
|
1565
|
+
probeId: "tailscale.status",
|
|
1566
|
+
ctx,
|
|
1567
|
+
local: () => readTailscaleStatusSummaryLocal(ctx)
|
|
1568
|
+
})
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
// packages/runtime/src/tailscale.ts
|
|
1572
|
+
var DEFAULT_TAILSCALE_STATUS_TIMEOUT_MS2 = 1500;
|
|
1573
|
+
function readStatusJsonFromFileSync(filePath) {
|
|
1574
|
+
const raw = readFileSync2(filePath, "utf8");
|
|
1575
|
+
return parseTailscaleStatusJson(raw);
|
|
1576
|
+
}
|
|
1577
|
+
function statusTimeoutMs2(env) {
|
|
1578
|
+
const parsed = Number.parseInt(env.OPENSCOUT_TAILSCALE_STATUS_TIMEOUT_MS ?? "", 10);
|
|
1579
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TAILSCALE_STATUS_TIMEOUT_MS2;
|
|
1580
|
+
}
|
|
1581
|
+
function readStatusJsonSync(env = process.env) {
|
|
1582
|
+
if (env.OPENSCOUT_TAILSCALE_AUTO_HOSTS === "0") {
|
|
1583
|
+
return null;
|
|
1584
|
+
}
|
|
1585
|
+
const fixturePath = env.OPENSCOUT_TAILSCALE_STATUS_JSON;
|
|
1586
|
+
if (fixturePath) {
|
|
1587
|
+
try {
|
|
1588
|
+
return readStatusJsonFromFileSync(fixturePath);
|
|
1589
|
+
} catch {
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
const tailscaleBin = env.OPENSCOUT_TAILSCALE_BIN ?? (env === process.env ? "tailscale" : undefined);
|
|
1594
|
+
if (!tailscaleBin) {
|
|
1595
|
+
return null;
|
|
1596
|
+
}
|
|
1597
|
+
try {
|
|
1598
|
+
const stdout = execFileSync(tailscaleBin, ["status", "--json"], {
|
|
1599
|
+
encoding: "utf8",
|
|
1600
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1601
|
+
timeout: statusTimeoutMs2(env),
|
|
1602
|
+
windowsHide: true
|
|
1603
|
+
});
|
|
1604
|
+
return parseTailscaleStatusJson(stdout);
|
|
1605
|
+
} catch {
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
function readTailscaleSelfWebHostsSync(env = process.env) {
|
|
1610
|
+
const status = readStatusJsonSync(env);
|
|
1611
|
+
if (!status || !isTailscaleBackendRunning(status)) {
|
|
1612
|
+
return [];
|
|
1613
|
+
}
|
|
1614
|
+
return tailscaleSelfWebHosts(summarizeTailscaleStatus(status).self);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// packages/runtime/src/local-config.ts
|
|
1618
|
+
import {
|
|
1619
|
+
existsSync as existsSync3,
|
|
1620
|
+
mkdirSync,
|
|
1621
|
+
readFileSync as readFileSync3,
|
|
1622
|
+
renameSync,
|
|
1623
|
+
writeFileSync
|
|
1624
|
+
} from "fs";
|
|
1625
|
+
import { homedir as homedir3, hostname as osHostname } from "os";
|
|
1626
|
+
import { dirname, join as join3 } from "path";
|
|
1627
|
+
var LOCAL_CONFIG_VERSION = 1;
|
|
1628
|
+
var DEFAULT_SCOUT_WEB_PORTAL_HOST = "scout.local";
|
|
1629
|
+
var DEFAULT_SCOUT_WEB_DEV_HOST = `dev.${DEFAULT_SCOUT_WEB_PORTAL_HOST}`;
|
|
1630
|
+
var DEFAULT_SCOUT_WEB_VITE_HMR_PATH = "/ws/hmr";
|
|
1631
|
+
var OPENSCOUT_PORTS = {
|
|
1632
|
+
broker: 43110,
|
|
1633
|
+
web: 43120,
|
|
1634
|
+
webTerminalRelay: 43121,
|
|
1635
|
+
vite: 43122,
|
|
1636
|
+
pairingBridge: 43130,
|
|
1637
|
+
pairingRelay: 43131,
|
|
1638
|
+
pairingFileServer: 43132,
|
|
1639
|
+
studio: 43140
|
|
1640
|
+
};
|
|
1641
|
+
var DEFAULT_LOCAL_CONFIG = {
|
|
1642
|
+
version: LOCAL_CONFIG_VERSION,
|
|
1643
|
+
host: "127.0.0.1",
|
|
1644
|
+
webLocalName: undefined,
|
|
1645
|
+
ports: {
|
|
1646
|
+
broker: OPENSCOUT_PORTS.broker,
|
|
1647
|
+
web: OPENSCOUT_PORTS.web,
|
|
1648
|
+
pairing: OPENSCOUT_PORTS.pairingBridge
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
function normalizeLocalHostnameLabel(value) {
|
|
1652
|
+
const firstLabel = value?.trim().replace(/\.local\.?$/i, "").split(".").find((part) => part.trim().length > 0)?.trim();
|
|
1653
|
+
const normalized = firstLabel?.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
1654
|
+
return normalized || "localhost";
|
|
1655
|
+
}
|
|
1656
|
+
function normalizeLocalHostname(value) {
|
|
1657
|
+
const trimmed = value?.trim().replace(/\.$/, "").toLowerCase();
|
|
1658
|
+
const labels = trimmed?.split(".").map((label) => label.trim().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-")).filter(Boolean);
|
|
1659
|
+
return labels && labels.length > 0 ? labels.join(".") : "localhost";
|
|
1660
|
+
}
|
|
1661
|
+
function resolveScoutWebNamedHostname(name) {
|
|
1662
|
+
const normalized = normalizeLocalHostname(name);
|
|
1663
|
+
return normalized.includes(".") ? normalized : `${normalized}.${DEFAULT_SCOUT_WEB_PORTAL_HOST}`;
|
|
1664
|
+
}
|
|
1665
|
+
function resolveScoutWebDevHostname(portalHost = DEFAULT_SCOUT_WEB_PORTAL_HOST) {
|
|
1666
|
+
const normalized = portalHost.trim().replace(/\.$/, "").toLowerCase();
|
|
1667
|
+
if (!normalized || normalized.startsWith("dev.")) {
|
|
1668
|
+
return normalized || DEFAULT_SCOUT_WEB_DEV_HOST;
|
|
1669
|
+
}
|
|
1670
|
+
return `dev.${normalized}`;
|
|
1671
|
+
}
|
|
1672
|
+
function resolveConfiguredScoutWebHostname(config = loadLocalConfig(), machineHostname = osHostname()) {
|
|
1673
|
+
if (config.webLocalName) {
|
|
1674
|
+
return resolveScoutWebNamedHostname(config.webLocalName);
|
|
1675
|
+
}
|
|
1676
|
+
return `${normalizeLocalHostnameLabel(machineHostname)}.${DEFAULT_SCOUT_WEB_PORTAL_HOST}`;
|
|
1677
|
+
}
|
|
1678
|
+
function localConfigHome() {
|
|
1679
|
+
return process.env.OPENSCOUT_HOME ?? join3(homedir3(), ".openscout");
|
|
1680
|
+
}
|
|
1681
|
+
function localConfigPath() {
|
|
1682
|
+
return join3(localConfigHome(), "config.json");
|
|
1683
|
+
}
|
|
1684
|
+
function loadLocalConfig() {
|
|
1685
|
+
const configPath = localConfigPath();
|
|
1686
|
+
if (!existsSync3(configPath))
|
|
1687
|
+
return { version: LOCAL_CONFIG_VERSION };
|
|
1688
|
+
try {
|
|
1689
|
+
return validateLocalConfig(JSON.parse(readFileSync3(configPath, "utf8")));
|
|
1690
|
+
} catch {
|
|
1691
|
+
return { version: LOCAL_CONFIG_VERSION };
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
function validateLocalConfig(input) {
|
|
1695
|
+
if (!input || typeof input !== "object")
|
|
1696
|
+
return { version: LOCAL_CONFIG_VERSION };
|
|
1697
|
+
const raw = input;
|
|
1698
|
+
const out = { version: LOCAL_CONFIG_VERSION };
|
|
1699
|
+
if (typeof raw.host === "string" && raw.host.trim().length > 0) {
|
|
1700
|
+
out.host = raw.host.trim();
|
|
1701
|
+
}
|
|
1702
|
+
if (typeof raw.webLocalName === "string" && raw.webLocalName.trim().length > 0) {
|
|
1703
|
+
out.webLocalName = raw.webLocalName.trim();
|
|
1704
|
+
}
|
|
1705
|
+
if (raw.ports && typeof raw.ports === "object") {
|
|
1706
|
+
const ports = raw.ports;
|
|
1707
|
+
const p = {};
|
|
1708
|
+
if (isValidPort(ports.broker))
|
|
1709
|
+
p.broker = ports.broker;
|
|
1710
|
+
if (isValidPort(ports.web))
|
|
1711
|
+
p.web = ports.web;
|
|
1712
|
+
if (isValidPort(ports.pairing))
|
|
1713
|
+
p.pairing = ports.pairing;
|
|
1714
|
+
if (Object.keys(p).length > 0)
|
|
1715
|
+
out.ports = p;
|
|
1716
|
+
}
|
|
1717
|
+
return out;
|
|
1718
|
+
}
|
|
1719
|
+
function isValidPort(value) {
|
|
1720
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0 && value < 65536;
|
|
1721
|
+
}
|
|
1722
|
+
function parseEnvPort(name) {
|
|
1723
|
+
const raw = process.env[name]?.trim();
|
|
1724
|
+
if (!raw) {
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1728
|
+
return isValidPort(parsed) ? parsed : undefined;
|
|
1729
|
+
}
|
|
1730
|
+
function resolveEffectiveLocalConfig() {
|
|
1731
|
+
const file = loadLocalConfig();
|
|
1732
|
+
const host = process.env.OPENSCOUT_BROKER_HOST?.trim() || process.env.OPENSCOUT_HOST?.trim() || file.host;
|
|
1733
|
+
const ports = {
|
|
1734
|
+
broker: parseEnvPort("OPENSCOUT_BROKER_PORT") ?? file.ports?.broker,
|
|
1735
|
+
web: parseEnvPort("OPENSCOUT_WEB_PORT") ?? parseEnvPort("SCOUT_WEB_PORT") ?? file.ports?.web,
|
|
1736
|
+
pairing: parseEnvPort("OPENSCOUT_PAIRING_PORT") ?? file.ports?.pairing
|
|
1737
|
+
};
|
|
1738
|
+
return {
|
|
1739
|
+
version: LOCAL_CONFIG_VERSION,
|
|
1740
|
+
...host ? { host } : {},
|
|
1741
|
+
...file.webLocalName ? { webLocalName: file.webLocalName } : {},
|
|
1742
|
+
...ports.broker || ports.web || ports.pairing ? { ports } : {}
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
function resolveBrokerPort() {
|
|
1746
|
+
return resolveEffectiveLocalConfig().ports?.broker ?? DEFAULT_LOCAL_CONFIG.ports.broker;
|
|
1747
|
+
}
|
|
1748
|
+
function resolveWebPort() {
|
|
1749
|
+
return resolveEffectiveLocalConfig().ports?.web ?? DEFAULT_LOCAL_CONFIG.ports.web;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// packages/runtime/src/tool-resolution.ts
|
|
1753
|
+
import { accessSync, constants, existsSync as existsSync4 } from "fs";
|
|
1754
|
+
import { homedir as homedir4 } from "os";
|
|
1755
|
+
import { basename, dirname as dirname2, join as join4, resolve } from "path";
|
|
220
1756
|
var DEFAULT_COMMON_EXECUTABLE_DIRECTORIES = [
|
|
221
|
-
|
|
1757
|
+
join4(homedir4(), ".bun", "bin"),
|
|
222
1758
|
"/opt/homebrew/bin",
|
|
223
1759
|
"/usr/local/bin"
|
|
224
1760
|
];
|
|
225
1761
|
function expandHomePath(value, env = process.env) {
|
|
226
|
-
const home = env.HOME ??
|
|
1762
|
+
const home = env.HOME ?? homedir4();
|
|
227
1763
|
if (value === "~") {
|
|
228
1764
|
return home;
|
|
229
1765
|
}
|
|
230
1766
|
if (value.startsWith("~/")) {
|
|
231
|
-
return
|
|
1767
|
+
return join4(home, value.slice(2));
|
|
232
1768
|
}
|
|
233
1769
|
return value;
|
|
234
1770
|
}
|
|
@@ -285,7 +1821,7 @@ function resolveExecutableFromSearch(options) {
|
|
|
285
1821
|
], env);
|
|
286
1822
|
for (const directory of searchDirectories) {
|
|
287
1823
|
for (const name of options.names) {
|
|
288
|
-
const candidate =
|
|
1824
|
+
const candidate = join4(directory, name);
|
|
289
1825
|
if (isExecutablePath(candidate)) {
|
|
290
1826
|
return {
|
|
291
1827
|
path: candidate,
|
|
@@ -309,7 +1845,7 @@ function findExecutableOnSearchPath(name, env) {
|
|
|
309
1845
|
...DEFAULT_COMMON_EXECUTABLE_DIRECTORIES
|
|
310
1846
|
], env);
|
|
311
1847
|
for (const directory of searchDirectories) {
|
|
312
|
-
const candidate =
|
|
1848
|
+
const candidate = join4(directory, name);
|
|
313
1849
|
if (isExecutablePath(candidate)) {
|
|
314
1850
|
return { path: candidate, source: "path" };
|
|
315
1851
|
}
|
|
@@ -317,36 +1853,116 @@ function findExecutableOnSearchPath(name, env) {
|
|
|
317
1853
|
return null;
|
|
318
1854
|
}
|
|
319
1855
|
|
|
1856
|
+
// packages/runtime/src/runtime-adapters.ts
|
|
1857
|
+
function normalizeRuntimeHost(value) {
|
|
1858
|
+
const normalized = value?.trim().toLowerCase();
|
|
1859
|
+
if (normalized === "bun")
|
|
1860
|
+
return "bun";
|
|
1861
|
+
if (normalized === "node")
|
|
1862
|
+
return "node";
|
|
1863
|
+
return null;
|
|
1864
|
+
}
|
|
1865
|
+
function normalizeRuntimeServiceAdapter(value) {
|
|
1866
|
+
const normalized = value?.trim().toLowerCase().replaceAll("_", "-");
|
|
1867
|
+
if (normalized === "macos-scoutd" || normalized === "macos-launchd")
|
|
1868
|
+
return "macos-scoutd";
|
|
1869
|
+
if (normalized === "linux-systemd-user" || normalized === "systemd-user")
|
|
1870
|
+
return "linux-systemd-user";
|
|
1871
|
+
if (normalized === "headless-foreground" || normalized === "foreground" || normalized === "headless") {
|
|
1872
|
+
return "headless-foreground";
|
|
1873
|
+
}
|
|
1874
|
+
if (normalized === "windows-service")
|
|
1875
|
+
return "windows-service";
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
function defaultServiceAdapterForPlatform(platform = process.platform, env = process.env) {
|
|
1879
|
+
const explicit = normalizeRuntimeServiceAdapter(env.OPENSCOUT_SERVICE_ADAPTER);
|
|
1880
|
+
if (explicit)
|
|
1881
|
+
return explicit;
|
|
1882
|
+
if (normalizeRuntimeHost(env.OPENSCOUT_RUNTIME_HOST) === "node") {
|
|
1883
|
+
return "headless-foreground";
|
|
1884
|
+
}
|
|
1885
|
+
if (platform === "darwin")
|
|
1886
|
+
return "macos-scoutd";
|
|
1887
|
+
if (platform === "linux")
|
|
1888
|
+
return "headless-foreground";
|
|
1889
|
+
if (platform === "win32")
|
|
1890
|
+
return "windows-service";
|
|
1891
|
+
return "headless-foreground";
|
|
1892
|
+
}
|
|
1893
|
+
|
|
320
1894
|
// packages/runtime/src/broker-process-manager.ts
|
|
321
1895
|
function isTmpPath(p) {
|
|
322
1896
|
return /^\/(?:private\/)?tmp\//.test(p);
|
|
323
1897
|
}
|
|
324
1898
|
var DEFAULT_BROKER_HOST = "127.0.0.1";
|
|
325
1899
|
var DEFAULT_BROKER_HOST_MESH = "0.0.0.0";
|
|
326
|
-
var DEFAULT_BROKER_PORT =
|
|
1900
|
+
var DEFAULT_BROKER_PORT = OPENSCOUT_PORTS.broker;
|
|
327
1901
|
var DEFAULT_ADVERTISE_SCOPE = "local";
|
|
328
|
-
function resolveAdvertiseScope() {
|
|
329
|
-
|
|
1902
|
+
function resolveAdvertiseScope(env = process.env) {
|
|
1903
|
+
if (openScoutNetworkDiscoveryEnabled(env))
|
|
1904
|
+
return "mesh";
|
|
1905
|
+
const raw = (env.OPENSCOUT_ADVERTISE_SCOPE ?? "").trim().toLowerCase();
|
|
330
1906
|
if (raw === "mesh")
|
|
331
1907
|
return "mesh";
|
|
332
1908
|
if (raw === "local")
|
|
333
1909
|
return "local";
|
|
334
1910
|
return DEFAULT_ADVERTISE_SCOPE;
|
|
335
1911
|
}
|
|
336
|
-
function resolveBrokerHost(scope = resolveAdvertiseScope()) {
|
|
337
|
-
const explicit =
|
|
338
|
-
if (
|
|
1912
|
+
function resolveBrokerHost(scope = resolveAdvertiseScope(), env = process.env) {
|
|
1913
|
+
const explicit = env.OPENSCOUT_BROKER_HOST?.trim();
|
|
1914
|
+
if (explicit) {
|
|
1915
|
+
if (scope === "mesh" && isLoopbackHost(explicit)) {
|
|
1916
|
+
return DEFAULT_BROKER_HOST_MESH;
|
|
1917
|
+
}
|
|
339
1918
|
return explicit;
|
|
340
1919
|
}
|
|
341
|
-
return scope === "mesh" ? DEFAULT_BROKER_HOST_MESH : DEFAULT_BROKER_HOST;
|
|
1920
|
+
return scope === "mesh" ? DEFAULT_BROKER_HOST_MESH : DEFAULT_BROKER_HOST;
|
|
1921
|
+
}
|
|
1922
|
+
function isLoopbackHost(host) {
|
|
1923
|
+
const trimmed = host.trim();
|
|
1924
|
+
return trimmed === "127.0.0.1" || trimmed === "::1" || trimmed === "localhost";
|
|
1925
|
+
}
|
|
1926
|
+
function localBrokerControlHost(host) {
|
|
1927
|
+
const trimmed = host.trim();
|
|
1928
|
+
if (!trimmed || isWildcardHost(trimmed)) {
|
|
1929
|
+
return DEFAULT_BROKER_HOST;
|
|
1930
|
+
}
|
|
1931
|
+
return trimmed;
|
|
1932
|
+
}
|
|
1933
|
+
function buildDefaultBrokerUrl(host = DEFAULT_BROKER_HOST, port = DEFAULT_BROKER_PORT) {
|
|
1934
|
+
return `http://${host}:${port}`;
|
|
1935
|
+
}
|
|
1936
|
+
function resolveBrokerUrl(host, port, scope, env = process.env) {
|
|
1937
|
+
const explicit = env.OPENSCOUT_BROKER_URL?.trim();
|
|
1938
|
+
if (explicit && !(scope === "mesh" && isUnreachableMeshBrokerUrl(explicit))) {
|
|
1939
|
+
return explicit;
|
|
1940
|
+
}
|
|
1941
|
+
if (scope === "mesh") {
|
|
1942
|
+
const tailnetHost = readTailscaleSelfWebHostsSync(env)[0];
|
|
1943
|
+
if (tailnetHost) {
|
|
1944
|
+
return buildDefaultBrokerUrl(tailnetHost, port);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
return buildDefaultBrokerUrl(host, port);
|
|
1948
|
+
}
|
|
1949
|
+
function isUnreachableMeshBrokerUrl(value) {
|
|
1950
|
+
try {
|
|
1951
|
+
const host = new URL(value).hostname;
|
|
1952
|
+
return isLoopbackHost(host) || isWildcardHost(host);
|
|
1953
|
+
} catch {
|
|
1954
|
+
return false;
|
|
1955
|
+
}
|
|
342
1956
|
}
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
1957
|
+
function isWildcardHost(host) {
|
|
1958
|
+
const trimmed = host.trim();
|
|
1959
|
+
return trimmed === "0.0.0.0" || trimmed === "::" || trimmed === "[::]";
|
|
1960
|
+
}
|
|
1961
|
+
function buildLocalBrokerControlUrl(host = DEFAULT_BROKER_HOST, port = DEFAULT_BROKER_PORT) {
|
|
1962
|
+
return buildDefaultBrokerUrl(localBrokerControlHost(host), port);
|
|
347
1963
|
}
|
|
348
1964
|
function buildDefaultBrokerSocketPath(runtimeDirectory) {
|
|
349
|
-
return
|
|
1965
|
+
return join5(runtimeDirectory, "broker.sock");
|
|
350
1966
|
}
|
|
351
1967
|
var DEFAULT_BROKER_URL = buildDefaultBrokerUrl();
|
|
352
1968
|
function runtimePackageDir() {
|
|
@@ -362,17 +1978,17 @@ function runtimePackageDir() {
|
|
|
362
1978
|
const fromGlobal = findGlobalRuntimeDir();
|
|
363
1979
|
if (fromGlobal)
|
|
364
1980
|
return fromGlobal;
|
|
365
|
-
const moduleDir =
|
|
1981
|
+
const moduleDir = dirname3(fileURLToPath(import.meta.url));
|
|
366
1982
|
return resolve2(moduleDir, "..");
|
|
367
1983
|
}
|
|
368
1984
|
function isInstalledRuntimePackageDir(candidate) {
|
|
369
|
-
return
|
|
1985
|
+
return existsSync5(join5(candidate, "package.json")) && existsSync5(join5(candidate, "bin", "openscout-runtime.mjs"));
|
|
370
1986
|
}
|
|
371
1987
|
function findGlobalRuntimeDir() {
|
|
372
1988
|
const candidates = [
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
1989
|
+
join5(homedir5(), ".bun", "node_modules", "@openscout", "runtime"),
|
|
1990
|
+
join5(homedir5(), ".bun", "install", "global", "node_modules", "@openscout", "runtime"),
|
|
1991
|
+
join5(homedir5(), ".bun", "install", "global", "node_modules", "@openscout", "scout", "node_modules", "@openscout", "runtime")
|
|
376
1992
|
];
|
|
377
1993
|
for (const c of candidates) {
|
|
378
1994
|
if (isInstalledRuntimePackageDir(c))
|
|
@@ -385,7 +2001,7 @@ function findGlobalRuntimeDir() {
|
|
|
385
2001
|
const scoutPkg = resolve2(scoutBin, "..", "..");
|
|
386
2002
|
if (isInstalledRuntimePackageDir(scoutPkg))
|
|
387
2003
|
return scoutPkg;
|
|
388
|
-
const nested =
|
|
2004
|
+
const nested = join5(scoutPkg, "node_modules", "@openscout", "runtime");
|
|
389
2005
|
if (isInstalledRuntimePackageDir(nested))
|
|
390
2006
|
return nested;
|
|
391
2007
|
const sibling = resolve2(scoutPkg, "..", "runtime");
|
|
@@ -396,30 +2012,43 @@ function findGlobalRuntimeDir() {
|
|
|
396
2012
|
return null;
|
|
397
2013
|
}
|
|
398
2014
|
function findBundledRuntimeDir() {
|
|
399
|
-
const moduleDir =
|
|
400
|
-
|
|
401
|
-
|
|
2015
|
+
const moduleDir = dirname3(fileURLToPath(import.meta.url));
|
|
2016
|
+
return resolveBundledRuntimeDirFromModuleDir(moduleDir);
|
|
2017
|
+
}
|
|
2018
|
+
function resolveBundledRuntimeDirFromModuleDir(moduleDir) {
|
|
2019
|
+
const candidates = [
|
|
2020
|
+
resolve2(moduleDir, ".."),
|
|
2021
|
+
resolve2(moduleDir, "..", "..")
|
|
2022
|
+
];
|
|
2023
|
+
for (const candidate of candidates) {
|
|
2024
|
+
if (isInstalledRuntimePackageDir(candidate))
|
|
2025
|
+
return candidate;
|
|
2026
|
+
}
|
|
2027
|
+
return null;
|
|
402
2028
|
}
|
|
403
2029
|
function findWorkspaceRuntimeDir(startDir) {
|
|
404
2030
|
let current = resolve2(startDir);
|
|
405
2031
|
while (true) {
|
|
406
|
-
const candidate =
|
|
407
|
-
if (
|
|
2032
|
+
const candidate = join5(current, "packages", "runtime");
|
|
2033
|
+
if (existsSync5(join5(candidate, "package.json")) && existsSync5(join5(candidate, "src"))) {
|
|
408
2034
|
return candidate;
|
|
409
2035
|
}
|
|
410
|
-
const parent =
|
|
2036
|
+
const parent = dirname3(current);
|
|
411
2037
|
if (parent === current)
|
|
412
2038
|
return null;
|
|
413
2039
|
current = parent;
|
|
414
2040
|
}
|
|
415
2041
|
}
|
|
416
2042
|
function resolveBunExecutable2() {
|
|
417
|
-
const bun =
|
|
2043
|
+
const bun = resolveOptionalBunExecutable();
|
|
418
2044
|
if (bun) {
|
|
419
|
-
return bun
|
|
2045
|
+
return bun;
|
|
420
2046
|
}
|
|
421
2047
|
throw new Error("Unable to locate Bun for broker service management. Install Bun or set OPENSCOUT_BUN_BIN.");
|
|
422
2048
|
}
|
|
2049
|
+
function resolveOptionalBunExecutable() {
|
|
2050
|
+
return resolveBunExecutable(process.env)?.path ?? null;
|
|
2051
|
+
}
|
|
423
2052
|
function resolveBrokerServiceMode() {
|
|
424
2053
|
const explicit = (process.env.OPENSCOUT_BROKER_SERVICE_MODE ?? "").trim().toLowerCase();
|
|
425
2054
|
if (explicit === "prod" || explicit === "production") {
|
|
@@ -430,13 +2059,6 @@ function resolveBrokerServiceMode() {
|
|
|
430
2059
|
}
|
|
431
2060
|
return "dev";
|
|
432
2061
|
}
|
|
433
|
-
function resolveBrokerStartTimeoutMs() {
|
|
434
|
-
const explicit = Number.parseInt(process.env.OPENSCOUT_BROKER_START_TIMEOUT_MS ?? "", 10);
|
|
435
|
-
if (Number.isFinite(explicit) && explicit > 0) {
|
|
436
|
-
return Math.max(explicit, BROKER_SERVICE_POLL_INTERVAL_MS);
|
|
437
|
-
}
|
|
438
|
-
return DEFAULT_BROKER_START_TIMEOUT_MS;
|
|
439
|
-
}
|
|
440
2062
|
function resolveBrokerServiceLabel(mode) {
|
|
441
2063
|
const explicit = process.env.OPENSCOUT_SERVICE_LABEL?.trim() || process.env.OPENSCOUT_BROKER_SERVICE_LABEL?.trim();
|
|
442
2064
|
if (explicit) {
|
|
@@ -452,33 +2074,23 @@ function resolveBrokerServiceLabel(mode) {
|
|
|
452
2074
|
return "dev.openscout";
|
|
453
2075
|
}
|
|
454
2076
|
}
|
|
455
|
-
function legacyBrokerServiceLabel(mode) {
|
|
456
|
-
switch (mode) {
|
|
457
|
-
case "prod":
|
|
458
|
-
return "com.openscout.broker";
|
|
459
|
-
case "custom":
|
|
460
|
-
return "com.openscout.broker.custom";
|
|
461
|
-
case "dev":
|
|
462
|
-
default:
|
|
463
|
-
return "dev.openscout.broker";
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
2077
|
function resolveBrokerServiceConfig() {
|
|
467
2078
|
const mode = resolveBrokerServiceMode();
|
|
468
2079
|
const label = resolveBrokerServiceLabel(mode);
|
|
2080
|
+
const serviceAdapter = resolveBrokerServiceAdapter();
|
|
469
2081
|
const uid = typeof process.getuid === "function" ? process.getuid() : Number.parseInt(process.env.UID ?? "0", 10);
|
|
470
2082
|
const supportPaths = resolveOpenScoutSupportPaths();
|
|
471
|
-
const defaultSupportDir =
|
|
2083
|
+
const defaultSupportDir = join5(homedir5(), "Library", "Application Support", "OpenScout");
|
|
472
2084
|
const supportDirectory = isTmpPath(supportPaths.supportDirectory) ? defaultSupportDir : supportPaths.supportDirectory;
|
|
473
|
-
const runtimeDirectory =
|
|
474
|
-
const logsDirectory =
|
|
475
|
-
const controlHome = isTmpPath(supportPaths.controlHome) ?
|
|
2085
|
+
const runtimeDirectory = join5(supportDirectory, "runtime");
|
|
2086
|
+
const logsDirectory = join5(supportDirectory, "logs", "broker");
|
|
2087
|
+
const controlHome = isTmpPath(supportPaths.controlHome) ? join5(homedir5(), ".openscout", "control-plane") : supportPaths.controlHome;
|
|
476
2088
|
const advertiseScope = resolveAdvertiseScope();
|
|
477
2089
|
const brokerHost = resolveBrokerHost(advertiseScope);
|
|
478
|
-
const brokerPort = Number.parseInt(process.env.OPENSCOUT_BROKER_PORT ?? String(
|
|
479
|
-
const brokerUrl =
|
|
2090
|
+
const brokerPort = Number.parseInt(process.env.OPENSCOUT_BROKER_PORT ?? String(resolveBrokerPort()), 10);
|
|
2091
|
+
const brokerUrl = resolveBrokerUrl(brokerHost, brokerPort, advertiseScope);
|
|
480
2092
|
const brokerSocketPath = process.env.OPENSCOUT_BROKER_SOCKET_PATH ?? buildDefaultBrokerSocketPath(runtimeDirectory);
|
|
481
|
-
const launchAgentPath =
|
|
2093
|
+
const launchAgentPath = join5(homedir5(), "Library", "LaunchAgents", `${label}.plist`);
|
|
482
2094
|
return {
|
|
483
2095
|
label,
|
|
484
2096
|
mode,
|
|
@@ -489,11 +2101,11 @@ function resolveBrokerServiceConfig() {
|
|
|
489
2101
|
supportDirectory,
|
|
490
2102
|
runtimeDirectory,
|
|
491
2103
|
logsDirectory,
|
|
492
|
-
stdoutLogPath:
|
|
493
|
-
stderrLogPath:
|
|
2104
|
+
stdoutLogPath: join5(logsDirectory, "stdout.log"),
|
|
2105
|
+
stderrLogPath: join5(logsDirectory, "stderr.log"),
|
|
494
2106
|
controlHome,
|
|
495
2107
|
runtimePackageDir: runtimePackageDir(),
|
|
496
|
-
bunExecutable: resolveBunExecutable2(),
|
|
2108
|
+
bunExecutable: serviceAdapter === "macos-scoutd" ? resolveBunExecutable2() : resolveOptionalBunExecutable(),
|
|
497
2109
|
brokerHost,
|
|
498
2110
|
brokerPort,
|
|
499
2111
|
brokerUrl,
|
|
@@ -502,75 +2114,259 @@ function resolveBrokerServiceConfig() {
|
|
|
502
2114
|
coreAgents: readCoreAgentsSync()
|
|
503
2115
|
};
|
|
504
2116
|
}
|
|
505
|
-
function
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
2117
|
+
function resolveBrokerServiceAdapter(env = process.env, platform = process.platform) {
|
|
2118
|
+
return defaultServiceAdapterForPlatform(platform, env);
|
|
2119
|
+
}
|
|
2120
|
+
function executableCandidate(path) {
|
|
2121
|
+
return isExecutablePath(path) ? path : null;
|
|
2122
|
+
}
|
|
2123
|
+
function resolveExecutableName(name) {
|
|
2124
|
+
return resolveExecutableFromSearch({ names: [name] })?.path ?? null;
|
|
2125
|
+
}
|
|
2126
|
+
function resolveEnvExecutable(value) {
|
|
2127
|
+
const trimmed = value?.trim();
|
|
2128
|
+
if (!trimmed)
|
|
2129
|
+
return null;
|
|
2130
|
+
if (trimmed.includes("/") || trimmed.startsWith(".")) {
|
|
2131
|
+
const expanded = resolve2(expandHomePath(trimmed));
|
|
2132
|
+
return executableCandidate(expanded);
|
|
2133
|
+
}
|
|
2134
|
+
return resolveExecutableName(trimmed);
|
|
2135
|
+
}
|
|
2136
|
+
function findWorkspaceRootFromRuntimeDir(runtimePackageDir2) {
|
|
2137
|
+
let current = resolve2(runtimePackageDir2);
|
|
2138
|
+
while (true) {
|
|
2139
|
+
if (existsSync5(join5(current, "Cargo.toml")) && existsSync5(join5(current, "crates", "scoutd", "Cargo.toml"))) {
|
|
2140
|
+
return current;
|
|
2141
|
+
}
|
|
2142
|
+
const parent = dirname3(current);
|
|
2143
|
+
if (parent === current)
|
|
2144
|
+
return null;
|
|
2145
|
+
current = parent;
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
function workspaceScoutdAllowed() {
|
|
2149
|
+
const raw = process.env.OPENSCOUT_ALLOW_WORKSPACE_SCOUTD?.trim().toLowerCase();
|
|
2150
|
+
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
2151
|
+
}
|
|
2152
|
+
function resolveScoutdCommand(config = resolveBrokerServiceConfig()) {
|
|
2153
|
+
const explicit = resolveEnvExecutable(process.env.OPENSCOUT_SCOUTD_BIN);
|
|
2154
|
+
if (explicit) {
|
|
2155
|
+
return { path: explicit, source: "env" };
|
|
2156
|
+
}
|
|
2157
|
+
const workspaceRoot = findWorkspaceRootFromRuntimeDir(config.runtimePackageDir);
|
|
2158
|
+
const packageCandidates = [
|
|
2159
|
+
join5(config.runtimePackageDir, "bin", "scoutd"),
|
|
2160
|
+
join5(config.runtimePackageDir, "native", "scoutd"),
|
|
2161
|
+
join5(config.runtimePackageDir, "scoutd"),
|
|
2162
|
+
workspaceRoot ? join5(workspaceRoot, "packages", "cli", "bin", "scoutd") : null,
|
|
2163
|
+
workspaceRoot ? join5(workspaceRoot, "packages", "runtime", "bin", "scoutd") : null,
|
|
2164
|
+
join5(config.runtimeDirectory, "scoutd"),
|
|
2165
|
+
join5(dirname3(config.runtimePackageDir), "scout", "bin", "scoutd")
|
|
2166
|
+
];
|
|
2167
|
+
for (const candidate of packageCandidates) {
|
|
2168
|
+
const resolved = executableCandidate(candidate);
|
|
2169
|
+
if (resolved) {
|
|
2170
|
+
return { path: resolved, source: "package" };
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
const fromPath = resolveExecutableName("scoutd");
|
|
2174
|
+
if (fromPath) {
|
|
2175
|
+
return { path: fromPath, source: "path" };
|
|
2176
|
+
}
|
|
2177
|
+
if (workspaceRoot && workspaceScoutdAllowed()) {
|
|
2178
|
+
for (const candidate of [
|
|
2179
|
+
join5(workspaceRoot, "target", "release", "scoutd"),
|
|
2180
|
+
join5(workspaceRoot, "target", "debug", "scoutd")
|
|
2181
|
+
]) {
|
|
2182
|
+
const resolved = executableCandidate(candidate);
|
|
2183
|
+
if (resolved) {
|
|
2184
|
+
return { path: resolved, source: "workspace" };
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
return null;
|
|
2189
|
+
}
|
|
2190
|
+
function nativeServiceEnvironment(config, scoutdPath) {
|
|
2191
|
+
const env = {
|
|
2192
|
+
...process.env,
|
|
2193
|
+
OPENSCOUT_SCOUTD_BIN: scoutdPath,
|
|
2194
|
+
OPENSCOUT_RUNTIME_PACKAGE_DIR: config.runtimePackageDir,
|
|
2195
|
+
OPENSCOUT_SUPPORT_DIRECTORY: config.supportDirectory,
|
|
2196
|
+
OPENSCOUT_CONTROL_HOME: config.controlHome,
|
|
509
2197
|
OPENSCOUT_BROKER_HOST: config.brokerHost,
|
|
510
2198
|
OPENSCOUT_BROKER_PORT: String(config.brokerPort),
|
|
511
2199
|
OPENSCOUT_BROKER_URL: config.brokerUrl,
|
|
512
2200
|
OPENSCOUT_BROKER_SOCKET_PATH: config.brokerSocketPath,
|
|
513
|
-
OPENSCOUT_CONTROL_HOME: config.controlHome,
|
|
514
2201
|
OPENSCOUT_BROKER_SERVICE_MODE: config.mode,
|
|
515
2202
|
OPENSCOUT_BROKER_SERVICE_LABEL: config.label,
|
|
516
2203
|
OPENSCOUT_SERVICE_LABEL: config.label,
|
|
517
|
-
OPENSCOUT_ADVERTISE_SCOPE: config.advertiseScope
|
|
518
|
-
HOME: homedir3(),
|
|
519
|
-
PATH: launchPath,
|
|
520
|
-
...collectOptionalEnvVars([
|
|
521
|
-
"OPENSCOUT_MESH_ID",
|
|
522
|
-
"OPENSCOUT_MESH_SEEDS",
|
|
523
|
-
"OPENSCOUT_MESH_DISCOVERY_INTERVAL_MS",
|
|
524
|
-
"OPENSCOUT_NODE_NAME",
|
|
525
|
-
"OPENSCOUT_NODE_ID",
|
|
526
|
-
"OPENSCOUT_NODE_QUALIFIER",
|
|
527
|
-
"OPENSCOUT_TAILSCALE_BIN",
|
|
528
|
-
"OPENSCOUT_TAILSCALE_STATUS_JSON",
|
|
529
|
-
"OPENSCOUT_SSE_KEEPALIVE_MS"
|
|
530
|
-
])
|
|
2204
|
+
OPENSCOUT_ADVERTISE_SCOPE: config.advertiseScope
|
|
531
2205
|
};
|
|
532
|
-
if (
|
|
533
|
-
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
return
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
2206
|
+
if (config.bunExecutable) {
|
|
2207
|
+
env.OPENSCOUT_BUN_BIN = config.bunExecutable;
|
|
2208
|
+
}
|
|
2209
|
+
if (config.coreAgents.length > 0) {
|
|
2210
|
+
env.OPENSCOUT_CORE_AGENTS = config.coreAgents.join(",");
|
|
2211
|
+
}
|
|
2212
|
+
return env;
|
|
2213
|
+
}
|
|
2214
|
+
function isRecord3(value) {
|
|
2215
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2216
|
+
}
|
|
2217
|
+
function readString2(value) {
|
|
2218
|
+
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
|
2219
|
+
}
|
|
2220
|
+
function readBoolean(value) {
|
|
2221
|
+
return typeof value === "boolean" ? value : undefined;
|
|
2222
|
+
}
|
|
2223
|
+
function readNumber2(value) {
|
|
2224
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
2225
|
+
}
|
|
2226
|
+
function readHealthTransport(value) {
|
|
2227
|
+
return value === "unix_socket" || value === "http" || value === "in_process" ? value : undefined;
|
|
2228
|
+
}
|
|
2229
|
+
function normalizeNativeServiceStatus(input, config) {
|
|
2230
|
+
const healthRecord = isRecord3(input.health) ? input.health : {};
|
|
2231
|
+
const healthReachable = readBoolean(healthRecord.reachable) ?? readBoolean(input.reachable) ?? false;
|
|
2232
|
+
const healthOk = readBoolean(healthRecord.ok) ?? (typeof input.health === "boolean" ? input.health : undefined) ?? false;
|
|
2233
|
+
const healthError = readString2(healthRecord.error) ?? readString2(input.healthError);
|
|
2234
|
+
const healthTransport = readHealthTransport(healthRecord.transport) ?? readHealthTransport(input.healthTransport);
|
|
2235
|
+
const healthNodeId = readString2(healthRecord.nodeId);
|
|
2236
|
+
const healthMeshId = readString2(healthRecord.meshId);
|
|
2237
|
+
const healthSocketFallbackError = readString2(healthRecord.socketFallbackError);
|
|
2238
|
+
const healthCounts = isRecord3(healthRecord.counts) ? healthRecord.counts : undefined;
|
|
2239
|
+
const installed = readBoolean(input.installed) ?? existsSync5(config.launchAgentPath);
|
|
2240
|
+
const loaded = readBoolean(input.loaded) ?? false;
|
|
2241
|
+
const stdoutLogPath = readString2(input.stdoutLogPath) ?? config.stdoutLogPath;
|
|
2242
|
+
const stderrLogPath = readString2(input.stderrLogPath) ?? config.stderrLogPath;
|
|
2243
|
+
const lastLogLine = readString2(input.lastLogLine) ?? (healthReachable ? readLastLogLine([stdoutLogPath, stderrLogPath]) : readLastLogLine([stderrLogPath, stdoutLogPath]));
|
|
2244
|
+
return {
|
|
2245
|
+
label: readString2(input.label) ?? config.label,
|
|
2246
|
+
mode: readString2(input.mode) ?? config.mode,
|
|
2247
|
+
launchAgentPath: readString2(input.launchAgentPath) ?? config.launchAgentPath,
|
|
2248
|
+
bootoutCommand: readString2(input.bootoutCommand) ?? bootoutCommand(config),
|
|
2249
|
+
brokerUrl: readString2(input.brokerUrl) ?? config.brokerUrl,
|
|
2250
|
+
brokerSocketPath: readString2(input.brokerSocketPath) ?? config.brokerSocketPath,
|
|
2251
|
+
supportDirectory: readString2(input.supportDirectory) ?? config.supportDirectory,
|
|
2252
|
+
runtimeDirectory: readString2(input.runtimeDirectory) ?? config.runtimeDirectory,
|
|
2253
|
+
controlHome: readString2(input.controlHome) ?? config.controlHome,
|
|
2254
|
+
stdoutLogPath,
|
|
2255
|
+
stderrLogPath,
|
|
2256
|
+
installed,
|
|
2257
|
+
loaded,
|
|
2258
|
+
pid: readNumber2(input.pid) ?? null,
|
|
2259
|
+
launchdState: readString2(input.launchdState) ?? null,
|
|
2260
|
+
lastExitStatus: readNumber2(input.lastExitStatus) ?? null,
|
|
2261
|
+
usesLaunchAgent: readBoolean(input.usesLaunchAgent) ?? (installed || loaded),
|
|
2262
|
+
reachable: healthReachable,
|
|
2263
|
+
serviceAdapter: "macos-scoutd",
|
|
2264
|
+
health: {
|
|
2265
|
+
reachable: healthReachable,
|
|
2266
|
+
ok: healthOk,
|
|
2267
|
+
checkedAt: readNumber2(healthRecord.checkedAt) ?? Date.now(),
|
|
2268
|
+
transport: healthTransport,
|
|
2269
|
+
socketPath: config.brokerSocketPath,
|
|
2270
|
+
...healthSocketFallbackError ? { socketFallbackError: healthSocketFallbackError } : {},
|
|
2271
|
+
...healthNodeId ? { nodeId: healthNodeId } : {},
|
|
2272
|
+
...healthMeshId ? { meshId: healthMeshId } : {},
|
|
2273
|
+
...healthCounts ? { counts: healthCounts } : {},
|
|
2274
|
+
...isRecord3(healthRecord.build) ? { build: healthRecord.build } : {},
|
|
2275
|
+
...isRecord3(healthRecord.services) ? { services: healthRecord.services } : {},
|
|
2276
|
+
error: healthError
|
|
2277
|
+
},
|
|
2278
|
+
lastLogLine
|
|
2279
|
+
};
|
|
2280
|
+
}
|
|
2281
|
+
var SCOUTD_MAX_BUFFER = 2 * 1024 * 1024;
|
|
2282
|
+
var SCOUTD_DEFAULT_TIMEOUT_MS = 45000;
|
|
2283
|
+
var SCOUTD_KILL_GRACE_MS = 250;
|
|
2284
|
+
function spawnScoutdJson(scoutdPath, command, env, timeoutMs) {
|
|
2285
|
+
return new Promise((resolvePromise, reject) => {
|
|
2286
|
+
const child = spawn2(scoutdPath, [command, "--json"], {
|
|
2287
|
+
env,
|
|
2288
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2289
|
+
});
|
|
2290
|
+
let stdout = "";
|
|
2291
|
+
let stderr = "";
|
|
2292
|
+
let settled = false;
|
|
2293
|
+
let timedOut = false;
|
|
2294
|
+
const killTimer = setTimeout(() => {
|
|
2295
|
+
timedOut = true;
|
|
2296
|
+
terminate();
|
|
2297
|
+
fail(new Error(`scoutd ${command} timed out after ${timeoutMs}ms`));
|
|
2298
|
+
}, timeoutMs);
|
|
2299
|
+
killTimer.unref?.();
|
|
2300
|
+
function terminate() {
|
|
2301
|
+
child.kill("SIGTERM");
|
|
2302
|
+
const hardKillTimer = setTimeout(() => child.kill("SIGKILL"), SCOUTD_KILL_GRACE_MS);
|
|
2303
|
+
hardKillTimer.unref?.();
|
|
2304
|
+
}
|
|
2305
|
+
function cleanup() {
|
|
2306
|
+
clearTimeout(killTimer);
|
|
2307
|
+
}
|
|
2308
|
+
function fail(error) {
|
|
2309
|
+
if (settled)
|
|
2310
|
+
return;
|
|
2311
|
+
settled = true;
|
|
2312
|
+
cleanup();
|
|
2313
|
+
reject(error);
|
|
2314
|
+
}
|
|
2315
|
+
function succeed(output) {
|
|
2316
|
+
if (settled)
|
|
2317
|
+
return;
|
|
2318
|
+
settled = true;
|
|
2319
|
+
cleanup();
|
|
2320
|
+
resolvePromise(output);
|
|
2321
|
+
}
|
|
2322
|
+
function append(kind, chunk) {
|
|
2323
|
+
const text = typeof chunk === "string" ? chunk : String(chunk);
|
|
2324
|
+
if (kind === "stdout")
|
|
2325
|
+
stdout += text;
|
|
2326
|
+
else
|
|
2327
|
+
stderr += text;
|
|
2328
|
+
if (stdout.length + stderr.length > SCOUTD_MAX_BUFFER) {
|
|
2329
|
+
terminate();
|
|
2330
|
+
fail(new Error(`scoutd ${command} exceeded output limit`));
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
child.stdout.setEncoding("utf8");
|
|
2334
|
+
child.stderr.setEncoding("utf8");
|
|
2335
|
+
child.stdout.on("data", (chunk) => append("stdout", chunk));
|
|
2336
|
+
child.stderr.on("data", (chunk) => append("stderr", chunk));
|
|
2337
|
+
child.on("error", (error) => fail(new Error(`scoutd ${command} failed: ${error.message}`)));
|
|
2338
|
+
child.on("close", (code, signal) => {
|
|
2339
|
+
if (settled || timedOut)
|
|
2340
|
+
return;
|
|
2341
|
+
const trimmedStdout = stdout.trim();
|
|
2342
|
+
const trimmedStderr = stderr.trim();
|
|
2343
|
+
if ((code ?? 1) !== 0) {
|
|
2344
|
+
const detail = trimmedStderr || trimmedStdout || `exit ${signal ?? code ?? "unknown status"}`;
|
|
2345
|
+
fail(new Error(`scoutd ${command} failed: ${detail}`));
|
|
2346
|
+
return;
|
|
2347
|
+
}
|
|
2348
|
+
succeed(trimmedStdout);
|
|
2349
|
+
});
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
async function runScoutdServiceCommand(command, config, timeoutMs = SCOUTD_DEFAULT_TIMEOUT_MS, runScoutdJson = spawnScoutdJson) {
|
|
2353
|
+
const scoutd = resolveScoutdCommand(config);
|
|
2354
|
+
if (!scoutd) {
|
|
2355
|
+
throw new Error("Unable to locate scoutd for broker service management. Build scoutd with `npm run scoutd:build`, install a package that includes scoutd, or set OPENSCOUT_SCOUTD_BIN.");
|
|
2356
|
+
}
|
|
2357
|
+
const stdout = await runScoutdJson(scoutd.path, command, nativeServiceEnvironment(config, scoutd.path), timeoutMs);
|
|
2358
|
+
let parsed;
|
|
2359
|
+
try {
|
|
2360
|
+
parsed = JSON.parse(stdout);
|
|
2361
|
+
} catch {
|
|
2362
|
+
throw new Error(`scoutd ${command} returned non-JSON stdout: ${stdout.slice(0, 400)}`);
|
|
2363
|
+
}
|
|
2364
|
+
return normalizeNativeServiceStatus(parsed, config);
|
|
569
2365
|
}
|
|
570
2366
|
function readCoreAgentsSync() {
|
|
571
2367
|
try {
|
|
572
2368
|
const settingsPath = resolveOpenScoutSupportPaths().settingsPath;
|
|
573
|
-
const raw =
|
|
2369
|
+
const raw = readFileSync4(settingsPath, "utf8");
|
|
574
2370
|
const settings = JSON.parse(raw);
|
|
575
2371
|
const raw_agents = settings?.agents?.coreAgents;
|
|
576
2372
|
if (Array.isArray(raw_agents)) {
|
|
@@ -579,88 +2375,14 @@ function readCoreAgentsSync() {
|
|
|
579
2375
|
} catch {}
|
|
580
2376
|
return [];
|
|
581
2377
|
}
|
|
582
|
-
function collectOptionalEnvVars(keys) {
|
|
583
|
-
const entries = {};
|
|
584
|
-
for (const key of keys) {
|
|
585
|
-
const value = process.env[key];
|
|
586
|
-
if (typeof value === "string" && value.trim().length > 0) {
|
|
587
|
-
entries[key] = value;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
return entries;
|
|
591
|
-
}
|
|
592
|
-
function resolveLaunchAgentPATH() {
|
|
593
|
-
const entries = [
|
|
594
|
-
join3(homedir3(), ".bun", "bin"),
|
|
595
|
-
...(process.env.PATH ?? "").split(":").filter(Boolean),
|
|
596
|
-
"/opt/homebrew/bin",
|
|
597
|
-
"/usr/local/bin",
|
|
598
|
-
"/usr/bin",
|
|
599
|
-
"/bin",
|
|
600
|
-
"/usr/sbin",
|
|
601
|
-
"/sbin"
|
|
602
|
-
];
|
|
603
|
-
return Array.from(new Set(entries)).filter((e) => !isTmpPath(e)).join(":");
|
|
604
|
-
}
|
|
605
|
-
function xmlEscape(value) {
|
|
606
|
-
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
607
|
-
}
|
|
608
|
-
function ensureParentDirectory(filePath) {
|
|
609
|
-
mkdirSync2(dirname2(filePath), { recursive: true });
|
|
610
|
-
}
|
|
611
|
-
function ensureServiceDirectories(config) {
|
|
612
|
-
ensureOpenScoutCleanSlateSync();
|
|
613
|
-
mkdirSync2(config.supportDirectory, { recursive: true });
|
|
614
|
-
mkdirSync2(config.runtimeDirectory, { recursive: true, mode: 448 });
|
|
615
|
-
chmodSync(config.runtimeDirectory, 448);
|
|
616
|
-
mkdirSync2(config.logsDirectory, { recursive: true });
|
|
617
|
-
mkdirSync2(config.controlHome, { recursive: true });
|
|
618
|
-
ensureParentDirectory(config.launchAgentPath);
|
|
619
|
-
}
|
|
620
|
-
function writeLaunchAgentPlist(config) {
|
|
621
|
-
ensureServiceDirectories(config);
|
|
622
|
-
writeFileSync2(config.launchAgentPath, renderLaunchAgentPlist(config), "utf8");
|
|
623
|
-
}
|
|
624
2378
|
function bootoutCommand(config) {
|
|
625
|
-
return
|
|
626
|
-
}
|
|
627
|
-
function legacyLaunchAgentPath(config) {
|
|
628
|
-
return join3(homedir3(), "Library", "LaunchAgents", `${legacyBrokerServiceLabel(config.mode)}.plist`);
|
|
629
|
-
}
|
|
630
|
-
function legacyServiceTarget(config) {
|
|
631
|
-
return `${config.domainTarget}/${legacyBrokerServiceLabel(config.mode)}`;
|
|
632
|
-
}
|
|
633
|
-
function bootoutLegacyBrokerService(config) {
|
|
634
|
-
const legacyTarget = legacyServiceTarget(config);
|
|
635
|
-
if (legacyTarget === config.serviceTarget) {
|
|
636
|
-
return;
|
|
637
|
-
}
|
|
638
|
-
runCommand(launchctlPath(), ["bootout", legacyTarget], { allowFailure: true });
|
|
639
|
-
}
|
|
640
|
-
function runCommand(command, args, options) {
|
|
641
|
-
const result = spawnSync(command, args, {
|
|
642
|
-
env: {
|
|
643
|
-
...process.env,
|
|
644
|
-
...options?.env ?? {}
|
|
645
|
-
},
|
|
646
|
-
encoding: "utf8"
|
|
647
|
-
});
|
|
648
|
-
const stdout = (result.stdout ?? "").trim();
|
|
649
|
-
const stderr = (result.stderr ?? "").trim();
|
|
650
|
-
const exitCode = result.status ?? 1;
|
|
651
|
-
if (exitCode !== 0 && !options?.allowFailure) {
|
|
652
|
-
throw new Error(stderr || stdout || `${command} exited with status ${exitCode}`);
|
|
653
|
-
}
|
|
654
|
-
return { exitCode, stdout, stderr };
|
|
655
|
-
}
|
|
656
|
-
function launchctlPath() {
|
|
657
|
-
return "/bin/launchctl";
|
|
2379
|
+
return `/bin/launchctl bootout ${config.serviceTarget}`;
|
|
658
2380
|
}
|
|
659
2381
|
function readLogLines(path) {
|
|
660
|
-
if (!
|
|
2382
|
+
if (!existsSync5(path)) {
|
|
661
2383
|
return [];
|
|
662
2384
|
}
|
|
663
|
-
return
|
|
2385
|
+
return readFileSync4(path, "utf8").split(`
|
|
664
2386
|
`).map((line) => line.trim()).filter(Boolean);
|
|
665
2387
|
}
|
|
666
2388
|
function isPackageScriptBanner(line) {
|
|
@@ -693,164 +2415,120 @@ function readLastLogLine(paths) {
|
|
|
693
2415
|
}
|
|
694
2416
|
return fallback;
|
|
695
2417
|
}
|
|
696
|
-
function
|
|
697
|
-
const
|
|
698
|
-
const stateMatch = output.match(/\bstate = ([^\n]+)/);
|
|
699
|
-
const lastExitMatch = output.match(/\blast exit code = (-?\d+)/i) || output.match(/\blast exit status = (-?\d+)/i);
|
|
2418
|
+
async function readHeadlessBrokerHealth(config) {
|
|
2419
|
+
const result = await requestScoutBrokerJsonWithTrace(config.brokerUrl, "/health", { socketPath: config.brokerSocketPath });
|
|
700
2420
|
return {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
lastExitStatus: lastExitMatch ? Number.parseInt(lastExitMatch[1] ?? "0", 10) : null
|
|
2421
|
+
health: result.value,
|
|
2422
|
+
trace: result.trace
|
|
704
2423
|
};
|
|
705
2424
|
}
|
|
706
|
-
function
|
|
707
|
-
|
|
708
|
-
if (printResult.exitCode !== 0) {
|
|
709
|
-
return {
|
|
710
|
-
loaded: false,
|
|
711
|
-
pid: null,
|
|
712
|
-
launchdState: null,
|
|
713
|
-
lastExitStatus: null,
|
|
714
|
-
raw: printResult.stderr || printResult.stdout
|
|
715
|
-
};
|
|
716
|
-
}
|
|
717
|
-
return {
|
|
718
|
-
loaded: true,
|
|
719
|
-
raw: printResult.stdout,
|
|
720
|
-
...parseLaunchctlPrint(printResult.stdout)
|
|
721
|
-
};
|
|
2425
|
+
function headlessLifecycleError(command) {
|
|
2426
|
+
return new Error(`Next step: run \`openscout-runtime broker\` in this shell or under your process manager. ` + `The headless service adapter leaves broker ${command} to that foreground process.`);
|
|
722
2427
|
}
|
|
723
|
-
async function
|
|
724
|
-
|
|
725
|
-
|
|
2428
|
+
async function runHeadlessForegroundServiceCommand(command, config, readHealth = readHeadlessBrokerHealth) {
|
|
2429
|
+
if (command !== "status") {
|
|
2430
|
+
throw headlessLifecycleError(command);
|
|
2431
|
+
}
|
|
726
2432
|
const checkedAt = Date.now();
|
|
727
2433
|
try {
|
|
728
|
-
const {
|
|
729
|
-
|
|
730
|
-
socketPath: config.brokerSocketPath
|
|
731
|
-
});
|
|
2434
|
+
const { health, trace } = await readHealth(config);
|
|
2435
|
+
const reachable = true;
|
|
732
2436
|
return {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
2437
|
+
serviceAdapter: "headless-foreground",
|
|
2438
|
+
label: config.label,
|
|
2439
|
+
mode: config.mode,
|
|
2440
|
+
launchAgentPath: config.launchAgentPath,
|
|
2441
|
+
bootoutCommand: "headless-foreground does not use launchd",
|
|
2442
|
+
brokerUrl: config.brokerUrl,
|
|
2443
|
+
brokerSocketPath: config.brokerSocketPath,
|
|
2444
|
+
supportDirectory: config.supportDirectory,
|
|
2445
|
+
runtimeDirectory: config.runtimeDirectory,
|
|
2446
|
+
controlHome: config.controlHome,
|
|
2447
|
+
stdoutLogPath: config.stdoutLogPath,
|
|
2448
|
+
stderrLogPath: config.stderrLogPath,
|
|
2449
|
+
installed: false,
|
|
2450
|
+
loaded: reachable,
|
|
2451
|
+
pid: null,
|
|
2452
|
+
launchdState: null,
|
|
2453
|
+
lastExitStatus: null,
|
|
2454
|
+
usesLaunchAgent: false,
|
|
2455
|
+
reachable,
|
|
2456
|
+
health: {
|
|
2457
|
+
reachable,
|
|
2458
|
+
ok: Boolean(health.ok),
|
|
2459
|
+
checkedAt,
|
|
2460
|
+
transport: trace.transport,
|
|
2461
|
+
socketPath: trace.socketPath,
|
|
2462
|
+
socketFallbackError: trace.socketFallbackError,
|
|
2463
|
+
nodeId: health.nodeId ?? undefined,
|
|
2464
|
+
meshId: health.meshId ?? undefined,
|
|
2465
|
+
counts: health.counts ?? undefined,
|
|
2466
|
+
build: health.build,
|
|
2467
|
+
services: health.services
|
|
2468
|
+
},
|
|
2469
|
+
lastLogLine: readLastLogLine([config.stderrLogPath, config.stdoutLogPath])
|
|
761
2470
|
};
|
|
762
2471
|
} catch (error) {
|
|
763
2472
|
return {
|
|
2473
|
+
serviceAdapter: "headless-foreground",
|
|
2474
|
+
label: config.label,
|
|
2475
|
+
mode: config.mode,
|
|
2476
|
+
launchAgentPath: config.launchAgentPath,
|
|
2477
|
+
bootoutCommand: "headless-foreground does not use launchd",
|
|
2478
|
+
brokerUrl: config.brokerUrl,
|
|
2479
|
+
brokerSocketPath: config.brokerSocketPath,
|
|
2480
|
+
supportDirectory: config.supportDirectory,
|
|
2481
|
+
runtimeDirectory: config.runtimeDirectory,
|
|
2482
|
+
controlHome: config.controlHome,
|
|
2483
|
+
stdoutLogPath: config.stdoutLogPath,
|
|
2484
|
+
stderrLogPath: config.stderrLogPath,
|
|
2485
|
+
installed: false,
|
|
2486
|
+
loaded: false,
|
|
2487
|
+
pid: null,
|
|
2488
|
+
launchdState: null,
|
|
2489
|
+
lastExitStatus: null,
|
|
2490
|
+
usesLaunchAgent: false,
|
|
764
2491
|
reachable: false,
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
2492
|
+
health: {
|
|
2493
|
+
reachable: false,
|
|
2494
|
+
ok: false,
|
|
2495
|
+
checkedAt,
|
|
2496
|
+
error: error instanceof Error ? error.message : String(error)
|
|
2497
|
+
},
|
|
2498
|
+
lastLogLine: readLastLogLine([config.stderrLogPath, config.stdoutLogPath])
|
|
769
2499
|
};
|
|
770
|
-
}
|
|
771
|
-
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
async function runBrokerServiceCommand(command, config) {
|
|
2503
|
+
const adapter = resolveBrokerServiceAdapter();
|
|
2504
|
+
switch (adapter) {
|
|
2505
|
+
case "macos-scoutd":
|
|
2506
|
+
return await runScoutdServiceCommand(command, config);
|
|
2507
|
+
case "headless-foreground":
|
|
2508
|
+
return await runHeadlessForegroundServiceCommand(command, config);
|
|
2509
|
+
case "linux-systemd-user":
|
|
2510
|
+
throw new Error("The linux-systemd-user service adapter is not implemented yet. " + "Use OPENSCOUT_SERVICE_ADAPTER=headless-foreground and run `openscout-runtime broker` under your process manager.");
|
|
2511
|
+
case "windows-service":
|
|
2512
|
+
throw new Error("The windows-service adapter is not implemented yet. " + "Use OPENSCOUT_SERVICE_ADAPTER=headless-foreground and run `openscout-runtime broker` from a shell.");
|
|
772
2513
|
}
|
|
773
2514
|
}
|
|
774
2515
|
async function brokerServiceStatus(config = resolveBrokerServiceConfig()) {
|
|
775
|
-
|
|
776
|
-
const launchctl = inspectLaunchctl(config);
|
|
777
|
-
const health = await fetchHealthSnapshot(config);
|
|
778
|
-
const installed = existsSync3(config.launchAgentPath);
|
|
779
|
-
const lastLogLine = health.reachable ? readLastLogLine([config.stdoutLogPath, config.stderrLogPath]) : readLastLogLine([config.stderrLogPath, config.stdoutLogPath]);
|
|
780
|
-
return {
|
|
781
|
-
label: config.label,
|
|
782
|
-
mode: config.mode,
|
|
783
|
-
launchAgentPath: config.launchAgentPath,
|
|
784
|
-
bootoutCommand: bootoutCommand(config),
|
|
785
|
-
brokerUrl: config.brokerUrl,
|
|
786
|
-
brokerSocketPath: config.brokerSocketPath,
|
|
787
|
-
supportDirectory: config.supportDirectory,
|
|
788
|
-
runtimeDirectory: config.runtimeDirectory,
|
|
789
|
-
controlHome: config.controlHome,
|
|
790
|
-
stdoutLogPath: config.stdoutLogPath,
|
|
791
|
-
stderrLogPath: config.stderrLogPath,
|
|
792
|
-
installed,
|
|
793
|
-
loaded: launchctl.loaded,
|
|
794
|
-
pid: launchctl.pid,
|
|
795
|
-
launchdState: launchctl.launchdState,
|
|
796
|
-
lastExitStatus: launchctl.lastExitStatus,
|
|
797
|
-
usesLaunchAgent: installed || launchctl.loaded,
|
|
798
|
-
reachable: health.reachable,
|
|
799
|
-
health,
|
|
800
|
-
lastLogLine
|
|
801
|
-
};
|
|
2516
|
+
return runBrokerServiceCommand("status", config);
|
|
802
2517
|
}
|
|
803
2518
|
async function installBrokerService(config = resolveBrokerServiceConfig()) {
|
|
804
|
-
|
|
805
|
-
writeLaunchAgentPlist(config);
|
|
806
|
-
return brokerServiceStatus(config);
|
|
2519
|
+
return runBrokerServiceCommand("install", config);
|
|
807
2520
|
}
|
|
808
2521
|
async function startBrokerService(config = resolveBrokerServiceConfig()) {
|
|
809
|
-
|
|
810
|
-
writeLaunchAgentPlist(config);
|
|
811
|
-
runCommand(launchctlPath(), ["bootout", config.serviceTarget], { allowFailure: true });
|
|
812
|
-
runCommand(launchctlPath(), ["bootstrap", config.domainTarget, config.launchAgentPath], { allowFailure: true });
|
|
813
|
-
runCommand(launchctlPath(), ["kickstart", "-k", config.serviceTarget], { allowFailure: true });
|
|
814
|
-
const attempts = Math.ceil(resolveBrokerStartTimeoutMs() / BROKER_SERVICE_POLL_INTERVAL_MS);
|
|
815
|
-
for (let attempt = 0;attempt < attempts; attempt += 1) {
|
|
816
|
-
const status2 = await brokerServiceStatus(config);
|
|
817
|
-
if (status2.health.reachable) {
|
|
818
|
-
return status2;
|
|
819
|
-
}
|
|
820
|
-
await sleep(BROKER_SERVICE_POLL_INTERVAL_MS);
|
|
821
|
-
}
|
|
822
|
-
const status = await brokerServiceStatus(config);
|
|
823
|
-
throw new Error(status.lastLogLine ?? status.health.error ?? "Broker service did not become healthy.");
|
|
824
|
-
}
|
|
825
|
-
function sleep(ms) {
|
|
826
|
-
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2522
|
+
return runBrokerServiceCommand("start", config);
|
|
827
2523
|
}
|
|
828
2524
|
async function stopBrokerService(config = resolveBrokerServiceConfig()) {
|
|
829
|
-
|
|
830
|
-
for (let attempt = 0;attempt < 30; attempt += 1) {
|
|
831
|
-
const status = await brokerServiceStatus(config);
|
|
832
|
-
if (!status.health.reachable) {
|
|
833
|
-
return status;
|
|
834
|
-
}
|
|
835
|
-
await sleep(BROKER_SERVICE_POLL_INTERVAL_MS);
|
|
836
|
-
}
|
|
837
|
-
return brokerServiceStatus(config);
|
|
2525
|
+
return runBrokerServiceCommand("stop", config);
|
|
838
2526
|
}
|
|
839
2527
|
async function restartBrokerService(config = resolveBrokerServiceConfig()) {
|
|
840
|
-
|
|
841
|
-
return startBrokerService(config);
|
|
2528
|
+
return runBrokerServiceCommand("restart", config);
|
|
842
2529
|
}
|
|
843
2530
|
async function uninstallBrokerService(config = resolveBrokerServiceConfig()) {
|
|
844
|
-
|
|
845
|
-
if (existsSync3(config.launchAgentPath)) {
|
|
846
|
-
rmSync2(config.launchAgentPath, { force: true });
|
|
847
|
-
}
|
|
848
|
-
const legacyPath = legacyLaunchAgentPath(config);
|
|
849
|
-
bootoutLegacyBrokerService(config);
|
|
850
|
-
if (existsSync3(legacyPath)) {
|
|
851
|
-
rmSync2(legacyPath, { force: true });
|
|
852
|
-
}
|
|
853
|
-
return brokerServiceStatus(config);
|
|
2531
|
+
return runBrokerServiceCommand("uninstall", config);
|
|
854
2532
|
}
|
|
855
2533
|
async function main() {
|
|
856
2534
|
const command = process.argv[2] ?? "status";
|
|
@@ -888,6 +2566,7 @@ async function main() {
|
|
|
888
2566
|
}
|
|
889
2567
|
function formatBrokerServiceStatus(status) {
|
|
890
2568
|
const lines = [
|
|
2569
|
+
`service adapter: ${status.serviceAdapter ?? "unknown"}`,
|
|
891
2570
|
`label: ${status.label}`,
|
|
892
2571
|
`mode: ${status.mode}`,
|
|
893
2572
|
`launch agent: ${status.installed ? status.launchAgentPath : "not installed"}`,
|
|
@@ -918,113 +2597,59 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1] && !pr
|
|
|
918
2597
|
});
|
|
919
2598
|
}
|
|
920
2599
|
|
|
921
|
-
// packages/runtime/src/local-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
renameSync,
|
|
927
|
-
writeFileSync as writeFileSync3
|
|
928
|
-
} from "fs";
|
|
929
|
-
import { homedir as homedir4, hostname as osHostname } from "os";
|
|
930
|
-
import { dirname as dirname3, join as join4 } from "path";
|
|
931
|
-
var LOCAL_CONFIG_VERSION = 1;
|
|
932
|
-
var DEFAULT_SCOUT_WEB_PORTAL_HOST = "scout.local";
|
|
933
|
-
var DEFAULT_LOCAL_CONFIG = {
|
|
934
|
-
version: LOCAL_CONFIG_VERSION,
|
|
935
|
-
host: "127.0.0.1",
|
|
936
|
-
webLocalName: undefined,
|
|
937
|
-
ports: {
|
|
938
|
-
broker: 65535,
|
|
939
|
-
web: 3200,
|
|
940
|
-
pairing: 7888
|
|
2600
|
+
// packages/runtime/src/local-edge.ts
|
|
2601
|
+
function normalizeViteHmrPath(value) {
|
|
2602
|
+
const trimmed = value?.trim();
|
|
2603
|
+
if (!trimmed) {
|
|
2604
|
+
return DEFAULT_SCOUT_WEB_VITE_HMR_PATH;
|
|
941
2605
|
}
|
|
942
|
-
};
|
|
943
|
-
|
|
944
|
-
const firstLabel = value?.trim().replace(/\.local\.?$/i, "").split(".").find((part) => part.trim().length > 0)?.trim();
|
|
945
|
-
const normalized = firstLabel?.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
946
|
-
return normalized || "localhost";
|
|
947
|
-
}
|
|
948
|
-
function normalizeLocalHostname(value) {
|
|
949
|
-
const trimmed = value?.trim().replace(/\.$/, "").toLowerCase();
|
|
950
|
-
const labels = trimmed?.split(".").map((label) => label.trim().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-")).filter(Boolean);
|
|
951
|
-
return labels && labels.length > 0 ? labels.join(".") : "localhost";
|
|
2606
|
+
const normalized = `${trimmed.startsWith("/") ? "" : "/"}${trimmed}`.replace(/\/{2,}/g, "/").replace(/\/+$/g, "");
|
|
2607
|
+
return normalized || DEFAULT_SCOUT_WEB_VITE_HMR_PATH;
|
|
952
2608
|
}
|
|
953
|
-
function
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
2609
|
+
function resolveViteUpstream(input) {
|
|
2610
|
+
const url = input.viteDevUrl?.trim();
|
|
2611
|
+
if (url) {
|
|
2612
|
+
try {
|
|
2613
|
+
return new URL(url).host;
|
|
2614
|
+
} catch {
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
960
2617
|
}
|
|
961
|
-
|
|
962
|
-
}
|
|
963
|
-
function localConfigHome() {
|
|
964
|
-
return process.env.OPENSCOUT_HOME ?? join4(homedir4(), ".openscout");
|
|
965
|
-
}
|
|
966
|
-
function localConfigPath() {
|
|
967
|
-
return join4(localConfigHome(), "config.json");
|
|
968
|
-
}
|
|
969
|
-
function loadLocalConfig() {
|
|
970
|
-
const configPath = localConfigPath();
|
|
971
|
-
if (!existsSync4(configPath))
|
|
972
|
-
return { version: LOCAL_CONFIG_VERSION };
|
|
973
|
-
try {
|
|
974
|
-
return validateLocalConfig(JSON.parse(readFileSync2(configPath, "utf8")));
|
|
975
|
-
} catch {
|
|
976
|
-
return { version: LOCAL_CONFIG_VERSION };
|
|
2618
|
+
if (typeof input.vitePort === "number" && input.vitePort > 0) {
|
|
2619
|
+
return `127.0.0.1:${input.vitePort}`;
|
|
977
2620
|
}
|
|
2621
|
+
return;
|
|
978
2622
|
}
|
|
979
|
-
function
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
const out = { version: LOCAL_CONFIG_VERSION };
|
|
984
|
-
if (typeof raw.host === "string" && raw.host.trim().length > 0) {
|
|
985
|
-
out.host = raw.host.trim();
|
|
986
|
-
}
|
|
987
|
-
if (typeof raw.webLocalName === "string" && raw.webLocalName.trim().length > 0) {
|
|
988
|
-
out.webLocalName = raw.webLocalName.trim();
|
|
989
|
-
}
|
|
990
|
-
if (raw.ports && typeof raw.ports === "object") {
|
|
991
|
-
const ports = raw.ports;
|
|
992
|
-
const p = {};
|
|
993
|
-
if (isValidPort(ports.broker))
|
|
994
|
-
p.broker = ports.broker;
|
|
995
|
-
if (isValidPort(ports.web))
|
|
996
|
-
p.web = ports.web;
|
|
997
|
-
if (isValidPort(ports.pairing))
|
|
998
|
-
p.pairing = ports.pairing;
|
|
999
|
-
if (Object.keys(p).length > 0)
|
|
1000
|
-
out.ports = p;
|
|
2623
|
+
function normalizeRouteHost(value) {
|
|
2624
|
+
const normalized = value?.trim().replace(/\.$/, "").toLowerCase();
|
|
2625
|
+
if (!normalized || normalized.includes("/") || /\s/.test(normalized)) {
|
|
2626
|
+
return null;
|
|
1001
2627
|
}
|
|
1002
|
-
return
|
|
1003
|
-
}
|
|
1004
|
-
function isValidPort(value) {
|
|
1005
|
-
return typeof value === "number" && Number.isInteger(value) && value > 0 && value < 65536;
|
|
1006
|
-
}
|
|
1007
|
-
function resolveBrokerPort() {
|
|
1008
|
-
return loadLocalConfig().ports?.broker ?? DEFAULT_LOCAL_CONFIG.ports.broker;
|
|
2628
|
+
return normalized;
|
|
1009
2629
|
}
|
|
1010
|
-
function resolveWebPort() {
|
|
1011
|
-
return loadLocalConfig().ports?.web ?? DEFAULT_LOCAL_CONFIG.ports.web;
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
// packages/runtime/src/local-edge.ts
|
|
1015
2630
|
function uniqRoutes(routes) {
|
|
1016
2631
|
const seen = new Set;
|
|
1017
2632
|
const out = [];
|
|
1018
2633
|
for (const route of routes) {
|
|
1019
|
-
const
|
|
2634
|
+
const host = normalizeRouteHost(route.host);
|
|
2635
|
+
if (!host) {
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
const key = `${host} ${route.upstream}`;
|
|
1020
2639
|
if (seen.has(key)) {
|
|
1021
2640
|
continue;
|
|
1022
2641
|
}
|
|
1023
2642
|
seen.add(key);
|
|
1024
|
-
out.push(route);
|
|
2643
|
+
out.push({ ...route, host });
|
|
1025
2644
|
}
|
|
1026
2645
|
return out;
|
|
1027
2646
|
}
|
|
2647
|
+
function formatCaddyHost(host) {
|
|
2648
|
+
if (host.includes(":") && !host.startsWith("[") && !host.endsWith("]")) {
|
|
2649
|
+
return `[${host}]`;
|
|
2650
|
+
}
|
|
2651
|
+
return host;
|
|
2652
|
+
}
|
|
1028
2653
|
function resolveOpenScoutLocalEdgeConfig(input = {}) {
|
|
1029
2654
|
const portalHost = resolveScoutWebNamedHostname(input.portalHost ?? DEFAULT_SCOUT_WEB_PORTAL_HOST);
|
|
1030
2655
|
const nodeHost = resolveScoutWebNamedHostname(input.nodeHost ?? resolveConfiguredScoutWebHostname());
|
|
@@ -1032,6 +2657,7 @@ function resolveOpenScoutLocalEdgeConfig(input = {}) {
|
|
|
1032
2657
|
const scheme = input.scheme ?? "http";
|
|
1033
2658
|
const upstream = `127.0.0.1:${input.webPort ?? resolveWebPort()}`;
|
|
1034
2659
|
const brokerUpstream = `127.0.0.1:${input.brokerPort ?? resolveBrokerPort()}`;
|
|
2660
|
+
const viteUpstream = resolveViteUpstream(input);
|
|
1035
2661
|
return {
|
|
1036
2662
|
portalHost,
|
|
1037
2663
|
nodeHost,
|
|
@@ -1040,8 +2666,18 @@ function resolveOpenScoutLocalEdgeConfig(input = {}) {
|
|
|
1040
2666
|
brokerUpstream,
|
|
1041
2667
|
routes: uniqRoutes([
|
|
1042
2668
|
{ host: portalHost, upstream },
|
|
1043
|
-
{ host: wildcardHost, upstream }
|
|
1044
|
-
|
|
2669
|
+
{ host: wildcardHost, upstream },
|
|
2670
|
+
...viteUpstream ? [{
|
|
2671
|
+
host: resolveScoutWebDevHostname(portalHost),
|
|
2672
|
+
upstream: viteUpstream,
|
|
2673
|
+
mode: "vite-dev"
|
|
2674
|
+
}] : [],
|
|
2675
|
+
...(input.extraHosts ?? []).map((host) => ({ host, upstream }))
|
|
2676
|
+
]),
|
|
2677
|
+
...viteUpstream ? {
|
|
2678
|
+
viteUpstream,
|
|
2679
|
+
viteHmrPath: normalizeViteHmrPath(input.viteHmrPath)
|
|
2680
|
+
} : {}
|
|
1045
2681
|
};
|
|
1046
2682
|
}
|
|
1047
2683
|
function renderOpenScoutStartPage(config) {
|
|
@@ -1242,12 +2878,10 @@ function renderOpenScoutStartPage(config) {
|
|
|
1242
2878
|
</body>
|
|
1243
2879
|
</html>`;
|
|
1244
2880
|
}
|
|
1245
|
-
function
|
|
1246
|
-
const
|
|
1247
|
-
const
|
|
1248
|
-
|
|
1249
|
-
const host = scheme === "http" ? `http://${route.host}` : route.host;
|
|
1250
|
-
return `${host} {
|
|
2881
|
+
function renderCaddyDefaultRouteBlock(config, route, scheme, startPage) {
|
|
2882
|
+
const routeHost = formatCaddyHost(route.host);
|
|
2883
|
+
const host = scheme === "http" ? `http://${routeHost}` : routeHost;
|
|
2884
|
+
return `${host} {
|
|
1251
2885
|
` + (scheme === "https" ? ` tls internal
|
|
1252
2886
|
` : "") + ` handle /__openscout/web/start {
|
|
1253
2887
|
` + ` rewrite * /v1/web/start
|
|
@@ -1259,8 +2893,8 @@ function renderOpenScoutCaddyfile(config) {
|
|
|
1259
2893
|
` + ` }
|
|
1260
2894
|
` + ` handle {
|
|
1261
2895
|
` + ` reverse_proxy ${route.upstream} {
|
|
1262
|
-
` + ` lb_try_duration
|
|
1263
|
-
` + ` lb_try_interval
|
|
2896
|
+
` + ` lb_try_duration 15s
|
|
2897
|
+
` + ` lb_try_interval 500ms
|
|
1264
2898
|
` + ` }
|
|
1265
2899
|
` + ` }
|
|
1266
2900
|
` + ` handle_errors {
|
|
@@ -1270,7 +2904,24 @@ function renderOpenScoutCaddyfile(config) {
|
|
|
1270
2904
|
` + `HTML 200
|
|
1271
2905
|
` + ` }
|
|
1272
2906
|
` + `}`;
|
|
1273
|
-
|
|
2907
|
+
}
|
|
2908
|
+
function renderCaddyViteDevRouteBlock(route, scheme) {
|
|
2909
|
+
const routeHost = formatCaddyHost(route.host);
|
|
2910
|
+
const host = scheme === "http" ? `http://${routeHost}` : routeHost;
|
|
2911
|
+
return `${host} {
|
|
2912
|
+
` + (scheme === "https" ? ` tls internal
|
|
2913
|
+
` : "") + ` handle {
|
|
2914
|
+
` + ` reverse_proxy ${route.upstream} {
|
|
2915
|
+
` + ` lb_try_duration 15s
|
|
2916
|
+
` + ` lb_try_interval 500ms
|
|
2917
|
+
` + ` }
|
|
2918
|
+
` + ` }
|
|
2919
|
+
` + `}`;
|
|
2920
|
+
}
|
|
2921
|
+
function renderOpenScoutCaddyfile(config) {
|
|
2922
|
+
const schemes = config.scheme === "both" ? ["http", "https"] : [config.scheme];
|
|
2923
|
+
const startPage = renderOpenScoutStartPage(config);
|
|
2924
|
+
const blocks = schemes.flatMap((scheme) => config.routes.map((route) => route.mode === "vite-dev" ? renderCaddyViteDevRouteBlock(route, scheme) : renderCaddyDefaultRouteBlock(config, route, scheme, startPage))).join(`
|
|
1274
2925
|
|
|
1275
2926
|
`);
|
|
1276
2927
|
return `${blocks}
|
|
@@ -1282,8 +2933,9 @@ var RESTART_MIN_DELAY_MS = 1000;
|
|
|
1282
2933
|
var RESTART_MAX_DELAY_MS = 30000;
|
|
1283
2934
|
var BROKER_HEALTH_TIMEOUT_MS = 30000;
|
|
1284
2935
|
var BROKER_HEALTH_POLL_MS = 250;
|
|
1285
|
-
var
|
|
1286
|
-
var
|
|
2936
|
+
var CHILD_SHUTDOWN_TIMEOUT_MS = 12000;
|
|
2937
|
+
var MENU_BUNDLE_ID = "app.openscout.scout.menu";
|
|
2938
|
+
var MENU_PROCESS_NAME = "ScoutMenu";
|
|
1287
2939
|
var PROCESS_NAME = "scout-base";
|
|
1288
2940
|
var BROKER_LAUNCHER_PROCESS_NAME = "scout-broker-run";
|
|
1289
2941
|
var EDGE_PROCESS_NAME = "scout-edge";
|
|
@@ -1296,8 +2948,9 @@ var mdnsProcesses = [];
|
|
|
1296
2948
|
var brokerRestartDelayMs = RESTART_MIN_DELAY_MS;
|
|
1297
2949
|
var edgeRestartDelayMs = RESTART_MIN_DELAY_MS;
|
|
1298
2950
|
var supervisedWebPid = null;
|
|
1299
|
-
var
|
|
2951
|
+
var baseKeepAlive = null;
|
|
1300
2952
|
var config = resolveBrokerServiceConfig();
|
|
2953
|
+
var brokerControlUrl = buildLocalBrokerControlUrl(config.brokerHost, config.brokerPort);
|
|
1301
2954
|
function log(message, details) {
|
|
1302
2955
|
if (details === undefined) {
|
|
1303
2956
|
console.log(`[openscout-base] ${message}`);
|
|
@@ -1312,24 +2965,54 @@ function warn(message, details) {
|
|
|
1312
2965
|
}
|
|
1313
2966
|
console.warn(`[openscout-base] ${message}`, details);
|
|
1314
2967
|
}
|
|
2968
|
+
function splitCsv(value) {
|
|
2969
|
+
return (value ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
2970
|
+
}
|
|
2971
|
+
function appendCsvValues(input, values) {
|
|
2972
|
+
const seen = new Set;
|
|
2973
|
+
const out = [];
|
|
2974
|
+
for (const value of [...splitCsv(input), ...values]) {
|
|
2975
|
+
const normalized = value.trim().toLowerCase();
|
|
2976
|
+
if (!normalized || seen.has(normalized)) {
|
|
2977
|
+
continue;
|
|
2978
|
+
}
|
|
2979
|
+
seen.add(normalized);
|
|
2980
|
+
out.push(normalized);
|
|
2981
|
+
}
|
|
2982
|
+
return out.length > 0 ? out.join(",") : undefined;
|
|
2983
|
+
}
|
|
2984
|
+
function resolveTailnetWebHosts() {
|
|
2985
|
+
return readTailscaleSelfWebHostsSync();
|
|
2986
|
+
}
|
|
2987
|
+
function resolveWebTrustedHostsEnv() {
|
|
2988
|
+
return appendCsvValues(process.env.OPENSCOUT_WEB_TRUSTED_HOSTS, resolveTailnetWebHosts());
|
|
2989
|
+
}
|
|
1315
2990
|
function ensureDirectory(path) {
|
|
1316
|
-
|
|
2991
|
+
mkdirSync2(path, { recursive: true });
|
|
1317
2992
|
}
|
|
1318
2993
|
function logFile(name) {
|
|
1319
|
-
const dir =
|
|
2994
|
+
const dir = join6(config.supportDirectory, "logs", "base");
|
|
1320
2995
|
ensureDirectory(dir);
|
|
1321
|
-
return openSync(
|
|
2996
|
+
return openSync(join6(dir, name), "a");
|
|
1322
2997
|
}
|
|
1323
2998
|
function runtimeEntrypoint(config2) {
|
|
1324
|
-
return
|
|
2999
|
+
return join6(config2.runtimePackageDir, "bin", "openscout-runtime.mjs");
|
|
1325
3000
|
}
|
|
1326
3001
|
function spawnBroker() {
|
|
1327
3002
|
if (shuttingDown || brokerProcess) {
|
|
1328
3003
|
return;
|
|
1329
3004
|
}
|
|
3005
|
+
if (!config.bunExecutable) {
|
|
3006
|
+
warn("broker supervisor cannot start broker without Bun", {
|
|
3007
|
+
hint: "Use the headless broker entrypoint under Node or install Bun for the macOS base supervisor."
|
|
3008
|
+
});
|
|
3009
|
+
scheduleBrokerRestart();
|
|
3010
|
+
return;
|
|
3011
|
+
}
|
|
3012
|
+
const webTrustedHostsEnv = resolveWebTrustedHostsEnv();
|
|
1330
3013
|
const stdout = logFile("broker.stdout.log");
|
|
1331
3014
|
const stderr = logFile("broker.stderr.log");
|
|
1332
|
-
brokerProcess =
|
|
3015
|
+
brokerProcess = spawn3(config.bunExecutable, [
|
|
1333
3016
|
"run",
|
|
1334
3017
|
runtimeEntrypoint(config),
|
|
1335
3018
|
"broker"
|
|
@@ -1344,7 +3027,9 @@ function spawnBroker() {
|
|
|
1344
3027
|
OPENSCOUT_BROKER_URL: config.brokerUrl,
|
|
1345
3028
|
OPENSCOUT_BROKER_SOCKET_PATH: config.brokerSocketPath,
|
|
1346
3029
|
OPENSCOUT_CONTROL_HOME: config.controlHome,
|
|
1347
|
-
OPENSCOUT_ADVERTISE_SCOPE: config.advertiseScope
|
|
3030
|
+
OPENSCOUT_ADVERTISE_SCOPE: config.advertiseScope,
|
|
3031
|
+
...openScoutNetworkServiceEnvironment(process.env),
|
|
3032
|
+
...webTrustedHostsEnv ? { OPENSCOUT_WEB_TRUSTED_HOSTS: webTrustedHostsEnv } : {}
|
|
1348
3033
|
},
|
|
1349
3034
|
stdio: ["ignore", stdout, stderr]
|
|
1350
3035
|
});
|
|
@@ -1372,7 +3057,7 @@ async function waitForBrokerHealth(timeoutMs = BROKER_HEALTH_TIMEOUT_MS) {
|
|
|
1372
3057
|
const deadline = Date.now() + timeoutMs;
|
|
1373
3058
|
while (Date.now() < deadline) {
|
|
1374
3059
|
try {
|
|
1375
|
-
const response = await fetch(new URL("/health",
|
|
3060
|
+
const response = await fetch(new URL("/health", brokerControlUrl), {
|
|
1376
3061
|
headers: { accept: "application/json" },
|
|
1377
3062
|
signal: AbortSignal.timeout(1000)
|
|
1378
3063
|
});
|
|
@@ -1384,7 +3069,7 @@ async function waitForBrokerHealth(timeoutMs = BROKER_HEALTH_TIMEOUT_MS) {
|
|
|
1384
3069
|
}
|
|
1385
3070
|
}
|
|
1386
3071
|
} catch {}
|
|
1387
|
-
await
|
|
3072
|
+
await sleep(BROKER_HEALTH_POLL_MS);
|
|
1388
3073
|
}
|
|
1389
3074
|
return false;
|
|
1390
3075
|
}
|
|
@@ -1395,6 +3080,9 @@ function resolveEdgeScheme() {
|
|
|
1395
3080
|
}
|
|
1396
3081
|
return "http";
|
|
1397
3082
|
}
|
|
3083
|
+
function forwardedProtoForEdgeScheme(scheme) {
|
|
3084
|
+
return scheme === "http" ? "http" : "https";
|
|
3085
|
+
}
|
|
1398
3086
|
function resolveEdgeConfig() {
|
|
1399
3087
|
const portalHost = process.env.OPENSCOUT_WEB_PORTAL_HOST?.trim() || DEFAULT_SCOUT_WEB_PORTAL_HOST;
|
|
1400
3088
|
const nodeHost = process.env.OPENSCOUT_WEB_ADVERTISED_HOST?.trim() || (process.env.OPENSCOUT_WEB_LOCAL_NAME?.trim() ? resolveScoutWebNamedHostname(process.env.OPENSCOUT_WEB_LOCAL_NAME) : resolveConfiguredScoutWebHostname());
|
|
@@ -1403,19 +3091,25 @@ function resolveEdgeConfig() {
|
|
|
1403
3091
|
nodeHost,
|
|
1404
3092
|
scheme: resolveEdgeScheme(),
|
|
1405
3093
|
brokerPort: config.brokerPort,
|
|
1406
|
-
webPort: Number.parseInt(process.env.OPENSCOUT_WEB_PORT ?? "", 10) || resolveWebPort()
|
|
3094
|
+
webPort: Number.parseInt(process.env.OPENSCOUT_WEB_PORT ?? "", 10) || resolveWebPort(),
|
|
3095
|
+
viteDevUrl: process.env.OPENSCOUT_WEB_VITE_URL,
|
|
3096
|
+
viteHmrPath: process.env.OPENSCOUT_WEB_VITE_HMR_PATH,
|
|
3097
|
+
extraHosts: [
|
|
3098
|
+
...splitCsv(process.env.OPENSCOUT_WEB_TRUSTED_HOSTS),
|
|
3099
|
+
...resolveTailnetWebHosts()
|
|
3100
|
+
]
|
|
1407
3101
|
});
|
|
1408
3102
|
}
|
|
1409
3103
|
function resolveLocalEdgeCaddyfilePath() {
|
|
1410
|
-
const dir =
|
|
3104
|
+
const dir = join6(homedir6(), ".scout", "local-edge");
|
|
1411
3105
|
ensureDirectory(dir);
|
|
1412
|
-
return
|
|
3106
|
+
return join6(dir, "Caddyfile");
|
|
1413
3107
|
}
|
|
1414
3108
|
function resolveCaddyExecutable() {
|
|
1415
3109
|
return process.env.OPENSCOUT_CADDY_BIN?.trim() || "caddy";
|
|
1416
3110
|
}
|
|
1417
3111
|
function spawnMdnsProxy(input) {
|
|
1418
|
-
return
|
|
3112
|
+
return spawn3("/usr/bin/dns-sd", [
|
|
1419
3113
|
"-P",
|
|
1420
3114
|
input.name,
|
|
1421
3115
|
input.scheme === "https" ? "_https._tcp" : "_http._tcp",
|
|
@@ -1451,7 +3145,7 @@ function startLocalEdge() {
|
|
|
1451
3145
|
const edgeConfig = resolveEdgeConfig();
|
|
1452
3146
|
const schemes = edgeConfig.scheme === "both" ? ["http", "https"] : [edgeConfig.scheme];
|
|
1453
3147
|
const caddyfilePath = resolveLocalEdgeCaddyfilePath();
|
|
1454
|
-
|
|
3148
|
+
writeFileSync2(caddyfilePath, renderOpenScoutCaddyfile(edgeConfig), "utf8");
|
|
1455
3149
|
mdnsProcesses = schemes.flatMap((scheme) => {
|
|
1456
3150
|
const edgePort = scheme === "https" ? 443 : 80;
|
|
1457
3151
|
const suffix = scheme.toUpperCase();
|
|
@@ -1470,7 +3164,7 @@ function startLocalEdge() {
|
|
|
1470
3164
|
})
|
|
1471
3165
|
];
|
|
1472
3166
|
});
|
|
1473
|
-
caddyProcess =
|
|
3167
|
+
caddyProcess = spawn3(resolveCaddyExecutable(), [
|
|
1474
3168
|
"run",
|
|
1475
3169
|
"--config",
|
|
1476
3170
|
caddyfilePath,
|
|
@@ -1519,8 +3213,8 @@ async function startWebWhenBrokerIsReady() {
|
|
|
1519
3213
|
}
|
|
1520
3214
|
try {
|
|
1521
3215
|
const edgeConfig = resolveEdgeConfig();
|
|
1522
|
-
const scheme = edgeConfig.scheme
|
|
1523
|
-
const response = await fetch(new URL("/v1/web/start",
|
|
3216
|
+
const scheme = forwardedProtoForEdgeScheme(edgeConfig.scheme);
|
|
3217
|
+
const response = await fetch(new URL("/v1/web/start", brokerControlUrl), {
|
|
1524
3218
|
method: "POST",
|
|
1525
3219
|
headers: {
|
|
1526
3220
|
accept: "application/json",
|
|
@@ -1553,8 +3247,8 @@ function resolvePortListenerPid(port) {
|
|
|
1553
3247
|
function findRepoMenuBundle() {
|
|
1554
3248
|
let current = dirname4(fileURLToPath2(import.meta.url));
|
|
1555
3249
|
while (true) {
|
|
1556
|
-
const candidate = resolve3(current, "apps", "macos", "dist", "
|
|
1557
|
-
if (
|
|
3250
|
+
const candidate = resolve3(current, "apps", "macos", "dist", "ScoutMenu.app");
|
|
3251
|
+
if (existsSync6(candidate)) {
|
|
1558
3252
|
return candidate;
|
|
1559
3253
|
}
|
|
1560
3254
|
const parent = dirname4(current);
|
|
@@ -1569,9 +3263,9 @@ function startMenuBarApp() {
|
|
|
1569
3263
|
return;
|
|
1570
3264
|
}
|
|
1571
3265
|
const explicitBundle = process.env.OPENSCOUT_MENU_BUNDLE_PATH?.trim();
|
|
1572
|
-
const repoBundle = explicitBundle &&
|
|
3266
|
+
const repoBundle = explicitBundle && existsSync6(explicitBundle) ? explicitBundle : findRepoMenuBundle();
|
|
1573
3267
|
const args = repoBundle ? [repoBundle] : ["-b", MENU_BUNDLE_ID];
|
|
1574
|
-
const child =
|
|
3268
|
+
const child = spawn3("open", args, {
|
|
1575
3269
|
stdio: ["ignore", logFile("menu.stdout.log"), logFile("menu.stderr.log")]
|
|
1576
3270
|
});
|
|
1577
3271
|
child.once("exit", (code) => {
|
|
@@ -1586,7 +3280,45 @@ function stopMenuBarApp() {
|
|
|
1586
3280
|
if (process.platform !== "darwin" || process.env.OPENSCOUT_BASE_MENU_ENABLED === "0") {
|
|
1587
3281
|
return;
|
|
1588
3282
|
}
|
|
1589
|
-
|
|
3283
|
+
spawn3("pkill", ["-x", MENU_PROCESS_NAME], { stdio: "ignore" }).unref();
|
|
3284
|
+
}
|
|
3285
|
+
function isChildExited(child) {
|
|
3286
|
+
return child.exitCode !== null || child.signalCode !== null;
|
|
3287
|
+
}
|
|
3288
|
+
function waitForChildExit(child, timeoutMs) {
|
|
3289
|
+
if (isChildExited(child)) {
|
|
3290
|
+
return Promise.resolve(true);
|
|
3291
|
+
}
|
|
3292
|
+
return new Promise((resolve4) => {
|
|
3293
|
+
const timeout = setTimeout(() => {
|
|
3294
|
+
child.off("exit", onExit);
|
|
3295
|
+
resolve4(false);
|
|
3296
|
+
}, timeoutMs);
|
|
3297
|
+
timeout.unref();
|
|
3298
|
+
const onExit = () => {
|
|
3299
|
+
clearTimeout(timeout);
|
|
3300
|
+
resolve4(true);
|
|
3301
|
+
};
|
|
3302
|
+
child.once("exit", onExit);
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
async function terminateChildProcess(child, label, timeoutMs = CHILD_SHUTDOWN_TIMEOUT_MS) {
|
|
3306
|
+
if (!child || isChildExited(child)) {
|
|
3307
|
+
return;
|
|
3308
|
+
}
|
|
3309
|
+
if (!child.killed) {
|
|
3310
|
+
child.kill("SIGTERM");
|
|
3311
|
+
}
|
|
3312
|
+
if (await waitForChildExit(child, timeoutMs)) {
|
|
3313
|
+
return;
|
|
3314
|
+
}
|
|
3315
|
+
warn(`${label} did not exit after SIGTERM; forcing shutdown`, { pid: child.pid });
|
|
3316
|
+
try {
|
|
3317
|
+
child.kill("SIGKILL");
|
|
3318
|
+
} catch {
|
|
3319
|
+
return;
|
|
3320
|
+
}
|
|
3321
|
+
await waitForChildExit(child, 2000);
|
|
1590
3322
|
}
|
|
1591
3323
|
function stopSupervisedWeb() {
|
|
1592
3324
|
if (supervisedWebPid && supervisedWebPid > 0) {
|
|
@@ -1601,27 +3333,26 @@ async function shutdown(exitCode = 0) {
|
|
|
1601
3333
|
return;
|
|
1602
3334
|
}
|
|
1603
3335
|
shuttingDown = true;
|
|
1604
|
-
if (
|
|
1605
|
-
clearInterval(
|
|
1606
|
-
|
|
3336
|
+
if (baseKeepAlive) {
|
|
3337
|
+
clearInterval(baseKeepAlive);
|
|
3338
|
+
baseKeepAlive = null;
|
|
1607
3339
|
}
|
|
1608
3340
|
stopSupervisedWeb();
|
|
1609
3341
|
stopMenuBarApp();
|
|
3342
|
+
const activeCaddyProcess = caddyProcess;
|
|
1610
3343
|
stopEdgeProcesses();
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
}
|
|
1614
|
-
await sleep2(500);
|
|
3344
|
+
await terminateChildProcess(brokerProcess, "broker");
|
|
3345
|
+
await terminateChildProcess(activeCaddyProcess, "local edge", 2000);
|
|
1615
3346
|
process.exit(exitCode);
|
|
1616
3347
|
}
|
|
1617
|
-
function
|
|
3348
|
+
function sleep(ms) {
|
|
1618
3349
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
1619
3350
|
}
|
|
1620
|
-
function
|
|
1621
|
-
if (
|
|
3351
|
+
function anchorBaseDaemonLifetime() {
|
|
3352
|
+
if (baseKeepAlive) {
|
|
1622
3353
|
return;
|
|
1623
3354
|
}
|
|
1624
|
-
|
|
3355
|
+
baseKeepAlive = setInterval(() => {
|
|
1625
3356
|
return;
|
|
1626
3357
|
}, 60000);
|
|
1627
3358
|
}
|
|
@@ -1638,7 +3369,7 @@ log("starting Scout base service", {
|
|
|
1638
3369
|
brokerUrl: config.brokerUrl,
|
|
1639
3370
|
bootout: `launchctl bootout ${config.serviceTarget}`
|
|
1640
3371
|
});
|
|
1641
|
-
|
|
3372
|
+
anchorBaseDaemonLifetime();
|
|
1642
3373
|
spawnBroker();
|
|
1643
3374
|
startLocalEdge();
|
|
1644
3375
|
startMenuBarApp();
|