@serfab/cadre-host 1.7.0 → 1.9.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 (90) hide show
  1. package/README.md +31 -16
  2. package/dist/bin/host.js +41 -20
  3. package/dist/bin/host.js.map +1 -1
  4. package/dist/donation/donation-service.d.ts +27 -4
  5. package/dist/donation/donation-service.js +57 -6
  6. package/dist/donation/donation-service.js.map +1 -1
  7. package/dist/donation/donation-store.d.ts +3 -1
  8. package/dist/donation/donation-store.js +5 -1
  9. package/dist/donation/donation-store.js.map +1 -1
  10. package/dist/donation/grant-service.d.ts +16 -7
  11. package/dist/donation/grant-service.js +57 -10
  12. package/dist/donation/grant-service.js.map +1 -1
  13. package/dist/donation/index.d.ts +2 -2
  14. package/dist/donation/index.js +1 -1
  15. package/dist/donation/index.js.map +1 -1
  16. package/dist/donation/types.d.ts +29 -7
  17. package/dist/donation/types.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/nat/address-resolver.d.ts +3 -2
  20. package/dist/nat/address-resolver.js.map +1 -1
  21. package/dist/orchestrator/host-process-orchestrator.d.ts +2 -2
  22. package/dist/orchestrator/host-process-orchestrator.js +22 -12
  23. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  24. package/dist/orchestrator/node-identity.d.ts +1 -1
  25. package/dist/orchestrator/node-identity.js +1 -1
  26. package/dist/orchestrator/port-allocator.d.ts +6 -39
  27. package/dist/orchestrator/port-allocator.js +9 -89
  28. package/dist/orchestrator/port-allocator.js.map +1 -1
  29. package/dist/server/events/types.d.ts +3 -0
  30. package/dist/server/index.d.ts +19 -18
  31. package/dist/server/index.js +13 -15
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/routes/grants-admin.d.ts +15 -3
  34. package/dist/server/routes/grants-admin.js +33 -7
  35. package/dist/server/routes/grants-admin.js.map +1 -1
  36. package/dist/server/routes/nodes.d.ts +13 -5
  37. package/dist/server/routes/nodes.js +58 -39
  38. package/dist/server/routes/nodes.js.map +1 -1
  39. package/dist/server/routes/status.d.ts +13 -0
  40. package/dist/server/routes/status.js +2 -0
  41. package/dist/server/routes/status.js.map +1 -1
  42. package/dist/ui/assets/index-C6nELNfC.js +23 -0
  43. package/dist/ui/assets/index-C6nELNfC.js.map +1 -0
  44. package/dist/ui/assets/index-TtIYSeZO.css +1 -0
  45. package/dist/ui/index.html +2 -2
  46. package/package.json +4 -4
  47. package/src/__tests__/global-setup.ts +58 -56
  48. package/src/__tests__/orchestrator-env-scrub.test.ts +12 -11
  49. package/src/__tests__/orchestrator-handle-contract.ts +134 -0
  50. package/src/__tests__/orchestrator-node-identity.test.ts +6 -7
  51. package/src/__tests__/orchestrator-owner.test.ts +6 -13
  52. package/src/__tests__/orchestrator-pin-keys.test.ts +6 -7
  53. package/src/__tests__/orchestrator-ports.test.ts +5 -34
  54. package/src/__tests__/orchestrator-push.test.ts +6 -5
  55. package/src/__tests__/orchestrator-teardown.ts +65 -0
  56. package/src/__tests__/orchestrator.test.ts +23 -22
  57. package/src/auth/__tests__/trust-circle-integration.test.ts +1 -1
  58. package/src/bin/host.ts +56 -33
  59. package/src/donation/__tests__/donation-service.test.ts +38 -0
  60. package/src/donation/__tests__/fake-orchestrator.test.ts +19 -14
  61. package/src/donation/__tests__/fake-orchestrator.ts +21 -3
  62. package/src/donation/__tests__/grant-service.test.ts +2 -2
  63. package/src/donation/donation-service.ts +58 -6
  64. package/src/donation/donation-store.ts +176 -171
  65. package/src/donation/grant-service.ts +252 -200
  66. package/src/donation/index.ts +65 -64
  67. package/src/donation/types.ts +260 -244
  68. package/src/index.ts +2 -0
  69. package/src/nat/address-resolver.ts +3 -2
  70. package/src/orchestrator/host-process-orchestrator.ts +1260 -1251
  71. package/src/orchestrator/node-identity.ts +1 -1
  72. package/src/orchestrator/port-allocator.ts +16 -91
  73. package/src/server/__tests__/fakes.ts +44 -0
  74. package/src/server/__tests__/grants-admin-route.test.ts +171 -73
  75. package/src/server/__tests__/nodes-route.test.ts +49 -25
  76. package/src/server/__tests__/publishers.test.ts +5 -41
  77. package/src/server/__tests__/server.smoke.test.ts +7 -37
  78. package/src/server/__tests__/status-route.test.ts +8 -21
  79. package/src/server/__tests__/strands-route.test.ts +3 -2
  80. package/src/server/events/types.ts +24 -23
  81. package/src/server/index.ts +267 -267
  82. package/src/server/routes/grants-admin.ts +74 -41
  83. package/src/server/routes/nodes.ts +61 -43
  84. package/src/server/routes/status.ts +16 -0
  85. package/dist/donation/__tests__/fake-orchestrator.d.ts +0 -103
  86. package/dist/donation/__tests__/fake-orchestrator.js +0 -180
  87. package/dist/donation/__tests__/fake-orchestrator.js.map +0 -1
  88. package/dist/ui/assets/index-BVoqLjyX.css +0 -1
  89. package/dist/ui/assets/index-CEoNRwM_.js +0 -17
  90. package/dist/ui/assets/index-CEoNRwM_.js.map +0 -1
@@ -1,41 +1,74 @@
1
- /**
2
- * Donation grant **admin** HTTP routes — thin adapters around
3
- * `GrantAdminHandlers` (`createGrantAdminHandlers(service)`).
4
- *
5
- * Mount path: `/grants-admin` — the admin surface the `cadre-host grant` CLI
6
- * targets. It is **loopback, no bearer**: same-machine admin, matching
7
- * cadre-host's local-UI "no login" posture (see docs/cadre-host.md §
8
- * Security posture). This is distinct from the grantee-facing `/grants`
9
- * provisioning surface that carries the bearer gate — that arrives in the
10
- * `2-donation-service` ticket.
11
- */
12
-
13
- import type { FastifyInstance } from 'fastify';
14
-
15
- import type { GrantAdminHandlers } from '../../donation/types.js';
16
-
17
- export interface GrantsAdminRoutesOptions {
18
- handlers: GrantAdminHandlers;
19
- }
20
-
21
- export function registerGrantsAdminRoutes(app: FastifyInstance, opts: GrantsAdminRoutesOptions): void {
22
- const { handlers } = opts;
23
-
24
- app.get('/grants-admin', async () => {
25
- return handlers.listGrants();
26
- });
27
-
28
- app.post('/grants-admin', async (request) => {
29
- const body = (request.body ?? {}) as { label?: unknown; maxNodes?: unknown; ttlMs?: unknown };
30
- const label = typeof body.label === 'string' ? body.label : '';
31
- const args: { label: string; maxNodes?: number; ttlMs?: number } = { label };
32
- if (typeof body.maxNodes === 'number') args.maxNodes = body.maxNodes;
33
- if (typeof body.ttlMs === 'number') args.ttlMs = body.ttlMs;
34
- return handlers.postGrant(args);
35
- });
36
-
37
- app.delete<{ Params: { token: string } }>('/grants-admin/:token', async (request) => {
38
- await handlers.deleteGrant(request.params.token);
39
- return { ok: true };
40
- });
41
- }
1
+ /**
2
+ * Donation grant **admin** HTTP routes — thin adapters around
3
+ * `GrantAdminHandlers` (`createGrantAdminHandlers(service, donations)`).
4
+ *
5
+ * Mount path: `/grants-admin` — the admin surface the `cadre-host grant` CLI
6
+ * targets. It is **loopback, no bearer**: same-machine admin, matching
7
+ * cadre-host's local-UI "no login" posture (see docs/cadre-host.md §
8
+ * Security posture). This is distinct from the grantee-facing `/grants`
9
+ * provisioning surface (`routes/grants.ts`), which carries the bearer gate.
10
+ *
11
+ * Clients: the `cadre-host grant` CLI and the local UI's Grants page. The list
12
+ * reports each grant's live-node count and the donations a revoke would end.
13
+ *
14
+ * Revoking a grant also terminates the nodes donated under it unless the
15
+ * caller passes `?keepNodes=true`; `DELETE /grants-admin/donations/:id` ends a
16
+ * single donated node. Those are the host's only teardown paths once a grant is
17
+ * revoked — the grantee's own `DELETE /grants/:id` is refused from then on.
18
+ *
19
+ * Each successful mutation publishes a `grants-changed` bus event, so an open
20
+ * Grants page refreshes when the CLI acts.
21
+ */
22
+
23
+ import type { FastifyInstance } from 'fastify';
24
+
25
+ import type { GrantAdminHandlers } from '../../donation/types.js';
26
+ import type { EventBus } from '../events/bus.js';
27
+
28
+ export interface GrantsAdminRoutesOptions {
29
+ handlers: GrantAdminHandlers;
30
+ events: EventBus;
31
+ }
32
+
33
+ export function registerGrantsAdminRoutes(app: FastifyInstance, opts: GrantsAdminRoutesOptions): void {
34
+ const { handlers, events } = opts;
35
+
36
+ app.get('/grants-admin', async () => {
37
+ return handlers.listGrants();
38
+ });
39
+
40
+ app.post('/grants-admin', async (request) => {
41
+ const body = (request.body ?? {}) as { label?: unknown; maxNodes?: unknown; ttlMs?: unknown };
42
+ const label = typeof body.label === 'string' ? body.label : '';
43
+ const args: { label: string; maxNodes?: number; ttlMs?: number } = { label };
44
+ if (typeof body.maxNodes === 'number') args.maxNodes = body.maxNodes;
45
+ if (typeof body.ttlMs === 'number') args.ttlMs = body.ttlMs;
46
+ const result = await handlers.postGrant(args);
47
+ events.publish({ type: 'grants-changed', kind: 'issued' });
48
+ return result;
49
+ });
50
+
51
+ app.delete<{ Params: { token: string }; Querystring: { keepNodes?: string } }>(
52
+ '/grants-admin/:token',
53
+ async (request) => {
54
+ const keepNodes = isTrueFlag(request.query.keepNodes);
55
+ const { terminated } = await handlers.deleteGrant(request.params.token, { keepNodes });
56
+ events.publish({ type: 'grants-changed', kind: 'revoked' });
57
+ return { ok: true, terminated };
58
+ },
59
+ );
60
+
61
+ // Two path segments past `/grants-admin`, so it never collides with `/:token`.
62
+ const { terminateDonation } = handlers;
63
+ if (terminateDonation) {
64
+ app.delete<{ Params: { id: string } }>('/grants-admin/donations/:id', async (request) => {
65
+ await terminateDonation(request.params.id);
66
+ events.publish({ type: 'grants-changed', kind: 'terminated' });
67
+ return { ok: true };
68
+ });
69
+ }
70
+ }
71
+
72
+ function isTrueFlag(value: string | undefined): boolean {
73
+ return value === 'true' || value === '1';
74
+ }
@@ -6,29 +6,38 @@
6
6
  * `onStateChange` (forwarded by `createLocalUiServer.start`) — the route
7
7
  * handlers don't re-publish.
8
8
  *
9
- * start/restart are real for the admin's owner node (re-spawned from the
10
- * persisted `OwnerSpawnConfig`). Generic node lifecycle is owned by the
11
- * grantee-facing donation surface (`/grants`) — this route no longer spawns
12
- * generic nodes; start/restart of a known non-owner node points the caller
13
- * there. Stop works for any running node.
9
+ * start/stop/restart are real for the admin's owner node only (start/restart
10
+ * re-spawn it from the persisted `OwnerSpawnConfig`). Every other node this
11
+ * route can see is a donated node, whose lifecycle belongs to the donation
12
+ * surface — this route no longer spawns generic nodes, and a stop here would
13
+ * be undone at once by the donation supervisor's respawn. So all three verbs
14
+ * answer a known non-owner node with 501, pointing at the donation teardown.
15
+ *
16
+ * Owner start/restart are founder-only: a donor-only host that once ran as a
17
+ * founder still holds the owner handle and saved config (so re-enabling
18
+ * `ownCadre` can resume it), but spawning it here would bring the owner node
19
+ * back without its trust circle, NAT or strand services — so those answer 409.
14
20
  */
15
21
 
16
22
  import { existsSync, openSync, readSync, closeSync, statSync } from 'node:fs';
17
23
 
18
- import type { FastifyInstance } from 'fastify';
24
+ import type { FastifyInstance, FastifyReply } from 'fastify';
19
25
 
20
26
  import type { HostProcessOrchestrator } from '../../orchestrator/index.js';
21
27
  import { defaultLogPath } from '../../orchestrator/log-rotator.js';
22
28
 
29
+ import type { HostRole } from './status.js';
30
+
23
31
  const DEFAULT_LOG_LINES = 200;
24
32
  const MAX_LOG_LINES = 2000;
25
33
 
26
34
  export interface NodesRoutesOptions {
27
35
  orchestrator: HostProcessOrchestrator;
36
+ role: HostRole;
28
37
  }
29
38
 
