@wopr-network/platform-core 1.30.0 → 1.31.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.
@@ -7,6 +7,8 @@ export interface BotFleetEvent {
7
7
  botId: string;
8
8
  tenantId: string;
9
9
  timestamp: string;
10
+ /** Image tag or version string, when available (e.g. from update events). */
11
+ version?: string;
10
12
  }
11
13
  export interface NodeFleetEvent {
12
14
  type: NodeEventType;
@@ -10,4 +10,4 @@ export interface FleetNotificationListenerDeps {
10
10
  /** Debounce window in ms before sending summary email. Default 60_000. */
11
11
  debounceMs?: number;
12
12
  }
13
- export declare function initFleetNotificationListener(deps: FleetNotificationListenerDeps): () => void;
13
+ export declare function initFleetNotificationListener(deps: FleetNotificationListenerDeps): () => Promise<void>;
@@ -17,9 +17,7 @@ export function initFleetNotificationListener(deps) {
17
17
  logger.warn("Fleet notification skipped: no email for tenant", { tenantId });
18
18
  return;
19
19
  }
20
- // TODO: Surface actual target version from RolloutOrchestrator context.
21
- // BotFleetEvent doesn't carry version info; "latest" is a placeholder.
22
- notificationService.notifyFleetUpdateComplete(tenantId, email, "latest", rollout.succeeded, rollout.failed);
20
+ notificationService.notifyFleetUpdateComplete(tenantId, email, rollout.version, rollout.succeeded, rollout.failed);
23
21
  }
