@helyx/bot 0.4.2 → 0.4.5

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 (38) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +13 -0
  3. package/dist/cloud-connector.d.ts +7 -0
  4. package/dist/cloud-connector.js +118 -1
  5. package/dist/cloud-control-dispatcher.js +84 -0
  6. package/dist/control-server.d.ts +187 -5
  7. package/dist/control-server.js +673 -6
  8. package/dist/developer-operations-module.js +8 -0
  9. package/dist/discord-owned-message-service.d.ts +39 -0
  10. package/dist/discord-owned-message-service.js +138 -0
  11. package/dist/events.d.ts +70 -1
  12. package/dist/events.js +557 -5
  13. package/dist/index.js +61 -13
  14. package/dist/interactions.d.ts +3 -2
  15. package/dist/interactions.js +66 -31
  16. package/dist/module-logging.d.ts +1 -0
  17. package/dist/module-logging.js +19 -1
  18. package/dist/modules.d.ts +9 -0
  19. package/dist/modules.js +45 -0
  20. package/dist/resource-authorization.d.ts +7 -0
  21. package/dist/resource-authorization.js +12 -0
  22. package/dist/scheduled-task-runner.d.ts +130 -0
  23. package/dist/scheduled-task-runner.js +417 -0
  24. package/dist/shard-cloud-proxy.d.ts +4 -0
  25. package/dist/shard-cloud-proxy.js +34 -0
  26. package/dist/shard-contracts.d.ts +574 -23
  27. package/dist/shard-contracts.js +346 -0
  28. package/dist/shard-coordinator.d.ts +5 -3
  29. package/dist/shard-coordinator.js +85 -2
  30. package/dist/shard-router.d.ts +10 -0
  31. package/dist/shard-router.js +58 -0
  32. package/dist/shard-supervisor.d.ts +2 -1
  33. package/dist/shard-supervisor.js +187 -5
  34. package/dist/shard-worker.d.ts +9 -3
  35. package/dist/shard-worker.js +97 -16
  36. package/dist/support-module.d.ts +9 -0
  37. package/dist/support-module.js +172 -0
  38. package/package.json +9 -9
@@ -43,6 +43,36 @@ const configurationValue = z.record(z.string().min(1).max(100), z.union([
43
43
  z.number().finite(),
44
44
  z.array(z.string().max(4_000)).max(100),
45
45
  ]));
46
+ const resourceType = z.string().regex(/^[a-z][a-z0-9_-]*$/u);
47
+ const resourceId = z.string().trim().min(1).max(100);
48
+ const resourceCursor = z.string().min(1).max(2_048);
49
+ const managedResourceValue = z.custom((value) => isBoundedManagedResourceValue(value), "Managed resource value exceeds the transport limits");
50
+ const managedResourceObject = z
51
+ .record(z.string().min(1).max(100), managedResourceValue)
52
+ .refine(hasBoundedManagedResourceBytes);
53
+ const managedResourceFilters = z
54
+ .record(z.string().regex(/^[a-z][a-zA-Z0-9_]*$/u), z.string().trim().min(1).max(100))
55
+ .refine((value) => Object.keys(value).length <= 5);
56
+ const scheduledTaskPayload = z
57
+ .record(z.string().regex(/^[a-z][A-Za-z0-9]{0,62}Id$/u), z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u))
58
+ .refine((value) => Object.keys(value).length <= 20)
59
+ .refine((value) => Buffer.byteLength(JSON.stringify(value), "utf8") <= 2_048);
60
+ const scheduledTaskResult = z.discriminatedUnion("outcome", [
61
+ z.object({ outcome: z.literal("completed") }).strict(),
62
+ z
63
+ .object({
64
+ outcome: z.enum(["deferred", "retry"]),
65
+ delayMs: z.number().int().min(100).max(86_400_000),
66
+ safeCode: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/u),
67
+ })
68
+ .strict(),
69
+ z
70
+ .object({
71
+ outcome: z.literal("requires_review"),
72
+ safeCode: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,99}$/u),
73
+ })
74
+ .strict(),
75
+ ]);
46
76
  export const shardIdentitySchema = z
47
77
  .object({
48
78
  version: z.literal(SHARD_CONTRACT_VERSION),
@@ -108,6 +138,27 @@ const sessionStartBudgetSchema = z
108
138
  observedAt: dateTime,
109
139
  })
110
140
  .strict();
