@wopr-network/platform-core 1.24.0 → 1.25.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.
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,93 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { initFleetUpdater } from "../init-fleet-updater.js";
3
+ function mockDocker() {
4
+ return {};
5
+ }
6
+ function mockFleet() {
7
+ return {};
8
+ }
9
+ function mockStore() {
10
+ return {
11
+ list: vi.fn(async () => []),
12
+ get: vi.fn(async () => undefined),
13
+ save: vi.fn(async () => { }),
14
+ delete: vi.fn(async () => { }),
15
+ };
16
+ }
17
+ function mockRepo(profiles = []) {
18
+ return {
19
+ list: vi.fn(async () => profiles),
20
+ get: vi.fn(async () => null),
21
+ save: vi.fn(async (p) => p),
22
+ delete: vi.fn(async () => true),
23
+ };
24
+ }
25
+ describe("initFleetUpdater", () => {
26
+ afterEach(() => {
27
+ vi.restoreAllMocks();
28
+ });
29
+ it("returns a handle with all components", async () => {
30
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
31
+ expect(handle.poller).toBeDefined();
32
+ expect(handle.updater).toBeDefined();
33
+ expect(handle.orchestrator).toBeDefined();
34
+ expect(handle.snapshotManager).toBeDefined();
35
+ expect(handle.stop).toBeTypeOf("function");
36
+ await handle.stop();
37
+ });
38
+ it("wires poller.onUpdateAvailable to orchestrator", async () => {
39
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
40
+ expect(handle.poller.onUpdateAvailable).toBeTypeOf("function");
41
+ await handle.stop();
42
+ });
43
+ it("accepts custom strategy config", async () => {
44
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
45
+ strategy: "immediate",
46
+ snapshotDir: "/tmp/snapshots",
47
+ });
48
+ expect(handle.orchestrator).toBeDefined();
49
+ await handle.stop();
50
+ });
51
+ it("accepts callbacks", async () => {
52
+ const onBotUpdated = vi.fn();
53
+ const onRolloutComplete = vi.fn();
54
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
55
+ onBotUpdated,
56
+ onRolloutComplete,
57
+ });
58
+ expect(handle.orchestrator).toBeDefined();
59
+ await handle.stop();
60
+ });
61
+ it("stop() stops the poller", async () => {
62
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
63
+ const stopSpy = vi.spyOn(handle.poller, "stop");
64
+ await handle.stop();
65
+ expect(stopSpy).toHaveBeenCalled();
66
+ });
67
+ it("filters manual-policy bots from updatable profiles", async () => {
68
+ const repo = mockRepo([
69
+ { id: "b1", updatePolicy: "nightly" },
70
+ { id: "b2", updatePolicy: "manual" },
71
+ { id: "b3", updatePolicy: "on-push" },
72
+ ]);
73
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), repo, {
74
+ strategy: "immediate",
75
+ });
76
+ const rolloutResult = await handle.orchestrator.rollout();
77
+ // b2 (manual) should be filtered out, b1 and b3 included
78
+ expect(rolloutResult.totalBots).toBe(2);
79
+ await handle.stop();
80
+ });
81
+ it("uses profileRepo for updatable profiles, not profileStore", async () => {
82
+ const store = mockStore();
83
+ const repo = mockRepo([{ id: "b1", updatePolicy: "nightly" }]);
84
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), store, repo, {
85
+ strategy: "immediate",
86
+ });
87
+ await handle.orchestrator.rollout();
88
+ // profileRepo.list() was called for updatable profiles
89
+ expect(repo.list).toHaveBeenCalled();
90
+ // profileStore.list() may also be called by ImagePoller — that's expected
91
+ await handle.stop();
92
+ });
93
+ });
@@ -1,3 +1,4 @@
1
+ export * from "./init-fleet-updater.js";
1
2
  export * from "./repository-types.js";
2
3
  export * from "./rollout-orchestrator.js";
3
4
  export * from "./rollout-strategy.js";
@@ -1,3 +1,4 @@
1
+ export * from "./init-fleet-updater.js";
1
2
  export * from "./repository-types.js";
2
3
  export * from "./rollout-orchestrator.js";
