@namzu/sdk 45.0.0 → 45.1.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 +55 -0
- package/dist/authorization/gate.d.ts.map +1 -1
- package/dist/authorization/gate.js +12 -2
- package/dist/authorization/gate.js.map +1 -1
- package/dist/authorization/rules.d.ts.map +1 -1
- package/dist/authorization/rules.js +37 -6
- package/dist/authorization/rules.js.map +1 -1
- package/dist/authorization/shell-lexer.d.ts +14 -0
- package/dist/authorization/shell-lexer.d.ts.map +1 -1
- package/dist/authorization/shell-lexer.js +19 -6
- package/dist/authorization/shell-lexer.js.map +1 -1
- package/dist/bridge/a2a/mapper.d.ts.map +1 -1
- package/dist/bridge/a2a/mapper.js +2 -0
- package/dist/bridge/a2a/mapper.js.map +1 -1
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +1 -0
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/directory/types.d.ts +2 -0
- package/dist/directory/types.d.ts.map +1 -1
- package/dist/directory/types.js.map +1 -1
- package/dist/manager/resident/outbox.d.ts +4 -4
- package/dist/prompt/coding-agent-doctrine.d.ts +1 -1
- package/dist/prompt/coding-agent-doctrine.d.ts.map +1 -1
- package/dist/prompt/coding-agent-doctrine.js +1 -0
- package/dist/prompt/coding-agent-doctrine.js.map +1 -1
- package/dist/public-runtime.d.ts +4 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +11 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-tools.d.ts +4 -0
- package/dist/public-tools.d.ts.map +1 -1
- package/dist/public-tools.js +10 -0
- package/dist/public-tools.js.map +1 -1
- package/dist/public-types.d.ts +7 -1
- package/dist/public-types.d.ts.map +1 -1
- package/dist/runtime/query/declined.d.ts +12 -0
- package/dist/runtime/query/declined.d.ts.map +1 -0
- package/dist/runtime/query/declined.js +12 -0
- package/dist/runtime/query/declined.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +3 -1
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +14 -2
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +7 -0
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +6 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/handoff.d.ts +22 -0
- package/dist/runtime/query/iteration/phases/handoff.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/handoff.js +65 -0
- package/dist/runtime/query/iteration/phases/handoff.js.map +1 -0
- package/dist/runtime/query/iteration/phases/index.d.ts +1 -0
- package/dist/runtime/query/iteration/phases/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/index.js +1 -0
- package/dist/runtime/query/iteration/phases/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +8 -3
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/resume-pending.d.ts.map +1 -1
- package/dist/runtime/query/resume-pending.js +3 -2
- package/dist/runtime/query/resume-pending.js.map +1 -1
- package/dist/runtime/query/review-policy.d.ts.map +1 -1
- package/dist/runtime/query/review-policy.js +4 -3
- package/dist/runtime/query/review-policy.js.map +1 -1
- package/dist/schedules/cron.d.ts +21 -0
- package/dist/schedules/cron.d.ts.map +1 -0
- package/dist/schedules/cron.js +167 -0
- package/dist/schedules/cron.js.map +1 -0
- package/dist/schedules/describe.d.ts +14 -0
- package/dist/schedules/describe.d.ts.map +1 -0
- package/dist/schedules/describe.js +133 -0
- package/dist/schedules/describe.js.map +1 -0
- package/dist/schedules/errors.d.ts +11 -0
- package/dist/schedules/errors.d.ts.map +1 -0
- package/dist/schedules/errors.js +15 -0
- package/dist/schedules/errors.js.map +1 -0
- package/dist/schedules/evaluate.d.ts +35 -0
- package/dist/schedules/evaluate.d.ts.map +1 -0
- package/dist/schedules/evaluate.js +158 -0
- package/dist/schedules/evaluate.js.map +1 -0
- package/dist/schedules/index.d.ts +11 -0
- package/dist/schedules/index.d.ts.map +1 -0
- package/dist/schedules/index.js +8 -0
- package/dist/schedules/index.js.map +1 -0
- package/dist/schedules/next-fire.d.ts +50 -0
- package/dist/schedules/next-fire.d.ts.map +1 -0
- package/dist/schedules/next-fire.js +250 -0
- package/dist/schedules/next-fire.js.map +1 -0
- package/dist/schedules/spec.d.ts +30 -0
- package/dist/schedules/spec.d.ts.map +1 -0
- package/dist/schedules/spec.js +169 -0
- package/dist/schedules/spec.js.map +1 -0
- package/dist/schedules/types.d.ts +144 -0
- package/dist/schedules/types.d.ts.map +1 -0
- package/dist/schedules/types.js +11 -0
- package/dist/schedules/types.js.map +1 -0
- package/dist/schedules/tz.d.ts +44 -0
- package/dist/schedules/tz.d.ts.map +1 -0
- package/dist/schedules/tz.js +141 -0
- package/dist/schedules/tz.js.map +1 -0
- package/dist/skills/index.d.ts +1 -1
- package/dist/skills/index.d.ts.map +1 -1
- package/dist/skills/index.js +1 -1
- package/dist/skills/index.js.map +1 -1
- package/dist/skills/loader.d.ts +16 -3
- package/dist/skills/loader.d.ts.map +1 -1
- package/dist/skills/loader.js +48 -4
- package/dist/skills/loader.js.map +1 -1
- package/dist/tools/builtins/browser-url.d.ts +83 -0
- package/dist/tools/builtins/browser-url.d.ts.map +1 -0
- package/dist/tools/builtins/browser-url.js +240 -0
- package/dist/tools/builtins/browser-url.js.map +1 -0
- package/dist/tools/builtins/browser.d.ts +367 -0
- package/dist/tools/builtins/browser.d.ts.map +1 -0
- package/dist/tools/builtins/browser.js +704 -0
- package/dist/tools/builtins/browser.js.map +1 -0
- package/dist/tools/builtins/skill.d.ts.map +1 -1
- package/dist/tools/builtins/skill.js +15 -0
- package/dist/tools/builtins/skill.js.map +1 -1
- package/dist/tools/defineTool.d.ts +2 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +1 -0
- package/dist/tools/defineTool.js.map +1 -1
- package/dist/tools/schedules/index.d.ts +5 -0
- package/dist/tools/schedules/index.d.ts.map +1 -0
- package/dist/tools/schedules/index.js +4 -0
- package/dist/tools/schedules/index.js.map +1 -0
- package/dist/tools/schedules/loop-tool.d.ts +14 -0
- package/dist/tools/schedules/loop-tool.d.ts.map +1 -0
- package/dist/tools/schedules/loop-tool.js +81 -0
- package/dist/tools/schedules/loop-tool.js.map +1 -0
- package/dist/tools/schedules/present.d.ts +16 -0
- package/dist/tools/schedules/present.d.ts.map +1 -0
- package/dist/tools/schedules/present.js +69 -0
- package/dist/tools/schedules/present.js.map +1 -0
- package/dist/tools/schedules/prompt-scan.d.ts +17 -0
- package/dist/tools/schedules/prompt-scan.d.ts.map +1 -0
- package/dist/tools/schedules/prompt-scan.js +92 -0
- package/dist/tools/schedules/prompt-scan.js.map +1 -0
- package/dist/tools/schedules/schedule-tool.d.ts +16 -0
- package/dist/tools/schedules/schedule-tool.d.ts.map +1 -0
- package/dist/tools/schedules/schedule-tool.js +327 -0
- package/dist/tools/schedules/schedule-tool.js.map +1 -0
- package/dist/tools/schedules/types.d.ts +184 -0
- package/dist/tools/schedules/types.d.ts.map +1 -0
- package/dist/tools/schedules/types.js +11 -0
- package/dist/tools/schedules/types.js.map +1 -0
- package/dist/types/authorization/index.d.ts +86 -9
- package/dist/types/authorization/index.d.ts.map +1 -1
- package/dist/types/authorization/index.js +11 -1
- package/dist/types/authorization/index.js.map +1 -1
- package/dist/types/browser/index.d.ts +280 -0
- package/dist/types/browser/index.d.ts.map +1 -0
- package/dist/types/browser/index.js +12 -0
- package/dist/types/browser/index.js.map +1 -0
- package/dist/types/session/events.d.ts +6 -0
- package/dist/types/session/events.d.ts.map +1 -1
- package/dist/types/session/events.js.map +1 -1
- package/dist/types/session/records.d.ts +23 -0
- package/dist/types/session/records.d.ts.map +1 -1
- package/dist/types/session/records.js +9 -0
- package/dist/types/session/records.js.map +1 -1
- package/dist/types/tool/index.d.ts +41 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/dist/types/tool/presentation.d.ts +7 -0
- package/dist/types/tool/presentation.d.ts.map +1 -1
- package/dist/utils/frontmatter.d.ts +18 -2
- package/dist/utils/frontmatter.d.ts.map +1 -1
- package/dist/utils/frontmatter.js +13 -3
- package/dist/utils/frontmatter.js.map +1 -1
- package/dist/utils/id.d.ts +8 -0
- package/dist/utils/id.d.ts.map +1 -1
- package/dist/utils/id.js +12 -0
- package/dist/utils/id.js.map +1 -1
- package/package.json +1 -1
- package/src/authorization/gate.ts +14 -2
- package/src/authorization/rules.ts +37 -7
- package/src/authorization/shell-lexer.ts +36 -6
- package/src/bridge/a2a/mapper.ts +2 -0
- package/src/bridge/sse/mapper.ts +1 -0
- package/src/directory/types.ts +2 -0
- package/src/prompt/coding-agent-doctrine.ts +1 -0
- package/src/public-runtime.ts +28 -0
- package/src/public-tools.ts +35 -0
- package/src/public-types.ts +50 -0
- package/src/runtime/query/declined.ts +12 -0
- package/src/runtime/query/executor.ts +17 -1
- package/src/runtime/query/index.ts +1 -0
- package/src/runtime/query/iteration/index.ts +8 -0
- package/src/runtime/query/iteration/phases/context.ts +6 -0
- package/src/runtime/query/iteration/phases/handoff.ts +74 -0
- package/src/runtime/query/iteration/phases/index.ts +1 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +11 -4
- package/src/runtime/query/resume-pending.ts +3 -2
- package/src/runtime/query/review-policy.ts +4 -3
- package/src/schedules/cron.ts +202 -0
- package/src/schedules/describe.ts +138 -0
- package/src/schedules/errors.ts +15 -0
- package/src/schedules/evaluate.ts +178 -0
- package/src/schedules/index.ts +18 -0
- package/src/schedules/next-fire.ts +257 -0
- package/src/schedules/spec.ts +210 -0
- package/src/schedules/types.ts +163 -0
- package/src/schedules/tz.ts +155 -0
- package/src/skills/index.ts +1 -1
- package/src/skills/loader.ts +55 -4
- package/src/tools/builtins/browser-url.ts +251 -0
- package/src/tools/builtins/browser.ts +817 -0
- package/src/tools/builtins/skill.ts +18 -0
- package/src/tools/defineTool.ts +3 -0
- package/src/tools/schedules/index.ts +4 -0
- package/src/tools/schedules/loop-tool.ts +85 -0
- package/src/tools/schedules/present.ts +86 -0
- package/src/tools/schedules/prompt-scan.ts +96 -0
- package/src/tools/schedules/schedule-tool.ts +376 -0
- package/src/tools/schedules/types.ts +197 -0
- package/src/types/authorization/index.ts +60 -2
- package/src/types/browser/index.ts +341 -0
- package/src/types/session/events.ts +6 -0
- package/src/types/session/records.ts +10 -0
- package/src/types/tool/index.ts +42 -0
- package/src/types/tool/presentation.ts +7 -0
- package/src/utils/frontmatter.ts +29 -3
- package/src/utils/id.ts +14 -0
|
@@ -3,6 +3,7 @@ import type { ToolCallSummary } from '../../../../types/hitl/index.js'
|
|
|
3
3
|
import type { ChatCompletionResponse } from '../../../../types/provider/index.js'
|
|
4
4
|
import type { SessionEvent } from '../../../../types/session/index.js'
|
|
5
5
|
import type { ShellDialect } from '../../../../types/tool/index.js'
|
|
6
|
+
import { DECLINED_TOOL_CALL_FEEDBACK } from '../../declined.js'
|
|
6
7
|
import type { PreparedToolBatch, ToolCallDenials } from '../../executor.js'
|
|
7
8
|
import {
|
|
8
9
|
awaitProjectInstructionCallback,
|
|
@@ -58,7 +59,9 @@ export async function* runToolReview(
|
|
|
58
59
|
// skill grants to read it the same way. A test double without the method
|
|
59
60
|
// leaves it unset, which reads the line for any POSIX shell.
|
|
60
61
|
const dialectFor = (toolName: string): { commandDialect?: ShellDialect } => {
|
|
61
|
-
const executor = ctx.toolExecutor as {
|
|
62
|
+
const executor = ctx.toolExecutor as {
|
|
63
|
+
commandDialect?: (name: string) => ShellDialect
|
|
64
|
+
}
|
|
62
65
|
return typeof executor.commandDialect === 'function'
|
|
63
66
|
? { commandDialect: executor.commandDialect(toolName) }
|
|
64
67
|
: {}
|
|
@@ -448,7 +451,11 @@ export async function* runToolReview(
|
|
|
448
451
|
}
|
|
449
452
|
for (const path of tc.escalation?.outsidePaths ?? []) {
|
|
450
453
|
await ctx.recorder.recordAudit({
|
|
451
|
-
what: {
|
|
454
|
+
what: {
|
|
455
|
+
action: 'outside_root_access',
|
|
456
|
+
tool: tc.name,
|
|
457
|
+
resource: path,
|
|
458
|
+
},
|
|
452
459
|
outcome: 'approved',
|
|
453
460
|
reason: "the turn's review approved this call",
|
|
454
461
|
})
|
|
@@ -465,7 +472,7 @@ export async function* runToolReview(
|
|
|
465
472
|
})
|
|
466
473
|
yield* ctx.drainPending()
|
|
467
474
|
|
|
468
|
-
const feedback = reviewDecision.feedback ||
|
|
475
|
+
const feedback = reviewDecision.feedback || DECLINED_TOOL_CALL_FEEDBACK
|
|
469
476
|
const denials = new Map(denyAll(feedback))
|
|
470
477
|
await settleEscalations(denials, undefined)
|
|
471
478
|
await settle(denials)
|
|
@@ -495,7 +502,7 @@ export async function* runToolReview(
|
|
|
495
502
|
}
|
|
496
503
|
}
|
|
497
504
|
if (mod.action === 'deny' && !denials.has(mod.toolCallId)) {
|
|
498
|
-
denials.set(mod.toolCallId,
|
|
505
|
+
denials.set(mod.toolCallId, DECLINED_TOOL_CALL_FEEDBACK)
|
|
499
506
|
}
|
|
500
507
|
}
|
|
501
508
|
|
|
@@ -14,6 +14,7 @@ import type { ChatCompletionResponse } from '../../types/provider/index.js'
|
|
|
14
14
|
import type { ToolExecutionSnapshot } from '../../types/session/tool-execution.js'
|
|
15
15
|
import type { Logger } from '../../utils/logger.js'
|
|
16
16
|
import type { RestoredCheckpoint } from './checkpoint.js'
|
|
17
|
+
import { DECLINED_TOOL_CALL_FEEDBACK } from './declined.js'
|
|
17
18
|
import type { PriorToolResults, ToolCallDenials, ToolExecutor } from './executor.js'
|
|
18
19
|
import { PendingAnswers } from './question-park.js'
|
|
19
20
|
import { readToolExecutions } from './tool-executions.js'
|
|
@@ -630,7 +631,7 @@ function derriveDenials(
|
|
|
630
631
|
return new Map()
|
|
631
632
|
|
|
632
633
|
case 'reject_tools': {
|
|
633
|
-
const reason = decision.feedback ||
|
|
634
|
+
const reason = decision.feedback || DECLINED_TOOL_CALL_FEEDBACK
|
|
634
635
|
return new Map(toolCalls.map((tc) => [tc.id, reason]))
|
|
635
636
|
}
|
|
636
637
|
|
|
@@ -642,7 +643,7 @@ function derriveDenials(
|
|
|
642
643
|
const denials = new Map<string, string>()
|
|
643
644
|
for (const mod of decision.modifications) {
|
|
644
645
|
if (mod.action === 'deny') {
|
|
645
|
-
denials.set(mod.toolCallId,
|
|
646
|
+
denials.set(mod.toolCallId, DECLINED_TOOL_CALL_FEEDBACK)
|
|
646
647
|
}
|
|
647
648
|
}
|
|
648
649
|
for (const mod of decision.modifications) {
|
|
@@ -27,6 +27,7 @@ import type { HITLResumeDecision, ResumeHandler, ToolCallSummary } from '../../t
|
|
|
27
27
|
import type { ApprovalPolicy } from '../../types/hitl/policy.js'
|
|
28
28
|
import type { SessionId, TurnId } from '../../types/ids/index.js'
|
|
29
29
|
import { PLAN_MODE_REFUSAL } from '../../types/permission/index.js'
|
|
30
|
+
import { DECLINED_TOOL_CALL_FEEDBACK } from './declined.js'
|
|
30
31
|
|
|
31
32
|
export type ReviewMode =
|
|
32
33
|
/** Ask a person. The default when a `prompt` is supplied. */
|
|
@@ -333,7 +334,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
333
334
|
if (answer.kind === 'reject') {
|
|
334
335
|
return {
|
|
335
336
|
action: 'reject_tools',
|
|
336
|
-
feedback: answer.feedback ??
|
|
337
|
+
feedback: answer.feedback ?? DECLINED_TOOL_CALL_FEEDBACK,
|
|
337
338
|
}
|
|
338
339
|
}
|
|
339
340
|
// "Approve all" still latches for the calls that follow; it never
|
|
@@ -355,7 +356,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
355
356
|
if (answer.kind === 'reject') {
|
|
356
357
|
return {
|
|
357
358
|
action: 'reject_tools',
|
|
358
|
-
feedback: answer.feedback ??
|
|
359
|
+
feedback: answer.feedback ?? DECLINED_TOOL_CALL_FEEDBACK,
|
|
359
360
|
}
|
|
360
361
|
}
|
|
361
362
|
// Latches for the ordinary calls that follow, never for the next path.
|
|
@@ -405,7 +406,7 @@ export function createReviewHandler(options: ReviewPolicyOptions = {}): ResumeHa
|
|
|
405
406
|
case 'reject':
|
|
406
407
|
return {
|
|
407
408
|
action: 'reject_tools',
|
|
408
|
-
feedback: answer.feedback ??
|
|
409
|
+
feedback: answer.feedback ?? DECLINED_TOOL_CALL_FEEDBACK,
|
|
409
410
|
}
|
|
410
411
|
}
|
|
411
412
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Five-field cron, parsed into sets.
|
|
3
|
+
*
|
|
4
|
+
* Minute, hour, day of month, month, day of week. Lists, ranges, steps and
|
|
5
|
+
* three-letter names; `0` and `7` are both Sunday. Vixie semantics for the
|
|
6
|
+
* two day fields: when BOTH are restricted a day matches if EITHER does, which
|
|
7
|
+
* is what every cron and every existing crontab assumes.
|
|
8
|
+
*
|
|
9
|
+
* Refused, each by name: `L`, `W`, `#`, `?`, a sixth (seconds) field and
|
|
10
|
+
* `@reboot`. A refused token is a better answer than a job that silently
|
|
11
|
+
* means something the operator did not write.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { ScheduleValidationError } from './errors.js'
|
|
15
|
+
import type { CronExpression } from './types.js'
|
|
16
|
+
|
|
17
|
+
const MACROS: Readonly<Record<string, string>> = {
|
|
18
|
+
'@yearly': '0 0 1 1 *',
|
|
19
|
+
'@annually': '0 0 1 1 *',
|
|
20
|
+
'@monthly': '0 0 1 * *',
|
|
21
|
+
'@weekly': '0 0 * * 0',
|
|
22
|
+
'@daily': '0 0 * * *',
|
|
23
|
+
'@midnight': '0 0 * * *',
|
|
24
|
+
'@hourly': '0 * * * *',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MONTH_NAMES = [
|
|
28
|
+
'jan',
|
|
29
|
+
'feb',
|
|
30
|
+
'mar',
|
|
31
|
+
'apr',
|
|
32
|
+
'may',
|
|
33
|
+
'jun',
|
|
34
|
+
'jul',
|
|
35
|
+
'aug',
|
|
36
|
+
'sep',
|
|
37
|
+
'oct',
|
|
38
|
+
'nov',
|
|
39
|
+
'dec',
|
|
40
|
+
]
|
|
41
|
+
const DAY_NAMES = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
|
|
42
|
+
|
|
43
|
+
interface FieldSpec {
|
|
44
|
+
readonly label: string
|
|
45
|
+
readonly min: number
|
|
46
|
+
readonly max: number
|
|
47
|
+
readonly names?: readonly string[]
|
|
48
|
+
/** Where `names[0]` sits in the numeric range. */
|
|
49
|
+
readonly nameBase?: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FIELDS: readonly FieldSpec[] = [
|
|
53
|
+
{ label: 'minute', min: 0, max: 59 },
|
|
54
|
+
{ label: 'hour', min: 0, max: 23 },
|
|
55
|
+
{ label: 'day of month', min: 1, max: 31 },
|
|
56
|
+
{ label: 'month', min: 1, max: 12, names: MONTH_NAMES, nameBase: 1 },
|
|
57
|
+
{ label: 'day of week', min: 0, max: 7, names: DAY_NAMES, nameBase: 0 },
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
function parseValue(raw: string, field: FieldSpec): number {
|
|
61
|
+
const lower = raw.toLowerCase()
|
|
62
|
+
if (field.names) {
|
|
63
|
+
const at = field.names.indexOf(lower)
|
|
64
|
+
if (at >= 0) return at + (field.nameBase ?? 0)
|
|
65
|
+
}
|
|
66
|
+
if (!/^\d+$/.test(raw)) {
|
|
67
|
+
throw new ScheduleValidationError(`${field.label}: "${raw}" is not a number or a name`, raw)
|
|
68
|
+
}
|
|
69
|
+
const n = Number(raw)
|
|
70
|
+
if (n < field.min || n > field.max) {
|
|
71
|
+
throw new ScheduleValidationError(
|
|
72
|
+
`${field.label}: ${n} is outside ${field.min}-${field.max}`,
|
|
73
|
+
raw,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
return n
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseField(text: string, field: FieldSpec): number[] {
|
|
80
|
+
if (text === '') throw new ScheduleValidationError(`${field.label}: empty field`, text)
|
|
81
|
+
const values = new Set<number>()
|
|
82
|
+
for (const part of text.split(',')) {
|
|
83
|
+
if (part === '') throw new ScheduleValidationError(`${field.label}: empty list entry`, text)
|
|
84
|
+
// Names (`jul`, `wed`) are removed first: they carry the letters the
|
|
85
|
+
// refused `L` and `W` are spelled with.
|
|
86
|
+
const withoutNames = part.replace(/[a-z]{3}/gi, (word) =>
|
|
87
|
+
field.names?.includes(word.toLowerCase()) ? '' : word,
|
|
88
|
+
)
|
|
89
|
+
for (const refused of ['L', 'W', '#', '?']) {
|
|
90
|
+
if (withoutNames.toUpperCase().includes(refused)) {
|
|
91
|
+
throw new ScheduleValidationError(
|
|
92
|
+
`${field.label}: "${refused}" is not supported (in "${part}")`,
|
|
93
|
+
refused,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const [rangeText = '', stepText, extra] = part.split('/')
|
|
98
|
+
if (extra !== undefined) {
|
|
99
|
+
throw new ScheduleValidationError(`${field.label}: "${part}" has more than one step`, part)
|
|
100
|
+
}
|
|
101
|
+
let step = 1
|
|
102
|
+
if (stepText !== undefined) {
|
|
103
|
+
if (!/^\d+$/.test(stepText) || Number(stepText) === 0) {
|
|
104
|
+
throw new ScheduleValidationError(
|
|
105
|
+
`${field.label}: step "${stepText}" must be a whole number above 0`,
|
|
106
|
+
part,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
step = Number(stepText)
|
|
110
|
+
}
|
|
111
|
+
let lo: number
|
|
112
|
+
let hi: number
|
|
113
|
+
if (rangeText === '*') {
|
|
114
|
+
lo = field.min
|
|
115
|
+
hi = field.label === 'day of week' ? 6 : field.max
|
|
116
|
+
} else if (rangeText.includes('-')) {
|
|
117
|
+
const [a = '', b = '', more] = rangeText.split('-')
|
|
118
|
+
if (more !== undefined) {
|
|
119
|
+
throw new ScheduleValidationError(`${field.label}: "${rangeText}" is not a range`, part)
|
|
120
|
+
}
|
|
121
|
+
lo = parseValue(a, field)
|
|
122
|
+
hi = parseValue(b, field)
|
|
123
|
+
if (hi < lo) {
|
|
124
|
+
throw new ScheduleValidationError(
|
|
125
|
+
`${field.label}: range "${rangeText}" runs backwards`,
|
|
126
|
+
rangeText,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
lo = parseValue(rangeText, field)
|
|
131
|
+
// `5/15` means "from 5, every 15", as in Vixie cron.
|
|
132
|
+
hi = stepText !== undefined ? (field.label === 'day of week' ? 6 : field.max) : lo
|
|
133
|
+
}
|
|
134
|
+
for (let v = lo; v <= hi; v += step) values.add(v)
|
|
135
|
+
}
|
|
136
|
+
return [...values].sort((a, b) => a - b)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Parse a five-field expression or a macro. Throws
|
|
141
|
+
* {@link ScheduleValidationError} naming the offending token.
|
|
142
|
+
*/
|
|
143
|
+
export function parseCronExpression(input: string): CronExpression {
|
|
144
|
+
const trimmed = input.trim()
|
|
145
|
+
if (trimmed === '') throw new ScheduleValidationError('empty cron expression', '')
|
|
146
|
+
let text = trimmed
|
|
147
|
+
if (trimmed.startsWith('@')) {
|
|
148
|
+
const macro = MACROS[trimmed.toLowerCase()]
|
|
149
|
+
if (!macro) {
|
|
150
|
+
throw new ScheduleValidationError(`"${trimmed}" is not a supported macro`, trimmed)
|
|
151
|
+
}
|
|
152
|
+
text = macro
|
|
153
|
+
}
|
|
154
|
+
const fields = text.split(/\s+/)
|
|
155
|
+
if (fields.length === 6) {
|
|
156
|
+
throw new ScheduleValidationError(
|
|
157
|
+
'six fields: a seconds field is not supported; use five fields (minute hour day month weekday)',
|
|
158
|
+
fields[0],
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
if (fields.length !== 5) {
|
|
162
|
+
throw new ScheduleValidationError(
|
|
163
|
+
`expected five fields (minute hour day month weekday), got ${fields.length}`,
|
|
164
|
+
text,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
const [mi = '', ho = '', dom = '', mon = '', dow = ''] = fields
|
|
168
|
+
const minutes = parseField(mi, FIELDS[0] as FieldSpec)
|
|
169
|
+
const hours = parseField(ho, FIELDS[1] as FieldSpec)
|
|
170
|
+
const daysOfMonth = parseField(dom, FIELDS[2] as FieldSpec)
|
|
171
|
+
const months = parseField(mon, FIELDS[3] as FieldSpec)
|
|
172
|
+
const weekdays = parseField(dow, FIELDS[4] as FieldSpec)
|
|
173
|
+
const normalizedWeekdays = [...new Set(weekdays.map((d) => (d === 7 ? 0 : d)))].sort(
|
|
174
|
+
(a, b) => a - b,
|
|
175
|
+
)
|
|
176
|
+
const fixedTime = !/[*/]/.test(mi) && !/[*/]/.test(ho)
|
|
177
|
+
return {
|
|
178
|
+
source: fields.join(' '),
|
|
179
|
+
minutes,
|
|
180
|
+
hours,
|
|
181
|
+
daysOfMonth,
|
|
182
|
+
months,
|
|
183
|
+
daysOfWeek: normalizedWeekdays,
|
|
184
|
+
domRestricted: !dom.startsWith('*'),
|
|
185
|
+
dowRestricted: !dow.startsWith('*'),
|
|
186
|
+
fixedTime,
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Whether the civil date matches the day fields (Vixie OR rule). */
|
|
191
|
+
export function cronMatchesDay(
|
|
192
|
+
expr: CronExpression,
|
|
193
|
+
month: number,
|
|
194
|
+
day: number,
|
|
195
|
+
weekday: number,
|
|
196
|
+
): boolean {
|
|
197
|
+
if (!expr.months.includes(month)) return false
|
|
198
|
+
const domMatch = expr.daysOfMonth.includes(day)
|
|
199
|
+
const dowMatch = expr.daysOfWeek.includes(weekday)
|
|
200
|
+
if (expr.domRestricted && expr.dowRestricted) return domMatch || dowMatch
|
|
201
|
+
return domMatch && dowMatch
|
|
202
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A schedule in words, for a confirmation screen or a listing.
|
|
3
|
+
*
|
|
4
|
+
* Covers the common shapes exactly and falls back to the expression itself
|
|
5
|
+
* for anything else, rather than paraphrasing it wrongly.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parseCronExpression } from './cron.js'
|
|
9
|
+
import type { CronExpression, ScheduleSpec } from './types.js'
|
|
10
|
+
import { formatWall } from './tz.js'
|
|
11
|
+
|
|
12
|
+
const MINUTE = 60_000
|
|
13
|
+
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
14
|
+
const MONTH_NAMES = [
|
|
15
|
+
'January',
|
|
16
|
+
'February',
|
|
17
|
+
'March',
|
|
18
|
+
'April',
|
|
19
|
+
'May',
|
|
20
|
+
'June',
|
|
21
|
+
'July',
|
|
22
|
+
'August',
|
|
23
|
+
'September',
|
|
24
|
+
'October',
|
|
25
|
+
'November',
|
|
26
|
+
'December',
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
export interface DescribeScheduleOptions {
|
|
30
|
+
/** Zone an `at` or `every` anchor is shown in. Default UTC. */
|
|
31
|
+
readonly tz?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pad(n: number): string {
|
|
35
|
+
return String(n).padStart(2, '0')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function plural(n: number, unit: string): string {
|
|
39
|
+
return `${n} ${unit}${n === 1 ? '' : 's'}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function describeInterval(ms: number): string {
|
|
43
|
+
if (ms % (7 * 24 * 60 * MINUTE) === 0) {
|
|
44
|
+
const w = ms / (7 * 24 * 60 * MINUTE)
|
|
45
|
+
return w === 1 ? 'every week' : `every ${plural(w, 'week')}`
|
|
46
|
+
}
|
|
47
|
+
if (ms % (24 * 60 * MINUTE) === 0) {
|
|
48
|
+
const d = ms / (24 * 60 * MINUTE)
|
|
49
|
+
return d === 1 ? 'every 24 hours' : `every ${plural(d, 'day')}`
|
|
50
|
+
}
|
|
51
|
+
if (ms % (60 * MINUTE) === 0) {
|
|
52
|
+
const h = ms / (60 * MINUTE)
|
|
53
|
+
return h === 1 ? 'every hour' : `every ${plural(h, 'hour')}`
|
|
54
|
+
}
|
|
55
|
+
const m = ms / MINUTE
|
|
56
|
+
return m === 1 ? 'every minute' : `every ${plural(m, 'minute')}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** `1,2,3,5` → `1–3, 5`. */
|
|
60
|
+
function ranges(values: readonly number[], name: (n: number) => string): string {
|
|
61
|
+
const out: string[] = []
|
|
62
|
+
let i = 0
|
|
63
|
+
while (i < values.length) {
|
|
64
|
+
let j = i
|
|
65
|
+
while (j + 1 < values.length && (values[j + 1] as number) === (values[j] as number) + 1) j++
|
|
66
|
+
const a = values[i] as number
|
|
67
|
+
const b = values[j] as number
|
|
68
|
+
if (j - i >= 2) out.push(`${name(a)} through ${name(b)}`)
|
|
69
|
+
else for (let k = i; k <= j; k++) out.push(name(values[k] as number))
|
|
70
|
+
i = j + 1
|
|
71
|
+
}
|
|
72
|
+
return out.join(', ')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isFull(values: readonly number[], min: number, max: number): boolean {
|
|
76
|
+
return values.length === max - min + 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function describeDays(e: CronExpression): string {
|
|
80
|
+
const allDom = !e.domRestricted
|
|
81
|
+
const allDow = !e.dowRestricted
|
|
82
|
+
const allMonths = isFull(e.months, 1, 12)
|
|
83
|
+
let days: string
|
|
84
|
+
if (allDom && allDow) days = 'every day'
|
|
85
|
+
else if (allDom) days = `on ${ranges(e.daysOfWeek, (d) => DAY_NAMES[d] ?? String(d))}`
|
|
86
|
+
else if (allDow) days = `on day ${ranges(e.daysOfMonth, String)} of the month`
|
|
87
|
+
else
|
|
88
|
+
days = `on day ${ranges(e.daysOfMonth, String)} of the month and on ${ranges(e.daysOfWeek, (d) => DAY_NAMES[d] ?? String(d))}`
|
|
89
|
+
if (!allMonths) days += ` in ${ranges(e.months, (m) => MONTH_NAMES[m - 1] ?? String(m))}`
|
|
90
|
+
return days
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function describeTimes(e: CronExpression, source: string): string {
|
|
94
|
+
const [mi = '', ho = ''] = source.split(' ')
|
|
95
|
+
const allMinutes = isFull(e.minutes, 0, 59)
|
|
96
|
+
const allHours = isFull(e.hours, 0, 23)
|
|
97
|
+
if (allMinutes && allHours) return 'every minute'
|
|
98
|
+
const stepMinute = /^\*\/(\d+)$/.exec(mi)
|
|
99
|
+
if (stepMinute && allHours) return `every ${plural(Number(stepMinute[1]), 'minute')}`
|
|
100
|
+
if (allHours && e.minutes.length <= 4) {
|
|
101
|
+
return e.minutes.length === 1 && e.minutes[0] === 0
|
|
102
|
+
? 'every hour, on the hour'
|
|
103
|
+
: `every hour at minute ${e.minutes.join(', ')}`
|
|
104
|
+
}
|
|
105
|
+
const stepHour = /^\*\/(\d+)$/.exec(ho)
|
|
106
|
+
if (stepHour && e.minutes.length === 1) {
|
|
107
|
+
return `every ${plural(Number(stepHour[1]), 'hour')} at minute ${e.minutes[0]}`
|
|
108
|
+
}
|
|
109
|
+
if (e.hours.length * e.minutes.length <= 6) {
|
|
110
|
+
const times: string[] = []
|
|
111
|
+
for (const h of e.hours) for (const m of e.minutes) times.push(`${pad(h)}:${pad(m)}`)
|
|
112
|
+
return `at ${times.join(', ')}`
|
|
113
|
+
}
|
|
114
|
+
return `at minute ${ranges(e.minutes, String)} of hour ${ranges(e.hours, String)}`
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The schedule in words: `at 03:00 every day (Europe/Istanbul)`. */
|
|
118
|
+
export function describeSchedule(
|
|
119
|
+
spec: ScheduleSpec,
|
|
120
|
+
options: DescribeScheduleOptions = {},
|
|
121
|
+
): string {
|
|
122
|
+
const tz = options.tz ?? 'UTC'
|
|
123
|
+
switch (spec.kind) {
|
|
124
|
+
case 'at':
|
|
125
|
+
return `once at ${formatWall(Date.parse(spec.at), tz)} (${tz})`
|
|
126
|
+
case 'every':
|
|
127
|
+
return describeInterval(spec.everyMs)
|
|
128
|
+
case 'cron': {
|
|
129
|
+
let e: CronExpression
|
|
130
|
+
try {
|
|
131
|
+
e = parseCronExpression(spec.expr)
|
|
132
|
+
} catch {
|
|
133
|
+
return `cron "${spec.expr}" (${spec.tz})`
|
|
134
|
+
}
|
|
135
|
+
return `${describeTimes(e, e.source)} ${describeDays(e)} (${spec.tz})`
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A schedule, cron expression or time zone that cannot be used.
|
|
3
|
+
*
|
|
4
|
+
* `token` names the piece of the input that was refused, so a caller can
|
|
5
|
+
* point at it instead of repeating the whole expression back.
|
|
6
|
+
*/
|
|
7
|
+
export class ScheduleValidationError extends Error {
|
|
8
|
+
readonly token: string | undefined
|
|
9
|
+
|
|
10
|
+
constructor(message: string, token?: string) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'ScheduleValidationError'
|
|
13
|
+
this.token = token
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What to do about one job now: fire, skip, record a miss, or nothing.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no clock, no I/O. The host passes the instant, the state it kept and
|
|
5
|
+
* whether an occurrence was already claimed, and applies the decision itself
|
|
6
|
+
* (append history, claim, dispatch). Evaluating the same `(state, now)` twice
|
|
7
|
+
* gives the same answer, so a host that lost its in-memory queue rebuilds it
|
|
8
|
+
* by evaluating again; an already-claimed key is never fired twice.
|
|
9
|
+
*
|
|
10
|
+
* Rules, in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. Occurrences due are those in `(lastEvaluatedAt, now]`, counted with a
|
|
13
|
+
* cap and never enumerated. A new job counts from its creation. A changed
|
|
14
|
+
* revision counts from the change, so editing `0 3 * * *` into `0 4 * * *`
|
|
15
|
+
* does not invent catch-ups for 04:00s that never belonged to the job.
|
|
16
|
+
* 2. `lastEvaluatedAt` never moves backwards: after a backward clock jump
|
|
17
|
+
* nothing is due until the wall clock passes it again.
|
|
18
|
+
* 3. A job that is not active produces skip records only (paused and
|
|
19
|
+
* awaiting-confirmation are collapsed per evaluation). A one-shot whose
|
|
20
|
+
* time passed while inactive is expired.
|
|
21
|
+
* 4. The latest due occurrence fires on time (`scheduled`, or `late` once
|
|
22
|
+
* more than five seconds late) within the late grace; within the catch-up
|
|
23
|
+
* window it fires once as `catch-up` and everything earlier is one
|
|
24
|
+
* `missed` record; older than the window, nothing fires.
|
|
25
|
+
* 5. An unfinished run of the same job skips what came due meanwhile.
|
|
26
|
+
* 6. A provider quota hold skips what comes due before the hold ends.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { countOccurrences, nextFireTime, previousFireTime } from './next-fire.js'
|
|
30
|
+
import type {
|
|
31
|
+
ScheduleDecision,
|
|
32
|
+
ScheduleEvaluationInput,
|
|
33
|
+
ScheduleMissedReason,
|
|
34
|
+
ScheduleSkipReason,
|
|
35
|
+
} from './types.js'
|
|
36
|
+
|
|
37
|
+
/** Default catch-up window: seven days. */
|
|
38
|
+
export const SCHEDULE_CATCH_UP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000
|
|
39
|
+
/** Default grace for a fire to still count as on time: two minutes. */
|
|
40
|
+
export const SCHEDULE_LATE_GRACE_MS = 120_000
|
|
41
|
+
/** A fire later than this is reported `late` rather than `scheduled`. */
|
|
42
|
+
const LATE_THRESHOLD_MS = 5_000
|
|
43
|
+
|
|
44
|
+
type Skip = ScheduleDecision['skip'][number]
|
|
45
|
+
|
|
46
|
+
function missedReason(input: ScheduleEvaluationInput, firstMissed: Date): ScheduleMissedReason {
|
|
47
|
+
const gap = input.daemon.observedGap
|
|
48
|
+
if (gap && firstMissed.getTime() >= gap.from.getTime()) {
|
|
49
|
+
if (gap.kind === 'asleep') return 'machine-asleep'
|
|
50
|
+
if (gap.kind === 'clock-forward') return 'clock-jumped-forward'
|
|
51
|
+
}
|
|
52
|
+
return 'daemon-not-running'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Evaluate one job at `now`. */
|
|
56
|
+
export function evaluateJob(input: ScheduleEvaluationInput): ScheduleDecision {
|
|
57
|
+
const { job, state, now } = input
|
|
58
|
+
const nowMs = now.getTime()
|
|
59
|
+
const lateGrace = input.lateGraceMs ?? SCHEDULE_LATE_GRACE_MS
|
|
60
|
+
const windowMs = job.catchUp?.windowMs ?? SCHEDULE_CATCH_UP_WINDOW_MS
|
|
61
|
+
|
|
62
|
+
let baseMs = Date.parse(state.lastEvaluatedAt ?? job.createdAt)
|
|
63
|
+
if (!Number.isFinite(baseMs)) baseMs = Date.parse(job.createdAt)
|
|
64
|
+
if (state.jobRevision !== undefined && state.jobRevision !== job.revision) {
|
|
65
|
+
baseMs = Math.max(baseMs, Date.parse(job.updatedAt))
|
|
66
|
+
}
|
|
67
|
+
const nextState = (evaluatedMs: number): ScheduleDecision['nextState'] => {
|
|
68
|
+
const next = nextFireTime(job.spec, new Date(evaluatedMs))
|
|
69
|
+
return {
|
|
70
|
+
lastEvaluatedAt: new Date(evaluatedMs),
|
|
71
|
+
...(next ? { nextFireAt: next } : {}),
|
|
72
|
+
jobRevision: job.revision,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (job.state === 'completed' || job.state === 'expired') {
|
|
77
|
+
return {
|
|
78
|
+
skip: [],
|
|
79
|
+
nextState: { lastEvaluatedAt: new Date(Math.max(baseMs, nowMs)), jobRevision: job.revision },
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Rule 2: a clock that went backwards decides nothing new.
|
|
83
|
+
if (nowMs <= baseMs) return { skip: [], nextState: nextState(baseMs) }
|
|
84
|
+
|
|
85
|
+
const due = countOccurrences(job.spec, new Date(baseMs), now)
|
|
86
|
+
if (due.count === 0 || !due.latest) {
|
|
87
|
+
if (job.spec.kind === 'at' && Date.parse(job.spec.at) <= baseMs && job.state !== 'active') {
|
|
88
|
+
return { skip: [], jobTransition: 'expired', nextState: nextState(nowMs) }
|
|
89
|
+
}
|
|
90
|
+
return { skip: [], nextState: nextState(nowMs) }
|
|
91
|
+
}
|
|
92
|
+
const latest = due.latest
|
|
93
|
+
|
|
94
|
+
const collapsed = (reason: ScheduleSkipReason): Skip[] => [
|
|
95
|
+
{ scheduledFor: latest, reason, count: due.count },
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
if (job.state === 'paused' || job.state === 'pending-confirmation') {
|
|
99
|
+
const reason: ScheduleSkipReason = job.state === 'paused' ? 'paused' : 'awaiting-confirmation'
|
|
100
|
+
return {
|
|
101
|
+
skip: collapsed(reason),
|
|
102
|
+
...(job.spec.kind === 'at' ? { jobTransition: 'expired' as const } : {}),
|
|
103
|
+
nextState: nextState(nowMs),
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const hold = state.quotaHoldUntil ? Date.parse(state.quotaHoldUntil) : Number.NaN
|
|
108
|
+
if (Number.isFinite(hold) && hold > nowMs) {
|
|
109
|
+
return { skip: collapsed('quota-hold'), nextState: nextState(nowMs) }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (state.activeRun) {
|
|
113
|
+
return {
|
|
114
|
+
skip: collapsed(
|
|
115
|
+
state.activeRun.status === 'awaiting-approval'
|
|
116
|
+
? 'previous-run-awaiting-approval'
|
|
117
|
+
: 'previous-run-active',
|
|
118
|
+
),
|
|
119
|
+
nextState: nextState(nowMs),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const key = String(latest.getTime())
|
|
124
|
+
const lateBy = nowMs - latest.getTime()
|
|
125
|
+
const earlier = due.count - 1
|
|
126
|
+
const missedBefore = (): ScheduleDecision['missed'] | undefined => {
|
|
127
|
+
if (earlier <= 0 || !due.first) return undefined
|
|
128
|
+
// The occurrence just before the fired one.
|
|
129
|
+
const to = previousFireTime(job.spec, new Date(latest.getTime() - 1)) ?? due.first
|
|
130
|
+
return {
|
|
131
|
+
from: due.first,
|
|
132
|
+
to,
|
|
133
|
+
count: earlier,
|
|
134
|
+
capped: due.capped,
|
|
135
|
+
reason: missedReason(input, due.first),
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const transition = job.spec.kind === 'at' ? { jobTransition: 'completed' as const } : {}
|
|
139
|
+
|
|
140
|
+
if (input.isClaimed?.(key)) {
|
|
141
|
+
// Somebody already started it; nothing to fire, nothing missed.
|
|
142
|
+
return { skip: [], ...transition, nextState: nextState(nowMs) }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (lateBy <= lateGrace || lateBy <= windowMs) {
|
|
146
|
+
const trigger =
|
|
147
|
+
lateBy <= lateGrace ? (lateBy > LATE_THRESHOLD_MS ? 'late' : 'scheduled') : 'catch-up'
|
|
148
|
+
const missed = missedBefore()
|
|
149
|
+
return {
|
|
150
|
+
fire: { key, scheduledFor: latest, trigger },
|
|
151
|
+
skip: [],
|
|
152
|
+
...(missed ? { missed } : {}),
|
|
153
|
+
...transition,
|
|
154
|
+
nextState: nextState(nowMs),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Everything due is older than the window.
|
|
159
|
+
const first = due.first ?? latest
|
|
160
|
+
return {
|
|
161
|
+
skip: [
|
|
162
|
+
{
|
|
163
|
+
scheduledFor: latest,
|
|
164
|
+
reason: job.spec.kind === 'at' ? 'one-shot-expired' : 'beyond-catch-up-window',
|
|
165
|
+
count: 1,
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
missed: {
|
|
169
|
+
from: first,
|
|
170
|
+
to: latest,
|
|
171
|
+
count: due.count,
|
|
172
|
+
capped: due.capped,
|
|
173
|
+
reason: missedReason(input, first),
|
|
174
|
+
},
|
|
175
|
+
...(job.spec.kind === 'at' ? { jobTransition: 'expired' as const } : {}),
|
|
176
|
+
nextState: nextState(nowMs),
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { parseCronExpression } from './cron.js'
|
|
2
|
+
export { describeSchedule } from './describe.js'
|
|
3
|
+
export type { DescribeScheduleOptions } from './describe.js'
|
|
4
|
+
export { ScheduleValidationError } from './errors.js'
|
|
5
|
+
export {
|
|
6
|
+
SCHEDULE_CATCH_UP_WINDOW_MS,
|
|
7
|
+
SCHEDULE_LATE_GRACE_MS,
|
|
8
|
+
evaluateJob,
|
|
9
|
+
} from './evaluate.js'
|
|
10
|
+
export {
|
|
11
|
+
countOccurrences,
|
|
12
|
+
nextFireTime,
|
|
13
|
+
previousFireTime,
|
|
14
|
+
} from './next-fire.js'
|
|
15
|
+
export { parseDuration, parseScheduleSpec, upcomingFireTimes } from './spec.js'
|
|
16
|
+
export type { ParseScheduleOptions } from './spec.js'
|
|
17
|
+
export { hostTimeZone, validateTimeZone } from './tz.js'
|
|
18
|
+
export type * from './types.js'
|