@wopr-network/platform-core 1.22.0 → 1.24.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.
Files changed (33) hide show
  1. package/dist/billing/crypto/btc/watcher.d.ts +5 -1
  2. package/dist/billing/crypto/btc/watcher.js +8 -4
  3. package/dist/billing/crypto/cursor-store.d.ts +28 -0
  4. package/dist/billing/crypto/cursor-store.js +43 -0
  5. package/dist/billing/crypto/evm/eth-watcher.d.ts +12 -4
  6. package/dist/billing/crypto/evm/eth-watcher.js +23 -9
  7. package/dist/billing/crypto/evm/watcher.d.ts +6 -0
  8. package/dist/billing/crypto/evm/watcher.js +54 -17
  9. package/dist/billing/crypto/index.d.ts +2 -0
  10. package/dist/billing/crypto/index.js +1 -0
  11. package/dist/db/schema/crypto.d.ts +121 -0
  12. package/dist/db/schema/crypto.js +16 -1
  13. package/dist/fleet/__tests__/rollout-orchestrator.test.d.ts +1 -0
  14. package/dist/fleet/__tests__/rollout-orchestrator.test.js +262 -0
  15. package/dist/fleet/index.d.ts +1 -0
  16. package/dist/fleet/index.js +1 -0
  17. package/dist/fleet/rollout-orchestrator.d.ts +69 -0
  18. package/dist/fleet/rollout-orchestrator.js +204 -0
  19. package/dist/fleet/services.d.ts +6 -0
  20. package/dist/fleet/services.js +22 -0
  21. package/drizzle/migrations/0007_watcher_cursors.sql +12 -0
  22. package/drizzle/migrations/meta/_journal.json +7 -0
  23. package/package.json +1 -1
  24. package/src/billing/crypto/btc/watcher.ts +11 -4
  25. package/src/billing/crypto/cursor-store.ts +61 -0
  26. package/src/billing/crypto/evm/eth-watcher.ts +25 -10
  27. package/src/billing/crypto/evm/watcher.ts +57 -19
  28. package/src/billing/crypto/index.ts +2 -0
  29. package/src/db/schema/crypto.ts +22 -1
  30. package/src/fleet/__tests__/rollout-orchestrator.test.ts +321 -0
  31. package/src/fleet/index.ts +1 -0
  32. package/src/fleet/rollout-orchestrator.ts +262 -0
  33. package/src/fleet/services.ts +28 -0
@@ -1,3 +1,4 @@
1
+ import type { IWatcherCursorStore } from "../cursor-store.js";
1
2
  import type { IPriceOracle } from "../oracle/types.js";
2
3
  import type { BitcoindConfig, BtcPaymentEvent } from "./types.js";
3
4
 
@@ -11,6 +12,8 @@ export interface BtcWatcherOpts {
11
12
  onPayment: (event: BtcPaymentEvent) => void | Promise<void>;
12
13
  /** Price oracle for BTC/USD conversion. */
13
14
  oracle: IPriceOracle;
15
+ /** Required — BTC has no block cursor, so txid dedup must be persisted. */
16
+ cursorStore: IWatcherCursorStore;
14
17
  }
15
18
 
