@wopr-network/platform-core 1.39.5 → 1.39.6

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.
@@ -53,20 +53,17 @@ export declare class FleetManager {
53
53
  */
54
54
  getInstance(id: string): Promise<Instance>;
55
55
  private buildInstance;
56
- /**
57
- * Restart: pull new image BEFORE restarting container to avoid downtime on pull failure.
58
- * Valid from: running, stopped, exited, dead states.
59
- * Throws InvalidStateTransitionError if the container is in an invalid state (e.g. paused).
60
- * For remote bots, delegates to the node agent via NodeCommandBus.
61
- */
62
- restart(id: string): Promise<void>;
63
56
  /**
64
57
  * Remove a bot: stop container, remove it, optionally remove volumes, delete profile.
65
58
  * For remote bots, delegates stop+remove to the node agent via NodeCommandBus.
59
+ * Container removal is delegated to Instance.remove(); fleet-level cleanup
60
+ * (profile store, network policy) stays here.
66
61
  */
67
62
  remove(id: string, removeVolumes?: boolean): Promise<void>;
68
63
  /**
69
64
  * Get live status of a single bot.
65
+ * Delegates to Instance.status() which returns a full BotStatus.
66
+ * Falls back to offline status when no container exists.
70
67
  */
71
68
  status(id: string): Promise<BotStatus>;
72
69
  /**
@@ -78,58 +75,40 @@ export declare class FleetManager {
78
75
  */
79
76
  listByTenant(tenantId: string): Promise<BotStatus[]>;
80
77
  /**
81
- * Get container logs.
78
+ * Get container logs. Delegates to Instance.logs().
82
79
  */
83
80
  logs(id: string, tail?: number): Promise<string>;
84
81
  /**
85
82
  * Stream container logs in real-time (follow mode).
86
- * Returns a Node.js ReadableStream that emits plain-text log chunks (already demultiplexed).
87
83
  * For remote bots, proxies via node-agent bot.logs command and returns a one-shot stream.
88
- * Caller is responsible for destroying the stream when done.
84
+ * For local bots, delegates to Instance.logStream().
89
85
  */
90
86
  logStream(id: string, opts: {
91
87
  since?: string;
92
88
  tail?: number;
93
89
  }): Promise<NodeJS.ReadableStream>;
94
- /** Fields that require container recreation when changed. */
95
- private static readonly CONTAINER_FIELDS;
96
- /**
97
- * Update a bot profile. Only recreates the container if container-relevant
98
- * fields changed. Rolls back the profile if container recreation fails.
99
- */
100
- update(id: string, updates: Partial<Omit<BotProfile, "id">>): Promise<BotProfile>;
101
90
  /**
102
- * Get disk usage for a bot's /data volume.
103
- * Returns null if the container is not running or exec fails.
91
+ * Get disk usage for a bot's /data volume. Delegates to Instance.getVolumeUsage().
104
92
  */
105
93
  getVolumeUsage(id: string): Promise<{
106
94
  usedBytes: number;
107
95
  totalBytes: number;
108
96
  availableBytes: number;
109
97
  } | null>;
110
- /** Get the underlying profile store */
111
- get profiles(): IProfileStore;
98
+ /** Fields that require container recreation when changed. */
99
+ private static readonly CONTAINER_FIELDS;
112
100
  /**
113
- * Assert that a container's current state is valid for the requested operation.
114
- * Guards against undefined/null Status values from Docker (uses "unknown" as fallback).
115
- * Throws InvalidStateTransitionError when the state is not in validStates.
101
+ * Update a bot profile. Only recreates the container if container-relevant
102
+ * fields changed. Rolls back the profile if container recreation fails.
116
103
  */
117
- private assertValidState;
104
+ update(id: string, updates: Partial<Omit<BotProfile, "id">>): Promise<BotProfile>;
105
+ /** Get the underlying profile store */
106
+ get profiles(): IProfileStore;
118
107
  private pullImage;
119
108
  private createContainer;
120
109
  private findContainer;
121
110
  private statusForProfile;
122
- private buildStatus;
123
- private offlineStatus;
124
- private getStats;
125
111
  }
126
112
  export declare class BotNotFoundError extends Error {
127
113
  constructor(id: string);
128
114
  }
