fix(schedule): close cron validation gaps
This commit is contained in:
@@ -56,7 +56,7 @@ Schedule owns a numeric five-field parser rather than exposing Croner's language
|
|||||||
|
|
||||||
The frequency proof enumerates the complete 400-year Gregorian date cycle and combines it with exact times-of-day. It checks same-day neighbors, cross-midnight neighbors, and the cycle seam, rejecting any nominal interval below five minutes without maintaining a quota or sampling a shorter window.
|
The frequency proof enumerates the complete 400-year Gregorian date cycle and combines it with exact times-of-day. It checks same-day neighbors, cross-midnight neighbors, and the cycle seam, rejecting any nominal interval below five minutes without maintaining a quota or sampling a shorter window.
|
||||||
|
|
||||||
The exact production dependency is `croner@10.0.1`, an MIT-licensed ESM package with no transitive dependencies. Schedule gives it hidden seconds=`0` and year=`1-9999`, constructs it paused without a callback, and retains timer, gate, admission, and persistence ownership. The adapter rejects gap-normalized candidates, chooses the first instant in an overlap, and requires strict forward/backward cursor movement. JavaScript constructors remap years 0–99, so an owned local-calendar walker handles that lower range and its transition before safe-year searches delegate to Croner. Live create and due handling use current Croner and ICU; replay only checks canonical rule/zone shapes, whole-minute four-digit UTC instants, and `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`, so tzdata changes never invalidate a committed history.
|
The exact production dependency is `croner@10.0.1`, an MIT-licensed ESM package with no transitive dependencies. Schedule gives it hidden seconds=`0` and year=`1-9999`, constructs it paused without a callback, and retains timer, gate, admission, and persistence ownership. The adapter rejects gap-normalized candidates, chooses the first instant in an overlap, and requires strict forward/backward cursor movement. JavaScript constructors remap years 0–99, so an owned local-calendar walker handles that lower range and its transition before safe-year searches delegate to Croner. Live create and due handling, including the pre-append package invariant, use current Croner and ICU; replay only checks canonical rule/zone shapes, whole-minute four-digit UTC instants, and `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`, so tzdata changes never invalidate a committed history.
|
||||||
|
|
||||||
### Persistence checkpoint and initialization recovery
|
### Persistence checkpoint and initialization recovery
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Schedule 拥有自己的数值五字段 parser,而不开放 Croner 语言。
|
|||||||
|
|
||||||
频率证明会枚举完整的 400 年 Gregorian 日期周期,并与精确的一日内时刻组合。它会检查同日相邻时点、跨午夜相邻时点与周期首尾衔接处的相邻时点,拒绝任何短于 5 分钟的名义间隔;整个过程既不维护配额,也不对更短窗口采样。
|
频率证明会枚举完整的 400 年 Gregorian 日期周期,并与精确的一日内时刻组合。它会检查同日相邻时点、跨午夜相邻时点与周期首尾衔接处的相邻时点,拒绝任何短于 5 分钟的名义间隔;整个过程既不维护配额,也不对更短窗口采样。
|
||||||
|
|
||||||
生产环境精确锁定的依赖是 `croner@10.0.1`:这是一个采用 MIT 许可证、不含传递依赖的 ESM 包。Schedule 为其提供隐藏的 seconds=`0` 与 year=`1-9999`,以 paused 状态且不带 callback 构造;timer、门控、准入与持久化仍由 Schedule 拥有。适配器会拒绝由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并要求正向与反向 cursor 严格移动。JavaScript 构造器会重映射 0–99 年,因此 Schedule 自有的本地日历搜索会处理这一低年份范围及其向安全年份的过渡;只有安全年份搜索才会委托给 Croner。live create 与到期处理使用当前 Croner 和 ICU;回放只检查规范化的规则/时区 shape、整分钟且年份为四位数的 UTC 时点,以及 `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`,因此 tzdata 变化绝不会使已提交的 history 失效。
|
生产环境精确锁定的依赖是 `croner@10.0.1`:这是一个采用 MIT 许可证、不含传递依赖的 ESM 包。Schedule 为其提供隐藏的 seconds=`0` 与 year=`1-9999`,以 paused 状态且不带 callback 构造;timer、门控、准入与持久化仍由 Schedule 拥有。适配器会拒绝由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并要求正向与反向 cursor 严格移动。JavaScript 构造器会重映射 0–99 年,因此 Schedule 自有的本地日历搜索会处理这一低年份范围及其向安全年份的过渡;只有安全年份搜索才会委托给 Croner。live create 与到期处理(包括 append 前的 package invariant)使用当前 Croner 和 ICU;回放只检查规范化的规则/时区 shape、整分钟且年份为四位数的 UTC 时点,以及 `currentScheduledAt <= occurrenceAt <= acceptedAt < nextScheduledAt`,因此 tzdata 变化绝不会使已提交的 history 失效。
|
||||||
|
|
||||||
### Persistence checkpoint 与初始化恢复
|
### Persistence checkpoint 与初始化恢复
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|||||||
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
import { REPO_ROOT, connectFreshWorkspace, newEnglishPage, probeFreePort, requireDist, saveFailureShot } from './support.ts'
|
||||||
|
|
||||||
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
|
const DEVELOPMENT_PROMPT = fileURLToPath(new URL('./snapshots/web-runtime-context/development-prompt.expected.md', import.meta.url))
|
||||||
|
const WEB_TIME_ZONE = 'UTC'
|
||||||
|
const SCHEDULE_OVERLAY = fileURLToPath(new URL('../../../examples/web-schedule/cordis.yml', import.meta.url))
|
||||||
|
const REAL_SCHEDULE_PROMPT = 'REAL_MODEL_SCHEDULE_PROBE'
|
||||||
|
|
||||||
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
function waitForReadyLine(child: ChildProcess): Promise<string> {
|
||||||
return new Promise((resolveReady, reject) => {
|
return new Promise((resolveReady, reject) => {
|
||||||
@@ -69,7 +72,7 @@ async function rpc<T>(baseUrl: string, method: string, payload: unknown): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface HistoryPage {
|
interface HistoryPage {
|
||||||
events: { event: { type: string; data: unknown } }[]
|
events: { event: { type: string; data: unknown }; view?: unknown }[]
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,8 +102,8 @@ function hasAssistantMarker(page: HistoryPage, marker: string): boolean {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function history(baseUrl: string, sessionId: string): Promise<HistoryPage> {
|
async function history(baseUrl: string, sessionId: string, maxMessages = 10): Promise<HistoryPage> {
|
||||||
return rpc<HistoryPage>(baseUrl, 'session.history', { sessionId, maxMessages: 10 })
|
return rpc<HistoryPage>(baseUrl, 'session.history', { sessionId, maxMessages })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise<string> {
|
async function waitForProviderTitle(baseUrl: string, sessionId: string): Promise<string> {
|
||||||
@@ -241,11 +244,14 @@ describe('dsh web keyless CLI smoke', () => {
|
|||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const baseUrl = await waitForReadyLine(child)
|
const baseUrl = await waitForReadyLine(child)
|
||||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {
|
||||||
|
timeZone: WEB_TIME_ZONE,
|
||||||
|
})
|
||||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||||
sessionId: created.sessionId,
|
sessionId: created.sessionId,
|
||||||
mode: 'queue',
|
mode: 'queue',
|
||||||
content: [{ type: 'text', text: 'go' }],
|
content: [{ type: 'text', text: 'go' }],
|
||||||
|
clientTimeZone: WEB_TIME_ZONE,
|
||||||
})
|
})
|
||||||
const capturedRequests = await Promise.race([
|
const capturedRequests = await Promise.race([
|
||||||
providerRequests,
|
providerRequests,
|
||||||
@@ -353,11 +359,14 @@ describe('dsh web keyless CLI smoke', () => {
|
|||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const baseUrl = await waitForReadyLine(child)
|
const baseUrl = await waitForReadyLine(child)
|
||||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {
|
||||||
|
timeZone: WEB_TIME_ZONE,
|
||||||
|
})
|
||||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||||
sessionId: created.sessionId,
|
sessionId: created.sessionId,
|
||||||
mode: 'queue',
|
mode: 'queue',
|
||||||
content: [{ type: 'text', text: promptMarker }],
|
content: [{ type: 'text', text: promptMarker }],
|
||||||
|
clientTimeZone: WEB_TIME_ZONE,
|
||||||
})
|
})
|
||||||
let page: HistoryPage | undefined
|
let page: HistoryPage | undefined
|
||||||
await expect.poll(async () => {
|
await expect.poll(async () => {
|
||||||
@@ -437,11 +446,14 @@ describe('dsh web keyless CLI smoke', () => {
|
|||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const baseUrl = await waitForReadyLine(child)
|
const baseUrl = await waitForReadyLine(child)
|
||||||
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {})
|
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {
|
||||||
|
timeZone: WEB_TIME_ZONE,
|
||||||
|
})
|
||||||
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||||
sessionId: created.sessionId,
|
sessionId: created.sessionId,
|
||||||
mode: 'queue',
|
mode: 'queue',
|
||||||
content: [{ type: 'text', text: 'go' }],
|
content: [{ type: 'text', text: 'go' }],
|
||||||
|
clientTimeZone: WEB_TIME_ZONE,
|
||||||
})
|
})
|
||||||
const captured = await Promise.race([
|
const captured = await Promise.race([
|
||||||
providerRequest,
|
providerRequest,
|
||||||
@@ -465,6 +477,85 @@ describe('dsh web keyless CLI smoke', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('web Schedule smoke (real model)', () => {
|
||||||
|
it('creates and dispatches a reminder with durable tool and receipt evidence', async () => {
|
||||||
|
requireDist()
|
||||||
|
const sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-schedule-real-'))
|
||||||
|
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'),
|
||||||
|
'web', '--port', '0', '--patch', SCHEDULE_OVERLAY,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: sessionsDir,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DSH_HOME: join(sessionsDir, '.dsh'),
|
||||||
|
DSH_AGENTS_HOME: join(sessionsDir, '.agents'),
|
||||||
|
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const baseUrl = (await waitForReadyLine(child)).replace('0.0.0.0', '127.0.0.1')
|
||||||
|
const created = await rpc<{ sessionId: string }>(baseUrl, 'session.create', {
|
||||||
|
timeZone: WEB_TIME_ZONE,
|
||||||
|
})
|
||||||
|
await rpc<{ accepted: true }>(baseUrl, 'session.prompt', {
|
||||||
|
sessionId: created.sessionId,
|
||||||
|
mode: 'queue',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: `Call schedule_create now with exactly {"prompt":"${REAL_SCHEDULE_PROMPT}","after_seconds":1}. Do not answer without using the tool.`,
|
||||||
|
}],
|
||||||
|
clientTimeZone: WEB_TIME_ZONE,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const page = await history(baseUrl, created.sessionId, 50)
|
||||||
|
const call = page.events.find(({ event }) =>
|
||||||
|
event.type === 'tool/call' && isRecord(event.data) && event.data.name === 'schedule_create')
|
||||||
|
const callId = isRecord(call?.event.data) ? call.event.data.callId : undefined
|
||||||
|
if (typeof callId !== 'string') return false
|
||||||
|
const result = page.events.find(({ event }) => {
|
||||||
|
if (event.type !== 'tool/result' || !isRecord(event.data) || !isRecord(event.data.message)) return false
|
||||||
|
const source = event.data.message.source
|
||||||
|
return isRecord(source) && source.callId === callId
|
||||||
|
})
|
||||||
|
const create = page.events.find(({ event }) => {
|
||||||
|
if (event.type !== 'schedule/change' || !isRecord(event.data)
|
||||||
|
|| event.data.operation !== 'create' || !isRecord(event.data.schedule)) return false
|
||||||
|
return event.data.schedule.prompt === REAL_SCHEDULE_PROMPT
|
||||||
|
})
|
||||||
|
const schedule = isRecord(create?.event.data) && isRecord(create.event.data.schedule)
|
||||||
|
? create.event.data.schedule
|
||||||
|
: undefined
|
||||||
|
const scheduleId = schedule?.id
|
||||||
|
if (typeof scheduleId !== 'string' || result === undefined
|
||||||
|
|| !JSON.stringify(result.event.data).includes(scheduleId)) return false
|
||||||
|
const dispatch = page.events.find(({ event }) =>
|
||||||
|
event.type === 'schedule/change' && isRecord(event.data)
|
||||||
|
&& event.data.operation === 'dispatch' && event.data.id === scheduleId)
|
||||||
|
if (dispatch === undefined || !isRecord(dispatch.view) || !isRecord(dispatch.view.view)) return false
|
||||||
|
return dispatch.view.for === 'event'
|
||||||
|
&& dispatch.view.view.scheduleId === scheduleId
|
||||||
|
&& dispatch.view.view.prompt === REAL_SCHEDULE_PROMPT
|
||||||
|
}, { timeout: 240_000, interval: 1_000 }).toBe(true)
|
||||||
|
} finally {
|
||||||
|
const closed = child.exitCode === null
|
||||||
|
? new Promise<void>((resolveClose) => { child.once('close', () => { resolveClose() }) })
|
||||||
|
: Promise.resolve()
|
||||||
|
if (child.exitCode === null) child.kill('SIGTERM')
|
||||||
|
await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 10_000).unref())])
|
||||||
|
if (child.exitCode === null) child.kill('SIGKILL')
|
||||||
|
rmSync(sessionsDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}, 300_000)
|
||||||
|
})
|
||||||
|
|
||||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke (real host, real key, W5)', () => {
|
||||||
let child: ChildProcess
|
let child: ChildProcess
|
||||||
let sessionsDir: string
|
let sessionsDir: string
|
||||||
|
|||||||
@@ -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.
|
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 0–99, 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 0–99, 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
|
## Management tools
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Web Host 会在创建 Session 时以及每次提交提示词时校验并规范
|
|||||||
|
|
||||||
Schedule 会针对完整的 400 年 Gregorian 历法周期证明名义本地间隔,其中包括跨午夜相邻时点与周期首尾衔接处的相邻时点;任何可能以不足 5 分钟的间隔重复发生的规则都会被拒绝。它通过 `Intl` 规范化显式时区;接受 `UTC`、IANA Area/Location 名称或链接,不接受本地默认值、缩写或数值偏移。
|
Schedule 会针对完整的 400 年 Gregorian 历法周期证明名义本地间隔,其中包括跨午夜相邻时点与周期首尾衔接处的相邻时点;任何可能以不足 5 分钟的间隔重复发生的规则都会被拒绝。它通过 `Intl` 规范化显式时区;接受 `UTC`、IANA Area/Location 名称或链接,不接受本地默认值、缩写或数值偏移。
|
||||||
|
|
||||||
私有 `croner@10.0.1` 适配器以 paused 状态运行,不创建 callback 或 timer。它补入隐藏的 seconds=`0` 与 year=`1-9999`,过滤由夏令时空档规范化产生的候选值,在重叠时段选择第一个时刻,并严格推进正向与反向 cursor。由于 JavaScript 构造器会重映射 0–99 年,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 构造器会重映射 0–99 年,Schedule 自有的本地日历搜索会覆盖这一低年份范围及其向安全年份的过渡;只有进入安全年份后,适配器才会将搜索委托给 Croner。create 选择严格晚于 admission 的第一个 match。延迟唤醒以持久目标为 baseline,选择比 baseline 更新且不晚于共享 `acceptedAt` 的最新 current match,并找到第一个未来 match。package invariant 只对新发生的 live create 与 dispatch append 应用同一套当前日历验证。回放只校验规范化结构、整分钟的 UTC 值与单调 dispatch 关系;绝不会让当前 Croner、ICU 或频率证明重新裁定历史 occurrence。
|
||||||
|
|
||||||
## 管理工具
|
## 管理工具
|
||||||
|
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ function parseCronField(raw: string, spec: CronFieldSpec): ParsedCronField {
|
|||||||
const canonical = step.value === 1 ? '*' : `*/${step.canonical}`
|
const canonical = step.value === 1 ? '*' : `*/${step.canonical}`
|
||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
canonical,
|
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,
|
timeZone: string,
|
||||||
epoch: number,
|
epoch: number,
|
||||||
): boolean {
|
): boolean {
|
||||||
|
/* v8 ignore next 4 -- pinned Croner emits finite in-range whole-minute candidates for this expression. */
|
||||||
if (!Number.isSafeInteger(epoch)
|
if (!Number.isSafeInteger(epoch)
|
||||||
|| epoch < MIN_FOUR_DIGIT_YEAR_MS
|
|| epoch < MIN_FOUR_DIGIT_YEAR_MS
|
||||||
|| epoch > MAX_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) {
|
if (new Date(after).getUTCFullYear() <= CRONER_LOW_YEAR_CUTOFF) {
|
||||||
const lower = ownedLowYearCronInstant(rule, timeZone, after, 1)
|
const lower = ownedLowYearCronInstant(rule, timeZone, after, 1)
|
||||||
if (lower !== undefined) return lower
|
if (lower !== undefined) return lower
|
||||||
cursor = Math.max(cursor, Date.parse('0109-12-31T23:59:59.999Z'))
|
|
||||||
}
|
}
|
||||||
const evaluator = cronEvaluator(rule, timeZone)
|
const evaluator = cronEvaluator(rule, timeZone)
|
||||||
const formatter = cronLocalFormatter(timeZone)
|
const formatter = cronLocalFormatter(timeZone)
|
||||||
@@ -872,6 +872,26 @@ function previousCronInstant(
|
|||||||
return latestCronInstantThrough(rule, timeZone, baseline, acceptedAt)
|
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. */
|
/** Decode the exact v1 after record shape. */
|
||||||
function decodeAfterRecord(value: unknown): AfterScheduleRecord {
|
function decodeAfterRecord(value: unknown): AfterScheduleRecord {
|
||||||
if (!isRecord(value) || !hasExactKeys(value, ['id', 'kind', 'prompt', 'afterSeconds', 'scheduledAt'])) {
|
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.
|
* Allocate the next readable id without reusing any prior session-local id.
|
||||||
* @param folded - Fold containing every previously created id.
|
* @param folded - Fold containing every previously created id.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import type { Context } from 'cordis'
|
import type { Context } from 'cordis'
|
||||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
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'
|
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. */
|
/** Service required before reserving this package's invariant ownership. */
|
||||||
export const inject = ['invariants']
|
export const inject = ['invariants']
|
||||||
|
|
||||||
/** Validate a complete exact-session stream under its fork suffix policy. */
|
/** Convert an owned Schedule validation failure into the invariant service's failure channel. */
|
||||||
function validate(events: readonly SessionEvent[], seedLength: number, fail: InvariantFailure): void {
|
function report(run: () => void, fail: InvariantFailure): void {
|
||||||
try {
|
try {
|
||||||
foldScheduleEvents(events, seedLength)
|
run()
|
||||||
} catch (error: unknown) {
|
} 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
|
if (!(error instanceof ScheduleLogError)) throw error
|
||||||
fail(error.message)
|
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 */
|
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||||
/** Install replay and pre-append validation for the owned event stream. */
|
/** Install replay and pre-append validation for the owned event stream. */
|
||||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
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]
|
const [session, event] = args as [Session, SessionEvent]
|
||||||
if (event.type !== 'schedule/change') return
|
if (event.type !== 'schedule/change') return
|
||||||
validate([...session.events, event], session.header.seedLength ?? 0, fail)
|
validate([...session.events, event], session.header.seedLength ?? 0, fail)
|
||||||
|
report(() => {
|
||||||
|
validateLiveScheduleChange(session.events, event.data, session.header.seedLength ?? 0)
|
||||||
|
}, fail)
|
||||||
}, { global: true })
|
}, { global: true })
|
||||||
}, { inject: ['sessions'] })
|
}, { inject: ['sessions'] })
|
||||||
/* jscpd:ignore-end */
|
/* jscpd:ignore-end */
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ describe('restricted cron grammar and frequency proof', () => {
|
|||||||
['5-20/05 1-3 * * *', '5-20/5 1-3 * * *'],
|
['5-20/05 1-3 * * *', '5-20/5 1-3 * * *'],
|
||||||
['05 01 01,15 01,12 *', '5 1 1,15 1,12 *'],
|
['05 01 01,15 01,12 *', '5 1 1,15 1,12 *'],
|
||||||
['0 0 * * 7', '0 0 * * 7'],
|
['0 0 * * 7', '0 0 * * 7'],
|
||||||
|
['0 9 * * */7', '0 9 * * */7'],
|
||||||
])('canonicalizes %s', (input, canonical) => {
|
])('canonicalizes %s', (input, canonical) => {
|
||||||
expect(canonicalizeCronExpression(input)).toBe(canonical)
|
expect(canonicalizeCronExpression(input)).toBe(canonical)
|
||||||
})
|
})
|
||||||
@@ -181,6 +182,13 @@ describe('Croner calendar adapter', () => {
|
|||||||
occurrenceAt: yearOne.scheduledAt,
|
occurrenceAt: yearOne.scheduledAt,
|
||||||
nextScheduledAt: '0001-01-03T00:00:00.000Z',
|
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', () => {
|
it('skips a DST gap and chooses the first instant in an overlap', () => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
|||||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||||
import * as scheduleInvariant from '../src/invariant.ts'
|
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'
|
import type { ScheduleChange } from '../src/types.ts'
|
||||||
|
|
||||||
function event(data: unknown, seq: number): SessionEvent {
|
function event(data: unknown, seq: number): SessionEvent {
|
||||||
@@ -53,6 +53,100 @@ describe('Schedule package invariant', () => {
|
|||||||
await ctx.fiber.dispose()
|
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 () => {
|
it('rejects a malformed existing owned stream during companion setup', async () => {
|
||||||
const ctx = new Context()
|
const ctx = new Context()
|
||||||
await ctx.plugin(SessionStore)
|
await ctx.plugin(SessionStore)
|
||||||
|
|||||||
Reference in New Issue
Block a user