@helyx/bot 0.4.33 → 0.4.36

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 (40) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cloud-management-module.js +7 -1
  3. package/dist/control-server.d.ts +6 -7
  4. package/dist/control-server.js +38 -45
  5. package/dist/dashboard-commands.d.ts +10 -0
  6. package/dist/dashboard-commands.js +47 -0
  7. package/dist/developer-operations-module.js +5 -1
  8. package/dist/discord-guild-health.d.ts +4 -3
  9. package/dist/discord-guild-health.js +6 -3
  10. package/dist/events.js +38 -16
  11. package/dist/index.js +43 -3
  12. package/dist/interactions.d.ts +3 -2
  13. package/dist/interactions.js +132 -45
  14. package/dist/internal-module-manifest.d.ts +13 -0
  15. package/dist/internal-module-manifest.js +14 -0
  16. package/dist/management-module.js +7 -2
  17. package/dist/module-availability-control.d.ts +24 -0
  18. package/dist/module-availability-control.js +61 -0
  19. package/dist/modules.js +24 -1
  20. package/dist/nexus-commands.d.ts +13 -0
  21. package/dist/nexus-commands.js +100 -0
  22. package/dist/numeric-barriers.d.ts +11 -0
  23. package/dist/numeric-barriers.js +14 -0
  24. package/dist/numeric-write-behind-proxy.d.ts +4 -4
  25. package/dist/numeric-write-behind-proxy.js +45 -24
  26. package/dist/numeric-write-behind-redis.d.ts +4 -4
  27. package/dist/numeric-write-behind-redis.js +132 -70
  28. package/dist/oauth-module.js +3 -1
  29. package/dist/permissions-module.js +18 -5
  30. package/dist/renewable-lease.d.ts +16 -0
  31. package/dist/renewable-lease.js +66 -0
  32. package/dist/scheduled-task-runner.js +15 -1
  33. package/dist/shard-router.d.ts +2 -0
  34. package/dist/shard-router.js +5 -0
  35. package/dist/shard-supervisor.d.ts +5 -0
  36. package/dist/shard-supervisor.js +63 -4
  37. package/dist/shard-worker.d.ts +7 -1
  38. package/dist/shard-worker.js +4 -1
  39. package/dist/support-module.js +3 -1
  40. package/package.json +5 -4
@@ -2,6 +2,7 @@ import {} from "@helyx/sdk";
2
2
  import {} from "@helyx/platform";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { createClient } from "redis";
5
+ import { RenewableLease } from "./renewable-lease.js";
5
6
  const REDIS_FLUSH_INTERVAL_MS = 30_000;
6
7
  const REDIS_BATCH_SIZE = 100;
7
8
  const REDIS_CAPACITY = 10_000;
@@ -189,40 +190,13 @@ export class RedisNumericWriteBehindService {
189
190
  return this.#flush(input, "", "", true);
190
191
  }
191
192
  async runMemberBarrier(input, operation) {
192
- const barrierId = await this.#acquireBarrier(input, "member", input.guildId, input.subjectId);
193
- const stopRenewal = this.#startBarrierRenewal(input, "member", input.guildId, input.subjectId, barrierId);
194
- try {
195
- await this.flushMember(input);
196
- return await operation();
197
- }
198
- finally {
199
- stopRenewal();
200
- await this.#releaseBarrier(input, "member", input.guildId, input.subjectId, barrierId);
201
- }
193
+ return this.#runBarrier(input, "member", input.guildId, input.subjectId, () => this.flushMember(input), operation);
202
194
  }
203
195
  async runGuildBarrier(input, operation) {
204
- const barrierId = await this.#acquireBarrier(input, "guild", input.guildId, "");
205
- const stopRenewal = this.#startBarrierRenewal(input, "guild", input.guildId, "", barrierId);
206
- try {
207
- await this.flushGuild(input);
208
- return await operation();
209
- }
210
- finally {
211
- stopRenewal();
212
- await this.#releaseBarrier(input, "guild", input.guildId, "", barrierId);
213
- }
196
+ return this.#runBarrier(input, "guild", input.guildId, "", () => this.flushGuild(input), operation);
214
197
  }
