From c96d48d49161c53b9b2a84942b7e7f1cdc3bf900 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 12:55:36 +0800 Subject: [PATCH 01/26] refactor(schedule): extract time vocabulary into dsh-util-time Move Schedule's strict RFC 3339 instant, local-calendar, and IANA zone parsing into @deepseek-ai/dsh-util-time, and have Schedule consume it. The shared entries answer discriminated results and own no failure vocabulary; Schedule keeps its error codes, diagnostics, durable record shapes, and all recurrence arithmetic. --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 19 ++ .../2026-09-24-shared-time-vocabulary.md | 39 +++ .../2026-09-24-shared-time-vocabulary.zh.md | 39 +++ packages/schedule/schedule/package.json | 2 + packages/schedule/schedule/src/domain.ts | 238 +++++---------- packages/schedule/schedule/tsconfig.json | 3 + packages/util/README.i18n.yaml | 12 +- packages/util/README.md | 6 +- packages/util/README.zh.md | 6 +- packages/util/time/README.i18n.yaml | 43 +-- packages/util/time/README.md | 72 ++++- packages/util/time/README.zh.md | 72 ++++- packages/util/time/package.json | 5 +- packages/util/time/src/index.ts | 29 +- packages/util/time/src/instant.ts | 279 ++++++++++++++++++ packages/util/time/tests/instant.spec.ts | 239 +++++++++++++++ pnpm-lock.yaml | 7 + 17 files changed, 874 insertions(+), 236 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md create mode 100644 .agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md create mode 100644 packages/util/time/src/instant.ts create mode 100644 packages/util/time/tests/instant.spec.ts 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..1048d91116 --- /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: 6adc7f8750843df4 + zh: 55a600663ece4b2a +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: + en: 290a11695db08ffa + zh: a19b859e39e8cf8a +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: + en: db5c53007cf0ae82 + zh: c654d66934c55d42 +/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: + en: a0a11d4bd4fa11e3 + zh: 3536397be8dd0add 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..c44b802411 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.md @@ -0,0 +1,39 @@ +# 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. Schedule had grown the other half of the same vocabulary privately inside `packages/schedule/schedule/src/domain.ts`: a strict RFC 3339 instant parser, a local-calendar parser, a four-digit-year bound and canonical formatter, a local wall-clock resolver, and a second IANA zone canonicalizer that threw `ScheduleInputError` instead of answering a result. Two consequences followed. The same three regular expressions and the same `Intl.DateTimeFormat` canonicalization existed twice, so a change to either profile had to be made twice and verified twice. And the next consumer of a wall-clock selector had no shared entry point to reach for, which is why the profile would have been copied a third time rather than imported. + +## 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. + +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`, `not_a_calendar_date`, `not_a_wall_clock_time`, `invalid_numeric_offset`, `malformed`, `out_of_range`, `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, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. + +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. + +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. + +Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` code and message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. `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 `packages/schedule/schedule/src/time.ts` extraction would shrink `domain.ts` 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 diagnostics are pinned verbatim by `tools.spec.ts`, snapshot expectations, and the model-visible tool result; a shared error class invites 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. + +**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 three regular expressions, the `Intl` canonicalization, the hand-rolled calendar conversion, and the four-digit bound arithmetic. + +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, so a reason added to the library's union fails the build at each caller through the exhaustive switch rather than silently taking a default. + +`dsh-time-context` keeps its own IANA regular expression and canonicalization, and stays out of this change. It rejects a non-canonical alias at the request boundary where Schedule canonicalizes one, so unifying it means deciding which of the two behaviors the browser-time-zone instruction wants; that decision belongs to the consumer, not to this extraction. 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..4c1d054d35 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-24-shared-time-vocabulary.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 面向墙钟选择器的共享时间词汇 + +Status: implemented + +[English](2026-09-24-shared-time-vocabulary.md) | 中文 + +## Problem + +`dsh-util-time` 此前只承担一项职责、面向一种调用方:`canonicalClientTimeZone` 在协议边界校验调用方所报时区并回答 `undefined`,由 `dsh-api-session-controller` 与 `dsh-subagent` 映射成各自的域错误。同一套词汇的另一半则在 `packages/schedule/schedule/src/domain.ts` 内部私下生长:严格的 RFC 3339 时刻解析器、本地日历解析器、四位年份边界与规范格式化、本地墙钟解析,以及第二个 IANA 时区规范化实现——它抛 `ScheduleInputError` 而不是回答结果。由此产生两个后果。同样的三段正则与同样的 `Intl.DateTimeFormat` 规范化存在两份,任何一侧剖面变更都要改两遍、验两遍。而下一个需要墙钟选择器的消费方没有共享入口可用,这正是该剖面会被第三次复制而不是被导入的原因。 + +## Decision + +`dsh-util-time` 拥有各边界所接收的时间词汇。它位于 `packages/util/time/src/instant.ts`,`src/index.ts` 转出它,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。 + +本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`not_a_calendar_date`、`not_a_wall_clock_time`、`invalid_numeric_offset`、`malformed`、`out_of_range`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。 + +Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。 + +受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。 + +可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 错误码与消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 + +## Alternatives considered + +**把共享模块保持为 Schedule 私有。** 抽成 `packages/schedule/schedule/src/time.ts` 可以缩小 `domain.ts`,而不触碰已发布包、其 lockfile 条目、README 或依赖集合。之所以否决,是因为本次要消除的重复是重复的**剖面**,不是重复的行数:时区正则、`Intl` 规范化步骤与时刻剖面仍会存在两份,下一个接收墙钟选择器的边界仍会复制出第三份。 + +**让本库自带错误类型。** 一个携带原因的 `TimeInputError` 可以让调用方直接重抛而不做映射,缩短每个调用点。之所以否决,是因为 `util/time` 既有契约规定每个边界拥有自己的拒绝词汇,而 Schedule 的诊断文案被 `tools.spec.ts`、快照期望与模型可见工具结果逐字固定;共享错误类会把一个调用方的措辞带进另一个调用方的界面。 + +**让本库回答 `undefined` 而不是原因。** 这正是 `canonicalClientTimeZone` 已有的形态,也是改动更小的做法。之所以否决,是因为 Schedule 必须区分格式错误的时刻、日历上不存在的日期与越界时钟字段,才能保留既有诊断;`canonicalizeTimeZone` 也必须区分名称本身不合法与本地无法解析。把二者压成一个假值,会迫使 Schedule 用自己的正则重新推导这层区分,从而恢复重复。 + +**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。 + +## Consequences + +时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了三段正则、`Intl` 规范化、手写日历换算与四位年份边界运算。 + +代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射,因此本库联合类型新增原因时,每个调用方都会因穷尽 switch 而构建失败,而不是静默走默认分支。 + +`dsh-time-context` 保留自己的 IANA 正则与规范化,不参与本次改动。它在请求边界拒绝非规范别名,而 Schedule 会把它规范化,因此统一二者意味着决定浏览器时区指令需要两种行为中的哪一种;该决定属于消费方,而不属于这次抽取。 diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 24fb47514a..5f3a142054 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -60,6 +60,7 @@ "@deepseek-ai/dsh-storage-domain": "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:~" }, @@ -87,6 +88,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 4f9b868478..46bdce8207 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -4,6 +4,17 @@ */ import { Temporal } from '@js-temporal/polyfill' +import { + MAX_FOUR_DIGIT_YEAR_MS, + MIN_FOUR_DIGIT_YEAR_MS, + canonicalizeTimeZone as canonicalizeSharedTimeZone, + formatFourDigitYearInstant, + fourDigitYearEpoch, + parseLocalCalendar, + parseOffsetInstant, + resolveLocalInstant, +} from '@deepseek-ai/dsh-util-time' +import type { 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 +61,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,114 +251,26 @@ function decodeId(value: unknown): ScheduleIdType { /** 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') - } - 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 -} - -/** 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.') - } - 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.', + const parsed = typeof value === 'string' ? fourDigitYearEpoch(value) : undefined + if (parsed === undefined || parsed.kind === 'rejected') { + throw new ScheduleLogError( + parsed?.reason === 'not_a_calendar_date' + ? 'scheduledAt is not a real UTC calendar instant' + : 'scheduledAt must be a canonical 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 + return value as string } -/** 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 +/** + * Map one shared zone rejection onto the stable Schedule input code. + * @param reason - Reason the shared vocabulary refused the name. + * @returns The public Schedule diagnostic for that refusal. + */ +function timeZoneError(reason: TimeZoneRejection): ScheduleInputError { + return reason === 'not_canonical_shape' + ? new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.') + : new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.') } /** @@ -368,52 +279,23 @@ 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) + 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) { +function parseLocalAt(value: LocalAtInput): LocalCalendarTime { + const parsed = parseLocalCalendar(value.date, value.time) + if (parsed.kind === 'rejected') { throw new ScheduleInputError( 'invalid_rule', - 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.', + parsed.reason === 'malformed' + ? 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.' + : 'The local at value must be a real ISO calendar date and time.', ) } - 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. */ @@ -423,14 +305,52 @@ function localInstant(local: Temporal.PlainDateTime, timeZone: string): number | } /** 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 'not_a_wall_clock_time': + 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 ScheduleLogError(`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. */ @@ -1318,7 +1238,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.') @@ -1335,7 +1255,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.') } diff --git a/packages/schedule/schedule/tsconfig.json b/packages/schedule/schedule/tsconfig.json index 6c0589042e..e3a3e1cd6c 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 70665431a3..70526d2d7e 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: 2424d44f0d79be74 + zh: 5bc05166e8d83c1e /util-shared-utilities/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /util-shared-utilities/packages: - en: 7952d6444283d100 - zh: 091165eb224731f9 + en: 662b2baeac105692 + zh: f076c6d39c2985b1 /util-shared-utilities/related-documentation: en: 432ae81110914571 zh: c5bf4efc007311f3 diff --git a/packages/util/README.md b/packages/util/README.md index b5fca2657a..7184f8f595 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 and instant 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 @@ -41,7 +41,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 | | [`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 dfe7d06219..33253cc139 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 路径、启动环境、出站代理策略、原生命令、输出保留、时间与时区词汇,以及超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 ## 目录 @@ -41,7 +41,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 6e0e48d71a..3d25e2ee8d 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: 78b8a5dd36b637f5 + zh: 451e95d5bc7d5a66 /dsh-util-time: en: bc299a9b8fe7717f zh: 896661ee0c4edf25 /dsh-util-time/summary: - en: f37c9eb6571622ac - zh: d782978ff11bd40f + en: 6d4d39093c2a2b5a + zh: 1dc4af01352581f2 /dsh-util-time/table-of-contents: - en: cd2a60a905c978d6 - zh: cc71d387d0cbd442 + en: d152484eb41ac6b4 + zh: f14f34fce35a8d90 /dsh-util-time/use-this-package: - en: 1472cf19b0bd7447 - zh: 73bf36759bc2d492 -/dsh-util-time/api: - en: cbb4b726e939fc03 - zh: e9df4a7487433146 + en: bbff250eba0f1b89 + zh: 0a618f05cc88ee2b +/dsh-util-time/use-this-package/when-to-use-it: + en: 1c5733835410b678 + zh: ddf336123fed82db +/dsh-util-time/use-this-package/entry-point: + en: 657c118ff4d9b99b + zh: 2b11d97a40a2044f +/dsh-util-time/understand-the-implementation: + en: 4daf141b0fbee0c4 + zh: 3dda79572f6c224b +/dsh-util-time/further-exploration: + en: 7730949c9d91249b + zh: 006dc7155d6b5afc /dsh-util-time/model-experience: - en: 02d997b5c1318f27 - zh: 5c55d060adea756d + en: 401c6ad02e1cdd75 + zh: fd72235cb50cb3fa /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: 8b29ec4add8f93e6 + zh: ee9bfd6224a4204c /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 788d7310f5..9ba371598d 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 for maintainers accepting a caller-reported zone or a wall-clock selector 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 or a wall-clock selector. `canonicalClientTimeZone` admits `UTC` or an IANA `Area/Location` name and answers the platform-canonical spelling, so an alias never reaches a durable record. A wall-clock consumer instead parses strict RFC 3339 instants, local calendar fields, and zone names through discriminated results, then resolves a local value with maintained `Temporal` DST and overlap behavior. The library owns no failure vocabulary or 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,40 +25,81 @@ 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. -No runtime invariant companion is published because this pure utility owns no event stream or mutable runtime data; unit tests verify zone canonicalization. +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-schedule` reads every absolute and wall-clock selector through the strict parsers and resolves its local targets through `resolveLocalInstant`. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value. -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. +No runtime invariant companion is published because this pure utility owns no event stream or mutable runtime data; unit tests verify every entry. + +### Entry point + +```ts +import { 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 +} +``` + +`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. Every other entry answers a discriminated result whose `rejected` arm carries a stable reason, so each caller again chooses its own code and diagnostic. ----- - -## 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 whole implementation; `src/index.ts` re-exports it and reduces `canonicalizeTimeZone` to 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. | +| `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 epoch inside the four-digit-year range; answers `undefined` outside it. | +| `resolveLocalInstant` | Exact calendar fields and an already-canonical zone. | + +
+ +----- + + +## 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. +- [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 and render model-visible scheduler text from them. #### 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, and canonical formatting only; a caller that walks candidate dates or intervals owns that search. +- **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 48f9d60919..c850608eb3 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 时刻、本地日历字段与时区名称,再借助受维护的 `Temporal` 解析本地值,获得 DST 与重叠时段的既定行为。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。 ## 目录 - [使用本包](#use-this-package) -- [API](#api) +- [理解实现](#understand-the-implementation) +- [延伸阅读](#further-exploration) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) - [开发备注](#dev-note) @@ -24,40 +25,81 @@ kind: "package-library" ## 使用本包 +### 何时使用 + 它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。 -不发布运行时不变量伴生入口;这个纯工具不拥有事件流或可变运行时数据,时区规范化由单元测试验证。 +在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。 -在接收时区的那个边界上调用它,让值在进入任何持久物之前先过一遍。不可用的名称回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。 +不发布运行时不变量伴生入口;这个纯工具不拥有事件流或可变运行时数据,每个入口都由单元测试验证。 + +### 入口 + +```ts +import { 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 +} +``` + +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。其余每个入口都回答带判别值的结果,其 `rejected` 分支携带稳定的拒绝原因,同样由调用方选择自己的错误码与诊断文案。 ----- - -## API + +## 理解实现 -```ts -import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' -``` +
+实现内部细节 —— 点击展开 -| 导出 | 职责 | +`src/instant.ts` 承载全部实现;`src/index.ts` 转出它,并把 `canonicalizeTimeZone` 归约为协议边界已经在消费的 `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`。 | +| `resolveLocalInstant` | 精确日历字段与已规范化的时区。 | + +
+ +----- + + +## 延伸阅读 + +- [Schedule 宿主存储](../../schedule/schedule/README.zh.md) —— 保存已提交 UTC 时点的消费方。 +- [Session 控制器](../../api/session-controller/README.zh.md) —— 规范化浏览器时区的请求边界。 +- [util/ 包地图](../README.zh.md) —— 本库在共享工具中的位置。 + +----- ## 模型体验 -间接影响,取决于把规范时区记到持久消息上的那个消费方——`dsh-time-context` 据此渲染该轮模型可见的时区指令与时间戳。 +间接影响,取决于把规范时区或已提交 UTC 时刻记到持久记录上、并据此渲染模型可见调度文本的那些消费方。 #### KV Cache 影响 -自身没有。把时区派生文本注入请求的那个消费方,对该请求的缓存行为负责。 +自身没有。把时区或调度的派生文本注入请求的那个消费方,对该请求的缓存行为负责。 ## 已知限制与延期工作 - **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 -- **只做校验**——不格式化、不做偏移运算、不推导 DST、不做时刻换算;需要这些的消费方直接用 `Intl`。 +- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析与规范格式化;遍历候选日期或间隔的搜索由调用方自己拥有。 +- **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。 ### 开发备注 diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 8aea082411..ac2ebb4033 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 wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization), and strict RFC 3339 instant, local-calendar, and zone resolution for wall-clock selectors", "version": "0.1.7-rc.1", "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/index.ts b/packages/util/time/src/index.ts index 1867965cf2..57c7b5d330 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -1,12 +1,17 @@ /** - * 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. + * + * Validation, canonicalization, and strict parsing only: this library formats + * no model-facing text and owns no failure vocabulary — every entry answers a + * discriminated result, and each boundary maps a reason onto its own domain + * code and diagnostic. * @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 * from './instant.ts' /** * Validate and canonicalize one caller-supplied IANA zone at a wire boundary. @@ -18,16 +23,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..8a52c4a6dc --- /dev/null +++ b/packages/util/time/src/instant.ts @@ -0,0 +1,279 @@ +/** + * Strict instant and local-calendar vocabulary for the boundaries that accept a + * wall-clock selector. + * + * Every entry 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. 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 epoch-millisecond bounds 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 epoch-millisecond bounds 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 { + /** Four-digit year; year 0 is rejected. */ + 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' + | 'not_a_calendar_date' + | 'not_a_wall_clock_time' + | '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 } + +/** 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 -- every numeric field is pre-checked, so only a non-existent date reaches here. */ + if (error instanceof RangeError) return undefined + /* v8 ignore next -- Temporal throws only RangeError for rejected overflow. */ + 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('not_a_wall_clock_time') + } + 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 }) +} + +/** + * 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. + * @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 { + // Intl rejects a well-formed name the platform's IANA data does not know. + return rejected('unsupported') + } + /* 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 local calendar fields. + * @param timeZone - Already-canonical `UTC` or IANA zone. + * @returns The resolved epoch milliseconds, or `undefined` for a nonexistent wall-clock time. + */ +export function resolveLocalInstant(time: LocalCalendarTime, timeZone: string): number | undefined { + const local = 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/instant.spec.ts b/packages/util/time/tests/instant.spec.ts new file mode 100644 index 0000000000..0920c37351 --- /dev/null +++ b/packages/util/time/tests/instant.spec.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest' +import { + MAX_FOUR_DIGIT_YEAR_MS, + MIN_FOUR_DIGIT_YEAR_MS, + canonicalizeTimeZone, + formatFourDigitYearInstant, + fourDigitYearEpoch, + 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: 'not_a_wall_clock_time', + }) + expect(parseOffsetInstant('2026-03-08T24:30:00Z')).toEqual({ + kind: 'rejected', + reason: 'not_a_wall_clock_time', + }) + expect(parseOffsetInstant('2026-03-08T02:60:00Z')).toEqual({ + kind: 'rejected', + reason: 'not_a_wall_clock_time', + }) + expect(parseOffsetInstant('2026-03-08T02:30:60Z')).toEqual({ + kind: 'rejected', + reason: 'not_a_wall_clock_time', + }) + 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('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' }) + expect(canonicalizeTimeZone('Not/A_Real_Zone')).toEqual({ kind: 'rejected', reason: 'unsupported' }) + }) +}) + +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')) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4bcc5c5e0a..3b5b05c5b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10251,6 +10251,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 @@ -13286,6 +13289,10 @@ importers: version: link:../../../vendor/cordis packages/util/time: + dependencies: + '@js-temporal/polyfill': + specifier: 0.5.1 + version: 0.5.1 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ From 7ee7dfde55b2ef0b27825a87ee14551b0224dc97 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 13:00:40 +0800 Subject: [PATCH 02/26] fix(schedule): preserve both local-calendar diagnostics A calendar date the month does not have keeps the at diagnostic; only an out-of-range clock field keeps the local at one. The extraction had collapsed both onto the local at wording. --- packages/schedule/schedule/src/domain.ts | 26 ++++++++++++++----- .../schedule/schedule/tests/domain.spec.ts | 19 ++++++++++++++ 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 46bdce8207..ed970e1800 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -14,7 +14,7 @@ import { parseOffsetInstant, resolveLocalInstant, } from '@deepseek-ai/dsh-util-time' -import type { LocalCalendarTime, TimeZoneRejection } from '@deepseek-ai/dsh-util-time' +import type { 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 { @@ -288,16 +288,28 @@ export function canonicalizeTimeZone(value: string): string { function parseLocalAt(value: LocalAtInput): LocalCalendarTime { const parsed = parseLocalCalendar(value.date, value.time) if (parsed.kind === 'rejected') { - throw new ScheduleInputError( - 'invalid_rule', - parsed.reason === 'malformed' - ? 'Local at requires date YYYY-MM-DD and time HH:mm:ss with optional one-to-three digit milliseconds.' - : 'The local at value must be a real ISO calendar date and time.', - ) + throw new ScheduleInputError('invalid_rule', localCalendarDiagnostic(parsed.reason)) } return parsed.time } +/** 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 ScheduleLogError(`unknown local calendar rejection ${String(unreachable)}`) + } + } +} + /** 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' }) diff --git a/packages/schedule/schedule/tests/domain.spec.ts b/packages/schedule/schedule/tests/domain.spec.ts index 41b1cd4913..0c2ed94c70 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', From 882a18c57b35a3f59db9a9b925e31dc400bc0010 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 13:02:11 +0800 Subject: [PATCH 03/26] docs: drop a nonexistent path reference from the time-vocabulary note --- .../architecture/2026-09-24-shared-time-vocabulary.i18n.yaml | 4 ++-- .../architecture/2026-09-24-shared-time-vocabulary.md | 2 +- .../architecture/2026-09-24-shared-time-vocabulary.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 1048d91116..2ef600c588 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 @@ -12,8 +12,8 @@ en: 290a11695db08ffa zh: a19b859e39e8cf8a /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: - en: db5c53007cf0ae82 - zh: c654d66934c55d42 + en: bc00edc00a7f81f0 + zh: 958bc76bb209604b /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: en: a0a11d4bd4fa11e3 zh: 3536397be8dd0add 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 c44b802411..86824d3b85 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 @@ -22,7 +22,7 @@ Observable behavior does not change. Every accepted input resolves to the same e ## Alternatives considered -**Keep the shared module private to Schedule.** A `packages/schedule/schedule/src/time.ts` extraction would shrink `domain.ts` 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. +**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 diagnostics are pinned verbatim by `tools.spec.ts`, snapshot expectations, and the model-visible tool result; a shared error class invites one caller's wording into another caller's surface. 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 4c1d054d35..128dc1d9e2 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 @@ -22,7 +22,7 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec ## Alternatives considered -**把共享模块保持为 Schedule 私有。** 抽成 `packages/schedule/schedule/src/time.ts` 可以缩小 `domain.ts`,而不触碰已发布包、其 lockfile 条目、README 或依赖集合。之所以否决,是因为本次要消除的重复是重复的**剖面**,不是重复的行数:时区正则、`Intl` 规范化步骤与时刻剖面仍会存在两份,下一个接收墙钟选择器的边界仍会复制出第三份。 +**把共享模块保持为 Schedule 私有。** 在 `domain.ts` 旁抽出一个 Schedule 私有的 `time` 模块可以缩小它,而不触碰已发布包、其 lockfile 条目、README 或依赖集合。之所以否决,是因为本次要消除的重复是重复的**剖面**,不是重复的行数:时区正则、`Intl` 规范化步骤与时刻剖面仍会存在两份,下一个接收墙钟选择器的边界仍会复制出第三份。 **让本库自带错误类型。** 一个携带原因的 `TimeInputError` 可以让调用方直接重抛而不做映射,缩短每个调用点。之所以否决,是因为 `util/time` 既有契约规定每个边界拥有自己的拒绝词汇,而 Schedule 的诊断文案被 `tools.spec.ts`、快照期望与模型可见工具结果逐字固定;共享错误类会把一个调用方的措辞带进另一个调用方的界面。 From 6c9c7cb69e0160df9325348788f1fed53afd4a97 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 13:08:06 +0800 Subject: [PATCH 04/26] docs(time): state the throwing preconditions and exact contracts - Instant entries that take already-validated values throw on violated preconditions; the module header said every entry answers a result. - formatFourDigitYearInstant also refuses a non-integral epoch. - Schedule's reason mappers switch over the closed unions with a never default, so a new library reason fails the build instead of falling through to a catch-all. - The Agent Note claimed the schedule suites pin these diagnostics; they assert codes only, and the note now records the added message assertion. --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 12 ++--- .../2026-09-24-shared-time-vocabulary.md | 6 +-- .../2026-09-24-shared-time-vocabulary.zh.md | 6 +-- packages/schedule/schedule/src/domain.ts | 49 +++++++++++++++---- packages/util/time/README.i18n.yaml | 4 +- packages/util/time/README.md | 2 +- packages/util/time/README.zh.md | 2 +- packages/util/time/src/instant.ts | 17 ++++--- 8 files changed, 67 insertions(+), 31 deletions(-) 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 2ef600c588..be43d6e233 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: 6adc7f8750843df4 zh: 55a600663ece4b2a /agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: - en: 290a11695db08ffa - zh: a19b859e39e8cf8a + en: 30ff0138ed91c0be + zh: 4df42e6b5759d465 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: - en: bc00edc00a7f81f0 - zh: 958bc76bb209604b + en: 5bf3cc0d112531e7 + zh: 99ed3e958d9d8ccb /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: a0a11d4bd4fa11e3 - zh: 3536397be8dd0add + en: 88e6959ae2fbeb84 + zh: 951922682320235d 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 86824d3b85..0a86af09d5 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 @@ -18,13 +18,13 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d 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. -Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` code and message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included. +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. The schedule suites assert error codes and never these message strings, so `packages/schedule/schedule/tests/domain.spec.ts` gains an assertion on the three local-calendar diagnostics; without it a wording regression in the extraction passed the whole suite. `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 diagnostics are pinned verbatim by `tools.spec.ts`, snapshot expectations, and the model-visible tool result; a shared error class invites one caller's wording into another caller's surface. +**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. @@ -34,6 +34,6 @@ Observable behavior does not change. Every accepted input resolves to the same e 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 three regular expressions, the `Intl` canonicalization, the hand-rolled calendar conversion, and the four-digit bound arithmetic. -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, so a reason added to the library's union fails the build at each caller through the exhaustive switch rather than silently taking a default. +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, and each mapper switches exhaustively over the library's closed reason unions with a `never` default, so a newly added reason fails the build at every affected caller instead of silently taking a catch-all. `dsh-time-context` keeps its own IANA regular expression and canonicalization, and stays out of this change. It rejects a non-canonical alias at the request boundary where Schedule canonicalizes one, so unifying it means deciding which of the two behaviors the browser-time-zone instruction wants; that decision belongs to the consumer, not to this extraction. 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 128dc1d9e2..ea9b437af9 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 @@ -18,13 +18,13 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。 -可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 错误码与消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 +可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `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 的诊断文案被 `tools.spec.ts`、快照期望与模型可见工具结果逐字固定;共享错误类会把一个调用方的措辞带进另一个调用方的界面。 +**让本库自带错误类型。** 一个携带原因的 `TimeInputError` 可以让调用方直接重抛而不做映射,缩短每个调用点。之所以否决,是因为 `util/time` 既有契约规定每个边界拥有自己的拒绝词汇,而 Schedule 的消息会通过工具结果的 `{ code, message }` 与客户端的消息卡片到达模型;共享错误类会把一个调用方的措辞带进另一个调用方的界面。 **让本库回答 `undefined` 而不是原因。** 这正是 `canonicalClientTimeZone` 已有的形态,也是改动更小的做法。之所以否决,是因为 Schedule 必须区分格式错误的时刻、日历上不存在的日期与越界时钟字段,才能保留既有诊断;`canonicalizeTimeZone` 也必须区分名称本身不合法与本地无法解析。把二者压成一个假值,会迫使 Schedule 用自己的正则重新推导这层区分,从而恢复重复。 @@ -34,6 +34,6 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了三段正则、`Intl` 规范化、手写日历换算与四位年份边界运算。 -代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射,因此本库联合类型新增原因时,每个调用方都会因穷尽 switch 而构建失败,而不是静默走默认分支。 +代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射,每个映射函数都以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让每个受影响的调用方构建失败,而不是静默走兜底分支。 `dsh-time-context` 保留自己的 IANA 正则与规范化,不参与本次改动。它在请求边界拒绝非规范别名,而 Schedule 会把它规范化,因此统一二者意味着决定浏览器时区指令需要两种行为中的哪一种;该决定属于消费方,而不属于这次抽取。 diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index ed970e1800..d459e9d21f 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -14,7 +14,9 @@ import { parseOffsetInstant, resolveLocalInstant, } from '@deepseek-ai/dsh-util-time' -import type { LocalCalendarRejection, LocalCalendarTime, TimeZoneRejection } 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 { @@ -253,24 +255,53 @@ function decodeId(value: unknown): ScheduleIdType { function decodeInstant(value: unknown): string { const parsed = typeof value === 'string' ? fourDigitYearEpoch(value) : undefined if (parsed === undefined || parsed.kind === 'rejected') { - throw new ScheduleLogError( - parsed?.reason === 'not_a_calendar_date' - ? 'scheduledAt is not a real UTC calendar instant' - : 'scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant', - ) + throw new ScheduleLogError(instantDiagnostic(parsed?.reason)) } return value as string } +/** + * Map one shared instant rejection onto the stable stored-data diagnostic. + * @param reason - Reason the shared vocabulary refused the instant, or undefined for a non-string field. + * @returns The Schedule log diagnostic for that refusal. + */ +function instantDiagnostic(reason: InstantRejection | undefined): string { + switch (reason) { + case 'not_a_calendar_date': + return 'scheduledAt is not a real UTC calendar instant' + case 'not_rfc3339': + case 'not_a_wall_clock_time': + case 'invalid_numeric_offset': + case undefined: + return 'scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant' + /* 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)}`) + } + } +} + /** * Map one shared zone rejection onto the stable Schedule input code. * @param reason - Reason the shared vocabulary refused the name. * @returns The public Schedule diagnostic for that refusal. */ function timeZoneError(reason: TimeZoneRejection): ScheduleInputError { - return reason === 'not_canonical_shape' - ? new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.') - : new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.') + switch (reason) { + case 'not_canonical_shape': + return new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.') + 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.') + /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ + default: { + const unreachable: never = reason + throw new ScheduleLogError(`unknown time zone rejection ${String(unreachable)}`) + } + } } /** diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index 3d25e2ee8d..750a0db194 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -24,8 +24,8 @@ en: 657c118ff4d9b99b zh: 2b11d97a40a2044f /dsh-util-time/understand-the-implementation: - en: 4daf141b0fbee0c4 - zh: 3dda79572f6c224b + en: 479489d729810a9f + zh: a238654f67e33b62 /dsh-util-time/further-exploration: en: 7730949c9d91249b zh: 006dc7155d6b5afc diff --git a/packages/util/time/README.md b/packages/util/time/README.md index 9ba371598d..32b61b83de 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -68,7 +68,7 @@ The strict parsers match a fixed regular expression before constructing any date | `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 epoch inside the four-digit-year range; answers `undefined` outside it. | +| `formatFourDigitYearInstant` | An integral epoch inside the four-digit-year range; answers `undefined` outside it or when non-integral. | | `resolveLocalInstant` | Exact calendar fields and an already-canonical zone. | diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index c850608eb3..9fbc058d1a 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -68,7 +68,7 @@ if (local.kind === 'accepted') { | `parseLocalCalendar` | `YYYY-MM-DD` 与 `HH:mm:ss`,可带 1 到 3 位小数秒。 | | `parseOffsetInstant` | 带显式 `Z` 或数字偏移的 RFC 3339;偏移量属于输入的一部分。 | | `fourDigitYearEpoch` | 规范存储剖面:恰好 3 位小数秒与 `Z`。 | -| `formatFourDigitYearInstant` | 四位年份范围内的纪元毫秒;超出范围回答 `undefined`。 | +| `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 | | `resolveLocalInstant` | 精确日历字段与已规范化的时区。 | diff --git a/packages/util/time/src/instant.ts b/packages/util/time/src/instant.ts index 8a52c4a6dc..0bec170408 100644 --- a/packages/util/time/src/instant.ts +++ b/packages/util/time/src/instant.ts @@ -2,10 +2,14 @@ * Strict instant and local-calendar vocabulary for the boundaries that accept a * wall-clock selector. * - * Every entry 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. Local wall-clock resolution uses the maintained - * `Temporal` implementation, so no caller owns offset sampling or DST rules. + * 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. The two entries that take + * already-validated values throw on violated preconditions instead — + * `resolveLocalInstant` raises a `RangeError` for an unknown zone or an + * impossible calendar field, and `formatFourDigitYearInstant` cannot throw. + * Local wall-clock resolution uses the maintained `Temporal` implementation, so + * no caller owns offset sampling or DST rules. * @module @deepseek-ai/dsh-util-time */ @@ -226,7 +230,7 @@ export function fourDigitYearEpoch(value: string): InstantResult { * * 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. + * @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 { @@ -268,9 +272,10 @@ export function canonicalizeTimeZone(value: string): TimeZoneResult { * 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 local calendar fields. + * @param time - Exact calendar fields of a real date whose fields are already in range. * @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 `time` names an impossible calendar value. */ export function resolveLocalInstant(time: LocalCalendarTime, timeZone: string): number | undefined { const local = Temporal.PlainDateTime.from(time, { overflow: 'reject' }) From 0a89067fdeb3325d9f3bfbebbcf3589f46aea49a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 13:15:06 +0800 Subject: [PATCH 05/26] docs: regenerate the module graph for the schedule to util-time edge --- docs/module-graph.md | 3 ++- docs/module-graph.zh.md | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index ef7567f3dd..c040e60589 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1321,6 +1321,7 @@ flowchart TD pkg_schedule --> pkg_storage_domain 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 @@ -1670,7 +1671,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`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) | | [`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 ac98ecddd9..18f9c9ed80 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1323,6 +1323,7 @@ flowchart TD pkg_schedule --> pkg_storage_domain 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 @@ -1672,7 +1673,7 @@ flowchart TD | [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) | | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | -| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) | +| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`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) | | [`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) | From b42cae0c8b2e20679e343416f0609253d78131d7 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 13:25:05 +0800 Subject: [PATCH 06/26] fix(schedule): mark the unreachable zone-rejection branch The shared parser never answers not_canonical_shape, so a switch over the union left that case uncovered and failed the per-file coverage gate. --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 4 ++-- .../2026-09-24-shared-time-vocabulary.md | 2 +- .../2026-09-24-shared-time-vocabulary.zh.md | 2 +- packages/schedule/schedule/src/domain.ts | 21 +++++++------------ 4 files changed, 11 insertions(+), 18 deletions(-) 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 be43d6e233..fe15d1a558 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 @@ -15,5 +15,5 @@ en: 5bf3cc0d112531e7 zh: 99ed3e958d9d8ccb /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 88e6959ae2fbeb84 - zh: 951922682320235d + en: 02aab911948d62d4 + zh: 78f9d683c0600727 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 0a86af09d5..c38468502e 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 @@ -34,6 +34,6 @@ Observable behavior does not change. Every accepted input resolves to the same e 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 three regular expressions, the `Intl` canonicalization, the hand-rolled calendar conversion, and the four-digit bound arithmetic. -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, and each mapper switches exhaustively over the library's closed reason unions with a `never` default, so a newly added reason fails the build at every affected caller instead of silently taking a catch-all. +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 cannot: the library's `not_canonical_shape` reason is unreachable, so a switch would leave a branch the schedule suites can never execute. `dsh-time-context` keeps its own IANA regular expression and canonicalization, and stays out of this change. It rejects a non-canonical alias at the request boundary where Schedule canonicalizes one, so unifying it means deciding which of the two behaviors the browser-time-zone instruction wants; that decision belongs to the consumer, not to this extraction. 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 ea9b437af9..3acbb58bd9 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 @@ -34,6 +34,6 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了三段正则、`Intl` 规范化、手写日历换算与四位年份边界运算。 -代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射,每个映射函数都以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让每个受影响的调用方构建失败,而不是静默走兜底分支。 +代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数做不到这一点:本库的 `not_canonical_shape` 原因不可达,用 switch 会留下一个 Schedule 测试永远无法执行的分支。 `dsh-time-context` 保留自己的 IANA 正则与规范化,不参与本次改动。它在请求边界拒绝非规范别名,而 Schedule 会把它规范化,因此统一二者意味着决定浏览器时区指令需要两种行为中的哪一种;该决定属于消费方,而不属于这次抽取。 diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index d459e9d21f..ebb549a6c2 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -288,20 +288,13 @@ function instantDiagnostic(reason: InstantRejection | undefined): string { * @returns The public Schedule diagnostic for that refusal. */ function timeZoneError(reason: TimeZoneRejection): ScheduleInputError { - switch (reason) { - case 'not_canonical_shape': - return new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.') - 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.') - /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ - default: { - const unreachable: never = reason - throw new ScheduleLogError(`unknown time zone rejection ${String(unreachable)}`) - } - } + // `not_canonical_shape` names a platform-canonical answer outside the Area/Location + // profile, which the shared parser's `Intl` step does not produce; this branch keeps + // the diagnostic this entry has always returned for that reason. + /* v8 ignore next 2 -- the shared parser never answers not_canonical_shape for a canonical zone. */ + return reason === 'not_canonical_shape' + ? new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.') + : new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.') } /** From 22f8be30a1719f462732812a1d5de00128b71298 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 21:54:34 +0800 Subject: [PATCH 07/26] refactor(time-context): consume the shared zone vocabulary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit time-context kept a third copy of the IANA profile: its own regular expression and its own Intl canonicalization in request-zone.ts. It now reads a request's reported zone through canonicalizeTimeZone and keeps its stricter policy — an alias whose canonical answer differs from the reported value is still refused, with the same three diagnostics. The shared rejection now carries the platform error it caught, so the unsupported refusal keeps its cause in both time-context and Schedule. --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 12 +++--- .../2026-09-24-shared-time-vocabulary.md | 6 ++- .../2026-09-24-shared-time-vocabulary.zh.md | 6 ++- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/context/time-context/package.json | 6 ++- .../context/time-context/src/request-zone.ts | 38 +++++++++++-------- .../time-context/tests/request-zone.spec.ts | 21 ++++++++++ packages/context/time-context/tsconfig.json | 3 ++ packages/schedule/schedule/src/domain.ts | 18 +++++++-- packages/util/time/README.i18n.yaml | 12 +++--- packages/util/time/README.md | 5 ++- packages/util/time/README.zh.md | 5 ++- packages/util/time/src/instant.ts | 14 +++++-- packages/util/time/tests/instant.spec.ts | 8 +++- pnpm-lock.yaml | 3 ++ 16 files changed, 114 insertions(+), 49 deletions(-) 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 fe15d1a558..cb843b513d 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 @@ -6,14 +6,14 @@ en: ae04747237709d0c zh: a3731d98d5e940f9 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/problem: - en: 6adc7f8750843df4 - zh: 55a600663ece4b2a + en: ad997234edc363a7 + zh: 25968ebb6fd9de71 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: - en: 30ff0138ed91c0be - zh: 4df42e6b5759d465 + en: 01f55d3c05812e4f + zh: 1764a14bcffeecd7 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: en: 5bf3cc0d112531e7 zh: 99ed3e958d9d8ccb /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 02aab911948d62d4 - zh: 78f9d683c0600727 + en: 3e10b12bf9e85faf + zh: 1362af0ff00c4683 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 c38468502e..d0daddb3c9 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 @@ -6,7 +6,7 @@ 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. Schedule had grown the other half of the same vocabulary privately inside `packages/schedule/schedule/src/domain.ts`: a strict RFC 3339 instant parser, a local-calendar parser, a four-digit-year bound and canonical formatter, a local wall-clock resolver, and a second IANA zone canonicalizer that threw `ScheduleInputError` instead of answering a result. Two consequences followed. The same three regular expressions and the same `Intl.DateTimeFormat` canonicalization existed twice, so a change to either profile had to be made twice and verified twice. And the next consumer of a wall-clock selector had no shared entry point to reach for, which is why the profile would have been copied a third time rather than imported. +`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. Three 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 third 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, and the instant profile twice, so a change to either 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 @@ -16,6 +16,8 @@ The library answers discriminated results and carries no failure vocabulary. `pa 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 a rejection carries the platform error the library caught so the `unsupported` refusal keeps its `cause`. + 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. 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. The schedule suites assert error codes and never these message strings, so `packages/schedule/schedule/tests/domain.spec.ts` gains an assertion on the three local-calendar diagnostics; without it a wording regression in the extraction passed the whole suite. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included. @@ -36,4 +38,4 @@ 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 cannot: the library's `not_canonical_shape` reason is unreachable, so a switch would leave a branch the schedule suites can never execute. -`dsh-time-context` keeps its own IANA regular expression and canonicalization, and stays out of this change. It rejects a non-canonical alias at the request boundary where Schedule canonicalizes one, so unifying it means deciding which of the two behaviors the browser-time-zone instruction wants; that decision belongs to the consumer, not to this extraction. +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. 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 3acbb58bd9..0d886b3cac 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 @@ -6,7 +6,7 @@ Status: implemented ## Problem -`dsh-util-time` 此前只承担一项职责、面向一种调用方:`canonicalClientTimeZone` 在协议边界校验调用方所报时区并回答 `undefined`,由 `dsh-api-session-controller` 与 `dsh-subagent` 映射成各自的域错误。同一套词汇的另一半则在 `packages/schedule/schedule/src/domain.ts` 内部私下生长:严格的 RFC 3339 时刻解析器、本地日历解析器、四位年份边界与规范格式化、本地墙钟解析,以及第二个 IANA 时区规范化实现——它抛 `ScheduleInputError` 而不是回答结果。由此产生两个后果。同样的三段正则与同样的 `Intl.DateTimeFormat` 规范化存在两份,任何一侧剖面变更都要改两遍、验两遍。而下一个需要墙钟选择器的消费方没有共享入口可用,这正是该剖面会被第三次复制而不是被导入的原因。 +`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 @@ -14,6 +14,8 @@ Status: implemented 本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`not_a_calendar_date`、`not_a_wall_clock_time`、`invalid_numeric_offset`、`malformed`、`out_of_range`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。 +`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因,而被拒绝的结果会携带本库捕获到的平台错误,使 `unsupported` 拒绝保留其 `cause`。 + Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。 受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。 @@ -36,4 +38,4 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数做不到这一点:本库的 `not_canonical_shape` 原因不可达,用 switch 会留下一个 Schedule 测试永远无法执行的分支。 -`dsh-time-context` 保留自己的 IANA 正则与规范化,不参与本次改动。它在请求边界拒绝非规范别名,而 Schedule 会把它规范化,因此统一二者意味着决定浏览器时区指令需要两种行为中的哪一种;该决定属于消费方,而不属于这次抽取。 +本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有其不变量所校验的持久读数字格式。 diff --git a/docs/module-graph.md b/docs/module-graph.md index c040e60589..13299ccc70 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -637,6 +637,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 @@ -1554,7 +1555,7 @@ flowchart TD | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`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), [`invariants`](../packages/runtime-diagnostics/invariants), [`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) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 18f9c9ed80..adcf2c8e4a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -639,6 +639,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 @@ -1556,7 +1557,7 @@ flowchart TD | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | -| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`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), [`invariants`](../packages/runtime-diagnostics/invariants), [`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) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 3ff8a1ed5c..998145cb93 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -42,7 +42,8 @@ "@deepseek-ai/dsh-invariants": "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:~", @@ -60,6 +61,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/request-zone.ts b/packages/context/time-context/src/request-zone.ts index 41dad757af..517a31e07d 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,22 @@ 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 }) + if (resolved.reason === 'unsupported') { + throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: resolved.cause }) } - if (canonical !== value) { - throw new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) - } - return value + // `not_canonical_shape` names a platform-canonical answer outside the Area/Location + // profile, which the shared vocabulary does not produce for a name it accepts. + /* v8 ignore next 2 -- Intl answers a canonical Area/Location for every name it accepts. */ + throw resolved.reason === 'not_canonical_shape' + ? new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) + : new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(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..8dbd648572 100644 --- a/packages/context/time-context/tests/request-zone.spec.ts +++ b/packages/context/time-context/tests/request-zone.spec.ts @@ -53,6 +53,27 @@ 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('+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 bb6a7d6927..e97b4b03d7 100644 --- a/packages/context/time-context/tsconfig.json +++ b/packages/context/time-context/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../session/session-projection" + }, + { + "path": "../../util/time" } ] } diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index ebb549a6c2..bd041e287b 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -285,16 +285,26 @@ function instantDiagnostic(reason: InstantRejection | undefined): string { /** * 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): ScheduleInputError { +function timeZoneError(reason: TimeZoneRejection, cause?: unknown): ScheduleInputError { + const options = cause === undefined ? undefined : { cause } // `not_canonical_shape` names a platform-canonical answer outside the Area/Location // profile, which the shared parser's `Intl` step does not produce; this branch keeps // the diagnostic this entry has always returned for that reason. /* v8 ignore next 2 -- the shared parser never answers not_canonical_shape for a canonical zone. */ return reason === 'not_canonical_shape' - ? new ScheduleInputError('invalid_time_zone', 'time_zone must resolve to UTC or an IANA Area/Location name.') - : new ScheduleInputError('invalid_time_zone', 'time_zone must be UTC or a valid IANA Area/Location name.') + ? new ScheduleInputError( + 'invalid_time_zone', + 'time_zone must resolve to UTC or an IANA Area/Location name.', + options, + ) + : new ScheduleInputError( + 'invalid_time_zone', + 'time_zone must be UTC or a valid IANA Area/Location name.', + options, + ) } /** @@ -304,7 +314,7 @@ function timeZoneError(reason: TimeZoneRejection): ScheduleInputError { */ export function canonicalizeTimeZone(value: string): string { const resolved = canonicalizeSharedTimeZone(value) - if (resolved.kind === 'rejected') throw timeZoneError(resolved.reason) + if (resolved.kind === 'rejected') throw timeZoneError(resolved.reason, resolved.cause) return resolved.timeZone } diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index 750a0db194..3e2ceb2cb7 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -18,17 +18,17 @@ en: bbff250eba0f1b89 zh: 0a618f05cc88ee2b /dsh-util-time/use-this-package/when-to-use-it: - en: 1c5733835410b678 - zh: ddf336123fed82db + en: f97e654746aa3712 + zh: 52ba385e9f3b96a9 /dsh-util-time/use-this-package/entry-point: en: 657c118ff4d9b99b zh: 2b11d97a40a2044f /dsh-util-time/understand-the-implementation: - en: 479489d729810a9f - zh: a238654f67e33b62 + en: a144232203c0c487 + zh: 5821b2adb564152c /dsh-util-time/further-exploration: - en: 7730949c9d91249b - zh: 006dc7155d6b5afc + en: 8aa95ebdfd1f323e + zh: c6879eac9fa6e087 /dsh-util-time/model-experience: en: 401c6ad02e1cdd75 zh: fd72235cb50cb3fa diff --git a/packages/util/time/README.md b/packages/util/time/README.md index 32b61b83de..422e896d8c 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -29,7 +29,7 @@ Shared time vocabulary for the boundaries that accept a caller's zone or a wall- 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 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-schedule` reads every absolute and wall-clock selector through the strict parsers and resolves its local targets through `resolveLocalInstant`. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value. +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`. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value. No runtime invariant companion is published because this pure utility owns no event stream or mutable runtime data; unit tests verify every entry. @@ -64,7 +64,7 @@ The strict parsers match a fixed regular expression before constructing any date | Entry | Accepted input | |---|---| | `canonicalClientTimeZone` | `UTC` or an IANA `Area/Location` name; answers `undefined` when refused. | -| `canonicalizeTimeZone` | The same names; answers the canonical spelling or a refusal reason. | +| `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`. | @@ -80,6 +80,7 @@ The strict parsers match a fixed regular expression before constructing any date - [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. ----- diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index 9fbc058d1a..d11c62a4e1 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -29,7 +29,7 @@ kind: "package-library" 它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。 -在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。 +在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。 不发布运行时不变量伴生入口;这个纯工具不拥有事件流或可变运行时数据,每个入口都由单元测试验证。 @@ -64,7 +64,7 @@ if (local.kind === 'accepted') { | 入口 | 接受的输入 | |---|---| | `canonicalClientTimeZone` | `UTC` 或 IANA `Area/Location` 名称;被拒绝时回答 `undefined`。 | -| `canonicalizeTimeZone` | 同样的名称;回答规范拼写或拒绝原因。 | +| `canonicalizeTimeZone` | 同样的名称;回答规范拼写或拒绝原因;平台拒绝了一个格式合法的名称时,还会携带该平台错误。 | | `parseLocalCalendar` | `YYYY-MM-DD` 与 `HH:mm:ss`,可带 1 到 3 位小数秒。 | | `parseOffsetInstant` | 带显式 `Z` 或数字偏移的 RFC 3339;偏移量属于输入的一部分。 | | `fourDigitYearEpoch` | 规范存储剖面:恰好 3 位小数秒与 `Z`。 | @@ -80,6 +80,7 @@ if (local.kind === 'accepted') { - [Schedule 宿主存储](../../schedule/schedule/README.zh.md) —— 保存已提交 UTC 时点的消费方。 - [Session 控制器](../../api/session-controller/README.zh.md) —— 规范化浏览器时区的请求边界。 +- [时间上下文](../../context/time-context/README.zh.md) —— 拒绝非规范浏览器时区的请求消费方。 - [util/ 包地图](../README.zh.md) —— 本库在共享工具中的位置。 ----- diff --git a/packages/util/time/src/instant.ts b/packages/util/time/src/instant.ts index 0bec170408..ff80c31ad4 100644 --- a/packages/util/time/src/instant.ts +++ b/packages/util/time/src/instant.ts @@ -91,7 +91,12 @@ export type LocalCalendarResult = /** Discriminated result of resolving one raw IANA zone selector. */ export type TimeZoneResult = | { readonly kind: 'accepted'; readonly timeZone: string } - | { readonly kind: 'rejected'; readonly reason: TimeZoneRejection } + | { + 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 } { @@ -257,9 +262,10 @@ export function canonicalizeTimeZone(value: string): TimeZoneResult { let canonical: string try { canonical = new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone - } catch { - // Intl rejects a well-formed name the platform's IANA data does not know. - return rejected('unsupported') + } 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') diff --git a/packages/util/time/tests/instant.spec.ts b/packages/util/time/tests/instant.spec.ts index 0920c37351..c0196fe974 100644 --- a/packages/util/time/tests/instant.spec.ts +++ b/packages/util/time/tests/instant.spec.ts @@ -205,7 +205,13 @@ describe('canonicalizeTimeZone', () => { 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' }) - expect(canonicalizeTimeZone('Not/A_Real_Zone')).toEqual({ kind: 'rejected', reason: 'unsupported' }) + 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') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b5b05c5b6..c2a648408e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6558,6 +6558,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: From 2d28a1dee7d6cbadd458998a76db7a0eab677123 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 22:19:01 +0800 Subject: [PATCH 08/26] docs: narrow the zone ignore and correct the time-vocabulary note - the guarded ignore covers only the uncovered branch point - the note says two consumers copied the profile, counts copies rather than consumers, and scopes the platform cause to the unsupported rejection - the note names the remaining instant-profile copy in session-format --- .../2026-09-24-shared-time-vocabulary.i18n.yaml | 12 ++++++------ .../2026-09-24-shared-time-vocabulary.md | 6 +++--- .../2026-09-24-shared-time-vocabulary.zh.md | 6 +++--- packages/context/time-context/src/request-zone.ts | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) 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 cb843b513d..8154996dd4 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 @@ -6,14 +6,14 @@ en: ae04747237709d0c zh: a3731d98d5e940f9 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/problem: - en: ad997234edc363a7 - zh: 25968ebb6fd9de71 + en: abd0263d14861926 + zh: e1736cd08e87f795 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision: - en: 01f55d3c05812e4f - zh: 1764a14bcffeecd7 + en: 1a837ec23f5d3fd5 + zh: d7985a6bfa119e18 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: en: 5bf3cc0d112531e7 zh: 99ed3e958d9d8ccb /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 3e10b12bf9e85faf - zh: 1362af0ff00c4683 + en: 3b73743147ddab96 + zh: 245bd1bf275f5953 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 d0daddb3c9..d2d6c01032 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 @@ -6,7 +6,7 @@ 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. Three 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 third 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, and the instant profile twice, so a change to either 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. +`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 @@ -16,7 +16,7 @@ The library answers discriminated results and carries no failure vocabulary. `pa 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 a rejection carries the platform error the library caught so the `unsupported` refusal keeps its `cause`. +`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. 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. @@ -38,4 +38,4 @@ 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 cannot: the library's `not_canonical_shape` reason is unreachable, so a switch would leave a branch the schedule suites can never execute. -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. +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 0d886b3cac..27709825de 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 @@ -6,7 +6,7 @@ Status: implemented ## 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` 规范化存在三份、时刻剖面存在两份,任何一侧变更都要逐份改、逐份验;而下一个需要墙钟选择器的消费方没有共享入口可用。 +`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 @@ -14,7 +14,7 @@ Status: implemented 本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`not_a_calendar_date`、`not_a_wall_clock_time`、`invalid_numeric_offset`、`malformed`、`out_of_range`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。 -`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因,而被拒绝的结果会携带本库捕获到的平台错误,使 `unsupported` 拒绝保留其 `cause`。 +`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。 Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。 @@ -38,4 +38,4 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数做不到这一点:本库的 `not_canonical_shape` 原因不可达,用 switch 会留下一个 Schedule 测试永远无法执行的分支。 -本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有其不变量所校验的持久读数字格式。 +本次未触及、仍归 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/context/time-context/src/request-zone.ts b/packages/context/time-context/src/request-zone.ts index 517a31e07d..02b957b726 100644 --- a/packages/context/time-context/src/request-zone.ts +++ b/packages/context/time-context/src/request-zone.ts @@ -39,7 +39,7 @@ function browserTimeZone(message: UserMessage): string | undefined { } // `not_canonical_shape` names a platform-canonical answer outside the Area/Location // profile, which the shared vocabulary does not produce for a name it accepts. - /* v8 ignore next 2 -- Intl answers a canonical Area/Location for every name it accepts. */ + /* v8 ignore next -- Intl answers a canonical Area/Location for every name it accepts. */ throw resolved.reason === 'not_canonical_shape' ? new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) : new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`) From 51a757285278e32b2554f4db300476d86e84d397 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 00:31:56 +0800 Subject: [PATCH 09/26] refactor(time): answer the shared resolver from one place - resolveLocalInstant accepts an already-built plain date-time, so the recurrence walkers drop their private copy of the disambiguation and round-trip decision - decodeInstant narrows its field explicitly and drops the assertion - the model-input exhaustiveness fallbacks throw a plain Error, matching the timing-change fallback, while durable decoding keeps ScheduleLogError - the library module and README state which entries answer a discriminated result --- packages/schedule/schedule/src/domain.ts | 34 ++++++++++-------------- packages/util/time/README.i18n.yaml | 8 +++--- packages/util/time/README.md | 4 +-- packages/util/time/README.zh.md | 4 +-- packages/util/time/src/index.ts | 8 +++--- packages/util/time/src/instant.ts | 13 +++++---- packages/util/time/tests/instant.spec.ts | 8 ++++++ 7 files changed, 43 insertions(+), 36 deletions(-) diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 84083f7cf2..97046c6ad7 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -253,26 +253,26 @@ function decodeId(value: unknown): ScheduleIdType { /** Validate one canonical four-digit-year UTC instant. */ function decodeInstant(value: unknown): string { - const parsed = typeof value === 'string' ? fourDigitYearEpoch(value) : undefined - if (parsed === undefined || parsed.kind === 'rejected') { - throw new ScheduleLogError(instantDiagnostic(parsed?.reason)) + if (typeof value !== 'string') { + throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant') } - return value as string + const parsed = fourDigitYearEpoch(value) + if (parsed.kind === 'rejected') throw new ScheduleLogError(instantDiagnostic(parsed.reason)) + return value } /** * Map one shared instant rejection onto the stable stored-data diagnostic. - * @param reason - Reason the shared vocabulary refused the instant, or undefined for a non-string field. + * @param reason - Reason the shared vocabulary refused the instant. * @returns The Schedule log diagnostic for that refusal. */ -function instantDiagnostic(reason: InstantRejection | undefined): string { +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 'not_a_wall_clock_time': case 'invalid_numeric_offset': - case undefined: return 'scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant' /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ default: { @@ -339,17 +339,11 @@ function localCalendarDiagnostic(reason: LocalCalendarRejection): string { /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ default: { const unreachable: never = reason - throw new ScheduleLogError(`unknown local calendar rejection ${String(unreachable)}`) + throw new Error(`unknown local calendar rejection ${String(unreachable)}`) } } } -/** 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 -} - /** Resolve a local one-shot, rejecting nonexistent wall-clock times. */ function resolveLocalAt(parts: LocalCalendarTime, timeZone: string): number { const target = resolveLocalInstant(parts, timeZone) @@ -392,7 +386,7 @@ function parseAtOffsetInstant(value: string): number { /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ default: { const unreachable: never = parsed.reason - throw new ScheduleLogError(`unknown instant rejection ${String(unreachable)}`) + throw new Error(`unknown instant rejection ${String(unreachable)}`) } } } @@ -818,7 +812,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() @@ -838,7 +832,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) @@ -986,7 +980,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() @@ -1040,7 +1034,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) { @@ -1048,7 +1042,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) diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index 3e2ceb2cb7..d9e8ac85ed 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -21,11 +21,11 @@ en: f97e654746aa3712 zh: 52ba385e9f3b96a9 /dsh-util-time/use-this-package/entry-point: - en: 657c118ff4d9b99b - zh: 2b11d97a40a2044f + en: 01ed3bdce21f0b71 + zh: 67e9fa556c3d25fd /dsh-util-time/understand-the-implementation: - en: a144232203c0c487 - zh: 5821b2adb564152c + en: a118f02fc58a14e5 + zh: ee5e0c0703f7eea2 /dsh-util-time/further-exploration: en: 8aa95ebdfd1f323e zh: c6879eac9fa6e087 diff --git a/packages/util/time/README.md b/packages/util/time/README.md index 422e896d8c..c491092c8c 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -47,7 +47,7 @@ if (local.kind === 'accepted') { } ``` -`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. Every other entry answers a discriminated result whose `rejected` arm carries a stable reason, so each caller again chooses its own code and diagnostic. +`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. Every entry that reads untrusted text answers a discriminated result whose `rejected` arm carries a stable reason, so each caller again chooses its own code and diagnostic. `formatFourDigitYearInstant` and `resolveLocalInstant` instead take already-validated values and answer a string or `undefined`. ----- @@ -57,7 +57,7 @@ if (local.kind === 'accepted') {
Implementation internals — click to expand -`src/instant.ts` holds the whole implementation; `src/index.ts` re-exports it and reduces `canonicalizeTimeZone` to 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 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. 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. diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index d11c62a4e1..57b83e9f59 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -47,7 +47,7 @@ if (local.kind === 'accepted') { } ``` -不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。其余每个入口都回答带判别值的结果,其 `rejected` 分支携带稳定的拒绝原因,同样由调用方选择自己的错误码与诊断文案。 +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。读取不可信文本的每个入口都回答带判别值的结果,其 `rejected` 分支携带稳定的拒绝原因,同样由调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 则接收已校验的值,直接回答字符串或 `undefined`。 ----- @@ -57,7 +57,7 @@ if (local.kind === 'accepted') {
实现内部细节 —— 点击展开 -`src/instant.ts` 承载全部实现;`src/index.ts` 转出它,并把 `canonicalizeTimeZone` 归约为协议边界已经在消费的 `string | undefined` 形式。该模块的 JSDoc 说明每个入口接受的输入剖面与全部拒绝原因。 +`src/instant.ts` 承载全部实现;`src/index.ts` 转出它,并新增 `canonicalClientTimeZone`——协议边界已经在消费的 `string | undefined` 形式。该模块的 JSDoc 说明每个入口接受的输入剖面与全部拒绝原因。 严格解析器先匹配固定正则,再构造任何日期,因此拼写错误、越界时钟字段与日历上不存在的日期分别回答不同的原因。本地解析交给 `@js-temporal/polyfill`:`disambiguation: 'earlier'` 取重叠时段中的第一个时刻,而本地字段往返比对对前向跳变缺口内的值回答 `undefined`。 diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts index 57c7b5d330..e81d831482 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -3,9 +3,11 @@ * wall-clock selector. * * Validation, canonicalization, and strict parsing only: this library formats - * no model-facing text and owns no failure vocabulary — every entry answers a - * discriminated result, and each boundary maps a reason onto its own domain - * code and diagnostic. + * no model-facing text and owns no failure vocabulary. An entry that reads + * untrusted text answers a discriminated result, and each caller maps a reason + * onto its own domain code and diagnostic; `formatFourDigitYearInstant` and + * `resolveLocalInstant` take already-validated values and answer their result + * directly. * @module @deepseek-ai/dsh-util-time */ diff --git a/packages/util/time/src/instant.ts b/packages/util/time/src/instant.ts index ff80c31ad4..a2e4de2d05 100644 --- a/packages/util/time/src/instant.ts +++ b/packages/util/time/src/instant.ts @@ -133,9 +133,9 @@ function plainDateTime(time: LocalCalendarTime): Temporal.PlainDateTime | undefi try { return Temporal.PlainDateTime.from(time, { overflow: 'reject' }) } catch (error: unknown) { - /* v8 ignore next -- every numeric field is pre-checked, so only a non-existent date reaches here. */ + /* v8 ignore next -- a rejected overflow answers a RangeError, so the false path is unreachable. */ if (error instanceof RangeError) return undefined - /* v8 ignore next -- Temporal throws only RangeError for rejected overflow. */ + /* v8 ignore next -- reached only when Temporal throws something other than a RangeError. */ throw error } } @@ -278,12 +278,15 @@ export function canonicalizeTimeZone(value: string): TimeZoneResult { * 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 whose fields are already in range. + * @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 `time` names an impossible calendar value. + * @throws RangeError when `timeZone` is not a known zone or the calendar value does not exist. */ -export function resolveLocalInstant(time: LocalCalendarTime, timeZone: string): number | undefined { +export function resolveLocalInstant( + time: LocalCalendarTime | Temporal.PlainDateTime, + timeZone: string, +): number | undefined { const local = 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/instant.spec.ts b/packages/util/time/tests/instant.spec.ts index c0196fe974..9403c9d5f8 100644 --- a/packages/util/time/tests/instant.spec.ts +++ b/packages/util/time/tests/instant.spec.ts @@ -1,3 +1,4 @@ +import { Temporal } from '@js-temporal/polyfill' import { describe, expect, it } from 'vitest' import { MAX_FOUR_DIGIT_YEAR_MS, @@ -242,4 +243,11 @@ describe('resolveLocalInstant', () => { 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() + }) }) From 92ee4e98f099674b91860f3758b1b88b21b1b2e1 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 00:41:31 +0800 Subject: [PATCH 10/26] docs(time): state each entry's return precisely - the README no longer claims resolveLocalInstant answers a string - the accepted-input table records the widened plain date-time input - canonicalClientTimeZone is named as the wire-boundary form rather than left outside a blanket claim about discriminated results - the non-canonical scheduledAt diagnostic has one source --- packages/schedule/schedule/src/domain.ts | 7 +++++-- packages/util/time/README.i18n.yaml | 8 ++++---- packages/util/time/README.md | 4 ++-- packages/util/time/README.zh.md | 4 ++-- packages/util/time/src/index.ts | 11 ++++++----- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 97046c6ad7..59e32e819c 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -251,10 +251,13 @@ 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') { - throw new ScheduleLogError('scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant') + throw new ScheduleLogError(NON_CANONICAL_INSTANT_MESSAGE) } const parsed = fourDigitYearEpoch(value) if (parsed.kind === 'rejected') throw new ScheduleLogError(instantDiagnostic(parsed.reason)) @@ -273,7 +276,7 @@ function instantDiagnostic(reason: InstantRejection): string { case 'not_rfc3339': case 'not_a_wall_clock_time': case 'invalid_numeric_offset': - return 'scheduledAt must be a canonical four-digit-year RFC 3339 UTC instant' + return NON_CANONICAL_INSTANT_MESSAGE /* v8 ignore next 3 -- the shared parser returns a closed rejection union. */ default: { const unreachable: never = reason diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index d9e8ac85ed..cd17b47167 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -21,11 +21,11 @@ en: f97e654746aa3712 zh: 52ba385e9f3b96a9 /dsh-util-time/use-this-package/entry-point: - en: 01ed3bdce21f0b71 - zh: 67e9fa556c3d25fd + en: e228864c11fc4cc7 + zh: 4da8068d3ec8e9b3 /dsh-util-time/understand-the-implementation: - en: a118f02fc58a14e5 - zh: ee5e0c0703f7eea2 + en: b4ff4a3e9574c97f + zh: 6ac32e07baec78e4 /dsh-util-time/further-exploration: en: 8aa95ebdfd1f323e zh: c6879eac9fa6e087 diff --git a/packages/util/time/README.md b/packages/util/time/README.md index c491092c8c..ba2983ee0b 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -47,7 +47,7 @@ if (local.kind === 'accepted') { } ``` -`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. Every entry that reads untrusted text answers a discriminated result whose `rejected` arm carries a stable reason, so each caller again chooses its own code and diagnostic. `formatFourDigitYearInstant` and `resolveLocalInstant` instead take already-validated values and answer a string or `undefined`. +`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` reports why it refused, as a discriminated result whose `rejected` arm carries a stable reason, so each caller chooses its own code and diagnostic; the strict parsers do the same. `canonicalClientTimeZone` is the wire-boundary form of the zone entry and answers the canonical name or `undefined`. `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. ----- @@ -69,7 +69,7 @@ The strict parsers match a fixed regular expression before constructing any date | `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. | -| `resolveLocalInstant` | Exact calendar fields and an already-canonical zone. | +| `resolveLocalInstant` | Exact calendar fields of a real date, or an already-built `Temporal.PlainDateTime`, plus an already-canonical zone. |
diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index 57b83e9f59..c28c8ae629 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -47,7 +47,7 @@ if (local.kind === 'accepted') { } ``` -不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。读取不可信文本的每个入口都回答带判别值的结果,其 `rejected` 分支携带稳定的拒绝原因,同样由调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 则接收已校验的值,直接回答字符串或 `undefined`。 +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone` 会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案;严格解析器同样如此。`canonicalClientTimeZone` 是时区入口在协议边界上的形式,回答规范名称或 `undefined`。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。 ----- @@ -69,7 +69,7 @@ if (local.kind === 'accepted') { | `parseOffsetInstant` | 带显式 `Z` 或数字偏移的 RFC 3339;偏移量属于输入的一部分。 | | `fourDigitYearEpoch` | 规范存储剖面:恰好 3 位小数秒与 `Z`。 | | `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 | -| `resolveLocalInstant` | 精确日历字段与已规范化的时区。 | +| `resolveLocalInstant` | 真实日期的精确日历字段,或已构造好的 `Temporal.PlainDateTime`,外加已规范化的时区。 |
diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts index e81d831482..712ccc81bc 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -3,11 +3,12 @@ * wall-clock selector. * * Validation, canonicalization, and strict parsing only: this library formats - * no model-facing text and owns no failure vocabulary. An entry that reads - * untrusted text answers a discriminated result, and each caller maps a reason - * onto its own domain code and diagnostic; `formatFourDigitYearInstant` and - * `resolveLocalInstant` take already-validated values and answer their result - * directly. + * 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. * @module @deepseek-ai/dsh-util-time */ From 7edd3efe85a4bca56d3e638c21721d81f5718ab6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 00:53:09 +0800 Subject: [PATCH 11/26] refactor(time): give the four-digit bound one owner - isFourDigitYearInstant is the shared predicate; Schedule's five representability guards delegate to it instead of re-deriving the range - the reason for a field outside its calendar range is out_of_range in both strict parsers, so year 0 no longer reads as a wall-clock problem - LocalCalendarTime.year states which entries reject year 0 and which accept it at the UTC edges - the note counts the five removed expressions and records that the zone mapper is a silent catch-all; the Chinese side mirrors the English paragraph order --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 8 ++--- .../2026-09-24-shared-time-vocabulary.md | 6 ++-- .../2026-09-24-shared-time-vocabulary.zh.md | 10 +++--- packages/schedule/schedule/src/domain.ts | 20 +++++------ packages/util/time/README.i18n.yaml | 4 +-- packages/util/time/README.md | 1 + packages/util/time/README.zh.md | 1 + packages/util/time/src/instant.ts | 35 ++++++++++++++----- packages/util/time/tests/instant.spec.ts | 20 ++++++++--- 9 files changed, 67 insertions(+), 38 deletions(-) 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 8154996dd4..1aeb7a8c7a 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: 1a837ec23f5d3fd5 - zh: d7985a6bfa119e18 + en: c210eaa5a6e384b0 + zh: 216fcad4fdb7c403 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: en: 5bf3cc0d112531e7 zh: 99ed3e958d9d8ccb /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 3b73743147ddab96 - zh: 245bd1bf275f5953 + en: 8e01e84e42601cd9 + zh: 96d102ce5b05445a 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 d2d6c01032..ffeb8f2405 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 @@ -12,7 +12,7 @@ English | [中文](2026-09-24-shared-time-vocabulary.zh.md) `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. -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`, `not_a_calendar_date`, `not_a_wall_clock_time`, `invalid_numeric_offset`, `malformed`, `out_of_range`, `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, and `resolveLocalInstant` answers epoch milliseconds or `undefined` for a wall-clock value inside a forward transition's gap. +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). 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. @@ -34,8 +34,8 @@ Observable behavior does not change. Every accepted input resolves to the same e ## 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 three regular expressions, the `Intl` canonicalization, the hand-rolled calendar conversion, and the four-digit bound arithmetic. +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 keeps the two bound constants for the 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 cannot: the library's `not_canonical_shape` reason is unreachable, so a switch would leave a branch the schedule suites can never execute. +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 cannot: the library's `not_canonical_shape` reason is unreachable on the current IANA data, so a switch would leave a branch the schedule suites can never execute. That mapper therefore folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic, and a reason added to `TimeZoneRejection` later would compile there and silently take that generic message. 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 27709825de..7fde2e5452 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 @@ -12,12 +12,12 @@ Status: implemented `dsh-util-time` 拥有各边界所接收的时间词汇。它位于 `packages/util/time/src/instant.ts`,`src/index.ts` 转出它,并保留 `canonicalClientTimeZone` 作为既有协议边界消费的 `string | undefined` 包装。 -本库回答带判别值的结果,不持有失败词汇。`parseOffsetInstant`、`fourDigitYearEpoch`、`parseLocalCalendar` 与 `canonicalizeTimeZone` 回答 `accepted` 分支,或回答携带稳定原因的 `rejected` 分支(`not_rfc3339`、`not_a_calendar_date`、`not_a_wall_clock_time`、`invalid_numeric_offset`、`malformed`、`out_of_range`、`blank`、`padded`、`not_area_location`、`unsupported`、`not_canonical_shape`)。每个调用方把原因映射成自己的错误码与诊断文案,因此本库从不撰写面向模型的文本。`formatFourDigitYearInstant` 返回规范时刻文本,越界时返回 `undefined`;`resolveLocalInstant` 回答纪元毫秒,对落在前向跳变缺口内的墙钟值回答 `undefined`。 - -`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。 +本库回答带判别值的结果,不持有失败词汇。`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 边界推导本地日期)。 Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`decodeInstant`、`parseAtInput` 与 `futureInstant` 仍是 Schedule 的入口,保留既有错误码与诊断文案;它们现在调用共享解析器并映射其原因。Schedule 仍拥有持久记录结构、折叠与状态转移权威、抗注入的框架文本,以及全部重复规则运算:cron 引擎、每日与每周日期遍历、最近到期搜索,以及 Schedule 特有的决策时刻边界。 +`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。 + 受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。 可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试只断言错误码、从不断言这些消息字符串,因此 `packages/schedule/schedule/tests/domain.spec.ts` 新增了对三种本地日历诊断文案的断言;没有它,本次抽取中的一处措辞回退能通过整套测试。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 @@ -34,8 +34,8 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec ## Consequences -时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了三段正则、`Intl` 规范化、手写日历换算与四位年份边界运算。 +时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它保留了用于 UTC 边界日期运算的两个边界常量,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。 -代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数做不到这一点:本库的 `not_canonical_shape` 原因不可达,用 switch 会留下一个 Schedule 测试永远无法执行的分支。 +代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数做不到这一点:在当前 IANA 数据下本库的 `not_canonical_shape` 原因不可达,用 switch 会留下一个 Schedule 测试永远无法执行的分支。因此该映射把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断,日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。 本次未触及、仍归 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/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 59e32e819c..9d48935dd6 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -10,6 +10,7 @@ import { canonicalizeTimeZone as canonicalizeSharedTimeZone, formatFourDigitYearInstant, fourDigitYearEpoch, + isFourDigitYearInstant, parseLocalCalendar, parseOffsetInstant, resolveLocalInstant, @@ -274,7 +275,7 @@ function instantDiagnostic(reason: InstantRejection): string { case 'not_a_calendar_date': return 'scheduledAt is not a real UTC calendar instant' case 'not_rfc3339': - case 'not_a_wall_clock_time': + 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. */ @@ -384,7 +385,7 @@ function parseAtOffsetInstant(value: string): number { case 'invalid_numeric_offset': throw new ScheduleInputError('invalid_rule', 'The at numeric offset is invalid.') case 'not_a_calendar_date': - case 'not_a_wall_clock_time': + 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: { @@ -755,9 +756,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) { @@ -774,7 +773,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({ @@ -844,8 +843,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 @@ -1368,7 +1366,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) @@ -1421,7 +1419,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) @@ -1455,7 +1453,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/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index cd17b47167..783391fab4 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -24,8 +24,8 @@ en: e228864c11fc4cc7 zh: 4da8068d3ec8e9b3 /dsh-util-time/understand-the-implementation: - en: b4ff4a3e9574c97f - zh: 6ac32e07baec78e4 + en: c814c83b60f43e0c + zh: 89021dc77356392b /dsh-util-time/further-exploration: en: 8aa95ebdfd1f323e zh: c6879eac9fa6e087 diff --git a/packages/util/time/README.md b/packages/util/time/README.md index ba2983ee0b..a9d1354bd3 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -69,6 +69,7 @@ The strict parsers match a fixed regular expression before constructing any date | `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. | diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index c28c8ae629..81feca9d13 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -69,6 +69,7 @@ if (local.kind === 'accepted') { | `parseOffsetInstant` | 带显式 `Z` 或数字偏移的 RFC 3339;偏移量属于输入的一部分。 | | `fourDigitYearEpoch` | 规范存储剖面:恰好 3 位小数秒与 `Z`。 | | `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 | +| `isFourDigitYearInstant` | 同一范围的谓词形式,只判断范围边界的调用方无需自行推导。 | | `resolveLocalInstant` | 真实日期的精确日历字段,或已构造好的 `Temporal.PlainDateTime`,外加已规范化的时区。 | diff --git a/packages/util/time/src/instant.ts b/packages/util/time/src/instant.ts index a2e4de2d05..4abc541f6d 100644 --- a/packages/util/time/src/instant.ts +++ b/packages/util/time/src/instant.ts @@ -4,12 +4,12 @@ * * 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. The two entries that take - * already-validated values throw on violated preconditions instead — - * `resolveLocalInstant` raises a `RangeError` for an unknown zone or an - * impossible calendar field, and `formatFourDigitYearInstant` cannot throw. - * Local wall-clock resolution uses the maintained `Temporal` implementation, so - * no caller owns offset sampling or DST rules. + * 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 */ @@ -44,7 +44,11 @@ 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 { - /** Four-digit year; year 0 is rejected. */ + /** + * 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 @@ -63,8 +67,9 @@ export interface LocalCalendarTime { /** 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' - | 'not_a_wall_clock_time' | 'invalid_numeric_offset' /** Why a local calendar selector is unusable. */ @@ -194,7 +199,7 @@ export function parseOffsetInstant(value: string): InstantResult { millisecond: fractionalMilliseconds(groups['fraction']), } if (parts.year === 0 || parts.hour > 23 || parts.minute > 59 || parts.second > 59) { - return rejected('not_a_wall_clock_time') + return rejected('out_of_range') } const local = plainDateTime(parts) if (local === undefined) return rejected('not_a_calendar_date') @@ -230,6 +235,18 @@ export function fourDigitYearEpoch(value: string): InstantResult { 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. * diff --git a/packages/util/time/tests/instant.spec.ts b/packages/util/time/tests/instant.spec.ts index 9403c9d5f8..1be860b3fc 100644 --- a/packages/util/time/tests/instant.spec.ts +++ b/packages/util/time/tests/instant.spec.ts @@ -6,6 +6,7 @@ import { canonicalizeTimeZone, formatFourDigitYearInstant, fourDigitYearEpoch, + isFourDigitYearInstant, parseLocalCalendar, parseOffsetInstant, resolveLocalInstant, @@ -100,19 +101,19 @@ describe('parseOffsetInstant', () => { 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: 'not_a_wall_clock_time', + reason: 'out_of_range', }) expect(parseOffsetInstant('2026-03-08T24:30:00Z')).toEqual({ kind: 'rejected', - reason: 'not_a_wall_clock_time', + reason: 'out_of_range', }) expect(parseOffsetInstant('2026-03-08T02:60:00Z')).toEqual({ kind: 'rejected', - reason: 'not_a_wall_clock_time', + reason: 'out_of_range', }) expect(parseOffsetInstant('2026-03-08T02:30:60Z')).toEqual({ kind: 'rejected', - reason: 'not_a_wall_clock_time', + reason: 'out_of_range', }) expect(parseOffsetInstant('2026-02-30T02:30:00Z')).toEqual({ kind: 'rejected', @@ -176,6 +177,17 @@ describe('fourDigitYearEpoch', () => { }) }) +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') From bbdac8febeabbbe0641351b017138e9d707d463a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 01:01:26 +0800 Subject: [PATCH 12/26] docs(time): state the module contract and the zone mapper as they are - the constants name one bound each - resolveLocalInstant reuses an instance a caller already holds instead of copying it per candidate date - the README entry-point paragraph drops its duplicate sentence - the note replaces an invalid reason for the zone mapper and a review-vantage sentence with the facts --- .../2026-09-24-shared-time-vocabulary.i18n.yaml | 8 ++++---- .../architecture/2026-09-24-shared-time-vocabulary.md | 4 ++-- .../architecture/2026-09-24-shared-time-vocabulary.zh.md | 4 ++-- packages/util/time/README.i18n.yaml | 4 ++-- packages/util/time/README.md | 2 +- packages/util/time/README.zh.md | 2 +- packages/util/time/src/instant.ts | 9 ++++++--- 7 files changed, 18 insertions(+), 15 deletions(-) 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 1aeb7a8c7a..43250c42a4 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: c210eaa5a6e384b0 - zh: 216fcad4fdb7c403 + en: d97690dac210f970 + zh: b78439b9f402b278 /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: en: 5bf3cc0d112531e7 zh: 99ed3e958d9d8ccb /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 8e01e84e42601cd9 - zh: 96d102ce5b05445a + en: 22e0e257ba361a33 + zh: 599ad36dccf8bd64 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 ffeb8f2405..3654d58a15 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 @@ -20,7 +20,7 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d 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. -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. The schedule suites assert error codes and never these message strings, so `packages/schedule/schedule/tests/domain.spec.ts` gains an assertion on the three local-calendar diagnostics; without it a wording regression in the extraction passed the whole suite. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included. +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 other suites assert error codes only, 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 @@ -36,6 +36,6 @@ Observable behavior does not change. Every accepted input resolves to the same e 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 keeps the two bound constants for the 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 cannot: the library's `not_canonical_shape` reason is unreachable on the current IANA data, so a switch would leave a branch the schedule suites can never execute. That mapper therefore folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic, and a reason added to `TimeZoneRejection` later would compile there and silently take that generic message. +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. 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 7fde2e5452..5e9f324332 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 @@ -20,7 +20,7 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。 -可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试只断言错误码、从不断言这些消息字符串,因此 `packages/schedule/schedule/tests/domain.spec.ts` 新增了对三种本地日历诊断文案的断言;没有它,本次抽取中的一处措辞回退能通过整套测试。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 +可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 其余测试只断言错误码,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 ## Alternatives considered @@ -36,6 +36,6 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它保留了用于 UTC 边界日期运算的两个边界常量,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。 -代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数做不到这一点:在当前 IANA 数据下本库的 `not_canonical_shape` 原因不可达,用 switch 会留下一个 Schedule 测试永远无法执行的分支。因此该映射把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断,日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。 +代价是更宽的已发布接口与更重的依赖图:`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` 新增原因时会通过编译并静默套用那条通用文案。 本次未触及、仍归 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 783391fab4..5bbae49730 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -21,8 +21,8 @@ en: f97e654746aa3712 zh: 52ba385e9f3b96a9 /dsh-util-time/use-this-package/entry-point: - en: e228864c11fc4cc7 - zh: 4da8068d3ec8e9b3 + en: b5b79b2f1617b788 + zh: 72606179d6a7c33c /dsh-util-time/understand-the-implementation: en: c814c83b60f43e0c zh: 89021dc77356392b diff --git a/packages/util/time/README.md b/packages/util/time/README.md index a9d1354bd3..cad20e46c2 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -47,7 +47,7 @@ if (local.kind === 'accepted') { } ``` -`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` reports why it refused, as a discriminated result whose `rejected` arm carries a stable reason, so each caller chooses its own code and diagnostic; the strict parsers do the same. `canonicalClientTimeZone` is the wire-boundary form of the zone entry and answers the canonical name or `undefined`. `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. +`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. ----- diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index 81feca9d13..c1537dcbab 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -47,7 +47,7 @@ if (local.kind === 'accepted') { } ``` -不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone` 会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案;严格解析器同样如此。`canonicalClientTimeZone` 是时区入口在协议边界上的形式,回答规范名称或 `undefined`。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。 +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。 ----- diff --git a/packages/util/time/src/instant.ts b/packages/util/time/src/instant.ts index 4abc541f6d..60aac90ffb 100644 --- a/packages/util/time/src/instant.ts +++ b/packages/util/time/src/instant.ts @@ -15,10 +15,10 @@ import { Temporal } from '@js-temporal/polyfill' -/** Inclusive epoch-millisecond bounds of a four-digit-year RFC 3339 UTC instant. */ +/** 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 epoch-millisecond bounds of a four-digit-year RFC 3339 UTC instant. */ +/** 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. */ @@ -304,7 +304,10 @@ export function resolveLocalInstant( time: LocalCalendarTime | Temporal.PlainDateTime, timeZone: string, ): number | undefined { - const local = Temporal.PlainDateTime.from(time, { overflow: 'reject' }) + // 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 } From 0939392e71ab7901edaaf5fc81ee7a1d665d77e5 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 14:48:01 +0800 Subject: [PATCH 13/26] feat(util-time): add a millisecond Duration span Duration holds one finite millisecond count behind a frozen class: a class rather than a branded number because TypeScript erases a brand through arithmetic, so only an instance can stop seconds and milliseconds from being added. It adds, subtracts, orders, converts to larger units fractionally, and splits a non-negative span into whole days, hours, minutes, and seconds. There is no valueOf and no toString, so a reader always states its unit. dsh-time-context reads its elapsed line through it and keeps the wording: a backward clock reading still clamps to zero there. The rendered text is byte-identical for every finite sample, verified by a differential over more than eight thousand inputs. A non-finite sample now throws from the span constructor instead of rendering 'NaNs', which the package's own reading invariant already refused. --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 12 +- .../2026-09-24-shared-time-vocabulary.md | 8 + .../2026-09-24-shared-time-vocabulary.zh.md | 8 + packages/context/time-context/src/index.ts | 19 +- packages/util/README.i18n.yaml | 6 +- packages/util/README.md | 4 +- packages/util/README.zh.md | 2 +- packages/util/time/README.i18n.yaml | 24 +-- packages/util/time/README.md | 18 +- packages/util/time/README.zh.md | 18 +- packages/util/time/package.json | 2 +- packages/util/time/src/duration.ts | 184 ++++++++++++++++++ packages/util/time/src/index.ts | 6 +- packages/util/time/tests/duration.spec.ts | 102 ++++++++++ 14 files changed, 366 insertions(+), 47 deletions(-) create mode 100644 packages/util/time/src/duration.ts create mode 100644 packages/util/time/tests/duration.spec.ts 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 43250c42a4..31c914f4ff 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: d97690dac210f970 - zh: b78439b9f402b278 + en: 34feee585cb33001 + zh: d882bbba56a679bd /agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered: - en: 5bf3cc0d112531e7 - zh: 99ed3e958d9d8ccb + en: 1737484c099fab52 + zh: 303b144c52d167bd /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 22e0e257ba361a33 - zh: 599ad36dccf8bd64 + en: 7e3004318640cca7 + zh: 007d7fe761718d37 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 3654d58a15..46dfbe6504 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 @@ -18,6 +18,8 @@ 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. + 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. 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 other suites assert error codes only, 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. @@ -30,6 +32,10 @@ Observable behavior does not change. Every accepted input resolves to the same e **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 @@ -38,4 +44,6 @@ 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. + 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 5e9f324332..8ff750c478 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 @@ -18,6 +18,8 @@ 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()` 就是全部操作集合。 + 受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。 可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 其余测试只断言错误码,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 @@ -30,6 +32,10 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec **让本库回答 `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 @@ -38,4 +44,6 @@ 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`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。 + 本次未触及、仍归 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/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 493630a811..55eb5779ad 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, @@ -66,15 +67,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/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 70526d2d7e..c63d668ebb 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -9,14 +9,14 @@ en: 7283f2afb343d150 zh: 32c56bd67c0314c6 /util-shared-utilities/summary: - en: 2424d44f0d79be74 + en: f21c74532a9c929e zh: 5bc05166e8d83c1e /util-shared-utilities/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 /util-shared-utilities/packages: - en: 662b2baeac105692 - zh: f076c6d39c2985b1 + en: 44eb08c6c7a5bbfb + zh: 9857e5374e5b14de /util-shared-utilities/related-documentation: en: 432ae81110914571 zh: c5bf4efc007311f3 diff --git a/packages/util/README.md b/packages/util/README.md index 7184f8f595..022eb85ec7 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -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 and instant 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. +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 @@ -41,7 +41,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) | Parses strict instants, local calendars, and caller-reported IANA zones | +| [`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 33253cc139..a31d113d30 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -41,7 +41,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 5bbae49730..c0d042b47a 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/time/README.md /: - en: 78b8a5dd36b637f5 - zh: 451e95d5bc7d5a66 + en: 6daf8db648646a31 + zh: 156a7d47413a6a55 /dsh-util-time: en: bc299a9b8fe7717f zh: 896661ee0c4edf25 /dsh-util-time/summary: - en: 6d4d39093c2a2b5a - zh: 1dc4af01352581f2 + en: cdc849b4f44094b1 + zh: b03edaa6a020d39d /dsh-util-time/table-of-contents: en: d152484eb41ac6b4 zh: f14f34fce35a8d90 @@ -18,14 +18,14 @@ en: bbff250eba0f1b89 zh: 0a618f05cc88ee2b /dsh-util-time/use-this-package/when-to-use-it: - en: f97e654746aa3712 - zh: 52ba385e9f3b96a9 + en: 7015b3ed33de4c53 + zh: d4fc9aee6f6910ca /dsh-util-time/use-this-package/entry-point: - en: b5b79b2f1617b788 - zh: 72606179d6a7c33c + en: 306f0f5d65af8223 + zh: a79b60c39be361c8 /dsh-util-time/understand-the-implementation: - en: c814c83b60f43e0c - zh: 89021dc77356392b + en: f42cb0fc332d7740 + zh: f980bcae2f72fdd8 /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: 8b29ec4add8f93e6 - zh: ee9bfd6224a4204c + en: b4108682b9b18245 + zh: 7dfb014c2dc1775f /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 cad20e46c2..7bb1e068ae 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -1,5 +1,5 @@ --- -description: "Strict RFC 3339 instant, local-calendar, and IANA zone parsing for maintainers accepting a caller-reported zone or a wall-clock selector at a 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,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Shared time vocabulary for the boundaries that accept a caller's zone or a wall-clock selector. `canonicalClientTimeZone` admits `UTC` or an IANA `Area/Location` name and answers the platform-canonical spelling, so an alias never reaches a durable record. A wall-clock consumer instead parses strict RFC 3339 instants, local calendar fields, and zone names through discriminated results, then resolves a local value with maintained `Temporal` DST and overlap behavior. The library owns no failure vocabulary or model-facing text: each caller maps a reason onto 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 @@ -29,14 +29,14 @@ Shared time vocabulary for the boundaries that accept a caller's zone or a wall- 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 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`. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value. +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. No runtime invariant companion is published because this pure utility owns no event stream or mutable runtime data; unit tests verify every entry. ### Entry point ```ts -import { canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time' +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 @@ -45,9 +45,12 @@ const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accept if (local.kind === 'accepted') { resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap } + +Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds: 30, milliseconds: 0 } +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. +`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. ----- @@ -71,6 +74,7 @@ The strict parsers match a fixed regular expression before constructing any date | `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, built from milliseconds or seconds, and never a bare count on read. | @@ -100,7 +104,9 @@ 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, and canonical formatting only; a caller that walks candidate dates or intervals owns that search. +- **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. - **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 c1537dcbab..acf2074ffc 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -1,5 +1,5 @@ --- -description: "面向在协议边界接收调用方所报时区或墙钟选择器的维护者,说明严格的 RFC 3339 时刻、本地日历与时区解析。" +description: "面向在协议边界接收调用方所报时区、墙钟选择器或耗时的维护者,说明严格的 RFC 3339 时刻、本地日历与时区解析,以及毫秒精度的时间段类型。" kind: "package-library" --- @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -共享时间词汇,供接收调用方时区或墙钟选择器的协议边界使用。`canonicalClientTimeZone` 只接受 `UTC` 或 IANA `Area/Location` 名称,并回答当前平台上的规范拼写,因此别名不会进入持久记录。需要墙钟语义的消费方则通过带判别值的结果解析严格的 RFC 3339 时刻、本地日历字段与时区名称,再借助受维护的 `Temporal` 解析本地值,获得 DST 与重叠时段的既定行为。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。 +共享时间词汇,供接收调用方时区、墙钟选择器或耗时的边界使用。`canonicalClientTimeZone` 回答当前平台上的规范 `UTC` 或 IANA `Area/Location` 拼写,因此别名不会进入持久记录。严格解析器以带判别值的结果读取 RFC 3339 时刻、本地日历字段与时区名称,`resolveLocalInstant` 则套用受维护的 `Temporal` 的 DST 与重叠行为。`Duration` 以毫秒保存一段时间并拆成整数量。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。 ## 目录 @@ -29,14 +29,14 @@ kind: "package-library" 它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。 -在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。 +在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。`dsh-time-context` 通过 `Duration` 读取其耗时读数,并保留它注入那一行的措辞。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。 不发布运行时不变量伴生入口;这个纯工具不拥有事件流或可变运行时数据,每个入口都由单元测试验证。 ### 入口 ```ts -import { canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time' +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 @@ -45,9 +45,12 @@ const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accept if (local.kind === 'accepted') { resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap } + +Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds: 30, milliseconds: 0 } +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`。 +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限,加减与排序都不做单位换算,按小数换算到秒、分、小时与天,并把非负的时间段拆成整数量。 ----- @@ -71,6 +74,7 @@ if (local.kind === 'accepted') { | `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 | | `isFourDigitYearInstant` | 同一范围的谓词形式,只判断范围边界的调用方无需自行推导。 | | `resolveLocalInstant` | 真实日期的精确日历字段,或已构造好的 `Temporal.PlainDateTime`,外加已规范化的时区。 | +| `Duration` | 一个有限的毫秒计数,可由毫秒或秒构造,读取时从不交出裸计数。 | @@ -100,7 +104,9 @@ if (local.kind === 'accepted') { - **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 -- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析与规范格式化;遍历候选日期或间隔的搜索由调用方自己拥有。 +- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。 +- **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。 +- **`Duration` 不做隐式单位换算**——它没有 `valueOf` 也没有 `toString`,读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。 - **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。 diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 70e234df7f..b18fc42f3e 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-util-time", - "description": "Time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization), and strict RFC 3339 instant, local-calendar, and zone resolution for wall-clock selectors", + "description": "Time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization), strict RFC 3339 instant, local-calendar, and zone resolution for wall-clock selectors, and a millisecond Duration span", "version": "0.1.7-rc.2", "publishConfig": { "access": "public" diff --git a/packages/util/time/src/duration.ts b/packages/util/time/src/duration.ts new file mode 100644 index 0000000000..727d6743fd --- /dev/null +++ b/packages/util/time/src/duration.ts @@ -0,0 +1,184 @@ +/** + * One span of time, held as a count of milliseconds. + * + * 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. There is no `valueOf` + * and no `toString`, so no reader ever receives a bare count whose unit it has + * to remember; a caller states the unit by reading `milliseconds` or by + * converting through one of the `to*` entries. + * @module @deepseek-ai/dsh-util-time + */ + +const MS_PER_SECOND = 1_000 +const MS_PER_MINUTE = 60 * MS_PER_SECOND +const MS_PER_HOUR = 60 * MS_PER_MINUTE +const MS_PER_DAY = 24 * MS_PER_HOUR +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 +} + +/** + * 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. + */ +export class Duration { + /** The additive identity. */ + static readonly zero: Duration = new Duration(0) + + private readonly span: number + + private constructor(milliseconds: number) { + this.span = milliseconds + Object.freeze(this) + } + + /** + * Build one span from a millisecond count. + * @param milliseconds - Finite millisecond count; a fractional count is kept as given. + * @returns The span. + * @throws TypeError when the count is not a finite number. + */ + static fromMilliseconds(milliseconds: number): Duration { + if (!Number.isFinite(milliseconds)) { + throw new TypeError(`duration milliseconds must be finite, got ${String(milliseconds)}`) + } + return new Duration(milliseconds) + } + + /** + * Build one span from a second count, for a boundary whose unit is seconds. + * @param seconds - Finite second count; a fractional count is kept as given. + * @returns The span. + * @throws TypeError when the count is not finite, or its millisecond equivalent is not. + */ + static fromSeconds(seconds: number): Duration { + if (!Number.isFinite(seconds)) { + throw new TypeError(`duration seconds must be finite, got ${String(seconds)}`) + } + return Duration.fromMilliseconds(seconds * MS_PER_SECOND) + } + + /** The span as a millisecond count; the unit is part of the name. */ + get milliseconds(): number { + return this.span + } + + /** + * Add one span to this span. + * @param other - Span to add; both operands already share the millisecond unit. + * @returns The sum. + */ + plus(other: Duration): Duration { + return new Duration(this.span + other.span) + } + + /** + * Subtract one span from this span. + * @param other - Span to subtract. + * @returns The difference, which is negative when `other` is longer. + */ + minus(other: Duration): Duration { + return new Duration(this.span - other.span) + } + + /** + * Order two spans. + * @param other - Span to compare with. + * @returns `-1` when this span is shorter, `0` when equal, `1` when longer. + */ + compare(other: Duration): -1 | 0 | 1 { + if (this.span < other.span) return -1 + return this.span > other.span ? 1 : 0 + } + + /** + * Whether the span is a negative interval, which every sign-sensitive reader + * must decide how to handle. + * @returns Whether the millisecond count is below zero. + */ + isNegative(): boolean { + return this.span < 0 + } + + /** + * The span in seconds. + * @returns The span as a fractional second count, lossless in both directions. + */ + toSeconds(): number { + return this.span / MS_PER_SECOND + } + + /** + * The span in minutes. + * @returns The span as a fractional minute count, lossless in both directions. + */ + toMinutes(): number { + return this.span / MS_PER_MINUTE + } + + /** + * The span in hours. + * @returns The span as a fractional hour count, lossless in both directions. + */ + toHours(): number { + return this.span / MS_PER_HOUR + } + + /** + * The span in days. + * + * 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. + */ + toDays(): number { + return this.span / MS_PER_DAY + } + + /** + * 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. + * @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 712ccc81bc..bbbd316134 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -1,6 +1,6 @@ /** * Time vocabulary shared by the boundaries that accept a caller's zone or a - * wall-clock selector. + * wall-clock selector, plus one span type for elapsed and interval arithmetic. * * Validation, canonicalization, and strict parsing only: this library formats * no model-facing text and owns no failure vocabulary. A refusal that carries a @@ -8,12 +8,14 @@ * 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. + * epoch milliseconds directly; `Duration` holds a span in milliseconds with no + * implicit numeric conversion. * @module @deepseek-ai/dsh-util-time */ import { canonicalizeTimeZone } from './instant.ts' +export * from './duration.ts' export * from './instant.ts' /** diff --git a/packages/util/time/tests/duration.spec.ts b/packages/util/time/tests/duration.spec.ts new file mode 100644 index 0000000000..866e1e1669 --- /dev/null +++ b/packages/util/time/tests/duration.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest' +import { Duration } from '@deepseek-ai/dsh-util-time' + +describe('Duration construction', () => { + it('holds the millisecond count it was built from, fraction included', () => { + expect(Duration.fromMilliseconds(1_500).milliseconds).toBe(1_500) + expect(Duration.fromMilliseconds(0.5).milliseconds).toBe(0.5) + expect(Duration.zero.milliseconds).toBe(0) + }) + + it('converts a second count at the boundary that carries seconds', () => { + expect(Duration.fromSeconds(300).milliseconds).toBe(300_000) + expect(Duration.fromSeconds(0.25).milliseconds).toBe(250) + expect(Duration.fromSeconds(-60).milliseconds).toBe(-60_000) + }) + + 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) + expect(() => Duration.fromSeconds(value)).toThrow(TypeError) + } + }) + + it('refuses a finite second count whose millisecond equivalent leaves the finite range', () => { + expect(() => Duration.fromSeconds(1e306)).toThrow(TypeError) + }) + + it('is frozen, and carries no implicit numeric 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) + // Without a `valueOf` or `toString` override, a coercion never answers the count. + expect(Number(span)).toBe(Number.NaN) + expect(String(span)).toBe('[object Object]') + }) +}) + +describe('Duration arithmetic', () => { + it('adds and subtracts in the one shared unit', () => { + expect(Duration.fromSeconds(2).plus(Duration.fromMilliseconds(500)).milliseconds).toBe(2_500) + expect(Duration.fromSeconds(2).minus(Duration.fromMilliseconds(500)).milliseconds).toBe(1_500) + expect(Duration.fromMilliseconds(1).minus(Duration.fromSeconds(1)).isNegative()).toBe(true) + expect(Duration.zero.plus(Duration.zero).milliseconds).toBe(0) + }) + + it('orders spans without surrendering the unit', () => { + const minute = Duration.fromSeconds(60) + const hour = Duration.fromSeconds(3_600) + expect(minute.compare(hour)).toBe(-1) + expect(hour.compare(minute)).toBe(1) + expect(minute.compare(Duration.fromMilliseconds(60_000))).toBe(0) + expect(minute.isNegative()).toBe(false) + expect(Duration.fromMilliseconds(-1).isNegative()).toBe(true) + }) +}) + +describe('Duration conversion', () => { + it('converts to larger units fractionally, so nothing is lost either way', () => { + 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) + }) + + it('covers a day as exactly twenty-four hours', () => { + expect(Duration.fromMilliseconds(86_400_000).toDays()).toBe(1) + expect(Duration.fromMilliseconds(-86_400_000).toDays()).toBe(-1) + }) +}) + +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(90_061_500).decompose()) + .toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500 }) + expect(Duration.fromMilliseconds(86_400_000).decompose()) + .toEqual({ days: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }) + expect(Duration.fromMilliseconds(90_061_500.75).decompose()) + .toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500.75 }) + }) + + it('answers frozen parts whose sum never exceeds the span', () => { + const parts = Duration.fromMilliseconds(3_661_999).decompose() + expect(Object.isFrozen(parts)).toBe(true) + 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('refuses a negative span instead of inventing a sign policy', () => { + expect(() => Duration.fromMilliseconds(-1).decompose()).toThrow(RangeError) + }) +}) From 994b9e07bffa64425ae29109bd33a346000d754f Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 14:55:58 +0800 Subject: [PATCH 14/26] fix: satisfy the lint and generated-catalog gates - the immutability assertion checked coercion overrides instead of stringifying the instance, which no-base-to-string rejects - the config catalog is regenerated for the package description --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- packages/util/time/tests/duration.spec.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7978a4afaf..da3bd03763 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3380,7 +3380,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:57`](../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 12383e538d..ddf0cb6456 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3382,7 +3382,7 @@ export type ShellDialect = 'bash' | 'pwsh' ## `@deepseek-ai/dsh-time-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/time-context/src/index.ts:56`](../packages/context/time-context/src/index.ts) +- `source`: [`packages/context/time-context/src/index.ts:57`](../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/packages/util/time/tests/duration.spec.ts b/packages/util/time/tests/duration.spec.ts index 866e1e1669..1a90124337 100644 --- a/packages/util/time/tests/duration.spec.ts +++ b/packages/util/time/tests/duration.spec.ts @@ -25,14 +25,14 @@ describe('Duration construction', () => { expect(() => Duration.fromSeconds(1e306)).toThrow(TypeError) }) - it('is frozen, and carries no implicit numeric conversion', () => { + 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) - // Without a `valueOf` or `toString` override, a coercion never answers the count. - expect(Number(span)).toBe(Number.NaN) - expect(String(span)).toBe('[object Object]') + // Neither coercion 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) }) }) From 0b5803cf0c03f6afac41a2d598a5f56f5a548af9 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 15:14:50 +0800 Subject: [PATCH 15/26] fix(util-time): bound a span, and state the conversions as they are - Duration rejects a count outside the exactly representable millisecond range, which is what makes decompose's whole-part identity exact; the postcondition is asserted over a sweep and a random sample - the to* JSDoc no longer claims a round trip restores the count: each is one division, so it answers the nearest double - a test rules out a Symbol.toPrimitive coercion hook, the README names both implementation modules, and the note records that the value type ships its full operation set before its second consumer exists --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 8 ++-- .../2026-09-24-shared-time-vocabulary.md | 8 ++-- .../2026-09-24-shared-time-vocabulary.zh.md | 8 ++-- packages/util/time/README.i18n.yaml | 12 +++--- packages/util/time/README.md | 7 ++-- packages/util/time/README.zh.md | 7 ++-- packages/util/time/src/duration.ts | 39 +++++++++++++------ packages/util/time/tests/duration.spec.ts | 37 ++++++++++++++++-- 8 files changed, 90 insertions(+), 36 deletions(-) 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', () => { From 830f66006e54a7495dac6b731aff234097778422 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 25 Sep 2026 16:33:11 +0800 Subject: [PATCH 16/26] fix(util-time): enforce the span bound in arithmetic, and correct the record - plus and minus construct through fromMilliseconds, so the documented bound has one enforcement point; the negative control reddens the new assertion - the class and README say Duration overrides no coercion hook rather than having none, and decompose says which parts are floored - the note no longer calls the differential a review probe, records that Schedule imports the bound constants, that both strict parsers refuse year 0, that Schedule's suites do assert a few message substrings, and that the util charter bans harness dependencies rather than external packages --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 8 +++---- .../2026-09-24-shared-time-vocabulary.md | 12 +++++----- .../2026-09-24-shared-time-vocabulary.zh.md | 12 +++++----- packages/util/README.i18n.yaml | 2 +- packages/util/README.zh.md | 2 +- packages/util/time/README.i18n.yaml | 4 ++-- packages/util/time/README.md | 2 +- packages/util/time/README.zh.md | 2 +- packages/util/time/package.json | 2 +- packages/util/time/src/duration.ts | 23 +++++++++++-------- packages/util/time/tests/duration.spec.ts | 8 +++++++ 11 files changed, 44 insertions(+), 33 deletions(-) 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 dcfe0eeb4e..0c6bd7165b 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: 4130cbbd30e06857 - zh: 85f9153cf5ad44ce + en: a573289069f0505c + zh: c4a125cbb0a7bcd4 /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: f7181f87764118d2 - zh: 0af236ff4b6a935b + en: 99a03606d21acf8d + zh: f7b56ff5f7a61a76 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 d9fc530938..31d3f31927 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 @@ -12,7 +12,7 @@ English | [中文](2026-09-24-shared-time-vocabulary.zh.md) `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). +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. @@ -20,9 +20,9 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d `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. +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 the group and root package maps keep the phrase while this package's own description names 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 other suites assert error codes only, 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. +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 @@ -40,12 +40,12 @@ Observable behavior does not change. Every accepted input resolves to the same e ## 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 keeps the two bound constants for the date arithmetic at the UTC edges and delegates every representability guard to `isFourDigitYearInstant`, so the range itself has one owner. +The time profile now has one home. A change to the accepted instant profile or the zone profile is made once and covered once, and a new boundary imports it instead of copying it. Schedule's `domain.ts` loses the five regular expressions that spelled the instant, calendar-date, local-time, and zone profiles, the `Intl` canonicalization, and the hand-rolled calendar conversion. It still imports the library's two bound constants for its date arithmetic at the UTC edges and delegates every representability guard to `isFourDigitYearInstant`, so the range itself has one owner. The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message. -The 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 value type ships its whole operation set before its second consumer exists. Only `fromMilliseconds` and `decompose()` have a production caller, `dsh-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. +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). 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 ff8368f558..687cd858b6 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 @@ -12,7 +12,7 @@ Status: implemented `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 边界推导本地日期)。 +本库回答带判别值的结果,不持有失败词汇。`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 特有的决策时刻边界。 @@ -20,9 +20,9 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec `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` 表述。 +受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此组与根包映射保留该说法,而本包自己的描述写明它加载了什么。 -可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 其余测试只断言错误码,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 +可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 的测试断言错误码以及少量无关的消息子串,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。 ## Alternatives considered @@ -40,12 +40,12 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec ## Consequences -时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。Schedule 的 `domain.ts` 去掉了为时刻、日历日期、本地时间与时区剖面命名的五段正则、`Intl` 规范化与手写日历换算。它保留了用于 UTC 边界日期运算的两个边界常量,并把每处可表示性守卫委托给 `isFourDigitYearInstant`,因此范围本身只有一个所有者。 +时间剖面现在只有一个归宿。时刻剖面或时区剖面的变更只做一次、只覆盖一次,新边界导入它而不是复制它。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` 新增原因时会通过编译并静默套用那条通用文案。 -该值类型在第二个消费方出现之前就交付了完整操作集合。目前生产代码只调用 `fromMilliseconds` 与 `decompose()`(由 time-context 调用);`fromSeconds`、`milliseconds`、`plus`、`minus`、`compare`、`isNegative`、`zero` 与四个 `to*` 换算都没有调用方。这是有意的选择而不是疏漏:`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段是预期的下一批消费方,而另一种做法——一个能保存时间段却只能拆解它的类——会在每个调用点出现时再长出同样的成员。包 README 把这些尚未迁移的边界记为已知限制。 +该值类型在第二个消费方出现之前就交付了完整操作集合。目前只有 `fromMilliseconds` 与 `decompose()` 有生产调用方,即 `dsh-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`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。 +耗时读数的拆分上收到库、措辞留在一侧:`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/README.i18n.yaml b/packages/util/README.i18n.yaml index c63d668ebb..38356f27bc 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/README.i18n.yaml @@ -10,7 +10,7 @@ zh: 32c56bd67c0314c6 /util-shared-utilities/summary: en: f21c74532a9c929e - zh: 5bc05166e8d83c1e + zh: f34c908f47f4a58a /util-shared-utilities/table-of-contents: en: 1eb35b015a6c423f zh: a8a3dceee2c9e943 diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index a31d113d30..40b04df6d8 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、无损 JSON 值、UUID、Harness home 路径、启动环境、出站代理策略、原生命令、输出保留、时间与时区词汇,以及超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 +`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、无损 JSON 值、UUID、Harness home 路径、启动环境、出站代理策略、原生命令、输出保留、时区、时刻与时间段词汇,以及超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 ## 目录 diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index 8559bd7e18..c9519add50 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -36,8 +36,8 @@ en: daf6e45a18c087b0 zh: e6714f1e65ed55ce /dsh-util-time/known-limitations-and-deferred-work: - en: 20e184351878f41b - zh: 1c94d8a715df7c1b + en: c045ad46a696321a + zh: 648cd96b1bec5fff /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 4f1d2ee9ea..6fc568b106 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -106,7 +106,7 @@ 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`, no `toString`, and no `Symbol.toPrimitive`, so a reader states its unit through `milliseconds` or a `to*` conversion. +- **`Duration` does not convert units implicitly** — it overrides no coercion hook (`valueOf`, `toString`, `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 dcf29ed9ee..53c408b332 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -106,7 +106,7 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and - **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 - **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。 - **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。 -- **`Duration` 不做隐式单位换算**——它没有 `valueOf`、`toString` 与 `Symbol.toPrimitive`,读取方必须通过 `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/package.json b/packages/util/time/package.json index b18fc42f3e..4bf0fdf4ae 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-util-time", - "description": "Time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization), strict RFC 3339 instant, local-calendar, and zone resolution for wall-clock selectors, and a millisecond Duration span", + "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.1.7-rc.2", "publishConfig": { "access": "public" diff --git a/packages/util/time/src/duration.ts b/packages/util/time/src/duration.ts index 172e9948fd..55a221e6b5 100644 --- a/packages/util/time/src/duration.ts +++ b/packages/util/time/src/duration.ts @@ -3,10 +3,10 @@ * * 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. There is no `valueOf` - * and no `toString`, so no reader ever receives a bare count whose unit it has - * to remember; a caller states the unit by reading `milliseconds` or by - * converting through one of the `to*` entries. + * milliseconds from being added, while an instance can. No coercion hook is + * overridden, so no reader receives a bare count whose unit it has to remember; + * a caller states the unit by reading `milliseconds` or by converting through + * one of the `to*` entries. * @module @deepseek-ai/dsh-util-time */ @@ -83,7 +83,7 @@ export class Duration { * Build one span from a second count, for a boundary whose unit is seconds. * @param seconds - Finite second count; a fractional count is kept as given. * @returns The span. - * @throws TypeError when the count is not finite, or its millisecond equivalent is not. + * @throws TypeError when the count is not finite, or its millisecond equivalent is not finite or leaves the exactly representable range. */ static fromSeconds(seconds: number): Duration { if (!Number.isFinite(seconds)) { @@ -101,18 +101,20 @@ export class Duration { * Add one span to this span. * @param other - Span to add; both operands already share the millisecond unit. * @returns The sum. + * @throws TypeError when the sum leaves the exactly representable millisecond range. */ plus(other: Duration): Duration { - return new Duration(this.span + other.span) + return Duration.fromMilliseconds(this.span + other.span) } /** * Subtract one span from this span. * @param other - Span to subtract. * @returns The difference, which is negative when `other` is longer. + * @throws TypeError when the difference leaves the exactly representable millisecond range. */ minus(other: Duration): Duration { - return new Duration(this.span - other.span) + return Duration.fromMilliseconds(this.span - other.span) } /** @@ -172,9 +174,10 @@ export class Duration { /** * Split the span into whole days, hours, minutes, seconds, and the remaining - * 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. + * 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. diff --git a/packages/util/time/tests/duration.spec.ts b/packages/util/time/tests/duration.spec.ts index f2b8de4a8b..8edd4f1ead 100644 --- a/packages/util/time/tests/duration.spec.ts +++ b/packages/util/time/tests/duration.spec.ts @@ -55,6 +55,14 @@ describe('Duration arithmetic', () => { expect(Duration.zero.plus(Duration.zero).milliseconds).toBe(0) }) + it('refuses a sum or difference that leaves the exactly representable range', () => { + const max = Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER) + expect(() => max.plus(Duration.fromMilliseconds(1))).toThrow(TypeError) + expect(() => Duration.zero.minus(max).minus(Duration.fromMilliseconds(1))).toThrow(TypeError) + // The bound is checked at every construction, not only at the factories. + expect(max.plus(Duration.fromMilliseconds(0)).milliseconds).toBe(Number.MAX_SAFE_INTEGER) + }) + it('orders spans without surrendering the unit', () => { const minute = Duration.fromSeconds(60) const hour = Duration.fromSeconds(3_600) From 9facb516149bb17ce937141fe956d0ad25bd11a3 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 26 Sep 2026 01:22:52 +0800 Subject: [PATCH 17/26] docs: state the coercion, changed samples, and charter files exactly - the note says Duration overrides no coercion hook, matching the README and the class doc rather than claiming it has no toString - the elapsed change names the two samples that move (NaN and +Infinity, not -Infinity, which the consumer still clamps to zero) - the charter sentence names the files that keep the phrase - the seconds-unit limitation separates a span from an epoch in seconds --- .../2026-09-24-shared-time-vocabulary.i18n.yaml | 8 ++++---- .../architecture/2026-09-24-shared-time-vocabulary.md | 6 +++--- .../architecture/2026-09-24-shared-time-vocabulary.zh.md | 6 +++--- packages/util/time/README.i18n.yaml | 4 ++-- packages/util/time/README.md | 2 +- packages/util/time/README.zh.md | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) 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 0c6bd7165b..c22f9dd39e 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: a573289069f0505c - zh: c4a125cbb0a7bcd4 + en: db91e6074fd74bdb + zh: 1957b8b0e1243afc /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: 99a03606d21acf8d - zh: f7b56ff5f7a61a76 + en: 78c1f9d2cbaee3e2 + zh: 8adbaa9f8d80b2eb 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 31d3f31927..70d8289089 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 @@ -18,9 +18,9 @@ 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, 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. +`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 it deliberately overrides no coercion hook, so no reader 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 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 the group and root package maps keep the phrase while this package's own description names what it loads. +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. @@ -46,6 +46,6 @@ The cost is a wider published surface and a heavier dependency graph: `dsh-util- The value type ships its whole operation set before its second consumer exists. Only `fromMilliseconds` and `decompose()` have a production caller, `dsh-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 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). 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 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; the package's own reading invariant already refused those two renderings, so the failure moves to the sample that produced it. 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 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 687cd858b6..44d867a15e 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 @@ -18,9 +18,9 @@ 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()` 就是全部操作集合,另有 `zero` 恒等元与面向以秒为单位边界的 `fromSeconds` 构造入口。 +`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结;计数只能通过 `milliseconds` 或按小数换算的 `toSeconds`/`toMinutes`/`toHours`/`toDays` 读取,且刻意不覆盖任何强制转换钩子,因此读取方永远不会拿到一个需要自己记单位的裸数字。加、减、排序、负值判断与整数量拆解 `decompose()` 就是全部操作集合,另有 `zero` 恒等元与面向以秒为单位边界的 `fromSeconds` 构造入口。 -受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 自身的描述与 README 去掉了 `zero-dependency` 这一说法,因为它容易被误读:该组的约定禁止的是 harness 依赖(以便任何组都能依赖 util),并不禁止外部包;因此组与根包映射保留该说法,而本包自己的描述写明它加载了什么。 +受维护的 `@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% 覆盖。 @@ -46,6 +46,6 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 该值类型在第二个消费方出现之前就交付了完整操作集合。目前只有 `fromMilliseconds` 与 `decompose()` 有生产调用方,即 `dsh-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`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。 +耗时读数的拆分上收到库、措辞留在一侧:`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/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index c9519add50..3e35ad4c58 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -36,8 +36,8 @@ en: daf6e45a18c087b0 zh: e6714f1e65ed55ce /dsh-util-time/known-limitations-and-deferred-work: - en: c045ad46a696321a - zh: 648cd96b1bec5fff + en: 06ea0ac6f5102d88 + zh: bdc0d5d2a1faba41 /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 6fc568b106..373f050a30 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -107,7 +107,7 @@ None of its own. The consumer that injects a zone- or schedule-derived line into - **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 overrides no coercion hook (`valueOf`, `toString`, `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. +- **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`; `Duration.fromSeconds` is their intended next consumer, and none has moved yet. 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. diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index 53c408b332..c61b4ef01c 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -107,7 +107,7 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and - **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。 - **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。 - **`Duration` 不做隐式单位换算**——它不覆盖任何强制转换钩子(`valueOf`、`toString`、`Symbol.toPrimitive`),读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。 -- **秒为单位的边界仍在自行相乘**——`dsh-schedule` 与 DeepSeek 的文件存储用 `* 1_000` 换算各自以秒为单位的字段;`Duration.fromSeconds` 是这些时间段的预期下一个消费方,目前都还没改。 +- **以秒为单位的跨度仍在自行相乘**——`dsh-schedule` 的间隔与延迟字段、DeepSeek 文件存储的刷新余量都是以秒为单位的跨度,用 `* 1_000` 换算;`Duration.fromSeconds` 是它们预期的下一个消费方,目前都还没改。以秒表示的纪元不是跨度,不得构造成跨度。 - **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。 From 8987e6c45afb89ee8ac4ec8040d8b0d6c8b2dae0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 8 Oct 2026 16:20:08 +0800 Subject: [PATCH 18/26] docs: drop references to the removed invariant mechanism origin/master removed the runtime invariant plugins and the package rule that asked a README to justify the absence, so the text that cited that mechanism no longer refers to anything: - the Agent Note states the elapsed-rendering change without the reading invariant it used to cite - the same note no longer says the durable reading format is one an invariant validates - the util/time README no longer explains why no invariant companion is published, and its module JSDoc no longer claims the library formats nothing while listing a canonical instant renderer --- .../architecture/2026-09-24-shared-time-vocabulary.i18n.yaml | 4 ++-- .../architecture/2026-09-24-shared-time-vocabulary.md | 4 ++-- .../architecture/2026-09-24-shared-time-vocabulary.zh.md | 4 ++-- packages/util/time/README.i18n.yaml | 4 ++-- packages/util/time/README.md | 1 - packages/util/time/README.zh.md | 1 - packages/util/time/src/index.ts | 4 ++-- 7 files changed, 10 insertions(+), 12 deletions(-) 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 c22f9dd39e..c7d4d181a9 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 @@ -15,5 +15,5 @@ en: 1737484c099fab52 zh: 303b144c52d167bd /agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences: - en: 78c1f9d2cbaee3e2 - zh: 8adbaa9f8d80b2eb + en: ef18e69924eb420b + zh: 27e42a923b94204a 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 70d8289089..5983ca85cf 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 @@ -46,6 +46,6 @@ The cost is a wider published surface and a heavier dependency graph: `dsh-util- The value type ships its whole operation set before its second consumer exists. Only `fromMilliseconds` and `decompose()` have a production caller, `dsh-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 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; the package's own reading invariant already refused those two renderings, so the failure moves to the sample that produced it. A `-Infinity` sample is unchanged, because the consumer clamps it to zero before the span is built. +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 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. +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 index 44d867a15e..fab2092c09 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 @@ -46,6 +46,6 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec 该值类型在第二个消费方出现之前就交付了完整操作集合。目前只有 `fromMilliseconds` 与 `decompose()` 有生产调用方,即 `dsh-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`,以及被钳到零的那次)。有两个样本会变:`NaN` 原本渲染出 `NaNs`、`+Infinity` 原本渲染出 `Infinityd NaNs`,现在两者都由时间段构造函数抛出;这两种渲染本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。`-Infinity` 样本不变,因为消费方在构造时间段之前已把它钳到零。 +耗时读数的拆分上收到库、措辞留在一侧:`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 时刻,这是本次未触及、仍然存在的时刻剖面副本。 +本次未触及、仍归 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 66374badf0..efc4bd77f1 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -18,8 +18,8 @@ en: bbff250eba0f1b89 zh: 0a618f05cc88ee2b /dsh-util-time/use-this-package/when-to-use-it: - en: 7015b3ed33de4c53 - zh: d4fc9aee6f6910ca + en: 85291d5fb4c9000b + zh: d1717c3b01efb5d0 /dsh-util-time/use-this-package/entry-point: en: 88668c3dd1bdb75b zh: fb48d941ae433335 diff --git a/packages/util/time/README.md b/packages/util/time/README.md index 01813d8b4b..50976eb86f 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -31,7 +31,6 @@ It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds 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. -No runtime invariant companion is published because this pure utility owns no event stream or mutable runtime data; unit tests verify every entry. ### Entry point diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index 3302a38ee8..642f921618 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -31,7 +31,6 @@ kind: "package-library" 在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。`dsh-time-context` 通过 `Duration` 读取其耗时读数,并保留它注入那一行的措辞。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。 -不发布运行时不变量伴生入口;这个纯工具不拥有事件流或可变运行时数据,每个入口都由单元测试验证。 ### 入口 diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts index bbbd316134..e143295709 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -2,8 +2,8 @@ * 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. * - * Validation, canonicalization, and strict parsing only: this library formats - * no model-facing text and owns no failure vocabulary. A refusal that carries a + * 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 From b66aa70051c572ff57355e35bb504c1e458e9ec2 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Sat, 10 Oct 2026 13:29:58 +0800 Subject: [PATCH 19/26] test(coverage): scope the fork exit ordering claim to the clean exit report Vitest reports an unexpected worker exit either as "Worker exited unexpectedly" from the exit event or as "Worker coverage-forks emitted error" when the channel closes under a send. The record describes the exit, and the error is logged before it, so only the first form can order the record ahead of the report. The previous assertion named one hardcoded message; #5900 widened it to whichever form appeared and so claimed an order the error form cannot satisfy. The case name no longer claims that order, and the report lookup relies on the assertion above it instead of repeating the check. Refs #5813 --- scripts/coverage-fork-diagnostics.spec.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/coverage-fork-diagnostics.spec.ts b/scripts/coverage-fork-diagnostics.spec.ts index be555a3003..3a6557b15a 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,12 @@ 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 reports no exit text, and the record cannot come first. + 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 }) => { From 4fe62777b373ad97ca0b56f56b29f36e5757cc92 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Sun, 11 Oct 2026 00:36:53 +0800 Subject: [PATCH 20/26] test(web): pin the collapse-timing clocks to the fixture day The scenario stamps its own prompt times from the Host wall clock, which `support.ts` pairs with `pinHostClock`/`pinBrowserClock` and a fixture `createdAt` so the calendar fact belongs to the scenario instead of the run, as `seeded-history.e2e.ts` already does on the same fixture. With both clocks on the fixture day the message and render instants cannot land on different days, so the snapshot no longer depends on which side of local midnight the run falls. Refs #5997 --- apps/web/tests/collapse-timing.e2e.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts index a33867b404..5c3a7489fe 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,13 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { let fixture: string let prompt: string let replayDir: string + let hostClock: ReturnType | undefined + let unpinBrowserClock: (() => void) | 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 +61,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') unpinBrowserClock = await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.getByRole('button', { name: 'Settings', exact: true }).waitFor() @@ -66,8 +72,13 @@ 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 { + unpinBrowserClock?.() + hostClock?.mockRestore() + } if (replayDir !== undefined) { await rm(replayDir, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) } From cde1086c0a9161eb3b8e405ba4efe35aef624946 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Sun, 11 Oct 2026 03:28:04 +0800 Subject: [PATCH 21/26] test(coverage): name the missing worker-exit line in the ordering comment --- scripts/coverage-fork-diagnostics.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/coverage-fork-diagnostics.spec.ts b/scripts/coverage-fork-diagnostics.spec.ts index 3a6557b15a..4450245255 100644 --- a/scripts/coverage-fork-diagnostics.spec.ts +++ b/scripts/coverage-fork-diagnostics.spec.ts @@ -198,7 +198,8 @@ describe('coverage fork configuration entry', () => { .toEqual([resolve('scripts/fixtures/coverage-fork.fixture.ts').replaceAll('\\', '/')]) 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 reports no exit text, and the record cannot come first. + // 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)) } From c949deb1cab37f41b00c85f41fba945c7214302b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 14 Oct 2026 15:27:15 +0800 Subject: [PATCH 22/26] refactor(util-time): publish only the span surface its consumers call The package API carried members and result types ahead of any caller. The span now publishes fromMilliseconds and decompose, the two entries dsh-time-context calls; the seconds constructor, the zero identity, the arithmetic and ordering operations, the millisecond getter, and the four fractional conversions land with the caller that needs them, recorded in the Agent Note and the README limitation. The package entry names its exports instead of re-exporting both modules, so the three result types and DurationParts, which no consumer names, are no longer part of the published surface. Every remaining new name has a named consumer: schedule imports the nine parser and bound names plus four rejection types, and canonicalizeTimeZone has seven consumer files. --- ...026-09-24-shared-time-vocabulary.i18n.yaml | 8 +- .../2026-09-24-shared-time-vocabulary.md | 4 +- .../2026-09-24-shared-time-vocabulary.zh.md | 4 +- packages/util/time/README.i18n.yaml | 12 +- packages/util/time/README.md | 11 +- packages/util/time/README.zh.md | 11 +- packages/util/time/src/duration.ts | 112 +-------------- packages/util/time/src/index.ts | 15 +- packages/util/time/tests/duration.spec.ts | 128 +++++++----------- 9 files changed, 89 insertions(+), 216 deletions(-) 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 c7d4d181a9..46925831d8 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: db91e6074fd74bdb - zh: 1957b8b0e1243afc + 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: ef18e69924eb420b - zh: 27e42a923b94204a + 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 index 5983ca85cf..607dc68b7a 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 @@ -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 it deliberately overrides no coercion hook, so no reader 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. +`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. @@ -44,7 +44,7 @@ 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 value type ships its whole operation set before its second consumer exists. Only `fromMilliseconds` and `decompose()` have a production caller, `dsh-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. +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. 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 fab2092c09..838f6b9d4b 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 @@ -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` 读取,且刻意不覆盖任何强制转换钩子,因此读取方永远不会拿到一个需要自己记单位的裸数字。加、减、排序、负值判断与整数量拆解 `decompose()` 就是全部操作集合,另有 `zero` 恒等元与面向以秒为单位边界的 `fromSeconds` 构造入口。 +`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` 与本包自己的描述写明它加载了什么。 @@ -44,7 +44,7 @@ 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` 新增原因时会通过编译并静默套用那条通用文案。 -该值类型在第二个消费方出现之前就交付了完整操作集合。目前只有 `fromMilliseconds` 与 `decompose()` 有生产调用方,即 `dsh-time-context`;`fromSeconds`、`milliseconds`、`plus`、`minus`、`compare`、`isNegative`、`zero` 与四个 `to*` 换算都没有调用方。这是有意的选择而不是疏漏:`dsh-schedule` 与 DeepSeek 文件存储里以秒为单位的字段是预期的下一批消费方,而另一种做法——一个能保存时间段却只能拆解它的类——会在每个调用点出现时再长出同样的成员。包 README 把这些尚未迁移的边界记为已知限制。 +值类型只发布消费方会调用的部分,因此这个时间段只暴露 `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` 样本不变,因为消费方在构造时间段之前已把它钳到零。 diff --git a/packages/util/time/README.i18n.yaml b/packages/util/time/README.i18n.yaml index efc4bd77f1..cad952bcf0 100644 --- a/packages/util/time/README.i18n.yaml +++ b/packages/util/time/README.i18n.yaml @@ -21,11 +21,11 @@ en: 85291d5fb4c9000b zh: d1717c3b01efb5d0 /dsh-util-time/use-this-package/entry-point: - en: 88668c3dd1bdb75b - zh: fb48d941ae433335 + en: 7ca7ddebcb6af6be + zh: 4702a29079d6b8a7 /dsh-util-time/understand-the-implementation: - en: ef1369f1e986acf5 - zh: b7a98e3d79048d50 + en: 14595019b72aa45f + zh: cfea261931019621 /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: 06ea0ac6f5102d88 - zh: bdc0d5d2a1faba41 + 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 50976eb86f..c86f1c7462 100644 --- a/packages/util/time/README.md +++ b/packages/util/time/README.md @@ -45,11 +45,10 @@ if (local.kind === 'accepted') { resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap } -Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds: 30, milliseconds: 0 } -Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and no coercion answers the count +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, 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. +`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. ----- @@ -73,7 +72,7 @@ The strict parsers match a fixed regular expression before constructing any date | `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, built from milliseconds or seconds, and never a bare count on read. | +| `Duration` | One finite millisecond count inside the exactly representable range, and its split into whole days, hours, minutes, seconds, and the remaining fraction. |
@@ -105,8 +104,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 overrides no coercion hook (`valueOf`, `toString`, `Symbol.toPrimitive`), so a reader states its unit through `milliseconds` or a `to*` conversion. -- **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`; `Duration.fromSeconds` is their intended next consumer, and none has moved yet. An epoch expressed in seconds is not a span and must not be built into one. +- **`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. diff --git a/packages/util/time/README.zh.md b/packages/util/time/README.zh.md index 642f921618..cecc42686f 100644 --- a/packages/util/time/README.zh.md +++ b/packages/util/time/README.zh.md @@ -45,11 +45,10 @@ if (local.kind === 'accepted') { resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap } -Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds: 30, milliseconds: 0 } -Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and no coercion answers the count +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` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限且落在可精确表示的范围内,加减、排序与负值判断都不做单位换算,按小数换算到秒、分、小时与天,并把非负的时间段拆成整数量。 +不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限且落在可精确表示的范围内,并把非负的时间段拆成整数量。它只发布消费方会调用的部分;面向其它单位或时间段算术的成员都随第一个需要它的调用方落地。 ----- @@ -73,7 +72,7 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and | `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 | | `isFourDigitYearInstant` | 同一范围的谓词形式,只判断范围边界的调用方无需自行推导。 | | `resolveLocalInstant` | 真实日期的精确日历字段,或已构造好的 `Temporal.PlainDateTime`,外加已规范化的时区。 | -| `Duration` | 一个有限的毫秒计数,可由毫秒或秒构造,读取时从不交出裸计数。 | +| `Duration` | 一个落在可精确表示范围内的毫秒计数,以及它拆出的整日、整时、整分、整秒与不足一秒的余数。 |
@@ -105,8 +104,8 @@ Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and - **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。 - **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。 - **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。 -- **`Duration` 不做隐式单位换算**——它不覆盖任何强制转换钩子(`valueOf`、`toString`、`Symbol.toPrimitive`),读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。 -- **以秒为单位的跨度仍在自行相乘**——`dsh-schedule` 的间隔与延迟字段、DeepSeek 文件存储的刷新余量都是以秒为单位的跨度,用 `* 1_000` 换算;`Duration.fromSeconds` 是它们预期的下一个消费方,目前都还没改。以秒表示的纪元不是跨度,不得构造成跨度。 +- **`Duration` 只发布消费方会调用的部分**——`fromMilliseconds` 与 `decompose()`;某个换算、某个算术运算或 `zero` 恒等元都随第一个需要它的调用方落地,公开契约跟着调用方长,而不是长在它前面。 +- **以秒为单位的跨度仍在自行相乘**——`dsh-schedule` 的间隔与延迟字段、DeepSeek 文件存储的刷新余量都是以秒为单位的跨度,用 `* 1_000` 换算;它们都还没迁移到本库,而以秒表示的纪元不是跨度,不得构造成跨度。 - **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。 diff --git a/packages/util/time/src/duration.ts b/packages/util/time/src/duration.ts index 55a221e6b5..3730d0a86f 100644 --- a/packages/util/time/src/duration.ts +++ b/packages/util/time/src/duration.ts @@ -1,19 +1,16 @@ /** - * One span of time, held as a count of milliseconds. + * 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. No coercion hook is - * overridden, so no reader receives a bare count whose unit it has to remember; - * a caller states the unit by reading `milliseconds` or by converting through - * one of the `to*` entries. + * 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 MS_PER_MINUTE = 60 * MS_PER_SECOND -const MS_PER_HOUR = 60 * MS_PER_MINUTE -const MS_PER_DAY = 24 * MS_PER_HOUR const SECONDS_PER_DAY = 86_400 const SECONDS_PER_HOUR = 3_600 const SECONDS_PER_MINUTE = 60 @@ -47,13 +44,9 @@ const MAX_SPAN_MS = Number.MAX_SAFE_INTEGER * 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. + * month is not a fixed count of milliseconds. */ export class Duration { - /** The additive identity. */ - static readonly zero: Duration = new Duration(0) - private readonly span: number private constructor(milliseconds: number) { @@ -79,99 +72,6 @@ export class Duration { return new Duration(milliseconds) } - /** - * Build one span from a second count, for a boundary whose unit is seconds. - * @param seconds - Finite second count; a fractional count is kept as given. - * @returns The span. - * @throws TypeError when the count is not finite, or its millisecond equivalent is not finite or leaves the exactly representable range. - */ - static fromSeconds(seconds: number): Duration { - if (!Number.isFinite(seconds)) { - throw new TypeError(`duration seconds must be finite, got ${String(seconds)}`) - } - return Duration.fromMilliseconds(seconds * MS_PER_SECOND) - } - - /** The span as a millisecond count; the unit is part of the name. */ - get milliseconds(): number { - return this.span - } - - /** - * Add one span to this span. - * @param other - Span to add; both operands already share the millisecond unit. - * @returns The sum. - * @throws TypeError when the sum leaves the exactly representable millisecond range. - */ - plus(other: Duration): Duration { - return Duration.fromMilliseconds(this.span + other.span) - } - - /** - * Subtract one span from this span. - * @param other - Span to subtract. - * @returns The difference, which is negative when `other` is longer. - * @throws TypeError when the difference leaves the exactly representable millisecond range. - */ - minus(other: Duration): Duration { - return Duration.fromMilliseconds(this.span - other.span) - } - - /** - * Order two spans. - * @param other - Span to compare with. - * @returns `-1` when this span is shorter, `0` when equal, `1` when longer. - */ - compare(other: Duration): -1 | 0 | 1 { - if (this.span < other.span) return -1 - return this.span > other.span ? 1 : 0 - } - - /** - * Whether the span is a negative interval, which every sign-sensitive reader - * must decide how to handle. - * @returns Whether the millisecond count is below zero. - */ - isNegative(): boolean { - return this.span < 0 - } - - /** - * The span in seconds. - * @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 - } - - /** - * The span in minutes. - * @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 - } - - /** - * The span in hours. - * @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 - } - - /** - * The span in days. - * - * 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 days count: one division, so the answer is the double nearest the exact ratio. - */ - toDays(): number { - return this.span / MS_PER_DAY - } - /** * Split the span into whole days, hours, minutes, seconds, and the remaining * fraction of a second. Days, hours, minutes, and seconds are floored; the diff --git a/packages/util/time/src/index.ts b/packages/util/time/src/index.ts index e143295709..31d50fda3f 100644 --- a/packages/util/time/src/index.ts +++ b/packages/util/time/src/index.ts @@ -15,8 +15,19 @@ import { canonicalizeTimeZone } from './instant.ts' -export * from './duration.ts' -export * 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. diff --git a/packages/util/time/tests/duration.spec.ts b/packages/util/time/tests/duration.spec.ts index 8edd4f1ead..1430bf7805 100644 --- a/packages/util/time/tests/duration.spec.ts +++ b/packages/util/time/tests/duration.spec.ts @@ -1,45 +1,59 @@ 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('holds the millisecond count it was built from, fraction included', () => { - expect(Duration.fromMilliseconds(1_500).milliseconds).toBe(1_500) - expect(Duration.fromMilliseconds(0.5).milliseconds).toBe(0.5) - expect(Duration.zero.milliseconds).toBe(0) + 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('converts a second count at the boundary that carries seconds', () => { - expect(Duration.fromSeconds(300).milliseconds).toBe(300_000) - expect(Duration.fromSeconds(0.25).milliseconds).toBe(250) - expect(Duration.fromSeconds(-60).milliseconds).toBe(-60_000) + 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) - expect(() => Duration.fromSeconds(value)).toThrow(TypeError) } }) - it('refuses a finite second count whose millisecond equivalent leaves the finite range', () => { - 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) + 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. - 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) + 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.milliseconds).toBe(1) + 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) @@ -47,54 +61,6 @@ describe('Duration construction', () => { }) }) -describe('Duration arithmetic', () => { - it('adds and subtracts in the one shared unit', () => { - expect(Duration.fromSeconds(2).plus(Duration.fromMilliseconds(500)).milliseconds).toBe(2_500) - expect(Duration.fromSeconds(2).minus(Duration.fromMilliseconds(500)).milliseconds).toBe(1_500) - expect(Duration.fromMilliseconds(1).minus(Duration.fromSeconds(1)).isNegative()).toBe(true) - expect(Duration.zero.plus(Duration.zero).milliseconds).toBe(0) - }) - - it('refuses a sum or difference that leaves the exactly representable range', () => { - const max = Duration.fromMilliseconds(Number.MAX_SAFE_INTEGER) - expect(() => max.plus(Duration.fromMilliseconds(1))).toThrow(TypeError) - expect(() => Duration.zero.minus(max).minus(Duration.fromMilliseconds(1))).toThrow(TypeError) - // The bound is checked at every construction, not only at the factories. - expect(max.plus(Duration.fromMilliseconds(0)).milliseconds).toBe(Number.MAX_SAFE_INTEGER) - }) - - it('orders spans without surrendering the unit', () => { - const minute = Duration.fromSeconds(60) - const hour = Duration.fromSeconds(3_600) - expect(minute.compare(hour)).toBe(-1) - expect(hour.compare(minute)).toBe(1) - expect(minute.compare(Duration.fromMilliseconds(60_000))).toBe(0) - expect(minute.isNegative()).toBe(false) - expect(Duration.fromMilliseconds(-1).isNegative()).toBe(true) - }) -}) - -describe('Duration conversion', () => { - 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', () => { - expect(Duration.fromMilliseconds(86_400_000).toDays()).toBe(1) - expect(Duration.fromMilliseconds(-86_400_000).toDays()).toBe(-1) - }) -}) - describe('Duration decompose', () => { it('splits a span into floored whole parts and the fractional remainder', () => { expect(Duration.fromMilliseconds(0).decompose()) @@ -103,39 +69,37 @@ describe('Duration 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(86_400_000).decompose()) - .toEqual({ days: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }) 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 whose sum never exceeds the span', () => { - const parts = Duration.fromMilliseconds(3_661_999).decompose() - expect(Object.isFrozen(parts)).toBe(true) - 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) + 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', () => { - 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) + expect(partSum(Duration.fromMilliseconds(span).decompose())).toBe(span) } - for (let i = 0; i < 2_000; i += 1) { - const span = Math.random() * Number.MAX_SAFE_INTEGER + for (const span of spans(2_000, 20_260_924)) { const parts = Duration.fromMilliseconds(span).decompose() - expect(sum(parts)).toBe(span) + 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) }) }) From adc27809f75a24c76178b6b2b6400b6970ee92fb Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 19 Oct 2026 15:12:50 +0800 Subject: [PATCH 23/26] fix: map every zone rejection exhaustively and document the signed span - timeZoneError and browserTimeZone switch over TimeZoneRejection instead of a ternary: each reason that shares a diagnostic is its own case, and the default arm assigns the reason to never so adding a reason fails the build (assertNever where the package already imports it). The unsupported cause is still forwarded. - the request-zone suite now exercises the blank refusal, so every case the switch lists is covered rather than sharing one ternary branch. - Duration documents that a span is signed: a negative count builds and decompose() refuses it, so the caller states its own sign policy. --- .../context/time-context/src/request-zone.ts | 21 ++++++++----- .../time-context/tests/request-zone.spec.ts | 2 ++ packages/schedule/schedule/src/domain.ts | 30 +++++++++---------- packages/util/time/src/duration.ts | 14 ++++++--- 4 files changed, 40 insertions(+), 27 deletions(-) diff --git a/packages/context/time-context/src/request-zone.ts b/packages/context/time-context/src/request-zone.ts index 02b957b726..800cd04124 100644 --- a/packages/context/time-context/src/request-zone.ts +++ b/packages/context/time-context/src/request-zone.ts @@ -34,15 +34,20 @@ function browserTimeZone(message: UserMessage): string | undefined { } return value } - if (resolved.reason === 'unsupported') { - throw new TypeError(`browser time zone is unsupported: ${JSON.stringify(value)}`, { cause: resolved.cause }) + 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') } - // `not_canonical_shape` names a platform-canonical answer outside the Area/Location - // profile, which the shared vocabulary does not produce for a name it accepts. - /* v8 ignore next -- Intl answers a canonical Area/Location for every name it accepts. */ - throw resolved.reason === 'not_canonical_shape' - ? new TypeError(`browser time zone must be canonical: ${JSON.stringify(value)}`) - : new TypeError(`browser time zone must be canonical UTC or IANA Area/Location: ${JSON.stringify(value)}`) } /** diff --git a/packages/context/time-context/tests/request-zone.spec.ts b/packages/context/time-context/tests/request-zone.spec.ts index 8dbd648572..74eb828337 100644 --- a/packages/context/time-context/tests/request-zone.spec.ts +++ b/packages/context/time-context/tests/request-zone.spec.ts @@ -63,6 +63,8 @@ describe('browser request-zone context', () => { 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) diff --git a/packages/schedule/schedule/src/domain.ts b/packages/schedule/schedule/src/domain.ts index 04a25734b5..492730d1c1 100644 --- a/packages/schedule/schedule/src/domain.ts +++ b/packages/schedule/schedule/src/domain.ts @@ -296,21 +296,21 @@ function instantDiagnostic(reason: InstantRejection): string { */ function timeZoneError(reason: TimeZoneRejection, cause?: unknown): ScheduleInputError { const options = cause === undefined ? undefined : { cause } - // `not_canonical_shape` names a platform-canonical answer outside the Area/Location - // profile, which the shared parser's `Intl` step does not produce; this branch keeps - // the diagnostic this entry has always returned for that reason. - /* v8 ignore next 2 -- the shared parser never answers not_canonical_shape for a canonical zone. */ - return reason === 'not_canonical_shape' - ? new ScheduleInputError( - 'invalid_time_zone', - 'time_zone must resolve to UTC or an IANA Area/Location name.', - options, - ) - : new ScheduleInputError( - 'invalid_time_zone', - 'time_zone must be UTC or a valid IANA Area/Location name.', - options, - ) + 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 + } + } } /** diff --git a/packages/util/time/src/duration.ts b/packages/util/time/src/duration.ts index 3730d0a86f..7543d299dc 100644 --- a/packages/util/time/src/duration.ts +++ b/packages/util/time/src/duration.ts @@ -42,9 +42,13 @@ 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 - * 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. + * 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 @@ -56,7 +60,9 @@ export class Duration { /** * Build one span from a millisecond count. - * @param milliseconds - Count within the exactly representable range (about 285,000 years); fractions are kept. + * 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. */ From f351c96d71e26388592148ba793597ba7b53c83e Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:01:50 +0800 Subject: [PATCH 24/26] fix(desktop): set Windows publisher and uninstall display name --- apps/desktop/electron-builder.config.d.mts | 1 + apps/desktop/package.json | 3 +++ apps/desktop/packaging/README.i18n.yaml | 4 ++-- apps/desktop/packaging/README.md | 2 ++ apps/desktop/packaging/README.zh.md | 2 ++ apps/desktop/scripts/electron-builder-config.mjs | 1 + 6 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 0682e6e9ad..b94e35d0e3 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -43,6 +43,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 c7c1246e9a..ad756bd5ca 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -4,6 +4,9 @@ "version": "0.2.1-alpha.4", "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 e42bbf1bfe..ca73b41c64 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -45,8 +45,8 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: bfd89e1d139c5729 - zh: 5d6cced5462ba13e + en: 080d251258b2358f + zh: e3d968a0384971db /desktop-packaging/desktop-runtime-preparation: en: a7aa3066e649ffe8 zh: 4334637910058c09 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index ebcd0671a5..2ac4a599f5 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -230,6 +230,8 @@ The served `time` is the real packing time, so pnpm 11's default 24-hour `minimu ## Runtime file selection and installation checks +Windows lists the installed application by product name without a version suffix; the version remains in the separate version field. The publisher comes from Desktop's `package.json` author name, independently of the signing certificate. These fields take effect when the rebuilt installer installs or updates the application. + Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](../scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory and Yarn development metadata, selected native compiler outputs, and node-pty prebuilds for other platforms. Non-Windows targets also omit `node-pty/third_party/conpty`; Windows targets retain that directory. It preserves runtime JavaScript, target native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 9e1a2a20b8..31ee315342 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -230,6 +230,8 @@ pnpm run dev:bundle-registry ## 运行时文件筛选与安装检查 +Windows 以不带版本后缀的产品名称显示已安装应用,版本保留在单独的版本字段中。发布者取自 Desktop 的 `package.json` 作者名称,与签名证书相互独立。这些字段在重新构建的安装包安装或更新应用时生效。 + Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](../scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录及 Yarn 开发元数据、选定的原生编译器输出和其他平台的 node-pty 预构建文件。非 Windows 目标还会排除 `node-pty/third_party/conpty`;Windows 目标保留该目录。它保留运行时 JavaScript、目标平台的原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 [Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 4b9cd27a8d..29997d48f1 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -250,6 +250,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)), From 0d9a7f9abfa04e1611055c7b5596d82adfc29cef Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:23:59 +0800 Subject: [PATCH 25/26] fix(desktop): preserve copyright and verify installer metadata --- apps/desktop/electron-builder.config.d.mts | 1 + apps/desktop/packaging/README.i18n.yaml | 8 ++++---- apps/desktop/packaging/README.md | 4 ++-- apps/desktop/packaging/README.zh.md | 4 ++-- apps/desktop/scripts/electron-builder-config.mjs | 1 + apps/desktop/tests/installer-packaging.spec.ts | 14 +++++++++++++- 6 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index b94e35d0e3..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: { diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index ca73b41c64..9cdb5430c4 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -45,14 +45,14 @@ en: b42f103358d0dfd7 zh: a534164ad9eb00da /desktop-packaging/runtime-file-selection-and-installation-checks: - en: 080d251258b2358f - zh: e3d968a0384971db + en: bfd89e1d139c5729 + zh: 5d6cced5462ba13e /desktop-packaging/desktop-runtime-preparation: 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 2ac4a599f5..d4590964a3 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -230,8 +230,6 @@ The served `time` is the real packing time, so pnpm 11's default 24-hour `minimu ## Runtime file selection and installation checks -Windows lists the installed application by product name without a version suffix; the version remains in the separate version field. The publisher comes from Desktop's `package.json` author name, independently of the signing certificate. These fields take effect when the rebuilt installer installs or updates the application. - Desktop packs workspace packages locally and installs external dependencies through the target's bundled Node and pnpm. [Desktop's file policy](../scripts/runtime-file-policy.ts) then filters the immutable `resources/app.asar/dsh/node_modules` copy before signing and integrity sealing. It omits TypeScript declarations, recognized JavaScript/CSS/TypeScript source maps, TypeScript build caches, Domino's test directory and Yarn development metadata, selected native compiler outputs, and node-pty prebuilds for other platforms. Non-Windows targets also omit `node-pty/third_party/conpty`; Windows targets retain that directory. It preserves runtime JavaScript, target native modules and their DLL/EXE helpers, WASM, unknown assets, licenses, and notices. Dependency manifests pass through electron-builder's metadata cleanup before integrity sealing, so archiving preserves their recorded bytes. The policy does not alter npm tarballs, the bundled package manager, or user-installed plugin files. The [Electron shell file list](../scripts/electron-builder-config.mjs) excludes recognized JavaScript/CSS/TypeScript source maps and TypeScript build caches from the separate `resources/app.asar/node_modules` dependency collection. These exclusions preserve runtime code, native modules, WASM, data files (including ordinary `.map` assets), licenses, and notices; [shell archive tests](../tests/shell-file-selection.spec.ts) check retained bytes and exclusions. @@ -274,6 +272,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 31ee315342..0d97182f5e 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -230,8 +230,6 @@ pnpm run dev:bundle-registry ## 运行时文件筛选与安装检查 -Windows 以不带版本后缀的产品名称显示已安装应用,版本保留在单独的版本字段中。发布者取自 Desktop 的 `package.json` 作者名称,与签名证书相互独立。这些字段在重新构建的安装包安装或更新应用时生效。 - Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安装外部依赖。[Desktop 文件策略](../scripts/runtime-file-policy.ts)随后在签名和完整性封装前过滤不可变的 `resources/app.asar/dsh/node_modules` 副本。它排除 TypeScript 声明、已识别的 JavaScript/CSS/TypeScript source map、TypeScript 构建缓存、Domino 测试目录及 Yarn 开发元数据、选定的原生编译器输出和其他平台的 node-pty 预构建文件。非 Windows 目标还会排除 `node-pty/third_party/conpty`;Windows 目标保留该目录。它保留运行时 JavaScript、目标平台的原生模块及其 DLL/EXE 辅助文件、WASM、未知资源、许可证和 notices。依赖清单在完整性封装前经过 electron-builder 的元数据清理,确保归档保持已记录的字节。该策略不修改 npm tarball、捆绑的包管理器或用户安装的插件文件。 [Electron 外壳文件清单](../scripts/electron-builder-config.mjs)从单独收集的 `resources/app.asar/node_modules` 依赖中排除已识别的 JavaScript/CSS/TypeScript source map 和 TypeScript 构建缓存。这些排除规则保留运行时代码、原生模块、WASM、数据文件(包括普通 `.map` 资源)、许可证和 notices;[外壳归档测试](../tests/shell-file-selection.spec.ts)检查保留文件的字节和排除项。 @@ -274,6 +272,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 29997d48f1..c693601ed6 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -114,6 +114,7 @@ export function createElectronBuilderConfig( ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, }, productName: 'DeepSeek Harness', + 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 }, diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 15a4610d98..6156a60da3 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) })) @@ -37,6 +38,17 @@ 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() }) + Object.defineProperties(metadataPackager, { + config: { value: config }, + metadata: { value: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) }, + }) + 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))) From 6895919d5eebe9c58004bda4bf89471710bc2ee6 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:37:46 +0800 Subject: [PATCH 26/26] test(desktop): avoid unsafe manifest assignment in metadata checks --- apps/desktop/scripts/electron-builder-config.mjs | 1 + apps/desktop/tests/installer-packaging.spec.ts | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index c693601ed6..7dc39ff326 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -114,6 +114,7 @@ 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}`, diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index 6156a60da3..d971bb262d 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -20,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', @@ -39,9 +39,10 @@ describe('installer preparation preserves application dependencies', () => { 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: JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) }, + metadata: { value: metadata }, }) const appInfo = new AppInfo(metadataPackager, undefined) expect(appInfo.companyName).toBe('Hangzhou DeepSeek Artificial Intelligence Co., Ltd.')