@monster-orange/den 0.0.0 → 0.1.0

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.
Files changed (231) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/LICENSE +21 -0
  3. package/README.md +165 -1
  4. package/bin/den +61 -0
  5. package/docs/distributions.md +256 -0
  6. package/make/demo.mk +82 -0
  7. package/make/den.mk +80 -0
  8. package/make/journeys.mk +57 -0
  9. package/make/local_dev.mk +154 -0
  10. package/make/security.mk +59 -0
  11. package/make/test.mk +405 -0
  12. package/package.json +99 -4
  13. package/scripts/auth-configure.ts +415 -0
  14. package/scripts/heal-config.ts +92 -0
  15. package/scripts/jsonl-playwright-reporter.js +70 -0
  16. package/scripts/manifest-read.cjs +91 -0
  17. package/scripts/setup-1password.ts +570 -0
  18. package/scripts/setup-secrets.ts +149 -0
  19. package/scripts/view-results.js +156 -0
  20. package/service/install.sh +112 -0
  21. package/src/pillars/api/fixtures/index.ts +128 -0
  22. package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
  23. package/src/pillars/api/global-setup.ts +103 -0
  24. package/src/pillars/api/playwright.config.ts +105 -0
  25. package/src/pillars/api/schemas/example-api.json +110 -0
  26. package/src/pillars/api/tests/health/health.spec.ts +19 -0
  27. package/src/pillars/api/tests/health/manifest.yml +6 -0
  28. package/src/pillars/browser/fixtures/index.ts +232 -0
  29. package/src/pillars/browser/fixtures/package.json +10 -0
  30. package/src/pillars/browser/global-setup.ts +140 -0
  31. package/src/pillars/browser/global-teardown.ts +48 -0
  32. package/src/pillars/browser/playwright.config.ts +212 -0
  33. package/src/pillars/browser/setup.ts +219 -0
  34. package/src/pillars/journeys/app-config.ts +237 -0
  35. package/src/pillars/journeys/discover.ts +160 -0
  36. package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
  37. package/src/pillars/journeys/observability/events.ts +288 -0
  38. package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
  39. package/src/pillars/journeys/report/friction.ts +304 -0
  40. package/src/pillars/journeys/report/junit.ts +105 -0
  41. package/src/pillars/journeys/report/render.ts +273 -0
  42. package/src/pillars/journeys/report/results.ts +323 -0
  43. package/src/pillars/journeys/run.ts +673 -0
  44. package/src/pillars/journeys/runner.ts +1025 -0
  45. package/src/pillars/journeys/sandbox.ts +406 -0
  46. package/src/pillars/journeys/spec/loader.ts +152 -0
  47. package/src/pillars/journeys/spec/schema.ts +208 -0
  48. package/src/pillars/journeys/teardown.ts +98 -0
  49. package/src/pillars/journeys/tools/browser-session.ts +219 -0
  50. package/src/pillars/journeys/tools/browser-tool.ts +914 -0
  51. package/src/pillars/journeys/tools/http-tool.ts +564 -0
  52. package/src/pillars/journeys/tools/ledger.ts +278 -0
  53. package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
  54. package/src/pillars/journeys/verify/assertions.ts +502 -0
  55. package/src/pillars/journeys/verify/verdict.ts +71 -0
  56. package/src/pillars/perf/build-report.ts +36 -0
  57. package/src/pillars/perf/k6.config.js +112 -0
  58. package/src/pillars/perf/run.ts +345 -0
  59. package/src/pillars/perf/scenarios/load-profile.js +97 -0
  60. package/src/pillars/perf/staging.ts +73 -0
  61. package/src/pillars/security/discover.ts +108 -0
  62. package/src/pillars/security/findings.ts +151 -0
  63. package/src/pillars/security/plan.ts +355 -0
  64. package/src/pillars/security/run.ts +729 -0
  65. package/src/pillars/smoke/admin-probe.ts +68 -0
  66. package/src/pillars/smoke/base-url.ts +56 -0
  67. package/src/pillars/smoke/cleanup.ts +8 -0
  68. package/src/pillars/smoke/discover.ts +237 -0
  69. package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
  70. package/src/pillars/smoke/fixture-manager.ts +217 -0
  71. package/src/pillars/smoke/fixtures.ts +468 -0
  72. package/src/pillars/smoke/global-setup-all.ts +262 -0
  73. package/src/pillars/smoke/global-setup-global.ts +262 -0
  74. package/src/pillars/smoke/global-setup.ts +273 -0
  75. package/src/pillars/smoke/global-teardown-all.ts +8 -0
  76. package/src/pillars/smoke/global-teardown-global.ts +8 -0
  77. package/src/pillars/smoke/global-teardown.ts +139 -0
  78. package/src/pillars/smoke/playwright-all.config.ts +147 -0
  79. package/src/pillars/smoke/playwright-global.config.ts +141 -0
  80. package/src/pillars/smoke/playwright.config.ts +162 -0
  81. package/src/pillars/smoke/setup-env.ts +132 -0
  82. package/src/pillars/smoke/setup-global.ts +126 -0
  83. package/src/pillars/smoke/setup.ts +115 -0
  84. package/src/pillars/smoke/smoke-repo.ts +16 -0
  85. package/src/pillars/smoke/staging.ts +49 -0
  86. package/src/pillars/suites/run.ts +147 -0
  87. package/src/scripts/configure-aws-profiles.ts +114 -0
  88. package/src/scripts/register-repos.ts +307 -0
  89. package/src/scripts/setup-default-workspace.ts +203 -0
  90. package/src/shared/applications.ts +138 -0
  91. package/src/shared/auth/api-token.ts +416 -0
  92. package/src/shared/auth/auth0-helper.ts +284 -0
  93. package/src/shared/auth/headless-login.ts +265 -0
  94. package/src/shared/auth/jwt-helper.ts +186 -0
  95. package/src/shared/auth/sudo-vault.ts +69 -0
  96. package/src/shared/auth/test-accounts.ts +98 -0
  97. package/src/shared/auth/token-injection.ts +184 -0
  98. package/src/shared/auth/token-providers/auth0.ts +80 -0
  99. package/src/shared/auth/token-providers/index.ts +182 -0
  100. package/src/shared/auth/token-refresh.ts +273 -0
  101. package/src/shared/banner.ts +15 -0
  102. package/src/shared/brand.ts +79 -0
  103. package/src/shared/cli-tabs.ts +131 -0
  104. package/src/shared/constants.ts +128 -0
  105. package/src/shared/den-config.ts +566 -0
  106. package/src/shared/dev-metrics/emit.ts +354 -0
  107. package/src/shared/edge/config.ts +229 -0
  108. package/src/shared/edge/render.ts +221 -0
  109. package/src/shared/edge/runtime.ts +268 -0
  110. package/src/shared/edge/types.ts +62 -0
  111. package/src/shared/engine.ts +67 -0
  112. package/src/shared/environment/aws-profiles.ts +49 -0
  113. package/src/shared/environment/env-selector.ts +161 -0
  114. package/src/shared/environment/playwright-env.ts +82 -0
  115. package/src/shared/environment-manager/catalog.ts +77 -0
  116. package/src/shared/environment-manager/clone-guard.ts +163 -0
  117. package/src/shared/environment-manager/dag.ts +286 -0
  118. package/src/shared/environment-manager/default-workspace.ts +182 -0
  119. package/src/shared/environment-manager/derivation/alias.ts +90 -0
  120. package/src/shared/environment-manager/derivation/cache.ts +97 -0
  121. package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
  122. package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
  123. package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
  124. package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
  125. package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
  126. package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
  127. package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
  128. package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
  129. package/src/shared/environment-manager/derivation/scan.ts +160 -0
  130. package/src/shared/environment-manager/derivation/text.ts +40 -0
  131. package/src/shared/environment-manager/derivation/types.ts +69 -0
  132. package/src/shared/environment-manager/health.ts +589 -0
  133. package/src/shared/environment-manager/index.ts +6 -0
  134. package/src/shared/environment-manager/load-applications.ts +78 -0
  135. package/src/shared/environment-manager/log-tail.ts +212 -0
  136. package/src/shared/environment-manager/make-targets.ts +78 -0
  137. package/src/shared/environment-manager/opctl.ts +334 -0
  138. package/src/shared/environment-manager/readiness.ts +93 -0
  139. package/src/shared/environment-manager/register-local.ts +72 -0
  140. package/src/shared/environment-manager/secrets-check.ts +260 -0
  141. package/src/shared/environment-manager/service-env.ts +110 -0
  142. package/src/shared/environment-manager/service-runner.ts +1588 -0
  143. package/src/shared/environment-manager/up-plan.ts +302 -0
  144. package/src/shared/environment-manager/workspace-manager.ts +905 -0
  145. package/src/shared/environment-manager/workspaces.ts +860 -0
  146. package/src/shared/manifest/deps.ts +27 -0
  147. package/src/shared/manifest/migrate-manifest.ts +101 -0
  148. package/src/shared/manifest/read-manifest.ts +160 -0
  149. package/src/shared/manifest/schema.ts +299 -0
  150. package/src/shared/manifest/service-links.ts +93 -0
  151. package/src/shared/manifest/validate-manifest.ts +140 -0
  152. package/src/shared/manifest/workspace-manifests.ts +92 -0
  153. package/src/shared/open-url.ts +122 -0
  154. package/src/shared/org.ts +44 -0
  155. package/src/shared/paths.ts +178 -0
  156. package/src/shared/perf/optional-fields.ts +105 -0
  157. package/src/shared/perf/report-normalizer.ts +404 -0
  158. package/src/shared/perf/report-paths.ts +19 -0
  159. package/src/shared/perf/report-store.ts +110 -0
  160. package/src/shared/perf/report-types.ts +70 -0
  161. package/src/shared/perf/test-paths.ts +25 -0
  162. package/src/shared/process/log-rotation.ts +147 -0
  163. package/src/shared/process/spawn-streaming.ts +229 -0
  164. package/src/shared/product.ts +92 -0
  165. package/src/shared/profile.ts +141 -0
  166. package/src/shared/repo-kind.ts +32 -0
  167. package/src/shared/reporting/jsonl-reporter.ts +131 -0
  168. package/src/shared/reporting/slack-reporter.ts +340 -0
  169. package/src/shared/results/result-reader.ts +266 -0
  170. package/src/shared/results/result-writer.ts +168 -0
  171. package/src/shared/secrets/doppler.ts +126 -0
  172. package/src/shared/secrets/env.ts +48 -0
  173. package/src/shared/secrets/index.ts +197 -0
  174. package/src/shared/secrets/keys.ts +33 -0
  175. package/src/shared/secrets/onepassword.ts +75 -0
  176. package/src/shared/secrets/types.ts +52 -0
  177. package/src/shared/service-url.ts +119 -0
  178. package/src/shared/smoke/branch-resolver.ts +141 -0
  179. package/src/shared/smoke/git-remote.ts +70 -0
  180. package/src/shared/smoke/repo-cloner.ts +151 -0
  181. package/src/shared/stacks/config.ts +137 -0
  182. package/src/shared/stacks/manifest-commands.ts +88 -0
  183. package/src/shared/stacks/render.ts +253 -0
  184. package/src/shared/stacks/runtime.ts +334 -0
  185. package/src/shared/stacks/types.ts +53 -0
  186. package/src/shared/terminal/tabs.ts +212 -0
  187. package/src/shared/test-discovery/module-scanner.ts +569 -0
  188. package/src/shared/types/index.ts +678 -0
  189. package/src/shared/ui-marker.ts +19 -0
  190. package/src/ui/cli/applications-command.ts +116 -0
  191. package/src/ui/cli/config-command.ts +68 -0
  192. package/src/ui/cli/create-main.ts +9 -0
  193. package/src/ui/cli/create.ts +360 -0
  194. package/src/ui/cli/edge-command.ts +91 -0
  195. package/src/ui/cli/flag-parser.ts +246 -0
  196. package/src/ui/cli/graph-command.ts +524 -0
  197. package/src/ui/cli/health-display.ts +63 -0
  198. package/src/ui/cli/index.ts +3656 -0
  199. package/src/ui/cli/init.ts +855 -0
  200. package/src/ui/cli/invocation-target.ts +175 -0
  201. package/src/ui/cli/logs-command.ts +104 -0
  202. package/src/ui/cli/module-selection.ts +31 -0
  203. package/src/ui/cli/prompts.ts +767 -0
  204. package/src/ui/cli/results-viewer.ts +436 -0
  205. package/src/ui/cli/secrets-preflight.ts +91 -0
  206. package/src/ui/cli/stack-command.ts +144 -0
  207. package/src/ui/cli/stalled-setup-recovery.ts +129 -0
  208. package/src/ui/cli/test-runner.ts +1312 -0
  209. package/src/ui/cli/web-command.ts +1073 -0
  210. package/src/ui/server/changelog-parser.ts +94 -0
  211. package/src/ui/server/daemon-reaper.ts +129 -0
  212. package/src/ui/server/editor-manager.ts +301 -0
  213. package/src/ui/server/github-service.ts +223 -0
  214. package/src/ui/server/http-helpers.ts +98 -0
  215. package/src/ui/server/index.ts +3236 -0
  216. package/src/ui/server/log-history.ts +452 -0
  217. package/src/ui/server/perf-report.ts +42 -0
  218. package/src/ui/server/public/environments.html +494 -0
  219. package/src/ui/server/public/favicon.ico +0 -0
  220. package/src/ui/server/public/header.js +1371 -0
  221. package/src/ui/server/public/index.html +2595 -0
  222. package/src/ui/server/public/local-dev.html +7405 -0
  223. package/src/ui/server/public/logs.html +1489 -0
  224. package/src/ui/server/public/perf-report.js +359 -0
  225. package/src/ui/server/public/release.html +2111 -0
  226. package/src/ui/server/public/services.html +1098 -0
  227. package/src/ui/server/public/workspace-picker.js +69 -0
  228. package/src/ui/server/release-deploy-session.ts +246 -0
  229. package/src/ui/server/release-service.ts +1125 -0
  230. package/src/ui/server/sudo-store.ts +111 -0
  231. package/src/ui/server/workspace-routes.ts +2154 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Workspace pill / picker models.
