@johpaz/hive-sdk 0.2.0 → 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 (86) hide show
  1. package/CHANGELOG.md +306 -0
  2. package/README.md +11 -3
  3. package/package.json +10 -4
  4. package/packages/core/src/agent/agent-catalog.ts +81 -24
  5. package/packages/core/src/agent/compaction.ts +20 -1
  6. package/packages/core/src/agent/context-compiler.ts +6 -3
  7. package/packages/core/src/agent/conversation-store.ts +136 -2
  8. package/packages/core/src/agent/curator.ts +12 -3
  9. package/packages/core/src/agent/llm-providers/nvidia.ts +39 -0
  10. package/packages/core/src/agent/llm-providers/openai-compat-base.ts +38 -2
  11. package/packages/core/src/agent/playbook-selector.ts +18 -3
  12. package/packages/core/src/agent/prompt-builder.ts +2 -2
  13. package/packages/core/src/agent/providers/index.ts +36 -1
  14. package/packages/core/src/agent/reflector.ts +32 -9
  15. package/packages/core/src/agent/skill-selector.ts +2 -2
  16. package/packages/core/src/agent/thread-store.ts +43 -0
  17. package/packages/core/src/agent/tool-selector.ts +2 -0
  18. package/packages/core/src/api/createAgent.ts +66 -1
  19. package/packages/core/src/artifacts/index.ts +15 -0
  20. package/packages/core/src/artifacts/store.ts +77 -2
  21. package/packages/core/src/canvas/index.ts +9 -0
  22. package/packages/core/src/ethics/EthicsGuard.ts +7 -1
  23. package/packages/core/src/events/index.ts +18 -0
  24. package/packages/core/src/events/tool-narration.ts +4 -0
  25. package/packages/core/src/gateway/channel-notify.ts +103 -6
  26. package/packages/core/src/gateway/durable-queue.ts +13 -1
  27. package/packages/core/src/gateway/index.ts +3 -0
  28. package/packages/core/src/gateway/job-store.ts +6 -0
  29. package/packages/core/src/harness/executors.ts +493 -0
  30. package/packages/core/src/harness/index.ts +12 -2
  31. package/packages/core/src/hooks/index.ts +203 -0
  32. package/packages/core/src/images/index.ts +161 -0
  33. package/packages/core/src/index.ts +1 -0
  34. package/packages/core/src/multimodal/vision-service.ts +45 -13
  35. package/packages/core/src/resilience/index.ts +13 -0
  36. package/packages/core/src/scheduler/CronScheduler.ts +48 -21
  37. package/packages/core/src/scheduler/cron/expression.ts +165 -0
  38. package/packages/core/src/scheduler/cron/index.ts +10 -0
  39. package/packages/core/src/scheduler/cron/job.ts +339 -0
  40. package/packages/core/src/scheduler/cron/next-run.ts +121 -0
  41. package/packages/core/src/scheduler/cron/zoned-time.ts +138 -0
  42. package/packages/core/src/scheduler/index.ts +21 -3
  43. package/packages/core/src/scheduler/integration.ts +16 -5
  44. package/packages/core/src/scheduler/types.ts +3 -18
  45. package/packages/core/src/services/agents.ts +268 -0
  46. package/packages/core/src/services/cron.ts +257 -0
  47. package/packages/core/src/services/endpoints.ts +289 -0
  48. package/packages/core/src/services/ethics.ts +107 -0
  49. package/packages/core/src/services/images.ts +212 -0
  50. package/packages/core/src/services/index.ts +112 -0
  51. package/packages/core/src/services/mcp.ts +201 -0
  52. package/packages/core/src/services/memory.ts +133 -0
  53. package/packages/core/src/services/models.ts +179 -0
  54. package/packages/core/src/services/providers.ts +152 -0
  55. package/packages/core/src/services/setup.ts +222 -0
  56. package/packages/core/src/services/skills.ts +241 -0
  57. package/packages/core/src/services/swarms.ts +307 -0
  58. package/packages/core/src/services/tools.ts +106 -0
  59. package/packages/core/src/sessions/index.ts +5 -3
  60. package/packages/core/src/sessions/resolve.ts +108 -0
  61. package/packages/core/src/skills/SkillLoader.ts +8 -1
  62. package/packages/core/src/skills/bundled/artifacts/artifact_reader/SKILL.md +105 -0
  63. package/packages/core/src/skills/bundled/cron_manager/SKILL.md +21 -11
  64. package/packages/core/src/skills/bundled/images/image_editor/SKILL.md +120 -0
  65. package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +12 -3
  66. package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +22 -7
  67. package/packages/core/src/skills/bundled-data.generated.ts +110 -12
  68. package/packages/core/src/storage/bootstrap.ts +74 -5
  69. package/packages/core/src/storage/collections.ts +106 -1
  70. package/packages/core/src/storage/crypto.ts +24 -7
  71. package/packages/core/src/storage/index.ts +2 -1
  72. package/packages/core/src/storage/onboarding.ts +59 -43
  73. package/packages/core/src/storage/reconcile.ts +6 -1
  74. package/packages/core/src/storage/seed.ts +89 -11
  75. package/packages/core/src/swarm/types.ts +3 -18
  76. package/packages/core/src/tool-runtime/embedded-worker.generated.ts +21 -0
  77. package/packages/core/src/tool-runtime/index.ts +129 -14
  78. package/packages/core/src/tools/agents/index.ts +18 -60
  79. package/packages/core/src/tools/cli/index.ts +55 -0
  80. package/packages/core/src/tools/core/index.ts +50 -2
  81. package/packages/core/src/tools/cron/index.ts +4 -4
  82. package/packages/core/src/tools/images/index.ts +130 -0
  83. package/packages/core/src/tools/index.ts +14 -1
  84. package/packages/core/src/tools/office/office-escribir-xlsx.ts +2 -1
  85. package/packages/core/src/tools/office/office-leer-xlsx.ts +2 -1
  86. package/packages/core/src/tools/office/xlsx-loader.ts +19 -0
