feat(schedule): add explicit-time-zone cron reminders

This commit is contained in:
pku-xht
2026-08-06 23:48:57 +08:00
committed by Tianyi Cui
parent 9abecc103d
commit 31c0c6a9f6
23 changed files with 1833 additions and 92 deletions
+648 -14
View File
@@ -4,13 +4,16 @@
*/
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { Cron } from 'croner'
import type {
AfterScheduleRecord,
AtInput,
AtScheduleRecord,
CronScheduleRecord,
EveryScheduleRecord,
LocalAtInput,
OneShotScheduleRecord,
RecurringScheduleRecord,
ScheduleChange,
ScheduleId as ScheduleIdType,
ScheduleRecord,
@@ -63,7 +66,7 @@ export class ScheduleLogError extends Error {
}
}
/** Error from a model-supplied after rule that cannot become a record. */
/** Error from a model-supplied Schedule rule that cannot become a record. */
export class ScheduleInputError extends Error {
/** Stable public Schedule input code. */
readonly code:
@@ -74,6 +77,7 @@ export class ScheduleInputError extends Error {
| 'not_future'
| 'time_out_of_range'
| 'frequency_too_high'
| 'no_future_occurrence'
/**
* Construct a stable input failure.
@@ -89,7 +93,8 @@ export class ScheduleInputError extends Error {
| 'timezone_confirmation_required'
| 'not_future'
| 'time_out_of_range'
| 'frequency_too_high',
| 'frequency_too_high'
| 'no_future_occurrence',
message: string,
options?: ErrorOptions,
) {
@@ -117,6 +122,14 @@ export interface EveryOccurrence {
readonly nextScheduledAt?: string
}
/** One calendar decision frozen by a durable Cron dispatch. */
export interface CronOccurrence {
/** Latest accepted occurrence, retaining a persisted baseline across tzdata changes. */
readonly occurrenceAt: string
/** First current-environment target strictly after the batch, or exhaustion. */
readonly nextScheduledAt?: string
}
/**
* Brand a raw session-local id without changing its runtime value.
* @param value - Raw session-local id.
@@ -396,6 +409,454 @@ function resolveLocalInstant(parts: CalendarParts, timeZone: string): number {
return first
}
interface CronFieldSpec {
readonly name: string
readonly min: number
readonly max: number
readonly cardinality?: number
readonly sundayAlias?: boolean
}
interface ParsedCronField {
readonly canonical: string
readonly values: readonly number[]
}
interface ParsedCronRule {
readonly canonical: string
readonly hasMatchingDate: boolean
readonly minute: ParsedCronField
readonly hour: ParsedCronField
readonly dayOfMonth: ParsedCronField
readonly month: ParsedCronField
readonly dayOfWeek: ParsedCronField
}
type CronRuleFields = Omit<ParsedCronRule, 'hasMatchingDate'>
const CRON_FIELD_SPECS = [
{ name: 'minute', min: 0, max: 59 },
{ name: 'hour', min: 0, max: 23 },
{ name: 'day-of-month', min: 1, max: 31 },
{ name: 'month', min: 1, max: 12 },
{ name: 'day-of-week', min: 0, max: 7, cardinality: 7, sundayAlias: true },
] as const satisfies readonly CronFieldSpec[]
const CRON_INTEGER = /^\d+$/
const CRON_LIST = /^\d+(?:,\d+)+$/
const CRON_RANGE = /^(?<lower>\d+)-(?<upper>\d+)$/
const CRON_WILDCARD_STEP = /^\*\/(?<step>\d+)$/
const CRON_RANGE_STEP = /^(?<lower>\d+)-(?<upper>\d+)\/(?<step>\d+)$/
/** Throw the stable public grammar failure for one cron field. */
function invalidCronField(spec: CronFieldSpec): never {
throw new ScheduleInputError('invalid_rule', `cron ${spec.name} has an unsupported value.`)
}
/** Parse one bounded decimal cron integer and return its canonical spelling. */
function cronInteger(raw: string, spec: CronFieldSpec): { value: number; canonical: string } {
if (!CRON_INTEGER.test(raw)) invalidCronField(spec)
const value = Number(raw)
if (!Number.isSafeInteger(value) || value < spec.min || value > spec.max) invalidCronField(spec)
return { value, canonical: String(value) }
}
/** Read one named group from a fixed successful cron-field expression. */
function cronGroup(
groups: Record<string, string | undefined>,
name: string,
spec: CronFieldSpec,
): string {
const value = groups[name]
/* v8 ignore next -- each caller requests a mandatory group from its matched expression. */
if (value === undefined) invalidCronField(spec)
return value
}
/** Expand one inclusive integer sequence. */
function cronRange(lower: number, upper: number, step = 1): number[] {
const values: number[] = []
for (let value = lower; value <= upper; value += step) values.push(value)
return values
}
/** Apply Sunday aliasing and reject duplicate semantics outside a wildcard. */
function cronValues(values: readonly number[], spec: CronFieldSpec, wildcard: boolean): readonly number[] {
const semantic = values.map(value => spec.sundayAlias === true && value === 7 ? 0 : value)
const unique = new Set<number>()
for (const value of semantic) {
if (!wildcard && unique.has(value)) invalidCronField(spec)
unique.add(value)
}
return Object.freeze([...unique].sort((left, right) => left - right))
}
/** Parse and canonicalize one complete cron field. */
function parseCronField(raw: string, spec: CronFieldSpec): ParsedCronField {
if (raw === '*') {
return Object.freeze({
canonical: '*',
values: cronValues(cronRange(spec.min, spec.max), spec, true),
})
}
const wildcardStep = CRON_WILDCARD_STEP.exec(raw)?.groups
if (wildcardStep !== undefined) {
const step = cronInteger(cronGroup(wildcardStep, 'step', spec), {
...spec,
min: 1,
max: spec.cardinality ?? spec.max - spec.min + 1,
})
const canonical = step.value === 1 ? '*' : `*/${step.canonical}`
return Object.freeze({
canonical,
values: cronValues(cronRange(spec.min, spec.max, step.value), spec, canonical === '*'),
})
}
const rangeStep = CRON_RANGE_STEP.exec(raw)?.groups
if (rangeStep !== undefined) {
const lower = cronInteger(cronGroup(rangeStep, 'lower', spec), spec)
const upper = cronInteger(cronGroup(rangeStep, 'upper', spec), spec)
const step = cronInteger(cronGroup(rangeStep, 'step', spec), {
...spec,
min: 1,
max: spec.cardinality ?? spec.max - spec.min + 1,
})
if (lower.value >= upper.value) invalidCronField(spec)
return Object.freeze({
canonical: `${lower.canonical}-${upper.canonical}/${step.canonical}`,
values: cronValues(cronRange(lower.value, upper.value, step.value), spec, false),
})
}
const range = CRON_RANGE.exec(raw)?.groups
if (range !== undefined) {
const lower = cronInteger(cronGroup(range, 'lower', spec), spec)
const upper = cronInteger(cronGroup(range, 'upper', spec), spec)
if (lower.value >= upper.value) invalidCronField(spec)
return Object.freeze({
canonical: `${lower.canonical}-${upper.canonical}`,
values: cronValues(cronRange(lower.value, upper.value), spec, false),
})
}
if (CRON_LIST.test(raw)) {
const entries = raw.split(',').map(entry => cronInteger(entry, spec))
let previous = Number.NEGATIVE_INFINITY
for (const entry of entries) {
if (previous >= entry.value) invalidCronField(spec)
previous = entry.value
}
return Object.freeze({
canonical: entries.map(entry => entry.canonical).join(','),
values: cronValues(entries.map(entry => entry.value), spec, false),
})
}
const entry = cronInteger(raw, spec)
return Object.freeze({ canonical: entry.canonical, values: cronValues([entry.value], spec, false) })
}
/** Whether one year follows Gregorian leap-year rules. */
function isGregorianLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
}
/** Whether a parsed rule matches one local calendar date. */
function cronMatchesDate(rule: CronRuleFields, month: number, day: number, dayOfWeek: number): boolean {
if (!rule.month.values.includes(month)) return false
return rule.dayOfMonth.canonical === '*'
? rule.dayOfWeek.values.includes(dayOfWeek)
: rule.dayOfMonth.values.includes(day)
}
/** Prove whether the 400-year Gregorian cycle has any or adjacent matching dates. */
function cronDatePattern(rule: CronRuleFields): { readonly any: boolean; readonly adjacent: boolean } {
let dayOfWeek = 6 // 2000-01-01 was Saturday; the Gregorian cycle repeats every 400 years.
let previous = false
let first = false
let last = false
let any = false
let adjacent = false
for (let year = 2000; year < 2400; year += 1) {
const monthLengths = [31, isGregorianLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
for (const [monthIndex, days] of monthLengths.entries()) {
const month = monthIndex + 1
for (let day = 1; day <= days; day += 1) {
const matches = cronMatchesDate(rule, month, day, dayOfWeek)
if (year === 2000 && month === 1 && day === 1) first = matches
adjacent ||= previous && matches
any ||= matches
previous = matches
last = matches
dayOfWeek = (dayOfWeek + 1) % 7
}
}
}
return { any, adjacent: (last && first) || adjacent }
}
/** Enforce the fixed five-minute nominal local-occurrence interval. */
function validateCronFrequency(
rule: CronRuleFields,
dates: { readonly any: boolean; readonly adjacent: boolean },
): void {
if (!dates.any) return
const times = rule.hour.values.flatMap(hour => rule.minute.values.map(minute => hour * 60 + minute))
.sort((left, right) => left - right)
let previous: number | undefined
for (const time of times) {
if (previous !== undefined && time - previous < 5) {
throw new ScheduleInputError('frequency_too_high', 'cron occurrences must be at least five minutes apart.')
}
previous = time
}
const first = Math.min(...times)
const last = Math.max(...times)
if (1_440 - last + first < 5 && dates.adjacent) {
throw new ScheduleInputError('frequency_too_high', 'cron occurrences must be at least five minutes apart.')
}
}
/** Parse the restricted five-field language and prove its nominal frequency. */
function parseCronRule(value: string, proveFrequency = true): ParsedCronRule {
if (value.length === 0 || value.trim() !== value) {
throw new ScheduleInputError('invalid_rule', 'cron must be a non-empty five-field expression without surrounding whitespace.')
}
const parts = value.split(/[\t\n\v\f\r ]+/u)
if (parts.length !== CRON_FIELD_SPECS.length) {
throw new ScheduleInputError('invalid_rule', 'cron must contain exactly five fields.')
}
const [minuteRaw, hourRaw, dayOfMonthRaw, monthRaw, dayOfWeekRaw] = parts as [
string, string, string, string, string,
]
const minute = parseCronField(minuteRaw, CRON_FIELD_SPECS[0])
const hour = parseCronField(hourRaw, CRON_FIELD_SPECS[1])
const dayOfMonth = parseCronField(dayOfMonthRaw, CRON_FIELD_SPECS[2])
const month = parseCronField(monthRaw, CRON_FIELD_SPECS[3])
const dayOfWeek = parseCronField(dayOfWeekRaw, CRON_FIELD_SPECS[4])
if (dayOfMonth.canonical !== '*' && dayOfWeek.canonical !== '*') {
throw new ScheduleInputError('invalid_rule', 'cron requires day-of-month or day-of-week to be *.')
}
const partial = Object.freeze({
canonical: [minute, hour, dayOfMonth, month, dayOfWeek].map(field => field.canonical).join(' '),
minute,
hour,
dayOfMonth,
month,
dayOfWeek,
})
if (!proveFrequency) return Object.freeze({ ...partial, hasMatchingDate: true })
const dates = cronDatePattern(partial)
validateCronFrequency(partial, dates)
return Object.freeze({ ...partial, hasMatchingDate: dates.any })
}
/**
* Validate and canonicalize the public five-field cron language.
* @param value - Raw model-supplied cron expression.
* @returns Canonical five-field text after the complete frequency proof.
*/
export function canonicalizeCronExpression(value: string): string {
return parseCronRule(value).canonical
}
/** Construct one paused Croner evaluator with the private seconds/year fields. */
function cronEvaluator(rule: ParsedCronRule, timeZone: string): Cron {
return new Cron(`0 ${rule.canonical} 1-9999`, {
paused: true,
timezone: timeZone,
mode: '7-part',
domAndDow: true,
legacyMode: false,
})
}
/** Formatter used to distinguish gaps and the first instant in an overlap. */
function cronLocalFormatter(timeZone: string): Intl.DateTimeFormat {
return new Intl.DateTimeFormat('en-US-u-ca-iso8601-nu-latn', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hourCycle: 'h23',
timeZoneName: 'longOffset',
})
}
/** Whether a Croner candidate is a real whole-minute match and the first overlap instant. */
function isCanonicalCronCandidate(
evaluator: Cron,
formatter: Intl.DateTimeFormat,
timeZone: string,
epoch: number,
): boolean {
if (!Number.isSafeInteger(epoch)
|| epoch < MIN_FOUR_DIGIT_YEAR_MS
|| epoch > MAX_FOUR_DIGIT_YEAR_MS
|| epoch % 60_000 !== 0
|| !evaluator.match(new Date(epoch))) return false
return resolveLocalInstant(localProjection(formatter, epoch), timeZone) === epoch
}
const CRONER_LOW_YEAR_CUTOFF = 108
const CRONER_LOW_YEAR_SEARCH_END = 109
const MAX_CRON_CURSOR_CORRECTIONS = 1_440
/** Search owned local-calendar candidates without JavaScript's legacy 0..99 year remapping. */
function ownedCronInstant(
rule: ParsedCronRule,
timeZone: string,
boundary: number,
direction: 1 | -1,
minYear: number,
maxYear: number,
lowerExclusive = MIN_FOUR_DIGIT_YEAR_MS - 1,
): number | undefined {
const utcYear = new Date(boundary).getUTCFullYear()
const startYear = direction === 1
? Math.max(minYear, utcYear - 1)
: Math.min(maxYear, utcYear + 1)
const months = direction === 1 ? rule.month.values : [...rule.month.values].reverse()
const times = rule.hour.values.flatMap(hour => rule.minute.values.map(minute => ({ hour, minute })))
if (direction === -1) times.reverse()
for (
let year = startYear;
direction === 1 ? year <= maxYear : year >= minYear;
year += direction
) {
const monthLengths = [31, isGregorianLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
for (const month of months) {
const daysInMonth = monthLengths[month - 1]
/* v8 ignore next -- parsed month values are restricted to 1..12. */
if (daysInMonth === undefined) continue
for (
let day = direction === 1 ? 1 : daysInMonth;
direction === 1 ? day <= daysInMonth : day >= 1;
day += direction
) {
const midnight = calendarEpoch({ year, month, day, hour: 0, minute: 0, second: 0, millisecond: 0 })
if (!cronMatchesDate(rule, month, day, new Date(midnight).getUTCDay())) continue
for (const time of times) {
let candidate: number
try {
candidate = resolveLocalInstant({
year,
month,
day,
hour: time.hour,
minute: time.minute,
second: 0,
millisecond: 0,
}, timeZone)
} catch (error: unknown) {
/* v8 ignore next -- canonical zones make non-Schedule failures unreachable here. */
if (!(error instanceof ScheduleInputError)) throw error
continue
}
if (candidate % 60_000 !== 0) continue
if (direction === 1) {
if (candidate > boundary) return candidate
} else {
if (candidate <= lowerExclusive) return undefined
if (candidate <= boundary) return candidate
}
}
}
}
}
return undefined
}
/** Find the first valid calendar occurrence strictly after one instant. */
function nextCronInstant(rule: ParsedCronRule, timeZone: string, after: number): number | undefined {
if (!rule.hasMatchingDate) return undefined
let cursor = after
if (new Date(after).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
const lower = ownedCronInstant(rule, timeZone, after, 1, 1, CRONER_LOW_YEAR_SEARCH_END)
if (lower !== undefined) return lower
cursor = Math.max(cursor, Date.parse('0109-12-31T23:59:59.999Z'))
}
const evaluator = cronEvaluator(rule, timeZone)
const formatter = cronLocalFormatter(timeZone)
let corrections = 0
while (cursor < MAX_FOUR_DIGIT_YEAR_MS) {
const candidate = evaluator.nextRun(new Date(cursor))
if (candidate === null) return undefined
const epoch = candidate.getTime()
if (!Number.isSafeInteger(epoch)) {
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not advance its cursor.')
}
if (epoch <= cursor) {
corrections += 1
if (corrections > MAX_CRON_CURSOR_CORRECTIONS) {
return ownedCronInstant(rule, timeZone, after, 1, 1, 9_999)
}
cursor += 60_000
continue
}
if (epoch > MAX_FOUR_DIGIT_YEAR_MS) return undefined
if (isCanonicalCronCandidate(evaluator, formatter, timeZone, epoch)) return epoch
return ownedCronInstant(rule, timeZone, after, 1, 1, 9_999)
}
/* v8 ignore next -- only repeated stale dependency candidates can exhaust the bounded cursor. */
return undefined
}
/** Find the latest valid calendar occurrence at or before one instant. */
function previousCronInstant(
rule: ParsedCronRule,
timeZone: string,
acceptedAt: number,
baseline: number,
): number | undefined {
if (new Date(acceptedAt).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
return ownedCronInstant(
rule, timeZone, acceptedAt, -1, 1, CRONER_LOW_YEAR_SEARCH_END, baseline,
)
}
const evaluator = cronEvaluator(rule, timeZone)
const formatter = cronLocalFormatter(timeZone)
const nextMinute = Math.floor(acceptedAt / 60_000) * 60_000 + 60_000
let reference = Math.min(MAX_FOUR_DIGIT_YEAR_MS, nextMinute)
let corrections = 0
while (reference > baseline) {
const candidate = evaluator.previousRuns(1, new Date(reference))[0]
if (candidate === undefined) {
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
}
const epoch = candidate.getTime()
if (!Number.isSafeInteger(epoch)) {
throw new ScheduleInputError('invalid_rule', 'The cron evaluator did not retreat its cursor.')
}
if (epoch >= reference) {
corrections += 1
if (corrections > MAX_CRON_CURSOR_CORRECTIONS) {
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
}
reference -= 60_000
continue
}
if (epoch <= baseline) {
return ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
}
if (isCanonicalCronCandidate(evaluator, formatter, timeZone, epoch)) return epoch
if (epoch >= MIN_FOUR_DIGIT_YEAR_MS && epoch <= MAX_FOUR_DIGIT_YEAR_MS
&& epoch % 60_000 === 0 && evaluator.match(candidate)) {
return resolveLocalInstant(localProjection(formatter, epoch), timeZone)
}
const owned = ownedCronInstant(rule, timeZone, acceptedAt, -1, 1, 9_999, baseline)
if (owned !== undefined) return owned
reference = Math.min(reference - 60_000, epoch - 1)
}
return undefined
}
/** Decode the exact v1 after record shape. */
function decodeAfterRecord(value: unknown): AfterScheduleRecord {
if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) {
@@ -461,6 +922,49 @@ function decodeEveryRecord(value: unknown): EveryScheduleRecord {
})
}
/** Decode the exact v1 calendar-recurring record shape without reevaluating occurrence membership. */
function decodeCronRecord(value: unknown): CronScheduleRecord {
if (!isRecord(value)
|| !hasExactKeys(value, ['id', 'kind', 'prompt', 'cron', 'timeZone', 'scheduledAt'])) {
throw new ScheduleLogError('cron schedule must contain exactly id, kind, prompt, cron, timeZone, and scheduledAt')
}
const prompt = value['prompt']
const cron = value['cron']
const timeZone = value['timeZone']
if (typeof prompt !== 'string' || prompt.length === 0 || prompt.trim() !== prompt) {
throw new ScheduleLogError('cron prompt must be non-empty and already trimmed')
}
if (typeof cron !== 'string' || typeof timeZone !== 'string') {
throw new ScheduleLogError('cron rule and timeZone must be strings')
}
try {
const rule = parseCronRule(cron, false)
if (rule.canonical !== cron) {
throw new ScheduleLogError('cron rule must use its canonical five-field representation')
}
if (timeZone !== 'UTC' && !IANA_ZONE.test(timeZone)) {
throw new ScheduleLogError('cron timeZone must use the persisted IANA Area/Location shape')
}
} catch (error: unknown) {
if (error instanceof ScheduleLogError) throw error
/* v8 ignore next -- owned cron validators throw Error subclasses. */
const detail = error instanceof Error ? error.message : String(error)
throw new ScheduleLogError(`cron record is invalid: ${detail}`)
}
const scheduledAt = decodeInstant(value['scheduledAt'])
if (Date.parse(scheduledAt) % 60_000 !== 0) {
throw new ScheduleLogError('cron scheduledAt must be a whole-minute UTC instant')
}
return Object.freeze({
id: decodeId(value['id']),
kind: 'cron',
prompt,
cron,
timeZone,
scheduledAt,
})
}
/** Decode one current durable record variant by its exact discriminator. */
function decodeScheduleRecord(value: unknown): ScheduleRecord {
if (!isRecord(value)) throw new ScheduleLogError('schedule record must be an object')
@@ -468,7 +972,8 @@ function decodeScheduleRecord(value: unknown): ScheduleRecord {
case 'after': return decodeAfterRecord(value)
case 'at': return decodeAtRecord(value)
case 'every': return decodeEveryRecord(value)
default: throw new ScheduleLogError('v1 schedule kind must be "after", "at", or "every"')
case 'cron': return decodeCronRecord(value)
default: throw new ScheduleLogError('v1 schedule kind must be "after", "at", "every", or "cron"')
}
}
@@ -518,7 +1023,28 @@ export function decodeScheduleChange(value: unknown): ScheduleChange {
acceptedAt: decodeInstant(value['acceptedAt']),
})
}
throw new ScheduleLogError('schedule dispatch must contain id and optional acceptedAt only')
if (hasExactKeys(value, ['version', 'operation', 'id', 'occurrenceAt', 'acceptedAt'])) {
return Object.freeze({
version: SCHEDULE_CHANGE_VERSION,
operation: 'dispatch',
id: decodeId(value['id']),
occurrenceAt: decodeInstant(value['occurrenceAt']),
acceptedAt: decodeInstant(value['acceptedAt']),
})
}
if (hasExactKeys(value, [
'version', 'operation', 'id', 'occurrenceAt', 'acceptedAt', 'nextScheduledAt',
])) {
return Object.freeze({
version: SCHEDULE_CHANGE_VERSION,
operation: 'dispatch',
id: decodeId(value['id']),
occurrenceAt: decodeInstant(value['occurrenceAt']),
acceptedAt: decodeInstant(value['acceptedAt']),
nextScheduledAt: decodeInstant(value['nextScheduledAt']),
})
}
throw new ScheduleLogError('schedule dispatch has an unsupported field combination')
}
default:
throw new ScheduleLogError('schedule/change operation must be create, delete, or dispatch')
@@ -562,6 +1088,41 @@ export function resolveEveryOccurrence(
})
}
/**
* Resolve one live calendar decision while retaining the persisted baseline across tzdata changes.
* @param record - Active canonical Cron record whose target is the prior environment's promise.
* @param acceptedAt - Shared recurring-batch wall-clock sample.
* @returns Latest current match after the baseline and first future match, if representable.
*/
export function resolveCronOccurrence(
record: CronScheduleRecord,
acceptedAt: number,
): CronOccurrence {
const target = Date.parse(record.scheduledAt)
if (!Number.isSafeInteger(acceptedAt)
|| acceptedAt < MIN_FOUR_DIGIT_YEAR_MS
|| acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) {
throw new ScheduleLogError('cron acceptedAt must be a representable four-digit-year instant')
}
if (acceptedAt < target) {
throw new ScheduleLogError('cron dispatch cannot precede the active scheduledAt')
}
try {
const rule = parseCronRule(record.cron, false)
const latest = previousCronInstant(rule, record.timeZone, acceptedAt, target)
const occurrence = latest !== undefined && latest > target ? latest : target
const next = nextCronInstant(rule, record.timeZone, acceptedAt)
return Object.freeze({
occurrenceAt: new Date(occurrence).toISOString(),
...(next === undefined ? {} : { nextScheduledAt: new Date(next).toISOString() }),
})
} catch (error: unknown) {
/* v8 ignore next -- the exact adapter and owned validators throw Errors. */
const detail = error instanceof Error ? error.message : String(error)
throw new ScheduleLogError(`cron evaluation failed: ${detail}`)
}
}
type DecodedDispatch = Extract<ScheduleChange, { operation: 'dispatch' }>
interface AppliedDispatch {
@@ -573,21 +1134,51 @@ interface AppliedDispatch {
/** Apply one decoded dispatch to its exact active record. */
function applyDispatch(record: ScheduleRecord, change: DecodedDispatch): AppliedDispatch {
const hasAcceptedAt = 'acceptedAt' in change
if (record.kind !== 'every') {
const hasOccurrenceAt = 'occurrenceAt' in change
if (record.kind !== 'every' && record.kind !== 'cron') {
if (hasAcceptedAt) throw new ScheduleLogError('one-shot dispatch must not contain acceptedAt')
return Object.freeze({ occurrenceAt: record.scheduledAt })
}
if (!hasAcceptedAt) throw new ScheduleLogError('every dispatch must contain acceptedAt')
const occurrence = resolveEveryOccurrence(record, Date.parse(change.acceptedAt))
if (record.kind === 'every') {
if (!hasAcceptedAt || hasOccurrenceAt) {
throw new ScheduleLogError('every dispatch must contain acceptedAt without calendar fields')
}
const occurrence = resolveEveryOccurrence(record, Date.parse(change.acceptedAt))
return Object.freeze({
occurrenceAt: occurrence.occurrenceAt,
acceptedAt: change.acceptedAt,
...(occurrence.nextScheduledAt === undefined
? {}
: {
nextRecord: Object.freeze({
...record,
scheduledAt: occurrence.nextScheduledAt,
}),
}),
})
}
if (!hasAcceptedAt || !hasOccurrenceAt) {
throw new ScheduleLogError('cron dispatch must contain occurrenceAt and acceptedAt')
}
const target = Date.parse(record.scheduledAt)
const occurrence = Date.parse(change.occurrenceAt)
const accepted = Date.parse(change.acceptedAt)
const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined
const next = nextScheduledAt === undefined ? undefined : Date.parse(nextScheduledAt)
if (target % 60_000 !== 0 || occurrence % 60_000 !== 0
|| occurrence < target || occurrence > accepted
|| (next !== undefined && (next % 60_000 !== 0 || next <= accepted))) {
throw new ScheduleLogError('cron dispatch times must preserve whole-minute monotonic progression')
}
return Object.freeze({
occurrenceAt: occurrence.occurrenceAt,
occurrenceAt: change.occurrenceAt,
acceptedAt: change.acceptedAt,
...(occurrence.nextScheduledAt === undefined
...(nextScheduledAt === undefined
? {}
: {
nextRecord: Object.freeze({
...record,
scheduledAt: occurrence.nextScheduledAt,
scheduledAt: nextScheduledAt,
}),
}),
})
@@ -651,10 +1242,10 @@ export function foldScheduleEvents(
}
}
}
// A gate beyond the supported time profile can never admit another Every batch.
// A gate beyond the supported time profile can never admit another recurring batch.
if (isRecurringGateExhausted(lastRecurringAcceptedAt)) {
for (const [id, record] of active) {
if (record.kind === 'every') active.delete(id)
if (record.kind === 'every' || record.kind === 'cron') active.delete(id)
}
}
return Object.freeze({
@@ -833,6 +1424,48 @@ export function createEveryScheduleRecord(
})
}
/**
* Validate one restricted calendar rule and compute its first current-environment target.
* @param id - Already allocated session-local id.
* @param prompt - User-authored reminder content.
* @param cron - Restricted five-field calendar expression.
* @param timeZone - Explicit `UTC` or IANA Area/Location selector.
* @param now - Single creation-time wall-clock sample in epoch milliseconds.
* @returns Frozen durable calendar record.
*/
export function createCronScheduleRecord(
id: ScheduleIdType,
prompt: string,
cron: string,
timeZone: string,
now: number,
): CronScheduleRecord {
const normalizedPrompt = prompt.trim()
if (normalizedPrompt.length === 0) {
throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.')
}
if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) {
throw new ScheduleInputError(
'time_out_of_range',
'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.',
)
}
const rule = parseCronRule(cron)
const canonicalTimeZone = canonicalizeTimeZone(timeZone)
const target = nextCronInstant(rule, canonicalTimeZone, now)
if (target === undefined) {
throw new ScheduleInputError('no_future_occurrence', 'The cron rule has no future four-digit-year occurrence.')
}
return Object.freeze({
id,
kind: 'cron',
prompt: normalizedPrompt,
cron: rule.canonical,
timeZone: canonicalTimeZone,
scheduledAt: new Date(target).toISOString(),
})
}
/**
* Derive one execution-local management view.
* @param record - Active durable record.
@@ -847,7 +1480,8 @@ export function scheduleView(
): ScheduleView {
const target = Date.parse(record.scheduledAt)
let deliveryNotBefore: string | undefined
if (record.kind === 'every' && now >= target && lastRecurringAcceptedAt !== undefined) {
if ((record.kind === 'every' || record.kind === 'cron')
&& now >= target && lastRecurringAcceptedAt !== undefined) {
const notBefore = Date.parse(lastRecurringAcceptedAt) + MIN_RECURRING_INTERVAL_SECONDS * 1_000
if (now < notBefore && notBefore <= MAX_FOUR_DIGIT_YEAR_MS) {
deliveryNotBefore = new Date(notBefore).toISOString()
@@ -974,7 +1608,7 @@ export function renderReminderFraming(record: OneShotScheduleRecord): string {
* @returns Stable model-visible text whose dynamic payload is canonical JSON.
*/
export function renderReminderBatchFraming(
reminders: readonly { readonly record: EveryScheduleRecord; readonly occurrenceAt: string }[],
reminders: readonly { readonly record: RecurringScheduleRecord; readonly occurrenceAt: string }[],
): string {
const payload = reminders.map(({ record, occurrenceAt }) => ({
schedule_id: record.id,
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Agent-scoped durable one-shot and fixed-rate reminders over the session event log.
* Agent-scoped durable one-shot, fixed-rate, and calendar reminders over the session event log.
* @module @deepseek-ai/dsh-tool-schedule
*/
+42 -17
View File
@@ -7,14 +7,15 @@ import type { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type {
EveryScheduleRecord,
OneShotScheduleRecord,
RecurringScheduleRecord,
} from './types.ts'
import {
foldScheduleEvents,
MIN_RECURRING_INTERVAL_SECONDS,
renderReminderBatchFraming,
renderReminderFraming,
resolveCronOccurrence,
resolveEveryOccurrence,
ScheduleLogError,
} from './domain.ts'
@@ -26,8 +27,9 @@ import { runScheduleTransaction } from './transaction.ts'
export const MAX_TIMER_DELAY_MS = 2_147_483_647
interface RecurringDue {
readonly record: EveryScheduleRecord
readonly record: RecurringScheduleRecord
readonly occurrenceAt: string
readonly nextScheduledAt?: string
}
type DueDecision =
@@ -40,7 +42,8 @@ function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
const indexed = folded.active.map((record, index) => ({ record, index }))
const dueOneShots = indexed
.filter((entry): entry is { record: OneShotScheduleRecord; index: number } =>
entry.record.kind !== 'every' && Date.parse(entry.record.scheduledAt) <= now)
entry.record.kind !== 'every' && entry.record.kind !== 'cron'
&& Date.parse(entry.record.scheduledAt) <= now)
.sort((left, right) =>
Date.parse(left.record.scheduledAt) - Date.parse(right.record.scheduledAt)
|| left.index - right.index)
@@ -48,8 +51,9 @@ function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
if (oneShot !== undefined) return { kind: 'one-shot', record: oneShot }
const recurring = indexed
.filter((entry): entry is { record: EveryScheduleRecord; index: number } =>
entry.record.kind === 'every' && Date.parse(entry.record.scheduledAt) <= now)
.filter((entry): entry is { record: RecurringScheduleRecord; index: number } =>
(entry.record.kind === 'every' || entry.record.kind === 'cron')
&& Date.parse(entry.record.scheduledAt) <= now)
.sort((left, right) =>
Date.parse(left.record.scheduledAt) - Date.parse(right.record.scheduledAt)
|| left.index - right.index)
@@ -60,15 +64,23 @@ function dueDecision(folded: FoldedSchedules, now: number): DueDecision {
return {
kind: 'recurring',
acceptedAt: new Date(now).toISOString(),
reminders: recurring.map(({ record }) => ({
record,
occurrenceAt: resolveEveryOccurrence(record, now).occurrenceAt,
})),
reminders: recurring.map(({ record }) => {
const occurrence = record.kind === 'every'
? resolveEveryOccurrence(record, now)
: resolveCronOccurrence(record, now)
return {
record,
occurrenceAt: occurrence.occurrenceAt,
...(occurrence.nextScheduledAt === undefined
? {}
: { nextScheduledAt: occurrence.nextScheduledAt }),
}
}),
}
}
const future = folded.active
.filter(record => recurring.length === 0 || record.kind !== 'every')
.filter(record => recurring.length === 0 || (record.kind !== 'every' && record.kind !== 'cron'))
.map(record => Date.parse(record.scheduledAt))
.filter(target => target > now)
if (recurring.length > 0) future.push(gate)
@@ -282,13 +294,26 @@ export class ScheduleOwner {
id: decision.record.id,
})
} else {
for (const { record } of decision.reminders) {
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: record.id,
acceptedAt: decision.acceptedAt,
})
for (const reminder of decision.reminders) {
if (reminder.record.kind === 'every') {
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: reminder.record.id,
acceptedAt: decision.acceptedAt,
})
} else {
this.agent.session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: reminder.record.id,
occurrenceAt: reminder.occurrenceAt,
acceptedAt: decision.acceptedAt,
...(reminder.nextScheduledAt === undefined
? {}
: { nextScheduledAt: reminder.nextScheduledAt }),
})
}
}
}
} catch (error: unknown) {
+56 -8
View File
@@ -14,6 +14,7 @@ import {
allocateScheduleId,
createAfterScheduleRecord,
createAtScheduleRecord,
createCronScheduleRecord,
createEveryScheduleRecord,
foldScheduleEvents,
isRecurringGateExhausted,
@@ -76,7 +77,21 @@ const EVERY_VIEW_SCHEMA = {
},
} as const
const VIEW_SCHEMA = { oneOf: [AFTER_VIEW_SCHEMA, AT_VIEW_SCHEMA, EVERY_VIEW_SCHEMA] } as const
const CRON_VIEW_SCHEMA = {
type: 'object',
additionalProperties: false,
properties: {
...SHARED_VIEW_PROPERTIES,
kind: { type: 'string', required: true, const: 'cron' },
cron: { type: 'string', required: true },
timeZone: { type: 'string', required: true },
deliveryNotBefore: { type: 'string' },
},
} as const
const VIEW_SCHEMA = {
oneOf: [AFTER_VIEW_SCHEMA, AT_VIEW_SCHEMA, EVERY_VIEW_SCHEMA, CRON_VIEW_SCHEMA],
} as const
/** Build one exact two-field error schema while preserving its literal code. */
function basicErrorSchema<const C extends string>(code: C) {
@@ -98,6 +113,7 @@ const BASIC_ERROR_SCHEMAS = [
basicErrorSchema('not_future'),
basicErrorSchema('time_out_of_range'),
basicErrorSchema('frequency_too_high'),
basicErrorSchema('no_future_occurrence'),
basicErrorSchema('corrupt_schedule_log'),
basicErrorSchema('internal_error'),
] as const
@@ -163,7 +179,8 @@ const DELETE_OUTPUT_SCHEMA = {
const CREATE_DESCRIPTION =
'Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: '
+ 'a positive safe-integer after_seconds delay, at as a strict offset date-time or local '
+ `date/time object, or safe-integer every_seconds of at least ${MIN_RECURRING_INTERVAL_SECONDS}. `
+ `date/time object, safe-integer every_seconds of at least ${MIN_RECURRING_INTERVAL_SECONDS}, `
+ 'or a restricted five-field cron paired with an explicit IANA time_zone. '
+ 'Delivery is session-local: the reminder runs on time only while this session '
+ 'is live and otherwise becomes overdue until the session is resumed.'
@@ -175,6 +192,14 @@ const DELETE_DESCRIPTION =
'Delete one active reminder in the current session by the exact id returned by schedule_create '
+ 'or schedule_list. Unknown or already-finished ids return deleted false.'
const CRON_DESCRIPTION =
'Five numeric fields in order: minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, '
+ 'day-of-week 0-7 (0 and 7 are Sunday). Each field is *, one integer, a strictly increasing '
+ 'integer list, an increasing a-b range, */s, or a-b/s. Day-of-month or day-of-week must be *. '
+ 'Steps are positive and at most the field cardinality (7 for day-of-week). Names, macros, '
+ 'seconds, years, ?, L, W, and # are unsupported; nominal matches must be at '
+ 'least five minutes apart. Requires time_zone.'
/** Deterministic model content for every canonical Schedule value. */
function renderValue(_args: unknown, value: unknown): ContentBlock[] {
// The ToolRegistry has already validated the value against the lossless-JSON output schema.
@@ -364,18 +389,25 @@ function validateCreateArgs(args: {
after_seconds?: number
at?: AtInput
every_seconds?: number
cron?: string
time_zone?: string
}): ScheduleToolError | undefined {
const keys = Object.keys(args as unknown as Record<string, unknown>)
const hasCronSelector = args.cron !== undefined || args.time_zone !== undefined
if (keys.some(key => key !== 'prompt'
&& key !== 'after_seconds'
&& key !== 'at'
&& key !== 'every_seconds')
&& key !== 'every_seconds'
&& key !== 'cron'
&& key !== 'time_zone')
|| Number(args.after_seconds !== undefined)
+ Number(args.at !== undefined)
+ Number(args.every_seconds !== undefined) !== 1) {
+ Number(args.every_seconds !== undefined)
+ Number(hasCronSelector) !== 1
|| (hasCronSelector && (args.cron === undefined || args.time_zone === undefined))) {
return {
code: 'invalid_selector',
message: 'schedule_create accepts exactly one of after_seconds, at, or every_seconds.',
message: 'schedule_create accepts exactly one of after_seconds, at, every_seconds, or cron with time_zone.',
}
}
if (args.prompt.trim().length === 0) {
@@ -440,6 +472,14 @@ export function registerScheduleTools(
type: 'number',
description: `Fixed-rate safe-integer interval in seconds, at least ${MIN_RECURRING_INTERVAL_SECONDS}.`,
},
cron: {
type: 'string',
description: CRON_DESCRIPTION,
},
time_zone: {
type: 'string',
description: 'Explicit UTC or IANA Area/Location for cron evaluation.',
},
at: {
description: 'Absolute target as strict offset RFC 3339 or local date/time with optional IANA zone.',
oneOf: [
@@ -467,7 +507,7 @@ export function registerScheduleTools(
notifyDurableChange()
const folded = foldForTool(agent)
if (isToolError(folded)) return folded
if (args.every_seconds !== undefined
if ((args.every_seconds !== undefined || args.cron !== undefined)
&& isRecurringGateExhausted(folded.lastRecurringAcceptedAt)) {
return {
code: 'time_out_of_range',
@@ -492,11 +532,19 @@ export function registerScheduleTools(
)
} else if (args.after_seconds !== undefined) {
record = createAfterScheduleRecord(id, args.prompt, args.after_seconds, Date.now())
} else {
} else if (args.every_seconds !== undefined) {
record = createEveryScheduleRecord(
id,
args.prompt,
args.every_seconds as number,
args.every_seconds,
Date.now(),
)
} else {
record = createCronScheduleRecord(
id,
args.prompt,
args.cron as string,
args.time_zone as string,
Date.now(),
)
}
+44 -2
View File
@@ -49,6 +49,22 @@ export interface EveryScheduleRecord {
readonly scheduledAt: string
}
/** Durable calendar reminder evaluated in one explicit IANA time zone. */
export interface CronScheduleRecord {
/** Session-local stable identity. */
readonly id: ScheduleId
/** Rule discriminator for a calendar recurring reminder. */
readonly kind: 'cron'
/** Trimmed user-authored reminder content. */
readonly prompt: string
/** Canonical restricted five-field cron expression. */
readonly cron: string
/** Canonical IANA time-zone name used for future evaluation. */
readonly timeZone: string
/** Earliest calendar occurrence not yet accepted. */
readonly scheduledAt: string
}
/** Structured local-calendar input accepted by `schedule_create`. */
export interface LocalAtInput {
/** Four-digit ISO calendar date. */
@@ -65,8 +81,11 @@ export type AtInput = string | LocalAtInput
/** One-shot record variants that terminate on an id-only dispatch. */
export type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord
/** Recurring record variants that share one model-turn gate. */
export type RecurringScheduleRecord = EveryScheduleRecord | CronScheduleRecord
/** The v1 durable reminder record union. */
export type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord
export type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord
/** Creates one durable reminder record. */
export interface ScheduleCreateChange {
@@ -98,8 +117,24 @@ export interface EveryScheduleDispatchChange {
readonly acceptedAt: string
}
/** Freezes one calendar decision against the live evaluator and tzdata. */
export interface CronScheduleDispatchChange {
readonly version: 1
readonly operation: 'dispatch'
readonly id: ScheduleId
/** Latest accepted calendar occurrence as canonical UTC. */
readonly occurrenceAt: string
/** Shared recurring-batch decision time as canonical UTC. */
readonly acceptedAt: string
/** First future calendar occurrence, omitted only at four-digit-year exhaustion. */
readonly nextScheduledAt?: string
}
/** Durable dispatch shapes supported by the current rule set. */
export type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispatchChange
export type ScheduleDispatchChange =
| OneShotScheduleDispatchChange
| EveryScheduleDispatchChange
| CronScheduleDispatchChange
/** Strict version-1 durable Schedule mutation union. */
export type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
@@ -175,6 +210,12 @@ export interface FrequencyTooHighError {
readonly message: string
}
/** Stable error returned when a recurring rule has no representable future occurrence. */
export interface NoFutureOccurrenceError {
readonly code: 'no_future_occurrence'
readonly message: string
}
/** Stable error returned when the durable Schedule stream is malformed. */
export interface CorruptScheduleLogError {
readonly code: 'corrupt_schedule_log'
@@ -204,6 +245,7 @@ export type ScheduleToolError =
| NotFutureError
| TimeOutOfRangeError
| FrequencyTooHighError
| NoFutureOccurrenceError
| CorruptScheduleLogError
| PersistenceUncertainError
| InternalScheduleError