@serfab/cadre-host 1.14.0 → 1.16.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 (107) hide show
  1. package/README.md +19 -4
  2. package/dist/bin/host.js +32 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/bin/nat-output.d.ts +5 -0
  5. package/dist/bin/nat-output.js +12 -1
  6. package/dist/bin/nat-output.js.map +1 -1
  7. package/dist/bin/ui-access.d.ts +22 -0
  8. package/dist/bin/ui-access.js +44 -0
  9. package/dist/bin/ui-access.js.map +1 -0
  10. package/dist/hosted/claim-details.d.ts +25 -3
  11. package/dist/hosted/claim-details.js +31 -37
  12. package/dist/hosted/claim-details.js.map +1 -1
  13. package/dist/hosted/hosted-node-service.d.ts +7 -0
  14. package/dist/hosted/hosted-node-service.js +16 -4
  15. package/dist/hosted/hosted-node-service.js.map +1 -1
  16. package/dist/hosted/hosted-node-watcher.d.ts +16 -4
  17. package/dist/hosted/hosted-node-watcher.js +45 -9
  18. package/dist/hosted/hosted-node-watcher.js.map +1 -1
  19. package/dist/hosted/node-status.d.ts +19 -3
  20. package/dist/hosted/node-status.js +21 -5
  21. package/dist/hosted/node-status.js.map +1 -1
  22. package/dist/hosted/types.d.ts +12 -5
  23. package/dist/hosted/types.js.map +1 -1
  24. package/dist/installer/config.d.ts +23 -0
  25. package/dist/installer/config.js +46 -0
  26. package/dist/installer/config.js.map +1 -1
  27. package/dist/installer/index.d.ts +2 -0
  28. package/dist/installer/index.js +7 -16
  29. package/dist/installer/index.js.map +1 -1
  30. package/dist/installer/wizard.d.ts +12 -2
  31. package/dist/installer/wizard.js +15 -7
  32. package/dist/installer/wizard.js.map +1 -1
  33. package/dist/nat/index.d.ts +2 -1
  34. package/dist/nat/index.js +1 -0
  35. package/dist/nat/index.js.map +1 -1
  36. package/dist/nat/nat-service.d.ts +22 -5
  37. package/dist/nat/nat-service.js +75 -26
  38. package/dist/nat/nat-service.js.map +1 -1
  39. package/dist/nat/nat-store.js +11 -0
  40. package/dist/nat/nat-store.js.map +1 -1
  41. package/dist/nat/network-mode.d.ts +19 -0
  42. package/dist/nat/network-mode.js +32 -0
  43. package/dist/nat/network-mode.js.map +1 -0
  44. package/dist/nat/reachability.d.ts +5 -4
  45. package/dist/nat/reachability.js +19 -6
  46. package/dist/nat/reachability.js.map +1 -1
  47. package/dist/nat/types.d.ts +26 -6
  48. package/dist/nat/types.js.map +1 -1
  49. package/dist/orchestrator/host-process-orchestrator.d.ts +3 -3
  50. package/dist/orchestrator/host-process-orchestrator.js +6 -15
  51. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  52. package/dist/package-version.d.ts +1 -0
  53. package/dist/package-version.js +4 -0
  54. package/dist/package-version.js.map +1 -0
  55. package/dist/server/routes/settings.d.ts +1 -0
  56. package/dist/server/routes/settings.js +15 -1
  57. package/dist/server/routes/settings.js.map +1 -1
  58. package/dist/server/routes/status.js +2 -32
  59. package/dist/server/routes/status.js.map +1 -1
  60. package/dist/ui/assets/index-BHd8CWZA.css +1 -0
  61. package/dist/ui/assets/index-Ccr2GjPT.js +23 -0
  62. package/dist/ui/assets/{index-4FWuU8ye.js.map → index-Ccr2GjPT.js.map} +1 -1
  63. package/dist/ui/index.html +2 -2
  64. package/package.json +4 -5
  65. package/src/__tests__/cli.smoke.test.ts +1 -1
  66. package/src/__tests__/orchestrator-claim-secret.test.ts +1 -2
  67. package/src/__tests__/orchestrator-env-scrub.test.ts +160 -163
  68. package/src/__tests__/orchestrator-handle-contract.ts +1 -1
  69. package/src/__tests__/orchestrator-node-identity.test.ts +0 -5
  70. package/src/__tests__/orchestrator-push.test.ts +3 -3
  71. package/src/__tests__/orchestrator.test.ts +3 -5
  72. package/src/__tests__/ui-access.test.ts +30 -0
  73. package/src/bin/host.ts +30 -20
  74. package/src/bin/nat-output.ts +12 -1
  75. package/src/bin/ui-access.ts +50 -0
  76. package/src/hosted/__tests__/fake-orchestrator.test.ts +0 -1
  77. package/src/hosted/__tests__/fake-orchestrator.ts +263 -265
  78. package/src/hosted/__tests__/hosted-node-service.test.ts +708 -609
  79. package/src/hosted/__tests__/hosted-node-supervisor.test.ts +475 -474
  80. package/src/hosted/claim-details.ts +44 -37
  81. package/src/hosted/hosted-node-service.ts +23 -4
  82. package/src/hosted/hosted-node-watcher.ts +288 -250
  83. package/src/hosted/node-status.ts +31 -5
  84. package/src/hosted/types.ts +160 -153
  85. package/src/installer/__tests__/installer.smoke.test.ts +184 -182
  86. package/src/installer/__tests__/wizard.test.ts +76 -56
  87. package/src/installer/config.ts +66 -0
  88. package/src/installer/index.ts +252 -259
  89. package/src/installer/wizard.ts +101 -87
  90. package/src/nat/__tests__/nat-service.test.ts +608 -540
  91. package/src/nat/__tests__/nat-store.test.ts +130 -127
  92. package/src/nat/__tests__/network-mode.test.ts +38 -0
  93. package/src/nat/index.ts +91 -87
  94. package/src/nat/nat-service.ts +79 -26
  95. package/src/nat/nat-store.ts +249 -237
  96. package/src/nat/network-mode.ts +36 -0
  97. package/src/nat/reachability.ts +155 -143
  98. package/src/nat/types.ts +232 -209
  99. package/src/orchestrator/host-process-orchestrator.ts +1089 -1098
  100. package/src/package-version.ts +4 -0
  101. package/src/server/__tests__/fakes.ts +41 -40
  102. package/src/server/__tests__/hosted-nodes-route.test.ts +161 -160
  103. package/src/server/__tests__/settings-route.test.ts +31 -0
  104. package/src/server/routes/settings.ts +128 -115
  105. package/src/server/routes/status.ts +72 -102
  106. package/dist/ui/assets/index-4FWuU8ye.js +0 -19
  107. package/dist/ui/assets/index-Di9Q4d01.css +0 -1
