@johpaz/hive-sdk 0.1.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/CHANGELOG.md +441 -0
  2. package/README.md +21 -3
  3. package/package.json +27 -5
  4. package/packages/core/src/agent/acceptance-checks.ts +9 -9
  5. package/packages/core/src/agent/agent-catalog.ts +82 -25
  6. package/packages/core/src/agent/agent-loop.ts +115 -26
  7. package/packages/core/src/agent/capability-search.ts +2 -2
  8. package/packages/core/src/agent/catalog-selector.ts +4 -4
  9. package/packages/core/src/agent/compaction.ts +30 -10
  10. package/packages/core/src/agent/context-compiler.ts +63 -36
  11. package/packages/core/src/agent/conversation-store.ts +167 -9
  12. package/packages/core/src/agent/curator.ts +16 -7
  13. package/packages/core/src/agent/delegation-runtime.ts +5 -5
  14. package/packages/core/src/agent/goal-runner.ts +9 -9
  15. package/packages/core/src/agent/index.ts +1 -0
  16. package/packages/core/src/agent/llm-client.ts +98 -37
  17. package/packages/core/src/agent/llm-providers/anthropic.ts +4 -4
  18. package/packages/core/src/agent/llm-providers/deepseek.ts +1 -1
  19. package/packages/core/src/agent/llm-providers/gemini.ts +4 -4
  20. package/packages/core/src/agent/llm-providers/groq.ts +1 -1
  21. package/packages/core/src/agent/llm-providers/hiveagents.ts +3 -3
  22. package/packages/core/src/agent/llm-providers/interface.ts +2 -2
  23. package/packages/core/src/agent/llm-providers/kimi.ts +1 -1
  24. package/packages/core/src/agent/llm-providers/minimax.ts +1 -1
  25. package/packages/core/src/agent/llm-providers/mistral.ts +1 -1
  26. package/packages/core/src/agent/llm-providers/modelscope.ts +1 -1
  27. package/packages/core/src/agent/llm-providers/nvidia.ts +40 -1
  28. package/packages/core/src/agent/llm-providers/ollama.ts +4 -4
  29. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +47 -7
  30. package/packages/core/src/agent/llm-providers/openai.ts +1 -1
  31. package/packages/core/src/agent/llm-providers/opencode-go.ts +1 -1
  32. package/packages/core/src/agent/llm-providers/openrouter.ts +1 -1
  33. package/packages/core/src/agent/llm-providers/qwen.ts +1 -1
  34. package/packages/core/src/agent/llm-providers/z-ai.ts +1 -1
  35. package/packages/core/src/agent/mcp-result-normalizer.ts +192 -0
  36. package/packages/core/src/agent/playbook-selector.ts +22 -7
  37. package/packages/core/src/agent/prompt-builder.ts +5 -5
  38. package/packages/core/src/agent/proof-packet.ts +5 -5
  39. package/packages/core/src/agent/providers/index.ts +39 -4
  40. package/packages/core/src/agent/realtime-providers/gemini-live.ts +238 -0
  41. package/packages/core/src/agent/realtime-providers/index.ts +29 -0
  42. package/packages/core/src/agent/realtime-providers/interface.ts +108 -0
  43. package/packages/core/src/agent/reflector.ts +38 -15
  44. package/packages/core/src/agent/run-store.ts +8 -8
  45. package/packages/core/src/agent/service.ts +10 -10
  46. package/packages/core/src/agent/skill-selector.ts +6 -6
  47. package/packages/core/src/agent/thread-id.ts +71 -0
  48. package/packages/core/src/agent/thread-store.ts +293 -0
  49. package/packages/core/src/agent/tool-selector.ts +9 -5
  50. package/packages/core/src/agent/tracer.ts +5 -5
  51. package/packages/core/src/api/createAgent.ts +67 -2
  52. package/packages/core/src/artifacts/index.ts +15 -0
  53. package/packages/core/src/artifacts/store.ts +161 -5
  54. package/packages/core/src/canvas/emitter.ts +2 -2
  55. package/packages/core/src/canvas/index.ts +9 -0
  56. package/packages/core/src/channels/telegram.ts +1 -1
  57. package/packages/core/src/channels/webchat.ts +1 -1
  58. package/packages/core/src/config/loader.ts +14 -5
  59. package/packages/core/src/ethics/EthicsGuard.ts +7 -1
  60. package/packages/core/src/events/agent-bus.ts +3 -3
  61. package/packages/core/src/events/channel-narration.ts +3 -3
  62. package/packages/core/src/events/event-bus.ts +1 -1
  63. package/packages/core/src/events/index.ts +18 -0
  64. package/packages/core/src/events/narration.ts +3 -3
  65. package/packages/core/src/events/tool-narration.ts +4 -0
  66. package/packages/core/src/gateway/channel-notify.ts +103 -6
  67. package/packages/core/src/gateway/delegation-groups.ts +4 -4
  68. package/packages/core/src/gateway/durable-queue.ts +18 -6
  69. package/packages/core/src/gateway/index.ts +3 -0
  70. package/packages/core/src/gateway/job-store.ts +11 -5
  71. package/packages/core/src/gateway/notification-inbox.ts +2 -2
  72. package/packages/core/src/gateway/server.ts +2 -2
  73. package/packages/core/src/harness/executors.ts +493 -0
  74. package/packages/core/src/harness/index.ts +12 -2
  75. package/packages/core/src/hooks/index.ts +203 -0
  76. package/packages/core/src/images/index.ts +161 -0
  77. package/packages/core/src/index.ts +1 -0
  78. package/packages/core/src/mcp/MCPClient.ts +3 -3
  79. package/packages/core/src/mcp/hot-reload.ts +5 -5
  80. package/packages/core/src/mcp/tool-sync.ts +5 -5
  81. package/packages/core/src/mcp/transports/index.ts +2 -2
  82. package/packages/core/src/mcp/transports/sse.ts +1 -1
  83. package/packages/core/src/models/index.ts +36 -0
  84. package/packages/core/src/multimodal/index.ts +2 -2
  85. package/packages/core/src/multimodal/vision-service.ts +51 -19
  86. package/packages/core/src/plugins/loader.ts +4 -1
  87. package/packages/core/src/resilience/circuit-breaker.ts +16 -5
  88. package/packages/core/src/resilience/index.ts +13 -0
  89. package/packages/core/src/resilience/retry.ts +1 -1
  90. package/packages/core/src/scheduler/CronScheduler.ts +54 -27
  91. package/packages/core/src/scheduler/cron/expression.ts +165 -0
  92. package/packages/core/src/scheduler/cron/index.ts +10 -0
  93. package/packages/core/src/scheduler/cron/job.ts +339 -0
  94. package/packages/core/src/scheduler/cron/next-run.ts +121 -0
  95. package/packages/core/src/scheduler/cron/zoned-time.ts +138 -0
  96. package/packages/core/src/scheduler/index.ts +21 -3
  97. package/packages/core/src/scheduler/integration.ts +25 -14
  98. package/packages/core/src/scheduler/types.ts +3 -18
  99. package/packages/core/src/services/agents.ts +268 -0
  100. package/packages/core/src/services/cron.ts +257 -0
  101. package/packages/core/src/services/endpoints.ts +289 -0
  102. package/packages/core/src/services/ethics.ts +107 -0
  103. package/packages/core/src/services/images.ts +212 -0
  104. package/packages/core/src/services/index.ts +112 -0
  105. package/packages/core/src/services/mcp.ts +201 -0
  106. package/packages/core/src/services/memory.ts +133 -0
  107. package/packages/core/src/services/models.ts +179 -0
  108. package/packages/core/src/services/providers.ts +152 -0
  109. package/packages/core/src/services/setup.ts +222 -0
  110. package/packages/core/src/services/skills.ts +241 -0
  111. package/packages/core/src/services/swarms.ts +307 -0
  112. package/packages/core/src/services/tools.ts +106 -0
  113. package/packages/core/src/sessions/index.ts +268 -0
  114. package/packages/core/src/sessions/resolve.ts +108 -0
  115. package/packages/core/src/skills/SkillLoader.ts +8 -1
  116. package/packages/core/src/skills/bundled/artifacts/artifact_reader/SKILL.md +105 -0
  117. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +21 -11
  118. package/packages/core/src/skills/bundled/images/image_editor/SKILL.md +120 -0
  119. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +12 -3
  120. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +22 -7
  121. package/packages/core/src/skills/bundled-data.generated.ts +110 -12
  122. package/packages/core/src/storage/bootstrap.ts +107 -12
  123. package/packages/core/src/storage/causal-events.ts +1 -1
  124. package/packages/core/src/storage/collections.ts +138 -2
  125. package/packages/core/src/storage/crypto.ts +35 -4
  126. package/packages/core/src/storage/hive.ts +1 -1
  127. package/packages/core/src/storage/hivedb.ts +10 -1
  128. package/packages/core/src/storage/index.ts +2 -1
  129. package/packages/core/src/storage/onboarding.ts +61 -45
  130. package/packages/core/src/storage/reconcile.ts +11 -6
  131. package/packages/core/src/storage/seed.ts +191 -23
  132. package/packages/core/src/storage/usage.ts +3 -3
  133. package/packages/core/src/swarm/AgentExecutor.ts +2 -2
  134. package/packages/core/src/swarm/Coordinator.ts +8 -8
  135. package/packages/core/src/swarm/EventBridge.ts +2 -2
  136. package/packages/core/src/swarm/RoleSwarm.ts +234 -0
  137. package/packages/core/src/swarm/TaskGraph.ts +2 -2
  138. package/packages/core/src/swarm/index.ts +7 -0
  139. package/packages/core/src/swarm/presets/HiveLearnPreset.ts +2 -2
  140. package/packages/core/src/swarm/presets/ResearchPreset.ts +2 -2
  141. package/packages/core/src/swarm/strategies/ParallelStrategy.ts +1 -1
  142. package/packages/core/src/swarm/strategies/PriorityStrategy.ts +3 -3
  143. package/packages/core/src/swarm/types.ts +3 -18
  144. package/packages/core/src/tool-runtime/embedded-worker.generated.ts +21 -0
  145. package/packages/core/src/tool-runtime/index.ts +129 -14
  146. package/packages/core/src/tools/ToolExecutor.ts +7 -3
  147. package/packages/core/src/tools/agents/index.ts +18 -60
  148. package/packages/core/src/tools/cli/index.ts +55 -0
  149. package/packages/core/src/tools/core/index.ts +52 -4
  150. package/packages/core/src/tools/cron/index.ts +8 -8
  151. package/packages/core/src/tools/images/index.ts +130 -0
  152. package/packages/core/src/tools/index.ts +14 -1
  153. package/packages/core/src/tools/office/office-escribir-xlsx.ts +2 -1
  154. package/packages/core/src/tools/office/office-leer-xlsx.ts +2 -1
  155. package/packages/core/src/tools/office/xlsx-loader.ts +19 -0
  156. package/packages/core/src/tools/web/artifact-inspect.ts +2 -2
  157. package/packages/core/src/tools/web/artifact-read.ts +162 -0
  158. package/packages/core/src/tools/web/browser-backend.ts +141 -44
  159. package/packages/core/src/tools/web/browser-click.ts +2 -2
  160. package/packages/core/src/tools/web/browser-extract.ts +2 -2
  161. package/packages/core/src/tools/web/browser-navigate.ts +2 -2
  162. package/packages/core/src/tools/web/browser-screenshot.ts +12 -5
  163. package/packages/core/src/tools/web/browser-script.ts +2 -2
  164. package/packages/core/src/tools/web/browser-service.ts +63 -384
  165. package/packages/core/src/tools/web/browser-session.ts +125 -0
  166. package/packages/core/src/tools/web/browser-type.ts +2 -2
  167. package/packages/core/src/tools/web/browser-wait.ts +2 -2
  168. package/packages/core/src/tools/web/computer-use.ts +553 -0
  169. package/packages/core/src/tools/web/index.ts +8 -1
  170. package/packages/core/src/tools/web/webview-backend.ts +460 -21
  171. package/packages/core/src/utils/index.ts +1 -0
  172. package/packages/core/src/utils/logger.ts +12 -4
  173. package/packages/core/src/utils/redact-binary.ts +17 -0
  174. package/packages/core/src/utils/toon.ts +1 -1
  175. package/packages/core/src/voice/index.ts +6 -6
  176. package/bun.lock +0 -859
  177. package/bunfig.toml +0 -9
  178. package/docs/API-AGENTS.md +0 -367
  179. package/docs/API-CONTEXT-COMPILER.md +0 -249
  180. package/docs/API-DAG-SCHEDULER.md +0 -273
  181. package/docs/API-TOOLS-SKILLS-CHANNELS.md +0 -446
  182. package/docs/API-WORKERS-EVENTS.md +0 -299
  183. package/docs/HIVE-HARNESS.md +0 -113
  184. package/docs/INDEX.md +0 -190
  185. package/docs/TEMPLATE-HIVE-APP.md +0 -360
  186. package/packages/cli/package.json +0 -17
  187. package/packages/cli/src/commands/create-app.test.ts +0 -180
  188. package/packages/core/package.json +0 -70
  189. package/packages/core/src/api/createAgent.test.ts +0 -160
  190. package/packages/core/src/canvas/canvas.test.ts +0 -36
  191. package/packages/core/src/channels/channels.test.ts +0 -18
  192. package/packages/core/src/ethics/EthicsGuard.test.ts +0 -108
  193. package/packages/core/src/gateway/gateway.test.ts +0 -38
  194. package/packages/core/src/memory/Scratchpad.test.ts +0 -68
  195. package/packages/core/src/scheduler/scheduler.test.ts +0 -15
  196. package/packages/core/src/skills/skills.test.ts +0 -62
  197. package/packages/core/src/swarm/swarm.test.ts +0 -24
  198. package/packages/core/src/tool-runtime/tool-runtime.test.ts +0 -99
  199. package/packages/core/src/tools/ToolRegistry.test.ts +0 -98
  200. package/packages/core/src/tools/api/api-request.test.ts +0 -164
  201. package/packages/core/src/tools/web/browser-service.test.ts +0 -83
  202. package/packages/core/src/workers/workers.test.ts +0 -41
  203. package/scripts/bump-version.ts +0 -248
  204. package/scripts/generate-skill-bundle.ts +0 -108
  205. package/test/acceptance-checks.test.ts +0 -403
  206. package/test/agent-loop-terminal-synthesis.test.ts +0 -32
  207. package/test/browser-backend.test.ts +0 -308
  208. package/test/catalog-agents-stay-enabled.test.ts +0 -117
  209. package/test/causal-events.test.ts +0 -117
  210. package/test/compaction.test.ts +0 -105
  211. package/test/context-compiler.test.ts +0 -269
  212. package/test/curator.test.ts +0 -130
  213. package/test/durable-queue.test.ts +0 -114
  214. package/test/harness-barrel.test.ts +0 -64
  215. package/test/hive-helpers.test.ts +0 -130
  216. package/test/hivedb-search.test.ts +0 -189
  217. package/test/internal-turns.test.ts +0 -166
  218. package/test/job-idempotency.test.ts +0 -68
  219. package/test/job-retry-backoff.test.ts +0 -184
  220. package/test/job-store.test.ts +0 -381
  221. package/test/llm-retry.test.ts +0 -97
  222. package/test/memory-perf.test.ts +0 -774
  223. package/test/minimal-loadout.test.ts +0 -78
  224. package/test/model-catalog.test.ts +0 -105
  225. package/test/preload.ts +0 -12
  226. package/test/reflector.test.ts +0 -320
  227. package/test/retention-cap.test.ts +0 -91
  228. package/test/retired-capabilities-pruned.test.ts +0 -192
  229. package/test/run-store.test.ts +0 -355
  230. package/test/scratchpad.test.ts +0 -74
  231. package/test/secrets-durability.test.ts +0 -119
  232. package/test/seed-model-reseed.test.ts +0 -155
  233. package/test/setup-agent-seed.test.ts +0 -264
  234. package/test/tool-inventory.test.ts +0 -65
  235. package/test/tool-runtime.test.ts +0 -258
  236. package/test/tool-selector-runtime-tools.test.ts +0 -117
  237. package/test/toon.test.ts +0 -429
  238. package/tsconfig.json +0 -42