129
- export declare class InvalidStateTransitionError extends Error {
130
- readonly botId: string;
131
- readonly operation: string;
132
- readonly currentState: string;
133
- readonly validStates: string[];
134
- constructor(botId: string, operation: string, currentState: string, validStates: string[]);
135
- }
@@ -106,6 +106,7 @@ export class FleetManager {
106
106
  instanceRepo: this.instanceRepo,
107
107
  proxyManager: this.proxyManager,
108
108
  eventEmitter: this.eventEmitter,
109
+ botMetricsTracker: this.botMetricsTracker,
109
110
  });
110
111
  remoteInstance.emitCreated();
111
112
  return remoteInstance;
@@ -173,45 +174,14 @@ export class FleetManager {
173
174
  instanceRepo: this.instanceRepo,
174
175
  proxyManager: this.proxyManager,
175
176
  eventEmitter: this.eventEmitter,
176
- });
177
- }
178
- /**
179
- * Restart: pull new image BEFORE restarting container to avoid downtime on pull failure.
180
- * Valid from: running, stopped, exited, dead states.
181
- * Throws InvalidStateTransitionError if the container is in an invalid state (e.g. paused).
182
- * For remote bots, delegates to the node agent via NodeCommandBus.
183
- */
184
- async restart(id) {
185
- return this.withLock(id, async () => {
186
- this.botMetricsTracker?.reset(id);
187
- const profile = await this.store.get(id);
188
- if (!profile)
189
- throw new BotNotFoundError(id);
190
- const remote = await this.resolveNodeId(id);
191
- if (remote) {
192
- await remote.commandBus.send(remote.nodeId, {
193
- type: "bot.restart",
194
- payload: { name: profile.name },
195
- });
196
- }
197
- else {
198
- // Pull new image first — if this fails, old container is unchanged
199
- await this.pullImage(profile.image);
200
- const container = await this.findContainer(id);
201
- if (!container)
202
- throw new BotNotFoundError(id);
203
- const info = await container.inspect();
204
- const validRestartStates = new Set(["running", "stopped", "exited", "dead"]);
205
- this.assertValidState(id, info.State.Status, "restart", validRestartStates);
206
- await container.restart();
207
- }
208
- logger.info(`Restarted bot ${id}`);
209
- this.emitEvent("bot.restarted", id, profile.tenantId);
177
+ botMetricsTracker: this.botMetricsTracker,
210
178
  });
211
179
  }
212
180
  /**
213
181
  * Remove a bot: stop container, remove it, optionally remove volumes, delete profile.
214
182
  * For remote bots, delegates stop+remove to the node agent via NodeCommandBus.
183
+ * Container removal is delegated to Instance.remove(); fleet-level cleanup
184
+ * (profile store, network policy) stays here.
215
185
  */
216
186
  async remove(id, removeVolumes = false) {
217
187
  return this.withLock(id, async () => {
@@ -226,13 +196,12 @@ export class FleetManager {
226
196
  });
227
197
  }
228
198
  else {
229
- const container = await this.findContainer(id);
230
- if (container) {
231
- const info = await container.inspect();
232
- if (info.State.Running) {
233
- await container.stop();
234
- }
235
- await container.remove({ v: removeVolumes });
199
+ try {
200
+ const instance = await this.buildInstance(profile);
201
+ await instance.remove(removeVolumes);
202
+ }
203
+ catch {
204
+ // Container may already be gone — not fatal for fleet-level cleanup
236
205
  }
237
206
  }
238
207
  // Clean up tenant network if no more containers remain
@@ -240,25 +209,20 @@ export class FleetManager {
240
209
  await this.networkPolicy.cleanupAfterRemoval(profile.tenantId);
241
210
  }
242
211
  await this.store.delete(id);
243
- if (this.proxyManager) {
244
- this.proxyManager.removeRoute(id);
245
- }
246
212
  logger.info(`Removed bot ${id}`);
247
213
  this.emitEvent("bot.removed", id, profile.tenantId);
248
214
  });
249
215
  }
250
216
  /**
251
217
  * Get live status of a single bot.
218
+ * Delegates to Instance.status() which returns a full BotStatus.
219
+ * Falls back to offline status when no container exists.
252
220
  */
