fix(schedule): close cron validation gaps
This commit is contained in:
@@ -510,7 +510,7 @@ function parseCronField(raw: string, spec: CronFieldSpec): ParsedCronField {
|
||||
const canonical = step.value === 1 ? '*' : `*/${step.canonical}`
|
||||
return Object.freeze({
|
||||
canonical,
|
||||
values: cronValues(cronRange(spec.min, spec.max, step.value), spec, canonical === '*'),
|
||||
values: cronValues(cronRange(spec.min, spec.max, step.value), spec, true),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -704,6 +704,7 @@ function isCanonicalCronCandidate(
|
||||
timeZone: string,
|
||||
epoch: number,
|
||||
): boolean {
|
||||
/* v8 ignore next 4 -- pinned Croner emits finite in-range whole-minute candidates for this expression. */
|
||||
if (!Number.isSafeInteger(epoch)
|
||||
|| epoch < MIN_FOUR_DIGIT_YEAR_MS
|
||||
|| epoch > MAX_FOUR_DIGIT_YEAR_MS
|
||||
@@ -789,7 +790,6 @@ function nextCronInstant(rule: ParsedCronRule, timeZone: string, after: number):
|
||||
if (new Date(after).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
|
||||
const lower = ownedLowYearCronInstant(rule, timeZone, after, 1)
|
||||
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)
|
||||
@@ -872,6 +872,26 @@ function previousCronInstant(
|
||||
return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
|
||||
}
|
||||
|
||||
/** Validate one newly appended Cron record against the current parser, ICU, and calendar adapter. */
|
||||
function validateLiveCronRecord(record: CronScheduleRecord): void {
|
||||
try {
|
||||
const rule = parseCronRule(record.cron)
|
||||
const timeZone = canonicalizeTimeZone(record.timeZone)
|
||||
if (timeZone !== record.timeZone) {
|
||||
throw new ScheduleLogError('live cron timeZone must use its current canonical IANA name')
|
||||
}
|
||||
const target = Date.parse(record.scheduledAt)
|
||||
if (nextCronInstant(rule, timeZone, target - 60_000) !== target) {
|
||||
throw new ScheduleLogError('live cron scheduledAt must match its rule in the current time-zone data')
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ScheduleLogError) throw error
|
||||
/* v8 ignore next -- current parser and adapter failures are Error subclasses. */
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new ScheduleLogError(`live cron record is invalid: ${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Decode the exact v1 after record shape. */
|
||||
function decodeAfterRecord(value: unknown): AfterScheduleRecord {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) {
|
||||
@@ -1270,6 +1290,33 @@ export function foldScheduleEvents(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a newly appended Cron fact with current calendar data without revalidating replay history.
|
||||
* @param events - Complete exact-session log before the candidate append.
|
||||
* @param value - Candidate `schedule/change` payload.
|
||||
* @param seedLength - Inherited prefix length excluded from child ownership.
|
||||
*/
|
||||
export function validateLiveScheduleChange(
|
||||
events: readonly SessionEvent[],
|
||||
value: unknown,
|
||||
seedLength = 0,
|
||||
): void {
|
||||
const change = decodeScheduleChange(value)
|
||||
if (change.operation === 'create') {
|
||||
if (change.schedule.kind === 'cron') validateLiveCronRecord(change.schedule)
|
||||
return
|
||||
}
|
||||
if (change.operation !== 'dispatch' || !('acceptedAt' in change) || !('occurrenceAt' in change)) return
|
||||
const record = foldScheduleEvents(events, seedLength).active.find(candidate => candidate.id === change.id)
|
||||
/* v8 ignore next -- the preceding candidate fold requires calendar fields to target an active Cron record. */
|
||||
if (record?.kind !== 'cron') return
|
||||
const expected = resolveCronOccurrence(record, Date.parse(change.acceptedAt))
|
||||
const nextScheduledAt = 'nextScheduledAt' in change ? change.nextScheduledAt : undefined
|
||||
if (change.occurrenceAt !== expected.occurrenceAt || nextScheduledAt !== expected.nextScheduledAt) {
|
||||
throw new ScheduleLogError('live cron dispatch must match the current calendar decision')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate the next readable id without reusing any prior session-local id.
|
||||
* @param folded - Fold containing every previously created id.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { foldScheduleEvents, ScheduleLogError } from './domain.ts'
|
||||
import { foldScheduleEvents, ScheduleLogError, validateLiveScheduleChange } from './domain.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-schedule'
|
||||
|
||||
@@ -15,17 +15,22 @@ export const name = 'tool-schedule-invariant'
|
||||
/** Service required before reserving this package's invariant ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Validate a complete exact-session stream under its fork suffix policy. */
|
||||
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
|
||||
/** Convert an owned Schedule validation failure into the invariant service's failure channel. */
|
||||
function report(run: () => void, fail: InvariantFailure): void {
|
||||
try {
|
||||
foldScheduleEvents(events, seedLength)
|
||||
run()
|
||||
} catch (error: unknown) {
|
||||
/* v8 ignore next -- foldScheduleEvents normalizes every rejected stream to ScheduleLogError. */
|
||||
/* v8 ignore next -- owned Schedule validators normalize failures to ScheduleLogError. */
|
||||
if (!(error instanceof ScheduleLogError)) throw error
|
||||
fail(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a complete exact-session stream under its fork suffix policy. */
|
||||
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
|
||||
report(() => { foldScheduleEvents(events, seedLength) }, fail)
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Install replay and pre-append validation for the owned event stream. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
@@ -40,6 +45,9 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'schedule/change') return
|
||||
validate([...session.events, event], session.header.seedLength ?? 0, fail)
|
||||
report(() => {
|
||||
validateLiveScheduleChange(session.events, event.data, session.header.seedLength ?? 0)
|
||||
}, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
Reference in New Issue
Block a user