@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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A short-lived cache for the web server's repeated reads (manifests, the
3
+ * edge config, the derived graph), so a request does not re-stat and
4
+ * re-read the same files dozens of times, and polls a few seconds apart
5
+ * share one read.
6
+ *
7
+ * Off unless the web server enables it: a CLI command and the tests read
8
+ * fresh every time, as before. The server clears it on every mutating
9
+ * request, so a write made through the UI is seen by the very next read;
10
+ * an edit made outside the server (an editor, the CLI) is seen within the
11
+ * TTL. Values are cloned on the way out, so a caller that mutates its copy
12
+ * never changes what the next caller gets.
13
+ */
14
+
15
+ let ttlMs = 0;
16
+ const entries = new Map<string, { at: number; value: unknown }>();
17
+ /** cachedReadStale's values: kept across a clear, only marked stale. */
18
+ const stale = new Map<string, { at: number; value: unknown }>();
19
+ const refreshing = new Set<string>();
20
+
21
+ /** Turn the cache on (web server only). */
22
+ export function enableReadCache(ms: number): void {
23
+ ttlMs = ms;
24
+ }
25
+
26
+ /** Drop every cached read (after a mutation, or in tests). */
27
+ export function clearReadCache(): void {
28
+ entries.clear();
29
+ for (const e of stale.values()) e.at = 0;
30
+ }
31
+
32
+ function copy<T>(v: T): T {
33
+ return v !== null && typeof v === "object" ? structuredClone(v) : v;
34
+ }
35
+
36
+ /** `load()`'s value for `key`, reused for the TTL when the cache is on. */
37
+ export function cachedRead<T>(key: string, load: () => T): T {
38
+ if (ttlMs <= 0) return load();
39
+ const hit = entries.get(key);
40
+ if (hit && Date.now() - hit.at < ttlMs) return copy(hit.value as T);
41
+ const value = load();
42
+ entries.set(key, { at: Date.now(), value });
43
+ return copy(value);
44
+ }
45
+
46
+ /**
47
+ * Like cachedRead, but past the TTL it still answers with the last value at
48
+ * once and recomputes after the current request (setImmediate), so no
49
+ * request waits on the recompute. For expensive, slow-changing results
50
+ * (the derived dependency graph) whose staleness only affects ordering.
51
+ */
52
+ export function cachedReadStale<T>(
53
+ key: string,
54
+ maxAgeMs: number,
55
+ load: () => T,
56
+ ): T {
57
+ if (ttlMs <= 0) return load();
58
+ const hit = stale.get(key);
59
+ if (!hit) {
60
+ const value = load();
61
+ stale.set(key, { at: Date.now(), value });
62
+ return copy(value);
63
+ }
64
+ if (Date.now() - hit.at >= maxAgeMs && !refreshing.has(key)) {
65
+ refreshing.add(key);
66
+ setImmediate(() => {
67
+ try {
68
+ stale.set(key, { at: Date.now(), value: load() });
69
+ } catch {
70
+ /* keep the last good value */
71
+ } finally {
72
+ refreshing.delete(key);
73
+ }
74
+ });
75
+ }
76
+ return copy(hit.value as T);
77
+ }
@@ -3,13 +3,18 @@
3
3
  * The CLI prints it; the web server serves it at /api/stacks (and through it
4
4
  * the MCP `stacks` tool).
5
5
  */
6
+ import { spawnSync } from "child_process";
7
+ import * as fs from "fs";
8
+ import * as path from "path";
9
+ import { readCatalog } from "../environment-manager/catalog.js";
6
10
  import { readEdgeConfig } from "../edge/config.js";
7
11
  import { routeUrl } from "../edge/render.js";
8
12
  import {
9
13
  getFocusedWorkspaceName,
10
14
  getWorkspaceByName,
15
+ listWorkspaceServices,
11
16
  } from "../environment-manager/workspaces.js";
12
- import { readStacks } from "./config.js";
17
+ import { readManifestStacks, readStacks } from "./config.js";
13
18
  import { projectName, stackWorkspace } from "./render.js";
14
19
  import { stackHealth, type StackContext } from "./runtime.js";
15
20
 
@@ -97,3 +102,106 @@ export function stackStatuses(
97
102
  return status;
98
103
  });
99
104
  }