@@ -1,16 +1,27 @@
1
1
  /**
2
2
  * Hive CronScheduler
3
3
  *
4
- * Croner-based scheduler for Hive with HiveDB persistence.
5
- * Manages recurring and one-shot cron jobs that execute through the agent pipeline.
4
+ * Manages recurring and one-shot cron jobs that execute through the agent pipeline,
5
+ * persisted in HiveDB.
6
+ *
7
+ * El motor de cron es propio (`./cron`), sin dependencias: sólo `setTimeout` e
8
+ * `Intl` del runtime. Antes era `croner`.
9
+ *
10
+ * `Bun.cron()` no sirve como reemplazo —se evaluó contra el runtime 1.4.0—:
11
+ * acepta sólo 5 campos y rechaza el sexto, no admite una fecha ISO como patrón
12
+ * (que es como se agendan los jobs `one_shot`), ignora la zona horaria, y su
13
+ * handle no expone la próxima corrida, que es de donde sale `next_run_at` y con
14
+ * lo que se detectan las corridas perdidas al arrancar. Tampoco tiene
15
+ * equivalente de `protect`, `maxRuns`, `interval`, `startAt`/`stopAt` ni
16
+ * `domAndDow`, todos campos persistidos de `CronJobDoc`.
6
17
  */
7
18
 
