@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.
- package/dist/fleet/fleet-manager.d.ts +14 -35
- package/dist/fleet/fleet-manager.js +52 -236
- package/dist/fleet/fleet-manager.test.js +13 -85
- package/dist/fleet/instance.d.ts +58 -3
- package/dist/fleet/instance.js +297 -33
- package/dist/fleet/instance.test.d.ts +1 -0
- package/dist/fleet/instance.test.js +343 -0
- package/dist/node-agent/types.d.ts +1 -1
- package/package.json +1 -1
- package/src/credits/README.md +106 -0
- package/src/fleet/fleet-manager.test.ts +13 -111
- package/src/fleet/fleet-manager.ts +50 -255
- package/src/fleet/instance.test.ts +390 -0
- package/src/fleet/instance.ts +318 -33
|
@@ -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
|
-
*
|
|
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
|
-
/**
|
|
111
|
-
|
|
98
|
+
/** Fields that require container recreation when changed. */
|
|
99
|
+
private static readonly CONTAINER_FIELDS;
|
|
112
100
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
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
|
|
283
|
-
|
|
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
|
-
*
|
|
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
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
return
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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");
|