fix(schedule): close cron validation gaps

This commit is contained in:
pku-xht
2026-08-08 04:30:24 +08:00
committed by Tianyi Cui
parent 3b16c5ca00
commit c981eaa1d5
9 changed files with 266 additions and 18 deletions

View File

@@ -34,7 +34,7 @@ The public cron language has exactly five numeric fields: minute, hour, day of m
Schedule proves the nominal local interval against the complete 400-year Gregorian cycle, including cross-midnight and cycle-seam neighbors, and rejects any rule that can recur in under five minutes. It canonicalizes the explicit zone through `Intl`; `UTC` and IANA Area/Location names or links are accepted, while local defaults, abbreviations, and numeric offsets are not.
The private `croner@10.0.1` adapter runs paused without a callback or timer. It supplies hidden seconds=`0` and year=`1-9999`, filters daylight-saving gap normalization, chooses the first instant in an overlap, and strictly advances forward and backward cursors. Because JavaScript constructors remap years 099, an owned local-calendar search covers that lower range and its transition before the adapter delegates safe years to Croner. Create chooses the first match strictly after admission. A late wake retains the persisted target as its baseline, selects the latest newer current match at or before the shared `acceptedAt`, and finds the first future match. Replay validates only canonical structure, whole-minute UTC values, and monotonic dispatch relations; it never asks current Croner, ICU, or the frequency proof to re-decide a historical occurrence.
The private `croner@10.0.1` adapter runs paused without a callback or timer. It supplies hidden seconds=`0` and year=`1-9999`, filters daylight-saving gap normalization, chooses the first instant in an overlap, and strictly advances forward and backward cursors. Because JavaScript constructors remap years 099, an owned local-calendar search covers that lower range and its transition before the adapter delegates safe years to Croner. Create chooses the first match strictly after admission. A late wake retains the persisted target as its baseline, selects the latest newer current match at or before the shared `acceptedAt`, and finds the first future match. The package invariant applies the same current calendar validation only to new live create and dispatch appends. Replay validates only canonical structure, whole-minute UTC values, and monotonic dispatch relations; it never asks current Croner, ICU, or the frequency proof to re-decide a historical occurrence.
## Management tools

View File

@@ -34,7 +34,7 @@ Web Host 会在创建 Session 时以及每次提交提示词时校验并规范
Schedule 会针对完整的 400 年 Gregorian 历法周期证明名义本地间隔,其中包括跨午夜相邻时点与周期首尾衔接处的相邻时点;任何可能以不足 5 分钟的间隔重复发生的规则都会被拒绝。它通过 `Intl` 规范化显式时区;接受 `UTC`、IANA Area/Location 名称或链接,不接受本地默认值、缩写或数值偏移。
私有 `croner@10.0.1` 适配器以 paused 状态运行,不创建 callback 或 timer。它补入隐藏的 seconds=`0` 与 year=`1-9999`,过滤由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并严格推进正向与反向 cursor。由于 JavaScript 构造器会重映射 099 年Schedule 自有的本地日历搜索会覆盖这一低年份范围及其向安全年份的过渡;只有进入安全年份后,适配器才会将搜索委托给 Croner。create 选择严格晚于 admission 的第一个 match。延迟唤醒以持久目标为 baseline选择比 baseline 更新且不晚于共享 `acceptedAt` 的最新 current match并找到第一个未来 match。回放只校验规范化结构、整分钟的 UTC 值与单调 dispatch 关系;绝不会让当前 Croner、ICU 或频率证明重新裁定历史 occurrence。
私有 `croner@10.0.1` 适配器以 paused 状态运行,不创建 callback 或 timer。它补入隐藏的 seconds=`0` 与 year=`1-9999`,过滤由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并严格推进正向与反向 cursor。由于 JavaScript 构造器会重映射 099 年Schedule 自有的本地日历搜索会覆盖这一低年份范围及其向安全年份的过渡;只有进入安全年份后,适配器才会将搜索委托给 Croner。create 选择严格晚于 admission 的第一个 match。延迟唤醒以持久目标为 baseline选择比 baseline 更新且不晚于共享 `acceptedAt` 的最新 current match并找到第一个未来 match。package invariant 只对新发生的 live create 与 dispatch append 应用同一套当前日历验证。回放只校验规范化结构、整分钟的 UTC 值与单调 dispatch 关系;绝不会让当前 Croner、ICU 或频率证明重新裁定历史 occurrence。
## 管理工具

View File

@@ -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.

