@monster-orange/den 0.2.4 → 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.
@@ -10,7 +10,10 @@
10
10
  * GET /api/run?pillar=X&... — SSE stream: runs tests, emits output line-by-line
11
11
  */
12
12
 
13
- import { composeHealthCmd } from "../../shared/stacks/manifest-commands.js";
13
+ import {
14
+ composeHealthTarget,
15
+ type ComposeHealthTarget,
16
+ } from "../../shared/stacks/compose-health.js";
14
17
  import { healthCheckFields } from "./health-row.js";
15
18
  import { hasRuntime, repoKind } from "../../shared/repo-kind.js";
16
19
  import * as http from "http";
@@ -60,7 +63,8 @@ import {
60
63
  } from "./log-history.js";
61
64
  import {
62
65
  pingHttp,
63
- pingShell,
66
+ pingShellCheck,
67
+ TimeoutStreaks,
64
68
  prewarmHealthTls,
65
69
  } from "../../shared/environment-manager/health.js";
66
70
  import {
@@ -167,7 +171,10 @@ interface HealthResult {
167
171
  repoPath: string;
168
172
  mode: "dev-managed" | "managed";
169
173
  isEntrypoint: boolean;
174
+ /** true up; false a completed check failed (down); null not known. */
170
175
  ok: boolean | null;
176
+ /** With ok null: a check ran but has not answered (pending, not down). */
177
+ pending?: boolean;
171
178
  status?: number;
172
179
  error?: string;
173
180
  /** Where the app opens for this env (open-in-new-tab), or null. */
@@ -209,9 +216,23 @@ function viewerRewrite(
209
216
  }
210
217
 
211
218
  const HEALTH_CACHE_TTL_MS = 8_000;
219
+ /** Consecutive timed-out health cycles per check (see TimeoutStreaks). */
220
+ const healthTimeouts = new TimeoutStreaks();
212
221
  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[]>>();
222
+ /**
223
+ * The full-env health run in flight per cache key. Concurrent polls and
224
+ * streams join it (a stream replays what already landed, then follows), so
225
+ * each cycle runs every check once: a second concurrent run doubled the
226
+ * shell checks' processes and pushed them past their budget under load.
227
+ */
228
+ const healthInFlight = new Map<
229
+ string,
230
+ {
231
+ run: Promise<HealthResult[]>;
232
+ landed: HealthResult[];
233
+ listeners: Set<(r: HealthResult) => void>;
234
+ }
235
+ >();
215
236
  /** /api/services builds, shared by concurrent polls and reused briefly. */
216
237
  /** The startup sweep of stale daemon records (see below the routes). */
217
238
  let startupPrune: Promise<void> = Promise.resolve();
@@ -918,6 +939,8 @@ async function handleRequest(
918
939
  org: string;
919
940
  /** Codes that count as up for a remote target.url probe. */
920
941
  expect?: Expect;
942
+ /** pingShell options (Den's own `stack health` can report unknown). */
943
+ compose: ComposeHealthTarget | null;
921
944
  meta: Pick<HealthResult, "runs" | "target">;
922
945
  }> = [];
923
946
  let edgeDomain: string | null = null;
@@ -950,6 +973,7 @@ async function handleRequest(
950
973
  openUrl: null,
951
974
  links: [],
952
975
  org: orgOf(resolved.repoName, catalog),
976
+ compose: null,
953
977
  meta: { runs: "none", target: null },
954
978
  });
955
979
  continue;
@@ -957,9 +981,9 @@ async function handleRequest(
957
981
  const svcCfg = manifest.services?.[resolved.subsystem];
958
982
  // A compose-run repo with no declared check gets the stack's own health
959
983
  // (container state + Docker healthchecks), as `den status` does.
960
- const composeHealth =
984
+ const compose =
961
985
  env === "local"
962
- ? composeHealthCmd(
986
+ ? composeHealthTarget(
963
987
  manifest,
964
988
  resolved.subsystem,
965
989
  resolved.repoName,
@@ -972,7 +996,7 @@ async function handleRequest(
972
996
  manifest,
973
997
  resolved.subsystem,
974
998
  env,
975
- composeHealth,
999
+ compose?.cmd ?? null,
976
1000
  );
977
1001
  const healthCfg = plan.check;
978
1002
  const remote = plan.placement.runs === "remote";
@@ -1031,6 +1055,7 @@ async function handleRequest(
1031
1055
  links: linksFor(manifest, resolved.subsystem, env),
1032
1056
  org: orgOf(resolved.repoName, catalog),
1033
1057
  ...(plan.expect ? { expect: plan.expect } : {}),
1058
+ compose,
1034
1059
  meta: { runs: plan.placement.runs, target: plan.placement.target },
1035
1060
  });
1036
1061
  }
@@ -1060,12 +1085,18 @@ async function handleRequest(
1060
1085
  conventional,
1061
1086
  meta,
1062
1087
  expect,
1088
+ compose,
1063
1089
  }) => {
1064
1090
  if (checkType !== "none") {
1065
1091
  const started = Date.now();
1066
1092
  const ping =
1067
1093
  checkType === "shell"
1068
- ? await pingShell(target, awsProfileEnv(env), repoPath)
1094
+ ? await pingShellCheck(
1095
+ target,
1096
+ awsProfileEnv(env),
1097
+ repoPath,
1098
+ compose,
1099
+ )
1069
1100
  : await pingHttp(
1070
1101
  target,
1071
1102
  env === "local" && meta.runs !== "remote"
@@ -1083,7 +1114,11 @@ async function handleRequest(
1083
1114
  isEntrypoint,
1084
1115
  ...meta,
1085
1116
  ms: Date.now() - started,
1086
- ...ping,
1117
+ // A first timeout is pending; a repeat on the next cycle is down.
1118
+ ...healthTimeouts.judge(
1119
+ `${env}|${repo}|${service}|${checkType}|${target}`,
1120
+ ping,
1121
+ ),
1087
1122
  };
1088
1123
  }
1089
1124
  // No declared health check: try the conventional probe when eligible.
@@ -1136,6 +1171,37 @@ async function handleRequest(
1136
1171
  return results;
1137
1172
  }
1138
1173
 
1174
+ /** Join the full-env health run in flight for (env, workspace), or start one. */
1175
+ function sharedHealthRun(env: Environment, workspace: string | null) {
1176
+ const key = healthCacheKey(env, workspace);
1177
+ const inFlight = healthInFlight.get(key);
1178
+ if (inFlight) return inFlight;
1179
+ const landed: HealthResult[] = [];
1180
+ const listeners = new Set<(r: HealthResult) => void>();
1181
+ const run = computeHealth(
1182
+ env,
1183
+ null,
1184
+ (r) => {
1185
+ landed.push(r);
1186
+ for (const l of listeners)
1187
+ try {
1188
+ l(r);
1189
+ } catch {
1190
+ /* a gone client must not stop the run */
1191
+ }
1192
+ },
1193
+ workspace,
1194
+ )
1195
+ .then((results) => {
1196
+ healthCache.set(key, { results, at: Date.now() });
1197
+ return results;
1198
+ })
1199
+ .finally(() => healthInFlight.delete(key));
1200
+ const shared = { run, landed, listeners };
1201
+ healthInFlight.set(key, shared);
1202
+ return shared;
1203
+ }
1204
+
1139
1205
  // Full-env health, cached. Every page navigation used to re-run all 30 checks
1140
1206
  // from scratch — and the slowest are shell probes that shell out to the aws
1141
1207
  // CLI (~0.45s each) — so the dots took ~0.6s to resolve on EVERY switch. A
@@ -1162,17 +1228,10 @@ async function handleRequest(
1162
1228
  if (hit && Date.now() - hit.at < HEALTH_CACHE_TTL_MS) {
1163
1229
  return sendJson(res, hit.results.map(forViewer));
1164
1230
  }
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));
1231
+ return sendJson(
1232
+ res,
1233
+ (await sharedHealthRun(env, wsParam).run).map(forViewer),
1234
+ );
1176
1235
  }
1177
1236
 
1178
1237
  // Same checks, streamed: each result is sent the moment it lands so the fast
@@ -1204,13 +1263,16 @@ async function handleRequest(
1204
1263
  }
1205
1264
 
1206
1265
  try {
1207
- const results = await computeHealth(
1208
- env,
1209
- null,
1210
- (r) => send({ type: "result", result: forViewer(r) }),
1211
- wsParam,
1212
- );
1213
- healthCache.set(cacheKey, { results, at: Date.now() });
1266
+ const shared = sharedHealthRun(env, wsParam);
1267
+ const forward = (r: HealthResult): void =>
1268
+ send({ type: "result", result: forViewer(r) });
1269
+ for (const r of shared.landed) forward(r);
1270
+ shared.listeners.add(forward);
1271
+ try {
1272
+ await shared.run;
1273
+ } finally {
1274
+ shared.listeners.delete(forward);
1275
+ }
1214
1276
  send({ type: "done", cached: false });
1215
1277
  } catch (e) {
1216
1278
  send({ type: "error", error: (e as Error).message });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * What the version pop-up shows: the newest release only, or every
3
+ * version once the reader asks for them, plus the one button that toggles
4
+ * between the two (null when there is nothing earlier to show).
5
+ */
6
+ export function changelogView(entries, showAll) {
7
+ const list = Array.isArray(entries) ? entries : [];
8
+ const earlier = list.length - 1;
9
+ if (earlier < 1) return { shown: list.slice(0, 1), toggle: null };
10
+ return {
11
+ shown: showAll ? list : list.slice(0, 1),
12
+ toggle: showAll
13
+ ? { label: "Show latest only", expanded: true }
14
+ : { label: `Show all versions (${earlier} earlier)`, expanded: false },
15
+ };
16
+ }
@@ -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
- body.innerHTML = entries
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
- '<span class="cl-ver">v' +
289
+ '<h4 class="cl-ver">v' +
238
290
  escHtml(entry.version) +
239
- "</span>" +
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) overlay.classList.add("open");
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
- ? "no health check"
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
- ? "no health check"
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
- overallState = allOk ? "ok" : anyOk ? "running" : "err";
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)}">
@@ -19,6 +19,7 @@ export const UI_MODULES: readonly string[] = [
19
19
  "/graph-model.js",
20
20
  "/panel.js",
21
21
  "/graph.css",
22
+ "/changelog-view.js",
22
23
  ];
23
24
 
24
25
  /** URL path → file inside node_modules. */
@@ -90,6 +90,7 @@ import {
90
90
  } from "../../shared/environment-manager/catalog.js";
91
91
  import { getReposRoot, readUserConfig } from "../../shared/den-config.js";
92
92
  import { getDistRoot } from "../../shared/paths.js";
93
+ import { recordRemovedFromWorkspace } from "../../shared/environment-manager/default-workspace.js";
93
94
  import { getSudoEnv, prewarmSudo } from "./sudo-store.js";
94
95
  import {
95
96
  devManagedPaths,
@@ -994,6 +995,12 @@ export async function handleWorkspaceRoutes(
994
995
  // Delete exact match, or all subsystem entries for the repo
995
996
  // (handles both bare-key workspaces like "shop" and
996
997
  // explicit-subsystem workspaces like "shop/api", "shop/db").
998
+ // Removal is deliberate: top-up must never bring the repo back.
999
+ recordRemovedFromWorkspace(
1000
+ file,
1001
+ wsName,
1002
+ parseAddress(address, catalog).repoName,
1003
+ );
997
1004
  if (services[address] !== undefined) {
998
1005
  delete services[address];
999
1006
  } else {