3
4
  export * from "./rollout-strategy.js";
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Wires the fleet auto-update pipeline: ImagePoller → RolloutOrchestrator → ContainerUpdater.
3
+ *
4
+ * Consumers call initFleetUpdater() with a Docker instance, FleetManager, and config.
5
+ * The pipeline detects new image digests, batches updates via a rollout strategy,
6
+ * snapshots volumes before updating, and restores on failure (nuclear rollback).
7
+ *
8
+ * When a new image digest is detected for ANY bot, the orchestrator triggers a
9
+ * fleet-wide rollout across all non-manual bots. This is intentional: the managed
10
+ * Paperclip image is shared across all tenants, so a single digest change means
11
+ * all bots need updating.
12
+ */
13
+ import type Docker from "dockerode";
14
+ import type { IBotProfileRepository } from "./bot-profile-repository.js";
15
+ import type { FleetManager } from "./fleet-manager.js";
16
+ import { ImagePoller } from "./image-poller.js";
17
+ import type { IProfileStore } from "./profile-store.js";
18
+ import { RolloutOrchestrator, type RolloutResult } from "./rollout-orchestrator.js";
19
+ import { type RollingWaveOptions } from "./rollout-strategy.js";
20
+ import { ContainerUpdater } from "./updater.js";
21
+ import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
22
+ export interface FleetUpdaterConfig {
23
+ /** Rollout strategy type. Default: "rolling-wave" */
24
+ strategy?: "rolling-wave" | "single-bot" | "immediate";
25
+ /** Options for RollingWaveStrategy (ignored for other strategies) */
26
+ strategyOptions?: RollingWaveOptions;
27
+ /** Directory for volume snapshots. Default: "/data/fleet/snapshots" */
28
+ snapshotDir?: string;
29
+ /** Called after each bot update */
30
+ onBotUpdated?: (result: {
31
+ botId: string;
32
+ success: boolean;
33
+ volumeRestored: boolean;
34
+ }) => void;
35
+ /** Called when a rollout completes */
36
+ onRolloutComplete?: (result: RolloutResult) => void;
37
+ }
38
+ export interface FleetUpdaterHandle {
39
+ poller: ImagePoller;
40
+ updater: ContainerUpdater;
41
+ orchestrator: RolloutOrchestrator;
42
+ snapshotManager: VolumeSnapshotManager;
43
+ /** Stop the poller and wait for any active rollout to finish */
44
+ stop: () => Promise<void>;
45
+ }
46
+ /**
47
+ * Initialize the fleet auto-update pipeline.
48
+ *
49
+ * Creates and wires: ImagePoller → RolloutOrchestrator → ContainerUpdater
50
+ * with VolumeSnapshotManager for nuclear rollback.
51
+ *
52
+ * @param docker - Dockerode instance for container operations
53
+ * @param fleet - FleetManager for container lifecycle
54
+ * @param profileStore - Legacy IProfileStore (used by ImagePoller/ContainerUpdater)
55
+ * @param profileRepo - PostgreSQL-backed IBotProfileRepository (used for updatable profile queries)
56
+ * @param config - Optional pipeline configuration
57
+ */
58
+ export declare function initFleetUpdater(docker: Docker, fleet: FleetManager, profileStore: IProfileStore, profileRepo: IBotProfileRepository, config?: FleetUpdaterConfig): FleetUpdaterHandle;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Wires the fleet auto-update pipeline: ImagePoller → RolloutOrchestrator → ContainerUpdater.
3
+ *
4
+ * Consumers call initFleetUpdater() with a Docker instance, FleetManager, and config.
5
+ * The pipeline detects new image digests, batches updates via a rollout strategy,
6
+ * snapshots volumes before updating, and restores on failure (nuclear rollback).
7
+ *
8
+ * When a new image digest is detected for ANY bot, the orchestrator triggers a
9
+ * fleet-wide rollout across all non-manual bots. This is intentional: the managed
10
+ * Paperclip image is shared across all tenants, so a single digest change means
11
+ * all bots need updating.
12
+ */
13
+ import { logger } from "../config/logger.js";
14
+ import { ImagePoller } from "./image-poller.js";
15
+ import { RolloutOrchestrator } from "./rollout-orchestrator.js";
16
+ import { createRolloutStrategy } from "./rollout-strategy.js";
17
+ import { ContainerUpdater } from "./updater.js";
18
+ import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
19
+ /**
20
+ * Initialize the fleet auto-update pipeline.
21
+ *
22
+ * Creates and wires: ImagePoller → RolloutOrchestrator → ContainerUpdater
23
+ * with VolumeSnapshotManager for nuclear rollback.
24
+ *
25
+ * @param docker - Dockerode instance for container operations
26
+ * @param fleet - FleetManager for container lifecycle
27
+ * @param profileStore - Legacy IProfileStore (used by ImagePoller/ContainerUpdater)
28
+ * @param profileRepo - PostgreSQL-backed IBotProfileRepository (used for updatable profile queries)
29
+ * @param config - Optional pipeline configuration
30
+ */
31
+ export function initFleetUpdater(docker, fleet, profileStore, profileRepo, config = {}) {
32
+ const { strategy: strategyType = "rolling-wave", strategyOptions, snapshotDir = "/data/fleet/snapshots", onBotUpdated, onRolloutComplete, } = config;
33
+ const poller = new ImagePoller(docker, profileStore);
34
+ const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
35
+ const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
36
+ const strategy = createRolloutStrategy(strategyType, strategyOptions);
37
+ const orchestrator = new RolloutOrchestrator({
38
+ updater,
39
+ snapshotManager,
40
+ strategy,
41
+ getUpdatableProfiles: async () => {
42
+ const profiles = await profileRepo.list();
43
+ return profiles.filter((p) => p.updatePolicy !== "manual");
44
+ },
45
+ onBotUpdated,
46
+ onRolloutComplete,
47
+ });
48
+ // Wire the detection → orchestration pipeline.
49
+ // Any digest change triggers a fleet-wide rollout because the managed image
50
+ // is shared across all tenants — one new digest means all bots need updating.
51
+ poller.onUpdateAvailable = async (_botId, _newDigest) => {
52
+ if (orchestrator.isRolling) {
53
+ logger.debug("Skipping update trigger — rollout already in progress");
54
+ return;
55
+ }
56
+ logger.info("New image digest detected — starting fleet-wide rollout");
57
+ await orchestrator.rollout().catch((err) => {
58
+ logger.error("Rollout failed", { err });
59
+ });
60
+ };
61
+ // Start polling
62
+ poller.start().catch((err) => {
63
+ logger.error("ImagePoller failed to start", { err });
64
+ });
65
+ logger.info("Fleet auto-update pipeline initialized", {
66
+ strategy: strategyType,
67
+ snapshotDir,
68
+ });
69
+ return {
70
+ poller,
71
+ updater,
72
+ orchestrator,
73
+ snapshotManager,
74
+ stop: async () => {
75
+ poller.stop();
76
+ // Wait for any in-flight rollout to complete before returning
77
+ if (orchestrator.isRolling) {
78
+ logger.info("Waiting for active rollout to finish before shutdown...");
79
+ // Poll until rollout finishes (max 5 minutes)
80
+ const deadline = Date.now() + 5 * 60 * 1000;
81
+ while (orchestrator.isRolling && Date.now() < deadline) {
82
+ await new Promise((r) => setTimeout(r, 1000));
83
+ }
84
+ }
85
+ logger.info("Fleet auto-update pipeline stopped");
86
+ },
87
+ };
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.24.0",
3
+ "version": "1.25.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -0,0 +1,129 @@
1
+ import type Docker from "dockerode";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import type { IBotProfileRepository } from "../bot-profile-repository.js";
4
+ import type { FleetManager } from "../fleet-manager.js";
5
+ import { initFleetUpdater } from "../init-fleet-updater.js";
6
+ import type { IProfileStore } from "../profile-store.js";
7
+
8
+ function mockDocker(): Docker {
9
+ return {} as Docker;
10
+ }
11
+
12
+ function mockFleet(): FleetManager {
13
+ return {} as FleetManager;
14
+ }
15
+
16
+ function mockStore(): IProfileStore {
17
+ return {
18
+ list: vi.fn(async () => []),
19
+ get: vi.fn(async () => undefined),
20
+ save: vi.fn(async () => {}),
21
+ delete: vi.fn(async () => {}),
22
+ } as unknown as IProfileStore;
23
+ }
24
+
25
+ function mockRepo(profiles: unknown[] = []): IBotProfileRepository {
26
+ return {
27
+ list: vi.fn(async () => profiles),
28
+ get: vi.fn(async () => null),
29
+ save: vi.fn(async (p: unknown) => p),
30
+ delete: vi.fn(async () => true),
31
+ } as unknown as IBotProfileRepository;
32
+ }
33
+
34
+ describe("initFleetUpdater", () => {
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ it("returns a handle with all components", async () => {
40
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
41
+
42
+ expect(handle.poller).toBeDefined();
43
+ expect(handle.updater).toBeDefined();
44
+ expect(handle.orchestrator).toBeDefined();
45
+ expect(handle.snapshotManager).toBeDefined();
46
+ expect(handle.stop).toBeTypeOf("function");
47
+
48
+ await handle.stop();
49
+ });
50
+
51
+ it("wires poller.onUpdateAvailable to orchestrator", async () => {
52
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
53
+
54
+ expect(handle.poller.onUpdateAvailable).toBeTypeOf("function");
55
+
56
+ await handle.stop();
57
+ });
58
+
59
+ it("accepts custom strategy config", async () => {
60
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
61
+ strategy: "immediate",
62
+ snapshotDir: "/tmp/snapshots",
63
+ });
64
+
65
+ expect(handle.orchestrator).toBeDefined();
66
+
67
+ await handle.stop();
68
+ });
69
+
70
+ it("accepts callbacks", async () => {
71
+ const onBotUpdated = vi.fn();
72
+ const onRolloutComplete = vi.fn();
73
+
74
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo(), {
75
+ onBotUpdated,
76
+ onRolloutComplete,
77
+ });
78
+
79
+ expect(handle.orchestrator).toBeDefined();
80
+
81
+ await handle.stop();
82
+ });
83
+
84
+ it("stop() stops the poller", async () => {
85
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), mockRepo());
86
+
87
+ const stopSpy = vi.spyOn(handle.poller, "stop");
88
+
89
+ await handle.stop();
90
+
91
+ expect(stopSpy).toHaveBeenCalled();
92
+ });
93
+
94
+ it("filters manual-policy bots from updatable profiles", async () => {
95
+ const repo = mockRepo([
96
+ { id: "b1", updatePolicy: "nightly" },
97
+ { id: "b2", updatePolicy: "manual" },
98
+ { id: "b3", updatePolicy: "on-push" },
99
+ ]);
100
+
101
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), mockStore(), repo, {
102
+ strategy: "immediate",
103
+ });
104
+
105
+ const rolloutResult = await handle.orchestrator.rollout();
106
+
107
+ // b2 (manual) should be filtered out, b1 and b3 included
108
+ expect(rolloutResult.totalBots).toBe(2);
109
+
110
+ await handle.stop();
111
+ });
112
+
113
+ it("uses profileRepo for updatable profiles, not profileStore", async () => {
114
+ const store = mockStore();
115
+ const repo = mockRepo([{ id: "b1", updatePolicy: "nightly" }]);
116
+
117
+ const handle = initFleetUpdater(mockDocker(), mockFleet(), store, repo, {
118
+ strategy: "immediate",
119
+ });
120
+
121
+ await handle.orchestrator.rollout();
122
+
123
+ // profileRepo.list() was called for updatable profiles
124
+ expect(repo.list).toHaveBeenCalled();
125
+ // profileStore.list() may also be called by ImagePoller — that's expected
126
+
127
+ await handle.stop();
128
+ });
129
+ });
@@ -1,3 +1,4 @@
1
+ export * from "./init-fleet-updater.js";
1
2
  export * from "./repository-types.js";