View File

@@ -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 */

View File

@@ -48,6 +48,7 @@ describe('restricted cron grammar and frequency proof', () => {
['5-20/05 1-3 * * *', '5-20/5 1-3 * * *'],
['05 01 01,15 01,12 *', '5 1 1,15 1,12 *'],
['0 0 * * 7', '0 0 * * 7'],
['0 9 * * */7', '0 9 * * */7'],
])('canonicalizes %s', (input, canonical) => {
expect(canonicalizeCronExpression(input)).toBe(canonical)
})
@@ -181,6 +182,13 @@ describe('Croner calendar adapter', () => {
occurrenceAt: yearOne.scheduledAt,
nextScheduledAt: '0001-01-03T00:00:00.000Z',
})
expect(createCronScheduleRecord(
ScheduleId('schedule-low-year-positive-offset-seam'),
'positive offset seam',
'0 0 1 1 *',
'Etc/GMT-14',
Date.parse('0108-12-31T23:59:59.999Z'),
).scheduledAt).toBe('0109-12-31T10:00:00.000Z')
})
it('skips a DST gap and chooses the first instant in an overlap', () => {

View File

@@ -4,7 +4,7 @@ import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import * as scheduleInvariant from '../src/invariant.ts'
import { ScheduleId } from '../src/domain.ts'
import { createCronScheduleRecord, resolveCronOccurrence, ScheduleId } from '../src/domain.ts'
import type { ScheduleChange } from '../src/types.ts'
function event(data: unknown, seq: number): SessionEvent {
@@ -53,6 +53,100 @@ describe('Schedule package invariant', () => {
await ctx.fiber.dispose()
})
it('validates live Cron records and dispatches with current calendar data', async () => {
const { ctx } = await harness()
const session = ctx.sessions.create(SessionId('schedule-live-cron-invariant'))
expect(() => session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: {
id: ScheduleId('schedule-invalid-live-cron'),
kind: 'cron',
prompt: 'invalid current target',
cron: '0 9 * * *',
timeZone: 'UTC',
scheduledAt: '2026-08-06T12:00:00.000Z',
},
})).toThrow(InvariantError)
expect(() => session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: {
id: ScheduleId('schedule-alias-live-cron'),
kind: 'cron',
prompt: 'noncanonical zone',
cron: '0 9 * * *',
timeZone: 'US/Eastern',
scheduledAt: '2026-08-06T13:00:00.000Z',
},
})).toThrow(InvariantError)
expect(() => session.append('schedule/change', {
version: 1,
operation: 'create',
schedule: {
id: ScheduleId('schedule-fast-live-cron'),
kind: 'cron',
prompt: 'too frequent',
cron: '* * * * *',
timeZone: 'UTC',
scheduledAt: '2026-08-06T12:00:00.000Z',
},
})).toThrow(InvariantError)
const record = createCronScheduleRecord(
ScheduleId('schedule-valid-live-cron'),
'valid current target',
'0 9 * * *',
'UTC',
Date.parse('2026-08-06T08:00:00.000Z'),
)
session.append('schedule/change', { version: 1, operation: 'create', schedule: record })
const acceptedAt = '2026-08-07T12:00:00.000Z'
const expected = resolveCronOccurrence(record, Date.parse(acceptedAt))
expect(() => session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: record.id,
occurrenceAt: record.scheduledAt,
acceptedAt,
nextScheduledAt: expected.nextScheduledAt,
})).toThrow(InvariantError)
expect(session.events).toHaveLength(1)
session.append('schedule/change', {
version: 1,
operation: 'dispatch',
id: record.id,
occurrenceAt: expected.occurrenceAt,
acceptedAt,
nextScheduledAt: expected.nextScheduledAt,
})
expect(session.events).toHaveLength(2)
await ctx.fiber.dispose()
})
it('keeps existing Cron replay structural across time-zone data changes', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(InvariantService)
ctx.sessions.create(SessionId('schedule-historical-cron-invariant'), {
seed: [event({
version: 1,
operation: 'create',
schedule: {
id: 'schedule-historical-cron',
kind: 'cron',
prompt: 'historical target',
cron: '0 9 * * *',
timeZone: 'UTC',
scheduledAt: '2026-08-06T12:00:00.000Z',
},
}, 0)],
})
const fiber = await ctx.plugin(scheduleInvariant)
await fiber.dispose()
await ctx.fiber.dispose()
})
it('rejects a malformed existing owned stream during companion setup', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)