@capybara-hq/kernel 0.1.0-rc.21 → 0.1.0-rc.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/admin.controller.d.ts +3 -3
- package/dist/admin/admin.controller.js +6 -2
- package/dist/admin/admin.schemas.d.ts +13 -0
- package/dist/agents/admin-agents.d.ts +16 -3
- package/dist/agents/admin-agents.js +65 -7
- package/dist/agents/agents.schemas.d.ts +24 -6
- package/dist/agents/agents.service.d.ts +4 -3
- package/dist/agents/agents.service.js +39 -26
- package/dist/agents/definition.d.ts +44 -6
- package/dist/agents/definition.js +86 -18
- package/dist/agents/from-template.d.ts +6 -3
- package/dist/agents/from-template.js +57 -44
- package/dist/agents/org-templates.js +97 -41
- package/dist/agents/templates.d.ts +6 -0
- package/dist/agents/templates.js +15 -1
- package/dist/background/launch.d.ts +43 -5
- package/dist/background/launch.js +88 -8
- package/dist/background/session.d.ts +14 -2
- package/dist/background/session.js +26 -4
- package/dist/background/status.d.ts +1 -1
- package/dist/background/status.js +3 -3
- package/dist/background/stop.d.ts +0 -2
- package/dist/background/stop.js +1 -21
- package/dist/catalog/admin-catalog.controller.d.ts +2 -4
- package/dist/catalog/admin-catalog.controller.js +2 -6
- package/dist/catalog/catalog-content.d.ts +5 -0
- package/dist/catalog/catalog-content.js +1 -0
- package/dist/catalog/catalog.module.js +3 -0
- package/dist/catalog/claude/report.d.ts +9 -2
- package/dist/catalog/claude/report.js +1 -0
- package/dist/catalog/claude/translate.js +1 -1
- package/dist/catalog/component-promotion.js +1 -2
- package/dist/catalog/component-state.d.ts +13 -1
- package/dist/catalog/component-state.js +14 -5
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/catalog/marketplaces.service.js +2 -1
- package/dist/catalog/org-catalog.controller.d.ts +13 -3
- package/dist/catalog/org-catalog.controller.js +26 -4
- package/dist/catalog/org-catalog.schemas.d.ts +13 -0
- package/dist/catalog/org-catalog.schemas.js +34 -0
- package/dist/catalog/org-plugin-uploads.controller.js +0 -1
- package/dist/catalog/org-plugin-uploads.d.ts +0 -1
- package/dist/catalog/org-plugin-uploads.js +0 -3
- package/dist/catalog/org-plugin-uploads.schemas.d.ts +0 -1
- package/dist/catalog/org-plugin-uploads.schemas.js +0 -2
- package/dist/catalog/platform-components.d.ts +0 -1
- package/dist/catalog/platform-components.js +3 -8
- package/dist/catalog/plugin-import.d.ts +0 -8
- package/dist/catalog/plugin-import.js +4 -35
- package/dist/catalog/plugin-install.d.ts +2 -2
- package/dist/catalog/plugin-install.js +2 -2
- package/dist/catalog/plugin-package.d.ts +7 -0
- package/dist/catalog/plugin-package.js +27 -1
- package/dist/catalog/plugin-upgrade.d.ts +5 -6
- package/dist/catalog/plugin-upgrade.js +5 -23
- package/dist/catalog/requests.service.d.ts +66 -10
- package/dist/catalog/requests.service.js +196 -15
- package/dist/channels/actions.d.ts +2 -0
- package/dist/channels/actions.js +19 -4
- package/dist/channels/cards.d.ts +16 -2
- package/dist/channels/cards.js +40 -7
- package/dist/channels/channels.module.js +3 -0
- package/dist/channels/outbound.d.ts +22 -1
- package/dist/channels/outbound.js +116 -7
- package/dist/channels/phrases.d.ts +25 -0
- package/dist/channels/phrases.js +19 -0
- package/dist/channels/threads.d.ts +12 -0
- package/dist/channels/threads.js +34 -0
- package/dist/config/schema.d.ts +23 -1
- package/dist/config/schema.js +107 -5
- package/dist/conversations/conversations.service.d.ts +42 -9
- package/dist/conversations/conversations.service.js +57 -25
- package/dist/conversations/events.d.ts +106 -11
- package/dist/conversations/events.js +121 -18
- package/dist/conversations/message-context.d.ts +6 -3
- package/dist/conversations/message-context.js +14 -10
- package/dist/conversations/queue.d.ts +15 -2
- package/dist/conversations/queue.js +22 -3
- package/dist/conversations/queue.service.d.ts +12 -6
- package/dist/conversations/queue.service.js +36 -13
- package/dist/db/kernel-schema.d.ts +105 -3
- package/dist/devices/conversation-devices.d.ts +8 -1
- package/dist/devices/conversation-devices.js +8 -0
- package/dist/devices/devices.controller.d.ts +21 -4
- package/dist/devices/devices.controller.js +46 -8
- package/dist/devices/devices.schemas.d.ts +9 -1
- package/dist/devices/devices.schemas.js +11 -3
- package/dist/devices/devices.service.d.ts +176 -4
- package/dist/devices/devices.service.js +347 -6
- package/dist/devices/dto.d.ts +56 -3
- package/dist/devices/dto.js +51 -1
- package/dist/devices/public-suffix.d.ts +6 -0
- package/dist/devices/public-suffix.js +79 -0
- package/dist/errors.d.ts +50 -5
- package/dist/errors.js +50 -5
- package/dist/evals/reconstruct.d.ts +18 -13
- package/dist/evals/reconstruct.js +50 -36
- package/dist/evals/recorded-tools.d.ts +7 -7
- package/dist/evals/recorded-tools.js +4 -3
- package/dist/evals/trajectory.d.ts +6 -1
- package/dist/evals/trajectory.js +7 -0
- package/dist/extensions/package.js +2 -2
- package/dist/http/api.module.js +5 -0
- package/dist/identity/settings.d.ts +53 -3
- package/dist/identity/settings.js +92 -9
- package/dist/inbox/inbox.module.js +2 -1
- package/dist/inbox/inbox.service.d.ts +19 -4
- package/dist/inbox/inbox.service.js +14 -3
- package/dist/infra/config/kernel-config.service.d.ts +7 -0
- package/dist/infra/config/kernel-config.service.js +31 -0
- package/dist/infra/events/kernel-events.d.ts +2 -1
- package/dist/local-devices/device-policy.service.d.ts +65 -0
- package/dist/local-devices/device-policy.service.js +217 -0
- package/dist/local-devices/local-device-gateway.d.ts +4 -2
- package/dist/local-devices/local-device-gateway.js +39 -11
- package/dist/local-devices/local-device-provider.d.ts +40 -3
- package/dist/local-devices/local-device-provider.js +109 -7
- package/dist/local-devices/local-devices.module.d.ts +2 -1
- package/dist/local-devices/local-devices.module.js +19 -3
- package/dist/local-devices/machines.d.ts +30 -3
- package/dist/local-devices/machines.js +79 -3
- package/dist/local-devices/network-grants.d.ts +104 -0
- package/dist/local-devices/network-grants.js +288 -0
- package/dist/local-devices/policy-summary.d.ts +20 -0
- package/dist/local-devices/policy-summary.js +63 -0
- package/dist/orgs/orgs.module.js +4 -10
- package/dist/orgs/roles.d.ts +1 -1
- package/dist/orgs/roles.js +4 -0
- package/dist/orgs/settings.controller.js +4 -3
- package/dist/orgs/settings.service.d.ts +172 -14
- package/dist/orgs/settings.service.js +229 -27
- package/dist/platform/exclusions.js +11 -1
- package/dist/platform/operation.js +4 -0
- package/dist/platform/ops/admin-items.ops.js +1 -1
- package/dist/platform/ops/admin.ops.js +32 -7
- package/dist/platform/ops/agent-skills.ops.js +2 -2
- package/dist/platform/ops/agent-templates.ops.js +2 -2
- package/dist/platform/ops/agents.ops.js +11 -15
- package/dist/platform/ops/devices.ops.js +3 -1
- package/dist/platform/ops/inbox.ops.js +19 -0
- package/dist/platform/ops/org-settings.ops.js +15 -6
- package/dist/platform/ops/orgs.ops.js +1 -0
- package/dist/platform/ops/platform.ops.d.ts +9 -1
- package/dist/platform/ops/platform.ops.js +34 -12
- package/dist/platform/ops/requests.ops.js +3 -2
- package/dist/platform/ops/schedules.ops.d.ts +21 -0
- package/dist/platform/ops/schedules.ops.js +470 -0
- package/dist/platform/ops/skills.ops.js +3 -3
- package/dist/platform/platform-ops.module.d.ts +2 -1
- package/dist/platform/platform-ops.module.js +5 -0
- package/dist/platform/route-schemas.js +3 -0
- package/dist/roles/worker.module.js +3 -0
- package/dist/runtime/context/build.js +13 -4
- package/dist/runtime/context/compaction.js +6 -3
- package/dist/runtime/context/device-paths.d.ts +43 -21
- package/dist/runtime/context/device-paths.js +104 -21
- package/dist/runtime/context/run-context.d.ts +9 -0
- package/dist/runtime/context/run-context.js +8 -0
- package/dist/runtime/context/skills.d.ts +14 -1
- package/dist/runtime/context/skills.js +36 -5
- package/dist/runtime/maintenance.d.ts +4 -2
- package/dist/runtime/maintenance.js +16 -5
- package/dist/runtime/run-finish.d.ts +6 -1
- package/dist/runtime/run-finish.js +16 -2
- package/dist/runtime/run-queue.js +21 -2
- package/dist/runtime/run-worker.js +14 -33
- package/dist/runtime/runtime.module.js +3 -0
- package/dist/runtime/subagents/definition.d.ts +32 -14
- package/dist/runtime/subagents/definition.js +106 -109
- package/dist/runtime/subagents/dispatch.js +4 -3
- package/dist/runtime/subagents/preamble.d.ts +2 -1
- package/dist/runtime/subagents/preamble.js +2 -1
- package/dist/runtime/waits.js +14 -4
- package/dist/sandbox/platform-tiers.d.ts +86 -0
- package/dist/sandbox/platform-tiers.js +97 -0
- package/dist/sandbox/sandbox.service.d.ts +121 -11
- package/dist/sandbox/sandbox.service.js +351 -30
- package/dist/sandbox/tiers.d.ts +98 -0
- package/dist/sandbox/tiers.js +104 -0
- package/dist/schedules/effective-settings.d.ts +16 -0
- package/dist/schedules/effective-settings.js +17 -0
- package/dist/schedules/errors.d.ts +8 -0
- package/dist/schedules/errors.js +8 -0
- package/dist/schedules/fire.d.ts +118 -0
- package/dist/schedules/fire.js +625 -0
- package/dist/schedules/grant-rules.d.ts +94 -0
- package/dist/schedules/grant-rules.js +126 -0
- package/dist/schedules/job-ids.d.ts +32 -0
- package/dist/schedules/job-ids.js +27 -0
- package/dist/schedules/pause.d.ts +9 -0
- package/dist/schedules/pause.js +33 -0
- package/dist/schedules/run-tracker.d.ts +81 -0
- package/dist/schedules/run-tracker.js +333 -0
- package/dist/schedules/scan.job.d.ts +64 -0
- package/dist/schedules/scan.job.js +303 -0
- package/dist/schedules/schedule-math.d.ts +31 -0
- package/dist/schedules/schedule-math.js +223 -0
- package/dist/schedules/scheduled-tasks.service.d.ts +351 -0
- package/dist/schedules/scheduled-tasks.service.js +1193 -0
- package/dist/schedules/schedules.controller.d.ts +68 -0
- package/dist/schedules/schedules.controller.js +242 -0
- package/dist/schedules/schedules.module.d.ts +16 -0
- package/dist/schedules/schedules.module.js +65 -0
- package/dist/schedules/schedules.schemas.d.ts +316 -0
- package/dist/schedules/schedules.schemas.js +186 -0
- package/dist/schedules/store.d.ts +81 -0
- package/dist/schedules/store.js +390 -0
- package/dist/schedules/store.module.d.ts +6 -0
- package/dist/schedules/store.module.js +21 -0
- package/dist/schedules/types.d.ts +108 -0
- package/dist/schedules/types.js +1 -0
- package/dist/schedules/unattended.d.ts +117 -0
- package/dist/schedules/unattended.js +228 -0
- package/dist/setup/oauth-token-probe.js +8 -4
- package/dist/skills/agent-skills.service.d.ts +4 -8
- package/dist/skills/agent-skills.service.js +18 -53
- package/dist/skills/catalog.d.ts +65 -62
- package/dist/skills/catalog.js +109 -206
- package/dist/skills/conversation-catalog.d.ts +3 -3
- package/dist/skills/conversation-catalog.js +5 -12
- package/dist/skills/invoke.d.ts +2 -2
- package/dist/skills/skills.controller.d.ts +3 -1
- package/dist/skills/skills.service.d.ts +38 -37
- package/dist/skills/skills.service.js +168 -107
- package/dist/skills/usable.d.ts +57 -8
- package/dist/skills/usable.js +172 -38
- package/dist/tools/approvals.controller.d.ts +4 -2
- package/dist/tools/approvals.controller.js +17 -2
- package/dist/tools/approvals.schemas.d.ts +1 -0
- package/dist/tools/approvals.schemas.js +3 -1
- package/dist/tools/approvals.service.d.ts +182 -7
- package/dist/tools/approvals.service.js +597 -13
- package/dist/tools/builtin-names.d.ts +25 -4
- package/dist/tools/builtin-names.js +45 -4
- package/dist/tools/builtins/builtin-tools.d.ts +8 -0
- package/dist/tools/builtins/builtin-tools.js +23 -2
- package/dist/tools/builtins/sandbox/bash.js +16 -7
- package/dist/tools/builtins/sandbox/common.d.ts +1 -1
- package/dist/tools/builtins/sandbox/common.js +33 -10
- package/dist/tools/builtins/sandbox/edit.js +7 -3
- package/dist/tools/builtins/sandbox/file-state.d.ts +31 -1
- package/dist/tools/builtins/sandbox/file-state.js +33 -3
- package/dist/tools/builtins/sandbox/write.js +8 -4
- package/dist/tools/builtins/schedules.d.ts +107 -0
- package/dist/tools/builtins/schedules.js +789 -0
- package/dist/tools/elicitation.d.ts +2 -0
- package/dist/tools/elicitation.js +1 -1
- package/dist/tools/fixed-ask.d.ts +7 -0
- package/dist/tools/fixed-ask.js +9 -0
- package/dist/tools/network-asks.d.ts +109 -0
- package/dist/tools/network-asks.js +221 -0
- package/dist/tools/pipeline.d.ts +53 -6
- package/dist/tools/pipeline.js +202 -13
- package/dist/tools/policy.d.ts +21 -8
- package/dist/tools/policy.js +63 -9
- package/dist/tools/registry.d.ts +10 -1
- package/dist/tools/tools.module.js +12 -0
- package/package.json +2 -2
- package/src/migrations/0113_scheduled_tasks.sql +108 -0
- package/src/migrations/0114_network_asks.sql +23 -0
- package/src/migrations/0115_network_requests.sql +28 -0
- package/src/migrations/0116_skills_default_on.sql +37 -0
- package/src/migrations/0117_sandbox_tiers.sql +11 -0
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
2
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
3
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
4
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
|
+
};
|
|
7
|
+
var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
8
|
+
return function (target, key) { decorator(target, key, paramIndex); }
|
|
9
|
+
};
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { getQueueToken } from "@nestjs/bullmq";
|
|
12
|
+
import { Inject, Injectable } from "@nestjs/common";
|
|
13
|
+
import { sql } from "kysely";
|
|
14
|
+
import { KernelConfigService } from "../infra/config/kernel-config.service.js";
|
|
15
|
+
import { afterCommit } from "../infra/database/after-commit.js";
|
|
16
|
+
import { KernelDb } from "../infra/database/kernel-db.js";
|
|
17
|
+
import { TransactionHost } from "../infra/database/transaction-host.js";
|
|
18
|
+
import { Transactional } from "../infra/database/transactional.decorator.js";
|
|
19
|
+
import { LockService } from "../infra/lock/lock.service.js";
|
|
20
|
+
import { LockedCron } from "../infra/schedule/locked-cron.decorator.js";
|
|
21
|
+
import { KERNEL_LOGGER } from "../infra/tokens.js";
|
|
22
|
+
import { MAINTENANCE_QUEUE_NAME } from "../runtime/maintenance-queue.js";
|
|
23
|
+
import { readScheduleSettings } from "./effective-settings.js";
|
|
24
|
+
import { SCHED_SCAN_JOB, schedFireJob } from "./job-ids.js";
|
|
25
|
+
import { pauseTaskIn } from "./pause.js";
|
|
26
|
+
import { lockTaskRow, ScheduledTaskRunTracker } from "./run-tracker.js";
|
|
27
|
+
import { dueTicks, nextTick } from "./schedule-math.js";
|
|
28
|
+
import { ScheduledTaskStore } from "./store.js";
|
|
29
|
+
/** Due tasks per scan; what is left waits for the next minute. */
|
|
30
|
+
const MAX_TASKS = 2000;
|
|
31
|
+
/** A pending task run never fired this long after its tick: its fire job is enqueued again. */
|
|
32
|
+
const REPAIR_AFTER_MS = 10 * 60_000;
|
|
33
|
+
/** ... and this long after its tick it is given up (`skipped`, `missed`). */
|
|
34
|
+
const GIVE_UP_AFTER_MS = 60 * 60_000;
|
|
35
|
+
const REPAIR_BATCH = 100;
|
|
36
|
+
/**
|
|
37
|
+
* The schedule math could not evaluate a due task's stored trigger (its transaction rolled back);
|
|
38
|
+
* the scan pauses it. Only the math raises it: a database error fails the scan pass instead (the
|
|
39
|
+
* next minute retries) and never pauses a healthy task.
|
|
40
|
+
*/
|
|
41
|
+
class TaskScanFailed extends Error {
|
|
42
|
+
taskId;
|
|
43
|
+
constructor(taskId, cause) {
|
|
44
|
+
super(`scheduled task ${taskId} cannot be advanced`, { cause });
|
|
45
|
+
this.taskId = taskId;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The scheduled task scanner (spec §6), once a minute across replicas. Each due task in its own
|
|
50
|
+
* transaction, locked with `FOR UPDATE SKIP LOCKED` (replicas share the work and never take the
|
|
51
|
+
* same task): the ticks missed outside the org's `catchUpWindowMinutes` become one `skipped` run
|
|
52
|
+
* (`missed`, at the first missed tick), the latest tick in the window gets its run (the unique
|
|
53
|
+
* `(task_id, scheduled_for)` key makes a second insert a no-op), `next_run_at` moves to the next
|
|
54
|
+
* tick plus the task's jitter (or a `once` task ends, or one past its `expires_at`), and after the
|
|
55
|
+
* commit `sched-fire-<taskRunId>` is enqueued. A task whose stored trigger the schedule math cannot
|
|
56
|
+
* evaluate is paused (`invalid_schedule`) instead of failing the scan; a database error fails the
|
|
57
|
+
* pass (the next minute retries) and pauses nothing.
|
|
58
|
+
*
|
|
59
|
+
* Then the repair: a run still `pending` and unfired 10 minutes after its tick has its fire job
|
|
60
|
+
* enqueued again, and is given up (`skipped`, `missed`) after an hour; a run fired an hour ago and
|
|
61
|
+
* still `pending` (its start failed for good) goes through the run tracker's check, which links,
|
|
62
|
+
* settles or releases it, so a `once` task's last run never stays pending.
|
|
63
|
+
*/
|
|
64
|
+
let ScheduledTaskScanJob = class ScheduledTaskScanJob {
|
|
65
|
+
kdb;
|
|
66
|
+
txHost;
|
|
67
|
+
config;
|
|
68
|
+
store;
|
|
69
|
+
tracker;
|
|
70
|
+
maintenance;
|
|
71
|
+
lockService;
|
|
72
|
+
log;
|
|
73
|
+
constructor(kdb, txHost, config, store, tracker, maintenance, lockService, log) {
|
|
74
|
+
this.kdb = kdb;
|
|
75
|
+
this.txHost = txHost;
|
|
76
|
+
this.config = config;
|
|
77
|
+
this.store = store;
|
|
78
|
+
this.tracker = tracker;
|
|
79
|
+
this.maintenance = maintenance;
|
|
80
|
+
this.lockService = lockService;
|
|
81
|
+
this.log = log;
|
|
82
|
+
}
|
|
83
|
+
get db() {
|
|
84
|
+
return this.kdb.db;
|
|
85
|
+
}
|
|
86
|
+
async scan() {
|
|
87
|
+
await this.scanAt(new Date());
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* One scan as of `now` (tests pass their own); returns how many due tasks it handled. The repair
|
|
91
|
+
* runs even when the scan loop failed (its error is rethrown afterwards). `repairBatch`: how many
|
|
92
|
+
* rows each repair step takes (tests pass a small one).
|
|
93
|
+
*/
|
|
94
|
+
async scanAt(now, repairBatch = REPAIR_BATCH) {
|
|
95
|
+
const settings = new Map();
|
|
96
|
+
let total = 0;
|
|
97
|
+
try {
|
|
98
|
+
for (; total < MAX_TASKS; total++) {
|
|
99
|
+
try {
|
|
100
|
+
if (!(await this.scanOne(now, settings)))
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
if (!(err instanceof TaskScanFailed))
|
|
105
|
+
throw err;
|
|
106
|
+
// one task that cannot be advanced must not hold the others back every minute
|
|
107
|
+
this.log.error("scheduled task cannot be advanced; paused", {
|
|
108
|
+
taskId: err.taskId,
|
|
109
|
+
err: err.cause,
|
|
110
|
+
});
|
|
111
|
+
await this.quarantine(err.taskId);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
await this.repair(now, repairBatch);
|
|
117
|
+
}
|
|
118
|
+
return total;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* One due task in its own transaction (a task whose trigger cannot be evaluated rolls back
|
|
122
|
+
* alone and is paused, so it cannot block the others): locked with `SKIP LOCKED`, so replicas take different tasks. False
|
|
123
|
+
* when no task is due.
|
|
124
|
+
*/
|
|
125
|
+
async scanOne(now, settings) {
|
|
126
|
+
const t = this.db;
|
|
127
|
+
const [task] = await this.store.lockDue(t, now, 1);
|
|
128
|
+
if (!task)
|
|
129
|
+
return false;
|
|
130
|
+
let s = settings.get(task.orgId);
|
|
131
|
+
if (s === undefined) {
|
|
132
|
+
s = await readScheduleSettings(t, this.config, task.orgId);
|
|
133
|
+
settings.set(task.orgId, s);
|
|
134
|
+
}
|
|
135
|
+
const fired = await this.advance(t, task, now, s.catchUpWindowMinutes);
|
|
136
|
+
if (fired !== null) {
|
|
137
|
+
const id = fired;
|
|
138
|
+
afterCommit(() => this.enqueue([id]));
|
|
139
|
+
}
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
/** A task the scan cannot advance stops being due: paused with `invalid_schedule`. */
|
|
143
|
+
async quarantine(taskId) {
|
|
144
|
+
await pauseTaskIn(this.db, taskId, "invalid_schedule", null);
|
|
145
|
+
}
|
|
146
|
+
/** One due task (see the class); returns the id of the run to fire, if any. */
|
|
147
|
+
async advance(t, task, now, catchUpWindowMinutes) {
|
|
148
|
+
const jitterMs = task.jitterSeconds * 1000;
|
|
149
|
+
// ticks are before jitter: the due tick is next_run_at - jitter, and a tick is due once
|
|
150
|
+
// tick + jitter <= now
|
|
151
|
+
const dueTick = new Date(task.nextRunAt.getTime() - jitterMs);
|
|
152
|
+
const until = new Date(now.getTime() - jitterMs);
|
|
153
|
+
// the schedule math alone may mark the task broken; database errors fail this pass only
|
|
154
|
+
let ticks;
|
|
155
|
+
let next;
|
|
156
|
+
try {
|
|
157
|
+
ticks = dueTicks(task.trigger, new Date(dueTick.getTime() - 1), until, catchUpWindowMinutes);
|
|
158
|
+
next = nextTick(task.trigger, until);
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
throw new TaskScanFailed(task.id, err);
|
|
162
|
+
}
|
|
163
|
+
const expires = task.expiresAt?.getTime() ?? Number.POSITIVE_INFINITY;
|
|
164
|
+
const fireAt = ticks.fire !== null && ticks.fire.getTime() <= expires ? ticks.fire : null;
|
|
165
|
+
const first = ticks.firstMissed;
|
|
166
|
+
if (first !== null && first.getTime() <= expires) {
|
|
167
|
+
await this.store.insertRun(t, {
|
|
168
|
+
id: randomUUID(),
|
|
169
|
+
taskId: task.id,
|
|
170
|
+
orgId: task.orgId,
|
|
171
|
+
scheduledFor: first,
|
|
172
|
+
trial: false,
|
|
173
|
+
status: "skipped",
|
|
174
|
+
skipReason: "missed",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
let fired = null;
|
|
178
|
+
if (fireAt !== null) {
|
|
179
|
+
const run = await this.store.insertRun(t, {
|
|
180
|
+
id: randomUUID(),
|
|
181
|
+
taskId: task.id,
|
|
182
|
+
orgId: task.orgId,
|
|
183
|
+
scheduledFor: fireAt,
|
|
184
|
+
trial: false,
|
|
185
|
+
});
|
|
186
|
+
fired = run?.id ?? null;
|
|
187
|
+
}
|
|
188
|
+
const patch = next === null
|
|
189
|
+
? { status: "ended", nextRunAt: null }
|
|
190
|
+
: next.getTime() > expires
|
|
191
|
+
? { status: "ended", pauseReason: "expired", nextRunAt: null }
|
|
192
|
+
: { nextRunAt: new Date(next.getTime() + jitterMs) };
|
|
193
|
+
await this.store.updateTask(t, task.id, patch);
|
|
194
|
+
return fired;
|
|
195
|
+
}
|
|
196
|
+
/** Enqueues the fire jobs (after the commit; a failure is repaired by a later scan). */
|
|
197
|
+
async enqueue(ids) {
|
|
198
|
+
for (const id of ids) {
|
|
199
|
+
const job = schedFireJob(id);
|
|
200
|
+
try {
|
|
201
|
+
await this.maintenance.add(job.name, job.data, job.opts);
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
this.log.warn("enqueueing a scheduled task run failed", { taskRunId: id, err });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Lost fire jobs (see the class). */
|
|
209
|
+
async repair(now, batch) {
|
|
210
|
+
const stale = await this.db
|
|
211
|
+
.selectFrom("scheduled_task_runs")
|
|
212
|
+
.select(["id", "scheduled_for"])
|
|
213
|
+
.where("status", "=", "pending")
|
|
214
|
+
.where("fired_at", "is", null)
|
|
215
|
+
.where("scheduled_for", "<", new Date(now.getTime() - REPAIR_AFTER_MS))
|
|
216
|
+
.orderBy("scheduled_for")
|
|
217
|
+
.limit(batch)
|
|
218
|
+
.execute();
|
|
219
|
+
const retry = [];
|
|
220
|
+
const lost = [];
|
|
221
|
+
for (const r of stale) {
|
|
222
|
+
const at = r.scheduled_for instanceof Date ? r.scheduled_for : new Date(r.scheduled_for);
|
|
223
|
+
(now.getTime() - at.getTime() > GIVE_UP_AFTER_MS ? lost : retry).push(r.id);
|
|
224
|
+
}
|
|
225
|
+
for (const id of lost)
|
|
226
|
+
await this.giveUp(id);
|
|
227
|
+
await this.enqueue(retry);
|
|
228
|
+
// the least recently rechecked first, so runs still in flight do not starve the others
|
|
229
|
+
const started = await this.db
|
|
230
|
+
.selectFrom("scheduled_task_runs")
|
|
231
|
+
.select("id")
|
|
232
|
+
.where("status", "=", "pending")
|
|
233
|
+
.where("fired_at", "<", new Date(now.getTime() - GIVE_UP_AFTER_MS))
|
|
234
|
+
.orderBy(sql `rechecked_at NULLS FIRST`)
|
|
235
|
+
.orderBy("scheduled_for")
|
|
236
|
+
.limit(batch)
|
|
237
|
+
.execute();
|
|
238
|
+
for (const r of started)
|
|
239
|
+
await this.recheck(r.id, now);
|
|
240
|
+
}
|
|
241
|
+
/** A run never fired an hour after its tick: given up, task row first (the one lock order). */
|
|
242
|
+
async giveUp(taskRunId) {
|
|
243
|
+
const owner = await this.db
|
|
244
|
+
.selectFrom("scheduled_task_runs")
|
|
245
|
+
.select("task_id")
|
|
246
|
+
.where("id", "=", taskRunId)
|
|
247
|
+
.executeTakeFirst();
|
|
248
|
+
if (!owner)
|
|
249
|
+
return;
|
|
250
|
+
await lockTaskRow(this.db, owner.task_id);
|
|
251
|
+
await this.db
|
|
252
|
+
.updateTable("scheduled_task_runs")
|
|
253
|
+
.set({ status: "skipped", skip_reason: "missed", error: "The run could not be started" })
|
|
254
|
+
.where("id", "=", taskRunId)
|
|
255
|
+
.where("status", "=", "pending")
|
|
256
|
+
.where("fired_at", "is", null)
|
|
257
|
+
.execute();
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* A started run still pending long after: the tracker links, settles or releases it; one still
|
|
261
|
+
* in flight is stamped (`rechecked_at`) and goes to the back of the next rechecks.
|
|
262
|
+
*/
|
|
263
|
+
async recheck(taskRunId, now) {
|
|
264
|
+
const run = await this.store.updateRun(this.db, taskRunId, {});
|
|
265
|
+
if (run?.status !== "pending")
|
|
266
|
+
return;
|
|
267
|
+
// inFlightIn takes the task row before the run (the one lock order)
|
|
268
|
+
if (await this.tracker.inFlightIn(run, now)) {
|
|
269
|
+
await this.db
|
|
270
|
+
.updateTable("scheduled_task_runs")
|
|
271
|
+
.set({ rechecked_at: now })
|
|
272
|
+
.where("id", "=", taskRunId)
|
|
273
|
+
.execute();
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
__decorate([
|
|
278
|
+
LockedCron("* * * * *", { name: SCHED_SCAN_JOB })
|
|
279
|
+
], ScheduledTaskScanJob.prototype, "scan", null);
|
|
280
|
+
__decorate([
|
|
281
|
+
Transactional()
|
|
282
|
+
], ScheduledTaskScanJob.prototype, "scanOne", null);
|
|
283
|
+
__decorate([
|
|
284
|
+
Transactional()
|
|
285
|
+
], ScheduledTaskScanJob.prototype, "quarantine", null);
|
|
286
|
+
__decorate([
|
|
287
|
+
Transactional()
|
|
288
|
+
], ScheduledTaskScanJob.prototype, "giveUp", null);
|
|
289
|
+
__decorate([
|
|
290
|
+
Transactional()
|
|
291
|
+
], ScheduledTaskScanJob.prototype, "recheck", null);
|
|
292
|
+
ScheduledTaskScanJob = __decorate([
|
|
293
|
+
Injectable(),
|
|
294
|
+
__param(0, Inject(KernelDb)),
|
|
295
|
+
__param(1, Inject(TransactionHost)),
|
|
296
|
+
__param(2, Inject(KernelConfigService)),
|
|
297
|
+
__param(3, Inject(ScheduledTaskStore)),
|
|
298
|
+
__param(4, Inject(ScheduledTaskRunTracker)),
|
|
299
|
+
__param(5, Inject(getQueueToken(MAINTENANCE_QUEUE_NAME))),
|
|
300
|
+
__param(6, Inject(LockService)),
|
|
301
|
+
__param(7, Inject(KERNEL_LOGGER))
|
|
302
|
+
], ScheduledTaskScanJob);
|
|
303
|
+
export { ScheduledTaskScanJob };
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ScheduleTrigger } from "./types.js";
|
|
2
|
+
export type TriggerError = "schedule.invalid_cron" | "schedule.invalid_time_zone" | "schedule.invalid_once_time" | "schedule.once_in_past";
|
|
3
|
+
export declare function validateTrigger(t: ScheduleTrigger, now: Date): {
|
|
4
|
+
ok: true;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
code: TriggerError;
|
|
8
|
+
};
|
|
9
|
+
/** The first tick strictly after `after` (before jitter); null for a past once task. */
|
|
10
|
+
export declare function nextTick(t: ScheduleTrigger, after: Date): Date | null;
|
|
11
|
+
/** The smallest gap, in minutes, among the next 50 fire times after `from`; null for once. */
|
|
12
|
+
export declare function minGapMinutes(t: ScheduleTrigger, from: Date): number | null;
|
|
13
|
+
/** A fixed per-task offset: `fnv1a32(id) mod min(floor(10 % of the interval), 300 s)`; once is 0. */
|
|
14
|
+
export declare function jitterSeconds(taskId: string, t: ScheduleTrigger, from: Date): number;
|
|
15
|
+
/**
|
|
16
|
+
* {@link jitterSeconds} from a gap already sampled ({@link minGapMinutes} of the same trigger and
|
|
17
|
+
* time), so a caller that checked the interval does not walk the 50 ticks again; null gap is 0.
|
|
18
|
+
*/
|
|
19
|
+
export declare function jitterForGap(taskId: string, gap: number | null): number;
|
|
20
|
+
/**
|
|
21
|
+
* The ticks in (lastTick, now], without walking them (spec §6 step 1): `fire` is the latest one
|
|
22
|
+
* when it lies inside the catch-up window, else null; `firstMissed` is the earliest of the others
|
|
23
|
+
* (every tick but `fire`), which the scanner records as one skipped run, or null when there are
|
|
24
|
+
* none. The latest tick is found by widening a window back from `now` (1 minute, doubling) until
|
|
25
|
+
* it holds a tick, then stepping forward inside it, so a month-long outage costs a handful of tick
|
|
26
|
+
* computations, not thousands.
|
|
27
|
+
*/
|
|
28
|
+
export declare function dueTicks(t: ScheduleTrigger, lastTick: Date, now: Date, catchUpWindowMinutes: number): {
|
|
29
|
+
fire: Date | null;
|
|
30
|
+
firstMissed: Date | null;
|
|
31
|
+
};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { CronTime } from "cron";
|
|
2
|
+
import { isTimeZone } from "../time-zone.js";
|
|
3
|
+
/** Spec §5: how many upcoming fire times the minimum-interval check looks at. */
|
|
4
|
+
const GAP_SAMPLE = 50;
|
|
5
|
+
const JITTER_CAP_SECONDS = 300;
|
|
6
|
+
const LOCAL_AT = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/;
|
|
7
|
+
function cronTime(expr, timeZone) {
|
|
8
|
+
if (expr.trim().split(/\s+/).length !== 5)
|
|
9
|
+
return null;
|
|
10
|
+
try {
|
|
11
|
+
return new CronTime(expr.trim(), timeZone);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const formatters = new Map();
|
|
18
|
+
/** One formatter per zone (pure, so a module-level cache is safe). */
|
|
19
|
+
function zoneFormat(timeZone) {
|
|
20
|
+
let f = formatters.get(timeZone);
|
|
21
|
+
if (!f) {
|
|
22
|
+
f = new Intl.DateTimeFormat("en-US", {
|
|
23
|
+
timeZone,
|
|
24
|
+
hourCycle: "h23",
|
|
25
|
+
year: "numeric",
|
|
26
|
+
month: "numeric",
|
|
27
|
+
day: "numeric",
|
|
28
|
+
hour: "numeric",
|
|
29
|
+
minute: "numeric",
|
|
30
|
+
second: "numeric",
|
|
31
|
+
});
|
|
32
|
+
formatters.set(timeZone, f);
|
|
33
|
+
}
|
|
34
|
+
return f;
|
|
35
|
+
}
|
|
36
|
+
/** The wall clock of `utc` (epoch ms) in `timeZone`, read as if it were UTC (epoch ms). */
|
|
37
|
+
function wallOf(utc, timeZone) {
|
|
38
|
+
const p = Object.fromEntries(zoneFormat(timeZone)
|
|
39
|
+
.formatToParts(new Date(utc))
|
|
40
|
+
.map((x) => [x.type, x.value]));
|
|
41
|
+
return Date.UTC(Number(p.year), Number(p.month) - 1, Number(p.day), Number(p.hour), Number(p.minute), Number(p.second));
|
|
42
|
+
}
|
|
43
|
+
/** The zone's UTC offset (ms) at `utc`. */
|
|
44
|
+
function offsetAt(utc, timeZone) {
|
|
45
|
+
return wallOf(Math.floor(utc / 1000) * 1000, timeZone) - Math.floor(utc / 1000) * 1000;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The UTC instant of a local date-time (no offset) in `timeZone`; null when malformed or out of
|
|
49
|
+
* range. A repeated local time takes its first occurrence; a time inside a DST gap resolves to the
|
|
50
|
+
* first valid instant after the gap.
|
|
51
|
+
*/
|
|
52
|
+
function onceInstant(at, timeZone) {
|
|
53
|
+
const m = LOCAL_AT.exec(at);
|
|
54
|
+
if (!m)
|
|
55
|
+
return null;
|
|
56
|
+
const n = m.slice(1).map((v) => (v === undefined ? 0 : Number(v)));
|
|
57
|
+
const [y = 0, mo = 0, d = 0, h = 0, mi = 0, sec = 0] = n;
|
|
58
|
+
const wall = Date.UTC(y, mo - 1, d, h, mi, sec);
|
|
59
|
+
const check = new Date(wall);
|
|
60
|
+
if (check.getUTCFullYear() !== y ||
|
|
61
|
+
check.getUTCMonth() !== mo - 1 ||
|
|
62
|
+
check.getUTCDate() !== d ||
|
|
63
|
+
h > 23 ||
|
|
64
|
+
mi > 59 ||
|
|
65
|
+
sec > 59) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const before = offsetAt(wall - 86400000, timeZone);
|
|
69
|
+
const after = offsetAt(wall + 86400000, timeZone);
|
|
70
|
+
const valid = [before, after]
|
|
71
|
+
.map((o) => wall - o)
|
|
72
|
+
.filter((u, i) => offsetAt(u, timeZone) === [before, after][i])
|
|
73
|
+
.sort((x, z) => x - z);
|
|
74
|
+
if (valid[0] !== undefined)
|
|
75
|
+
return new Date(valid[0]);
|
|
76
|
+
// In a gap: find the transition second between the two candidates.
|
|
77
|
+
let lo = Math.floor((wall - after) / 1000);
|
|
78
|
+
let hi = Math.floor((wall - before) / 1000);
|
|
79
|
+
while (lo < hi) {
|
|
80
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
81
|
+
if (offsetAt(mid * 1000, timeZone) === before)
|
|
82
|
+
lo = mid + 1;
|
|
83
|
+
else
|
|
84
|
+
hi = mid;
|
|
85
|
+
}
|
|
86
|
+
return new Date(lo * 1000);
|
|
87
|
+
}
|
|
88
|
+
export function validateTrigger(t, now) {
|
|
89
|
+
if (!isTimeZone(t.timeZone))
|
|
90
|
+
return { ok: false, code: "schedule.invalid_time_zone" };
|
|
91
|
+
if (t.kind === "cron") {
|
|
92
|
+
return cronTime(t.expr, t.timeZone)
|
|
93
|
+
? { ok: true }
|
|
94
|
+
: { ok: false, code: "schedule.invalid_cron" };
|
|
95
|
+
}
|
|
96
|
+
const at = onceInstant(t.at, t.timeZone);
|
|
97
|
+
if (!at)
|
|
98
|
+
return { ok: false, code: "schedule.invalid_once_time" };
|
|
99
|
+
if (at.getTime() <= now.getTime())
|
|
100
|
+
return { ok: false, code: "schedule.once_in_past" };
|
|
101
|
+
return { ok: true };
|
|
102
|
+
}
|
|
103
|
+
/** The first tick strictly after `after` (before jitter); null for a past once task. */
|
|
104
|
+
export function nextTick(t, after) {
|
|
105
|
+
return ticker(t)(after);
|
|
106
|
+
}
|
|
107
|
+
/** How far back a DST transition can move a tick: longer than any gap (spec §5). */
|
|
108
|
+
const TRANSITION_LOOKBACK_MS = 6 * 3600_000;
|
|
109
|
+
/**
|
|
110
|
+
* {@link nextTick} for one trigger, with its `CronTime`s built once: callers that walk many ticks
|
|
111
|
+
* (the gap sample, the catch-up enumeration) reuse one ticker instead of rebuilding per tick.
|
|
112
|
+
*/
|
|
113
|
+
function ticker(t) {
|
|
114
|
+
if (t.kind === "once") {
|
|
115
|
+
const at = onceInstant(t.at, t.timeZone);
|
|
116
|
+
return (after) => (at && at.getTime() > after.getTime() ? at : null);
|
|
117
|
+
}
|
|
118
|
+
const time = cronTime(t.expr, t.timeZone);
|
|
119
|
+
const wallTime = cronTime(t.expr, "UTC");
|
|
120
|
+
if (!time || !wallTime)
|
|
121
|
+
return () => null;
|
|
122
|
+
return (after) => {
|
|
123
|
+
let cursor = after;
|
|
124
|
+
for (let i = 0; i < 10; i++) {
|
|
125
|
+
const tick = new Date(time.getNextDateFrom(cursor, t.timeZone).toMillis());
|
|
126
|
+
const ms = tick.getTime();
|
|
127
|
+
// CronTime moves a tick that falls in a DST gap to the next valid instant; the spec says a
|
|
128
|
+
// skipped local time does not fire, so keep only ticks whose wall clock the expression
|
|
129
|
+
// names. A moved tick lies just after a transition, so only then is the check needed.
|
|
130
|
+
if (offsetAt(ms, t.timeZone) === offsetAt(ms - TRANSITION_LOOKBACK_MS, t.timeZone)) {
|
|
131
|
+
return tick;
|
|
132
|
+
}
|
|
133
|
+
const wall = localWallClock(tick, t.timeZone);
|
|
134
|
+
if (wallTime.getNextDateFrom(new Date(wall - 1000), "UTC").toMillis() === wall)
|
|
135
|
+
return tick;
|
|
136
|
+
cursor = tick;
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** The wall clock of `at` in `timeZone`, read as if it were UTC (epoch ms, seconds dropped). */
|
|
142
|
+
function localWallClock(at, timeZone) {
|
|
143
|
+
return Math.floor(wallOf(at.getTime(), timeZone) / 60000) * 60000;
|
|
144
|
+
}
|
|
145
|
+
/** The smallest gap, in minutes, among the next 50 fire times after `from`; null for once. */
|
|
146
|
+
export function minGapMinutes(t, from) {
|
|
147
|
+
if (t.kind === "once")
|
|
148
|
+
return null;
|
|
149
|
+
const next = ticker(t);
|
|
150
|
+
let prev = next(from);
|
|
151
|
+
let min = Number.POSITIVE_INFINITY;
|
|
152
|
+
for (let i = 1; prev && i < GAP_SAMPLE; i++) {
|
|
153
|
+
const tick = next(prev);
|
|
154
|
+
if (!tick)
|
|
155
|
+
break;
|
|
156
|
+
min = Math.min(min, (tick.getTime() - prev.getTime()) / 60000);
|
|
157
|
+
prev = tick;
|
|
158
|
+
}
|
|
159
|
+
return Number.isFinite(min) ? min : null;
|
|
160
|
+
}
|
|
161
|
+
/** 32-bit FNV-1a over the UTF-8 bytes of `s`. */
|
|
162
|
+
function fnv1a32(s) {
|
|
163
|
+
let h = 0x811c9dc5;
|
|
164
|
+
for (const b of new TextEncoder().encode(s)) {
|
|
165
|
+
h ^= b;
|
|
166
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
167
|
+
}
|
|
168
|
+
return h;
|
|
169
|
+
}
|
|
170
|
+
/** A fixed per-task offset: `fnv1a32(id) mod min(floor(10 % of the interval), 300 s)`; once is 0. */
|
|
171
|
+
export function jitterSeconds(taskId, t, from) {
|
|
172
|
+
return jitterForGap(taskId, minGapMinutes(t, from));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* {@link jitterSeconds} from a gap already sampled ({@link minGapMinutes} of the same trigger and
|
|
176
|
+
* time), so a caller that checked the interval does not walk the 50 ticks again; null gap is 0.
|
|
177
|
+
*/
|
|
178
|
+
export function jitterForGap(taskId, gap) {
|
|
179
|
+
if (gap === null)
|
|
180
|
+
return 0;
|
|
181
|
+
const range = Math.min(Math.floor(0.1 * gap * 60), JITTER_CAP_SECONDS);
|
|
182
|
+
return range > 0 ? fnv1a32(taskId) % range : 0;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The ticks in (lastTick, now], without walking them (spec §6 step 1): `fire` is the latest one
|
|
186
|
+
* when it lies inside the catch-up window, else null; `firstMissed` is the earliest of the others
|
|
187
|
+
* (every tick but `fire`), which the scanner records as one skipped run, or null when there are
|
|
188
|
+
* none. The latest tick is found by widening a window back from `now` (1 minute, doubling) until
|
|
189
|
+
* it holds a tick, then stepping forward inside it, so a month-long outage costs a handful of tick
|
|
190
|
+
* computations, not thousands.
|
|
191
|
+
*/
|
|
192
|
+
export function dueTicks(t, lastTick, now, catchUpWindowMinutes) {
|
|
193
|
+
const next = ticker(t);
|
|
194
|
+
const end = now.getTime();
|
|
195
|
+
const first = next(lastTick);
|
|
196
|
+
if (!first || first.getTime() > end)
|
|
197
|
+
return { fire: null, firstMissed: null };
|
|
198
|
+
const latest = latestTick(next, lastTick.getTime(), end) ?? first;
|
|
199
|
+
const fire = latest.getTime() >= end - catchUpWindowMinutes * 60000 ? latest : null;
|
|
200
|
+
const missed = fire === null || first.getTime() < fire.getTime();
|
|
201
|
+
return { fire, firstMissed: missed ? first : null };
|
|
202
|
+
}
|
|
203
|
+
/** The latest tick in (lastMs, endMs]; null when there is none. */
|
|
204
|
+
function latestTick(next, lastMs, endMs) {
|
|
205
|
+
let from = null;
|
|
206
|
+
for (let span = 60_000;; span *= 2) {
|
|
207
|
+
const lo = Math.max(endMs - span, lastMs);
|
|
208
|
+
const tick = next(new Date(lo));
|
|
209
|
+
if (tick && tick.getTime() <= endMs) {
|
|
210
|
+
from = tick;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
if (lo === lastMs)
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
let latest = from;
|
|
217
|
+
for (;;) {
|
|
218
|
+
const n = next(latest);
|
|
219
|
+
if (!n || n.getTime() > endMs)
|
|
220
|
+
return latest;
|
|
221
|
+
latest = n;
|
|
222
|
+
}
|
|
223
|
+
}
|