@wopr-network/platform-core 1.25.0 → 1.27.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 (48) hide show
  1. package/dist/billing/crypto/index.d.ts +4 -0
  2. package/dist/billing/crypto/index.js +2 -0
  3. package/dist/billing/crypto/payment-method-store.d.ts +38 -0
  4. package/dist/billing/crypto/payment-method-store.js +82 -0
  5. package/dist/billing/crypto/unified-checkout.d.ts +35 -0
  6. package/dist/billing/crypto/unified-checkout.js +128 -0
  7. package/dist/db/schema/crypto.d.ts +216 -0
  8. package/dist/db/schema/crypto.js +20 -1
  9. package/dist/db/schema/index.d.ts +1 -0
  10. package/dist/db/schema/index.js +1 -0
  11. package/dist/db/schema/tenant-update-configs.d.ts +79 -0
  12. package/dist/db/schema/tenant-update-configs.js +15 -0
  13. package/dist/fleet/__tests__/tenant-update-config-repository.test.d.ts +1 -0
  14. package/dist/fleet/__tests__/tenant-update-config-repository.test.js +55 -0
  15. package/dist/fleet/drizzle-tenant-update-config-repository.d.ts +10 -0
  16. package/dist/fleet/drizzle-tenant-update-config-repository.js +44 -0
  17. package/dist/fleet/fleet-event-emitter.d.ts +1 -1
  18. package/dist/fleet/index.d.ts +2 -0
  19. package/dist/fleet/index.js +2 -0
  20. package/dist/fleet/init-fleet-updater.d.ts +6 -0
  21. package/dist/fleet/init-fleet-updater.js +29 -4
  22. package/dist/fleet/services.d.ts +2 -0
  23. package/dist/fleet/services.js +10 -0
  24. package/dist/fleet/tenant-update-config-repository.d.ts +11 -0
  25. package/dist/fleet/tenant-update-config-repository.js +1 -0
  26. package/dist/trpc/fleet-update-config-router.d.ts +24 -0
  27. package/dist/trpc/fleet-update-config-router.js +26 -0
  28. package/dist/trpc/index.d.ts +1 -0
  29. package/dist/trpc/index.js +1 -0
  30. package/drizzle/migrations/0008_payment_methods.sql +22 -0
  31. package/drizzle/migrations/0009_tenant_update_configs.sql +6 -0
  32. package/drizzle/migrations/meta/_journal.json +14 -0
  33. package/package.json +1 -1
  34. package/src/billing/crypto/index.ts +4 -0
  35. package/src/billing/crypto/payment-method-store.ts +117 -0
  36. package/src/billing/crypto/unified-checkout.ts +190 -0
  37. package/src/db/schema/crypto.ts +21 -1
  38. package/src/db/schema/index.ts +1 -0
  39. package/src/db/schema/tenant-update-configs.ts +16 -0
  40. package/src/fleet/__tests__/tenant-update-config-repository.test.ts +69 -0
  41. package/src/fleet/drizzle-tenant-update-config-repository.ts +48 -0
  42. package/src/fleet/fleet-event-emitter.ts +8 -1
  43. package/src/fleet/index.ts +2 -0
  44. package/src/fleet/init-fleet-updater.ts +38 -3
  45. package/src/fleet/services.ts +13 -0
  46. package/src/fleet/tenant-update-config-repository.ts +12 -0
  47. package/src/trpc/fleet-update-config-router.ts +31 -0
  48. package/src/trpc/index.ts +1 -0
