diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml new file mode 100644 index 0000000000..46925831d8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-24-shared-time-vocabulary.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md +/agent-note-shared-time-vocabulary-for-wall-clock-selectors: + en: ae04747237709d0c + zh: a3731d98d5e940f9 +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/problem: + en: abd0263d14861926 + zh: e1736cd08e87f795 +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: + en: 52b45f1ac62e65ab + zh: 7f83cd4fb8dd09a6 +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: + en: 1737484c099fab52 + zh: 303b144c52d167bd +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: + en: 6ec74e7ac065bd5e + zh: 04d029059dea732b diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md new file mode 100644 index 0000000000..607dc68b7a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md @@ -0,0 +1,51 @@ +# Agent Note: Shared time vocabulary for wall-clock selectors + +Status: implemented + +English | [中文](2026-09-24-shared-time-vocabulary.zh.md) + +## Problem + +`dsh-util-time` existed for one job and one caller shape: `canonicalClientTimeZone` validated a caller-reported zone at a wire boundary and answered `undefined`, which `dsh-api-session-controller` and `dsh-subagent` mapped onto their own domain errors. Two other consumers had grown parts of the same vocabulary privately. Schedule held a strict RFC 3339 instant parser, a local-calendar parser, a four-digit-year bound and canonical formatter, a local wall-clock resolver, and its own IANA zone canonicalizer in `packages/schedule/schedule/src/domain.ts`, throwing `ScheduleInputError` instead of answering a result. `dsh-time-context` held a second copy of the IANA zone profile in `packages/context/time-context/src/request-zone.ts`, with its own regular expression and its own `Intl.DateTimeFormat` canonicalization, to enforce the stricter policy that a request may only name a zone another process resolves back to the same identity. The IANA profile and the `Intl` canonicalization step therefore existed three times in all, so a change to that profile had to be made and verified once per copy, and the next consumer of a wall-clock selector had no shared entry point to reach for. + +## Decision + +`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds the parsers, `src/duration.ts` holds the span type, and `src/index.ts` re-exports both and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume. + +The library answers discriminated results and carries no failure vocabulary. `parseOffsetInstant`, `fourDigitYearEpoch`, `parseLocalCalendar`, and `canonicalizeTimeZone` answer an `accepted` arm or a `rejected` arm naming a stable reason (`not_rfc3339`, `out_of_range`, `not_a_calendar_date`, `invalid_numeric_offset`, `malformed`, `blank`, `padded`, `not_area_location`, `unsupported`, `not_canonical_shape`). Each caller maps a reason onto its own code and diagnostic, so the library never writes model-facing text. `formatFourDigitYearInstant` returns the canonical instant text or `undefined` outside the bound, `isFourDigitYearInstant` answers whether an epoch is representable at all, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. The reason vocabulary is parallel between the two strict parsers: the same condition is `out_of_range` in both, so year 0 is refused by `parseOffsetInstant` and `parseLocalCalendar` alike, while `resolveLocalInstant` accepts any year a real `Temporal` date carries because its callers derive local dates at the UTC edges. `LocalCalendarTime.year` records that split. + +Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `decodeInstant`, `parseAtInput`, and `futureInstant` remain Schedule entry points with their existing codes and diagnostics; they now call the shared parser and map its reason. Schedule still owns the durable record shapes, the fold and transition authority, the injection-resistant framing, and all recurrence arithmetic: the cron engine, the daily and weekly date walks, the latest-due search, and the schedule-specific accepted-decision bound. + +`dsh-time-context` consumes it too and keeps its stricter policy. `deriveBrowserTimeZoneContext` reads each user-rpc zone through `canonicalizeTimeZone` and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its `unsupported` rejection carries the platform error the library caught, so that refusal keeps its `cause` in both consumers. + +`Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen, no coercion hook is overridden, and a span is bounded by the exactly representable millisecond range, which is what makes the split exact. The published surface is `fromMilliseconds` and a whole-part `decompose()`, the two entries the migrated consumer calls. + +The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s own description and README drop the `zero-dependency` phrase as ambiguous: the group's charter forbids harness dependencies so any group may depend on util, and does not ban external packages, which is why `packages/README.md` and the `util/` line in `AGENTS.md` keep the phrase while `packages/util/README.md` and this package's own description name what it loads. + +Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's suites assert error codes and a few unrelated message substrings, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included. + +## Alternatives considered + +**Keep the shared module private to Schedule.** A Schedule-local `time` module beside `domain.ts` would shrink that file without touching a published package, its lockfile entry, its README, or its dependency set. It was rejected because the duplication this change exists to remove is the duplicate *profile*, not the duplicate line count: the zone regular expression, the canonical `Intl` step, and the instant profile would still exist twice, and the next boundary that accepts a wall-clock selector would copy a third. + +**Give the library its own error type.** A `TimeInputError` carrying the reason would let callers rethrow instead of mapping, shortening each call site. It was rejected because `util/time`'s existing contract is that each boundary owns its refusal vocabulary, and Schedule's messages reach the model through the tool result `{ code, message }` and the client's message card; a shared error class would put one caller's wording into another caller's surface. + +**Have the library return `undefined` instead of reasons.** That is the shape `canonicalClientTimeZone` already has, and it was the smaller change. It was rejected because Schedule must distinguish a malformed instant from a non-existent calendar date from an out-of-range clock field to keep its existing diagnostics, and `canonicalizeTimeZone` must distinguish a bad name from a locally unresolvable one; collapsing those to one falsy value would have forced Schedule to re-derive the distinction with its own regular expressions, restoring the duplication. + +**Brand the millisecond count instead of building a class.** `Branded` already exists in this repository for opaque identities, and a branded count is smaller than a class. It was rejected because the brand does not survive the operation that matters: TypeScript accepts `brandedA + brandedB` and answers an unbranded `number`, so a caller who adds a span to an epoch silently loses the unit, which is the exact mistake the type exists to prevent. A class makes that addition a type error, and it also gives the whole-part split one home. + +**Hold the span in nanoseconds.** `Temporal` carries nanoseconds internally as a `bigint`, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a `bigint`, which no `number` arithmetic, no `Date`, and no `JSON.stringify` accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a `number` would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name. + +**Add formatting and DST arithmetic for other consumers too.** No current consumer needs model-facing formatting or offset arithmetic from this library: `dsh-time-context` owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need. + +## Consequences + +The time profile now has one home. A change to the accepted instant profile or the zone profile is made once and covered once, and a new boundary imports it instead of copying it. Schedule's `domain.ts` loses the five regular expressions that spelled the instant, calendar-date, local-time, and zone profiles, the `Intl` canonicalization, and the hand-rolled calendar conversion. It still imports the library's two bound constants for its date arithmetic at the UTC edges and delegates every representability guard to `isFourDigitYearInstant`, so the range itself has one owner. + +The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message. + +A value type publishes what its consumers call, so the span exposes the constructor and the split that `dsh-time-context` uses and nothing else. A conversion, an arithmetic operation, or a `zero` identity lands with the caller that needs it; the seconds-valued fields at `dsh-schedule` and the DeepSeek file store are the spans that will ask for a second-unit conversion, and the bound and the freeze the class already carries are what make that addition a small one. The package README records the un-migrated boundaries as a known limitation. + +The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, measured over more than eight thousand inputs including negatives, fractions, and values past a year. That measurement is not a committed test: the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). Two samples do change: `NaN` rendered `NaNs` and `+Infinity` rendered `Infinityd NaNs`, and both now throw from the span constructor, so a reading that is not a finite number fails where it is produced instead of rendering text. A `-Infinity` sample is unchanged, because the consumer clamps it to zero before the span is built. + +Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch. diff --git a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md new file mode 100644 index 0000000000..838f6b9d4b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 面向墙钟选择器的共享时间词汇 + +Status: implemented + +[English](2026-09-24-shared-time-vocabulary.md) | 中文 + +## Problem + +`dsh-util-time` 此前只承担一项职责、面向一种调用方:`canonicalClientTimeZone` 在协议边界校验调用方所报时区并回答 `undefined`,由 `dsh-api-session-controller` 与 `dsh-subagent` 映射成各自的域错误。同一套词汇的其余部分被另外两个消费方各自私下养大。Schedule 在 `packages/schedule/schedule/src/domain.ts` 里持有严格的 RFC 3339 时刻解析器、本地日历解析器、四位年份边界与规范格式化、本地墙钟解析,以及自己的 IANA 时区规范化实现——它抛 `ScheduleInputError` 而不是回答结果。`dsh-time-context` 在 `packages/context/time-context/src/request-zone.ts` 里持有第二份 IANA 时区剖面,配有自己的正则与自己的 `Intl.DateTimeFormat` 规范化,用来执行更严格的策略:一轮请求只能报出另一个进程会解析回同一标识的时区。因此 IANA 剖面与那一步 `Intl` 规范化合计存在三份,该剖面的任何变更都要逐份改、逐份验;而下一个需要墙钟选择器的消费方没有共享入口可用。 + +## Decision + +`dsh-util-time` 拥有各边界所接收的时间词汇。解析器位于 `packages/util/time/src/instant.ts`,时间段类型位于 `src/duration.ts`,`src/index.ts` 转出两者,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。 + +本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`out_of_range`、`not_a_calendar_date`、`invalid_numeric_offset`、`malformed`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`isFourDigitYearInstant` 回答某个纪元是否可表示;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。两个严格解析器的原因词汇彼此对称:同一条件在两者中都叫 `out_of_range`,所以年份 0 被 `parseOffsetInstant` 与 `parseLocalCalendar` 同样拒绝;而 `resolveLocalInstant` 接受真实 `Temporal` 日期携带的任何年份,因为它的调用方需要在 UTC 边界推导本地日期。`LocalCalendarTime.year` 记录了这一分工。 + +Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。 + +`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。 + +`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结,不覆盖任何强制转换钩子,且时间段受可精确表示的毫秒范围约束——这正是拆分精确的前提。公开面就是 `fromMilliseconds` 与整数量拆解 `decompose()`,即已迁移消费方所调用的两个入口。 + +受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此 `packages/README.md` 与 `AGENTS.md` 里的 `util/` 一行保留该说法,而 `packages/util/README.md` 与本包自己的描述写明它加载了什么。 + +可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 + +## Alternatives considered + +**把共享模块保持为 Schedule 私有。** 在 `domain.ts` 旁抽出一个 Schedule 私有的 `time` 模块可以缩小它,而不触碰已发布包、其 lockfile 条目、README 或依赖集合。之所以否决,是因为本次要消除的重复是重复的**剖面**,不是重复的行数:时区正则、`Intl` 规范化步骤与时刻剖面仍会存在两份,下一个接收墙钟选择器的边界仍会复制出第三份。 + +**让本库自带错误类型。** 一个携带原因的 `TimeInputError` 可以让调用方直接重抛而不做映射,缩短每个调用点。之所以否决,是因为 `util/time` 既有契约规定每个边界拥有自己的拒绝词汇,而 Schedule 的消息会通过工具结果的 `{ code, message }` 与客户端的消息卡片到达模型;共享错误类会把一个调用方的措辞带进另一个调用方的界面。 + +**让本库回答 `undefined` 而不是原因。** 这正是 `canonicalClientTimeZone` 已有的形态,也是改动更小的做法。之所以否决,是因为 Schedule 必须区分格式错误的时刻、日历上不存在的日期与越界时钟字段,才能保留既有诊断;`canonicalizeTimeZone` 也必须区分名称本身不合法与本地无法解析。把二者压成一个假值,会迫使 Schedule 用自己的正则重新推导这层区分,从而恢复重复。 + +**给毫秒计数加 brand,而不是造类。** 本仓库已有用于不透明标识的 `Branded`,branded 计数也比类更小。之所以否决,是因为 brand 撑不过真正要紧的那步运算:TypeScript 接受 `brandedA + brandedB` 并回答无品牌的 `number`,于是把时间段加到纪元上的调用方会静默丢掉单位,而这正是该类型要防的错误。类会把这种相加变成类型错误,也让整数量拆解只有一个归宿。 + +**用纳秒保存时间段。** `Temporal` 内部以 `bigint` 携带纳秒,所以纳秒时间段是可表示的。之所以否决,理由与边界保持毫秒相同:计数必须变成 `bigint`,而 `number` 算术、`Date` 与 `JSON.stringify` 都不接受它,于是每个消费方与每条持久记录都要多一次字符串或二进制转换。若改用 `number` 表示纳秒,在当前离线纪元量级上它会被量化到约 256 ns,那是同一个名字下的另一个值。 + +**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。 + +## Consequences + +时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它仍然导入本库的两个边界常量用于其 UTC 边界日期运算,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。 + +代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。 + +值类型只发布消费方会调用的部分,因此这个时间段只暴露 `dsh-time-context` 用到的构造入口与拆解,其余不发布。某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地;`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段就是将来会要求第二个单位换算的那些跨度,而类已经携带的范围约束与冻结正是让这次添加保持很小的原因。包 README 把这些尚未迁移的边界记为已知限制。 + +耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得。该测量不是提交进仓库的测试:消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有两个样本会变:`NaN` 原本渲染出 `NaNs`、`+Infinity` 原本渲染出 `Infinityd NaNs`,现在两者都由时间段构造函数抛出,于是非有限的读数在产生它的地方失败,而不是渲染出一段文本。`-Infinity` 样本不变,因为消费方在构造时间段之前已把它钳到零。 + +本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 0682e6e9ad..d6a1a63bfa 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -3,6 +3,7 @@ import type { AfterPackContext, BeforePackContext } from 'app-builder-lib' /** Electron-builder fields asserted by the Desktop release tests. */ export interface DesktopElectronBuilderConfig { readonly appId: string + readonly copyright: string readonly artifactName: string readonly protocols: readonly [{ readonly name: 'DeepSeek Harness'; readonly schemes: readonly ['dsh'] }] readonly directories: { @@ -43,6 +44,7 @@ export interface DesktopElectronBuilderConfig { } } readonly nsis: { + readonly uninstallDisplayName: '${productName}' readonly include: string readonly oneClick: false readonly perMachine: false diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1d44f92b93..64a6bff8fc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,6 +4,9 @@ "version": "0.2.1-rc.2", "private": true, "license": "MIT", + "author": { + "name": "Hangzhou DeepSeek Artificial Intelligence Co., Ltd." + }, "type": "module", "main": "lib/main.js", "scripts": { diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index af378a930a..96c2ee0cf0 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -51,8 +51,8 @@ en: a7aa3066e649ffe8 zh: 4334637910058c09 /desktop-packaging/installation-and-release-verification: - en: 0006b6b1ae4998f4 - zh: 2bab6918b0c37b25 + en: eb35eb973b796643 + zh: c1c52311632b7296 /desktop-packaging/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 6e2ec84cc8..a77d260591 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -274,6 +274,8 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn ## Installation and release verification +Windows lists the installed application by product name without a version suffix; the version remains in the separate version field. Desktop's `package.json` `author.name` supplies the installer publisher and executable company name, independently of the signing certificate. Windows and macOS retain product-name copyright text. Installation or upgrade with a rebuilt installer applies the new metadata; existing installations retain their previous values until then. + Keep the generated installer, its version and packaging records together. Review signatures and runtime checks before installation, then follow the [installed-update qualification procedure](../tests/installed-update/README.md) for installed launch and upgrade evidence. The [Desktop test record](../tests/README.md) owns verification limits; a successful workflow alone does not establish production preset, new-workstation or installed-upgrade acceptance. ## Dev Note diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 3c8d101ecc..54e9d740f8 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -274,6 +274,8 @@ macOS 签名打包在组装 App 时、代码签名前写入 `Contents/Resources/ ## 安装与发布验收 +Windows 以不带版本后缀的产品名称显示已安装应用,版本保留在单独的版本字段中。Desktop 的 `package.json` 中的 `author.name` 提供安装包发布者和可执行文件公司名称,与签名证书相互独立。Windows 和 macOS 的版权文本仍使用产品名称。重新构建的安装包在安装或升级时写入新元数据;既有安装在此之前保留原值。 + 一起保留生成的安装包、版本和打包记录。安装前检查签名与运行时验证结果,再按[已安装更新验收流程](../tests/installed-update/README.zh.md)记录安装后启动及升级证据。[Desktop 测试记录](../tests/README.zh.md)维护验证限制;工作流成功本身不代表 production preset、新工作站或已安装升级验收通过。 ## 开发备注 diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 4b9cd27a8d..7dc39ff326 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -114,6 +114,8 @@ export function createElectronBuilderConfig( ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, }, productName: 'DeepSeek Harness', + // The default copyright uses author.name; this line names the product instead. + copyright: `Copyright © ${new Date().getFullYear()} \${productName}`, // Unsigned builds carry their own suffix so a shared file can never pass for a release artifact. artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${output.suffix}.\${ext}`, directories: { output: output.directory }, @@ -250,6 +252,7 @@ export function createElectronBuilderConfig( target: ['AppImage'], }, nsis: { + uninstallDisplayName: '${productName}', installerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'), uninstallerSidebar: join(buildPaths.root, 'installer-ui', 'uninstaller-sidebar.bmp'), include: fileURLToPath(new URL('./installer.nsh', import.meta.url)), diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 15a4610d98..d971bb262d 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -1,7 +1,8 @@ import { tmpdir } from 'node:os' import { readFileSync } from 'node:fs' import { Arch, Platform } from 'electron-builder' -import { Packager } from 'app-builder-lib' +import { AppInfo, Packager } from 'app-builder-lib' +import { expandMacro } from 'app-builder-lib/out/util/macroExpander.js' import { describe, expect, it, vi } from 'vitest' const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => undefined) })) @@ -19,7 +20,7 @@ describe('installer preparation preserves application dependencies', () => { DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://test.example.com', }, platform, 'x64')).toThrow('DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN') }) - it.each(['win32', 'darwin'] as const)('keeps electron-builder responsible for node_modules on %s', async (platform) => { + it.each(['win32', 'darwin'] as const)('preserves installer metadata and dependency ownership on %s', async (platform) => { execute.mockClear() const env = { DSH_DESKTOP_APP_ID: 'com.example.installer', @@ -37,6 +38,18 @@ describe('installer preparation preserves application dependencies', () => { try { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(env, platform, 'x64') + const metadataPackager = new Packager({ projectDir: tmpdir() }) + const metadata: unknown = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) + Object.defineProperties(metadataPackager, { + config: { value: config }, + metadata: { value: metadata }, + }) + const appInfo = new AppInfo(metadataPackager, undefined) + expect(appInfo.companyName).toBe('Hangzhou DeepSeek Artificial Intelligence Co., Ltd.') + expect(appInfo.copyright).toBe(`Copyright © ${new Date().getFullYear()} DeepSeek Harness`) + if (platform === 'win32') { + expect(expandMacro(config.nsis.uninstallDisplayName, null, appInfo, {}, false)).toBe('DeepSeek Harness') + } const aboutIcon = config.extraResources.find(resource => resource.to === 'icon.png') expect(aboutIcon).toBeDefined() expect(readFileSync(aboutIcon!.from)).toEqual(readFileSync(new URL('../resources/icon-windows.png', import.meta.url))) diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts index 0c6a2ecf02..a19b71192b 100644 --- a/apps/web/tests/collapse-timing.e2e.ts +++ b/apps/web/tests/collapse-timing.e2e.ts @@ -12,7 +12,7 @@ import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, parseSeedFixture, readPersistedEvents, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' -import { newEnglishPage, openSettings, saveFailureShot, writeComposerDraft } from './support.ts' +import { newEnglishPage, openSettings, pinBrowserClock, pinHostClock, saveFailureShot, WEB_FIXTURE_TIME, writeComposerDraft } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/collapse-timing', import.meta.url)) const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)) @@ -38,8 +38,12 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { let fixture: string let prompt: string let replayDir: string + let hostClock: ReturnType | undefined beforeAll(async () => { + // Seeded and live prompt timestamps share the renderer's fixture day, so a run + // that spans Asia/Shanghai midnight renders no date prefix in the aria. + if (MODE !== 'record') hostClock = pinHostClock() fixture = await readFile(SEED, 'utf8') const prompts = fixtureUserPrompts(fixture) expect(prompts).toHaveLength(1) @@ -56,9 +60,10 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { // This rendering borrows a completed turn and replays only its final response as a continuation. compareReplaySession: false, }) - await seedSession(scaffold, fixture, SEED_ID) + await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME }) browser = await chromium.launch({ headless: true }) page = await newEnglishPage(browser) + if (MODE !== 'record') await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() @@ -66,8 +71,12 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { afterAll(async () => { const failures: unknown[] = [] - await browser?.close().catch((error: unknown) => failures.push(error)) - await scaffold?.close().catch((error: unknown) => failures.push(error)) + try { + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + } finally { + hostClock?.mockRestore() + } if (replayDir !== undefined) { await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) } diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 495b641086..c7722bf3cb 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3704,7 +3704,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:46`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c2e07458bb..4cdeb835d6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3706,7 +3706,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:46`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:47`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ diff --git a/docs/module-graph.md b/docs/module-graph.md index 8618d0fef7..90537b1933 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -654,6 +654,7 @@ flowchart TD pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_time_context --> pkg_session_projection + pkg_time_context --> pkg_util_time pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_session pkg_tmux_context --> pkg_session_projection @@ -1388,6 +1389,7 @@ flowchart TD pkg_schedule --> pkg_subagent pkg_schedule --> pkg_tools pkg_schedule --> pkg_typert_protocol + pkg_schedule --> pkg_util_time pkg_schedule --> pkg_workspace pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1646,7 +1648,7 @@ flowchart TD | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`util-time`](../packages/util/time) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`util-values`](../packages/util/values) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1773,7 +1775,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 345f5702e3..148fb51478 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -656,6 +656,7 @@ flowchart TD pkg_time_context --> pkg_llm pkg_time_context --> pkg_session pkg_time_context --> pkg_session_projection + pkg_time_context --> pkg_util_time pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_session pkg_tmux_context --> pkg_session_projection @@ -1390,6 +1391,7 @@ flowchart TD pkg_schedule --> pkg_subagent pkg_schedule --> pkg_tools pkg_schedule --> pkg_typert_protocol + pkg_schedule --> pkg_util_time pkg_schedule --> pkg_workspace pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1648,7 +1650,7 @@ flowchart TD | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | | [`harness-dev-context`](../packages/context/harness-dev-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | +| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`util-time`](../packages/util/time) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) | | [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`util-values`](../packages/util/values) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | @@ -1775,7 +1777,7 @@ flowchart TD | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow), [`working-directory`](../packages/session/working-directory) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`util-time`](../packages/util/time), [`workspace`](../packages/workspace/workspace) | | [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) | | [`sdk-jsonrpc-server`](../packages/sdk/server) | `sdk` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`working-directory`](../packages/session/working-directory) | | [`client-test-runtime`](../packages/test-support/client-runtime) | `test-support` | [`api-gateway`](../packages/api/gateway), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-hmr`](../packages/client/hmr), [`client-modules`](../packages/client/modules), [`client-store`](../packages/client/store), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web`](../packages/client/web), [`remote-mock`](../packages/test-support/remote-mock), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index edb9343501..44f5899cbf 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -38,7 +38,8 @@ "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*" + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:~", @@ -55,6 +56,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", - "@deepseek-ai/dsh-tools": "workspace:*" + "@deepseek-ai/dsh-tools": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*" } } diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 03bc586d45..c1d959ecd2 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -19,6 +19,7 @@ declare module '@deepseek-ai/dsh-llm' { import type { UserMessage } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' +import { Duration } from '@deepseek-ai/dsh-util-time' import type {} from '@deepseek-ai/dsh-session-projection' import { deriveBrowserTimeZoneContext, @@ -56,15 +57,17 @@ export const Config: z = z.object({ refreshIntervalMs: z.number(), }) -/** Format a non-negative elapsed millisecond count as compact whole-second units. */ +/** + * Format a non-negative elapsed millisecond count as compact whole-second units. + * + * A backward wall-clock reading is a property of the clock rather than of the + * span, so it clamps to zero here; the shared span type then supplies the whole + * parts, and this package keeps the wording. + */ function formatDuration(elapsedMs: number): string { - let seconds = Math.floor(Math.max(0, elapsedMs) / 1000) - const days = Math.floor(seconds / 86_400) - seconds %= 86_400 - const hours = Math.floor(seconds / 3600) - seconds %= 3600 - const minutes = Math.floor(seconds / 60) - seconds %= 60 + const { days, hours, minutes, seconds } = Duration + .fromMilliseconds(Math.max(0, elapsedMs)) + .decompose() const parts: string[] = [] if (days > 0) parts.push(`${days}d`) if (hours > 0) parts.push(`${hours}h`) diff --git a/packages/context/time-context/src/request-zone.ts b/packages/context/time-context/src/request-zone.ts index 41dad757af..800cd04124 100644 --- a/packages/context/time-context/src/request-zone.ts +++ b/packages/context/time-context/src/request-zone.ts @@ -1,17 +1,22 @@ /** Browser-zone derivation and model-facing policy text for one open request turn. */ +import { canonicalizeTimeZone } from '@deepseek-ai/dsh-util-time' import type { UserMessage } from '@deepseek-ai/dsh-llm' import { assertNever } from '@deepseek-ai/dsh-util-values' -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ - /** Browser-zone facts derived from user-rpc messages in one open turn. */ export type BrowserTimeZoneContext = | { readonly kind: 'resolved'; readonly timeZone: string } | { readonly kind: 'mixed'; readonly timeZones: readonly string[] } | { readonly kind: 'missing' } -/** Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. */ +/** + * Read and validate a Host-canonicalized browser zone from one ordinary user-rpc message. + * + * A request may only name a zone another process resolves back to the same + * identity, so the shared vocabulary's canonical answer must equal the reported + * value; an alias is refused here rather than silently rewritten. + */ function browserTimeZone(message: UserMessage): string | undefined { const source = message.source const value = source.kind === 'user' @@ -22,21 +27,27 @@ function browserTimeZone(message: UserMessage): string | undefined { ? source.clientTimeZone : undefined if (value === undefined) return undefined - if (value !== 'UTC' && !IANA_TIME_ZONE.test(value)) { - throw new TypeError( - `browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`, - ) + const resolved = canonicalizeTimeZone(value) + if (resolved.kind === 'accepted') { + if (resolved.timeZone !== value) { + throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) + } + return value } - let canonical: string - try { - canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone - } catch (error: unknown) { - throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: error }) + switch (resolved.reason) { + case 'unsupported': + throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: resolved.cause }) + case 'blank': + case 'padded': + case 'not_area_location': + throw new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`) + /* v8 ignore next 2 -- Intl answers a canonical Area/Location for every name it accepts. */ + case 'not_canonical_shape': + throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) + /* v8 ignore next 2 -- TimeZoneRejection is closed; this arm only makes adding a reason a compile error. */ + default: + assertNever(resolved.reason, 'TimeZoneRejection') } - if (canonical !== value) { - throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) - } - return value } /** diff --git a/packages/context/time-context/tests/request-zone.spec.ts b/packages/context/time-context/tests/request-zone.spec.ts index a4752eea73..74eb828337 100644 --- a/packages/context/time-context/tests/request-zone.spec.ts +++ b/packages/context/time-context/tests/request-zone.spec.ts @@ -53,6 +53,29 @@ describe('browser request-zone context', () => { ])).toThrow(/browser time zone must be canonical/) }) + it('keeps the exact refusal and the platform cause for every unusable zone', () => { + const refusal = (timeZone: string): Error => { + try { + deriveBrowserTimeZoneContext([browserMessage(timeZone)]) + throw new Error('expected a browser-zone refusal') + } catch (error: unknown) { + expect(error).toBeInstanceOf(TypeError) + return error as Error + } + } + expect(refusal('').message) + .toBe('browser time zone must be canonical UTC or IANA Area/Location: ""') + expect(refusal('+08:00').message) + .toBe('browser time zone must be canonical UTC or IANA Area/Location: "+08:00"') + expect(refusal(' UTC').message) + .toBe('browser time zone must be canonical UTC or IANA Area/Location: " UTC"') + expect(refusal('Etc/UTC').message).toBe('browser time zone must be canonical: "Etc/UTC"') + const unsupported = refusal('Not/A_Real_Zone') + expect(unsupported.message).toBe('browser time zone is unsupported: "Not/A_Real_Zone"') + // The platform refusal stays the operator's only evidence of why a well-formed name failed. + expect((unsupported as Error & { cause?: unknown }).cause).toBeInstanceOf(RangeError) + }) + it('renders one explicit model policy for every context', () => { expect(renderBrowserTimeZoneContext({ kind: 'resolved', timeZone: 'Asia/Shanghai' })) .toContain('Interpret otherwise-unqualified dates and times in this zone.') diff --git a/packages/context/time-context/tsconfig.json b/packages/context/time-context/tsconfig.json index c8284be70f..e947d3a439 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../session/session-projection" + }, + { + "path": "../../util/time" } ] } diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 17ca577e57..d25dda9871 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" }, @@ -84,6 +85,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-util-time": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" } diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 28748ace0a..492730d1c1 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -4,6 +4,20 @@ */ import { Temporal } from '@js-temporal/polyfill' +import { + MAX_FOUR_DIGIT_YEAR_MS, + MIN_FOUR_DIGIT_YEAR_MS, + canonicalizeTimeZone as canonicalizeSharedTimeZone, + formatFourDigitYearInstant, + fourDigitYearEpoch, + isFourDigitYearInstant, + parseLocalCalendar, + parseOffsetInstant, + resolveLocalInstant, +} from '@deepseek-ai/dsh-util-time' +import type { + InstantRejection, LocalCalendarRejection, LocalCalendarTime, TimeZoneRejection, +} from '@deepseek-ai/dsh-util-time' import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionLogOffset as SessionLogOffsetType } from '@deepseek-ai/dsh-session' import type { @@ -50,19 +64,7 @@ export const MAX_TITLE_LENGTH = 120 */ export const CRON_SEARCH_HORIZON_YEARS = 400 -const MIN_FOUR_DIGIT_YEAR_MS = Date.parse('0001-01-01T00:00:00.000Z') -const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z') -const UTC_INSTANT = /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/ -const OFFSET_INSTANT = new RegExp( - String.raw`^(?\d{4})-(?\d{2})-(?\d{2})` - + String.raw`T(?\d{2}):(?\d{2}):(?\d{2})` - + String.raw`(?:\.(?\d{1,3}))?(?Z|(?[+-])` - + String.raw`(?\d{2}):(?\d{2}))$`, -) -const LOCAL_DATE = /^(?\d{4})-(?\d{2})-(?\d{2})$/ -const LOCAL_TIME = /^(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?\d{1,3}))?$/ const LOCAL_CLOCK_TIME = /^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?$/ -const IANA_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ /** Error from malformed or transition-invalid durable Schedule data. */ export class ScheduleLogError extends Error { @@ -252,116 +254,63 @@ function decodeId(value: unknown): ScheduleIdType { return ScheduleId(value) } +/** Stable Schedule diagnostic for a `scheduledAt` outside the canonical profile. */ +const NON_CANONICAL_INSTANT_MESSAGE = 'scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant' + /** Validate one canonical four-digit-year UTC instant. */ function decodeInstant(value: unknown): string { - if (typeof value !== 'string' || !UTC_INSTANT.test(value)) { - throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant') - } - const epoch = Date.parse(value) - if (!Number.isFinite(epoch) || new Date(epoch).toISOString() !== value) { - throw new ScheduleLogError('scheduledAt is not a real UTC calendar instant') + if (typeof value !== 'string') { + throw new ScheduleLogError(NON_CANONICAL_INSTANT_MESSAGE) } + const parsed = fourDigitYearEpoch(value) + if (parsed.kind === 'rejected') throw new ScheduleLogError(instantDiagnostic(parsed.reason)) return value } -interface CalendarParts { - readonly year: number - readonly month: number - readonly day: number - readonly hour: number - readonly minute: number - readonly second: number - readonly millisecond: number +/** + * Map one shared instant rejection onto the stable stored-data diagnostic. + * @param reason - Reason the shared vocabulary refused the instant. + * @returns The Schedule log diagnostic for that refusal. + */ +function instantDiagnostic(reason: InstantRejection): string { + switch (reason) { + case 'not_a_calendar_date': + return 'scheduledAt is not a real UTC calendar instant' + case 'not_rfc3339': + case 'out_of_range': + case 'invalid_numeric_offset': + return NON_CANONICAL_INSTANT_MESSAGE + /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ + default: { + const unreachable: never = reason + throw new ScheduleLogError(`unknown instant rejection ${String(unreachable)}`) + } + } } -/** Read one required named regular-expression group as a number. */ -function groupNumber(groups: Record, name: string): number { - const value = groups[name] - /* v8 ignore next -- successful fixed regexes always provide every requested group. */ - if (value === undefined) throw new ScheduleInputError('invalid_rule', 'The at value has an invalid shape.') - return Number(value) -} - -/** Convert exact calendar fields to a UTC-shaped epoch while rejecting normalization. */ -function calendarEpoch(parts: CalendarParts): number { - const value = new Date(0) - value.setUTCHours(0, 0, 0, 0) - value.setUTCFullYear(parts.year, parts.month - 1, parts.day) - value.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond) - const epoch = value.getTime() - if (!Number.isFinite(epoch) - || value.getUTCFullYear() !== parts.year - || value.getUTCMonth() + 1 !== parts.month - || value.getUTCDate() !== parts.day - || value.getUTCHours() !== parts.hour - || value.getUTCMinutes() !== parts.minute - || value.getUTCSeconds() !== parts.second - || value.getUTCMilliseconds() !== parts.millisecond) { - throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.') +/** + * Map one shared zone rejection onto the stable Schedule input code. + * @param reason - Reason the shared vocabulary refused the name. + * @param cause - Platform refusal the shared vocabulary caught, when it carried one. + * @returns The public Schedule diagnostic for that refusal. + */ +function timeZoneError(reason: TimeZoneRejection, cause?: unknown): ScheduleInputError { + const options = cause === undefined ? undefined : { cause } + switch (reason) { + case 'blank': + case 'padded': + case 'not_area_location': + case 'unsupported': + return new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.', options) + /* v8 ignore next 2 -- the shared parser does not answer not_canonical_shape for a canonical zone. */ + case 'not_canonical_shape': + return new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.', options) + /* v8 ignore next 3 -- TimeZoneRejection is closed; this arm only makes adding a reason a compile error. */ + default: { + const unreachable: never = reason + return unreachable + } } - return epoch -} - -/** Normalize an optional one-to-three digit fractional second to milliseconds. */ -function milliseconds(value: string | undefined): number { - return value === undefined ? 0 : Number(value.padEnd(3, '0')) -} - -/** Require a safe, representable, strictly future UTC target. */ -function futureInstant(epoch: number, now: number): string { - if (!Number.isSafeInteger(now) || !Number.isSafeInteger(epoch) - || epoch < MIN_FOUR_DIGIT_YEAR_MS || epoch > MAX_FOUR_DIGIT_YEAR_MS) { - throw new ScheduleInputError( - 'time_out_of_range', - 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', - ) - } - if (epoch <= now) { - throw new ScheduleInputError('not_future', 'The scheduled time must be strictly in the future.') - } - const instant = new Date(epoch).toISOString() - /* v8 ignore next -- an in-range integral Date always formats as the canonical UTC profile. */ - if (!UTC_INSTANT.test(instant)) { - throw new ScheduleInputError( - 'time_out_of_range', - 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', - ) - } - return instant -} - -/** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */ -function parseOffsetInstant(value: string): number { - const match = OFFSET_INSTANT.exec(value) - const groups = match?.groups - if (groups === undefined) { - throw new ScheduleInputError( - 'invalid_rule', - 'at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.', - ) - } - const parts: CalendarParts = { - year: groupNumber(groups, 'year'), - month: groupNumber(groups, 'month'), - day: groupNumber(groups, 'day'), - hour: groupNumber(groups, 'hour'), - minute: groupNumber(groups, 'minute'), - second: groupNumber(groups, 'second'), - millisecond: milliseconds(groups['fraction']), - } - if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { - throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.') - } - const localEpoch = calendarEpoch(parts) - if (groups['zone'] === 'Z') return localEpoch - const offsetHour = groupNumber(groups, 'offsetHour') - const offsetMinute = groupNumber(groups, 'offsetMinute') - if (offsetHour > 23 || offsetMinute > 59 - || (groups['sign'] === '-' && offsetHour === 0 && offsetMinute === 0)) { - throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.') - } - const direction = groups['sign'] === '+' ? 1 : -1 - return localEpoch - direction * (offsetHour * 60 + offsetMinute) * 60_000 } /** @@ -370,69 +319,84 @@ function parseOffsetInstant(value: string): number { * @returns The runtime's canonical IANA name. */ export function canonicalizeTimeZone(value: string): string { - if (value.length === 0 || value.trim() !== value || (value !== 'UTC' && !IANA_ZONE.test(value))) { - throw new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.') - } - let canonical: string - try { - canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone - } catch (error: unknown) { - throw new ScheduleInputError( - 'invalid_time_zone', - 'time_zone must be UTC or a valid IANA Area/Location name.', - { cause: error }, - ) - } - /* v8 ignore next -- Intl returns the requested canonical zone or an IANA canonical alias. */ - if (canonical !== 'UTC' && !IANA_ZONE.test(canonical)) { - throw new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.') - } - return canonical + const resolved = canonicalizeSharedTimeZone(value) + if (resolved.kind === 'rejected') throw timeZoneError(resolved.reason, resolved.cause) + return resolved.timeZone } /** Parse strict local calendar fields without consulting a process time zone. */ -function parseLocalAt(value: LocalAtInput): CalendarParts { - const dateMatch = LOCAL_DATE.exec(value.date) - const timeMatch = LOCAL_TIME.exec(value.time) - const date = dateMatch?.groups - const time = timeMatch?.groups - if (date === undefined || time === undefined) { - throw new ScheduleInputError( - 'invalid_rule', - 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.', - ) +function parseLocalAt(value: LocalAtInput): LocalCalendarTime { + const parsed = parseLocalCalendar(value.date, value.time) + if (parsed.kind === 'rejected') { + throw new ScheduleInputError('invalid_rule', localCalendarDiagnostic(parsed.reason)) } - const parts: CalendarParts = { - year: groupNumber(date, 'year'), - month: groupNumber(date, 'month'), - day: groupNumber(date, 'day'), - hour: groupNumber(time, 'hour'), - minute: groupNumber(time, 'minute'), - second: groupNumber(time, 'second'), - millisecond: milliseconds(time['fraction']), - } - if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { - throw new ScheduleInputError('invalid_rule', 'The local at value must be a real ISO calendar date and time.') - } - calendarEpoch(parts) - return parts + return parsed.time } -/** Resolve only the earlier overlap instant; a gap fails the local-field round trip. */ -function localInstant(local: Temporal.PlainDateTime, timeZone: string): number | undefined { - const zoned = local.toZonedDateTime(timeZone, { disambiguation: 'earlier' }) - return zoned.toPlainDateTime().equals(local) ? zoned.epochMilliseconds : undefined +/** Map one shared local-calendar rejection onto the stable Schedule diagnostic. */ +function localCalendarDiagnostic(reason: LocalCalendarRejection): string { + switch (reason) { + case 'malformed': + return 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.' + case 'not_a_calendar_date': + return 'The at value must be a real ISO calendar date and time.' + case 'out_of_range': + return 'The local at value must be a real ISO calendar date and time.' + /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ + default: { + const unreachable: never = reason + throw new Error(`unknown local calendar rejection ${String(unreachable)}`) + } + } } /** Resolve a local one-shot, rejecting nonexistent wall-clock times. */ -function resolveLocalInstant(parts: CalendarParts, timeZone: string): number { - const target = localInstant(Temporal.PlainDateTime.from(parts, { overflow: 'reject' }), timeZone) +function resolveLocalAt(parts: LocalCalendarTime, timeZone: string): number { + const target = resolveLocalInstant(parts, timeZone) if (target === undefined) { throw new ScheduleInputError('invalid_rule', 'The local at time does not exist in the selected time zone.') } return target } +/** Require a safe, representable, strictly future UTC target. */ +function futureInstant(epoch: number, now: number): string { + const instant = formatFourDigitYearInstant(epoch) + if (!Number.isSafeInteger(now) || instant === undefined) { + throw new ScheduleInputError( + 'time_out_of_range', + 'The scheduled time must be representable as a four-digit-year RFC 3339 UTC instant.', + ) + } + if (epoch <= now) { + throw new ScheduleInputError('not_future', 'The scheduled time must be strictly in the future.') + } + return instant +} + +/** Parse a strict RFC 3339 instant whose numeric offset is part of the input. */ +function parseAtOffsetInstant(value: string): number { + const parsed = parseOffsetInstant(value) + if (parsed.kind === 'accepted') return parsed.epoch + switch (parsed.reason) { + case 'not_rfc3339': + throw new ScheduleInputError( + 'invalid_rule', + 'at must use YYYY-MM-DDTHH:mm:ss with optional 1-3 digit fractional seconds and an explicit Z or numeric offset.', + ) + case 'invalid_numeric_offset': + throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.') + case 'not_a_calendar_date': + case 'out_of_range': + throw new ScheduleInputError('invalid_rule', 'The at value must be a real ISO calendar date and time.') + /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ + default: { + const unreachable: never = parsed.reason + throw new Error(`unknown instant rejection ${String(unreachable)}`) + } + } +} + /** Decode the exact v1 after record shape. */ function decodeAfterRecord(value: unknown): LegacyAfterScheduleRecord { /* v8 ignore next -- decodeLegacyScheduleRecord rejects a non-object before it dispatches here. */ @@ -794,9 +758,7 @@ export function resolveEveryOccurrence( ): RecurringOccurrence { const target = Date.parse(record.scheduledAt) const interval = record.everySeconds * 1_000 - if (!Number.isSafeInteger(acceptedAt) - || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS - || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(acceptedAt)) { throw new ScheduleLogError('every acceptedAt must be a representable four-digit-year instant') } if (!Number.isSafeInteger(interval) || interval <= 0) { @@ -813,7 +775,7 @@ export function resolveEveryOccurrence( } const occurrenceAt = new Date(occurrence).toISOString() const next = occurrence + interval - if (!Number.isSafeInteger(next) || next > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(next)) { return Object.freeze({ occurrenceAt }) } return Object.freeze({ @@ -854,7 +816,7 @@ function nextMatchingTarget( } for (; Temporal.PlainDate.compare(date, lastDate) <= 0; date = date.add({ days: 1 })) { if (!matches(date)) continue - const target = localInstant(date.toPlainDateTime(time), timeZone) + const target = resolveLocalInstant(date.toPlainDateTime(time), timeZone) if (target === undefined) continue if (target > MAX_FOUR_DIGIT_YEAR_MS) return undefined if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > now) return new Date(target).toISOString() @@ -874,7 +836,7 @@ function latestDueOccurrence( let date = localDate(acceptedAt, 'UTC').add({ days: 1 }) for (;; date = date.subtract({ days: 1 })) { if (!matches(date)) continue - const candidate = localInstant(date.toPlainDateTime(time), timeZone) + const candidate = resolveLocalInstant(date.toPlainDateTime(time), timeZone) if (candidate === undefined || candidate > acceptedAt) continue // A committed target remains due even if current tzdata places the rule before it. return Math.max(candidate, savedTarget) @@ -883,8 +845,7 @@ function latestDueOccurrence( /** Resolve a wall-clock rule only when its decision is at a representable four-digit-year instant. */ function acceptedDecision(selector: string, acceptedAt: number): number { - if (!Number.isSafeInteger(acceptedAt) - || acceptedAt < MIN_FOUR_DIGIT_YEAR_MS || acceptedAt > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(acceptedAt)) { throw new ScheduleLogError(`${selector} acceptedAt must be a representable four-digit-year instant`) } return acceptedAt @@ -1022,7 +983,7 @@ function nextCronTarget(parsed: ParsedCronExpression, timeZone: string, floor: n if (matches(date)) { for (const time of times) { if (date.equals(firstDate) && Temporal.PlainTime.compare(time, floorTime) < 0) continue - const target = localInstant(date.toPlainDateTime(time), timeZone) + const target = resolveLocalInstant(date.toPlainDateTime(time), timeZone) if (target === undefined) continue if (target > MAX_FOUR_DIGIT_YEAR_MS) return undefined if (target >= MIN_FOUR_DIGIT_YEAR_MS && target > floor) return new Date(target).toISOString() @@ -1076,7 +1037,7 @@ function latestCronOccurrence( // An earliest time that a DST gap removes leaves the bound to the first time // that does exist, and a date with no existing time keeps the full walk. const earliest = times.reduce( - (found, time) => found ?? localInstant(date.toPlainDateTime(time), timeZone), + (found, time) => found ?? resolveLocalInstant(date.toPlainDateTime(time), timeZone), undefined, ) if (earliest !== undefined && earliest > decision) { @@ -1084,7 +1045,7 @@ function latestCronOccurrence( continue } for (const time of [...times].reverse()) { - const candidate = localInstant(date.toPlainDateTime(time), timeZone) + const candidate = resolveLocalInstant(date.toPlainDateTime(time), timeZone) if (candidate === undefined || candidate > decision || candidate < MIN_FOUR_DIGIT_YEAR_MS) continue // A committed target remains due even if current tzdata places the rule before it. return Math.max(candidate, savedTarget) @@ -1320,7 +1281,7 @@ export function createAtScheduleRecord( export function parseAtInput(at: AtInput): number { let target: number if (typeof at === 'string') { - target = parseOffsetInstant(at) + target = parseAtOffsetInstant(at) } else if (isRecord(at)) { if (!hasExactKeys(at, ['date', 'time', 'time_zone'])) { throw new ScheduleInputError('invalid_rule', 'Local at must contain exactly date, time, and time_zone.') @@ -1337,7 +1298,7 @@ export function parseAtInput(at: AtInput): number { time: at['time'], time_zone: rawTimeZone, } - target = resolveLocalInstant(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone)) + target = resolveLocalAt(parseLocalAt(local), canonicalizeTimeZone(rawTimeZone)) } else { throw new ScheduleInputError('invalid_rule', 'at must be an explicit-offset string or local calendar object.') } @@ -1407,7 +1368,7 @@ export function createDailyScheduleRecord( throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.') } const { time, timeZone } = parseDailyInput(daily) - if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(now)) { throw new ScheduleInputError('time_out_of_range', 'Daily creation time must be a representable four-digit-year UTC instant.') } const scheduledAt = nextDailyTarget(dailyTime(time), timeZone, now) @@ -1460,7 +1421,7 @@ export function createWeeklyScheduleRecord( throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.') } const { time, timeZone, weekdays } = parseWeeklyInput(weekly) - if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(now)) { throw new ScheduleInputError('time_out_of_range', 'Weekly creation time must be a representable four-digit-year UTC instant.') } const scheduledAt = nextWeeklyTarget(weeklyTime(time), timeZone, weekdays, now) @@ -1494,7 +1455,7 @@ export function createCronScheduleRecord( throw new ScheduleInputError('invalid_prompt', 'prompt must be non-empty after trimming.') } const { expression, timeZone } = parseCronInput(cron) - if (!Number.isSafeInteger(now) || now < MIN_FOUR_DIGIT_YEAR_MS || now > MAX_FOUR_DIGIT_YEAR_MS) { + if (!isFourDigitYearInstant(now)) { throw new ScheduleInputError('time_out_of_range', 'Cron creation time must be a representable four-digit-year UTC instant.') } const scheduledAt = nextCronTarget(parseCronExpression(expression), timeZone, now) diff --git a/packages/schedule/schedule/tests/domain.spec.ts b/packages/schedule/schedule/tests/domain.spec.ts index 79ede24bd4..d8e87222b4 100644 --- a/packages/schedule/schedule/tests/domain.spec.ts +++ b/packages/schedule/schedule/tests/domain.spec.ts @@ -22,6 +22,7 @@ import { resolveEveryOccurrence, scheduleView, } from '../src/domain.ts' +import type { LocalAtInput } from '../src/types.ts' function scheduleEvent(data: unknown, seq = 0): SessionEvent { return { type: 'schedule/change', seq, time: 1, data } as SessionEvent @@ -557,6 +558,24 @@ describe('absolute record and time-zone resolution', () => { )).toThrow(ScheduleInputError) }) + it('keeps the three local-calendar diagnostics distinct', () => { + const diagnostic = (at: LocalAtInput): string => { + try { + createAtScheduleRecord(ScheduleId('schedule-at'), 'x', at, now, 'x') + throw new Error('expected local selector failure') + } catch (error: unknown) { + expect(error).toBeInstanceOf(ScheduleInputError) + return (error as ScheduleInputError).message + } + } + expect(diagnostic({ date: '2026-02-30', time: '09:00:00', time_zone: 'UTC' })) + .toBe('The at value must be a real ISO calendar date and time.') + expect(diagnostic({ date: '2026-08-06', time: '24:00:00', time_zone: 'UTC' })) + .toBe('The local at value must be a real ISO calendar date and time.') + expect(diagnostic({ date: '2026/08/06', time: '09:00:00', time_zone: 'UTC' })) + .toBe('Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.') + }) + it('rejects empty prompts and local instants outside the four-digit range', () => { expect(() => createAtScheduleRecord( ScheduleId('schedule-at'), ' ', '2026-08-06T01:00:00Z', now, 'Title', diff --git a/packages/schedule/schedule/tsconfig.json b/packages/schedule/schedule/tsconfig.json index 3667dd3ce9..caf721d0c9 100644 --- a/packages/schedule/schedule/tsconfig.json +++ b/packages/schedule/schedule/tsconfig.json @@ -47,6 +47,9 @@ { "path": "../../typert/protocol" }, + { + "path": "../../util/time" + }, { "path": "../../workspace/workspace" } diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index ba5a2109bc..a0c881f1b6 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -3,20 +3,20 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/README.md /: - en: 036b48d7ea9bdc0e - zh: e8e35eb01d8eb2d4 + en: 225f3432e1eb335a + zh: 5d5acd66295641da /util-shared-utilities: en: 7283f2afb343d150 zh: 32c56bd67c0314c6 /util-shared-utilities/summary: - en: 63e37eb06d07deb5 - zh: 26d94a8a8f9079ce + en: f21c74532a9c929e + zh: f34c908f47f4a58a /util-shared-utilities/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /util-shared-utilities/packages: - en: ad806472c407b81a - zh: 452ef35a52fedd9c + en: 2143dc15a12c830c + zh: c94fb851bac20a74 /util-shared-utilities/related-documentation: en: 432ae81110914571 zh: c5bf4efc007311f3 diff --git a/packages/util/README.md b/packages/util/README.md index 300ace8e7d..41a1988bb8 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -1,5 +1,5 @@ --- -description: "Package map for shared utilities: atomic file writes, branded ids, deques, JSON values, harness home paths, launch environment, native commands, output retention, time zones, and timeouts." +description: "Package map for shared utilities: atomic file writes, branded ids, deques, JSON values, harness home paths, launch environment, native commands, output retention, time and zone vocabulary, and timeouts." kind: "package-group" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone canonicalization, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics. +The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone, instant, and span vocabulary, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics. ## Table of Contents @@ -42,7 +42,7 @@ Each package provides one primitive; open a package page for how to use it. | [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers | | [`code-language/`](code-language/README.md) | Maps a filename suffix to the syntax-highlighting language that the Code preview, diff review, and the read card render | | [`output-retention/`](output-retention/README.md) | Bounds model-facing output and reports exact omission metadata | -| [`time/`](time/README.md) | Validates and canonicalizes a caller-reported IANA time zone | +| [`time/`](time/README.md) | Parses strict instants, local calendars, and caller-reported IANA zones, and holds a millisecond span | | [`timeout/`](timeout/README.md) | Deadline arithmetic, signal fusion, and timeout-versus-cancel classification | ----- diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index b98915c257..cd8cdcf3e8 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -1,5 +1,5 @@ --- -description: "共享工具家族的包映射:原子文件写入、品牌化 id、双端队列、JSON 值、harness 主目录路径、启动环境、原生命令、输出保留、时区与超时。" +description: "共享工具家族的包映射:原子文件写入、品牌化 id、双端队列、JSON 值、harness 主目录路径、启动环境、原生命令、输出保留、时间与时区词汇、超时。" kind: "package-group" --- @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、无损 JSON 值、UUID、Harness home 路径、启动环境、出站代理策略、原生命令、输出保留、时区规范化和超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 +`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、无损 JSON 值、UUID、Harness home 路径、启动环境、出站代理策略、原生命令、输出保留、时区、时刻与时间段词汇,以及超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 ## 目录 @@ -42,7 +42,7 @@ kind: "package-group" | [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器安全的 Workspace 路径与显示辅助函数 | | [`code-language/`](code-language/README.zh.md) | 把文件名后缀映射为 Code 预览、diff 审阅与 read 卡片所渲染的语法高亮语言 | | [`output-retention/`](output-retention/README.zh.md) | 限制面向模型的输出并报告精确的省略元数据 | -| [`time/`](time/README.zh.md) | 校验并规范化调用方所报的 IANA 时区 | +| [`time/`](time/README.zh.md) | 解析严格的时刻、本地日历与调用方所报的 IANA 时区,并持有毫秒精度的时间段 | | [`timeout/`](timeout/README.zh.md) | 截止时间运算、信号融合与超时和取消分类 | ----- diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index b49ed1f8e7..cad952bcf0 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -3,32 +3,41 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/time/README.md /: - en: 1816778111548561 - zh: 460c67cc44e59372 + en: 6daf8db648646a31 + zh: 156a7d47413a6a55 /dsh-util-time: en: bc299a9b8fe7717f zh: 896661ee0c4edf25 /dsh-util-time/summary: - en: f37c9eb6571622ac - zh: d782978ff11bd40f + en: cdc849b4f44094b1 + zh: b03edaa6a020d39d /dsh-util-time/table-of-contents: - en: cd2a60a905c978d6 - zh: cc71d387d0cbd442 + en: d152484eb41ac6b4 + zh: f14f34fce35a8d90 /dsh-util-time/use-this-package: - en: 1485e3ff508a8960 - zh: 47ba622434bd2625 -/dsh-util-time/api: - en: cbb4b726e939fc03 - zh: e9df4a7487433146 + en: bbff250eba0f1b89 + zh: 0a618f05cc88ee2b +/dsh-util-time/use-this-package/when-to-use-it: + en: 85291d5fb4c9000b + zh: d1717c3b01efb5d0 +/dsh-util-time/use-this-package/entry-point: + en: 7ca7ddebcb6af6be + zh: 4702a29079d6b8a7 +/dsh-util-time/understand-the-implementation: + en: 14595019b72aa45f + zh: cfea261931019621 +/dsh-util-time/further-exploration: + en: 8aa95ebdfd1f323e + zh: c6879eac9fa6e087 /dsh-util-time/model-experience: - en: 02d997b5c1318f27 - zh: 5c55d060adea756d + en: 3ea15a23d9901c72 + zh: c39eb7d446dd13af /dsh-util-time/model-experience/kv-cache-effect: - en: f63edbd9621eee70 - zh: 5e3982508b32b039 + en: daf6e45a18c087b0 + zh: e6714f1e65ed55ce /dsh-util-time/known-limitations-and-deferred-work: - en: 130db809b9a6db90 - zh: 5b0b72ada5b131dd + en: 854d5bf170c91bba + zh: b5544e6bd1319d90 /dsh-util-time/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: e88aeb617ffb74ea diff --git a/packages/util/time/README.md b/packages/util/time/README.md index 40f6b4d9ef..c86f1c7462 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -1,5 +1,5 @@ --- -description: "IANA time-zone validation and canonicalization for maintainers accepting a caller-reported zone at a wire boundary." +description: "Strict RFC 3339 instant, local-calendar, and IANA zone parsing, plus a millisecond span type, for maintainers accepting a caller-reported zone, a wall-clock selector, or an elapsed time at a boundary." kind: "package-library" --- @@ -9,12 +9,13 @@ English | [中文](README.zh.md) ## Summary -Zero-dependency zone vocabulary for the wire boundaries that accept a caller's time zone. `canonicalClientTimeZone` admits `UTC` or an IANA `Area/Location` name and answers the platform-canonical spelling of it, so an alias never reaches a durable record: a zone identity is stored on messages and re-derived later by another process, where an alias would not compare equal. The library validates and canonicalizes only — it formats no time and owns no failure vocabulary, because each boundary throws its own domain code. +Shared time vocabulary for the boundaries that accept a caller's zone, a wall-clock selector, or an elapsed time. `canonicalClientTimeZone` answers the platform-canonical `UTC` or IANA `Area/Location` spelling, so an alias never reaches a durable record. The strict parsers read RFC 3339 instants, local calendar fields, and zone names as discriminated results, and `resolveLocalInstant` applies maintained `Temporal` DST and overlap behavior. `Duration` holds a span in milliseconds and splits it into whole parts. The library owns no failure vocabulary and no model-facing text: each caller maps a reason onto its own domain code. ## Table of Contents - [Use this package](#use-this-package) -- [API](#api) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) - [Dev Note](#dev-note) @@ -24,38 +25,88 @@ Zero-dependency zone vocabulary for the wire boundaries that accept a caller's t ## Use this package +### When to use it + It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state. -Call it at the boundary that receives the zone, before the value reaches anything durable. An unusable name answers `undefined`, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. +Call it at the boundary that receives the zone or the wall-clock selector, before the value reaches anything durable. `dsh-api-session-controller` and `dsh-subagent` admit a caller-reported zone through `canonicalClientTimeZone`; `dsh-time-context` admits the zone a request turn reports through `canonicalizeTimeZone` and refuses an alias rather than rewriting it; `dsh-schedule` reads every absolute and wall-clock selector through the strict parsers and resolves its local targets through `resolveLocalInstant`. `dsh-time-context` reads its elapsed reading through `Duration` and keeps the wording of the line it injects. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value. + + +### Entry point + +```ts +import { Duration, canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time' + +const zone = canonicalClientTimeZone('US/Eastern') // 'America/New_York' or undefined +const stored = fourDigitYearEpoch('2026-03-08T02:30:00.000Z') // { kind: 'accepted', epoch } or a rejection reason +const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accepted', time } or a rejection reason + +if (local.kind === 'accepted') { + resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap +} + +Duration.fromMilliseconds(1_500).decompose() // { days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 500 } +``` + +`canonicalClientTimeZone` answers `undefined` for a name it refuses, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. `canonicalizeTimeZone`, the strict parsers, and `fourDigitYearEpoch` instead report why they refused, as a discriminated result whose `rejected` arm carries a stable reason, so each caller chooses its own code and diagnostic. `formatFourDigitYearInstant` and `resolveLocalInstant` take already-validated values and answer a string, or epoch milliseconds, with `undefined` for an unrepresentable epoch or a wall-clock value inside a DST gap. `Duration` is a frozen value type over one millisecond count: it validates that the count is finite and inside the exactly representable range, and splits a non-negative span into whole parts. It publishes only the operations its consumers call; a member for another unit or for span arithmetic lands with the caller that needs it. ----- - -## API + +## Understand the implementation -```ts -import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' -``` +
+Implementation internals — click to expand -| Export | Role | +`src/instant.ts` holds the parsers and `src/duration.ts` the span; `src/index.ts` re-exports both and adds `canonicalClientTimeZone`, the `string | undefined` form the wire boundaries already consume. The module JSDoc there states each entry's accepted profile and every reason. + +The strict parsers match a fixed regular expression before constructing any date, so a malformed spelling, an out-of-range clock field, and a date the calendar does not have answer distinct reasons. Local resolution defers to `@js-temporal/polyfill`: `disambiguation: 'earlier'` picks the first instant of an overlap, and the local-field round trip answers `undefined` for a value inside a forward transition's gap. + +| Entry | Accepted input | |---|---| -| `canonicalClientTimeZone(value)` | Canonical `UTC` or IANA `Area/Location` name for an accepted zone, `undefined` for a blank, padded, abbreviated, single-segment, or platform-unsupported one. | +| `canonicalClientTimeZone` | `UTC` or an IANA `Area/Location` name; answers `undefined` when refused. | +| `canonicalizeTimeZone` | The same names; answers the canonical spelling or a refusal reason, and carries the platform error when the platform refused a well-formed name. | +| `parseLocalCalendar` | `YYYY-MM-DD` and `HH:mm:ss`, with optional one-to-three fractional digits. | +| `parseOffsetInstant` | RFC 3339 with an explicit `Z` or numeric offset; the offset is part of the input. | +| `fourDigitYearEpoch` | The canonical stored profile: exactly three fractional digits and a `Z`. | +| `formatFourDigitYearInstant` | An integral epoch inside the four-digit-year range; answers `undefined` outside it or when non-integral. | +| `isFourDigitYearInstant` | The same range, as a predicate, so a caller that only needs the bound does not re-derive it. | +| `resolveLocalInstant` | Exact calendar fields of a real date, or an already-built `Temporal.PlainDateTime`, plus an already-canonical zone. | +| `Duration` | One finite millisecond count inside the exactly representable range, and its split into whole days, hours, minutes, seconds, and the remaining fraction. | + +
+ +----- + + +## Further Exploration + +- [Schedule Host storage](../../schedule/schedule/README.md) — the consumer that stores committed UTC targets. +- [Session controller](../../api/session-controller/README.md) — the request boundary that canonicalizes a browser zone. +- [Time context](../../context/time-context/README.md) — the request consumer that refuses a non-canonical browser zone. +- [util/ package map](../README.md) — where this library sits among the shared utilities. + +----- ## Model Experience -Indirectly, through the consumer that records a canonical zone on a durable message, from which `dsh-time-context` renders the turn's model-visible zone instruction and timestamp. +Indirectly, through the consumers that store a canonical zone or a committed UTC instant on durable records: `dsh-time-context` renders the turn's model-visible zone instruction and timestamp from the zone, and `dsh-schedule` renders its model-visible scheduler text from the committed instant. #### KV Cache effect -None of its own. The consumer that injects a zone-derived line into a request owns that request's cache behavior. +None of its own. The consumer that injects a zone- or schedule-derived line into a request owns that request's cache behavior. ## Known Limitations and Deferred Work - **Alias resolution follows the runtime's ICU data** — which name an alias group canonicalizes to is the platform's answer, so two processes on different Node builds can disagree about it. -- **Validation only** — no formatting, offset arithmetic, DST reasoning, or instant conversion; consumers needing those use `Intl` directly. +- **No recurrence arithmetic** — instant validity, local-calendar parsing, zone resolution, canonical formatting, and a millisecond span only; a caller that walks candidate dates or intervals owns that search. +- **`Duration` has no calendar units** — a day is exactly 24 hours and a month has no fixed length, so a caller that needs local calendar days derives them from the zone. +- **`Duration` publishes only what a consumer calls** — `fromMilliseconds` and `decompose()`; a conversion, an arithmetic operation, or a `zero` identity lands with the caller that needs it, so the published contract grows with a caller rather than ahead of one. +- **Seconds-unit spans still multiply** — `dsh-schedule`'s interval and delay fields and the DeepSeek file store's refresh margin are second-valued spans converted with `* 1_000`; none has moved onto this library, and an epoch expressed in seconds is not a span and must not be built into one. +- **The `Temporal` polyfill is a runtime dependency** — a consumer that imports only `canonicalClientTimeZone` still resolves it at module load, because both entries share one module. ### Dev Note diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index d58f52853c..cecc42686f 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向在协议边界接收调用方所报时区的维护者,说明 IANA 时区校验与规范化。" +description: "面向在协议边界接收调用方所报时区、墙钟选择器或耗时的维护者,说明严格的 RFC 3339 时刻、本地日历与时区解析,以及毫秒精度的时间段类型。" kind: "package-library" --- @@ -9,12 +9,13 @@ kind: "package-library" ## 概述 -零依赖的时区词汇,供接收调用方时区的协议边界使用。`canonicalClientTimeZone` 只接受 `UTC` 或 IANA `Area/Location` 名称,并回答该名称在当前平台上的规范拼写,因此别名不会进入持久记录:时区标识会存储在消息中,并由另一个进程稍后重新推导;别名在该进程中无法与规范名称比较为相等。本库只做校验与规范化——不格式化任何时间,也不持有失败词汇,因为每个边界抛自己的域码。 +共享时间词汇,供接收调用方时区、墙钟选择器或耗时的边界使用。`canonicalClientTimeZone` 回答当前平台上的规范 `UTC` 或 IANA `Area/Location` 拼写,因此别名不会进入持久记录。严格解析器以带判别值的结果读取 RFC 3339 时刻、本地日历字段与时区名称,`resolveLocalInstant` 则套用受维护的 `Temporal` 的 DST 与重叠行为。`Duration` 以毫秒保存一段时间并拆成整数量。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。 ## 目录 - [使用本包](#use-this-package) -- [API](#api) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -24,38 +25,88 @@ kind: "package-library" ## 使用本包 +### 何时使用 + 它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。 -在接收时区的那个边界上调用它,让值在进入任何持久物之前先过一遍。不可用的名称回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。 +在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。`dsh-time-context` 通过 `Duration` 读取其耗时读数,并保留它注入那一行的措辞。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。 + + +### 入口 + +```ts +import { Duration, canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time' + +const zone = canonicalClientTimeZone('US/Eastern') // 'America/New_York' or undefined +const stored = fourDigitYearEpoch('2026-03-08T02:30:00.000Z') // { kind: 'accepted', epoch } or a rejection reason +const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accepted', time } or a rejection reason + +if (local.kind === 'accepted') { + resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap +} + +Duration.fromMilliseconds(1_500).decompose() // { days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 500 } +``` + +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限且落在可精确表示的范围内,并把非负的时间段拆成整数量。它只发布消费方会调用的部分;面向其它单位或时间段算术的成员都随第一个需要它的调用方落地。 ----- - -## API + +## 理解实现 -```ts -import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' -``` +
+实现内部细节 —— 点击展开 -| 导出 | 职责 | +`src/instant.ts` 承载各解析器,`src/duration.ts` 承载时间段;`src/index.ts` 转出两者,并新增 `canonicalClientTimeZone`——协议边界已经在消费的 `string | undefined` 形式。该模块的 JSDoc 说明每个入口接受的输入剖面与全部拒绝原因。 + +严格解析器先匹配固定正则,再构造任何日期,因此拼写错误、越界时钟字段与日历上不存在的日期分别回答不同的原因。本地解析交给 `@js-temporal/polyfill`:`disambiguation: 'earlier'` 取重叠时段中的第一个时刻,而本地字段往返比对对前向跳变缺口内的值回答 `undefined`。 + +| 入口 | 接受的输入 | |---|---| -| `canonicalClientTimeZone(value)` | 对接受的时区回答规范的 `UTC` 或 IANA `Area/Location` 名称;空串、带空白、缩写、单段或平台不支持的名称回答 `undefined`。 | +| `canonicalClientTimeZone` | `UTC` 或 IANA `Area/Location` 名称;被拒绝时回答 `undefined`。 | +| `canonicalizeTimeZone` | 同样的名称;回答规范拼写或拒绝原因;平台拒绝了一个格式合法的名称时,还会携带该平台错误。 | +| `parseLocalCalendar` | `YYYY-MM-DD` 与 `HH:mm:ss`,可带 1 到 3 位小数秒。 | +| `parseOffsetInstant` | 带显式 `Z` 或数字偏移的 RFC 3339;偏移量属于输入的一部分。 | +| `fourDigitYearEpoch` | 规范存储剖面:恰好 3 位小数秒与 `Z`。 | +| `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 | +| `isFourDigitYearInstant` | 同一范围的谓词形式,只判断范围边界的调用方无需自行推导。 | +| `resolveLocalInstant` | 真实日期的精确日历字段,或已构造好的 `Temporal.PlainDateTime`,外加已规范化的时区。 | +| `Duration` | 一个落在可精确表示范围内的毫秒计数,以及它拆出的整日、整时、整分、整秒与不足一秒的余数。 | + +
+ +----- + + +## 延伸阅读 + +- [Schedule 宿主存储](../../schedule/schedule/README.zh.md) —— 保存已提交 UTC 时点的消费方。 +- [Session 控制器](../../api/session-controller/README.zh.md) —— 规范化浏览器时区的请求边界。 +- [时间上下文](../../context/time-context/README.zh.md) —— 拒绝非规范浏览器时区的请求消费方。 +- [util/ 包地图](../README.zh.md) —— 本库在共享工具中的位置。 + +----- ## 模型体验 -间接影响,取决于把规范时区记到持久消息上的那个消费方——`dsh-time-context` 据此渲染该轮模型可见的时区指令与时间戳。 +间接影响,取决于把规范时区或已提交 UTC 时刻记到持久记录上的那些消费方:`dsh-time-context` 据时区渲染本轮模型可见的时区指令与时间戳,`dsh-schedule` 据已提交时刻渲染其模型可见的调度文本。 #### KV Cache 影响 -自身没有。把时区派生文本注入请求的那个消费方,对该请求的缓存行为负责。 +自身没有。把时区或调度的派生文本注入请求的那个消费方,对该请求的缓存行为负责。 ## 已知限制与延期工作 - **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 -- **只做校验**——不格式化、不做偏移运算、不推导 DST、不做时刻换算;需要这些的消费方直接用 `Intl`。 +- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。 +- **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。 +- **`Duration` 只发布消费方会调用的部分**——`fromMilliseconds` 与 `decompose()`;某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地,公开契约跟着调用方长,而不是长在它前面。 +- **以秒为单位的跨度仍在自行相乘**——`dsh-schedule` 的间隔与延迟字段、DeepSeek 文件存储的刷新余量都是以秒为单位的跨度,用 `* 1_000` 换算;它们都还没迁移到本库,而以秒表示的纪元不是跨度,不得构造成跨度。 +- **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。 ### 开发备注 diff --git a/packages/util/time/package.json b/packages/util/time/package.json index dbcd9b63f8..79f454f7fc 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-util-time", - "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", + "description": "Time vocabulary shared by the boundaries that accept a caller's zone, a wall-clock selector, or an elapsed time: canonicalClientTimeZone (IANA zone validation and canonicalization), strict RFC 3339 instant, local-calendar, and zone resolution, and a millisecond Duration span", "version": "0.2.1-rc.2", "publishConfig": { "access": "public" @@ -26,6 +26,9 @@ "lib/types/**/*.d.ts" ], "license": "MIT", + "dependencies": { + "@js-temporal/polyfill": "0.5.1" + }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:~" }, diff --git a/packages/util/time/src/duration.ts b/packages/util/time/src/duration.ts new file mode 100644 index 0000000000..7543d299dc --- /dev/null +++ b/packages/util/time/src/duration.ts @@ -0,0 +1,110 @@ +/** + * One span of time, held as a count of milliseconds and split into the whole + * parts a reading is written in. + * + * A class rather than a branded number: arithmetic on two branded numbers + * answers an unbranded `number`, so a brand cannot stop seconds and + * milliseconds from being added, while an instance can. The published surface + * is the set the migrated consumers call; a further member lands with the caller + * that needs it. + * @module @deepseek-ai/dsh-util-time + */ + +const MS_PER_SECOND = 1_000 +const SECONDS_PER_DAY = 86_400 +const SECONDS_PER_HOUR = 3_600 +const SECONDS_PER_MINUTE = 60 + +/** Whole parts of one non-negative span, in the units a reading is written in. */ +export interface DurationParts { + /** Whole days. */ + readonly days: number + /** Whole hours left after the days, from 0 through 23. */ + readonly hours: number + /** Whole minutes left after the hours, from 0 through 59. */ + readonly minutes: number + /** Whole seconds left after the minutes, from 0 through 59. */ + readonly seconds: number + /** Fractional milliseconds left after the seconds, from 0 up to (but excluding) 1000. */ + readonly milliseconds: number +} + +/** + * Largest millisecond count a span holds. + * + * Every integer up to this bound is exactly representable, which is what makes + * the whole-part split exact; beyond it the parts a reader gets back would not + * add up to the span. + */ +const MAX_SPAN_MS = Number.MAX_SAFE_INTEGER + +/** + * One span of time in milliseconds. + * + * Instances are frozen, so a span cannot drift after it is built. A span is + * signed: a negative count builds, because the clock difference that produced it + * is real, but `decompose()` refuses it because a negative interval has no single + * whole-part split. A caller that reports parts therefore decides its own policy + * first, and the elapsed reading clamps a backward clock reading to zero before + * it builds the span. A span is bounded by the exactly representable millisecond + * range, which is wider than any interval this harness schedules, and it carries + * no calendar meaning: a month is not a fixed count of milliseconds. + */ +export class Duration { + private readonly span: number + + private constructor(milliseconds: number) { + this.span = milliseconds + Object.freeze(this) + } + + /** + * Build one span from a millisecond count. + * A negative count is accepted, so a caller can hold a backward clock difference; + * such a span cannot be decomposed, and the caller states its own sign policy. + * @param milliseconds - Signed count within the exactly representable range (about 285,000 years); fractions are kept. + * @returns The span. + * @throws TypeError when the count is not finite, or exceeds the exactly representable millisecond range. + */ + static fromMilliseconds(milliseconds: number): Duration { + if (!Number.isFinite(milliseconds)) { + throw new TypeError(`duration milliseconds must be finite, got ${String(milliseconds)}`) + } + if (Math.abs(milliseconds) > MAX_SPAN_MS) { + throw new TypeError( + `duration milliseconds must not exceed ${MAX_SPAN_MS} so its parts stay exact, got ${String(milliseconds)}`, + ) + } + return new Duration(milliseconds) + } + + /** + * Split the span into whole days, hours, minutes, seconds, and the remaining + * fraction of a second. Days, hours, minutes, and seconds are floored; the + * millisecond part keeps the fraction below one second. Every span this class + * accepts is inside the exactly representable millisecond range, so the parts + * add back up to the span. + * @returns The frozen whole parts. + * @throws RangeError when the span is negative, because a negative interval + * has no single whole-part decomposition: the caller owns that policy. + */ + decompose(): DurationParts { + if (this.span < 0) { + throw new RangeError('a negative duration has no whole-part decomposition') + } + let seconds = Math.floor(this.span / MS_PER_SECOND) + const days = Math.floor(seconds / SECONDS_PER_DAY) + seconds %= SECONDS_PER_DAY + const hours = Math.floor(seconds / SECONDS_PER_HOUR) + seconds %= SECONDS_PER_HOUR + const minutes = Math.floor(seconds / SECONDS_PER_MINUTE) + seconds %= SECONDS_PER_MINUTE + return Object.freeze({ + days, + hours, + minutes, + seconds, + milliseconds: this.span - Math.floor(this.span / MS_PER_SECOND) * MS_PER_SECOND, + }) + } +} diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts index 1867965cf2..31d50fda3f 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -1,12 +1,33 @@ /** - * Time vocabulary shared by the wire boundaries that accept a caller's zone. - * Validation and canonicalization only: this library formats nothing and owns - * no failure vocabulary — each boundary declares and throws its own refusal. + * Time vocabulary shared by the boundaries that accept a caller's zone or a + * wall-clock selector, plus one span type for elapsed and interval arithmetic. + * + * The library validates, canonicalizes, parses, and renders one canonical instant + * format; it produces no model-facing text and owns no failure vocabulary. A refusal that carries a + * reason is a discriminated result, and each caller maps that reason onto its + * own domain code and diagnostic. `canonicalClientTimeZone` is the wire-boundary + * form and answers a name or `undefined`; `formatFourDigitYearInstant` and + * `resolveLocalInstant` take already-validated values and answer a string or + * epoch milliseconds directly; `Duration` holds a span in milliseconds with no + * implicit numeric conversion. * @module @deepseek-ai/dsh-util-time */ -/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ -const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ +import { canonicalizeTimeZone } from './instant.ts' + +export { Duration } from './duration.ts' +export { + MAX_FOUR_DIGIT_YEAR_MS, + MIN_FOUR_DIGIT_YEAR_MS, + canonicalizeTimeZone, + formatFourDigitYearInstant, + fourDigitYearEpoch, + isFourDigitYearInstant, + parseLocalCalendar, + parseOffsetInstant, + resolveLocalInstant, +} from './instant.ts' +export type { InstantRejection, LocalCalendarRejection, LocalCalendarTime, TimeZoneRejection } from './instant.ts' /** * Validate and canonicalize one caller-supplied IANA zone at a wire boundary. @@ -18,16 +39,6 @@ const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ * @returns the canonical zone, or `undefined` when the name is unusable. */ export function canonicalClientTimeZone(value: string): string | undefined { - if (value.length === 0 || value.trim() !== value - || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined - try { - const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }) - .resolvedOptions().timeZone - /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */ - if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined - return canonical - } catch { - // Intl rejects unsupported zone names; the caller maps that parser rejection. - return undefined - } + const resolved = canonicalizeTimeZone(value) + return resolved.kind === 'accepted' ? resolved.timeZone : undefined } diff --git a/packages/util/time/src/instant.ts b/packages/util/time/src/instant.ts new file mode 100644 index 0000000000..60aac90ffb --- /dev/null +++ b/packages/util/time/src/instant.ts @@ -0,0 +1,313 @@ +/** + * Strict instant and local-calendar vocabulary for the boundaries that accept a + * wall-clock selector. + * + * Every entry that reads untrusted text answers a discriminated result instead + * of throwing: the library carries no failure vocabulary, and each caller maps + * a reason onto its own domain code and diagnostic. `formatFourDigitYearInstant` + * and `isFourDigitYearInstant` never throw, while `resolveLocalInstant` raises a + * `RangeError` when a caller violates its precondition — an unknown zone, or + * calendar fields naming a value the calendar does not have. Local wall-clock + * resolution uses the maintained `Temporal` implementation, so no caller owns + * offset sampling or DST rules. + * @module @deepseek-ai/dsh-util-time + */ + +import { Temporal } from '@js-temporal/polyfill' + +/** Inclusive lower epoch-millisecond bound of a four-digit-year RFC 3339 UTC instant. */ +export const MIN_FOUR_DIGIT_YEAR_MS = Date.parse('0001-01-01T00:00:00.000Z') + +/** Inclusive upper epoch-millisecond bound of a four-digit-year RFC 3339 UTC instant. */ +export const MAX_FOUR_DIGIT_YEAR_MS = Date.parse('9999-12-31T23:59:59.999Z') + +/** Canonical four-digit-year RFC 3339 UTC instant with mandatory milliseconds. */ +const UTC_INSTANT = + /^(?!0000)\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d\.\d{3}Z$/ + +/** RFC 3339 instant with mandatory date, time, and explicit zone; fraction is optional. */ +const OFFSET_INSTANT = new RegExp( + String.raw`^(?\d{4})-(?\d{2})-(?\d{2})` + + String.raw`T(?\d{2}):(?\d{2}):(?\d{2})` + + String.raw`(?:\.(?\d{1,3}))?(?Z|(?[+-])` + + String.raw`(?\d{2}):(?\d{2}))$`, +) + +/** Four-digit ISO calendar date. */ +const LOCAL_DATE = /^(?\d{4})-(?\d{2})-(?\d{2})$/ + +/** Local wall-clock time with optional one-to-three digit fractional seconds. */ +const LOCAL_TIME = /^(?\d{2}):(?\d{2}):(?\d{2})(?:\.(?\d{1,3}))?$/ + +/** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */ +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +/** Exact calendar fields of one local wall-clock value. */ +export interface LocalCalendarTime { + /** + * Year of the calendar date. `parseLocalCalendar` rejects year 0 in untrusted + * text; `resolveLocalInstant` accepts any year a real `Temporal` date carries, + * so a caller that needs the four-digit bound filters the answered epoch. + */ + readonly year: number + /** Month from 1 through 12. */ + readonly month: number + /** Day of month from 1 through the month's length. */ + readonly day: number + /** Hour from 0 through 23. */ + readonly hour: number + /** Minute from 0 through 59. */ + readonly minute: number + /** Second from 0 through 59; leap seconds are rejected. */ + readonly second: number + /** Millisecond from 0 through 999. */ + readonly millisecond: number +} + +/** Why an RFC 3339 instant string is unusable. */ +export type InstantRejection = + | 'not_rfc3339' + /** A field outside its calendar range: year 0, or an hour, minute, or second above its maximum. */ + | 'out_of_range' + | 'not_a_calendar_date' + | 'invalid_numeric_offset' + +/** Why a local calendar selector is unusable. */ +export type LocalCalendarRejection = 'malformed' | 'out_of_range' | 'not_a_calendar_date' + +/** Why one IANA zone selector is unusable. */ +export type TimeZoneRejection = + | 'blank' + | 'padded' + | 'not_area_location' + | 'unsupported' + | 'not_canonical_shape' + +/** Discriminated result of parsing one RFC 3339 instant string. */ +export type InstantResult = + | { readonly kind: 'accepted'; readonly epoch: number } + | { readonly kind: 'rejected'; readonly reason: InstantRejection } + +/** Discriminated result of parsing one local calendar selector. */ +export type LocalCalendarResult = + | { readonly kind: 'accepted'; readonly time: LocalCalendarTime } + | { readonly kind: 'rejected'; readonly reason: LocalCalendarRejection } + +/** Discriminated result of resolving one raw IANA zone selector. */ +export type TimeZoneResult = + | { readonly kind: 'accepted'; readonly timeZone: string } + | { + readonly kind: 'rejected' + readonly reason: TimeZoneRejection + /** Platform error caught while resolving the name; present when the platform refused a well-formed one. */ + readonly cause?: unknown + } + +/** Reject with one stable reason. */ +function rejected(reason: Reason): { kind: 'rejected'; reason: Reason } { + return Object.freeze({ kind: 'rejected', reason } as const) +} + +/** Read one required named regular-expression group as a number. */ +function groupNumber(groups: Record, name: string): number { + const value = groups[name] + /* v8 ignore next -- a successful fixed regex always provides every requested group. */ + if (value === undefined) throw new TypeError(`missing required regex group ${name}`) + return Number(value) +} + +/** Normalize an optional one-to-three digit fractional second to milliseconds. */ +function fractionalMilliseconds(value: string | undefined): number { + return value === undefined ? 0 : Number(value.padEnd(3, '0')) +} + +/** Whether an integral epoch is inside the four-digit-year range. */ +function inFourDigitYearRange(epoch: number): boolean { + return Number.isSafeInteger(epoch) && epoch >= MIN_FOUR_DIGIT_YEAR_MS && epoch <= MAX_FOUR_DIGIT_YEAR_MS +} + +/** + * Build one plain date-time while refusing a date the calendar does not have. + * + * Every field is already range-checked by the caller, so the only refusal left + * is a non-existent calendar date such as February 30. + * @param time - Calendar fields with every numeric field in range. + * @returns The plain date-time, or `undefined` when the date does not exist. + */ +function plainDateTime(time: LocalCalendarTime): Temporal.PlainDateTime | undefined { + try { + return Temporal.PlainDateTime.from(time, { overflow: 'reject' }) + } catch (error: unknown) { + /* v8 ignore next -- a rejected overflow answers a RangeError, so the false path is unreachable. */ + if (error instanceof RangeError) return undefined + /* v8 ignore next -- reached only when Temporal throws something other than a RangeError. */ + throw error + } +} + +/** + * Read one local calendar selector strictly, without consulting a process zone. + * + * The date and time texts are matched separately, so a malformed spelling, a + * non-existent calendar date such as February 30, and an out-of-range clock + * field answer distinct reasons. + * @param date - Candidate `YYYY-MM-DD` date text. + * @param time - Candidate `HH:mm:ss` time text with optional 1-3 fractional digits. + * @returns The parsed calendar fields, or the reason they are unusable. + */ +export function parseLocalCalendar(date: string, time: string): LocalCalendarResult { + const dateMatch = LOCAL_DATE.exec(date) + const timeMatch = LOCAL_TIME.exec(time) + const dateGroups = dateMatch?.groups + const timeGroups = timeMatch?.groups + if (dateGroups === undefined || timeGroups === undefined) return rejected('malformed') + const parts: LocalCalendarTime = { + year: groupNumber(dateGroups, 'year'), + month: groupNumber(dateGroups, 'month'), + day: groupNumber(dateGroups, 'day'), + hour: groupNumber(timeGroups, 'hour'), + minute: groupNumber(timeGroups, 'minute'), + second: groupNumber(timeGroups, 'second'), + millisecond: fractionalMilliseconds(timeGroups['fraction']), + } + if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { + return rejected('out_of_range') + } + if (plainDateTime(parts) === undefined) return rejected('not_a_calendar_date') + return Object.freeze({ kind: 'accepted', time: parts }) +} + +/** + * Parse one RFC 3339 instant whose numeric offset is part of the input. + * + * A bare `Z` and an explicit numeric offset are both accepted, and the offset is + * subtracted from the local wall-clock value. A negative zero offset, an offset + * whose hour or minute is out of range, and a non-existent calendar date are refused. + * @param value - Candidate instant text. + * @returns The resolved epoch milliseconds, or the reason the text is unusable. + */ +export function parseOffsetInstant(value: string): InstantResult { + const groups = OFFSET_INSTANT.exec(value)?.groups + if (groups === undefined) return rejected('not_rfc3339') + const parts: LocalCalendarTime = { + year: groupNumber(groups, 'year'), + month: groupNumber(groups, 'month'), + day: groupNumber(groups, 'day'), + hour: groupNumber(groups, 'hour'), + minute: groupNumber(groups, 'minute'), + second: groupNumber(groups, 'second'), + millisecond: fractionalMilliseconds(groups['fraction']), + } + if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { + return rejected('out_of_range') + } + const local = plainDateTime(parts) + if (local === undefined) return rejected('not_a_calendar_date') + const localEpoch = local.toZonedDateTime('UTC').epochMilliseconds + if (groups['zone'] === 'Z') return Object.freeze({ kind: 'accepted', epoch: localEpoch }) + const offsetHour = groupNumber(groups, 'offsetHour') + const offsetMinute = groupNumber(groups, 'offsetMinute') + if (offsetHour > 23 || offsetMinute > 59 + || (groups['sign'] === '-' && offsetHour === 0 && offsetMinute === 0)) { + return rejected('invalid_numeric_offset') + } + const direction = groups['sign'] === '+' ? 1 : -1 + return Object.freeze({ + kind: 'accepted', + epoch: localEpoch - direction * (offsetHour * 60 + offsetMinute) * 60_000, + }) +} + +/** + * Validate one canonical four-digit-year RFC 3339 UTC instant. + * + * The accepted profile carries exactly three fractional digits and a `Z`, and + * every year from 0001 through 9999 whose calendar date exists is representable. + * @param value - Untrusted candidate instant. + * @returns The epoch milliseconds, or the reason the text is not the canonical profile. + */ +export function fourDigitYearEpoch(value: string): InstantResult { + if (!UTC_INSTANT.test(value)) return rejected('not_rfc3339') + const epoch = Date.parse(value) + if (!Number.isFinite(epoch) || !inFourDigitYearRange(epoch) || new Date(epoch).toISOString() !== value) { + return rejected('not_a_calendar_date') + } + return Object.freeze({ kind: 'accepted', epoch }) +} + +/** + * Whether an epoch is representable as a canonical four-digit-year RFC 3339 UTC instant. + * + * Callers that only need the bound use this instead of re-deriving it from the + * two constants, so the range has one owner. + * @param epoch - Candidate epoch milliseconds. + * @returns Whether the epoch is a safe integer inside the four-digit-year range. + */ +export function isFourDigitYearInstant(epoch: number): boolean { + return inFourDigitYearRange(epoch) +} + +/** + * Format one epoch as the canonical four-digit-year RFC 3339 UTC instant. + * + * The bound check runs first, so a caller that also compares the result with a + * decision time never formats an unrepresentable instant. + * @param epoch - Candidate epoch milliseconds; a non-integral value is unrepresentable. + * @returns The canonical instant text, or `undefined` when the epoch is unrepresentable. + */ +export function formatFourDigitYearInstant(epoch: number): string | undefined { + if (!inFourDigitYearRange(epoch)) return undefined + const instant = new Date(epoch).toISOString() + /* v8 ignore next -- an in-range integral Date always formats as the canonical UTC profile. */ + if (!UTC_INSTANT.test(instant)) return undefined + return instant +} + +/** + * Validate and canonicalize one raw IANA zone selector. + * + * A zone identity is stored on durable records and resolved again by another + * process, so an alias is answered by its platform-canonical name. Abbreviations, + * numeric offsets, single-segment names, and unknown names are refused. + * @param value - Candidate `UTC` or IANA Area/Location name. + * @returns The canonical zone, or the reason the name is unusable. + */ +export function canonicalizeTimeZone(value: string): TimeZoneResult { + if (value.length === 0) return rejected('blank') + if (value.trim() !== value) return rejected('padded') + if (value !== 'UTC' && !IANA_TIME_ZONE.test(value)) return rejected('not_area_location') + let canonical: string + try { + canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone + } catch (error: unknown) { + // Intl rejects a well-formed name the platform's IANA data does not know; + // the caught error is the caller's only evidence for its own diagnostic. + return Object.freeze({ kind: 'rejected', reason: 'unsupported', cause: error } as const) + } + /* v8 ignore next -- Intl answers UTC or a canonical IANA Area/Location for every accepted name. */ + if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return rejected('not_canonical_shape') + return Object.freeze({ kind: 'accepted', timeZone: canonical }) +} + +/** + * Resolve one local wall-clock value in an explicit zone. + * + * A value inside a forward transition's gap does not exist, so it answers + * `undefined`; a value inside an overlap resolves to the earlier instant, which + * is the same choice the maintained `Temporal` implementation makes. + * @param time - Exact calendar fields of a real date, or an already-built plain date-time. + * @param timeZone - Already-canonical `UTC` or IANA zone. + * @returns The resolved epoch milliseconds, or `undefined` for a nonexistent wall-clock time. + * @throws RangeError when `timeZone` is not a known zone or the calendar value does not exist. + */ +export function resolveLocalInstant( + time: LocalCalendarTime | Temporal.PlainDateTime, + timeZone: string, +): number | undefined { + // A caller walking candidate dates already holds an instance, and `from` copies it. + const local = time instanceof Temporal.PlainDateTime + ? time + : Temporal.PlainDateTime.from(time, { overflow: 'reject' }) + const zoned = local.toZonedDateTime(timeZone, { disambiguation: 'earlier' }) + return zoned.toPlainDateTime().equals(local) ? zoned.epochMilliseconds : undefined +} diff --git a/packages/util/time/tests/duration.spec.ts b/packages/util/time/tests/duration.spec.ts new file mode 100644 index 0000000000..1430bf7805 --- /dev/null +++ b/packages/util/time/tests/duration.spec.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { Duration } from '@deepseek-ai/dsh-util-time' + +/** Rebuild a span from its own parts, so a wrong split shows up as a wrong total. */ +const partSum = (parts: { + days: number + hours: number + minutes: number + seconds: number + milliseconds: number +}): number => + parts.days * 86_400_000 + parts.hours * 3_600_000 + + parts.minutes * 60_000 + parts.seconds * 1_000 + parts.milliseconds + +/** Deterministic span source, so a failure is reproducible from the seed alone. */ +const spans = (count: number, seed: number): number[] => { + const out: number[] = [] + let state = seed + for (let i = 0; i < count; i += 1) { + state = (state * 1_103_515_245 + 12_345) % 2_147_483_648 + out.push((state / 2_147_483_648) * Number.MAX_SAFE_INTEGER) + } + return out +} + +describe('Duration construction', () => { + it('accepts a fractional millisecond count and keeps the fraction', () => { + expect(Duration.fromMilliseconds(1_500.25).decompose()) + .toEqual({ days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 500.25 }) + }) + + it('accepts the largest exactly representable count', () => { + const parts = Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER).decompose() + expect(parts).toEqual({ days: 104_249_991, hours: 8, minutes: 59, seconds: 0, milliseconds: 991 }) + expect(partSum(parts)).toBe(Number.MAX_SAFE_INTEGER) + }) + + it('refuses a count that is not a finite number', () => { + for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { + expect(() => Duration.fromMilliseconds(value)).toThrow(TypeError) + } + }) + + it('refuses a count above the exactly representable millisecond range', () => { + expect(() => Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER + 1)).toThrow(TypeError) + expect(() => Duration.fromMilliseconds(-Number.MAX_SAFE_INTEGER - 2)).toThrow(TypeError) + // The bound is what keeps the whole-part identity exact, so it is asserted, not assumed. + expect(partSum(Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER).decompose())) + .toBe(Number.MAX_SAFE_INTEGER) + }) + + it('is frozen, and overrides no implicit conversion', () => { + const span = Duration.fromMilliseconds(1) + expect(Object.isFrozen(span)).toBe(true) + expect(Reflect.set(span, 'span', 2)).toBe(false) + expect(span.decompose().milliseconds).toBe(1) + // No coercion hook is overridden, so no caller obtains the count by coercion. + expect(Object.hasOwn(Duration.prototype, 'valueOf')).toBe(false) + expect(Object.hasOwn(Duration.prototype, 'toString')).toBe(false) + expect(Object.hasOwn(Duration.prototype, Symbol.toPrimitive)).toBe(false) + }) +}) + +describe('Duration decompose', () => { + it('splits a span into floored whole parts and the fractional remainder', () => { + expect(Duration.fromMilliseconds(0).decompose()) + .toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }) + expect(Duration.fromMilliseconds(999).decompose()) + .toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 999 }) + expect(Duration.fromMilliseconds(1_000).decompose()) + .toEqual({ days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 0 }) + expect(Duration.fromMilliseconds(3_661_999).decompose()) + .toEqual({ days: 0, hours: 1, minutes: 1, seconds: 1, milliseconds: 999 }) + expect(Duration.fromMilliseconds(90_061_500).decompose()) + .toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500 }) + expect(Duration.fromMilliseconds(90_061_500.75).decompose()) + .toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500.75 }) + expect(Duration.fromMilliseconds(86_400_000).decompose()) + .toEqual({ days: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }) + }) + + it('answers frozen parts', () => { + expect(Object.isFrozen(Duration.fromMilliseconds(3_661_999).decompose())).toBe(true) + }) + + it('keeps the whole-part identity across the accepted range', () => { + for (let span = 0; span <= 120_000; span += 1) { + expect(partSum(Duration.fromMilliseconds(span).decompose())).toBe(span) + } + for (const span of spans(2_000, 20_260_924)) { + const parts = Duration.fromMilliseconds(span).decompose() + expect(partSum(parts)).toBe(span) + expect(parts.milliseconds).toBeGreaterThanOrEqual(0) + expect(parts.milliseconds).toBeLessThan(1_000) + expect(parts.hours).toBeLessThan(24) + expect(parts.minutes).toBeLessThan(60) + expect(parts.seconds).toBeLessThan(60) + } + }) + + it('refuses a negative span instead of inventing a sign policy', () => { + expect(() => Duration.fromMilliseconds(-1).decompose()).toThrow(RangeError) + expect(() => Duration.fromMilliseconds(-0.5).decompose()).toThrow(RangeError) + }) +}) diff --git a/packages/util/time/tests/instant.spec.ts b/packages/util/time/tests/instant.spec.ts new file mode 100644 index 0000000000..1be860b3fc --- /dev/null +++ b/packages/util/time/tests/instant.spec.ts @@ -0,0 +1,265 @@ +import { Temporal } from '@js-temporal/polyfill' +import { describe, expect, it } from 'vitest' +import { + MAX_FOUR_DIGIT_YEAR_MS, + MIN_FOUR_DIGIT_YEAR_MS, + canonicalizeTimeZone, + formatFourDigitYearInstant, + fourDigitYearEpoch, + isFourDigitYearInstant, + parseLocalCalendar, + parseOffsetInstant, + resolveLocalInstant, +} from '@deepseek-ai/dsh-util-time' + +describe('parseLocalCalendar', () => { + it('reads exact calendar fields and normalizes an absent fraction to zero', () => { + expect(parseLocalCalendar('2026-03-08', '02:30:00')).toEqual({ + kind: 'accepted', + time: { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0, millisecond: 0 }, + }) + }) + + it('pads a one- or two-digit fraction to milliseconds', () => { + expect(parseLocalCalendar('2026-03-08', '02:30:00.5')).toEqual({ + kind: 'accepted', + time: { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0, millisecond: 500 }, + }) + expect(parseLocalCalendar('2026-03-08', '02:30:00.25')).toEqual({ + kind: 'accepted', + time: { year: 2026, month: 3, day: 8, hour: 2, minute: 30, second: 0, millisecond: 250 }, + }) + }) + + it('distinguishes malformed text from an out-of-range field', () => { + expect(parseLocalCalendar('2026-3-08', '02:30:00').kind).toBe('rejected') + expect(parseLocalCalendar('2026-3-08', '02:30:00')).toEqual({ kind: 'rejected', reason: 'malformed' }) + expect(parseLocalCalendar('2026-03-08', '2:30:00')).toEqual({ kind: 'rejected', reason: 'malformed' }) + expect(parseLocalCalendar('2026-03-08', '02:30:00.1234')).toEqual({ kind: 'rejected', reason: 'malformed' }) + expect(parseLocalCalendar('0000-03-08', '02:30:00')).toEqual({ kind: 'rejected', reason: 'out_of_range' }) + expect(parseLocalCalendar('2026-03-08', '24:00:00')).toEqual({ kind: 'rejected', reason: 'out_of_range' }) + expect(parseLocalCalendar('2026-03-08', '02:60:00')).toEqual({ kind: 'rejected', reason: 'out_of_range' }) + expect(parseLocalCalendar('2026-03-08', '02:30:60')).toEqual({ kind: 'rejected', reason: 'out_of_range' }) + }) + + it('rejects a date the calendar does not have', () => { + expect(parseLocalCalendar('2026-02-30', '02:30:00')).toEqual({ + kind: 'rejected', + reason: 'not_a_calendar_date', + }) + expect(parseLocalCalendar('2025-02-29', '02:30:00')).toEqual({ + kind: 'rejected', + reason: 'not_a_calendar_date', + }) + expect(parseLocalCalendar('2024-02-29', '02:30:00').kind).toBe('accepted') + }) +}) + +describe('parseOffsetInstant', () => { + it('reads a Z instant as its UTC epoch', () => { + expect(parseOffsetInstant('2026-03-08T02:30:00Z')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.000Z'), + }) + }) + + it('subtracts a numeric offset from the written wall-clock value', () => { + const wallClock = Date.parse('2026-03-08T02:30:00.000Z') + expect(parseOffsetInstant('2026-03-08T02:30:00+08:00')).toEqual({ + kind: 'accepted', + epoch: wallClock - 8 * 3_600_000, + }) + expect(parseOffsetInstant('2026-03-08T02:30:00-05:30')).toEqual({ + kind: 'accepted', + epoch: wallClock + 5.5 * 3_600_000, + }) + }) + + it('normalizes an optional one-to-three digit fraction to milliseconds', () => { + expect(parseOffsetInstant('2026-03-08T02:30:00.5Z')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.500Z'), + }) + expect(parseOffsetInstant('2026-03-08T02:30:00.25Z')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.250Z'), + }) + }) + + it('rejects text that is not the accepted RFC 3339 profile', () => { + for (const value of [ + '2026-03-08T02:30:00', + '2026-03-08 02:30:00Z', + '2026-03-08T02:30Z', + '2026-03-08T02:30:00.1234Z', + '2026-3-08T02:30:00Z', + ]) { + expect(parseOffsetInstant(value)).toEqual({ kind: 'rejected', reason: 'not_rfc3339' }) + } + }) + + it('rejects a clock field, a zero year, and a non-existent calendar date', () => { + expect(parseOffsetInstant('0000-03-08T02:30:00Z')).toEqual({ + kind: 'rejected', + reason: 'out_of_range', + }) + expect(parseOffsetInstant('2026-03-08T24:30:00Z')).toEqual({ + kind: 'rejected', + reason: 'out_of_range', + }) + expect(parseOffsetInstant('2026-03-08T02:60:00Z')).toEqual({ + kind: 'rejected', + reason: 'out_of_range', + }) + expect(parseOffsetInstant('2026-03-08T02:30:60Z')).toEqual({ + kind: 'rejected', + reason: 'out_of_range', + }) + expect(parseOffsetInstant('2026-02-30T02:30:00Z')).toEqual({ + kind: 'rejected', + reason: 'not_a_calendar_date', + }) + }) + + it('rejects an unusable numeric offset', () => { + expect(parseOffsetInstant('2026-03-08T02:30:00+24:00')).toEqual({ + kind: 'rejected', + reason: 'invalid_numeric_offset', + }) + expect(parseOffsetInstant('2026-03-08T02:30:00+08:60')).toEqual({ + kind: 'rejected', + reason: 'invalid_numeric_offset', + }) + expect(parseOffsetInstant('2026-03-08T02:30:00-00:00')).toEqual({ + kind: 'rejected', + reason: 'invalid_numeric_offset', + }) + expect(parseOffsetInstant('2026-03-08T02:30:00+00:00')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.000Z'), + }) + }) +}) + +describe('fourDigitYearEpoch', () => { + it('accepts the canonical profile and answers its epoch', () => { + expect(fourDigitYearEpoch('2026-03-08T02:30:00.000Z')).toEqual({ + kind: 'accepted', + epoch: Date.parse('2026-03-08T02:30:00.000Z'), + }) + expect(fourDigitYearEpoch('0001-01-01T00:00:00.000Z')).toEqual({ + kind: 'accepted', + epoch: MIN_FOUR_DIGIT_YEAR_MS, + }) + expect(fourDigitYearEpoch('9999-12-31T23:59:59.999Z')).toEqual({ + kind: 'accepted', + epoch: MAX_FOUR_DIGIT_YEAR_MS, + }) + }) + + it('rejects everything outside the canonical profile', () => { + for (const value of [ + '2026-03-08T02:30:00Z', + '2026-03-08T02:30:00.000+00:00', + '2026-03-08T02:30:00.00Z', + '0000-01-01T00:00:00.000Z', + '2026-3-08T02:30:00.000Z', + ]) { + expect(fourDigitYearEpoch(value)).toEqual({ kind: 'rejected', reason: 'not_rfc3339' }) + } + }) + + it('rejects a profile-shaped text whose date does not exist', () => { + expect(fourDigitYearEpoch('2026-02-30T02:30:00.000Z')).toEqual({ + kind: 'rejected', + reason: 'not_a_calendar_date', + }) + }) +}) + +describe('isFourDigitYearInstant', () => { + it('answers the range including both bounds and excludes everything else', () => { + expect(isFourDigitYearInstant(MIN_FOUR_DIGIT_YEAR_MS)).toBe(true) + expect(isFourDigitYearInstant(MAX_FOUR_DIGIT_YEAR_MS)).toBe(true) + expect(isFourDigitYearInstant(Date.parse('2026-03-08T02:30:00.000Z'))).toBe(true) + for (const epoch of [MIN_FOUR_DIGIT_YEAR_MS - 1, MAX_FOUR_DIGIT_YEAR_MS + 1, 0.5, Number.NaN]) { + expect(isFourDigitYearInstant(epoch)).toBe(false) + } + }) +}) + +describe('formatFourDigitYearInstant', () => { + it('formats both inclusive bounds in the canonical profile', () => { + expect(formatFourDigitYearInstant(MIN_FOUR_DIGIT_YEAR_MS)).toBe('0001-01-01T00:00:00.000Z') + expect(formatFourDigitYearInstant(MAX_FOUR_DIGIT_YEAR_MS)).toBe('9999-12-31T23:59:59.999Z') + }) + + it('refuses a non-integral or out-of-range epoch', () => { + for (const epoch of [MIN_FOUR_DIGIT_YEAR_MS - 1, MAX_FOUR_DIGIT_YEAR_MS + 1, 0.5, Number.NaN]) { + expect(formatFourDigitYearInstant(epoch)).toBeUndefined() + } + }) +}) + +describe('canonicalizeTimeZone', () => { + it('answers the runtime-canonical name', () => { + expect(canonicalizeTimeZone('UTC')).toEqual({ kind: 'accepted', timeZone: 'UTC' }) + expect(canonicalizeTimeZone('US/Eastern')).toEqual({ kind: 'accepted', timeZone: 'America/New_York' }) + expect(canonicalizeTimeZone('America/New_York')).toEqual({ + kind: 'accepted', + timeZone: 'America/New_York', + }) + }) + + it('reports why a name is refused', () => { + expect(canonicalizeTimeZone('')).toEqual({ kind: 'rejected', reason: 'blank' }) + expect(canonicalizeTimeZone(' UTC')).toEqual({ kind: 'rejected', reason: 'padded' }) + expect(canonicalizeTimeZone('UTC ')).toEqual({ kind: 'rejected', reason: 'padded' }) + expect(canonicalizeTimeZone('Asia')).toEqual({ kind: 'rejected', reason: 'not_area_location' }) + expect(canonicalizeTimeZone('GMT+8')).toEqual({ kind: 'rejected', reason: 'not_area_location' }) + expect(canonicalizeTimeZone('utc')).toEqual({ kind: 'rejected', reason: 'not_area_location' }) + const unsupported = canonicalizeTimeZone('Not/A_Real_Zone') + if (unsupported.kind !== 'rejected') throw new Error('expected a rejection') + expect(unsupported.reason).toBe('unsupported') + // The platform refusal is the caller's only evidence of why a well-formed name failed, + // so the rejection carries it instead of hiding it. + expect(unsupported.cause).toBeInstanceOf(RangeError) + expect(canonicalizeTimeZone('Asia')).not.toHaveProperty('cause') + }) +}) + +describe('resolveLocalInstant', () => { + const local = (hour: number, minute: number) => ({ + year: 2026, month: 3, day: 8, hour, minute, second: 0, millisecond: 0, + }) + + it('resolves a wall-clock value that exists exactly once', () => { + expect(resolveLocalInstant(local(12, 0), 'America/New_York')).toBe( + Date.parse('2026-03-08T16:00:00.000Z'), + ) + }) + + it('answers undefined for a forward-transition gap', () => { + // 2026-03-08T02:30 local does not exist in America/New_York. + expect(resolveLocalInstant(local(2, 30), 'America/New_York')).toBeUndefined() + }) + + it('selects the earlier instant of a backward-transition overlap', () => { + // 2026-11-01T01:30 local occurs twice in America/New_York. + const overlap = { ...local(1, 30), month: 11, day: 1 } + expect(resolveLocalInstant(overlap, 'America/New_York')).toBe( + Date.parse('2026-11-01T05:30:00.000Z'), + ) + }) + + it('resolves UTC without a transition', () => { + expect(resolveLocalInstant(local(2, 30), 'UTC')).toBe(Date.parse('2026-03-08T02:30:00.000Z')) + }) + + it('accepts an already-built plain date-time, so a caller that holds one needs no copy', () => { + expect(resolveLocalInstant(Temporal.PlainDateTime.from('2026-03-08T12:00:00'), 'America/New_York')) + .toBe(Date.parse('2026-03-08T16:00:00.000Z')) + expect(resolveLocalInstant(Temporal.PlainDateTime.from('2026-03-08T02:30:00'), 'America/New_York')) + .toBeUndefined() + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 457a87af68..d079d14dfc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7088,6 +7088,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../../core/tools + '@deepseek-ai/dsh-util-time': + specifier: workspace:* + version: link:../../util/time packages/context/tmux-context: dependencies: @@ -12109,6 +12112,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:* version: link:../../typert/protocol + '@deepseek-ai/dsh-util-time': + specifier: workspace:* + version: link:../../util/time '@deepseek-ai/dsh-workspace': specifier: workspace:* version: link:../../workspace/workspace @@ -15225,6 +15231,10 @@ importers: version: 7.8.0 packages/util/time: + dependencies: + '@js-temporal/polyfill': + specifier: 0.5.1 + version: 0.5.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ diff --git a/scripts/coverage-fork-diagnostics.spec.ts b/scripts/coverage-fork-diagnostics.spec.ts index be555a3003..4450245255 100644 --- a/scripts/coverage-fork-diagnostics.spec.ts +++ b/scripts/coverage-fork-diagnostics.spec.ts @@ -159,7 +159,7 @@ function workerExitReport(output: string, pool: string): string | undefined { } describe('coverage fork configuration entry', () => { - it('preserves the real Vitest failure and adds metadata before its error report', async ({ signal }) => { + it('preserves the real Vitest failure and attributes the same exit Vitest reports', async ({ signal }) => { const [stock, diagnostic] = await Promise.all([ configuredPool('stock', 'exit', signal), configuredPool('diagnostic', 'exit', signal), @@ -196,8 +196,13 @@ describe('coverage fork configuration entry', () => { }) expect(record.files.map(file => file.replaceAll('\\', '/'))) .toEqual([resolve('scripts/fixtures/coverage-fork.fixture.ts').replaceAll('\\', '/')]) - expect(output.indexOf('coverage-worker-exit:')) - .toBeLessThan(output.indexOf(workerExitReport(output, 'coverage-forks')!)) + const report = workerExitReport(output, 'coverage-forks')! + // A worker error is logged when the channel closes under a send, which precedes the exit the + // record describes. Vitest then prints no "Worker exited unexpectedly" line, so only that + // line states an order the record can hold. + if (report === 'Worker exited unexpectedly') { + expect(output.indexOf('coverage-worker-exit:')).toBeLessThan(output.indexOf(report)) + } }) it('keeps a successful configured run silent and successful', async ({ signal }) => {