2
3
  export * from "./rollout-orchestrator.js";
3
4
  export * from "./rollout-strategy.js";
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Wires the fleet auto-update pipeline: ImagePoller → RolloutOrchestrator → ContainerUpdater.
3
+ *
4
+ * Consumers call initFleetUpdater() with a Docker instance, FleetManager, and config.
5
+ * The pipeline detects new image digests, batches updates via a rollout strategy,
6
+ * snapshots volumes before updating, and restores on failure (nuclear rollback).
7
+ *
8
+ * When a new image digest is detected for ANY bot, the orchestrator triggers a
9
+ * fleet-wide rollout across all non-manual bots. This is intentional: the managed
10
+ * Paperclip image is shared across all tenants, so a single digest change means
11
+ * all bots need updating.
12
+ */
13
+
14
+ import type Docker from "dockerode";
15
+ import { logger } from "../config/logger.js";
16
+ import type { IBotProfileRepository } from "./bot-profile-repository.js";
17
+ import type { FleetManager } from "./fleet-manager.js";
18
+ import { ImagePoller } from "./image-poller.js";
19
+ import type { IProfileStore } from "./profile-store.js";
20
+ import { RolloutOrchestrator, type RolloutResult } from "./rollout-orchestrator.js";
21
+ import { createRolloutStrategy, type RollingWaveOptions } from "./rollout-strategy.js";
22
+ import { ContainerUpdater } from "./updater.js";
23
+ import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
24
+
25
+ export interface FleetUpdaterConfig {
26
+ /** Rollout strategy type. Default: "rolling-wave" */
27
+ strategy?: "rolling-wave" | "single-bot" | "immediate";
28
+ /** Options for RollingWaveStrategy (ignored for other strategies) */
29
+ strategyOptions?: RollingWaveOptions;
30
+ /** Directory for volume snapshots. Default: "/data/fleet/snapshots" */
31
+ snapshotDir?: string;
32
+ /** Called after each bot update */
33
+ onBotUpdated?: (result: { botId: string; success: boolean; volumeRestored: boolean }) => void;
34
+ /** Called when a rollout completes */
35
+ onRolloutComplete?: (result: RolloutResult) => void;
36
+ }
37
+
38
+ export interface FleetUpdaterHandle {
39
+ poller: ImagePoller;
40
+ updater: ContainerUpdater;
41
+ orchestrator: RolloutOrchestrator;
42
+ snapshotManager: VolumeSnapshotManager;
43
+ /** Stop the poller and wait for any active rollout to finish */
44
+ stop: () => Promise<void>;
45
+ }
46
+
47
+ /**
48
+ * Initialize the fleet auto-update pipeline.
49
+ *
50
+ * Creates and wires: ImagePoller → RolloutOrchestrator → ContainerUpdater
51
+ * with VolumeSnapshotManager for nuclear rollback.
52
+ *
53
+ * @param docker - Dockerode instance for container operations
54
+ * @param fleet - FleetManager for container lifecycle
55
+ * @param profileStore - Legacy IProfileStore (used by ImagePoller/ContainerUpdater)
56
+ * @param profileRepo - PostgreSQL-backed IBotProfileRepository (used for updatable profile queries)
57
+ * @param config - Optional pipeline configuration
58
+ */
59
+ export function initFleetUpdater(
60
+ docker: Docker,
61
+ fleet: FleetManager,
62
+ profileStore: IProfileStore,
63
+ profileRepo: IBotProfileRepository,
64
+ config: FleetUpdaterConfig = {},
65
+ ): FleetUpdaterHandle {
66
+ const {
67
+ strategy: strategyType = "rolling-wave",
68
+ strategyOptions,
69
+ snapshotDir = "/data/fleet/snapshots",
70
+ onBotUpdated,
71
+ onRolloutComplete,
72
+ } = config;
73
+
74
+ const poller = new ImagePoller(docker, profileStore);
75
+ const updater = new ContainerUpdater(docker, profileStore, fleet, poller);
76
+ const snapshotManager = new VolumeSnapshotManager(docker, snapshotDir);
77
+ const strategy = createRolloutStrategy(strategyType, strategyOptions);
78
+
79
+ const orchestrator = new RolloutOrchestrator({
80
+ updater,
81
+ snapshotManager,
82
+ strategy,
83
+ getUpdatableProfiles: async () => {
84
+ const profiles = await profileRepo.list();
85
+ return profiles.filter((p) => p.updatePolicy !== "manual");
86
+ },
87
+ onBotUpdated,
88
+ onRolloutComplete,
89
+ });
90
+
91
+ // Wire the detection → orchestration pipeline.
92
+ // Any digest change triggers a fleet-wide rollout because the managed image
93
+ // is shared across all tenants — one new digest means all bots need updating.
94
+ poller.onUpdateAvailable = async (_botId: string, _newDigest: string) => {
95
+ if (orchestrator.isRolling) {
96
+ logger.debug("Skipping update trigger — rollout already in progress");
97
+ return;
98
+ }
99
+ logger.info("New image digest detected — starting fleet-wide rollout");
100
+ await orchestrator.rollout().catch((err) => {
101
+ logger.error("Rollout failed", { err });
102
+ });
103
+ };
104
+
105
+ // Start polling
106
+ poller.start().catch((err) => {
107
+ logger.error("ImagePoller failed to start", { err });
108
+ });
109
+
110
+ logger.info("Fleet auto-update pipeline initialized", {
111
+ strategy: strategyType,
112
+ snapshotDir,
113
+ });
114
+
115
+ return {
116
+ poller,
117
+ updater,
118
+ orchestrator,
119
+ snapshotManager,
120
+ stop: async () => {
121
+ poller.stop();
122
+ // Wait for any in-flight rollout to complete before returning
123
+ if (orchestrator.isRolling) {
124
+ logger.info("Waiting for active rollout to finish before shutdown...");
125
+ // Poll until rollout finishes (max 5 minutes)
126
+ const deadline = Date.now() + 5 * 60 * 1000;
127
+ while (orchestrator.isRolling && Date.now() < deadline) {
128
+ await new Promise((r) => setTimeout(r, 1000));
129
+ }
130
+ }
131
+ logger.info("Fleet auto-update pipeline stopped");
132
+ },
133
+ };
134
+ }