@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/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,117 @@ All notable changes to Den are documented here. Each release lists the
|
|
|
4
4
|
config and manifest changes a distribution must make to move to it under
|
|
5
5
|
**Migrations**.
|
|
6
6
|
|
|
7
|
+
## [0.2.4] - 2026-11-04
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`GET /api/services` no longer blocks the web server.** It looked up
|
|
12
|
+
each workspace checkout's branch with a synchronous `git rev-parse`, one
|
|
13
|
+
after another (~40ms each), so with a dozen repos every request held the
|
|
14
|
+
event loop for 0.5-1.5s and everything else waited behind it: the UI,
|
|
15
|
+
the MCP server's reads, and `/api/server-info`, which `<cli> mcp connect`
|
|
16
|
+
probes with a 1.5s timeout. The lookups now run as async child processes
|
|
17
|
+
in parallel; concurrent requests (one per open tab) share one build, and
|
|
18
|
+
a settled build is reused for 2s (`?refresh=1` forces a new one; any
|
|
19
|
+
mutating request clears it). Concurrent `/api/health` polls also share
|
|
20
|
+
one run instead of each starting its own.
|
|
21
|
+
- **`<cli> mcp connect` / `mcp clients` tell a slow web server from a
|
|
22
|
+
stopped one.** A server that accepts the connection but does not answer
|
|
23
|
+
within 1.5s is reported as "not answering within 1.5s", not "not
|
|
24
|
+
running". When the org config sets `web.service` (the web UI runs as a
|
|
25
|
+
launchd service), the advice is `<cli> service status` instead of
|
|
26
|
+
`<cli> web up`.
|
|
27
|
+
- **Web server shutdown no longer leaks an unhandled rejection, or hangs.**
|
|
28
|
+
On SIGTERM/SIGINT the server reads the running-services registry, whose
|
|
29
|
+
path resolves the distribution root again; if that walk fails (the dist's
|
|
30
|
+
den.yml moved or removed while the server ran), the async shutdown
|
|
31
|
+
rejected unhandled ("unhandled rejection (continuing): Error: cannot
|
|
32
|
+
locate the distribution root: walked up from
|
|
33
|
+
<dist>/node_modules/@monster-orange/den/src/ui/server") and, its signal
|
|
34
|
+
guard already set, the process never exited, so a service manager had to
|
|
35
|
+
wait out its timeout and kill it. The shutdown now logs the failure and
|
|
36
|
+
exits.
|
|
37
|
+
- **Deactivating a workspace no longer stops a service that moved to
|
|
38
|
+
another workspace.** A daemon's owners were the workspaces recorded when
|
|
39
|
+
it started, so a repo moved from `default` into `ws-b` while its daemon
|
|
40
|
+
ran was still "owned" only by `default`, and `workspace deactivate
|
|
41
|
+
default` stopped it. Release now re-derives the owners from the current
|
|
42
|
+
workspaces.yml membership: a daemon is stopped only when no active
|
|
43
|
+
workspace still lists its repo, and otherwise its recorded owners become
|
|
44
|
+
the active workspaces that do.
|
|
45
|
+
- **`<cli> workspace --help` lists every subcommand**: activate,
|
|
46
|
+
deactivate and focus were missing (and `workspace focus <name>` now
|
|
47
|
+
exists in the CLI, as it did in the web UI). Deactivate's help and output
|
|
48
|
+
say that it releases only the services Den tracks: compose stacks of the
|
|
49
|
+
workspace's repos keep running, and the output names the running ones
|
|
50
|
+
with `<cli> down <repo>` for each (from one `docker ps`; a generic line
|
|
51
|
+
when docker can't be asked).
|
|
52
|
+
- **The first `/api/health/stream` (and `/api/health`) cycle no longer
|
|
53
|
+
blocks the web server for a second.** Resolving the manifest of a repo
|
|
54
|
+
without its own (a held manifest under `manifests/`) looked up the
|
|
55
|
+
checkout's origin with a synchronous `git config --get
|
|
56
|
+
remote.origin.url`, one repo after another (~40-100ms each), so a
|
|
57
|
+
server's first health run held the event loop for 1-1.5s. The origin is
|
|
58
|
+
now read from the checkout's git config file (worktrees and
|
|
59
|
+
subdirectories included), never by spawning git. Parsed manifests are
|
|
60
|
+
also reused while the file is unchanged: one request read the same
|
|
61
|
+
manifests several times, and parsing them held the loop ~100-200ms per
|
|
62
|
+
health run.
|
|
63
|
+
- **`GET /api/workspaces/available-repos` no longer blocks the web
|
|
64
|
+
server.** It ran `git remote get-url origin` (and, for managed clones,
|
|
65
|
+
`git rev-parse`) synchronously per checkout; the lookups are now async
|
|
66
|
+
and in parallel, and concurrent requests share one walk (reused for 2s;
|
|
67
|
+
`?refresh=1` forces a new one, any mutating request clears it).
|
|
68
|
+
- **`workspace deactivate` names the compose stacks a repo runs itself.**
|
|
69
|
+
It only knew Den stacks (a manifest's `compose:` section, project
|
|
70
|
+
`<stack>-<workspace>`), so a distribution whose repos start their own
|
|
71
|
+
compose projects (`make up`) got no line at all. A running project now
|
|
72
|
+
counts when Compose started it from inside one of the workspace's
|
|
73
|
+
checkouts (its `com.docker.compose.project.working_dir` label), as well
|
|
74
|
+
as when it is a Den stack's project.
|
|
75
|
+
- **`<cli> workspace <sub> --help`** prints that subcommand's help, not the
|
|
76
|
+
whole `workspace` help.
|
|
77
|
+
- **No synchronous directory walks or spawns on the web server's hot
|
|
78
|
+
request paths** (`/api/workspaces/available-repos`, the `/api/health`
|
|
79
|
+
stream). On a loaded machine (load average ~50) the dev.1 fixes still
|
|
80
|
+
left stalls of 1-5s; a CPU profile showed why:
|
|
81
|
+
- available-repos recursed into every directory without a manifest file
|
|
82
|
+
of its own, so in a distribution whose repos use held manifests it
|
|
83
|
+
walked each repo's whole tree (to depth 4) with `readdirSync` /
|
|
84
|
+
`existsSync`: seconds per call. It now never descends into a git
|
|
85
|
+
checkout or `node_modules`, and the walk is `fs.promises`.
|
|
86
|
+
- The distribution root (and so `product()`) was re-resolved on every
|
|
87
|
+
call, a den.yml `existsSync` per `*_ROOT` variable each time, hundreds
|
|
88
|
+
of times per request. It is now resolved once per set of inputs (the
|
|
89
|
+
env vars and entry script it reads); the engine root likewise.
|
|
90
|
+
- Manifests, the edge config and the derived graph order are reused
|
|
91
|
+
across a request and the polls after it (5s; any mutating request
|
|
92
|
+
clears them), and the graph order is recomputed after a request, never
|
|
93
|
+
inside one.
|
|
94
|
+
- HTTPS health checks built a TLS context from ~150 root certificates on
|
|
95
|
+
every check; one context is built once, at startup.
|
|
96
|
+
- The startup sweep of stale daemon records ran `docker ps` / `opctl
|
|
97
|
+
container ls` synchronously; it is async now (concurrent callers share
|
|
98
|
+
one call), and `/api/service/daemons` waits for it.
|
|
99
|
+
A new test runs the server with a spy on every synchronous fs and
|
|
100
|
+
child_process call and fails if either route makes a sync spawn or
|
|
101
|
+
directory walk, or any sync call outside a short allowlist of single
|
|
102
|
+
config-file reads.
|
|
103
|
+
- **The web UI service no longer runs at background priority.**
|
|
104
|
+
`service install` wrote `ProcessType: Background` into the LaunchAgent,
|
|
105
|
+
so macOS ran the UI server on efficiency cores with throttled I/O: on a
|
|
106
|
+
busy machine its first requests stalled for 5-11s. It is now
|
|
107
|
+
`Interactive`, and `service status` shows launchd's spawn type.
|
|
108
|
+
- **`workspace deactivate` never suggests stopping a stack another active
|
|
109
|
+
workspace still holds.** Such a stack is listed as "in use by another
|
|
110
|
+
workspace: <project> (still in <ws>)" instead of with a `down <repo>`
|
|
111
|
+
command, and a repo with a stack still in use gets no `down` suggestion.
|
|
112
|
+
|
|
113
|
+
### Migrations
|
|
114
|
+
|
|
115
|
+
- Re-run `<cli> service install` to pick up the new LaunchAgent priority
|
|
116
|
+
(`<cli> service status` should show `spawn type = interactive`).
|
|
117
|
+
|
|
7
118
|
## [0.2.3] - 2026-11-04
|
|
8
119
|
|
|
9
120
|
### Added
|
package/package.json
CHANGED
package/service/install.sh
CHANGED
|
@@ -65,7 +65,9 @@ data = {
|
|
|
65
65
|
"RunAtLoad": True,
|
|
66
66
|
"KeepAlive": True,
|
|
67
67
|
"ThrottleInterval": 10,
|
|
68
|
-
|
|
68
|
+
# Interactive, not Background: macOS gives Background agents efficiency
|
|
69
|
+
# cores and throttled I/O, and the UI stalled for seconds under load.
|
|
70
|
+
"ProcessType": "Interactive",
|
|
69
71
|
"StandardOutPath": f"{logs}/web.log",
|
|
70
72
|
"StandardErrorPath": f"{logs}/web.log",
|
|
71
73
|
}
|
|
@@ -115,7 +117,7 @@ case "${1:-install}" in
|
|
|
115
117
|
status)
|
|
116
118
|
# launchctl's own "Could not find service" is noise when it isn't loaded.
|
|
117
119
|
if out="$(launchctl print "$DOMAIN/$LABEL" 2>/dev/null)"; then
|
|
118
|
-
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs)'
|
|
120
|
+
printf '%s\n' "$out" | grep -E '^\s*(state|pid|last exit code|runs|spawn type)'
|
|
119
121
|
else
|
|
120
122
|
echo "$LABEL is not loaded"
|
|
121
123
|
fi
|
|
@@ -31,6 +31,7 @@ import * as yaml from "yaml";
|
|
|
31
31
|
import { brandSlug, defaultBrand } from "../brand.js";
|
|
32
32
|
import { DEFAULT_PORT } from "../constants.js";
|
|
33
33
|
import { getDistRoot } from "../paths.js";
|
|
34
|
+
import { cachedRead } from "../read-cache.js";
|
|
34
35
|
import { readManifestStacks } from "../stacks/config.js";
|
|
35
36
|
import { getActiveWorkspaces } from "../environment-manager/workspaces.js";
|
|
36
37
|
import { assertDomain } from "./render.js";
|
|
@@ -154,6 +155,21 @@ function manifestStackNames(): string[] {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
export function readEdgeConfig(root: string = getDistRoot()): EdgeConfig {
|
|
158
|
+
// The web server reads this on every request (open links, health rows);
|
|
159
|
+
// reused for the read cache's TTL there, read fresh everywhere else. An
|
|
160
|
+
// error is cached as such: most instances have no edge block at all.
|
|
161
|
+
const r = cachedRead(`edge-config:${root}`, () => {
|
|
162
|
+
try {
|
|
163
|
+
return { config: computeEdgeConfig(root) };
|
|
164
|
+
} catch (e) {
|
|
165
|
+
return { error: (e as Error).message };
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
if ("error" in r) throw new Error(r.error);
|
|
169
|
+
return r.config;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function computeEdgeConfig(root: string): EdgeConfig {
|
|
157
173
|
const { team: t, user: u } = layers(root);
|
|
158
174
|
const team = (t.edge ?? {}) as RawEdge;
|
|
159
175
|
const user = (u.edge ?? {}) as RawEdge;
|
|
@@ -175,6 +175,36 @@ export function __setExtraCaForTests(pems: string[] | null): void {
|
|
|
175
175
|
_extraCa = pems;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
+
/**
|
|
179
|
+
* One TLS context with the public roots plus extraCa(), built once. Passing
|
|
180
|
+
* `ca` per request made Node parse ~150 root certificates on every HTTPS
|
|
181
|
+
* health check, synchronously: tens of ms per check, per poll.
|
|
182
|
+
*/
|
|
183
|
+
/** Build the HTTPS checks' trust context now (web server startup). */
|
|
184
|
+
export function prewarmHealthTls(): void {
|
|
185
|
+
trustContext();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let _trust: {
|
|
189
|
+
ca: string[];
|
|
190
|
+
context: tls.SecureContext;
|
|
191
|
+
agent: https.Agent;
|
|
192
|
+
} | null = null;
|
|
193
|
+
function trustContext(): { context: tls.SecureContext; agent: https.Agent } {
|
|
194
|
+
const extra = extraCa();
|
|
195
|
+
if (_trust?.ca !== extra) {
|
|
196
|
+
_trust?.agent.destroy();
|
|
197
|
+
_trust = {
|
|
198
|
+
ca: extra,
|
|
199
|
+
context: tls.createSecureContext({
|
|
200
|
+
ca: [...tls.rootCertificates, ...extra],
|
|
201
|
+
}),
|
|
202
|
+
agent: new https.Agent({ keepAlive: false }),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
return _trust;
|
|
206
|
+
}
|
|
207
|
+
|
|
178
208
|
/**
|
|
179
209
|
* HTTPS check. Connects to the c-ares-resolved IP (see above) but keeps the
|
|
180
210
|
* hostname for SNI and certificate verification; rewriting the URL to the IP,
|
|
@@ -201,7 +231,12 @@ async function pingHttps(parsed: URL, timeoutMs: number): Promise<PingResult> {
|
|
|
201
231
|
{
|
|
202
232
|
method: "GET",
|
|
203
233
|
servername: isIpLiteral(host) ? undefined : host,
|
|
204
|
-
|
|
234
|
+
// tls.connect's option, passed through by the agent (not in the
|
|
235
|
+
// https.RequestOptions type).
|
|
236
|
+
...({ secureContext: trustContext().context } as https.RequestOptions),
|
|
237
|
+
// Its own agent: a socket kept alive under one trust set must never
|
|
238
|
+
// be reused for a check under another.
|
|
239
|
+
agent: trustContext().agent,
|
|
205
240
|
lookup: (_h, opts, cb) => {
|
|
206
241
|
// Node may ask for all addresses (autoSelectFamily).
|
|
207
242
|
if ((opts as { all?: boolean }).all)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* repo's dev/up op, captured at spawn time and/or parsed on demand.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { execFileSync, execSync } from "child_process";
|
|
13
|
+
import { execFile, execFileSync, execSync } from "child_process";
|
|
14
14
|
import * as fs from "fs";
|
|
15
15
|
import * as path from "path";
|
|
16
16
|
import { parse as parseYaml } from "yaml";
|
|
@@ -333,3 +333,85 @@ export function diffSpawnedContainers(
|
|
|
333
333
|
for (const c of after) if (!had.has(c.base)) fresh.add(c.base);
|
|
334
334
|
return [...fresh];
|
|
335
335
|
}
|
|
336
|
+
|
|
337
|
+
// ── Async variants (the web server) ──────────────────────────────────────────
|
|
338
|
+
// The sync calls above block the event loop for the whole `docker ps` /
|
|
339
|
+
// `opctl container ls` (hundreds of ms on a loaded machine). The server uses
|
|
340
|
+
// these instead; concurrent callers share one in-flight call.
|
|
341
|
+
|
|
342
|
+
function execOut(cmd: string, args: string[]): Promise<string | null> {
|
|
343
|
+
return new Promise((resolve) => {
|
|
344
|
+
execFile(
|
|
345
|
+
cmd,
|
|
346
|
+
args,
|
|
347
|
+
{ encoding: "utf-8", timeout: 5_000, maxBuffer: 16 * 1024 * 1024 },
|
|
348
|
+
(err, stdout) => resolve(err ? null : stdout),
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function shared<T>(run: () => Promise<T>): () => Promise<T> {
|
|
354
|
+
let inFlight: Promise<T> | null = null;
|
|
355
|
+
return () =>
|
|
356
|
+
(inFlight ??= run().finally(() => {
|
|
357
|
+
inFlight = null;
|
|
358
|
+
}));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Whether `opctl` is on PATH, without spawning a shell. Cached. */
|
|
362
|
+
export async function isOpctlAvailableAsync(): Promise<boolean> {
|
|
363
|
+
if (opctlAvailableCache !== null) return opctlAvailableCache;
|
|
364
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
365
|
+
if (!dir) continue;
|
|
366
|
+
try {
|
|
367
|
+
await fs.promises.access(path.join(dir, "opctl"), fs.constants.X_OK);
|
|
368
|
+
return (opctlAvailableCache = true);
|
|
369
|
+
} catch {
|
|
370
|
+
/* next */
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return (opctlAvailableCache = false);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** runningDockerNames, async; null when docker is unavailable. */
|
|
377
|
+
export const runningDockerNamesAsync = shared(
|
|
378
|
+
async (): Promise<string[] | null> => {
|
|
379
|
+
const out = await execOut("docker", ["ps", "--format", "{{.Names}}"]);
|
|
380
|
+
return out === null
|
|
381
|
+
? null
|
|
382
|
+
: out
|
|
383
|
+
.split("\n")
|
|
384
|
+
.map((l) => l.trim())
|
|
385
|
+
.filter(Boolean);
|
|
386
|
+
},
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
/** listRunningOpctlContainers, async. */
|
|
390
|
+
export const listRunningOpctlContainersAsync = shared(
|
|
391
|
+
async (): Promise<{ name: string; base: string }[]> => {
|
|
392
|
+
const byBase = new Map<string, { name: string; base: string }>();
|
|
393
|
+
const [opctlOut, dockerNames] = await Promise.all([
|
|
394
|
+
isOpctlAvailableAsync().then((ok) =>
|
|
395
|
+
ok ? execOut("opctl", ["container", "ls"]) : null,
|
|
396
|
+
),
|
|
397
|
+
runningDockerNamesAsync(),
|
|
398
|
+
]);
|
|
399
|
+
if (opctlOut !== null) {
|
|
400
|
+
const lines = opctlOut.split("\n");
|
|
401
|
+
const firstToken = lines[0]?.trim().split(/\s+/)[0];
|
|
402
|
+
const body =
|
|
403
|
+
firstToken === "NAME" || firstToken === "ID" ? lines.slice(1) : lines;
|
|
404
|
+
for (const line of body) {
|
|
405
|
+
const name = line.trim().split(/\s+/)[0];
|
|
406
|
+
if (!name) continue;
|
|
407
|
+
const base = name.replace(/-[0-9a-f]{8}$/i, "");
|
|
408
|
+
byBase.set(base, { name, base });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
for (const name of dockerNames ?? []) {
|
|
412
|
+
const m = name.match(DOCKER_OPCTL_NAME);
|
|
413
|
+
if (m && !byBase.has(m[1])) byBase.set(m[1], { name, base: m[1] });
|
|
414
|
+
}
|
|
415
|
+
return [...byBase.values()];
|
|
416
|
+
},
|
|
417
|
+
);
|
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
inspectOpctlContainer,
|
|
45
45
|
isOpctlAvailable,
|
|
46
46
|
listRunningOpctlContainers,
|
|
47
|
+
listRunningOpctlContainersAsync,
|
|
48
|
+
isOpctlAvailableAsync,
|
|
47
49
|
} from "./opctl.js";
|
|
48
50
|
import {
|
|
49
51
|
getActiveWorkspace,
|
|
@@ -357,6 +359,24 @@ export function activeDaemonOwners(
|
|
|
357
359
|
return (record?.workspaces ?? []).filter((w) => active.has(w));
|
|
358
360
|
}
|
|
359
361
|
|
|
362
|
+
/** Whether workspace `name`'s current config lists `daemon`'s service. */
|
|
363
|
+
function workspaceContainsDaemon(
|
|
364
|
+
name: string,
|
|
365
|
+
daemon: Pick<DaemonRecord, "address" | "repoName">,
|
|
366
|
+
): boolean {
|
|
367
|
+
const services = getWorkspaceByName(name)?.config.services ?? {};
|
|
368
|
+
return daemon.address in services || daemon.repoName in services;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Replace a running daemon's owners (re-derived from membership). */
|
|
372
|
+
function setDaemonOwners(address: string, workspaces: string[]): void {
|
|
373
|
+
loadDaemonsFromFile();
|
|
374
|
+
const existing = _daemonRegistry.get(address);
|
|
375
|
+
if (!existing) return;
|
|
376
|
+
_daemonRegistry.set(address, { ...existing, workspaces });
|
|
377
|
+
saveDaemonsFile(getDaemonsFile());
|
|
378
|
+
}
|
|
379
|
+
|
|
360
380
|
/** Outcome of releasing one daemon during a workspace deactivation. */
|
|
361
381
|
export interface DaemonRelease {
|
|
362
382
|
address: string;
|
|
@@ -382,15 +402,19 @@ export async function releaseWorkspaceDaemons(
|
|
|
382
402
|
const owned = listDaemons().filter((d) =>
|
|
383
403
|
(d.workspaces ?? []).includes(workspace),
|
|
384
404
|
);
|
|
385
|
-
const active =
|
|
405
|
+
const active = getActiveWorkspaces();
|
|
386
406
|
const releases: DaemonRelease[] = [];
|
|
387
407
|
|
|
388
408
|
for (const daemon of owned) {
|
|
389
|
-
|
|
390
|
-
|
|
409
|
+
// Owners re-derived from the CURRENT membership, not the ones recorded at
|
|
410
|
+
// start: a repo moved into another workspace while its daemon ran belongs
|
|
411
|
+
// to that workspace now, and one moved out no longer belongs to this one.
|
|
412
|
+
// Stopped only when no active workspace still contains it.
|
|
413
|
+
const remaining = active.filter(
|
|
414
|
+
(w) => w !== workspace && workspaceContainsDaemon(w, daemon),
|
|
391
415
|
);
|
|
392
416
|
if (remaining.length > 0) {
|
|
393
|
-
|
|
417
|
+
setDaemonOwners(daemon.address, remaining);
|
|
394
418
|
onLine(
|
|
395
419
|
`detached "${workspace}" from ${daemon.address} — still running for [${remaining.join(", ")}]`,
|
|
396
420
|
);
|
|
@@ -639,6 +663,27 @@ export function pruneDeadDaemons(): string[] {
|
|
|
639
663
|
const running = isOpctlAvailable()
|
|
640
664
|
? new Set(listRunningOpctlContainers().map((c) => c.base))
|
|
641
665
|
: null;
|
|
666
|
+
return pruneAgainst(daemons, running);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* pruneDeadDaemons without blocking: the container listing runs as async
|
|
671
|
+
* child processes (the web server's startup, which used to hold the event
|
|
672
|
+
* loop for the `docker ps`).
|
|
673
|
+
*/
|
|
674
|
+
export async function pruneDeadDaemonsAsync(): Promise<string[]> {
|
|
675
|
+
const daemons = listDaemons();
|
|
676
|
+
if (daemons.length === 0) return [];
|
|
677
|
+
const running = (await isOpctlAvailableAsync())
|
|
678
|
+
? new Set((await listRunningOpctlContainersAsync()).map((c) => c.base))
|
|
679
|
+
: null;
|
|
680
|
+
return pruneAgainst(daemons, running);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function pruneAgainst(
|
|
684
|
+
daemons: DaemonRecord[],
|
|
685
|
+
running: Set<string> | null,
|
|
686
|
+
): string[] {
|
|
642
687
|
const pruned: string[] = [];
|
|
643
688
|
for (const d of daemons) {
|
|
644
689
|
try {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A checkout's `origin` URL, read from its git config file instead of by
|
|
3
|
+
* running `git`. Callers on the web server's request path (manifest
|
|
4
|
+
* resolution for every repo without its own manifest) used to spawn
|
|
5
|
+
* `git config --get remote.origin.url` synchronously, one repo after another:
|
|
6
|
+
* ~40-100ms each, which held the event loop for a second on a cold server.
|
|
7
|
+
*
|
|
8
|
+
* Same answer as `git -C <dir> config --get remote.origin.url` for ordinary
|
|
9
|
+
* checkouts and linked worktrees: the repo is found by walking up from `dir`
|
|
10
|
+
* to the first `.git` (a directory, or a `gitdir:` file), and a worktree's
|
|
11
|
+
* config is its common dir's.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "fs";
|
|
14
|
+
import * as path from "path";
|
|
15
|
+
|
|
16
|
+
/** The git directory for `start` (walking up), or null outside a repo. */
|
|
17
|
+
export function findGitDir(start: string): string | null {
|
|
18
|
+
let dir = path.resolve(start);
|
|
19
|
+
for (;;) {
|
|
20
|
+
const dotGit = path.join(dir, ".git");
|
|
21
|
+
try {
|
|
22
|
+
const st = fs.statSync(dotGit);
|
|
23
|
+
if (st.isDirectory()) return dotGit;
|
|
24
|
+
if (st.isFile()) {
|
|
25
|
+
const m = /^gitdir:\s*(.+?)\s*$/m.exec(
|
|
26
|
+
fs.readFileSync(dotGit, "utf-8"),
|
|
27
|
+
);
|
|
28
|
+
if (m) return path.resolve(dir, m[1]);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
/* no .git here: keep walking */
|
|
32
|
+
}
|
|
33
|
+
const parent = path.dirname(dir);
|
|
34
|
+
if (parent === dir) return null;
|
|
35
|
+
dir = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The `url` of `[remote "origin"]` in a git config file's text, or null. */
|
|
40
|
+
export function originUrlFromConfig(text: string): string | null {
|
|
41
|
+
let inOrigin = false;
|
|
42
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
43
|
+
const line = raw.trim();
|
|
44
|
+
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
|
45
|
+
if (line.startsWith("[")) {
|
|
46
|
+
inOrigin = /^\[\s*remote\s+"origin"\s*\]$/i.test(line);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!inOrigin) continue;
|
|
50
|
+
const m = /^url\s*=\s*(.*)$/i.exec(line);
|
|
51
|
+
if (m) {
|
|
52
|
+
const value = m[1].replace(/\s+[#;].*$/, "").trim();
|
|
53
|
+
return value.replace(/^"(.*)"$/, "$1") || null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `dir`'s origin URL, or null (not a checkout, or no origin). Never spawns. */
|
|
60
|
+
export function readOriginUrl(dir: string): string | null {
|
|
61
|
+
const gitDir = findGitDir(dir);
|
|
62
|
+
if (!gitDir) return null;
|
|
63
|
+
let common = gitDir;
|
|
64
|
+
try {
|
|
65
|
+
const rel = fs.readFileSync(path.join(gitDir, "commondir"), "utf-8").trim();
|
|
66
|
+
if (rel) common = path.resolve(gitDir, rel);
|
|
67
|
+
} catch {
|
|
68
|
+
/* not a linked worktree */
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return originUrlFromConfig(
|
|
72
|
+
fs.readFileSync(path.join(common, "config"), "utf-8"),
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
|
|
16
16
|
import * as fs from "fs";
|
|
17
17
|
import * as path from "path";
|
|
18
|
-
import {
|
|
18
|
+
import { readOriginUrl } from "../git/origin-url.js";
|
|
19
|
+
import { cachedRead } from "../read-cache.js";
|
|
19
20
|
import { getDistRoot } from "../paths.js";
|
|
20
21
|
import { parse as parseYaml } from "yaml";
|
|
21
22
|
import type { RawManifest } from "../types/index.js";
|
|
@@ -48,21 +49,15 @@ const originCache = new Map<string, string | null>();
|
|
|
48
49
|
/** Repo name of `repoPath`'s git origin (`…/<name>.git`), cached. */
|
|
49
50
|
export function originRepoName(repoPath: string): string | null {
|
|
50
51
|
if (originCache.has(repoPath)) return originCache.get(repoPath)!;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
{ encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 2000 },
|
|
57
|
-
).trim();
|
|
58
|
-
name =
|
|
59
|
-
url
|
|
52
|
+
// Read from the git config file, never by spawning git: this runs on the
|
|
53
|
+
// web server's request path for every repo without its own manifest.
|
|
54
|
+
const url = readOriginUrl(repoPath);
|
|
55
|
+
const name = url
|
|
56
|
+
? url
|
|
60
57
|
.replace(/\.git$/, "")
|
|
61
58
|
.split(/[/:]/)
|
|
62
|
-
.pop() || null
|
|
63
|
-
|
|
64
|
-
/* not a git checkout, or no origin */
|
|
65
|
-
}
|
|
59
|
+
.pop() || null
|
|
60
|
+
: null;
|
|
66
61
|
originCache.set(repoPath, name);
|
|
67
62
|
return name;
|
|
68
63
|
}
|
|
@@ -169,6 +164,29 @@ export function parseManifestText(
|
|
|
169
164
|
return parsed as Record<string, unknown>;
|
|
170
165
|
}
|
|
171
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Parsed manifests by file, reused while the file's text is unchanged. A
|
|
169
|
+
* single web request reads the same manifests several times (kind, health
|
|
170
|
+
* plan, stack names, edge links), and parsing YAML is the expensive part:
|
|
171
|
+
* it held the event loop ~100-200ms per /api/health run. Callers get their
|
|
172
|
+
* own copy, so mutating a result never leaks into the cache.
|
|
173
|
+
*/
|
|
174
|
+
const parsedCache = new Map<
|
|
175
|
+
string,
|
|
176
|
+
{ raw: string; parsed: Record<string, unknown> | null }
|
|
177
|
+
>();
|
|
178
|
+
|
|
179
|
+
function parseCached(
|
|
180
|
+
file: string,
|
|
181
|
+
raw: string,
|
|
182
|
+
): Record<string, unknown> | null {
|
|
183
|
+
const hit = parsedCache.get(file);
|
|
184
|
+
if (hit && hit.raw === raw) return hit.parsed && structuredClone(hit.parsed);
|
|
185
|
+
const parsed = parseManifestText(file, raw);
|
|
186
|
+
parsedCache.set(file, { raw, parsed });
|
|
187
|
+
return parsed && structuredClone(parsed);
|
|
188
|
+
}
|
|
189
|
+
|
|
172
190
|
/** The message for an override that has no base to apply to. */
|
|
173
191
|
export function orphanOverrideMessage(): string {
|
|
174
192
|
return `an override with no base manifest is not a manifest: add a ${product().manifestYml} to the repo, or to manifests/`;
|
|
@@ -198,7 +216,7 @@ function combine(
|
|
|
198
216
|
): ManifestLoad {
|
|
199
217
|
const out: ManifestLoad = { manifest: null, layers: [], ...at };
|
|
200
218
|
const parse = (file: string, raw: string): Record<string, unknown> => {
|
|
201
|
-
const parsed =
|
|
219
|
+
const parsed = parseCached(file, raw);
|
|
202
220
|
if (!parsed) throw new Error(`${file}: not a map`);
|
|
203
221
|
return parsed;
|
|
204
222
|
};
|
|
@@ -224,6 +242,12 @@ function combine(
|
|
|
224
242
|
|
|
225
243
|
/** Resolve, read and merge `repoPath`'s manifest layers. Never throws. */
|
|
226
244
|
export function loadManifest(repoPath: string): ManifestLoad {
|
|
245
|
+
// The web server resolves the same manifests many times per request; it
|
|
246
|
+
// reuses them for the read cache's TTL (see read-cache.ts).
|
|
247
|
+
return cachedRead(`manifest:${repoPath}`, () => loadManifestFresh(repoPath));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function loadManifestFresh(repoPath: string): ManifestLoad {
|
|
227
251
|
const at = locate(repoPath);
|
|
228
252
|
try {
|
|
229
253
|
return combine(
|
package/src/shared/paths.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
loadProfile,
|
|
5
|
+
onProfileCacheReset,
|
|
5
6
|
PROFILE_FILE,
|
|
6
7
|
readEnv,
|
|
7
8
|
type ProductProfile,
|
|
@@ -74,6 +75,34 @@ function rootFromEnv(): string | null {
|
|
|
74
75
|
* anchor fails loudly.
|
|
75
76
|
*/
|
|
76
77
|
export function getDistRoot(): string {
|
|
78
|
+
// Resolved once per set of inputs: the walk and its den.yml checks used to
|
|
79
|
+
// run on every call (hundreds per web request, via product()). The inputs
|
|
80
|
+
// are the env vars it reads and the entry script; change any and it
|
|
81
|
+
// re-resolves. A failure is never cached.
|
|
82
|
+
const key = distRootKey();
|
|
83
|
+
if (distRootMemo?.key === key) return distRootMemo.root;
|
|
84
|
+
const root = resolveDistRoot();
|
|
85
|
+
distRootMemo = { key, root };
|
|
86
|
+
return root;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let distRootMemo: { key: string; root: string } | null = null;
|
|
90
|
+
|
|
91
|
+
/** Everything getDistRoot's answer depends on, as one string. */
|
|
92
|
+
function distRootKey(): string {
|
|
93
|
+
let key = `${process.argv[1] ?? ""}\0${process.env.DEN_PROFILE ?? ""}`;
|
|
94
|
+
for (const name in process.env)
|
|
95
|
+
if (name.endsWith("_ROOT")) key += `\0${name}=${process.env[name]}`;
|
|
96
|
+
return key;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Forget the resolved root (tests that rebuild a root in place). */
|
|
100
|
+
export function resetDistRootCache(): void {
|
|
101
|
+
distRootMemo = null;
|
|
102
|
+
}
|
|
103
|
+
onProfileCacheReset(resetDistRootCache);
|
|
104
|
+
|
|
105
|
+
function resolveDistRoot(): string {
|
|
77
106
|
const fromEnv = rootFromEnv();
|
|
78
107
|
if (fromEnv) return fromEnv;
|
|
79
108
|
const start = entryDir();
|
|
@@ -128,6 +157,19 @@ function isProductRootVar(dir: string, key: string): boolean {
|
|
|
128
157
|
* node_modules/@monster-orange/den; in Den's own checkout, its root.
|
|
129
158
|
*/
|
|
130
159
|
export function getEngineRoot(): string {
|
|
160
|
+
const key = `${process.env.DEN_ENGINE_ROOT ?? ""}\0${process.argv[1] ?? ""}\0${process.cwd()}`;
|
|
161
|
+
if (engineRootMemo?.key === key) return engineRootMemo.root;
|
|
162
|
+
const root = resolveEngineRoot();
|
|
163
|
+
engineRootMemo = { key, root };
|
|
164
|
+
return root;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let engineRootMemo: { key: string; root: string } | null = null;
|
|
168
|
+
onProfileCacheReset(() => {
|
|
169
|
+
engineRootMemo = null;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
function resolveEngineRoot(): string {
|
|
131
173
|
const env = process.env.DEN_ENGINE_ROOT?.trim();
|
|
132
174
|
if (env) return path.resolve(env);
|
|
133
175
|
for (const start of [entryDir(), process.cwd()]) {
|
package/src/shared/product.ts
CHANGED
|
@@ -35,10 +35,16 @@ function withDerived(p: ProductProfile): Product {
|
|
|
35
35
|
};
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
let derived: { profile: ProductProfile; product: Product } | null = null;
|
|
39
|
+
|
|
38
40
|
/** The running product. Never throws. */
|
|
39
41
|
export function product(): Product {
|
|
40
42
|
try {
|
|
41
|
-
|
|
43
|
+
const profile = loadProfile(getDistRoot());
|
|
44
|
+
// Same profile object (loadProfile caches it): same derived names.
|
|
45
|
+
if (derived?.profile !== profile)
|
|
46
|
+
derived = { profile, product: withDerived(profile) };
|
|
47
|
+
return derived.product;
|
|
42
48
|
} catch {
|
|
43
49
|
const pinned = process.env.DEN_PROFILE?.trim();
|
|
44
50
|
try {
|
package/src/shared/profile.ts
CHANGED
|
@@ -122,8 +122,16 @@ export function loadProfile(root: string): ProductProfile {
|
|
|
122
122
|
return cached.profile;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
const resetHooks: (() => void)[] = [];
|
|
126
|
+
|
|
127
|
+
/** Run `hook` whenever resetProfileCache runs (caches derived from it). */
|
|
128
|
+
export function onProfileCacheReset(hook: () => void): void {
|
|
129
|
+
resetHooks.push(hook);
|
|
130
|
+
}
|
|
131
|
+
|
|
125
132
|
export function resetProfileCache(): void {
|
|
126
133
|
cached = null;
|
|
134
|
+
for (const hook of resetHooks) hook();
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
/** `${PREFIX}_${key}`: the env-var name this distribution uses for `key`. */
|