@@ -0,0 +1,190 @@
1
+ import { Credit } from "../../credits/credit.js";
2
+ import type { ICryptoChargeRepository } from "./charge-store.js";
3
+ import { deriveDepositAddress } from "./evm/address-gen.js";
4
+ import { centsToNative } from "./oracle/convert.js";
5
+ import type { IPriceOracle } from "./oracle/types.js";
6
+ import type { PaymentMethodRecord } from "./payment-method-store.js";
7
+
8
+ export const MIN_CHECKOUT_USD = 10;
9
+
10
+ export interface UnifiedCheckoutDeps {
11
+ chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
12
+ oracle: IPriceOracle;
13
+ evmXpub: string;
14
+ btcXpub?: string;
15
+ }
16
+
17
+ export interface UnifiedCheckoutResult {
18
+ depositAddress: string;
19
+ /** Human-readable amount to send (e.g. "50 USDC", "0.014285 ETH"). */
20
+ displayAmount: string;
21
+ amountUsd: number;
22
+ token: string;
23
+ chain: string;
24
+ referenceId: string;
25
+ /** For volatile assets: price at checkout time (USD cents per unit). */
26
+ priceCents?: number;
27
+ }
28
+
29
+ /**
30
+ * Unified checkout — one entry point for all payment methods.
31
+ *
32
+ * Looks up the method record, routes by type:
33
+ * - erc20: derives EVM address, computes token amount (1:1 USD for stablecoins)
34
+ * - native (ETH): derives EVM address, oracle-priced
35
+ * - native (BTC): derives BTC address, oracle-priced
36
+ *
37
+ * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
38
+ */
39
+ export async function createUnifiedCheckout(
40
+ deps: UnifiedCheckoutDeps,
41
+ method: PaymentMethodRecord,
42
+ opts: { tenant: string; amountUsd: number },
43
+ ): Promise<UnifiedCheckoutResult> {
44
+ if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_CHECKOUT_USD) {
45
+ throw new Error(`Minimum payment amount is $${MIN_CHECKOUT_USD}`);
46
+ }
47
+
48
+ const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
49
+
50
+ if (method.type === "erc20") {
51
+ return handleErc20(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
52
+ }
53
+ if (method.token === "ETH") {
54
+ return handleNativeEth(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
55
+ }
56
+ if (method.token === "BTC") {
57
+ return handleNativeBtc(deps, method, opts.tenant, amountUsdCents, opts.amountUsd);
58
+ }
59
+
60
+ throw new Error(`Unsupported payment method type: ${method.type}/${method.token}`);
61
+ }
62
+
63
+ async function handleErc20(
64
+ deps: UnifiedCheckoutDeps,
65
+ method: PaymentMethodRecord,
66
+ tenant: string,
67
+ amountUsdCents: number,
68
+ amountUsd: number,
69
+ ): Promise<UnifiedCheckoutResult> {
70
+ const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
71
+
72
+ return {
73
+ depositAddress,
74
+ displayAmount: `${amountUsd} ${method.token}`,
75
+ amountUsd,
76
+ token: method.token,
77
+ chain: method.chain,
78
+ referenceId: `erc20:${method.chain}:${depositAddress}`,
79
+ };
80
+ }
81
+
82
+ async function handleNativeEth(
83
+ deps: UnifiedCheckoutDeps,
84
+ method: PaymentMethodRecord,
85
+ tenant: string,
86
+ amountUsdCents: number,
87
+ amountUsd: number,
88
+ ): Promise<UnifiedCheckoutResult> {
89
+ const { priceCents } = await deps.oracle.getPrice("ETH");
90
+ const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
91
+ const depositAddress = await deriveAndStore(deps, method, tenant, amountUsdCents);
92
+
93
+ const divisor = BigInt("1000000000000000000");
94
+ const whole = expectedWei / divisor;
95
+ const frac = (expectedWei % divisor).toString().padStart(18, "0").slice(0, 6);
96
+
97
+ return {
98
+ depositAddress,
99
+ displayAmount: `${whole}.${frac} ETH`,
100
+ amountUsd,
101
+ token: "ETH",
102
+ chain: method.chain,
103
+ referenceId: `eth:${method.chain}:${depositAddress}`,
104
+ priceCents,
105
+ };
106
+ }
107
+
108
+ async function handleNativeBtc(
109
+ deps: UnifiedCheckoutDeps,
110
+ _method: PaymentMethodRecord,
111
+ tenant: string,
112
+ amountUsdCents: number,
113
+ amountUsd: number,
114
+ ): Promise<UnifiedCheckoutResult> {
115
+ const { priceCents } = await deps.oracle.getPrice("BTC");
116
+ const expectedSats = centsToNative(amountUsdCents, priceCents, 8);
117
+
118
+ // BTC address derivation uses btcXpub — import from btc module
119
+ const { deriveBtcAddress } = await import("./btc/address-gen.js");
120
+ if (!deps.btcXpub) throw new Error("BTC payments not configured (no BTC_XPUB)");
121
+
122
+ const maxRetries = 3;
123
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
124
+ const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
125
+ const depositAddress = deriveBtcAddress(deps.btcXpub, derivationIndex, "mainnet");
126
+ const referenceId = `btc:${depositAddress}`;
127
+
128
+ try {
129
+ await deps.chargeStore.createStablecoinCharge({
130
+ referenceId,
131
+ tenantId: tenant,
132
+ amountUsdCents,
133
+ chain: "bitcoin",
134
+ token: "BTC",
135
+ depositAddress,
136
+ derivationIndex,
137
+ });
138
+
139
+ const btcAmount = Number(expectedSats) / 100_000_000;
140
+ return {
141
+ depositAddress,
142
+ displayAmount: `${btcAmount.toFixed(8)} BTC`,
143
+ amountUsd,
144
+ token: "BTC",
145
+ chain: "bitcoin",
146
+ referenceId,
147
+ priceCents,
148
+ };
149
+ } catch (err: unknown) {
150
+ const code = (err as { code?: string }).code;
151
+ const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
152
+ if (!isConflict || attempt === maxRetries) throw err;
153
+ }
154
+ }
155
+
156
+ throw new Error("Failed to claim derivation index after retries");
157
+ }
158
+
159
+ /** Derive an EVM deposit address and store the charge. Retries on unique conflict. */
160
+ async function deriveAndStore(
161
+ deps: UnifiedCheckoutDeps,
162
+ method: PaymentMethodRecord,
163
+ tenant: string,
164
+ amountUsdCents: number,
165
+ ): Promise<string> {
166
+ const maxRetries = 3;
167
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
168
+ const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
169
+ const depositAddress = deriveDepositAddress(deps.evmXpub, derivationIndex);
170
+ const referenceId = `${method.type}:${method.chain}:${depositAddress}`;
171
+
172
+ try {
173
+ await deps.chargeStore.createStablecoinCharge({
174
+ referenceId,
175
+ tenantId: tenant,
176
+ amountUsdCents,
177
+ chain: method.chain,
178
+ token: method.token,
179
+ depositAddress,
180
+ derivationIndex,
181
+ });
182
+ return depositAddress;
183
+ } catch (err: unknown) {
184
+ const code = (err as { code?: string }).code;
185
+ const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
186
+ if (!isConflict || attempt === maxRetries) throw err;
187
+ }
188
+ }
189
+ throw new Error("Failed to claim derivation index after retries");
190
+ }
@@ -1,5 +1,5 @@
1
1
  import { sql } from "drizzle-orm";