3
+ *
4
+ * Pure and DOM-free (like perf-report.js) so the shape of the header's
5
+ * workspace menu is unit-testable under Jest's node environment; header.js
6
+ * owns everything that touches the DOM.
7
+ *
8
+ * Input is always the `/api/workspaces` payload:
9
+ * { workspaces: { [name]: config }, active: string[] | string, focus?: string }
10
+ * `active` is an array since multi-active workspaces; older payloads sent a
11
+ * single string, which both helpers still accept.
12
+ */
13
+
14
+ /** Active workspace names, normalised to an array. */
15
+ function activeNames(data) {
16
+ const active = data?.active;
17
+ if (Array.isArray(active)) return active.filter(Boolean);
18
+ return active ? [active] : [];
19
+ }
20
+
21
+ /** Focused workspace name: explicit focus → first active → null. */
22
+ function focusName(data) {
23
+ const active = activeNames(data);
24
+ if (data?.focus) return data.focus;
25
+ return active[0] ?? null;
26
+ }
27
+
28
+ /**
29
+ * Label + tooltip for the header pill.
30
+ * Falls back to "default" so the pill never renders empty.
31
+ */
32
+ export function buildWorkspacePillModel(data) {
33
+ const active = activeNames(data);
34
+ const label = focusName(data) ?? "default";
35
+ return {
36
+ label,
37
+ // Only worth spelling out when more than one workspace is active —
38
+ // otherwise the pill text already says everything.
39
+ title:
40
+ active.length > 1
41
+ ? `Active: ${active.join(", ")} · Focus: ${label}`
42
+ : "Switch workspace",
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Menu model for the pill's dropdown: every known workspace, active ones
48
+ * first (they are the likely targets), each side alphabetical so the list
49
+ * order is stable across polls.
50
+ */
51
+ export function buildWorkspaceMenuModel(data) {
52
+ const active = new Set(activeNames(data));
53
+ const focus = focusName(data);
54
+ // Union of the catalog and the active set: a workspace listed as active but
55
+ // missing from `workspaces` still has to be selectable (and visible).
56
+ const names = new Set([...Object.keys(data?.workspaces ?? {}), ...active]);
57
+
58
+ const items = [...names].map((name) => ({
59
+ name,
60
+ active: active.has(name),
61
+ focused: name === focus,
62
+ }));
63
+ items.sort(
64
+ (a, b) =>
65
+ Number(b.active) - Number(a.active) || a.name.localeCompare(b.name),
66
+ );
67
+
68
+ return { focus, items };
69
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Deploy session manager for the Release tab.
3
+ *
4
+ * A deploy is a long-running, server-side thing (dispatch → gate → monitor) that
5
+ * MUST outlive any single browser page. Den's tabs are separate full-page
6
+ * navigations, so if the deploy were tied to the SSE request (as the first cut
7
+ * was), navigating away from Release would both lose the log AND abort den's
8
+ * monitoring of a real, already-dispatched deploy.
9
+ *
10
+ * This decouples them: `start()` runs the deploy in the background against an
11
+ * in-memory replay buffer; browser clients `attach()` to REPLAY the log so far
12
+ * and then stream live events. Re-attaching after a reload restores the full
13
+ * log. The deploy runs to completion regardless of who is (or isn't) attached;
14
+ * only an explicit `dismiss()` (the user closing the log) stops it.
15
+ *
16
+ * Sessions run CONCURRENTLY. Deploys of different systems are independent —
17
+ * they dispatch to different repos and GitHub scopes its own `concurrency:`
18
+ * group per repo-and-environment — so a global lock only stopped the operator
19
+ * from doing things that were never in conflict. The one real collision is
20
+ * dispatching the SAME repo to the SAME environment twice while the first is
21
+ * still in flight, which `conflicts()` rejects.
22
+ */
23
+
24
+ import {
25
+ releaseService,
26
+ type DeployPlanEntry,
27
+ type ReleaseEvent,
28
+ } from "./release-service.js";
29
+ import {
30
+ recordInvocation,
31
+ recordFailure,
32
+ } from "../../shared/dev-metrics/emit.js";
33
+
34
+ /** Public (serializable) view of a session — no subscribers/internal flags. */
35
+ export interface DeploySessionInfo {
36
+ id: string;
37
+ label: string;
38
+ env: string;
39
+ mode: string;
40
+ status: "running" | "done";
41
+ ok: boolean | null;
42
+ startedAt: number;
43
+ endedAt: number | null;
44
+ /** Repos this session deploys — lets the UI disable just their buttons. */
45
+ repos: string[];
46
+ }
47
+
48
+ type Subscriber = (ev: ReleaseEvent) => void;
49
+
50
+ interface Session extends DeploySessionInfo {
51
+ events: ReleaseEvent[];
52
+ subs: Set<Subscriber>;
53
+ dismissed: boolean;
54
+ }
55
+
56
+ // A deploy log is modest (dispatch/gate/monitor lines); cap the replay buffer so
57
+ // a pathologically long-running deploy can't grow it without bound.
58
+ const MAX_EVENTS = 5000;
59
+
60
+ // Finished sessions are retained so a reload can restore their log. Cap how
61
+ // many, so a long session of many deploys can't grow the map without bound.
62
+ const MAX_FINISHED_SESSIONS = 20;
63
+
64
+ function newId(): string {
65
+ return `dep_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
66
+ }
67
+
68
+ class DeploySessionManager {
69
+ private sessions = new Map<string, Session>();
70
+
71
+ private info(s: Session): DeploySessionInfo {
72
+ const { events: _e, subs: _s, dismissed: _d, ...info } = s;
73
+ void _e;
74
+ void _s;
75
+ void _d;
76
+ return info;
77
+ }
78
+
79
+ /** All restorable sessions (running or finished), newest first. */
80
+ active(): DeploySessionInfo[] {
81
+ return [...this.sessions.values()]
82
+ .filter((s) => !s.dismissed)
83
+ .sort((a, b) => b.startedAt - a.startedAt)
84
+ .map((s) => this.info(s));
85
+ }
86
+
87
+ /** True while ANY deploy is live. Kept for callers that just need a flag. */
88
+ busy(): boolean {
89
+ return [...this.sessions.values()].some(
90
+ (s) => !s.dismissed && s.status === "running",
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Repos in `plan` that already have a live deploy to `env` — dispatching the
96
+ * same repo+env twice would race two runs against one GitHub concurrency
97
+ * group. Different repos, or the same repo to a different env, do not
98
+ * conflict. Empty array means the deploy is safe to start.
99
+ */
100
+ conflicts(env: string, plan: DeployPlanEntry[]): string[] {
101
+ const live = new Set<string>();
102
+ for (const s of this.sessions.values()) {
103
+ if (s.dismissed || s.status !== "running" || s.env !== env) continue;
104
+ for (const r of s.repos) live.add(r);
105
+ }
106
+ return plan.map((p) => p.repo).filter((r) => live.has(r));
107
+ }
108
+
109
+ /**
110
+ * Start a deploy in the background. `notices` are emitted into the log before
111
+ * the deploy begins (e.g. the header line and a hotfix warning). Callers
112
+ * should check `conflicts()` first — this does not re-check.
113
+ */
114
+ start(opts: {
115
+ env: string;
116
+ mode: string;
117
+ label: string;
118
+ plan: DeployPlanEntry[];
119
+ notices?: ReleaseEvent[];
120
+ }): DeploySessionInfo {
121
+ const session: Session = {
122
+ id: newId(),
123
+ label: opts.label,
124
+ env: opts.env,
125
+ mode: opts.mode,
126
+ status: "running",
127
+ ok: null,
128
+ startedAt: Date.now(),
129
+ endedAt: null,
130
+ repos: opts.plan.map((p) => p.repo),
131
+ events: [],
132
+ subs: new Set(),
133
+ dismissed: false,
134
+ };
135
+ this.sessions.set(session.id, session);
136
+ this.prune();
137
+
138
+ const emit = (ev: ReleaseEvent): void => {
139
+ session.events.push(ev);
140
+ if (session.events.length > MAX_EVENTS) {
141
+ session.events.splice(0, session.events.length - MAX_EVENTS);
142
+ }
143
+ for (const sub of session.subs) {
144
+ try {
145
+ sub(ev);
146
+ } catch {
147
+ /* a dead subscriber must not break the fan-out */
148
+ }
149
+ }
150
+ };
151
+
152
+ for (const n of opts.notices ?? []) emit(n);
153
+
154
+ const startedAt = session.startedAt;
155
+ // Decoupled from any client. aborted() fires only when the user dismisses
156
+ // the log, giving an escape hatch for a stuck monitor; navigation does not.
157
+ void releaseService
158
+ .deploy(opts.env, opts.plan, emit, () => session.dismissed)
159
+ .then((ok) => {
160
+ session.ok = ok;
161
+ recordInvocation({
162
+ command: "release.deploy",
163
+ surface: "ux",
164
+ durationMs: Date.now() - startedAt,
165
+ exit: ok ? 0 : 1,
166
+ extra: { mode: opts.mode, env: opts.env, repos: opts.plan.length },
167
+ });
168
+ })
169
+ .catch((e) => {
170
+ session.ok = false;
171
+ recordFailure({
172
+ command: "release.deploy",
173
+ surface: "ux",
174
+ reason:
175
+ (e as { constructor?: { name?: string } })?.constructor?.name ??
176
+ "Error",
177
+ });
178
+ emit({
179
+ type: "log",
180
+ level: "error",
181
+ text: `Deploy error: ${(e as Error).message}`,
182
+ });
183
+ emit({ type: "done", ok: false, summary: "Deploy error." });
184
+ })
185
+ .finally(() => {
186
+ session.status = "done";
187
+ session.endedAt = Date.now();
188
+ // Dismissed mid-run: nothing can attach to it now, so drop it once the
189
+ // deploy loop has actually exited.
190
+ if (session.dismissed) this.sessions.delete(session.id);
191
+ });
192
+
193
+ return this.info(session);
194
+ }
195
+
196
+ /**
197
+ * Bound the map. Finished sessions are kept so a reload can still restore the
198
+ * log, but not forever — drop the oldest finished ones past the cap. Running
199
+ * sessions are never pruned.
200
+ */
201
+ private prune(): void {
202
+ const finished = [...this.sessions.values()]
203
+ .filter((s) => s.status === "done")
204
+ .sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0));
205
+ const excess = finished.length - MAX_FINISHED_SESSIONS;
206
+ for (let i = 0; i < excess; i++) this.sessions.delete(finished[i].id);
207
+ }
208
+
209
+ /**
210
+ * Attach a subscriber to session `id`: replays every buffered event (the full
211
+ * log, terminal `done` included when finished) then streams live ones.
212
+ * Returns an unsubscribe fn, or null if the session is gone/dismissed.
213
+ */
214
+ attach(id: string, sub: Subscriber): (() => void) | null {
215
+ const s = this.sessions.get(id);
216
+ if (!s || s.dismissed) return null;
217
+ for (const ev of s.events) {
218
+ try {
219
+ sub(ev);
220
+ } catch {
221
+ /* ignore */
222
+ }
223
+ }
224
+ s.subs.add(sub);
225
+ return () => {
226
+ s.subs.delete(sub);
227
+ };
228
+ }
229
+
230
+ /**
231
+ * Dismiss (close) session `id`: signals the deploy to stop monitoring and
232
+ * removes it from restore. A finished session's slot is freed immediately; a
233
+ * running one frees once its loop exits.
234
+ */
235
+ dismiss(id: string): boolean {
236
+ const s = this.sessions.get(id);
237
+ if (!s) return false;
238
+ s.dismissed = true;
239
+ // A finished session can go now; a running one is dropped by its own
240
+ // .finally() once the deploy loop notices the dismissal and exits.
241
+ if (s.status === "done") this.sessions.delete(id);
242
+ return true;
243
+ }
244
+ }
245
+
246
+ export const deploySessions = new DeploySessionManager();