feat(web): list active reminders in the session header
This commit is contained in:
parent
f8b0ca046f
commit
2a9b940ef5
88 changed files with 3104 additions and 172 deletions
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-19-session-projection-state-and-client-views.md
|
||||
2026-08-19-session-projection-state-and-client-views.md: 14da0525b2cc838ff496d5902dd66ae6ab456af4
|
||||
2026-08-19-session-projection-state-and-client-views.zh.md: 3b1ed72bf240ebf43924826d42226ff301ad8fca
|
||||
2026-08-19-session-projection-state-and-client-views.md: 8223ffdb411ad182a2847aa139ab35be453457be
|
||||
2026-08-19-session-projection-state-and-client-views.zh.md: a09f6515859bd1b3bb702f7c379d903f581d2cf4
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ English | [中文](2026-08-19-session-projection-state-and-client-views.zh.md)
|
|||
|
||||
## Problem
|
||||
|
||||
The projection registry persisted each unit's internal fold state without a runtime schema, while `SessionProjectionMap` described the client value returned by `view`. This left restored state unvalidated and made the same type table appear to describe two values that may differ. Host consumers also needed the current folded state without serializing every registered client view or exposing internal-only state through the client protocol.
|
||||
The projection registry persisted each unit's internal fold state without a runtime schema, while `SessionProjectionMap` described the client value returned by `view`. This left restored state unvalidated and made the same type table appear to describe two values that may differ. Host consumers also needed the current folded state without serializing every registered client view or exposing internal-only state through the client protocol. Finally, an empty-argument `init()` could not receive immutable Session facts such as the fork boundary, forcing a fork-sensitive domain either to inspect ambient state or to duplicate its fold outside the registry.
|
||||
|
||||
## Decision
|
||||
|
||||
|
|
@ -14,9 +14,11 @@ The projection registry persisted each unit's internal fold state without a runt
|
|||
|
||||
A unit whose key also appears in `SessionProjectionMap` supplies `wire.viewSchema` and `wire.view`. Every unit's state is checkpointed — client-visible and host-only alike; the `persist` opt-in is gone, so no unit can silently skip the durable cache. Snapshot APIs return only `SessionProjectionMap`, so internal states cannot enter API payloads. Host code reads one current state through `stateOf(session, key)`; the returned reference is borrowed and must not be mutated.
|
||||
|
||||
`ProjectionDefinition.init(initialization)` receives an immutable `ProjectionInitialization`. Its current field is normalized `seedLength`: live lazy and event-driven cells use `session.header.seedLength ?? 0`, while cache, history, and detached Subagent restores pass the value from the same persisted header read that supplied their events. The unit remains a pure synchronous fold and cannot acquire a Session or other ambient mutable state through this input.
|
||||
|
||||
## Consequences
|
||||
|
||||
Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers.
|
||||
Projection state and client values are independently typed and validated without introducing a second client DTO vocabulary. A unit may expose a compact or compatibility-preserving client value while retaining richer host state. Malformed cached state cannot seed `viewCheckpoint`; restore rejects malformed state and the cache's existing full-read fallback rebuilds it from the log. Host consumers can replace private log scans with the same incremental fold used by carriers. Fork-sensitive units can now share that path while deterministically excluding inherited prefixes.
|
||||
|
||||
The original [session-projection proposal](../../proposed/architecture/2026-07-27-session-projection-and-command-log.md) now records this split. The earlier [subagent identity projection](2026-08-06-subagent-list-identity-projection.md) and [projected token usage](2026-07-29-projected-token-usage-and-request-context.md) decisions remain current; their domain folds move to the state table without changing their user-facing values.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
## 问题
|
||||
|
||||
投影注册表会持久化各单元的内部折叠状态,却没有运行时 schema;与此同时,`SessionProjectionMap` 描述的是 `view` 返回的客户端值。这使恢复出的状态未经校验,也让同一张类型表看似同时描述两种可能不同的值。host 消费方还需要读取当前折叠状态,但不应为此序列化全部已注册客户端视图,也不应把内部状态暴露到客户端协议。
|
||||
投影注册表会持久化各单元的内部折叠状态,却没有运行时 schema;与此同时,`SessionProjectionMap` 描述的是 `view` 返回的客户端值。这使恢复出的状态未经校验,也让同一张类型表看似同时描述两种可能不同的值。host 消费方还需要读取当前折叠状态,但不应为此序列化全部已注册客户端视图,也不应把内部状态暴露到客户端协议。最后,无参数 `init()` 无法接收 fork 边界等不可变 Session 事实,迫使 fork-sensitive 领域读取环境状态或在注册表外重复 fold。
|
||||
|
||||
## 决策
|
||||
|
||||
|
|
@ -14,9 +14,11 @@
|
|||
|
||||
如果一个单元的 key 也存在于 `SessionProjectionMap`,该单元就提供 `wire.viewSchema` 与 `wire.view`。每个单元的状态都会写入检查点——client-visible 与 host-only 一视同仁;`persist` 选择项已移除,任何单元都不能悄悄跳过持久化缓存。快照 API 只返回 `SessionProjectionMap`,因此内部状态不会进入 API 载荷。host 代码通过 `stateOf(session, key)` 读取一份当前状态;返回的是借用引用,不得修改。
|
||||
|
||||
`ProjectionDefinition.init(initialization)` 接收不可变的 `ProjectionInitialization`。当前字段是规范化后的 `seedLength`:live 惰性与事件驱动 cell 使用 `session.header.seedLength ?? 0`,cache、history 与 detached Subagent restore 则从提供对应事件的同一次持久 header 读取传入该值。单元仍是纯同步 fold,不能借此输入取得 Session 或其他环境可变状态。
|
||||
|
||||
## 结果
|
||||
|
||||
投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。
|
||||
投影状态和客户端值分别获得类型与校验,同时不引入第二套客户端 DTO 词汇。单元可以保留更丰富的 host 状态,并暴露紧凑或兼容既有结构的客户端值。畸形缓存状态不能为 `viewCheckpoint` 提供数据;恢复会拒绝畸形状态,并由缓存既有的全量读取回退从日志重建。host 消费方可以用同一套增量折叠替换私有日志扫描。fork-sensitive 单元现在也能共享这条路径,并确定性地排除继承前缀。
|
||||
|
||||
原始 [session-projection 提案](../../proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md)已记录这次拆分。既有的 [subagent 身份投影](2026-08-06-subagent-list-identity-projection.zh.md)与[投影化 token 用量](2026-07-29-projected-token-usage-and-request-context.zh.md)决策仍然有效;其中的领域折叠迁入状态表,不改变面向用户的值。
|
||||
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# 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
|
||||
2026-08-05-durable-web-schedule.md: 03b27c5117ce8552d690262179dafd8845dc9a6f
|
||||
2026-08-05-durable-web-schedule.zh.md: 2ed7e54e5c356efa4aa12c4ed4b543f490642a77
|
||||
2026-08-05-durable-web-schedule.md: d079f8e49277dc6b717f0f4393bd52b46946522e
|
||||
2026-08-05-durable-web-schedule.zh.md: 061fb588b82d44d88ebba8cacb6d417405616789
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Busy Agents, long waits, wall-clock changes, cold Sessions, forks, persistence f
|
|||
|
||||
## 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`; the default Web tree remains unchanged. 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 it.
|
||||
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](../simplification/2026-08-09-conversational-schedule-delivery.md)).
|
||||
|
||||
|
|
@ -28,6 +28,8 @@ The user-visible boundary is `session-local`: the original Session runs an on-ti
|
|||
|
||||
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 `SessionHeader.seedLength`.
|
||||
|
||||
When `ctx.sessionProjections` exists, Schedule registers a strict unit that uses the same transition and publishes the complete active `ScheduleRecord[]`. Its initialized state retains the normalized `seedLength`, active records, and every used id; live, cached, history, and detached reads all obtain that boundary from the same Session header as their events. 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.
|
||||
|
|
@ -56,6 +58,12 @@ The accepted path clears pending persistence and claims the true idle phase. It
|
|||
|
||||
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
|
||||
|
||||
[`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.md) reads the full active projection only after the current Session opens successfully. It derives localized frequency, browser-local target time, relative time, overdue state, and stable presentation order without persisting those values. The header entry is absent for missing or empty projections and closes when the last live record disappears.
|
||||
|
||||
The catalog deliberately has no detail, mutation, retry, toast, raw UTC, Schedule id, or special transcript card. It is current active state, not a dispatch receipt; the ordinary Assistant turn remains the only delivery presentation. The Web bundle owns one disabled client row and its resolution dependency, while the Schedule overlay only enables that row together with the Host services.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Use `ctx.jobs`.** Jobs own process-local work, outcomes, and notifications rather than Session-log state and conversation follow-ups.
|
||||
|
|
@ -74,7 +82,7 @@ Dispatch records queue admission, not model completion or user receipt. Framing
|
|||
|
||||
## 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, 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. Host/client tests pin browser-zone sampling and prompt-bound validation. Keyless assembled Web scenarios cover browser-local At and an overdue two-record Every batch through ordinary assistant follow-ups with no receipt UI.
|
||||
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. Host/client tests pin browser-zone sampling, prompt-bound validation, open-state gating, localized exact intervals, ordering, keyboard/focus behavior, and strict projection failure. Keyless assembled Web scenarios cover browser-local At, an overdue two-record Every batch through ordinary assistant follow-ups, and the active catalog across live change, reload, fork isolation, narrow dark layout, and ordinary Web disabled composition.
|
||||
|
||||
## Consequences
|
||||
|
||||
|
|
@ -82,5 +90,6 @@ Package tests pin strict replay, one-shot and Every transitions, creation-anchor
|
|||
- 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 without creating a second durable state 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.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ Status: implemented
|
|||
|
||||
## 决策
|
||||
|
||||
[Schedule 指南](../../../../docs/user/guide/schedule.zh.md)使用显式加载 `@deepseek-ai/dsh-time-context` 与 `@deepseek-ai/dsh-schedule` 的 overlay;默认 Web 配置树保持不变。Schedule 只观察插件加载后发布的根 Agent,并在该 Agent scope 中安装三个工具和一个可丢弃 owner。cold history 读取、已发布的根、child Agent 与其他 host 都不会激活它。
|
||||
[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 回执([对话式交付](../simplification/2026-08-09-conversational-schedule-delivery.zh.md))。
|
||||
|
||||
|
|
@ -28,6 +28,8 @@ Status: implemented
|
|||
|
||||
版本 1 `schedule/change` stream 是唯一持久的 Schedule 权威。create 记录拥有一个 Session 内不复用的品牌 id、trim 后的提示词、规则判别字段和 UTC 目标。delete 与一次性 dispatch 是终结转换。Every dispatch 会存储 id 与决策时点,使 fold 将该记录直接推进到错过的发生时点之后。严格 decoder 与纯 fold 会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的 dispatch,以及针对非活动记录的转换。普通 Session 折叠完整 stream;fork 只折叠 `SessionHeader.seedLength` 位置及其后的 event。
|
||||
|
||||
`ctx.sessionProjections` 存在时,Schedule 会注册一个复用同一 transition 的严格单元,并发布完整的活动 `ScheduleRecord[]`。其初始化状态保留规范化后的 `seedLength`、活动记录与全部已使用 id;live、缓存、history 与 detached 读取都从提供对应事件的同一个 Session header 获得该边界。损坏的持久输入会使既有读取路径失败,而不会产生部分数组。浏览器安全的记录词汇通过纯类型子路径 `@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 是否已经提交。
|
||||
|
|
@ -56,6 +58,12 @@ Agent-scoped owner 从持久 fold 派生最早目标。超长目标使用有界
|
|||
|
||||
dispatch 记录的是队列准入,而不是模型完成或用户收到提醒。framing 构造或同步入队失败不会追加 dispatch。append 失败会使该 owner fault,因为消息可能已经入队。Agent 或插件 dispose 会取消 timer、停止新工作、撤销工具注册,并等待进行中的工作,且不会删除持久记录。follow-up 获得准入后、持久 dispatch 前发生崩溃,可能使提醒在恢复后重复;本设计不作 exactly-once 承诺。
|
||||
|
||||
### 只读 Web 目录
|
||||
|
||||
[`dsh-client-ui-schedule`](../../../../packages/client/ui-schedule/README.zh.md)只有在当前 Session 成功打开后才读取完整活动 projection。它在浏览器端派生本地化周期、浏览器本地目标时间、相对时间、逾期状态与稳定呈现顺序,不持久化这些值。projection 缺失或为空时 header 入口不存在,最后一条 live 记录消失时入口也会关闭。
|
||||
|
||||
该目录有意不提供详情、mutation、Retry、Toast、原始 UTC、Schedule id 或特殊 transcript 卡片。它表示当前活动状态,而非 dispatch 回执;普通 Assistant 轮次仍是唯一交付呈现。Web bundle 拥有一个 disabled client row 及其解析依赖,Schedule overlay 只负责与 Host 服务一起启用该 row。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
**使用 `ctx.jobs`。** Task 拥有进程本地工作、结果和通知,而不是 Session 日志状态和对话 follow-up。
|
||||
|
|
@ -74,7 +82,7 @@ dispatch 记录的是队列准入,而不是模型完成或用户收到提醒
|
|||
|
||||
## 验证
|
||||
|
||||
包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。Host/client 测试固定浏览器时区采样与绑定到提示词的校验。无密钥组装 Web 场景覆盖浏览器本地 At,以及通过普通 assistant follow-up 交付的逾期双记录 Every 批次,两者都没有回执 UI。
|
||||
包测试以逐文件 100% coverage 固定严格回放、一次性与 Every 状态转换、创建锚点运算、只追赶最新一次、多记录批处理、fork 后缀、id 复用、偏移量与本地日历 profile、IANA 校验、夏令时缺口与重叠、时间边界、timer 分段、墙钟变化、overdue 准入、固定 framing、入队与 append 失败、barrier 恢复、projection 注册与恢复、注册 rollback 和完全停稳的 dispose。属性测试会在不同间隔与跳过跨度下比较 Every 计算与回放。production JSONL restart 测试证明一条 overdue 提醒会经过真实 Agent 生命周期 dispatch,并且再次 restart 后不会重复 dispatch。Host/client 测试固定浏览器时区采样、绑定到提示词的校验、open-state 门槛、本地化精确间隔、排序、键盘/焦点行为与严格 projection 失败。无密钥组装 Web 场景覆盖浏览器本地 At、通过普通 assistant follow-up 交付的逾期双记录 Every 批次,以及活动目录的 live 变化、reload、fork 隔离、窄屏暗色布局和普通 Web disabled 组合。
|
||||
|
||||
## 后果
|
||||
|
||||
|
|
@ -82,5 +90,6 @@ dispatch 记录的是队列准入,而不是模型完成或用户收到提醒
|
|||
- cold Session 不工作、不发送外部通知;重新打开后可能交付 overdue 工作。
|
||||
- 无需持久 Session 时区状态或从 Schedule 到 time-context 的依赖,绝对时间输入仍然具有确定性。
|
||||
- 用户看到普通对话输出;dispatch 绝不会夸大模型成功或 acknowledgement。
|
||||
- 显式启用 Schedule 的 Web 用户可以查看完整活动集合,而不会引入第二份持久状态或第二种交付含义。
|
||||
- 每个 live 根只增加从 fold 派生的 timer、可选 idle wait 与一个 in-flight operation。
|
||||
- 固定速率周期性受到至少 5 分钟、只追赶最新一次,以及每条逾期记录只在一个批次中贡献一个发生时点的约束;日历周期性仍在此产品边界之外。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md
|
||||
2026-08-25-read-only-web-schedule-catalog.md: ffd5db921988a58147f1291171a0442eda0f3ff3
|
||||
2026-08-25-read-only-web-schedule-catalog.zh.md: db61b874156f2ad0b3e96df89e8e5ed6665882e6
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# Agent Note: Read-only Web Schedule catalog
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-25-read-only-web-schedule-catalog.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Schedule already persisted active reminders and delivered due work as ordinary later conversation turns, but a person using Web could not inspect what remained active. The model-facing `schedule_list` tool was not a suitable browser contract: calling it would add a tool transaction, couple UI to Agent availability, and duplicate the Session projection transport already used for durable read models.
|
||||
|
||||
The catalog also had to preserve two existing boundaries. A fork must not inherit the parent Session's active reminders even though its event array contains the inherited prefix, and an active-reminder list must not become a second delivery receipt beside the ordinary Assistant response.
|
||||
|
||||
## Decision
|
||||
|
||||
Schedule registers an optional `schedule` Session projection and a separate browser package renders that complete active value. The durable `schedule/change` stream remains the only authority; the browser performs presentation-only derivation and exposes no mutation.
|
||||
|
||||
### Seed-aware strict projection
|
||||
|
||||
`ProjectionDefinition.init()` now receives immutable `ProjectionInitialization { seedLength }`. Live lazy builds, event-driven builds, persisted-cache restores, Session history reads, and detached Subagent reads normalize the boundary from the same Session header that supplied their events. Existing units may ignore the input. A fork-sensitive unit can retain it in state and skip every event whose `seq` is below the boundary without consulting an ambient Session object.
|
||||
|
||||
The Schedule unit persists `{ seedLength, active, seenIds }`, reuses the domain's strict decoder and `applyScheduleChange` transition, and publishes the complete active `ScheduleRecord[]`. Keeping `seenIds` preserves the no-reuse invariant after cached restore. Its strict state schema rejects malformed records, duplicate ids, and active ids absent from the used-id set. A damaged event or checkpoint fails the existing read/open path; no partial array is published.
|
||||
|
||||
`@deepseek-ai/dsh-schedule/client` is a type-only browser-safe export of the durable record vocabulary. It does not pull the Cordis plugin, runtime, timers, tools, or Node dependencies into the client graph.
|
||||
|
||||
### Web composition and visibility
|
||||
|
||||
The shipped Web bundle owns the `@deepseek-ai/dsh-client-ui-schedule` resolution dependency and one `ui-schedule` row with `disabled: true`. The existing Schedule overlay loads `time-context` and the Schedule Host plugin, then enables that row by id. Ordinary Web therefore resolves but never starts the plugin; only an explicit Schedule composition gets both halves.
|
||||
|
||||
The header action reads `openState` through the standard Session hook and the `schedule` projection through `useProjection`. It renders only when `openState === 'open'` and the array is non-empty. This gate also hides a prewarmed listing-cache value when opening the current Session fails. A live update that removes the final record closes and unmounts the control.
|
||||
|
||||
The slot entry uses internal order 10: static Agent and Subagent information precede it, while the Jobs entry at order 20 follows it. The component owns no shared store; popover visibility is its only local interaction state.
|
||||
|
||||
### Presentation and interaction
|
||||
|
||||
The 336px popover renders one non-focusable row per active record. The prompt is complete plain text with wrapping and no line clamp; the list scrolls vertically when its content exceeds the existing maximum height. Rows contain no Schedule id, raw UTC, details, or controls.
|
||||
|
||||
Each row presents status separately from three metadata fields. After and At are localized as Once. Every chooses the largest day, hour, minute, or second unit that divides the durable `everySeconds` value exactly, so 300 seconds becomes 5 minutes while 301 stays 301 seconds. The browser formats `scheduledAt` in its current locale and time zone and derives relative time from its current clock. These values are not written back to the projection.
|
||||
|
||||
Rows sort overdue first, then by ascending `scheduledAt`, then by the projection array index. The final tie-break preserves the Schedule fold's creation order without adding a durable ordering field. Scheduled state uses the business-blue semantic dot; overdue uses the warning-amber semantic dot and row treatment.
|
||||
|
||||
The trigger is the catalog's only tab stop. Native button behavior provides Enter and Space activation. Escape closes an open popover and returns focus to the trigger; a pointer press outside closes it. When a projection update removes the last row, the component does not call focus or move it to a neighboring header action.
|
||||
|
||||
### Delivery boundary
|
||||
|
||||
The catalog is current active state, not history or proof of delivery. A terminal delete or dispatch removes a row. Due work still enters the transcript only through the ordinary Schedule `followup()` and Assistant result. The catalog emits no message, card, toast, acknowledgement, retry affordance, or Schedule-specific error entry.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Call `schedule_list` from the browser.** This crosses the model-facing tool boundary, requires a live Agent, and creates request and stale-response machinery for data already available through the projection carrier.
|
||||
|
||||
**Render raw `schedule/change` events.** Events are persistence protocol, not presentation. A client-side fold would duplicate strict domain logic and expose internal ids and transitions.
|
||||
|
||||
**Persist status, relative time, or display order.** These values depend on the viewing browser's clock, locale, and time zone. Persisting them would make replay environment-dependent and introduce unnecessary durable fields.
|
||||
|
||||
**Show the control while Session open is failing.** A cached list value may be older than a corrupt tail. Rendering it would present stale partial truth precisely when strict replay rejected the authoritative Session.
|
||||
|
||||
**Add row actions or a receipt history.** Mutation belongs to the existing tools, while delivery history belongs to the ordinary transcript. Combining either with this catalog would change its authority and accessibility model.
|
||||
|
||||
## Verification
|
||||
|
||||
Projection tests cover shared transition equivalence, creation order, fork-prefix exclusion, checkpoint restore, strict corruption propagation, and registration teardown. Registry, cache, history, and Subagent tests cover normalized initialization on live, lazy, full-log, and detached paths. Browser tests cover capability absence, open-state gating, English and Chinese copy, exact interval units, local and relative time, clock crossing, status and stable sorting, complete plain-text prompts, scrolling, live removal, outside dismissal, keyboard activation, Escape focus return, and no focus migration on external unmount. The keyless shipped-Web scenario covers default-disabled versus overlay-enabled composition, live changes, reload and cold baseline, fork isolation, ordinary Assistant delivery, header ordering, and narrow dark layout.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A person can inspect every active reminder without invoking the model or adding another durable source of truth.
|
||||
- Fork isolation now belongs to the shared projection initialization contract rather than a Schedule-specific out-of-band scan.
|
||||
- Browser time labels may differ across viewers by locale, time zone, and clock while the durable records remain identical.
|
||||
- Corrupt Schedule history fails the normal Session path and never degrades into a plausible-looking partial catalog.
|
||||
- The catalog cannot acknowledge, retry, edit, or prove delivery; those semantics remain deliberately outside this surface.
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# Agent Note:只读 Web Schedule 目录
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-25-read-only-web-schedule-catalog.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Schedule 已经持久化活动提醒,并把到期工作作为普通后续对话轮次交付,但 Web 用户无法查看仍有哪些提醒处于活动状态。面向模型的 `schedule_list` 工具不适合作为浏览器契约:调用它会增加一次工具事务、把 UI 耦合到 Agent 可用性,并重复 Session projection transport 已经提供的持久读模型通道。
|
||||
|
||||
该目录还必须保留两条既有边界。fork 的事件数组虽然包含继承前缀,却不能继承父 Session 的活动提醒;活动提醒列表也不能在普通 Assistant 回答之外变成第二种交付回执。
|
||||
|
||||
## 决策
|
||||
|
||||
Schedule 注册一个可选的 `schedule` Session projection,由独立浏览器包渲染这份完整活动值。持久 `schedule/change` stream 仍是唯一权威;浏览器只做呈现派生,不公开 mutation。
|
||||
|
||||
### seed-aware 严格 projection
|
||||
|
||||
`ProjectionDefinition.init()` 现在接收不可变的 `ProjectionInitialization { seedLength }`。live 惰性构建、事件驱动构建、持久化缓存恢复、Session history 读取与 detached Subagent 读取,都从提供对应事件的同一个 Session header 规范化该边界。既有单元可以忽略此输入。fork-sensitive 单元可以把它保存在状态中,并跳过 `seq` 小于边界的每个事件,而无需读取环境 Session 对象。
|
||||
|
||||
Schedule 单元持久化 `{ seedLength, active, seenIds }`,复用领域的严格 decoder 与 `applyScheduleChange` transition,并发布完整的活动 `ScheduleRecord[]`。保留 `seenIds` 可在缓存恢复后继续维持 id 不复用不变量。严格 state schema 会拒绝畸形记录、重复 id,以及不在已使用集合中的活动 id。损坏事件或 checkpoint 会使既有读取/打开路径失败;系统不会发布部分数组。
|
||||
|
||||
`@deepseek-ai/dsh-schedule/client` 是持久记录词汇的纯类型浏览器安全出口。它不会把 Cordis 插件、runtime、timer、工具或 Node 依赖带入 client graph。
|
||||
|
||||
### Web 组合与可见性
|
||||
|
||||
shipped Web bundle 拥有 `@deepseek-ai/dsh-client-ui-schedule` 的解析依赖,以及一个带 `disabled: true` 的 `ui-schedule` row。现有 Schedule overlay 加载 `time-context` 与 Schedule Host 插件,再按 id 启用该 row。普通 Web 因而只解析但绝不启动该插件;只有显式 Schedule 组合同时获得 Host 与 client 两半。
|
||||
|
||||
header action 通过标准 Session hook 读取 `openState`,通过 `useProjection` 读取 `schedule` projection。只有 `openState === 'open'` 且数组非空时才渲染。这条门槛也会在当前 Session 打开失败时隐藏曾由列表缓存预热的值。live 更新移除最后一条记录时,控件会关闭并卸载。
|
||||
|
||||
slot 条目使用内部 order 10:静态 Agent 与 Subagent 信息位于它之前,order 20 的 Jobs 入口位于它之后。组件不拥有共享 store;popover 是否打开是唯一的本地交互状态。
|
||||
|
||||
### 呈现与交互
|
||||
|
||||
336px 弹层为每条活动记录渲染一行不可聚焦内容。prompt 是可完整换行、没有 line clamp 的纯文本;内容超过既有最大高度时,列表在内部纵向滚动。行中不包含 Schedule id、原始 UTC、详情或操作控件。
|
||||
|
||||
每行把状态与三项元数据分开呈现。After 与 At 本地化为「单次」。Every 选择能够整除持久 `everySeconds` 值的最大日、小时、分钟或秒单位,因此 300 秒显示为 5 分钟,301 秒仍显示为 301 秒。浏览器用当前 locale 与时区格式化 `scheduledAt`,并按当前时钟派生相对时间。这些值都不会写回 projection。
|
||||
|
||||
行先按 overdue 排序,再按 `scheduledAt` 升序,最后按 projection 数组索引排序。最终 tie-break 保留 Schedule fold 的创建顺序,不增加持久排序字段。scheduled 状态使用业务蓝语义圆点;overdue 使用警告琥珀语义圆点与行背景。
|
||||
|
||||
触发器是目录唯一的 Tab stop。原生 button 行为提供 Enter 与 Space 激活。Escape 会关闭已打开的弹层并把焦点还给触发器;在外部按下指针也会关闭。projection 更新移除最后一行时,组件不会调用 focus,也不会把焦点迁移到相邻 header action。
|
||||
|
||||
### 交付边界
|
||||
|
||||
该目录表示当前活动状态,不是历史或交付证明。终结性的 delete 或 dispatch 会移除一行。到期工作仍只通过普通 Schedule `followup()` 与 Assistant 结果进入 transcript。目录不发出消息、卡片、Toast、acknowledgement、Retry 控件或 Schedule 专属错误入口。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
**从浏览器调用 `schedule_list`。** 这会跨越面向模型的工具边界,需要 live Agent,并为 projection carrier 已经拥有的数据制造请求与旧响应处理机制。
|
||||
|
||||
**渲染原始 `schedule/change` 事件。** 事件是持久化协议,不是呈现协议。客户端 fold 会重复严格领域逻辑,并暴露内部 id 与 transition。
|
||||
|
||||
**持久化状态、相对时间或显示顺序。** 这些值取决于查看方浏览器的时钟、locale 与时区。持久化它们会使回放依赖环境,并增加不必要的持久字段。
|
||||
|
||||
**在 Session 打开失败时仍显示控件。** 缓存的列表值可能旧于损坏的 tail。显示它会在严格回放已经拒绝权威 Session 时呈现貌似可信的部分事实。
|
||||
|
||||
**增加行操作或回执历史。** mutation 属于既有工具,交付历史属于普通 transcript。把任一项并入该目录都会改变它的权威与可访问性模型。
|
||||
|
||||
## 验证
|
||||
|
||||
projection 测试覆盖共享 transition 等价性、创建顺序、fork 前缀排除、checkpoint 恢复、严格损坏传播与注册拆除。注册表、cache、history 与 Subagent 测试覆盖 live、惰性、全量日志和 detached 路径上的规范化初始化。浏览器测试覆盖能力缺失、open-state 门槛、中英文文案、精确周期单位、本地与相对时间、时钟越界、状态与稳定排序、完整纯文本 prompt、滚动、live 移除、外部关闭、键盘激活、Escape 回焦,以及外部卸载时不迁移焦点。无密钥 shipped-Web 场景覆盖默认 disabled 与 overlay enabled 组合、live 变化、reload 与 cold baseline、fork 隔离、普通 Assistant 交付、header 排序和窄屏暗色布局。
|
||||
|
||||
## 后果
|
||||
|
||||
- 用户可以查看每条活动提醒,而无需调用模型或增加另一份持久权威。
|
||||
- fork 隔离现在属于共享 projection 初始化契约,而不是 Schedule 专属的带外扫描。
|
||||
- 不同查看者的浏览器时间标签可能因 locale、时区与时钟而不同,持久记录仍完全相同。
|
||||
- 损坏的 Schedule history 会使正常 Session 路径失败,绝不会降级成貌似可信的部分目录。
|
||||
- 该目录不能确认、重试、编辑或证明交付;这些语义有意留在此界面之外。
|
||||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-09-conversational-schedule-delivery.md
|
||||
2026-08-09-conversational-schedule-delivery.md: 8795d9da1d08bf6e74ecd6b374db69e3c143ad7d
|
||||
2026-08-09-conversational-schedule-delivery.zh.md: c9688f80cb67a936aa331f09a2a03200c3be2af8
|
||||
2026-08-09-conversational-schedule-delivery.md: 84a97b8945cd3334f9a431ffd5c9ace138699405
|
||||
2026-08-09-conversational-schedule-delivery.zh.md: 1d3430a8c725349fd1e2565a735e12fec46e549f
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ A due reminder waits for the Agent's idle maintenance phase and calls `followup(
|
|||
|
||||
`schedule/change` remains the only durable Schedule state. Its dispatch operation records that the follow-up was synchronously queued, which prevents ordinary restart replay after the dispatch is durable. Dispatch does not claim model success, user acknowledgement, or an external notification. The narrow crash interval between enqueue and durable dispatch remains at-least-once.
|
||||
|
||||
Schedule exposes no presentation projection, Host sidecar, browser event node, keyed event slot, or client renderer. Session persistence retains its shared `flush()` contract and has no Schedule-driven success event. The opt-in Web overlay loads only `@deepseek-ai/dsh-schedule`.
|
||||
Schedule exposes no delivery-receipt projection, Host sidecar, browser event node, keyed event slot, or receipt renderer. Session persistence retains its shared `flush()` contract and has no Schedule-driven success event. A separate Session projection may publish the complete currently active record set, and `dsh-client-ui-schedule` may render that set read-only; neither contains dispatch success, acknowledgement, or transcript history. The opt-in Web overlay loads the Host Schedule services and enables the Web bundle's default-disabled catalog row.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
|
@ -30,10 +30,10 @@ Schedule exposes no presentation projection, Host sidecar, browser event node, k
|
|||
|
||||
## Verification
|
||||
|
||||
Package lifecycle tests pin idle waiting, maintenance ownership, follow-up-before-dispatch ordering, synchronous enqueue failure, model-independent dispatch, and restart replay. The assembled Web scenario snapshots the resulting assistant row and asserts that a persisted Schedule dispatch has no special history view. Source and dependency audits reject the removed presentation symbols, event, sidecar, slot, renderer package, and overlay entry.
|
||||
Package lifecycle tests pin idle waiting, maintenance ownership, follow-up-before-dispatch ordering, synchronous enqueue failure, model-independent dispatch, and restart replay. The assembled Web scenario snapshots the resulting assistant row and asserts that a persisted Schedule dispatch has no special history view, toast, or receipt. Projection and catalog tests separately prove that only active records appear and disappear on terminal transitions. Source and dependency audits reject the removed receipt symbols, event, sidecar, keyed event slot, and renderer path.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Schedule is contained in its package plus ordinary composition and catalog wiring; Session, persistence, Host, client runtime, and conversation UI carry no Schedule-specific behavior.
|
||||
- Schedule delivery is contained in its package plus ordinary composition; Session, persistence, Host carriers, and conversation rendering carry no Schedule-specific receipt behavior. The optional active-state projection and header catalog remain a separate read-only concern.
|
||||
- Users see the reminder only through the conversation's normal model response. A failed model turn remains a failed turn rather than a contradictory success receipt.
|
||||
- Consumers that need external or acknowledged delivery require a different product boundary with its own notification and acknowledgement semantics.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ Schedule 已经通过将普通的 agent(智能体)后续轮次排入队列
|
|||
|
||||
`schedule/change` 仍是唯一持久 Schedule 状态。其 dispatch 操作记录后续轮次已同步入队,这会在 dispatch 持久化后阻止普通的重启回放。dispatch 不表示模型成功、用户确认或外部通知。入队与持久 dispatch 之间的狭窄崩溃窗口仍保留至少一次语义。
|
||||
|
||||
Schedule 不公开呈现投影、Host 伴随数据、浏览器事件节点、按事件键控的 slot 或客户端渲染器。会话持久化保留共享的 `flush()` 约定,且不存在由 Schedule 驱动的成功事件。显式启用的 Web overlay 只加载 `@deepseek-ai/dsh-schedule`。
|
||||
Schedule 不公开交付回执 projection、Host 伴随数据、浏览器事件节点、按事件键控的 slot 或回执 renderer。会话持久化保留共享的 `flush()` 约定,且不存在由 Schedule 驱动的成功事件。另一条 Session projection 可以发布完整的当前活动记录集合,`dsh-client-ui-schedule` 可以只读渲染该集合;两者都不包含 dispatch 成功、acknowledgement 或 transcript 历史。显式启用的 Web overlay 会加载 Host Schedule 服务,并启用 Web bundle 中默认 disabled 的目录 row。
|
||||
|
||||
## 已考虑的替代方案
|
||||
|
||||
|
|
@ -30,10 +30,10 @@ Schedule 不公开呈现投影、Host 伴随数据、浏览器事件节点、按
|
|||
|
||||
## 验证
|
||||
|
||||
包生命周期测试固定 idle 等待、maintenance 所有权、后续轮次先于 dispatch 的顺序、同步入队失败、与模型无关的 dispatch 和重启回放。组装后的 Web 场景为产生的 assistant 行生成快照,并断言已持久化的 Schedule dispatch 没有特殊 history view。源码与依赖审计会拒绝残留的已移除呈现符号、事件、sidecar、slot、渲染器包与 overlay 配置项。
|
||||
包生命周期测试固定 idle 等待、maintenance 所有权、后续轮次先于 dispatch 的顺序、同步入队失败、与模型无关的 dispatch 和重启回放。组装后的 Web 场景为产生的 assistant 行生成快照,并断言已持久化的 Schedule dispatch 没有特殊 history view、Toast 或回执。projection 与目录测试另行证明只有活动记录出现,并在终结 transition 后消失。源码与依赖审计会拒绝残留的已移除回执符号、事件、sidecar、按事件键控的 slot 与 renderer 路径。
|
||||
|
||||
## 后果
|
||||
|
||||
- Schedule 的实现仅涉及其自身包、常规组合与目录接线;会话、持久化、Host、客户端运行时和对话 UI 不携带 Schedule 专属行为。
|
||||
- Schedule 交付仅涉及其自身包与常规组合;Session、持久化、Host 载体和对话渲染不携带 Schedule 专属回执行为。可选的活动状态 projection 与 header 目录是另一项只读关注点。
|
||||
- 用户只能通过对话中的普通模型响应看到提醒。失败的模型轮次仍是失败轮次,不会出现与之矛盾的成功回执。
|
||||
- 需要外部交付或交付确认的消费方必须采用另一条产品边界,并由其拥有自己的通知和确认语义。
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md
|
||||
2026-07-27-session-projection-and-command-log.md: 1e0dd435d972cfe35d1433417a3884845e384444
|
||||
2026-07-27-session-projection-and-command-log.zh.md: dcb077d87eae02c28714fd752606e0966bc70f94
|
||||
2026-07-27-session-projection-and-command-log.md: 305ba7c5dd6ff7c3816a54e9ed2c2a17570a2664
|
||||
2026-07-27-session-projection-and-command-log.zh.md: a99b1deb0c9d69227943c5620f9cd2b88b983e21
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ The underlying gap is architectural: the client has no seam for a plugin to obse
|
|||
|
||||
Four infrastructure pieces, then the domains become pure contributors.
|
||||
|
||||
### Whole-value event rule
|
||||
### Deterministic fold and complete wire value
|
||||
|
||||
A state-carrying log event MUST carry the complete post-change state, never a bare delta. All three domains already comply: `todo/write` is a whole-list snapshot, `plan/mode` a whole boolean, `goal/change` metadata a full `GoalSnapshot` (or a whole-value clear tombstone). The rule keeps every domain's transition trivially cheap (the framework drives it per event), keeps values self-describing on the wire, and lets any consumer treat the latest pushed value as final — out-of-order immunity by seq comparison, self-healing because a missed update is corrected by the next one.
|
||||
A projection unit MUST synchronously and deterministically validate and fold the Session events its domain owns. Those durable events may carry complete values or incremental domain transitions; the framework does not prescribe either encoding. When a unit has a client view, `wire.view` MUST publish the complete current value, never a delta. The host therefore remains the sole computation site, and clients can treat each projection frame as final for its seq: higher seq wins, while a later frame repairs a missed one.
|
||||
|
||||
### Host projection registry (`dsh-session-projection`, new package)
|
||||
|
||||
|
|
@ -32,12 +32,16 @@ What a domain registers is a **state-driven computation unit** — a pure fold p
|
|||
export interface SessionProjectionStateMap {} // host fold states
|
||||
export interface SessionProjectionMap {} // client-visible whole values
|
||||
|
||||
export interface ProjectionInitialization {
|
||||
readonly seedLength: number
|
||||
}
|
||||
|
||||
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S> {
|
||||
key: K
|
||||
stateSchema: ZodType<S>
|
||||
persist?: boolean // host-only units opt in; client-visible units always persist
|
||||
/** State for the empty log. */
|
||||
init(): S
|
||||
init(initialization: ProjectionInitialization): S
|
||||
/** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
|
||||
apply(state: S, event: SessionEvent): S
|
||||
/** Client view; omitted for host-only units. */
|
||||
|
|
@ -56,14 +60,15 @@ declare module 'cordis' {
|
|||
|
||||
- `SessionProjectionStateMap` types host fold states; `SessionProjectionMap` remains the one client DTO table shared by the wire block and React hook via `import type`. A unit may remain host-only by omitting `wire`. How a client value is *rendered* is the slot system's business, never the projection layer's. The state/view split is specified by the [implemented state and client-view note](../../implemented/architecture/2026-08-19-session-projection-state-and-client-views.md).
|
||||
- **The host is the only place a projection is computed.** The framework drives every registered unit forward eagerly: each committed session event passes through `apply`; a unit uninterested in an event returns the same state reference, and an unchanged reference (`Object.is`) produces no downstream work. Clients never fold domain events — they receive finished values (baseline block + push frame below). This removes the double-implementation trap (plan's two-event fold written once, on the host) and any client-side domain code.
|
||||
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init()`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
|
||||
- **Initialization is immutable and follows the event source.** `ProjectionDefinition.init(initialization)` receives normalized Session facts rather than ambient mutable state. Its current field is `seedLength`: live cells read `session.header.seedLength ?? 0`, while cache, history, and detached restores pass the value from the same persisted header read that supplied their events. A fork-sensitive unit can therefore exclude the inherited prefix without duplicating its fold outside the registry.
|
||||
- **State is always computed, never logged.** The log holds events only; the unit's state lives in the framework's per-session watermark cache (`{state, observedSeq}` per unit) and, in a later phase, in a **persisted projection cache** on the domain-KV storage seam: rows of `(sessionId, key, ver, seq, val)` (`ver` = the unit's `stateVersion`, `seq` = the watermark, `val` = the state JSON). A row is never wrong, only possibly stale — its `seq` says exactly how stale. The one read recipe, cold and live alike: take the cached state (or `init(initialization)`), forward-apply only the events past its watermark, `view` the result. Cold listings (every session's title across all workspaces) become an index read plus, at worst, a short tail replay; the session-persistence seam grows a read-from-seq primitive for that tail in the same later phase. Write policy: throttled (count/interval, configurable) plus two mandatory points — `turn/end` and detach (the live-to-cold moment). A crash between writes costs a longer tail replay, never a wrong value.
|
||||
- A domain's input event set is its own choice: todos folds `todo/write` alone; plan folds `plan/mode` plus its own `/plan` `command/run` records (see the plan section); goal folds `goal/change` metadata; session title folds its title events (retiring the bespoke `session/title` frame and the client's title-snapshot map — the fourth hand-rolled projection this seam absorbs).
|
||||
- Registration is an effect (disposer with the fiber): an unloaded plugin's key disappears from subsequent responses and the client reads it as capability absence — HMR semantics for free. Duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
|
||||
- The package owns `./invariant` (every served key has a live registration).
|
||||
|
||||
### Shipped consumer: the subagent identity unit
|
||||
|
||||
The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md) registers a `subagent` unit — the durable mode/label identity folded last-wins from `subagent/descriptor` — and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child (the watermark cache, zero log reads) and `restore({}, events, 0)` over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision.
|
||||
The registry's two read faces already serve a shipped consumer beyond this RFC's wire plan: [subagent list identity via the projection unit](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.md) registers a `subagent` unit — the durable mode/label identity folded last-wins from `subagent/descriptor` — and `SubagentRuntime.listChildren` reads it through `snapshot()` for a live child (the watermark cache, zero log reads) and `restore({}, events, 0, { seedLength: header.seedLength ?? 0 })` over one persistence inspection for a cold one. The registry contract is unchanged: no failure channel and no new read face — a unit never throws, an absent value is the signal, and how absence renders is that consumer's decision.
|
||||
|
||||
### Wire: projections block on the history tail page
|
||||
|
||||
|
|
@ -148,7 +153,7 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
|
|||
|
||||
**A dedicated `session.projections` RPC** — rejected: baseline-refresh moments coincide exactly with tail-page pulls, so a separate unary buys a second round-trip, a second seq to reconcile, and a client-side "when to refetch" decision that the rider design deletes outright.
|
||||
|
||||
**An opaque `get(agent)` provider contract** — rejected: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — `get` has nothing to run against), or resume from a mid-log position. Registering the `(init, apply, view)` unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit.
|
||||
**An opaque `get(agent)` provider contract** — rejected: with the computation model hidden inside the domain, the framework can never checkpoint the state, serve cold sessions (no agent, no loaded log — `get` has nothing to run against), or resume from a mid-log position. Registering the `(init(initialization), apply, view)` unit hands the framework the drive and keeps the domain to pure mathematics; a domain with host-side behavioral needs still keeps its own service subscriptions independently of the projection unit.
|
||||
|
||||
**A live-only overlay hook (`live?(agent, base)`) for plan's pending intent** — rejected: it existed solely because the user's plan *selection* was not in the log. Routing the selection through the standard command channel puts `command/run` on the account, pending becomes a pure replay quantity, and the projection remains a pure fold with an optional client view.
|
||||
|
||||
|
|
@ -156,9 +161,9 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
|
|||
|
||||
**Client-side folding (per-domain projection cells with a `fromEvent`)** — rejected: once plan's unit folds two event types, a client cell must duplicate the host's transition logic in the browser — the same fold written twice, evolving separately. Pushing finished values (the title-frame precedent, generalized) keeps one computation site and reduces the client to a generic seq-guarded value store; domains write zero client code.
|
||||
|
||||
**Bounded reverse scan over the log tail (absorber declarations).** Rejected: no implementation supports it, it serves only domains whose every event carries the full folded state, and the persisted projection cache covers the same cold-read need uniformly (cache row plus forward tail replay — the same recipe as the client's baseline and catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve.
|
||||
**Bounded reverse scan over the log tail (absorber declarations).** Rejected: no implementation supports it, and a bounded suffix cannot generally reconstruct a deterministic fold whose earlier transitions still affect current state. The persisted projection cache covers the cold-read need for both complete-value and incremental domains (cache row plus forward tail replay — the same recipe as the client's baseline and catch-up, and as paged loading). Revisit only if a real cold-read path emerges that checkpointing cannot serve.
|
||||
|
||||
**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: it exists only to serve delta events. The whole-value rule makes every domain last-wins; goal's refetch loop, its coalescing, and its stale-read fence all disappear.
|
||||
**An `invalidate`-style cell (mark dirty, refetch on domain events)** — rejected: the host fold already converts either complete-value or incremental events into a complete projection frame. Refetching would duplicate seq coordination and reintroduce a client-side baseline decision; goal's refetch loop, its coalescing, and its stale-read fence all disappear.
|
||||
|
||||
**Hanging the registry off `ctx.apiProxy`** — rejected: session projections are not web-specific (TUI, ACP, headless are future consumers), and domain packages must not depend on the apiproxy package. The independent seam also deletes #587's type-only import edge from api-proxy into the plan package.
|
||||
|
||||
|
|
@ -170,11 +175,11 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
|
|||
|
||||
**Keeping `setPlanMode` as a dedicated RPC** — rejected: plan selection is a user command like any other; the command channel gives it durable recording, flow rendering, multi-tab visibility, and admission semantics without a bespoke wire method. Web UI affordances (a toggle) compose the command line internally.
|
||||
|
||||
**Making mutation RPC responses feed cell state** — rejected: the committed mux event arrives immediately and carries the same whole value with a seq; responses feeding state is what required #527's write-revision fence.
|
||||
**Making mutation RPC responses feed cell state** — rejected: the projection frame derived from the committed event arrives immediately and carries the complete current value with a seq; responses feeding state is what required #527's write-revision fence.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- A domain plugin ships per-session log-derived state to React by writing only: the whole-value event declaration, one host unit `register`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files.
|
||||
- A domain plugin ships per-session log-derived state to React by writing only: its durable event declaration, one deterministic host unit with `init(initialization)`, `apply`, and a complete `wire.view`, its `SessionProjectionMap` merge, and inject callbacks — zero client-side folding code, no edits to the client `Session` class, `ConversationSnapshot`, api-proxy, or the wire schema files. Live and detached folds receive the same normalized initialization facts from the header that supplied their events.
|
||||
- The history tail page carries `projections` with `asOfSeq` equal to the window tail seq; loadOlder pages never carry it; a deployment without the registry serves histories without the block and clients treat every key as absent.
|
||||
- A stale baseline cannot overwrite a newer `session/projection` frame, and a replayed frame cannot regress the value store (higher-seq-wins tests on both paths).
|
||||
- A slash command executed on one tab renders a durable node in the flow on refresh, on a second tab, and after resume; unregistered commands render the generic card; the composer notice path for command outcomes is gone.
|
||||
|
|
@ -183,10 +188,10 @@ Infrastructure first; the three in-flight PRs are left untouched and re-target a
|
|||
|
||||
## Risks
|
||||
|
||||
- **Whole-value rule is load-bearing**: a future domain logging bare deltas cannot serve consumers from its latest event and complicates its own unit. Mitigation: the rule is stated here and in the projection package README; the unit contract makes the full state explicit at every transition.
|
||||
- **Deterministic fold and complete wire value are load-bearing**: a unit that consults ambient mutable state cannot be rebuilt consistently, and a client delta would force domain folding back into the browser. Mitigation: immutable `ProjectionInitialization`, the pure unit contract, schemas, and complete `wire.view` output keep reconstruction on the host and the client store generic.
|
||||
- **Synchronous unit discipline**: `init`/`apply`/`view` that await would tear the consistency cut. The registry documents and the invariant companion asserts synchronicity as far as practical; review owns the rest.
|
||||
- **Live registry churn is not pushed**: loading or unloading a domain plugin mid-session changes the key set, but no session event fires and no frame is pushed; open clients hold the stale key until the next tail pull (reconnect, gap repair, open). Accepted as a dev-only (HMR) staleness window — a registry-change push can be added to the change feed later without contract impact.
|
||||
- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Units are cheap per-event by construction (whole-value rule), non-matching events return the same reference, and the count of registered domains is small; if a hot path ever shows, per-unit event-type prefilters can be added without contract change.
|
||||
- **Eager drive costs on busy sessions**: every committed event passes every registered unit's `apply`. Non-matching events return the same reference and the count of registered domains is small; if an incremental transition creates a hot path, per-unit event-type prefilters can be added without contract change.
|
||||
- **Projection payload growth**: every tail page carries every registered key. Payloads are whole values of UI-scale state (a todo list, a goal snapshot); if a future domain's value is large, per-key opt-out or lazy keys can be added to the request without changing the model.
|
||||
- **Command log volume**: two log-only events per slash command; bounded by human command frequency, negligible against chunk volume.
|
||||
- **Re-target churn**: three open PRs rebase onto a moved foundation. Accepted cost of infrastructure-first.
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ Status: proposed
|
|||
|
||||
先立四件基础设施,之后各领域都退化为纯贡献方。
|
||||
|
||||
### 全量值事件规则
|
||||
### 确定性折叠与完整协议值
|
||||
|
||||
携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量。三个领域现状已然合规:`todo/write` 是整表快照,`plan/mode` 是一个完整布尔值,`goal/change` 元数据是完整的 `GoalSnapshot`(或一个全量值清除墓碑)。该规则让每个领域的状态转移始终足够廉价(框架逐事件驱动它),让值在协议层自描述,并让任何消费方都可以把最近推送的值当作最终值——靠 seq 比较获得乱序免疫,且自愈:漏掉的更新会被下一次更新纠正。
|
||||
投影单元必须同步且确定性地校验并折叠其领域拥有的 Session 事件。这些持久事件可以携带完整值,也可以携带增量式领域转换;框架不规定其中任何一种编码。单元存在客户端视图时,`wire.view` 必须发布完整当前值,绝不能发布增量。host 因而仍是唯一计算地点,客户端可以把每个投影帧视为其 seq 对应的最终结果:seq 较高者胜,后续帧也会修复漏帧。
|
||||
|
||||
### host 侧投影注册表(`dsh-session-projection`,新包)
|
||||
|
||||
|
|
@ -32,12 +32,16 @@ Status: proposed
|
|||
export interface SessionProjectionStateMap {} // host fold states
|
||||
export interface SessionProjectionMap {} // client-visible whole values
|
||||
|
||||
export interface ProjectionInitialization {
|
||||
readonly seedLength: number
|
||||
}
|
||||
|
||||
export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S> {
|
||||
key: K
|
||||
stateSchema: ZodType<S>
|
||||
persist?: boolean // host-only units opt in; client-visible units always persist
|
||||
/** State for the empty log. */
|
||||
init(): S
|
||||
init(initialization: ProjectionInitialization): S
|
||||
/** Pure transition: previous state + one event → next state. The framework drives it; domains hold no subscriptions. */
|
||||
apply(state: S, event: SessionEvent): S
|
||||
/** Client view; omitted for host-only units. */
|
||||
|
|
@ -56,14 +60,15 @@ declare module 'cordis' {
|
|||
|
||||
- `SessionProjectionStateMap` 描述 host 折叠状态;`SessionProjectionMap` 继续作为协议块和 React 钩子经 `import type` 共享的唯一客户端 DTO 表。单元省略 `wire` 即保持 host-only。客户端值如何*渲染*是 slot 体系的事,永远不归投影层管。状态/视图拆分见[已实现的状态与客户端视图记录](../../implemented/architecture/2026-08-19-session-projection-state-and-client-views.zh.md)。
|
||||
- **host 是投影唯一的计算地点。** 框架主动驱动(eager drive)每个已注册的单元:每个已提交的会话事件都经过 `apply`;对某事件不感兴趣的单元返回同一个状态引用,而引用未变(`Object.is`)就不产生任何下游工作。客户端从不折叠领域事件——它们收到的是成品值(基线块 + 下文的推送帧)。这消除了双重实现陷阱(plan 的双事件折叠只在 host 写一遍),也消除了一切客户端侧领域代码。
|
||||
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init()`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
|
||||
- **初始化输入不可变,并与事件来源一致。** `ProjectionDefinition.init(initialization)` 接收规范化后的 Session 事实,而非环境可变状态。当前字段是 `seedLength`:live cell 读取 `session.header.seedLength ?? 0`,cache、history 与 detached restore 则传入提供对应事件的同一次持久 header 读取所得值。fork-sensitive 单元因而可以排除继承前缀,无需在注册表外重复自己的折叠。
|
||||
- **状态永远靠计算得出,绝不入日志。** 日志只存事件;单元的状态住在框架的按会话水位线缓存里(每单元一份 `{state, observedSeq}`),并在后续阶段进入 domain-KV 存储 seam 上的**持久投影缓存(persisted projection cache)**:形如 `(sessionId, key, ver, seq, val)` 的行(`ver` = 单元的 `stateVersion`,`seq` = 水位线,`val` = 状态 JSON)。一行永远不会是错的,至多是陈旧的——其 `seq` 精确说明陈旧到哪。冷读与活读共用同一套读取配方:取缓存状态(或 `init(initialization)`),只对超出其水位线的事件做正向 `apply`,再对结果做 `view`。冷列表(跨全部 workspace 列出每个会话的标题)变成一次索引读,至多外加一小段尾部回放;session-persistence seam 在同一后续阶段为这段尾部补一个按 seq 起读的原语。写入策略:节流(次数/间隔,可配置)外加两个强制点——`turn/end` 与 detach(由活转冷的时刻)。两次写入之间崩溃的代价是尾部回放更长一些,绝不会是值出错。
|
||||
- 领域的输入事件集由领域自己选择:todos 只折叠 `todo/write`;plan 折叠 `plan/mode` 外加它自己的 `/plan` `command/run` 记录(见 plan 一节);goal 折叠 `goal/change` 元数据;会话标题折叠其标题事件(顺带下线专设的 `session/title` 帧与客户端的标题快照表——这是该 seam 收编的第四个手工投影)。
|
||||
- 注册是 effect(disposer 随 fiber 走):插件卸载后其 key 从后续响应中消失,客户端将其读作能力缺失——HMR(热模块替换)语义随之自动成立。key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
|
||||
- 该包拥有 `./invariant`(每个被服务的 key 都有一条存活的注册)。
|
||||
|
||||
### 已交付的消费方:subagent 身份单元
|
||||
|
||||
注册表的两处既有读法已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md)注册了 `subagent` 单元——从 `subagent/descriptor` 按 last-wins 折叠出的持久化 mode/label 身份——`SubagentRuntime.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 则用一次持久化整读的结果调用 `restore({}, events, 0)` 读取。注册表约定不变:没有失败通道、没有新读法——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。
|
||||
注册表的两处既有读法已经服务于本 RFC 协议计划之外的一个已交付消费方:[subagent 列表经投影单元读取身份](../../implemented/architecture/2026-08-06-subagent-list-identity-projection.zh.md)注册了 `subagent` 单元——从 `subagent/descriptor` 按 last-wins 折叠出的持久化 mode/label 身份——`SubagentRuntime.listChildren` 对 live child 经 `snapshot()` 读取(水位缓存,零日志读),对 cold child 则用一次持久化整读的结果调用 `restore({}, events, 0, { seedLength: header.seedLength ?? 0 })` 读取。注册表约定不变:没有失败通道、没有新读法——单元永不抛错,值缺席本身就是信号,缺席如何呈现是该消费方自己的决定。
|
||||
|
||||
### 协议层:历史尾页上的 projections 块
|
||||
|
||||
|
|
@ -148,7 +153,7 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器
|
|||
|
||||
**专设一个 `session.projections` RPC**——不予采纳:基线刷新时刻与尾页拉取精确重合,单独的一元 RPC 只会换来第二次往返、第二个待调和的 seq,以及一个客户端「何时重取」决策——而搭载设计把这个决策整个删掉了。
|
||||
|
||||
**不透明的 `get(agent)` 提供方约定**——否决:计算模型藏在领域内部时,框架永远无法为状态做检查点、无法服务冷会话(没有 agent、没有已加载的日志——`get` 无处可跑)、也无法从日志中段续算。注册 `(init, apply, view)` 单元把驱动权交给框架,领域只留纯数学;有 host 侧行为需求的领域,其服务订阅照旧自持,与投影单元互不牵连。
|
||||
**不透明的 `get(agent)` 提供方约定**——否决:计算模型藏在领域内部时,框架永远无法为状态做检查点、无法服务冷会话(没有 agent、没有已加载的日志——`get` 无处可跑)、也无法从日志中段续算。注册 `(init(initialization), apply, view)` 单元把驱动权交给框架,领域只留纯数学;有 host 侧行为需求的领域,其服务订阅照旧自持,与投影单元互不牵连。
|
||||
|
||||
**为 plan 待定意图专设的仅实时叠加钩子(`live?(agent, base)`)**——不予采纳:它存在的唯一理由是用户的 plan *选择*不在日志里。让选择走标准命令通道后,`command/run` 上了账,待定态成为纯回放量,投影继续由纯折叠与可选客户端视图构成。
|
||||
|
||||
|
|
@ -156,9 +161,9 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器
|
|||
|
||||
**客户端侧折叠(带 `fromEvent` 的按领域投影 cell)**——否决:一旦 plan 的单元要折叠两种事件,客户端 cell 就必须在浏览器里复刻 host 的状态转移逻辑——同一个折叠写两遍、各自演化。推送成品值(标题帧先例的泛化)保住唯一计算地点,并把客户端简化为一个由 seq 把守的通用值仓;领域零客户端代码。
|
||||
|
||||
**对日志尾部的有界反向扫描(absorber 声明)。**不予采纳:现有实现均不支持它,它只服务于「每个事件都携带完整折叠状态」的领域,而持久投影缓存以统一方式覆盖同一冷读需求(缓存行加正向尾部回放——与客户端的基线和追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。
|
||||
**对日志尾部的有界反向扫描(absorber 声明)。**不予采纳:现有实现均不支持它,而且有界后缀通常无法重建仍受更早转换影响的确定性折叠。持久投影缓存能统一覆盖完整值领域与增量领域的冷读需求(缓存行加正向尾部回放——与客户端的基线和追赶、与分页加载是同一套配方)。只有当出现检查点机制服务不了的真实冷读路径时才重议。
|
||||
|
||||
**`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:它的存在只为伺候增量事件。全量值规则让每个领域都是 last-wins;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。
|
||||
**`invalidate` 式 cell(标脏,遇领域事件就重取)**——不予采纳:host 折叠已经把完整值事件或增量事件转换为完整投影帧。重取会重复 seq 协调,并重新引入客户端侧的基线决策;goal 的重取循环、合并逻辑、陈旧读栅栏随之全部消失。
|
||||
|
||||
**把注册表挂到 `ctx.apiProxy` 名下**——不予采纳:会话投影并非 web 专属(TUI、ACP(Agent Client Protocol)、headless 都是未来消费方),且领域包不得依赖 apiproxy 包。独立 seam 还顺带删掉了 #587 从 api-proxy 指向 plan 包的 type-only 导入边。
|
||||
|
||||
|
|
@ -170,11 +175,11 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器
|
|||
|
||||
**保留 `setPlanMode` 专用 RPC**——不予采纳:plan 选择就是一条普通的用户命令;命令通道给它持久记录、flow 渲染、多标签页可见性与准入语义,不需要专设协议方法。Web UI 的交互组件(一个开关)在内部拼出命令行即可。
|
||||
|
||||
**让变更 RPC 的响应喂 cell 状态**——不予采纳:已提交的 mux 事件即刻到达,携带同一个全量值外加 seq;「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。
|
||||
**让变更 RPC 的响应喂 cell 状态**——不予采纳:由已提交事件导出的投影帧会立即到达,并携带带 seq 的完整当前值;「响应喂状态」正是当初逼出 #527 写 revision 栅栏的根源。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- 领域插件把按会话的日志派生状态送达 React,只需写:全量值事件声明、一次 host 侧单元 `register`、自己那份 `SessionProjectionMap` merge、以及 inject 回调——零客户端侧代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。
|
||||
- 领域插件把按会话的日志派生状态送达 React,只需写:自己的持久事件声明、一个具有 `init(initialization)`、`apply` 和完整 `wire.view` 的确定性 host 单元、自己那份 `SessionProjectionMap` merge,以及 inject 回调——零客户端侧折叠代码,不改客户端 `Session` 类、`ConversationSnapshot`、api-proxy 或任何协议 schema 文件。live 与 detached 折叠从提供对应事件的 header 获得相同的规范化初始化事实。
|
||||
- 历史尾页携带 `projections`,其 `asOfSeq` 等于窗口尾部 seq;loadOlder 页永不携带;未装注册表的部署照常返回不带该块的历史,客户端把所有 key 视为缺席。
|
||||
- 陈旧的基线不能覆盖更新的 `session/projection` 帧,重放的帧也不能让值仓倒退(两条路径都做 seq 高者胜测试)。
|
||||
- 在一个标签页执行的斜杠命令,刷新后、在第二个标签页上、恢复之后都在 flow 中渲染出持久节点;未注册的命令渲染通用卡片;命令结果的 composer 通知路径彻底移除。
|
||||
|
|
@ -183,10 +188,10 @@ host 侧命令执行器(`packages/interaction/commands`)在调用处理器
|
|||
|
||||
## 风险
|
||||
|
||||
- **全量值规则是承重结构**:未来某个领域若只记裸增量,就无法凭其最新事件服务消费方,还会让自己的单元复杂化。缓解:该规则写明在本 Note 与投影包的 README 里;单元约定让完整状态在每次转移处都是显式的。
|
||||
- **确定性折叠与完整协议值是承重结构**:读取环境可变状态的单元无法得到一致重建,而客户端增量会迫使浏览器重新承担领域折叠。缓解:不可变的 `ProjectionInitialization`、纯单元约定、schema 与完整 `wire.view` 输出把重建留在 host,并让客户端值仓保持通用。
|
||||
- **单元的同步纪律**:`init`/`apply`/`view` 一旦 await 就会撕裂一致性切面。注册表在文档中申明这条纪律,invariant 配套在可行范围内断言同步性;其余由评审把关。
|
||||
- **注册表的实时增删不做推送**:会话中途加载或卸载领域插件会改变键集,但不会触发任何会话事件、也不会推任何帧;开着的客户端持有陈旧的 key 直到下次尾页拉取(重连、缺口修补、打开)。接受为仅开发期(HMR)的陈旧时窗——日后可以在变更流上加一个注册表变更推送,约定不受影响。
|
||||
- **忙碌会话上的主动驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。按构造,单元的逐事件开销很低(全量值规则),不匹配的事件返回同一引用,且已注册领域的数量很小;若真出现热点路径,可以加按单元的事件类型预过滤,约定不变。
|
||||
- **忙碌会话上的主动驱动开销**:每个已提交事件都要过每个已注册单元的 `apply`。不匹配的事件返回同一引用,且已注册领域的数量很小;若某项增量转换形成热点路径,可以加按单元的事件类型预过滤,约定不变。
|
||||
- **投影载荷膨胀**:每个尾页携带每个已注册的 key。载荷是 UI 量级状态的全量值(一张 todo 清单、一份 goal 快照);将来若某领域的值很大,可以在请求上加逐 key 的 opt-out 或惰性 key,模型本身不用改。
|
||||
- **命令日志体量**:每条斜杠命令两个仅日志事件;上限由人敲命令的频率决定,相对分片体量可忽略不计。
|
||||
- **重新对接的返工**:三个未合入的 PR 要变基到挪动后的地基上。这是基础设施先行的既定代价。
|
||||
|
|
|
|||
|
|
@ -7,3 +7,6 @@
|
|||
|
||||
- id: schedule
|
||||
name: '@deepseek-ai/dsh-schedule'
|
||||
|
||||
- id: ui-schedule
|
||||
disabled: false
|
||||
|
|
|
|||
|
|
@ -1,14 +1,21 @@
|
|||
/** Keyless assembled-Web evidence for conversational Schedule delivery. */
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
|
||||
import { composeEntries, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot'
|
||||
import { JobId } from '@deepseek-ai/dsh-jobs'
|
||||
import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
formatSystemPromptSnapshot,
|
||||
formatToolSchemasSnapshot,
|
||||
} from '@deepseek-ai/dsh-session-snapshot'
|
||||
import {
|
||||
ScheduleId,
|
||||
createEveryScheduleRecord,
|
||||
|
|
@ -21,11 +28,18 @@ import {
|
|||
captureStableAria,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
parseSeedFixture,
|
||||
seedSession,
|
||||
watchConsole,
|
||||
webSnapshotMode,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, conversationContextKey, saveFailureShot } from './support.ts'
|
||||
import {
|
||||
connectFreshWorkspace,
|
||||
conversationContextKey,
|
||||
REPO_ROOT,
|
||||
saveFailureShot,
|
||||
} from './support.ts'
|
||||
|
||||
const MODE = webSnapshotMode()
|
||||
const OVERLAY = fileURLToPath(new URL('../../cli/config/examples/schedule/cordis.yml', import.meta.url))
|
||||
|
|
@ -49,6 +63,25 @@ const EVERY_PROMPTS = ['Check primary metrics', 'Check secondary metrics'] as co
|
|||
const EVERY_REPLY = 'Reminders: Check primary metrics; Check secondary metrics.'
|
||||
const EVERY_INTERVAL_SECONDS = 60 * 60
|
||||
const EVERY_FIXTURE_AGE_MS = 90 * 60 * 1_000
|
||||
const CATALOG_SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/schedule-catalog', import.meta.url))
|
||||
const CATALOG_FIXTURE = join(CATALOG_SNAPSHOT_DIR, 'session.jsonl')
|
||||
const CATALOG_EXPECTED = join(CATALOG_SNAPSHOT_DIR, 'catalog.expected.md')
|
||||
const CATALOG_SYSTEM_PROMPT = join(CATALOG_SNAPSHOT_DIR, 'system-prompt.expected.md')
|
||||
const CATALOG_TOOL_SCHEMAS = join(CATALOG_SNAPSHOT_DIR, 'tool-schemas.expected.json')
|
||||
const BASE_PATCH = fileURLToPath(new URL('../../../packages/bundle/base/cordis.patch.yml', import.meta.url))
|
||||
const WEB_PATCH = fileURLToPath(new URL('../../../packages/bundle/web-app/cordis.patch.yml', import.meta.url))
|
||||
const CATALOG_NOW = Date.parse('2099-08-25T12:00:00.000Z')
|
||||
const CATALOG_SESSION_ID = SessionId('schedule-catalog-web-e2e')
|
||||
const DAMAGED_SESSION_ID = SessionId('schedule-catalog-damaged-web-e2e')
|
||||
const CATALOG_TITLE = 'Active schedule catalog'
|
||||
const DAMAGED_TITLE = 'Damaged schedule catalog'
|
||||
const FORK_TITLE = 'Forked schedule catalog'
|
||||
const LONG_PROMPT_END = 'and preserve every final word without truncation.'
|
||||
const CATALOG_IDS = {
|
||||
after: ScheduleId('catalog-after'),
|
||||
at: ScheduleId('catalog-at'),
|
||||
every: ScheduleId('catalog-every'),
|
||||
} as const
|
||||
|
||||
/** Emit one complete assistant text response. */
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
|
|
@ -196,6 +229,43 @@ function assistantKey(event: SessionEvent<'assistant/message'>): string {
|
|||
return conversationContextKey('assistant-step', `${String(event.data.turn)}:${String(event.data.step)}`)
|
||||
}
|
||||
|
||||
/** Wait until opening a persisted Session publishes its live Agent. */
|
||||
async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<Agent> {
|
||||
const deadline = Date.now() + 30_000
|
||||
for (;;) {
|
||||
const found = scaffold.ctx.agents.get(sessionId)
|
||||
if (found !== undefined) return found
|
||||
if (Date.now() >= deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
/** Expand the first Workspace row and open the named Session. */
|
||||
async function openSession(page: Page, title: string): Promise<void> {
|
||||
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<void>(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
const row = page.getByRole('treeitem', { name: new RegExp(title) })
|
||||
await row.waitFor({ timeout: 15_000 })
|
||||
await row.click()
|
||||
await page.getByRole('navigation', { name: 'Session hierarchy' })
|
||||
.getByRole('button', { name: title, exact: true })
|
||||
.waitFor({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
/** Normalize the run-local paths embedded in one assembled system prompt. */
|
||||
function normalizeScheduleSystemPrompt(value: string, scaffold: WebScaffold, cwd: string): string {
|
||||
return value
|
||||
.split(REPO_ROOT).join('{{sourceRoot}}')
|
||||
.split(scaffold.baseUrl).join('{{webUrl}}')
|
||||
.split(cwd).join('{{cwd}}')
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
||||
let scaffold: WebScaffold
|
||||
let afterHandle: AgentHandle
|
||||
|
|
@ -543,3 +613,317 @@ describe.skipIf(MODE === 'record')('web e2e: conversational reminders', () => {
|
|||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let parentAgent: Agent
|
||||
let backgroundJob: JobId | undefined
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let fixture = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
fixture = await readFile(CATALOG_FIXTURE, 'utf8')
|
||||
scaffold = await launchWebScaffold({
|
||||
extraOverlayPath: OVERLAY,
|
||||
replayFixture: CATALOG_FIXTURE,
|
||||
replayProvidersOnly: true,
|
||||
})
|
||||
await seedSession(scaffold, fixture, CATALOG_SESSION_ID, 'standard')
|
||||
await seedSession(
|
||||
scaffold,
|
||||
fixture.replace(CATALOG_TITLE, DAMAGED_TITLE),
|
||||
DAMAGED_SESSION_ID,
|
||||
'standard',
|
||||
)
|
||||
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
|
||||
await workspace.attachSession(CATALOG_SESSION_ID)
|
||||
await workspace.attachSession(DAMAGED_SESSION_ID)
|
||||
|
||||
// Seed the list cache for both cold Sessions; preserve the damaged Session's
|
||||
// valid row before its later bad tail exercises the open-state visibility gate.
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(CATALOG_SESSION_ID)
|
||||
await scaffold.ctx.sessionProjectionCache.coldSnapshot(DAMAGED_SESSION_ID)
|
||||
|
||||
browser = await chromium.launch()
|
||||
page = await browser.newPage({
|
||||
viewport: { width: 1680, height: 1000 },
|
||||
locale: 'en-US',
|
||||
timezoneId: AT_BROWSER_ZONE,
|
||||
})
|
||||
await page.clock.setFixedTime(new Date(CATALOG_NOW))
|
||||
await page.addInitScript(() => { localStorage.setItem('dsh.locale', 'en') })
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_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<void>(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
await page.getByRole('treeitem', { name: new RegExp(CATALOG_TITLE) }).waitFor({ timeout: 15_000 })
|
||||
await page.getByRole('treeitem', { name: new RegExp(DAMAGED_TITLE) }).waitFor({ timeout: 15_000 })
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
const failures: unknown[] = []
|
||||
if (backgroundJob !== undefined && parentAgent !== undefined) {
|
||||
try {
|
||||
scaffold.ctx.jobs.kill(backgroundJob, parentAgent, 'Schedule catalog test teardown')
|
||||
} catch (error: unknown) {
|
||||
failures.push(error)
|
||||
}
|
||||
}
|
||||
await browser?.close().catch((error: unknown) => failures.push(error))
|
||||
await scaffold?.close().catch((error: unknown) => failures.push(error))
|
||||
if (failures.length === 1) throw failures[0]
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'Schedule catalog teardown failed')
|
||||
})
|
||||
|
||||
it('keeps the base Web client disabled and enables its existing row only through the overlay', () => {
|
||||
const base = composeEntries([
|
||||
loadOverlayPatches('Schedule catalog base roster', BASE_PATCH),
|
||||
loadOverlayPatches('Schedule catalog base roster', WEB_PATCH),
|
||||
])
|
||||
const scheduled = composeEntries([
|
||||
loadOverlayPatches('Schedule catalog overlay roster', BASE_PATCH),
|
||||
loadOverlayPatches('Schedule catalog overlay roster', WEB_PATCH),
|
||||
loadOverlayPatches('Schedule catalog overlay roster', OVERLAY),
|
||||
])
|
||||
expect(base.find(entry => entry.id === 'ui-schedule')).toMatchObject({
|
||||
name: '@deepseek-ai/dsh-client-ui-schedule',
|
||||
disabled: true,
|
||||
})
|
||||
expect(scheduled.find(entry => entry.id === 'ui-schedule')).toMatchObject({
|
||||
name: '@deepseek-ai/dsh-client-ui-schedule',
|
||||
disabled: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the cold and reloaded catalog with exact ordering and metadata', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog'))
|
||||
await openSession(page, CATALOG_TITLE)
|
||||
parentAgent = await liveAgent(scaffold, CATALOG_SESSION_ID)
|
||||
|
||||
const trigger = page.getByRole('button', { name: '3 reminders' })
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.focus()
|
||||
await trigger.press('Enter')
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
await trigger.press('Escape')
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await trigger.press('Space')
|
||||
const catalog = page.getByRole('list', { name: 'Active reminders' })
|
||||
await catalog.waitFor({ timeout: 10_000 })
|
||||
const rows = catalog.getByRole('listitem')
|
||||
expect(await rows.count()).toBe(3)
|
||||
const renderedRows = await rows.evaluateAll(items => items.map(item => ({
|
||||
overdue: item.getAttribute('data-overdue'),
|
||||
text: item.textContent,
|
||||
})))
|
||||
expect(renderedRows.map(row => row.overdue)).toEqual(['true', 'false', 'false'])
|
||||
expect(renderedRows.map(row => row.text?.includes('Review overdue deployment') ?? false))
|
||||
.toEqual([true, false, false])
|
||||
expect(renderedRows.map(row => row.text?.includes('Join release review') ?? false))
|
||||
.toEqual([false, true, false])
|
||||
expect(renderedRows.map(row => row.text?.includes('Check exact cadence') ?? false))
|
||||
.toEqual([false, false, true])
|
||||
expect(await rows.nth(0).locator('[data-schedule-status]').getAttribute('data-schedule-status')).toBe('overdue')
|
||||
expect(await rows.nth(0).textContent()).toContain('Overdue')
|
||||
expect(await rows.nth(1).locator('[data-schedule-status]').getAttribute('data-schedule-status')).toBe('scheduled')
|
||||
expect(await rows.nth(1).textContent()).toContain('Scheduled')
|
||||
expect(await rows.nth(0).textContent()).toContain('Once')
|
||||
expect(await rows.nth(0).textContent()).toContain('1 minute overdue')
|
||||
expect(await rows.nth(1).textContent()).toContain(LONG_PROMPT_END)
|
||||
expect(await rows.nth(1).textContent()).toContain('Once')
|
||||
expect(await rows.nth(1).textContent()).toContain('in 6 minutes')
|
||||
expect(await rows.nth(2).textContent()).toContain('Every 301 seconds')
|
||||
expect(await rows.nth(2).textContent()).toContain('in 6 minutes')
|
||||
expect(await catalog.locator('button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])').count()).toBe(0)
|
||||
expect(await rows.nth(1).locator('[class*="prompt"]').evaluate(element => ({
|
||||
overflowWrap: getComputedStyle(element).overflowWrap,
|
||||
whiteSpace: getComputedStyle(element).whiteSpace,
|
||||
}))).toEqual({ overflowWrap: 'anywhere', whiteSpace: 'normal' })
|
||||
expect(await catalog.evaluate(element => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
const text = await catalog.textContent() ?? ''
|
||||
expect(text).not.toMatch(/catalog-(?:after|at|every)|2099-08-25T|Delete|Retry|Details/)
|
||||
expect((await catalog.boundingBox())?.width).toBe(336)
|
||||
await compareOrRefreshGolden(
|
||||
CATALOG_EXPECTED,
|
||||
await captureStableAria(page, '[aria-label="Active reminders"]', scaffold.workspaceCwd),
|
||||
MODE,
|
||||
)
|
||||
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await page.clock.setFixedTime(new Date(CATALOG_NOW))
|
||||
const reloadedTrigger = page.getByRole('button', { name: '3 reminders' })
|
||||
await reloadedTrigger.waitFor({ timeout: 15_000 })
|
||||
await reloadedTrigger.click()
|
||||
expect(await page.getByRole('list', { name: 'Active reminders' }).getByRole('listitem').count()).toBe(3)
|
||||
}, 60_000)
|
||||
|
||||
it('places the 336px catalog between preset context and Jobs at the 900px dark baseline', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog-dark'))
|
||||
const scheduleTrigger = page.getByRole('button', { name: '3 reminders' })
|
||||
if (await scheduleTrigger.getAttribute('aria-expanded') === 'true') await scheduleTrigger.click()
|
||||
|
||||
const started = await scaffold.ctx.tools.execute({
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
callId: CallId('schedule-catalog-job'),
|
||||
name: 'bash',
|
||||
arguments: {
|
||||
command: 'sleep 45',
|
||||
description: 'Hold a background slot open for Schedule placement',
|
||||
run_in_background: true,
|
||||
},
|
||||
agent: parentAgent,
|
||||
})
|
||||
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
|
||||
const matched = /\bbash-\d+\b/.exec(reported)
|
||||
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
|
||||
backgroundJob = JobId(matched[0])
|
||||
|
||||
const jobTrigger = page.getByRole('button', { name: '1 background job running' })
|
||||
await jobTrigger.waitFor({ timeout: 15_000 })
|
||||
const header = page.getByRole('banner')
|
||||
const preset = header.getByText('Standard mode', { exact: true })
|
||||
const [presetBox, scheduleBox, jobBox] = await Promise.all([
|
||||
preset.boundingBox(),
|
||||
scheduleTrigger.boundingBox(),
|
||||
jobTrigger.boundingBox(),
|
||||
])
|
||||
if (presetBox === null || scheduleBox === null || jobBox === null) {
|
||||
throw new Error('Session header actions did not expose layout boxes')
|
||||
}
|
||||
expect(presetBox.x + presetBox.width).toBeLessThanOrEqual(scheduleBox.x)
|
||||
expect(scheduleBox.x + scheduleBox.width).toBeLessThanOrEqual(jobBox.x)
|
||||
|
||||
await scheduleTrigger.click()
|
||||
const menu = page.getByRole('list', { name: 'Active reminders' })
|
||||
const lightBackground = await menu.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await scheduleTrigger.click()
|
||||
await page.setViewportSize({ width: 900, height: 900 })
|
||||
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
|
||||
await scheduleTrigger.click()
|
||||
const dark = await menu.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
return {
|
||||
background: getComputedStyle(element).backgroundColor,
|
||||
width: box.width,
|
||||
right: box.right,
|
||||
viewport: window.innerWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}
|
||||
})
|
||||
expect(dark.width).toBe(336)
|
||||
expect(dark.right).toBeLessThanOrEqual(dark.viewport)
|
||||
expect(dark.scrollWidth).toBeLessThanOrEqual(dark.viewport)
|
||||
expect(dark.background).not.toBe(lightBackground)
|
||||
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
await scheduleTrigger.click()
|
||||
}, 60_000)
|
||||
|
||||
it('does not inherit parent reminders into a fork', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog-fork'))
|
||||
const forked = await scaffold.ctx.sessionController.fork({ sessionId: CATALOG_SESSION_ID })
|
||||
const childAgent = scaffold.ctx.agents.get(forked.sessionId)
|
||||
if (childAgent === undefined) throw new Error('fork did not publish its Agent')
|
||||
childAgent.session.append('session/title', {
|
||||
title: FORK_TITLE,
|
||||
messageSeqs: [],
|
||||
source: { kind: 'user' },
|
||||
})
|
||||
await expect(scaffold.ctx.sessions.flush(childAgent.session)).resolves.toBe(true)
|
||||
expect(childAgent.session.header.seedLength).toBeGreaterThan(0)
|
||||
expect(scaffold.ctx.sessionProjections.snapshot(childAgent.session).values.schedule).toEqual([])
|
||||
|
||||
await openSession(page, FORK_TITLE)
|
||||
expect(await page.getByRole('button', { name: /reminder/ }).count()).toBe(0)
|
||||
await openSession(page, CATALOG_TITLE)
|
||||
await page.getByRole('button', { name: '3 reminders' }).waitFor({ timeout: 15_000 })
|
||||
}, 60_000)
|
||||
|
||||
it('removes live rows and closes the trigger when the last reminder disappears', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog-live-remove'))
|
||||
const trigger = page.getByRole('button', { name: '3 reminders' })
|
||||
await trigger.click()
|
||||
const catalog = page.getByRole('list', { name: 'Active reminders' })
|
||||
await catalog.waitFor({ timeout: 10_000 })
|
||||
|
||||
for (const id of [CATALOG_IDS.after, CATALOG_IDS.at]) {
|
||||
parentAgent.session.append('schedule/change', { version: 1, operation: 'delete', id })
|
||||
}
|
||||
await expect(scaffold.ctx.sessions.flush(parentAgent.session)).resolves.toBe(true)
|
||||
await page.getByRole('button', { name: '1 reminder' }).waitFor({ timeout: 15_000 })
|
||||
expect(await catalog.getByRole('listitem').count()).toBe(1)
|
||||
expect(await catalog.textContent()).toContain('Check exact cadence')
|
||||
|
||||
parentAgent.session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'delete',
|
||||
id: CATALOG_IDS.every,
|
||||
})
|
||||
await expect(scaffold.ctx.sessions.flush(parentAgent.session)).resolves.toBe(true)
|
||||
await expect.poll(() => page.getByRole('button', { name: /reminder/ }).count(), {
|
||||
timeout: 15_000,
|
||||
}).toBe(0)
|
||||
expect(await page.getByRole('list', { name: 'Active reminders' }).count()).toBe(0)
|
||||
expect(await page.locator('[role="banner"] button:focus').count()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it('hides a prewarmed cached catalog when the Session open fails', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-catalog-damaged'))
|
||||
const parsed = parseSeedFixture(fixture)
|
||||
await scaffold.ctx.sessionPersistence.append(DAMAGED_SESSION_ID, [{
|
||||
type: 'schedule/change',
|
||||
seq: parsed.events.length,
|
||||
time: CATALOG_NOW,
|
||||
data: { version: 1, operation: 'delete', id: ScheduleId('missing') },
|
||||
}])
|
||||
|
||||
await openSession(page, DAMAGED_TITLE)
|
||||
await page.getByText(/Failed to load history:/).waitFor({ timeout: 15_000 })
|
||||
expect(await page.getByRole('button', { name: /reminder/ }).count()).toBe(0)
|
||||
expect(await page.getByRole('button', { name: /Retry/i }).count()).toBe(0)
|
||||
}, 60_000)
|
||||
|
||||
it('pins the Schedule overlay request header and keeps the fixture inventory closed', async () => {
|
||||
parentAgent.followup(createUserMessage({
|
||||
content: [{ type: 'text', text: 'Probe the Schedule overlay request header.' }],
|
||||
source: { kind: 'plugin', plugin: 'schedule-web-e2e' },
|
||||
}))
|
||||
await parentAgent.whenIdle()
|
||||
const request = parentAgent.session.events.findLast(event => event.type === 'request/header')
|
||||
if (request?.type !== 'request/header'
|
||||
|| typeof request.data.header.system !== 'string'
|
||||
|| !Array.isArray(request.data.header.tools)) {
|
||||
throw new Error('Schedule overlay produced no complete request header')
|
||||
}
|
||||
const system = normalizeScheduleSystemPrompt(
|
||||
request.data.header.system,
|
||||
scaffold,
|
||||
parentAgent.session.header.cwd ?? scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(CATALOG_SYSTEM_PROMPT, formatSystemPromptSnapshot(system).trimEnd(), MODE)
|
||||
await compareOrRefreshGolden(
|
||||
CATALOG_TOOL_SCHEMAS,
|
||||
formatToolSchemasSnapshot(request.data.header.tools).trimEnd(),
|
||||
MODE,
|
||||
)
|
||||
await assertFixtureInventory(CATALOG_SNAPSHOT_DIR, [
|
||||
'catalog.expected.md',
|
||||
'session.jsonl',
|
||||
'system-prompt.expected.md',
|
||||
'tool-schemas.expected.json',
|
||||
])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 603f96385b1ab9a804ce2fa17f012343887c929a
|
||||
config-catalog.zh.md: 97ebe046478eb195a99e5c820684cf24f111da20
|
||||
config-catalog.md: 53d6d290b9a32a891488c1af2b539526a319454c
|
||||
config-catalog.zh.md: 785b70ff5abffd6f5336e62c137930f7498de7d1
|
||||
|
|
|
|||
|
|
@ -3335,6 +3335,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
|||
- `@deepseek-ai/dsh-client-ui-plan` ([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-reference` ([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-renderer` ([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-schedule` ([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-session` ([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings` ([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-general` ([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
|
||||
|
|
|
|||
|
|
@ -3337,6 +3337,7 @@ export interface Config {
|
|||
- `@deepseek-ai/dsh-client-ui-plan`([`packages/client/ui-plan/src/index.ts`](../packages/client/ui-plan/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-reference`([`packages/client/ui-reference/src/index.ts`](../packages/client/ui-reference/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-renderer`([`packages/client/ui-renderer/src/index.ts`](../packages/client/ui-renderer/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-schedule`([`packages/client/ui-schedule/src/index.ts`](../packages/client/ui-schedule/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-session`([`packages/client/ui-session/src/index.ts`](../packages/client/ui-session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings`([`packages/client/ui-settings/src/index.ts`](../packages/client/ui-settings/src/index.ts))
|
||||
- `@deepseek-ai/dsh-client-ui-settings-general`([`packages/client/ui-settings-general/src/index.ts`](../packages/client/ui-settings-general/src/index.ts))
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: ea402f19468fe92455f828a23f3478235903776b
|
||||
module-graph.zh.md: af3efe50e535791d4f060a5b598ddf52013b465d
|
||||
module-graph.md: 8cb7314ae0c9002f7619216e71d56f374354abd4
|
||||
module-graph.zh.md: e2631aea9baa1e36262ce2177282fc17eb7e6d27
|
||||
|
|
|
|||
|
|
@ -153,6 +153,7 @@ flowchart TD
|
|||
pkg_client_ui_primitives["client-ui-primitives"]
|
||||
pkg_client_ui_reference["client-ui-reference"]
|
||||
pkg_client_ui_renderer["client-ui-renderer"]
|
||||
pkg_client_ui_schedule["client-ui-schedule"]
|
||||
pkg_client_ui_session["client-ui-session"]
|
||||
pkg_client_ui_settings["client-ui-settings"]
|
||||
pkg_client_ui_settings_general["client-ui-settings-general"]
|
||||
|
|
@ -898,6 +899,7 @@ flowchart TD
|
|||
pkg_schedule --> pkg_llm
|
||||
pkg_schedule --> pkg_session
|
||||
pkg_schedule --> pkg_session_persistence
|
||||
pkg_schedule --> pkg_session_projection
|
||||
pkg_schedule --> pkg_tools
|
||||
pkg_session_checkpoint_policy --> pkg_agent
|
||||
pkg_session_checkpoint_policy --> pkg_invariants
|
||||
|
|
@ -1438,6 +1440,14 @@ flowchart TD
|
|||
pkg_client_ui_plan --> pkg_invariants
|
||||
pkg_client_ui_plan --> pkg_plan_mode
|
||||
pkg_client_ui_plan --> pkg_session
|
||||
pkg_client_ui_schedule --> pkg_api_session_controller
|
||||
pkg_client_ui_schedule --> pkg_client_locale
|
||||
pkg_client_ui_schedule --> pkg_client_ui_conversation
|
||||
pkg_client_ui_schedule --> pkg_client_ui_primitives
|
||||
pkg_client_ui_schedule --> pkg_client_ui_renderer
|
||||
pkg_client_ui_schedule --> pkg_client_ui_session
|
||||
pkg_client_ui_schedule --> pkg_invariants
|
||||
pkg_client_ui_schedule --> pkg_schedule
|
||||
pkg_client_ui_settings_general --> pkg_api_remotes
|
||||
pkg_client_ui_settings_general --> pkg_client_connection
|
||||
pkg_client_ui_settings_general --> pkg_client_locale
|
||||
|
|
@ -1795,7 +1805,7 @@ flowchart TD
|
|||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`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), [`tools`](../packages/core/tools) |
|
||||
| [`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) |
|
||||
| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
|
||||
|
|
@ -1870,6 +1880,7 @@ flowchart TD
|
|||
| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) |
|
||||
| [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | [`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), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
|
||||
|
|
|
|||
|
|
@ -155,6 +155,7 @@ flowchart TD
|
|||
pkg_client_ui_primitives["client-ui-primitives"]
|
||||
pkg_client_ui_reference["client-ui-reference"]
|
||||
pkg_client_ui_renderer["client-ui-renderer"]
|
||||
pkg_client_ui_schedule["client-ui-schedule"]
|
||||
pkg_client_ui_session["client-ui-session"]
|
||||
pkg_client_ui_settings["client-ui-settings"]
|
||||
pkg_client_ui_settings_general["client-ui-settings-general"]
|
||||
|
|
@ -900,6 +901,7 @@ flowchart TD
|
|||
pkg_schedule --> pkg_llm
|
||||
pkg_schedule --> pkg_session
|
||||
pkg_schedule --> pkg_session_persistence
|
||||
pkg_schedule --> pkg_session_projection
|
||||
pkg_schedule --> pkg_tools
|
||||
pkg_session_checkpoint_policy --> pkg_agent
|
||||
pkg_session_checkpoint_policy --> pkg_invariants
|
||||
|
|
@ -1440,6 +1442,14 @@ flowchart TD
|
|||
pkg_client_ui_plan --> pkg_invariants
|
||||
pkg_client_ui_plan --> pkg_plan_mode
|
||||
pkg_client_ui_plan --> pkg_session
|
||||
pkg_client_ui_schedule --> pkg_api_session_controller
|
||||
pkg_client_ui_schedule --> pkg_client_locale
|
||||
pkg_client_ui_schedule --> pkg_client_ui_conversation
|
||||
pkg_client_ui_schedule --> pkg_client_ui_primitives
|
||||
pkg_client_ui_schedule --> pkg_client_ui_renderer
|
||||
pkg_client_ui_schedule --> pkg_client_ui_session
|
||||
pkg_client_ui_schedule --> pkg_invariants
|
||||
pkg_client_ui_schedule --> pkg_schedule
|
||||
pkg_client_ui_settings_general --> pkg_api_remotes
|
||||
pkg_client_ui_settings_general --> pkg_client_connection
|
||||
pkg_client_ui_settings_general --> pkg_client_locale
|
||||
|
|
@ -1797,7 +1807,7 @@ flowchart TD
|
|||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`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), [`tools`](../packages/core/tools) |
|
||||
| [`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) |
|
||||
| [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) |
|
||||
| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) |
|
||||
|
|
@ -1872,6 +1882,7 @@ flowchart TD
|
|||
| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
|
||||
| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) |
|
||||
| [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) |
|
||||
| [`client-ui-schedule`](../packages/client/ui-schedule) | `client` | [`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), [`invariants`](../packages/runtime-diagnostics/invariants), [`schedule`](../packages/schedule/schedule) |
|
||||
| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) |
|
||||
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) |
|
||||
| [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/schedule.md
|
||||
schedule.md: b5abbf4d82e9bdd38c5fd2a396888422c36e91f6
|
||||
schedule.zh.md: dfcbcd567171fbdd97dedeb76760526ac8f88626
|
||||
schedule.md: 2fd5036457219fb4d9117dcb16579bfe5ee7f768
|
||||
schedule.zh.md: 8e3260650bc806632d3a75f7212cd897fc7b550a
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
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 the persistence and lifecycle decisions, [conversational delivery](../../.agents/notes/implemented/simplification/2026-08-09-conversational-schedule-delivery.md) owns the no-receipt boundary, the [explicit time-zone boundary](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md) owns browser-local interpretation, and [bounded fixed-rate Schedule](../../.agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.md) owns recurrence. 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 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 the persistence and lifecycle decisions, [conversational delivery](../../.agents/notes/implemented/simplification/2026-08-09-conversational-schedule-delivery.md) owns the no-receipt boundary, the [read-only Web catalog](../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md) owns active-state presentation, the [explicit time-zone boundary](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.md) owns browser-local interpretation, and [bounded fixed-rate Schedule](../../.agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.md) owns recurrence. 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.
|
||||
|
||||
## Durable records
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ 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 `SessionHeader.seedLength`, so it retains history without adopting the parent Session's active reminders. The `schedule/change` declaration and source location are also indexed in the [persistence catalog](../persistence-catalog.md#schedulechange--log-only).
|
||||
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 `SessionHeader.seedLength`, so it retains history without adopting the parent Session's active reminders. The Schedule projection receives that same boundary through `ProjectionInitialization`, uses the shared transition, and persists both 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).
|
||||
|
||||
## Active views and management
|
||||
|
||||
|
|
@ -177,10 +177,20 @@ 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`.
|
||||
|
||||
## Read-only Web catalog
|
||||
|
||||
When the optional Session projection registry is present, Schedule registers the client-visible `schedule` key whose value is the complete active `ScheduleRecord[]`. Live drive, lazy build, persisted-cache restore, Session history, and detached Subagent reads all initialize the fold from the `seedLength` in the same Session header as the events. A malformed event or checkpoint fails the existing read/open path; no partial active array is published.
|
||||
|
||||
The shipped Web bundle owns a disabled `ui-schedule` row and the package-resolution dependency. The explicit Schedule overlay enables that existing row together with `time-context` and the Schedule Host plugin, so ordinary Web startup keeps the client plugin inactive. After a Session opens successfully, [`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.md) reads the projection through `useProjection('schedule')`; an absent or empty value, or any non-open Session state, renders no entry.
|
||||
|
||||
The header popover is a 336px read-only list. It shows complete plain-text prompts, localized Once or an exact unrounded Every interval, browser-local target time, browser-clock-relative time, and a separate scheduled or overdue status. Overdue rows sort first, then by target, with the projection's create order breaking exact ties. The trigger is the only tab stop; native Enter/Space activation, Escape focus return, outside-pointer dismissal, and no-focus-transfer unmount on the last live removal are the full interaction surface.
|
||||
|
||||
The catalog is current active state, not a receipt or history. It exposes no Schedule id, raw UTC, detail, mutation, retry, toast, or special conversation card. A due reminder still appears only as the ordinary Assistant output described below.
|
||||
|
||||
## Live delivery
|
||||
|
||||
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.
|
||||
|
||||
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 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 or browser renderer. 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.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[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-conversational-schedule-delivery.zh.md) 负责无回执边界,[显式时区边界](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md) 负责浏览器本地解释,[有界固定速率 Schedule](../../.agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.zh.md) 负责重复调度。本页记录 [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久数据形状和面向模型的数据形状;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合、工具行为与确切的提醒 framing。
|
||||
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-conversational-schedule-delivery.zh.md) 负责无回执边界,[只读 Web 目录](../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.zh.md)负责活动状态呈现,[显式时区边界](../../.agents/notes/implemented/simplification/2026-08-09-explicit-schedule-time-zone.zh.md) 负责浏览器本地解释,[有界固定速率 Schedule](../../.agents/notes/implemented/simplification/2026-08-09-bounded-fixed-rate-schedule.zh.md) 负责重复调度。本页记录 [`packages/schedule/schedule/src/types.ts`](../../packages/schedule/schedule/src/types.ts) 中的持久数据形状和面向模型的数据形状;[包 README](../../packages/schedule/schedule/README.zh.md) 负责组合、工具行为与确切的提醒 framing。
|
||||
|
||||
## 持久记录
|
||||
|
||||
|
|
@ -149,7 +149,7 @@ type ScheduleDispatchChange = OneShotScheduleDispatchChange | EveryScheduleDispa
|
|||
type ScheduleChange = ScheduleCreateChange | ScheduleDeleteChange | ScheduleDispatchChange
|
||||
```
|
||||
|
||||
严格 decoder 与 fold 会拒绝未知版本、额外字段、复用 id、不匹配的一次性提醒或 Every dispatch 形状,以及针对非活动记录的 delete 或 dispatch 转换。普通 Session 折叠完整事件流。fork 只折叠 `SessionHeader.seedLength` 位置及其后的事件,因此保留历史,但不会接管父 Session 的活动提醒。`schedule/change` 声明和源码位置也编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。
|
||||
严格 decoder 与 fold 会拒绝未知版本、额外字段、复用 id、不匹配的一次性提醒或 Every dispatch 形状,以及针对非活动记录的 delete 或 dispatch 转换。普通 Session 折叠完整事件流。fork 只折叠 `SessionHeader.seedLength` 位置及其后的事件,因此保留历史,但不会接管父 Session 的活动提醒。Schedule projection 通过 `ProjectionInitialization` 接收同一边界,复用共享 transition,并持久化活动记录与已使用 id 历史,使缓存恢复继续保持严格回放。`schedule/change` 声明和源码位置也编入[持久化目录](../persistence-catalog.zh.md#schedulechange--log-only)。
|
||||
|
||||
## 活动视图与管理
|
||||
|
||||
|
|
@ -177,10 +177,20 @@ 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`。
|
||||
|
||||
## 只读 Web 目录
|
||||
|
||||
可选 Session projection 注册表存在时,Schedule 会注册客户端可见的 `schedule` key,其值是完整的活动 `ScheduleRecord[]`。live 驱动、惰性构建、持久化缓存恢复、Session history 与 detached Subagent 读取,都会从提供对应事件的同一个 Session header 中取得 `seedLength` 来初始化 fold。畸形事件或 checkpoint 会使既有读取/打开路径失败;系统不会发布部分活动数组。
|
||||
|
||||
shipped Web bundle 拥有默认 disabled 的 `ui-schedule` row 与包解析依赖。显式 Schedule overlay 会把该既有 row 与 `time-context`、Schedule Host 插件一同启用,因此普通 Web 启动仍不会激活该 client 插件。Session 成功打开后,[`dsh-client-ui-schedule`](../../packages/client/ui-schedule/README.zh.md)通过 `useProjection('schedule')` 读取投影;值缺失或为空,以及任何非 open 的 Session 状态,都不会渲染入口。
|
||||
|
||||
header 弹层是一个 336px 的只读列表。它显示完整纯文本 prompt、本地化的「单次」或未经舍入的精确 Every 间隔、浏览器本地目标时间、按浏览器时钟派生的相对时间,以及独立的 scheduled/overdue 状态。逾期行优先,其后按目标排序;完全并列时以 projection 的创建顺序打破。触发器是唯一 Tab stop;原生 Enter/Space 激活、Escape 回焦、外部指针关闭,以及最后一条 live 记录移除时不迁移焦点的卸载,就是完整交互面。
|
||||
|
||||
该目录是当前活动状态,不是回执或历史。它不公开 Schedule id、原始 UTC、详情、mutation、Retry、Toast 或特殊对话卡片。到期提醒仍只通过下文所述的普通 Assistant 输出出现。
|
||||
|
||||
## Live 交付
|
||||
|
||||
进程内 owner 根据持久 fold 派生最早的 timer,并在每次有界等待后重新读取墙钟。cold Session 不执行任何工作;重新打开后会重建 timer,并使已经过去的目标进入 overdue 状态。到期的一次性提醒享有优先级,每次只进入一个后续轮次。当没有一次性提醒到期时,所有 overdue 的 Every 记录会组成上述单个批次。
|
||||
|
||||
到期工作会先等待 Agent 完全 idle 并认领 maintenance phase,再重新折叠状态、采样本次判断、将一个 `followup()` 排入队列,并追加对应的 dispatch 变更。它绝不会调用 `steer()`,也绝不会中断当前轮次。
|
||||
|
||||
获得准入的一次性提醒或固定速率批次会启动一个普通的后续轮次,且只通过普通对话 transcript(文本记录)出现;Schedule 不提供独立的持久 Web 回执或浏览器渲染器。如果 framing 构造或同步队列准入失败,则不会记录 dispatch,提醒仍保持活动。队列准入后、持久 dispatch 前的狭窄崩溃窗口可能使提醒内容在恢复后重复,因此该边界提供的是尽力而为的至少一次交付,而非恰好一次交付。
|
||||
获得准入的一次性提醒或固定速率批次会启动一个普通的后续轮次,且只通过普通对话 transcript(文本记录)出现;Schedule 不提供独立的持久 Web 回执。上面的只读活动目录绝不表示交付成功。如果 framing 构造或同步队列准入失败,则不会记录 dispatch,提醒仍保持活动。队列准入后、持久 dispatch 前的狭窄崩溃窗口可能使提醒内容在恢复后重复,因此该边界提供的是尽力而为的至少一次交付,而非恰好一次交付。
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md
|
||||
session-projection.md: 8614cf3466eff8deb360a7667ff6b4e37da1bf6e
|
||||
session-projection.zh.md: b9e213b60e0df9de54e4c4805d11e64ca866dad2
|
||||
session-projection.md: b7539e3fbf2caec9ac006843fe4993693f4c980c
|
||||
session-projection.zh.md: da8f13d69ff1b951bf3c715c82bb4a50928ad600
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ Source: [`packages/session/session-projection/src/index.ts`](../../packages/sess
|
|||
|
||||
`SessionProjectionStateMap` is the merge-extensible table of host fold states, while `SessionProjectionMap` retains the client-visible whole values. A domain contributes one `ProjectionDefinition` per state key; a `wire` block makes that key client-visible, and rendering belongs to the slot system, never this layer:
|
||||
|
||||
```ts type-equiv
|
||||
/** Minimal immutable Session fact supplied when a projection state is initialized. */
|
||||
interface ProjectionInitialization {
|
||||
/** Number of inherited leading events that belong to a fork's source Session. */
|
||||
readonly seedLength: number
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One domain's state-driven computation unit: a pure synchronous fold plus
|
||||
|
|
@ -29,9 +37,10 @@ interface ProjectionDefinition<
|
|||
stateSchema: ZodType<S>
|
||||
/**
|
||||
* State for the empty log.
|
||||
* @param initialization - immutable Session facts needed to establish the fold boundary.
|
||||
* @returns the initial state.
|
||||
*/
|
||||
init(): NoInfer<S>
|
||||
init(initialization: ProjectionInitialization): NoInfer<S>
|
||||
/**
|
||||
* Pure transition: previous state + one committed event → next state. A
|
||||
* unit uninterested in an event MUST return the same state reference — an
|
||||
|
|
@ -62,7 +71,7 @@ interface ProjectionDefinition<
|
|||
}
|
||||
```
|
||||
|
||||
The whole-value event rule is load-bearing: a state-carrying log event carries the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
|
||||
The load-bearing rule is a deterministic synchronous fold with a complete wire value. A domain may own whole-value events or incremental transitions, but it validates and folds them on the Host; clients never replay those events or receive a delta. `init` receives immutable normalized Session facts rather than reaching into ambient state. Today that input is `seedLength`, which lets fork-sensitive domains ignore the inherited prefix while ordinary Sessions receive zero.
|
||||
|
||||
## The snapshot and the change feed
|
||||
|
||||
|
|
@ -98,7 +107,7 @@ type ProjectionChangeListener = (
|
|||
|
||||
## The registry: `ctx.sessionProjections`
|
||||
|
||||
`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
|
||||
`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, calls `init({ seedLength: session.header.seedLength ?? 0 })` before folding the in-memory log on first touch (event or read). Detached cache, history, and Subagent restore paths pass the same normalized value from the header returned with their persisted event read. Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; duplicate keys throw. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected.
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
|
@ -274,11 +283,12 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
|
|||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
||||
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
||||
* @param initialization - normalized facts from the stored header returned by the same read.
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
*/
|
||||
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, initialization: ProjectionInitialization, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
```
|
||||
|
||||
Types: [Session](session.md) · [SessionEvent](session.md)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@
|
|||
|
||||
`SessionProjectionStateMap` 是 host 侧折叠状态的 merge-extensible 类型表,`SessionProjectionMap` 则继续表示客户端可见的全量值。领域为每个状态 key 贡献一个 `ProjectionDefinition`;`wire` 块使该 key 对客户端可见,渲染归 slot 体系管,永远不归本层:
|
||||
|
||||
```ts type-equiv
|
||||
/** Minimal immutable Session fact supplied when a projection state is initialized. */
|
||||
interface ProjectionInitialization {
|
||||
/** Number of inherited leading events that belong to a fork's source Session. */
|
||||
readonly seedLength: number
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* One domain's state-driven computation unit: a pure synchronous fold plus
|
||||
|
|
@ -29,9 +37,10 @@ interface ProjectionDefinition<
|
|||
stateSchema: ZodType<S>
|
||||
/**
|
||||
* State for the empty log.
|
||||
* @param initialization - immutable Session facts needed to establish the fold boundary.
|
||||
* @returns the initial state.
|
||||
*/
|
||||
init(): NoInfer<S>
|
||||
init(initialization: ProjectionInitialization): NoInfer<S>
|
||||
/**
|
||||
* Pure transition: previous state + one committed event → next state. A
|
||||
* unit uninterested in an event MUST return the same state reference — an
|
||||
|
|
@ -62,7 +71,7 @@ interface ProjectionDefinition<
|
|||
}
|
||||
```
|
||||
|
||||
全量值事件规则是承重结构:携带状态的日志事件携带的是变更后的完整状态,绝不是裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
|
||||
承重规则是确定性同步 fold 与完整 wire 值。领域可以拥有全量值事件,也可以拥有增量 transition,但它会在 Host 上校验并折叠这些事件;客户端既不回放这些事件,也不会收到 delta。`init` 接收不可变且规范化的 Session 事实,而不是读取环境状态。当前输入是 `seedLength`,使 fork-sensitive 领域可以忽略继承前缀,普通 Session 则收到零。
|
||||
|
||||
## 快照与变更流
|
||||
|
||||
|
|
@ -98,7 +107,7 @@ type ProjectionChangeListener = (
|
|||
|
||||
## 注册表:`ctx.sessionProjections`
|
||||
|
||||
`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
|
||||
`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都会在首次触达(事件或读取)时先调用 `init({ seedLength: session.header.seedLength ?? 0 })`,再折叠内存日志。detached cache、history 与 Subagent restore 路径从同一次持久事件读取返回的 header 传入同一规范值。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 重复直接 throw。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
|
|
@ -274,11 +283,12 @@ viewCheckpoint(checkpoint: ProjectionCheckpoint): Partial<SessionProjectionMap>
|
|||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
||||
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
||||
* @param initialization - normalized facts from the stored header returned by the same read.
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
*/
|
||||
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, initialization: ProjectionInitialization, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }
|
||||
```
|
||||
|
||||
Types: [Session](session.zh.md) · [SessionEvent](session.zh.md)
|
||||
|
|
|
|||
|
|
@ -184,7 +184,11 @@ export class SessionHistoryController {
|
|||
const throughSeq = events.at(-1)?.seq ?? -1
|
||||
const snapshot = source.kind === 'attached' && source.session.seq - 1 === throughSeq
|
||||
? registry.snapshot(source.session)
|
||||
: registry.restore({}, events, 0).snapshot
|
||||
: registry.restore({}, events, 0, {
|
||||
seedLength: (source.kind === 'attached'
|
||||
? source.session.header.seedLength
|
||||
: source.header.seedLength) ?? 0,
|
||||
}).snapshot
|
||||
return {
|
||||
asOfSeq: snapshot.asOfSeq,
|
||||
// Projection definitions validate whole JSON values before snapshot publication.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client'
|
|||
import { ProjectionValueStore } from '../src/client/sessions/projection-store.ts'
|
||||
import { Session } from '../src/client/sessions/session.ts'
|
||||
import { SessionManager } from '../src/client/sessions/manager.ts'
|
||||
import { FakeApiClient, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts'
|
||||
import { entries, plainTurn } from './event-script.client.ts'
|
||||
|
||||
// Test-domain keys merged into the projection map (the Service Definition package's
|
||||
|
|
@ -101,6 +101,23 @@ describe('Session projection value semantics', () => {
|
|||
})
|
||||
|
||||
describe('Session tail-page seeding', () => {
|
||||
it('retains a prewarmed projection when opening the Session fails', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const projections = new ProjectionValueStore()
|
||||
projections.apply('test/marks', { marks: ['cached'] }, 5)
|
||||
const session = new Session(SID, api, fakeRemote(api), { projections })
|
||||
api.onHistory = () => Promise.resolve(err({
|
||||
code: 'session-not-found',
|
||||
message: 'gone',
|
||||
details: { sessionId: SID },
|
||||
}))
|
||||
|
||||
await session.open()
|
||||
|
||||
expect(session.getSnapshot().openState).toBe('error')
|
||||
expect(session.projections.get('test/marks')).toEqual({ marks: ['cached'] })
|
||||
})
|
||||
|
||||
it('seeds the store from a history response carrying a projections block', async () => {
|
||||
const api = new FakeApiClient()
|
||||
const session = new Session(SID, api, fakeRemote(api))
|
||||
|
|
|
|||
|
|
@ -423,23 +423,38 @@ describe('SessionHistoryController', () => {
|
|||
|
||||
it('uses attached and detached projection cuts and isolates a child projection failure', async () => {
|
||||
const attached = await setup()
|
||||
const session = attached.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } })
|
||||
session.append('turn/start', { turn: 1 })
|
||||
const session = attached.ctx.sessions.create(SessionId('projected'), {
|
||||
seed: [event('turn/start', 0, { turn: 1 })],
|
||||
meta: { cwd: '/workspace', seedLength: 1 },
|
||||
})
|
||||
const snapshot = vi.fn(() => ({ asOfSeq: 0, values: { title: 'attached' } }))
|
||||
attached.ctx.provide('sessionProjections', { snapshot, restore: vi.fn() } as never)
|
||||
const attachedRestore = vi.fn(() => ({ snapshot: { asOfSeq: 0, values: { title: 'attached-prefix' } } }))
|
||||
attached.ctx.provide('sessionProjections', { snapshot, restore: attachedRestore } as never)
|
||||
await expect(attached.transport.page({
|
||||
address: { kind: 'session', sessionId: session.id },
|
||||
throughSeq: 0,
|
||||
throughSeq: 1,
|
||||
}, signal())).resolves.toMatchObject({ projections: { asOfSeq: 0, values: { title: 'attached' } } })
|
||||
expect(snapshot).toHaveBeenCalledWith(session)
|
||||
await attached.transport.page({
|
||||
address: { kind: 'session', sessionId: session.id }, throughSeq: 0,
|
||||
}, signal())
|
||||
expect(attachedRestore).toHaveBeenCalledWith({}, expect.any(Array), 0, { seedLength: 1 })
|
||||
|
||||
const legacy = attached.ctx.sessions.create(SessionId('projected-legacy'), { meta: { cwd: '/workspace' } })
|
||||
legacy.append('turn/start', { turn: 1 })
|
||||
await attached.transport.page({
|
||||
address: { kind: 'session', sessionId: legacy.id }, throughSeq: -1,
|
||||
}, signal())
|
||||
expect(attachedRestore).toHaveBeenCalledWith({}, [], 0, { seedLength: 0 })
|
||||
|
||||
const older = await attached.transport.page({
|
||||
address: { kind: 'session', sessionId: session.id }, throughSeq: 0, beforeSeq: 1,
|
||||
address: { kind: 'session', sessionId: session.id }, throughSeq: 1, beforeSeq: 1,
|
||||
}, signal())
|
||||
expect('projections' in older).toBe(false)
|
||||
|
||||
const detached = await setup()
|
||||
const coldId = SessionId('projected-cold')
|
||||
const header = { version: 0, id: coldId, createdAt: 1, cwd: '/workspace' }
|
||||
const header = { version: 0, id: coldId, createdAt: 1, cwd: '/workspace', seedLength: 3 }
|
||||
cold(detached.ctx, header, [event('turn/start', 0, { turn: 1 })])
|
||||
const restore = vi.fn(() => ({ snapshot: { asOfSeq: 0, values: { title: 'cold' } } }))
|
||||
detached.ctx.provide('sessionProjections', { snapshot: vi.fn(), restore } as never)
|
||||
|
|
@ -447,7 +462,7 @@ describe('SessionHistoryController', () => {
|
|||
address: { kind: 'session', sessionId: coldId },
|
||||
throughSeq: 0,
|
||||
}, signal())).resolves.toMatchObject({ projections: { values: { title: 'cold' } } })
|
||||
expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0)
|
||||
expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0, { seedLength: 3 })
|
||||
|
||||
const failed = await setup()
|
||||
cold(failed.ctx, header, [event('turn/start', 0, { turn: 1 })])
|
||||
|
|
|
|||
|
|
@ -269,6 +269,13 @@
|
|||
- id: ui-reference
|
||||
name: '@deepseek-ai/dsh-client-ui-reference'
|
||||
|
||||
# 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.
|
||||
- id: ui-schedule
|
||||
name: '@deepseek-ai/dsh-client-ui-schedule'
|
||||
disabled: true
|
||||
|
||||
# Background jobs: the session-header list over the jobsBySession mirror.
|
||||
- id: ui-jobs
|
||||
name: '@deepseek-ai/dsh-client-ui-jobs'
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@
|
|||
"@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-schedule": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-user-questions": "workspace:^",
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/README.md
|
||||
README.md: eaf01b282ead3c4638435e3d02d6a803ad1faa15
|
||||
README.zh.md: 2ff4b1529f07e0f31b3d06a9577d3c480dd34916
|
||||
README.md: 4a59c9e0af610aed667e2ec3c9fe3fdfec5b9cde
|
||||
README.zh.md: 447205a6a6c6fbc20f32da70398283a0edb0c18a
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
|||
| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. |
|
||||
| [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source. |
|
||||
| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. |
|
||||
| [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog. |
|
||||
| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header. |
|
||||
| [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces. |
|
||||
| [`ui-permission/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access. |
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
|||
| [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用。 |
|
||||
| [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source。 |
|
||||
| [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent(子 agent)导航、子级 transcript(文本记录)的状态和内联引用。 |
|
||||
| [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读 header 目录中列出当前 Session 的活动提醒。 |
|
||||
| [`ui-jobs/`](ui-jobs/README.zh.md) | 在会话标题栏列出当前会话的后台任务。 |
|
||||
| [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择。 |
|
||||
| [`ui-permission/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
|
|
|
|||
6
packages/client/ui-schedule/README.i18n.yaml
Normal file
6
packages/client/ui-schedule/README.i18n.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-schedule/README.md
|
||||
README.md: 12cdad12195627d1d6098e29911589e5a6068f81
|
||||
README.zh.md: e5e6c2f60a98c6fdcaf2bc9c6508ee161e2351e3
|
||||
27
packages/client/ui-schedule/README.md
Normal file
27
packages/client/ui-schedule/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# @deepseek-ai/dsh-client-ui-schedule
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Read-only Web catalog for the current Session's active Schedule records. The plugin contributes one `conversation.session.header.actions` entry after the static Agent and Subagent context and before the background Jobs entry. It reads `openState` through the standard Session hook and the complete `schedule` value through `useProjection`; it issues no RPC and receives no mutation callback.
|
||||
|
||||
The trigger exists only while the Session is successfully open and the projection contains at least one record. Its popover is 336px wide, scrolls vertically when needed, and shows each prompt as complete wrapping plain text. Every row renders status separately from three metadata fields: localized Once or the largest exact whole unit for an Every interval, browser-local target time, and browser-clock-relative time. Intervals are never rounded. Overdue records sort first, followed by `scheduledAt`; exact ties preserve the projection's creation order.
|
||||
|
||||
Only the native button is in the tab order. Enter and Space use its normal button activation, Escape closes the popover and restores trigger focus, and an outside pointer press dismisses it. If a live projection update removes the final record, the component closes and unmounts without moving focus to another header action.
|
||||
|
||||
This catalog is not a delivery receipt. It exposes no Schedule id, raw UTC value, details, mutation, retry, toast, or Schedule-specific transcript card. Due reminders still arrive only as ordinary Assistant conversation output, and a failed Session open hides even a previously cached catalog value.
|
||||
|
||||
The behavior and ownership boundary are recorded in the [read-only Web Schedule catalog Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.md).
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this package renders a completed client projection for a human and never changes prompts, messages, schemas, streams, or tool results.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; the package never assembles or sends provider requests.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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, deleting, and inspecting model-facing delivery state remain with the Schedule tools; this package deliberately has no action controls.
|
||||
27
packages/client/ui-schedule/README.zh.md
Normal file
27
packages/client/ui-schedule/README.zh.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# @deepseek-ai/dsh-client-ui-schedule
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
当前 Session 活动 Schedule 记录的只读 Web 目录。插件向 `conversation.session.header.actions` 贡献一个入口,位置在静态 Agent 与 Subagent 上下文之后、后台 Jobs 入口之前。它通过标准 Session hook 读取 `openState`,通过 `useProjection` 读取完整的 `schedule` 值;不发 RPC,也不接收 mutation callback。
|
||||
|
||||
只有 Session 已成功打开且 projection 至少包含一条记录时才显示触发器。弹层宽 336px,内容过高时在内部纵向滚动;每条 prompt 都以可完整换行的纯文本显示。每行把状态与三项元数据分开呈现:本地化的「单次」或 Every 间隔可整除的最大完整单位、浏览器本地目标时间,以及按浏览器时钟派生的相对时间。间隔绝不舍入。逾期记录排在最前,随后按 `scheduledAt` 排序;完全并列时保留 projection 中的创建顺序。
|
||||
|
||||
只有原生按钮进入 Tab 顺序。Enter 与 Space 使用按钮的正常激活行为;Escape 关闭弹层并把焦点交还触发器;在外部按下指针也会关闭。若 live projection 更新移除了最后一条记录,组件会关闭并卸载,但不会主动把焦点移到另一个 header action。
|
||||
|
||||
该目录不是交付回执。它不显示 Schedule id、原始 UTC、详情、mutation、Retry、Toast 或 Schedule 专属 transcript 卡片。到期提醒仍只通过普通 Assistant 对话输出到达;Session 打开失败时,即使此前缓存过目录值,也会隐藏入口。
|
||||
|
||||
行为与归属边界记录在[只读 Web Schedule 目录 Agent Note](../../../.agents/notes/implemented/feature/2026-08-25-read-only-web-schedule-catalog.zh.md)中。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为本包只为人类渲染已经完成的客户端 projection,从不改变 prompt、消息、schema、流或工具结果。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包从不组装或发送 provider 请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅含活动记录**——终结性的 delete 与 dispatch 转换会移除对应行;普通 transcript 仍是唯一的提醒交付历史。
|
||||
- **浏览器派生时间**——本地时间与相对时间标签使用当前浏览器的 locale、时区和时钟。它们是呈现值,不是持久 Schedule 事实。
|
||||
- **只读界面**——创建、删除和检查面向模型的交付状态仍归 Schedule 工具;本包有意不提供操作控件。
|
||||
81
packages/client/ui-schedule/package.json
Normal file
81
packages/client/ui-schedule/package.json
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-schedule",
|
||||
"description": "Read-only active Schedule catalog in the Web Session header",
|
||||
"version": "0.1.1-rc.2",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-ui-conversation",
|
||||
"@deepseek-ai/dsh-client-ui-primitives"
|
||||
],
|
||||
"platform": "web"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/client/ui-schedule"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-schedule": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-api-session-controller": "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-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-schedule": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/react": "~18.3.1",
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
.root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-height: 28px;
|
||||
padding: 3px 2px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
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);
|
||||
}
|
||||
|
||||
.trigger svg {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.trigger > svg:last-child {
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.triggerOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: 336px;
|
||||
max-width: min(336px, calc(100vw - 32px));
|
||||
max-height: min(420px, calc(100vh - 140px));
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
list-style: none;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 54px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.rowOverdue {
|
||||
background: var(--dsw-alias-state-warn-tertiary);
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.statusDot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
.rowOverdue .status {
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
|
||||
.rowOverdue .statusDot {
|
||||
background: var(--dsw-alias-state-warn-primary);
|
||||
}
|
||||
|
||||
.prompt {
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 11px;
|
||||
line-height: 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.relative,
|
||||
.relativeOverdue {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.relativeOverdue {
|
||||
color: var(--dsw-alias-state-warn-label);
|
||||
}
|
||||
196
packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx
Normal file
196
packages/client/ui-schedule/src/client/ScheduleCatalogAction.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
|
||||
import { IconChevronDownOutline14, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { NS } from './locales.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<typeof NS>
|
||||
|
||||
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 UNIT_SECONDS: readonly { unit: TimeUnit; seconds: number }[] = [
|
||||
{ unit: 'day', seconds: 86_400 },
|
||||
{ unit: 'hour', seconds: 3_600 },
|
||||
{ unit: 'minute', seconds: 60 },
|
||||
SECOND_UNIT,
|
||||
]
|
||||
|
||||
/** Minimal clock glyph kept private to this one feature. */
|
||||
function ScheduleClockIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<circle cx="7" cy="7" r="5.75" stroke="currentColor" strokeWidth="1.25" />
|
||||
<path d="M7 3.75V7.2L9.25 8.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/** Localized unit word for one integral magnitude. */
|
||||
function unitLabel(unit: TimeUnit, value: number, t: TranslateNS<typeof NS>): 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<typeof NS>,
|
||||
): 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<typeof NS>,
|
||||
): 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) {
|
||||
const openState = useSession(snapshot => snapshot.openState)
|
||||
const projected = useProjection('schedule')
|
||||
const records = projected ?? EMPTY_RECORDS
|
||||
const visible = openState === 'open' && records.length > 0
|
||||
const [open, setOpen] = useState(false)
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useDismissOnOutsidePointer(rootRef, open, setOpen)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setNow(Date.now())
|
||||
const timer = setInterval(() => { setNow(Date.now()) }, SECOND_MS)
|
||||
return () => { clearInterval(timer) }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (visible || !open) return
|
||||
setOpen(false)
|
||||
}, [visible, open])
|
||||
|
||||
const rows = useMemo(() => orderScheduleRecords(records, now), [records, now])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const countKey = records.length === 1 ? 'trigger.one' : 'trigger.other'
|
||||
const countLabel = t(countKey, { count: records.length })
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
if (event.key !== 'Escape' || !open) return
|
||||
event.preventDefault()
|
||||
setOpen(false)
|
||||
triggerRef.current?.focus()
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className={css.root} onKeyDown={onKeyDown}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-expanded={open}
|
||||
aria-label={countLabel}
|
||||
onClick={() => {
|
||||
setNow(Date.now())
|
||||
setOpen(current => !current)
|
||||
}}
|
||||
>
|
||||
<ScheduleClockIcon />
|
||||
<span className={css.count}>{countLabel}</span>
|
||||
<IconChevronDownOutline14 className={open ? css.triggerOpen : undefined} />
|
||||
</button>
|
||||
{open
|
||||
? (
|
||||
<ul className={css.menu} aria-label={t('list.aria')}>
|
||||
{rows.map((record) => {
|
||||
const overdue = Date.parse(record.scheduledAt) <= now
|
||||
return (
|
||||
<li
|
||||
key={record.id}
|
||||
className={overdue ? `${css.row} ${css.rowOverdue}` : css.row}
|
||||
data-schedule-reminder=""
|
||||
data-overdue={overdue ? 'true' : 'false'}
|
||||
>
|
||||
<span
|
||||
className={css.status}
|
||||
data-schedule-status={overdue ? 'overdue' : 'scheduled'}
|
||||
>
|
||||
<span className={css.statusDot} aria-hidden="true" />
|
||||
<span>{t(overdue ? 'status.overdue' : 'status.scheduled')}</span>
|
||||
</span>
|
||||
<span className={css.prompt}>{record.prompt}</span>
|
||||
<span className={css.metadata}>
|
||||
<span>{formatScheduleFrequency(record, t)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatScheduleLocalTime(record.scheduledAt)}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span className={overdue ? css.relativeOverdue : css.relative}>
|
||||
{formatScheduleRelative(record.scheduledAt, now, t)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
packages/client/ui-schedule/src/client/index.ts
Normal file
35
packages/client/ui-schedule/src/client/index.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/** Browser half of the read-only Schedule catalog. */
|
||||
|
||||
import type { Context as ClientContext } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type {} from '@deepseek-ai/dsh-schedule/client'
|
||||
import { ScheduleCatalogAction } from './ScheduleCatalogAction.tsx'
|
||||
import { en, NS, zh, type ScheduleCatalogKey } from './locales.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Read-only active Schedule catalog copy. */
|
||||
'schedule.catalog': ScheduleCatalogKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Required services for locale registration and header-slot contribution. */
|
||||
export const inject = ['slots', 'locale']
|
||||
|
||||
/** Register the dictionaries and Session-header catalog action. */
|
||||
export function apply(ctx: ClientContext): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-schedule: dictionaries')
|
||||
ctx.slots.inject(
|
||||
'conversation.session.header.actions',
|
||||
() => ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'schedule-catalog',
|
||||
// Static Session identity precedes this entry; background jobs follow it.
|
||||
order: 10,
|
||||
locale: NS,
|
||||
}, ScheduleCatalogAction),
|
||||
)
|
||||
}
|
||||
51
packages/client/ui-schedule/src/client/locales.ts
Normal file
51
packages/client/ui-schedule/src/client/locales.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/** `schedule.catalog` namespace dictionaries. */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'schedule.catalog'
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh = {
|
||||
'trigger.one': '{count} 个提醒',
|
||||
'trigger.other': '{count} 个提醒',
|
||||
'list.aria': '活动提醒',
|
||||
'status.scheduled': '等待中',
|
||||
'status.overdue': '已逾期',
|
||||
'frequency.once': '单次',
|
||||
'frequency.every': '{value}{unit}一次',
|
||||
'unit.day.one': '天',
|
||||
'unit.day.other': '天',
|
||||
'unit.hour.one': '小时',
|
||||
'unit.hour.other': '小时',
|
||||
'unit.minute.one': '分钟',
|
||||
'unit.minute.other': '分钟',
|
||||
'unit.second.one': '秒',
|
||||
'unit.second.other': '秒',
|
||||
'relative.now': '现在到期',
|
||||
'relative.future': '{value}{unit}后',
|
||||
'relative.overdue': '已逾期 {value}{unit}',
|
||||
} as const
|
||||
|
||||
/** English dictionary, key-identical to the Chinese source of truth. */
|
||||
export const en: Record<ScheduleCatalogKey, string> = {
|
||||
'trigger.one': '{count} reminder',
|
||||
'trigger.other': '{count} reminders',
|
||||
'list.aria': 'Active reminders',
|
||||
'status.scheduled': 'Scheduled',
|
||||
'status.overdue': 'Overdue',
|
||||
'frequency.once': 'Once',
|
||||
'frequency.every': 'Every {value} {unit}',
|
||||
'unit.day.one': 'day',
|
||||
'unit.day.other': 'days',
|
||||
'unit.hour.one': 'hour',
|
||||
'unit.hour.other': 'hours',
|
||||
'unit.minute.one': 'minute',
|
||||
'unit.minute.other': 'minutes',
|
||||
'unit.second.one': 'second',
|
||||
'unit.second.other': 'seconds',
|
||||
'relative.now': 'Due now',
|
||||
'relative.future': 'in {value} {unit}',
|
||||
'relative.overdue': '{value} {unit} overdue',
|
||||
}
|
||||
|
||||
/** Key domain of the Schedule catalog namespace. */
|
||||
export type ScheduleCatalogKey = keyof typeof zh
|
||||
6
packages/client/ui-schedule/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-schedule/src/css-modules.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
7
packages/client/ui-schedule/src/index.ts
Normal file
7
packages/client/ui-schedule/src/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* Read-only Schedule catalog plugin, node half. The empty apply keeps the
|
||||
* optional browser feature addressable from the host-owned Loader overlay.
|
||||
*/
|
||||
|
||||
/** Host plugin body — Schedule catalog behavior exists only in the browser entry. */
|
||||
export function apply(): void {}
|
||||
20
packages/client/ui-schedule/src/invariant.ts
Normal file
20
packages/client/ui-schedule/src/invariant.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/** Package-owned invariant companion for the read-only Schedule catalog. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-schedule'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-schedule-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: the package owns no mutable cross-plugin state. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
101
packages/client/ui-schedule/tests/browser-plugin.client.spec.ts
Normal file
101
packages/client/ui-schedule/tests/browser-plugin.client.spec.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { apply, inject } from '../src/client/index.ts'
|
||||
import { apply as applyNode } from '../src/index.ts'
|
||||
import * as ScheduleInvariant from '../src/invariant.ts'
|
||||
import { en, NS, zh } from '../src/client/locales.ts'
|
||||
|
||||
const Empty = () => null
|
||||
|
||||
function headerEntryIds(ctx: Context): (string | undefined)[] {
|
||||
return ctx.slots
|
||||
.entries('conversation.session.header.actions')
|
||||
.map(entry => entry.options.id)
|
||||
}
|
||||
|
||||
async function baseContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotRegistry).await()
|
||||
ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
|
||||
ctx.provide('remote', { $on: () => () => {} } as never)
|
||||
ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
|
||||
await ctx.plugin({ inject: localeInject, apply: applyLocale }).await()
|
||||
return ctx
|
||||
}
|
||||
|
||||
function declareHeader(ctx: Context): () => void {
|
||||
return ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, Empty)
|
||||
}
|
||||
|
||||
describe('ui-schedule browser half', () => {
|
||||
it('declares only the services used by registration', () => {
|
||||
expect(inject).toEqual(['slots', 'locale'])
|
||||
})
|
||||
|
||||
it('waits for the header declaration, orders between static context and Jobs, and tears down', async () => {
|
||||
const ctx = await baseContext()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(headerEntryIds(ctx)).toEqual([])
|
||||
|
||||
const header = declareHeader(ctx)
|
||||
ctx.slots.register({
|
||||
name: 'conversation.session.header.actions', id: 'agent-preset', order: -10,
|
||||
}, Empty)
|
||||
ctx.slots.register({
|
||||
name: 'conversation.session.header.actions', id: 'job-list', order: 20,
|
||||
}, Empty)
|
||||
expect(headerEntryIds(ctx)).toEqual(['agent-preset', 'schedule-catalog', 'job-list'])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(headerEntryIds(ctx)).toEqual(['agent-preset', 'job-list'])
|
||||
header()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('registers both dictionaries and releases them with its fiber', async () => {
|
||||
const ctx = await baseContext()
|
||||
declareHeader(ctx)
|
||||
ctx.locale.setLocale('zh')
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
const translate = ctx.locale.bind(NS)
|
||||
expect(translate('list.aria')).toBe(zh['list.aria'])
|
||||
ctx.locale.setLocale('en')
|
||||
expect(translate('list.aria')).toBe(en['list.aria'])
|
||||
expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
|
||||
|
||||
await fiber.dispose()
|
||||
expect(translate('list.aria')).not.toBe(en['list.aria'])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui-schedule node and invariant halves', () => {
|
||||
it('keeps the node half inert', () => {
|
||||
expect(applyNode).not.toThrow()
|
||||
})
|
||||
|
||||
it('reserves package ownership under its invariant companion name', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantRegistry, { enabled: true })
|
||||
const fiber = ctx.plugin(ScheduleInvariant)
|
||||
await fiber.await()
|
||||
expect(ScheduleInvariant.name).toBe('client-ui-schedule-invariant')
|
||||
expect(ScheduleInvariant.inject).toEqual(['invariants'])
|
||||
expect(() => {
|
||||
Reflect.apply(ctx.emit.bind(ctx), undefined, ['unrelated/event'])
|
||||
}).not.toThrow()
|
||||
await fiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
// @vitest-environment jsdom
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { SessionSnapshot, UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { ScheduleRecord } from '@deepseek-ai/dsh-schedule/client'
|
||||
import { ScheduleId } from '@deepseek-ai/dsh-schedule'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import {
|
||||
formatScheduleFrequency,
|
||||
formatScheduleLocalTime,
|
||||
formatScheduleRelative,
|
||||
orderScheduleRecords,
|
||||
ScheduleCatalogAction,
|
||||
type ScheduleCatalogActionProps,
|
||||
} from '../src/client/ScheduleCatalogAction.tsx'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
const SESSION = 'schedule-session' as SessionId
|
||||
const START = Date.parse('2026-08-25T12:00:00.000Z')
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(START)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function record(
|
||||
id: string,
|
||||
kind: ScheduleRecord['kind'],
|
||||
scheduledAt: number,
|
||||
options: { prompt?: string; everySeconds?: number } = {},
|
||||
): ScheduleRecord {
|
||||
const common = {
|
||||
id: ScheduleId(id),
|
||||
kind,
|
||||
prompt: options.prompt ?? id,
|
||||
scheduledAt: new Date(scheduledAt).toISOString(),
|
||||
}
|
||||
if (kind === 'after') return { ...common, kind, afterSeconds: 30 }
|
||||
if (kind === 'every') return { ...common, kind, everySeconds: options.everySeconds ?? 300 }
|
||||
return { ...common, kind }
|
||||
}
|
||||
|
||||
function sessionSnapshot(openState: SessionSnapshot['openState']): SessionSnapshot {
|
||||
return {
|
||||
sessionId: SESSION,
|
||||
queue: [],
|
||||
running: false,
|
||||
subagent: null,
|
||||
removed: false,
|
||||
openState,
|
||||
openError: null,
|
||||
hasMore: false,
|
||||
loadingOlder: false,
|
||||
promptError: null,
|
||||
blank: false,
|
||||
lastAgentError: null,
|
||||
promptAttempted: false,
|
||||
awaitingFirstTurn: false,
|
||||
}
|
||||
}
|
||||
|
||||
function props(
|
||||
records: readonly ScheduleRecord[] | undefined,
|
||||
openState: SessionSnapshot['openState'] = 'open',
|
||||
dictionary: typeof zh | typeof en = en,
|
||||
): ScheduleCatalogActionProps {
|
||||
const snapshot = sessionSnapshot(openState)
|
||||
const useSession = <T,>(select: (value: SessionSnapshot) => T): T => select(snapshot)
|
||||
const useProjection = ((key: string, select?: (value: unknown) => unknown) => {
|
||||
const value = key === 'schedule' ? records : undefined
|
||||
return select === undefined ? value : select(value)
|
||||
}) as UseProjection
|
||||
return {
|
||||
sessionId: SESSION,
|
||||
useSession,
|
||||
useProjection,
|
||||
t: makeTranslate(dictionary),
|
||||
} as unknown as ScheduleCatalogActionProps
|
||||
}
|
||||
|
||||
function prompts(): string[] {
|
||||
return within(screen.getByRole('list', { name: en['list.aria'] }))
|
||||
.getAllByRole('listitem')
|
||||
.map(item => item.querySelector('[class*="prompt"]')?.textContent ?? '')
|
||||
}
|
||||
|
||||
describe('ScheduleCatalogAction visibility', () => {
|
||||
it('renders only for a successfully opened Session with a non-empty projection', () => {
|
||||
const active = [record('active', 'after', START + 60_000)]
|
||||
const view = render(<ScheduleCatalogAction {...props(undefined)} />)
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
|
||||
view.rerender(<ScheduleCatalogAction {...props([], 'open')} />)
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
for (const state of ['cold', 'loading', 'error'] as const) {
|
||||
view.rerender(<ScheduleCatalogAction {...props(active, state)} />)
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
}
|
||||
|
||||
view.rerender(<ScheduleCatalogAction {...props(active)} />)
|
||||
expect(screen.getByRole('button', { name: '1 reminder' })).toBeDefined()
|
||||
})
|
||||
|
||||
it('closes and removes the trigger when the last live record disappears', () => {
|
||||
const active = [record('active', 'after', START + 60_000)]
|
||||
const view = render(<><button type="button">Neighbor</button><ScheduleCatalogAction {...props(active)} /></>)
|
||||
const trigger = screen.getByRole('button', { name: '1 reminder' })
|
||||
fireEvent.click(trigger)
|
||||
trigger.focus()
|
||||
expect(screen.getByRole('list', { name: en['list.aria'] })).toBeDefined()
|
||||
|
||||
view.rerender(<><button type="button">Neighbor</button><ScheduleCatalogAction {...props([])} /></>)
|
||||
expect(screen.queryByRole('button', { name: '1 reminder' })).toBeNull()
|
||||
expect(document.activeElement).toBe(document.body)
|
||||
expect(screen.getByRole('button', { name: 'Neighbor' })).not.toBe(document.activeElement)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScheduleCatalogAction rows', () => {
|
||||
it('shows only prompt and the three derived metadata fields, with overdue records first', () => {
|
||||
const rawPrompt = '<img src=x onerror=alert(1)> Keep the complete long reminder prompt visible without truncation.'
|
||||
const overdue = record('hidden-id', 'after', START - 60_000, { prompt: rawPrompt })
|
||||
const every = record('every-id', 'every', START + 300_000, { prompt: 'Check metrics', everySeconds: 300 })
|
||||
const at = record('at-id', 'at', START + 3_600_000, { prompt: 'Join meeting' })
|
||||
render(<ScheduleCatalogAction {...props([at, every, overdue])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(prompts()).toEqual([rawPrompt, 'Check metrics', 'Join meeting'])
|
||||
const rows = screen.getAllByRole('listitem')
|
||||
expect(rows[0]?.getAttribute('data-overdue')).toBe('true')
|
||||
expect(rows[1]?.getAttribute('data-overdue')).toBe('false')
|
||||
expect(rows[0]?.querySelector('[data-schedule-status]')?.getAttribute('data-schedule-status')).toBe('overdue')
|
||||
expect(rows[1]?.querySelector('[data-schedule-status]')?.getAttribute('data-schedule-status')).toBe('scheduled')
|
||||
expect(rows[0]?.textContent).toContain('Overdue')
|
||||
expect(rows[1]?.textContent).toContain('Scheduled')
|
||||
expect(rows[0]?.textContent).toContain('Once')
|
||||
expect(rows[0]?.textContent).toContain('1 minute overdue')
|
||||
expect(rows[1]?.textContent).toContain('Every 5 minutes')
|
||||
expect(rows[1]?.textContent).toContain('in 5 minutes')
|
||||
expect(rows[2]?.textContent).toContain('Once')
|
||||
expect(rows[2]?.textContent).toContain('in 1 hour')
|
||||
expect(rows[2]?.textContent).toContain(formatScheduleLocalTime(at.scheduledAt))
|
||||
expect(document.querySelector('img')).toBeNull()
|
||||
const text = screen.getByRole('list').textContent ?? ''
|
||||
expect(text).not.toContain('hidden-id')
|
||||
expect(text).not.toContain(overdue.scheduledAt)
|
||||
expect(text).not.toMatch(/Delete|Retry|Details/)
|
||||
expect(within(screen.getByRole('list')).queryAllByRole('button')).toHaveLength(0)
|
||||
expect(rows.every(row => row.tabIndex === -1)).toBe(true)
|
||||
})
|
||||
|
||||
it('renders exact recurring units without rounding and localizes both dictionaries', () => {
|
||||
const tEn = makeTranslate(en)
|
||||
const tZh = makeTranslate(zh)
|
||||
const samples = [
|
||||
[86_400, 'Every 1 day', '1天一次'],
|
||||
[172_800, 'Every 2 days', '2天一次'],
|
||||
[3_600, 'Every 1 hour', '1小时一次'],
|
||||
[7_200, 'Every 2 hours', '2小时一次'],
|
||||
[300, 'Every 5 minutes', '5分钟一次'],
|
||||
[301, 'Every 301 seconds', '301秒一次'],
|
||||
] as const
|
||||
for (const [seconds, english, chinese] of samples) {
|
||||
const item = record(String(seconds), 'every', START + 1_000, { everySeconds: seconds })
|
||||
expect(formatScheduleFrequency(item, tEn)).toBe(english)
|
||||
expect(formatScheduleFrequency(item, tZh)).toBe(chinese)
|
||||
}
|
||||
expect(formatScheduleFrequency(record('once', 'at', START + 1_000), tZh)).toBe('单次')
|
||||
expect(tZh('status.scheduled')).toBe('等待中')
|
||||
expect(tZh('status.overdue')).toBe('已逾期')
|
||||
})
|
||||
|
||||
it('derives relative seconds, minutes, hours, days, and the exact due boundary', () => {
|
||||
const t = makeTranslate(en)
|
||||
expect(formatScheduleRelative(new Date(START).toISOString(), START, t)).toBe('Due now')
|
||||
expect(formatScheduleRelative(new Date(START + 500).toISOString(), START, t)).toBe('in 1 second')
|
||||
expect(formatScheduleRelative(new Date(START + 61_000).toISOString(), START, t)).toBe('in 2 minutes')
|
||||
expect(formatScheduleRelative(new Date(START - 3_600_000).toISOString(), START, t)).toBe('1 hour overdue')
|
||||
expect(formatScheduleRelative(new Date(START - 172_800_000).toISOString(), START, t)).toBe('2 days overdue')
|
||||
})
|
||||
|
||||
it('keeps equal targets stable and updates overdue status as the browser clock advances', () => {
|
||||
const first = record('first', 'at', START + 500)
|
||||
const second = record('second', 'at', START + 500)
|
||||
expect(orderScheduleRecords([first, second], START).map(item => item.id)).toEqual(['first', 'second'])
|
||||
expect(orderScheduleRecords([
|
||||
record('future', 'at', START + 1_000),
|
||||
record('overdue', 'at', START - 1_000),
|
||||
], START).map(item => item.id)).toEqual(['overdue', 'future'])
|
||||
|
||||
render(<ScheduleCatalogAction {...props([first, second])} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(screen.getAllByRole('listitem').every(row => row.getAttribute('data-overdue') === 'false')).toBe(true)
|
||||
act(() => { vi.advanceTimersByTime(1_000) })
|
||||
expect(screen.getAllByRole('listitem').every(row => row.getAttribute('data-overdue') === 'true')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ScheduleCatalogAction dismissal', () => {
|
||||
const active = [record('active', 'after', START + 60_000)]
|
||||
|
||||
it('closes on Escape, restores trigger focus, and ignores unrelated or closed keys', () => {
|
||||
render(<ScheduleCatalogAction {...props(active)} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.keyDown(trigger, { key: 'Escape' })
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('toggles from the trigger and dismisses only on an outside pointer press', () => {
|
||||
render(<ScheduleCatalogAction {...props(active)} />)
|
||||
const trigger = screen.getByRole('button')
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.pointerDown(screen.getByRole('list', { name: en['list.aria'] }))
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('true')
|
||||
fireEvent.pointerDown(document.body)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(trigger)
|
||||
fireEvent.click(trigger)
|
||||
expect(trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
42
packages/client/ui-schedule/tsconfig.json
Normal file
42
packages/client/ui-schedule/tsconfig.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../api/session-controller/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../schedule/schedule"
|
||||
},
|
||||
{
|
||||
"path": "../locale"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../ui-session"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-schedule/tsdown.config.ts
Normal file
3
packages/client/ui-schedule/tsdown.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-schedule', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
|
|
@ -1143,6 +1143,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
|||
occupants: [
|
||||
'client-ui-agent-preset AgentPresetLabel id \'agent-preset\'',
|
||||
'client-ui-jobs JobListAction id \'job-list\'',
|
||||
'client-ui-schedule ScheduleCatalogAction id \'schedule-catalog\'',
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
|
|
|
|||
|
|
@ -1466,9 +1466,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
|||
returns: 'whole values per key with a usable row; empty when none.',
|
||||
},
|
||||
{
|
||||
signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
|
||||
signature: 'restore( checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number, initialization: ProjectionInitialization, ): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint }',
|
||||
description: 'Cold read: fold every persisted unit over a stored log suffix, seeding each from its checkpoint row when usable — the one read recipe (cached state + forward tail replay + `view`) applied without a live `Session`. Call with the events returned by a persistence `readFrom(id, restoreFloor(checkpoint))` and that same floor as `baseSeq`; the floor\'s one-below anchor makes the supplied end honest, so a shrunk log is detected here. A row is usable iff its `ver` matches the live unit\'s `stateVersion`, it does not predate `baseSeq` (`seq >= baseSeq - 1`), and it does not claim events past the supplied end (`seq <= endSeq`); an unusable row is discarded and its key refolds from `init` — which is only sound over the full log, so a discarded row with `baseSeq > 0` throws (the caller re-reads from seq 0, e.g. after a crash-repair truncation shrank the log below a row\'s watermark).',
|
||||
parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }],
|
||||
parameters: [{ name: 'checkpoint', description: 'persisted rows for one session (possibly stale or empty).' }, { name: 'events', description: 'the stored events with `seq >= baseSeq`, in seq order.' }, { name: 'baseSeq', description: 'the seq `events` starts at (its first event\'s seq when non-empty).' }, { name: 'initialization', description: 'normalized facts from the stored header returned by the same read.' }],
|
||||
returns: 'the snapshot cut at the supplied log end (`asOfSeq` is the last supplied event\'s seq, `baseSeq - 1` for an empty tail) plus the refreshed checkpoint rows at that cut, ready for a durable write-back.',
|
||||
},
|
||||
],
|
||||
|
|
@ -4183,7 +4183,11 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
|||
},
|
||||
{
|
||||
name: 'ProjectionDefinition',
|
||||
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {\n key: K;\n stateSchema: ZodType<S>;\n init(): NoInfer<S>;\n apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType<SessionProjectionMap[K]>;\n view(state: NoInfer<S>): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}',
|
||||
declaration: 'export interface ProjectionDefinition<K extends keyof SessionProjectionStateMap, S extends SessionProjectionStateMap[K] = SessionProjectionStateMap[K]> {\n key: K;\n stateSchema: ZodType<S>;\n init(initialization: ProjectionInitialization): NoInfer<S>;\n apply(state: NoInfer<S>, event: SessionEvent): NoInfer<S>;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType<SessionProjectionMap[K]>;\n view(state: NoInfer<S>): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionInitialization',
|
||||
declaration: 'export interface ProjectionInitialization {\n readonly seedLength: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'ProjectionSnapshot',
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/schedule/README.md
|
||||
README.md: 2fac190cc19f40e5d5e87acacaddce970e4b8474
|
||||
README.zh.md: af4cd96dcb5f095a7ea83556e5f73eca532d0dee
|
||||
README.md: 67d54465a5a32c7264f1624e697b068b0a421d8a
|
||||
README.zh.md: cc662272a3ad488e19b1bcfd6657ced09b3975c0
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The Schedule family owns reminders whose durable state lives in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel.
|
||||
The Schedule family owns reminders whose durable state lives in the original Session log. A process-local owner waits only while that Session has a live root Agent; cold Sessions resume overdue work when they become live again and never imply an external notification channel. An optional Session projection publishes the complete active-record set for read-only clients without changing that delivery boundary.
|
||||
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `schedule/` | Versioned Schedule events and fold, model-facing create/list/delete tools, and a live root-Agent timer owner | — |
|
||||
| `schedule/` | Versioned Schedule events and fold, the active-record Session projection, model-facing create/list/delete tools, and a live root-Agent timer owner | — |
|
||||
|
||||
The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream; due work enters the same conversation through the Agent's ordinary follow-up queue.
|
||||
The package deliberately exposes no public Schedule service or mutable database. Tools and runtime append to the Session stream; due work enters the same conversation through the Agent's ordinary follow-up queue. The browser presentation is owned separately by [`dsh-client-ui-schedule`](../client/ui-schedule/README.md), whose catalog is current state rather than a delivery receipt.
|
||||
|
||||
See [Session-local Schedule](../../docs/subsystems/schedule.md) for the durable record, transition, view, and delivery contracts.
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志中。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但这不意味着存在外部通知渠道。
|
||||
Schedule 家族负责管理提醒,其持久状态保存在原 Session 日志中。进程内 owner 只会在该 Session 拥有 live 根 Agent 时等待;cold Session 再次 live 后会恢复逾期工作,但这不意味着存在外部通知渠道。可选的 Session projection 会向只读客户端发布完整活动记录集合,而不改变该交付边界。
|
||||
|
||||
| 包 | 职责 | ctx 键 |
|
||||
|---|---|---|
|
||||
| `schedule/` | 版本化 Schedule 事件与 fold、面向模型的创建/列出/删除工具,以及 live 根 Agent timer owner | 无 |
|
||||
| `schedule/` | 版本化 Schedule 事件与 fold、活动记录 Session projection、面向模型的创建/列出/删除工具,以及 live 根 Agent timer owner | 无 |
|
||||
|
||||
本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。
|
||||
本包有意不公开 Schedule service 或可变数据库。工具与 runtime 向 Session stream 追加事件;到期工作通过 Agent 的普通 follow-up 队列进入同一对话。浏览器呈现由 [`dsh-client-ui-schedule`](../client/ui-schedule/README.zh.md) 单独拥有;其目录表示当前状态,而非交付回执。
|
||||
|
||||
有关持久记录、转换、视图与交付约定,请参阅[仅限 Session 内的 Schedule](../../docs/subsystems/schedule.zh.md)。
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/schedule/schedule/README.md
|
||||
README.md: f56c689198b7e17a85f43a1ecb6e5f1527da6a91
|
||||
README.zh.md: 7180565370d83a7207c73aa9a2539219974dace0
|
||||
README.md: ed5917ec3d6cbf617fe18bcc6898dec6cc417a32
|
||||
README.zh.md: bda4e4d4861cebcc7c836a2bcf6e760c92789c88
|
||||
|
|
|
|||
|
|
@ -10,13 +10,21 @@ Load this function plugin after `ctx.sessions`, `ctx.agents`, `ctx.tools`, `ctx.
|
|||
|
||||
Time-context is not a Schedule dependency. A composition may mount `@deepseek-ai/dsh-time-context` so the model can interpret natural language in the browser's request-local zone, as the official Schedule Web overlay does. The model must still pass an explicit offset or `time_zone` to `schedule_create`; Schedule never imports or infers from model context.
|
||||
|
||||
Session projection is optional. When `ctx.sessionProjections` exists, the plugin registers the strict `schedule` unit and exposes the complete active `ScheduleRecord[]`; a headless composition without the registry keeps the same tools and runtime. The browser-safe record vocabulary is available from the type-only `@deepseek-ai/dsh-schedule/client` export. The shipped Web bundle resolves the `ui-schedule` client package through a disabled row, and the explicit Schedule overlay enables that row alongside the Host Schedule services.
|
||||
|
||||
Every operation that reads or decides from the Schedule fold first awaits `ctx.sessions.flush(session)`. A missing, rejected, or detached persistence path returns `persistence_uncertain`; it never turns an unconfirmed live suffix into a list or not-found answer. A successful create or actual delete also awaits a post-append barrier before confirming the mutation.
|
||||
|
||||
## Durable state
|
||||
|
||||
The package owns the strict version-1 `schedule/change` create, delete, and dispatch union. Every create record contains a stable Session-local `ScheduleId`, the trimmed prompt, and a four-digit-year RFC 3339 UTC `scheduledAt`. An `after` record also stores `afterSeconds`; an `at` record stores no copy of its submitted offset, local calendar fields, or interpreting zone; an `every` record stores `everySeconds` and treats `scheduledAt` as the earliest creation-anchor-aligned occurrence not yet dispatched. Delete and one-shot dispatch carry only the id. Every dispatch adds `acceptedAt`, from which replay advances directly to the first anchor-aligned target after that decision time.
|
||||
|
||||
Replay rejects unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The package's `./invariant` companion applies the same policy to existing logs and candidate events.
|
||||
Replay rejects unknown versions, extra fields, reused ids, mismatched one-shot or Every dispatch shapes, and delete or dispatch transitions against inactive records. Normal Sessions fold the complete log. A fork folds only `session.events.slice(session.header.seedLength ?? 0)`, so it does not inherit its parent's reminders. The Schedule projection receives the same normalized `seedLength` when its state is initialized and ignores the inherited prefix while using the same strict transition function. The package's `./invariant` companion applies the same policy to existing logs and candidate events.
|
||||
|
||||
## Client projection
|
||||
|
||||
The optional `schedule` Session projection persists `{ seedLength, active, seenIds }` as strict plain JSON and publishes only the complete `active` array. Its checkpoint schema reuses the durable Schedule decoder, rejects duplicate or inconsistent ids, and fails the existing Session open or read path on corrupt durable events instead of publishing a partial catalog. Live lazy build, event-driven build, cached restore, history reads, and detached Subagent reads all receive `seedLength` from the same Session header that supplied their events.
|
||||
|
||||
The projection carries durable records only. It does not persist or transmit `scheduled` versus `overdue`, localized text, relative time, browser-local time, sorting state, open state, or delivery receipts. [`dsh-client-ui-schedule`](../../client/ui-schedule/README.md) derives those presentation values from the full array and the viewing browser's clock.
|
||||
|
||||
## Absolute-time input
|
||||
|
||||
|
|
@ -40,7 +48,7 @@ The live owner derives the earliest target from the durable fold. It splits wait
|
|||
|
||||
An overdue reminder first checkpoints persistence. If a turn or another maintenance task owns the Agent, `runMaintenance()` rejects the idle-phase claim; the record stays active and the owner retries after `whenIdle()`. A successful maintenance task refolds, samples one decision time, builds the appropriate fixed framing, synchronously queues `followup()`, and appends dispatch before releasing the phase. A one-shot appends its id. Each Every record in a batch appends its id plus the same `acceptedAt`; integer arithmetic selects that record's latest due creation-anchor-aligned occurrence and advances it directly to the first future target. Missed intervals are never enumerated or replayed, distinct overdue records each contribute one occurrence, and there is no shared recurrence gate. Waking input remains parked until release, after which the owner checkpoints dispatch.
|
||||
|
||||
The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt or Schedule-specific browser UI. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer.
|
||||
The follow-up opens a normal later turn after the Agent becomes fully idle; it never steers or interrupts the current conversation. Its assistant output appears through the ordinary transcript, with no independent receipt. The optional Web catalog shows only currently active records and never represents dispatch success. Dispatch means the follow-up was queued and recorded, not that the model succeeded or the user read the answer.
|
||||
|
||||
Framing or synchronous follow-up failure writes no dispatch. An append failure faults that owner because the message may already be queued; a barrier rejection leaves dispatch pending for a later ordinary preflight. Agent or plugin disposal cancels timers, stops new work, and awaits in-flight preflights and idle waits without deleting durable records.
|
||||
|
||||
|
|
@ -115,3 +123,4 @@ The batch appends after existing history and preserves its reusable prefix. Its
|
|||
- **Latest-only catch-up** — an overdue Every record contributes only its latest due occurrence, so Schedule never replays a missed backlog.
|
||||
- **Narrow crash duplicate window** — a crash after synchronous follow-up admission but before the dispatch checkpoint can repeat the reminder; the package does not claim model completion, user acknowledgement, or exactly-once effects.
|
||||
- **Load-order boundary** — the plugin does not scan or adopt Agents that were already live when it loaded.
|
||||
- **Catalog is read-only current state** — the optional Web surface has no history, mutation, retry, or acknowledgement semantics; terminal records disappear and delivery remains ordinary conversation output.
|
||||
|
|
|
|||
|
|
@ -10,13 +10,21 @@
|
|||
|
||||
Time-context 不是 Schedule 的依赖。组合可以挂载 `@deepseek-ai/dsh-time-context`,使模型能够按浏览器的请求本地时区解释自然语言;官方 Schedule Web overlay 正是如此。模型仍必须向 `schedule_create` 传入显式偏移量或 `time_zone`;Schedule 绝不会从模型上下文中导入或推断该值。
|
||||
|
||||
Session projection 是可选能力。`ctx.sessionProjections` 存在时,插件会注册严格的 `schedule` 单元并公开完整的活动 `ScheduleRecord[]`;不带注册表的 headless 组合仍保留相同工具与 runtime。浏览器安全的记录词汇由纯类型出口 `@deepseek-ai/dsh-schedule/client` 提供。shipped Web bundle 通过默认 disabled 的 row 解析 `ui-schedule` client 包,显式 Schedule overlay 再与 Host Schedule 服务一起启用该 row。
|
||||
|
||||
每项从 Schedule 折叠结果读取或作出判断的操作,都会先等待 `ctx.sessions.flush(session)`。持久化路径缺失、拒绝或已分离时,操作返回 `persistence_uncertain`;它绝不会把未经确认的 live 后缀当成列表或未找到结果。成功创建或实际删除后,还会等待追加后的持久化 barrier(屏障)再确认变更。
|
||||
|
||||
## 持久状态
|
||||
|
||||
此包拥有严格的版本 1 `schedule/change` create、delete 与 dispatch 联合。每条 create 记录都包含稳定的会话本地 `ScheduleId`、已 trim 的提示词,以及使用四位年份的 RFC 3339 UTC `scheduledAt`。`after` 记录还会存储 `afterSeconds`;`at` 记录不会保留所提交的偏移量、本地日历字段或解释该值时所用的时区;`every` 记录存储 `everySeconds`,并把 `scheduledAt` 视为尚未 dispatch 的最早一个创建锚点对齐发生时点。delete 与一次性 dispatch 只携带 id。Every dispatch 还会添加 `acceptedAt`;回放会据此直接推进到该决策时点之后的第一个锚点对齐目标。
|
||||
|
||||
回放会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的一次性或 Every dispatch,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。
|
||||
回放会拒绝未知版本、额外字段、重复使用的 id、形状不匹配的一次性或 Every dispatch,以及针对非活动记录的 delete 或 dispatch 转换。普通会话折叠完整日志。fork 只折叠 `session.events.slice(session.header.seedLength ?? 0)`,因此不会继承父会话的提醒。Schedule projection 初始化时接收同一个已规范化的 `seedLength`,忽略继承前缀,并复用同一严格 transition 函数。此包的 `./invariant` 配套模块会对现有日志和候选事件应用相同策略。
|
||||
|
||||
## 客户端 projection
|
||||
|
||||
可选的 `schedule` Session projection 以严格纯 JSON 持久化 `{ seedLength, active, seenIds }`,只发布完整的 `active` 数组。其 checkpoint schema 复用持久 Schedule decoder,拒绝重复或内部不一致的 id;损坏的持久事件会使既有 Session 打开或读取路径失败,而不会发布部分目录。live 惰性构建、事件驱动构建、缓存恢复、history 读取与 detached Subagent 读取,都从提供对应事件的同一个 Session header 接收 `seedLength`。
|
||||
|
||||
projection 只携带持久记录,不持久化或传输 `scheduled`/`overdue`、本地化文案、相对时间、浏览器本地时间、排序状态、开合状态或交付回执。[`dsh-client-ui-schedule`](../../client/ui-schedule/README.zh.md) 从完整数组与查看方浏览器时钟派生这些呈现值。
|
||||
|
||||
## 绝对时间输入
|
||||
|
||||
|
|
@ -40,7 +48,7 @@ live owner 从持久折叠结果派生最早的目标。它会拆分超过 Node
|
|||
|
||||
overdue 提醒首先为持久化建立检查点。如果 agent 已被某个轮次或另一项 maintenance task 占用,`runMaintenance()` 会拒绝对 idle phase 的认领;记录会保持活动,owner 会在 `whenIdle()` 后重试。获准执行的 maintenance task 会重新折叠、采样一个决策时点、构造相应的固定 framing、同步将 `followup()` 入队,并在释放 phase 前追加 dispatch。一次性提醒只追加 id。批次中的每条 Every 记录都会追加其 id 和相同的 `acceptedAt`;整数运算会选择该记录最新一个已到期且与创建锚点对齐的发生时点,并将记录直接推进到第一个未来目标。系统绝不会枚举或回放错过的间隔;每条不同的逾期记录各贡献一个发生时点,并且不存在共享的周期性准入门控。触发唤醒的 input 会保持 parked,直到 phase 释放;随后 owner 为 dispatch 建立检查点。
|
||||
|
||||
Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript(文本记录)显示,不存在独立回执或 Schedule 专属浏览器 UI。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。
|
||||
Agent 完全 idle 后,follow-up 会开启一个普通的后续轮次;它绝不会中途引导或中断当前对话。assistant 输出通过普通 transcript(文本记录)显示,不存在独立回执。可选 Web 目录只显示当前活动记录,绝不表示 dispatch 成功。dispatch 表示 follow-up 已入队并被记录,不表示模型成功或用户已读取回答。
|
||||
|
||||
framing 构造或同步 follow-up 失败不会写入 dispatch。追加失败会使该 owner 进入故障状态,因为消息可能已经入队;barrier 拒绝会把 dispatch 留给后续普通 preflight。agent 或插件执行资源释放时,会取消 timer、停止新工作,并等待进行中的 preflight 与 idle wait,且不会删除持久记录。
|
||||
|
||||
|
|
@ -115,3 +123,4 @@ reminders_json: <JSON.stringify(reminders)>
|
|||
- **只追赶最新一次**:逾期 Every 记录只贡献其最新一个到期发生时点,因此 Schedule 绝不会回放因错过间隔而形成的积压。
|
||||
- **存在狭窄的崩溃重复窗口**:同步 follow-up 获得准入后、dispatch 检查点完成前发生崩溃,可能使提醒重复;此包不承诺模型完成、用户确认或副作用恰好执行一次。
|
||||
- **加载顺序边界**:插件不会扫描或接管加载时已经 live 的 Agent。
|
||||
- **目录只是只读当前状态**:可选 Web 界面没有历史、mutation、Retry 或 acknowledgement 语义;终结记录会消失,交付仍然是普通对话输出。
|
||||
|
|
|
|||
|
|
@ -22,12 +22,17 @@
|
|||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client.d.ts",
|
||||
"default": "./lib/types/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
|
|
@ -38,9 +43,13 @@
|
|||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
|
|
@ -52,6 +61,7 @@
|
|||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
|
|
|
|||
2
packages/schedule/schedule/src/client.ts
Normal file
2
packages/schedule/schedule/src/client.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** Browser-safe Schedule vocabulary. @module @deepseek-ai/dsh-schedule/client */
|
||||
export type * from './types.ts'
|
||||
|
|
@ -566,6 +566,57 @@ function dispatchedRecord(record: ScheduleRecord, change: DecodedDispatch): Sche
|
|||
: Object.freeze({ ...record, scheduledAt: occurrence.nextScheduledAt })
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one already-decoded Schedule change to a complete fold value.
|
||||
*
|
||||
* This is the single transition authority shared by full-log replay and the
|
||||
* incremental Session projection. Inputs are never mutated; unchanged event
|
||||
* filtering remains the caller's responsibility.
|
||||
* @param folded - complete active records and used-id history before the change.
|
||||
* @param change - one strictly decoded durable mutation.
|
||||
* @returns the complete fold value after the mutation.
|
||||
*/
|
||||
export function applyScheduleChange(
|
||||
folded: FoldedSchedules,
|
||||
change: ScheduleChange,
|
||||
): FoldedSchedules {
|
||||
const active = new Map(folded.active.map(record => [record.id, record]))
|
||||
const seen = new Set(folded.seenIds)
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
if (seen.has(change.schedule.id)) {
|
||||
throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`)
|
||||
}
|
||||
seen.add(change.schedule.id)
|
||||
active.set(change.schedule.id, change.schedule)
|
||||
break
|
||||
case 'delete':
|
||||
if (!active.delete(change.id)) {
|
||||
throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`)
|
||||
}
|
||||
break
|
||||
case 'dispatch': {
|
||||
const record = active.get(change.id)
|
||||
if (record === undefined) {
|
||||
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`)
|
||||
}
|
||||
const next = dispatchedRecord(record, change)
|
||||
if (next === undefined) active.delete(change.id)
|
||||
else active.set(change.id, next)
|
||||
break
|
||||
}
|
||||
/* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
|
||||
default: {
|
||||
const unreachable: never = change
|
||||
throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`)
|
||||
}
|
||||
}
|
||||
return Object.freeze({
|
||||
active: Object.freeze([...active.values()]),
|
||||
seenIds: Object.freeze([...seen]),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the package-owned stream after the durable fork seed boundary.
|
||||
* @param events - Complete ordered session log or candidate-extended log.
|
||||
|
|
@ -579,45 +630,15 @@ export function foldScheduleEvents(
|
|||
if (!Number.isSafeInteger(seedLength) || seedLength < 0 || seedLength > events.length) {
|
||||
throw new ScheduleLogError('schedule seedLength must be within the supplied event log')
|
||||
}
|
||||
const active = new Map<ScheduleIdType, ScheduleRecord>()
|
||||
const seen = new Set<ScheduleIdType>()
|
||||
let folded: FoldedSchedules = Object.freeze({
|
||||
active: Object.freeze([]),
|
||||
seenIds: Object.freeze([]),
|
||||
})
|
||||
for (const event of events.slice(seedLength)) {
|
||||
if (event.type !== 'schedule/change') continue
|
||||
const change = decodeScheduleChange(event.data)
|
||||
switch (change.operation) {
|
||||
case 'create':
|
||||
if (seen.has(change.schedule.id)) {
|
||||
throw new ScheduleLogError(`schedule id ${JSON.stringify(change.schedule.id)} was reused`)
|
||||
}
|
||||
seen.add(change.schedule.id)
|
||||
active.set(change.schedule.id, change.schedule)
|
||||
break
|
||||
case 'delete':
|
||||
if (!active.delete(change.id)) {
|
||||
throw new ScheduleLogError(`schedule delete targets inactive id ${JSON.stringify(change.id)}`)
|
||||
}
|
||||
break
|
||||
case 'dispatch': {
|
||||
const record = active.get(change.id)
|
||||
if (record === undefined) {
|
||||
throw new ScheduleLogError(`schedule dispatch targets inactive id ${JSON.stringify(change.id)}`)
|
||||
}
|
||||
const next = dispatchedRecord(record, change)
|
||||
if (next === undefined) active.delete(change.id)
|
||||
else active.set(change.id, next)
|
||||
break
|
||||
}
|
||||
/* v8 ignore next 3 -- decodeScheduleChange returns a closed operation union. */
|
||||
default: {
|
||||
const unreachable: never = change
|
||||
throw new ScheduleLogError(`unknown decoded schedule change ${String(unreachable)}`)
|
||||
}
|
||||
}
|
||||
folded = applyScheduleChange(folded, decodeScheduleChange(event.data))
|
||||
}
|
||||
return Object.freeze({
|
||||
active: Object.freeze([...active.values()]),
|
||||
seenIds: Object.freeze([...seen]),
|
||||
})
|
||||
return folded
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@
|
|||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
// Type-only: resolves ctx.sessionProjections for the optional projection child.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import { scheduleProjectionDefinition } from './projection.ts'
|
||||
import { ScheduleRuntime } from './runtime.ts'
|
||||
import { registerScheduleTools } from './tools.ts'
|
||||
|
||||
|
|
@ -38,6 +41,10 @@ type OwnerCleanup = () => void | Promise<void>
|
|||
|
||||
/** Install Schedule only for root agents published after this plugin loads. */
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.inject(['sessionProjections'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register(scheduleProjectionDefinition)
|
||||
})
|
||||
|
||||
const runtimes = new Map<Agent, OwnerCleanup>()
|
||||
let stopping = false
|
||||
|
||||
|
|
|
|||
89
packages/schedule/schedule/src/projection.ts
Normal file
89
packages/schedule/schedule/src/projection.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Strict Session projection of the Schedule domain's active reminder set.
|
||||
* @module @deepseek-ai/dsh-schedule/projection
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
|
||||
import { applyScheduleChange, decodeScheduleChange } from './domain.ts'
|
||||
import type { FoldedSchedules } from './domain.ts'
|
||||
import type { ScheduleChange, ScheduleId, ScheduleRecord } from './types.ts'
|
||||
|
||||
/** Persisted projection state: the immutable fork boundary plus the complete Schedule fold. */
|
||||
export interface ScheduleProjectionState extends FoldedSchedules {
|
||||
readonly seedLength: number
|
||||
}
|
||||
|
||||
const scheduleId = z.unknown().transform((value, context): ScheduleId => {
|
||||
try {
|
||||
const change = decodeScheduleChange({ version: 1, operation: 'delete', id: value }) as Extract<
|
||||
ScheduleChange,
|
||||
{ operation: 'delete' }
|
||||
>
|
||||
return change.id
|
||||
} catch {
|
||||
context.addIssue({ code: 'custom', message: 'invalid Schedule id' })
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
|
||||
const scheduleRecord = z.unknown().transform((value, context): ScheduleRecord => {
|
||||
try {
|
||||
const change = decodeScheduleChange({ version: 1, operation: 'create', schedule: value }) as Extract<
|
||||
ScheduleChange,
|
||||
{ operation: 'create' }
|
||||
>
|
||||
return change.schedule
|
||||
} catch {
|
||||
context.addIssue({ code: 'custom', message: 'invalid Schedule record' })
|
||||
return z.NEVER
|
||||
}
|
||||
})
|
||||
|
||||
const scheduleRecords = z.array(scheduleRecord) as unknown as z.ZodType<readonly ScheduleRecord[]>
|
||||
|
||||
const scheduleProjectionStateSchema = z.object({
|
||||
seedLength: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER),
|
||||
active: scheduleRecords,
|
||||
seenIds: z.array(scheduleId),
|
||||
}).strict().superRefine((state, context) => {
|
||||
const seen = new Set(state.seenIds)
|
||||
if (seen.size !== state.seenIds.length) {
|
||||
context.addIssue({ code: 'custom', message: 'seen Schedule ids must be unique' })
|
||||
}
|
||||
const active = new Set<ScheduleId>()
|
||||
for (const record of state.active) {
|
||||
if (!seen.has(record.id)) {
|
||||
context.addIssue({ code: 'custom', message: 'every active Schedule id must have been seen' })
|
||||
}
|
||||
if (active.has(record.id)) {
|
||||
context.addIssue({ code: 'custom', message: 'active Schedule ids must be unique' })
|
||||
}
|
||||
active.add(record.id)
|
||||
}
|
||||
}) as unknown as z.ZodType<ScheduleProjectionState>
|
||||
|
||||
/** Projection definition sharing the Schedule domain's strict transition authority. */
|
||||
export const scheduleProjectionDefinition = {
|
||||
key: 'schedule',
|
||||
stateSchema: scheduleProjectionStateSchema,
|
||||
init: ({ seedLength }) => ({ seedLength, active: [], seenIds: [] }),
|
||||
apply: (state, event) => {
|
||||
if (event.seq < state.seedLength || event.type !== 'schedule/change') return state
|
||||
return {
|
||||
seedLength: state.seedLength,
|
||||
...applyScheduleChange(state, decodeScheduleChange(event.data)),
|
||||
}
|
||||
},
|
||||
wire: {
|
||||
viewSchema: scheduleRecords,
|
||||
view: state => state.active,
|
||||
},
|
||||
stateVersion: 1,
|
||||
} satisfies ProjectionDefinition<'schedule', ScheduleProjectionState>
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionStateMap {
|
||||
schedule: ScheduleProjectionState
|
||||
}
|
||||
}
|
||||
|
|
@ -219,3 +219,10 @@ declare module '@deepseek-ai/dsh-session/types' {
|
|||
'schedule/change': ScheduleChange
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/** Complete active reminders owned by this Session's post-fork suffix. */
|
||||
schedule: readonly ScheduleRecord[]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
153
packages/schedule/schedule/tests/projection.spec.ts
Normal file
153
packages/schedule/schedule/tests/projection.spec.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import { apply as applySchedule } from '../src/index.ts'
|
||||
import { foldScheduleEvents, ScheduleId, ScheduleLogError } from '../src/domain.ts'
|
||||
import { scheduleProjectionDefinition, type ScheduleProjectionState } from '../src/projection.ts'
|
||||
import type { ScheduleRecord } from '../src/types.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
function afterRecord(id: string, prompt = id): ScheduleRecord {
|
||||
return {
|
||||
id: ScheduleId(id),
|
||||
kind: 'after',
|
||||
prompt,
|
||||
afterSeconds: 30,
|
||||
scheduledAt: '2026-08-25T12:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function atRecord(id: string): ScheduleRecord {
|
||||
return {
|
||||
id: ScheduleId(id),
|
||||
kind: 'at',
|
||||
prompt: id,
|
||||
scheduledAt: '2026-08-25T13:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function everyRecord(id: string): ScheduleRecord {
|
||||
return {
|
||||
id: ScheduleId(id),
|
||||
kind: 'every',
|
||||
prompt: id,
|
||||
everySeconds: 300,
|
||||
scheduledAt: '2026-08-25T14:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
function change(data: unknown, seq: number): SessionEvent {
|
||||
return { type: 'schedule/change', seq, time: seq, data } as SessionEvent
|
||||
}
|
||||
|
||||
function created(record: ScheduleRecord, seq: number): SessionEvent {
|
||||
return change({ version: 1, operation: 'create', schedule: record }, seq)
|
||||
}
|
||||
|
||||
describe('Schedule Session projection', () => {
|
||||
it('shares strict transitions with full replay and excludes the inherited fork prefix', () => {
|
||||
const events: SessionEvent[] = [
|
||||
created(afterRecord('parent'), 0),
|
||||
created(atRecord('child-at'), 1),
|
||||
created(everyRecord('child-every'), 2),
|
||||
change({
|
||||
version: 1,
|
||||
operation: 'dispatch',
|
||||
id: 'child-every',
|
||||
acceptedAt: '2026-08-25T14:02:00.000Z',
|
||||
}, 3),
|
||||
{ type: 'turn/start', seq: 4, time: 4, data: { turn: 1 } },
|
||||
]
|
||||
let projected: ScheduleProjectionState = scheduleProjectionDefinition.init({ seedLength: 1 })
|
||||
for (const event of events) projected = scheduleProjectionDefinition.apply(projected, event)
|
||||
|
||||
expect(projected).toEqual({ seedLength: 1, ...foldScheduleEvents(events, 1) })
|
||||
expect(scheduleProjectionDefinition.wire.view(projected)).toEqual(projected.active)
|
||||
expect(projected.active.map(record => record.id)).toEqual(['child-at', 'child-every'])
|
||||
})
|
||||
|
||||
it('restores checkpoints, folds a bounded tail, and fails loud on damaged durable data', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(scheduleProjectionDefinition)
|
||||
|
||||
const first = created(afterRecord('one'), 0)
|
||||
const second = created(atRecord('two'), 1)
|
||||
const initial = ctx.sessionProjections.restore({}, [first, second], 0, { seedLength: 0 })
|
||||
expect(initial.snapshot.values.schedule?.map(record => record.id)).toEqual(['one', 'two'])
|
||||
|
||||
const removed = change({ version: 1, operation: 'delete', id: 'one' }, 2)
|
||||
const resumed = ctx.sessionProjections.restore(
|
||||
initial.checkpoint,
|
||||
[second, removed],
|
||||
1,
|
||||
{ seedLength: 0 },
|
||||
)
|
||||
expect(resumed.snapshot.values.schedule?.map(record => record.id)).toEqual(['two'])
|
||||
expect(resumed.checkpoint.schedule).toMatchObject({ ver: 1, seq: 2 })
|
||||
|
||||
expect(() => ctx.sessionProjections.restore(
|
||||
{},
|
||||
[change({ version: 1, operation: 'delete', id: 'missing' }, 0)],
|
||||
0,
|
||||
{ seedLength: 0 },
|
||||
)).toThrow(ScheduleLogError)
|
||||
})
|
||||
|
||||
it('rejects malformed or internally inconsistent checkpoint states', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(scheduleProjectionDefinition)
|
||||
const row = (val: unknown) => ({ schedule: { ver: 1, seq: 0, val } })
|
||||
|
||||
expect(ctx.sessionProjections.viewCheckpoint(row({
|
||||
seedLength: 0,
|
||||
active: [{ ...afterRecord('bad-time'), scheduledAt: 'not-an-instant' }],
|
||||
seenIds: ['bad-time'],
|
||||
}))).toEqual({})
|
||||
expect(ctx.sessionProjections.viewCheckpoint(row({
|
||||
seedLength: 0,
|
||||
active: [afterRecord('missing')],
|
||||
seenIds: [],
|
||||
}))).toEqual({})
|
||||
expect(ctx.sessionProjections.viewCheckpoint(row({
|
||||
seedLength: 0,
|
||||
active: [afterRecord('duplicate'), afterRecord('duplicate')],
|
||||
seenIds: ['duplicate', 'duplicate'],
|
||||
}))).toEqual({})
|
||||
expect(ctx.sessionProjections.viewCheckpoint(row({
|
||||
seedLength: 0,
|
||||
active: [],
|
||||
seenIds: [' bad-id'],
|
||||
}))).toEqual({})
|
||||
})
|
||||
|
||||
it('registers only while the Schedule plugin fiber is live', async () => {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const fiber = ctx.plugin({ apply: applySchedule })
|
||||
await fiber.await()
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('schedule/change', {
|
||||
version: 1,
|
||||
operation: 'create',
|
||||
schedule: afterRecord('live'),
|
||||
})
|
||||
expect(ctx.sessionProjections.snapshot(session).values.schedule).toHaveLength(1)
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionProjections.snapshot(session).values).toEqual({})
|
||||
})
|
||||
})
|
||||
|
|
@ -35,6 +35,9 @@
|
|||
{
|
||||
"path": "../../session/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,13 +183,17 @@ export class SessionProjectionCache extends Service {
|
|||
const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
|
||||
try {
|
||||
if (!related) throw new Error('unrelated log identity')
|
||||
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
|
||||
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor, {
|
||||
seedLength: tail.meta.seedLength ?? 0,
|
||||
})
|
||||
} catch {
|
||||
// Recoverable failures are an unrelated record, a row outside the
|
||||
// supplied suffix or log end, and stateSchema rejection. The full read
|
||||
// removes every checkpoint seed and lets each unit refold from init.
|
||||
const whole = await persistence.readFrom(id, 0, signal)
|
||||
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
|
||||
restored = this.ctx.sessionProjections.restore({}, whole.events, 0, {
|
||||
seedLength: whole.meta.seedLength ?? 0,
|
||||
})
|
||||
}
|
||||
await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
|
||||
return restored.snapshot
|
||||
|
|
|
|||
|
|
@ -52,12 +52,17 @@ const marksUnit = (stateVersion = 1) => ({
|
|||
}) satisfies ProjectionDefinition<'cache-test/marks', MarksState>
|
||||
|
||||
/** A persistence double serving readFrom over a fixed per-id stored log (headers stamp createdAt 0). */
|
||||
function fakePersistence(logs: Map<string, SessionEvent[]>) {
|
||||
function fakePersistence(logs: Map<string, SessionEvent[]>, seedLength?: number) {
|
||||
const readFrom = vi.fn(async (id: SessionId, fromSeq: number) => {
|
||||
const events = logs.get(String(id))
|
||||
if (events === undefined) throw new Error(`session "${id}" not found`)
|
||||
return {
|
||||
meta: { version: 0, id, createdAt: 0 },
|
||||
meta: {
|
||||
version: 0,
|
||||
id,
|
||||
createdAt: 0,
|
||||
...seedLength === undefined ? {} : { seedLength },
|
||||
},
|
||||
events: events.filter(event => event.seq >= fromSeq),
|
||||
}
|
||||
})
|
||||
|
|
@ -73,6 +78,7 @@ interface HarnessOptions {
|
|||
config?: { writeEveryEvents: number; writeIntervalMs: number }
|
||||
stateVersion?: number
|
||||
logs?: Map<string, SessionEvent[]>
|
||||
seedLength?: number
|
||||
}
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
|
@ -90,7 +96,7 @@ async function harness(options: HarnessOptions = {}) {
|
|||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(marksUnit(options.stateVersion))
|
||||
const persistence = fakePersistence(logs)
|
||||
const persistence = fakePersistence(logs, options.seedLength)
|
||||
ctx.provide('sessionPersistence', persistence as never)
|
||||
const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 })
|
||||
return { ctx, pool, logs, fiber, persistence, cache: ctx.sessionProjectionCache }
|
||||
|
|
@ -305,6 +311,19 @@ describe('SessionProjectionCache cold read', () => {
|
|||
expect(persistence.readFrom).toHaveBeenNthCalledWith(2, SessionId('malformed'), 0, undefined)
|
||||
})
|
||||
|
||||
it('passes the stored seed boundary through bounded and fallback full restores', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['seeded', storedLog([['real']])]])
|
||||
seedRow(pool, 'seeded', { ver: 1, seq: 1, val: { marks: 'not-an-array' } })
|
||||
const { ctx, cache } = await harness({ pool, logs, seedLength: 2 })
|
||||
const restore = vi.spyOn(ctx.sessionProjections, 'restore')
|
||||
|
||||
await cache.coldSnapshot(SessionId('seeded'))
|
||||
|
||||
expect(restore).toHaveBeenNthCalledWith(1, expect.any(Object), expect.any(Array), 1, { seedLength: 2 })
|
||||
expect(restore).toHaveBeenNthCalledWith(2, {}, expect.any(Array), 0, { seedLength: 2 })
|
||||
})
|
||||
|
||||
it('write-back failure is contained: the snapshot is still served', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const logs = new Map([['soft', storedLog([['a']])]])
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@
|
|||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/session/session-projection/README.md
|
||||
README.md: 3b7ccecb7040b5340cd24da45d99bbfdc13fa15c
|
||||
README.zh.md: bdf691761a6debf8f904924ab7198a5aed093b02
|
||||
README.md: 9a4687c1ac612a81966536e8590625e11889183f
|
||||
README.zh.md: b79ea28a29e43bc474f372c4392636d24c0c9568
|
||||
|
|
|
|||
|
|
@ -17,13 +17,15 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr
|
|||
|
||||
- `SessionProjectionMap` — the merge-extensible client-view table shared by wire blocks and client hooks. Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
|
||||
- `SessionProjectionStateMap` — the merge-extensible host fold-state table. Every client-visible key appears in both tables; host-only keys appear only here.
|
||||
- `ProjectionDefinition<K, S>` — `{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only.
|
||||
- `ProjectionInitialization` — immutable Session facts supplied at every fresh fold boundary; it currently contains normalized `seedLength` so a domain can exclude an inherited fork prefix without reading ambient Session state.
|
||||
- `ProjectionDefinition<K, S>` — `{ key, stateSchema, init(initialization), apply(state, event), wire?, stateVersion }`: a synchronous state-driven computation unit. `wire` supplies `viewSchema` and `view`; omitting it makes the unit host-only.
|
||||
|
||||
## Contract
|
||||
|
||||
- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch.
|
||||
- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, calls `init({ seedLength })` and folds the in-memory log on first touch.
|
||||
- **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream.
|
||||
- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
|
||||
- **Deterministic fold, complete wire value.** A unit synchronously validates and folds the Session events its domain owns; those durable events may be complete values or domain transitions. When a `wire` view exists, it always publishes the complete current value rather than a client-side delta.
|
||||
- **Initialization follows the event source.** Live lazy and event-driven builds normalize `session.header.seedLength ?? 0`. Detached restore callers pass the same normalized value from the header returned with the persisted event read. The initialization object is immutable input to `init`; a unit must not fetch Session or process state behind the registry.
|
||||
- **Synchronous unit discipline.** `init`/`apply`/`wire.view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally async view returns a Promise, which fails `wire.viewSchema.parse`.
|
||||
- **State is validated plain JSON, `stateVersion` is its invalidation anchor.** The persisted projection cache stores `(sessionId, key, ver, seq, val)` rows and validates `val` with `stateSchema` before use; bump `stateVersion` whenever the state fields or fold semantics change. Every unit's state is checkpointed — client-visible and host-only alike.
|
||||
- **No wire vocabulary here.** The registry exposes only the change feed and the snapshot read face; carriers (api-proxy) mint their own frames (`session/projection`) and blocks from them.
|
||||
|
|
@ -45,6 +47,6 @@ None; projections never assemble or send provider requests.
|
|||
|
||||
- **Every tail page carries every client-visible key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large.
|
||||
- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there.
|
||||
- **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
|
||||
- **Eager drive touches every unit per event** — cheap by construction (deterministic synchronous folds and the same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change.
|
||||
- **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead.
|
||||
- **Synchronous unit discipline is only partially mechanical** — `wire.viewSchema.parse` rejects a Promise-returning view, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists.
|
||||
|
|
|
|||
|
|
@ -17,13 +17,15 @@
|
|||
|
||||
- `SessionProjectionMap`——协议块与客户端钩子共享的 merge-extensible client view 表。值是协议层 JSON 全量值;渲染归 slot 体系管,永远不归本层。
|
||||
- `SessionProjectionStateMap`——merge-extensible host 折叠状态表。每个 client-visible key 同时出现在两个表中;host-only key 只出现在这里。
|
||||
- `ProjectionDefinition<K, S>`——`{ key, stateSchema, init(), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。
|
||||
- `ProjectionInitialization`——每个新折叠边界都会收到的不可变 Session 事实;当前包含规范化后的 `seedLength`,使领域无需读取环境 Session 状态即可排除 fork 继承前缀。
|
||||
- `ProjectionDefinition<K, S>`——`{ key, stateSchema, init(initialization), apply(state, event), wire?, stateVersion }`:同步的状态驱动计算单元。`wire` 提供 `viewSchema` 与 `view`;省略它即为 host-only 单元。
|
||||
|
||||
## 约定
|
||||
|
||||
- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都在首次触达时从 `init` 出发在内存日志上折叠。
|
||||
- **框架负责驱动,领域负责计算。** 注册表只订阅一次 `session/event`;每个已提交事件都会主动经过每个单元的 `apply`。领域不持有任何订阅。cell(每会话每单元一份 `{state, observedSeq}`,以 WeakMap 为键)惰性构建——在事件流过之后才注册的单元,或读取一个早于该注册的会话,都会在首次触达时调用 `init({ seedLength })` 并折叠内存日志。
|
||||
- **同引用即无工作。** 对与单元无关的事件,`apply` 必须返回同一个状态引用;驱动以 `Object.is` 把守变更流,因此不匹配的事件只花一次调用,不产生任何下游工作。
|
||||
- **全量值事件规则(承重)。** 携带状态的日志事件必须携带变更后的完整状态,绝不携带裸增量——这让每次状态转移始终足够廉价,也让每个被供给的值自描述(对消费方即 last-wins)。
|
||||
- **确定性 fold,完整 wire 值。** 单元同步校验并折叠其领域拥有的 Session 事件;这些持久事件既可以是完整值,也可以是领域 transition。存在 `wire` 视图时,它始终发布完整当前值,而不是让客户端处理 delta。
|
||||
- **初始化跟随事件来源。** live 惰性构建与事件驱动构建会规范化 `session.header.seedLength ?? 0`。detached restore 调用方从与持久事件同一次读取返回的 header 传入同样的规范值。初始化对象是 `init` 的不可变输入;单元不得绕过注册表读取 Session 或进程状态。
|
||||
- **单元的同步纪律。**`init`/`apply`/`wire.view` 必须是同步的;载体在切出页面切片的同一 tick 内读取 `snapshot()`,`asOfSeq` 之所以是一个一致切面正系于此。误写成异步的 view 会返回 Promise,并被 `wire.viewSchema.parse` 拒绝。
|
||||
- **状态是经校验的纯 JSON,`stateVersion` 是其失效锚点。** 持久投影缓存存储 `(sessionId, key, ver, seq, val)` 行,并在使用前以 `stateSchema` 校验 `val`;状态字段或折叠语义一旦变化就递增 `stateVersion`。每个单元的状态都会被检查点化——client-visible 与 host-only 一视同仁。
|
||||
- **本层没有协议词汇。** 注册表只暴露变更流与快照读取面;载体(api-proxy)据此自铸各自的帧(`session/projection`)与块。
|
||||
|
|
@ -45,6 +47,6 @@
|
|||
|
||||
- **每个尾页携带每个 client-visible key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。
|
||||
- **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。
|
||||
- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。
|
||||
- **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(确定性同步 fold、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。
|
||||
- **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。
|
||||
- **单元同步纪律只有部分可机械把关**——`wire.viewSchema.parse` 能拒绝返回 Promise 的 view,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套项记载了为何不存在运行时检查。
|
||||
|
|
|
|||
|
|
@ -10,9 +10,9 @@
|
|||
* (capability-seam three-way split). Design authority: the session-projection
|
||||
* RFC (.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md).
|
||||
*
|
||||
* Whole-value event rule (load-bearing): a state-carrying log event MUST
|
||||
* carry the complete post-change state, never a bare delta — it keeps every
|
||||
* unit's transition trivially cheap and every served value self-describing.
|
||||
* Fold rule (load-bearing): a unit synchronously and deterministically
|
||||
* validates and folds the Session events its domain owns. The client-facing
|
||||
* wire value, when present, is always the complete current value.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-projection
|
||||
*/
|
||||
|
|
@ -31,6 +31,12 @@ import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts
|
|||
|
||||
export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts'
|
||||
|
||||
/** Minimal immutable Session fact supplied when a projection state is initialized. */
|
||||
export interface ProjectionInitialization {
|
||||
/** Number of inherited leading events that belong to a fork's source Session. */
|
||||
readonly seedLength: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One domain's state-driven computation unit: a pure synchronous fold plus
|
||||
* declarations and an optional client view — never an opaque getter. The framework drives
|
||||
|
|
@ -49,9 +55,10 @@ export interface ProjectionDefinition<
|
|||
stateSchema: ZodType<S>
|
||||
/**
|
||||
* State for the empty log.
|
||||
* @param initialization - immutable Session facts needed to establish the fold boundary.
|
||||
* @returns the initial state.
|
||||
*/
|
||||
init(): NoInfer<S>
|
||||
init(initialization: ProjectionInitialization): NoInfer<S>
|
||||
/**
|
||||
* Pure transition: previous state + one committed event → next state. A
|
||||
* unit uninterested in an event MUST return the same state reference — an
|
||||
|
|
@ -129,7 +136,7 @@ export type ProjectionCheckpoint = Record<string, ProjectionCheckpointRow>
|
|||
interface ErasedDefinition {
|
||||
key: string
|
||||
stateSchema: { parse(value: unknown): unknown }
|
||||
init(): unknown
|
||||
init(initialization: ProjectionInitialization): unknown
|
||||
apply(state: unknown, event: SessionEvent): unknown
|
||||
wire: { viewSchema: { parse(value: unknown): unknown }; view(state: unknown): unknown } | undefined
|
||||
stateVersion: number
|
||||
|
|
@ -230,7 +237,7 @@ export class SessionProjectionRegistry extends Service {
|
|||
const erased: ErasedDefinition = {
|
||||
key: definition.key,
|
||||
stateSchema: definition.stateSchema,
|
||||
init: () => definition.init(),
|
||||
init: initialization => definition.init(initialization),
|
||||
apply: (state, event) => definition.apply(state as S, event),
|
||||
wire: wire === undefined
|
||||
? undefined
|
||||
|
|
@ -413,6 +420,7 @@ export class SessionProjectionRegistry extends Service {
|
|||
* @param checkpoint - persisted rows for one session (possibly stale or empty).
|
||||
* @param events - the stored events with `seq >= baseSeq`, in seq order.
|
||||
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
|
||||
* @param initialization - normalized facts from the stored header returned by the same read.
|
||||
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
|
||||
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
|
||||
* refreshed checkpoint rows at that cut, ready for a durable write-back.
|
||||
|
|
@ -421,6 +429,7 @@ export class SessionProjectionRegistry extends Service {
|
|||
checkpoint: ProjectionCheckpoint,
|
||||
events: readonly SessionEvent[],
|
||||
baseSeq: number,
|
||||
initialization: ProjectionInitialization,
|
||||
):
|
||||
{ snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
|
||||
const endSeq = events.at(-1)?.seq ?? baseSeq - 1
|
||||
|
|
@ -439,7 +448,7 @@ export class SessionProjectionRegistry extends Service {
|
|||
+ 'its checkpoint row is missing, version-mismatched, or beyond the supplied log end; re-read from seq 0',
|
||||
)
|
||||
}
|
||||
let state = usable ? def.stateSchema.parse(row.val) : def.init()
|
||||
let state = usable ? def.stateSchema.parse(row.val) : def.init(initialization)
|
||||
const from = usable ? row.seq : baseSeq - 1
|
||||
for (const event of events) {
|
||||
if (event.seq > from) state = def.apply(state, event)
|
||||
|
|
@ -454,8 +463,12 @@ export class SessionProjectionRegistry extends Service {
|
|||
}
|
||||
|
||||
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
|
||||
private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell {
|
||||
let state = def.init()
|
||||
private buildCell(
|
||||
def: ErasedDefinition,
|
||||
events: readonly SessionEvent[],
|
||||
initialization: ProjectionInitialization,
|
||||
): UnitCell {
|
||||
let state = def.init(initialization)
|
||||
for (const event of events) state = def.apply(state, event)
|
||||
return { state, observedSeq: (events.at(-1)?.seq ?? -1) }
|
||||
}
|
||||
|
|
@ -464,7 +477,9 @@ export class SessionProjectionRegistry extends Service {
|
|||
private cellFor(registration: Registration, session: Session): UnitCell {
|
||||
let cell = registration.cells.get(session)
|
||||
if (cell === undefined) {
|
||||
cell = this.buildCell(registration.def, session.events)
|
||||
cell = this.buildCell(registration.def, session.events, {
|
||||
seedLength: session.header.seedLength ?? 0,
|
||||
})
|
||||
registration.cells.set(session, cell)
|
||||
}
|
||||
return cell
|
||||
|
|
@ -477,7 +492,9 @@ export class SessionProjectionRegistry extends Service {
|
|||
if (cell === undefined) {
|
||||
// Late build mid-stream: fold history before this event (seq = log
|
||||
// index, so the prefix slice is exact), then take the normal gate.
|
||||
cell = this.buildCell(registration.def, session.events.slice(0, event.seq))
|
||||
cell = this.buildCell(registration.def, session.events.slice(0, event.seq), {
|
||||
seedLength: session.header.seedLength ?? 0,
|
||||
})
|
||||
registration.cells.set(session, cell)
|
||||
}
|
||||
const next = registration.def.apply(cell.state, event)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' {
|
|||
interface SessionProjectionStateMap {
|
||||
'test/marks': MarksState
|
||||
'test/count': number
|
||||
'test/seed': number
|
||||
}
|
||||
|
||||
interface SessionProjectionMap {
|
||||
|
|
@ -33,6 +34,7 @@ declare module '@deepseek-ai/dsh-session/types' {
|
|||
}
|
||||
|
||||
type MarksState = { marks: string[] } | null
|
||||
const INITIALIZATION = { seedLength: 0 } as const
|
||||
/** Whole-value unit: latest test/mark event wins; unrelated events return the same reference. */
|
||||
const marksUnit = (): Omit<ProjectionDefinition<'test/marks', MarksState>, 'wire'>
|
||||
& { wire: NonNullable<ProjectionDefinition<'test/marks', MarksState>['wire']> } => ({
|
||||
|
|
@ -56,6 +58,15 @@ const countUnit = (): ProjectionDefinition<'test/count', number> => ({
|
|||
stateVersion: 1,
|
||||
})
|
||||
|
||||
/** Host-only sentinel proving which Session seed boundary initialized a cell. */
|
||||
const seedUnit = (): ProjectionDefinition<'test/seed', number> => ({
|
||||
key: 'test/seed',
|
||||
stateSchema: z.number().int().nonnegative(),
|
||||
init: initialization => initialization.seedLength,
|
||||
apply: state => state,
|
||||
stateVersion: 1,
|
||||
})
|
||||
|
||||
async function harness(): Promise<{ ctx: Context; session: Session }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
|
@ -95,6 +106,30 @@ describe('SessionProjectionRegistry drive', () => {
|
|||
expect(snapshot.values['test/marks']).toEqual({ marks: [] })
|
||||
})
|
||||
|
||||
it('passes the normalized fork boundary to lazy, event-driven, and restore initialization', async () => {
|
||||
const { ctx } = await harness()
|
||||
const parentMark: SessionEvent = {
|
||||
type: 'test/mark', seq: 0, time: 0, data: { marks: ['parent'] },
|
||||
}
|
||||
|
||||
const lazy = ctx.sessions.create(undefined, {
|
||||
seed: [parentMark],
|
||||
meta: { seedLength: 1 },
|
||||
})
|
||||
ctx.sessionProjections.register(seedUnit())
|
||||
expect(ctx.sessionProjections.stateOf(lazy, 'test/seed')).toBe(1)
|
||||
|
||||
const driven = ctx.sessions.create(undefined, {
|
||||
seed: [parentMark],
|
||||
meta: { seedLength: 1 },
|
||||
})
|
||||
mark(driven, ['child'])
|
||||
expect(ctx.sessionProjections.stateOf(driven, 'test/seed')).toBe(1)
|
||||
|
||||
const restored = ctx.sessionProjections.restore({}, [], 0, { seedLength: 7 })
|
||||
expect(restored.checkpoint['test/seed']).toEqual({ ver: 1, seq: -1, val: 7 })
|
||||
})
|
||||
|
||||
it('notifies onChanged with the validated view and the causing seq, and skips same-reference applies', async () => {
|
||||
const { ctx, session } = await harness()
|
||||
ctx.sessionProjections.register(marksUnit())
|
||||
|
|
@ -280,7 +315,7 @@ describe('SessionProjectionRegistry drive', () => {
|
|||
expect(() => ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } },
|
||||
'test/count': { ver: 99, seq: 2, val: 3 },
|
||||
}, tail, 3)).toThrow(/re-read from seq 0/)
|
||||
}, tail, 3, INITIALIZATION)).toThrow(/re-read from seq 0/)
|
||||
// The full-log re-read (baseSeq 0) refolds the mismatched key from init.
|
||||
const full: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
|
|
@ -291,7 +326,7 @@ describe('SessionProjectionRegistry drive', () => {
|
|||
const { snapshot, checkpoint } = ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } },
|
||||
'test/count': { ver: 99, seq: 2, val: 3 },
|
||||
}, full, 0)
|
||||
}, full, 0, INITIALIZATION)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] })
|
||||
expect('test/count' in snapshot.values).toBe(false)
|
||||
|
|
@ -312,7 +347,7 @@ describe('SessionProjectionRegistry drive', () => {
|
|||
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } },
|
||||
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3)
|
||||
const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, tail, 3, INITIALIZATION)
|
||||
expect(snapshot.asOfSeq).toBe(4)
|
||||
// marks already covers the tail (watermark 4): nothing re-applied.
|
||||
expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] })
|
||||
|
|
@ -324,7 +359,7 @@ describe('SessionProjectionRegistry drive', () => {
|
|||
const { snapshot: current, checkpoint: currentCheckpoint } = ctx.sessionProjections.restore({
|
||||
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
|
||||
'test/count': { ver: 1, seq: 4, val: 5 },
|
||||
}, [], 5)
|
||||
}, [], 5, INITIALIZATION)
|
||||
expect(current.asOfSeq).toBe(4)
|
||||
expect('test/count' in current.values).toBe(false)
|
||||
expect(currentCheckpoint['test/count']).toEqual({ ver: 1, seq: 4, val: 5 })
|
||||
|
|
@ -355,7 +390,7 @@ describe('SessionProjectionRegistry drive', () => {
|
|||
'test/marks': { marks: ['stored'] },
|
||||
})
|
||||
|
||||
const restored = ctx.sessionProjections.restore(rows, [], 5)
|
||||
const restored = ctx.sessionProjections.restore(rows, [], 5, INITIALIZATION)
|
||||
expect(restored.snapshot.values).toEqual({
|
||||
'test/marks': { marks: ['stored'] },
|
||||
})
|
||||
|
|
@ -370,7 +405,7 @@ describe('SessionProjectionRegistry drive', () => {
|
|||
}
|
||||
|
||||
expect(ctx.sessionProjections.viewCheckpoint(drifted)).toEqual({})
|
||||
expect(() => ctx.sessionProjections.restore(drifted, [], 3)).toThrow()
|
||||
expect(() => ctx.sessionProjections.restore(drifted, [], 3, INITIALIZATION)).toThrow()
|
||||
})
|
||||
|
||||
it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => {
|
||||
|
|
@ -383,18 +418,18 @@ describe('SessionProjectionRegistry drive', () => {
|
|||
expect(floor).toBe(9)
|
||||
// …an intact log serves the anchor event and the checkpoint stands as-is.
|
||||
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
|
||||
const anchored = ctx.sessionProjections.restore(rows, [anchor], 9)
|
||||
const anchored = ctx.sessionProjections.restore(rows, [anchor], 9, INITIALIZATION)
|
||||
expect(anchored.snapshot.values).toEqual({})
|
||||
expect(anchored.checkpoint['test/count']).toEqual({ ver: 1, seq: 9, val: 10 })
|
||||
// …while a log crash-repaired down to fewer events returns an empty tail:
|
||||
// the row overreaches the proven end and a tail read cannot fix this key.
|
||||
expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/)
|
||||
expect(() => ctx.sessionProjections.restore(rows, [], 9, INITIALIZATION)).toThrow(/re-read from seq 0/)
|
||||
// The full re-read discards the overreaching row and refolds from init.
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
|
||||
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0)
|
||||
const { snapshot, checkpoint } = ctx.sessionProjections.restore(rows, events, 0, INITIALIZATION)
|
||||
expect(snapshot.asOfSeq).toBe(1)
|
||||
expect(snapshot.values).toEqual({})
|
||||
expect(checkpoint['test/count']).toEqual({ ver: 1, seq: 1, val: 2 })
|
||||
|
|
|
|||
|
|
@ -395,7 +395,9 @@ async function resolveColdIdentity(
|
|||
}
|
||||
let identity: SubagentIdentityProjection | null | undefined
|
||||
try {
|
||||
identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
|
||||
identity = projections.restore({}, inspected.events, 0, {
|
||||
seedLength: inspected.meta.seedLength ?? 0,
|
||||
}).snapshot.values.subagent
|
||||
} catch {
|
||||
// The restore folds EVERY registered unit over this child's log, so any
|
||||
// unit's fold or schema can reject damaged payloads — deterministic data
|
||||
|
|
|
|||
|
|
@ -459,11 +459,13 @@ describe('SubagentRuntime.listChildren', () => {
|
|||
values: { subagent: { mode: 'continuable', label: 'ancestor label', seq: 2 } },
|
||||
})
|
||||
const inspect = vi.spyOn(ctx.sessionPersistence, 'inspect')
|
||||
const restore = vi.spyOn(ctx.sessionProjections, 'restore')
|
||||
await expect(ctx.subagents.listChildren(parent.id)).resolves.toEqual([{
|
||||
kind: 'child', id: forkChild, label: 'own label', mode: 'continuable',
|
||||
activity: 'inactive', hasChildren: false,
|
||||
}])
|
||||
expect(inspect).toHaveBeenCalledTimes(1)
|
||||
expect(restore).toHaveBeenCalledWith({}, expect.any(Array), 0, { seedLength: seed.length })
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
|
|
|||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
|
|
@ -1415,6 +1415,9 @@ importers:
|
|||
'@deepseek-ai/dsh-client-ui-renderer':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-renderer
|
||||
'@deepseek-ai/dsh-client-ui-schedule':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-schedule
|
||||
'@deepseek-ai/dsh-client-ui-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../client/ui-session
|
||||
|
|
@ -2809,6 +2812,57 @@ importers:
|
|||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-schedule:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-session-controller':
|
||||
specifier: workspace:^
|
||||
version: link:../../api/session-controller
|
||||
'@deepseek-ai/dsh-client-locale':
|
||||
specifier: workspace:^
|
||||
version: link:../locale
|
||||
'@deepseek-ai/dsh-client-test-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../../test-support/client-runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
'@deepseek-ai/dsh-client-ui-renderer':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-renderer
|
||||
'@deepseek-ai/dsh-client-ui-session':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-session
|
||||
'@deepseek-ai/dsh-client-ui-slots':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-slots
|
||||
'@deepseek-ai/dsh-invariants':
|
||||
specifier: workspace:^
|
||||
version: link:../../runtime-diagnostics/invariants
|
||||
'@deepseek-ai/dsh-schedule':
|
||||
specifier: workspace:^
|
||||
version: link:../../schedule/schedule
|
||||
'@deepseek-ai/dsh-session':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/session
|
||||
'@testing-library/react':
|
||||
specifier: ^16.1.0
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-session:
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
|
|
@ -6602,6 +6656,10 @@ importers:
|
|||
version: link:../sandbox-local
|
||||
|
||||
packages/schedule/schedule:
|
||||
dependencies:
|
||||
zod:
|
||||
specifier: ^4.4.3
|
||||
version: 4.4.3
|
||||
devDependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
|
|
@ -6636,6 +6694,9 @@ importers:
|
|||
'@deepseek-ai/dsh-session-persistence-jsonl':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-persistence-jsonl
|
||||
'@deepseek-ai/dsh-session-projection':
|
||||
specifier: workspace:^
|
||||
version: link:../../session/session-projection
|
||||
'@deepseek-ai/dsh-system-prompt':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/system-prompt
|
||||
|
|
|
|||
|
|
@ -579,6 +579,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
|||
WorkflowRunInfo: 'workflow.md',
|
||||
WorkflowStartRequest: 'workflow.md',
|
||||
ProjectionDefinition: 'session-projection.md',
|
||||
ProjectionInitialization: 'session-projection.md',
|
||||
SessionProjectionMap: 'session-projection.md',
|
||||
SessionProjectionStateMap: 'session-projection.md',
|
||||
ProjectionChangeListener: 'session-projection.md',
|
||||
|
|
|
|||
|
|
@ -1871,6 +1871,11 @@
|
|||
"symbol": "PresetSpec",
|
||||
"source": "packages/interaction/permission-presets/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/session-projection.md",
|
||||
"symbol": "ProjectionInitialization",
|
||||
"source": "packages/session/session-projection/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/session-projection.md",
|
||||
"symbol": "ProjectionDefinition",
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
|||
'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over the message-feedback sidecar; ratings and notes never enter the Session log, model context, or telemetry.' },
|
||||
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
|
||||
'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' },
|
||||
'packages/client/ui-schedule': { kind: 'none', reason: 'Browser-side read-only projection of active Schedule records; dsh-schedule owns the model-facing tools and delivery.' },
|
||||
'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' },
|
||||
'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-reference': { kind: 'indirect', reason: 'Browser-side reference selection delegates file guidance and session snapshot preparation to Host-owned providers.' },
|
||||
|
|
|
|||
4
snapshots/web/schedule-catalog/catalog.expected.md
Normal file
4
snapshots/web/schedule-catalog/catalog.expected.md
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
- list "Active reminders":
|
||||
- listitem: Overdue Review overdue deployment Once Aug 25, 2099, 7:59 PM 1 minute overdue
|
||||
- listitem: Scheduled Join release review with the release owners, verify the rollout checklist, capture each unresolved dependency, confirm the customer-facing message, compare the staged configuration with the approved release notes, inspect the deployment dashboard for every region, confirm that database migrations completed without warnings, review the rollback steps with the incident commander, verify that support has the final customer timeline, record every owner and deadline, check the public status wording against the internal decision, review the accessibility and localization sign-offs, confirm the monitoring thresholds and alert routes, read back the final launch sequence, document every unresolved question in plain language, keep all technical qualifiers and exception cases visible, include the exact handoff conditions for each downstream team, retain the complete audit context for the final decision, and preserve every final word without truncation. Once Aug 25, 2099, 8:05 PM in 6 minutes
|
||||
- listitem: Scheduled Check exact cadence Every 301 seconds Aug 25, 2099, 8:05 PM in 6 minutes
|
||||
11
snapshots/web/schedule-catalog/session.jsonl
Normal file
11
snapshots/web/schedule-catalog/session.jsonl
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{"type":"session","version":0,"id":"{{session:1}}","createdAt":1787644800000,"cwd":"{{cwd}}","agentPreset":"standard"}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"user/message","data":{"role":"user","content":[{"type":"text","text":"Show the active reminders."}],"source":{"kind":"user"},"id":"{{message:1}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","data":{"title":"Active schedule catalog","messageSeqs":[1],"source":{"kind":"user"}}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"The active reminders are available in the session header."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"{{message:2}}"},"usage":{"inputTokens":4,"outputTokens":10}},"surfaceOp":"append"}
|
||||
{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-after","kind":"after","prompt":"Review overdue deployment","afterSeconds":60,"scheduledAt":"2099-08-25T11:59:00.000Z"}}}
|
||||
{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-at","kind":"at","prompt":"Join release review with the release owners, verify the rollout checklist, capture each unresolved dependency, confirm the customer-facing message, compare the staged configuration with the approved release notes, inspect the deployment dashboard for every region, confirm that database migrations completed without warnings, review the rollback steps with the incident commander, verify that support has the final customer timeline, record every owner and deadline, check the public status wording against the internal decision, review the accessibility and localization sign-offs, confirm the monitoring thresholds and alert routes, read back the final launch sequence, document every unresolved question in plain language, keep all technical qualifiers and exception cases visible, include the exact handoff conditions for each downstream team, retain the complete audit context for the final decision, and preserve every final word without truncation.","scheduledAt":"2099-08-25T12:05:01.000Z"}}}
|
||||
{"type":"schedule/change","data":{"version":1,"operation":"create","schedule":{"id":"catalog-every","kind":"every","prompt":"Check exact cadence","everySeconds":301,"scheduledAt":"2099-08-25T12:05:01.000Z"}}}
|
||||
{"type":"step/end","data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
8
snapshots/web/schedule-catalog/snapshot.yml
Normal file
8
snapshots/web/schedule-catalog/snapshot.yml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
version: 1
|
||||
scenario: schedule-catalog
|
||||
profile: web
|
||||
composition: web-schedule
|
||||
recording: authored
|
||||
header:
|
||||
class: web-schedule
|
||||
pin: true
|
||||
37
snapshots/web/schedule-catalog/system-prompt.expected.md
Normal file
37
snapshots/web/schedule-catalog/system-prompt.expected.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
You are an AI agent powered by DeepSeek Harness.
|
||||
|
||||
The DeepSeek Harness implementation checkout is at {{sourceRoot}}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.
|
||||
|
||||
You are interacting with the user through the DeepSeek Harness Web GUI at {{webUrl}}. When the user refers to "this page", "this GUI", or "this app" without naming another target, they mean this GUI. The browser provides no implicit DOM, route, or screenshot context. The client-plugin HMR receiver is active, but client-plugin changes reload without a refresh only while `pnpm run dev:web` is also running from this same checkout to rebuild their bundles; verify that watcher before promising automatic updates. Every other change — the apps/web shell and plain packages — requires rebuilding the affected Web artifacts and verifying this existing URL after a page refresh. Starting another server does not update this GUI. The apps/web Vite entry builds the shell but is not a standalone application because only dsh web injects window.__DSH_BOOT__. Do not start a replacement server unless the user asks; if one is needed, use a managed background job and verify its exact URL.
|
||||
|
||||
You are a coding agent powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.
|
||||
|
||||
Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.
|
||||
|
||||
Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.
|
||||
|
||||
Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-policy requires it) and prefer edit for targeted changes.
|
||||
|
||||
Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-observation-policy requires it), unless you just created or edited it in this session.
|
||||
|
||||
Use the glob tool — not shell find — to discover files by path pattern. A pattern with no "/" matches basenames at any depth, so "*" matches every file in the tree rather than its top level. Results are files only, never directories, and include hidden and ignored files: a result that fits comes back in modification-time order, while a larger one keeps the modification-time-ordered head.
|
||||
|
||||
Use the grep tool — not shell grep or rg — to search file contents. Use read on a matched file when you need surrounding context.
|
||||
|
||||
Check the [exit code: N] marker on every bash result; investigate failures before moving on.
|
||||
|
||||
Track every background job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.
|
||||
|
||||
Use the web_search tool to discover current information on the web. The required queries array accepts 1–4 non-empty search queries; use a one-item array for a single search. It returns an optional answer plus a list of source URLs. Use the returned source snippets when available, and cite the relevant URLs as markdown links.
|
||||
|
||||
Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked.
|
||||
|
||||
Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.
|
||||
|
||||
Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.
|
||||
|
||||
Use subagent_fork in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
|
||||
|
||||
Use subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.
|
||||
|
||||
When you successfully create or modify files, mention the primary outputs in your final response. To make those and any other changed-file references clickable in Web, format them as Markdown inline code using the exact file-tool path, or a basename when unique among the files changed in that turn.
|
||||
761
snapshots/web/schedule-catalog/tool-schemas.expected.json
Normal file
761
snapshots/web/schedule-catalog/tool-schemas.expected.json
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
{
|
||||
"initial": [
|
||||
{
|
||||
"name": "ask_user_question",
|
||||
"description": "Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable id that will be echoed in the answer.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"questions": {
|
||||
"type": "array",
|
||||
"description": "Questions to ask the user before continuing.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Stable id for this question; echoed in the answer."
|
||||
},
|
||||
"question": {
|
||||
"type": "string",
|
||||
"description": "The specific question to ask the user."
|
||||
},
|
||||
"header": {
|
||||
"type": "string",
|
||||
"description": "Optional short heading for the question, such as \"Confirm\" or \"Choose Mode\"."
|
||||
},
|
||||
"options": {
|
||||
"type": "array",
|
||||
"description": "Optional choices to show the user. If you recommend one, put it first and append \"(Recommended)\" to that label.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"description": "Short user-facing option label."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One sentence explaining the tradeoff or impact."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"label"
|
||||
]
|
||||
}
|
||||
},
|
||||
"multi_select": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the user may select more than one option. Defaults to false."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"question"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"questions"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bash",
|
||||
"description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to execute."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."
|
||||
},
|
||||
"timeoutMs": {
|
||||
"type": "number",
|
||||
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
|
||||
},
|
||||
"workdir": {
|
||||
"type": "string",
|
||||
"description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Run in the background and return a job id immediately (collect with job_output, stop with job_kill). No timeout applies."
|
||||
},
|
||||
"sandbox_permissions": {
|
||||
"type": "string",
|
||||
"description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.",
|
||||
"enum": [
|
||||
"workspace-write",
|
||||
"danger-full-access"
|
||||
]
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"command",
|
||||
"description"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "create_goal",
|
||||
"description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "The concrete completion objective inferred from the direct human request."
|
||||
},
|
||||
"max_goal_rounds": {
|
||||
"type": "number",
|
||||
"description": "Optional positive safe-integer limit on automatic continuation rounds."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"objective"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "edit",
|
||||
"description": "Edit an existing UTF-8 text file by replacing literal text.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to edit, resolved by the filesystem backend."
|
||||
},
|
||||
"old_string": {
|
||||
"type": "string",
|
||||
"description": "Literal text to replace. Must match exactly."
|
||||
},
|
||||
"new_string": {
|
||||
"type": "string",
|
||||
"description": "Literal replacement text. Use an empty string to delete the match."
|
||||
},
|
||||
"replace_all": {
|
||||
"type": "boolean",
|
||||
"description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once."
|
||||
},
|
||||
"sandbox_permissions": {
|
||||
"type": "string",
|
||||
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
|
||||
"enum": [
|
||||
"workspace-write",
|
||||
"danger-full-access"
|
||||
]
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path",
|
||||
"old_string",
|
||||
"new_string"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "exit_plan_mode",
|
||||
"description": "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. Send the COMPLETE plan as markdown, starting with a # heading that names it. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plan": {
|
||||
"type": "string",
|
||||
"description": "The complete plan, as markdown, starting with a # heading that names it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"plan"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_goal",
|
||||
"description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "glob",
|
||||
"description": "Find files whose paths match a glob pattern. Returns matching file paths — never directories — including hidden and ignored files (VCS metadata directories are excluded). Up to 100 paths come back in modification-time order; a larger result returns the first 100 paths in modification-time order, says so, and reports where the complete sorted list was saved. This tool does not enumerate directory entries.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Glob pattern to match file paths against (e.g. \"**/*.ts\", \"src/**/*.test.js\"). A pattern with no \"/\" matches the basename at any depth, so \"*\" and \"*.ts\" both search the whole tree; include a separator to anchor the depth."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Directory to search in. Defaults to the session workspace; a relative path resolves against it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "grep",
|
||||
"description": "Search file contents with a ripgrep regular expression. Returns matching lines with line numbers, grouped by file. Returns the first 250 matches inline; a capped result reports where the complete match list was saved. Use read on a matched file for surrounding context.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regular expression to search for (ripgrep syntax)."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File or directory to search. Defaults to the session workspace; a relative path resolves against it."
|
||||
},
|
||||
"include": {
|
||||
"type": "string",
|
||||
"description": "One glob filter for which files to search (e.g. \"*.ts\", \"*.{js,jsx}\"). Not a list; negation is not supported."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"pattern"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "interrupt_agent",
|
||||
"description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "The agent id of the running agent to interrupt."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"agent_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "job_kill",
|
||||
"description": "Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Job id returned by the tool that started the background work."
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Optional short reason, recorded in the log and forwarded to the job."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"job_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "job_list",
|
||||
"description": "List your background jobs (running and finished) with their ids, kinds, and statuses.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "job_output",
|
||||
"description": "Read a background job. Stream jobs return only output since the previous read; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Job id returned by the tool that started the background work."
|
||||
},
|
||||
"wait": {
|
||||
"type": "boolean",
|
||||
"description": "Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job alive."
|
||||
},
|
||||
"timeout_ms": {
|
||||
"type": "number",
|
||||
"description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"job_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "list_agents",
|
||||
"description": "List your continuable background subagents by durable id and label. Use it to recall which ones you started, not to poll for completion — you are told when one finishes. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and ready means it exists only in storage — resumable, not terminal, and not a result waiting to be collected; a `send_message` starts a new turn on the same conversation, and a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"scope": {
|
||||
"type": "string",
|
||||
"description": "children (default) lists direct children only; descendants walks the complete tree below you.",
|
||||
"enum": [
|
||||
"children",
|
||||
"descendants"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ralph",
|
||||
"description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "The immutable completion objective for every fresh Ralph round."
|
||||
},
|
||||
"maxRounds": {
|
||||
"type": "number",
|
||||
"description": "Optional positive safe-integer round cap, bounded by the deployment ceiling."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"objective"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "read",
|
||||
"description": "Read a UTF-8 text file and return line-numbered content.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to read, resolved by the filesystem backend."
|
||||
},
|
||||
"offset": {
|
||||
"type": "number",
|
||||
"description": "1-based first line to return. Defaults to 1."
|
||||
},
|
||||
"limit": {
|
||||
"type": "number",
|
||||
"description": "Maximum number of lines to return. Defaults to 2000."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "read_image",
|
||||
"description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Harness validates and downscales large supported images before the next model request, so use this tool directly instead of installing image libraries or creating thumbnails merely to inspect an image. Independent files may be read concurrently in small batches. Requires the current model to accept image input.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to the image file, resolved by the filesystem backend."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "schedule_create",
|
||||
"description": "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.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Reminder content to present when the target becomes due."
|
||||
},
|
||||
"after_seconds": {
|
||||
"type": "number",
|
||||
"description": "Positive safe-integer delay in seconds."
|
||||
},
|
||||
"every_seconds": {
|
||||
"type": "number",
|
||||
"description": "Fixed-rate safe-integer interval in seconds, at least 300."
|
||||
},
|
||||
"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": [
|
||||
"prompt"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "schedule_delete",
|
||||
"description": "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.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Exact session-local schedule id."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "schedule_list",
|
||||
"description": "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.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "send_message",
|
||||
"description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"subagent_id": {
|
||||
"type": "string",
|
||||
"description": "The subagent id returned when the background subagent was started."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "The message to deliver to the subagent."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"subagent_id",
|
||||
"message"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "skill",
|
||||
"description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The exact skill name from the available skills list."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "subagent",
|
||||
"description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short (3-5 word) description of the delegated task, for display."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"prompt"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "subagent_fork",
|
||||
"description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn). Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive its result, not its intermediate steps. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "A short (3-5 word) description of the delegated task, for display."
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"description",
|
||||
"prompt"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "todo_write",
|
||||
"description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"todos": {
|
||||
"type": "array",
|
||||
"description": "The COMPLETE task list, replacing any previous list.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "What the task is — a short imperative line."
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "pending (not started) | in_progress (now) | completed (done).",
|
||||
"enum": [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"completed"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"content",
|
||||
"status"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"todos"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "update_goal",
|
||||
"description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"goal_id": {
|
||||
"type": "string",
|
||||
"description": "Exact id returned by get_goal."
|
||||
},
|
||||
"revision": {
|
||||
"type": "number",
|
||||
"description": "Exact positive revision returned by get_goal."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "edit | pause | resume | complete | blocked",
|
||||
"enum": [
|
||||
"edit",
|
||||
"pause",
|
||||
"resume",
|
||||
"complete",
|
||||
"blocked"
|
||||
]
|
||||
},
|
||||
"objective": {
|
||||
"type": "string",
|
||||
"description": "Replacement objective; valid only with action edit."
|
||||
},
|
||||
"max_goal_rounds": {
|
||||
"type": "number",
|
||||
"description": "Replacement cap; valid only with action edit."
|
||||
},
|
||||
"blocked_reason": {
|
||||
"type": "string",
|
||||
"description": "Concrete blocking condition; required only with action blocked."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"goal_id",
|
||||
"revision",
|
||||
"action"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "web_search",
|
||||
"description": "Search the web for current information. Provide 1–4 queries in the required queries array. Returns an optional summary answer and a list of source URLs.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"queries": {
|
||||
"type": "array",
|
||||
"description": "Required search queries; accepts 1–4 items and merges their results.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"queries"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "workflow",
|
||||
"description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return <value>` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise<any>` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise<any[]>` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise<any[]>` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return <json-value>`)."
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "The workflow identity block (plain JSON — never code).",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Short kebab-case workflow name."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One-line description of what the workflow does."
|
||||
},
|
||||
"whenToUse": {
|
||||
"type": "string",
|
||||
"description": "Optional guidance on when this workflow applies."
|
||||
},
|
||||
"phases": {
|
||||
"type": "array",
|
||||
"description": "Optional phase declarations matched by phase() calls.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The phase title phase() calls match by exact string."
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"description": "Optional one-line description of the phase."
|
||||
},
|
||||
"provider": {
|
||||
"type": "string",
|
||||
"description": "Optional provider override this phase is expected to use."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional model override this phase is expected to use."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"title"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"description"
|
||||
]
|
||||
},
|
||||
"args": {
|
||||
"type": "object",
|
||||
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"script",
|
||||
"meta"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write",
|
||||
"description": "Create or fully replace a UTF-8 text file.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file_path": {
|
||||
"type": "string",
|
||||
"description": "Path to write, resolved by the filesystem backend."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Full UTF-8 text content to write."
|
||||
},
|
||||
"sandbox_permissions": {
|
||||
"type": "string",
|
||||
"description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.",
|
||||
"enum": [
|
||||
"workspace-write",
|
||||
"danger-full-access"
|
||||
]
|
||||
},
|
||||
"justification": {
|
||||
"type": "string",
|
||||
"description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"file_path",
|
||||
"content"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"changes": []
|
||||
}
|
||||
|
|
@ -245,6 +245,9 @@
|
|||
"@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"],
|
||||
"@deepseek-ai/dsh-client-ui-settings-plugins": ["./packages/client/ui-settings-plugins/src"],
|
||||
"@deepseek-ai/dsh-client-ui-jobs": ["./packages/client/ui-jobs/src"],
|
||||
"@deepseek-ai/dsh-client-ui-schedule": ["./packages/client/ui-schedule/src"],
|
||||
"@deepseek-ai/dsh-client-ui-schedule/client": ["./packages/client/ui-schedule/src/client/index.ts"],
|
||||
"@deepseek-ai/dsh-client-ui-schedule/invariant": ["./packages/client/ui-schedule/src/invariant.ts"],
|
||||
"@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"],
|
||||
"@deepseek-ai/dsh-client-ui-user-questions": ["./packages/client/ui-user-questions/src"],
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"],
|
||||
|
|
@ -256,6 +259,7 @@
|
|||
"@deepseek-ai/dsh-client-ui-settings-plugin-inventory": ["./packages/client/ui-settings-plugin-inventory/src"],
|
||||
"@deepseek-ai/dsh-client-locale": ["./packages/client/locale/src"],
|
||||
"@deepseek-ai/dsh-client-web": ["./packages/client/web/src"],
|
||||
"@deepseek-ai/dsh-schedule/client": ["./packages/schedule/schedule/src/client.ts"],
|
||||
// sdk/ folders are role-named without their npm-side sdk/jsonrpc prefixes,
|
||||
// so the generic wildcard cannot map these three package names.
|
||||
"@deepseek-ai/dsh-sdk-client": ["./packages/sdk/client/src"],
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@
|
|||
{ "path": "./packages/client/ui-reference" },
|
||||
{ "path": "./packages/client/ui-subagent" },
|
||||
{ "path": "./packages/client/ui-jobs" },
|
||||
{ "path": "./packages/client/ui-schedule" },
|
||||
{ "path": "./packages/client/ui-directory-picker-browse" },
|
||||
{ "path": "./packages/client/ui-directory-picker-native" },
|
||||
{ "path": "./packages/client/ui-goal" },
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue