@wopr-network/platform-core 1.37.0 → 1.39.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.
@@ -9,8 +9,10 @@ function mockContainer() {
9
9
  remove: vi.fn().mockResolvedValue(undefined),
10
10
  inspect: vi.fn().mockResolvedValue({
11
11
  Id: "container-123",
12
+ Name: "/wopr-test",
12
13
  Created: "2026-01-01T00:00:00Z",
13
14
  State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z" },
15
+ NetworkSettings: { Ports: {} },
14
16
  }),
15
17
  stats: vi.fn().mockResolvedValue({
16
18
  cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
@@ -24,7 +26,7 @@ function makeDocker() {
24
26
  return {
25
27
  pull: vi.fn().mockResolvedValue("stream"),
26
28
  createContainer: vi.fn().mockResolvedValue(mockContainer()),
27
- listContainers: vi.fn().mockResolvedValue([]),
29
+ listContainers: vi.fn().mockResolvedValue([{ Id: "container-123" }]),
28
30
  getContainer: vi.fn().mockReturnValue(mockContainer()),
29
31
  modem: {
30
32
  followProgress: vi.fn((_stream, cb) => cb(null)),
@@ -6,6 +6,7 @@ import type { NetworkPolicy } from "../network/network-policy.js";
6
6
  import type { ProxyManagerInterface } from "../proxy/types.js";
7
7
  import type { IBotInstanceRepository } from "./bot-instance-repository.js";
8
8
  import type { FleetEventEmitter } from "./fleet-event-emitter.js";
9
+ import { Instance } from "./instance.js";
9
10
  import type { INodeCommandBus } from "./node-command-bus.js";
10
11
  import type { IProfileStore } from "./profile-store.js";
11
12
  import type { BotProfile, BotStatus } from "./types.js";
@@ -40,19 +41,18 @@ export declare class FleetManager {
40
41
  */
41
42
  create(params: Omit<BotProfile, "id"> & {
42
43
  id?: string;
43
- }, resourceLimits?: ContainerResourceLimits): Promise<BotProfile>;
44
+ }, resourceLimits?: ContainerResourceLimits): Promise<Instance>;
44
45
  /**
45
- * Start a stopped bot container.
46
- * Valid from: stopped, created, exited, dead, error states.
47
- * Throws InvalidStateTransitionError if the container is already running.
46
+ * Build an Instance from a profile after container creation.
47
+ * Inspects the Docker container to resolve container name and URL.
48
48
  */
49
- start(id: string): Promise<void>;
49
+ private resolvePort;
50
50
  /**
51
- * Stop a running bot container.
52
- * Valid from: running, starting, restarting states.
53
- * Throws InvalidStateTransitionError if the container is not running.
51
+ * Get an Instance handle for an existing bot by ID.
52
+ * Looks up the profile and inspects the Docker container.
54
53
  */
55
- stop(id: string): Promise<void>;
54
+ getInstance(id: string): Promise<Instance>;
55
+ private buildInstance;
56
56
  /**
57
57
  * Restart: pull new image BEFORE restarting container to avoid downtime on pull failure.
58
58
  * Valid from: running, stopped, exited, dead states.
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { PassThrough } from "node:stream";
3
3
  import { logger } from "../config/logger.js";
4
4
  import { buildDiscoveryEnv } from "../discovery/discovery-config.js";
5
+ import { Instance } from "./instance.js";
5
6
  import { getSharedVolumeConfig } from "./shared-volume-config.js";
6
7
  const CONTAINER_LABEL = "wopr.managed";
7
8
  const CONTAINER_ID_LABEL = "wopr.bot-id";
@@ -85,7 +86,6 @@ export class FleetManager {
85
86
  try {
86
87
  const remote = await this.resolveNodeId(id);
87
88
  if (remote) {
88
- // Dispatch to remote node agent — it handles pull + create + start
89
89
  await remote.commandBus.send(remote.nodeId, {
90
90
  type: "bot.start",
91
91
  payload: {
@@ -95,9 +95,22 @@ export class FleetManager {
95
95
  restart: profile.restartPolicy,
96
96
  },
97
97
  });
98
+ // Remote bots have no local container — return a remote Instance
99
+ const containerName = `wopr-${profile.name.replace(/_/g, "-")}`;
100
+ const remoteInstance = new Instance({
101
+ docker: this.docker,
102
+ profile,
103
+ containerId: `remote:${remote.nodeId}`,
104
+ containerName,
105
+ url: `remote://${remote.nodeId}/${containerName}`,
106
+ instanceRepo: this.instanceRepo,
107
+ proxyManager: this.proxyManager,
108
+ eventEmitter: this.eventEmitter,
109
+ });
110
+ remoteInstance.emitCreated();
111
+ return remoteInstance;
98
112
  }
99
113
  else {
100
- // Local dockerode fallback
101
114
  await this.pullImage(profile.image);
102
115
  await this.createContainer(profile, resourceLimits);
103
116
  }
@@ -109,111 +122,57 @@ export class FleetManager {
109
122
  await this.store.delete(profile.id);
110
123
  throw err;
111
124
  }
112
- // Register proxy route for tenant subdomain routing (non-fatal)
113
- if (this.proxyManager) {
114
- try {
115
- const subdomain = profile.name.toLowerCase().replace(/_/g, "-");
116
- await this.proxyManager.addRoute({
117
- instanceId: profile.id,
118
- subdomain,
119
- upstreamHost: `wopr-${subdomain}`,
120
- upstreamPort: 7437,
121
- healthy: true,
122
- });
123
- }
124
- catch (err) {
125
- logger.warn("Proxy route registration failed (non-fatal)", { botId: profile.id, err });
126
- }
127
- }
128
- this.emitEvent("bot.created", profile.id, profile.tenantId);
129
- return profile;
125
+ const instance = await this.buildInstance(profile);
126
+ instance.emitCreated();
127
+ return instance;
130
128
  };
131
129
  return hasExplicitId ? this.withLock(id, doCreate) : doCreate();
132
130
  }
133
131
  /**
134
- * Start a stopped bot container.
135
- * Valid from: stopped, created, exited, dead, error states.
136
- * Throws InvalidStateTransitionError if the container is already running.
132
+ * Build an Instance from a profile after container creation.
133
+ * Inspects the Docker container to resolve container name and URL.
137
134
  */
138
- async start(id) {
139
- return this.withLock(id, async () => {
140
- this.botMetricsTracker?.reset(id);
141
- const remote = await this.resolveNodeId(id);
142
- if (remote) {
143
- const profile = await this.store.get(id);
144
- if (!profile)
145
- throw new BotNotFoundError(id);
146
- await remote.commandBus.send(remote.nodeId, {
147
- type: "bot.start",
148
- payload: {
149
- name: profile.name,
150
- image: profile.image,
151
- env: profile.env,
152
- restart: profile.restartPolicy,
153
- },
154
- });
155
- }
156
- else {
157
- const container = await this.findContainer(id);
158
- if (!container)
159
- throw new BotNotFoundError(id);
160
- const info = await container.inspect();
161
- const validStartStates = new Set(["stopped", "created", "exited", "dead", "error"]);
162
- this.assertValidState(id, info.State.Status, "start", validStartStates);
163
- await container.start();
164
- }
165
- if (this.proxyManager) {
166
- this.proxyManager.updateHealth(id, true);
167
- }
168
- logger.info(`Started bot ${id}`);
169
- let startedTenantId;
170
- try {
171
- startedTenantId = (await this.store.get(id))?.tenantId;
172
- }
173
- catch (err) {
174
- logger.warn(`Failed to fetch profile after starting bot ${id}`, { err });
175
- }
176
- this.emitEvent("bot.started", id, startedTenantId);
177
- });
135
+ resolvePort(profile) {
136
+ const envPort = profile.env?.PORT;
137
+ return envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
178
138
  }
179
139
  /**
180
- * Stop a running bot container.
181
- * Valid from: running, starting, restarting states.
182
- * Throws InvalidStateTransitionError if the container is not running.
140
+ * Get an Instance handle for an existing bot by ID.
141
+ * Looks up the profile and inspects the Docker container.
183
142
  */
184
- async stop(id) {
185
- return this.withLock(id, async () => {
186
- const remote = await this.resolveNodeId(id);
187
- if (remote) {
188
- const profile = await this.store.get(id);
189
- if (!profile)
190
- throw new BotNotFoundError(id);
191
- await remote.commandBus.send(remote.nodeId, {
192
- type: "bot.stop",
193
- payload: { name: profile.name },
194
- });
195
- }
196
- else {
197
- const container = await this.findContainer(id);
198
- if (!container)
199
- throw new BotNotFoundError(id);
200
- const info = await container.inspect();
201
- const validStopStates = new Set(["running", "starting", "restarting"]);
202
- this.assertValidState(id, info.State.Status, "stop", validStopStates);
203
- await container.stop();
204
- }
205
- if (this.proxyManager) {
206
- this.proxyManager.updateHealth(id, false);
207
- }
208
- logger.info(`Stopped bot ${id}`);
209
- let stoppedTenantId;
210
- try {
211
- stoppedTenantId = (await this.store.get(id))?.tenantId;
212
- }
213
- catch (err) {
214
- logger.warn(`Failed to fetch profile after stopping bot ${id}`, { err });
215
- }
216
- this.emitEvent("bot.stopped", id, stoppedTenantId);
143
+ async getInstance(id) {
144
+ const profile = await this.store.get(id);
145
+ if (!profile)
146
+ throw new BotNotFoundError(id);
147
+ return this.buildInstance(profile);
148
+ }
149
+ async buildInstance(profile) {
150
+ const dockerContainer = await this.findContainer(profile.id);
151
+ if (!dockerContainer)
152
+ throw new Error(`Container for ${profile.id} not found after creation`);
153
+ const info = await dockerContainer.inspect();
154
+ const containerName = info.Name.replace(/^\//, "");
155
+ const containerId = info.Id;
156
+ // Resolve URL from network DNS or host port mapping
157
+ let url;
158
+ const port = this.resolvePort(profile);
159
+ if (profile.network) {
160
+ url = `http://${containerName}:${port}`;
161
+ }
162
+ else {
163
+ const portBindings = info.NetworkSettings?.Ports?.[`${port}/tcp`];
164
+ const hostPort = portBindings?.[0]?.HostPort ?? String(port);
165
+ url = `http://localhost:${hostPort}`;
166
+ }
167
+ return new Instance({
168
+ docker: this.docker,
169
+ profile,
170
+ containerId,
171
+ containerName,
172
+ url,
173
+ instanceRepo: this.instanceRepo,
174
+ proxyManager: this.proxyManager,
175
+ eventEmitter: this.eventEmitter,
217
176
  });
218
177
  }
219
178
  /**
@@ -397,6 +356,8 @@ export class FleetManager {
397
356
  "volumeName",
398
357
  "name",
399
358
  "discovery",
359
+ "network",
360
+ "ephemeral",
400
361
  ]);
401
362
  /**
402
363
  * Update a bot profile. Only recreates the container if container-relevant
@@ -557,22 +518,28 @@ export class FleetManager {
557
518
  if (sharedVolConfig.enabled) {
558
519
  binds.push(`${sharedVolConfig.volumeName}:${sharedVolConfig.mountPath}:ro`);
559
520
  }
521
+ const isEphemeral = profile.ephemeral === true;
560
522
  const hostConfig = {
561
523
  RestartPolicy: {
562
524
  Name: restartPolicyMap[profile.restartPolicy] || "",
563
525
  },
564
526
  Binds: binds.length > 0 ? binds : undefined,
565
527
  SecurityOpt: ["no-new-privileges"],
566
- CapDrop: ["ALL"],
567
- CapAdd: ["NET_BIND_SERVICE"],
568
- ReadonlyRootfs: true,
569
- Tmpfs: {
570
- "/tmp": "rw,noexec,nosuid,size=64m",
571
- "/var/tmp": "rw,noexec,nosuid,size=64m",
572
- },
528
+ CapDrop: isEphemeral ? undefined : ["ALL"],
529
+ CapAdd: isEphemeral ? undefined : ["NET_BIND_SERVICE"],
530
+ ReadonlyRootfs: !isEphemeral,
531
+ Tmpfs: isEphemeral
532
+ ? undefined
533
+ : {
534
+ "/tmp": "rw,noexec,nosuid,size=64m",
535
+ "/var/tmp": "rw,noexec,nosuid,size=64m",
536
+ },
573
537
  };
574
- // Set tenant network isolation if NetworkPolicy is configured
575
- if (this.networkPolicy) {
538
+ // Set network: explicit profile.network takes precedence, then NetworkPolicy
539
+ if (profile.network) {
540
+ hostConfig.NetworkMode = profile.network;
541
+ }
542
+ else if (this.networkPolicy) {
576
543
  const networkMode = await this.networkPolicy.prepareForContainer(profile.tenantId);
577
544
  hostConfig.NetworkMode = networkMode;
578
545
  }
@@ -1,5 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { BotNotFoundError, FleetManager, InvalidStateTransitionError } from "./fleet-manager.js";
2
+ import { BotNotFoundError, FleetManager } from "./fleet-manager.js";
3
3
  // --- Mock helpers ---
4
4
  function mockContainer(overrides = {}) {
5
5
  return {
@@ -10,6 +10,7 @@ function mockContainer(overrides = {}) {
10
10
  remove: vi.fn().mockResolvedValue(undefined),
11
11
  inspect: vi.fn().mockResolvedValue({
12
12
  Id: "container-123",
13
+ Name: "/wopr-test-bot",
13
14
  Created: "2026-01-01T00:00:00Z",
14
15
  State: {
15
16
  Status: "running",
@@ -17,6 +18,7 @@ function mockContainer(overrides = {}) {
17
18
  StartedAt: "2026-01-01T00:00:00Z",
18
19
  Health: { Status: "healthy" },
19
20
  },
21
+ NetworkSettings: { Ports: {} },
20
22
  }),
21
23
  stats: vi.fn().mockResolvedValue({
22
24
  cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
@@ -89,9 +91,9 @@ describe("FleetManager", () => {
89
91
  });
90
92
  describe("create", () => {
91
93
  it("saves profile, pulls image, and creates container", async () => {
92
- const profile = await fleet.create(PROFILE_PARAMS);
93
- expect(profile.id).toEqual(expect.any(String));
94
- expect(profile.name).toBe("test-bot");
94
+ const instance = await fleet.create(PROFILE_PARAMS);
95
+ expect(instance.id).toEqual(expect.any(String));
96
+ expect(instance.profile.name).toBe("test-bot");
95
97
  expect(store.save).toHaveBeenCalledWith(expect.objectContaining({ name: "test-bot" }));
96
98
  expect(docker.pull).toHaveBeenCalledWith("ghcr.io/wopr-network/wopr:stable", {});
97
99
  expect(docker.createContainer).toHaveBeenCalledWith(expect.objectContaining({
@@ -111,79 +113,18 @@ describe("FleetManager", () => {
111
113
  });
112
114
  });
113
115
  describe("start", () => {
114
- it("starts an existing stopped container", async () => {
115
- // Container must be in a stopped/created/exited state
116
- container.inspect.mockResolvedValue({
117
- Id: "container-123",
118
- Created: "2026-01-01T00:00:00Z",
119
- State: { Status: "stopped", Running: false, StartedAt: "", Health: { Status: "healthy" } },
120
- });
121
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
122
- await fleet.start("bot-id");
123
- expect(container.start).toHaveBeenCalled();
124
- });
125
- it("starts an exited container", async () => {
126
- container.inspect.mockResolvedValue({
127
- Id: "container-123",
128
- Created: "2026-01-01T00:00:00Z",
129
- State: { Status: "exited", Running: false, StartedAt: "", Health: null },
130
- });
131
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
132
- await fleet.start("bot-id");
116
+ it("starts a created container via Instance.start()", async () => {
117
+ const instance = await fleet.create(PROFILE_PARAMS);
118
+ await instance.start();
133
119
  expect(container.start).toHaveBeenCalled();
134
120
  });
135
- it("starts a dead container", async () => {
136
- container.inspect.mockResolvedValue({
137
- Id: "container-123",
138
- Created: "2026-01-01T00:00:00Z",
139
- State: { Status: "dead", Running: false, StartedAt: "", Health: null },
140
- });
141
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
142
- await fleet.start("bot-id");
143
- expect(container.start).toHaveBeenCalled();
144
- });
145
- it("throws InvalidStateTransitionError with 'unknown' when Status is missing/null", async () => {
146
- container.inspect.mockResolvedValue({
147
- Id: "container-123",
148
- Created: "2026-01-01T00:00:00Z",
149
- State: { Status: null, Running: false, StartedAt: "", Health: null },
150
- });
151
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
152
- await expect(fleet.start("bot-id")).rejects.toThrow(InvalidStateTransitionError);
153
- await expect(fleet.start("bot-id")).rejects.toThrow(/unknown/);
154
- });
155
- it("throws InvalidStateTransitionError when container is already running", async () => {
156
- // Default mockContainer has state "running"
157
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
158
- await expect(fleet.start("bot-id")).rejects.toThrow(InvalidStateTransitionError);
159
- await expect(fleet.start("bot-id")).rejects.toThrow(/Cannot start bot/);
160
- });
161
- it("throws BotNotFoundError when container not found", async () => {
162
- docker.listContainers.mockResolvedValue([]);
163
- await expect(fleet.start("missing")).rejects.toThrow(BotNotFoundError);
164
- });
165
121
  });
166
122
  describe("stop", () => {
167
- it("stops a running container", async () => {
168
- // Default mockContainer has state "running" — valid for stop
169
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
170
- await fleet.stop("bot-id");
123
+ it("stops a running container via Instance.stop()", async () => {
124
+ const instance = await fleet.create(PROFILE_PARAMS);
125
+ await instance.stop();
171
126
  expect(container.stop).toHaveBeenCalled();
172
127
  });
173
- it("throws InvalidStateTransitionError when container is already stopped", async () => {
174
- container.inspect.mockResolvedValue({
175
- Id: "container-123",
176
- Created: "2026-01-01T00:00:00Z",
177
- State: { Status: "exited", Running: false, StartedAt: "", Health: null },
178
- });
179
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
180
- await expect(fleet.stop("bot-id")).rejects.toThrow(InvalidStateTransitionError);
181
- await expect(fleet.stop("bot-id")).rejects.toThrow(/Cannot stop bot/);
182
- });
183
- it("throws BotNotFoundError when container not found", async () => {
184
- docker.listContainers.mockResolvedValue([]);
185
- await expect(fleet.stop("missing")).rejects.toThrow(BotNotFoundError);
186
- });
187
128
  });
188
129
  describe("restart", () => {
189
130
  it("pulls image before restarting a running container", async () => {
@@ -406,21 +347,23 @@ describe("FleetManager", () => {
406
347
  undefined, // networkPolicy
407
348
  proxyManager);
408
349
  });
409
- it("calls addRoute on create with correct subdomain and upstream", async () => {
410
- const profile = await proxyFleet.create(PROFILE_PARAMS);
350
+ it("calls addRoute via setupProxy with correct subdomain and upstream", async () => {
351
+ const instance = await proxyFleet.create(PROFILE_PARAMS);
352
+ await instance.setupProxy();
411
353
  expect(proxyManager.addRoute).toHaveBeenCalledWith({
412
- instanceId: profile.id,
354
+ instanceId: instance.id,
413
355
  subdomain: "test-bot",
414
356
  upstreamHost: "wopr-test-bot",
415
357
  upstreamPort: 7437,
416
358
  healthy: true,
417
359
  });
418
360
  });
419
- it("still returns profile when addRoute fails (non-fatal)", async () => {
361
+ it("still returns instance when setupProxy addRoute fails (non-fatal)", async () => {
362
+ const instance = await proxyFleet.create(PROFILE_PARAMS);
420
363
  proxyManager.addRoute.mockRejectedValueOnce(new Error("DNS fail"));
421
- const profile = await proxyFleet.create(PROFILE_PARAMS);
422
- expect(profile.id).toEqual(expect.any(String));
423
- expect(profile.name).toBe("test-bot");
364
+ await instance.setupProxy();
365
+ expect(instance.id).toEqual(expect.any(String));
366
+ expect(instance.profile.name).toBe("test-bot");
424
367
  });
425
368
  it("calls removeRoute on remove", async () => {
426
369
  await store.save({ id: "bot-id", ...PROFILE_PARAMS });
@@ -428,32 +371,29 @@ describe("FleetManager", () => {
428
371
  await proxyFleet.remove("bot-id");
429
372
  expect(proxyManager.removeRoute).toHaveBeenCalledWith("bot-id");
430
373
  });
431
- it("calls updateHealth(true) on start", async () => {
432
- container.inspect.mockResolvedValue({
433
- Id: "container-123",
434
- Created: "2026-01-01T00:00:00Z",
435
- State: { Status: "stopped", Running: false, StartedAt: "", Health: { Status: "healthy" } },
436
- });
437
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
438
- await proxyFleet.start("bot-id");
439
- expect(proxyManager.updateHealth).toHaveBeenCalledWith("bot-id", true);
374
+ it("starts via Instance.start()", async () => {
375
+ const instance = await proxyFleet.create(PROFILE_PARAMS);
376
+ await instance.start();
377
+ expect(container.start).toHaveBeenCalled();
440
378
  });
441
- it("calls updateHealth(false) on stop", async () => {
442
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
443
- await proxyFleet.stop("bot-id");
444
- expect(proxyManager.updateHealth).toHaveBeenCalledWith("bot-id", false);
379
+ it("stops via Instance.stop()", async () => {
380
+ const instance = await proxyFleet.create(PROFILE_PARAMS);
381
+ await instance.stop();
382
+ expect(container.stop).toHaveBeenCalled();
445
383
  });
446
384
  it("does not call proxy methods when no proxyManager is provided", async () => {
447
385
  // `fleet` from the outer beforeEach has no proxyManager
448
- const profile = await fleet.create(PROFILE_PARAMS);
386
+ const instance = await fleet.create(PROFILE_PARAMS);
387
+ await instance.setupProxy(); // no-op since no proxyManager
449
388
  expect(proxyManager.addRoute).not.toHaveBeenCalled();
450
- expect(profile.id).toEqual(expect.any(String));
389
+ expect(instance.id).toEqual(expect.any(String));
451
390
  });
452
391
  it("normalizes underscores to hyphens in subdomain", async () => {
453
- await proxyFleet.create({ ...PROFILE_PARAMS, name: "my_cool_bot" });
392
+ const instance = await proxyFleet.create({ ...PROFILE_PARAMS, name: "my_cool_bot" });
393
+ await instance.setupProxy();
454
394
  expect(proxyManager.addRoute).toHaveBeenCalledWith(expect.objectContaining({
455
395
  subdomain: "my-cool-bot",
456
- upstreamHost: "wopr-my-cool-bot",
396
+ upstreamHost: "wopr-test-bot",
457
397
  }));
458
398
  });
459
399
  });
@@ -503,64 +443,6 @@ describe("FleetManager", () => {
503
443
  });
504
444
  });
505
445
  describe("per-bot mutex", () => {
506
- it("serializes 5 concurrent startBot calls into exactly 1 start per call", async () => {
507
- // Containers must be in stopped state so the state guard passes
508
- container.inspect.mockResolvedValue({
509
- Id: "container-123",
510
- Created: "2026-01-01T00:00:00Z",
511
- State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
512
- });
513
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
514
- let startCount = 0;
515
- container.start.mockImplementation(async () => {
516
- startCount++;
517
- const current = startCount;
518
- await new Promise((r) => setTimeout(r, 10));
519
- expect(startCount).toBe(current);
520
- });
521
- const promises = Array.from({ length: 5 }, () => fleet.start("bot-id"));
522
- await Promise.all(promises);
523
- expect(container.start).toHaveBeenCalledTimes(5);
524
- });
525
- it("does not block operations on different bots (proves concurrency)", async () => {
526
- // For this test: start needs stopped state, stop needs running state.
527
- // Both bot-a and bot-b share the same mock container.
528
- // Use a stopped state so start() passes, and mock stop() directly so it bypasses state check.
529
- container.inspect.mockResolvedValue({
530
- Id: "container-123",
531
- Created: "2026-01-01T00:00:00Z",
532
- State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
533
- });
534
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
535
- let releaseStart;
536
- container.start.mockImplementation(() => new Promise((resolve) => {
537
- releaseStart = resolve;
538
- }));
539
- const stopSpy = vi.fn();
540
- // Override inspect for the stop path to return "running" state
541
- let callCount = 0;
542
- container.inspect.mockImplementation(async () => {
543
- callCount++;
544
- // First call is for bot-a start, subsequent for bot-b stop
545
- return callCount === 1
546
- ? { Id: "container-123", State: { Status: "stopped", Running: false } }
547
- : { Id: "container-123", State: { Status: "running", Running: true } };
548
- });
549
- container.stop.mockImplementation(async () => {
550
- stopSpy();
551
- });
552
- const startPromise = fleet.start("bot-a");
553
- await Promise.resolve(); // allow start lock acquisition
554
- await expect(fleet.stop("bot-b")).resolves.toBeUndefined();
555
- expect(stopSpy).toHaveBeenCalledTimes(1);
556
- releaseStart();
557
- await startPromise;
558
- });
559
- it("releases lock even when operation throws", async () => {
560
- docker.listContainers.mockResolvedValue([]);
561
- await expect(fleet.start("bot-id")).rejects.toThrow(BotNotFoundError);
562
- await expect(fleet.start("bot-id")).rejects.toThrow(BotNotFoundError);
563
- });
564
446
  it("serializes concurrent create calls with the same explicit ID (mutual exclusion)", async () => {
565
447
  // Add a delay to store.save so the race is observable: the mutex must ensure
566
448
  // the first save completes before the second call checks for the existing profile.
@@ -628,7 +510,7 @@ describe("FleetManager", () => {
628
510
  const botId = "bot-remote-1";
629
511
  await instanceRepo.create({ id: botId, tenantId: "t1", name: "test-bot", nodeId: "node-1" });
630
512
  const fleet = new FleetManager(docker, store, undefined, undefined, undefined, bus, instanceRepo);
631
- const profile = await fleet.create({
513
+ const instance = await fleet.create({
632
514
  id: botId,
633
515
  tenantId: "t1",
634
516
  name: "test-bot",
@@ -639,7 +521,7 @@ describe("FleetManager", () => {
639
521
  releaseChannel: "stable",
640
522
  updatePolicy: "manual",
641
523
  });
642
- expect(profile.id).toBe(botId);
524
+ expect(instance.id).toBe(botId);
643
525
  expect(bus.send).toHaveBeenCalledWith("node-1", {
644
526
  type: "bot.start",
645
527
  payload: {
@@ -675,31 +557,6 @@ describe("FleetManager", () => {
675
557
  expect(bus.send).not.toHaveBeenCalled();
676
558
  expect(docker.pull).toHaveBeenCalled();
677
559
  });
678
- it("should dispatch stop via commandBus when bot has nodeId", async () => {
679
- const container = mockContainer();
680
- const docker = mockDocker(container);
681
- const store = mockStore();
682
- const bus = mockCommandBus();
683
- const instanceRepo = mockInstanceRepo();
684
- const botId = "bot-stop-1";
685
- await instanceRepo.create({ id: botId, tenantId: "t1", name: "stop-bot", nodeId: "node-2" });
686
- const fleet = new FleetManager(docker, store, undefined, undefined, undefined, bus, instanceRepo);
687
- await store.save({
688
- id: botId,
689
- tenantId: "t1",
690
- name: "stop-bot",
691
- description: "",
692
- image: "ghcr.io/wopr-network/wopr:latest",
693
- env: {},
694
- restartPolicy: "unless-stopped",
695
- });
696
- await fleet.stop(botId);
697
- expect(bus.send).toHaveBeenCalledWith("node-2", {
698
- type: "bot.stop",
699
- payload: { name: "stop-bot" },
700
- });
701
- expect(container.stop).not.toHaveBeenCalled();
702
- });
703
560
  it("should dispatch restart via commandBus when bot has nodeId", async () => {
704
561
  const container = mockContainer();
705
562
  const docker = mockDocker(container);