Sync current schedule integration into Team UI layer
This commit is contained in:
commit
997fc4b05e
251 files changed
+19848
-19104
No files matched your search
@@ -3,17 +3,17 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md
|
||||
/agent-note-host-owned-scheduled-messages:
|
||||
en: 11854b220af809ea
|
||||
zh: 0f0575de37107ed8
|
||||
en: b3b0dde47f58ca48
|
||||
zh: d4ba3a6cf1228578
|
||||
/agent-note-host-owned-scheduled-messages/problem:
|
||||
en: 2c774d05ae75784f
|
||||
zh: 619ba7f281b13233
|
||||
/agent-note-host-owned-scheduled-messages/decision:
|
||||
en: 1210b3975fd26bb3
|
||||
zh: 3322e6d0aa63fdb0
|
||||
en: 20dcfc8e9c44cde7
|
||||
zh: d9609e41eb4e9bd9
|
||||
/agent-note-host-owned-scheduled-messages/alternatives-considered:
|
||||
en: 21d7ec9a9ff3cbf2
|
||||
zh: 93d03b8b3012aa7e
|
||||
/agent-note-host-owned-scheduled-messages/consequences:
|
||||
en: 3ae0cd93ed95431e
|
||||
zh: 25a0a2bc25863651
|
||||
en: ce4ef02663cd485c
|
||||
zh: ad8ae567c3bab8e0
|
||||
@@ -4,36 +4,20 @@ Status: implemented
|
||||
|
||||
English | [中文](2026-09-16-host-schedule-storage.zh.md)
|
||||
|
||||
This note remains current for Host authority, whole-unit durability, and saved inbox receipts. [Typed rule storage](2026-10-21-schedule-rule-storage.md) and [Session targets](2026-09-17-standalone-schedules.md) replace its original management and target contracts.
|
||||
|
||||
## Problem
|
||||
|
||||
Scheduled messages must remain discoverable after restart without loading every Session. A timer attached to a live Agent cannot dispatch a task whose Session remains cold. Users also need to inspect pending and delivered tasks without activating their conversation; removing a task after delivery loses that management record.
|
||||
|
||||
## Decision
|
||||
|
||||
The existing Schedule package owns one Host storage domain containing each task's original Session id, reminder record, `active` or `inactive` status, latest delivery receipt, and saved delivery history. The domain is authoritative for retained tasks. The Host scheduler restores timers only for active tasks; a due task uses the Session controller's existing resolution operation and queues an ordinary follow-up in the original Session. Querying and deleting tasks do not resolve an Agent. Stored records lacking status normalize to `active` without scanning or recreating historical Sessions.
|
||||
The original `schedule` domain and whole-unit layout remain authoritative; v2 accepts v1. [Typed rule storage](2026-10-21-schedule-rule-storage.md) supersedes the original Session-owned management API, top-level binding writes, and two-state lifecycle; [Session targets](2026-09-17-standalone-schedules.md) owns Host-wide management and delivery targets. The seven normal timing kinds preserve committed targets. An explicit target wins; absent targets read the old Session binding or reserve a stable new-once identity. No startup migration runs.
|
||||
|
||||
The task domain declares the whole-unit layout. Tasks are authoritative, so the JSON backend rejects an unreadable file, malformed document, unsupported version, or invalid task as a complete load failure instead of interpreting it as deletion. Failed recovery leaves `schedule.json` unchanged, while an initially absent file opens as an empty domain; repairing the file preserves task identity. `Service.init` waits for both storage validation and runtime setup, management methods wait for the same initialization result, and an unloading owner releases late-acquired storage without starting dispatch. Dispatch admission is separate from initialization: an ancestor can close Agent admission after a task mutation commits but before runtime cleanup starts. That refusal is logged without automatic retry and does not turn the persisted mutation into a failed management result. Invalid storage and failed cleanup registration still reject startup.
|
||||
|
||||
This replaces the earlier Session-log authority, live-only activation, and read-only catalog decisions. The [Schedule reference](../../../../packages/schedule/schedule/README.md) owns explicit absolute-time validation, fixed-rate arithmetic, and the distinction between queue admission and model completion. Historical `schedule/change` creation and fold types remain restricted to After, At, and Every through `LegacyScheduleRecord`; a separate Host decoder accepts the current `ScheduleRecord`, including Daily, Weekly, and Cron. The Host record decoder requires a stored `title` that is non-empty after trimming, already trimmed, and at most 120 characters, and the strict task schema rejects the whole domain open instead of dropping such a task. The historical change decoder tolerates an absent `title` so an already-written Session log stays readable, and it applies the same three checks when the member is present. The storage domain remains version 1. Historical events do not populate that domain, and existing `at` tasks are not converted into daily or weekly rules. Loading a Session with active legacy reminders warns that they must be recreated.
|
||||
|
||||
A plugin-sourced `followup()` synchronously appends the message to the Session inbox. Successful Session flush acknowledges durable delivery. One task-record put stores `lastDelivery`, appends a delivery record with the occurrence target, acknowledgment time, actual message id, and instruction snapshot, and updates the one-shot's `inactive` status or the recurring task's next target and status. Task state and its saved history therefore share one commit. Inactive tasks remain stored and never schedule another delivery; explicit deletion stops future delivery and removes the row together with its saved records. These records acknowledge inbox delivery, not user receipt or model execution completion.
|
||||
|
||||
New tasks initialize an empty `deliveryHistory`. A task without that optional field exposes its existing latest receipt, if any, and marks earlier records unavailable. Reading never rewrites the old task or invents the lost history or instruction snapshot. Its next acknowledged delivery preserves that available receipt and appends the new one. Strict decoding rejects duplicate message ids and disagreement between the latest receipt and the last history entry. History follows append order, not wall-clock sorting, so clock rollback does not reverse acknowledged deliveries.
|
||||
|
||||
A recurring task contributes its latest due occurrence once, including after Host restart. Every remains creation-aligned until an interval edit establishes a new anchor; Daily retains a local time and explicit IANA zone, and Weekly adds a non-empty ISO weekday set stored as unique ascending numbers. Every, Daily, Weekly, and Cron use `renderRecurringReminderBatchFraming` and share one follow-up message and its real message id when due for the same Session. The fixed text both renderers emit is the [delivered-reminder origin line](2026-09-24-scheduled-message-framing.md) decision. A recurring task with no representable next target remains inactive with its last receipt. A stopped Host does not send messages.
|
||||
|
||||
Daily is a fourth mutually exclusive creation selector, Weekly a fifth, and Cron a sixth, none a conversion of `every_seconds` or `at`. Weekly selects every local date whose ISO weekday belongs to its stored set; the decoder rejects an unnormalized stored set, and the selector rejects an empty, repeated, out-of-range, or non-integer one. Their first targets are strictly future. Missing local times and dates are skipped; overlaps select the earlier instant once per date. The next target is after the delivery decision and on a local date later than the selected occurrence's date. Only UTC targets are restricted to years 0001–9999; local dates may cross into year 0 or 10000 at the edges. After Session restoration, the runtime rechecks due members against the current clock so a rollback does not admit future tasks.
|
||||
|
||||
The maintained `@js-temporal/polyfill` 0.5.1 resolves local dates and overlap/gap behavior using the Host's IANA data; Schedule does not own offset-sampling logic or add another package layer. Each daily or weekly record stores normalized `HH:mm:ss.SSS` time, its zone, its weekday set where applicable, and a committed next UTC instant. Decoding and restart do not recompute that instant. Valid zone aliases remain readable across canonical-name changes, while subsequent targets use current Host time-zone data.
|
||||
|
||||
Model `schedule_list`, Remote `list`, and the Session header's right-aligned reminder clock show only active tasks. The independent Automation tasks page opens from the sidebar and reads active and inactive Host tasks through the read-only Remote `catalog` method, without activating Sessions or reading their history. That clock is absent while the Session has no active reminder, opens a single task's detail directly when exactly one remains, and otherwise lists them so one can be chosen; both paths open one right-Sidebar task tab that renders the same detail component the Automation tasks page uses, so neither path selects the main panel. The tab names its task and original Session through its navigation parameters and carries that binding into every mutation, so opening or deleting from it never activates a Session. The page has one All, Enabled, and Inactive status filter row, retained inactive details, latest delivery metadata, and an original-Session control that sits in the detail tab strip beside the task actions. Its header's New action starts a new Session, where the reminder is created by asking the model; the page offers no creation form. Navigation waits for Session and archive metadata, explains unavailable targets, and never restores an archived Session implicitly. Task retention remains independent of original-Session availability. Every, Daily, Weekly, and Cron are recurring rules; the page groups tasks only by status. Daily and Weekly rule labels retain the saved local time, exact rule zone, and stored weekday set rather than substituting the browser zone used for next-target display. Explicit confirmed deletion reuses `delete` with the entry's original Session binding, requiring that the supplied binding match the stored task. This argument check is not independent caller authorization: model tools capture the current Agent's Session, while the global user interface selects the task's original binding. Cross-Session model modification policy remains a product decision, not a consequence of the storage layout. A payload-free change notification invalidates queries, and reconnect queries recover missed notifications. Deletion prevents subsequent dispatch but does not retract a queued message.
|
||||
|
||||
The second detail level separates Rules from Delivery records. Opening records requests `history` with the task's original Session binding and an explicit page limit of 1–100. An exclusive message-id cursor reads older receipts without changing position when new deliveries append. Catalogs retain only latest-receipt metadata rather than duplicating full histories in every task-list response. Missing tasks and unknown cursors are distinct failures, not empty successful history. The records view loads lazily, rejects stale responses, and keeps original-Session navigation separate from inspection.
|
||||
|
||||
Name, instruction, and timing updates share the dispatch and deletion FIFO. Comparing the complete observed record inside that queue prevents a stale draft from overwriting a saved rule or a target advanced by delivery. This avoids adding a persisted revision counter, at the cost of rejecting drafts after normal delivery changes the target. One task put replaces the rule, name, or instruction, preserving identity, original Session binding, status, latest receipt, and saved history. A request may omit the timing change, the name, or the instruction; an omitted field keeps its stored value, and a name or instruction change alone leaves the committed target untouched. Normalized no-ops leave the committed target and stored bytes unchanged. Changed one-shots retain their identity as At records; changed fixed intervals use a new accepted-save anchor; changed wall-clock rules re-resolve their first future target from the same accepted-save sample, and an equivalent normalized rule stays a no-op. A timing change may select a different recurrence kind, and the new rule computes its target exactly as creation would from that accepted-save sample. Deletion removes the row together with its saved records ([consequences of that reversal](2026-09-22-schedule-hard-delete-and-bounded-history.md)), so the runtime never schedules the task again and `history` no longer resolves for its original binding. Inactive tasks are not reopened.
|
||||
|
||||
The page uses the existing Host task domain and scheduler, not a mock engine or a Session projection. The name, the instruction, and the run-time rows edit one local draft for the shown task, and one Save commits that draft as a single update carrying the complete expected record; Cancel restores the stored values, a failed save retains the draft, an unsaved draft survives an authoritative refresh, and a committed save and a failed catalog refresh remain separate outcomes. Pause and execution-history operations remain unsupported. Lifecycle status and latest delivery acknowledgment do not describe model results.
|
||||
|
||||
The client presentation derives from existing persisted data instead of widening a persisted format. The `schedule_create` transcript card reads the created task out of the call's model-visible result JSON, which already serializes the canonical task value, rather than declaring `presentationMeta`: a persisted metadata channel would add a durable field for a card that Session-log replay reconstructs from the result alone, and previously recorded Sessions render the card without a format change. The Sidebar Session-row clock mark occupies the root-scoped `sidebar.session.row.leading` seat rather than requesting a per-Session provider, because a Session-scoped seat would bind every listed Session and thereby activate and retain it; the same root-scoped owner share feeds `sidebar.session.row.hover`, and both read the shared Host catalog snapshot `schedule/catalog()` without reading a Session log.
|
||||
|
||||
Session inbox persistence and the task put occur in separate persistence systems. Inbox persistence precedes the task put to avoid marking delivery before its message exists. A crash or task-write failure after inbox persistence can repeat a message; storing status, target, and receipt together does not make the two stores atomic. Dispatch failures retain the task and produce a diagnostic; there is no independent retry timer. This feature does not create or replace a Goal.
|
||||
@@ -56,10 +40,4 @@ Session inbox persistence and the task put occur in separate persistence systems
|
||||
|
||||
## Consequences
|
||||
|
||||
**Keep the mock's hourly choice out of the stored vocabulary.** The approved mock lists 每小时整点 beside the daily, weekly and one-shot choices. Hourly and fixed-rate intervals are the same elapsed-time rule, so the card maps that menu slot onto the existing interval kind instead of adding a persisted kind. A dedicated hourly kind would need a new durable variant, its decoder, its target resolution, and its tool schema, all to express what an interval already expresses.
|
||||
|
||||
Task discovery and timers do not depend on loaded Sessions. The after, absolute-time, and fixed-rate selectors retain their semantics alongside Daily, Weekly, and Cron; reading existing saved tasks does not rewrite them. The Host must have its ordinary Session persistence and recovery services available, and the shipped Web composition mounts the Schedule service for the whole deployment. Explicit deletion stops future delivery and removes the task from `list` and `catalog` without deleting conversation messages; the removed row takes its saved receipts with it. History is pruned to the configured window and record cap when an acknowledgment is appended, loaded into Host memory, and rewritten with the task on each acknowledgment. Pagination limits the returned record count; the plugin configuration (`deliveryHistoryDays`, default 30, and `deliveryHistoryRecords`, default 200) limits storage growth. Earlier overwritten receipts and deliveries whose task write failed cannot be reconstructed. Pause and execution-history operations remain unsupported.
|
||||
|
||||
Required coverage includes startup from stored tasks, missing-status normalization, cold Session restoration, latest-only catch-up, inactive-task retention without repeat dispatch, real message receipts, deletion across restart, removal of a deleted task's saved deliveries, queue/persistence failures, and disposal. Wall-clock obligations include gaps and skipped dates, earlier-only overlaps, fractional seconds, UTC-range edges, committed-target and alias preservation, and post-restoration clock rollback. Weekly adds weekday normalization and validation, next-target selection across a gap and an overlap, and stored set preservation. Historical decoders must reject Daily, Weekly, and Cron while Host storage accepts them. Client coverage includes every recurring kind, exact rule time, zone, and weekday set, All/Enabled/Inactive status filters, retained inactive details and original-Session navigation, authoritative refresh after confirmed deletion and notification, empty lists, reconnect, and late responses. Presentation coverage includes the page's New action starting a Session, the transcript card's narrowing of persisted result JSON including an incomplete or replayed result, the header clock's direct open for exactly one task and its count label, the idle-only row mark with the hover-card two-task limit and omission line. History coverage includes legacy byte preservation, append and restart, immutable sent instructions, strict latest-receipt consistency, stable cursor pagination, binding checks, failed persistence, and late page replies during refresh or task changes. Cross-store crash duplication remains an explicit limitation rather than a completion claim.
|
||||
|
||||
Name, instruction, and timing coverage includes all supported rule kinds, complete-record conflicts, delivery/save ordering, cancellation, normalized no-ops, unchanged-history persistence, pending controls, draft retention, and collision-free task identity. The [built CLI process test](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) drives public Remote requests and reloads actual task-file bytes in a fresh Host. Its task and receipt seeds establish retained-data inputs, not real creation, delivery, or recorded-model evidence; those require ordinary Session operations and captured model requests.
|
||||
Task discovery and timers do not require loaded Sessions. History remains in the same task row and shares its acknowledgment commit; reads never invent missing prompts, timestamps, or earlier deliveries. Historical Session-log After, At, and Every remain frozen and do not populate this domain. The [Schedule reference](../../../../docs/subsystems/schedule.md) owns timing, management, UI projection, and retention contracts. The [built CLI test](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) checks raw old rows, edit-time normalization, conflicts, and cold restart through public Remote requests; seeded receipts are retained-data evidence rather than delivery evidence.
|
||||
@@ -4,36 +4,20 @@ Status: implemented
|
||||
|
||||
[English](2026-09-16-host-schedule-storage.md) | 中文
|
||||
|
||||
本 note 仍拥有宿主权威来源、整单元持久性和已保存收件箱回执。[带类型规则存储](2026-10-21-schedule-rule-storage.zh.md)和 [Session 目标](2026-09-17-standalone-schedules.zh.md)替代原管理与目标契约。
|
||||
|
||||
## Problem
|
||||
|
||||
定时消息必须能在重启后被发现,无须加载每个 Session。挂在 live Agent 上的 timer 无法投递目标 Session 仍为 cold 的任务。用户还需要在不激活对话的情况下查看待投递和已投递任务;投递后删除任务会丢失这条管理记录。
|
||||
|
||||
## Decision
|
||||
|
||||
现有 Schedule 包拥有一个 Host storage domain,保存每个任务的原始 Session id、提醒记录、`active` 或 `inactive` 状态,最近一次投递回执,以及已保存的投递历史。该 domain 是保留任务的权威来源。Host scheduler 仅为活动任务恢复 timer;到期任务使用 Session controller 现有的解析操作,在原 Session 中排入普通 follow-up。查询和删除任务不会解析 Agent。已存储但缺少状态的记录规范化为 `active`,不扫描或重建历史 Session。
|
||||
原有 `schedule` 领域和整单元布局仍是权威来源;v2 接受 v1。[带类型规则存储](2026-10-21-schedule-rule-storage.zh.md)替代原按 Session 所有的管理 API、顶层绑定写入和两状态生命周期;[Session 目标](2026-09-17-standalone-schedules.zh.md)拥有宿主管理范围与执行目标。七种正常时间类型保留已提交发生时点。显式目标优先;缺少目标时读取旧 Session 绑定或预留稳定的首次新建后复用身份。不运行启动迁移。
|
||||
|
||||
任务 domain 声明整 unit 布局。任务是权威数据,因此 JSON 后端会把文件不可读、文档损坏、版本不受支持或任务非法作为整批加载失败,而不是解释为删除。恢复失败不会改写 `schedule.json`,初始不存在的文件则作为空 domain 打开;修复该文件后会保留任务身份。`Service.init` 等待存储校验和运行时初始化,管理方法等待同一初始化结果;拥有方卸载时释放迟到的存储资源,而不开始投递。投递调度准入与初始化分开:任务变更提交后、运行时清理开始前,祖先可能关闭 Agent 准入。此类拒绝会记录日志而不自动重试,不会将已持久化的变更变成失败的管理结果。存储非法和清理注册失败仍拒绝启动。
|
||||
|
||||
本决策替代先前的 Session 日志权威来源、仅 live 时激活和只读目录决策。[Schedule 参考](../../../../packages/schedule/schedule/README.zh.md)负责显式绝对时间校验、固定速率计算,以及区分排队接纳与模型完成。历史 `schedule/change` 的创建与 fold 类型通过 `LegacyScheduleRecord` 保持为 After、At 和 Every;独立的宿主解码器接受包含 Daily、Weekly 与 Cron 的当前 `ScheduleRecord`。宿主记录解码器要求已存储的 `title` 去除首尾空白后非空、本身已去除首尾空白且不超过 120 个字符,严格的 task schema 会拒绝整个 domain 的打开,而不是丢弃这样的任务。历史变更解码器容忍缺失的 `title`,以便已写入的 Session 日志仍可读取;该成员存在时,同样按这三项检查校验。storage domain 保持版本 1。历史事件不会填充该 domain,已有 `at` 任务也不会转换为每日或每周规则。加载含有活动旧提醒的 Session 时,会警告需要重新创建提醒。
|
||||
|
||||
插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后确认持久投递。一次任务记录 put 存储 `lastDelivery`,追加包含发生目标、确认时间、真实消息 id 和指令快照的发送记录,并更新一次性任务的 `inactive` 状态或重复任务的下一目标和状态。因此,任务状态与其保存的历史共用一次提交。未运行任务仍保留在存储中,且不会再次安排投递;显式删除会阻止后续投递,并把该行连同其已保存记录一并移除。这些记录确认收件箱投递,不表示用户收到消息或模型执行完成。
|
||||
|
||||
新任务初始化空的 `deliveryHistory`。缺少该可选字段的任务会展示已有的最近一次回执(如果存在),并标明更早记录不可用。读取不会重写旧任务,也不会虚构已丢失的历史或指令快照。下一次确认投递时,保留已有回执并追加新记录。严格解码拒绝重复消息 id,以及最近一次回执与历史末项不一致的情况。历史按追加顺序排列,不按墙钟排序,因此时钟回拨不会颠倒已确认投递的顺序。
|
||||
|
||||
重复任务只投递一次最新到期发生时点,Host 重启后也一样。Every 保持与创建时间对齐,直到修改间隔后建立新的起算时点;Daily 保留本地时间与显式 IANA 时区,Weekly 另存非空 ISO 星期集合(唯一且升序)。Every、Daily、Weekly 与 Cron 均使用 `renderRecurringReminderBatchFraming`,同一 Session 的到期任务共用一条 follow-up 消息及其真实消息 id。两个渲染器输出的固定文本见[投递提醒的来源行](2026-09-24-scheduled-message-framing.zh.md)决策。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 Host 不会发送消息。
|
||||
|
||||
Daily 是互斥的第四个创建选择器,Weekly 是第五个,Cron 是第六个,三者都不是对 `every_seconds` 或 `at` 的转换。Weekly 选择本地日期 ISO 星期属于其所存集合的每个日期;解码器拒绝未规范化的已存集合,选择器拒绝空、重复、超出范围或非整数的集合。它们的首个目标严格晚于当前时间。缺失的本地时间或日期被跳过;重叠时间选择较早时点,每个日期一次。下一目标晚于投递判断时刻,且其本地日期晚于所选发生时点的日期。仅 UTC 目标限制在 0001–9999 年;边缘处的本地日期可以进入第 0 年或第 10000 年。Session 恢复后,运行时依据当前时钟重新核对到期成员,避免时钟回拨时接纳未来任务。
|
||||
|
||||
受维护的 `@js-temporal/polyfill` 0.5.1 使用宿主 IANA 数据解析本地日期及重叠或缺口;Schedule 不拥有偏移量采样逻辑,也不增加包层级。每日或每周记录保存规范化后的 `HH:mm:ss.SSS` 时间、时区、适用的星期集合及已提交的下一 UTC 时点。解码和重启不会重新计算该时点。规范名称变化后,有效时区别名仍可读取;后续目标使用宿主当前的时区数据。
|
||||
|
||||
模型 `schedule_list`、Remote `list` 和 Session 顶部右侧的提醒时钟仅显示活动任务。独立的自动化任务页面从侧边栏打开,通过只读 Remote `catalog` 方法读取活动和未运行 Host 任务,不激活 Session 或读取其历史。该时钟在该 Session 没有活动提醒时不显示;恰好有一个时直接打开该任务详情,否则列出提醒供选择。两条路径都打开同一个右侧边栏任务页签,该页签渲染自动化任务页面所用的同一详情组件,因此都不会切换主面板。页签通过导航参数指明任务及其原 Session,并把该绑定带入每个修改操作,因此从页签打开或删除都不会激活 Session。页面提供一行“全部/已开启/未运行”状态筛选,保留未运行任务详情、最近一次投递元数据和固定在位于详情页签条内、与任务操作并排的原 Session 入口。页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面不提供创建表单。导航等待 Session 与归档元数据,解释不可用目标,且不会隐式恢复已归档 Session。任务保留与原 Session 可用性相互独立。Every、Daily、Weekly 和 Cron 均为重复规则;页面只按状态分组。Daily 与 Weekly 规则标签保留保存的本地时间、精确规则时区和所存星期集合,不替换为下一目标显示所用的浏览器时区。显式确认删除复用 `delete`,传入条目的原始 Session 绑定,要求传入绑定与已存任务一致。这项参数校验不是独立的调用者鉴权:模型工具捕获当前 Agent 的 Session,全局用户界面则选择任务的原始绑定。跨会话模型修改政策仍是产品决策,不由存储布局直接决定。无载荷的变更通知使查询失效,重新连接时重新查询以恢复漏掉的通知。删除阻止后续投递,但不撤回已排队消息。
|
||||
|
||||
第二层详情将“规则”与“发送记录”分开。打开发送记录时,`history` 请求携带任务的原始 Session 绑定,以及 1–100 的显式分页数量。排他的消息 id 游标用于读取更早回执,新投递追加后不会改变其定位。目录仅保留最近一次回执元数据,不在每次任务列表响应中复制完整历史。任务不存在和游标未知属于独立失败,不是查询成功的空历史。记录视图按需加载,拒绝晚到的过期响应,并将原 Session 导航与查看记录分开。
|
||||
|
||||
名称、指令和时间更新与投递、删除共用 FIFO。在该队列内比较完整的已观察记录,可防止旧草稿覆盖已保存规则或投递已推进的目标。这样无须增加持久版本计数器,代价是正常投递改变目标后也会拒绝旧草稿。一次任务 put 替换规则、名称或指令,保留身份、原始 Session 绑定、状态、最近一次回执和已保存历史。请求可以省略时间变更、名称或指令;省略的字段保留已存储值,仅修改名称或指令不会改变已提交目标。归一化后等价的修改保留已提交目标与存储字节。修改后的一次性任务以 At 记录保留身份;固定间隔改变后以接纳保存时刻重新起算;钟表规则改变后以同一接纳保存时刻重新解析首个未来目标,而归一化后等价的规则不产生变更。时间变更可以选择不同的重复类型,新规则按创建时相同的方式、以同一接纳保存时刻计算目标。删除会移除该行及其已保存记录([该反转的后果](2026-09-22-schedule-hard-delete-and-bounded-history.zh.md)),因此运行时不会再调度该任务,`history` 也不再按其原始绑定解析。未运行任务不会被重新启用。
|
||||
|
||||
页面使用现有 Host 任务 domain 和调度器,不使用 mock 引擎或 Session projection。名称、指令和运行时间行编辑当前任务的同一份本地草稿,一次保存把该草稿作为携带完整预期记录的单个更新提交;取消恢复已存储值,保存失败保留草稿,未保存的草稿会保留到权威刷新之后,保存已提交与目录刷新失败仍是不同结果。暂停和执行历史操作仍不受支持。生命周期状态与最近一次投递确认不描述模型结果。
|
||||
|
||||
客户端的呈现从已有持久化数据派生,而不扩展持久化格式。`schedule_create` transcript(文本记录)卡片从该调用对模型可见的结果 JSON 中读出创建的任务——该 JSON 已经序列化规范任务值——而不是声明 `presentationMeta`:持久化 metadata 通道会为一张仅凭结果即可在 Session 日志回放中重建的卡片增加持久字段,而早于该卡片录制的 Session 无需格式变更即可渲染它。侧栏 Session 行的时钟标记占用 root 作用域的 `sidebar.session.row.leading` slot,而不是请求逐 Session provider,因为 Session 作用域的 slot 会绑定列表中每个 Session,从而激活并保留它;同一个 root 作用域属主约定也供给 `sidebar.session.row.hover`,两者都读取共享的 Host 目录快照 `schedule/catalog()`,不读取 Session 日志。
|
||||
|
||||
Session 收件箱持久化和任务 put 发生在两个持久化系统中。收件箱持久化先于任务 put,以免消息尚不存在时就标记已投递。收件箱持久化后崩溃或任务写入失败可能重复发送消息;状态、目标和回执一起存储,并不使两个存储具有原子性。投递失败保留任务并输出诊断;没有独立的重试 timer。此功能不创建或替换 Goal。
|
||||
@@ -56,10 +40,4 @@ Session 收件箱持久化和任务 put 发生在两个持久化系统中。收
|
||||
|
||||
## Consequences
|
||||
|
||||
**不把 mock 的「每小时整点」引入持久词表。** 设计稿把它与每天、每周、仅一次并列。每小时整点与固定间隔在语义上是同一种按经过时间计量的规则,因此详情里的该项直接映射到既有的间隔类型,而不是新增一个持久种类。若真要新增,需要新的持久变体、解码器、目标解析与工具 schema,而它表达的能力现有间隔已经覆盖。
|
||||
|
||||
任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,而随发行版交付的 Web 组合为整个部署挂载 Schedule 服务。显式删除会阻止后续投递,并将任务从 `list` 和 `catalog` 中移除,不删除对话消息;被移除的行会带走其已保存回执。历史在追加确认时按配置的窗口与条数上限裁剪,随后加载到 Host 内存,并在每次确认时随任务一起重写。分页限制返回记录数量,不限制存储增长或指令字节数。此前被覆盖的回执,以及任务写入失败的投递记录,无法重建。暂停和执行历史操作仍不受支持。
|
||||
|
||||
必要覆盖范围包括从已存储任务启动、缺失状态规范化、冷 Session 恢复、仅补发最新发生时点、保留未运行任务且不重复投递、真实消息回执、重启后的删除结果、删除会移除已保存发送记录、排队或持久化失败,以及销毁。钟表规则的验证要求包括缺口和跳过的日期、仅选择较早时点的重叠、小数秒、UTC 范围边缘、已提交目标和别名保留,以及恢复后的时钟回拨。Weekly 另需覆盖星期集合的规范化与校验、跨缺口与重叠的下一目标选择,以及已存集合的保留。历史解码器必须拒绝 Daily、Weekly 与 Cron,而宿主存储接受它们。客户端覆盖范围包括每种重复规则、精确规则时间、时区和星期集合、全部/已开启/未运行状态筛选、保留未运行任务详情和原 Session 导航、确认删除和通知后的权威刷新、空列表、重新连接和晚到响应。呈现覆盖范围包括页面“新建”操作打开新会话、transcript 卡片从不完整或回放的结果 JSON 中收窄任务、页头时钟在恰好一个任务时直接打开并给出数量标签、仅空闲行标记及其悬浮卡片两个任务上限与遗漏行。历史覆盖范围包括旧文件字节保留、追加与重启、不可变的发送指令、最近回执一致性严格校验、稳定游标分页、绑定校验、持久化失败,以及刷新或切换任务期间晚到的分页响应。跨存储崩溃导致的重复投递仍是明确限制,不能被表述为完成保证。
|
||||
|
||||
名称、指令和时间编辑覆盖范围包括所有支持的规则类型、完整记录冲突、投递与保存的顺序、取消、规范化后的无变更、历史持久化不变、等待期间的控件、草稿保留,以及无碰撞的任务标识。[已构建 CLI 进程测试](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts) 驱动公开 Remote 请求,并在新 Host 中重新加载实际任务文件字节。任务和回执种子只是已保留数据的输入,不是真实创建、投递或模型录制证据;这些证据需要普通 Session 操作与捕获的模型请求。
|
||||
任务发现和定时器无需已加载 Session。历史保留在同一任务行中,与确认一同提交;读取不虚构缺失指令、时间戳或更早投递。历史 Session 日志 After、At、Every 保持冻结,不填充此领域。[Schedule 参考](../../../../docs/subsystems/schedule.zh.md)拥有时间、管理、UI 投影和保留契约。[构建后 CLI 测试](../../../../apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts)通过公开 Remote 请求检查旧原始行、编辑时规范化、冲突和冷重启;种入回执是保留数据证据,不是投递证据。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-17-standalone-schedules.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-17-standalone-schedules.md
|
||||
/agent-note-standalone-schedules-and-session-targets:
|
||||
en: ce8ed282ec843da0
|
||||
zh: a5ca78422ea009ec
|
||||
/agent-note-standalone-schedules-and-session-targets/problem:
|
||||
en: 2a6769cd378455bb
|
||||
zh: 84cfb0f2e1fde8e1
|
||||
/agent-note-standalone-schedules-and-session-targets/decision:
|
||||
en: 73ed15d2219f2898
|
||||
zh: db8ea6ae79a95241
|
||||
/agent-note-standalone-schedules-and-session-targets/alternatives-considered:
|
||||
en: 1def5c19511fc59e
|
||||
zh: 84f0f5d568272c39
|
||||
/agent-note-standalone-schedules-and-session-targets/consequences:
|
||||
en: bda079e488b5374b
|
||||
zh: d5ffe96b33e8e65d
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Standalone schedules and Session targets
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-17-standalone-schedules.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Host tasks need global management and explicit delivery targets while remaining recognizable in their Workspace.
|
||||
|
||||
## Decision
|
||||
|
||||
The storage domain, importer, placeholder RRULE, and parallel API decisions are superseded by [typed rule storage and shared recurrence](2026-10-21-schedule-rule-storage.md). Keep the existing `schedule` domain and one management API; the target, execution, and notice decisions below remain current.
|
||||
|
||||
Targets are `session`, `new_once`, or `new`. `session` stores one existing Session id; create and edit check, without activating that Session, that it exists and does not belong to a delegated child. `new_once` reserves a Session id when the task is created or retargeted, creates that exact Session at first delivery, and then becomes `session`. `new` saves a reserved Session id in `pendingDelivery` before each create attempt, so a retry after a failed delivery uses the same Session. A created Session uses the target's working directory and Agent preset, or the Host defaults. When the target names a working directory, the Session joins the Workspace registered there, if any. While the Session has no title, it takes the task title; a `new` target adds the Session's creation minute in the timing rule's zone, for example `Weekday digest · 2026-10-12 09:00`, so the Sessions of successive deliveries stay distinguishable. A failed join or rename is logged and does not block delivery. `involvedSessionIds` lists the creating and editing Agent Sessions and each existing or reserved target Session in first-involvement order; Sessions that a `new` target creates are not listed.
|
||||
|
||||
The runtime keeps latest-only catch-up. It delivers the latest occurrence at or before the delivery decision and advances to the next future occurrence; an exhausted rule stays stored with `ended` status, and a paused task has no timer. Each delivery of one task, timed or manual, comes at least 60 seconds after the occurrence of the previous delivery, and the occurrence of a manual run is its run time; for a `new` target, whose every delivery creates a Session, a deployment can raise that interval through `newSessionIntervalSeconds`, which defaults to 60. The floor counts from the previous occurrence when the next stored occurrence keeps the interval, and from the previous acknowledgment otherwise, so acknowledgment latency does not accumulate and a late delivery does not shift later occurrences; the next on-time occurrence can then follow the late delivery by less than one interval. A task delivered to a reused Session, `session` or `new_once`, holds its execution slot until the target Agent is idle, and occurrences that become due meanwhile coalesce into one later delivery, so that Session has at most one pending delivery of the task; a `new` target holds no slot, and its launches run beside one another with only the delivery interval between them. Due tasks with a `session` target share one message per target Session, except After and At tasks. Manual run shares target resolution, the delivery interval, Session inbox persistence, the receipt, and the involvement update with timed delivery, but keeps the next occurrence and status. Both refuse an archived destination Session, including a reserved retry, because the archived-Session gate would block the turn that the message wakes. Session inbox persistence precedes the task receipt write, so a crash between the two stores can repeat a message. The implementation adds no scheduler, run, or ownership service.
|
||||
|
||||
After a committed create, update, or delete, Schedule queues a change notice on its own chain, so Session I/O never holds the mutation and delivery queue. Each involved Session other than the actor whose Agent is loaded receives a `schedule`-sourced message for its next model step that does not start a model turn, so notices queued while the Session is idle reach the model with its next input instead of each running as a model turn; the message names the change and carries the task id and title as JSON strings. A Session without a loaded Agent receives nothing and reads the current task when it next lists tasks. A notice failure is logged and does not roll back the committed change. Shutdown drains queued notices before storage closes.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Require a caller-supplied schedule id.** A user-facing title and canonical identity serve different purposes, but accepting ids would expose collision and validation policy. The Host generates identity and returns it for later operations.
|
||||
|
||||
**Keep creator Session ownership.** Ownership would keep list and mutation APIs scoped and would conflict with global management, retargeting, and Session creation policies. Historical involvement is metadata and notification routing, not authorization.
|
||||
|
||||
**Check spacing by sampling upcoming occurrences.** Each occurrence query enumerates from `DTSTART`, so a high-frequency rule with an early start blocked the Host thread before a sampled check could refuse it, and a sample cannot bound the complete sequence. Static rule checks before enumeration and the per-task delivery interval enforce the spacing instead.
|
||||
|
||||
**Add scheduler, run, and notification services.** The existing Schedule service, storage domain, runtime, and Session controller already own these responsibilities at the required scale. New layers would add indirection without changing durability.
|
||||
|
||||
**Leave created Sessions untitled and outside Workspaces.** Such a Session appears as one more untitled Session outside the Workspace that owns its directory, so the user cannot tell which task created it or, for a `new` target, which delivery it received. Joining the Workspace at the target directory and naming the Session after the task, with the creation minute for `new`, needs no stored field. A failure is only logged because delivery has priority over presentation.
|
||||
|
||||
## Consequences
|
||||
|
||||
Tasks outlive Agents and are managed by id. Archive admission reports active Session targets; explicit stop deletes tasks. Host availability, cross-store at-least-once delivery, best-effort notices, and unbounded per-delivery concurrency remain limitations. The [Schedule reference](../../../../docs/subsystems/schedule.md) owns the exact current contracts and the [management upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/schedule-remote-host-tasks/guide.md) covers changed callers.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 独立计划任务与 Session 目标
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-17-standalone-schedules.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
宿主任务需要全局管理和显式执行目标,并在工作区中可辨认。
|
||||
|
||||
## Decision
|
||||
|
||||
存储领域、导入器、占位 RRULE 和并行 API 决策由[带类型规则存储与共享重复计算](2026-10-21-schedule-rule-storage.zh.md)替代。保留现有 `schedule` 领域和统一管理 API;下述目标、执行和通知决策保持有效。
|
||||
|
||||
目标分为 `session`、`new_once` 和 `new`。`session` 存储一个现有 Session id;创建和编辑会在不激活该 Session 的情况下检查它存在且不属于被委派的子 Agent。`new_once` 在创建任务或重新指定目标时预留 Session id,在首次投递时创建该精确 Session,然后转换为 `session`。`new` 在每次创建尝试前把预留的 Session id 保存在 `pendingDelivery` 中,因此投递失败后的重试使用同一 Session。新建 Session 使用目标中的工作目录和 Agent preset,未提供时使用 Host 默认值。目标指定工作目录时,该 Session 加入在该目录注册的 Workspace(若存在)。该 Session 尚无标题时以任务标题命名;`new` 目标再加上该 Session 的创建时间(精确到分钟,按规则 `DTSTART` 的时区),例如 `Weekday digest · 2026-10-12 09:00`,以便区分各次投递创建的 Session。加入或命名失败只记录日志,不阻止投递。`involvedSessionIds` 按首次参与顺序列出创建和编辑任务的 Agent Session,以及每个现有或预留的目标 Session;`new` 目标创建的 Session 不列入其中。
|
||||
|
||||
运行时保留仅补发最新发生时点的行为。它投递不晚于投递判断时刻的最新发生时点,并推进到下一个未来发生时点;耗尽的规则以 `ended` 状态保留在存储中,暂停的任务没有计时器。同一任务的每次投递,无论定时还是立即执行,都在上一次投递的发生时点之后至少 60 秒,立即执行的发生时点是其运行时间;`new` 目标的每次投递都会创建 Session,部署可通过 `newSessionIntervalSeconds` 加大该间隔,默认 60。下一个已存储发生时点满足该间隔时,最小投递间隔从上一次发生时点起算,否则从上一次确认时间起算,因此确认延迟不会累积,延迟的投递也不会推移之后的发生时点;下一个准时的发生时点随后可能在延迟投递之后不足一个间隔到来。投递到复用 Session(`session` 或 `new_once`)的任务在目标 Agent 空闲之前一直占用其执行槽位,期间到期的发生时点合并为之后的一次投递,因此该 Session 最多有该任务的一次待处理投递;`new` 目标不占用槽位,各次启动并行运行,彼此之间只隔投递间隔。目标为 `session` 的到期任务按目标 Session 共享一条消息,After 和 At 任务除外。立即执行与定时投递共用目标解析、投递间隔、Session 收件箱持久化、回执与相关 Session 更新,但保留下一发生时点和状态。二者都拒绝已归档的目的 Session,包括重试时复用的预留 Session,因为已归档会话门禁会阻止该消息唤醒的轮次。Session 收件箱持久化先于任务回执写入,因此两个存储之间崩溃可能重复消息。实现不增加 scheduler、run 或 ownership service。
|
||||
|
||||
create、update 或 delete 提交后,Schedule 在独立的链上排队一条变更通知,因此 Session I/O 不会占用变更与投递队列。除操作者之外、Agent 已加载的每个相关 Session 都会收到一条供下一个模型步骤读取的 `schedule` 来源消息,该消息不会启动模型轮次,因此 Session 空闲期间排队的通知会随其下一条输入到达模型,而不会各自作为一个模型轮次运行;消息说明变更类型,并以 JSON 字符串携带任务 id 和标题。没有已加载 Agent 的 Session 不会收到消息,会在下次列出任务时读取当前任务。通知失败会记录日志,但不会回滚已提交的变更。关闭时先排空已排队的通知,再关闭存储。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**要求调用方提供计划任务 id。** 用户可见标题与规范身份用途不同,但接受 id 会暴露冲突与校验策略。Host 生成身份并返回,供后续操作使用。
|
||||
|
||||
**保留创建者 Session 所有权。** 所有权会让 list 与 mutation API 保持作用域限制,并与全局管理、重新指定目标和 Session 创建策略冲突。历史参与关系是元数据和通知路由,不是授权。
|
||||
|
||||
**通过抽样即将到来的发生时点检查间隔。** 每次发生时点查询都从 `DTSTART` 开始枚举,因此起始时间较早的高频规则会在抽样检查拒绝它之前阻塞 Host 线程,而且抽样无法约束完整序列。改为在枚举之前做静态规则检查,并用每个任务的投递间隔约束间隔。
|
||||
|
||||
**增加 scheduler、run 和 notification service。** 现有 Schedule service、存储领域、runtime 和 Session 控制器已按所需规模承担这些职责。新层级只会增加间接性,不改变持久性。
|
||||
|
||||
**让新建 Session 保持无标题且不属于任何 Workspace。** 这样的 Session 会作为又一个无标题 Session 出现在拥有其目录的 Workspace 之外,用户无法分辨是哪个任务创建了它,也无法分辨对于 `new` 目标它接收了哪一次投递。加入目标目录处的 Workspace,并以任务标题命名 Session(`new` 再加创建分钟),不需要存储字段。失败只记录日志,因为投递优先于呈现。
|
||||
|
||||
## Consequences
|
||||
|
||||
任务寿命长于 Agent,并按 id 管理。归档准入报告活动 Session 目标;显式停止删除任务。宿主可用性、跨存储至少一次投递、尽力通知和每次新建目标的无界并发仍是限制。[Schedule 参考](../../../../docs/subsystems/schedule.zh.md)拥有精确的当前契约,[管理升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/schedule-remote-host-tasks/guide.zh.md)说明调用方变更。
|
||||
+8
-8
@@ -6,14 +6,14 @@
|
||||
en: 17a73230ad22ec0b
|
||||
zh: 5bcf27d7c0e86764
|
||||
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/problem:
|
||||
en: 98e82a0add324882
|
||||
zh: c10e85a3f2a522f8
|
||||
en: c49b11c393e59f35
|
||||
zh: 03cab3ae79a6c878
|
||||
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/decision:
|
||||
en: 56328745115714b5
|
||||
zh: f30d39f59aa06a13
|
||||
en: 48272c9bbe2a0040
|
||||
zh: 668799310bcd20ba
|
||||
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/alternatives-considered:
|
||||
en: 0c4eb01054be1141
|
||||
zh: 95980f8097fa36fd
|
||||
en: 7663bb52e1034eeb
|
||||
zh: 2ef8f146c5642837
|
||||
/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/consequences:
|
||||
en: 05121f4b77b8ee12
|
||||
zh: 1751c9b1a752ce6b
|
||||
en: f911ddff2a9b81da
|
||||
zh: 38ee18e3f6b10c26
|
||||
+5
-5
@@ -6,15 +6,15 @@ English | [中文](2026-09-22-schedule-hard-delete-and-bounded-history.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Schedule domain is one whole-unit JSON document that the backend rewrites on every mutation, so every retained byte in a task row grows every write. Saved delivery records were unbounded: each acknowledgment appended to the array, and a deleted task kept its row and records so the open detail could still read them. The document's size therefore followed a task's delivery count, and deletion added a state every reader had to skip.
|
||||
The Schedule domain stores each task as one JSON document that the backend rewrites whole on every change of that task, so every retained byte in a task row grows every write of that task. Saved delivery records were unbounded: each acknowledgment appended to the array, and a deleted task kept its row and records so the open detail could still read them. The document's size therefore followed a task's delivery count, and deletion added a state every reader had to skip.
|
||||
|
||||
## Decision
|
||||
|
||||
Deletion is hard deletion. `ScheduleService.delete` removes the storage row through `tasks.delete`, so the task stops scheduling, leaves `list` and `catalog`, and `history` answers `schedule_not_found` for the same `(sessionId, id)`; the row's saved delivery records are removed with it. The task schema has no `deleted` field, and no client surface offers a recycle bin or a read of an already deleted task's records.
|
||||
Deletion is hard deletion. The `delete` action of `ScheduleService.edit` removes the storage row through `tasks.delete`, so the task stops scheduling, leaves `list` and `catalog`, and `history` answers `schedule_not_found` for its id; the row's saved delivery records are removed with it. `deleteInactive` removes only ended tasks in one `tasks.deleteMany` batch and preserves active and paused tasks. The task schema has no `deleted` field, and no client surface offers a recycle bin or a read of an already deleted task's records.
|
||||
|
||||
Delivery history is bounded by plugin `Config`: `deliveryHistoryDays` (default 30, 1–3650) and `deliveryHistoryRecords` (default 200, 1–10000). `appendDelivery` applies both bounds on the only write path. It keeps the records whose `deliveredAt` is at or after the appended receipt's `deliveredAt` minus the configured day window, keeps the newest `deliveryHistoryRecords` of those, and always retains the appended latest receipt; a pruned window sets `earlierRecordsUnavailable` and `earlierRecordsPruned`, which later appends preserve. The latter flag is optional in stored rows and confirms actual removal; legacy unavailability alone does not imply pruning. The history response includes the current retention limits, and the Client shows the cleanup notice only for confirmed pruning after loading the final nonempty page.
|
||||
|
||||
The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision: one authoritative version-1 task domain, timer restoration for active tasks only, cold-Session delivery through the Session controller, the catalog and detail surfaces, and the historical-versus-Host decoder split. Its status vocabulary is `active` or `inactive`; `inactive` is the sole non-active name, and the client filter row reads All, Enabled, and Inactive. That note stays active: this supersession is partial, so it is neither archived nor frozen, and it remains the owner of everything above while the deletion and retention decisions it recorded are replaced here.
|
||||
The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision: timer restoration for active tasks only, cold-Session delivery through the Session controller, the catalog and detail surfaces, and the historical-versus-Host decoder split. That note stays active: this supersession is partial, so it is neither archived nor frozen, and it remains the owner of everything above while the deletion and retention decisions it recorded are replaced here. [Standalone schedules and Session targets](2026-09-17-standalone-schedules.md) owns the authoritative task domain and its `active`, `paused`, or `ended` status. Native and Remote catalogs report these same three statuses across the Host. The Automation tasks page's status filter row reads All, Enabled, Paused, and Ended.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -24,10 +24,10 @@ The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note ke
|
||||
|
||||
**Measure the window from the task instead of from each receipt.** Anchoring the window to the task's creation or committed target would discard a newly written receipt of an old task. Measuring back from the appended receipt's `deliveredAt` keeps the window anchored to the data being kept.
|
||||
|
||||
**Keep history unbounded and let operators clean up.** The domain publishes one whole-unit document per write, so unbounded history makes the storage document's size a function of delivery counts. Retention is a bound the storage layout requires, not a retention preference.
|
||||
**Keep history unbounded and let operators clean up.** The backend rewrites each task document whole on every write of that task, so unbounded history makes the task document's size a function of delivery counts. Retention is a bound the storage layout requires, not a retention preference.
|
||||
|
||||
## Consequences
|
||||
|
||||
Deletion now offers no recovery: a deleted task's saved deliveries cannot be read back, and a task that still needs its full history must be read before deletion. Configured retention also discards receipts the task once had; the retained flag records that a window was pruned rather than pretending the history is complete.
|
||||
|
||||
In exchange, the persisted task set has one deletion semantics, the storage document's size is bounded by configuration instead of by delivery counts, and `inactive` is the only non-active lifecycle name. The plugin `Config` is the place to raise the bounds when a deployment needs longer delivery history.
|
||||
In exchange, the persisted task set has one deletion semantics, the size of each task document is bounded by configuration instead of by delivery counts, and deletion adds no lifecycle state. The plugin `Config` is the place to raise the bounds when a deployment needs longer delivery history.
|
||||
+5
-5
@@ -6,15 +6,15 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
Schedule domain 是后端每次变更都会重写的单个整 unit JSON 文档,因此任务行中保留的每个字节都会随每次写入增长。已保存的发送记录没有上限:每次确认都会向数组追加,而被删除的任务会保留其行与记录,以便已打开的详情仍能读取。于是文档大小随任务的投递次数变化,删除还引入了一个所有读取方都必须跳过的状态。
|
||||
Schedule domain 把每个任务存储为一个 JSON 文档,后端在该任务每次变更时整体重写它,因此任务行中保留的每个字节都会随该任务的每次写入增长。已保存的发送记录没有上限:每次确认都会向数组追加,而被删除的任务会保留其行与记录,以便已打开的详情仍能读取。于是文档大小随任务的投递次数变化,删除还引入了一个所有读取方都必须跳过的状态。
|
||||
|
||||
## Decision
|
||||
|
||||
删除是硬删除。`ScheduleService.delete` 通过 `tasks.delete` 移除存储行,因此任务停止调度、离开 `list` 与 `catalog`,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`;该行已保存的投递记录随行一并移除。任务 schema 没有 `deleted` 字段,也没有任何界面提供回收站或读取已删除任务记录的能力。
|
||||
删除是硬删除。`ScheduleService.edit` 的 `delete` 操作通过 `tasks.delete` 移除存储行,因此任务停止调度、离开 `list` 与 `catalog`,`history` 对其 id 返回 `schedule_not_found`;该行已保存的投递记录随行一并移除。`deleteInactive` 通过一次 `tasks.deleteMany` 批量写入仅删除已结束任务,保留活动和暂停任务。任务 schema 没有 `deleted` 字段,也没有任何界面提供回收站或读取已删除任务记录的能力。
|
||||
|
||||
投递历史受插件 `Config` 限制:`deliveryHistoryDays`(默认 30,1–3650)与 `deliveryHistoryRecords`(默认 200,1–10000)。`appendDelivery` 在唯一的写入路径上同时应用这两个上限。它保留 `deliveredAt` 不早于「本次追加回执的 `deliveredAt` 减去配置的天数窗口」的记录,在其中保留最新的 `deliveryHistoryRecords` 条,并始终保留本次追加的最近一次回执;发生裁剪时设置 `earlierRecordsUnavailable` 和 `earlierRecordsPruned`,后续追加会保留这两个标志。后者在存储行中可选,用于确认实际删除;仅有旧格式的不可用标志不表示发生过裁剪。历史响应携带当前保留上限,客户端仅在加载完最后一页且历史非空、确认裁剪时显示清理提示。
|
||||
|
||||
[Host-owned scheduled messages](2026-09-16-host-schedule-storage.zh.md) 笔记保留其余决策:唯一的权威版本 1 任务 domain、只为活动任务恢复定时器、通过 Session controller 投递到冷会话、目录与详情界面,以及历史解码器与宿主解码器的划分。其状态词汇为 `active` 或 `inactive`;`inactive` 是唯一的非活动名称,客户端筛选行为全部、活动和未运行。该笔记保持活动状态:这是一次部分取代,因此它既未归档也未冻结,仍是上述内容的归属笔记,而它记录的删除与保留决策由本笔记取代。
|
||||
[Host-owned scheduled messages](2026-09-16-host-schedule-storage.zh.md) 笔记保留其余决策:只为活动任务恢复定时器、通过 Session controller 投递到冷会话、目录与详情界面,以及历史解码器与宿主解码器的划分。该笔记保持活动状态:这是一次部分取代,因此它既未归档也未冻结,仍是上述内容的归属笔记,而它记录的删除与保留决策由本笔记取代。[独立计划任务与 Session 目标](2026-09-17-standalone-schedules.zh.md) 拥有权威任务 domain 及其 `active`、`paused` 或 `ended` 状态。原生和 Remote 目录在宿主范围内报告相同的三种状态。自动化任务页面的状态筛选行为全部、已开启、已暂停和已结束。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -24,10 +24,10 @@ Schedule domain 是后端每次变更都会重写的单个整 unit JSON 文档
|
||||
|
||||
**以任务而非每条回执为窗口起点。** 把窗口锚定在任务的创建时间或已提交目标上,会丢弃旧任务刚写入的回执。从本次追加回执的 `deliveredAt` 向前计算,使窗口锚定在所要保留的数据上。
|
||||
|
||||
**保留无上限的历史,由运维清理。** 该 domain 每次写入都发布整个 unit 文档,因此无上限的历史会让存储文档的大小随投递次数增长。保留上限是存储布局所要求的约束,而不是保留策略偏好。
|
||||
**保留无上限的历史,由运维清理。** 后端在该任务每次写入时整体重写其任务文档,因此无上限的历史会让任务文档的大小随投递次数增长。保留上限是存储布局所要求的约束,而不是保留策略偏好。
|
||||
|
||||
## Consequences
|
||||
|
||||
删除不再提供恢复能力:已删除任务的已保存投递无法再读回,仍需要完整历史的必须在删除前读取。配置的保留策略也会丢弃任务曾经拥有的回执;保留的标志记录窗口发生过裁剪,而不是假装历史完整。
|
||||
|
||||
换取的是:持久任务集合只有一种删除语义,存储文档大小由配置而非投递次数决定,且 `inactive` 是唯一的非活动生命周期名称。当某个部署需要更长的投递历史时,应提高插件 `Config` 中的上限。
|
||||
换取的是:持久任务集合只有一种删除语义,每个任务文档的大小由配置而非投递次数决定,且删除不增加生命周期状态。当某个部署需要更长的投递历史时,应提高插件 `Config` 中的上限。
|
||||
+6
-6
@@ -9,14 +9,14 @@
|
||||
en: 27b4d1d25cd1c178
|
||||
zh: 3ddc4d9a09b6182d
|
||||
/agent-note-preset-scoped-schedule-tools/decision:
|
||||
en: 3c4708981d34217d
|
||||
zh: cd0c07dc0888f3db
|
||||
en: 652fb37ad4e5a826
|
||||
zh: f5c3cc1e9a79b4ca
|
||||
/agent-note-preset-scoped-schedule-tools/alternatives-considered:
|
||||
en: 5cfe619038339caa
|
||||
zh: c2d2651d304d867c
|
||||
/agent-note-preset-scoped-schedule-tools/consequences:
|
||||
en: d7c7e6b8e6268559
|
||||
zh: 801e5be70e00cc90
|
||||
en: aceed1aa98ed9bec
|
||||
zh: b9d99a25a8b25417
|
||||
/agent-note-preset-scoped-schedule-tools/testing:
|
||||
en: d42cb866907a96b5
|
||||
zh: 6e66f8babe744bcb
|
||||
en: 05080859ac790524
|
||||
zh: 4b220c10da961608
|
||||
@@ -10,9 +10,9 @@ English | [中文](2026-09-24-preset-scoped-schedule-tools.zh.md)
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-tool-schedule` (`packages/schedule/tool-schedule`) contributes the four tools as a preset-level Consumer. It declares `inject = ['tools']` and registers the definitions on `ctx.tools` from inside `ctx.inject(['schedule'], …)`, so the mounting scope is the one that owns them and the registration waits for the Host Schedule service in that same scope. The shipped Web profile mounts the row in its `standard`, `cordis`, and `ptc` presets and omits it from `minimal`. Cordis effect ownership disposes the definitions with the mount, and a composition that never resolves `schedule` registers none of them. The [Web bundle](../../../../packages/bundle/web-app/README.md) owns the `schedule` and `ui-schedule` rows the shipped composition mounts; this record owns the reminder tools' preset placement.
|
||||
`@deepseek-ai/dsh-tool-schedule` (`packages/schedule/tool-schedule`) contributes `schedule_create`, `schedule_list`, `schedule_view`, and `schedule_edit` as a preset-level Consumer. It declares `inject = ['tools', 'systemPrompt']` and registers the definitions and shared authorization instruction from inside `ctx.inject(['schedule'], …)`, so the mounting scope owns them and registration waits for the Host Schedule service in that same scope. The shipped Web profile mounts the row in its `standard`, `cordis`, and `ptc` presets and omits it from `minimal`. Cordis effect ownership disposes the definitions with the mount, and a composition that never resolves `schedule` registers none of them. The [Web bundle](../../../../packages/bundle/web-app/README.md) owns the `schedule` and `ui-schedule` rows the shipped composition mounts; this record owns the reminder tools' preset placement.
|
||||
|
||||
`@deepseek-ai/dsh-schedule` keeps the version-1 storage domain, the Host timer and serialized queue, Host delivery through the Session controller, the Automation page's backing reads, and the `ctx.schedule` interface. `dsh-tool-schedule` is the model-facing consumer of that interface: it checks selector and identity constraints before the service call, reads `exec.agent` for the Session binding, and maps failures that are not `ScheduleInputError` to `internal_error` so storage details never reach the model.
|
||||
`@deepseek-ai/dsh-schedule` keeps the `schedule` storage domain, now writing v2 and reading v1, the Host timer and serialized queue, Host delivery, the Automation tasks page's backing reads, and the `ctx.schedule` interface. `dsh-tool-schedule` is the model-facing consumer of that interface: it maps RRULE and target inputs to service requests, reads `exec.agent` for the actor and `this_session` destination, and maps undeclared failures to `internal_error` so storage details never reach the model. Management addresses every Host task by id; the target alone selects the receiving Session.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -26,9 +26,9 @@ English | [中文](2026-09-24-preset-scoped-schedule-tools.zh.md)
|
||||
- Each preset that mounts the row pays the four schemas' fixed token cost, and tool availability is read from the composition instead of inferred from the Host service's presence.
|
||||
- A deployment can mount `dsh-schedule` for storage and delivery without granting its agents model-driven reminder management.
|
||||
- `dsh-schedule` injects no `ctx.tools` and registers no model-facing tool.
|
||||
- A delegated child inherits the preset's four reminder schemas. `subagentCallerRefusal` in `dsh-tool-schedule` returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }` from each tool when the caller's delegation depth is above zero. The [preset composition decision](../simplification/2026-10-09-preset-owned-subagent-composition.md) removes the redundant child filter; visibility does not authorize execution.
|
||||
- A Session a delegated child owns can never receive a delivered reminder, so `ScheduleService.create` and `ScheduleService.update` refuse it with `subagent_session`. The refusal reads the delegation depth — the accounting the delegation cap itself enforces, carried by the persisted session header across a cold resume. The rule sits in the service, so other consumers reach it too — the Automation surface included.
|
||||
- A delegated child inherits the preset's four reminder schemas. `runForCaller` in `dsh-tool-schedule` returns `{ code: 'subagent_session', message: 'A delegated subagent cannot use scheduled tasks.' }` from each tool when the caller's delegation depth is above zero. The [preset composition decision](../simplification/2026-10-09-preset-owned-subagent-composition.md) removes the redundant child filter; visibility does not authorize execution.
|
||||
- A Session a delegated child owns can never receive a delivered reminder, so `ScheduleService.create` and target changes or arming through `ScheduleService.edit` refuse it with `subagent_session`. The refusal reads the delegation depth — the accounting the delegation cap itself enforces, carried by the persisted session header across a cold resume. The rule sits in the service, so other consumers reach it too — the Automation surface included.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every tool for a delegated child caller`. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its tool table without reminder schemas. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets omit `toolFilter`. The Web preset e2e also checks equal parent/child tool schemas and refusal from all four reminder executors for fresh and seeded children. `snapshots/web/subagent-fork-schedule` records all four refusals in a seeded fork through the shipped Web composition and shares the standard preset's prompt and schema pins.
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` pins the four definitions and their error mapping, including `refuses every call from a registered delegated child whose scope mounts the tools`, and `packages/subagent/subagent/tests/schedule-tools.spec.ts` runs a delegated child through the tool executor and pins that refusal without a stored task. `apps/cli/tests/web-agent-presets.e2e.ts` asserts the `minimal` preset's tool list, and `snapshots/web/minimal-preset/tool-schemas.expected.json` records its tool table without reminder schemas. `packages/schedule/schedule/tests/subagent-ownership.spec.ts` pins the delegation-depth refusal, and `scripts/optional-bundles.spec.ts` asserts that both delegation rows in the three presets omit `toolFilter`. The Web preset e2e also checks equal parent/child tool schemas and refusal from all four reminder executors for fresh and seeded children. `snapshots/web/subagent-fork-schedule` records all four refusals in a seeded fork through the shipped Web composition and shares the standard preset's prompt and schema pins.
|
||||
+5
-5
@@ -10,9 +10,9 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
`@deepseek-ai/dsh-tool-schedule`(`packages/schedule/tool-schedule`)以 preset 级 Consumer 的身份贡献这四个工具。它声明 `inject = ['tools']`,并在 `ctx.inject(['schedule'], …)` 内通过 `ctx.tools` 注册这些定义,因此拥有它们的是挂载它的作用域,而注册会等待同一作用域中的宿主 Schedule 服务。发布版 Web profile 在其 `standard`、`cordis` 和 `ptc` preset 中挂载该行,`minimal` 不挂载。Cordis 的 effect 所有权随挂载卸载而释放这些定义,而始终未解析出 `schedule` 的组合不会注册其中任何一个。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责随发行版交付的组合挂载的 `schedule` 与 `ui-schedule` 两行;本记录负责提醒工具的 preset 归属。
|
||||
`@deepseek-ai/dsh-tool-schedule`(`packages/schedule/tool-schedule`)以 preset 级 Consumer 的身份贡献 `schedule_create`、`schedule_list`、`schedule_view` 和 `schedule_edit`。它声明 `inject = ['tools', 'systemPrompt']`,并在 `ctx.inject(['schedule'], …)` 内注册这些定义和共用授权指令,因此挂载作用域拥有它们,注册会等待同一作用域中的宿主 Schedule 服务。发布版 Web profile 在其 `standard`、`cordis` 和 `ptc` preset 中挂载该行,`minimal` 不挂载。Cordis 的 effect 所有权随挂载卸载而释放这些定义,而始终未解析出 `schedule` 的组合不会注册其中任何一个。[Web bundle](../../../../packages/bundle/web-app/README.zh.md)负责随发行版交付的组合挂载的 `schedule` 与 `ui-schedule` 两行;本记录负责提醒工具的 preset 归属。
|
||||
|
||||
`@deepseek-ai/dsh-schedule` 保留版本 1 storage domain、宿主定时器与串行队列、经由 Session controller 的宿主投递、自动化任务页面的读取来源,以及 `ctx.schedule` 接口。`dsh-tool-schedule` 是该接口面向模型的消费者:它在调用服务前校验选择器与身份约束,从 `exec.agent` 读取 Session 绑定,并把非 `ScheduleInputError` 的失败映射为 `internal_error`,使存储细节不会到达模型。
|
||||
`@deepseek-ai/dsh-schedule` 保留 `schedule` 存储域,现写入 v2 并读取 v1,同时保留宿主定时器与串行队列、宿主投递、自动化任务页面的读取来源,以及 `ctx.schedule` 接口。`dsh-tool-schedule` 是该接口面向模型的消费者:它将 RRULE 与目标输入映射为服务请求,从 `exec.agent` 读取操作者与 `this_session` 目的地,并把未声明的失败映射为 `internal_error`,使存储细节不会到达模型。管理按 id 寻址宿主全部任务;仅目标选择接收 Session。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -26,9 +26,9 @@ Status: implemented
|
||||
- 挂载该行的每个 preset 承担四个 schema 的固定 token 成本,工具可用性取自组合,而不是由宿主服务是否存在推断。
|
||||
- 部署可以只为存储与投递挂载 `dsh-schedule`,而不授予其 Agent 由模型驱动的提醒管理能力。
|
||||
- `dsh-schedule` 不注入 `ctx.tools`,也不注册任何面向模型的工具。
|
||||
- 被委派的子 agent 继承 preset 的四个提醒 schema。调用方委派深度大于零时,`dsh-tool-schedule` 中的 `subagentCallerRefusal` 从每个工具返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' }`。[preset 组装决策](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md)移除了重复的子级过滤;可见性不授予执行权限。
|
||||
- 被委派子 agent 拥有的 Session 永远收不到投递的提醒,因此 `ScheduleService.create` 与 `ScheduleService.update` 会以 `subagent_session` 拒绝它。该拒绝读取委派深度——委派封顶本身执行的记账,由持久化 session header 承载并跨冷恢复保留。这条规则在服务层,因此其它消费方(包括自动化任务页面)也会命中。
|
||||
- 被委派的子 agent 继承 preset 的四个提醒 schema。调用方委派深度大于零时,`dsh-tool-schedule` 中的 `runForCaller` 从每个工具返回 `{ code: 'subagent_session', message: 'A delegated subagent cannot use scheduled tasks.' }`。[preset 组装决策](../simplification/2026-10-09-preset-owned-subagent-composition.zh.md)移除了重复的子级过滤;可见性不授予执行权限。
|
||||
- 被委派子 agent 拥有的 Session 永远收不到投递的提醒,因此 `ScheduleService.create` 以及通过 `ScheduleService.edit` 改变目标或启用任务时会以 `subagent_session` 拒绝它。该拒绝读取委派深度——委派封顶本身执行的记账,由持久化 session header 承载并跨冷恢复保留。这条规则在服务层,因此其它消费方(包括自动化任务页面)也会命中。
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every tool for a delegated child caller`。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其不含提醒工具 schema 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都不含 `toolFilter`。Web preset e2e 还检查父子工具 schema 相等,且四个提醒执行器对新建和带种子的子级均拒绝执行。`snapshots/web/subagent-fork-schedule` 通过出货 Web 组合记录带种子 fork 中的四次拒绝,并共享 standard preset 的提示和 schema 预期。
|
||||
`packages/schedule/tool-schedule/tests/tool-schedule.spec.ts` 固定这四个定义及其错误映射,包括 `refuses every call from a registered delegated child whose scope mounts the tools`;`packages/subagent/subagent/tests/schedule-tools.spec.ts` 通过工具执行器运行一个被委派的子 agent,并在没有已存任务时固定该拒绝。`apps/cli/tests/web-agent-presets.e2e.ts` 断言 `minimal` preset 的工具列表,`snapshots/web/minimal-preset/tool-schemas.expected.json` 记录其不含提醒工具 schema 的工具表。`packages/schedule/schedule/tests/subagent-ownership.spec.ts` 固定委派深度拒绝,`scripts/optional-bundles.spec.ts` 断言三个 preset 的两行委派行都不含 `toolFilter`。Web preset e2e 还检查父子工具 schema 相等,且四个提醒执行器对新建和带种子的子级均拒绝执行。`snapshots/web/subagent-fork-schedule` 通过出货 Web 组合记录带种子 fork 中的四次拒绝,并共享 standard preset 的提示和 schema 预期。
|
||||
+8
-8
@@ -6,14 +6,14 @@
|
||||
en: f524c3b0ee74c586
|
||||
zh: ec4e85f0fd853532
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/problem:
|
||||
en: c4156e2a8a3ad379
|
||||
zh: 992b76509f83d61f
|
||||
en: 57f2fb0a35f6a420
|
||||
zh: 7174afde673c62d6
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/decision:
|
||||
en: b9b203da394f58e5
|
||||
zh: ac511458d60dab49
|
||||
en: 136514e302d80f4c
|
||||
zh: b34e140f41547d06
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/alternatives-considered:
|
||||
en: 8074a2b9b9f5d782
|
||||
zh: 1604b7ee5d34375e
|
||||
en: 49cc4e396052fc09
|
||||
zh: adb8b01f29ce76fb
|
||||
/agent-note-delivered-reminders-are-framed-as-scheduled-messages-from-the-user/consequences:
|
||||
en: 85e03b541bfce57c
|
||||
zh: 77decbb804f6a0b9
|
||||
en: 55c346fbd5feb417
|
||||
zh: 29631345d447f643
|
||||
@@ -6,17 +6,17 @@ English | [中文](2026-09-24-scheduled-message-framing.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Due reminders reach their original Session as user-role messages with producer kind `schedule`. Before this decision, both renderers opened that message with an instruction about how to treat its payload: one-shot delivery asked the model to present `reminder_prompt_json` as untrusted reminder content and not as new user instructions, and recurring batch delivery asked the same for every `reminder_prompt` in `reminders_json`. The message therefore asserted that its own content was untrusted instead of naming where it came from.
|
||||
Due reminders reach their target Session as user-role messages with producer kind `schedule`. Before this decision, both renderers opened that message with an instruction about how to treat its payload: one-shot delivery asked the model to present `reminder_prompt_json` as untrusted reminder content and not as new user instructions, and recurring batch delivery asked the same for every `reminder_prompt` in `reminders_json`. The message therefore asserted that its own content was untrusted instead of naming where it came from.
|
||||
|
||||
## Decision
|
||||
|
||||
`renderReminderFraming` and `renderRecurringReminderBatchFraming` in [`packages/schedule/schedule/src/domain.ts`](../../../../packages/schedule/schedule/src/domain.ts) both emit one shared fixed line, `SCHEDULED_MESSAGE_FRAMING`: `This is a scheduled message from the user`. It is the block's second line, after the bracketed marker (`[SCHEDULE REMINDER]`, `[SCHEDULE REMINDER BATCH]`), and the dynamic field lines are unchanged: a one-shot message still appends `schedule_id_json`, `occurrence_at`, and `reminder_prompt_json`, and a recurring batch still appends `reminders_json`.
|
||||
|
||||
The fixed line states the origin of the message: a schedule bound to that Session. A model reading a user-role message whose fixed block carries it reads the following reminder payload (`reminder_prompt_json` in a one-shot message, `reminders_json` in a batch) as content the user scheduled, and the Host cannot attest that the user typed it: `schedule_create` and `schedule_update` let the model write the prompt, and a model-written prompt can carry text the model read from the web, files, or command output. The previous line marked that payload untrusted, and this decision drops that instruction, so the change accepts the residual risk that model-authored text arrives under a user-origin line. Reintroduction condition: a demonstrated injection through a model-written prompt restores a clause that scopes the payload as reminder text rather than a new instruction, and the five pinned framing assertions in the package specs plus `expectReminderFraming` in the Web e2e are the check that such a change is deliberate. Framing-line forgery is closed by encoding: the schedule id and prompt are JSON strings, the occurrence instant is a validated canonical RFC 3339 value, and each occupies one line, so a prompt carrying embedded newlines or an `occurrence_at:`-style line cannot add framing lines to the block. The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision, including delivery through the Session controller and the saved delivery records.
|
||||
The fixed line states the origin of the message: a schedule that delivers to that Session. A model reading a user-role message whose fixed block carries it reads the following reminder payload (`reminder_prompt_json` in a one-shot message, `reminders_json` in a batch) as content the user scheduled, and the Host cannot attest that the user typed it: `schedule_create` and `schedule_edit` let the model write the prompt, and a model-written prompt can carry text the model read from the web, files, or command output. The previous line marked that payload untrusted, and this decision drops that instruction, so the change accepts the residual risk that model-authored text arrives under a user-origin line. Reintroduction condition: a demonstrated injection through a model-written prompt restores a clause that scopes the payload as reminder text rather than a new instruction, and the five pinned framing assertions in the package specs plus `expectReminderFraming` in the Web e2e are the check that such a change is deliberate. Framing-line forgery is closed by encoding: the schedule id and prompt are JSON strings, the occurrence instant is a validated canonical RFC 3339 value, and each occupies one line, so a prompt carrying embedded newlines or an `occurrence_at:`-style line cannot add framing lines to the block. The [Host-owned scheduled messages](2026-09-16-host-schedule-storage.md) note keeps the rest of its decision, including delivery through the Session controller and the saved delivery records.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Keep the untrusted-content instruction.** It asserted that the prompt was untrusted input, which the delivered message does not need: the prompt is written in the Session the schedule is bound to — by the model through `schedule_create` and `schedule_update`, or by the user in the Web task detail — and the delivery is an ordinary user-role message there.
|
||||
**Keep the untrusted-content instruction.** It asserted that the prompt was untrusted input, which the delivered message does not need: the model writes the prompt through `schedule_create` and `schedule_edit`, a human caller writes it through the Web task detail or Remote `create` and `edit`, and the delivery is an ordinary user-role message in its target Session.
|
||||
|
||||
**Change only the recurring batch line.** One-shot and recurring delivery would then disagree about the origin of the same kind of message, and each later wording change would need both renderers edited separately. Both paths now read one constant.
|
||||
|
||||
@@ -24,6 +24,6 @@ The fixed line states the origin of the message: a schedule bound to that Sessio
|
||||
|
||||
Model-visible reminder text changes for both delivery paths, so a Session log recorded before this decision reconstructs the previous framing, and the Web notice that renders a delivered message shows the new line when expanded. Nothing else moves: no session event, storage field, client code, protocol, or durable format changes, and the delivered text remains reconstructable from the log.
|
||||
|
||||
The prompt text carries no authorship guarantee. It is written in the Session the schedule is bound to, by the model through `schedule_create` and `schedule_update` or by the user in the Web task detail, and a model-written reminder can quote text the model read from the web, files, or command output. The task's Session binding scopes which Session's model reaches the task; it is not caller authorization and says nothing about where the prompt content came from. The Decision holds the accepted risk and its reintroduction condition.
|
||||
The prompt text carries no authorship guarantee. The model writes it through `schedule_create` and `schedule_edit`, a human caller writes it through the Web task detail or Remote `create` and `edit`, and a model-written reminder can quote text the model read from the web, files, or command output. Those tools reach every Host task by id from any Session whose preset mounts them, so the task's target Session is not caller authorization and says nothing about where the prompt content came from. The Decision holds the accepted risk and its reintroduction condition.
|
||||
|
||||
No recorded-session snapshot covers a delivered reminder, and this change adds none: no committed fixture contains a `schedule` message, and `snapshots/web/schedule-catalog` records catalog queries only. Two harness capabilities are missing for such a fixture: no scenario patch controls the wall clock that `packages/schedule/schedule/src/runtime.ts` reads for delivery, and the snapshot harness seeds Sessions rather than the schedule storage domain that holds Host tasks, so those rows exist only where a Web test writes them by hand. This change records the missing harness support instead of a fixture. The delivered text is pinned by the five full-string assertions in `packages/schedule/schedule/tests/{domain,daily,weekly,cron}.spec.ts` and by `expectReminderFraming` in `apps/web/tests/schedule-after.e2e.ts`, which asserts the line in the assembled request for the one-shot and recurring paths.
|
||||
@@ -6,17 +6,17 @@ Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
到期的提醒以生产者 kind 为 `schedule` 的 user-role 消息进入其原 Session。在本决策之前,两个渲染器都用一句关于如何处理其载荷的固定指令开篇:单次投递要求模型把 `reminder_prompt_json` 作为不可信的提醒内容呈现、而不是新的用户指令;周期批次投递对 `reminders_json` 中的每个 `reminder_prompt` 提出同样的要求。于是这条消息断言自身内容不可信,而没有说明它来自哪里。
|
||||
到期的提醒以生产者 kind 为 `schedule` 的 user-role 消息进入其目标 Session。在本决策之前,两个渲染器都用一句关于如何处理其载荷的固定指令开篇:单次投递要求模型把 `reminder_prompt_json` 作为不可信的提醒内容呈现、而不是新的用户指令;周期批次投递对 `reminders_json` 中的每个 `reminder_prompt` 提出同样的要求。于是这条消息断言自身内容不可信,而没有说明它来自哪里。
|
||||
|
||||
## Decision
|
||||
|
||||
[`packages/schedule/schedule/src/domain.ts`](../../../../packages/schedule/schedule/src/domain.ts) 中的 `renderReminderFraming` 与 `renderRecurringReminderBatchFraming` 都输出同一条固定行,即模块常量 `SCHEDULED_MESSAGE_FRAMING`:`This is a scheduled message from the user`;它是该块的第二行,位于方括号标记(`[SCHEDULE REMINDER]`、`[SCHEDULE REMINDER BATCH]`)之后。动态字段行不变:单次消息仍追加 `schedule_id_json`、`occurrence_at`、`reminder_prompt_json`,周期批次仍追加 `reminders_json`。
|
||||
|
||||
这条固定行说明消息的来源:绑定到该 Session 的一条定时任务。模型读到固定块中带有它的 user-role 消息时,会把随后的提醒载荷(单次消息的 `reminder_prompt_json`、批次的 `reminders_json`)当作用户安排的内容,而 Host 无法证明该文本由用户键入:`schedule_create` 与 `schedule_update` 允许模型写入提示词,模型写入的提示词可以带上模型从网页、文件或命令输出中读到的文本。旧固定行把该载荷标记为不可信,本决策有意去掉这一指令,因此接受「模型生成的文本以用户来源行投递」这一残留风险。再引入条件:一旦出现经由模型写入提示词完成注入的实例,就恢复一句把载荷限定为提醒文本、而非新指令的说明;包内五处固定断言与 Web e2e 的 `expectReminderFraming` 是「此类改动必须是有意为之」的检查。固定行无法被动态字段伪造,这由编码保证:schedule id 与提示词是 JSON 字符串,发生时点是经校验的规范 RFC 3339 值,且各自只占一行,因此带有换行或形如 `occurrence_at:` 的提示词无法向该块增加固定行。[Host 拥有的定时消息](2026-09-16-host-schedule-storage.zh.md) 记下的其余决策不变,包括通过 Session controller 投递以及保存的投递记录。
|
||||
这条固定行说明消息的来源:投递到该 Session 的一条定时任务。模型读到固定块中带有它的 user-role 消息时,会把随后的提醒载荷(单次消息的 `reminder_prompt_json`、批次的 `reminders_json`)当作用户安排的内容,而 Host 无法证明该文本由用户键入:`schedule_create` 与 `schedule_edit` 允许模型写入提示词,模型写入的提示词可以带上模型从网页、文件或命令输出中读到的文本。旧固定行把该载荷标记为不可信,本决策有意去掉这一指令,因此接受「模型生成的文本以用户来源行投递」这一残留风险。再引入条件:一旦出现经由模型写入提示词完成注入的实例,就恢复一句把载荷限定为提醒文本、而非新指令的说明;包内五处固定断言与 Web e2e 的 `expectReminderFraming` 是「此类改动必须是有意为之」的检查。固定行无法被动态字段伪造,这由编码保证:schedule id 与提示词是 JSON 字符串,发生时点是经校验的规范 RFC 3339 值,且各自只占一行,因此带有换行或形如 `occurrence_at:` 的提示词无法向该块增加固定行。[Host 拥有的定时消息](2026-09-16-host-schedule-storage.zh.md) 记下的其余决策不变,包括通过 Session controller 投递以及保存的投递记录。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**保留「不可信内容」指令。** 它断言提示词是不可信输入,而投递的消息并不需要这一断言:提示词写在绑定该定时任务的 Session 内——由模型通过 `schedule_create` 与 `schedule_update` 写入,或由用户在 Web 任务详情中写入——投递本身就是该 Session 中的普通 user-role 消息。
|
||||
**保留「不可信内容」指令。** 它断言提示词是不可信输入,而投递的消息并不需要这一断言:模型通过 `schedule_create` 与 `schedule_edit` 写入提示词,人工调用方通过 Web 任务详情或 Remote `create` 与 `edit` 写入它,投递本身就是目标 Session 中的普通 user-role 消息。
|
||||
|
||||
**只改周期批次那一行。** 单次与周期投递会对同类消息的来源给出不同说法,此后每次改措辞都要分别改两个渲染器。现在两条路径读同一个常量。
|
||||
|
||||
@@ -24,6 +24,6 @@ Status: implemented
|
||||
|
||||
两个投递路径的模型可见提醒文本都改变,因此在该决策之前记录的 Session 日志重建出的是旧的固定文本;Web 中渲染该投递消息的通知在展开时显示新的一行。其余不动:没有 session 事件、存储字段、客户端代码、协议或持久化格式变化,投递文本仍可从日志重建。
|
||||
|
||||
提示词文本不携带作者保证。它写在绑定该定时任务的 Session 内——由模型通过 `schedule_create` 与 `schedule_update` 写入,或由用户在 Web 任务详情中写入——模型创建的提醒可以把模型从网页、文件或命令输出中读到的文本原样写入。任务的 Session 绑定限定的是哪个 Session 的模型能访问该任务;它不是调用方鉴权,也不说明提示词内容的来源。本决策中记下该残留风险与再引入条件。
|
||||
提示词文本不携带作者保证。模型通过 `schedule_create` 与 `schedule_edit` 写入它,人工调用方通过 Web 任务详情或 Remote `create` 与 `edit` 写入它;模型创建的提醒可以把模型从网页、文件或命令输出中读到的文本原样写入。这些工具可以从任何挂载它们的 preset 所属的 Session 按 id 访问 Host 的每个任务,因此任务的目标 Session 不是调用方鉴权,也不说明提示词内容的来源。本决策中记下该残留风险与再引入条件。
|
||||
|
||||
没有录制会话快照覆盖提醒投递,本次也没有新增:已提交夹具中没有 `schedule` 消息,`snapshots/web/schedule-catalog` 只记录任务查询。构成该夹具还缺两项 harness 能力:没有场景补丁能控制 `packages/schedule/schedule/src/runtime.ts` 读取的墙上时钟;快照 harness 播种的是 Session,而不是承载 Host 任务的 schedule storage domain,那些任务行只在 Web 测试里手工写入。因此本次记录缺失的 harness 支持,而不新增夹具。投递文本改由 `packages/schedule/schedule/tests/{domain,daily,weekly,cron}.spec.ts` 中五处整串断言,以及 `apps/web/tests/schedule-after.e2e.ts` 中在装配后请求上断言该行、覆盖单次与周期两条路径的 `expectReminderFraming` 固定。
|
||||
@@ -9,11 +9,11 @@
|
||||
en: abd0263d14861926
|
||||
zh: e1736cd08e87f795
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision:
|
||||
en: fdd39b7e8fa89d70
|
||||
zh: 4a0dfc81509e5485
|
||||
en: f1c2b98bf56cdba3
|
||||
zh: 7e9444f8e73a4834
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered:
|
||||
en: 1737484c099fab52
|
||||
zh: 303b144c52d167bd
|
||||
en: 3964f7d653747b55
|
||||
zh: b50636faea4917ef
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences:
|
||||
en: 79d8d66071453d1f
|
||||
zh: f940f0b6d74148d6
|
||||
en: 9fdaa9da9c9952eb
|
||||
zh: b941f68c9c1bb994
|
||||
@@ -10,11 +10,11 @@ English | [中文](2026-09-24-shared-time-vocabulary.zh.md)
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds the parsers, `src/duration.ts` holds the span type, and `src/index.ts` re-exports both and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume.
|
||||
`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds strict instant and zone parsers, `src/duration.ts` holds the span type, and `src/cron.ts` / `src/rrule.ts` hold bounded recurrence values. `src/index.ts` exports them and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume.
|
||||
|
||||
The library answers discriminated results and carries no failure vocabulary. `parseOffsetInstant`, `fourDigitYearEpoch`, `parseLocalCalendar`, and `canonicalizeTimeZone` answer an `accepted` arm or a `rejected` arm naming a stable reason (`not_rfc3339`, `out_of_range`, `not_a_calendar_date`, `invalid_numeric_offset`, `malformed`, `blank`, `padded`, `not_area_location`, `unsupported`, `not_canonical_shape`). Each caller maps a reason onto its own code and diagnostic, so the library never writes model-facing text. `formatFourDigitYearInstant` returns the canonical instant text or `undefined` outside the bound, `isFourDigitYearInstant` answers whether an epoch is representable at all, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. The reason vocabulary is parallel between the two strict parsers: the same condition is `out_of_range` in both, so year 0 is refused by `parseOffsetInstant` and `parseLocalCalendar` alike, while `resolveLocalInstant` accepts any year a real `Temporal` date carries because its callers derive local dates at the UTC edges. `LocalCalendarTime.year` records that split.
|
||||
|
||||
Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `decodeInstant`, `parseAtInput`, and `futureInstant` remain Schedule entry points with their existing codes and diagnostics; they now call the shared parser and map its reason. Schedule still owns the durable record shapes, the fold and transition authority, the injection-resistant framing, and all recurrence arithmetic: the cron engine, the daily and weekly date walks, the latest-due search, and the schedule-specific accepted-decision bound.
|
||||
Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `decodeInstant`, `parseAtInput`, and `futureInstant` remain Schedule entry points with their existing codes and diagnostics; they call the shared parser and map its reason. Pure Cron/RRULE parsing and bounded occurrence searches belong to the library, take explicit reference times, and validate committed targets with `fourDigitYearEpoch`. Schedule owns durable records, the fold and transition authority, injection-resistant framing, daily and weekly date walks, delivery limits, and domain errors. The shared values own no task state, wall clock, or timers.
|
||||
|
||||
`dsh-time-context` consumes it too and keeps its stricter policy. `deriveBrowserTimeZoneContext` reads each user-rpc zone through `canonicalizeTimeZone` and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its `unsupported` rejection carries the platform error the library caught, so that refusal keeps its `cause` in both consumers.
|
||||
|
||||
@@ -22,7 +22,7 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d
|
||||
|
||||
The last copy of the canonical instant profile moves too. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` validated a stored schedule instant with its own regular expression and round trip; the shared `fourDigitYearEpoch` owns that same profile, so the migration now asks it and keeps its own error type and message. Because that migration reads released logs, its accepted set is a compatibility promise: `packages/session/session-format-v0-to-v1/tests/validation.spec.ts` accepts the four-digit-year bounds and refuses each spelling a regular expression alone would admit (`0000`, `2026-02-30`, a four-digit fraction, a numeric offset, surrounding whitespace).
|
||||
|
||||
The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads.
|
||||
The maintained `@js-temporal/polyfill` 0.5.1 is a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`; `rrule` supplies RRULE enumeration behind the library's strict syntax and cost checks. Local resolution uses Temporal, so the Host's IANA data decides gaps and overlaps and no caller owns offset sampling. The util group's charter forbids harness dependencies so any group may depend on util; it permits external packages, which this package's own description and README identify.
|
||||
|
||||
Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included.
|
||||
|
||||
@@ -38,13 +38,13 @@ Observable behavior does not change. Every accepted input resolves to the same e
|
||||
|
||||
**Hold the span in nanoseconds.** `Temporal` carries nanoseconds internally as a `bigint`, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a `bigint`, which no `number` arithmetic, no `Date`, and no `JSON.stringify` accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a `number` would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name.
|
||||
|
||||
**Add formatting and DST arithmetic for other consumers too.** No current consumer needs model-facing formatting or offset arithmetic from this library: `dsh-time-context` owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need.
|
||||
**Share model-facing formatting too.** `dsh-time-context` owns its formatter and zone policy text. Moving that presentation into the library would add consumer policy to the shared arithmetic, against the package rule that ties each abstraction to a current owner and need.
|
||||
|
||||
## Consequences
|
||||
|
||||
The time profile now has one home. A change to the accepted instant profile or the zone profile is made once and covered once, and a new boundary imports it instead of copying it. Schedule's `domain.ts` loses the five regular expressions that spelled the instant, calendar-date, local-time, and zone profiles, the `Intl` canonicalization, and the hand-rolled calendar conversion. It still imports the library's two bound constants for its date arithmetic at the UTC edges and delegates every representability guard to `isFourDigitYearInstant`, so the range itself has one owner.
|
||||
|
||||
The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message.
|
||||
The cost is a wider published surface and a heavier dependency graph: the root entry resolves `@js-temporal/polyfill` and `rrule` even for consumers that only need a zone or span. The README records that cost and the bounded recurrence profiles. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message.
|
||||
|
||||
A value type publishes what its consumers call, so the span exposes the constructor and the split that `dsh-time-context` uses and nothing else. A conversion, an arithmetic operation, or a `zero` identity lands with the caller that needs it; the seconds-valued fields at `dsh-schedule` and the DeepSeek file store are the spans that will ask for a second-unit conversion, and the bound and the freeze the class already carries are what make that addition a small one. The package README records the un-migrated boundaries as a known limitation.
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
`dsh-util-time` 拥有各边界所接收的时间词汇。解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,`src/index.ts` 转出两者,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。
|
||||
`dsh-util-time` 拥有各边界所接收的时间词汇。严格时刻与时区解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,有界重复规则值位于 `src/cron.ts` / `src/rrule.ts`。`src/index.ts` 导出这些入口,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。
|
||||
|
||||
本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`out_of_range`、`not_a_calendar_date`、`invalid_numeric_offset`、`malformed`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`isFourDigitYearInstant` 回答某个纪元是否可表示;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。两个严格解析器的原因词汇彼此对称:同一条件在两者中都叫 `out_of_range`,所以年份 0 被 `parseOffsetInstant` 与 `parseLocalCalendar` 同样拒绝;而 `resolveLocalInstant` 接受真实 `Temporal` 日期携带的任何年份,因为它的调用方需要在 UTC 边界推导本地日期。`LocalCalendarTime.year` 记录了这一分工。
|
||||
|
||||
Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。
|
||||
Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们调用共享解析器并映射其原因。纯 Cron/RRULE 解析与有界发生时点搜索归本库所有,接收显式参考时间,并通过 `fourDigitYearEpoch` 校验已提交时点。Schedule 拥有持久记录、折叠与状态转移权威、抗注入的框架文本、每日与每周日期遍历、投递限制及领域错误。共享值不拥有任务状态、墙钟或定时器。
|
||||
|
||||
`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。
|
||||
|
||||
@@ -22,7 +22,7 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
|
||||
|
||||
最后一份规范时刻剖面副本也一并迁移。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 原本用自己的一段正则与往返比对校验持久记录里的调度时刻;共享的 `fourDigitYearEpoch` 拥有同一剖面,因此该迁移改为询问它,并保留自己的错误类型与文案。由于这个迁移要读取已发布的日志,其接受集是兼容性承诺:`packages/session/session-format-v0-to-v1/tests/validation.spec.ts` 接受四位年份两端,并拒绝只靠正则会放进来的每种拼写(`0000`、`2026-02-30`、四位小数、数字偏移、首尾空白)。
|
||||
|
||||
受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。
|
||||
受维护的 `@js-temporal/polyfill` 0.5.1 是 `dsh-util-time` 与 `dsh-schedule` 的运行时依赖;`rrule` 在本库的严格语法与成本检查之后提供 RRULE 枚举。本地解析使用 Temporal,因此缺口与重叠由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。util 组的约定禁止 harness 依赖,以便任何组都能依赖 util;它允许外部包,本包自己的描述与 README 写明了这些依赖。
|
||||
|
||||
可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。
|
||||
|
||||
@@ -38,13 +38,13 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
|
||||
|
||||
**用纳秒保存时间段。** `Temporal` 内部以 `bigint` 携带纳秒,所以纳秒时间段是可表示的。之所以否决,理由与边界保持毫秒相同:计数必须变成 `bigint`,而 `number` 算术、`Date` 与 `JSON.stringify` 都不接受它,于是每个消费方与每条持久记录都要多一次字符串或二进制转换。若改用 `number` 表示纳秒,在当前离线纪元量级上它会被量化到约 256 ns,那是同一个名字下的另一个值。
|
||||
|
||||
**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。
|
||||
**同时共享面向模型的格式化。** `dsh-time-context` 拥有自己的格式化器与时区策略文本。把这些呈现迁入本库会向共享算术加入消费方策略,违背“每项抽象都绑定当前所有者与需求”的包规则。
|
||||
|
||||
## Consequences
|
||||
|
||||
时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它仍然导入本库的两个边界常量用于其 UTC 边界日期运算,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。
|
||||
|
||||
代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。
|
||||
代价是更宽的已发布接口与更重的依赖图:根入口会解析 `@js-temporal/polyfill` 和 `rrule`,只需要时区或跨度的消费方也一样。README 记录了这些成本与有界重复规则子集。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。
|
||||
|
||||
值类型只发布消费方会调用的部分,因此这个时间段只暴露 `dsh-time-context` 用到的构造入口与拆解,其余不发布。某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地;`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段就是将来会要求第二个单位换算的那些跨度,而类已经携带的范围约束与冻结正是让这次添加保持很小的原因。包 README 把这些尚未迁移的边界记为已知限制。
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-21-schedule-rule-storage.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-schedule-rule-storage.md
|
||||
/agent-note-schedule-rule-storage-and-shared-recurrence:
|
||||
en: 34817514a06a4351
|
||||
zh: a9e33a1c4d5845d1
|
||||
/agent-note-schedule-rule-storage-and-shared-recurrence/problem:
|
||||
en: 22eed4a004cbb1c5
|
||||
zh: dc7b6f8ed9c074cf
|
||||
/agent-note-schedule-rule-storage-and-shared-recurrence/decision:
|
||||
en: 50bfa3e81af7c2b0
|
||||
zh: e9bd5fae6e611030
|
||||
/agent-note-schedule-rule-storage-and-shared-recurrence/alternatives-considered:
|
||||
en: fbfcd35c35fb637a
|
||||
zh: df9dd646e6d18e3c
|
||||
/agent-note-schedule-rule-storage-and-shared-recurrence/consequences:
|
||||
en: 61213641597c18bb
|
||||
zh: 393a79013e311311
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: Schedule rule storage and shared recurrence
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-21-schedule-rule-storage.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Host tasks already use the versioned `schedule` domain. A second domain, importer, and placeholder RRULE duplicate timing and storage authority. Cron and RRULE should be supported timing types rather than separate generations of the task API.
|
||||
|
||||
## Decision
|
||||
|
||||
Use `schedule` version 2 with `compatibleVersions: [1]`, retaining its backend routing, envelope, and whole-unit layout. Extend the existing accepted-version policy to authoritative JSON and SQLite units; reads leave storage unchanged, and the next write commits the current stamp with its data. Add `rrule` to the six existing record kinds and evaluate every kind normally. Remove the standalone store importer, import marker, placeholder RRULE, and parallel Session-bound management API. Move reusable Cron and RRULE parsing and bounded occurrence arithmetic into `dsh-util-time`; Schedule owns records, targets, delivery admission, history, and errors.
|
||||
|
||||
An explicit target wins. Without one, read an existing `sessionId` as a Session target; otherwise reserve a stable `new_once` target. Optional new metadata receives read defaults without rewriting storage. Every successful edit writes the current row format without `sessionId`. Custom timing accepts either RRULE or a five-field Cron expression with an explicit zone; UI labels name neither syntax. Model guidance continues to teach RRULE.
|
||||
|
||||
This partially supersedes [standalone schedules](2026-09-17-standalone-schedules.md): its target, execution, notification, and Host-wide management decisions remain. It restores the domain and layout in [Host schedule storage](2026-09-16-host-schedule-storage.md), while replacing that note's Session-bound management API. Both notes remain active and link to this decision.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Changing the domain or layout would require routing and copy logic without changing the feature. Converting Cron into RRULE can change day-of-month/day-of-week and time-zone semantics. A generic value migration engine is deferred until an incompatible storage version requires one; the existing domain envelope already identifies the version.
|
||||
|
||||
## Consequences
|
||||
|
||||
Missing creation metadata cannot recover historical timestamps. A default target must retain its reserved Session identity across reads and retries. Old receipts need their original destination retained before retargeting. A current unit stamp can contain older untouched rows, so future compatible readers must retain those defaults until a complete rewrite allows removal. Old binaries reject a newer stamp. Session inbox persistence and task acknowledgment remain separate commits, so delivery remains at least once.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Agent Note: 调度规则存储与共享重复时间计算
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-21-schedule-rule-storage.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Host 任务已经使用带版本的 `schedule` 域。第二个域、导入器和占位 RRULE 会重复定义时间与存储的权威来源。Cron 和 RRULE 应当是并列的时间规则类型,而非两代任务 API。
|
||||
|
||||
## 决策
|
||||
|
||||
使用 `schedule` 版本 2 和 `compatibleVersions: [1]`,保留后端路由、封套和整单元布局。将现有接受版本策略扩展到权威 JSON 和 SQLite 单元;读取保持存储不变,下一次写入将当前版本戳与数据一同提交。在现有六种记录类型旁添加 `rrule`,所有类型均正常求值。移除独立存储的导入器、导入标记、占位 RRULE 和并行的 Session 绑定管理 API。将可复用的 Cron、RRULE 解析及有界时间计算移至 `dsh-util-time`;Schedule 负责记录、目标、投递准入、历史和领域错误。
|
||||
|
||||
显式目标优先。缺少目标时,将已有 `sessionId` 读取为 Session 目标;否则保留稳定的 `new_once` 目标。新增可选元数据使用读取默认值,不重写存储。每次成功编辑都写入不含 `sessionId` 的当前行格式。自定义时间同时接受 RRULE 和带显式时区的五字段 Cron 表达式;界面标签不提示任何语法名称。模型指引继续推荐 RRULE。
|
||||
|
||||
本决策部分替代[独立定时任务](2026-09-17-standalone-schedules.zh.md),保留其目标、执行、通知和宿主级管理决策。它恢复[宿主任务存储](2026-09-16-host-schedule-storage.zh.md)的领域与布局,并替代按 Session 绑定的管理 API。两个 note 保持活动并链接到本决策。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
更换域或布局需要增加路由和复制逻辑,却不改变功能。将 Cron 转换为 RRULE 可能改变月内日期、星期和时区语义。通用值迁移引擎留待不兼容存储版本确有需要时再引入;现有域封套已经标识版本。
|
||||
|
||||
## 影响
|
||||
|
||||
缺失的创建元数据无法恢复历史时间戳。默认目标必须在读取和重试间保持预留 Session 标识。重新指定目标前,旧回执需要保留原始投递目标。当前单元版本戳可包含未改动的旧行,因此未来兼容读取器必须保留这些默认规则,直至完整重写后才能移除。旧程序拒绝更新的版本戳。Session 收件箱持久化与任务确认仍是两次独立提交,因此投递仍然至少一次。
|
||||
+8
-8
@@ -6,14 +6,14 @@
|
||||
en: 8be4b1d210d5d8bf
|
||||
zh: e9124b48467818b3
|
||||
/agent-note-archive-admission-reads-the-stored-tasks/problem:
|
||||
en: 093de39b40f7cc97
|
||||
zh: 50c712bde174bc96
|
||||
en: 4e5bdccf5388d0e5
|
||||
zh: 29e68a698f1fa746
|
||||
/agent-note-archive-admission-reads-the-stored-tasks/decision:
|
||||
en: afbf818442c0794a
|
||||
zh: 4749e63d9757bbbb
|
||||
en: fa45f421aab1315c
|
||||
zh: d8982ea5833d3770
|
||||
/agent-note-archive-admission-reads-the-stored-tasks/alternatives-considered:
|
||||
en: 099360f2c97a50ea
|
||||
zh: eac4746c4602f152
|
||||
en: b38b58e4d91b66ac
|
||||
zh: f9c7253ffa94d76c
|
||||
/agent-note-archive-admission-reads-the-stored-tasks/consequences:
|
||||
en: cb2883b34c2c73fa
|
||||
zh: 9bb1489c9711cf99
|
||||
en: b034eb6ea31341b9
|
||||
zh: f78029ff55addc4e
|
||||
+6
-6
@@ -8,20 +8,20 @@ English | [中文](2026-09-22-archive-admission-reads-stored-tasks.zh.md)
|
||||
|
||||
Archive admission asks every provider what still runs for a Session before the archive is written, and a stop-archive then asks those providers to end it ([archiving a Session with running work](2026-09-21-archive-stops-running-session-work.md)). The Schedule provider answered from its owned runtime's fold of the live Session log, so only a Session with a live Agent reported the `schedule` family, and the stop deleted through the management `delete` path.
|
||||
|
||||
Host tasks are stored in the Schedule domain document and outlive their Session's Agent ([the storage decision](../architecture/2026-09-16-host-schedule-storage.md)), so that fold no longer exists: `ScheduleRuntime.activeRecords()` is gone, and the stored task table is the only place a due reminder can be seen. A cold Session whose stored rows were still armed therefore reported nothing, archived without the confirmation dialog the running case asks for, and nobody was told which reminder the Host would keep.
|
||||
Host tasks are stored in the Schedule domain and outlive their Session's Agent ([the storage decision](../architecture/2026-09-16-host-schedule-storage.md)), so that fold no longer exists: `ScheduleRuntime.activeRecords()` is gone, and the stored task table is the only place a due reminder can be seen. A cold Session whose stored rows were still armed therefore reported nothing, archived without the confirmation dialog the running case asks for, and nobody was told which reminder the Host would keep.
|
||||
|
||||
## Decision
|
||||
|
||||
The Schedule provider answers archive admission from the stored Host task rows, and a stop deletes them. `workspace/session-activity` reads `list({ sessionId })` — the stored rows whose binding is that Session and whose status is active — reports them as the `schedule` family, one item per task with its stored id and title, and prepends that family to `next()` so every other provider keeps its entries. `workspace/session-stop` removes those rows directly inside one slot of the same serialized queue the tools use, so a stop is ordered behind a create whose write is still in flight and cannot miss the row that create is about to commit; it attempts every row, because a row left armed would still fire, and it notifies observers for the rows that landed before the first failure reaches the caller. Session liveness is never consulted: an idle or cold Session whose stored rows are armed refuses the archive, and the confirmation dialog names exactly those reminders. Both listeners are registered in one effect named `schedule.archiveAdmission()`, so unloading the plugin withdraws them together.
|
||||
The Schedule provider answers archive admission from the stored Host task rows, and a stop deletes them. `workspace/session-activity` reads the stored tasks whose status is active and whose `session` or reserved `new_once` target has the matching Session id, reports them as the `schedule` family, one item per task with its stored id and title, and prepends that family to `next()` so every other provider keeps its entries. `workspace/session-stop` removes those rows in one `deleteMany` batch inside one slot of the same serialized queue the tools use, so a stop is ordered behind a create whose write is still in flight and cannot miss the row that create is about to commit. A successful nonempty batch announces `schedule/changed`, re-drives the timer once, and queues deletion notices for other loaded involved Agents. Session liveness is never consulted: an idle or cold Session whose stored rows are armed refuses the archive, and the confirmation dialog names exactly those reminders. Both listeners are registered in one effect named `schedule.archiveAdmission()`, so unloading the plugin withdraws them together.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Report the `schedule` family from live Sessions only.** The stored tasks are authoritative and outlive their Session's Agent, so consulting liveness would let a cold Session with armed rows archive silently, and its due row then dispatches into a turn the gate ends as `blocked`, consuming the occurrence without a model step. The admission answer costs one table read either way.
|
||||
**Report the `schedule` family from live Sessions only.** The stored tasks are authoritative and outlive their Session's Agent, so consulting liveness would let a cold Session with armed rows archive silently; its due delivery then rejects the archived destination. The admission answer costs one table read either way.
|
||||
|
||||
**Delete each row through the public management `delete` path.** Re-entering the serialized queue from inside the slot that already holds it deadlocks, so the stop removes the rows itself: the same durable change, with one notification and one timer re-drive per stop instead of one per row.
|
||||
**Delete each row through the `delete` action of public `edit`.** Re-entering the serialized queue from inside the slot that already holds it deadlocks, so the stop removes the rows itself: the same durable change, with one `schedule/changed` notification and one timer re-drive per stop instead of one per row.
|
||||
|
||||
**Keep a runtime-side mirror of the Session's active tasks.** A mirror duplicates the authoritative document, drifts after a restart, and disappears with the Agent — the exact case admission must still answer for.
|
||||
**Keep a runtime-side mirror of the Session's active tasks.** A mirror duplicates the authoritative store, drifts after a restart, and disappears with the Agent — the exact case admission must still answer for.
|
||||
|
||||
## Consequences
|
||||
|
||||
Admission costs one stored-table read, and a Session with no live Agent can refuse the archive: a Session that looks quiet may now ask for confirmation because its stored reminders are armed. A stop deletes rows one at a time — the domain table offers no batch delete — so a failure partway leaves the earlier deletes committed; observers hear about those, every row was attempted, and the first failure still reaches the caller. One residue follows. A crash between the archive write and the stop leaves stored rows armed in an archived Session: unarchiving shows them again, and when such a row's target passes the Schedule runtime selects it on its own timer and resolves the Session's Agent itself — that resolution is ungated, and only `agent/pre-step` refuses the model step — so a one-shot dispatches a follow-up into a turn the gate ends as `blocked` and that occurrence is consumed without a model step. The seam itself is unchanged: the workspace package still knows no Schedule vocabulary, and this provider owns only reminders.
|
||||
Admission costs one stored-table read, and a Session with no live Agent can refuse the archive: a Session that looks quiet may now ask for confirmation because its stored reminders are armed. A stop uses one domain `deleteMany` batch. A normal write failure preserves every row; an `unknown-state` failure may have removed rows and quarantines the domain until it is reopened. Either failure announces `schedule/changed` so observers reread and then reaches the caller. A crash between the archive write and the stop can leave stored rows armed in an archived Session; delivery rejects with `schedule/target-archived` before enqueue, without writing a receipt or consuming the occurrence. The seam itself is unchanged: the workspace package still knows no Schedule vocabulary, and this provider owns only reminders.
|
||||
+6
-6
@@ -8,20 +8,20 @@
|
||||
|
||||
归档准入在写入归档前询问每个提供方:该 Session 还有什么在跑;随后「停止并归档」再要求它们结束这些工作([归档仍有工作在跑的 Session](2026-09-21-archive-stops-running-session-work.zh.md))。Schedule 提供方原先回答的是其归属 runtime 对活会话日志的 fold,因此只有带活 Agent 的 Session 才报告 `schedule` 族,停止也走管理 `delete` 路径。
|
||||
|
||||
宿主任务存储在 Schedule 领域文档中,且比其 Session 的 Agent 活得更久([存储决策](../architecture/2026-09-16-host-schedule-storage.zh.md)),因此那个 fold 已不存在:`ScheduleRuntime.activeRecords()` 已经移除,已存储的任务表是到期提醒唯一能被看见的地方。存储行仍被武装的冷 Session 因此什么也不报告,归档时不会弹出运行态才会弹出的确认对话框,也没有人被告知宿主会继续保留哪条提醒。
|
||||
宿主任务存储在 Schedule 领域中,且比其 Session 的 Agent 活得更久([存储决策](../architecture/2026-09-16-host-schedule-storage.zh.md)),因此那个 fold 已不存在:`ScheduleRuntime.activeRecords()` 已经移除,已存储的任务表是到期提醒唯一能被看见的地方。存储行仍被武装的冷 Session 因此什么也不报告,归档时不会弹出运行态才会弹出的确认对话框,也没有人被告知宿主会继续保留哪条提醒。
|
||||
|
||||
## 决策
|
||||
|
||||
Schedule 提供方从已存储的宿主任务行回答归档准入,并由停止删除这些行。`workspace/session-activity` 读取 `list({ sessionId })`——绑定到该 Session 且状态为活动的已存储行——把它们作为 `schedule` 族报告,每条任务一项、携带其已存储 id 与 title,并把该族前插到 `next()` 的结果之前,使其他提供方保留各自条目。`workspace/session-stop` 在与工具相同的串行队列的一个槽位里直接删除这些行,因此停止会排在写入尚未完成的创建之后,不会漏掉那个创建即将提交的行;它会尝试每一行,因为留下的行仍会触发;对已经落地的行先通知观察者,再把首个失败交给调用方。是否存活从不参与判断:存储行仍被武装的空闲或冷 Session 会拒绝归档,确认对话框会准确列出这些提醒。两个监听器注册在同一个名为 `schedule.archiveAdmission()` 的 effect 中,卸载插件即一并撤回。
|
||||
Schedule 提供方从已存储的宿主任务行回答归档准入,并由停止删除这些行。`workspace/session-activity` 读取状态为活动且 `session` 或预留的 `new_once` 目标的 `sessionId` 与该 Session 匹配的已存储任务,把它们作为 `schedule` 族报告,每条任务一项、携带其已存储 id 与 title,并把该族前插到 `next()` 的结果之前,使其他提供方保留各自条目。`workspace/session-stop` 在与工具相同的串行队列的一个槽位里通过一次 `deleteMany` 批量写入删除这些行,因此停止会排在写入尚未完成的创建之后,不会漏掉那个创建即将提交的行。非空批量写入成功后发出 `schedule/changed`,重算计时器一次,并向其他已加载的相关 Agent 排入删除通知。是否存活从不参与判断:存储行仍被武装的空闲或冷 Session 会拒绝归档,确认对话框会准确列出这些提醒。两个监听器注册在同一个名为 `schedule.archiveAdmission()` 的 effect 中,卸载插件即一并撤回。
|
||||
|
||||
## 已考虑的备选
|
||||
|
||||
**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档,其到期行随后投递进一个被门禁以 `blocked` 收口的回合,在没有模型步的情况下消耗掉这次触发。两种做法在准入上都只需一次读表。
|
||||
**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档;其到期投递随后因目标已归档而被拒绝。两种做法在准入上都只需一次读表。
|
||||
|
||||
**每行都走公开的管理 `delete` 路径删除。** 在已经持有该槽位的串行队列内部再次进入队列会自锁,因此停止自己删行:同样的持久化变更,每次停止一次通知、一次计时器重算,而不是每行各一次。
|
||||
**每行都通过公开 `edit` 的 `delete` 操作删除。** 在已经持有该槽位的串行队列内部再次进入队列会自锁,因此停止自己删行:同样的持久化变更,每次停止一次 `schedule/changed` 通知、一次计时器重算,而不是每行各一次。
|
||||
|
||||
**在 runtime 侧保留该 Session 活动任务的镜像。** 镜像会重复权威文档、在重启后漂移、并随 Agent 一起消失——而这正是准入必须仍能回答的情形。
|
||||
**在 runtime 侧保留该 Session 活动任务的镜像。** 镜像会重复权威存储、在重启后漂移、并随 Agent 一起消失——而这正是准入必须仍能回答的情形。
|
||||
|
||||
## 后果
|
||||
|
||||
准入的代价是一次读已存储的表,而没有活 Agent 的 Session 也能拒绝归档:看起来安静的 Session 现在可能因为其已存储提醒仍被武装而要求确认。停止逐行删除——领域表不提供批量删除——因此删到一半失败时,先前删除已提交;观察者会被告知这些,每一行都已被尝试,首个失败仍交给调用方。由此剩下一处残余:在归档写入与停止之间崩溃,会在已归档的 Session 里留下仍被武装的已存储行;取消归档时它们再次出现;当这类行的目标时刻过去,Schedule runtime 会按自己的定时器选中它并自行 resolve 该 Session 的 Agent——这一步没有门禁,只有 `agent/pre-step` 拒绝模型步——一次性提醒于是把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发在没有模型步的情况下被消耗。接缝本身未变:workspace 包仍然不认识 Schedule 词汇,而这个提供方只拥有提醒。
|
||||
准入的代价是一次读已存储的表,而没有活 Agent 的 Session 也能拒绝归档:看起来安静的 Session 现在可能因为其已存储提醒仍被武装而要求确认。停止使用一次领域 `deleteMany` 批量写入。正常写入失败保留全部行;`unknown-state` 失败可能已移除行,并隔离领域直到重新打开。两种失败均发出 `schedule/changed`,让观察方重读,再交给调用方。在归档写入与停止之间崩溃,仍可能在已归档的 Session 中留下仍被武装的已存储行;投递在入队前以 `schedule/target-archived` 拒绝,不写入回执,也不消耗该次发生时点。接缝本身未变:workspace 包仍然不认识 Schedule 词汇,而这个提供方只拥有提醒。
|
||||
@@ -131,6 +131,7 @@ External packages installed for runtime use or embedded in distributed Host or b
|
||||
| [`react-dom`](https://github.com/facebook/react) | MIT |
|
||||
| [`readable-stream`](https://github.com/nodejs/readable-stream) | MIT |
|
||||
| [`resolve.exports`](https://github.com/lukeed/resolve.exports) | MIT |
|
||||
| [`rrule`](https://github.com/jakubroztocil/rrule) | BSD-3-Clause |
|
||||
| [`semver`](https://github.com/npm/node-semver) | ISC |
|
||||
| [`serve-static`](https://github.com/expressjs/serve-static) | MIT |
|
||||
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
|
||||
|
||||
@@ -174,7 +174,7 @@ describe('Python SDK dsh profile keyless smoke', () => {
|
||||
expect(modelRequests[0]?.output_config).toEqual({ effort: 'max' })
|
||||
expect(modelRequests[0]?.max_tokens).toBe(1234)
|
||||
const expectedTools = ['read', 'write', 'edit', 'web_fetch', 'web_search', 'subagent', 'subagent_fork',
|
||||
'present', 'ask_user_question', 'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update', 'plugin_manager']
|
||||
'present', 'ask_user_question', 'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view', 'plugin_manager']
|
||||
const requestText = JSON.stringify(modelRequests[0])
|
||||
if (ptc) {
|
||||
expect(toolNames).toEqual(['run_code'])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** Reminder activation must share SDK ownership and initialized model limits after process restart. */
|
||||
/** Schedule authoring and restart delivery must honor the shipped SDK composition. */
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -30,7 +30,8 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t
|
||||
if (restarting) restored.push(request)
|
||||
const content = requests.length === 1 ? {
|
||||
type: 'tool_use', id: 'create-reminder', name: 'schedule_create',
|
||||
input: { title: 'Check SDK', prompt: 'Report the SDK reminder', after_seconds: 86400 },
|
||||
input: { title: 'Check SDK', prompt: 'Report the SDK reminder',
|
||||
rrule: 'DTSTART:20300102T000000Z\nRRULE:FREQ=DAILY;COUNT=1', target: 'this_session' },
|
||||
} : { type: 'text', text: 'ready' }
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(messagesResponse(content, requests.length === 1 ? 'tool_use' : 'end_turn'))
|
||||
@@ -70,3 +71,64 @@ it('delivers an overdue reminder with the SDK route and lets the client resume t
|
||||
const session = await second.resumeSession('saved')
|
||||
expect(await session.run('Continue after the reminder')).toMatchObject({ finalResponse: 'ready' })
|
||||
})
|
||||
|
||||
it('refuses new Session destinations before storing tasks in the shipped SDK profile', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'sdk-schedule-targets-'))
|
||||
const requests: Record<string, unknown>[] = []
|
||||
const calls = ['new', 'new_once', 'this_session'].flatMap(target => [
|
||||
{ type: 'tool_use', id: `create-${target}`, name: 'schedule_create',
|
||||
input: { title: `SDK ${target}`, prompt: 'Check SDK',
|
||||
rrule: 'DTSTART:20300102T000000Z\nRRULE:FREQ=DAILY;COUNT=1', target } },
|
||||
{ type: 'tool_use', id: `list-${target}`, name: 'schedule_list', input: {} },
|
||||
])
|
||||
const server = createServer((incoming, response) => {
|
||||
let body = ''
|
||||
incoming.setEncoding('utf8')
|
||||
incoming.on('data', (chunk: string) => { body += chunk })
|
||||
incoming.on('end', () => {
|
||||
const content = calls[requests.length] ?? { type: 'text', text: 'ready' }
|
||||
requests.push(JSON.parse(body) as Record<string, unknown>)
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' })
|
||||
response.end(messagesResponse(content, content.type === 'tool_use' ? 'tool_use' : 'end_turn'))
|
||||
})
|
||||
})
|
||||
const clients: DeepSeekHarness[] = []
|
||||
onTestFinished(async () => {
|
||||
try { await Promise.all(clients.map(client => client.close())) } finally {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
|
||||
})
|
||||
} finally { await rm(root, { recursive: true, force: true }) }
|
||||
}
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('model fixture has no port')
|
||||
const env = { ...process.env, DSH_PERMISSION_MODE: 'danger-full-access', DSH_TELEMETRY_DISABLED: '1',
|
||||
DEEPSEEK_API_KEY: 'keyless-schedule-fixture', DEEPSEEK_BASE_URL: `http://127.0.0.1:${String(address.port)}` }
|
||||
clearAmbientProxyEnv(env)
|
||||
const client = new DeepSeekHarness({ cwd: root, dshHome: join(root, '.dsh'),
|
||||
model: 'deepseek-v4-pro', initializeTimeoutMs: 30_000, env })
|
||||
clients.push(client)
|
||||
expect((await client.session('schedule-owner').run('Check the supported schedule destinations')).finalResponse).toBe('ready')
|
||||
expect(requests).toHaveLength(calls.length + 1)
|
||||
const results = calls.map((call, index) => {
|
||||
const messages = requests[index + 1]?.messages as {
|
||||
content: { type: string; tool_use_id?: string; content?: { type: string; text?: string }[] }[]
|
||||
}[]
|
||||
const result = messages.flatMap(message => message.content)
|
||||
.find(block => block.type === 'tool_result' && block.tool_use_id === call.id)
|
||||
const text = result?.content?.find(block => block.type === 'text')?.text
|
||||
if (text === undefined) throw new Error(`SDK model request has no result for ${call.id}`)
|
||||
return JSON.parse(text) as Record<string, unknown>
|
||||
})
|
||||
for (const index of [0, 2]) {
|
||||
expect(results[index]).toMatchObject({
|
||||
code: 'invalid_rule', message: expect.stringContaining('choose an existing Session target') as string,
|
||||
})
|
||||
expect(results[index + 1]).toMatchObject({ items: [], shown: 0, total: 0 })
|
||||
}
|
||||
expect(results[4]).toMatchObject({ title: 'SDK this_session', target: { kind: 'session', sessionId: 'schedule-owner' } })
|
||||
expect(results[5]).toMatchObject({ items: [results[4]], shown: 1, total: 1 })
|
||||
})
|
||||
@@ -1,14 +1,18 @@
|
||||
/** Built CLI, public Remote requests, and persisted task files; seeded receipts are not delivery evidence. */
|
||||
/**
|
||||
* Built CLI, public Remote requests, and persisted task files; seeded receipts are not delivery evidence.
|
||||
* Existing rows load without writes; edits keep the envelope and store the current target shape.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { DailyScheduleRecord, ScheduleCatalogEntry, ScheduleTask } from '@deepseek-ai/dsh-schedule'
|
||||
import { expect, it } from 'vitest'
|
||||
import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts'
|
||||
|
||||
const id = 'retained-remote-task' as DailyScheduleRecord['id']
|
||||
const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['sessionId']
|
||||
const sessionId = 'unloaded-owner' as SessionId
|
||||
const record: DailyScheduleRecord = {
|
||||
id, kind: 'daily', title: 'Retained instruction', prompt: 'Retained instruction\nwith a second line',
|
||||
time: '09:00:00.125', timeZone: 'Asia/Shanghai', scheduledAt: '2099-01-01T01:00:00.125Z',
|
||||
@@ -20,15 +24,33 @@ const task = {
|
||||
sessionId, record, status: 'active', lastDelivery,
|
||||
deliveryHistory: { earlierRecordsUnavailable: false, records: [{ ...lastDelivery, prompt: record.prompt }] },
|
||||
}
|
||||
const taskPath = (root: string) => join(root, 'home', 'storages', 'schedule.json')
|
||||
|
||||
async function seed(root: string, bytes: string): Promise<void> {
|
||||
await mkdir(join(root, 'home', 'storages'), { recursive: true })
|
||||
await writeFile(taskPath(root), bytes)
|
||||
const source = `${JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks: { [id]: task } } }, null, 2)}\n`
|
||||
const sourcePath = (root: string) => join(root, 'home', 'storages', 'schedule.json')
|
||||
const receipt = { ...lastDelivery, targetSessionId: sessionId }
|
||||
const historyRequest = { request: { id, limit: 20 } }
|
||||
const expectedHistory = {
|
||||
ok: true,
|
||||
value: {
|
||||
id, records: [{ ...receipt, prompt: record.prompt }], earlierRecordsUnavailable: false,
|
||||
earlierRecordsPruned: false, retention: { days: 30, records: 200 },
|
||||
},
|
||||
}
|
||||
|
||||
function encoded(value: unknown): string {
|
||||
return `${JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks: { [id]: value } } }, null, 2)}\n`
|
||||
/** Catalog entry retains the stored daily rule and has no invented creation or change times. */
|
||||
const retainedEntry = (title: string) => expect.objectContaining({
|
||||
id, title, prompt: record.prompt, scheduledAt: record.scheduledAt, target: { kind: 'session', sessionId },
|
||||
involvedSessionIds: [sessionId], status: 'active', lastDelivery: receipt,
|
||||
kind: 'daily', time: record.time, timeZone: record.timeZone,
|
||||
}) as object
|
||||
|
||||
/** Seed the existing domain or the exact bytes a preceding Host edited. */
|
||||
async function seed(root: string, bytes = source): Promise<void> {
|
||||
await mkdir(join(root, 'home', 'storages'), { recursive: true })
|
||||
await writeFile(sourcePath(root), bytes)
|
||||
}
|
||||
|
||||
async function readStore(root: string): Promise<string> {
|
||||
return readFile(sourcePath(root), 'utf8')
|
||||
}
|
||||
|
||||
async function connect(url: string, signal: AbortSignal) {
|
||||
@@ -48,71 +70,84 @@ async function connect(url: string, signal: AbortSignal) {
|
||||
}
|
||||
}
|
||||
|
||||
it('edits a retained task through built Remote bindings and preserves its identity and seeded history after cold startup', async (test) => {
|
||||
let persisted = encoded(task)
|
||||
let updated = record
|
||||
const historyRequest = { request: { sessionId, id, limit: 20 } }
|
||||
const expectedHistory = {
|
||||
ok: true,
|
||||
value: {
|
||||
id, records: task.deliveryHistory.records, earlierRecordsUnavailable: false,
|
||||
earlierRecordsPruned: false, retention: { days: 30, records: 200 },
|
||||
},
|
||||
}
|
||||
it('edits an existing task through built Remote bindings and keeps its identity and seeded history after cold startup', async (test) => {
|
||||
let persisted: string | undefined
|
||||
let renamed: ScheduleCatalogEntry | undefined
|
||||
await withDefaultWeb(test, async ({ root, url }) => {
|
||||
const rpc = await connect(url, test.signal)
|
||||
expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [{ ...record, sessionId, status: 'active', lastDelivery }] })
|
||||
const catalog = await rpc('schedule/catalog', {})
|
||||
expect(catalog).toEqual({ ok: true, value: [retainedEntry(record.title)] })
|
||||
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
|
||||
const target = new Date(Date.now() + 12 * 60 * 60 * 1_000)
|
||||
target.setUTCMilliseconds(125)
|
||||
updated = { ...record, time: target.toISOString().slice(11, 23), timeZone: 'UTC', scheduledAt: target.toISOString() }
|
||||
const change = { kind: 'daily', daily: { time: updated.time, time_zone: 'UTC' } }
|
||||
expect(await rpc('schedule/update', { request: { sessionId, id, expected: record, change } }))
|
||||
.toEqual({ ok: true, value: { id, updated: true, record: updated } })
|
||||
persisted = await readFile(taskPath(root), 'utf8')
|
||||
expect(JSON.parse(persisted)).toEqual({
|
||||
unit: { name: 'schedule', version: 1 },
|
||||
global: null,
|
||||
tables: { tasks: { [id]: { ...task, record: updated } } },
|
||||
const { value: [retained] } = catalog as { value: ScheduleCatalogEntry[] }
|
||||
const rename = {
|
||||
request: { id, action: 'update', patch: { title: 'Renamed instruction' }, expectedUpdatedAt: retained?.updatedAt ?? null },
|
||||
}
|
||||
const edited = await rpc('schedule/edit', rename)
|
||||
expect(edited).toEqual({ ok: true, value: retainedEntry('Renamed instruction') })
|
||||
renamed = (edited as { value: ScheduleCatalogEntry }).value
|
||||
persisted = await readStore(root)
|
||||
const document = storedUnit(persisted)
|
||||
expect(document.unit).toEqual({ name: 'schedule', version: 2 })
|
||||
expect(document.global).toBeNull()
|
||||
expect(document.tables.tasks[id]!).toMatchObject({ record: { ...record, title: 'Renamed instruction' },
|
||||
target: { kind: 'session', sessionId }, status: 'active', updatedAt: renamed.updatedAt, lastDelivery: receipt })
|
||||
expect(document.tables.tasks[id]!).not.toHaveProperty('sessionId')
|
||||
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
|
||||
|
||||
expect(await rpc('schedule/edit', rename)).toEqual({ ok: true, value: { id, code: 'schedule_conflict' } })
|
||||
expect(await rpc('schedule/edit', { request: { id: 'missing-task', action: 'delete' } }))
|
||||
.toEqual({ ok: true, value: { id: 'missing-task', code: 'schedule_not_found' } })
|
||||
expect(await readStore(root)).toEqual(persisted)
|
||||
}, { prepare: root => seed(root) })
|
||||
|
||||
await withDefaultWeb(test, async ({ root, url }) => {
|
||||
const rpc = await connect(url, test.signal)
|
||||
expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [renamed] })
|
||||
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
|
||||
expect(await readStore(root)).toEqual(persisted)
|
||||
await rpc('session/create', { request: { sessionId, cwd: root } })
|
||||
const rrule = 'DTSTART;TZID=Asia/Shanghai:20990101T090000\nRRULE:FREQ=DAILY'
|
||||
const replaced = await rpc('schedule/edit', {
|
||||
request: { id, action: 'update', patch: { timing: { kind: 'rrule', rrule } }, expectedUpdatedAt: renamed?.updatedAt },
|
||||
})
|
||||
expect(replaced).toEqual({
|
||||
ok: true, value: expect.objectContaining({ id, title: 'Renamed instruction', scheduledAt: '2099-01-01T01:00:00.000Z' }) as object,
|
||||
})
|
||||
const stored = storedUnit(await readStore(root)).tables.tasks[id]!
|
||||
expect(stored.record).toMatchObject({ kind: 'rrule', rrule })
|
||||
expect(stored).not.toHaveProperty('sessionId')
|
||||
expect(stored).toMatchObject({ target: { kind: 'session', sessionId }, lastDelivery: receipt })
|
||||
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
|
||||
|
||||
expect(await rpc('schedule/update', { request: { sessionId, id, expected: record, change } }))
|
||||
.toEqual({ ok: true, value: { id, updated: false, code: 'schedule_conflict' } })
|
||||
expect(await rpc('schedule/update', { request: { sessionId: 'other-owner', id, expected: updated, change } }))
|
||||
.toEqual({ ok: true, value: { id, updated: false, code: 'schedule_not_found' } })
|
||||
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
|
||||
}, { prepare: root => seed(root, persisted) })
|
||||
|
||||
await withDefaultWeb(test, async ({ root, url }) => {
|
||||
const rpc = await connect(url, test.signal)
|
||||
expect(await rpc('schedule/catalog', {})).toEqual({ ok: true, value: [{ ...updated, sessionId, status: 'active', lastDelivery }] })
|
||||
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
|
||||
expect(await rpc('schedule/update', { request: {
|
||||
sessionId, id, expected: updated, change: { kind: 'daily', daily: { time: updated.time, time_zone: 'Etc/UTC' } },
|
||||
} })).toEqual({ ok: true, value: { id, updated: false, record: updated } })
|
||||
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
|
||||
}, { prepare: root => seed(root, persisted) })
|
||||
})
|
||||
|
||||
it('creates and idempotently adopts the explicit Session id bound to seeded task data without touching its task file', async (test) => {
|
||||
const persisted = encoded(task)
|
||||
const expectedList = { ok: true, value: [record] }
|
||||
const expectedHistory = {
|
||||
ok: true,
|
||||
value: {
|
||||
id, records: task.deliveryHistory.records, earlierRecordsUnavailable: false,
|
||||
earlierRecordsPruned: false, retention: { days: 30, records: 200 },
|
||||
},
|
||||
}
|
||||
it('creates and idempotently adopts the explicit Session id bound to existing task data without touching its task files', async (test) => {
|
||||
await withDefaultWeb(test, async ({ root, url }) => {
|
||||
const rpc = await connect(url, test.signal)
|
||||
// A Schedule request waits for startup to validate the task store.
|
||||
const view = await rpc('schedule/view', { request: { id } })
|
||||
expect(view).toEqual({ ok: true, value: retainedEntry(record.title) })
|
||||
const retained = await readStore(root)
|
||||
const create = { request: { sessionId, cwd: root } }
|
||||
const expectedCreate = { ok: true, value: { sessionId, agentPreset: 'standard' } }
|
||||
expect(await rpc('session/create', create)).toEqual(expectedCreate)
|
||||
expect(await rpc('session/create', create)).toEqual(expectedCreate)
|
||||
expect(await rpc('schedule/list', { request: { sessionId } })).toEqual(expectedList)
|
||||
expect(await rpc('schedule/history', { request: { sessionId, id, limit: 20 } })).toEqual(expectedHistory)
|
||||
expect(await readFile(taskPath(root), 'utf8')).toBe(persisted)
|
||||
}, { prepare: root => seed(root, persisted) })
|
||||
expect(await rpc('schedule/view', { request: { id } })).toEqual(view)
|
||||
expect(await rpc('schedule/history', historyRequest)).toEqual(expectedHistory)
|
||||
expect(await readStore(root)).toEqual(retained)
|
||||
expect(await readFile(sourcePath(root), 'utf8')).toBe(source)
|
||||
}, { prepare: root => seed(root) })
|
||||
})
|
||||
|
||||
/** Read test-owned output without normalizing away the fields asserted below. */
|
||||
function storedUnit(text: string): {
|
||||
readonly unit: unknown
|
||||
readonly global: unknown
|
||||
readonly tables: { readonly tasks: Record<string, ScheduleTask> }
|
||||
} {
|
||||
return JSON.parse(text) as {
|
||||
unit: unknown
|
||||
global: unknown
|
||||
tables: { tasks: Record<string, ScheduleTask> }
|
||||
}
|
||||
}
|
||||
@@ -302,7 +302,7 @@ describe('the shipped Web composition', () => {
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'list_subagent_models', 'plugin_manager', 'present', 'read', 'read_image',
|
||||
'schedule_create', 'schedule_delete', 'schedule_list', 'schedule_update',
|
||||
'schedule_create', 'schedule_edit', 'schedule_list', 'schedule_view',
|
||||
'send_message', 'skill',
|
||||
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'wait_agent', 'web_fetch', 'web_search',
|
||||
'workflow', 'working_directory', 'write',
|
||||
@@ -1004,7 +1004,7 @@ describe('a forked session', () => {
|
||||
})
|
||||
|
||||
describe('a delegated child', () => {
|
||||
it.each([false, true])('inherits preset tools with seeded=%s and refuses child reminders', async (seeded) => {
|
||||
it.each([false, true])('inherits preset tools with seeded=%s and refuses child scheduled tasks', async (seeded) => {
|
||||
const parent = await ctx.agents.create({
|
||||
sessionId: SessionId(`preset-child-parent-${seeded}`),
|
||||
meta: { agentPreset: 'standard' },
|
||||
@@ -1026,10 +1026,12 @@ describe('a delegated child', () => {
|
||||
expect(toolNames(ctx, child.agent)).toContain('bash')
|
||||
expect(ctx.tools.schemas(child.agent)).toEqual(ctx.tools.schemas(parent.agent))
|
||||
const calls = [
|
||||
['schedule_create', { prompt: 'Check', after_seconds: 60, title: 'Check' }],
|
||||
['schedule_create', {
|
||||
title: 'Check', prompt: 'Check', rrule: 'DTSTART:20990101T010000Z\nRRULE:FREQ=DAILY;COUNT=1', target: 'this_session',
|
||||
}],
|
||||
['schedule_list', {}],
|
||||
['schedule_delete', { id: 'task-1' }],
|
||||
['schedule_update', { id: 'task-1', title: 'Check' }],
|
||||
['schedule_view', { id: 'task-1' }],
|
||||
['schedule_edit', { id: 'task-1', action: 'update', title: 'Check' }],
|
||||
] as const
|
||||
for (const [name, args] of calls) {
|
||||
expect(toolNames(ctx, child.agent)).toContain(name)
|
||||
@@ -1037,7 +1039,7 @@ describe('a delegated child', () => {
|
||||
callId: ToolCallId(`child-${name}`), signal: new AbortController().signal,
|
||||
name, arguments: args, agent: child.agent,
|
||||
})
|
||||
expect(result.value).toEqual({ code: 'subagent_session', message: 'A delegated subagent cannot use reminders.' })
|
||||
expect(result.value).toEqual({ code: 'subagent_session', message: 'A delegated subagent cannot use scheduled tasks.' })
|
||||
}
|
||||
expect(child.agent.session.header.agentPreset).toBe('standard')
|
||||
} finally {
|
||||
|
||||
@@ -227,9 +227,8 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
|
||||
})
|
||||
mock.unary('session/list', () => ok({ items: structuredClone(sessions) }))
|
||||
// The shipped composition mounts the `ui-schedule` row, so the Schedule
|
||||
// client reads the catalog and the per-Session list over this mock.
|
||||
// client reads the catalog over this mock.
|
||||
mock.unary('schedule/catalog', () => ok([]))
|
||||
mock.unary('schedule/list', () => ok([]))
|
||||
mock.unary('session/projections', (request: unknown) => {
|
||||
const sessionId = recordString(recordValue(request, 'request'), 'sessionId')
|
||||
const summary = sessions.find(candidate => candidate.sessionId === sessionId)
|
||||
|
||||
@@ -22,8 +22,8 @@ import {
|
||||
ScheduleId,
|
||||
createAfterScheduleRecord,
|
||||
createEveryScheduleRecord,
|
||||
foldScheduleEvents,
|
||||
type EveryScheduleRecord,
|
||||
type ScheduleRecord,
|
||||
} from '@deepseek-ai/dsh-schedule'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
@@ -155,7 +155,9 @@ class EveryReminderAdapter extends LlmAdapter {
|
||||
|
||||
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
this.requests.push(options)
|
||||
yield * textResponse(EVERY_REPLY)
|
||||
const last = options.messages.findLast(message => message.source?.kind === 'schedule')
|
||||
yield * textResponse(last?.content.some(block => block.type === 'text' && block.text.includes('This is a scheduled message from the user') && !block.text.includes('[SCHEDULE CHANGE]')) === true
|
||||
? EVERY_REPLY : 'Task change noted.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +186,21 @@ function localAt(epoch: number, timeZone: string): LocalAt {
|
||||
}
|
||||
}
|
||||
|
||||
/** One occurrence in the target-based tool's explicit local-time format. */
|
||||
function oneOccurrence(at: LocalAt): string {
|
||||
return `DTSTART;TZID=${at.time_zone}:${at.date.replaceAll('-', '')}T${at.time.replaceAll(':', '')}\nRRULE:FREQ=DAILY;COUNT=1`
|
||||
}
|
||||
|
||||
/** Historical rules retain their exact dispatch semantics in the Host task domain. */
|
||||
function hostTask(record: ScheduleRecord, sessionId: SessionId): ScheduleTask {
|
||||
const instant = new Date().toISOString()
|
||||
return {
|
||||
record, target: { kind: 'session', sessionId }, involvedSessionIds: [sessionId], status: 'active',
|
||||
createdAt: instant, updatedAt: instant,
|
||||
deliveryHistory: { records: [], earlierRecordsUnavailable: false, earlierRecordsPruned: false },
|
||||
}
|
||||
}
|
||||
|
||||
/** Dynamic model seam proving request-local browser context becomes an explicit At selector. */
|
||||
class BrowserZoneAtAdapter extends LlmAdapter {
|
||||
override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] }
|
||||
@@ -201,7 +218,7 @@ class BrowserZoneAtAdapter extends LlmAdapter {
|
||||
const target = Math.ceil((Date.now() + 5_000) / 1_000) * 1_000
|
||||
this.selectedAt = localAt(target, AT_BROWSER_ZONE)
|
||||
this.scheduledAt = new Date(target).toISOString()
|
||||
const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, title: AT_PROMPT, at: this.selectedAt })
|
||||
const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, title: AT_PROMPT, rrule: oneOccurrence(this.selectedAt), target: 'this_session' })
|
||||
const callId = ToolCallId('schedule-at-browser-zone')
|
||||
yield { type: 'block-start', index: 0, blockType: 'tool-call' }
|
||||
yield {
|
||||
@@ -224,7 +241,10 @@ class BrowserZoneAtAdapter extends LlmAdapter {
|
||||
yield { type: 'finish', reason: { kind: 'tool-calls' } }
|
||||
return
|
||||
}
|
||||
yield * textResponse(this.requests.length === 3 ? AT_ACK : AT_REPLY)
|
||||
const last = options.messages.findLast(message => message.source?.kind === 'schedule')
|
||||
yield * textResponse(this.requests.length === 3 ? AT_ACK
|
||||
: last?.content.some(block => block.type === 'text' && block.text.includes('This is a scheduled message from the user') && !block.text.includes('[SCHEDULE CHANGE]')) === true
|
||||
? AT_REPLY : 'Task change noted.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +269,7 @@ function requestText(options: GenerateOptions): string {
|
||||
function expectReminderFraming(options: GenerateOptions): void {
|
||||
const reminder = options.messages.find(message => (
|
||||
message.role === 'user' && message.source?.kind === 'schedule'
|
||||
&& message.content.some(block => block.type === 'text' && block.text.includes('This is a scheduled message from the user') && !block.text.includes('[SCHEDULE CHANGE]'))
|
||||
))
|
||||
expect(reminder?.role).toBe('user')
|
||||
const text = reminder?.content.find(block => block.type === 'text')?.text
|
||||
@@ -277,15 +298,11 @@ function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
||||
return conversationContextKey('assistant-step', `${String(event.data.turn)}:${String(event.data.step)}`)
|
||||
}
|
||||
|
||||
/** Wait until opening a persisted Session publishes its live Agent. */
|
||||
/** Restore a persisted Session explicitly when the fixture needs a tool-calling Agent. */
|
||||
async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<Agent> {
|
||||
const deadline = Date.now() + 30_000
|
||||
for (;;) {
|
||||
const found = scaffold.ctx.agents.get(sessionId)
|
||||
if (found !== undefined) return found
|
||||
if (Date.now() >= deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
const resolved = await scaffold.ctx.sessionController.resolveAgent(sessionId)
|
||||
if ('error' in resolved) throw new Error(`restoring session "${sessionId}" failed`)
|
||||
return resolved.agent
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
@@ -360,7 +377,8 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
callId: ToolCallId('schedule-after-create'),
|
||||
name: 'schedule_create',
|
||||
arguments: { prompt: AFTER_PROMPT, title: AFTER_PROMPT, after_seconds: 1 },
|
||||
arguments: { prompt: AFTER_PROMPT, title: AFTER_PROMPT,
|
||||
rrule: oneOccurrence(localAt(Math.ceil((Date.now() + 5_000) / 1_000) * 1_000, 'UTC')), target: 'this_session' },
|
||||
agent: afterHandle.agent,
|
||||
})
|
||||
if (afterCreated.isError) {
|
||||
@@ -372,11 +390,8 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
}
|
||||
expect(createdView.id).toMatch(/^schedule-/)
|
||||
expect(createdView).toMatchObject({
|
||||
kind: 'after',
|
||||
prompt: AFTER_PROMPT,
|
||||
afterSeconds: 1,
|
||||
state: 'scheduled',
|
||||
deliveryMode: 'host',
|
||||
target: { kind: 'session', sessionId: afterHandle.agent.id }, status: 'active',
|
||||
})
|
||||
afterAssistantReply = await waitForReply(afterHandle, AFTER_REPLY, 15_000)
|
||||
await afterHandle.agent.whenIdle()
|
||||
@@ -415,12 +430,10 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
const domain = scaffold.ctx.storageDomain.get('schedule')
|
||||
if (domain === undefined) throw new Error('Schedule domain was not opened')
|
||||
for (const record of everyRecords) {
|
||||
await domain.table('tasks').put(record.id, { sessionId: everyHandle.agent.id, record, status: 'active' })
|
||||
await domain.table('tasks').put(record.id, hostTask(record, everyHandle.agent.id))
|
||||
}
|
||||
const wake = await scaffold.ctx.schedule.create(everyHandle.agent.id, {
|
||||
prompt: 'Unused future scheduling wake', title: 'Future wake', after_seconds: 86_400,
|
||||
})
|
||||
await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id })
|
||||
const wake = await scaffold.ctx.schedule.create({ prompt: 'Unused future scheduling wake', title: 'Future wake', timing: { kind: 'after', after_seconds: 86_400 }, target: { kind: 'session', sessionId: everyHandle.agent.id } }, everyHandle.agent.id)
|
||||
await scaffold.ctx.schedule.edit({ action: 'delete', id: wake.id })
|
||||
await workspace.attachSession(everyHandle.agent.id)
|
||||
const everyListed = await executeToolFixture(scaffold.ctx, {
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
@@ -472,9 +485,8 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
await atSession.click()
|
||||
const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
|
||||
await composer.fill(AT_USER_PROMPT)
|
||||
const settled = scaffold.whenTurnSettled(60_000)
|
||||
await page.getByRole('button', { name: 'Send message', exact: true }).click()
|
||||
expect(await settled).toBe(atHandle.agent.id)
|
||||
await waitForReply(atHandle, AT_ACK, 60_000)
|
||||
await page.getByText(AT_ACK, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
atAssistantReply = await waitForReply(atHandle, AT_REPLY, 20_000)
|
||||
await atHandle.agent.whenIdle()
|
||||
@@ -518,18 +530,19 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
)
|
||||
await expectNoReminderEntry(page)
|
||||
await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).some(task => (
|
||||
task.sessionId === afterHandle.agent.id && task.status === 'inactive' && task.lastDelivery !== undefined
|
||||
task.target.kind === 'session' && task.target.sessionId === afterHandle.agent.id && task.status === 'ended' && task.lastDelivery !== undefined
|
||||
))).toBe(true)
|
||||
const delivered = (await scaffold.ctx.schedule.catalog()).find(task => task.sessionId === afterHandle.agent.id)
|
||||
const delivered = (await scaffold.ctx.schedule.catalog()).find(task => task.target.kind === 'session' && task.target.sessionId === afterHandle.agent.id)
|
||||
if (delivered?.lastDelivery === undefined) throw new Error('delivered one-shot receipt was not retained')
|
||||
expect(await scaffold.ctx.schedule.list({ sessionId: afterHandle.agent.id })).toEqual([])
|
||||
expect((await scaffold.ctx.schedule.list({ active: true })).items.filter(task =>
|
||||
task.target.kind === 'session' && task.target.sessionId === afterHandle.agent.id)).toEqual([])
|
||||
// Archiving the Session shown in the main pane returns the app to its
|
||||
// workspace-selection view and unmounts the Tasks page with it, so the
|
||||
// linked-Session assertions run with another Session in the main pane.
|
||||
await page.getByRole('treeitem', { name: /Explicit local-time reminder/ }).click()
|
||||
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
|
||||
const manager = page.getByTestId('task-manager-page')
|
||||
await manager.getByRole('button', { name: 'Inactive', exact: true }).click()
|
||||
await manager.getByRole('button', { name: 'Ended', exact: true }).click()
|
||||
await manager.getByRole('list', { name: 'Task catalog' })
|
||||
.getByRole('button', { name: AFTER_PROMPT, exact: true }).click()
|
||||
const detail = manager.getByRole('complementary', { name: 'Task details' })
|
||||
@@ -550,7 +563,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
await scaffold.ctx.workspaceRegistry.archiveSession(afterHandle.agent.id)
|
||||
try {
|
||||
await expect.poll(() => linkedSession.isDisabled(), { timeout: 15_000 }).toBe(true)
|
||||
expect(await detail.getByText('The original session is archived.', { exact: true }).count()).toBe(1)
|
||||
expect(await detail.getByText('The linked session is archived.', { exact: true }).count()).toBe(1)
|
||||
expect(await detail.getByText('Scheduled After follow-up', { exact: true }).count()).toBe(1)
|
||||
const moreActions = detail.getByRole('button', { name: 'Automation task actions', exact: true })
|
||||
await moreActions.click()
|
||||
@@ -562,7 +575,9 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
// must not reach the page's own handler and close the whole panel.
|
||||
await expect.poll(() => page.getByRole('menu').count(), { timeout: 5_000 }).toBe(0)
|
||||
await detail.waitFor({ timeout: 5_000 })
|
||||
expect((await scaffold.ctx.schedule.catalog()).find(task => task.id === delivered.id)).toEqual(delivered)
|
||||
expect((await scaffold.ctx.schedule.catalog()).find(task => task.id === delivered.id)).toMatchObject({
|
||||
id: delivered.id, status: 'ended', prompt: delivered.prompt, lastDelivery: delivered.lastDelivery,
|
||||
})
|
||||
} finally {
|
||||
await scaffold.ctx.workspaceRegistry.unarchiveSession(afterHandle.agent.id)
|
||||
}
|
||||
@@ -577,6 +592,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
const messages = everyHandle.agent.session.snapshotEvents().filter(event => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'schedule'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text.includes('This is a scheduled message from the user') && !block.text.includes('[SCHEDULE CHANGE]'))
|
||||
))
|
||||
expect(messages).toHaveLength(1)
|
||||
const texts = messages.map((event) => {
|
||||
@@ -587,9 +603,11 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
expect(texts.filter(text => text.includes(record.id))).toHaveLength(1)
|
||||
expect(texts.some(text => text.includes(record.prompt))).toBe(true)
|
||||
}
|
||||
expect(everyAdapter.requests).toHaveLength(1)
|
||||
for (const request of everyAdapter.requests) expectReminderFraming(request)
|
||||
const active = await scaffold.ctx.schedule.list({ sessionId: everyHandle.agent.id })
|
||||
const request = everyAdapter.requests.find(request => request.messages.some(message => message.content.some(block =>
|
||||
block.type === 'text' && block.text.includes('This is a scheduled message from the user') && !block.text.includes('[SCHEDULE CHANGE]'))))
|
||||
if (request === undefined) throw new Error('the recurring delivery did not reach the model')
|
||||
expectReminderFraming(request)
|
||||
const active = (await scaffold.ctx.schedule.list({ active: true })).items
|
||||
expect(active).toHaveLength(2)
|
||||
for (const record of active) {
|
||||
const original = everyRecords.find(candidate => candidate.id === record.id)
|
||||
@@ -611,11 +629,11 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
await captureStableAria(page, selector, scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
await page.getByRole('button', { name: '2 reminders', exact: true }).waitFor({ timeout: 15_000 })
|
||||
expect(await page.getByRole('list', { name: 'Active reminders' }).count()).toBe(0)
|
||||
await page.getByRole('button', { name: '2 automation tasks', exact: true }).waitFor({ timeout: 15_000 })
|
||||
expect(await page.getByRole('list', { name: 'Enabled automation tasks' }).count()).toBe(0)
|
||||
|
||||
const original = everyRecords[0]
|
||||
const first = await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })
|
||||
const first = await scaffold.ctx.schedule.history({ id: original.id, limit: 20 })
|
||||
if ('code' in first || first.records.length !== 1) throw new Error('expected one actual recurring receipt')
|
||||
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
|
||||
const manager = page.getByTestId('task-manager-page')
|
||||
@@ -631,17 +649,19 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
if (domain === undefined) throw new Error('Schedule domain was not opened')
|
||||
const latest = first.records[0]!
|
||||
// Make the next occurrence due through the fixture's existing stored-time input.
|
||||
const stored = hostTask(original, everyHandle.agent.id)
|
||||
const dueAt = new Date(Date.now() - 1_000).toISOString()
|
||||
await domain.table('tasks').put(original.id, {
|
||||
sessionId: everyHandle.agent.id,
|
||||
record: { ...original, scheduledAt: new Date(Date.now() - 1_000).toISOString() },
|
||||
status: 'active',
|
||||
lastDelivery: { scheduledAt: latest.scheduledAt, deliveredAt: latest.deliveredAt, messageId: latest.messageId },
|
||||
deliveryHistory: { records: [...first.records].reverse(), earlierRecordsUnavailable: first.earlierRecordsUnavailable },
|
||||
...stored, record: { ...stored.record, scheduledAt: dueAt },
|
||||
lastDelivery: { ...latest, targetSessionId: everyHandle.agent.id },
|
||||
deliveryHistory: { records: [...first.records].reverse().map(receipt => ({ ...receipt, targetSessionId: everyHandle.agent.id })),
|
||||
earlierRecordsUnavailable: first.earlierRecordsUnavailable },
|
||||
})
|
||||
const wake = await scaffold.ctx.schedule.create(everyHandle.agent.id, { prompt: 'Future history refresh wake', title: 'History refresh wake', after_seconds: 86_400 })
|
||||
await scaffold.ctx.schedule.delete({ sessionId: everyHandle.agent.id, id: wake.id })
|
||||
await expect.poll(() => receipts.count(), { timeout: 15_000 }).toBe(2)
|
||||
const saved = await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })
|
||||
const wake = await scaffold.ctx.schedule.create({ prompt: 'Future history refresh wake', title: 'History refresh wake', timing: { kind: 'after', after_seconds: 86_400 }, target: { kind: 'session', sessionId: everyHandle.agent.id } }, everyHandle.agent.id)
|
||||
await scaffold.ctx.schedule.edit({ action: 'delete', id: wake.id })
|
||||
// A second delivery waits for the Host's one-minute admission interval.
|
||||
await expect.poll(() => receipts.count(), { timeout: 75_000 }).toBe(2)
|
||||
const saved = await scaffold.ctx.schedule.history({ id: original.id, limit: 20 })
|
||||
if ('code' in saved) throw new Error('recurring delivery history was unavailable')
|
||||
expect(saved.records).toHaveLength(2)
|
||||
expect(saved.records[1]).toEqual(latest)
|
||||
@@ -652,28 +672,28 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
}
|
||||
expect(await detail.getByRole('tab', { name: 'Records', exact: true }).getAttribute('aria-selected')).toBe('true')
|
||||
await detail.getByRole('tab', { name: 'Rules', exact: true }).click()
|
||||
// The interval row states its quantity in the friendly unit the stored
|
||||
// seconds select: 3600 seconds is one whole hour, so the row shows 1 and
|
||||
// doubling the interval is two of those hours.
|
||||
const interval = detail.getByLabel('Repeat every', { exact: true })
|
||||
await detail.getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
await detail.getByRole('button', { name: /^Repeat / }).click()
|
||||
await page.getByRole('menuitem', { name: 'Every N hours', exact: true }).click()
|
||||
const interval = detail.getByLabel('Interval', { exact: true })
|
||||
expect(await interval.inputValue()).toBe('1')
|
||||
await interval.fill('2')
|
||||
// Run-time rows edit a local draft; the save bar is the only commit path.
|
||||
await detail.getByText('Unsaved changes', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await detail.getByRole('button', { name: 'Save changes', exact: true }).click()
|
||||
await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).find(task => task.id === original.id), { timeout: 15_000 })
|
||||
.toMatchObject({
|
||||
id: original.id, sessionId: everyHandle.agent.id, prompt: original.prompt,
|
||||
kind: 'every', everySeconds: EVERY_INTERVAL_SECONDS * 2, status: 'active',
|
||||
})
|
||||
expect(await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, id: original.id, limit: 20 })).toEqual(saved)
|
||||
await expect.poll(async () => rruleOf((await scaffold.ctx.schedule.catalog()).find(task => task.id === original.id)),
|
||||
{ timeout: 15_000 })
|
||||
.toContain('FREQ=HOURLY;INTERVAL=2')
|
||||
expect(await scaffold.ctx.schedule.history({ id: original.id, limit: 20 })).toEqual({
|
||||
...saved, records: saved.records.map(receipt => ({ ...receipt, targetSessionId: everyHandle.agent.id })),
|
||||
})
|
||||
await detail.getByRole('tab', { name: 'Records', exact: true }).click()
|
||||
await expect.poll(() => receipts.count()).toBe(2)
|
||||
// The linked-session row renders on the Rules view only.
|
||||
await detail.getByRole('tab', { name: 'Rules', exact: true }).click()
|
||||
await detail.getByRole('button', { name: 'Linked session Fixed-rate reminder batch: open original session', exact: true }).click()
|
||||
await page.getByRole('button', { name: '2 reminders', exact: true }).waitFor({ timeout: 15_000 })
|
||||
}, 60_000)
|
||||
await detail.getByRole('button', { name: /^Linked session.*: open linked session$/ }).click()
|
||||
await page.getByRole('button', { name: '2 automation tasks', exact: true }).waitFor({ timeout: 15_000 })
|
||||
})
|
||||
|
||||
it('uses request-local browser context to create an explicit local At reminder', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-at'))
|
||||
@@ -706,7 +726,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
event.type === 'tool/call' && event.data.name === 'schedule_create'
|
||||
))
|
||||
if (toolCall?.type !== 'tool/call') throw new Error('missing schedule_create tool call')
|
||||
expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, title: AT_PROMPT, at: selectedAt })
|
||||
expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, title: AT_PROMPT, rrule: oneOccurrence(selectedAt), target: 'this_session' })
|
||||
const result = atHandle.agent.session.snapshotEvents().find(event => (
|
||||
event.type === 'tool/result' && event.data.message.toolCallId === toolCall.data.callId
|
||||
))
|
||||
@@ -714,15 +734,18 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
expect(result.data.message.isError).not.toBe(true)
|
||||
const resultText = result.data.message.content.find(block => block.type === 'text')
|
||||
if (resultText?.type !== 'text') throw new Error('missing schedule_create response fields')
|
||||
expect(JSON.parse(resultText.text)).toMatchObject({ kind: 'at', prompt: AT_PROMPT, scheduledAt })
|
||||
expect(JSON.parse(resultText.text)).toMatchObject({ prompt: AT_PROMPT, scheduledAt,
|
||||
target: { kind: 'session', sessionId: atHandle.agent.id } })
|
||||
const reminders = atHandle.agent.session.snapshotEvents().filter(event => (
|
||||
event.type === 'user/message'
|
||||
&& event.data.source.kind === 'schedule'
|
||||
&& event.data.content.some(block => block.type === 'text' && block.text.includes('This is a scheduled message from the user') && !block.text.includes('[SCHEDULE CHANGE]'))
|
||||
))
|
||||
expect(reminders).toHaveLength(1)
|
||||
expect(await scaffold.ctx.schedule.list({ sessionId: atHandle.agent.id })).toEqual([])
|
||||
expect(atAdapter.requests).toHaveLength(4)
|
||||
const reminderRequest = atAdapter.requests[3]
|
||||
expect((await scaffold.ctx.schedule.list({ active: true })).items.filter(task =>
|
||||
task.target.kind === 'session' && task.target.sessionId === atHandle.agent.id)).toEqual([])
|
||||
const reminderRequest = atAdapter.requests.find(request => request.messages.some(message => message.content.some(block =>
|
||||
block.type === 'text' && block.text.includes('This is a scheduled message from the user') && !block.text.includes('[SCHEDULE CHANGE]'))))
|
||||
if (reminderRequest === undefined) throw new Error('model did not receive the At reminder')
|
||||
expectReminderFraming(reminderRequest)
|
||||
|
||||
@@ -768,16 +791,23 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
extraOverlayPath: [await writeEveryStepOverlay(root)],
|
||||
})
|
||||
await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard')
|
||||
const records = foldScheduleEvents(fixture.trim().split('\n').slice(1).map(line => JSON.parse(line) as SessionEvent)).active
|
||||
const events = fixture.trim().split('\n').slice(1).map(line => JSON.parse(line) as SessionEvent)
|
||||
const records = events.flatMap(event => event.type === 'schedule/change' && event.data.operation === 'create'
|
||||
? [event.data.schedule] : [])
|
||||
expect(records.map(record => record.id)).toEqual(Object.keys(CATALOG_TITLES))
|
||||
const domain = scaffold.ctx.storageDomain.get('schedule')
|
||||
if (domain === undefined) throw new Error('Schedule domain was not opened')
|
||||
// The fixture holds the bytes its writer produced before titles existed, so
|
||||
// each recorded task is seeded under the name this catalog lane asserts.
|
||||
const seeded = records.map(record => ({ ...record, title: CATALOG_TITLES[record.id] }))
|
||||
const seeded = records.map((record) => {
|
||||
const title = CATALOG_TITLES[record.id]
|
||||
if (title === undefined) throw new Error(`unnamed catalog fixture task: ${record.id}`)
|
||||
return { ...record, title }
|
||||
})
|
||||
for (const record of seeded) {
|
||||
await domain.table('tasks').put(record.id, { sessionId: CATALOG_SESSION_ID, record, status: 'active' })
|
||||
await domain.table('tasks').put(record.id, hostTask(record, CATALOG_SESSION_ID))
|
||||
}
|
||||
expect(await scaffold.ctx.schedule.list({ sessionId: CATALOG_SESSION_ID })).toEqual(seeded)
|
||||
expect((await scaffold.ctx.schedule.list({ active: true })).items).toMatchObject(seeded)
|
||||
expect(scaffold.ctx.agents.get(CATALOG_SESSION_ID)).toBeUndefined()
|
||||
|
||||
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
|
||||
@@ -816,9 +846,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
it('manages cross-Session tasks from the sidebar without activating their Sessions', async () => {
|
||||
const otherSessionId = SessionId('schedule-manager-other-session')
|
||||
await seedSession(scaffold, await readFile(CATALOG_FIXTURE, 'utf8'), otherSessionId, 'standard')
|
||||
const other = await scaffold.ctx.schedule.create(otherSessionId, {
|
||||
prompt: 'Inspect the second workspace deployment', title: 'Second workspace deployment', at: '2099-08-25T12:08:00.000Z',
|
||||
})
|
||||
const other = await scaffold.ctx.schedule.create({ prompt: 'Inspect the second workspace deployment', title: 'Second workspace deployment', timing: { kind: 'at', at: '2099-08-25T12:08:00.000Z' }, target: { kind: 'session', sessionId: otherSessionId } }, otherSessionId)
|
||||
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
|
||||
const manager = page.getByTestId('task-manager-page')
|
||||
await manager.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
|
||||
@@ -831,7 +859,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
// unrestricted state; the repeating task is reached by name instead of a
|
||||
// retired type chip.
|
||||
const statuses = manager.getByRole('group', { name: 'Task status' })
|
||||
expect(await statuses.getByRole('button').count()).toBe(3)
|
||||
expect(await statuses.getByRole('button').count()).toBe(4)
|
||||
await manager.getByRole('button', { name: 'All', exact: true }).click()
|
||||
await expect.poll(() => tasks.getByRole('listitem').count()).toBe(4)
|
||||
expect(await tasks.textContent()).toContain('Check exact cadence')
|
||||
@@ -850,8 +878,8 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
await page.setViewportSize({ width: 600, height: 340 })
|
||||
// The linked Session is a seat in the detail's tab strip, above the rule's
|
||||
// scroll region, so it stays fully visible however far that region scrolls.
|
||||
const contextRow = details.locator('[class*="detailContext"]')
|
||||
const linkedSession = contextRow.getByRole('button', { name: 'Linked session: open original session', exact: true })
|
||||
const contextRow = details.locator('[class*="hostSessionFooter"]')
|
||||
const linkedSession = contextRow.getByRole('button', { name: 'Linked session: open linked session', exact: true })
|
||||
expect(await contextRow.getByRole('button').count()).toBe(1)
|
||||
expect(await linkedSession.textContent()).toContain(otherSessionId)
|
||||
expect(await contextRow.evaluate(element => element.closest('[class*="detailScroll"]') === null)).toBe(true)
|
||||
@@ -887,9 +915,9 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
await tasks.getByRole('button', { name: 'Check exact cadence', exact: true }).click()
|
||||
// This Session's catalog row carries no title before the Session is opened,
|
||||
// so its link shows the Session id and is named without one.
|
||||
await manager.getByRole('button', { name: 'Linked session: open original session', exact: true }).click()
|
||||
await manager.getByRole('button', { name: /^Linked session.*: open linked session$/ }).click()
|
||||
await liveAgent(scaffold, CATALOG_SESSION_ID)
|
||||
await page.getByRole('button', { name: '3 reminders', exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: '3 automation tasks', exact: true }).waitFor()
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
@@ -935,20 +963,20 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
|
||||
const manager = page.getByTestId('task-manager-page')
|
||||
await manager.getByRole('button', { name: 'Check exact cadence', exact: true }).click()
|
||||
await manager.getByRole('button', { name: `Linked session ${CATALOG_TITLE}: open original session`, exact: true }).click()
|
||||
await manager.getByRole('button', { name: /^Linked session.*: open linked session$/ }).click()
|
||||
await liveAgent(scaffold, CATALOG_SESSION_ID)
|
||||
await page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
.getByText(CATALOG_TITLE, { exact: true }).waitFor({ timeout: 15_000 })
|
||||
|
||||
const trigger = page.getByRole('button', { name: '3 reminders' })
|
||||
const trigger = page.getByRole('button', { name: '3 automation tasks' })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
const catalog = page.getByRole('list', { name: 'Active reminders' })
|
||||
const catalog = page.getByRole('list', { name: 'Enabled automation tasks' })
|
||||
await catalog.waitFor({ timeout: 10_000 })
|
||||
expect(await catalog.getByRole('listitem').count()).toBe(3)
|
||||
const lightLayout = await page.evaluate(() => {
|
||||
const triggerElement = document.querySelector('button[aria-label="3 reminders"]')
|
||||
const catalogElement = document.querySelector('[aria-label="Active reminders"]')
|
||||
const triggerElement = document.querySelector('button[aria-label="3 automation tasks"]')
|
||||
const catalogElement = document.querySelector('[aria-label="Enabled automation tasks"]')
|
||||
if (!(triggerElement instanceof HTMLElement) || !(catalogElement instanceof HTMLElement)) {
|
||||
throw new Error('active reminder trigger or catalog is not mounted')
|
||||
}
|
||||
@@ -983,7 +1011,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
// pushing the popover past its own width, and a longer stored name wraps
|
||||
// through the same `overflow-wrap: anywhere` declaration the stylesheet
|
||||
// states for the title.
|
||||
const longPrompt = longRow.getByRole('button', { name: 'Open reminder details: Join release review' })
|
||||
const longPrompt = longRow.getByRole('button', { name: 'Open automation task details: Join release review' })
|
||||
const promptLayout = await longPrompt.evaluate(element => ({
|
||||
clientWidth: element.clientWidth,
|
||||
scrollWidth: element.scrollWidth,
|
||||
@@ -1046,7 +1074,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
expect(darkBackground).not.toBe(lightLayout.background)
|
||||
await compareOrRefreshGolden(
|
||||
CATALOG_EXPECTED,
|
||||
await captureStableAria(page, '[aria-label="Active reminders"]', scaffold.workspaceCwd),
|
||||
await captureStableAria(page, '[aria-label="Enabled automation tasks"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
|
||||
@@ -1069,16 +1097,16 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
expect(metadataText).toMatch(/\d{1,2}:\d{2}/)
|
||||
// The delete glyph is an aria-hidden SVG, so the ARIA golden cannot prove it
|
||||
// still renders; the DOM has to.
|
||||
const deleteButton = catalog.getByRole('button', { name: /^Delete reminder:/ }).first()
|
||||
const deleteButton = catalog.getByRole('button', { name: /^Delete automation task:/ }).first()
|
||||
await expect.poll(() => deleteButton.locator('svg').count(), { timeout: 15_000 }).toBe(1)
|
||||
|
||||
for (let remaining = 3; remaining > 0; remaining--) {
|
||||
await catalog.getByRole('button', { name: /^Delete reminder:/ }).first().click()
|
||||
await expect.poll(() => catalog.getByRole('button', { name: /^Delete reminder:/ }).count(), { timeout: 15_000 })
|
||||
await catalog.getByRole('button', { name: /^Delete automation task:/ }).first().click()
|
||||
await expect.poll(() => catalog.getByRole('button', { name: /^Delete automation task:/ }).count(), { timeout: 15_000 })
|
||||
.toBe(remaining - 1)
|
||||
}
|
||||
await expectNoReminderEntry(page)
|
||||
expect(await scaffold.ctx.schedule.list({ sessionId: CATALOG_SESSION_ID })).toEqual([])
|
||||
expect((await scaffold.ctx.schedule.list({ active: true })).items).toEqual([])
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await expectNoReminderEntry(page)
|
||||
@@ -1100,9 +1128,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
|
||||
it('shows the pruning footer only for confirmed cleanup after the final saved page', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-retention'))
|
||||
const record = await scaffold.ctx.schedule.create(CATALOG_SESSION_ID, {
|
||||
prompt: 'Review retained records', title: 'History retention review', at: '2099-09-01T12:00:00.000Z',
|
||||
})
|
||||
const record = await scaffold.ctx.schedule.create({ prompt: 'Review retained records', title: 'History retention review', timing: { kind: 'at', at: '2099-09-01T12:00:00.000Z' }, target: { kind: 'session', sessionId: CATALOG_SESSION_ID } }, CATALOG_SESSION_ID)
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
|
||||
const manager = page.getByTestId('task-manager-page')
|
||||
@@ -1119,10 +1145,10 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
const domain = scaffold.ctx.storageDomain.get('schedule')
|
||||
if (domain === undefined) throw new Error('Schedule domain was not opened')
|
||||
const instant = new Date().toISOString()
|
||||
const receipt = { scheduledAt: instant, deliveredAt: instant, messageId: MessageId('retention-legacy') }
|
||||
const receipt = { scheduledAt: instant, deliveredAt: instant, messageId: MessageId('retention-legacy'), targetSessionId: CATALOG_SESSION_ID }
|
||||
await rulesTab.click()
|
||||
await domain.table('tasks').put(record.id, {
|
||||
sessionId: CATALOG_SESSION_ID, record, status: 'inactive', lastDelivery: receipt,
|
||||
...hostTask(record, CATALOG_SESSION_ID), status: 'ended', lastDelivery: receipt,
|
||||
deliveryHistory: { records: [receipt], earlierRecordsUnavailable: true },
|
||||
})
|
||||
await recordsTab.click()
|
||||
@@ -1131,13 +1157,16 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
expect(await notice.count()).toBe(0)
|
||||
await rulesTab.click()
|
||||
let task: ScheduleTask = {
|
||||
sessionId: CATALOG_SESSION_ID, record, status: 'inactive',
|
||||
deliveryHistory: { records: [], earlierRecordsUnavailable: false },
|
||||
record,
|
||||
target: { kind: 'session', sessionId: CATALOG_SESSION_ID },
|
||||
involvedSessionIds: [CATALOG_SESSION_ID], status: 'ended',
|
||||
createdAt: instant, updatedAt: instant,
|
||||
deliveryHistory: { records: [], earlierRecordsUnavailable: false, earlierRecordsPruned: false },
|
||||
}
|
||||
for (let index = 0; index < 23; index++) {
|
||||
const time = new Date(Date.now() - (index === 0 ? 40 * 86_400_000 : (23 - index) * 3_600_000)).toISOString()
|
||||
task = { ...task, ...appendDelivery(task, {
|
||||
scheduledAt: time, deliveredAt: time, messageId: MessageId(`retention-${index}`),
|
||||
scheduledAt: time, deliveredAt: time, messageId: MessageId(`retention-${index}`), targetSessionId: CATALOG_SESSION_ID,
|
||||
}, { days: 30, records: 200 }) }
|
||||
}
|
||||
expect(task.deliveryHistory?.records).toHaveLength(22)
|
||||
@@ -1153,7 +1182,8 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
await info.click()
|
||||
const bounds = detail.getByText('Each task keeps up to 200 delivery records from the last 30 days.', { exact: true })
|
||||
await bounds.waitFor()
|
||||
expect(await detail.locator('footer').innerText()).toMatchInlineSnapshot(`
|
||||
const retentionFooter = detail.locator('[class*="retentionEnd"]')
|
||||
expect(await retentionFooter.innerText()).toMatchInlineSnapshot(`
|
||||
"Earlier delivery records have been cleared
|
||||
|
||||
Each task keeps up to 200 delivery records from the last 30 days.
|
||||
@@ -1163,15 +1193,17 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
await info.click()
|
||||
expect(await bounds.count()).toBe(0)
|
||||
const panelBox = await detail.boundingBox()
|
||||
const footerBox = await detail.locator('footer').boundingBox()
|
||||
const footerBox = await retentionFooter.boundingBox()
|
||||
const sessionFooterBox = await detail.locator('[class*="hostSessionFooter"]').boundingBox()
|
||||
expect(panelBox).not.toBeNull()
|
||||
expect(footerBox).not.toBeNull()
|
||||
expect(Math.abs(footerBox!.x - panelBox!.x)).toBeLessThan(2)
|
||||
expect(Math.abs(footerBox!.y + footerBox!.height - panelBox!.y - panelBox!.height)).toBeLessThan(2)
|
||||
expect(await detail.locator('footer').evaluate(element => getComputedStyle(element).borderTopWidth)).toBe('0px')
|
||||
expect(sessionFooterBox).not.toBeNull()
|
||||
expect(footerBox!.y + footerBox!.height).toBeLessThanOrEqual(sessionFooterBox!.y)
|
||||
expect(await retentionFooter.evaluate(element => getComputedStyle(element).borderTopWidth)).toBe('0px')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await scaffold.ctx.schedule.delete({ sessionId: CATALOG_SESSION_ID, id: record.id })
|
||||
await scaffold.ctx.schedule.edit({ action: 'delete', id: record.id })
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
@@ -1182,20 +1214,22 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
const clock = new Intl.DateTimeFormat('en-GB-u-nu-latn', {
|
||||
timeZone, hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
||||
})
|
||||
const time = clock.format(new Date(Date.now() + 3_600_000))
|
||||
const at = localAt(Math.ceil((Date.now() + 3_600_000) / 60_000) * 60_000, timeZone)
|
||||
const time = at.time
|
||||
const rrule = `DTSTART;TZID=${timeZone}:${at.date.replaceAll('-', '')}T${time.replaceAll(':', '')}\nRRULE:FREQ=DAILY`
|
||||
const prompt = 'Inspect the daily calendar rule'
|
||||
const result = await executeToolFixture(scaffold.ctx, {
|
||||
signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-daily-catalog-create'),
|
||||
name: 'schedule_create', arguments: { prompt, title: prompt, daily: { time, time_zone: timeZone } }, agent,
|
||||
name: 'schedule_create', arguments: { prompt, title: prompt, rrule, target: 'this_session' }, agent,
|
||||
})
|
||||
const created = (await scaffold.ctx.schedule.catalog()).find(task => (
|
||||
task.sessionId === CATALOG_SESSION_ID && task.prompt === prompt
|
||||
task.target.kind === 'session' && task.target.sessionId === CATALOG_SESSION_ID && task.prompt === prompt
|
||||
))
|
||||
if (created === undefined) throw new Error('daily tool did not create a stored task')
|
||||
try {
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.value).toMatchObject({ kind: 'daily', time: `${time}.000`, timeZone })
|
||||
expect(created).toMatchObject({ kind: 'daily', time: `${time}.000`, timeZone, status: 'active' })
|
||||
expect(result.value).toMatchObject({ rrule, target: { kind: 'session', sessionId: CATALOG_SESSION_ID } })
|
||||
expect(created).toMatchObject({ rrule, status: 'active' })
|
||||
expect(clock.format(new Date(created.scheduledAt))).toBe(time)
|
||||
await page.clock.setFixedTime(new Date())
|
||||
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
|
||||
@@ -1219,6 +1253,7 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
const detail = manager.getByRole('complementary', { name: 'Task details' })
|
||||
expect(await detail.getByText(frequency, { exact: true }).count()).toBe(1)
|
||||
expect(await detail.getByText('Once', { exact: true }).count()).toBe(0)
|
||||
await detail.getByRole('button', { name: 'Edit', exact: true }).click()
|
||||
const runTime = detail.getByRole('region', { name: 'Run time', exact: true })
|
||||
const timeField = runTime.getByRole('button', { name: 'Time', exact: true })
|
||||
// The Time zone row is a menu trigger button; the zone is chosen, never typed.
|
||||
@@ -1227,37 +1262,36 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
// Zone rows lead with the current offset and the localized zone name, so
|
||||
// the browser's own zone is the row marked as the system one.
|
||||
await page.getByRole('menuitem', { name: /China Standard Time \(system\)$/ }).click()
|
||||
// The Time row opens three clock columns; a pick stages the clock itself,
|
||||
// and the stored rule keeps the millisecond precision the picker writes.
|
||||
// Daily guided rules keep minute precision; a pick stages the clock.
|
||||
await timeField.click()
|
||||
expect(await timeField.getAttribute('aria-expanded')).toBe('true')
|
||||
await page.getByRole('listbox', { name: 'Hour', exact: true }).getByRole('option', { name: '12', exact: true }).click()
|
||||
await page.getByRole('listbox', { name: 'Minute', exact: true }).getByRole('option', { name: '34', exact: true }).click()
|
||||
await page.getByRole('listbox', { name: 'Second', exact: true }).getByRole('option', { name: '56', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await timeField.textContent()).toBe('12:34:56')
|
||||
expect(await timeField.textContent()).toBe('12:34:00')
|
||||
await expect.poll(() => detail.getByText('Unsaved changes', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
// The staged draft reaches the Host only through the save bar's Save action.
|
||||
await detail.getByRole('button', { name: 'Save changes', exact: true }).click()
|
||||
await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).find(task => task.id === created.id), { timeout: 15_000 })
|
||||
.toMatchObject({ time: '12:34:56.000', timeZone: 'Asia/Shanghai' })
|
||||
await expect.poll(async () => rruleOf((await scaffold.ctx.schedule.catalog()).find(task => task.id === created.id)),
|
||||
{ timeout: 15_000 })
|
||||
.toContain('DTSTART;TZID=Asia/Shanghai:')
|
||||
const saved = (await scaffold.ctx.schedule.catalog()).find(task => task.id === created.id)
|
||||
expect(saved).toMatchObject({
|
||||
id: created.id, sessionId: CATALOG_SESSION_ID, prompt, status: 'active',
|
||||
kind: 'daily', time: '12:34:56.000', timeZone: 'Asia/Shanghai',
|
||||
id: created.id, target: { kind: 'session', sessionId: CATALOG_SESSION_ID }, prompt, status: 'active',
|
||||
})
|
||||
expect(rruleOf(saved)).toContain('BYHOUR=12;BYMINUTE=34')
|
||||
// A stored zone equal to the browser zone is omitted from the frequency line.
|
||||
await expect.poll(() => detail.getByText('Daily at 12:34:56', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await scaffold.ctx.schedule.history({ sessionId: CATALOG_SESSION_ID, id: created.id, limit: 20 }))
|
||||
await expect.poll(() => detail.getByText('Daily at 12:34', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect(await scaffold.ctx.schedule.history({ id: created.id, limit: 20 }))
|
||||
.toEqual({ id: created.id, records: [], earlierRecordsUnavailable: false,
|
||||
earlierRecordsPruned: false, retention: { days: 30, records: 200 } })
|
||||
// The created rule stays active, so the ended-status filter hides every
|
||||
// row; the retired type chips no longer exist.
|
||||
await manager.getByRole('button', { name: 'Inactive', exact: true }).click()
|
||||
await manager.getByRole('button', { name: 'Ended', exact: true }).click()
|
||||
await expect.poll(() => tasks.getByRole('listitem').count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await scaffold.ctx.schedule.delete({ sessionId: CATALOG_SESSION_ID, id: created.id })
|
||||
await scaffold.ctx.schedule.edit({ id: created.id, action: 'delete' })
|
||||
await page.clock.setFixedTime(new Date(CATALOG_NOW))
|
||||
}
|
||||
}, 60_000)
|
||||
@@ -1279,17 +1313,20 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
scheduledAt: record.scheduledAt,
|
||||
deliveredAt: new Date(Date.parse(record.scheduledAt) + 1_000).toISOString(),
|
||||
messageId: MessageId(`${id}-delivery`),
|
||||
targetSessionId: endedSession,
|
||||
}
|
||||
await domain.table('tasks').put(record.id, {
|
||||
sessionId: endedSession, record, status: 'inactive', lastDelivery: receipt,
|
||||
deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false },
|
||||
target: { kind: 'session', sessionId: endedSession }, involvedSessionIds: [endedSession],
|
||||
record, status: 'ended', lastDelivery: receipt,
|
||||
deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false, earlierRecordsPruned: false },
|
||||
})
|
||||
}
|
||||
// The running task is what the cleanup must leave alone. Creating it also
|
||||
// announces a committed Host change, so the open page reads the rows seeded
|
||||
// above without waiting for its own first catalog read.
|
||||
const kept = await scaffold.ctx.schedule.create(endedSession, {
|
||||
prompt: 'Keep this running task', title: 'Keep this running task', at: '2099-08-25T12:08:00.000Z',
|
||||
const kept = await scaffold.ctx.schedule.create({
|
||||
prompt: 'Keep this running task', title: 'Keep this running task',
|
||||
timing: { kind: 'at', at: '2099-08-25T12:08:00.000Z' },
|
||||
})
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
|
||||
@@ -1304,22 +1341,22 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
// The confirmation states the Host-side target and removes nothing until it
|
||||
// is confirmed.
|
||||
await more.click()
|
||||
await page.getByRole('menuitem', { name: 'Clear all inactive tasks', exact: true }).click()
|
||||
const confirmation = page.getByRole('dialog', { name: 'Clear inactive tasks (2)?' })
|
||||
await page.getByRole('menuitem', { name: 'Clear all ended tasks', exact: true }).click()
|
||||
const confirmation = page.getByRole('dialog', { name: 'Clear ended tasks (2)?' })
|
||||
await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click()
|
||||
expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2)
|
||||
expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'ended')).toHaveLength(2)
|
||||
|
||||
await more.click()
|
||||
await page.getByRole('menuitem', { name: 'Clear all inactive tasks', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Clear all ended tasks', exact: true }).click()
|
||||
await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click()
|
||||
await expect.poll(
|
||||
async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive'),
|
||||
async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'ended'),
|
||||
{ timeout: 15_000 },
|
||||
).toEqual([])
|
||||
const remaining = await scaffold.ctx.schedule.catalog()
|
||||
expect(remaining.some(task => endedIds.includes(task.id))).toBe(false)
|
||||
expect(remaining.map(task => task.title)).toContain('Keep this running task')
|
||||
await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
await page.getByText('Cleared 2 ended tasks.', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
// The trigger left with the rows it removed, so the page heading owns the
|
||||
// focus after the confirmation closes; this confirms the heading fallback
|
||||
// in a real browser.
|
||||
@@ -1332,15 +1369,20 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
await manager.getByRole('button', { name: 'All', exact: true }).click()
|
||||
await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' })
|
||||
.getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([])
|
||||
expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'ended')).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
// The shared page and Host outlive a failed assertion: remove every row
|
||||
// this scenario introduced so the next one starts from the same state. A
|
||||
// teardown rejection must not mask the assertion that failed first.
|
||||
await Promise.allSettled(
|
||||
[kept.id, ...endedIds].map(id => scaffold.ctx.schedule.delete({ sessionId: endedSession, id })),
|
||||
[kept.id, ...endedIds].map(id => scaffold.ctx.schedule.edit({ id, action: 'delete' })),
|
||||
)
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
/** Read a known RRULE task from the shared catalog after an explicit rule replacement. */
|
||||
function rruleOf(task: ScheduleRecord | undefined): string | undefined {
|
||||
return task?.kind === 'rrule' ? task.rrule : undefined
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/** Manual task creation and editing through the shipped Web composition. */
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { launchWebScaffold, watchConsole } from './scaffold.ts'
|
||||
|
||||
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 })
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
||||
try {
|
||||
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')
|
||||
const createStyles = await manager.getByRole('group', { name: '创建', exact: true }).getByRole('button').evaluateAll(buttons => buttons.map((button) => {
|
||||
const style = getComputedStyle(button)
|
||||
return { height: style.height, topLeft: style.borderTopLeftRadius, bottomLeft: style.borderBottomLeftRadius,
|
||||
topRight: style.borderTopRightRadius, bottomRight: style.borderBottomRightRadius }
|
||||
}))
|
||||
expect(createStyles).toEqual([
|
||||
{ height: '32px', topLeft: '16px', bottomLeft: '16px', topRight: '0px', bottomRight: '0px' },
|
||||
{ height: '32px', topLeft: '0px', bottomLeft: '0px', topRight: '16px', bottomRight: '16px' },
|
||||
])
|
||||
await manager.getByRole('button', { name: '已暂停', exact: true }).click()
|
||||
await manager.getByRole('heading', { name: '没有已暂停的自动化任务', exact: true }).waitFor()
|
||||
await manager.getByRole('button', { name: '选择创建方式', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: '手动创建', exact: true }).waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
await manager.getByRole('button', { name: '新建自动化任务', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '手动创建', exact: true })
|
||||
await dialog.getByRole('textbox', { name: '自动化任务名称', exact: true }).fill('AI 新闻简报')
|
||||
await dialog.getByRole('textbox', { name: '自动化任务内容', exact: true }).fill('整理过去 24 小时的 AI 新闻,附原文链接。')
|
||||
await dialog.getByRole('button', { name: /^重复 / }).click()
|
||||
await page.getByRole('menuitem', { name: '周一至周五', exact: true }).click()
|
||||
await dialog.getByRole('button', { name: '时间', exact: true }).click()
|
||||
await page.getByRole('listbox', { name: '时', exact: true }).getByRole('option', { name: '09', exact: true }).click()
|
||||
await page.getByRole('listbox', { name: '分', exact: true }).getByRole('option', { name: '00', exact: true }).click()
|
||||
await page.getByRole('listbox', { name: '秒', exact: true }).getByRole('option', { name: '00', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await dialog.getByRole('button', { name: /^执行位置 / }).click()
|
||||
await page.getByRole('menuitem', { name: '新建会话并复用 首次运行时创建,之后沿用这个会话', exact: true }).waitFor()
|
||||
expect(await page.getByRole('menuitem').count()).toBe(2)
|
||||
const workspace = dialog.getByRole('button', { name: /^关联工作区 / })
|
||||
await expect.poll(() => workspace.textContent()).toContain('默认工作区')
|
||||
await page.keyboard.press('Escape')
|
||||
// The workspace menu states each directory, so the default workspace names its folder.
|
||||
const defaultPath = scaffold.ctx.workspaceRegistry.list()[0]?.path
|
||||
if (defaultPath === undefined) throw new Error('first use did not register the default workspace')
|
||||
await workspace.click()
|
||||
await page.getByRole('menuitem').getByTitle(defaultPath, { exact: true }).waitFor()
|
||||
await page.keyboard.press('Escape')
|
||||
expect(await dialog.count()).toBe(1)
|
||||
await dialog.getByRole('button', { name: '创建', exact: true }).click()
|
||||
const detail = manager.getByRole('complementary', { name: '任务详情', exact: true })
|
||||
await detail.getByRole('heading', { name: 'AI 新闻简报', exact: true }).waitFor()
|
||||
expect(await manager.getByRole('button', { name: '全部', exact: true }).getAttribute('aria-pressed')).toBe('true')
|
||||
expect(await manager.getByRole('list', { name: '任务列表', exact: true }).getByRole('button', { name: 'AI 新闻简报', exact: true }).count()).toBe(1)
|
||||
await detail.getByText('待创建', { exact: true }).waitFor()
|
||||
const task = (await scaffold.ctx.schedule.catalog()).find(task => task.title === 'AI 新闻简报')
|
||||
if (task === undefined || task.target.kind !== 'new_once') throw new Error('manual task was not created with the reusable target')
|
||||
expect(task.target.create.cwd).toBe(defaultPath)
|
||||
await detail.getByRole('button', { name: '编辑', exact: true }).click()
|
||||
await detail.getByRole('textbox', { name: '自动化任务内容', exact: true }).fill('整理过去 24 小时的 AI 新闻,附原文链接,按重要性排序。')
|
||||
await detail.getByRole('button', { name: '保存修改', exact: true }).click()
|
||||
await detail.getByRole('textbox', { name: '自动化任务内容', exact: true }).waitFor({ state: 'detached' })
|
||||
await detail.getByText('整理过去 24 小时的 AI 新闻,附原文链接,按重要性排序。', { exact: true }).waitFor()
|
||||
const saved = await scaffold.ctx.schedule.view({ id: task.id })
|
||||
if ('code' in saved) throw new Error('edited task was not retained')
|
||||
expect(saved.prompt).toBe('整理过去 24 小时的 AI 新闻,附原文链接,按重要性排序。')
|
||||
await manager.getByRole('button', { name: '选择创建方式', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: '手动创建', exact: true }).click()
|
||||
await dialog.getByRole('textbox', { name: '自动化任务名称', exact: true }).fill('检查最新进展')
|
||||
await dialog.getByRole('textbox', { name: '自动化任务内容', exact: true }).fill('汇总最近的更新')
|
||||
await dialog.getByRole('button', { name: /^重复 / }).click()
|
||||
await page.getByRole('menuitem', { name: '每 N 分钟', exact: true }).click()
|
||||
await dialog.getByRole('spinbutton', { name: '重复间隔', exact: true }).fill('5')
|
||||
await dialog.getByRole('button', { name: /^执行位置 / }).click()
|
||||
await page.getByRole('menuitem', { name: '每次新建会话 每次运行都创建独立会话', exact: true }).click()
|
||||
await dialog.getByText('运行间隔至少 1分钟', { exact: true }).waitFor()
|
||||
const beforeCreate = Date.now()
|
||||
await dialog.getByRole('button', { name: '创建', exact: true }).click()
|
||||
await detail.getByRole('heading', { name: '检查最新进展', exact: true }).waitFor()
|
||||
const intervalTask = (await scaffold.ctx.schedule.catalog()).find(item => item.title === '检查最新进展')
|
||||
if (intervalTask === undefined) throw new Error('interval task was not created')
|
||||
expect(intervalTask.target.kind).toBe('new')
|
||||
expect(Date.parse(intervalTask.scheduledAt) - beforeCreate).toBeGreaterThan(240_000)
|
||||
expect(Date.parse(intervalTask.scheduledAt) - beforeCreate).toBeLessThanOrEqual(300_000)
|
||||
await detail.getByRole('button', { name: '暂停任务', exact: true }).click()
|
||||
await detail.getByRole('button', { name: '恢复任务', exact: true }).waitFor()
|
||||
expect(await detail.getByText('待创建', { exact: true }).count()).toBe(0)
|
||||
expect(await detail.getByText('最近运行会话', { exact: true }).count()).toBe(0)
|
||||
expect(await detail.getByText('运行间隔至少', { exact: false }).count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(scaffold.ctx.agents.get(task.target.sessionId)).toBeUndefined()
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
|
||||
it('creates and edits Custom Cron through the built UI while retaining its explicit zone and record kind', async () => {
|
||||
const scaffold = await launchWebScaffold({ firstUse: true })
|
||||
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined
|
||||
try {
|
||||
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()
|
||||
await page.getByRole('menuitem', { name: '手动创建', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '手动创建', exact: true })
|
||||
await dialog.getByRole('textbox', { name: '自动化任务名称', exact: true }).fill('Cron deployment check')
|
||||
await dialog.getByRole('textbox', { name: '自动化任务内容', exact: true }).fill('Check the deployment status.')
|
||||
await dialog.getByRole('button', { name: /^重复 / }).click()
|
||||
await page.getByRole('menuitem', { name: '自定义', exact: true }).click()
|
||||
await dialog.getByRole('textbox', { name: '自定义', exact: true }).fill('0 9 * * 1-5')
|
||||
await dialog.getByRole('button', { name: /^时区 / }).waitFor()
|
||||
await dialog.getByRole('button', { name: '创建', exact: true }).click()
|
||||
const detail = manager.getByRole('complementary', { name: '任务详情', exact: true })
|
||||
await detail.getByRole('heading', { name: 'Cron deployment check', exact: true }).waitFor()
|
||||
const created = (await scaffold.ctx.schedule.catalog()).find(task => task.title === 'Cron deployment check')
|
||||
if (created === undefined) throw new Error('Custom Cron creation did not store a task')
|
||||
expect(created).toMatchObject({ kind: 'cron', expression: '0 9 * * 1-5', timeZone: 'Asia/Shanghai' })
|
||||
expect(created).not.toHaveProperty('rrule')
|
||||
|
||||
await detail.getByRole('button', { name: '编辑', exact: true }).click()
|
||||
await detail.getByRole('textbox', { name: '自定义', exact: true }).fill(' ')
|
||||
await detail.getByRole('button', { name: '保存修改', exact: true }).click()
|
||||
await detail.getByRole('alert').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()
|
||||
await detail.getByRole('button', { name: '保存修改', exact: true }).click()
|
||||
await detail.getByRole('textbox', { name: '自定义', exact: true }).waitFor({ state: 'detached' })
|
||||
const edited = await scaffold.ctx.schedule.view({ id: created.id })
|
||||
expect(edited).toMatchObject({ id: created.id, kind: 'cron', expression: '0 10 * * 1-5', timeZone: 'Asia/Shanghai' })
|
||||
expect(edited).not.toHaveProperty('rrule')
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
try { await browser?.close() } finally { await scaffold.close() }
|
||||
}
|
||||
})
|
||||
@@ -4,12 +4,10 @@
|
||||
import type { Browser, Locator, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-schedule'
|
||||
import { launchWebScaffold, watchConsole } from './scaffold.ts'
|
||||
import { revealNavigationDrawer } from './support.ts'
|
||||
|
||||
const PHONE_TASK_SESSION = SessionId('schedule-phone-web-e2e')
|
||||
const PHONE_TASK_TITLE = 'Review overdue deployment'
|
||||
const PHONE_PAGE_WIDTH = 390
|
||||
|
||||
@@ -48,7 +46,7 @@ async function viewFacts(page: Page): Promise<ViewFacts> {
|
||||
width: window.innerWidth,
|
||||
documentWidth: document.documentElement.scrollWidth,
|
||||
smallestField: steps.length === 0 ? -1 : Math.min(...steps),
|
||||
name: step('[class*="_editName"]'),
|
||||
name: step('[class*="_editorField"] input'),
|
||||
search: step('[class*="_searchField"] input'),
|
||||
instruction: step('[class*="_instruction"]'),
|
||||
}
|
||||
@@ -59,8 +57,9 @@ it('keeps the automation page on screen with its text fields at the touch step',
|
||||
const scaffold = await launchWebScaffold()
|
||||
const url = scaffold.authenticatedUrl
|
||||
try {
|
||||
await scaffold.ctx.schedule.create(PHONE_TASK_SESSION, {
|
||||
prompt: 'Check the deployment log', title: PHONE_TASK_TITLE, at: '2099-08-25T12:08:00.000Z',
|
||||
await scaffold.ctx.schedule.create({
|
||||
prompt: 'Check the deployment log', title: PHONE_TASK_TITLE,
|
||||
timing: { kind: 'at', at: '2099-08-25T12:08:00.000Z' }, target: { kind: 'new_once' },
|
||||
})
|
||||
const browser: Browser = await chromium.launch()
|
||||
try {
|
||||
@@ -80,15 +79,15 @@ it('keeps the automation page on screen with its text fields at the touch step',
|
||||
search: '16px',
|
||||
instruction: 'absent',
|
||||
})
|
||||
// The detail keeps the floor too, and the name control keeps the 20px step
|
||||
// the floor must not flatten.
|
||||
// The editor keeps the touch floor for its name and instruction fields.
|
||||
await phonePanel.getByRole('button', { name: PHONE_TASK_TITLE, exact: true }).click()
|
||||
await phonePanel.locator('[class*="_detailScroll"]').waitFor({ timeout: 10_000 })
|
||||
await phonePanel.getByRole('button', { name: '编辑', exact: true }).click()
|
||||
await expect.poll(() => viewFacts(phonePage)).toEqual({
|
||||
width: PHONE_PAGE_WIDTH,
|
||||
documentWidth: PHONE_PAGE_WIDTH,
|
||||
smallestField: 16,
|
||||
name: '20px',
|
||||
name: '16px',
|
||||
search: '16px',
|
||||
instruction: '16px',
|
||||
})
|
||||
@@ -109,11 +108,12 @@ it('keeps the automation page on screen with its text fields at the touch step',
|
||||
})
|
||||
await desktopPanel.getByRole('button', { name: PHONE_TASK_TITLE, exact: true }).click()
|
||||
await desktopPanel.locator('[class*="_detailScroll"]').waitFor({ timeout: 10_000 })
|
||||
await desktopPanel.getByRole('button', { name: '编辑', exact: true }).click()
|
||||
await expect.poll(() => viewFacts(desktopPage)).toEqual({
|
||||
width: 1024,
|
||||
documentWidth: 1024,
|
||||
smallestField: 14,
|
||||
name: '20px',
|
||||
name: '14px',
|
||||
search: '14px',
|
||||
instruction: '14px',
|
||||
})
|
||||
|
||||
@@ -492,9 +492,9 @@ const EXPECTED_TOOLS = [
|
||||
'read',
|
||||
'read_image',
|
||||
'schedule_create',
|
||||
'schedule_delete',
|
||||
'schedule_edit',
|
||||
'schedule_list',
|
||||
'schedule_update',
|
||||
'schedule_view',
|
||||
'send_message',
|
||||
'skill',
|
||||
'subagent',
|
||||
|
||||
@@ -81,9 +81,9 @@ it.skipIf(MODE === 'record')('replays inherited schedule tools and refusals in a
|
||||
const results = child.snapshotEvents().slice(child.inheritedEventCount)
|
||||
.filter(event => event.type === 'tool/result')
|
||||
expect(results.map(event => event.data.message.content)).toEqual(Array.from({ length: 4 }, () => [
|
||||
{ type: 'text', text: '{"code":"subagent_session","message":"A delegated subagent cannot use reminders."}' },
|
||||
{ type: 'text', text: '{"code":"subagent_session","message":"A delegated subagent cannot use scheduled tasks."}' },
|
||||
]))
|
||||
expect(await scaffold.ctx.schedule.list({ sessionId: child.id })).toEqual([])
|
||||
expect((await scaffold.ctx.schedule.list({ active: true })).items).toEqual([])
|
||||
|
||||
if (MODE === 'refresh') {
|
||||
const replacements = refreshFixtureReplacements(raw.map((content, index) => ({
|
||||
|
||||
@@ -55,7 +55,7 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => {
|
||||
// turn-tail create card carries the same `data-tool` marker without being
|
||||
// one of the keyed call views this case walks.
|
||||
const rows = page.locator('[data-chat-call-id] [data-tool]')
|
||||
expect(await rows.count()).toBe(45)
|
||||
expect(await rows.count()).toBe(50)
|
||||
for (const row of await rows.all()) {
|
||||
await row.getByRole('button', { expanded: false }).first().click()
|
||||
expect(await row.getByRole('listitem').count()).toBeGreaterThan(0)
|
||||
@@ -70,8 +70,10 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => {
|
||||
await expect.poll(() => schema.textContent()).toContain('"properties"')
|
||||
const callIds = [
|
||||
...Array.from({ length: 3 }, (_, index) => `details-call-${index + 1}`),
|
||||
// The authored schedule_update round sits beside its schedule_create sibling.
|
||||
// The authored schedule_update round sits beside its schedule_create sibling, and the rounds of the current
|
||||
// Schedule tools follow them.
|
||||
'details-call-3b',
|
||||
...['c', 'd', 'e', 'f', 'g'].map(suffix => `details-call-3${suffix}`),
|
||||
...Array.from({ length: 41 }, (_, index) => `details-call-${index + 4}`),
|
||||
]
|
||||
const snapshots = await Promise.all(callIds.map(id =>
|
||||
@@ -79,9 +81,12 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => {
|
||||
const snapshot = snapshots.join('\n')
|
||||
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
|
||||
await page.setViewportSize({ width: 360, height: 800 })
|
||||
const card = page.locator('[data-chat-call-id] [data-tool="schedule_create"]')
|
||||
expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
const reminders = page.locator('[data-tool="schedule_list"]')
|
||||
const cards = page.locator('[data-chat-call-id] [data-tool="schedule_create"]')
|
||||
expect(await cards.count()).toBe(2)
|
||||
for (const card of await cards.all()) {
|
||||
expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true)
|
||||
}
|
||||
const reminders = page.locator('[data-chat-call-id="details-call-6"] [data-tool="schedule_list"]')
|
||||
await scrollIntoView(reminders)
|
||||
const list = reminders.getByRole('list').first()
|
||||
await list.evaluate((element) => {
|
||||
|
||||
@@ -211,6 +211,7 @@
|
||||
"tests/agent-preset-authoring.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/schedule-after.e2e.ts",
|
||||
"tests/schedule-manual.e2e.ts",
|
||||
"tests/feedback-command.e2e.ts",
|
||||
"tests/feedback-release.e2e.ts",
|
||||
"tests/agent-team-panel.e2e.ts",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: c35fb453700e12e4
|
||||
zh: 8833a3f8dc6e295d
|
||||
en: e1f7a4359af577e9
|
||||
zh: 642903ce98672936
|
||||
@@ -735,7 +735,7 @@ flowchart LR
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. |
|
||||
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
|
||||
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. |
|
||||
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in the original Session. |
|
||||
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in each task's target Session. |
|
||||
| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. |
|
||||
| `ctx.ssh` | `core` | [`ssh`](../packages/ssh/ssh) | - | [`fs-ssh`](../packages/ssh/fs-ssh), [`subprocess-ssh`](../packages/ssh/subprocess-ssh), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | - | Owns one authenticated OpenSSH connection, installed helper identity, independent program streams and disconnect cleanup for the paired remote providers. |
|
||||
| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | The bash executors, the PTY shell backend, the LSP host, and the out-of-process ACP, Codex, and Claude Code subagent backends spawn through ctx.subprocess; the service owns process coordinates, tree/session lifetime, stdio dispositions, terminal mechanics, and kill escalation. |
|
||||
|
||||
@@ -737,7 +737,7 @@ flowchart LR
|
||||
| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 |
|
||||
| `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. |
|
||||
| `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 |
|
||||
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入原 Session。 |
|
||||
| `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入每个任务的目标 Session。 |
|
||||
| `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | 从会话日志折叠带修订版本的目标状态,并将实时延续激活保留在进程本地。 |
|
||||
| `ctx.ssh` | `core` | [`ssh`](../packages/ssh/ssh) | - | [`fs-ssh`](../packages/ssh/fs-ssh), [`subprocess-ssh`](../packages/ssh/subprocess-ssh), [`sandbox-ssh`](../packages/ssh/sandbox-ssh) | - | 负责一条经过认证的 OpenSSH 连接、已安装辅助程序身份、独立程序流,以及配套远端提供方的断连清理。 |
|
||||
| `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local), [`subprocess-ssh`](../packages/ssh/subprocess-ssh) | [`bash-local`](../packages/shell/bash-local), [`bash-sandbox`](../packages/shell/bash-sandbox), [`terminal-bash`](../packages/terminal/terminal-bash), [`lsp-stdio`](../packages/lsp/lsp-stdio), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | - | Bash 执行器、PTY shell 后端、LSP Host,以及进程外 ACP、Codex 和 Claude Code subagent 后端都通过 ctx.subprocess 执行 spawn;该服务负责进程坐标、进程树/会话生命周期、stdio 处置、终端机制和 kill 升级。 |
|
||||
|
||||
@@ -2903,7 +2903,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-schedule`
|
||||
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:83`](../packages/schedule/schedule/src/index.ts)
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:80`](../packages/schedule/schedule/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the Host Schedule domain. */
|
||||
@@ -2918,6 +2918,11 @@ export interface Config {
|
||||
* cap and the window wins, and the newest records survive.
|
||||
*/
|
||||
deliveryHistoryRecords?: number
|
||||
/**
|
||||
* Shortest spacing, in seconds, from the occurrence of one delivery to the next delivery of one task whose target
|
||||
* creates a Session at every delivery; omission defaults to 60. Timed and manual deliveries both wait for it.
|
||||
*/
|
||||
newSessionIntervalSeconds?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-schedule -->
|
||||
@@ -3741,7 +3746,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-system-prompt`
|
||||
|
||||
- `source`: [`packages/core/system-prompt/src/index.ts:263`](../packages/core/system-prompt/src/index.ts)
|
||||
- `source`: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */
|
||||
@@ -4932,7 +4937,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `tools` · `systemPrompt` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) |
|
||||
|
||||
@@ -2905,7 +2905,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-schedule`
|
||||
|
||||
- `inject`: `agents` · `sessions` · `storageDomain` · `sessionPersistence`
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:83`](../packages/schedule/schedule/src/index.ts)
|
||||
- `source`: [`packages/schedule/schedule/src/index.ts:80`](../packages/schedule/schedule/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configuration for the Host Schedule domain. */
|
||||
@@ -2920,6 +2920,11 @@ export interface Config {
|
||||
* cap and the window wins, and the newest records survive.
|
||||
*/
|
||||
deliveryHistoryRecords?: number
|
||||
/**
|
||||
* Shortest spacing, in seconds, from the occurrence of one delivery to the next delivery of one task whose target
|
||||
* creates a Session at every delivery; omission defaults to 60. Timed and manual deliveries both wait for it.
|
||||
*/
|
||||
newSessionIntervalSeconds?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-schedule -->
|
||||
@@ -3743,7 +3748,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-system-prompt`
|
||||
|
||||
- `source`: [`packages/core/system-prompt/src/index.ts:263`](../packages/core/system-prompt/src/index.ts)
|
||||
- `source`: [`packages/core/system-prompt/src/index.ts:264`](../packages/core/system-prompt/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the deployment-authored fragment of the system prompt (see {@link Config.personaPrefix} for its contract). */
|
||||
@@ -4934,7 +4939,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-tool-call-timeout-policy` | `tools` | [`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-cordis` | `tools` · `cordisInspect` | [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-executor-native` | `tools` | [`packages/core/tool-executor-native/src/index.ts`](../packages/core/tool-executor-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `tools` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `tools` · `systemPrompt` | [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tool-working-directory` | `tools` · `workingDirectory` | [`packages/session/tool-working-directory/src/index.ts`](../packages/session/tool-working-directory/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-tools` | `systemPrompt` | [`packages/core/tools/src/index.ts`](../packages/core/tools/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-user-questions` | — | [`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts) |
|
||||
|
||||
@@ -66,10 +66,10 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
|
||||
| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:424`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - |
|
||||
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:452`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:634`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:71`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
|
||||
@@ -68,10 +68,10 @@
|
||||
| `plugin-manager/operation` | `parallel` | [`packages/boot/plugin-manager/src/types.ts:424`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`parallel`) | - |
|
||||
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:452`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
|
||||
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
|
||||
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:634`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
|
||||
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:71`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `forge-session`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), `ui-trajectory`, [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), `ui-trajectory`, [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
|
||||
@@ -1498,6 +1498,7 @@ flowchart TD
|
||||
pkg_tool_schedule --> pkg_llm
|
||||
pkg_tool_schedule --> pkg_schedule
|
||||
pkg_tool_schedule --> pkg_subagent
|
||||
pkg_tool_schedule --> pkg_system_prompt
|
||||
pkg_tool_schedule --> pkg_tools
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
@@ -1887,6 +1888,6 @@ flowchart TD
|
||||
| [`tool-agent-team`](../packages/team/tool-agent-team) | `team` | [`agent`](../packages/core/agent), [`agent-team`](../packages/team/agent-team), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-names`](../packages/subagent/subagent-names), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
@@ -1500,6 +1500,7 @@ flowchart TD
|
||||
pkg_tool_schedule --> pkg_llm
|
||||
pkg_tool_schedule --> pkg_schedule
|
||||
pkg_tool_schedule --> pkg_subagent
|
||||
pkg_tool_schedule --> pkg_system_prompt
|
||||
pkg_tool_schedule --> pkg_tools
|
||||
pkg_sdk_jsonrpc_server --> pkg_agent
|
||||
pkg_sdk_jsonrpc_server --> pkg_attachment
|
||||
@@ -1889,6 +1890,6 @@ flowchart TD
|
||||
| [`tool-agent-team`](../packages/team/tool-agent-team) | `team` | [`agent`](../packages/core/agent), [`agent-team`](../packages/team/agent-team), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-names`](../packages/subagent/subagent-names), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-context-menu`](../packages/client/ui-context-menu), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
|
||||
| [`tool-schedule`](../packages/schedule/tool-schedule) | `schedule` | [`llm`](../packages/llm/llm), [`schedule`](../packages/schedule/schedule), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`schedule`](../packages/schedule/schedule), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval), [`user-questions`](../packages/interaction/user-questions), [`working-directory`](../packages/session/working-directory) |
|
||||
<!-- END GENERATED module-graph:packages -->
|
||||
@@ -174,8 +174,8 @@
|
||||
en: e5b55405bd2b69f5
|
||||
zh: e5b55405bd2b69f5
|
||||
/session-persistence-event-catalog/events/schedule/schedule-change-log-only:
|
||||
en: e73c202fb99159b6
|
||||
zh: b1f43bbd116f83da
|
||||
en: 6f7f856e4e6275a9
|
||||
zh: 92b4fa43a0d7305d
|
||||
/session-persistence-event-catalog/events/session:
|
||||
en: a1ac8f2660a4f61e
|
||||
zh: a1ac8f2660a4f61e
|
||||
@@ -1035,8 +1035,8 @@
|
||||
en: 3bdc8ecd0bdf0c9c
|
||||
zh: 0fe5b75c174ebab4
|
||||
/session-persistence-event-catalog/resolved-persistence-types/everyscheduledispatchchange:
|
||||
en: 54c487394ce0db1d
|
||||
zh: 48d7c9affbe841cb
|
||||
en: cbd5eb354b025918
|
||||
zh: 953ffea4b40b88cf
|
||||
/session-persistence-event-catalog/resolved-persistence-types/feedbackrecord:
|
||||
en: 3b6931556cfe6b9e
|
||||
zh: ce5a5b1d92ce9eac
|
||||
@@ -1170,8 +1170,8 @@
|
||||
en: 6e301c0ea35d9e0e
|
||||
zh: 6fbf1d318db9a4e0
|
||||
/session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange:
|
||||
en: a3300babe57b6b67
|
||||
zh: 4aaf06a149b399c3
|
||||
en: c1eb536fc54d2f9a
|
||||
zh: 50f6b4ce47c8482a
|
||||
/session-persistence-event-catalog/resolved-persistence-types/oneshotsubagentdescriptordata:
|
||||
en: 9ba0cfd5ee9d6a38
|
||||
zh: 1414fd7468c42651
|
||||
@@ -1206,14 +1206,14 @@
|
||||
en: c9fa5343ea99733b
|
||||
zh: d6de41351c57a73f
|
||||
/session-persistence-event-catalog/resolved-persistence-types/schedulechange:
|
||||
en: 16be53be76c16720
|
||||
zh: 38fd164dde86ccac
|
||||
en: b20a355fde48f699
|
||||
zh: 5d0fd858e2dbff06
|
||||
/session-persistence-event-catalog/resolved-persistence-types/schedulecreatechange:
|
||||
en: c2d3053c1ce07725
|
||||
zh: 0a3201d7c483c7c1
|
||||
en: f99a99a41572d9a8
|
||||
zh: b29532cdd77e5fd0
|
||||
/session-persistence-event-catalog/resolved-persistence-types/scheduledeletechange:
|
||||
en: 35c0b15c285d045e
|
||||
zh: 8ccce6ea60dd5742
|
||||
en: 96e9a9877d2ad882
|
||||
zh: be0fd7e1d142afa3
|
||||
/session-persistence-event-catalog/resolved-persistence-types/sessionheader:
|
||||
en: 0536dab0d2eed6ad
|
||||
zh: 54396338c9917818
|
||||
|
||||
@@ -806,15 +806,15 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
|
||||
|
||||
```ts persistence-catalog
|
||||
/**
|
||||
* Versioned Schedule mutation. The owning package validates the complete
|
||||
* session-local transition stream before accepting a candidate event.
|
||||
* Historical version-1 Session-local reminder mutation. New tasks live in Host storage;
|
||||
* these events remain inert and never populate the Host task store.
|
||||
*/
|
||||
'schedule/change': ScheduleChange
|
||||
```
|
||||
|
||||
Types: [ScheduleChange](subsystems/schedule.md)
|
||||
|
||||
Source: [`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/schedule/src/types.ts)
|
||||
Source: [`packages/schedule/schedule/src/types.ts:625`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
### `session/*`
|
||||
|
||||
@@ -3479,7 +3479,7 @@ Sources: [`packages/core/session/src/types.ts:246`](../packages/core/session/src
|
||||
|
||||
SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:244`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:220`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -4424,7 +4424,7 @@ Sources: [`packages/core/agent/src/types.ts:79`](../packages/core/agent/src/type
|
||||
|
||||
SHA-256: `8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:237`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:213`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -4637,7 +4637,7 @@ One of:
|
||||
|
||||
SHA-256: `2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:256`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:232`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
One of:
|
||||
|
||||
@@ -4656,7 +4656,7 @@ One of:
|
||||
|
||||
SHA-256: `eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:223`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:199`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
@@ -4674,7 +4674,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:223`](../packages/schedule/sc
|
||||
|
||||
SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0`
|
||||
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/schedule/src/types.ts)
|
||||
Sources: [`packages/schedule/schedule/src/types.ts:206`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -808,15 +808,15 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
|
||||
```ts persistence-catalog
|
||||
/**
|
||||
* Versioned Schedule mutation. The owning package validates the complete
|
||||
* session-local transition stream before accepting a candidate event.
|
||||
* Historical version-1 Session-local reminder mutation. New tasks live in Host storage;
|
||||
* these events remain inert and never populate the Host task store.
|
||||
*/
|
||||
'schedule/change': ScheduleChange
|
||||
```
|
||||
|
||||
类型:[ScheduleChange](subsystems/schedule.zh.md)
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:625`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
### `session/*`
|
||||
|
||||
@@ -3481,7 +3481,7 @@ SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7`
|
||||
|
||||
SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:244`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:220`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -4426,7 +4426,7 @@ SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b`
|
||||
|
||||
SHA-256: `8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:237`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:213`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -4639,7 +4639,7 @@ SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd`
|
||||
|
||||
SHA-256: `2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:256`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:232`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
以下类型之一:
|
||||
|
||||
@@ -4658,7 +4658,7 @@ SHA-256: `2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f`
|
||||
|
||||
SHA-256: `eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:223`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:199`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
@@ -4676,7 +4676,7 @@ SHA-256: `eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4`
|
||||
|
||||
SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0`
|
||||
|
||||
来源:[`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/schedule/src/types.ts)
|
||||
来源:[`packages/schedule/schedule/src/types.ts:206`](../packages/schedule/schedule/src/types.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+5
-5
@@ -124,7 +124,7 @@
|
||||
{"digest":"16d0507cb94b4cfc84a87446b1ba7652d6ec1d65ee1684789e747e30d542800e","names":[],"sources":[]},
|
||||
{"digest":"17aae616ef020b8b9f011ec398ec935450fb06d3e2a0722c456f63aeaf5ff728","names":[],"sources":[]},
|
||||
{"digest":"17d1afb770d9941936130996da00dc86782cfef731d8d6526162c301256a4ac3","names":[],"sources":["packages/core/session/src/types.ts:435"]},
|
||||
{"digest":"18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0","names":["packages/schedule/schedule/src/types.ts#ScheduleDeleteChange"],"sources":["packages/schedule/schedule/src/types.ts:230"]},
|
||||
{"digest":"18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0","names":["packages/schedule/schedule/src/types.ts#ScheduleDeleteChange"],"sources":["packages/schedule/schedule/src/types.ts:206"]},
|
||||
{"digest":"18ee62b8900a4c3d046700f05d7a4d49d6cab2a660a020c87481d1603dd8bd4f","names":["packages/session/session-persistence-jsonl/src/format.ts#HeaderLine"],"sources":["packages/session/session-persistence-jsonl/src/format.ts:83"]},
|
||||
{"digest":"18fcdd273a2f2f9d813a3be5e71df5903a963a5c88e46b90b14de6e2db890da0","names":[],"sources":[]},
|
||||
{"digest":"18fce9540bb20897a66275499faee6b8296c97e9901fa1ff45a191772448ad1a","names":[],"sources":[]},
|
||||
@@ -184,7 +184,7 @@
|
||||
{"digest":"2c64d4bc7264fd3f8bed47e96ab8a593bce05a1e4e1c436dcd244a3b7e0c2a9d","names":[],"sources":[]},
|
||||
{"digest":"2d11ca7b0d4493e244b74eba093227866b33249841e59a1e9bf56afed38604c3","names":[],"sources":["packages/web/web-search-deepseek/src/provider.ts:71"]},
|
||||
{"digest":"2da725e554cd89301d829ed9dc45a572c3cd1b93b5e91d0d71f007976d8a0863","names":[],"sources":[]},
|
||||
{"digest":"2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f","names":["packages/schedule/schedule/src/types.ts#ScheduleChange"],"sources":["packages/schedule/schedule/src/types.ts:256"]},
|
||||
{"digest":"2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f","names":["packages/schedule/schedule/src/types.ts#ScheduleChange"],"sources":["packages/schedule/schedule/src/types.ts:232"]},
|
||||
{"digest":"2e4debca12f2c449f96dc6574f3fbf2fb346193443114a241d01a236362ec48c","names":[],"sources":[]},
|
||||
{"digest":"2e505098f9209e52eebd577f1a6f9d4e773ec4d78987aff28a7c786442b9f54c","names":[],"sources":[]},
|
||||
{"digest":"2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e","names":["packages/team/agent-team/src/types.ts#TeamMessageSnapshot"],"sources":["packages/team/agent-team/src/types.ts:108"]},
|
||||
@@ -425,7 +425,7 @@
|
||||
{"digest":"8e31bb958e9b015e6f8407eaade32217068159e816f3cc99c3ad76eb889732e0","names":[],"sources":["packages/llm/llm/src/types.ts:167"]},
|
||||
{"digest":"8e611bf46821e5003a1605a73560a6f56388295149d449dfe2142d5ec335f29c","names":["packages/llm/llm/src/types.ts#ToolAdditionBlock"],"sources":["packages/llm/llm/src/types.ts:122"]},
|
||||
{"digest":"8e8155594e07812356b67d732196dda894b0f9c91a5969d51612cf490b0a016d","names":[],"sources":[]},
|
||||
{"digest":"8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af","names":["packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:237"]},
|
||||
{"digest":"8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af","names":["packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:213"]},
|
||||
{"digest":"8f324cea3916c0517261e88167def8efb3a9994c1135e23d40ed9fd48f8c7bd4","names":[],"sources":[]},
|
||||
{"digest":"8f6d609794bf5afc01d7ccf3e03811d32f4b8047f58a2c9a98db0154c2ad7cb9","names":[],"sources":[]},
|
||||
{"digest":"8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543","names":[],"sources":["packages/core/tools/src/index.ts:28"]},
|
||||
@@ -638,7 +638,7 @@
|
||||
{"digest":"e4c18e294232c3ba6c9f1999f168263cc55956147cfe4121720899be8e52edd1","names":[],"sources":["packages/core/session/src/types.ts:369"]},
|
||||
{"digest":"e4cac694f8b6e1d52e48c699464f1c4c26111ef87e558f7931040d00fdc8d486","names":[],"sources":[]},
|
||||
{"digest":"e5d3828df1ec3e2a66879de0659e0f4866d9cfffc00825ee731a66f8a0c03d98","names":[],"sources":[]},
|
||||
{"digest":"e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344","names":["packages/schedule/schedule/src/types.ts#EveryScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:244"]},
|
||||
{"digest":"e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344","names":["packages/schedule/schedule/src/types.ts#EveryScheduleDispatchChange"],"sources":["packages/schedule/schedule/src/types.ts:220"]},
|
||||
{"digest":"e6411b09c1d982f789bf741ca74e2bea69df56029847f86a6148e722039ee732","names":[],"sources":[]},
|
||||
{"digest":"e72269f931559e901e5e8fe1cfcbd0669fb795f43cc038ff39fba62f60403c33","names":[],"sources":["packages/llm/llm/src/assistant-stream.ts:21"]},
|
||||
{"digest":"e75916628f3f10c2d50658bd143052a46285fbf1a9a700ba54947614603d26b4","names":[],"sources":[]},
|
||||
@@ -656,7 +656,7 @@
|
||||
{"digest":"edbbfff1128099fda7b792c56330d2cb04debc9790988e66d1c3f4026e813e0e","names":[],"sources":[]},
|
||||
{"digest":"edd37b1e4974af648633014435572089b1e8fb71910468a0767a7e28fdd22abf","names":[],"sources":[]},
|
||||
{"digest":"ee577a0cc18664c9daee50e9455153321155604f8f1817362f8f4a4b6fb9e1ad","names":[],"sources":[]},
|
||||
{"digest":"eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4","names":["packages/schedule/schedule/src/types.ts#ScheduleCreateChange"],"sources":["packages/schedule/schedule/src/types.ts:223"]},
|
||||
{"digest":"eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4","names":["packages/schedule/schedule/src/types.ts#ScheduleCreateChange"],"sources":["packages/schedule/schedule/src/types.ts:199"]},
|
||||
{"digest":"eefbea819caeeea91ccb02dfafc16c68dd7f961cf9c1b2be25a2084509cf5c5a","names":[],"sources":[]},
|
||||
{"digest":"ef11a3b7fa2033ae4279545ff8a8a2ee4e301b9b73e5b91654c48e6d08283545","names":[],"sources":["packages/core/session/src/types.ts:305","packages/core/session/src/types.ts:307"]},
|
||||
{"digest":"ef226a90ddac6cd8e631a3a337a3a3e27f9494be92befb7c718b404681d75e21","names":[],"sources":[]},
|
||||
|
||||
@@ -3,38 +3,47 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/schedule.md
|
||||
/host-wide-schedule:
|
||||
en: 71c4686e65a7c7f9
|
||||
zh: dfba124c28db2f63
|
||||
/host-wide-schedule/durable-records:
|
||||
en: 41cd197b5c74bfa3
|
||||
zh: 18ed11259036d726
|
||||
en: d977861519ae2e36
|
||||
zh: 910ba93329cfe56b
|
||||
/host-wide-schedule/durable-tasks:
|
||||
en: 5ddc67179a897c74
|
||||
zh: 47024e886fa70abf
|
||||
/host-wide-schedule/storage-versioning:
|
||||
en: ad9b7b25eebb91b3
|
||||
zh: 6158c5cc9ef12a7f
|
||||
/host-wide-schedule/recurrence-rules:
|
||||
en: f68c0b3ff09ce20e
|
||||
zh: 9fc4470739ff640c
|
||||
/host-wide-schedule/absolute-time-input:
|
||||
en: a07f1a7153b0074f
|
||||
zh: f3c584db1073e49d
|
||||
/host-wide-schedule/daily-wall-clock-input:
|
||||
en: 2d249edc3dfccb8a
|
||||
zh: 33fecda132b03b84
|
||||
en: a7c1c1b63ffb19cf
|
||||
zh: 9b00f818c4774ad8
|
||||
/host-wide-schedule/daily-and-weekly-input:
|
||||
en: a4c09868c5009bef
|
||||
zh: 511ec3d7ae6b0d98
|
||||
/host-wide-schedule/fixed-rate-input-and-catch-up:
|
||||
en: d2b5997cce3806dc
|
||||
zh: 2483ba9e38ad3cf3
|
||||
/host-wide-schedule/cron-wall-clock-input:
|
||||
en: 1ba9ae84f05d8919
|
||||
zh: 5b4abfdcddbbcae0
|
||||
/host-wide-schedule/historical-session-changes:
|
||||
en: d3bdcf22dd771a7d
|
||||
zh: 11af3e617a7ef3fd
|
||||
/host-wide-schedule/active-views-and-management:
|
||||
en: 93798ad69f74ddd5
|
||||
zh: 634126ad8accc356
|
||||
/host-wide-schedule/web-catalog:
|
||||
en: 3377a013bf8cbfb7
|
||||
zh: c5666dc3f28b087f
|
||||
en: 79fb76147de2d82c
|
||||
zh: 8a75d2bc1229caf3
|
||||
/host-wide-schedule/cron-input:
|
||||
en: 0019e68edcba3717
|
||||
zh: 82efc84f09bb8062
|
||||
/host-wide-schedule/rrule-input:
|
||||
en: f93ebb99c73f5d38
|
||||
zh: 8b5787f0ea845ad2
|
||||
/host-wide-schedule/delivery-targets:
|
||||
en: d10b60799a055427
|
||||
zh: 68d33f2f59f826b8
|
||||
/host-wide-schedule/task-management:
|
||||
en: 7a979f8fef09af59
|
||||
zh: 475d982686030c50
|
||||
/host-wide-schedule/timing-edits:
|
||||
en: ce5d2095a3ad2e82
|
||||
zh: 167b3715f8eaffc1
|
||||
en: 93c882d1c1924527
|
||||
zh: 750b73ab93c9517a
|
||||
/host-wide-schedule/saved-delivery-records:
|
||||
en: 077aa6573d9ede1a
|
||||
zh: 0acea592a6e3630a
|
||||
en: 968ae643ca6533a9
|
||||
zh: ec47298b370b2b05
|
||||
/host-wide-schedule/host-delivery:
|
||||
en: d5fc5b853529b139
|
||||
zh: 4cc5eba14f9660e6
|
||||
en: 362e9b4c26f90b5a
|
||||
zh: 37a9882fcf5bafc1
|
||||
/host-wide-schedule/historical-session-changes:
|
||||
en: 0df44daf6df88577
|
||||
zh: 178d9dc601ac2843
|
||||
+163
-443
@@ -2,448 +2,140 @@
|
||||
|
||||
English | [中文](schedule.zh.md)
|
||||
|
||||
Schedule stores reminders independently of Session activation and delivers them to their original Session. This page records the durable and model-facing types from [`types.ts`](../../packages/schedule/schedule/src/types.ts); the [package README](../../packages/schedule/schedule/README.md) owns composition and reminder framing.
|
||||
Schedule stores Host tasks independently of Session activation. One management API addresses tasks by id, and one evaluator handles seven authoritative timing kinds: `after`, `at`, `every`, `daily`, `weekly`, `cron`, and `rrule`. Targets reuse an existing Session, create one Session and reuse it, or create a Session for each delivery. The [package README](../../packages/schedule/schedule/README.md) owns composition and reminder framing; [`types.ts`](../../packages/schedule/schedule/src/types.ts) owns the complete value and request types.
|
||||
|
||||
## Durable records
|
||||
<a id="durable-tasks"></a>
|
||||
## Durable tasks
|
||||
|
||||
`ScheduleId` is a globally unique [branded id](core.md#branded-ids). The version-1 `schedule` storage domain owns a `tasks` table whose entries bind a `ScheduleRecord` to its original `sessionId`, with stored `status: 'active' | 'inactive'` and optional `lastDelivery`. Records lacking status normalize to `active` without scanning or recreating historical Sessions. Creation canonicalizes the target into RFC 3339 UTC `scheduledAt`; `after` retains the submitted delay, `every` retains its fixed interval, `daily` and `weekly` retain normalized local `time` and their explicit `timeZone`, and `cron` retains its canonical five-field `expression` and explicit `timeZone`. Daily, weekly, and cron decoding preserve the committed instant and stored zone spelling. The stored task record declares a required `title`, the short name the task surfaces show; creation requires it and never derives it from the instruction. No stored record may omit its title: a record whose `title` is missing, blank after trimming, untrimmed, or longer than 120 characters fails durable decoding with `ScheduleLogError`, and the schedule domain declares no backup-and-skip policy, so one such stored task rejects the whole domain open instead of being dropped. Only explicit deletion removes a task; previously physically deleted tasks are not restored or fabricated.
|
||||
The plugin README owns the current [v2 task schema, v1 defaults, and persisted JSON example](../../packages/schedule/schedule/README.md#storage-format). The domain retains its name, routing, and whole-unit layout. All seven timing kinds remain authoritative; normalization preserves committed occurrences and does not convert Cron into RRULE.
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable one-shot reminder created from a positive delay. */
|
||||
interface AfterScheduleRecord {
|
||||
/** Globally unique task identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for a delayed one-shot reminder. */
|
||||
readonly kind: 'after'
|
||||
/** Required stored task name; already trimmed, non-empty, and at most 120 characters. */
|
||||
readonly title: string
|
||||
/** Trimmed reminder content supplied at creation. */
|
||||
readonly prompt: string
|
||||
/** Positive safe-integer delay accepted at creation. */
|
||||
readonly afterSeconds: number
|
||||
/** Four-digit-year RFC 3339 UTC target. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
```
|
||||
Malformed storage, invalid rows, mismatched task keys, and unsupported versions reject before tasks activate. Failed opening leaves the medium unchanged. An absent file starts empty. Missing target Sessions remain visible in management; reading a task never activates its Session.
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable one-shot reminder created from an absolute instant. */
|
||||
interface AtScheduleRecord {
|
||||
/** Globally unique task identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for an absolute one-shot reminder. */
|
||||
readonly kind: 'at'
|
||||
/** Required stored task name; already trimmed, non-empty, and at most 120 characters. */
|
||||
readonly title: string
|
||||
/** Trimmed reminder content supplied at creation. */
|
||||
readonly prompt: string
|
||||
/** Four-digit-year RFC 3339 UTC target. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
```
|
||||
<a id="storage-versioning"></a>
|
||||
## Storage versioning
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable fixed-rate reminder aligned to creation or its most recent interval edit. */
|
||||
interface EveryScheduleRecord {
|
||||
/** Globally unique task identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for a fixed-rate recurring reminder. */
|
||||
readonly kind: 'every'
|
||||
/** Required stored task name; already trimmed, non-empty, and at most 120 characters. */
|
||||
readonly title: string
|
||||
/** Trimmed reminder content supplied at creation. */
|
||||
readonly prompt: string
|
||||
/** Fixed safe-integer interval, never below one minute. */
|
||||
readonly everySeconds: number
|
||||
/** Next anchor-aligned occurrence while active, or final occurrence when inactive. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
```
|
||||
The README defines [when tasks upgrade](../../packages/schedule/schedule/README.md#storage-format), SQLite's unchanged physical schema, mutation costs, and a future compatible v3. `compatibleVersions` admits old unit stamps for current decoding; normal edits and delivery persist current rows. Reads leave storage unchanged. The shared [domain version policy](storage.md#domain-versioning) applies to both JSON and SQLite.
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable daily wall-clock reminder; gaps skip a date and overlaps use the earlier instant. */
|
||||
interface DailyScheduleRecord {
|
||||
/** Globally unique task identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for a daily wall-clock reminder. */
|
||||
readonly kind: 'daily'
|
||||
/** Required stored task name; already trimmed, non-empty, and at most 120 characters. */
|
||||
readonly title: string
|
||||
/** Trimmed reminder content supplied at creation. */
|
||||
readonly prompt: string
|
||||
/** Local time normalized to HH:mm:ss.SSS. */
|
||||
readonly time: string
|
||||
/** Explicit IANA zone; equivalent timing edits retain the stored spelling. */
|
||||
readonly timeZone: string
|
||||
/** Committed next UTC instant while active, or final occurrence when inactive. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
```
|
||||
<a id="recurrence-rules"></a>
|
||||
## Recurrence rules
|
||||
|
||||
```ts type-equiv
|
||||
/** One-shot task variants. */
|
||||
type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord
|
||||
```
|
||||
[`dsh-util-time`](../../packages/util/time/README.md) owns reusable `Cron` and `RRule` parsing and bounded occurrence arithmetic. Both parsed values expose their authoritative expression and zone, `next(after)`, and `resolve(scheduledAt, acceptedAt)`. Parsers return an accepted value or a rejection reason; Schedule maps reasons to its own input errors and owns record validation, admission limits, persistence, and dispatch.
|
||||
|
||||
```ts type-equiv
|
||||
/** Recurring Host task variants. */
|
||||
type RecurringScheduleRecord = EveryScheduleRecord | DailyScheduleRecord | WeeklyScheduleRecord | CronScheduleRecord
|
||||
```
|
||||
Every stored `scheduledAt` is a canonical RFC 3339 UTC instant with millisecond precision and a year from 0001 through 9999. Decoding never recalculates it from current zone data. Latest-only catch-up preserves that committed floor, delivers the latest due occurrence once, and commits the first future occurrence. Exhaustion leaves the task stored as `ended`.
|
||||
|
||||
```ts type-equiv
|
||||
/** Reminder rule and target, stored with its original Session binding. */
|
||||
type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord
|
||||
```
|
||||
| Kind | Authoritative timing |
|
||||
|---|---|
|
||||
| `after` | Positive integer `afterSeconds` and the committed one-shot UTC target. |
|
||||
| `at` | Committed one-shot UTC target. Input accepts an explicit-offset instant or a local date, time, and explicit zone. |
|
||||
| `every` | Integer `everySeconds` of at least 60; occurrences stay aligned to their committed interval anchor. |
|
||||
| `daily` | Local `time` as `HH:mm:ss.SSS` and explicit `timeZone`. |
|
||||
| `weekly` | Daily fields plus a nonempty, unique, ascending ISO `weekdays` set, Monday 1 through Sunday 7. |
|
||||
| `cron` | Canonical five-field `expression` and explicit `timeZone`. |
|
||||
| `rrule` | One normalized `DTSTART` line and one `RRULE` line in `rrule`; `DTSTART` supplies the anchor and zone. |
|
||||
|
||||
Local calendar rules skip missing wall times and choose the earlier instant of an overlap. Fixed intervals retain their elapsed-time meaning. A one-shot delay may be shorter than a minute; the per-task delivery floor still applies after its first delivery.
|
||||
|
||||
<a id="absolute-time-input"></a>
|
||||
## Absolute-time input
|
||||
|
||||
The `at` selector is either a strict offset-bearing RFC 3339 string or an exact local-calendar object. The local form keeps its interpretation explicit at the tool boundary:
|
||||
|
||||
```ts type-equiv
|
||||
/** Structured local-calendar input accepted by creation and timing edits. */
|
||||
interface LocalAtInput {
|
||||
/** Four-digit ISO calendar date. */
|
||||
readonly date: string
|
||||
/** Local wall-clock time with optional one-to-three digit milliseconds. */
|
||||
readonly time: string
|
||||
/** Explicit UTC or IANA Area/Location zone. */
|
||||
readonly time_zone: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Absolute selector accepted by creation and timing edits. */
|
||||
type AtInput = string | LocalAtInput
|
||||
```
|
||||
|
||||
The shipped Web composition mounts the `schedule` service and the `ui-schedule` task page. The clock reading and the four reminder tools are preset-owned: `standard`, `cordis`, and `ptc` declare `time-context` and `@deepseek-ai/dsh-tool-schedule` together, and `minimal` declares neither. The `tool-subagent` and `tool-subagent-fork` rows in those three presets deny the four tools, and each tool refuses a delegated caller with `subagent_session`, so a delegated child neither sees the tools nor has a call accepted. Time-context tells the model to interpret otherwise-unqualified natural-language dates and times in that request-local zone when the open turn has one unambiguous browser zone; mixed or missing browser-zone records tell the model to ask. That guidance is not a durable Session default: the model must still pass an offset in the string form or `time_zone` in the local form, and Schedule never reads browser, Session, process, or model context.
|
||||
|
||||
Schedule rejects invalid offsets and zones, offset-free strings, non-future targets, and local times inside daylight-saving gaps. A daylight-saving overlap chooses its first, earlier instant. Successful creation stores only canonical UTC `scheduledAt`, so replay never depends on ambient time-zone state.
|
||||
An `at` selector accepts an RFC 3339 instant with an explicit `Z` or numeric offset, or `{ date, time, time_zone }`. Local dates use `YYYY-MM-DD`; local times use `HH:mm:ss` with optional one-to-three fractional digits. Ambiguous or nonexistent calendar values are validated through the shared time utilities. A new absolute target must be strictly future and representable.
|
||||
|
||||
<a id="daily-wall-clock-input"></a>
|
||||
## Daily wall-clock input
|
||||
## Daily and weekly input
|
||||
|
||||
`daily` is one of the mutually exclusive creation selectors, alongside `after_seconds`, `at`, `every_seconds`, the weekly selector, and the cron selector. For example, `daily: { time: '23:00:00', time_zone: 'Asia/Shanghai' }` selects 23:00 in that zone on each eligible local date. The exact object has no date, offset, or interval field:
|
||||
|
||||
```ts type-equiv
|
||||
/** Daily local-time selector accepted by creation and timing edits. */
|
||||
interface DailyInput {
|
||||
/** Local HH:mm:ss time with optional one-to-three fractional digits. */
|
||||
readonly time: string
|
||||
/** Explicit UTC or IANA Area/Location zone. */
|
||||
readonly time_zone: string
|
||||
}
|
||||
```
|
||||
|
||||
Creation requires `HH:mm:ss` with optional one-to-three fractional digits, rejects leap seconds and `24:00`, canonicalizes the explicit IANA zone, and stores `time` as `HH:mm:ss.SSS`. The first `scheduledAt` is strictly after creation time. A nonexistent local time or entire date is skipped; an overlap selects the earlier instant once per local date. If that earlier instant has passed, the later duplicate is not eligible. Only UTC target years 0001–9999 are constrained; local dates at the UTC range edges may fall in year 0 or 10000.
|
||||
|
||||
A saved next target is a committed UTC instant. The Host decoder and restart preserve that instant without resolving it against time-zone rules again, and valid stored zone aliases remain readable when canonical names change. Subsequent targets use the Host's current IANA data. The next target must be both after the delivery decision and on a local date later than the selected occurrence's date. Creation fails with `time_out_of_range` when no future UTC target is representable.
|
||||
Daily input supplies `{ time, time_zone }`; Weekly adds explicit `weekdays`. Creation chooses the first strictly future actual occurrence. Catch-up chooses the latest actual due occurrence and advances to a later matching local date. Equivalent edits retain the stored IANA alias and pinned UTC target, including milliseconds.
|
||||
|
||||
<a id="fixed-rate-input-and-catch-up"></a>
|
||||
## Fixed-rate input and catch-up
|
||||
|
||||
`every_seconds` is a safe-integer interval of at least 60 seconds, aligned to creation time. It measures elapsed time, not daily wall-clock time: `every_seconds: 86400` cannot substitute for `daily` across offset changes. The protocol provides no shared cooldown or cross-record admission rule; the cron selector described below is wall-clock timing, not a fixed-rate interval.
|
||||
|
||||
When the Host starts with an overdue Every, Daily, Weekly, or Cron record, it sends only the latest due occurrence. Every advances to the first target aligned to its current interval anchor after the delivery decision; Daily, Weekly, and Cron follow the local-date rules above and below. Missed occurrences do not accumulate. If no next UTC target is representable, delivery retains the record as inactive with its latest receipt.
|
||||
|
||||
One-shots produce individual follow-ups. Recurring records due in the same scan for the same Session share one follow-up rendered by `renderRecurringReminderBatchFraming`, with one latest occurrence per record. The one-minute minimum applies only to fixed-rate intervals; delivery does not wait for the target Agent to become idle.
|
||||
Every input supplies `every_seconds`. A new interval starts from acceptance time; a changed interval establishes a new anchor at edit acceptance. An unchanged interval or a name/prompt edit keeps the old anchor. Downtime coalesces missed intervals instead of replaying each occurrence.
|
||||
|
||||
<a id="cron-wall-clock-input"></a>
|
||||
## Cron wall-clock input
|
||||
## Cron input
|
||||
|
||||
`cron` is one of the mutually exclusive creation selectors. Its exact object carries only the strict five-field Vixie expression `minute hour day-of-month month day-of-week` and an explicit IANA zone, for example `cron: { expression: '*/15 9-17 * * 1-5', time_zone: 'Asia/Shanghai' }`. The field bounds are:
|
||||
Cron input supplies `{ expression, time_zone }`. Its five numeric fields are minute, hour, day-of-month, month, and day-of-week. Supported atoms are `*`, a value, an inclusive range, a star or range step, and comma-separated lists. Sunday accepts 0 or 7 and canonicalizes to 0. Names, macros, a seconds field, and a value followed by a step are rejected.
|
||||
|
||||
| Field | Accepted range | Notes |
|
||||
|---|---|---|
|
||||
| minute | 0-59 | |
|
||||
| hour | 0-23 | |
|
||||
| day-of-month | 1-31 | |
|
||||
| month | 1-12 | |
|
||||
| day-of-week | 0-7 | Both `0` and `7` mean Sunday. |
|
||||
Cron keeps Vixie day matching: when both day fields are restricted, day-of-month **or** day-of-week may match; when either field starts with `*`, including a stepped star, both matched sets must match. Canonicalization preserves that distinction. Searches are bounded by one 400-year Gregorian cycle and the four-digit UTC range. A rule with no representable future occurrence is refused when armed.
|
||||
|
||||
Every field accepts `*`, a single value, an `a-b` range, a `*/n` or `a-b/n` step with `n >= 1`, and a comma-separated list of those forms. Everything else is rejected with `invalid_rule` and a message naming the offending field: `L`, `W`, and `#` operators, `JAN`/`MON` names, `@daily`-style macros, six-field expressions with a seconds field, out-of-range values, inverted ranges, zero steps, and empty fields. Because the dialect has five fields, the smallest interval is one minute; sub-minute scheduling stays unsupported.
|
||||
Cron records are never converted into RRULE. Their saved expression and zone remain authoritative through reads, descriptive edits, retargeting, and restart. The Custom editor accepts a five-field Cron expression and exposes its explicit zone selector.
|
||||
|
||||
Creation canonicalizes the expression before storing it. Canonicalization expands each field into its matched value set and re-encodes that set deterministically: repeated values collapse, adjacent values and ranges merge, a uniform step is written as `a-b/n` or, for a field that started with `*`, as a star-step (`*` for every value, otherwise the widest star-step walk plus any remaining values), a step of `1` is dropped, and Sunday is written as `0`. A field that did not start with `*` never becomes a star-step. Preserving each field's star flag is what keeps the day-of-month/day-of-week rule below Vixie-faithful across the stored expression. The durable decoder rejects a stored expression that is not canonical.
|
||||
<a id="rrule-input"></a>
|
||||
## RRULE input
|
||||
|
||||
The first target is the first strictly future instant whose local date and time in the zone match. It reuses the Daily and Weekly local-time resolution: a non-existent local time is skipped, and an ambiguous one takes the earlier occurrence once for that date. The committed UTC `scheduledAt` is never recomputed on restart.
|
||||
RRULE accepts a bounded RFC 5545 subset, defined by the shared [`rrule.ts` parser](../../packages/util/time/src/rrule.ts). Input has exactly two nonempty content lines: `DTSTART` followed by `RRULE`. The start is a real `YYYYMMDDTHHMMSS` date-time with UTC `Z` or an explicit IANA `TZID`; floating local time, numeric offsets, date-only values, and extra content lines are rejected. The engine uses `rrule` 2.8.1 and supports `MINUTELY`, `HOURLY`, `DAILY`, `WEEKLY`, `MONTHLY`, and `YEARLY`. A one-shot uses `COUNT=1`.
|
||||
|
||||
Day-of-month and day-of-week follow Vixie semantics. A field is a star when its text starts with `*`, independently of the values it matches, so `*/1` and `*/2` are stars while `1-31` and `0-7` are restricted. When either field is a star, a local date matches only when both fields match; when neither is a star, it matches when either field matches.
|
||||
Only `FREQ`, `INTERVAL`, `COUNT`, `UNTIL`, `WKST`, `BYSECOND`, `BYMINUTE`, `BYHOUR`, `BYDAY`, `BYMONTHDAY`, `BYYEARDAY`, `BYWEEKNO`, `BYMONTH`, and `BYSETPOS` are accepted; names and list values must be unique. `INTERVAL` is a positive safe integer. `WKST` is a weekday from `MO` through `SU` and defaults to `MO`. Numeric lists must fit the parser's ranges. Minute/hour rules accept no `BY` parts. `BYMONTHDAY` and ordinal `BYDAY` require Monthly or Yearly; `BYYEARDAY` and `BYWEEKNO` require Yearly. Ordinal `BYDAY` cannot combine with `BYWEEKNO`, and `BYSETPOS` requires another `BY` part.
|
||||
|
||||
Cron decoding preserves the committed instant and the stored zone spelling, and rejects a non-canonical stored expression. Cron belongs only to the current Host `ScheduleRecord`; the frozen historical Session decoder does not admit it.
|
||||
Validation precedes enumeration. `SECONDLY`, multiple `BYSECOND` values, and more than 10,080 possible candidates per frequency period are rejected. `COUNT` is 1 through 1,000 and cannot coexist with `UNTIL`; `UNTIL` is an absolute UTC date-time ending in `Z`. Starts and limits use years 0100 through 9999. At most 31 set positions are accepted, each from −366 through −1 or 1 through 366 and within its bounded period. The stricter new-rule authoring profile also rejects `BYSETPOS` when a period has at most one candidate; decoding and resuming an unchanged supported rule use its normal evaluator.
|
||||
|
||||
## Historical Session changes
|
||||
A nonexistent local `DTSTART` is rejected. Later missing local occurrences are skipped without consuming `COUNT`; overlaps choose the earlier instant. `COUNT` counts each actual occurrence once, including when positive and negative set positions select the same occurrence.
|
||||
|
||||
Version-1 `schedule/change` events remain decodable as historical Session data. Their create and fold types use `LegacyScheduleRecord`, which admits only After, At, and Every; Daily, Weekly, and Cron belong only to the current Host `ScheduleRecord`. The Host record decoder is separate from the frozen historical decoder. A create record written before titles existed carries no `title`, so the historical decoder admits that absent member while the Host decoder still requires it. Historical events do not populate the storage domain or schedule delivery. Existing reminders in these events require explicit recreation through `schedule_create`; no implicit Session migration or conversion of old `at` records occurs.
|
||||
Custom RRULE text retains its `DTSTART` zone. The model-facing tools continue to teach and accept RRULE; the shared management API accepts each timing kind explicitly.
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Frozen Session event and fold vocabulary; daily rules belong only to Host storage.
|
||||
*
|
||||
* A version-1 event written before titles existed persists no `title` member, so
|
||||
* `after`, `at`, and `every` decode without one and stay readable. The Host task
|
||||
* record requires the member and never persists a record without it.
|
||||
*/
|
||||
type LegacyScheduleRecord =
|
||||
| LegacyAfterScheduleRecord
|
||||
| LegacyAtScheduleRecord
|
||||
| LegacyEveryScheduleRecord
|
||||
```
|
||||
<a id="delivery-targets"></a>
|
||||
## Delivery targets
|
||||
|
||||
```ts type-equiv
|
||||
/** Creates one durable reminder record. */
|
||||
interface ScheduleCreateChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'create'
|
||||
readonly schedule: LegacyScheduleRecord
|
||||
}
|
||||
```
|
||||
| Target | Delivery behavior |
|
||||
|---|---|
|
||||
| `session` | Deliver to one existing Session. Arming checks inside the management FIFO that it exists, is not archived, and is not a delegated child. Delivery rechecks archived destinations. |
|
||||
| `new_once` | Reserve a Session id before first delivery, create that exact Session when needed, then store `session` and reuse it. Retries and restart keep the reservation. |
|
||||
| `new` | Create a Session per delivery. Persist `pendingDelivery.targetSessionId` before creation so a failed attempt retries the same Session. |
|
||||
|
||||
```ts type-equiv
|
||||
/** Deletes one currently active reminder. */
|
||||
interface ScheduleDeleteChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'delete'
|
||||
readonly id: ScheduleId
|
||||
}
|
||||
```
|
||||
An omitted creation target defaults to `new_once`. Adding or replacing a `new` or `new_once` target requires the embedding's Session creation provider. SDK compositions without it reject those requests with `invalid_rule` before saving; existing `session` targets remain available. Creation values `cwd` and `agentPreset` are optional and use Host defaults. A created Session joins the Workspace registered at its directory, if any, and takes the task title while untitled; `new` adds its creation minute in the rule's zone. Join or rename failures are logged without blocking delivery.
|
||||
|
||||
```ts type-equiv
|
||||
/** Records that one active one-shot reminder entered the durable dispatch history. */
|
||||
interface OneShotScheduleDispatchChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'dispatch'
|
||||
readonly id: ScheduleId
|
||||
}
|
||||
```
|
||||
<a id="task-management"></a>
|
||||
## Task management
|
||||
|
||||
```ts type-equiv
|
||||
/** Records one fixed-rate decision and advances directly past missed occurrences. */
|
||||
interface EveryScheduleDispatchChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'dispatch'
|
||||
readonly id: ScheduleId
|
||||
/** Wall-clock decision time used to select the latest due occurrence. */
|
||||
readonly acceptedAt: string
|
||||
}
|
||||
```
|
||||
All callers use the same `ScheduleCreateRequest`, `ScheduleCatalogEntry`, `ScheduleTiming`, and `ScheduleEditRequest`. Browser-safe types come from `@deepseek-ai/dsh-schedule/client`. There is no Session-bound management surface or second family of type aliases.
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable dispatch shapes supported by the current rule set. */
|
||||
type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispatchChange
|
||||
```
|
||||
| Operation | Behavior |
|
||||
|---|---|
|
||||
| `create(request, actorSessionId?, signal?)` | Allocate the id and store title, prompt, typed `timing`, and optional target. |
|
||||
| `list(request?)` | Read a page; default 100, maximum 500, optional `active` filter and nonnegative `offset`. |
|
||||
| `catalog()` / `view({ id })` | Read all tasks without a page limit, or one task. |
|
||||
| `history({ id, limit, before? })` | Read saved deliveries without Session activation; limit is 1 through 100. |
|
||||
| `edit(request, actorSessionId?, signal?)` | Apply an `update` patch or `delete` atomically by id. |
|
||||
| `deleteInactive()` | Remove only `ended` tasks in one batch, preserve active and paused tasks, and return `{ deleted }`. An empty set performs no write. |
|
||||
| `run({ id }, signal?)` | Deliver now through normal admission without changing the scheduled occurrence or lifecycle. |
|
||||
| `deliveryLimits()` | Read the configured minimum spacing for `new` targets. |
|
||||
|
||||
```ts type-equiv
|
||||
/** Strict version-1 durable Schedule mutation union. */
|
||||
type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
|
||||
```
|
||||
Remote routes expose `create`, `list`, `catalog`, `view`, `history`, `edit`, `deleteInactive`, `run`, and `deliveryLimits`; the transport wrappers delegate to the same management methods. Remote validation failures use `schedule/input-invalid` with the Schedule input code. Native callers receive `ScheduleInputError`.
|
||||
|
||||
The historical decoder and fold reject unknown versions, extra fields, reused ids, and invalid transitions. The event remains indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only). Session history is not the authority for active tasks.
|
||||
A successful nonempty `deleteInactive` batch emits `schedule/changed` for readers to refresh; it queues no Agent notices. A rejected batch also emits that event so readers discard stale state. A normal failure preserves all rows; an `unknown-state` failure can have removed rows and quarantines the domain until reopening. The failed call rejects instead of reporting a deletion count.
|
||||
|
||||
## Active views and management
|
||||
An update may replace title, prompt, typed timing, target, or activity. Omitted fields keep their values. A changed rule or explicit resume computes a future occurrence at FIFO acceptance; equivalent timing and descriptive edits keep the committed target. A delivered one-shot needs new future timing to resume. Retargeting does not require converting its timing kind.
|
||||
|
||||
Tool values combine the stored record with timing derived from the current wall clock. The Host restores the original Session when delivery requires it; listing and deleting tasks do not activate that Session.
|
||||
|
||||
```ts type-equiv
|
||||
/** Current delivery timing derived from the durable record and wall clock. */
|
||||
type ScheduleState = 'scheduled' | 'overdue'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Host-driven delivery resumes the original Session when needed. */
|
||||
type ScheduleDeliveryMode = 'host'
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Complete model-facing view of one active reminder. */
|
||||
type ScheduleView = ScheduleRecord & {
|
||||
/** Whether the target remains in the future. */
|
||||
readonly state: ScheduleState
|
||||
/** Reminder delivery never leaves the owning session. */
|
||||
readonly deliveryMode: ScheduleDeliveryMode
|
||||
}
|
||||
```
|
||||
|
||||
The [tool catalog](../tool-catalog.md#deepseek-aidsh-tool-schedule) owns schemas for `schedule_create`, `schedule_list`, `schedule_delete`, and `schedule_update`, which [`@deepseek-ai/dsh-tool-schedule`](../../packages/schedule/tool-schedule/README.md) contributes to each preset that mounts it; the Host service owns storage and delivery. Create, delete, and update acknowledge the storage-domain write. A Host-wide queue serializes these mutations against due delivery; deleting a task does not remove an already queued message. Model tools address the current Session, while the shared Host create, list, update, and delete methods accept an explicit Session binding. `create` and `update` refuse a Session a delegated child owns with `subagent_session` when its delegation depth is above zero, and delivery cannot reach such a Session. The four model tools refuse a delegated caller in the tool layer, while `list`, `catalog`, `history`, and `delete` still serve that Session. Creation requires a `title` that must be non-empty after trimming and at most 120 characters; a missing, blank, or over-long title is rejected with `invalid_prompt`, and creation never derives one from the instruction. Create and list views carry the stored `title` alongside the instruction, and a stored record whose `title` is missing or invalid is rejected at decode.
|
||||
|
||||
```ts type-equiv
|
||||
/** Reminder creation selector, shared by the model consumer and Host service. */
|
||||
interface ScheduleCreateRequest {
|
||||
/** Non-empty reminder text. */
|
||||
prompt: string
|
||||
/** Required task name of at most 120 characters, non-empty after trimming; names the card, detail heading, and task lists. */
|
||||
title: string
|
||||
/** Relative one-shot delay in seconds. */
|
||||
after_seconds?: number
|
||||
/** Absolute one-shot target. */
|
||||
at?: AtInput
|
||||
/** Fixed recurrence interval in seconds. */
|
||||
every_seconds?: number
|
||||
/** Daily wall-clock time in an explicit IANA zone. */
|
||||
daily?: DailyInput
|
||||
/** Weekly wall-clock time and explicit ISO weekday set in an IANA zone. */
|
||||
weekly?: WeeklyInput
|
||||
/** Five-field cron expression evaluated in an explicit IANA zone. */
|
||||
cron?: CronInput
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Session-scoped task list, without Agent activation. */
|
||||
interface ScheduleListRequest {
|
||||
/** Original Session binding. */
|
||||
sessionId: SessionId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Delete request identifying a task within its original Session binding. */
|
||||
interface ScheduleDeleteRequest extends ScheduleListRequest {
|
||||
/** Task to remove. */
|
||||
id: ScheduleId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Successful `schedule_delete` value, including the non-mutating not-found result. */
|
||||
type ScheduleDeleteResult =
|
||||
| { readonly id: ScheduleId; readonly deleted: true }
|
||||
| { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Successful Host-wide removal of every ended task, whether or not any remained. */
|
||||
interface ScheduleDeleteInactiveResult {
|
||||
/** Ended task rows this call removed; zero when the Host held none. */
|
||||
readonly deleted: number
|
||||
}
|
||||
```
|
||||
|
||||
## Web catalog
|
||||
|
||||
The Remote `schedule.catalog()` method returns active and inactive Host tasks with their original Session bindings, ordered by `scheduledAt` ascending and then lexicographically by `id`. It reads only the Schedule domain, without activating Sessions or reading their history. Browser consumers receive this browser-safe type:
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable Session inbox delivery acknowledgment, not model execution completion. */
|
||||
interface ScheduleDeliveryReceipt {
|
||||
/** Canonical UTC target of the delivered occurrence. */
|
||||
readonly scheduledAt: string
|
||||
/** Canonical UTC time sampled after Session persistence acknowledged delivery. */
|
||||
readonly deliveredAt: string
|
||||
/** Identity of the delivered message, shared by tasks in one recurring batch. */
|
||||
readonly messageId: MessageId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Browser-safe retained reminder with its original Session binding. */
|
||||
type ScheduleCatalogEntry = ScheduleRecord & {
|
||||
/** Session receiving this reminder when it becomes due. */
|
||||
readonly sessionId: SessionId
|
||||
/** Inactive reminders remain visible but never schedule another delivery. */
|
||||
readonly status: 'active' | 'inactive'
|
||||
/** Most recent durably acknowledged inbox delivery, when available. */
|
||||
readonly lastDelivery?: ScheduleDeliveryReceipt
|
||||
}
|
||||
```
|
||||
|
||||
The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed; a normal failed batch rejects with nothing removed; an `unknown-state` failure may have removed rows and quarantines the Host schedule domain, so later schedule operations reject until it is reopened. Any rejection announces `schedule/changed`, and clients then re-read. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again.
|
||||
|
||||
The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
|
||||
`expectedUpdatedAt` is an optional optimistic comparison. A string compares with the saved revision; explicit `null` compares with an absent revision. Mismatch returns `schedule_conflict`. An edit of a row without a revision creates one; later revisions advance monotonically even if the clock rolls back. Cancellation is checked before the write begins, and a started durable write is not rolled back.
|
||||
|
||||
<a id="timing-edits"></a>
|
||||
## Timing edits
|
||||
|
||||
`schedule.update({ sessionId, id, expected, change })` edits an active task without activating its Session. `expected` is the complete observed `ScheduleRecord`, not a catalog entry with extra metadata. The Host compares it to the current record inside the same FIFO used by dispatch and deletion. Normal delivery can advance the target during editing; that stale snapshot returns `schedule_conflict` instead of overwriting the current rule. Missing or mismatched bindings return `schedule_not_found`, and inactive tasks return `schedule_ended`.
|
||||
|
||||
```ts type-equiv
|
||||
/** Timing-only edit; it may select a different recurrence kind than the stored record, and one-shot targets use an absolute `at`. */
|
||||
type ScheduleTimingChange =
|
||||
| { readonly kind: 'at'; readonly at: AtInput }
|
||||
| { readonly kind: 'every'; readonly every_seconds: number }
|
||||
| { readonly kind: 'daily'; readonly daily: DailyInput }
|
||||
| { readonly kind: 'weekly'; readonly weekly: WeeklyInput }
|
||||
| { readonly kind: 'cron'; readonly cron: CronInput }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Compare-and-update request within the original Session binding. */
|
||||
interface ScheduleUpdateRequest extends ScheduleDeleteRequest, ScheduleUpdateContent {
|
||||
/** Complete record observed when editing began, including the committed target. */
|
||||
readonly expected: ScheduleRecord
|
||||
/** New timing; its kind may differ from the stored record's kind, and an omitted value keeps the committed target. */
|
||||
readonly change?: ScheduleTimingChange
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Successful current record, non-mutating lookup/conflict failure, or invalid name/instruction/timing. */
|
||||
type ScheduleUpdateResult =
|
||||
| { readonly id: ScheduleId; readonly updated: boolean; readonly record: ScheduleRecord }
|
||||
| ScheduleUpdateMiss
|
||||
| ScheduleToolError
|
||||
```
|
||||
|
||||
A changed Daily rule computes its first strictly future target using the saved local time and zone semantics. A changed Cron rule canonicalizes its expression and computes its first strictly future target under the same local-date, gap, and earlier-overlap rules. A changed Every interval starts a new anchor at accepted save time plus that interval. An After or At task can receive a different absolute target as an At record with the same id. These operations preserve the stored `title`, the prompt, original Session binding, lifecycle status, and all saved receipts. A change may also select a different recurrence kind, which recomputes the target from the new rule with the same id, title, prompt, Session binding, status, and saved receipts. An equivalent normalized rule returns `updated: false` with its unchanged record; it does not rewrite storage, reset the target, or emit a change event. A differently spelled but equivalent Cron expression is such a no-op, because the comparison uses the canonical expression and the canonical zone.
|
||||
|
||||
Input validation uses existing timing errors. A changed absolute target must still be future when its FIFO slot runs. The successful task put precedes the change notification and timer recomputation. Storage and lifecycle failures reject; cancellation is checked after queue and initialization waits but cannot roll back an in-flight write. One-shot records retain a UTC instant rather than the editing form's IANA zone. Timing edits append no historical Session event: the model reaches the same in-place update through `schedule_update`, which compares the record it read with the stored one instead of deleting and recreating the task.
|
||||
The `timing` discriminant selects exactly its matching payload, for example `{ kind: "cron", cron: { expression: "0 9 * * 1-5", time_zone: "Asia/Shanghai" } }` or `{ kind: "rrule", rrule: "DTSTART:20990101T090000Z\nRRULE:FREQ=DAILY" }`. Other selectors use `after_seconds`, `at`, `every_seconds`, `daily`, or `weekly`. Invalid combinations are refused before persistence. The UI saves typed Cron/RRULE input through this same API and preserves untouched timing.
|
||||
|
||||
<a id="saved-delivery-records"></a>
|
||||
## Saved delivery records
|
||||
|
||||
`schedule.history({ sessionId, id, limit })` reads saved receipts without activating a Session. The explicit limit is a safe integer from 1 through 100. Pages follow reverse append order, not wall-clock sorting; the optional message-id cursor excludes the oldest record from the preceding page. New arrivals do not shift that cursor. A missing task or wrong Session binding returns `schedule_not_found`; an unknown cursor returns `delivery_cursor_not_found`, not an empty successful page.
|
||||
A receipt records `scheduledAt`, `deliveredAt`, `messageId`, and `targetSessionId`; it acknowledges a durable Session inbox write, not model completion. A saved record may also have its immutable sent `prompt`. Old receipts without a target id use their resolved reusable destination before any retargeting edit, so a later target cannot rewrite their history. A receipt without either an explicit destination or a known reusable target rejects decoding; a new default reservation cannot establish a historical destination.
|
||||
|
||||
```ts type-equiv
|
||||
/** Saved inbox delivery with its immutable sent prompt when recorded by this Host. */
|
||||
interface ScheduleDeliveryRecord extends ScheduleDeliveryReceipt {
|
||||
/** Prompt sent for this occurrence; unavailable for legacy receipts. */
|
||||
readonly prompt?: string
|
||||
}
|
||||
```
|
||||
New tasks start with known empty history. An old row without `deliveryHistory` exposes its latest receipt, if present, and sets `earlierRecordsUnavailable: true`; it does not invent lost history or prompt snapshots. An omitted `earlierRecordsPruned` reads as false. Duplicate message ids or disagreement between the latest saved record and `lastDelivery` reject decoding.
|
||||
|
||||
```ts type-equiv
|
||||
/** Explicitly bounded saved-delivery query within one Session binding. */
|
||||
interface ScheduleDeliveryHistoryRequest extends ScheduleDeleteRequest {
|
||||
/** Required safe-integer page size from 1 through 100. */
|
||||
limit: number
|
||||
/** Message identity of the oldest entry in the previous page; excluded from this page. */
|
||||
before?: MessageId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Configured limits applied when appending one task's delivery receipt. */
|
||||
interface DeliveryRetentionBounds {
|
||||
/** Retained window in days, measured back from the acknowledgment being appended. */
|
||||
readonly days: number
|
||||
/** Maximum retained records per task; the newest survive. */
|
||||
readonly records: number
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Newest-first saved deliveries, or a non-mutating task/cursor lookup failure. */
|
||||
type ScheduleDeliveryHistoryResult =
|
||||
| {
|
||||
readonly id: ScheduleId
|
||||
readonly records: ScheduleDeliveryRecord[]
|
||||
/** Whether earlier delivery records may be unavailable; missing receipts are never reconstructed. */
|
||||
readonly earlierRecordsUnavailable: boolean
|
||||
/** True only after an append removed saved records; absent legacy evidence is false. */
|
||||
readonly earlierRecordsPruned: boolean
|
||||
/** Current Host retention configuration, also used by the delivery writer. */
|
||||
readonly retention: DeliveryRetentionBounds
|
||||
/** Oldest returned message identity, present only when older saved records remain. */
|
||||
readonly nextBefore?: MessageId
|
||||
}
|
||||
| { readonly id: ScheduleId; readonly code: 'schedule_not_found' | 'delivery_cursor_not_found' }
|
||||
```
|
||||
|
||||
New tasks initialize an empty history. Older task rows without history expose only their existing latest receipt, mark earlier records unavailable, and are not rewritten by reads. Future acknowledgments preserve that receipt and append actual new receipts with instruction snapshots. Lost history and missing legacy instruction snapshots are never reconstructed. The same task-row write commits history, latest receipt, status, and next target; explicit task deletion stops future delivery, removes the task row together with its saved receipts, and takes it out of `list` and `catalog`; conversation messages are unaffected. Saved delivery records are pruned on append to the configured `deliveryHistoryDays` window, measured back from each receipt's `deliveredAt`, and to the `deliveryHistoryRecords` cap; the appended latest receipt always survives, and a pruned window marks earlier records unavailable. Pagination bounds returned record count rather than storage size or instruction bytes.
|
||||
History follows append order despite clock rollback. Acknowledgment appends and prunes in the same task commit, keeping at most 200 records and 30 days by default while retaining the latest receipt. Query pages are newest first and use an exclusive message-id cursor. Hard deletion removes the task and its history without removing already queued Session messages.
|
||||
|
||||
<a id="host-delivery"></a>
|
||||
## Host delivery
|
||||
|
||||
The Host reads persisted tasks at startup and sets its timer to the earliest active target. Inactive tasks remain queryable but never schedule another delivery. At delivery it resolves the original Session, including cold Sessions, through `schedule/resolve-agent`. The embedding owns activation: the SDK handler waits for initialization and retains its Session handle and model options; without a handler, the Web Session controller restores the Session. The composition waits for its activation provider before starting Schedule. After restoration it samples the wall clock again and excludes members that are no longer due after a rollback; those tasks retain their timer obligation. Neither a Goal nor a separate Run completion record participates in dispatch.
|
||||
One Host timer drives active tasks. Runtime and management writes share one FIFO. Existing and reserved Sessions resolve through `schedule/resolve-agent`, so the embedding owns activation and the injected Web controller is the fallback. Reused-Session tasks hold their execution slot until the target Agent becomes idle; intervening occurrences coalesce. A `new` target permits executions beside one another. Due recurring records targeting the same existing Session share one message; `after` and `at` deliver separately.
|
||||
|
||||
Plugin-sourced `followup()` synchronously appends the message to the Session inbox. After a successful Session flush, one task-record put appends a saved delivery with its instruction snapshot and stores the latest `ScheduleDeliveryReceipt` together with the one-shot's `inactive` status or the recurring task's next target and status. Recurring tasks in the same batch share the receipt's `messageId`; each receipt retains that task's delivered occurrence time. Delivery never steers or cancels the current turn and does not wait for model completion.
|
||||
Timed and manual deliveries obey a per-task minimum interval of 60 seconds. `newSessionIntervalSeconds` can raise it for `new` targets, up to one week. The floor uses the last occurrence when the saved successor preserves the interval, otherwise the last acknowledgment, so delivery latency does not move the rule's anchor. Reads expose `executionActive` and `deliveryFloorEndsAt` only while applicable.
|
||||
|
||||
A failure to resolve the Session, enqueue the message, or confirm persistence leaves the task stored and reports a Host warning. A later management change or Host restart retries it. Session persistence and the task put are not atomic across stores. A crash after inbox persistence but before the task put can repeat delivery; the receipt does not close this gap. Schedule provides no model-result acknowledgement, execution cancellation, or external notification channel.
|
||||
Session inbox persistence precedes the task receipt write, so a crash between stores can repeat a delivery. The Host must be running. An active task targeting a Session contributes archive-admission activity; an archive stop deletes those active tasks.
|
||||
|
||||
After a committed management change, Schedule queues a best-effort notice to each involved loaded Agent except the actor. Notices join the next model step without opening a turn, and their Session I/O does not hold the management/delivery queue. New-per-delivery Sessions are not accumulated in involvement metadata.
|
||||
|
||||
<a id="historical-session-changes"></a>
|
||||
## Historical Session changes
|
||||
|
||||
Historical version-1 `schedule/change` events retain the frozen `LegacyScheduleRecord` types: `after`, `at`, and `every`, including old optional titles. They remain recorded Session data without a Schedule interpreter, warning hook, importer, or timer. Existing Host tasks in `schedule.json` still use the normal decoder and evaluator.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -457,64 +149,87 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.schedule` — `ScheduleService`
|
||||
|
||||
Shared management service; reads, deletion, and timing edits never activate a Session.
|
||||
Shared management service; reads and management writes never activate a Session, and only delivery creates or resumes one.
|
||||
|
||||
`sessionPersistence` is a load-order requirement rather than a directly called service: a delivery commits only when `ctx.sessions.flush()` reports that a `session/flush` listener participated, and the persistence backend providing this service is the plugin that registers that listener.
|
||||
`sessionPersistence` supplies header-only target validation and the `session/flush` listener that acknowledges durable inbox delivery through `ctx.sessions.flush()`.
|
||||
|
||||
```ts cordis-catalog
|
||||
/** Read effective delivery limits without loading or activating a task.
|
||||
* @returns The configured minimum spacing between deliveries that each create a Session, in seconds.
|
||||
*/
|
||||
@Remote('deliveryLimits') deliveryLimits(): { readonly newSessionIntervalSeconds: number }
|
||||
|
||||
/**
|
||||
* Create a reminder bound to the caller-selected Session without activating it.
|
||||
* Create one task with a Host-generated identity for a human Remote caller.
|
||||
* @param request - Title, prompt, recurrence, and delivery destination.
|
||||
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
|
||||
* @returns The stored task. Cancellation does not roll back an in-flight write.
|
||||
*/
|
||||
@Remote('create') async remoteCreate(request: ScheduleCreateRequest, signal?: AbortSignal): Promise<ScheduleCatalogEntry>
|
||||
|
||||
/**
|
||||
* Create one task and record the acting Agent's Session as involved; the Remote form omits that Session.
|
||||
*
|
||||
* The request must supply a title; a missing, blank-after-trim, or over-long
|
||||
* title rejects with `invalid_prompt` instead of deriving one from the prompt.
|
||||
* A Session a delegated child owns rejects with `subagent_session`, because delivery
|
||||
* can never reach it: the child is one whose delegation depth is above zero.
|
||||
* The record is built from the clock reading taken before the request joins the
|
||||
* serialized queue, so a create that waits behind a longer operation keeps its
|
||||
* request-time anchor and may already be due when the queue reaches it.
|
||||
* @param sessionId - Original Session receiving the reminder.
|
||||
* @param request - Validated tool selector, required title, and reminder content.
|
||||
* @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
|
||||
* @returns The durably stored schedule. Cancellation does not roll back an in-flight write.
|
||||
* The record is built from the clock reading taken before the request joins the serialized queue. A `session`
|
||||
* target must exist and must not belong to a delegated child; a `new_once` target reserves its Session identity.
|
||||
* @param request - Title, prompt, recurrence, and delivery destination.
|
||||
* @param actorSessionId - Session of the acting Agent, or undefined for a human caller.
|
||||
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
|
||||
* @returns The stored task. Cancellation does not roll back an in-flight write.
|
||||
*/
|
||||
async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: AbortSignal): Promise<ScheduleRecord>
|
||||
async create( request: ScheduleCreateRequest, actorSessionId?: SessionId, signal?: AbortSignal, ): Promise<ScheduleCatalogEntry>
|
||||
|
||||
/**
|
||||
* Read the selected Session's active tasks without resuming its Agent.
|
||||
* @param request - Session whose task list is requested.
|
||||
* @returns Persisted reminders in storage order.
|
||||
* Read one page of Host tasks without activating target Sessions.
|
||||
* @param request - Optional activity filter, page size, and offset.
|
||||
* @returns The requested page and total matching task count.
|
||||
*/
|
||||
@Remote('list') async list(request: ScheduleListRequest): Promise<ScheduleRecord[]>
|
||||
async list(request: ScheduleListRequest = {}): Promise<ScheduleListResult>
|
||||
|
||||
/**
|
||||
* Read all active and inactive Host reminders with their original Session bindings.
|
||||
* A deleted reminder has no row, so it is absent here.
|
||||
* Does not activate Sessions or read Session history.
|
||||
* @returns Reminders ordered by scheduledAt ascending, then lexicographically by id.
|
||||
* Remote form of the shared Host-wide list.
|
||||
* @param request - Optional activity filter, page size, and offset.
|
||||
* @returns The requested page and total matching task count.
|
||||
*/
|
||||
@Remote('list') async remoteList(request: ScheduleListRequest): Promise<ScheduleListResult>
|
||||
|
||||
/**
|
||||
* Read every Host task without a page bound or Session activation.
|
||||
* @returns All tasks, active first, with target and execution metadata.
|
||||
*/
|
||||
@Remote('catalog') async catalog(): Promise<ScheduleCatalogEntry[]>
|
||||
|
||||
/**
|
||||
* Read saved inbox deliveries without activating or reading the original Session.
|
||||
* The task's own row supplies its binding, so its records stay readable through this lookup.
|
||||
* @param request - Session binding, task identity, explicit limit, and optional exclusive message cursor.
|
||||
* @returns Newest-first deliveries in append order, or a task/cursor lookup failure.
|
||||
* @throws ScheduleInputError when limit is not a safe integer from 1 through 100.
|
||||
* Read one task without activating its target Sessions.
|
||||
* @param request - Task identity.
|
||||
* @returns The task, or a non-mutating miss.
|
||||
*/
|
||||
@Remote('history') async history(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>
|
||||
@Remote('view') async view(request: ScheduleViewRequest): Promise<ScheduleViewResult>
|
||||
|
||||
/**
|
||||
* Delete one task belonging to the selected Session, leaving queued messages intact.
|
||||
*
|
||||
* The row is removed: the task no longer schedules, leaves `list` and `catalog`, and its
|
||||
* saved delivery records go with it. A task bound to a Session a delegated child owns
|
||||
* stays deletable even though creation and timing edits refuse that binding, so a task
|
||||
* stored before that rule existed remains removable.
|
||||
* @param request - Session and exact task identity.
|
||||
* @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
|
||||
* @returns Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write.
|
||||
* Read one page of saved deliveries without activating or reading a target Session.
|
||||
* @param request - Task identity, explicit page size, and optional exclusive cursor.
|
||||
* @returns Newest-first saved deliveries, or a task/cursor lookup failure.
|
||||
*/
|
||||
@Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise<ScheduleDeleteResult>
|
||||
async history(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>
|
||||
|
||||
/**
|
||||
* Remote form of the target-based {@link ScheduleService.history}; an invalid limit rejects with
|
||||
* `schedule/input-invalid`.
|
||||
* @param request - Task identity, explicit limit, and optional exclusive message cursor.
|
||||
* @returns Newest-first deliveries in append order, or a task/cursor lookup failure.
|
||||
*/
|
||||
@Remote('history') async remoteHistory(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>
|
||||
|
||||
/**
|
||||
* Deliver one task now without consuming or moving its scheduled occurrence; an archived destination Session rejects
|
||||
* with `schedule/target-archived`.
|
||||
* @param request - Task identity.
|
||||
* @param signal - Cancellation checked after entering the delivery transaction and before delivery.
|
||||
* @returns Task state after Session persistence acknowledged the delivery, `schedule_execution_pending` while
|
||||
* another execution of the task is queued, running, or inside its delivery floor, or a non-mutating miss.
|
||||
*/
|
||||
@Remote('run') async run(request: ScheduleRunRequest, signal?: AbortSignal): Promise<ScheduleRunResult>
|
||||
|
||||
/**
|
||||
* Delete every ended Host task, leaving active tasks armed.
|
||||
@@ -526,27 +241,32 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor
|
||||
* failure rejects with every listed row still stored, while a backend
|
||||
* `unknown-state` failure may have removed rows and quarantines the Host
|
||||
* domain, so later schedule operations reject until the domain is reopened.
|
||||
* Every rejection announces `schedule/changed` because it does not establish
|
||||
* A successful nonempty batch emits `schedule/changed` without Agent notices.
|
||||
* A rejected batch announces `schedule/changed` because it does not establish
|
||||
* which rows landed, and clients then re-read the catalog.
|
||||
* @returns Count of removed rows; zero when the Host held no ended task.
|
||||
*/
|
||||
@Remote('deleteInactive') async deleteInactive(): Promise<ScheduleDeleteInactiveResult>
|
||||
|
||||
/**
|
||||
* Update the name, instruction, and timing of an active task within the original Session
|
||||
* binding without activating the Session or changing saved deliveries.
|
||||
*
|
||||
* Each supplied field replaces its stored value; an omitted field keeps it. A name or
|
||||
* instruction change alone does not reset the committed target. A Session a delegated
|
||||
* child owns returns the non-mutating `subagent_session` result, so an edit cannot
|
||||
* re-arm a task bound to a Session delivery can never reach, and the Web editor can
|
||||
* explain the refusal through the ordinary result it already renders.
|
||||
* @param request - Task binding, complete observed record, and any combination of timing, name, and instruction.
|
||||
* @param signal - Cancellation checked after domain readiness and FIFO waits, before persistence begins.
|
||||
* @returns The committed record, unchanged record for a no-op, or a non-mutating input/lookup/conflict/refusal result.
|
||||
* Storage and lifecycle failures reject; cancellation after a write starts does not roll it back.
|
||||
* Update or delete one task for a human Remote caller; an invalid patch rejects with `schedule/input-invalid`.
|
||||
* @param request - Task identity and one update or delete operation.
|
||||
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
|
||||
* @returns The updated task, the deletion outcome, or a non-mutating miss or conflict.
|
||||
*/
|
||||
@Remote('update') async update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise<ScheduleUpdateResult>
|
||||
@Remote('edit') async remoteEdit(request: ScheduleEditRequest, signal?: AbortSignal): Promise<ScheduleEditResult>
|
||||
|
||||
/**
|
||||
* Update or delete one task and record the acting Agent's Session as involved; the Remote form omits that Session.
|
||||
*
|
||||
* An update applies every patch field in one write and recomputes the next occurrence when the rule changes or
|
||||
* the task resumes. A supplied `expectedUpdatedAt` that differs from the stored value returns `schedule_conflict`.
|
||||
* @param request - Task identity and one update or delete operation.
|
||||
* @param actorSessionId - Session of the acting Agent, or undefined for a human caller.
|
||||
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
|
||||
* @returns The updated task, the deletion outcome, or a non-mutating miss or conflict.
|
||||
*/
|
||||
async edit( request: ScheduleEditRequest, actorSessionId?: SessionId, signal?: AbortSignal, ): Promise<ScheduleEditResult>
|
||||
```
|
||||
|
||||
Types: [SessionId](core.md)
|
||||
|
||||
+166
-446
@@ -2,448 +2,140 @@
|
||||
|
||||
[English](schedule.md) | 中文
|
||||
|
||||
Schedule 独立于 Session 的加载状态存储提醒,并将其投递到原 Session。本页记录 [`types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久与模型可见类型;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合方式与提醒文本格式。
|
||||
Schedule 独立于 Session 的激活状态存储宿主任务。同一管理 API 按 id 操作任务,同一求值器处理七种权威时间类型:`after`、`at`、`every`、`daily`、`weekly`、`cron` 和 `rrule`。目标可以复用已有 Session、创建一个 Session 后复用,或每次投递创建 Session。[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合与提醒格式;完整值类型和请求类型以 [`types.ts`](../../packages/schedule/schedule/src/types.ts) 为准。
|
||||
|
||||
## 持久记录
|
||||
<a id="durable-tasks"></a>
|
||||
## 持久任务
|
||||
|
||||
`ScheduleId` 是全局唯一的[品牌化 ID](core.zh.md#branded-ids)。版本 1 的 `schedule` storage domain 的 `tasks` 表将每条 `ScheduleRecord` 与原始 `sessionId` 绑定,并存储 `status: 'active' | 'inactive'` 和可选的 `lastDelivery`。缺少状态的记录规范化为 `active`,不扫描或重建历史 Session。创建时将目标规范化为 RFC 3339 UTC `scheduledAt`;`after` 保留提交的延迟,`every` 保留固定间隔,`daily` 与 `weekly` 保留规范化后的本地 `time` 及显式 `timeZone`,`cron` 保留规范化后的五字段 `expression` 及显式 `timeZone`。每日、每周与 cron 记录解码保留已提交时点及已存储的时区拼写。已存储的任务记录声明必填的 `title`,即任务界面显示的短名称;创建时必须提供该字段,且绝不从指令派生。任何已存储记录都不得缺少标题:`title` 缺失、去除首尾空白后为空、带首尾空白或超过 120 个字符的已存储记录都会以 `ScheduleLogError` 拒绝持久化解码;schedule domain 未声明备份并跳过的策略,因此一条这样的已存储任务会拒绝整个 domain 的打开,而不是被丢弃。仅显式删除会移除任务;此前已物理删除的任务不会被恢复或虚构。
|
||||
插件 README 拥有当前 [v2 任务 schema、v1 默认值与持久 JSON 示例](../../packages/schedule/schedule/README.zh.md#storage-format)。领域保留名称、路由和整单元布局。七种时间类型均保持权威性;规范化保留已提交发生时点,不将 Cron 转为 RRULE。
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable one-shot reminder created from a positive delay. */
|
||||
interface AfterScheduleRecord {
|
||||
/** Globally unique task identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for a delayed one-shot reminder. */
|
||||
readonly kind: 'after'
|
||||
/** Required stored task name; already trimmed, non-empty, and at most 120 characters. */
|
||||
readonly title: string
|
||||
/** Trimmed reminder content supplied at creation. */
|
||||
readonly prompt: string
|
||||
/** Positive safe-integer delay accepted at creation. */
|
||||
readonly afterSeconds: number
|
||||
/** Four-digit-year RFC 3339 UTC target. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
```
|
||||
损坏的存储、无效行、与任务身份不一致的键及不支持的版本在任务激活前拒绝。打开失败不改变介质。文件不存在时从空域开始。目标 Session 缺失的任务仍在管理界面中可见;读取任务从不激活其 Session。
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable one-shot reminder created from an absolute instant. */
|
||||
interface AtScheduleRecord {
|
||||
/** Globally unique task identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for an absolute one-shot reminder. */
|
||||
readonly kind: 'at'
|
||||
/** Required stored task name; already trimmed, non-empty, and at most 120 characters. */
|
||||
readonly title: string
|
||||
/** Trimmed reminder content supplied at creation. */
|
||||
readonly prompt: string
|
||||
/** Four-digit-year RFC 3339 UTC target. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
```
|
||||
<a id="storage-versioning"></a>
|
||||
## 存储版本
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable fixed-rate reminder aligned to creation or its most recent interval edit. */
|
||||
interface EveryScheduleRecord {
|
||||
/** Globally unique task identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for a fixed-rate recurring reminder. */
|
||||
readonly kind: 'every'
|
||||
/** Required stored task name; already trimmed, non-empty, and at most 120 characters. */
|
||||
readonly title: string
|
||||
/** Trimmed reminder content supplied at creation. */
|
||||
readonly prompt: string
|
||||
/** Fixed safe-integer interval, never below one minute. */
|
||||
readonly everySeconds: number
|
||||
/** Next anchor-aligned occurrence while active, or final occurrence when inactive. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
```
|
||||
README 定义[任务何时升级](../../packages/schedule/schedule/README.zh.md#storage-format)、SQLite 不变的物理 schema、修改成本以及未来兼容 v3。`compatibleVersions` 允许旧单元版本戳进入当前解码;正常编辑与投递持久化当前行。读取保持存储不变。共享的[领域版本策略](storage.zh.md#domain-versioning)同时适用于 JSON 和 SQLite。
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable daily wall-clock reminder; gaps skip a date and overlaps use the earlier instant. */
|
||||
interface DailyScheduleRecord {
|
||||
/** Globally unique task identity. */
|
||||
readonly id: ScheduleId
|
||||
/** Rule discriminator for a daily wall-clock reminder. */
|
||||
readonly kind: 'daily'
|
||||
/** Required stored task name; already trimmed, non-empty, and at most 120 characters. */
|
||||
readonly title: string
|
||||
/** Trimmed reminder content supplied at creation. */
|
||||
readonly prompt: string
|
||||
/** Local time normalized to HH:mm:ss.SSS. */
|
||||
readonly time: string
|
||||
/** Explicit IANA zone; equivalent timing edits retain the stored spelling. */
|
||||
readonly timeZone: string
|
||||
/** Committed next UTC instant while active, or final occurrence when inactive. */
|
||||
readonly scheduledAt: string
|
||||
}
|
||||
```
|
||||
<a id="recurrence-rules"></a>
|
||||
## 重复规则
|
||||
|
||||
```ts type-equiv
|
||||
/** One-shot task variants. */
|
||||
type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord
|
||||
```
|
||||
[`dsh-util-time`](../../packages/util/time/README.zh.md) 负责可复用的 `Cron`、`RRule` 解析及有界发生时点计算。两种解析值均暴露其权威表达式、时区、`next(after)` 和 `resolve(scheduledAt, acceptedAt)`。解析器返回接受值或拒绝原因;Schedule 将原因映射为自身输入错误,并负责记录验证、准入限制、持久化与投递。
|
||||
|
||||
```ts type-equiv
|
||||
/** Recurring Host task variants. */
|
||||
type RecurringScheduleRecord = EveryScheduleRecord | DailyScheduleRecord | WeeklyScheduleRecord | CronScheduleRecord
|
||||
```
|
||||
每个已存 `scheduledAt` 都是带毫秒精度、年份为 0001 至 9999 的规范 RFC 3339 UTC 时刻。解码不会根据当前时区数据重新计算它。只追赶最新一次的策略保留该已提交下界,投递最新到期时点一次,再提交第一个未来时点。规则耗尽后任务以 `ended` 状态保留。
|
||||
|
||||
```ts type-equiv
|
||||
/** Reminder rule and target, stored with its original Session binding. */
|
||||
type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord
|
||||
```
|
||||
| 类型 | 权威时间字段 |
|
||||
|---|---|
|
||||
| `after` | 正整数 `afterSeconds` 和已提交的一次性 UTC 目标。 |
|
||||
| `at` | 已提交的一次性 UTC 目标。输入接受显式偏移时刻,或本地日期、时间与显式时区。 |
|
||||
| `every` | 不小于 60 的整数 `everySeconds`;发生时点始终对齐已提交的间隔锚点。 |
|
||||
| `daily` | `HH:mm:ss.SSS` 形式的本地 `time` 与显式 `timeZone`。 |
|
||||
| `weekly` | Daily 字段加非空、唯一、升序的 ISO `weekdays` 集合,周一为 1、周日为 7。 |
|
||||
| `cron` | 规范五字段 `expression` 与显式 `timeZone`。 |
|
||||
| `rrule` | `rrule` 中一条规范 `DTSTART` 和一条 `RRULE` 行;`DTSTART` 提供锚点与时区。 |
|
||||
|
||||
本地日历规则跳过不存在的墙钟时间,并在重叠时选择较早的时刻。固定间隔保留经过时间的含义。一次性延迟可短于一分钟;首次投递之后仍适用单任务最小投递间隔。
|
||||
|
||||
<a id="absolute-time-input"></a>
|
||||
## 绝对时间输入
|
||||
|
||||
`at` 选择器可以是严格且带偏移量的 RFC 3339 字符串,也可以是精确的本地日历对象。本地形式让这种解释在工具边界保持显式:
|
||||
|
||||
```ts type-equiv
|
||||
/** Structured local-calendar input accepted by creation and timing edits. */
|
||||
interface LocalAtInput {
|
||||
/** Four-digit ISO calendar date. */
|
||||
readonly date: string
|
||||
/** Local wall-clock time with optional one-to-three digit milliseconds. */
|
||||
readonly time: string
|
||||
/** Explicit UTC or IANA Area/Location zone. */
|
||||
readonly time_zone: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Absolute selector accepted by creation and timing edits. */
|
||||
type AtInput = string | LocalAtInput
|
||||
```
|
||||
|
||||
随发行版交付的 Web 组合挂载 `schedule` 服务与 `ui-schedule` 任务页面。时钟读数与四个提醒工具归 preset 所有:`standard`、`cordis` 与 `ptc` 一同声明 `time-context` 与 `@deepseek-ai/dsh-tool-schedule`,`minimal` 两者都不声明。这三个 preset 中的 `tool-subagent` 与 `tool-subagent-fork` 两行都 deny 这四个工具,且每个工具都以 `subagent_session` 拒绝被委派的调用方,因此被委派的子 agent 既看不到这些工具,其调用也不会被接受。当 open turn 只有一个无歧义的浏览器时区时,Time-context 会告诉模型按该请求本地时区解释未明确限定时区的自然语言日期和时间;浏览器时区记录混合或缺失时,则告诉模型询问用户。该指引不是持久 Session 默认值:模型仍必须在字符串形式中传入偏移量,或在本地形式中传入 `time_zone`;Schedule 绝不会读取浏览器、Session、进程或模型上下文。
|
||||
|
||||
Schedule 会拒绝无效偏移量与时区、不带偏移量的字符串、非未来目标,以及落在夏令时缺口内的本地时间。遇到夏令时重叠时,会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,因此回放绝不依赖环境时区状态。
|
||||
`at` 选择器接受带显式 `Z` 或数字偏移的 RFC 3339 时刻,或 `{ date, time, time_zone }`。本地日期使用 `YYYY-MM-DD`;本地时间使用 `HH:mm:ss`,可带一至三位小数秒。歧义或不存在的日历值通过共享时间工具验证。新的绝对目标必须严格位于未来且可表示。
|
||||
|
||||
<a id="daily-wall-clock-input"></a>
|
||||
## 每日本地钟表时间输入
|
||||
## 每日与每周输入
|
||||
|
||||
`daily` 是与 `after_seconds`、`at`、`every_seconds`、`weekly` 和 `cron` 互斥的创建选择器之一。例如,`daily: { time: '23:00:00', time_zone: 'Asia/Shanghai' }` 选择该时区每个符合条件的本地日期的 23:00。对象必须精确包含以下字段,不包含日期、偏移量或间隔:
|
||||
Daily 输入提供 `{ time, time_zone }`;Weekly 另加显式 `weekdays`。创建时选择第一个严格位于未来的实际发生时点。追赶选择最新实际到期时点,并推进到之后匹配的本地日期。等价编辑保留已存 IANA 别名和固定 UTC 目标,包括毫秒。
|
||||
|
||||
```ts type-equiv
|
||||
/** Daily local-time selector accepted by creation and timing edits. */
|
||||
interface DailyInput {
|
||||
/** Local HH:mm:ss time with optional one-to-three fractional digits. */
|
||||
readonly time: string
|
||||
/** Explicit UTC or IANA Area/Location zone. */
|
||||
readonly time_zone: string
|
||||
}
|
||||
```
|
||||
<a id="fixed-rate-input-and-catch-up"></a>
|
||||
## 固定频率输入与追赶
|
||||
|
||||
创建时要求 `HH:mm:ss` 及可选的一至三位小数秒,拒绝闰秒和 `24:00`,将显式 IANA 时区规范化,并以 `HH:mm:ss.SSS` 存储 `time`。首个 `scheduledAt` 严格晚于创建时刻。不存在的本地时间或整个日期被跳过;重叠时间选择较早时点,每个本地日期一次。若较早时点已过去,较晚的重复时点不符合条件。仅 UTC 目标年份受 0001–9999 范围约束;UTC 范围边缘的本地日期可以落在第 0 年或第 10000 年。
|
||||
|
||||
已保存的下一目标是已提交的 UTC 时点。宿主解码器与重启保留该时点,不依据时区规则重新解析;规范名称变化后,有效的已存储时区别名仍可读取。后续目标使用宿主当前的 IANA 数据。下一目标必须晚于投递判断时刻,并且其本地日期晚于所选发生时点的日期。没有可表示的未来 UTC 目标时,创建以 `time_out_of_range` 失败。
|
||||
|
||||
## 固定速率输入与补偿
|
||||
|
||||
`every_seconds` 是至少 60 秒的安全整数间隔,与创建时间对齐。它衡量经过的时间,而不是每日本地钟表时间:发生偏移量变化时,`every_seconds: 86400` 不能替代 `daily`。协议不提供共享冷却时间或跨记录准入规则;下文描述的 cron 选择器是钟表时间语义,不是固定速率间隔。
|
||||
|
||||
宿主启动时若 Every、Daily、Weekly 或 Cron 记录已逾期,只发送最新一次到期发生时点。Every 推进到投递判断时刻之后第一个与当前间隔起算点对齐的目标;Daily、Weekly 和 Cron 遵循上文与下文的本地日期规则。错过的发生时点不会累积。若没有可表示的下一 UTC 目标,投递后保留未运行记录及最近一次回执。
|
||||
|
||||
一次性提醒各自产生一条 follow-up。同一次扫描中属于同一 Session 的到期重复记录共用由 `renderRecurringReminderBatchFraming` 渲染的一条 follow-up,每条记录各包含最新一次触发。一分钟下限仅适用于固定速率间隔;投递不等待目标 Agent 空闲。
|
||||
Every 输入提供 `every_seconds`。新间隔从接受时间开始;修改间隔会在编辑被接受时建立新锚点。不变的间隔或名称/指令编辑保留原锚点。停机后合并错过的间隔,而非逐次重放。
|
||||
|
||||
<a id="cron-wall-clock-input"></a>
|
||||
## Cron 钟表时间输入
|
||||
## Cron 输入
|
||||
|
||||
`cron` 是互斥的创建选择器之一。它的对象只携带严格的标准五字段 Vixie 表达式 `minute hour day-of-month month day-of-week` 和显式 IANA 时区,例如 `cron: { expression: '*/15 9-17 * * 1-5', time_zone: 'Asia/Shanghai' }`。字段取值范围如下:
|
||||
Cron 输入提供 `{ expression, time_zone }`。五个数字字段依次为分钟、小时、月内日期、月份和星期。支持的原子形式为 `*`、单值、闭区间、星号或区间步长,以及逗号分隔列表。周日接受 0 或 7,规范为 0。拒绝名称、宏、秒字段和单值后跟步长的形式。
|
||||
|
||||
| 字段 | 取值范围 | 说明 |
|
||||
|---|---|---|
|
||||
| minute | 0-59 | |
|
||||
| hour | 0-23 | |
|
||||
| day-of-month | 1-31 | |
|
||||
| month | 1-12 | |
|
||||
| day-of-week | 0-7 | `0` 和 `7` 都表示周日。 |
|
||||
Cron 保留 Vixie 的日期匹配方式:两个日期字段都受限时,月内日期**或**星期任意一个匹配即可;任一字段以 `*` 开头(包括带步长的星号)时,两组匹配值均须匹配。规范化保留此差异。搜索受一个 400 年公历周期和四位 UTC 年份范围约束。启用时拒绝没有可表示未来时点的规则。
|
||||
|
||||
每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。其他写法一律以 `invalid_rule` 拒绝,且错误信息会指出违规字段:`L`、`W`、`#` 运算符,`JAN`/`MON` 名称,`@daily` 风格宏,带秒字段的六字段表达式,超出范围的值、反向范围、零步长和空字段。由于该方言只有五个字段,最小间隔为一分钟;亚分钟级调度仍然不受支持。
|
||||
Cron 记录从不转换为 RRULE。读取、描述性编辑、重新指定目标和重启均保留其已存表达式与时区作为权威来源。自定义编辑器接受五字段 Cron 表达式,并显示显式时区选择器。
|
||||
|
||||
创建时会先规范化表达式再存储。规范化会把每个字段展开为匹配值集合,再确定性地重新编码该集合:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长。保留每个字段的星号标志,正是下文 day-of-month/day-of-week 规则在存储的表达式上仍与 Vixie 一致的原因。持久化解码器会拒绝非规范化的已存储表达式。
|
||||
<a id="rrule-input"></a>
|
||||
## RRULE 输入
|
||||
|
||||
首个目标是该时区内本地日期与时间匹配的第一个严格未来时点。它复用 Daily 与 Weekly 的本地时间解析:不存在的本地时间被跳过,重叠时间每个日期只取较早一次。已提交的 UTC `scheduledAt` 在重启时绝不重新计算。
|
||||
RRULE 接受有界的 RFC 5545 子集,以共享的 [`rrule.ts` 解析器](../../packages/util/time/src/rrule.ts)为准。输入恰好包含两条非空内容行:先 `DTSTART`,再 `RRULE`。开始时间为真实的 `YYYYMMDDTHHMMSS` 日期时间,使用 UTC `Z` 或显式 IANA `TZID`;拒绝浮动本地时间、数字偏移、仅日期值及额外内容行。引擎使用 `rrule` 2.8.1,支持 `MINUTELY`、`HOURLY`、`DAILY`、`WEEKLY`、`MONTHLY` 和 `YEARLY`。一次性投递使用 `COUNT=1`。
|
||||
|
||||
day-of-month 与 day-of-week 遵循 Vixie 语义。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此 `*/1` 与 `*/2` 都是星号字段,而 `1-31` 与 `0-7` 受限。任一字段为星号字段时,本地日期只有在两个字段都匹配时才算匹配;两个字段都不是星号字段时,匹配任一字段即算匹配。
|
||||
只接受 `FREQ`、`INTERVAL`、`COUNT`、`UNTIL`、`WKST`、`BYSECOND`、`BYMINUTE`、`BYHOUR`、`BYDAY`、`BYMONTHDAY`、`BYYEARDAY`、`BYWEEKNO`、`BYMONTH` 和 `BYSETPOS`;字段名和列表值必须唯一。`INTERVAL` 为正安全整数。`WKST` 为 `MO` 至 `SU` 中的星期,默认 `MO`。数字列表须在解析器规定的范围内。分钟/小时规则不接受 `BY` 部分。`BYMONTHDAY` 和带序数的 `BYDAY` 仅用于每月或每年;`BYYEARDAY` 和 `BYWEEKNO` 仅用于每年。带序数的 `BYDAY` 不能与 `BYWEEKNO` 组合,`BYSETPOS` 必须搭配另一项 `BY`。
|
||||
|
||||
cron 解码保留已提交时点及已存储的时区拼写,并拒绝非规范化的已存储表达式。cron 仅属于当前宿主的 `ScheduleRecord`;冻结的历史 Session 解码器不接受它。
|
||||
验证发生在枚举之前。拒绝 `SECONDLY`、多个 `BYSECOND` 值及一个频率周期中超过 10,080 个可能候选时点的规则。`COUNT` 为 1 至 1,000,不能与 `UNTIL` 共存;`UNTIL` 为以 `Z` 结尾的绝对 UTC 日期时间。开始与截止年份为 0100 至 9999。集合位置至多 31 个,每个位置为 −366 至 −1 或 1 至 366,且必须在有界周期内。较严格的新规则编写检查还会在周期至多有一个候选时点时拒绝 `BYSETPOS`;解码和恢复未改变的受支持规则仍使用正常求值器。
|
||||
|
||||
## 历史 Session 变更
|
||||
不存在的本地 `DTSTART` 会被拒绝。后续不存在的本地发生时点会跳过,且不消耗 `COUNT`;重叠时间选择较早时刻。即使正负集合位置选中同一时点,`COUNT` 也仅计数一次。
|
||||
|
||||
版本 1 的 `schedule/change` 事件仍可作为历史 Session 数据解码。其 create 和 fold 类型使用 `LegacyScheduleRecord`,仅接受 After、At 和 Every;Daily、Weekly 与 Cron 仅属于当前宿主的 `ScheduleRecord`。宿主记录解码器独立于冻结的历史解码器。在 title 出现之前写入的 create 记录不含 `title`,因此历史解码器接受该缺失成员,而宿主解码器仍要求它。历史事件不会填充 storage domain 或触发投递。这些事件中的已有提醒需要显式通过 `schedule_create` 重新创建;不会隐式迁移 Session 或转换旧 `at` 记录。
|
||||
自定义 RRULE 文本保留其 `DTSTART` 时区。面向模型的工具继续指导并接受 RRULE;共享管理 API 则显式接受每种时间类型。
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Frozen Session event and fold vocabulary; daily rules belong only to Host storage.
|
||||
*
|
||||
* A version-1 event written before titles existed persists no `title` member, so
|
||||
* `after`, `at`, and `every` decode without one and stay readable. The Host task
|
||||
* record requires the member and never persists a record without it.
|
||||
*/
|
||||
type LegacyScheduleRecord =
|
||||
| LegacyAfterScheduleRecord
|
||||
| LegacyAtScheduleRecord
|
||||
| LegacyEveryScheduleRecord
|
||||
```
|
||||
<a id="delivery-targets"></a>
|
||||
## 投递目标
|
||||
|
||||
```ts type-equiv
|
||||
/** Creates one durable reminder record. */
|
||||
interface ScheduleCreateChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'create'
|
||||
readonly schedule: LegacyScheduleRecord
|
||||
}
|
||||
```
|
||||
| 目标 | 投递行为 |
|
||||
|---|---|
|
||||
| `session` | 投递到一个已有 Session。启用时在管理 FIFO 内检查其存在、未归档且不属于委托子 Agent。投递时再次检查归档状态。 |
|
||||
| `new_once` | 首次投递前预留 Session id,需要时创建该 Session,然后保存为 `session` 并复用。重试和重启保留该预留标识。 |
|
||||
| `new` | 每次投递创建 Session。创建前持久化 `pendingDelivery.targetSessionId`,使失败尝试重用同一 Session。 |
|
||||
|
||||
```ts type-equiv
|
||||
/** Deletes one currently active reminder. */
|
||||
interface ScheduleDeleteChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'delete'
|
||||
readonly id: ScheduleId
|
||||
}
|
||||
```
|
||||
创建时省略目标则默认 `new_once`。新增或替换 `new`、`new_once` 目标需要嵌入环境提供 Session 创建能力。SDK 组合缺少该能力时,在保存前以 `invalid_rule` 拒绝这些请求;已有 `session` 目标仍可使用。创建值 `cwd` 和 `agentPreset` 可省略,使用 Host 默认值。创建的 Session 会加入其目录已注册的 Workspace(如有),且尚无标题时使用任务标题;`new` 另加该规则时区中的创建分钟。加入或重命名失败会记录日志,但不阻断投递。
|
||||
|
||||
```ts type-equiv
|
||||
/** Records that one active one-shot reminder entered the durable dispatch history. */
|
||||
interface OneShotScheduleDispatchChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'dispatch'
|
||||
readonly id: ScheduleId
|
||||
}
|
||||
```
|
||||
<a id="task-management"></a>
|
||||
## 任务管理
|
||||
|
||||
```ts type-equiv
|
||||
/** Records one fixed-rate decision and advances directly past missed occurrences. */
|
||||
interface EveryScheduleDispatchChange {
|
||||
readonly version: 1
|
||||
readonly operation: 'dispatch'
|
||||
readonly id: ScheduleId
|
||||
/** Wall-clock decision time used to select the latest due occurrence. */
|
||||
readonly acceptedAt: string
|
||||
}
|
||||
```
|
||||
所有调用方使用相同的 `ScheduleCreateRequest`、`ScheduleCatalogEntry`、`ScheduleTiming` 和 `ScheduleEditRequest`。浏览器安全类型来自 `@deepseek-ai/dsh-schedule/client`。不存在 Session 绑定的管理入口或第二套类型别名。
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable dispatch shapes supported by the current rule set. */
|
||||
type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispatchChange
|
||||
```
|
||||
| 操作 | 行为 |
|
||||
|---|---|
|
||||
| `create(request, actorSessionId?, signal?)` | 分配 id 并保存标题、指令、带类型的 `timing` 及可选目标。 |
|
||||
| `list(request?)` | 分页读取;默认 100、最多 500,可选 `active` 过滤及非负 `offset`。 |
|
||||
| `catalog()` / `view({ id })` | 无分页上限读取所有任务,或读取一个任务。 |
|
||||
| `history({ id, limit, before? })` | 不激活 Session 地读取已保存投递;limit 为 1 至 100。 |
|
||||
| `edit(request, actorSessionId?, signal?)` | 按 id 原子应用 `update` patch 或 `delete`。 |
|
||||
| `deleteInactive()` | 一次批量写入仅删除 `ended` 任务,保留活动和暂停任务,返回 `{ deleted }`。空集合不写入。 |
|
||||
| `run({ id }, signal?)` | 经正常准入立即投递,不改变已计划发生时点或生命周期。 |
|
||||
| `deliveryLimits()` | 读取 `new` 目标的配置最小间隔。 |
|
||||
|
||||
```ts type-equiv
|
||||
/** Strict version-1 durable Schedule mutation union. */
|
||||
type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
|
||||
```
|
||||
Remote 路由提供 `create`、`list`、`catalog`、`view`、`history`、`edit`、`deleteInactive`、`run` 和 `deliveryLimits`;传输包装器委托给相同管理方法。Remote 验证失败使用带 Schedule 输入码的 `schedule/input-invalid`。原生调用方收到 `ScheduleInputError`。
|
||||
|
||||
历史 decoder 与 fold 拒绝未知版本、额外字段、复用 ID 和无效转换。该事件仍编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。Session 历史不负责决定当前活动任务。
|
||||
非空的 `deleteInactive` 批量写入成功后发出 `schedule/changed`,供读取方刷新;不向 Agent 排入通知。批量写入被拒绝时也发出该事件,让读取方丢弃旧状态。正常失败保留全部行;`unknown-state` 失败可能已移除行,并隔离领域直到重新打开。失败调用以拒绝结束,不返回删除数量。
|
||||
|
||||
## 活动视图与管理
|
||||
更新可以替换标题、指令、带类型时间、目标或活动状态。省略字段保留原值。规则改变或显式恢复时,在 FIFO 接受时刻计算未来时点;等价时间及描述性编辑保留已提交目标。已投递的一次性任务需要新的未来时间才能恢复。重新指定目标不要求转换时间类型。
|
||||
|
||||
工具值将存储记录与当前墙钟派生的时间状态组合。宿主在投递需要时恢复原 Session;列出和删除任务不会激活该 Session。
|
||||
`expectedUpdatedAt` 是可选的乐观比较。字符串与已存修订比较;显式 `null` 与缺失的修订比较。不一致时返回 `schedule_conflict`。编辑没有修订的行会创建修订;即使时钟回拨,后续修订仍单调递增。开始写入前检查取消,已经开始的持久写入不回滚。
|
||||
|
||||
```ts type-equiv
|
||||
/** Current delivery timing derived from the durable record and wall clock. */
|
||||
type ScheduleState = 'scheduled' | 'overdue'
|
||||
```
|
||||
<a id="timing-edits"></a>
|
||||
## 时间编辑
|
||||
|
||||
```ts type-equiv
|
||||
/** Host-driven delivery resumes the original Session when needed. */
|
||||
type ScheduleDeliveryMode = 'host'
|
||||
```
|
||||
`timing` 判别字段只选择其匹配的载荷,例如 `{ kind: "cron", cron: { expression: "0 9 * * 1-5", time_zone: "Asia/Shanghai" } }` 或 `{ kind: "rrule", rrule: "DTSTART:20990101T090000Z\nRRULE:FREQ=DAILY" }`。其他选择器使用 `after_seconds`、`at`、`every_seconds`、`daily` 或 `weekly`。持久化前拒绝无效组合。UI 经相同 API 保存带类型的 Cron/RRULE 输入,并保留未修改的时间规则。
|
||||
|
||||
```ts type-equiv
|
||||
/** Complete model-facing view of one active reminder. */
|
||||
type ScheduleView = ScheduleRecord & {
|
||||
/** Whether the target remains in the future. */
|
||||
readonly state: ScheduleState
|
||||
/** Reminder delivery never leaves the owning session. */
|
||||
readonly deliveryMode: ScheduleDeliveryMode
|
||||
}
|
||||
```
|
||||
<a id="saved-delivery-records"></a>
|
||||
## 已保存投递记录
|
||||
|
||||
[工具目录](../tool-catalog.zh.md#deepseek-aidsh-tool-schedule) 负责 `schedule_create`、`schedule_list`、`schedule_delete` 和 `schedule_update` 的 schema,这四个工具由 [`@deepseek-ai/dsh-tool-schedule`](../../packages/schedule/tool-schedule/README.zh.md) 在挂载它的每个 preset 中贡献;宿主服务拥有存储与投递。创建、删除和更新在 storage domain 写入确认后返回成功。宿主级队列将这些变更与到期投递串行化;删除任务不会移除已入队消息。模型工具操作当前 Session,共享宿主的 create、list、update 和 delete 方法接受显式 Session 绑定。`create` 与 `update` 在其委派深度大于零时以 `subagent_session` 拒绝被委派子 agent 拥有的 Session,且投递无法到达这样的 Session。四个模型工具在工具层拒绝被委派的调用方,而 `list`、`catalog`、`history` 与 `delete` 仍服务该 Session。创建必须提供 `title`,其去除首尾空白后必须非空且不超过 120 个字符;缺失、空白或过长的标题以 `invalid_prompt` 拒绝,且创建过程绝不从指令派生标题。create 和 list 视图在指令之外同时携带已存储的 `title`,且 `title` 缺失或非法的已存储记录在解码时被拒绝。
|
||||
回执记录 `scheduledAt`、`deliveredAt`、`messageId` 和 `targetSessionId`;它确认持久 Session 收件箱写入,而非模型完成。已保存记录还可含不可变的已发送 `prompt`。旧回执缺少目标 id 时,在任何重新指定目标的编辑之前使用已解析的可复用目标,因此后续目标不会重写其历史。 回执既没有显式目的地也没有已知复用目标时,拒绝解码;新默认预留不能证明历史目的地。
|
||||
|
||||
```ts type-equiv
|
||||
/** Reminder creation selector, shared by the model consumer and Host service. */
|
||||
interface ScheduleCreateRequest {
|
||||
/** Non-empty reminder text. */
|
||||
prompt: string
|
||||
/** Required task name of at most 120 characters, non-empty after trimming; names the card, detail heading, and task lists. */
|
||||
title: string
|
||||
/** Relative one-shot delay in seconds. */
|
||||
after_seconds?: number
|
||||
/** Absolute one-shot target. */
|
||||
at?: AtInput
|
||||
/** Fixed recurrence interval in seconds. */
|
||||
every_seconds?: number
|
||||
/** Daily wall-clock time in an explicit IANA zone. */
|
||||
daily?: DailyInput
|
||||
/** Weekly wall-clock time and explicit ISO weekday set in an IANA zone. */
|
||||
weekly?: WeeklyInput
|
||||
/** Five-field cron expression evaluated in an explicit IANA zone. */
|
||||
cron?: CronInput
|
||||
}
|
||||
```
|
||||
新任务从已知的空历史开始。旧行没有 `deliveryHistory` 时,暴露其最新回执(如有),并设置 `earlierRecordsUnavailable: true`;不会编造丢失的历史或指令快照。省略的 `earlierRecordsPruned` 读为 false。消息 id 重复,或最新保存记录与 `lastDelivery` 不一致,均拒绝解码。
|
||||
|
||||
```ts type-equiv
|
||||
/** Session-scoped task list, without Agent activation. */
|
||||
interface ScheduleListRequest {
|
||||
/** Original Session binding. */
|
||||
sessionId: SessionId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Delete request identifying a task within its original Session binding. */
|
||||
interface ScheduleDeleteRequest extends ScheduleListRequest {
|
||||
/** Task to remove. */
|
||||
id: ScheduleId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Successful `schedule_delete` value, including the non-mutating not-found result. */
|
||||
type ScheduleDeleteResult =
|
||||
| { readonly id: ScheduleId; readonly deleted: true }
|
||||
| { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Successful Host-wide removal of every ended task, whether or not any remained. */
|
||||
interface ScheduleDeleteInactiveResult {
|
||||
/** Ended task rows this call removed; zero when the Host held none. */
|
||||
readonly deleted: number
|
||||
}
|
||||
```
|
||||
|
||||
## Web 目录
|
||||
|
||||
Remote 方法 `schedule.catalog()` 返回活动和未运行的宿主任务及其原始 Session 绑定,先按 `scheduledAt` 升序排列,再按 `id` 字典序排列。它只读取 Schedule domain,不激活 Session 或读取其历史。浏览器消费方接收以下可安全用于浏览器的类型:
|
||||
|
||||
```ts type-equiv
|
||||
/** Durable Session inbox delivery acknowledgment, not model execution completion. */
|
||||
interface ScheduleDeliveryReceipt {
|
||||
/** Canonical UTC target of the delivered occurrence. */
|
||||
readonly scheduledAt: string
|
||||
/** Canonical UTC time sampled after Session persistence acknowledged delivery. */
|
||||
readonly deliveredAt: string
|
||||
/** Identity of the delivered message, shared by tasks in one recurring batch. */
|
||||
readonly messageId: MessageId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Browser-safe retained reminder with its original Session binding. */
|
||||
type ScheduleCatalogEntry = ScheduleRecord & {
|
||||
/** Session receiving this reminder when it becomes due. */
|
||||
readonly sessionId: SessionId
|
||||
/** Inactive reminders remain visible but never schedule another delivery. */
|
||||
readonly status: 'active' | 'inactive'
|
||||
/** Most recent durably acknowledged inbox delivery, when available. */
|
||||
readonly lastDelivery?: ScheduleDeliveryReceipt
|
||||
}
|
||||
```
|
||||
|
||||
Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;普通失败时不删除任何行;后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,之后该领域的操作都会拒绝,直到重新打开。任何拒绝都会先发出 `schedule/changed`,客户端重新读取。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。
|
||||
|
||||
随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
|
||||
|
||||
## 修改时间
|
||||
|
||||
`schedule.update({ sessionId, id, expected, change })` 修改活动任务,不激活其 Session。`expected` 是完整的已观察 `ScheduleRecord`,不是附带额外元数据的目录条目。Host 在投递和删除共用的 FIFO 内将它与当前记录比较。正常投递可能在编辑期间推进目标;此时旧快照返回 `schedule_conflict`,不会覆盖当前规则。任务不存在或绑定不匹配时返回 `schedule_not_found`,未运行任务返回 `schedule_ended`。
|
||||
|
||||
```ts type-equiv
|
||||
/** Timing-only edit; it may select a different recurrence kind than the stored record, and one-shot targets use an absolute `at`. */
|
||||
type ScheduleTimingChange =
|
||||
| { readonly kind: 'at'; readonly at: AtInput }
|
||||
| { readonly kind: 'every'; readonly every_seconds: number }
|
||||
| { readonly kind: 'daily'; readonly daily: DailyInput }
|
||||
| { readonly kind: 'weekly'; readonly weekly: WeeklyInput }
|
||||
| { readonly kind: 'cron'; readonly cron: CronInput }
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Compare-and-update request within the original Session binding. */
|
||||
interface ScheduleUpdateRequest extends ScheduleDeleteRequest, ScheduleUpdateContent {
|
||||
/** Complete record observed when editing began, including the committed target. */
|
||||
readonly expected: ScheduleRecord
|
||||
/** New timing; its kind may differ from the stored record's kind, and an omitted value keeps the committed target. */
|
||||
readonly change?: ScheduleTimingChange
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Successful current record, non-mutating lookup/conflict failure, or invalid name/instruction/timing. */
|
||||
type ScheduleUpdateResult =
|
||||
| { readonly id: ScheduleId; readonly updated: boolean; readonly record: ScheduleRecord }
|
||||
| ScheduleUpdateMiss
|
||||
| ScheduleToolError
|
||||
```
|
||||
|
||||
Daily 规则改变后,沿用本地时间和时区语义计算严格晚于当前时间的首个目标。Cron 规则改变后,先规范化表达式,再按相同的本地日期、缺口与较早重叠规则计算首个严格未来目标。Every 间隔改变后,以接纳保存时刻加该间隔建立新的起点。After 或 At 任务可以在保留 id 的同时,通过 At 记录接收新的绝对目标。这些操作保留已存储的 `title`、指令、原始 Session 绑定、生命周期状态和全部已保存回执。变更也可以选择不同的重复类型,此时按新规则重算目标,并保留同一 id、标题、指令、Session 绑定、状态与全部已保存回执。归一化后等价的规则返回 `updated: false` 及未改变的记录,不重写存储、不重设目标,也不发出变更事件。写法不同但等价的 Cron 表达式就是这样的无变更,因为比较使用规范化表达式与规范化时区。
|
||||
|
||||
输入校验复用现有时间错误。改变后的绝对目标在 FIFO 槽位开始执行时仍须位于未来。任务 put 成功后才发出变更通知并重算 timer。存储和生命周期失败会 reject;取消检查发生在排队和初始化等待之后,但不能回滚已经开始的写入。一次性记录保留 UTC 时点,而非编辑表单中的 IANA 时区。时间编辑不追加历史 Session 事件:模型通过 `schedule_update` 做同样的原地修改,它与读取到的记录做比对,而不是删除后重建任务。
|
||||
|
||||
## 已保存的发送记录
|
||||
|
||||
`schedule.history({ sessionId, id, limit })` 读取已保存的回执,不激活 Session。显式 limit 必须是 1–100 的安全整数。分页按追加顺序逆序排列,不按墙钟排序;可选的消息 id 游标排除上一页最早的记录。新记录追加不会改变该游标的定位。任务不存在或 Session 绑定错误时返回 `schedule_not_found`;游标未知时返回 `delivery_cursor_not_found`,不会视为查询成功的空页。
|
||||
|
||||
```ts type-equiv
|
||||
/** Saved inbox delivery with its immutable sent prompt when recorded by this Host. */
|
||||
interface ScheduleDeliveryRecord extends ScheduleDeliveryReceipt {
|
||||
/** Prompt sent for this occurrence; unavailable for legacy receipts. */
|
||||
readonly prompt?: string
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Explicitly bounded saved-delivery query within one Session binding. */
|
||||
interface ScheduleDeliveryHistoryRequest extends ScheduleDeleteRequest {
|
||||
/** Required safe-integer page size from 1 through 100. */
|
||||
limit: number
|
||||
/** Message identity of the oldest entry in the previous page; excluded from this page. */
|
||||
before?: MessageId
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Configured limits applied when appending one task's delivery receipt. */
|
||||
interface DeliveryRetentionBounds {
|
||||
/** Retained window in days, measured back from the acknowledgment being appended. */
|
||||
readonly days: number
|
||||
/** Maximum retained records per task; the newest survive. */
|
||||
readonly records: number
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** Newest-first saved deliveries, or a non-mutating task/cursor lookup failure. */
|
||||
type ScheduleDeliveryHistoryResult =
|
||||
| {
|
||||
readonly id: ScheduleId
|
||||
readonly records: ScheduleDeliveryRecord[]
|
||||
/** Whether earlier delivery records may be unavailable; missing receipts are never reconstructed. */
|
||||
readonly earlierRecordsUnavailable: boolean
|
||||
/** True only after an append removed saved records; absent legacy evidence is false. */
|
||||
readonly earlierRecordsPruned: boolean
|
||||
/** Current Host retention configuration, also used by the delivery writer. */
|
||||
readonly retention: DeliveryRetentionBounds
|
||||
/** Oldest returned message identity, present only when older saved records remain. */
|
||||
readonly nextBefore?: MessageId
|
||||
}
|
||||
| { readonly id: ScheduleId; readonly code: 'schedule_not_found' | 'delivery_cursor_not_found' }
|
||||
```
|
||||
|
||||
新任务初始化空历史。缺少历史字段的旧任务仅展示已有的最近一次回执,标明更早记录不可用,且读取不会重写任务行。后续确认会保留该回执,并追加带有指令快照的真实新回执。已丢失的历史与缺失的旧指令快照不会被重建。历史、最近回执、状态和下一目标由同一次任务行写入提交;显式删除任务会停止后续投递、把任务行连同其已保存的回执一并移除,并使其离开 `list` 与 `catalog`;对话消息不受影响。已保存的发送记录在追加确认时按配置的 `deliveryHistoryDays` 窗口(以每条回执的 `deliveredAt` 向前计算)与 `deliveryHistoryRecords` 条数上限裁剪;新追加的最近一次回执始终保留,发生裁剪时标记更早记录不可用。分页限制返回的记录数量,不限制存储大小或指令字节数。
|
||||
历史按追加顺序排列,不受时钟回拨影响。确认在同一任务提交中追加并裁剪,默认保留最多 200 条、30 天,同时保留最新回执。查询页从新到旧,使用不含游标自身的消息 id 游标。硬删除移除任务及历史,不移除已入队的 Session 消息。
|
||||
|
||||
<a id="host-delivery"></a>
|
||||
## 宿主投递
|
||||
|
||||
宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递通过 `schedule/resolve-agent` 解析原 Session,包括尚未激活的会话。嵌入方拥有激活过程:SDK 处理方等待初始化,并保留其 Session handle 和模型选项;没有处理方时由 Web Session controller 恢复会话。组合等待其激活提供方就绪后再启动 Schedule。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。
|
||||
一个 Host 定时器驱动活动任务。运行时与管理写入共用一个 FIFO。已有和预留 Session 通过 `schedule/resolve-agent` 解析,由嵌入环境拥有激活,已注入的 Web 控制器作为后备。复用 Session 的任务占用执行槽,直到目标 Agent 空闲;期间发生的时点会合并。`new` 目标允许多个执行并行。到期且投向相同已有 Session 的重复记录共享一条消息;`after` 和 `at` 单独投递。
|
||||
|
||||
插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后,一次任务记录 put 追加带有指令快照的发送记录,并同时存储最新的 `ScheduleDeliveryReceipt`,以及一次性任务的 `inactive` 状态或重复任务的下一目标和状态。同一批次的重复任务共享回执的 `messageId`;每条回执保留该任务已投递的发生时点。投递不会中途引导或取消当前轮次,也不等待模型完成。
|
||||
定时与手动投递均遵守单任务 60 秒最小间隔。`newSessionIntervalSeconds` 可为 `new` 目标提高间隔,最多一周。已存后继时点保持间隔时,下界从上次发生时点计算;否则从上次确认计算,因此投递延迟不会移动规则锚点。读取仅在适用时暴露 `executionActive` 和 `deliveryFloorEndsAt`。
|
||||
|
||||
Session 获取、消息入队或持久化确认失败时,任务继续保存在存储中,并报告宿主警告。后续管理变更或宿主重启会重试。Session 持久化与任务 put 不具有跨存储原子性。收件箱已持久化、任务尚未 put 时发生崩溃,可能重复投递;回执并未消除这一间隔。Schedule 不提供模型结果回执、执行取消或外部通知渠道。
|
||||
Session 收件箱持久化先于任务回执写入,因此两份存储之间崩溃可能重复投递。Host 必须运行。指向某 Session 的活动任务会参与其归档准入;归档停止操作会删除这些活动任务。
|
||||
|
||||
管理变更提交后,Schedule 向除操作者之外的每个已加载相关 Agent 排队发送尽力而为的通知。通知加入下一模型步骤,不开启新轮次;其 Session I/O 不占用管理/投递队列。每次投递新建的 Session 不会累积到相关元数据中。
|
||||
|
||||
<a id="historical-session-changes"></a>
|
||||
## 历史 Session 变更
|
||||
|
||||
历史版本 1 的 `schedule/change` 事件保留冻结的 `LegacyScheduleRecord` 类型:`after`、`at`、`every`,包括原来的可选标题。它们仍是已保存的 Session 数据,Schedule 不为其保留解释器、警告钩子、导入器或定时器。`schedule.json` 中已有的 Host 任务仍使用正常的解码器与求值器。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
@@ -457,64 +149,87 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
|
||||
|
||||
### `ctx.schedule` — `ScheduleService`
|
||||
|
||||
Shared management service; reads, deletion, and timing edits never activate a Session.
|
||||
Shared management service; reads and management writes never activate a Session, and only delivery creates or resumes one.
|
||||
|
||||
`sessionPersistence` is a load-order requirement rather than a directly called service: a delivery commits only when `ctx.sessions.flush()` reports that a `session/flush` listener participated, and the persistence backend providing this service is the plugin that registers that listener.
|
||||
`sessionPersistence` supplies header-only target validation and the `session/flush` listener that acknowledges durable inbox delivery through `ctx.sessions.flush()`.
|
||||
|
||||
```ts cordis-catalog
|
||||
/** Read effective delivery limits without loading or activating a task.
|
||||
* @returns The configured minimum spacing between deliveries that each create a Session, in seconds.
|
||||
*/
|
||||
@Remote('deliveryLimits') deliveryLimits(): { readonly newSessionIntervalSeconds: number }
|
||||
|
||||
/**
|
||||
* Create a reminder bound to the caller-selected Session without activating it.
|
||||
* Create one task with a Host-generated identity for a human Remote caller.
|
||||
* @param request - Title, prompt, recurrence, and delivery destination.
|
||||
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
|
||||
* @returns The stored task. Cancellation does not roll back an in-flight write.
|
||||
*/
|
||||
@Remote('create') async remoteCreate(request: ScheduleCreateRequest, signal?: AbortSignal): Promise<ScheduleCatalogEntry>
|
||||
|
||||
/**
|
||||
* Create one task and record the acting Agent's Session as involved; the Remote form omits that Session.
|
||||
*
|
||||
* The request must supply a title; a missing, blank-after-trim, or over-long
|
||||
* title rejects with `invalid_prompt` instead of deriving one from the prompt.
|
||||
* A Session a delegated child owns rejects with `subagent_session`, because delivery
|
||||
* can never reach it: the child is one whose delegation depth is above zero.
|
||||
* The record is built from the clock reading taken before the request joins the
|
||||
* serialized queue, so a create that waits behind a longer operation keeps its
|
||||
* request-time anchor and may already be due when the queue reaches it.
|
||||
* @param sessionId - Original Session receiving the reminder.
|
||||
* @param request - Validated tool selector, required title, and reminder content.
|
||||
* @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
|
||||
* @returns The durably stored schedule. Cancellation does not roll back an in-flight write.
|
||||
* The record is built from the clock reading taken before the request joins the serialized queue. A `session`
|
||||
* target must exist and must not belong to a delegated child; a `new_once` target reserves its Session identity.
|
||||
* @param request - Title, prompt, recurrence, and delivery destination.
|
||||
* @param actorSessionId - Session of the acting Agent, or undefined for a human caller.
|
||||
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
|
||||
* @returns The stored task. Cancellation does not roll back an in-flight write.
|
||||
*/
|
||||
async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: AbortSignal): Promise<ScheduleRecord>
|
||||
async create( request: ScheduleCreateRequest, actorSessionId?: SessionId, signal?: AbortSignal, ): Promise<ScheduleCatalogEntry>
|
||||
|
||||
/**
|
||||
* Read the selected Session's active tasks without resuming its Agent.
|
||||
* @param request - Session whose task list is requested.
|
||||
* @returns Persisted reminders in storage order.
|
||||
* Read one page of Host tasks without activating target Sessions.
|
||||
* @param request - Optional activity filter, page size, and offset.
|
||||
* @returns The requested page and total matching task count.
|
||||
*/
|
||||
@Remote('list') async list(request: ScheduleListRequest): Promise<ScheduleRecord[]>
|
||||
async list(request: ScheduleListRequest = {}): Promise<ScheduleListResult>
|
||||
|
||||
/**
|
||||
* Read all active and inactive Host reminders with their original Session bindings.
|
||||
* A deleted reminder has no row, so it is absent here.
|
||||
* Does not activate Sessions or read Session history.
|
||||
* @returns Reminders ordered by scheduledAt ascending, then lexicographically by id.
|
||||
* Remote form of the shared Host-wide list.
|
||||
* @param request - Optional activity filter, page size, and offset.
|
||||
* @returns The requested page and total matching task count.
|
||||
*/
|
||||
@Remote('list') async remoteList(request: ScheduleListRequest): Promise<ScheduleListResult>
|
||||
|
||||
/**
|
||||
* Read every Host task without a page bound or Session activation.
|
||||
* @returns All tasks, active first, with target and execution metadata.
|
||||
*/
|
||||
@Remote('catalog') async catalog(): Promise<ScheduleCatalogEntry[]>
|
||||
|
||||
/**
|
||||
* Read saved inbox deliveries without activating or reading the original Session.
|
||||
* The task's own row supplies its binding, so its records stay readable through this lookup.
|
||||
* @param request - Session binding, task identity, explicit limit, and optional exclusive message cursor.
|
||||
* @returns Newest-first deliveries in append order, or a task/cursor lookup failure.
|
||||
* @throws ScheduleInputError when limit is not a safe integer from 1 through 100.
|
||||
* Read one task without activating its target Sessions.
|
||||
* @param request - Task identity.
|
||||
* @returns The task, or a non-mutating miss.
|
||||
*/
|
||||
@Remote('history') async history(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>
|
||||
@Remote('view') async view(request: ScheduleViewRequest): Promise<ScheduleViewResult>
|
||||
|
||||
/**
|
||||
* Delete one task belonging to the selected Session, leaving queued messages intact.
|
||||
*
|
||||
* The row is removed: the task no longer schedules, leaves `list` and `catalog`, and its
|
||||
* saved delivery records go with it. A task bound to a Session a delegated child owns
|
||||
* stays deletable even though creation and timing edits refuse that binding, so a task
|
||||
* stored before that rule existed remains removable.
|
||||
* @param request - Session and exact task identity.
|
||||
* @param signal - Optional cancellation checked before persistence begins, including after FIFO waits.
|
||||
* @returns Whether that Session owned a deleted task. Cancellation does not roll back an in-flight write.
|
||||
* Read one page of saved deliveries without activating or reading a target Session.
|
||||
* @param request - Task identity, explicit page size, and optional exclusive cursor.
|
||||
* @returns Newest-first saved deliveries, or a task/cursor lookup failure.
|
||||
*/
|
||||
@Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise<ScheduleDeleteResult>
|
||||
async history(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>
|
||||
|
||||
/**
|
||||
* Remote form of the target-based {@link ScheduleService.history}; an invalid limit rejects with
|
||||
* `schedule/input-invalid`.
|
||||
* @param request - Task identity, explicit limit, and optional exclusive message cursor.
|
||||
* @returns Newest-first deliveries in append order, or a task/cursor lookup failure.
|
||||
*/
|
||||
@Remote('history') async remoteHistory(request: ScheduleDeliveryHistoryRequest): Promise<ScheduleDeliveryHistoryResult>
|
||||
|
||||
/**
|
||||
* Deliver one task now without consuming or moving its scheduled occurrence; an archived destination Session rejects
|
||||
* with `schedule/target-archived`.
|
||||
* @param request - Task identity.
|
||||
* @param signal - Cancellation checked after entering the delivery transaction and before delivery.
|
||||
* @returns Task state after Session persistence acknowledged the delivery, `schedule_execution_pending` while
|
||||
* another execution of the task is queued, running, or inside its delivery floor, or a non-mutating miss.
|
||||
*/
|
||||
@Remote('run') async run(request: ScheduleRunRequest, signal?: AbortSignal): Promise<ScheduleRunResult>
|
||||
|
||||
/**
|
||||
* Delete every ended Host task, leaving active tasks armed.
|
||||
@@ -526,27 +241,32 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor
|
||||
* failure rejects with every listed row still stored, while a backend
|
||||
* `unknown-state` failure may have removed rows and quarantines the Host
|
||||
* domain, so later schedule operations reject until the domain is reopened.
|
||||
* Every rejection announces `schedule/changed` because it does not establish
|
||||
* A successful nonempty batch emits `schedule/changed` without Agent notices.
|
||||
* A rejected batch announces `schedule/changed` because it does not establish
|
||||
* which rows landed, and clients then re-read the catalog.
|
||||
* @returns Count of removed rows; zero when the Host held no ended task.
|
||||
*/
|
||||
@Remote('deleteInactive') async deleteInactive(): Promise<ScheduleDeleteInactiveResult>
|
||||
|
||||
/**
|
||||
* Update the name, instruction, and timing of an active task within the original Session
|
||||
* binding without activating the Session or changing saved deliveries.
|
||||
*
|
||||
* Each supplied field replaces its stored value; an omitted field keeps it. A name or
|
||||
* instruction change alone does not reset the committed target. A Session a delegated
|
||||
* child owns returns the non-mutating `subagent_session` result, so an edit cannot
|
||||
* re-arm a task bound to a Session delivery can never reach, and the Web editor can
|
||||
* explain the refusal through the ordinary result it already renders.
|
||||
* @param request - Task binding, complete observed record, and any combination of timing, name, and instruction.
|
||||
* @param signal - Cancellation checked after domain readiness and FIFO waits, before persistence begins.
|
||||
* @returns The committed record, unchanged record for a no-op, or a non-mutating input/lookup/conflict/refusal result.
|
||||
* Storage and lifecycle failures reject; cancellation after a write starts does not roll it back.
|
||||
* Update or delete one task for a human Remote caller; an invalid patch rejects with `schedule/input-invalid`.
|
||||
* @param request - Task identity and one update or delete operation.
|
||||
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
|
||||
* @returns The updated task, the deletion outcome, or a non-mutating miss or conflict.
|
||||
*/
|
||||
@Remote('update') async update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise<ScheduleUpdateResult>
|
||||
@Remote('edit') async remoteEdit(request: ScheduleEditRequest, signal?: AbortSignal): Promise<ScheduleEditResult>
|
||||
|
||||
/**
|
||||
* Update or delete one task and record the acting Agent's Session as involved; the Remote form omits that Session.
|
||||
*
|
||||
* An update applies every patch field in one write and recomputes the next occurrence when the rule changes or
|
||||
* the task resumes. A supplied `expectedUpdatedAt` that differs from the stored value returns `schedule_conflict`.
|
||||
* @param request - Task identity and one update or delete operation.
|
||||
* @param actorSessionId - Session of the acting Agent, or undefined for a human caller.
|
||||
* @param signal - Cancellation checked before the durable write begins, including after FIFO waits.
|
||||
* @returns The updated task, the deletion outcome, or a non-mutating miss or conflict.
|
||||
*/
|
||||
async edit( request: ScheduleEditRequest, actorSessionId?: SessionId, signal?: AbortSignal, ): Promise<ScheduleEditResult>
|
||||
```
|
||||
|
||||
Types: [SessionId](core.zh.md)
|
||||
|
||||
+12
-12
@@ -9,8 +9,8 @@
|
||||
en: 6cc2cd9ced70bb09
|
||||
zh: 1c0e6cb4ba2ed637
|
||||
/tool-schema-catalog/tool-package-map:
|
||||
en: a048be6e38c929b7
|
||||
zh: 4c8f979f96a71254
|
||||
en: a1b2a95aa66ea6b6
|
||||
zh: 3511895e766987d5
|
||||
/tool-schema-catalog/deepseek-ai-dsh-plugin-manager:
|
||||
en: e0143916bb634762
|
||||
zh: e0143916bb634762
|
||||
@@ -186,17 +186,17 @@
|
||||
en: 9fd2eccb1ee51372
|
||||
zh: 9fd2eccb1ee51372
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-schedule/schedule-create:
|
||||
en: 606fc2b8d62ba044
|
||||
zh: 7523e106168c2ab2
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-schedule/schedule-delete:
|
||||
en: 5fc994535c95cd6b
|
||||
zh: 1910b545d7b92379
|
||||
en: 7237e492f01fb8fb
|
||||
zh: 2fd2bea81919baf6
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-schedule/schedule-edit:
|
||||
en: 08db239b9e1c3e8f
|
||||
zh: c3860a8f20a01965
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-schedule/schedule-list:
|
||||
en: 5bcfc051e364df29
|
||||
zh: b77e60c123800eb1
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-schedule/schedule-update:
|
||||
en: b9f4691532157a1d
|
||||
zh: 8a0597790dc5f999
|
||||
en: e2c657d431fd02a8
|
||||
zh: 7651bf05938eff69
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-schedule/schedule-view:
|
||||
en: 008cd46ab3c05305
|
||||
zh: 8ef5a0f40640013e
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-lsp:
|
||||
en: 32982123b1f0e5bc
|
||||
zh: 32982123b1f0e5bc
|
||||
|
||||
+80
-220
@@ -34,7 +34,7 @@ This table connects model-visible tool names to the plugin package and service s
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.workingDirectory`, `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background jobs) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. |
|
||||
| `@deepseek-ai/dsh-tool-terminal` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.workingDirectory`, `ctx.tools`, `ctx.terminals`, `ctx.systemPrompt`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot shell/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.jobs`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. |
|
||||
| `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`, `schedule_delete`, `schedule_list`, `schedule_update` | `ctx.tools`, `ctx.schedule` | `tool/call`, `Schedule storage domain create, update, or delete`, `tool/result` | - | A preset or Agent scope mounts this package; the preset decides which agents receive the four management tools. Each call acts on the calling Agent's Session. Accepts after_seconds, explicit absolute at, bounded fixed-rate every_seconds, daily and weekly local times in an explicit IANA zone, and cron as a five-field expression. Management uses the Host storage domain; due messages resume the original Session. |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`, `schedule_edit`, `schedule_list`, `schedule_view` | `ctx.tools`, `ctx.schedule`, `ctx.systemPrompt` | `tool/call`, `Schedule storage domain create, update, or delete`, `tool/result` | - | A preset or Agent scope mounts this package and decides which agents receive its tools. Management reads and writes Host tasks by id across Sessions. Tasks store an RFC 5545 recurrence and an existing, first-created-and-reused, or per-run-new Session target. Delegated subagents cannot call these tools. |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.workingDirectory`, `ctx.tools`, `ctx.lsp`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | The lsp tool keeps provider selection and language-server subprocesses behind ctx.lsp, so its model-visible schema stays stable across providers. Requires a registered provider (e.g. `@deepseek-ai/dsh-lsp-stdio`) at runtime; without one, a query returns the structured `LSP_UNAVAILABLE` error rather than changing the schema. |
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflowEngine`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`, `ctx.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - |
|
||||
@@ -1481,134 +1481,48 @@ create, edit, pause, and resume require direct-human root authority; complete an
|
||||
|
||||
### `schedule_create`
|
||||
|
||||
Create a reminder in the current session that delivers prompt when it becomes due. Supply exactly one timing parameter: after_seconds, at, every_seconds, daily, weekly, or cron. Local times that do not exist in the zone are skipped; repeated local times fire once, at the earlier instant. After downtime, a recurring reminder delivers only its latest missed occurrence. Delivery can repeat after a crash.
|
||||
Create a scheduled task. At each occurrence of rrule, prompt is delivered to the target session as a scheduled message from the user. The result includes the task id and the next occurrence as scheduledAt in UTC and scheduledAtLocal in the zone of rrule. After downtime, only the latest missed occurrence is delivered. Delivery can repeat after a crash.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Reminder content to present when the target becomes due."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt."
|
||||
"description": "Task name of at most 120 characters in the user's language: action plus object. Omit timing, status, ids, and bracketed suffixes."
|
||||
},
|
||||
"after_seconds": {
|
||||
"type": "number",
|
||||
"description": "Delay in whole seconds."
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Instruction delivered at each occurrence."
|
||||
},
|
||||
"every_seconds": {
|
||||
"type": "number",
|
||||
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
|
||||
"rrule": {
|
||||
"type": "string",
|
||||
"description": "Supported RFC 5545 subset: one DTSTART line, a newline, and one RRULE line. DTSTART ends in Z for UTC or carries TZID with an IANA time zone; a fixed offset such as UTC+5 is Etc/GMT-5, with the sign reversed. DTSTART must name an existing local time. A TZID rule keeps local clock time across daylight-saving changes; generated nonexistent local times are skipped, and repeated ones occur once, at the earlier instant. DTSTART may be in the past; delivery starts with the first occurrence after creation. A one-time delivery is RRULE:FREQ=DAILY;COUNT=1. No SECONDLY; BYSECOND takes one value. COUNT is at most 1000; use UNTIL for longer rules. MINUTELY and HOURLY accept no BY parts, so select clock times with DAILY or WEEKLY plus BYHOUR and BYMINUTE. BYMONTHDAY and ordinal BYDAY require MONTHLY or YEARLY; BYYEARDAY and BYWEEKNO require YEARLY. Ordinal BYDAY cannot combine with BYWEEKNO. Each FREQ period allows at most 10080 candidates before BYSETPOS, which accepts at most 31 values. UNTIL is a UTC instant ending in Z. Example: \"DTSTART;TZID=Asia/Singapore:20260901T080000\\nRRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1\""
|
||||
},
|
||||
"daily": {
|
||||
"type": "object",
|
||||
"description": "Every day at a local time.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"time_zone"
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "this_session delivers to this session; new_once delivers to the same new session every time, opened at the first delivery; new opens a different new session for every delivery. New sessions use this session's working directory and preset. With this_session or new_once, a delivery waits until the previous one finishes, and only the latest occurrence that passed meanwhile is delivered. Use this_session for reminders and for follow-ups to this conversation; use new_once or new for independent work, such as a recurring report, and whenever the user asks for a new conversation.",
|
||||
"enum": [
|
||||
"this_session",
|
||||
"new_once",
|
||||
"new"
|
||||
]
|
||||
},
|
||||
"weekly": {
|
||||
"type": "object",
|
||||
"description": "On the given weekdays at a local time.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
},
|
||||
"weekdays": {
|
||||
"type": "array",
|
||||
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"time_zone",
|
||||
"weekdays"
|
||||
]
|
||||
},
|
||||
"cron": {
|
||||
"type": "object",
|
||||
"description": "Five-field Vixie cron expression in a time zone.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expression",
|
||||
"time_zone"
|
||||
]
|
||||
},
|
||||
"at": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "string"
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"date",
|
||||
"time",
|
||||
"time_zone"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"prompt",
|
||||
"title"
|
||||
"rrule",
|
||||
"target"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
|
||||
### `schedule_delete`
|
||||
### `schedule_edit`
|
||||
|
||||
Delete a reminder in the current session, active or inactive. Deletion does not retract a reminder message that is already queued.
|
||||
Update, pause, resume, or delete one scheduled task by id. Deleting does not retract queued messages.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1616,11 +1530,49 @@ Delete a reminder in the current session, active or inactive. Deletion does not
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Exact schedule id."
|
||||
"description": "Task id."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "update applies the provided fields, at least one; delete removes the task.",
|
||||
"enum": [
|
||||
"update",
|
||||
"delete"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Task name of at most 120 characters in the user's language: action plus object. Omit timing, status, ids, and bracketed suffixes."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "New instruction delivered at each occurrence."
|
||||
},
|
||||
"rrule": {
|
||||
"type": "string",
|
||||
"description": "New recurrence in the schedule_create rrule format."
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Omit to retain the destination and its creation settings. Providing target replaces them; new_once reserves a different Session. New targets capture this Session's working directory and preset. Choices are the same as schedule_create.",
|
||||
"enum": [
|
||||
"this_session",
|
||||
"new_once",
|
||||
"new"
|
||||
]
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"description": "false pauses the task; true resumes only if its timing has a future occurrence. Otherwise provide a new rrule."
|
||||
},
|
||||
"expectedUpdatedAt": {
|
||||
"type": "string",
|
||||
"description": "updatedAt from the latest result that returned this task; if the task changed after that result, the result is schedule_conflict and nothing changes."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
"id",
|
||||
"action"
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -1629,20 +1581,33 @@ Source: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/to
|
||||
|
||||
### `schedule_list`
|
||||
|
||||
List the active reminders in the current session.
|
||||
List scheduled tasks, including tasks created from other sessions. Active tasks come first, ordered by scheduledAt.
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"description": "true for active tasks only; false for paused and ended tasks only; omitted for all tasks."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Page size from 1 to 500; defaults to 100."
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Number of tasks to skip; defaults to 0."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
|
||||
### `schedule_update`
|
||||
### `schedule_view`
|
||||
|
||||
Change a reminder in place, keeping its id. Supply a new title, prompt, or at most one timing parameter; omitted fields keep their stored values. To change a relative delay, create a new reminder.
|
||||
View one scheduled task by id.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1650,112 +1615,7 @@ Change a reminder in place, keeping its id. Supply a new title, prompt, or at mo
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Schedule id returned by schedule_list."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "New task name; follow the schedule_create title rules."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "New reminder content."
|
||||
},
|
||||
"every_seconds": {
|
||||
"type": "number",
|
||||
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
|
||||
},
|
||||
"daily": {
|
||||
"type": "object",
|
||||
"description": "Every day at a local time.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"time_zone"
|
||||
]
|
||||
},
|
||||
"weekly": {
|
||||
"type": "object",
|
||||
"description": "On the given weekdays at a local time.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
},
|
||||
"weekdays": {
|
||||
"type": "array",
|
||||
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"time_zone",
|
||||
"weekdays"
|
||||
]
|
||||
},
|
||||
"cron": {
|
||||
"type": "object",
|
||||
"description": "Five-field Vixie cron expression in a time zone.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expression",
|
||||
"time_zone"
|
||||
]
|
||||
},
|
||||
"at": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "string"
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"date",
|
||||
"time",
|
||||
"time_zone"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
|
||||
"description": "Task id."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1766,7 +1626,7 @@ Change a reminder in place, keeping its id. Supply a new title, prompt, or at mo
|
||||
|
||||
Source: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
|
||||
A preset or Agent scope mounts this package; the preset decides which agents receive the four management tools. Each call acts on the calling Agent's Session. Accepts after_seconds, explicit absolute at, bounded fixed-rate every_seconds, daily and weekly local times in an explicit IANA zone, and cron as a five-field expression. Management uses the Host storage domain; due messages resume the original Session.
|
||||
A preset or Agent scope mounts this package and decides which agents receive its tools. Management reads and writes Host tasks by id across Sessions. Tasks store an RFC 5545 recurrence and an existing, first-created-and-reused, or per-run-new Session target. Delegated subagents cannot call these tools.
|
||||
|
||||
<a id="deepseek-aidsh-tool-lsp"></a>
|
||||
|
||||
|
||||
+84
-224
@@ -38,7 +38,7 @@
|
||||
| `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.workingDirectory`、`ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 |
|
||||
| `@deepseek-ai/dsh-tool-terminal` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.workingDirectory`、`ctx.tools`、`ctx.terminals`、`ctx.systemPrompt`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.jobs`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 |
|
||||
| `@deepseek-ai/dsh-tool-goal` | `create_goal`、`get_goal`、`update_goal` | `ctx.tools`、`ctx.agents`、`ctx.goals`、`ctx.systemPrompt`、`a calling Agent in an authorized open turn` | `tool/call`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`、`schedule_delete`、`schedule_list`、`schedule_update` | `ctx.tools`、`ctx.schedule` | `tool/call`、Schedule storage domain 创建、更新或删除、`tool/result` | - | preset 或 Agent scope 挂载本包;由 preset 决定哪些 agent 获得这四个管理工具。每次调用都作用于调用方 Agent 的 Session。接受 after_seconds、显式绝对 at、有界固定速率 every_seconds、带显式 IANA 时区的每日与每周本地时间,以及作为五字段表达式的 cron。管理使用宿主 storage domain;到期消息会恢复原 Session。 |
|
||||
| `@deepseek-ai/dsh-tool-schedule` | `schedule_create`、`schedule_edit`、`schedule_list`、`schedule_view` | `ctx.tools`、`ctx.schedule`、`ctx.systemPrompt` | `tool/call`、Schedule storage domain 创建、更新或删除、`tool/result` | - | preset 或 Agent scope 挂载本包,并决定哪些 Agent 获得工具。管理工具按 id 跨会话读写 Host 的任务。任务保存 RFC 5545 重复规则,执行位置可选已有会话、首次创建后复用的会话或每次运行新建的会话。委派子 Agent 不能调用这些工具。 |
|
||||
| `@deepseek-ai/dsh-tool-lsp` | `lsp` | `ctx.workingDirectory`、`ctx.tools`、`ctx.lsp`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后,因此其模型可见 schema 在更换提供方时保持稳定。运行时要求已注册提供方,例如 `@deepseek-ai/dsh-lsp-stdio`;如果没有提供方,查询会返回结构化 `LSP_UNAVAILABLE` 错误,而不会改变 schema。 |
|
||||
| `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`、`ctx.workflowEngine`、`ctx.subagents`、`ctx.systemPrompt`、`a calling Agent (exec.agent parents every fresh round)` | `tool/call`、`tool/result`、`workflow and child session events during execution` | - | 固定的前台工作流会在每个 Round 启动一个全新的结构化子级;模型只能选择不可变目标和可选的 Round 上限。 |
|
||||
| `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.workingDirectory`、`ctx.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - |
|
||||
@@ -1487,134 +1487,48 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
|
||||
|
||||
### `schedule_create`
|
||||
|
||||
在当前会话中创建一条提醒,到期时投递 prompt。请恰好提供一个时间参数:after_seconds、at、every_seconds、daily、weekly 或 cron。时区中不存在的本地时间会被跳过;重复出现的本地时间只在较早的时刻触发一次。停机后,重复提醒只投递最近错过的一次。崩溃后可能重复投递。
|
||||
创建定时任务。rrule 的每次触发都会将 prompt 作为用户的定时消息投递到目标会话。结果包含任务 id 和下一次触发时间:scheduledAt 为 UTC 时间,scheduledAtLocal 为 rrule 所在时区的时间。停机后只补发最近一次错过的触发。崩溃后可能重复投递。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Reminder content to present when the target becomes due."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Concise task name in the user's language, at most 120 characters. Name only the core action and subject; keep essential target identifiers such as PR numbers and branch names. Do not include execution frequency (e.g. daily, hourly), scheduled dates/times, time zones, task status, or configuration and execution details (e.g. read-only mode, profile-tag filters), even when embedded in the requested or existing title. Do not append parenthetical notes. Put scheduling information in the timing parameters and execution instructions in prompt."
|
||||
"description": "Task name of at most 120 characters in the user's language: action plus object. Omit timing, status, ids, and bracketed suffixes."
|
||||
},
|
||||
"after_seconds": {
|
||||
"type": "number",
|
||||
"description": "Delay in whole seconds."
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Instruction delivered at each occurrence."
|
||||
},
|
||||
"every_seconds": {
|
||||
"type": "number",
|
||||
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
|
||||
"rrule": {
|
||||
"type": "string",
|
||||
"description": "Supported RFC 5545 subset: one DTSTART line, a newline, and one RRULE line. DTSTART ends in Z for UTC or carries TZID with an IANA time zone; a fixed offset such as UTC+5 is Etc/GMT-5, with the sign reversed. DTSTART must name an existing local time. A TZID rule keeps local clock time across daylight-saving changes; generated nonexistent local times are skipped, and repeated ones occur once, at the earlier instant. DTSTART may be in the past; delivery starts with the first occurrence after creation. A one-time delivery is RRULE:FREQ=DAILY;COUNT=1. No SECONDLY; BYSECOND takes one value. COUNT is at most 1000; use UNTIL for longer rules. MINUTELY and HOURLY accept no BY parts, so select clock times with DAILY or WEEKLY plus BYHOUR and BYMINUTE. BYMONTHDAY and ordinal BYDAY require MONTHLY or YEARLY; BYYEARDAY and BYWEEKNO require YEARLY. Ordinal BYDAY cannot combine with BYWEEKNO. Each FREQ period allows at most 10080 candidates before BYSETPOS, which accepts at most 31 values. UNTIL is a UTC instant ending in Z. Example: \"DTSTART;TZID=Asia/Singapore:20260901T080000\\nRRULE:FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=-1\""
|
||||
},
|
||||
"daily": {
|
||||
"type": "object",
|
||||
"description": "Every day at a local time.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"time_zone"
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "this_session delivers to this session; new_once delivers to the same new session every time, opened at the first delivery; new opens a different new session for every delivery. New sessions use this session's working directory and preset. With this_session or new_once, a delivery waits until the previous one finishes, and only the latest occurrence that passed meanwhile is delivered. Use this_session for reminders and for follow-ups to this conversation; use new_once or new for independent work, such as a recurring report, and whenever the user asks for a new conversation.",
|
||||
"enum": [
|
||||
"this_session",
|
||||
"new_once",
|
||||
"new"
|
||||
]
|
||||
},
|
||||
"weekly": {
|
||||
"type": "object",
|
||||
"description": "On the given weekdays at a local time.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
},
|
||||
"weekdays": {
|
||||
"type": "array",
|
||||
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"time_zone",
|
||||
"weekdays"
|
||||
]
|
||||
},
|
||||
"cron": {
|
||||
"type": "object",
|
||||
"description": "Five-field Vixie cron expression in a time zone.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expression",
|
||||
"time_zone"
|
||||
]
|
||||
},
|
||||
"at": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "string"
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"date",
|
||||
"time",
|
||||
"time_zone"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title",
|
||||
"prompt",
|
||||
"title"
|
||||
"rrule",
|
||||
"target"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
源码: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
|
||||
### `schedule_delete`
|
||||
### `schedule_edit`
|
||||
|
||||
删除当前会话中的一条提醒,活动或已结束的均可。删除不会撤回已经入队的提醒消息。
|
||||
按 id 更新、暂停、恢复或删除一个定时任务。删除不会撤回已排队的消息。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1622,33 +1536,84 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Exact schedule id."
|
||||
"description": "Task id."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "update applies the provided fields, at least one; delete removes the task.",
|
||||
"enum": [
|
||||
"update",
|
||||
"delete"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Task name of at most 120 characters in the user's language: action plus object. Omit timing, status, ids, and bracketed suffixes."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "New instruction delivered at each occurrence."
|
||||
},
|
||||
"rrule": {
|
||||
"type": "string",
|
||||
"description": "New recurrence in the schedule_create rrule format."
|
||||
},
|
||||
"target": {
|
||||
"type": "string",
|
||||
"description": "Omit to retain the destination and its creation settings. Providing target replaces them; new_once reserves a different Session. New targets capture this Session's working directory and preset. Choices are the same as schedule_create.",
|
||||
"enum": [
|
||||
"this_session",
|
||||
"new_once",
|
||||
"new"
|
||||
]
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"description": "false pauses the task; true resumes only if its timing has a future occurrence. Otherwise provide a new rrule."
|
||||
},
|
||||
"expectedUpdatedAt": {
|
||||
"type": "string",
|
||||
"description": "updatedAt from the latest result that returned this task; if the task changed after that result, the result is schedule_conflict and nothing changes."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
"id",
|
||||
"action"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
源码: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
|
||||
### `schedule_list`
|
||||
|
||||
列出当前会话中的活动提醒。
|
||||
列出定时任务,包括从其他会话创建的任务。活动任务排在前面,按 scheduledAt 排序。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"active": {
|
||||
"type": "boolean",
|
||||
"description": "true for active tasks only; false for paused and ended tasks only; omitted for all tasks."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Page size from 1 to 500; defaults to 100."
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer",
|
||||
"description": "Number of tasks to skip; defaults to 0."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
源码: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
|
||||
### `schedule_update`
|
||||
### `schedule_view`
|
||||
|
||||
原地修改一条提醒并保留其 id。提供新的 title、prompt,或至多一个时间参数;未提供的字段保持原值。需要相对延迟时请新建一条提醒。
|
||||
按 id 查看一个定时任务。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1656,112 +1621,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Schedule id returned by schedule_list."
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "New task name; follow the schedule_create title rules."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "New reminder content."
|
||||
},
|
||||
"every_seconds": {
|
||||
"type": "number",
|
||||
"description": "Fixed-rate interval in whole seconds, at least 60, aligned to the creation time; changing it with schedule_update re-aligns it to the save time."
|
||||
},
|
||||
"daily": {
|
||||
"type": "object",
|
||||
"description": "Every day at a local time.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "HH:mm:ss with optional 1-3 fractional digits, for example 23:00:00."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"time_zone"
|
||||
]
|
||||
},
|
||||
"weekly": {
|
||||
"type": "object",
|
||||
"description": "On the given weekdays at a local time.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"time": {
|
||||
"type": "string",
|
||||
"description": "HH:mm:ss with optional 1-3 fractional digits, for example 09:00:00."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
},
|
||||
"weekdays": {
|
||||
"type": "array",
|
||||
"description": "ISO weekdays, Monday 1 through Sunday 7, without repetitions.",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"time",
|
||||
"time_zone",
|
||||
"weekdays"
|
||||
]
|
||||
},
|
||||
"cron": {
|
||||
"type": "object",
|
||||
"description": "Five-field Vixie cron expression in a time zone.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"expression": {
|
||||
"type": "string",
|
||||
"description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\". When both day fields are restricted, a date matches if either one matches."
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string",
|
||||
"description": "UTC or IANA Area/Location, for example Asia/Shanghai."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expression",
|
||||
"time_zone"
|
||||
]
|
||||
},
|
||||
"at": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"date": {
|
||||
"type": "string"
|
||||
},
|
||||
"time": {
|
||||
"type": "string"
|
||||
},
|
||||
"time_zone": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"date",
|
||||
"time",
|
||||
"time_zone"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "Absolute target: an RFC 3339 date-time with offset, or a local date, time, and IANA time_zone."
|
||||
"description": "Task id."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1770,9 +1630,9 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
源码: [`packages/schedule/tool-schedule/src/index.ts`](../packages/schedule/tool-schedule/src/index.ts)
|
||||
|
||||
preset 或 Agent scope 挂载本包;由 preset 决定哪些 agent 获得这四个管理工具。每次调用都作用于调用方 Agent 的 Session。接受 after_seconds、显式绝对 at、有界固定速率 every_seconds、带显式 IANA 时区的每日与每周本地时间,以及作为五字段表达式的 cron。管理使用宿主 storage domain;到期消息会恢复原 Session。
|
||||
preset 或 Agent scope 挂载本包,并决定哪些 Agent 获得工具。管理工具按 id 跨会话读写 Host 的任务。任务保存 RFC 5545 重复规则,执行位置可选已有会话、首次创建后复用的会话或每次运行新建的会话。委派子 Agent 不能调用这些工具。
|
||||
|
||||
<a id="deepseek-aidsh-tool-lsp"></a>
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/schedule-agent-tools/guide.md
|
||||
/:
|
||||
en: 534c875c6423421f
|
||||
zh: 041b62aed9b5689f
|
||||
/schedule-tools-manage-every-host-task-by-rrule-and-target:
|
||||
en: 390ea3dd55433b66
|
||||
zh: eb1b5e4cf2ae6551
|
||||
/schedule-tools-manage-every-host-task-by-rrule-and-target/change:
|
||||
en: 3609120a0dee5cee
|
||||
zh: 0b5d3c77c998e46d
|
||||
/schedule-tools-manage-every-host-task-by-rrule-and-target/migration:
|
||||
en: ca46aafc12815cc4
|
||||
zh: c99e9160e2bb871e
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "The Schedule model tools become `schedule_create`, `schedule_list`, `schedule_view`, and `schedule_edit` over every Host task, and `schedule_update` and `schedule_delete` are removed."
|
||||
---
|
||||
|
||||
# Schedule tools manage every Host task by RRULE and target
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Earlier, `@deepseek-ai/dsh-tool-schedule` registered `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`. They managed only the reminders of the calling Session, and `schedule_create` took one timing selector: `after_seconds`, `at`, `every_seconds`, `daily`, `weekly`, or `cron`.
|
||||
|
||||
The package now registers `schedule_create`, `schedule_list`, `schedule_view`, and `schedule_edit`:
|
||||
|
||||
- `schedule_create` takes `title`, `prompt`, `rrule` (one `DTSTART` line and one `RRULE` line), and `target` (`this_session`, `new_once`, or `new`). It accepts no timing selector.
|
||||
- `schedule_list` returns one page `{ items, shown, total, limit, offset }` of active, paused, and ended tasks instead of an array of active reminders.
|
||||
- `schedule_view` reads one task by id.
|
||||
- `schedule_edit` with `action: "update"` replaces `schedule_update` and can also change `rrule` and `target` or pause and resume a task; `action: "delete"` replaces `schedule_delete`.
|
||||
- Each tool reads, changes, and deletes any Host task by id, not only the tasks of the calling Session.
|
||||
- A call from a delegated subagent returns `subagent_session` with the message `A delegated subagent cannot use scheduled tasks.`
|
||||
|
||||
Existing reminders keep their timing and delivery. Recorded Sessions keep their earlier calls and results, and the Web transcript still renders them.
|
||||
|
||||
This affects:
|
||||
|
||||
- prompts, skills, and scripts that tell a model to call `schedule_update`, `schedule_delete`, or a timing selector;
|
||||
- deployments that rely on each Session seeing only its own reminders;
|
||||
- TypeScript code that imports `scheduleView` from the root entry, or `ScheduleView`, `ScheduleState`, `ScheduleDeliveryMode`, `ScheduleCreateValue`, `ScheduleListValue`, `ScheduleDeleteRequest`, `ScheduleDeleteValue`, `ScheduleTimingChange`, `ScheduleUpdateContent`, `ScheduleUpdateMiss`, `ScheduleUpdateRequest`, `ScheduleUpdateResult`, or `ScheduleUpdateValue` from the root or `/client` entry of `@deepseek-ai/dsh-schedule`.
|
||||
|
||||
## Migration
|
||||
|
||||
1. In prompts, skills, and scripts, replace `schedule_update` and `schedule_delete` with `schedule_edit`, replace each timing selector with an RRULE, and supply the required `target` on creation. For example, `daily: { time: "09:00:00", time_zone: "Asia/Shanghai" }` becomes `rrule: "DTSTART;TZID=Asia/Shanghai:20261013T090000\nRRULE:FREQ=DAILY"`; use `target: "this_session"` to keep delivery in the calling Session.
|
||||
2. If the agents of a preset must not manage the tasks of other Sessions, remove the `@deepseek-ai/dsh-tool-schedule` row from that preset.
|
||||
3. In TypeScript code, use `ScheduleCreateRequest`, `ScheduleTiming`, `SchedulePatch`, and `ScheduleEditRequest` for writes, and `ScheduleViewResult`, `ScheduleListResult`, and `ScheduleEditResult` for reads and edits. Call `ScheduleService.view`, paged `list`, and `edit` instead of `scheduleView`, `update`, and `delete`. `ScheduleDeleteResult`, `ScheduleDeleteChange`, and `ScheduleDeleteInactiveResult` remain exported from the root and `/client`; the old request/value types above do not. The [Schedule service README](../../../../packages/schedule/schedule/README.md) describes these APIs.
|
||||
4. Custom compositions must provide both `tools` and `systemPrompt` before mounting this consumer; registration also waits for `schedule`. The `tool:schedule` section supplies the shared authorization instruction. A `complete: true` prompt override must include that instruction itself.
|
||||
5. Open a Session under each changed preset and confirm that its tool list shows the four tool names. Delegate a task and confirm that a Schedule tool call from the child returns `subagent_session`; the child's tool list includes the four names, as [Configure subagent composition through presets](../../v0.2.1-alpha.1/subagent-preset-composition/guide.md) describes.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Schedule 的模型工具改为面向宿主全部任务的 `schedule_create`、`schedule_list`、`schedule_view` 与 `schedule_edit`,并移除 `schedule_update` 与 `schedule_delete`。"
|
||||
---
|
||||
|
||||
# Schedule 工具按 RRULE 与目标管理宿主的全部任务
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
较早时,`@deepseek-ai/dsh-tool-schedule` 注册 `schedule_create`、`schedule_list`、`schedule_update` 与 `schedule_delete`。它们只管理调用方 Session 的提醒,`schedule_create` 接受一个时间选择参数:`after_seconds`、`at`、`every_seconds`、`daily`、`weekly` 或 `cron`。
|
||||
|
||||
现在本包注册 `schedule_create`、`schedule_list`、`schedule_view` 与 `schedule_edit`:
|
||||
|
||||
- `schedule_create` 接受 `title`、`prompt`、`rrule`(一行 `DTSTART` 与一行 `RRULE`)和 `target`(`this_session`、`new_once` 或 `new`)。它不接受任何时间选择参数。
|
||||
- `schedule_list` 返回一页 `{ items, shown, total, limit, offset }`,包含活动、暂停与已结束的任务,而不是活动提醒的数组。
|
||||
- `schedule_view` 按 id 读取一个任务。
|
||||
- 带 `action: "update"` 的 `schedule_edit` 取代 `schedule_update`,并且还能改变 `rrule` 与 `target`,或暂停与恢复任务;`action: "delete"` 取代 `schedule_delete`。
|
||||
- 每个工具都按 id 读取、改变和删除宿主的任意任务,而不只是调用方 Session 的任务。
|
||||
- 被委派的子 agent 发起的调用返回 `subagent_session`,消息为 `A delegated subagent cannot use scheduled tasks.`
|
||||
|
||||
已有提醒保留其时间规则与投递方式。已录制的 Session 保留较早的调用与结果,Web transcript 仍会渲染它们。
|
||||
|
||||
受影响的对象:
|
||||
|
||||
- 让模型调用 `schedule_update`、`schedule_delete` 或某个时间选择参数的提示词、skill 与脚本;
|
||||
- 依赖“每个 Session 只看到自己的提醒”的部署;
|
||||
- 从 `@deepseek-ai/dsh-schedule` 根入口导入 `scheduleView`,或从根入口或 `/client` 入口导入 `ScheduleView`、`ScheduleState`、`ScheduleDeliveryMode`、`ScheduleCreateValue`、`ScheduleListValue`、`ScheduleDeleteRequest`、`ScheduleDeleteValue`、`ScheduleTimingChange`、`ScheduleUpdateContent`、`ScheduleUpdateMiss`、`ScheduleUpdateRequest`、`ScheduleUpdateResult` 或 `ScheduleUpdateValue` 的 TypeScript 代码。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在提示词、skill 与脚本中,把 `schedule_update` 与 `schedule_delete` 替换为 `schedule_edit`,把每个时间选择参数替换为 RRULE,并在创建时提供必填的 `target`。例如,`daily: { time: "09:00:00", time_zone: "Asia/Shanghai" }` 变为 `rrule: "DTSTART;TZID=Asia/Shanghai:20261013T090000\nRRULE:FREQ=DAILY"`;使用 `target: "this_session"` 将投递保留在调用方 Session。
|
||||
2. 如果某个 preset 的 agent 不应管理其他 Session 的任务,从该 preset 中移除 `@deepseek-ai/dsh-tool-schedule` 这一行。
|
||||
3. 在 TypeScript 代码中,写入使用 `ScheduleCreateRequest`、`ScheduleTiming`、`SchedulePatch` 和 `ScheduleEditRequest`,读取与编辑结果使用 `ScheduleViewResult`、`ScheduleListResult` 和 `ScheduleEditResult`。调用 `ScheduleService.view`、分页的 `list` 和 `edit`,替换 `scheduleView`、`update` 和 `delete`。根入口与 `/client` 仍导出 `ScheduleDeleteResult`、`ScheduleDeleteChange` 和 `ScheduleDeleteInactiveResult`,但不再导出上述旧请求/结果值类型。[Schedule 服务 README](../../../../packages/schedule/schedule/README.zh.md) 描述了这些 API。
|
||||
4. 自定义组合须先提供 `tools` 和 `systemPrompt`,再挂载此消费者;注册还会等待 `schedule`。`tool:schedule` 区段提供共用授权指令。使用 `complete: true` 的提示词覆盖时,须自行保留该指令。
|
||||
5. 在每个改动过的 preset 下打开一个 Session,确认其工具列表显示这四个工具名。委派一个任务,确认子 agent 调用 Schedule 工具时返回 `subagent_session`;子 agent 的工具列表包含这四个工具名,详见[通过 preset 配置子 Agent 组装](../../v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md)。
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.2-alpha.2/schedule-remote-host-tasks/guide.md
|
||||
/:
|
||||
en: 69c0cf0c8201d784
|
||||
zh: a7831ef117a6f20c
|
||||
/schedule-uses-one-host-wide-management-api:
|
||||
en: 380dae2dc6802943
|
||||
zh: dfdca40926880a67
|
||||
/schedule-uses-one-host-wide-management-api/change:
|
||||
en: 8fab9b2c62b64745
|
||||
zh: b7cff45264a852f8
|
||||
/schedule-uses-one-host-wide-management-api/migration:
|
||||
en: 2f20ccf5e82d299e
|
||||
zh: b01fa9cd6d297f5e
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Schedule clients use one Host-wide management API and typed timing records; stored Host tasks keep the existing domain and layout."
|
||||
---
|
||||
|
||||
# Schedule uses one Host-wide management API
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
Schedule management addresses every Host task by id. The native and Remote APIs share one service; the previous Session-bound `delete` and `update` methods are removed. Native creation takes a typed `timing`, and catalog results discriminate After, At, Every, Daily, Weekly, Cron, and RRULE with `kind`.
|
||||
|
||||
The existing `schedule` domain writes version 2 and reads version 1 without startup writes, retaining its route, envelope, and layout. SQLite's physical schema stays at version 1. Back up the store before upgrading: the first successful v2 write stamps the unit v2, which a v1 binary refuses to open. Downgrade requires restoring that backup. Edits and delivery save current rows without a separate migration command. The plugin README owns the [stored schema, upgrade timing, and costs](../../../../packages/schedule/schedule/README.md#storage-format).
|
||||
|
||||
## Migration
|
||||
|
||||
1. Native callers use `create({ title, prompt, timing, target? }, actorSessionId?, signal?)` and `edit(request, actorSessionId?, signal?)`. Remote callers use `schedule/create` and `schedule/edit` with a `request` argument. Wrap a Cron selector as `{ kind: "cron", cron: { expression, time_zone } }`, or RRULE as `{ kind: "rrule", rrule }`. Model tool arguments continue to expose RRULE.
|
||||
2. Replace Session-bound deletion with `edit({ id, action: "delete" })`; replace updates with `edit({ id, action: "update", patch, expectedUpdatedAt? })`. Timing edits use `patch.timing`. Supply the read revision, or `null` to compare an absent revision; handle `schedule_conflict` by rereading.
|
||||
3. `list({ active?, limit?, offset? })` returns `{ items, shown, total, limit, offset }` across the Host. `catalog()` returns all tasks; `view({ id })`, `history({ id, limit, before? })`, and `run({ id })` need no owner Session. Read a reusable destination from `target`, and each receipt destination from `targetSessionId`.
|
||||
4. Use the unprefixed types exported by the package root or `/client`, including `ScheduleRecord`, `ScheduleTiming`, and `ScheduleCatalogEntry`. Narrow by `kind` before reading timing fields. Handle `paused` and `ended` instead of `inactive`, and allow absent creation/change timestamps. The root helpers `scheduleView`, `decodeScheduleChange`, `foldScheduleEvents`, and `SCHEDULE_CHANGE_VERSION` are removed; historical event types remain frozen data declarations. The [tool upgrade guide](../schedule-agent-tools/guide.md) lists the removed types and their replacements.
|
||||
5. Verify that Automation tasks shows existing Cron and calendar tasks and that a metadata edit preserves their timing and history. Historical Session-log reminders still require explicit recreation; they are outside this Host-task change.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Schedule 客户端改用统一的宿主管理 API 和带类型时间记录;已有宿主任务保留现有领域和布局。"
|
||||
---
|
||||
|
||||
# Schedule 使用统一的宿主管理 API
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
Schedule 管理按 id 寻址宿主全部任务。原生与 Remote API 共用一个服务;移除先前按 Session 绑定的 `delete` 和 `update` 方法。原生创建接收带类型的 `timing`,目录结果通过 `kind` 区分 After、At、Every、Daily、Weekly、Cron 和 RRULE。
|
||||
|
||||
现有 `schedule` 领域写入版本 2,读取版本 1 时不进行启动写入,保留路由、封套和布局。SQLite 物理 schema 保持版本 1。升级前备份存储:首次成功的 v2 写入将单元标记为 v2,v1 程序会拒绝打开。降级需要恢复该备份。编辑和投递保存当前行,无需独立迁移命令。插件 README 拥有[存储 schema、升级时机与成本](../../../../packages/schedule/schedule/README.zh.md#storage-format)。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 原生调用方使用 `create({ title, prompt, timing, target? }, actorSessionId?, signal?)` 和 `edit(request, actorSessionId?, signal?)`。Remote 调用方使用带 `request` 参数的 `schedule/create` 和 `schedule/edit`。Cron 选择器包装为 `{ kind: "cron", cron: { expression, time_zone } }`,RRULE 包装为 `{ kind: "rrule", rrule }`。模型工具参数继续暴露 RRULE。
|
||||
2. 将按 Session 绑定的删除替换为 `edit({ id, action: "delete" })`;更新替换为 `edit({ id, action: "update", patch, expectedUpdatedAt? })`。时间编辑使用 `patch.timing`。提供读取的版本,或以 `null` 比较缺失版本;遇到 `schedule_conflict` 时重新读取。
|
||||
3. `list({ active?, limit?, offset? })` 返回宿主范围的 `{ items, shown, total, limit, offset }`。`catalog()` 返回全部任务;`view({ id })`、`history({ id, limit, before? })` 和 `run({ id })` 无需所有者 Session。从 `target` 读取可复用目的地,从 `targetSessionId` 读取每条回执目的地。
|
||||
4. 使用包根入口或 `/client` 导出的无前缀类型,包括 `ScheduleRecord`、`ScheduleTiming` 和 `ScheduleCatalogEntry`。读取时间字段前先按 `kind` 收窄。用 `paused` 和 `ended` 替代 `inactive`,并允许创建/变更时间戳缺失。根入口助手 `scheduleView`、`decodeScheduleChange`、`foldScheduleEvents` 和 `SCHEDULE_CHANGE_VERSION` 已移除;历史事件类型仍是冻结的数据声明。[工具升级指南](../schedule-agent-tools/guide.zh.md)列出已移除类型及其替代方式。
|
||||
5. 确认自动化任务显示已有 Cron 和日历任务,且元数据编辑保留其时间规则与历史。历史 Session 日志提醒仍需显式重建;它们不属于本次宿主任务变更。
|
||||
@@ -6,26 +6,26 @@
|
||||
en: c3cb91d1d53b7603
|
||||
zh: 715ced3f1a2e9dd5
|
||||
/schedule-reminders/summary:
|
||||
en: bc85e4ac935f2fd5
|
||||
zh: c39e54e4beaa3f9a
|
||||
en: a2a838b22f6e0b9a
|
||||
zh: b3f3664ba85cb86c
|
||||
/schedule-reminders/table-of-contents:
|
||||
en: b3b2a6e0ce02bdee
|
||||
zh: 2ddb168de79fd19c
|
||||
en: c1d163bf7d366e24
|
||||
zh: 34dccfbb0bf3701d
|
||||
/schedule-reminders/create-reminders:
|
||||
en: f6d1a21af61a44fc
|
||||
zh: a92f177c97880b95
|
||||
/schedule-reminders/inspect-and-delete-tasks:
|
||||
en: b74bc63a1273680b
|
||||
zh: b6d806e91c361b99
|
||||
/schedule-reminders/edit-an-active-task:
|
||||
en: b3426f5dbc185958
|
||||
zh: ccf118e2415a5e77
|
||||
en: 9bd962e3653a8a72
|
||||
zh: 6b0887ed1477420c
|
||||
/schedule-reminders/inspect-run-and-delete-tasks:
|
||||
en: 9407dca2b849be4a
|
||||
zh: 9e158a42f4d95305
|
||||
/schedule-reminders/edit-a-task:
|
||||
en: 45f9dac1f9d86696
|
||||
zh: 3193f010cc6168da
|
||||
/schedule-reminders/timing-and-delivery-reference:
|
||||
en: 6e5aa8ae5906df4f
|
||||
zh: afdf670a31fae2bb
|
||||
en: acb84b04d567d15c
|
||||
zh: c658a81d8c6875f4
|
||||
/schedule-reminders/further-exploration:
|
||||
en: b7eb797281f028c0
|
||||
zh: 3754f9d19127a3c5
|
||||
en: 39e252476ff2a2c8
|
||||
zh: fa803f0eb67abb2d
|
||||
/schedule-reminders/dev-note:
|
||||
en: b75c8458928b72b9
|
||||
zh: 003ba4e17b4b5269
|
||||
+45
-26
@@ -4,13 +4,13 @@ English | [中文](schedule.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Create reminders in a conversation, then inspect active and inactive tasks and edit an active task's name, instruction, and run time from the Automation tasks page. Daily reminders follow a saved local time and time zone; fixed-rate reminders follow elapsed intervals. Delivered one-shot tasks remain available until you explicitly delete them.
|
||||
Ask the model in a conversation to create, list, view, edit, pause, resume, or delete reminders. Each reminder repeats by its rule and delivers its instruction to an existing conversation, to one new conversation, or to a new conversation at every occurrence. The Automation tasks page lists every reminder of the Host and can create, edit, run, pause, resume, and delete one. Delivered one-shot tasks remain available until you explicitly delete them.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Create reminders](#create-reminders)
|
||||
- [Inspect and delete tasks](#manage-tasks)
|
||||
- [Edit an active task](#edit-timing)
|
||||
- [Inspect, run, and delete tasks](#manage-tasks)
|
||||
- [Edit a task](#edit-timing)
|
||||
- [Timing and delivery reference](#timing-and-delivery)
|
||||
- [Further Exploration](#further-exploration)
|
||||
|
||||
@@ -19,59 +19,78 @@ Create reminders in a conversation, then inspect active and inactive tasks and e
|
||||
|
||||
The shipped Web profile includes the Host Schedule service and the Automation tasks page. Configure a model provider before asking it to create reminders.
|
||||
|
||||
The `standard`, `cordis`, and `ptc` presets declare the four reminder tools and the clock context that gives the model the current time and the browser's zone, so those presets provide `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete`; the `minimal` preset declares neither. A delegated subagent sees none of the four tools, because the presets' delegation rows deny them; a call that reaches one anyway is refused.
|
||||
The `standard`, `cordis`, and `ptc` presets declare the four reminder tools and the clock context that gives the model the current time and the browser's zone, so those presets provide `schedule_create`, `schedule_list`, `schedule_view`, and `schedule_edit`; the `minimal` preset declares neither. A delegated subagent sees the four tools, but each tool refuses a call from a delegated subagent, so a subagent cannot read or change reminders.
|
||||
|
||||
Ask the model to create, list, edit, or delete reminders. It uses `schedule_create`, `schedule_list`, `schedule_update`, and `schedule_delete` (update changes one reminder in place and keeps its id and saved delivery records); the Automation tasks page's New action opens a New Session for a creation instead, with the request already written in its composer. Supported choices are a one-time delay in positive whole seconds, an absolute date and time, a fixed interval of at least one minute, a daily local time with an IANA time zone, a weekly local time with an IANA time zone and ISO weekdays from Monday 1 through Sunday 7, or a five-field cron expression with an explicit IANA time zone, stored in canonical form.
|
||||
Ask the model to create, list, view, edit, pause, resume, or delete reminders. It uses `schedule_create` to create one, `schedule_list` to list the reminders of every conversation, `schedule_view` to read one, and `schedule_edit` to change, pause, resume, or delete one; an edit keeps the reminder id and saved delivery records. A reminder that the model creates has a name, an instruction, a start time in UTC or in an IANA time zone, and an RFC 5545 repeat rule, such as every weekday at 09:00 or only once. It delivers to this conversation, to one new conversation that its first delivery creates, or to a new conversation at every delivery; a new conversation uses the working directory and preset of this conversation. Each delivery of a reminder comes at least one minute after the scheduled time of the previous delivery, or after the previous Run now; a reminder that opens a new conversation at every delivery waits for the Host's minimum run interval instead, one minute by default. A late delivery, for example after a Host restart, does not delay the next one.
|
||||
|
||||
For example, ask: “Remind me every day at 23:00 in Asia/Shanghai to check the weather.” Open Automation tasks and select the created reminder. Check that its frequency shows a daily rule and the requested zone, not Once. A one-time reminder does not become recurring after delivery.
|
||||
A conversation that a delivery creates joins the Workspace registered at its working directory and takes the reminder name as its title. For a reminder that opens a new conversation at every delivery, the title also states the launch time in the zone of the reminder's rule, for example `Weekday digest · 2026-10-12 09:00`.
|
||||
|
||||
For example, ask: “Remind me every day at 23:00 in Asia/Shanghai to check the weather.” Below the reply, a card shows the created reminder's name and frequency; Open shows its details in the right sidebar. Check that the frequency repeats every day at 23:00, not Once. A one-time reminder does not become recurring after delivery. After the reminder is deleted, the card shows Deleted and no longer opens it.
|
||||
|
||||
To create a reminder without the model, open Automation tasks from the sidebar, open Choose a creation method beside New, and choose Create manually; when no task exists, New automation task opens the same dialog. The Create manually dialog asks for an Automation task name, an Automation task instruction, Run in, Linked workspace, and Run time. It starts with Once at one hour from now in the browser's time zone and with Create a conversation and reuse it. Run in offers Create a conversation and reuse it, which creates one conversation at the first run and keeps using it, and New conversation for every run, which creates a separate conversation at each run and shows the Host's minimum run interval. Linked workspace lists each registered Workspace with its directory and selects the Workspace whose directory the new conversation uses; it starts at Default workspace, or at the first Workspace when the default Workspace is not registered. When no Workspace exists, Linked workspace shows Ungrouped and the Host project directory is used. The new conversation uses the default preset. Create creates the task and selects it in the list, and Cancel closes the dialog without creating one. If the Host refuses the task, the dialog keeps your input and states the problem, for example that the date and time must be in the future.
|
||||
|
||||
New, and Create in a conversation in the same menu, open a new conversation in the current or most recent Workspace; ask the model there to create the task.
|
||||
|
||||
<a id="manage-tasks"></a>
|
||||
## Inspect and delete tasks
|
||||
## Inspect, run, and delete tasks
|
||||
|
||||
Open Automation tasks from the sidebar to view reminders across Sessions without activating their conversations. Search by the stored task name, the instruction, or the task's internal Session id, and filter by the single All, Enabled, or Inactive status row. An idle, unarchived Session row whose Session has at least one active task shows a clock mark; an archived row keeps that cell blank, with its live status on the hover card only. A long hover on a Session row with active tasks lists up to two of those tasks with their frequency and their next run as a device-zone time with its relative duration. The open Session's header shows an icon-only reminder clock while active reminders exist: it opens the sole task's details directly, or a list of the Session's reminders otherwise.
|
||||
Ask the model to list reminders to see the reminders of every conversation, active ones first, or to show, pause, resume, or delete one reminder by its id.
|
||||
|
||||
Select a task to open Rules, then choose Records to load the newest 20 saved records. Each saved record reads as one compact row: a clock glyph, the occurrence time in the task's saved zone (the browser's zone for a one-shot or interval task), and the saved instruction when one was stored. Records without a saved instruction never show the current instruction in its place. Load older records adds the next page. Records follow newest-first saved order even if the clock moves backward; they do not describe model execution results. The original Session entry sits in the detail tab strip, so it stays visible while the Rules view scrolls. Unmodified Left/Right arrows and Home/End switch the focused tabs; Alt, Ctrl, Meta, and Shift combinations are not intercepted. Refreshing the task preserves the selected tab.
|
||||
Open Automation tasks from the sidebar to view reminders across Sessions without activating their conversations. Search by the task name, the instruction, or the id of the task's conversation, and filter by Task status: All, Enabled, Paused, or Ended. Rows are ordered by their scheduled time, earliest first. Each row shows the task name and one summary line: the status of a paused or ended task, the frequency, and, for an enabled task, Next scheduled time with its local and relative time.
|
||||
|
||||
An empty record view means a successful query returned no saved records. Loading, request failure, a missing task, and an invalid older-record cursor have separate messages. Use Retry delivery records after a failed request or missing-task response; use Refresh delivery records after an invalid cursor to reload the newest page. Previously loaded records may remain visible with a warning while a request fails. A new latest delivery refreshes the newest page. Late responses for a task or view you have left do not replace the current view.
|
||||
A Session's tasks are the tasks that deliver to it, including the latest run of a task that creates a new conversation for every run, and the tasks that its Agent created or edited. An idle, unarchived Session row whose Session has at least one active task shows a clock mark; an archived row keeps that cell blank, with its live status on the hover card only. A long hover on a Session row with active tasks lists up to two of those tasks with their frequency and their next run as a device-zone time with its relative duration. The open Session's header shows an icon-only reminder clock while the Session has active tasks: it opens the sole task's details in the right sidebar, or otherwise a list of those tasks in which each row opens the task's details or deletes the task.
|
||||
|
||||
For tasks without saved history, only an existing latest receipt can be shown until new deliveries add records; later deliveries do not recover the missing history or clear its notice. Unsaved earlier records and instruction snapshots cannot be recovered, and the current instruction is not substituted for a missing saved instruction. The earlier-history notice appears only when the Host marks earlier records unavailable; new tasks with no deliveries show the empty state without that notice.
|
||||
Select a task to open its details. Rules shows the task name, Next run with its local and relative time or the status of a paused or ended task, the instruction, Repeat, and Run in, which shows Current linked conversation for a task that delivers to an existing conversation. The bottom of the details shows Linked session, or Latest run conversation for a task that creates a new conversation for every run. Its button opens that conversation when the Session is available and unarchived, and is disabled otherwise; before the first run creates the conversation, the entry shows Pending creation. For a task that delivers to an existing conversation, Rules states when that conversation is archived, unavailable, or still loading. The task details and deletion remain available even when a conversation cannot open. Browsing tasks does not unarchive Sessions.
|
||||
|
||||
Active tasks participate in scheduling. Inactive tasks do not schedule another delivery. A one-time task becomes inactive after its reminder is durably written to the conversation inbox; choose All tasks or Inactive to find it. The conversation-header catalog and model `schedule_list` show only active reminders, so disappearance from those views does not mean the retained task was deleted.
|
||||
Choose Records to load the newest 20 saved records. Each record shows the occurrence time in the zone of the task's rule (the browser's zone when the rule names none), a button that opens the conversation that received the delivery when that conversation is available, and the saved instruction; Expand shows a long instruction in full. Records without a saved instruction never show the current instruction in its place. Load more adds the next page. Records follow newest-first saved order even if the clock moves backward; they do not describe model execution results. Left/Right arrows and Home/End switch the focused tabs.
|
||||
|
||||
The Linked session entry in the detail tab strip opens the original conversation when its metadata is ready and the Session is available and unarchived. Otherwise the entry is disabled with a reason. The task details and deletion action remain available even when its original Session cannot open. Browsing tasks does not unarchive Sessions.
|
||||
An empty record view means a successful query returned no saved records. Loading, request failure, a missing task, and an invalid older-record cursor have separate messages. Use Retry after a failed request or missing-task response; use Refresh after an invalid cursor to reload the newest page. Previously loaded records may remain visible with a warning while a request fails. A new latest delivery refreshes the newest page. Late responses for a task or view you have left do not replace the current view.
|
||||
|
||||
Choose Delete task and confirm to stop future delivery and remove the task from the list, including for an inactive task. Deletion removes the task together with its saved delivery records, so the task's saved history is gone. Confirming the deletion closes the panel or tab that asked for it and reports the outcome in one app-wide notice — deleted, or a failure that leaves the task in place to retry; it does not remove the original conversation or retract a message already queued there. The row stays visible until a successful query confirms the remaining tasks. If a query fails, Retry reloads the catalog; the error is not an empty successful result.
|
||||
When retention removed earlier records, the last page states that earlier delivery records have been cleared, and Retention rules shows how many records and how many days each task keeps. An older task without saved history shows only its latest delivery, without an instruction, until new deliveries add records. Unsaved earlier records and instruction snapshots cannot be recovered.
|
||||
|
||||
Active tasks participate in scheduling. Paused and ended tasks do not schedule another delivery. A one-time task ends after its reminder is durably written to the conversation inbox, and a repeating task ends after the last occurrence of its rule; choose All or Ended in Task status to find an ended task. The Session header clock, the row mark, and the hover card show only active tasks, so disappearance from those views does not mean that the task was deleted.
|
||||
|
||||
Pause in the details stops the future deliveries of an active task, and Resume restarts a paused task. Run now, in the Automation task actions menu, asks the Host to deliver the instruction once now and keeps the next scheduled run, also for a paused or ended task. While a delivery of a task that delivers to an existing conversation or to one new conversation is queued or running, Edit, Pause, Resume, Run now, and Delete task are disabled, and the details state that the task is queued or running and that you manage it in its conversation, which the button at the bottom of the details opens. A task that creates a new conversation for every run is not disabled this way; its runs continue beside one another. After a delivery, Run now stays disabled until the task's minimum delivery interval ends; while only the interval holds it, hold the pointer on Run now or move the keyboard to it to see when it becomes available. If the Host refuses a run for one of these reasons, the details state that the task is queued, running, or was just delivered. Run now is also disabled while the conversation that the task delivers to is archived. If you stop the conversation while the task's instruction waits there, the instruction stays queued until your next message, the task's next scheduled run, or Run now starts it.
|
||||
|
||||
Choose Delete task from the Automation task actions menu and confirm to stop future delivery and remove the task from the list, including for a paused or ended task. Deletion removes the task together with its saved delivery records, so the task's saved history is gone. Confirming the deletion reports the outcome in one app-wide notice — deleted, which also closes the details, or a failure that leaves the task in place to retry; it does not remove the conversations or retract a message already queued there. The row stays visible until a successful query confirms the remaining tasks. If a query fails, Retry reloads the catalog; the error is not an empty successful result.
|
||||
|
||||
<a id="edit-timing"></a>
|
||||
## Edit an active task
|
||||
## Edit a task
|
||||
|
||||
In Automation tasks, select an active task and open Rules to edit its name, instruction, and run time. Inactive tasks are read-only, and edits stay in a local draft until you choose Save changes. Editing keeps the task id, original conversation, latest delivery receipt, and all saved delivery records.
|
||||
Ask the model to change a reminder's name, instruction, repeat rule, or delivery target, or to pause or resume it; the change keeps the reminder id and saved delivery records.
|
||||
|
||||
- Daily and Weekly: edit Time and Time zone; a weekly rule also toggles Weekday. A clock row shows whole seconds, an untouched row keeps a stored millisecond value for its submit, and the stored IANA zone is kept. A changed rule selects its first future occurrence using the DST rules below.
|
||||
- Every: choose Every N hours, Every N minutes, or Every N seconds and enter the quantity in the unit the row states; the smallest accepted interval is 1 hour, 1 minute, or 60 seconds for that unit, and the submitted interval is whole seconds. A changed interval starts from the Host's accepted-save time, with the first target one new interval later. Time zones do not affect this elapsed interval.
|
||||
- One-shot After/At: edit separate Date and Time rows and Time zone. The initial zone is the record's stored zone, or this device's zone when the record stores none, and the seeded clock names the same stored instant; a one-shot rule that stored no zone shows one hint stating that the selected zone interprets the entered date and time. Changing the zone keeps those clock values and changes the instant. A changed target saves as At with the same task id. The zone is not retained as recurrence metadata.
|
||||
In Automation tasks, select a task and choose Edit to change its Automation task name, Automation task instruction, Run in, Linked workspace, and Run time, including for a paused or ended task. Edits stay in a local draft until you choose Save changes. Editing keeps the task id, latest delivery receipt, and all saved delivery records. A changed rule makes an ended task active again and keeps a paused task paused. For a task that delivers to an existing conversation, Run in shows Current linked conversation; choosing another option makes later runs create their conversations and sets Linked workspace to its starting value, as in Create manually. A task keeps its stored preset while its Workspace stays the same; a changed Workspace uses the default preset.
|
||||
|
||||
The rows change only a local draft. Save changes submits the name, instruction, and the complete rule in one update, and a save bar appears while the draft differs from the stored task; Cancel restores the stored values. The Repeat menu offers Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), and the Host accepts any of them, so a one-shot After may save as At. An equivalent normalized rule performs no write or target reset: the same Every interval does not restart its timing, and an unchanged one-shot target preserves After or At. The rows are disabled while a save is in flight. Leaving the card does not roll back a Host write already begun.
|
||||
- Once: Date opens a month calendar, Time opens Hour, Minute, and Second columns, and Time zone interprets them. The task runs once and then ends.
|
||||
- Every N minutes and Every N hours: Interval sets the number of minutes or hours, at least 1. The interval counts from the minute at which you save the task.
|
||||
- Every day: Interval sets the number of days, and Time opens Hour and Minute columns.
|
||||
- Weekly and Monday to Friday: Interval sets the number of weeks, Weekday selects the days, and Time opens Hour, Minute, and Second columns. Monday to Friday selects those five days at once.
|
||||
- Monthly: Interval sets the number of months, Day of the month selects a date from 1 through 31, and Time opens Hour, Minute, and Second columns. A month without the selected date runs on its last day.
|
||||
- Custom: Custom rule holds the rule text. A rule that the other choices cannot reproduce opens in Custom. An existing task without editable rule text shows its original frequency and keeps that timing until you save a replacement.
|
||||
|
||||
A catalog refresh keeps the fields you have edited and takes every other field from the refreshed record, so a concurrent change to another field survives an unsaved edit. Invalid input is reported and blocks Save. If delivery or another edit changed the task, a conflict prevents overwriting that change; retry to edit the latest rule. An accepted update refreshes the catalog. A rejected or unconfirmed update is distinct from a catalog-loading error: use Retry to reload the catalog, not to repeat an acknowledged update. If the update itself cannot be confirmed, check the task before retrying. Late responses after closing details or switching tasks do not replace the current view.
|
||||
Time zone lists recently selected zones first, marks the system zone, and searches by UTC offset, IANA ID, or city. It appears for guided choices and Custom expressions that use a separate zone; otherwise the Custom rule text supplies its zone.
|
||||
|
||||
The fields change only a local draft. While you edit, the bottom of the details shows Unsaved changes with Cancel and Save changes; Save changes sends only the changed fields in one update, and Cancel restores the stored values. A save sends a new rule only when the timing changed. Save changes stays disabled while the name or instruction is empty or a guided choice is incomplete. The Host validates a supplied Custom rule on save; invalid text keeps the draft and shows an error. The fields are disabled while a save is in flight. Leaving the details does not roll back a Host write already begun.
|
||||
|
||||
A catalog refresh does not change the draft. If delivery or another edit changed the task before the save arrives, the Host refuses the save and changes nothing; the details state that the task changed elsewhere, Load latest version replaces the draft with the latest version, and Save changes stays disabled until then. If another save fails, the draft stays and the details ask you to try again; check the task before retrying an update whose outcome is unknown. An accepted update refreshes the catalog. Late responses after closing details or switching tasks do not replace the current view.
|
||||
|
||||
<a id="timing-and-delivery"></a>
|
||||
## Timing and delivery reference
|
||||
|
||||
Reminders remain stored when their Session is not open. While the Host runs, it restores the original Session when a reminder becomes due and queues a follow-up without interrupting current work. Restarting checks stored tasks and sends an overdue one-shot or only the latest missed occurrence of each recurring task. Closing the Host stops scheduling until it starts again. Due recurring tasks for the same Session can share one message.
|
||||
Reminders remain stored when their Session is not open. While the Host runs, it restores or creates the target Session when a reminder becomes due and queues a follow-up without interrupting current work. Restarting checks stored tasks and sends an overdue one-shot or only the latest missed occurrence of each recurring task. Closing the Host stops scheduling until it starts again. Due recurring tasks for the same Session can share one message.
|
||||
|
||||
Fixed-rate reminders stay aligned to creation until their interval changes; a changed interval aligns to the accepted-save time. Daily reminders instead follow the saved local time and IANA zone. A daily local time or date that does not exist is skipped; an overlapping time uses the earlier instant once that day. An absolute-time request rejects a nonexistent local time and chooses the earlier instant during an overlap. The browser supplies its zone for interpreting natural-language requests; naming a zone explicitly avoids ambiguity. Task details keep a saved wall-clock rule's zone in its frequency text and show every next target as a device-zone clock; a one-shot task stores only the UTC instant, so its timestamps use the browser's zone.
|
||||
A repeat rule in an IANA zone follows local wall time: it skips a local time that does not exist and uses the earlier instant of a repeated local time. Existing tasks keep their timing until you save a new rule: a fixed-rate reminder stays aligned to its creation time, and a daily, weekly, or cron reminder follows its saved local times in its IANA zone. The browser supplies its zone for interpreting natural-language requests; naming a zone explicitly avoids ambiguity. The Automation tasks page shows every next run as a browser-zone clock, and the frequency text names a rule's zone when it differs from the browser's zone; a rule that names no zone uses the browser's zone.
|
||||
|
||||
Saved delivery records confirm messages persisted in the conversation inbox, not that the model completed the requested work. Failed inbox persistence or task writes add no saved record. A crash or task-write failure after inbox persistence can leave a delivered message unrecorded and repeat a delivery; exactly-once delivery is not guaranteed. This is not an execution history or an external notification channel.
|
||||
Saved delivery records confirm messages persisted in the conversation inbox, not that the model completed the requested work. Failed inbox persistence or task writes add no saved record. A crash or task-write failure after inbox persistence can leave a delivered message unrecorded and repeat a delivery; exactly-once delivery is not guaranteed. Closing the Host discards each message that still waits in a conversation inbox: its saved record remains, the model never receives that message, and the Host does not send it again. This is not an execution history or an external notification channel.
|
||||
|
||||
Saved records are pruned when an acknowledgment is appended: the Host keeps the records whose acknowledgment time falls inside the configured `deliveryHistoryDays` window, measured back from the appended receipt's `deliveredAt`, and at most the newest `deliveryHistoryRecords` records. The latest receipt always survives, and a pruned window marks earlier records unavailable. The Host keeps the retained history in memory and rewrites the full task row for each acknowledgment. Pagination limits returned record count, not the retained history or instruction bytes.
|
||||
|
||||
Pause and a new Session for each run are not supported. Name, instruction, and run-time editing is available to the model through `schedule_update`, which changes the reminder in place and keeps its id and saved delivery records, and to the Automation tasks page against the selected task's binding; neither is a cross-Session relay workflow. Reminders recorded only in an older Session log are not scheduled automatically; recreate the ones you still need. Previously deleted task records are not reconstructed.
|
||||
Name, instruction, timing, and target editing, pausing, and resuming are available to the model through `schedule_edit` and to the Automation tasks page for every reminder; neither is a cross-Session relay workflow. A change adds a notice to the next model request of each involved conversation whose Agent is loaded, except the conversation that made the change. Reminders recorded only in an older Session log are not scheduled automatically; recreate the ones you still need. Previously deleted task records are not reconstructed.
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
- [Schedule reference](../../../packages/schedule/schedule/README.md) documents exact timing selectors, persistence, and delivery limits.
|
||||
- [Schedule reference](../../../packages/schedule/schedule/README.md) documents task fields, delivery targets, recurrence rules, persistence, and delivery limits.
|
||||
- [Automation tasks page reference](../../../packages/client/ui-schedule/README.md) describes the global page and conversation-header catalog.
|
||||
|
||||
## Dev Note
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
## 概述
|
||||
|
||||
在对话中创建提醒,再从自动化任务页面查看活动和未运行任务,并修改活动任务的名称、指令和运行时间。每日提醒遵循保存的本地时间与时区;固定速率提醒遵循经过的时间间隔。已投递的一次性任务会保留到显式删除为止。
|
||||
在对话中请模型创建、列出、查看、修改、暂停、恢复或删除提醒。每条提醒按其规则重复,并把指令投递到一个现有对话、一个新对话,或在每个发生时点投递到一个新对话。自动化任务页面列出宿主的所有提醒,并可创建、修改、运行、暂停、恢复和删除提醒。已投递的一次性任务会保留到显式删除为止。
|
||||
|
||||
## 目录
|
||||
|
||||
- [创建提醒](#create-reminders)
|
||||
- [查看和删除任务](#manage-tasks)
|
||||
- [修改活动任务](#edit-timing)
|
||||
- [查看、运行和删除任务](#manage-tasks)
|
||||
- [修改任务](#edit-timing)
|
||||
- [时间与投递参考](#timing-and-delivery)
|
||||
- [延伸阅读](#further-exploration)
|
||||
|
||||
@@ -19,59 +19,78 @@
|
||||
|
||||
发布的 Web profile 自带宿主 Schedule 服务与自动化任务页面。在请求模型创建提醒前,先配置模型 provider。
|
||||
|
||||
`standard`、`cordis` 和 `ptc` preset 声明四个提醒工具与把当前时间和浏览器时区交给模型的时钟上下文,因此这些 preset 提供 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`;`minimal` preset 两者都不声明。被委派的子 agent 看不到这四个工具,因为三个 preset 的委派行已将其 deny;即使某个调用仍能到达工具,也会被拒绝。
|
||||
`standard`、`cordis` 和 `ptc` preset 声明四个提醒工具与把当前时间和浏览器时区交给模型的时钟上下文,因此这些 preset 提供 `schedule_create`、`schedule_list`、`schedule_view` 和 `schedule_edit`;`minimal` preset 两者都不声明。被委派的 subagent 能看到这四个工具,但每个工具都会拒绝来自被委派 subagent 的调用,因此 subagent 无法读取或修改提醒。
|
||||
|
||||
向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少一分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。
|
||||
向模型提出创建、列出、查看、修改、暂停、恢复或删除提醒的请求。模型用 `schedule_create` 创建提醒,用 `schedule_list` 列出所有对话的提醒,用 `schedule_view` 读取一条提醒,用 `schedule_edit` 修改、暂停、恢复或删除一条提醒;修改保留提醒 id 与已保存的投递记录。模型创建的提醒有名称、指令、UTC 或某个 IANA 时区下的开始时间,以及一条 RFC 5545 重复规则,例如每个工作日 09:00 或仅一次。它投递到当前对话、首次投递时创建的一个新对话,或在每次投递时投递到一个新对话;新对话使用当前对话的工作目录与 preset。同一提醒的每次投递都在上一次投递的计划时间或上一次“立即执行”之后至少一分钟;每次投递都打开新对话的提醒改为等待宿主的最小运行间隔,默认为一分钟。延迟的投递(例如宿主重启之后)不会推迟下一次投递。
|
||||
|
||||
例如,请求:“每天 Asia/Shanghai 时区 23:00 提醒我查看天气。”打开自动化任务页面并选择刚创建的提醒,检查频率显示的是每日规则和所需时区,而非仅一次。一次性提醒不会在投递后变成重复任务。
|
||||
投递创建的对话加入在其工作目录注册的 Workspace,并以提醒名称作为标题。对于每次投递都打开新对话的提醒,标题还写出该提醒规则所用时区下的启动时间,例如 `Weekday digest · 2026-10-12 09:00`。
|
||||
|
||||
例如,请求:“每天 Asia/Shanghai 时区 23:00 提醒我查看天气。”回复下方的卡片显示刚创建的提醒的名称和频率;“打开”在右侧栏显示其详情。检查频率是每天 23:00 重复,而非“仅一次”。一次性提醒不会在投递后变成重复任务。提醒删除后,卡片显示“已删除”,且不再打开该提醒。
|
||||
|
||||
不经模型创建提醒时,从侧栏打开自动化任务页面,打开“新建”旁的“选择创建方式”,再选择“手动创建”;尚无任务时,“新建自动化任务”打开同一对话框。“手动创建”对话框要求填写“自动化任务名称”“自动化任务内容”“执行位置”“关联工作区”和“运行时间”。初始运行时间为“仅一次”,时间为浏览器时区下的一小时后,初始执行位置为“新建对话并复用”。“执行位置”提供“新建对话并复用”和“每次新建对话”:前者在首次运行时创建一个对话并在之后沿用,后者在每次运行时创建独立对话,并显示宿主的最小运行间隔。“关联工作区”列出每个已注册的 Workspace 及其目录,并选择新对话使用其目录的 Workspace;初始值为“默认工作区”,未注册默认 Workspace 时为第一个 Workspace。尚无任何 Workspace 时,“关联工作区”显示“未分组”,并使用宿主项目目录。新对话使用默认 preset。“创建”创建该任务并在列表中选中它,“取消”关闭对话框而不创建任务。若宿主拒绝该任务,对话框保留你的输入并说明问题,例如日期和时间必须在未来。
|
||||
|
||||
“新建”以及同一菜单中的“在对话中创建”会在当前或最近使用的 Workspace 中打开一个新对话;在其中请模型创建该任务。
|
||||
|
||||
<a id="manage-tasks"></a>
|
||||
## 查看和删除任务
|
||||
## 查看、运行和删除任务
|
||||
|
||||
从侧栏打开自动化任务页面,可跨 Session 查看提醒,而不激活相应对话。按任务已存储的名称、指令或任务所属的内部 Session id 搜索,并用唯一的“全部/已开启/未运行”状态筛选行过滤。Session 行在其 Session 有至少一个活动任务、自身空闲且未归档时显示时钟标识;归档行该格留空,其实时状态只在悬浮卡片中显示。悬停任一带活动任务的 Session 行时,最多列出其中两个任务及其频率,以及设备时区下的下次运行时间与相对时长。已打开 Session 的顶部在有活动提醒时显示仅图标时钟入口:只有一个任务时直接打开其详情,否则列出该 Session 的提醒。
|
||||
请模型列出提醒,可查看所有对话的提醒,活动提醒在前;也可请模型按 id 显示、暂停、恢复或删除一条提醒。
|
||||
|
||||
选择任务打开“规则”,再选择“任务运行记录”加载最新的 20 条已保存记录。每条已保存记录为一行紧凑信息:时钟图标、任务保存时区下的发生时间(一次性或间隔任务则为浏览器时区),以及保存过的指令。没有保存指令的记录不会用当前指令顶替。“加载更早的记录”增加下一页。记录按保存顺序从新到旧排列,即使时钟回拨也不改变顺序;它们不描述模型执行结果。原会话入口位于详情页签条内,因此在“规则”视图滚动时都保持可见。不带修饰键的左右方向键和 Home/End 可切换已聚焦的页签;不会拦截与 Alt、Ctrl、Meta 或 Shift 的组合。刷新任务会保留当前页签。
|
||||
从侧栏打开自动化任务页面,可跨 Session 查看提醒,而不激活相应对话。按任务名称、指令或任务所属对话的 id 搜索,并用“任务状态”(“全部”“已开启”“已暂停”或“已结束”)过滤。各行按计划时间排列,最早的在前。每行显示任务名称和一行摘要:已暂停或已结束任务的状态、频率,以及已开启任务的“下次计划时间”与其本地时间和相对时间。
|
||||
|
||||
记录空态表示查询成功且没有返回已保存记录。加载中、请求失败、任务不存在和较早记录游标无效分别显示不同提示。请求失败或返回任务不存在时,使用“重试加载发送记录”;游标无效时,使用“刷新发送记录”重新加载最新一页。请求失败时,此前加载的记录可能仍会显示,并附有警告。新的最近一次投递会刷新最新一页。离开任务或视图后才到达的响应不会替换当前视图。
|
||||
一个 Session 的任务包括投递到该 Session 的任务(对于每次运行都新建对话的任务,以其最近一次运行为准),以及该 Session 的 Agent(智能体)创建或修改过的任务。空闲、未归档且至少有一个活动任务的 Session 行显示时钟标识;归档行该格留空,其实时状态只在悬浮卡片中显示。长时间悬停在带活动任务的 Session 行上时,最多列出其中两个任务及其频率,以及设备时区下的下次运行时间与相对时长。已打开 Session 的顶部在该 Session 有活动任务时显示仅图标的提醒时钟:只有一个任务时,它在右侧栏打开该任务的详情;否则列出这些任务,每行可打开任务详情或删除任务。
|
||||
|
||||
没有已保存历史的任务只能显示已有的最近一次回执,直到新投递增加记录;后续投递不会恢复缺失历史或清除相应提示。未保存的更早记录和指令快照无法恢复,不会用当前指令代替缺失的已保存指令。仅当宿主标记更早记录不可用时,才显示更早历史提示;尚未投递的新任务显示空态,不显示该提示。
|
||||
选择任务打开其详情。“规则”显示任务名称、“下次运行”及其本地时间和相对时间(已暂停或已结束的任务显示其状态)、指令、“重复”和“执行位置”;投递到现有对话的任务,“执行位置”显示“当前关联对话”。详情底部显示“关联会话”;对于每次运行都新建对话的任务,显示“最近运行对话”。其按钮在 Session 可用且未归档时打开该对话,否则禁用;首次运行创建对话之前,该条目显示“待创建”。对于投递到现有对话的任务,“规则”会说明该对话已归档、不可用或仍在加载。即使对话无法打开,任务详情与删除操作仍然可用。浏览任务不会取消 Session 归档。
|
||||
|
||||
活动任务参与调度;未运行任务不会再次安排投递。一次性任务在提醒持久写入对话收件箱后变为未运行;选择所有任务或未运行即可找到它。对话顶部目录和模型 `schedule_list` 仅显示活动提醒,因此从这些视图消失并不表示保留的任务已被删除。
|
||||
选择“任务运行记录”加载最新的 20 条已保存记录。每条记录显示任务规则所用时区下的发生时间(规则未指定时区时为浏览器时区)、一个在接收该投递的对话可用时打开它的按钮,以及保存过的指令;“展开”完整显示较长的指令。没有保存指令的记录不会用当前指令顶替。“加载更多”增加下一页。记录按保存顺序从新到旧排列,即使时钟回拨也不改变顺序;它们不描述模型执行结果。左右方向键和 Home/End 可切换已聚焦的页签。
|
||||
|
||||
详情页签条内的关联会话入口在元数据就绪、Session 可用且未归档时打开原对话;否则入口禁用并显示原因。即使原 Session 无法打开,任务详情与删除操作仍然可用。浏览任务不会取消 Session 归档。
|
||||
记录空态表示查询成功且没有返回已保存记录。加载中、请求失败、任务不存在和较早记录游标无效分别显示不同提示。请求失败或返回任务不存在时,使用“重试”;游标无效时,使用“刷新”重新加载最新一页。请求失败时,此前加载的记录可能仍会显示,并附有警告。新的最近一次投递会刷新最新一页。离开任务或视图后才到达的响应不会替换当前视图。
|
||||
|
||||
选择删除任务并确认,可停止未来投递并将该任务从列表中移除,包括未运行任务。删除会移除该任务及其已保存的发送记录,因此该任务的已保存历史随之消失。确认删除会关闭发起删除的面板或页签,并在同一个应用级提示里报告结果——已删除,或失败(失败时任务保留、可重试);删除不会删除原对话,也不会撤回已进入队列的消息。该行会保留到成功查询确认剩余任务为止。查询失败时可通过重试重新加载目录;错误不会被视为查询成功的空结果。
|
||||
保留上限移除了更早的记录时,最后一页提示“更早的运行记录已清理”,“保留规则”说明每个任务保留的记录条数和天数。没有已保存历史的旧任务只显示其最近一次投递,且不含指令,直到新投递增加记录。未保存的更早记录和指令快照无法恢复。
|
||||
|
||||
活动任务参与调度;已暂停和已结束的任务不会再次安排投递。一次性任务在提醒持久写入对话收件箱后结束,重复任务在其规则的最后一个发生时点后结束;在“任务状态”中选择“全部”或“已结束”即可找到已结束的任务。Session 顶部的提醒时钟、Session 行的时钟标识和悬浮卡片只显示活动任务,因此从这些视图消失并不表示任务已被删除。
|
||||
|
||||
详情中的“暂停任务”停止活动任务之后的投递,“恢复任务”重新启动已暂停的任务。“自动化任务操作”菜单中的“立即执行”请宿主现在投递一次指令,并保留下次计划运行;已暂停或已结束的任务也可以这样运行。投递到现有对话或一个新对话的任务的一次投递已排队或正在运行时,“编辑”“暂停任务”“恢复任务”“立即执行”和“删除任务”均禁用,详情说明此任务已排队或执行中,请在会话中管理任务;详情底部的按钮打开该会话。每次运行都创建新对话的任务不会这样禁用;它的各次运行并行继续。一次投递之后,“立即执行”保持禁用,直到该任务的最小投递间隔结束;仅因间隔禁用时,将指针停在“立即执行”上或用键盘移到该项,即可查看它恢复可用的时间。若宿主因上述原因之一拒绝运行,详情会说明该任务已排队、执行中或刚执行过。任务投递到的对话已归档时,“立即执行”同样禁用。若在任务指令仍在对话中等待时停止该对话,指令会保持排队,直到你的下一条消息、该任务的下一次计划运行或“立即执行”使其开始。
|
||||
|
||||
从“自动化任务操作”菜单选择“删除任务”并确认,可停止未来投递并将该任务从列表中移除,包括已暂停或已结束的任务。删除会移除该任务及其已保存的发送记录,因此该任务的已保存历史随之消失。确认删除会在同一个应用级提示里报告结果——已删除(同时关闭详情),或失败(失败时任务保留、可重试);删除不会删除相关对话,也不会撤回已进入其队列的消息。该行会保留到成功查询确认剩余任务为止。查询失败时,“重试”重新加载目录;错误不会被视为查询成功的空结果。
|
||||
|
||||
<a id="edit-timing"></a>
|
||||
## 修改活动任务
|
||||
## 修改任务
|
||||
|
||||
在自动化任务页面选择活动任务,打开“规则”,即可修改其名称、指令和运行时间。未运行任务保持只读,修改先保存在本地草稿中,直到选择“保存更改”。编辑保留任务 id、原对话、最近一次投递回执以及全部已保存的发送记录。
|
||||
请模型修改提醒的名称、指令、重复规则或投递目标,或暂停、恢复提醒;修改保留提醒 id 与已保存的投递记录。
|
||||
|
||||
- Daily 和 Weekly:修改“时间”和“时区”;每周规则还可切换“星期”。时钟行显示整秒,未改动的行在提交时保留已存储的毫秒值,已存储的 IANA 时区保持不变。规则改变后,按下文的夏令时规则选择首个未来时点。
|
||||
- Every:选择“每 N 小时”“每 N 分钟”或“每 N 秒”,并按行内标出的单位填写数量;该单位下的最小间隔为 1 小时、1 分钟或 60 秒,提交的间隔为整秒。新间隔从宿主接受保存的时间起算,首个目标为一个新间隔之后。时区不影响这个经过时间间隔。
|
||||
- 一次性 After/At:分别修改“日期”“时间”两行以及“时区”。初始时区取记录已存储的时区;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一已存储瞬时。未保存时区的一次性规则会显示一条提示,说明输入的日期时间按所选时区解释。更改时区会保留这些钟表值并改变时点。目标改变后以相同任务 id 保存为 At,不将时区保留为重复规则元数据。
|
||||
在自动化任务页面选择任务并选择“编辑”,即可修改其“自动化任务名称”“自动化任务内容”“执行位置”“关联工作区”和“运行时间”,包括已暂停或已结束的任务。修改先保存在本地草稿中,直到选择“保存修改”。编辑保留任务 id、最近一次投递回执以及全部已保存的发送记录。规则改变后,已结束的任务重新变为活动,已暂停的任务保持暂停。对于投递到现有对话的任务,“执行位置”显示“当前关联对话”;选择其他选项后,之后的运行会创建各自的对话,“关联工作区”也设为其初始值,与“手动创建”相同。Workspace 保持不变时,任务保留已存储的 preset;改换 Workspace 后使用默认 preset。
|
||||
|
||||
各行只修改本地草稿。“保存更改”会以一次更新提交名称、指令和完整规则;草稿与已存储任务不同时显示保存栏,“取消”恢复已存储的值。“重复”菜单提供每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron),宿主接受其中任何一项,因此一次性 After 可保存为 At。规范化后等价的规则不写入或重设目标:相同的 Every 间隔不会重新起算,未变更的一次性目标保留 After 或 At。保存期间卡片各行禁用。离开卡片不会回滚宿主已开始的写入。
|
||||
- “仅一次”:“日期”打开月历,“时间”打开“时”“分”“秒”三列,“时区”决定如何解释它们。任务运行一次后结束。
|
||||
- “每 N 分钟”和“每 N 小时”:“重复间隔”设置分钟数或小时数,至少为 1。间隔从你保存任务时所在的分钟起算。
|
||||
- “每天”:“重复间隔”设置天数,“时间”打开“时”“分”两列。
|
||||
- “每周”和“周一至周五”:“重复间隔”设置周数,“星期”选择日子,“时间”打开“时”“分”“秒”三列。“周一至周五”一次选中这五天。
|
||||
- “每月”:“重复间隔”设置月数,“每月日期”选择 1 到 31 日中的一天,“时间”打开“时”“分”“秒”三列。某月没有所选日期时,任务在该月最后一天运行。
|
||||
- “自定义”:“自定义规则”包含规则文本。其他选项无法重现的规则以“自定义”打开。没有可编辑规则文本的已有任务显示其原频率,并保留该时间规则,直到你保存替代规则。
|
||||
|
||||
刷新目录会保留你已修改的字段,其余字段取自已刷新的记录,因此其他字段的并发变化不会被未保存的修改覆盖。输入无效时显示错误并阻止保存。若投递或其他编辑改变了任务,冲突会阻止覆盖该变化;请重试以修改最新规则。更新被接受后刷新目录。更新被拒绝或无法确认与目录加载错误相互区分:应使用“重试”重新加载目录,而不是重复已确认的更新。若更新本身无法确认,应在重试前检查任务。关闭详情或切换任务后才到达的响应不会替换当前视图。
|
||||
“时区”先列出最近选择的时区,标出系统时区,并可按 UTC 偏移、IANA 标识或城市搜索。引导式选项和使用独立时区的“自定义”表达式显示此字段;其他“自定义”规则由文本指定时区。
|
||||
|
||||
各字段只修改本地草稿。编辑期间,详情底部显示“有未保存的修改”以及“取消”和“保存修改”;“保存修改”以一次更新只提交已修改的字段,“取消”恢复已存储的值。只有时间规则改变时,保存才会提交新规则。名称或指令为空,或引导式选择不完整时,“保存修改”保持禁用。宿主在保存时验证所填的“自定义”规则;文本无效时保留草稿并显示错误。保存进行期间,各字段禁用。离开详情不会回滚宿主已开始的写入。
|
||||
|
||||
刷新目录不会改变草稿。若投递或其他编辑在保存到达前改变了任务,宿主拒绝保存且不做任何修改;详情会说明此任务已在其他位置更新,“载入最新版本”用最新版本替换草稿,在此之前“保存修改”保持禁用。保存因其他原因失败时,草稿保留,详情提示你重试;对于结果未知的更新,请在重试前检查任务。更新被接受后刷新目录。关闭详情或切换任务后才到达的响应不会替换当前视图。
|
||||
|
||||
<a id="timing-and-delivery"></a>
|
||||
## 时间与投递参考
|
||||
|
||||
Session 未打开时,提醒仍然保存。宿主运行期间,在提醒到期时恢复原 Session 并排入 follow-up,不中断当前工作。重启后检查存储的任务,投递逾期的一次性提醒,或每条重复任务最近错过的一次。关闭宿主后,调度停止,直到宿主再次启动。同一 Session 的到期重复任务可以共用一条消息。
|
||||
Session 未打开时,提醒仍然保存。宿主运行期间,在提醒到期时恢复或创建目标 Session 并排入 follow-up,不中断当前工作。重启后检查存储的任务,投递逾期的一次性提醒,或每条重复任务最近错过的一次。关闭宿主后,调度停止,直到宿主再次启动。同一 Session 的到期重复任务可以共用一条消息。
|
||||
|
||||
固定速率提醒在间隔改变前与创建时间对齐;间隔改变后与接受保存的时间对齐。每日提醒则遵循保存的本地时间与 IANA 时区。不存在的每日本地时间或日期会被跳过;重叠时间选择当天较早的一次。绝对时间请求会拒绝不存在的本地时间,并在重叠时选择较早时刻。浏览器提供其时区来解释自然语言请求;显式指定时区可以避免歧义。任务详情在频率文案里保留钟表规则保存的时区,并把每个下一目标显示为设备时区的钟点;一次性任务仅存储 UTC 时点,因此其时间戳使用浏览器时区。
|
||||
IANA 时区下的重复规则遵循本地钟表时间:跳过不存在的本地时间,重复出现的本地时间取较早的时刻。已有任务在你保存新规则之前保留其时间规则:固定速率提醒保持与创建时间对齐,每日、每周或 cron 提醒遵循其 IANA 时区下保存的本地时间。浏览器提供其时区来解释自然语言请求;显式指定时区可以避免歧义。自动化任务页面把每个下次运行时间显示为浏览器时区的钟点;规则的时区与浏览器时区不同时,频率文本会标出该时区;未指定时区的规则使用浏览器时区。
|
||||
|
||||
已保存的发送记录确认消息已持久化到对话收件箱,不表示模型完成了所请求的工作。收件箱持久化或任务写入失败时,不增加已保存记录。收件箱持久化后崩溃或任务写入失败,可能留下未记入发送记录的已投递消息,并重复投递;不保证恰好投递一次。这不是执行历史,也不是外部通知渠道。
|
||||
已保存的发送记录确认消息已持久化到对话收件箱,不表示模型完成了所请求的工作。收件箱持久化或任务写入失败时,不增加已保存记录。收件箱持久化后崩溃或任务写入失败,可能留下未记入发送记录的已投递消息,并重复投递;不保证恰好投递一次。关闭宿主会丢弃仍在对话收件箱中等待的每条消息:其已保存记录仍然保留,模型不会收到该消息,宿主也不会再次发送。这不是执行历史,也不是外部通知渠道。
|
||||
|
||||
已保存的记录在追加确认时裁剪:宿主保留确认时间落在配置的 `deliveryHistoryDays` 窗口内的记录(窗口以本次追加回执的 `deliveredAt` 向前计算),且最多保留最新的 `deliveryHistoryRecords` 条。最近一次回执始终保留,发生裁剪时标记更早记录不可用。宿主将保留的历史保存在内存中,每次确认均重写完整任务行。分页限制返回的记录数,不限制保留的历史或指令字节数。
|
||||
|
||||
不支持暂停和每次运行新建 Session。名称、指令和运行时间的修改对模型开放:`schedule_update` 原地修改提醒并保留其 id 与已保存的投递记录;自动化任务页面也可按所选任务的绑定修改。两者都不是跨会话转交工作流。仅记录在旧 Session 日志中的提醒不会自动调度;请重新创建仍需要的提醒。此前已删除的任务记录不会被重建。
|
||||
模型可通过 `schedule_edit` 修改每条提醒的名称、指令、时间规则和目标,或暂停、恢复提醒;自动化任务页面也可对每条提醒这样做。两者都不是跨会话转交工作流。每次修改都会在每个 Agent 已加载的关联对话的下一次模型请求中加入一条通知,发起修改的对话除外。仅记录在旧 Session 日志中的提醒不会自动调度;请重新创建仍需要的提醒。此前已删除的任务记录不会被重建。
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 延伸阅读
|
||||
|
||||
- [Schedule 参考](../../../packages/schedule/schedule/README.zh.md) 说明精确的时间选择器、持久化与投递限制。
|
||||
- [Schedule 参考](../../../packages/schedule/schedule/README.zh.md) 说明任务字段、投递目标、重复规则、持久化与投递限制。
|
||||
- [自动化任务页面参考](../../../packages/client/ui-schedule/README.zh.md) 说明全局页面与对话顶部目录。
|
||||
|
||||
## 开发备注
|
||||
|
||||
@@ -129,7 +129,7 @@
|
||||
name: '@deepseek-ai/dsh-api-workspace-files'
|
||||
|
||||
# The Schedule service owns the task store and delivers each due occurrence
|
||||
# as a follow-up in its original Session. The clock reading and the four
|
||||
# as a follow-up to the task's target Session. The clock reading and the four
|
||||
# reminder tools belong to the `standard`, `cordis`, and `ptc` presets,
|
||||
# which declare them in their own scope.
|
||||
- id: schedule
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 1eb35b015a6c423f
|
||||
zh: a8a3dceee2c9e943
|
||||
/client-web-gui-browser-half/packages:
|
||||
en: 78c1e5818e230244
|
||||
zh: bce0efe78a240903
|
||||
en: d7adae6baeed495b
|
||||
zh: 9e49fb00adc8eec1
|
||||
/client-web-gui-browser-half/related-documentation:
|
||||
en: d1d506e68fab7333
|
||||
zh: 1a90fe6f3f8f24a9
|
||||
|
||||
@@ -67,7 +67,7 @@ The kernel packages boot and serve the page; the UI feature packages present it.
|
||||
| [`ui-agent-team/`](ui-agent-team/README.md) | Session Team switch and shared tasks below the Subagent tree | — |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — |
|
||||
| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — |
|
||||
| [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — |
|
||||
| [`ui-schedule/`](ui-schedule/README.md) | Provides the sidebar Automation tasks page and right Sidebar task tab, which create, edit, run, pause, resume, and delete Host scheduled tasks, plus the Session header clock and row mark | — |
|
||||
| [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces | — |
|
||||
| [`ui-permission-presets/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access | — |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control | — |
|
||||
|
||||
@@ -67,7 +67,7 @@ kind: "package-group"
|
||||
| [`ui-agent-team/`](ui-agent-team/README.zh.md) | Session Team 开关与 Subagent 树下方的共享任务 | — |
|
||||
| [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent 导航、子级 transcript(文本记录)状态与内联引用 | — |
|
||||
| [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — |
|
||||
| [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前会话中生效的提醒 | — |
|
||||
| [`ui-schedule/`](ui-schedule/README.zh.md) | 提供侧栏“自动化任务”页面与右侧栏任务页签,用于创建、编辑、立即运行、暂停、恢复和删除宿主的计划任务,并提供 Session 顶部时钟与行标记 | — |
|
||||
| [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择 | — |
|
||||
| [`ui-permission-presets/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式 | — |
|
||||
| [`ui-plan/`](ui-plan/README.zh.md) | 展示生效中的 plan mode 状态及其退出控件 | — |
|
||||
|
||||
@@ -82,7 +82,7 @@ function styleInjectionModule(
|
||||
* Everything else under @deepseek-ai/* is either a module-table entry
|
||||
* (external) or a leak the purity gate rejects.
|
||||
*/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-commands\/builtin$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
|
||||
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|official-bundles|deque|output-retention|typert-protocol|util-crypto|util-time|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-util-file-drag$|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-commands\/builtin$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
|
||||
|
||||
/**
|
||||
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
||||
|
||||
@@ -56,6 +56,8 @@ function stableRequestPromptAnchor(
|
||||
* their step's input; in-history updates stay at their own positions. The
|
||||
* request-prompt Definition owns replacement and later-series cards. Positional
|
||||
* replacements advance the effective prompt without changing historical cards.
|
||||
* A published node becomes `hidden` under the same key while a prepended replacement
|
||||
* leaves its prompt order unknown, and `visible` again once older history resolves it.
|
||||
* @param inspect - Pure surface interpretation supplied by uiConversation.
|
||||
* @returns The Chat system-prompt Definition.
|
||||
*/
|
||||
@@ -75,7 +77,11 @@ export function systemMessageDefinition(inspect: SystemPromptInspector): Convers
|
||||
buildViewNode: (context) => {
|
||||
const state = context.state?.introduced
|
||||
if (state === undefined || state.text === ''
|
||||
|| context.start?.event.type !== 'system/message' || context.start.event.surfaceOp !== 'append') return null
|
||||
|| context.start?.event.type !== 'system/message' || context.start.event.surfaceOp !== 'append') {
|
||||
const current = context.current.get('chat') as ChatNode | null | undefined
|
||||
if (current?.kind !== 'system-prompt') return null
|
||||
return chatNode(context, 'system-prompt', current.anchorSeq, current.data, { visibility: 'hidden' })
|
||||
}
|
||||
const anchor = state.update ? state.seq : requestPromptAnchor(context.start, undefined, true)
|
||||
return chatNode(context, 'system-prompt', anchor, { text: state.text, ...state.update ? { update: true } : {} })
|
||||
},
|
||||
|
||||
@@ -2254,6 +2254,18 @@ describe('built-in conversation node Definitions', () => {
|
||||
.map(candidate => candidate.data)).toEqual([{ text: 'A' }, { text: 'B', update: true }, { text: 'C' }])
|
||||
})
|
||||
|
||||
it('hides a published prompt node while a prepended replacement makes its prompt unknown', () => {
|
||||
const value = assembler([systemAt(10, 'Shown')], true)
|
||||
const { key } = node(snapshot(value), 'system-prompt')!
|
||||
expect(snapshot(value).nodes.get(key)?.visibility).toBe('visible')
|
||||
value.prepend([systemAt(5, 'Older', 2)], true)
|
||||
value.flush()
|
||||
expect(snapshot(value).nodes.get(key)).toMatchObject({ visibility: 'hidden', data: { text: 'Shown' } })
|
||||
value.prepend([systemAt(1, 'First')], false)
|
||||
value.flush()
|
||||
expect(snapshot(value).nodes.get(key)).toMatchObject({ visibility: 'visible', data: { text: 'Shown', update: true } })
|
||||
})
|
||||
|
||||
it('never renders a system/message as a transcript bubble', () => {
|
||||
const value = assembler([
|
||||
at(1, 'turn/start', { turn: 1 }),
|
||||
|
||||
@@ -92,10 +92,12 @@ export const zh = {
|
||||
'tool.title.getGoal': '查看目标',
|
||||
'tool.title.updateGoal': '更新目标',
|
||||
'tool.preparing.content': '{kilobytes}KB',
|
||||
'tool.title.createSchedule': '创建定时任务',
|
||||
'tool.title.listSchedules': '查看定时任务',
|
||||
'tool.title.deleteSchedule': '删除定时任务',
|
||||
'tool.title.updateSchedule': '修改定时任务',
|
||||
'tool.title.createSchedule': '创建自动化任务',
|
||||
'tool.title.listSchedules': '查看自动化任务',
|
||||
'tool.title.viewSchedule': '查看自动化任务详情',
|
||||
'tool.title.editSchedule': '编辑自动化任务',
|
||||
'tool.title.deleteSchedule': '删除自动化任务',
|
||||
'tool.title.updateSchedule': '修改自动化任务',
|
||||
'detail.state': '状态',
|
||||
'detail.todo.completed': '已完成',
|
||||
'detail.todo.in_progress': '进行中',
|
||||
@@ -131,12 +133,16 @@ export const zh = {
|
||||
'detail.schedule.frequency': '重复',
|
||||
'detail.schedule.scheduled': '等待触发',
|
||||
'detail.schedule.overdue': '已到期,等待会话恢复',
|
||||
'detail.schedule.empty': '没有定时任务',
|
||||
'detail.schedule.active': '已开启',
|
||||
'detail.schedule.paused': '已暂停',
|
||||
'detail.schedule.ended': '已结束',
|
||||
'detail.schedule.empty': '没有自动化任务',
|
||||
'detail.schedule.deleted': '已删除',
|
||||
'detail.schedule.count': '{count} 个定时任务',
|
||||
'detail.schedule.count': '{count} 个自动化任务',
|
||||
'detail.schedule.daily': '每天 {time}({zone})',
|
||||
'detail.schedule.weekly': '每周 {days} {time}({zone})',
|
||||
'detail.schedule.cron': 'Cron {expression}({zone})',
|
||||
'detail.schedule.custom': '自定义',
|
||||
'detail.weekday.1': '周一',
|
||||
'detail.weekday.2': '周二',
|
||||
'detail.weekday.3': '周三',
|
||||
@@ -473,10 +479,12 @@ export const en = {
|
||||
'tool.title.getGoal': 'View goal',
|
||||
'tool.title.updateGoal': 'Update goal',
|
||||
'tool.preparing.content': '{kilobytes}KB',
|
||||
'tool.title.createSchedule': 'Create reminder',
|
||||
'tool.title.listSchedules': 'List reminders',
|
||||
'tool.title.deleteSchedule': 'Delete reminder',
|
||||
'tool.title.updateSchedule': 'Update reminder',
|
||||
'tool.title.createSchedule': 'Create automation task',
|
||||
'tool.title.listSchedules': 'List automation tasks',
|
||||
'tool.title.viewSchedule': 'View automation task',
|
||||
'tool.title.editSchedule': 'Edit automation task',
|
||||
'tool.title.deleteSchedule': 'Delete automation task',
|
||||
'tool.title.updateSchedule': 'Update automation task',
|
||||
'detail.state': 'Status',
|
||||
'detail.todo.completed': 'Completed',
|
||||
'detail.todo.in_progress': 'In progress',
|
||||
@@ -512,12 +520,16 @@ export const en = {
|
||||
'detail.schedule.frequency': 'Repeat',
|
||||
'detail.schedule.scheduled': 'Scheduled',
|
||||
'detail.schedule.overdue': 'Overdue, awaiting session resume',
|
||||
'detail.schedule.empty': 'No reminders',
|
||||
'detail.schedule.active': 'Enabled',
|
||||
'detail.schedule.paused': 'Paused',
|
||||
'detail.schedule.ended': 'Ended',
|
||||
'detail.schedule.empty': 'No automation tasks',
|
||||
'detail.schedule.deleted': 'Deleted',
|
||||
'detail.schedule.count': '{count} reminders',
|
||||
'detail.schedule.count': '{count} automation tasks',
|
||||
'detail.schedule.daily': 'Every day at {time} ({zone})',
|
||||
'detail.schedule.weekly': 'Weekly on {days} at {time} ({zone})',
|
||||
'detail.schedule.cron': 'Cron {expression} ({zone})',
|
||||
'detail.schedule.custom': 'Custom',
|
||||
'detail.weekday.1': 'Mon',
|
||||
'detail.weekday.2': 'Tue',
|
||||
'detail.weekday.3': 'Wed',
|
||||
@@ -853,10 +865,12 @@ export const ja = {
|
||||
'tool.title.getGoal': '目標を表示',
|
||||
'tool.title.updateGoal': '目標を更新',
|
||||
'tool.preparing.content': '{kilobytes}KB',
|
||||
'tool.title.createSchedule': 'リマインダーを作成',
|
||||
'tool.title.listSchedules': 'リマインダーを一覧表示',
|
||||
'tool.title.deleteSchedule': 'リマインダーを削除',
|
||||
'tool.title.updateSchedule': 'リマインダーを更新',
|
||||
'tool.title.createSchedule': '自動化タスクを作成',
|
||||
'tool.title.viewSchedule': '自動化タスクを表示',
|
||||
'tool.title.editSchedule': '自動化タスクを編集',
|
||||
'tool.title.listSchedules': '自動化タスクを一覧表示',
|
||||
'tool.title.deleteSchedule': '自動化タスクを削除',
|
||||
'tool.title.updateSchedule': '自動化タスクを更新',
|
||||
'detail.state': 'ステータス',
|
||||
'detail.todo.completed': '完了',
|
||||
'detail.todo.in_progress': '進行中',
|
||||
@@ -890,14 +904,18 @@ export const ja = {
|
||||
'detail.schedule.every': '{interval} ごと',
|
||||
'detail.schedule.when': 'スケジュール設定',
|
||||
'detail.schedule.frequency': '繰り返し',
|
||||
'detail.schedule.active': '有効',
|
||||
'detail.schedule.paused': '一時停止中',
|
||||
'detail.schedule.ended': '終了',
|
||||
'detail.schedule.scheduled': 'スケジュール済み',
|
||||
'detail.schedule.overdue': '期限超過、セッション再開待ち',
|
||||
'detail.schedule.empty': 'リマインダーなし',
|
||||
'detail.schedule.empty': '自動化タスクなし',
|
||||
'detail.schedule.deleted': '削除済み',
|
||||
'detail.schedule.count': '{count}件のリマインダー',
|
||||
'detail.schedule.count': '{count}件の自動化タスク',
|
||||
'detail.schedule.daily': '毎日 {time} ({zone})',
|
||||
'detail.schedule.weekly': '毎週 {days} の {time} ({zone})',
|
||||
'detail.schedule.cron': 'Cron {expression} ({zone})',
|
||||
'detail.schedule.custom': 'カスタム',
|
||||
'detail.weekday.1': '月',
|
||||
'detail.weekday.2': '火',
|
||||
'detail.weekday.3': '水',
|
||||
@@ -1231,10 +1249,10 @@ export const es = {
|
||||
'tool.title.getGoal': 'Ver objetivo',
|
||||
'tool.title.updateGoal': 'Actualizar objetivo',
|
||||
'tool.preparing.content': '{kilobytes}KB',
|
||||
'tool.title.createSchedule': 'Crear recordatorio',
|
||||
'tool.title.listSchedules': 'Recordatorios de lista',
|
||||
'tool.title.deleteSchedule': 'Eliminar recordatorio',
|
||||
'tool.title.updateSchedule': 'Recordatorio de actualización',
|
||||
'tool.title.createSchedule': 'Crear tarea de automatización',
|
||||
'tool.title.listSchedules': 'Listar tareas de automatización',
|
||||
'tool.title.deleteSchedule': 'Eliminar tarea de automatización',
|
||||
'tool.title.updateSchedule': 'Actualizar tarea de automatización',
|
||||
'detail.state': 'Estado',
|
||||
'detail.todo.completed': 'Completado',
|
||||
'detail.todo.in_progress': 'En progreso',
|
||||
@@ -1270,12 +1288,13 @@ export const es = {
|
||||
'detail.schedule.frequency': 'repetir',
|
||||
'detail.schedule.scheduled': 'Programado',
|
||||
'detail.schedule.overdue': 'Atrasado, a la espera de reanudar la sesión.',
|
||||
'detail.schedule.empty': 'Sin recordatorios',
|
||||
'detail.schedule.empty': 'Sin tareas de automatización',
|
||||
'detail.schedule.deleted': 'Eliminado',
|
||||
'detail.schedule.count': 'Recordatorios de {count}',
|
||||
'detail.schedule.count': '{count} tareas de automatización',
|
||||
'detail.schedule.daily': 'Todos los días en {time} ({zone})',
|
||||
'detail.schedule.weekly': 'Semanalmente en {days} en {time} ({zone})',
|
||||
'detail.schedule.cron': 'Cron {expression} ({zone})',
|
||||
'detail.schedule.custom': 'Personalizado',
|
||||
'detail.weekday.1': 'lun',
|
||||
'detail.weekday.2': 'mar',
|
||||
'detail.weekday.3': 'mié',
|
||||
@@ -1524,6 +1543,11 @@ export const es = {
|
||||
// aligned with these English values.
|
||||
'terminal.sendInput': '(enviar entrada)',
|
||||
'terminal.session': 'Terminal {sessionId}',
|
||||
'tool.title.viewSchedule': 'Ver tarea de automatización',
|
||||
'tool.title.editSchedule': 'Editar tarea de automatización',
|
||||
'detail.schedule.active': 'Habilitado',
|
||||
'detail.schedule.paused': 'Pausada',
|
||||
'detail.schedule.ended': 'Finalizada',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** French dictionary, checked against the English keys. */
|
||||
@@ -1611,10 +1635,10 @@ export const fr = {
|
||||
'tool.title.getGoal': "Afficher l'objectif",
|
||||
'tool.title.updateGoal': 'Objectif de mise à jour',
|
||||
'tool.preparing.content': '{kilobytes}KB',
|
||||
'tool.title.createSchedule': 'Créer un rappel',
|
||||
'tool.title.listSchedules': 'Liste des rappels',
|
||||
'tool.title.deleteSchedule': 'Supprimer le rappel',
|
||||
'tool.title.updateSchedule': 'Rappel de mise à jour',
|
||||
'tool.title.createSchedule': 'Créer une tâche d\'automatisation',
|
||||
'tool.title.listSchedules': 'Lister les tâches d\'automatisation',
|
||||
'tool.title.deleteSchedule': 'Supprimer la tâche d\'automatisation',
|
||||
'tool.title.updateSchedule': 'Mettre à jour la tâche d\'automatisation',
|
||||
'detail.state': 'Statut',
|
||||
'detail.todo.completed': 'Terminé',
|
||||
'detail.todo.in_progress': 'En cours',
|
||||
@@ -1650,12 +1674,13 @@ export const fr = {
|
||||
'detail.schedule.frequency': 'Répéter',
|
||||
'detail.schedule.scheduled': 'Programmé',
|
||||
'detail.schedule.overdue': 'En retard, en attente de reprise de la session',
|
||||
'detail.schedule.empty': 'Aucun rappel',
|
||||
'detail.schedule.empty': 'Aucune tâche d\'automatisation',
|
||||
'detail.schedule.deleted': 'Supprimé',
|
||||
'detail.schedule.count': 'Rappels {count}',
|
||||
'detail.schedule.count': '{count} tâches d\'automatisation',
|
||||
'detail.schedule.daily': 'Tous les jours à {time} ({zone})',
|
||||
'detail.schedule.weekly': 'Hebdomadaire sur {days} à {time} ({zone})',
|
||||
'detail.schedule.cron': 'Cron {expression} ({zone})',
|
||||
'detail.schedule.custom': 'Personnalisé',
|
||||
'detail.weekday.1': 'lundi',
|
||||
'detail.weekday.2': 'mar.',
|
||||
'detail.weekday.3': 'mer',
|
||||
@@ -1904,6 +1929,11 @@ export const fr = {
|
||||
// aligned with these English values.
|
||||
'terminal.sendInput': '(envoyer une entrée)',
|
||||
'terminal.session': 'Borne {sessionId}',
|
||||
'tool.title.viewSchedule': 'Voir la tâche d\'automatisation',
|
||||
'tool.title.editSchedule': 'Modifier la tâche d\'automatisation',
|
||||
'detail.schedule.active': 'Activé',
|
||||
'detail.schedule.paused': 'Suspendue',
|
||||
'detail.schedule.ended': 'Terminée',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** German dictionary, checked against the English keys. */
|
||||
@@ -1991,10 +2021,10 @@ export const de = {
|
||||
'tool.title.getGoal': 'Ziel ansehen',
|
||||
'tool.title.updateGoal': 'Ziel aktualisieren',
|
||||
'tool.preparing.content': '{kilobytes}KB',
|
||||
'tool.title.createSchedule': 'Erinnerung erstellen',
|
||||
'tool.title.listSchedules': 'Listen Sie Erinnerungen auf',
|
||||
'tool.title.deleteSchedule': 'Erinnerung löschen',
|
||||
'tool.title.updateSchedule': 'Update-Erinnerung',
|
||||
'tool.title.createSchedule': 'Automatisierungsaufgabe erstellen',
|
||||
'tool.title.listSchedules': 'Automatisierungsaufgaben auflisten',
|
||||
'tool.title.deleteSchedule': 'Automatisierungsaufgabe löschen',
|
||||
'tool.title.updateSchedule': 'Automatisierungsaufgabe aktualisieren',
|
||||
'detail.state': 'Status',
|
||||
'detail.todo.completed': 'Abgeschlossen',
|
||||
'detail.todo.in_progress': 'In Bearbeitung',
|
||||
@@ -2030,12 +2060,13 @@ export const de = {
|
||||
'detail.schedule.frequency': 'Wiederholen',
|
||||
'detail.schedule.scheduled': 'Geplant',
|
||||
'detail.schedule.overdue': 'Überfällig, ich warte auf die Fortsetzung der Sitzung',
|
||||
'detail.schedule.empty': 'Keine Erinnerungen',
|
||||
'detail.schedule.empty': 'Keine Automatisierungsaufgaben',
|
||||
'detail.schedule.deleted': 'Gelöscht',
|
||||
'detail.schedule.count': '{count} Erinnerungen',
|
||||
'detail.schedule.count': '{count} Automatisierungsaufgaben',
|
||||
'detail.schedule.daily': 'Täglich um {time} ({zone})',
|
||||
'detail.schedule.weekly': 'Wöchentlich auf {days} um {time} ({zone})',
|
||||
'detail.schedule.cron': 'Cron {expression} ({zone})',
|
||||
'detail.schedule.custom': 'Benutzerdefiniert',
|
||||
'detail.weekday.1': 'Mo',
|
||||
'detail.weekday.2': 'Di',
|
||||
'detail.weekday.3': 'Mi',
|
||||
@@ -2284,6 +2315,11 @@ export const de = {
|
||||
// aligned with these English values.
|
||||
'terminal.sendInput': '(Eingabe senden)',
|
||||
'terminal.session': 'Terminal {sessionId}',
|
||||
'tool.title.viewSchedule': 'Automatisierungsaufgabe anzeigen',
|
||||
'tool.title.editSchedule': 'Automatisierungsaufgabe bearbeiten',
|
||||
'detail.schedule.active': 'Aktiviert',
|
||||
'detail.schedule.paused': 'Pausiert',
|
||||
'detail.schedule.ended': 'Beendet',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** Russian dictionary, checked against the English keys. */
|
||||
@@ -2371,10 +2407,10 @@ export const ru = {
|
||||
'tool.title.getGoal': 'Посмотреть цель',
|
||||
'tool.title.updateGoal': 'Обновить цель',
|
||||
'tool.preparing.content': '{kilobytes}KB',
|
||||
'tool.title.createSchedule': 'Создать напоминание',
|
||||
'tool.title.listSchedules': 'Список напоминаний',
|
||||
'tool.title.deleteSchedule': 'Удалить напоминание',
|
||||
'tool.title.updateSchedule': 'Обновить напоминание',
|
||||
'tool.title.createSchedule': 'Создать задачу автоматизации',
|
||||
'tool.title.listSchedules': 'Показать задачи автоматизации',
|
||||
'tool.title.deleteSchedule': 'Удалить задачу автоматизации',
|
||||
'tool.title.updateSchedule': 'Обновить задачу автоматизации',
|
||||
'detail.state': 'Статус',
|
||||
'detail.todo.completed': 'Завершено',
|
||||
'detail.todo.in_progress': 'В процессе',
|
||||
@@ -2410,12 +2446,13 @@ export const ru = {
|
||||
'detail.schedule.frequency': 'Повторить',
|
||||
'detail.schedule.scheduled': 'Запланировано',
|
||||
'detail.schedule.overdue': 'Просрочено, ждём возобновления сеанса',
|
||||
'detail.schedule.empty': 'Нет напоминаний',
|
||||
'detail.schedule.empty': 'Нет задач автоматизации',
|
||||
'detail.schedule.deleted': 'Удалено',
|
||||
'detail.schedule.count': '{count} напоминания',
|
||||
'detail.schedule.count': '{count} задачи автоматизации',
|
||||
'detail.schedule.daily': 'Каждый день в {time} ({zone})',
|
||||
'detail.schedule.weekly': 'Еженедельно на {days} по адресу {time} ({zone})',
|
||||
'detail.schedule.cron': 'Крон {expression} ({zone})',
|
||||
'detail.schedule.custom': 'Произвольно',
|
||||
'detail.weekday.1': 'Пн.',
|
||||
'detail.weekday.2': 'Вт',
|
||||
'detail.weekday.3': 'ср.',
|
||||
@@ -2664,6 +2701,11 @@ export const ru = {
|
||||
// aligned with these English values.
|
||||
'terminal.sendInput': '(отправить ввод)',
|
||||
'terminal.session': 'Терминал {sessionId}',
|
||||
'tool.title.viewSchedule': 'Посмотреть задачу автоматизации',
|
||||
'tool.title.editSchedule': 'Изменить задачу автоматизации',
|
||||
'detail.schedule.active': 'Включено',
|
||||
'detail.schedule.paused': 'Приостановлена',
|
||||
'detail.schedule.ended': 'Завершена',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
|
||||
/** Portuguese dictionary, checked against the English keys. */
|
||||
@@ -2751,10 +2793,10 @@ export const pt = {
|
||||
'tool.title.getGoal': 'Ver meta',
|
||||
'tool.title.updateGoal': 'Atualizar meta',
|
||||
'tool.preparing.content': '{kilobytes}KB',
|
||||
'tool.title.createSchedule': 'Criar lembrete',
|
||||
'tool.title.listSchedules': 'Listar lembretes',
|
||||
'tool.title.deleteSchedule': 'Excluir lembrete',
|
||||
'tool.title.updateSchedule': 'Lembrete de atualização',
|
||||
'tool.title.createSchedule': 'Criar tarefa de automação',
|
||||
'tool.title.listSchedules': 'Listar tarefas de automação',
|
||||
'tool.title.deleteSchedule': 'Excluir tarefa de automação',
|
||||
'tool.title.updateSchedule': 'Atualizar tarefa de automação',
|
||||
'detail.state': 'Estado',
|
||||
'detail.todo.completed': 'Concluído',
|
||||
'detail.todo.in_progress': 'Em andamento',
|
||||
@@ -2790,12 +2832,13 @@ export const pt = {
|
||||
'detail.schedule.frequency': 'Repetir',
|
||||
'detail.schedule.scheduled': 'Agendado',
|
||||
'detail.schedule.overdue': 'Atrasado, aguardando retomada da sessão',
|
||||
'detail.schedule.empty': 'Sem lembretes',
|
||||
'detail.schedule.empty': 'Sem tarefas de automação',
|
||||
'detail.schedule.deleted': 'Excluído',
|
||||
'detail.schedule.count': '{count} lembretes',
|
||||
'detail.schedule.count': '{count} tarefas de automação',
|
||||
'detail.schedule.daily': 'Todos os dias às {time} ({zone})',
|
||||
'detail.schedule.weekly': 'Semanalmente em {days} às {time} ({zone})',
|
||||
'detail.schedule.cron': 'Cron {expression} ({zone})',
|
||||
'detail.schedule.custom': 'Personalizado',
|
||||
'detail.weekday.1': 'segunda-feira',
|
||||
'detail.weekday.2': 'Terça',
|
||||
'detail.weekday.3': 'Quarta',
|
||||
@@ -3044,4 +3087,9 @@ export const pt = {
|
||||
// aligned with these English values.
|
||||
'terminal.sendInput': '(enviar entrada)',
|
||||
'terminal.session': 'Terminal {sessionId}',
|
||||
'tool.title.viewSchedule': 'Ver tarefa de automação',
|
||||
'tool.title.editSchedule': 'Editar tarefa de automação',
|
||||
'detail.schedule.active': 'Habilitado',
|
||||
'detail.schedule.paused': 'Pausada',
|
||||
'detail.schedule.ended': 'Concluída',
|
||||
} satisfies Record<keyof typeof en, string>
|
||||
@@ -18,8 +18,8 @@
|
||||
en: ead6be55c7c9d7cf
|
||||
zh: 125ad36db7328551
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 289fe5c01b026e81
|
||||
zh: 5680066a8443fba9
|
||||
en: b9d0c4f0fedba393
|
||||
zh: 046e2f671160794d
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: f13b4ba09521d300
|
||||
zh: a6093993d8fd0cdb
|
||||
|
||||
@@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. |
|
||||
| `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. |
|
||||
| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. |
|
||||
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
|
||||
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. A disabled `MenuItemButton` with a `disabledReason` stays hoverable and focusable (`aria-disabled`) but cannot be activated; it shows the reason in a `Tooltip` and exposes it as the row's description. |
|
||||
| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
|
||||
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
||||
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. |
|
||||
|
||||
@@ -49,7 +49,7 @@ kind: "package-library"
|
||||
| `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 |
|
||||
| `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 |
|
||||
| `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 |
|
||||
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
||||
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 带 `disabledReason` 的禁用 `MenuItemButton` 仍可悬停和聚焦(`aria-disabled`),但不能激活;它在 `Tooltip` 中显示原因,并把原因作为该行的描述。 |
|
||||
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
|
||||
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
||||
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 |
|
||||
|
||||
@@ -121,7 +121,7 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.item:hover:not(:disabled) {
|
||||
.item:hover:not(:disabled):not([aria-disabled='true']) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +173,8 @@
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.item:disabled {
|
||||
.item:disabled,
|
||||
.item[aria-disabled='true'] {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
@@ -251,7 +252,7 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.danger:hover:not(:disabled) {
|
||||
.danger:hover:not(:disabled):not([aria-disabled='true']) {
|
||||
background: var(--dsw-alias-interactive-bg-hover-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { observeComposition } from './keyboard-composition.ts'
|
||||
import { focusWithoutRing } from './focus.ts'
|
||||
import { ShortcutKeys } from './ShortcutKeys.tsx'
|
||||
import { MenuSurface } from './MenuSurface.tsx'
|
||||
import { Tooltip } from './Tooltip.tsx'
|
||||
import css from './Menu.module.css'
|
||||
|
||||
/** Selectable row (optionally with a nested submenu). */
|
||||
@@ -55,6 +56,12 @@ export interface MenuItemButtonProps {
|
||||
current?: boolean
|
||||
/** Whether the row cannot be activated. */
|
||||
disabled?: boolean
|
||||
/**
|
||||
* Why the disabled row cannot be activated. The row then stays hoverable and
|
||||
* focusable, shows the reason in a tooltip, and exposes it as the row's
|
||||
* description; an enabled row ignores it.
|
||||
*/
|
||||
disabledReason?: string | undefined
|
||||
/** Destructive row: error-colored text/icon and danger hover fill. */
|
||||
danger?: boolean
|
||||
/**
|
||||
@@ -79,30 +86,41 @@ export interface MenuItemButtonProps {
|
||||
* @param props.icon - optional leading icon.
|
||||
* @param props.current - whether the row identifies the currently displayed page or view.
|
||||
* @param props.disabled - whether the row cannot be activated.
|
||||
* @param props.disabledReason - why a disabled row cannot be activated, shown in a tooltip and read as its description.
|
||||
* @param props.danger - whether to use the destructive row colors.
|
||||
* @param props.separatorBefore - whether this row starts a new group (hairline above it).
|
||||
* @param props.onSelect - row activation callback.
|
||||
* @returns one menu-item row.
|
||||
*/
|
||||
export function MenuItemButton({
|
||||
children, shortcut, icon, current, disabled = false, danger = false, separatorBefore = false, onSelect,
|
||||
children, shortcut, icon, current, disabled = false, disabledReason, danger = false, separatorBefore = false, onSelect,
|
||||
}: MenuItemButtonProps) {
|
||||
const reasonId = useId()
|
||||
// Native disabled buttons do not deliver the hover/focus events Tooltip needs,
|
||||
// so an explained row is only marked unavailable. The Tooltip wraps every row,
|
||||
// so a reason that comes or goes never remounts the focused row.
|
||||
const explained = disabled && disabledReason !== undefined
|
||||
return (
|
||||
<div className={css.itemWrap}>
|
||||
{separatorBefore && <div className={css.separator} role="separator" />}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, danger && css.danger)}
|
||||
disabled={disabled}
|
||||
aria-keyshortcuts={shortcut?.aria}
|
||||
aria-current={current === true ? 'page' : undefined}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{icon !== undefined && <span className={css.itemIcon}>{icon}</span>}
|
||||
<span className={css.itemLabel}>{children}</span>
|
||||
{shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={shortcut.keys} className={css.shortcutKeys} /></span>}
|
||||
</button>
|
||||
<Tooltip label={disabledReason ?? ''} disabled={!explained} side="bottom" delayMs={500} maxWidth={300} portal>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={clsx(css.item, danger && css.danger)}
|
||||
disabled={disabled && !explained}
|
||||
aria-disabled={explained || undefined}
|
||||
aria-describedby={explained ? reasonId : undefined}
|
||||
aria-keyshortcuts={shortcut?.aria}
|
||||
aria-current={current === true ? 'page' : undefined}
|
||||
onClick={explained ? undefined : onSelect}
|
||||
>
|
||||
{icon !== undefined && <span className={css.itemIcon}>{icon}</span>}
|
||||
<span className={css.itemLabel}>{children}</span>
|
||||
{shortcut !== undefined && <span aria-hidden="true" className={css.shortcut}><ShortcutKeys keys={shortcut.keys} className={css.shortcutKeys} /></span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{explained && <span id={reasonId} hidden>{disabledReason}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -425,6 +443,11 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
|
||||
if (insideList) {
|
||||
if (focused instanceof Element && focused.getAttribute('role') === 'menuitem') {
|
||||
e.preventDefault()
|
||||
if (focused.getAttribute('aria-disabled') === 'true') {
|
||||
onClose()
|
||||
refocusAnchor(true)
|
||||
return
|
||||
}
|
||||
selectingWithTab.current = true
|
||||
try { (focused as HTMLElement).click() }
|
||||
finally { selectingWithTab.current = false }
|
||||
|
||||
@@ -226,6 +226,68 @@ describe('Menu', () => {
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('explains an unavailable component row on hover and focus without activating it', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
|
||||
try {
|
||||
const onSelect = vi.fn()
|
||||
const reason = 'Available at 15:51:37.'
|
||||
const menu = (explanation: string | undefined) => (
|
||||
<Menu open anchor={<button type="button">trigger</button>} onClose={() => {}}>
|
||||
<MenuItemButton disabled={explanation !== undefined} disabledReason={explanation} onSelect={onSelect}>Run now</MenuItemButton>
|
||||
</Menu>
|
||||
)
|
||||
const { rerender } = render(menu(reason))
|
||||
const row = screen.getByRole('menuitem', { name: 'Run now', description: reason })
|
||||
expect(row.hasAttribute('disabled')).toBe(false)
|
||||
expect(row.getAttribute('aria-disabled')).toBe('true')
|
||||
fireEvent.click(row)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
fireEvent.pointerEnter(row, { pointerType: 'mouse' })
|
||||
act(() => { vi.advanceTimersByTime(499) })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByRole('tooltip').textContent).toBe(reason)
|
||||
fireEvent.pointerLeave(row, { pointerType: 'mouse' })
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
// The arrow walk reaches the row, and keyboard focus shows the reason at once.
|
||||
const trigger = screen.getByRole('button', { name: 'trigger' })
|
||||
fireEvent.keyDown(document.body, { key: 'Tab' })
|
||||
trigger.focus()
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(row)
|
||||
expect(screen.getByRole('tooltip').textContent).toBe(reason)
|
||||
// A reason that goes away keeps the focused row mounted and makes it an ordinary row.
|
||||
rerender(menu(undefined))
|
||||
expect(document.activeElement).toBe(row)
|
||||
expect(row.hasAttribute('aria-disabled')).toBe(false)
|
||||
expect(row.hasAttribute('aria-describedby')).toBe(false)
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
fireEvent.click(row)
|
||||
expect(onSelect).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('closes an explained unavailable row on Tab and returns focus to the trigger', () => {
|
||||
const onClose = vi.fn()
|
||||
const onSelect = vi.fn()
|
||||
render(<Menu open anchor={<button type="button">trigger</button>} onClose={onClose}>
|
||||
<MenuItemButton disabled disabledReason="Wait for the next run interval" onSelect={onSelect}>Run now</MenuItemButton>
|
||||
</Menu>)
|
||||
const trigger = screen.getByRole('button', { name: 'trigger' })
|
||||
trigger.focus()
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
const row = screen.getByRole('menuitem', { name: 'Run now' })
|
||||
expect(document.activeElement).toBe(row)
|
||||
fireEvent.keyDown(row, { key: 'Tab' })
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('disabled item does not select; Escape and outside pointerdown close', () => {
|
||||
const onSelect = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
@@ -3,38 +3,38 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md
|
||||
/:
|
||||
en: 265778d19fe0dd76
|
||||
zh: 78b0a191a4bd4e4e
|
||||
en: b4f472194d36189e
|
||||
zh: 9dc81770c98be03b
|
||||
/deepseek-ai-dsh-client-ui-schedule:
|
||||
en: 862120984d5392eb
|
||||
zh: eb3e5d5a802da592
|
||||
/deepseek-ai-dsh-client-ui-schedule/summary:
|
||||
en: 8dfc371c14bb4415
|
||||
zh: 5e2fa588983524ed
|
||||
en: 0ccbbf29836a8fca
|
||||
zh: d2de9b43e89ab05e
|
||||
/deepseek-ai-dsh-client-ui-schedule/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 405b30feaf48c087
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
|
||||
en: 09b1e66bcd4881da
|
||||
zh: 5bf2178d6842cfcf
|
||||
en: 43cae3c92503c742
|
||||
zh: 41c64476fd9f2375
|
||||
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
|
||||
en: 47cd16c142e82385
|
||||
zh: 181849a3c7a167fd
|
||||
en: 4005b800e930f581
|
||||
zh: ccb9318dad7f7e8f
|
||||
/deepseek-ai-dsh-client-ui-schedule/understand-the-implementation:
|
||||
en: 959145f9a08c3913
|
||||
zh: 7c0afb742790c474
|
||||
en: 38d8ce4465c803a4
|
||||
zh: e2378f3a9b70aa38
|
||||
/deepseek-ai-dsh-client-ui-schedule/further-exploration:
|
||||
en: 06e265120b05dbf5
|
||||
zh: 08ab1ef979b8ea40
|
||||
en: 2fd968202916b197
|
||||
zh: f1ab66194846562b
|
||||
/deepseek-ai-dsh-client-ui-schedule/model-experience:
|
||||
en: a8838aba5fd29fd8
|
||||
zh: b9f67d82c81dd030
|
||||
en: 064b67705e5da4ca
|
||||
zh: 2163217c14878c59
|
||||
/deepseek-ai-dsh-client-ui-schedule/model-experience/kv-cache-effect:
|
||||
en: 4b81350950285d47
|
||||
zh: 6dc3d5cf7ca8a622
|
||||
en: 52239e5688dfebe1
|
||||
zh: 37343f5dbde010e8
|
||||
/deepseek-ai-dsh-client-ui-schedule/known-limitations-and-deferred-work:
|
||||
en: ce8cd6e03f4763ae
|
||||
zh: 65ad26aad74d83ca
|
||||
en: 6ee939ce57fcea66
|
||||
zh: 0e99fadf1fb7ac4d
|
||||
/deepseek-ai-dsh-client-ui-schedule/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
en: 18a59ce211e19bbd
|
||||
zh: a4b126f9a4eaca6f
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Web task management across Sessions and a current-Session reminder catalog."
|
||||
description: "Automation task management and Session reminder entries in the existing Web UI."
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Manage active and inactive tasks from the Automation tasks page and the right Sidebar task tab: search, filter, edit name, instruction, and run time, browse records, delete, or open the original conversation. Every surface names a task by its title. An open Session's header shows an icon-only reminder clock while active reminders exist and opens that task's detail; a `schedule_create` call renders a transcript card; an idle, unarchived Session row with active tasks shows a clock mark and hover list.
|
||||
The Automation tasks page and right Sidebar task tab share task rules, local edits, saved delivery records, deletion, and conversation navigation. Manual creation and target selection use the Host Schedule service; conversation cards, header clocks, and Session row marks retain their existing locations.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -27,85 +27,69 @@ Manage active and inactive tasks from the Automation tasks page and the right Si
|
||||
|
||||
Below 768px, the task list uses 20px horizontal insets, matching the Plugins page.
|
||||
|
||||
The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an inactive task, the search row's More task actions menu offers Clear all inactive tasks, which removes every inactive task after a confirmation that states how many inactive tasks the catalog currently holds. The page's task list supports text search and one Enabled, Inactive, and All status filter row, starting on Enabled; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
|
||||
The shipped Web composition mounts `ui-schedule` with Host Schedule. The Automation tasks sidebar entry opens the task list without selecting or activating a Session. New retains its existing behavior: open a new conversation and let the user enter an instruction. Its adjacent menu offers Create in conversation and Create manually. Manual creation opens the shared Modal, validates required fields, and saves a task without starting an Agent or sending the instruction. A failed request leaves the form and its values visible. The empty state shows a decorative document-and-clock illustration whose colors follow the theme.
|
||||
|
||||
The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. On a coarse pointer the list's search box and the detail's instruction and run-time fields take a 16px step — iOS Safari zooms the visual viewport for a focused field below that size — while the name control keeps its 20px step and a fine pointer keeps the compact steps.
|
||||
Enabled, Paused, Ended, and All filters combine with text search over the title, prompt, and linked Session id; the page starts on Enabled. The search-row menu clears all ended tasks after confirmation, preserving active and paused tasks. Selecting a task opens Rules / Records. Edit stages a draft; Save sends changed fields with `expectedUpdatedAt`, or `null` when no stored revision exists. An unchanged Save still asks the Host to store the current row shape. A successful save returns to the read-only view and focuses Edit. Conflict retains the draft and offers Reload; Cancel discards it. Late responses cannot change another task's detail. Coarse-pointer search, instruction, and timing fields use 16px text to prevent focus zoom on iOS Safari.
|
||||
|
||||
Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view.
|
||||
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.
|
||||
|
||||
Rules shows the rule in a Run time card whose rows edit a local draft: Repeat is a menu of Weekly, Monday to Friday, Every day, Every N hours, Every N minutes, Every N seconds, Once, and Custom (cron), a weekly rule adds a Weekday row, a fixed-rate choice edits only the interval quantity in the unit it names, a one-shot rule edits separate Date and Time rows, and Custom (cron) edits one Cron expression row holding a five-field expression, interpreted in the selected time zone. The card parses that expression in the browser and, under the row, shows a localized sentence describing it; an expression the Host would reject shows the localized validation message in place of that sentence and blocks Save. A stored weekly rule whose weekday set is exactly Monday through Friday selects the Monday to Friday choice. The Date row is a read-only trigger showing the staged date as `YYYY/MM/DD` and opening a month calendar with localized weekday headings, month navigation, and a marked today; the draft and the submitted text keep the ISO form; the Time row is a read-only trigger showing one 24-hour clock and opening three scrolling columns of hours, minutes, and seconds with the staged value marked and scrolled into view. A calendar or clock pick stages the draft under the same staged save as every other row, arrow keys walk the open column or grid, Enter stages the marked value, Escape closes the panel and returns focus to its row, and a disabled row cannot open one. Clock rows show whole seconds, an untouched row keeps a stored millisecond value for its submit, and editing a time submits whole-second precision. Time zone is a searchable, height-bounded menu. Its search field takes focus automatically on desktop; phones and tablets require explicit focus. An unlabeled top block keeps the five most recently selected exact zones in browser-local storage; first use seeds the actual system zone and UTC rather than inferring a location from UI language. The system zone is marked, and the remaining inventory is ordered by current UTC offset and canonical IANA id. Each visible label puts `UTC±HH:MM` before the localized zone name; canonical ids stay internal but remain searchable. Zones that ICU renders with the same offset and localized name share one visible row; every grouped id remains searchable, and an already stored id remains selected instead of being rewritten. The inventory comes from `Intl.supportedValuesOf('timeZone')`, and names come from `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`: both are browser ICU/CLDR data, not a scraped or product-maintained translation table. There is therefore no crawler or generated zone-name resource to refresh. A third-party locale supplies its ordinary `time.locale` value and automatically receives the corresponding ICU names; only the selector's surrounding UI copy needs translation. When the runtime cannot enumerate zones, a short identifier fallback is offered, and a stored zone outside the offered set is kept. Daily, Weekly, and Cron keep their stored rule and IANA zone, and each frequency line names the zone only when it differs from the host's system zone. A one-shot After/At seeds the date and time from the record's stored zone, or this device's zone when the record stores none, so the seeded clock names the stored instant; changing that zone keeps the entered clock values and changes the instant. A kind the card seeds on selection uses the committed occurrence's clock and the choice's zone, which is the record's stored zone or this device's zone, a seeded weekly rule starts from that occurrence's weekday in that zone, and a seeded cron rule states that occurrence's clock as a daily expression. Every accepts at least 60 whole seconds, independent of time zones. One Save submits the complete expected record with the staged change and preserves the id, original Session, latest receipt, and saved history; [Schedule](../../schedule/schedule/README.md#use-this-package) defines future-target selection, reanchoring, and unchanged-rule no-ops.
|
||||
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. Save validates Custom text through the Host; invalid or empty input keeps the draft open and shows the existing correction message. 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 rows are disabled while a save is in flight, while the catalog is loading, during a deletion, and for an inactive task. A save bar appears only while the local draft differs from the stored task: it shows Unsaved changes with Cancel and Save changes, and Save changes becomes Saving… while the save is in flight. Cancel restores the stored values. A catalog refresh becomes the shown values only while the draft has no unsaved edit, so an unsaved draft survives an authoritative refresh. A failed save keeps the draft and shows a localized message; after a conflict, retry against the refreshed record. If the task disappears from the catalog, Delete is disabled until the task returns. A successful update refreshes the authoritative catalog. A rejected or unconfirmed update is distinct from a catalog-read failure: Retry reloads the catalog without presenting the update as failed. If the update itself cannot be confirmed, check the task before retrying. Late responses after switching tasks or closing details do not replace the current view, and no failure rolls back a Host write already begun.
|
||||
Pause and resume update lifecycle state. 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 Edit, 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.
|
||||
|
||||
Opening Records lazily requests the newest 20 saved records. Load older records adds another page in newest-first append order, independent of wall-clock rollback. The tab strip is the whole header of that view: the editable name and next-run line belong to Rules. Each Saved delivery record leads with a clock glyph and its occurrence time as the locale's month name and clock field pair in the task's zone, or the browser's zone for a one-shot record, and shows the actual saved prompt when present. A prompt longer than two lines shows its first two lines and an Expand toggle, which Collapse reverses; a collapsed prompt rechecks its length when the panel width changes. A legacy receipt without a prompt never substitutes the current instruction.
|
||||
The list, detail, header, and Session hover show the later of the scheduled occurrence and the Host delivery floor as the next run. Creation and editing show one short minimum-interval hint for per-run conversation targets, read from `schedule.deliveryLimits`; a failed read offers Retry. The read-only detail keeps the next-run time without repeating that hint. Scheduled and immediate runs share this limit.
|
||||
|
||||
After the last saved page loads, a nonempty history with confirmed pruning shows a borderless notice at the bottom left of the detail panel. Its info button expands or collapses the current Host retention rules. Empty history, legacy unavailability without confirmed pruning, incomplete pagination, and failed reads show no cleanup notice.
|
||||
Records lazily requests 20 saved deliveries, supports newest-first pagination, retains loaded records after failure, and ignores superseded or unmounted requests. Each record shows its occurrence and saved prompt, with Expand / Collapse for long prompts; legacy records without prompt text never substitute the current instruction. A receipt's actual destination has a conversation link when the Session is available. Confirmed pruning is stated only after the final saved page and offers the current retention explanation. These records acknowledge inbox delivery, not model execution success.
|
||||
|
||||
A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view.
|
||||
|
||||
The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear tasks.` after a failure; a normal failure leaves the remaining inactive rows for a retry, while a backend `unknown-state` failure may have removed some rows and quarantines the Host's schedule domain until it is reopened. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed.
|
||||
|
||||
An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then.
|
||||
|
||||
The popover is body-portaled and targets 336px, sharing the trigger left edge when space permits and shifting left to retain a 16px viewport margin when the trigger is near the right edge; its width never exceeds the viewport width minus 32px, and it scrolls vertically when the list overflows. It exposes no task id, raw UTC value, or delivery details; each row carries only its detail opener and its Delete action.
|
||||
|
||||
Calling `schedule_create` renders the created task in the transcript as a card with a clock icon, its stored title, its frequency, and an Open button that opens that task's right-Sidebar tab. The card first narrows the task out of the call's persisted result JSON and reads its `title` from that result; once a catalog read that succeeded after the card appeared resolves that task, the card follows the catalog for the current name and frequency, and shows its deleted state instead of a frequency when that read lacks the record. A result recorded before the stored field existed falls back to the instruction's first line. A running call, a failed creation, or a result carrying no complete task keeps the row, shows the raw result text when the result is a single non-empty text block, and offers no Open action. The card adds no Host field and no Session-log format change, so Sessions recorded before the card existed render it too.
|
||||
|
||||
The Sidebar's Session rows mark scheduled Sessions: while a row is idle, unarchived, and its Session has at least one active task, the row shows a clock mark; an archived row keeps that cell blank, with its live status on the hover card only. A long hover on a Session with active tasks adds an automation section inside that row's existing hover card, whether or not the row is idle, listing up to two tasks with their icon, stored title, frequency, and next-run text, and an omission line when further tasks are hidden. A row with a higher-priority state — a pending interaction, live activity, or an unviewed completion — keeps its own dot and renders no mark, so the two never appear together. The mark and the hover section read the shared active and inactive Host task catalog and select their own Session's active tasks from it, so any number of visible rows costs one read; they never activate, retain, or unarchive a Session, and never read a Session log.
|
||||
|
||||
-----
|
||||
An open conversation's header clock remains hidden before the first successful read or while no active related tasks exist. One task opens its right Sidebar tab; several open the existing popover. Idle, unarchived Session rows with active related tasks retain their clock mark and hover list. `schedule_create` continues to render a Turn-level card with a task title, repeat description, and Open action. Persisted legacy and current tool results both render; an authoritative read newer than the card resolves later edits or deletion.
|
||||
|
||||
### Time-zone data source
|
||||
|
||||
Time-zone inventory and localization are offline. `Intl` reads ICU/CLDR tables embedded in the browser runtime and never fetches or downloads zone data while DSH runs. There is no crawler to refresh; a third-party locale supplies `time.locale` and translates only the selector's own UI copy.
|
||||
|
||||
Access: the plugin registers these surfaces only while the current Peer may call `schedule/catalog`, and the Session-header reminder catalog renders only while that Session also allows `schedule/list`; the task tab type and the turn definition stay registered. The owner always has access.
|
||||
Time-zone enumeration and localized names use the browser's embedded ICU/CLDR data through `Intl`. There is no runtime download. The editor retains a stored alias even when enumeration omits it, and uses the system zone plus UTC when enumeration is unavailable.
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
<summary>Implementation details — click to expand</summary>
|
||||
|
||||
The browser entry registers the `schedules` main panel and matching sidebar entry, the `schedule-catalog` entry among the Session header's right-aligned utilities at `order: -5` immediately left of the overflow menu, the `schedule_create` transcript card at the turn level through `ctx.uiConversation.events` and ui-chat's list seat `conversation.chat.turnTail`, so it renders after the turn's closing assistant text and stays visible when the tool-call group is collapsed, the root-scoped Session-row seats `sidebar.session.row.leading` and `sidebar.session.row.hover`, and the right-Sidebar page type `@deepseek-ai/dsh-client-ui-schedule/task` under the kind `scheduleTask`. The type registers in two stages: its definition into `ctx.sidebarRightTabs`, then its body and chip into the keyed `sidebar.right.pane.tab` and `sidebar.right.pane.tab.title` seats under the definition's id. Opening a task from the Session entry calls `ctx.sidebarRight.openTab` with that entry's Session id and the chosen task id, so the detail appears in the Sidebar for the Session the entry belongs to; the Automation tasks page keeps its own list and shares the same detail component. The global page, the tab, the row mark, and the row hover section read active and inactive tasks from one `schedule.catalog` source; the header entry keeps its own per-Session `schedule.list` read, which returns only that Session's active tasks. Every surface uses the same observable query lifecycle and refreshes after `schedule/changed` or connection resets. The transcript card narrows the created task from the call's persisted result JSON rather than a Host-added field; the call's own Tool-group cell stays with ui-tool's generic keyed tool view, and the card appears only for a call that produced a complete task. The reconciliation is causal, never clock-based: a catalog snapshot carries `readRequest` (the ordinal of the newest requested read) and `readSettled` (the ordinal of the read that produced its records); the Turn tail anchors `readRequest` when it mounts and requests one read, uses its retained records only once `readSettled` exceeds that anchor, and the source coalesces the requests of one commit into a single `list()`, sharing that read only with callers that have not already observed it. Comparing the browser's clock with the Host's would not establish that order. The shared delete callback and injected `onUpdateTiming` retain the selected record's original Session binding rather than using the currently open conversation. The detail holds the shown task's local draft, the authoritative values it compares that draft against, and any pending or failed save; the owner retains the task while a save is pending or failed and after a confirmed deletion, so a catalog refresh that drops the row can neither close the surfaced failure nor close the detail that states the deletion. Save submits one `schedule.update` request carrying the complete expected `ScheduleRecord` and the staged change; `onUpdateTiming` refreshes the catalog without turning a readback failure into a mutation failure.
|
||||
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 disposable selection, search, form, and paging state. The Host owns task storage and execution. Browsing catalogs does not activate a Session.
|
||||
|
||||
A restored task tab carries no navigation parameters, because the Sidebar persists a tab's layout record and not what its opener passed. The page type therefore keeps its own binding from the Session and the layout record's id to the Session and id of the task that record last showed, stored under one `dsh.schedule.task-tab.v1.<sessionId>` localStorage key per Session with one entry per tab id and never any task content. Its body and chip read the entry only for a record without navigation parameters, remove it once a read that succeeded after the tab appeared cannot resolve it, and report no missing task on a binding's behalf. Once such a read leaves the tab without a task identity, the body names that state instead of reporting a load that never finishes.
|
||||
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.
|
||||
|
||||
Subscriptions exist only while the framework observes a source. The final unsubscribe releases event listeners and invalidates pending responses. Components receive framework-bound `useCatalog` hooks and explicit action callbacks. The mounted Records view reads `schedule.history` with the selected task's original Session binding; catalog and list responses omit full history. Components own disposable search, filtering, selection, confirmation, and history-page state. Reminder records remain Host-owned and do not come from historical Session projections or browser-local demo data.
|
||||
|
||||
Task menus use the [standard menu geometry](../../../docs/ui-radius.md#nested-hover), including keyboard-highlighted rows. Created-task cards follow the [file-card family](../../../docs/ui-radius.md#radius-scale) for their radius and icon-tile material.
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
- [Schedule](../../schedule/schedule/README.md) owns persistence, dispatch, and deletion semantics.
|
||||
- [Slots](../../../docs/subsystems/slots.md) documents injected hooks and panel contributions.
|
||||
- [Schedule](../../schedule/schedule/README.md) owns persistence, destinations, lifecycle, and delivery semantics.
|
||||
- [Slots](../../../docs/subsystems/slots.md) defines framework readers and panel contributions.
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as this browser UI registers no model-facing tools or messages; Schedule owns reminder delivery.
|
||||
None, as this browser package registers no model tools or messages.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None. Catalog presentation does not enter model requests.
|
||||
None. Task-list content does not enter model requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Saved records describe inbox delivery, not model execution results. Tasks without saved history expose only their existing receipt until new deliveries append records; unsaved earlier records and prompt snapshots cannot be recovered. Previously physically deleted tasks are not restored.
|
||||
- Twenty-record pages bound the number of records requested, not prompt bytes or the storage a task retains. Delivery history is bounded by the Host plugin's configuration: `deliveryHistoryDays` (default 30) and `deliveryHistoryRecords` (default 200), pruned when an acknowledgment is appended, with the latest receipt always kept and a pruned older window reported as unavailable; see [Schedule limits](../../schedule/schedule/README.md#known-limitations-and-deferred-work).
|
||||
- Deletion does not cancel a reminder already queued for execution. The linked-Session control in the detail tab strip opens the original conversation through ordinary Session navigation and its recovery policy, not a message-specific jump.
|
||||
- Reminder creation remains with the Schedule tools. Name, instruction, and run-time edits are available only in the Rules view of an active task, in the Automation tasks page and in the task tab; pausing and immediate execution are not available, and the Run time card's Repeat menu accepts any choice, including one that changes the stored recurrence kind, in which case the target is recomputed from the new rule. The page's New action starts a Session instead of creating a task in place, and the Session-header popover itself has no run-time editor. The Time zone menu offers the runtime's IANA zone inventory, or a short curated list when the runtime cannot enumerate zones.
|
||||
- Saved records describe inbox delivery, not execution results; absent historical prompts or physically deleted tasks cannot be reconstructed.
|
||||
- The Host limits saved history by `deliveryHistoryDays` and `deliveryHistoryRecords`; page size limits record count, not prompt bytes.
|
||||
- Conversation links open the Session through normal navigation, not a specific message. Deleting a task does not cancel messages already delivered.
|
||||
- This UI provides no task-level authorization for model tools. Host Schedule owns access control and must enforce any per-Session restrictions.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
<summary>Maintenance context — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "跨会话的 Web 任务管理页面及当前会话的提醒列表。"
|
||||
description: "基于现有 Web UI 的自动化任务管理和会话提醒入口。"
|
||||
kind: "package-reference"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-reference"
|
||||
|
||||
## 概述
|
||||
|
||||
在「自动化任务」页面和右侧栏的任务页签中管理各个会话的活动和已结束定时任务:搜索、筛选、修改名称、指令和运行时间、浏览任务运行记录、删除任务,或打开原会话。每个界面都用任务已存储的标题命名任务。已打开会话在存在活动提醒时,页头显示仅图标的提醒时钟,点击在右侧栏打开该任务详情;`schedule_create` 调用把创建的任务渲染为会话卡片;侧栏中带活动任务的未归档空闲会话行显示时钟标记,其悬停卡片列出这些任务。
|
||||
自动化任务页和右侧栏任务页签共用任务规则、本地编辑、已保存的投递记录、删除及会话导航。手动创建和执行目标选择使用宿主的 Schedule 服务;会话卡片、页头时钟和会话行标记保留现有位置。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -27,39 +27,25 @@ kind: "package-reference"
|
||||
|
||||
宽度小于 768px 时,任务列表使用 20px 的左右内边距,与插件页一致。
|
||||
|
||||
随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在已结束任务后,搜索行右侧的「更多任务操作」菜单提供「清除全部已结束任务」,经确认后移除全部已结束任务,确认提示说明目录当前包含多少个已结束任务。列表支持文本搜索和一行“已开启/已结束/全部”状态筛选,默认停在“已开启”;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或已结束任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
|
||||
发布的 Web 组合将 `ui-schedule` 与宿主 Schedule 一起挂载。自动化任务侧栏入口打开任务列表,不选择或激活会话。“新建”保留现有行为:进入新会话,由用户输入指令。相邻下拉提供“在对话中创建”和“手动创建”。手动创建打开共用 Modal,校验必填项并保存任务,不启动 Agent 或发送指令。请求失败时保留表单和已填内容。空态显示装饰性的文档与时钟插图,其颜色随主题变化。
|
||||
|
||||
页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。粗指针设备上,列表的搜索框以及详情的指令和运行时间输入框采用 16px 字号——iOS Safari 会放大聚焦到更小字号输入框时的视觉视口——而名称输入框保留 20px、细指针设备保留紧凑字号。
|
||||
“已开启”“已暂停”“已结束”“全部”筛选结合标题、指令和关联会话 id 搜索,默认显示已开启任务。搜索行菜单在确认后清除全部已结束任务,保留已开启和已暂停任务。选择任务打开“规则 / 任务运行记录”。编辑暂存草稿;保存发送变更字段及 `expectedUpdatedAt`,存储版本缺失时发送 `null`。未改动的保存仍要求宿主写入当前行格式。成功后返回只读视图并聚焦“编辑”。冲突保留草稿并提供“重新加载”;取消丢弃草稿。迟到响应不会改变其他任务详情。粗指针设备的搜索、指令和时间字段使用 16px 字号,避免 iOS Safari 聚焦时缩放。
|
||||
|
||||
从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,已结束任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。
|
||||
执行位置下拉提供“新建对话并复用”和“每次新建对话”,手动创建默认前者。已经关联会话的任务,执行位置显示“当前关联对话”;编辑时保留原目标,只有用户主动选择新方式才会切换。“关联工作区”选择器列出框架工作区目录,在每个工作区名称下显示其路径,并以本地化默认名称标注首次使用时创建的工作区。新的执行位置默认使用该工作区;没有该工作区时使用列表中的第一个。没有目录的已有任务显示“未分组”,因为它创建的会话不加入任何工作区。复用目标的首次会话尚未存在时,详情底部入口显示“待创建”。每次新建的目标在首次投递后显示“最近运行对话”,首次投递前隐藏该入口;更早的对话仍可从任务运行记录进入。可用链接在打开前再次检查会话是否仍存在、是否归档;缺失或归档会话保持禁用。
|
||||
|
||||
“规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。桌面端打开时自动聚焦搜索框;手机和平板需要手动聚焦。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。
|
||||
重复编辑提供仅一次、每分钟、每小时、每天、每周、每月和自定义规则。日期和时间控件复用已有选择器。引导选项生成 RRULE;分钟和小时间隔从所选时区的当前分钟开始。自定义选项和自定义输入框不提示任何语法名称;输入框接受两行 RRULE 或带显式时区的五字段 Cron。RRULE 在 `DTSTART` 中保留时区;Cron 将表达式和所选时区保留为 Cron 时间类型。复杂 RRULE 保持原文。保存通过 Host 校验自定义文本;无效或空输入保持草稿打开,并显示已有纠正提示。已有 After、At、Every、Daily、Weekly 记录在自定义中显示描述,并在输入替代规则前保留原类型。编辑其他字段保留已提交发生时点。
|
||||
|
||||
行在保存进行中、目录加载中、删除期间以及任务已结束时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。
|
||||
暂停和恢复修改生命周期状态。详情菜单提供立即执行和删除,不提供取消排队消息:同一条收件箱消息可能包含多个任务的投递。投递到复用对话的投递排队或执行期间,详情禁用编辑、暂停与恢复、立即执行和删除,并说明此任务已排队或执行中,请在会话中管理任务;详情底部的会话入口打开该会话。最小投递间隔结束前禁用再次立即执行,执行空闲后仍可暂停或删除。仅因间隔禁用时,立即执行项仍可聚焦,其提示说明未满最小运行间隔,并给出可以立即执行的时间。已经关联会话的任务,其关联对话已归档时,同样禁用立即执行,因为宿主拒绝这次投递。宿主以 `schedule_execution_pending` 拒绝的立即执行显示为状态提示,而非错误。删除需要确认,移除任务及其已保存记录,保留会话和已经投递的消息,并通过现有应用级提示报告结果。确认对话框首先聚焦“取消”;对话框关闭后,焦点回到详情菜单按钮。宿主答复之前,详情保持显示该任务。删除生效后关闭详情或其任务页签;删除失败时保留任务,以便再次尝试。
|
||||
|
||||
打开“任务运行记录”时才请求最新的 20 条已保存记录。“加载更早的记录”按追加顺序从新到旧增加一页,不受实际时间回拨影响。该视图中页签条就是整个页头:名称输入框和“下次运行”行只属于“规则”。每条“已保存的任务运行记录”以时钟图标和任务时区下的本地月份名与时钟字段开头(一次性记录使用浏览器时区),并在存在时显示实际保存的提示文本。超过两行的提示文本只显示前两行并提供“展开”,展开后可“收起”;面板宽度变化时,收起状态的提示文本会重新判断是否超过两行。旧回执没有提示文本时,绝不以当前指令代替。
|
||||
列表、详情、页头和会话悬浮内容的下次运行时间,取计划发生时间和宿主最早允许投递时间中较晚的一项。创建和编辑“每次新建对话”时,通过 `schedule.deliveryLimits` 读取宿主配置,以一句短提示显示最小间隔;读取失败可重试。只读详情保留下次运行时间,不重复该提示。定时与立即执行共用此限制。
|
||||
|
||||
最后一页加载完成后,非空且确认裁剪过的历史在详情面板左下角显示无分隔线的清理提示,信息图标可展开或收起当前宿主的保留规则。空历史、未确认裁剪的旧格式历史、尚未加载完的分页和读取失败均不显示清理提示。
|
||||
“任务运行记录”按需请求 20 条已保存投递,支持从新到旧分页,失败时保留已加载记录,并忽略被刷新替代或卸载后的请求。每条记录显示投递发生时间与已保存指令,长指令可展开、收起;缺少指令的旧记录不使用当前指令替代。回执的实际执行会话可用时提供入口。仅在最后一页且确认裁剪后显示清理提示,并提供当前保留规则说明。这些记录确认收件箱投递,不表示模型执行成功。
|
||||
|
||||
历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。
|
||||
|
||||
自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「清除任务失败」;普通失败会保留尚未移除的已结束行以便重试,后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,直到重新打开。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以已结束状态保存。显式删除前,通过全部或已结束筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;已结束状态和该回执均不表示模型执行完成。
|
||||
|
||||
已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。
|
||||
|
||||
弹层通过 portal 挂到 body,目标宽度为 336px;空间足够时与触发按钮左边缘对齐,触发器靠近视口右侧时向左避让并保留 16px 视口边距,宽度不超过视口宽度减 32px,列表溢出时纵向滚动。它不显示任务 id、原始 UTC 值或投递详情;每行只带自身的详情入口和删除操作。
|
||||
|
||||
调用 `schedule_create` 时,创建的任务会以卡片形式渲染在 transcript 中,包含时钟图标、已存储的标题、频率,以及打开该任务右侧栏页签的“打开”按钮。卡片先按该调用持久化的结果 JSON 收窄出任务,并从该结果读取 `title`;当一次在卡片出现之后成功结束的目录读取解析到该任务时,卡片改为跟随目录显示当前名称与频率,若该读取中不含该记录,则以“已删除”代替频率显示。在已存储字段存在之前录制的结果回退为指令首行。仍在运行的调用、创建失败,或结果中没有完整任务时,卡片保留该行,并在结果为单个非空文本块时显示原始结果文本,不提供“打开”操作。卡片不增加 Host 字段,也不改变 Session 日志格式,因此早于该卡片录制的 Session 也会渲染它。
|
||||
|
||||
侧栏的 Session 行会标记有定时任务的会话:当某行处于空闲状态、未归档且其会话至少有一个活动任务时,该行显示时钟标记;归档行该格留空,其实时状态只在悬浮卡片中显示。对存在活动任务的会话长按悬停时,无论该行是否空闲,都会在其已有的悬浮卡片内增加自动化区段,最多列出两个任务(图标、已存储的标题、频率与下次运行文本),还有更多任务被隐藏时显示遗漏行。处于更高优先级状态的行——有待处理交互、活动仍在进行,或有未查看的完成提醒——保留自身状态点且不渲染标记,两者不会同时出现。该标记和悬停区段读取共享的活动与已结束 Host 任务目录,并从中筛选本会话的活动任务;它们不会激活、保留或取消归档任何会话,也不读取 Session 日志。
|
||||
|
||||
-----
|
||||
打开会话的页头时钟在首次读取成功之前或没有活动关联任务时保持隐藏。一个任务直接打开右侧栏页签;多个任务打开现有弹层。空闲、未归档且有活动关联任务的会话行保留时钟标记和悬浮列表。`schedule_create` 继续在轮次末尾渲染任务卡片,显示标题、重复描述和“打开”操作。持久化的旧工具结果与当前结果均可渲染;比卡片更新的权威读取同步后续修改或删除。
|
||||
|
||||
### 时区数据来源
|
||||
|
||||
时区清单与本地化完全离线。`Intl` 读取浏览器运行时内嵌的 ICU/CLDR 表,DSH 运行时不会获取或下载时区数据。没有需要刷新的爬虫;第三方语言包提供 `time.locale`,并仅翻译选择器自身的 UI 文案。
|
||||
|
||||
访问控制:只有当前 Peer 可以调用 `schedule/catalog` 时本插件才注册这些界面,会话头部的提醒还要求该会话允许 `schedule/list` 才渲染;任务 tab 类型与 turn 定义始终注册。owner 始终有权限。
|
||||
时区清单和本地化名称通过 `Intl` 使用浏览器内嵌的 ICU/CLDR 数据,不在运行时下载。选择器保留清单未列出的已存储别名;无法枚举时使用系统时区和 UTC。
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 理解实现
|
||||
@@ -67,39 +53,37 @@ kind: "package-reference"
|
||||
<details>
|
||||
<summary>实现内部细节 — 点击展开</summary>
|
||||
|
||||
浏览器入口注册 `schedules` 主面板及对应的侧栏入口、Session 页头右侧功能位中位于 `order: -5` 且紧邻更多操作菜单左侧的 `schedule-catalog` 入口、经由 `ctx.uiConversation.events` 与 ui-chat 的 list seat `conversation.chat.turnTail` 在轮次层级注册的 `schedule_create` transcript 卡片(因此它渲染在该轮收尾的 assistant 文本之后,并在工具调用组折叠时仍然可见)、root 作用域的两个 Session 行 seat `sidebar.session.row.leading` 与 `sidebar.session.row.hover`,以及 kind 为 `scheduleTask` 的右侧栏页类型 `@deepseek-ai/dsh-client-ui-schedule/task`。该类型分两阶段注册:先把其定义注册进 `ctx.sidebarRightTabs`,再按该定义的 id 把正文与 chip 注册进带键 seat `sidebar.right.pane.tab` 与 `sidebar.right.pane.tab.title`。从 Session 入口打开任务时调用 `ctx.sidebarRight.openTab`,传入该入口的 Session id 与所选任务 id,详情因此出现在该入口所属 Session 的右侧栏中;自动化任务页面保留自己的列表并共用同一个详情组件。全局页面、任务页签、行标记与行悬停区段都从同一个 `schedule.catalog` 源读取活动与已结束任务;页头入口保留自己的按 Session `schedule.list` 读取,只返回该 Session 的活动任务。每个面都使用同一套可观察的查询生命周期,并在 `schedule/changed` 或连接重置后刷新。transcript 卡片从该调用持久化的结果 JSON 收窄出创建的任务,而不是依赖 Host 追加字段;该调用自身的工具调用组单元格由 ui-tool 的通用键控视图渲染,且卡片只对产生完整任务的调用出现。该对齐是因果判定而非时钟比较:目录快照带有 `readRequest`(最新被请求的读取序号)与 `readSettled`(产出当前记录的读取序号);Turn 尾部挂载时锚定 `readRequest` 并请求一次读取,仅在 `readSettled` 大于该锚点后才用其保留记录判定,且源把一次提交内的请求合并为单次 `list()`,只把该读取共享给尚未观察到它的调用者。比较浏览器时钟与 Host 时钟无法确立这一顺序。共用的删除回调与注入的 `onUpdateTiming` 保留所选记录的原 Session 绑定,而不使用当前打开的对话。详情保留当前任务的本地草稿、用于比较的权威值以及等待中或失败的保存;保存等待中或失败期间以及删除确认后,属主保留所显示的任务,使目录刷新在丢弃该行时既不能关闭已呈现的失败,也不能关闭陈述删除的详情。保存发出一次携带完整预期 `ScheduleRecord` 和分阶段变更的 `schedule.update` 请求;`onUpdateTiming` 刷新目录,而不把回读失败变成修改失败。
|
||||
Peer 可读取 `schedule/catalog` 时,插件注册现有 `schedules` 主面板、会话页头功能位、轮次尾部卡片、会话行标记与悬浮内容,以及 `scheduleTask` 右侧栏页签。槽位注入提供框架读取器与操作回调;组件拥有可丢弃的选择、搜索、表单和分页状态。宿主拥有任务存储与执行。浏览目录不会激活会话。
|
||||
|
||||
刷新恢复出的任务标签没有导航参数,因为 Sidebar 只持久化标签的布局记录,不持久化打开方传入的内容。因此该页面类型自行保存从 Session 加布局记录 id 到该记录最后显示任务的 Session 和 id 的关联:每个 Session 一个 `dsh.schedule.task-tab.v1.<sessionId>` localStorage 键,按 tab id 一条记录,且从不保存任务内容。正文和 chip 只对没有导航参数的记录读取该条目,在标签出现之后成功的一次读取无法解析时将其删除,且不代替关联声称任务缺失。这样的一次读取无法为该标签给出任务标识时,正文陈述该状态,而不呈现永不结束的加载。
|
||||
管理页、任务页签、行标记和会话页头投影共享一个可观察的 `schedule.catalog` 数据源。页头与会话行根据关联关系和执行目标筛选活动记录。修改使用基于任务 id 的 `schedule.create`、`schedule.edit` 和 `schedule.run`;历史使用任务标识和游标,不携带会话所有权参数。修改确认后发起新的权威读取;回读失败仍是目录错误,不将已经确认的写入改判为失败。
|
||||
|
||||
仅在框架观察数据源期间订阅。最后一个订阅取消时释放事件监听,并使尚未完成的响应失效。组件接收框架绑定的 `useCatalog` hook 和明确的操作回调。已挂载的任务运行记录视图使用所选任务的原会话绑定读取 `schedule.history`;目录和列表响应不包含完整历史。组件拥有可丢弃的搜索、筛选、选择、确认和历史分页状态。提醒记录由 Host 拥有,不来自历史会话投影或浏览器本地演示数据。
|
||||
|
||||
任务菜单使用[标准菜单几何](../../../docs/ui-radius.zh.md#nested-hover),包含键盘高亮行。已创建任务卡片的圆角和图标底框材质遵循[文件卡片家族](../../../docs/ui-radius.zh.md#radius-scale)。
|
||||
侧栏布局记录不保存打开参数,因此 `TaskTabBindings` 按会话和页签 id 在 localStorage 中保存任务标识,不保存任务内容,并清理已关闭页签的关联。可观察目录仅在被读取时订阅,在 Schedule 变更或连接重置时刷新,最后一个订阅退出后使未完成读取失效。
|
||||
|
||||
</details>
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 延伸阅读
|
||||
|
||||
- [Schedule](../../schedule/schedule/README.zh.md) 拥有持久化、投递和删除语义。
|
||||
- [槽位](../../../docs/subsystems/slots.zh.md) 说明注入 hook 和面板贡献。
|
||||
- [Schedule](../../schedule/schedule/README.zh.md) 拥有持久化、执行目标、生命周期和投递语义。
|
||||
- [槽位](../../../docs/subsystems/slots.zh.md) 定义框架读取器和面板贡献。
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无,因为浏览器 UI 不注册面向模型的工具或消息;提醒投递由 Schedule 拥有。
|
||||
无:此浏览器包不注册模型工具或消息,Schedule 拥有提醒投递。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无。列表展示不进入模型请求。
|
||||
无。任务列表内容不进入模型请求。
|
||||
|
||||
## 已知限制与后续工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 已保存的记录描述收件箱投递,不表示模型执行结果。没有已保存历史的任务仅呈现已有回执,直到新投递追加记录;未保存的更早记录和提示文本快照无法恢复。此前已物理删除的任务不会恢复。
|
||||
- 每页 20 条仅限制请求的记录数,不限制提示文本字节数或任务保留的存储。投递历史由 Host 插件的配置设界:`deliveryHistoryDays`(默认 30)与 `deliveryHistoryRecords`(默认 200),在追加确认时裁剪,最近一次回执始终保留,被裁剪的更早窗口报告为不可用;见 [Schedule 限制](../../schedule/schedule/README.zh.md#known-limitations-and-deferred-work)。
|
||||
- 删除不会取消已经排队执行的提醒。详情页签条内的关联会话入口使用普通会话导航及其恢复策略打开原对话,不直接跳转到特定消息。
|
||||
- 创建提醒仍通过 Schedule 工具完成。仅在活动任务的“规则”中可修改名称、指令和运行时间,自动化任务页面和任务页签均可;不提供暂停或立即执行,且“运行时间”卡片的“重复”菜单接受任意选择,包括改变已存储重复规则种类;此时按新规则重算目标。页面的“新建”操作会打开一个会话,而不是在页面内创建任务;会话页头的弹层本身不提供运行时间编辑器。“时区”菜单提供运行时的 IANA 时区清单,运行时无法枚举时改用简短的候选列表。
|
||||
- 已保存记录描述收件箱投递,不表示执行结果;无法重建缺失的历史指令或已物理删除的任务。
|
||||
- 宿主通过 `deliveryHistoryDays` 和 `deliveryHistoryRecords` 限制历史;分页大小限制记录数量,不限制指令字节数。
|
||||
- 会话入口通过普通导航打开会话,不跳转到特定消息。删除任务不会取消已投递的消息。
|
||||
- 此 UI 不为模型工具提供任务级授权。宿主 Schedule 拥有访问控制,任何按会话限制都必须由宿主执行。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"@deepseek-ai/dsh-client-ui-workspace": "workspace:*",
|
||||
"@deepseek-ai/dsh-schedule": "workspace:*",
|
||||
"@deepseek-ai/dsh-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-time": "workspace:*",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent, ReactNode, RefObject } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { secondPrecision } from './task-timing.ts'
|
||||
import { PickerPopover } from './PickerPopover.tsx'
|
||||
import type { TaskManagerKey } from './task-manager-locales.ts'
|
||||
import css from './ClockPicker.module.css'
|
||||
@@ -28,20 +27,21 @@ const COLUMNS = [
|
||||
{ label: 'timing.second', options: padded(60) },
|
||||
] as const satisfies readonly { readonly label: TaskManagerKey; readonly options: readonly string[] }[]
|
||||
|
||||
/** A whole-second clock, the only text a pick composes onto. */
|
||||
const CLOCK_SECONDS = /^\d{2}:\d{2}:\d{2}$/
|
||||
/** Leading hour and minute of a clock, with its optional whole seconds. */
|
||||
const CLOCK = /^(\d{2}):(\d{2})(?::(\d{2}))?/
|
||||
|
||||
/**
|
||||
* The `HH:MM:SS` clock a pick composes onto.
|
||||
*
|
||||
* A value that states no clock at all starts from midnight, so the first pick
|
||||
* still submits one complete clock rather than leaving a row empty.
|
||||
* A clock without seconds gains `:00`, and fractional seconds drop. A value
|
||||
* that states no clock at all starts from midnight, so the first pick still
|
||||
* submits one complete clock rather than leaving a row empty.
|
||||
* @param value - staged clock text.
|
||||
* @returns its whole-second clock, or midnight when it states none.
|
||||
*/
|
||||
function clockBase(value: string): string {
|
||||
const seconds = secondPrecision(value)
|
||||
return CLOCK_SECONDS.test(seconds) ? seconds : '00:00:00'
|
||||
const match = CLOCK.exec(value)
|
||||
return match === null ? '00:00:00' : `${match[1]}:${match[2]}:${match[3] ?? '00'}`
|
||||
}
|
||||
|
||||
/** One column's position in display order. */
|
||||
@@ -83,9 +83,9 @@ export interface ClockPickerProps {
|
||||
/** Dismiss the panel. */
|
||||
readonly onClose: () => void
|
||||
/**
|
||||
* Whether the panel offers the seconds column (default true). A cron rule has
|
||||
* no seconds field, so its form hides the column; a pick then keeps the staged
|
||||
* clock's own seconds.
|
||||
* Whether the panel offers the seconds column (default true). A daily rule
|
||||
* names only its hour and minute, so its form hides the column; a pick then
|
||||
* keeps the staged clock's own seconds.
|
||||
*/
|
||||
readonly seconds?: boolean
|
||||
/** Row copy. */
|
||||
|
||||
@@ -7,14 +7,15 @@ import {
|
||||
IconWarningOutlineRegular, StateDot, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ScheduleDeliveryHistoryRequest, ScheduleDeliveryHistoryResult } from '@deepseek-ai/dsh-schedule/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { formatScheduleNextRun } from './schedule-format.ts'
|
||||
import css from './TaskManagerPage.module.css'
|
||||
|
||||
/** Read saved records without opening or activating the original Session. */
|
||||
export interface DeliveryHistoryInjected {
|
||||
/**
|
||||
* Read a newest-first page for the exact task and Session.
|
||||
* @param request - Task binding, required page size, and optional older-page cursor.
|
||||
* Read a newest-first page of one task's saved deliveries.
|
||||
* @param request - Task, required page size, and optional older-page cursor.
|
||||
* @returns Remote success or failure; transport exceptions may reject the promise.
|
||||
*/
|
||||
readonly loadHistory: (request: ScheduleDeliveryHistoryRequest) => Promise<RemoteResult<ScheduleDeliveryHistoryResult>>
|
||||
@@ -25,7 +26,12 @@ type Cursor = ScheduleDeliveryHistoryRequest['before']
|
||||
type Failure = { key: 'delivery.error' | 'delivery.notFound' | 'delivery.cursorError'; before: Cursor }
|
||||
type Props = DeliveryHistoryInjected & PropsLocale<'schedule.manager'> & {
|
||||
id: ScheduleDeliveryHistoryRequest['id']
|
||||
sessionId: ScheduleDeliveryHistoryRequest['sessionId']
|
||||
/** Open the Session that accepted one delivery. */
|
||||
onOpenSession: (id: SessionId) => void
|
||||
/** Display titles of known Sessions, by id. */
|
||||
sessionTitles: Readonly<Record<string, string>>
|
||||
/** Sessions that a delivery's open button may open. */
|
||||
availableSessionIds: readonly string[]
|
||||
latestMessageId: Cursor
|
||||
/** IANA zone of the task's own wall-clock rule; undefined for a one-shot or interval task. */
|
||||
timeZone?: string | undefined
|
||||
@@ -62,12 +68,14 @@ function SavedPrompt({ prompt, t }: { prompt: string } & PropsLocale<'schedule.m
|
||||
}
|
||||
|
||||
/**
|
||||
* Render immutable saved deliveries; the parent keys this view by task and Session.
|
||||
* Render immutable saved deliveries; the parent keys this view by task.
|
||||
* Refreshes supersede pending older pages, and unmount ignores both late outcomes.
|
||||
* @param props - Exact task binding, latest receipt identity, Remote callback, task zone, and locale.
|
||||
* @param props - Task, latest receipt identity, Remote callback, Session links, task zone, and locale.
|
||||
* @returns Saved records and explicit loading, failure, and pagination actions.
|
||||
*/
|
||||
export function DeliveryHistory({ id, sessionId, latestMessageId, timeZone, loadHistory, t }: Props) {
|
||||
export function DeliveryHistory({
|
||||
id, latestMessageId, timeZone, loadHistory, onOpenSession, sessionTitles, availableSessionIds, t,
|
||||
}: Props) {
|
||||
const [page, setPage] = useState<Page>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [failure, setFailure] = useState<Failure>()
|
||||
@@ -82,7 +90,7 @@ export function DeliveryHistory({ id, sessionId, latestMessageId, timeZone, load
|
||||
setFailure(undefined)
|
||||
let result: RemoteResult<ScheduleDeliveryHistoryResult>
|
||||
try {
|
||||
result = await loadHistory({ id, sessionId, limit: 20, ...(before === undefined ? {} : { before }) })
|
||||
result = await loadHistory({ id, limit: 20, ...(before === undefined ? {} : { before }) })
|
||||
} catch (_error: unknown) {
|
||||
// Transport exceptions use the same localized retry as Remote failures.
|
||||
if (epoch !== request.current.epoch) return
|
||||
@@ -113,7 +121,7 @@ export function DeliveryHistory({ id, sessionId, latestMessageId, timeZone, load
|
||||
return { ...next, records }
|
||||
})
|
||||
}
|
||||
}, [id, sessionId, loadHistory])
|
||||
}, [id, loadHistory])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
@@ -153,6 +161,8 @@ export function DeliveryHistory({ id, sessionId, latestMessageId, timeZone, load
|
||||
<div className={css.deliveryBody}>
|
||||
<div className={css.deliveryHead}>
|
||||
<time className={css.deliveryTime} dateTime={record.scheduledAt}>{formatOccurrence(record.scheduledAt)}</time>
|
||||
<Button size="sm" disabled={!availableSessionIds.includes(record.targetSessionId)}
|
||||
onClick={() => { onOpenSession(record.targetSessionId) }}>{sessionTitles[record.targetSessionId] ?? t('host.openDeliverySession')}</Button>
|
||||
</div>
|
||||
{record.prompt !== undefined && <SavedPrompt prompt={record.prompt} t={t} />}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Display the Host's effective admission interval before selecting per-run conversations. */
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { Button, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostTaskActions } from './TaskDetail.tsx'
|
||||
import css from './TaskManagerPage.module.css'
|
||||
|
||||
/** Read the configured interval through an injected action; failed reads stay visible and retryable.
|
||||
* @param props - The failure-contained read action and task-manager translations.
|
||||
* @returns The configured interval, a loading indicator, or a retryable failure.
|
||||
*/
|
||||
export function NewSessionLimit({ load, t }: PropsLocale<'schedule.manager'> & {
|
||||
readonly load: HostTaskActions['loadNewSessionInterval']
|
||||
}): ReactNode {
|
||||
const [attempt, setAttempt] = useState(0)
|
||||
const [interval, setInterval] = useState<number | 'loading' | 'error'>('loading')
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
setInterval('loading')
|
||||
void load().then((seconds) => {
|
||||
if (active) setInterval(seconds ?? 'error')
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [load, attempt])
|
||||
if (interval === 'loading') return <div className={css.saveNotice} role="status" aria-label={t('host.limitsLoading')}><StateDot state="ongoing" /></div>
|
||||
if (interval === 'error') return <div className={css.notice}>
|
||||
<p role="alert">{t('host.limitsError')}</p>
|
||||
<Button size="sm" onClick={() => { setAttempt(value => value + 1) }}>{t('list.retry')}</Button>
|
||||
</div>
|
||||
const unit = interval % 3_600 === 0 ? 'hour' : interval % 60 === 0 ? 'minute' : 'second'
|
||||
const value = interval / (unit === 'hour' ? 3_600 : unit === 'minute' ? 60 : 1)
|
||||
const label = new Intl.NumberFormat(t('time.locale'), { style: 'unit', unit, unitDisplay: 'long' }).format(value)
|
||||
return <p className={css.saveNotice}>{t('host.newSessionInterval', { interval: label })}</p>
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/** The existing task rule menu treatment shared by target and timing fields. */
|
||||
import { useId, useMemo, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { TaskMenu } from './TaskMenu.tsx'
|
||||
import type { TaskMenuEntry } from './TaskMenu.tsx'
|
||||
import { zoneChoices, zoneName } from './schedule-format.ts'
|
||||
import { loadRecentTimeZones, rememberTimeZone } from './recent-time-zones.ts'
|
||||
import css from './TaskManagerPage.module.css'
|
||||
|
||||
/** A labelled menu row using the current task detail's trigger, portal, and keyboard behavior. */
|
||||
export function RuleSelectRow({ label, value, current, items, disabled, onChange, onDismiss, header, listClassName }: {
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly current: string
|
||||
readonly items: readonly TaskMenuEntry[]
|
||||
readonly disabled: boolean
|
||||
readonly onChange: (value: string) => void
|
||||
readonly onDismiss?: () => void
|
||||
readonly header?: ReactNode
|
||||
readonly listClassName?: string | undefined
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false)
|
||||
const close = (): void => { setOpen(false); onDismiss?.() }
|
||||
return <TaskMenu className={css.ruleRowMenu} open={open && !disabled} onClose={close}
|
||||
items={items} selectedId={value} align="end" portal header={header} listClassName={listClassName}
|
||||
onSelect={(id) => { close(); onChange(id) }}
|
||||
anchor={<button type="button" className={css.ruleValue} disabled={disabled}
|
||||
aria-haspopup="menu" aria-expanded={open && !disabled} onClick={() => { if (open) close(); else setOpen(true) }}>
|
||||
<span className={css.ruleLabel}>{label}</span>{' '}
|
||||
<span className={clsx(css.ruleValueFace, css.ruleControl)}><span className={css.ruleCurrent}>{current}</span>
|
||||
<IconChevronDownOutlineRegular /></span>
|
||||
</button>} />
|
||||
}
|
||||
|
||||
/** The current task detail's searchable, localized zone menu with recent choices. */
|
||||
export function RuleZoneRow({ value, disabled, onChange, t }: PropsLocale<'schedule.manager'> & {
|
||||
readonly value: string
|
||||
readonly disabled: boolean
|
||||
readonly onChange: (value: string) => void
|
||||
}): ReactNode {
|
||||
const systemZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const [query, setQuery] = useState('')
|
||||
const [recent, setRecent] = useState(() => loadRecentTimeZones(systemZone))
|
||||
const id = useId()
|
||||
const catalog = useMemo(() => {
|
||||
const byLabel = new Map<string, { id: string; label: string; zones: string[] }>()
|
||||
const at = Date.now()
|
||||
for (const zone of zoneChoices(value, systemZone, at)) {
|
||||
const label = zoneName(zone, systemZone, t, at)
|
||||
const existing = byLabel.get(label)
|
||||
if (existing === undefined) byLabel.set(label, { id: zone, label, zones: [zone] })
|
||||
else { existing.zones.push(zone); if (zone === value) existing.id = zone }
|
||||
}
|
||||
return [...byLabel.values()]
|
||||
}, [value, systemZone, t])
|
||||
const normalized = query.trim().toLocaleLowerCase(t('time.locale'))
|
||||
const matches = catalog.filter(item => `${item.zones.join(' ')} ${item.label}`.toLocaleLowerCase(t('time.locale')).includes(normalized))
|
||||
.map(item => ({ ...item, id: item.zones.find(zone => zone.toLocaleLowerCase(t('time.locale')) === normalized) ?? item.id }))
|
||||
const recentItems = normalized === '' ? recent.flatMap((zone) => {
|
||||
const item = catalog.find(item => item.zones.includes(zone))
|
||||
return item === undefined ? [] : [{ ...item, id: zone }]
|
||||
}).filter((item, index, items) => items.findIndex(candidate => candidate.label === item.label) === index) : []
|
||||
const available = matches.filter(item => !recentItems.some(recent => recent.label === item.label))
|
||||
const items: readonly TaskMenuEntry[] = recentItems.length === 0 ? available : available.length === 0 ? recentItems
|
||||
: [...recentItems, { type: 'separator', id: `${id}-recent` }, ...available]
|
||||
return <RuleSelectRow label={t('timing.zone')} value={value} current={zoneName(value, systemZone, t)} disabled={disabled}
|
||||
onDismiss={() => { setQuery('') }}
|
||||
items={items.length === 0 ? [{ id: `${id}-empty`, label: t('timing.zoneNoResults'), disabled: true }] : items}
|
||||
listClassName={css.zoneMenu} header={<input className={css.zoneSearch} type="search" value={query} data-menu-field=""
|
||||
placeholder={t('timing.zoneSearch')} aria-label={t('timing.zoneSearch')} onChange={(event) => { setQuery(event.target.value) }} />}
|
||||
onChange={(zone) => { setQuery(''); setRecent(recent => rememberTimeZone(recent, zone)); onChange(zone) }} />
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { ScheduleId } from '@deepseek-ai/dsh-schedule/client'
|
||||
import type { ScheduleId, ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule/client'
|
||||
import type { CatalogInjected } from './catalog-source.ts'
|
||||
import { NS } from './locales.ts'
|
||||
import { taskNextRunAt } from './task-record.ts'
|
||||
import { selectSessionScheduleFacts } from './session-schedule-state.ts'
|
||||
import {
|
||||
formatScheduleFrequency, nextRunParts, orderScheduleRecords, taskName,
|
||||
} from './schedule-format.ts'
|
||||
@@ -20,7 +22,7 @@ import css from './ScheduleCatalogAction.module.css'
|
||||
|
||||
/** Full props for the Session-header Schedule catalog action. */
|
||||
export type ScheduleCatalogActionProps =
|
||||
PropsRuntime<'conversation.session.header.utilities'> & PropsLocale<typeof NS> & InjectFace<CatalogInjected & {
|
||||
PropsRuntime<'conversation.session.header.utilities'> & PropsLocale<typeof NS> & InjectFace<CatalogInjected<ScheduleCatalogEntry> & {
|
||||
/**
|
||||
* Show one task's detail in the right Sidebar for this entry's Session.
|
||||
* @param id - Task chosen by the Session entry.
|
||||
@@ -42,11 +44,12 @@ export function ScheduleCatalogAction(props: ScheduleCatalogActionProps) {
|
||||
}
|
||||
|
||||
function SessionScheduleCatalog({
|
||||
useSession, useCatalog, onDelete, onRetry, openTaskDetail, t,
|
||||
sessionId, useSession, useCatalog, onDelete, onRetry, openTaskDetail, t,
|
||||
}: ScheduleCatalogActionProps) {
|
||||
const openState = useSession(snapshot => snapshot.openState)
|
||||
const catalogState = useCatalog(value => value)
|
||||
const { records, status, deleting } = catalogState
|
||||
const { status, deleting } = catalogState
|
||||
const { records } = useMemo(() => selectSessionScheduleFacts(catalogState, sessionId), [catalogState, sessionId])
|
||||
const visible = openState === 'open'
|
||||
const [open, setOpen] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
@@ -148,9 +151,10 @@ function SessionScheduleCatalog({
|
||||
<button type="button" onClick={() => { void onRetry() }}>{t('list.retry')}</button>
|
||||
</li>}
|
||||
{rows.map((record) => {
|
||||
const overdue = Date.parse(record.scheduledAt) <= now
|
||||
const nextRunAt = taskNextRunAt(record)
|
||||
const overdue = Date.parse(nextRunAt) <= now
|
||||
// One pair per row: both halves come from this one call.
|
||||
const nextRun = nextRunParts(record.scheduledAt, t('time.locale'), now, t)
|
||||
const nextRun = nextRunParts(nextRunAt, t('time.locale'), now, t)
|
||||
return (
|
||||
<li
|
||||
key={record.id}
|
||||
@@ -179,7 +183,7 @@ function SessionScheduleCatalog({
|
||||
first; the popover's clock only refreshes the parenthesized
|
||||
distance, which ticks while it is open instead of freezing
|
||||
at the moment the list opened. */}
|
||||
<time dateTime={record.scheduledAt}>{nextRun.absolute}</time>
|
||||
<time dateTime={nextRunAt}>{nextRun.absolute}</time>
|
||||
<span className={css.nextRunRelative}>{nextRun.relative}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -4,16 +4,15 @@
|
||||
* The tab's layout record stores nothing durable. It selects its task from the
|
||||
* shared Host catalog by the navigation parameters it was opened with and
|
||||
* renders the same `TaskDetail` component the Tasks page renders, so both views
|
||||
* offer identical fields, timing edits, saved deliveries, confirmed deletion,
|
||||
* and the original Session link. A record restored by a reload carries no
|
||||
* parameters, so the body falls back to the provider-owned binding that the
|
||||
* navigation wrote; a retained edit draft keeps the detail on screen after the
|
||||
* catalog row disappears, and a confirmed deletion closes this tab once the
|
||||
* refreshed catalog reports the row gone; without a draft, the body reports
|
||||
* that the task is gone.
|
||||
* offer identical fields, Host edits and runs, saved deliveries, confirmed
|
||||
* deletion, and the linked Session entry. A record restored by a reload carries
|
||||
* no parameters, so the body falls back to the provider-owned binding that the
|
||||
* navigation wrote; a task that an open edit or a confirmed deletion retained
|
||||
* keeps the detail on screen after the catalog row disappears, and an applied
|
||||
* deletion closes this tab once the catalog refresh after it settles; without a
|
||||
* retained task, the body reports that the task is gone.
|
||||
*
|
||||
* The parameters name the task's original Session, and every mutation carries
|
||||
* that binding, so reading or deleting from here never activates a Session.
|
||||
* The catalog read and deletion name only the task id, so neither activates a Session.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -131,7 +130,6 @@ export function ScheduleTaskTab(props: ScheduleTaskTabProps): ReactNode {
|
||||
task={task}
|
||||
authoritative={catalogRecord !== undefined}
|
||||
onDeleted={() => { tab.actions.close() }}
|
||||
withinSession={sessionId}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import { formatScheduleFrequency, nextRunParts, orderScheduleRecords, taskName } from './schedule-format.ts'
|
||||
import { taskNextRunAt } from './task-record.ts'
|
||||
import {
|
||||
useSessionScheduleFacts, type SessionScheduleCatalogObservable,
|
||||
} from './session-schedule-state.ts'
|
||||
@@ -53,7 +54,8 @@ export function SessionScheduleHover({
|
||||
return (
|
||||
<div className={css.tasks} data-session-schedule-tasks="">
|
||||
{shown.map((record) => {
|
||||
const nextRun = nextRunParts(record.scheduledAt, t('time.locale'), now, t)
|
||||
const nextRunAt = taskNextRunAt(record)
|
||||
const nextRun = nextRunParts(nextRunAt, t('time.locale'), now, t)
|
||||
return (
|
||||
<div className={css.task} key={record.id} data-session-schedule-task="">
|
||||
<span className={css.taskIcon} aria-hidden="true"><IconClockOutlineRegular size={12} /></span>
|
||||
@@ -63,7 +65,7 @@ export function SessionScheduleHover({
|
||||
card show: the device-zone stamp first, then the distance. */}
|
||||
<span className={css.taskTiming}>
|
||||
{`${formatScheduleFrequency(record, t)} · `}
|
||||
<time dateTime={record.scheduledAt}>{nextRun.absolute}</time>
|
||||
<time dateTime={nextRunAt}>{nextRun.absolute}</time>
|
||||
{' '}
|
||||
<span className={css.taskRelative}>{nextRun.relative}</span>
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/** Manual creation reuses the existing Modal and Rules editor controls. */
|
||||
import { useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ScheduleCatalogEntry, ScheduleCreateRequest } from '@deepseek-ai/dsh-schedule/client'
|
||||
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { TaskEditorFields, draftTarget, newTaskDraft, taskDraftValid } from './TaskEditorFields.tsx'
|
||||
import type { ProjectChoices } from './TaskEditorFields.tsx'
|
||||
import { timingFromDraft } from './editor-timing.ts'
|
||||
import type { HostTaskActions } from './TaskDetail.tsx'
|
||||
import { NewSessionLimit } from './NewSessionLimit.tsx'
|
||||
import css from './TaskManagerPage.module.css'
|
||||
|
||||
/** Creation outcome retains Host validation and never closes a failed draft. */
|
||||
export type TaskCreateOutcome = { readonly created: ScheduleCatalogEntry }
|
||||
| { readonly failed: 'host.createError' | 'host.notFuture' | 'host.invalidRule' }
|
||||
|
||||
/** Create one task without starting an Agent or sending its instruction. */
|
||||
export function TaskCreateDialog({ projects, onCreate, onClose, onCreated, loadNewSessionInterval, t }: PropsLocale<'schedule.manager'> & {
|
||||
readonly projects: ProjectChoices
|
||||
readonly loadNewSessionInterval: HostTaskActions['loadNewSessionInterval']
|
||||
readonly onCreate: (request: ScheduleCreateRequest) => Promise<TaskCreateOutcome>
|
||||
readonly onClose: () => void
|
||||
readonly onCreated: (task: ScheduleCatalogEntry) => void
|
||||
}): ReactNode {
|
||||
const [input, setDraft] = useState(newTaskDraft)
|
||||
// An unchosen directory follows the starting Workspace, which can arrive after the dialog opens.
|
||||
const draft = input.cwd === '' ? { ...input, cwd: projects.defaultCwd } : input
|
||||
const [pending, setPending] = useState(false)
|
||||
const [failure, setFailure] = useState<Extract<TaskCreateOutcome, { failed: string }>['failed'] | null>(null)
|
||||
const save = async (): Promise<void> => {
|
||||
setPending(true)
|
||||
setFailure(null)
|
||||
const outcome = await onCreate({
|
||||
title: draft.title, prompt: draft.prompt, timing: timingFromDraft(draft, true), target: draftTarget(draft),
|
||||
})
|
||||
setPending(false)
|
||||
if ('created' in outcome) onCreated(outcome.created)
|
||||
else setFailure(outcome.failed)
|
||||
}
|
||||
return <Modal open className={css.manualDialog as string} title={t('host.manualCreate')}
|
||||
closeLabel={t('host.closeCreate')} onClose={() => { if (!pending) onClose() }}
|
||||
footer={<div className={css.confirmActions}>
|
||||
<Button disabled={pending} onClick={onClose}>{t('delete.cancel')}</Button>
|
||||
<Button variant="primary" disabled={pending || !taskDraftValid(draft)} onClick={() => { void save() }}>
|
||||
{t(pending ? 'host.creating' : 'host.create')}
|
||||
</Button>
|
||||
</div>}>
|
||||
<TaskEditorFields draft={draft} onChange={setDraft} projects={projects} disabled={pending} t={t} />
|
||||
{draft.target === 'new' && <NewSessionLimit load={loadNewSessionInterval} t={t} />}
|
||||
{failure !== null && <p className={css.saveFailure} role="alert">{t(failure)}</p>}
|
||||
</Modal>
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,202 @@
|
||||
/** Fields shared by manual creation and editing inside the existing Rules view. */
|
||||
import type { ReactNode } from 'react'
|
||||
import { useId, useRef, useState } from 'react'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ScheduleCatalogEntry, ScheduleTargetRequest } from '@deepseek-ai/dsh-schedule/client'
|
||||
import clsx from 'clsx'
|
||||
import { DEFAULT_WORKSPACE_DIRECTORY, workspaceDisplayTitle } from '@deepseek-ai/dsh-api-workspace-controller/default-workspace'
|
||||
import { IconClockOutlineRegular, Input, PathLabel } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { ClockPicker } from './ClockPicker.tsx'
|
||||
import { DatePicker } from './DatePicker.tsx'
|
||||
import { defaultTiming, rruleFromDraft, timingFromRecord } from './editor-timing.ts'
|
||||
import type { FrequencyPreset, TimingDraft } from './editor-timing.ts'
|
||||
import { RuleSelectRow, RuleZoneRow } from './RuleSelectRow.tsx'
|
||||
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. */
|
||||
export interface TaskEditorDraft extends TimingDraft {
|
||||
readonly title: string
|
||||
readonly prompt: string
|
||||
readonly target: 'session' | 'new_once' | 'new'
|
||||
readonly sessionId: string
|
||||
readonly cwd: string
|
||||
}
|
||||
|
||||
/** A Session or project label supplied by the owning framework readers. */
|
||||
export interface EditorChoice { readonly value: string; readonly label: string }
|
||||
|
||||
/** Registered Workspaces offered as the directory of a created Session. */
|
||||
export interface ProjectChoices {
|
||||
readonly items: readonly EditorChoice[]
|
||||
/** Directory a new destination starts with: the default Workspace, else the first listed one, else empty. */
|
||||
readonly defaultCwd: string
|
||||
}
|
||||
|
||||
/** Label Workspaces as the Workspace list does and select the directory a new destination starts with. */
|
||||
export function projectChoices(workspaces: WorkspaceSnapshot['items'], defaultName: string): ProjectChoices {
|
||||
return {
|
||||
items: workspaces.map(workspace => ({ value: workspace.path, label: workspaceDisplayTitle(workspace.title, defaultName) })),
|
||||
defaultCwd: (workspaces.find(workspace => workspace.title === DEFAULT_WORKSPACE_DIRECTORY) ?? workspaces[0])?.path ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Name a destination strategy; a Session target is the conversation that the task already uses. */
|
||||
export function targetLabelKey(target: TaskEditorDraft['target']): TaskManagerKey {
|
||||
return target === 'session' ? 'host.currentConversation' : target === 'new_once' ? 'host.targetNewOnce' : 'host.targetNew'
|
||||
}
|
||||
|
||||
/** Initialize a new task without creating or activating a Session. */
|
||||
export function newTaskDraft(): TaskEditorDraft {
|
||||
return { ...defaultTiming(), title: '', prompt: '', target: 'new_once', sessionId: '', cwd: '' }
|
||||
}
|
||||
|
||||
/** Seed an edit without normalizing unsupported stored rules. */
|
||||
export function taskDraft(task: ScheduleCatalogEntry): TaskEditorDraft {
|
||||
return {
|
||||
...timingFromRecord(task), title: task.title, prompt: task.prompt,
|
||||
target: task.target.kind, sessionId: task.target.kind === 'session' ? task.target.sessionId : '',
|
||||
cwd: task.target.kind === 'session' ? '' : task.target.create.cwd ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** Required fields and guided selections that the form can validate locally. */
|
||||
export function taskDraftValid(draft: TaskEditorDraft): boolean {
|
||||
return 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))
|
||||
}
|
||||
|
||||
/** Resolve the explicit destination, preserving an existing preset when the project is unchanged. */
|
||||
export function draftTarget(draft: TaskEditorDraft, original?: ScheduleCatalogEntry): ScheduleTargetRequest {
|
||||
if (draft.target === 'session') return { kind: 'session', sessionId: draft.sessionId as SessionId }
|
||||
const previous = original?.target.kind === 'session' ? undefined : original?.target.create
|
||||
const create = {
|
||||
...(draft.cwd === '' ? {} : { cwd: draft.cwd }),
|
||||
...((previous?.cwd ?? '') === draft.cwd && previous?.agentPreset !== undefined ? { agentPreset: previous.agentPreset } : {}),
|
||||
}
|
||||
return { kind: draft.target, create }
|
||||
}
|
||||
|
||||
/** Render manual creation and inline edits with the existing task rule controls. */
|
||||
export function TaskEditorFields({ draft, onChange, projects, disabled, original, t }: {
|
||||
readonly draft: TaskEditorDraft
|
||||
readonly onChange: (draft: TaskEditorDraft) => void
|
||||
readonly projects: ProjectChoices
|
||||
readonly disabled: boolean
|
||||
readonly original?: ScheduleCatalogEntry
|
||||
readonly t: PropsLocale<'schedule.manager'>['t']
|
||||
}): ReactNode {
|
||||
const [dateOpen, setDateOpen] = useState(false)
|
||||
const [clockOpen, setClockOpen] = useState(false)
|
||||
const dateAnchor = useRef<HTMLButtonElement>(null)
|
||||
const clockAnchor = useRef<HTMLButtonElement>(null)
|
||||
const id = useId()
|
||||
const rowId = (name: string): string => `${id}-${name}`
|
||||
const change = (patch: Partial<TaskEditorDraft>): void => { onChange({ ...draft, ...patch }) }
|
||||
const clock = draft.frequency === 'daily' ? `${draft.dailyTime}:00` : draft.start.slice(11)
|
||||
const frequencyZone: FrequencyZone = { system: Intl.DateTimeFormat().resolvedOptions().timeZone, label: zone => zoneLabel(zone, t) }
|
||||
const targetLabel = (target: TaskEditorDraft['target']): string => t(targetLabelKey(target))
|
||||
const weekdays = draft.frequency === 'weekly' && draft.weekdays.length === 5 && [1, 2, 3, 4, 5].every(day => draft.weekdays.includes(day))
|
||||
const frequencyItems = [
|
||||
{ id: 'weekly', label: t('rule.weekly') }, { id: 'weekdays', label: t('rule.weekdays') },
|
||||
{ id: 'daily', label: t('rule.daily') }, { id: 'hourly', label: t('rule.everyHours') },
|
||||
{ id: 'minutely', label: t('rule.everyMinutes') }, { id: 'monthly', label: t('host.frequency.monthly') },
|
||||
{ id: 'once', label: t('rule.once') }, { id: 'advanced', label: t('host.frequency.advanced') },
|
||||
]
|
||||
const frequencyValue = weekdays ? 'weekdays' : draft.frequency
|
||||
// The directory line tells which folder a Workspace title such as Default workspace stands for.
|
||||
const workspaceItems = projects.items.map(choice => ({
|
||||
id: choice.value, label: <span className={css.workspaceMenuItem}><span>{choice.label}</span>
|
||||
<PathLabel path={choice.value} className={css.workspaceMenuPath} /></span>,
|
||||
}))
|
||||
return <fieldset disabled={disabled} className={css.taskEditor} onKeyDownCapture={(event) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || !dateOpen && !clockOpen) return
|
||||
event.preventDefault(); event.stopPropagation()
|
||||
if (dateOpen) { setDateOpen(false); dateAnchor.current?.focus() }
|
||||
else { setClockOpen(false); clockAnchor.current?.focus() }
|
||||
}}>
|
||||
<label className={css.editorField}>{t('detail.name')}<Input value={draft.title} maxLength={120}
|
||||
onChange={(event) => { change({ title: event.target.value }) }} /></label>
|
||||
<label className={css.editorField}>{t('detail.instruction')}<textarea className={css.instruction}
|
||||
value={draft.prompt} onChange={(event) => { change({ prompt: event.target.value }) }} /></label>
|
||||
<div className={css.ruleRows}>
|
||||
<RuleSelectRow label={t('host.target')} value={draft.target} current={targetLabel(draft.target)} disabled={disabled}
|
||||
listClassName={css.targetMenu} items={(['new_once', 'new'] as const).map(value => ({
|
||||
id: value, label: <span className={css.targetMenuItem}><span>{targetLabel(value)}</span>
|
||||
<span className={css.targetMenuDescription}>{t(value === 'new_once' ? 'host.targetNewOnceHelp' : 'host.targetNewHelp')}</span></span>,
|
||||
}))}
|
||||
onChange={(value) => { change({ target: value as TaskEditorDraft['target'],
|
||||
...(draft.target === 'session' ? { cwd: projects.defaultCwd } : {}) }) }} />
|
||||
{draft.target !== 'session' && <RuleSelectRow label={t('host.project')} value={draft.cwd} disabled={disabled}
|
||||
current={projects.items.find(choice => choice.value === draft.cwd)?.label ?? (draft.cwd || t('host.ungrouped'))}
|
||||
listClassName={css.workspaceMenu} items={workspaceItems.length === 0 ? [{ id: '', label: t('host.ungrouped'), disabled: true }]
|
||||
: workspaceItems} onChange={(cwd) => { change({ cwd }) }} />}
|
||||
</div>
|
||||
<section className={css.editorField} aria-label={t('rule.title')}><h3>{t('rule.title')}</h3><div className={css.ruleRows}>
|
||||
<RuleSelectRow label={t('rule.repeat')} value={frequencyValue} disabled={disabled} items={frequencyItems}
|
||||
current={(frequencyItems.find(item => item.id === frequencyValue) as { label: string }).label} onChange={(value) => {
|
||||
const frequency = value === 'weekdays' ? 'weekly' : value as FrequencyPreset
|
||||
change({ frequency, ...(value === 'weekdays' ? { weekdays: [1, 2, 3, 4, 5] } : {}),
|
||||
...(frequency === 'advanced' ? { rrule: rruleFromDraft(draft, true) } : {}) })
|
||||
}} />
|
||||
{draft.frequency !== 'once' && draft.frequency !== 'advanced' && <div className={css.ruleRow}>
|
||||
<label className={css.ruleLabel} htmlFor={rowId('interval')}>{t('host.interval')}</label>
|
||||
<RuleStepper id={rowId('interval')} min={1} max={Number.MAX_SAFE_INTEGER} value={Number(draft.interval)} disabled={disabled}
|
||||
increaseLabel={t('timing.intervalIncrease')} decreaseLabel={t('timing.intervalDecrease')}
|
||||
onChange={(value) => { change({ interval: String(value) }) }} />
|
||||
</div>}
|
||||
{draft.frequency === 'weekly' && <RuleWeekdayRow weekdays={draft.weekdays} disabled={disabled} t={t} rowId={rowId}
|
||||
onToggle={(day) => { change({ weekdays: draft.weekdays.includes(day)
|
||||
? draft.weekdays.filter(value => value !== day) : [...draft.weekdays, day].sort() }) }} />}
|
||||
{draft.frequency === 'monthly' && <div className={css.ruleRow}>
|
||||
<label className={css.ruleLabel} htmlFor={rowId('monthDay')}>{t('host.monthDay')}</label>
|
||||
<RuleStepper id={rowId('monthDay')} min={1} max={31} value={draft.monthDay} disabled={disabled}
|
||||
increaseLabel={t('timing.intervalIncrease')} decreaseLabel={t('timing.intervalDecrease')}
|
||||
onChange={(value) => { change({ monthDay: value }) }} />
|
||||
</div>}
|
||||
{draft.frequency === 'once' && <div className={css.ruleRow}>
|
||||
<label className={css.ruleLabel} htmlFor={rowId('date')}>{t('timing.date')}</label>
|
||||
<button ref={dateAnchor} id={rowId('date')} type="button" disabled={disabled}
|
||||
className={clsx(css.ruleInput, css.ruleControl, css.pickerTrigger)} aria-label={t('timing.date')}
|
||||
aria-haspopup="dialog" aria-expanded={dateOpen} onClick={() => { setDateOpen(true) }}>
|
||||
<span>{draft.start.slice(0, 10).replaceAll('-', '/')}</span><IconCalendarOutlineRegular className={css.pickerIcon} />
|
||||
</button>
|
||||
<DatePicker open={dateOpen && !disabled} anchorRef={dateAnchor} value={draft.start.slice(0, 10)}
|
||||
onPick={(date) => { change({ start: `${date}T${clock}` }); setDateOpen(false) }}
|
||||
onClose={() => { setDateOpen(false) }} t={t} />
|
||||
</div>}
|
||||
{draft.frequency !== 'advanced' && draft.frequency !== 'minutely' && draft.frequency !== 'hourly' && <div className={css.ruleRow}>
|
||||
<label className={css.ruleLabel} htmlFor={rowId('time')}>{t('timing.time')}</label>
|
||||
<button ref={clockAnchor} id={rowId('time')} type="button" disabled={disabled}
|
||||
className={clsx(css.ruleInput, css.ruleControl, css.pickerTrigger)} aria-label={t('timing.time')}
|
||||
aria-haspopup="dialog" aria-expanded={clockOpen} onClick={() => { setClockOpen(true) }}>
|
||||
<span>{clock}</span><IconClockOutlineRegular className={css.pickerIcon} />
|
||||
</button>
|
||||
<ClockPicker open={clockOpen && !disabled} anchorRef={clockAnchor} value={clock} seconds={draft.frequency !== 'daily'}
|
||||
onPick={(time) => { change(draft.frequency === 'daily' ? { dailyTime: time.slice(0, 5) }
|
||||
: { start: `${draft.start.slice(0, 10)}T${time}` }) }} onClose={() => { setClockOpen(false) }} t={t} />
|
||||
</div>}
|
||||
{(draft.frequency !== 'advanced' || draft.rrule.trim() !== '' && !draft.rrule.trimStart().startsWith('DTSTART')) && <RuleZoneRow value={draft.timeZone} disabled={disabled} t={t}
|
||||
onChange={(timeZone) => { change({ timeZone }) }} />}
|
||||
</div></section>
|
||||
{draft.frequency === 'advanced' && <div className={css.editorField}>
|
||||
{original !== undefined && original.kind !== 'rrule' && original.kind !== 'cron' && draft.rrule === ''
|
||||
&& <p>{formatScheduleFrequency(original, t, frequencyZone)}</p>}
|
||||
<label className={css.editorField}>{t('host.customRule')}<textarea className={css.instruction}
|
||||
value={draft.rrule} onChange={(event) => { change({ rrule: event.target.value }) }} /></label>
|
||||
</div>}
|
||||
|
||||
</fieldset>
|
||||
}
|
||||
@@ -101,6 +101,46 @@
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* The existing Plugins page's adjacent creation menu. */
|
||||
.createGroup { display: inline-flex; align-items: stretch; }
|
||||
.createPrimary { border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
||||
.createMore {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
border-radius: 0 var(--dsw-radius-lg) var(--dsw-radius-lg) 0;
|
||||
border-left: 0.5px solid color-mix(in srgb, var(--dsw-alias-label-primary-foreground) 30%, transparent);
|
||||
}
|
||||
.manualDialog { width: 520px; max-width: calc(100vw - 48px); }
|
||||
.createMenu [role='menuitem'] { gap: 10px; padding: 6px 12px; }
|
||||
.targetMenu { width: 320px; max-width: calc(100vw - 48px); }
|
||||
.targetMenu [role='menuitem'] { height: 72px; box-sizing: border-box; gap: 10px; padding: 6px 12px; }
|
||||
.targetMenuItem { display: flex; flex-direction: column; gap: 2px; white-space: normal; }
|
||||
.targetMenuDescription {
|
||||
display: -webkit-box;
|
||||
max-height: 36px;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
.workspaceMenu { width: 320px; max-width: calc(100vw - 48px); }
|
||||
.workspaceMenu [role='menuitem'] { gap: 10px; padding: 6px 12px; }
|
||||
.workspaceMenuItem { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.workspaceMenuItem > span:first-child { overflow: hidden; text-overflow: ellipsis; }
|
||||
.workspaceMenuPath { line-height: 18px; }
|
||||
.taskEditor { border: 0; margin: 0; padding: 0; min-width: 0; display: grid; gap: 20px; }
|
||||
.editorField { display: grid; gap: 8px; font-size: 14px; min-width: 0; }
|
||||
.editorField > h3 { margin: 0; font: inherit; }
|
||||
.hostRuleRow { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 12px 0; font-size: 13px; }
|
||||
.hostRuleRow > :last-child { text-align: right; overflow-wrap: anywhere; }
|
||||
.hostSessionFooter { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 16px 24px; font-size: 13px; }
|
||||
.hostSessionFooter button { min-width: 0; max-width: 75%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border: 0; }
|
||||
.hostSessionEmpty { color: var(--dsw-alias-label-tertiary); }
|
||||
|
||||
/* The mock's `.list-tools`: the one row of filter tabs, 8px between chips and
|
||||
20px between the groups, with the list 14px below. */
|
||||
.filters {
|
||||
@@ -161,10 +201,6 @@
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.catalogActions {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.catalogMore {
|
||||
width: var(--task-search-height);
|
||||
height: var(--task-search-height);
|
||||
@@ -511,7 +547,7 @@
|
||||
margin-right: calc(6px - var(--detail-gutter));
|
||||
}
|
||||
|
||||
/* The mock's `.detail-heading`: the name control is the first content of the
|
||||
/* The mock's `.detail-heading`: the task name is the first content of the
|
||||
scrolling rule view, directly above the next-run line and with no separator
|
||||
of its own between them. */
|
||||
.detailHeader {
|
||||
@@ -521,35 +557,8 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* The mock's `.edit-name`: the stored task name as one borderless line that
|
||||
underlines only on hover and focus. The detail pane's primary text at 500 weight. */
|
||||
.editName {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.editName:hover {
|
||||
box-shadow: 0 1px var(--dsw-alias-border-l3);
|
||||
}
|
||||
}
|
||||
|
||||
.editName:focus {
|
||||
box-shadow: 0 1px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
}
|
||||
|
||||
/* An ended task's stored name as plain text on the control's step, padded to
|
||||
the control's 32px height so the header keeps one figure. */
|
||||
/* The mock's `.edit-name` as plain text: the stored task name in the detail
|
||||
pane's primary text at 500 weight, padded to a 32px line. */
|
||||
.readonlyName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -710,7 +719,6 @@
|
||||
margin: 24px var(--detail-gutter) 0;
|
||||
}
|
||||
|
||||
.recordsPanel:not([hidden]),
|
||||
.deliveryHistory {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -774,23 +782,11 @@
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* The mock puts the border on the rows group, not on the card that carries the
|
||||
`运行时间` heading. */
|
||||
/* The read-only rule rows sit 28px below the task instruction. */
|
||||
.ruleCard {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
/* The mock's `.automation-detail .form-section h3`: the `运行时间` heading is
|
||||
13px tertiary ink, regular weight, and the mock's 20px leading. The 6px
|
||||
lead-in nudges it off the card's rounded edge, as the hint below. */
|
||||
.ruleCard h3 {
|
||||
margin: 0 0 8px 10px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.ruleRows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -799,12 +795,6 @@
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Escape-key guard around a dropdown: the wrapper takes no box of its own, so
|
||||
the menu keeps its flex position while the guard sees the portaled list. */
|
||||
.menuGuard {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* The searchable zone inventory is intentionally shorter than a general menu:
|
||||
recent choices stay visible without letting the global list dominate the detail. */
|
||||
.zoneMenu.zoneMenu {
|
||||
@@ -833,10 +823,7 @@
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
/* The Escape guard is `display: contents`, so a menu row is the last DOM child
|
||||
only through that wrapper; both selectors reach the rendered last row. */
|
||||
.ruleRows > :last-child,
|
||||
.ruleRows > :last-child > .ruleRowMenu {
|
||||
.ruleRows > :last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -916,7 +903,7 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* An ended task keeps its values readable: tertiary rather than dimmed ink. */
|
||||
/* A disabled value stays readable: tertiary rather than dimmed ink. */
|
||||
.ruleValue:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: default;
|
||||
@@ -957,23 +944,6 @@
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* The monthly date grid reads as a calendar month: seven fixed columns on the
|
||||
weekday pills' 4px gap, right-aligned under the row like the weekday pills. */
|
||||
.ruleMonthDays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(36px, max-content));
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* Stretched pills would keep their day number on the left edge, so each
|
||||
centers its number in its cell. */
|
||||
.ruleMonthDays > * {
|
||||
justify-self: stretch;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ruleInput {
|
||||
flex: 0 1 58%;
|
||||
min-width: 0;
|
||||
@@ -995,24 +965,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* An ended task keeps its values readable: tertiary rather than dimmed ink. */
|
||||
/* A disabled value stays readable: tertiary rather than dimmed ink. */
|
||||
.ruleInput:disabled {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The elapsed-interval row keeps the quantity and its unit on one right-aligned
|
||||
line, and the quantity sits in the settings font-size stepper pill
|
||||
(ui-theme FontSizeRow.module.css `.stepper`): a 36px 18px-radius module-fill
|
||||
pill whose right-edge arrow column shows on hover or focus, with the unit
|
||||
word outside the pill. */
|
||||
.ruleInterval {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The repeat interval and the month day sit in the settings font-size stepper
|
||||
pill (ui-theme FontSizeRow.module.css `.stepper`): a 36px 18px-radius
|
||||
module-fill pill whose right-edge arrow column shows on hover or focus. */
|
||||
.ruleIntervalStepper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -1107,12 +1067,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ruleIntervalUnit {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* The Date and Time rows open their picker from a read-only value button, which
|
||||
keeps the input's own hover fill, focus ring, and disabled tone. */
|
||||
.pickerTrigger {
|
||||
@@ -1133,20 +1087,6 @@
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
/* The 6px lead-in matches the card heading above, off the rounded edge. */
|
||||
.ruleHint {
|
||||
margin: 8px 0 0 10px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* The cron hint doubles as the row's rejection line, so its unreadable-expression
|
||||
state takes the save failure's error color in place of a second message. */
|
||||
.ruleHintError {
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
/* The mock's `.automation-detail .instruction`: the editable instruction box on
|
||||
the panel's 12px padding, 10px radius, and 24px line rhythm, with the neutral
|
||||
`--border` hairline that only the hover state brightens. The mock inherits the
|
||||
@@ -1192,8 +1132,8 @@
|
||||
border-color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* An ended task's stored instruction reads as plain text: no box and no edit
|
||||
affordance, on the field's own 14px/24px step. */
|
||||
/* The stored instruction reads as plain text: no box and no edit affordance,
|
||||
on the instruction field's own 14px/24px step. */
|
||||
.readonlyPrompt {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
@@ -1283,12 +1223,6 @@
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.confirmTitle {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Instruction text sits in the row's text column at the mock's 13px/22px and
|
||||
shows two lines until its toggle expands it. */
|
||||
.savedPrompt {
|
||||
@@ -1352,28 +1286,8 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The original-Session control is a seat inside the strip's action cluster, so
|
||||
it keeps the strip's own figure and never scrolls with the detail body. */
|
||||
.detailContext {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
/* A disabled entry passes the pointer to this wrapper, whose tooltip names the
|
||||
compact icon; the wrapper keeps the disabled cursor. */
|
||||
.detailContext:has(> button:disabled) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.detailContext > button:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The staged-edit bar: it appears under the detail tab strip only while the
|
||||
Run time draft differs from the stored task. */
|
||||
/* The edit bar closes the detail under its scrolling view while a task is in
|
||||
edit mode. */
|
||||
.saveFooter {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
@@ -1400,9 +1314,7 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* One staged save's localized failure: it can name the task name, the
|
||||
instruction, or a timing row, so it leads the bar that submits all three and
|
||||
keeps that bar's gutter. */
|
||||
/* The localized reason an edit, a run, or a manual create did not take effect. */
|
||||
.saveFailure {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
@@ -1413,28 +1325,7 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.linkedSession {
|
||||
display: flex;
|
||||
flex: 0 1 auto;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
/* The strip's icon buttons are 28px `sm` capsules (14px), so this pill keeps
|
||||
the same corner. */
|
||||
border-radius: 14px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.linkedSession:focus-visible {
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* The notice keeps its own line under the strip and above the staged save bar. */
|
||||
/* The linked-Session notice keeps its own line under the rule card. */
|
||||
.detailNotice {
|
||||
flex-shrink: 0;
|
||||
margin: 0;
|
||||
@@ -1445,34 +1336,6 @@
|
||||
background: var(--dsw-alias-bg-base);
|
||||
}
|
||||
|
||||
.linkedSessionLabel {
|
||||
flex-shrink: 0;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.linkedSessionTarget {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* The strip shows the control's label; the Session's own name stays in the DOM as
|
||||
the control's accessible description, while the visible button carries the
|
||||
hover title. */
|
||||
.linkedSessionName {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.confirmDialog {
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100dvh - 48px);
|
||||
@@ -1500,15 +1363,21 @@
|
||||
--dsh-button-outline-color: var(--dsw-alias-state-error-primary);
|
||||
}
|
||||
|
||||
.confirmPrompt {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.page :focus-visible {
|
||||
outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Text fields match :focus-visible on pointer focus too, so the page ring
|
||||
would flash on every click into them; their own underline and border cues
|
||||
already mark focus, so they opt out of the ring. */
|
||||
.page .editName:focus-visible,
|
||||
would flash on every click into them; their own border cues already mark
|
||||
focus, so they opt out of the ring. */
|
||||
.page .editorField input:focus-visible,
|
||||
.page .instruction:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
@@ -1563,6 +1432,7 @@
|
||||
as a page wider than the screen. A fine pointer keeps those steps. */
|
||||
@media (pointer: coarse) {
|
||||
.searchField input,
|
||||
.editorField input,
|
||||
.instruction,
|
||||
.ruleInput,
|
||||
.zoneSearch {
|
||||
|
||||
@@ -3,11 +3,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconEllipsisOutlineRegular, IconPlusOutlineRegular,
|
||||
IconSearchOutlineRegular, IconTrashOutlineRegular, Input, Menu, Modal, Tooltip,
|
||||
IconSearchOutlineRegular, IconTrashOutlineRegular, IconChevronDownOutlineRegular, IconEditOutlineRegular,
|
||||
Input, Menu, MenuItemButton, Modal, ReferenceIconRegular, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { ScheduleCatalogEntry, ScheduleId } from '@deepseek-ai/dsh-schedule/client'
|
||||
import type { ScheduleId, ScheduleCatalogEntry, ScheduleCreateRequest } from '@deepseek-ai/dsh-schedule/client'
|
||||
import { TaskCreateDialog, type TaskCreateOutcome } from './TaskCreateDialog.tsx'
|
||||
import { projectChoices } from './TaskEditorFields.tsx'
|
||||
import { taskNextRunAt, taskSessionId } from './task-record.ts'
|
||||
import type { CatalogSnapshot } from './catalog-source.ts'
|
||||
import { CatalogFeedback } from './CatalogFeedback.tsx'
|
||||
import { TaskDetail, useTaskDetail, type TaskDetailInjected } from './TaskDetail.tsx'
|
||||
@@ -23,7 +27,7 @@ export interface TaskManagerInjected extends TaskDetailInjected {
|
||||
readonly hooks: { readonly catalog: HostObservable<CatalogSnapshot<ScheduleCatalogEntry>> }
|
||||
/**
|
||||
* Start a new Session, where a reminder is created by asking the model to
|
||||
* schedule it. The page deliberately has no creation form of its own.
|
||||
* schedule it, using the existing new-conversation action.
|
||||
*/
|
||||
readonly onNewTask: () => void
|
||||
/**
|
||||
@@ -36,6 +40,8 @@ export interface TaskManagerInjected extends TaskDetailInjected {
|
||||
* @returns resolution after the outcome is reported.
|
||||
*/
|
||||
readonly onClearEnded: () => Promise<void>
|
||||
/** Create a task directly through the Host, without a model request. */
|
||||
readonly onCreateTask: (request: ScheduleCreateRequest) => Promise<TaskCreateOutcome>
|
||||
}
|
||||
|
||||
/** Root-scoped task catalog props derived from the framework and injected actions. */
|
||||
@@ -46,12 +52,15 @@ export type TaskManagerPageProps = PropsRuntime<'main'>
|
||||
type StatusFilter = 'all' | ScheduleCatalogEntry['status']
|
||||
|
||||
/**
|
||||
* Render retained tasks with authoritative deletion and timing-only edits.
|
||||
* Render retained tasks with manual creation, inline editing, and authoritative deletion.
|
||||
* @param props - framework catalog snapshot, localized copy, and action callbacks.
|
||||
* @returns the searchable task list beside the selected task's detail.
|
||||
*/
|
||||
export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
const { useCatalog, onNewTask, onClearEnded, onRetry, t } = props
|
||||
const { useCatalog, onNewTask, onClearEnded, onRetry, onCreateTask, useWorkspaces, t } = props
|
||||
const workspaces = useWorkspaces(snapshot => snapshot)
|
||||
const [createMenuOpen, setCreateMenuOpen] = useState(false)
|
||||
const [manualOpen, setManualOpen] = useState(false)
|
||||
const catalog = useCatalog(snapshot => snapshot)
|
||||
const { records, status } = catalog
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -65,7 +74,8 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
// this text describes.
|
||||
const now = useRelativeClock()
|
||||
const detail = useTaskDetail(props, catalog, selectedId ?? undefined)
|
||||
const { record: catalogRecord, task: selected, id: detailId, confirmId, setConfirmId, setTab } = detail
|
||||
const { record: catalogRecord, task: selected,
|
||||
props: { id: detailId, confirmId, onConfirm: setConfirmId, onTabChange: setTab } } = detail
|
||||
const rowRef = useRef<HTMLButtonElement | null>(null)
|
||||
const headingRef = useRef<HTMLHeadingElement>(null)
|
||||
const clearTriggerRef = useRef<HTMLButtonElement | null>(null)
|
||||
@@ -79,7 +89,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
// The bulk action's target and count come from the complete catalog, not from
|
||||
// the filtered rows: it removes every ended task, whatever the view shows.
|
||||
const endedCount = useMemo(
|
||||
() => records.filter(record => record.status === 'inactive').length,
|
||||
() => records.filter(record => record.status === 'ended').length,
|
||||
[records],
|
||||
)
|
||||
const rows = useMemo(() => {
|
||||
@@ -87,21 +97,20 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
return records.filter(record => (
|
||||
(statusFilter === 'all' || record.status === statusFilter)
|
||||
&& (record.prompt.toLowerCase().includes(query)
|
||||
|| record.sessionId.toLowerCase().includes(query)
|
||||
|| (taskSessionId(record) ?? '').toLowerCase().includes(query)
|
||||
|| taskName(record).toLowerCase().includes(query))
|
||||
)).toSorted((left, right) =>
|
||||
Date.parse(left.scheduledAt) - Date.parse(right.scheduledAt))
|
||||
Date.parse(taskNextRunAt(left)) - Date.parse(taskNextRunAt(right)))
|
||||
}, [records, search, statusFilter])
|
||||
|
||||
// An inactive-only filter is its own empty state: no task is inactive, rather than no task matched.
|
||||
const emptyTitle = statusFilter === 'inactive' && search.trim() === ''
|
||||
? 'list.emptyInactive'
|
||||
: records.length === 0 ? 'list.empty' : 'list.noMatches'
|
||||
const emptyTitle = search.trim() !== '' ? 'list.noMatches'
|
||||
: statusFilter === 'paused' ? 'list.emptyPaused'
|
||||
: statusFilter === 'ended' ? 'list.emptyInactive'
|
||||
: statusFilter === 'active' ? 'list.emptyActive' : 'list.empty'
|
||||
// The list's frequency line omits a rule zone equal to the host's and otherwise
|
||||
// names it, the same way the detail's Repeat row does.
|
||||
const systemZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const frequencyZone: FrequencyZone = { system: systemZone, label: zone => zoneLabel(zone, t) }
|
||||
const frequency = (record: ScheduleCatalogEntry): string => formatScheduleFrequency(record, t, frequencyZone)
|
||||
|
||||
useEffect(() => {
|
||||
// The detail focuses its own panel; with nothing selected, focus returns
|
||||
@@ -173,25 +182,48 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
// Modal's document bubble listener, which runs after this React handler,
|
||||
// so the `clearConfirm` guard is what keeps this page from also closing
|
||||
// the detail while the dialog closes.
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || clearConfirm
|
||||
if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || clearConfirm || manualOpen
|
||||
|| selectedId === null) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeDetails()
|
||||
}}
|
||||
>
|
||||
{manualOpen && <TaskCreateDialog
|
||||
loadNewSessionInterval={props.hostActions.loadNewSessionInterval}
|
||||
projects={projectChoices(workspaces.items, t('host.defaultProject'))}
|
||||
onCreate={onCreateTask} onClose={() => { setManualOpen(false) }}
|
||||
onCreated={(task) => {
|
||||
setManualOpen(false); setSearch(''); setStatusFilter('all'); setSelectedId(task.id); setTab('rule')
|
||||
}} t={t} />}
|
||||
<div className={css.listPane}>
|
||||
<div className={css.pageScroll}>
|
||||
<div className={css.pageContent}>
|
||||
<div className={css.pageHeading}>
|
||||
<h1 ref={headingRef} tabIndex={-1}>{t('title')}</h1>
|
||||
<div className={css.creationActions}>
|
||||
<Button variant="primary" size="sm" className={css.headerAction} icon={<IconPlusOutlineRegular size={13} />} onClick={onNewTask}>{t('new.action')}</Button>
|
||||
<span className={css.createGroup} role="group" aria-label={t('host.create')}>
|
||||
<Button variant="primary" size="sm" className={clsx(css.headerAction, css.createPrimary)} icon={<IconPlusOutlineRegular size={13} />}
|
||||
onClick={onNewTask}>{t('new.action')}</Button>
|
||||
<Menu open={createMenuOpen} onClose={() => { setCreateMenuOpen(false) }} align="end" portal autoFocus
|
||||
listClassName={css.createMenu}
|
||||
anchor={<Button variant="primary" size="sm" className={css.createMore} aria-label={t('host.createMethod')}
|
||||
aria-haspopup="menu" aria-expanded={createMenuOpen} onClick={() => { setCreateMenuOpen(value => !value) }}>
|
||||
<IconChevronDownOutlineRegular size={12} />
|
||||
</Button>}>
|
||||
<MenuItemButton icon={<ReferenceIconRegular kind="session" size={14} />}
|
||||
onSelect={() => { setCreateMenuOpen(false); onNewTask() }}>{t('host.chatCreate')}</MenuItemButton>
|
||||
<MenuItemButton icon={<IconEditOutlineRegular size={14} />}
|
||||
onSelect={() => { setCreateMenuOpen(false); setManualOpen(true) }}>
|
||||
{t('host.manualCreate')}
|
||||
</MenuItemButton>
|
||||
</Menu>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.filters}>
|
||||
<div className={css.filterTabs} role="group" aria-label={t('statusFilter.label')}>
|
||||
{(['active', 'inactive', 'all'] as const).map(value => (
|
||||
{(['active', 'paused', 'ended', 'all'] as const).map(value => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
@@ -254,7 +286,7 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
{status === 'ready' && rows.length === 0 && <div className={css.empty} role="status">
|
||||
<ScheduleEmptyIllustration className={css.emptyIllustration} />
|
||||
<h2>{t(emptyTitle)}</h2>
|
||||
<Button variant="outline" className={css.emptyAction} onClick={onNewTask}>
|
||||
<Button variant="outline" className={css.emptyAction} onClick={() => { setManualOpen(true) }}>
|
||||
{t('empty.action')}
|
||||
</Button>
|
||||
</div>}
|
||||
@@ -262,12 +294,13 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
{rows.map((record) => {
|
||||
// One pair per row: the same absolute stamp and distance reach
|
||||
// both halves, from one formatting call.
|
||||
const nextRun = nextRunParts(record.scheduledAt, t('time.locale'), now, t)
|
||||
const nextRunAt = taskNextRunAt(record)
|
||||
const nextRun = nextRunParts(nextRunAt, t('time.locale'), now, t)
|
||||
return (
|
||||
<li key={record.id}>
|
||||
<Button
|
||||
className={clsx(css.row, selectedId === record.id && css.selectedRow,
|
||||
record.status === 'inactive' && css.endedRow)}
|
||||
record.status !== 'active' && css.endedRow)}
|
||||
aria-label={taskName(record)}
|
||||
aria-describedby={`${detailId}-metadata-${record.id}`}
|
||||
aria-expanded={selectedId === record.id}
|
||||
@@ -282,11 +315,11 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
|
||||
<span className={css.rowContent}>
|
||||
<span className={css.rowTitle}>{taskName(record)}</span>
|
||||
<span className={css.rowSummary} id={`${detailId}-metadata-${record.id}`}>
|
||||
{record.status === 'inactive'
|
||||
&& <span className={css.metadata}>{t('status.inactive')}</span>}
|
||||
<span className={css.metadata}>{frequency(record)}</span>
|
||||
{record.status !== 'active'
|
||||
&& <span className={css.metadata}>{t(`status.${record.status}`)}</span>}
|
||||
<span className={css.metadata}>{formatScheduleFrequency(record, t, frequencyZone)}</span>
|
||||
{record.status === 'active' && <span className={css.metadata}>
|
||||
{t('list.nextPrefix')}<time dateTime={record.scheduledAt}>
|
||||
{t('list.nextPrefix')}<time dateTime={nextRunAt}>
|
||||
{nextRun.absolute}
|
||||
</time>
|
||||
{' '}<span className={css.nextRunRelative}>{nextRun.relative}</span>
|
||||
|
||||
@@ -196,12 +196,15 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo
|
||||
}
|
||||
/* jscpd:ignore-start -- schedule-local copy of the shared ui-primitives Menu; the two owners change independently. */
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (e.defaultPrevented) return
|
||||
// Where the keyboard is, computed once: the menu owns it when it holds a
|
||||
// row or sits on its anchor region.
|
||||
const focused = document.activeElement
|
||||
const insideList = listRef.current?.contains(focused) === true
|
||||
const anchored = rootRef.current?.contains(focused) === true || insideList
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// Closing hands the keyboard back when the menu had it.
|
||||
dismiss()
|
||||
if (anchored) refocusAnchor()
|
||||
@@ -272,10 +275,10 @@ export function TaskMenu({ open, anchor, items = [], selectedId, onSelect, onClo
|
||||
const onWindowBlur = (): void => {
|
||||
if (document.activeElement instanceof HTMLIFrameElement) dismiss()
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('keydown', onKeyDown, true)
|
||||
window.addEventListener('blur', onWindowBlur)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('keydown', onKeyDown, true)
|
||||
window.removeEventListener('blur', onWindowBlur)
|
||||
}
|
||||
}, [open, dismiss])
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/** Shared rule controls taken from the existing task detail. */
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import css from './TaskManagerPage.module.css'
|
||||
|
||||
type Weekday = 1 | 2 | 3 | 4 | 5 | 6 | 7
|
||||
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7] as const
|
||||
const WEEKDAY_LABELS = {
|
||||
1: 'frequency.weekday.1', 2: 'frequency.weekday.2', 3: 'frequency.weekday.3', 4: 'frequency.weekday.4',
|
||||
5: 'frequency.weekday.5', 6: 'frequency.weekday.6', 7: 'frequency.weekday.7',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Render the weekly rule's Weekday row: one pill per weekday, named by the row's
|
||||
* label and toggled in the rule the caller is editing.
|
||||
* @param props.weekdays - weekdays the rule currently selects, as ISO weekday numbers (Monday is 1).
|
||||
* @param props.disabled - whether the rule's controls are read-only.
|
||||
* @param props.t - frequency translator owning the weekday labels.
|
||||
* @param props.rowId - id builder for the row's label, which names the pill group.
|
||||
* @param props.onToggle - toggle one weekday in the rule being edited.
|
||||
* @returns the labelled Weekday row.
|
||||
*/
|
||||
export function RuleWeekdayRow({ weekdays, disabled, t, rowId, onToggle }: {
|
||||
weekdays: readonly number[]
|
||||
disabled: boolean
|
||||
t: PropsLocale<'schedule.manager'>['t']
|
||||
rowId: (name: string) => string
|
||||
onToggle: (weekday: Weekday) => void
|
||||
}): ReactNode {
|
||||
return <div className={css.ruleRow}>
|
||||
<span className={css.ruleLabel} id={rowId('weekday')}>{t('rule.weekday')}</span>
|
||||
<div className={clsx(css.ruleWeekdays, css.ruleControl)} role="group" aria-labelledby={rowId('weekday')}>
|
||||
{WEEKDAYS.map((weekday) => {
|
||||
const selected = weekdays.includes(weekday)
|
||||
return <Pill
|
||||
key={weekday}
|
||||
className={css.ruleControl}
|
||||
active={selected}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
aria-label={t('rule.weekdayOption', { weekday: t(WEEKDAY_LABELS[weekday]) })}
|
||||
onClick={() => { onToggle(weekday) }}
|
||||
>{t(WEEKDAY_LABELS[weekday])}</Pill>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
/** Inputs of one whole-number stepper of the task rule form. */
|
||||
interface RuleStepperProps {
|
||||
/** Id the row's label points at. */
|
||||
readonly id: string
|
||||
/** Smallest value the field accepts. */
|
||||
readonly min: number
|
||||
/** Largest value the field accepts. */
|
||||
readonly max: number
|
||||
/** Staged value. */
|
||||
readonly value: number
|
||||
/** Whether every edit is blocked. */
|
||||
readonly disabled: boolean
|
||||
/** Accessible name of the up arrow. */
|
||||
readonly increaseLabel: string
|
||||
/** Accessible name of the down arrow. */
|
||||
readonly decreaseLabel: string
|
||||
/** Stage one clamped whole value. */
|
||||
readonly onChange: (value: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* One whole-number stepper of the task rule form, such as the repeat interval or
|
||||
* the day of the month. Values clamp to the field's bounds, and an emptied or
|
||||
* fractional input stages nothing, so the staged rule stays valid on every edit.
|
||||
* @param props - bounds, staged value, blocking state, arrow labels, and callback.
|
||||
* @returns the stepper pill.
|
||||
*/
|
||||
export function RuleStepper({ id, min, max, value, disabled, increaseLabel, decreaseLabel, onChange }: RuleStepperProps): ReactNode {
|
||||
const clamp = (next: number): number => Math.min(max, Math.max(min, next))
|
||||
return <span
|
||||
className={css.ruleIntervalStepper}
|
||||
style={{ '--interval-digits': String(value).length } as CSSProperties}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
className={clsx(css.ruleInput, css.ruleControl, css.ruleIntervalInput)}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={1}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value)
|
||||
if (event.target.value === '' || !Number.isSafeInteger(next)) return
|
||||
onChange(clamp(next))
|
||||
}}
|
||||
/>
|
||||
<span className={css.ruleIntervalArrows}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ruleIntervalArrow}
|
||||
aria-label={increaseLabel}
|
||||
disabled={disabled || value >= max}
|
||||
onClick={() => { onChange(clamp(value + 1)) }}
|
||||
>
|
||||
<IconChevronUpOutlineRegular size={9} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.ruleIntervalArrow}
|
||||
aria-label={decreaseLabel}
|
||||
disabled={disabled || value <= min}
|
||||
onClick={() => { onChange(clamp(value - 1)) }}
|
||||
>
|
||||
<IconChevronDownOutlineRegular size={9} />
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
@@ -81,11 +81,14 @@ export interface CatalogInjected<Item extends { readonly id: ScheduleId } = Sche
|
||||
* Mutations retain visible rows until an authoritative list read succeeds, and
|
||||
* each deletion resolves with its own outcome for the caller to report.
|
||||
* @param deps - Remote calls and invalidation subscriptions.
|
||||
* @returns observable catalog and component callbacks.
|
||||
* @returns observable catalog, component callbacks, and acknowledged-creation retention.
|
||||
*/
|
||||
export function createCatalogSource<Item extends { readonly id: ScheduleId } = ScheduleRecord>(
|
||||
deps: CatalogDependencies<Item>,
|
||||
): CatalogInjected<Item> {
|
||||
): CatalogInjected<Item> & {
|
||||
/** Retain a Host-confirmed creation until the next successful catalog read. */
|
||||
readonly retainCreated: (record: Item) => void
|
||||
} {
|
||||
let snapshot: CatalogSnapshot<Item> = { records: [], status: 'loading', deleting: [], settled: false, readRequest: 0, readSettled: 0 }
|
||||
const listeners = new Set<() => void>()
|
||||
let disposers: readonly (() => void)[] = []
|
||||
@@ -187,5 +190,10 @@ export function createCatalogSource<Item extends { readonly id: ScheduleId } = S
|
||||
},
|
||||
onDelete: remove,
|
||||
onRetry: (since = 0) => refresh(false, since),
|
||||
retainCreated: (record) => {
|
||||
// A read started before creation cannot replace its acknowledged result.
|
||||
epoch++
|
||||
if (!snapshot.records.some(item => item.id === record.id)) publish({ ...snapshot, records: [...snapshot.records, record] })
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/** Lossless guided and Custom editor projections for typed Schedule timing. */
|
||||
import type { ScheduleCatalogEntry, ScheduleTiming } from '@deepseek-ai/dsh-schedule/client'
|
||||
import { hostFrequencyKind, ruleFields } from './task-record.ts'
|
||||
|
||||
/** Periods represented by guided controls, plus a lossless advanced editor. */
|
||||
export type FrequencyPreset = 'once' | 'minutely' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'advanced'
|
||||
|
||||
/** Editable values that determine one typed timing selector. */
|
||||
export interface TimingDraft {
|
||||
readonly frequency: FrequencyPreset
|
||||
readonly interval: string
|
||||
readonly start: string
|
||||
readonly timeZone: string
|
||||
readonly rrule: string
|
||||
/** Existing timing without editable text, retained until a replacement is supplied. */
|
||||
readonly retainedTiming?: ScheduleTiming
|
||||
readonly weekdays: readonly number[]
|
||||
/** Day of the month; a month without that day runs on its last day. */
|
||||
readonly monthDay: number
|
||||
/** Daily `HH:MM` clock time. */
|
||||
readonly dailyTime: string
|
||||
}
|
||||
|
||||
const WEEKDAY = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] as const
|
||||
const FREQUENCY = {
|
||||
minutely: 'MINUTELY', hourly: 'HOURLY', daily: 'DAILY', weekly: 'WEEKLY', monthly: 'MONTHLY',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The current minute in the selected zone, as the anchor of a newly saved minute or hour interval.
|
||||
* @param timeZone - Zone used by the stored DTSTART.
|
||||
* @returns Zone-local date and whole-minute time.
|
||||
*/
|
||||
function startInZone(timeZone: string): 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())
|
||||
/* 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`
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial form values for a new schedule.
|
||||
* @returns Guided timing with a one-hour-ahead one-shot default.
|
||||
*/
|
||||
export function defaultTiming(): TimingDraft {
|
||||
const date = new Date(Date.now() + 60 * 60_000)
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60_000)
|
||||
const start = `${local.toISOString().slice(0, 16)}:00`
|
||||
const day = Number(start.slice(8, 10))
|
||||
const weekday = (new Date(`${start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7 + 1
|
||||
return {
|
||||
frequency: 'once', interval: '1', start,
|
||||
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, rrule: '',
|
||||
weekdays: [weekday], monthDay: day, dailyTime: start.slice(11, 16),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build exactly one DTSTART and one RRULE for the supported form choices.
|
||||
* @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.
|
||||
* @returns RFC 5545 content lines.
|
||||
*/
|
||||
export function rruleFromDraft(draft: TimingDraft, intervalFromNow = false): string {
|
||||
if (draft.frequency === 'advanced') return draft.rrule
|
||||
const anchor = intervalFromNow && (draft.frequency === 'minutely' || draft.frequency === 'hourly')
|
||||
? startInZone(draft.timeZone) : draft.start
|
||||
const time = draft.frequency === 'daily' ? `${draft.dailyTime}:00` : anchor.slice(11)
|
||||
const start = `${anchor.slice(0, 10)}T${time}`.replaceAll(/[-:]/g, '')
|
||||
let rule: string
|
||||
switch (draft.frequency) {
|
||||
case 'once': rule = 'FREQ=DAILY;COUNT=1'; break
|
||||
case 'minutely':
|
||||
case 'hourly': rule = `FREQ=${FREQUENCY[draft.frequency]};INTERVAL=${draft.interval}`; break
|
||||
case 'daily':
|
||||
rule = `FREQ=DAILY;INTERVAL=${draft.interval};BYHOUR=${Number(draft.dailyTime.slice(0, 2))}`
|
||||
+ `;BYMINUTE=${Number(draft.dailyTime.slice(3, 5))}`
|
||||
break
|
||||
case 'weekly': rule = `FREQ=WEEKLY;INTERVAL=${draft.interval};BYDAY=${draft.weekdays.map(day => WEEKDAY[day - 1]).join(',')}`; break
|
||||
case 'monthly': rule = `FREQ=MONTHLY;INTERVAL=${draft.interval};BYMONTHDAY=${draft.monthDay},-1;BYSETPOS=1`; break
|
||||
/* 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)}`)
|
||||
}
|
||||
return `DTSTART;TZID=${draft.timeZone}:${start}\nRRULE:${rule}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover only supported form rules; unknown rules remain verbatim in Advanced mode.
|
||||
* @param record - Stored schedule to edit.
|
||||
* @returns Guided values or the original advanced text.
|
||||
*/
|
||||
export function timingFromRecord(record: ScheduleCatalogEntry): TimingDraft {
|
||||
const initial = defaultTiming()
|
||||
if (record.kind === 'cron') {
|
||||
return { ...initial, frequency: 'advanced', rrule: record.expression, timeZone: record.timeZone }
|
||||
}
|
||||
if (record.kind !== 'rrule') {
|
||||
let retainedTiming: ScheduleTiming
|
||||
switch (record.kind) {
|
||||
case 'after':
|
||||
case 'at': retainedTiming = { kind: 'at', at: record.scheduledAt }; break
|
||||
case 'every': retainedTiming = { kind: 'every', every_seconds: record.everySeconds }; break
|
||||
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
|
||||
}
|
||||
return { ...initial, frequency: 'advanced', rrule: '',
|
||||
timeZone: 'timeZone' in record ? record.timeZone : initial.timeZone,
|
||||
retainedTiming,
|
||||
}
|
||||
}
|
||||
const startMatch = /^DTSTART(?:;TZID=([^:]+))?:(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?/m.exec(record.rrule)
|
||||
const fields = ruleFields(record.rrule)
|
||||
const kind = hostFrequencyKind(record)
|
||||
const start = startMatch === null ? initial.start
|
||||
: `${startMatch[2]}-${startMatch[3]}-${startMatch[4]}T${startMatch[5]}:${startMatch[6]}:${startMatch[7]}`
|
||||
const byDay = fields.get('BYDAY')?.split(',') ?? []
|
||||
// A guided daily rule names one hour and one minute, or neither and keeps the DTSTART clock.
|
||||
const hour = fields.get('BYHOUR') ?? start.slice(11, 13)
|
||||
const minute = fields.get('BYMINUTE') ?? start.slice(14, 16)
|
||||
// A guided monthly rule lists its day before -1, the fallback for a shorter month.
|
||||
const monthDay = kind === 'monthly' ? fields.get('BYMONTHDAY') : undefined
|
||||
const startWeekday = (new Date(`${start.slice(0, 10)}T00:00:00Z`).getUTCDay() + 6) % 7 + 1
|
||||
return {
|
||||
frequency: kind === 'custom' ? 'advanced' : kind,
|
||||
interval: fields.get('INTERVAL') ?? '1', start,
|
||||
timeZone: startMatch?.[1] ?? 'UTC',
|
||||
rrule: record.rrule,
|
||||
weekdays: byDay.length === 0 ? [startWeekday] : byDay.map(day => WEEKDAY.indexOf(day as typeof WEEKDAY[number]) + 1),
|
||||
monthDay: Number.parseInt(monthDay ?? start.slice(8, 10), 10),
|
||||
dailyTime: kind === 'daily' ? `${hour.padStart(2, '0')}:${minute.padStart(2, '0')}` : start.slice(11, 16),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @returns Typed timing for the shared Schedule management API.
|
||||
*/
|
||||
export function timingFromDraft(draft: TimingDraft, intervalFromNow = false): ScheduleTiming {
|
||||
if (draft.frequency === 'advanced') {
|
||||
if (draft.rrule.trim() === '' && draft.retainedTiming !== undefined) return draft.retainedTiming
|
||||
if (!draft.rrule.trimStart().startsWith('DTSTART')) {
|
||||
return { kind: 'cron', cron: { expression: draft.rrule.trim(), time_zone: draft.timeZone } }
|
||||
}
|
||||
}
|
||||
return { kind: 'rrule', rrule: rruleFromDraft(draft, intervalFromNow) }
|
||||
}
|
||||
@@ -12,14 +12,20 @@
|
||||
|
||||
/** Simplified Chinese rule and duration copy, key-set source of truth. */
|
||||
export const frequencyZh = {
|
||||
'frequency.hostMonthly': '每月 {days} 日 {time},不足该日则月末',
|
||||
'frequency.hostDailyInterval': '每 {interval} 天 {time}',
|
||||
'frequency.hostWeeklyInterval': '每 {interval} 周的周{weekdays} {time}',
|
||||
'frequency.hostMonthlyInterval': '每 {interval} 个月的 {days} 日 {time},不足该日则月末',
|
||||
'frequency.hostZone': '{rule}({timeZone})',
|
||||
'frequency.hostCustom': '自定义',
|
||||
'time.locale': 'zh-CN',
|
||||
'time.utcPrefix': 'UTC',
|
||||
'frequency.daily': '每天 {time}({timeZone})',
|
||||
'frequency.dailyLocal': '每天 {time}',
|
||||
'frequency.weekly': '每周{weekdays} {time}({timeZone})',
|
||||
'frequency.weeklyLocal': '每周{weekdays} {time}',
|
||||
'frequency.cron': 'Cron {expression}({timeZone})',
|
||||
'frequency.cronLocal': 'Cron {expression}',
|
||||
'frequency.cron': '自定义({timeZone})',
|
||||
'frequency.cronLocal': '自定义',
|
||||
'frequency.cronRule': '{rule}({timeZone})',
|
||||
'cron.list.join': '、',
|
||||
'cron.part.join': ' ',
|
||||
@@ -70,14 +76,20 @@ export const frequencyZh = {
|
||||
|
||||
/** English rule and duration copy, key-identical to the Chinese source. */
|
||||
export const frequencyEn: Record<keyof typeof frequencyZh, string> = {
|
||||
'frequency.hostMonthly': 'Monthly on day {days} at {time}, or month end',
|
||||
'frequency.hostDailyInterval': 'Every {interval} days at {time}',
|
||||
'frequency.hostWeeklyInterval': 'Every {interval} weeks on {weekdays} at {time}',
|
||||
'frequency.hostMonthlyInterval': 'Every {interval} months on day {days} at {time}, or month end',
|
||||
'frequency.hostZone': '{rule} ({timeZone})',
|
||||
'frequency.hostCustom': 'Custom',
|
||||
'time.locale': 'en',
|
||||
'time.utcPrefix': 'UTC',
|
||||
'frequency.daily': 'Daily at {time} ({timeZone})',
|
||||
'frequency.dailyLocal': 'Daily at {time}',
|
||||
'frequency.weekly': 'Weekly on {weekdays} at {time} ({timeZone})',
|
||||
'frequency.weeklyLocal': 'Weekly on {weekdays} at {time}',
|
||||
'frequency.cron': 'Cron {expression} ({timeZone})',
|
||||
'frequency.cronLocal': 'Cron {expression}',
|
||||
'frequency.cron': 'Custom ({timeZone})',
|
||||
'frequency.cronLocal': 'Custom',
|
||||
'frequency.cronRule': '{rule} ({timeZone})',
|
||||
'cron.list.join': ', ',
|
||||
'cron.part.join': ' ',
|
||||
@@ -128,14 +140,20 @@ export const frequencyEn: Record<keyof typeof frequencyZh, string> = {
|
||||
|
||||
/** Japanese copy, checked against the English keys. */
|
||||
export const frequencyJa = {
|
||||
'frequency.hostMonthly': '毎月{days}日の{time}(その日がない月は月末)',
|
||||
'frequency.hostDailyInterval': '{interval}日ごとの{time}',
|
||||
'frequency.hostWeeklyInterval': '{interval}週ごとの{weekdays}の{time}',
|
||||
'frequency.hostMonthlyInterval': '{interval}か月ごとの{days}日の{time}(その日がない月は月末)',
|
||||
'frequency.hostZone': '{rule} ({timeZone})',
|
||||
'frequency.hostCustom': 'カスタム',
|
||||
'time.locale': 'ja-JP',
|
||||
'time.utcPrefix': 'UTC',
|
||||
'frequency.daily': '毎日 {time} ({timeZone})',
|
||||
'frequency.dailyLocal': '毎日 {time}',
|
||||
'frequency.weekly': '毎週 {weekdays} の {time} ({timeZone})',
|
||||
'frequency.weeklyLocal': '毎週 {weekdays} の {time}',
|
||||
'frequency.cron': 'Cron {expression} ({timeZone})',
|
||||
'frequency.cronLocal': 'Cron {expression}',
|
||||
'frequency.cron': 'カスタム ({timeZone})',
|
||||
'frequency.cronLocal': 'カスタム',
|
||||
'frequency.cronRule': '{rule} ({timeZone})',
|
||||
'cron.list.join': '、',
|
||||
'cron.part.join': ' ',
|
||||
@@ -192,8 +210,8 @@ export const frequencyEs = {
|
||||
'frequency.dailyLocal': 'Diariamente en {time}',
|
||||
'frequency.weekly': 'Semanalmente en {weekdays} en {time} ({timeZone})',
|
||||
'frequency.weeklyLocal': 'Semanalmente en {weekdays} en {time}',
|
||||
'frequency.cron': 'Cron {expression} ({timeZone})',
|
||||
'frequency.cronLocal': 'Cron {expression}',
|
||||
'frequency.cron': 'Personalizado ({timeZone})',
|
||||
'frequency.cronLocal': 'Personalizado',
|
||||
'frequency.cronRule': '{rule} ({timeZone})',
|
||||
'cron.list.join': ', ',
|
||||
'cron.part.join': ' ',
|
||||
@@ -240,6 +258,12 @@ export const frequencyEs = {
|
||||
'relative.now': 'Vencimiento ahora',
|
||||
'relative.future': 'en {value} {unit}',
|
||||
'relative.overdue': '{value} {unit} vencido',
|
||||
'frequency.hostMonthly': 'Cada mes, los días {days} a las {time}, o a fin de mes',
|
||||
'frequency.hostDailyInterval': 'Cada {interval} días a las {time}',
|
||||
'frequency.hostWeeklyInterval': 'Cada {interval} semanas, {weekdays}, a las {time}',
|
||||
'frequency.hostMonthlyInterval': 'Cada {interval} meses, los días {days} a las {time}, o a fin de mes',
|
||||
'frequency.hostZone': '{rule} ({timeZone})',
|
||||
'frequency.hostCustom': 'Personalizado',
|
||||
} satisfies typeof frequencyEn
|
||||
|
||||
/** French dictionary, checked against the English keys. */
|
||||
@@ -250,8 +274,8 @@ export const frequencyFr = {
|
||||
'frequency.dailyLocal': 'Tous les jours à {time}',
|
||||
'frequency.weekly': 'Hebdomadaire sur {weekdays} à {time} ({timeZone})',
|
||||
'frequency.weeklyLocal': 'Hebdomadaire sur {weekdays} à {time}',
|
||||
'frequency.cron': 'Cron {expression} ({timeZone})',
|
||||
'frequency.cronLocal': 'Cron {expression}',
|
||||
'frequency.cron': 'Personnalisé ({timeZone})',
|
||||
'frequency.cronLocal': 'Personnalisé',
|
||||
'frequency.cronRule': '{rule} ({timeZone})',
|
||||
'cron.list.join': ', ',
|
||||
'cron.part.join': ' ',
|
||||
@@ -298,6 +322,12 @@ export const frequencyFr = {
|
||||
'relative.now': 'À rendre maintenant',
|
||||
'relative.future': 'dans {value} {unit}',
|
||||
'relative.overdue': '{value} {unit} en retard',
|
||||
'frequency.hostMonthly': 'Chaque mois, les jours {days} à {time}, ou en fin de mois',
|
||||
'frequency.hostDailyInterval': 'Tous les {interval} jours à {time}',
|
||||
'frequency.hostWeeklyInterval': 'Toutes les {interval} semaines, {weekdays}, à {time}',
|
||||
'frequency.hostMonthlyInterval': 'Tous les {interval} mois, les jours {days} à {time}, ou en fin de mois',
|
||||
'frequency.hostZone': '{rule} ({timeZone})',
|
||||
'frequency.hostCustom': 'Personnalisé',
|
||||
} satisfies typeof frequencyEn
|
||||
|
||||
/** German dictionary, checked against the English keys. */
|
||||
@@ -308,8 +338,8 @@ export const frequencyDe = {
|
||||
'frequency.dailyLocal': 'Täglich um {time}',
|
||||
'frequency.weekly': 'Wöchentlich auf {weekdays} um {time} ({timeZone})',
|
||||
'frequency.weeklyLocal': 'Wöchentlich auf {weekdays} und {time}',
|
||||
'frequency.cron': 'Cron {expression} ({timeZone})',
|
||||
'frequency.cronLocal': 'Cron {expression}',
|
||||
'frequency.cron': 'Benutzerdefiniert ({timeZone})',
|
||||
'frequency.cronLocal': 'Benutzerdefiniert',
|
||||
'frequency.cronRule': '{rule} ({timeZone})',
|
||||
'cron.list.join': ', ',
|
||||
'cron.part.join': ' ',
|
||||
@@ -356,6 +386,12 @@ export const frequencyDe = {
|
||||
'relative.now': 'Jetzt fällig',
|
||||
'relative.future': 'in {value} {unit}',
|
||||
'relative.overdue': '{value} {unit} überfällig',
|
||||
'frequency.hostMonthly': 'Monatlich an Tag {days} um {time}, sonst am Monatsende',
|
||||
'frequency.hostDailyInterval': 'Alle {interval} Tage um {time}',
|
||||
'frequency.hostWeeklyInterval': 'Alle {interval} Wochen am {weekdays} um {time}',
|
||||
'frequency.hostMonthlyInterval': 'Alle {interval} Monate an Tag {days} um {time}, sonst am Monatsende',
|
||||
'frequency.hostZone': '{rule} ({timeZone})',
|
||||
'frequency.hostCustom': 'Benutzerdefiniert',
|
||||
} satisfies typeof frequencyEn
|
||||
|
||||
/** Russian dictionary, checked against the English keys. */
|
||||
@@ -366,8 +402,8 @@ export const frequencyRu = {
|
||||
'frequency.dailyLocal': 'Ежедневно в {time}',
|
||||
'frequency.weekly': 'Еженедельно на {weekdays} по адресу {time} ({timeZone})',
|
||||
'frequency.weeklyLocal': 'Еженедельно на {weekdays} по адресу {time}',
|
||||
'frequency.cron': 'Крон {expression} ({timeZone})',
|
||||
'frequency.cronLocal': 'Крон {expression}',
|
||||
'frequency.cron': 'Произвольно ({timeZone})',
|
||||
'frequency.cronLocal': 'Произвольно',
|
||||
'frequency.cronRule': '{rule} ({timeZone})',
|
||||
'cron.list.join': ', ',
|
||||
'cron.part.join': ' ',
|
||||
@@ -414,6 +450,12 @@ export const frequencyRu = {
|
||||
'relative.now': 'Срок оплаты сейчас',
|
||||
'relative.future': 'в {value} {unit}',
|
||||
'relative.overdue': '{value} {unit} просрочен',
|
||||
'frequency.hostMonthly': 'Каждый месяц, дни {days}, в {time}, либо в конце месяца',
|
||||
'frequency.hostDailyInterval': 'Каждые {interval} дней в {time}',
|
||||
'frequency.hostWeeklyInterval': 'Каждые {interval} недель, {weekdays}, в {time}',
|
||||
'frequency.hostMonthlyInterval': 'Каждые {interval} месяцев, дни {days}, в {time}, либо в конце месяца',
|
||||
'frequency.hostZone': '{rule} ({timeZone})',
|
||||
'frequency.hostCustom': 'Произвольно',
|
||||
} satisfies typeof frequencyEn
|
||||
|
||||
/** Portuguese dictionary, checked against the English keys. */
|
||||
@@ -424,8 +466,8 @@ export const frequencyPt = {
|
||||
'frequency.dailyLocal': 'Diariamente às {time}',
|
||||
'frequency.weekly': 'Semanalmente em {weekdays} às {time} ({timeZone})',
|
||||
'frequency.weeklyLocal': 'Semanalmente em {weekdays} às {time}',
|
||||
'frequency.cron': 'Cron {expression} ({timeZone})',
|
||||
'frequency.cronLocal': 'Cron {expression}',
|
||||
'frequency.cron': 'Personalizado ({timeZone})',
|
||||
'frequency.cronLocal': 'Personalizado',
|
||||
'frequency.cronRule': '{rule} ({timeZone})',
|
||||
'cron.list.join': ', ',
|
||||
'cron.part.join': ' ',
|
||||
@@ -472,4 +514,10 @@ export const frequencyPt = {
|
||||
'relative.now': 'Vencimento agora',
|
||||
'relative.future': 'em {value} {unit}',
|
||||
'relative.overdue': '{value} {unit} atrasado',
|
||||
'frequency.hostMonthly': 'Todo mês, nos dias {days} às {time}, ou no fim do mês',
|
||||
'frequency.hostDailyInterval': 'A cada {interval} dias às {time}',
|
||||
'frequency.hostWeeklyInterval': 'A cada {interval} semanas, {weekdays}, às {time}',
|
||||
'frequency.hostMonthlyInterval': 'A cada {interval} meses, nos dias {days} às {time}, ou no fim do mês',
|
||||
'frequency.hostZone': '{rule} ({timeZone})',
|
||||
'frequency.hostCustom': 'Personalizado',
|
||||
} satisfies typeof frequencyEn
|
||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user