@@ -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
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Próxima corrida de una expresión cron, en la zona horaria del job.
3
+ *
4
+ * La búsqueda avanza **día por día** en el reloj de pared de la zona y, dentro
5
+ * del día que casa, recorre las horas/minutos/segundos que casan. Podría
6
+ * hacerse aritmética de campos con acarreo y sería algo más rápido, pero acá
7
+ * cada job calcula esto una vez por corrida: la claridad vale más que los
8
+ * microsegundos, y el acarreo es justo donde se esconden los errores de fin de
9
+ * mes y de año bisiesto.
10
+ *
11
+ * Avanzar el día con `Date.UTC(y, m-1, d+1)` es deliberado: deja que el runtime
12
+ * resuelva el largo de cada mes y los bisiestos, así que un 31 de febrero nunca
13
+ * llega a probarse.
14
+ */
15
+
16
+ import type { CronFields } from "./expression.ts"
17
+ import { toInstant, toWallClock, wallClockWeekday, type WallClock } from "./zoned-time.ts"
18
+
19
+ /**
20
+ * Tope de la búsqueda.
21
+ *
22
+ * Hay expresiones válidas que no ocurren nunca: `0 0 30 2 *` —30 de febrero—
23
+ * parsea bien y no casa jamás. Sin tope, buscarle la próxima corrida cuelga el
24
+ * proceso. Cinco años cubre de sobra cualquier expresión que sí ocurra, incluida
25
+ * la del 29 de febrero, que puede tardar hasta ocho.
26
+ */
27
+ const MAX_DIAS = 366 * 8
28
+
29
+ /** ¿Casa el día (mes + día del mes/semana) según la semántica de `domAndDow`? */
30
+ function diaCasa(fields: CronFields, wall: WallClock, domAndDow: boolean): boolean {
31
+ if (!fields.month.includes(wall.month)) return false
32
+
33
+ const porMes = fields.dayOfMonth.includes(wall.day)
34
+ const porSemana = fields.dayOfWeek.includes(wallClockWeekday(wall))
35
+
36
+ // Ninguno de los dos restringe (`* *`): casa cualquier día.
37
+ if (!fields.domRestricted && !fields.dowRestricted) return true
38
+
39
+ // Los dos restringen: es el caso raro del cron clásico, donde por defecto la
40
+ // lógica es O y no Y — `0 9 15 * 1` es "los 15 **o** los lunes". `domAndDow`
41
+ // existe para pedir la otra.
42
+ if (fields.domRestricted && fields.dowRestricted) {
43
+ return domAndDow ? porMes && porSemana : porMes || porSemana
44
+ }
45
+
46
+ return fields.domRestricted ? porMes : porSemana
47
+ }
48
+
49
+ /** Las horas del día que casan, de la más temprana a la más tarde, desde un piso. */
50
+ function* horasDelDia(
51
+ fields: CronFields,
52
+ piso: { hour: number; minute: number; second: number } | null,
53
+ ): Generator<{ hour: number; minute: number; second: number }> {
54
+ for (const hour of fields.hour) {
55
+ if (piso && hour < piso.hour) continue
56
+ for (const minute of fields.minute) {
57
+ if (piso && hour === piso.hour && minute < piso.minute) continue
58
+ for (const second of fields.second) {
59
+ if (piso && hour === piso.hour && minute === piso.minute && second < piso.second) continue
60
+ yield { hour, minute, second }
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ /** El día siguiente en el calendario, a las 00:00:00. */
67
+ function diaSiguiente(wall: WallClock): WallClock {
68
+ const d = new Date(Date.UTC(wall.year, wall.month - 1, wall.day + 1))
69
+ return {
70
+ year: d.getUTCFullYear(),
71
+ month: d.getUTCMonth() + 1,
72
+ day: d.getUTCDate(),
73
+ hour: 0,
74
+ minute: 0,
75
+ second: 0,
76
+ }
77
+ }
78
+
79
+ export interface NextOccurrenceOptions {
80
+ timeZone: string
81
+ domAndDow?: boolean
82
+ }
83
+
84
+ /**
85
+ * La próxima vez que la expresión casa, **estrictamente después** de `from`.
86
+ * `null` si no ocurre dentro del horizonte de búsqueda.
87
+ */
88
+ export function nextOccurrence(
89
+ fields: CronFields,
90
+ from: Date,
91
+ opts: NextOccurrenceOptions,
92
+ ): Date | null {
93
+ const { timeZone, domAndDow = false } = opts
94
+
95
+ // Un segundo después: sin esto, pedirle la próxima corrida a un job justo
96
+ // cuando acaba de correr devolvería el mismo instante y el scheduler se
97
+ // reprogramaría con demora cero, en bucle.
98
+ let cursor = toWallClock(new Date(from.getTime() + 1000), timeZone)
99
+ let piso: { hour: number; minute: number; second: number } | null = {
100
+ hour: cursor.hour,
101
+ minute: cursor.minute,
102
+ second: cursor.second,
103
+ }
104
+
105
+ for (let dia = 0; dia < MAX_DIAS; dia++) {
106
+ if (diaCasa(fields, cursor, domAndDow)) {
107
+ for (const hora of horasDelDia(fields, piso)) {
108
+ const instante = toInstant({ ...cursor, ...hora }, timeZone)
109
+ // `null` = esa hora no existe hoy en esta zona porque el reloj saltó
110
+ // hacia adelante. Se prueba la siguiente que casa en vez de inventar un
111
+ // instante: correr "a las 2:30" el día que 2:30 no existió sería correr
112
+ // a una hora que nadie agendó.
113
+ if (instante) return instante
114
+ }
115
+ }
116
+ cursor = diaSiguiente(cursor)
117
+ piso = null
118
+ }
119
+
120
+ return null
121
+ }