105
+
106
+ /** A running Compose project started from one of a workspace's repos. */
107
+ export interface WorkspaceStack {
108
+ /** The Den stack name, or the project name for a stack the repo runs. */
109
+ name: string;
110
+ repo: string;
111
+ project: string;
112
+ }
113
+
114
+ /** A running Compose project and the directory it was started from. */
115
+ export interface RunningProject {
116
+ project: string;
117
+ /** `com.docker.compose.project.working_dir`; "" when unlabelled. */
118
+ workingDir: string;
119
+ }
120
+
121
+ /**
122
+ * The Compose projects with running containers, from one `docker ps`; null
123
+ * when docker can't be asked (not installed, daemon down, too slow).
124
+ */
125
+ export function runningComposeProjects(): RunningProject[] | null {
126
+ const r = spawnSync(
127
+ "docker",
128
+ [
129
+ "ps",
130
+ "--format",
131
+ '{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.project.working_dir"}}',
132
+ ],
133
+ { encoding: "utf-8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] },
134
+ );
135
+ if (r.error || r.status !== 0) return null;
136
+ const seen = new Map<string, RunningProject>();
137
+ for (const line of (r.stdout ?? "").split("\n")) {
138
+ const [project = "", workingDir = ""] = line.trim().split("\t");
139
+ if (project && !seen.has(project))
140
+ seen.set(project, { project, workingDir: workingDir.trim() });
141
+ }
142
+ return [...seen.values()];
143
+ }
144
+
145
+ /** `p` and its realpath (compose may record either form). */
146
+ function pathForms(p: string): string[] {
147
+ try {
148
+ const real = fs.realpathSync(p);
149
+ return real === p ? [p] : [p, real];
150
+ } catch {
151
+ return [p];
152
+ }
153
+ }
154
+
155
+ /**
156
+ * `workspace`'s running compose stacks: what `workspace deactivate` leaves
157
+ * up, since it releases only the daemons Den tracks. A project counts when
158
+ * it is a Den stack one of the workspace's repos declares (`compose:`, named
159
+ * by projectName), or when Compose started it from inside one of the
160
+ * workspace's checkouts (its working_dir label): the stacks a repo runs
161
+ * itself, e.g. from `make up`. Null when docker could not be asked.
162
+ */
163
+ export function runningWorkspaceStacks(
164
+ workspace: string,
165
+ running: () => RunningProject[] | null = runningComposeProjects,
166
+ ): WorkspaceStack[] | null {
167
+ const ws = getWorkspaceByName(workspace);
168
+ if (!ws) return [];
169
+ const denStacks = new Map<string, { name: string; repo: string }>();
170
+ const checkouts: { repo: string; forms: string[] }[] = [];
171
+ try {
172
+ const { stackWorkspace: suffix } = resolveStackWorkspaces(workspace);
173
+ for (const s of Object.values(readManifestStacks(workspace)))
174
+ denStacks.set(projectName(s.name, suffix), {
175
+ name: s.name,
176
+ repo: s.repo,
177
+ });
178
+ const seen = new Set<string>();
179
+ for (const svc of listWorkspaceServices(ws.config, readCatalog(), ws.name))
180
+ if (svc.repoPath && !seen.has(svc.repoPath)) {
181
+ seen.add(svc.repoPath);
182
+ checkouts.push({ repo: svc.repoName, forms: pathForms(svc.repoPath) });
183
+ }
184
+ } catch {
185
+ return null; // an unreadable manifest or workspace name: can't tell
186
+ }
187
+ if (denStacks.size === 0 && checkouts.length === 0) return [];
188
+ const up = running();
189
+ if (!up) return null;
190
+ const inside = (dir: string, root: string): boolean =>
191
+ dir === root || dir.startsWith(`${root}${path.sep}`);
192
+ const out: WorkspaceStack[] = [];
193
+ for (const { project, workingDir } of up) {
194
+ const den = denStacks.get(project);
195
+ if (den) {
196
+ out.push({ ...den, project });
197
+ continue;
198
+ }
199
+ if (!workingDir) continue;
200
+ const dirs = pathForms(workingDir);
201
+ const owner = checkouts.find((c) =>
202
+ c.forms.some((root) => dirs.some((d) => inside(d, root))),
203
+ );
204
+ if (owner) out.push({ name: project, repo: owner.repo, project });
205
+ }
206
+ return out;
207
+ }
@@ -5,6 +5,11 @@
5
5
  * Main entry point for the interactive CLI
6
6
  */
7
7
 
8
+ import { composeStacksNotice } from "./workspace-notes.js";
9
+ import {
10
+ runningComposeProjects,
11
+ runningWorkspaceStacks,
12
+ } from "../../shared/stacks/status.js";
8
13
  import { isNamedTarget } from "./named-target.js";
9
14
  import chalk from "chalk";
10
15
  import oraSpinner from "ora";
@@ -46,7 +51,9 @@ import {
46
51
  isWorkspaceActive,
47
52
  activateWorkspace,
48
53
  deactivateWorkspace,
54
+ getActiveWorkspaces,
49
55
  setActiveWorkspace,
56
+ setFocusedWorkspace,
50
57
  createWorkspace,
51
58
  deleteWorkspaceConfig,
52
59
  parseAddress,
@@ -195,6 +202,58 @@ const DEN_HELP_DESCRIPTION = ` ${product().title} - local-first test, service,
195
202
  * Concise `<command> -h` help for commands without a dedicated help renderer.
196
203
  * Flag-heavy commands (up, graph, use-local, use-managed) have richer handlers.
197
204
  */
205
+ /** `workspace` subcommands, for `workspace --help` and `workspace <sub> --help`. */
206
+ const WORKSPACE_SUBCOMMANDS: {
207
+ name: string;
208
+ usage: string;
209
+ lines: string[];
210
+ }[] = [
211
+ {
212
+ name: "list",
213
+ usage: "list",
214
+ lines: ["every workspace, the active set and the focus"],
215
+ },
216
+ {
217
+ name: "activate",
218
+ usage: "activate <n>",
219
+ lines: ["add a workspace to the active set"],
220
+ },
221
+ {
222
+ name: "deactivate",
223
+ usage: "deactivate <n>",
224
+ lines: [
225
+ "remove it from the active set and stop the services",
226
+ "only it owns. Only services {cli} tracks: compose",
227
+ "stacks keep running; stop them with `{cli} down <repo>`",
228
+ ],
229
+ },
230
+ {
231
+ name: "focus",
232
+ usage: "focus <n>",
233
+ lines: ["make an active workspace the default for commands"],
234
+ },
235
+ {
236
+ name: "use",
237
+ usage: "use <n>",
238
+ lines: ["activate and focus a workspace"],
239
+ },
240
+ {
241
+ name: "create",
242
+ usage: "create [n]",
243
+ lines: ["create an empty workspace"],
244
+ },
245
+ {
246
+ name: "delete",
247
+ usage: "delete <n>",
248
+ lines: ["delete a workspace (and its directory)"],
249
+ },
250
+ {
251
+ name: "code",
252
+ usage: "code [n]",
253
+ lines: ["open it in your editor (VS Code: a .code-workspace)"],
254
+ },
255
+ ];
256
+
198
257
  const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
199
258
  down: {
200
259
  usage: "down [repo] [flags]",
@@ -252,8 +311,16 @@ const COMMAND_HELP: Record<string, { usage: string; lines: string[] }> = {
252
311
  lines: ["Check managed repos for upstream updates and offer to pull."],
253
312
  },
254
313
  workspace: {
255
- usage: "workspace <list|use|create|delete|code> [name]",
256
- lines: ["Manage workspaces (named sets of services + their modes)."],
314
+ usage: `workspace <${WORKSPACE_SUBCOMMANDS.map((c) => c.name).join("|")}> [name]`,
315
+ lines: [
316
+ "Manage workspaces (named sets of services + their modes).",
317
+ "",
318
+ ...WORKSPACE_SUBCOMMANDS.flatMap((c) =>
319
+ c.lines.map(
320
+ (line, i) => ` ${(i === 0 ? c.usage : "").padEnd(16)} ${line}`,
321
+ ),
322
+ ),
323
+ ],
257
324
  },
258
325
  run: {
259
326
  usage: "run [options]",
@@ -583,7 +650,7 @@ class DenCLI {
583
650
  !["-h", "--help", "-v", "--version"].includes(command) &&
584
651
  (ownArgs.includes("-h") || ownArgs.includes("--help"))
585
652
  ) {
586
- this.printCommandHelp(command);
653
+ this.printCommandHelp(command, ownArgs[1]);
587
654
  return;
588
655
  }
589
656
 
@@ -2375,7 +2442,19 @@ class DenCLI {
2375
2442
  }
2376
2443
 
2377
2444
  /** Help for `<command> -h`: dedicated renderer for flag-heavy commands, else the table. */
2378
- private printCommandHelp(command: string): void {
2445
+ private printCommandHelp(command: string, sub?: string): void {
2446
+ // `workspace <sub> --help`: that subcommand only.
2447
+ const wsSub =
2448
+ command === "workspace"
2449
+ ? WORKSPACE_SUBCOMMANDS.find((c) => c.name === sub)
2450
+ : undefined;
2451
+ if (wsSub) {
2452
+ console.log(`\n ${product().cli} workspace ${wsSub.usage}\n`);
2453
+ for (const line of wsSub.lines)
2454
+ console.log(` ${line.replaceAll("{cli}", product().cli)}`);
2455
+ console.log();
2456
+ return;
2457
+ }
2379
2458
  switch (command) {
2380
2459
  case "up":
2381
2460
  return this.printUpHelp();
@@ -2398,7 +2477,8 @@ class DenCLI {
2398
2477
  return;
2399
2478
  }
2400
2479
  console.log(`\n ${product().cli} ${entry.usage}\n`);
2401
- for (const line of entry.lines) console.log(` ${line}`);
2480
+ for (const line of entry.lines)
2481
+ console.log(` ${line.replaceAll("{cli}", product().cli)}`.trimEnd());
2402
2482
  console.log();
2403
2483
  }
2404
2484
 
@@ -3501,9 +3581,36 @@ class DenCLI {
3501
3581
  }
3502
3582
  }
3503
3583
  }
3584
+ // Deactivate releases tracked daemons only; say what it left up.
3585
+ // One docker ps for this workspace and every other active one.
3586
+ let up: ReturnType<typeof runningComposeProjects> | undefined;
3587
+ const running = () =>
3588
+ up === undefined ? (up = runningComposeProjects()) : up;
3589
+ const stacks = runningWorkspaceStacks(name, running);
3590
+ const heldBy = new Map<string, string[]>();
3591
+ if (stacks?.length)
3592
+ for (const other of getActiveWorkspaces())
3593
+ for (const s of runningWorkspaceStacks(other, running) ?? [])
3594
+ heldBy.set(s.project, [...(heldBy.get(s.project) ?? []), other]);
3595
+ const stacksNote = composeStacksNotice(product().cli, stacks, heldBy);
3596
+ if (stacksNote) console.log(chalk.yellow(` ${stacksNote}`));
3504
3597
  console.log("");
3505
3598
  break;
3506
3599
  }
3600
+ case "focus": {
3601
+ const name = args[1];
3602
+ if (!name) {
3603
+ console.log(
3604
+ chalk.red(`Usage: ${product().cli} workspace focus <name>`),
3605
+ );
3606
+ return;
3607
+ }
3608
+ setFocusedWorkspace(name);
3609
+ console.log(
3610
+ chalk.green(`\n ✓ Focused workspace: ${chalk.bold(name)}\n`),
3611
+ );
3612
+ break;
3613
+ }
3507
3614
  case "use": {
3508
3615
  const name = args[1];
3509
3616
  if (!name) {
@@ -3611,7 +3718,7 @@ class DenCLI {
3611
3718
  default:
3612
3719
  console.log(
3613
3720
  chalk.gray(
3614
- `Usage: ${product().cli} workspace <list|activate|deactivate|use|create|delete|code> [name]`,
3721
+ `Usage: ${product().cli} workspace <list|activate|deactivate|focus|use|create|delete|code> [name]`,
3615
3722
  ),
3616
3723
  );
3617
3724
  }
@@ -3871,7 +3978,6 @@ class DenCLI {
3871
3978
  " service install Run the web UI as a launchd service (status, print, uninstall)",
3872
3979
  );
3873
3980
  console.log();
3874
- console.log(" workspace list List workspaces");
3875
3981
  console.log(
3876
3982
  " workspace list List workspaces (active set + focus)",
3877
3983
  );
@@ -3879,11 +3985,14 @@ class DenCLI {
3879
3985
  console.log(
3880
3986
  " workspace deactivate <n> Remove workspace from the active set",
3881
3987
  );
3988
+ console.log(
3989
+ " workspace focus <n> Focus an active workspace (the default)",
3990
+ );
3882
3991
  console.log(" workspace use <n> Activate and focus a workspace");
3883
- console.log(" workspace create [n] Create a new workspace");
3884
- console.log(" workspace delete <n> Delete a workspace");
3992
+ console.log(" workspace create [n] Create a new workspace");
3993
+ console.log(" workspace delete <n> Delete a workspace");
3885
3994
  console.log(
3886
- " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
3995
+ " workspace code [n] Open workspace in VS Code (generates .code-workspace)",
3887
3996
  );
3888
3997
  console.log("\nTest commands:");
3889
3998
  console.log(
@@ -25,7 +25,7 @@ import { product } from "../../shared/product.js";
25
25
  import { getDistRoot } from "../../shared/paths.js";
26
26
  import { readWebPort } from "../../shared/edge/config.js";
27
27
  import { DEFAULT_PORT } from "../../shared/constants.js";
28
- import { readMcpConfig } from "../../shared/den-config.js";
28
+ import { readDenConfig, readMcpConfig } from "../../shared/den-config.js";
29
29
 
30
30
  export const MCP_CONNECT_SUBCOMMANDS = [
31
31
  "connect",
@@ -199,6 +199,78 @@ function status(
199
199
  : chalk.gray("not connected");
200
200
  }
201
201
 
202
+ /** How long `mcp connect` waits for /api/server-info before giving up. */
203
+ export const WEB_PROBE_TIMEOUT_MS = 1500;
204
+
205
+ /** What a probe of the web server's /api/server-info found. */
206
+ export type WebProbe =
207
+ | { kind: "up"; version?: string }
208
+ /** Something accepted the connection but sent no answer in time. */
209
+ | { kind: "timeout" }
210
+ /** Nothing answered: connection refused, or not the web server. */
211
+ | { kind: "down" };
212
+
213
+ export async function probeWebServer(
214
+ url: string,
215
+ timeoutMs: number = WEB_PROBE_TIMEOUT_MS,
216
+ fetchImpl: typeof fetch = fetch,
217
+ ): Promise<WebProbe> {
218
+ try {
219
+ const r = await fetchImpl(`${url}/api/server-info`, {
220
+ signal: AbortSignal.timeout(timeoutMs),
221
+ });
222
+ const info = (await r.json()) as { version?: string };
223
+ return { kind: "up", version: info.version };
224
+ } catch (e) {
225
+ return (e as Error).name === "TimeoutError"
226
+ ? { kind: "timeout" }
227
+ : { kind: "down" };
228
+ }
229
+ }
230
+
231
+ /** Whether the org config runs the web UI as a service (`web.service`). */
232
+ function webRunsAsService(): boolean {
233
+ try {
234
+ return readDenConfig().web?.service !== undefined;
235
+ } catch {
236
+ return false;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * The status line for a probe. A server that is up but slow is "not
242
+ * answering", never "not running"; and when the web UI runs as a service
243
+ * (`web.service`), the advice is the service's status, not `web up`.
244
+ */
245
+ export function webServerMessage(
246
+ probe: WebProbe,
247
+ url: string,
248
+ opts: { cli: string; asService: boolean; timeoutMs?: number },
249
+ ): string {
250
+ if (probe.kind === "up")
251
+ return (
252
+ chalk.green(`web server up at ${url}`) +
253
+ (probe.version ? chalk.gray(` (Den ${probe.version})`) : "")
254
+ );
255
+ const svc = `\`${opts.cli} service status\``;
256
+ if (probe.kind === "timeout") {
257
+ const secs = (opts.timeoutMs ?? WEB_PROBE_TIMEOUT_MS) / 1000;
258
+ return chalk.yellow(
259
+ `web server at ${url} is not answering within ${secs}s (busy or stuck): ` +
260
+ `agents' reads wait on it too; if it stays slow, ` +
261
+ (opts.asService
262
+ ? `check ${svc}`
263
+ : `restart it with \`${opts.cli} web restart\``),
264
+ );
265
+ }
266
+ return chalk.yellow(
267
+ `web server not running at ${url}: agents get read errors until ` +
268
+ (opts.asService
269
+ ? `the service is up (check ${svc})`
270
+ : `\`${opts.cli} web up\``),
271
+ );
272
+ }
273
+
202
274
  async function webServerLine(port?: number): Promise<string> {
203
275
  let p = port;
204
276
  if (p === undefined) {
@@ -209,20 +281,10 @@ async function webServerLine(port?: number): Promise<string> {
209
281
  }
210
282
  }
211
283
  const url = `http://localhost:${p}`;
212
- try {
213
- const r = await fetch(`${url}/api/server-info`, {
214
- signal: AbortSignal.timeout(1500),
215
- });
216
- const info = (await r.json()) as { version?: string };
217
- return (
218
- chalk.green(`web server up at ${url}`) +
219
- (info.version ? chalk.gray(` (Den ${info.version})`) : "")
220
- );
221
- } catch {
222
- return chalk.yellow(
223
- `web server not running at ${url}: agents get read errors until \`${product().cli} web up\``,
224
- );
225
- }
284
+ return webServerMessage(await probeWebServer(url), url, {
285
+ cli: product().cli,
286
+ asService: webRunsAsService(),
287
+ });
226
288
  }
227
289
 
228
290
  function usage(): string {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * What `<cli> workspace deactivate` says about what it leaves running: it
3
+ * releases only the daemons Den tracks, so a compose stack started from one
4
+ * of the workspace's repos keeps running until `<cli> down <repo>`. A stack
5
+ * another active workspace still holds is named as in use, never with a
6
+ * command that would stop it.
7
+ */
8
+ import type { WorkspaceStack } from "../../shared/stacks/status.js";
9
+
10
+ /**
11
+ * The note for `stacks`: the running ones (null when docker could not be
12
+ * asked, so it is said generically), or nothing when none is running.
13
+ * `heldBy` maps a Compose project to the other active workspaces that hold
14
+ * it.
15
+ */
16
+ export function composeStacksNotice(
17
+ cli: string,
18
+ stacks: WorkspaceStack[] | null,
19
+ heldBy: ReadonlyMap<string, string[]> = new Map(),
20
+ ): string | null {
21
+ if (stacks === null)
22
+ return `compose stacks keep running: stop them with \`${cli} down <repo>\``;
23
+ if (stacks.length === 0) return null;
24
+ const held = stacks.filter((s) => heldBy.get(s.project)?.length);
25
+ // `down <repo>` stops all of a repo's stacks: never suggest it for a repo
26
+ // with a stack still in use.
27
+ const heldRepos = new Set(held.map((s) => s.repo));
28
+ const free = stacks.filter((s) => !heldRepos.has(s.repo));
29
+ const inUse = held.map(
30
+ (s) => `${s.project} (still in ${heldBy.get(s.project)!.join(", ")})`,
31
+ );
32
+ const parts: string[] = [];
33
+ if (free.length) {
34
+ const repos = [...new Set(free.map((s) => s.repo))];
35
+ parts.push(
36
+ `compose stacks keep running (${free.map((s) => s.project).join(", ")}): ` +
37
+ `stop them with ${repos.map((r) => `\`${cli} down ${r}\``).join(", ")}`,
38
+ );
39
+ }
40
+ if (inUse.length)
41
+ parts.push(`in use by another workspace: ${inUse.join(", ")}`);
42
+ return parts.join("; ");
43
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The dependency graph's top-down repo order (entry repo first, infra last),
3
+ * which the health row and /api/services rank by.
4
+ *
5
+ * Deriving the graph walks every repo's sources (fingerprints, scans), so it
6
+ * is far too slow to redo on each 5s poll. In the web server it is kept for
7
+ * 30s and then recomputed after the request that noticed, never inside it
8
+ * (read-cache.ts); a stale order only means a briefly stale sort.
9
+ */
10
+ import { deriveGraph } from "../../shared/environment-manager/derivation/derive-graph.js";
11
+ import { orderForDown } from "../../shared/environment-manager/derivation/derived-up.js";
12
+ import { cachedReadStale } from "../../shared/read-cache.js";
13
+
14
+ const GRAPH_ORDER_MAX_AGE_MS = 30_000;
15
+
16
+ export function graphOrder(workspace: string | undefined): string[] {
17
+ return cachedReadStale(
18
+ `graph-order:${workspace ?? ""}`,
19
+ GRAPH_ORDER_MAX_AGE_MS,
20
+ () => orderForDown(deriveGraph({ scope: "all", workspace })),
21
+ );
22
+ }