253
221
  async status(id) {
254
222
  const profile = await this.store.get(id);
255
223
  if (!profile)
256
224
  throw new BotNotFoundError(id);
257
- const container = await this.findContainer(id);
258
- if (!container) {
259
- return this.offlineStatus(profile);
260
- }
261
- return this.buildStatus(profile, container);
225
+ return this.statusForProfile(profile);
262
226
  }
263
227
  /**
264
228
  * List all bots with live status.
@@ -276,40 +240,16 @@ export class FleetManager {
276
240
  return Promise.all(tenantProfiles.map((p) => this.statusForProfile(p)));
277
241
  }
278
242
  /**
279
- * Get container logs.
243
+ * Get container logs. Delegates to Instance.logs().
280
244
  */
281
245
  async logs(id, tail = 100) {
282
- const container = await this.findContainer(id);
283
- if (!container)
284
- throw new BotNotFoundError(id);
285
- const logBuffer = await container.logs({
286
- stdout: true,
287
- stderr: true,
288
- tail,
289
- timestamps: true,
290
- });
291
- // Docker returns multiplexed binary frames when Tty is false (the default).
292
- // Demultiplex by stripping the 8-byte header from each frame so callers
293
- // receive plain text instead of binary garbage interleaved with log lines.
294
- const buf = Buffer.isBuffer(logBuffer) ? logBuffer : Buffer.from(logBuffer, "binary");
295
- const chunks = [];
296
- let offset = 0;
297
- while (offset + 8 <= buf.length) {
298
- const frameSize = buf.readUInt32BE(offset + 4);
299
- const end = offset + 8 + frameSize;
300
- if (end > buf.length)
301
- break;
302
- chunks.push(buf.subarray(offset + 8, end));
303
- offset = end;
304
- }
305
- // If demux produced nothing (e.g. TTY container), fall back to raw string
306
- return chunks.length > 0 ? Buffer.concat(chunks).toString("utf-8") : buf.toString("utf-8");
246
+ const instance = await this.getInstance(id);
247
+ return instance.logs(tail);
307
248
  }
308
249
  /**
309
250
  * Stream container logs in real-time (follow mode).
310
- * Returns a Node.js ReadableStream that emits plain-text log chunks (already demultiplexed).
311
251
  * For remote bots, proxies via node-agent bot.logs command and returns a one-shot stream.
312
- * Caller is responsible for destroying the stream when done.
252
+ * For local bots, delegates to Instance.logStream().
313
253
  */
