@h-ai/scheduler 0.1.0-alpha.67 → 0.1.0-alpha.69

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/dist/index.js CHANGED
@@ -1,21 +1,20 @@
1
1
  import { z } from 'zod';
2
- import { cache } from '@h-ai/cache';
3
2
  import { core, ok, err } from '@h-ai/core';
4
3
  import { reldb, BaseReldbCrudRepository } from '@h-ai/reldb';
5
4
  import { randomUUID, createHash } from 'crypto';
5
+ import { isDeepStrictEqual } from 'util';
6
+ import { Cron } from 'croner';
6
7
  import { Script } from 'vm';
7
8
  import { Worker } from 'worker_threads';
8
- import { Cron } from 'croner';
9
- import { isDeepStrictEqual } from 'util';
10
9
 
11
10
  // src/scheduler-config.ts
12
11
  var SchedulerConfigSchema = z.object({
13
- /** 是否启用数据库记录(默认 true,需要 @h-ai/reldb 已初始化) */
14
- enableDb: z.boolean().default(true),
12
+ /** 必须使用共享数据库保存执行状态;false 不再允许,需要先初始化 @h-ai/reldb。 */
13
+ enableDb: z.literal(true).default(true),
15
14
  /** 调度检查间隔,单位毫秒(默认 1000,即每秒检查一次) */
16
15
  tickInterval: z.number().int().min(100).default(1e3),
17
- /** 分布式锁过期时间,单位毫秒(默认 300000,即 5 分钟) */
18
- lockExpireMs: z.number().int().min(1e4).default(3e5),
16
+ /** 执行租约毫秒数(默认 300000,范围 1000–86400000),在途每三分之一周期续期。 */
17
+ leaseMs: z.number().int().min(1e3).max(864e5).default(3e5),
19
18
  /** 节点标识(默认自动生成 UUID,多节点部署时建议设置固定标识以便审计) */
20
19
  nodeId: z.string().min(1).optional(),
21
20
  /** 执行日志最大保留条数(启用 DB 时生效) */
@@ -23,6 +22,14 @@ var SchedulerConfigSchema = z.object({
23
22
  /** 执行日志保留天数(启用 DB 时生效) */
24
23
  retentionDays: z.number().int().min(1).optional()
25
24
  });
25
+ var TriggerTaskSchema = z.object({
26
+ source: z.string().max(255).optional(),
27
+ idempotencyKey: z.string().min(1).max(128).optional()
28
+ });
29
+ var ExecutionResolutionSchema = z.object({
30
+ status: z.enum(["success", "discarded"]),
31
+ expectedUpdatedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER - 1)
32
+ });
26
33
 
27
34
  // messages/en-US.json
28
35
  var en_US_default = {
@@ -49,13 +56,55 @@ var en_US_default = {
49
56
  scheduler_invalidPersistedTask: "Invalid persisted task {taskId}: {error}",
50
57
  scheduler_taskRunning: "Task is currently running: {taskId}",
51
58
  scheduler_invalidApiConfig: "Invalid api_config for task: {taskId}",
52
- scheduler_lockAcquireFailed: "Failed to acquire distributed lock, another node already holds it: {taskId}",
53
59
  scheduler_jsCompileFailed: "Failed to compile JS task: {error}",
54
60
  scheduler_hookExecutionFailed: "Task lifecycle hook execution failed: {error}",
55
61
  scheduler_taskHandlerMissing: "Task has no executable handler and no global execute hook is configured: {taskId}",
56
62
  scheduler_invalidHandlerConfig: "Invalid task handler config: {taskId}",
57
63
  scheduler_invalidRetryConfig: "Invalid retry policy config for task: {taskId}",
58
- scheduler_reldbNotInitialized: "enableDb is true but @h-ai/reldb is not initialized, please call reldb.init() first"
64
+ scheduler_reldbNotInitialized: "enableDb is true but @h-ai/reldb is not initialized, please call reldb.init() first",
65
+ scheduler_executionUnknown: "Execution outcome is unknown; stop the old runner and verify external effects before resolving",
66
+ scheduler_executionStateFailed: "Shared execution state unavailable or owner lease lost",
67
+ scheduler_executionInputInvalid: "Invalid execution identifier or administration input",
68
+ scheduler_occurrenceHandled: "Occurrence already handled for task: {taskId}",
69
+ scheduler_logPersistFailed: "Execution log persistence is not confirmed. Repair it by receipt without rerunning the business task"
70
+ };
71
+
72
+ // messages/ja-JP.json
73
+ var ja_JP_default = {
74
+ $schema: "https://inlang.com/schema/inlang-message-format",
75
+ scheduler_notInitialized: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u5148\u306Bscheduler.init()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
76
+ scheduler_initFailed: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u306E\u521D\u671F\u5316\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
77
+ scheduler_alreadyRunning: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u306F\u65E2\u306B\u5B9F\u884C\u4E2D\u3067\u3059",
78
+ scheduler_notRunning: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u304C\u5B9F\u884C\u3055\u308C\u3066\u3044\u307E\u305B\u3093",
79
+ scheduler_closing: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u3092\u7D42\u4E86\u3057\u3066\u3044\u307E\u3059\u3002\u30BF\u30B9\u30AF\u306E\u5B9F\u884C\u3092\u30AD\u30E3\u30F3\u30BB\u30EB\u3057\u307E\u3057\u305F",
80
+ scheduler_definitionsChanged: "\u66F4\u65B0\u4E2D\u306B\u30BF\u30B9\u30AF\u5B9A\u7FA9\u304C\u5909\u66F4\u3055\u308C\u307E\u3057\u305F\u3002\u64CD\u4F5C\u3092\u518D\u8A66\u884C\u3057\u3066\u304F\u3060\u3055\u3044",
81
+ scheduler_taskNoLongerDue: "\u30BF\u30B9\u30AF{taskId}\u304C\u524A\u9664\u3001\u7121\u52B9\u5316\u3001\u307E\u305F\u306F\u518D\u30B9\u30B1\u30B8\u30E5\u30FC\u30EB\u3055\u308C\u307E\u3057\u305F",
82
+ scheduler_taskNotFound: "\u30BF\u30B9\u30AF\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093: {taskId}",
83
+ scheduler_taskAlreadyExists: "\u30BF\u30B9\u30AF\u306F\u65E2\u306B\u5B58\u5728\u3057\u307E\u3059: {taskId}",
84
+ scheduler_invalidCron: "cron\u5F0F\u304C\u7121\u52B9\u3067\u3059: {expression}",
85
+ scheduler_executionFailed: "\u30BF\u30B9\u30AF\u306E\u5B9F\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
86
+ scheduler_jsExecutionFailed: "JS\u30B3\u30FC\u30C9\u306E\u5B9F\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
87
+ scheduler_jsTimedOut: "JS\u30BF\u30B9\u30AF\u304C{timeout}ms\u3067\u30BF\u30A4\u30E0\u30A2\u30A6\u30C8\u3057\u307E\u3057\u305F",
88
+ scheduler_apiExecutionFailed: "API\u547C\u3073\u51FA\u3057\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
89
+ scheduler_apiResponseTooLarge: "API\u30EC\u30B9\u30DD\u30F3\u30B9\u304C{maxResponseBytes}\u30D0\u30A4\u30C8\u306E\u5236\u9650\u3092\u8D85\u3048\u3066\u3044\u307E\u3059",
90
+ scheduler_dbSaveFailed: "\u5B9F\u884C\u30ED\u30B0\u306E\u4FDD\u5B58\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
91
+ scheduler_dbNotInitialized: "\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u306A\u3044\u305F\u3081\u3001\u5B9F\u884C\u30ED\u30B0\u3092\u4FDD\u5B58\u3067\u304D\u307E\u305B\u3093",
92
+ scheduler_configError: "\u30B9\u30B1\u30B8\u30E5\u30FC\u30E9\u30FC\u8A2D\u5B9A\u306E\u691C\u8A3C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
93
+ scheduler_taskIdEmpty: "\u30BF\u30B9\u30AFID\u3092\u7A7A\u306B\u3059\u308B\u3053\u3068\u306F\u3067\u304D\u307E\u305B\u3093",
94
+ scheduler_invalidPersistedTask: "\u4FDD\u5B58\u3055\u308C\u305F\u30BF\u30B9\u30AF{taskId}\u304C\u7121\u52B9\u3067\u3059: {error}",
95
+ scheduler_taskRunning: "\u30BF\u30B9\u30AF\u306F\u73FE\u5728\u5B9F\u884C\u4E2D\u3067\u3059: {taskId}",
96
+ scheduler_invalidApiConfig: "\u30BF\u30B9\u30AF\u306Eapi_config\u304C\u7121\u52B9\u3067\u3059: {taskId}",
97
+ scheduler_jsCompileFailed: "JS\u30BF\u30B9\u30AF\u306E\u30B3\u30F3\u30D1\u30A4\u30EB\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
98
+ scheduler_hookExecutionFailed: "\u30BF\u30B9\u30AF\u306E\u30E9\u30A4\u30D5\u30B5\u30A4\u30AF\u30EB\u30D5\u30C3\u30AF\u306E\u5B9F\u884C\u306B\u5931\u6557\u3057\u307E\u3057\u305F: {error}",
99
+ scheduler_taskHandlerMissing: "\u5B9F\u884C\u53EF\u80FD\u306A\u30CF\u30F3\u30C9\u30E9\u30FC\u3082\u30B0\u30ED\u30FC\u30D0\u30EBexecute\u30D5\u30C3\u30AF\u3082\u8A2D\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093: {taskId}",
100
+ scheduler_invalidHandlerConfig: "\u30BF\u30B9\u30AF\u30CF\u30F3\u30C9\u30E9\u30FC\u306E\u8A2D\u5B9A\u304C\u7121\u52B9\u3067\u3059: {taskId}",
101
+ scheduler_invalidRetryConfig: "\u30BF\u30B9\u30AF\u306E\u518D\u8A66\u884C\u30DD\u30EA\u30B7\u30FC\u306E\u8A2D\u5B9A\u304C\u7121\u52B9\u3067\u3059: {taskId}",
102
+ scheduler_reldbNotInitialized: "enableDb\u306Ftrue\u3067\u3059\u304C\u3001@h-ai/reldb\u304C\u521D\u671F\u5316\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u5148\u306Breldb.init()\u3092\u547C\u3073\u51FA\u3057\u3066\u304F\u3060\u3055\u3044",
103
+ scheduler_executionUnknown: "\u5B9F\u884C\u7D50\u679C\u306F\u4E0D\u660E\u3067\u3059\u3002\u624B\u52D5\u89E3\u6C7A\u3059\u308B\u524D\u306B\u53E4\u3044\u5B9F\u884C\u30D7\u30ED\u30BB\u30B9\u3092\u505C\u6B62\u3057\u3001\u5916\u90E8\u3078\u306E\u5F71\u97FF\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044",
104
+ scheduler_executionStateFailed: "\u5171\u6709\u5B9F\u884C\u72B6\u614B\u3092\u5229\u7528\u3067\u304D\u306A\u3044\u304B\u3001\u6240\u6709\u8005\u30EA\u30FC\u30B9\u304C\u5931\u308F\u308C\u307E\u3057\u305F",
105
+ scheduler_executionInputInvalid: "\u5B9F\u884C\u8B58\u5225\u5B50\u307E\u305F\u306F\u7BA1\u7406\u64CD\u4F5C\u306E\u5165\u529B\u304C\u7121\u52B9\u3067\u3059",
106
+ scheduler_occurrenceHandled: "\u30BF\u30B9\u30AF {taskId} \u306E\u4ECA\u56DE\u306E\u30B9\u30B1\u30B8\u30E5\u30FC\u30EB\u306F\u65E2\u306B\u51E6\u7406\u6E08\u307F\u3067\u3059",
107
+ scheduler_logPersistFailed: "\u5B9F\u884C\u30ED\u30B0\u306E\u6C38\u7D9A\u5316\u306F\u672A\u78BA\u8A8D\u3067\u3059\u3002\u53D7\u9818\u8A18\u9332\u306B\u57FA\u3065\u3044\u3066\u30ED\u30B0\u3092\u88DC\u5B8C\u3057\u3001\u696D\u52D9\u30BF\u30B9\u30AF\u3092\u518D\u5B9F\u884C\u3057\u306A\u3044\u3067\u304F\u3060\u3055\u3044"
59
108
  };
60
109
 
61
110
  // messages/zh-CN.json
@@ -83,19 +132,24 @@ var zh_CN_default = {
83
132
  scheduler_invalidPersistedTask: "\u6301\u4E45\u5316\u4EFB\u52A1 {taskId} \u65E0\u6548\uFF1A{error}",
84
133
  scheduler_taskRunning: "\u4EFB\u52A1\u6B63\u5728\u6267\u884C\u4E2D: {taskId}",
85
134
  scheduler_invalidApiConfig: "\u4EFB\u52A1 api_config \u683C\u5F0F\u65E0\u6548: {taskId}",
86
- scheduler_lockAcquireFailed: "\u83B7\u53D6\u5206\u5E03\u5F0F\u9501\u5931\u8D25\uFF0C\u5176\u4ED6\u8282\u70B9\u5DF2\u83B7\u9501: {taskId}",
87
135
  scheduler_jsCompileFailed: "JS \u4EFB\u52A1\u7F16\u8BD1\u5931\u8D25: {error}",
88
136
  scheduler_hookExecutionFailed: "\u4EFB\u52A1\u751F\u547D\u5468\u671F\u56DE\u8C03\u6267\u884C\u5931\u8D25: {error}",
89
137
  scheduler_taskHandlerMissing: "\u4EFB\u52A1\u7F3A\u5C11\u53EF\u6267\u884C handler\uFF0C\u4E14\u672A\u914D\u7F6E\u5168\u5C40\u6267\u884C\u56DE\u8C03: {taskId}",
90
138
  scheduler_invalidHandlerConfig: "\u4EFB\u52A1 handler \u914D\u7F6E\u65E0\u6548: {taskId}",
91
139
  scheduler_invalidRetryConfig: "\u4EFB\u52A1\u91CD\u8BD5\u7B56\u7565\u914D\u7F6E\u65E0\u6548: {taskId}",
92
- scheduler_reldbNotInitialized: "enableDb \u4E3A true \u4F46 @h-ai/reldb \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 reldb.init()"
140
+ scheduler_reldbNotInitialized: "enableDb \u4E3A true \u4F46 @h-ai/reldb \u672A\u521D\u59CB\u5316\uFF0C\u8BF7\u5148\u8C03\u7528 reldb.init()",
141
+ scheduler_executionUnknown: "\u6267\u884C\u7ED3\u679C\u672A\u77E5\uFF1B\u8BF7\u5148\u7EC8\u6B62\u65E7 runner \u5E76\u6838\u5BF9\u5916\u90E8\u526F\u4F5C\u7528\u540E\u88C1\u5B9A",
142
+ scheduler_executionStateFailed: "\u5171\u4EAB\u6267\u884C\u72B6\u6001\u4E0D\u53EF\u7528\u6216 owner \u79DF\u7EA6\u5DF2\u5931\u6548",
143
+ scheduler_executionInputInvalid: "\u6267\u884C\u6807\u8BC6\u6216\u8FD0\u7EF4\u8F93\u5165\u4E0D\u5408\u6CD5",
144
+ scheduler_occurrenceHandled: "\u672C\u6B21\u8C03\u5EA6\u5DF2\u5904\u7406: {taskId}",
145
+ scheduler_logPersistFailed: "\u6267\u884C\u65E5\u5FD7\u5C1A\u672A\u786E\u8BA4\u6301\u4E45\u5316\uFF0C\u8BF7\u6309\u6267\u884C\u56DE\u6267\u8865\u5199\uFF1B\u4E0D\u8981\u91CD\u65B0\u6267\u884C\u4E1A\u52A1"
93
146
  };
94
147
 
95
148
  // src/scheduler-i18n.ts
96
149
  var schedulerM = core.i18n.createMessageGetter({
97
150
  "zh-CN": zh_CN_default,
98
- "en-US": en_US_default
151
+ "en-US": en_US_default,
152
+ "ja-JP": ja_JP_default
99
153
  });
100
154
  var SchedulerErrorInfo = {
101
155
  NOT_INITIALIZED: "010:500",
@@ -110,13 +164,264 @@ var SchedulerErrorInfo = {
110
164
  DB_SAVE_FAILED: "026:500",
111
165
  ALREADY_RUNNING: "027:409",
112
166
  NOT_RUNNING: "028:400",
113
- LOCK_ACQUIRE_FAILED: "029:409",
114
167
  JS_COMPILE_FAILED: "030:500",
115
- HOOK_EXECUTION_FAILED: "031:500"
168
+ HOOK_EXECUTION_FAILED: "031:500",
169
+ LOG_PERSIST_FAILED: "032:500"
116
170
  };
117
171
  var HaiSchedulerError = core.error.buildHaiErrorsDef("scheduler", SchedulerErrorInfo);
118
172
 
119
- // src/repositories/scheduler-log-repository.ts
173
+ // src/repositories/scheduler-repository-execution.ts
174
+ var TABLE = "hai_scheduler_executions";
175
+ function executionId(taskId, type, key) {
176
+ return createHash("sha256").update(JSON.stringify([taskId, type, key])).digest("hex");
177
+ }
178
+ function executionEntry(row) {
179
+ return { occurrenceId: row.occurrenceId, taskId: row.taskId, status: row.status, sequence: row.sequence, attemptCount: row.attemptCount, updatedAt: row.updatedAt, logPersistence: row.logPersistence };
180
+ }
181
+ function failed(cause) {
182
+ return err(HaiSchedulerError.DB_SAVE_FAILED, schedulerM("scheduler_executionStateFailed"), cause);
183
+ }
184
+ var SchedulerExecutionRepository = class extends BaseReldbCrudRepository {
185
+ constructor(db) {
186
+ super(db, {
187
+ table: TABLE,
188
+ idColumn: "sequence",
189
+ idField: "sequence",
190
+ fields: [
191
+ { fieldName: "sequence", columnName: "sequence", def: { type: "INTEGER", primaryKey: true, autoIncrement: true }, select: true, create: false, update: false },
192
+ ...[
193
+ ["occurrenceId", "occurrence_id", true, true],
194
+ ["taskId", "task_id", true, false],
195
+ ["activeTask", "active_task", false, true],
196
+ ["status", "status", true, false],
197
+ ["owner", "owner", false, false],
198
+ ["snapshot", "snapshot", true, false],
199
+ ["triggerJson", "trigger_json", true, false],
200
+ ["logJson", "log_json", false, false],
201
+ ["logsJson", "logs_json", false, false],
202
+ ["logPersistence", "log_persistence", true, false]
203
+ ].map(([fieldName, columnName, notNull, unique]) => ({
204
+ fieldName: String(fieldName),
205
+ columnName: String(columnName),
206
+ def: { type: ["snapshot", "triggerJson", "logJson", "logsJson"].includes(String(fieldName)) ? "JSON" : "TEXT", notNull: Boolean(notNull), unique: Boolean(unique) },
207
+ select: true,
208
+ create: true,
209
+ update: true
210
+ })),
211
+ ...[
212
+ ["leasedUntil", "leased_until"],
213
+ ["attemptCount", "attempt_count"],
214
+ ["maxAttempts", "max_attempts"],
215
+ ["updatedAt", "updated_at"],
216
+ ["createdAt", "created_at"]
217
+ ].map(([fieldName, columnName]) => ({
218
+ fieldName,
219
+ columnName,
220
+ def: { type: "BIGINT", notNull: true },
221
+ select: true,
222
+ create: true,
223
+ update: true
224
+ })),
225
+ { fieldName: "idempotent", columnName: "idempotent", def: { type: "BOOLEAN", notNull: true }, select: true, create: true, update: true }
226
+ ]
227
+ });
228
+ }
229
+ /**
230
+ * 只按已初始化的数据库类型选择存储端毫秒时钟。
231
+ * PostgreSQL/MySQL 使用执行时钟,不能使用固定在事务/语句开始处的时间快照。
232
+ * 期限表达式直接进入有 owner 条件的 SQL;先 SELECT 时间再写入仍有等待窗口。
233
+ *
234
+ * @example
235
+ * ```ts
236
+ * // 模块内部上下文:当前仓库实例仅生成固定白名单 SQL,不接收调用方表达式。
237
+ * const deadlinePredicate = `leased_until > ${this.databaseClock()}`
238
+ * ```
239
+ */
240
+ databaseClock() {
241
+ if (this.db.config?.type === "postgresql")
242
+ return "CAST(FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000) AS BIGINT)";
243
+ if (this.db.config?.type === "mysql")
244
+ return "CAST(UNIX_TIMESTAMP(SYSDATE(3)) * 1000 AS UNSIGNED)";
245
+ return "CAST((julianday('now') - 2440587.5) * 86400000 AS INTEGER)";
246
+ }
247
+ /** 同毫秒内的持久状态版本仍单调增加,日志确认/人工裁定不依赖节点墙钟。 */
248
+ nextUpdatedAt() {
249
+ const clock = this.databaseClock();
250
+ return `CASE WHEN updated_at >= ${clock} THEN updated_at + 1 ELSE ${clock} END`;
251
+ }
252
+ /** 存储端毫秒时钟供 cron 计算共享分钟身份;读取失败停止本轮调度,不回退节点时钟。 */
253
+ async currentTime() {
254
+ const result = await this.db.sql.get(`SELECT ${this.databaseClock()} AS now`);
255
+ if (!result.success)
256
+ return failed(result.error);
257
+ const now = Number(result.data?.now);
258
+ return Number.isSafeInteger(now) && now >= 0 ? ok(now) : failed();
259
+ }
260
+ /** 读取统一校验 BIGINT,不能把 PG 字符串时间或舍入的序号用于 owner 判断。 */
261
+ normalize(row) {
262
+ const value = { ...row };
263
+ for (const field of ["snapshot", "triggerJson", "logJson", "logsJson"]) {
264
+ if (row[field] !== void 0 && row[field] !== null && typeof row[field] !== "string")
265
+ value[field] = JSON.stringify(row[field]);
266
+ }
267
+ for (const field of ["sequence", "leasedUntil", "attemptCount", "maxAttempts", "updatedAt", "createdAt"]) {
268
+ const number = Number(row[field]);
269
+ if (!Number.isSafeInteger(number) || number < 0)
270
+ return failed();
271
+ value[field] = number;
272
+ }
273
+ return ok(value);
274
+ }
275
+ /** 按稳定回执读取。@example await repository.byOccurrence(id) */
276
+ async byOccurrence(id) {
277
+ const rows = await this.findAll({ where: "occurrence_id = ?", params: [id], limit: 1 });
278
+ if (!rows.success)
279
+ return failed(rows.error);
280
+ return rows.data[0] ? this.normalize(rows.data[0]) : ok(null);
281
+ }
282
+ /** 有界读取过期执行,跨分钟恢复原 occurrence,而不是创建新的副作用身份。@example await repository.expired() */
283
+ async expired() {
284
+ return this.rows(`status IN (?, ?) AND leased_until <= ${this.databaseClock()}`, ["claimed", "running"], 100, 0);
285
+ }
286
+ /** 任务被删除或触发元数据损坏时,过期记录隔离为 unknown,允许可信人工核对而非永久悬挂。 */
287
+ async isolateExpired(row) {
288
+ const result = await this.db.sql.execute(
289
+ `UPDATE ${TABLE} SET status = ?, owner = NULL, leased_until = 0, updated_at = ${this.nextUpdatedAt()}
290
+ WHERE occurrence_id = ? AND owner = ? AND leased_until <= ${this.databaseClock()} AND status IN (?, ?)`,
291
+ ["unknown", row.occurrenceId, row.owner, "claimed", "running"]
292
+ );
293
+ return result.success && result.data.changes === 1 ? ok(void 0) : failed();
294
+ }
295
+ /** 有界读取待人工核对记录。@example await repository.unknown(50, 0) */
296
+ async unknown(limit, offset) {
297
+ return this.rows("status = ?", ["unknown"], limit, offset);
298
+ }
299
+ /** 仅查询已发布终态且日志待补写的回执。@example await repository.pendingLogs(50, 0) */
300
+ async pendingLogs(limit, offset) {
301
+ return this.rows("log_persistence = ? AND status NOT IN (?, ?)", ["pending", "claimed", "running"], limit, offset);
302
+ }
303
+ /**
304
+ * 确认相同终态快照的日志投影;不变更业务状态,不重新领取任务。
305
+ * @example await repository.confirmLogs(row)
306
+ */
307
+ async confirmLogs(row) {
308
+ if (!row.logJson || !row.logsJson)
309
+ return failed();
310
+ const log = JSON.parse(row.logJson);
311
+ const result = await this.db.sql.execute(
312
+ `UPDATE ${TABLE} SET log_persistence = ?, log_json = ?, updated_at = ${this.nextUpdatedAt()}
313
+ WHERE occurrence_id = ? AND updated_at = ? AND log_persistence = ? AND status NOT IN (?, ?)`,
314
+ ["persisted", JSON.stringify({ ...log, persistence: { status: "persisted" } }), row.occurrenceId, row.updatedAt, "pending", "claimed", "running"]
315
+ );
316
+ if (result.success && result.data.changes === 1)
317
+ return ok(void 0);
318
+ const current = await this.byOccurrence(row.occurrenceId);
319
+ return current.success && current.data?.logPersistence === "persisted" && current.data.logsJson === row.logsJson ? ok(void 0) : failed();
320
+ }
321
+ async rows(where, params, limit, offset) {
322
+ const result = await this.findAll({ where, params, limit, offset, orderBy: "sequence ASC" });
323
+ if (!result.success)
324
+ return failed(result.error);
325
+ const rows = [];
326
+ for (const row of result.data) {
327
+ const normalized = this.normalize(row);
328
+ if (!normalized.success)
329
+ return normalized;
330
+ rows.push(normalized.data);
331
+ }
332
+ return ok(rows);
333
+ }
334
+ /**
335
+ * 唯一 active_task 防止不同 occurrence 并行;同 occurrence 只可恢复安全的过期记录。
336
+ * @example await repository.claim(task, id, trigger, nodeId, leaseMs)
337
+ */
338
+ async claim(task, id, trigger, nodeId, leaseMs) {
339
+ const ready = await this.ensureReady();
340
+ if (!ready.success)
341
+ return failed(ready.error);
342
+ const clock = this.databaseClock();
343
+ const owner = `${nodeId}:${randomUUID()}`;
344
+ let created;
345
+ try {
346
+ created = await this.db.sql.execute(
347
+ `INSERT INTO ${TABLE} (occurrence_id, task_id, active_task, status, owner, leased_until, attempt_count, max_attempts, idempotent, snapshot, trigger_json, log_persistence, updated_at, created_at)
348
+ VALUES (?, ?, ?, ?, ?, ${clock} + ?, 0, ?, ?, ?, ?, ?, ${clock}, ${clock})`,
349
+ [id, task.id, task.id, "claimed", owner, leaseMs, task.retry?.maxAttempts ?? 1, this.db.config?.type === "postgresql" ? task.idempotent === true : Number(task.idempotent === true), JSON.stringify(task), JSON.stringify(trigger), "unconfirmed"]
350
+ );
351
+ } catch (cause) {
352
+ return failed(cause);
353
+ }
354
+ const existing = await this.byOccurrence(id);
355
+ if (!existing.success)
356
+ return existing;
357
+ if (!existing.data) {
358
+ const active = await this.findAll({ where: "active_task = ?", params: [task.id], limit: 1 });
359
+ return active.success && active.data.length > 0 ? ok(null) : failed(created.success ? void 0 : created.error);
360
+ }
361
+ const row = existing.data;
362
+ if (created.success || ["success", "failed", "interrupted", "discarded"].includes(row.status))
363
+ return ok(row);
364
+ if (row.status === "unknown")
365
+ return ok(null);
366
+ if (row.status === "running" && (!row.idempotent || row.attemptCount >= row.maxAttempts)) {
367
+ const isolated = await this.db.sql.execute(
368
+ `UPDATE ${TABLE} SET status = ?, owner = NULL, leased_until = 0, updated_at = ${this.nextUpdatedAt()}
369
+ WHERE occurrence_id = ? AND owner = ? AND leased_until <= ${clock} AND status = ?`,
370
+ ["unknown", id, row.owner, "running"]
371
+ );
372
+ return isolated.success ? ok(null) : failed(isolated.error);
373
+ }
374
+ const recovered = await this.db.sql.execute(
375
+ `UPDATE ${TABLE} SET status = ?, owner = ?, leased_until = ${clock} + ?, updated_at = ${this.nextUpdatedAt()}
376
+ WHERE occurrence_id = ? AND owner = ? AND leased_until <= ${clock} AND status IN (?, ?)`,
377
+ ["claimed", owner, leaseMs, id, row.owner, "claimed", "running"]
378
+ );
379
+ if (!recovered.success)
380
+ return failed(recovered.error);
381
+ return recovered.data.changes === 1 ? this.byOccurrence(id) : ok(null);
382
+ }
383
+ /** 外部副作用前的条件门闩,并原子递增持久尝试次数。@example await repository.begin(row, leaseMs) */
384
+ async begin(row, leaseMs) {
385
+ const clock = this.databaseClock();
386
+ const result = await this.db.sql.execute(
387
+ `UPDATE ${TABLE} SET status = ?, attempt_count = attempt_count + 1, leased_until = ${clock} + ?, updated_at = ${this.nextUpdatedAt()}
388
+ WHERE occurrence_id = ? AND owner = ? AND leased_until > ${clock} AND status IN (?, ?) AND attempt_count < max_attempts`,
389
+ ["running", leaseMs, row.occurrenceId, row.owner, "claimed", "running"]
390
+ );
391
+ if (!result.success || result.data.changes !== 1)
392
+ return failed(result.success ? void 0 : result.error);
393
+ row.status = "running";
394
+ row.attemptCount += 1;
395
+ return ok(void 0);
396
+ }
397
+ /** 只续租仍然有效的 owner;过期凭据不能复活。@example await repository.renew(row, leaseMs) */
398
+ async renew(row, leaseMs) {
399
+ const clock = this.databaseClock();
400
+ const result = await this.db.sql.execute(
401
+ `UPDATE ${TABLE} SET leased_until = ${clock} + ?, updated_at = ${this.nextUpdatedAt()} WHERE occurrence_id = ? AND owner = ? AND leased_until > ${clock} AND status IN (?, ?)`,
402
+ [leaseMs, row.occurrenceId, row.owner, "claimed", "running"]
403
+ );
404
+ return result.success && result.data.changes === 1 ? ok(void 0) : failed();
405
+ }
406
+ /** 有效 owner 确认终态;unknown 保留串行槽,阻止下一 cron/manual 与旧副作用并行。@example await repository.finish(row, log) */
407
+ async finish(row, log, logs = [log]) {
408
+ const clock = this.databaseClock();
409
+ const result = await this.db.sql.execute(
410
+ `UPDATE ${TABLE} SET status = ?, active_task = ?, owner = NULL, leased_until = 0, log_json = ?, logs_json = ?, log_persistence = ?, updated_at = ${this.nextUpdatedAt()}
411
+ WHERE occurrence_id = ? AND owner = ? AND leased_until > ${clock} AND status IN (?, ?)`,
412
+ [log.status, log.status === "unknown" ? row.taskId : null, JSON.stringify(log), JSON.stringify(logs), "pending", row.occurrenceId, row.owner, "claimed", "running"]
413
+ );
414
+ return result.success && result.data.changes === 1 ? ok(void 0) : failed();
415
+ }
416
+ /** 人工隔离旧 runner 后条件裁定;不重排旧 occurrence,只有成功裁定才释放任务槽。@example await repository.resolve(id, 'discarded', updatedAt) */
417
+ async resolve(id, status, expectedUpdatedAt) {
418
+ const result = await this.db.sql.execute(
419
+ `UPDATE ${TABLE} SET status = ?, active_task = NULL, updated_at = ${this.nextUpdatedAt()} WHERE occurrence_id = ? AND status = ? AND updated_at = ?`,
420
+ [status, id, "unknown", expectedUpdatedAt]
421
+ );
422
+ return result.success && result.data.changes === 1 ? ok(void 0) : failed();
423
+ }
424
+ };
120
425
  var logger = core.logger.child({ module: "scheduler", scope: "log-repository" });
121
426
  var SCHEDULER_LOG_TABLE = "hai_scheduler_logs";
122
427
  var SchedulerLogRepository = class extends BaseReldbCrudRepository {
@@ -125,41 +430,53 @@ var SchedulerLogRepository = class extends BaseReldbCrudRepository {
125
430
  table: SCHEDULER_LOG_TABLE,
126
431
  idColumn: "id",
127
432
  fields: [
128
- { fieldName: "id", columnName: "id", def: { type: "INTEGER", primaryKey: true, autoIncrement: true }, select: true, create: false, update: false },
433
+ { fieldName: "id", columnName: "id", def: { type: "TEXT", primaryKey: true }, select: true, create: true, update: false },
434
+ { fieldName: "occurrenceId", columnName: "occurrence_id", def: { type: "TEXT" }, select: true, create: true, update: false },
129
435
  { fieldName: "taskId", columnName: "task_id", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
130
436
  { fieldName: "taskName", columnName: "task_name", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
131
437
  { fieldName: "taskType", columnName: "task_type", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
132
438
  { fieldName: "triggerType", columnName: "trigger_type", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
133
439
  { fieldName: "triggerSource", columnName: "trigger_source", def: { type: "TEXT" }, select: true, create: true, update: false },
134
440
  { fieldName: "status", columnName: "status", def: { type: "TEXT", notNull: true }, select: true, create: true, update: false },
135
- { fieldName: "result", columnName: "result", def: { type: "TEXT" }, select: true, create: true, update: false },
136
- { fieldName: "error", columnName: "error", def: { type: "TEXT" }, select: true, create: true, update: false },
441
+ { fieldName: "payload", columnName: "payload", def: { type: "JSON", notNull: true }, select: true, create: true, update: false },
137
442
  { fieldName: "startedAt", columnName: "started_at", def: { type: "BIGINT", notNull: true }, select: true, create: true, update: false },
138
443
  { fieldName: "finishedAt", columnName: "finished_at", def: { type: "BIGINT", notNull: true }, select: true, create: true, update: false },
139
444
  { fieldName: "duration", columnName: "duration", def: { type: "INTEGER", notNull: true }, select: true, create: true, update: false }
140
445
  ]
141
446
  });
142
447
  }
448
+ /**
449
+ * 按固定 UUID 写入完整日志;写入失败后重读相同主键,只有真实内容相同才视为已保存。
450
+ * @example `const saved = await repository.saveLog(log); if (!saved.success) return saved`
451
+ */
143
452
  async saveLog(log) {
453
+ const payload = { ...log, persistence: { status: "persisted" } };
144
454
  try {
145
- const createResult = await this.create({
455
+ const created = await this.create({
456
+ id: log.id,
457
+ occurrenceId: log.occurrenceId,
146
458
  taskId: log.taskId,
147
459
  taskName: log.taskName,
148
460
  taskType: log.taskType,
149
461
  triggerType: log.triggerType,
150
462
  triggerSource: log.triggerSource,
151
463
  status: log.status,
152
- result: log.result,
153
- error: log.error,
464
+ payload,
154
465
  startedAt: log.startedAt,
155
466
  finishedAt: log.finishedAt,
156
467
  duration: log.duration
157
468
  });
158
- if (!createResult.success)
159
- logger.error("Failed to save execution log", { taskId: log.taskId, error: createResult.error.message });
160
- } catch (error) {
161
- logger.error("Failed to save execution log", { taskId: log.taskId, error });
469
+ if (created.success)
470
+ return ok(void 0);
471
+ } catch {
472
+ }
473
+ try {
474
+ const existing = await this.findById(log.id);
475
+ if (existing.success && existing.data && isDeepStrictEqual(existing.data.payload, payload))
476
+ return ok(void 0);
477
+ } catch {
162
478
  }
479
+ return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
163
480
  }
164
481
  async queryLogs(options) {
165
482
  const { taskId, status, triggerType, triggerSource, startedAfter, startedBefore, pagination } = options ?? {};
@@ -193,7 +510,7 @@ var SchedulerLogRepository = class extends BaseReldbCrudRepository {
193
510
  const pageResult = await this.findPage({
194
511
  where: conditions.length > 0 ? conditions.join(" AND ") : void 0,
195
512
  params: params.length > 0 ? params : void 0,
196
- orderBy: "id DESC",
513
+ orderBy: "started_at DESC, id DESC",
197
514
  pagination,
198
515
  overrides: { defaultPageSize: 20, maxPageSize: 200 }
199
516
  });
@@ -206,20 +523,7 @@ var SchedulerLogRepository = class extends BaseReldbCrudRepository {
206
523
  }
207
524
  return ok({
208
525
  ...pageResult.data,
209
- items: pageResult.data.items.map((row) => ({
210
- id: row.id,
211
- taskId: row.taskId,
212
- taskName: row.taskName,
213
- taskType: row.taskType,
214
- triggerType: row.triggerType,
215
- triggerSource: row.triggerSource ?? null,
216
- status: row.status,
217
- result: row.result ?? null,
218
- error: row.error ?? null,
219
- startedAt: Number(row.startedAt),
220
- finishedAt: Number(row.finishedAt),
221
- duration: row.duration
222
- }))
526
+ items: pageResult.data.items.map((row) => ({ ...row.payload, persistence: { status: "persisted" } }))
223
527
  });
224
528
  } catch (error) {
225
529
  logger.error("Failed to query execution logs", { error });
@@ -289,7 +593,7 @@ var logger2 = core.logger.child({ module: "scheduler", scope: "task-repository"
289
593
  var SCHEDULER_TASK_TABLE = "hai_scheduler_tasks";
290
594
  var TaskParamsSchema = z.record(z.string(), z.unknown());
291
595
  var TaskRetryPolicySchema = z.object({
292
- maxAttempts: z.number().int().min(1),
596
+ maxAttempts: z.number().int().min(1).max(100),
293
597
  backoffMs: z.array(z.number().int().min(0)).optional()
294
598
  });
295
599
  var ApiTaskConfigSchema = z.object({
@@ -298,7 +602,8 @@ var ApiTaskConfigSchema = z.object({
298
602
  method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
299
603
  headers: z.record(z.string(), z.string()).optional(),
300
604
  body: z.unknown().optional(),
301
- timeout: z.number().int().positive().optional()
605
+ timeout: z.number().int().positive().optional(),
606
+ maxResponseBytes: z.number().int().positive().max(Number.MAX_SAFE_INTEGER).optional()
302
607
  });
303
608
  var JsTaskConfigSchema = z.object({
304
609
  kind: z.literal("js"),
@@ -319,6 +624,7 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
319
624
  { fieldName: "cron", columnName: "cron", def: { type: "TEXT", notNull: true }, select: true, create: true, update: true },
320
625
  { fieldName: "timezone", columnName: "timezone", def: { type: "TEXT" }, select: true, create: true, update: true },
321
626
  { fieldName: "enabled", columnName: "enabled", def: { type: "BOOLEAN", notNull: true, defaultValue: true }, select: true, create: true, update: true },
627
+ { fieldName: "idempotent", columnName: "idempotent", def: { type: "BOOLEAN", notNull: true, defaultValue: false }, select: true, create: true, update: true },
322
628
  { fieldName: "deleteAfterRun", columnName: "delete_after_run", def: { type: "BOOLEAN", notNull: true, defaultValue: false }, select: true, create: true, update: true },
323
629
  { fieldName: "retry", columnName: "retry", def: { type: "JSON" }, select: true, create: true, update: true },
324
630
  { fieldName: "params", columnName: "params", def: { type: "JSON" }, select: true, create: true, update: true },
@@ -337,6 +643,7 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
337
643
  cron: task.cron,
338
644
  timezone: task.timezone ?? null,
339
645
  enabled: task.enabled !== false,
646
+ idempotent: task.idempotent === true,
340
647
  deleteAfterRun: task.deleteAfterRun === true,
341
648
  retry: task.retry ?? null,
342
649
  params: task.params ?? {},
@@ -386,6 +693,8 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
386
693
  data.timezone = updates.timezone ?? null;
387
694
  if (updates.enabled !== void 0)
388
695
  data.enabled = updates.enabled;
696
+ if (updates.idempotent !== void 0)
697
+ data.idempotent = updates.idempotent;
389
698
  if (updates.deleteAfterRun !== void 0)
390
699
  data.deleteAfterRun = updates.deleteAfterRun;
391
700
  if (updates.retry !== void 0)
@@ -422,11 +731,11 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
422
731
  async deleteTask(taskId, expected) {
423
732
  try {
424
733
  if (expected) {
425
- const conditions = ["task_id = ?", "task_name = ?", "cron = ?", "COALESCE(timezone, '') = ?", "COALESCE(description, '') = ?", "enabled = ?", "delete_after_run = ?"];
734
+ const conditions = ["task_id = ?", "task_name = ?", "cron = ?", "COALESCE(timezone, '') = ?", "COALESCE(description, '') = ?", "enabled = ?", "delete_after_run = ?", "idempotent = ?"];
426
735
  const postgres = this.db.config?.type === "postgresql";
427
736
  const enabled = expected.enabled !== false;
428
737
  const deleteAfterRun = expected.deleteAfterRun === true;
429
- const params = [taskId, expected.name, expected.cron, expected.timezone ?? "", expected.description ?? "", postgres ? enabled : Number(enabled), postgres ? deleteAfterRun : Number(deleteAfterRun)];
738
+ const params = [taskId, expected.name, expected.cron, expected.timezone ?? "", expected.description ?? "", postgres ? enabled : Number(enabled), postgres ? deleteAfterRun : Number(deleteAfterRun), postgres ? expected.idempotent === true : Number(expected.idempotent === true)];
430
739
  const jsonValue = this.db.config?.type === "mysql" ? "CAST(? AS JSON)" : this.db.config?.type === "postgresql" ? "CAST(? AS JSONB)" : "?";
431
740
  const jsonFields = [["retry", expected.retry], ["params", expected.params ?? {}], ["handler", expected.handler]];
432
741
  for (const [column, value] of jsonFields) {
@@ -503,6 +812,7 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
503
812
  cron: row.cron,
504
813
  ...row.timezone ? { timezone: row.timezone } : {},
505
814
  enabled: row.enabled,
815
+ idempotent: row.idempotent,
506
816
  deleteAfterRun: row.deleteAfterRun,
507
817
  ...retryResult.data ? { retry: retryResult.data } : {},
508
818
  params: paramsResult.data,
@@ -522,429 +832,80 @@ var SchedulerTaskRepository = class extends BaseReldbCrudRepository {
522
832
  }
523
833
  }
524
834
  };
525
-
526
- // src/scheduler-cancellation.ts
527
- async function waitWithSignal(operation, signal) {
528
- signal.throwIfAborted();
529
- return new Promise((resolve, reject) => {
530
- const aborted = () => reject(signal.reason);
531
- signal.addEventListener("abort", aborted, { once: true });
532
- Promise.resolve().then(operation).then(resolve, reject).finally(() => {
533
- signal.removeEventListener("abort", aborted);
534
- });
535
- });
536
- }
537
- var compiledHandlerCache = /* @__PURE__ */ new Map();
538
- function createCacheKey(config) {
539
- const keySource = `${config.code}:${config.timeout ?? 0}`;
540
- return createHash("sha256").update(keySource).digest("hex");
541
- }
542
- function clearJsTaskHandlerCache() {
543
- compiledHandlerCache.clear();
544
- }
545
- function compileJsTaskHandler(config) {
546
- const cacheKey = createCacheKey(config);
547
- const cachedHandler = compiledHandlerCache.get(cacheKey);
548
- if (cachedHandler)
549
- return ok(cachedHandler);
835
+ function parseCronExpression(expression, timezone) {
550
836
  try {
551
- void new Script(`(${config.code})`);
552
- const handler = async (context, signal) => {
553
- signal?.throwIfAborted();
554
- return await new Promise((resolve, reject) => {
555
- const timeout = config.timeout ?? 3e4;
556
- const worker = new Worker(new URL("./scheduler-js-worker.js", import.meta.url), {
557
- workerData: { code: config.code, context },
558
- execArgv: []
559
- });
560
- let settled = false;
561
- let timer;
562
- const aborted = () => finish(new Error(schedulerM("scheduler_closing")));
563
- function finish(error, result) {
564
- if (settled)
565
- return;
566
- settled = true;
567
- clearTimeout(timer);
568
- signal?.removeEventListener("abort", aborted);
569
- void worker.terminate().then(() => error ? reject(error) : resolve(result), reject);
570
- }
571
- timer = setTimeout(() => finish(new Error(schedulerM("scheduler_jsTimedOut", { params: { timeout } }))), timeout);
572
- signal?.addEventListener("abort", aborted, { once: true });
573
- worker.once("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
574
- worker.once("exit", (code) => finish(new Error(`JS worker exited before returning a result (${code})`)));
575
- worker.once("message", (message) => {
576
- if (message.success)
577
- finish(void 0, message.data);
578
- else
579
- finish(new Error(message.message));
580
- });
581
- });
582
- };
583
- compiledHandlerCache.set(cacheKey, handler);
584
- return ok(handler);
585
- } catch (error) {
837
+ const cron = new Cron(expression, { legacyMode: false, timezone: timezone ?? "UTC" });
838
+ return ok(cron);
839
+ } catch {
586
840
  return err(
587
- HaiSchedulerError.JS_COMPILE_FAILED,
588
- schedulerM("scheduler_jsCompileFailed", {
589
- params: { error: error instanceof Error ? error.message : String(error) }
590
- }),
591
- error
841
+ HaiSchedulerError.INVALID_CRON,
842
+ schedulerM("scheduler_invalidCron", { params: { expression } })
592
843
  );
593
844
  }
594
845
  }
595
846
 
596
- // src/scheduler-executor.ts
597
- var logger3 = core.logger.child({ module: "scheduler", scope: "executor" });
598
- var currentLogRepo = null;
599
- var currentCleanupPolicy = {};
600
- function setLogRepository(repo, cleanupPolicy = {}) {
601
- currentLogRepo = repo;
602
- currentCleanupPolicy = { ...cleanupPolicy };
847
+ // src/scheduler-functions.ts
848
+ var logger3 = core.logger.child({ module: "scheduler", scope: "functions" });
849
+ var taskRegistry = /* @__PURE__ */ new Map();
850
+ var cronCache = /* @__PURE__ */ new Map();
851
+ var persistedTaskIds = /* @__PURE__ */ new Set();
852
+ var definitionEpoch = 0;
853
+ var activeRefresh;
854
+ var currentHooks = {};
855
+ function getTaskRegistry() {
856
+ return taskRegistry;
603
857
  }
604
- function sanitizeUrl(url) {
605
- try {
606
- const parsedUrl = new URL(url);
607
- if (parsedUrl.password)
608
- parsedUrl.password = "***";
609
- if (parsedUrl.username)
610
- parsedUrl.username = "***";
611
- return parsedUrl.toString();
612
- } catch {
613
- return "(invalid url)";
614
- }
858
+ function getTask(taskId) {
859
+ return taskRegistry.get(taskId);
615
860
  }
616
- function resolveTaskType(task, hooks) {
617
- if (task.handler)
618
- return task.handler.kind;
619
- if (hooks.onTaskExecute)
620
- return "hook";
621
- return "hook";
861
+ function hasTask(taskId) {
862
+ return taskRegistry.has(taskId);
622
863
  }
623
- function createTaskContext(task, trigger, occurrenceId) {
624
- return {
625
- ...occurrenceId ? { occurrenceId } : {},
626
- task,
627
- taskId: task.id,
628
- params: task.params ?? {},
629
- trigger
630
- };
864
+ function setTask(taskId, task) {
865
+ definitionEpoch++;
866
+ taskRegistry.set(taskId, task);
631
867
  }
632
- async function notifyTaskStart(hooks, event) {
633
- if (!hooks.onTaskStart)
634
- return;
635
- try {
636
- await waitWithSignal(() => hooks.onTaskStart(event), event.signal);
637
- } catch (error) {
638
- logger3.warn("Task start hook failed", { taskId: event.task.id, error });
639
- }
868
+ function deleteTask(taskId) {
869
+ definitionEpoch++;
870
+ taskRegistry.delete(taskId);
640
871
  }
641
- async function notifyTaskInterrupted(hooks, event) {
642
- if (!hooks.onTaskInterrupted)
643
- return;
644
- try {
645
- await waitWithSignal(() => hooks.onTaskInterrupted(event), event.signal);
646
- } catch (error) {
647
- logger3.warn("Task interrupted hook failed", { taskId: event.task.id, error });
648
- }
872
+ function getCron(taskId) {
873
+ return cronCache.get(taskId);
649
874
  }
650
- async function notifyTaskFinish(hooks, event) {
651
- if (!hooks.onTaskFinish)
652
- return;
653
- try {
654
- await waitWithSignal(() => hooks.onTaskFinish(event), event.signal);
655
- } catch (error) {
656
- logger3.warn("Task finish hook failed", { taskId: event.task.id, error });
657
- }
875
+ function setCron(taskId, cron) {
876
+ cronCache.set(taskId, cron);
658
877
  }
659
- async function saveExecutionLog(task, trigger, taskType, status, startedAt, finishedAt, result, error) {
660
- const log = {
661
- id: 0,
662
- taskId: task.id,
663
- taskName: task.name,
664
- taskType,
665
- triggerType: trigger.type,
666
- triggerSource: trigger.source,
667
- status,
668
- result,
669
- error,
670
- startedAt,
671
- finishedAt,
672
- duration: finishedAt - startedAt
673
- };
674
- if (!currentLogRepo)
675
- return log;
676
- await currentLogRepo.saveLog(log);
677
- const cleanupResult = await currentLogRepo.cleanupLogs(currentCleanupPolicy);
678
- if (!cleanupResult.success) {
679
- logger3.warn("Failed to cleanup execution logs", {
680
- taskId: task.id,
681
- error: cleanupResult.error.message,
682
- maxLogs: currentCleanupPolicy.maxLogs,
683
- retentionDays: currentCleanupPolicy.retentionDays
684
- });
685
- }
686
- return log;
878
+ function deleteCron(taskId) {
879
+ cronCache.delete(taskId);
687
880
  }
688
- async function saveInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt) {
689
- return saveExecutionLog(task, trigger, taskType, "interrupted", startedAt, finishedAt, null, reason);
881
+ function setHooks(hooks) {
882
+ currentHooks = { ...hooks };
690
883
  }
691
- async function interruptTask(task, trigger, reason, hooks, signal = new AbortController().signal) {
692
- const startedAt = Date.now();
693
- const taskType = resolveTaskType(task, hooks);
694
- await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
695
- const finishedAt = Date.now();
696
- const interruptedLog = await saveInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt);
697
- await notifyTaskInterrupted(hooks, {
698
- task,
699
- trigger,
700
- startedAt,
701
- interruptedAt: finishedAt,
702
- signal,
703
- reason
704
- });
705
- return interruptedLog;
884
+ function clearHooks() {
885
+ currentHooks = {};
706
886
  }
707
- async function executeJsTask(task, context, signal = new AbortController().signal) {
708
- const handler = task.handler;
709
- if (!handler || handler.kind !== "js") {
887
+ function getHooks() {
888
+ return currentHooks;
889
+ }
890
+ function resetTaskState() {
891
+ definitionEpoch++;
892
+ activeRefresh = void 0;
893
+ persistedTaskIds.clear();
894
+ taskRegistry.clear();
895
+ cronCache.clear();
896
+ currentHooks = {};
897
+ }
898
+ function normalizeTaskDescription(description) {
899
+ if (description === void 0)
900
+ return void 0;
901
+ const trimmed = description.trim();
902
+ return trimmed.length > 0 ? trimmed : void 0;
903
+ }
904
+ function validateRetryPolicy(taskId, retry) {
905
+ if (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1 || retry.maxAttempts > 100) {
710
906
  return err(
711
907
  HaiSchedulerError.EXECUTION_FAILED,
712
- schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } })
713
- );
714
- }
715
- const compileResult = compileJsTaskHandler(handler);
716
- if (!compileResult.success)
717
- return compileResult;
718
- try {
719
- const result = await compileResult.data(context, signal);
720
- return ok(result !== void 0 ? JSON.stringify(result) : null);
721
- } catch (error) {
722
- const message = error instanceof Error ? error.message : String(error);
723
- logger3.error("JS task execution failed", { taskId: task.id, error: message });
724
- return err(
725
- HaiSchedulerError.JS_EXECUTION_FAILED,
726
- schedulerM("scheduler_jsExecutionFailed", { params: { error: message } }),
727
- error
728
- );
729
- }
730
- }
731
- async function executeApiTask(task, context, signal = new AbortController().signal) {
732
- const handler = task.handler;
733
- if (!handler || handler.kind !== "api") {
734
- return err(
735
- HaiSchedulerError.EXECUTION_FAILED,
736
- schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } })
737
- );
738
- }
739
- const { url, method = "GET", headers, body, timeout = 3e4, maxResponseBytes = 1024 * 1024 } = handler;
740
- if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0)
741
- return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } }));
742
- const controller = new AbortController();
743
- const timer = setTimeout(() => controller.abort(), timeout);
744
- try {
745
- const response = await fetch(url, {
746
- method,
747
- headers: {
748
- ...headers,
749
- ...body !== void 0 ? { "Content-Type": "application/json" } : {}
750
- },
751
- signal: AbortSignal.any([signal, controller.signal]),
752
- ...body !== void 0 ? { body: JSON.stringify(body) } : {}
753
- });
754
- let receivedBytes = 0;
755
- let responseText = "";
756
- const reader = response.body?.getReader();
757
- if (reader) {
758
- const decoder = new TextDecoder();
759
- try {
760
- while (true) {
761
- const chunk = await reader.read();
762
- if (chunk.done)
763
- break;
764
- receivedBytes += chunk.value.byteLength;
765
- if (receivedBytes > maxResponseBytes) {
766
- controller.abort();
767
- return err(HaiSchedulerError.API_EXECUTION_FAILED, schedulerM("scheduler_apiResponseTooLarge", { params: { maxResponseBytes } }));
768
- }
769
- responseText += decoder.decode(chunk.value, { stream: true });
770
- }
771
- responseText += decoder.decode();
772
- } finally {
773
- reader.releaseLock();
774
- }
775
- }
776
- if (!response.ok) {
777
- logger3.error("API task returned non-OK status", {
778
- taskId: context.taskId,
779
- status: response.status,
780
- url: sanitizeUrl(url)
781
- });
782
- return err(
783
- HaiSchedulerError.API_EXECUTION_FAILED,
784
- schedulerM("scheduler_apiExecutionFailed", {
785
- params: { error: `HTTP ${response.status}: ${responseText.slice(0, 200)}` }
786
- })
787
- );
788
- }
789
- return ok(responseText || null);
790
- } catch (error) {
791
- const message = error instanceof Error ? error.message : String(error);
792
- logger3.error("API task execution failed", { taskId: context.taskId, error: message, url: sanitizeUrl(url) });
793
- return err(
794
- HaiSchedulerError.API_EXECUTION_FAILED,
795
- schedulerM("scheduler_apiExecutionFailed", { params: { error: message } }),
796
- error
797
- );
798
- } finally {
799
- clearTimeout(timer);
800
- }
801
- }
802
- async function executeHookTask(task, context, hooks, startedAt, signal) {
803
- if (!hooks.onTaskExecute) {
804
- return err(
805
- HaiSchedulerError.EXECUTION_FAILED,
806
- schedulerM("scheduler_taskHandlerMissing", { params: { taskId: task.id } })
807
- );
808
- }
809
- const executeEvent = {
810
- task,
811
- trigger: context.trigger,
812
- startedAt,
813
- context,
814
- signal
815
- };
816
- try {
817
- const result = await waitWithSignal(() => hooks.onTaskExecute(executeEvent), signal);
818
- return ok(result !== void 0 ? JSON.stringify(result) : null);
819
- } catch (error) {
820
- const message = error instanceof Error ? error.message : String(error);
821
- logger3.error("Task execute hook failed", { taskId: task.id, error: message });
822
- return err(
823
- HaiSchedulerError.HOOK_EXECUTION_FAILED,
824
- schedulerM("scheduler_hookExecutionFailed", { params: { error: message } }),
825
- error
826
- );
827
- }
828
- }
829
- async function executeTask(task, trigger, hooks, signal = new AbortController().signal, occurrenceId) {
830
- const startedAt = Date.now();
831
- const taskType = resolveTaskType(task, hooks);
832
- const context = createTaskContext(task, trigger, occurrenceId);
833
- await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
834
- let executionResult;
835
- let shouldNotifyInterrupted = false;
836
- if (signal.aborted) {
837
- executionResult = err(HaiSchedulerError.EXECUTION_FAILED, schedulerM("scheduler_closing"));
838
- shouldNotifyInterrupted = true;
839
- } else if (!task.handler) {
840
- executionResult = await executeHookTask(task, context, hooks, startedAt, signal);
841
- shouldNotifyInterrupted = !hooks.onTaskExecute || !executionResult.success && executionResult.error.cause instanceof Error && executionResult.error.cause.name === "SchedulerTaskSkippedError";
842
- } else if (task.handler.kind === "api") {
843
- executionResult = await executeApiTask(task, context, signal);
844
- } else {
845
- executionResult = await executeJsTask(task, context, signal);
846
- }
847
- shouldNotifyInterrupted ||= signal.aborted;
848
- const finishedAt = Date.now();
849
- const status = executionResult.success ? "success" : shouldNotifyInterrupted ? "interrupted" : "failed";
850
- const log = await saveExecutionLog(
851
- task,
852
- trigger,
853
- taskType,
854
- status,
855
- startedAt,
856
- finishedAt,
857
- executionResult.success ? executionResult.data : null,
858
- executionResult.success ? null : executionResult.error.message
859
- );
860
- if (!executionResult.success && shouldNotifyInterrupted) {
861
- await notifyTaskInterrupted(hooks, {
862
- task,
863
- trigger,
864
- startedAt,
865
- interruptedAt: finishedAt,
866
- signal,
867
- reason: executionResult.error.message
868
- });
869
- }
870
- if (status !== "interrupted")
871
- await notifyTaskFinish(hooks, { task, trigger, startedAt, finishedAt, log, signal });
872
- return log;
873
- }
874
- function parseCronExpression(expression, timezone) {
875
- try {
876
- const cron = new Cron(expression, { legacyMode: false, ...timezone ? { timezone } : {} });
877
- return ok(cron);
878
- } catch {
879
- return err(
880
- HaiSchedulerError.INVALID_CRON,
881
- schedulerM("scheduler_invalidCron", { params: { expression } })
882
- );
883
- }
884
- }
885
-
886
- // src/scheduler-functions.ts
887
- var logger4 = core.logger.child({ module: "scheduler", scope: "functions" });
888
- var taskRegistry = /* @__PURE__ */ new Map();
889
- var cronCache = /* @__PURE__ */ new Map();
890
- var persistedTaskIds = /* @__PURE__ */ new Set();
891
- var definitionEpoch = 0;
892
- var activeRefresh;
893
- var currentHooks = {};
894
- function getTaskRegistry() {
895
- return taskRegistry;
896
- }
897
- function getTask(taskId) {
898
- return taskRegistry.get(taskId);
899
- }
900
- function hasTask(taskId) {
901
- return taskRegistry.has(taskId);
902
- }
903
- function setTask(taskId, task) {
904
- definitionEpoch++;
905
- taskRegistry.set(taskId, task);
906
- }
907
- function deleteTask(taskId) {
908
- definitionEpoch++;
909
- taskRegistry.delete(taskId);
910
- }
911
- function getCron(taskId) {
912
- return cronCache.get(taskId);
913
- }
914
- function setCron(taskId, cron) {
915
- cronCache.set(taskId, cron);
916
- }
917
- function deleteCron(taskId) {
918
- cronCache.delete(taskId);
919
- }
920
- function setHooks(hooks) {
921
- currentHooks = { ...hooks };
922
- }
923
- function clearHooks() {
924
- currentHooks = {};
925
- }
926
- function getHooks() {
927
- return currentHooks;
928
- }
929
- function resetTaskState() {
930
- definitionEpoch++;
931
- activeRefresh = void 0;
932
- persistedTaskIds.clear();
933
- taskRegistry.clear();
934
- cronCache.clear();
935
- currentHooks = {};
936
- }
937
- function normalizeTaskDescription(description) {
938
- if (description === void 0)
939
- return void 0;
940
- const trimmed = description.trim();
941
- return trimmed.length > 0 ? trimmed : void 0;
942
- }
943
- function validateRetryPolicy(taskId, retry) {
944
- if (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1) {
945
- return err(
946
- HaiSchedulerError.EXECUTION_FAILED,
947
- schedulerM("scheduler_invalidRetryConfig", { params: { taskId } })
908
+ schedulerM("scheduler_invalidRetryConfig", { params: { taskId } })
948
909
  );
949
910
  }
950
911
  if (retry.backoffMs !== void 0) {
@@ -1016,6 +977,9 @@ async function loadConfigTasks(tasks) {
1016
977
  return ok(void 0);
1017
978
  }
1018
979
  async function registerTask(task, taskRepo2) {
980
+ if (task.handler && !TaskHandlerConfigSchema.safeParse(task.handler).success || task.idempotent !== void 0 && typeof task.idempotent !== "boolean") {
981
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } }));
982
+ }
1019
983
  if (!task.id) {
1020
984
  return err(
1021
985
  HaiSchedulerError.EXECUTION_FAILED,
@@ -1039,6 +1003,7 @@ async function registerTask(task, taskRepo2) {
1039
1003
  description: normalizeTaskDescription(task.description),
1040
1004
  timezone: task.timezone,
1041
1005
  enabled: task.enabled !== false,
1006
+ idempotent: task.idempotent === true,
1042
1007
  deleteAfterRun: task.deleteAfterRun === true,
1043
1008
  retry: retryResult.data,
1044
1009
  params: task.params ?? {}
@@ -1046,7 +1011,7 @@ async function registerTask(task, taskRepo2) {
1046
1011
  if (taskRepo2) {
1047
1012
  const saveResult = await taskRepo2.saveTask(normalizedTask);
1048
1013
  if (!saveResult.success) {
1049
- logger4.warn("Failed to persist task definition", { taskId: task.id, error: saveResult.error.message });
1014
+ logger3.warn("Failed to persist task definition", { taskId: task.id, error: saveResult.error.message });
1050
1015
  return err(
1051
1016
  HaiSchedulerError.DB_SAVE_FAILED,
1052
1017
  schedulerM("scheduler_dbSaveFailed", { params: { error: saveResult.error.message } }),
@@ -1062,131 +1027,525 @@ async function registerTask(task, taskRepo2) {
1062
1027
  async function unregisterTask(taskId, taskRepo2, expected) {
1063
1028
  if (!hasTask(taskId)) {
1064
1029
  return err(
1065
- HaiSchedulerError.TASK_NOT_FOUND,
1066
- schedulerM("scheduler_taskNotFound", { params: { taskId } })
1030
+ HaiSchedulerError.TASK_NOT_FOUND,
1031
+ schedulerM("scheduler_taskNotFound", { params: { taskId } })
1032
+ );
1033
+ }
1034
+ if (taskRepo2) {
1035
+ const deleteResult = await taskRepo2.deleteTask(taskId, expected);
1036
+ if (!deleteResult.success) {
1037
+ logger3.warn("Failed to delete persisted task definition", { taskId, error: deleteResult.error.message });
1038
+ return err(
1039
+ HaiSchedulerError.DB_SAVE_FAILED,
1040
+ schedulerM("scheduler_dbSaveFailed", { params: { error: deleteResult.error.message } }),
1041
+ deleteResult.error
1042
+ );
1043
+ }
1044
+ if (expected && !deleteResult.data)
1045
+ return ok(void 0);
1046
+ }
1047
+ deleteTask(taskId);
1048
+ deleteCron(taskId);
1049
+ persistedTaskIds.delete(taskId);
1050
+ return ok(void 0);
1051
+ }
1052
+ async function updateRegisteredTask(taskId, updates, taskRepo2) {
1053
+ const existingTask = getTask(taskId);
1054
+ if (!existingTask) {
1055
+ return err(
1056
+ HaiSchedulerError.TASK_NOT_FOUND,
1057
+ schedulerM("scheduler_taskNotFound", { params: { taskId } })
1058
+ );
1059
+ }
1060
+ let updatedCron = getCron(taskId);
1061
+ if (updates.cron !== void 0 || updates.timezone !== void 0) {
1062
+ const cronExpression = updates.cron ?? existingTask.cron;
1063
+ const timezone = updates.timezone === null ? void 0 : updates.timezone ?? existingTask.timezone;
1064
+ const cronResult = parseCronExpression(cronExpression, timezone);
1065
+ if (!cronResult.success)
1066
+ return cronResult;
1067
+ updatedCron = cronResult.data;
1068
+ }
1069
+ let normalizedRetry;
1070
+ if (updates.retry !== void 0) {
1071
+ if (updates.retry === null) {
1072
+ normalizedRetry = null;
1073
+ } else {
1074
+ const retryResult = validateRetryPolicy(taskId, updates.retry);
1075
+ if (!retryResult.success)
1076
+ return retryResult;
1077
+ normalizedRetry = retryResult.data;
1078
+ }
1079
+ }
1080
+ const normalizedDescription = updates.description !== void 0 ? normalizeTaskDescription(updates.description) ?? "" : void 0;
1081
+ const normalizedUpdates = {
1082
+ ...updates,
1083
+ ...updates.description !== void 0 ? { description: normalizedDescription } : {},
1084
+ ...updates.retry !== void 0 ? { retry: normalizedRetry } : {}
1085
+ };
1086
+ if (updates.handler && !TaskHandlerConfigSchema.safeParse(updates.handler).success || updates.idempotent !== void 0 && typeof updates.idempotent !== "boolean") {
1087
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId } }));
1088
+ }
1089
+ const updatedTask = {
1090
+ ...existingTask,
1091
+ ...normalizedUpdates.name !== void 0 ? { name: normalizedUpdates.name } : {},
1092
+ ...normalizedUpdates.description !== void 0 ? { description: normalizedUpdates.description || void 0 } : {},
1093
+ ...normalizedUpdates.cron !== void 0 ? { cron: normalizedUpdates.cron } : {},
1094
+ ...normalizedUpdates.timezone !== void 0 ? { timezone: normalizedUpdates.timezone ?? void 0 } : {},
1095
+ ...normalizedUpdates.enabled !== void 0 ? { enabled: normalizedUpdates.enabled } : {},
1096
+ ...normalizedUpdates.idempotent !== void 0 ? { idempotent: normalizedUpdates.idempotent } : {},
1097
+ ...normalizedUpdates.deleteAfterRun !== void 0 ? { deleteAfterRun: normalizedUpdates.deleteAfterRun } : {},
1098
+ ...normalizedUpdates.retry !== void 0 ? { retry: normalizedUpdates.retry ?? void 0 } : {},
1099
+ ...normalizedUpdates.params !== void 0 ? { params: normalizedUpdates.params } : {},
1100
+ ...normalizedUpdates.handler !== void 0 ? { handler: normalizedUpdates.handler ?? void 0 } : {}
1101
+ };
1102
+ if (taskRepo2) {
1103
+ const updateResult = await taskRepo2.updateTask(taskId, normalizedUpdates);
1104
+ if (!updateResult.success) {
1105
+ logger3.warn("Failed to update persisted task definition", { taskId, error: updateResult.error.message });
1106
+ return err(
1107
+ HaiSchedulerError.DB_SAVE_FAILED,
1108
+ schedulerM("scheduler_dbSaveFailed", { params: { error: updateResult.error.message } }),
1109
+ updateResult.error
1110
+ );
1111
+ }
1112
+ }
1113
+ setTask(taskId, updatedTask);
1114
+ if (updatedCron)
1115
+ setCron(taskId, updatedCron);
1116
+ return ok(void 0);
1117
+ }
1118
+ async function queryTaskLogs(logRepo2, options) {
1119
+ if (!logRepo2) {
1120
+ return err(
1121
+ HaiSchedulerError.DB_SAVE_FAILED,
1122
+ schedulerM("scheduler_dbNotInitialized")
1123
+ );
1124
+ }
1125
+ return logRepo2.queryLogs(options);
1126
+ }
1127
+ var compiledHandlerCache = /* @__PURE__ */ new Map();
1128
+ function createCacheKey(config) {
1129
+ const keySource = `${config.code}:${config.timeout ?? 0}`;
1130
+ return createHash("sha256").update(keySource).digest("hex");
1131
+ }
1132
+ function clearJsTaskHandlerCache() {
1133
+ compiledHandlerCache.clear();
1134
+ }
1135
+ function compileJsTaskHandler(config) {
1136
+ const cacheKey = createCacheKey(config);
1137
+ const cachedHandler = compiledHandlerCache.get(cacheKey);
1138
+ if (cachedHandler)
1139
+ return ok(cachedHandler);
1140
+ try {
1141
+ void new Script(`(${config.code})`);
1142
+ const handler = async (context, signal) => {
1143
+ signal?.throwIfAborted();
1144
+ return await new Promise((resolve, reject) => {
1145
+ const timeout = config.timeout ?? 3e4;
1146
+ const worker = new Worker(new URL("./scheduler-js-worker.js", import.meta.url), {
1147
+ workerData: { code: config.code, context },
1148
+ execArgv: []
1149
+ });
1150
+ let settled = false;
1151
+ let timer;
1152
+ const aborted = () => finish(new Error(schedulerM("scheduler_closing")));
1153
+ function finish(error, result) {
1154
+ if (settled)
1155
+ return;
1156
+ settled = true;
1157
+ clearTimeout(timer);
1158
+ signal?.removeEventListener("abort", aborted);
1159
+ void worker.terminate().then(() => error ? reject(error) : resolve(result), reject);
1160
+ }
1161
+ timer = setTimeout(() => finish(new Error(schedulerM("scheduler_jsTimedOut", { params: { timeout } }))), timeout);
1162
+ signal?.addEventListener("abort", aborted, { once: true });
1163
+ worker.once("error", (error) => finish(error instanceof Error ? error : new Error(String(error))));
1164
+ worker.once("exit", (code) => finish(new Error(`JS worker exited before returning a result (${code})`)));
1165
+ worker.once("message", (message) => {
1166
+ if (message.success)
1167
+ finish(void 0, message.data);
1168
+ else
1169
+ finish(new Error(message.message));
1170
+ });
1171
+ });
1172
+ };
1173
+ compiledHandlerCache.set(cacheKey, handler);
1174
+ return ok(handler);
1175
+ } catch (error) {
1176
+ return err(
1177
+ HaiSchedulerError.JS_COMPILE_FAILED,
1178
+ schedulerM("scheduler_jsCompileFailed", {
1179
+ params: { error: error instanceof Error ? error.message : String(error) }
1180
+ }),
1181
+ error
1182
+ );
1183
+ }
1184
+ }
1185
+
1186
+ // src/scheduler-cancellation.ts
1187
+ async function waitWithSignal(operation, signal) {
1188
+ signal.throwIfAborted();
1189
+ return new Promise((resolve, reject) => {
1190
+ const aborted = () => reject(signal.reason);
1191
+ signal.addEventListener("abort", aborted, { once: true });
1192
+ Promise.resolve().then(operation).then(resolve, reject).finally(() => {
1193
+ signal.removeEventListener("abort", aborted);
1194
+ });
1195
+ });
1196
+ }
1197
+
1198
+ // src/scheduler-executor.ts
1199
+ var logger4 = core.logger.child({ module: "scheduler", scope: "executor" });
1200
+ function resolveTaskType(task, hooks) {
1201
+ if (task.handler)
1202
+ return task.handler.kind;
1203
+ if (hooks.onTaskExecute)
1204
+ return "hook";
1205
+ return "hook";
1206
+ }
1207
+ function createTaskContext(task, trigger, execution) {
1208
+ return {
1209
+ ...execution,
1210
+ task,
1211
+ taskId: task.id,
1212
+ params: task.params ?? {},
1213
+ trigger
1214
+ };
1215
+ }
1216
+ async function notifyTaskStart(hooks, event) {
1217
+ if (!hooks.onTaskStart)
1218
+ return;
1219
+ try {
1220
+ await waitWithSignal(() => hooks.onTaskStart(event), event.signal);
1221
+ } catch (error) {
1222
+ logger4.warn("Task start hook failed", { taskId: event.task.id, error });
1223
+ }
1224
+ }
1225
+ async function notifyTaskInterrupted(hooks, event) {
1226
+ if (!hooks.onTaskInterrupted)
1227
+ return;
1228
+ try {
1229
+ await waitWithSignal(() => hooks.onTaskInterrupted(event), event.signal);
1230
+ } catch (error) {
1231
+ logger4.warn("Task interrupted hook failed", { taskId: event.task.id, error });
1232
+ }
1233
+ }
1234
+ async function notifyTaskFinish(hooks, event) {
1235
+ if (!hooks.onTaskFinish)
1236
+ return;
1237
+ try {
1238
+ await waitWithSignal(() => hooks.onTaskFinish(event), event.signal);
1239
+ } catch (error) {
1240
+ logger4.warn("Task finish hook failed", { taskId: event.task.id, error });
1241
+ }
1242
+ }
1243
+ function createExecutionLog(task, trigger, taskType, status, startedAt, finishedAt, result, error, occurrenceId) {
1244
+ const log = {
1245
+ id: crypto.randomUUID(),
1246
+ persistence: { status: "unconfirmed" },
1247
+ taskId: task.id,
1248
+ taskName: task.name,
1249
+ taskType,
1250
+ triggerType: trigger.type,
1251
+ triggerSource: trigger.source,
1252
+ status,
1253
+ result,
1254
+ error,
1255
+ startedAt,
1256
+ finishedAt,
1257
+ duration: finishedAt - startedAt,
1258
+ ...occurrenceId ? { occurrenceId } : {}
1259
+ };
1260
+ return log;
1261
+ }
1262
+ async function createInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt) {
1263
+ return createExecutionLog(task, trigger, taskType, "interrupted", startedAt, finishedAt, null, reason);
1264
+ }
1265
+ async function interruptTask(task, trigger, reason, hooks, signal = new AbortController().signal) {
1266
+ const startedAt = Date.now();
1267
+ const taskType = resolveTaskType(task, hooks);
1268
+ await notifyTaskStart(hooks, { task, trigger, startedAt, signal });
1269
+ const finishedAt = Date.now();
1270
+ const interruptedLog = await createInterruptedTaskLog(task, trigger, reason, taskType, startedAt, finishedAt);
1271
+ await notifyTaskInterrupted(hooks, {
1272
+ task,
1273
+ trigger,
1274
+ startedAt,
1275
+ interruptedAt: finishedAt,
1276
+ signal,
1277
+ reason
1278
+ });
1279
+ return interruptedLog;
1280
+ }
1281
+ async function executeJsTask(task, context, signal = new AbortController().signal) {
1282
+ const handler = task.handler;
1283
+ if (!handler || handler.kind !== "js") {
1284
+ return err(
1285
+ HaiSchedulerError.EXECUTION_FAILED,
1286
+ schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } })
1287
+ );
1288
+ }
1289
+ const compileResult = compileJsTaskHandler(handler);
1290
+ if (!compileResult.success)
1291
+ return compileResult;
1292
+ try {
1293
+ const result = await compileResult.data(context, signal);
1294
+ return ok(result !== void 0 ? JSON.stringify(result) : null);
1295
+ } catch (error) {
1296
+ const message = error instanceof Error ? error.message : String(error);
1297
+ logger4.error("JS task execution failed", { taskId: task.id, error: message });
1298
+ return err(
1299
+ HaiSchedulerError.JS_EXECUTION_FAILED,
1300
+ schedulerM("scheduler_jsExecutionFailed", { params: { error: message } }),
1301
+ error
1302
+ );
1303
+ }
1304
+ }
1305
+ async function executeApiTask(task, context, signal = new AbortController().signal) {
1306
+ const handler = task.handler;
1307
+ if (!handler || handler.kind !== "api") {
1308
+ return err(
1309
+ HaiSchedulerError.EXECUTION_FAILED,
1310
+ schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } })
1067
1311
  );
1068
1312
  }
1069
- if (taskRepo2) {
1070
- const deleteResult = await taskRepo2.deleteTask(taskId, expected);
1071
- if (!deleteResult.success) {
1072
- logger4.warn("Failed to delete persisted task definition", { taskId, error: deleteResult.error.message });
1313
+ const { url, method = "GET", headers, body, timeout = 3e4, maxResponseBytes = 1024 * 1024 } = handler;
1314
+ if (!Number.isSafeInteger(maxResponseBytes) || maxResponseBytes <= 0)
1315
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_invalidHandlerConfig", { params: { taskId: task.id } }));
1316
+ const controller = new AbortController();
1317
+ const timer = setTimeout(() => controller.abort(), timeout);
1318
+ try {
1319
+ const requestHeaders = new Headers(headers);
1320
+ if (body !== void 0)
1321
+ requestHeaders.set("Content-Type", "application/json");
1322
+ if (context.occurrenceId) {
1323
+ requestHeaders.set("Idempotency-Key", context.occurrenceId);
1324
+ requestHeaders.set("X-Scheduler-Sequence", String(context.sequence));
1325
+ requestHeaders.set("X-Scheduler-Attempt", String(context.attempt));
1326
+ requestHeaders.set("X-Scheduler-Task", encodeURIComponent(context.taskId));
1327
+ }
1328
+ const response = await fetch(url, {
1329
+ method,
1330
+ headers: requestHeaders,
1331
+ signal: AbortSignal.any([signal, controller.signal]),
1332
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
1333
+ });
1334
+ let receivedBytes = 0;
1335
+ let responseText = "";
1336
+ const reader = response.body?.getReader();
1337
+ if (reader) {
1338
+ const decoder = new TextDecoder();
1339
+ try {
1340
+ while (true) {
1341
+ const chunk = await reader.read();
1342
+ if (chunk.done)
1343
+ break;
1344
+ receivedBytes += chunk.value.byteLength;
1345
+ if (receivedBytes > maxResponseBytes) {
1346
+ controller.abort();
1347
+ return err(HaiSchedulerError.API_EXECUTION_FAILED, schedulerM("scheduler_apiResponseTooLarge", { params: { maxResponseBytes } }));
1348
+ }
1349
+ responseText += decoder.decode(chunk.value, { stream: true });
1350
+ }
1351
+ responseText += decoder.decode();
1352
+ } finally {
1353
+ reader.releaseLock();
1354
+ }
1355
+ }
1356
+ if (!response.ok) {
1357
+ logger4.error("API task returned non-OK status", {
1358
+ taskId: context.taskId,
1359
+ status: response.status,
1360
+ url: core.sanitize.sanitizeUrl(url)
1361
+ });
1073
1362
  return err(
1074
- HaiSchedulerError.DB_SAVE_FAILED,
1075
- schedulerM("scheduler_dbSaveFailed", { params: { error: deleteResult.error.message } }),
1076
- deleteResult.error
1363
+ HaiSchedulerError.API_EXECUTION_FAILED,
1364
+ schedulerM("scheduler_apiExecutionFailed", {
1365
+ params: { error: `HTTP ${response.status}: ${responseText.slice(0, 200)}` }
1366
+ })
1077
1367
  );
1078
1368
  }
1079
- if (expected && !deleteResult.data)
1080
- return ok(void 0);
1369
+ return ok(responseText || null);
1370
+ } catch (error) {
1371
+ const message = error instanceof Error ? error.message : String(error);
1372
+ logger4.error("API task execution failed", { taskId: context.taskId, error: message, url: core.sanitize.sanitizeUrl(url) });
1373
+ return err(
1374
+ HaiSchedulerError.API_EXECUTION_FAILED,
1375
+ schedulerM("scheduler_apiExecutionFailed", { params: { error: message } }),
1376
+ error
1377
+ );
1378
+ } finally {
1379
+ clearTimeout(timer);
1081
1380
  }
1082
- deleteTask(taskId);
1083
- deleteCron(taskId);
1084
- persistedTaskIds.delete(taskId);
1085
- return ok(void 0);
1086
1381
  }
1087
- async function updateRegisteredTask(taskId, updates, taskRepo2) {
1088
- const existingTask = getTask(taskId);
1089
- if (!existingTask) {
1382
+ async function executeHookTask(task, context, hooks, startedAt, signal) {
1383
+ if (!hooks.onTaskExecute) {
1090
1384
  return err(
1091
- HaiSchedulerError.TASK_NOT_FOUND,
1092
- schedulerM("scheduler_taskNotFound", { params: { taskId } })
1385
+ HaiSchedulerError.EXECUTION_FAILED,
1386
+ schedulerM("scheduler_taskHandlerMissing", { params: { taskId: task.id } })
1093
1387
  );
1094
1388
  }
1095
- let updatedCron = getCron(taskId);
1096
- if (updates.cron !== void 0 || updates.timezone !== void 0) {
1097
- const cronExpression = updates.cron ?? existingTask.cron;
1098
- const timezone = updates.timezone === null ? void 0 : updates.timezone ?? existingTask.timezone;
1099
- const cronResult = parseCronExpression(cronExpression, timezone);
1100
- if (!cronResult.success)
1101
- return cronResult;
1102
- updatedCron = cronResult.data;
1103
- }
1104
- let normalizedRetry;
1105
- if (updates.retry !== void 0) {
1106
- if (updates.retry === null) {
1107
- normalizedRetry = null;
1108
- } else {
1109
- const retryResult = validateRetryPolicy(taskId, updates.retry);
1110
- if (!retryResult.success)
1111
- return retryResult;
1112
- normalizedRetry = retryResult.data;
1113
- }
1114
- }
1115
- const normalizedDescription = updates.description !== void 0 ? normalizeTaskDescription(updates.description) ?? "" : void 0;
1116
- const normalizedUpdates = {
1117
- ...updates,
1118
- ...updates.description !== void 0 ? { description: normalizedDescription } : {},
1119
- ...updates.retry !== void 0 ? { retry: normalizedRetry } : {}
1120
- };
1121
- const updatedTask = {
1122
- ...existingTask,
1123
- ...normalizedUpdates.name !== void 0 ? { name: normalizedUpdates.name } : {},
1124
- ...normalizedUpdates.description !== void 0 ? { description: normalizedUpdates.description || void 0 } : {},
1125
- ...normalizedUpdates.cron !== void 0 ? { cron: normalizedUpdates.cron } : {},
1126
- ...normalizedUpdates.timezone !== void 0 ? { timezone: normalizedUpdates.timezone ?? void 0 } : {},
1127
- ...normalizedUpdates.enabled !== void 0 ? { enabled: normalizedUpdates.enabled } : {},
1128
- ...normalizedUpdates.deleteAfterRun !== void 0 ? { deleteAfterRun: normalizedUpdates.deleteAfterRun } : {},
1129
- ...normalizedUpdates.retry !== void 0 ? { retry: normalizedUpdates.retry ?? void 0 } : {},
1130
- ...normalizedUpdates.params !== void 0 ? { params: normalizedUpdates.params } : {},
1131
- ...normalizedUpdates.handler !== void 0 ? { handler: normalizedUpdates.handler ?? void 0 } : {}
1389
+ const executeEvent = {
1390
+ task,
1391
+ trigger: context.trigger,
1392
+ startedAt,
1393
+ context,
1394
+ signal
1132
1395
  };
1133
- if (taskRepo2) {
1134
- const updateResult = await taskRepo2.updateTask(taskId, normalizedUpdates);
1135
- if (!updateResult.success) {
1136
- logger4.warn("Failed to update persisted task definition", { taskId, error: updateResult.error.message });
1137
- return err(
1138
- HaiSchedulerError.DB_SAVE_FAILED,
1139
- schedulerM("scheduler_dbSaveFailed", { params: { error: updateResult.error.message } }),
1140
- updateResult.error
1141
- );
1142
- }
1143
- }
1144
- setTask(taskId, updatedTask);
1145
- if (updatedCron)
1146
- setCron(taskId, updatedCron);
1147
- return ok(void 0);
1148
- }
1149
- async function queryTaskLogs(logRepo2, options) {
1150
- if (!logRepo2) {
1396
+ try {
1397
+ const result = await waitWithSignal(() => hooks.onTaskExecute(executeEvent), signal);
1398
+ return ok(result !== void 0 ? JSON.stringify(result) : null);
1399
+ } catch (error) {
1400
+ const message = error instanceof Error ? error.message : String(error);
1401
+ logger4.error("Task execute hook failed", { taskId: task.id, error: message });
1151
1402
  return err(
1152
- HaiSchedulerError.DB_SAVE_FAILED,
1153
- schedulerM("scheduler_dbNotInitialized")
1403
+ HaiSchedulerError.HOOK_EXECUTION_FAILED,
1404
+ schedulerM("scheduler_hookExecutionFailed", { params: { error: message } }),
1405
+ error
1154
1406
  );
1155
1407
  }
1156
- return logRepo2.queryLogs(options);
1157
1408
  }
1409
+ async function executeTask(task, trigger, hooks, signal = new AbortController().signal, execution) {
1410
+ const startedAt = Date.now();
1411
+ const taskType = resolveTaskType(task, hooks);
1412
+ const context = createTaskContext(task, trigger, execution);
1413
+ await notifyTaskStart(hooks, { task, trigger, startedAt, signal, context });
1414
+ let executionResult;
1415
+ let shouldNotifyInterrupted = false;
1416
+ if (signal.aborted) {
1417
+ executionResult = err(HaiSchedulerError.EXECUTION_FAILED, schedulerM("scheduler_closing"));
1418
+ shouldNotifyInterrupted = true;
1419
+ } else if (!task.handler) {
1420
+ executionResult = await executeHookTask(task, context, hooks, startedAt, signal);
1421
+ shouldNotifyInterrupted = !hooks.onTaskExecute || !executionResult.success && executionResult.error.cause instanceof Error && executionResult.error.cause.name === "SchedulerTaskSkippedError";
1422
+ } else if (task.handler.kind === "api") {
1423
+ executionResult = await executeApiTask(task, context, signal);
1424
+ } else {
1425
+ executionResult = await executeJsTask(task, context, signal);
1426
+ }
1427
+ shouldNotifyInterrupted ||= signal.aborted;
1428
+ const finishedAt = Date.now();
1429
+ let status = executionResult.success ? "success" : shouldNotifyInterrupted ? "interrupted" : "failed";
1430
+ if (execution?.executionToken && !task.idempotent && !executionResult.success)
1431
+ status = "unknown";
1432
+ const log = createExecutionLog(
1433
+ task,
1434
+ trigger,
1435
+ taskType,
1436
+ status,
1437
+ startedAt,
1438
+ finishedAt,
1439
+ executionResult.success ? executionResult.data : null,
1440
+ executionResult.success ? null : executionResult.error.message,
1441
+ context.occurrenceId
1442
+ );
1443
+ if (!executionResult.success && shouldNotifyInterrupted) {
1444
+ await notifyTaskInterrupted(hooks, {
1445
+ task,
1446
+ trigger,
1447
+ startedAt,
1448
+ interruptedAt: finishedAt,
1449
+ signal,
1450
+ reason: executionResult.error.message,
1451
+ context
1452
+ });
1453
+ }
1454
+ if (status !== "interrupted" && !signal.aborted)
1455
+ await notifyTaskFinish(hooks, { task, trigger, startedAt, finishedAt, log, signal, context });
1456
+ return log;
1457
+ }
1458
+
1459
+ // src/scheduler-runner.ts
1158
1460
  var logger5 = core.logger.child({ module: "scheduler", scope: "runner" });
1159
1461
  var runningTasks = /* @__PURE__ */ new Set();
1160
1462
  var activeRuns = /* @__PURE__ */ new Map();
1463
+ var activeRepairs = /* @__PURE__ */ new Set();
1161
1464
  var tickTimer = null;
1162
- var tickInProgress = false;
1465
+ var tickInProgress = null;
1163
1466
  var lastTickMinute = -1;
1164
1467
  var currentNodeId = "";
1165
- var currentLockTtlSec = 300;
1468
+ var currentLeaseMs = 3e5;
1166
1469
  var currentTaskRepo = null;
1167
- function configureLock(nodeId, lockTtlSec) {
1470
+ var currentExecutionRepo = null;
1471
+ var currentLogRepo = null;
1472
+ var currentCleanupPolicy = {};
1473
+ function setLogRepository(repo, policy = {}) {
1474
+ currentLogRepo = repo;
1475
+ currentCleanupPolicy = { ...policy };
1476
+ }
1477
+ var publishedLogSchema = z.object({
1478
+ id: z.uuid(),
1479
+ occurrenceId: z.string().regex(/^[a-f\d]{64}$/),
1480
+ taskId: z.string(),
1481
+ taskName: z.string(),
1482
+ taskType: z.enum(["api", "js", "hook"]),
1483
+ triggerType: z.enum(["manual", "scheduled"]),
1484
+ triggerSource: z.string().nullable(),
1485
+ status: z.enum(["success", "failed", "interrupted", "unknown"]),
1486
+ result: z.string().nullable(),
1487
+ error: z.string().nullable(),
1488
+ startedAt: z.number().safe(),
1489
+ finishedAt: z.number().safe(),
1490
+ duration: z.number().safe(),
1491
+ persistence: z.object({ status: z.enum(["pending", "persisted", "unconfirmed"]), error: z.object({ code: z.string(), message: z.string() }).optional() })
1492
+ });
1493
+ function repairExecutionLogs(id) {
1494
+ const operation = projectExecutionLogs(id);
1495
+ activeRepairs.add(operation);
1496
+ void operation.finally(() => activeRepairs.delete(operation)).catch(() => {
1497
+ });
1498
+ return operation;
1499
+ }
1500
+ async function projectExecutionLogs(id) {
1501
+ const execution = currentExecutionRepo;
1502
+ const logs = currentLogRepo;
1503
+ const cleanupPolicy = currentCleanupPolicy;
1504
+ if (!execution || !logs)
1505
+ return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
1506
+ try {
1507
+ const found = await execution.byOccurrence(id);
1508
+ if (!found.success || !found.data || ["claimed", "running"].includes(found.data.status))
1509
+ return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
1510
+ const row = found.data;
1511
+ if (row.logPersistence === "persisted")
1512
+ return ok(void 0);
1513
+ const parsed = z.array(publishedLogSchema).min(1).max(101).safeParse(JSON.parse(row.logsJson ?? "null"));
1514
+ if (!parsed.success || parsed.data.some((log) => log.occurrenceId !== id || log.taskId !== row.taskId))
1515
+ return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
1516
+ for (const log of parsed.data) {
1517
+ const saved = await logs.saveLog(log);
1518
+ if (!saved.success)
1519
+ return saved;
1520
+ }
1521
+ const confirmed = await execution.confirmLogs(row);
1522
+ if (!confirmed.success)
1523
+ return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
1524
+ const cleanup = await logs.cleanupLogs(cleanupPolicy);
1525
+ if (!cleanup.success)
1526
+ logger5.warn("Execution logs persisted but retention cleanup failed", { occurrenceId: id, errorCode: cleanup.error.code });
1527
+ return ok(void 0);
1528
+ } catch {
1529
+ return err(HaiSchedulerError.LOG_PERSIST_FAILED, schedulerM("scheduler_logPersistFailed"));
1530
+ }
1531
+ }
1532
+ function configureExecution(repo, nodeId = "", leaseMs = 3e5) {
1533
+ currentExecutionRepo = repo;
1168
1534
  currentNodeId = nodeId;
1169
- currentLockTtlSec = lockTtlSec;
1535
+ currentLeaseMs = leaseMs;
1170
1536
  }
1171
1537
  function setTaskRepository(repo) {
1172
1538
  currentTaskRepo = repo;
1173
1539
  }
1174
- function isTaskRunning(taskId) {
1175
- return runningTasks.has(taskId);
1176
- }
1177
1540
  function isTimerRunning() {
1178
1541
  return tickTimer !== null;
1179
1542
  }
1180
1543
  function resetRunner() {
1181
1544
  runningTasks.clear();
1182
- if (tickTimer) {
1183
- clearInterval(tickTimer);
1184
- tickTimer = null;
1185
- }
1545
+ stopTimer();
1186
1546
  lastTickMinute = -1;
1187
- currentNodeId = "";
1188
- currentLockTtlSec = 300;
1189
1547
  currentTaskRepo = null;
1548
+ configureExecution(null);
1190
1549
  }
1191
1550
  async function sleep(ms, signal) {
1192
1551
  if (ms <= 0 || signal.aborted)
@@ -1207,167 +1566,215 @@ async function drainRunner() {
1207
1566
  for (const controller of activeRuns.values())
1208
1567
  controller.abort(new Error(schedulerM("scheduler_closing")));
1209
1568
  await Promise.allSettled([...activeRuns.keys()]);
1210
- }
1211
- function resolveRetryBackoffMs(task, attempt) {
1212
- const backoff = task.retry?.backoffMs;
1213
- if (!backoff || backoff.length === 0)
1214
- return 0;
1215
- const index = Math.min(Math.max(attempt - 2, 0), backoff.length - 1);
1216
- return backoff[index] ?? 0;
1569
+ await Promise.allSettled([...activeRepairs]);
1217
1570
  }
1218
1571
  async function tick() {
1219
- if (tickInProgress)
1572
+ const timer = tickTimer;
1573
+ const repository = currentTaskRepo;
1574
+ const executionRepo2 = currentExecutionRepo;
1575
+ if (!timer || tickInProgress === timer)
1220
1576
  return;
1221
- tickInProgress = true;
1577
+ tickInProgress = timer;
1578
+ const isCurrent = () => tickTimer === timer && currentTaskRepo === repository && currentExecutionRepo === executionRepo2;
1222
1579
  try {
1223
- const refreshed = await loadPersistedTasks(currentTaskRepo);
1224
- if (!refreshed.success) {
1225
- logger5.warn("Task definition refresh failed; skipping scheduling", { error: refreshed.error.message });
1580
+ const refreshed = await loadPersistedTasks(repository);
1581
+ if (!refreshed.success || !isCurrent())
1226
1582
  return;
1227
- }
1228
- if (!tickTimer)
1583
+ const expired = await executionRepo2?.expired();
1584
+ if (!isCurrent())
1229
1585
  return;
1230
- scheduleDueTasks();
1586
+ if (expired?.success) {
1587
+ for (const row of expired.data) {
1588
+ const task = getTask(row.taskId);
1589
+ if (!task) {
1590
+ await executionRepo2?.isolateExpired(row);
1591
+ if (!isCurrent())
1592
+ return;
1593
+ continue;
1594
+ }
1595
+ if (task && !runningTasks.has(task.id)) {
1596
+ let trigger;
1597
+ try {
1598
+ trigger = JSON.parse(row.triggerJson);
1599
+ } catch {
1600
+ await executionRepo2?.isolateExpired(row);
1601
+ if (!isCurrent())
1602
+ return;
1603
+ continue;
1604
+ }
1605
+ void runTask(task, trigger.minute, trigger, void 0, row).catch((error) => logger5.warn("Execution recovery failed", { taskId: task.id, error }));
1606
+ }
1607
+ }
1608
+ }
1609
+ const now = await executionRepo2?.currentTime();
1610
+ if (now?.success && isCurrent())
1611
+ scheduleDueTasks(now.data);
1231
1612
  } finally {
1232
- tickInProgress = false;
1613
+ if (tickInProgress === timer)
1614
+ tickInProgress = null;
1233
1615
  }
1234
1616
  }
1235
- function scheduleDueTasks() {
1236
- const now = /* @__PURE__ */ new Date();
1237
- const currentMinute = Math.floor(now.getTime() / 6e4);
1617
+ function scheduleDueTasks(databaseNow) {
1618
+ const currentMinute = Math.floor(databaseNow / 6e4);
1238
1619
  if (currentMinute === lastTickMinute)
1239
1620
  return;
1240
1621
  lastTickMinute = currentMinute;
1241
1622
  for (const [taskId, task] of getTaskRegistry()) {
1242
- if (task.enabled === false)
1623
+ if (task.enabled === false || runningTasks.has(taskId))
1243
1624
  continue;
1244
- const cron = getCron(taskId);
1245
- if (!cron || !cron.match(new Date(currentMinute * 6e4)))
1625
+ if (!getCron(taskId)?.match(new Date(currentMinute * 6e4)))
1246
1626
  continue;
1247
- if (runningTasks.has(taskId)) {
1248
- logger5.debug("Skipping task already running", { taskId, taskName: task.name });
1249
- continue;
1250
- }
1251
- void runTask(task, currentMinute, { type: "scheduled", source: null }).catch((error) => {
1252
- logger5.error("Unhandled task execution error", { taskId, error });
1253
- });
1627
+ void runTask(task, currentMinute, { type: "scheduled", source: null }).catch((error) => logger5.error("Unhandled task execution error", { taskId, error }));
1254
1628
  }
1255
1629
  }
1256
- function runTask(task, minuteTimestamp, trigger = { type: "manual", source: null }) {
1630
+ function runTask(task, minuteTimestamp, trigger = { type: "manual", source: null }, options, recovery) {
1257
1631
  const controller = new AbortController();
1258
- const run = runTaskExecution(task, minuteTimestamp, trigger, controller.signal);
1632
+ const run = runTaskExecution(task, minuteTimestamp, trigger, controller, options, recovery);
1259
1633
  activeRuns.set(run, controller);
1260
1634
  void run.finally(() => activeRuns.delete(run)).catch(() => {
1261
1635
  });
1262
1636
  return run;
1263
1637
  }
1264
- async function runTaskExecution(task, minuteTimestamp, trigger, signal) {
1265
- const hooks = getHooks();
1638
+ async function runTaskExecution(task, minute, trigger, controller, options, recovery) {
1266
1639
  const repository = currentTaskRepo;
1267
- const occurrenceId = minuteTimestamp === void 0 ? `manual:${randomUUID()}` : `scheduled:${minuteTimestamp}`;
1268
- const lockKey = minuteTimestamp !== void 0 ? `hai:scheduler:${task.id}:${minuteTimestamp}` : void 0;
1269
- if (cache.isInitialized && lockKey) {
1270
- const lockResult = await waitWithSignal(() => cache.lock.acquire(lockKey, { ttl: currentLockTtlSec, owner: currentNodeId }), signal).catch(() => null);
1271
- if (!lockResult)
1272
- return interruptTask(task, trigger, schedulerM("scheduler_closing"), {}, signal);
1273
- if (!lockResult.success) {
1274
- logger5.warn("Failed to acquire distributed lock, interrupting task", { taskId: task.id, error: lockResult.error.message });
1275
- return interruptTask(
1276
- task,
1277
- trigger,
1278
- schedulerM("scheduler_lockAcquireFailed", { params: { taskId: task.id } }),
1279
- hooks,
1280
- signal
1281
- );
1640
+ const executionRepo2 = currentExecutionRepo;
1641
+ const logRepository = currentLogRepo;
1642
+ const hooks = getHooks();
1643
+ const leaseMs = currentLeaseMs;
1644
+ const occurrenceId = recovery?.occurrenceId ?? executionId(task.id, trigger.type, minute === void 0 ? options?.idempotencyKey ?? randomUUID() : String(minute));
1645
+ const interrupted = async (reason, observationOnly = false) => {
1646
+ const log = { ...await interruptTask(task, trigger, reason, {}, controller.signal), occurrenceId };
1647
+ if (observationOnly && logRepository) {
1648
+ const saved = await logRepository.saveLog(log);
1649
+ log.persistence = saved.success ? { status: "persisted" } : { status: "unconfirmed", error: { code: String(saved.error.code), message: saved.error.message } };
1282
1650
  }
1283
- if (!lockResult.data) {
1284
- logger5.debug("Skipping task, another node holds the lock", { taskId: task.id, minuteTimestamp });
1285
- return interruptTask(
1286
- task,
1287
- trigger,
1288
- schedulerM("scheduler_lockAcquireFailed", { params: { taskId: task.id } }),
1289
- hooks,
1290
- signal
1291
- );
1651
+ return log;
1652
+ };
1653
+ if (!executionRepo2)
1654
+ return interrupted(schedulerM("scheduler_executionStateFailed"), true);
1655
+ const claimed = await executionRepo2.claim(task, occurrenceId, { ...trigger, ...minute !== void 0 ? { minute } : {} }, currentNodeId, leaseMs);
1656
+ if (!claimed.success)
1657
+ return interrupted(claimed.error.message, true);
1658
+ if (!claimed.data)
1659
+ return interrupted(schedulerM("scheduler_taskRunning", { params: { taskId: task.id } }), true);
1660
+ const row = claimed.data;
1661
+ if (!["claimed", "running"].includes(row.status)) {
1662
+ if (minute === void 0) {
1663
+ try {
1664
+ const log = row.logJson ? JSON.parse(row.logJson) : {
1665
+ id: randomUUID(),
1666
+ persistence: { status: "unconfirmed" },
1667
+ taskId: row.taskId,
1668
+ taskName: task.name,
1669
+ taskType: task.handler?.kind ?? "hook",
1670
+ triggerType: trigger.type,
1671
+ triggerSource: trigger.source,
1672
+ status: "interrupted",
1673
+ result: null,
1674
+ error: null,
1675
+ startedAt: row.createdAt,
1676
+ finishedAt: row.updatedAt,
1677
+ duration: row.updatedAt - row.createdAt
1678
+ };
1679
+ const status = row.status === "discarded" ? "interrupted" : row.status;
1680
+ return { ...log, status, error: status === "success" ? null : log.error, occurrenceId };
1681
+ } catch {
1682
+ return interrupted(schedulerM("scheduler_executionStateFailed"));
1683
+ }
1292
1684
  }
1293
- }
1294
- if (runningTasks.has(task.id)) {
1295
- return interruptTask(
1296
- task,
1297
- trigger,
1298
- schedulerM("scheduler_taskRunning", { params: { taskId: task.id } }),
1299
- hooks,
1300
- signal
1301
- );
1685
+ return interrupted(schedulerM("scheduler_occurrenceHandled", { params: { taskId: task.id } }), true);
1302
1686
  }
1303
1687
  runningTasks.add(task.id);
1688
+ let timer;
1689
+ let renewing;
1690
+ let stopped = false;
1691
+ let lost = false;
1692
+ const renew = async () => {
1693
+ try {
1694
+ const result = await executionRepo2.renew(row, leaseMs);
1695
+ lost = !result.success;
1696
+ } catch {
1697
+ lost = true;
1698
+ }
1699
+ if (lost) {
1700
+ controller.abort(new Error(schedulerM("scheduler_executionStateFailed")));
1701
+ } else if (!stopped) {
1702
+ timer = setTimeout(() => {
1703
+ renewing = renew();
1704
+ }, Math.max(1, Math.floor(leaseMs / 3)));
1705
+ }
1706
+ };
1707
+ timer = setTimeout(() => {
1708
+ renewing = renew();
1709
+ }, Math.max(1, Math.floor(leaseMs / 3)));
1304
1710
  try {
1305
- let maxAttempts = Math.max(1, task.retry?.maxAttempts ?? 1);
1306
- let log = null;
1307
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1308
- if (signal.aborted) {
1309
- log = await interruptTask(task, trigger, schedulerM("scheduler_closing"), {}, signal);
1711
+ let log;
1712
+ const attempts = [];
1713
+ for (; row.attemptCount < row.maxAttempts; ) {
1714
+ if (controller.signal.aborted)
1310
1715
  break;
1311
- }
1312
1716
  const refreshed = await loadPersistedTasks(repository);
1313
- if (!refreshed.success) {
1314
- log = await interruptTask(task, trigger, refreshed.error.message, hooks, signal);
1315
- break;
1316
- }
1317
1717
  const latest = getTask(task.id);
1318
- const stillDue = minuteTimestamp === void 0 || getCron(task.id)?.match(new Date(minuteTimestamp * 6e4));
1319
- if (!latest || latest.enabled === false || !stillDue || attempt > 1 && !isDeepStrictEqual(latest, task)) {
1320
- log = await interruptTask(task, trigger, schedulerM("scheduler_taskNoLongerDue", { params: { taskId: task.id } }), hooks, signal);
1718
+ let unchanged = false;
1719
+ try {
1720
+ unchanged = latest !== void 0 && isDeepStrictEqual(JSON.parse(JSON.stringify(latest)), JSON.parse(row.snapshot));
1721
+ } catch {
1722
+ }
1723
+ if (!refreshed.success || !latest || latest.enabled === false || !unchanged) {
1724
+ log = await interrupted(schedulerM("scheduler_taskNoLongerDue", { params: { taskId: task.id } }));
1725
+ if (row.attemptCount > 0)
1726
+ log.status = "unknown";
1321
1727
  break;
1322
1728
  }
1323
1729
  task = latest;
1324
- if (attempt === 1)
1325
- maxAttempts = Math.max(1, task.retry?.maxAttempts ?? 1);
1326
- log = await executeTask(task, trigger, hooks, signal, occurrenceId);
1327
- if (log.status !== "failed")
1730
+ if (!task.handler && !hooks.onTaskExecute) {
1731
+ log = { ...await interruptTask(task, trigger, schedulerM("scheduler_taskHandlerMissing", { params: { taskId: task.id } }), hooks, controller.signal), occurrenceId };
1732
+ break;
1733
+ }
1734
+ const begun = await executionRepo2.begin(row, leaseMs);
1735
+ if (!begun.success) {
1736
+ lost = true;
1328
1737
  break;
1329
- if (attempt >= maxAttempts)
1738
+ }
1739
+ log = await executeTask(task, trigger, hooks, controller.signal, { occurrenceId, executionToken: row.owner, sequence: row.sequence, attempt: row.attemptCount });
1740
+ log.occurrenceId = occurrenceId;
1741
+ attempts.push(log);
1742
+ if (log.status !== "failed" || !row.idempotent || row.attemptCount >= row.maxAttempts)
1330
1743
  break;
1331
- const backoffMs = resolveRetryBackoffMs(task, attempt + 1);
1332
- logger5.warn("Retrying failed task execution", {
1333
- taskId: task.id,
1334
- attempt,
1335
- maxAttempts,
1336
- backoffMs
1337
- });
1338
- await sleep(backoffMs, signal);
1744
+ const delays = task.retry?.backoffMs ?? [];
1745
+ const delay = delays[Math.min(row.attemptCount - 1, Math.max(0, delays.length - 1))] ?? 0;
1746
+ await sleep(delay, controller.signal);
1339
1747
  }
1340
- if (!log) {
1341
- return interruptTask(
1342
- task,
1343
- trigger,
1344
- schedulerM("scheduler_executionFailed", { params: { error: "Task execution produced no log" } }),
1345
- hooks,
1346
- signal
1347
- );
1748
+ if (!log)
1749
+ log = await interrupted(schedulerM("scheduler_executionStateFailed"));
1750
+ if (lost) {
1751
+ return { ...log, status: "unknown", persistence: { status: "unconfirmed" }, error: schedulerM("scheduler_executionUnknown"), occurrenceId };
1348
1752
  }
1349
- if (log.status === "failed")
1350
- logger5.warn("Task execution failed", { taskId: task.id, error: log.error });
1351
- else if (log.status === "interrupted")
1352
- logger5.debug("Task execution interrupted", { taskId: task.id, error: log.error });
1753
+ if (controller.signal.aborted && row.attemptCount > 0)
1754
+ log = { ...log, status: "unknown", error: schedulerM("scheduler_executionUnknown") };
1755
+ const currentAttempt = attempts.findIndex((attempt) => attempt.id === log.id);
1756
+ if (currentAttempt >= 0)
1757
+ attempts[currentAttempt] = log;
1353
1758
  else
1354
- logger5.debug("Task execution succeeded", { taskId: task.id, duration: log.duration });
1355
- if (task.deleteAfterRun === true && !signal.aborted && log.status === "success") {
1759
+ attempts.push(log);
1760
+ const published = attempts.map((attempt) => ({ ...attempt, persistence: { status: "pending" } }));
1761
+ const finished = await executionRepo2.finish(row, { ...log, persistence: { status: "pending" } }, published);
1762
+ if (!finished.success)
1763
+ return { ...log, status: "unknown", persistence: { status: "unconfirmed" }, error: finished.error.message, occurrenceId };
1764
+ const projected = await repairExecutionLogs(occurrenceId);
1765
+ log.persistence = projected.success ? { status: "persisted" } : { status: "pending", error: { code: String(projected.error.code), message: projected.error.message } };
1766
+ if (!projected.success)
1767
+ logger5.warn("Execution completed with pending log persistence", { taskId: task.id, occurrenceId, errorCode: projected.error.code });
1768
+ if (task.deleteAfterRun && log.status === "success") {
1356
1769
  const refreshed = await loadPersistedTasks(repository);
1357
- if (!refreshed.success || !isDeepStrictEqual(getTask(task.id), task))
1358
- return log;
1359
- const unregisterResult = await unregisterTask(task.id, repository, task);
1360
- if (!unregisterResult.success) {
1361
- logger5.warn("Failed to auto-delete one-time task after run", {
1362
- taskId: task.id,
1363
- error: unregisterResult.error.message
1364
- });
1365
- } else if (!getTask(task.id)) {
1366
- logger5.info("Auto-deleted one-time task after run", { taskId: task.id });
1367
- }
1770
+ if (refreshed.success && isDeepStrictEqual(getTask(task.id), task))
1771
+ await unregisterTask(task.id, repository, task);
1368
1772
  }
1369
1773
  return log;
1370
1774
  } finally {
1775
+ stopped = true;
1776
+ clearTimeout(timer);
1777
+ await renewing;
1371
1778
  runningTasks.delete(task.id);
1372
1779
  }
1373
1780
  }
@@ -1376,9 +1783,8 @@ function startTimer(tickInterval) {
1376
1783
  tickTimer = setInterval(tick, tickInterval);
1377
1784
  }
1378
1785
  function stopTimer() {
1379
- if (!tickTimer)
1380
- return;
1381
- clearInterval(tickTimer);
1786
+ if (tickTimer)
1787
+ clearInterval(tickTimer);
1382
1788
  tickTimer = null;
1383
1789
  }
1384
1790
 
@@ -1389,31 +1795,32 @@ var initInProgress = false;
1389
1795
  var closeInProgress = null;
1390
1796
  var taskRepo = null;
1391
1797
  var logRepo = null;
1798
+ var executionRepo = null;
1799
+ var executionIdSchema = z.string().regex(/^[a-f\d]{64}$/);
1800
+ var executionListSchema = z.object({ limit: z.number().int().min(1).max(100).default(50), offset: z.number().int().min(0).max(1e6).default(0) });
1392
1801
  var notInitialized = core.module.createNotInitializedKit(
1393
1802
  HaiSchedulerError.NOT_INITIALIZED,
1394
1803
  () => schedulerM("scheduler_notInitialized")
1395
1804
  );
1396
1805
  async function initDatabase(parsed) {
1397
- if (!parsed.enableDb)
1398
- return parsed;
1399
1806
  if (!reldb.isInitialized) {
1400
1807
  logger6.error("enableDb is true but @h-ai/reldb is not initialized");
1401
1808
  throw new Error(schedulerM("scheduler_reldbNotInitialized"));
1402
1809
  }
1403
1810
  taskRepo = new SchedulerTaskRepository(reldb);
1404
1811
  logRepo = new SchedulerLogRepository(reldb);
1812
+ executionRepo = new SchedulerExecutionRepository(reldb);
1813
+ const ready = await executionRepo.count();
1814
+ if (!ready.success)
1815
+ throw new Error(schedulerM("scheduler_executionStateFailed"));
1405
1816
  setTaskRepository(taskRepo);
1406
1817
  setLogRepository(logRepo, {
1407
1818
  maxLogs: parsed.maxLogs,
1408
1819
  retentionDays: parsed.retentionDays
1409
1820
  });
1410
1821
  const nodeId = parsed.nodeId ?? crypto.randomUUID();
1411
- const lockTtlSec = Math.ceil(parsed.lockExpireMs / 1e3);
1412
- configureLock(nodeId, lockTtlSec);
1413
- if (cache.isInitialized)
1414
- logger6.info("Distributed lock configured (cache-based)", { nodeId, lockTtlSec });
1415
- else
1416
- logger6.warn("Cache module not initialized, distributed lock will be enabled when cache becomes available");
1822
+ configureExecution(executionRepo, nodeId, parsed.leaseMs);
1823
+ logger6.info("Shared execution repository configured", { nodeId, leaseMs: parsed.leaseMs });
1417
1824
  return { ...parsed, nodeId };
1418
1825
  }
1419
1826
  function normalizeTriggerInput(options) {
@@ -1423,6 +1830,49 @@ function normalizeTriggerInput(options) {
1423
1830
  };
1424
1831
  }
1425
1832
  var scheduler = {
1833
+ execution: {
1834
+ async get(id) {
1835
+ if (!currentConfig || !executionRepo)
1836
+ return notInitialized.result();
1837
+ if (!executionIdSchema.safeParse(id).success)
1838
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
1839
+ const row = await executionRepo.byOccurrence(id);
1840
+ return row.success ? ok(row.data ? executionEntry(row.data) : null) : row;
1841
+ },
1842
+ async listUnknown(options) {
1843
+ if (!currentConfig || !executionRepo)
1844
+ return notInitialized.result();
1845
+ const parsed = executionListSchema.safeParse(options ?? {});
1846
+ if (!parsed.success)
1847
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
1848
+ const rows = await executionRepo.unknown(parsed.data.limit, parsed.data.offset);
1849
+ return rows.success ? ok(rows.data.map(executionEntry)) : rows;
1850
+ },
1851
+ async listLogFailures(options) {
1852
+ if (!currentConfig || !executionRepo)
1853
+ return notInitialized.result();
1854
+ const parsed = executionListSchema.safeParse(options ?? {});
1855
+ if (!parsed.success)
1856
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
1857
+ const rows = await executionRepo.pendingLogs(parsed.data.limit, parsed.data.offset);
1858
+ return rows.success ? ok(rows.data.map(executionEntry)) : rows;
1859
+ },
1860
+ async repairLog(id) {
1861
+ if (!currentConfig || !executionRepo || closeInProgress)
1862
+ return notInitialized.result();
1863
+ if (!executionIdSchema.safeParse(id).success)
1864
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
1865
+ return repairExecutionLogs(id);
1866
+ },
1867
+ async resolveUnknown(id, resolution) {
1868
+ if (!currentConfig || !executionRepo)
1869
+ return notInitialized.result();
1870
+ const parsed = ExecutionResolutionSchema.safeParse(resolution);
1871
+ if (!executionIdSchema.safeParse(id).success || !parsed.success)
1872
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
1873
+ return executionRepo.resolve(id, parsed.data.status, parsed.data.expectedUpdatedAt);
1874
+ }
1875
+ },
1426
1876
  async init(config) {
1427
1877
  if (initInProgress) {
1428
1878
  logger6.warn("Scheduler init already in progress, skipping concurrent call");
@@ -1558,6 +2008,9 @@ var scheduler = {
1558
2008
  async trigger(taskId, options) {
1559
2009
  if (!currentConfig)
1560
2010
  return notInitialized.result();
2011
+ const parsed = TriggerTaskSchema.safeParse(options ?? {});
2012
+ if (!parsed.success)
2013
+ return err(HaiSchedulerError.CONFIG_ERROR, schedulerM("scheduler_executionInputInvalid"));
1561
2014
  const config = currentConfig;
1562
2015
  const refreshed = await loadPersistedTasks(taskRepo);
1563
2016
  if (!refreshed.success)
@@ -1571,13 +2024,7 @@ var scheduler = {
1571
2024
  schedulerM("scheduler_taskNotFound", { params: { taskId } })
1572
2025
  );
1573
2026
  }
1574
- if (isTaskRunning(taskId)) {
1575
- return err(
1576
- HaiSchedulerError.EXECUTION_FAILED,
1577
- schedulerM("scheduler_taskRunning", { params: { taskId } })
1578
- );
1579
- }
1580
- const log = await runTask(task, void 0, normalizeTriggerInput(options));
2027
+ const log = await runTask(task, void 0, normalizeTriggerInput(parsed.data), parsed.data);
1581
2028
  return ok(log);
1582
2029
  },
1583
2030
  async getLogs(options) {
@@ -1627,6 +2074,7 @@ var scheduler = {
1627
2074
  setTaskRepository(null);
1628
2075
  taskRepo = null;
1629
2076
  logRepo = null;
2077
+ executionRepo = null;
1630
2078
  logger6.info("Scheduler module closed");
1631
2079
  })();
1632
2080
  try {
@@ -1637,6 +2085,6 @@ var scheduler = {
1637
2085
  }
1638
2086
  };
1639
2087
 
1640
- export { HaiSchedulerError, SchedulerConfigSchema, scheduler };
2088
+ export { ExecutionResolutionSchema, HaiSchedulerError, SchedulerConfigSchema, TriggerTaskSchema, scheduler };
1641
2089
  //# sourceMappingURL=index.js.map
1642
2090
  //# sourceMappingURL=index.js.map