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 index 31c914f4ff..dcfe0eeb4e 100644 --- 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 @@ -9,11 +9,11 @@ en: abd0263d14861926 zh: e1736cd08e87f795 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: - en: 34feee585cb33001 - zh: d882bbba56a679bd + en: 4130cbbd30e06857 + zh: 85f9153cf5ad44ce /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: 7e3004318640cca7 - zh: 007d7fe761718d37 + en: f7181f87764118d2 + zh: 0af236ff4b6a935b 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 index 46dfbe6504..d9fc530938 100644 --- a/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md @@ -10,7 +10,7 @@ English | [中文](2026-09-24-shared-time-vocabulary.zh.md) ## Decision -`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds it, `src/index.ts` re-exports it and keeps `canonicalClientTimeZone` as the `string | undefined` wrapper the existing wire boundaries consume. +`dsh-util-time` owns the time vocabulary that boundaries accept. `packages/util/time/src/instant.ts` holds 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, and `LocalCalendarTime.year` states which of them rejects year 0 (`parseLocalCalendar`) and which accepts it (`resolveLocalInstant`, whose callers derive local dates at the UTC edges). @@ -18,7 +18,7 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d `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; the count is readable only as `milliseconds` or through a fractional `toSeconds`/`toMinutes`/`toHours`/`toDays` conversion, and there is deliberately no `valueOf` and no `toString`, so no reader ever receives a bare count whose unit it has to remember. Addition, subtraction, ordering, and a whole-part `decompose()` are the whole operation set. +`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; the count is readable only as `milliseconds` or through a fractional `toSeconds`/`toMinutes`/`toHours`/`toDays` conversion, and there is deliberately no `valueOf` and no `toString`, so no reader ever receives a bare count whose unit it has to remember. Addition, subtraction, ordering, a negative-span test, and a whole-part `decompose()` are the whole operation set, alongside a `zero` identity and a `fromSeconds` constructor for a boundary whose unit is seconds. 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 README and description lose the `zero-dependency` claim the added dependency contradicts. @@ -44,6 +44,8 @@ The time profile now has one home. A change to the accepted instant profile or t The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message. -The 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, verified by a differential over more than eight thousand inputs including negatives, fractions, and values past a year. One input does change: a non-finite elapsed sample now throws from the span constructor instead of rendering `NaNs`, which the package's own reading invariant already refused, so the failure moves to the sample that produced it. +The value type ships its whole operation set before its second consumer exists. Only `fromMilliseconds` and `decompose()` are called in production today, by time-context; `fromSeconds`, `milliseconds`, `plus`, `minus`, `compare`, `isNegative`, `zero`, and the four `to*` conversions are published without a caller. That is a deliberate choice rather than an oversight: the seconds-valued fields at `dsh-schedule` and the DeepSeek file store are the intended next consumers, and the alternative — a class that holds a span but can only split it — would have to grow the same members one call site at a time. 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 with a differential over more than eight thousand inputs including negatives, fractions, and values past a year; that differential was a review probe, not a committed test, and the consumer's own suite pins the readings it renders (`1d 1h 1m 1s`, `1m 1s`, `4m 2s`, and the clamped zero). One input does change: a non-finite elapsed sample now throws from the span constructor instead of rendering `NaNs`, which the package's own reading invariant already refused, so the failure moves to the sample that produced it. 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 its invariant validates. `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 index 8ff750c478..ff8368f558 100644 --- 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 @@ -10,7 +10,7 @@ Status: implemented ## Decision -`dsh-util-time` 拥有各边界所接收的时间词汇。它位于 `packages/util/time/src/instant.ts`,`src/index.ts` 转出它,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。 +`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`,而 `LocalCalendarTime.year` 说明了二者中谁拒绝年份 0(`parseLocalCalendar`)、谁接受它(`resolveLocalInstant`,其调用方需要在 UTC 边界推导本地日期)。 @@ -18,7 +18,7 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec `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` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结;计数只能通过 `milliseconds` 或按小数换算的 `toSeconds`/`toMinutes`/`toHours`/`toDays` 读取,且刻意不提供 `valueOf` 与 `toString`,因此读取方永远不会拿到一个需要自己记单位的裸数字。加、减、排序与整数量拆解 `decompose()` 就是全部操作集合。 +`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结;计数只能通过 `milliseconds` 或按小数换算的 `toSeconds`/`toMinutes`/`toHours`/`toDays` 读取,且刻意不提供 `valueOf` 与 `toString`,因此读取方永远不会拿到一个需要自己记单位的裸数字。加、减、排序、负值判断与整数量拆解 `decompose()` 就是全部操作集合,另有 `zero` 恒等元与面向以秒为单位边界的 `fromSeconds` 构造入口。 受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。 @@ -44,6 +44,8 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 代价是更宽的已发布接口与更重的依赖图:`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` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,已用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分验证。有一个输入会变:非有限的耗时样本现在由时间段构造函数抛出,而不是渲染出 `NaNs`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。 +该值类型在第二个消费方出现之前就交付了完整操作集合。目前生产代码只调用 `fromMilliseconds` 与 `decompose()`(由 time-context 调用);`fromSeconds`、`milliseconds`、`plus`、`minus`、`compare`、`isNegative`、`zero` 与四个 `to*` 换算都没有调用方。这是有意的选择而不是疏漏:`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段是预期的下一批消费方,而另一种做法——一个能保存时间段却只能拆解它的类——会在每个调用点出现时再长出同样的成员。包 README 把这些尚未迁移的边界记为已知限制。 + +耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分测得;该差分是评审用的探针而非提交进仓库的测试,消费方自己那套测试固定了它渲染出的读数(`1d 1h 1m 1s`、`1m 1s`、`4m 2s`,以及被钳到零的那次)。有一个输入会变:非有限的耗时样本现在由时间段构造函数抛出,而不是渲染出 `NaNs`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。 本次未触及、仍归 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/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index c0d042b47a..8559bd7e18 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -21,11 +21,11 @@ en: 7015b3ed33de4c53 zh: d4fc9aee6f6910ca /dsh-util-time/use-this-package/entry-point: - en: 306f0f5d65af8223 - zh: a79b60c39be361c8 + en: 88668c3dd1bdb75b + zh: fb48d941ae433335 /dsh-util-time/understand-the-implementation: - en: f42cb0fc332d7740 - zh: f980bcae2f72fdd8 + en: ef1369f1e986acf5 + zh: b7a98e3d79048d50 /dsh-util-time/further-exploration: en: 8aa95ebdfd1f323e zh: c6879eac9fa6e087 @@ -36,8 +36,8 @@ en: daf6e45a18c087b0 zh: e6714f1e65ed55ce /dsh-util-time/known-limitations-and-deferred-work: - en: b4108682b9b18245 - zh: 7dfb014c2dc1775f + en: 20e184351878f41b + zh: 1c94d8a715df7c1b /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 7bb1e068ae..4f1d2ee9ea 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -50,7 +50,7 @@ Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and no coercion answers the count ``` -`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, adds and orders without any unit conversion, converts to seconds, minutes, hours, and days fractionally, and splits a non-negative span into whole parts. +`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, adds, subtracts, orders, and reports a negative span without any unit conversion, converts to seconds, minutes, hours, and days fractionally, and splits a non-negative span into whole parts. ----- @@ -60,7 +60,7 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and
Implementation internals — click to expand -`src/instant.ts` holds the whole implementation; `src/index.ts` re-exports it 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. +`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. @@ -106,7 +106,8 @@ None of its own. The consumer that injects a zone- or schedule-derived line into - **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. - **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` does not convert units implicitly** — it has no `valueOf` and no `toString`, so a reader states its unit through `milliseconds` or a `to*` conversion. +- **`Duration` does not convert units implicitly** — it has no `valueOf`, no `toString`, and no `Symbol.toPrimitive`, so a reader states its unit through `milliseconds` or a `to*` conversion. +- **Seconds-unit boundaries still multiply** — `dsh-schedule` and the DeepSeek file store convert their own second-valued fields with `* 1_000`; `Duration.fromSeconds` is the intended next consumer for those spans, and none of them has moved yet. - **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. diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index acf2074ffc..dcf29ed9ee 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -50,7 +50,7 @@ Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and no coercion answers the count ``` -不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限,加减与排序都不做单位换算,按小数换算到秒、分、小时与天,并把非负的时间段拆成整数量。 +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限且落在可精确表示的范围内,加减、排序与负值判断都不做单位换算,按小数换算到秒、分、小时与天,并把非负的时间段拆成整数量。 ----- @@ -60,7 +60,7 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and
实现内部细节 —— 点击展开 -`src/instant.ts` 承载全部实现;`src/index.ts` 转出它,并新增 `canonicalClientTimeZone`——协议边界已经在消费的 `string | undefined` 形式。该模块的 JSDoc 说明每个入口接受的输入剖面与全部拒绝原因。 +`src/instant.ts` 承载各解析器,`src/duration.ts` 承载时间段;`src/index.ts` 转出两者,并新增 `canonicalClientTimeZone`——协议边界已经在消费的 `string | undefined` 形式。该模块的 JSDoc 说明每个入口接受的输入剖面与全部拒绝原因。 严格解析器先匹配固定正则,再构造任何日期,因此拼写错误、越界时钟字段与日历上不存在的日期分别回答不同的原因。本地解析交给 `@js-temporal/polyfill`:`disambiguation: 'earlier'` 取重叠时段中的第一个时刻,而本地字段往返比对对前向跳变缺口内的值回答 `undefined`。 @@ -106,7 +106,8 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and - **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 - **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。 - **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。 -- **`Duration` 不做隐式单位换算**——它没有 `valueOf` 也没有 `toString`,读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。 +- **`Duration` 不做隐式单位换算**——它没有 `valueOf`、`toString` 与 `Symbol.toPrimitive`,读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。 +- **秒为单位的边界仍在自行相乘**——`dsh-schedule` 与 DeepSeek 的文件存储用 `* 1_000` 换算各自以秒为单位的字段;`Duration.fromSeconds` 是这些时间段的预期下一个消费方,目前都还没改。 - **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。 diff --git a/packages/util/time/src/duration.ts b/packages/util/time/src/duration.ts index 727d6743fd..172e9948fd 100644 --- a/packages/util/time/src/duration.ts +++ b/packages/util/time/src/duration.ts @@ -32,12 +32,23 @@ export interface DurationParts { 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, and they carry - * no calendar meaning: a month is not a fixed count of milliseconds, so no entry - * here converts to or from calendar units. + * Instances are frozen, so a span cannot drift after it is built. 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, so no entry here converts to or + * from calendar units. */ export class Duration { /** The additive identity. */ @@ -52,14 +63,19 @@ export class Duration { /** * Build one span from a millisecond count. - * @param milliseconds - Finite millisecond count; a fractional count is kept as given. + * @param milliseconds - Count within the exactly representable range (about 285,000 years); fractions are kept. * @returns The span. - * @throws TypeError when the count is not a finite number. + * @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) } @@ -120,7 +136,7 @@ export class Duration { /** * The span in seconds. - * @returns The span as a fractional second count, lossless in both directions. + * @returns The span as a fractional seconds count: one division, so the answer is the double nearest the exact ratio. */ toSeconds(): number { return this.span / MS_PER_SECOND @@ -128,7 +144,7 @@ export class Duration { /** * The span in minutes. - * @returns The span as a fractional minute count, lossless in both directions. + * @returns The span as a fractional minutes count: one division, so the answer is the double nearest the exact ratio. */ toMinutes(): number { return this.span / MS_PER_MINUTE @@ -136,7 +152,7 @@ export class Duration { /** * The span in hours. - * @returns The span as a fractional hour count, lossless in both directions. + * @returns The span as a fractional hours count: one division, so the answer is the double nearest the exact ratio. */ toHours(): number { return this.span / MS_PER_HOUR @@ -148,7 +164,7 @@ export class Duration { * A day here is exactly 24 hours. A wall-clock reading that crosses a * daylight-saving transition is not a whole number of days, so a caller that * needs local calendar days derives them from the zone instead. - * @returns The span as a fractional day count, lossless in both directions. + * @returns The span as a fractional days count: one division, so the answer is the double nearest the exact ratio. */ toDays(): number { return this.span / MS_PER_DAY @@ -156,8 +172,9 @@ export class Duration { /** * Split the span into whole days, hours, minutes, seconds, and the remaining - * fraction of a second. Each part is floored, so the parts never exceed the - * span. + * fraction of a second. Each part is floored, and every span this class accepts + * is inside the exactly representable millisecond range, so the parts add back + * up to the span and the remainder stays below one second. * @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. diff --git a/packages/util/time/tests/duration.spec.ts b/packages/util/time/tests/duration.spec.ts index 1a90124337..f2b8de4a8b 100644 --- a/packages/util/time/tests/duration.spec.ts +++ b/packages/util/time/tests/duration.spec.ts @@ -25,14 +25,25 @@ describe('Duration construction', () => { expect(() => Duration.fromSeconds(1e306)).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).milliseconds).toBe(Number.MAX_SAFE_INTEGER) + // The bound is what keeps the whole-part identity exact, so it is asserted, not assumed. + const parts = Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER).decompose() + const summed = parts.days * 86_400_000 + parts.hours * 3_600_000 + + parts.minutes * 60_000 + parts.seconds * 1_000 + parts.milliseconds + expect(summed).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.milliseconds).toBe(1) - // Neither coercion is overridden, so no caller obtains the count by coercion. + // 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) }) }) @@ -56,13 +67,18 @@ describe('Duration arithmetic', () => { }) describe('Duration conversion', () => { - it('converts to larger units fractionally, so nothing is lost either way', () => { + it('converts to larger units fractionally, by one division each', () => { const span = Duration.fromMilliseconds(90_061_500) expect(span.toSeconds()).toBe(90_061.5) expect(span.toMinutes()).toBe(1_501.025) expect(span.toHours()).toBe(25.017_083_333_333_332) expect(span.toDays()).toBe(1.042_378_472_222_222_2) expect(Duration.fromSeconds(span.toSeconds()).milliseconds).toBe(span.milliseconds) + // A conversion is a single division, so a round trip returns the nearest + // double rather than the original count: 1001 ms is 1.001 s, and 1.001 s is + // not exactly 1001 ms. + expect(Duration.fromMilliseconds(1_001).toSeconds()).toBe(1.001) + expect(Duration.fromSeconds(1.001).milliseconds).toBe(1_000.999_999_999_999_9) }) it('covers a day as exactly twenty-four hours', () => { @@ -93,7 +109,22 @@ describe('Duration decompose', () => { const summed = parts.days * 86_400_000 + parts.hours * 3_600_000 + parts.minutes * 60_000 + parts.seconds * 1_000 + parts.milliseconds expect(summed).toBe(3_661_999) - expect(summed).toBeLessThanOrEqual(3_661_999) + }) + + it('keeps the whole-part identity across the accepted range', () => { + const sum = (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 + for (let span = 0; span <= 120_000; span += 1) { + expect(sum(Duration.fromMilliseconds(span).decompose())).toBe(span) + } + for (let i = 0; i < 2_000; i += 1) { + const span = Math.random() * Number.MAX_SAFE_INTEGER + const parts = Duration.fromMilliseconds(span).decompose() + expect(sum(parts)).toBe(span) + expect(parts.milliseconds).toBeGreaterThanOrEqual(0) + expect(parts.milliseconds).toBeLessThan(1_000) + } }) it('refuses a negative span instead of inventing a sign policy', () => {