314
254
  async logStream(id, opts) {
315
255
  // Check for remote node assignment first (mirrors start/stop/restart pattern)
@@ -327,26 +267,20 @@ export class FleetManager {
327
267
  pt.end(logData);
328
268
  return pt;
329
269
  }
330
- const container = await this.findContainer(id);
331
- if (!container)
332
- throw new BotNotFoundError(id);
333
- const logOpts = {
334
- stdout: true,
335
- stderr: true,
336
- follow: true,
337
- tail: opts.tail ?? 100,
338
- timestamps: true,
339
- };
340
- if (opts.since) {
341
- logOpts.since = opts.since;
270
+ const instance = await this.getInstance(id);
271
+ return instance.logStream(opts);
272
+ }
273
+ /**
274
+ * Get disk usage for a bot's /data volume. Delegates to Instance.getVolumeUsage().
275
+ */
276
+ async getVolumeUsage(id) {
277
+ try {
278
+ const instance = await this.getInstance(id);
279
+ return instance.getVolumeUsage();
280
+ }
281
+ catch {
282
+ return null;
342
283
  }
343
- // Docker returns a multiplexed binary stream when Tty is false (the default for
344
- // containers created by createContainer without Tty:true). Demultiplex it so
345
- // callers receive plain text without 8-byte binary frame headers.
346
- const multiplexed = (await container.logs(logOpts));
347
- const pt = new PassThrough();
348
- this.docker.modem.demuxStream(multiplexed, pt, pt);
349
- return pt;
350
284
  }
351
285
  /** Fields that require container recreation when changed. */
352
286
  static CONTAINER_FIELDS = new Set([
@@ -417,72 +351,11 @@ export class FleetManager {
417
351
  return updated;
418
352
  });
419
353
  }
420
- /**
421
- * Get disk usage for a bot's /data volume.
422
- * Returns null if the container is not running or exec fails.
423
- */
424
- async getVolumeUsage(id) {
425
- const container = await this.findContainer(id);
426
- if (!container)
427
- return null;
428
- try {
429
- const info = await container.inspect();
430
- if (!info.State.Running)
431
- return null;
432
- const exec = await container.exec({
433
- Cmd: ["df", "-B1", "/data"],
434
- AttachStdout: true,
435
- AttachStderr: false,
436
- });
437
- const output = await new Promise((resolve, reject) => {
438
- exec.start({}, (err, stream) => {
439
- if (err)
440
- return reject(err);
441
- if (!stream)
442
- return reject(new Error("No stream from exec"));
443
- let data = "";
444
- stream.on("data", (chunk) => {
445
- data += chunk.toString();
446
- });
447
- stream.on("end", () => resolve(data));
448
- stream.on("error", reject);
449
- });
450
- });
451
- // Parse df output — second line has the numbers
452
- const lines = output.trim().split("\n");
453
- if (lines.length < 2)
454
- return null;
455
- const parts = lines[lines.length - 1].split(/\s+/);
456
- if (parts.length < 4)
457
- return null;
458
- const totalBytes = parseInt(parts[1], 10);
459
- const usedBytes = parseInt(parts[2], 10);
460
- const availableBytes = parseInt(parts[3], 10);
461
- if (Number.isNaN(totalBytes) || Number.isNaN(usedBytes) || Number.isNaN(availableBytes))
462
- return null;
463
- return { usedBytes, totalBytes, availableBytes };
464
- }
465
- catch {
466
- logger.warn(`Failed to get volume usage for bot ${id}`);
467
- return null;
468
- }
469
- }
470
354
  /** Get the underlying profile store */
471
355
  get profiles() {
472
356
  return this.store;
473
357
  }
474
358
  // --- Private helpers ---
475
- /**
476
- * Assert that a container's current state is valid for the requested operation.
477
- * Guards against undefined/null Status values from Docker (uses "unknown" as fallback).
478
- * Throws InvalidStateTransitionError when the state is not in validStates.
479
- */
480
- assertValidState(id, rawStatus, operation, validStates) {
481
- const currentState = typeof rawStatus === "string" && rawStatus ? rawStatus : "unknown";
482
- if (!validStates.has(currentState)) {
483
- throw new InvalidStateTransitionError(id, operation, currentState, [...validStates]);
484
- }
485
- }
486
359
  async pullImage(image) {
487
360
  logger.info(`Pulling image ${image}`);
488
361
  // Build authconfig from environment variables if present.
@@ -589,71 +462,29 @@ export class FleetManager {
589
462
  return this.docker.getContainer(containers[0].Id);
590
463
  }
591
464
  async statusForProfile(profile) {
592
- const container = await this.findContainer(profile.id);
593
- if (!container)
594
- return this.offlineStatus(profile);
595
- return this.buildStatus(profile, container);
596
- }
597
- async buildStatus(profile, container) {
598
- const info = await container.inspect();
599
- let stats = null;
600
- if (info.State.Running) {
601
- try {
602
- stats = await this.getStats(container);
603
- }
604
- catch {
605
- // stats not available
606
- }
465
+ try {
466
+ const instance = await this.buildInstance(profile);
467
+ return instance.status();
468
+ }
469
+ catch {
470
+ // Container not found — return offline status
471
+ const now = new Date().toISOString();
472
+ return {
473
+ id: profile.id,
474
+ name: profile.name,
475
+ description: profile.description,
476
+ image: profile.image,
477
+ containerId: null,
478
+ state: "stopped",
479
+ health: null,
480
+ uptime: null,
481
+ startedAt: null,
482
+ createdAt: now,
483
+ updatedAt: now,
484
+ stats: null,
485
+ applicationMetrics: null,
486
+ };
607
487
  }
608
- const now = new Date().toISOString();
609
- return {
610
- id: profile.id,
611
- name: profile.name,
612
- description: profile.description,
613
- image: profile.image,
614
- containerId: info.Id,
615
- state: info.State.Status,
616
- health: info.State.Health?.Status ?? null,
617
- uptime: info.State.Running && info.State.StartedAt ? info.State.StartedAt : null,
618
- startedAt: info.State.StartedAt || null,
619
- createdAt: info.Created || now,
620
- updatedAt: now,
621
- stats,
622
- applicationMetrics: this.botMetricsTracker?.getMetrics(profile.id) ?? null,
623
- };
624
- }
625
- offlineStatus(profile) {
626
- const now = new Date().toISOString();
627
- return {
628
- id: profile.id,
629
- name: profile.name,
630
- description: profile.description,
631
- image: profile.image,
632
- containerId: null,
633
- state: "stopped",
634
- health: null,
635
- uptime: null,
636
- startedAt: null,
637
- createdAt: now,
638
- updatedAt: now,
639
- stats: null,
640
- applicationMetrics: null,
641
- };
642
- }
643
- async getStats(container) {
644
- const raw = await container.stats({ stream: false });
645
- const cpuDelta = raw.cpu_stats.cpu_usage.total_usage - raw.precpu_stats.cpu_usage.total_usage;
646
- const systemDelta = raw.cpu_stats.system_cpu_usage - raw.precpu_stats.system_cpu_usage;
647
- const numCpus = raw.cpu_stats.online_cpus || 1;
648
- const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * numCpus * 100 : 0;
649
- const memUsage = raw.memory_stats.usage || 0;
650
- const memLimit = raw.memory_stats.limit || 1;
651
- return {
652
- cpuPercent: Math.round(cpuPercent * 100) / 100,
653
- memoryUsageMb: Math.round(memUsage / 1024 / 1024),
654
- memoryLimitMb: Math.round(memLimit / 1024 / 1024),
655
- memoryPercent: Math.round((memUsage / memLimit) * 100 * 100) / 100,
656
- };
657
488
  }
658
489
  }
659
490
  export class BotNotFoundError extends Error {
@@ -662,18 +493,3 @@ export class BotNotFoundError extends Error {
662
493
  this.name = "BotNotFoundError";
663
494
  }
664
495
  }
665
- export class InvalidStateTransitionError extends Error {
666
- botId;
667
- operation;
668
- currentState;
669
- validStates;
670
- constructor(botId, operation, currentState, validStates) {
671
- super(`Cannot ${operation} bot ${botId}: container is in state "${currentState}". ` +
672
- `Valid states for ${operation}: ${validStates.join(", ")}.`);
673
- this.name = "InvalidStateTransitionError";
674
- this.botId = botId;
675
- this.operation = operation;
676
- this.currentState = currentState;
677
- this.validStates = validStates;
678
- }
679
- }
@@ -126,74 +126,21 @@ describe("FleetManager", () => {
126
126
  expect(container.stop).toHaveBeenCalled();
127
127
  });
128
128
  });