24
22
  catch (err) {
25
23
  logger.error("Fleet notification flush error", { err, tenantId });
@@ -35,6 +33,7 @@ export function initFleetNotificationListener(deps) {
35
33
  if (!rollout) {
36
34
  rollout = {
37
35
  tenantId: botEvent.tenantId,
36
+ version: botEvent.version ?? "latest",
38
37
  succeeded: 0,
39
38
  failed: 0,
40
39
  timer: setTimeout(() => flush(botEvent.tenantId), debounceMs),
@@ -45,6 +44,8 @@ export function initFleetNotificationListener(deps) {
45
44
  // Reset timer on each new event (sliding window)
46
45
  clearTimeout(rollout.timer);
47
46
  rollout.timer = setTimeout(() => flush(botEvent.tenantId), debounceMs);
47
+ if (botEvent.version)
48
+ rollout.version = botEvent.version;
48
49
  }
49
50
  if (botEvent.type === "bot.updated") {
50
51
  rollout.succeeded++;
@@ -53,13 +54,14 @@ export function initFleetNotificationListener(deps) {
53
54
  rollout.failed++;
54
55
  }
55
56
  });
56
- return () => {
57
+ return async () => {
57
58
  unsubscribe();
58
- // Flush all pending on shutdown
59
+ const flushes = [];
59
60
  for (const [tenantId, rollout] of pending) {
60
61
  clearTimeout(rollout.timer);
61
- void flush(tenantId);
62
+ flushes.push(flush(tenantId));
62
63
  }
63
64
  pending.clear();
65
+ await Promise.allSettled(flushes);
64
66
  };
65
67
  }
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import type Docker from "dockerode";
14
14
  import type { IBotProfileRepository } from "./bot-profile-repository.js";
15
- import type { FleetEventEmitter } from "./fleet-event-emitter.js";
15
+ import { FleetEventEmitter } from "./fleet-event-emitter.js";
16
16
  import type { FleetManager } from "./fleet-manager.js";
17
17
  import { ImagePoller } from "./image-poller.js";
18
18
  import type { IProfileStore } from "./profile-store.js";
@@ -46,6 +46,8 @@ export interface FleetUpdaterHandle {
46
46
  updater: ContainerUpdater;
47
47
  orchestrator: RolloutOrchestrator;
48
48
  snapshotManager: VolumeSnapshotManager;
49
+ /** Fleet event emitter for subscribing to bot/node lifecycle events */
50
+ eventEmitter: FleetEventEmitter;
49
51
  /** Stop the poller and wait for any active rollout to finish */
50
52
  stop: () => Promise<void>;
51
53
  }
@@ -11,6 +11,7 @@
11
11
  * all bots need updating.
12
12
  */
13
13
  import { logger } from "../config/logger.js";
14
+ import { FleetEventEmitter } from "./fleet-event-emitter.js";
14
15
  import { ImagePoller } from "./image-poller.js";
15
16
  import { RolloutOrchestrator } from "./rollout-orchestrator.js";
16
17
  import { createRolloutStrategy } from "./rollout-strategy.js";
@@ -29,7 +30,8 @@ import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
29
30
  * @param config - Optional pipeline configuration
30
31
  */
31
32
  export function initFleetUpdater(docker, fleet, profileStore, profileRepo, config = {}) {
32
- const { strategy: strategyType = "rolling-wave", strategyOptions, snapshotDir = "/data/fleet/snapshots", onBotUpdated, onRolloutComplete, configRepo, eventEmitter, } = config;
33
+ const { strategy: strategyType = "rolling-wave", strategyOptions, snapshotDir = "/data/fleet/snapshots", onBotUpdated, onRolloutComplete, configRepo, eventEmitter: configEventEmitter, } = config;
34
+ const emitter = configEventEmitter ?? new FleetEventEmitter();
33
35
  const poller = new ImagePoller(docker, profileStore);
34
36
  const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
35
37
  const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
@@ -54,16 +56,28 @@ export function initFleetUpdater(docker, fleet, profileStore, profileRepo, confi
54
56
  return results.filter((p) => p !== null);
55
57
  },
56
58
  onBotUpdated: (result) => {
57
- // Emit fleet events if emitter is provided
58
- if (eventEmitter) {
59
- eventEmitter.emit({
59
+ // Fire-and-forget: resolve tenantId + emit event asynchronously
60
+ // The orchestrator callback is sync (void return) — async work must not block rollout progress
61
+ void (async () => {
62
+ let tenantId = "";
63
+ try {
64
+ const profile = await profileRepo.get(result.botId);
65
+ if (profile)
66
+ tenantId = profile.tenantId;
67
+ }
68
+ catch {
69
+ // Best-effort — event still fires with empty tenantId
70
+ }
71
+ // Extract version tag from image name (e.g. "ghcr.io/org/image:v1.2.3" → "v1.2.3")
72
+ const version = result.newImage.includes(":") ? (result.newImage.split(":").pop() ?? "latest") : "latest";
73
+ emitter.emit({
60
74
  type: result.success ? "bot.updated" : "bot.update_failed",
61
75
  botId: result.botId,
62
- tenantId: "",
76
+ tenantId,
63
77
  timestamp: new Date().toISOString(),
78
+ version,
64
79
  });
65
- }
66
- // Chain user-provided callback
80
+ })();
67
81
  onBotUpdated?.(result);
68
82
  },
69
83
  onRolloutComplete: (result) => {
@@ -96,6 +110,7 @@ export function initFleetUpdater(docker, fleet, profileStore, profileRepo, confi
96
110
  updater,
97
111
  orchestrator,
98
112
  snapshotManager,
113
+ eventEmitter: emitter,
99
114
  stop: async () => {
100
115
  poller.stop();
101
116
  // Wait for any in-flight rollout to complete before returning
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.30.0",
3
+ "version": "1.31.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -26,6 +26,8 @@ export interface BotFleetEvent {
26
26
  botId: string;
27
27
  tenantId: string;
28
28
  timestamp: string;
29
+ /** Image tag or version string, when available (e.g. from update events). */
30
+ version?: string;
29
31
  }
30
32
 
31
33
  export interface NodeFleetEvent {
@@ -15,12 +15,13 @@ export interface FleetNotificationListenerDeps {
15
15
 
16
16
  interface PendingRollout {
17
17
  tenantId: string;
18
+ version: string;
18
19
  succeeded: number;
19
20
  failed: number;
20
21
  timer: ReturnType<typeof setTimeout>;
21
22
  }
22
23
 
23
- export function initFleetNotificationListener(deps: FleetNotificationListenerDeps): () => void {
24
+ export function initFleetNotificationListener(deps: FleetNotificationListenerDeps): () => Promise<void> {
24
25
  const { eventEmitter, notificationService, preferences, resolveEmail } = deps;
25
26
  const debounceMs = deps.debounceMs ?? 60_000;
26
27
  const pending = new Map<string, PendingRollout>();
@@ -40,9 +41,13 @@ export function initFleetNotificationListener(deps: FleetNotificationListenerDep
40
41
  return;
41
42
  }
42
43
 
43
- // TODO: Surface actual target version from RolloutOrchestrator context.
44
- // BotFleetEvent doesn't carry version info; "latest" is a placeholder.
45
- notificationService.notifyFleetUpdateComplete(tenantId, email, "latest", rollout.succeeded, rollout.failed);
44
+ notificationService.notifyFleetUpdateComplete(
45
+ tenantId,
46
+ email,
47
+ rollout.version,
48
+ rollout.succeeded,
49
+ rollout.failed,
50
+ );
46
51
  } catch (err) {
47
52
  logger.error("Fleet notification flush error", { err, tenantId });
48
53
  }
@@ -57,6 +62,7 @@ export function initFleetNotificationListener(deps: FleetNotificationListenerDep
57
62
  if (!rollout) {
58
63
  rollout = {
59
64
  tenantId: botEvent.tenantId,
65
+ version: botEvent.version ?? "latest",
60
66
  succeeded: 0,
61
67
  failed: 0,
62
68
  timer: setTimeout(() => flush(botEvent.tenantId), debounceMs),
@@ -66,6 +72,7 @@ export function initFleetNotificationListener(deps: FleetNotificationListenerDep
66
72
  // Reset timer on each new event (sliding window)
67
73
  clearTimeout(rollout.timer);
68
74
  rollout.timer = setTimeout(() => flush(botEvent.tenantId), debounceMs);
75
+ if (botEvent.version) rollout.version = botEvent.version;
69
76
  }
70
77
 
71
78
  if (botEvent.type === "bot.updated") {
@@ -75,13 +82,14 @@ export function initFleetNotificationListener(deps: FleetNotificationListenerDep
75
82
  }
76
83
  });
77
84
 
78
- return () => {
85
+ return async () => {
79
86
  unsubscribe();
80
- // Flush all pending on shutdown
87
+ const flushes: Promise<void>[] = [];
81
88
  for (const [tenantId, rollout] of pending) {
82
89
  clearTimeout(rollout.timer);
83
- void flush(tenantId);
90
+ flushes.push(flush(tenantId));
84
91
  }
85
92
  pending.clear();
93
+ await Promise.allSettled(flushes);
86
94
  };
87
95
  }
@@ -14,7 +14,7 @@
14
14
  import type Docker from "dockerode";
15
15
  import { logger } from "../config/logger.js";
16
16
  import type { IBotProfileRepository } from "./bot-profile-repository.js";
17
- import type { FleetEventEmitter } from "./fleet-event-emitter.js";
17
+ import { FleetEventEmitter } from "./fleet-event-emitter.js";
18
18
  import type { FleetManager } from "./fleet-manager.js";
19
19
  import { ImagePoller } from "./image-poller.js";
20
20
  import type { IProfileStore } from "./profile-store.js";
@@ -46,6 +46,8 @@ export interface FleetUpdaterHandle {
46
46
  updater: ContainerUpdater;
47
47
  orchestrator: RolloutOrchestrator;
48
48
  snapshotManager: VolumeSnapshotManager;
49
+ /** Fleet event emitter for subscribing to bot/node lifecycle events */
50
+ eventEmitter: FleetEventEmitter;
49
51
  /** Stop the poller and wait for any active rollout to finish */
50
52
  stop: () => Promise<void>;
51
53
  }
@@ -76,9 +78,11 @@ export function initFleetUpdater(
76
78
  onBotUpdated,
77
79
  onRolloutComplete,
78
80
  configRepo,
79
- eventEmitter,
81
+ eventEmitter: configEventEmitter,
80
82
  } = config;
81
83
 
84
+ const emitter = configEventEmitter ?? new FleetEventEmitter();
85
+
82
86
  const poller = new ImagePoller(docker, profileStore);
83
87
  const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
84
88
  const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
@@ -106,16 +110,28 @@ export function initFleetUpdater(
106
110
  return results.filter((p) => p !== null);
107
111
  },
108
112
  onBotUpdated: (result) => {
109
- // Emit fleet events if emitter is provided
110
- if (eventEmitter) {
111
- eventEmitter.emit({
113
+ // Fire-and-forget: resolve tenantId + emit event asynchronously
114
+ // The orchestrator callback is sync (void return) — async work must not block rollout progress
115
+ void (async () => {
116
+ let tenantId = "";
117
+ try {
118
+ const profile = await profileRepo.get(result.botId);
119
+ if (profile) tenantId = profile.tenantId;
120
+ } catch {
121
+ // Best-effort — event still fires with empty tenantId
122
+ }
123
+
124
+ // Extract version tag from image name (e.g. "ghcr.io/org/image:v1.2.3" → "v1.2.3")
125
+ const version = result.newImage.includes(":") ? (result.newImage.split(":").pop() ?? "latest") : "latest";
126
+
127
+ emitter.emit({
112
128
  type: result.success ? "bot.updated" : "bot.update_failed",
113
129
  botId: result.botId,
114
- tenantId: "",
130
+ tenantId,
115
131
  timestamp: new Date().toISOString(),
132
+ version,
116
133
  });
117
- }
118
- // Chain user-provided callback
134
+ })();
119
135
  onBotUpdated?.(result);
120
136
  },
121
137
  onRolloutComplete: (result) => {
@@ -152,6 +168,7 @@ export function initFleetUpdater(
152
168
  updater,
153
169
  orchestrator,
154
170
  snapshotManager,
171
+ eventEmitter: emitter,
155
172
  stop: async () => {
156
173
  poller.stop();
157
174
  // Wait for any in-flight rollout to complete before returning