215
198
  async runSubjectBarrier(input, operation) {
216
- const barrierId = await this.#acquireBarrier(input, "subject", "", input.subjectId);
217
- const stopRenewal = this.#startBarrierRenewal(input, "subject", "", input.subjectId, barrierId);
218
- try {
219
- await this.flushSubject(input);
220
- return await operation();
221
- }
222
- finally {
223
- stopRenewal();
224
- await this.#releaseBarrier(input, "subject", "", input.subjectId, barrierId);
225
- }
199
+ return this.#runBarrier(input, "subject", "", input.subjectId, () => this.flushSubject(input), operation);
226
200
  }
227
201
  async suppressSubject(input) {
228
202
  this.#assertAvailable(input);
@@ -420,16 +394,17 @@ export class RedisNumericWriteBehindService {
420
394
  async #drainRegistration(registration, guildId, subjectId, force) {
421
395
  while (true) {
422
396
  const proposedOperationId = randomUUID();
397
+ const proposedClaimToken = randomUUID();
423
398
  const raw = await this.#redis.eval(CLAIM_SCRIPT, {
424
399
  keys: keysFor(this.#prefix, registration),
425
400
  arguments: [
426
401
  String(Date.now()),
427
402
  String(REDIS_CLAIM_LEASE_MS),
428
403
  proposedOperationId,
404
+ proposedClaimToken,
429
405
  String(REDIS_BATCH_SIZE),
430
406
  guildId,
431
407
  subjectId,
432
- force ? "1" : "0",
433
408
  this.#supervisorId ?? "",
434
409
  this.#shardLeaseKeyPrefix ?? "",
435
410
  String(REDIS_RETRY_RETENTION_MS),
@@ -451,33 +426,45 @@ export class RedisNumericWriteBehindService {
451
426
  ...this.#health,
452
427
  inFlightEntries: claim.entries.length,
453
428
  };
429
+ const renewal = new RenewableLease({
430
+ intervalMs: Math.max(250, Math.floor(REDIS_CLAIM_LEASE_MS / 3)),
431
+ renew: () => this.#renewClaim(registration, claim),
432
+ onError: (error) => this.#onError?.(error),
433
+ });
434
+ renewal.start();
454
435
  try {
455
- const output = await this.#writer.mutateNumericBatch({
456
- moduleId: registration.moduleId,
457
- collection: registration.collection,
458
- operationId: claim.operationId,
459
- uniqueBy: registration.uniqueBy,
460
- mutations: claim.entries.map(decodeMutation),
461
- select: registration.select,
462
- });
463
- if (output.results.length !== claim.entries.length)
464
- throw new Error("Redis numeric writer result count was invalid");
465
- const committed = {
466
- moduleId: registration.moduleId,
467
- collection: registration.collection,
468
- operationId: claim.operationId,
469
- replayed: output.replayed,
470
- mutations: claim.entries.map(decodeMutation),
471
- results: output.results,
472
- };
473
- for (const subscriber of this.#subscribers.get(scopeKey(registration)) ?? [])
474
- await subscriber(committed);
475
- const acknowledged = await this.#redis.eval(ACK_SCRIPT, {
476
- keys: keysFor(this.#prefix, registration),
477
- arguments: [claim.operationId],
436
+ await renewal.run(async (signal) => {
437
+ const output = await this.#writer.mutateNumericBatch({
438
+ moduleId: registration.moduleId,
439
+ collection: registration.collection,
440
+ operationId: claim.operationId,
441
+ uniqueBy: registration.uniqueBy,
442
+ mutations: claim.entries.map(decodeMutation),
443
+ select: registration.select,
444
+ });
445
+ signal.throwIfAborted();
446
+ if (output.results.length !== claim.entries.length)
447
+ throw new Error("Redis numeric writer result count was invalid");
448
+ const committed = {
449
+ moduleId: registration.moduleId,
450
+ collection: registration.collection,
451
+ operationId: claim.operationId,
452
+ replayed: output.replayed,
453
+ mutations: claim.entries.map(decodeMutation),
454
+ results: output.results,
455
+ };
456
+ for (const subscriber of this.#subscribers.get(scopeKey(registration)) ?? []) {
457
+ signal.throwIfAborted();
458
+ await subscriber(committed, signal);
459
+ }
460
+ signal.throwIfAborted();
461
+ const acknowledged = await this.#redis.eval(ACK_SCRIPT, {
462
+ keys: keysFor(this.#prefix, registration),
463
+ arguments: [claim.operationId, claim.claimToken],
464
+ });
465
+ if (Number(acknowledged) !== 1)
466
+ throw new Error("Redis numeric claim ownership changed");
478
467
  });
479
- if (Number(acknowledged) !== 1)
480
- throw new Error("Redis numeric claim ownership changed");
481
468
  }
482
469
  catch (error) {
483
470
  const attempt = claim.attempt + 1;
@@ -485,7 +472,12 @@ export class RedisNumericWriteBehindService {
485
472
  Math.min(REDIS_RETRY_MAX_MS, REDIS_RETRY_BASE_MS * 2 ** Math.min(claim.attempt, 16));
486
473
  await this.#redis.eval(RETRY_SCRIPT, {
487
474
  keys: keysFor(this.#prefix, registration),
488
- arguments: [claim.operationId, String(attempt), String(retryAt)],
475
+ arguments: [
476
+ claim.operationId,
477
+ claim.claimToken,
478
+ String(attempt),
479
+ String(retryAt),
480
+ ],
489
481
  });
490
482
  this.#health = {
491
483
  ...this.#health,
@@ -498,6 +490,9 @@ export class RedisNumericWriteBehindService {
498
490
  throw error;
499
491
  return;
500
492
  }
493
+ finally {
494
+ await renewal.stop();
495
+ }
501
496
  this.#health = {
502
497
  ...this.#health,
503
498
  inFlightEntries: 0,
@@ -505,6 +500,20 @@ export class RedisNumericWriteBehindService {
505
500
  };
506
501
  }
507
502
  }
503
+ async #renewClaim(registration, claim) {
504
+ const now = Date.now();
505
+ const renewed = await this.#redis.eval(RENEW_CLAIM_SCRIPT, {
506
+ keys: keysFor(this.#prefix, registration),
507
+ arguments: [
508
+ claim.operationId,
509
+ claim.claimToken,
510
+ String(now),
511
+ String(now + REDIS_CLAIM_LEASE_MS),
512
+ ],
513
+ });
514
+ if (Number(renewed) !== 1)
515
+ throw new Error("Redis numeric claim lease expired");
516
+ }
508
517
  async #acquireBarrier(scope, kind, guildId, subjectId) {