141
+ const schedulerWorkHealthSchema = z
142
+ .object({
143
+ sampleLimit: z.number().int().min(1).max(5_000),
144
+ sampled: z.number().int().min(0),
145
+ ready: z.number().int().min(0),
146
+ expiredLeases: z.number().int().min(0),
147
+ retried: z.number().int().min(0),
148
+ requiresReview: z.number().int().min(0),
149
+ oldestReadyAt: dateTime.nullable(),
150
+ })
151
+ .strict();
152
+ export const schedulerOperationalHealthSchema = z.discriminatedUnion("status", [
153
+ z
154
+ .object({
155
+ status: z.literal("available"),
156
+ oneShot: schedulerWorkHealthSchema,
157
+ coalesced: schedulerWorkHealthSchema,
158
+ })
159
+ .strict(),
160
+ z.object({ status: z.literal("unavailable") }).strict(),
161
+ ]);
111
162
  export const shardHealthSnapshotSchema = z
112
163
  .object({
113
164
  version: z.literal(SHARD_CONTRACT_VERSION),
@@ -125,6 +176,7 @@ export const shardHealthSnapshotSchema = z
125
176
  database: z.enum(["healthy", "degraded", "unavailable"]),
126
177
  })
127
178
  .strict(),
179
+ scheduler: schedulerOperationalHealthSchema.optional(),
128
180
  sessionStarts: sessionStartBudgetSchema.optional(),
129
181
  shards: z.array(shardProcessHealthSchema).max(4_096),
130
182
  })
@@ -193,6 +245,118 @@ export const shardControlOperationSchema = z.discriminatedUnion("kind", [
193
245
  ...mutationContext.shape,
194
246
  })
195
247
  .strict(),
248
+ z
249
+ .object({
250
+ kind: z.literal("guild.module.resource.list"),
251
+ guildId: snowflake,
252
+ moduleId,
253
+ resourceType,
254
+ cursor: resourceCursor.optional(),
255
+ limit: z.number().int().min(1).max(50),
256
+ search: z.string().trim().min(1).max(200).optional(),
257
+ filters: managedResourceFilters.optional(),
258
+ ...mutationContext.shape,
259
+ })
260
+ .strict(),
261
+ z
262
+ .object({
263
+ kind: z.literal("guild.module.resource.read"),
264
+ guildId: snowflake,
265
+ moduleId,
266
+ resourceType,
267
+ resourceId,
268
+ ...mutationContext.shape,
269
+ })
270
+ .strict(),
271
+ z
272
+ .object({
273
+ kind: z.literal("guild.module.resource.relation.list"),
274
+ guildId: snowflake,
275
+ moduleId,
276
+ resourceType,
277
+ resourceId,
278
+ relationId: resourceType,
279
+ cursor: resourceCursor.optional(),
280
+ limit: z.number().int().min(1).max(50),
281
+ ...mutationContext.shape,
282
+ })
283
+ .strict(),
284
+ z
285
+ .object({
286
+ kind: z.literal("guild.module.resource.preview"),
287
+ guildId: snowflake,
288
+ moduleId,
289
+ resourceType,
290
+ value: managedResourceObject,
291
+ ...mutationContext.shape,
292
+ })
293
+ .strict(),
294
+ z
295
+ .object({
296
+ kind: z.literal("guild.module.resource.create"),
297
+ guildId: snowflake,
298
+ moduleId,
299
+ resourceType,
300
+ intent: z.enum(["draft", "publish"]),
301
+ value: managedResourceObject,
302
+ ...mutationContext.shape,
303
+ })
304
+ .strict(),
305
+ z
306
+ .object({
307
+ kind: z.literal("guild.module.resource.update"),
308
+ guildId: snowflake,
309
+ moduleId,
310
+ resourceType,
311
+ resourceId,
312
+ expectedRevision: z.number().int().min(0),
313
+ value: managedResourceObject,
314
+ ...mutationContext.shape,
315
+ })
316
+ .strict(),
317
+ z
318
+ .object({
319
+ kind: z.literal("guild.module.resource.delete"),
320
+ guildId: snowflake,
321
+ moduleId,
322
+ resourceType,
323
+ resourceId,
324
+ expectedRevision: z.number().int().min(0),
325
+ ...mutationContext.shape,
326
+ })
327
+ .strict(),
328
+ z
329
+ .object({
330
+ kind: z.literal("guild.module.resource.action.execute"),
331
+ guildId: snowflake,
332
+ moduleId,
333
+ resourceType,
334
+ resourceId,
335
+ actionId: resourceType,
336
+ expectedRevision: z.number().int().min(0),
337
+ value: managedResourceObject,
338
+ ...mutationContext.shape,
339
+ })
340
+ .strict(),
341
+ z
342
+ .object({
343
+ kind: z.literal("support.notification.send"),
344
+ guildId: snowflake,
345
+ notificationId: z.uuid(),
346
+ reference: z.string().trim().min(1).max(40),
347
+ caseType: z.enum(["bug", "general_support", "security"]),
348
+ categoryOrModule: z.string().trim().min(1).max(100),
349
+ reportedSeverity: z
350
+ .enum(["low", "medium", "high", "critical"])
351
+ .nullable(),
352
+ source: z.enum([
353
+ "dashboard_bug",
354
+ "dashboard_support",
355
+ "dashboard_security",
356
+ "discord_command",
357
+ ]),
358
+ })
359
+ .strict(),
196
360
  z.object({ kind: z.literal("privacy.providers.list") }).strict(),