2
- import { index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
2
+ import { boolean, 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.
@@ -46,6 +46,26 @@ export const watcherCursors = pgTable("watcher_cursors", {
46
46
  updatedAt: text("updated_at").notNull().default(sql`(now())`),
47
47
  });
48
48
 
49
+ /**
50
+ * Payment method registry — runtime-configurable tokens/chains.
51
+ * Admin inserts a row to enable a new payment method. No deploy needed.
52
+ * Contract addresses are immutable on-chain but configurable here.
53
+ */
54
+ export const paymentMethods = pgTable("payment_methods", {
55
+ id: text("id").primaryKey(), // "USDC:base", "ETH:base", "BTC:mainnet"
56
+ type: text("type").notNull(), // "stablecoin", "eth", "btc"
57
+ token: text("token").notNull(), // "USDC", "ETH", "BTC"
58
+ chain: text("chain").notNull(), // "base", "ethereum", "bitcoin"
59
+ contractAddress: text("contract_address"), // null for native (ETH, BTC)
60
+ decimals: integer("decimals").notNull(),
61
+ displayName: text("display_name").notNull(),
62
+ enabled: boolean("enabled").notNull().default(true),
63
+ displayOrder: integer("display_order").notNull().default(0),
64
+ rpcUrl: text("rpc_url"), // override per-chain RPC (null = use default)
65
+ confirmations: integer("confirmations").notNull().default(1),
66
+ createdAt: text("created_at").notNull().default(sql`(now())`),
67
+ });
68
+
49
69
  /** Processed transaction IDs for watchers without block cursors (e.g. BTC). */
50
70
  export const watcherProcessed = pgTable(
51
71
  "watcher_processed",
@@ -64,6 +64,7 @@ export * from "./tenant-capability-settings.js";
64
64
  export * from "./tenant-customers.js";
65
65
  export * from "./tenant-model-selection.js";
66
66
  export * from "./tenant-status.js";
67
+ export * from "./tenant-update-configs.js";
67
68
  export * from "./tenants.js";
68
69
  export * from "./user-roles.js";
69
70
  export * from "./vps-subscriptions.js";
@@ -0,0 +1,16 @@
1
+ import { bigint, integer, pgTable, text } from "drizzle-orm/pg-core";
2
+
3
+ /**
4
+ * Per-tenant update configuration — controls whether a tenant's bots
5
+ * are updated automatically or require manual intervention.
6
+ */
7
+ export const tenantUpdateConfigs = pgTable("tenant_update_configs", {
8
+ /** Owning tenant (one config per tenant) */
9
+ tenantId: text("tenant_id").primaryKey(),
10
+ /** Update mode: 'auto' for automatic updates, 'manual' for opt-in */
11
+ mode: text("mode").notNull().default("manual"),
12
+ /** Preferred hour (0-23 UTC) for automatic updates */
13
+ preferredHourUtc: integer("preferred_hour_utc").notNull().default(3),
14
+ /** Epoch ms of last config change */
15
+ updatedAt: bigint("updated_at", { mode: "number" }).notNull(),
16
+ });
@@ -0,0 +1,69 @@
1
+ import type { PGlite } from "@electric-sql/pglite";
2
+ import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
3
+ import type { DrizzleDb } from "../../db/index.js";
4
+ import { beginTestTransaction, createTestDb, endTestTransaction, rollbackTestTransaction } from "../../test/db.js";
5
+ import { DrizzleTenantUpdateConfigRepository } from "../drizzle-tenant-update-config-repository.js";
6
+
7
+ describe("DrizzleTenantUpdateConfigRepository", () => {
8
+ let db: DrizzleDb;
9
+ let pool: PGlite;
10
+ let repo: DrizzleTenantUpdateConfigRepository;
11
+
12
+ beforeAll(async () => {
13
+ ({ db, pool } = await createTestDb());
14
+ await beginTestTransaction(pool);
15
+ });
16
+
17
+ afterAll(async () => {
18
+ await endTestTransaction(pool);
19
+ await pool.close();
20
+ });
21
+
22
+ beforeEach(async () => {
23
+ await rollbackTestTransaction(pool);
24
+ repo = new DrizzleTenantUpdateConfigRepository(db);
25
+ });
26
+
27
+ it("get returns null for unknown tenant", async () => {
28
+ expect(await repo.get("nonexistent")).toBeNull();
29
+ });
30
+
31
+ it("upsert creates a new config", async () => {
32
+ await repo.upsert("tenant-1", { mode: "auto", preferredHourUtc: 5 });
33
+ const config = await repo.get("tenant-1");
34
+ expect(config).not.toBeNull();
35
+ expect(config?.tenantId).toBe("tenant-1");
36
+ expect(config?.mode).toBe("auto");
37
+ expect(config?.preferredHourUtc).toBe(5);
38
+ expect(config?.updatedAt).toBeGreaterThan(0);
39
+ });
40
+
41
+ it("upsert updates an existing config", async () => {
42
+ await repo.upsert("tenant-1", { mode: "auto", preferredHourUtc: 5 });
43
+ const before = await repo.get("tenant-1");
44
+
45
+ await repo.upsert("tenant-1", { mode: "manual", preferredHourUtc: 12 });
46
+ const after = await repo.get("tenant-1");
47
+
48
+ expect(after?.mode).toBe("manual");
49
+ expect(after?.preferredHourUtc).toBe(12);
50
+ expect(after?.updatedAt).toBeGreaterThanOrEqual(before?.updatedAt ?? 0);
51
+ });
52
+
53
+ it("listAutoEnabled returns only auto-mode tenants", async () => {
54
+ await repo.upsert("tenant-auto-1", { mode: "auto", preferredHourUtc: 3 });
55
+ await repo.upsert("tenant-auto-2", { mode: "auto", preferredHourUtc: 8 });
56
+ await repo.upsert("tenant-manual", { mode: "manual", preferredHourUtc: 0 });
57
+
58
+ const autoConfigs = await repo.listAutoEnabled();
59
+ expect(autoConfigs).toHaveLength(2);
60
+ expect(autoConfigs.every((c) => c.mode === "auto")).toBe(true);
61
+ expect(autoConfigs.map((c) => c.tenantId).sort()).toEqual(["tenant-auto-1", "tenant-auto-2"]);
62
+ });
63
+
64
+ it("listAutoEnabled returns empty array when none enabled", async () => {
65
+ await repo.upsert("tenant-manual", { mode: "manual", preferredHourUtc: 0 });
66
+ const autoConfigs = await repo.listAutoEnabled();
67
+ expect(autoConfigs).toEqual([]);
68
+ });
69
+ });
@@ -0,0 +1,48 @@
1
+ import { eq } from "drizzle-orm";
2
+ import type { DrizzleDb } from "../db/index.js";
3
+ import { tenantUpdateConfigs } from "../db/schema/index.js";
4
+ import type { ITenantUpdateConfigRepository, TenantUpdateConfig } from "./tenant-update-config-repository.js";
5
+
6
+ /** Drizzle-backed implementation of ITenantUpdateConfigRepository. */
7
+ export class DrizzleTenantUpdateConfigRepository implements ITenantUpdateConfigRepository {
8
+ constructor(private readonly db: DrizzleDb) {}
9
+
10
+ async get(tenantId: string): Promise<TenantUpdateConfig | null> {
11
+ const rows = await this.db.select().from(tenantUpdateConfigs).where(eq(tenantUpdateConfigs.tenantId, tenantId));
12
+ return rows[0] ? toConfig(rows[0]) : null;
13
+ }
14
+
15
+ async upsert(tenantId: string, config: Omit<TenantUpdateConfig, "tenantId" | "updatedAt">): Promise<void> {
16
+ const now = Date.now();
17
+ await this.db
18
+ .insert(tenantUpdateConfigs)
19
+ .values({
20
+ tenantId,
21
+ mode: config.mode,
22
+ preferredHourUtc: config.preferredHourUtc,
23
+ updatedAt: now,
24
+ })
25
+ .onConflictDoUpdate({
26
+ target: tenantUpdateConfigs.tenantId,
27
+ set: {
28
+ mode: config.mode,
29
+ preferredHourUtc: config.preferredHourUtc,
30
+ updatedAt: now,
31
+ },
32
+ });
33
+ }
34
+
35
+ async listAutoEnabled(): Promise<TenantUpdateConfig[]> {
36
+ const rows = await this.db.select().from(tenantUpdateConfigs).where(eq(tenantUpdateConfigs.mode, "auto"));
37
+ return rows.map(toConfig);
38
+ }
39
+ }
40
+
41
+ function toConfig(row: typeof tenantUpdateConfigs.$inferSelect): TenantUpdateConfig {
42
+ return {
43
+ tenantId: row.tenantId,
44
+ mode: row.mode as "auto" | "manual",
45
+ preferredHourUtc: row.preferredHourUtc,
46
+ updatedAt: row.updatedAt,
47
+ };
48
+ }
@@ -1,7 +1,14 @@
1
1
  import { logger } from "../config/logger.js";
2
2
  import type { IFleetEventRepository } from "./fleet-event-repository.js";
3
3
 
4
- export type BotEventType = "bot.started" | "bot.stopped" | "bot.created" | "bot.removed" | "bot.restarted";
4
+ export type BotEventType =
5
+ | "bot.started"
6
+ | "bot.stopped"
7
+ | "bot.created"
8
+ | "bot.removed"
9
+ | "bot.restarted"
10
+ | "bot.updated"
11
+ | "bot.update_failed";
5
12
 
6
13
  export type NodeEventType =
7
14
  | "node.provisioned"
@@ -1,7 +1,9 @@
1
+ export * from "./drizzle-tenant-update-config-repository.js";
1
2
  export * from "./init-fleet-updater.js";
2
3
  export * from "./repository-types.js";
3
4
  export * from "./rollout-orchestrator.js";
4
5
  export * from "./rollout-strategy.js";
5
6
  export * from "./services.js";
7
+ export * from "./tenant-update-config-repository.js";
6
8
  export * from "./types.js";
7
9
  export * from "./volume-snapshot-manager.js";
@@ -14,11 +14,13 @@
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
18
  import type { FleetManager } from "./fleet-manager.js";
18
19
  import { ImagePoller } from "./image-poller.js";
19
20
  import type { IProfileStore } from "./profile-store.js";
20
21
  import { RolloutOrchestrator, type RolloutResult } from "./rollout-orchestrator.js";
21
22
  import { createRolloutStrategy, type RollingWaveOptions } from "./rollout-strategy.js";
23
+ import type { ITenantUpdateConfigRepository } from "./tenant-update-config-repository.js";
22
24
  import { ContainerUpdater } from "./updater.js";
23
25
  import { VolumeSnapshotManager } from "./volume-snapshot-manager.js";
24
26
 
@@ -33,6 +35,10 @@ export interface FleetUpdaterConfig {
33
35
  onBotUpdated?: (result: { botId: string; success: boolean; volumeRestored: boolean }) => void;
34
36
  /** Called when a rollout completes */
35
37
  onRolloutComplete?: (result: RolloutResult) => void;
38
+ /** Optional per-tenant update config repository. When provided, tenants with mode=manual are excluded. */
39
+ configRepo?: ITenantUpdateConfigRepository;
40
+ /** Optional fleet event emitter. When provided, bot.updated / bot.update_failed events are emitted. */
41
+ eventEmitter?: FleetEventEmitter;
36
42
  }
37
43
 
38
44
  export interface FleetUpdaterHandle {
@@ -69,6 +75,8 @@ export function initFleetUpdater(
69
75
  snapshotDir = "/data/fleet/snapshots",
70
76
  onBotUpdated,
71
77
  onRolloutComplete,
78
+ configRepo,
79
+ eventEmitter,
72
80
  } = config;
73
81
 
74
82
  const poller = new ImagePoller(docker, profileStore);
@@ -82,10 +90,37 @@ export function initFleetUpdater(
82
90
  strategy,
83
91
  getUpdatableProfiles: async () => {
84
92
  const profiles = await profileRepo.list();
85
- return profiles.filter((p) => p.updatePolicy !== "manual");
93
+ const nonManualPolicy = profiles.filter((p) => p.updatePolicy !== "manual");
94
+
95
+ if (!configRepo) return nonManualPolicy;
96
+
97
+ // Filter out tenants whose per-tenant config is set to manual
98
+ const results = await Promise.all(
99
+ nonManualPolicy.map(async (p) => {
100
+ const tenantCfg = await configRepo.get(p.tenantId);
101
+ // If tenant has an explicit config with mode=manual, exclude
102
+ if (tenantCfg && tenantCfg.mode === "manual") return null;
103
+ return p;
104
+ }),
105
+ );
106
+ return results.filter((p) => p !== null);
107
+ },
108
+ onBotUpdated: (result) => {
109
+ // Emit fleet events if emitter is provided
110
+ if (eventEmitter) {
111
+ eventEmitter.emit({
112
+ type: result.success ? "bot.updated" : "bot.update_failed",
113
+ botId: result.botId,
114
+ tenantId: "",
115
+ timestamp: new Date().toISOString(),
116
+ });
117
+ }
118
+ // Chain user-provided callback
119
+ onBotUpdated?.(result);
120
+ },
121
+ onRolloutComplete: (result) => {
122
+ onRolloutComplete?.(result);
86
123
  },
87
- onBotUpdated,
88
- onRolloutComplete,
89
124
  });
90
125
 
91
126
  // Wire the detection → orchestration pipeline.
@@ -50,6 +50,7 @@ import { DrizzleBotProfileRepository } from "./drizzle-bot-profile-repository.js
50
50
  import { DrizzleFleetEventRepository } from "./drizzle-fleet-event-repository.js";
51
51
  import { DrizzleNodeRepository } from "./drizzle-node-repository.js";
52
52
  import { DrizzleRecoveryRepository } from "./drizzle-recovery-repository.js";
53
+ import { DrizzleTenantUpdateConfigRepository } from "./drizzle-tenant-update-config-repository.js";
53
54
  import { FleetEventEmitter } from "./fleet-event-emitter.js";
54
55
  import type { IFleetEventRepository } from "./fleet-event-repository.js";
55
56
  import type { IGpuAllocationRepository } from "./gpu-allocation-repository.js";
@@ -76,6 +77,7 @@ import type { IRecoveryRepository } from "./recovery-repository.js";
76
77
  import type { IRegistrationTokenRepository } from "./registration-token-store.js";
77
78
  import { DrizzleRegistrationTokenRepository } from "./registration-token-store.js";
78
79
  import { DrizzleSpendingCapStore } from "./spending-cap-repository.js";
80
+ import type { ITenantUpdateConfigRepository } from "./tenant-update-config-repository.js";
79
81
  import type { IVpsRepository } from "./vps-repository.js";
80
82
  import { DrizzleVpsRepository } from "./vps-repository.js";
81
83
 
@@ -128,6 +130,9 @@ let _fleetEventEmitter: FleetEventEmitter | null = null;
128
130
  // Fleet event repository
129
131
  let _fleetEventRepo: IFleetEventRepository | null = null;
130
132
 
133
+ // Tenant update config
134
+ let _tenantUpdateConfigRepo: ITenantUpdateConfigRepository | null = null;
135
+
131
136
  // Infrastructure
132
137
  let _doClient: DOClient | null = null;
133
138
  let _nodeProvider: INodeProvider | null = null;
@@ -403,6 +408,13 @@ export function getFleetEventRepo(): IFleetEventRepository {
403
408
  return _fleetEventRepo;
404
409
  }
405
410
 
411
+ export function getTenantUpdateConfigRepo(): ITenantUpdateConfigRepository {
412
+ if (!_tenantUpdateConfigRepo) {
413
+ _tenantUpdateConfigRepo = new DrizzleTenantUpdateConfigRepository(getDb());
414
+ }
415
+ return _tenantUpdateConfigRepo;
416
+ }
417
+
406
418
  // ---------------------------------------------------------------------------
407
419
  // HeartbeatWatchdog
408
420
  // ---------------------------------------------------------------------------
@@ -894,6 +906,7 @@ export function _resetForTest(): void {
894
906
  _inferenceWatchdog = null;
895
907
  _fleetEventRepo = null;
896
908
  _fleetEventEmitter = null;
909
+ _tenantUpdateConfigRepo = null;
897
910
  _doClient = null;
898
911
  _nodeProvider = null;
899
912
  _nodeProvisioner = null;
@@ -0,0 +1,12 @@
1
+ export interface TenantUpdateConfig {
2
+ tenantId: string;
3
+ mode: "auto" | "manual";
4
+ preferredHourUtc: number;
5
+ updatedAt: number;
6
+ }
7
+
8
+ export interface ITenantUpdateConfigRepository {
9
+ get(tenantId: string): Promise<TenantUpdateConfig | null>;
10
+ upsert(tenantId: string, config: Omit<TenantUpdateConfig, "tenantId" | "updatedAt">): Promise<void>;
11
+ listAutoEnabled(): Promise<TenantUpdateConfig[]>;
12
+ }
@@ -0,0 +1,31 @@
1
+ import { z } from "zod";
2
+ import { logger } from "../config/logger.js";
3
+ import type { ITenantUpdateConfigRepository } from "../fleet/tenant-update-config-repository.js";
4
+ import { protectedProcedure, router } from "./init.js";
5
+
6
+ export function createFleetUpdateConfigRouter(configRepo: ITenantUpdateConfigRepository) {
7
+ return router({
8
+ getUpdateConfig: protectedProcedure.input(z.object({ tenantId: z.string().min(1) })).query(async ({ input }) => {
9
+ return configRepo.get(input.tenantId);
10
+ }),
11
+
12
+ setUpdateConfig: protectedProcedure
13
+ .input(
14
+ z.object({
15
+ tenantId: z.string().min(1),
16
+ mode: z.enum(["auto", "manual"]),
17
+ preferredHourUtc: z.number().int().min(0).max(23).optional(),
18
+ }),
19
+ )
20
+ .mutation(async ({ input }) => {
21
+ await configRepo.upsert(input.tenantId, {
22
+ mode: input.mode,
23
+ preferredHourUtc: input.preferredHourUtc ?? 3,
24
+ });
25
+ logger.info("Tenant update config changed", {
26
+ tenantId: input.tenantId,
27
+ mode: input.mode,
28
+ });
29
+ }),
30
+ });
31
+ }
package/src/trpc/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
1
2
  export {
2
3
  adminProcedure,
3
4
  createCallerFactory,