129
- describe("restart", () => {
130
- it("pulls image before restarting a running container", async () => {
131
- // Store the profile first; default mockContainer has state "running" — valid for restart
132
- await store.save({ id: "bot-id", ...PROFILE_PARAMS });
133
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
134
- await fleet.restart("bot-id");
135
- // Pull is called first
136
- expect(docker.pull).toHaveBeenCalledWith(PROFILE_PARAMS.image, {});
137
- // Then restart
138
- expect(container.restart).toHaveBeenCalled();
139
- });
140
- it("restarts a container in exited state (crash recovery)", async () => {
141
- await store.save({ id: "bot-id", ...PROFILE_PARAMS });
142
- container.inspect.mockResolvedValue({
143
- Id: "container-123",
144
- Created: "2026-01-01T00:00:00Z",
145
- State: { Status: "exited", Running: false, StartedAt: "", Health: null },
146
- });
147
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
148
- await fleet.restart("bot-id");
149
- expect(container.restart).toHaveBeenCalled();
150
- });
151
- it("restarts a container in stopped state", async () => {
152
- await store.save({ id: "bot-id", ...PROFILE_PARAMS });
153
- container.inspect.mockResolvedValue({
154
- Id: "container-123",
155
- Created: "2026-01-01T00:00:00Z",
156
- State: { Status: "stopped", Running: false, StartedAt: "", Health: null },
157
- });
158
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
159
- await fleet.restart("bot-id");
160
- expect(container.restart).toHaveBeenCalled();
161
- });
162
- it("restarts a container in dead state (crash recovery)", async () => {
163
- await store.save({ id: "bot-id", ...PROFILE_PARAMS });
164
- container.inspect.mockResolvedValue({
165
- Id: "container-123",
166
- Created: "2026-01-01T00:00:00Z",
167
- State: { Status: "dead", Running: false, StartedAt: "", Health: null },
168
- });
169
- docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
170
- await fleet.restart("bot-id");
171
- expect(container.restart).toHaveBeenCalled();
172
- });
173
- it("does not restart if pull fails", async () => {
174
- await store.save({ id: "bot-id", ...PROFILE_PARAMS });
175
- docker.modem.followProgress.mockImplementation((_stream, cb) => cb(new Error("Pull failed")));
176
- await expect(fleet.restart("bot-id")).rejects.toThrow("Pull failed");
177
- expect(container.restart).not.toHaveBeenCalled();
178
- });
179
- it("throws BotNotFoundError for missing profile", async () => {
180
- await expect(fleet.restart("missing")).rejects.toThrow(BotNotFoundError);
181
- });
182
- });
129
+ // restart tests moved to instance.test.ts — Instance.restart() + Instance.pullImage()
183
130
  describe("remove", () => {
184
131
  it("stops running container, removes it, and deletes profile", async () => {
185
132
  await store.save({ id: "bot-id", ...PROFILE_PARAMS });
186
133
  docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
187
134
  await fleet.remove("bot-id");
188
135
  expect(container.stop).toHaveBeenCalled();
189
- expect(container.remove).toHaveBeenCalledWith({ v: false });
136
+ expect(container.remove).toHaveBeenCalledWith({ force: true, v: false });
190
137
  expect(store.delete).toHaveBeenCalledWith("bot-id");
191
138
  });
192
139
  it("removes volumes when requested", async () => {
193
140
  await store.save({ id: "bot-id", ...PROFILE_PARAMS });
194
141
  docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
195
142
  await fleet.remove("bot-id", true);
196
- expect(container.remove).toHaveBeenCalledWith({ v: true });
143
+ expect(container.remove).toHaveBeenCalledWith({ force: true, v: true });
197
144
  });
198
145
  it("deletes profile even when no container exists", async () => {
199
146
  await store.save({ id: "bot-id", ...PROFILE_PARAMS });
@@ -240,13 +187,13 @@ describe("FleetManager", () => {
240
187
  });
241
188
  describe("logs", () => {
242
189
  it("returns container logs", async () => {
190
+ await store.save({ id: "bot-id", ...PROFILE_PARAMS });
243
191
  docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
244
192
  const logs = await fleet.logs("bot-id", 50);
245
193
  expect(container.logs).toHaveBeenCalledWith(expect.objectContaining({ tail: 50, stdout: true, stderr: true }));
246
194
  expect(logs).toContain("log line 1");
247
195
  });
248
- it("throws BotNotFoundError when container not found", async () => {
249
- docker.listContainers.mockResolvedValue([]);
196
+ it("throws BotNotFoundError when profile not found", async () => {
250
197
  await expect(fleet.logs("missing")).rejects.toThrow(BotNotFoundError);
251
198
  });
252
199
  });
@@ -255,6 +202,7 @@ describe("FleetManager", () => {
255
202
  const { PassThrough } = await import("node:stream");
256
203
  const mockStream = new PassThrough();
257
204
  container.logs.mockResolvedValue(mockStream);
205
+ await store.save({ id: "bot-id", ...PROFILE_PARAMS });
258
206
  docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
259
207
  const stream = await fleet.logStream("bot-id", { since: "2026-01-01T00:00:00Z", tail: 50 });
260
208
  // Result is a PassThrough (demuxed), not the raw multiplexed stream
@@ -276,6 +224,7 @@ describe("FleetManager", () => {
276
224
  const { PassThrough } = await import("node:stream");
277
225
  const mockStream = new PassThrough();
278
226
  container.logs.mockResolvedValue(mockStream);
227
+ await store.save({ id: "bot-id", ...PROFILE_PARAMS });
279
228
  docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
280
229
  await fleet.logStream("bot-id", {});
281
230
  expect(container.logs).toHaveBeenCalledWith({
@@ -287,8 +236,7 @@ describe("FleetManager", () => {
287
236
  });
288
237
  mockStream.destroy();
289
238
  });
290
- it("throws BotNotFoundError when container not found", async () => {
291
- docker.listContainers.mockResolvedValue([]);
239
+ it("throws BotNotFoundError when profile not found", async () => {
292
240
  await expect(fleet.logStream("missing", {})).rejects.toThrow(BotNotFoundError);
293
241
  });
294
242
  it("proxies via node-agent for remote bots", async () => {
@@ -557,31 +505,7 @@ describe("FleetManager", () => {
557
505
  expect(bus.send).not.toHaveBeenCalled();
558
506
  expect(docker.pull).toHaveBeenCalled();
559
507
  });
560
- it("should dispatch restart via commandBus when bot has nodeId", async () => {
561
- const container = mockContainer();
562
- const docker = mockDocker(container);
563
- const store = mockStore();
564
- const bus = mockCommandBus();
565
- const instanceRepo = mockInstanceRepo();
566
- const botId = "bot-restart-1";
567
- await instanceRepo.create({ id: botId, tenantId: "t1", name: "restart-bot", nodeId: "node-3" });
568
- const fleet = new FleetManager(docker, store, undefined, undefined, undefined, bus, instanceRepo);
569
- await store.save({
570
- id: botId,
571
- tenantId: "t1",
572
- name: "restart-bot",
573
- description: "",
574
- image: "ghcr.io/wopr-network/wopr:latest",
575
- env: {},
576
- restartPolicy: "unless-stopped",
577
- });
578
- await fleet.restart(botId);
579
- expect(bus.send).toHaveBeenCalledWith("node-3", {
580
- type: "bot.restart",
581
- payload: { name: "restart-bot" },
582
- });
583
- expect(container.restart).not.toHaveBeenCalled();
584
- });
508
+ // remote restart test removed restart moved to Instance
585
509
  it("should dispatch remove via commandBus when bot has nodeId", async () => {
586
510
  const container = mockContainer();
587
511
  const docker = mockDocker(container);
@@ -645,6 +569,7 @@ describe("FleetManager", () => {
645
569
  const containerWithExec = mockContainer({
646
570
  exec: vi.fn().mockResolvedValue(execMock),
647
571
  });
572
+ await store.save({ id: "bot-id", ...PROFILE_PARAMS });
648
573
  docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
649
574
  docker.getContainer.mockReturnValue(containerWithExec);
650
575
  const result = await fleet.getVolumeUsage("bot-id");
@@ -655,6 +580,7 @@ describe("FleetManager", () => {
655
580
  });
656
581
  });
657
582
  it("returns null when container is not found", async () => {
583
+ await store.save({ id: "bot-id", ...PROFILE_PARAMS });
658
584
  docker.listContainers.mockResolvedValue([]);
659
585
  const result = await fleet.getVolumeUsage("bot-id");
660
586
  expect(result).toBeNull();
@@ -666,6 +592,7 @@ describe("FleetManager", () => {
666
592
  State: { Running: false },
667
593
  }),
668
594
  });
595
+ await store.save({ id: "bot-id", ...PROFILE_PARAMS });
669
596
  docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
670
597
  docker.getContainer.mockReturnValue(stoppedContainer);
671
598
  const result = await fleet.getVolumeUsage("bot-id");
@@ -675,6 +602,7 @@ describe("FleetManager", () => {
675
602
  const containerWithFailingExec = mockContainer({
676
603
  exec: vi.fn().mockRejectedValue(new Error("exec failed")),
677
604
  });
605
+ await store.save({ id: "bot-id", ...PROFILE_PARAMS });
678
606
  docker.listContainers.mockResolvedValue([{ Id: "container-123" }]);
679
607
  docker.getContainer.mockReturnValue(containerWithFailingExec);
680
608
  const result = await fleet.getVolumeUsage("bot-id");