@monster-orange/den 0.2.3 → 0.2.5
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 +208 -0
- package/docs/distributions.md +64 -16
- package/package.json +1 -1
- package/service/install.sh +4 -2
- package/src/shared/constants.ts +7 -0
- package/src/shared/den-config.ts +130 -12
- package/src/shared/edge/config.ts +16 -0
- package/src/shared/engine.ts +18 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -87
- package/src/shared/environment-manager/health.ts +229 -30
- 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/compose-health.ts +150 -0
- package/src/shared/stacks/runtime.ts +71 -24
- package/src/shared/stacks/status.ts +112 -3
- package/src/ui/cli/health-display.ts +1 -0
- package/src/ui/cli/index.ts +181 -31
- package/src/ui/cli/mcp-connect.ts +77 -15
- package/src/ui/cli/stack-command.ts +40 -12
- package/src/ui/cli/web-command.ts +25 -7
- package/src/ui/cli/workspace-notes.ts +43 -0
- package/src/ui/server/graph-order.ts +22 -0
- package/src/ui/server/index.ts +174 -197
- package/src/ui/server/public/changelog-view.js +16 -0
- package/src/ui/server/public/header.js +121 -12
- package/src/ui/server/services-list.ts +210 -0
- package/src/ui/server/single-flight.ts +53 -0
- package/src/ui/server/ui-assets.ts +1 -0
- package/src/ui/server/workspace-routes.ts +247 -181
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
buildWorkspaceMenuModel,
|
|
16
16
|
buildWorkspacePillModel,
|
|
17
17
|
} from "./workspace-picker.js";
|
|
18
|
+
import { changelogView } from "./changelog-view.js";
|
|
18
19
|
|
|
19
20
|
// 5s between polls — measured from each poll's COMPLETION (schedulePoll
|
|
20
21
|
// re-arms after renderHealthDots resolves), so slow probes can't overlap.
|
|
@@ -80,6 +81,8 @@ function _injectNavStyles() {
|
|
|
80
81
|
const LS_CHANGELOG_SEEN = "__DEN_CLI__-changelog-seen";
|
|
81
82
|
let _changelogEntries = [];
|
|
82
83
|
let _changelogInjected = false;
|
|
84
|
+
/** The pop-up lists every version only after the reader asks. */
|
|
85
|
+
let _clShowAll = false;
|
|
83
86
|
|
|
84
87
|
function _injectChangelogStyles() {
|
|
85
88
|
if (_changelogInjected) return;
|
|
@@ -134,6 +137,7 @@ function _injectChangelogStyles() {
|
|
|
134
137
|
.cl-entry { margin-bottom: 18px; }
|
|
135
138
|
.cl-ver-row { display: flex; align-items: baseline; gap: 8px; margin-bottom: 8px; }
|
|
136
139
|
.cl-ver {
|
|
140
|
+
margin: 0;
|
|
137
141
|
font-family: var(--mono, monospace); font-size: 13px; font-weight: 700;
|
|
138
142
|
color: var(--accent, #58a6ff);
|
|
139
143
|
}
|
|
@@ -166,6 +170,13 @@ function _injectChangelogStyles() {
|
|
|
166
170
|
display: inline-flex; align-items: center; justify-content: center;
|
|
167
171
|
transition: color .1s, border-color .1s;
|
|
168
172
|
}
|
|
173
|
+
.cl-more-btn {
|
|
174
|
+
margin-right: auto; padding: 4px 12px;
|
|
175
|
+
font: inherit; font-size: 11px; color: var(--muted, #8b949e);
|
|
176
|
+
background: none; border: 1px solid var(--border, #30363d); border-radius: 6px;
|
|
177
|
+
cursor: pointer;
|
|
178
|
+
}
|
|
179
|
+
.cl-more-btn:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
|
|
169
180
|
.cl-copy-btn:hover { color: var(--accent, #58a6ff); border-color: var(--accent, #58a6ff); }
|
|
170
181
|
.cl-copy-btn.copied { color: #3fb950; border-color: #3fb950; }
|
|
171
182
|
.cl-slack-btn:hover { color: #e01e5a; border-color: #e01e5a; }
|
|
@@ -189,12 +200,18 @@ function _injectChangelogStyles() {
|
|
|
189
200
|
overlay.id = "cl-overlay";
|
|
190
201
|
overlay.className = "cl-overlay";
|
|
191
202
|
overlay.innerHTML =
|
|
192
|
-
'<div class="cl-box">' +
|
|
193
|
-
'<div class="cl-hdr"><h3>What\'s new</h3><button class="cl-close" id="cl-close-btn" title="Close">×</button></div>' +
|
|
203
|
+
'<div class="cl-box" role="dialog" aria-modal="true" aria-labelledby="cl-title">' +
|
|
204
|
+
'<div class="cl-hdr"><h3 id="cl-title" tabindex="-1">What\'s new</h3><button type="button" class="cl-close" id="cl-close-btn" title="Close" aria-label="Close">×</button></div>' +
|
|
194
205
|
'<div class="cl-body" id="cl-body"></div>' +
|
|
195
|
-
'<div class="cl-footer"><button class="cl-got-it" id="cl-got-it-btn">Got it</button></div>' +
|
|
206
|
+
'<div class="cl-footer"><button type="button" class="cl-more-btn" id="cl-more" aria-controls="cl-body" aria-expanded="false" hidden></button><button class="cl-got-it" id="cl-got-it-btn">Got it</button></div>' +
|
|
196
207
|
"</div>";
|
|
197
208
|
document.body.appendChild(overlay);
|
|
209
|
+
document.getElementById("cl-more")?.addEventListener("click", () => {
|
|
210
|
+
_clShowAll = !_clShowAll;
|
|
211
|
+
_renderChangelogBody(_changelogEntries);
|
|
212
|
+
const body = document.getElementById("cl-body");
|
|
213
|
+
if (body) body.scrollTop = 0;
|
|
214
|
+
});
|
|
198
215
|
|
|
199
216
|
overlay.addEventListener("click", (e) => {
|
|
200
217
|
if (e.target === overlay) window.closeChangelog();
|
|
@@ -207,10 +224,44 @@ function _injectChangelogStyles() {
|
|
|
207
224
|
?.addEventListener("click", () => window.closeChangelog());
|
|
208
225
|
|
|
209
226
|
document.addEventListener("keydown", (e) => {
|
|
227
|
+
if (!_changelogIsOpen()) return;
|
|
210
228
|
if (e.key === "Escape") window.closeChangelog();
|
|
229
|
+
else if (e.key === "Tab") _trapChangelogTab(e);
|
|
211
230
|
});
|
|
212
231
|
}
|
|
213
232
|
|
|
233
|
+
function _changelogIsOpen() {
|
|
234
|
+
return !!document.getElementById("cl-overlay")?.classList.contains("open");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The dialog's focusable controls, in order (hidden ones excluded). */
|
|
238
|
+
function _changelogFocusables() {
|
|
239
|
+
const box = document.querySelector("#cl-overlay .cl-box");
|
|
240
|
+
if (!box) return [];
|
|
241
|
+
return [...box.querySelectorAll("button, a[href], [tabindex]")].filter(
|
|
242
|
+
(el) => !el.hidden && el.getAttribute("tabindex") !== "-1",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Keep Tab inside the open dialog (aria-modal). */
|
|
247
|
+
function _trapChangelogTab(e) {
|
|
248
|
+
const els = _changelogFocusables();
|
|
249
|
+
if (!els.length) return;
|
|
250
|
+
const first = els[0];
|
|
251
|
+
const last = els[els.length - 1];
|
|
252
|
+
const inside = els.includes(document.activeElement);
|
|
253
|
+
if (e.shiftKey && (document.activeElement === first || !inside)) {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
last.focus();
|
|
256
|
+
} else if (!e.shiftKey && (document.activeElement === last || !inside)) {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
first.focus();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** What had focus before the dialog opened; it gets it back on close. */
|
|
263
|
+
let _clReturnFocus = null;
|
|
264
|
+
|
|
214
265
|
function _renderChangelogBody(entries) {
|
|
215
266
|
const body = document.getElementById("cl-body");
|
|
216
267
|
if (!body) return;
|
|
@@ -228,22 +279,25 @@ function _renderChangelogBody(entries) {
|
|
|
228
279
|
escHtml(s)
|
|
229
280
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
230
281
|
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
231
|
-
|
|
282
|
+
const view = changelogView(entries, _clShowAll);
|
|
283
|
+
body.innerHTML = view.shown
|
|
232
284
|
.map(
|
|
233
285
|
(entry, i) =>
|
|
234
286
|
(i > 0 ? '<hr class="cl-divider">' : "") +
|
|
235
287
|
'<div class="cl-entry">' +
|
|
236
288
|
'<div class="cl-ver-row">' +
|
|
237
|
-
'<
|
|
289
|
+
'<h4 class="cl-ver">v' +
|
|
238
290
|
escHtml(entry.version) +
|
|
239
|
-
"</
|
|
291
|
+
"</h4>" +
|
|
240
292
|
'<span class="cl-date">' +
|
|
241
293
|
escHtml(entry.date) +
|
|
242
294
|
"</span>" +
|
|
243
295
|
'<div class="cl-copy-group">' +
|
|
244
296
|
'<button class="cl-copy-btn" data-cl-version="' +
|
|
245
297
|
escHtml(entry.version) +
|
|
246
|
-
'" title="Copy as Markdown"
|
|
298
|
+
'" title="Copy as Markdown" aria-label="Copy v' +
|
|
299
|
+
escHtml(entry.version) +
|
|
300
|
+
' as Markdown">' +
|
|
247
301
|
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
248
302
|
'<rect x="4" y="0.5" width="8.5" height="8.5" rx="1.5"/>' +
|
|
249
303
|
'<path d="M8.5 9v2.5a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h2.5"/>' +
|
|
@@ -251,7 +305,9 @@ function _renderChangelogBody(entries) {
|
|
|
251
305
|
"</button>" +
|
|
252
306
|
'<button class="cl-slack-btn" data-cl-version="' +
|
|
253
307
|
escHtml(entry.version) +
|
|
254
|
-
'" title="Copy for Slack"
|
|
308
|
+
'" title="Copy for Slack" aria-label="Copy v' +
|
|
309
|
+
escHtml(entry.version) +
|
|
310
|
+
' for Slack">' +
|
|
255
311
|
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
256
312
|
'<path d="M1 2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L5 12v-3H2a1 1 0 0 1-1-1V2z"/>' +
|
|
257
313
|
"</svg>" +
|
|
@@ -290,6 +346,17 @@ function _renderChangelogBody(entries) {
|
|
|
290
346
|
)
|
|
291
347
|
.join("");
|
|
292
348
|
|
|
349
|
+
// The toggle lives in the footer, so it is in view however long the
|
|
350
|
+
// newest entry is.
|
|
351
|
+
const more = document.getElementById("cl-more");
|
|
352
|
+
if (more) {
|
|
353
|
+
more.hidden = !view.toggle;
|
|
354
|
+
if (view.toggle) {
|
|
355
|
+
more.textContent = view.toggle.label;
|
|
356
|
+
more.setAttribute("aria-expanded", String(view.toggle.expanded));
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
293
360
|
const _slackIcon =
|
|
294
361
|
'<svg width="13" height="13" viewBox="0 0 13 13" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
295
362
|
'<path d="M1 2a1 1 0 0 1 1-1h9a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H7.5L5 12v-3H2a1 1 0 0 1-1-1V2z"/>' +
|
|
@@ -455,15 +522,39 @@ function _buildMarkdown(entry) {
|
|
|
455
522
|
}
|
|
456
523
|
|
|
457
524
|
window.openChangelog = function () {
|
|
525
|
+
_clShowAll = false;
|
|
458
526
|
_injectChangelogStyles();
|
|
459
527
|
_renderChangelogBody(_changelogEntries);
|
|
460
528
|
const overlay = document.getElementById("cl-overlay");
|
|
461
|
-
if (overlay)
|
|
529
|
+
if (!overlay) return;
|
|
530
|
+
if (!overlay.classList.contains("open")) {
|
|
531
|
+
// A first-visit auto-open has nothing focused: return to the pill.
|
|
532
|
+
const active = document.activeElement;
|
|
533
|
+
_clReturnFocus =
|
|
534
|
+
active && active !== document.body
|
|
535
|
+
? active
|
|
536
|
+
: document.getElementById("version-pill");
|
|
537
|
+
}
|
|
538
|
+
overlay.classList.add("open");
|
|
539
|
+
// After .open: while the overlay is display:none the body cannot scroll,
|
|
540
|
+
// and a reset made then does not stick. Always open at the newest release.
|
|
541
|
+
const body = document.getElementById("cl-body");
|
|
542
|
+
if (body) body.scrollTop = 0;
|
|
543
|
+
// The heading first, so a screen reader starts at the latest notes.
|
|
544
|
+
document.getElementById("cl-title")?.focus();
|
|
462
545
|
};
|
|
463
546
|
|
|
464
547
|
window.closeChangelog = function () {
|
|
465
548
|
const overlay = document.getElementById("cl-overlay");
|
|
549
|
+
const wasOpen = !!overlay?.classList.contains("open");
|
|
466
550
|
if (overlay) overlay.classList.remove("open");
|
|
551
|
+
if (wasOpen) {
|
|
552
|
+
const back = _clReturnFocus?.isConnected
|
|
553
|
+
? _clReturnFocus
|
|
554
|
+
: document.getElementById("version-pill");
|
|
555
|
+
_clReturnFocus = null;
|
|
556
|
+
back?.focus();
|
|
557
|
+
}
|
|
467
558
|
if (_changelogEntries[0]?.version) {
|
|
468
559
|
try {
|
|
469
560
|
localStorage.setItem(LS_CHANGELOG_SEEN, _changelogEntries[0].version);
|
|
@@ -490,6 +581,17 @@ export async function initChangelog() {
|
|
|
490
581
|
if (pill && latest?.version) {
|
|
491
582
|
pill.querySelector(".v-txt").textContent = "v" + latest.version;
|
|
492
583
|
}
|
|
584
|
+
// The pill names the running build (0.2.5-dev.2, not the changelog's
|
|
585
|
+
// newest heading, 0.2.5), so a prerelease under test says what it is.
|
|
586
|
+
fetch("/api/server-info")
|
|
587
|
+
.then((r) => r.json())
|
|
588
|
+
.then((info) => {
|
|
589
|
+
const txt = pill?.querySelector(".v-txt");
|
|
590
|
+
if (txt && typeof info?.version === "string" && info.version) {
|
|
591
|
+
txt.textContent = "v" + info.version;
|
|
592
|
+
}
|
|
593
|
+
})
|
|
594
|
+
.catch(() => {});
|
|
493
595
|
|
|
494
596
|
let lastSeen = null;
|
|
495
597
|
try {
|
|
@@ -1043,7 +1145,9 @@ function _repoHealthItem(repo, items, escAttr) {
|
|
|
1043
1145
|
const state = ok === true ? "ok" : ok === false ? "err" : "";
|
|
1044
1146
|
const tip =
|
|
1045
1147
|
ok == null
|
|
1046
|
-
?
|
|
1148
|
+
? error
|
|
1149
|
+
? `${what} — ${error}`
|
|
1150
|
+
: "no health check"
|
|
1047
1151
|
: error
|
|
1048
1152
|
? `${what} — ${error}`
|
|
1049
1153
|
: `${what} → ${status}`;
|
|
@@ -1063,14 +1167,19 @@ function _repoHealthItem(repo, items, escAttr) {
|
|
|
1063
1167
|
overallState = entry.ok === true ? "ok" : entry.ok === false ? "err" : "";
|
|
1064
1168
|
tip =
|
|
1065
1169
|
entry.ok == null
|
|
1066
|
-
?
|
|
1170
|
+
? entry.error
|
|
1171
|
+
? `${entry.url ?? entry.check} — ${entry.error}`
|
|
1172
|
+
: "no health check"
|
|
1067
1173
|
: entry.error
|
|
1068
1174
|
? `${entry.url ?? entry.check} — ${entry.error}`
|
|
1069
1175
|
: `${entry.url ?? entry.check} → ${entry.status}`;
|
|
1070
1176
|
} else {
|
|
1177
|
+
// Only a completed, failed check (ok false) is down; ok null (no check,
|
|
1178
|
+
// or no answer yet) is unknown and never turns the dot red.
|
|
1071
1179
|
const allOk = items.every((i) => i.ok === true);
|
|
1072
1180
|
const anyOk = items.some((i) => i.ok === true);
|
|
1073
|
-
|
|
1181
|
+
const anyDown = items.some((i) => i.ok === false);
|
|
1182
|
+
overallState = allOk ? "ok" : anyOk ? "running" : anyDown ? "err" : "";
|
|
1074
1183
|
tip = `${items.length} services`;
|
|
1075
1184
|
}
|
|
1076
1185
|
return `<div class="health-item" title="${escAttr(tip)}">
|
|
@@ -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
|
+
}
|