Merge pull request #7780 from deepseek-harness/worktree-sync-dsh-0.2.2-to-master-1103

release(dsh): sync release/dsh-0.2.2 back to master (#7704)
This commit is contained in:
imccyu
2026-11-03 14:07:24 +08:00
committed by GitHub
10 changed files with 495 additions and 54 deletions
+101 -1
View File
@@ -1,6 +1,7 @@
/** Schedule creation, prompt prefill, and editing through the shipped Web composition. */
import { chromium } from 'playwright'
import { expect, it } from 'vitest'
import type { ScheduleTiming } from '@deepseek-ai/dsh-schedule/client'
import { launchWebScaffold, watchConsole } from './scaffold.ts'
import { writeComposerDraft } from './support.ts'
@@ -47,6 +48,105 @@ it.each([
}
})
it('opens stored timing fields immediately without selecting another repeat option', async () => {
const scaffold = await launchWebScaffold({ firstUse: true })
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
try {
const clock = new Date(Date.now() + 4 * 60 * 60_000).toISOString().slice(11, 16)
const choices = [
{ timing: { kind: 'after', after_seconds: 3600 }, repeat: '仅一次', field: '日期' },
{ timing: { kind: 'at', at: '2099-01-05T09:00:00.000Z' }, repeat: '仅一次', field: '日期' },
{ timing: { kind: 'every', every_seconds: 300 }, repeat: '每 N 分钟', field: '重复间隔' },
{ timing: { kind: 'daily', daily: { time: `${clock}:00`, time_zone: 'UTC' } }, repeat: '每天', field: '时间' },
{ timing: { kind: 'weekly', weekly: { time: `${clock}:45`, time_zone: 'UTC', weekdays: [1, 5] } },
repeat: '每周', field: '星期' },
] satisfies { timing: ScheduleTiming; repeat: string; field: string }[]
const tasks = []
for (const [index, choice] of choices.entries()) {
const task = await scaffold.ctx.schedule.create({ title: `Stored task ${index}`, prompt: 'Review the project', timing: choice.timing })
await scaffold.ctx.schedule.edit({ id: task.id, action: 'update', patch: { active: false } })
tasks.push({ ...choice, task })
}
browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai' })
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'zh') })
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('button', { name: '自动化任务', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('button', { name: '全部', exact: true }).click()
for (const { task, repeat, field } of tasks) {
await manager.getByRole('button', { name: task.title, exact: true }).click()
const detail = manager.getByRole('complementary', { name: '任务详情', exact: true })
expect(await detail.getByRole('button', { name: `重复 ${repeat}`, exact: true }).isVisible()).toBe(true)
const role = field === '星期' ? 'group' : field === '重复间隔' ? 'spinbutton' : 'button'
expect(await detail.getByRole(role, { name: field, exact: true }).isVisible()).toBe(true)
expect(await detail.getByRole('button', { name: '保存修改', exact: true }).isDisabled()).toBe(true)
expect(await scaffold.ctx.schedule.view({ id: task.id })).toMatchObject({ kind: task.kind, scheduledAt: task.scheduledAt })
if (field === '日期') {
await detail.getByRole('button', { name: '日期', exact: true }).click()
await page.getByRole('dialog', { name: '日期', exact: true }).waitFor()
await page.keyboard.press('Escape')
}
}
expect(tripwire.pageErrors).toEqual([])
} finally {
try { await browser?.close() } finally { await scaffold.close() }
}
})
it.each(['2026-03-07T16:00:00.000Z', '2026-03-08T16:00:00.000Z'])(
'saves a stored weekly task on %s without a false timing error', async (now) => {
const scaffold = await launchWebScaffold({ firstUse: true })
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
try {
const task = await scaffold.ctx.schedule.create({ title: 'Weekly review', prompt: 'Review the project',
timing: { kind: 'weekly', weekly: { time: '02:30:00', time_zone: 'America/New_York', weekdays: [5] } } })
await scaffold.ctx.schedule.edit({ id: task.id, action: 'update', patch: { active: false } })
browser = await chromium.launch()
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: 'zh-CN', timezoneId: 'America/New_York' })
await page.addInitScript((now) => {
localStorage.setItem('dsh.locale', 'zh')
Date.now = () => Date.parse(now)
}, now)
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('button', { name: '自动化任务', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
await manager.getByRole('button', { name: '全部', exact: true }).click()
await manager.getByRole('button', { name: task.title, exact: true }).click()
const detail = manager.getByRole('complementary', { name: '任务详情', exact: true })
await detail.getByRole('group', { name: '星期', exact: true }).getByRole('button').nth(0).click()
expect(await detail.getByRole('status').count()).toBe(0)
const save = detail.getByRole('button', { name: '保存修改', exact: true })
expect(await save.isEnabled()).toBe(true)
await save.click()
await expect.poll(async () => {
const record = await scaffold.ctx.schedule.view({ id: task.id })
return 'code' in record ? undefined : record.kind
}).toBe('rrule')
const updated = await scaffold.ctx.schedule.view({ id: task.id })
expect(updated).toMatchObject({ kind: 'rrule', status: 'paused', target: task.target })
if ('code' in updated || updated.kind !== 'rrule') throw new Error('expected an edited weekly rule')
expect(updated.rrule).toContain('BYDAY=MO,FR')
await page.reload({ waitUntil: 'load' })
await page.getByRole('button', { name: '自动化任务', exact: true }).click()
await manager.getByRole('button', { name: '全部', exact: true }).click()
await manager.getByRole('button', { name: task.title, exact: true }).click()
await detail.getByRole('button', { name: '重复 每周', exact: true }).waitFor()
expect(await detail.getByRole('group', { name: '星期', exact: true }).getByRole('button')
.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-pressed'))))
.toEqual(['true', 'false', 'false', 'false', 'true', 'false', 'false'])
expect(await detail.getByRole('status').count()).toBe(0)
expect(await save.isDisabled()).toBe(true)
expect(tripwire.pageErrors).toEqual([])
} finally {
try { await browser?.close() } finally { await scaffold.close() }
}
},
)
it('creates from a paused empty state, selects the task, and saves an inline edit without starting an Agent', async () => {
// First use registers the default Workspace, which a new task's directory starts with.
const scaffold = await launchWebScaffold({ firstUse: true })
@@ -222,7 +322,7 @@ it('creates and edits Custom Cron through the built UI while retaining its expli
await detail.getByRole('textbox', { name: '自定义', exact: true }).fill(' ')
expect(await detail.getByRole('button', { name: '保存修改', exact: true }).isDisabled()).toBe(true)
await detail.getByRole('status').getByText('请检查必填项和运行规则', { exact: true }).waitFor()
await detail.getByRole('status').getByText('请修改自定义规则、选择其他重复方式,或让智能体协助修改', { exact: true }).waitFor()
expect(await scaffold.ctx.schedule.view({ id: created.id })).toEqual(created)
await detail.getByRole('textbox', { name: '自定义', exact: true }).fill('0 10 * * 1-5')
await detail.getByRole('button', { name: /^时区 / }).waitFor()
+4 -4
View File
@@ -15,14 +15,14 @@
en: d152484eb41ac6b4
zh: 405b30feaf48c087
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
en: 5cd34ed47dea2d59
zh: c77b078e9e2c2197
en: 99626c29eb801b6f
zh: 9693d278f467ad90
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
en: 4005b800e930f581
zh: ccb9318dad7f7e8f
/deepseek-ai-dsh-client-ui-schedule/understand-the-implementation:
en: f9895e84de5f5eb7
zh: 844cba2fc3f424bc
en: 72598a09be42251d
zh: 4140b2eec82c7f1f
/deepseek-ai-dsh-client-ui-schedule/further-exploration:
en: 2fd968202916b197
zh: f1ab66194846562b
+3 -1
View File
@@ -33,7 +33,7 @@ Enabled, Paused, Ended, and All filters combine with text search over the title,
The destination menu offers a new conversation that the first delivery creates and later deliveries reuse, or a new conversation at every delivery. Manual creation defaults to reuse. A task already bound to a Session shows Current linked conversation as its destination, and editing it preserves that destination unless the user explicitly chooses a new one. The Linked workspace selector lists the framework Workspace catalog, shows each Workspace's directory under its name, and labels the first-use Workspace with its localized default name. A new destination starts in that Workspace, else in the first listed one. A stored task without a directory shows Ungrouped, because the Sessions it creates join no Workspace. For a reusable destination, the bottom conversation entry shows Pending creation until the first Session exists. A per-run destination instead shows Latest run conversation after its first delivery and hides the entry before that delivery; earlier conversations remain accessible from delivery records. Available links recheck membership and archive state before opening; missing or archived Sessions remain disabled.
The repeat editor offers once, minute, hour, day, week, month, and custom rules. Date and clock controls reuse existing pickers. Guided presets produce RRULE; minute and hour intervals start from the current minute in the selected zone. The Custom choice and Customize input do not name either syntax; the input accepts a two-line RRULE or a five-field Cron expression with an explicit zone. RRULE retains the zone in `DTSTART`; Cron retains its expression and selected zone as Cron timing. Complex RRULE values remain verbatim. The shared time parser checks new or changed Custom text before enabling Save; invalid or empty input keeps the draft and shows the correction message. The Host performs the final validation on save. Existing After, At, Every, Daily, and Weekly records show their description in Custom and keep their typed timing until a replacement is entered. Editing other fields preserves the committed occurrence.
The repeat editor offers once, minute, hour, day, week, month, and custom rules. Date and clock controls reuse existing pickers. Guided presets produce RRULE; minute and hour intervals start from the current minute in the selected zone. The Custom choice and Customize input do not name either syntax; the input accepts a two-line RRULE or a five-field Cron expression with an explicit zone. RRULE retains the zone in `DTSTART`; Cron retains its expression and selected zone as Cron timing. Complex RRULE values remain verbatim. The shared time parser checks new or changed Custom text before enabling Save; invalid or empty input keeps the draft and explains which field to change. Invalid Custom text offers manual correction, another repeat option, or correction by the Agent. The Host performs the final validation on save. Stored After and At tasks show date and clock fields immediately; a repeated local time uses UTC when needed to retain the exact instant. Every, Daily, and Weekly tasks show the matching controls when their interval or wall-clock precision is representable. Other stored timings remain in Custom and keep their typed timing until replaced. Viewing a task or editing other fields preserves its timing and committed occurrence.
Pause and resume update lifecycle state. Unsaved changes hold pause, resume, Run now, and Delete until you save or discard them. The detail menu offers Run now and Delete. It offers no queued-message cancellation: one inbox message may contain deliveries from several tasks. While a delivery to a reused conversation is queued or running, the detail disables its fields, Save, pause and resume, Run now, and Delete, and states that the task is queued or running and is managed in its conversation, which the bottom conversation entry opens. The minimum delivery interval disables another immediate run until the interval ends, while pause and deletion remain available once execution is idle. When the interval is the only cause, the Run now row stays focusable, and its tooltip states that the minimum run interval has not passed and when Run now becomes available. An archived linked conversation of a task already bound to a Session also disables Run now, because the Host refuses that delivery. A run that the Host refuses with `schedule_execution_pending` shows a status notice instead of an error. Deletion requires confirmation, removes the task and its saved records, retains Sessions and already-delivered messages, and reports the result through the existing app-wide toast. The confirmation focuses Cancel first; when it closes, focus returns to the detail menu button. The detail keeps the task until the Host answers. An applied deletion closes the detail or its task tab; a failed deletion keeps the task open for another attempt.
@@ -57,6 +57,8 @@ Task menus use the [standard menu geometry](../../../docs/ui-radius.md#nested-ho
While the Peer may read `schedule/catalog`, the plugin registers the existing `schedules` main panel, Session header utility, Turn tail card, Session row mark and hover entries, and `scheduleTask` right Sidebar tab. Slot injection supplies framework readers and action callbacks; components own selection, search, and paging state. Entry-declared stores retain each task draft and its CAS baseline across navigation within their root or Session scope; the main panel and Session tabs have independent drafts. Pristine forms follow the catalog. A dirty form retains all fields and its original revision. A newer catalog revision prevents saving until the user discards the draft or explicitly reapplies changed title, prompt, timing, and target groups to that revision. Reapplying does not write; the user reviews the resulting form and saves with the new CAS baseline. Untouched groups follow the newer record. Missing tasks retain editable local drafts while Host mutations stay disabled. Drafts are not persisted; a Client reload or disposal of the owning entry or Session scope drops them. The Host owns task storage and execution. Browsing catalogs does not activate a Session. Conflict feedback reuses the catalog notice style; its outlined review action wraps below the message in narrow panes.
Edits to typed calendar timing use the shared recurrence evaluator to find the first valid future occurrence while preserving interval alignment across daylight-saving gaps. Successful edits use Schedule’s existing [current-format write path](../../schedule/schedule/README.md); a content-only edit retains the timing kind, precision, expression, and committed occurrence.
The manager, task tabs, row marks, and conversation-header projections share one observable `schedule.catalog` source. Header and row views filter active records by involvement and delivery target. Mutations use task-id-based `schedule.create`, `schedule.edit`, and `schedule.run`; history uses task identity and cursor without a Session ownership parameter. A mutation acknowledgment triggers a fresh authoritative read; a failed read remains a catalog error rather than changing the acknowledged write into a failure.
Sidebar layout records omit opener parameters. `TaskTabBindings` therefore persists task identity by Session and tab id in localStorage, without storing task content. Closed tab bindings are pruned. Observable catalog requests subscribe only while read, invalidate on Schedule changes and connection reset, and retire outstanding reads after the final subscriber leaves.
+3 -1
View File
@@ -33,7 +33,7 @@ kind: "package-reference"
执行位置下拉提供“新建对话并复用”和“每次新建对话”,手动创建默认前者。已经关联会话的任务,执行位置显示“当前关联对话”;编辑时保留原目标,只有用户主动选择新方式才会切换。“关联工作区”选择器列出框架工作区目录,在每个工作区名称下显示其路径,并以本地化默认名称标注首次使用时创建的工作区。新的执行位置默认使用该工作区;没有该工作区时使用列表中的第一个。没有目录的已有任务显示“未分组”,因为它创建的会话不加入任何工作区。复用目标的首次会话尚未存在时,详情底部入口显示“待创建”。每次新建的目标在首次投递后显示“最近运行对话”,首次投递前隐藏该入口;更早的对话仍可从任务运行记录进入。可用链接在打开前再次检查会话是否仍存在、是否归档;缺失或归档会话保持禁用。
重复编辑提供仅一次、每分钟、每小时、每天、每周、每月和自定义规则。日期和时间控件复用已有选择器。引导选项生成 RRULE;分钟和小时间隔从所选时区的当前分钟开始。自定义选项和自定义输入框不提示任何语法名称;输入框接受两行 RRULE 或带显式时区的五字段 Cron。RRULE 在 `DTSTART` 中保留时区;Cron 将表达式和所选时区保留为 Cron 时间类型。复杂 RRULE 保持原文。新的或已修改的自定义文本由共享时间解析器校验,校验通过后才能保存;无效或空输入保留草稿并显示纠正提示。宿主在保存时执行最终校验。已有 After、At、Every、Daily、Weekly 记录在自定义中显示描述,并在输入替代规则前保留原类型。编辑其他字段保留已提交发生时点。
重复编辑提供仅一次、每分钟、每小时、每天、每周、每月和自定义规则。日期和时间控件复用已有选择器。引导选项生成 RRULE;分钟和小时间隔从所选时区的当前分钟开始。自定义选项和自定义输入框不提示任何语法名称;输入框接受两行 RRULE 或带显式时区的五字段 Cron。RRULE 在 `DTSTART` 中保留时区;Cron 将表达式和所选时区保留为 Cron 时间类型。复杂 RRULE 保持原文。新的或已修改的自定义文本由共享时间解析器校验,校验通过后才能保存;无效或空输入保留草稿,并说明需要修改的字段。自定义文本无效时,提示手动修改规则、选择其他重复方式,或让智能体修改规则。宿主在保存时执行最终校验。已有 After、At 任务直接显示日期和时间控件;本地时间重复时,必要时使用 UTC 保留准确时点。Every、Daily、Weekly 任务在控件可表示其间隔或钟表时间精度时直接显示对应控件;其他时间规则仍在自定义中显示描述,并在替换前保留原类型。查看任务或编辑其他字段保留其时间规则及已提交发生时点。
暂停和恢复修改生命周期状态。有未保存修改时,先保存或放弃更改,才能暂停、恢复、立即执行或删除。详情菜单提供立即执行和删除,不提供取消排队消息:同一条收件箱消息可能包含多个任务的投递。投递到复用对话的投递排队或执行期间,详情禁用字段、保存、暂停与恢复、立即执行和删除,并说明此任务已排队或执行中,请在会话中管理任务;详情底部的会话入口打开该会话。最小投递间隔结束前禁用再次立即执行,执行空闲后仍可暂停或删除。仅因间隔禁用时,立即执行项仍可聚焦,其提示说明未满最小运行间隔,并给出可以立即执行的时间。已经关联会话的任务,其关联对话已归档时,同样禁用立即执行,因为宿主拒绝这次投递。宿主以 `schedule_execution_pending` 拒绝的立即执行显示为状态提示,而非错误。删除需要确认,移除任务及其已保存记录,保留会话和已经投递的消息,并通过现有应用级提示报告结果。确认对话框首先聚焦“取消”;对话框关闭后,焦点回到详情菜单按钮。宿主答复之前,详情保持显示该任务。删除生效后关闭详情或其任务页签;删除失败时保留任务,以便再次尝试。
@@ -57,6 +57,8 @@ kind: "package-reference"
Peer 可读取 `schedule/catalog` 时,插件注册现有 `schedules` 主面板、会话页头功能位、轮次尾部卡片、会话行标记与悬浮内容,以及 `scheduleTask` 右侧栏页签。槽位注入提供框架读取器与操作回调;组件拥有选择、搜索和分页状态。入口声明的存储在根或会话作用域中按任务保留草稿和 CAS 基线,支持导航后恢复;主面板与会话页签的草稿相互独立。未修改的表单跟随目录更新。已修改的表单保留全部字段及原版本。目录出现新版本后,须放弃草稿,或由用户明确将改过的名称、内容、时间和目标字段组重新应用到该版本,才能保存。此操作不写入任务;用户复核结果后,以新版本作为 CAS 基线保存。未修改的字段组采用新记录的值。任务缺失时保留可编辑的本地草稿,宿主修改操作仍禁用。草稿不持久化;客户端重新加载,或所属入口、会话作用域销毁时,草稿随之释放。宿主拥有任务存储与执行。浏览目录不会激活会话。冲突反馈沿用目录提示样式;复核按钮采用描边样式,在窄面板中换到提示下方。
修改类型化日历时间时,通过共享重复规则求值器查找首个有效未来发生时点,并保留跨夏令时缺口的间隔对齐。成功编辑沿用 Schedule 的[当前格式写入路径](../../schedule/schedule/README.zh.md);仅修改内容时保留时间类型、精度、表达式及已提交发生时点。
管理页、任务页签、行标记和会话页头投影共享一个可观察的 `schedule.catalog` 数据源。页头与会话行根据关联关系和执行目标筛选活动记录。修改使用基于任务 id 的 `schedule.create`、`schedule.edit` 和 `schedule.run`;历史使用任务标识和游标,不携带会话所有权参数。修改确认后发起新的权威读取;回读失败仍是目录错误,不将已经确认的写入改判为失败。
侧栏布局记录不保存打开参数,因此 `TaskTabBindings` 按会话和页签 id 在 localStorage 中保存任务标识,不保存任务内容,并清理已关闭页签的关联。可观察目录仅在被读取时订阅,在 Schedule 变更或连接重置时刷新,最后一个订阅退出后使未完成读取失效。
@@ -13,7 +13,7 @@ import {
import { CatalogFeedback } from './CatalogFeedback.tsx'
import { DeliveryHistory, type DeliveryHistoryInjected } from './DeliveryHistory.tsx'
import type { CatalogDeleteOutcome, CatalogSnapshot } from './catalog-source.ts'
import { TaskEditorFields, draftTarget, newTaskDraft, projectChoices, reapplyTaskDraft, taskDraft, taskDraftPatch, taskDraftValid } from './TaskEditorFields.tsx'
import { TaskEditorFields, draftTarget, newTaskDraft, projectChoices, reapplyTaskDraft, taskDraft, taskDraftIssue, taskDraftPatch } from './TaskEditorFields.tsx'
import type { TaskEditorDraft } from './TaskEditorFields.tsx'
import type { TaskEditorStoreProps } from './editor-store.ts'
import { timingFromDraft } from './editor-timing.ts'
@@ -213,7 +213,8 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
const draft = task === undefined && input.cwd === '' ? { ...input, cwd: projects.defaultCwd } : input
const dirty = base === undefined
? JSON.stringify(draft) !== JSON.stringify({ ...initial, cwd: initial.cwd || projects.defaultCwd }) : storedDirty
const valid = !dirty || taskDraftValid(draft, changes)
const issue = dirty ? taskDraftIssue(draft, changes) : undefined
const valid = issue === undefined
const [actionPending, setPending] = useState(false)
const [actionFailure, setFailure] = useState<HostEditFailure | HostRunFailure | null>(null)
const saving = cached?.pending === true
@@ -383,8 +384,8 @@ function TaskDetailView(props: TaskDetailProps): ReactNode {
projects={projects} original={base} t={t} />
{draft.target === 'new'
&& <NewSessionLimit load={hostActions.loadNewSessionInterval} t={t} />}
{failure === null && dirty && !valid && draft.title.trim() !== '' && draft.prompt.trim() !== ''
&& <p className={css.saveFailure} role="status">{t('rule.invalid')}</p>}
{failure === null && issue !== undefined && issue !== 'incomplete'
&& <p className={css.saveFailure} role="status">{t(issue)}</p>}
</>}
{tab === 'records' && task !== undefined && <DeliveryHistory id={task.id} latestMessageId={task.lastDelivery?.messageId}
timeZone={recordTimeZone(task)} loadHistory={loadHistory} onOpenSession={onOpenSession}
@@ -18,6 +18,7 @@ import { formatScheduleFrequency, zoneLabel } from './schedule-format.ts'
import type { FrequencyZone } from './schedule-format.ts'
import { RuleStepper, RuleWeekdayRow } from './TaskRuleControls.tsx'
import { IconCalendarOutlineRegular } from './CalendarIcon.tsx'
import type { TaskManagerKey } from './task-manager-locales.ts'
import css from './TaskManagerPage.module.css'
/** Local form values; only Save writes a task. */
@@ -58,24 +59,29 @@ export function taskDraft(task: ScheduleCatalogEntry): TaskEditorDraft {
}
}
/** Required fields and guided selections that the form can validate locally. */
export function taskDraftValid(draft: TaskEditorDraft, changes?: SchedulePatch): boolean {
const fieldsValid = draft.title.trim() !== '' && draft.prompt.trim() !== ''
&& draft.title.trim().length <= 120
&& (draft.target !== 'session' || draft.sessionId !== '')
&& (draft.frequency === 'once' || draft.frequency === 'advanced'
|| (/^[1-9]\d*$/.test(draft.interval) && Number.isSafeInteger(Number(draft.interval))))
&& (draft.frequency !== 'weekly' || draft.weekdays.length > 0)
&& (draft.frequency !== 'monthly' || Number.isInteger(draft.monthDay) && draft.monthDay >= 1 && draft.monthDay <= 31)
&& (draft.frequency !== 'daily' || /^(?:[01]\d|2[0-3]):[0-5]\d$/.test(draft.dailyTime))
if (!fieldsValid) return false
/**
* Validate the form while retaining untouched stored timing.
* @param draft - Current form values.
* @param changes - Existing task patch; omitted validates a new timing as well.
* @returns First correction key, `incomplete` for missing required fields, or undefined when valid.
*/
export function taskDraftIssue(draft: TaskEditorDraft, changes?: SchedulePatch): TaskManagerKey | 'incomplete' | undefined {
if (draft.title.trim() === '' || draft.prompt.trim() === '' || draft.target === 'session' && draft.sessionId === '') return 'incomplete'
if (draft.title.trim().length > 120) return 'rule.invalidTitle'
if (draft.frequency !== 'once' && draft.frequency !== 'advanced'
&& (!/^[1-9]\d*$/.test(draft.interval) || !Number.isSafeInteger(Number(draft.interval)))) return 'rule.invalidInterval'
if (draft.frequency === 'weekly' && draft.weekdays.length === 0) return 'rule.requiredWeekday'
if (draft.frequency === 'monthly' && (!Number.isInteger(draft.monthDay) || draft.monthDay < 1 || draft.monthDay > 31)) return 'rule.invalidMonthDay'
if (draft.frequency === 'daily' && !/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(draft.dailyTime)) return 'rule.invalidTime'
// An untouched stored rule remains authoritative for a content-only edit.
if (changes !== undefined && changes.timing === undefined) return true
if (changes !== undefined && changes.timing === undefined) return undefined
const timing = timingFromDraft(draft, true)
if (timing.kind === 'cron') return Cron.parse(timing.cron.expression, timing.cron.time_zone).kind === 'accepted'
if (timing.kind !== 'rrule') return true
const parsed = RRule.parse(timing.rrule, { rejectIneffectiveSetPositions: true })
return parsed.kind === 'accepted'
const parsed = timing.kind === 'cron' ? Cron.parse(timing.cron.expression, timing.cron.time_zone)
: timing.kind === 'rrule' ? RRule.parse(timing.rrule, { rejectIneffectiveSetPositions: true }) : undefined
if (parsed?.kind !== 'rejected') return undefined
if (parsed.reason === 'time_zone') return 'rule.invalidZone'
if (parsed.reason === 'density') return 'rule.tooComplex'
return draft.frequency === 'advanced' ? 'rule.invalid' : 'rule.invalidTime'
}
/** Only meaningful field changes enter the CAS edit; untouched timing is never rewritten. */
@@ -1,6 +1,6 @@
/** Lossless guided and Custom editor projections for typed Schedule timing. */
import type { ScheduleCatalogEntry, ScheduleTiming } from '@deepseek-ai/dsh-schedule/client'
import { Cron, RRule } from '@deepseek-ai/dsh-util-time'
import { Cron, RRule, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
import { hostFrequencyKind, ruleFields } from './task-record.ts'
/** Periods represented by guided controls, plus a lossless advanced editor. */
@@ -13,7 +13,7 @@ export interface TimingDraft {
readonly start: string
readonly timeZone: string
readonly rrule: string
/** Existing timing without editable text, retained until a replacement is supplied. */
/** Stored typed timing, retained in Custom and used to anchor guided edits to the current period. */
readonly retainedTiming?: ScheduleTiming
readonly weekdays: readonly number[]
/** Day of the month; a month without that day runs on its last day. */
@@ -28,18 +28,19 @@ const FREQUENCY = {
} as const
/**
* The current minute in the selected zone, as the anchor of a newly saved minute or hour interval.
* Render an instant as the selected zone's local date and clock time.
* @param timeZone - Zone used by the stored DTSTART.
* @returns Zone-local date and whole-minute time.
* @param at - Instant in epoch milliseconds.
* @returns Zone-local date and second-precision time.
*/
function startInZone(timeZone: string): string {
function startInZone(timeZone: string, at: number): string {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(Date.now())
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).formatToParts(at)
/* v8 ignore next -- the formatter emits every date and time part that its options request. */
const field = (type: Intl.DateTimeFormatPartTypes): string => parts.find(part => part.type === type)?.value ?? ''
return `${field('year')}-${field('month')}-${field('day')}T${field('hour')}:${field('minute')}:00`
return `${field('year').padStart(4, '0')}-${field('month')}-${field('day')}T${field('hour')}:${field('minute')}:${field('second')}`
}
/**
@@ -60,17 +61,20 @@ export function defaultTiming(): TimingDraft {
}
/**
* Build exactly one DTSTART and one RRULE for the supported form choices.
* Format guided timing as DTSTART and RRULE content lines; return advanced text verbatim.
* Draft fields and future occurrences require separate validation.
* @param draft - Guided timing values or advanced text.
* @param intervalFromNow - Anchor a newly saved minute/hour interval to the current minute in its zone; false preserves the draft anchor.
* @param intervalFromNow - Use the current minute for intervals and the current period for edited typed recurrences;
* false preserves the draft anchor.
* @returns RFC 5545 content lines.
*/
export function rruleFromDraft(draft: TimingDraft, intervalFromNow = false): string {
if (draft.frequency === 'advanced') return draft.rrule
const now = Date.now()
const anchor = intervalFromNow && (draft.frequency === 'minutely' || draft.frequency === 'hourly')
? startInZone(draft.timeZone) : draft.start
? startInZone(draft.timeZone, Math.floor(now / 60_000) * 60_000) : draft.start
const time = draft.frequency === 'daily' ? `${draft.dailyTime}:00` : anchor.slice(11)
const start = `${anchor.slice(0, 10)}T${time}`.replaceAll(/[-:]/g, '')
let start = `${anchor.slice(0, 10)}T${time}`.replaceAll(/[-:]/g, '')
let rule: string
switch (draft.frequency) {
case 'once': rule = 'FREQ=DAILY;COUNT=1'; break
@@ -85,6 +89,19 @@ export function rruleFromDraft(draft: TimingDraft, intervalFromNow = false): str
/* v8 ignore next -- FrequencyPreset is closed and 'advanced' returned above; a new preset fails to compile here. */
default: throw new Error(`Unsupported schedule timing: ${String(draft.frequency satisfies never)}`)
}
if (intervalFromNow && draft.retainedTiming !== undefined
&& draft.frequency !== 'once' && draft.frequency !== 'minutely' && draft.frequency !== 'hourly') {
const clock = draft.frequency === 'daily' ? ''
: `;BYHOUR=${Number(time.slice(0, 2))};BYMINUTE=${Number(time.slice(3, 5))}`
// Anchor the interval grid to this period so an earlier edited clock can still occur within it.
const probe = `DTSTART;TZID=${draft.timeZone}:${startInZone(draft.timeZone, now).replaceAll(/[-:]/g, '')}`
+ `\nRRULE:${rule}${clock};BYSECOND=${Number(time.slice(6, 8))}`
const parsed = RRule.parse(probe)
const next = parsed.kind === 'accepted' ? parsed.rule.next(now) : undefined
// Callers reject invalid drafts; the Host rejects exhaustion. BYSECOND would make this probe Custom.
if (next === undefined) return probe
start = startInZone(draft.timeZone, Date.parse(next)).replaceAll(/[-:]/g, '')
}
return `DTSTART;TZID=${draft.timeZone}:${start}\nRRULE:${rule}`
}
@@ -107,6 +124,34 @@ export function timingFromRecord(record: ScheduleCatalogEntry): TimingDraft {
case 'daily': retainedTiming = { kind: 'daily', daily: { time: record.time, time_zone: record.timeZone } }; break
case 'weekly': retainedTiming = { kind: 'weekly', weekly: { time: record.time, time_zone: record.timeZone, weekdays: record.weekdays } }; break
}
const scheduledAt = Date.parse(record.scheduledAt)
let timeZone = 'timeZone' in record ? record.timeZone : record.kind === 'every' ? 'UTC' : initial.timeZone
let start = startInZone(timeZone, scheduledAt)
if (record.kind === 'after' || record.kind === 'at') {
const local = parseLocalCalendar(start.slice(0, 10), start.slice(11))
if (local.kind === 'rejected' || resolveLocalInstant(local.time, timeZone) !== Math.floor(scheduledAt / 1_000) * 1_000) {
// A local time that cannot round-trip to the stored instant needs an unambiguous UTC display.
timeZone = 'UTC'
start = startInZone(timeZone, scheduledAt)
}
}
const guided = { ...initial, start, timeZone, retainedTiming, dailyTime: start.slice(11, 16),
monthDay: Number(start.slice(8, 10)),
weekdays: [(new Date(`${start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7 + 1],
}
if (record.kind === 'after' || record.kind === 'at') {
return { ...guided, frequency: 'once' }
}
if (record.kind === 'every' && Number.isSafeInteger(record.everySeconds / 60)) {
const hourly = record.everySeconds % 3_600 === 0
return { ...guided, frequency: hourly ? 'hourly' : 'minutely', interval: String(record.everySeconds / (hourly ? 3_600 : 60)) }
}
if (record.kind === 'daily' && /^\d{2}:\d{2}:00(?:\.0+)?$/.test(record.time)) {
return { ...guided, frequency: 'daily', dailyTime: record.time.slice(0, 5) }
}
if (record.kind === 'weekly' && /^\d{2}:\d{2}:\d{2}(?:\.0+)?$/.test(record.time)) {
return { ...guided, frequency: 'weekly', start: `${start.slice(0, 10)}T${record.time.slice(0, 8)}`, weekdays: record.weekdays }
}
return { ...initial, frequency: 'advanced', rrule: '',
timeZone: 'timeZone' in record ? record.timeZone : initial.timeZone,
retainedTiming,
@@ -138,7 +183,7 @@ export function timingFromRecord(record: ScheduleCatalogEntry): TimingDraft {
/**
* Resolve Custom Cron or RRULE text into its distinct management type.
* @param draft - Current rule editor values, including any untouched stored timing.
* @param intervalFromNow - Whether guided interval timing starts from the current minute.
* @param intervalFromNow - Whether saves anchor intervals and edited typed recurrences to the current date and time.
* @returns Typed timing for the shared Schedule management API.
*/
export function timingFromDraft(draft: TimingDraft, intervalFromNow = false): ScheduleTiming {
@@ -121,7 +121,14 @@ export const en = {
'rule.unsaved': 'Unsaved changes',
'rule.save': 'Save changes',
'rule.saving': 'Saving…',
'rule.invalid': 'Check the required fields and schedule',
'rule.invalid': 'Edit the custom rule, choose another repeat option, or ask the agent to correct it.',
'rule.invalidTitle': 'Use a task name of 1–120 characters.',
'rule.invalidInterval': 'Enter a whole number of at least 1 for the interval.',
'rule.requiredWeekday': 'Select at least one weekday.',
'rule.invalidMonthDay': 'Enter a day of the month from 1 to 31.',
'rule.invalidTime': 'Choose a valid date and time in the selected time zone.',
'rule.invalidZone': 'Choose a valid time zone.',
'rule.tooComplex': 'This rule is too frequent or complex. Choose a repeat option or ask the agent to simplify it.',
'rule.cancel': 'Discard',
'rule.once': 'Once',
'rule.everyMinutes': 'Every N minutes',
@@ -280,7 +287,14 @@ export const zh: Record<TaskManagerKey, string> = {
'rule.unsaved': '有未保存的修改',
'rule.save': '保存修改',
'rule.saving': '保存中…',
'rule.invalid': '请检查必填项和运行规则',
'rule.invalid': '请修改自定义规则、选择其他重复方式,或让智能体协助修改',
'rule.invalidTitle': '请输入1至120个字符的任务名称',
'rule.invalidInterval': '重复间隔需为不小于1的整数',
'rule.requiredWeekday': '请至少选择一天',
'rule.invalidMonthDay': '每月日期需为1至31之间的整数',
'rule.invalidTime': '请在所选时区内选择有效的日期和时间',
'rule.invalidZone': '请选择有效的时区',
'rule.tooComplex': '此规则的运行频率过高或过于复杂,请选择其他重复方式,或让智能体简化规则',
'rule.cancel': '放弃',
'rule.once': '仅一次',
'rule.everyMinutes': '每 N 分钟',
@@ -433,7 +447,14 @@ export const ja = {
'rule.unsaved': '未保存の変更',
'rule.save': '変更を保存',
'rule.saving': '保存中…',
'rule.invalid': '必須項目と実行ルールを確認してください',
'rule.invalid': 'カスタムルールを修正するか、別の繰り返し設定を選ぶか、エージェントに修正を依頼してください',
'rule.invalidTitle': 'タスク名は1〜120文字で入力してください',
'rule.invalidInterval': '間隔には1以上の整数を入力してください',
'rule.requiredWeekday': '曜日を1つ以上選んでください',
'rule.invalidMonthDay': '毎月の日付には1〜31の整数を入力してください',
'rule.invalidTime': '選択したタイムゾーンで有効な日付と時刻を選んでください',
'rule.invalidZone': '有効なタイムゾーンを選んでください',
'rule.tooComplex': 'このルールは実行頻度が高すぎるか、複雑すぎます。別の繰り返し設定を選ぶか、エージェントに簡略化を依頼してください',
'rule.cancel': '破棄',
'rule.once': '1回のみ',
'rule.everyMinutes': 'N分ごと',
@@ -542,7 +563,14 @@ export const es = {
'rule.unsaved': 'Cambios no guardados',
'rule.save': 'Guardar cambios',
'rule.saving': 'Guardando…',
'rule.invalid': 'Comprueba los campos obligatorios y la programación',
'rule.invalid': 'Edita la regla personalizada, elige otra opción de repetición o pide al agente que la corrija.',
'rule.invalidTitle': 'Usa un nombre de tarea de entre 1 y 120 caracteres.',
'rule.invalidInterval': 'Introduce un número entero de al menos 1 para el intervalo.',
'rule.requiredWeekday': 'Selecciona al menos un día de la semana.',
'rule.invalidMonthDay': 'Introduce un día del mes del 1 al 31.',
'rule.invalidTime': 'Elige una fecha y una hora válidas en la zona horaria seleccionada.',
'rule.invalidZone': 'Elige una zona horaria válida.',
'rule.tooComplex': 'Esta regla es demasiado frecuente o compleja. Elige otra opción de repetición o pide al agente que la simplifique.',
'rule.cancel': 'Descartar',
'rule.once': 'una vez',
'rule.everyMinutes': 'Cada N minutos',
@@ -696,7 +724,14 @@ export const fr = {
'rule.unsaved': 'Modifications non enregistrées',
'rule.save': 'Enregistrer les modifications',
'rule.saving': 'Sauvegarde…',
'rule.invalid': 'Vérifiez les champs obligatoires et la planification',
'rule.invalid': 'Modifiez la règle personnalisée, choisissez une autre option de répétition ou demandez à l’agent de la corriger.',
'rule.invalidTitle': 'Utilisez un nom de tâche de 1 à 120 caractères.',
'rule.invalidInterval': 'Saisissez un nombre entier supérieur ou égal à 1 pour l’intervalle.',
'rule.requiredWeekday': 'Sélectionnez au moins un jour de la semaine.',
'rule.invalidMonthDay': 'Saisissez un jour du mois compris entre 1 et 31.',
'rule.invalidTime': 'Choisissez une date et une heure valides dans le fuseau horaire sélectionné.',
'rule.invalidZone': 'Choisissez un fuseau horaire valide.',
'rule.tooComplex': 'Cette règle est trop fréquente ou complexe. Choisissez une option de répétition ou demandez à l’agent de la simplifier.',
'rule.cancel': 'Abandonner',
'rule.once': 'Une fois',
'rule.everyMinutes': 'Toutes les N minutes',
@@ -850,7 +885,14 @@ export const de = {
'rule.unsaved': 'Nicht gespeicherte Änderungen',
'rule.save': 'Änderungen speichern',
'rule.saving': 'Speichern…',
'rule.invalid': 'Pflichtfelder und Zeitplan prüfen',
'rule.invalid': 'Bearbeiten Sie die benutzerdefinierte Regel, wählen Sie eine andere Wiederholung oder bitten Sie den Agenten um eine Korrektur.',
'rule.invalidTitle': 'Verwenden Sie einen Aufgabennamen mit 1 bis 120 Zeichen.',
'rule.invalidInterval': 'Geben Sie für das Intervall eine ganze Zahl von mindestens 1 ein.',
'rule.requiredWeekday': 'Wählen Sie mindestens einen Wochentag.',
'rule.invalidMonthDay': 'Geben Sie einen Tag des Monats von 1 bis 31 ein.',
'rule.invalidTime': 'Wählen Sie ein gültiges Datum und eine gültige Uhrzeit in der ausgewählten Zeitzone.',
'rule.invalidZone': 'Wählen Sie eine gültige Zeitzone.',
'rule.tooComplex': 'Diese Regel ist zu häufig oder zu komplex. Wählen Sie eine Wiederholung oder bitten Sie den Agenten, die Regel zu vereinfachen.',
'rule.cancel': 'Verwerfen',
'rule.once': 'Einmal',
'rule.everyMinutes': 'Alle N Minuten',
@@ -1004,7 +1046,14 @@ export const ru = {
'rule.unsaved': 'Несохраненные изменения',
'rule.save': 'Сохранить изменения',
'rule.saving': 'Сохранение…',
'rule.invalid': 'Проверьте обязательные поля и расписание',
'rule.invalid': 'Измените пользовательское правило, выберите другой вариант повтора или попросите агента исправить правило.',
'rule.invalidTitle': 'Введите название задачи длиной от 1 до 120 символов.',
'rule.invalidInterval': 'Введите для интервала целое число не меньше 1.',
'rule.requiredWeekday': 'Выберите хотя бы один день недели.',
'rule.invalidMonthDay': 'Введите день месяца от 1 до 31.',
'rule.invalidTime': 'Выберите допустимые дату и время в выбранном часовом поясе.',
'rule.invalidZone': 'Выберите допустимый часовой пояс.',
'rule.tooComplex': 'Это правило слишком частое или сложное. Выберите вариант повтора или попросите агента упростить правило.',
'rule.cancel': 'Отменить',
'rule.once': 'Однажды',
'rule.everyMinutes': 'Каждые N минут',
@@ -1158,7 +1207,14 @@ export const pt = {
'rule.unsaved': 'Alterações não salvas',
'rule.save': 'Salvar alterações',
'rule.saving': 'Salvando…',
'rule.invalid': 'Verifique os campos obrigatórios e o agendamento',
'rule.invalid': 'Edite a regra personalizada, escolha outra opção de repetição ou peça ao agente para corrigi-la.',
'rule.invalidTitle': 'Use um nome de tarefa com 1 a 120 caracteres.',
'rule.invalidInterval': 'Insira um número inteiro de pelo menos 1 para o intervalo.',
'rule.requiredWeekday': 'Selecione pelo menos um dia da semana.',
'rule.invalidMonthDay': 'Insira um dia do mês entre 1 e 31.',
'rule.invalidTime': 'Escolha uma data e hora válidas no fuso horário selecionado.',
'rule.invalidZone': 'Escolha um fuso horário válido.',
'rule.tooComplex': 'Esta regra é muito frequente ou complexa. Escolha uma opção de repetição ou peça ao agente para simplificar a regra.',
'rule.cancel': 'Descartar',
'rule.once': 'Uma vez',
'rule.everyMinutes': 'A cada N minutos',
@@ -6,15 +6,16 @@ import type { ScheduleId, ScheduleCatalogEntry, ScheduleRecord } from '@deepseek
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import { RRule } from '@deepseek-ai/dsh-util-time'
import { TaskManagerPage, type TaskManagerPageProps } from '../src/client/TaskManagerPage.tsx'
import type { HostTaskActions } from '../src/client/TaskDetail.tsx'
import type { TaskCreateOutcome } from '../src/client/TaskDetail.tsx'
import { createEditorStore } from '../src/client/editor-store.ts'
import type { CatalogSnapshot } from '../src/client/catalog-source.ts'
import { timingFromRecord } from '../src/client/editor-timing.ts'
import { createRRuleScheduleRecord } from '../../../schedule/schedule/src/domain.ts'
import { rruleFromDraft, timingFromRecord } from '../src/client/editor-timing.ts'
import { createAtScheduleRecord, createDailyScheduleRecord, createRRuleScheduleRecord, createWeeklyScheduleRecord } from '../../../schedule/schedule/src/domain.ts'
import { ScheduleId as createScheduleId } from '../../../schedule/schedule/src/domain.ts'
import { draftTarget, reapplyTaskDraft, taskDraft, taskDraftPatch, taskDraftValid } from '../src/client/TaskEditorFields.tsx'
import { draftTarget, reapplyTaskDraft, taskDraft, taskDraftIssue, taskDraftPatch } from '../src/client/TaskEditorFields.tsx'
import { formatAvailableAt, formatScheduleFrequency, orderScheduleRecords, zoneLabel, zoneName } from '../src/client/schedule-format.ts'
import { en, zh, ja, es, fr, de, ru, pt } from '../src/client/task-manager-locales.ts'
import { pinSystemZone } from './zone-fixture.ts'
@@ -288,13 +289,13 @@ describe('Host task editor on the existing manager', () => {
const historical: ScheduleCatalogEntry = { ...common, kind: 'every', everySeconds: 60,
target: { kind: 'session', sessionId: targetSession } }
const draft = taskDraft(historical)
expect(taskDraftValid(draft)).toBe(true)
expect(taskDraftIssue(draft)).toBeUndefined()
expect(draftTarget(draft, historical)).toEqual(historical.target)
})
it.each(['once', 'advanced'] as const)('leaves future-occurrence validation to the Host for %s', (frequency) => {
expect(taskDraftValid({ ...taskDraft(task), frequency, start: '2000-01-01T09:00:00', timeZone: 'UTC',
rrule: 'DTSTART;TZID=UTC:20000101T090000\nRRULE:FREQ=DAILY;COUNT=1' })).toBe(true)
expect(taskDraftIssue({ ...taskDraft(task), frequency, start: '2000-01-01T09:00:00', timeZone: 'UTC',
rrule: 'DTSTART;TZID=UTC:20000101T090000\nRRULE:FREQ=DAILY;COUNT=1' })).toBeUndefined()
})
it('does not normalize untouched legacy text when opening or editing another field', async () => {
@@ -1080,6 +1081,220 @@ describe('Host task actions', () => {
})
describe('rule editing', () => {
it.each([
{ zone: 'America/New_York', now: '2026-03-08T16:00:00.000Z', next: '2026-03-09T06:30:00.000Z' },
{ zone: 'Europe/Berlin', now: '2026-03-29T12:00:00.000Z', next: '2026-03-30T00:30:00.000Z' },
{ zone: 'Australia/Sydney', now: '2026-10-04T06:00:00.000Z', next: '2026-10-04T15:30:00.000Z' },
])('saves a weekday edit with a real first occurrence on the $zone spring transition', ({ zone, now, next }) => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse(now))
const { rrule: _rrule, kind: _kind, ...common } = task
const record: ScheduleCatalogEntry = { ...common, ...createWeeklyScheduleRecord(createScheduleId(task.id), task.prompt,
{ time: '02:30:00', time_zone: zone, weekdays: [5] }, Date.now(), task.title) }
const draft = { ...taskDraft(record), weekdays: [1, 5] }
const changes = taskDraftPatch(draft, record, true)
expect(taskDraftIssue(draft, changes)).toBeUndefined()
expect(changes.timing?.kind).toBe('rrule')
if (changes.timing?.kind !== 'rrule') throw new Error('expected a guided rule')
expect(createRRuleScheduleRecord(createScheduleId(task.id), task.title, task.prompt, changes.timing.rrule, Date.now()).scheduledAt)
.toBe(next)
})
it.each([
{ frequency: 'daily', interval: '1', next: '2026-03-09T06:30:00.000Z' },
{ frequency: 'daily', interval: '2', next: '2026-03-10T06:30:00.000Z' },
{ frequency: 'weekly', interval: '2', next: '2026-03-16T06:30:00.000Z' },
{ frequency: 'monthly', interval: '1', next: '2026-03-09T06:30:00.000Z' },
] as const)('keeps the current $frequency period when a clock falls in a gap', ({ frequency, interval, next }) => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-03-08T16:00:00.000Z'))
const { rrule: _rrule, kind: _kind, ...common } = task
const record: ScheduleCatalogEntry = { ...common, ...createDailyScheduleRecord(createScheduleId(task.id), task.prompt,
{ time: '09:00:00', time_zone: 'America/New_York' }, Date.now(), task.title) }
const draft = { ...taskDraft(record), frequency, interval, dailyTime: '02:30', start: '2026-03-09T02:30:00',
weekdays: [1, 5], monthDay: 9 }
const changes = taskDraftPatch(draft, record, true)
expect(taskDraftIssue(draft, changes)).toBeUndefined()
if (changes.timing?.kind !== 'rrule') throw new Error('expected a guided rule')
expect(createRRuleScheduleRecord(createScheduleId(task.id), task.title, task.prompt, changes.timing.rrule, Date.now()).scheduledAt)
.toBe(next)
})
it.each([
{ at: '2026-11-01T05:30:00.000Z', now: '2026-11-01T05:15:00.000Z', system: 'America/New_York',
zone: 'America/New_York', start: '2026-11-01T01:30:00', next: '2026-11-01T05:40:00.000Z' },
{ at: '2026-11-01T06:30:00.125Z', now: '2026-11-01T06:15:00.000Z', system: 'America/New_York',
zone: 'UTC', start: '2026-11-01T06:30:00', next: '2026-11-01T06:40:00.000Z' },
{ at: '0999-01-05T09:30:00.125Z', now: '0998-01-01T00:00:00.000Z', system: 'UTC',
zone: 'UTC', start: '0999-01-05T09:30:00', next: '0999-01-05T09:40:00.000Z' },
{ at: '9999-12-31T23:30:00.125Z', now: '2026-11-01T06:15:00.000Z', system: 'Asia/Shanghai',
zone: 'UTC', start: '9999-12-31T23:30:00', next: '9999-12-31T23:40:00.000Z' },
])('keeps an unambiguous editable one-shot clock for $at', ({ at, now, system, zone, start, next }) => {
const options = new Intl.DateTimeFormat().resolvedOptions()
const systemZone = vi.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({ ...options, timeZone: system })
vi.spyOn(Date, 'now').mockReturnValue(Date.parse(now))
const { rrule: _rrule, kind: _kind, ...common } = task
const record: ScheduleCatalogEntry = { ...common,
...createAtScheduleRecord(createScheduleId(task.id), task.prompt, at, Date.now(), task.title) }
const initial = taskDraft(record)
expect(initial).toMatchObject({ frequency: 'once', timeZone: zone, start })
expect(taskDraftPatch({ ...initial, title: 'Renamed' }, record)).toEqual({ title: 'Renamed' })
const draft = { ...initial, start: `${initial.start.slice(0, 14)}40:00` }
const changes = taskDraftPatch(draft, record, true)
if (changes.timing?.kind !== 'rrule') throw new Error('expected a guided rule')
systemZone.mockRestore()
expect(createRRuleScheduleRecord(createScheduleId(task.id), task.title, task.prompt, changes.timing.rrule, Date.now()).scheduledAt)
.toBe(next)
})
it.each([
{ now: '9999-12-31T23:59:59.999Z', interval: '1' },
{ now: '2026-03-08T16:00:00.000Z', interval: String(Number.MAX_SAFE_INTEGER) },
])('keeps an exhausted calendar rule valid for the Host at $now', ({ now, interval }) => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse(now))
const { rrule: _rrule, kind: _kind, ...common } = task
const record: ScheduleCatalogEntry = { ...common, kind: 'daily', time: '09:00:00.000', timeZone: 'UTC',
scheduledAt: '9999-12-31T09:00:00.000Z', status: 'ended' }
const draft = { ...taskDraft(record), dailyTime: '10:00', interval }
const changes = taskDraftPatch(draft, record, true)
expect(taskDraftIssue(draft, changes)).toBeUndefined()
if (changes.timing?.kind !== 'rrule') throw new Error('expected a guided rule')
const parsed = RRule.parse(changes.timing.rrule)
if (parsed.kind !== 'accepted') throw new Error('expected a parsed rule')
expect(parsed.rule.next(Date.now())).toBeUndefined()
})
it.each([
{ patch: { title: '' }, issue: 'incomplete' },
{ patch: { prompt: '' }, issue: 'incomplete' },
{ patch: { target: 'session', sessionId: '' }, issue: 'incomplete' },
{ patch: { title: 'x'.repeat(121) }, issue: 'rule.invalidTitle' },
{ patch: { interval: '0' }, issue: 'rule.invalidInterval' },
{ patch: { interval: '9007199254740992' }, issue: 'rule.invalidInterval' },
{ patch: { weekdays: [] }, issue: 'rule.requiredWeekday' },
{ patch: { frequency: 'monthly', monthDay: 32 }, issue: 'rule.invalidMonthDay' },
{ patch: { frequency: 'daily', dailyTime: '25:00' }, issue: 'rule.invalidTime' },
{ patch: { frequency: 'once', start: '2099-02-30T09:00:00' }, issue: 'rule.invalidTime' },
{ patch: { frequency: 'advanced', rrule: 'not a rule' }, issue: 'rule.invalid' },
{ patch: { frequency: 'advanced', rrule: '* * * * *', timeZone: 'Invalid/Zone' }, issue: 'rule.invalidZone' },
{ patch: { frequency: 'advanced', rrule: 'DTSTART;TZID=UTC:20990101T090000\nRRULE:FREQ=SECONDLY' }, issue: 'rule.tooComplex' },
] satisfies { patch: Partial<ReturnType<typeof taskDraft>>; issue: string }[])(
'identifies the correction $issue without writing the draft', ({ patch, issue }) => {
expect(taskDraftIssue({ ...taskDraft(task), ...patch })).toBe(issue)
},
)
it.each([en, zh])('explains an empty weekday selection and removes the message after correction', (dictionary) => {
const { rrule: _rrule, kind: _kind, ...common } = task
const record: ScheduleCatalogEntry = { ...common, kind: 'weekly', time: '09:00:00.000', timeZone: 'UTC', weekdays: [1, 3] }
mount([record], { t: makeTranslate(dictionary) })
const detail = openEdit()
const dayButton = (day: 1 | 3): HTMLElement => within(detail).getByRole('button', {
name: makeTranslate(dictionary)('rule.weekdayOption', { weekday: dictionary[`frequency.weekday.${day}`] }),
})
for (const day of [1, 3] as const) fireEvent.click(dayButton(day))
expect(RRule.parse(rruleFromDraft({ ...taskDraft(record), weekdays: [] }, true)).kind).toBe('rejected')
expect(within(detail).getByRole('status').textContent).toBe(dictionary['rule.requiredWeekday'])
expect(within(detail).getByRole('button', { name: dictionary['rule.save'] }).hasAttribute('disabled')).toBe(true)
fireEvent.click(dayButton(1))
expect(within(detail).queryByRole('status')).toBeNull()
expect(within(detail).getByRole('button', { name: dictionary['rule.save'] }).hasAttribute('disabled')).toBe(false)
})
it.each([
{ timing: { kind: 'after', afterSeconds: 60 }, repeat: 'rule.once', control: 'timing.date', value: '2099/01/05' },
{ timing: { kind: 'at' }, repeat: 'rule.once', control: 'timing.date', value: '2099/01/05' },
{ timing: { kind: 'after', afterSeconds: 60, scheduledAt: '2099-01-05T09:00:00.125Z' },
repeat: 'rule.once', control: 'timing.time', value: '09:00:00' },
{ timing: { kind: 'at', scheduledAt: '2099-01-05T09:00:00.125Z' },
repeat: 'rule.once', control: 'timing.time', value: '09:00:00' },
{ timing: { kind: 'every', everySeconds: 300 }, repeat: 'rule.everyMinutes', control: 'host.interval', value: '5' },
{ timing: { kind: 'every', everySeconds: 7200 }, repeat: 'rule.everyHours', control: 'host.interval', value: '2' },
{ timing: { kind: 'every', everySeconds: 300, scheduledAt: '2099-01-05T09:00:00.125Z' },
repeat: 'rule.everyMinutes', control: 'host.interval', value: '5' },
{ timing: { kind: 'daily', time: '09:35:00.000', timeZone: 'Asia/Tokyo' },
repeat: 'rule.daily', control: 'timing.time', value: '09:35:00' },
{ timing: { kind: 'weekly', time: '09:35:45.000', timeZone: 'Asia/Tokyo', weekdays: [1, 5] },
repeat: 'rule.weekly', control: 'timing.time', value: '09:35:45' },
] satisfies { timing: Partial<ScheduleRecord>; repeat: keyof typeof en; control: keyof typeof en; value: string }[])(
'opens stored $timing.kind timing controls without changing the repeat choice', async ({ timing, repeat, control, value }) => {
pinSystemZone('UTC')
const { rrule: _rrule, kind: _kind, ...common } = task
const h = mount([{ ...common, ...timing }])
const detail = openEdit()
expect(within(detail).getByRole('button', { name: `${en['rule.repeat']} ${en[repeat]}` })).toBeDefined()
const field = within(detail).getByRole<HTMLInputElement>(control === 'host.interval' ? 'spinbutton' : 'button',
{ name: en[control] })
expect(control === 'host.interval' ? field.value : field.textContent).toBe(value)
if (timing.kind === 'weekly') {
expect([en['frequency.weekday.1'], en['frequency.weekday.2'], en['frequency.weekday.5']]
.map(day => weekday(day).getAttribute('aria-pressed'))).toEqual(['true', 'false', 'true'])
}
expect(within(detail).getByRole('button', { name: en['rule.save'] }).hasAttribute('disabled')).toBe(true)
fireEvent.change(within(detail).getByRole('textbox', { name: en['detail.name'] }), { target: { value: 'Renamed' } })
await save(detail)
expect(h.props.hostActions.edit).toHaveBeenCalledExactlyOnceWith({ id: task.id, action: 'update',
expectedUpdatedAt: task.updatedAt, patch: { title: 'Renamed' } })
},
)
it.each([
{ kind: 'every', everySeconds: 90 },
{ kind: 'daily', time: '09:00:30.000', timeZone: 'Asia/Tokyo' },
{ kind: 'daily', time: '09:00:00.125', timeZone: 'Asia/Tokyo' },
{ kind: 'weekly', time: '09:00:00.125', timeZone: 'Asia/Tokyo', weekdays: [1, 5] },
] satisfies Partial<ScheduleRecord>[])('keeps unrepresentable $kind precision in Custom', (timing) => {
const { rrule: _rrule, kind: _kind, ...common } = task
const record = { ...common, ...timing }
const draft = taskDraft(record)
expect(draft.frequency).toBe('advanced')
expect(draft.retainedTiming).toBeDefined()
expect(taskDraftIssue(draft)).toBeUndefined()
expect(taskDraftPatch({ ...draft, title: 'Renamed' }, record)).toEqual({ title: 'Renamed' })
})
it('edits stored weekly weekdays directly while keeping the original clock and zone', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2099-01-04T22:00:00.000Z'))
const { rrule: _rrule, kind: _kind, ...common } = task
const h = mount([{ ...common, kind: 'weekly', time: '09:35:45.000', timeZone: 'Asia/Tokyo', weekdays: [5],
scheduledAt: '2099-01-09T00:35:45.000Z' }])
const detail = openEdit()
fireEvent.click(weekday(en['frequency.weekday.1']))
await save(detail)
expectSavedRule(h, 'DTSTART;TZID=Asia/Tokyo:20990105T093545\nRRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,FR')
})
it('allows an edited native daily clock to run today before its previously committed next occurrence', async () => {
vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2099-01-05T00:00:00.000Z'))
const { rrule: _rrule, kind: _kind, ...common } = task
const record: ScheduleCatalogEntry = { ...common, kind: 'daily', time: '08:00:00.000', timeZone: 'Asia/Tokyo',
scheduledAt: '2099-01-05T23:00:00.000Z' }
const draft = { ...taskDraft(record), dailyTime: '10:00' }
expect(taskDraftPatch(draft, record, true)).toEqual({
timing: { kind: 'rrule', rrule: 'DTSTART;TZID=Asia/Tokyo:20990105T100000\nRRULE:FREQ=DAILY;INTERVAL=1;BYHOUR=10;BYMINUTE=0' },
})
})
it('uses one clock reading when saving a stored interval across midnight', () => {
const { rrule: _rrule, kind: _kind, ...common } = task
const draft = taskDraft({ ...common, kind: 'every', everySeconds: 300 })
vi.spyOn(Date, 'now').mockReturnValueOnce(Date.parse('2099-01-05T23:59:59.999Z'))
.mockReturnValue(Date.parse('2099-01-06T00:00:00.000Z'))
expect(rruleFromDraft(draft, true)).toBe('DTSTART;TZID=UTC:20990105T235900\nRRULE:FREQ=MINUTELY;INTERVAL=5')
})
it('keeps the stored clock and calendar fields when changing a one-shot task to another repeat preset', () => {
pinSystemZone('UTC')
const { rrule: _rrule, kind: _kind, ...common } = task
mount([{ ...common, kind: 'at', scheduledAt: '2099-01-06T09:35:45.000Z' }])
const detail = openEdit()
chooseRepeat(detail, en['rule.daily'])
expect(within(detail).getByRole('button', { name: en['timing.time'] }).textContent).toBe('09:35:00')
chooseRepeat(detail, en['host.frequency.monthly'])
expect(within(detail).getByRole<HTMLInputElement>('spinbutton', { name: en['host.monthDay'] }).value).toBe('6')
chooseRepeat(detail, en['rule.weekly'])
expect(weekday(en['frequency.weekday.2']).getAttribute('aria-pressed')).toBe('true')
expect(within(detail).getByRole('button', { name: en['timing.time'] }).textContent).toBe('09:35:45')
})
it('does not write an unchanged row', async () => {
const h = mount()
const detail = openEdit()
@@ -170,6 +170,20 @@ function boundTo(id: ScheduleId): TaskTabBindings {
}
describe.each([['English', en], ['Chinese', zh]] as const)('task tab body in %s', (_name, dictionary) => {
it('opens stored weekly controls immediately and restores them after viewing records', () => {
const { rrule: _rrule, kind: _kind, ...common } = at
mount({ records: [{ ...common, kind: 'weekly', time: '09:30:00.000', timeZone: 'UTC', weekdays: [1, 5] }] },
{ sessionId: SESSION, id: at.id }, dictionary)
const detail = screen.getByRole('complementary', { name: dictionary['detail.label'] })
const days = (): (string | null)[] => within(within(detail).getByRole('group', { name: dictionary['rule.weekday'] }))
.getAllByRole('button').map(button => button.getAttribute('aria-pressed'))
expect(days()).toEqual(['true', 'false', 'false', 'false', 'true', 'false', 'false'])
fireEvent.click(within(detail).getByRole('tab', { name: dictionary['detail.records'] }))
fireEvent.click(within(detail).getByRole('tab', { name: dictionary['detail.rule'] }))
expect(days()).toEqual(['true', 'false', 'false', 'false', 'true', 'false', 'false'])
expect(within(detail).getByRole('button', { name: dictionary['rule.save'] }).hasAttribute('disabled')).toBe(true)
})
it('renders the named task rule, its actions, and its target Session entry without a close control', () => {
const h = mount({}, { sessionId: SESSION, id: at.id }, dictionary)
const detail = screen.getByRole('complementary', { name: dictionary['detail.label'] })