@@ -1,250 +1,288 @@
1
- import debug from 'debug';
2
-
3
- import { decodeCadreInvitation } from '@serfab/cadre-core';
4
-
5
- import type { NodeStateListener } from '../orchestrator/types.js';
6
- import type { HostedNodeStore } from './hosted-node-store.js';
7
- import { errorMessage, readNodeStatus, reportsClaim, type NodeStatus } from './node-status.js';
8
- import type { HostedNode, HostedNodeChange, HostedNodeStatus } from './types.js';
9
-
10
- const log = debug('cadre:host:hosted-node-watcher');
11
-
12
- /** How often an unclaimed node's `/status` is read for the claim, and a joining node's for its redemption. */
13
- export const HOSTED_NODE_CLAIM_POLL_MS = 2_000;
14
-
15
- /** How often a joined node's `/status` is read for its connection count. */
16
- export const HOSTED_NODE_CONNECTED_POLL_MS = 15_000;
17
-
18
- /**
19
- * The orchestrator surface the watcher needs: the exit/start signal, when the orchestrator
20
- * has one, and the stop of a node whose invitation failed.
21
- */
22
- export interface WatchedOrchestrator {
23
- onStateChange?(listener: NodeStateListener): () => void;
24
- stopContainer(dockerId: string): Promise<void>;
25
- }
26
-
27
- /** Statuses the watcher reads every tick: a node still on its way into its cadre. */
28
- const PENDING_STATUSES: ReadonlySet<HostedNodeStatus> = new Set<HostedNodeStatus>(['unclaimed', 'joining']);
29
-
30
- export interface HostedNodeWatcherOptions {
31
- store: HostedNodeStore;
32
- orchestrator: WatchedOrchestrator;
33
- now: () => Date;
34
- /** Where a claim or a liveness change is reported (`HostedNodeService.emit`). */
35
- emit: (change: HostedNodeChange) => void;
36
- }
37
-
38
- /**
39
- * Follows each hosted node's `/status`: an `unclaimed` record becomes `joined` when
40
- * the node reports a finished claim; a `joining` record becomes `joined` when a member
41
- * accepted its invitation, or `error` when the redemption failed; and a `joined`
42
- * record's `connected` follows the node's control-connection count. Best-effort: a poll
43
- * that fails is retried on the next tick, and nothing here throws out of the timer.
44
- *
45
- * One timer at the claim cadence drives all three: every tick polls every unclaimed and
46
- * joining node, and each joined node every {@link HOSTED_NODE_CONNECTED_POLL_MS}, or at once when it
47
- * was poked — the orchestrator's state change on a respawn pokes the node, and the
48
- * poke stands until a poll succeeds, so a respawned child is noticed on its first
49
- * answer rather than at the next 15 s mark. Polls are serialized on one promise
50
- * tail so a timer pass and a poked pass cannot both write one record.
51
- *
52
- * NOTE: the 2 s timer ticks while nothing is unclaimed (one cached store read, no
53
- * I/O per tick); if it ever shows up in a profile, stop it when no record is
54
- * unclaimed and restart it from `poke`.
55
- */
56
- export class HostedNodeWatcher {
57
- private readonly store: HostedNodeStore;
58
- private readonly orchestrator: WatchedOrchestrator;
59
- private readonly now: () => Date;
60
- private readonly emit: (change: HostedNodeChange) => void;
61
-
62
- private tail: Promise<void> = Promise.resolve();
63
- private timer?: ReturnType<typeof setInterval>;
64
- private unsubscribe?: () => void;
65
- /** When each joined node's `/status` last answered (ms since epoch). */
66
- private readonly lastPolledAt = new Map<string, number>();
67
- /** Joined nodes to poll on the next pass regardless of cadence. */
68
- private readonly due = new Set<string>();
69
-
70
- constructor(opts: HostedNodeWatcherOptions) {
71
- this.store = opts.store;
72
- this.orchestrator = opts.orchestrator;
73
- this.now = opts.now;
74
- this.emit = opts.emit;
75
- }
76
-
77
- /** Start the timer and the state-change subscription, and poll once. Idempotent. */
78
- start(): void {
79
- if (this.timer) return;
80
- this.unsubscribe = this.orchestrator.onStateChange?.((info) => { this.poke(info.id); });
81
- this.timer = setInterval(() => { this.poke(); }, HOSTED_NODE_CLAIM_POLL_MS);
82
- this.timer.unref();
83
- this.poke();
84
- }
85
-
86
- stop(): void {
87
- if (this.timer) {
88
- clearInterval(this.timer);
89
- this.timer = undefined;
90
- }
91
- this.unsubscribe?.();
92
- this.unsubscribe = undefined;
93
- }
94
-
95
- /** Ask for a pass now; `id` names a joined node to poll ahead of its cadence. Fire-and-forget. */
96
- poke(id?: string): void {
97
- if (id) this.due.add(id);
98
- void this.poll().catch((err) => { log('status poll failed: %s', errorMessage(err)); });
99
- }
100
-
101
- /** One pass over every unclaimed, joining and due joined record, serialized against the others. */
102
- poll(): Promise<void> {
103
- const next = this.tail.then(() => this.pollOnce(), () => this.pollOnce());
104
- this.tail = next.then(() => undefined, () => undefined);
105
- return next;
106
- }
107
-
108
- private async pollOnce(): Promise<void> {
109
- let records: HostedNode[];
110
- try {
111
- records = this.store.list().filter((n) => PENDING_STATUSES.has(n.status) || n.status === 'joined');
112
- } catch (err) {
113
- // A malformed hosted-nodes.json throws on every load; log rather than let it escape the timer.
114
- log('poll could not list hosted nodes: %s', errorMessage(err));
115
- return;
116
- }
117
- this.forgetRemoved(new Set(records.map((r) => r.id)));
118
- for (const record of records) {
119
- if (this.shouldPoll(record)) await this.pollOne(record);
120
- }
121
- }
122
-
123
- /** Drop the cadence and poke bookkeeping of nodes no longer on record, so neither map grows with removals. */
124
- private forgetRemoved(live: Set<string>): void {
125
- for (const id of this.lastPolledAt.keys()) if (!live.has(id)) this.lastPolledAt.delete(id);
126
- for (const id of this.due) if (!live.has(id)) this.due.delete(id);
127
- }
128
-
129
- private shouldPoll(record: HostedNode): boolean {
130
- if (PENDING_STATUSES.has(record.status) || this.due.has(record.id)) return true;
131
- const last = this.lastPolledAt.get(record.id);
132
- return last === undefined || this.now().getTime() - last >= HOSTED_NODE_CONNECTED_POLL_MS;
133
- }
134
-
135
- private async pollOne(record: HostedNode): Promise<void> {
136
- if (!record.statusEndpoint) return;
137
- let status: NodeStatus;
138
- try {
139
- status = await readNodeStatus(record.statusEndpoint);
140
- } catch (err) {
141
- log('hosted node %s did not answer /status (retried next tick): %s', record.id, errorMessage(err));
142
- return;
143
- }
144
- this.lastPolledAt.set(record.id, this.now().getTime());
145
- this.due.delete(record.id);
146
-
147
- // Re-read: `record` predates the round-trip and `store.put` replaces the whole
148
- // row, so writing that copy back would resurrect a node removed meanwhile, or
149
- // undo a respawn's new handles. The store is synchronous, so this read-decide-
150
- // write is atomic against the event loop as long as no `await` sneaks in.
151
- const current = this.store.get(record.id);
152
- if (!current || current.status !== record.status) return;
153
- if (current.status === 'unclaimed') {
154
- this.applyUnclaimed(current, status);
155
- } else if (current.status === 'joining') {
156
- await this.applyJoining(current, status);
157
- } else {
158
- this.applyJoined(current, status);
159
- }
160
- }
161
-
162
- /** An unclaimed node that reports a finished claim is `joined`, with the party and owner the claim named. */
163
- private applyUnclaimed(current: HostedNode, status: NodeStatus): void {
164
- if (!reportsClaim(status)) return;
165
- this.store.put({
166
- ...current,
167
- status: 'joined',
168
- partyId: status.node.partyId,
169
- ownerKey: status.node.claimedBy,
170
- peerId: current.peerId ?? status.peerId,
171
- connected: status.node.connectionPaths.total > 0,
172
- updatedAt: this.now().toISOString(),
173
- });
174
- log('hosted node %s was claimed by owner %s into cadre %s', current.id, status.node.claimedBy.slice(0, 8), status.node.partyId);
175
- this.emit({ kind: 'claimed', id: current.id });
176
- }
177
-
178
- /**
179
- * A joining node whose redemption settled: `joined` with the member that admitted it and
180
- * the invitation's issuer as its owner, or `error` with the reason and whether Retry may
181
- * help. A failed node's child is stopped after the write, as a give-up's is, and kept
182
- * with its workdir for Retry; the write comes first so the exit the stop raises finds
183
- * a record the supervisor leaves alone. Still `pending`: nothing.
184
- */
185
- private async applyJoining(current: HostedNode, status: NodeStatus): Promise<void> {
186
- const invitation = status.node.invitation;
187
- if (invitation?.state === 'accepted') {
188
- const ownerKey = issuerOf(current);
189
- this.store.put({
190
- ...current,
191
- status: 'joined',
192
- ...(ownerKey ? { ownerKey } : {}),
193
- ...(invitation.memberPeerId ? { memberPeerId: invitation.memberPeerId } : {}),
194
- peerId: current.peerId ?? status.peerId,
195
- connected: status.node.connectionPaths.total > 0,
196
- updatedAt: this.now().toISOString(),
197
- });
198
- log('hosted node %s joined cadre %s at member %s', current.id, current.partyId, invitation.memberPeerId ?? '(unnamed)');
199
- this.emit({ kind: 'joined', id: current.id });
200
- return;
201
- }
202
- if (invitation?.state !== 'failed') return;
203
- const error = invitation.error ?? 'the invitation could not be redeemed';
204
- this.store.put({
205
- ...current,
206
- status: 'error',
207
- error,
208
- retryable: invitation.retryable === true,
209
- peerId: current.peerId ?? status.peerId,
210
- updatedAt: this.now().toISOString(),
211
- });
212
- log('hosted node %s could not join cadre %s (%s): %s', current.id, current.partyId, invitation.retryable ? 'retryable' : 'final', error);
213
- this.emit({ kind: 'changed', id: current.id });
214
- if (!current.dockerId) return;
215
- try {
216
- await this.orchestrator.stopContainer(current.dockerId);
217
- } catch (err) {
218
- log('failed to stop hosted node %s after its invitation failed: %s', current.id, errorMessage(err));
219
- }
220
- }
221
-
222
- /**
223
- * A joined node's `connected` follows its control-connection count; published only on a
224
- * change. Its `/status` keeps reporting the invitation for as long as the child that
225
- * redeemed it runs, and whatever it says there is ignored: the node is a member, and the
226
- * record says so.
227
- */
228
- private applyJoined(current: HostedNode, status: NodeStatus): void {
229
- const connected = status.node.connectionPaths.total > 0;
230
- if (current.connected === connected) return;
231
- this.store.put({ ...current, connected, updatedAt: this.now().toISOString() });
232
- log('hosted node %s is now %s', current.id, connected ? 'connected' : 'disconnected');
233
- this.emit({ kind: 'changed', id: current.id });
234
- }
235
- }
236
-
237
- /**
238
- * The owner a joined invitation node is shown under: the issuer of its invitation. The
239
- * record's bundle decoded at `join`, so this only fails on a hand-edited
240
- * `hosted-nodes.json`; the node is a member either way, so the record says so without one.
241
- */
242
- function issuerOf(node: HostedNode): string | undefined {
243
- if (node.join.kind !== 'invitation') return undefined;
244
- try {
245
- return decodeCadreInvitation(node.join.encoded).invite.issuerKey;
246
- } catch (err) {
247
- log('hosted node %s carries an invitation that no longer decodes: %s', node.id, errorMessage(err));
248
- return undefined;
249
- }
250
- }
1
+ import debug from 'debug';
2
+
3
+ import { decodeCadreInvitation } from '@serfab/cadre-core';
4
+
5
+ import type { NodeStateListener } from '../orchestrator/types.js';
6
+ import type { HostedNodeStore } from './hosted-node-store.js';
7
+ import { errorMessage, readWatchedNodeStatus, reportsClaim, reportsRemoval, type NodeStatus, type RemovedNodeStatus } from './node-status.js';
8
+ import type { HostedNode, HostedNodeChange, HostedNodeStatus } from './types.js';
9
+
10
+ const log = debug('cadre:host:hosted-node-watcher');
11
+
12
+ /** How often an unclaimed node's `/status` is read for the claim, and a joining node's for its redemption. */
13
+ export const HOSTED_NODE_CLAIM_POLL_MS = 2_000;
14
+
15
+ /** How often a joined node's `/status` is read for its connection count. */
16
+ export const HOSTED_NODE_CONNECTED_POLL_MS = 15_000;
17
+
18
+ /** The `error` a node an owner removed from its cadre is recorded with. */
19
+ const REMOVED_BY_OWNER_ERROR = 'Removed from its cadre by an owner';
20
+
21
+ /**
22
+ * The orchestrator surface the watcher needs: the exit/start signal, when the orchestrator
23
+ * has one, and the stop of a node whose invitation failed or that an owner removed.
24
+ */
25
+ export interface WatchedOrchestrator {
26
+ onStateChange?(listener: NodeStateListener): () => void;
27
+ stopContainer(dockerId: string): Promise<void>;
28
+ }
29
+
30
+ /** Statuses the watcher reads every tick: a node still on its way into its cadre. */
31
+ const PENDING_STATUSES: ReadonlySet<HostedNodeStatus> = new Set<HostedNodeStatus>(['unclaimed', 'joining']);
32
+
33
+ export interface HostedNodeWatcherOptions {
34
+ store: HostedNodeStore;
35
+ orchestrator: WatchedOrchestrator;
36
+ now: () => Date;
37
+ /** Where a claim or a liveness change is reported (`HostedNodeService.emit`). */
38
+ emit: (change: HostedNodeChange) => void;
39
+ }
40
+
41
+ /**
42
+ * Follows each hosted node's `/status`: an `unclaimed` record becomes `joined` when
43
+ * the node reports a finished claim; a `joining` record becomes `joined` when a member
44
+ * accepted its invitation, or `error` when the redemption failed; a `joined`
45
+ * record's `connected` follows the node's control-connection count; and any of them
46
+ * becomes `error` when the node reports that an owner removed it from its cadre.
47
+ * Best-effort: a poll that fails is retried on the next tick, and nothing here throws
48
+ * out of the timer.
49
+ *
50
+ * One timer at the claim cadence drives all three: every tick polls every unclaimed and
51
+ * joining node, and each joined node every {@link HOSTED_NODE_CONNECTED_POLL_MS}, or at once when it
52
+ * was poked — the orchestrator's state change on a respawn pokes the node, and the
53
+ * poke stands until a poll succeeds, so a respawned child is noticed on its first
54
+ * answer rather than at the next 15 s mark. Polls are serialized on one promise
55
+ * tail so a timer pass and a poked pass cannot both write one record.
56
+ *
57
+ * NOTE: the 2 s timer ticks while nothing is unclaimed (one cached store read, no
58
+ * I/O per tick); if it ever shows up in a profile, stop it when no record is
59
+ * unclaimed and restart it from `poke`.
60
+ */
61
+ export class HostedNodeWatcher {
62
+ private readonly store: HostedNodeStore;
63
+ private readonly orchestrator: WatchedOrchestrator;
64
+ private readonly now: () => Date;
65
+ private readonly emit: (change: HostedNodeChange) => void;
66
+
67
+ private tail: Promise<void> = Promise.resolve();
68
+ private timer?: ReturnType<typeof setInterval>;
69
+ private unsubscribe?: () => void;
70
+ /** When each joined node's `/status` last answered (ms since epoch). */
71
+ private readonly lastPolledAt = new Map<string, number>();
72
+ /** Joined nodes to poll on the next pass regardless of cadence. */
73
+ private readonly due = new Set<string>();
74
+
75
+ constructor(opts: HostedNodeWatcherOptions) {
76
+ this.store = opts.store;
77
+ this.orchestrator = opts.orchestrator;
78
+ this.now = opts.now;
79
+ this.emit = opts.emit;
80
+ }
81
+
82
+ /** Start the timer and the state-change subscription, and poll once. Idempotent. */
83
+ start(): void {
84
+ if (this.timer) return;
85
+ this.unsubscribe = this.orchestrator.onStateChange?.((info) => { this.poke(info.id); });
86
+ this.timer = setInterval(() => { this.poke(); }, HOSTED_NODE_CLAIM_POLL_MS);
87
+ this.timer.unref();
88
+ this.poke();
89
+ }
90
+
91
+ stop(): void {
92
+ if (this.timer) {
93
+ clearInterval(this.timer);
94
+ this.timer = undefined;
95
+ }
96
+ this.unsubscribe?.();
97
+ this.unsubscribe = undefined;
98
+ }
99
+
100
+ /** Ask for a pass now; `id` names a joined node to poll ahead of its cadence. Fire-and-forget. */
101
+ poke(id?: string): void {
102
+ if (id) this.due.add(id);
103
+ void this.poll().catch((err) => { log('status poll failed: %s', errorMessage(err)); });
104
+ }
105
+
106
+ /** One pass over every unclaimed, joining and due joined record, serialized against the others. */
107
+ poll(): Promise<void> {
108
+ const next = this.tail.then(() => this.pollOnce(), () => this.pollOnce());
109
+ this.tail = next.then(() => undefined, () => undefined);
110
+ return next;
111
+ }
112
+
113
+ private async pollOnce(): Promise<void> {
114
+ let records: HostedNode[];
115
+ try {
116
+ records = this.store.list().filter((n) => PENDING_STATUSES.has(n.status) || n.status === 'joined');
117
+ } catch (err) {
118
+ // A malformed hosted-nodes.json throws on every load; log rather than let it escape the timer.
119
+ log('poll could not list hosted nodes: %s', errorMessage(err));
120
+ return;
121
+ }
122
+ this.forgetRemoved(new Set(records.map((r) => r.id)));
123
+ for (const record of records) {
124
+ if (this.shouldPoll(record)) await this.pollOne(record);
125
+ }
126
+ }
127
+
128
+ /** Drop the cadence and poke bookkeeping of nodes no longer on record, so neither map grows with removals. */
129
+ private forgetRemoved(live: Set<string>): void {
130
+ for (const id of this.lastPolledAt.keys()) if (!live.has(id)) this.lastPolledAt.delete(id);
131
+ for (const id of this.due) if (!live.has(id)) this.due.delete(id);
132
+ }
133
+
134
+ private shouldPoll(record: HostedNode): boolean {
135
+ if (PENDING_STATUSES.has(record.status) || this.due.has(record.id)) return true;
136
+ const last = this.lastPolledAt.get(record.id);
137
+ return last === undefined || this.now().getTime() - last >= HOSTED_NODE_CONNECTED_POLL_MS;
138
+ }
139
+
140
+ private async pollOne(record: HostedNode): Promise<void> {
141
+ if (!record.statusEndpoint) return;
142
+ let status: NodeStatus | RemovedNodeStatus;
143
+ try {
144
+ status = await readWatchedNodeStatus(record.statusEndpoint);
145
+ } catch (err) {
146
+ log('hosted node %s did not answer /status (retried next tick): %s', record.id, errorMessage(err));
147
+ return;
148
+ }
149
+ this.lastPolledAt.set(record.id, this.now().getTime());
150
+ this.due.delete(record.id);
151
+
152
+ // Re-read: `record` predates the round-trip and `store.put` replaces the whole
153
+ // row, so writing that copy back would resurrect a node removed meanwhile, or
154
+ // undo a respawn's new handles. The store is synchronous, so this read-decide-
155
+ // write is atomic against the event loop as long as no `await` sneaks in.
156
+ const current = this.store.get(record.id);
157
+ if (!current || current.status !== record.status) return;
158
+ if (reportsRemoval(status)) {
159
+ await this.applyRemoved(current, status);
160
+ } else if (current.status === 'unclaimed') {
161
+ this.applyUnclaimed(current, status);
162
+ } else if (current.status === 'joining') {
163
+ await this.applyJoining(current, status);
164
+ } else {
165
+ this.applyJoined(current, status);
166
+ }
167
+ }
168
+
169
+ /** An unclaimed node that reports a finished claim is `joined`, with the party and owner the claim named. */
170
+ private applyUnclaimed(current: HostedNode, status: NodeStatus): void {
171
+ if (!reportsClaim(status)) return;
172
+ this.store.put({
173
+ ...current,
174
+ status: 'joined',
175
+ partyId: status.node.partyId,
176
+ ownerKey: status.node.claimedBy,
177
+ peerId: current.peerId ?? status.peerId,
178
+ connected: status.node.connectionPaths.total > 0,
179
+ updatedAt: this.now().toISOString(),
180
+ });
181
+ log('hosted node %s was claimed by owner %s into cadre %s', current.id, status.node.claimedBy.slice(0, 8), status.node.partyId);
182
+ this.emit({ kind: 'claimed', id: current.id });
183
+ }
184
+
185
+ /**
186
+ * A joining node whose redemption settled: `joined` with the member that admitted it and
187
+ * the invitation's issuer as its owner, or `error` with the reason and whether Retry may
188
+ * help. A failed node's child is stopped after the write, as a give-up's is, and kept
189
+ * with its workdir for Retry; the write comes first so the exit the stop raises finds
190
+ * a record the supervisor leaves alone. Still `pending`: nothing.
191
+ */
192
+ private async applyJoining(current: HostedNode, status: NodeStatus): Promise<void> {
193
+ const invitation = status.node.invitation;
194
+ if (invitation?.state === 'accepted') {
195
+ const ownerKey = issuerOf(current);
196
+ this.store.put({
197
+ ...current,
198
+ status: 'joined',
199
+ ...(ownerKey ? { ownerKey } : {}),
200
+ ...(invitation.memberPeerId ? { memberPeerId: invitation.memberPeerId } : {}),
201
+ peerId: current.peerId ?? status.peerId,
202
+ connected: status.node.connectionPaths.total > 0,
203
+ updatedAt: this.now().toISOString(),
204
+ });
205
+ log('hosted node %s joined cadre %s at member %s', current.id, current.partyId, invitation.memberPeerId ?? '(unnamed)');
206
+ this.emit({ kind: 'joined', id: current.id });
207
+ return;
208
+ }
209
+ if (invitation?.state !== 'failed') return;
210
+ const error = invitation.error ?? 'the invitation could not be redeemed';
211
+ this.store.put({
212
+ ...current,
213
+ status: 'error',
214
+ error,
215
+ retryable: invitation.retryable === true,
216
+ peerId: current.peerId ?? status.peerId,
217
+ updatedAt: this.now().toISOString(),
218
+ });
219
+ log('hosted node %s could not join cadre %s (%s): %s', current.id, current.partyId, invitation.retryable ? 'retryable' : 'final', error);
220
+ this.emit({ kind: 'changed', id: current.id });
221
+ await this.stopChild(current, 'its invitation failed');
222
+ }
223
+
224
+ /**
225
+ * A node that reports an owner removed it from its cadre: `error`, not retryable, its child
226
+ * stopped after the write as a failed invitation's is, and its workdir kept for Remove (or a
227
+ * claim node's Reset). The child had already stopped its node, and stays up only to report
228
+ * the removal, so stopping it loses nothing. The party and owner come from the report, for an
229
+ * `unclaimed` record whose claim this watcher never saw: its own party is the placeholder.
230
+ */
231
+ private async applyRemoved(current: HostedNode, status: RemovedNodeStatus): Promise<void> {
232
+ const partyId = status.node.partyId || current.partyId;
233
+ const ownerKey = current.ownerKey ?? status.node.claimedBy;
234
+ this.store.put({
235
+ ...current,
236
+ status: 'error',
237
+ partyId,
238
+ ...(ownerKey ? { ownerKey } : {}),
239
+ error: REMOVED_BY_OWNER_ERROR,
240
+ retryable: false,
241
+ removedByOwner: true,
242
+ updatedAt: this.now().toISOString(),
243
+ });
244
+ log('hosted node %s was removed from cadre %s by an owner', current.id, partyId);
245
+ this.emit({ kind: 'changed', id: current.id });
246
+ await this.stopChild(current, 'an owner removed it');
247
+ }
248
+
249
+ /** Stop a node's child after its record went `error`; a failed stop is logged, the record stands. */
250
+ private async stopChild(current: HostedNode, why: string): Promise<void> {
251
+ if (!current.dockerId) return;
252
+ try {
253
+ await this.orchestrator.stopContainer(current.dockerId);
254
+ } catch (err) {
255
+ log('failed to stop hosted node %s after %s: %s', current.id, why, errorMessage(err));
256
+ }
257
+ }
258
+
259
+ /**
260
+ * A joined node's `connected` follows its control-connection count; published only on a
261
+ * change. Its `/status` keeps reporting the invitation for as long as the child that
262
+ * redeemed it runs, and whatever it says there is ignored: the node is a member, and the
263
+ * record says so.
264
+ */
265
+ private applyJoined(current: HostedNode, status: NodeStatus): void {
266
+ const connected = status.node.connectionPaths.total > 0;
267
+ if (current.connected === connected) return;
268
+ this.store.put({ ...current, connected, updatedAt: this.now().toISOString() });
269
+ log('hosted node %s is now %s', current.id, connected ? 'connected' : 'disconnected');
270
+ this.emit({ kind: 'changed', id: current.id });
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The owner a joined invitation node is shown under: the issuer of its invitation. The
276
+ * record's bundle decoded at `join`, so this fails only on a hand-edited
277
+ * `hosted-nodes.json` or on a record written before invitations carried their
278
+ * `sereus-invite:1.` prefix; the node is a member either way, so the record says so without one.
279
+ */
280
+ function issuerOf(node: HostedNode): string | undefined {
281
+ if (node.join.kind !== 'invitation') return undefined;
282
+ try {
283
+ return decodeCadreInvitation(node.join.encoded).invite.issuerKey;
284
+ } catch (err) {
285
+ log('hosted node %s carries an invitation that no longer decodes: %s', node.id, errorMessage(err));
286
+ return undefined;
287
+ }
288
+ }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Reading a hosted child's `/status` — the one window the host has onto a node it
3
- * runs: its peer id and addresses before the claim, and the claim itself, the party
4
- * and the owner key after it. Shared by `HostedNodeService.claimDetails` and the
5
- * status watcher.
3
+ * runs: its peer id and addresses before the claim, the claim itself, the party
4
+ * and the owner key after it, and its removal by an owner. Shared by
5
+ * `HostedNodeService.claimDetails` and the status watcher.
6
6
  */
7
7
 
8
8
  // The child's `/status` is typed against the CLI's own answer, so a field the CLI
@@ -24,12 +24,36 @@ export function statusUrlFor(healthEndpoint: string): string {
24
24
  /** A `/status` answer from a node that has its peer identity. */
25
25
  export type NodeStatus = HealthStatus & { peerId: string };
26
26
 
27
+ /**
28
+ * A `/status` answer from a child an owner removed from its cadre. The child stopped its node
29
+ * and stays up only to report this, so it has no peer identity.
30
+ */
31
+ export type RemovedNodeStatus = HealthStatus & { node: { removed: NonNullable<HealthStatus['node']['removed']> } };
32
+
27
33
  /**
28
34
  * The child's `/status`, fresh every call. Throws `node_unavailable` while the child
29
35
  * does not answer, answers with an error, or has no peer identity yet — every one of
30
36
  * which a caller retries.
31
37
  */
32
38
  export async function readNodeStatus(statusEndpoint: string): Promise<NodeStatus> {
39
+ return requirePeerIdentity(await fetchNodeStatus(statusEndpoint));
40
+ }
41
+
42
+ /**
43
+ * {@link readNodeStatus} for the watcher, which also has to hear a removal: a child reporting
44
+ * one is answered as it is rather than refused for having no peer identity.
45
+ */
46
+ export async function readWatchedNodeStatus(statusEndpoint: string): Promise<NodeStatus | RemovedNodeStatus> {
47
+ const status = await fetchNodeStatus(statusEndpoint);
48
+ return reportsRemoval(status) ? status : requirePeerIdentity(status);
49
+ }
50
+
51
+ /** Whether the child reports that an owner removed it from its cadre. */
52
+ export function reportsRemoval(status: HealthStatus): status is RemovedNodeStatus {
53
+ return Boolean(status.node.removed);
54
+ }
55
+
56
+ async function fetchNodeStatus(statusEndpoint: string): Promise<HealthStatus> {
33
57
  let res: Response;
34
58
  try {
35
59
  res = await fetch(statusEndpoint);
@@ -39,12 +63,14 @@ export async function readNodeStatus(statusEndpoint: string): Promise<NodeStatus
39
63
  if (!res.ok) {
40
64
  throw new HostedNodeError('node_unavailable', `Hosted node /status returned ${res.status}`);
41
65
  }
42
- let status: HealthStatus;
43
66
  try {
44
- status = (await res.json()) as HealthStatus;
67
+ return (await res.json()) as HealthStatus;
45
68
  } catch (err) {
46
69
  throw new HostedNodeError('node_unavailable', `Hosted node /status is not JSON: ${errorMessage(err)}`);
47
70
  }
71
+ }
72
+
73
+ function requirePeerIdentity(status: HealthStatus): NodeStatus {
48
74
  if (!status.peerId || !status.multiaddrs?.length) {
49
75
  throw new HostedNodeError('node_unavailable', 'Hosted node has no peer identity yet');
50
76
  }