@vtechcom/hexcore 2.0.0-beta.1

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 (173) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/LICENSE +201 -0
  3. package/README.md +283 -0
  4. package/dist/app.module.js +55 -0
  5. package/dist/app.module.js.map +1 -0
  6. package/dist/auth.js +151 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/chain/cost-models.js +81 -0
  9. package/dist/chain/cost-models.js.map +1 -0
  10. package/dist/chain/provider.js +269 -0
  11. package/dist/chain/provider.js.map +1 -0
  12. package/dist/chain/socket.js +48 -0
  13. package/dist/chain/socket.js.map +1 -0
  14. package/dist/cli/doctor.js +43 -0
  15. package/dist/cli/doctor.js.map +1 -0
  16. package/dist/cli/hexcore.js +435 -0
  17. package/dist/cli/hexcore.js.map +1 -0
  18. package/dist/common/auth.guard.js +66 -0
  19. package/dist/common/auth.guard.js.map +1 -0
  20. package/dist/common/http.js +171 -0
  21. package/dist/common/http.js.map +1 -0
  22. package/dist/common/zod-validation.pipe.js +47 -0
  23. package/dist/common/zod-validation.pipe.js.map +1 -0
  24. package/dist/config.js +354 -0
  25. package/dist/config.js.map +1 -0
  26. package/dist/config.module.js +35 -0
  27. package/dist/config.module.js.map +1 -0
  28. package/dist/controllers/cardano.controller.js +173 -0
  29. package/dist/controllers/cardano.controller.js.map +1 -0
  30. package/dist/controllers/heads.controller.js +225 -0
  31. package/dist/controllers/heads.controller.js.map +1 -0
  32. package/dist/controllers/setup.controller.js +127 -0
  33. package/dist/controllers/setup.controller.js.map +1 -0
  34. package/dist/controllers/system.controller.js +153 -0
  35. package/dist/controllers/system.controller.js.map +1 -0
  36. package/dist/db/database.module.js +73 -0
  37. package/dist/db/database.module.js.map +1 -0
  38. package/dist/db/index.js +135 -0
  39. package/dist/db/index.js.map +1 -0
  40. package/dist/db/repositories.js +220 -0
  41. package/dist/db/repositories.js.map +1 -0
  42. package/dist/docker/container-stats.js +134 -0
  43. package/dist/docker/container-stats.js.map +1 -0
  44. package/dist/docker/docker.service.js +335 -0
  45. package/dist/docker/docker.service.js.map +1 -0
  46. package/dist/hydra/argv.js +172 -0
  47. package/dist/hydra/argv.js.map +1 -0
  48. package/dist/hydra/blockfrost-keys.js +15 -0
  49. package/dist/hydra/blockfrost-keys.js.map +1 -0
  50. package/dist/hydra/boot-progress.js +165 -0
  51. package/dist/hydra/boot-progress.js.map +1 -0
  52. package/dist/hydra/capabilities.js +131 -0
  53. package/dist/hydra/capabilities.js.map +1 -0
  54. package/dist/hydra/head.service.js +1072 -0
  55. package/dist/hydra/head.service.js.map +1 -0
  56. package/dist/hydra/keys.js +301 -0
  57. package/dist/hydra/keys.js.map +1 -0
  58. package/dist/hydra/node-monitor.service.js +265 -0
  59. package/dist/hydra/node-monitor.service.js.map +1 -0
  60. package/dist/hydra/ws-client.js +177 -0
  61. package/dist/hydra/ws-client.js.map +1 -0
  62. package/dist/main.js +106 -0
  63. package/dist/main.js.map +1 -0
  64. package/dist/preflight.js +215 -0
  65. package/dist/preflight.js.map +1 -0
  66. package/dist/settings.js +243 -0
  67. package/dist/settings.js.map +1 -0
  68. package/dist/setup.service.js +257 -0
  69. package/dist/setup.service.js.map +1 -0
  70. package/dist/version.js +46 -0
  71. package/dist/version.js.map +1 -0
  72. package/node_modules/@hexcore/shared/dist/cjs/api.d.ts +730 -0
  73. package/node_modules/@hexcore/shared/dist/cjs/api.d.ts.map +1 -0
  74. package/node_modules/@hexcore/shared/dist/cjs/api.js +358 -0
  75. package/node_modules/@hexcore/shared/dist/cjs/api.js.map +1 -0
  76. package/node_modules/@hexcore/shared/dist/cjs/domain.d.ts +298 -0
  77. package/node_modules/@hexcore/shared/dist/cjs/domain.d.ts.map +1 -0
  78. package/node_modules/@hexcore/shared/dist/cjs/domain.js +49 -0
  79. package/node_modules/@hexcore/shared/dist/cjs/domain.js.map +1 -0
  80. package/node_modules/@hexcore/shared/dist/cjs/hydra-versions.d.ts +279 -0
  81. package/node_modules/@hexcore/shared/dist/cjs/hydra-versions.d.ts.map +1 -0
  82. package/node_modules/@hexcore/shared/dist/cjs/hydra-versions.js +453 -0
  83. package/node_modules/@hexcore/shared/dist/cjs/hydra-versions.js.map +1 -0
  84. package/node_modules/@hexcore/shared/dist/cjs/index.d.ts +7 -0
  85. package/node_modules/@hexcore/shared/dist/cjs/index.d.ts.map +1 -0
  86. package/node_modules/@hexcore/shared/dist/cjs/index.js +23 -0
  87. package/node_modules/@hexcore/shared/dist/cjs/index.js.map +1 -0
  88. package/node_modules/@hexcore/shared/dist/cjs/naming.d.ts +118 -0
  89. package/node_modules/@hexcore/shared/dist/cjs/naming.d.ts.map +1 -0
  90. package/node_modules/@hexcore/shared/dist/cjs/naming.js +103 -0
  91. package/node_modules/@hexcore/shared/dist/cjs/naming.js.map +1 -0
  92. package/node_modules/@hexcore/shared/dist/cjs/offline.d.ts +73 -0
  93. package/node_modules/@hexcore/shared/dist/cjs/offline.d.ts.map +1 -0
  94. package/node_modules/@hexcore/shared/dist/cjs/offline.js +80 -0
  95. package/node_modules/@hexcore/shared/dist/cjs/offline.js.map +1 -0
  96. package/node_modules/@hexcore/shared/dist/cjs/protocol-params.d.ts +79 -0
  97. package/node_modules/@hexcore/shared/dist/cjs/protocol-params.d.ts.map +1 -0
  98. package/node_modules/@hexcore/shared/dist/cjs/protocol-params.js +223 -0
  99. package/node_modules/@hexcore/shared/dist/cjs/protocol-params.js.map +1 -0
  100. package/node_modules/@hexcore/shared/dist/esm/api.js +355 -0
  101. package/node_modules/@hexcore/shared/dist/esm/api.js.map +1 -0
  102. package/node_modules/@hexcore/shared/dist/esm/domain.js +46 -0
  103. package/node_modules/@hexcore/shared/dist/esm/domain.js.map +1 -0
  104. package/node_modules/@hexcore/shared/dist/esm/hydra-versions.js +441 -0
  105. package/node_modules/@hexcore/shared/dist/esm/hydra-versions.js.map +1 -0
  106. package/node_modules/@hexcore/shared/dist/esm/index.js +7 -0
  107. package/node_modules/@hexcore/shared/dist/esm/index.js.map +1 -0
  108. package/node_modules/@hexcore/shared/dist/esm/naming.js +95 -0
  109. package/node_modules/@hexcore/shared/dist/esm/naming.js.map +1 -0
  110. package/node_modules/@hexcore/shared/dist/esm/offline.js +75 -0
  111. package/node_modules/@hexcore/shared/dist/esm/offline.js.map +1 -0
  112. package/node_modules/@hexcore/shared/dist/esm/package.json +3 -0
  113. package/node_modules/@hexcore/shared/dist/esm/protocol-params.js +217 -0
  114. package/node_modules/@hexcore/shared/dist/esm/protocol-params.js.map +1 -0
  115. package/node_modules/@hexcore/shared/package.json +15 -0
  116. package/package.json +67 -0
  117. package/web/200.html +23 -0
  118. package/web/404.html +23 -0
  119. package/web/_nuxt/2MxohCD-.js +1 -0
  120. package/web/_nuxt/4pFXjB_u.js +1 -0
  121. package/web/_nuxt/B-v0dEQX.js +1 -0
  122. package/web/_nuxt/B4yE3U25.js +1 -0
  123. package/web/_nuxt/BFh8Ai0L.js +1 -0
  124. package/web/_nuxt/BgPn4NJH.js +1 -0
  125. package/web/_nuxt/Bj1xviSh.js +1 -0
  126. package/web/_nuxt/BnotlrL7.js +1 -0
  127. package/web/_nuxt/C1YRg2ic.js +1 -0
  128. package/web/_nuxt/CGD7MYQH.js +1 -0
  129. package/web/_nuxt/CQcG7Bu4.js +1 -0
  130. package/web/_nuxt/CS3KqpH_.js +1 -0
  131. package/web/_nuxt/CT-yt694.js +1 -0
  132. package/web/_nuxt/CZK6xUH-.js +1 -0
  133. package/web/_nuxt/CcdyYRYz.js +1 -0
  134. package/web/_nuxt/CePNTU1S.js +1 -0
  135. package/web/_nuxt/CgPLsCeU.js +5 -0
  136. package/web/_nuxt/CkCrahXt.js +1 -0
  137. package/web/_nuxt/CnOqDT9k.js +3 -0
  138. package/web/_nuxt/CqsSss-D.js +1 -0
  139. package/web/_nuxt/Cr8dL64Q.js +1 -0
  140. package/web/_nuxt/D3CwzwU0.js +1 -0
  141. package/web/_nuxt/DDANn6-H.js +1 -0
  142. package/web/_nuxt/DDl9uKIA.js +1 -0
  143. package/web/_nuxt/DL9i0ayn.js +1 -0
  144. package/web/_nuxt/DLNoK9BC.js +1 -0
  145. package/web/_nuxt/DLztFeOQ.js +1 -0
  146. package/web/_nuxt/DPihKXdt.js +1 -0
  147. package/web/_nuxt/DXyr6Naa.js +1 -0
  148. package/web/_nuxt/DeDg5p0D.js +1 -0
  149. package/web/_nuxt/DgDlQiDU.js +1 -0
  150. package/web/_nuxt/DyPI7qJE.js +30 -0
  151. package/web/_nuxt/LGfPBi70.js +1 -0
  152. package/web/_nuxt/Onf26W-r.js +1 -0
  153. package/web/_nuxt/QZNQe_OX.js +1 -0
  154. package/web/_nuxt/Y8IJM-iz.js +1 -0
  155. package/web/_nuxt/YUGC0nt0.js +1 -0
  156. package/web/_nuxt/builds/latest.json +1 -0
  157. package/web/_nuxt/builds/meta/7e461629-93e2-4694-b237-6d5c6a85c97c.json +1 -0
  158. package/web/_nuxt/builds/meta/dev.json +1 -0
  159. package/web/_nuxt/entry.BxsAxZxf.css +1 -0
  160. package/web/_nuxt/error-404.XbtAw-qw.css +1 -0
  161. package/web/_nuxt/error-500.CNHVltWM.css +1 -0
  162. package/web/_nuxt/rS90pPZ_.js +1 -0
  163. package/web/_nuxt/rhEiP908.js +1 -0
  164. package/web/_nuxt/vSj9Ec0g.js +1 -0
  165. package/web/_nuxt/woNjBJMN.js +1 -0
  166. package/web/favicon.svg +28 -0
  167. package/web/heads/index.html +23 -0
  168. package/web/heads/new/index.html +23 -0
  169. package/web/index.html +23 -0
  170. package/web/login/index.html +23 -0
  171. package/web/logo.svg +56 -0
  172. package/web/settings/index.html +23 -0
  173. package/web/setup/index.html +23 -0