16
19
  interface ReceivedByAddress {
@@ -26,7 +29,8 @@ export class BtcWatcher {
26
29
  private readonly onPayment: BtcWatcherOpts["onPayment"];
27
30
  private readonly minConfirmations: number;
28
31
  private readonly oracle: IPriceOracle;
29
- private readonly processedTxids = new Set<string>();
32
+ private readonly cursorStore: IWatcherCursorStore;
33
+ private readonly watcherId: string;
30
34
 
31
35
  constructor(opts: BtcWatcherOpts) {
32
36
  this.rpc = opts.rpcCall;
@@ -34,6 +38,8 @@ export class BtcWatcher {
34
38
  this.onPayment = opts.onPayment;
35
39
  this.minConfirmations = opts.config.confirmations;
36
40
  this.oracle = opts.oracle;
41
+ this.cursorStore = opts.cursorStore;
42
+ this.watcherId = `btc:${opts.config.network}`;
37
43
  }
38
44
 
39
45
  /** Update the set of watched addresses. */
@@ -64,7 +70,8 @@ export class BtcWatcher {
64
70
  if (!this.addresses.has(entry.address)) continue;
65
71
 
66
72
  for (const txid of entry.txids) {
67
- if (this.processedTxids.has(txid)) continue;
73
+ // Skip already-processed txids (persisted to DB, survives restart)
74
+ if (await this.cursorStore.hasProcessedTx(this.watcherId, txid)) continue;
68
75
 
69
76
  // Get transaction details for the exact amount sent to this address
70
77
  const tx = (await this.rpc("gettransaction", [txid, true])) as {
@@ -88,8 +95,8 @@ export class BtcWatcher {
88
95
  };
89
96
 
90
97
  await this.onPayment(event);
91
- // Add AFTER successful onPayment to avoid skipping on failure
92
- this.processedTxids.add(txid);
98
+ // Persist AFTER successful onPayment survives restart, no unbounded memory
99
+ await this.cursorStore.markProcessedTx(this.watcherId, txid);
93
100
  }
94
101
  }
95
102
  }
@@ -0,0 +1,61 @@
1
+ import { and, eq, sql } from "drizzle-orm";
2
+ import type { PlatformDb } from "../../db/index.js";
3
+ import { watcherCursors, watcherProcessed } from "../../db/schema/crypto.js";
4
+
5
+ export interface IWatcherCursorStore {
6
+ /** Get persisted block cursor for a watcher. */
7
+ get(watcherId: string): Promise<number | null>;
8
+ /** Save block cursor after processing a range. */
9
+ save(watcherId: string, cursorBlock: number): Promise<void>;
10
+ /** Check if a specific tx has been processed (for watchers without block cursors). */
11
+ hasProcessedTx(watcherId: string, txId: string): Promise<boolean>;
12
+ /** Mark a tx as processed (for watchers without block cursors). */
13
+ markProcessedTx(watcherId: string, txId: string): Promise<void>;
14
+ }
15
+
16
+ /**
17
+ * Persists watcher state to PostgreSQL.
18
+ *
19
+ * Two patterns:
20
+ * - Block cursor (EVM watchers): save/get cursor block number
21
+ * - Processed txids (BTC watcher): hasProcessedTx/markProcessedTx
22
+ *
23
+ * Eliminates all in-memory watcher state. Clean restart recovery.
24
+ */
25
+ export class DrizzleWatcherCursorStore implements IWatcherCursorStore {
26
+ constructor(private readonly db: PlatformDb) {}
27
+
28
+ async get(watcherId: string): Promise<number | null> {
29
+ const row = (
30
+ await this.db
31
+ .select({ cursorBlock: watcherCursors.cursorBlock })
32
+ .from(watcherCursors)
33
+ .where(eq(watcherCursors.watcherId, watcherId))
34
+ )[0];
35
+ return row?.cursorBlock ?? null;
36
+ }
37
+
38
+ async save(watcherId: string, cursorBlock: number): Promise<void> {
39
+ await this.db
40
+ .insert(watcherCursors)
41
+ .values({ watcherId, cursorBlock })
42
+ .onConflictDoUpdate({
43
+ target: watcherCursors.watcherId,
44
+ set: { cursorBlock, updatedAt: sql`(now())` },
45
+ });
46
+ }
47
+
48
+ async hasProcessedTx(watcherId: string, txId: string): Promise<boolean> {
49
+ const row = (
50
+ await this.db
51
+ .select({ txId: watcherProcessed.txId })
52
+ .from(watcherProcessed)
53
+ .where(and(eq(watcherProcessed.watcherId, watcherId), eq(watcherProcessed.txId, txId)))
54
+ )[0];
55
+ return row !== undefined;
56
+ }
57
+
58
+ async markProcessedTx(watcherId: string, txId: string): Promise<void> {
59
+ await this.db.insert(watcherProcessed).values({ watcherId, txId }).onConflictDoNothing();
60
+ }
61
+ }
@@ -1,3 +1,4 @@
1
+ import type { IWatcherCursorStore } from "../cursor-store.js";
1
2
  import { nativeToCents } from "../oracle/convert.js";
2
3
  import type { IPriceOracle } from "../oracle/types.js";
3
4
  import { getChainConfig } from "./config.js";
@@ -25,6 +26,7 @@ export interface EthWatcherOpts {
25
26
  fromBlock: number;
26
27
  onPayment: (event: EthPaymentEvent) => void | Promise<void>;
27
28
  watchedAddresses?: string[];
29
+ cursorStore?: IWatcherCursorStore;
28
30
  }
29
31
 
30
32
  interface RpcTransaction {
@@ -42,7 +44,9 @@ interface RpcTransaction {
42
44
  * this scans blocks for transactions where `to` matches a watched deposit
43
45
  * address and `value > 0`.
44
46
  *
45
- * Uses the price oracle to convert wei USD cents at detection time.
47
+ * Processes one block at a time and persists cursor after each block.
48
+ * On restart, resumes from the last committed cursor — no replay, no
49
+ * unbounded in-memory state.
46
50
  */
47
51
  export class EthWatcher {
48
52
  private _cursor: number;
@@ -51,8 +55,9 @@ export class EthWatcher {
51
55
  private readonly oracle: IPriceOracle;
52
56
  private readonly onPayment: EthWatcherOpts["onPayment"];
53
57
  private readonly confirmations: number;
58
+ private readonly cursorStore?: IWatcherCursorStore;
59
+ private readonly watcherId: string;
54
60
  private _watchedAddresses: Set<string>;
55
- private readonly processedTxids = new Set<string>();
56
61
 
57
62
  constructor(opts: EthWatcherOpts) {
58
63
  this.chain = opts.chain;
@@ -61,9 +66,18 @@ export class EthWatcher {
61
66
  this._cursor = opts.fromBlock;
62
67
  this.onPayment = opts.onPayment;
63
68
  this.confirmations = getChainConfig(opts.chain).confirmations;
69
+ this.cursorStore = opts.cursorStore;
70
+ this.watcherId = `eth:${opts.chain}`;
64
71
  this._watchedAddresses = new Set((opts.watchedAddresses ?? []).map((a) => a.toLowerCase()));
65
72
  }
66
73
 
74
+ /** Load cursor from DB. Call once at startup before first poll. */
75
+ async init(): Promise<void> {
76
+ if (!this.cursorStore) return;
77
+ const saved = await this.cursorStore.get(this.watcherId);
78
+ if (saved !== null) this._cursor = saved;
79
+ }
80
+
67
81
  setWatchedAddresses(addresses: string[]): void {
68
82
  this._watchedAddresses = new Set(addresses.map((a) => a.toLowerCase()));
69
83
  }
@@ -75,8 +89,9 @@ export class EthWatcher {
75
89
  /**
76
90
  * Poll for new native ETH transfers to watched addresses.
77
91
  *
78
- * Scans each confirmed block's transactions. Only processes txs
79
- * where `to` is in the watched set and `value > 0`.
92
+ * Processes one block at a time. After each block is fully processed,
93
+ * the cursor is persisted to the DB. If onPayment fails mid-block,
94
+ * the cursor hasn't advanced — the entire block is retried on next poll.
80
95
  */
81
96
  async poll(): Promise<void> {
82
97
  if (this._watchedAddresses.size === 0) return;
@@ -104,8 +119,6 @@ export class EthWatcher {
104
119
  const valueWei = BigInt(tx.value);
105
120
  if (valueWei === 0n) continue;
106
121
 
107
- if (this.processedTxids.has(tx.hash)) continue;
108
-
109
122
  const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
110
123
 
111
124
  const event: EthPaymentEvent = {
@@ -119,11 +132,13 @@ export class EthWatcher {
119
132
  };
120
133
 
121
134
  await this.onPayment(event);
122
- // Add to processed AFTER successful onPayment to avoid skipping on failure
123
- this.processedTxids.add(tx.hash);
124
135
  }
125
- }
126
136
 
127
- this._cursor = confirmed + 1;
137
+ // Block fully processed — persist cursor so we never re-scan it.
138
+ this._cursor = blockNum + 1;
139
+ if (this.cursorStore) {
140
+ await this.cursorStore.save(this.watcherId, this._cursor);
141
+ }
142
+ }
128
143
  }
129
144
  }
@@ -1,3 +1,4 @@
1
+ import type { IWatcherCursorStore } from "../cursor-store.js";
1
2
  import { centsFromTokenAmount, getChainConfig, getTokenConfig } from "./config.js";
2
3
  import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./types.js";
3
4
 
@@ -13,6 +14,7 @@ export interface EvmWatcherOpts {
13
14
  onPayment: (event: EvmPaymentEvent) => void | Promise<void>;
14
15
  /** Active deposit addresses to watch. Filters eth_getLogs by topic[2] (to address). */
15
16
  watchedAddresses?: string[];
17
+ cursorStore?: IWatcherCursorStore;
16
18
  }
17
19
 
18
20
  interface RpcLog {
@@ -33,6 +35,8 @@ export class EvmWatcher {
33
35
  private readonly confirmations: number;
34
36
  private readonly contractAddress: string;
35
37
  private readonly decimals: number;
38
+ private readonly cursorStore?: IWatcherCursorStore;
39
+ private readonly watcherId: string;
36
40
  private _watchedAddresses: string[];
37
41
 
38
42
  constructor(opts: EvmWatcherOpts) {
@@ -41,6 +45,8 @@ export class EvmWatcher {
41
45
  this.rpc = opts.rpcCall;
42
46
  this._cursor = opts.fromBlock;
43
47
  this.onPayment = opts.onPayment;
48
+ this.cursorStore = opts.cursorStore;
49
+ this.watcherId = `evm:${opts.chain}:${opts.token}`;
44
50
  this._watchedAddresses = (opts.watchedAddresses ?? []).map((a) => a.toLowerCase());
45
51
 
46
52
  const chainCfg = getChainConfig(opts.chain);
@@ -50,6 +56,13 @@ export class EvmWatcher {
50
56
  this.decimals = tokenCfg.decimals;
51
57
  }
52
58
 
59
+ /** Load cursor from DB. Call once at startup before first poll. */
60
+ async init(): Promise<void> {
61
+ if (!this.cursorStore) return;
62
+ const saved = await this.cursorStore.get(this.watcherId);
63
+ if (saved !== null) this._cursor = saved;
64
+ }
65
+
53
66
  /** Update the set of watched deposit addresses (e.g. after a new checkout). */
54
67
  setWatchedAddresses(addresses: string[]): void {
55
68
  this._watchedAddresses = addresses.map((a) => a.toLowerCase());
@@ -86,28 +99,53 @@ export class EvmWatcher {
86
99
  },
87
100
  ])) as RpcLog[];
88
101
 
102
+ // Group logs by block for incremental cursor checkpointing.
103
+ // If onPayment fails mid-batch, only the current block is replayed on next poll.
104
+ const logsByBlock = new Map<number, RpcLog[]>();
89
105
  for (const log of logs) {
90
- const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
91
- const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
92
- const rawAmount = BigInt(log.data);
93
- const amountUsdCents = centsFromTokenAmount(rawAmount, this.decimals);
94
-
95
- const event: EvmPaymentEvent = {
96
- chain: this.chain,
97
- token: this.token,
98
- from,
99
- to,
100
- rawAmount: rawAmount.toString(),
101
- amountUsdCents,
102
- txHash: log.transactionHash,
103
- blockNumber: Number.parseInt(log.blockNumber, 16),
104
- logIndex: Number.parseInt(log.logIndex, 16),
105
- };
106
-
107
- await this.onPayment(event);
106
+ const bn = Number.parseInt(log.blockNumber, 16);
107
+ const arr = logsByBlock.get(bn);
108
+ if (arr) arr.push(log);
109
+ else logsByBlock.set(bn, [log]);
108
110
  }
109
111
 
110
- this._cursor = confirmed + 1;
112
+ // Process blocks in order, checkpoint after each.
113
+ const blockNums = [...logsByBlock.keys()].sort((a, b) => a - b);
114
+ for (const blockNum of blockNums) {
115
+ for (const log of logsByBlock.get(blockNum) ?? []) {
116
+ const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
117
+ const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
118
+ const rawAmount = BigInt(log.data);
119
+ const amountUsdCents = centsFromTokenAmount(rawAmount, this.decimals);
120
+
121
+ const event: EvmPaymentEvent = {
122
+ chain: this.chain,
123
+ token: this.token,
124
+ from,
125
+ to,
126
+ rawAmount: rawAmount.toString(),
127
+ amountUsdCents,
128
+ txHash: log.transactionHash,
129
+ blockNumber: blockNum,
130
+ logIndex: Number.parseInt(log.logIndex, 16),
131
+ };
132
+
133
+ await this.onPayment(event);
134
+ }
135
+
136
+ this._cursor = blockNum + 1;
137
+ if (this.cursorStore) {
138
+ await this.cursorStore.save(this.watcherId, this._cursor);
139
+ }
140
+ }
141
+
142
+ // Advance cursor even if no logs were found in the range.
143
+ if (blockNums.length === 0) {
144
+ this._cursor = confirmed + 1;
145
+ if (this.cursorStore) {
146
+ await this.cursorStore.save(this.watcherId, this._cursor);
147
+ }
148
+ }
111
149
  }
112
150
  }
113
151
 
@@ -4,6 +4,8 @@ export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-
4
4
  export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
5
5
  export type { CryptoConfig } from "./client.js";
6
6
  export { BTCPayClient, loadCryptoConfig } from "./client.js";
7
+ export type { IWatcherCursorStore } from "./cursor-store.js";
8
+ export { DrizzleWatcherCursorStore } from "./cursor-store.js";
7
9
  export * from "./evm/index.js";
8
10
  export * from "./oracle/index.js";
9
11
  export type {
@@ -1,5 +1,5 @@
1
1
  import { sql } from "drizzle-orm";
2
- import { index, integer, pgTable, text } from "drizzle-orm/pg-core";
2
+ import { index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
3
3
 
4
4
  /**
5
5
  * Crypto payment charges — tracks the lifecycle of each BTCPay invoice.
@@ -35,3 +35,24 @@ export const cryptoCharges = pgTable(
35
35
  // Enforced via migration: CREATE UNIQUE INDEX.
36
36
  ],
37
37
  );
38
+
39
+ /**
40
+ * Watcher cursor persistence — tracks the last processed block per watcher.
41
+ * Eliminates in-memory processedTxids and enables clean restart recovery.
42
+ */
43
+ export const watcherCursors = pgTable("watcher_cursors", {
44
+ watcherId: text("watcher_id").primaryKey(),
45
+ cursorBlock: integer("cursor_block").notNull(),
46
+ updatedAt: text("updated_at").notNull().default(sql`(now())`),
47
+ });
48
+
49
+ /** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
50
+ export const watcherProcessed = pgTable(
51
+ "watcher_processed",
52
+ {
53
+ watcherId: text("watcher_id").notNull(),
54
+ txId: text("tx_id").notNull(),
55
+ processedAt: text("processed_at").notNull().default(sql`(now())`),
56
+ },
57
+ (table) => [primaryKey({ columns: [table.watcherId, table.txId] })],
58
+ );
@@ -0,0 +1,321 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { RolloutOrchestrator } from "../rollout-orchestrator.js";
3
+ import type { IRolloutStrategy } from "../rollout-strategy.js";
4
+ import type { BotProfile } from "../types.js";
5
+ import type { ContainerUpdater, UpdateResult } from "../updater.js";
6
+ import type { VolumeSnapshotManager } from "../volume-snapshot-manager.js";
7
+
8
+ function makeProfile(id: string, volumeName?: string): BotProfile {
9
+ return {
10
+ id,
11
+ tenantId: "tenant-1",
12
+ name: `bot-${id}`,
13
+ description: "",
14
+ image: "ghcr.io/wopr-network/paperclip:managed",
15
+ env: {},
16
+ restartPolicy: "unless-stopped",
17
+ releaseChannel: "stable",
18
+ updatePolicy: "nightly",
19
+ volumeName,
20
+ } as BotProfile;
21
+ }
22
+
23
+ function makeResult(botId: string, success: boolean): UpdateResult {
24
+ return {
25
+ botId,
26
+ success,
27
+ previousImage: "old:latest",
28
+ newImage: "new:latest",
29
+ previousDigest: "sha256:old",
30
+ newDigest: "sha256:new",
31
+ rolledBack: !success,
32
+ error: success ? undefined : "Health check failed",
33
+ };
34
+ }
35
+
36
+ function mockUpdater(results: Map<string, UpdateResult>): ContainerUpdater {
37
+ return {
38
+ updateBot: vi.fn(async (botId: string) => results.get(botId) ?? makeResult(botId, true)),
39
+ } as unknown as ContainerUpdater;
40
+ }
41
+
42
+ function mockSnapshotManager(): VolumeSnapshotManager {
43
+ return {
44
+ snapshot: vi.fn(async (volumeName: string) => ({
45
+ id: `${volumeName}-snap`,
46
+ volumeName,
47
+ archivePath: `/backup/${volumeName}-snap.tar`,
48
+ createdAt: new Date(),
49
+ sizeBytes: 1024,
50
+ })),
51
+ restore: vi.fn(async () => {}),
52
+ delete: vi.fn(async () => {}),
53
+ } as unknown as VolumeSnapshotManager;
54
+ }
55
+
56
+ function mockStrategy(overrides: Partial<IRolloutStrategy> = {}): IRolloutStrategy {
57
+ return {
58
+ nextBatch: (remaining) => remaining.slice(0, 2),
59
+ pauseDuration: () => 0,
60
+ onBotFailure: () => "skip",
61
+ maxRetries: () => 2,
62
+ healthCheckTimeout: () => 120_000,
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ describe("RolloutOrchestrator", () => {
68
+ let updater: ReturnType<typeof mockUpdater>;
69
+ let snapMgr: ReturnType<typeof mockSnapshotManager>;
70
+
71
+ beforeEach(() => {
72
+ vi.clearAllMocks();
73
+ updater = mockUpdater(new Map());
74
+ snapMgr = mockSnapshotManager();
75
+ });
76
+
77
+ it("processes all bots in batches", async () => {
78
+ const profiles = [makeProfile("b1", "vol-1"), makeProfile("b2", "vol-2"), makeProfile("b3", "vol-3")];
79
+ const strategy = mockStrategy({ nextBatch: (r) => r.slice(0, 2) });
80
+
81
+ const orch = new RolloutOrchestrator({
82
+ updater,
83
+ snapshotManager: snapMgr,
84
+ strategy,
85
+ getUpdatableProfiles: async () => profiles,
86
+ });
87
+
88
+ const result = await orch.rollout();
89
+
90
+ expect(result.totalBots).toBe(3);
91
+ expect(result.succeeded).toBe(3);
92
+ expect(result.failed).toBe(0);
93
+ expect(result.aborted).toBe(false);
94
+ expect(updater.updateBot).toHaveBeenCalledTimes(3);
95
+ });
96
+
97
+ it("snapshots volumes before updating", async () => {
98
+ const profiles = [makeProfile("b1", "my-volume")];
99
+
100
+ const orch = new RolloutOrchestrator({
101
+ updater,
102
+ snapshotManager: snapMgr,
103
+ strategy: mockStrategy(),
104
+ getUpdatableProfiles: async () => profiles,
105
+ });
106
+
107
+ await orch.rollout();
108
+
109
+ expect(snapMgr.snapshot).toHaveBeenCalledWith("my-volume");
110
+ // On success, snapshot is cleaned up
111
+ expect(snapMgr.delete).toHaveBeenCalledWith("my-volume-snap");
112
+ });
113
+
114
+ it("skips snapshot for bots without volumes", async () => {
115
+ const profiles = [makeProfile("b1")]; // no volumeName
116
+
117
+ const orch = new RolloutOrchestrator({
118
+ updater,
119
+ snapshotManager: snapMgr,
120
+ strategy: mockStrategy(),
121
+ getUpdatableProfiles: async () => profiles,
122
+ });
123
+
124
+ await orch.rollout();
125
+
126
+ expect(snapMgr.snapshot).not.toHaveBeenCalled();
127
+ expect(updater.updateBot).toHaveBeenCalledWith("b1");
128
+ });
129
+
130
+ it("restores volumes on update failure", async () => {
131
+ const failResults = new Map([["b1", makeResult("b1", false)]]);
132
+ updater = mockUpdater(failResults);
133
+ const profiles = [makeProfile("b1", "my-volume")];
134
+
135
+ const orch = new RolloutOrchestrator({
136
+ updater,
137
+ snapshotManager: snapMgr,
138
+ strategy: mockStrategy(),
139
+ getUpdatableProfiles: async () => profiles,
140
+ });
141
+
142
+ const result = await orch.rollout();
143
+
144
+ expect(result.failed).toBe(1);
145
+ expect(result.results[0].volumeRestored).toBe(true);
146
+ expect(snapMgr.restore).toHaveBeenCalledWith("my-volume-snap");
147
+ // Snapshot NOT deleted on failure (restored instead)
148
+ expect(snapMgr.delete).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it("aborts rollout when strategy says abort", async () => {
152
+ const failResults = new Map([["b1", makeResult("b1", false)]]);
153
+ updater = mockUpdater(failResults);
154
+ const profiles = [makeProfile("b1", "v1"), makeProfile("b2", "v2"), makeProfile("b3", "v3")];
155
+ const strategy = mockStrategy({
156
+ nextBatch: (r) => r.slice(0, 1),
157
+ onBotFailure: () => "abort",
158
+ });
159
+
160
+ const orch = new RolloutOrchestrator({
161
+ updater,
162
+ snapshotManager: snapMgr,
163
+ strategy,
164
+ getUpdatableProfiles: async () => profiles,
165
+ });
166
+
167
+ const result = await orch.rollout();
168
+
169
+ expect(result.aborted).toBe(true);
170
+ expect(result.succeeded).toBe(0);
171
+ expect(result.failed).toBe(1);
172
+ expect(result.skipped).toBe(2); // b2, b3 never processed
173
+ expect(updater.updateBot).toHaveBeenCalledTimes(1);
174
+ });
175
+
176
+ it("returns empty result when no bots to update", async () => {
177
+ const orch = new RolloutOrchestrator({
178
+ updater,
179
+ snapshotManager: snapMgr,
180
+ strategy: mockStrategy(),
181
+ getUpdatableProfiles: async () => [],
182
+ });
183
+
184
+ const result = await orch.rollout();
185
+
186
+ expect(result.totalBots).toBe(0);
187
+ expect(result.results).toHaveLength(0);
188
+ });
189
+
190
+ it("rejects concurrent rollouts", async () => {
191
+ const profiles = [makeProfile("b1")];
192
+ // Make updateBot slow
193
+ updater = {
194
+ updateBot: vi.fn(async (botId: string) => {
195
+ await new Promise((r) => setTimeout(r, 100));
196
+ return makeResult(botId, true);
197
+ }),
198
+ } as unknown as ContainerUpdater;
199
+
200
+ const orch = new RolloutOrchestrator({
201
+ updater,
202
+ snapshotManager: snapMgr,
203
+ strategy: mockStrategy(),
204
+ getUpdatableProfiles: async () => profiles,
205
+ });
206
+
207
+ const [r1, r2] = await Promise.all([orch.rollout(), orch.rollout()]);
208
+
209
+ // One succeeds, one is rejected as already running
210
+ const succeeded = [r1, r2].find((r) => r.totalBots > 0);
211
+ const rejected = [r1, r2].find((r) => r.alreadyRunning);
212
+ expect(succeeded).toBeDefined();
213
+ expect(rejected).toBeDefined();
214
+ expect(rejected?.alreadyRunning).toBe(true);
215
+ expect(rejected?.totalBots).toBe(0);
216
+ });
217
+
218
+ it("retries failed bots when strategy says retry", async () => {
219
+ let callCount = 0;
220
+ updater = {
221
+ updateBot: vi.fn(async (botId: string) => {
222
+ callCount++;
223
+ // Fail first attempt, succeed on retry
224
+ if (botId === "b1" && callCount === 1) return makeResult("b1", false);
225
+ return makeResult(botId, true);
226
+ }),
227
+ } as unknown as ContainerUpdater;
228
+
229
+ const profiles = [makeProfile("b1")];
230
+ const strategy = mockStrategy({
231
+ nextBatch: (r) => r.slice(0, 1),
232
+ onBotFailure: (_botId, _err, attempt) => (attempt < 2 ? "retry" : "skip"),
233
+ });
234
+
235
+ const orch = new RolloutOrchestrator({
236
+ updater,
237
+ snapshotManager: snapMgr,
238
+ strategy,
239
+ getUpdatableProfiles: async () => profiles,
240
+ });
241
+
242
+ const result = await orch.rollout();
243
+
244
+ // b1 failed once, retried, succeeded
245
+ expect(updater.updateBot).toHaveBeenCalledTimes(2);
246
+ expect(result.succeeded).toBe(1);
247
+ expect(result.failed).toBe(1); // first attempt counted as failed
248
+ });
249
+
250
+ it("calls onBotUpdated callback for each bot", async () => {
251
+ const profiles = [makeProfile("b1"), makeProfile("b2")];
252
+ const onBotUpdated = vi.fn();
253
+
254
+ const orch = new RolloutOrchestrator({
255
+ updater,
256
+ snapshotManager: snapMgr,
257
+ strategy: mockStrategy(),
258
+ getUpdatableProfiles: async () => profiles,
259
+ onBotUpdated,
260
+ });
261
+
262
+ await orch.rollout();
263
+
264
+ expect(onBotUpdated).toHaveBeenCalledTimes(2);
265
+ });
266
+
267
+ it("calls onRolloutComplete callback", async () => {
268
+ const profiles = [makeProfile("b1")];
269
+ const onRolloutComplete = vi.fn();
270
+
271
+ const orch = new RolloutOrchestrator({
272
+ updater,
273
+ snapshotManager: snapMgr,
274
+ strategy: mockStrategy(),
275
+ getUpdatableProfiles: async () => profiles,
276
+ onRolloutComplete,
277
+ });
278
+
279
+ await orch.rollout();
280
+
281
+ expect(onRolloutComplete).toHaveBeenCalledTimes(1);
282
+ expect(onRolloutComplete).toHaveBeenCalledWith(
283
+ expect.objectContaining({ totalBots: 1, succeeded: 1, aborted: false }),
284
+ );
285
+ });
286
+
287
+ it("continues on snapshot failure (best-effort)", async () => {
288
+ const profiles = [makeProfile("b1", "my-volume")];
289
+ snapMgr.snapshot = vi.fn().mockRejectedValue(new Error("disk full"));
290
+
291
+ const orch = new RolloutOrchestrator({
292
+ updater,
293
+ snapshotManager: snapMgr,
294
+ strategy: mockStrategy(),
295
+ getUpdatableProfiles: async () => profiles,
296
+ });
297
+
298
+ const result = await orch.rollout();
299
+
300
+ // Update still proceeds despite snapshot failure
301
+ expect(result.succeeded).toBe(1);
302
+ expect(updater.updateBot).toHaveBeenCalledWith("b1");
303
+ });
304
+
305
+ it("isRolling reflects rollout state", async () => {
306
+ const profiles = [makeProfile("b1")];
307
+
308
+ const orch = new RolloutOrchestrator({
309
+ updater,
310
+ snapshotManager: snapMgr,
311
+ strategy: mockStrategy(),
312
+ getUpdatableProfiles: async () => profiles,
313
+ });
314
+
315
+ expect(orch.isRolling).toBe(false);
316
+ const promise = orch.rollout();
317
+ // isRolling is true during rollout (may already be done for sync mocks)
318
+ await promise;
319
+ expect(orch.isRolling).toBe(false);
320
+ });
321
+ });
@@ -1,4 +1,5 @@
1
1
  export * from "./repository-types.js";
2
+ export * from "./rollout-orchestrator.js";
2
3
  export * from "./rollout-strategy.js";
3
4
  export * from "./services.js";
4
5
  export * from "./types.js";