509
518
  this.#assertAvailable(scope);
510
519
  const barrierId = randomUUID();
@@ -550,12 +559,47 @@ export class RedisNumericWriteBehindService {
550
559
  if (Number(result) !== 1)
551
560
  throw new Error("Numeric write-behind barrier lease expired");
552
561
  }
553
- #startBarrierRenewal(scope, kind, guildId, subjectId, barrierId) {
554
- const timer = setInterval(() => {
555
- void this.#renewBarrier(scope, kind, guildId, subjectId, barrierId).catch((error) => this.#onError?.(toError(error)));
556
- }, Math.max(250, Math.floor(this.#barrierLeaseMs / 3)));
557
- timer.unref();
558
- return () => clearInterval(timer);
562
+ async #runBarrier(scope, kind, guildId, subjectId, flush, operation) {
563
+ const barrierId = await this.#acquireBarrier(scope, kind, guildId, subjectId);
564
+ const renewal = new RenewableLease({
565
+ intervalMs: Math.max(250, Math.floor(this.#barrierLeaseMs / 3)),
566
+ renew: () => this.#renewBarrier(scope, kind, guildId, subjectId, barrierId),
567
+ onError: (error) => this.#onError?.(error),
568
+ });
569
+ renewal.start();
570
+ const failures = [];
571
+ let result;
572
+ let completed = false;
573
+ try {
574
+ result = await renewal.run(async (signal) => {
575
+ await flush();
576
+ signal.throwIfAborted();
577
+ return operation(signal);
578
+ });
579
+ completed = true;
580
+ }
581
+ catch (error) {
582
+ failures.push(error);
583
+ }
584
+ try {
585
+ await renewal.stop();
586
+ }
587
+ catch (error) {
588
+ failures.push(error);
589
+ }
590
+ try {
591
+ await this.#releaseBarrier(scope, kind, guildId, subjectId, barrierId);
592
+ }
593
+ catch (error) {
594
+ failures.push(error);
595
+ }
596
+ if (failures.length === 1)
597
+ throw failures[0];
598
+ if (failures.length > 1)
599
+ throw new AggregateError(failures, "Numeric barrier cleanup failed");
600
+ if (!completed)
601
+ throw new Error("Numeric barrier operation did not complete");
602
+ return result;
559
603
  }
560
604
  async #waitForSuppression(scope, script, arguments_) {
561
605
  const deadline = Date.now() + this.#suppressionDrainTimeoutMs;
@@ -785,6 +829,9 @@ function parseClaim(raw) {
785
829
  !("operationId" in parsed) ||
786
830
  typeof parsed.operationId !== "string" ||
787
831
  !UUID.test(parsed.operationId) ||
832
+ !("claimToken" in parsed) ||
833
+ typeof parsed.claimToken !== "string" ||
834
+ !UUID.test(parsed.claimToken) ||
788
835
  !("entries" in parsed) ||
789
836
  !Array.isArray(parsed.entries) ||
790
837
  parsed.entries.length < 1 ||
@@ -940,10 +987,10 @@ ${BARRIER_HELPERS_LUA}
940
987
  local now = tonumber(ARGV[1])
941
988
  local lease_ms = tonumber(ARGV[2])
942
989
  local proposed_id = ARGV[3]
943
- local limit = tonumber(ARGV[4])
944
- local guild_filter = ARGV[5]
945
- local subject_filter = ARGV[6]
946
- local force = ARGV[7] == '1'
990
+ local claim_token = ARGV[4]
991
+ local limit = tonumber(ARGV[5])
992
+ local guild_filter = ARGV[6]
993
+ local subject_filter = ARGV[7]
947
994
  local owner = ARGV[8]
948
995
  local lease_prefix = ARGV[9]
949
996
  local retry_retention_ms = tonumber(ARGV[10])
@@ -958,7 +1005,7 @@ for index = 1, #staged, 2 do
958
1005
  redis.call('HDEL', KEYS[3], member_key)
959
1006
  end
960
1007
  end
961
- local due = redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', force and '+inf' or now, 'LIMIT', 0, 100)
1008
+ local due = redis.call('ZRANGEBYSCORE', KEYS[5], '-inf', now, 'LIMIT', 0, 100)
962
1009
  for _, operation_id in ipairs(due) do
963
1010
  local payload = redis.call('HGET', KEYS[4], operation_id)
964
1011
  if payload then
@@ -1000,6 +1047,7 @@ for _, operation_id in ipairs(due) do
1000
1047
  end
1001
1048
  claim.leaseKey = lease_key
1002
1049
  claim.leaseValue = lease_value
1050
+ claim.claimToken = claim_token
1003
1051
  local encoded = cjson.encode(claim)
1004
1052
  redis.call('HSET', KEYS[4], operation_id, encoded)
1005
1053
  redis.call('ZADD', KEYS[5], now + lease_ms, operation_id)
@@ -1049,6 +1097,7 @@ if #entries == 0 then
1049
1097
  end
1050
1098
  local claim = {
1051
1099
  operationId = proposed_id,
1100
+ claimToken = claim_token,
1052
1101
  entries = entries,
1053
1102
  attempt = 0,
1054
1103
  expiresAt = now + retry_retention_ms,
@@ -1068,6 +1117,7 @@ const ACK_SCRIPT = `
1068
1117
  local payload = redis.call('HGET', KEYS[4], ARGV[1])
1069
1118
  if not payload then return 0 end
1070
1119
  local claim = cjson.decode(payload)
1120
+ if claim.claimToken ~= ARGV[2] then return 0 end
1071
1121
  if claim.leaseKey and claim.leaseKey ~= '' and
1072
1122
  redis.call('GET', claim.leaseKey) ~= claim.leaseValue then return 0 end
1073
1123
  for _, entry in ipairs(claim.entries) do
@@ -1082,13 +1132,25 @@ const RETRY_SCRIPT = `
1082
1132
  local payload = redis.call('HGET', KEYS[4], ARGV[1])
1083
1133
  if not payload then return 0 end
1084
1134
  local claim = cjson.decode(payload)
1085
- claim.attempt = tonumber(ARGV[2])
1135
+ if claim.claimToken ~= ARGV[2] then return 0 end
1136
+ claim.attempt = tonumber(ARGV[3])
1086
1137
  redis.call('HSET', KEYS[4], ARGV[1], cjson.encode(claim))
1087
- local retry_at = tonumber(ARGV[3])
1138
+ local retry_at = tonumber(ARGV[4])
1088
1139
  if claim.expiresAt and retry_at > tonumber(claim.expiresAt) then retry_at = tonumber(claim.expiresAt) end
1089
1140
  redis.call('ZADD', KEYS[5], retry_at, ARGV[1])
1090
1141
  return 1
1091
1142
  `;
1143
+ const RENEW_CLAIM_SCRIPT = `
1144
+ local payload = redis.call('HGET', KEYS[4], ARGV[1])
1145
+ if not payload then return 0 end
1146
+ local claim = cjson.decode(payload)
1147
+ if claim.claimToken ~= ARGV[2] then return 0 end
1148
+ if claim.leaseKey and claim.leaseKey ~= '' and
1149
+ redis.call('GET', claim.leaseKey) ~= claim.leaseValue then return 0 end
1150
+ if tonumber(redis.call('ZSCORE', KEYS[5], ARGV[1]) or 0) <= tonumber(ARGV[3]) then return 0 end
1151
+ redis.call('ZADD', KEYS[5], ARGV[4], ARGV[1])
1152
+ return 1
1153
+ `;
1092
1154
  const HEALTH_SCRIPT = `
1093
1155
  local oldest = redis.call('ZRANGE', KEYS[2], 0, 0, 'WITHSCORES')
1094
1156
  return cjson.encode({
@@ -1,4 +1,5 @@
1
1
  import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
2
3
  const MODULE_ID = "helyx.oauth";
3
4
  const MODAL_ID = "helyx.oauth.revoke";
4
5
  const SERVICE_FIELD_ID = "helyx.oauth.revoke.service";
@@ -15,7 +16,7 @@ const everyonePolicy = {
15
16
  export function createOAuthModule() {
16
17
  return defineModule({
17
18
  manifest: {
18
- manifestVersion: "0.1",
19
+ ...INTERNAL_MODULE_MANIFEST_FIELDS,
19
20
  releaseStatus: "live",
20
21
  id: MODULE_ID,
21
22
  name: "Helyx OAuth",
@@ -43,6 +44,7 @@ export function createOAuthModule() {
43
44
  {
44
45
  name: "revoke",
45
46
  description: "Revoke a service and delete the OAuth details Helyx stores for you.",
47
+ acknowledgement: "immediate-modal",
46
48
  rateLimit: { scope: "user", limit: 5, windowSeconds: 60 },
47
49
  execute: async (context) => {
48
50
  const oauth = context.services.get(HELYX_SERVICE_NAMES.oauth);
@@ -1,6 +1,8 @@
1
1
  import { defineModule, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
2
+ import { isCustomerSelectableModule } from "@helyx/module-manifest";
2
3
  import { PermissionFlagsBits } from "discord.js";
3
4
  import { canMutatePermissionPolicy, permissionMutationDenialMessage, } from "@helyx/core";
5
+ import { INTERNAL_MODULE_MANIFEST_FIELDS } from "./internal-module-manifest.js";
4
6
  const MODULE_SELECT_ID = "helyx.permissions.module";
5
7
  const TARGET_SELECT_ID = "helyx.permissions.target";
6
8
  const MODULE_PREVIOUS_ID = "helyx.permissions.module.previous";
@@ -17,13 +19,15 @@ const MODULE_PAGE_SIZE = 25;
17
19
  const TARGET_PAGE_SIZE = 24;
18
20
  const VIOLET = 0x7c3aed;
19
21
  export function createPermissionsModule(configurableModules) {
20
- const modules = new Map(configurableModules.map((module) => [module.manifest.id, module]));
22
+ const modules = new Map(configurableModules
23
+ .filter((module) => isCustomerSelectableModule(module.manifest))
24
+ .map((module) => [module.manifest.id, module]));
21
25
  const commandModules = [...modules.values()]
22
26
  .filter((module) => commandTargets(module).length > 0)
23
27
  .sort((left, right) => left.manifest.name.localeCompare(right.manifest.name, "en-GB"));
24
28
  return defineModule({
25
29
  manifest: {
26
- manifestVersion: "0.1",
30
+ ...INTERNAL_MODULE_MANIFEST_FIELDS,
27
31
  releaseStatus: "live",
28
32
  id: "helyx.permissions",
29
33
  name: "Permissions",
@@ -46,6 +50,7 @@ export function createPermissionsModule(configurableModules) {
46
50
  {
47
51
  name: "permissions",
48
52
  description: "Configure access to Helyx modules and commands",
53
+ acknowledgement: "deferred-ephemeral",
49
54
  registration: "global",
50
55
  contexts: ["guild"],
51
56
  integrationTypes: ["guild_install"],
@@ -359,9 +364,17 @@ function restrictedPolicy() {
359
364
  };
360
365
  }
361
366
  function commandTargets(module) {
362
- return (module.interactions?.commands ?? []).flatMap((command) => command.subcommands?.length
363
- ? command.subcommands.map((subcommand) => `${command.name}.${subcommand.name}`)
364
- : [command.name]);
367
+ const targets = (module.interactions?.commands ?? []).flatMap((command) => {
368
+ const nested = [
369
+ ...(command.subcommands ?? []).map((subcommand) => `${command.name}.${subcommand.name}`),
370
+ ...(command.subcommandGroups ?? []).flatMap((group) => group.subcommands.map((subcommand) => `${command.name}.${group.name}.${subcommand.name}`)),
371
+ ];
372
+ if (nested.length > 0)
373
+ return nested;
374
+ return [command.name];
375
+ });
376
+ targets.push(...(module.nexusCommands ?? []).map((command) => `nexus.${command.path}`));
377
+ return [...new Set(targets)];
365
378
  }
366
379
  function modulePickerResponse(modules, requestedPage) {
367
380
  const pages = pageCount(modules.length, MODULE_PAGE_SIZE);
@@ -0,0 +1,16 @@
1
+ interface RenewableLeaseOptions {
2
+ intervalMs: number;
3
+ renew(): Promise<void>;
4
+ onError?(error: Error): void;
5
+ }
6
+ export declare class RenewableLease {
7
+ #private;
8
+ private readonly options;
9
+ constructor(options: RenewableLeaseOptions);
10
+ start(): void;
11
+ assertOwned(): void;
12
+ run<Result>(operation: (signal: AbortSignal) => Promise<Result>): Promise<Result>;
13
+ stop(): Promise<void>;
14
+ }
15
+ export {};
16
+ //# sourceMappingURL=renewable-lease.d.ts.map
@@ -0,0 +1,66 @@
1
+ export class RenewableLease {
2
+ options;
3
+ #controller = new AbortController();
4
+ #lost;
5
+ #resolveLost;
6
+ #failure;
7
+ #renewing;
8
+ #timer;
9
+ constructor(options) {
10
+ this.options = options;
11
+ let resolveLost;
12
+ this.#lost = new Promise((resolve) => {
13
+ resolveLost = resolve;
14
+ });
15
+ this.#resolveLost = resolveLost;
16
+ }
17
+ start() {
18
+ if (this.#timer || this.#failure)
19
+ return;
20
+ this.#timer = setInterval(() => this.#tick(), this.options.intervalMs);
21
+ this.#timer.unref();
22
+ }
23
+ assertOwned() {
24
+ if (this.#failure)
25
+ throw this.#failure;
26
+ }
27
+ async run(operation) {
28
+ this.assertOwned();
29
+ const operationOutcome = Promise.resolve()
30
+ .then(() => operation(this.#controller.signal))
31
+ .then((value) => ({ kind: "completed", value }), (error) => ({ kind: "failed", error }));
32
+ const outcome = await Promise.race([
33
+ operationOutcome,
34
+ this.#lost.then((error) => ({ kind: "lease-lost", error })),
35
+ ]);
36
+ if (outcome.kind === "lease-lost") {
37
+ await operationOutcome;
38
+ throw outcome.error;
39
+ }
40
+ if (outcome.kind === "failed")
41
+ throw outcome.error;
42
+ this.assertOwned();
43
+ return outcome.value;
44
+ }
45
+ async stop() {
46
+ if (this.#timer)
47
+ clearInterval(this.#timer);
48
+ this.#timer = undefined;
49
+ await this.#renewing;
50
+ }
51
+ #tick() {
52
+ if (this.#renewing || this.#failure)
53
+ return;
54
+ const renewing = this.options.renew().catch((error) => {
55
+ const failure = error instanceof Error ? error : new Error("Lease renewal failed");
56
+ this.#failure = failure;
57
+ this.#controller.abort(failure);
58
+ this.#resolveLost(failure);
59
+ this.options.onError?.(failure);
60
+ });
61
+ this.#renewing = renewing.finally(() => {
62
+ this.#renewing = undefined;
63
+ });
64
+ }
65
+ }
66
+ //# sourceMappingURL=renewable-lease.js.map
@@ -58,7 +58,14 @@ export class ScheduledTaskRunner {
58
58
  const active = this.#active;
59
59
  if (!active)
60
60
  return;
61
- await Promise.race([active, wait(this.#stopTimeoutMs)]);
61
+ const drainedBeforeDeadline = await Promise.race([
62
+ active.then(() => true),
63
+ wait(this.#stopTimeoutMs).then(() => false),
64
+ ]);
65
+ if (!drainedBeforeDeadline) {
66
+ this.#logger.warn({ event: "scheduler.stop_deadline_exceeded" }, "Scheduled task runner is still draining after its shutdown deadline");
67
+ await active;
68
+ }
62
69
  }
63
70
  async runOnce() {
64
71
  if (this.#stopping)
@@ -321,6 +328,13 @@ export function createLocalScheduledTaskRoute(input) {
321
328
  const module = modules.get(task.moduleId);
322
329
  if (!module)
323
330
  return deferred("module_unavailable", DEFAULT_POLL_INTERVAL_MS * 30);
331
+ if (typeof input.services.has === "function" &&
332
+ input.services.has(HELYX_SERVICE_NAMES.moduleAvailability)) {
333
+ const availability = input.services.get(HELYX_SERVICE_NAMES.moduleAvailability);
334
+ if (typeof availability?.isAvailable === "function" &&
335
+ !(await availability.isAvailable(task.moduleId)))
336
+ return deferred("module_globally_disabled", DEFAULT_POLL_INTERVAL_MS * 30);
337
+ }
324
338
  if (task.guildId) {
325
339
  const enabled = await input.services
326
340
  .get(HELYX_SERVICE_NAMES.installations)
@@ -11,11 +11,13 @@ export declare class ShardControlRouter implements BotControlHandler {
11
11
  localLeases: ReadonlyMap<number, ShardLease>;
12
12
  dispatchLocal: LocalDispatcher;
13
13
  privacy?: Pick<BotControlHandler, "listPrivacyProviders" | "capturePrivacy" | "erasePrivacy">;
14
+ availability?: Pick<BotControlHandler, "listAvailabilityModules">;
14
15
  localLeaderLease?: ShardLeaderLease;
15
16
  coordinator?: ShardCoordinator;
16
17
  notificationGuildId?: string;
17
18
  });
18
19
  cleanupOAuthConsumer(input: Parameters<BotControlHandler["cleanupOAuthConsumer"]>[0]): ReturnType<BotControlHandler["cleanupOAuthConsumer"]>;
20
+ listAvailabilityModules(): ReturnType<BotControlHandler["listAvailabilityModules"]>;
19
21
  getGuildState(guildId: string): ReturnType<BotControlHandler["getGuildState"]>;
20
22
  canAccessDashboard(input: Parameters<BotControlHandler["canAccessDashboard"]>[0]): ReturnType<BotControlHandler["canAccessDashboard"]>;
21
23
  getGuildAccessPolicy(input: Parameters<BotControlHandler["getGuildAccessPolicy"]>[0]): ReturnType<BotControlHandler["getGuildAccessPolicy"]>;
@@ -15,6 +15,11 @@ export class ShardControlRouter {
15
15
  cleanupOAuthConsumer(input) {
16
16
  return this.#route({ kind: "oauth.consumer.cleanup", ...input });
17
17
  }
18
+ listAvailabilityModules() {
19
+ if (!this.input.availability)
20
+ return Promise.reject(new BotControlTransportError(503, "Module availability catalogue is unavailable"));
21
+ return this.input.availability.listAvailabilityModules();
22
+ }
18
23
  getGuildState(guildId) {
19
24
  return this.#route({ kind: "guild.state.get", guildId });
20
25
  }
@@ -9,6 +9,11 @@ export declare function supervisorShutdownExitCode(reason: string): number | und
9
9
  export declare function createStartupWorkerHeartbeatMaintenance(heartbeat: () => void, intervalMs?: number): {
10
10
  stop(): void;
11
11
  };
12
+ export declare function relinquishRestartCircuitOwnership(input: {
13
+ shardId: number;
14
+ localLeases: Map<number, ShardLease>;
15
+ release(lease: ShardLease): Promise<boolean>;
16
+ }): Promise<boolean>;
12
17
  export declare function deferScheduledRoutingFailures(route: () => Promise<ScheduledTaskExecutionResult>): Promise<ScheduledTaskExecutionResult>;
13
18
  export declare function startShardSupervisor(environment: BotEnvironment, processEnvironment: NodeJS.ProcessEnv, workerEntry: string): Promise<BotHandle>;
14
19
  export declare function splitNumericCommittedBatch(batch: NumericWriteBehindCommittedBatch, index: number): NumericWriteBehindCommittedBatch;