30
39
  export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptions): void {
31
- const { orchestrator } = opts;
40
+ const { orchestrator, role } = opts;
32
41
 
33
42
  app.get('/api/nodes', async () => {
34
43
  return { ok: true, data: { nodes: orchestrator.listNodes() } };
@@ -36,12 +45,7 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
36
45
 
37
46
  app.get<{ Params: { id: string } }>('/api/nodes/:id', async (request, reply) => {
38
47
  const node = orchestrator.getNode(request.params.id);
39
- if (!node) {
40
- return reply.code(404).send({
41
- ok: false,
42
- error: { code: 'not_found', message: `Unknown node: ${request.params.id}` },
43
- });
44
- }
48
+ if (!node) return notFound(reply, request.params.id);
45
49
  let stats = null;
46
50
  try {
47
51
  stats = await orchestrator.getStats(node.dockerId);
@@ -55,12 +59,7 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
55
59
  '/api/nodes/:id/logs',
56
60
  async (request, reply) => {
57
61
  const node = orchestrator.getNode(request.params.id);
58
- if (!node) {
59
- return reply.code(404).send({
60
- ok: false,
61
- error: { code: 'not_found', message: `Unknown node: ${request.params.id}` },
62
- });
63
- }
62
+ if (!node) return notFound(reply, request.params.id);
64
63
  const requested = Number(request.query.lines ?? DEFAULT_LOG_LINES);
65
64
  const tail = Number.isFinite(requested)
66
65
  ? Math.max(1, Math.min(MAX_LOG_LINES, Math.floor(requested)))
@@ -70,72 +69,91 @@ export function registerNodesRoutes(app: FastifyInstance, opts: NodesRoutesOptio
70
69
  },
71
70
  );
72
71
 
72
+ // start / stop / restart: real for the admin's owner node (started from the
73
+ // persisted OwnerSpawnConfig). Donated-node lifecycle belongs to the donation
74
+ // surface — those ids return a clear not_implemented that points there;
75
+ // unknown ids 404.
73
76
  app.post<{ Params: { id: string } }>('/api/nodes/:id/stop', async (request, reply) => {
74
- const dockerId = orchestrator.resolveDockerId(request.params.id);
75
- if (!dockerId) {
76
- return reply.code(404).send({
77
- ok: false,
78
- error: { code: 'not_found', message: `Unknown node: ${request.params.id}` },
79
- });
77
+ const { id } = request.params;
78
+ if (!orchestrator.isOwnerNode(id)) {
79
+ return ownerOnlyFallback(reply, orchestrator, id, 'stop');
80
80
  }
81
+ const dockerId = orchestrator.resolveDockerId(id);
82
+ if (!dockerId) return notFound(reply, id);
81
83
  await orchestrator.stopContainer(dockerId);
82
84
  // The orchestrator emits its own onStateChange — createLocalUiServer
83
85
  // forwards that to the bus. Don't double-publish here.
84
86
  return { ok: true };
85
87
  });
86
88
 
87
- // start / restart: real for the admin's owner node (spawned from the
88
- // persisted OwnerSpawnConfig). Generic node lifecycle now belongs to the
89
- // donation surface (/grants) — those ids return a clear not_implemented that
90
- // points there; unknown ids 404.
91
89
  app.post<{ Params: { id: string } }>('/api/nodes/:id/start', async (request, reply) => {
92
90
  const { id } = request.params;
93
91
  if (orchestrator.isOwnerNode(id)) {
92
+ if (role === 'donor') return ownCadreDisabled(reply, 'start');
94
93
  if (!orchestrator.hasOwnerConfig()) {
95
94
  return notImplemented(reply, `start ${id}: owner node has no saved spawn config.`);
96
95
  }
97
96
  const node = await orchestrator.ensureOwnerNode();
98
97
  return { ok: true, data: { node } };
99
98
  }
100
- return startRestartFallback(reply, orchestrator, id, 'start');
99
+ return ownerOnlyFallback(reply, orchestrator, id, 'start');
101
100
  });
102
101
 
103
102
  app.post<{ Params: { id: string } }>('/api/nodes/:id/restart', async (request, reply) => {
104
103
  const { id } = request.params;
105
104
  if (orchestrator.isOwnerNode(id)) {
105
+ if (role === 'donor') return ownCadreDisabled(reply, 'restart');
106
106
  if (!orchestrator.hasOwnerConfig()) {
107
107
  return notImplemented(reply, `restart ${id}: owner node has no saved spawn config.`);
108
108
  }
109
109
  const node = await orchestrator.restartOwnerNode();
110
110
  return { ok: true, data: { node } };
111
111
  }
112
- return startRestartFallback(reply, orchestrator, id, 'restart');
112
+ return ownerOnlyFallback(reply, orchestrator, id, 'restart');
113
113
  });
114
114
  }
115
115
 
116
- /** 404 for unknown ids; 501 not_implemented for known non-owner nodes. */
117
- function startRestartFallback(
118
- reply: import('fastify').FastifyReply,
116
+ /**
117
+ * 404 for unknown ids; 501 not_implemented for known non-owner nodes — all of
118
+ * them donated. A donated node's containerId is its donation id, so the
119
+ * message names `node.id` rather than the route param, which may be the
120
+ * opaque dockerId — only the donation id is what the terminate command takes.
121
+ */
122
+ function ownerOnlyFallback(
123
+ reply: FastifyReply,
119
124
  orchestrator: HostProcessOrchestrator,
120
125
  id: string,
121
- verb: string,
126
+ verb: 'start' | 'stop' | 'restart',
122
127
  ) {
123
- if (!orchestrator.getNode(id)) {
124
- return reply.code(404).send({
125
- ok: false,
126
- error: { code: 'not_found', message: `Unknown node: ${id}` },
127
- });
128
- }
128
+ const node = orchestrator.getNode(id);
129
+ if (!node) return notFound(reply, id);
129
130
  return notImplemented(
130
131
  reply,
131
- `${verb} ${id}: only the owner node is start/restart-able here; generic donated-node lifecycle is owned by the donation surface — use POST /grants to provision and DELETE /grants/:id to terminate.`,
132
+ `${verb} ${id}: only the owner node can be started, stopped or restarted here; a donated node's lifecycle is owned by the donation surface — end it with 'cadre-host grant terminate ${node.id}' (DELETE /grants-admin/donations/${node.id}).`,
132
133
  );
133
134
  }
134
135
 
135
- function notImplemented(reply: import('fastify').FastifyReply, message: string) {
136
+ function notFound(reply: FastifyReply, id: string) {
137
+ return reply.code(404).send({
138
+ ok: false,
139
+ error: { code: 'not_found', message: `Unknown node: ${id}` },
140
+ });
141
+ }
142
+
143
+ function notImplemented(reply: FastifyReply, message: string) {
136
144
  return reply.code(501).send({ ok: false, error: { code: 'not_implemented', message } });
137
145
  }
138
146
 
147
+ function ownCadreDisabled(reply: FastifyReply, verb: 'start' | 'restart') {
148
+ return reply.code(409).send({
149
+ ok: false,
150
+ error: {
151
+ code: 'own_cadre_disabled',
152
+ message: `${verb} owner: this host's own cadre is turned off (donor-only mode). Set ownCadre.enabled in host.config.json and restart cadre-host to run it again.`,
153
+ },
154
+ });
155
+ }
156
+
139
157
  /**
140
158
  * Read the last `n` lines from a log file by walking the tail in chunks.
141
159
  * Returns [] for a missing file.
@@ -20,8 +20,15 @@ import type { UpdateService } from '../../update/index.js';
20
20
 
21
21
  const PROCESS_STARTED_AT = process.hrtime.bigint();
22
22
 
23
+ /**
24
+ * Which role the running process is in: `'founder'` also runs the host's own
25
+ * cadre (`ownCadre.enabled` at start), `'donor'` only donates nodes.
26
+ */
27
+ export type HostRole = 'founder' | 'donor';
28
+
23
29
  export interface StatusRouteOptions {
24
30
  orchestrator: HostProcessOrchestrator;
31
+ role: HostRole;
25
32
  /** Present only when the host runs its own personal cadre (`ownCadre.enabled`). */
26
33
  trustCircle?: TrustCircleService;
27
34
  /** Present only when the host runs its own personal cadre (`ownCadre.enabled`). */
@@ -31,11 +38,18 @@ export interface StatusRouteOptions {
31
38
 
32
39
  export interface StatusResponse {
33
40
  service: { name: 'cadre-host'; version: string; uptimeSeconds: number };
41
+ /**
42
+ * Decided at start from `ownCadre.enabled` — the running process, not the file
43
+ * on disk, which may have been edited since without a restart.
44
+ */
45
+ role: HostRole;
34
46
  nodes: Array<{
35
47
  id: string;
36
48
  partyId: string;
37
49
  status: ManagedNodeInfo['status'];
38
50
  profile: ManagedNodeInfo['profile'];
51
+ /** Present (true) only on the host's own owner node; every other node is donated. */
52
+ owner?: true;
39
53
  }>;
40
54
  /** Omitted in donor-only mode (no host-own trust circle). */
41
55
  trustCircle?: { members: number; pending: number };
@@ -53,6 +67,7 @@ export function registerStatusRoute(app: FastifyInstance, opts: StatusRouteOptio
53
67
  partyId: n.partyId,
54
68
  status: n.status,
55
69
  profile: n.profile,
70
+ ...(n.owner ? { owner: true as const } : {}),
56
71
  }));
57
72
 
58
73
  const response: StatusResponse = {
@@ -61,6 +76,7 @@ export function registerStatusRoute(app: FastifyInstance, opts: StatusRouteOptio
61
76
  version,
62
77
  uptimeSeconds: secondsSince(PROCESS_STARTED_AT),
63
78
  },
79
+ role: opts.role,
64
80
  nodes,
65
81
  };
66
82
 
@@ -1,103 +0,0 @@
1
- /**
2
- * Shared fake orchestrator for the donation unit tests — no real child
3
- * processes. Records every create / stop / remove, hands out deterministic
4
- * unique spawn results, and tracks per-`dockerId` liveness so a test can crash
5
- * one node and let the supervisor observe exactly that one as down.
6
- *
7
- * This file holds no tests of its own; its contract is pinned by the sibling
8
- * `fake-orchestrator.test.ts`, which exists so a future agent cannot make a
9
- * failing donation test go green by relaxing the fake.
10
- */
11
- import type { Orchestrator, OrchestratorCreateRequest, OrchestratorCreateResult, OrchestratorStats } from '@serfab/cadre-provider';
12
- import type { ManagedNodeInfo, NodeStateListener } from '../../orchestrator/types.js';
13
- /**
14
- * Mirrors the parts of `HostProcessOrchestrator`'s handle lifecycle the donation
15
- * code depends on:
16
- *
17
- * - a **successful** `createContainer` drops every prior handle for the same
18
- * `containerId` (the real `dropStaleHandle`), so an old `dockerId` stops
19
- * resolving the moment its container re-spawns;
20
- * - a **failed** `createContainer` leaves those handles exactly as it found them
21
- * (the real `restoreDroppedHandles`);
22
- * - `stopContainer` / `removeContainer` throw `Container not found: <dockerId>`
23
- * for a handle this orchestrator no longer knows (the real `requireHandle`);
24
- * - `reclaimWorkdir` refuses a containerId that still resolves to a live
25
- * handle (the real guard — that directory belongs to `removeContainer`).
26
- *
27
- * Consequently `stopped` / `removed` mean **"handles this orchestrator actually
28
- * acted on"**, not "calls attempted" — a stop aimed at a dropped handle is
29
- * rejected and never appears. `onStop` still fires for those rejected calls, so
30
- * a test can observe the attempt separately from its outcome.
31
- */
32
- export declare class FakeOrchestrator implements Orchestrator {
33
- createCalls: OrchestratorCreateRequest[];
34
- stopped: string[];
35
- removed: string[];
36
- /**
37
- * Container ids whose workdir this orchestrator actually removed. Same
38
- * "acted on, not attempted" rule as `stopped` / `removed`: a refused reclaim
39
- * leaves no entry.
40
- */
41
- reclaimedWorkdirs: string[];
42
- failCreate: boolean;
43
- createDelayMs: number;
44
- /**
45
- * Observation hook — lets a test inspect the world as the stop happens. Fires
46
- * even when the handle is unknown and the stop is then rejected.
47
- */
48
- onStop?: (dockerId: string) => void;
49
- /**
50
- * Observation hook — fires before the spawn resolves or fails, modelling the
51
- * real class's *pre-drop* `await` window (`ensureNodeIdentity`, `resolvePush`).
52
- * Work driven from here sees the previous handle still alive. Contrast
53
- * {@link onSpawned}.
54
- */
55
- onCreate?: (request: OrchestratorCreateRequest) => void;
56
- /**
57
- * Observation hook — fires once the drop has landed and the new child is
58
- * registered, immediately before `createContainer` resolves. Models the
59
- * microtask gap between the real `createContainer` returning and its caller's
60
- * `await` resuming: the real drop → launch → return runs synchronously, so
61
- * this is the earliest point other code can observe the post-drop world.
62
- * Contrast {@link onCreate}.
63
- */
64
- onSpawned?: (dockerId: string) => void;
65
- /** Observation hook — fires before the liveness answer is computed. */
66
- onIsRunning?: (dockerId: string) => void;
67
- private readonly children;
68
- private readonly listeners;
69
- private counter;
70
- createContainer(request: OrchestratorCreateRequest): Promise<OrchestratorCreateResult>;
71
- stopContainer(dockerId: string): Promise<void>;
72
- removeContainer(dockerId: string): Promise<void>;
73
- /**
74
- * NOTE: this and {@link getLogs} take an unknown `dockerId` where the real
75
- * class throws (`requireHandle`). No donation path calls either, so the gap is
76
- * unobservable — route them through `requireChild` if one starts to.
77
- */
78
- getStats(): Promise<OrchestratorStats>;
79
- /** Unknown handles read as not running, matching the real orchestrator. */
80
- isRunning(dockerId: string): Promise<boolean>;
81
- /** Resolve a spawn's friendly containerId back to its dockerId. */
82
- resolveDockerId(containerId: string): string | undefined;
83
- /**
84
- * Remove the workdir of a container no handle owns, mirroring the real
85
- * `reclaimWorkdir`. Refuses (returns `false`, records nothing) while a child
86
- * for that containerId still resolves — that directory belongs to
87
- * `removeContainer`, which stops the child first.
88
- *
89
- * There is no directory here to check for existence, so a first reclaim of an
90
- * unresolvable id always reports `true`; the real class returns `false` when
91
- * the path is already gone. Nothing in the donation layer branches on the
92
- * return value, so the gap is unobservable — model it if one starts to.
93
- */
94
- reclaimWorkdir(containerId: string): boolean;
95
- getLogs(): Promise<string>;
96
- onStateChange(listener: NodeStateListener): () => void;
97
- /** Kill a child out from under the host — the crash the supervisor exists for. */
98
- crash(dockerId: string): void;
99
- /** Emit an arbitrary state change (e.g. the owner node stopping). */
100
- emit(info: ManagedNodeInfo): void;
101
- /** Mirrors `HostProcessOrchestrator.requireHandle` — unknown handles throw. */
102
- private requireChild;
103
- }
@@ -1,180 +0,0 @@
1
- /**
2
- * Shared fake orchestrator for the donation unit tests — no real child
3
- * processes. Records every create / stop / remove, hands out deterministic
4
- * unique spawn results, and tracks per-`dockerId` liveness so a test can crash
5
- * one node and let the supervisor observe exactly that one as down.
6
- *
7
- * This file holds no tests of its own; its contract is pinned by the sibling
8
- * `fake-orchestrator.test.ts`, which exists so a future agent cannot make a
9
- * failing donation test go green by relaxing the fake.
10
- */
11
- function sleep(ms) {
12
- return new Promise((res) => setTimeout(res, ms));
13
- }
14
- /**
15
- * Mirrors the parts of `HostProcessOrchestrator`'s handle lifecycle the donation
16
- * code depends on:
17
- *
18
- * - a **successful** `createContainer` drops every prior handle for the same
19
- * `containerId` (the real `dropStaleHandle`), so an old `dockerId` stops
20
- * resolving the moment its container re-spawns;
21
- * - a **failed** `createContainer` leaves those handles exactly as it found them
22
- * (the real `restoreDroppedHandles`);
23
- * - `stopContainer` / `removeContainer` throw `Container not found: <dockerId>`
24
- * for a handle this orchestrator no longer knows (the real `requireHandle`);
25
- * - `reclaimWorkdir` refuses a containerId that still resolves to a live
26
- * handle (the real guard — that directory belongs to `removeContainer`).
27
- *
28
- * Consequently `stopped` / `removed` mean **"handles this orchestrator actually
29
- * acted on"**, not "calls attempted" — a stop aimed at a dropped handle is
30
- * rejected and never appears. `onStop` still fires for those rejected calls, so
31
- * a test can observe the attempt separately from its outcome.
32
- */
33
- export class FakeOrchestrator {
34
- constructor() {
35
- this.createCalls = [];
36
- this.stopped = [];
37
- this.removed = [];
38
- /**
39
- * Container ids whose workdir this orchestrator actually removed. Same
40
- * "acted on, not attempted" rule as `stopped` / `removed`: a refused reclaim
41
- * leaves no entry.
42
- */
43
- this.reclaimedWorkdirs = [];
44
- this.failCreate = false;
45
- this.createDelayMs = 0;
46
- this.children = new Map();
47
- this.listeners = new Set();
48
- this.counter = 0;
49
- }
50
- async createContainer(request) {
51
- this.createCalls.push(request);
52
- this.onCreate?.(request);
53
- if (this.createDelayMs)
54
- await sleep(this.createDelayMs);
55
- if (this.failCreate)
56
- throw new Error('spawn boom');
57
- // Drop the prior handles for this container, mirroring `dropStaleHandle`.
58
- // Success-only, and placed here on purpose: the real drop happens after the
59
- // spawn's every `await` and cannot fail afterwards, and a create that throws
60
- // puts the handles back (`restoreDroppedHandles`) — so a failed create must
61
- // leave them untouched. `DonationSupervisor`'s give-up test depends on that.
62
- for (const [id, child] of this.children) {
63
- if (child.containerId === request.containerId)
64
- this.children.delete(id);
65
- }
66
- const n = ++this.counter;
67
- const dockerId = `dock_${n}`;
68
- this.children.set(dockerId, {
69
- containerId: request.containerId,
70
- partyId: request.partyId,
71
- profile: request.profile,
72
- running: true,
73
- });
74
- this.onSpawned?.(dockerId);
75
- return {
76
- dockerId,
77
- healthEndpoint: `http://127.0.0.1:${9000 + n}/health`,
78
- metricsEndpoint: `http://127.0.0.1:${9000 + n}/metrics`,
79
- seedEndpoint: `http://127.0.0.1:${9000 + n}/seed`,
80
- seedToken: `seed-token-${n}`,
81
- p2pPort: 4000 + n,
82
- };
83
- }
84
- async stopContainer(dockerId) {
85
- // Before the check: a test may want to observe that a stop was *attempted*
86
- // even when this orchestrator rejects it.
87
- this.onStop?.(dockerId);
88
- const child = this.requireChild(dockerId);
89
- this.stopped.push(dockerId);
90
- child.running = false;
91
- }
92
- async removeContainer(dockerId) {
93
- // NOTE: the real `removeContainer` stops a still-live child first; this one
94
- // only deletes, so a remove aimed at a running child records no `stopped`
95
- // entry and emits no state change. Every donation caller stops before it
96
- // reclaims, so nothing sees the difference today — if one stops doing that,
97
- // stop the child here too.
98
- this.requireChild(dockerId);
99
- this.removed.push(dockerId);
100
- this.children.delete(dockerId);
101
- }
102
- /**
103
- * NOTE: this and {@link getLogs} take an unknown `dockerId` where the real
104
- * class throws (`requireHandle`). No donation path calls either, so the gap is
105
- * unobservable — route them through `requireChild` if one starts to.
106
- */
107
- async getStats() {
108
- return { cpuPercent: 0, memoryBytes: 0, networkRxBytes: 0, networkTxBytes: 0 };
109
- }
110
- /** Unknown handles read as not running, matching the real orchestrator. */
111
- async isRunning(dockerId) {
112
- this.onIsRunning?.(dockerId);
113
- return this.children.get(dockerId)?.running ?? false;
114
- }
115
- /** Resolve a spawn's friendly containerId back to its dockerId. */
116
- resolveDockerId(containerId) {
117
- for (const [dockerId, child] of this.children) {
118
- if (child.containerId === containerId)
119
- return dockerId;
120
- }
121
- return undefined;
122
- }
123
- /**
124
- * Remove the workdir of a container no handle owns, mirroring the real
125
- * `reclaimWorkdir`. Refuses (returns `false`, records nothing) while a child
126
- * for that containerId still resolves — that directory belongs to
127
- * `removeContainer`, which stops the child first.
128
- *
129
- * There is no directory here to check for existence, so a first reclaim of an
130
- * unresolvable id always reports `true`; the real class returns `false` when
131
- * the path is already gone. Nothing in the donation layer branches on the
132
- * return value, so the gap is unobservable — model it if one starts to.
133
- */
134
- reclaimWorkdir(containerId) {
135
- if (this.resolveDockerId(containerId))
136
- return false;
137
- this.reclaimedWorkdirs.push(containerId);
138
- return true;
139
- }
140
- async getLogs() {
141
- return '';
142
- }
143
- onStateChange(listener) {
144
- this.listeners.add(listener);
145
- return () => { this.listeners.delete(listener); };
146
- }
147
- /** Kill a child out from under the host — the crash the supervisor exists for. */
148
- crash(dockerId) {
149
- const child = this.children.get(dockerId);
150
- if (!child?.running)
151
- return;
152
- child.running = false;
153
- this.emit(toNodeInfo(dockerId, child));
154
- }
155
- /** Emit an arbitrary state change (e.g. the owner node stopping). */
156
- emit(info) {
157
- for (const listener of this.listeners)
158
- listener(info);
159
- }
160
- /** Mirrors `HostProcessOrchestrator.requireHandle` — unknown handles throw. */
161
- requireChild(dockerId) {
162
- const child = this.children.get(dockerId);
163
- if (!child)
164
- throw new Error(`Container not found: ${dockerId}`);
165
- return child;
166
- }
167
- }
168
- function toNodeInfo(dockerId, child) {
169
- return {
170
- id: child.containerId,
171
- dockerId,
172
- partyId: child.partyId,
173
- profile: child.profile,
174
- status: child.running ? 'running' : 'stopped',
175
- spawnedAt: new Date(0).toISOString(),
176
- workdir: `/fake/${child.containerId}`,
177
- ports: { health: 0, metrics: 0, p2p: 0, admin: 0, ws: 0 },
178
- };
179
- }
180
- //# sourceMappingURL=fake-orchestrator.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"fake-orchestrator.js","sourceRoot":"","sources":["../../../src/donation/__tests__/fake-orchestrator.ts"],"names":[],"mappings":"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"}