@helyx/bot 0.4.1 → 0.4.4
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.
- package/CHANGELOG.md +35 -0
- package/README.md +13 -0
- package/dist/cloud-connector.d.ts +7 -0
- package/dist/cloud-connector.js +118 -1
- package/dist/cloud-control-dispatcher.js +84 -0
- package/dist/control-server.d.ts +187 -5
- package/dist/control-server.js +673 -6
- package/dist/developer-operations-module.js +8 -0
- package/dist/discord-owned-message-service.d.ts +39 -0
- package/dist/discord-owned-message-service.js +138 -0
- package/dist/events.d.ts +70 -1
- package/dist/events.js +557 -5
- package/dist/index.js +61 -13
- package/dist/interactions.d.ts +3 -2
- package/dist/interactions.js +66 -31
- package/dist/module-logging.d.ts +1 -0
- package/dist/module-logging.js +19 -1
- package/dist/modules.d.ts +9 -0
- package/dist/modules.js +45 -0
- package/dist/resource-authorization.d.ts +7 -0
- package/dist/resource-authorization.js +12 -0
- package/dist/scheduled-task-runner.d.ts +130 -0
- package/dist/scheduled-task-runner.js +417 -0
- package/dist/shard-cloud-proxy.d.ts +4 -0
- package/dist/shard-cloud-proxy.js +34 -0
- package/dist/shard-contracts.d.ts +574 -23
- package/dist/shard-contracts.js +346 -0
- package/dist/shard-coordinator.d.ts +5 -3
- package/dist/shard-coordinator.js +85 -2
- package/dist/shard-router.d.ts +10 -0
- package/dist/shard-router.js +58 -0
- package/dist/shard-supervisor.d.ts +2 -1
- package/dist/shard-supervisor.js +187 -5
- package/dist/shard-worker.d.ts +8 -2
- package/dist/shard-worker.js +97 -16
- package/dist/support-module.d.ts +9 -0
- package/dist/support-module.js +172 -0
- package/package.json +9 -9
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { type CoalescedTaskRecord, type ScheduledTaskRecord, type ScheduledTaskDefinition, type SchedulerHealth } from "@helyx/database";
|
|
2
|
+
import { type Logger, type ModuleDefinition, type ModuleScheduledTaskExecution, type ScheduledTaskExecutionResult, type ServiceAccess } from "@helyx/sdk";
|
|
3
|
+
import type { Pool } from "pg";
|
|
4
|
+
export interface ScheduledTaskRoute {
|
|
5
|
+
(task: ModuleScheduledTaskExecution, signal: AbortSignal): Promise<ScheduledTaskExecutionResult>;
|
|
6
|
+
}
|
|
7
|
+
interface ScheduledTaskRepositoryContract {
|
|
8
|
+
claimDue(input: {
|
|
9
|
+
workerId: string;
|
|
10
|
+
leaseMs: number;
|
|
11
|
+
limit?: number;
|
|
12
|
+
eligible?: readonly ScheduledTaskDefinition[];
|
|
13
|
+
}): Promise<ScheduledTaskRecord[]>;
|
|
14
|
+
claimCoalesced(input: {
|
|
15
|
+
workerId: string;
|
|
16
|
+
leaseMs: number;
|
|
17
|
+
limit?: number;
|
|
18
|
+
eligible?: readonly ScheduledTaskDefinition[];
|
|
19
|
+
}): Promise<CoalescedTaskRecord[]>;
|
|
20
|
+
heartbeat(input: {
|
|
21
|
+
taskId: string;
|
|
22
|
+
workerId: string;
|
|
23
|
+
leaseGeneration: number;
|
|
24
|
+
leaseMs: number;
|
|
25
|
+
}): Promise<boolean>;
|
|
26
|
+
heartbeatCoalesced(input: {
|
|
27
|
+
laneId: string;
|
|
28
|
+
workerId: string;
|
|
29
|
+
leaseGeneration: number;
|
|
30
|
+
claimedGeneration: number;
|
|
31
|
+
leaseMs: number;
|
|
32
|
+
}): Promise<boolean>;
|
|
33
|
+
complete(input: {
|
|
34
|
+
taskId: string;
|
|
35
|
+
workerId: string;
|
|
36
|
+
leaseGeneration: number;
|
|
37
|
+
}): Promise<boolean>;
|
|
38
|
+
completeCoalesced(input: {
|
|
39
|
+
laneId: string;
|
|
40
|
+
workerId: string;
|
|
41
|
+
leaseGeneration: number;
|
|
42
|
+
claimedGeneration: number;
|
|
43
|
+
}): Promise<boolean>;
|
|
44
|
+
defer(input: {
|
|
45
|
+
taskId: string;
|
|
46
|
+
workerId: string;
|
|
47
|
+
leaseGeneration: number;
|
|
48
|
+
delayMs: number;
|
|
49
|
+
safeCode: string;
|
|
50
|
+
}): Promise<boolean>;
|
|
51
|
+
deferCoalesced(input: {
|
|
52
|
+
laneId: string;
|
|
53
|
+
workerId: string;
|
|
54
|
+
leaseGeneration: number;
|
|
55
|
+
claimedGeneration: number;
|
|
56
|
+
delayMs: number;
|
|
57
|
+
safeCode: string;
|
|
58
|
+
}): Promise<boolean>;
|
|
59
|
+
retry(input: {
|
|
60
|
+
taskId: string;
|
|
61
|
+
workerId: string;
|
|
62
|
+
leaseGeneration: number;
|
|
63
|
+
delayMs: number;
|
|
64
|
+
safeCode: string;
|
|
65
|
+
}): Promise<boolean>;
|
|
66
|
+
retryCoalesced(input: {
|
|
67
|
+
laneId: string;
|
|
68
|
+
workerId: string;
|
|
69
|
+
leaseGeneration: number;
|
|
70
|
+
claimedGeneration: number;
|
|
71
|
+
delayMs: number;
|
|
72
|
+
safeCode: string;
|
|
73
|
+
}): Promise<boolean>;
|
|
74
|
+
markRequiresReview(input: {
|
|
75
|
+
taskId: string;
|
|
76
|
+
workerId: string;
|
|
77
|
+
leaseGeneration: number;
|
|
78
|
+
safeCode: string;
|
|
79
|
+
}): Promise<boolean>;
|
|
80
|
+
markCoalescedRequiresReview(input: {
|
|
81
|
+
laneId: string;
|
|
82
|
+
workerId: string;
|
|
83
|
+
leaseGeneration: number;
|
|
84
|
+
claimedGeneration: number;
|
|
85
|
+
safeCode: string;
|
|
86
|
+
}): Promise<boolean>;
|
|
87
|
+
getHealth(sampleLimit?: number): Promise<SchedulerHealth>;
|
|
88
|
+
}
|
|
89
|
+
export declare class ScheduledTaskRunner {
|
|
90
|
+
#private;
|
|
91
|
+
constructor(input: {
|
|
92
|
+
repository: ScheduledTaskRepositoryContract;
|
|
93
|
+
route: ScheduledTaskRoute;
|
|
94
|
+
logger: Logger;
|
|
95
|
+
workerId?: string;
|
|
96
|
+
pollIntervalMs?: number;
|
|
97
|
+
leaseMs?: number;
|
|
98
|
+
claimLimit?: number;
|
|
99
|
+
stopTimeoutMs?: number;
|
|
100
|
+
eligibleTasks?: readonly (ScheduledTaskDefinition & {
|
|
101
|
+
delivery: "one-shot" | "coalesced";
|
|
102
|
+
})[];
|
|
103
|
+
});
|
|
104
|
+
start(): void;
|
|
105
|
+
stop(): Promise<void>;
|
|
106
|
+
runOnce(): Promise<void>;
|
|
107
|
+
getHealth(): Promise<SchedulerHealth>;
|
|
108
|
+
}
|
|
109
|
+
export declare function createScheduledTaskRunner(input: {
|
|
110
|
+
database: Pool;
|
|
111
|
+
modules: readonly ModuleDefinition[];
|
|
112
|
+
route: ScheduledTaskRoute;
|
|
113
|
+
logger: Logger;
|
|
114
|
+
workerId?: string;
|
|
115
|
+
}): ScheduledTaskRunner;
|
|
116
|
+
export declare function schedulerOperationalHealth(health: SchedulerHealth): {
|
|
117
|
+
status: "available";
|
|
118
|
+
oneShot: ReturnType<typeof mapSchedulerWorkHealth>;
|
|
119
|
+
coalesced: ReturnType<typeof mapSchedulerWorkHealth>;
|
|
120
|
+
};
|
|
121
|
+
export declare function createLocalScheduledTaskRoute(input: {
|
|
122
|
+
modules: readonly ModuleDefinition[];
|
|
123
|
+
services: ServiceAccess;
|
|
124
|
+
logger: Logger;
|
|
125
|
+
}): ScheduledTaskRoute;
|
|
126
|
+
declare function mapSchedulerWorkHealth(health: SchedulerHealth["oneShot"]): Omit<SchedulerHealth["oneShot"], "oldestReadyAt"> & {
|
|
127
|
+
oldestReadyAt: string | null;
|
|
128
|
+
};
|
|
129
|
+
export {};
|
|
130
|
+
//# sourceMappingURL=scheduled-task-runner.d.ts.map
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { CORE_RECORD_RETENTION_TASK, ScheduledTaskRepository, } from "@helyx/database";
|
|
2
|
+
import { getModuleScheduledTaskContribution, HELYX_SERVICE_NAMES, } from "@helyx/sdk";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
const DEFAULT_POLL_INTERVAL_MS = 1_000;
|
|
5
|
+
const DEFAULT_LEASE_MS = 45_000;
|
|
6
|
+
const DEFAULT_CLAIM_LIMIT = 8;
|
|
7
|
+
const DEFAULT_STOP_TIMEOUT_MS = 5_000;
|
|
8
|
+
const MAX_RESULT_DELAY_MS = 24 * 60 * 60 * 1_000;
|
|
9
|
+
const SAFE_CODE = /^[a-z0-9][a-z0-9._-]{0,99}$/u;
|
|
10
|
+
export class ScheduledTaskRunner {
|
|
11
|
+
#repository;
|
|
12
|
+
#route;
|
|
13
|
+
#logger;
|
|
14
|
+
#workerId;
|
|
15
|
+
#pollIntervalMs;
|
|
16
|
+
#leaseMs;
|
|
17
|
+
#claimLimit;
|
|
18
|
+
#stopTimeoutMs;
|
|
19
|
+
#oneShotEligible;
|
|
20
|
+
#coalescedEligible;
|
|
21
|
+
#catalogueFiltered;
|
|
22
|
+
#timer;
|
|
23
|
+
#active;
|
|
24
|
+
#controller = new AbortController();
|
|
25
|
+
#stopping = false;
|
|
26
|
+
constructor(input) {
|
|
27
|
+
this.#repository = input.repository;
|
|
28
|
+
this.#route = input.route;
|
|
29
|
+
this.#logger = input.logger;
|
|
30
|
+
this.#workerId = input.workerId ?? `scheduled-runner:${randomUUID()}`;
|
|
31
|
+
this.#pollIntervalMs = boundedInteger(input.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS, 100, 60_000, "scheduler poll interval");
|
|
32
|
+
this.#leaseMs = boundedInteger(input.leaseMs ?? DEFAULT_LEASE_MS, 5_000, 5 * 60_000, "scheduler lease");
|
|
33
|
+
this.#claimLimit = boundedInteger(input.claimLimit ?? DEFAULT_CLAIM_LIMIT, 1, 25, "scheduler claim limit");
|
|
34
|
+
this.#stopTimeoutMs = boundedInteger(input.stopTimeoutMs ?? DEFAULT_STOP_TIMEOUT_MS, 100, 30_000, "scheduler stop timeout");
|
|
35
|
+
this.#catalogueFiltered = input.eligibleTasks !== undefined;
|
|
36
|
+
this.#oneShotEligible = input.eligibleTasks
|
|
37
|
+
?.filter(({ delivery }) => delivery === "one-shot")
|
|
38
|
+
.map(withoutDelivery);
|
|
39
|
+
this.#coalescedEligible = input.eligibleTasks
|
|
40
|
+
?.filter(({ delivery }) => delivery === "coalesced")
|
|
41
|
+
.map(withoutDelivery);
|
|
42
|
+
}
|
|
43
|
+
start() {
|
|
44
|
+
if (this.#timer || this.#stopping)
|
|
45
|
+
throw new Error("Scheduled task runner is already started or stopped");
|
|
46
|
+
this.#runSoon();
|
|
47
|
+
this.#timer = setInterval(() => this.#runSoon(), this.#pollIntervalMs);
|
|
48
|
+
this.#timer.unref();
|
|
49
|
+
}
|
|
50
|
+
async stop() {
|
|
51
|
+
if (!this.#stopping) {
|
|
52
|
+
this.#stopping = true;
|
|
53
|
+
if (this.#timer)
|
|
54
|
+
clearInterval(this.#timer);
|
|
55
|
+
this.#timer = undefined;
|
|
56
|
+
this.#controller.abort();
|
|
57
|
+
}
|
|
58
|
+
const active = this.#active;
|
|
59
|
+
if (!active)
|
|
60
|
+
return;
|
|
61
|
+
await Promise.race([active, wait(this.#stopTimeoutMs)]);
|
|
62
|
+
}
|
|
63
|
+
async runOnce() {
|
|
64
|
+
if (this.#stopping)
|
|
65
|
+
return;
|
|
66
|
+
if (this.#catalogueFiltered &&
|
|
67
|
+
this.#oneShotEligible?.length === 0 &&
|
|
68
|
+
this.#coalescedEligible?.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
const [oneShot, coalesced] = await Promise.all([
|
|
71
|
+
this.#catalogueFiltered && this.#oneShotEligible?.length === 0
|
|
72
|
+
? Promise.resolve([])
|
|
73
|
+
: this.#repository.claimDue({
|
|
74
|
+
workerId: this.#workerId,
|
|
75
|
+
leaseMs: this.#leaseMs,
|
|
76
|
+
limit: this.#claimLimit,
|
|
77
|
+
...(this.#oneShotEligible === undefined
|
|
78
|
+
? {}
|
|
79
|
+
: { eligible: this.#oneShotEligible }),
|
|
80
|
+
}),
|
|
81
|
+
this.#catalogueFiltered && this.#coalescedEligible?.length === 0
|
|
82
|
+
? Promise.resolve([])
|
|
83
|
+
: this.#repository.claimCoalesced({
|
|
84
|
+
workerId: this.#workerId,
|
|
85
|
+
leaseMs: this.#leaseMs,
|
|
86
|
+
limit: this.#claimLimit,
|
|
87
|
+
...(this.#coalescedEligible === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { eligible: this.#coalescedEligible }),
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
const tasks = [
|
|
93
|
+
...oneShot.map((record) => ({
|
|
94
|
+
delivery: "one-shot",
|
|
95
|
+
record,
|
|
96
|
+
})),
|
|
97
|
+
...coalesced.map((record) => ({
|
|
98
|
+
delivery: "coalesced",
|
|
99
|
+
record,
|
|
100
|
+
})),
|
|
101
|
+
];
|
|
102
|
+
await Promise.all(tasks.map((task) => this.#execute(task).catch((error) => {
|
|
103
|
+
this.#logger.error({
|
|
104
|
+
event: "scheduler.settlement_failed",
|
|
105
|
+
delivery: task.delivery,
|
|
106
|
+
taskId: task.record.id,
|
|
107
|
+
moduleId: task.record.moduleId,
|
|
108
|
+
errorName: error instanceof Error ? error.name : "Error",
|
|
109
|
+
}, "Scheduled task could not be settled");
|
|
110
|
+
})));
|
|
111
|
+
}
|
|
112
|
+
getHealth() {
|
|
113
|
+
return this.#repository.getHealth();
|
|
114
|
+
}
|
|
115
|
+
#runSoon() {
|
|
116
|
+
if (this.#active || this.#stopping)
|
|
117
|
+
return;
|
|
118
|
+
this.#active = this.runOnce()
|
|
119
|
+
.catch((error) => {
|
|
120
|
+
this.#logger.error({
|
|
121
|
+
event: "scheduler.claim_failed",
|
|
122
|
+
errorName: error instanceof Error ? error.name : "Error",
|
|
123
|
+
}, "Scheduled task claim failed");
|
|
124
|
+
})
|
|
125
|
+
.finally(() => {
|
|
126
|
+
this.#active = undefined;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
async #execute(task) {
|
|
130
|
+
const runnerSignal = this.#controller.signal;
|
|
131
|
+
if (runnerSignal.aborted)
|
|
132
|
+
return;
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
let heartbeat;
|
|
135
|
+
let heartbeatRunning = false;
|
|
136
|
+
const abort = () => {
|
|
137
|
+
if (heartbeat)
|
|
138
|
+
clearInterval(heartbeat);
|
|
139
|
+
heartbeat = undefined;
|
|
140
|
+
controller.abort();
|
|
141
|
+
};
|
|
142
|
+
runnerSignal.addEventListener("abort", abort, { once: true });
|
|
143
|
+
const renewLease = () => {
|
|
144
|
+
if (heartbeatRunning || controller.signal.aborted)
|
|
145
|
+
return;
|
|
146
|
+
heartbeatRunning = true;
|
|
147
|
+
void this.#heartbeat(task)
|
|
148
|
+
.then((alive) => {
|
|
149
|
+
if (!alive)
|
|
150
|
+
abort();
|
|
151
|
+
})
|
|
152
|
+
.catch((error) => {
|
|
153
|
+
this.#logger.error({
|
|
154
|
+
event: "scheduler.heartbeat_failed",
|
|
155
|
+
delivery: task.delivery,
|
|
156
|
+
taskId: task.record.id,
|
|
157
|
+
moduleId: task.record.moduleId,
|
|
158
|
+
errorName: error instanceof Error ? error.name : "Error",
|
|
159
|
+
}, "Scheduled task lease heartbeat failed");
|
|
160
|
+
abort();
|
|
161
|
+
})
|
|
162
|
+
.finally(() => {
|
|
163
|
+
heartbeatRunning = false;
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
heartbeat = setInterval(renewLease, Math.max(1_000, Math.floor(this.#leaseMs / 3)));
|
|
167
|
+
heartbeat.unref();
|
|
168
|
+
let result;
|
|
169
|
+
try {
|
|
170
|
+
result = validateExecutionResult(await this.#route(toExecution(task), controller.signal));
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
result = controller.signal.aborted
|
|
174
|
+
? deferred("runner_stopped", 1_000)
|
|
175
|
+
: {
|
|
176
|
+
outcome: "retry",
|
|
177
|
+
delayMs: retryDelay(task.record.attemptCount),
|
|
178
|
+
safeCode: "handler_failed",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
finally {
|
|
182
|
+
if (heartbeat)
|
|
183
|
+
clearInterval(heartbeat);
|
|
184
|
+
runnerSignal.removeEventListener("abort", abort);
|
|
185
|
+
}
|
|
186
|
+
if (runnerSignal.aborted || controller.signal.aborted)
|
|
187
|
+
return;
|
|
188
|
+
await this.#settle(task, result);
|
|
189
|
+
}
|
|
190
|
+
#heartbeat(task) {
|
|
191
|
+
if (task.delivery === "one-shot") {
|
|
192
|
+
return this.#repository.heartbeat({
|
|
193
|
+
taskId: task.record.id,
|
|
194
|
+
workerId: this.#workerId,
|
|
195
|
+
leaseGeneration: task.record.leaseGeneration,
|
|
196
|
+
leaseMs: this.#leaseMs,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
return this.#repository.heartbeatCoalesced({
|
|
200
|
+
laneId: task.record.id,
|
|
201
|
+
workerId: this.#workerId,
|
|
202
|
+
leaseGeneration: task.record.leaseGeneration,
|
|
203
|
+
claimedGeneration: task.record.claimedGeneration,
|
|
204
|
+
leaseMs: this.#leaseMs,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
#settle(task, result) {
|
|
208
|
+
const common = {
|
|
209
|
+
workerId: this.#workerId,
|
|
210
|
+
leaseGeneration: task.record.leaseGeneration,
|
|
211
|
+
};
|
|
212
|
+
if (task.delivery === "one-shot") {
|
|
213
|
+
const target = { ...common, taskId: task.record.id };
|
|
214
|
+
switch (result.outcome) {
|
|
215
|
+
case "completed":
|
|
216
|
+
return this.#repository.complete(target);
|
|
217
|
+
case "deferred":
|
|
218
|
+
return this.#repository.defer({
|
|
219
|
+
...target,
|
|
220
|
+
delayMs: result.delayMs,
|
|
221
|
+
safeCode: result.safeCode,
|
|
222
|
+
});
|
|
223
|
+
case "retry":
|
|
224
|
+
return this.#repository.retry({
|
|
225
|
+
...target,
|
|
226
|
+
delayMs: result.delayMs,
|
|
227
|
+
safeCode: result.safeCode,
|
|
228
|
+
});
|
|
229
|
+
case "requires_review":
|
|
230
|
+
return this.#repository.markRequiresReview({
|
|
231
|
+
...target,
|
|
232
|
+
safeCode: result.safeCode,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const target = {
|
|
237
|
+
...common,
|
|
238
|
+
laneId: task.record.id,
|
|
239
|
+
claimedGeneration: task.record.claimedGeneration,
|
|
240
|
+
};
|
|
241
|
+
switch (result.outcome) {
|
|
242
|
+
case "completed":
|
|
243
|
+
return this.#repository.completeCoalesced(target);
|
|
244
|
+
case "deferred":
|
|
245
|
+
return this.#repository.deferCoalesced({
|
|
246
|
+
...target,
|
|
247
|
+
delayMs: result.delayMs,
|
|
248
|
+
safeCode: result.safeCode,
|
|
249
|
+
});
|
|
250
|
+
case "retry":
|
|
251
|
+
return this.#repository.retryCoalesced({
|
|
252
|
+
...target,
|
|
253
|
+
delayMs: result.delayMs,
|
|
254
|
+
safeCode: result.safeCode,
|
|
255
|
+
});
|
|
256
|
+
case "requires_review":
|
|
257
|
+
return this.#repository.markCoalescedRequiresReview({
|
|
258
|
+
...target,
|
|
259
|
+
safeCode: result.safeCode,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
export function createScheduledTaskRunner(input) {
|
|
265
|
+
return new ScheduledTaskRunner({
|
|
266
|
+
repository: new ScheduledTaskRepository(input.database),
|
|
267
|
+
route: input.route,
|
|
268
|
+
logger: input.logger,
|
|
269
|
+
eligibleTasks: [
|
|
270
|
+
{
|
|
271
|
+
...CORE_RECORD_RETENTION_TASK,
|
|
272
|
+
delivery: "one-shot",
|
|
273
|
+
runWhenDisabled: true,
|
|
274
|
+
},
|
|
275
|
+
...input.modules.flatMap((module) => (module.scheduledTasks ?? []).map((contribution) => ({
|
|
276
|
+
moduleId: module.manifest.id,
|
|
277
|
+
taskKind: contribution.kind,
|
|
278
|
+
taskVersion: contribution.version,
|
|
279
|
+
delivery: contribution.delivery,
|
|
280
|
+
}))),
|
|
281
|
+
],
|
|
282
|
+
...(input.workerId === undefined ? {} : { workerId: input.workerId }),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
export function schedulerOperationalHealth(health) {
|
|
286
|
+
return {
|
|
287
|
+
status: "available",
|
|
288
|
+
oneShot: mapSchedulerWorkHealth(health.oneShot),
|
|
289
|
+
coalesced: mapSchedulerWorkHealth(health.coalesced),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
export function createLocalScheduledTaskRoute(input) {
|
|
293
|
+
const modules = new Map(input.modules.map((module) => [module.manifest.id, module]));
|
|
294
|
+
return async (task, signal) => {
|
|
295
|
+
if (task.moduleId === CORE_RECORD_RETENTION_TASK.moduleId &&
|
|
296
|
+
task.taskKind === CORE_RECORD_RETENTION_TASK.taskKind &&
|
|
297
|
+
task.taskVersion === CORE_RECORD_RETENTION_TASK.taskVersion &&
|
|
298
|
+
task.delivery === "one-shot") {
|
|
299
|
+
const retentionTaskId = task.payload.retentionTaskId;
|
|
300
|
+
if (Object.keys(task.payload).length !== 1 ||
|
|
301
|
+
typeof retentionTaskId !== "string" ||
|
|
302
|
+
!/^[0-9a-f]{8}-[0-9a-f-]{27}$/iu.test(retentionTaskId))
|
|
303
|
+
return { outcome: "requires_review", safeCode: "payload_invalid" };
|
|
304
|
+
const result = await input.services
|
|
305
|
+
.get(HELYX_SERVICE_NAMES.recordRetention)
|
|
306
|
+
.execute(retentionTaskId);
|
|
307
|
+
return result.completed
|
|
308
|
+
? { outcome: "completed" }
|
|
309
|
+
: {
|
|
310
|
+
outcome: "deferred",
|
|
311
|
+
delayMs: 100,
|
|
312
|
+
safeCode: "retention_batch_remaining",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const module = modules.get(task.moduleId);
|
|
316
|
+
if (!module)
|
|
317
|
+
return deferred("module_unavailable", DEFAULT_POLL_INTERVAL_MS * 30);
|
|
318
|
+
if (task.guildId) {
|
|
319
|
+
const enabled = await input.services
|
|
320
|
+
.get(HELYX_SERVICE_NAMES.installations)
|
|
321
|
+
.isModuleEnabled(task.guildId, task.moduleId);
|
|
322
|
+
if (!enabled)
|
|
323
|
+
return deferred("module_disabled", DEFAULT_POLL_INTERVAL_MS * 30);
|
|
324
|
+
}
|
|
325
|
+
const contribution = getModuleScheduledTaskContribution(module, task.taskKind);
|
|
326
|
+
if (!contribution ||
|
|
327
|
+
contribution.delivery !== task.delivery ||
|
|
328
|
+
contribution.version !== task.taskVersion) {
|
|
329
|
+
return { outcome: "requires_review", safeCode: "handler_mismatch" };
|
|
330
|
+
}
|
|
331
|
+
let payload;
|
|
332
|
+
try {
|
|
333
|
+
payload = contribution.validatePayload(task.payload);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return { outcome: "requires_review", safeCode: "payload_invalid" };
|
|
337
|
+
}
|
|
338
|
+
if (!samePayload(payload, task.payload))
|
|
339
|
+
return { outcome: "requires_review", safeCode: "payload_mismatch" };
|
|
340
|
+
return contribution.execute({
|
|
341
|
+
logger: input.logger.child({
|
|
342
|
+
moduleId: task.moduleId,
|
|
343
|
+
taskKind: task.taskKind,
|
|
344
|
+
taskId: task.taskId,
|
|
345
|
+
}),
|
|
346
|
+
services: input.services,
|
|
347
|
+
signal,
|
|
348
|
+
}, task);
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
function toExecution(task) {
|
|
352
|
+
const common = {
|
|
353
|
+
taskId: task.record.id,
|
|
354
|
+
moduleId: task.record.moduleId,
|
|
355
|
+
taskKind: task.record.taskKind,
|
|
356
|
+
taskVersion: task.record.taskVersion,
|
|
357
|
+
guildId: task.record.guildId,
|
|
358
|
+
payload: task.record.payload,
|
|
359
|
+
};
|
|
360
|
+
return task.delivery === "one-shot"
|
|
361
|
+
? { delivery: "one-shot", ...common }
|
|
362
|
+
: {
|
|
363
|
+
delivery: "coalesced",
|
|
364
|
+
...common,
|
|
365
|
+
claimedGeneration: task.record.claimedGeneration,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
function validateExecutionResult(result) {
|
|
369
|
+
if (result.outcome === "completed")
|
|
370
|
+
return result;
|
|
371
|
+
if (!SAFE_CODE.test(result.safeCode))
|
|
372
|
+
throw new Error("Scheduled task returned an invalid safe code");
|
|
373
|
+
if (result.outcome === "requires_review")
|
|
374
|
+
return result;
|
|
375
|
+
boundedInteger(result.delayMs, 100, MAX_RESULT_DELAY_MS, "task delay");
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
function deferred(safeCode, delayMs) {
|
|
379
|
+
return { outcome: "deferred", delayMs, safeCode };
|
|
380
|
+
}
|
|
381
|
+
function retryDelay(attemptCount) {
|
|
382
|
+
return Math.min(5 * 60_000, 1_000 * 2 ** Math.min(attemptCount, 8));
|
|
383
|
+
}
|
|
384
|
+
function withoutDelivery(input) {
|
|
385
|
+
return {
|
|
386
|
+
moduleId: input.moduleId,
|
|
387
|
+
taskKind: input.taskKind,
|
|
388
|
+
taskVersion: input.taskVersion,
|
|
389
|
+
...(input.runWhenDisabled === undefined
|
|
390
|
+
? {}
|
|
391
|
+
: { runWhenDisabled: input.runWhenDisabled }),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function boundedInteger(value, minimum, maximum, label) {
|
|
395
|
+
if (!Number.isSafeInteger(value) || value < minimum || value > maximum)
|
|
396
|
+
throw new Error(`${label} must be between ${minimum} and ${maximum}`);
|
|
397
|
+
return value;
|
|
398
|
+
}
|
|
399
|
+
function samePayload(left, right) {
|
|
400
|
+
const leftEntries = Object.entries(left);
|
|
401
|
+
const rightKeys = Object.keys(right);
|
|
402
|
+
return (leftEntries.length === rightKeys.length &&
|
|
403
|
+
leftEntries.every(([key, value]) => right[key] === value));
|
|
404
|
+
}
|
|
405
|
+
function wait(milliseconds) {
|
|
406
|
+
return new Promise((resolve) => {
|
|
407
|
+
const timer = setTimeout(resolve, milliseconds);
|
|
408
|
+
timer.unref();
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
function mapSchedulerWorkHealth(health) {
|
|
412
|
+
return {
|
|
413
|
+
...health,
|
|
414
|
+
oldestReadyAt: health.oldestReadyAt?.toISOString() ?? null,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
//# sourceMappingURL=scheduled-task-runner.js.map
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CloudConnectorConsentRecord } from "@helyx/database";
|
|
2
|
+
import type { SupportBugCreatePayload, SupportBugCreateResponsePayload } from "@helyx/control-protocol";
|
|
2
3
|
import type { CloudConnector, CloudConnectorStatus } from "./cloud-connector.js";
|
|
3
4
|
import { type ShardCloudResponse } from "./shard-contracts.js";
|
|
4
5
|
export declare class WorkerCloudConnectorProxy implements CloudConnector {
|
|
@@ -15,6 +16,9 @@ export declare class WorkerCloudConnectorProxy implements CloudConnector {
|
|
|
15
16
|
enrol(token: string, source: "startup" | "discord" | "file"): Promise<CloudConnectorStatus>;
|
|
16
17
|
disconnect(): Promise<void>;
|
|
17
18
|
rotate(): Promise<CloudConnectorStatus>;
|
|
19
|
+
submitBug(input: SupportBugCreatePayload): Promise<Extract<SupportBugCreateResponsePayload, {
|
|
20
|
+
ok: true;
|
|
21
|
+
}>["result"]>;
|
|
18
22
|
stop(): Promise<void>;
|
|
19
23
|
accept(response: ShardCloudResponse): void;
|
|
20
24
|
}
|
|
@@ -28,6 +28,19 @@ export class WorkerCloudConnectorProxy {
|
|
|
28
28
|
this.#status = parseStatus(await this.#request({ kind: "cloud.rotate" }));
|
|
29
29
|
return this.#status;
|
|
30
30
|
}
|
|
31
|
+
async submitBug(input) {
|
|
32
|
+
return parseSupportResult(await this.#request({
|
|
33
|
+
kind: "support.bug.create",
|
|
34
|
+
reporterDiscordUserId: input.reporterDiscordUserId,
|
|
35
|
+
guildId: input.guildId,
|
|
36
|
+
moduleOrArea: input.moduleOrArea,
|
|
37
|
+
summary: input.summary,
|
|
38
|
+
whatHappened: input.whatHappened,
|
|
39
|
+
...(input.extraDetail ? { extraDetail: input.extraDetail } : {}),
|
|
40
|
+
interactionId: input.interactionId,
|
|
41
|
+
correlationId: input.correlationId,
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
31
44
|
stop() {
|
|
32
45
|
for (const pending of this.#pending.values()) {
|
|
33
46
|
clearTimeout(pending.timer);
|
|
@@ -91,4 +104,25 @@ function parseConsent(value) {
|
|
|
91
104
|
const consent = value;
|
|
92
105
|
return { ...consent, updatedAt: new Date(consent.updatedAt) };
|
|
93
106
|
}
|
|
107
|
+
function parseSupportResult(value) {
|
|
108
|
+
const claimUrl = typeof value === "object" && value !== null && "claimUrl" in value
|
|
109
|
+
? value.claimUrl
|
|
110
|
+
: undefined;
|
|
111
|
+
if (typeof value !== "object" ||
|
|
112
|
+
value === null ||
|
|
113
|
+
!("reference" in value) ||
|
|
114
|
+
typeof value.reference !== "string" ||
|
|
115
|
+
!("created" in value) ||
|
|
116
|
+
typeof value.created !== "boolean" ||
|
|
117
|
+
(claimUrl !== undefined &&
|
|
118
|
+
(typeof claimUrl !== "string" ||
|
|
119
|
+
claimUrl.length > 2_048 ||
|
|
120
|
+
!claimUrl.startsWith("https://"))))
|
|
121
|
+
throw new Error("Cloud supervisor returned an invalid support result");
|
|
122
|
+
return {
|
|
123
|
+
reference: value.reference,
|
|
124
|
+
created: value.created,
|
|
125
|
+
...(typeof claimUrl === "string" ? { claimUrl } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
94
128
|
//# sourceMappingURL=shard-cloud-proxy.js.map
|