@monster-orange/den 0.2.3 → 0.2.4
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/CHANGELOG.md +111 -0
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/environment-manager/health.ts +36 -1
- package/src/shared/environment-manager/opctl.ts +83 -1
- package/src/shared/environment-manager/service-runner.ts +49 -4
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/paths.ts +42 -0
- package/src/shared/product.ts +7 -1
- package/src/shared/profile.ts +8 -0
- package/src/shared/read-cache.ts +77 -0
- package/src/shared/stacks/status.ts +109 -1
- package/src/ui/cli/index.ts +119 -10
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +98 -183
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/workspace-routes.ts +240 -181
package/src/ui/server/index.ts
CHANGED
|
@@ -39,7 +39,6 @@ import type {
|
|
|
39
39
|
import { getRunDonePayload } from "./perf-report.js";
|
|
40
40
|
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
41
41
|
import {
|
|
42
|
-
readWorkspacesFile,
|
|
43
42
|
getActiveWorkspace,
|
|
44
43
|
getActiveWorkspaces,
|
|
45
44
|
getFocusedWorkspaceName,
|
|
@@ -49,10 +48,7 @@ import {
|
|
|
49
48
|
resolveDepAddress,
|
|
50
49
|
parseAddress,
|
|
51
50
|
readManifestAnyBranch,
|
|
52
|
-
noAutostartRepos,
|
|
53
51
|
} from "../../shared/environment-manager/workspaces.js";
|
|
54
|
-
import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
|
|
55
|
-
import { orderForDown } from "../../shared/environment-manager/derivation/derived-up.js";
|
|
56
52
|
import { findServiceContainer } from "../../shared/environment-manager/service-container.js";
|
|
57
53
|
import {
|
|
58
54
|
resolveHistorySource,
|
|
@@ -65,6 +61,7 @@ import {
|
|
|
65
61
|
import {
|
|
66
62
|
pingHttp,
|
|
67
63
|
pingShell,
|
|
64
|
+
prewarmHealthTls,
|
|
68
65
|
} from "../../shared/environment-manager/health.js";
|
|
69
66
|
import {
|
|
70
67
|
runServiceCommand,
|
|
@@ -74,7 +71,7 @@ import {
|
|
|
74
71
|
unregisterDaemon,
|
|
75
72
|
checkServiceHealth,
|
|
76
73
|
loadDaemonsFromFile,
|
|
77
|
-
|
|
74
|
+
pruneDeadDaemonsAsync,
|
|
78
75
|
isInteractiveServiceCommand,
|
|
79
76
|
rewriteLegacyOpctlKillCommandForContainer,
|
|
80
77
|
shouldDetachServiceCommand,
|
|
@@ -142,6 +139,10 @@ import {
|
|
|
142
139
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
143
140
|
import { serveUiAsset } from "./ui-assets.js";
|
|
144
141
|
import { graphPrefs, mergeGraphPrefs } from "./graph-prefs.js";
|
|
142
|
+
import { singleFlight } from "./single-flight.js";
|
|
143
|
+
import { graphOrder } from "./graph-order.js";
|
|
144
|
+
import { clearReadCache, enableReadCache } from "../../shared/read-cache.js";
|
|
145
|
+
import { buildServicesList, type ServicesList } from "./services-list.js";
|
|
145
146
|
|
|
146
147
|
const __filename = fileURLToPath(import.meta.url);
|
|
147
148
|
const __dirname = path.dirname(__filename);
|
|
@@ -209,6 +210,17 @@ function viewerRewrite(
|
|
|
209
210
|
|
|
210
211
|
const HEALTH_CACHE_TTL_MS = 8_000;
|
|
211
212
|
const healthCache = new Map<string, { results: HealthResult[]; at: number }>();
|
|
213
|
+
/** The full-env health run in flight per cache key: concurrent polls join it. */
|
|
214
|
+
const healthInFlight = new Map<string, Promise<HealthResult[]>>();
|
|
215
|
+
/** /api/services builds, shared by concurrent polls and reused briefly. */
|
|
216
|
+
/** The startup sweep of stale daemon records (see below the routes). */
|
|
217
|
+
let startupPrune: Promise<void> = Promise.resolve();
|
|
218
|
+
const SERVICES_CACHE_TTL_MS = 2_000;
|
|
219
|
+
// Manifests, the edge config and the derived graph order are reused across
|
|
220
|
+
// a request and the polls after it (cleared by any mutating request).
|
|
221
|
+
const READ_CACHE_TTL_MS = 5_000;
|
|
222
|
+
enableReadCache(READ_CACHE_TTL_MS);
|
|
223
|
+
const servicesCache = singleFlight<ServicesList>(SERVICES_CACHE_TTL_MS);
|
|
212
224
|
/** A workspace-scoped run covers a different service set than the default one. */
|
|
213
225
|
function healthCacheKey(env: Environment, workspace: string | null): string {
|
|
214
226
|
return `${env}|${workspace ?? "*"}`;
|
|
@@ -534,6 +546,15 @@ async function handleRequest(
|
|
|
534
546
|
): Promise<void> {
|
|
535
547
|
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
|
536
548
|
const pathname = url.pathname;
|
|
549
|
+
// A mutating request may change what /api/services would list (exec and
|
|
550
|
+
// sync stream over GET, for EventSource).
|
|
551
|
+
if (
|
|
552
|
+
(req.method !== "GET" && req.method !== "HEAD") ||
|
|
553
|
+
/\/(exec|sync)$/.test(pathname)
|
|
554
|
+
) {
|
|
555
|
+
servicesCache.clear();
|
|
556
|
+
clearReadCache();
|
|
557
|
+
}
|
|
537
558
|
|
|
538
559
|
// ── Request logging ────────────────────────────────────────────────────────
|
|
539
560
|
// /api/health fires on the UI's 5s polling interval forever — it dominates
|
|
@@ -1019,9 +1040,7 @@ async function handleRequest(
|
|
|
1019
1040
|
// direction as the graph (and `den status`). orderForDown is the reverse
|
|
1020
1041
|
// of the deps-first startup order.
|
|
1021
1042
|
const rank = new Map(
|
|
1022
|
-
|
|
1023
|
-
deriveGraph({ scope: "all", workspace: workspaces[0]?.name }),
|
|
1024
|
-
).map((repo, i) => [repo, i]),
|
|
1043
|
+
graphOrder(workspaces[0]?.name).map((repo, i) => [repo, i]),
|
|
1025
1044
|
);
|
|
1026
1045
|
const sortedEntries = [...entries].sort((a, b) => {
|
|
1027
1046
|
const ra = rank.get(a.repo) ?? Number.MAX_SAFE_INTEGER;
|
|
@@ -1143,9 +1162,17 @@ async function handleRequest(
|
|
|
1143
1162
|
if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
|
|
1144
1163
|
return sendJson(res, hit.results.map(forViewer));
|
|
1145
1164
|
}
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1165
|
+
let run = healthInFlight.get(cacheKey);
|
|
1166
|
+
if (!run) {
|
|
1167
|
+
run = computeHealth(env, null, undefined, wsParam)
|
|
1168
|
+
.then((results) => {
|
|
1169
|
+
healthCache.set(cacheKey, { results, at: Date.now() });
|
|
1170
|
+
return results;
|
|
1171
|
+
})
|
|
1172
|
+
.finally(() => healthInFlight.delete(cacheKey));
|
|
1173
|
+
healthInFlight.set(cacheKey, run);
|
|
1174
|
+
}
|
|
1175
|
+
return sendJson(res, (await run).map(forViewer));
|
|
1149
1176
|
}
|
|
1150
1177
|
|
|
1151
1178
|
// Same checks, streamed: each result is sent the moment it lands so the fast
|
|
@@ -1196,169 +1223,15 @@ async function handleRequest(
|
|
|
1196
1223
|
const env = (url.searchParams.get("env") ?? "local") as Environment;
|
|
1197
1224
|
// Optional: caller may request services for a specific (non-active) workspace.
|
|
1198
1225
|
const wsOverride = url.searchParams.get("workspace") ?? null;
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
// running containers with no declared command, no-ops gracefully), and
|
|
1202
|
-
// `restart` is orchestrated by the exec runner as down → verify → up — so
|
|
1203
|
-
// every service with an `up` can offer both even when the manifest doesn't
|
|
1204
|
-
// declare them.
|
|
1205
|
-
const withDown = (cmds: string[]): string[] => {
|
|
1206
|
-
if (env !== "local") return cmds;
|
|
1207
|
-
const out = [...cmds];
|
|
1208
|
-
if (!out.includes("down")) out.push("down");
|
|
1209
|
-
if (!out.includes("restart") && out.includes("up")) out.push("restart");
|
|
1210
|
-
return out;
|
|
1211
|
-
};
|
|
1212
|
-
|
|
1213
|
-
// Legacy: discovered repos from .den/ symlinks / registered-repos.json
|
|
1214
|
-
const legacyRepos = discoverRepos();
|
|
1215
|
-
const legacyEntries = legacyRepos.flatMap((repo) => {
|
|
1216
|
-
const manifestLayers = loadManifest(repo.repoPath).layers;
|
|
1217
|
-
return Object.entries(repo.manifest.services ?? {}).map(
|
|
1218
|
-
([svcName, svc]) => ({
|
|
1219
|
-
repo: repo.repoName,
|
|
1220
|
-
service: svcName,
|
|
1221
|
-
address: `${repo.repoName}/${svcName}`,
|
|
1222
|
-
repoPath: repo.repoPath,
|
|
1223
|
-
commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
|
|
1224
|
-
logs: svc.logs?.[env] ?? null,
|
|
1225
|
-
mode: "dev-managed" as const,
|
|
1226
|
-
branch: null as string | null,
|
|
1227
|
-
fromCatalog: false,
|
|
1228
|
-
isEntrypoint: repo.manifest.entrypoint === svcName,
|
|
1229
|
-
autostart: true, // legacy discovery has no workspace entry to toggle
|
|
1230
|
-
deps: (svc.depends_on ?? []).map(depService),
|
|
1231
|
-
cloned: true, // legacy discovery only ever finds on-disk repos
|
|
1232
|
-
manifestLayers,
|
|
1233
|
-
...servicePlacement(repo.manifest, svcName, env),
|
|
1234
|
-
}),
|
|
1235
|
-
);
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
// Catalog entries (workspace-aware)
|
|
1239
|
-
const catalog = readCatalog();
|
|
1240
|
-
const wsFile = wsOverride ? readWorkspacesFile() : null;
|
|
1241
|
-
const ws = wsOverride
|
|
1242
|
-
? wsFile?.workspaces[wsOverride]
|
|
1243
|
-
? { name: wsOverride, config: wsFile!.workspaces[wsOverride] }
|
|
1244
|
-
: null
|
|
1245
|
-
: getActiveWorkspace();
|
|
1246
|
-
const getCurrentBranch = (repoPath: string): string | null => {
|
|
1247
|
-
try {
|
|
1248
|
-
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1249
|
-
cwd: repoPath,
|
|
1250
|
-
encoding: "utf-8",
|
|
1251
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1252
|
-
timeout: GIT_REMOTE_TIMEOUT_MS,
|
|
1253
|
-
}).trim();
|
|
1254
|
-
} catch {
|
|
1255
|
-
return null;
|
|
1256
|
-
}
|
|
1257
|
-
};
|
|
1258
|
-
|
|
1259
|
-
const noAutostart = ws
|
|
1260
|
-
? noAutostartRepos(ws.config, catalog)
|
|
1261
|
-
: new Set<string>();
|
|
1262
|
-
const daemonOwners = new Map(
|
|
1263
|
-
listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
|
|
1264
|
-
);
|
|
1265
|
-
// Modules and libraries have no runtime: no card, health or commands.
|
|
1266
|
-
// They show as contributors on the Applications tab instead.
|
|
1267
|
-
const catalogEntries = ws
|
|
1268
|
-
? listWorkspaceServices(ws.config, catalog, ws.name)
|
|
1269
|
-
.filter((r) => hasRuntime(repoKind(r.repoPath)))
|
|
1270
|
-
.flatMap((resolved) => {
|
|
1271
|
-
// The manifest and the layers it was built from: repo or held,
|
|
1272
|
-
// plus override when manifests/<repo>.override.yml applied.
|
|
1273
|
-
const { manifest, layers: manifestLayers } = loadManifest(
|
|
1274
|
-
resolved.repoPath,
|
|
1275
|
-
);
|
|
1276
|
-
const svc = manifest?.services?.[resolved.subsystem];
|
|
1277
|
-
if (!manifest || !svc) {
|
|
1278
|
-
// Workspace membership is config-driven: a managed entry
|
|
1279
|
-
// whose clone isn't on disk yet (or a checkout without a usable
|
|
1280
|
-
// manifest) must still render everywhere — flagged `cloned:false`
|
|
1281
|
-
// so the UI can offer Sync/clone instead of dropping the system.
|
|
1282
|
-
// `branch` comes from the workspace entry; never probe git on a
|
|
1283
|
-
// path that may not exist.
|
|
1284
|
-
return [
|
|
1285
|
-
{
|
|
1286
|
-
repo: resolved.repoName,
|
|
1287
|
-
service: resolved.subsystem,
|
|
1288
|
-
address: resolved.address,
|
|
1289
|
-
repoPath: resolved.repoPath,
|
|
1290
|
-
commands: [] as string[],
|
|
1291
|
-
logs: null,
|
|
1292
|
-
mode: resolved.mode,
|
|
1293
|
-
branch: resolved.branch,
|
|
1294
|
-
fromCatalog: true,
|
|
1295
|
-
isEntrypoint: false,
|
|
1296
|
-
autostart: !noAutostart.has(resolved.repoName),
|
|
1297
|
-
owners: daemonOwners.get(resolved.address) ?? [],
|
|
1298
|
-
deps: [] as string[],
|
|
1299
|
-
cloned: fs.existsSync(resolved.repoPath),
|
|
1300
|
-
manifestLayers,
|
|
1301
|
-
...servicePlacement(null, resolved.subsystem, env),
|
|
1302
|
-
},
|
|
1303
|
-
];
|
|
1304
|
-
}
|
|
1305
|
-
return [
|
|
1306
|
-
{
|
|
1307
|
-
repo: resolved.repoName,
|
|
1308
|
-
service: resolved.subsystem,
|
|
1309
|
-
address: resolved.address,
|
|
1310
|
-
repoPath: resolved.repoPath,
|
|
1311
|
-
commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
|
|
1312
|
-
logs: svc.logs?.[env] ?? null,
|
|
1313
|
-
mode: resolved.mode,
|
|
1314
|
-
branch: resolved.branch ?? getCurrentBranch(resolved.repoPath),
|
|
1315
|
-
fromCatalog: true,
|
|
1316
|
-
isEntrypoint: manifest.entrypoint === resolved.subsystem,
|
|
1317
|
-
autostart: !noAutostart.has(resolved.repoName),
|
|
1318
|
-
owners: daemonOwners.get(resolved.address) ?? [],
|
|
1319
|
-
deps: (svc.depends_on ?? []).map((d) =>
|
|
1320
|
-
resolveDepAddress(depService(d), catalog, ws.config, ws.name),
|
|
1321
|
-
),
|
|
1322
|
-
cloned: true,
|
|
1323
|
-
manifestLayers,
|
|
1324
|
-
...servicePlacement(manifest, resolved.subsystem, env),
|
|
1325
|
-
},
|
|
1326
|
-
];
|
|
1327
|
-
})
|
|
1328
|
-
: [];
|
|
1329
|
-
|
|
1330
|
-
// Merge: catalog entries take precedence over legacy for same repo.
|
|
1331
|
-
// When a workspace filter is active, also exclude legacy entries not in
|
|
1332
|
-
// the workspace (legacy discovery is not workspace-scoped).
|
|
1333
|
-
const catalogRepos = new Set(Object.keys(catalog));
|
|
1334
|
-
const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
|
|
1335
|
-
const merged = [
|
|
1336
|
-
...legacyEntries.filter((e) => {
|
|
1337
|
-
if (catalogRepos.has(e.repo)) return false;
|
|
1338
|
-
if (wsServiceKeys && !wsServiceKeys.has(e.repo)) return false;
|
|
1339
|
-
return true;
|
|
1340
|
-
}),
|
|
1341
|
-
...catalogEntries,
|
|
1342
|
-
];
|
|
1343
|
-
|
|
1344
|
-
// Attach the dependency-graph position so consumers can present systems
|
|
1345
|
-
// top-down (entry repo first, infra-stack last) — the same order the
|
|
1346
|
-
// health row and `den status` use. Added as a FIELD rather than by
|
|
1347
|
-
// re-sorting the array, because four pages consume this payload and some
|
|
1348
|
-
// rely on the current order.
|
|
1349
|
-
const graphRank = new Map(
|
|
1350
|
-
orderForDown(deriveGraph({ scope: "all", workspace: ws?.name })).map(
|
|
1351
|
-
(repo, i) => [repo, i],
|
|
1352
|
-
),
|
|
1353
|
-
);
|
|
1354
|
-
const infraRepo = readDenConfig()?.graph?.infra?.repo ?? null;
|
|
1226
|
+
// Every open tab polls this: concurrent requests share one build, and a
|
|
1227
|
+
// settled one is reused briefly (any mutating request clears it).
|
|
1355
1228
|
return sendJson(
|
|
1356
1229
|
res,
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1230
|
+
await servicesCache.get(
|
|
1231
|
+
`${env}|${wsOverride ?? ""}`,
|
|
1232
|
+
() => buildServicesList(env, wsOverride),
|
|
1233
|
+
url.searchParams.get("refresh") === "1",
|
|
1234
|
+
),
|
|
1362
1235
|
);
|
|
1363
1236
|
}
|
|
1364
1237
|
|
|
@@ -1836,6 +1709,7 @@ async function handleRequest(
|
|
|
1836
1709
|
|
|
1837
1710
|
// ── API: running daemons list ──────────────────────────────────────────────
|
|
1838
1711
|
if (pathname === "/api/service/daemons") {
|
|
1712
|
+
await startupPrune;
|
|
1839
1713
|
const daemons = listDaemons().map((d) => ({
|
|
1840
1714
|
address: d.address,
|
|
1841
1715
|
repoName: d.repoName,
|
|
@@ -3206,15 +3080,20 @@ server.on("error", (err: NodeJS.ErrnoException) => {
|
|
|
3206
3080
|
// service stopped outside den — otherwise reopen as tabs in the web UI and
|
|
3207
3081
|
// clutter the shutdown prompt. Container liveness (not health) is the signal: a
|
|
3208
3082
|
// service's shared-infra health check stays green even when the service is down.
|
|
3209
|
-
//
|
|
3210
|
-
//
|
|
3083
|
+
// The container listing runs as async child processes (a sync `docker ps`
|
|
3084
|
+
// here held the event loop at startup); /api/service/daemons waits for it,
|
|
3085
|
+
// so the UI never gets a stale entry on its first load.
|
|
3211
3086
|
loadDaemonsFromFile();
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3087
|
+
startupPrune = pruneDeadDaemonsAsync()
|
|
3088
|
+
.then((pruned) => {
|
|
3089
|
+
if (pruned.length > 0)
|
|
3090
|
+
console.error(
|
|
3091
|
+
`Cleared ${pruned.length} stale daemon(s): ${pruned.join(", ")}`,
|
|
3092
|
+
);
|
|
3093
|
+
})
|
|
3094
|
+
.catch((e: unknown) => {
|
|
3095
|
+
console.error(`[server] stale-daemon sweep failed: ${String(e)}`);
|
|
3096
|
+
});
|
|
3218
3097
|
|
|
3219
3098
|
// Background recovery: after a short delay (long enough for services to be
|
|
3220
3099
|
// responding), health-check each loaded daemon. Removal requires strong
|
|
@@ -3272,6 +3151,16 @@ process.on("unhandledRejection", (reason) => {
|
|
|
3272
3151
|
console.error("[server] unhandled rejection (continuing):", reason);
|
|
3273
3152
|
});
|
|
3274
3153
|
|
|
3154
|
+
// Build, before serving, what the first health poll and /api/services would
|
|
3155
|
+
// otherwise build inside a request: the dependency-graph order and the HTTPS
|
|
3156
|
+
// checks' trust context (both synchronous).
|
|
3157
|
+
try {
|
|
3158
|
+
for (const ws of getActiveWorkspaces()) graphOrder(ws);
|
|
3159
|
+
prewarmHealthTls();
|
|
3160
|
+
} catch (e) {
|
|
3161
|
+
serverLog(` ⚠ cache warm-up failed: ${(e as Error).message}`);
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3275
3164
|
// Bind loopback only — this UI runs privileged local actions (sudo-backed
|
|
3276
3165
|
// service commands), so it must not be reachable from the LAN. Opt out with
|
|
3277
3166
|
// DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
|
|
@@ -3375,7 +3264,20 @@ async function stopDaemons(): Promise<void> {
|
|
|
3375
3264
|
|
|
3376
3265
|
async function handleShutdown(signal: string): Promise<void> {
|
|
3377
3266
|
clearSudoPassword();
|
|
3378
|
-
|
|
3267
|
+
// The registry lives in the state dir, which is resolved from the
|
|
3268
|
+
// distribution root on every call: if that can no longer be found (den.yml
|
|
3269
|
+
// moved or removed while the server ran), still exit. Never product() here:
|
|
3270
|
+
// it resolves the same root.
|
|
3271
|
+
let daemons: ReturnType<typeof listDaemons>;
|
|
3272
|
+
try {
|
|
3273
|
+
daemons = listDaemons();
|
|
3274
|
+
} catch (e) {
|
|
3275
|
+
console.error(
|
|
3276
|
+
`\n[server] ${signal}: cannot read the running-services registry ` +
|
|
3277
|
+
`(${(e as Error).message}); exiting and leaving any services running.`,
|
|
3278
|
+
);
|
|
3279
|
+
process.exit(0);
|
|
3280
|
+
}
|
|
3379
3281
|
if (daemons.length === 0) {
|
|
3380
3282
|
process.exit(0);
|
|
3381
3283
|
}
|
|
@@ -3419,16 +3321,29 @@ async function handleShutdown(signal: string): Promise<void> {
|
|
|
3419
3321
|
process.exit(0);
|
|
3420
3322
|
}
|
|
3421
3323
|
|
|
3324
|
+
/**
|
|
3325
|
+
* Run the shutdown for `signal`. A failure still ends the process: the guard
|
|
3326
|
+
* below ignores every later signal, so a shutdown that rejected would leave
|
|
3327
|
+
* the server running and deaf to SIGTERM (a service manager then waits out
|
|
3328
|
+
* its timeout and SIGKILLs it).
|
|
3329
|
+
*/
|
|
3330
|
+
function shutdownOn(signal: string): void {
|
|
3331
|
+
handleShutdown(signal).catch((e: unknown) => {
|
|
3332
|
+
console.error(`[server] ${signal}: shutdown failed (exiting):`, e);
|
|
3333
|
+
process.exit(1);
|
|
3334
|
+
});
|
|
3335
|
+
}
|
|
3336
|
+
|
|
3422
3337
|
let _shuttingDown = false;
|
|
3423
3338
|
process.on("SIGINT", () => {
|
|
3424
3339
|
if (_shuttingDown) return;
|
|
3425
3340
|
_shuttingDown = true;
|
|
3426
3341
|
console.log(""); // newline after ^C
|
|
3427
|
-
|
|
3342
|
+
shutdownOn("SIGINT");
|
|
3428
3343
|
});
|
|
3429
3344
|
|
|
3430
3345
|
process.on("SIGTERM", () => {
|
|
3431
3346
|
if (_shuttingDown) return;
|
|
3432
3347
|
_shuttingDown = true;
|
|
3433
|
-
|
|
3348
|
+
shutdownOn("SIGTERM");
|
|
3434
3349
|
});
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /api/services payload: every service of the legacy-discovered repos and
|
|
3
|
+
* of the requested (or active) workspace, with commands, branch, deps and
|
|
4
|
+
* graph rank.
|
|
5
|
+
*
|
|
6
|
+
* Built off the request path's critical section: the per-repo branch lookups
|
|
7
|
+
* (`git rev-parse`, ~40ms each) run as async child processes in parallel
|
|
8
|
+
* instead of one execSync after another. With ~a dozen repos the sequential
|
|
9
|
+
* version held the event loop for 0.5-1.5s, so every other request (even
|
|
10
|
+
* /api/server-info, which answers in ~1ms) waited behind it.
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from "child_process";
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import type { Environment } from "../../shared/types/index.js";
|
|
15
|
+
import { discoverRepos } from "../../pillars/smoke/discover.js";
|
|
16
|
+
import { readCatalog } from "../../shared/environment-manager/catalog.js";
|
|
17
|
+
import {
|
|
18
|
+
getActiveWorkspace,
|
|
19
|
+
listWorkspaceServices,
|
|
20
|
+
noAutostartRepos,
|
|
21
|
+
readWorkspacesFile,
|
|
22
|
+
resolveDepAddress,
|
|
23
|
+
} from "../../shared/environment-manager/workspaces.js";
|
|
24
|
+
import { graphOrder } from "./graph-order.js";
|
|
25
|
+
import { listDaemons } from "../../shared/environment-manager/service-runner.js";
|
|
26
|
+
import { loadManifest } from "../../shared/manifest/read-manifest.js";
|
|
27
|
+
import { servicePlacement } from "../../shared/manifest/placement.js";
|
|
28
|
+
import { depService } from "../../shared/manifest/deps.js";
|
|
29
|
+
import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
|
|
30
|
+
import { readDenConfig } from "../../shared/den-config.js";
|
|
31
|
+
import { GIT_REMOTE_TIMEOUT_MS } from "../../shared/constants.js";
|
|
32
|
+
|
|
33
|
+
/** `repoPath`'s checked-out branch, or null (not a checkout, git failed). */
|
|
34
|
+
export function currentBranch(repoPath: string): Promise<string | null> {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
execFile(
|
|
37
|
+
"git",
|
|
38
|
+
["rev-parse", "--abbrev-ref", "HEAD"],
|
|
39
|
+
{ cwd: repoPath, encoding: "utf-8", timeout: GIT_REMOTE_TIMEOUT_MS },
|
|
40
|
+
(err, stdout) => resolve(err ? null : stdout.trim()),
|
|
41
|
+
);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function buildServicesList(
|
|
46
|
+
env: Environment,
|
|
47
|
+
wsOverride: string | null,
|
|
48
|
+
) {
|
|
49
|
+
// Conventional lifecycle locally: `down` is opctl-conventional (stops
|
|
50
|
+
// running containers with no declared command, no-ops gracefully), and
|
|
51
|
+
// `restart` is orchestrated by the exec runner as down → verify → up — so
|
|
52
|
+
// every service with an `up` can offer both even when the manifest doesn't
|
|
53
|
+
// declare them.
|
|
54
|
+
const withDown = (cmds: string[]): string[] => {
|
|
55
|
+
if (env !== "local") return cmds;
|
|
56
|
+
const out = [...cmds];
|
|
57
|
+
if (!out.includes("down")) out.push("down");
|
|
58
|
+
if (!out.includes("restart") && out.includes("up")) out.push("restart");
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// Legacy: discovered repos from .den/ symlinks / registered-repos.json
|
|
63
|
+
const legacyRepos = discoverRepos();
|
|
64
|
+
const legacyEntries = legacyRepos.flatMap((repo) => {
|
|
65
|
+
const manifestLayers = loadManifest(repo.repoPath).layers;
|
|
66
|
+
return Object.entries(repo.manifest.services ?? {}).map(
|
|
67
|
+
([svcName, svc]) => ({
|
|
68
|
+
repo: repo.repoName,
|
|
69
|
+
service: svcName,
|
|
70
|
+
address: `${repo.repoName}/${svcName}`,
|
|
71
|
+
repoPath: repo.repoPath,
|
|
72
|
+
commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
|
|
73
|
+
logs: svc.logs?.[env] ?? null,
|
|
74
|
+
mode: "dev-managed" as const,
|
|
75
|
+
branch: null as string | null,
|
|
76
|
+
fromCatalog: false,
|
|
77
|
+
isEntrypoint: repo.manifest.entrypoint === svcName,
|
|
78
|
+
autostart: true, // legacy discovery has no workspace entry to toggle
|
|
79
|
+
deps: (svc.depends_on ?? []).map(depService),
|
|
80
|
+
cloned: true, // legacy discovery only ever finds on-disk repos
|
|
81
|
+
manifestLayers,
|
|
82
|
+
...servicePlacement(repo.manifest, svcName, env),
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// Catalog entries (workspace-aware)
|
|
88
|
+
const catalog = readCatalog();
|
|
89
|
+
const wsFile = wsOverride ? readWorkspacesFile() : null;
|
|
90
|
+
const ws = wsOverride
|
|
91
|
+
? wsFile?.workspaces[wsOverride]
|
|
92
|
+
? { name: wsOverride, config: wsFile.workspaces[wsOverride] }
|
|
93
|
+
: null
|
|
94
|
+
: getActiveWorkspace();
|
|
95
|
+
|
|
96
|
+
// One lookup per checkout, all in flight at once; filled in below.
|
|
97
|
+
const branches = new Map<string, Promise<string | null>>();
|
|
98
|
+
const branchOf = (repoPath: string): Promise<string | null> => {
|
|
99
|
+
let p = branches.get(repoPath);
|
|
100
|
+
if (!p) branches.set(repoPath, (p = currentBranch(repoPath)));
|
|
101
|
+
return p;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const noAutostart = ws
|
|
105
|
+
? noAutostartRepos(ws.config, catalog)
|
|
106
|
+
: new Set<string>();
|
|
107
|
+
const daemonOwners = new Map(
|
|
108
|
+
listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
|
|
109
|
+
);
|
|
110
|
+
// Modules and libraries have no runtime: no card, health or commands.
|
|
111
|
+
// They show as contributors on the Applications tab instead.
|
|
112
|
+
const pendingBranches: Promise<void>[] = [];
|
|
113
|
+
const catalogEntries = ws
|
|
114
|
+
? listWorkspaceServices(ws.config, catalog, ws.name)
|
|
115
|
+
.filter((r) => hasRuntime(repoKind(r.repoPath)))
|
|
116
|
+
.flatMap((resolved) => {
|
|
117
|
+
// The manifest and the layers it was built from: repo or held,
|
|
118
|
+
// plus override when manifests/<repo>.override.yml applied.
|
|
119
|
+
const { manifest, layers: manifestLayers } = loadManifest(
|
|
120
|
+
resolved.repoPath,
|
|
121
|
+
);
|
|
122
|
+
const svc = manifest?.services?.[resolved.subsystem];
|
|
123
|
+
if (!manifest || !svc) {
|
|
124
|
+
// Workspace membership is config-driven: a managed entry
|
|
125
|
+
// whose clone isn't on disk yet (or a checkout without a usable
|
|
126
|
+
// manifest) must still render everywhere — flagged `cloned:false`
|
|
127
|
+
// so the UI can offer Sync/clone instead of dropping the system.
|
|
128
|
+
// `branch` comes from the workspace entry; never probe git on a
|
|
129
|
+
// path that may not exist.
|
|
130
|
+
return [
|
|
131
|
+
{
|
|
132
|
+
repo: resolved.repoName,
|
|
133
|
+
service: resolved.subsystem,
|
|
134
|
+
address: resolved.address,
|
|
135
|
+
repoPath: resolved.repoPath,
|
|
136
|
+
commands: [] as string[],
|
|
137
|
+
logs: null,
|
|
138
|
+
mode: resolved.mode,
|
|
139
|
+
branch: resolved.branch as string | null,
|
|
140
|
+
fromCatalog: true,
|
|
141
|
+
isEntrypoint: false,
|
|
142
|
+
autostart: !noAutostart.has(resolved.repoName),
|
|
143
|
+
owners: daemonOwners.get(resolved.address) ?? [],
|
|
144
|
+
deps: [] as string[],
|
|
145
|
+
cloned: fs.existsSync(resolved.repoPath),
|
|
146
|
+
manifestLayers,
|
|
147
|
+
...servicePlacement(null, resolved.subsystem, env),
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
const entry = {
|
|
152
|
+
repo: resolved.repoName,
|
|
153
|
+
service: resolved.subsystem,
|
|
154
|
+
address: resolved.address,
|
|
155
|
+
repoPath: resolved.repoPath,
|
|
156
|
+
commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
|
|
157
|
+
logs: svc.logs?.[env] ?? null,
|
|
158
|
+
mode: resolved.mode,
|
|
159
|
+
branch: resolved.branch as string | null,
|
|
160
|
+
fromCatalog: true,
|
|
161
|
+
isEntrypoint: manifest.entrypoint === resolved.subsystem,
|
|
162
|
+
autostart: !noAutostart.has(resolved.repoName),
|
|
163
|
+
owners: daemonOwners.get(resolved.address) ?? [],
|
|
164
|
+
deps: (svc.depends_on ?? []).map((d) =>
|
|
165
|
+
resolveDepAddress(depService(d), catalog, ws.config, ws.name),
|
|
166
|
+
),
|
|
167
|
+
cloned: true,
|
|
168
|
+
manifestLayers,
|
|
169
|
+
...servicePlacement(manifest, resolved.subsystem, env),
|
|
170
|
+
};
|
|
171
|
+
if (entry.branch == null)
|
|
172
|
+
pendingBranches.push(
|
|
173
|
+
branchOf(resolved.repoPath).then((b) => {
|
|
174
|
+
entry.branch = b;
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
return [entry];
|
|
178
|
+
})
|
|
179
|
+
: [];
|
|
180
|
+
|
|
181
|
+
// Merge: catalog entries take precedence over legacy for same repo.
|
|
182
|
+
// When a workspace filter is active, also exclude legacy entries not in
|
|
183
|
+
// the workspace (legacy discovery is not workspace-scoped).
|
|
184
|
+
const catalogRepos = new Set(Object.keys(catalog));
|
|
185
|
+
const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
|
|
186
|
+
const merged = [
|
|
187
|
+
...legacyEntries.filter((e) => {
|
|
188
|
+
if (catalogRepos.has(e.repo)) return false;
|
|
189
|
+
if (wsServiceKeys && !wsServiceKeys.has(e.repo)) return false;
|
|
190
|
+
return true;
|
|
191
|
+
}),
|
|
192
|
+
...catalogEntries,
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
// Attach the dependency-graph position so consumers can present systems
|
|
196
|
+
// top-down (entry repo first, infra-stack last) — the same order the
|
|
197
|
+
// health row and `den status` use. Added as a FIELD rather than by
|
|
198
|
+
// re-sorting the array, because four pages consume this payload and some
|
|
199
|
+
// rely on the current order.
|
|
200
|
+
const graphRank = new Map(graphOrder(ws?.name).map((repo, i) => [repo, i]));
|
|
201
|
+
const infraRepo = readDenConfig()?.graph?.infra?.repo ?? null;
|
|
202
|
+
await Promise.all(pendingBranches);
|
|
203
|
+
return merged.map((e) => ({
|
|
204
|
+
...e,
|
|
205
|
+
graphRank: graphRank.get(e.repo) ?? Number.MAX_SAFE_INTEGER,
|
|
206
|
+
infra: infraRepo !== null && e.repo === infraRepo,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export type ServicesList = Awaited<ReturnType<typeof buildServicesList>>;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short-lived, keyed cache whose builds are single-flight: callers that ask
|
|
3
|
+
* for the same key while a build is running share it instead of starting their
|
|
4
|
+
* own, and a settled result is reused for `ttlMs`. A failed build is never
|
|
5
|
+
* cached, so the next caller retries.
|
|
6
|
+
*
|
|
7
|
+
* For server routes the UI polls from every open tab (/api/services,
|
|
8
|
+
* /api/health): N tabs cost one build, not N.
|
|
9
|
+
*/
|
|
10
|
+
export interface SingleFlight<T> {
|
|
11
|
+
/** The cached or in-flight value for `key`; `fresh` forces a new build. */
|
|
12
|
+
get(key: string, build: () => Promise<T>, fresh?: boolean): Promise<T>;
|
|
13
|
+
/** Drop every entry (after a change the cached value may not reflect). */
|
|
14
|
+
clear(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function singleFlight<T>(
|
|
18
|
+
ttlMs: number,
|
|
19
|
+
now: () => number = Date.now,
|
|
20
|
+
): SingleFlight<T> {
|
|
21
|
+
const entries = new Map<
|
|
22
|
+
string,
|
|
23
|
+
{ promise: Promise<T>; settledAt: number | null }
|
|
24
|
+
>();
|
|
25
|
+
return {
|
|
26
|
+
get(key, build, fresh = false) {
|
|
27
|
+
const hit = entries.get(key);
|
|
28
|
+
if (
|
|
29
|
+
!fresh &&
|
|
30
|
+
hit &&
|
|
31
|
+
(hit.settledAt === null || now() - hit.settledAt < ttlMs)
|
|
32
|
+
)
|
|
33
|
+
return hit.promise;
|
|
34
|
+
const entry: { promise: Promise<T>; settledAt: number | null } = {
|
|
35
|
+
promise: Promise.resolve().then(build),
|
|
36
|
+
settledAt: null,
|
|
37
|
+
};
|
|
38
|
+
entries.set(key, entry);
|
|
39
|
+
entry.promise.then(
|
|
40
|
+
() => {
|
|
41
|
+
entry.settledAt = now();
|
|
42
|
+
},
|
|
43
|
+
() => {
|
|
44
|
+
if (entries.get(key) === entry) entries.delete(key);
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
return entry.promise;
|
|
48
|
+
},
|
|
49
|
+
clear() {
|
|
50
|
+
entries.clear();
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|