@wopr-network/platform-core 1.38.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.
@@ -428,36 +428,36 @@ describe("ContainerUpdater", () => {
428
428
  expect(result.error).toBe("Bot not found");
429
429
  });
430
430
  it("performs update by pulling, stopping, and recreating", async () => {
431
- // The fleet.update method will be called to recreate the container
432
- // We need to make sure listContainers returns properly
431
+ // Trace through doUpdateBot("bot-1"):
432
+ // 1. getContainerDigest: listContainers + getContainer + inspect + getImage
433
+ // 2. wasRunning check: listContainers + getContainer + inspect
434
+ // 3. docker.pull + followProgress
435
+ // 4. fleet.update(botId, { image }):
436
+ // a. findContainer: listContainers + getContainer
437
+ // b. inspect (wasRunning check)
438
+ // c. pullImage (updates.image set): docker.pull + followProgress
439
+ // d. stop + remove
440
+ // e. createContainer
441
+ // f. findContainer after recreate (wasRunning): listContainers + getContainer
442
+ // g. start
443
+ // 5. waitForHealthy: listContainers + getContainer + inspect
444
+ // 6. getContainerDigest (final): listContainers + getContainer + inspect + getImage
433
445
  docker.listContainers
434
- .mockResolvedValueOnce([{ Id: "container-123" }]) // getContainerDigest during updateBot
435
- .mockResolvedValueOnce([{ Id: "container-123" }]) // step 2: find container to stop
436
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update -> findContainer
437
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update -> findContainer after recreate
438
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.start -> findContainer
439
- .mockResolvedValueOnce([{ Id: "container-123" }]); // waitForHealthy
446
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
447
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
448
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
449
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
450
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
451
+ .mockResolvedValueOnce([{ Id: "container-123" }]); // 6: final getContainerDigest
440
452
  docker.getContainer.mockReturnValue(container);
441
- // Sequence inspect calls: first two return "running" (for wasRunning checks in
442
- // doUpdateBot and fleet.update), third returns "stopped" so fleet.start()'s
443
- // assertValidState guard allows the start, subsequent calls use the default (running+healthy).
444
453
  const runningInspect = {
445
454
  Id: "container-123",
446
455
  Image: "sha256:abc123",
447
456
  Created: "2026-01-01T00:00:00Z",
448
457
  State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
449
458
  };
450
- const stoppedInspect = {
451
- Id: "container-123",
452
- Image: "sha256:abc123",
453
- Created: "2026-01-01T00:00:00Z",
454
- State: { Status: "stopped", Running: false, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
455
- };
456
- container.inspect
457
- .mockResolvedValueOnce(runningInspect) // getContainerDigest: initial digest check
458
- .mockResolvedValueOnce(runningInspect) // doUpdateBot: wasRunning check
459
- .mockResolvedValueOnce(runningInspect) // fleet.update: wasRunning check
460
- .mockResolvedValueOnce(stoppedInspect); // fleet.start: assertValidState (newly recreated container)
459
+ // All inspect calls return running+healthy (getContainerDigest, wasRunning checks, waitForHealthy, final digest)
460
+ container.inspect.mockResolvedValue(runningInspect);
461
461
  const result = await updater.updateBot("bot-1");
462
462
  expect(result.success).toBe(true);
463
463
  expect(result.rolledBack).toBe(false);
@@ -469,34 +469,25 @@ describe("ContainerUpdater", () => {
469
469
  docker.modem.followProgress.mockImplementationOnce((_stream, cb) => {
470
470
  resolvePull = cb;
471
471
  });
472
+ // After the first followProgress (which hangs), subsequent calls use default (instant resolve)
473
+ // Trace: getContainerDigest, wasRunning, fleet.update->findContainer,
474
+ // fleet.update->findContainer after recreate, waitForHealthy, final getContainerDigest
472
475
  docker.listContainers
473
- .mockResolvedValueOnce([{ Id: "container-123" }]) // first update: getContainerDigest
474
- .mockResolvedValueOnce([{ Id: "container-123" }]) // first update: step 2
475
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.update
476
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.start
477
- .mockResolvedValueOnce([{ Id: "container-123" }]) // waitForHealthy
478
- .mockResolvedValueOnce([{ Id: "container-123" }]); // get new digest
476
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
477
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
478
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
479
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
480
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
481
+ .mockResolvedValueOnce([{ Id: "container-123" }]); // 6: final getContainerDigest
479
482
  docker.getContainer.mockReturnValue(container);
480
- // Sequence inspect calls: first two return "running" (for wasRunning checks in
481
- // doUpdateBot and fleet.update), third returns "stopped" so fleet.start()'s
482
- // assertValidState guard allows the start.
483
483
  const runningInspect = {
484
484
  Id: "container-123",
485
485
  Image: "sha256:abc123",
486
486
  Created: "2026-01-01T00:00:00Z",
487
487
  State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
488
488
  };
489
- const stoppedInspect = {
490
- Id: "container-123",
491
- Image: "sha256:abc123",
492
- Created: "2026-01-01T00:00:00Z",
493
- State: { Status: "stopped", Running: false, StartedAt: "2026-01-01T00:00:00Z", Health: { Status: "healthy" } },
494
- };
495
- container.inspect
496
- .mockResolvedValueOnce(runningInspect) // getContainerDigest: initial digest check
497
- .mockResolvedValueOnce(runningInspect) // doUpdateBot: wasRunning check
498
- .mockResolvedValueOnce(runningInspect) // fleet.update: wasRunning check
499
- .mockResolvedValueOnce(stoppedInspect); // fleet.start: assertValidState (newly recreated container)
489
+ // All inspect calls return running+healthy
490
+ container.inspect.mockResolvedValue(runningInspect);
500
491
  // Start first update (will block on pull)
501
492
  const first = updater.updateBot("bot-1");
502
493
  // Wait for the pull to be called so the lock is held
@@ -517,6 +508,7 @@ describe("ContainerUpdater", () => {
517
508
  const unhealthyContainer = mockContainer({
518
509
  inspect: vi.fn().mockResolvedValue({
519
510
  Id: "container-123",
511
+ Name: "/wopr-test-bot",
520
512
  Image: "sha256:new999",
521
513
  Created: "2026-01-01T00:00:00Z",
522
514
  State: {
@@ -525,23 +517,46 @@ describe("ContainerUpdater", () => {
525
517
  StartedAt: "2026-01-01T00:00:00Z",
526
518
  Health: { Status: "unhealthy" },
527
519
  },
520
+ NetworkSettings: { Ports: {} },
528
521
  }),
529
522
  });
530
- // Make the new container unhealthy so rollback happens
523
+ // Trace through doUpdateBot("bot-1"):
524
+ // 1. getContainerDigest: listContainers + getContainer + inspect + getImage
525
+ // 2. wasRunning check: listContainers + getContainer + inspect
526
+ // 3. docker.pull + followProgress
527
+ // 4. fleet.update(botId, { image }):
528
+ // a. findContainer: listContainers + getContainer
529
+ // b. inspect (wasRunning check in update)
530
+ // c. pullImage (updates.image is set): docker.pull + followProgress
531
+ // d. stop + remove
532
+ // e. createContainer
533
+ // f. findContainer after recreate (wasRunning): listContainers + getContainer
534
+ // g. start
535
+ // 5. waitForHealthy: listContainers + getContainer + inspect
536
+ // 6. unhealthy -> rollback via fleet.update:
537
+ // a. findContainer: listContainers + getContainer
538
+ // b. inspect (wasRunning)
539
+ // c. pullImage: docker.pull + followProgress
540
+ // d. stop + remove
541
+ // e. createContainer
542
+ // f. findContainer after recreate (wasRunning): listContainers + getContainer
543
+ // g. start
531
544
  docker.listContainers
532
- .mockResolvedValueOnce([{ Id: "container-123" }]) // getContainerDigest
533
- .mockResolvedValueOnce([{ Id: "container-123" }]) // step 2: find container to stop
534
- .mockResolvedValueOnce([]) // fleet.update -> findContainer (no existing)
535
- .mockResolvedValueOnce([{ Id: "container-123" }]) // fleet.start
536
- .mockResolvedValueOnce([{ Id: "container-123" }]) // waitForHealthy
537
- .mockResolvedValueOnce([]) // rollback fleet.update -> findContainer
538
- .mockResolvedValueOnce([{ Id: "container-123" }]); // rollback fleet.start
545
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 1: getContainerDigest
546
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 2: doUpdateBot wasRunning
547
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4a: fleet.update -> findContainer
548
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 4f: fleet.update -> findContainer after recreate
549
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 5: waitForHealthy
550
+ .mockResolvedValueOnce([{ Id: "container-123" }]) // 6a: rollback fleet.update -> findContainer
551
+ .mockResolvedValueOnce([{ Id: "container-123" }]); // 6f: rollback fleet.update -> findContainer after recreate
539
552
  docker.getContainer
540
- .mockReturnValueOnce(container) // getContainerDigest inspect
541
- .mockReturnValueOnce(container) // step 2: stop + remove
542
- .mockReturnValueOnce(container) // fleet.start
543
- .mockReturnValueOnce(unhealthyContainer) // waitForHealthy inspect
544
- .mockReturnValueOnce(container); // rollback fleet.start
553
+ .mockReturnValueOnce(container) // 1: getContainerDigest
554
+ .mockReturnValueOnce(container) // 2: doUpdateBot wasRunning
555
+ .mockReturnValueOnce(container) // 4a: fleet.update -> findContainer (stop+remove this)
556
+ .mockReturnValueOnce(container) // 4f: fleet.update -> findContainer after recreate (start)
557
+ .mockReturnValueOnce(unhealthyContainer) // 5: waitForHealthy -> unhealthy
558
+ .mockReturnValueOnce(container) // 6a: rollback fleet.update -> findContainer (stop+remove)
559
+ .mockReturnValueOnce(container); // 6f: rollback fleet.update -> findContainer after recreate (start)
545
560
  const result = await updater.updateBot("bot-1");
546
561
  expect(result.success).toBe(false);
547
562
  expect(result.rolledBack).toBe(true);
@@ -3,6 +3,7 @@ export * from "./fleet-manager.js";
3
3
  export type { FleetNotificationListenerDeps } from "./fleet-notification-listener.js";
4
4
  export { initFleetNotificationListener } from "./fleet-notification-listener.js";
5
5
  export * from "./init-fleet-updater.js";
6
+ export * from "./instance.js";
6
7
  export * from "./repository-types.js";
7
8
  export * from "./rollout-orchestrator.js";
8
9
  export * from "./rollout-strategy.js";
@@ -2,6 +2,7 @@ export * from "./drizzle-tenant-update-config-repository.js";
2
2
  export * from "./fleet-manager.js";
3
3
  export { initFleetNotificationListener } from "./fleet-notification-listener.js";
4
4
  export * from "./init-fleet-updater.js";
5
+ export * from "./instance.js";
5
6
  export * from "./repository-types.js";
6
7
  export * from "./rollout-orchestrator.js";
7
8
  export * from "./rollout-strategy.js";
@@ -0,0 +1,54 @@
1
+ import type Docker from "dockerode";
2
+ import type { ProxyManagerInterface } from "../proxy/types.js";
3
+ import type { IBotInstanceRepository } from "./bot-instance-repository.js";
4
+ import type { FleetEventEmitter } from "./fleet-event-emitter.js";
5
+ import type { BotProfile } from "./types.js";
6
+ /**
7
+ * Instance — a runtime handle to a container.
8
+ *
9
+ * FleetManager is the factory: pull image, create container, return Instance.
10
+ * Instance owns its lifecycle: start, stop, remove, setupBilling, setupProxy.
11
+ *
12
+ * Ephemeral instances (e.g., holyshippers) skip billing and proxy setup.
13
+ * They bill per-token at the gateway layer, not per-instance.
14
+ */
15
+ export interface InstanceDeps {
16
+ docker: Docker;
17
+ profile: BotProfile;
18
+ containerId: string;
19
+ containerName: string;
20
+ url: string;
21
+ /** Optional — non-ephemeral instances use these for billing/proxy/events */
22
+ instanceRepo?: IBotInstanceRepository;
23
+ proxyManager?: ProxyManagerInterface;
24
+ eventEmitter?: FleetEventEmitter;
25
+ }
26
+ export declare class Instance {
27
+ readonly id: string;
28
+ readonly containerId: string;
29
+ readonly containerName: string;
30
+ readonly url: string;
31
+ readonly profile: BotProfile;
32
+ private readonly docker;
33
+ private readonly instanceRepo;
34
+ private readonly proxyManager;
35
+ private readonly eventEmitter;
36
+ constructor(deps: InstanceDeps);
37
+ /** Emit bot.created — call only from FleetManager.create(), not getInstance() */
38
+ emitCreated(): void;
39
+ start(): Promise<void>;
40
+ stop(): Promise<void>;
41
+ remove(): Promise<void>;
42
+ status(): Promise<"running" | "stopped" | "gone">;
43
+ /**
44
+ * Register this instance in the billing system.
45
+ * Skip for ephemeral instances — they bill per-token, not per-instance.
46
+ */
47
+ setupBilling(): Promise<void>;
48
+ /**
49
+ * Register a proxy route for tenant subdomain routing.
50
+ * Skip for ephemeral instances — they're accessed directly via Docker DNS.
51
+ */
52
+ setupProxy(): Promise<void>;
53
+ private emit;
54
+ }
@@ -0,0 +1,136 @@
1
+ import { logger } from "../config/logger.js";
2
+ export class Instance {
3
+ id;
4
+ containerId;
5
+ containerName;
6
+ url;
7
+ profile;
8
+ docker;
9
+ instanceRepo;
10
+ proxyManager;
11
+ eventEmitter;
12
+ constructor(deps) {
13
+ this.id = deps.profile.id;
14
+ this.containerId = deps.containerId;
15
+ this.containerName = deps.containerName;
16
+ this.url = deps.url;
17
+ this.profile = deps.profile;
18
+ this.docker = deps.docker;
19
+ this.instanceRepo = deps.instanceRepo;
20
+ this.proxyManager = deps.proxyManager;
21
+ this.eventEmitter = deps.eventEmitter;
22
+ }
23
+ /** Emit bot.created — call only from FleetManager.create(), not getInstance() */
24
+ emitCreated() {
25
+ this.emit("bot.created");
26
+ }
27
+ async start() {
28
+ const container = this.docker.getContainer(this.containerId);
29
+ await container.start();
30
+ logger.info(`Instance started`, { id: this.id, containerName: this.containerName, url: this.url });
31
+ this.emit("bot.started");
32
+ }
33
+ async stop() {
34
+ const container = this.docker.getContainer(this.containerId);
35
+ try {
36
+ await container.stop({ t: 10 });
37
+ }
38
+ catch (err) {
39
+ const msg = err instanceof Error ? err.message : String(err);
40
+ if (!msg.includes("not running") && !msg.includes("already stopped")) {
41
+ throw err;
42
+ }
43
+ }
44
+ logger.info(`Instance stopped`, { id: this.id, containerName: this.containerName });
45
+ this.emit("bot.stopped");
46
+ }
47
+ async remove() {
48
+ const container = this.docker.getContainer(this.containerId);
49
+ try {
50
+ await container.stop({ t: 5 }).catch(() => { });
51
+ await container.remove({ force: true });
52
+ }
53
+ catch (err) {
54
+ const msg = err instanceof Error ? err.message : String(err);
55
+ if (!msg.includes("No such container")) {
56
+ throw err;
57
+ }
58
+ }
59
+ if (this.proxyManager) {
60
+ try {
61
+ await this.proxyManager.removeRoute(this.id);
62
+ }
63
+ catch (err) {
64
+ logger.warn("Proxy route cleanup failed (non-fatal)", { id: this.id, err });
65
+ }
66
+ }
67
+ logger.info(`Instance removed`, { id: this.id, containerName: this.containerName });
68
+ this.emit("bot.removed");
69
+ }
70
+ async status() {
71
+ try {
72
+ const container = this.docker.getContainer(this.containerId);
73
+ const info = await container.inspect();
74
+ return info.State.Running ? "running" : "stopped";
75
+ }
76
+ catch {
77
+ return "gone";
78
+ }
79
+ }
80
+ /**
81
+ * Register this instance in the billing system.
82
+ * Skip for ephemeral instances — they bill per-token, not per-instance.
83
+ */
84
+ async setupBilling() {
85
+ if (this.profile.ephemeral) {
86
+ logger.info("Skipping billing setup (ephemeral)", { id: this.id });
87
+ return;
88
+ }
89
+ if (!this.instanceRepo) {
90
+ logger.warn("No instance repo — billing setup skipped", { id: this.id });
91
+ return;
92
+ }
93
+ await this.instanceRepo.register(this.id, this.profile.tenantId, this.profile.name);
94
+ logger.info("Billing registered", { id: this.id, tenantId: this.profile.tenantId });
95
+ }
96
+ /**
97
+ * Register a proxy route for tenant subdomain routing.
98
+ * Skip for ephemeral instances — they're accessed directly via Docker DNS.
99
+ */
100
+ async setupProxy() {
101
+ if (this.profile.ephemeral) {
102
+ logger.info("Skipping proxy setup (ephemeral)", { id: this.id });
103
+ return;
104
+ }
105
+ if (!this.proxyManager) {
106
+ logger.warn("No proxy manager — proxy setup skipped", { id: this.id });
107
+ return;
108
+ }
109
+ try {
110
+ const subdomain = this.profile.name.toLowerCase().replace(/_/g, "-");
111
+ const envPort = this.profile.env?.PORT;
112
+ const upstreamPort = envPort ? Number.parseInt(envPort, 10) || 7437 : 7437;
113
+ await this.proxyManager.addRoute({
114
+ instanceId: this.id,
115
+ subdomain,
116
+ upstreamHost: this.containerName,
117
+ upstreamPort,
118
+ healthy: true,
119
+ });
120
+ logger.info("Proxy route registered", { id: this.id, subdomain });
121
+ }
122
+ catch (err) {
123
+ logger.warn("Proxy route registration failed (non-fatal)", { id: this.id, err });
124
+ }
125
+ }
126
+ emit(type) {
127
+ if (this.eventEmitter) {
128
+ this.eventEmitter.emit({
129
+ type,
130
+ botId: this.id,
131
+ tenantId: this.profile.tenantId,
132
+ timestamp: new Date().toISOString(),
133
+ });
134
+ }
135
+ }
136
+ }
@@ -100,15 +100,11 @@ export class ContainerUpdater {
100
100
  resolve();
101
101
  });
102
102
  });
103
- // Step 2: Delegate stop -> remove -> recreate to FleetManager.update().
104
- // fleet.update() already implements: stop old -> remove old -> create new,
103
+ // Step 2: Delegate stop -> remove -> recreate -> start to FleetManager.update().
104
+ // fleet.update() already implements: stop old -> remove old -> create new -> start if was running,
105
105
  // with profile rollback if container creation fails.
106
106
  await this.fleet.update(botId, { image: profile.image });
107
- // Step 3: Start the new container only if the old one was running
108
- if (wasRunning) {
109
- await this.fleet.start(botId);
110
- }
111
- // Step 4: Verify health (only meaningful if container is running)
107
+ // Step 3: Verify health (only meaningful if container is running)
112
108
  if (wasRunning) {
113
109
  const healthy = await this.waitForHealthy(botId);
114
110
  if (!healthy) {
@@ -196,19 +192,14 @@ export class ContainerUpdater {
196
192
  * Roll back to a previous image by updating the bot profile and recreating.
197
193
  * Only starts the container on rollback if it was running before the update.
198
194
  */
199
- async rollback(botId, previousImage, previousDigest, wasRunning) {
195
+ async rollback(botId, previousImage, previousDigest, _wasRunning) {
200
196
  // Use digest-pinned image reference when available to prevent rollback
201
197
  // from pulling a newer image that was pushed between the update and rollback.
202
198
  const rollbackImage = previousDigest ? `${previousImage.split(":")[0]}@${previousDigest}` : previousImage;
203
199
  logger.info(`Rolling back bot ${botId} to ${rollbackImage}`);
204
200
  try {
201
+ // fleet.update() handles stop -> remove -> recreate -> start-if-was-running internally
205
202
  await this.fleet.update(botId, { image: rollbackImage });
206
- // Only start on rollback if the container was running before the update
207
- if (wasRunning) {
208
- await this.fleet.start(botId).catch((err) => {
209
- logger.warn(`Failed to start bot ${botId} during rollback (may already be running)`, { err });
210
- });
211
- }
212
203
  return {
213
204
  botId,
214
205
  success: false,
@@ -66,7 +66,6 @@ function mockDocker(container = null) {
66
66
  function mockFleet() {
67
67
  return {
68
68
  update: vi.fn().mockResolvedValue(undefined),
69
- start: vi.fn().mockResolvedValue(undefined),
70
69
  };
71
70
  }
72
71
  function mockPoller() {
@@ -131,7 +130,6 @@ describe("ContainerUpdater", () => {
131
130
  // Verify the update pipeline was called
132
131
  expect(docker.pull).toHaveBeenCalledWith("ghcr.io/wopr-network/wopr:stable");
133
132
  expect(fleet.update).toHaveBeenCalledWith("bot-1", { image: "ghcr.io/wopr-network/wopr:stable" });
134
- expect(fleet.start).toHaveBeenCalledWith("bot-1");
135
133
  });
136
134
  it("skips start and health check when container was not running", async () => {
137
135
  const stoppedContainer = mockContainer({
@@ -154,7 +152,6 @@ describe("ContainerUpdater", () => {
154
152
  const result = await updater.updateBot("bot-1");
155
153
  expect(result.success).toBe(true);
156
154
  expect(fleet.update).toHaveBeenCalled();
157
- expect(fleet.start).not.toHaveBeenCalled();
158
155
  });
159
156
  it("considers container healthy when no HEALTHCHECK is configured", async () => {
160
157
  const noHealthContainer = mockContainer({
@@ -221,8 +218,6 @@ describe("ContainerUpdater", () => {
221
218
  expect(fleet.update).toHaveBeenLastCalledWith("bot-1", {
222
219
  image: "ghcr.io/wopr-network/wopr@sha256:abc123",
223
220
  });
224
- // Rollback starts the container since it was running
225
- expect(fleet.start).toHaveBeenCalledTimes(2);
226
221
  });
227
222
  it("rolls back when health check times out (stays in starting)", async () => {
228
223
  const startingContainer = mockContainer({
@@ -257,16 +252,18 @@ describe("ContainerUpdater", () => {
257
252
  });
258
253
  });
259
254
  // --- Rollback on startup failure ---
260
- describe("rollback on startup failure", () => {
261
- it("rolls back when fleet.start throws", async () => {
255
+ describe("rollback on update failure", () => {
256
+ it("rolls back when fleet.update throws (start embedded in update)", async () => {
262
257
  // getContainerDigest
263
258
  docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
264
259
  docker.getContainer.mockReturnValueOnce(container);
265
260
  // wasRunning check
266
261
  docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
267
262
  docker.getContainer.mockReturnValueOnce(container);
268
- // fleet.start fails after fleet.update succeeds
269
- fleet.start.mockRejectedValueOnce(new Error("Container start failed"));
263
+ // fleet.update fails (which now includes starting the container)
264
+ fleet.update
265
+ .mockRejectedValueOnce(new Error("Container start failed"))
266
+ .mockResolvedValueOnce(undefined); // rollback update succeeds
270
267
  const result = await updater.updateBot("bot-1");
271
268
  expect(result.success).toBe(false);
272
269
  expect(result.rolledBack).toBe(true);
@@ -282,12 +279,10 @@ describe("ContainerUpdater", () => {
282
279
  // wasRunning check
283
280
  docker.listContainers.mockResolvedValueOnce([{ Id: "container-123" }]);
284
281
  docker.getContainer.mockReturnValueOnce(container);
285
- // fleet.start fails
286
- fleet.start.mockRejectedValueOnce(new Error("Container start failed"));
287
- // fleet.update (rollback) also fails
282
+ // fleet.update fails, then rollback fleet.update also fails
288
283
  fleet.update
289
- .mockResolvedValueOnce(undefined) // initial update succeeds
290
- .mockRejectedValueOnce(new Error("Rollback recreate failed")); // rollback fails
284
+ .mockRejectedValueOnce(new Error("Container start failed"))
285
+ .mockRejectedValueOnce(new Error("Rollback recreate failed"));
291
286
  const result = await updater.updateBot("bot-1");
292
287
  expect(result.success).toBe(false);
293
288
  expect(result.rolledBack).toBe(false);
@@ -548,8 +543,6 @@ describe("ContainerUpdater", () => {
548
543
  const result = await updater.updateBot("bot-1");
549
544
  expect(result.success).toBe(false);
550
545
  expect(result.rolledBack).toBe(true);
551
- // fleet.start should NOT be called at all — container was stopped
552
- expect(fleet.start).not.toHaveBeenCalled();
553
546
  });
554
547
  });
555
548
  // --- Edge case: wasRunning detection failure ---
@@ -565,8 +558,6 @@ describe("ContainerUpdater", () => {
565
558
  docker.getContainer.mockReturnValueOnce(container);
566
559
  const result = await updater.updateBot("bot-1");
567
560
  expect(result.success).toBe(true);
568
- // Since wasRunning defaults to false on error, start and health check are skipped
569
- expect(fleet.start).not.toHaveBeenCalled();
570
561
  });
571
562
  });
572
563
  });
@@ -46,9 +46,9 @@ export declare const commandSchema: z.ZodObject<{
46
46
  type: z.ZodEnum<{
47
47
  "bot.logs": "bot.logs";
48
48
  "bot.start": "bot.start";
49
- "bot.stop": "bot.stop";
50
49
  "bot.restart": "bot.restart";
51
50
  "bot.remove": "bot.remove";
51
+ "bot.stop": "bot.stop";
52
52
  "bot.update": "bot.update";
53
53
  "bot.export": "bot.export";
54
54
  "bot.import": "bot.import";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.38.0",
3
+ "version": "1.39.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,8 +12,10 @@ function mockContainer() {
12
12
  remove: vi.fn().mockResolvedValue(undefined),
13
13
  inspect: vi.fn().mockResolvedValue({
14
14
  Id: "container-123",
15
+ Name: "/wopr-test",
15
16
  Created: "2026-01-01T00:00:00Z",
16
17
  State: { Status: "running", Running: true, StartedAt: "2026-01-01T00:00:00Z" },
18
+ NetworkSettings: { Ports: {} },
17
19
  }),
18
20
  stats: vi.fn().mockResolvedValue({
19
21
  cpu_stats: { cpu_usage: { total_usage: 200 }, system_cpu_usage: 2000, online_cpus: 2 },
@@ -28,7 +30,7 @@ function makeDocker(): Docker {
28
30
  return {
29
31
  pull: vi.fn().mockResolvedValue("stream"),
30
32
  createContainer: vi.fn().mockResolvedValue(mockContainer()),
31
- listContainers: vi.fn().mockResolvedValue([]),
33
+ listContainers: vi.fn().mockResolvedValue([{ Id: "container-123" }]),
32
34
  getContainer: vi.fn().mockReturnValue(mockContainer()),
33
35
  modem: {
34
36
  followProgress: vi.fn((_stream: unknown, cb: (err: Error | null) => void) => cb(null)),