8
- import { Cron } from "croner";
9
- import { logger } from "../utils/logger";
10
- import { notifyTaskCompletion } from "./integration";
11
- import { col, toIndexable, fromIndexable } from "../storage/hive";
12
- import type { CronJobDoc, TaskRunDoc } from "../storage/collections";
13
- import { expireArtifacts } from "../artifacts/store";
19
+ import { Cron } from "./cron/index.ts";
20
+ import { logger } from "../utils/logger.ts";
21
+ import { notifyTaskCompletion } from "./integration.ts";
22
+ import { col, toIndexable, fromIndexable } from "../storage/hive.ts";
23
+ import type { CronJobDoc, TaskRunDoc } from "../storage/collections.ts";
24
+ import { expireArtifacts } from "../artifacts/store.ts";
14
25
  import type {
15
26
  CronJob,
16
27
  TaskRun,
@@ -18,7 +29,7 @@ import type {
18
29
  UpdateCronJobInput,
19
30
  CronJobStatus,
20
31
  CronJobExecutionHandler,
21
- } from "./types";
32
+ } from "./types.ts";
22
33
 
23
34
  const log = logger.child("CronScheduler");
24
35
 
@@ -76,7 +87,7 @@ export class CronScheduler {
76
87
 
77
88
  if (misfirePolicy === "fire_once" && withinGrace) {
78
89
  log.info(`[boot:misfire] Job "${task.name}" (${task.id}) misfired at ${misfireTime.toISOString()} — executing now (fire_once, within grace)`);
79
- // Activate the job first (so the Croner handle exists for future runs)
90
+ // Activate the job first (so it stays scheduled for future runs)
80
91
  await this.activate(task);
81
92
  // Then execute it immediately
82
93
  this.execute(task.id).catch((err) => {
@@ -100,7 +111,7 @@ export class CronScheduler {
100
111
  });
101
112
  await this.activate(task);
102
113
  } else {
103
- // skip policy — recurring re-schedules its next occurrence via Croner
114
+ // skip policy — recurring re-schedules its next occurrence on its own
104
115
  log.info(`[boot:misfire] Job "${task.name}" (${task.id}) misfired at ${misfireTime.toISOString()} — skipping (misfire_policy=skip)`);
105
116
  await this.updateJob(task.id, {
106
117
  last_error: `Missed run at ${misfireTime.toISOString()} (policy: skip)`,
@@ -120,7 +131,7 @@ export class CronScheduler {
120
131
  }
121
132
 
122
133
  /**
123
- * Activate a cron job - create or recreate its Croner instance
134
+ * Activate a cron job - create or recreate its scheduled instance
124
135
  */
125
136
  async activate(task: CronJob): Promise<void> {
126
137
  const existingJob = this.jobs.get(task.id);
@@ -218,13 +229,29 @@ export class CronScheduler {
218
229
  }
219
230
  }
220
231
 
221
- /** Read-modify-write helper for cron_jobs partial updates, retrying on OCC conflict. */
222
- private async updateJob(taskId: string, patch: Partial<CronJobDoc>): Promise<CronJobDoc | null> {
232
+ /**
233
+ * Read-modify-write helper for cron_jobs partial updates, retrying on OCC conflict.
234
+ *
235
+ * El parche puede ser una función, y para los contadores **tiene que serlo**:
236
+ * un objeto fijo se calcula una sola vez, fuera del bucle, así que al
237
+ * reintentar por conflicto de versión se vuelve a escribir el valor viejo y
238
+ * el incremento se pierde. Con dos ejecuciones solapadas del mismo job —algo
239
+ * normal en uno que tarda más que su intervalo y no declara `protect`— ambas
240
+ * leen `error_count: 4` y ambas escriben 5: el job nunca llega al umbral de
241
+ * auto-pausa y sigue fallando para siempre.
242
+ */
243
+ private async updateJob(
244
+ taskId: string,
245
+ patch: Partial<CronJobDoc> | ((actual: CronJobDoc) => Partial<CronJobDoc>),
246
+ ): Promise<CronJobDoc | null> {
223
247
  const cronJobsCol = await col<CronJobDoc>("cronJobs");
224
248
  for (let attempt = 0; attempt < 5; attempt++) {
225
249
  const existing = await cronJobsCol.get(taskId);
226
250
  if (!existing) return null;
227
- const merged = { ...existing.doc, ...patch };
251
+ const merged = {
252
+ ...existing.doc,
253
+ ...(typeof patch === "function" ? patch(existing.doc) : patch),
254
+ };
228
255
  try {
229
256
  await cronJobsCol.put(taskId, merged, { expectedVersion: existing.version });
230
257
  return merged;
@@ -284,11 +311,11 @@ export class CronScheduler {
284
311
  agent_response: result.response?.slice(0, 1000) || null,
285
312
  });
286
313
 
287
- const refreshed = await this.updateJob(task.id, {
288
- run_count: task.run_count + 1,
314
+ const refreshed = await this.updateJob(task.id, (actual) => ({
315
+ run_count: actual.run_count + 1,
289
316
  last_run_at: finishedAt,
290
317
  last_error: null,
291
- });
318
+ }));
292
319
 
293
320
  const job = this.jobs.get(task.id);
294
321
  if (job) {
@@ -322,10 +349,10 @@ export class CronScheduler {
322
349
  error_message: errorMessage,
323
350
  });
324
351
 
325
- const updated = await this.updateJob(task.id, {
326
- error_count: task.error_count + 1,
352
+ const updated = await this.updateJob(task.id, (actual) => ({
353
+ error_count: actual.error_count + 1,
327
354
  last_error: errorMessage,
328
- });
355
+ }));
329
356
 
330
357
  log.error(`[execute] Job "${task.name}" (${task.id}) failed: ${errorMessage}`);
331
358
 
@@ -358,12 +385,12 @@ export class CronScheduler {
358
385
  }
359
386
 
360
387
  /**
361
- * Handle errors from Croner
388
+ * Handle errors raised by the scheduled job
362
389
  */
363
390
  private handleError(task: CronJob, error: Error): void {
364
391
  log.error(`[error] Job "${task.name}" (${task.id}) error: ${error.message}`);
365
392
 
366
- // Fix 3: record Croner-level errors in task_runs for full history
393
+ // Fix 3: record scheduler-level errors in task_runs for full history
367
394
  Promise.resolve().then(async () => {
368
395
  const runId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
369
396
  const now = new Date().toISOString();
@@ -384,10 +411,10 @@ export class CronScheduler {
384
411
  log.warn(`[handleError] Failed to insert task_run: ${(e as Error).message}`);
385
412
  }
386
413
 
387
- await this.updateJob(task.id, {
388
- error_count: task.error_count + 1,
414
+ await this.updateJob(task.id, (actual) => ({
415
+ error_count: actual.error_count + 1,
389
416
  last_error: error.message,
390
- });
417
+ }));
391
418
  });
392
419
  }
393
420
 
@@ -431,7 +458,7 @@ export class CronScheduler {
431
458
  }
432
459
 
433
460
  /**
434
- * Deactivate a cron job - stop Croner instance but keep in DB
461
+ * Deactivate a cron job - stop the scheduled instance but keep it in DB
435
462
  */
436
463
  deactivate(taskId: string): void {
437
464
  const job = this.jobs.get(taskId);
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Parseo de expresiones cron.
3
+ *
4
+ * Acepta 5 campos (`min hora dom mes dow`) y 6 (con segundos adelante), que es
5
+ * lo que aceptaba croner y por lo tanto lo que puede haber guardado en
6
+ * `cronJobDoc.cron_expression` de instalaciones anteriores. `Bun.cron` sólo
7
+ * admite 5 y rechaza el sexto con un error, así que no sirve como reemplazo
8
+ * directo.
9
+ *
10
+ * Cada campo se expande a la lista ordenada de valores que casa. Expandir por
11
+ * adelantado —en vez de evaluar la expresión en cada comparación— hace que
12
+ * buscar la próxima corrida sea recorrer números, y es lo que permite saltar de
13
+ * un match al siguiente sin probar minuto por minuto.
14
+ */
15
+
16
+ export interface CronFields {
17
+ second: number[]
18
+ minute: number[]
19
+ hour: number[]
20
+ dayOfMonth: number[]
21
+ /** 1-12. */
22
+ month: number[]
23
+ /** 0-6, 0 = domingo. */
24
+ dayOfWeek: number[]
25
+ /** `false` cuando el campo es `*`: no restringe qué días casan. */
26
+ domRestricted: boolean
27
+ dowRestricted: boolean
28
+ /** La expresión trae campo de segundos. */
29
+ hasSeconds: boolean
30
+ }
31
+
32
+ const MESES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]
33
+ const DIAS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"]
34
+
35
+ interface Rango {
36
+ min: number
37
+ max: number
38
+ nombre: string
39
+ /** Alias por nombre (ENE, LUN…), en minúsculas. */
40
+ nombres?: string[]
41
+ }
42
+
43
+ function traducirNombre(texto: string, rango: Rango): string {
44
+ if (!rango.nombres) return texto
45
+ const i = rango.nombres.indexOf(texto.toLowerCase())
46
+ return i >= 0 ? String(i + rango.min) : texto
47
+ }
48
+
49
+ function entero(texto: string, rango: Rango, expr: string): number {
50
+ const n = Number(texto)
51
+ if (!Number.isInteger(n)) {
52
+ throw new Error(`campo ${rango.nombre}: "${texto}" no es un número (en "${expr}")`)
53
+ }
54
+ return n
55
+ }
56
+
57
+ /** Expande un campo (`*`, `5`, `1-5`, `*​/2`, `1-9/3`, `a,b`) a sus valores. */
58
+ function expandirCampo(campo: string, rango: Rango, expr: string): { valores: number[]; restringe: boolean } {
59
+ // `?` es sinónimo de `*` en los dialectos que lo traen; aceptarlo evita
60
+ // rechazar expresiones que en otro scheduler funcionaban.
61
+ const texto = campo.trim() === "?" ? "*" : campo.trim()
62
+ if (texto === "") throw new Error(`campo ${rango.nombre} vacío (en "${expr}")`)
63
+
64
+ const valores = new Set<number>()
65
+ let restringe = false
66
+
67
+ for (const parte of texto.split(",")) {
68
+ const [base, pasoTexto] = parte.split("/")
69
+ if (pasoTexto !== undefined && parte.split("/").length > 2) {
70
+ throw new Error(`campo ${rango.nombre}: "${parte}" tiene más de un "/" (en "${expr}")`)
71
+ }
72
+
73
+ const paso = pasoTexto === undefined ? 1 : entero(pasoTexto, rango, expr)
74
+ if (paso < 1) throw new Error(`campo ${rango.nombre}: el paso debe ser ≥ 1 (en "${expr}")`)
75
+
76
+ let desde: number
77
+ let hasta: number
78
+
79
+ if (base === "*" || base === "") {
80
+ desde = rango.min
81
+ hasta = rango.max
82
+ } else if (base.includes("-")) {
83
+ restringe = true
84
+ const [a, b] = base.split("-")
85
+ desde = entero(traducirNombre(a, rango), rango, expr)
86
+ hasta = entero(traducirNombre(b, rango), rango, expr)
87
+ } else {
88
+ restringe = true
89
+ desde = entero(traducirNombre(base, rango), rango, expr)
90
+ // `5/15` significa "desde 5, cada 15" — sin paso es un valor suelto.
91
+ hasta = pasoTexto === undefined ? desde : rango.max
92
+ }
93
+
94
+ for (const v of [desde, hasta]) {
95
+ if (v < rango.min || v > rango.max) {
96
+ throw new Error(
97
+ `campo ${rango.nombre}: ${v} fuera de rango ${rango.min}-${rango.max} (en "${expr}")`,
98
+ )
99
+ }
100
+ }
101
+
102
+ if (desde > hasta) {
103
+ // Un rango que da la vuelta (`22-4` en horas) se lee como las dos puntas.
104
+ for (let v = desde; v <= rango.max; v += paso) valores.add(v)
105
+ for (let v = rango.min; v <= hasta; v += paso) valores.add(v)
106
+ } else {
107
+ for (let v = desde; v <= hasta; v += paso) valores.add(v)
108
+ }
109
+ }
110
+
111
+ return { valores: [...valores].sort((a, b) => a - b), restringe }
112
+ }
113
+
114
+ /**
115
+ * Parsea la expresión. Lanza con un mensaje que dice **qué campo** falló y por
116
+ * qué: el mensaje va a parar a la respuesta de `cron.create`, así que lo lee el
117
+ * modelo —o la persona— que se equivocó al escribirla.
118
+ */
119
+ export function parseCronExpression(expr: string): CronFields {
120
+ const campos = expr.trim().split(/\s+/)
121
+ if (campos.length !== 5 && campos.length !== 6) {
122
+ throw new Error(
123
+ `una expresión cron lleva 5 campos (min hora día mes día-semana) o 6 con segundos adelante; ` +
124
+ `"${expr}" tiene ${campos.length}`,
125
+ )
126
+ }
127
+
128
+ const hasSeconds = campos.length === 6
129
+ const [seg, min, hora, dom, mes, dow] = hasSeconds
130
+ ? campos
131
+ : ["0", ...campos]
132
+
133
+ const segundo = expandirCampo(seg, { min: 0, max: 59, nombre: "segundos" }, expr)
134
+ const minuto = expandirCampo(min, { min: 0, max: 59, nombre: "minutos" }, expr)
135
+ const horas = expandirCampo(hora, { min: 0, max: 23, nombre: "horas" }, expr)
136
+ const diaMes = expandirCampo(dom, { min: 1, max: 31, nombre: "día del mes" }, expr)
137
+ const meses = expandirCampo(mes, { min: 1, max: 12, nombre: "mes", nombres: MESES }, expr)
138
+ const diaSemana = expandirCampo(dow, { min: 0, max: 7, nombre: "día de la semana", nombres: DIAS }, expr)
139
+
140
+ // 7 y 0 son ambos domingo: se normaliza a 0 para que `0-7` no deje un valor
141
+ // que después no casa con ningún `getUTCDay()`.
142
+ const dowNormalizado = [...new Set(diaSemana.valores.map((d) => (d === 7 ? 0 : d)))].sort((a, b) => a - b)
143
+
144
+ return {
145
+ second: segundo.valores,
146
+ minute: minuto.valores,
147
+ hour: horas.valores,
148
+ dayOfMonth: diaMes.valores,
149
+ month: meses.valores,
150
+ dayOfWeek: dowNormalizado,
151
+ domRestricted: diaMes.restringe,
152
+ dowRestricted: diaSemana.restringe,
153
+ hasSeconds,
154
+ }
155
+ }
156
+
157
+ /** `true` si la expresión parsea. Para validar sin construir un job. */
158
+ export function isValidCronExpression(expr: string): boolean {
159
+ try {
160
+ parseCronExpression(expr)
161
+ return true
162
+ } catch {
163
+ return false
164
+ }
165
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Motor de cron propio, sin dependencias: sólo el runtime de Bun.
3
+ *
4
+ * Reemplaza a `croner`. Ver `job.ts` para por qué tampoco se usa `Bun.cron()`.
5
+ */
6
+
7
+ export { Cron, type CronOptions, type CronFunction } from "./job.ts"
8
+ export { parseCronExpression, isValidCronExpression, type CronFields } from "./expression.ts"
9
+ export { nextOccurrence, type NextOccurrenceOptions } from "./next-run.ts"
10
+ export { toWallClock, toInstant, assertTimeZone, type WallClock } from "./zoned-time.ts"
@@ -0,0 +1,339 @@
1
+ /**
2
+ * `Cron` — un job agendado, sin dependencias.
3
+ *
4
+ * Reemplaza a `croner` conservando la superficie que el SDK ya usaba
5
+ * (`nextRun`, `pause`, `resume`, `stop`, y las opciones `timezone`, `protect`,
6
+ * `catch`, `name`, `domAndDow`, `maxRuns`, `interval`, `startAt`, `stopAt`),
7
+ * porque esas opciones son campos persistidos de `CronJobDoc` y cambiarlas
8
+ * habría sido migrar la base para no ganar nada.
9
+ *
10
+ * No usa `Bun.cron()`: ese sólo acepta 5 campos, no admite una fecha ISO como
11
+ * patrón —que es como se agendan los jobs `one_shot`—, ignora la zona horaria y
12
+ * su handle no expone la próxima corrida, que es de donde sale `next_run_at` y
13
+ * con lo que el scheduler detecta las corridas perdidas al arrancar. Lo que sí
14
+ * se usa de Bun es el runtime pelado: `setTimeout` e `Intl`.
15
+ */
16
+
17
+ import { parseCronExpression, type CronFields } from "./expression.ts"
18
+ import { nextOccurrence } from "./next-run.ts"
19
+ import { assertTimeZone } from "./zoned-time.ts"
20
+
21
+ /**
22
+ * Tope de `setTimeout`.
23
+ *
24
+ * Un delay mayor a 2^31-1 ms desborda a 32 bits y el timer dispara **de
25
+ * inmediato**, no tarde. Un job anual programado de una sola vez correría al
26
+ * instante, y otra vez, y otra: es el error clásico de escribir un scheduler a
27
+ * mano. Los esperas largas se encadenan en tramos de ~24 días.
28
+ */
29
+ const MAX_DELAY_MS = 2_147_483_647
30
+
31
+ export type CronFunction = (job: Cron) => void | Promise<void>
32
+
33
+ export interface CronOptions {
34
+ /** Zona IANA en la que se interpreta la expresión. Por defecto, UTC. */
35
+ timezone?: string
36
+ /** No arrancar una corrida si la anterior sigue en curso. */
37
+ protect?: boolean
38
+ /** Qué hacer si la función lanza. Una función la recibe; `true` la traga. */
39
+ catch?: boolean | ((error: Error, job: Cron) => void)
40
+ name?: string
41
+ /** Exigir que casen día del mes **y** día de semana, en vez de cualquiera. */
42
+ domAndDow?: boolean
43
+ /** Dejar de correr después de estas corridas. */
44
+ maxRuns?: number
45
+ /** Segundos mínimos entre el arranque de una corrida y la siguiente. */
46
+ interval?: number
47
+ /** No correr antes de esta fecha. */
48
+ startAt?: string | Date
49
+ /** No correr después de esta fecha. */
50
+ stopAt?: string | Date
51
+ /** Nace pausado. */
52
+ paused?: boolean
53
+ }
54
+
55
+ function aFecha(valor: string | Date | undefined, campo: string): Date | null {
56
+ if (valor === undefined || valor === null) return null
57
+ const d = valor instanceof Date ? valor : new Date(valor)
58
+ if (Number.isNaN(d.getTime())) throw new Error(`${campo} no es una fecha válida: "${String(valor)}"`)
59
+ return d
60
+ }
61
+
62
+ export class Cron {
63
+ /** El patrón tal como se recibió. */
64
+ readonly pattern: string
65
+ readonly name: string
66
+
67
+ private readonly fields: CronFields | null
68
+ /** Instante único, cuando el patrón es una fecha en vez de una expresión. */
69
+ private readonly fireAt: Date | null
70
+ private readonly fn: CronFunction | null
71
+ private readonly timeZone: string
72
+ private readonly protect: boolean
73
+ private readonly onError: boolean | ((error: Error, job: Cron) => void)
74
+ private readonly domAndDow: boolean
75
+ private readonly maxRuns: number | null
76
+ private readonly intervalMs: number
77
+ private readonly startAt: Date | null
78
+ private readonly stopAt: Date | null
79
+
80
+ private timer: ReturnType<typeof setTimeout> | null = null
81
+ private paused: boolean
82
+ private detenido = false
83
+ private ocupado = false
84
+ private corridas = 0
85
+ private ultimoArranque: Date | null = null
86
+ /** La corrida para la que está armado el timer. */
87
+ private proxima: Date | null = null
88
+
89
+ constructor(pattern: string | Date, options?: CronOptions | null, fn?: CronFunction) {
90
+ const opts = options ?? {}
91
+
92
+ this.timeZone = opts.timezone || "UTC"
93
+ assertTimeZone(this.timeZone)
94
+
95
+ this.protect = opts.protect === true
96
+ this.onError = opts.catch ?? false
97
+ this.domAndDow = opts.domAndDow === true
98
+ this.maxRuns = opts.maxRuns ?? null
99
+ this.intervalMs = (opts.interval ?? 0) * 1000
100
+ this.startAt = aFecha(opts.startAt, "startAt")
101
+ this.stopAt = aFecha(opts.stopAt, "stopAt")
102
+ this.paused = opts.paused === true
103
+ this.fn = fn ?? null
104
+
105
+ // Una fecha —o una cadena que lo sea— agenda una sola corrida. Es como se
106
+ // agendan los jobs `one_shot`, que pasan su `fire_at` como patrón.
107
+ if (pattern instanceof Date || esFechaISO(pattern)) {
108
+ const cuando = aFecha(pattern, "el patrón")!
109
+ this.pattern = pattern instanceof Date ? pattern.toISOString() : pattern
110
+ this.fireAt = cuando
111
+ this.fields = null
112
+ } else {
113
+ this.pattern = pattern
114
+ this.fireAt = null
115
+ this.fields = parseCronExpression(pattern)
116
+ }
117
+
118
+ this.name = opts.name ?? this.pattern
119
+
120
+ // Sin función no hay nada que correr: `new Cron(expr)` es la forma de
121
+ // validar una expresión, y no debe dejar un timer suelto.
122
+ if (this.fn) this.armar()
123
+ }
124
+
125
+ /** La próxima corrida, o `null` si ya no queda ninguna. */
126
+ nextRun(from?: Date): Date | null {
127
+ if (this.detenido) return null
128
+ if (this.maxRuns !== null && this.corridas >= this.maxRuns) return null
129
+
130
+ const desde = from ?? new Date()
131
+ let candidata: Date | null
132
+
133
+ if (this.fireAt) {
134
+ candidata = this.fireAt.getTime() > desde.getTime() ? this.fireAt : null
135
+ } else {
136
+ candidata = nextOccurrence(this.fields!, desde, {
137
+ timeZone: this.timeZone,
138
+ domAndDow: this.domAndDow,
139
+ })
140
+ }
141
+ if (!candidata) return null
142
+
143
+ // `startAt` corre la primera corrida hacia adelante en vez de saltearla: un
144
+ // job con ventana de arranque futura debe correr cuando la ventana abre.
145
+ if (this.startAt && candidata.getTime() < this.startAt.getTime()) {
146
+ candidata = this.fireAt
147
+ ? this.fireAt
148
+ : nextOccurrence(this.fields!, new Date(this.startAt.getTime() - 1000), {
149
+ timeZone: this.timeZone,
150
+ domAndDow: this.domAndDow,
151
+ })
152
+ if (!candidata || candidata.getTime() < this.startAt.getTime()) return null
153
+ }
154
+
155
+ if (this.stopAt && candidata.getTime() > this.stopAt.getTime()) return null
156
+
157
+ // `interval` es un piso entre arranques: con `*/1 * * * *` e `interval: 300`
158
+ // el job corre cada cinco minutos, no cada uno.
159
+ if (this.intervalMs > 0 && this.ultimoArranque) {
160
+ const piso = this.ultimoArranque.getTime() + this.intervalMs
161
+ if (candidata.getTime() < piso) {
162
+ // El piso se redondea hacia arriba al segundo antes de buscar desde él.
163
+ // `nextOccurrence` trabaja en segundos enteros —descarta los
164
+ // milisegundos al leer el reloj de pared—, así que buscar desde un piso
165
+ // con milisegundos devuelve el segundo redondeado hacia ABAJO, que
166
+ // sigue siendo menor que el piso. Reintentar con el mismo valor recursa
167
+ // para siempre y cuelga el proceso sin un error que lo explique.
168
+ const pisoEnSegundos = Math.ceil(piso / 1000) * 1000
169
+ candidata = nextOccurrence(this.fields!, new Date(pisoEnSegundos - 1000), {
170
+ timeZone: this.timeZone,
171
+ domAndDow: this.domAndDow,
172
+ })
173
+ if (!candidata) return null
174
+ if (this.stopAt && candidata.getTime() > this.stopAt.getTime()) return null
175
+ }
176
+ }
177
+
178
+ return candidata
179
+ }
180
+
181
+ /** Las próximas `n` corridas. Útil para mostrar una agenda. */
182
+ nextRuns(n: number, from?: Date): Date[] {
183
+ const salida: Date[] = []
184
+ let cursor = from ?? new Date()
185
+ for (let i = 0; i < n; i++) {
186
+ const siguiente = this.nextRun(cursor)
187
+ if (!siguiente) break
188
+ salida.push(siguiente)
189
+ cursor = siguiente
190
+ }
191
+ return salida
192
+ }
193
+
194
+ /** Suspende las corridas sin perder el job. `true` si quedó pausado. */
195
+ pause(): boolean {
196
+ if (this.detenido) return false
197
+ this.paused = true
198
+ this.desarmar()
199
+ return true
200
+ }
201
+
202
+ /** Reanuda un job pausado. `true` si quedó corriendo. */
203
+ resume(): boolean {
204
+ if (this.detenido) return false
205
+ this.paused = false
206
+ if (this.fn) this.armar()
207
+ return true
208
+ }
209
+
210
+ /** Termina el job para siempre. No se puede reanudar. */
211
+ stop(): void {
212
+ this.detenido = true
213
+ this.paused = false
214
+ this.desarmar()
215
+ }
216
+
217
+ /** ¿Está agendado y sin pausar? */
218
+ isRunning(): boolean {
219
+ return !this.detenido && !this.paused && this.timer !== null
220
+ }
221
+
222
+ /** ¿Hay una corrida en curso ahora mismo? */
223
+ isBusy(): boolean {
224
+ return this.ocupado
225
+ }
226
+
227
+ /**
228
+ * Corre la función ahora mismo, fuera de agenda, sin tocar la programación.
229
+ *
230
+ * Es lo que hace la tool `cron.trigger` ("corré esto ya"). Ignora `protect` a
231
+ * propósito: quien dispara a mano está pidiendo una corrida, no sugiriéndola,
232
+ * y devolver silencio porque la anterior sigue en curso se ve como que el
233
+ * botón no funciona.
234
+ */
235
+ trigger(): void {
236
+ if (this.detenido) return
237
+ void (async () => {
238
+ this.ocupado = true
239
+ this.corridas++
240
+ this.ultimoArranque = new Date()
241
+ try {
242
+ await this.fn?.(this)
243
+ } catch (err) {
244
+ const error = err instanceof Error ? err : new Error(String(err))
245
+ if (typeof this.onError === "function") this.onError(error, this)
246
+ else if (!this.onError) throw error
247
+ } finally {
248
+ this.ocupado = false
249
+ }
250
+ })()
251
+ }
252
+
253
+ /** Cuántas veces corrió. */
254
+ runCount(): number {
255
+ return this.corridas
256
+ }
257
+
258
+ /** Cuándo arrancó la última corrida. */
259
+ previousRun(): Date | null {
260
+ return this.ultimoArranque
261
+ }
262
+
263
+ // ── Interno ────────────────────────────────────────────────────────────────
264
+
265
+ private desarmar(): void {
266
+ if (this.timer !== null) {
267
+ clearTimeout(this.timer)
268
+ this.timer = null
269
+ }
270
+ this.proxima = null
271
+ }
272
+
273
+ private armar(): void {
274
+ this.desarmar()
275
+ if (this.detenido || this.paused) return
276
+
277
+ const siguiente = this.nextRun()
278
+ if (!siguiente) return
279
+
280
+ this.proxima = siguiente
281
+ this.programar()
282
+ }
283
+
284
+ /** Arma el timer, encadenando tramos cuando la espera excede el tope. */
285
+ private programar(): void {
286
+ if (!this.proxima) return
287
+ const falta = this.proxima.getTime() - Date.now()
288
+
289
+ if (falta > MAX_DELAY_MS) {
290
+ this.timer = setTimeout(() => this.programar(), MAX_DELAY_MS)
291
+ return
292
+ }
293
+
294
+ this.timer = setTimeout(() => {
295
+ this.timer = null
296
+ void this.disparar()
297
+ }, Math.max(0, falta))
298
+ }
299
+
300
+ private async disparar(): Promise<void> {
301
+ if (this.detenido || this.paused) return
302
+
303
+ // `protect` se comprueba acá y no al agendar: lo que importa es si la
304
+ // anterior sigue corriendo **ahora**, no si lo estaba cuando se armó.
305
+ // La corrida se saltea pero el job se re-agenda igual; si no, un job lento
306
+ // se apagaría solo al primer solapamiento.
307
+ if (this.protect && this.ocupado) {
308
+ this.armar()
309
+ return
310
+ }
311
+
312
+ this.ocupado = true
313
+ this.corridas++
314
+ this.ultimoArranque = new Date()
315
+
316
+ try {
317
+ await this.fn?.(this)
318
+ } catch (err) {
319
+ const error = err instanceof Error ? err : new Error(String(err))
320
+ if (typeof this.onError === "function") this.onError(error, this)
321
+ else if (!this.onError) throw error
322
+ } finally {
323
+ this.ocupado = false
324
+ // Re-agendar va en el `finally`: si quedara después del `await` y la
325
+ // función lanzara con `catch: false`, el job dejaría de correr para
326
+ // siempre por un error de una sola corrida.
327
+ this.armar()
328
+ }
329
+ }
330
+ }
331
+
332
+ /** ¿Es una fecha ISO en vez de una expresión cron? */
333
+ function esFechaISO(valor: string): boolean {
334
+ // Una expresión cron nunca lleva "-" en la primera posición ni ":" en ningún
335
+ // lado, así que alcanza con exigir la forma de fecha antes de intentar
336
+ // parsearla — `new Date("0 9 * * *")` en algunos runtimes no da NaN.
337
+ if (!/^\d{4}-\d{2}-\d{2}([T ]|$)/.test(valor.trim())) return false
338
+ return !Number.isNaN(new Date(valor).getTime())
339
+ }