diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml new file mode 100644 index 0000000000..945359e6e7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-16-host-schedule-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-09-16-host-schedule-storage.md +/agent-note-host-owned-scheduled-messages: + en: 11854b220af809ea + zh: 0f0575de37107ed8 +/agent-note-host-owned-scheduled-messages/problem: + en: 2c774d05ae75784f + zh: 619ba7f281b13233 +/agent-note-host-owned-scheduled-messages/decision: + en: 4a38d9bd933241b0 + zh: b246c74f68e92a25 +/agent-note-host-owned-scheduled-messages/alternatives-considered: + en: 21d7ec9a9ff3cbf2 + zh: 93d03b8b3012aa7e +/agent-note-host-owned-scheduled-messages/consequences: + en: 5b8d0a26024f795e + zh: c4d6f36d54783c47 diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md new file mode 100644 index 0000000000..f063dba6e4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.md @@ -0,0 +1,65 @@ +# Agent Note: Host-owned scheduled messages + +Status: implemented + +English | [中文](2026-09-16-host-schedule-storage.zh.md) + +## 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 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 supersedes the Session-log authority, live-only activation, and read-only catalog decisions in the [Session-local reminders note](../feature/2026-08-05-durable-web-schedule.md). Its explicit absolute-time validation, fixed-rate arithmetic, and distinction between queue admission and model completion remain applicable. Historical `schedule/change` creation, fold, and invariant 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. 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. + +## Alternatives considered + +**Load every Session at startup.** Session activation is unnecessary for discovering tasks and would couple timer restoration to Agent initialization and conversation size. + +**Introduce separate rule, run, scheduler, and delivery packages.** Durable schedules and their saved inbox receipts support task inspection without independent execution records or model completion tracking. + +**Write history separately from the task.** The domain exposes independent per-record writes, not a multi-record transaction. Keeping history in the task row makes status, next target, latest receipt, and appended history commit together. A separate history store requires an explicit transactional or recovery design before adopting it. + +**Advance before enqueue or promise exactly-once delivery.** Advance-first can lose a message on enqueue failure. Exactly-once delivery needs a shared transaction or a durable idempotency protocol that the two stores do not provide. + +**Import every historical Session automatically.** A startup scan would reintroduce the cold-history dependency. Explicit recreation keeps the persisted authority unambiguous; historical decoding preserves access to existing logs. + +**Represent daily reminders as 86,400-second intervals.** Fixed elapsed time cannot preserve a local wall-clock rule when offsets change. A distinct Daily record retains the rule needed to resolve each later date. Weekly extends the same decision with an explicit weekday set rather than a 604,800-second interval, so a later offset change still resolves each selected local date. + +**Sample offsets manually or recalculate saved targets during decoding.** Temporal supplies maintained calendar and disambiguation behavior without a Schedule-owned offset search. A committed target remains due even when newer time-zone data would resolve the rule differently; only later targets use that data. + +## 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 opt-in overlay remains responsible for enabling Schedule. 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. diff --git a/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md new file mode 100644 index 0000000000..433c1acb0a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-16-host-schedule-storage.zh.md @@ -0,0 +1,65 @@ +# Agent Note: Host 拥有的定时消息 + +Status: implemented + +[English](2026-09-16-host-schedule-storage.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。 + +任务 domain 声明整 unit 布局。任务是权威数据,因此 JSON 后端会把文件不可读、文档损坏、版本不受支持或任务非法作为整批加载失败,而不是解释为删除。恢复失败不会改写 `schedule.json`,初始不存在的文件则作为空 domain 打开;修复该文件后会保留任务身份。`Service.init` 等待存储校验和运行时初始化,管理方法等待同一初始化结果;拥有方卸载时释放迟到的存储资源,而不开始投递。投递调度准入与初始化分开:任务变更提交后、运行时清理开始前,祖先可能关闭 Agent 准入。此类拒绝会记录日志而不自动重试,不会将已持久化的变更变成失败的管理结果。存储非法和清理注册失败仍拒绝启动。 + +此决策替代[会话内提醒记录](../feature/2026-08-05-durable-web-schedule.zh.md)中的 Session 日志权威来源、仅 live 时激活和只读目录决策。其中显式绝对时间校验、固定速率计算,以及区分排队接纳与模型完成的决策仍然适用。历史 `schedule/change` 的创建、fold 和 invariant 类型通过 `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。没有可表示的下一目标时,重复任务保留未运行状态及最近一次回执。停止的 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。 + +## Alternatives considered + +**启动时加载每个 Session。** 发现任务不需要激活 Session,这会让 timer 恢复依赖 Agent 初始化和对话大小。 + +**引入独立的 rule、run、scheduler 和 delivery 包。** 持久 schedule 与已保存的收件箱回执足以支持任务查看,无须独立执行记录或模型完成跟踪。 + +**将历史与任务分开写入。** domain 提供独立的逐记录写入,不提供多记录事务。将历史保存在任务行内,可使状态、下一目标、最近一次回执和追加历史一起提交。采用独立历史存储之前,需要明确的事务或恢复设计。 + +**入队前推进,或承诺恰好投递一次。** 先推进可能在入队失败时丢失消息。恰好投递一次需要共享事务或持久幂等协议,这两个存储并不提供。 + +**自动导入全部历史 Session。** 启动扫描会重新引入对 cold 历史的依赖。显式重建使持久化权威来源保持明确;历史解码保留了现有日志的访问能力。 + +**以 86,400 秒间隔表示每日提醒。** 固定经过时长无法在偏移量变化时保留本地钟表规则。独立的 Daily 记录保留了解析每个后续日期所需的规则。Weekly 以显式星期集合而非 604,800 秒间隔延续同一决策,因此偏移量变化后仍能解析每个所选本地日期。 + +**手动采样偏移量或在解码时重新计算已保存目标。** Temporal 提供受维护的日历与消歧行为,无须由 Schedule 实现偏移量搜索。即使较新的时区数据对规则给出不同解析,已提交目标仍然到期;仅后续目标使用该数据。 + +## Consequences + +**不把 mock 的「每小时整点」引入持久词表。** 设计稿把它与每天、每周、仅一次并列。每小时整点与固定间隔在语义上是同一种按经过时间计量的规则,因此详情里的该项直接映射到既有的间隔类型,而不是新增一个持久种类。若真要新增,需要新的持久变体、解码器、目标解析与工具 schema,而它表达的能力现有间隔已经覆盖。 + +任务发现和 timer 不依赖已加载的 Session。延迟、绝对时间和固定速率选择器在与 Daily、Weekly、Cron 并存时保留自身语义;读取已有任务不会重写它们。Host 必须提供普通 Session 持久化和恢复服务,Schedule 仍由显式启用的 overlay 加载。显式删除会阻止后续投递,并将任务从 `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 操作与捕获的模型请求。 diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml new file mode 100644 index 0000000000..575b3ca146 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-22-schedule-hard-delete-and-bounded-history.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-22-schedule-hard-delete-and-bounded-history.md +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history: + en: 17a73230ad22ec0b + zh: 5bcf27d7c0e86764 +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/problem: + en: 98e82a0add324882 + zh: c10e85a3f2a522f8 +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/decision: + en: 56328745115714b5 + zh: f30d39f59aa06a13 +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/alternatives-considered: + en: 0c4eb01054be1141 + zh: 95980f8097fa36fd +/agent-note-schedule-deletes-task-rows-and-bounds-delivery-history/consequences: + en: 05121f4b77b8ee12 + zh: 1751c9b1a752ce6b diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md new file mode 100644 index 0000000000..90127d6887 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.md @@ -0,0 +1,33 @@ +# Agent Note: Schedule deletes task rows and bounds delivery history + +Status: implemented + +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. + +## 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. + +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. + +## Alternatives considered + +**Keep the retained `deleted` row.** The flag existed so `history` could answer after deletion. Nothing read those records after deletion, the row kept every prompt snapshot in the storage document, and the state had to be excluded by hand from `list`, `catalog`, and the timer. Removing the row makes deletion mean one thing. + +**Bound only by record count.** A count cap alone keeps arbitrarily old receipts when deliveries are sparse, and it drops a burst of recent ones when they are not. The day window bounds staleness while the cap bounds the document. + +**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. + +## 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. diff --git a/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md new file mode 100644 index 0000000000..dfc8ab4ff3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-22-schedule-hard-delete-and-bounded-history.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Schedule 删除任务行并限制投递历史 + +Status: implemented + +[English](2026-09-22-schedule-hard-delete-and-bounded-history.md) | 中文 + +## Problem + +Schedule domain 是后端每次变更都会重写的单个整 unit JSON 文档,因此任务行中保留的每个字节都会随每次写入增长。已保存的发送记录没有上限:每次确认都会向数组追加,而被删除的任务会保留其行与记录,以便已打开的详情仍能读取。于是文档大小随任务的投递次数变化,删除还引入了一个所有读取方都必须跳过的状态。 + +## Decision + +删除是硬删除。`ScheduleService.delete` 通过 `tasks.delete` 移除存储行,因此任务停止调度、离开 `list` 与 `catalog`,`history` 对相同的 `(sessionId, id)` 返回 `schedule_not_found`;该行已保存的投递记录随行一并移除。任务 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` 是唯一的非活动名称,客户端筛选行为全部、活动和未运行。该笔记保持活动状态:这是一次部分取代,因此它既未归档也未冻结,仍是上述内容的归属笔记,而它记录的删除与保留决策由本笔记取代。 + +## Alternatives considered + +**保留带 `deleted` 标志的任务行。** 该标志的存在是为了让 `history` 在删除后仍能回答。删除后没有任何代码读取这些记录,而该行把每个提示文本快照都留在存储文档中,还必须手工把它排除在 `list`、`catalog` 和定时器之外。移除该行让删除只有一种含义。 + +**只按条数设上限。** 仅有条数上限时,投递稀疏的任务会保留任意久远的回执,投递密集时又会丢弃近期的一批回执。天数窗口限制陈旧程度,条数上限限制文档大小。 + +**以任务而非每条回执为窗口起点。** 把窗口锚定在任务的创建时间或已提交目标上,会丢弃旧任务刚写入的回执。从本次追加回执的 `deliveredAt` 向前计算,使窗口锚定在所要保留的数据上。 + +**保留无上限的历史,由运维清理。** 该 domain 每次写入都发布整个 unit 文档,因此无上限的历史会让存储文档的大小随投递次数增长。保留上限是存储布局所要求的约束,而不是保留策略偏好。 + +## Consequences + +删除不再提供恢复能力:已删除任务的已保存投递无法再读回,仍需要完整历史的必须在删除前读取。配置的保留策略也会丢弃任务曾经拥有的回执;保留的标志记录窗口发生过裁剪,而不是假装历史完整。 + +换取的是:持久任务集合只有一种删除语义,存储文档大小由配置而非投递次数决定,且 `inactive` 是唯一的非活动生命周期名称。当某个部署需要更长的投递历史时,应提高插件 `Config` 中的上限。 diff --git a/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.i18n.yaml new file mode 100644 index 0000000000..5bd54753a0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-23-schedule-local-menu.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-23-schedule-local-menu.md +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client: + en: a9ed8f9cf0bdfd66 + zh: ff11822efe6b48b7 +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/problem: + en: 076e6ca799bf2045 + zh: d3f87030015cdf3c +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/decision: + en: c35ab9fa2549866d + zh: 01855f87b884dc93 +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/alternatives-considered: + en: 3648e55bb76a4381 + zh: 83c98e0789a6df9c +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/consequences: + en: f6380c38e35bce37 + zh: 1379ff1763d0eb10 +/agent-note-own-the-schedule-detail-menus-inside-the-schedule-client/testing: + en: f1de601175678a9a + zh: 58fddec29a462b98 diff --git a/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.md b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.md new file mode 100644 index 0000000000..fd3266bbc4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.md @@ -0,0 +1,43 @@ +# Agent Note: Own the Schedule detail menus inside the Schedule client + +Status: implemented + +English | [中文](2026-09-23-schedule-local-menu.zh.md) + +## Problem + +The Automation tasks detail needs a menu whose list carries a pinned control: the Time zone row's search field must stay above the scrolling zone rows. The shared `Menu` in `@deepseek-ai/dsh-client-ui-primitives` renders rows, separators, labels, a selection mark, and a keyboard walk, but it has no header slot. The Delivery records tab shows a saved record's message id behind an info glyph, and the reader must be able to read and copy that id. + +Serving either need by extending `ui-primitives` puts one feature's behavior into the package every client plugin consumes. The same branch had already removed unrelated `ui-layout` and `ui-sidebar-right` edits after review asked why a Schedule change touched them, and a shared primitive extended for a single caller still costs every future reader of that primitive. + +## Decision + +`packages/client/ui-schedule` owns its menu. `src/client/TaskMenu.tsx` with `TaskMenu.module.css` holds a schedule-local copy that renders rows, separators, labels, disabled and destructive rows, a trailing `selectedId` mark, the pointer and keyboard walk, portaled placement, the pinned `header` slot, and the `data-menu-field` focus handover. It imports the shared `useAnchoredPosition` and `useDismissOnOutsidePointer` hooks, which are the placement and dismissal behavior any anchored panel needs, and it imports no runtime value from `ui-primitives`. The four detail menus use it. + +The Delivery records tab names and copies a record's message id through an info button. The button's accessible name states the action and the record (`Copy message ID: `), the shared `Tooltip` states the id on hover and on keyboard focus, and activation copies it through the shared `writeClipboard` helper and reports `Copied` in a status region beside the glyph. `Tooltip` therefore keeps its master shape: the mode that held one bubble open under the pointer is gone, and the Delivery records view does not use the `HoverCard` that opens on the pointer alone. + +`packages/client/ui-primitives` returns to master except the clock glyph's stroke. The client bundle purity gate in `packages/client/tsdown.client.ts` is the neighboring rule for the other direction of this boundary: a client plugin may import another plugin's package only as types or as a module-table request, so shared runtime vocabulary travels through an inline-safe layer or a cordis service rather than a package edge. + +## Alternatives considered + +**Add a `header` prop to the shared `Menu`.** This is what the branch first did: an optional slot plus the `data-menu-field` focus handover, with tests in the shared package. It gives the smallest diff and keeps one menu implementation, and it was rejected because the only caller is the Schedule detail: `ui-primitives` would carry API that exists for one feature, and every client plugin's bundle would read a header contract none of them use. + +**Add an `interactive` mode to the shared `Tooltip`.** Shipped first and then removed: a grace period between anchor and bubble, pointer hold, click toggling, and selectable text, with six tests. It lost because the shared tooltip already opens on focus and the shared clipboard helper already performs the copy, so the mode added a second way to hold one bubble open for a single caller. + +**Name and copy the message id through `HoverCard`.** Shipped second and then replaced: the card holds while the pointer rests on it, selects its text, and copies on activation. It lost because `HoverCard` opens on `onPointerEnter` alone, so the already-focusable glyph showed a keyboard reader nothing and offered no copy path. + +**Render the zone search field outside the menu list.** A search input above the trigger, or a separate row in the rule card, needs no primitive change. It was rejected because the field would scroll away from the list it filters or sit in a different surface than the rows it filters. + +**Build the zone picker on the schedule-local `PickerPopover`.** The Date and Time pickers already use that panel, so the zone list could reuse it. It was rejected because `PickerPopover` positions and dismisses a panel but renders no list semantics: the keyboard walk, the row roles, and the selected-row mark would have to be rebuilt inside the Schedule client anyway, which is the copy this note describes with more surface than the menu it replaces. + +## Consequences + +The Schedule client carries a second menu implementation: 346 lines of component, 189 lines of CSS, and 403 lines of tests, in exchange for `ui-primitives` returning to master except the clock glyph's stroke. The copy is trimmed to what the detail uses, and the absent capabilities stay absent: submenus, component-rendered rows, a pinned footer, multiple selection ids, the fill selection mode, dense and compact spacing, mount-time autofocus, a caller-supplied anchor rect, pointer-leave closing, and a side parameter. A later Schedule menu that needs one of them extends the copy or revisits this decision. + +Copying is an activation, not a text selection: the tooltip shows the id but does not accept the pointer, so the desktop gesture that copies the id is a click on the glyph. That is the capability the removed interactive mode had and this shape gives up. Shared behavior stays shared: placement, dismissal, focus handover, the duplication gate's exclusion markers naming both owners, and per-file coverage all apply to the copy. The copied region carries `jscpd:ignore-start` and `jscpd:ignore-end` with the two owners named, following the [cross-package value dependencies note](../../archived/process/2026-08-23-client-cross-package-value-dependencies.md); the schedule-specific header, handover, and Tab semantics sit outside that region. + +The copy places its list with `useAnchoredPosition`, which clamps every edge by the same 12px margin, while the shared menu's top edge used the frame-top clearance helper. A list that must clamp upward against the top of a macOS window can therefore sit about 12px lower than the shared menu did in the same situation; the stylesheet still reserves the clearance for the list's own maximum height, so the common case is unchanged. + +## Testing + +`packages/client/ui-schedule/tests/task-menu.client.spec.tsx` pins the pointer and keyboard walk, the `header` field's Home/End ownership, the `data-menu-field` handover after placement, the selected-row mark, disabled rows, portal placement, outside dismissal, and focus return; the three menu-header tests that lived in `ui-primitives` moved there with the capability. `delivery-history.client.spec.tsx` pins the tooltip that keyboard focus raises, the button's accessible name, the clipboard write, and the copy confirmation that clears. The coverage gate reports 100% per file for `packages/client/ui-schedule/src`, and the duplication gate reports no clones across `packages/client/ui-schedule` and `packages/client/ui-primitives`. diff --git a/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.zh.md b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.zh.md new file mode 100644 index 0000000000..924f14f5ad --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-23-schedule-local-menu.zh.md @@ -0,0 +1,43 @@ +# Agent Note: Schedule 详情菜单由 Schedule 客户端自己拥有 + +Status: implemented + +[English](2026-09-23-schedule-local-menu.md) | 中文 + +## Problem + +Automation tasks 详情需要一个列表顶部钉住控件的菜单:Time zone 行的搜索框必须停在滚动时区行之上。`@deepseek-ai/dsh-client-ui-primitives` 里的共享 `Menu` 会渲染行、分隔线、标题行、选中标记和键盘走位,但没有 header 槽。Delivery records 页把一条已保存记录的 message id 放在 info 图标背后,读者要能读到并复制这个 id。 + +用扩展 `ui-primitives` 来满足其中任一需求,都会把单个功能的行为放进每个 client 插件都消费的包里。同一条分支此前已经因为 review 质询“Schedule 的改动为什么碰它们”而撤掉了无关的 `ui-layout` 与 `ui-sidebar-right` 编辑;而一个只为单一调用方扩展的共享原语,对之后每一个读它的人都是成本。 + +## Decision + +`packages/client/ui-schedule` 自己拥有菜单。`src/client/TaskMenu.tsx` 与 `TaskMenu.module.css` 保存一份 schedule 本地副本,渲染行、分隔线、标题行、disabled 与 dangerous 行、`selectedId` 尾随标记、指针与键盘走位、portal 定位、钉住的 `header` 槽,以及 `data-menu-field` 焦点交接。它导入共享的 `useAnchoredPosition` 与 `useDismissOnOutsidePointer` hook——任何锚定面板都需要的定位与关闭行为——不从 `ui-primitives` 导入任何运行时值。详情的四个菜单都改用它。 + +Delivery records 页用一个 info 按钮给出并复制记录的消息 id。该按钮的可访问名称同时说明动作与记录(`Copy message ID: `),共享 `Tooltip` 在悬停与键盘聚焦时给出该 id,激活时通过共享的 `writeClipboard` 复制,并在图标旁用 status 区域报告 `Copied`。因此 `Tooltip` 保持 master 形态:那个让气泡在指针下保持展开的模式已不存在,Delivery records 页也不使用只在指针下打开的 `HoverCard`。 + +`packages/client/ui-primitives` 除时钟图标描边外回到 master。`packages/client/tsdown.client.ts` 的 client bundle purity gate 是这条边界另一方向上的相邻规则:client 插件只能以类型或 module-table request 的形式导入另一个插件的包,因此共享运行时词汇要么走 inline-safe 层,要么走 cordis service,而不是包与包之间的边。 + +## Alternatives considered + +**给共享 `Menu` 加 `header` prop。** 这是分支最初的做法:一个可选槽加 `data-menu-field` 焦点交接,并在共享包里带测试。它的 diff 最小、只保留一份菜单实现;被否决是因为唯一的调用方是 Schedule 详情:`ui-primitives` 会背上只为单个功能存在的 API,而每个 client 插件的 bundle 都要读一份没人使用的 header 契约。 + +**给共享 `Tooltip` 加 `interactive` 模式。** 先落地后撤除:锚点与气泡之间的宽限期、指针停留、点击切换、可选中的文字,外加六个测试。被否决是因为共享 tooltip 本来就支持聚焦展开、共享剪贴板助手本来就负责复制,这个模式只是为单一调用方多出了一种保持气泡展开的方式。 + +**用 `HoverCard` 给出并复制消息 id。** 第二个落地又被替换的版本:指针停在卡片上时保持展开、文字可选中、激活即复制。被否决是因为 `HoverCard` 只在 `onPointerEnter` 打开,于是那个本来就可聚焦的图标对键盘读者什么都不显示,也没有复制入口。 + +**把时区搜索框渲染在菜单列表之外。** 放在触发器上方的输入框,或规则卡里的独立一行,都不需要改原语。被否决是因为该输入框会与它所过滤的列表分离滚动,或落在与它过滤的行不同的界面层上。 + +**用 schedule 本地的 `PickerPopover` 实现时区选择器。** Date 与 Time picker 已经用它。被否决是因为 `PickerPopover` 只负责定位与关闭面板,不渲染列表语义:键盘走位、行 role、选中行标记都要在 Schedule 客户端里重建,那正是本 note 描述的副本,而且比它所替代的菜单要维护更多东西。 + +## Consequences + +Schedule 客户端多出一份菜单实现:346 行组件、189 行 CSS、403 行测试,换来 `ui-primitives` 除时钟图标描边外回到 master。副本按详情实际使用裁剪,缺失的能力保持缺失:submenu、组件渲染行、钉住的 footer、多个 selectedId、fill 选中模式、dense 与 compact 间距、挂载时 autofocus、调用方提供锚点矩形、指针离开即关闭,以及 side 参数。日后某个 Schedule 菜单需要其中一项时,要么扩展副本,要么重新审视这个决定。 + +复制是一次激活,而不是选中文本:tooltip 给出 id 但不接受指针,所以桌面端复制该 id 的手势是点击图标;这正是被撤掉的交互模式所具备、而当前形态放弃的能力。共享行为仍是共享的:定位、关闭、焦点交接、点名两个 owner 的 duplication gate 排除标记,以及 per-file 覆盖率都适用于这份副本。被拷贝的区域带 `jscpd:ignore-start` 与 `jscpd:ignore-end` 并点名两个 owner,遵循 [cross-package value dependencies note](../../archived/process/2026-08-23-client-cross-package-value-dependencies.md);schedule 专有的 header、交接与 Tab 语义不在该区域内。 + +副本用 `useAnchoredPosition` 定位列表,它对四条边都用同一个 12px margin,而共享菜单的顶边用的是 frame-top clearance helper。因此在 macOS 窗口顶部需要向上钳制的列表,可能比共享菜单原先的位置低约 12px;样式表仍为该列表自身的最大高度保留 clearance,常见情形不变。 + +## Testing + +`packages/client/ui-schedule/tests/task-menu.client.spec.tsx` 钉住指针与键盘走位、`header` 输入框对 Home/End 的独占、落位后的 `data-menu-field` 交接、选中行标记、disabled 行、portal 定位、外部关闭与焦点回返;原先位于 `ui-primitives` 的三个菜单 header 测试随该能力一起迁入。`delivery-history.client.spec.tsx` 钉住键盘聚焦展开的工具提示、按钮的可访问名称、剪贴板写入,以及会自行清除的复制确认。覆盖率门禁对 `packages/client/ui-schedule/src` 报告 per-file 100%,duplication 门禁在 `packages/client/ui-schedule` 与 `packages/client/ui-primitives` 上报告 0 克隆。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.i18n.yaml new file mode 100644 index 0000000000..dc30220e34 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-18-schedule-task-tab-restore.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/bug-fix/2026-09-18-schedule-task-tab-restore.md +/agent-note-task-tab-recovery-after-a-reload: + en: d7edcc16307d350a + zh: 63301b138b2a203b +/agent-note-task-tab-recovery-after-a-reload/problem: + en: 244f309423fff24e + zh: ddd2ab66a3f4fd5b +/agent-note-task-tab-recovery-after-a-reload/decision: + en: 16e1bcb4a0860e91 + zh: 587715df43d5c96f +/agent-note-task-tab-recovery-after-a-reload/alternatives-considered: + en: 2fb8e2e0688d22ac + zh: a584b183723aace7 +/agent-note-task-tab-recovery-after-a-reload/consequences: + en: 20b7e4c551df0bc0 + zh: c17426fb981f0b06 +/agent-note-task-tab-recovery-after-a-reload/testing: + en: 3581ad83c52ee3b0 + zh: fbffd886a46b76c6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.md b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.md new file mode 100644 index 0000000000..3809dfadce --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.md @@ -0,0 +1,45 @@ +# Agent Note: Task tab recovery after a reload + +Status: implemented + +English | [中文](2026-09-18-schedule-task-tab-restore.zh.md) + +## Problem + +The right Sidebar persists a Session's layout as tab records and not the navigation parameters an opener passed, per the [tab type and navigation decision](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md). A `scheduleTask` tab restored by a reload therefore had no task identity: its body could show only its loading feedback and its chip only the constant label, until the user opened the task again from the Session entry. Recovering content belongs to the tab type's provider, as the [layout persistence decision](../architecture/2026-09-14-sidebar-layout-provider-recovery.md) established for terminals, and the Sidebar exposes no parameter store for a page type to reuse. + +## Decision + +The task tab page keeps a durable binding to the task it last showed and resolves a restored record from it. + +The binding key is the Sidebar Session plus the layout record's own id. `dsh.sidebar-right.v1.` persists the tab records and the counter that mints their ids, so a record keeps its id across a reload and no record minted later in that layout reuses it. The record's `contentId` is not a usable key: every tab of one page kind is recorded under the same `sidebar://` address, and page deduplication is per pane, so two panes may each hold a task tab. Each entry also stores the `kind` and `contentId` of the record it was written for, and a read drops and ignores an entry whose pair differs from the record reading it. + +One localStorage document per Session, keyed `dsh.schedule.task-tab.v1.`, holds one entry per tab id, mapping that page to the Session and id of its last task. No task content is stored. A write keeps only the entries whose tab the Session's committed layout still holds, read through `ctx.sidebarRight.tabsIn(sessionId)`; a Session whose layout is not adopted leaves its entries alone. An entry that a read succeeding after the tab appeared cannot resolve is removed, which leaves the tab with the unbound-tab notice, the same state as one that never had a binding. + +The body writes the binding in an effect whenever a navigation carries this type's parameters, and reads it only while the record has no navigation parameters; parameters of another kind leave the record in its missing-task state. A recovered identity then follows the existing rules: the detail whenever the retained records hold the task, the catalog feedback until a read requested after the tab's baseline settles without the task, and the query-failure state with its retry when the read fails. A binding such a read cannot resolve shows the unbound-tab notice instead of the loading feedback, and never the missing-task notice, because that notice states that an opener named a task. + +The body measures the answer from the catalog read request ordinal it observed for its current navigation, and asks for a read whenever the retained records leave it without a task and no read requested after that ordinal has succeeded, reporting the catalog feedback while that read is pending. The Sidebar navigates a tab again without remounting its body, so the navigation's revision moves the baseline; a read requested before the baseline never states that the tab's task is gone. + +## Alternatives considered + +**Key the binding by `contentId`.** Rejected: every tab of one page kind is recorded under the same address, and page deduplication is per pane, so two task tabs in two panes would share one binding and restore the same task. + +**Persist navigation parameters in the Sidebar layout.** Rejected: it moves content parameters into the layout snapshot that the [navigation decision](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md) keeps transient, and every future page type would inherit the format. + +**Declare the kind `multiple: true` so every open mints a unique contentId.** Rejected: it changes what opening a task does — a second task opens a second tab instead of showing in the existing one — to obtain a key, and it cannot recover records restored before the change. + +**Resolve the task from the catalog.** Rejected: the catalog holds no per-tab association, and the Session entry the tab was opened from is gone after a reload. + +**Reuse the client store's localStorage persistence.** Rejected: `createSnapshotStore`'s `persist` option saves one whole store value under one name and rehydrates it when that store is created, so it needs a store per Session; the binding also has to reject reused ids and prune closed tabs on write, which a keyed document does in one place with no subscription for a body to re-render through. + +**Store the task title beside the binding to confirm the recovery.** Rejected: a title is task content, it changes with an edit, and the settled catalog read already decides whether the bound id resolves. + +## Consequences + +A reloaded Session restores every task tab's detail and chip without reopening the task. The binding is window-local browser state: another browser, a cleared origin, or a tab the layout kit duplicated has none, and such a record states that the tab has no task instead of claiming the task is gone. A discarded layout snapshot can mint a tab id that a binding still names; the kind and contentId guard rejects an entry of another kind, and an entry of the same kind is overwritten by the next navigation of that tab or removed once its task stops resolving. + +Entries of Sessions whose layouts are never adopted again remain until their next write; write-time pruning bounds every adopted Session, and no entry holds content. + +## Testing + +Unit tests cover writing on navigation, reading only without parameters, recovery into the detail and the chip, degradation of an unresolvable binding, committed-tab pruning, reused-id rejection, malformed stored documents, and storage that is absent or rejects writes. diff --git a/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.zh.md b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.zh.md new file mode 100644 index 0000000000..84a1a1247b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-18-schedule-task-tab-restore.zh.md @@ -0,0 +1,45 @@ +# Agent Note: 刷新后任务标签页的恢复 + +Status: implemented + +[English](2026-09-18-schedule-task-tab-restore.md) | 中文 + +## Problem + +右 Sidebar 按 [标签类型与导航决策](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md) 只把 Session 布局持久化为标签记录,不持久化打开方传入的导航参数。因此刷新恢复出的 `scheduleTask` 标签没有任务身份:正文只能显示加载反馈,chip 只能显示固定文案,直到用户从 Session 入口重新打开该任务。按 [布局持久化决策](../architecture/2026-09-14-sidebar-layout-provider-recovery.zh.md) 为终端确立的划分,恢复内容属于标签类型自身 provider 的职责,而 Sidebar 没有可供页面类型复用的参数存储。 + +## Decision + +任务标签页保存到它最后显示任务的持久关联,并据此恢复刷新出的记录。 + +关联键是 Sidebar Session 加布局记录自身的 id。`dsh.sidebar-right.v1.` 持久化标签记录以及铸造其 id 的计数器,因此记录刷新后保持 id,该布局之后铸造的记录也不会复用它。记录的 `contentId` 不能作为键:同一种页面类型的所有标签都记录在同一个 `sidebar://` 地址下,而页面去重按 pane 进行,两个 pane 可以各有一个任务标签。每条记录还保存写入时该记录的 `kind` 和 `contentId`;读取时若这一对与读取它的记录不一致,就删除该条并视为无关联。 + +每个 Session 一个 localStorage 文档,键为 `dsh.schedule.task-tab.v1.`,按 tab id 保存一条记录,把该页面映射到其最后任务的 Session 和 id。不保存任何任务内容。写入时只保留 Session 已提交布局仍然持有的标签对应的记录,该布局通过 `ctx.sidebarRight.tabsIn(sessionId)` 读取;布局未被采用的 Session 保持其记录不变。标签页出现之后成功的一次读取无法解析的记录会被删除,使该标签显示未关联页签提示,与从未有关联的标签相同。 + +只要一次导航携带本类型的参数,正文就在 effect 中写入该关联;只有记录完全没有导航参数时才读取它,其他类型的参数让记录停留在任务缺失状态。恢复出的身份随后遵循既有的渲染规则:保留的记录包含该任务时即显示详情,判据之后请求的读取仍未给出该任务时显示目录反馈,读取失败时显示带重试的查询失败状态。这样的读取仍无法解析的关联显示未关联页签提示,而不是加载反馈,也绝不产生任务缺失提示,因为该提示的含义是打开方指名了一个任务。 + +正文以它为当前导航观察到的目录读取请求序号作为判据,并在保留的记录使标签页没有任务、且该序号之后没有读取成功时补发一次读取,该读取未落地期间显示目录反馈。Sidebar 会在不重新挂载正文的情况下对同一标签再次导航,因此以导航的 revision 移动判据;早于判据发出的读取永远不能断言该标签的任务已消失。 + +## Alternatives considered + +**用 `contentId` 作为关联键。** 否决:同一种页面类型的所有标签都记录在同一地址下,且页面去重按 pane 进行,两个 pane 的任务标签会共用一个关联并恢复出同一个任务。 + +**把导航参数持久化进 Sidebar 布局。** 否决:这会把内容参数放进 [导航决策](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md) 保持为临时的布局快照,且之后每种页面类型都会继承该格式。 + +**把 kind 声明为 `multiple: true`,让每次打开都铸造唯一 contentId。** 否决:为了取得一个键而改变打开任务的行为——打开第二个任务会新开一个标签,而不是在已有标签中显示;而且它无法恢复改动之前已存在的记录。 + +**从目录解析任务。** 否决:目录不保存按标签的关联,而打开该标签的 Session 入口在刷新后已不存在。 + +**复用 client store 的 localStorage 持久化。** 否决:`createSnapshotStore` 的 `persist` 选项把一个 store 的整体值保存在一个键下,并在该 store 创建时回填,因此需要按 Session 各建一个 store;而该关联还必须拒绝复用的 id 并在写入时清理已关闭标签,这些由一份按键文档在一处完成,也不需要正文为其订阅重渲染。 + +**在关联旁保存任务标题以确认恢复。** 否决:标题属于任务内容,会随编辑变化,而已定论的目录读取本身就能判断绑定的 id 是否可解析。 + +## Consequences + +刷新后的 Session 无需重新打开任务即可恢复每个任务标签的详情和 chip。该关联是窗口内的浏览器状态:其他浏览器、被清除的站点数据,或被布局工具包复制出的标签都没有它,这类记录会说明该页签未关联任务,而不会声称任务已不存在。被丢弃的布局快照可能铸造出关联仍然指名的 tab id;kind 与 contentId 守卫会拒绝其他类型的记录,而同类型记录会因该标签的下一次导航被覆盖,或在任务不再可解析时被删除。 + +布局不再被采用的 Session 的记录会保留到其下次写入;写入时的清理使每个已采用的 Session 有界,且任何记录都不含内容。 + +## Testing + +单元测试覆盖:导航时写入、仅在无参数时读取、详情与 chip 的恢复、无法解析关联的降级、已提交标签的清理、复用 id 的拒绝、损坏的存储文档,以及存储缺失或拒绝写入的情况。 diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml index 95f9c571b7..74f90930fc 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.i18n.yaml @@ -2,36 +2,30 @@ # 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/feature/2026-08-05-durable-web-schedule.md -/agent-note-durable-session-local-reminders: - en: c40fc2bc6dd1b469 - zh: c3932a1c8c60579b -/agent-note-durable-session-local-reminders/problem: - en: 75d2d50c48475d22 - zh: 293c507e9e516f29 -/agent-note-durable-session-local-reminders/decision: - en: 7fd21223d6306a39 - zh: b792a415d9dccba0 -/agent-note-durable-session-local-reminders/decision/session-log-authority-and-tools: - en: 23a928cba17217d6 - zh: 7995b144db6e4c70 -/agent-note-durable-session-local-reminders/decision/explicit-absolute-time-boundary: - en: 2e1fe906d42245aa - zh: bd21d4a2dd7d8afc -/agent-note-durable-session-local-reminders/decision/bounded-fixed-rate-semantics: - en: 2ebada22d530b18a - zh: 90e7dd128c8ba54a -/agent-note-durable-session-local-reminders/decision/live-delivery-lifecycle: - en: e69281749cf18500 - zh: 9ce38e39e486f2d6 -/agent-note-durable-session-local-reminders/decision/read-only-web-catalog: - en: 6ea0fcb2512d1434 - zh: ef9e430200677c9f -/agent-note-durable-session-local-reminders/alternatives-considered: - en: d86b5e8b0380c22a - zh: e6b6fce158608b08 -/agent-note-durable-session-local-reminders/verification: - en: 716fce79e742ecfa - zh: 6abb1480f1b9e2d9 -/agent-note-durable-session-local-reminders/consequences: - en: 7abf5e7d73b32153 - zh: c178fb3f1f28dee4 +/agent-note-durable-scheduled-reminders: + en: 7d312bc635f68bef + zh: f675a214bfe26dac +/agent-note-durable-scheduled-reminders/problem: + en: 85e09d88c1383aeb + zh: 2d90fcbf2fe5ad3c +/agent-note-durable-scheduled-reminders/decision: + en: a15c7c13f167e5ac + zh: 63bcc2ef60c2f344 +/agent-note-durable-scheduled-reminders/decision/explicit-absolute-time-boundary: + en: 16cc539a319f99b4 + zh: ae06872529f2227d +/agent-note-durable-scheduled-reminders/decision/bounded-fixed-rate-semantics: + en: 4a12a91087629614 + zh: 4f23eee242eeadd3 +/agent-note-durable-scheduled-reminders/decision/queue-admission-is-not-completion: + en: 1d88821105254303 + zh: 08982a1534d8fd18 +/agent-note-durable-scheduled-reminders/alternatives-considered: + en: f6d44a87797410ca + zh: 383ac9e712492c93 +/agent-note-durable-scheduled-reminders/verification: + en: 113a6d381ff1b145 + zh: 59f9693741adbbab +/agent-note-durable-scheduled-reminders/consequences: + en: 2121b1640094e601 + zh: 9420468b80ff1155 diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md index b09ab752c9..f918a6f44f 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md @@ -1,93 +1,49 @@ -# Agent Note: Durable Session-local reminders +# Agent Note: Durable scheduled reminders Status: implemented English | [中文](2026-08-05-durable-web-schedule.zh.md) +The [Host-owned scheduled messages decision](../architecture/2026-09-16-host-schedule-storage.md) owns task storage, timer activation, dispatch, delivery records, and the task catalog. This note owns the two timing decisions that survive it — the explicit absolute-time boundary and bounded fixed-rate arithmetic — and the queue-admission boundary they share. + ## Problem -A reminder created inside a conversation must remain attributable to that exact Session and survive a process restart. A process-local timer or inbox item cannot provide that durability, while a global scheduler or private database introduces a second identity, persistence, and lifecycle system. - -Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence failures, absolute calendar input, and teardown make a simple timeout insufficient. The design must distinguish a durable record from its disposable live wait, keep a fork from inheriting its parent's active reminders, and avoid spreading Schedule-specific presentation or time-zone state across unrelated components. +A reminder created inside a conversation must remain attributable to that exact Session and survive a process restart. Busy Agents, long waits, wall-clock changes, cold Sessions, and persistence failures make a simple timeout insufficient. Absolute calendar input and repeated wake-ups raise two timing questions that storage and activation choices do not answer: what an absolute time means when no recurring rule stores a zone, and how a fixed interval catches up after downtime without replaying every missed occurrence. ## Decision -The [Schedule guide](../../../../docs/user/guide/schedule.md) uses an overlay that explicitly loads `@deepseek-ai/dsh-time-context` and `@deepseek-ai/dsh-schedule`, and enables the Web bundle's otherwise-disabled `ui-schedule` row. The default Web startup graph remains inactive for Schedule. Schedule observes only root Agents published after the plugin loads and installs its three tools plus one disposable owner in that Agent scope. Cold history reads, already-published roots, child Agents, and other hosts do not activate the runtime. - -The user-visible boundary is `session-local`: the original Session runs an on-time reminder only while live, does no external notification while cold, and processes an overdue reminder after it becomes live again. Due work waits until the Agent is fully idle, then enters the ordinary next-turn queue through `followup()`; it never steers the current turn and has no independent Web receipt ([conversational delivery](../../archived/simplification/2026-08-09-conversational-schedule-delivery.md)). - -| Scenario | Durable fact | Live behavior | User-visible result | -| --- | --- | --- | --- | -| Create and manage | `schedule/change` create/delete in the original Session | Agent-scoped tools checkpoint before reads and after mutations | Stable id, UTC target, state, and `session-local` disclosure | -| Due while busy | Active create remains in the fold | Owner waits for idle maintenance, queues one follow-up, then appends dispatch | A later ordinary conversation turn | -| Several Every records are overdue | Each active record retains its earliest unaccepted anchor-aligned target | One decision selects each record's latest occurrence and advances it past now | One ordinary follow-up containing one occurrence per record | -| Process stopped or Session cold | Active create remains persisted | No timer or background scan; resume rebuilds the owner | Future target waits; overdue target is attempted | -| Fork | Parent events remain in the inherited prefix | Child fold starts at the exact `inheritedEventCount` | Parent work does not become active in the child | - -### Session-log authority and tools - -The version-1 `schedule/change` stream is the only durable Schedule authority. A create record owns a Session-local, non-reused branded id, the trimmed prompt, its rule discriminator, and UTC target. Delete and one-shot dispatch are terminal transitions. Every dispatch stores its id and decision time so the fold advances that record directly past missed occurrences. The strict decoder and pure fold reject unknown versions, extra fields, reused ids, mismatched dispatch shapes, and transitions against inactive records. A normal Session folds its complete stream; a fork folds only events at or after the `inheritedEventCount` passed into projection initialization. - -When `ctx.sessionProjections` exists, Schedule registers a strict unit that uses the same transition and publishes the complete active `ScheduleRecord[]`; the shared [projection state decision](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md) owns its initialization and restore contract. Corrupt durable input fails the existing read path rather than yielding a partial array. The browser-safe record vocabulary is exposed through the type-only `@deepseek-ai/dsh-schedule/client` subpath. - -The current rule union accepts a non-empty prompt and exactly one selector. `after_seconds` is a positive safe-integer delay whose record is `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`. `at` is either strict RFC 3339 with `Z` or a numeric offset, or structured `{ date, time, time_zone }` with an explicit zone; its record is `{ id, kind: 'at', prompt, scheduledAt }`. `every_seconds` is a safe integer of at least 300 whose `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` record stays aligned to its creation-plus-interval sequence. One-shot dispatch stores only the id; Every dispatch stores `id + acceptedAt`. Tool values derive `scheduled` or `overdue` and include `deliveryMode: 'session-local'`. - -An Agent-scoped FIFO serializes management transactions and the live owner's due transaction from preflight through post-append barriers. Every tool read first awaits `ctx.sessions.flush(session)`. Create rejects input-shape failures before the FIFO when possible, preflights, allocates an id, appends, and checkpoints again. Delete validates its id before the FIFO, preflights before deciding whether it is active, and checkpoints again only after append. List and not-found delete never answer from an unconfirmed live suffix. Failed barriers return `persistence_uncertain` rather than guessing whether an eager write committed. - -Every successful management preflight asks the live owner to recompute. A later list can therefore confirm a retained create after a previous post-append rejection and arm it without a private persistence-retry timer. - ### Explicit absolute-time boundary Natural-language interpretation and Schedule parsing are deliberately separate ([time-zone simplification](../simplification/2026-08-09-explicit-schedule-time-zone.md)). Each browser prompt carries its Host-validated IANA zone only on that durable user message. Time-context tells the model to assume that zone for otherwise-unqualified dates and times. Schedule neither imports that plugin nor stores a Session zone: the model must turn its interpretation into an offset-bearing RFC 3339 value or a local object with explicit `time_zone`. -Schedule validates exact calendar shapes, offsets, zone names, and a strictly future four-digit-year instant. A local time inside a daylight-saving gap is rejected; an overlap chooses its first, earlier instant. A successful create stores only canonical UTC `scheduledAt`, not the original offset, local fields, or zone. +Schedule validates exact calendar shapes, offsets, zone names, and a strictly future four-digit-year instant. A structured local time inside a daylight-saving gap is rejected; an overlap chooses its first, earlier instant. A one-time `at` or `after` record stores only the canonical UTC `scheduledAt`, not the input offset, local fields, or zone. A `daily`, `weekly`, or `cron` record instead stores its normalized local rule with an explicit `timeZone` ([timing selectors](../../../../packages/schedule/schedule/README.md)). ### Bounded fixed-rate semantics -Every is a fixed-duration interval, not a calendar rule. The first target is creation time plus the interval. At a due decision, integer division selects the latest sequence point at or before the sampled wall clock and the first sequence point after it. The selected occurrence is presented once and the record advances directly to the future target, so a cold Session never accumulates a replay backlog and delayed model work never shifts the sequence. +Every is a fixed-duration interval, not a calendar rule. Its first target is creation time plus the interval. At a due decision, integer division selects the latest sequence point at or before the sampled wall clock and the first sequence point after it. The selected occurrence is presented once and the record advances directly to the future target, so a cold Session never accumulates a replay backlog and delayed model work never shifts the sequence. Every, Daily, Weekly, and Cron records overdue for one Session share one message, and each record contributes only its latest occurrence ([storage and dispatch](../architecture/2026-09-16-host-schedule-storage.md)). -All distinct overdue Every records participate in one batch, each with one latest occurrence and one shared `acceptedAt`. There is no cross-record cooldown, gate, quota, or retained batch timestamp. A five-minute minimum bounds wake and model-request frequency. If the next sequence point would exceed the four-digit-year storage range, dispatch terminates that record. +A five-minute minimum bounds wake and model-request frequency. There is no cross-record cooldown, gate, quota, or retained batch timestamp. If the next sequence point would exceed the four-digit-year storage range, dispatch terminates that record. -Calendar and Cron expressions are deliberately absent ([bounded recurrence simplification](../../archived/simplification/2026-08-09-bounded-fixed-rate-schedule.md)); supporting them would add a time-zone-sensitive calendar language, evaluator dependency, validation surface, and tzdata replay policy unrelated to fixed-rate reminders. +### Queue admission is not completion -### Live delivery lifecycle - -The Agent-scoped owner derives its earliest target from the durable fold. Long targets use bounded timer segments, and every wake reads the wall clock again, so a rollback cannot fire early and a forward jump becomes overdue. Due one-shots have priority and are admitted one at a time; otherwise every overdue Every record enters one batch in target and creation order. If a turn or maintenance task owns the Agent, `runMaintenance()` rejects the claim; the records stay active and one `whenIdle()` wait triggers another attempt. A rejected preflight or contained framing/enqueue failure also leaves them active without starting a private retry timer. - -The accepted path clears pending persistence and claims the true idle phase. It refolds the exact Session suffix, samples the decision clock, constructs fixed reminder framing with JSON-escaped values, synchronously queues one `followup()`, and appends dispatch before releasing maintenance. A one-shot appends an id-only terminal dispatch. A fixed-rate batch appends one `id + acceptedAt` transition per participating record. Waking input remains parked until release, so the message cannot be claimed before dispatch enters the log; afterward the owner checkpoints dispatch. - -Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. An append failure faults that owner because the message may already be queued. Agent or plugin disposal cancels timers, stops new work, unwinds tool registrations, and awaits in-flight work without deleting durable records. A crash after follow-up admission but before durable dispatch can repeat the reminder after recovery; the design makes no exactly-once promise. - -### Read-only Web catalog - -The Schedule overlay enables the otherwise-disabled [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) client together with the Host service. The complete active projection also feeds [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.md). This note owns that opt-in read-only presentation boundary: the projection is current active state, not a dispatch or delivery receipt, so ordinary Assistant turns remain the delivery presentation. The catalog is a fixed `document.body` portal whose left edge follows the trigger when space permits and shifts left to retain a 16px viewport margin near the right edge. `useAnchoredPosition` owns measurement and resize, captured-scroll, panel-resize, and cleanup behavior; Schedule supplies the trigger and portal refs, bottom placement, a 5px gap, and the existing inside/outside dismissal boundary without adding a general popover abstraction. +Dispatch records queue admission, not model completion or user receipt. Framing or synchronous enqueue failure appends no dispatch. A crash after follow-up admission but before the durable receipt can repeat the reminder after recovery; the design makes no exactly-once promise. ## Alternatives considered -**Use `ctx.jobs`.** Jobs own process-local work, outcomes, and notifications rather than Session-log state and conversation follow-ups. - -**Store reminders in a private database or global scheduler.** This could run cold Sessions but requires a second identity map, startup scan, ownership lease, crash protocol, and notification policy. +**Use `ctx.jobs`.** Jobs own process-local work, outcomes, and notifications rather than durable task state and conversation follow-ups. **Persist a Session time zone and infer local `at`.** This spreads one interpretive default through Session core, Host create/fork, persistence formats, clients, and mismatch recovery. Request-local model guidance plus an explicit tool boundary deletes that coupling. -**Keep an independent durable Web receipt.** Dispatch is an internal queue fact, not the user's reminder. Rendering the ordinary assistant answer avoids a second delivery meaning and removes Schedule code from Host and client layers. +**Claim dispatch before `followup()` or add exactly-once fencing.** Claim-first can silently lose a reminder when enqueue fails. Cross-process exactly-once needs a lease, outbox, acknowledgement, and downstream idempotency boundary outside this design. -**Add a general recurring-rule engine.** Fixed-duration intervals need only anchor arithmetic. A shared recurrence abstraction, global admission gate, and calendar evaluator would enlarge replay and runtime state without serving the retained product behavior. - -**Claim dispatch before `followup()` or add exactly-once fencing.** Claim-first can silently lose a reminder when enqueue fails. Cross-process exactly-once needs a lease, outbox, acknowledgement, and downstream idempotency boundary outside this Session-local scope. - -**Adopt existing roots or register global tools.** Late adoption makes plugin load order activate unseen timers and exposes tools outside the supported root composition. +**Add a general recurring-rule engine for fixed intervals.** Fixed-duration intervals need only anchor arithmetic. A shared recurrence abstraction, global admission gate, and calendar evaluator would enlarge replay and runtime state without serving fixed-rate behavior. The cron selector is a separate wall-clock rule with its own Host-owned dialect and evaluation ([cron input](../../../../docs/subsystems/schedule.md#cron-wall-clock-input)). ## Verification -Package tests pin strict replay, one-shot and Every transitions, creation-anchor arithmetic, latest-only catch-up, multi-record batching, fork suffixes, id reuse, offset and local-calendar profiles, IANA validation, daylight-saving gaps and overlaps, time bounds, timer segmentation, wall-clock movement, overdue admission, fixed framing, enqueue and append failures, barrier recovery, projection registration and restoration, registration rollback, and quiescent disposal at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. A production JSONL restart test proves one overdue reminder dispatches through the real Agent lifecycle and does not redispatch after another restart. Focused client suites own catalog and sidebar behavior, including the body portal, spacious left alignment, portal-inside pointer handling, outside dismissal, Escape, live empty, and timer cleanup. The shared primitive suite owns the positioning hook's resize, captured-scroll, panel-resize, and cleanup lifecycle. Keyless assembled Web scenarios retain ordinary After/At/Every delivery evidence plus one 900×900 Schedule-catalog smoke for overlay reachability, fixed portal placement, right-edge clamping, width and overflow, ordinary/search alarms, narrow dark layout, a light-theme browser screenshot, and one live empty update. +Package tests pin fixed-rate arithmetic, creation anchors, latest-only catch-up, batch selection, IANA validation, daylight-saving gaps and overlaps, and time bounds at per-file 100% coverage. A property test compares Every calculation and replay across varied intervals and skipped spans. ## Consequences -- Reminder state survives restart through ordinary Session persistence without a new database or public service. -- Cold Sessions do no work and send no external notification; reopening one may deliver overdue work. - Absolute input is deterministic without persistent Session-zone state or a dependency from Schedule to time-context. -- Users see normal conversation output; dispatch never overstates model success or acknowledgement. -- Opt-in Web users can inspect the complete active set and recognize cache-known active Sessions in ordinary or search rows without creating a second durable state, runtime signal, or delivery meaning. -- Each live root adds only fold-derived timers, an optional idle wait, and one in-flight operation. -- Fixed-rate recurrence is bounded by a five-minute minimum, latest-only catch-up, and one batched occurrence per overdue record; calendar recurrence remains outside this product boundary. +- A fixed interval stays aligned to its creation anchor, presents one occurrence per overdue record, and cannot accumulate a replay backlog. +- Delivery never overstates model success or acknowledgment, and a crash between queue admission and the durable receipt can repeat a message. diff --git a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md index 8d84be3e35..dec8916db7 100644 --- a/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md @@ -1,93 +1,49 @@ -# Agent Note: 持久、仅限 Session 内的提醒 +# Agent Note: 持久定时提醒 Status: implemented [English](2026-08-05-durable-web-schedule.md) | 中文 +[Host 拥有的定时消息决策](../architecture/2026-09-16-host-schedule-storage.zh.md)拥有任务存储、timer 激活、投递、发送记录和任务目录。本记录拥有在该决策之下仍然成立的两个时间决策——显式绝对时间边界与有界固定速率计算——以及二者共有的队列准入边界。 + ## 问题 -在对话中创建的提醒必须始终归属于确切的那个 Session,并且跨进程重启存活。进程本地 timer 或 inbox 项无法提供这种持久性,而全局 scheduler 或私有数据库又会引入第二套身份、持久化和生命周期系统。 - -繁忙的 Agent(智能体)、长等待、墙钟变化、cold Session、fork、持久化失败、绝对日历输入和资源释放,使简单 timeout 无法满足要求。设计必须区分持久记录与可丢弃的 live wait,阻止 fork 继承父 Session 的活动提醒,并避免把 Schedule 专属的呈现或时区状态扩散到无关组件。 +在对话中创建的提醒必须始终归属于确切的那个 Session,并且跨进程重启存活。繁忙的 Agent(智能体)、长等待、墙钟变化、cold Session 和持久化失败使简单 timeout 无法满足要求。绝对日历输入与重复唤醒带来两个计时问题,而存储与激活方式的选择并不能回答它们:当重复规则不保存时区时,绝对时间意味着什么;固定间隔在停机后如何追赶,而不重放每一个错过的发生时点。 ## 决策 -[Schedule 指南](../../../../docs/user/guide/schedule.zh.md)使用显式加载 `@deepseek-ai/dsh-time-context` 与 `@deepseek-ai/dsh-schedule`,并启用 Web bundle 中默认 disabled 的 `ui-schedule` row 的 overlay。默认 Web 启动图不会激活 Schedule。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他 host 都不会激活 runtime。 - -用户可见边界是 `session-local`:原 Session 只有在 live 时才会准时运行提醒,cold 期间不发送任何外部通知;该 Session 再次 live 后才会处理 overdue 提醒。到期工作会等待 Agent 完全 idle,再通过 `followup()` 进入普通的下一轮队列;它绝不会中途引导当前轮次,也没有独立 Web 回执([对话式交付](../../archived/simplification/2026-08-09-conversational-schedule-delivery.md))。 - -| 场景 | 持久事实 | live 行为 | 用户可见结果 | -| --- | --- | --- | --- | -| 创建与管理 | 原 Session 中的 `schedule/change` create/delete | Agent-scoped 工具在读取前、变更后执行 checkpoint | 稳定 id、UTC 目标、状态与 `session-local` 说明 | -| 到期时繁忙 | 活动 create 仍在 fold 中 | owner 等待 idle maintenance,排入一个 follow-up,再追加 dispatch | 后续一个普通对话轮次 | -| 多条 Every 记录逾期 | 每条活动记录都保留最早一个尚未接受且与锚点对齐的目标 | 一次决策选择每条记录的最新发生时点,并将其推进到当前时刻之后 | 一个普通 follow-up,其中每条记录各有一个发生时点 | -| 进程停止或 Session cold | 活动 create 仍在 persistence 中 | 不存在 timer 或后台扫描;resume 重建 owner | 未来目标继续等待;overdue 目标会被尝试 | -| fork | 父 event 留在继承前缀 | child fold 从精确 `inheritedEventCount` 开始 | 父工作不会在 child 中变为活动状态 | - -### Session 日志权威与工具 - -版本 1 `schedule/change` stream 是唯一持久的 Schedule 权威。create 记录拥有一个 Session 内不复用的品牌 id、trim 后的提示词、规则判别字段和 UTC 目标。delete 与一次性 dispatch 是终结转换。Every dispatch 会存储 id 与决策时点,使 fold 将该记录直接推进到错过的发生时点之后。严格 decoder 与纯 fold 会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的 dispatch,以及针对非活动记录的转换。普通 Session 折叠完整 stream;fork 只折叠传入 projection 初始化的 `inheritedEventCount` 位置及其后的 event。 - -`ctx.sessionProjections` 存在时,Schedule 会注册一个复用同一 transition 的严格单元,并发布完整的活动 `ScheduleRecord[]`;共享的 [projection state 决策](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)拥有其初始化与 restore 约定。损坏的持久输入会使既有读取路径失败,而不会产生部分数组。浏览器安全的记录词汇通过纯类型子路径 `@deepseek-ai/dsh-schedule/client` 暴露。 - -当前规则 union 接受非空提示词和恰好一个 selector。`after_seconds` 是正的安全整数 delay,其记录为 `{ id, kind: 'after', prompt, afterSeconds, scheduledAt }`。`at` 可以是带 `Z` 或数值偏移量且严格符合 RFC 3339 的值,也可以是带显式时区的结构化 `{ date, time, time_zone }`;其记录为 `{ id, kind: 'at', prompt, scheduledAt }`。`every_seconds` 是不小于 300 的安全整数,其 `{ id, kind: 'every', prompt, everySeconds, scheduledAt }` 记录始终与从创建时刻加一个间隔开始的序列对齐。一次性 dispatch 只存储 id;Every dispatch 存储 `id + acceptedAt`。工具值派生 `scheduled` 或 `overdue`,并包含 `deliveryMode: 'session-local'`。 - -一个 Agent-scoped FIFO 会将管理事务与 live owner 的到期事务从 preflight 到 post-append barrier 全程串行化。每项工具读取都会先等待 `ctx.sessions.flush(session)`。create 会尽可能在进入 FIFO 前拒绝输入形状错误,随后执行 preflight、分配 id、追加记录并再次 checkpoint。delete 会在进入 FIFO 前验证 id,在判断其是否活动前执行 preflight,并且只在追加后再次 checkpoint。list 与 not-found delete 绝不会根据未经确认的 live 后缀作答。barrier 失败会返回 `persistence_uncertain`,而不是猜测 eager write 是否已经提交。 - -每次成功的管理 preflight 也会要求 live owner 重新计算。因此,如果先前的 post-append 被拒绝,后续 list 可以确认保留的 create 并将其 arm,而无需私有的 persistence 重试 timer。 - ### 显式绝对时间边界 自然语言解释与 Schedule 解析被有意分开([时区简化](../simplification/2026-08-09-explicit-schedule-time-zone.zh.md))。每条浏览器提示词只在其对应的持久 user message 上携带由 Host 校验过的 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该时区。Schedule 既不导入该插件,也不存储 Session 时区:模型必须把其解释结果转换为带偏移量的 RFC 3339 值,或带显式 `time_zone` 的本地对象。 -Schedule 会校验精确的日历形状、偏移量、时区名称,以及一个严格位于未来、年份为四位数的时点。落在夏令时缺口内的本地时间会被拒绝;遇到重叠时会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,不会存储原始偏移量、本地字段或时区。 +Schedule 会校验精确的日历形状、偏移量、时区名称,以及严格位于未来、年份为四位数的时点。结构化本地时间落在夏令时缺口内会被拒绝;遇到重叠时选择第一次出现的较早时点。一次性 `at` 或 `after` 记录只存储规范化的 UTC `scheduledAt`,不存储输入的偏移量、本地字段或时区。`daily`、`weekly` 或 `cron` 记录则存储规范化后的本地规则及其显式 `timeZone`([时间选择器](../../../../packages/schedule/schedule/README.zh.md))。 ### 有界固定速率语义 -Every 是固定时长间隔,而不是日历规则。第一个目标是创建时刻加上一个间隔。作出到期决策时,整数除法会选出不晚于所采样墙钟的最新序列点,以及其后的第一个序列点。选中的发生时点只呈现一次,记录会直接推进到未来目标,因此 cold Session 绝不会积累回放任务,延迟执行的模型工作也绝不会使该序列漂移。 +Every 是固定时长间隔,而不是日历规则。其首个目标是创建时刻加上该间隔。作出到期决策时,整数除法选出不晚于所采样墙钟的最新序列点,以及其后的第一个序列点。选中的发生时点只呈现一次,记录直接推进到未来目标,因此 cold Session 绝不会积累回放任务,延迟执行的模型工作也绝不会使该序列漂移。同一 Session 内到期的 Every、Daily、Weekly 和 Cron 记录共用一条消息,每条记录只贡献其最新一次发生时点([存储与投递](../architecture/2026-09-16-host-schedule-storage.zh.md))。 -所有不同的逾期 Every 记录都会参与同一个批次,每条记录各自提供一个最新发生时点,并共享同一个 `acceptedAt`。系统不存在跨记录的冷却、门控、配额或保留的批次时间戳。至少 5 分钟的限制约束了唤醒与模型请求频率。如果下一个序列点会超出四位年份存储范围,dispatch 会终结该记录。 +至少五分钟的下限约束唤醒与模型请求频率。系统不存在跨记录的冷却、门控、配额或保留的批次时间戳。如果下一个序列点会超出四位年份存储范围,dispatch 会终结该记录。 -日历表达式与 Cron 表达式被有意排除([有界周期性简化](../../archived/simplification/2026-08-09-bounded-fixed-rate-schedule.md));支持这些表达式需要增加时区敏感的日历语言、求值器依赖、校验范围和 tzdata 回放策略,而这些都与固定速率提醒无关。 +### 队列准入不是完成 -### Live 交付生命周期 - -Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界 timer 分段,每次 wake 都会重新读取墙钟,因此回拨不会提前触发,前跳则会形成 overdue。已到期的一次性提醒优先,每次准入一条;否则,所有逾期 Every 记录会按目标时间和创建顺序进入同一个批次。如果 Agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝此次认领;这些记录保持活动,并由一次 `whenIdle()` wait 触发另一次尝试。被拒绝的 preflight 或被收容的 framing/入队失败同样会使其保持活动,但不会启动私有重试 timer。 - -获得准入的路径会刷新所有 pending persistence 并认领真正的 idle phase。它会重新折叠确切的 Session 后缀、采样 decision clock、用经过 JSON 转义的值构造固定提醒 framing、同步排入一个 `followup()`,并在释放 maintenance 前追加 dispatch。一次性提醒会追加只含 id 的终结 dispatch。固定速率批次会为每条参与记录追加一个 `id + acceptedAt` 转换。触发唤醒的 input 会保持 parked,直到 maintenance 释放,因此在 dispatch 进入日志前,消息不会被认领;随后 owner 会为 dispatch 执行 checkpoint。 - -dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。Agent 或插件 dispose 会取消 timer、停止新工作、撤销工具注册,并等待进行中的工作,且不会删除持久记录。follow-up 获得准入后、持久 dispatch 前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。 - -### 只读 Web 目录 - -Schedule overlay 会把默认禁用的 [`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md) client 与 Host 服务一同启用。完整活动 projection 也会交给 [`dsh-client-ui-workspace`](../../../../packages/client/ui-workspace/README.zh.md)。本 Note 拥有这条 opt-in 只读呈现边界:该 projection 表示当前活动状态,而非 dispatch 或交付回执,因此普通 Assistant 轮次仍是交付呈现。目录是挂到 `document.body` 的 fixed portal;空间足够时左边缘跟随触发按钮,靠近视口右侧时向左避让并保留 16px 边距。`useAnchoredPosition` 拥有测量以及 resize、捕获阶段 scroll、面板 resize 与清理行为;Schedule 提供触发器与 portal ref、bottom 放置、5px 间距和既有内外 dismissal 边界,不增加通用 popover 抽象。 +dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。follow-up 获得准入后、持久回执前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。 ## 已考虑的替代方案 -**使用 `ctx.jobs`。** Task 拥有进程本地工作、结果和通知,而不是 Session 日志状态和对话 follow-up。 - -**把提醒存入私有数据库或全局 scheduler。** 这样可以运行 cold Session,却需要第二套身份映射、启动扫描、ownership lease、崩溃协议和通知策略。 +**使用 `ctx.jobs`。** Task 拥有进程本地工作、结果和通知,而不是持久任务状态和对话 follow-up。 **持久化 Session 时区并推断本地 `at`。** 这会让一个解释默认值扩散到 Session core、Host create/fork、持久化格式、client 和不匹配恢复中。请求本地的模型指导与显式工具边界消除了这种耦合。 -**保留独立的持久 Web 回执。** dispatch 是内部队列事实,而不是用户的提醒。渲染普通 assistant 回答既避免了第二种交付含义,也从 Host 与 client 层移除了 Schedule 代码。 +**在 `followup()` 前认领 dispatch,或增加 exactly-once fencing。** claim-first 会在入队失败时静默丢失提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,超出了本设计范围。 -**增加通用周期规则引擎。** 固定时长间隔只需要锚点运算。共享的周期抽象、全局准入门控和日历求值器会扩大回放与运行时状态,却不能服务于保留的产品行为。 - -**在 `followup()` 前认领 dispatch,或增加 exactly-once fencing。** claim-first 会在入队失败时静默丢失提醒。跨进程 exactly-once 需要 lease、outbox、acknowledgement 与下游幂等边界,超出了此 Session-local 范围。 - -**接管既有根或注册全局工具。** 晚接管会让插件加载顺序激活不可见的 timer,并把工具暴露到受支持的根组合之外。 +**为固定间隔增加通用周期规则引擎。** 固定时长间隔只需要锚点运算。共享的周期抽象、全局准入门控和日历求值器会扩大回放与运行时状态,却不能服务于固定速率行为。cron 选择器是独立的钟表规则,拥有自己的 Host 所有方言与求值([cron 输入](../../../../docs/subsystems/schedule.zh.md#cron-wall-clock-input))。 ## 验证 -包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。聚焦 client suite 拥有目录与侧边栏行为,包括 body portal、空间充足时的左对齐、portal 内指针处理、外部 dismissal、Escape、live empty 与 timer 清理。共享 primitive suite 拥有定位 hook 的 resize、捕获阶段 scroll、面板 resize 与清理生命周期。无密钥组装 Web 场景保留普通 After/At/Every 交付证据,再由一个 900×900 Schedule 目录 smoke 覆盖 overlay 可达性、fixed portal 定位、右侧钳制、宽度与 overflow、普通/搜索闹钟、窄屏暗色布局、浅色主题浏览器截图与一次 live empty 更新。 +包测试以逐文件 100% coverage 固定固定速率计算、创建锚点、只追赶最新一次、批次选择、IANA 校验、夏令时缺口与重叠以及时间边界。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。 ## 后果 -- 提醒状态通过普通 Session persistence 跨重启存活,无需新数据库或公开 service。 -- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 工作。 - 无需持久 Session 时区状态或从 Schedule 到 time-context 的依赖,绝对时间输入仍然具有确定性。 -- 用户看到普通对话输出;dispatch 绝不会夸大模型成功或 acknowledgement。 -- 显式启用 Schedule 的 Web 用户可以查看完整活动集合,并在普通行或搜索结果中辨认 cache 已知的活动 Session,而不会引入第二份持久状态、runtime 信号或第二种交付含义。 -- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。 -- 固定速率周期性受到至少 5 分钟、只追赶最新一次,以及每条逾期记录只在一个批次中贡献一个发生时点的约束;日历周期性仍在此产品边界之外。 +- 固定间隔始终与其创建锚点对齐,每条逾期记录只呈现一个发生时点,且不会积累回放任务。 +- 投递绝不会夸大模型成功或 acknowledgement;队列准入与持久回执之间发生崩溃可能使消息重复。 diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml index cda575574b..bf652c02d6 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.i18n.yaml @@ -9,11 +9,11 @@ en: 7926983089aebab2 zh: 366e32db00674a83 /agent-note-archiving-a-session-with-running-work/decision: - en: ba02c1b57e6ca76b - zh: 2908fcda98c4734e + en: 23adfbe72d671d6c + zh: 6fec976acfa54720 /agent-note-archiving-a-session-with-running-work/alternatives-considered: - en: c9d0a5c8d9924ad6 - zh: f848ba8fa3c0d030 + en: 708268e1fe4b4b75 + zh: 64a8f6d6f333e090 /agent-note-archiving-a-session-with-running-work/consequences: - en: 977e6f36e62c0618 - zh: 706de6bc6bb06282 + en: cb3fdefd60483d62 + zh: 87d3cead6dc8c37c diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md index 2df18c00e6..4bc3bab644 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.md @@ -14,7 +14,7 @@ Archive never hides running work. The registry owns the rule and declares one ca - **Refuse by default.** `archiveSession(sessionId)` rejects a non-empty activity answer with `WorkspaceActiveSessionError`; the API maps it to `workspace/session-active`, whose details carry the families with item ids and labels. A caller that did not ask to stop work never hides it, whichever client or SDK it is. - **Stop on request, after the write.** `archiveSession(sessionId, { stopActivity: true })` skips the activity check, writes the archive, and then dispatches the stop event: the durable archive set is what the pre-step gate reads, so every wake a stop induces — a cancelled child's settlement, a queued follow-up — is already blocked. The stops are issued and never awaited to settlement, so the Remote returns once every provider's request is out and the user's row hides at once; a stop that fails is a logged warning, not a reason to undo the archive. -- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active reminder as a management delete in its transaction queue — the same durable change the `schedule_delete` tool records, between the same two persistence barriers — answering from its owned runtime's fold of the live log rather than from the projection registry, which is a Client view. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. +- **The stop is the user's stop.** The Agent registry cancels the turn with `agent.cancel({ kind: 'user' })` — the stop button's path, so the aborted tool call settles as a `tool/result` and the turn ends as `aborted`, but without the button's `keepInbox`, so queued input is discarded with a logged inbox splice. The job registry seam kills the owned jobs, installed by its constructor for every implementation through the abstract `list` and `kill`. The Subagent runtime cancels running subagent descendants (durable lineage, subagent origin, any depth, never a fork) as their parent would. Each request is contained on its own, so one child or job that throws does not keep the others running. The Schedule plugin deletes every active Host task stored for the Session ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)) inside one slot of the same serialized queue the tools use. It reads the stored rows rather than a runtime fold of the Session log, because a Host task outlives its Session's Agent and is the only place a due reminder can be seen. Each provider owns exactly the vocabulary it already has: the Agent registry knows turns, the job seam knows jobs, the Subagent runtime knows children, the Schedule plugin knows reminders; the API Session Controller keeps only the archived-lineage step gate. No new event type and no write inside another owner's turn, so a restored Session continues from a regularly ended log and unarchive brings nothing back that was stopped. - **Archived means no model step.** The API Session Controller's `agent/pre-step` listener rejects a step proposed for an archived Session or for a subagent descendant of one, which the loop ends as `blocked` without a request. This closes the window in which a late waking delivery — a subagent settlement, a queued follow-up, a turn started between the activity answer and the write — would run a hidden turn, and keeps a child whose cancel was ignored from working for a parent nobody can see; unarchiving lifts the gate for the lineage. - **The refusal is the confirmation.** The sidebar sends the plain archive first; a quiet Session archives without a dialog as before. The `workspace/session-active` refusal opens the stop-and-archive dialog, whose list comes from the Host's answer rather than a client-side mirror of running state, so the dialog names exactly what the Host will stop. Confirming resends with `stopActivity`; the notice offers undo, which restores the Session without resuming the stopped work. @@ -32,10 +32,10 @@ Archive never hides running work. The registry owns the rule and declares one ca **Report the families from the API Session Controller.** The controller sees every live Agent, the job registry, and the lineage fields on Session headers, so it could enumerate turns, children, and jobs itself — but then it would know each owner's vocabulary (descriptors and labels, the parent-cause cancel, job ownership). The Agent registry, the job seam, and the Subagent runtime already own that vocabulary, so each family lives with its owner; the controller keeps only the lineage step gate, which reads the archive set and header fields alone. The gate cannot move into `dsh-workspace` in turn: the owners now depend on its types, so the workspace package cannot depend on the Agent event vocabulary without a cycle. -**Refuse cold Sessions with active reminders.** A cold reminder fires only after a resume; an archived Session cannot be opened from the sidebar, and a resume through another operation meets the pre-step gate, so nothing runs hidden; only live Sessions report the `schedule` family. +**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 ([archive admission reads the stored tasks](2026-09-22-archive-admission-reads-stored-tasks.md)). ## Consequences -An archived Session is guaranteed idle: it either was quiet, or its work was stopped the way the user stops it, and no step runs for it until it is restored. The pin-and-archive decision's "archive needs no confirmation" holds for quiet Sessions only; the running case asks. Stop settlement is best effort: a job that ignores kill or a child that ignores cancel is logged, and the pre-step gate keeps the Session from spending model requests meanwhile. The activity check and the archive write are two steps, so a turn starting between them is hidden, and every step whose pre-step precedes the write still runs with its tool calls, until the first step after the write is blocked. Two residues follow from the write-first order and the gate. A crash between the archive write and the Schedule plugin's delete barrier leaves an archived Session whose reminders are still recorded; unarchiving shows them again, as if the stop had never been requested. A cold archived Session reports no reminders, and an operation that resumes its Agent — rename, model, or queue edits address a Session without unarchiving it — re-arms them: a due one-shot then dispatches its follow-up into a turn the gate ends as `blocked`, so that occurrence is consumed without a model step and is not replayed after unarchive. +An archived Session is guaranteed idle: it either was quiet, or its work was stopped the way the user stops it, and no step runs for it until it is restored. The pin-and-archive decision's "archive needs no confirmation" holds for quiet Sessions only; the running case asks. Stop settlement is best effort: a job that ignores kill or a child that ignores cancel is logged, and the pre-step gate keeps the Session from spending model requests meanwhile. The activity check and the archive write are two steps, so a turn starting between them is hidden, and every step whose pre-step precedes the write still runs with its tool calls, until the first step after the write is blocked. Two residues follow from the write-first order and the gate. A crash between the archive write and the Schedule plugin's stop leaves an archived Session whose stored tasks are still armed; unarchiving shows them again, as if the stop had never been requested, and when such a row's target passes the Schedule runtime selects it on its own timer and resolves the Session's Agent itself, so a one-shot dispatches its follow-up into a turn the gate ends as `blocked` and that occurrence is consumed without a model step and is not replayed after unarchive. Admission itself reports the stored tasks of a Session without a live Agent, so such a Session is refused before that residue can start. Peer products surveyed for this choice: Codex and Kimi Code stop then archive; Claude Code stops by starving the remote session of writes; Reasonix and MiniMax refuse a busy Session, MiniMax after a bounded cancel; Pi has no archive but aborts before every session switch; OpenCode neither refuses nor stops. Every product that stops does so through its ordinary cancel path so the log ends regularly. diff --git a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md index 423a01bd2f..c7a912fa65 100644 --- a/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md +++ b/.agents/notes/implemented/feature/2026-09-21-archive-stops-running-session-work.zh.md @@ -14,7 +14,7 @@ - **默认拒绝。** `archiveSession(sessionId)` 对非空的活动答案以 `WorkspaceActiveSessionError` 拒绝;API 把它映射为 `workspace/session-active`,details 携带各族及各项的 id 与名称。没有要求停止工作的调用方绝不会把工作藏起来,无论它是哪个客户端或 SDK。 - **按请求停止,先写后停。** `archiveSession(sessionId, { stopActivity: true })` 跳过活动检查,先写入归档,再派发停止事件:pre-step 门禁读取的正是持久化的归档集合,因此停止所引发的每一次唤醒——被取消子代理的结算、排队的 follow-up——都已被拦下。停止请求只发出、从不等待收敛,因此 Remote 在每个提供方的请求发出后即返回,用户的行随即隐藏;停止失败只是一条 warn 日志,不是撤销归档的理由。 -- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件把每条活动提醒作为其事务队列中的管理删除来删除——与 `schedule_delete` 工具相同的持久化变更,处于同样的两道持久化屏障之间——答案来自其归属 runtime 对活日志的 fold,而不是投影注册表这个 Client 视图。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 +- **停止即用户的停止。** Agent 注册表用 `agent.cancel({ kind: 'user' })` 取消回合——与停止按钮同一条路径,因此被打断的工具调用以 `tool/result` 收口、回合以 `aborted` 结束,但不带按钮的 `keepInbox`,因此排队输入被丢弃并记录一次收件箱拼接。任务注册表接缝 kill 所属任务,由其构造函数只通过抽象的 `list` 与 `kill` 为每个实现安装。Subagent runtime 以父身份取消运行中的子代理子孙(持久化血缘、subagent 来源、任意深度、从不包括 fork)。每个请求各自兜住,一个抛错的子代理或任务不会让其余的继续跑。Schedule 插件在与工具相同的串行队列的一个槽位里删除该 Session 已存储的全部活动宿主任务([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。它读已存储的行,而不是对会话日志做 runtime fold,因为宿主任务比其 Session 的 Agent 活得更久,是到期提醒唯一能被看见的地方。每个提供方只拥有它本来就有的词汇:Agent 注册表认识回合,任务接缝认识任务,Subagent runtime 认识子代理,Schedule 插件认识提醒;API Session Controller 只保留已归档血缘的步骤门禁。没有新事件类型,也不在别的所有者的回合内写入,因此恢复后的 Session 从一份正常收尾的日志继续,取消归档不会带回任何被停止的东西。 - **已归档即不跑模型步。** API Session Controller 的 `agent/pre-step` 监听器拒绝为已归档 Session 或其子代理子孙提出的步骤,loop 以 `blocked` 收口且不发请求。这封住了迟到的唤醒投递——子代理结算、排队的 follow-up、在活动作答与写入之间开始的回合——跑出隐藏回合的窗口,也让无视 cancel 的子代理无法替一个谁也看不见的父级干活;取消归档即为整条血缘解除门禁。 - **拒绝即确认。** 侧栏先发普通归档;静止的 Session 一如既往无对话框直接归档。`workspace/session-active` 拒绝会打开"停止并归档"对话框,其清单来自宿主的答案而非客户端对运行状态的镜像,因此对话框列出的正是宿主将要停止的内容。确认后带 `stopActivity` 重发;提示提供撤销,撤销只恢复 Session,不会让被停止的工作继续。 @@ -32,10 +32,10 @@ **由 API Session Controller 报告各族。** 控制器看得到每个活 Agent、任务注册表和 Session header 上的血缘字段,本可以自己枚举回合、子代理和任务——但那样它就得认识每个 owner 的词汇(描述符与名称、父级原因的取消、任务归属)。Agent 注册表、任务接缝与 Subagent runtime 本来就拥有这些词汇,所以每一族都跟着自己的 owner;控制器只保留只读归档集合与 header 字段的血缘步骤门禁。门禁也不能反过来搬进 `dsh-workspace`:各 owner 现在依赖它的类型,workspace 包再依赖 Agent 的事件词汇就会成环。 -**拒绝带活动提醒的冷 Session。** 冷提醒只在 resume 后才触发;已归档的 Session 无法从侧栏打开,经其他操作 resume 也会撞上 pre-step 门禁,所以没有东西在隐藏运行;只有活 Session 报告 `schedule` 族。 +**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档,其到期行随后投递进一个被门禁以 `blocked` 收口的回合,在没有模型步的情况下消耗掉这次触发([归档准入读取已存储的任务](2026-09-22-archive-admission-reads-stored-tasks.zh.md))。 ## 后果 -已归档的 Session 保证空闲:它要么本来就静止,要么其工作已按用户停止的方式停止,且在恢复之前不会为它跑任何一步。置顶与归档决策里的"归档无需确认"仅对静止 Session 成立;运行中的情形会先询问。停止的收敛是尽力而为:无视 kill 的任务或无视 cancel 的子代理会被记录,pre-step 门禁在此期间阻止该 Session 消耗模型请求。活动检查与归档写入是两步,其间开始的回合会被隐藏,pre-step 先于该写入的每一步连同其工具调用照常执行,直到写入之后的第一步被阻断。先写后停与门禁留下两处残余。在归档写入与 Schedule 插件的删除屏障之间崩溃,会留下一个提醒仍被记录的已归档 Session;取消归档时它们再次出现,与从未请求过停止时一样。冷的已归档 Session 不报告提醒,而恢复其 Agent 的操作——重命名、模型、队列编辑都按 Session 寻址而不取消归档——会重新武装它们:到期的一次性提醒把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发因此在没有模型步的情况下被消耗,取消归档后也不会重放。 +已归档的 Session 保证空闲:它要么本来就静止,要么其工作已按用户停止的方式停止,且在恢复之前不会为它跑任何一步。置顶与归档决策里的"归档无需确认"仅对静止 Session 成立;运行中的情形会先询问。停止的收敛是尽力而为:无视 kill 的任务或无视 cancel 的子代理会被记录,pre-step 门禁在此期间阻止该 Session 消耗模型请求。活动检查与归档写入是两步,其间开始的回合会被隐藏,pre-step 先于该写入的每一步连同其工具调用照常执行,直到写入之后的第一步被阻断。先写后停与门禁留下两处残余。在归档写入与 Schedule 插件的停止之间崩溃,会留下一个已存储任务仍被武装的已归档 Session;取消归档时它们再次出现,与从未请求过停止时一样;当这类行的目标时刻过去,Schedule runtime 会按自己的定时器选中它并自行 resolve 该 Session 的 Agent,一次性提醒于是把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发因此在没有模型步的情况下被消耗,取消归档后也不会重放。准入本身会报告没有活 Agent 的 Session 的已存储任务,因此这类 Session 在残余发生之前就被拒绝。 为此决策调研的同行产品:Codex 与 Kimi Code 先停止再归档;Claude Code 通过让远端会话无法写入来停止;Reasonix 与 MiniMax 拒绝忙碌的 Session,MiniMax 在有界取消之后才拒绝;Pi 没有归档但在每次切换会话前 abort;OpenCode 既不拒绝也不停止。每一个会停止的产品都走自己的常规取消路径,以便日志正常收尾。 diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml new file mode 100644 index 0000000000..cc02859b0e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-22-archive-admission-reads-stored-tasks.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/feature/2026-09-22-archive-admission-reads-stored-tasks.md +/agent-note-archive-admission-reads-the-stored-tasks: + en: 8be4b1d210d5d8bf + zh: e9124b48467818b3 +/agent-note-archive-admission-reads-the-stored-tasks/problem: + en: 093de39b40f7cc97 + zh: 50c712bde174bc96 +/agent-note-archive-admission-reads-the-stored-tasks/decision: + en: afbf818442c0794a + zh: 4749e63d9757bbbb +/agent-note-archive-admission-reads-the-stored-tasks/alternatives-considered: + en: 099360f2c97a50ea + zh: eac4746c4602f152 +/agent-note-archive-admission-reads-the-stored-tasks/consequences: + en: cb2883b34c2c73fa + zh: 9bb1489c9711cf99 diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md new file mode 100644 index 0000000000..2073ae420d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.md @@ -0,0 +1,27 @@ +# Agent Note: Archive admission reads the stored tasks + +Status: implemented + +English | [中文](2026-09-22-archive-admission-reads-stored-tasks.zh.md) + +## Problem + +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. + +## 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. + +## 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. + +**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. + +**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. + +## 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. diff --git a/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md new file mode 100644 index 0000000000..c95fb280c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-22-archive-admission-reads-stored-tasks.zh.md @@ -0,0 +1,27 @@ +# Agent Note:归档准入读取已存储的任务 + +状态:implemented + +[English](2026-09-22-archive-admission-reads-stored-tasks.md) | 中文 + +## 问题 + +归档准入在写入归档前询问每个提供方:该 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 提供方从已存储的宿主任务行回答归档准入,并由停止删除这些行。`workspace/session-activity` 读取 `list({ sessionId })`——绑定到该 Session 且状态为活动的已存储行——把它们作为 `schedule` 族报告,每条任务一项、携带其已存储 id 与 title,并把该族前插到 `next()` 的结果之前,使其他提供方保留各自条目。`workspace/session-stop` 在与工具相同的串行队列的一个槽位里直接删除这些行,因此停止会排在写入尚未完成的创建之后,不会漏掉那个创建即将提交的行;它会尝试每一行,因为留下的行仍会触发;对已经落地的行先通知观察者,再把首个失败交给调用方。是否存活从不参与判断:存储行仍被武装的空闲或冷 Session 会拒绝归档,确认对话框会准确列出这些提醒。两个监听器注册在同一个名为 `schedule.archiveAdmission()` 的 effect 中,卸载插件即一并撤回。 + +## 已考虑的备选 + +**只由活着的 Session 报告 `schedule` 族。** 已存储的任务才是权威,且比其 Session 的 Agent 活得更久,因此以存活为条件会让存储行被武装的冷 Session 静默归档,其到期行随后投递进一个被门禁以 `blocked` 收口的回合,在没有模型步的情况下消耗掉这次触发。两种做法在准入上都只需一次读表。 + +**每行都走公开的管理 `delete` 路径删除。** 在已经持有该槽位的串行队列内部再次进入队列会自锁,因此停止自己删行:同样的持久化变更,每次停止一次通知、一次计时器重算,而不是每行各一次。 + +**在 runtime 侧保留该 Session 活动任务的镜像。** 镜像会重复权威文档、在重启后漂移、并随 Agent 一起消失——而这正是准入必须仍能回答的情形。 + +## 后果 + +准入的代价是一次读已存储的表,而没有活 Agent 的 Session 也能拒绝归档:看起来安静的 Session 现在可能因为其已存储提醒仍被武装而要求确认。停止逐行删除——领域表不提供批量删除——因此删到一半失败时,先前删除已提交;观察者会被告知这些,每一行都已被尝试,首个失败仍交给调用方。由此剩下一处残余:在归档写入与停止之间崩溃,会在已归档的 Session 里留下仍被武装的已存储行;取消归档时它们再次出现;当这类行的目标时刻过去,Schedule runtime 会按自己的定时器选中它并自行 resolve 该 Session 的 Agent——这一步没有门禁,只有 `agent/pre-step` 拒绝模型步——一次性提醒于是把 follow-up 投递进一个被门禁以 `blocked` 收口的回合,该次触发在没有模型步的情况下被消耗。接缝本身未变:workspace 包仍然不认识 Schedule 词汇,而这个提供方只拥有提醒。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 862299b9a2..3f325012a0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -43,6 +43,7 @@ External packages installed for runtime use or distributed inside the prebuilt b | [`@fortune-sheet/react`](https://github.com/ruilisi/fortune-sheet) | MIT | | [`@joplin/turndown-plugin-gfm`](https://github.com/laurent22/joplin-turndown-plugin-gfm) | MIT | | [`@jridgewell/gen-mapping`](https://github.com/jridgewell/sourcemaps) | MIT | +| [`@js-temporal/polyfill`](https://github.com/js-temporal/temporal-polyfill) | ISC | | [`@lexical/history`](https://github.com/facebook/lexical) | MIT | | [`@lexical/plain-text`](https://github.com/facebook/lexical) | MIT | | [`@lexical/text`](https://github.com/facebook/lexical) | MIT | diff --git a/apps/cli/config/examples/schedule/cordis.yml b/apps/cli/config/examples/schedule/cordis.yml index 04fe85214d..eab335e0a5 100644 --- a/apps/cli/config/examples/schedule/cordis.yml +++ b/apps/cli/config/examples/schedule/cordis.yml @@ -1,5 +1,4 @@ -# Opt-in Schedule patch over the shipped Web composition. The owner observes -# only roots published after this overlay loads. +# Opt-in host Schedule service and its Session task catalog. - insert: - id: time-context diff --git a/apps/cli/tests/profiles/web/tests/default-web-process.ts b/apps/cli/tests/profiles/web/tests/default-web-process.ts index 341a97e4c7..537ac77702 100644 --- a/apps/cli/tests/profiles/web/tests/default-web-process.ts +++ b/apps/cli/tests/profiles/web/tests/default-web-process.ts @@ -5,7 +5,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { get } from 'node:http' +import { request as httpRequest } from 'node:http' import type { IncomingHttpHeaders } from 'node:http' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' import ts from 'typescript' @@ -18,6 +18,7 @@ const repoRoot = fileURLToPath(new URL('../../../../../../', import.meta.url)) /** Live process whose IPC observer reports independently collected runtime state. */ interface DefaultWeb { + root: string url: string request: (command: 'roster' | 'mount-experimental' | 'mount-experimental-entry') => Promise } @@ -26,13 +27,19 @@ interface DefaultWeb { * Boot the built Web profile under plain Node and dispose it to quiescence after an assertion callback. * @param test - owning Vitest case, including its timeout, cancellation, and cleanup hooks. * @param inspect - assertions against the running process and its ephemeral loopback URL. + * @param options - additional profile patches and test data prepared before startup. */ -export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWeb) => Promise): Promise { +export async function withDefaultWeb( + test: TestContext, + inspect: (app: DefaultWeb) => Promise, + options: { patches?: readonly string[]; prepare?: (root: string) => Promise } = {}, +): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-web-default-isolation-')) let removal: Promise | undefined const removeRoot = (): Promise => removal ??= rm(root, { recursive: true, force: true }) test.onTestFinished(removeRoot) try { + await options.prepare?.(root) await writeFile(join(root, 'package.json'), JSON.stringify({ type: 'module' }) + '\n') for (const relative of ['runtime-roster.ts', 'fixtures/runtime-roster-observer.ts']) { const source = await readFile(new URL(relative, import.meta.url), 'utf8') @@ -53,7 +60,10 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe const launch = resolveExampleLaunch({ srcBin: join(repoRoot, 'apps/cli/src/bin.ts'), mode: 'lib', - configArgs: ['--profile', 'web', '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open'], + configArgs: [ + '--profile', 'web', ...options.patches?.flatMap(path => ['--patch', path]) ?? [], + '--patch', patch, '--host', '127.0.0.1', '--port', '0', '--no-open', + ], env: { NODE_OPTIONS: undefined, NODE_PATH: undefined, @@ -145,7 +155,7 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe return /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)?.[1] }, { timeout: test.task.timeout }).toBeDefined() const url = /dsh web: (http:\/\/[^\s]+)/u.exec(stdout)![1]! - await inspect({ url, request }) + await inspect({ root, url, request }) } finally { const result = await close() test.signal.removeEventListener('abort', abort) @@ -167,13 +177,26 @@ export async function withDefaultWeb(test: TestContext, inspect: (app: DefaultWe * @param headers - optional authentication cookie. * @returns complete response after its stream ends. */ -export function webGet(url: string | URL, signal: AbortSignal, headers: Record = {}): Promise<{ - status: number | undefined - headers: IncomingHttpHeaders - text: string -}> { +export function webGet( + url: string | URL, signal: AbortSignal, headers: Record = {}, +): ReturnType { + return webRequest(url, signal, { method: 'GET', headers }) +} + +/** + * Send one public-protocol request directly to the test-owned loopback process. + * @param url - Exact URL of the owned process. + * @param signal - Owning test cancellation signal. + * @param options - HTTP method, headers, and optional serialized request body. + * @returns Complete response after its stream ends. + */ +export function webRequest( + url: string | URL, + signal: AbortSignal, + options: { method: 'GET' | 'POST'; headers?: Record; body?: string }, +): Promise<{ status: number | undefined; headers: IncomingHttpHeaders; text: string }> { return new Promise((resolve, reject) => { - const request = get(url, { headers, agent: false, signal }, (response) => { + const request = httpRequest(url, { method: options.method, headers: options.headers, agent: false, signal }, (response) => { response.setEncoding('utf8') let text = '' response.on('data', (chunk: string) => { text += chunk }) @@ -181,5 +204,6 @@ export function webGet(url: string | URL, signal: AbortSignal, headers: Record { resolve({ status: response.statusCode, headers: response.headers, text }) }) }) request.once('error', reject) + request.end(options.body) }) } diff --git a/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts new file mode 100644 index 0000000000..14081ab1a1 --- /dev/null +++ b/apps/cli/tests/profiles/web/tests/schedule-timing.expected.e2e.ts @@ -0,0 +1,120 @@ +/** Built CLI, public Remote requests, and persisted task files; seeded receipts are not delivery evidence. */ + +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { DailyScheduleRecord, ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule' +import { expect, it } from 'vitest' +import { withDefaultWeb, webGet, webRequest } from './default-web-process.ts' + +const overlay = fileURLToPath(new URL('../../../../config/examples/schedule/cordis.yml', import.meta.url)) +const id = 'retained-remote-task' as DailyScheduleRecord['id'] +const sessionId = 'unloaded-owner' as ScheduleCatalogEntry['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', +} +const lastDelivery = { + scheduledAt: '2026-01-01T01:00:00.125Z', deliveredAt: '2026-01-01T01:00:01.250Z', messageId: 'saved-receipt', +} +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 { + await mkdir(join(root, 'home', 'storages'), { recursive: true }) + await writeFile(taskPath(root), bytes) +} + +function encoded(value: unknown): string { + return `${JSON.stringify({ unit: { name: 'schedule', version: 1 }, global: null, tables: { tasks: { [id]: value } } }, null, 2)}\n` +} + +async function connect(url: string, signal: AbortSignal) { + const auth = await webGet(url, signal) + const cookie = auth.headers['set-cookie']?.[0]?.split(';', 1)[0] + if (cookie === undefined) throw new Error('Owned Web process did not issue its authentication cookie') + return async (method: string, args: Record) => { + const rpcId = randomUUID() + const response = await webRequest(new URL(`/api/${method}`, url), signal, { + method: 'POST', headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'client-request', rpcId, method, payload: { args } }), + }) + expect(response.status, response.text).toBe(200) + const envelope = JSON.parse(response.text) as { type?: unknown; rpcId?: unknown; result?: unknown } + expect(envelope).toMatchObject({ type: 'server-response', rpcId }) + return envelope.result + } +} + +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 }, + }, + } + 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 }] }) + 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 } } }, + }) + 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) + }, { patches: [overlay], 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) + }, { patches: [overlay], 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 }, + }, + } + await withDefaultWeb(test, async ({ root, url }) => { + const rpc = await connect(url, test.signal) + 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) + }, { patches: [overlay], prepare: root => seed(root, persisted) }) +}) diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index 3e92ed1e34..5d718f3efe 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -437,8 +437,18 @@ async function bootPreview(origin: string, browser: Browser): Promise { const sessions = page.getByRole('tree', { name: 'Sessions' }) const showcase = sessions.getByRole('treeitem').filter({ hasText: SHOWCASE_TITLE }) await expect.poll(() => showcase.count(), { timeout: 15_000 }).toBe(1) + // The fixture's DSH_HOME carries two active Schedule tasks for this + // Session, so the catalog the Schedule client half reads over the tunnel + // must reach the row mark and the header entry. A composition without the + // schedule overlay, or a Host that dropped the seeded storage, shows + // neither. + await showcase.locator('[data-session-schedule-mark]').waitFor({ timeout: 15_000 }) await showcase.click() await page.getByText(SHOWCASE_TAIL, { exact: true }).waitFor({ timeout: 30_000 }) + await expect.poll( + async () => page.locator('[data-schedule-reminder-entry]').getAttribute('aria-label'), + { timeout: 15_000 }, + ).toBe('2 reminders') expect(await page.getByText(SHOWCASE_OLDEST, { exact: true }).count()).toBe(0) // Complete Turns can fold while earlier history is still unloaded. diff --git a/apps/web/tests/schedule-after.e2e.ts b/apps/web/tests/schedule-after.e2e.ts index 9ce1560f7b..8a18e865ee 100644 --- a/apps/web/tests/schedule-after.e2e.ts +++ b/apps/web/tests/schedule-after.e2e.ts @@ -9,14 +9,15 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { bundlePatchPaths, composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' -import { ToolCallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { MessageId, ToolCallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { appendDelivery } from '../../../packages/schedule/schedule/src/delivery-history.ts' +import type { ScheduleTask } from '@deepseek-ai/dsh-schedule' import type { ContextFormed, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { ScheduleId, createEveryScheduleRecord, foldScheduleEvents, - resolveEveryOccurrence, type EveryScheduleRecord, } from '@deepseek-ai/dsh-schedule' import { @@ -73,13 +74,17 @@ const WEB_PATCHES = bundlePatchPaths(WEB_BUNDLE, (JSON.parse(readFileSync(join(W const CATALOG_NOW = Date.parse('2099-08-25T12:00:00.000Z') const CATALOG_SESSION_ID = SessionId('schedule-catalog-web-e2e') const CATALOG_TITLE = 'Active schedule catalog' -const REMINDER_TRIGGER_NAME = /^\d+ reminders?$/ -const ACTIVE_SCHEDULE_LABEL = 'Has active scheduled task' -const CATALOG_IDS = { - after: ScheduleId('catalog-after'), - at: ScheduleId('catalog-at'), - every: ScheduleId('catalog-every'), -} as const +/** + * Task name seeded for each recorded catalog task. + * + * The recorded `schedule/change` payloads predate titles, so the Host task rows + * this lane asserts are seeded from these names rather than from the fixture. + */ +const CATALOG_TITLES: Readonly> = { + 'catalog-after': 'Review overdue deployment', + 'catalog-at': 'Join release review', + 'catalog-every': 'Check exact cadence', +} /** Emit one complete assistant text response. */ function textResponse(text: string): StreamChunk[] { @@ -90,6 +95,15 @@ function textResponse(text: string): StreamChunk[] { ] } +/** + * Wait until the Session header carries no reminder entry. The entry stays + * mounted while a read is loading or failed, so its absence proves a successful + * read that found no active reminder for this Session. + */ +async function expectNoReminderEntry(page: Page): Promise { + await expect.poll(() => page.locator('[data-schedule-reminder-entry]').count(), { timeout: 15_000 }).toBe(0) +} + /** Deterministic model seam that turns one due reminder into ordinary assistant prose. */ class ReminderAdapter extends LlmAdapter { override async listModels(provider: string) { return [{ provider, id: MODEL, name: `${provider}/${MODEL}` }] } @@ -154,7 +168,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, at: this.selectedAt }) + const argumentsJson = JSON.stringify({ prompt: AT_PROMPT, title: AT_PROMPT, at: this.selectedAt }) const callId = ToolCallId('schedule-at-browser-zone') yield { type: 'block-start', index: 0, blockType: 'tool-call' } yield { @@ -241,25 +255,6 @@ async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { - const workspace = page.locator('[role="treeitem"]').first() - await workspace.waitFor({ timeout: 15_000 }) - const deadline = Date.now() + 5_000 - while (await workspace.getAttribute('aria-expanded') !== 'true') { - if (Date.now() >= deadline) throw new Error('workspace item did not expand') - await workspace.click() - await new Promise(resolve => setTimeout(resolve, 50)) - } - const row = page.getByRole('treeitem', { name: new RegExp(title) }) - await row.waitFor({ timeout: 15_000 }) - await row.click() - // The current crumb renders as plain text, not a button. - await page.getByRole('navigation', { name: 'Session hierarchy' }) - .getByText(title, { exact: true }) - .waitFor({ timeout: 15_000 }) -} - describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { let scaffold: WebScaffold let afterHandle: AgentHandle @@ -324,19 +319,23 @@ 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, after_seconds: 1 }, + arguments: { prompt: AFTER_PROMPT, title: AFTER_PROMPT, after_seconds: 1 }, agent: afterHandle.agent, }) if (afterCreated.isError) { throw new Error(`Schedule After create failed: ${JSON.stringify(afterCreated.value)}`) } - expect(afterCreated.value).toMatchObject({ - id: 'schedule-1', + const createdView: unknown = afterCreated.value + if (typeof createdView !== 'object' || createdView === null || !('id' in createdView) || typeof createdView.id !== 'string') { + throw new Error(`Schedule After create returned no task id: ${JSON.stringify(createdView)}`) + } + expect(createdView.id).toMatch(/^schedule-/) + expect(createdView).toMatchObject({ kind: 'after', prompt: AFTER_PROMPT, afterSeconds: 1, state: 'scheduled', - deliveryMode: 'session-local', + deliveryMode: 'host', }) afterAssistantReply = await waitForReply(afterHandle, AFTER_REPLY, 15_000) await afterHandle.agent.whenIdle() @@ -359,22 +358,25 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { EVERY_PROMPTS[0], EVERY_INTERVAL_SECONDS, seededAt - EVERY_FIXTURE_AGE_MS, + 'Every primary', ), createEveryScheduleRecord( ScheduleId('schedule-every-secondary'), EVERY_PROMPTS[1], EVERY_INTERVAL_SECONDS, seededAt - EVERY_FIXTURE_AGE_MS, + 'Every secondary', ), ] + const domain = scaffold.ctx.storageDomain.get('schedule') + if (domain === undefined) throw new Error('Schedule domain was not opened') for (const record of everyRecords) { - everyHandle.agent.session.append('schedule/change', { - version: 1, - operation: 'create', - schedule: record, - }) + await domain.table('tasks').put(record.id, { sessionId: everyHandle.agent.id, record, status: 'active' }) } - await expect(scaffold.ctx.sessions.flush(everyHandle.agent.session)).resolves.toBe(true) + 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 }) await workspace.attachSession(everyHandle.agent.id) const everyListed = await scaffold.ctx.tools.execute({ signal: AbortSignal.timeout(10_000), @@ -461,52 +463,87 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { await captureStableAria(page, selector, scaffold.workspaceCwd), MODE, ) - expect(await page.getByRole('button', { name: REMINDER_TRIGGER_NAME }).count()).toBe(0) + 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 + ))).toBe(true) + const delivered = (await scaffold.ctx.schedule.catalog()).find(task => task.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([]) + // 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('list', { name: 'Task catalog' }) + .getByRole('button', { name: AFTER_PROMPT, exact: true }).click() + const detail = manager.getByRole('complementary', { name: 'Task details' }) + expect(await detail.getByRole('tab', { name: 'Rules', exact: true }).getAttribute('aria-selected')).toBe('true') + expect(await detail.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0) + await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click() + await detail.getByRole('tabpanel', { name: 'Delivery records', exact: true }).waitFor() + const receipt = detail.getByRole('region', { name: 'Saved delivery record' }) + await receipt.waitFor() + expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0) + // The row identifies its occurrence by the scheduled instant it renders. + expect(await receipt.locator(`time[datetime="${delivered.lastDelivery.scheduledAt}"]`).count()).toBe(1) + expect(await receipt.textContent()).toContain(AFTER_PROMPT) + expect(await detail.getByText('Next scheduled time', { exact: true }).count()).toBe(0) + // The linked-session row renders on the Rules view only. + await detail.getByRole('tab', { name: 'Rules', exact: true }).click() + const linkedSession = detail.getByRole('button', { name: /^Linked session/ }) + 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('Scheduled After follow-up', { exact: true }).count()).toBe(1) + const moreActions = detail.getByRole('button', { name: 'Automation task actions', exact: true }) + await moreActions.click() + const deleteAction = page.getByRole('menuitem', { name: 'Delete task', exact: true }) + await deleteAction.waitFor({ timeout: 15_000 }) + expect(await deleteAction.isDisabled()).toBe(false) + await page.keyboard.press('Escape') + // Dismissing the menu leaves the detail it was opened from: the Escape + // 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) + } finally { + await scaffold.ctx.workspaceRegistry.unarchiveSession(afterHandle.agent.id) + } + await linkedSession.waitFor({ timeout: 15_000 }) + await expect.poll(() => linkedSession.isDisabled(), { timeout: 15_000 }).toBe(false) + await linkedSession.click() + await expectNoReminderEntry(page) }, 60_000) - it('batches one latest occurrence per overdue Every record into an ordinary follow-up', async () => { + it('batches one latest occurrence per overdue Every record and advances both schedules', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-every')) - const ids = new Set(everyRecords.map(record => record.id)) - const dispatches = everyHandle.agent.session.snapshotEvents().filter(event => ( - event.type === 'schedule/change' - && event.data.operation === 'dispatch' - && ids.has(event.data.id) - )) - expect(dispatches).toHaveLength(2) - const acceptedAt = dispatches.map((event) => { - if (event.type !== 'schedule/change' || event.data.operation !== 'dispatch' - || !('acceptedAt' in event.data)) throw new Error('expected Every dispatch') - return event.data.acceptedAt - }) - expect(new Set(acceptedAt).size).toBe(1) - const decision = acceptedAt[0] - if (decision === undefined) throw new Error('missing Every decision time') - - const batch = everyHandle.agent.session.snapshotEvents().find(event => ( + 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.startsWith('[SCHEDULE REMINDER BATCH]')) )) - if (batch?.type !== 'user/message') throw new Error('missing Every batch message') - const batchBlock = batch.data.content.find(block => block.type === 'text') - if (batchBlock?.type !== 'text') throw new Error('missing Every batch text') + expect(messages).toHaveLength(1) + const texts = messages.map((event) => { + if (event.type !== 'user/message') throw new Error('expected reminder message') + return event.data.content.filter(block => block.type === 'text').map(block => block.text).join('') + }) for (const record of everyRecords) { - const occurrenceAt = resolveEveryOccurrence(record, Date.parse(decision)).occurrenceAt - expect(batchBlock.text).toContain(JSON.stringify({ - schedule_id: record.id, - occurrence_at: occurrenceAt, - reminder_prompt: record.prompt, - }).slice(1, -1)) + 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) - const reminderRequest = everyAdapter.requests[0] - if (reminderRequest === undefined) throw new Error('model did not receive the Every batch') - expect(requestText(reminderRequest)).toContain(batchBlock.text) - expectReminderFraming(reminderRequest) - const active = foldScheduleEvents(everyHandle.agent.session.snapshotEvents()).active + for (const request of everyAdapter.requests) expectReminderFraming(request) + const active = await scaffold.ctx.schedule.list({ sessionId: everyHandle.agent.id }) expect(active).toHaveLength(2) - expect(active.every(record => Date.parse(record.scheduledAt) > Date.parse(decision))).toBe(true) + for (const record of active) { + const original = everyRecords.find(candidate => candidate.id === record.id) + if (original === undefined) throw new Error('unexpected recurring record') + expect(Date.parse(record.scheduledAt) - Date.parse(original.scheduledAt)) + .toBe(EVERY_INTERVAL_SECONDS * 1_000) + } const session = page.getByRole('treeitem', { name: /Fixed-rate reminder batch/ }) await session.click() @@ -523,6 +560,66 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { ) await page.getByRole('button', { name: '2 reminders', exact: true }).waitFor({ timeout: 15_000 }) expect(await page.getByRole('list', { name: 'Active reminders' }).count()).toBe(0) + + const original = everyRecords[0] + const first = await scaffold.ctx.schedule.history({ sessionId: everyHandle.agent.id, 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') + await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: original.title, exact: true }).click() + const detail = manager.getByRole('complementary', { name: 'Task details' }) + await detail.getByRole('tab', { name: 'Delivery records', exact: true }).click() + const receipts = detail.getByRole('region', { name: 'Saved delivery record' }) + const recordsPanel = detail.getByRole('tabpanel', { name: 'Delivery records', exact: true }) + await expect.poll(() => receipts.count()).toBe(1) + expect(await detail.getByText('Earlier delivery records have been cleared', { exact: true }).count()).toBe(0) + + const domain = scaffold.ctx.storageDomain.get('schedule') + 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. + 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 }, + }) + 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 }) + if ('code' in saved) throw new Error('recurring delivery history was unavailable') + expect(saved.records).toHaveLength(2) + expect(saved.records[1]).toEqual(latest) + expect(saved.records[0]?.messageId).not.toBe(latest.messageId) + for (const record of saved.records) { + // Each saved occurrence renders its own scheduled instant. + expect(await recordsPanel.locator(`time[datetime="${record.scheduledAt}"]`).count()).toBe(1) + } + expect(await detail.getByRole('tab', { name: 'Delivery 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 }) + 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 detail.getByRole('tab', { name: 'Delivery 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) it('uses request-local browser context to create an explicit local At reminder', async () => { @@ -556,26 +653,21 @@ 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, at: selectedAt }) - const created = atHandle.agent.session.snapshotEvents().find(event => ( - event.type === 'schedule/change' - && event.data.operation === 'create' - && event.data.schedule.kind === 'at' + expect(JSON.parse(toolCall.data.arguments)).toEqual({ prompt: AT_PROMPT, title: AT_PROMPT, at: selectedAt }) + const result = atHandle.agent.session.snapshotEvents().find(event => ( + event.type === 'tool/result' && event.data.message.toolCallId === toolCall.data.callId )) - if (created?.type !== 'schedule/change' || created.data.operation !== 'create') { - throw new Error('explicit local At call did not create a durable record') - } - const schedule = created.data.schedule - expect(schedule).toMatchObject({ - kind: 'at', - prompt: AT_PROMPT, - scheduledAt, - }) - expect(atHandle.agent.session.snapshotEvents().filter(event => ( - event.type === 'schedule/change' - && event.data.operation === 'dispatch' - && event.data.id === schedule.id - ))).toHaveLength(1) + if (result?.type !== 'tool/result') throw new Error('missing schedule_create result') + 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 }) + const reminders = atHandle.agent.session.snapshotEvents().filter(event => ( + event.type === 'user/message' + && event.data.source.kind === 'schedule' + )) + 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] if (reminderRequest === undefined) throw new Error('model did not receive the At reminder') @@ -594,7 +686,7 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => { await captureStableAria(page, selector, scaffold.workspaceCwd), MODE, ) - expect(await page.getByRole('button', { name: REMINDER_TRIGGER_NAME }).count()).toBe(0) + await expectNoReminderEntry(page) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) @@ -620,18 +712,21 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { extraOverlayPath: OVERLAY, }) 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 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] })) + for (const record of seeded) { + await domain.table('tasks').put(record.id, { sessionId: CATALOG_SESSION_ID, record, status: 'active' }) + } + expect(await scaffold.ctx.schedule.list({ sessionId: CATALOG_SESSION_ID })).toEqual(seeded) + expect(scaffold.ctx.agents.get(CATALOG_SESSION_ID)).toBeUndefined() + const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd) await workspace.attachSession(CATALOG_SESSION_ID) - // Seed the zero-I/O list view before the Session is opened. - const catalogReader = await scaffold.ctx.sessionPersistence.open(CATALOG_SESSION_ID, 'read') - try { - const catalogEvents = [...(await catalogReader.read()).events] - scaffold.ctx.sessionProjectionCache.coldSnapshot(catalogReader.header, catalogReader.inheritedEventCount, catalogEvents) - } finally { - await catalogReader.close() - } - browser = await chromium.launch() page = await browser.newPage({ viewport: { width: 900, height: 900 }, @@ -649,15 +744,6 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { await openSidebar.click() await page.getByRole('button', { name: 'Collapse sidebar' }).waitFor({ timeout: 10_000 }) } - const workspaceRow = page.locator('[role="treeitem"]').first() - await workspaceRow.waitFor({ timeout: 15_000 }) - const expansionDeadline = Date.now() + 5_000 - while (await workspaceRow.getAttribute('aria-expanded') !== 'true') { - if (Date.now() >= expansionDeadline) throw new Error('workspace item did not expand') - await workspaceRow.click() - await new Promise(resolve => setTimeout(resolve, 50)) - } - await page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }).waitFor({ timeout: 15_000 }) }, 120_000) afterAll(async () => { @@ -668,7 +754,86 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { if (failures.length > 1) throw new AggregateError(failures, 'Schedule catalog teardown failed') }) - it('replays the overlay-only catalog and sidebar marker, then removes both live', async () => { + 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', + }) + 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() + const tasks = manager.getByRole('list', { name: 'Task catalog' }) + await expect.poll(() => tasks.getByRole('listitem').count()).toBe(4) + expect(scaffold.ctx.agents.get(CATALOG_SESSION_ID)).toBeUndefined() + expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() + + // The filter row carries the status group alone, whose first chip is the + // 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) + 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') + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('second workspace') + await expect.poll(() => tasks.getByRole('listitem').count()).toBe(1) + await tasks.getByRole('button', { name: other.title, exact: true }).click() + const details = manager.getByRole('complementary', { name: 'Task details' }) + expect(await details.textContent()).toContain(otherSessionId) + expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() + await details.getByRole('tab', { name: 'Delivery records', exact: true }).click() + await details.getByText('No delivery record available', { exact: true }).waitFor() + expect(await details.getByRole('region', { name: 'Saved delivery record' }).count()).toBe(0) + expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() + await details.getByRole('tab', { name: 'Rules', exact: true }).click() + + 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 }) + 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) + await details.locator('[class*="detailScroll"]').evaluate((element) => { element.scrollTop = element.scrollHeight }) + const linkedBounds = await linkedSession.boundingBox() + expect(linkedBounds).not.toBeNull() + expect(linkedBounds!.y).toBeGreaterThanOrEqual(0) + expect(linkedBounds!.y + linkedBounds!.height).toBeLessThanOrEqual(340) + expect(await details.getByRole('button', { name: 'Automation task actions', exact: true }).isVisible()).toBe(true) + await page.setViewportSize({ width: 900, height: 900 }) + + const moreActions = details.getByRole('button', { name: 'Automation task actions', exact: true }) + await moreActions.click() + await page.getByRole('menuitem', { name: 'Delete task', exact: true }).click() + const confirmation = page.getByRole('dialog', { name: 'Delete this task?' }) + await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click() + expect((await scaffold.ctx.schedule.catalog()).some(task => task.id === other.id)).toBe(true) + await moreActions.click() + await page.getByRole('menuitem', { name: 'Delete task', exact: true }).click() + await confirmation.getByRole('button', { name: 'Confirm deletion', exact: true }).click() + await expect.poll(async () => (await scaffold.ctx.schedule.catalog()).some(task => task.id === other.id)) + .toBe(false) + // A confirmed deletion closes the surface that asked for it and reports the + // outcome through the app-wide toast, so the notice outlives the panel that + // raised it. The Host removed the row with its saved records, so neither the + // rule view nor the records view can read the task again. + await expect.poll(() => details.count()).toBe(0) + await page.getByText('Task deleted.', { exact: true }).waitFor({ timeout: 15_000 }) + expect(scaffold.ctx.agents.get(otherSessionId)).toBeUndefined() + + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('Check exact cadence') + 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 liveAgent(scaffold, CATALOG_SESSION_ID) + await page.getByRole('button', { name: '3 reminders', exact: true }).waitFor() + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + + it('reads stored tasks without a live Session and deletes them through the catalog', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog')) const base = composeEntries([ loadOverlayPatches('Schedule catalog base roster', BASE_PATCH), @@ -688,34 +853,13 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { disabled: false, }) - const catalogRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) - expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - - await page.getByRole('button', { name: 'Search sessions' }).click() - const search = page.getByPlaceholder('Search session names', { exact: false }) - await search.fill(CATALOG_TITLE) - const result = page.getByRole('tree', { name: 'Search results' }) - .getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) - await result.waitFor({ timeout: 15_000 }) - expect(await result.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - expect(await result.getByRole('button').count()).toBe(0) - - await page.getByRole('button', { name: 'Clear search' }).click() - await catalogRow.waitFor({ timeout: 15_000 }) - - await page.getByRole('button', { name: 'View options' }).click() - await page.getByRole('menuitem', { name: 'In one list' }).click() - const flatRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) - await flatRow.waitFor({ timeout: 15_000 }) - expect(await flatRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - - await page.getByRole('button', { name: 'View options' }).click() - await page.getByRole('menuitem', { name: 'WorkSpace', exact: true }).click() - await catalogRow.waitFor({ timeout: 15_000 }) - expect(await catalogRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - - await openSession(page, CATALOG_TITLE) - const parentAgent = await liveAgent(scaffold, CATALOG_SESSION_ID) + 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 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' }) await trigger.waitFor({ timeout: 15_000 }) @@ -755,13 +899,16 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(lightLayout.background).not.toBe('rgba(0, 0, 0, 0)') const longRow = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) const cadenceRow = catalog.getByRole('listitem').filter({ hasText: 'Check exact cadence' }) - const longPrompt = longRow.locator(':scope > span').nth(1) + // The row's opener is a block box, so its own width is the one a stored name + // can widen: this lane's 19-character seeded name renders inside it without + // 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 promptLayout = await longPrompt.evaluate(element => ({ - height: element.getBoundingClientRect().height, clientWidth: element.clientWidth, scrollWidth: element.scrollWidth, })) - expect(promptLayout.height).toBeGreaterThan(18) expect(promptLayout.scrollWidth).toBeLessThanOrEqual(promptLayout.clientWidth) const [longRowLayout, cadenceRowLayout] = await Promise.all([ longRow.evaluate((element) => { @@ -785,11 +932,23 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { } expect(longRowLayout.bottom).toBeLessThanOrEqual(cadenceRowLayout.top) expect(cadenceRowLayout.bottom).toBeGreaterThan(cadenceRowLayout.top) + // The popover caps its height at min(420px, calc(100vh - 140px)) + // (ScheduleCatalogAction.module.css). Each of the three rows keeps its 48px + // min-height plus the 6px list padding and two 1px gaps, so the list needs + // at least 152px; a 260px viewport leaves a 120px budget, which must clamp + // the list and make the clip scroll. + const clampedViewportHeight = 260 + const clampedHeightCap = Math.min(420, clampedViewportHeight - 140) + const naturalHeight = await catalog.evaluate(element => element.clientHeight) + expect(naturalHeight).toBeGreaterThan(clampedHeightCap) + await page.setViewportSize({ width: 900, height: clampedViewportHeight }) const scrollLayout = await catalog.evaluate(element => ({ clientHeight: element.clientHeight, scrollHeight: element.scrollHeight, })) + expect(scrollLayout.clientHeight).toBeLessThan(naturalHeight) expect(scrollLayout.scrollHeight).toBeGreaterThan(scrollLayout.clientHeight) + await page.setViewportSize({ width: 900, height: 900 }) const evidenceDir = join(REPO_ROOT, '.artifacts') await mkdir(evidenceDir, { recursive: true }) @@ -812,19 +971,44 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { MODE, ) - const sessionRow = page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }) - expect(await sessionRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count()).toBe(1) - for (const id of Object.values(CATALOG_IDS)) { - parentAgent.session.append('schedule/change', { version: 1, operation: 'delete', id }) + // The next-run line states the target as the reader's local clock stamp + // followed by the distance to it in parentheses: the stamp is the instant in + // this device's zone, and only the parenthesized distance ticks. + const nextRunLine = catalog.getByRole('listitem').filter({ hasText: 'Join release review' }) + .locator('[class*="nextRun"]') + await expect.poll(() => nextRunLine.count(), { timeout: 15_000 }).toBeGreaterThan(0) + const metadataText = await nextRunLine.first().textContent() ?? '' + const nextRun = await nextRunLine.first().locator('time').getAttribute('dateTime') + if (nextRun === null) throw new Error('the next-run line names no instant') + const nextRunAbsolute = new Intl.DateTimeFormat('en', { + month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit', + hour12: false, timeZone: AT_BROWSER_ZONE, + }).format(new Date(nextRun)) + expect(metadataText).toContain(`Next run ${nextRunAbsolute}`) + expect(metadataText).toMatch(/Next run [^)]+\((?:in \d+ \w+|Due now|\d+ .*overdue)\)/) + // The stamp is a local clock time, not a bare duration. + 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() + 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 }) + .toBe(remaining - 1) } - await expect(scaffold.ctx.sessions.flush(parentAgent.session)).resolves.toBe(true) - await expect.poll(() => page.getByRole('button', { name: REMINDER_TRIGGER_NAME }).count(), { - timeout: 15_000, - }).toBe(0) - expect(await page.getByRole('list', { name: 'Active reminders' }).count()).toBe(0) - await expect.poll(() => sessionRow.getByRole('img', { name: ACTIVE_SCHEDULE_LABEL }).count(), { - timeout: 15_000, - }).toBe(0) + await expectNoReminderEntry(page) + expect(await scaffold.ctx.schedule.list({ sessionId: CATALOG_SESSION_ID })).toEqual([]) + await page.reload({ waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + await expectNoReminderEntry(page) + // This authored pin owns its sidecars through the snapshot manifest's + // `header.pin: true` contract, which the session-snapshot corpus enforces + // (`scripts/session-snapshot-corpus.corpus.ts`). The sidecars have no replay + // comparison here: this scenario seeds its fixture and then runs further + // turns, so `assertReplaySession` (enabled only by `replayFixture`) has no + // matching recording to compare against. await assertFixtureInventory(CATALOG_SNAPSHOT_DIR, [ 'catalog.expected.md', 'session.v3.jsonl', @@ -834,4 +1018,168 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }, 60_000) + + 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', + }) + try { + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'All', exact: true }).click() + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill(record.title) + await manager.getByRole('list', { name: 'Task catalog' }).getByRole('button', { name: record.title, exact: true }).click() + const detail = manager.getByRole('complementary', { name: 'Task details' }) + const recordsTab = detail.getByRole('tab', { name: 'Delivery records', exact: true }) + const rulesTab = detail.getByRole('tab', { name: 'Rules', exact: true }) + const notice = detail.getByText('Earlier delivery records have been cleared', { exact: true }) + await recordsTab.click() + await detail.getByText('No delivery record available', { exact: true }).waitFor() + expect(await notice.count()).toBe(0) + 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') } + await rulesTab.click() + await domain.table('tasks').put(record.id, { + sessionId: CATALOG_SESSION_ID, record, status: 'inactive', lastDelivery: receipt, + deliveryHistory: { records: [receipt], earlierRecordsUnavailable: true }, + }) + await recordsTab.click() + const saved = detail.getByRole('region', { name: 'Saved delivery record' }) + await expect.poll(() => saved.count()).toBe(1) + expect(await notice.count()).toBe(0) + await rulesTab.click() + let task: ScheduleTask = { + sessionId: CATALOG_SESSION_ID, record, status: 'inactive', + deliveryHistory: { records: [], earlierRecordsUnavailable: 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}`), + }, { days: 30, records: 200 }) } + } + expect(task.deliveryHistory?.records).toHaveLength(22) + expect(task.deliveryHistory?.earlierRecordsPruned).toBe(true) + await domain.table('tasks').put(record.id, task) + await recordsTab.click() + await expect.poll(() => saved.count()).toBe(20) + expect(await notice.count()).toBe(0) + await detail.getByRole('button', { name: 'Load more', exact: true }).click() + await notice.waitFor() + expect(await saved.count()).toBe(22) + const info = detail.getByRole('button', { name: 'Retention rules', exact: true }) + 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(` + "Earlier delivery records have been cleared + + Each task keeps up to 200 delivery records from the last 30 days. + + When a new record is saved, older records outside these limits are cleared automatically. Clearing records does not stop the task." + `) + await info.click() + expect(await bounds.count()).toBe(0) + const panelBox = await detail.boundingBox() + const footerBox = await detail.locator('footer').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(tripwire.pageErrors).toEqual([]) + } finally { + await scaffold.ctx.schedule.delete({ sessionId: CATALOG_SESSION_ID, id: record.id }) + } + }, 60_000) + + it('shows a tool-created daily rule as repeating with its original time zone', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-daily')) + const agent = await liveAgent(scaffold, CATALOG_SESSION_ID) + const timeZone = 'America/New_York' + 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 prompt = 'Inspect the daily calendar rule' + const result = await scaffold.ctx.tools.execute({ + signal: AbortSignal.timeout(10_000), callId: ToolCallId('schedule-daily-catalog-create'), + name: 'schedule_create', arguments: { prompt, title: prompt, daily: { time, time_zone: timeZone } }, agent, + }) + const created = (await scaffold.ctx.schedule.catalog()).find(task => ( + task.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(clock.format(new Date(created.scheduledAt))).toBe(time) + await page.clock.setFixedTime(new Date()) + await page.getByRole('button', { name: 'Automation tasks', exact: true }).click() + const manager = page.getByTestId('task-manager-page') + await manager.getByRole('button', { name: 'All', exact: true }).click() + await manager.getByRole('searchbox', { name: 'Search tasks' }).fill(prompt) + const tasks = manager.getByRole('list', { name: 'Task catalog' }) + const row = tasks.getByRole('button', { name: prompt, exact: true }) + await row.waitFor() + // The frequency line names the stored zone by its localized label: the + // current UTC offset and the ICU generic name of that zone. Both parts + // come from the same ICU data the app reads, so the assertion follows the + // runtime's own zone names. + const zoneName = new Intl.DateTimeFormat('en', { timeZone, timeZoneName: 'longGeneric' }) + .formatToParts(new Date()).find(part => part.type === 'timeZoneName')?.value + const zoneOffset = new Intl.DateTimeFormat('en', { timeZone, timeZoneName: 'longOffset' }) + .formatToParts(new Date()).find(part => part.type === 'timeZoneName')?.value?.replace(/^GMT/, 'UTC') + const frequency = `Daily at ${time.replace(/:00$/, '')} (${zoneOffset} · ${zoneName})` + expect(await row.textContent()).toContain(frequency) + await row.click() + 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) + 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. + const zoneField = runTime.getByRole('button', { name: /Time zone/ }) + await zoneField.click() + // 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. + 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') + 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' }) + 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', + }) + // 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 })) + .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 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 page.clock.setFixedTime(new Date(CATALOG_NOW)) + } + }, 60_000) }) diff --git a/apps/web/tests/tool-details.e2e.ts b/apps/web/tests/tool-details.e2e.ts index 4dcaa0f759..02597218c0 100644 --- a/apps/web/tests/tool-details.e2e.ts +++ b/apps/web/tests/tool-details.e2e.ts @@ -52,7 +52,7 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { it('expands every recorded tool through its keyed renderer', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-tool-details')) const rows = page.locator('[data-tool]') - expect(await rows.count()).toBe(44) + expect(await rows.count()).toBe(45) for (const row of await rows.all()) { await row.getByRole('button', { expanded: false }).first().click() expect(await row.getByRole('listitem').count()).toBeGreaterThan(0) @@ -65,8 +65,14 @@ describe.skipIf(MODE === 'record')('web e2e: compact Tool details', () => { await page.locator('[data-tool="create_goal"]').getByText('Awaiting continuation', { exact: true }).waitFor() const schema = page.locator('[data-tool="cordis_inspect_list"] pre').first() await expect.poll(() => schema.textContent()).toContain('"properties"') - const snapshots = await Promise.all(Array.from({ length: 44 }, (_, index) => - captureStableAria(page, `[data-chat-call-id="details-call-${index + 1}"] [data-tool]`, scaffold.workspaceCwd))) + const callIds = [ + ...Array.from({ length: 3 }, (_, index) => `details-call-${index + 1}`), + // The authored schedule_update round sits beside its schedule_create sibling. + 'details-call-3b', + ...Array.from({ length: 41 }, (_, index) => `details-call-${index + 4}`), + ] + const snapshots = await Promise.all(callIds.map(id => + captureStableAria(page, `[data-chat-call-id="${id}"] [data-tool]`, scaffold.workspaceCwd))) const snapshot = snapshots.join('\n') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) await page.setViewportSize({ width: 360, height: 800 }) diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 424540985a..889225b9e7 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: ea114a5c950fa88f - zh: 1d42873021ba511d + en: 35a8ab92922009f2 + zh: b2d0accdef6b94bd diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 2914231cea..7a80b84a95 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -174,6 +174,8 @@ flowchart LR svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_base["base"] pkg_sdk_minimal["sdk-minimal"] + pkg_schedule["schedule"] + svc_schedule["ctx.schedule
Host scheduled messages"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] pkg_ssh["ssh"] @@ -357,6 +359,7 @@ flowchart LR pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy pkg_sandbox_ssh --> svc_sandbox + pkg_schedule --> svc_schedule pkg_session --> svc_sessions pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions pkg_session_persistence --> svc_sessionPersistence @@ -626,6 +629,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 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.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. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index a1223a351c..6348eb35bc 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -176,6 +176,8 @@ flowchart LR svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_base["base"] pkg_sdk_minimal["sdk-minimal"] + pkg_schedule["schedule"] + svc_schedule["ctx.schedule
Host scheduled messages"] pkg_goal["goal"] svc_goals["ctx.goals
Same-session goal domain"] pkg_ssh["ssh"] @@ -359,6 +361,7 @@ flowchart LR pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy pkg_sandbox_ssh --> svc_sandbox + pkg_schedule --> svc_schedule pkg_session --> svc_sessions pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions pkg_session_persistence --> svc_sessionPersistence @@ -628,6 +631,7 @@ flowchart LR | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 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.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 升级。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e47d18af6d..8e34019eba 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2430,6 +2430,31 @@ export interface Config { ``` + +
+ +## `@deepseek-ai/dsh-schedule` + +- `inject`: `agents` · `sessions` · `tools` · `storageDomain` · `sessionController` · `sessionPersistence` +- `source`: [`packages/schedule/schedule/src/index.ts:73`](../packages/schedule/schedule/src/index.ts) + +```ts config-catalog +/** Configuration for the Host Schedule domain. */ +export interface Config { + /** + * Delivery-history window retained per task, in days; omission defaults to 30. + * Pruning happens when an acknowledgment is appended, and `lastDelivery` is always retained. + */ + deliveryHistoryDays?: number + /** + * Retained delivery records per task; omission defaults to 200. The older of this + * cap and the window wins, and the newest records survive. + */ + deliveryHistoryRecords?: number +} +``` + + @@ -4290,7 +4315,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) | | `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) | | `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) | -| `@deepseek-ai/dsh-schedule` | `agents` · `sessions` · `tools` · `sessionPersistence` | [`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts) | | `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) | | `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) | | `@deepseek-ai/dsh-session-projection` | — | [`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 2be1da68e3..2f47ae86d1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2432,6 +2432,31 @@ export interface Config { ``` + + + +## `@deepseek-ai/dsh-schedule` + +- `inject`: `agents` · `sessions` · `tools` · `storageDomain` · `sessionController` · `sessionPersistence` +- `source`: [`packages/schedule/schedule/src/index.ts:73`](../packages/schedule/schedule/src/index.ts) + +```ts config-catalog +/** Configuration for the Host Schedule domain. */ +export interface Config { + /** + * Delivery-history window retained per task, in days; omission defaults to 30. + * Pruning happens when an acknowledgment is appended, and `lastDelivery` is always retained. + */ + deliveryHistoryDays?: number + /** + * Retained delivery records per task; omission defaults to 200. The older of this + * cap and the window wins, and the newest records survive. + */ + deliveryHistoryRecords?: number +} +``` + + @@ -4292,7 +4317,6 @@ export interface Config { | `@deepseek-ai/dsh-lsp` | — | [`packages/lsp/lsp/src/index.ts`](../packages/lsp/lsp/src/index.ts) | | `@deepseek-ai/dsh-mcp-resources` | `tools` | [`packages/mcp/mcp-resources/src/index.ts`](../packages/mcp/mcp-resources/src/index.ts) | | `@deepseek-ai/dsh-sandbox-ssh` | `ssh` | [`packages/ssh/sandbox-ssh/src/index.ts`](../packages/ssh/sandbox-ssh/src/index.ts) | -| `@deepseek-ai/dsh-schedule` | `agents` · `sessions` · `tools` · `sessionPersistence` | [`packages/schedule/schedule/src/index.ts`](../packages/schedule/schedule/src/index.ts) | | `@deepseek-ai/dsh-session` | — | [`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts) | | `@deepseek-ai/dsh-session-checkpoint-policy` | `llm` · `sessionPersistence` · `sessions` · `tools` | [`packages/session/session-checkpoint-policy/src/index.ts`](../packages/session/session-checkpoint-policy/src/index.ts) | | `@deepseek-ai/dsh-session-projection` | — | [`packages/session/session-projection/src/index.ts`](../packages/session/session-projection/src/index.ts) | diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 090e0d726d..f419955c56 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -10,4 +10,4 @@ zh: a8505006244f5619 /event-producer-and-consumer-matrix/non-harness-or-undeclared-event-strings-seen-in-package-source: en: 96dc0ff1580e5741 - zh: f076e3afa8fbc57c + zh: 96dc0ff1580e5741 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 13141d9f06..7728ddfe30 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -12,7 +12,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | @@ -20,7 +20,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -59,6 +59,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `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), [`workspace-changes`](../packages/deliverables/workspace-changes) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index c23914a1c7..7bf6c9252d 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -14,7 +14,7 @@ | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:71`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | @@ -22,7 +22,7 @@ | `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:611`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | @@ -61,6 +61,7 @@ | `plugin-manager/changed` | `emit` | [`packages/boot/plugin-manager/src/types.ts:243`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-log` | `emit` | [`packages/boot/plugin-manager/src/types.ts:249`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | | `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:256`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` | +| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:471`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:53`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `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), [`workspace-changes`](../packages/deliverables/workspace-changes) | @@ -107,4 +108,4 @@ | `slots/changed` | `ui-renderer` (`emit`) | - | -维护模式:自动生成:Cordis 事件声明及生产方/监听方关系从仓库 TypeScript Program 中解析。 +Maintenance mode: generated: Cordis event declarations and producer/listener edges are resolved from the repository TypeScript Program. diff --git a/docs/module-graph.md b/docs/module-graph.md index 02110d0aee..b15903d4d5 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -926,15 +926,6 @@ flowchart TD pkg_agent_preset_registry --> pkg_system_prompt pkg_agent_preset_registry --> pkg_tools pkg_agent_preset_registry --> pkg_typert_protocol - pkg_schedule --> pkg_agent - pkg_schedule --> pkg_brand - pkg_schedule --> pkg_invariants - pkg_schedule --> pkg_llm - pkg_schedule --> pkg_session - pkg_schedule --> pkg_session_persistence - pkg_schedule --> pkg_session_projection - pkg_schedule --> pkg_tools - pkg_schedule --> pkg_workspace pkg_session_checkpoint_policy --> pkg_agent pkg_session_checkpoint_policy --> pkg_llm pkg_session_checkpoint_policy --> pkg_session @@ -1318,6 +1309,18 @@ flowchart TD pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools + pkg_schedule --> pkg_agent + pkg_schedule --> pkg_api_session_controller + pkg_schedule --> pkg_brand + pkg_schedule --> pkg_invariants + pkg_schedule --> pkg_llm + pkg_schedule --> pkg_session + pkg_schedule --> pkg_session_persistence + pkg_schedule --> pkg_session_projection + pkg_schedule --> pkg_storage_domain + pkg_schedule --> pkg_tools + pkg_schedule --> pkg_typert_protocol + pkg_schedule --> pkg_workspace pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session @@ -1608,7 +1611,6 @@ flowchart TD | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | `preset` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`workspace`](../packages/workspace/workspace) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | @@ -1666,6 +1668,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`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-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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 05f8539211..62a60aef0d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -928,15 +928,6 @@ flowchart TD pkg_agent_preset_registry --> pkg_system_prompt pkg_agent_preset_registry --> pkg_tools pkg_agent_preset_registry --> pkg_typert_protocol - pkg_schedule --> pkg_agent - pkg_schedule --> pkg_brand - pkg_schedule --> pkg_invariants - pkg_schedule --> pkg_llm - pkg_schedule --> pkg_session - pkg_schedule --> pkg_session_persistence - pkg_schedule --> pkg_session_projection - pkg_schedule --> pkg_tools - pkg_schedule --> pkg_workspace pkg_session_checkpoint_policy --> pkg_agent pkg_session_checkpoint_policy --> pkg_llm pkg_session_checkpoint_policy --> pkg_session @@ -1320,6 +1311,18 @@ flowchart TD pkg_experimental_tool_agent_team --> pkg_session pkg_experimental_tool_agent_team --> pkg_system_prompt pkg_experimental_tool_agent_team --> pkg_tools + pkg_schedule --> pkg_agent + pkg_schedule --> pkg_api_session_controller + pkg_schedule --> pkg_brand + pkg_schedule --> pkg_invariants + pkg_schedule --> pkg_llm + pkg_schedule --> pkg_session + pkg_schedule --> pkg_session_persistence + pkg_schedule --> pkg_session_projection + pkg_schedule --> pkg_storage_domain + pkg_schedule --> pkg_tools + pkg_schedule --> pkg_typert_protocol + pkg_schedule --> pkg_workspace pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol pkg_sdk_client --> pkg_session @@ -1610,7 +1613,6 @@ flowchart TD | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-resources`](../packages/mcp/mcp-resources) | `mcp` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`agent-preset-registry`](../packages/preset/agent-preset-registry) | `preset` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools), [`workspace`](../packages/workspace/workspace) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | @@ -1668,6 +1670,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`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-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) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 0eb4bb9107..69c28679ec 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: e7c80d1de4d328b4 zh: d9f368c27fd9f998 /session-persistence-event-catalog/persistence-type-fingerprints: - en: 42ad01cbefcc0966 - zh: c21832fd903f4d6e + en: 0254fc43535dfaad + zh: 2f592cfae60e10ac /session-persistence-event-catalog/event-envelope: en: 7ebb1a2e0dd1f916 zh: 8946f415dda5c1b8 @@ -165,8 +165,8 @@ en: e5b55405bd2b69f5 zh: e5b55405bd2b69f5 /session-persistence-event-catalog/events/schedule/schedule-change-log-only: - en: 1d23d6738a98fbde - zh: 3da1dfebfe2f1d2e + en: 353442ce8925614f + zh: 71e6ab8296c74d2a /session-persistence-event-catalog/events/session: en: a1ac8f2660a4f61e zh: a1ac8f2660a4f61e @@ -897,11 +897,8 @@ en: d462b9a5e0855776 zh: d462b9a5e0855776 /session-persistence-event-catalog/resolved-persistence-types/4: - en: 1be6fa10b6117b5c - zh: 1be6fa10b6117b5c -/session-persistence-event-catalog/resolved-persistence-types/afterschedulerecord: - en: fa6f933b0bd06745 - zh: b66de9881c47473e + en: 9ec6b8bd755f3e33 + zh: 9ec6b8bd755f3e33 /session-persistence-event-catalog/resolved-persistence-types/agentinstructionchange: en: c79fd9b4b7a0a5ba zh: d196895911636220 @@ -927,11 +924,8 @@ en: 144fc40c557c39fb zh: 93edf73620f690bc /session-persistence-event-catalog/resolved-persistence-types/assistantstreamrecord~2: - en: 92d1cfcb2e6460c7 - zh: 709b94be1f8b7270 -/session-persistence-event-catalog/resolved-persistence-types/atschedulerecord: - en: 8b2f283ddc7660ac - zh: f449d9579770fae3 + en: e393deed01de3665 + zh: 0fa98ad93c22ce79 /session-persistence-event-catalog/resolved-persistence-types/commandsource: en: 86c7e2b4ae099661 zh: 2ffe1bd3f25b96fd @@ -966,11 +960,8 @@ en: d31020fa1fda6fa4 zh: 77984c4bf09443fb /session-persistence-event-catalog/resolved-persistence-types/everyscheduledispatchchange: - en: 89ddd311d931d6de - zh: 721587f81b40511b -/session-persistence-event-catalog/resolved-persistence-types/everyschedulerecord: - en: 936195016fec40d5 - zh: 60b2c64f544a2d2a + en: 54c487394ce0db1d + zh: 48d7c9affbe841cb /session-persistence-event-catalog/resolved-persistence-types/feedbackrecord: en: 3b6931556cfe6b9e zh: ce5a5b1d92ce9eac @@ -1035,8 +1026,20 @@ en: c239aa9c90226c35 zh: 3561e77832c7c8b5 /session-persistence-event-catalog/resolved-persistence-types/jsonvalue~2: - en: 11f46fc45dcbe604 - zh: 2661bc7ebf7b6152 + en: 6fb52f5c682b7efd + zh: 129b3a4109b617bc +/session-persistence-event-catalog/resolved-persistence-types/legacyafterschedulerecord: + en: 8a1ee52ff092bd30 + zh: 10db0a03ecc0ddfd +/session-persistence-event-catalog/resolved-persistence-types/legacyatschedulerecord: + en: 1362c9c357bfd526 + zh: 1d22c433bf49ea95 +/session-persistence-event-catalog/resolved-persistence-types/legacyeveryschedulerecord: + en: 4db10a2563b18010 + zh: cf8d17875d5b9002 +/session-persistence-event-catalog/resolved-persistence-types/legacyschedulerecord: + en: d2a3b6594fc71ea0 + zh: a5a7df42594fdb57 /session-persistence-event-catalog/resolved-persistence-types/llmcallconfig: en: aa3a91341bf04b9e zh: d767b45d45846333 @@ -1080,8 +1083,8 @@ en: 771e9ab3e64076bc zh: 30d5095a93549d55 /session-persistence-event-catalog/resolved-persistence-types/oneshotscheduledispatchchange: - en: f96aaab863f77802 - zh: 3d6f70473794ee06 + en: a3300babe57b6b67 + zh: 4aaf06a149b399c3 /session-persistence-event-catalog/resolved-persistence-types/oneshotsubagentdescriptordata: en: 9ba0cfd5ee9d6a38 zh: 1414fd7468c42651 @@ -1113,20 +1116,17 @@ en: 93c4403f94ecdc14 zh: 2b2eb0c400881d71 /session-persistence-event-catalog/resolved-persistence-types/sandboxmode: - en: 15c400d874e75ff1 - zh: ebcd2df0ba05f0db + en: 13071a796a3bfcd2 + zh: 93e5fbb1cdf78a79 /session-persistence-event-catalog/resolved-persistence-types/schedulechange: - en: 6a8be1d4040fb88c - zh: fe5430a81d577932 + en: 16be53be76c16720 + zh: 38fd164dde86ccac /session-persistence-event-catalog/resolved-persistence-types/schedulecreatechange: - en: 2ade6ccef730c278 - zh: 9fe4aec379fc45d9 + en: c2d3053c1ce07725 + zh: 0a3201d7c483c7c1 /session-persistence-event-catalog/resolved-persistence-types/scheduledeletechange: - en: 59231642e68b813a - zh: 80be1173e96d670f -/session-persistence-event-catalog/resolved-persistence-types/schedulerecord: - en: f9867069eed2d3fb - zh: 391a44aba281b0e4 + en: 35c0b15c285d045e + zh: 8ccce6ea60dd5742 /session-persistence-event-catalog/resolved-persistence-types/sessionheader: en: 0f60d736cd9d6c94 zh: 205b8833a4efc5e5 @@ -1278,8 +1278,8 @@ en: 38211d71372021e4 zh: c0305af81a67e285 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 8f788be68a2720c8 - zh: 67ec07a9c8699f6f + en: 052f9ce1988e44f2 + zh: 5adaad6c9bca43e2 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1941,11 +1941,11 @@ en: f34ce1d84ad0fb1e zh: c1c49f37c1e33f8c /session-persistence-event-catalog/resolved-persistence-types/type-sandbox-mode: - en: 590152834b153366 - zh: 1d051321572572a7 + en: 4d2313b10fef1ff0 + zh: 3a2cd6bf870ac7d3 /session-persistence-event-catalog/resolved-persistence-types/type-schedule-change: - en: 6c114aa51492ebf0 - zh: 1270e031f482c587 + en: 40e813f93a0215de + zh: d46d0f7e04e7323b /session-persistence-event-catalog/resolved-persistence-types/type-session-log-deepseek-delivery-accepted: en: d4dbca005a27b78b zh: b5792c3f38a6ba75 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index e654091bce..f3689af2d6 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -50,7 +50,7 @@ The [format references](persistence-changes/historical-formats/README.md) cover | `event:request/context` | event | `37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf` | [`{ type: "request/context" }`](#persistence-type-sha256-37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf) | | `event:request/header` | event | `4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41` | [`{ type: "request/header" }`](#persistence-type-sha256-4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41) | | `event:sandbox/mode` | event | `516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415` | [`{ type: "sandbox/mode" }`](#persistence-type-sha256-516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415) | -| `event:schedule/change` | event | `2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42` | [`{ type: "schedule/change" }`](#persistence-type-sha256-2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42) | +| `event:schedule/change` | event | `a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb` | [`{ type: "schedule/change" }`](#persistence-type-sha256-a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb) | | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | @@ -775,7 +775,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s Types: [ScheduleChange](subsystems/schedule.md) -Source: [`packages/schedule/schedule/src/types.ts:228`](../packages/schedule/schedule/src/types.ts) +Source: [`packages/schedule/schedule/src/types.ts:358`](../packages/schedule/schedule/src/types.ts) ### `session/*` @@ -2907,26 +2907,6 @@ SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0` `4` - - - - - - -### `AfterScheduleRecord` - -SHA-256: `27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe` - -Sources: [`packages/schedule/schedule/src/types.ts:15`](../packages/schedule/schedule/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `afterSeconds` | required | `number` | -| `id` | required | `string` | -| `kind` | required | `"after"` | -| `prompt` | required | `string` | -| `scheduledAt` | required | `string` | - @@ -3074,25 +3054,6 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7` Array of [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a178e6cc6f6544a4876f7f710cfea55eda473c8d329ef). - - - - - - -### `AtScheduleRecord` - -SHA-256: `658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e` - -Sources: [`packages/schedule/schedule/src/types.ts:29`](../packages/schedule/schedule/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `id` | required | `string` | -| `kind` | required | `"at"` | -| `prompt` | required | `string` | -| `scheduledAt` | required | `string` | - @@ -3298,7 +3259,7 @@ Sources: [`packages/core/session/src/types.ts:240`](../packages/core/session/src SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344` -Sources: [`packages/schedule/schedule/src/types.ts:95`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:244`](../packages/schedule/schedule/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -3307,26 +3268,6 @@ Sources: [`packages/schedule/schedule/src/types.ts:95`](../packages/schedule/sch | `operation` | required | `"dispatch"` | | `version` | required | `1` | - - - - - - -### `EveryScheduleRecord` - -SHA-256: `66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14` - -Sources: [`packages/schedule/schedule/src/types.ts:41`](../packages/schedule/schedule/src/types.ts) - -| Property | Presence | Type | -|---|---|---| -| `everySeconds` | required | `number` | -| `id` | required | `string` | -| `kind` | required | `"every"` | -| `prompt` | required | `string` | -| `scheduledAt` | required | `string` | - @@ -3725,6 +3666,86 @@ SHA-256: `72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227` Array of [`JsonValue`](#persistence-type-sha256-c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6). + + + + + + +### `LegacyAfterScheduleRecord` + +SHA-256: `3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f` + +Sources: [`packages/schedule/schedule/src/types.ts:161`](../packages/schedule/schedule/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `afterSeconds` | required | `number` | +| `id` | required | `string` | +| `kind` | required | `"after"` | +| `prompt` | required | `string` | +| `scheduledAt` | required | `string` | +| `title` | optional | `string` | + + + + + + + +### `LegacyAtScheduleRecord` + +SHA-256: `b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505` + +Sources: [`packages/schedule/schedule/src/types.ts:167`](../packages/schedule/schedule/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `id` | required | `string` | +| `kind` | required | `"at"` | +| `prompt` | required | `string` | +| `scheduledAt` | required | `string` | +| `title` | optional | `string` | + + + + + + + +### `LegacyEveryScheduleRecord` + +SHA-256: `a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb` + +Sources: [`packages/schedule/schedule/src/types.ts:173`](../packages/schedule/schedule/src/types.ts) + +| Property | Presence | Type | +|---|---|---| +| `everySeconds` | required | `number` | +| `id` | required | `string` | +| `kind` | required | `"every"` | +| `prompt` | required | `string` | +| `scheduledAt` | required | `string` | +| `title` | optional | `string` | + + + + + + + +### `LegacyScheduleRecord` + +SHA-256: `4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd` + +Sources: [`packages/schedule/schedule/src/types.ts:185`](../packages/schedule/schedule/src/types.ts) + +| kind | Form property | Other required fields | Full definition | +|---|---|---|---| +| `after` | not declared | `afterSeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyAfterScheduleRecord`](#persistence-type-sha256-3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f) | +| `at` | not declared | `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyAtScheduleRecord`](#persistence-type-sha256-b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505) | +| `every` | not declared | `everySeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyEveryScheduleRecord`](#persistence-type-sha256-a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb) | + @@ -4092,7 +4113,7 @@ Sources: [`packages/api/session-controller/src/types.ts:100`](../packages/api/se SHA-256: `8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af` -Sources: [`packages/schedule/schedule/src/types.ts:88`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:237`](../packages/schedule/schedule/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4294,7 +4315,7 @@ One of: - `"read-only"` - `"workspace-write"` - + @@ -4302,18 +4323,18 @@ One of: ### `ScheduleChange` -SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1` +SHA-256: `2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f` -Sources: [`packages/schedule/schedule/src/types.ts:107`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:256`](../packages/schedule/schedule/src/types.ts) One of: - [`EveryScheduleDispatchChange`](#persistence-type-sha256-e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344) - [`ScheduleDeleteChange`](#persistence-type-sha256-18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0) - [`OneShotScheduleDispatchChange`](#persistence-type-sha256-8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af) -- [`ScheduleCreateChange`](#persistence-type-sha256-ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f) +- [`ScheduleCreateChange`](#persistence-type-sha256-eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4) - + @@ -4321,14 +4342,14 @@ One of: ### `ScheduleCreateChange` -SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f` +SHA-256: `eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4` -Sources: [`packages/schedule/schedule/src/types.ts:74`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:223`](../packages/schedule/schedule/src/types.ts) | Property | Presence | Type | |---|---|---| | `operation` | required | `"create"` | -| `schedule` | required | [`ScheduleRecord`](#persistence-type-sha256-d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87) | +| `schedule` | required | [`LegacyScheduleRecord`](#persistence-type-sha256-4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd) | | `version` | required | `1` | @@ -4341,7 +4362,7 @@ Sources: [`packages/schedule/schedule/src/types.ts:74`](../packages/schedule/sch SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` -Sources: [`packages/schedule/schedule/src/types.ts:81`](../packages/schedule/schedule/src/types.ts) +Sources: [`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/schedule/src/types.ts) | Property | Presence | Type | |---|---|---| @@ -4349,24 +4370,6 @@ Sources: [`packages/schedule/schedule/src/types.ts:81`](../packages/schedule/sch | `operation` | required | `"delete"` | | `version` | required | `1` | - - - - - - -### `ScheduleRecord` - -SHA-256: `d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87` - -Sources: [`packages/schedule/schedule/src/types.ts:71`](../packages/schedule/schedule/src/types.ts) - -| kind | Form property | Other required fields | Full definition | -|---|---|---|---| -| `after` | not declared | `afterSeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`AfterScheduleRecord`](#persistence-type-sha256-27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe) | -| `at` | not declared | `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`AtScheduleRecord`](#persistence-type-sha256-658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e) | -| `every` | not declared | `everySeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`EveryScheduleRecord`](#persistence-type-sha256-66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14) | - @@ -5315,7 +5318,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -8162,17 +8165,17 @@ SHA-256: `516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415` | `time` | required | `number` | | `type` | required | `"sandbox/mode"` | - + ### `{ type: "schedule/change" }` -SHA-256: `2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42` +SHA-256: `a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb` | Property | Presence | Type | |---|---|---| -| `data` | required | [`ScheduleChange`](#persistence-type-sha256-e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1) | +| `data` | required | [`ScheduleChange`](#persistence-type-sha256-2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f) | | `ignorable` | optional | `true` | | `seq` | required | `number` | | `time` | required | `number` | diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 744bc3e42d..c9dd19db2b 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -52,7 +52,7 @@ | `event:request/context` | event | `37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf` | [`{ type: "request/context" }`](#persistence-type-sha256-37cbc9cf06d494cfe5c67f078af1605eacb8e4c2c853e9e26455b73de0ee7ccf) | | `event:request/header` | event | `4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41` | [`{ type: "request/header" }`](#persistence-type-sha256-4208123b50df5006b181481ab45fcf1cde807b88d3fd4d340090bc2e202fac41) | | `event:sandbox/mode` | event | `516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415` | [`{ type: "sandbox/mode" }`](#persistence-type-sha256-516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415) | -| `event:schedule/change` | event | `2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42` | [`{ type: "schedule/change" }`](#persistence-type-sha256-2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42) | +| `event:schedule/change` | event | `a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb` | [`{ type: "schedule/change" }`](#persistence-type-sha256-a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb) | | `event:session-log-deepseek/delivery-accepted` | event | `d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9` | [`{ type: "session-log-deepseek/delivery-accepted" }`](#persistence-type-sha256-d63b8b8ffad9c02fd80c43a17df4f240c1fe8118ecca9de34f9d5871838ab5b9) | | `event:session/end-seed` | event | `5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e` | [`{ type: "session/end-seed" }`](#persistence-type-sha256-5e6db6e24948d4a853c71cb9fabd252ad051ce93d4672c1266cf837c1c17b84e) | | `event:session/title` | event | `1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5` | [`{ type: "session/title" }`](#persistence-type-sha256-1b912703e2d64f91c99c675b8f805b01076c8325b905c1218ad81ef0b24909d5) | @@ -777,7 +777,7 @@ export type SessionEvent = { 类型:[ScheduleChange](subsystems/schedule.zh.md) -来源:[`packages/schedule/schedule/src/types.ts:228`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:358`](../packages/schedule/schedule/src/types.ts) ### `session/*` @@ -2909,26 +2909,6 @@ SHA-256: `af6e173f7be819bcd0a5c8d77918649c10ce761d8a184ff472f2e01ce64eb2e0` `4` - - - - - - -### `AfterScheduleRecord` - -SHA-256: `27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe` - -来源:[`packages/schedule/schedule/src/types.ts:15`](../packages/schedule/schedule/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `afterSeconds` | 必需 | `number` | -| `id` | 必需 | `string` | -| `kind` | 必需 | `"after"` | -| `prompt` | 必需 | `string` | -| `scheduledAt` | 必需 | `string` | - @@ -3076,25 +3056,6 @@ SHA-256: `84bbfdce7d2eab5b0c53e72ff7406db856c8bfd621b965b72fd86598ba493bf7` [`AssistantStreamRecord`](#persistence-type-sha256-33341aac09312300de5a178e6cc6f6544a4876f7f710cfea55eda473c8d329ef) 的数组。 - - - - - - -### `AtScheduleRecord` - -SHA-256: `658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e` - -来源:[`packages/schedule/schedule/src/types.ts:29`](../packages/schedule/schedule/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `id` | 必需 | `string` | -| `kind` | 必需 | `"at"` | -| `prompt` | 必需 | `string` | -| `scheduledAt` | 必需 | `string` | - @@ -3300,7 +3261,7 @@ SHA-256: `9a930283b91d493d6347146f2475bb107294b379415ca02162354f46a64164f7` SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344` -来源:[`packages/schedule/schedule/src/types.ts:95`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:244`](../packages/schedule/schedule/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -3309,26 +3270,6 @@ SHA-256: `e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344` | `operation` | 必需 | `"dispatch"` | | `version` | 必需 | `1` | - - - - - - -### `EveryScheduleRecord` - -SHA-256: `66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14` - -来源:[`packages/schedule/schedule/src/types.ts:41`](../packages/schedule/schedule/src/types.ts) - -| 属性 | 存在性 | 类型 | -|---|---|---| -| `everySeconds` | 必需 | `number` | -| `id` | 必需 | `string` | -| `kind` | 必需 | `"every"` | -| `prompt` | 必需 | `string` | -| `scheduledAt` | 必需 | `string` | - @@ -3727,6 +3668,86 @@ SHA-256: `72ec79127a9c0d0241b1106a74cc2b24c81ce467170f1c3c93f7b71a9496d227` [`JsonValue`](#persistence-type-sha256-c592ce75aab73fcab19c1d7845684c72cf402b78d2e1f2833a58ecf9f3598ed6) 的数组。 + + + + + + +### `LegacyAfterScheduleRecord` + +SHA-256: `3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f` + +来源:[`packages/schedule/schedule/src/types.ts:161`](../packages/schedule/schedule/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `afterSeconds` | 必需 | `number` | +| `id` | 必需 | `string` | +| `kind` | 必需 | `"after"` | +| `prompt` | 必需 | `string` | +| `scheduledAt` | 必需 | `string` | +| `title` | 可选 | `string` | + + + + + + + +### `LegacyAtScheduleRecord` + +SHA-256: `b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505` + +来源:[`packages/schedule/schedule/src/types.ts:167`](../packages/schedule/schedule/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `id` | 必需 | `string` | +| `kind` | 必需 | `"at"` | +| `prompt` | 必需 | `string` | +| `scheduledAt` | 必需 | `string` | +| `title` | 可选 | `string` | + + + + + + + +### `LegacyEveryScheduleRecord` + +SHA-256: `a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb` + +来源:[`packages/schedule/schedule/src/types.ts:173`](../packages/schedule/schedule/src/types.ts) + +| 属性 | 存在性 | 类型 | +|---|---|---| +| `everySeconds` | 必需 | `number` | +| `id` | 必需 | `string` | +| `kind` | 必需 | `"every"` | +| `prompt` | 必需 | `string` | +| `scheduledAt` | 必需 | `string` | +| `title` | 可选 | `string` | + + + + + + + +### `LegacyScheduleRecord` + +SHA-256: `4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd` + +来源:[`packages/schedule/schedule/src/types.ts:185`](../packages/schedule/schedule/src/types.ts) + +| kind | form 属性 | 其他必需字段 | 完整定义 | +|---|---|---|---| +| `after` | 未声明 | `afterSeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyAfterScheduleRecord`](#persistence-type-sha256-3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f) | +| `at` | 未声明 | `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyAtScheduleRecord`](#persistence-type-sha256-b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505) | +| `every` | 未声明 | `everySeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`LegacyEveryScheduleRecord`](#persistence-type-sha256-a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb) | + @@ -4094,7 +4115,7 @@ SHA-256: `f09e5e7acf5ff5ccd193ff2eeec015fa2ff7c29d98bc465f8c3302058d47e29b` SHA-256: `8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af` -来源:[`packages/schedule/schedule/src/types.ts:88`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:237`](../packages/schedule/schedule/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4296,7 +4317,7 @@ SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` - `"read-only"` - `"workspace-write"` - + @@ -4304,18 +4325,18 @@ SHA-256: `fab2bb09bc5e3b9db0685e8f5500b029af1a2b33e70bc703c1d5d0f596fbc1fd` ### `ScheduleChange` -SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1` +SHA-256: `2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f` -来源:[`packages/schedule/schedule/src/types.ts:107`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:256`](../packages/schedule/schedule/src/types.ts) 以下类型之一: - [`EveryScheduleDispatchChange`](#persistence-type-sha256-e6063d0451dc02026fd9b54ad3f77c7b1b6392ad3e872a7737ac84e13ca72344) - [`ScheduleDeleteChange`](#persistence-type-sha256-18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0) - [`OneShotScheduleDispatchChange`](#persistence-type-sha256-8e8e91646a5cae3fc78f7532a0013ff2088ae47077642c2edd507a508209b8af) -- [`ScheduleCreateChange`](#persistence-type-sha256-ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f) +- [`ScheduleCreateChange`](#persistence-type-sha256-eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4) - + @@ -4323,14 +4344,14 @@ SHA-256: `e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1` ### `ScheduleCreateChange` -SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f` +SHA-256: `eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4` -来源:[`packages/schedule/schedule/src/types.ts:74`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:223`](../packages/schedule/schedule/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| | `operation` | 必需 | `"create"` | -| `schedule` | 必需 | [`ScheduleRecord`](#persistence-type-sha256-d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87) | +| `schedule` | 必需 | [`LegacyScheduleRecord`](#persistence-type-sha256-4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd) | | `version` | 必需 | `1` | @@ -4343,7 +4364,7 @@ SHA-256: `ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f` SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` -来源:[`packages/schedule/schedule/src/types.ts:81`](../packages/schedule/schedule/src/types.ts) +来源:[`packages/schedule/schedule/src/types.ts:230`](../packages/schedule/schedule/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| @@ -4351,24 +4372,6 @@ SHA-256: `18dc21f12bbac8ade8377e6fbb54179e20c3d955d73fba3244c57fb6bf001df0` | `operation` | 必需 | `"delete"` | | `version` | 必需 | `1` | - - - - - - -### `ScheduleRecord` - -SHA-256: `d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87` - -来源:[`packages/schedule/schedule/src/types.ts:71`](../packages/schedule/schedule/src/types.ts) - -| kind | form 属性 | 其他必需字段 | 完整定义 | -|---|---|---|---| -| `after` | 未声明 | `afterSeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`AfterScheduleRecord`](#persistence-type-sha256-27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe) | -| `at` | 未声明 | `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`AtScheduleRecord`](#persistence-type-sha256-658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e) | -| `every` | 未声明 | `everySeconds`: `number`; `id`: `string`; `prompt`: `string`; `scheduledAt`: `string` | [`EveryScheduleRecord`](#persistence-type-sha256-66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14) | - @@ -5317,7 +5320,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:12`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:398`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -8164,17 +8167,17 @@ SHA-256: `516da4cdd6d2f1e5ce488e648578ca51f40e458f707b803e5b24de870e799415` | `time` | 必需 | `number` | | `type` | 必需 | `"sandbox/mode"` | - + ### `{ type: "schedule/change" }` -SHA-256: `2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42` +SHA-256: `a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb` | 属性 | 存在性 | 类型 | |---|---|---| -| `data` | 必需 | [`ScheduleChange`](#persistence-type-sha256-e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1) | +| `data` | 必需 | [`ScheduleChange`](#persistence-type-sha256-2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f) | | `ignorable` | 可选 | `true` | | `seq` | 必需 | `number` | | `time` | 必需 | `number` | diff --git a/docs/persistence-changes/2026-09-18-schedule-optional-title.i18n.yaml b/docs/persistence-changes/2026-09-18-schedule-optional-title.i18n.yaml new file mode 100644 index 0000000000..4ab106dfe2 --- /dev/null +++ b/docs/persistence-changes/2026-09-18-schedule-optional-title.i18n.yaml @@ -0,0 +1,28 @@ +# Bilingual-pair consistency record for 2026-09-18-schedule-optional-title.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/persistence-changes/2026-09-18-schedule-optional-title.md +/: + en: 3d5a424ae7d7e46b + zh: c22a8e4a62d8cc1a +/2026-09-18-schedule-optional-title: + en: dff09041a3682fc5 + zh: f27d9f3e7dfe8996 +/2026-09-18-schedule-optional-title/summary: + en: cf0b4015528ea944 + zh: c1400b008fffe333 +/2026-09-18-schedule-optional-title/table-of-contents: + en: 6cf314a069ceb451 + zh: 9adff678e9ecab3b +/2026-09-18-schedule-optional-title/declaration: + en: 6907136f28e48c90 + zh: 02f8298485f3d8e7 +/2026-09-18-schedule-optional-title/compatibility: + en: d9caa582fc9306ee + zh: 3a725f4e16a5bb8e +/2026-09-18-schedule-optional-title/verification: + en: 62bf89a21ce1ff76 + zh: 71ede4795dd8a053 +/2026-09-18-schedule-optional-title/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/persistence-changes/2026-09-18-schedule-optional-title.md b/docs/persistence-changes/2026-09-18-schedule-optional-title.md new file mode 100644 index 0000000000..30e129548b --- /dev/null +++ b/docs/persistence-changes/2026-09-18-schedule-optional-title.md @@ -0,0 +1,48 @@ +--- +description: "Records a persistence type transition and its compatibility acknowledgement." +kind: persistence-change +--- + +# 2026-09-18-schedule-optional-title + +English | [中文](2026-09-18-schedule-optional-title.zh.md) + +## Summary + +Makes the stored title optional on the after, at, and every variants of a persisted schedule/change create record. + +## Table of Contents + +- [Declaration](#declaration) +- [Compatibility](#compatibility) +- [Verification](#verification) +- [Dev Note](#dev-note) + + +## Declaration + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-18-schedule-optional-title +baseline: false +changes: + - root: "event:schedule/change" + previous: "2026-09-11-initial" + after: "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb" + decision: same-version +``` + + +## Compatibility + +A version-1 Session event written before titles existed has no title member, so those logs decode and fold without one instead of being refused. Readers that require a name treat an absent title as an unnamed task. The Host task record still requires the member: the storage decoder rejects a stored task without it, and creation, update, and the schedule tools still require it. + + +## Verification + +pnpm exec vitest run packages/schedule/schedule/tests: 18 files, 789 tests passed. + + +## Dev Note + +None. diff --git a/docs/persistence-changes/2026-09-18-schedule-optional-title.schema.json b/docs/persistence-changes/2026-09-18-schedule-optional-title.schema.json new file mode 100644 index 0000000000..0b9bc4798a --- /dev/null +++ b/docs/persistence-changes/2026-09-18-schedule-optional-title.schema.json @@ -0,0 +1,302 @@ +{ + "formatVersion": 1, + "roots": [ + { + "key": "event:schedule/change", + "kind": "event", + "event": "schedule/change", + "surface": false, + "digest": "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 19, + "optional": true + }, + { + "name": "seq", + "type": 13, + "optional": false + }, + { + "name": "time", + "type": 13, + "optional": false + }, + { + "name": "type", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 6, + 8, + 9 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "acceptedAt", + "type": 3, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "dispatch" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 7, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "delete" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "operation", + "type": 10, + "optional": false + }, + { + "name": "schedule", + "type": 11, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "create" + }, + { + "kind": "union", + "types": [ + 12, + 15, + 17 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 13, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 14, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 13, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 16, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 18, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "schedule/change" + } + ] + } + } + ], + "types": [] +} diff --git a/docs/persistence-changes/2026-09-18-schedule-optional-title.zh.md b/docs/persistence-changes/2026-09-18-schedule-optional-title.zh.md new file mode 100644 index 0000000000..d46d5920c1 --- /dev/null +++ b/docs/persistence-changes/2026-09-18-schedule-optional-title.zh.md @@ -0,0 +1,48 @@ +--- +description: "记录持久化类型更改及其兼容性确认。" +kind: persistence-change +--- + +# 2026-09-18-schedule-optional-title + +[English](2026-09-18-schedule-optional-title.md) | 中文 + +## 概述 + +将持久化的 schedule/change create 记录中 after、at、every 三个变体的已存 title 改为可选。 + +## 目录 + +- [声明](#declaration) +- [兼容性](#compatibility) +- [验证](#verification) +- [开发备注](#dev-note) + + +## 声明 + +```yaml persistence-change +schemaVersion: 1 +id: 2026-09-18-schedule-optional-title +baseline: false +changes: + - root: "event:schedule/change" + previous: "2026-09-11-initial" + after: "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb" + decision: same-version +``` + + +## 兼容性 + +在 title 出现之前写入的版本 1 Session 事件没有 title 成员,因此这些日志可以在没有该成员的情况下解码与折叠,而不会被拒绝。需要名称的读取方将缺失的 title 视为未命名任务。宿主任务记录仍然要求该成员:存储解码器拒绝没有它的已存任务,创建、更新与 schedule 工具也仍然要求它。 + + +## 验证 + +pnpm exec vitest run packages/schedule/schedule/tests:18 个文件、789 个测试通过。 + + +## 开发备注 + +无。 diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 446bcf7503..a92d9bd4e7 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -11189,7 +11189,7 @@ "kind": "event", "event": "schedule/change", "surface": false, - "digest": "2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42", + "digest": "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb", "schema": { "root": 0, "nodes": [ @@ -11377,6 +11377,11 @@ "name": "scheduledAt", "type": 3, "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true } ], "indices": [] @@ -11416,6 +11421,11 @@ "name": "scheduledAt", "type": 3, "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true } ], "indices": [] @@ -11446,6 +11456,11 @@ "name": "scheduledAt", "type": 3, "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true } ], "indices": [] @@ -29438,7 +29453,7 @@ "packages/schedule/schedule/src/types.ts#ScheduleDeleteChange" ], "sources": [ - "packages/schedule/schedule/src/types.ts:81" + "packages/schedule/schedule/src/types.ts:230" ] }, { @@ -32630,63 +32645,6 @@ "names": [], "sources": [] }, - { - "digest": "27e337d3d6b402b221e50c247a3f518a70d0c474cc60beee15fac3e9c5cbfcbe", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 1, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 3, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "after" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#AfterScheduleRecord" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:15" - ] - }, { "digest": "287211846beea1eccb5f9acee00d20388a38fcb2e95858fa2332d6f58529379a", "schema": { @@ -32836,285 +32794,6 @@ "names": [], "sources": [] }, - { - "digest": "2a7f86849ae54b3398ee49661a757c4fcb59a6192b7036ee2ff514617e13fb42", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "data", - "type": 1, - "optional": false - }, - { - "name": "ignorable", - "type": 19, - "optional": true - }, - { - "name": "seq", - "type": 13, - "optional": false - }, - { - "name": "time", - "type": 13, - "optional": false - }, - { - "name": "type", - "type": 20, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "union", - "types": [ - 2, - 6, - 8, - 9 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "acceptedAt", - "type": 3, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "operation", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "dispatch" - }, - { - "kind": "literal", - "value": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "operation", - "type": 7, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "delete" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "operation", - "type": 4, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "operation", - "type": 10, - "optional": false - }, - { - "name": "schedule", - "type": 11, - "optional": false - }, - { - "name": "version", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "create" - }, - { - "kind": "union", - "types": [ - 12, - 15, - 17 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 13, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 14, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "literal", - "value": "after" - }, - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 13, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 16, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "every" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 18, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "at" - }, - { - "kind": "literal", - "value": true - }, - { - "kind": "literal", - "value": "schedule/change" - } - ] - }, - "names": [], - "sources": [] - }, { "digest": "2aad1179f957631477b582e57cd8f538cb82618fc3fb816bde1171154235b065", "schema": { @@ -33388,6 +33067,265 @@ "names": [], "sources": [] }, + { + "digest": "2e3523541e942ef6cc9fbc5b91f242e0ca4c6ada479b79b96025b797f6e41f3f", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 5, + 7, + 8 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "acceptedAt", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "operation", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "dispatch" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "operation", + "type": 6, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "delete" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "operation", + "type": 3, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "operation", + "type": 9, + "optional": false + }, + { + "name": "schedule", + "type": 10, + "optional": false + }, + { + "name": "version", + "type": 4, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "create" + }, + { + "kind": "union", + "types": [ + 11, + 14, + 16 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 12, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 13, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 12, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 15, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 17, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#ScheduleChange" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:256" + ] + }, { "digest": "2e4debca12f2c449f96dc6574f3fbf2fb346193443114a241d01a236362ec48c", "schema": { @@ -39330,6 +39268,68 @@ "names": [], "sources": [] }, + { + "digest": "3fdf9582c3b5597df104d32d97dab6c6ee3a93794f3034ec6ada6272404ea27f", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 1, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 3, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "after" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#LegacyAfterScheduleRecord" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:161" + ] + }, { "digest": "408927267422a7b2beb6df354ff7c1406d929a5646554f48adf85eb8372f811c", "schema": { @@ -44121,6 +44121,151 @@ "names": [], "sources": [] }, + { + "digest": "4b1e632e05776982a2d21c25a50c7c04c199d311c5675218fa73ff9fe30d22cd", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "union", + "types": [ + 1, + 5, + 7 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 4, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 2, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 6, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 8, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#LegacyScheduleRecord" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:185" + ] + }, { "digest": "4b259f7f8f0a44ad5e90b70a73553ced1df27012d126921d397ff403b6c86731", "schema": { @@ -44624,7 +44769,7 @@ "packages/llm/llm/src/brand.ts:31", "packages/llm/llm/src/brand.ts:43", "packages/llm/llm/src/brand.ts:67", - "packages/schedule/schedule/src/types.ts:12", + "packages/schedule/schedule/src/types.ts:14", "packages/session/session-title/src/index.ts:42", "packages/session/session-title/src/types.ts:17", "packages/webhook/webhook/src/brand.ts:12", @@ -54330,54 +54475,6 @@ "packages/llm/llm/src/message.ts:162" ] }, - { - "digest": "658d895d187b26d83f3b53109a3bf77bbfba9f6f9a6d4bc4ee4315b4eb36239e", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 1, - "optional": false - }, - { - "name": "kind", - "type": 2, - "optional": false - }, - { - "name": "prompt", - "type": 1, - "optional": false - }, - { - "name": "scheduledAt", - "type": 1, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "at" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#AtScheduleRecord" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:29" - ] - }, { "digest": "6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25", "schema": { @@ -54563,63 +54660,6 @@ "packages/llm/llm/src/types.ts:461" ] }, - { - "digest": "66ece68282d8c06330fb83329a24ae54c386d5ccfab296bbf18369da8beb7d14", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 1, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 3, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "every" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#EveryScheduleRecord" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:41" - ] - }, { "digest": "672c4aa2a9b736e3d6e5da6346d2e2ed5e3573f2d0baafa18e7407c9f3fee3db", "schema": { @@ -63758,7 +63798,7 @@ "packages/schedule/schedule/src/types.ts#OneShotScheduleDispatchChange" ], "sources": [ - "packages/schedule/schedule/src/types.ts:88" + "packages/schedule/schedule/src/types.ts:237" ] }, { @@ -68471,6 +68511,300 @@ "names": [], "sources": [] }, + { + "digest": "a0a2e5c42e1c929445ecd1cd70f49be6b66441894ec72c08e8ae332821d4a3cb", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "data", + "type": 1, + "optional": false + }, + { + "name": "ignorable", + "type": 19, + "optional": true + }, + { + "name": "seq", + "type": 13, + "optional": false + }, + { + "name": "time", + "type": 13, + "optional": false + }, + { + "name": "type", + "type": 20, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "union", + "types": [ + 2, + 6, + 8, + 9 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "acceptedAt", + "type": 3, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "dispatch" + }, + { + "kind": "literal", + "value": 1 + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 7, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "delete" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "operation", + "type": 4, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "object", + "properties": [ + { + "name": "operation", + "type": 10, + "optional": false + }, + { + "name": "schedule", + "type": 11, + "optional": false + }, + { + "name": "version", + "type": 5, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "create" + }, + { + "kind": "union", + "types": [ + 12, + 15, + 17 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 13, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 14, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 13, + "optional": false + }, + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 16, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 3, + "optional": false + }, + { + "name": "kind", + "type": 18, + "optional": false + }, + { + "name": "prompt", + "type": 3, + "optional": false + }, + { + "name": "scheduledAt", + "type": 3, + "optional": false + }, + { + "name": "title", + "type": 3, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + }, + { + "kind": "literal", + "value": true + }, + { + "kind": "literal", + "value": "schedule/change" + } + ] + }, + "names": [], + "sources": [] + }, { "digest": "a0baff42eb900561f91032b6950a3925cf19c5c8d9ba33948ac69e4ac7dee573", "schema": { @@ -69540,6 +69874,68 @@ "names": [], "sources": [] }, + { + "digest": "a926dc71814edd01a4f041eb5fc766d153db3407ef4704e98f4c1b6527309ebb", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 1, + "optional": false + }, + { + "name": "id", + "type": 2, + "optional": false + }, + { + "name": "kind", + "type": 3, + "optional": false + }, + { + "name": "prompt", + "type": 2, + "optional": false + }, + { + "name": "scheduledAt", + "type": 2, + "optional": false + }, + { + "name": "title", + "type": 2, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "every" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#LegacyEveryScheduleRecord" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:173" + ] + }, { "digest": "a99ec0993c62c058e50af9f68922202a3b54f5e259bc2abf1915a73381d2316d", "schema": { @@ -72178,6 +72574,59 @@ "names": [], "sources": [] }, + { + "digest": "b63b089a00b3b9357675b3e0c3d3e9aa4fdbda1fba9c91a6b0c59ffdbb89f505", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 1, + "optional": false + }, + { + "name": "kind", + "type": 2, + "optional": false + }, + { + "name": "prompt", + "type": 1, + "optional": false + }, + { + "name": "scheduledAt", + "type": 1, + "optional": false + }, + { + "name": "title", + "type": 1, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "at" + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#LegacyAtScheduleRecord" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:167" + ] + }, { "digest": "b6896938660a1dfe4ddd100fb3164d9f8fa57feeabfe37441a80106c9942697a", "schema": { @@ -74016,165 +74465,6 @@ "packages/compaction/compaction/src/types.ts:72" ] }, - { - "digest": "ca6425edd20afe34d0cedc6ad1abad1ad7b32e2fbc4c1d24db53939e80bf887f", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "object", - "properties": [ - { - "name": "operation", - "type": 1, - "optional": false - }, - { - "name": "schedule", - "type": 2, - "optional": false - }, - { - "name": "version", - "type": 11, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "create" - }, - { - "kind": "union", - "types": [ - 3, - 7, - 9 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 4, - "optional": false - }, - { - "name": "id", - "type": 5, - "optional": false - }, - { - "name": "kind", - "type": 6, - "optional": false - }, - { - "name": "prompt", - "type": 5, - "optional": false - }, - { - "name": "scheduledAt", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "after" - }, - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 4, - "optional": false - }, - { - "name": "id", - "type": 5, - "optional": false - }, - { - "name": "kind", - "type": 8, - "optional": false - }, - { - "name": "prompt", - "type": 5, - "optional": false - }, - { - "name": "scheduledAt", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "every" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 5, - "optional": false - }, - { - "name": "kind", - "type": 10, - "optional": false - }, - { - "name": "prompt", - "type": 5, - "optional": false - }, - { - "name": "scheduledAt", - "type": 5, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "at" - }, - { - "kind": "literal", - "value": 1 - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#ScheduleCreateChange" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:74" - ] - }, { "digest": "cb1c8959e1726f82f9a3d2ef2bc90bf8c451a98fd56175e956022124c770c818", "schema": { @@ -78037,136 +78327,6 @@ "names": [], "sources": [] }, - { - "digest": "d8f868c6b3e7189de6c1ffe2ea04794ea2aa31315b6347d12536e46020afac87", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 5, - 7 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 2, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 4, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "after" - }, - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 2, - "optional": false - }, - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 6, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "every" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 3, - "optional": false - }, - { - "name": "kind", - "type": 8, - "optional": false - }, - { - "name": "prompt", - "type": 3, - "optional": false - }, - { - "name": "scheduledAt", - "type": 3, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "at" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#ScheduleRecord" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:71" - ] - }, { "digest": "d9196086e41e4dd5c2ce87f5c25529c8112a1731d258cf0728747d41a9ee3241", "schema": { @@ -81555,250 +81715,6 @@ "names": [], "sources": [] }, - { - "digest": "e1d0e52dcd1855ffb803efc18f8f7b776c48d1c25757e8a75c0281fa298c02e1", - "schema": { - "root": 0, - "nodes": [ - { - "kind": "union", - "types": [ - 1, - 5, - 7, - 8 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "acceptedAt", - "type": 2, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "operation", - "type": 3, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "string" - }, - { - "kind": "literal", - "value": "dispatch" - }, - { - "kind": "literal", - "value": 1 - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "operation", - "type": 6, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "delete" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "operation", - "type": 3, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "object", - "properties": [ - { - "name": "operation", - "type": 9, - "optional": false - }, - { - "name": "schedule", - "type": 10, - "optional": false - }, - { - "name": "version", - "type": 4, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "create" - }, - { - "kind": "union", - "types": [ - 11, - 14, - 16 - ] - }, - { - "kind": "object", - "properties": [ - { - "name": "afterSeconds", - "type": 12, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 13, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "primitive", - "type": "number" - }, - { - "kind": "literal", - "value": "after" - }, - { - "kind": "object", - "properties": [ - { - "name": "everySeconds", - "type": 12, - "optional": false - }, - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 15, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "every" - }, - { - "kind": "object", - "properties": [ - { - "name": "id", - "type": 2, - "optional": false - }, - { - "name": "kind", - "type": 17, - "optional": false - }, - { - "name": "prompt", - "type": 2, - "optional": false - }, - { - "name": "scheduledAt", - "type": 2, - "optional": false - } - ], - "indices": [] - }, - { - "kind": "literal", - "value": "at" - } - ] - }, - "names": [ - "packages/schedule/schedule/src/types.ts#ScheduleChange" - ], - "sources": [ - "packages/schedule/schedule/src/types.ts:107" - ] - }, { "digest": "e2ad8f5bcd813f268ffef1b10a12d7ddd2b8ced8b9b6d0275b3f6b7ed094a187", "schema": { @@ -82985,7 +82901,7 @@ "packages/schedule/schedule/src/types.ts#EveryScheduleDispatchChange" ], "sources": [ - "packages/schedule/schedule/src/types.ts:95" + "packages/schedule/schedule/src/types.ts:244" ] }, { @@ -83842,6 +83758,180 @@ "names": [], "sources": [] }, + { + "digest": "eea687d437880bd5f55fb447a561112800ca34334c0724d9cecb98493059d9e4", + "schema": { + "root": 0, + "nodes": [ + { + "kind": "object", + "properties": [ + { + "name": "operation", + "type": 1, + "optional": false + }, + { + "name": "schedule", + "type": 2, + "optional": false + }, + { + "name": "version", + "type": 11, + "optional": false + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "create" + }, + { + "kind": "union", + "types": [ + 3, + 7, + 9 + ] + }, + { + "kind": "object", + "properties": [ + { + "name": "afterSeconds", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 6, + "optional": false + }, + { + "name": "prompt", + "type": 5, + "optional": false + }, + { + "name": "scheduledAt", + "type": 5, + "optional": false + }, + { + "name": "title", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "primitive", + "type": "number" + }, + { + "kind": "primitive", + "type": "string" + }, + { + "kind": "literal", + "value": "after" + }, + { + "kind": "object", + "properties": [ + { + "name": "everySeconds", + "type": 4, + "optional": false + }, + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 8, + "optional": false + }, + { + "name": "prompt", + "type": 5, + "optional": false + }, + { + "name": "scheduledAt", + "type": 5, + "optional": false + }, + { + "name": "title", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "every" + }, + { + "kind": "object", + "properties": [ + { + "name": "id", + "type": 5, + "optional": false + }, + { + "name": "kind", + "type": 10, + "optional": false + }, + { + "name": "prompt", + "type": 5, + "optional": false + }, + { + "name": "scheduledAt", + "type": 5, + "optional": false + }, + { + "name": "title", + "type": 5, + "optional": true + } + ], + "indices": [] + }, + { + "kind": "literal", + "value": "at" + }, + { + "kind": "literal", + "value": 1 + } + ] + }, + "names": [ + "packages/schedule/schedule/src/types.ts#ScheduleCreateChange" + ], + "sources": [ + "packages/schedule/schedule/src/types.ts:223" + ] + }, { "digest": "eef4ef54dc7a133d47448a4ee822e45a351314923ef5f66db34c8b24e4b32d80", "schema": { diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 0f2b71ceeb..a7934d3e58 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -3,5 +3,5 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md /subsystems: - en: abd1088109b69d49 - zh: 7564a0579de5e142 + en: 58466f6fd60755eb + zh: ab46a374f5a3c3ed diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index d2e8dea206..479dbe7cb3 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -13,7 +13,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [scope.md](scope.md) | scoped registration identity, dispatch carriers, and the owned `Scope` context | | [typert.md](typert.md) | Remote invocation descriptors, lookup/Context declarations, Typert registries, and the Host Gateway/Client API boundaries | | [goal.md](goal.md) | persisted goal identity, lifecycle snapshots, activation, change records, and round attribution | -| [schedule.md](schedule.md) | Session-local reminder records, durable transitions, active views, and ordinary-conversation delivery | +| [schedule.md](schedule.md) | Host-owned reminder records, durable transitions, active and ended views, and ordinary-conversation delivery | | [todo.md](todo.md) | the todo package's whole-list item type, durable event ownership, projection, and open-turn invariant | | [deliverables.md](deliverables.md) | what a turn hands to the user: `PresentedFile` deliveries from `present` and the Host-served `WorkspaceChangesSummary` of changed files from git snapshots | | [commands.md](commands.md) | the human-command registry service: definitions, adapter discovery, direct invocation, results, and parsing views | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 78c97a016a..a6b9226df6 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -13,7 +13,7 @@ | [scope.md](scope.zh.md) | 作用域注册标识、dispatch 载体,以及拥有的 `Scope` 上下文 | | [typert.md](typert.zh.md) | 远程调用描述符、lookup/Context 声明、Typert 注册表,以及 Host Gateway/Client API 边界 | | [goal.md](goal.zh.md) | 持久 goal 标识、生命周期快照、激活、变更记录与 Round 归属 | -| [schedule.md](schedule.zh.md) | 仅限会话内的提醒记录、持久转换、活动视图与普通对话交付 | +| [schedule.md](schedule.zh.md) | Host 拥有的提醒记录、持久转换、活动和已结束视图与普通对话交付 | | [todo.md](todo.zh.md) | todo 包的整列表条目类型、持久事件所有权、投影和未结束轮次不变式 | | [deliverables.md](deliverables.zh.md) | 一轮交给用户的东西:`present` 声明的 `PresentedFile` 交付,以及由 git 快照得出、由 Host 提供的 `WorkspaceChangesSummary` 改动摘要 | | [commands.md](commands.zh.md) | 人类命令注册表服务:定义、适配器发现、直接调用、结果与解析视图 | diff --git a/docs/subsystems/schedule.i18n.yaml b/docs/subsystems/schedule.i18n.yaml index f73b0b4513..5fae9f54a6 100644 --- a/docs/subsystems/schedule.i18n.yaml +++ b/docs/subsystems/schedule.i18n.yaml @@ -2,27 +2,39 @@ # 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/subsystems/schedule.md -/session-local-schedule: - en: d9c45890973c8312 - zh: dc24bdb262b521c8 -/session-local-schedule/durable-records: - en: 65b12288096cbacb - zh: 50a39aaf9fd16b76 -/session-local-schedule/absolute-time-input: - en: 831be0104c9a76ab - zh: f95f1791a918e272 -/session-local-schedule/fixed-rate-input-and-catch-up: - en: 071aa73c5c4ca8cd - zh: 34f4ea9a0d0e3f90 -/session-local-schedule/durable-changes-and-replay: - en: cc9573bae1cabf26 - zh: 9d32f9efcbb7b3c1 -/session-local-schedule/active-views-and-management: - en: 057d439bc9263d02 - zh: 9a675fd7fecf4e45 -/session-local-schedule/read-only-web-catalog: - en: 3bfdb5370bd16560 - zh: 5176c01962819f0f -/session-local-schedule/live-delivery: - en: 95a78df72f98f9b8 - zh: ffaa6f163cdf9034 +/host-wide-schedule: + en: 71c4686e65a7c7f9 + zh: dfba124c28db2f63 +/host-wide-schedule/durable-records: + en: 41cd197b5c74bfa3 + zh: 18ed11259036d726 +/host-wide-schedule/absolute-time-input: + en: 9f814e5a1c9f93c1 + zh: 5f04bd46f43ebbf9 +/host-wide-schedule/daily-wall-clock-input: + en: 2d249edc3dfccb8a + zh: 33fecda132b03b84 +/host-wide-schedule/fixed-rate-input-and-catch-up: + en: 9057ff8733ca051a + zh: c4bf9d86363808cd +/host-wide-schedule/cron-wall-clock-input: + en: 1ba9ae84f05d8919 + zh: 5b4abfdcddbbcae0 +/host-wide-schedule/historical-session-changes: + en: 9cd7f65e0699aa91 + zh: 42f2a6fda33d2ff0 +/host-wide-schedule/active-views-and-management: + en: 0656173de50ee132 + zh: 54176540371d0f28 +/host-wide-schedule/web-catalog: + en: 3526df6b736ae81c + zh: 677a09e8ac367e56 +/host-wide-schedule/timing-edits: + en: ce5d2095a3ad2e82 + zh: 167b3715f8eaffc1 +/host-wide-schedule/saved-delivery-records: + en: 077aa6573d9ede1a + zh: 0acea592a6e3630a +/host-wide-schedule/host-delivery: + en: b6d522d24b9b97ba + zh: 84df8ea09bedf51c diff --git a/docs/subsystems/schedule.md b/docs/subsystems/schedule.md index b92b81c6f2..6dca5eabcc 100644 --- a/docs/subsystems/schedule.md +++ b/docs/subsystems/schedule.md @@ -1,20 +1,22 @@ -# Session-local Schedule +# Host-wide Schedule English | [中文](schedule.zh.md) -Schedule owns durable reminders that return to the original live Session as ordinary later conversation turns. The [durable Schedule Agent Note](../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md) owns persistence, lifecycle, and active-state presentation, and the [explicit time-zone boundary](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md) owns browser-local interpretation. This page records the durable and model-facing shapes from [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts); the [package README](../../packages/schedule/schedule/README.md) owns composition, tool behavior, and the exact reminder framing. +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. ## Durable records -`ScheduleId` is a [branded id](core.md#branded-ids), unique and never reused within one Session. Version 1 supports a positive safe-integer `after_seconds` delay, an explicit absolute `at` target, or a safe-integer `every_seconds` interval of at least five minutes. Creation canonicalizes every first target into a four-digit-year RFC 3339 UTC `scheduledAt`; an `after` record retains its submitted delay, an `at` record stores only the resulting instant, and an `every` record retains its fixed interval and next target. +`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. ```ts type-equiv /** Durable one-shot reminder created from a positive delay. */ interface AfterScheduleRecord { - /** Session-local stable identity. */ + /** 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. */ @@ -27,10 +29,12 @@ interface AfterScheduleRecord { ```ts type-equiv /** Durable one-shot reminder created from an absolute instant. */ interface AtScheduleRecord { - /** Session-local stable identity. */ + /** 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. */ @@ -39,29 +43,56 @@ interface AtScheduleRecord { ``` ```ts type-equiv -/** Durable fixed-rate reminder whose next target remains creation-anchor-aligned. */ +/** Durable fixed-rate reminder aligned to creation or its most recent interval edit. */ interface EveryScheduleRecord { - /** Session-local stable identity. */ + /** 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 five minutes. */ readonly everySeconds: number - /** Earliest anchor-aligned occurrence not yet dispatched. */ + /** Next anchor-aligned occurrence while active, or final occurrence when inactive. */ readonly scheduledAt: string } ``` ```ts type-equiv -/** One-shot record variants that terminate on an id-only dispatch. */ +/** 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 +} +``` + +```ts type-equiv +/** One-shot task variants. */ type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord ``` ```ts type-equiv -/** The v1 durable reminder record union. */ -type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord +/** Recurring Host task variants. */ +type RecurringScheduleRecord = EveryScheduleRecord | DailyScheduleRecord | WeeklyScheduleRecord | CronScheduleRecord +``` + +```ts type-equiv +/** Reminder rule and target, stored with its original Session binding. */ +type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord ``` ## Absolute-time input @@ -69,7 +100,7 @@ type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord 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 `schedule_create`. */ +/** Structured local-calendar input accepted by creation and timing edits. */ interface LocalAtInput { /** Four-digit ISO calendar date. */ readonly date: string @@ -81,7 +112,7 @@ interface LocalAtInput { ``` ```ts type-equiv -/** Absolute selector accepted by `schedule_create`. */ +/** Absolute selector accepted by creation and timing edits. */ type AtInput = string | LocalAtInput ``` @@ -89,24 +120,80 @@ The official Web overlay samples the browser's IANA zone for every prompt. Time- 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. + +## Daily wall-clock 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. + ## Fixed-rate input and catch-up -`every_seconds` is a per-record interval of at least 300 seconds, anchored to creation time. It is fixed-rate recurrence only: the protocol has no calendar or Cron expression, recurrence time zone, shared cooldown, or cross-record admission gate. +`every_seconds` is a safe-integer interval of at least 300 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 a Session was cold or busy across several targets, one Every record contributes only its latest due occurrence. The dispatch advances it directly to the first creation-anchor-aligned target after the dispatch decision time, without enumerating, persisting, or replaying missed intervals. If that next target cannot fit in a four-digit UTC year, the final dispatch terminates the record. +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. -When multiple distinct Every records are overdue and no one-shot is due, each contributes one occurrence to the same follow-up batch in target and creation order. Every record keeps independent state, while all dispatches in that admitted batch use the same decision time. Batching bounds model turns; the five-minute minimum bounds each record's timer frequency. +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 five-minute minimum applies only to fixed-rate intervals; delivery does not wait for the target Agent to become idle. -## Durable changes and replay + +## Cron wall-clock input -The version-1 `schedule/change` Session event is the only durable Schedule authority. Create stores the complete record, and delete is a terminal id-only transition. A one-shot dispatch is also terminal and id-only. An Every dispatch carries the wall-clock decision time used to select its latest due occurrence and normally advances the active record instead of terminating it. Dispatch means the follow-up was synchronously queued, not that a model answer succeeded or the user read it. +`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: + +| 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. | + +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. + +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. + +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. + +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. + +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. + +## Historical Session changes + +Version-1 `schedule/change` events remain decodable as historical Session data. Their create, fold, and invariant 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. + +```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 +``` ```ts type-equiv /** Creates one durable reminder record. */ interface ScheduleCreateChange { readonly version: 1 readonly operation: 'create' - readonly schedule: ScheduleRecord + readonly schedule: LegacyScheduleRecord } ``` @@ -149,11 +236,11 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange ``` -The strict decoder and fold reject unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. A normal Session folds its complete event stream. A fork folds only events at or after its exact `inheritedEventCount`, so it retains history without adopting the parent Session's active reminders. Projection initialization receives that cut beside the immutable header, uses the shared transition, and persists both the cut, active records, and used-id history so cached restore preserves strict replay. The `schedule/change` declaration and source location are also indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only). +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. ## Active views and management -Tool values combine the durable record with delivery state derived from the current wall clock. `session-local` means the original Session must be live: no external notification channel or cold-session scheduler exists. +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. */ @@ -161,8 +248,8 @@ type ScheduleState = 'scheduled' | 'overdue' ``` ```ts type-equiv -/** Fixed v1 delivery boundary: the original session must be live. */ -type ScheduleDeliveryMode = 'session-local' +/** Host-driven delivery resumes the original Session when needed. */ +type ScheduleDeliveryMode = 'host' ``` ```ts type-equiv @@ -175,18 +262,282 @@ type ScheduleView = ScheduleRecord & { } ``` -The generated [tool catalog](../tool-catalog.md#deepseek-aidsh-schedule) owns the argument and result schemas for `schedule_create`, `schedule_list`, and `schedule_delete`. Management calls serialize with due work in one Agent-scoped queue. Every read or decision first waits for the shared Session persistence barrier; create and an actual delete wait again after appending. A barrier failure reports `persistence_uncertain` instead of guessing whether an eager write committed. The other stable error codes are `invalid_prompt`, `invalid_selector`, `invalid_rule`, `invalid_time_zone`, `not_future`, `time_out_of_range`, `frequency_too_high`, `corrupt_schedule_log`, and `internal_error`. +The [tool catalog](../tool-catalog.md#deepseek-aidsh-schedule) owns schemas for `schedule_create`, `schedule_list`, `schedule_delete`, and `schedule_update`. 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. 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. -## Read-only Web catalog +```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 +} +``` -When the optional Session projection registry is present, Schedule registers the client-visible `schedule` key whose value is the complete active `ScheduleRecord[]`. Live, cache, history, and detached reads use the same header-aware strict fold; malformed authoritative input fails the existing read path instead of publishing a partial value. +```ts type-equiv +/** Session-scoped task list, without Agent activation. */ +interface ScheduleListRequest { + /** Original Session binding. */ + sessionId: SessionId +} +``` -The shipped Web bundle keeps `ui-schedule` disabled by default, while the explicit Schedule overlay enables it together with the Host capability. [`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.md) owns the header interaction, [`dsh-client-ui-workspace`](../../packages/client/ui-workspace/README.md) owns list-row presentation, and the durable Schedule Agent Note owns their shared active-state boundary. The shared value represents current active state, never delivery history or a receipt; due reminders still appear through the ordinary Assistant output described below. +```ts type-equiv +/** Delete request identifying a task within its original Session binding. */ +interface ScheduleDeleteRequest extends ScheduleListRequest { + /** Task to remove. */ + id: ScheduleId +} +``` -## Live delivery +```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' } +``` -The process-local owner derives its earliest timer from the durable fold and rereads the wall clock after every bounded wait. Cold Sessions do no work; reopening one reconstructs timers and makes past targets overdue. Due one-shots take priority and enter one later turn at a time. When no one-shot is due, all overdue Every records form the single batch described above. +## Web catalog -Due work waits for the Agent to become fully idle and claims the maintenance phase before it refolds state, samples the decision, queues one `followup()`, and appends the corresponding dispatch changes. It never calls `steer()` and never interrupts a current turn. +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: -The admitted one-shot or fixed-rate batch starts one normal later turn and appears only through the ordinary conversation transcript; Schedule has no independent durable Web receipt. The read-only active catalog above never represents delivery success. If framing or synchronous queue admission fails, no dispatch is recorded and the reminder stays active. The narrow crash interval after admission but before durable dispatch can repeat reminder content after recovery, so the boundary is best-effort at-least-once rather than exactly-once delivery. +```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. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again. + +The Web bundle keeps `ui-schedule` disabled by default; the Schedule overlay enables it 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. Rules and Delivery records separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution. + +## 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. + +## 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. + +```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' } +``` + +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. + +## 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 through the Session controller, including cold Sessions. 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. + +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. + +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. + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.schedule` — `ScheduleService` + +Shared management service; reads, deletion, and timing edits never activate a Session. + +`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. + +```ts cordis-catalog +/** + * Create a reminder bound to the caller-selected Session without activating it. + * + * 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. + * 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. + */ +async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: AbortSignal): Promise + +/** + * 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. + */ +@Remote('list') async list(request: ScheduleListRequest): Promise + +/** + * 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('catalog') async catalog(): Promise + +/** + * 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. + */ +@Remote('history') async history(request: ScheduleDeliveryHistoryRequest): Promise + +/** + * 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. + * @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. + */ +@Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise + +/** + * 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. + * @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 result. + * Storage and lifecycle failures reject; cancellation after a write starts does not roll it back. + */ +@Remote('update') async update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise +``` + +Types: [SessionId](core.md) + +Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts) + + + +### `schedule/*` events + + + +#### `schedule/changed` — emit + +Durable task set changed; clients refetch global task and Session-active catalogs. + +```ts cordis-catalog +/** Durable task set changed; clients refetch global task and Session-active catalogs. + * @mode emit + */ +'schedule/changed'(): void +``` + +Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) + diff --git a/docs/subsystems/schedule.zh.md b/docs/subsystems/schedule.zh.md index a3e6a4e5f8..df3af3f5e1 100644 --- a/docs/subsystems/schedule.zh.md +++ b/docs/subsystems/schedule.zh.md @@ -1,20 +1,22 @@ -# 仅限 Session 内的 Schedule +# 宿主级 Schedule [English](schedule.md) | 中文 -Schedule 拥有持久提醒;这些提醒会作为普通的后续对话轮次返回原 live Session。[持久 Schedule Agent Note](../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md) 负责持久化、生命周期与活动状态呈现,[显式时区边界](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md) 负责浏览器本地解释。本页记录 [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久数据形状和面向模型的数据形状;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合、工具行为与确切的提醒 framing。 +Schedule 独立于 Session 的加载状态存储提醒,并将其投递到原 Session。本页记录 [`types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久与模型可见类型;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合方式与提醒文本格式。 ## 持久记录 -`ScheduleId` 是[品牌化 id](core.zh.md#branded-ids),在单个 Session 内唯一且绝不复用。版本 1 支持正的安全整数 `after_seconds` 延时、显式的绝对 `at` 目标,或至少五分钟的安全整数 `every_seconds` 间隔。创建操作会将每个初始目标规范化为使用四位年份的 RFC 3339 UTC `scheduledAt`;`after` 记录会保留提交的延时,`at` 记录只存储结果时点,`every` 记录则保留固定间隔和下一个目标。 +`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 的打开,而不是被丢弃。仅显式删除会移除任务;此前已物理删除的任务不会被恢复或虚构。 ```ts type-equiv /** Durable one-shot reminder created from a positive delay. */ interface AfterScheduleRecord { - /** Session-local stable identity. */ + /** 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. */ @@ -27,10 +29,12 @@ interface AfterScheduleRecord { ```ts type-equiv /** Durable one-shot reminder created from an absolute instant. */ interface AtScheduleRecord { - /** Session-local stable identity. */ + /** 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. */ @@ -39,29 +43,56 @@ interface AtScheduleRecord { ``` ```ts type-equiv -/** Durable fixed-rate reminder whose next target remains creation-anchor-aligned. */ +/** Durable fixed-rate reminder aligned to creation or its most recent interval edit. */ interface EveryScheduleRecord { - /** Session-local stable identity. */ + /** 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 five minutes. */ readonly everySeconds: number - /** Earliest anchor-aligned occurrence not yet dispatched. */ + /** Next anchor-aligned occurrence while active, or final occurrence when inactive. */ readonly scheduledAt: string } ``` ```ts type-equiv -/** One-shot record variants that terminate on an id-only dispatch. */ +/** 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 +} +``` + +```ts type-equiv +/** One-shot task variants. */ type OneShotScheduleRecord = AfterScheduleRecord | AtScheduleRecord ``` ```ts type-equiv -/** The v1 durable reminder record union. */ -type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord +/** Recurring Host task variants. */ +type RecurringScheduleRecord = EveryScheduleRecord | DailyScheduleRecord | WeeklyScheduleRecord | CronScheduleRecord +``` + +```ts type-equiv +/** Reminder rule and target, stored with its original Session binding. */ +type ScheduleRecord = OneShotScheduleRecord | RecurringScheduleRecord ``` ## 绝对时间输入 @@ -69,7 +100,7 @@ type ScheduleRecord = OneShotScheduleRecord | EveryScheduleRecord `at` 选择器可以是严格且带偏移量的 RFC 3339 字符串,也可以是精确的本地日历对象。本地形式让这种解释在工具边界保持显式: ```ts type-equiv -/** Structured local-calendar input accepted by `schedule_create`. */ +/** Structured local-calendar input accepted by creation and timing edits. */ interface LocalAtInput { /** Four-digit ISO calendar date. */ readonly date: string @@ -81,7 +112,7 @@ interface LocalAtInput { ``` ```ts type-equiv -/** Absolute selector accepted by `schedule_create`. */ +/** Absolute selector accepted by creation and timing edits. */ type AtInput = string | LocalAtInput ``` @@ -89,24 +120,80 @@ type AtInput = string | LocalAtInput Schedule 会拒绝无效偏移量与时区、不带偏移量的字符串、非未来目标,以及落在夏令时缺口内的本地时间。遇到夏令时重叠时,会选择第一次出现的较早时点。创建成功后只存储规范化后的 UTC `scheduledAt`,因此回放绝不依赖环境时区状态。 + +## 每日本地钟表时间输入 + +`daily` 是与 `after_seconds`、`at`、`every_seconds`、`weekly` 和 `cron` 互斥的创建选择器之一。例如,`daily: { time: '23:00:00', time_zone: 'Asia/Shanghai' }` 选择该时区每个符合条件的本地日期的 23:00。对象必须精确包含以下字段,不包含日期、偏移量或间隔: + +```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 +} +``` + +创建时要求 `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` 是每条记录单独拥有且至少为 300 秒的间隔,以创建时间为锚点。它只提供固定速率重复调度:协议不包含日历规则或 Cron 表达式、重复调度时区、共享冷却时间或跨记录准入门禁。 +`every_seconds` 是至少 300 秒的安全整数间隔,与创建时间对齐。它衡量经过的时间,而不是每日本地钟表时间:发生偏移量变化时,`every_seconds: 86400` 不能替代 `daily`。协议不提供共享冷却时间或跨记录准入规则;下文描述的 cron 选择器是钟表时间语义,不是固定速率间隔。 -如果一个 Session 在多个目标到期期间处于 cold 或 busy 状态,一条 Every 记录只会贡献其中最新的一次到期触发。dispatch 会直接将记录推进到 dispatch 判断时刻之后第一个与创建锚点对齐的目标,而不会枚举、持久化或回放错过的间隔。如果下一个目标无法落在四位数年份的 UTC 范围内,最后一次 dispatch 将终结该记录。 +宿主启动时若 Every、Daily、Weekly 或 Cron 记录已逾期,只发送最新一次到期发生时点。Every 推进到投递判断时刻之后第一个与当前间隔起算点对齐的目标;Daily、Weekly 和 Cron 遵循上文与下文的本地日期规则。错过的发生时点不会累积。若没有可表示的下一 UTC 目标,投递后保留未运行记录及最近一次回执。 -当多条彼此不同的 Every 记录均已到期,且没有一次性提醒到期时,每条记录都会向同一个 follow-up 批次贡献一次触发,并按目标时间和创建顺序排列。每条 Every 记录的状态互相独立,但该获准批次中的所有 dispatch 都使用同一个判断时刻。批处理限制模型轮次数量;五分钟下限限制每条记录的 timer 频率。 +一次性提醒各自产生一条 follow-up。同一次扫描中属于同一 Session 的到期重复记录共用由 `renderRecurringReminderBatchFraming` 渲染的一条 follow-up,每条记录各包含最新一次触发。五分钟下限仅适用于固定速率间隔;投递不等待目标 Agent 空闲。 -## 持久变更与回放 + +## Cron 钟表时间输入 -版本 1 的 `schedule/change` 会话事件是 Schedule 唯一的持久权威。create 保存完整记录,delete 是终结性且仅含 id 的转换。一次性提醒的 dispatch 同样是终结性且仅含 id。Every dispatch 携带用于选择最新到期触发的墙钟判断时刻,通常推进活动记录而不终结它。dispatch 表示 follow-up 已同步入队,而不表示模型答复成功或用户已读取答复。 +`cron` 是互斥的创建选择器之一。它的对象只携带严格的标准五字段 Vixie 表达式 `minute hour day-of-month month day-of-week` 和显式 IANA 时区,例如 `cron: { expression: '*/15 9-17 * * 1-5', time_zone: 'Asia/Shanghai' }`。字段取值范围如下: + +| 字段 | 取值范围 | 说明 | +|---|---|---| +| minute | 0-59 | | +| hour | 0-23 | | +| day-of-month | 1-31 | | +| month | 1-12 | | +| day-of-week | 0-7 | `0` 和 `7` 都表示周日。 | + +每个字段接受 `*`、单个值、`a-b` 范围、`n >= 1` 的 `*/n` 与 `a-b/n` 步长,以及由这些形式组成的逗号分隔列表。其他写法一律以 `invalid_rule` 拒绝,且错误信息会指出违规字段:`L`、`W`、`#` 运算符,`JAN`/`MON` 名称,`@daily` 风格宏,带秒字段的六字段表达式,超出范围的值、反向范围、零步长和空字段。由于该方言只有五个字段,最小间隔为一分钟;亚分钟级调度仍然不受支持。 + +创建时会先规范化表达式再存储。规范化会把每个字段展开为匹配值集合,再确定性地重新编码该集合:重复值合并,相邻的值与范围合并,均匀步长写为 `a-b/n`,而以 `*` 开头的字段写为星号步长(匹配全部取值时写 `*`,否则写最宽的星号步长并追加其余取值),步长 `1` 被移除,周日统一写为 `0`。不以 `*` 开头的字段绝不会变成星号步长。保留每个字段的星号标志,正是下文 day-of-month/day-of-week 规则在存储的表达式上仍与 Vixie 一致的原因。持久化解码器会拒绝非规范化的已存储表达式。 + +首个目标是该时区内本地日期与时间匹配的第一个严格未来时点。它复用 Daily 与 Weekly 的本地时间解析:不存在的本地时间被跳过,重叠时间每个日期只取较早一次。已提交的 UTC `scheduledAt` 在重启时绝不重新计算。 + +day-of-month 与 day-of-week 遵循 Vixie 语义。字段文本以 `*` 开头即为星号字段,与它匹配的取值集合无关,因此 `*/1` 与 `*/2` 都是星号字段,而 `1-31` 与 `0-7` 受限。任一字段为星号字段时,本地日期只有在两个字段都匹配时才算匹配;两个字段都不是星号字段时,匹配任一字段即算匹配。 + +cron 解码保留已提交时点及已存储的时区拼写,并拒绝非规范化的已存储表达式。cron 仅属于当前宿主的 `ScheduleRecord`;冻结的历史 Session 解码器不接受它。 + +## 历史 Session 变更 + +版本 1 的 `schedule/change` 事件仍可作为历史 Session 数据解码。其 create、fold 和 invariant 类型使用 `LegacyScheduleRecord`,仅接受 After、At 和 Every;Daily、Weekly 与 Cron 仅属于当前宿主的 `ScheduleRecord`。宿主记录解码器独立于冻结的历史解码器。在 title 出现之前写入的 create 记录不含 `title`,因此历史解码器接受该缺失成员,而宿主解码器仍要求它。历史事件不会填充 storage domain 或触发投递。这些事件中的已有提醒需要显式通过 `schedule_create` 重新创建;不会隐式迁移 Session 或转换旧 `at` 记录。 + +```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 +``` ```ts type-equiv /** Creates one durable reminder record. */ interface ScheduleCreateChange { readonly version: 1 readonly operation: 'create' - readonly schedule: ScheduleRecord + readonly schedule: LegacyScheduleRecord } ``` @@ -149,11 +236,11 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange ``` -严格 decoder 与 fold 会拒绝未知版本、额外字段、复用 id、不匹配的一次性提醒或 Every dispatch 形状,以及针对非活动记录的 delete 或 dispatch 转换。普通 Session 折叠完整事件流。fork 只折叠精确 `inheritedEventCount` 位置及其后的事件,因此保留历史,但不会接管父 Session 的活动提醒。Projection 初始化会在不可变 header 旁接收该 cut,复用共享 transition,并持久化 cut、活动记录与已使用 id 历史,使缓存恢复继续保持严格回放。`schedule/change` 声明和源码位置也编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。 +历史 decoder 与 fold 拒绝未知版本、额外字段、复用 ID 和无效转换。该事件仍编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。Session 历史不负责决定当前活动任务。 ## 活动视图与管理 -工具值将持久记录与根据当前墙钟派生的交付状态组合起来。`session-local` 表示原 Session 必须处于 live 状态:不存在外部通知渠道或 cold Session scheduler。 +工具值将存储记录与当前墙钟派生的时间状态组合。宿主在投递需要时恢复原 Session;列出和删除任务不会激活该 Session。 ```ts type-equiv /** Current delivery timing derived from the durable record and wall clock. */ @@ -161,8 +248,8 @@ type ScheduleState = 'scheduled' | 'overdue' ``` ```ts type-equiv -/** Fixed v1 delivery boundary: the original session must be live. */ -type ScheduleDeliveryMode = 'session-local' +/** Host-driven delivery resumes the original Session when needed. */ +type ScheduleDeliveryMode = 'host' ``` ```ts type-equiv @@ -175,18 +262,282 @@ type ScheduleView = ScheduleRecord & { } ``` -生成的[工具目录](../tool-catalog.zh.md#deepseek-aidsh-schedule)负责 `schedule_create`、`schedule_list` 和 `schedule_delete` 的参数与结果 schema。一条 Agent-scoped 队列将管理调用与到期工作串行化。每次读取或判断都会先等待共享的 Session 持久化 barrier;create 与实际执行的 delete 在追加后还会再次等待。barrier 失败会报告 `persistence_uncertain`,而不是猜测 eager write 是否已提交。其他稳定错误代码是 `invalid_prompt`、`invalid_selector`、`invalid_rule`、`invalid_time_zone`、`not_future`、`time_out_of_range`、`frequency_too_high`、`corrupt_schedule_log` 和 `internal_error`。 +[工具目录](../tool-catalog.zh.md#deepseek-aidsh-schedule) 负责 `schedule_create`、`schedule_list`、`schedule_delete` 和 `schedule_update` 的 schema。创建、删除和更新在 storage domain 写入确认后返回成功。宿主级队列将这些变更与到期投递串行化;删除任务不会移除已入队消息。模型工具操作当前 Session,共享宿主的 create、list、update 和 delete 方法接受显式 Session 绑定。创建必须提供 `title`,其去除首尾空白后必须非空且不超过 120 个字符;缺失、空白或过长的标题以 `invalid_prompt` 拒绝,且创建过程绝不从指令派生标题。create 和 list 视图在指令之外同时携带已存储的 `title`,且 `title` 缺失或非法的已存储记录在解码时被拒绝。 -## 只读 Web 目录 +```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 +} +``` -可选 Session projection 注册表存在时,Schedule 会注册客户端可见的 `schedule` key,其值是完整的活动 `ScheduleRecord[]`。live、cache、history 与 detached 读取共用同一套 header-aware 严格 fold;畸形权威输入会使既有读取路径失败,而不会发布部分值。 +```ts type-equiv +/** Session-scoped task list, without Agent activation. */ +interface ScheduleListRequest { + /** Original Session binding. */ + sessionId: SessionId +} +``` -shipped Web bundle 默认禁用 `ui-schedule`,显式 Schedule overlay 则把它与 Host 能力一同启用。[`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.zh.md)拥有 header 交互,[`dsh-client-ui-workspace`](../../packages/client/ui-workspace/README.zh.md)拥有列表行呈现,持久 Schedule Agent Note 拥有二者共享的活动状态边界。共享值只表示当前活动状态,绝不表示交付历史或回执;到期提醒仍通过下文所述的普通 Assistant 输出出现。 +```ts type-equiv +/** Delete request identifying a task within its original Session binding. */ +interface ScheduleDeleteRequest extends ScheduleListRequest { + /** Task to remove. */ + id: ScheduleId +} +``` -## Live 交付 +```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' } +``` -进程内 owner 根据持久 fold 派生最早的 timer,并在每次有界等待后重新读取墙钟。cold Session 不执行任何工作;重新打开后会重建 timer,并使已经过去的目标进入 overdue 状态。到期的一次性提醒享有优先级,每次只进入一个后续轮次。当没有一次性提醒到期时,所有 overdue 的 Every 记录会组成上述单个批次。 +## Web 目录 -到期工作会先等待 Agent 完全 idle 并认领 maintenance phase,再重新折叠状态、采样本次判断、将一个 `followup()` 排入队列,并追加对应的 dispatch 变更。它绝不会调用 `steer()`,也绝不会中断当前轮次。 +Remote 方法 `schedule.catalog()` 返回活动和未运行的宿主任务及其原始 Session 绑定,先按 `scheduledAt` 升序排列,再按 `id` 字典序排列。它只读取 Schedule domain,不激活 Session 或读取其历史。浏览器消费方接收以下可安全用于浏览器的类型: -获得准入的一次性提醒或固定速率批次会启动一个普通的后续轮次,且只通过普通对话 transcript(文本记录)出现;Schedule 不提供独立的持久 Web 回执。上面的只读活动目录绝不表示交付成功。如果 framing 构造或同步队列准入失败,则不会记录 dispatch,提醒仍保持活动。队列准入后、持久 dispatch 前的狭窄崩溃窗口可能使提醒内容在恢复后重复,因此该边界提供的是尽力而为的至少一次交付,而非恰好一次交付。 +```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,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。 + +Web bundle 默认禁用 `ui-schedule`;Schedule overlay 将它与宿主能力一起启用。[客户端包](../../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` 条数上限裁剪;新追加的最近一次回执始终保留,发生裁剪时标记更早记录不可用。分页限制返回的记录数量,不限制存储大小或指令字节数。 + +## 宿主投递 + +宿主启动时读取持久任务,并将定时器设为最早的活动目标。未运行任务仍可查询,但不会再次安排投递。投递时通过 Session controller 获取原 Session,包括尚未加载的 Session。恢复后重新采样实际时间,并排除时钟回拨后尚未到期的成员;这些任务仍保留定时义务。投递不涉及 Goal 或独立的 Run 完成记录。 + +插件来源的 `followup()` 同步将消息追加到 Session 收件箱。Session flush 成功后,一次任务记录 put 追加带有指令快照的发送记录,并同时存储最新的 `ScheduleDeliveryReceipt`,以及一次性任务的 `inactive` 状态或重复任务的下一目标和状态。同一批次的重复任务共享回执的 `messageId`;每条回执保留该任务已投递的发生时点。投递不会中途引导或取消当前轮次,也不等待模型完成。 + +Session 获取、消息入队或持久化确认失败时,任务继续保存在存储中,并报告宿主警告。后续管理变更或宿主重启会重试。Session 持久化与任务 put 不具有跨存储原子性。收件箱已持久化、任务尚未 put 时发生崩溃,可能重复投递;回执并未消除这一间隔。Schedule 不提供模型结果回执、执行取消或外部通知渠道。 + + + + + +## Cordis API + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.schedule` — `ScheduleService` + +Shared management service; reads, deletion, and timing edits never activate a Session. + +`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. + +```ts cordis-catalog +/** + * Create a reminder bound to the caller-selected Session without activating it. + * + * 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. + * 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. + */ +async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: AbortSignal): Promise + +/** + * 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. + */ +@Remote('list') async list(request: ScheduleListRequest): Promise + +/** + * 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('catalog') async catalog(): Promise + +/** + * 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. + */ +@Remote('history') async history(request: ScheduleDeliveryHistoryRequest): Promise + +/** + * 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. + * @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. + */ +@Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise + +/** + * 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. + * @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 result. + * Storage and lifecycle failures reject; cancellation after a write starts does not roll it back. + */ +@Remote('update') async update(request: ScheduleUpdateRequest, signal?: AbortSignal): Promise +``` + +Types: [SessionId](core.zh.md) + +Source: [`packages/schedule/schedule/src/index.ts`](../../packages/schedule/schedule/src/index.ts) + + + +### `schedule/*` events + + + +#### `schedule/changed` — emit + +Durable task set changed; clients refetch global task and Session-active catalogs. + +```ts cordis-catalog +/** Durable task set changed; clients refetch global task and Session-active catalogs. + * @mode emit + */ +'schedule/changed'(): void +``` + +Source: [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) + diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index eb3cee0809..cf6fb49b8f 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -9,8 +9,8 @@ en: 6cc2cd9ced70bb09 zh: 1c0e6cb4ba2ed637 /tool-schema-catalog/tool-package-map: - en: dae063dda1a53c5a - zh: 19fc8498d0603265 + en: ac9bc0329363007d + zh: 0ef8b06296058cc6 /tool-schema-catalog/deepseek-ai-dsh-plugin-manager: en: e0143916bb634762 zh: e0143916bb634762 @@ -174,14 +174,17 @@ en: 573da0cf49b1eed6 zh: 573da0cf49b1eed6 /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-create: - en: d9ff99ac30a603eb - zh: 15e658a8f7003e90 + en: ddd8148a0f9c849b + zh: 8c930b69c46eab92 /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-delete: - en: 8b37dec413d42cd7 - zh: 292685c19ab48260 + en: 3b5a084ce0d87d13 + zh: c1356d4b062f0e63 /tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-list: - en: 3c18d46b1371641e - zh: 13f0d78532bdec8b + en: 4422ae2e2a4ca897 + zh: 56431645118d6ca0 +/tool-schema-catalog/deepseek-ai-dsh-schedule/schedule-update: + en: d2cc5c77c73d6068 + zh: 639a86d6588e9d82 /tool-schema-catalog/deepseek-ai-dsh-tool-lsp: en: 32982123b1f0e5bc zh: 32982123b1f0e5bc diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 3e4407192e..0fa4a68948 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -32,7 +32,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `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.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-schedule` | `schedule_create`, `schedule_delete`, `schedule_list` | `ctx.tools`, `ctx.sessions`, `Session persistence`, `a future live root Agent` | `tool/call`, `schedule/change create or delete`, `tool/result` | - | Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, explicit absolute at, and bounded fixed-rate every_seconds, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. | +| `@deepseek-ai/dsh-schedule` | `schedule_create`, `schedule_delete`, `schedule_list`, `schedule_update` | `ctx.tools`, `ctx.schedule`, `a live root Agent` | `tool/call`, `Schedule storage domain create, update, or delete`, `tool/result` | - | Registered in live root Agent scopes while the opt-in Schedule service is loaded. 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-lsp` | `lsp` | `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.tools`, `ctx.agents`, `ctx.skills` | `tool/call`, `tool/result`, `user/message replacement catalogs via agent.inject()` | - | - | @@ -1382,7 +1382,7 @@ create, edit, pause, and resume require direct-human root authority; complete an ### `schedule_create` -Create one reminder in the current session. Supply a non-empty prompt and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, or safe-integer every_seconds of at least 300. Fixed-rate reminders stay creation-aligned, skip missed occurrences, and batch one latest occurrence per overdue rule. Delivery is session-local: the reminder runs on time only while this session is live and otherwise becomes overdue until the session is resumed. +Create one reminder in the current session. Supply a non-empty prompt, a title, and exactly one selector: a positive safe-integer after_seconds delay, at as a strict offset date-time or local date/time object, safe-integer every_seconds of at least 300, daily as {time: "23:00:00", time_zone: "Asia/Shanghai"}, weekly as {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} with Monday 1 through Sunday 7, or cron as {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} with the five fields minute hour day-of-month month day-of-week. Every creation requires a title of at most 120 characters, non-empty after trimming; it names the task on its card, its detail heading, and in the task lists. Daily, weekly, and cron reminders retain that local time and zone; missing wall-clock times skip the date and repeated times use only the earlier instant. A cron day-of-month and day-of-week pair matches when either field matches once both are restricted. Fixed-rate targets stay creation-aligned until an interval edit establishes a new anchor. All four recurring kinds batch one latest occurrence per overdue rule. The Host restores this session when a reminder is due. After downtime, each recurring reminder delivers its latest missed occurrence once. Delivery can repeat after a crash. ```json { @@ -1392,6 +1392,10 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl "type": "string", "description": "Reminder content to present when the target becomes due." }, + "title": { + "type": "string", + "description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists." + }, "after_seconds": { "type": "number", "description": "Positive safe-integer delay in seconds." @@ -1400,6 +1404,71 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl "type": "number", "description": "Fixed-rate safe-integer interval in seconds, at least 300." }, + "daily": { + "type": "object", + "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "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": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.", + "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": "Non-empty 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 evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "expression": { + "type": "string", + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "expression", + "time_zone" + ] + }, "at": { "oneOf": [ { @@ -1430,7 +1499,8 @@ Create one reminder in the current session. Supply a non-empty prompt and exactl } }, "required": [ - "prompt" + "prompt", + "title" ] } ``` @@ -1439,7 +1509,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul ### `schedule_delete` -Delete one active reminder in the current session by the exact id returned by schedule_create or schedule_list. Unknown or already-finished ids return deleted false. +Delete one retained reminder in the current session by its exact id, whether active or inactive. Unknown or already-deleted ids return deleted false. Deletion does not retract a queued message. ```json { @@ -1447,7 +1517,7 @@ Delete one active reminder in the current session by the exact id returned by sc "properties": { "id": { "type": "string", - "description": "Exact session-local schedule id." + "description": "Exact schedule id." } }, "required": [ @@ -1460,7 +1530,7 @@ Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedul ### `schedule_list` -List every active reminder in the current session in creation order, including its exact id, UTC target, scheduled or overdue state, and session-local delivery mode. +List every active reminder in the current session, including its exact id, title, UTC target, scheduled or overdue state, and host delivery mode. The returned order is not significant. ```json { @@ -1471,7 +1541,133 @@ List every active reminder in the current session in creation order, including i Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedule/src/tools.ts) -Registered only inside live root Agent scopes created after the opt-in Schedule plugin loads. Version 1 accepts after_seconds, explicit absolute at, and bounded fixed-rate every_seconds, and discloses session-local delivery; management reads and mutations require the shared Session persistence barrier. +### `schedule_update` + +Change one reminder in the current session in place, keeping its id and its saved delivery records: address it by the exact id schedule_list returned, then supply a new title or prompt, or exactly one new selector from at, every_seconds, daily, weekly, or cron in the same forms schedule_create accepts. An omitted field keeps its stored value. After is not updatable: create a new reminder for a relative delay. The Host compares the record it finds for that id with the stored one, so a concurrent edit returns schedule_conflict instead of overwriting it; an inactive or unknown reminder returns updated false. Editing an every_seconds interval anchors the new fixed rate at the accepted save time; a name or instruction change alone keeps the committed target. + +```json +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Exact schedule id that schedule_list returned for one reminder." + }, + "title": { + "type": "string", + "description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name." + }, + "prompt": { + "type": "string", + "description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction." + }, + "every_seconds": { + "type": "number", + "description": "Fixed-rate safe-integer interval in seconds, at least 300." + }, + "daily": { + "type": "object", + "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "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": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.", + "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": "Non-empty 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 evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "expression": { + "type": "string", + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + }, + "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 as strict offset RFC 3339 or local date/time with an explicit IANA zone." + } + }, + "required": [ + "id" + ] +} +``` + +Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedule/src/tools.ts) + +Registered in live root Agent scopes while the opt-in Schedule service is loaded. 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. diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index cf78ee4d4f..c64eb3413e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -36,7 +36,7 @@ | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `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.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-schedule` | `schedule_create`、`schedule_delete`、`schedule_list` | `ctx.tools`、`ctx.sessions`、Session 持久化、未来创建的 live 根 Agent | `tool/call`、`schedule/change create or delete`、`tool/result` | - | 仅在选择启用的 Schedule 插件加载后创建的 live 根 Agent scope 内注册。版本 1 接受 after_seconds、显式绝对 at 和有界固定速率 every_seconds,并披露 session-local 交付;管理读取与变更必须通过共享的 Session 持久化 barrier。 | +| `@deepseek-ai/dsh-schedule` | `schedule_create`、`schedule_delete`、`schedule_list`、`schedule_update` | `ctx.tools`、`ctx.schedule`、live 根 Agent | `tool/call`、Schedule storage domain 创建、更新或删除、`tool/result` | - | 选择启用的 Schedule 服务加载期间,在 live 根 Agent scope 内注册。接受 after_seconds、显式绝对 at、有界固定速率 every_seconds、带显式 IANA 时区的每日与每周本地时间,以及作为五字段表达式的 cron。管理使用宿主 storage domain;到期消息会恢复原 Session。 | | `@deepseek-ai/dsh-tool-lsp` | `lsp` | `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.tools`、`ctx.agents`、`ctx.skills` | `tool/call`、`tool/result`、`user/message replacement catalogs via agent.inject()` | - | - | @@ -1388,7 +1388,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_create` -在当前会话中创建一条提醒。请提供非空 prompt 和恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;或不小于 300 的安全整数 every_seconds。固定速率提醒始终与创建时刻对齐,会跳过错过的发生时点,并把每条逾期规则的最新一个发生时点合并到一个批次中。交付模式是 session-local:只有此会话处于 live 状态时,提醒才会准时运行;否则提醒会进入 overdue 状态,直至会话恢复。 +在当前会话中创建一条提醒。请提供非空 prompt、标题,以及恰好一个 selector:正的安全整数 after_seconds 延时;作为严格带偏移日期时间或本地日期/时间对象的 at;不小于 300 的安全整数 every_seconds;形如 {time: "23:00:00", time_zone: "Asia/Shanghai"} 的 daily;形如 {time: "09:00:00", time_zone: "Asia/Shanghai", weekdays: [1, 3]} 的 weekly,其中周一为 1、周日为 7;或形如 {expression: "*/15 9-17 * * 1-5", time_zone: "Asia/Shanghai"} 的 cron,其五个字段为 minute hour day-of-month month day-of-week。每次创建都必须提供标题,最多 120 个字符且去除首尾空白后非空;该标题用于任务卡片、详情标题和任务列表。每日、每周与 cron 提醒保留指定的本地时间与时区;不存在的钟表时间会跳过该日期,重复时刻只取较早实例。cron 的 day-of-month 与 day-of-week 都受限时,匹配任一字段即算匹配。固定速率提醒在间隔编辑确立新起点前始终与创建时刻对齐。四类重复提醒都会将每条逾期规则的最新一个发生时点合并为一批投递。宿主会在提醒到期时恢复此会话。停机后,每条重复提醒只投递最新错过的一次。崩溃后可能重复投递。 ```json { @@ -1398,6 +1398,10 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "type": "string", "description": "Reminder content to present when the target becomes due." }, + "title": { + "type": "string", + "description": "Required task name of at most 120 characters, non-empty after trimming; it becomes the task card title, the detail heading, and the name in the task lists." + }, "after_seconds": { "type": "number", "description": "Positive safe-integer delay in seconds." @@ -1406,6 +1410,71 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "type": "number", "description": "Fixed-rate safe-integer interval in seconds, at least 300." }, + "daily": { + "type": "object", + "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "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": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.", + "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": "Non-empty 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 evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "expression": { + "type": "string", + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + }, + "time_zone": { + "type": "string", + "description": "UTC or IANA Area/Location, for example Asia/Shanghai." + } + }, + "required": [ + "expression", + "time_zone" + ] + }, "at": { "oneOf": [ { @@ -1436,7 +1505,8 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete } }, "required": [ - "prompt" + "prompt", + "title" ] } ``` @@ -1445,7 +1515,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_delete` -使用 schedule_create 或 schedule_list 返回的确切 id,删除当前会话中的一条活动提醒。未知或已经结束的 id 会返回 deleted false。 +使用确切 id 删除当前会话中保留的一条提醒,活动或未运行的提醒均可删除。未知或已经删除的 id 会返回 deleted false。删除不会撤回已经入队的消息。 ```json { @@ -1453,7 +1523,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete "properties": { "id": { "type": "string", - "description": "Exact session-local schedule id." + "description": "Exact schedule id." } }, "required": [ @@ -1466,7 +1536,7 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete ### `schedule_list` -按创建顺序列出当前会话中的所有活动提醒,包括确切 id、UTC 目标、scheduled 或 overdue 状态,以及 session-local 交付模式。 +列出当前会话中的所有活动提醒,包括确切 id、标题、UTC 目标、scheduled 或 overdue 状态,以及 host 交付模式。返回顺序无关紧要。 ```json { @@ -1477,7 +1547,137 @@ create、edit、pause 和 resume 要求直接来自人类的根权限;complete 来源:[`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedule/src/tools.ts) -仅在选择启用的 Schedule 插件加载后创建的 live 根 Agent scope 内注册。版本 1 接受 after_seconds、显式绝对 at 和有界固定速率 every_seconds,并披露 session-local 交付;管理读取与变更必须通过共享的 Session 持久化 barrier。 +选择启用的 Schedule 服务加载期间,在 live 根 Agent scope 内注册。接受 after_seconds、显式绝对 at、有界固定速率 every_seconds、带显式 IANA 时区的每日与每周本地时间,以及作为五字段表达式的 cron。管理使用宿主 storage domain;到期消息会恢复原 Session。 + + + +### `schedule_update` + +在当前会话中原地修改一条提醒,保留其 id 与已保存的投递记录:用 `schedule_list` 返回的精确 id 定位,然后给出新的 title 或 prompt,或从 at、every_seconds、daily、weekly、cron 中给出恰好一个新选择器(形式与 `schedule_create` 相同)。未提供的字段保持原值。`after` 不支持更新;需要相对延迟时请新建一条提醒。宿主会把它为该 id 读到的记录与已存储记录比对,因此并发编辑返回 `schedule_conflict`,而不是覆盖已存储的修改;已结束或不存在的提醒返回 `updated: false`。修改 `every_seconds` 间隔会把新的固定速率锚定在接受保存的时刻;只改名称或指令则保留已提交的目标。 + +```json +{ + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Exact schedule id that schedule_list returned for one reminder." + }, + "title": { + "type": "string", + "description": "New task name of at most 120 characters, non-empty after trimming; omitted keeps the stored name." + }, + "prompt": { + "type": "string", + "description": "New reminder content, non-empty after trimming; omitted keeps the stored instruction." + }, + "every_seconds": { + "type": "number", + "description": "Fixed-rate safe-integer interval in seconds, at least 300." + }, + "daily": { + "type": "object", + "description": "Daily local wall-clock time in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once.", + "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": "Weekly local wall-clock time on explicit ISO weekdays in an explicit IANA zone; skips nonexistent times and uses the earlier repeated time once per date.", + "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": "Non-empty 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 evaluated in an explicit IANA zone; skips nonexistent local times and uses the earlier repeated time once per date.", + "additionalProperties": false, + "properties": { + "expression": { + "type": "string", + "description": "minute hour day-of-month month day-of-week, for example \"*/15 9-17 * * 1-5\"." + }, + "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 as strict offset RFC 3339 or local date/time with an explicit IANA zone." + } + }, + "required": [ + "id" + ] +} +``` + +Source: [`packages/schedule/schedule/src/tools.ts`](../packages/schedule/schedule/src/tools.ts) + +Registered in live root Agent scopes while the opt-in Schedule service is loaded. 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. diff --git a/docs/user/guide/schedule.i18n.yaml b/docs/user/guide/schedule.i18n.yaml index 53d47f563d..a439c0fee0 100644 --- a/docs/user/guide/schedule.i18n.yaml +++ b/docs/user/guide/schedule.i18n.yaml @@ -2,6 +2,30 @@ # 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/user/guide/schedule.md -/schedule-session-local-reminders: - en: 758fff68dae2823c - zh: eda1d8a529a97b5d +/schedule-reminders: + en: c3cb91d1d53b7603 + zh: 715ced3f1a2e9dd5 +/schedule-reminders/summary: + en: bc85e4ac935f2fd5 + zh: c39e54e4beaa3f9a +/schedule-reminders/table-of-contents: + en: a1159100bd5429cd + zh: 2ca8ff4462484376 +/schedule-reminders/enable-and-create-reminders: + en: bba6e40f1d266983 + zh: 82477fb6308e4789 +/schedule-reminders/inspect-and-delete-tasks: + en: a44fc1aabc2ab957 + zh: 31f444d4c7fd5c7a +/schedule-reminders/edit-an-active-task: + en: f28e54059d256ff0 + zh: 9e59ceef4cf2dd45 +/schedule-reminders/timing-and-delivery-reference: + en: 6e5aa8ae5906df4f + zh: afdf670a31fae2bb +/schedule-reminders/further-exploration: + en: b7eb797281f028c0 + zh: 3754f9d19127a3c5 +/schedule-reminders/dev-note: + en: b75c8458928b72b9 + zh: 003ba4e17b4b5269 diff --git a/docs/user/guide/schedule.md b/docs/user/guide/schedule.md index fdb4899925..046279b0ac 100644 --- a/docs/user/guide/schedule.md +++ b/docs/user/guide/schedule.md @@ -1,21 +1,81 @@ -# Schedule session-local reminders +# Schedule reminders English | [中文](schedule.zh.md) -This overlay opts one `dsh web` process into Schedule reminders without changing the shipped default Web composition: +## 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. + +## Table of Contents + +- [Enable and create reminders](#create-reminders) +- [Inspect and delete tasks](#manage-tasks) +- [Edit an active task](#edit-timing) +- [Timing and delivery reference](#timing-and-delivery) +- [Further Exploration](#further-exploration) + + +## Enable and create reminders + +From a repository checkout, enable Schedule for one `dsh web` process with the explicit overlay. Configure a model provider before asking it to create reminders. ```sh dsh web --patch apps/cli/config/examples/schedule/cordis.yml ``` -The current overlay supports reminders created with a positive whole-number `after_seconds`, an absolute `at` target, or a fixed-rate `every_seconds` interval of at least 300 seconds. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies delivery as `session-local`. +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 five minutes, 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. -With this overlay enabled, a successfully opened Session with active reminders shows a read-only catalog in the conversation header. It lists the complete prompt, scheduled or overdue status, one-time or exact repeating cadence, browser-local target time, and relative time. The sidebar also places a non-interactive alarm after the title of grouped, flat, and search rows when their currently available projection value is non-empty. These surfaces never create, edit, delete, or acknowledge reminders, and a cold Session's cached alarm can be briefly missing or stale. +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. -The browser attaches its IANA zone to each prompt. Time-context tells the model to interpret otherwise-unqualified dates and times in that request's browser zone. This assumption belongs to natural-language interpretation only: `schedule_create.at` must be either a strict RFC 3339 date-time with `Z` or a numeric offset, or `{ date, time, time_zone }` with an explicit `UTC` or IANA Area/Location zone. Schedule does not retain or infer a Session default zone. Daylight-saving gaps are rejected, overlaps choose the first instant, and successful records keep only the resulting UTC target. + +## Inspect and delete tasks -The original Session log owns each reminder. A live root Agent waits until it is fully idle, then queues a normal follow-up turn in that conversation. It never steers current work and adds no separate receipt or reminder card. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Reading cold history never activates it, and a fork does not inherit its parent's reminders. +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. -Every reminders stay aligned to their creation time. If one is overdue, only its latest due occurrence is presented and the next target remains on the original fixed-rate sequence. All distinct Every records overdue at the same idle decision are combined into one follow-up with one occurrence each; missed intervals do not create a backlog. Due one-shots run before that batch. Calendar and Cron expressions are not supported. +Select a task to open Rules, then choose Delivery 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. -Create and actual delete operations acknowledge success only after Session persistence confirms their event prefix. Schedule does not provide browser, operating-system, email, SMS, or other external notification. A durable dispatch records that the follow-up was queued; it does not acknowledge model success or user receipt. +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. + +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. + +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. + +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. + +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. + + +## Edit an active 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. + +- 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, 5 minutes, or 300 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. + +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. + +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. + + +## 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. + +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. + +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 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. + + +## Further Exploration + +- [Schedule reference](../../../packages/schedule/schedule/README.md) documents exact timing selectors, persistence, and delivery limits. +- [Automation tasks page reference](../../../packages/client/ui-schedule/README.md) describes the global page and conversation-header catalog. + +## Dev Note + +None. diff --git a/docs/user/guide/schedule.zh.md b/docs/user/guide/schedule.zh.md index d5d134ed7e..8c8a65af17 100644 --- a/docs/user/guide/schedule.zh.md +++ b/docs/user/guide/schedule.zh.md @@ -1,21 +1,81 @@ -# 安排会话内提醒 +# Schedule 提醒 [English](schedule.md) | 中文 -此 overlay 让一个 `dsh web` 进程显式启用 Schedule 提醒,同时不改变交付的默认 Web 组合: +## 概述 + +在对话中创建提醒,再从自动化任务页面查看活动和未运行任务,并修改活动任务的名称、指令和运行时间。每日提醒遵循保存的本地时间与时区;固定速率提醒遵循经过的时间间隔。已投递的一次性任务会保留到显式删除为止。 + +## 目录 + +- [启用并创建提醒](#create-reminders) +- [查看和删除任务](#manage-tasks) +- [修改活动任务](#edit-timing) +- [时间与投递参考](#timing-and-delivery) +- [延伸阅读](#further-exploration) + + +## 启用并创建提醒 + +在仓库检出目录中,通过显式 overlay 为一个 `dsh web` 进程启用 Schedule。在请求模型创建提醒前,先配置模型 provider。 ```sh dsh web --patch apps/cli/config/examples/schedule/cordis.yml ``` -当前 overlay 支持使用正整数 `after_seconds`、绝对时间 `at` 目标,或至少 300 秒的固定速率 `every_seconds` 间隔创建提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付标为 `session-local`。 +向模型提出创建、列出、修改或删除提醒的请求。模型使用 `schedule_create`、`schedule_list`、`schedule_update` 和 `schedule_delete`(更新原地修改一条提醒,保留其 id 与已保存的投递记录);「自动化任务」页面的「新建」操作则为创建打开一个新会话,其 composer 已写好该请求。支持正整数秒的一次性延迟、绝对日期时间、至少五分钟的固定间隔、带 IANA 时区的每日本地时间、带 IANA 时区和 ISO 星期(周一 1 至周日 7)的每周本地时间,以及带显式 IANA 时区的五字段 cron 表达式(表达式以规范化形式保存)。 -启用此 overlay 后,成功打开且存在活动提醒的 Session 会在对话 header 中显示只读目录。目录列出完整 prompt、等待中或已逾期状态、单次或精确重复周期、浏览器本地目标时间与相对时间。侧边栏还会在 grouped、flat 与 search 行当前可用的 projection 值非空时,于标题后显示不可交互的闹钟。这些界面不会创建、编辑、删除或确认提醒;cold Session 的缓存闹钟允许短暂漏显或残留。 +例如,请求:“每天 Asia/Shanghai 时区 23:00 提醒我查看天气。”打开自动化任务页面并选择刚创建的提醒,检查频率显示的是每日规则和所需时区,而非仅一次。一次性提醒不会在投递后变成重复任务。 -浏览器会为每条提示词附加其 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该请求的浏览器时区。此假设仅用于自然语言解释:`schedule_create.at` 必须是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,或是带显式 `UTC` 或 IANA Area/Location 时区的 `{ date, time, time_zone }`。Schedule 不保留或推断 Session 默认时区。夏令时缺口会被拒绝,重叠时段选择第一个时刻;成功创建的记录只保留所得的 UTC 目标。 + +## 查看和删除任务 -每条提醒由原 Session 日志拥有。live 根 Agent 会等待到完全 idle,再在该对话中排入一个普通 follow-up 轮次。它绝不会中途引导当前工作,也不会添加独立回执或提醒卡片。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。 +从侧栏打开自动化任务页面,可跨 Session 查看提醒,而不激活相应对话。按任务已存储的名称、指令或任务所属的内部 Session id 搜索,并用唯一的“全部/已开启/未运行”状态筛选行过滤。Session 行在其 Session 有至少一个活动任务、自身空闲且未归档时显示时钟标识;归档行该格留空,其实时状态只在悬浮卡片中显示。悬停任一带活动任务的 Session 行时,最多列出其中两个任务及其频率,以及设备时区下的下次运行时间与相对时长。已打开 Session 的顶部在有活动提醒时显示仅图标时钟入口:只有一个任务时直接打开其详情,否则列出该 Session 的提醒。 -Every 提醒始终与其创建时刻对齐。如果提醒逾期,只会呈现最新一个到期发生时点,下一个目标仍保留在原固定速率序列上。同一次 idle 决策中逾期的所有不同 Every 记录会合并为一个 follow-up,每条记录各有一个发生时点;错过的间隔不会形成积压。已到期的一次性提醒会在该批次之前运行。不支持日历表达式和 Cron 表达式。 +选择任务打开“规则”,再选择“发送记录”加载最新的 20 条已保存记录。每条已保存记录为一行紧凑信息:时钟图标、任务保存时区下的发生时间(一次性或间隔任务则为浏览器时区),以及保存过的指令。没有保存指令的记录不会用当前指令顶替。“加载更早的记录”增加下一页。记录按保存顺序从新到旧排列,即使时钟回拨也不改变顺序;它们不描述模型执行结果。原会话入口位于详情页签条内,因此在“规则”视图滚动时都保持可见。不带修饰键的左右方向键和 Home/End 可切换已聚焦的页签;不会拦截与 Alt、Ctrl、Meta 或 Shift 的组合。刷新任务会保留当前页签。 -创建和实际删除操作只有在 Session persistence 确认对应事件前缀后才会确认成功。Schedule 不提供浏览器、操作系统、邮件、短信或其他外部通知。持久 dispatch 会记录 follow-up 已经入队;它不确认模型成功或用户已收到提醒。 +记录空态表示查询成功且没有返回已保存记录。加载中、请求失败、任务不存在和较早记录游标无效分别显示不同提示。请求失败或返回任务不存在时,使用“重试加载发送记录”;游标无效时,使用“刷新发送记录”重新加载最新一页。请求失败时,此前加载的记录可能仍会显示,并附有警告。新的最近一次投递会刷新最新一页。离开任务或视图后才到达的响应不会替换当前视图。 + +没有已保存历史的任务只能显示已有的最近一次回执,直到新投递增加记录;后续投递不会恢复缺失历史或清除相应提示。未保存的更早记录和指令快照无法恢复,不会用当前指令代替缺失的已保存指令。仅当宿主标记更早记录不可用时,才显示更早历史提示;尚未投递的新任务显示空态,不显示该提示。 + +活动任务参与调度;未运行任务不会再次安排投递。一次性任务在提醒持久写入对话收件箱后变为未运行;选择所有任务或未运行即可找到它。对话顶部目录和模型 `schedule_list` 仅显示活动提醒,因此从这些视图消失并不表示保留的任务已被删除。 + +详情页签条内的关联会话入口在元数据就绪、Session 可用且未归档时打开原对话;否则入口禁用并显示原因。即使原 Session 无法打开,任务详情与删除操作仍然可用。浏览任务不会取消 Session 归档。 + +选择删除任务并确认,可停止未来投递并将该任务从列表中移除,包括未运行任务。删除会移除该任务及其已保存的发送记录,因此该任务的已保存历史随之消失。确认删除会关闭发起删除的面板或页签,并在同一个应用级提示里报告结果——已删除,或失败(失败时任务保留、可重试);删除不会删除原对话,也不会撤回已进入队列的消息。该行会保留到成功查询确认剩余任务为止。查询失败时可通过重试重新加载目录;错误不会被视为查询成功的空结果。 + + +## 修改活动任务 + +在自动化任务页面选择活动任务,打开“规则”,即可修改其名称、指令和运行时间。未运行任务保持只读,修改先保存在本地草稿中,直到选择“保存更改”。编辑保留任务 id、原对话、最近一次投递回执以及全部已保存的发送记录。 + +- Daily 和 Weekly:修改“时间”和“时区”;每周规则还可切换“星期”。时钟行显示整秒,未改动的行在提交时保留已存储的毫秒值,已存储的 IANA 时区保持不变。规则改变后,按下文的夏令时规则选择首个未来时点。 +- Every:选择“每 N 小时”“每 N 分钟”或“每 N 秒”,并按行内标出的单位填写数量;该单位下的最小间隔为 1 小时、5 分钟或 300 秒,提交的间隔为整秒。新间隔从宿主接受保存的时间起算,首个目标为一个新间隔之后。时区不影响这个经过时间间隔。 +- 一次性 After/At:分别修改“日期”“时间”两行以及“时区”。初始时区取记录已存储的时区;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一已存储瞬时。未保存时区的一次性规则会显示一条提示,说明输入的日期时间按所选时区解释。更改时区会保留这些钟表值并改变时点。目标改变后以相同任务 id 保存为 At,不将时区保留为重复规则元数据。 + +各行只修改本地草稿。“保存更改”会以一次更新提交名称、指令和完整规则;草稿与已存储任务不同时显示保存栏,“取消”恢复已存储的值。“重复”菜单提供每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron),宿主接受其中任何一项,因此一次性 After 可保存为 At。规范化后等价的规则不写入或重设目标:相同的 Every 间隔不会重新起算,未变更的一次性目标保留 After 或 At。保存期间卡片各行禁用。离开卡片不会回滚宿主已开始的写入。 + +刷新目录会保留你已修改的字段,其余字段取自已刷新的记录,因此其他字段的并发变化不会被未保存的修改覆盖。输入无效时显示错误并阻止保存。若投递或其他编辑改变了任务,冲突会阻止覆盖该变化;请重试以修改最新规则。更新被接受后刷新目录。更新被拒绝或无法确认与目录加载错误相互区分:应使用“重试”重新加载目录,而不是重复已确认的更新。若更新本身无法确认,应在重试前检查任务。关闭详情或切换任务后才到达的响应不会替换当前视图。 + + +## 时间与投递参考 + +Session 未打开时,提醒仍然保存。宿主运行期间,在提醒到期时恢复原 Session 并排入 follow-up,不中断当前工作。重启后检查存储的任务,投递逾期的一次性提醒,或每条重复任务最近错过的一次。关闭宿主后,调度停止,直到宿主再次启动。同一 Session 的到期重复任务可以共用一条消息。 + +固定速率提醒在间隔改变前与创建时间对齐;间隔改变后与接受保存的时间对齐。每日提醒则遵循保存的本地时间与 IANA 时区。不存在的每日本地时间或日期会被跳过;重叠时间选择当天较早的一次。绝对时间请求会拒绝不存在的本地时间,并在重叠时选择较早时刻。浏览器提供其时区来解释自然语言请求;显式指定时区可以避免歧义。任务详情在频率文案里保留钟表规则保存的时区,并把每个下一目标显示为设备时区的钟点;一次性任务仅存储 UTC 时点,因此其时间戳使用浏览器时区。 + +已保存的发送记录确认消息已持久化到对话收件箱,不表示模型完成了所请求的工作。收件箱持久化或任务写入失败时,不增加已保存记录。收件箱持久化后崩溃或任务写入失败,可能留下未记入发送记录的已投递消息,并重复投递;不保证恰好投递一次。这不是执行历史,也不是外部通知渠道。 + +已保存的记录在追加确认时裁剪:宿主保留确认时间落在配置的 `deliveryHistoryDays` 窗口内的记录(窗口以本次追加回执的 `deliveredAt` 向前计算),且最多保留最新的 `deliveryHistoryRecords` 条。最近一次回执始终保留,发生裁剪时标记更早记录不可用。宿主将保留的历史保存在内存中,每次确认均重写完整任务行。分页限制返回的记录数,不限制保留的历史或指令字节数。 + +不支持暂停和每次运行新建 Session。名称、指令和运行时间的修改对模型开放:`schedule_update` 原地修改提醒并保留其 id 与已保存的投递记录;自动化任务页面也可按所选任务的绑定修改。两者都不是跨会话转交工作流。仅记录在旧 Session 日志中的提醒不会自动调度;请重新创建仍需要的提醒。此前已删除的任务记录不会被重建。 + + +## 延伸阅读 + +- [Schedule 参考](../../../packages/schedule/schedule/README.zh.md) 说明精确的时间选择器、持久化与投递限制。 +- [自动化任务页面参考](../../../packages/client/ui-schedule/README.zh.md) 说明全局页面与对话顶部目录。 + +## 开发备注 + +无。 diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index a68d0e7d56..d091e6d74c 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -15,8 +15,8 @@ en: ffc55792d6e922e1 zh: 83bd759638857e2d /packages/package-groups: - en: 9602bc97f4c13c51 - zh: 267f84da67b8c6fb + en: 2818a70fa6bbb1b6 + zh: 800b34102e82c192 /packages/release-expectations: en: 1ab880da3642bde1 zh: 6e168322f026a9cf diff --git a/packages/README.md b/packages/README.md index 0ce8e92027..54c4cdc758 100644 --- a/packages/README.md +++ b/packages/README.md @@ -32,7 +32,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`api/`](api/README.md) | Remote BFF assembly and Typert RPC gateway | | [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | | [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | -| [`schedule/`](schedule/README.md) | Session-local scheduled follow-ups | +| [`schedule/`](schedule/README.md) | Host-owned scheduled follow-ups | | [`feedback/`](feedback/README.md) | Human feedback capture and command | | [`identity/`](identity/README.md) | Shared anonymous identity | | [`llm/`](llm/README.md) | LLM capability family: abstract service + provider adapters | diff --git a/packages/README.zh.md b/packages/README.zh.md index 8e0650d8f6..9ea4426082 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -32,7 +32,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`api/`](api/README.zh.md) | Remote BFF 装配与 Typert RPC 网关 | | [`typert/`](typert/README.zh.md) | 类型图生成、产物加载与运行时注册表 | | [`goal/`](goal/README.zh.md) | 同会话 goal 的持久化与生命周期 | -| [`schedule/`](schedule/README.zh.md) | 仅限会话内的定时后续操作 | +| [`schedule/`](schedule/README.zh.md) | Host 拥有的定时后续操作 | | [`feedback/`](feedback/README.zh.md) | 人类反馈的采集与命令 | | [`identity/`](identity/README.zh.md) | 共享匿名身份 | | [`llm/`](llm/README.zh.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index 850d57540a..16ee438803 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -15,11 +15,11 @@ en: a7a78c6d1aeebeb3 zh: 4bb69f32d8b05ba3 /deepseek-ai-dsh-api-remotes/use-this-package: - en: a6229be20242889b - zh: 8d270ab676d44e21 + en: b85e651d75ad3930 + zh: 586ab15f54aefed6 /deepseek-ai-dsh-api-remotes/forwarded-host-events: - en: 3a6f03172f88e970 - zh: 23cad6f60df8e4f2 + en: d6d82bf0e55ae5e9 + zh: c3a0c80f8b115039 /deepseek-ai-dsh-api-remotes/build-boundary: en: 7a9934a3ba5b185f zh: 40457fd5707f57f0 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 493418fbce..78a8d8fc06 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -27,7 +27,7 @@ Two-sided BFF for Host Remote capabilities selected by this application. The Hos [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package only selects and mounts that generated Session contribution; it does not duplicate activation policy. -The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +The Client assembly mounts Office conversion, Commands, credentials, settings, Goal, Schedule, dynamic Cordis, file and Session references, read-only Host plugin inventory, plugin-installation registry response comparison, message feedback, permission presets, Session Controller, subagents, and Workspace Controller contributions. The `permissionPresets` namespace returns the complete process-level catalog used by current-session controls. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. This facade is also the front door for the wire type vocabulary a Client package names. It re-exports, type-only, the Remote failure vocabulary (`RemoteResult`, `RemoteFailure`, `RemoteErrorCode`, `RemoteErrorDetailsMap`), the Host facts (`RemoteHostFacts`), and each selected domain's client-safe payload types, so a Client feature package imports one specifier instead of reaching into `dsh-typert-protocol`, the Gateway, or an owner's Host entry. Two kinds of package deliberately skip this door: the API-layer packages this assembly itself selects — importing it back would close a dependency cycle — and their tests, which take the failure vocabulary from `dsh-typert-protocol` directly. A UI package's tests instead take the `RemoteError` constructor from [`dsh-client-test-runtime`](../../test-support/client-runtime/README.md). @@ -44,7 +44,7 @@ The listener signature is not restated here. Each allowlisted event's Cordis `Ev The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. -The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. +The payload-free `schedule/changed` event invalidates Schedule task queries; Clients re-read after a notification and reconnect. The payload-free `permission-presets/catalog-changed` event invalidates the process catalog. A Client subscribes before its first `permissionPresets.catalog()` read and re-reads the complete snapshot after a notification; the event carries no catalog state and changes no Session sequence. ## Build boundary diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index 69190cf2ce..cd09dbb7a3 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -27,7 +27,7 @@ kind: "package-reference" [`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 agent(智能体)与会话身份策略,包括供其他 namespace 使用的 Typert lookup 解析器。本包只选择并挂载生成的会话 contribution,不复制激活策略。 -Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、Schedule、动态 Cordis、文件与会话引用、只读 Host 插件清单、插件安装源响应比较、消息反馈、权限预设、会话控制器、subagents 和 Workspace 控制器 contribution。`permissionPresets` namespace 返回 current-session 控件使用的完整进程级目录。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪的 namespace 服务、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 本 facade 同时是 Client 包指称 wire 类型词汇的正门。它以 type-only 方式转出 Remote 失败词汇(`RemoteResult`、`RemoteFailure`、`RemoteErrorCode`、`RemoteErrorDetailsMap`)、Host 事实(`RemoteHostFacts`),以及各已选领域对 Client 安全的载荷类型,因此 Client 功能包只 import 一个 specifier,不必伸手进 `dsh-typert-protocol`、Gateway 或某个拥有方的 Host 入口。有两类包刻意不走这道门:本装配自己选中的 API 层包——反向 import 会形成依赖环——以及它们的测试,后者直接从 `dsh-typert-protocol` 取失败词汇。UI 包的测试则从 [`dsh-client-test-runtime`](../../test-support/client-runtime/README.zh.md) 取 `RemoteError` 构造器。 @@ -44,7 +44,7 @@ Client 组合挂载 Office 转换、Commands、凭据、settings、Goal、动态 Host entry 为每条 Client 流独立注册一组 allowlist listener 和一个队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` 逻辑流前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动流。 -无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 +无 payload 的 `schedule/changed` 事件使 Schedule 任务查询失效;Client 在收到通知及重连后重新读取。无 payload 的 `permission-presets/catalog-changed` 事件使进程目录失效。Client 在首次读取 `permissionPresets.catalog()` 前先订阅,并在通知后重新读取完整快照;该事件不携带目录状态,也不改变 Session 序号。 ## 构建边界 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index a201c15042..54ec56c0fa 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -59,7 +59,6 @@ "@deepseek-ai/dsh-scope": "workspace:*" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", @@ -67,6 +66,7 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:*", "@deepseek-ai/dsh-api-settings-controller": "workspace:*", "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-files": "workspace:*", "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-file-upload": "workspace:*", "@deepseek-ai/dsh-commands": "workspace:*", @@ -77,20 +77,16 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-message-feedback": "workspace:*", - "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-permission-presets": "workspace:*", + "@deepseek-ai/dsh-schedule": "workspace:*", + "@deepseek-ai/dsh-scope": "workspace:*", "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-settings": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", "@deepseek-ai/dsh-user-questions": "workspace:*", - "@deepseek-ai/dsh-client-connection": "workspace:*", - "@deepseek-ai/dsh-client-file-upload": "workspace:*", - "@deepseek-ai/dsh-typert-protocol": "workspace:*", - "@deepseek-ai/dsh-scope": "workspace:*", - "@deepseek-ai/dsh-api-session-controller": "workspace:*", - "@deepseek-ai/dsh-api-workspace-files": "workspace:*", + "@deepseek-ai/cordis": "workspace:~", "zod": "^4.4.3", "@deepseek-ai/dsh-command-feedback": "workspace:*", "@deepseek-ai/dsh-api-terminal-controller": "workspace:*", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index a00689e949..7262b843a7 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -7,6 +7,7 @@ import accountRemote from '@deepseek-ai/dsh-api-account-controller/remote' import settingsControllerRemote from '@deepseek-ai/dsh-api-settings-controller/remote' import officeToPdfRemote from '@deepseek-ai/dsh-office-to-pdf/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' +import scheduleRemote from '@deepseek-ai/dsh-schedule/remote' import llmRemote from '@deepseek-ai/dsh-llm/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' import pluginManagerRemote from '@deepseek-ai/dsh-plugin-manager/remote' @@ -40,6 +41,7 @@ export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-api-settings-controller/remote' export type {} from '@deepseek-ai/dsh-api-account-controller/remote' export type {} from '@deepseek-ai/dsh-goal/remote' +export type {} from '@deepseek-ai/dsh-schedule/remote' export type {} from '@deepseek-ai/dsh-office-to-pdf/remote' export type {} from '@deepseek-ai/dsh-llm/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' @@ -173,7 +175,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, + agentPresetsRemote, commandsRemote, settingsControllerRemote, accountRemote, goalsRemote, llmRemote, dynamicRemote, scheduleRemote, pluginInventoryRemote, pluginManagerRemote, pluginRegistryProbeRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index daee61cfe8..f57a5c6481 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events' import type {} from '@deepseek-ai/dsh-deepseek-account/types' import type {} from '@deepseek-ai/dsh-permission-presets/types' import type {} from '@deepseek-ai/dsh-plugin-manager/types' +import type {} from '@deepseek-ai/dsh-schedule/client' import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' /** @@ -42,5 +43,6 @@ export const API_REMOTE_FORWARDED_EVENTS = [ { event: 'plugin-manager/install-log', mode: 'emit' }, { event: 'plugin-manager/install-state', mode: 'emit' }, { event: 'settings/document-updated', mode: 'emit' }, + { event: 'schedule/changed', mode: 'emit' }, { event: 'user-questions/request', mode: 'waterfall' }, ] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 8539b44058..80841acf6d 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -123,6 +123,16 @@ describe('Remote event Host source', () => { }, }) + emitRaw(ctx, 'schedule/changed', []) + await expect(first.next()).resolves.toEqual({ + done: false, + value: { event: 'schedule/changed', args: [] }, + }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'schedule/changed', args: [] }, + }) + const firstDone = first.next() firstAbort.abort(new Error('first Client disconnected')) emitRaw(ctx, 'commands/change', []) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 55407602bc..2e74f687da 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -38,6 +38,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../schedule/schedule" + }, { "path": "../../feedback/message-feedback" }, diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 9357ca3a8e..5a93826f6d 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -35,6 +35,9 @@ { "path": "../../goal/goal" }, + { + "path": "../../schedule/schedule" + }, { "path": "../../preset/agent-preset-registry" }, diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index ba61e4835c..72e5f00004 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -352,9 +352,9 @@ - id: ui-jobs name: '@deepseek-ai/dsh-client-ui-jobs' - # Read-only active Schedule catalog. The shipped Web graph resolves the - # client package but leaves it disabled; the explicit Schedule overlay - # enables this same row together with the host Schedule services. + # Task management and Session reminder catalog. The shipped Web graph + # resolves the client package but leaves it disabled; the explicit Schedule + # overlay enables this same row together with the Host Schedule services. - id: ui-schedule name: '@deepseek-ai/dsh-client-ui-schedule' disabled: true diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index b11421345c..070d9e352f 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -94,6 +94,7 @@ export const zh = { 'tool.title.createSchedule': '创建定时任务', 'tool.title.listSchedules': '查看定时任务', 'tool.title.deleteSchedule': '删除定时任务', + 'tool.title.updateSchedule': '修改定时任务', 'detail.state': '状态', 'detail.todo.completed': '已完成', 'detail.todo.in_progress': '进行中', @@ -132,6 +133,17 @@ export const zh = { 'detail.schedule.empty': '没有定时任务', 'detail.schedule.deleted': '已删除', 'detail.schedule.count': '{count} 个定时任务', + 'detail.schedule.daily': '每天 {time}({zone})', + 'detail.schedule.weekly': '每周 {days} {time}({zone})', + 'detail.schedule.cron': 'Cron {expression}({zone})', + 'detail.weekday.1': '周一', + 'detail.weekday.2': '周二', + 'detail.weekday.3': '周三', + 'detail.weekday.4': '周四', + 'detail.weekday.5': '周五', + 'detail.weekday.6': '周六', + 'detail.weekday.7': '周日', + 'detail.weekday.join': '、', 'tool.title.inspectProviders': '检查提供方', 'tool.title.queryRuntime': '查询运行时', 'tool.title.inspectPlugins': '检查动态插件', @@ -453,6 +465,7 @@ export const en = { 'tool.title.createSchedule': 'Create reminder', 'tool.title.listSchedules': 'List reminders', 'tool.title.deleteSchedule': 'Delete reminder', + 'tool.title.updateSchedule': 'Update reminder', 'detail.state': 'Status', 'detail.todo.completed': 'Completed', 'detail.todo.in_progress': 'In progress', @@ -491,6 +504,17 @@ export const en = { 'detail.schedule.empty': 'No reminders', 'detail.schedule.deleted': 'Deleted', 'detail.schedule.count': '{count} reminders', + 'detail.schedule.daily': 'Every day at {time} ({zone})', + 'detail.schedule.weekly': 'Weekly on {days} at {time} ({zone})', + 'detail.schedule.cron': 'Cron {expression} ({zone})', + 'detail.weekday.1': 'Mon', + 'detail.weekday.2': 'Tue', + 'detail.weekday.3': 'Wed', + 'detail.weekday.4': 'Thu', + 'detail.weekday.5': 'Fri', + 'detail.weekday.6': 'Sat', + 'detail.weekday.7': 'Sun', + 'detail.weekday.join': ', ', 'tool.title.inspectProviders': 'Inspect providers', 'tool.title.queryRuntime': 'Query runtime', 'tool.title.inspectPlugins': 'Inspect plugins', diff --git a/packages/client/ui-conversation/tests/composer-blocks.client.spec.ts b/packages/client/ui-conversation/tests/composer-blocks.client.spec.ts new file mode 100644 index 0000000000..febdc067a7 --- /dev/null +++ b/packages/client/ui-conversation/tests/composer-blocks.client.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' + +const SESSION = 'blocked-session' as SessionId + +describe('ComposerBlockRegistry', () => { + it('republishes only when the copy changes', () => { + const registry = new ComposerBlockRegistry() + const listener = vi.fn() + const store = registry.storeFor(SESSION) + store.subscribe(listener) + + registry.set(SESSION, { reason: 'select a model first' }) + expect(store.getSnapshot()).toEqual({ reason: 'select a model first' }) + expect(listener).toHaveBeenCalledTimes(1) + + // Identical copy: no publication at all. + registry.set(SESSION, { reason: 'select a model first' }) + expect(listener).toHaveBeenCalledTimes(1) + + registry.set(SESSION, undefined) + expect(store.getSnapshot()).toBeUndefined() + + registry.forget(SESSION) + expect(registry.storeFor(SESSION).getSnapshot()).toBeUndefined() + }) +}) diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 1629d28a35..56b7fac3a4 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -1059,8 +1059,8 @@ export const IconDatabaseOutlineMedium = (props: IconProps) => ( const IconClockOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => ( ) diff --git a/packages/client/ui-schedule/README.i18n.yaml b/packages/client/ui-schedule/README.i18n.yaml index f4555ceab2..b7d6d89b78 100644 --- a/packages/client/ui-schedule/README.i18n.yaml +++ b/packages/client/ui-schedule/README.i18n.yaml @@ -3,41 +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: af257c854bda5c00 - zh: 55426306f517d696 + en: 265778d19fe0dd76 + zh: 78b0a191a4bd4e4e /deepseek-ai-dsh-client-ui-schedule: en: 862120984d5392eb zh: eb3e5d5a802da592 /deepseek-ai-dsh-client-ui-schedule/summary: - en: 8b9b3a84423f4b1d - zh: 9521ab04209deb47 + en: 8dfc371c14bb4415 + zh: 9b3545c1a7468a7a /deepseek-ai-dsh-client-ui-schedule/table-of-contents: en: d152484eb41ac6b4 - zh: 09388d293f9be9cb + zh: 405b30feaf48c087 /deepseek-ai-dsh-client-ui-schedule/use-this-package: - en: 6e1e74b73a4eb24e - zh: ffc0fcf59a2156cd -/deepseek-ai-dsh-client-ui-schedule/use-this-package/read-and-dismiss-the-catalog: - en: 44a33487c9c18789 - zh: 5b567a9fc4c099a7 + en: 6ae804e65293c141 + zh: ebdbe1b5b81ea52d +/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source: + en: 6bd6882dbbbefcb0 + zh: 03aa15f1a65d13dc /deepseek-ai-dsh-client-ui-schedule/understand-the-implementation: - en: 2430ec651a2a577d - zh: 6f5cad74adeaae32 -/deepseek-ai-dsh-client-ui-schedule/understand-the-implementation/source-map: - en: e89204b9b4dd3003 - zh: 3ab55a0503de4b4f + en: f863859edc1cb0f7 + zh: 2bf04cdd1f17bb18 /deepseek-ai-dsh-client-ui-schedule/further-exploration: - en: 81ae5a7021733ada - zh: 54d4ec1070685345 + en: 06e265120b05dbf5 + zh: 08ab1ef979b8ea40 /deepseek-ai-dsh-client-ui-schedule/model-experience: - en: 5c6b73a7cfda0351 - zh: fa075bf6a3258a94 + en: a8838aba5fd29fd8 + zh: b9f67d82c81dd030 /deepseek-ai-dsh-client-ui-schedule/model-experience/kv-cache-effect: - en: 47f982e326fe77fd - zh: c2241315dc0c89c9 + en: 4b81350950285d47 + zh: 6dc3d5cf7ca8a622 /deepseek-ai-dsh-client-ui-schedule/known-limitations-and-deferred-work: - en: 8efb04400a306d00 - zh: 1da6b0f94d3bd6a2 + en: ce8cd6e03f4763ae + zh: 65ad26aad74d83ca /deepseek-ai-dsh-client-ui-schedule/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 - zh: 3bb0c20c93f16f98 + zh: a4b126f9a4eaca6f diff --git a/packages/client/ui-schedule/README.md b/packages/client/ui-schedule/README.md index 689bd3fa21..fc60b7c3f1 100644 --- a/packages/client/ui-schedule/README.md +++ b/packages/client/ui-schedule/README.md @@ -1,5 +1,5 @@ --- -description: "The read-only Web catalog for active Schedule reminders, for users choosing the surface and maintainers of its projection, timing, and accessibility behavior." +description: "Web task management across Sessions and a current-Session reminder catalog." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders a read-only catalog of the current Session's active Schedule reminders in the Web header. It reads the complete `schedule` projection and issues no RPC or mutation. The browser derives status, local time, relative time, and ordering without adding those presentation values to durable state. The shipped Web bundle keeps the plugin disabled until the explicit Schedule overlay enables both the Host Schedule services and this client row. +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. ## Table of Contents @@ -25,78 +25,81 @@ This package renders a read-only catalog of the current Session's active Schedul ## Use this package -Enable the Schedule overlay before starting the Web Session that should expose reminders: +Enable the Schedule overlay: ```sh dsh web --patch apps/cli/config/examples/schedule/cordis.yml ``` -The shipped Web graph already resolves `@deepseek-ai/dsh-client-ui-schedule` through a disabled `ui-schedule` row; the overlay enables that row together with `@deepseek-ai/dsh-schedule`. The trigger appears only while the Session is successfully open and the projection contains at least one active record. Opening it shows overdue rows first, then future rows by target time, with exact ties preserving the projection's creation order. +The shipped Web bundle contains a disabled `ui-schedule` row; the overlay enables it with the Host Schedule service. 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. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state keeps the heading's New action beside the notice. Selecting an active or inactive task opens a second detail level with Rules and Delivery 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. 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. -### Read and dismiss the catalog +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. -Each row shows the complete wrapping prompt, a separate Scheduled or Overdue status, localized Once or the largest exact whole unit for a repeating interval, browser-local target time, and browser-clock-relative time. Scheduled uses the shared idle dot and Overdue uses the shared warning dot. Intervals are never rounded, and the three metadata fields wrap across lines instead of clipping valid large values. The body-portaled popover targets 336px, shares the trigger's left edge when space permits, and shifts left to retain a 16px viewport margin when the trigger is near the right edge; its maximum width is the viewport width minus 32px. It scrolls vertically when needed and exposes no Schedule id, raw UTC value, details, or action controls. +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. 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 300 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. -Only the native trigger button enters the tab order. Enter and Space use normal button activation; while focus remains on the trigger or catalog, Escape closes the popover and restores trigger focus; an outside pointer press dismisses it. If a live update removes the final record, the component closes and unmounts without moving focus to another header action. A failed Session open hides the trigger even when a tentative cached projection exists. +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. + +Opening Delivery 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 legacy receipt without a prompt never substitutes the current instruction. + +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. + +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, and the header popover — 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. Confirming a deletion closes the detail or the tab it was confirmed from, and 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. ----- +### 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. + ## Understand the implementation
Implementation internals — click to expand -The browser plugin contributes `schedule-catalog` to `conversation.session.header.actions` at order 10, after static Agent and Subagent context and before background Jobs. It reads `openState` through the standard Session hook and the complete value through `useProjection('schedule')`; popover visibility is its only local interaction state. The component portals the catalog to `document.body` and gives its trigger and panel refs to `useAnchoredPosition`, which publishes fixed coordinates after measuring the rendered panel, keeps a 5px gap below the trigger, clamps to a 16px viewport margin, and remeasures on resize, captured scroll, and panel resize. The catalog ref also makes pointer presses inside the portal part of the existing dismissal boundary. Browser formatting uses the viewing locale, time zone, and clock, while durable Schedule records remain unchanged. +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. -### Source map +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.` 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. -| File | Role | -|---|---| -| [`src/client/index.ts`](src/client/index.ts) | Browser entry: locale registration and Session-header slot contribution | -| [`src/client/ScheduleCatalogAction.tsx`](src/client/ScheduleCatalogAction.tsx) | Visibility, ordering, formatting, popover, and keyboard behavior | -| [`src/client/locales.ts`](src/client/locales.ts) | English and Chinese catalog copy | -| [`src/index.ts`](src/index.ts) | Empty Host apply that keeps the optional browser feature addressable by Loader | -| — | No runtime invariant companion is published because this read-only client catalog owns no mutable cross-plugin state. | +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 Delivery 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. -The [durable Web Schedule Agent Note](../../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.md) owns the active projection and opt-in presentation boundary; this package owns the catalog's timing and accessibility behavior. +No runtime invariant companion is published because the catalogs render Host-owned task state and own only disposable query and interaction state.
------ - ## Further Exploration -Read these pages when the catalog itself is not enough. They move from the browser presentation to the durable Schedule state and shared projection transport. - -- [Schedule package](../../schedule/schedule/README.md) — creates, lists, cancels, and delivers the reminders shown here. -- [Schedule subsystem](../../../docs/subsystems/schedule.md) — durable record, transition, and delivery semantics. -- [Session projections subsystem](../../../docs/subsystems/session-projection.md) — the complete-value transport this package reads. -- [Client package map](../README.md) — adjacent browser UI packages. - ------ +- [Schedule](../../schedule/schedule/README.md) owns persistence, dispatch, and deletion semantics. +- [Slots](../../../docs/subsystems/slots.md) documents injected hooks and panel contributions. ## Model Experience -None, as this package renders a completed client projection for a human and never changes prompts, messages, schemas, streams, or tool results. +None, as this browser UI registers no model-facing tools or messages; Schedule owns reminder delivery. #### KV Cache effect -None; the package never assembles or sends provider requests. +None. Catalog presentation does not enter model requests. ## Known Limitations and Deferred Work - -These limits define the current Schedule catalog. They are current package constraints, not a reminder-service comparison or a task backlog. - -- **Active records only** — terminal delete and dispatch transitions remove rows; the ordinary transcript remains the only reminder-delivery history. -- **Browser-derived time** — local and relative labels use the viewing browser's current locale, time zone, and clock. They are presentation values, not durable Schedule facts. -- **Read-only surface** — creating and deleting reminders remain with the Schedule tools; the catalog has no mutation, retry, acknowledgement, toast, or delivery-receipt semantics. -- **Open Session required** — a failed open hides even a tentative cached value because strict Session replay remains authoritative. +- 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. ### Dev Note diff --git a/packages/client/ui-schedule/README.zh.md b/packages/client/ui-schedule/README.zh.md index b868eaee03..2ff1533fae 100644 --- a/packages/client/ui-schedule/README.zh.md +++ b/packages/client/ui-schedule/README.zh.md @@ -1,5 +1,5 @@ --- -description: "说明活动 Schedule 提醒的只读 Web 目录,供用户选择该界面,也供维护者了解其 projection、时间与无障碍行为。" +description: "跨会话的 Web 任务管理页面及当前会话的提醒列表。" kind: "package-reference" --- @@ -9,15 +9,15 @@ kind: "package-reference" ## 概述 -本包在 Web 会话头部渲染当前会话活动 Schedule 提醒的只读目录。它读取完整的 `schedule` projection,不发 RPC,也不执行 mutation。浏览器派生状态、本地时间、相对时间与排序,不把这些呈现值加入持久状态。随附 Web bundle 默认禁用该插件,只有显式 Schedule overlay 才会同时启用 Host Schedule 服务与此客户端 row。 +在「自动化任务」页面和右侧栏的任务页签中管理各个会话的活动和未运行定时任务:搜索、筛选、修改名称、指令和运行时间、浏览任务运行记录、删除任务,或打开原会话。每个界面都用任务已存储的标题命名任务。已打开会话在存在活动提醒时,页头显示仅图标的提醒时钟,点击在右侧栏打开该任务详情;`schedule_create` 调用把创建的任务渲染为会话卡片;侧栏中带活动任务的未归档空闲会话行显示时钟标记,其悬停卡片列出这些任务。 ## 目录 - [使用本包](#use-this-package) - [理解实现](#understand-the-implementation) -- [进一步探索](#further-exploration) +- [延伸阅读](#further-exploration) - [模型体验](#model-experience) -- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [已知限制与后续工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) ----- @@ -25,84 +25,87 @@ kind: "package-reference" ## 使用本包 -在需要显示提醒的 Web 会话启动前启用 Schedule overlay: +启用 Schedule overlay: ```sh dsh web --patch apps/cli/config/examples/schedule/cordis.yml ``` -随附 Web graph 已通过 disabled 的 `ui-schedule` row 解析 `@deepseek-ai/dsh-client-ui-schedule`;overlay 会把该 row 与 `@deepseek-ai/dsh-schedule` 一起启用。只有会话已成功打开且 projection 至少包含一条活动记录时,触发器才会出现。打开目录后,逾期行在前,未来行再按目标时间排序;完全并列时保留 projection 的创建顺序。 +发布的 Web bundle 包含默认禁用的 `ui-schedule` 行;overlay 将其与 Host Schedule 服务一起启用。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示旁保留页头的“新建”操作。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。 -### 阅读和关闭目录 +从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。 -每一行显示可完整换行的提示词、独立的「等待中」或「已逾期」状态、本地化的「单次」或重复间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。「等待中」使用共享 idle 灰点,「已逾期」使用共享 warning 橙点。间隔绝不舍入,三项元数据会按行换行,不会裁剪合法的大数值。通过 portal 挂到 body 的弹层目标宽度为 336px;空间足够时与触发按钮左边缘对齐,触发器靠近视口右侧时向左避让并保留 16px 视口边距,最大宽度为视口宽度减 32px。弹层会在需要时纵向滚动,且不显示 Schedule id、原始 UTC 值、详情或操作控件。 +“规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 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 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 300 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。 -只有原生触发按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;焦点仍在触发器或目录内时,Escape 会关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live 更新移除最后一条记录,组件会关闭并卸载,但不会把焦点移到另一个会话头部动作。会话打开失败时,即使存在暂定的缓存 projection,也会隐藏触发器。 +行在保存进行中、目录加载中、删除期间以及任务未运行时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。 + +打开“任务运行记录”时才请求最新的 20 条已保存记录。“加载更早的记录”按追加顺序从新到旧增加一页,不受实际时间回拨影响。该视图中页签条就是整个页头:名称输入框和“下次运行”行只属于“规则”。每条“已保存的任务运行记录”以时钟图标和任务时区下的本地月份名与时钟字段开头(一次性记录使用浏览器时区),并在存在时显示实际保存的提示文本。旧回执没有提示文本时,绝不以当前指令代替。 + +最后一页加载完成后,非空且确认裁剪过的历史在详情面板左下角显示无分隔线的清理提示,信息图标可展开或收起当前宿主的保留规则。空历史、未确认裁剪的旧格式历史、尚未加载完的分页和读取失败均不显示清理提示。 + +历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。 + +自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。 + +已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。 + +弹层通过 portal 挂到 body,目标宽度为 336px;空间足够时与触发按钮左边缘对齐,触发器靠近视口右侧时向左避让并保留 16px 视口边距,宽度不超过视口宽度减 32px,列表溢出时纵向滚动。它不显示任务 id、原始 UTC 值或投递详情;每行只带自身的详情入口和删除操作。 + +调用 `schedule_create` 时,创建的任务会以卡片形式渲染在 transcript 中,包含时钟图标、已存储的标题、频率,以及打开该任务右侧栏页签的“打开”按钮。卡片先按该调用持久化的结果 JSON 收窄出任务,并从该结果读取 `title`;当一次在卡片出现之后成功结束的目录读取解析到该任务时,卡片改为跟随目录显示当前名称与频率,若该读取中不含该记录,则以“已删除”代替频率显示。在已存储字段存在之前录制的结果回退为指令首行。仍在运行的调用、创建失败,或结果中没有完整任务时,卡片保留该行,并在结果为单个非空文本块时显示原始结果文本,不提供“打开”操作。卡片不增加 Host 字段,也不改变 Session 日志格式,因此早于该卡片录制的 Session 也会渲染它。 + +侧栏的 Session 行会标记有定时任务的会话:当某行处于空闲状态、未归档且其会话至少有一个活动任务时,该行显示时钟标记;归档行该格留空,其实时状态只在悬浮卡片中显示。对存在活动任务的会话长按悬停时,无论该行是否空闲,都会在其已有的悬浮卡片内增加自动化区段,最多列出两个任务(图标、已存储的标题、频率与下次运行文本),还有更多任务被隐藏时显示遗漏行。处于更高优先级状态的行——有待处理交互、活动仍在进行,或有未查看的完成提醒——保留自身状态点且不渲染标记,两者不会同时出现。该标记和悬停区段读取共享的活动与未运行 Host 任务目录,并从中筛选本会话的活动任务;它们不会激活、保留或取消归档任何会话,也不读取 Session 日志。 ----- +### 时区数据来源 + +时区清单与本地化完全离线。`Intl` 读取浏览器运行时内嵌的 ICU/CLDR 表,DSH 运行时不会获取或下载时区数据。没有需要刷新的爬虫;第三方语言包提供 `time.locale`,并仅翻译选择器自身的 UI 文案。 + ## 理解实现
-实现细节——点击展开 +实现内部细节 — 点击展开 -浏览器插件以顺序 10 向 `conversation.session.header.actions` 贡献 `schedule-catalog`,位于静态 agent(智能体)与 subagent 上下文之后、后台任务之前。它通过标准会话钩子读取 `openState`,通过 `useProjection('schedule')` 读取完整值;弹层开合是它唯一的本地交互状态。组件把目录 portal 到 `document.body`,并将触发器与面板 ref 交给 `useAnchoredPosition`;该 hook 在测量已渲染面板后发布 fixed 坐标,使面板位于触发器下方 5px、钳制在 16px 视口边距内,并在 resize、捕获阶段 scroll 与面板 resize 时重新测量。目录 ref 也让 portal 内的指针按下继续属于既有 dismissal 边界之内。浏览器格式化使用查看方的 locale、时区与时钟,持久 Schedule 记录保持不变。 +浏览器入口注册 `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` 刷新目录,而不把回读失败变成修改失败。 -### 源码地图 +刷新恢复出的任务标签没有导航参数,因为 Sidebar 只持久化标签的布局记录,不持久化打开方传入的内容。因此该页面类型自行保存从 Session 加布局记录 id 到该记录最后显示任务的 Session 和 id 的关联:每个 Session 一个 `dsh.schedule.task-tab.v1.` localStorage 键,按 tab id 一条记录,且从不保存任务内容。正文和 chip 只对没有导航参数的记录读取该条目,在标签出现之后成功的一次读取无法解析时将其删除,且不代替关联声称任务缺失。这样的一次读取无法为该标签给出任务标识时,正文陈述该状态,而不呈现永不结束的加载。 -| 文件 | 职责 | -|---|---| -| [`src/client/index.ts`](src/client/index.ts) | 浏览器入口:注册 locale 并贡献会话头部 slot | -| [`src/client/ScheduleCatalogAction.tsx`](src/client/ScheduleCatalogAction.tsx) | 可见性、排序、格式化、弹层与键盘行为 | -| [`src/client/locales.ts`](src/client/locales.ts) | 中英文目录文案 | -| [`src/index.ts`](src/index.ts) | 空的 Host apply,使 Loader 可以寻址该可选浏览器功能 | -| — | 不发布运行时不变量伴生入口;这个只读客户端目录不拥有可变的跨插件状态。 | +仅在框架观察数据源期间订阅。最后一个订阅取消时释放事件监听,并使尚未完成的响应失效。组件接收框架绑定的 `useCatalog` hook 和明确的操作回调。已挂载的任务运行记录视图使用所选任务的原会话绑定读取 `schedule.history`;目录和列表响应不包含完整历史。组件拥有可丢弃的搜索、筛选、选择、确认和历史分页状态。提醒记录由 Host 拥有,不来自历史会话投影或浏览器本地演示数据。 -[持久 Web Schedule Agent Note](../../../.agents/notes/implemented/feature/2026-08-05-durable-web-schedule.zh.md) 拥有活动 projection 与 opt-in 呈现边界;本包拥有目录的时间与无障碍行为。 +本包不发布运行时 invariant companion,因为目录呈现 Host 拥有的任务状态,自身只拥有可丢弃的查询和交互状态。
------ - -## 进一步探索 +## 延伸阅读 -当目录本身不够用时阅读以下页面。它们从浏览器呈现逐步进入持久 Schedule 状态与共享 projection 传输。 - -- [Schedule 包](../../schedule/schedule/README.zh.md)——创建、列出、取消并交付这里显示的提醒。 -- [Schedule 子系统](../../../docs/subsystems/schedule.zh.md)——持久记录、转换与交付语义。 -- [会话投影子系统](../../../docs/subsystems/session-projection.zh.md)——本包读取的完整值传输。 -- [客户端包映射](../README.zh.md)——相邻的浏览器 UI 包。 - ------ +- [Schedule](../../schedule/schedule/README.zh.md) 拥有持久化、投递和删除语义。 +- [槽位](../../../docs/subsystems/slots.zh.md) 说明注入 hook 和面板贡献。 ## 模型体验 -无,因为本包只为人类渲染已经完成的客户端 projection,从不改变提示词、消息、schema、流或工具结果。 +无,因为浏览器 UI 不注册面向模型的工具或消息;提醒投递由 Schedule 拥有。 #### KV Cache 影响 -无;本包从不组装或发送提供方请求。 +无。列表展示不进入模型请求。 -## 已知限制与延期工作 +## 已知限制与后续工作 - -这些限制界定当前 Schedule 目录。它们是当前包约束,不是提醒服务对比或任务积压。 - -- **仅含活动记录**——终结性的 delete 与 dispatch 转换会移除对应行;普通 transcript(文本记录)仍是唯一的提醒交付历史。 -- **浏览器派生时间**——本地时间与相对时间标签使用查看方浏览器当前的 locale、时区与时钟。它们是呈现值,不是持久 Schedule 事实。 -- **只读界面**——创建与删除提醒仍由 Schedule 工具负责;目录没有 mutation、Retry、acknowledgement、Toast 或交付回执语义。 -- **要求会话打开成功**——打开失败时,即使存在暂定缓存值也会隐藏,因为严格会话回放仍是权威。 +- 已保存的记录描述收件箱投递,不表示模型执行结果。没有已保存历史的任务仅呈现已有回执,直到新投递追加记录;未保存的更早记录和提示文本快照无法恢复。此前已物理删除的任务不会恢复。 +- 每页 20 条仅限制请求的记录数,不限制提示文本字节数或任务保留的存储。投递历史由 Host 插件的配置设界:`deliveryHistoryDays`(默认 30)与 `deliveryHistoryRecords`(默认 200),在追加确认时裁剪,最近一次回执始终保留,被裁剪的更早窗口报告为不可用;见 [Schedule 限制](../../schedule/schedule/README.zh.md#known-limitations-and-deferred-work)。 +- 删除不会取消已经排队执行的提醒。详情页签条内的关联会话入口使用普通会话导航及其恢复策略打开原对话,不直接跳转到特定消息。 +- 创建提醒仍通过 Schedule 工具完成。仅在活动任务的“规则”中可修改名称、指令和运行时间,自动化任务页面和任务页签均可;不提供暂停或立即执行,且“运行时间”卡片的“重复”菜单接受任意选择,包括改变已存储重复规则种类;此时按新规则重算目标。页面的“新建”操作会打开一个会话,而不是在页面内创建任务;会话页头的弹层本身不提供运行时间编辑器。“时区”菜单提供运行时的 IANA 时区清单,运行时无法枚举时改用简短的候选列表。 ### 开发备注
-维护者的工作上下文——点击展开 +维护工作上下文 — 点击展开 无。 diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index b02a6c84b6..ebb156683f 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", - "description": "Read-only active Schedule catalog in the Web Session header", + "description": "Host task management page and Session reminder catalog", "version": "0.1.7-rc.1", "type": "module", "main": "lib/index.js", @@ -20,8 +20,17 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-api-session-controller", + "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-sidebar", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-tool", + "@deepseek-ai/dsh-client-ui-workspace", "@deepseek-ai/dsh-client-ui-primitives" ], "platform": "web" @@ -44,19 +53,29 @@ "@deepseek-ai/cordis": "workspace:~" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:*", "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-test-runtime": "workspace:*", "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-tool": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", "@deepseek-ai/dsh-schedule": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", + "clsx": "^2.1.1", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", "react-dom": "^18.2.0" diff --git a/packages/client/ui-schedule/src/client/CalendarIcon.tsx b/packages/client/ui-schedule/src/client/CalendarIcon.tsx new file mode 100644 index 0000000000..4bcfaf88d1 --- /dev/null +++ b/packages/client/ui-schedule/src/client/CalendarIcon.tsx @@ -0,0 +1,35 @@ +/** Calendar glyph for the date row's picker trigger. */ +import type { ReactNode } from 'react' + +/** Inputs of the calendar glyph, matching the product icon set's props. */ +export interface CalendarIconProps { + /** Extra class for layout placement; color rides currentColor. */ + readonly className?: string | undefined +} + +/** + * Draw the 14px calendar outline the Date row's trigger carries. + * + * The shared product icon set has no calendar glyph, so this package draws the + * one its own picker needs, at that set's regular 1px stroke. + * @param props - extra class for layout placement. + * @returns the decorative glyph. + */ +export function IconCalendarOutlineRegular({ className }: CalendarIconProps): ReactNode { + return ( + + ) +} diff --git a/packages/client/ui-schedule/src/client/CatalogFeedback.tsx b/packages/client/ui-schedule/src/client/CatalogFeedback.tsx new file mode 100644 index 0000000000..376a880edc --- /dev/null +++ b/packages/client/ui-schedule/src/client/CatalogFeedback.tsx @@ -0,0 +1,47 @@ +/** Catalog query states shared by the task list, the detail, and the task tab. */ +import type { ReactNode } from 'react' +import { Button, IconWarningOutlineRegular, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import css from './TaskManagerPage.module.css' + +/** Props of the catalog states: query state, shown content, and the retry action. */ +export type CatalogFeedbackProps = PropsLocale<'schedule.manager'> & { + /** Catalog query state; loading and a failed read each render their own state. */ + readonly status: 'loading' | 'ready' | 'error' + /** + * Whether catalog content is on screen: shown content keeps a compact + * failure notice beside it, while an empty panel centers its states. + */ + readonly populated: boolean + /** Reload the catalog after a query failure. */ + readonly onRetry: () => Promise +} + +/** + * Render the catalog's loading and query-failure states. + * + * An empty panel centers a bare spinner while loading and the failure with its + * retry; a panel that already shows content keeps a compact notice for a failed + * refresh, and stays silent while a refresh loads, so the retained rows remain + * readable. Deletion outcomes are not catalog states: the app-wide toast + * announces them. + * @param props - query state, whether content is shown, the retry action, and localized copy. + * @returns the applicable states, or nothing while the catalog is ready. + */ +export function CatalogFeedback({ status, populated, onRetry, t }: CatalogFeedbackProps): ReactNode { + return <> + {status === 'loading' && !populated &&
+ +
} + {status === 'error' && (populated + ?
+

{t('list.error')}

+ +
+ :
+ +

{t('list.error')}

+ +
)} + +} diff --git a/packages/client/ui-schedule/src/client/ClockPicker.module.css b/packages/client/ui-schedule/src/client/ClockPicker.module.css new file mode 100644 index 0000000000..08e3eab512 --- /dev/null +++ b/packages/client/ui-schedule/src/client/ClockPicker.module.css @@ -0,0 +1,48 @@ +/* Three scrolling clock columns inside the shared picker card. */ +.clock { + flex-direction: row; + gap: 2px; + padding: 3px; +} + +.column { + display: flex; + flex-direction: column; + width: 52px; + max-height: 216px; + overflow-y: auto; + overscroll-behavior: contain; +} + +/* One clock value. The right column's rows are 13px copy, so the columns set + their own size rather than inheriting the card's. */ +.option { + display: flex; + flex: none; + align-items: center; + justify-content: center; + min-height: 32px; + border-radius: 10px; + color: var(--dsw-alias-label-primary); + font-size: 13px; + font-variant-numeric: tabular-nums; + line-height: 1.6; + cursor: pointer; + outline: none; +} + +.option:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.option:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; +} + +/* The value the row shows, marked the way the card's active chips are. */ +.selected { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-button-ghost-active-fill); + box-shadow: inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border); +} diff --git a/packages/client/ui-schedule/src/client/ClockPicker.tsx b/packages/client/ui-schedule/src/client/ClockPicker.tsx new file mode 100644 index 0000000000..9962aecd62 --- /dev/null +++ b/packages/client/ui-schedule/src/client/ClockPicker.tsx @@ -0,0 +1,215 @@ +/** + * Three-column clock picker for one staged Run time value. + * + * The picker owns only the panel: the row renders the read-only trigger that + * shows the value, and its Escape guard closes the panel and hands focus back. + */ +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' + +/** Row copy of the task manager namespace, as the pickers receive it. */ +type Translate = PropsLocale<'schedule.manager'>['t'] + +/** Padded two-digit clock values from zero through `count - 1`. */ +function padded(count: number): readonly string[] { + return Array.from({ length: count }, (_value, index) => String(index).padStart(2, '0')) +} + +/** The picker's columns in display order, each with the label row copy names it by. */ +const COLUMNS = [ + { label: 'timing.hour', options: padded(24) }, + { label: 'timing.minute', options: padded(60) }, + { 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}$/ + +/** + * 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. + * @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' +} + +/** One column's position in display order. */ +type ClockColumnIndex = 0 | 1 | 2 + +/** The picker's columns in display order, by position. */ +const COLUMN_INDICES: readonly ClockColumnIndex[] = [0, 1, 2] + +/** + * Index of one column's option for one clock. + * @param clock - whole-second clock text. + * @param column - column index in display order. + * @returns that column's option index, or the first option for a value the column does not list. + */ +function columnIndex(clock: string, column: ClockColumnIndex): number { + const options = COLUMNS[column].options + const start = column === 0 ? 0 : column * 3 + return Math.max(0, options.indexOf(clock.slice(start, start + 2))) +} + +/** One option the panel shows as highlighted and holds the keyboard on. */ +interface ClockCursor { + /** Column the keyboard is in, in display order. */ + readonly column: number + /** Option index inside that column. */ + readonly index: number +} + +/** Inputs of the clock picker. */ +export interface ClockPickerProps { + /** Whether the panel is showing. */ + readonly open: boolean + /** The row's trigger button, which anchors the panel. */ + readonly anchorRef: RefObject + /** The staged clock text. */ + readonly value: string + /** Stage one picked `HH:MM:SS` clock. */ + readonly onPick: (time: string) => void + /** 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. + */ + readonly seconds?: boolean + /** Row copy. */ + readonly t: Translate +} + +/** + * Render the clock panel: hours, minutes, and optionally seconds, one scrolling column each. + * @param props - open state, the trigger, the staged clock, the pick and close callbacks, the seconds-column switch, and row copy. + * @returns the anchored panel while open, and nothing while closed. + */ +export function ClockPicker({ open, anchorRef, value, onPick, onClose, seconds = true, t }: ClockPickerProps): ReactNode { + const columns = seconds ? COLUMNS : COLUMNS.slice(0, 2) + const refs = useRef(new Map()) + const valueRef = useRef(value) + valueRef.current = value + const base = clockBase(value) + const picked = [base.slice(0, 2), base.slice(3, 5), base.slice(6, 8)] + // Each column keeps one focusable entry: the row its own value occupies, so + // the keyboard reaches minutes and seconds, and each column's arrows walk + // from the value it shows. The live cursor adds the row the keyboard moved + // onto while it stays inside that column. + const columnIndices = COLUMN_INDICES.map(column => columnIndex(base, column)) + const [cursor, setCursor] = useState({ column: 0, index: columnIndex(base, 0) }) + + // Opening starts on the clock the row shows, with every column scrolled to + // that value: the first arrow key then walks from the current time in the + // column the keyboard enters rather than from midnight. + useEffect(() => { + if (!open) return + setCursor({ column: 0, index: columnIndex(clockBase(valueRef.current), 0) }) + for (const column of COLUMN_INDICES) { + refs.current.get(`${column}:${columnIndex(clockBase(valueRef.current), column)}`) + ?.scrollIntoView({ block: 'nearest' }) + } + }, [open]) + + // The cursor owns the keyboard: a pick, an arrow, or the open itself moves the + // highlight and takes focus with it, so the panel keeps the keyboard until it + // closes. + useEffect(() => { + if (!open) return + refs.current.get(`${cursor.column}:${cursor.index}`)?.focus() + }, [open, cursor]) + + /** + * Stage one option and move the cursor onto it. + * @param column - column index the option belongs to. + * @param option - padded option text. + * @param index - option index inside that column. + */ + const pick = (column: number, option: string, index: number): void => { + setCursor({ column, index }) + const next = [...picked] + next[column] = option + onPick(next.join(':')) + } + + /** + * Move inside one column, move between columns, or stage the option the + * keyboard is on. + * @param event - keydown from that option. + * @param column - column index the option belongs to. + * @param index - option index inside that column. + * @param option - padded option text. + * @param count - number of options in the column. + */ + const onOptionKeyDown = ( + event: KeyboardEvent, column: number, index: number, option: string, count: number, + ): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + pick(column, option, index) + return + } + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault() + const next = columns[column + (event.key === 'ArrowRight' ? 1 : -1)] + if (next === undefined) return + // Columns hold different counts, so the row carried across stays inside + // the target column. + const target = columns.indexOf(next) + setCursor({ column: target, index: Math.min(index, next.options.length - 1) }) + return + } + if (event.key === 'Home' || event.key === 'End') { + event.preventDefault() + setCursor({ column, index: event.key === 'Home' ? 0 : count - 1 }) + return + } + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return + event.preventDefault() + const step = event.key === 'ArrowDown' ? 1 : -1 + setCursor({ column, index: Math.min(Math.max(index + step, 0), count - 1) }) + } + + return + {columns.map((column, at) => ( +
+ {column.options.map((option, index) => ( +
{ + const key = `${at}:${index}` + if (element === null) refs.current.delete(key) + else refs.current.set(key, element) + }} + role="option" + aria-selected={option === picked[at]} + tabIndex={ + (cursor.column === at ? cursor.index : columnIndices[at]) === index ? 0 : -1 + } + className={clsx(css.option, option === picked[at] && css.selected)} + onClick={() => { pick(at, option, index) }} + onKeyDown={(event) => { onOptionKeyDown(event, at, index, option, column.options.length) }} + >{option}
+ ))} +
+ ))} +
+} diff --git a/packages/client/ui-schedule/src/client/DatePicker.module.css b/packages/client/ui-schedule/src/client/DatePicker.module.css new file mode 100644 index 0000000000..60d40152a1 --- /dev/null +++ b/packages/client/ui-schedule/src/client/DatePicker.module.css @@ -0,0 +1,107 @@ +/* Month calendar inside the shared picker card. */ +.calendar { + flex-direction: column; + gap: 4px; + width: 232px; + padding: 8px; +} + +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; +} + +.title { + flex: 1 1 auto; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 1.6; + text-align: center; +} + +.nav { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.nav:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.nav:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; +} + +.grid { + display: flex; + flex-direction: column; + gap: 2px; +} + +.week { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +/* The right column's own 13px step: the panel has no inherited type scale. */ +.weekday { + display: flex; + align-items: center; + justify-content: center; + height: 22px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 1.6; +} + +.cell { + display: flex; + align-items: center; + justify-content: center; + height: 28px; + border-radius: 8px; + color: var(--dsw-alias-label-primary); + font-size: 13px; + font-variant-numeric: tabular-nums; + line-height: 1.6; + cursor: pointer; + outline: none; +} + +.cell:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.cell:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; +} + +/* Today keeps a hairline ring; the picked day takes the active chip fill. */ +.cell[aria-current='date'] { + box-shadow: inset 0 0 0 0.5px var(--dsw-alias-border-l3); +} + +.selected { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-button-ghost-active-fill); + box-shadow: inset 0 0 0 1px var(--dsw-alias-button-ghost-active-border); +} + +.blank { + height: 28px; +} diff --git a/packages/client/ui-schedule/src/client/DatePicker.tsx b/packages/client/ui-schedule/src/client/DatePicker.tsx new file mode 100644 index 0000000000..b6aab6de48 --- /dev/null +++ b/packages/client/ui-schedule/src/client/DatePicker.tsx @@ -0,0 +1,226 @@ +/** + * Month calendar for one staged one-shot date. + * + * The picker owns only the panel: the row renders the read-only trigger, whose + * text exposes the date as `YYYY/MM/DD` while the draft it stages keeps the ISO + * text this panel writes back and the Host receives. The row's Escape guard + * closes the panel and hands focus back. + */ +import { useEffect, useRef, useState } from 'react' +import type { KeyboardEvent, ReactNode, RefObject } from 'react' +import clsx from 'clsx' +import { + IconChevronLeftOutlineRegular, IconChevronRightOutlineRegular, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { PickerPopover } from './PickerPopover.tsx' +import type { TaskManagerKey } from './task-manager-locales.ts' +import css from './DatePicker.module.css' + +/** Row copy of the task manager namespace, as the pickers receive it. */ +type Translate = PropsLocale<'schedule.manager'>['t'] + +/** ISO calendar date, the only text this row stores. */ +const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/ + +/** Weekday column headings, Monday first as the weekly choice orders them. */ +const WEEKDAY_KEYS = [ + 'frequency.weekday.1', 'frequency.weekday.2', 'frequency.weekday.3', 'frequency.weekday.4', + 'frequency.weekday.5', 'frequency.weekday.6', 'frequency.weekday.7', +] as const satisfies readonly TaskManagerKey[] + +/** Arrow key to the number of days it moves the focused cell. */ +const STEP_BY_KEY: Readonly> = { + ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7, +} + +/** One month and the day the keyboard is on, as the panel shows them. */ +interface MonthView { + /** Four-digit year. */ + readonly year: number + /** Zero-based month. */ + readonly month: number + /** Day of that month, 1 through its last day. */ + readonly day: number +} + +/** + * The month and day one stored date opens on. + * @param date - staged ISO date text. + * @returns its own year, month, and day, or today when it is not an ISO calendar date. + */ +function viewOf(date: string): MonthView { + const match = ISO_DATE.exec(date) + if (match === null) { + const today = new Date() + return { year: today.getFullYear(), month: today.getMonth(), day: today.getDate() } + } + return { year: Number(match[1]), month: Number(match[2]) - 1, day: Number(match[3]) } +} + +/** Zero-padded two-digit field. */ +function pad(value: number): string { + return String(value).padStart(2, '0') +} + +/** + * Last day of one month. + * @param view - year and zero-based month. + * @returns that month's length in days. + */ +function daysIn(view: Pick): number { + return new Date(Date.UTC(view.year, view.month + 1, 0)).getUTCDate() +} + +/** + * The same day in another month, clamped to that month's length. + * @param view - current month and day. + * @param step - months to move, negative for earlier. + * @returns the moved view. + */ +function shiftMonth(view: MonthView, step: number): MonthView { + const months = view.year * 12 + view.month + step + const moved = { year: Math.floor(months / 12), month: months % 12 } + return { ...moved, day: Math.min(view.day, daysIn(moved)) } +} + +/** + * ISO text of one day in a shown month. + * @param view - shown year and month. + * @param day - day of that month. + * @returns `YYYY-MM-DD`. + */ +function isoOf(view: Pick, day: number): string { + return `${view.year}-${pad(view.month + 1)}-${pad(day)}` +} + +/** Inputs of the date picker. */ +export interface DatePickerProps { + /** Whether the panel is showing. */ + readonly open: boolean + /** The row's trigger button, which anchors the panel. */ + readonly anchorRef: RefObject + /** The staged ISO date. */ + readonly value: string + /** Stage one picked ISO date. */ + readonly onPick: (date: string) => void + /** Dismiss the panel. */ + readonly onClose: () => void + /** Row copy. */ + readonly t: Translate +} + +/** + * Render the month panel: one localized heading row over weeks of day cells. + * @param props - open state, the trigger, the staged date, the pick and close callbacks, and row copy. + * @returns the anchored panel while open, and nothing while closed. + */ +export function DatePicker({ open, anchorRef, value, onPick, onClose, t }: DatePickerProps): ReactNode { + const refs = useRef(new Map()) + const valueRef = useRef(value) + valueRef.current = value + const [view, setView] = useState(() => viewOf(value)) + const today = new Date() + const todayIso = isoOf({ year: today.getFullYear(), month: today.getMonth() }, today.getDate()) + + // Opening returns to the stored day, so the grid starts where the row does. + useEffect(() => { + if (!open) return + setView(viewOf(valueRef.current)) + }, [open]) + + // The focused day owns the keyboard, and a pick leaves it on that day. + useEffect(() => { + if (!open) return + refs.current.get(String(view.day))?.focus() + }, [open, view]) + + /** + * Stage one day of the shown month and leave the keyboard on it. + * @param day - day of the shown month. + */ + const pick = (day: number): void => { + setView(current => ({ ...current, day })) + onPick(isoOf(view, day)) + } + + /** + * Move the focused cell inside the month, or stage the day it is on. + * @param event - keydown from that cell. + * @param day - day of the shown month the cell holds. + */ + const onCellKeyDown = (event: KeyboardEvent, day: number): void => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + pick(day) + return + } + const step = STEP_BY_KEY[event.key] ?? 0 + if (step === 0) return + event.preventDefault() + const last = daysIn(view) + setView(current => ({ ...current, day: Math.min(Math.max(day + step, 1), last) })) + } + + const first = new Date(Date.UTC(view.year, view.month, 1)).getUTCDay() + const offset = (first + 6) % 7 + const total = daysIn(view) + const cellCount = Math.ceil((offset + total) / 7) * 7 + const weeks = Array.from({ length: cellCount / 7 }, (_value, week) => week) + const monthTitle = new Intl.DateTimeFormat(t('time.locale'), { + month: 'long', year: 'numeric', timeZone: 'UTC', + }).format(Date.UTC(view.year, view.month, 1)) + + return +
+ + {monthTitle} + +
+
+
+ {WEEKDAY_KEYS.map(key => ( +
{t(key)}
+ ))} +
+ {weeks.map(week => ( +
+ {Array.from({ length: 7 }, (_value, column) => week * 7 + column - offset + 1).map(day => ( + day < 1 || day > total + ? + ))} +
+ +} diff --git a/packages/client/ui-schedule/src/client/DeleteToast.tsx b/packages/client/ui-schedule/src/client/DeleteToast.tsx new file mode 100644 index 0000000000..af3da62aff --- /dev/null +++ b/packages/client/ui-schedule/src/client/DeleteToast.tsx @@ -0,0 +1,88 @@ +/** + * Transient outcome notice for schedule deletions. + * + * Every deletion — the Tasks page detail, the session task tab, and the + * Session-header popover — reports its settled outcome into one store, and one + * `shell.overlay` entry renders it as the app-wide banner. The surfaces keep no + * in-place deletion notice of their own, so an outcome outlives the panel or + * tab that asked for it. + */ +import type { ReactNode } from 'react' +import { IconWarningOutlineRegular, Toast } 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 { CatalogDeleteOutcome } from './catalog-source.ts' + +/** One reported deletion outcome; `seq` keys the banner so a re-show restarts it. */ +export interface DeleteToastState { + readonly kind: 'deleted' | 'deleteFailed' + readonly seq: number +} + +/** Store the deletion wiring reports into and the overlay entry reads. */ +export interface DeleteToastSource { + /** Current notice for the overlay entry, or null while none shows. */ + readonly hooks: { readonly toast: HostObservable } + /** + * Show the notice of one settled deletion; 'pending' raises nothing because + * the deletion already in flight reports its own outcome. + * @param outcome - resolution of `CatalogInjected.onDelete`. + */ + readonly report: (outcome: CatalogDeleteOutcome) => void + /** Clear the shown notice. */ + readonly dismiss: () => void +} + +/** + * Create the one deletion-outcome store the overlay entry shows. + * @returns the observable notice with its report and dismiss actions. + */ +export function createDeleteToastSource(): DeleteToastSource { + let state: DeleteToastState | null = null + let seq = 0 + const listeners = new Set<() => void>() + const publish = (next: DeleteToastState | null): void => { + state = next + for (const listener of listeners) listener() + } + return { + hooks: { + toast: { + getSnapshot: () => state, + subscribe(listener) { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + }, + report: (outcome) => { + if (outcome === 'pending') return + publish({ kind: outcome === 'deleted' ? 'deleted' : 'deleteFailed', seq: ++seq }) + }, + dismiss: () => { publish(null) }, + } +} + +/** Props of the `shell.overlay` entry: the notice store and localized copy. */ +export type ScheduleDeleteToastProps = PropsRuntime<'shell.overlay'> + & PropsLocale<'schedule.manager'> + & InjectFace> + +/** + * Render the current deletion notice: a success banner for a confirmed + * deletion, a warning for one that could not be confirmed, or nothing. + * @param props - the notice hook, its dismissal, and the locale seat. + * @returns the banner on display, or null. + */ +export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToastProps): ReactNode { + const toast = useToast(current => current) + if (toast === null) return null + return toast.kind === 'deleted' + ? + : } + onDone={dismiss} + /> +} diff --git a/packages/client/ui-schedule/src/client/DeliveryHistory.tsx b/packages/client/ui-schedule/src/client/DeliveryHistory.tsx new file mode 100644 index 0000000000..89f71adc6c --- /dev/null +++ b/packages/client/ui-schedule/src/client/DeliveryHistory.tsx @@ -0,0 +1,150 @@ +/** Lazy saved delivery pages owned by the selected task's mounted records view. */ +import { useCallback, useEffect, useId, useRef, useState } from 'react' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { Button, IconClockOutlineRegular, IconInfoOutlineRegular, IconWarningOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ScheduleDeliveryHistoryRequest, ScheduleDeliveryHistoryResult } from '@deepseek-ai/dsh-schedule/client' +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. + * @returns Remote success or failure; transport exceptions may reject the promise. + */ + readonly loadHistory: (request: ScheduleDeliveryHistoryRequest) => Promise> +} + +type Page = Extract +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'] + latestMessageId: Cursor + /** IANA zone of the task's own wall-clock rule; undefined for a one-shot or interval task. */ + timeZone?: string | undefined +} + +/** + * Render immutable saved deliveries; the parent keys this view by task and Session. + * 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. + * @returns Saved records and explicit loading, failure, and pagination actions. + */ +export function DeliveryHistory({ id, sessionId, latestMessageId, timeZone, loadHistory, t }: Props) { + const [page, setPage] = useState() + const [loading, setLoading] = useState(true) + const [failure, setFailure] = useState() + const [retentionOpen, setRetentionOpen] = useState(false) + const retentionId = useId() + const request = useRef({ epoch: 0, pending: false }) + const load = useCallback(async (before?: Cursor): Promise => { + if (request.current.pending) return + request.current.pending = true + const epoch = ++request.current.epoch + setLoading(true) + setFailure(undefined) + let result: RemoteResult + try { + result = await loadHistory({ id, sessionId, limit: 20, ...(before === undefined ? {} : { before }) }) + } catch (_error: unknown) { + // Transport exceptions use the same localized retry as Remote failures. + if (epoch !== request.current.epoch) return + request.current.pending = false + setLoading(false) + setFailure({ key: 'delivery.error', before }) + return + } + if (epoch !== request.current.epoch) return + request.current.pending = false + setLoading(false) + if (!result.ok) { + setFailure({ key: 'delivery.error', before }) + } else if ('code' in result.value) { + setFailure({ key: result.value.code === 'schedule_not_found' ? 'delivery.notFound' : 'delivery.cursorError', before: undefined }) + } else { + const next = result.value + setPage((previous) => { + if (before === undefined || previous === undefined) return next + const seen = new Set(previous.records.map(record => record.messageId)) + const records = [...previous.records] + for (const record of next.records) { + if (!seen.has(record.messageId)) { + seen.add(record.messageId) + records.push(record) + } + } + return { ...next, records } + }) + } + }, [id, sessionId, loadHistory]) + + useEffect(() => { + void load() + return () => { request.current.epoch++; request.current.pending = false } + }, [load, latestMessageId]) + + const formatOccurrence = (value: string): string => formatScheduleNextRun(value, t('time.locale'), timeZone) + const hasRecords = page !== undefined && page.records.length > 0 + return
+
+ {loading && !hasRecords &&
+ +
} + {/* The compact form concerns records already on screen; a failure with + nothing loaded centers itself in the panel. */} + {failure !== undefined && (hasRecords + ?
+

{t(failure.key)}

+ +
+ :
+ +

{t(failure.key)}

+ +
)} + {!loading && failure === undefined && page?.records.length === 0 + &&
+ +

{t('delivery.empty')}

+
} + {page?.records.map(record =>
+ +
+
+ +
+ {record.prompt !== undefined &&

{record.prompt}

} +
+
)} + {page?.nextBefore !== undefined && failure === undefined && } +
+ {hasRecords && page.earlierRecordsPruned && page.nextBefore === undefined && !loading && failure === undefined + &&
+
+ {t('delivery.pruned')} + + + +
+ {retentionOpen &&
+

{t('delivery.retentionBounds', { days: page.retention.days, records: page.retention.records })}

+

{t('delivery.retentionExplanation')}

+
} +
} +
+} diff --git a/packages/client/ui-schedule/src/client/PickerPopover.module.css b/packages/client/ui-schedule/src/client/PickerPopover.module.css new file mode 100644 index 0000000000..78128383fb --- /dev/null +++ b/packages/client/ui-schedule/src/client/PickerPopover.module.css @@ -0,0 +1,20 @@ +/* Shared card of the timing pickers: the Repeat and time-zone dropdown's own + translucent surface, portal-positioned from its trigger. */ +.panel { + position: fixed; + top: auto; + left: auto; + box-sizing: border-box; + z-index: 1100; + display: flex; + border: 0; + border-radius: 16px; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); + box-shadow: var(--dsw-elevation-prominent); + /* Elevated surface: the scrolling columns take the l2 thumb tokens, as in + every other menu card (see ui-theme styles/scrollbar.css). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} diff --git a/packages/client/ui-schedule/src/client/PickerPopover.tsx b/packages/client/ui-schedule/src/client/PickerPopover.tsx new file mode 100644 index 0000000000..5c6e1c5484 --- /dev/null +++ b/packages/client/ui-schedule/src/client/PickerPopover.tsx @@ -0,0 +1,59 @@ +/** Anchored, outside-dismissed popover shell the timing pickers share. */ +import { useRef } from 'react' +import type { CSSProperties, ReactNode, RefObject } from 'react' +import { createPortal } from 'react-dom' +import clsx from 'clsx' +import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives' +import css from './PickerPopover.module.css' + +/** Unplaced portal frame: laid out at the viewport origin but unpainted until measured. */ +const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } + +/** Inputs of the picker shell. */ +export interface PickerPopoverProps { + /** Whether the panel is mounted. */ + readonly open: boolean + /** The row's trigger button, which anchors the panel and counts as inside for dismissal. */ + readonly anchorRef: RefObject + /** Accessible name of the panel. */ + readonly label: string + /** Layout class of this picker's contents. + * (`| undefined` for exactOptionalPropertyTypes: the CSS module types every class as optional.) */ + readonly className: string | undefined + /** Dismiss the panel. */ + readonly onClose: () => void + /** The picker's columns or month grid. */ + readonly children: ReactNode +} + +/** + * Render one picker panel into `document.body`, fixed below its trigger. + * + * The panel is a portal, so an ancestor's `overflow` cannot crop it, and a + * pointerdown outside both the trigger and the panel dismisses it. + * @param props - open state, the trigger, the panel name and layout class, the dismissal callback, and the contents. + * @returns the panel while open, and nothing while closed. + */ +export function PickerPopover({ open, anchorRef, label, className, onClose, children }: PickerPopoverProps): ReactNode { + const panelRef = useRef(null) + useDismissOnOutsidePointer(anchorRef, open, onClose, panelRef) + const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12 }) + if (!open) return null + return createPortal( +
{ event.stopPropagation() }} + > + {children} +
, + document.body, + ) +} diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css index 79ff738c65..0be3acadf2 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css @@ -1,43 +1,37 @@ .root { position: relative; + /* Sets the reminder entry slightly apart from the utility to its left, which + sits at the header cluster's own tighter rhythm. */ + margin-left: 4px; } +/* Icon-only entry: the 28px round target used by the header's other icon + buttons, with the reminder count carried by the accessible name only. */ .trigger { display: inline-flex; + flex: none; align-items: center; - gap: 4px; - min-height: 28px; - padding: 3px 2px; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; border: 0; - border-radius: 6px; + border-radius: 28px; background: transparent; color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 18px; cursor: pointer; } .trigger:hover, .trigger:focus-visible { color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-interactive-bg-hover); } .trigger svg { flex: none; } -.trigger > svg:last-child { - transition: transform 120ms ease; -} - -.triggerOpen { - transform: rotate(180deg); -} - -.count { - margin-left: 2px; -} - .menu { position: fixed; z-index: 100; @@ -64,42 +58,82 @@ .row { display: flex; - flex-direction: column; + align-items: start; flex-shrink: 0; gap: 2px; box-sizing: border-box; width: 100%; min-height: 48px; - padding: 6px 8px; - border-radius: 7px; + padding: 10px 12px; + border-radius: 10px; color: var(--dsw-alias-label-primary); } +/* A task row is one whole-row opener, so the row itself carries the hover + surface; the status, error, and deletion notices stay inert. */ +.rowTask { + position: relative; +} + +.rowTask:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + .rowOverdue { background: var(--dsw-alias-state-warn-tertiary); } -.status { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 10px; - line-height: 15px; +.rowOverdue:hover { + background: var(--dsw-alias-state-warn-tertiary); } -.rowOverdue .status { - color: var(--dsw-alias-state-warn-label); +.body { + display: flex; + flex: 1; + flex-direction: column; + gap: 3px; + min-width: 0; } -.prompt { - font-size: 12px; - line-height: 17px; +.title { + font-size: 13px; + line-height: 18px; overflow-wrap: anywhere; white-space: normal; } -.metadata { +/* The name is the row's detail opener, so it keeps the row's own text styles. */ +.openButton { + display: block; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +/* The opener's hit area covers its whole row; the delete button, positioned + later in the row, stacks above it and keeps its own click. */ +.openButton::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 10px; +} + +.openButton:focus-visible { + outline: none; +} + +.openButton:focus-visible::after { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: -2px; +} + +.metadata, +.nextRun { display: flex; flex-wrap: wrap; align-items: center; @@ -110,6 +144,46 @@ line-height: 15px; } -.relativeOverdue { +.metadataOverdue { color: var(--dsw-alias-state-warn-label); } + +/* The next target states its own line and takes the frequency line's rule above. */ + +/* The countdown beside the absolute next run keeps the line's own ink; the + popover is narrow, so an unbreakable stamp still wraps. */ +.nextRunRelative { + min-width: 0; + overflow-wrap: anywhere; +} + +.deleteButton { + position: relative; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + border: 0; + border-radius: 6px; + background: none; + color: var(--dsw-alias-label-secondary); + cursor: pointer; +} + +.deleteButton:hover, +.deleteButton:focus-visible { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); +} + +.deleteButton:disabled { + cursor: wait; +} + +/* The open entry keeps the same quiet gray as its hover state. */ +.trigger[aria-expanded='true'] { + background: var(--dsw-alias-interactive-bg-hover); +} diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx index b5bb0ab207..abeba05542 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx @@ -2,111 +2,43 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, } from 'react' import { createPortal } from 'react-dom' -import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' import { - IconAlarmClockOutlineRegular, - IconChevronDownOutlineRegular, - StateDot, + IconClockOutlineRegular, + IconTrashOutlineRegular, useAnchoredPosition, useDismissOnOutsidePointer, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +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 { CatalogInjected } from './catalog-source.ts' import { NS } from './locales.ts' +import { + formatScheduleFrequency, nextRunParts, orderScheduleRecords, taskName, +} from './schedule-format.ts' import css from './ScheduleCatalogAction.module.css' /** Full props for the Session-header Schedule catalog action. */ export type ScheduleCatalogActionProps = - PropsRuntime<'conversation.session.header.actions'> & PropsLocale + PropsRuntime<'conversation.session.header.utilities'> & PropsLocale & InjectFace void + }> -type TimeUnit = 'day' | 'hour' | 'minute' | 'second' - -const EMPTY_RECORDS: readonly ScheduleRecord[] = [] const SECOND_MS = 1_000 -const SECOND_UNIT = { unit: 'second', seconds: 1 } as const const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 } -const UNIT_SECONDS: readonly { unit: TimeUnit; seconds: number }[] = [ - { unit: 'day', seconds: 86_400 }, - { unit: 'hour', seconds: 3_600 }, - { unit: 'minute', seconds: 60 }, - SECOND_UNIT, -] -/** Localized unit word for one integral magnitude. */ -function unitLabel(unit: TimeUnit, value: number, t: TranslateNS): string { - const keys = { - day: ['unit.day.one', 'unit.day.other'], - hour: ['unit.hour.one', 'unit.hour.other'], - minute: ['unit.minute.one', 'unit.minute.other'], - second: ['unit.second.one', 'unit.second.other'], - } as const - const pair = keys[unit] - return t(value === 1 ? pair[0] : pair[1], { count: value }) -} - -/** Pick the largest exact whole unit without rounding the durable interval. */ -export function formatScheduleFrequency( - record: ScheduleRecord, - t: TranslateNS, -): string { - if (record.kind !== 'every') return t('frequency.once') - let selected: { unit: TimeUnit; seconds: number } = SECOND_UNIT - for (const candidate of UNIT_SECONDS) { - if (record.everySeconds % candidate.seconds !== 0) continue - selected = candidate - break - } - const value = record.everySeconds / selected.seconds - return t('frequency.every', { value, unit: unitLabel(selected.unit, value, t) }) -} - -/** Format the durable UTC target in the browser's current locale and time zone. */ -export function formatScheduleLocalTime(scheduledAt: string, locale?: string): string { - return new Intl.DateTimeFormat(locale, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(Date.parse(scheduledAt)) -} - -/** Human relative target using the largest natural clock unit. */ -export function formatScheduleRelative( - scheduledAt: string, - now: number, - t: TranslateNS, -): string { - const difference = Date.parse(scheduledAt) - now - if (difference === 0) return t('relative.now') - const absoluteSeconds = Math.abs(difference) / SECOND_MS - const selected = UNIT_SECONDS.find(candidate => absoluteSeconds >= candidate.seconds) - ?? SECOND_UNIT - const value = Math.max(1, difference > 0 - ? Math.ceil(absoluteSeconds / selected.seconds) - : Math.floor(absoluteSeconds / selected.seconds)) - const unit = unitLabel(selected.unit, value, t) - return t(difference > 0 ? 'relative.future' : 'relative.overdue', { value, unit }) -} - -/** Overdue records first, then ascending target time; exact ties stay stable. */ -export function orderScheduleRecords( - records: readonly ScheduleRecord[], - now: number, -): ScheduleRecord[] { - return records.map((record, index) => ({ record, index })).sort((left, right) => { - const leftTime = Date.parse(left.record.scheduledAt) - const rightTime = Date.parse(right.record.scheduledAt) - const leftOverdue = leftTime <= now - const rightOverdue = rightTime <= now - if (leftOverdue !== rightOverdue) return Number(rightOverdue) - Number(leftOverdue) - return leftTime - rightTime || left.index - right.index - }).map(({ record }) => record) -} - -/** Read-only current-Session active reminder catalog. */ -export function ScheduleCatalogAction({ useSession, useProjection, t }: ScheduleCatalogActionProps) { +/** Current-Session reminder catalog with durable deletion. */ +export function ScheduleCatalogAction({ + useSession, useCatalog, onDelete, onRetry, openTaskDetail, t, +}: ScheduleCatalogActionProps) { const openState = useSession(snapshot => snapshot.openState) - const projected = useProjection('schedule') - const records = projected ?? EMPTY_RECORDS - const visible = openState === 'open' && records.length > 0 + const catalogState = useCatalog(value => value) + const { records, status, deleting } = catalogState + const visible = openState === 'open' const [open, setOpen] = useState(false) const [now, setNow] = useState(() => Date.now()) const rootRef = useRef(null) @@ -138,14 +70,35 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule const rows = useMemo(() => orderScheduleRecords(records, now), [records, now]) if (!visible) return null + // Show the entry only once it has something to open, or a failed read to + // retry. An empty first read must not mount a chip that the authoritative + // answer then removes, which reads as a flash in the header. + if (records.length === 0 && status !== 'error') return null - const countKey = records.length === 1 ? 'trigger.one' : 'trigger.other' - const countLabel = t(countKey, { count: records.length }) + // The entry is icon-only, so its accessible name is the only place the + // reminder count reaches assistive technology. A refresh republishes + // `loading` while retaining the rows it already lists, so the count follows + // the known records rather than the read state: the label may go generic only + // before the first successful read, when there is no count to name. + const known = records.length + const triggerLabel = known === 0 + ? t('trigger.label') + : t(known === 1 ? 'trigger.one' : 'trigger.other', { count: known }) + // One task has no list to choose from, so its entry opens the detail directly + // once the read settles. A refresh republishes `loading` while retaining the + // rows, so the entry stays expandable for that read: the popover shows the + // loading row above the records it already lists. + const soleTask = status === 'ready' && records.length === 1 ? records[0] : undefined + const expandable = soleTask === undefined || open const toggleCatalog = (): void => { setNow(Date.now()) setOpen(current => !current) } - const onKeyDown = (event: KeyboardEvent): void => { + const openTask = (id: ScheduleId): void => { + setOpen(false) + openTaskDetail(id) + } + const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || !open) return event.preventDefault() setOpen(false) @@ -156,13 +109,19 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule ref={triggerRef} type="button" className={css.trigger} - aria-expanded={open} - aria-label={countLabel} - onClick={toggleCatalog} + data-schedule-reminder-entry="" + aria-expanded={expandable ? open : undefined} + aria-label={triggerLabel} + onClick={() => { + if (expandable) { + toggleCatalog() + return + } + setNow(Date.now()) + openTask(soleTask.id) + }} > - - {countLabel} - + ) const catalog = open @@ -172,28 +131,59 @@ export function ScheduleCatalogAction({ useSession, useProjection, t }: Schedule className={css.menu} style={catalogPosition ?? MEASURE_STYLE} aria-label={t('list.aria')} + onKeyDown={onKeyDown} > + {status === 'loading' &&
  • {t('list.loading')}
  • } + {status === 'error' &&
  • + {t('list.error')} + +
  • } {rows.map((record) => { const overdue = Date.parse(record.scheduledAt) <= now + // One pair per row: both halves come from this one call. + const nextRun = nextRunParts(record.scheduledAt, t('time.locale'), now, t) return (
  • - - - {t(overdue ? 'status.overdue' : 'status.scheduled')} - - {record.prompt} - - {formatScheduleFrequency(record, t)} - - {formatScheduleLocalTime(record.scheduledAt, document.documentElement.lang)} - - - {formatScheduleRelative(record.scheduledAt, now, t)} + + + + {formatScheduleFrequency(record, t)} + + {/* The next target states its own line instead of trailing the + frequency: one long weekday rule wraps, and the rows then keep + their names and actions on the same lines. */} + + {`${t('list.nextRun')} `} + {/* The list rows and the detail both state the local stamp + 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. */} + + {nextRun.relative} +
  • ) })} diff --git a/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css b/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css new file mode 100644 index 0000000000..393eb1750f --- /dev/null +++ b/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css @@ -0,0 +1,139 @@ +/* schedule_create transcript card: clock, task title, frequency, open action. + The box is the delivery card's (ui-deliverables Deliverables.module.css + `.file`): the same fill/hover pair, 60px row, 8/10 padding, and 18px corner, + with one inset overlay button making the whole row open the task. */ + +.card { + --card-fill: var(--dsw-static-neutral-50); + --card-hover: var(--dsw-static-neutral-100); + position: relative; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + padding: 8px 10px; + overflow: hidden; + border: 0.5px solid var(--dsw-alias-border-l1); + border-radius: 18px; + background: var(--card-fill); + color: var(--dsw-alias-label-primary); + transition: background-color 120ms ease; +} + +:global(body[data-ds-dark-theme]) .card { + --card-fill: var(--dsw-static-neutral-850); + --card-hover: var(--dsw-static-neutral-800); +} + +/* Only a card whose whole row opens the task answers hover. */ +.card:has(.cardOpen):hover { + background: var(--card-hover); +} + +.cardOpen { + position: absolute; + z-index: 1; + inset: 0; + width: 100%; + padding: 0; + border: 0; + border-radius: inherit; + background: transparent; + cursor: pointer; +} + +.cardOpen:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--dsw-alias-brand-primary); +} + +.row { + position: relative; + z-index: 2; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + min-height: 44px; + pointer-events: none; +} + +/* The clock glyph sits in the delivery card's icon frame (`.fileIcon`): a + 40px bordered 10px-corner square on the card's own fill. */ +.leading { + display: grid; + flex: none; + place-items: center; + box-sizing: border-box; + width: 40px; + height: 40px; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: 10px; + background: var(--card-fill); + color: var(--dsw-alias-label-secondary); +} + +.leading svg { + flex: none; +} + +/* Title over the frequency line, both truncated before the open control, on + the delivery card's own steps (`.fileName` 13/20 at 500 over the 10/16 + tertiary `.description`). */ +.body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.title { + overflow: hidden; + font-size: 13px; + line-height: 20px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.frequency { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 10px; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A task the catalog no longer states as the call wrote it: the card says what + happened instead of repeating the rule it was created with. */ +.status { + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The open control keeps the primitive's outlined capsule geometry and stays + its own target over the row-wide overlay. */ +.openButton { + flex: none; + pointer-events: auto; +} + +/* A result the card cannot read as a task keeps its raw model-facing text. */ +.fallback { + position: relative; + z-index: 2; + margin: 0; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/packages/client/ui-schedule/src/client/ScheduleCreateCard.tsx b/packages/client/ui-schedule/src/client/ScheduleCreateCard.tsx new file mode 100644 index 0000000000..421950c6ed --- /dev/null +++ b/packages/client/ui-schedule/src/client/ScheduleCreateCard.tsx @@ -0,0 +1,86 @@ +import { Button, IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client' +import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/client' +import { formatScheduleFrequency, taskName } from './schedule-format.ts' +import { scheduleCreateCardModel } from './schedule-create-card.ts' +import css from './ScheduleCreateCard.module.css' + +/** Task navigation the card receives from its Turn-tail registration. */ +export interface ScheduleCreateCardInjected { + /** + * Show one created task's detail in the right Sidebar. + * @param id - Task created by this call. + */ + readonly openTaskDetail: (id: ScheduleId) => void +} + +/** Props of the `schedule_create` transcript card. */ +export type ScheduleCreateCardProps = ToolCallViewProps + & PropsLocale<'schedule.manager'> + & InjectFace + & { readonly currentTask?: ScheduleRecord | null } + +/** + * Fields one card reads. + * + * The keyed Tool seat also carries the owner's file opener, image loader, and + * workspace facts; this card reads none of them, so the Turn tail that renders + * it supplies these fields alone and a template that starts reading another + * owner field fails to compile here. + */ +export type ScheduleCreateCardInput = Pick + +/** + * Render one `schedule_create` call as the created task's card. + * + * A settled result yields the task's title, its localized frequency, and the + * button that opens that task's right-Sidebar detail. A running call, a failed + * creation, or a replayed result with no complete task renders its title and + * raw result text with no open action, so the card never presents an identity + * it does not have. + * @param props - the settled card payload, injected task navigation, and copy. + * @returns the created task's transcript card. + */ +export function ScheduleCreateCard({ block, toolName, openTaskDetail, t, currentTask }: ScheduleCreateCardInput) { + const model = scheduleCreateCardModel(block, toolName) + const task = currentTask ?? model.task + const deleted = currentTask === null + const title = currentTask === undefined || currentTask === null ? model.title : taskName(currentTask) + const openable = task !== undefined && !deleted + return ( +
    + {openable + ? ( + + ) + : null} +
    + {task === undefined && model.output !== null ?
    {model.output}
    : null} +
    + ) +} diff --git a/packages/client/ui-schedule/src/client/ScheduleTaskTab.tsx b/packages/client/ui-schedule/src/client/ScheduleTaskTab.tsx new file mode 100644 index 0000000000..9b4936e2a4 --- /dev/null +++ b/packages/client/ui-schedule/src/client/ScheduleTaskTab.tsx @@ -0,0 +1,137 @@ +/** + * The task tab's body: one retained task's detail in the right Sidebar. + * + * 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. + * + * The parameters name the task's original Session, and every mutation carries + * that binding, so reading or deleting from here never activates a Session. + */ +import { useEffect, useState } from 'react' +import type { ReactNode } from 'react' +import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ScheduleCatalogEntry } from '@deepseek-ai/dsh-schedule/client' +import type { CatalogSnapshot } from './catalog-source.ts' +import { CatalogFeedback } from './CatalogFeedback.tsx' +import { TaskDetail, useTaskDetail, type TaskDetailInjected } from './TaskDetail.tsx' +import type { TaskTabBindings } from './task-tab-bindings.ts' +import { useTaskTabTarget } from './task-tab-target.ts' +import css from './TaskManagerPage.module.css' + +/** The Host task catalog the body, its chip, and the Tasks page all read. */ +export interface ScheduleTaskCatalogInjected { + /** Shared Host task catalog with its query and deletion state. */ + readonly hooks: { readonly catalog: HostObservable> } +} + +/** The provider-owned recovery a restored tab page resolves its task through. */ +export interface ScheduleTaskBindingInjected { + /** Durable binding from one task tab page to the task it last showed. */ + readonly taskBindings: TaskTabBindings +} + +/** Everything the task tab's body needs injected. */ +export interface ScheduleTaskTabInjected extends TaskDetailInjected, ScheduleTaskCatalogInjected, ScheduleTaskBindingInjected {} + +/** Props of the task tab's body: the tab it draws, localized copy, and its catalog face. */ +export type ScheduleTaskTabProps = PropsRuntime<'sidebar.right.pane.tab'> + & PropsLocale<'schedule.manager'> + & InjectFace + +/** + * Render the single task named by this tab's navigation parameters or by the + * binding those parameters last wrote. + * + * When neither names a task, the body reports the catalog feedback until a read + * requested after the baseline for the current navigation succeeds without the + * task; the missing-task state then covers both an unbound restored tab and a + * navigated tab whose task is gone. + * @param props - tab information, the Host task catalog, localized copy, and action callbacks. + * @returns the task's detail, or its centered loading, query-failure, or missing-task state. + */ +export function ScheduleTaskTab(props: ScheduleTaskTabProps): ReactNode { + const { + sessionId, useTabInfo, useCatalog, onRetry, taskBindings, t, + } = props + const { tab } = useTabInfo() + const { navigated, navigation, recovered, params } = useTaskTabTarget(sessionId, tab, taskBindings) + const catalog = useCatalog(snapshot => snapshot) + const detail = useTaskDetail(props, catalog, params?.id) + const { task, record: catalogRecord } = detail + // A restored tab can appear with a task whose creation the last successful read + // has not seen yet, so the retained records state that this tab's task is gone + // only from a read requested after the baseline for its current navigation - the + // rule the created-task card applies to a missing record. The baseline is the + // request ordinal this tab observed when it started showing that navigation: the + // Sidebar navigates a tab again without remounting its component, so the + // navigation's revision moves the baseline, while a restored tab whose binding is + // forgotten keeps the baseline it appeared with. A row the records already hold is + // shown from whatever read published it. + const navigationRevision = navigated ? tab.navigation.revision : undefined + const [baseline, setBaseline] = useState(() => ( + { revision: navigationRevision, request: catalog.readRequest } + )) + if (baseline.revision !== navigationRevision) { + setBaseline({ revision: navigationRevision, request: catalog.readRequest }) + } + const answered = catalog.settled && catalog.readSettled > baseline.request + useEffect(() => { + // The retained records leave this tab without a task and no read requested + // after that baseline has succeeded: ask for one, naming the baseline ordinal. + // A refresh shares only a read requested after that ordinal, so the read that + // answers for this tab is never one sent before the baseline. + if (task === undefined && !answered) void onRetry(baseline.request) + }, [onRetry, baseline.request, task, answered]) + + useEffect(() => { + if (navigation === undefined) return + taskBindings.write(sessionId, { id: tab.id, kind: tab.kind, contentId: tab.contentId }, navigation) + }, [taskBindings, sessionId, tab.id, tab.kind, tab.contentId, navigation?.sessionId, navigation?.id]) + + useEffect(() => { + // A binding this tab cannot resolve against a read that succeeded after the tab + // appeared described a task that no longer exists; forgetting it leaves the + // restored tab in the same feedback state as one that never had a binding. A + // refresh republishes `loading` over the retained records, so that read is the + // one that answers, not the read state. + if (recovered === undefined || navigated || !answered || catalogRecord !== undefined) return + taskBindings.forget(sessionId, { id: tab.id, kind: tab.kind, contentId: tab.contentId }) + }, [taskBindings, sessionId, tab.id, tab.kind, tab.contentId, recovered, navigated, answered, catalogRecord]) + + if (task === undefined) { + // A tab this window cannot name a task for has nothing left to load once a + // read succeeds, so it states the task's absence rather than reporting a load + // that never ends; an unbound restored tab and a navigated tab whose task is + // gone read the same to the user, so both center the one missing-task state. + // A ready catalog from a read this tab may not use is not that answer: the + // tab is waiting for its own read, so it reports the load instead. + const pending = catalog.status === 'ready' && !answered + return
    + {answered + ?
    + +

    {t('detail.missing')}

    +
    + : } +
    + } + + return
    + { tab.actions.close() }} + withinSession={sessionId} + /> +
    +} diff --git a/packages/client/ui-schedule/src/client/ScheduleTaskTabTitle.tsx b/packages/client/ui-schedule/src/client/ScheduleTaskTabTitle.tsx new file mode 100644 index 0000000000..6ed1823ffc --- /dev/null +++ b/packages/client/ui-schedule/src/client/ScheduleTaskTabTitle.tsx @@ -0,0 +1,39 @@ +/** + * The task tab's chip: the shown task's stored title, or the detail label while + * the shared catalog has no row for it yet. Registered under + * `sidebar.right.pane.tab.title`; without it the chip would show the constant + * text the definition captured at open time, which names no task. + * + * A restored record carries no navigation parameters, so the chip resolves the + * same provider-owned binding its body does and keeps naming the shown task. + */ +import type { ReactNode } from 'react' +import { IconClockOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import { taskName } from './schedule-format.ts' +import type { ScheduleTaskBindingInjected, ScheduleTaskCatalogInjected } from './ScheduleTaskTab.tsx' +import { useTaskTabTarget } from './task-tab-target.ts' +import css from './TaskManagerPage.module.css' + +/** Props of the task tab's chip: the tab it names and the catalog row it reads. */ +export type ScheduleTaskTabTitleProps = PropsRuntime<'sidebar.right.pane.tab.title'> + & PropsLocale<'schedule.manager'> + & InjectFace + +/** + * Render the clock glyph and the named task's stored title as the chip text. + * @param props - tab information, the Host task catalog, the tab bindings, and localized copy. + * @returns the clock glyph followed by the task's stored title, or the detail label without a catalog row. + */ +export function ScheduleTaskTabTitle({ sessionId, useTabInfo, useCatalog, taskBindings, t }: ScheduleTaskTabTitleProps): ReactNode { + const { tab } = useTabInfo() + const { params } = useTaskTabTarget(sessionId, tab, taskBindings) + const records = useCatalog(snapshot => snapshot.records) + const record = params === undefined ? undefined : records.find(item => item.id === params.id) + return ( + <> + + {record === undefined ? t('detail.label') : taskName(record)} + + ) +} diff --git a/packages/client/ui-schedule/src/client/ScheduleTurnCard.module.css b/packages/client/ui-schedule/src/client/ScheduleTurnCard.module.css new file mode 100644 index 0000000000..000398bc1e --- /dev/null +++ b/packages/client/ui-schedule/src/client/ScheduleTurnCard.module.css @@ -0,0 +1,9 @@ +/* The Turn tail's created-task stack: adjacent cards sit 10px apart, and the + stack keeps the 4px vertical offset the cards' own margins used to state + against the closing prose around them. */ +.list { + display: flex; + flex-direction: column; + gap: 10px; + margin: 4px 0; +} diff --git a/packages/client/ui-schedule/src/client/ScheduleTurnCard.tsx b/packages/client/ui-schedule/src/client/ScheduleTurnCard.tsx new file mode 100644 index 0000000000..bc10de4126 --- /dev/null +++ b/packages/client/ui-schedule/src/client/ScheduleTurnCard.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef } from 'react' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { ScheduleCatalogEntry, ScheduleId } from '@deepseek-ai/dsh-schedule/client' +import type { CatalogInjected } from './catalog-source.ts' +import { ScheduleCreateCard, type ScheduleCreateCardInput, type ScheduleCreateCardProps } from './ScheduleCreateCard.tsx' +import { scheduleCreateCardModel } from './schedule-create-card.ts' +import { SCHEDULE_CREATE_TOOL, selectScheduleTasks } from './schedule-turn.ts' +import css from './ScheduleTurnCard.module.css' + +/** Task navigation the turn row receives from its list registration. */ +export interface ScheduleTurnCardInjected extends Pick, 'hooks' | 'onRetry'> { + /** + * Show one created task's detail in the right Sidebar. + * @param id - Task created by this Turn. + */ + readonly openTaskDetail: (id: ScheduleId) => void +} + +/** Props of the created-task row of one Turn tail. */ +export type ScheduleTurnCardProps = PropsRuntime<'conversation.chat.turnTail'> + & InjectFace + & PropsLocale<'schedule.manager'> + +/** + * Props of one card rendered from the Turn tail. + * + * The keyed Tool seat also carries the owner's file opener, image loader, and + * workspace facts; this card reads none of them, and the Turn tail supplies + * only the fields it does. + * @param block - settled result node recorded against the Turn. + * @param openTaskDetail - right-Sidebar navigation for the created task. + * @param t - the active locale's copy. + * @returns the composed props of one `ScheduleCreateCard`. + */ +function cardProps( + block: ToolResultNode, + openTaskDetail: ScheduleTurnCardInjected['openTaskDetail'], + t: ScheduleCreateCardProps['t'], + currentTask: ScheduleCatalogEntry | null | undefined, +): ScheduleCreateCardInput { + const supplied: ScheduleCreateCardInput = { + callId: block.callId, + toolName: SCHEDULE_CREATE_TOOL, + block, + openTaskDetail, + t, + ...(currentTask === undefined ? {} : { currentTask }), + } + return supplied +} + +interface CurrentScheduleCardsProps { + readonly created: readonly ToolResultNode[] + readonly openTaskDetail: ScheduleTurnCardInjected['openTaskDetail'] + readonly t: ScheduleCreateCardProps['t'] + readonly useCatalog: ScheduleTurnCardProps['useCatalog'] + readonly onRetry: ScheduleTurnCardInjected['onRetry'] +} + +/** Created-task cards reconciled against the authoritative Host catalog. */ +function CurrentScheduleCards({ created, openTaskDetail, t, useCatalog, onRetry }: CurrentScheduleCardsProps) { + const catalog = useCatalog(snapshot => snapshot) + // The cards carry the records their creation calls wrote, so only a read issued + // after they appeared can state that one is gone: the retained records of an + // earlier read may simply predate the creation. This tail requests that read + // itself - the source batches the requests of one commit into a single read, so a + // conversation with several created-task cards still sends one - which makes the + // order one of cause, never a comparison of the browser's clock with the Host's. + // The ordinal is the request the source issued, so a read sent before this tail + // rendered cannot pass the gate even if it resolves afterwards. A failed read + // leaves the figure, so the cards keep the created rule until a read succeeds. + const requestAtMount = useRef(catalog.readRequest).current + // The mount ordinal goes with the request: the source shares an in-flight read + // only when that read was requested after this tail appeared, so a read sent + // before it cannot answer for it. + useEffect(() => { void onRetry(requestAtMount) }, [onRetry, requestAtMount]) + const records = catalog.settled && catalog.readSettled > requestAtMount ? catalog.records : undefined + return created.map((block) => { + const task = scheduleCreateCardModel(block, SCHEDULE_CREATE_TOOL).task + const current = task === undefined || records === undefined + ? undefined + : records.find(record => record.id === task.id) ?? null + return ( + + ) + }) +} + +/** + * Render the tasks one Turn created as standalone cards beneath its closing + * prose. + * + * The Turn tail is a list seat independent of the Turn's process disclosure, + * so the cards stay visible while the Tool group is collapsed. Every list entry + * renders for every Turn, so this one narrows its own Turn here. + * @param props - closing Turn owner currency, turn-tail navigation, and copy. + * @returns one created-task card per settled `schedule_create` result, or null when the Turn created none. + */ +export function ScheduleTurnCard(props: ScheduleTurnCardProps) { + const matched = selectScheduleTasks(props) + if (matched === null) return null + return ( +
    + +
    + ) +} diff --git a/packages/client/ui-schedule/src/client/SessionScheduleHover.tsx b/packages/client/ui-schedule/src/client/SessionScheduleHover.tsx new file mode 100644 index 0000000000..b55f929860 --- /dev/null +++ b/packages/client/ui-schedule/src/client/SessionScheduleHover.tsx @@ -0,0 +1,77 @@ +/** + * Scheduled-task section of the Sidebar Session-row hover card. + * + * The row's own card already carries the Session title, its relative time, and + * the trailing status line; this seat contributes the Session's active tasks + * between them. It is mounted only while the card is open, and it projects the + * one shared Host task catalog onto the hovered Session. + */ + +import { useState } from 'react' +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 { + useSessionScheduleFacts, type SessionScheduleCatalogObservable, +} from './session-schedule-state.ts' +import { NS } from './locales.ts' +import css from './SessionScheduleMark.module.css' + +/** Most task rows one hover card shows before it reports the omitted remainder. */ +export const SESSION_HOVER_TASK_LIMIT = 2 + +/** Injected share of the Session-row hover-card task section: the shared Host task catalog. */ +export interface SessionScheduleHoverInjected { + /** One catalog shared by every row; the renderer binds it to the `useCatalog` selector hook. */ + readonly hooks: { readonly catalog: SessionScheduleCatalogObservable } +} + +/** Full props of the Session-row hover-card task section. */ +export type SessionScheduleHoverProps = + PropsRuntime<'sidebar.session.row.hover'> & PropsLocale & InjectFace + +/** + * Render up to {@link SESSION_HOVER_TASK_LIMIT} overdue-first task rows. + * + * The reference clock is sampled once per mount: the card is a long-hover + * preview, so its "next run" text must not drift while the pointer rests. + * @param props.sessionId - Session this row shows. + * @param props.useCatalog - selector hook over the shared Host task catalog. + * @param props.t - Schedule catalog locale seat. + * @returns the task rows plus an omission line, or nothing without an active task. + */ +export function SessionScheduleHover({ + sessionId, useCatalog, t, +}: SessionScheduleHoverProps) { + const facts = useSessionScheduleFacts(useCatalog, sessionId) + const [now] = useState(() => Date.now()) + if (!facts.hasActive) return null + const ordered = orderScheduleRecords(facts.records, now) + const shown = ordered.slice(0, SESSION_HOVER_TASK_LIMIT) + const omitted = ordered.length - shown.length + return ( +
    + {shown.map((record) => { + const nextRun = nextRunParts(record.scheduledAt, t('time.locale'), now, t) + return ( +
    + + + {taskName(record)} + {/* The same next-run shape the list rows, the detail, and the chat + card show: the device-zone stamp first, then the distance. */} + + {`${formatScheduleFrequency(record, t)} · `} + + {' '} + {nextRun.relative} + + +
    + ) + })} + {omitted > 0 &&
    {t('hover.more', { count: omitted })}
    } +
    + ) +} diff --git a/packages/client/ui-schedule/src/client/SessionScheduleMark.module.css b/packages/client/ui-schedule/src/client/SessionScheduleMark.module.css new file mode 100644 index 0000000000..50af682ebc --- /dev/null +++ b/packages/client/ui-schedule/src/client/SessionScheduleMark.module.css @@ -0,0 +1,80 @@ +/* Session-row clock mark: the row's leading decoration seat, sized like the + * status dot's own 16px cell so the title keeps its alignment either way. */ +.mark { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 20px; + color: var(--dsw-alias-label-tertiary); +} + +/* Task rows inside the Session hover card (surface is #2C2C2E in both themes, + * so these are the card's literal label colors, as in Rows.module.css). */ +.tasks { + display: flex; + flex-direction: column; + gap: 8px; +} + +.task { + display: flex; + align-items: flex-start; + gap: 8px; + min-width: 0; +} + +.taskIcon { + flex: none; + display: inline-flex; + align-items: center; + height: 16px; + color: #CFD3D6; +} + +.taskBody { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.taskName { + font-size: 12px; + line-height: 16px; + color: #FFFFFF; + overflow-wrap: break-word; +} + +.taskTiming { + font-size: 12px; + line-height: 16px; + color: #CFD3D6; +} + +/* The distance beside the absolute next run. The card's surface is the fixed + #2C2C2E in both themes, so its label steps are literal light grays rather than + the theme tokens (which flip with the surface); this one is a step below + `.taskTiming`. The card is narrow, so a long stamp still wraps. */ +.taskRelative { + color: #ADB2B8; + overflow-wrap: anywhere; +} + +.omitted { + font-size: 12px; + line-height: 16px; + color: #ADB2B8; +} + +/* Accessible name of the icon-only mark (same pattern as the row's status + * dot labels). */ +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/packages/client/ui-schedule/src/client/SessionScheduleMark.tsx b/packages/client/ui-schedule/src/client/SessionScheduleMark.tsx new file mode 100644 index 0000000000..b81400e701 --- /dev/null +++ b/packages/client/ui-schedule/src/client/SessionScheduleMark.tsx @@ -0,0 +1,59 @@ +/** + * Sidebar Session-row clock mark: one Session's active scheduled-task + * indicator, seated in the row's leading 16px cell before the title. + * + * The row offers that cell to this seat only while its own primary state is + * idle, so an approval request, a new message, or live activity keeps the + * row's state dot in the same cell and never mounts the mark. The mark + * projects the one shared Host task catalog onto this Session; it activates, + * retains, and unarchives nothing, and it never reads a Session log. + */ + +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 { + useSessionScheduleFacts, type SessionScheduleCatalogObservable, +} from './session-schedule-state.ts' +import { NS } from './locales.ts' +import css from './SessionScheduleMark.module.css' + +/** Injected share of the Session-row clock mark: the shared Host task catalog. */ +export interface SessionScheduleMarkInjected { + /** One catalog shared by every row; the renderer binds it to the `useCatalog` selector hook. */ + readonly hooks: { readonly catalog: SessionScheduleCatalogObservable } +} + +/** Full props of the Session-row clock mark. */ +export type SessionScheduleMarkProps = + PropsRuntime<'sidebar.session.row.leading'> & PropsLocale & InjectFace + +/** + * Render the clock mark while this Session's active tasks are non-empty. + * @param props.sessionId - Session this row shows. + * @param props.useCatalog - selector hook over the shared Host task catalog. + * @param props.t - Schedule catalog locale seat. + * @returns the mark, or nothing while the read is unresolved, failed, ended, or empty. + */ +export function SessionScheduleMark({ + sessionId, useCatalog, t, +}: SessionScheduleMarkProps) { + const facts = useSessionScheduleFacts(useCatalog, sessionId) + if (!facts.hasActive) return null + return ( + { event.stopPropagation() }} + > + + {/* The glyph is aria-hidden, so the count reaches assistive technology + only through this label, as the row's status dots do. */} + {t('mark.aria', { count: facts.records.length })} + + ) +} diff --git a/packages/client/ui-schedule/src/client/TaskDetail.tsx b/packages/client/ui-schedule/src/client/TaskDetail.tsx new file mode 100644 index 0000000000..7b07669322 --- /dev/null +++ b/packages/client/ui-schedule/src/client/TaskDetail.tsx @@ -0,0 +1,2177 @@ +/** One retained task's rule, saved deliveries, run-time edits, deletion, and original-Session link. */ +import { assertNever } from '@deepseek-ai/dsh-util-values' +import { useEffect, useId, useMemo, useRef, useState } from 'react' +import type { CSSProperties, KeyboardEvent, ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconChevronUpOutlineRegular, + IconClockOutlineRegular, IconCloseOutlineRegular, + IconEllipsisOutlineRegular, IconTrashOutlineRegular, Modal, Pill, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client' +import type { + ScheduleCatalogEntry, ScheduleId, ScheduleRecord, ScheduleTimingChange, ScheduleUpdateContent, + ScheduleUpdateRequest, ScheduleUpdateResult, +} from '@deepseek-ai/dsh-schedule/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { CatalogFeedback } from './CatalogFeedback.tsx' +import { IconCalendarOutlineRegular } from './CalendarIcon.tsx' +import { ClockPicker } from './ClockPicker.tsx' +import { DatePicker } from './DatePicker.tsx' +import { DeliveryHistory, type DeliveryHistoryInjected } from './DeliveryHistory.tsx' +import type { CatalogDeleteOutcome, CatalogSnapshot } from './catalog-source.ts' +import { draftZone, secondPrecision, slashDate, timingDraft, timingError, timingSnapshot, zonedWallClock } from './task-timing.ts' +import type { TaskTimingInjected, TimingDraft } from './task-timing.ts' +import { useRelativeClock } from './relative-clock.ts' +import { formatScheduleFrequency, nextRunParts, recordTimeZone, zoneChoices, zoneLabel, zoneName } from './schedule-format.ts' +import type { FrequencyZone } from './schedule-format.ts' +import { cronPreview, cronShapeExpression, parseCronExpression, recognizeCronShape } from './task-cron.ts' +import type { CronBuilderState } from './task-cron.ts' +import { sessionLabel, sessionLinkState } from './session-link.ts' +import { loadRecentTimeZones, rememberTimeZone } from './recent-time-zones.ts' +import type { TaskManagerKey } from './task-manager-locales.ts' +import { TaskMenu } from './TaskMenu.tsx' +import type { TaskMenuEntry } from './TaskMenu.tsx' +import css from './TaskManagerPage.module.css' + +/** The two views of one task's detail: its rule, or its saved delivery records. */ +export type TaskDetailTab = 'rule' | 'records' + +/** Locale seat of the task manager namespace, as this component receives it. */ +type RuleTranslate = PropsLocale<'schedule.manager'>['t'] + +/** Recurrence choices the Run time card's Repeat row offers, in mock menu order. */ +const RULE_CHOICES = [ + 'weekly', 'weekdays', 'daily', 'every-hour', 'every-minute', 'every-second', 'once', 'cron', +] as const + +/** One recurrence choice a retained task can be switched to. */ +type RuleKind = 'daily' | 'weekdays' | 'weekly' | 'every' | 'once' | 'cron' + +/** One visible Repeat-menu option; interval units are separate user intents. */ +type RuleChoice = (typeof RULE_CHOICES)[number] + +/** One Repeat-menu option backed by the stored elapsed-interval rule. */ +type IntervalChoice = Extract + +/** Repeat menu label of each recurrence choice. */ +const RULE_KIND_LABELS: Record = { + daily: 'rule.daily', weekdays: 'rule.weekdays', weekly: 'rule.weekly', + every: 'rule.everyMinutes', once: 'rule.once', cron: 'rule.cron', +} + +/** Repeat-menu label of each visible choice. */ +const RULE_CHOICE_LABELS: Record = { + daily: 'rule.daily', weekdays: 'rule.weekdays', weekly: 'rule.weekly', + 'every-minute': 'rule.everyMinutes', 'every-hour': 'rule.everyHours', 'every-second': 'rule.everySeconds', + once: 'rule.once', cron: 'rule.cron', +} + +/** Unit word the elapsed-interval row shows beside its quantity input. */ +const INTERVAL_UNIT_LABELS: Record = { + hour: 'timing.unit.hour', minute: 'timing.unit.minute', second: 'timing.unit.second', +} + +/** Lower-bound hint of the elapsed-interval row, stated in the row's own unit. */ +const INTERVAL_HINT_KEYS: Record = { + hour: 'timing.intervalHint.hour', + minute: 'timing.intervalHint.minute', + second: 'timing.intervalHint.second', +} + +/** Below-floor save message, stated in the same unit as the row and its hint. */ +const INTERVAL_ERROR_KEYS: Record = { + hour: 'timing.invalidInterval.hour', + minute: 'timing.invalidInterval.minute', + second: 'timing.invalidInterval.second', +} + +/** Messages `draftError` can return; each clears as soon as edits fix the draft. */ +const LOCAL_FAILURES: ReadonlySet = new Set([ + 'rule.invalidTitle', 'rule.invalidPrompt', 'timing.invalidInterval', 'rule.cronInvalid', 'timing.invalid', +]) + +/** Elapsed interval a rule receives when it starts repeating without a stored interval. */ +const RULE_DEFAULT_EVERY_SECONDS = 3_600 + +/** Friendly units the elapsed-interval editor can express without changing storage semantics. */ +const INTERVAL_UNITS = ['second', 'minute', 'hour'] as const + +/** One unit offered beside the elapsed-interval quantity. */ +type IntervalUnit = (typeof INTERVAL_UNITS)[number] + +/** Seconds represented by each friendly interval unit. */ +const INTERVAL_UNIT_SECONDS: Record = { second: 1, minute: 60, hour: 3_600 } + +/** Shortest elapsed interval the Host accepts, stated in seconds. */ +const MIN_INTERVAL_SECONDS = 300 + +/** Exhaustive recurrence-choice labels of the stored rule records. */ +const RULE_KIND_BY_RECORD: Record = { + at: 'once', after: 'once', every: 'every', daily: 'daily', weekly: 'weekly', cron: 'cron', +} + +/** ISO weekdays of the weekly choice in display order, Monday through Sunday. */ +const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7] as const + +/** One ISO weekday of the weekly choice. */ +type Weekday = (typeof WEEKDAYS)[number] + +/** ISO weekday set the `weekdays` choice stores: Monday through Friday. */ +const WEEKDAY_RULE = [1, 2, 3, 4, 5] as const satisfies readonly Weekday[] + +/** + * Recurrence choices that state their rule with the clock and zone rows. + * + * The cron choice carries its clock inside its expression and its timing draft + * has no time row, so it neither carries nor receives that pair: an expression + * seeded from the occurrence's UTC clock with another zone would name a + * different instant, and carrying an expression's empty time would empty the + * clock row a switch to another choice shows. + */ +const CLOCK_KINDS: readonly RuleKind[] = ['daily', 'weekdays', 'weekly'] + +/** Localized name of each ISO weekday. */ +const WEEKDAY_LABELS: Record = { + 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', +} + +/** + * Whether a stored weekly record carries exactly the Monday-to-Friday set the + * `weekdays` choice stores, so the Repeat row shows that choice for it. + * @param weekdays - stored ISO weekday set. + * @returns whether the set is Monday through Friday. + */ +function isWeekdayRule(weekdays: readonly number[]): boolean { + return weekdays.length === WEEKDAY_RULE.length && WEEKDAY_RULE.every(day => weekdays.includes(day)) +} + +const sessionLinkMessages = { + loading: 'detail.sessionLoading', archived: 'detail.sessionArchived', unavailable: 'detail.sessionUnavailable', +} as const + +/** + * Deletion, name/instruction/timing updates, saved-history, and original-Session + * actions one task's detail calls. + */ +export interface TaskDetailInjected extends DeliveryHistoryInjected, TaskTimingInjected { + /** + * Delete one retained task through its original Session binding. + * @param id - Task shown in this detail. + * @returns The deletion's outcome after the Remote acknowledgement and + * authoritative refresh; the wiring reports it as the app-wide toast. + */ + readonly onDelete: (id: ScheduleId) => Promise + /** + * Reload the catalog after a query failure or an acknowledged timing update, + * and from a surface that needs a read newer than its own record. + * @param since - request ordinal the caller last observed, 0 to share any read in flight. + * @returns Resolution after publishing the read result; failures remain in catalog state. + */ + readonly onRetry: (since?: number) => Promise + /** + * Open an available, unarchived original conversation after rechecking current metadata. + * @param id - Session bound to the shown task. + */ + readonly onOpenSession: (id: SessionId) => void +} + +/** One task's detail, as the Tasks page and the task tab both compose it. */ +export type TaskDetailProps = TaskDetailInjected + & PropsLocale<'schedule.manager'> + & Pick, 'useSessions' | 'useWorkspaces'> + & { + /** Task shown; an authoritative catalog row refreshes on its own, without an edit draft. */ + readonly task: ScheduleCatalogEntry + /** Whether an authoritative catalog row backs the shown task; a draft alone disables deletion. */ + readonly authoritative: boolean + /** Element id shared by the detail panels and any control that points at them. */ + readonly id: string + /** Catalog query state; loading keeps every action visible and disabled. */ + readonly status: 'loading' | 'ready' | 'error' + /** Tasks with a deletion in flight. */ + readonly deleting: readonly ScheduleId[] + /** + * Report the shown task so its owner retains it while an update is pending + * or failed, or while a confirmed deletion awaits the refreshed catalog. + */ + readonly onEditState: (task: ScheduleCatalogEntry | null) => void + /** Active detail view; the owner resets it to Rules when it selects another task. */ + readonly tab: TaskDetailTab + /** Select the active detail view. */ + readonly onTabChange: (tab: TaskDetailTab) => void + /** Task whose deletion confirmation the owner asked for, or null when none is open. */ + readonly confirmId: ScheduleId | null + /** Ask for one task's deletion confirmation, or dismiss it with null. */ + readonly onConfirm: (id: ScheduleId | null) => void + /** + * Leave the shown task once the refreshed catalog confirms its deletion: + * the Tasks page clears its selection and the session task tab closes. + */ + readonly onDeleted: () => void + /** Close this detail; a view with no list to return to omits the control. */ + readonly onClose?: () => void + /** Session whose Sidebar hosts this detail; a task linked to it omits the redundant original-Session entry. */ + readonly withinSession?: SessionId + } + +/** One task detail's editing state, as its Tasks page or task tab owns it. */ +export interface TaskDetailController { + /** Catalog row the shown id resolves to, or undefined when the read has no such row. */ + readonly record: ScheduleCatalogEntry | undefined + /** Task to show: the catalog row, or the draft a pending or failed mutation retained. */ + readonly task: ScheduleCatalogEntry | undefined + /** Element id shared by the detail panels and any control that points at them. */ + readonly id: string + /** Task whose deletion confirmation is open, or null when none is. */ + readonly confirmId: ScheduleId | null + /** Ask for one task's deletion confirmation, or dismiss it with null. */ + readonly setConfirmId: (id: ScheduleId | null) => void + /** Select the active detail view. */ + readonly setTab: (tab: TaskDetailTab) => void + /** Props the shared detail element takes, without the task it shows. */ + readonly props: Omit +} + +/** + * Own the editing state one task detail shares across both of its owners. + * + * The shown task is the catalog row for `taskId`, or the draft a mutation + * pending against that row retained after the row left the catalog. The view, + * the confirmation, and the draft all reset when `taskId` changes, so one + * task's draft never carries into another. + * @param injected - detail actions, localized copy, and framework readers. + * @param catalog - the owner's authoritative task catalog snapshot. + * @param taskId - task the owner selected, or undefined when it selected none. + * @returns the shown task and catalog row, its element id, its confirmation, and the detail props. + */ +export function useTaskDetail( + injected: TaskDetailInjected & PropsLocale<'schedule.manager'> & Pick, 'useSessions' | 'useWorkspaces'>, + catalog: CatalogSnapshot, + taskId: ScheduleId | undefined, +): TaskDetailController { + const [draft, setDraft] = useState(null) + const [tab, setTab] = useState('rule') + const [confirmId, setConfirmId] = useState(null) + const id = useId() + useEffect(() => { + setDraft(null) + setTab('rule') + setConfirmId(null) + }, [taskId]) + const record = taskId === undefined ? undefined : catalog.records.find(item => item.id === taskId) + const task = record ?? (draft !== null && draft.id === taskId ? draft : undefined) + return { + record, task, id, confirmId, setConfirmId, setTab, + props: { + ...injected, + status: catalog.status, + deleting: catalog.deleting, + id, + onEditState: setDraft, + tab, + onTabChange: setTab, + confirmId, + onConfirm: setConfirmId, + }, + } +} + +/** + * Render one task's rule or saved deliveries with confirm-first deletion and its original Session. + * + * A deletion this detail confirmed settles with the refreshed catalog: once + * that refresh reports the row gone, the detail calls `onDeleted` and its owner + * leaves the task. The app-wide toast, not this detail, announces the outcome. + * @param props - task, catalog state, detail view, confirmation, localized copy, and action callbacks. + * @returns the detail region, its deletion confirmation dialog, and the linked Session entry. + */ +export function TaskDetail({ + task, authoritative, id, status, deleting, onDelete, onRetry, onUpdateTiming, loadHistory, onOpenSession, + onEditState, tab, onTabChange, confirmId, onConfirm, onDeleted, onClose, withinSession, useSessions, useWorkspaces, t, +}: TaskDetailProps): ReactNode { + const sessions = useSessions(snapshot => snapshot) + const workspaces = useWorkspaces(snapshot => snapshot) + const detailTabsRef = useRef(null) + const moreRef = useRef(null) + const nameRef = useRef(null) + const panelRef = useRef(null) + const footerRef = useRef(null) + const [menuOpen, setMenuOpen] = useState(false) + // The next-run line states how long remains, so the panel reads the same + // ticking clock the task list does. + const now = useRelativeClock() + const busy = deleting.includes(task.id) || status === 'loading' + const confirming = authoritative && confirmId === task.id + const sessionLink = sessionLinkState(task.sessionId, sessions, workspaces) + const linkedSession = sessionLabel(task.sessionId, sessions) + const sessionNotice = sessionLink === 'available' ? undefined : t(sessionLinkMessages[sessionLink]) + const taskIdentity = `${task.sessionId}\u0000${task.id}` + const [edit, setEdit] = useState(() => initialRuleEdit(task)) + // The elapsed-interval unit lives here, not in the Run time card, so the saved + // draft's below-floor message states the same unit the row shows. + const [intervalUnit, setIntervalUnit] = useState(() => preferredIntervalUnit(edit.shown.draft.seconds)) + // A unit the user picked in the Repeat menu outranks the seeded seconds, so + // the kind effect below must not derive one back from the whole-hour seed and + // overwrite the explicit choice. + const explicitUnit = useRef(false) + const previousKind = useRef(edit.shown.kind) + useEffect(() => { + if (previousKind.current === edit.shown.kind) return + previousKind.current = edit.shown.kind + if (edit.shown.kind !== 'every') { + // Leaving the interval drops the explicit choice with the row that held it. + explicitUnit.current = false + return + } + if (explicitUnit.current) return + setIntervalUnit(preferredIntervalUnit(edit.shown.draft.seconds)) + }, [edit.shown.kind, edit.shown.draft.seconds]) + const [pending, setPending] = useState(false) + const [failure, setFailure] = useState() + // A locally refused save re-reads the draft on every render, so its message + // leaves the hint slot as soon as edits remove the cause and the descriptive + // hint returns; Host failures stay until the next cancel or save. + const shownFailure = failure !== undefined && LOCAL_FAILURES.has(failure) + ? draftError(edit.shown) + : failure + const [deletionConfirmed, setDeletionConfirmed] = useState(false) + const deletionInFlight = useRef(null) + const [trackedIdentity, setTrackedIdentity] = useState(taskIdentity) + const identityRef = useRef(taskIdentity) + identityRef.current = taskIdentity + // Which save this detail last submitted. `mounted` and the task identity are + // not enough to retire a response: the same detail renders every task, and a + // save of task A can still be in flight when the reader leaves A, opens B, and + // comes back to A. A later submission, or a task switch, retires every earlier + // one, so only the newest response may touch the draft. + // + // A retired response reports neither success nor failure: its outcome reaches + // this detail only through the catalog refresh that the save's own callback + // triggers. That is deliberate — the reader is looking at another task, and a + // notice about a save they left belongs to the refresh, not to this draft. + const submissions = useRef(0) + const chosenWeekdays = useRef<{ identity: string; weekdays: readonly Weekday[] } | undefined>(undefined) + /** The day set this task's card remembers, or undefined when it has none. */ + const taskMemory = (): readonly Weekday[] | undefined => + chosenWeekdays.current?.identity === taskIdentity ? chosenWeekdays.current.weekdays : undefined + const mounted = useRef(true) + + useEffect(() => { + mounted.current = true + return () => { mounted.current = false } + }, []) + + useEffect(() => { + // Focus follows the shown task, but the panel itself takes it: landing in the + // name control would place a text caret the reader did not ask for. Focusing + // without scrolling keeps a restored panel from jumping as it re-renders. + panelRef.current?.focus({ preventScroll: true }) + }, [task.sessionId, task.id]) + + useEffect(() => { + // The owner retains this task while a save is in flight or failed and after + // a confirmed deletion, so a catalog refresh that drops the row can neither + // close a surfaced failure nor unmount the detail before `onDeleted` runs. + onEditState(pending || shownFailure !== undefined || deletionConfirmed ? task : null) + }, [pending, shownFailure, deletionConfirmed, task, onEditState]) + + useEffect(() => { + // The catalog reports each task's deletion while it is in flight. A + // confirmed deletion whose attempt ended with the task still in the + // authoritative ready list was not applied, so its rule comes back; a + // successful deletion reports loading first and then drops the row. + if (deleting.includes(task.id)) { + deletionInFlight.current = task.id + return + } + if (!deletionConfirmed || deletionInFlight.current !== task.id) return + deletionInFlight.current = null + if (status === 'ready' && authoritative) setDeletionConfirmed(false) + }, [deletionConfirmed, deleting, task.id, status, authoritative]) + + useEffect(() => { + if (confirming) footerRef.current?.querySelector('button')?.focus() + }, [confirming]) + + const closeConfirmation = (): void => { + onConfirm(null) + moreRef.current?.focus() + } + + const menuItems: TaskMenuEntry[] = [ + { + id: 'delete', + label: t(deleting.includes(task.id) ? 'delete.pending' : 'delete.action'), + icon: , + danger: true, + disabled: busy || !authoritative, + }, + ] + + const storedValues = ruleValues(task) + const propKey = shownValues(storedValues) + if (trackedIdentity !== taskIdentity) { + // A shown task's unsaved draft, failure, pending save, and deletion never carry over. + submissions.current += 1 + chosenWeekdays.current = undefined + explicitUnit.current = false + previousKind.current = storedValues.kind + setTrackedIdentity(taskIdentity) + setEdit(initialRuleEdit(task)) + setIntervalUnit(preferredIntervalUnit(storedValues.draft.seconds)) + setPending(false) + setFailure(undefined) + setDeletionConfirmed(false) + } else if (edit.propKey !== propKey) { + // A memory that only mirrors the record's own day set is a seed rather than a + // choice, so any refresh that finds the two equal retires it — a renamed + // record alone included, where the retirement settles the same state. Where + // the refresh changed the set, a kept seed would restore the pre-refresh set. + const memory = taskMemory() + if (memory !== undefined && sameWeekdays(memory, edit.stored.weekdays)) { + chosenWeekdays.current = undefined + } + // An ended task is read-only: its unsaved notice and Cancel action are gone, + // so a surviving draft would read as the stored rule. The refresh replaces + // the draft with the stored values instead. + setEdit(task.status === 'inactive' + ? { propKey, stored: storedValues, shown: storedValues } + // `mergeRuleDraft` decides by rule kind: a staged switch keeps its own time + // fields, a remote kind change adopts or keeps a whole rule, and matching + // kinds re-seed field by field, so a value the user changed survives while + // every other field takes the refreshed record. The refreshed record stays + // the complete expected rule of the next save. + : { propKey, stored: storedValues, shown: mergeRuleDraft(storedValues, edit.shown, edit.stored) }) + } + useEffect(() => { + // An ended task is read-only and offers no Cancel, so nothing can submit the + // draft any more: the stored values replace it rather than reading as saved. + if (task.status !== 'inactive') return + setEdit(current => (shownValues(current.shown) === shownValues(current.stored) + ? current + : { ...current, shown: current.stored })) + }, [task.status]) + const shown = edit.shown + const dirty = shownValues(shown) !== shownValues(edit.stored) + // The deletion is done once the refreshed catalog reports no row: the owner + // then leaves the task through `onDeleted`, and the guards below keep the + // one frame before that effect from showing a rule the deletion removed. + const deleted = deletionConfirmed && !authoritative + useEffect(() => { + if (deleted) onDeleted() + }, [deleted, onDeleted]) + // One pair for the next-run line: both halves come from this one call. + const nextRun = nextRunParts(task.scheduledAt, t('time.locale'), now, t) + + // The day set one task's card last had in hand, kept across a detour through a + // kind that shows no weekday row: switching Monday-to-Friday to Daily and then + // to Weekly would otherwise collapse the rule to the one day the carried clock + // falls on, though the user chose five. + // + // It is scoped to this task's identity, because the same `TaskDetail` renders + // whichever task is selected: a set chosen on one task must never reach + // another. Cancel drops it with the draft it belongs to. + const rememberWeekdays = (weekdays: readonly Weekday[]): void => { + chosenWeekdays.current = { identity: taskIdentity, weekdays } + } + /** + * Day set one rule states, when its kind carries one. + * @param kind - choice the rule is stated as. + * @param weekdays - that rule's day set. + * @returns the set for the kinds that carry one, otherwise undefined. + */ + const setOf = (kind: RuleKind, weekdays: readonly Weekday[]): readonly Weekday[] | undefined => + kind === 'weekdays' || kind === 'weekly' ? weekdays : undefined + const chooseKind = (kind: RuleKind, unit?: IntervalUnit): void => { + // Switching among the interval choices restates the unit without changing + // the kind, so the unit is taken before the same-kind return below. + if (unit !== undefined) { + explicitUnit.current = true + setIntervalUnit(unit) + } + if (kind === shown.kind) return + // The memory is written here, outside the state updater — as `toggleWeekday` + // also does — because it is a ref: a double-invoked updater must not decide + // what it holds. Leaving a rule that states a day set refreshes it with that + // set, which is the one the user has in hand; the stored rule only seeds a + // memory this task has not got yet, because overwriting an existing one would + // throw away an edited set and make the answer depend on how many kinds the + // detour passed through. + const leavingSet = setOf(shown.kind, shown.weekdays) + ?? (taskMemory() === undefined ? setOf(storedValues.kind, storedValues.weekdays) : undefined) + if (leavingSet !== undefined) rememberWeekdays(leavingSet) + setEdit((current) => { + // Choosing the stored kind again is not a change to stage: it restores + // the stored values instead of reseeding the rows from the current + // occurrence, which would stage the occurrence's UTC clock for a rule the + // record already states in its own zone. + const restore = kind === current.stored.kind + // Two clock-time choices name the same instant with a wall clock in one + // zone, so the switch carries the pair the card shows instead of restating + // that instant in UTC and rewriting the zone the rule states. That pair is + // the instant the committed occurrence already names, so the weekly choice + // seeds its ISO weekday set from the occurrence in that zone. + const carriesClock = !restore + && CLOCK_KINDS.includes(kind) + && CLOCK_KINDS.includes(current.shown.kind) + // The Monday-to-Friday choice and the weekly choice are the same stored + // weekly rule, so switching between them keeps the day set the user + // already has. Seeding it from one occurrence would collapse the rule to + // the single day that occurrence falls on. + // + // After a detour through Daily the shown kind says nothing, and the sets + // in order of authority are the one this task's card remembers, then the + // one the stored rule itself states (a stored Monday-to-Friday rule still + // means five days), and only then the occurrence's own day. + const storedSet = setOf(current.stored.kind, current.stored.weekdays) + const carried = kind === 'weekly' + ? (setOf(current.shown.kind, current.shown.weekdays) ?? taskMemory() ?? storedSet) + : undefined + return { + ...current, + // The weekdays choice stages the Monday-to-Friday set it submits; every + // other choice stages the set of the stored rule it seeds from, except + // the weekly choice of a clock-time switch, whose set names the local day + // the carried clock falls on, and the weekly choice of a stored + // Monday-to-Friday rule, which keeps that stored set. + shown: { + ...current.shown, + kind, + draft: restore + ? current.stored.draft + : carriesClock + ? { ...seedDraft(task, kind), time: current.shown.draft.time, timeZone: current.shown.draft.timeZone } + : unit === undefined + ? seedDraft(task, kind) + : { ...seedDraft(task, kind), seconds: String(seedIntervalSeconds()) }, + weekdays: kind === 'weekdays' + ? [...WEEKDAY_RULE] + : kind === 'weekly' && carried !== undefined + // Carried before restored: the set the draft carries is either + // the one the user just edited or the stored rule's own, so an + // edited set survives a detour back to the stored Weekly choice. + ? [...carried] + : restore + ? current.stored.weekdays + : kind === 'weekly' && carriesClock + ? [zonedWeekday(task.scheduledAt, current.shown.draft.timeZone)] + : seedWeekdays(task), + }, + } + }) + } + const chooseZone = (zone: string): void => { + if (zone === shown.draft.timeZone) return + setEdit(current => ({ ...current, shown: { ...current.shown, draft: { ...current.shown.draft, timeZone: zone } } })) + } + const toggleWeekday = (weekday: Weekday): void => { + // The Host rejects an empty weekday set, so the last selected day stays on. + if (shown.weekdays.length === 1 && shown.weekdays.includes(weekday)) return + const weekdays = shown.weekdays.includes(weekday) + ? shown.weekdays.filter(day => day !== weekday) + : WEEKDAYS.filter(day => day === weekday || shown.weekdays.includes(day)) + rememberWeekdays(weekdays) + setEdit(current => ({ ...current, shown: { ...current.shown, weekdays } })) + } + const editDraft = (patch: Partial): void => { + setEdit(current => ({ ...current, shown: { ...current.shown, draft: { ...current.shown.draft, ...patch } } })) + } + const editContent = (patch: Partial>): void => { + setEdit(current => ({ ...current, shown: { ...current.shown, ...patch } })) + } + const cancelDraft = (): void => { + // The stored values become the shown values again, so the notice clears with them. + chosenWeekdays.current = undefined + setEdit(current => ({ ...current, shown: current.stored })) + setFailure(undefined) + } + const saveDraft = async (): Promise => { + const invalid = draftError(shown) + if (invalid !== undefined) { + setFailure(invalid) + return + } + const submitted = taskIdentity + const generation = ++submissions.current + setPending(true) + setFailure(undefined) + const request: ScheduleUpdateRequest = { + sessionId: task.sessionId, id: task.id, + // The refreshed record stays the complete expected rule even while an + // unsaved draft keeps the shown values. + expected: timingSnapshot(task), + ...contentChange(shown, storedValues), + } + let result: RemoteResult + try { + // A name- or instruction-only save submits no timing change: `change` is optional. + result = await onUpdateTiming(timingValues(shown) === timingValues(storedValues) + ? request + : { ...request, change: ruleChange(shown.draft, shown.kind, shown.weekdays) }) + } catch (_error: unknown) { + // A rejected request cannot establish whether the Host committed the edit. + if (!mounted.current || identityRef.current !== submitted + || generation !== submissions.current) return + setPending(false) + setFailure('rule.error.unknown') + return + } + if (!mounted.current || identityRef.current !== submitted + || generation !== submissions.current) return + setPending(false) + if (!result.ok) { + setFailure('rule.error.unknown') + return + } + if ('code' in result.value) { + setFailure(ruleError(result.value.code)) + return + } + const saved = ruleValues(result.value.record) + // A refresh that landed while this save was in flight already seeded a newer + // record. This response describes the record the save submitted, so it must + // not put those older values back; the newer seed stays authoritative and + // the in-flight draft leaves with the completed save. + setEdit(current => (current.propKey === propKey + ? { ...current, stored: saved, shown: saved } + : { ...current, shown: current.stored })) + } + + const confirmDelete = (): void => { + // Confirming retains the task before the acknowledgement-driven refresh can + // drop its row, so this detail and its saved records stay reachable. + setDeletionConfirmed(true) + closeConfirmation() + void onDelete(task.id) + } + + return <> +