@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.
- package/CHANGELOG.md +441 -0
- package/README.md +21 -3
- package/package.json +27 -5
- package/packages/core/src/agent/acceptance-checks.ts +9 -9
- package/packages/core/src/agent/agent-catalog.ts +82 -25
- package/packages/core/src/agent/agent-loop.ts +115 -26
- package/packages/core/src/agent/capability-search.ts +2 -2
- package/packages/core/src/agent/catalog-selector.ts +4 -4
- package/packages/core/src/agent/compaction.ts +30 -10
- package/packages/core/src/agent/context-compiler.ts +63 -36
- package/packages/core/src/agent/conversation-store.ts +167 -9
- package/packages/core/src/agent/curator.ts +16 -7
- package/packages/core/src/agent/delegation-runtime.ts +5 -5
- package/packages/core/src/agent/goal-runner.ts +9 -9
- package/packages/core/src/agent/index.ts +1 -0
- package/packages/core/src/agent/llm-client.ts +98 -37
- package/packages/core/src/agent/llm-providers/anthropic.ts +4 -4
- package/packages/core/src/agent/llm-providers/deepseek.ts +1 -1
- package/packages/core/src/agent/llm-providers/gemini.ts +4 -4
- package/packages/core/src/agent/llm-providers/groq.ts +1 -1
- package/packages/core/src/agent/llm-providers/hiveagents.ts +3 -3
- package/packages/core/src/agent/llm-providers/interface.ts +2 -2
- package/packages/core/src/agent/llm-providers/kimi.ts +1 -1
- package/packages/core/src/agent/llm-providers/minimax.ts +1 -1
- package/packages/core/src/agent/llm-providers/mistral.ts +1 -1
- package/packages/core/src/agent/llm-providers/modelscope.ts +1 -1
- package/packages/core/src/agent/llm-providers/nvidia.ts +40 -1
- package/packages/core/src/agent/llm-providers/ollama.ts +4 -4
- package/packages/core/src/agent/llm-providers/openai-compat-base.ts +47 -7
- package/packages/core/src/agent/llm-providers/openai.ts +1 -1
- package/packages/core/src/agent/llm-providers/opencode-go.ts +1 -1
- package/packages/core/src/agent/llm-providers/openrouter.ts +1 -1
- package/packages/core/src/agent/llm-providers/qwen.ts +1 -1
- package/packages/core/src/agent/llm-providers/z-ai.ts +1 -1
- package/packages/core/src/agent/mcp-result-normalizer.ts +192 -0
- package/packages/core/src/agent/playbook-selector.ts +22 -7
- package/packages/core/src/agent/prompt-builder.ts +5 -5
- package/packages/core/src/agent/proof-packet.ts +5 -5
- package/packages/core/src/agent/providers/index.ts +39 -4
- package/packages/core/src/agent/realtime-providers/gemini-live.ts +238 -0
- package/packages/core/src/agent/realtime-providers/index.ts +29 -0
- package/packages/core/src/agent/realtime-providers/interface.ts +108 -0
- package/packages/core/src/agent/reflector.ts +38 -15
- package/packages/core/src/agent/run-store.ts +8 -8
- package/packages/core/src/agent/service.ts +10 -10
- package/packages/core/src/agent/skill-selector.ts +6 -6
- package/packages/core/src/agent/thread-id.ts +71 -0
- package/packages/core/src/agent/thread-store.ts +293 -0
- package/packages/core/src/agent/tool-selector.ts +9 -5
- package/packages/core/src/agent/tracer.ts +5 -5
- package/packages/core/src/api/createAgent.ts +67 -2
- package/packages/core/src/artifacts/index.ts +15 -0
- package/packages/core/src/artifacts/store.ts +161 -5
- package/packages/core/src/canvas/emitter.ts +2 -2
- package/packages/core/src/canvas/index.ts +9 -0
- package/packages/core/src/channels/telegram.ts +1 -1
- package/packages/core/src/channels/webchat.ts +1 -1
- package/packages/core/src/config/loader.ts +14 -5
- package/packages/core/src/ethics/EthicsGuard.ts +7 -1
- package/packages/core/src/events/agent-bus.ts +3 -3
- package/packages/core/src/events/channel-narration.ts +3 -3
- package/packages/core/src/events/event-bus.ts +1 -1
- package/packages/core/src/events/index.ts +18 -0
- package/packages/core/src/events/narration.ts +3 -3
- 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/delegation-groups.ts +4 -4
- package/packages/core/src/gateway/durable-queue.ts +18 -6
- package/packages/core/src/gateway/index.ts +3 -0
- package/packages/core/src/gateway/job-store.ts +11 -5
- package/packages/core/src/gateway/notification-inbox.ts +2 -2
- package/packages/core/src/gateway/server.ts +2 -2
- 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/mcp/MCPClient.ts +3 -3
- package/packages/core/src/mcp/hot-reload.ts +5 -5
- package/packages/core/src/mcp/tool-sync.ts +5 -5
- package/packages/core/src/mcp/transports/index.ts +2 -2
- package/packages/core/src/mcp/transports/sse.ts +1 -1
- package/packages/core/src/models/index.ts +36 -0
- package/packages/core/src/multimodal/index.ts +2 -2
- package/packages/core/src/multimodal/vision-service.ts +51 -19
- package/packages/core/src/plugins/loader.ts +4 -1
- package/packages/core/src/resilience/circuit-breaker.ts +16 -5
- package/packages/core/src/resilience/index.ts +13 -0
- package/packages/core/src/resilience/retry.ts +1 -1
- package/packages/core/src/scheduler/CronScheduler.ts +54 -27
- 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 +25 -14
- 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 +268 -0
- 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 +107 -12
- package/packages/core/src/storage/causal-events.ts +1 -1
- package/packages/core/src/storage/collections.ts +138 -2
- package/packages/core/src/storage/crypto.ts +35 -4
- package/packages/core/src/storage/hive.ts +1 -1
- package/packages/core/src/storage/hivedb.ts +10 -1
- package/packages/core/src/storage/index.ts +2 -1
- package/packages/core/src/storage/onboarding.ts +61 -45
- package/packages/core/src/storage/reconcile.ts +11 -6
- package/packages/core/src/storage/seed.ts +191 -23
- package/packages/core/src/storage/usage.ts +3 -3
- package/packages/core/src/swarm/AgentExecutor.ts +2 -2
- package/packages/core/src/swarm/Coordinator.ts +8 -8
- package/packages/core/src/swarm/EventBridge.ts +2 -2
- package/packages/core/src/swarm/RoleSwarm.ts +234 -0
- package/packages/core/src/swarm/TaskGraph.ts +2 -2
- package/packages/core/src/swarm/index.ts +7 -0
- package/packages/core/src/swarm/presets/HiveLearnPreset.ts +2 -2
- package/packages/core/src/swarm/presets/ResearchPreset.ts +2 -2
- package/packages/core/src/swarm/strategies/ParallelStrategy.ts +1 -1
- package/packages/core/src/swarm/strategies/PriorityStrategy.ts +3 -3
- 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/ToolExecutor.ts +7 -3
- 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 +52 -4
- package/packages/core/src/tools/cron/index.ts +8 -8
- 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
- package/packages/core/src/tools/web/artifact-inspect.ts +2 -2
- package/packages/core/src/tools/web/artifact-read.ts +162 -0
- package/packages/core/src/tools/web/browser-backend.ts +141 -44
- package/packages/core/src/tools/web/browser-click.ts +2 -2
- package/packages/core/src/tools/web/browser-extract.ts +2 -2
- package/packages/core/src/tools/web/browser-navigate.ts +2 -2
- package/packages/core/src/tools/web/browser-screenshot.ts +12 -5
- package/packages/core/src/tools/web/browser-script.ts +2 -2
- package/packages/core/src/tools/web/browser-service.ts +63 -384
- package/packages/core/src/tools/web/browser-session.ts +125 -0
- package/packages/core/src/tools/web/browser-type.ts +2 -2
- package/packages/core/src/tools/web/browser-wait.ts +2 -2
- package/packages/core/src/tools/web/computer-use.ts +553 -0
- package/packages/core/src/tools/web/index.ts +8 -1
- package/packages/core/src/tools/web/webview-backend.ts +460 -21
- package/packages/core/src/utils/index.ts +1 -0
- package/packages/core/src/utils/logger.ts +12 -4
- package/packages/core/src/utils/redact-binary.ts +17 -0
- package/packages/core/src/utils/toon.ts +1 -1
- package/packages/core/src/voice/index.ts +6 -6
- package/bun.lock +0 -859
- package/bunfig.toml +0 -9
- package/docs/API-AGENTS.md +0 -367
- package/docs/API-CONTEXT-COMPILER.md +0 -249
- package/docs/API-DAG-SCHEDULER.md +0 -273
- package/docs/API-TOOLS-SKILLS-CHANNELS.md +0 -446
- package/docs/API-WORKERS-EVENTS.md +0 -299
- package/docs/HIVE-HARNESS.md +0 -113
- package/docs/INDEX.md +0 -190
- package/docs/TEMPLATE-HIVE-APP.md +0 -360
- package/packages/cli/package.json +0 -17
- package/packages/cli/src/commands/create-app.test.ts +0 -180
- package/packages/core/package.json +0 -70
- package/packages/core/src/api/createAgent.test.ts +0 -160
- package/packages/core/src/canvas/canvas.test.ts +0 -36
- package/packages/core/src/channels/channels.test.ts +0 -18
- package/packages/core/src/ethics/EthicsGuard.test.ts +0 -108
- package/packages/core/src/gateway/gateway.test.ts +0 -38
- package/packages/core/src/memory/Scratchpad.test.ts +0 -68
- package/packages/core/src/scheduler/scheduler.test.ts +0 -15
- package/packages/core/src/skills/skills.test.ts +0 -62
- package/packages/core/src/swarm/swarm.test.ts +0 -24
- package/packages/core/src/tool-runtime/tool-runtime.test.ts +0 -99
- package/packages/core/src/tools/ToolRegistry.test.ts +0 -98
- package/packages/core/src/tools/api/api-request.test.ts +0 -164
- package/packages/core/src/tools/web/browser-service.test.ts +0 -83
- package/packages/core/src/workers/workers.test.ts +0 -41
- package/scripts/bump-version.ts +0 -248
- package/scripts/generate-skill-bundle.ts +0 -108
- package/test/acceptance-checks.test.ts +0 -403
- package/test/agent-loop-terminal-synthesis.test.ts +0 -32
- package/test/browser-backend.test.ts +0 -308
- package/test/catalog-agents-stay-enabled.test.ts +0 -117
- package/test/causal-events.test.ts +0 -117
- package/test/compaction.test.ts +0 -105
- package/test/context-compiler.test.ts +0 -269
- package/test/curator.test.ts +0 -130
- package/test/durable-queue.test.ts +0 -114
- package/test/harness-barrel.test.ts +0 -64
- package/test/hive-helpers.test.ts +0 -130
- package/test/hivedb-search.test.ts +0 -189
- package/test/internal-turns.test.ts +0 -166
- package/test/job-idempotency.test.ts +0 -68
- package/test/job-retry-backoff.test.ts +0 -184
- package/test/job-store.test.ts +0 -381
- package/test/llm-retry.test.ts +0 -97
- package/test/memory-perf.test.ts +0 -774
- package/test/minimal-loadout.test.ts +0 -78
- package/test/model-catalog.test.ts +0 -105
- package/test/preload.ts +0 -12
- package/test/reflector.test.ts +0 -320
- package/test/retention-cap.test.ts +0 -91
- package/test/retired-capabilities-pruned.test.ts +0 -192
- package/test/run-store.test.ts +0 -355
- package/test/scratchpad.test.ts +0 -74
- package/test/secrets-durability.test.ts +0 -119
- package/test/seed-model-reseed.test.ts +0 -155
- package/test/setup-agent-seed.test.ts +0 -264
- package/test/tool-inventory.test.ts +0 -65
- package/test/tool-runtime.test.ts +0 -258
- package/test/tool-selector-runtime-tools.test.ts +0 -117
- package/test/toon.test.ts +0 -429
- package/tsconfig.json +0 -42
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hive CronScheduler
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 "
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
222
|
-
|
|
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 = {
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
+
}
|