@monster-orange/den 0.2.2 → 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 +151 -0
- package/docs/distributions.md +51 -0
- package/package.json +1 -1
- package/scripts/setup-secrets.ts +11 -0
- 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 +205 -8
- package/src/shared/git/origin-url.ts +77 -0
- package/src/shared/manifest/read-manifest.ts +39 -15
- package/src/shared/manifest/schema.ts +15 -0
- 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/secrets/doppler.ts +10 -5
- package/src/shared/secrets/index.ts +1 -0
- package/src/shared/secrets/keys.ts +2 -0
- package/src/shared/secrets/secret-env.ts +149 -0
- package/src/shared/secrets/types.ts +10 -0
- package/src/shared/stacks/config.ts +6 -0
- package/src/shared/stacks/runtime.ts +51 -3
- package/src/shared/stacks/status.ts +109 -1
- package/src/shared/stacks/types.ts +8 -0
- package/src/shared/types/index.ts +15 -0
- package/src/ui/cli/index.ts +151 -10
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +47 -5
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/mcp/connect.ts +3 -1
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +101 -183
- package/src/ui/server/lifecycle-exec.ts +23 -0
- 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 +272 -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,15 +61,17 @@ 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,
|
|
68
|
+
prepareServiceSecretEnv,
|
|
71
69
|
resolveService,
|
|
72
70
|
listDaemons,
|
|
73
71
|
unregisterDaemon,
|
|
74
72
|
checkServiceHealth,
|
|
75
73
|
loadDaemonsFromFile,
|
|
76
|
-
|
|
74
|
+
pruneDeadDaemonsAsync,
|
|
77
75
|
isInteractiveServiceCommand,
|
|
78
76
|
rewriteLegacyOpctlKillCommandForContainer,
|
|
79
77
|
shouldDetachServiceCommand,
|
|
@@ -141,6 +139,10 @@ import {
|
|
|
141
139
|
import { handleReadonlyRoutes } from "./readonly-routes.js";
|
|
142
140
|
import { serveUiAsset } from "./ui-assets.js";
|
|
143
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";
|
|
144
146
|
|
|
145
147
|
const __filename = fileURLToPath(import.meta.url);
|
|
146
148
|
const __dirname = path.dirname(__filename);
|
|
@@ -208,6 +210,17 @@ function viewerRewrite(
|
|
|
208
210
|
|
|
209
211
|
const HEALTH_CACHE_TTL_MS = 8_000;
|
|
210
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);
|
|
211
224
|
/** A workspace-scoped run covers a different service set than the default one. */
|
|
212
225
|
function healthCacheKey(env: Environment, workspace: string | null): string {
|
|
213
226
|
return `${env}|${workspace ?? "*"}`;
|
|
@@ -533,6 +546,15 @@ async function handleRequest(
|
|
|
533
546
|
): Promise<void> {
|
|
534
547
|
const url = new URL(req.url ?? "/", `http://localhost:${PORT}`);
|
|
535
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
|
+
}
|
|
536
558
|
|
|
537
559
|
// ── Request logging ────────────────────────────────────────────────────────
|
|
538
560
|
// /api/health fires on the UI's 5s polling interval forever — it dominates
|
|
@@ -1018,9 +1040,7 @@ async function handleRequest(
|
|
|
1018
1040
|
// direction as the graph (and `den status`). orderForDown is the reverse
|
|
1019
1041
|
// of the deps-first startup order.
|
|
1020
1042
|
const rank = new Map(
|
|
1021
|
-
|
|
1022
|
-
deriveGraph({ scope: "all", workspace: workspaces[0]?.name }),
|
|
1023
|
-
).map((repo, i) => [repo, i]),
|
|
1043
|
+
graphOrder(workspaces[0]?.name).map((repo, i) => [repo, i]),
|
|
1024
1044
|
);
|
|
1025
1045
|
const sortedEntries = [...entries].sort((a, b) => {
|
|
1026
1046
|
const ra = rank.get(a.repo) ?? Number.MAX_SAFE_INTEGER;
|
|
@@ -1142,9 +1162,17 @@ async function handleRequest(
|
|
|
1142
1162
|
if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
|
|
1143
1163
|
return sendJson(res, hit.results.map(forViewer));
|
|
1144
1164
|
}
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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));
|
|
1148
1176
|
}
|
|
1149
1177
|
|
|
1150
1178
|
// Same checks, streamed: each result is sent the moment it lands so the fast
|
|
@@ -1195,169 +1223,15 @@ async function handleRequest(
|
|
|
1195
1223
|
const env = (url.searchParams.get("env") ?? "local") as Environment;
|
|
1196
1224
|
// Optional: caller may request services for a specific (non-active) workspace.
|
|
1197
1225
|
const wsOverride = url.searchParams.get("workspace") ?? null;
|
|
1198
|
-
|
|
1199
|
-
//
|
|
1200
|
-
// running containers with no declared command, no-ops gracefully), and
|
|
1201
|
-
// `restart` is orchestrated by the exec runner as down → verify → up — so
|
|
1202
|
-
// every service with an `up` can offer both even when the manifest doesn't
|
|
1203
|
-
// declare them.
|
|
1204
|
-
const withDown = (cmds: string[]): string[] => {
|
|
1205
|
-
if (env !== "local") return cmds;
|
|
1206
|
-
const out = [...cmds];
|
|
1207
|
-
if (!out.includes("down")) out.push("down");
|
|
1208
|
-
if (!out.includes("restart") && out.includes("up")) out.push("restart");
|
|
1209
|
-
return out;
|
|
1210
|
-
};
|
|
1211
|
-
|
|
1212
|
-
// Legacy: discovered repos from .den/ symlinks / registered-repos.json
|
|
1213
|
-
const legacyRepos = discoverRepos();
|
|
1214
|
-
const legacyEntries = legacyRepos.flatMap((repo) => {
|
|
1215
|
-
const manifestLayers = loadManifest(repo.repoPath).layers;
|
|
1216
|
-
return Object.entries(repo.manifest.services ?? {}).map(
|
|
1217
|
-
([svcName, svc]) => ({
|
|
1218
|
-
repo: repo.repoName,
|
|
1219
|
-
service: svcName,
|
|
1220
|
-
address: `${repo.repoName}/${svcName}`,
|
|
1221
|
-
repoPath: repo.repoPath,
|
|
1222
|
-
commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
|
|
1223
|
-
logs: svc.logs?.[env] ?? null,
|
|
1224
|
-
mode: "dev-managed" as const,
|
|
1225
|
-
branch: null as string | null,
|
|
1226
|
-
fromCatalog: false,
|
|
1227
|
-
isEntrypoint: repo.manifest.entrypoint === svcName,
|
|
1228
|
-
autostart: true, // legacy discovery has no workspace entry to toggle
|
|
1229
|
-
deps: (svc.depends_on ?? []).map(depService),
|
|
1230
|
-
cloned: true, // legacy discovery only ever finds on-disk repos
|
|
1231
|
-
manifestLayers,
|
|
1232
|
-
...servicePlacement(repo.manifest, svcName, env),
|
|
1233
|
-
}),
|
|
1234
|
-
);
|
|
1235
|
-
});
|
|
1236
|
-
|
|
1237
|
-
// Catalog entries (workspace-aware)
|
|
1238
|
-
const catalog = readCatalog();
|
|
1239
|
-
const wsFile = wsOverride ? readWorkspacesFile() : null;
|
|
1240
|
-
const ws = wsOverride
|
|
1241
|
-
? wsFile?.workspaces[wsOverride]
|
|
1242
|
-
? { name: wsOverride, config: wsFile!.workspaces[wsOverride] }
|
|
1243
|
-
: null
|
|
1244
|
-
: getActiveWorkspace();
|
|
1245
|
-
const getCurrentBranch = (repoPath: string): string | null => {
|
|
1246
|
-
try {
|
|
1247
|
-
return execSync("git rev-parse --abbrev-ref HEAD", {
|
|
1248
|
-
cwd: repoPath,
|
|
1249
|
-
encoding: "utf-8",
|
|
1250
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
1251
|
-
timeout: GIT_REMOTE_TIMEOUT_MS,
|
|
1252
|
-
}).trim();
|
|
1253
|
-
} catch {
|
|
1254
|
-
return null;
|
|
1255
|
-
}
|
|
1256
|
-
};
|
|
1257
|
-
|
|
1258
|
-
const noAutostart = ws
|
|
1259
|
-
? noAutostartRepos(ws.config, catalog)
|
|
1260
|
-
: new Set<string>();
|
|
1261
|
-
const daemonOwners = new Map(
|
|
1262
|
-
listDaemons().map((d) => [d.address, d.workspaces ?? []] as const),
|
|
1263
|
-
);
|
|
1264
|
-
// Modules and libraries have no runtime: no card, health or commands.
|
|
1265
|
-
// They show as contributors on the Applications tab instead.
|
|
1266
|
-
const catalogEntries = ws
|
|
1267
|
-
? listWorkspaceServices(ws.config, catalog, ws.name)
|
|
1268
|
-
.filter((r) => hasRuntime(repoKind(r.repoPath)))
|
|
1269
|
-
.flatMap((resolved) => {
|
|
1270
|
-
// The manifest and the layers it was built from: repo or held,
|
|
1271
|
-
// plus override when manifests/<repo>.override.yml applied.
|
|
1272
|
-
const { manifest, layers: manifestLayers } = loadManifest(
|
|
1273
|
-
resolved.repoPath,
|
|
1274
|
-
);
|
|
1275
|
-
const svc = manifest?.services?.[resolved.subsystem];
|
|
1276
|
-
if (!manifest || !svc) {
|
|
1277
|
-
// Workspace membership is config-driven: a managed entry
|
|
1278
|
-
// whose clone isn't on disk yet (or a checkout without a usable
|
|
1279
|
-
// manifest) must still render everywhere — flagged `cloned:false`
|
|
1280
|
-
// so the UI can offer Sync/clone instead of dropping the system.
|
|
1281
|
-
// `branch` comes from the workspace entry; never probe git on a
|
|
1282
|
-
// path that may not exist.
|
|
1283
|
-
return [
|
|
1284
|
-
{
|
|
1285
|
-
repo: resolved.repoName,
|
|
1286
|
-
service: resolved.subsystem,
|
|
1287
|
-
address: resolved.address,
|
|
1288
|
-
repoPath: resolved.repoPath,
|
|
1289
|
-
commands: [] as string[],
|
|
1290
|
-
logs: null,
|
|
1291
|
-
mode: resolved.mode,
|
|
1292
|
-
branch: resolved.branch,
|
|
1293
|
-
fromCatalog: true,
|
|
1294
|
-
isEntrypoint: false,
|
|
1295
|
-
autostart: !noAutostart.has(resolved.repoName),
|
|
1296
|
-
owners: daemonOwners.get(resolved.address) ?? [],
|
|
1297
|
-
deps: [] as string[],
|
|
1298
|
-
cloned: fs.existsSync(resolved.repoPath),
|
|
1299
|
-
manifestLayers,
|
|
1300
|
-
...servicePlacement(null, resolved.subsystem, env),
|
|
1301
|
-
},
|
|
1302
|
-
];
|
|
1303
|
-
}
|
|
1304
|
-
return [
|
|
1305
|
-
{
|
|
1306
|
-
repo: resolved.repoName,
|
|
1307
|
-
service: resolved.subsystem,
|
|
1308
|
-
address: resolved.address,
|
|
1309
|
-
repoPath: resolved.repoPath,
|
|
1310
|
-
commands: withDown(Object.keys(svc.commands?.[env] ?? {})),
|
|
1311
|
-
logs: svc.logs?.[env] ?? null,
|
|
1312
|
-
mode: resolved.mode,
|
|
1313
|
-
branch: resolved.branch ?? getCurrentBranch(resolved.repoPath),
|
|
1314
|
-
fromCatalog: true,
|
|
1315
|
-
isEntrypoint: manifest.entrypoint === resolved.subsystem,
|
|
1316
|
-
autostart: !noAutostart.has(resolved.repoName),
|
|
1317
|
-
owners: daemonOwners.get(resolved.address) ?? [],
|
|
1318
|
-
deps: (svc.depends_on ?? []).map((d) =>
|
|
1319
|
-
resolveDepAddress(depService(d), catalog, ws.config, ws.name),
|
|
1320
|
-
),
|
|
1321
|
-
cloned: true,
|
|
1322
|
-
manifestLayers,
|
|
1323
|
-
...servicePlacement(manifest, resolved.subsystem, env),
|
|
1324
|
-
},
|
|
1325
|
-
];
|
|
1326
|
-
})
|
|
1327
|
-
: [];
|
|
1328
|
-
|
|
1329
|
-
// Merge: catalog entries take precedence over legacy for same repo.
|
|
1330
|
-
// When a workspace filter is active, also exclude legacy entries not in
|
|
1331
|
-
// the workspace (legacy discovery is not workspace-scoped).
|
|
1332
|
-
const catalogRepos = new Set(Object.keys(catalog));
|
|
1333
|
-
const wsServiceKeys = ws ? new Set(Object.keys(ws.config.services)) : null;
|
|
1334
|
-
const merged = [
|
|
1335
|
-
...legacyEntries.filter((e) => {
|
|
1336
|
-
if (catalogRepos.has(e.repo)) return false;
|
|
1337
|
-
if (wsServiceKeys && !wsServiceKeys.has(e.repo)) return false;
|
|
1338
|
-
return true;
|
|
1339
|
-
}),
|
|
1340
|
-
...catalogEntries,
|
|
1341
|
-
];
|
|
1342
|
-
|
|
1343
|
-
// Attach the dependency-graph position so consumers can present systems
|
|
1344
|
-
// top-down (entry repo first, infra-stack last) — the same order the
|
|
1345
|
-
// health row and `den status` use. Added as a FIELD rather than by
|
|
1346
|
-
// re-sorting the array, because four pages consume this payload and some
|
|
1347
|
-
// rely on the current order.
|
|
1348
|
-
const graphRank = new Map(
|
|
1349
|
-
orderForDown(deriveGraph({ scope: "all", workspace: ws?.name })).map(
|
|
1350
|
-
(repo, i) => [repo, i],
|
|
1351
|
-
),
|
|
1352
|
-
);
|
|
1353
|
-
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).
|
|
1354
1228
|
return sendJson(
|
|
1355
1229
|
res,
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1230
|
+
await servicesCache.get(
|
|
1231
|
+
`${env}|${wsOverride ?? ""}`,
|
|
1232
|
+
() => buildServicesList(env, wsOverride),
|
|
1233
|
+
url.searchParams.get("refresh") === "1",
|
|
1234
|
+
),
|
|
1361
1235
|
);
|
|
1362
1236
|
}
|
|
1363
1237
|
|
|
@@ -1477,6 +1351,8 @@ async function handleRequest(
|
|
|
1477
1351
|
{
|
|
1478
1352
|
run: runServiceCommand,
|
|
1479
1353
|
health: (r) => checkServiceHealth(r, "local"),
|
|
1354
|
+
secretEnv: (address, cmd, workspace) =>
|
|
1355
|
+
prepareServiceSecretEnv(address, cmd, workspace),
|
|
1480
1356
|
},
|
|
1481
1357
|
);
|
|
1482
1358
|
return;
|
|
@@ -1833,6 +1709,7 @@ async function handleRequest(
|
|
|
1833
1709
|
|
|
1834
1710
|
// ── API: running daemons list ──────────────────────────────────────────────
|
|
1835
1711
|
if (pathname === "/api/service/daemons") {
|
|
1712
|
+
await startupPrune;
|
|
1836
1713
|
const daemons = listDaemons().map((d) => ({
|
|
1837
1714
|
address: d.address,
|
|
1838
1715
|
repoName: d.repoName,
|
|
@@ -3203,15 +3080,20 @@ server.on("error", (err: NodeJS.ErrnoException) => {
|
|
|
3203
3080
|
// service stopped outside den — otherwise reopen as tabs in the web UI and
|
|
3204
3081
|
// clutter the shutdown prompt. Container liveness (not health) is the signal: a
|
|
3205
3082
|
// service's shared-infra health check stays green even when the service is down.
|
|
3206
|
-
//
|
|
3207
|
-
//
|
|
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.
|
|
3208
3086
|
loadDaemonsFromFile();
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
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
|
+
});
|
|
3215
3097
|
|
|
3216
3098
|
// Background recovery: after a short delay (long enough for services to be
|
|
3217
3099
|
// responding), health-check each loaded daemon. Removal requires strong
|
|
@@ -3269,6 +3151,16 @@ process.on("unhandledRejection", (reason) => {
|
|
|
3269
3151
|
console.error("[server] unhandled rejection (continuing):", reason);
|
|
3270
3152
|
});
|
|
3271
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
|
+
|
|
3272
3164
|
// Bind loopback only — this UI runs privileged local actions (sudo-backed
|
|
3273
3165
|
// service commands), so it must not be reachable from the LAN. Opt out with
|
|
3274
3166
|
// DEN_BIND_HOST=0.0.0.0 for deliberate exposure.
|
|
@@ -3372,7 +3264,20 @@ async function stopDaemons(): Promise<void> {
|
|
|
3372
3264
|
|
|
3373
3265
|
async function handleShutdown(signal: string): Promise<void> {
|
|
3374
3266
|
clearSudoPassword();
|
|
3375
|
-
|
|
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
|
+
}
|
|
3376
3281
|
if (daemons.length === 0) {
|
|
3377
3282
|
process.exit(0);
|
|
3378
3283
|
}
|
|
@@ -3416,16 +3321,29 @@ async function handleShutdown(signal: string): Promise<void> {
|
|
|
3416
3321
|
process.exit(0);
|
|
3417
3322
|
}
|
|
3418
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
|
+
|
|
3419
3337
|
let _shuttingDown = false;
|
|
3420
3338
|
process.on("SIGINT", () => {
|
|
3421
3339
|
if (_shuttingDown) return;
|
|
3422
3340
|
_shuttingDown = true;
|
|
3423
3341
|
console.log(""); // newline after ^C
|
|
3424
|
-
|
|
3342
|
+
shutdownOn("SIGINT");
|
|
3425
3343
|
});
|
|
3426
3344
|
|
|
3427
3345
|
process.on("SIGTERM", () => {
|
|
3428
3346
|
if (_shuttingDown) return;
|
|
3429
3347
|
_shuttingDown = true;
|
|
3430
|
-
|
|
3348
|
+
shutdownOn("SIGTERM");
|
|
3431
3349
|
});
|
|
@@ -21,6 +21,7 @@ import type { ResolvedService } from "../../shared/types/index.js";
|
|
|
21
21
|
import type {
|
|
22
22
|
RunCommandOptions,
|
|
23
23
|
RunCommandResult,
|
|
24
|
+
ServiceSecretEnvResult,
|
|
24
25
|
} from "../../shared/environment-manager/service-runner.js";
|
|
25
26
|
import type { ServiceHealthResult } from "../../shared/environment-manager/health.js";
|
|
26
27
|
|
|
@@ -34,6 +35,16 @@ export function isLifecycleExecCmd(c: string): c is LifecycleExecCmd {
|
|
|
34
35
|
export interface LifecycleExecDeps {
|
|
35
36
|
run: (o: RunCommandOptions) => RunCommandResult;
|
|
36
37
|
health: (r: ResolvedService) => Promise<ServiceHealthResult>;
|
|
38
|
+
/**
|
|
39
|
+
* R16: resolve the service's secret_env (prepareServiceSecretEnv). Unset,
|
|
40
|
+
* nothing is resolved, and the runner refuses to start a service that
|
|
41
|
+
* declares secret_env.
|
|
42
|
+
*/
|
|
43
|
+
secretEnv?: (
|
|
44
|
+
address: string,
|
|
45
|
+
cmd: LifecycleExecCmd,
|
|
46
|
+
workspace?: string,
|
|
47
|
+
) => Promise<ServiceSecretEnvResult>;
|
|
37
48
|
/** Health poll interval after a detached start (default 1s). */
|
|
38
49
|
pollMs?: number;
|
|
39
50
|
/** Stop polling after this long (default 120s). */
|
|
@@ -106,12 +117,24 @@ export async function streamLifecycleExec(
|
|
|
106
117
|
}
|
|
107
118
|
}
|
|
108
119
|
|
|
120
|
+
// R16: all of the service's secret_env, or nothing starts. The message
|
|
121
|
+
// names keys, refs and the provider, never a value.
|
|
122
|
+
const secrets = deps.secretEnv
|
|
123
|
+
? await deps.secretEnv(resolved.address, cmd, o.workspace)
|
|
124
|
+
: { ok: true as const, secretEnv: undefined };
|
|
125
|
+
if (!secrets.ok) {
|
|
126
|
+
if (!clientGone) emit({ type: "error", text: secrets.message });
|
|
127
|
+
finish(1, true);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
109
131
|
const result = deps.run({
|
|
110
132
|
address: resolved.address,
|
|
111
133
|
command: cmd,
|
|
112
134
|
workspace: o.workspace,
|
|
113
135
|
runtime: "daemon",
|
|
114
136
|
env: o.env,
|
|
137
|
+
secretEnv: secrets.secretEnv,
|
|
115
138
|
onLine: (text) => {
|
|
116
139
|
if (!clientGone) emit({ type: "line", text });
|
|
117
140
|
},
|
|
@@ -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>>;
|