197
361
  z
198
362
  .object({
@@ -269,6 +433,19 @@ const shardCloudOperationSchema = z.discriminatedUnion("kind", [
269
433
  .strict(),
270
434
  z.object({ kind: z.literal("cloud.disconnect") }).strict(),
271
435
  z.object({ kind: z.literal("cloud.rotate") }).strict(),
436
+ z
437
+ .object({
438
+ kind: z.literal("support.bug.create"),
439
+ reporterDiscordUserId: snowflake,
440
+ guildId: snowflake,
441
+ moduleOrArea: z.string().trim().min(1).max(100),
442
+ summary: z.string().trim().min(10).max(200),
443
+ whatHappened: z.string().trim().min(10).max(1_000),
444
+ extraDetail: z.string().trim().min(1).max(1_000).optional(),
445
+ interactionId: snowflake,
446
+ correlationId: z.uuid(),
447
+ })
448
+ .strict(),
272
449
  ]);
273
450
  export const shardCloudRequestSchema = z
274
451
  .object({
@@ -315,10 +492,53 @@ export const shardOperationsHealthResponseSchema = z
315
492
  health: shardHealthSnapshotSchema,
316
493
  })
317
494
  .strict();
495
+ export const shardScheduledTaskRequestSchema = z
496
+ .object({
497
+ version: z.literal(SHARD_CONTRACT_VERSION),
498
+ type: z.literal("helyx.scheduled.request"),
499
+ requestId: z.uuid(),
500
+ deadlineAt: dateTime,
501
+ identity: shardIdentitySchema,
502
+ task: z.discriminatedUnion("delivery", [
503
+ z
504
+ .object({
505
+ delivery: z.literal("one-shot"),
506
+ taskId: z.uuid(),
507
+ moduleId,
508
+ taskKind: z.string().regex(/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u),
509
+ taskVersion: z.number().int().positive(),
510
+ guildId: snowflake,
511
+ payload: scheduledTaskPayload,
512
+ })
513
+ .strict(),
514
+ z
515
+ .object({
516
+ delivery: z.literal("coalesced"),
517
+ taskId: z.uuid(),
518
+ moduleId,
519
+ taskKind: z.string().regex(/^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/u),
520
+ taskVersion: z.number().int().positive(),
521
+ guildId: snowflake,
522
+ payload: scheduledTaskPayload,
523
+ claimedGeneration: z.number().int().positive(),
524
+ })
525
+ .strict(),
526
+ ]),
527
+ })
528
+ .strict();
529
+ export const shardScheduledTaskResponseSchema = z
530
+ .object({
531
+ version: z.literal(SHARD_CONTRACT_VERSION),
532
+ type: z.literal("helyx.scheduled.response"),
533
+ requestId: z.uuid(),
534
+ result: scheduledTaskResult,
535
+ })
536
+ .strict();
318
537
  export const supervisorMessageSchema = z.discriminatedUnion("type", [
319
538
  shardControlRequestSchema,
320
539
  shardCloudResponseSchema,
321
540
  shardOperationsHealthResponseSchema,
541
+ shardScheduledTaskRequestSchema,
322
542
  z
323
543
  .object({
324
544
  version: z.literal(SHARD_CONTRACT_VERSION),
@@ -344,6 +564,7 @@ export const workerMessageSchema = z.discriminatedUnion("type", [
344
564
  shardControlResponseSchema,
345
565
  shardCloudRequestSchema,
346
566
  shardOperationsHealthRequestSchema,
567
+ shardScheduledTaskResponseSchema,
347
568
  z
348
569
  .object({
349
570
  version: z.literal(SHARD_CONTRACT_VERSION),
@@ -418,6 +639,103 @@ export async function executeShardControlOperation(handler, operation) {
418
639
  correlationId: operation.correlationId,
419
640
  });
420
641
  }
642
+ case "guild.module.resource.list":
643
+ return handler.listManagedResources({
644
+ guildId: operation.guildId,
645
+ moduleId: operation.moduleId,
646
+ resourceType: operation.resourceType,
647
+ ...(operation.cursor === undefined ? {} : { cursor: operation.cursor }),
648
+ ...(operation.search === undefined ? {} : { search: operation.search }),
649
+ ...(operation.filters === undefined
650
+ ? {}
651
+ : { filters: operation.filters }),
652
+ limit: operation.limit,
653
+ actorUserId: operation.actorUserId,
654
+ correlationId: operation.correlationId,
655
+ });
656
+ case "guild.module.resource.read":
657
+ return handler.readManagedResource({
658
+ guildId: operation.guildId,
659
+ moduleId: operation.moduleId,
660
+ resourceType: operation.resourceType,
661
+ resourceId: operation.resourceId,
662
+ actorUserId: operation.actorUserId,
663
+ correlationId: operation.correlationId,
664
+ });
665
+ case "guild.module.resource.relation.list":
666
+ return handler.listManagedResourceRelation({
667
+ guildId: operation.guildId,
668
+ moduleId: operation.moduleId,
669
+ resourceType: operation.resourceType,
670
+ resourceId: operation.resourceId,
671
+ relationId: operation.relationId,
672
+ ...(operation.cursor === undefined ? {} : { cursor: operation.cursor }),
673
+ limit: operation.limit,
674
+ actorUserId: operation.actorUserId,
675
+ correlationId: operation.correlationId,
676
+ });
677
+ case "guild.module.resource.preview":
678
+ return handler.previewManagedResource({
679
+ guildId: operation.guildId,
680
+ moduleId: operation.moduleId,
681
+ resourceType: operation.resourceType,
682
+ value: operation.value,
683
+ actorUserId: operation.actorUserId,
684
+ correlationId: operation.correlationId,
685
+ });
686
+ case "guild.module.resource.create":
687
+ return handler.createManagedResource({
688
+ guildId: operation.guildId,
689
+ moduleId: operation.moduleId,
690
+ resourceType: operation.resourceType,
691
+ intent: operation.intent,
692
+ value: operation.value,
693
+ actorUserId: operation.actorUserId,
694
+ correlationId: operation.correlationId,
695
+ });
696
+ case "guild.module.resource.update":
697
+ return handler.updateManagedResource({
698
+ guildId: operation.guildId,
699
+ moduleId: operation.moduleId,
700
+ resourceType: operation.resourceType,
701
+ resourceId: operation.resourceId,
702
+ expectedRevision: operation.expectedRevision,
703
+ value: operation.value,
704
+ actorUserId: operation.actorUserId,
705
+ correlationId: operation.correlationId,
706
+ });
707
+ case "guild.module.resource.delete":
708
+ return handler.deleteManagedResource({
709
+ guildId: operation.guildId,
710
+ moduleId: operation.moduleId,
711
+ resourceType: operation.resourceType,
712
+ resourceId: operation.resourceId,
713
+ expectedRevision: operation.expectedRevision,
714
+ actorUserId: operation.actorUserId,
715
+ correlationId: operation.correlationId,
716
+ });
717
+ case "guild.module.resource.action.execute":
718
+ return handler.executeManagedResourceAction({
719
+ guildId: operation.guildId,
720
+ moduleId: operation.moduleId,
721
+ resourceType: operation.resourceType,
722
+ resourceId: operation.resourceId,
723
+ actionId: operation.actionId,
724
+ expectedRevision: operation.expectedRevision,
725
+ value: operation.value,
726
+ actorUserId: operation.actorUserId,
727
+ correlationId: operation.correlationId,
728
+ });
729
+ case "support.notification.send": {
730
+ return handler.sendSupportNotification({
731
+ notificationId: operation.notificationId,
732
+ reference: operation.reference,
733
+ caseType: operation.caseType,
734
+ categoryOrModule: operation.categoryOrModule,
735
+ reportedSeverity: operation.reportedSeverity,
736
+ source: operation.source,
737
+ });
738
+ }
421
739
  case "privacy.providers.list":
422
740
  return handler.listPrivacyProviders();
423
741
  case "privacy.capture": {
@@ -442,4 +760,32 @@ export async function executeShardControlOperation(handler, operation) {
442
760
  }
443
761
  }
444
762
  }
763
+ function isBoundedManagedResourceValue(value, depth = 0, budget = { keys: 0 }) {
764
+ if (depth > 8)
765
+ return false;
766
+ if (value === null ||
767
+ typeof value === "boolean" ||
768
+ (typeof value === "number" && Number.isFinite(value)))
769
+ return true;
770
+ if (typeof value === "string")
771
+ return value.length <= 4_000;
772
+ if (Array.isArray(value))
773
+ return (value.length <= 100 &&
774
+ value.every((item) => isBoundedManagedResourceValue(item, depth + 1, budget)));
775
+ if (typeof value !== "object")
776
+ return false;
777
+ const prototype = Object.getPrototypeOf(value);
778
+ if (prototype !== Object.prototype && prototype !== null)
779
+ return false;
780
+ const entries = Object.entries(value);
781
+ budget.keys += entries.length;
782
+ return (entries.length <= 100 &&
783
+ budget.keys <= 500 &&
784
+ entries.every(([key, item]) => key.length >= 1 &&
785
+ key.length <= 100 &&
786
+ isBoundedManagedResourceValue(item, depth + 1, budget)));
787
+ }
788
+ function hasBoundedManagedResourceBytes(value) {
789
+ return (new TextEncoder().encode(JSON.stringify(value)).byteLength <= 48 * 1_024);
790
+ }
445
791
  //# sourceMappingURL=shard-contracts.js.map
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
- import { type ShardControlRequest, type ShardControlResponse, type ShardCloudRequest, type ShardCloudResponse, type ShardIdentity, type ShardProcessHealth } from "./shard-contracts.js";
3
+ import { type ShardControlRequest, type ShardControlResponse, type ShardCloudRequest, type ShardCloudResponse, type ShardIdentity, type ShardProcessHealth, type ShardScheduledTaskRequest, type ShardScheduledTaskResponse } from "./shard-contracts.js";
4
4
  declare const generationDescriptorSchema: z.ZodObject<{
5
5
  version: z.ZodLiteral<1>;
6
6
  applicationId: z.ZodString;
@@ -30,7 +30,7 @@ declare const leaderLeaseSchema: z.ZodObject<{
30
30
  export type ShardLeaderLease = z.infer<typeof leaderLeaseSchema>;
31
31
  export interface ShardCoordinator {
32
32
  readonly supervisorId: ReturnType<typeof randomUUID>;
33
- start(handler: (targetShardId: number, request: ShardControlRequest) => Promise<ShardControlResponse>, leaderHandler?: (request: ShardControlRequest) => Promise<ShardControlResponse>, cloudHandler?: (request: ShardCloudRequest) => Promise<ShardCloudResponse>): Promise<void>;
33
+ start(handler: (targetShardId: number, request: ShardControlRequest) => Promise<ShardControlResponse>, leaderHandler?: (request: ShardControlRequest) => Promise<ShardControlResponse>, cloudHandler?: (request: ShardCloudRequest) => Promise<ShardCloudResponse>, scheduledHandler?: (targetShardId: number, request: ShardScheduledTaskRequest) => Promise<ShardScheduledTaskResponse>): Promise<void>;
34
34
  checkReady(): Promise<void>;
35
35
  assertGeneration(descriptor: ShardGenerationDescriptor, proposedDeploymentOrder: number): Promise<number>;
36
36
  refreshGeneration(descriptor: ShardGenerationDescriptor, deploymentOrder: number): Promise<boolean>;
@@ -44,6 +44,7 @@ export interface ShardCoordinator {
44
44
  releaseLeader(lease: ShardLeaderLease): Promise<boolean>;
45
45
  getLeaderLease(): Promise<ShardLeaderLease | undefined>;
46
46
  routeRemote(lease: ShardLease, request: ShardControlRequest): Promise<ShardControlResponse>;
47
+ routeRemoteScheduled(lease: ShardLease, request: ShardScheduledTaskRequest): Promise<ShardScheduledTaskResponse>;
47
48
  routeLeader(lease: ShardLeaderLease, request: ShardControlRequest): Promise<ShardControlResponse>;
48
49
  routeLeaderCloud(lease: ShardLeaderLease, request: ShardCloudRequest): Promise<ShardCloudResponse>;
49
50
  reserveSessionStarts(count: number, available: number, resetAfterMs: number): Promise<boolean>;
@@ -61,7 +62,7 @@ export declare class RedisShardCoordinator implements ShardCoordinator {
61
62
  constructor(redisUrl: string, namespace: string, options?: {
62
63
  onError?: (error: Error) => void;
63
64
  });
64
- start(handler: (targetShardId: number, request: ShardControlRequest) => Promise<ShardControlResponse>, leaderHandler?: (request: ShardControlRequest) => Promise<ShardControlResponse>, cloudHandler?: (request: ShardCloudRequest) => Promise<ShardCloudResponse>): Promise<void>;
65
+ start(handler: (targetShardId: number, request: ShardControlRequest) => Promise<ShardControlResponse>, leaderHandler?: (request: ShardControlRequest) => Promise<ShardControlResponse>, cloudHandler?: (request: ShardCloudRequest) => Promise<ShardCloudResponse>, scheduledHandler?: (targetShardId: number, request: ShardScheduledTaskRequest) => Promise<ShardScheduledTaskResponse>): Promise<void>;
65
66
  checkReady(): Promise<void>;
66
67
  assertGeneration(descriptor: ShardGenerationDescriptor, proposedDeploymentOrder: number): Promise<number>;
67
68
  refreshGeneration(descriptor: ShardGenerationDescriptor, deploymentOrder: number): Promise<boolean>;
@@ -75,6 +76,7 @@ export declare class RedisShardCoordinator implements ShardCoordinator {
75
76
  releaseLeader(lease: ShardLeaderLease): Promise<boolean>;
76
77
  getLeaderLease(): Promise<ShardLeaderLease | undefined>;
77
78
  routeRemote(lease: ShardLease, request: ShardControlRequest): Promise<ShardControlResponse>;
79
+ routeRemoteScheduled(lease: ShardLease, request: ShardScheduledTaskRequest): Promise<ShardScheduledTaskResponse>;
78
80
  routeLeader(lease: ShardLeaderLease, request: ShardControlRequest): Promise<ShardControlResponse>;
79
81
  routeLeaderCloud(lease: ShardLeaderLease, request: ShardCloudRequest): Promise<ShardCloudResponse>;
80
82
  reserveSessionStarts(count: number, available: number, resetAfterMs: number): Promise<boolean>;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createClient } from "redis";
3
3
  import { z } from "zod";
4
- import { SHARD_CONTROL_MAX_BYTES, assertShardMessageSize, shardCloudRequestSchema, shardCloudResponseSchema, shardControlRequestSchema, shardControlResponseSchema, shardIdentitySchema, shardProcessHealthSchema, } from "./shard-contracts.js";
4
+ import { SHARD_CONTROL_MAX_BYTES, assertShardMessageSize, shardCloudRequestSchema, shardCloudResponseSchema, shardControlRequestSchema, shardControlResponseSchema, shardIdentitySchema, shardProcessHealthSchema, shardScheduledTaskRequestSchema, shardScheduledTaskResponseSchema, } from "./shard-contracts.js";
5
5
  const LEASE_TTL_MS = 30_000;
6
6
  const HEALTH_TTL_MS = 30_000;
7
7
  const GENERATION_TTL_MS = 30_000;
@@ -52,6 +52,15 @@ const remoteRequestSchema = z.discriminatedUnion("target", [
52
52
  request: shardControlRequestSchema,
53
53
  })
54
54
  .strict(),
55
+ z
56
+ .object({
57
+ version: z.literal(1),
58
+ type: z.literal("helyx.remote.request"),
59
+ target: z.literal("scheduled"),
60
+ targetShardId: z.number().int().min(0).max(4_095),
61
+ request: shardScheduledTaskRequestSchema,
62
+ })
63
+ .strict(),
55
64
  z
56
65
  .object({
57
66
  version: z.literal(1),
@@ -86,6 +95,7 @@ export class RedisShardCoordinator {
86
95
  #handler;
87
96
  #leaderHandler;
88
97
  #cloudHandler;
98
+ #scheduledHandler;
89
99
  constructor(redisUrl, namespace, options = {}) {
90
100
  this.#prefix = `helyx:shard:v1:${namespace}`;
91
101
  this.#command = createClient({ url: redisUrl });
@@ -94,10 +104,11 @@ export class RedisShardCoordinator {
94
104
  this.#command.on("error", (error) => this.#report(error));
95
105
  this.#subscriber.on("error", (error) => this.#report(error));
96
106
  }
97
- async start(handler, leaderHandler, cloudHandler) {
107
+ async start(handler, leaderHandler, cloudHandler, scheduledHandler) {
98
108
  this.#handler = handler;
99
109
  this.#leaderHandler = leaderHandler;
100
110
  this.#cloudHandler = cloudHandler;
111
+ this.#scheduledHandler = scheduledHandler;
101
112
  await Promise.all([this.#command.connect(), this.#subscriber.connect()]);
102
113
  await this.#subscriber.subscribe(this.#requestChannel(), (raw) => {
103
114
  void this.#handleRemoteRequest(raw).catch((error) => this.#report(error));
@@ -305,6 +316,26 @@ export class RedisShardCoordinator {
305
316
  }
306
317
  throw new Error("Shard control request timed out");
307
318
  }
319
+ async routeRemoteScheduled(lease, request) {
320
+ const acceptedLease = leaseSchema.parse(lease);
321
+ const acceptedRequest = shardScheduledTaskRequestSchema.parse(request);
322
+ if (acceptedRequest.identity.generation !== acceptedLease.generation ||
323
+ acceptedRequest.identity.fencingToken !== acceptedLease.fencingToken ||
324
+ acceptedRequest.identity.shardId !== acceptedLease.shardId)
325
+ throw new Error("Scheduled shard route ownership changed");
326
+ const envelope = remoteRequestSchema.parse({
327
+ version: 1,
328
+ type: "helyx.remote.request",
329
+ target: "scheduled",
330
+ targetShardId: acceptedLease.shardId,
331
+ request: acceptedRequest,
332
+ });
333
+ assertShardMessageSize(envelope);
334
+ const listeners = await this.#command.publish(this.#requestChannel(acceptedLease.supervisorId), JSON.stringify(envelope));
335
+ if (listeners < 1)
336
+ throw new Error("Owning shard supervisor is unavailable");
337
+ return this.#waitForScheduledResponse(acceptedRequest);
338
+ }
308
339
  async routeLeader(lease, request) {
309
340
  const acceptedLease = leaderLeaseSchema.parse(lease);
310
341
  const acceptedRequest = shardControlRequestSchema.parse(request);
@@ -482,6 +513,38 @@ export class RedisShardCoordinator {
482
513
  await this.#writeResponse(response);
483
514
  return;
484
515
  }
516
+ if (envelope.data.target === "scheduled") {
517
+ const { targetShardId, request } = envelope.data;
518
+ const lease = await this.getShardLease(targetShardId);
519
+ if (!lease ||
520
+ lease.supervisorId !== this.supervisorId ||
521
+ lease.generation !== request.identity.generation ||
522
+ lease.fencingToken !== request.identity.fencingToken ||
523
+ lease.shardId !== request.identity.shardId ||
524
+ !this.#scheduledHandler)
525
+ return this.#writeScheduledResponse({
526
+ version: 1,
527
+ type: "helyx.scheduled.response",
528
+ requestId: request.requestId,
529
+ result: {
530
+ outcome: "deferred",
531
+ delayMs: 1_000,
532
+ safeCode: "shard_ownership_changed",
533
+ },
534
+ });
535
+ const response = await this.#scheduledHandler(targetShardId, request).catch(() => ({
536
+ version: 1,
537
+ type: "helyx.scheduled.response",
538
+ requestId: request.requestId,
539
+ result: {
540
+ outcome: "deferred",
541
+ delayMs: 1_000,
542
+ safeCode: "shard_unavailable",
543
+ },
544
+ }));
545
+ await this.#writeScheduledResponse(response);
546
+ return;
547
+ }
485
548
  const { targetShardId, request } = envelope.data;
486
549
  const lease = await this.getShardLease(targetShardId);
487
550
  if (!lease ||
@@ -516,6 +579,11 @@ export class RedisShardCoordinator {
516
579
  assertShardMessageSize(accepted);
517
580
  await this.#command.set(this.#responseKey(accepted.requestId), JSON.stringify(accepted), { EX: RESPONSE_TTL_SECONDS });
518
581
  }
582
+ async #writeScheduledResponse(response) {
583
+ const accepted = shardScheduledTaskResponseSchema.parse(response);
584
+ assertShardMessageSize(accepted);
585
+ await this.#command.set(this.#responseKey(accepted.requestId), JSON.stringify(accepted), { EX: RESPONSE_TTL_SECONDS });
586
+ }
519
587
  async #waitForResponse(request) {
520
588
  const deadline = new Date(request.deadlineAt).getTime();
521
589
  const responseKey = this.#responseKey(request.requestId);
@@ -531,6 +599,21 @@ export class RedisShardCoordinator {
531
599
  }
532
600
  throw new Error("Shard control request timed out");
533
601
  }
602
+ async #waitForScheduledResponse(request) {
603
+ const deadline = new Date(request.deadlineAt).getTime();
604
+ const responseKey = this.#responseKey(request.requestId);
605
+ while (Date.now() < deadline) {
606
+ const raw = await this.#command.get(responseKey);
607
+ if (raw) {
608
+ await this.#command.del(responseKey);
609
+ const parsed = shardScheduledTaskResponseSchema.parse(parseJson(raw));
610
+ assertShardMessageSize(parsed);
611
+ return parsed;
612
+ }
613
+ await wait(ROUTE_POLL_MS);
614
+ }
615
+ throw new Error("Scheduled shard request timed out");
616
+ }
534
617
  #generationKey() {
535
618
  return `${this.#prefix}:generation`;
536
619
  }
@@ -13,6 +13,7 @@ export declare class ShardControlRouter implements BotControlHandler {
13
13
  privacy?: Pick<BotControlHandler, "listPrivacyProviders" | "capturePrivacy" | "erasePrivacy">;
14
14
  localLeaderLease?: ShardLeaderLease;
15
15
  coordinator?: ShardCoordinator;
16
+ notificationGuildId?: string;
16
17
  });
17
18
  getGuildState(guildId: string): ReturnType<BotControlHandler["getGuildState"]>;
18
19
  setModuleStatus(input: Parameters<BotControlHandler["setModuleStatus"]>[0]): ReturnType<BotControlHandler["setModuleStatus"]>;
@@ -20,6 +21,15 @@ export declare class ShardControlRouter implements BotControlHandler {
20
21
  updatePermissions(input: Parameters<BotControlHandler["updatePermissions"]>[0]): ReturnType<BotControlHandler["updatePermissions"]>;
21
22
  updateLoggingPolicy(input: Parameters<BotControlHandler["updateLoggingPolicy"]>[0]): ReturnType<BotControlHandler["updateLoggingPolicy"]>;
22
23
  executeDashboardAction(input: Parameters<BotControlHandler["executeDashboardAction"]>[0]): ReturnType<BotControlHandler["executeDashboardAction"]>;
24
+ listManagedResources(input: Parameters<BotControlHandler["listManagedResources"]>[0]): ReturnType<BotControlHandler["listManagedResources"]>;
25
+ readManagedResource(input: Parameters<BotControlHandler["readManagedResource"]>[0]): ReturnType<BotControlHandler["readManagedResource"]>;
26
+ listManagedResourceRelation(input: Parameters<BotControlHandler["listManagedResourceRelation"]>[0]): ReturnType<BotControlHandler["listManagedResourceRelation"]>;
27
+ previewManagedResource(input: Parameters<BotControlHandler["previewManagedResource"]>[0]): ReturnType<BotControlHandler["previewManagedResource"]>;
28
+ createManagedResource(input: Parameters<BotControlHandler["createManagedResource"]>[0]): ReturnType<BotControlHandler["createManagedResource"]>;
29
+ updateManagedResource(input: Parameters<BotControlHandler["updateManagedResource"]>[0]): ReturnType<BotControlHandler["updateManagedResource"]>;
30
+ deleteManagedResource(input: Parameters<BotControlHandler["deleteManagedResource"]>[0]): ReturnType<BotControlHandler["deleteManagedResource"]>;
31
+ executeManagedResourceAction(input: Parameters<BotControlHandler["executeManagedResourceAction"]>[0]): ReturnType<BotControlHandler["executeManagedResourceAction"]>;
32
+ sendSupportNotification(input: Parameters<BotControlHandler["sendSupportNotification"]>[0]): ReturnType<BotControlHandler["sendSupportNotification"]>;
23
33
  listPrivacyProviders(): ReturnType<BotControlHandler["listPrivacyProviders"]>;
24
34
  capturePrivacy(input: Parameters<BotControlHandler["capturePrivacy"]>[0]): ReturnType<BotControlHandler["capturePrivacy"]>;
25
35
  erasePrivacy(input: Parameters<BotControlHandler["erasePrivacy"]>[0]): ReturnType<BotControlHandler["erasePrivacy"]>;