@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.
- package/CHANGELOG.md +306 -0
- package/README.md +11 -3
- package/package.json +10 -4
- package/packages/core/src/agent/agent-catalog.ts +81 -24
- package/packages/core/src/agent/compaction.ts +20 -1
- package/packages/core/src/agent/context-compiler.ts +6 -3
- package/packages/core/src/agent/conversation-store.ts +136 -2
- package/packages/core/src/agent/curator.ts +12 -3
- package/packages/core/src/agent/llm-providers/nvidia.ts +39 -0
- package/packages/core/src/agent/llm-providers/openai-compat-base.ts +38 -2
- package/packages/core/src/agent/playbook-selector.ts +18 -3
- package/packages/core/src/agent/prompt-builder.ts +2 -2
- package/packages/core/src/agent/providers/index.ts +36 -1
- package/packages/core/src/agent/reflector.ts +32 -9
- package/packages/core/src/agent/skill-selector.ts +2 -2
- package/packages/core/src/agent/thread-store.ts +43 -0
- package/packages/core/src/agent/tool-selector.ts +2 -0
- package/packages/core/src/api/createAgent.ts +66 -1
- package/packages/core/src/artifacts/index.ts +15 -0
- package/packages/core/src/artifacts/store.ts +77 -2
- package/packages/core/src/canvas/index.ts +9 -0
- package/packages/core/src/ethics/EthicsGuard.ts +7 -1
- package/packages/core/src/events/index.ts +18 -0
- package/packages/core/src/events/tool-narration.ts +4 -0
- package/packages/core/src/gateway/channel-notify.ts +103 -6
- package/packages/core/src/gateway/durable-queue.ts +13 -1
- package/packages/core/src/gateway/index.ts +3 -0
- package/packages/core/src/gateway/job-store.ts +6 -0
- package/packages/core/src/harness/executors.ts +493 -0
- package/packages/core/src/harness/index.ts +12 -2
- package/packages/core/src/hooks/index.ts +203 -0
- package/packages/core/src/images/index.ts +161 -0
- package/packages/core/src/index.ts +1 -0
- package/packages/core/src/multimodal/vision-service.ts +45 -13
- package/packages/core/src/resilience/index.ts +13 -0
- package/packages/core/src/scheduler/CronScheduler.ts +48 -21
- package/packages/core/src/scheduler/cron/expression.ts +165 -0
- package/packages/core/src/scheduler/cron/index.ts +10 -0
- package/packages/core/src/scheduler/cron/job.ts +339 -0
- package/packages/core/src/scheduler/cron/next-run.ts +121 -0
- package/packages/core/src/scheduler/cron/zoned-time.ts +138 -0
- package/packages/core/src/scheduler/index.ts +21 -3
- package/packages/core/src/scheduler/integration.ts +16 -5
- package/packages/core/src/scheduler/types.ts +3 -18
- package/packages/core/src/services/agents.ts +268 -0
- package/packages/core/src/services/cron.ts +257 -0
- package/packages/core/src/services/endpoints.ts +289 -0
- package/packages/core/src/services/ethics.ts +107 -0
- package/packages/core/src/services/images.ts +212 -0
- package/packages/core/src/services/index.ts +112 -0
- package/packages/core/src/services/mcp.ts +201 -0
- package/packages/core/src/services/memory.ts +133 -0
- package/packages/core/src/services/models.ts +179 -0
- package/packages/core/src/services/providers.ts +152 -0
- package/packages/core/src/services/setup.ts +222 -0
- package/packages/core/src/services/skills.ts +241 -0
- package/packages/core/src/services/swarms.ts +307 -0
- package/packages/core/src/services/tools.ts +106 -0
- package/packages/core/src/sessions/index.ts +5 -3
- package/packages/core/src/sessions/resolve.ts +108 -0
- package/packages/core/src/skills/SkillLoader.ts +8 -1
- package/packages/core/src/skills/bundled/artifacts/artifact_reader/SKILL.md +105 -0
- package/packages/core/src/skills/bundled/cron_manager/SKILL.md +21 -11
- package/packages/core/src/skills/bundled/images/image_editor/SKILL.md +120 -0
- package/packages/core/src/skills/bundled/web/browser_automate/SKILL.md +12 -3
- package/packages/core/src/skills/bundled/web/browser_scrape/SKILL.md +22 -7
- package/packages/core/src/skills/bundled-data.generated.ts +110 -12
- package/packages/core/src/storage/bootstrap.ts +74 -5
- package/packages/core/src/storage/collections.ts +106 -1
- package/packages/core/src/storage/crypto.ts +24 -7
- package/packages/core/src/storage/index.ts +2 -1
- package/packages/core/src/storage/onboarding.ts +59 -43
- package/packages/core/src/storage/reconcile.ts +6 -1
- package/packages/core/src/storage/seed.ts +89 -11
- package/packages/core/src/swarm/types.ts +3 -18
- package/packages/core/src/tool-runtime/embedded-worker.generated.ts +21 -0
- package/packages/core/src/tool-runtime/index.ts +129 -14
- package/packages/core/src/tools/agents/index.ts +18 -60
- package/packages/core/src/tools/cli/index.ts +55 -0
- package/packages/core/src/tools/core/index.ts +50 -2
- package/packages/core/src/tools/cron/index.ts +4 -4
- package/packages/core/src/tools/images/index.ts +130 -0
- package/packages/core/src/tools/index.ts +14 -1
- package/packages/core/src/tools/office/office-escribir-xlsx.ts +2 -1
- package/packages/core/src/tools/office/office-leer-xlsx.ts +2 -1
- 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
|
+
}
|