@@ -0,0 +1,1072 @@
1
+ "use strict";
2
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
3
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
5
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
6
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
7
+ };
8
+ var __metadata = (this && this.__metadata) || function (k, v) {
9
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
10
+ };
11
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
12
+ return function (target, key) { decorator(target, key, paramIndex); }
13
+ };
14
+ var HeadService_1;
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.HeadService = exports.HeadValidationError = exports.HeadConflictError = exports.HeadNotFoundError = void 0;
17
+ /**
18
+ * Head lifecycle service.
19
+ *
20
+ * Owns the whole "boot N hydra-nodes that form one Head" flow:
21
+ * create -> allocate ports, generate keys, write envelopes, persist rows
22
+ * start -> fetch protocol params, ensure image/network, create N containers
23
+ * wired as peers of each other, wait for every node to greet
24
+ * stop -> remove containers, keep keys + persistence
25
+ * reset -> wipe persistence (optionally keys) for a clean restart
26
+ *
27
+ * Differences from v1 that matter:
28
+ * - keys are generated here, not supplied by the caller
29
+ * - ports are deterministic per (head, node index) instead of "scan upward
30
+ * from 10005 until something is free", which made peer wiring unpredictable
31
+ * - a failed boot rolls back every container it created and records the real
32
+ * error on the head row instead of leaving orphans behind
33
+ */
34
+ const promises_1 = require("node:fs/promises");
35
+ const shared_1 = require("@hexcore/shared");
36
+ const common_1 = require("@nestjs/common");
37
+ const config_module_1 = require("../config.module");
38
+ const repositories_1 = require("../db/repositories");
39
+ const docker_service_1 = require("../docker/docker.service");
40
+ const container_stats_1 = require("../docker/container-stats");
41
+ const capabilities_1 = require("./capabilities");
42
+ const node_monitor_service_1 = require("./node-monitor.service");
43
+ const boot_progress_1 = require("./boot-progress");
44
+ const blockfrost_keys_1 = require("./blockfrost-keys");
45
+ const socket_1 = require("../chain/socket");
46
+ const argv_1 = require("./argv");
47
+ const keys_1 = require("./keys");
48
+ const ws_client_1 = require("./ws-client");
49
+ const provider_1 = require("../chain/provider");
50
+ class HeadNotFoundError extends Error {
51
+ constructor(id) {
52
+ super(`Head ${id} not found`);
53
+ this.name = 'HeadNotFoundError';
54
+ }
55
+ }
56
+ exports.HeadNotFoundError = HeadNotFoundError;
57
+ class HeadConflictError extends Error {
58
+ constructor(message) {
59
+ super(message);
60
+ this.name = 'HeadConflictError';
61
+ }
62
+ }
63
+ exports.HeadConflictError = HeadConflictError;
64
+ /**
65
+ * A request that is well-formed but cannot be satisfied by THIS server — the
66
+ * cardano-node socket is missing, or no Blockfrost key exists at any level.
67
+ * Zod cannot see server state, so these checks live in the service, but they
68
+ * are field errors to the caller and are reported as such: the create form
69
+ * highlights the input instead of showing a bare toast.
70
+ */
71
+ class HeadValidationError extends Error {
72
+ field;
73
+ constructor(field, message) {
74
+ super(message);
75
+ this.field = field;
76
+ this.name = 'HeadValidationError';
77
+ }
78
+ }
79
+ exports.HeadValidationError = HeadValidationError;
80
+ let HeadService = HeadService_1 = class HeadService {
81
+ config;
82
+ docker;
83
+ heads;
84
+ nodes;
85
+ events;
86
+ capabilities;
87
+ monitor;
88
+ boot;
89
+ logger = new common_1.Logger(HeadService_1.name);
90
+ /** Heads currently being started/stopped — guards against double-boot. */
91
+ inFlight = new Set();
92
+ constructor(config, docker, heads, nodes, events, capabilities, monitor, boot) {
93
+ this.config = config;
94
+ this.docker = docker;
95
+ this.heads = heads;
96
+ this.nodes = nodes;
97
+ this.events = events;
98
+ this.capabilities = capabilities;
99
+ this.monitor = monitor;
100
+ this.boot = boot;
101
+ }
102
+ /**
103
+ * Adopt reality once, at boot.
104
+ *
105
+ * The control-plane can be restarted while heads are up — a deploy, a crash,
106
+ * a machine reboot — and the status columns only ever recorded what the last
107
+ * lifecycle call intended. Reconciling here means a head that died while
108
+ * nothing was watching is already corrected, and its live nodes already have
109
+ * a monitor connection, before the first request arrives.
110
+ *
111
+ * Best effort: Docker being down at boot is not a reason to refuse to start.
112
+ */
113
+ onApplicationBootstrap() {
114
+ void this.reconcile(0).catch((err) => this.logger.warn(`initial reconcile failed: ${err.message}`));
115
+ }
116
+ // ---------------------------------------------------------------- queries
117
+ /** Whether a node with no key of its own would still find one at boot. */
118
+ get serverKeyConfigured() {
119
+ return Boolean(this.config.blockfrostProjectId);
120
+ }
121
+ async list() {
122
+ await this.reconcile();
123
+ return this.heads.list().map(h => (0, repositories_1.toHeadView)(h, this.nodes.byHead(h.id), this.serverKeyConfigured));
124
+ }
125
+ async get(id) {
126
+ await this.reconcile();
127
+ return this.view(id);
128
+ }
129
+ /** Read a head from the DB without reconciling — for callers that just did. */
130
+ view(id) {
131
+ const row = this.heads.findById(id);
132
+ if (!row)
133
+ throw new HeadNotFoundError(id);
134
+ return (0, repositories_1.toHeadView)(row, this.nodes.byHead(id), this.serverKeyConfigured);
135
+ }
136
+ recentEvents(headId, limit = 100) {
137
+ return this.events.recent(headId, limit);
138
+ }
139
+ // ------------------------------------------------------------- reconcile
140
+ /** Last reconciliation, so a burst of requests does not hit Docker N times. */
141
+ lastReconcileAt = 0;
142
+ reconcileInFlight = null;
143
+ /**
144
+ * Make the stored status agree with what Docker actually has.
145
+ *
146
+ * The status columns were only ever written by the lifecycle methods, so
147
+ * they recorded *intent*, not fact: a head whose containers died — OOM, a
148
+ * daemon restart, `docker rm` by hand — kept reporting RUNNING forever,
149
+ * because nothing ever looked again. The same gap left a head stuck on
150
+ * STARTING when the control-plane was restarted mid-boot.
151
+ *
152
+ * One `listContainers` call answers for every head, so this is cheap enough
153
+ * to run on the list and detail endpoints the UI already polls, rather than
154
+ * on a timer that would keep a laptop awake.
155
+ *
156
+ * Two rules keep it from doing harm:
157
+ * - **A head with an action in flight is skipped.** Reconciling mid-start
158
+ * would race the boot it is watching and mark half-created containers
159
+ * failed.
160
+ * - **An unreachable Docker changes nothing.** If the daemon is down every
161
+ * container looks gone, and writing that would turn a transient outage
162
+ * into every head being marked FAILED.
163
+ */
164
+ async reconcile(minIntervalMs = 2000) {
165
+ if (this.reconcileInFlight)
166
+ return this.reconcileInFlight;
167
+ if (Date.now() - this.lastReconcileAt < minIntervalMs)
168
+ return;
169
+ this.reconcileInFlight = this.runReconcile().finally(() => {
170
+ this.lastReconcileAt = Date.now();
171
+ this.reconcileInFlight = null;
172
+ });
173
+ return this.reconcileInFlight;
174
+ }
175
+ async runReconcile() {
176
+ let running;
177
+ try {
178
+ const containers = await this.docker.listManaged();
179
+ running = new Set(containers
180
+ .filter(c => c.State === 'running')
181
+ .flatMap(c => c.Names ?? [])
182
+ // Docker returns names with a leading slash.
183
+ .map(n => n.replace(/^\//, '')));
184
+ }
185
+ catch (err) {
186
+ this.logger.debug(`skipping reconcile — Docker unreachable: ${err.message}`);
187
+ return;
188
+ }
189
+ for (const head of this.heads.list()) {
190
+ if (this.inFlight.has(head.id))
191
+ continue;
192
+ const nodeRows = this.nodes.byHead(head.id);
193
+ if (nodeRows.length === 0)
194
+ continue;
195
+ let up = 0;
196
+ for (const node of nodeRows) {
197
+ const isUp = running.has(node.name);
198
+ if (isUp)
199
+ up++;
200
+ if (isUp) {
201
+ // Keep a connection to every node that is actually up, so a
202
+ // peer dropping out is observed rather than guessed at.
203
+ this.monitor.watch(node.name, node.api_port);
204
+ if (node.status !== 'RUNNING')
205
+ this.nodes.setStatus(node.id, 'RUNNING', node.container_id);
206
+ }
207
+ else {
208
+ this.monitor.unwatch(node.name);
209
+ // FAILED rather than STOPPED: `stop` sets STOPPED itself, so
210
+ // a container that vanished from under a RUNNING row did not
211
+ // go away on purpose.
212
+ if (node.status === 'RUNNING' || node.status === 'STARTING') {
213
+ this.nodes.setStatus(node.id, 'FAILED', null);
214
+ }
215
+ }
216
+ }
217
+ this.reconcileHeadStatus(head, nodeRows.length, up);
218
+ }
219
+ }
220
+ /** Head-level verdict from how many of its containers are actually up. */
221
+ reconcileHeadStatus(head, total, up) {
222
+ const status = head.status;
223
+ if (up === 0 && (status === 'RUNNING' || status === 'STARTING')) {
224
+ const message = status === 'STARTING'
225
+ ? `Head ${head.id} was left STARTING with no containers running — the start never finished.`
226
+ : `Head ${head.id} was marked RUNNING but none of its ${total} container(s) are running.`;
227
+ this.heads.setStatus(head.id, 'FAILED', message);
228
+ this.heads.setHydraState(head.id, 'UNKNOWN');
229
+ this.nodes.setStatusForHead(head.id, 'FAILED');
230
+ this.events.add(head.id, 'head.desynced', message, 'error');
231
+ this.logger.warn(message);
232
+ return;
233
+ }
234
+ // Containers are up under a head that does not think it is running:
235
+ // a start that completed after the control-plane went down, or someone
236
+ // starting the containers by hand. Adopt them rather than showing a
237
+ // stopped head with live nodes.
238
+ if (up > 0 && status !== 'RUNNING' && status !== 'STARTING') {
239
+ const message = `Head ${head.id} has ${up}/${total} container(s) running while marked ${status} — adopted as RUNNING.`;
240
+ this.heads.setStatus(head.id, 'RUNNING', null);
241
+ this.events.add(head.id, 'head.desynced', message, 'warn');
242
+ this.logger.warn(message);
243
+ return;
244
+ }
245
+ // A start that finished while nothing was watching.
246
+ if (up === total && status === 'STARTING') {
247
+ this.heads.setStatus(head.id, 'RUNNING', null);
248
+ this.events.add(head.id, 'head.started', `All ${total} node(s) are up`);
249
+ }
250
+ }
251
+ // ----------------------------------------------------------------- create
252
+ /**
253
+ * Allocate the next free block of ports. Each node occupies PORTS_PER_NODE
254
+ * consecutive numbers, so we simply continue past the highest port in use.
255
+ */
256
+ nextSlot() {
257
+ const max = this.nodes.maxApiPort();
258
+ if (max === null)
259
+ return 0;
260
+ return Math.floor((max - this.config.portBase) / shared_1.PORTS_PER_NODE) + 1;
261
+ }
262
+ async create(input) {
263
+ if (this.heads.findByName(input.name)) {
264
+ throw new HeadConflictError(`A head named "${input.name}" already exists`);
265
+ }
266
+ const network = input.network ?? this.config.network;
267
+ const chainBackend = input.chainBackend ?? this.config.chainBackend;
268
+ /**
269
+ * Refuse a head that could never boot, while the caller is still on the
270
+ * form and can do something about it.
271
+ *
272
+ * Neither credential is required to run the control-plane any more, so
273
+ * both gaps are possible at once: no node socket, and no Blockfrost key
274
+ * anywhere. Left to `start`, either one surfaces as a container that
275
+ * dies during option parsing — a readiness timeout that names nothing.
276
+ */
277
+ if (chainBackend === 'offline') {
278
+ /**
279
+ * Offline mode needs no credential at all — that is the point of it
280
+ * — but its node cap is lower, and the schema can only enforce that
281
+ * when the request names the backend explicitly. A server whose
282
+ * CHAIN_BACKEND default is `offline` reaches here instead.
283
+ */
284
+ if (input.nodeCount > shared_1.MAX_OFFLINE_NODES) {
285
+ throw new HeadValidationError('nodeCount', `An offline head runs at most ${shared_1.MAX_OFFLINE_NODES} nodes — they all share this machine, ` +
286
+ 'with no layer 1 to spread the load over.');
287
+ }
288
+ }
289
+ else if (chainBackend === 'cardano-node') {
290
+ const socket = await (0, socket_1.checkNodeSocket)(this.config.cardanoNodeSocket);
291
+ if (!socket.available) {
292
+ throw new HeadValidationError('chainBackend', `cardano-node backend unavailable: ${socket.reason}`);
293
+ }
294
+ }
295
+ else {
296
+ const nodeKeys = input.nodeBlockfrostProjectIds;
297
+ const missing = Array.from({ length: input.nodeCount }, (_, i) => (0, blockfrost_keys_1.resolveBlockfrostKey)(nodeKeys?.[i], input.blockfrostProjectId, this.config.blockfrostProjectId)).some(k => k.key === null);
298
+ if (missing) {
299
+ throw new HeadValidationError(nodeKeys ? 'nodeBlockfrostProjectIds' : 'blockfrostProjectId', 'A Blockfrost project id is required: this server has no default one configured, ' +
300
+ 'so every node needs a key. Get a free one at https://blockfrost.io.');
301
+ }
302
+ }
303
+ /**
304
+ * Parse every supplied Cardano key before writing anything. Creating a
305
+ * head is a row, N node rows and four files per node — a key that only
306
+ * fails on the last node would otherwise leave a half-built head behind.
307
+ */
308
+ input.nodeCardanoKeys?.forEach((provided, index) => {
309
+ if (!provided)
310
+ return;
311
+ try {
312
+ (0, keys_1.validateCardanoKeyInput)(provided, network);
313
+ }
314
+ catch (err) {
315
+ throw new HeadValidationError(`nodeCardanoKeys.${index}.signingKey`, `Node ${index}: ${err.message}`);
316
+ }
317
+ });
318
+ const offline = chainBackend === 'offline' ? input.offline : undefined;
319
+ const logging = input.logging;
320
+ const headId = this.heads.create({
321
+ name: input.name,
322
+ description: input.description ?? null,
323
+ network,
324
+ chain_backend: chainBackend,
325
+ node_count: input.nodeCount,
326
+ contestation_period: input.contestationPeriod,
327
+ deposit_period: input.depositPeriod,
328
+ persistence_rotate_after: input.persistenceRotateAfter,
329
+ // Stored as given: an empty override object is not the same as no
330
+ // overrides, and the difference is visible in the head view.
331
+ protocol_parameters: input.protocolParameters ? JSON.stringify(input.protocolParameters) : null,
332
+ blockfrost_project_id: input.blockfrostProjectId ?? null,
333
+ // Pinned per head at creation: a later HYDRA_NODE_IMAGE bump must
334
+ // not silently move an existing head onto another version, since
335
+ // 2.x cannot resume persistence written by 0.22.x.
336
+ hydra_node_image: input.hydraNodeImage ?? this.config.hydraNodeImage,
337
+ log_quiet: logging?.quiet ? 1 : 0,
338
+ log_max_size_mb: logging?.maxSizeMb ?? 10,
339
+ log_max_files: logging?.maxFiles ?? 3,
340
+ // Filled in below once the row id exists, which is what the default
341
+ // seed is derived from.
342
+ offline_head_seed: offline?.headSeed ?? null,
343
+ offline_initial_utxo: offline?.initialUtxo ? JSON.stringify(offline.initialUtxo) : null,
344
+ offline_seed_lovelace: chainBackend === 'offline' ? (offline?.seedLovelace ?? shared_1.DEFAULT_OFFLINE_SEED_LOVELACE) : null,
345
+ offline_ledger_genesis: offline?.ledgerGenesis ? JSON.stringify(offline.ledgerGenesis) : null,
346
+ });
347
+ /**
348
+ * Pin the seed now that the id exists, instead of deriving it on every
349
+ * start. hydra-node derives the offline head id from this value, so it
350
+ * has to be stable for the life of the head — and stored, so an
351
+ * operator can read back what their head is actually keyed on.
352
+ */
353
+ if (chainBackend === 'offline' && !offline?.headSeed) {
354
+ this.heads.update(headId, { offline_head_seed: (0, shared_1.defaultOfflineHeadSeed)(headId) });
355
+ }
356
+ const hp = (0, shared_1.headPaths)(this.config.stateDir, headId);
357
+ await (0, promises_1.mkdir)(hp.keys, { recursive: true });
358
+ await (0, promises_1.mkdir)(hp.persistence, { recursive: true });
359
+ const baseSlot = this.nextSlot();
360
+ for (let i = 0; i < input.nodeCount; i++) {
361
+ const ports = (0, shared_1.portsForSlot)(baseSlot + i, this.config.portBase);
362
+ // Generating keys up front means the UI can show fundable addresses
363
+ // the moment a head is created, before anything is started. A node
364
+ // may instead bring its own Cardano key, so an already-funded
365
+ // address can be reused rather than filled from the faucet again.
366
+ const keys = await (0, keys_1.ensureNodeKeys)(this.config.stateDir, headId, i, network, input.nodeCardanoKeys?.[i] ?? undefined);
367
+ this.nodes.create({
368
+ head_id: headId,
369
+ node_index: i,
370
+ name: (0, shared_1.nodeContainerName)(headId, i),
371
+ api_port: ports.api,
372
+ p2p_port: ports.p2p,
373
+ monitoring_port: ports.monitoring,
374
+ hydra_vkey: keys.hydraVk.cborHex,
375
+ cardano_address: keys.cardanoAddress,
376
+ // Only stored when it differs from the head's; otherwise the
377
+ // head's key stays the single place to change it later.
378
+ blockfrost_project_id: input.nodeBlockfrostProjectIds?.[i] ?? null,
379
+ });
380
+ }
381
+ this.events.add(headId, 'head.created', `Head "${input.name}" created with ${input.nodeCount} node(s)`);
382
+ this.logger.log(`head ${headId} created with ${input.nodeCount} node(s)`);
383
+ return this.view(headId);
384
+ }
385
+ /** Patch mutable head settings. Takes effect on the next start. */
386
+ update(id, patch) {
387
+ const head = this.heads.findById(id);
388
+ if (!head)
389
+ throw new HeadNotFoundError(id);
390
+ if (patch.name && patch.name !== head.name && this.heads.findByName(patch.name)) {
391
+ throw new HeadConflictError(`A head named "${patch.name}" already exists`);
392
+ }
393
+ /**
394
+ * Swapping the image under a live head would leave the running
395
+ * containers and the row disagreeing about what is deployed, so it is
396
+ * only allowed while nothing is up. Even then it is not free: hydra-node
397
+ * 2.x cannot resume persistence written by 0.22.x (no commit phase,
398
+ * different event store, different script hashes), so a cross-line
399
+ * change needs a reset — say so instead of failing at the next start.
400
+ */
401
+ const imageChanged = patch.hydraNodeImage !== undefined && patch.hydraNodeImage !== head.hydra_node_image;
402
+ if (imageChanged && (head.status === 'RUNNING' || head.status === 'STARTING')) {
403
+ throw new HeadConflictError(`Cannot change the hydra-node image while head ${id} is ${head.status.toLowerCase()}. Stop it first.`);
404
+ }
405
+ this.heads.update(id, {
406
+ ...(patch.name !== undefined ? { name: patch.name } : {}),
407
+ ...(patch.description !== undefined ? { description: patch.description } : {}),
408
+ ...(patch.contestationPeriod !== undefined ? { contestation_period: patch.contestationPeriod } : {}),
409
+ ...(patch.depositPeriod !== undefined ? { deposit_period: patch.depositPeriod } : {}),
410
+ ...(patch.persistenceRotateAfter !== undefined
411
+ ? { persistence_rotate_after: patch.persistenceRotateAfter }
412
+ : {}),
413
+ ...(patch.protocolParameters !== undefined
414
+ ? { protocol_parameters: JSON.stringify(patch.protocolParameters) }
415
+ : {}),
416
+ ...(patch.blockfrostProjectId !== undefined ? { blockfrost_project_id: patch.blockfrostProjectId } : {}),
417
+ ...(patch.hydraNodeImage !== undefined ? { hydra_node_image: patch.hydraNodeImage } : {}),
418
+ ...(patch.logging !== undefined
419
+ ? {
420
+ log_quiet: patch.logging.quiet ? 1 : 0,
421
+ log_max_size_mb: patch.logging.maxSizeMb,
422
+ log_max_files: patch.logging.maxFiles,
423
+ }
424
+ : {}),
425
+ /**
426
+ * Offline settings are patchable except the seed, which is left
427
+ * out on purpose: it determines the offline head id, so changing it
428
+ * under existing persistence points the node at a head that its own
429
+ * state does not describe. Recreate the head instead.
430
+ */
431
+ ...(patch.offline?.initialUtxo !== undefined
432
+ ? { offline_initial_utxo: JSON.stringify(patch.offline.initialUtxo) }
433
+ : {}),
434
+ ...(patch.offline?.seedLovelace !== undefined ? { offline_seed_lovelace: patch.offline.seedLovelace } : {}),
435
+ ...(patch.offline?.ledgerGenesis !== undefined
436
+ ? { offline_ledger_genesis: JSON.stringify(patch.offline.ledgerGenesis) }
437
+ : {}),
438
+ });
439
+ this.events.add(id, 'head.updated', 'Head settings updated');
440
+ if (imageChanged) {
441
+ const from = (0, shared_1.findVersionProfile)(head.hydra_node_image);
442
+ const to = (0, shared_1.findVersionProfile)(patch.hydraNodeImage);
443
+ const crossLine = from?.id !== to?.id;
444
+ this.events.add(id, 'head.image_changed', `hydra-node image ${head.hydra_node_image} → ${patch.hydraNodeImage}` +
445
+ (crossLine ? ' — reset this head before starting; its persistence is not portable' : ''), crossLine ? 'warn' : 'info');
446
+ }
447
+ return this.view(id);
448
+ }
449
+ // ------------------------------------------------------------------ start
450
+ async start(id) {
451
+ const head = this.heads.findById(id);
452
+ if (!head)
453
+ throw new HeadNotFoundError(id);
454
+ if (this.inFlight.has(id))
455
+ throw new HeadConflictError(`Head ${id} is already starting or stopping`);
456
+ const nodeRows = this.nodes.byHead(id);
457
+ if (nodeRows.length === 0)
458
+ throw new HeadConflictError(`Head ${id} has no nodes`);
459
+ const runningNow = this.nodes.countRunning();
460
+ if (runningNow + nodeRows.length > this.config.maxActiveNodes) {
461
+ throw new HeadConflictError(`Starting this head would exceed MAX_ACTIVE_NODES (${this.config.maxActiveNodes}). ` +
462
+ `Currently running: ${runningNow}, this head needs ${nodeRows.length}.`);
463
+ }
464
+ this.inFlight.add(id);
465
+ this.heads.setStatus(id, 'STARTING');
466
+ this.nodes.setStatusForHead(id, 'STARTING');
467
+ this.events.add(id, 'head.starting', `Starting ${nodeRows.length} node(s)`);
468
+ /**
469
+ * The live view of this boot, polled by the UI while the POST is still
470
+ * in flight. Starting a head is six phases and the image pull alone can
471
+ * run for minutes; a spinner over all of it says nothing about whether
472
+ * anything is happening, and on failure the single error left on the row
473
+ * is usually the last symptom rather than the cause.
474
+ */
475
+ const run = this.boot.begin(id);
476
+ run.log(`Starting ${nodeRows.length} node(s) on ${head.hydra_node_image}`);
477
+ const created = [];
478
+ try {
479
+ run.step('docker');
480
+ await this.docker.ping();
481
+ await this.docker.ensureNetwork();
482
+ run.step('image');
483
+ if (await this.docker.hasImage(head.hydra_node_image)) {
484
+ run.detail('image', 'already present');
485
+ }
486
+ else {
487
+ // A first pull is the longest part of a cold start by far, and
488
+ // the only one worth streaming: without it the operator cannot
489
+ // tell a slow download from a stuck one.
490
+ run.detail('image', 'pulling — this is the slow part of a first run');
491
+ await this.docker.ensureImage(head.hydra_node_image, line => run.detail('image', line));
492
+ run.detail('image', 'pulled');
493
+ }
494
+ // Settle which flags this image accepts before building any argv:
495
+ // an unknown flag aborts hydra-node during option parsing, and the
496
+ // resulting container death reads as a generic readiness timeout.
497
+ // A declared version profile answers it; anything else is probed.
498
+ run.step('flags');
499
+ const resolved = await this.capabilities.resolve(head.hydra_node_image, this.config.hydraArgOverrides);
500
+ this.logger.log(`head ${id}: ${head.hydra_node_image} → ${resolved.caps.version} ` +
501
+ `(${resolved.source}${resolved.profileId ? ` ${resolved.profileId}` : ''})`);
502
+ run.detail('flags', `${resolved.caps.version} (${resolved.source})`);
503
+ run.log(`Flags settled from the ${resolved.source}: ${resolved.caps.version}`);
504
+ const hp = (0, shared_1.headPaths)(this.config.stateDir, id);
505
+ await (0, promises_1.mkdir)(hp.persistence, { recursive: true });
506
+ /**
507
+ * Re-check the backend's credential here as well as at creation:
508
+ * a socket can disappear with the node that owned it, and a head
509
+ * created while one existed would otherwise boot into containers
510
+ * that die during option parsing.
511
+ */
512
+ if (head.chain_backend === 'cardano-node') {
513
+ const socket = await (0, socket_1.checkNodeSocket)(this.config.cardanoNodeSocket);
514
+ if (!socket.available) {
515
+ throw new Error(`cardano-node backend unavailable: ${socket.reason}. ` +
516
+ 'Fix CARDANO_NODE_SOCKET, or recreate this head on the Blockfrost backend.');
517
+ }
518
+ }
519
+ /**
520
+ * Warn about upstream defects this version carries on this backend.
521
+ *
522
+ * Recorded as an event, not just logged: the create form shows the
523
+ * same warning, but a head created through the API never saw it, and
524
+ * the image cannot be changed later without resetting the head. This
525
+ * is the last point where someone can still see it.
526
+ */
527
+ for (const issue of (0, shared_1.knownIssuesFor)(head.hydra_node_image, head.chain_backend, (0, shared_1.toHostPlatform)(process.platform, process.arch))) {
528
+ const message =
529
+ // Not every issue has an upstream number — an image built
530
+ // for the wrong architecture is not a bug in the node.
531
+ `${head.hydra_node_image}: ${issue.title}${issue.id ? ` (hydra#${issue.id})` : ''}. ` +
532
+ `${issue.impact}` +
533
+ (issue.fixedIn ? ` Fixed in hydra-node ${issue.fixedIn}.` : ` See ${issue.url}`);
534
+ this.events.add(id, 'head.known_issue', message, issue.severity === 'blocker' ? 'warn' : 'info');
535
+ this.logger.warn(`head ${id}: ${message}`);
536
+ run.log(message, 'warn');
537
+ }
538
+ /**
539
+ * An image with no offline branch cannot run this head at all —
540
+ * there is no online argv to fall back to, since the head has no
541
+ * credential for either backend. Better a named error here than a
542
+ * container that dies in option parsing.
543
+ */
544
+ if (head.chain_backend === 'offline' && !resolved.caps.offline) {
545
+ throw new Error(`${head.hydra_node_image} does not support offline mode (no --offline-head-seed). ` +
546
+ 'Use a hydra-node image that does, or recreate this head on a chain backend.');
547
+ }
548
+ run.step('config');
549
+ // Resolved once and reused: the protocol parameters fetch and each
550
+ // node's token file must not disagree about which key is in play.
551
+ const nodeKeys = new Map(nodeRows.map(node => [
552
+ node.node_index,
553
+ (0, blockfrost_keys_1.resolveBlockfrostKey)(node.blockfrost_project_id, head.blockfrost_project_id, this.config.blockfrostProjectId),
554
+ ]));
555
+ /**
556
+ * Protocol parameters for the L2 ledger. Read over Blockfrost even
557
+ * for a cardano-node head, so any key that resolves will do — but
558
+ * NOT for an offline head: one that reached out to Blockfrost at
559
+ * start would fail on a machine with no network, which is exactly
560
+ * where offline mode is worth having. It gets the static baseline,
561
+ * which now carries real cost models (see chain/cost-models.ts).
562
+ *
563
+ * Whatever the source, the head's own overrides are merged last, so
564
+ * a pinned parameter survives an epoch that moves the live value.
565
+ */
566
+ const overrides = head.protocol_parameters
567
+ ? JSON.parse(head.protocol_parameters)
568
+ : undefined;
569
+ const params = await (0, provider_1.fetchProtocolParameters)({
570
+ network: head.network,
571
+ projectId: head.chain_backend === 'offline'
572
+ ? ''
573
+ : nodeKeys.get(nodeRows[0].node_index)?.key ||
574
+ head.blockfrost_project_id ||
575
+ this.config.blockfrostProjectId,
576
+ baseUrl: this.config.blockfrostBaseUrl,
577
+ overrides,
578
+ });
579
+ await (0, promises_1.writeFile)(hp.protocolParameters, JSON.stringify(params, null, 2), 'utf8');
580
+ run.log(head.chain_backend === 'offline'
581
+ ? 'Protocol parameters written from the built-in baseline'
582
+ : 'Protocol parameters written from the chain');
583
+ if (head.chain_backend === 'offline') {
584
+ await this.writeOfflineFiles(head, nodeRows);
585
+ run.log('Offline starting UTxO written');
586
+ }
587
+ /**
588
+ * One token file per node, read by hydra-node with `--blockfrost`.
589
+ * Nodes sharing a key just get identical contents; a node with its
590
+ * own key gets its own budget, which is the point of allowing it.
591
+ */
592
+ if (head.chain_backend === 'blockfrost') {
593
+ for (const node of nodeRows) {
594
+ const { key } = nodeKeys.get(node.node_index);
595
+ if (!key) {
596
+ throw new Error(`${node.name} has no Blockfrost project id, and neither the head nor the ` +
597
+ 'server supplies one. Set BLOCKFROST_PROJECT_ID or recreate the head with a key.');
598
+ }
599
+ await (0, promises_1.writeFile)(hp.nodeBlockfrostToken(node.node_index), key, {
600
+ encoding: 'utf8',
601
+ mode: 0o600,
602
+ });
603
+ }
604
+ // Heads created before per-node keys left one shared file here;
605
+ // nothing reads it now, so do not leave a project id on disk.
606
+ await (0, promises_1.rm)(hp.legacyBlockfrostToken, { force: true });
607
+ }
608
+ for (const node of nodeRows) {
609
+ await (0, promises_1.mkdir)(hp.nodePersistence(node.node_index), { recursive: true });
610
+ await (0, keys_1.ensureNodeKeys)(this.config.stateDir, id, node.node_index, head.network);
611
+ }
612
+ run.step('containers', `0/${nodeRows.length} created`);
613
+ for (const node of nodeRows) {
614
+ const peers = nodeRows.filter(n => n.id !== node.id);
615
+ const cmd = this.buildNodeCommand(head, node, peers, resolved);
616
+ const container = await this.docker.createAndStart({
617
+ name: node.name,
618
+ image: head.hydra_node_image,
619
+ cmd,
620
+ binds: this.buildBinds(head),
621
+ ports: [node.api_port, node.p2p_port, node.monitoring_port],
622
+ labels: this.docker.labelsFor(id, node.id, node.node_index),
623
+ // Docker's json-file driver is unbounded by default, so a
624
+ // head left running writes logs until the disk fills.
625
+ logRotation: { maxSizeMb: head.log_max_size_mb, maxFiles: head.log_max_files },
626
+ env: [
627
+ 'ETCD_AUTO_COMPACTION_MODE=periodic',
628
+ 'ETCD_AUTO_COMPACTION_RETENTION=168h',
629
+ 'ETCD_HEARTBEAT_INTERVAL=1000',
630
+ 'ETCD_ELECTION_TIMEOUT=5000',
631
+ ],
632
+ });
633
+ created.push(node.name);
634
+ this.nodes.setStatus(node.id, 'STARTING', container.id);
635
+ run.detail('containers', `${created.length}/${nodeRows.length} created`);
636
+ run.log(`${node.name} created on api ${node.api_port}`);
637
+ }
638
+ // Wait for every node to answer on its API port.
639
+ run.step('ready', `0/${nodeRows.length} ready`);
640
+ const ports = nodeRows.map(n => n.api_port);
641
+ const results = await (0, ws_client_1.waitForNodes)(ports, {
642
+ timeoutMs: 180_000,
643
+ onProgress: (ready, total) => {
644
+ this.logger.log(`head ${id}: ${ready}/${total} nodes ready`);
645
+ run.detail('ready', `${ready}/${total} ready`);
646
+ },
647
+ /**
648
+ * Pull what the containers have printed on every poll. This is
649
+ * the part that turns "node never became ready" into an answer:
650
+ * hydra-node explains its own refusals — an unknown flag, a key
651
+ * it cannot read, a chain it cannot reach — and until now none
652
+ * of that reached anyone watching the head come up.
653
+ */
654
+ onTick: () => this.collectBootLogs(run, nodeRows),
655
+ });
656
+ // One last sweep, so whatever a node printed just before giving up
657
+ // is in the pane the operator is about to read.
658
+ await this.collectBootLogs(run, nodeRows);
659
+ const unreachable = nodeRows.filter(n => !results.get(n.api_port)?.reachable);
660
+ if (unreachable.length > 0) {
661
+ const detail = unreachable
662
+ .map(n => `${n.name} (port ${n.api_port}): ${results.get(n.api_port)?.error ?? 'no response'}`)
663
+ .join('; ');
664
+ throw new Error(`Node(s) never became ready — ${detail}`);
665
+ }
666
+ for (const node of nodeRows) {
667
+ this.nodes.setStatus(node.id, 'RUNNING', node.container_id);
668
+ // Hold a connection open from here on: a peer dropping out later
669
+ // is announced as an event, and nothing that connects after the
670
+ // fact can see it.
671
+ this.monitor.watch(node.name, node.api_port);
672
+ }
673
+ const state = (0, ws_client_1.aggregateHydraState)(nodeRows.map(n => results.get(n.api_port)?.info?.hydraState ?? 'UNKNOWN'));
674
+ this.heads.setStatus(id, 'RUNNING', null);
675
+ this.heads.setHydraState(id, state);
676
+ this.events.add(id, 'head.started', `All ${nodeRows.length} node(s) ready (state ${state})`);
677
+ this.logger.log(`head ${id} running (state ${state})`);
678
+ run.succeed(`All ${nodeRows.length} node(s) ready (Hydra state ${state})`);
679
+ return this.view(id);
680
+ }
681
+ catch (err) {
682
+ const message = err.message;
683
+ this.logger.error(`head ${id} start failed — rolling back: ${message}`);
684
+ /**
685
+ * Fail the run BEFORE the rollback removes the containers: the pane
686
+ * is the only place their output survives, and it is what turns a
687
+ * readiness timeout into a reason.
688
+ */
689
+ run.fail(message);
690
+ // Roll back every container we created so a retry starts clean.
691
+ for (const name of created) {
692
+ this.monitor.unwatch(name);
693
+ await this.docker.removeContainer(name);
694
+ }
695
+ run.log(`Rolled back ${created.length} container(s) so a retry starts clean`, 'warn');
696
+ this.nodes.setStatusForHead(id, 'FAILED');
697
+ this.heads.setStatus(id, 'FAILED', message);
698
+ this.events.add(id, 'head.failed', message, 'error');
699
+ throw err;
700
+ }
701
+ finally {
702
+ this.inFlight.delete(id);
703
+ }
704
+ }
705
+ // ------------------------------------------------------------------- stop
706
+ async stop(id) {
707
+ const head = this.heads.findById(id);
708
+ if (!head)
709
+ throw new HeadNotFoundError(id);
710
+ if (this.inFlight.has(id))
711
+ throw new HeadConflictError(`Head ${id} is busy`);
712
+ this.inFlight.add(id);
713
+ try {
714
+ for (const node of this.nodes.byHead(id)) {
715
+ // Drop the connection before the container goes, so its close
716
+ // does not read as a node that failed on its own.
717
+ this.monitor.unwatch(node.name);
718
+ await this.docker.removeContainer(node.name);
719
+ this.nodes.setStatus(node.id, 'STOPPED', null);
720
+ }
721
+ this.heads.setStatus(id, 'STOPPED', null);
722
+ this.heads.setHydraState(id, 'UNKNOWN');
723
+ this.events.add(id, 'head.stopped', 'Head stopped');
724
+ return this.view(id);
725
+ }
726
+ finally {
727
+ this.inFlight.delete(id);
728
+ }
729
+ }
730
+ async restart(id) {
731
+ await this.stop(id);
732
+ return this.start(id);
733
+ }
734
+ /**
735
+ * Wipe the head's persistence so the next start begins from a fresh Hydra
736
+ * state. In v1 this was the only way out of a corrupted head, but it was a
737
+ * manual `rm -rf` on the server.
738
+ */
739
+ async reset(id, includeKeys = false) {
740
+ const head = this.heads.findById(id);
741
+ if (!head)
742
+ throw new HeadNotFoundError(id);
743
+ if (head.status === 'RUNNING' || head.status === 'STARTING') {
744
+ throw new HeadConflictError('Stop the head before resetting it');
745
+ }
746
+ const hp = (0, shared_1.headPaths)(this.config.stateDir, id);
747
+ await (0, promises_1.rm)(hp.persistence, { recursive: true, force: true });
748
+ await (0, promises_1.mkdir)(hp.persistence, { recursive: true });
749
+ if (includeKeys) {
750
+ await (0, promises_1.rm)(hp.keys, { recursive: true, force: true });
751
+ await (0, promises_1.mkdir)(hp.keys, { recursive: true });
752
+ for (const node of this.nodes.byHead(id)) {
753
+ const keys = await (0, keys_1.ensureNodeKeys)(this.config.stateDir, id, node.node_index, head.network);
754
+ // New keys mean new addresses — the UI must show the fresh ones.
755
+ this.nodes.setStatus(node.id, 'CREATED', null);
756
+ this.nodes.setIdentity(node.id, keys.hydraVk.cborHex, keys.cardanoAddress);
757
+ }
758
+ }
759
+ this.heads.setStatus(id, 'CONFIGURED', null);
760
+ this.heads.setHydraState(id, 'UNKNOWN');
761
+ this.events.add(id, 'head.reset', includeKeys ? 'Persistence and keys wiped' : 'Persistence wiped');
762
+ return this.view(id);
763
+ }
764
+ async delete(id) {
765
+ const head = this.heads.findById(id);
766
+ if (!head)
767
+ throw new HeadNotFoundError(id);
768
+ /**
769
+ * A start in flight is still creating containers and, on failure, rolls
770
+ * back by name. Deleting underneath it would race that rollback and
771
+ * leave containers whose head row is already gone — unreachable from
772
+ * the UI and only removable by hand.
773
+ */
774
+ if (this.inFlight.has(id)) {
775
+ throw new HeadConflictError(`Head ${id} is starting or stopping — wait for that to finish first`);
776
+ }
777
+ for (const node of this.nodes.byHead(id)) {
778
+ this.monitor.unwatch(node.name);
779
+ await this.docker.removeContainer(node.name);
780
+ }
781
+ await (0, promises_1.rm)((0, shared_1.headPaths)(this.config.stateDir, id).root, { recursive: true, force: true });
782
+ this.heads.delete(id);
783
+ this.events.add(null, 'head.deleted', `Head ${id} ("${head.name}") deleted`);
784
+ }
785
+ // ----------------------------------------------------------------- status
786
+ /**
787
+ * Live status of a head.
788
+ *
789
+ * Three sources, and each answers something the others cannot:
790
+ * - **Docker** says whether the container exists and runs. A node that is
791
+ * simply gone is a different failure from one that is up but silent, and
792
+ * treating them the same is what let a dead head report RUNNING.
793
+ * - **The monitor's open connection** carries the node's own view, folded
794
+ * from its event stream — including the connectivity events (a peer
795
+ * dropping out) that nothing can observe after the fact.
796
+ * - **A one-shot probe**, only as a fallback for a node the monitor has not
797
+ * heard from yet, so a status request right after a start still answers.
798
+ */
799
+ async liveStatus(id) {
800
+ await this.reconcile();
801
+ const head = this.heads.findById(id);
802
+ if (!head)
803
+ throw new HeadNotFoundError(id);
804
+ const rows = this.nodes.byHead(id);
805
+ const nodes = await Promise.all(rows.map(async (row) => {
806
+ const containerRunning = await this.docker.isRunning(row.name);
807
+ const base = { nodeId: row.id, name: row.name, containerRunning };
808
+ if (!containerRunning) {
809
+ return {
810
+ ...base,
811
+ reachable: false,
812
+ hydraState: 'UNKNOWN',
813
+ error: 'container not running',
814
+ };
815
+ }
816
+ const live = this.monitor.stateFor(row.name);
817
+ if (live?.connected) {
818
+ return {
819
+ ...base,
820
+ reachable: true,
821
+ hydraState: live.hydraState,
822
+ version: live.version ?? undefined,
823
+ snapshotUtxoCount: live.snapshotUtxoCount ?? undefined,
824
+ peersDown: [...live.peersDown],
825
+ networkConnected: live.networkConnected ?? undefined,
826
+ chainSynced: live.chainSynced ?? undefined,
827
+ headId: live.headId ?? undefined,
828
+ lastMessageAt: live.lastMessageAt ?? undefined,
829
+ };
830
+ }
831
+ // The container is up but our connection is not — either it just
832
+ // started, or the API is not answering. A probe distinguishes
833
+ // those two, which the monitor alone cannot.
834
+ const probe = await (0, ws_client_1.probeNode)(row.api_port, 5000);
835
+ return {
836
+ ...base,
837
+ reachable: probe.reachable,
838
+ hydraState: probe.info?.hydraState ?? 'UNKNOWN',
839
+ version: probe.info?.version,
840
+ snapshotUtxoCount: probe.info?.snapshotUtxoCount,
841
+ headId: probe.info?.headId,
842
+ chainSynced: probe.info?.chainSynced,
843
+ error: probe.error ?? live?.lastError ?? undefined,
844
+ };
845
+ }));
846
+ const state = (0, ws_client_1.aggregateHydraState)(nodes.map(n => n.hydraState));
847
+ if (state !== head.hydra_state)
848
+ this.heads.setHydraState(id, state);
849
+ const containersRunning = nodes.filter(n => n.containerRunning).length;
850
+ const { degraded, degradedReasons } = this.describeHealth(head.status, nodes, containersRunning);
851
+ return {
852
+ headId: id,
853
+ status: head.status,
854
+ hydraState: state,
855
+ nodes,
856
+ checkedAt: new Date().toISOString(),
857
+ degraded,
858
+ degradedReasons,
859
+ containersRunning,
860
+ };
861
+ }
862
+ /**
863
+ * Is the head whole, and if not, what is missing.
864
+ *
865
+ * Kept off `status` on purpose. A three-node head with one node down is
866
+ * still RUNNING and still serving the other two — but a head that is short a
867
+ * party cannot reach consensus on a snapshot, so reporting it as plain
868
+ * "running" is how a half-dead head goes unnoticed until someone asks why
869
+ * nothing confirms.
870
+ *
871
+ * Only meaningful for a head that is supposed to be up; a STOPPED head is
872
+ * not degraded, it is stopped.
873
+ */
874
+ describeHealth(status, nodes, containersRunning) {
875
+ if (status !== 'RUNNING' && status !== 'STARTING')
876
+ return { degraded: false, degradedReasons: [] };
877
+ const reasons = [];
878
+ const missing = nodes.filter(n => !n.containerRunning);
879
+ if (missing.length > 0) {
880
+ reasons.push(`${missing.length} of ${nodes.length} container(s) not running: ${missing.map(n => n.name).join(', ')}`);
881
+ }
882
+ const silent = nodes.filter(n => n.containerRunning && !n.reachable);
883
+ if (silent.length > 0) {
884
+ reasons.push(`${silent.map(n => n.name).join(', ')} running but not answering the Hydra API`);
885
+ }
886
+ /**
887
+ * The signal a peer dropping out actually produces. The head's own state
888
+ * stays Open — losing a peer is a connectivity fact, not a protocol
889
+ * transition — so `GET /head` will never report this and only the
890
+ * surviving nodes' event streams can.
891
+ */
892
+ const isolated = nodes.filter(n => n.reachable && n.networkConnected === false);
893
+ if (isolated.length > 0) {
894
+ reasons.push(`${isolated.map(n => n.name).join(', ')} lost the Hydra network — a peer is unreachable`);
895
+ }
896
+ /**
897
+ * Peers a node told us went away. Only departures are tracked — the
898
+ * connections that predate our own are never replayed to us — so a
899
+ * non-empty list is a fact, while an empty one only means nothing has
900
+ * been reported. See NodeLiveStatus.peersDown.
901
+ */
902
+ const lostPeers = nodes.filter(n => (n.peersDown?.length ?? 0) > 0);
903
+ if (lostPeers.length > 0) {
904
+ reasons.push(lostPeers.map(n => `${n.name} lost peer(s) ${n.peersDown.join(', ')}`).join('; '));
905
+ }
906
+ const behind = nodes.filter(n => n.chainSynced === 'CatchingUp');
907
+ if (behind.length > 0) {
908
+ reasons.push(`${behind.map(n => n.name).join(', ')} is catching up with the chain`);
909
+ }
910
+ return { degraded: reasons.length > 0 || containersRunning < nodes.length, degradedReasons: reasons };
911
+ }
912
+ /**
913
+ * What this head is costing the machine right now.
914
+ *
915
+ * Sampled for every node in one call rather than per node, because the
916
+ * question this answers is a head-level one: docs/PRODUCT.md sizes the
917
+ * node-count ceilings on roughly 500 MB per hydra-node at peak, and an
918
+ * offline head runs up to five of them side by side on the one machine. A
919
+ * per-node figure cannot be checked against that budget; the total can.
920
+ *
921
+ * Nodes are sampled in parallel and one failure does not sink the rest —
922
+ * a container removed between the row read and the sample is reported as
923
+ * not running, which is exactly what it is.
924
+ */
925
+ async resourceUsage(id) {
926
+ const head = this.heads.findById(id);
927
+ if (!head)
928
+ throw new HeadNotFoundError(id);
929
+ const sampledAt = new Date().toISOString();
930
+ const nodes = await Promise.all(this.nodes.byHead(id).map(async (row) => {
931
+ const base = { nodeId: row.id, name: row.name, sampledAt };
932
+ try {
933
+ const raw = await this.docker.stats(row.name);
934
+ if (!raw)
935
+ return { ...base, running: false, ...(0, container_stats_1.emptyUsage)() };
936
+ return { ...base, running: true, ...(0, container_stats_1.readContainerStats)(raw) };
937
+ }
938
+ catch (err) {
939
+ // Docker being unreachable must not turn a status pane into
940
+ // an error page — the head view above it still works.
941
+ this.logger.debug(`stats for ${row.name} failed: ${err.message}`);
942
+ return { ...base, running: false, ...(0, container_stats_1.emptyUsage)() };
943
+ }
944
+ }));
945
+ const running = nodes.filter(n => n.running);
946
+ const sum = (pick) => {
947
+ const values = running.map(pick).filter((v) => v !== null);
948
+ return values.length > 0 ? values.reduce((a, b) => a + b, 0) : null;
949
+ };
950
+ return {
951
+ headId: id,
952
+ nodes,
953
+ totalMemoryBytes: sum(n => n.memoryBytes),
954
+ totalCpuPercent: sum(n => n.cpuPercent),
955
+ sampledAt,
956
+ };
957
+ }
958
+ async nodeLogs(headId, nodeIndex, tail = 200) {
959
+ const node = this.nodes.byHead(headId).find(n => n.node_index === nodeIndex);
960
+ if (!node)
961
+ throw new HeadNotFoundError(headId);
962
+ return this.docker.logs(node.name, tail);
963
+ }
964
+ // ---------------------------------------------------------------- private
965
+ /**
966
+ * The two files an offline head boots from.
967
+ *
968
+ * `--initial-utxo` is the L2 ledger's entire starting state — there is no
969
+ * chain to commit from, so whatever is in this file is all the head will
970
+ * ever hold. Written fresh on every start so an edited seed amount takes
971
+ * effect after a reset, and so the addresses follow a `reset --includeKeys`
972
+ * that changed them.
973
+ *
974
+ * `--ledger-genesis` is only written when the operator supplied one.
975
+ * hydra-node's own default is to run without it, and a hand-built partial
976
+ * genesis fails to decode and kills the node during startup — so an absent
977
+ * genesis stays absent rather than being invented.
978
+ */
979
+ async writeOfflineFiles(head, nodeRows) {
980
+ const hp = (0, shared_1.headPaths)(this.config.stateDir, head.id);
981
+ let utxo;
982
+ if (head.offline_initial_utxo) {
983
+ utxo = JSON.parse(head.offline_initial_utxo);
984
+ }
985
+ else {
986
+ // Seeding the nodes' OWN addresses is what makes the default useful:
987
+ // the operator already holds those signing keys, so everything in
988
+ // the head is spendable without importing anything.
989
+ const funded = nodeRows.filter(n => n.cardano_address);
990
+ if (funded.length === 0) {
991
+ throw new Error(`Head ${head.id} has no Cardano address to seed its offline UTxO with. ` +
992
+ 'Reset the head with key regeneration, or supply an initial UTxO explicitly.');
993
+ }
994
+ utxo = (0, shared_1.buildInitialUtxo)(funded.map(n => ({
995
+ address: n.cardano_address,
996
+ lovelace: head.offline_seed_lovelace ?? shared_1.DEFAULT_OFFLINE_SEED_LOVELACE,
997
+ })));
998
+ }
999
+ await (0, promises_1.writeFile)(hp.initialUtxo, JSON.stringify(utxo, null, 2), 'utf8');
1000
+ if (head.offline_ledger_genesis) {
1001
+ await (0, promises_1.writeFile)(hp.ledgerGenesis, head.offline_ledger_genesis, 'utf8');
1002
+ }
1003
+ else {
1004
+ // A file left behind by a head that used to have one would still be
1005
+ // found by the flag if it were ever re-enabled.
1006
+ await (0, promises_1.rm)(hp.ledgerGenesis, { force: true });
1007
+ }
1008
+ }
1009
+ /**
1010
+ * Pull each container's recent output into the boot pane.
1011
+ *
1012
+ * Best effort by design: a container that has not been created yet, or has
1013
+ * already died, answers with nothing useful, and a logging failure must
1014
+ * never be the reason a head fails to start.
1015
+ */
1016
+ async collectBootLogs(run, nodeRows) {
1017
+ await Promise.all(nodeRows.map(async (node) => {
1018
+ try {
1019
+ const logs = await this.docker.logs(node.name, 40);
1020
+ if (logs)
1021
+ run.appendContainerLogs(node.name, logs);
1022
+ }
1023
+ catch {
1024
+ /* the container is not up yet, or is already gone */
1025
+ }
1026
+ }));
1027
+ }
1028
+ /** Live progress of the current or last boot, for the UI to poll. */
1029
+ bootProgress(id) {
1030
+ if (!this.heads.findById(id))
1031
+ throw new HeadNotFoundError(id);
1032
+ return this.boot.get(id) ?? null;
1033
+ }
1034
+ buildBinds(head) {
1035
+ const binds = [`${this.config.stateDir}:/data`];
1036
+ if (head.chain_backend === 'cardano-node' && this.config.cardanoNodeSocket) {
1037
+ binds.push(`${this.config.cardanoNodeSocket}:/cardano-node/node.socket`);
1038
+ }
1039
+ return binds;
1040
+ }
1041
+ /**
1042
+ * Assemble the hydra-node argv.
1043
+ *
1044
+ * Delegates to the pure builders in argv.ts so the exact flag set can be
1045
+ * asserted in tests without Docker or a Blockfrost key. `resolved.caps`
1046
+ * gates the flags that only exist on newer images, and `resolved.args`
1047
+ * carries the values that version wants — see capabilities.ts.
1048
+ */
1049
+ buildNodeCommand(head, node, peers, resolved) {
1050
+ return (0, argv_1.buildNodeArgv)(head, node, peers, {
1051
+ caps: resolved.caps,
1052
+ hydraScriptsTxId: this.config.hydraScriptsTxId,
1053
+ unsyncedPeriod: resolved.args.unsyncedPeriod,
1054
+ blockfrostQueryTimeout: resolved.args.blockfrostQueryTimeout,
1055
+ blockfrostRetryTimeout: resolved.args.blockfrostRetryTimeout,
1056
+ extraArgs: resolved.args.extraArgs,
1057
+ });
1058
+ }
1059
+ };
1060
+ exports.HeadService = HeadService;
1061
+ exports.HeadService = HeadService = HeadService_1 = __decorate([
1062
+ (0, common_1.Injectable)(),
1063
+ __param(0, (0, common_1.Inject)(config_module_1.APP_CONFIG)),
1064
+ __metadata("design:paramtypes", [Object, docker_service_1.DockerService,
1065
+ repositories_1.HeadRepository,
1066
+ repositories_1.NodeRepository,
1067
+ repositories_1.EventRepository,
1068
+ capabilities_1.NodeCapabilitiesService,
1069
+ node_monitor_service_1.NodeMonitorService,
1070
+ boot_progress_1.BootTracker])
1071
+ ], HeadService);
1072
+ //# sourceMappingURL=head.service.js.map