From c96d48d49161c53b9b2a84942b7e7f1cdc3bf900 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 24 Sep 2026 12:55:36 +0800 Subject: [PATCH 001/287] 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 002/287] 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 003/287] 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 004/287] 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 005/287] 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 006/287] 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 007/287] 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 008/287] 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 009/287] 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 010/287] 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 011/287] 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 012/287] 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 013/287] 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 014/287] 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 015/287] 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 016/287] 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 017/287] 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 094a8701fe4703b5b57720357c2b2f18e31fdfdf Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Tue, 29 Sep 2026 12:48:04 +0800 Subject: [PATCH 018/287] Publish weighted approval as a completed check run Unmet weighted approval conditions previously left a pending commit status that CI watchers treat as running work, so gh pr checks --watch and similar monitors never finished. Publish the decision as the weighted approval decision check run instead: action_required blocks merging while completing the check, and failure reports evaluation errors. --- ...eighted-approvals-remain-pending.i18n.yaml | 22 --- ...ocked-weighted-approvals-remain-pending.md | 33 ---- ...ed-weighted-approvals-remain-pending.zh.md | 33 ---- ...pproval-review-workflow-identity.i18n.yaml | 4 +- ...09-10-approval-review-workflow-identity.md | 2 +- ...10-approval-review-workflow-identity.zh.md | 2 +- ...production-blame-approval-weight.i18n.yaml | 8 +- ...-09-11-production-blame-approval-weight.md | 4 +- ...-11-production-blame-approval-weight.zh.md | 4 +- ...026-09-15-pr-approval-delegation.i18n.yaml | 4 +- .../2026-09-15-pr-approval-delegation.md | 2 +- .../2026-09-15-pr-approval-delegation.zh.md | 2 +- ...hted-approval-decision-check-run.i18n.yaml | 22 +++ ...29-weighted-approval-decision-check-run.md | 50 ++++++ ...weighted-approval-decision-check-run.zh.md | 50 ++++++ .github/review-ownership/README.md | 12 +- .github/review-ownership/check-approval.mjs | 66 ++++--- .../review-ownership/check-approval.test.mjs | 169 +++++++++++------- .github/workflows/weighted-approval.yml | 14 +- scripts/ci-workflow.spec.ts | 8 +- 20 files changed, 293 insertions(+), 218 deletions(-) delete mode 100644 .agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.i18n.yaml delete mode 100644 .agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.md delete mode 100644 .agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.zh.md create mode 100644 .agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md create mode 100644 .agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md diff --git a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.i18n.yaml deleted file mode 100644 index 3cf9104f6e..0000000000 --- a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.i18n.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-09-blocked-weighted-approvals-remain-pending.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/process/2026-09-09-blocked-weighted-approvals-remain-pending.md -/agent-note-blocked-weighted-approvals-remain-pending: - en: 6a5da25eb874aece - zh: 654aa967a9222501 -/agent-note-blocked-weighted-approvals-remain-pending/problem: - en: 0afdda30d02b666b - zh: 6624aa9c8c58ea0a -/agent-note-blocked-weighted-approvals-remain-pending/decision: - en: cf7ac63f422b4cfb - zh: 250385e42d546d30 -/agent-note-blocked-weighted-approvals-remain-pending/verification: - en: af674f57088d303a - zh: b9c42f04beec9be5 -/agent-note-blocked-weighted-approvals-remain-pending/alternatives-considered: - en: 13d5f67070bd3396 - zh: c75db3746d31272e -/agent-note-blocked-weighted-approvals-remain-pending/consequences: - en: 284f19608b0d449f - zh: 1d9bd95239ba6ffa diff --git a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.md b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.md deleted file mode 100644 index 50ab64dadc..0000000000 --- a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Blocked weighted approvals remain pending - -Status: implemented - -English | [中文](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md) - -## Problem - -The weighted approval commit status must distinguish an unmet merge condition from a failed policy evaluation. An effective `CHANGES_REQUESTED` review from a write-capable reviewer prevents a pull request from satisfying the approval policy, but it is a reversible review state rather than an evaluation failure. - -Publishing `failure` for that review state conflates the approval decision with the health of the publisher. It also treats one unmet policy condition differently from a draft pull request or insufficient approval points, which remain pending while contributors can resolve them. - -## Decision - -A completed weighted approval evaluation publishes `pending` when the pull request is a draft, has fewer than the required approval points, or has an effective `CHANGES_REQUESTED` review from a write-capable reviewer. A blocking review dominates the point total, so the status remains pending even when counted approvals reach the threshold. - -The evaluation publishes `success` only when the pull request is ready, the point threshold is met, and no blocking review exists. The separate `weighted approval publisher` Actions job reports whether evaluation and status publication completed. An evaluation failure publishes an `error` commit status and fails that job. - -## Verification - -[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case and the exact published `pending` payload. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the separate publisher job name. - -## Alternatives considered - -**Publish `failure` for a blocking review.** This keeps a visibly failed status until the review changes, but it represents an unmet and reversible merge condition as a malfunction and conflates policy outcome with publisher health. - -**Let approval points override a blocking review.** This makes the score the only success condition, but it permits a successful status while a write-capable reviewer's effective decision still requests changes. - -## Consequences - -Required-status branch rules block a pull request because `pending` does not satisfy the required status. Contributors can distinguish review work that remains from a failed approval evaluation, while the publisher job and `error` status retain the operational failure signal. - -Consumers do not receive a failed commit status solely because a blocking review exists. They must inspect the status description or effective reviews when they need to distinguish a blocker from other pending approval conditions. diff --git a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.zh.md b/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.zh.md deleted file mode 100644 index 58ab10b3ac..0000000000 --- a/.agents/notes/implemented/process/2026-09-09-blocked-weighted-approvals-remain-pending.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 阻塞中的加权批准保持 pending - -Status: implemented - -[English](2026-09-09-blocked-weighted-approvals-remain-pending.md) | 中文 - -## 问题 - -`weighted approval` commit status 必须区分尚未满足的合并条件与失败的策略评估。具有写权限的评审人所提交且仍然生效的 `CHANGES_REQUESTED` 评审会阻止 PR 满足批准策略,但它是一种可撤销的评审状态,而不是评估故障。 - -为这种评审状态发布 `failure` 会混淆批准决策与 publisher 的健康状态。它还会让一个尚未满足的策略条件区别于 draft PR 或批准点数不足;后两种情况在贡献者能够解决问题期间会保持 pending。 - -## 决策 - -完成的加权批准评估会在 PR 为 draft、批准点数少于要求,或具有写权限的评审人存在仍然生效的 `CHANGES_REQUESTED` 评审时发布 `pending`。阻塞性评审的优先级高于点数总和,因此即使计入的批准已经达到阈值,状态仍保持 pending。 - -只有在 PR 已进入 ready 状态、达到点数阈值且不存在阻塞性评审时,评估才发布 `success`。独立的 `weighted approval publisher` Actions job 报告评估和状态发布是否完成。评估故障会发布 `error` commit status,并使该 job 失败。 - -## 验证 - -[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景,以及准确发布的 `pending` payload。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定独立的 publisher job 名称。 - -## 考虑过的替代方案 - -**为阻塞性评审发布 `failure`。** 该方案会在评审改变之前保持明显的失败状态,但它会把尚未满足且可撤销的合并条件表示为故障,并混淆策略结果与 publisher 的健康状态。 - -**允许批准点数覆盖阻塞性评审。** 该方案会让分数成为唯一的成功条件,但也允许在具有写权限的评审人仍然有效地要求修改时发布成功状态。 - -## 后果 - -需要该状态的分支规则会阻止 PR,因为 `pending` 不满足必需状态。贡献者可以区分尚待处理的评审工作与失败的批准评估,而 publisher job 和 `error` 状态保留运行故障信号。 - -消费方不会仅因存在阻塞性评审而收到失败的 commit status。如果需要区分 blocker 与其他 pending 批准条件,它们必须检查状态描述或仍然生效的评审。 diff --git a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml index 521e943a63..1dfe18dbe3 100644 --- a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml @@ -15,5 +15,5 @@ en: f8fdd869a03ac5ff zh: c355eb36e9cba350 /agent-note-identify-approval-review-workflows-by-file-path/consequences: - en: 9f31e023a04a7849 - zh: 66831c450af4c88f + en: 3edc29dc7c926f64 + zh: a84264a691a6248b diff --git a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md index 77164134e8..de1e08ef78 100644 --- a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md +++ b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md @@ -22,4 +22,4 @@ The [approval publisher](../../../../.github/review-ownership/check-approval.mjs Run-title expansion does not prevent approval refreshes, while an unexpected workflow file still fails validation. Moving the review-event workflow requires updating the publisher's expected path. -[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) cover a numbered run name, invalid source paths and events, unsuccessful runs, invalid titles, and superseded heads. The [approval outcome policy](2026-09-09-blocked-weighted-approvals-remain-pending.md) continues to own pending and successful status semantics. +[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) cover a numbered run name, invalid source paths and events, unsuccessful runs, invalid titles, and superseded heads. The [approval decision check run](2026-09-29-weighted-approval-decision-check-run.md) continues to own the published conclusions. diff --git a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md index 736851d6f3..6b3aa3d359 100644 --- a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md +++ b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md @@ -22,4 +22,4 @@ GitHub 可能用展开后的 `run-name` 填充工作流运行的 `name`。审批 运行标题的展开不会阻止审批刷新,而非预期的工作流文件仍无法通过验证。移动评审事件工作流时,需要更新发布器预期的路径。 -[审批策略测试](../../../../.github/review-ownership/check-approval.test.mjs) 覆盖带编号的运行名称、无效的来源路径和事件、未成功的运行、无效标题以及已被替代的头提交。[审批结果策略](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md) 继续负责待定与成功状态的语义。 +[审批策略测试](../../../../.github/review-ownership/check-approval.test.mjs) 覆盖带编号的运行名称、无效的来源路径和事件、未成功的运行、无效标题以及已被替代的头提交。[审批决策 check run](2026-09-29-weighted-approval-decision-check-run.zh.md) 继续负责所发布的 conclusion。 diff --git a/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.i18n.yaml b/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.i18n.yaml index 9272a63484..683ee70272 100644 --- a/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.i18n.yaml @@ -9,14 +9,14 @@ en: 2dbd821b8d65b054 zh: 9bef2993db51a00d /agent-note-weight-approvals-by-changed-production-line-ownership/decision: - en: 7dc225f91538ace1 - zh: a22d05ffd8d32b6d + en: a0b790864eae2f43 + zh: aa2bd4ed8d569180 /agent-note-weight-approvals-by-changed-production-line-ownership/alternatives-considered: en: 5e8af03e3ff6018a zh: 6b518c8eee8c5172 /agent-note-weight-approvals-by-changed-production-line-ownership/consequences: - en: 08495483b77771be - zh: a798d4af0eda55fe + en: bcf33d4ce1d367c1 + zh: 0623ac49e33a59e6 /agent-note-weight-approvals-by-changed-production-line-ownership/verification: en: cb604625373372b9 zh: f9056d20ab612537 diff --git a/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md b/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md index d9a223c2f3..84ca47b4d8 100644 --- a/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md +++ b/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md @@ -12,7 +12,7 @@ A fixed one-point reviewer weight does not reflect authorship of the code a pull The [approval policy](../../../../.github/review-ownership/README.md) scales a one-point approval by `min(2, 1 + 4 × ownedLines / totalLines)` over changed old production lines. Ownership of 0% gives one point, 12.5% gives 1.5 points, and 25% or more gives two points. Scores are not rounded before comparison with the two-point success threshold. The merge base supplies both classification and blame, while GitHub associates blame commits with reviewer accounts. Unlinked authors remain in the denominator. Additions have no prior owner and contribute no lines; an empty denominator produces no boost. -The publisher marks the head pending before dependency setup and evaluation, so an interrupted history fetch cannot preserve an earlier success. It uses the live base branch and exact reviewed head, reads complete Git history without checking out PR code, and skips attribution when base points or blockers already decide the result. A maintained lexer separates comments from code across the repository’s source languages. Author lookups batch commits and all reviewers share one measurement. Existing [pending-status semantics](2026-09-09-blocked-weighted-approvals-remain-pending.md) and [review-event validation](2026-09-10-approval-review-workflow-identity.md) remain independent requirements. +The publisher marks the decision check run in progress before dependency setup and evaluation, so an interrupted history fetch cannot preserve an earlier success. It uses the live base branch and exact reviewed head, reads complete Git history without checking out PR code, and skips attribution when base points or blockers already decide the result. A maintained lexer separates comments from code across the repository’s source languages. Author lookups batch commits and all reviewers share one measurement. Existing [decision check-run semantics](2026-09-29-weighted-approval-decision-check-run.md) and [review-event validation](2026-09-10-approval-review-workflow-identity.md) remain independent requirements. ## Alternatives considered @@ -28,7 +28,7 @@ The publisher marks the head pending before dependency setup and evaluation, so History fetching dominates cold execution. Complete history is required before selecting the merge base: a shallow apparent ancestor can exclude changes from the denominator, so shallow classification cannot safely decide that blame is unnecessary. On 2026-09-11, local measurements of PRs #3969 and #3977 count 73 and 535 old production lines across 4 and 14 files. Three local runs take 0.31–0.32 seconds and 0.87–0.93 seconds; one batched author query adds approximately one second per PR. A fresh single-branch bare clone of master takes 47 seconds on the same host. These are host observations, not CI time guarantees. -Blame measures last-touch authorship, not review quality or semantic expertise. The production classifier excludes unsupported source locations and file extensions; adding shipped source outside its inventory requires extending that classifier. Lexer classification is lexical rather than a semantic test of executable behavior. Evaluation errors retain the error status instead of silently awarding or withholding the boost. +Blame measures last-touch authorship, not review quality or semantic expertise. The production classifier excludes unsupported source locations and file extensions; adding shipped source outside its inventory requires extending that classifier. Lexer classification is lexical rather than a semantic test of executable behavior. Evaluation errors conclude the check as `failure` instead of silently awarding or withholding the boost. ## Verification diff --git a/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.zh.md b/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.zh.md index f4dfc9d4fc..5c29e0938d 100644 --- a/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.zh.md +++ b/.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.zh.md @@ -12,7 +12,7 @@ Status: implemented [审批策略](../../../../.github/review-ownership/README.md) 按变更旧生产代码行的归属比例,以 `min(2, 1 + 4 × ownedLines / totalLines)` 调整一分批准的权重。归属比例为 0% 时计一分,12.5% 时计 1.5 分,25% 及以上时计两分。分数在与两分通过线比较前不做舍入。合并基点同时提供代码分类和 blame 依据,GitHub 将归属提交关联到评审者账号。无法关联账号的作者仍计入分母。新增行没有原作者,不计入行数;分母为空时不提升权重。 -发布器在依赖安装和评估前将头提交标记为待定,避免历史拉取中断后保留此前的成功状态。它使用实时 base 分支和被评审的精确 head,读取完整 Git 历史,不检出 PR 代码;基础分数或阻塞评审已决定结果时跳过归属计算。维护中的词法分析器区分仓库各源码语言中的注释与代码。作者查询按提交批量执行,所有评审者共用一次统计。现有的[待定状态语义](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md)和[评审事件验证](2026-09-10-approval-review-workflow-identity.zh.md)仍是独立要求。 +发布器在依赖安装和评估前将决策 check run 标记为进行中,避免历史拉取中断后保留此前的成功状态。它使用实时 base 分支和被评审的精确 head,读取完整 Git 历史,不检出 PR 代码;基础分数或阻塞评审已决定结果时跳过归属计算。维护中的词法分析器区分仓库各源码语言中的注释与代码。作者查询按提交批量执行,所有评审者共用一次统计。现有的[决策 check run 语义](2026-09-29-weighted-approval-decision-check-run.zh.md)和[评审事件验证](2026-09-10-approval-review-workflow-identity.zh.md)仍是独立要求。 ## Alternatives considered @@ -28,7 +28,7 @@ Status: implemented 冷启动的主要开销是拉取历史。选择合并基点前必须具备完整历史:浅历史中看似共同祖先的提交可能使部分变更被排除出分母,因此不能据此安全判定无需 blame。2026-09-11,在本机测量 PR #3969 和 #3977,得到 4 和 14 个文件中的 73 和 535 行旧生产代码。三次本地运行分别耗时 0.31–0.32 秒和 0.87–0.93 秒;每个 PR 的一次批量作者查询额外耗时约一秒。同一主机上,全新单分支裸克隆 master 耗时 47 秒。这些是本机观测值,不是 CI 耗时保证。 -Blame 衡量最后修改者归属,不代表评审质量或语义上的专业能力。生产代码分类器排除未支持的源码位置和扩展名;在其清单之外添加交付源码时,必须扩展分类器。词法分类不判断代码是否在语义上可执行。评估错误保留错误状态,不静默授予或取消加权。 +Blame 衡量最后修改者归属,不代表评审质量或语义上的专业能力。生产代码分类器排除未支持的源码位置和扩展名;在其清单之外添加交付源码时,必须扩展分类器。词法分类不判断代码是否在语义上可执行。评估错误使该检查以 `failure` 结束,不静默授予或取消加权。 ## Verification diff --git a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml index 14bf4d7016..50bca79957 100644 --- a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml @@ -9,8 +9,8 @@ en: 5fe2f3b3cb7ce046 zh: ed27d4efc995b8b5 /agent-note-pr-scoped-approval-delegation/decision: - en: 41c5c90b82d323c6 - zh: 55d5ed09d88bb821 + en: bf8f1ff30114310f + zh: a163e1276dd384f8 /agent-note-pr-scoped-approval-delegation/alternatives-considered: en: a6006217cb29613a zh: 83968328096f1d91 diff --git a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md index 4dcf0e8a8d..0282f9c058 100644 --- a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md +++ b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md @@ -10,7 +10,7 @@ A reviewer may trust another reviewer to decide a particular PR while retaining ## Decision -The [approval policy](../../../../.github/review-ownership/README.md#delegating-points) accepts `/delegate @username` in PR conversation comments. Each eligible sender's points follow the named recipient's effective approval, once, using the sender's weight. Delegation applies only to that PR and only between write-capable accounts other than the PR author. Received points cannot be forwarded. The command dismisses the sender's prior approvals and change requests through GitHub. Other effective blocking reviews still follow [blocking-review policy](2026-09-09-blocked-weighted-approvals-remain-pending.md). +The [approval policy](../../../../.github/review-ownership/README.md#delegating-points) accepts `/delegate @username` in PR conversation comments. Each eligible sender's points follow the named recipient's effective approval, once, using the sender's weight. Delegation applies only to that PR and only between write-capable accounts other than the PR author. Received points cannot be forwarded. The command dismisses the sender's prior approvals and change requests through GitHub. Other effective blocking reviews still follow [blocking-review policy](2026-09-29-weighted-approval-decision-check-run.md). The latest surviving eligible command in comment creation order determines the recipient. Edited commands require the latest editor to match the author’s immutable account ID; other writers cannot transfer or cancel that author’s points by editing a comment. A self-delegation or a subsequently submitted review restores the sender's own decision. Comment-only reviews also reclaim the points; pending reviews do not. Equal timestamps favor the review, and subsequent dismissal cannot revive the delegation. A fresh command after the review can delegate again. Edits and deletions recompute from current comments, including restoration of older commands. The trusted publisher filters conversation comment changes for `/delegate`, resolves the live open PR head, and reads comments and editor identity as API data. Every evaluation reconciles active eligible commands, dismissing prior decision reviews and requesting recipients unless already approved or requested. This covers replaced queued events and draft-to-ready transitions; other requested reviewers remain unchanged. Scores refresh after dismissal, which retains the old submission timestamps and cannot cancel delegation. Removing the recipient's approval removes counted points while retaining delegation for their next approval. Drafts do not dismiss or request reviews. Dismissal failures fail evaluation; request failures are logged separately and retried on later evaluations. Closed or merged PRs are skipped before status writes and dependency setup. It preserves the separate [review-workflow validation](2026-09-10-approval-review-workflow-identity.md). diff --git a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md index bf21724d67..7d26a42d0d 100644 --- a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md @@ -10,7 +10,7 @@ Status: implemented ## Decision -[批准策略](../../../../.github/review-ownership/README.md#delegating-points) 接受 PR 普通评论中的 `/delegate @username`。每位符合资格的委托人,其分数跟随指定受托人的有效批准,按委托人的权重计入一次。委托仅适用于当前 PR,且双方都必须具有写权限,不能是 PR 作者。收到的分数不能继续转交。命令会通过 GitHub 撤销委托人原有的批准和变更请求。其他有效阻塞评审仍遵循[阻塞评审策略](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md)。 +[批准策略](../../../../.github/review-ownership/README.md#delegating-points) 接受 PR 普通评论中的 `/delegate @username`。每位符合资格的委托人,其分数跟随指定受托人的有效批准,按委托人的权重计入一次。委托仅适用于当前 PR,且双方都必须具有写权限,不能是 PR 作者。收到的分数不能继续转交。命令会通过 GitHub 撤销委托人原有的批准和变更请求。其他有效阻塞评审仍遵循[阻塞评审策略](2026-09-29-weighted-approval-decision-check-run.zh.md)。 按评论创建顺序,最新、仍存在且符合资格的命令决定受托人。编辑过的命令要求最近编辑者与作者的不可变账户 ID 一致;其他有写权限的人不能通过编辑评论转交或取消作者的分数。委托给自己或随后提交评审会恢复使用委托人本人的评审决定。仅评论的评审也会收回分数;尚未提交的评审不会。时间戳相同时以评审为准,随后撤销评审也不会重新启用委托。评审之后的新命令可以再次委托。编辑和删除会根据当前评论重算,包括恢复较早的命令。可信发布器按 `/delegate` 筛选普通评论变更,解析实时且未关闭的 PR 头提交,并将评论及编辑者身份作为 API 数据读取。每次评估都会处理所有有效且符合资格的命令,撤销旧的决定性评审,并请求尚未批准、尚未收到请求的受托人进行评审。这覆盖了排队事件被替换以及草稿转为 ready 的情况;其他 requested reviewers 保持不变。撤销后刷新分数;撤销保留旧提交时间戳,不会取消委托。移除受托人的批准只会移除计入的分数,委托仍然保留,供其下次批准使用。草稿 PR 不撤销或请求评审。撤销失败会使评估失败;请求失败单独记录日志,并在后续评估中重试。已关闭或合并的 PR 会在写入状态和安装依赖之前跳过。独立的[评审工作流验证](2026-09-10-approval-review-workflow-identity.zh.md) 保持不变。 diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml new file mode 100644 index 0000000000..e3d242d672 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-29-weighted-approval-decision-check-run.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/process/2026-09-29-weighted-approval-decision-check-run.md +/agent-note-weighted-approval-decisions-are-completed-check-runs: + en: 1b120e96b0f5f340 + zh: ff20c20fe0558964 +/agent-note-weighted-approval-decisions-are-completed-check-runs/problem: + en: 6ba6f6a15a1562ec + zh: 1cbef3e736c258e6 +/agent-note-weighted-approval-decisions-are-completed-check-runs/decision: + en: 933e4c1438771907 + zh: f6056382f3b555c9 +/agent-note-weighted-approval-decisions-are-completed-check-runs/verification: + en: d1f65b5b577b08b8 + zh: 7a193cec1acec003 +/agent-note-weighted-approval-decisions-are-completed-check-runs/alternatives-considered: + en: a7ef91c0506766a8 + zh: d6584e31652b14ca +/agent-note-weighted-approval-decisions-are-completed-check-runs/consequences: + en: 4cafdf12b326c68a + zh: c37a57d26d2f0894 diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md new file mode 100644 index 0000000000..f4d8f9e639 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md @@ -0,0 +1,50 @@ +# Agent Note: Weighted approval decisions are completed check runs + +Status: implemented + +English | [中文](2026-09-29-weighted-approval-decision-check-run.zh.md) + +## Problem + +The weighted approval policy must block merging until its conditions hold, distinguish an unmet condition from a failed evaluation, and let tools that wait for pull-request checks to finish terminate. A draft pull request, insufficient approval points, and an effective `CHANGES_REQUESTED` review from a write-capable reviewer are reversible review states rather than evaluation failures. + +Commit statuses offer only `pending`, `success`, `failure`, and `error`. A `pending` commit status blocks merging, but `gh pr checks --watch` and other CI watchers treat it as running work and wait until someone approves. A `failure` commit status ends the wait but reports an unmet, reversible condition as a malfunction and conflates the policy outcome with publisher health. + +## Decision + +The [publisher](../../../../.github/review-ownership/check-approval.mjs) reports the decision as the `weighted approval decision` check run on the pull-request head, written with the job's GitHub Actions token. Branch rules require that check run with GitHub Actions as its expected source. + +| Condition | Check run | +|---|---| +| Dependency setup or evaluation in progress | `in_progress` | +| Draft, fewer than the required points, or an effective write-capable `CHANGES_REQUESTED` review | `completed`, conclusion `action_required` | +| Ready, threshold met, and no blocking review | `completed`, conclusion `success` | +| Setup or evaluation failure | `completed`, conclusion `failure` | + +A blocking review dominates the point total, so the conclusion remains `action_required` even when counted approvals reach the threshold. GitHub accepts only `success`, `neutral`, and `skipped` for a required check, so `action_required` blocks merging while completing the check. + +The publisher lists the head's check runs with that name, updates every run owned by GitHub Actions, and creates one only when none exists. An incomplete list or a run without a valid ID fails the publication. + +## Verification + +[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case, the exact published check-run payloads, updating an existing run instead of creating another, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` permission, the `in_progress` and `failure` phase commands, and the separate publisher job name. + +## Alternatives considered + +**Keep the `pending` commit status.** Branch rules block correctly, but CI watchers cannot distinguish an approval wait from running checks and do not finish. + +**Publish `failure` for unmet conditions.** This ends the wait, but it represents an unmet, reversible merge condition as a malfunction and conflates the policy outcome with publisher health. + +**Publish nothing until success.** A required check that was never reported remains expected and is invisible to many watchers. Revoking a success already published on the same head still requires a non-success write, and each watcher's treatment of a missing required check is unspecified. + +**Keep the `weighted approval` name.** When a check and a commit status share a required name, GitHub requires both to pass. Heads that carry a `pending` commit status from the earlier publisher would stay blocked until a new push. + +**Create a new check run on every evaluation.** Several same-name check runs on one head make the result depend on how GitHub and each watcher select among them. + +**Let approval points override a blocking review.** This makes the score the only success condition, but it permits success while a write-capable reviewer's effective decision still requests changes. + +## Consequences + +CI watchers finish while the decision waits on reviewers. `gh pr checks` groups `action_required` with failed checks and exits non-zero; consumers distinguish it by the check name, the `ACTION_REQUIRED` conclusion, or the output title, which carries the score or blocker. The `weighted approval publisher` Actions job and the `failure` conclusion carry operational failures. + +Revoking an earlier success on the same head returns the completed check run to `in_progress`, a transition the GitHub REST reference does not restrict. diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md new file mode 100644 index 0000000000..4dee85c8f7 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 加权批准决策使用已完成的 check run + +Status: implemented + +[English](2026-09-29-weighted-approval-decision-check-run.md) | 中文 + +## 问题 + +加权批准策略必须在条件满足之前阻止合并,区分尚未满足的条件与失败的评估,并让等待 PR 检查结束的工具能够退出。draft PR、批准点数不足,以及具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审,都是可撤销的评审状态,而不是评估故障。 + +commit status 只提供 `pending`、`success`、`failure` 和 `error`。`pending` commit status 会阻止合并,但 `gh pr checks --watch` 和其他 CI 监视工具把它当作仍在运行的工作,会一直等到有人批准。`failure` commit status 能结束等待,但会把尚未满足且可撤销的条件报告为故障,并混淆策略结果与 publisher 的健康状态。 + +## 决策 + +[publisher](../../../../.github/review-ownership/check-approval.mjs) 使用该 job 的 GitHub Actions token,在 PR head 上以 `weighted approval decision` check run 报告决策。分支规则要求该 check run,并把 GitHub Actions 设为预期来源。 + +| 条件 | Check run | +|---|---| +| 依赖安装或评估进行中 | `in_progress` | +| draft、点数少于要求,或存在具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审 | `completed`,conclusion 为 `action_required` | +| 已 ready、达到阈值且没有阻塞性评审 | `completed`,conclusion 为 `success` | +| 安装或评估失败 | `completed`,conclusion 为 `failure` | + +阻塞性评审的优先级高于点数总和,因此即使计入的批准已经达到阈值,conclusion 仍为 `action_required`。GitHub 只接受 `success`、`neutral` 和 `skipped` 作为必需检查的通过结果,因此 `action_required` 会阻止合并,同时让该检查处于完成状态。 + +publisher 列出 head 上同名的 check run,更新所有属于 GitHub Actions 的 run,只有在不存在时才创建一个。列表不完整或 run 没有有效 ID 时发布失败。 + +## 验证 + +[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景、准确发布的 check run payload、更新已有 run 而不是另建一个,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 权限、`in_progress` 和 `failure` 阶段命令,以及独立的 publisher job 名称。 + +## 考虑过的替代方案 + +**保留 `pending` commit status。** 分支规则能正确阻止合并,但 CI 监视工具无法区分等待批准与仍在运行的检查,因此不会结束。 + +**为尚未满足的条件发布 `failure`。** 该方案能结束等待,但会把尚未满足且可撤销的合并条件表示为故障,并混淆策略结果与 publisher 的健康状态。 + +**在成功之前不发布任何结果。** 从未上报的必需检查保持 expected 状态,许多监视工具看不到它。撤销同一 head 上已发布的成功仍然需要写入非成功结果,而各监视工具如何对待缺失的必需检查没有规定。 + +**保留 `weighted approval` 名称。** 当 check 和 commit status 使用同一个必需名称时,GitHub 要求两者都通过。带有旧 publisher 所写 `pending` commit status 的 head 会一直被阻止,直到出现新的 push。 + +**每次评估都创建新的 check run。** 同一 head 上存在多个同名 check run 时,结果取决于 GitHub 和各监视工具如何在其中选择。 + +**允许批准点数覆盖阻塞性评审。** 该方案会让分数成为唯一的成功条件,但也允许在具有写权限的评审人仍然有效地要求修改时发布成功结果。 + +## 后果 + +决策等待评审人时,CI 监视工具会结束。`gh pr checks` 把 `action_required` 与失败的检查归为一组并以非零状态退出;消费方可以通过 check 名称、`ACTION_REQUIRED` conclusion 或携带分数或 blocker 的输出标题加以区分。`weighted approval publisher` Actions job 和 `failure` conclusion 承载运行故障信号。 + +撤销同一 head 上此前的成功结果时,已完成的 check run 会回到 `in_progress`;GitHub REST 参考文档没有限制这一转换。 diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index 2da4c5e2cd..7b461ae864 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -16,11 +16,11 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes ## Approval scoring -The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. +The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and check publication completed, while the `weighted approval decision` check run carries the approval decision on the pull request head. Branch rules must require that check run with GitHub Actions as its expected source; a name-only requirement can accept a same-named check or status from another integration. The publisher updates the head's existing GitHub Actions decision check run and creates one only when none exists. It marks the check run `in_progress` before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures conclude it as `failure`. A completed evaluation concludes `action_required` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps that conclusion even when counted approvals reach the threshold. It concludes `success` only when the threshold is met, the pull request is ready, and no such blocker exists. `action_required` blocks merging but completes the check, so tools that wait for checks to finish do not wait for reviewers; the check-run title carries the score or blocker. Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count. -The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error. +The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup concludes the check as `failure`. A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when reviewer points plus author credit already meet the threshold or a blocking review exists. Displayed scores use at most three decimal places; the decision uses unrounded scores with a tolerance of `1e-12` points for floating-point error. The curve endpoints come from the policy’s default and required points. @@ -28,13 +28,13 @@ Production source means supported code files under `src/` in `packages/`, `apps/ Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements. -The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before status writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though approval statuses target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event. +The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before check-run writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though decision check runs target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the check run. Permission changes take effect on the next subscribed event. ## Delegating points -Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal publishes an error and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests. +Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal concludes the check as `failure` and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests. Both accounts must currently have write or admin permission, and neither may be the PR author. Commands involving ineligible accounts are ignored. A sender's newest surviving eligible, author-authorized command wins, in comment creation order; editing an older comment does not move it after newer comments. Submitting any review after delegation automatically takes the points back, including an approval, change request, or comment-only review. Submitting an inline diff comment with “Add single comment” also creates a comment-only review and reclaims the points. Pending reviews do not count; a later dismissal does not restore the delegation. When timestamps are equal, the review takes precedence. A new command after the review can delegate again; editing an older command cannot reactivate it. Post `/delegate @your-own-username` to restore your own review decision. Editing or deleting a command recomputes delegation from remaining comments, so deleting the latest command can restore an older one. Quoted commands, code blocks, review bodies, inline review comments, and commands mixed with other text do not count. Surrounding whitespace is allowed; account matching is case-insensitive. An edited command is accepted only when GitHub identifies the original author as its latest editor; edits by other accounts cannot delegate or cancel that author’s points. @@ -44,7 +44,7 @@ Each sender contributes at most once, using their own fixed weight or production ## Security -All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision. +All actions in the check-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures conclude the check as `failure`; request failures remain separate from the approval decision. Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run. @@ -52,7 +52,7 @@ Approval policy changes take effect only after they merge into the default branc ## Verification -Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration. +Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, check-run publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration. diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs index 5792299c46..489acd1779 100644 --- a/.github/review-ownership/check-approval.mjs +++ b/.github/review-ownership/check-approval.mjs @@ -10,7 +10,7 @@ import { authorCreditPoints, countMergedAuthorPulls } from './author-weight.mjs' const API_VERSION = '2026-03-10' const MAX_PULL_REQUEST_RECORDS = 3_000 const PAGE_SIZE = 100 -const STATUS_CONTEXT = 'weighted approval' +const CHECK_NAME = 'weighted approval decision' const WRITABLE_PERMISSIONS = new Set(['admin', 'write']) const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) @@ -209,13 +209,13 @@ function timestamp(value, subject) { /** * Evaluate approval points from current reviews and repository permissions. * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs. - * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated. + * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'action_required' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and check-run output fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated. */ export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) { const pull = pullRequestFromEvent(event) const policy = parseApprovalPolicy(policySource) if (pull.draft) { - return approvalResult(pull, policy.requiredPoints, [], [], [], 'pending', 'draft pull request') + return approvalResult(pull, policy.requiredPoints, [], [], [], 'action_required', 'draft pull request') } const reviews = await listPullRequestReviews(api, pull.repository, pull.number) @@ -286,11 +286,11 @@ export async function evaluateApproval({ event, policySource, api, getOwnership return next }, authorCredit?.points ?? 0) if (blockers.length > 0) { - return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending', + return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'action_required', `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit, activeDelegations) } // Tolerate floating-point addition error without rounding approval scores. - const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending' + const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'action_required' return approvalResult( pull, policy.requiredPoints, @@ -305,13 +305,13 @@ export async function evaluateApproval({ event, policySource, api, getOwnership } /** - * Evaluate and publish the required commit status, publishing an error status when evaluation fails. + * Evaluate and publish the required check run, concluding it as failure when evaluation fails. * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs. * @returns {Promise>>} Published approval decision. */ export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) { const pull = pullRequestFromEvent(event) - await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl) + await publishCheck(api, pull, 'in_progress', 'Evaluating approval points.', runUrl) let result try { result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount }) @@ -331,7 +331,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount }) } } catch (error) { - await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl) + await publishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl) throw error } write(result.authorCredit @@ -342,8 +342,8 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw `@${login}: ${points}${delegatedTo ? ` (delegated to @${delegatedTo})` : ''}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`)) writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`)) writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`)) - await publishStatus(api, pull, result.state, result.description, runUrl) - write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`) + await publishCheck(api, pull, result.state, result.description, runUrl) + write(`Published ${JSON.stringify(CHECK_NAME)} check run ${JSON.stringify(result.state)}.`) try { await requestDelegatedReviews(result, api, write) } catch (error) { @@ -353,14 +353,14 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw } /** - * Revoke a previous success before dependency installation, or report its failure. - * @param {{event: unknown, api: (path: string, options: object) => Promise, runUrl: string, phase: string}} options Publication inputs. - * @returns {Promise} Completion of the status write. + * Revoke a previous conclusion before dependency installation, or report its failure. + * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, runUrl: string, phase: string}} options Publication inputs. + * @returns {Promise} Completion of the check-run write. */ export async function publishApprovalPhase({ event, api, runUrl, phase }) { - if (!['pending', 'error'].includes(phase)) throw new Error('invalid approval setup phase') - await publishStatus(api, pullRequestFromEvent(event), phase, - phase === 'pending' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl) + if (!['in_progress', 'failure'].includes(phase)) throw new Error('invalid approval setup phase') + await publishCheck(api, pullRequestFromEvent(event), phase, + phase === 'in_progress' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl) } /** @@ -414,7 +414,7 @@ export async function approvalEventFromComment({ event, api }) { } /** - * Refresh a pull-request event before any status write; closed PRs and stale heads are skipped. + * Refresh a pull-request event before any check-run write; closed PRs and stale heads are skipped. * @param {{event: unknown, api: (path: string) => Promise}} options Pull-request event and API caller. * @returns {Promise | null>} Live event, or null for a closed PR or superseded head. */ @@ -467,18 +467,26 @@ async function reviewerPermission(api, repository, login) { return response.permission.toLowerCase() } -async function publishStatus(api, pull, state, description, runUrl) { +// Updating the head's existing GitHub Actions check run keeps one decision per head for branch rules and CI watchers. +async function publishCheck(api, pull, state, title, runUrl) { if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS') - if (description.length > 140) throw new Error('commit status description exceeds 140 characters') - await api(`/repos/${pull.repository}/statuses/${pull.headSha}`, { - method: 'POST', - body: { - state, - context: STATUS_CONTEXT, - description, - target_url: runUrl, - }, - }) + const body = { + ...(state === 'in_progress' ? { status: state } : { status: 'completed', conclusion: state }), + details_url: runUrl, + output: { title, summary: `${title} [Workflow run](${runUrl})` }, + } + const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=${PAGE_SIZE}`) + if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) { + throw new Error('check-run list response is invalid or incomplete') + } + const runs = listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions') + if (runs.length === 0) { + await api(`/repos/${pull.repository}/check-runs`, { method: 'POST', body: { name: CHECK_NAME, head_sha: pull.headSha, ...body } }) + } + for (const { id } of runs) { + if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id') + await api(`/repos/${pull.repository}/check-runs/${id}`, { method: 'PATCH', body }) + } } function pullRequestFromEvent(event) { @@ -571,7 +579,7 @@ async function main() { const phase = process.argv[2] if (phase) { await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase }) - if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n') + if (phase === 'in_progress' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n') return } await runApprovalCheck({ diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs index b5d0b6cbb3..186e97d1b3 100644 --- a/.github/review-ownership/check-approval.test.mjs +++ b/.github/review-ownership/check-approval.test.mjs @@ -11,14 +11,18 @@ import { evaluateApproval as evaluateWithHistory, listPullRequestReviews, parseApprovalPolicy, - runApprovalCheck as runWithHistory, + runApprovalCheck as runPublisher, publishApprovalPhase, } from './check-approval.mjs' const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8') -const withoutComments = api => (path, options) => path.includes('/comments?') ? [] : api(path, options) -const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) }) -const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) }) +const withoutCheckRuns = api => (path, options) => + path.includes('/check-runs?') ? { total_count: 0, check_runs: [] } : api(path, options) +const withEmptyReads = api => withoutCheckRuns((path, options) => path.includes('/comments?') ? [] : api(path, options)) +const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) }) +const runWithHistory = options => runPublisher({ ...options, api: withoutCheckRuns(options.api) }) +const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) }) +const publishedState = body => body.conclusion ?? body.status const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678' const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({ @@ -198,7 +202,7 @@ test('accepts two one-point approvals and ignores reviews without write access', assert.deepEqual(result.ignoredReviewers, ['reader']) }) -test('keeps one one-point approval pending without failing the status', async () => { +test('requires action for one one-point approval without failing the check', async () => { const result = await evaluateApproval({ event: pullRequestEvent(), policySource, @@ -209,7 +213,7 @@ test('keeps one one-point approval pending without failing the status', async () throw new Error(`unexpected API path ${path}`) }, }) - assert.equal(result.state, 'pending') + assert.equal(result.state, 'action_required') assert.equal(result.points, 1) }) @@ -228,11 +232,11 @@ test('ignores a reviewer whose collaborator permission lookup returns 404', asyn }, }) const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api }) - assert.equal(result.state, 'pending') + assert.equal(result.state, 'action_required') assert.deepEqual(result.ignoredReviewers, ['former-writer']) }) -test('keeps the status pending on a write-capable change request while ignoring the author and read-only reviewers', async () => { +test('requires action on a write-capable change request while ignoring the author and read-only reviewers', async () => { const statuses = [] const result = await runApprovalCheck({ event: pullRequestEvent({ author: 'author' }), @@ -251,7 +255,7 @@ test('keeps the status pending on a write-capable change request while ignoring if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' } if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' } if (path.includes('/collaborators/reader/permission')) return { permission: 'read' } - if (path.includes('/statuses/')) { + if (path.includes('/check-runs')) { statuses.push(options.body) return {} } @@ -259,32 +263,35 @@ test('keeps the status pending on a write-capable change request while ignoring }, write: () => {}, }) - assert.equal(result.state, 'pending') + assert.equal(result.state, 'action_required') assert.equal(result.description, '1 blocking change request.') assert.equal(result.points, 2) assert.deepEqual(result.blockers, ['blocker']) assert.deepEqual(result.ignoredReviewers, ['reader']) assert.deepEqual(statuses, [{ - state: 'pending', - context: 'weighted approval', - description: 'Evaluating approval points.', - target_url: 'https://github.example/actions/runs/1', + name: 'weighted approval decision', + head_sha: HEAD_SHA, + status: 'in_progress', + details_url: 'https://github.example/actions/runs/1', + output: { title: 'Evaluating approval points.', summary: 'Evaluating approval points. [Workflow run](https://github.example/actions/runs/1)' }, }, { - state: 'pending', - context: 'weighted approval', - description: '1 blocking change request.', - target_url: 'https://github.example/actions/runs/1', + name: 'weighted approval decision', + head_sha: HEAD_SHA, + status: 'completed', + conclusion: 'action_required', + details_url: 'https://github.example/actions/runs/1', + output: { title: '1 blocking change request.', summary: '1 blocking change request. [Workflow run](https://github.example/actions/runs/1)' }, }]) }) -test('keeps drafts pending without reading reviews', async () => { +test('requires action on drafts without reading reviews', async () => { const result = await evaluateApproval({ event: pullRequestEvent({ draft: true }), policySource, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), api: async () => { throw new Error('draft evaluation must not call GitHub') }, }) - assert.equal(result.state, 'pending') + assert.equal(result.state, 'action_required') assert.equal(result.points, 0) assert.match(result.description, /draft pull request/u) }) @@ -301,21 +308,23 @@ test('publishes the required status and replaces stale success with error on eva calls.push({ path, options }) if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')] if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' } - if (path.includes('/statuses/')) return {} + if (path.includes('/check-runs')) return {} throw new Error(`unexpected API path ${path}`) }, write: line => output.push(line), }) assert.equal(result.state, 'success') assert.deepEqual(calls.at(-1), { - path: `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`, + path: '/repos/deepseek-harness/deepseek-harness/check-runs', options: { method: 'POST', body: { - state: 'success', - context: 'weighted approval', - description: '2/2 approval points.', - target_url: 'https://github.example/actions/runs/1', + name: 'weighted approval decision', + head_sha: HEAD_SHA, + status: 'completed', + conclusion: 'success', + details_url: 'https://github.example/actions/runs/1', + output: { title: '2/2 approval points.', summary: '2/2 approval points. [Workflow run](https://github.example/actions/runs/1)' }, }, }, }) @@ -330,7 +339,7 @@ test('publishes the required status and replaces stale success with error on eva runUrl: 'https://github.example/actions/runs/2', api: async (path, options = {}) => { if (path.includes('/reviews?')) throw new Error('reviews unavailable') - if (path.includes('/statuses/')) { + if (path.includes('/check-runs')) { failures.push({ path, options }) return {} } @@ -338,9 +347,9 @@ test('publishes the required status and replaces stale success with error on eva }, write: () => {}, }), /reviews unavailable/u) - assert.equal(failures[0].options.body.state, 'pending') - assert.equal(failures[1].options.body.state, 'error') - assert.equal(failures[1].options.body.description, 'Approval evaluation failed.') + assert.equal(publishedState(failures[0].options.body), 'in_progress') + assert.equal(publishedState(failures[1].options.body), 'failure') + assert.equal(failures[1].options.body.output.title, 'Approval evaluation failed.') }) test('sends authenticated JSON and escapes an API error body', async () => { @@ -383,7 +392,7 @@ for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999 }) assert.equal(result.points, expectedPoints) assert.equal(result.description, `${Number(expectedPoints.toFixed(3))}/2 approval points (author 0).`) - assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending') + assert.equal(result.state, expectedPoints === 2 ? 'success' : 'action_required') assert.equal(measurements, 1) assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines }) }) @@ -403,7 +412,7 @@ test('does not fetch ownership when a change request blocks approval', async () }) assert.equal(measurements, 0) assert.equal(result.points, 2) - assert.equal(result.state, 'pending') + assert.equal(result.state, 'action_required') }) test('does not fetch history when approvals already have two-point weights', async () => { @@ -424,11 +433,11 @@ test('publishes error when production attribution fails', async () => { if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] if (path.includes('/permission')) return { permission: 'write' } if (path.includes('/comments?')) return [] - states.push(options.body.state) + states.push(publishedState(options.body)) return {} }, }), /incomplete history/u) - assert.deepEqual(states, ['pending', 'error']) + assert.deepEqual(states, ['in_progress', 'failure']) }) @@ -437,17 +446,17 @@ test('revokes a previous success before starting expensive attribution', async ( await runApprovalCheck({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {}, getOwnership: async () => { - assert.deepEqual(states, ['pending']) + assert.deepEqual(states, ['in_progress']) return { totalLines: 100, reviewerLines: { writer: 25 } } }, api: async (path, options) => { if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] if (path.includes('/permission')) return { permission: 'write' } - states.push(options.body.state) + states.push(publishedState(options.body)) return {} }, }) - assert.deepEqual(states, ['pending', 'success']) + assert.deepEqual(states, ['in_progress', 'success']) }) for (const reviewers of [['first', 'second'], ['turtle1999', 'first']]) { @@ -477,14 +486,38 @@ test('publishes setup phases without evaluating or installing dependencies', asy const states = [] const options = { event: pullRequestEvent(), runUrl: 'https://github.example/run/1', - api: async (path, { body }) => { assert.match(path, /\/statuses\//u); states.push(body.state) }, + api: withEmptyReads(async (path, { body }) => { assert.match(path, /\/check-runs$/u); states.push(publishedState(body)) }), } - await publishApprovalPhase({ ...options, phase: 'pending' }) - await publishApprovalPhase({ ...options, phase: 'error' }) - assert.deepEqual(states, ['pending', 'error']) + await publishApprovalPhase({ ...options, phase: 'in_progress' }) + await publishApprovalPhase({ ...options, phase: 'failure' }) + assert.deepEqual(states, ['in_progress', 'failure']) await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u) }) +test('updates the head\'s GitHub Actions decision check run instead of creating another', async () => { + const calls = [] + const checkRuns = [ + { id: 7, app: { slug: 'github-actions' } }, + { id: 8, app: { slug: 'other-app' } }, + ] + await publishApprovalPhase({ + event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'in_progress', + api: async (path, options) => { + calls.push({ path, method: options?.method }) + return { total_count: checkRuns.length, check_runs: checkRuns } + }, + }) + assert.deepEqual(calls, [ + { path: `/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`, method: undefined }, + { path: '/repos/deepseek-harness/deepseek-harness/check-runs/7', method: 'PATCH' }, + ]) + for (const response of [{}, { total_count: 101, check_runs: [] }, { total_count: 1, check_runs: [{ id: '7', app: { slug: 'github-actions' } }] }]) { + await assert.rejects(publishApprovalPhase({ + event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure', api: async () => response, + }), /check-run list response|valid id/u) + } +}) + for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) { test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => { const result = await evaluateApproval({ @@ -493,7 +526,7 @@ for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089] }) assert.equal(result.points, credit) assert.equal(result.authorCredit.points, credit) - assert.equal(result.state, 'pending') + assert.equal(result.state, 'action_required') assert.deepEqual(result.approvals, []) }) } @@ -509,7 +542,7 @@ test('combines author credit and reviewer ownership at the passing threshold', a } }) -for (const [mergedCount, state] of [[90, 'pending'], [91, 'success'], [100, 'success']]) { +for (const [mergedCount, state] of [[90, 'action_required'], [91, 'success'], [100, 'success']]) { test(`one ordinary approval with ${mergedCount} merged PRs is ${state}`, async () => { const result = await evaluateApproval({ event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount, @@ -532,7 +565,7 @@ test('blockers skip history and ignore non-write reviewers', async () => { ? [review('writer', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED'), review('reader', 'APPROVED')] : { permission: path.includes('/reader/') ? 'read' : 'write' }, }) - assert.equal(result.state, 'pending') + assert.equal(result.state, 'action_required') assert.equal(result.points, 1) assert.equal(result.authorCredit, null) assert.deepEqual(result.blockers, ['blocker']) @@ -550,20 +583,20 @@ test('drafts skip history and history failures revoke success with error', async getMergedCount: async () => { throw new Error('history unavailable') }, api: async (path, options) => { if (path.includes('/reviews?')) return [] - states.push(options.body.state) + states.push(publishedState(options.body)) }, }), /history unavailable/u) - assert.deepEqual(states, ['pending', 'error']) + assert.deepEqual(states, ['in_progress', 'failure']) }) -test('a score just below the threshold remains pending even if its display rounds to two', async () => { +test('a score just below the threshold requires action even if its display rounds to two', async () => { const result = await evaluateApproval({ event: pullRequestEvent(), policySource, getMergedCount: async () => 50, getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 112499 } }), api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' }, }) - assert.equal(result.state, 'pending') + assert.equal(result.state, 'action_required') assert.ok(result.points < 2) }) @@ -587,14 +620,14 @@ test('the publisher counts merged history through the production API path', asyn } if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] if (path.includes('/permission')) return { permission: 'write' } - states.push(options.body.state) + states.push(publishedState(options.body)) return {} }, }) assert.equal(result.points, 2) assert.equal(result.authorCredit.points, 0.55) assert.equal(result.approvals[0].points, 1.45) - assert.deepEqual(states, ['pending', 'success']) + assert.deepEqual(states, ['in_progress', 'success']) assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).') }) @@ -612,7 +645,7 @@ test('sufficient reviewer points and drafts publish without querying author hist return {} }, }) - assert.equal(result.state, draft ? 'pending' : 'success') + assert.equal(result.state, draft ? 'action_required' : 'success') assert.equal(result.authorCredit, null) assert.equal(output[0], `Author credit: not evaluated (${draft ? 'draft' : 'reviewer points suffice'}).`) } @@ -678,7 +711,7 @@ test('delegates the sender fixed points once after the recipient approves, retai }) }) const points = recipientState === 'APPROVED' ? 3 : 0 assert.equal(result.points, points) - assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'pending') + assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'action_required') assert.deepEqual(result.blockers, [ ...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []), ...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []), @@ -786,7 +819,7 @@ test('a sender submitted review takes back delegation, including comment-only an }) }) assert.equal(result.delegations.length, state === 'PENDING' ? 1 : 0) assert.equal(result.points, state === 'APPROVED' || state === 'PENDING' ? 3 : 1) - assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'pending') + assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'action_required') if (state === 'APPROVED') assert.equal(result.approvals.find(({ login }) => login === 'Turtle1999').delegatedTo, undefined) } } @@ -829,7 +862,7 @@ test('an active delegate command requests review once and preserves other reques if (options?.method === 'POST') { requests.push(options.body); return {} } return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] } } - if (path.includes('/statuses/')) return {} + if (path.includes('/check-runs')) return {} return api(path, options) }, }) @@ -857,7 +890,7 @@ test('inactive commands and drafts do not request review', async () => { policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {}, api: async (path, options) => { assert.ok(!path.endsWith('/requested_reviewers')) - if (path.includes('/statuses/')) return {} + if (path.includes('/check-runs')) return {} return api(path, options) }, }) @@ -873,7 +906,7 @@ test('failed review requests preserve the computed score and report the retry', event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), write: line => output.push(line), api: async (path, options) => { - if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} } + if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} } if (path.endsWith('/requested_reviewers')) { if (options?.method === 'POST') throw new Error('request rejected') return failedRead ? {} : { users: [] } @@ -883,7 +916,7 @@ test('failed review requests preserve the computed score and report the retry', }) assert.match(output.at(-1), failedRead ? /no users array/u : /request rejected/u) assert.match(output.at(-1), /score is unchanged.*will retry/u) - assert.deepEqual(statuses, ['pending', 'success']) + assert.deepEqual(statuses, ['in_progress', 'success']) } }) @@ -895,7 +928,7 @@ test('delegation dismisses only the sender old decisions and stays active across const states = [] const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews }) const api = async (path, options) => { - if (path.includes('/statuses/')) { states.push(options.body.state); return {} } + if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} } if (path.endsWith('/dismissals')) { calls.push(options) const id = Number(path.split('/').at(-2)) @@ -919,13 +952,13 @@ test('delegation dismisses only the sender old decisions and stays active across assert.deepEqual(result.blockers, ['other']) assert.equal(result.delegations.length, 1) assert.deepEqual(result.delegations[0].reviewIds, []) - assert.deepEqual(states, ['pending', 'pending']) + assert.deepEqual(states, ['in_progress', 'action_required']) reviews.find(review => review.id === 21).state = 'DISMISSED' for (const state of ['APPROVED', 'DISMISSED', 'COMMENTED', 'APPROVED']) { reviews.find(review => review.id === 20).state = state const next = await evaluateDelegation({ api }) assert.equal(next.delegations.length, 1) - assert.equal(next.state, state === 'APPROVED' ? 'success' : 'pending') + assert.equal(next.state, state === 'APPROVED' ? 'success' : 'action_required') } reviews.push(review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z', 30)) const reclaimed = await evaluateDelegation({ api }) @@ -939,7 +972,7 @@ test('a new sender review arriving during dismissal is not dismissed or delegate const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0, api: async (path, options) => { - if (path.includes('/statuses/')) return {} + if (path.includes('/check-runs')) return {} if (path.endsWith('/dismissals')) { assert.ok(path.endsWith('/reviews/10/dismissals')) reviews[0].state = 'DISMISSED' @@ -961,7 +994,7 @@ test('a failed or unconfirmed dismissal prevents review requests and publishes e await assert.rejects(runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', write: () => {}, api: async (path, options) => { - if (path.includes('/statuses/')) { states.push(options.body.state); return {} } + if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} } if (path.endsWith('/dismissals')) { if (rejected) throw new Error('dismissal denied') return { id: 10, state: 'CHANGES_REQUESTED' } @@ -970,7 +1003,7 @@ test('a failed or unconfirmed dismissal prevents review requests and publishes e return read(path, options) }, }), rejected ? /dismissal denied/u : /dismissal was not confirmed/u) - assert.deepEqual(states, ['pending', 'error']) + assert.deepEqual(states, ['in_progress', 'failure']) } }) @@ -983,7 +1016,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: line => output.push(line), api: async (path, options) => { - if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} } + if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} } if (path.includes('/comments?')) return comments(path) return api(path, options) }, @@ -996,7 +1029,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing }) assert.equal(pages.length, 2) assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)')) - assert.deepEqual(statuses.splice(0), ['pending', 'success']) + assert.deepEqual(statuses.splice(0), ['in_progress', 'success']) for (const [comments, message] of [ [() => { throw new Error('comment API unavailable') }, /comment API unavailable/u], [() => ({}), /comments response is not an array/u], @@ -1006,7 +1039,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing [() => Array.from({ length: 100 }, () => comment('writer', 'text')), /comments exceed 3000/u], ]) { await assert.rejects(run(comments), message) - assert.deepEqual(statuses.splice(0), ['pending', 'error']) + assert.deepEqual(statuses.splice(0), ['in_progress', 'failure']) } }) @@ -1090,7 +1123,7 @@ test('later events reconcile all active delegations after a draft or replaced co const effects = [] const requested = [] const api = async (path, options) => { - if (path.includes('/statuses/')) return {} + if (path.includes('/check-runs')) return {} if (path.endsWith('/dismissals')) { const old = reviews.find(review => path.endsWith(`/reviews/${review.id}/dismissals`)) effects.push(old.id) @@ -1122,7 +1155,7 @@ test('already approved delegates need no new request', async () => { }) const result = await runWithHistory({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {}, api: (path, options) => { - if (path.includes('/statuses/')) return {} + if (path.includes('/check-runs')) return {} assert.ok(!path.endsWith('/requested_reviewers')) return read(path, options) }, diff --git a/.github/workflows/weighted-approval.yml b/.github/workflows/weighted-approval.yml index eb6f2fe3ba..896fc262c4 100644 --- a/.github/workflows/weighted-approval.yml +++ b/.github/workflows/weighted-approval.yml @@ -10,9 +10,9 @@ on: types: [completed] permissions: + checks: write contents: read pull-requests: write - statuses: write concurrency: group: weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }} @@ -29,20 +29,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - # SECURITY: the status-writing job executes policy from the trusted default + # SECURITY: the check-writing job executes policy from the trusted default # branch and reads pull-request reviews and comments only as API data. - name: Check out trusted approval policy uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - - name: Revoke previous approval status + - name: Revoke previous approval decision id: revoke env: GITHUB_TOKEN: ${{ github.token }} GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: node .github/review-ownership/check-approval.mjs pending - # SECURITY: dependency setup runs in the status-writing job. + run: node .github/review-ownership/check-approval.mjs in_progress + # SECURITY: dependency setup runs in the check-writing job. - if: steps.revoke.outputs.active == 'true' uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: @@ -52,7 +52,7 @@ jobs: - name: Install production lexer if: steps.revoke.outputs.active == 'true' run: python3 -m pip install -r .github/review-ownership/requirements.txt - - name: Publish weighted approval status + - name: Publish weighted approval decision if: steps.revoke.outputs.active == 'true' env: GITHUB_TOKEN: ${{ github.token }} @@ -63,4 +63,4 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: node .github/review-ownership/check-approval.mjs error + run: node .github/review-ownership/check-approval.mjs failure diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 86179be885..1275d4a7b0 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -972,7 +972,7 @@ describe('Weighted approval workflow', () => { if (!Array.isArray(recordJob.steps)) throw new TypeError('weighted-approval review event job must define steps') const steps = job.steps.filter(isRecord) const checkout = steps.find(step => step.name === 'Check out trusted approval policy') - const publish = steps.find(step => step.name === 'Publish weighted approval status') + const publish = steps.find(step => step.name === 'Publish weighted approval decision') const recordSteps = recordJob.steps.filter(isRecord) const record = recordSteps.find(step => step.name === 'Record review event') @@ -987,9 +987,9 @@ describe('Weighted approval workflow', () => { expect(review.types).toEqual(['submitted', 'edited', 'dismissed']) expect(reviewEvent.permissions).toEqual({}) expect(publisher.permissions).toEqual({ + checks: 'write', contents: 'read', 'pull-requests': 'write', - statuses: 'write', }) expect(publisher.concurrency).toEqual({ group: "weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}", @@ -1017,10 +1017,10 @@ describe('Weighted approval workflow', () => { const revokeIndex = steps.findIndex(step => step.id === 'revoke') expect(revokeIndex).toBeGreaterThan(steps.indexOf(checkout!)) expect(revokeIndex).toBeLessThan(setupIndex) - expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs pending') + expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs in_progress') expect(steps.at(-1)).toMatchObject({ if: "failure() && steps.revoke.outputs.active == 'true'", - run: 'node .github/review-ownership/check-approval.mjs error', + run: 'node .github/review-ownership/check-approval.mjs failure', }) const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk') expect(pythonJob.steps).toContainEqual({ From a0ed3981fe287fbe17fbf0cac10e6547595773d6 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 30 Sep 2026 13:42:02 +0800 Subject: [PATCH 019/287] fix(ci): revoke the approval decision through its conclusion A check run that reached completed never returns to in_progress: GitHub answers 200 and leaves the run completed, so the revoke write was a silent no-op on every re-evaluation and a published success survived an interrupted setup or history fetch. Revoke a published success by concluding it action_required, start a head that has no decision in progress, and drop the status-only check-run update. The setup-failure step ran only when the revoke step succeeded, so a failed or cancelled publisher left the earlier conclusion standing. It now runs on failure and on a cancellation that precedes the decision. The decision note, the review-ownership README, and the two related notes record the measured transition matrix and the fallback condition. --- ...pproval-review-workflow-identity.i18n.yaml | 4 +- ...09-10-approval-review-workflow-identity.md | 2 +- ...10-approval-review-workflow-identity.zh.md | 2 +- ...026-09-15-pr-approval-delegation.i18n.yaml | 4 +- .../2026-09-15-pr-approval-delegation.md | 2 +- .../2026-09-15-pr-approval-delegation.zh.md | 2 +- ...hted-approval-decision-check-run.i18n.yaml | 16 +-- ...29-weighted-approval-decision-check-run.md | 17 +-- ...weighted-approval-decision-check-run.zh.md | 17 +-- .agents/skills/dsh-pre-push-checks/SKILL.md | 2 +- .github/review-ownership/README.md | 2 +- .github/review-ownership/check-approval.mjs | 71 ++++++++---- .../review-ownership/check-approval.test.mjs | 102 +++++++++++------- .github/workflows/weighted-approval.yml | 7 +- scripts/ci-workflow.spec.ts | 5 +- 15 files changed, 162 insertions(+), 93 deletions(-) diff --git a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml index 1dfe18dbe3..335e5a5f86 100644 --- a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.i18n.yaml @@ -6,8 +6,8 @@ en: 8a332b9d080f3d62 zh: 29f1de05d7867a77 /agent-note-identify-approval-review-workflows-by-file-path/problem: - en: 6b927a7545ac2c48 - zh: 0902f03aa478d5ee + en: 63913473297103c4 + zh: 42d7312b9bc439e7 /agent-note-identify-approval-review-workflows-by-file-path/decision: en: 82802438f1b8d16c zh: ffe8c1fcbcc39abe diff --git a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md index de1e08ef78..f6a9cc7c17 100644 --- a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md +++ b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.md @@ -6,7 +6,7 @@ English | [中文](2026-09-10-approval-review-workflow-identity.zh.md) ## Problem -GitHub can populate a workflow run's `name` with its expanded `run-name`. The approval review workflow includes the pull-request number in that title, so comparing `workflow_run.name` with the static workflow name rejects valid review events before refreshing the approval status. +GitHub can populate a workflow run's `name` with its expanded `run-name`. The approval review workflow includes the pull-request number in that title, so comparing `workflow_run.name` with the static workflow name rejects valid review events before refreshing the approval decision. ## Decision diff --git a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md index 6b3aa3d359..2ed453467f 100644 --- a/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md +++ b/.agents/notes/implemented/process/2026-09-10-approval-review-workflow-identity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -GitHub 可能用展开后的 `run-name` 填充工作流运行的 `name`。审批评审工作流在该标题中包含拉取请求编号,因此将 `workflow_run.name` 与静态工作流名称比较,会在刷新审批状态前拒绝有效的评审事件。 +GitHub 可能用展开后的 `run-name` 填充工作流运行的 `name`。审批评审工作流在该标题中包含拉取请求编号,因此将 `workflow_run.name` 与静态工作流名称比较,会在刷新审批决策前拒绝有效的评审事件。 ## 决策 diff --git a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml index 50bca79957..f01abc3399 100644 --- a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.i18n.yaml @@ -9,8 +9,8 @@ en: 5fe2f3b3cb7ce046 zh: ed27d4efc995b8b5 /agent-note-pr-scoped-approval-delegation/decision: - en: bf8f1ff30114310f - zh: a163e1276dd384f8 + en: 87114939824c3994 + zh: 34d71c57f0459017 /agent-note-pr-scoped-approval-delegation/alternatives-considered: en: a6006217cb29613a zh: 83968328096f1d91 diff --git a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md index 0282f9c058..cbc7d7fbec 100644 --- a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md +++ b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md @@ -12,7 +12,7 @@ A reviewer may trust another reviewer to decide a particular PR while retaining The [approval policy](../../../../.github/review-ownership/README.md#delegating-points) accepts `/delegate @username` in PR conversation comments. Each eligible sender's points follow the named recipient's effective approval, once, using the sender's weight. Delegation applies only to that PR and only between write-capable accounts other than the PR author. Received points cannot be forwarded. The command dismisses the sender's prior approvals and change requests through GitHub. Other effective blocking reviews still follow [blocking-review policy](2026-09-29-weighted-approval-decision-check-run.md). -The latest surviving eligible command in comment creation order determines the recipient. Edited commands require the latest editor to match the author’s immutable account ID; other writers cannot transfer or cancel that author’s points by editing a comment. A self-delegation or a subsequently submitted review restores the sender's own decision. Comment-only reviews also reclaim the points; pending reviews do not. Equal timestamps favor the review, and subsequent dismissal cannot revive the delegation. A fresh command after the review can delegate again. Edits and deletions recompute from current comments, including restoration of older commands. The trusted publisher filters conversation comment changes for `/delegate`, resolves the live open PR head, and reads comments and editor identity as API data. Every evaluation reconciles active eligible commands, dismissing prior decision reviews and requesting recipients unless already approved or requested. This covers replaced queued events and draft-to-ready transitions; other requested reviewers remain unchanged. Scores refresh after dismissal, which retains the old submission timestamps and cannot cancel delegation. Removing the recipient's approval removes counted points while retaining delegation for their next approval. Drafts do not dismiss or request reviews. Dismissal failures fail evaluation; request failures are logged separately and retried on later evaluations. Closed or merged PRs are skipped before status writes and dependency setup. It preserves the separate [review-workflow validation](2026-09-10-approval-review-workflow-identity.md). +The latest surviving eligible command in comment creation order determines the recipient. Edited commands require the latest editor to match the author’s immutable account ID; other writers cannot transfer or cancel that author’s points by editing a comment. A self-delegation or a subsequently submitted review restores the sender's own decision. Comment-only reviews also reclaim the points; pending reviews do not. Equal timestamps favor the review, and subsequent dismissal cannot revive the delegation. A fresh command after the review can delegate again. Edits and deletions recompute from current comments, including restoration of older commands. The trusted publisher filters conversation comment changes for `/delegate`, resolves the live open PR head, and reads comments and editor identity as API data. Every evaluation reconciles active eligible commands, dismissing prior decision reviews and requesting recipients unless already approved or requested. This covers replaced queued events and draft-to-ready transitions; other requested reviewers remain unchanged. Scores refresh after dismissal, which retains the old submission timestamps and cannot cancel delegation. Removing the recipient's approval removes counted points while retaining delegation for their next approval. Drafts do not dismiss or request reviews. Dismissal failures fail evaluation; request failures are logged separately and retried on later evaluations. Closed or merged PRs are skipped before check-run writes and dependency setup. It preserves the separate [review-workflow validation](2026-09-10-approval-review-workflow-identity.md). ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md index 7d26a42d0d..348c62055f 100644 --- a/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md +++ b/.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md @@ -12,7 +12,7 @@ Status: implemented [批准策略](../../../../.github/review-ownership/README.md#delegating-points) 接受 PR 普通评论中的 `/delegate @username`。每位符合资格的委托人,其分数跟随指定受托人的有效批准,按委托人的权重计入一次。委托仅适用于当前 PR,且双方都必须具有写权限,不能是 PR 作者。收到的分数不能继续转交。命令会通过 GitHub 撤销委托人原有的批准和变更请求。其他有效阻塞评审仍遵循[阻塞评审策略](2026-09-29-weighted-approval-decision-check-run.zh.md)。 -按评论创建顺序,最新、仍存在且符合资格的命令决定受托人。编辑过的命令要求最近编辑者与作者的不可变账户 ID 一致;其他有写权限的人不能通过编辑评论转交或取消作者的分数。委托给自己或随后提交评审会恢复使用委托人本人的评审决定。仅评论的评审也会收回分数;尚未提交的评审不会。时间戳相同时以评审为准,随后撤销评审也不会重新启用委托。评审之后的新命令可以再次委托。编辑和删除会根据当前评论重算,包括恢复较早的命令。可信发布器按 `/delegate` 筛选普通评论变更,解析实时且未关闭的 PR 头提交,并将评论及编辑者身份作为 API 数据读取。每次评估都会处理所有有效且符合资格的命令,撤销旧的决定性评审,并请求尚未批准、尚未收到请求的受托人进行评审。这覆盖了排队事件被替换以及草稿转为 ready 的情况;其他 requested reviewers 保持不变。撤销后刷新分数;撤销保留旧提交时间戳,不会取消委托。移除受托人的批准只会移除计入的分数,委托仍然保留,供其下次批准使用。草稿 PR 不撤销或请求评审。撤销失败会使评估失败;请求失败单独记录日志,并在后续评估中重试。已关闭或合并的 PR 会在写入状态和安装依赖之前跳过。独立的[评审工作流验证](2026-09-10-approval-review-workflow-identity.zh.md) 保持不变。 +按评论创建顺序,最新、仍存在且符合资格的命令决定受托人。编辑过的命令要求最近编辑者与作者的不可变账户 ID 一致;其他有写权限的人不能通过编辑评论转交或取消作者的分数。委托给自己或随后提交评审会恢复使用委托人本人的评审决定。仅评论的评审也会收回分数;尚未提交的评审不会。时间戳相同时以评审为准,随后撤销评审也不会重新启用委托。评审之后的新命令可以再次委托。编辑和删除会根据当前评论重算,包括恢复较早的命令。可信发布器按 `/delegate` 筛选普通评论变更,解析实时且未关闭的 PR 头提交,并将评论及编辑者身份作为 API 数据读取。每次评估都会处理所有有效且符合资格的命令,撤销旧的决定性评审,并请求尚未批准、尚未收到请求的受托人进行评审。这覆盖了排队事件被替换以及草稿转为 ready 的情况;其他 requested reviewers 保持不变。撤销后刷新分数;撤销保留旧提交时间戳,不会取消委托。移除受托人的批准只会移除计入的分数,委托仍然保留,供其下次批准使用。草稿 PR 不撤销或请求评审。撤销失败会使评估失败;请求失败单独记录日志,并在后续评估中重试。已关闭或合并的 PR 会在写入 check run 和安装依赖之前跳过。独立的[评审工作流验证](2026-09-10-approval-review-workflow-identity.zh.md) 保持不变。 ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml index e3d242d672..89856bb840 100644 --- a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml @@ -9,14 +9,14 @@ en: 6ba6f6a15a1562ec zh: 1cbef3e736c258e6 /agent-note-weighted-approval-decisions-are-completed-check-runs/decision: - en: 933e4c1438771907 - zh: f6056382f3b555c9 + en: b070a25a6d990e8c + zh: fa555f8e418dad1f /agent-note-weighted-approval-decisions-are-completed-check-runs/verification: - en: d1f65b5b577b08b8 - zh: 7a193cec1acec003 + en: 5e5de198a6fbdcca + zh: 74c6dccdea7984b8 /agent-note-weighted-approval-decisions-are-completed-check-runs/alternatives-considered: - en: a7ef91c0506766a8 - zh: d6584e31652b14ca + en: b8daea66b0a90d26 + zh: fb7079c815b38281 /agent-note-weighted-approval-decisions-are-completed-check-runs/consequences: - en: 4cafdf12b326c68a - zh: c37a57d26d2f0894 + en: 772e9b9c0133d264 + zh: 0c36b0819adaa622 diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md index f4d8f9e639..2c4550ba4a 100644 --- a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md +++ b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md @@ -16,18 +16,21 @@ The [publisher](../../../../.github/review-ownership/check-approval.mjs) reports | Condition | Check run | |---|---| -| Dependency setup or evaluation in progress | `in_progress` | +| First evaluation of a head | `in_progress` until the decision is written | +| Re-evaluating a head that published `success` | conclusion `action_required` until the decision is written | | Draft, fewer than the required points, or an effective write-capable `CHANGES_REQUESTED` review | `completed`, conclusion `action_required` | | Ready, threshold met, and no blocking review | `completed`, conclusion `success` | -| Setup or evaluation failure | `completed`, conclusion `failure` | +| Setup, evaluation, or cancellation failure | `completed`, conclusion `failure` | + +A check run that reached `completed` never returns to `in_progress` or `queued`: GitHub answers `200` to that update and leaves the run completed, so only the conclusion is mutable. The publisher therefore revokes a published success by writing the blocking conclusion before dependency setup, which covers the setup and history fetch that dominate a cold evaluation. The head keeps one GitHub Actions run with the required name: the publisher updates the head's existing run and creates one only when none exists. An incomplete list or a run without a valid ID fails the publication. A run cancelled after the decision was published keeps that decision; every other failed or cancelled run concludes `failure`. A blocking review dominates the point total, so the conclusion remains `action_required` even when counted approvals reach the threshold. GitHub accepts only `success`, `neutral`, and `skipped` for a required check, so `action_required` blocks merging while completing the check. -The publisher lists the head's check runs with that name, updates every run owned by GitHub Actions, and creates one only when none exists. An incomplete list or a run without a valid ID fails the publication. - ## Verification -[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case, the exact published check-run payloads, updating an existing run instead of creating another, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` permission, the `in_progress` and `failure` phase commands, and the separate publisher job name. +[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case, the exact published check-run payloads, revoking a published success through its conclusion, updating an existing run instead of creating another, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` permission, the `revoke` and `failure` phase commands, the publish step identity, and the fallback that runs when a run fails or is cancelled before publishing. + +The transition matrix and the fallback condition were measured on 2026-09-30 with throwaway tag runs in this repository, using the same GitHub Actions token as the publisher. A run created as `in_progress` and completed as `success` accepted `status: in_progress` and `status: queued` with `200` and stayed `completed`, while conclusion-only updates moved it to `action_required`, back to `success`, and to `failure`. The fallback step ran for a failed publish and for a cancellation during the publish step, and did not run when the job was cancelled after the decision was published. ## Alternatives considered @@ -35,6 +38,8 @@ The publisher lists the head's check runs with that name, updates every run owne **Publish `failure` for unmet conditions.** This ends the wait, but it represents an unmet, reversible merge condition as a malfunction and conflates the policy outcome with publisher health. +**Revoke by returning the completed run to `in_progress`.** GitHub accepts that update without applying it, so the published success would stand through dependency setup and the history fetch. + **Publish nothing until success.** A required check that was never reported remains expected and is invisible to many watchers. Revoking a success already published on the same head still requires a non-success write, and each watcher's treatment of a missing required check is unspecified. **Keep the `weighted approval` name.** When a check and a commit status share a required name, GitHub requires both to pass. Heads that carry a `pending` commit status from the earlier publisher would stay blocked until a new push. @@ -47,4 +52,4 @@ The publisher lists the head's check runs with that name, updates every run owne CI watchers finish while the decision waits on reviewers. `gh pr checks` groups `action_required` with failed checks and exits non-zero; consumers distinguish it by the check name, the `ACTION_REQUIRED` conclusion, or the output title, which carries the score or blocker. The `weighted approval publisher` Actions job and the `failure` conclusion carry operational failures. -Revoking an earlier success on the same head returns the completed check run to `in_progress`, a transition the GitHub REST reference does not restrict. +Re-evaluating a head that already published `success` reports `action_required` until the new decision is written, so a watcher that polls inside that window sees a failed check. Heads created before this change keep their `weighted approval` commit status: no API completes or removes a commit status, so those heads stay pending for watchers until they receive a new commit. diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md index 4dee85c8f7..d8b894351d 100644 --- a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md +++ b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md @@ -16,18 +16,21 @@ commit status 只提供 `pending`、`success`、`failure` 和 `error`。`pending | 条件 | Check run | |---|---| -| 依赖安装或评估进行中 | `in_progress` | +| head 的首次评估 | 写入决策前为 `in_progress` | +| 重新评估已发布 `success` 的 head | 写入决策前 conclusion 为 `action_required` | | draft、点数少于要求,或存在具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审 | `completed`,conclusion 为 `action_required` | | 已 ready、达到阈值且没有阻塞性评审 | `completed`,conclusion 为 `success` | -| 安装或评估失败 | `completed`,conclusion 为 `failure` | +| 安装、评估或取消导致的失败 | `completed`,conclusion 为 `failure` | + +已经到达 `completed` 的 check run 不会再回到 `in_progress` 或 `queued`:GitHub 对该更新返回 `200`,但让 run 保持已完成,因此只有 conclusion 可变。publisher 因此在依赖安装前用阻塞性 conclusion 撤销已发布的成功,覆盖冷启动中占主要耗时的安装与历史拉取。同一 head 上只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建一个。列表不完整或 run 没有有效 ID 时发布失败。决策已发布后被取消的 run 保留该决策;其余失败或被取消的 run 都以 `failure` 结束。 阻塞性评审的优先级高于点数总和,因此即使计入的批准已经达到阈值,conclusion 仍为 `action_required`。GitHub 只接受 `success`、`neutral` 和 `skipped` 作为必需检查的通过结果,因此 `action_required` 会阻止合并,同时让该检查处于完成状态。 -publisher 列出 head 上同名的 check run,更新所有属于 GitHub Actions 的 run,只有在不存在时才创建一个。列表不完整或 run 没有有效 ID 时发布失败。 - ## 验证 -[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景、准确发布的 check run payload、更新已有 run 而不是另建一个,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 权限、`in_progress` 和 `failure` 阶段命令,以及独立的 publisher job 名称。 +[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景、准确发布的 check run payload、通过 conclusion 撤销已发布的成功、更新已有 run 而不是另建一个,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 权限、`revoke` 和 `failure` 阶段命令、publish 步骤的身份,以及运行失败或在发布前被取消时执行的兜底步骤。 + +转换矩阵与该兜底条件于 2026-09-30 在本仓库用一次性 tag 运行实测,使用的是与 publisher 相同的 GitHub Actions token。以 `in_progress` 创建、以 `success` 完成的 run,接受 `status: in_progress` 和 `status: queued` 的更新时都返回 `200` 并保持 `completed`;只改 conclusion 的更新把它依次改为 `action_required`、`success` 和 `failure`。兜底步骤在发布失败以及在发布步骤执行期间被取消时运行,而在决策发布之后才取消 job 时不运行。 ## 考虑过的替代方案 @@ -35,6 +38,8 @@ publisher 列出 head 上同名的 check run,更新所有属于 GitHub Actions **为尚未满足的条件发布 `failure`。** 该方案能结束等待,但会把尚未满足且可撤销的合并条件表示为故障,并混淆策略结果与 publisher 的健康状态。 +**通过把已完成的 run 改回 `in_progress` 来撤销。** GitHub 接受该更新但不执行,因此已发布的成功会在依赖安装与历史拉取期间继续存在。 + **在成功之前不发布任何结果。** 从未上报的必需检查保持 expected 状态,许多监视工具看不到它。撤销同一 head 上已发布的成功仍然需要写入非成功结果,而各监视工具如何对待缺失的必需检查没有规定。 **保留 `weighted approval` 名称。** 当 check 和 commit status 使用同一个必需名称时,GitHub 要求两者都通过。带有旧 publisher 所写 `pending` commit status 的 head 会一直被阻止,直到出现新的 push。 @@ -47,4 +52,4 @@ publisher 列出 head 上同名的 check run,更新所有属于 GitHub Actions 决策等待评审人时,CI 监视工具会结束。`gh pr checks` 把 `action_required` 与失败的检查归为一组并以非零状态退出;消费方可以通过 check 名称、`ACTION_REQUIRED` conclusion 或携带分数或 blocker 的输出标题加以区分。`weighted approval publisher` Actions job 和 `failure` conclusion 承载运行故障信号。 -撤销同一 head 上此前的成功结果时,已完成的 check run 会回到 `in_progress`;GitHub REST 参考文档没有限制这一转换。 +重新评估已经发布 `success` 的 head 时,在新决策写入前会报告 `action_required`,因此在该窗口内轮询的监视工具会看到失败的检查。本次变更之前创建的 head 会保留原有的 `weighted approval` commit status:没有任何 API 能完成或删除 commit status,因此这些 head 对监视工具一直是 pending,直到它们收到新的 commit。 diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 6567dde5dc..3ab326a4b3 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -123,7 +123,7 @@ For GitHub PRs, inspect remote CI after the push: gh pr checks ``` -Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. +Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. A `weighted approval decision` check that `gh pr checks` lists as failed means the approval gate is waiting on reviewers (`ACTION_REQUIRED`), not that the branch broke. When `gh pr checks` reports "no checks reported" and `/actions/runs?head_sha=` returns `total_count: 0`, read mergeability before suspecting the push or a dropped GitHub event: diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index 7b461ae864..0d99299847 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -16,7 +16,7 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes ## Approval scoring -The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and check publication completed, while the `weighted approval decision` check run carries the approval decision on the pull request head. Branch rules must require that check run with GitHub Actions as its expected source; a name-only requirement can accept a same-named check or status from another integration. The publisher updates the head's existing GitHub Actions decision check run and creates one only when none exists. It marks the check run `in_progress` before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures conclude it as `failure`. A completed evaluation concludes `action_required` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps that conclusion even when counted approvals reach the threshold. It concludes `success` only when the threshold is met, the pull request is ready, and no such blocker exists. `action_required` blocks merging but completes the check, so tools that wait for checks to finish do not wait for reviewers; the check-run title carries the score or blocker. +The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and check publication completed, while the `weighted approval decision` check run carries the approval decision on the pull request head. Branch rules must require that check run with GitHub Actions as its expected source; a name-only requirement can accept a same-named check or status from another integration. The publisher updates the head's existing GitHub Actions decision check run and creates one only when none exists; a head without one starts `in_progress` and a re-evaluated head that published `success` is concluded `action_required` before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. A completed check run only ever changes conclusion, so every later write completes it again. Setup, evaluation, and cancellation failures conclude it as `failure`. A completed evaluation concludes `action_required` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps that conclusion even when counted approvals reach the threshold. It concludes `success` only when the threshold is met, the pull request is ready, and no such blocker exists. `action_required` blocks merging but completes the check, so tools that wait for checks to finish do not wait for reviewers; the check-run title carries the score or blocker. Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count. diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs index 489acd1779..83b6eb68a2 100644 --- a/.github/review-ownership/check-approval.mjs +++ b/.github/review-ownership/check-approval.mjs @@ -311,7 +311,6 @@ export async function evaluateApproval({ event, policySource, api, getOwnership */ export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) { const pull = pullRequestFromEvent(event) - await publishCheck(api, pull, 'in_progress', 'Evaluating approval points.', runUrl) let result try { result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount }) @@ -353,14 +352,15 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw } /** - * Revoke a previous conclusion before dependency installation, or report its failure. + * Revoke a published success before dependency installation, or conclude a failed setup. * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, runUrl: string, phase: string}} options Publication inputs. * @returns {Promise} Completion of the check-run write. */ export async function publishApprovalPhase({ event, api, runUrl, phase }) { - if (!['in_progress', 'failure'].includes(phase)) throw new Error('invalid approval setup phase') - await publishCheck(api, pullRequestFromEvent(event), phase, - phase === 'in_progress' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl) + const pull = pullRequestFromEvent(event) + if (phase === 'revoke') return revokeApproval(api, pull, runUrl) + if (phase === 'failure') return publishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl) + throw new Error('invalid approval setup phase') } /** @@ -467,21 +467,15 @@ async function reviewerPermission(api, repository, login) { return response.permission.toLowerCase() } -// Updating the head's existing GitHub Actions check run keeps one decision per head for branch rules and CI watchers. -async function publishCheck(api, pull, state, title, runUrl) { - if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS') - const body = { - ...(state === 'in_progress' ? { status: state } : { status: 'completed', conclusion: state }), - details_url: runUrl, - output: { title, summary: `${title} [Workflow run](${runUrl})` }, - } - const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=${PAGE_SIZE}`) - if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) { - throw new Error('check-run list response is invalid or incomplete') - } - const runs = listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions') +// One decision per head keeps branch rules and CI watchers unambiguous; the head's existing GitHub +// Actions run is updated and a run is created only when none exists. +async function publishCheck(api, pull, conclusion, title, runUrl) { + requireHttpsRunUrl(runUrl) + const body = { status: 'completed', conclusion, details_url: runUrl, output: { title, summary: `${title} [Workflow run](${runUrl})` } } + const runs = await decisionRuns(api, pull) if (runs.length === 0) { await api(`/repos/${pull.repository}/check-runs`, { method: 'POST', body: { name: CHECK_NAME, head_sha: pull.headSha, ...body } }) + return } for (const { id } of runs) { if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id') @@ -489,6 +483,45 @@ async function publishCheck(api, pull, state, title, runUrl) { } } +// A head without a decision starts in progress; a published success becomes action_required, so an +// interrupted evaluation cannot leave a success that its current inputs no longer support. +async function revokeApproval(api, pull, runUrl) { + requireHttpsRunUrl(runUrl) + const title = 'Preparing approval evaluation.' + const output = { title, summary: `${title} [Workflow run](${runUrl})` } + const runs = await decisionRuns(api, pull) + if (runs.length === 0) { + await api(`/repos/${pull.repository}/check-runs`, { + method: 'POST', + body: { name: CHECK_NAME, head_sha: pull.headSha, status: 'in_progress', details_url: runUrl, output }, + }) + return + } + for (const { id, conclusion } of runs) { + if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id') + // Only a published success satisfies the branch rule; every other conclusion already blocks it. + if (conclusion !== 'success') continue + await api(`/repos/${pull.repository}/check-runs/${id}`, { + method: 'PATCH', + body: { status: 'completed', conclusion: 'action_required', details_url: runUrl, output }, + }) + } +} + +// A check run that reached completed never returns to in_progress: GitHub answers 200 and leaves the +// run completed, so a published decision can only be replaced by another conclusion. +async function decisionRuns(api, pull) { + const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=${PAGE_SIZE}`) + if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) { + throw new Error('check-run list response is invalid or incomplete') + } + return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions') +} + +function requireHttpsRunUrl(runUrl) { + if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS') +} + function pullRequestFromEvent(event) { const repository = repositoryFromEvent(event) if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user) @@ -579,7 +612,7 @@ async function main() { const phase = process.argv[2] if (phase) { await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase }) - if (phase === 'in_progress' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n') + if (phase === 'revoke' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n') return } await runApprovalCheck({ diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs index 186e97d1b3..fe7582108d 100644 --- a/.github/review-ownership/check-approval.test.mjs +++ b/.github/review-ownership/check-approval.test.mjs @@ -16,8 +16,10 @@ import { } from './check-approval.mjs' const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8') +const emptyCheckRuns = { total_count: 0, check_runs: [] } +const isCheckRunsList = path => path.includes('/commits/') && path.includes('/check-runs?') const withoutCheckRuns = api => (path, options) => - path.includes('/check-runs?') ? { total_count: 0, check_runs: [] } : api(path, options) + isCheckRunsList(path) ? emptyCheckRuns : api(path, options) const withEmptyReads = api => withoutCheckRuns((path, options) => path.includes('/comments?') ? [] : api(path, options)) const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) }) const runWithHistory = options => runPublisher({ ...options, api: withoutCheckRuns(options.api) }) @@ -237,7 +239,7 @@ test('ignores a reviewer whose collaborator permission lookup returns 404', asyn }) test('requires action on a write-capable change request while ignoring the author and read-only reviewers', async () => { - const statuses = [] + const writes = [] const result = await runApprovalCheck({ event: pullRequestEvent({ author: 'author' }), policySource, @@ -255,8 +257,9 @@ test('requires action on a write-capable change request while ignoring the autho if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' } if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' } if (path.includes('/collaborators/reader/permission')) return { permission: 'read' } + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) { - statuses.push(options.body) + writes.push(options.body) return {} } throw new Error(`unexpected API path ${path}`) @@ -268,13 +271,7 @@ test('requires action on a write-capable change request while ignoring the autho assert.equal(result.points, 2) assert.deepEqual(result.blockers, ['blocker']) assert.deepEqual(result.ignoredReviewers, ['reader']) - assert.deepEqual(statuses, [{ - name: 'weighted approval decision', - head_sha: HEAD_SHA, - status: 'in_progress', - details_url: 'https://github.example/actions/runs/1', - output: { title: 'Evaluating approval points.', summary: 'Evaluating approval points. [Workflow run](https://github.example/actions/runs/1)' }, - }, { + assert.deepEqual(writes, [{ name: 'weighted approval decision', head_sha: HEAD_SHA, status: 'completed', @@ -296,7 +293,7 @@ test('requires action on drafts without reading reviews', async () => { assert.match(result.description, /draft pull request/u) }) -test('publishes the required status and replaces stale success with error on evaluation failure', async () => { +test('publishes the decision check run and concludes failure when evaluation fails', async () => { const calls = [] const output = [] const result = await runApprovalCheck({ @@ -308,6 +305,7 @@ test('publishes the required status and replaces stale success with error on eva calls.push({ path, options }) if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')] if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' } + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) return {} throw new Error(`unexpected API path ${path}`) }, @@ -339,6 +337,7 @@ test('publishes the required status and replaces stale success with error on eva runUrl: 'https://github.example/actions/runs/2', api: async (path, options = {}) => { if (path.includes('/reviews?')) throw new Error('reviews unavailable') + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) { failures.push({ path, options }) return {} @@ -347,9 +346,10 @@ test('publishes the required status and replaces stale success with error on eva }, write: () => {}, }), /reviews unavailable/u) - assert.equal(publishedState(failures[0].options.body), 'in_progress') - assert.equal(publishedState(failures[1].options.body), 'failure') - assert.equal(failures[1].options.body.output.title, 'Approval evaluation failed.') + assert.deepEqual(failures.map(({ path }) => path), ['/repos/deepseek-harness/deepseek-harness/check-runs']) + assert.equal(failures[0].options.body.status, 'completed') + assert.equal(failures[0].options.body.conclusion, 'failure') + assert.equal(failures[0].options.body.output.title, 'Approval evaluation failed.') }) test('sends authenticated JSON and escapes an API error body', async () => { @@ -424,7 +424,7 @@ test('does not fetch history when approvals already have two-point weights', asy assert.equal(result.points, 2) }) -test('publishes error when production attribution fails', async () => { +test('concludes failure when production attribution fails', async () => { const states = [] await assert.rejects(runApprovalCheck({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', @@ -433,30 +433,43 @@ test('publishes error when production attribution fails', async () => { if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] if (path.includes('/permission')) return { permission: 'write' } if (path.includes('/comments?')) return [] + if (isCheckRunsList(path)) return emptyCheckRuns states.push(publishedState(options.body)) return {} }, }), /incomplete history/u) - assert.deepEqual(states, ['in_progress', 'failure']) + assert.deepEqual(states, ['failure']) }) -test('revokes a previous success before starting expensive attribution', async () => { - const states = [] - await runApprovalCheck({ - event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {}, - getOwnership: async () => { - assert.deepEqual(states, ['in_progress']) - return { totalLines: 100, reviewerLines: { writer: 25 } } - }, +test('revokes a published success without returning a completed run to in progress', async () => { + const calls = [] + const checkRuns = [ + { id: 7, app: { slug: 'github-actions' }, conclusion: 'success' }, + { id: 8, app: { slug: 'github-actions' }, conclusion: 'action_required' }, + { id: 9, app: { slug: 'other-app' }, conclusion: 'success' }, + ] + await publishApprovalPhase({ + event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'revoke', api: async (path, options) => { - if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] - if (path.includes('/permission')) return { permission: 'write' } - states.push(publishedState(options.body)) - return {} + calls.push({ path, method: options?.method, body: options?.body }) + return isCheckRunsList(path) ? { total_count: checkRuns.length, check_runs: checkRuns } : {} }, }) - assert.deepEqual(states, ['in_progress', 'success']) + assert.deepEqual(calls, [{ + path: `/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`, + method: undefined, + body: undefined, + }, { + path: '/repos/deepseek-harness/deepseek-harness/check-runs/7', + method: 'PATCH', + body: { + status: 'completed', + conclusion: 'action_required', + details_url: 'https://github.example/run/1', + output: { title: 'Preparing approval evaluation.', summary: 'Preparing approval evaluation. [Workflow run](https://github.example/run/1)' }, + }, + }]) }) for (const reviewers of [['first', 'second'], ['turtle1999', 'first']]) { @@ -488,7 +501,7 @@ test('publishes setup phases without evaluating or installing dependencies', asy event: pullRequestEvent(), runUrl: 'https://github.example/run/1', api: withEmptyReads(async (path, { body }) => { assert.match(path, /\/check-runs$/u); states.push(publishedState(body)) }), } - await publishApprovalPhase({ ...options, phase: 'in_progress' }) + await publishApprovalPhase({ ...options, phase: 'revoke' }) await publishApprovalPhase({ ...options, phase: 'failure' }) assert.deepEqual(states, ['in_progress', 'failure']) await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u) @@ -497,11 +510,11 @@ test('publishes setup phases without evaluating or installing dependencies', asy test('updates the head\'s GitHub Actions decision check run instead of creating another', async () => { const calls = [] const checkRuns = [ - { id: 7, app: { slug: 'github-actions' } }, - { id: 8, app: { slug: 'other-app' } }, + { id: 7, app: { slug: 'github-actions' }, conclusion: 'action_required' }, + { id: 8, app: { slug: 'other-app' }, conclusion: 'action_required' }, ] await publishApprovalPhase({ - event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'in_progress', + event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure', api: async (path, options) => { calls.push({ path, method: options?.method }) return { total_count: checkRuns.length, check_runs: checkRuns } @@ -586,7 +599,7 @@ test('drafts skip history and history failures revoke success with error', async states.push(publishedState(options.body)) }, }), /history unavailable/u) - assert.deepEqual(states, ['in_progress', 'failure']) + assert.deepEqual(states, ['failure']) }) @@ -627,7 +640,7 @@ test('the publisher counts merged history through the production API path', asyn assert.equal(result.points, 2) assert.equal(result.authorCredit.points, 0.55) assert.equal(result.approvals[0].points, 1.45) - assert.deepEqual(states, ['in_progress', 'success']) + assert.deepEqual(states, ['success']) assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).') }) @@ -862,6 +875,7 @@ test('an active delegate command requests review once and preserves other reques if (options?.method === 'POST') { requests.push(options.body); return {} } return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] } } + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) return {} return api(path, options) }, @@ -890,6 +904,7 @@ test('inactive commands and drafts do not request review', async () => { policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {}, api: async (path, options) => { assert.ok(!path.endsWith('/requested_reviewers')) + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) return {} return api(path, options) }, @@ -906,6 +921,7 @@ test('failed review requests preserve the computed score and report the retry', event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), write: line => output.push(line), api: async (path, options) => { + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} } if (path.endsWith('/requested_reviewers')) { if (options?.method === 'POST') throw new Error('request rejected') @@ -916,7 +932,7 @@ test('failed review requests preserve the computed score and report the retry', }) assert.match(output.at(-1), failedRead ? /no users array/u : /request rejected/u) assert.match(output.at(-1), /score is unchanged.*will retry/u) - assert.deepEqual(statuses, ['in_progress', 'success']) + assert.deepEqual(statuses, ['success']) } }) @@ -928,6 +944,7 @@ test('delegation dismisses only the sender old decisions and stays active across const states = [] const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews }) const api = async (path, options) => { + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} } if (path.endsWith('/dismissals')) { calls.push(options) @@ -952,7 +969,7 @@ test('delegation dismisses only the sender old decisions and stays active across assert.deepEqual(result.blockers, ['other']) assert.equal(result.delegations.length, 1) assert.deepEqual(result.delegations[0].reviewIds, []) - assert.deepEqual(states, ['in_progress', 'action_required']) + assert.deepEqual(states, ['action_required']) reviews.find(review => review.id === 21).state = 'DISMISSED' for (const state of ['APPROVED', 'DISMISSED', 'COMMENTED', 'APPROVED']) { reviews.find(review => review.id === 20).state = state @@ -972,6 +989,7 @@ test('a new sender review arriving during dismissal is not dismissed or delegate const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0, api: async (path, options) => { + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) return {} if (path.endsWith('/dismissals')) { assert.ok(path.endsWith('/reviews/10/dismissals')) @@ -994,6 +1012,7 @@ test('a failed or unconfirmed dismissal prevents review requests and publishes e await assert.rejects(runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', write: () => {}, api: async (path, options) => { + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} } if (path.endsWith('/dismissals')) { if (rejected) throw new Error('dismissal denied') @@ -1003,7 +1022,7 @@ test('a failed or unconfirmed dismissal prevents review requests and publishes e return read(path, options) }, }), rejected ? /dismissal denied/u : /dismissal was not confirmed/u) - assert.deepEqual(states, ['in_progress', 'failure']) + assert.deepEqual(states, ['failure']) } }) @@ -1016,6 +1035,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: line => output.push(line), api: async (path, options) => { + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} } if (path.includes('/comments?')) return comments(path) return api(path, options) @@ -1029,7 +1049,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing }) assert.equal(pages.length, 2) assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)')) - assert.deepEqual(statuses.splice(0), ['in_progress', 'success']) + assert.deepEqual(statuses.splice(0), ['success']) for (const [comments, message] of [ [() => { throw new Error('comment API unavailable') }, /comment API unavailable/u], [() => ({}), /comments response is not an array/u], @@ -1039,7 +1059,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing [() => Array.from({ length: 100 }, () => comment('writer', 'text')), /comments exceed 3000/u], ]) { await assert.rejects(run(comments), message) - assert.deepEqual(statuses.splice(0), ['in_progress', 'failure']) + assert.deepEqual(statuses.splice(0), ['failure']) } }) @@ -1123,6 +1143,7 @@ test('later events reconcile all active delegations after a draft or replaced co const effects = [] const requested = [] const api = async (path, options) => { + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) return {} if (path.endsWith('/dismissals')) { const old = reviews.find(review => path.endsWith(`/reviews/${review.id}/dismissals`)) @@ -1155,6 +1176,7 @@ test('already approved delegates need no new request', async () => { }) const result = await runWithHistory({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {}, api: (path, options) => { + if (isCheckRunsList(path)) return emptyCheckRuns if (path.includes('/check-runs')) return {} assert.ok(!path.endsWith('/requested_reviewers')) return read(path, options) diff --git a/.github/workflows/weighted-approval.yml b/.github/workflows/weighted-approval.yml index 896fc262c4..f72ada5942 100644 --- a/.github/workflows/weighted-approval.yml +++ b/.github/workflows/weighted-approval.yml @@ -41,7 +41,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: node .github/review-ownership/check-approval.mjs in_progress + run: node .github/review-ownership/check-approval.mjs revoke # SECURITY: dependency setup runs in the check-writing job. - if: steps.revoke.outputs.active == 'true' uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 @@ -53,13 +53,16 @@ jobs: if: steps.revoke.outputs.active == 'true' run: python3 -m pip install -r .github/review-ownership/requirements.txt - name: Publish weighted approval decision + id: publish if: steps.revoke.outputs.active == 'true' env: GITHUB_TOKEN: ${{ github.token }} GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: node .github/review-ownership/check-approval.mjs + # A failed or cancelled run must not leave an earlier conclusion standing. A job cancelled + # after the decision was published keeps that decision instead of overwriting it with failure. - name: Publish approval setup failure - if: failure() && steps.revoke.outputs.active == 'true' + if: failure() || (cancelled() && steps.publish.outcome != 'success') env: GITHUB_TOKEN: ${{ github.token }} GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 1275d4a7b0..e69d659644 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -1017,9 +1017,9 @@ describe('Weighted approval workflow', () => { const revokeIndex = steps.findIndex(step => step.id === 'revoke') expect(revokeIndex).toBeGreaterThan(steps.indexOf(checkout!)) expect(revokeIndex).toBeLessThan(setupIndex) - expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs in_progress') + expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs revoke') expect(steps.at(-1)).toMatchObject({ - if: "failure() && steps.revoke.outputs.active == 'true'", + if: "failure() || (cancelled() && steps.publish.outcome != 'success')", run: 'node .github/review-ownership/check-approval.mjs failure', }) const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk') @@ -1032,6 +1032,7 @@ describe('Weighted approval workflow', () => { run: 'python3 -m pip install -r .github/review-ownership/requirements.txt', }) expect(publish).toMatchObject({ + id: 'publish', if: "steps.revoke.outputs.active == 'true'", env: { GITHUB_TOKEN: '${{ github.token }}', From 8987e6c45afb89ee8ac4ec8040d8b0d6c8b2dae0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 8 Oct 2026 16:20:08 +0800 Subject: [PATCH 020/287] 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 021/287] 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 022/287] 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 023/287] 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 5ecf33aab1e493ca355a34f1ba0bcaaef5541b27 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 12:03:36 +0800 Subject: [PATCH 024/287] refactor(session-projection): acquire retained readers during activation --- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 12 +- packages/core/agent-loop/src/inbox.ts | 28 ++--- packages/core/agent-loop/src/index.ts | 108 +++++----------- packages/core/agent-loop/tests/inbox.spec.ts | 33 ++--- packages/core/agent-loop/tests/loop.spec.ts | 6 +- .../agent-loop/tests/request-freeze.spec.ts | 11 +- .../tests/resource-lifecycle.spec.ts | 8 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 12 +- packages/core/agent/package.json | 3 + packages/core/agent/src/index.ts | 2 +- packages/core/agent/src/projection.ts | 56 ++++++++- .../tests/cache.spec.ts | 18 +-- .../tests/fixtures.spec.ts | 4 +- .../session-projection/README.i18n.yaml | 8 +- packages/session/session-projection/README.md | 7 +- .../session/session-projection/README.zh.md | 7 +- .../session/session-projection/src/index.ts | 99 ++++++++------- .../tests/registration-reader.spec.ts | 113 +++++++++++++++++ .../session-projection/tests/registry.spec.ts | 115 +++++++++--------- 22 files changed, 388 insertions(+), 270 deletions(-) create mode 100644 packages/session/session-projection/tests/registration-reader.spec.ts diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index 0bbdb05d37..ab692943de 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -39,8 +39,8 @@ en: 2a8b93869eb5a2f9 zh: ddab8e738d188a61 /deepseek-ai-dsh-agent-loop/understand-the-implementation/creation-and-teardown: - en: d079cf181ece9267 - zh: 047f76fecfc5497c + en: 79aa89d1db536612 + zh: a86635bf4aa54b59 /deepseek-ai-dsh-agent-loop/understand-the-implementation/persistence-integration: en: 3c5721bc00a0bbf4 zh: 711f832343ffbb75 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 62dcd35a92..7308c64c73 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -109,7 +109,7 @@ The loop deep-freezes each derived message identity on its first request and reu Creation is one rollback-covered transaction: construct a private session, concrete agent, and scoped context; await optional setup; enter both registries; announce `session/created`; await serial `agent/created` listeners; then release queued input. A caller creating a runtime child sets `options.parentAgent`; the caller Context separately owns the transaction and live handle. Setup, commit, or listener failure and owner disposal roll back the prepared resources. Announcements already delivered remain observable and receive matching disposal notifications. Teardown stops and drains the driver, unwinds the scope, closes the session's write path, detaches the agent, then detaches the session. Every detach binds the exact entered object, so a stale disposer cannot remove a later same-id replacement. -Factory unload retains the inbox and turn-boundary projections through scope cleanup, including cancellation from scoped abort listeners, until every Agent teardown and startup task settles. Cleanup failures do not release those projections early. Caller unload, factory unload, and handle disposal await the same teardown. +The loop awaits inbox and turn-boundary registration before publishing its factory and passes those handles into synchronous Agent construction. During factory unload, global registrations may disappear before teardown finishes; retained handles remain readable through scope cleanup, including cancellation from scoped abort listeners. Caller unload, factory unload, and handle disposal await the same teardown. ### Persistence integration diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index ee355a328d..714f3f3839 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -109,7 +109,7 @@ const handle = await ctx.agents.create({ 创建是同一个受回滚保护的事务:构造私有会话、具象 agent 与带作用域上下文;等待可选 setup;进入两个注册表;宣告 `session/created`;等待串行 `agent/created` 监听器;随后释放已排队输入。创建运行时子 Agent 的调用方设置 `options.parentAgent`;调用方 Context 则单独拥有事务和存活句柄。Setup、commit、监听器失败或所有者 dispose 都会回滚已准备的资源。已送达的宣告仍可被观察,并有配对的销毁通知。Teardown 停止并排空驱动器、撤销作用域、关闭会话写路径、detach agent,再 detach 会话。每次 detach 都绑定到确切进入的对象,因此陈旧 disposer 无法移除之后出现的同 id 替代项。 -工厂卸载时在整个作用域清理期间保留 inbox 投影与轮次边界投影,包括作用域内 abort 监听器发起取消的时刻,直到所有 Agent teardown 和启动任务结束。清理失败不会使这些投影提前释放。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。 +loop 在发布工厂前等待 inbox 与轮次边界注册完成,并将句柄传入 Agent 的同步构造过程。工厂卸载时,全局注册可以先于 teardown 完成而消失;保留句柄在整个作用域清理期间仍可读取,包括作用域内 abort 监听器发起取消的时刻。调用方卸载、工厂卸载和句柄释放等待同一次 teardown。 ### 持久化集成 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index adf2e33ddc..65797b988f 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -12,6 +12,8 @@ import type { AgentStatus, CancelOptions, InboxTarget, + InboxState, + TurnBoundaryProjection, PreStepDecision, RequestErrorAction, } from '@deepseek-ai/dsh-agent' @@ -31,7 +33,7 @@ import type { EpochHeader, RequestContext, Session, SessionId, SessionSeq, TurnE import { canonicalHeader, headerEquals, ToolCallRecovery } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import { ReactLoopInbox } from './inbox.ts' import { RuntimeContextProjection } from './runtime-context.ts' @@ -124,15 +126,15 @@ export class ReactLoopAgent implements Agent { public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, + inbox: ProjectionHandle, + boundary: ProjectionHandle, ) { this.requestSurfaceGeneration = session.surface.contentGeneration this.dispatch = agentEvents(loopCtx, this) this.scope = createScope(loopCtx, this) this.ctx = this.scope.ctx - this.inbox = new ReactLoopInbox(this.ctx.sessionProjections, session, this.dispatch) - /* v8 ignore next -- the loop registers its own turnBoundary unit, so the key is always present */ - const lastTurn = this.loopCtx.sessionProjections.stateOf(session, 'turnBoundary')?.lastTurn ?? 0 - this.phase = { kind: 'idle', lastTurn } + this.inbox = new ReactLoopInbox(() => inbox.stateOf(session), session, this.dispatch) + this.phase = { kind: 'idle', lastTurn: boundary.stateOf(session).lastTurn } this.runtimeContext = new RuntimeContextProjection(this.ctx, session) this.systemPrompt = new SystemPromptProjection(session) } diff --git a/packages/core/agent-loop/src/inbox.ts b/packages/core/agent-loop/src/inbox.ts index ef00887a54..affe0c6b2f 100644 --- a/packages/core/agent-loop/src/inbox.ts +++ b/packages/core/agent-loop/src/inbox.ts @@ -6,7 +6,6 @@ import type { MessageId } from '@deepseek-ai/dsh-llm' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import type { Session, SessionEventMap, UserMessage } from '@deepseek-ai/dsh-session' import type { AgentEventDispatch, @@ -67,30 +66,30 @@ export const inboxProjectionDefinition = { /** * Driver-owned durable Inbox implementation used by ReactLoopAgent and focused * provider tests. - * @param projections - registry with the standard Inbox projection registered by AgentLoop. + * @param readState - reader for the live Inbox projection state. * @param session - session whose durable events store pending input. * @param dispatch - agent-scoped notifications for Inbox lifecycle events. */ export class ReactLoopInbox implements InboxContract { constructor( - private readonly projections: SessionProjectionRegistry, + private readonly readState: () => InboxState, private readonly session: Session, private readonly dispatch: AgentEventDispatch, ) {} /** Prompts awaiting individual turns. */ get nextTurn(): readonly UserMessage[] { - return this.current()['next-turn'] + return this.readState()['next-turn'] } /** Input awaiting the next step boundary. */ get nextStep(): readonly UserMessage[] { - return this.current()['next-step'] + return this.readState()['next-step'] } /** Whether either pending-message list contains work. */ get hasPending(): boolean { - const state = this.current() + const state = this.readState() return state['next-turn'].length > 0 || state['next-step'].length > 0 } @@ -119,7 +118,7 @@ export class ReactLoopInbox implements InboxContract { * @param message - message to append. */ append(target: InboxTarget, message: UserMessage): void { - this.splice(target, this.current()[target].length, 0, [message]) + this.splice(target, this.readState()[target].length, 0, [message]) } /** @@ -175,7 +174,7 @@ export class ReactLoopInbox implements InboxContract { /** Locate one pending identity across both owned lists. */ private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined { - const state = this.current() + const state = this.readState() for (const target of ['next-turn', 'next-step'] as const) { const index = state[target].findIndex(message => message.id === messageId) if (index >= 0) return { target, index } @@ -183,17 +182,6 @@ export class ReactLoopInbox implements InboxContract { return undefined } - /** Read the current durable projection state. */ - private current(): InboxState { - const state = this.projections.stateOf(this.session, 'inbox') - if (state === undefined) { - throw new Error( - `agent "${this.session.id}" cannot read inbox state: its projection registration is not active`, - ) - } - return state - } - /** Commit one normalized mutation and publish its live events. */ private mutate( target: InboxTarget, @@ -202,7 +190,7 @@ export class ReactLoopInbox implements InboxContract { inserted: UserMessage[], discardRemoved: boolean, ): UserMessage[] { - const state = this.current() + const state = this.readState() const inbox = state[target] const truncatedStart = Math.trunc(start) const offset = Number.isNaN(truncatedStart) ? 0 : truncatedStart diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ca4532af8c..60d4e2e8c5 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -9,26 +9,26 @@ import type { Volatile } from '@deepseek-ai/cosmokit' import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import z from '@deepseek-ai/schemastery' -import { z as zod } from 'zod' import { brandString } from '@deepseek-ai/dsh-brand' -import type { - Agent, - AgentFactory, - AgentHandle, - AgentOptions, - AgentSetup, - CreateAgentOptions, - ResumeAgentOptions, - SessionStartSource, - TurnBoundaryProjection, +import { + turnBoundaryProjectionDefinition, + type Agent, + type AgentFactory, + type AgentHandle, + type AgentOptions, + type AgentSetup, + type CreateAgentOptions, + type ResumeAgentOptions, + type SessionStartSource, + type InboxState, + type TurnBoundaryProjection, } from '@deepseek-ai/dsh-agent' import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' -import { interruptedTurnClosers, SessionLogOffset, SessionPreparation, SessionSeq } from '@deepseek-ai/dsh-session' +import { interruptedTurnClosers, SessionLogOffset, SessionPreparation } from '@deepseek-ai/dsh-session' import type { Session, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { SessionPersistenceNotFoundError } from '@deepseek-ai/dsh-session-persistence' import type { SessionHandle, SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import { ReactLoopAgent } from './agent.ts' @@ -36,6 +36,8 @@ import { inboxProjectionDefinition } from './inbox.ts' import { DEFAULT_MAX_PARALLEL_TOOL_CALLS } from './constants.ts' import type {} from './runtime-context.ts' +export { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' + /** Fiber states that cannot own or serve a new lifecycle. */ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.UNLOADING, @@ -43,57 +45,6 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) -const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ - openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), - lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), - lastStepBoundary: zod.object({ - kind: zod.union([zod.literal('start'), zod.literal('end')]), - seq: zod.number().int().nonnegative().transform(SessionSeq), - }).nullable(), - lastTurn: zod.number().int().nonnegative(), -}) - -/** Host projection of agent turn and step boundaries. */ -export const turnBoundaryProjectionDefinition = { - key: 'turnBoundary', - stateVersion: 2, - stateSchema: turnBoundaryProjectionSchema, - init: () => ({ - openTurnStartSeq: null, - lastStepStartSeq: null, - lastStepBoundary: null, - lastTurn: 0, - }), - apply: (state, event) => { - switch (event.type) { - case 'turn/start': - return { - ...state, - openTurnStartSeq: event.seq, - lastTurn: event.data.turn, - } - case 'turn/end': - return { - ...state, - openTurnStartSeq: null, - } - case 'step/start': - return { - ...state, - lastStepStartSeq: event.seq, - lastStepBoundary: { kind: 'start', seq: event.seq }, - } - case 'step/end': - return { - ...state, - lastStepBoundary: { kind: 'end', seq: event.seq }, - } - default: - return state - } - }, -} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> - /** Re-throw one collected failure as itself, or several as one `AggregateError`. */ function throwCollectedFailures(failures: readonly unknown[], message: string): void { if (failures.length === 1) throw failures[0] @@ -361,6 +312,8 @@ export class AgentLoop extends Service implements AgentFactory { private readonly ownership: FactoryOwnership /** Plain holder prevents Cordis from re-tracing the factory's dependency context through a caller shadow. */ private readonly runtime: { ctx: Context } + private inbox!: ProjectionHandle + private turnBoundary!: ProjectionHandle constructor(ctx: Context, config: Config) { super(ctx, 'agentLoop') @@ -372,19 +325,17 @@ export class AgentLoop extends Service implements AgentFactory { validateConfiguredAgents(this.config.agents) // Register only after every config validation above has passed, so a // rejected constructor leaves no projection unit behind. - const ownership = this.ownership = new FactoryOwnership(ctx.fiber) + this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } - ctx.effect(function* () { - const unregisterTurnBoundary = ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - yield unregisterTurnBoundary - const unregisterInbox = ctx.sessionProjections.register(inboxProjectionDefinition) - yield unregisterInbox - // Cordis skips later disposers after a rejection; finally still releases projections. - yield () => ownership.dispose().finally(() => { - unregisterInbox() - unregisterTurnBoundary() - }) - }, 'agentLoop.transactions()') + } + + protected async [Service.init](): Promise { + const ctx = this.runtime.ctx + const ownership = this.ownership + // Agents retain readers while fiber disposal removes global projection registrations. + ctx.effect(() => () => ownership.dispose(), 'agentLoop.transactions()') + this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + this.inbox = await ctx.sessionProjections.register(inboxProjectionDefinition) ctx.effect(() => ctx.agents.setFactory(this), 'agentLoop.setFactory()') ctx.systemPrompt.variable('provider', context => context.agent?.options.provider) ctx.systemPrompt.variable('model', context => context.agent?.options.model) @@ -599,9 +550,10 @@ export class AgentLoop extends Service implements AgentFactory { } } const untrack = this.ownership.track(dispose) + const { inbox, turnBoundary } = this try { unfollowOwner = ownerCtx.effect(function* () { - const created = machine = new ReactLoopAgent(loopCtx, id, options, session) + const created = machine = new ReactLoopAgent(loopCtx, id, options, session, inbox, turnBoundary) machineReady.resolve() // Adopt the scope on its original fiber so it cannot unload before teardown. // A loop already unloading during minting owns the provisional scope itself. diff --git a/packages/core/agent-loop/tests/inbox.spec.ts b/packages/core/agent-loop/tests/inbox.spec.ts index 739d18b990..ba908715e0 100644 --- a/packages/core/agent-loop/tests/inbox.spec.ts +++ b/packages/core/agent-loop/tests/inbox.spec.ts @@ -49,10 +49,10 @@ async function inboxAgent(rawId: string): Promise<{ onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const session = ctx.sessions.create(SessionId(rawId)) const agent = stubAgent(rawId, { ctx, session }) - const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)) + const inbox = new ReactLoopInbox(() => projection.stateOf(session), session, agentEvents(ctx, agent)) Object.assign(agent, { inbox }) return { ctx, session, agent, inbox } } @@ -67,9 +67,9 @@ async function reconstructPersistedInbox( const session = ctx.sessions.create(SessionId(rawId)) populate(session) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const agent = stubAgent(rawId, { ctx, session }) - const inbox = new ReactLoopInbox(ctx.sessionProjections, session, agentEvents(ctx, agent)) + const inbox = new ReactLoopInbox(() => projection.stateOf(session), session, agentEvents(ctx, agent)) try { void inbox.nextTurn } catch (error: unknown) { @@ -80,12 +80,12 @@ async function reconstructPersistedInbox( } describe('ReactLoopInbox', () => { - it('reads the shared projection without owning its registration', async () => { + it('retains inbox reads after the shared registration is released', async () => { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - const unregister = ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const session = ctx.sessions.create(SessionId('inbox-projection')) const pending = createUserMessage({ content: [{ type: 'text', text: 'pending' }], @@ -96,8 +96,8 @@ describe('ReactLoopInbox', () => { }) const agent = stubAgent('inbox-projection', { ctx, session }) const dispatch = agentEvents(ctx, agent) - const first = new ReactLoopInbox(ctx.sessionProjections, session, dispatch) - const second = new ReactLoopInbox(ctx.sessionProjections, session, dispatch) + const first = new ReactLoopInbox(() => projection.stateOf(session), session, dispatch) + const second = new ReactLoopInbox(() => projection.stateOf(session), session, dispatch) expect(first.nextTurn).toEqual([pending]) expect(second.nextTurn).toEqual([pending]) @@ -106,9 +106,12 @@ describe('ReactLoopInbox', () => { 'next-step': [], }) - unregister() - expect(ctx.sessionProjections.stateOf(session, 'inbox')).toBeUndefined() - expect(() => first.nextTurn).toThrow('its projection registration is not active') + await projection.dispose() + expect(ctx.sessionProjections.checkpoint(session).inbox?.val).toBeUndefined() + expect(first.nextTurn).toEqual([pending]) + first.clear() + expect(second.nextTurn).toEqual([]) + expect(ctx.sessionProjections.snapshot(session).values.inbox).toBeUndefined() }) it('rejects invalid durable coordinates and duplicate identities during reconstruction', async () => { @@ -141,10 +144,10 @@ describe('ReactLoopInbox', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(inboxProjectionDefinition) + const projection = await ctx.sessionProjections.register(inboxProjectionDefinition) const parent = ctx.sessions.create(SessionId('inbox-fork-parent')) const parentAgent = stubAgent('inbox-fork-parent', { ctx, session: parent }) - const parentInbox = new ReactLoopInbox(ctx.sessionProjections, parent, agentEvents(ctx, parentAgent)) + const parentInbox = new ReactLoopInbox(() => projection.stateOf(parent), parent, agentEvents(ctx, parentAgent)) const inherited = createUserMessage({ content: [{ type: 'text', text: 'parent pending' }], source: { kind: 'user' }, @@ -152,7 +155,7 @@ describe('ReactLoopInbox', () => { parentInbox.append('next-turn', inherited) const child = ctx.sessions.fork(parent, undefined, SessionId('inbox-fork-child')) const childAgent = stubAgent('inbox-fork-child', { ctx, session: child }) - const childInbox = new ReactLoopInbox(ctx.sessionProjections, child, agentEvents(ctx, childAgent)) + const childInbox = new ReactLoopInbox(() => projection.stateOf(child), child, agentEvents(ctx, childAgent)) expect(child.inheritedEventCount).toBe(parent.snapshotEvents().length) expect(childInbox.nextTurn).toEqual([inherited]) @@ -175,7 +178,7 @@ describe('ReactLoopInbox', () => { let observed: readonly UserMessage[] | undefined ctx.on('session/event', (subject, event) => { if (subject === session && event.type === 'agent/inbox/spliced') { - observed = ctx.sessionProjections.stateOf(session, 'inbox')?.['next-turn'] + observed = inbox.nextTurn } }) diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 9ae652df61..0dccc2a43b 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, onTestFinished, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import LlmRuntime, { createUserMessage, ToolCallId, LlmError, ReasoningEffortId, StreamChunk, expandAssistantStream } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -1912,6 +1912,7 @@ describe('agent loop', () => { defaultEffort: effort, }) const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -1923,6 +1924,7 @@ describe('agent loop', () => { }) ctx.llm.registerAdapter(['mock'], adapter) + await vi.waitFor(() => { expect(ctx.agents.list()).toHaveLength(1) }) const agent = ctx.agents.list()[0]! expect(agent).toBeDefined() expect(agent.id).toBe(agent.session.id) @@ -1940,6 +1942,7 @@ describe('agent loop', () => { it('attaches config agent cwd to the fresh session header', async () => { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -1950,6 +1953,7 @@ describe('agent loop', () => { agents: [{ id: SessionId('config-agent'), provider: 'mock', model: 'mock', cwd: '/work/project' }], }) + await vi.waitFor(() => { expect(ctx.agents.list()).toHaveLength(1) }) const agent = ctx.agents.list()[0]! expect(agent.session.header.cwd).toBe('/work/project') }) diff --git a/packages/core/agent-loop/tests/request-freeze.spec.ts b/packages/core/agent-loop/tests/request-freeze.spec.ts index 560be88627..ccb4c89770 100644 --- a/packages/core/agent-loop/tests/request-freeze.spec.ts +++ b/packages/core/agent-loop/tests/request-freeze.spec.ts @@ -3,12 +3,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import type { Agent } from '@deepseek-ai/dsh-agent' +import { turnBoundaryProjectionDefinition, type Agent } from '@deepseek-ai/dsh-agent' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createAssistantMessage, createUserMessage, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, ToolSchema } from '@deepseek-ai/dsh-llm' import { Session, SessionId, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import * as values from '@deepseek-ai/dsh-util-values' +import { inboxProjectionDefinition } from '../src/inbox.ts' import { ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -73,7 +74,9 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(userEvent.data.content)).toBe(false) expect(Object.isFrozen(assistantEvent.data.message)).toBe(false) ctx.effect(() => ctx.sessions.enter(session)) - const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session) + const agent = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, + await loopCtx.sessionProjections.register(inboxProjectionDefinition), + await loopCtx.sessionProjections.register(turnBoundaryProjectionDefinition)) cleanups.push(async () => { agent.cancel({ kind: 'disposed' }) await agent.whenIdle() @@ -122,7 +125,9 @@ describe('loop-owned request freezing', () => { expect(Object.isFrozen(session.deriveMessages())).toBe(false) expect(freeze.mock.calls.filter(([value]) => value === userEvent.data)).toHaveLength(1) expect(freeze.mock.calls.filter(([value]) => value === replacement.data)).toHaveLength(1) - const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session) + const resumed = new ReactLoopAgent(loopCtx, id, { provider: 'mock', model: 'mock' }, session, + await loopCtx.sessionProjections.register(inboxProjectionDefinition), + await loopCtx.sessionProjections.register(turnBoundaryProjectionDefinition)) cleanups.push(async () => { resumed.cancel({ kind: 'disposed' }) await resumed.whenIdle() diff --git a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts index 4ed14d48a4..cb4e272ecf 100644 --- a/packages/core/agent-loop/tests/resource-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/resource-lifecycle.spec.ts @@ -38,7 +38,6 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed const aborted = Promise.withResolvers() let workExited = false let scopeDisposedBeforeWorkExit = false - let projectionPresentAtWorkExit = false let scopeDisposed = false let abortHandled = 0 const controller = new AbortController() @@ -57,8 +56,6 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed const work = agent.runMaintenance(async (signal) => { signal.addEventListener('abort', () => { aborted.resolve(undefined) }, { once: true }) await release.promise - projectionPresentAtWorkExit = projections.stateOf(agent.session, 'inbox') !== undefined - && projections.stateOf(agent.session, 'turnBoundary') !== undefined workExited = true }) const disposal = loop.dispose() @@ -71,11 +68,10 @@ it.each([0, 1, 2])('retains Agent resources until all work exits with %i failed await Promise.all([disposal, ownerDisposal]) for (const { handle, error } of failed) await expect(handle.dispose()).rejects.toBe(error) expect(scopeDisposedBeforeWorkExit).toBe(false) - expect(projectionPresentAtWorkExit).toBe(true) expect(scopeDisposed).toBe(true) expect(abortHandled).toBe(1) - expect(projections.stateOf(agent.session, 'inbox')).toBeUndefined() - expect(projections.stateOf(agent.session, 'turnBoundary')).toBeUndefined() + expect(projections.checkpoint(agent.session).inbox).toBeUndefined() + expect(projections.checkpoint(agent.session).turnBoundary).toBeUndefined() expect(ctx.agents.get(agent.id)).toBeUndefined() expect(ctx.sessions.get(agent.session.id)).toBeUndefined() }) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2addf9cba2..02d84f2fba 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -288,7 +288,7 @@ describe('agent scope lifecycle', () => { const { ctx, loopFiber } = await harnessWithLoop() onTestFinished(() => ctx.fiber.dispose()) const cold = ctx.sessions.create(SessionId('projection-before-any-agent')) - expect(ctx.sessionProjections.stateOf(cold, 'inbox')).toEqual({ 'next-turn': [], 'next-step': [] }) + expect(ctx.sessionProjections.checkpoint(cold).inbox?.val).toEqual({ 'next-turn': [], 'next-step': [] }) let first!: Awaited> let second!: Awaited> const firstOwner = await ctx.plugin(Object.assign(async (inner: Context) => { @@ -304,15 +304,15 @@ describe('agent scope lifecycle', () => { }) }, { inject: ['agents'] })) - expect(ctx.sessionProjections.stateOf(first.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(first.agent.session).inbox?.val).toBeDefined() await firstOwner.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeDefined() await secondOwner.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeDefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeDefined() await Promise.all([first.dispose(), second.dispose()]) await loopFiber.dispose() - expect(ctx.sessionProjections.stateOf(second.agent.session, 'inbox')).toBeUndefined() + expect(ctx.sessionProjections.checkpoint(second.agent.session).inbox?.val).toBeUndefined() }) it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { @@ -567,7 +567,7 @@ describe('agent scope lifecycle', () => { await ctx.fiber.dispose() }) - it('factory unload during scope minting skips setup and awaits provisional cleanup', async () => { + it('factory unload during scope minting drains provisional setup and cleanup', async () => { const { ctx, loopFiber } = await harnessWithLoop() let unloaded = false let setupCalls = 0 diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index deb3a08154..a8cd2318c6 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -60,5 +60,8 @@ "@deepseek-ai/dsh-util-values": "workspace:*", "@deepseek-ai/dsh-workspace": "workspace:*", "@deepseek-ai/cordis": "workspace:~" + }, + "dependencies": { + "zod": "4.4.3" } } diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 54dd8a805d..b4ba4e77be 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -18,7 +18,7 @@ import type { AgentOptions, SessionStartSource } from './runtime-types.ts' export * from './runtime-types.ts' export * from './types.ts' -export type * from './projection.ts' +export { turnBoundaryProjectionDefinition } from './projection.ts' export * from './consumed-work.ts' export * from './model-selection.ts' export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts' diff --git a/packages/core/agent/src/projection.ts b/packages/core/agent/src/projection.ts index 89c10ac858..64333a692f 100644 --- a/packages/core/agent/src/projection.ts +++ b/packages/core/agent/src/projection.ts @@ -1,5 +1,8 @@ +/** Shared pure fold of the Session's turn and step events. */ import type { TurnBoundaryProjection } from './types.ts' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { SessionSeq } from '@deepseek-ai/dsh-session' +import { z as zod } from 'zod' declare module '@deepseek-ai/dsh-session-projection/types' { interface SessionProjectionStateMap { @@ -8,4 +11,53 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -export {} +const turnBoundaryProjectionSchema: zod.ZodType = zod.object({ + openTurnStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), + lastStepStartSeq: zod.number().int().nonnegative().transform(SessionSeq).nullable(), + lastStepBoundary: zod.object({ + kind: zod.union([zod.literal('start'), zod.literal('end')]), + seq: zod.number().int().nonnegative().transform(SessionSeq), + }).nullable(), + lastTurn: zod.number().int().nonnegative(), +}) + +/** Host projection of agent turn and step boundaries. */ +export const turnBoundaryProjectionDefinition = { + key: 'turnBoundary', + stateVersion: 2, + stateSchema: turnBoundaryProjectionSchema, + init: () => ({ + openTurnStartSeq: null, + lastStepStartSeq: null, + lastStepBoundary: null, + lastTurn: 0, + }), + apply: (state, event) => { + switch (event.type) { + case 'turn/start': + return { + ...state, + openTurnStartSeq: event.seq, + lastTurn: event.data.turn, + } + case 'turn/end': + return { + ...state, + openTurnStartSeq: null, + } + case 'step/start': + return { + ...state, + lastStepStartSeq: event.seq, + lastStepBoundary: { kind: 'start', seq: event.seq }, + } + case 'step/end': + return { + ...state, + lastStepBoundary: { kind: 'end', seq: event.seq }, + } + default: + return state + } + }, +} satisfies ProjectionDefinition<'turnBoundary', TurnBoundaryProjection> diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index 87860844ee..77d2f81ab3 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -149,7 +149,7 @@ async function harness(options: HarnessOptions = {}) { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit(options.stateVersion)) + await ctx.sessionProjections.register(marksUnit(options.stateVersion)) const fiber = await ctx.plugin(SessionProjectionCache, options.config ?? { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return { ctx, root, fiber, cache: ctx.sessionProjectionCache } } @@ -300,7 +300,7 @@ describe('SessionProjectionCache write policy', () => { await ctx.sessionProjectionCache.write(clean) expect((await storedRows(root, clean.id))?.['cache-test/marks']).toEqual({ ver: 1, seq: -1, val: null }) // A unit whose state violates the plain-JSON contract fails the write loud. - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'cache-test/marks2', stateSchema: z.custom>(() => true), init: () => new Map(), @@ -336,7 +336,7 @@ describe('SessionProjectionCache write policy', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) // A directory where the record document must land makes the atomic @@ -387,7 +387,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/secondary-marks': { ver: 1, seq: SessionSeq(6), val: { marks: ['secondary'] } }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(secondaryMarksUnit) + await ctx.sessionProjections.register(secondaryMarksUnit) // The block carries one watermark: the seq every served value has folded // through at least, so the lower of the two rows names the block. @@ -457,7 +457,7 @@ describe('SessionProjectionCache listing read', () => { }, ) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) const seededHeader = { ...headerOf(id), isSeeded: true } // An older format generation never serves the full block, but its title @@ -509,7 +509,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/secret': { ver: 1, seq: SessionSeq(4), val: 'private prompt text' }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(secretUnit) + await ctx.sessionProjections.register(secretUnit) const header = headerOf(SessionId('host-state')) expect(cache.cachedSnapshot(header)).toEqual({ @@ -546,7 +546,7 @@ describe('SessionProjectionCache listing read', () => { 'cache-test/marks3': { ver: 1, seq: SessionSeq(4), val: { marks: ['b'] } }, }) const { ctx, cache } = await harness({ root }) - ctx.sessionProjections.register(marks3Unit) + await ctx.sessionProjections.register(marks3Unit) const block = cache.cachedSnapshot(headerOf(SessionId('multi-row'))) expect(block?.values).toEqual({ 'cache-test/marks': { marks: ['a'] }, @@ -752,7 +752,7 @@ describe('SessionProjectionCache cold-read seeding', () => { }) const { cache, ctx } = await harness({ root }) const apply = vi.fn((_state: number, _event: SessionEvent) => 1) - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'cache-test/count', stateSchema: z.number().int().nonnegative(), init: () => 0, @@ -794,7 +794,7 @@ describe('SessionProjectionCache cold-read seeding', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) // A directory where the record document must land makes the write-back diff --git a/packages/session/session-projection-cache/tests/fixtures.spec.ts b/packages/session/session-projection-cache/tests/fixtures.spec.ts index 2849ea1b81..1fd5126eeb 100644 --- a/packages/session/session-projection-cache/tests/fixtures.spec.ts +++ b/packages/session/session-projection-cache/tests/fixtures.spec.ts @@ -110,7 +110,7 @@ async function harness(root: string) { const ctx = await storageHarness(root) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return { ctx, cache: ctx.sessionProjectionCache } } @@ -324,7 +324,7 @@ describe('archived version recovery', () => { await ctx.plugin({ name: storageDomainName, inject: storageDomainInject, apply: storageDomainApply, Config: storageDomainConfig }, { backend: 'json' }) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleUnit) + await ctx.sessionProjections.register(titleUnit) const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) // The boot survives the broken record — this line rejecting IS the fixed bug. await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c6305ff877..cf0fd8eeeb 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -18,14 +18,14 @@ en: 3a15581f0b043e79 zh: 302fc8afe8fb45fa /deepseek-ai-dsh-session-projection/use-this-package/when-to-choose-it: - en: 90b7c26cb45be52a - zh: 386e294ec09607c1 + en: 7ea79ed69e0ea085 + zh: b7791be4c547495c /deepseek-ai-dsh-session-projection/use-this-package/define-a-projection-unit: en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: 6a19c075f826b3f7 - zh: c200277cb58062be + en: e10e44c46035dbdb + zh: dff312b52f4ec2ce /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 45f1c05fb7e08180 zh: f8d62c749899651a diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 79c5e0b213..61e2677e47 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -29,7 +29,7 @@ Mount `dsh-session-projection` wherever client carriers need current values of l ### When to choose it -Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. Skip it for host-only bookkeeping that no client reads: a unit without a `wire` block stays host-only. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. +Choose it when a domain keeps state that clients should see without re-deriving it — a todo list, a goal snapshot, conversation statistics. The registry drives units eagerly over committed events, so any registered unit's value is current by construction. A unit without a `wire` block keeps its state host-only. Host readers declare `sessionProjections` in their plugin dependencies and await registration before exposing synchronous reads. ### Define a projection unit @@ -55,10 +55,11 @@ const definition = { ### Register and read -`register(definition)` installs the unit; registrants with the same key and `stateVersion` share its cells, while an incompatible version or invalid `stateVersion` throws. Registration is an effect on the calling fiber, so the last unload removes the key and its cached cells. The returned disposer can be yielded into a composite `ctx.effect()` on the same fiber to keep the unit available through dependent cleanup. Carriers read a consistent synchronous cut over every client-visible unit with `snapshot(session)` — `{ asOfSeq, values }`, where `asOfSeq` is the seq of the last event every value reflects — and subscribe to per-change notifications with `onChanged(listener)`. `stateOf(session, key)` reads one unit's live read-only host state without computing unrelated views. +`await register(definition)` installs the unit and returns a handle with synchronous `stateOf(session)` reads and `dispose()`. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. ```text -const dispose = ctx.sessionProjections.register(definition) +const projection = await ctx.sessionProjections.register(definition) +const state = projection.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index cb44e32ef9..2a0ac169d7 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -29,7 +29,7 @@ kind: "package-reference" ### 何时选择 -当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。当维护的是无客户端读取的 host-only 记账时跳过:不带 `wire` 块的单元保持 host-only。host 读取方要么在插件 `inject` 中声明 `sessionProjections`,要么在注册表或必需 key 缺席时明确失败。贡献方可以继续通过 `ctx.inject(['sessionProjections'], ...)` 保持可选注册。 +当领域保存客户端应看到、但不应自行重新派生的状态——todo 清单、goal 快照、对话统计——时选择本包。注册表在已提交事件上主动驱动单元,因此任何已注册单元的值按构造即为当前值。不带 `wire` 块的单元将状态保留在 host。host 读取方在插件依赖中声明 `sessionProjections`,并等待注册完成后才提供同步读取。 ### 定义投影单元 @@ -55,10 +55,11 @@ const definition = { ### 注册与读取 -`register(definition)` 安装单元;具有相同 key 和 `stateVersion` 的注册方共享其 cell,版本不兼容或 `stateVersion` 非法时会 throw。注册是挂在调用方 fiber 上的 effect,因此最后一个注册方卸载后会移除 key 及其缓存 cell。返回的 disposer 可以 yield 到同一 fiber 上的复合 `ctx.effect()` 中,使单元在依赖它的清理完成前保持可用。载体用 `snapshot(session)` 对每个客户端可见单元读取一致的同步切面——`{ asOfSeq, values }`,其中 `asOfSeq` 是所有值共同反映到的最后一个事件的 seq——并用 `onChanged(listener)` 订阅逐变更通知。`stateOf(session, key)` 读取一个单元的实时只读 host 状态,不计算无关视图。 +`await register(definition)` 安装单元,返回提供同步 `stateOf(session)` 读取和 `dispose()` 的 handle。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 ```text -const dispose = ctx.sessionProjections.register(definition) +const projection = await ctx.sessionProjections.register(definition) +const state = projection.stateOf(session) const { asOfSeq, values } = ctx.sessionProjections.snapshot(session) ``` diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index ac6e63458f..9a27c2b98a 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -37,6 +37,19 @@ import type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts export type { SessionProjectionMap, SessionProjectionStateMap } from './types.ts' +/** An effect-owned registration with a reader retained independently of registration. */ +export interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** Unregister from global drives and snapshots without invalidating this reader. */ + dispose(): void | Promise +} + /** * One domain's state-driven computation unit: a pure synchronous fold plus * declarations and an optional client view — never an opaque getter. The framework drives @@ -156,8 +169,8 @@ interface UnitCell { } /** - * One live registration: the unit plus its per-session cells (dropped whole - * once the last registrant releases it). + * One shared unit and its per-session cells, retained by acquired readers after + * the final registration leaves the live table. * * `refs` exists because one unit definition already serves every session — the * cells are keyed by `Session` — while registrants are per-session: @@ -186,15 +199,11 @@ function cursorBefore(offset: SessionLogOffset): SessionSeqCursor { * raw result changes by `Object.is`. * Cells build lazily — a unit registered after events flowed, or a session * older than the registry, folds `init` over the in-memory log on first - * touch (event or read). Registration is an effect (disposer rides the - * calling fiber): an unloaded domain plugin's key disappears from snapshots - * and clients read it as capability absence. A host reader either declares - * `sessionProjections` in its plugin `inject` or fails explicitly when the - * registry or required key is absent. Contributors may preserve optional - * registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key - * share one unit and are counted: the same tool package mounted in N agent - * presets registers N times, and the key survives until the last one - * unloads. + * touch (event or read). Consumers declare `sessionProjections` as a required + * injection, await registration during activation, and retain its ready handle. + * Registrants sharing a key share one unit and are counted. The last release + * removes the unit from live drives, checkpoints, and client snapshots; retained + * handles still fold new events on demand using the original unit. */ export class SessionProjectionRegistry extends Service { private readonly registrations = new Map() @@ -223,12 +232,12 @@ export class SessionProjectionRegistry extends Service { } /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ register< K extends keyof SessionProjectionMap, @@ -237,22 +246,23 @@ export class SessionProjectionRegistry extends Service { definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, - ): () => void + ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, - ): () => void - register( + ): Promise> + async register( definition: ProjectionDefinition, - ): () => void { + ): Promise> { const wire = definition.wire as { viewSchema: ZodType view(state: S): unknown @@ -270,27 +280,34 @@ export class SessionProjectionRegistry extends Service { if (!Number.isSafeInteger(definition.stateVersion) || definition.stateVersion < 0) { throw new Error(`session projection ${JSON.stringify(definition.key)} stateVersion must be a non-negative integer, got ${String(definition.stateVersion)}`) } - const dispose = this.ctx.effect(function* (this: SessionProjectionRegistry) { + let registration!: Registration + const dispose = this.ctx.effect(() => { const key = erased.key const existing = this.registrations.get(key) if (existing === undefined) { - this.registrations.set(key, { def: erased, cells: new WeakMap(), refs: 1 }) + registration = { def: erased, cells: new WeakMap(), refs: 1 } + this.registrations.set(key, registration) } else { if (existing.def.stateVersion !== erased.stateVersion) { throw new Error(`session projection key ${JSON.stringify(key)} is already registered at stateVersion ${String(existing.def.stateVersion)}; refusing to share it with stateVersion ${String(erased.stateVersion)}`) } existing.refs += 1 + registration = existing } - yield () => { - const live = this.registrations.get(key) - /* v8 ignore next -- the disposer runs once per successful registration, so the entry it counted is still here */ - if (live === undefined) return - live.refs -= 1 - if (live.refs === 0) this.registrations.delete(key) + return () => { + registration.refs -= 1 + if (registration.refs === 0) this.registrations.delete(key) } - }.bind(this), 'sessionProjections.register()') - // oxlint-disable-next-line typescript/no-misused-promises -- synchronous cleanup; keep the Cordis disposer so effects can adopt it. - return dispose + }, 'sessionProjections.register()') + // Observe disposal during the asynchronous registration handoff before exposing a reader. + await Promise.resolve() + this.ctx.fiber.assertActive() + return { + dispose, + stateOf: (session) => { + return this.cellFor(registration, session).state as S + }, + } } /** @@ -309,24 +326,6 @@ export class SessionProjectionRegistry extends Service { return () => void dispose() } - /** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ - stateOf( - session: Session, - key: K, - ): SessionProjectionStateMap[K] | undefined { - const registration = this.registrations.get(key) - if (registration === undefined) return undefined - this.materializeCells(session) - return this.cellFor(registration, session).state as SessionProjectionStateMap[K] - } - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/packages/session/session-projection/tests/registration-reader.spec.ts b/packages/session/session-projection/tests/registration-reader.spec.ts new file mode 100644 index 0000000000..3ee393b85a --- /dev/null +++ b/packages/session/session-projection/tests/registration-reader.spec.ts @@ -0,0 +1,113 @@ +/** Retained readers follow Session events independently of registry participation. */ +import { Context } from '@deepseek-ai/cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { z } from 'zod' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + 'test/reader': number + 'test/unrelated-reader': number + } + interface SessionProjectionMap { + 'test/reader': number + } +} + +const definition = { + key: 'test/reader', + stateSchema: z.number(), + stateVersion: 1, + init: () => 0, + apply: (state: number) => state + 1, + wire: { viewSchema: z.number(), view: (state: number) => state }, +} satisfies ProjectionDefinition<'test/reader', number> + +async function setup() { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + const owner = await ctx.plugin(Object.assign(function registrationOwner() {}, { inject: ['sessionProjections'] })) + const session = ctx.sessions.create() + return { ctx, owner, session } +} + +describe('retained projection readers', () => { + it.each(['handle', 'fiber'] as const)('reads new events after %s disposal without rejoining snapshots or notifications', async (release) => { + const { ctx, owner, session } = await setup() + const apply = vi.fn(definition.apply) + const handle = await owner.ctx.sessionProjections.register({ ...definition, apply }) + const changed = vi.fn() + ctx.sessionProjections.onChanged(changed) + expect(handle.stateOf(session)).toBe(0) + session.append('turn/start', { turn: 1 }) + expect(handle.stateOf(session)).toBe(1) + expect(changed).toHaveBeenCalledOnce() + if (release === 'handle') await handle.dispose() + else await owner.dispose() + session.append('turn/start', { turn: 2 }) + expect(apply).toHaveBeenCalledTimes(1) + expect(handle.stateOf(session)).toBe(2) + expect(apply).toHaveBeenCalledTimes(2) + expect(changed).toHaveBeenCalledOnce() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + const later = ctx.sessions.create() + later.append('turn/start', { turn: 1 }) + expect(handle.stateOf(later)).toBe(1) + }) + + it('keeps the original unit when the same key is registered with a new version', async () => { + const { ctx, owner, session } = await setup() + const original = await owner.ctx.sessionProjections.register(definition) + expect(original.stateOf(session)).toBe(0) + await original.dispose() + const replacement = await ctx.sessionProjections.register({ ...definition, stateVersion: 2, apply: state => state + 10 }) + session.append('turn/start', { turn: 1 }) + expect(original.stateOf(session)).toBe(1) + expect(replacement.stateOf(session)).toBe(10) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/reader': 10 }) + }) + + it('reads only the selected unit without materializing unrelated registrations', async () => { + const { ctx, owner, session } = await setup() + const unrelatedInit = vi.fn(() => 0) + await ctx.sessionProjections.register({ + key: 'test/unrelated-reader', stateSchema: z.number(), stateVersion: 1, + init: unrelatedInit, apply: state => state, + }) + const handle = await owner.ctx.sessionProjections.register(definition) + expect(handle.stateOf(session)).toBe(0) + expect(unrelatedInit).not.toHaveBeenCalled() + }) + + it('rejects initialization when the owner unloads before the handle is returned', async () => { + const { ctx, owner, session } = await setup() + const registration = owner.ctx.sessionProjections.register(definition) + const rejected = expect(registration).rejects.toThrow() + await owner.dispose() + await rejected + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + }) + + it.each(['twice', 'owner-unload'] as const)('releases one reference once under %s disposal', async (release) => { + const { ctx, owner, session } = await setup() + const retained = await ctx.sessionProjections.register(definition) + const handle = await owner.ctx.sessionProjections.register(definition) + if (release === 'twice') { + await handle.dispose() + await handle.dispose() + } else { + await Promise.all([owner.dispose(), Promise.resolve(handle.dispose())]) + } + session.append('turn/start', { turn: 1 }) + expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/reader': 1 }) + expect(handle.stateOf(session)).toBe(1) + await retained.dispose() + expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) + }) + +}) diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index de7ecdde60..189bcf96f2 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -165,7 +165,7 @@ describe('SessionProjectionRegistry drive', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(cutUnit()) + const cut = await ctx.sessionProjections.register(cutUnit()) const inherited: SessionEvent[] = [ { type: 'turn/start', seq: SessionSeq(0), time: 1, data: { turn: 1 } }, { type: 'turn/end', seq: SessionSeq(1), time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, @@ -176,7 +176,7 @@ describe('SessionProjectionRegistry drive', () => { meta: { isSeeded: true }, }) - expect(ctx.sessionProjections.stateOf(session, 'test/cut')).toBe(inherited.length) + expect(cut.stateOf(session)).toBe(inherited.length) const restored = ctx.sessionProjections.restore( {}, inherited, @@ -197,12 +197,12 @@ describe('SessionProjectionRegistry drive', () => { inherited, SessionLogOffset(0), ).asOfSeq).toBe(1) - expect(ctx.sessionProjections.stateOf(prepared, 'test/cut')).toBe(inherited.length) + expect(cut.stateOf(prepared)).toBe(inherited.length) }) it('drives a registered unit over committed events and snapshots the current value', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['a']) mark(session, ['a', 'b']) const snapshot = ctx.sessionProjections.snapshot(session) @@ -213,7 +213,7 @@ describe('SessionProjectionRegistry drive', () => { it('builds the cell lazily from the full log for a unit registered after events flowed', async () => { const { ctx, session } = await harness() mark(session, ['pre-registration']) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['pre-registration'] }) // The lazily-built cell then continues on the live drive path. mark(session, ['after']) @@ -222,7 +222,7 @@ describe('SessionProjectionRegistry drive', () => { it('serves init-derived state and asOfSeq -1 for an empty log', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const snapshot = ctx.sessionProjections.snapshot(session) expect(snapshot.asOfSeq).toBe(-1) expect(snapshot.values['test/marks']).toEqual({ marks: [] }) @@ -230,7 +230,7 @@ describe('SessionProjectionRegistry drive', () => { it('notifies onChanged with the validated view and the causing seq, and skips same-reference applies', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const seen: { key: string; value: unknown; seq: SessionSeq; sessionId: string }[] = [] ctx.sessionProjections.onChanged((changedSession, key, value, seq) => { seen.push({ key, value, seq, sessionId: String(changedSession.id) }) @@ -244,19 +244,19 @@ describe('SessionProjectionRegistry drive', () => { it('does not compute a view while no change listener exists', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + const handle = await ctx.sessionProjections.register(stableViewUnit(view)) session.append('turn/start', { turn: 1 }) session.append('turn/start', { turn: 2 }) - expect(ctx.sessionProjections.stateOf(session, 'test/stable-view')?.revision).toBe(2) + expect(handle.stateOf(session).revision).toBe(2) expect(view).not.toHaveBeenCalled() }) it('publishes the first observed view and suppresses later same-reference views', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + await ctx.sessionProjections.register(stableViewUnit(view)) const seen: unknown[] = [] ctx.sessionProjections.onChanged((_session, key, value) => { @@ -277,7 +277,7 @@ describe('SessionProjectionRegistry drive', () => { it('publishes the first view after an unobserved state change', async () => { const { ctx, session } = await harness() const view = vi.fn((state: StableViewState) => state.value) - ctx.sessionProjections.register(stableViewUnit(view)) + await ctx.sessionProjections.register(stableViewUnit(view)) const first: unknown[] = [] const stop = ctx.sessionProjections.onChanged((_session, key, value) => { if (key === 'test/stable-view') first.push(value) @@ -305,7 +305,7 @@ describe('SessionProjectionRegistry drive', () => { const stateByEvent = new Map() const viewByState = new Map() const computedViews: MarksView[] = [] - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'test/marks', stateSchema: marksViewSchema.nullable(), init: () => initialState, @@ -431,7 +431,7 @@ describe('SessionProjectionRegistry drive', () => { it('drives independently per session (cells are per-session watermarks)', async () => { const { ctx, session } = await harness() const other = ctx.sessions.create() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['one']) mark(other, ['two']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['one'] }) @@ -440,70 +440,71 @@ describe('SessionProjectionRegistry drive', () => { it('updates host-only units without publishing them to wire listeners', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + const count = await ctx.sessionProjections.register(countUnit()) const changedKeys: string[] = [] ctx.sessionProjections.onChanged((_session, key) => { changedKeys.push(key) }) session.append('turn/start', { turn: 1 }) expect(changedKeys).toEqual([]) - expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1) + expect(count.stateOf(session)).toBe(1) expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/marks': { marks: [] } }) }) it('shares one unit between registrants of the same key', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // One definition already serves every session (cells are keyed by // Session), and registrants are per-session now: an agent preset mounts // the same tool package once per agent. - expect(() => ctx.sessionProjections.register(marksUnit())).not.toThrow() + const handle = await ctx.sessionProjections.register(marksUnit()) + expect(typeof handle.stateOf).toBe('function') mark(session, ['kept']) expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) }) it('keeps the unit until the last registrant releases it', async () => { const { ctx, session } = await harness() - const first = ctx.sessionProjections.register(marksUnit()) - const second = ctx.sessionProjections.register(marksUnit()) + const first = await ctx.sessionProjections.register(marksUnit()) + const second = await ctx.sessionProjections.register(marksUnit()) mark(session, ['kept']) - first() + await first.dispose() // The regression this counts against: without last-release semantics, one // session ending strips the projection from every other live session, // because the first registrant owns the only disposer. expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['kept'] }) - second() + await second.dispose() expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) it('refuses to share a key across a stateVersion change', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // The one incompatibility a runtime comparison can name: the versioned // contract says the cached state shape differs, so the two cannot share // cells. Everything else about a definition is functions. - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) - .toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 9 })) + .rejects.toThrow(/already registered at stateVersion 1; refusing to share it with stateVersion 9/) }) it('rejects a non-integer or negative stateVersion at register time', async () => { const { ctx } = await harness() - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).toThrow(/stateVersion/) - expect(() => ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 })).toThrow(/stateVersion/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: -1 })).rejects.toThrow(/stateVersion/) + await expect(ctx.sessionProjections.register({ ...marksUnit(), stateVersion: 1.5 })).rejects.toThrow(/stateVersion/) }) it('register() disposer removes the key (with its cells) and frees it for re-registration', async () => { const { ctx, session } = await harness() - const dispose = ctx.sessionProjections.register(marksUnit()) + const dispose = await ctx.sessionProjections.register(marksUnit()) mark(session, ['cached']) - dispose() + await dispose.dispose() expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) // Fresh registration rebuilds from the log, not from a stale cell. expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toEqual({ marks: ['cached'] }) }) @@ -511,8 +512,8 @@ describe('SessionProjectionRegistry drive', () => { it('removes registrations and change listeners when their owning fiber unloads (HMR safety)', async () => { const { ctx, session } = await harness() const notifications: string[] = [] - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.sessionProjections.register(marksUnit()) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + await inner.sessionProjections.register(marksUnit()) inner.sessionProjections.onChanged((_session, key) => { notifications.push(key) }) @@ -525,38 +526,36 @@ describe('SessionProjectionRegistry drive', () => { expect(ctx.sessionProjections.snapshot(session).values).toEqual({}) }) - it('keeps a yielded registration live until the composite finishes using it', async () => { + it('retains readable state during owner teardown', async () => { const { ctx, session } = await harness() onTestFinished(async () => { await ctx.fiber.dispose() }) let observed: MarksState | undefined - const fiber = await ctx.plugin(Object.assign((inner: Context) => { - inner.effect(function* () { - yield inner.sessionProjections.register(marksUnit()) - yield () => { observed = inner.sessionProjections.stateOf(session, 'test/marks') } - }) + const fiber = await ctx.plugin(Object.assign(async (inner: Context) => { + const handle = await inner.sessionProjections.register(marksUnit()) + inner.effect(() => () => { observed = handle.stateOf(session) }) }, { inject: ['sessionProjections'] })) mark(session, ['live']) await fiber.dispose() expect(observed).toEqual({ marks: ['live'] }) - expect(ctx.sessionProjections.stateOf(session, 'test/marks')).toBeUndefined() + expect(ctx.sessionProjections.snapshot(session).values['test/marks']).toBeUndefined() }) it('snapshot serves client views and excludes host-only state', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + const count = await ctx.sessionProjections.register(countUnit()) mark(session, ['a', 'b']) const values = ctx.sessionProjections.snapshot(session).values expect(values['test/marks']).toEqual({ marks: ['a', 'b'] }) expect('test/count' in values).toBe(false) - expect(ctx.sessionProjections.stateOf(session, 'test/count')).toBe(1) + expect(count.stateOf(session)).toBe(1) expect('test/unregistered' in values).toBe(false) }) it('checkpoints every persisted unit with its stateVersion and per-cell watermark', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register({ ...countUnit(), stateVersion: 7 }) const markEvent = mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) expect(rows['test/marks']).toEqual({ ver: 1, seq: markEvent.seq, val: { marks: ['a'] } }) @@ -568,7 +567,7 @@ describe('SessionProjectionRegistry drive', () => { it('checkpoint states are detached clones — mutating them cannot corrupt the watermark cache', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) mark(session, ['a']) const rows = ctx.sessionProjections.checkpoint(session) // Hostile (or merely careless) consumer mutates the handed-out state. @@ -582,8 +581,8 @@ describe('SessionProjectionRegistry drive', () => { it('restoreFloor anchors one below the lowest usable watermark and at 0 for missing or mismatched rows', async () => { const { ctx } = await harness() expect(ctx.sessionProjections.restoreFloor({})).toBeUndefined() // no unit registered - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) expect(ctx.sessionProjections.restoreFloor({})).toBe(0) // Lowest usable watermark is count's 5 → the anchored tail starts AT 5 // (one below the first needed seq 6), so the read proves seq 5 still exists. @@ -605,8 +604,8 @@ describe('SessionProjectionRegistry drive', () => { it('restore folds the tail past each usable row and refolds from init on version mismatch', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const tail: SessionEvent[] = [ { type: 'test/mark', seq: SessionSeq(3), time: 3, data: { marks: ['new'] } }, { type: 'turn/end', seq: SessionSeq(4), time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, @@ -639,8 +638,8 @@ describe('SessionProjectionRegistry drive', () => { it('restore over a suffix folds only past each row watermark and serves an exact empty-tail cut', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['done'] } }, 'test/count': { ver: 1, seq: SessionSeq(2), val: 3 }, @@ -675,8 +674,8 @@ describe('SessionProjectionRegistry drive', () => { it('viewCheckpoint serves version-matching rows without any log and skips mismatched keys', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const values = ctx.sessionProjections.viewCheckpoint({ 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['stored'] } }, 'test/count': { ver: 99, seq: SessionSeq(4), val: 5 }, // mismatched: absent @@ -688,8 +687,8 @@ describe('SessionProjectionRegistry drive', () => { it('viewCheckpoint and restore exclude host-only state while retaining its checkpoint', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/marks': { ver: 1, seq: SessionSeq(4), val: { marks: ['stored'] } }, 'test/count': { ver: 1, seq: SessionSeq(4), val: 5 }, @@ -713,7 +712,7 @@ describe('SessionProjectionRegistry drive', () => { it('rejects version-matching rows whose state no longer matches the registered schema', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(marksUnit()) + await ctx.sessionProjections.register(marksUnit()) const drifted = { 'test/marks': { ver: 1, seq: SessionSeq(2), val: { marks: 'not-an-array' } }, } @@ -730,7 +729,7 @@ describe('SessionProjectionRegistry drive', () => { it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => { const { ctx } = await harness() - ctx.sessionProjections.register(countUnit()) + await ctx.sessionProjections.register(countUnit()) const rows = { 'test/count': { ver: 1, seq: SessionSeq(9), val: 10 } } // The anchored floor sits ON the watermark, so the tail read must return // at least seq 9 from an intact log… @@ -775,7 +774,7 @@ describe('SessionProjectionRegistry drive', () => { it('fails loud when a unit view violates its own schema (async unit output is unrepresentable)', async () => { const { ctx, session } = await harness() - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: 'test/marks', stateSchema: z.object({ marks: z.array(z.string()) }).nullable(), init: () => null as MarksState, From 08e48f34b583f19453305f78dbc0a554c2099890 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 12:03:36 +0800 Subject: [PATCH 025/287] refactor(plugins): migrate projection consumers to retained handles --- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + packages/api/session-controller/src/agent.ts | 30 +- packages/api/session-controller/src/index.ts | 37 +- packages/api/session-controller/src/list.ts | 32 +- .../src/model-selection-projection.ts | 12 +- .../tests/agent.host.spec.ts | 16 +- .../commands-queue-attachment.host.spec.ts | 2 +- .../tests/controller.host.spec.ts | 6 +- .../tests/list-scheduling.host.spec.ts | 8 +- .../tests/projection-discovery.host.spec.ts | 6 +- .../tests/session-cold.host.spec.ts | 42 +- .../tests/session-fork.host.spec.ts | 44 +- .../session-history-journal.host.spec.ts | 12 +- .../tests/session-list-blank.host.spec.ts | 2 +- .../tests/session-models.host.spec.ts | 44 +- .../session-open-workspace-path.host.spec.ts | 36 +- .../tests/session-presets.host.spec.ts | 4 +- .../tests/session-projections.host.spec.ts | 86 ++-- .../tests/session-rename.host.spec.ts | 10 +- .../tests/session-search.host.spec.ts | 56 +-- .../tests/subagent-catalog.host.spec.ts | 4 +- .../session-controller/tests/test-remote.ts | 33 +- .../tests/transport.host.spec.ts | 2 +- .../agent-instructions/README.i18n.yaml | 4 +- packages/context/agent-instructions/README.md | 2 +- .../context/agent-instructions/README.zh.md | 2 +- .../context/agent-instructions/src/index.ts | 23 +- .../tests/agent-instructions.spec.ts | 33 +- .../harness-dev-context/README.i18n.yaml | 4 +- .../context/harness-dev-context/README.md | 2 +- .../context/harness-dev-context/README.zh.md | 2 +- .../context/harness-dev-context/src/index.ts | 8 +- packages/context/time-context/src/index.ts | 8 +- .../time-context/tests/time-context.spec.ts | 6 +- packages/context/tmux-context/src/index.ts | 12 +- .../deliverables/tool-present/src/index.ts | 11 +- .../tool-present/tests/built-errors.e2e.ts | 6 +- .../tool-present/tests/present.spec.ts | 9 +- .../experimental/agent-team/README.i18n.yaml | 8 +- packages/experimental/agent-team/README.md | 4 +- packages/experimental/agent-team/README.zh.md | 4 +- packages/experimental/agent-team/src/index.ts | 26 +- .../experimental/agent-team/src/journal.ts | 9 +- .../tests/projection-events.spec.ts | 6 +- .../agent-team/tests/team.spec.ts | 60 ++- .../claude-code-mods/README.i18n.yaml | 4 +- .../experimental/claude-code-mods/README.md | 2 +- .../claude-code-mods/README.zh.md | 2 +- .../claude-code-mods/src/host-ops.ts | 34 +- .../claude-code-mods/src/index.ts | 27 +- .../claude-code-mods/tests/bridge.spec.ts | 4 +- .../claude-code-mods/tests/define-mod.spec.ts | 8 +- .../claude-code-mods/tests/host-ops.spec.ts | 42 +- .../tests/activation.host.spec.ts | 6 +- .../hooks-claude-code/src/index.ts | 34 +- .../hooks-claude-code/tests/coverage-cases.ts | 2 +- .../experimental/hooks-codex/src/index.ts | 71 +-- .../hooks-codex/tests/coverage-cases.ts | 2 +- .../tests/provider.spec.ts | 4 +- .../tool-session-query/src/index.ts | 25 +- .../tool-session-query/src/operations.ts | 423 ++++++++++-------- .../src/workspace-access.ts | 21 +- .../tests/sqlite-integration.spec.ts | 8 +- .../tests/tool-session-query.spec.ts | 24 +- packages/fs/tool-fs/tests/tools.spec.ts | 2 +- packages/goal/goal/src/index.ts | 19 +- packages/goal/goal/tests/goal.spec.ts | 12 +- packages/goal/goal/tests/projection.spec.ts | 5 +- packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 1 + packages/goal/tool-goal/README.zh.md | 1 + packages/goal/tool-goal/package.json | 3 +- packages/goal/tool-goal/src/authority.ts | 140 +++--- packages/goal/tool-goal/src/index.ts | 13 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 49 +- .../permission-presets/README.i18n.yaml | 8 +- .../interaction/permission-presets/README.md | 4 +- .../permission-presets/README.zh.md | 4 +- .../permission-presets/src/index.ts | 14 +- .../tests/permission-presets.spec.ts | 19 +- .../interaction/user-questions/src/index.ts | 21 +- .../tests/user-questions.spec.ts | 2 +- packages/llm/llm-retry/src/index.ts | 20 +- packages/llm/llm-retry/tests/retry.spec.ts | 29 +- packages/llm/token-meter/src/index.ts | 10 +- .../context-breakdown-projection.spec.ts | 3 +- .../token-meter/tests/route-pricing.spec.ts | 6 +- .../llm/token-meter/tests/token-meter.spec.ts | 8 +- packages/plan/plan-mode/README.i18n.yaml | 8 +- packages/plan/plan-mode/README.md | 4 +- packages/plan/plan-mode/README.zh.md | 4 +- packages/plan/plan-mode/src/index.ts | 18 +- .../plan/plan-mode/tests/integration.spec.ts | 3 +- .../plan/plan-mode/tests/plan-mode.spec.ts | 21 +- .../plan/plan-mode/tests/projection.spec.ts | 8 +- .../preset/agent-preset-registry/src/index.ts | 16 +- .../tests/registry.spec.ts | 6 +- packages/sandbox/sandbox-policy/src/index.ts | 11 +- .../sandbox-policy/tests/policy.spec.ts | 4 +- packages/session/session-stats/src/index.ts | 8 +- .../tests/provider.spec.ts | 4 +- packages/session/session-title/src/index.ts | 19 +- .../session-title/tests/projection.spec.ts | 6 +- .../session-title/tests/provider.spec.ts | 4 +- packages/session/session-title/tsconfig.json | 1 - .../session/session-turn-outline/src/index.ts | 8 +- .../tests/projection.spec.ts | 2 +- .../session/working-directory/src/index.ts | 13 +- packages/shell/tool-bash/tests/tools.spec.ts | 2 +- packages/shell/tool-pwsh/tests/tools.spec.ts | 2 +- .../fixtures/loader/scoped-tool-subagent.ts | 4 +- .../tests/subagent-dsh-sdk.spec.ts | 2 + packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- packages/subagent/subagent/package.json | 3 - packages/subagent/subagent/src/index.ts | 16 +- .../subagent/subagent/tests/catalog.spec.ts | 4 +- .../tests/continuation-inheritance.spec.ts | 2 +- .../subagent/tests/continuation.spec.ts | 5 + .../subagent/subagent/tests/control.spec.ts | 2 + .../subagent/tests/list-children.spec.ts | 9 +- .../subagent/subagent/tests/service.spec.ts | 4 +- .../subagent/tests/timing-projection.spec.ts | 8 +- packages/subagent/tool-subagent/src/index.ts | 14 +- .../src/model-selection-state.ts | 14 +- .../subagent/tool-subagent/tests/harness.ts | 7 +- .../tests/model-selection-settings.spec.ts | 41 +- .../tool-subagent/tests/tool-subagent.spec.ts | 20 +- packages/terminal/terminal-bash/src/index.ts | 9 +- .../terminal-bash/tests/index.spec.ts | 2 +- .../tests/agent-loop-testkit.spec.ts | 2 +- packages/todo/tool-todo/src/index.ts | 6 +- pnpm-lock.yaml | 7 + 136 files changed, 1253 insertions(+), 1029 deletions(-) diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 94dd4e518f..5dc133470c 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: d26a58a8f2406cd5 - zh: 82135eceb9adb628 + en: 5209c0658a6f643b + zh: bb2f9575215bac7a /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index ca922877f8..94daea954c 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -27,6 +27,8 @@ Desktop analytics follows the [product collection policy](../../client/product-a ## Use this package +The controller registers the `agentPreset` projection independently of preset execution support. Its value is the recorded preset id or null; key presence does not indicate that `agentPresets` is composed. + History pages and follow opening snapshots carry one `{ type: 'event', event: SessionWireEvent }` record per durable Session event. The Client retains each accepted record as one durable `SessionEventLikeEntry`; Assistant token boundaries remain inside the compact stream on `assistant/message` or `assistant/attempt`. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 585c4e0363..79773c94fb 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" ## 使用本包 +控制器独立于预设执行能力注册 `agentPreset` 投影。其值为已记录的预设 id 或 null;键存在不代表组合中包含 `agentPresets`。 + 历史页与 follow opening 快照为每个持久 Session 事件携带一条 `{ type: 'event', event: SessionWireEvent }` record。Client 把每条已接受 record 保留为一个持久 `SessionEventLikeEntry`;Assistant token 边界保留在 `assistant/message` 或 `assistant/attempt` 的紧凑流内。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;控制器不解析工具定义、不运行展示转换器,也不附加 UI 数据。 Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 3f0fa77944..41f56e4f14 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -7,14 +7,16 @@ import type { Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' -import type {} from '@deepseek-ai/dsh-agent-preset-registry' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type {} from '@deepseek-ai/dsh-typert-registry' -import type { ModelSelection } from './types.ts' +import type { ModelSelection, ModelSelectionProjectionState } from './types.ts' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { modelSelectionProjection } from './model-selection-projection.ts' /** Cold Session identity absent from persistence. */ export class ApiSessionNotFound extends Error {} @@ -138,13 +140,24 @@ export async function inspectApiSession( /** Owns every operation that may create, resume, or configure a Web Agent. */ export class ApiSessionAgentController { + private modelSelection!: ProjectionHandle + private agentPreset!: ProjectionHandle private readonly resumes = new Map>() private readonly creations = new Map>() private readonly selections = new WeakMap() private readonly imageAdmissionChains = new WeakMap>() /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ - constructor(private readonly ctx: Context) { + constructor(private readonly ctx: Context) {} + + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + const ctx = this.ctx + this.modelSelection = await ctx.sessionProjections.register(modelSelectionProjection) + this.agentPreset = await ctx.sessionProjections.register(agentPresetProjectionDefinition) ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { const found = await this.resolveAgent(sessionId) if ('error' in found) throw found.error @@ -283,10 +296,7 @@ export class ApiSessionAgentController { selectionFor(agent: Agent): InstalledSelection { const installed = this.selections.get(agent) if (installed !== undefined) return installed - const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection') - if (projectionState === undefined) { - throw new Error('api-session: required modelSelection projection is not registered') - } + const projectionState = this.modelSelection.stateOf(agent.session) let picked = projectionState.pending === null ? undefined : agentModelSelection(projectionState.pending) @@ -355,10 +365,10 @@ export class ApiSessionAgentController { /** * Read the current Agent preset from the Session projection. * @param session - live Session whose projection state is available. - * @returns the current preset, or undefined when the capability is absent. + * @returns the current preset, or undefined when the Session has no recorded preset. */ presetForSession(session: Session): string | undefined { - return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined + return this.agentPreset.stateOf(session) ?? undefined } /** @@ -506,7 +516,7 @@ export class ApiSessionAgentController { /** * Read the current Agent preset from an all-projections observation. * @param observation - exact Session observation carrying its projection snapshot. - * @returns the current preset, or undefined when the capability is absent. + * @returns the current preset, or undefined when the Session has no recorded preset. */ presetForObservation(observation: SessionObservation): string | undefined { if (observation.projections === undefined) { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index 0ba5bf8ff0..fbb4234fdf 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -4,7 +4,7 @@ import { hostname } from 'node:os' import { resolve } from 'node:path' import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' @@ -25,7 +25,6 @@ import { SessionHistoryController } from './history.ts' import { SessionFileReferences } from './file-references.ts' import { ApiSessionList } from './list.ts' import { buildModelCatalog, hasProviderApiKey } from './catalog.ts' -import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' import { SessionMediaReferences } from './media-references.ts' import { ArchivedSessionGate } from './archived-session-gate.ts' @@ -122,16 +121,17 @@ export class SessionController extends TypertRemoteService { listWorkSliceMs: z.natural().min(1).default(16), }) - private readonly agents: ApiSessionAgentController - private readonly commands: SessionCommandController - private readonly controlState: SessionControlController - private readonly history: SessionHistoryController - private readonly listState: ApiSessionList + private agents!: ApiSessionAgentController + private commands!: SessionCommandController + private controlState!: SessionControlController + private history!: SessionHistoryController + private listState!: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise private readonly fileApplications: typeof nativeFileApplications private readonly openFileApplication: typeof openNativeFileApplication private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean + private readonly listWorkSliceMs: number private readonly promotions = new Set>() /** @@ -141,9 +141,19 @@ export class SessionController extends TypertRemoteService { */ constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) { super(ctx, 'sessionController', { namespace: 'session' }) - const resolved = SessionController.Config(config) - installModelSelectionProjection(ctx) + this.listWorkSliceMs = SessionController.Config(config).listWorkSliceMs + this.fileApplications = internals.fileApplications ?? nativeFileApplications + this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication + this.openPath = internals.openPath ?? openNativeAssociatedPath + this.revealPath = internals.revealPath ?? revealNativePath + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + } + + protected async [Service.init](): Promise { + const ctx = this.ctx this.agents = new ApiSessionAgentController(ctx) + await this.agents.initialize() this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) ctx.effect(() => ctx.fileUploads.registerAgentResolver(async (sessionId) => { const result = await this.agents.resolveAgent(sessionId) @@ -157,13 +167,8 @@ export class SessionController extends TypertRemoteService { await Promise.allSettled([...this.promotions]) }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) - this.listState = new ApiSessionList(ctx, resolved.listWorkSliceMs) - this.fileApplications = internals.fileApplications ?? nativeFileApplications - this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication - this.openPath = internals.openPath ?? openNativeAssociatedPath - this.revealPath = internals.revealPath ?? revealNativePath - this.canOpenPath = internals.canOpenPath - ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) + this.listState = new ApiSessionList(ctx, this.listWorkSliceMs) + await this.listState.initialize() ctx.plugin(SessionFileReferences) ctx.plugin(SessionMediaReferences) ctx.plugin(SessionSkillCatalog) diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts index d7ef1ad43c..f109fee5e0 100644 --- a/packages/api/session-controller/src/list.ts +++ b/packages/api/session-controller/src/list.ts @@ -80,8 +80,14 @@ export class ApiSessionList { * @param ctx - Host context carrying Session, query, persistence, and projection services. * @param workSliceMs - Resolved positive integral list-work budget in milliseconds. */ - constructor(private readonly ctx: Context, private readonly workSliceMs: number) { - ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ + constructor(private readonly ctx: Context, private readonly workSliceMs: number) {} + + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + await this.ctx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ key: 'sessionListMetadata', stateSchema: sessionListMetadataSchema, init: () => ({ blank: true, lastPromptAt: null }), @@ -89,18 +95,16 @@ export class ApiSessionList { wire: { viewSchema: sessionListMetadataSchema, view: state => state }, stateVersion: 1, }) - ctx.inject(['attachments'], (attachmentCtx) => { - ctx.sessionProjections.register<'imageLimits', null>({ - key: 'imageLimits', - stateSchema: z.null(), - init: () => null, - apply: state => state, - wire: { - viewSchema: imageLimitsSchema, - view: () => attachmentCtx.attachments.imageLimits, - }, - stateVersion: 1, - }) + await this.ctx.sessionProjections.register<'imageLimits', null>({ + key: 'imageLimits', + stateSchema: z.null(), + init: () => null, + apply: state => state, + wire: { + viewSchema: imageLimitsSchema, + view: () => this.ctx.attachments.imageLimits, + }, + stateVersion: 1, }) } diff --git a/packages/api/session-controller/src/model-selection-projection.ts b/packages/api/session-controller/src/model-selection-projection.ts index 47eddde181..071cf5e685 100644 --- a/packages/api/session-controller/src/model-selection-projection.ts +++ b/packages/api/session-controller/src/model-selection-projection.ts @@ -1,6 +1,5 @@ /** Durable model-selection intent and request-use projection. */ -import type { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { z } from 'zod' @@ -55,7 +54,8 @@ function applyModelSelectionProjection( : { lastUsed, pending } } -const modelSelectionProjection = { +/** Last-used and pending model selections shared by Session controller readers. */ +export const modelSelectionProjection = { key: 'modelSelection', stateSchema: modelSelectionProjectionStateSchema, init: () => ({ lastUsed: null, pending: null }), @@ -73,11 +73,3 @@ function sameSelection(left: ModelSelection | null, right: ModelSelection | null && left.model === right.model && left.reasoningEffort === right.reasoningEffort) } - -/** - * Register the durable model-selection projection when the registry is present. - * @param ctx - Session Controller context. - */ -export function installModelSelectionProjection(ctx: Context): void { - ctx.sessionProjections.register(modelSelectionProjection) -} diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index 4756ae45b0..10a062d87e 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -18,7 +18,6 @@ import { ApiSessionSubagentOwnership, inspectApiSession, } from '../src/agent.ts' -import { installModelSelectionProjection } from '../src/model-selection-projection.ts' import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' const roots: Context[] = [] @@ -38,13 +37,14 @@ async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentControl await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - installModelSelectionProjection(ctx) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), saveSelection: () => Promise.resolve(), } as never) - return { ctx, agents: new ApiSessionAgentController(ctx) } + const agents = new ApiSessionAgentController(ctx) + await agents.initialize() + return { ctx, agents } } function header(id: string, cwd: string | null = '/workspace'): SessionHeader { @@ -288,14 +288,6 @@ describe('ApiSession Agent lookup and recovery', () => { }) describe('ApiSession model selection', () => { - it('requires the model-selection projection', async () => { - const { ctx, agents } = await harness() - const live = agent(ctx, header('missing-model-projection')) - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - - expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection') - }) - it('reads a reasoning-free request and consumes only the exact pending selection', async () => { const { ctx, agents } = await harness() const logged = agent(ctx, header('logged-model')) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts index 5a4d946bf0..ff5f980250 100644 --- a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -38,7 +38,7 @@ async function commandHarness( await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const sessionId = SessionId('commands-session') const ancestor = Session.create(SessionId('ancestor')) ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({ diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts index 43d1394851..45d3a523ec 100644 --- a/packages/api/session-controller/tests/controller.host.spec.ts +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -52,7 +52,7 @@ describe('SessionController facade', () => { bindPrompt: () => ({ commit: () => {}, [Symbol.dispose]: () => {} }), retirePrompt: () => {}, } as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const status = vi.fn() const failure = vi.fn() const activity = vi.fn() @@ -157,7 +157,7 @@ describe('SessionController facade', () => { events: [], }), }) as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents const apiError = vi.fn() ctx.on('api-session/error', apiError) @@ -213,7 +213,7 @@ describe('SessionController facade', () => { events: [], }), }) as never) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents const started = Promise.withResolvers() const release = Promise.withResolvers() diff --git a/packages/api/session-controller/tests/list-scheduling.host.spec.ts b/packages/api/session-controller/tests/list-scheduling.host.spec.ts index eefea76713..eeae34ff03 100644 --- a/packages/api/session-controller/tests/list-scheduling.host.spec.ts +++ b/packages/api/session-controller/tests/list-scheduling.host.spec.ts @@ -9,6 +9,7 @@ import { resolve } from 'node:path' import { performance } from 'node:perf_hooks' import { scheduler } from 'node:timers/promises' import SessionController from '../src/index.ts' +import { createSessionTestController, TEST_IMAGE_LIMITS } from './test-remote.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { ApiSessionList } from '../src/list.ts' @@ -47,7 +48,9 @@ async function harness(rows: readonly Row[], summaryMs = 8, workSliceMs = 8) { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) + ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS } as never) const list = new ApiSessionList(ctx, workSliceMs) + await list.initialize() const clock = { now: 0 } const prepared = new Map() const agents = new Map() @@ -185,7 +188,10 @@ describe('Session-list work slices', () => { h.ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) h.ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'test', model: 'test' }) } as never) h.ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) - const controller = new SessionController(h.ctx, { nativeOpen: false, listWorkSliceMs: 4 }, { canOpenPath: () => false }) + const controller = await createSessionTestController(h.ctx, { + nativeOpen: false, listWorkSliceMs: 4, canOpenPath: () => false, + defaultModelSelection: () => ({ provider: 'test', model: 'test' }), cwd: '/tmp', + }) const yieldWork = vi.spyOn(scheduler, 'yield') const result = await controller.list({}, new AbortController().signal) expect(result.items).toHaveLength(3) diff --git a/packages/api/session-controller/tests/projection-discovery.host.spec.ts b/packages/api/session-controller/tests/projection-discovery.host.spec.ts index 0a14c90f0a..ce51110107 100644 --- a/packages/api/session-controller/tests/projection-discovery.host.spec.ts +++ b/packages/api/session-controller/tests/projection-discovery.host.spec.ts @@ -49,8 +49,8 @@ async function harness(withPersistence = true) { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) - ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(titleProjectionDefinition) if (withPersistence) { await ctx.plugin(JsonlSessionPersistence, { root: join(root, 'logs'), compression: 'none' }) const list = vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(new Error('unexpected corpus listing')) @@ -58,7 +58,7 @@ async function harness(withPersistence = true) { expect(list).not.toHaveBeenCalled() }) } - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test' }), cwd: root, }) diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts index a6b7a033dd..dd1a68f610 100644 --- a/packages/api/session-controller/tests/session-cold.host.spec.ts +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -97,7 +97,7 @@ describe('sessions.list cold merge', () => { const stat = vi.fn() const open = vi.fn() ctx.provide('sessionPersistence', { list, stat, open } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -147,7 +147,7 @@ describe('sessions.list cold merge', () => { ? { asOfSeq: 2, values: { title: 'Cached predecessor title' } } : undefined, } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -209,7 +209,7 @@ describe('sessions.list cold merge', () => { }, cachedPredecessorTitle: () => undefined, } as never) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.list(request({})) expect(response.ok).toBe(true) @@ -247,7 +247,7 @@ describe('attached updatedAt tracks human prompts', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await new Promise(resolve => setTimeout(resolve, 0)) // Old work, resumed just now: the log tail would report the pickup. @@ -306,7 +306,7 @@ describe('cold history recovery view', () => { list: () => Promise.resolve([structuredClone(meta)]), inspect: () => Promise.resolve({ meta: structuredClone(meta), events: structuredClone(events) }), }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const history = await remote.page({ address: { kind: 'session', sessionId }, @@ -365,7 +365,7 @@ describe('Remote Agent and Session lookup policy', () => { }), }) const resume = vi.spyOn(ctx.agents, 'resume') - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -391,7 +391,7 @@ describe('Remote Agent and Session lookup policy', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -427,7 +427,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -469,7 +469,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -511,7 +511,7 @@ describe('Remote Agent and Session lookup policy', () => { return { agent: published, dispose: () => Promise.resolve() } }) const defaultLookup = ctx.typert.lookups.get('agent') - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -562,8 +562,8 @@ describe('subagent ownership fence', () => { inspect, }) const resume = vi.spyOn(ctx.agents, 'resume') - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const history = await new SessionHistoryController( ctx, @@ -629,7 +629,7 @@ describe('subagent ownership fence', () => { // answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const prompt = await remote.prompt(promptRequest({ sessionId, @@ -669,7 +669,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const stopped = await remote.cancel(request({ sessionId: originChild.id })) expect(stopped.ok).toBe(false) @@ -719,7 +719,7 @@ describe('subagent ownership fence', () => { id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent await ctx.agents.register(agent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.prompt(promptRequest({ sessionId: agent.id, @@ -740,7 +740,7 @@ describe('subagent ownership fence', () => { id: session.id, session, inbox: inboxFor(), status: 'idle', ctx, followup, } as unknown as Agent await ctx.agents.register(agent) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -805,7 +805,7 @@ describe('degenerate composition (no persistence, no factory)', () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const listed = await remote.list(request({})) expect(listed.ok).toBe(true) @@ -833,7 +833,7 @@ describe('degenerate composition (no persistence, no factory)', () => { stat, inspect, }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await remote.page({ address: { kind: 'session', sessionId: sid('session-missing') }, @@ -883,7 +883,7 @@ describe('sessions.prompt synchronous rejection', () => { provider: 'p', id: 'm', name: 'Model', inputModalities: ['text', 'image'], }), } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) @@ -960,7 +960,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await remote.prompt(promptRequest({ @@ -1001,7 +1001,7 @@ describe('sessions.prompt synchronous rejection', () => { await ctx.agents.register(child) throw new Error('session id already published') }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' })) expect(selection.ok).toBe(false) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index e6ba8321ce..87fbd2456e 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -90,7 +90,7 @@ async function liveAgent( return session } -const remote = (ctx: Context) => createSessionTestRemote(ctx, { +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }), cwd: '/tmp', }) @@ -120,7 +120,7 @@ describe('sessions.fork', () => { }) as never) const open = vi.spyOn(ctx.sessionPersistence, 'open') - const response = await remote(ctx).fork({ + const response = await (await remote(ctx)).fork({ sessionId: source.id, ...allowMigration === undefined ? {} : { allowMigration }, }) @@ -150,7 +150,7 @@ describe('sessions.fork', () => { const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') const create = vi.spyOn(ctx.agents, 'create') - await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + await expect((await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ ok: false, error: { code: 'session/migration-required', details: { sessionId: sourceId } }, }) expect(stat).toHaveBeenCalledExactlyOnceWith(sourceId) @@ -171,7 +171,7 @@ describe('sessions.fork', () => { const stat = vi.fn(() => Promise.reject(new Error('unexpected metadata read'))) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) - const result = await remote(ctx).fork({ sessionId: source.id, allowMigration: false }) + const result = await (await remote(ctx)).fork({ sessionId: source.id, allowMigration: false }) if (!result.ok) throw result.error expect(stat).not.toHaveBeenCalled() @@ -199,7 +199,7 @@ describe('sessions.fork', () => { return finish.promise }) ctx.provide('sessionPersistence', testSessionPersistence(ctx, { stat }) as never) - const operation = remote(ctx).fork({ sessionId: sourceId, allowMigration: false }) + const operation = (await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false }) try { await entered.promise const live = ctx.sessions.create(sourceId, { meta: source.header }) @@ -229,7 +229,7 @@ describe('sessions.fork', () => { const observe = vi.spyOn(ctx.sessionQuery, 'observeSession') const create = vi.spyOn(ctx.agents, 'create') - await expect(remote(ctx).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ + await expect((await remote(ctx)).fork({ sessionId: sourceId, allowMigration: false })).resolves.toMatchObject({ ok: false, error: { code: 'gateway/internal', message: 'source metadata unavailable' }, }) @@ -262,9 +262,9 @@ describe('sessions.fork', () => { expect(compacted.at(-1)).toMatchObject({ type: 'compaction/end', data: { turn: null } }) source.inbox.append('next-turn', message('queued after compaction')) - const response = await createSessionTestRemote(ctx, { + const response = await (await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp', - }).fork({ sessionId: source.id }) + })).fork({ sessionId: source.id }) if (!response.ok) throw response.error const child = ctx.agents.get(response.value.sessionId)! @@ -304,9 +304,9 @@ describe('sessions.fork', () => { } const original = source.session.snapshotEvents() const atSeq = anchor === 'omitted' ? undefined : boundary - const response = await createSessionTestRemote(ctx, { + const response = await (await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'mock', model: 'mock' }), cwd: '/tmp', - }).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) }) + })).fork({ sessionId: source.id, ...(atSeq === undefined ? {} : { atSeq }) }) if (!response.ok) throw response.error const child = ctx.agents.get(response.value.sessionId)! const requestCount = adapter.requests.length @@ -327,7 +327,7 @@ describe('sessions.fork', () => { it('cuts exactly at a completed turn and records lineage and cwd', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-source', 2) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 2 })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: 2 })) expect(response.ok ? null : response.error).toBeNull() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -369,7 +369,7 @@ describe('sessions.fork', () => { root: { header: owner.header, live: true, persisted: false }, }) - const response = await remote(ctx).fork(request({ sessionId: grandchild.id })) + const response = await (await remote(ctx)).fork(request({ sessionId: grandchild.id })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -424,7 +424,7 @@ describe('sessions.fork', () => { }) as never) const resume = vi.spyOn(ctx.agents, 'resume') - const response = await remote(ctx).fork(request({ sessionId: sourceId })) + const response = await (await remote(ctx)).fork(request({ sessionId: sourceId })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -441,7 +441,7 @@ describe('sessions.fork', () => { it('uses the latest completed prefix only when atSeq is omitted', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-tail', 2, 'open') - const proxy = remote(ctx) + const proxy = await remote(ctx) const omitted = await proxy.fork(request({ sessionId: source.id })) expect(omitted.ok).toBe(true) if (omitted.ok) { @@ -464,7 +464,7 @@ describe('sessions.fork', () => { it('rejects an omitted boundary when no turn has completed', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-never-completed', 0, 'open') - const response = await remote(ctx).fork(request({ sessionId: source.id })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id })) expect(response).toMatchObject({ ok: false, error: { code: 'session/fork-unavailable', details: { sessionId: source.id } }, @@ -481,7 +481,7 @@ describe('sessions.fork', () => { const inherited = source.snapshotEvents() if (openNextTurn) source.append('turn/start', { turn: 2 }) - const response = await remote(ctx).fork({ sessionId: source.id }) + const response = await (await remote(ctx)).fork({ sessionId: source.id }) if (!response.ok) throw response.error const child = ctx.sessions.get(response.value.sessionId)! @@ -496,7 +496,7 @@ describe('sessions.fork', () => { it('rejects invalid fork anchors before reading or creating a Session', async () => { const ctx = await composed() - const proxy = remote(ctx) + const proxy = await remote(ctx) for (const atSeq of [-1, 0.5]) { await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq }))) @@ -510,7 +510,7 @@ describe('sessions.fork', () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-aborted', 1, 'aborted') const anchor = (source.snapshotEvents().at(-1)?.seq ?? 0) - 1 - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: anchor })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -527,7 +527,7 @@ describe('sessions.fork', () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-open', 1, 'open') const boundary = source.snapshotEvents().at(-1)?.seq ?? 0 - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.sessions.get(response.value.sessionId) @@ -548,7 +548,7 @@ describe('sessions.fork', () => { const boundary = source.snapshotEvents().at(-1)?.seq ?? 0 source.append('session/title', { title: 'after boundary', messageSeqs: [], source: { kind: 'fallback' } }) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -604,7 +604,7 @@ describe('sessions.fork', () => { }) as never) const resume = vi.spyOn(ctx.agents, 'resume') - const response = await remote(ctx).fork(request({ sessionId: sourceId, atSeq: 4 })) + const response = await (await remote(ctx)).fork(request({ sessionId: sourceId, atSeq: 4 })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return @@ -656,7 +656,7 @@ describe('sessions.fork', () => { reason: 'change', }) source.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: boundary.seq })) + const response = await (await remote(ctx)).fork(request({ sessionId: source.id, atSeq: boundary.seq })) expect(response.ok ? undefined : response.error).toBeUndefined() if (!response.ok) return const child = ctx.agents.get(response.value.sessionId) diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index 86ed42a678..bf0c163914 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -719,7 +719,7 @@ describe('Session history raw journal', () => { it('serves raw call and result entries without parsing tool arguments', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const start = session.append('turn/start', { turn: 1 }) const call = session.append('tool/call', { @@ -760,7 +760,7 @@ describe('Session history raw journal', () => { ])('paginates $name in one contiguous Turn window', async ({ replies, beforeMessage, firstTurn, steering, minMessages = 50 }) => { const { ctx } = await harness() onTestFinished(() => ctx.fiber.dispose()) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const starts: SessionEvent[] = [] const messages: SessionEvent[][] = [] @@ -818,7 +818,7 @@ describe('Session history raw journal', () => { it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) const first = appendUserText(session, 'first prompt') @@ -872,7 +872,7 @@ describe('Session history raw journal', () => { it('paginates a message with a large embedded stream without expanding physical records', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) session.append('turn/start', { turn: 1 }) session.append('step/start', { turn: 1, step: 1 }) @@ -910,7 +910,7 @@ describe('Session history raw journal', () => { it('keeps an earlier declared source on the same message-aligned page', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const source = session.append('request/context', { provider: 'p', model: 'm' }) const laterSource = session.append('request/context', { provider: 'p', model: 'm' }) @@ -931,7 +931,7 @@ describe('Session history raw journal', () => { it('keeps compact reasoning and tool-call runs nested in one attempt event', async () => { const { ctx } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) const callId = ToolCallId('packed-call') const attempt = session.append('assistant/attempt', { diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts index fce236d3e3..7ea81ea4bc 100644 --- a/packages/api/session-controller/tests/session-list-blank.host.spec.ts +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -25,7 +25,7 @@ async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; att await ctx.plugin(AgentRegistry) return { ctx, - remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), + remote: await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), attach: async (session) => { await ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index e852460e93..6af6983f70 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -183,7 +183,7 @@ describe('Web session model selection', () => { ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -251,7 +251,7 @@ describe('Web session model selection', () => { const steer = vi.fn() const followup = vi.fn() Object.assign(agent, { steer, followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -280,7 +280,7 @@ describe('Web session model selection', () => { it('allows a text-only selection while durable or pending images remain available for later models', async () => { const { ctx, agent, sessionId } = await harness() registerTextOnly(ctx) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -318,7 +318,7 @@ describe('Web session model selection', () => { } const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) ctx.provide('attachments', { readImage } as never) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -394,7 +394,7 @@ describe('Web session model selection', () => { value: { providers: { custom: { apiKeyEnv: 'CUSTOM_API_KEY', models: [] } } }, }]) await ctx.agentDefaultModel.saveSelection({ provider: 'removed', model: 'saved' }) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', }) await controller.initializeDefaultModel() @@ -411,7 +411,7 @@ describe('Web session model selection', () => { model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const catalog = expectValue(await remote.modelCatalog()) expect(currentSelection(ctx, sessionId)).toEqual({ @@ -460,7 +460,7 @@ describe('Web session model selection', () => { return Promise.reject('string catalog failure') } }('String Failure', [])) - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -488,7 +488,7 @@ describe('Web session model selection', () => { it('rejects unlisted models and switches available models only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -567,7 +567,7 @@ describe('Web session model selection', () => { it('reads the Agent default live for a session whose log names no selection', async () => { const { ctx, sessionId } = await harness() let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) @@ -589,7 +589,7 @@ describe('Web session model selection', () => { model: 'deepseek-chat', }) let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', }) @@ -607,12 +607,14 @@ describe('Web session model selection', () => { reasoningEffort: ReasoningEffortId('high'), adapterDefaults: { reasoningEffort: true }, }) - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }), cwd: '/tmp', }) - expect(new ApiSessionAgentController(ctx).selectionFor(agent).current) + const controller = new ApiSessionAgentController(ctx) + await controller.initialize() + expect(controller.selectionFor(agent).current) .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) await ctx.fiber.dispose() }) @@ -627,7 +629,7 @@ describe('Web session model selection', () => { await Promise.allSettled([...operations, ...saves]) await ctx.fiber.dispose() }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), saveDefaultModelSelection: () => { saves.push(release.promise) @@ -653,7 +655,7 @@ describe('Web session model selection', () => { onTestFinished(() => { warn.mockRestore() }) const saved: unknown[] = [] let reject = false - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), saveDefaultModelSelection: (selection) => { saved.push(selection) @@ -692,7 +694,7 @@ describe('Web session model selection', () => { const { ctx, sessionId, agent } = await harness() const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) const list = vi.spyOn(ctx.llm, 'listModels') @@ -716,7 +718,7 @@ describe('Web session model selection', () => { }()) try { await expect(ctx.llm.resolveModelInfo('uncatalogued', 'custom-model')).resolves.toMatchObject({ id: 'custom-model' }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'uncatalogued', model: 'custom-model' }), cwd: '/tmp', }) expect(await remote.selectModel({ sessionId, provider: 'uncatalogued', model: 'custom-model' })) @@ -730,7 +732,7 @@ describe('Web session model selection', () => { const { ctx, sessionId, agent } = await harness() const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', }) @@ -752,7 +754,7 @@ describe('Web session model selection', () => { it('admits a removed model without changing its saved selection', async () => { const { ctx, sessionId, agent } = await harness({ provider: 'deepseek-official', model: 'removed-model' }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) const events = [...agent.session.snapshotEvents()] @@ -768,7 +770,7 @@ describe('Web session model selection', () => { it('serves a session and its catalog when the stored default names a route that is gone', async () => { const { ctx, sessionId } = await harness() - createSessionTestRemote(ctx, { + await createSessionTestRemote(ctx, { // What a Models-page removal leaves behind: the settings document still // names the route the user last picked, and nothing serves it. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), @@ -816,7 +818,7 @@ describe('Web session model selection', () => { }, AttachmentStore.prototype) as never) const followup = vi.fn() Object.assign(agent, { followup }) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', }) @@ -887,7 +889,7 @@ it('initializes the account model without reasoning metadata and rejects an empt const dispose = ctx.llm.registerAdapter(['deepseek-account'], new CatalogAdapter('Account', [ { provider: 'deepseek-account', id: 'basic', name: 'Basic' }, ])) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), cwd: '/tmp', }) await controller.initializeDefaultModel() diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index e8216b5214..a2ab39254a 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -23,7 +23,7 @@ async function context(): Promise { describe('session/openWorkspacePath', () => { it('reports the deployment opener capability independently of a Session', async () => { const ctx = await context() - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', canOpenPath: () => false, @@ -33,21 +33,21 @@ describe('session/openWorkspacePath', () => { }) it('derives opener availability from config, an injected opener, or the platform probe', async () => { - const configured = createSessionTestRemote(await context(), { + const configured = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, }) await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) - const injected = createSessionTestRemote(await context(), { + const injected = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath: () => Promise.resolve(), }) await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true }) - const detected = createSessionTestRemote(await context(), { + const detected = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', }) @@ -57,7 +57,7 @@ describe('session/openWorkspacePath', () => { it('hands a Client-resolved workspace path to the Host opener unchanged', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -73,7 +73,7 @@ describe('session/openWorkspacePath', () => { it('normalizes relative and absolute Host-resolvable paths', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -87,7 +87,7 @@ describe('session/openWorkspacePath', () => { it('rejects empty paths before opening anything', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -102,7 +102,7 @@ describe('session/openWorkspacePath', () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text'))) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -129,7 +129,7 @@ describe('session/openWorkspacePath', () => { throw new Error('opening stopped') }) .mockRejectedValueOnce('desktop unavailable') - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, @@ -150,7 +150,7 @@ it('reports Host file-manager metadata and dispatches reveal separately from def const ctx = await context() const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, }) try { @@ -167,7 +167,7 @@ it('uses the native reveal adapter without a test override and respects unsuppor const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined) const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null) try { - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, }) expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null }) @@ -183,7 +183,7 @@ it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before nat onTestFinished(() => { mapping.mockRestore() }) const openPath = vi.fn(async () => {}) const revealPath = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, }) await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal)) @@ -197,7 +197,7 @@ it('rejects a filesystem mapping that resolves to another process path', async ( const mapping = vi.spyOn(ctx.fs, 'processPath').mockReturnValue('/different/report.html') onTestFinished(() => { mapping.mockRestore() }) const openPath = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, }) await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal)) @@ -209,7 +209,7 @@ it('dispatches default-app opening to the association adapter', async () => { const ctx = await context() const open = vi.spyOn(nativeCommand, 'openNativeAssociatedPath').mockResolvedValue(undefined) onTestFinished(() => { open.mockRestore() }) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, }) const signal = new AbortController().signal @@ -223,7 +223,7 @@ it('uses the same path verification for handler queries and explicit application const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] const fileApplications = vi.fn(async () => applications) const openFileApplication = vi.fn(async () => {}) - const controller = createSessionTestController(ctx, { + const controller = await createSessionTestController(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, openFileApplication, }) @@ -242,11 +242,11 @@ it('uses the same path verification for handler queries and explicit application it('avoids desktop queries when unavailable and rejects an empty query path', async () => { const fileApplications = vi.fn(async () => []) - const unavailable = createSessionTestController(await context(), { + const unavailable = await createSessionTestController(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications, }) expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([]) - const available = createSessionTestController(await context(), { + const available = await createSessionTestController(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, }) await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) @@ -262,7 +262,7 @@ it('returns bounded query failures and classifies cancellation through the Remot lifetime.abort() throw new Error('native query stopped') }) - const remote = createSessionTestRemote(await context(), { + const remote = await createSessionTestRemote(await context(), { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, }) await expect(remote.workspacePathApplications({ path: '/file.mp3' })).resolves.toMatchObject({ diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 5406d235f8..e39c7ea7d2 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -75,11 +75,11 @@ async function harness(presets?: readonly string[]) { }, } ctx.agents.setFactory(factory) - const remote = createSessionTestRemote(ctx, { + const remote = await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, }) - if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) + if (presets !== undefined) await ctx.sessionProjections.register(agentPresetProjectionDefinition) return { ctx, remote } } diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts index ed4f4a6b0c..0b565dfd33 100644 --- a/packages/api/session-controller/tests/session-projections.host.spec.ts +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -160,7 +160,7 @@ function seedMessages(session: Session, count: number): void { } } -const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('session.history projections block', () => { it('keeps the v0 numeric seed cut on the wire while logical headers expose only lineage', async () => { @@ -182,7 +182,7 @@ describe('session.history projections block', () => { }, }) - const snapshot = await opening(remote(ctx), child.id) + const snapshot = await opening(await remote(ctx), child.id) expect(snapshot.header).toEqual({ version: SESSION_FORMAT_VERSION, @@ -197,7 +197,7 @@ describe('session.history projections block', () => { it('tracks pending and used model selections across repeated request headers', async () => { const { ctx, session } = await harness(true) - remote(ctx) + await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const selected = { provider: 'p', model: 'next' } session.append('model/selection', selected) @@ -225,9 +225,9 @@ describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 3) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) const { records, projections } = snapshot expect(projections.asOfSeq).toBe(session.seq - 1) expect(projections.values['test/last-user']).toEqual({ text: 'm2' }) @@ -257,7 +257,7 @@ describe('session.history projections block', () => { list: () => Promise.resolve([meta]), inspect: () => Promise.resolve({ meta, events, inheritedEventCount: SessionLogOffset(0) }), }) as never) - const snapshot = await opening(remote(ctx), coldId) + const snapshot = await opening(await remote(ctx), coldId) expect(snapshot.projections.values.inbox).toEqual({ 'next-turn': [message], @@ -269,7 +269,7 @@ describe('session.history projections block', () => { it('removes claimed steering from the pending Inbox projection immediately', async () => { const { ctx, session, claim } = await harness(true) - const proxy = remote(ctx) + const proxy = await remote(ctx) const message = createUserMessage({ content: [{ type: 'text', text: 'apply this now' }], source: { kind: 'user' }, @@ -309,10 +309,10 @@ describe('session.history projections block', () => { it('returns a complete current replacement cut on each follow generation', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 2) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) expect(snapshot.records.map(record => record.event.seq)).toEqual([0, 1]) expect(snapshot.projections.asOfSeq).toBe(1) @@ -323,9 +323,9 @@ describe('session.history projections block', () => { it('projects an empty log at cursor -1', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) - const snapshot = await opening(remote(ctx), session.id) + const snapshot = await opening(await remote(ctx), session.id) expect(snapshot.records).toEqual([]) expect(snapshot.projections.asOfSeq).toBe(-1) @@ -350,7 +350,7 @@ describe('session.history projections block', () => { saveImage(): Promise { return Promise.reject(new Error('unused')) } readImage(): Promise { return Promise.reject(new Error('unused')) } }) - const gateway = remote(ctx) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) seedMessages(session, 2) const snapshot = await opening(gateway, session.id) @@ -377,18 +377,18 @@ describe('session.history projections block', () => { await expect(extra).resolves.toEqual({ done: true, value: undefined }) }) - it('leaves the imageLimits key absent while no attachment service is composed', async () => { + it("publishes image limits from the Controller's attachment dependency", async () => { const { ctx, session } = await harness(true) seedMessages(session, 1) - const snapshot = await opening(remote(ctx), session.id) - expect('imageLimits' in snapshot.projections.values).toBe(false) + const snapshot = await opening(await remote(ctx), session.id) + expect(snapshot.projections.values.imageLimits).toEqual(ctx.attachments.imageLimits) }) it('never carries the block on loadOlder pages (beforeSeq present)', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) + await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 5) - const older = await page(remote(ctx), request({ + const older = await page(await remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1, beforeSeq: 3, maxMessages: 2, })) expect(older.ok).toBe(true) @@ -399,7 +399,7 @@ describe('session.history projections block', () => { it('serves no block when the composition has no projection registry', async () => { const { ctx, session } = await harness(false) seedMessages(session, 2) - const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) + const response = await page(await remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) expect(response.ok).toBe(true) if (!response.ok) throw new Error('unreachable') expect('projections' in response.value).toBe(false) @@ -407,8 +407,8 @@ describe('session.history projections block', () => { it('never exposes a host-only unit through history, listing, or push frames', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(internalCountUnit()) - const proxy = remote(ctx) + await ctx.sessionProjections.register(internalCountUnit()) + const proxy = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const abort = new AbortController() const iterator = proxy.control(abort.signal)[Symbol.asyncIterator]() @@ -438,13 +438,13 @@ describe('session.history projections block', () => { it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => { const { ctx, session } = await harness(true) - const dispose = ctx.sessionProjections.register(lastUserUnit()) + const dispose = await ctx.sessionProjections.register(lastUserUnit()) seedMessages(session, 1) - const proxy = remote(ctx) + const proxy = await remote(ctx) const before = await opening(proxy, session.id) expect(before.projections.values['test/last-user']).toEqual({ text: 'm0' }) - dispose() + await dispose.dispose() const after = await opening(proxy, session.id) // The registry stays mounted; only the disposed key leaves while the // gateway-owned Session-list unit remains. @@ -459,8 +459,8 @@ describe('session.history projections block', () => { it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => { const { ctx, session } = await harness(true) expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false) - const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => { - createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const fiber = ctx.plugin(Object.assign(async (gatewayCtx: Context) => { + await createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) }, { inject: ['sessions', 'agents', 'sessionProjections'] })) await fiber.await() await vi.waitFor(() => { @@ -475,8 +475,8 @@ describe('session.history projections block', () => { describe('session.list projections column', () => { it('serves every already-materialized wire value from the live registry without folding', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) - const gateway = remote(ctx) + await ctx.sessionProjections.register(lastUserUnit()) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) session.append('turn/start', { turn: 1 }) seedMessages(session, 1) @@ -496,8 +496,8 @@ describe('session.list projections column', () => { const session = ctx.sessions.create(SessionId('preset-list'), { meta: { cwd: '/workspace', agentPreset: 'standard' }, }) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - const gateway = remote(ctx) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) session.append('agent-preset/selected', { agentPreset: 'minimal' }) @@ -512,9 +512,9 @@ describe('session.list projections column', () => { seedMessages(session, 1) const unit = lastUserUnit() const apply = vi.fn(unit.apply) - ctx.sessionProjections.register({ ...unit, apply }) + await ctx.sessionProjections.register({ ...unit, apply }) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -525,7 +525,7 @@ describe('session.list projections column', () => { it('omits the column entirely when no registry is mounted', async () => { const { ctx, session } = await harness(false) seedMessages(session, 1) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -555,7 +555,7 @@ describe('session.list projections column', () => { } : undefined), } as never) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === coldId) expect(row?.running).toBe(false) @@ -580,9 +580,9 @@ describe('session.list projections column', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(privatePromptUnit()) + await ctx.sessionProjections.register(privatePromptUnit()) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) - const gateway = remote(ctx) + const gateway = await remote(ctx) await new Promise(resolve => setTimeout(resolve, 0)) const id = SessionId('session-cold-host-state') @@ -635,7 +635,7 @@ describe('session.list projections column', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.sessionProjections.register(titleProjectionDefinition) await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 100, writeIntervalMs: 60_000 }) return ctx } @@ -647,7 +647,7 @@ describe('session.list projections column', () => { let lastSeq: number | undefined let promptTime: number | undefined try { - remote(first) + await remote(first) await new Promise(resolve => setTimeout(resolve, 0)) let child: Session | undefined const owner = await first.plugin(Object.assign((sessionCtx: Context) => { @@ -679,7 +679,7 @@ describe('session.list projections column', () => { // record, and nothing opens the log. const second = await boot() try { - const gateway = remote(second) + const gateway = await remote(second) await new Promise(resolve => setTimeout(resolve, 0)) const load = () => { throw new Error('a cold fork listing must not load the event log') } second.provide('sessionPersistence', testSessionPersistence(second, { @@ -712,7 +712,7 @@ describe('session.list projections column', () => { ctx.provide('sessionPersistence', testSessionPersistence(ctx, { list: async () => [{ version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 5, isSeeded: false, cwd: '/tmp' }], }) as never) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === coldId) expect(row).toBeDefined() @@ -721,7 +721,7 @@ describe('session.list projections column', () => { it('a throwing column read degrades that row, never the listing', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ ...lastUserUnit(), wire: { viewSchema: z.union([z.object({ text: z.string() }), z.null()]), @@ -729,7 +729,7 @@ describe('session.list projections column', () => { }, }) seedMessages(session, 1) - const response = await remote(ctx).list(request({})) + const response = await (await remote(ctx)).list(request({})) if (!response.ok) throw new Error('unreachable') const row = response.value.items.find(item => item.sessionId === session.id) expect(row).toBeDefined() @@ -754,8 +754,8 @@ describe('Session control projection frames', () => { it('broadcasts changed view references with the causing seq and skips same-reference applies', async () => { const { ctx, session } = await harness(true) - ctx.sessionProjections.register(lastUserUnit()) - const proxy = remote(ctx) + await ctx.sessionProjections.register(lastUserUnit()) + const proxy = await remote(ctx) // The controller's onChanged subscription lives in an inject child whose // fiber activates asynchronously; yield until it lands before appending. await new Promise(resolve => setTimeout(resolve, 0)) diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts index 0e4754d8b0..c9f9502331 100644 --- a/packages/api/session-controller/tests/session-rename.host.spec.ts +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -65,14 +65,14 @@ async function liveAgent(ctx: Context, id: string, turns: number): Promise createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) +const remote = async (ctx: Context) => await createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { const ctx = await composed() const source = await liveAgent(ctx, 'session-rename', 1) - const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: ' new name ' })) + const renamed = await (await remote(ctx)).rename(request({ sessionId: source.id, title: ' new name ' })) expect(renamed.ok).toBe(true) if (!renamed.ok) return expect(renamed.value.title).toBe('new name') @@ -86,7 +86,7 @@ describe('sessions.rename', () => { const source = await liveAgent(ctx, 'session-rename-bad', 1) // U+200B passes a client-side trim gate but normalizes to empty host-side. - const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ​ ' })) + const response = await (await remote(ctx)).rename(request({ sessionId: source.id, title: ' ​ ' })) expect(response.ok).toBe(false) if (!response.ok) { expect(response.error).toMatchObject({ @@ -107,7 +107,7 @@ describe('sessions.rename', () => { const stale = await liveAgent(foreign, 'session-rename-stale', 1) await ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) - const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' })) + const response = await (await remote(ctx)).rename(request({ sessionId: stale.id, title: 'name' })) expect(response.ok).toBe(false) if (!response.ok) expect(response.error.code).toBe('gateway/internal') }) @@ -116,7 +116,7 @@ describe('sessions.rename', () => { const ctx = await composed(false) const source = await liveAgent(ctx, 'session-no-titles', 1) - const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' })) + const response = await (await remote(ctx)).rename(request({ sessionId: source.id, title: 'name' })) expect(response.ok).toBe(false) if (!response.ok) { expect(response.error.code).toBe('gateway/internal') diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts index d8690ed0a0..b96b6e9463 100644 --- a/packages/api/session-controller/tests/session-search.host.spec.ts +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -17,7 +17,7 @@ import { type SessionSearchHit, type SessionSearchRequest, } from '@deepseek-ai/dsh-session-query' -import { createSessionTestRemote, testSessionPersistence } from './test-remote.ts' +import { createSessionTestRemote, testSessionPersistence, TEST_IMAGE_LIMITS } from './test-remote.ts' import { ApiSessionList } from '../src/list.ts' const sid = (value: string): SessionId => value as SessionId @@ -96,7 +96,9 @@ function installSearchQuery( describe('session.search', () => { it('rejects search when the query service is absent', async () => { const ctx = await baseContext() + ctx.provide('attachments', { imageLimits: TEST_IMAGE_LIMITS } as never) const list = new ApiSessionList(ctx, 8) + await list.initialize() await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/internal', @@ -151,7 +153,7 @@ describe('session.search', () => { ], })) installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const signal = new AbortController().signal const response = await remote.search(request(' matching answer '), signal) @@ -187,7 +189,7 @@ describe('session.search', () => { ctx.sessions.create(sid('visible'), { meta: header('visible') }) const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) { await expect(remote.search(request(query), new AbortController().signal)) @@ -201,7 +203,7 @@ describe('session.search', () => { const ctx = await baseContext() const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const response = await remote.search( request('anything'), @@ -235,7 +237,7 @@ describe('session.search', () => { ], })) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('match'), new AbortController().signal, ) @@ -262,7 +264,7 @@ describe('session.search', () => { }) .mockResolvedValueOnce({ items: items.slice(19) }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('match'), new AbortController().signal, ) @@ -303,7 +305,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('adaptive-page-limit'), new AbortController().signal, ) @@ -346,7 +348,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('endless-pages'), new AbortController().signal, ) @@ -411,7 +413,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('stale-restart'), new AbortController().signal, ) @@ -450,7 +452,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('stale-churn'), new AbortController().signal, ) @@ -479,7 +481,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('abort-stale'), controller.signal, ) @@ -500,7 +502,7 @@ describe('session.search', () => { ))) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('first-page-stale'), new AbortController().signal, ) @@ -524,7 +526,7 @@ describe('session.search', () => { )) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('continuation-invalid-limit'), new AbortController().signal, ) @@ -551,7 +553,7 @@ describe('session.search', () => { )) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('minimum-page-limit'), new AbortController().signal, ) @@ -577,7 +579,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('abort-invalid-limit'), controller.signal, ) @@ -596,7 +598,7 @@ describe('session.search', () => { const searchSessions = vi.fn(() => Promise.resolve({ items: oversized })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('oversized-page'), new AbortController().signal, ) @@ -622,7 +624,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('adapted-oversized-page'), new AbortController().signal, ) @@ -648,7 +650,7 @@ describe('session.search', () => { } installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] })) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('bounded-snippet'), new AbortController().signal, ) @@ -670,7 +672,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('repeated-cursor'), new AbortController().signal, ) @@ -693,7 +695,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('repeated-lookahead-cursor'), new AbortController().signal, ) @@ -720,7 +722,7 @@ describe('session.search', () => { .mockResolvedValueOnce({ items: items.slice(20) }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('duplicate-pages'), new AbortController().signal, ) @@ -748,7 +750,7 @@ describe('session.search', () => { }) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('cancel-continuation'), controller.signal, ) @@ -777,7 +779,7 @@ describe('session.search', () => { })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('large corpus'), new AbortController().signal, ) @@ -813,7 +815,7 @@ describe('session.search', () => { const searchSessions = vi.fn() installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('cancel-during-visibility'), controller.signal, ) @@ -840,7 +842,7 @@ describe('session.search', () => { const searchSessions = vi.fn(() => Promise.resolve({ items: [] })) installSearchQuery(ctx, searchSessions) - const response = await createSessionTestRemote(ctx, defaults).search( + const response = await (await createSessionTestRemote(ctx, defaults)).search( request('header-only-visibility'), new AbortController().signal, ) @@ -856,7 +858,7 @@ describe('session.search', () => { it('maps preflight cancellation, query cancellation, and provider failure', async () => { const missingCtx = await baseContext() missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) - const missingApi = createSessionTestRemote(missingCtx, defaults) + const missingApi = await createSessionTestRemote(missingCtx, defaults) const preAborted = new AbortController() preAborted.abort() const cancelledBeforeLookup = await missingApi.search( @@ -875,7 +877,7 @@ describe('session.search', () => { .mockRejectedValueOnce(aborted) .mockRejectedValueOnce(new Error('database unavailable')) installSearchQuery(ctx, searchSessions) - const remote = createSessionTestRemote(ctx, defaults) + const remote = await createSessionTestRemote(ctx, defaults) const cancelled = await remote.search( request('first'), diff --git a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts index de1852991a..7d7d5b384f 100644 --- a/packages/api/session-controller/tests/subagent-catalog.host.spec.ts +++ b/packages/api/session-controller/tests/subagent-catalog.host.spec.ts @@ -59,7 +59,7 @@ async function bench() { installSessionReadTestServices(ctx) provideWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) return { ctx, controller } } @@ -195,7 +195,7 @@ describe('SessionController subagent catalog', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) installSessionReadTestServices(ctx) - const controller = createSessionTestController(ctx, defaults) + const controller = await createSessionTestController(ctx, defaults) const session = ctx.sessions.create(PARENT, { meta: { cwd: '/workspace' } }) const expected = ctx.sessionProjections.snapshot(session) expect(expected.values.subagentCatalog).toBeUndefined() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 71db06b22b..3c4a7afa79 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -2,7 +2,7 @@ import type { SessionControllerInternals } from '../src/index.ts' import { SessionLogOffset } from '@deepseek-ai/dsh-session' -import type { Context } from '@deepseek-ai/cordis' +import { Service, type Context } from '@deepseek-ai/cordis' import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' import type { AdmittedPromptContentPart, @@ -103,11 +103,13 @@ export interface TestSessionRemoteDefaults { readonly openFileApplication?: SessionControllerInternals['openFileApplication'] readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean + readonly listWorkSliceMs?: number } const installed = new WeakMap() -const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ +/** Image policy for controller fixtures without attachment I/O. */ +export const TEST_IMAGE_LIMITS: ImageAttachmentLimits = Object.freeze({ maxImageBytes: 5 * 1024 * 1024, maxImagesPerMessage: 20, maxMessageImageBytes: 100 * 1024 * 1024, @@ -231,10 +233,10 @@ export function installSessionReadTestServices(ctx: Context): void { if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx) } -function installControllers( +async function installControllers( ctx: Context, defaults: TestSessionRemoteDefaults, -): SessionController { +): Promise { const found = installed.get(ctx) if (found !== undefined) return found @@ -290,12 +292,18 @@ function installControllers( } installSessionReadTestServices(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) - let controller: SessionController + class TestController extends SessionController { + initialize(): Promise { + return this[Service.init]() + } + } + let controller: TestController try { - controller = new SessionController( + controller = new TestController( ctx, { ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, + ...defaults.listWorkSliceMs === undefined ? {} : { listWorkSliceMs: defaults.listWorkSliceMs }, }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, @@ -305,6 +313,7 @@ function installControllers( ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, ) + await controller.initialize() } finally { cwd.mockRestore() } @@ -313,11 +322,11 @@ function installControllers( } /** Build or return the production Session Controller for a direct unit harness. */ -export function createSessionTestController( +export async function createSessionTestController( ctx: Context, defaults: TestSessionRemoteDefaults, -): SessionController { - return installControllers(ctx, defaults) +): Promise { + return await installControllers(ctx, defaults) } function remoteResult( @@ -341,11 +350,11 @@ function remoteResult( } /** Build the generated Session Remote's unary result semantics without a carrier. */ -export function createSessionTestRemote( +export async function createSessionTestRemote( ctx: Context, defaults: TestSessionRemoteDefaults, -): TestSessionRemote { - const direct = createSessionTestController(ctx, defaults) +): Promise { + const direct = await createSessionTestController(ctx, defaults) return { workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult( () => direct.workspacePathApplications(request, signal), signal, diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts index 890000a0ac..66b697fcef 100644 --- a/packages/api/session-controller/tests/transport.host.spec.ts +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -76,7 +76,7 @@ async function setup(): Promise<{ ctx: Context; transport: SessionHistoryControl const ctx = new Context() await ctx.plugin(SessionStore) installSessionReadTestServices(ctx) - ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + await ctx.sessionProjections.register(subagentIdentityProjectionDefinition) const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) return { ctx, transport } } diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml index 9fc0cb6eae..818bcda27b 100644 --- a/packages/context/agent-instructions/README.i18n.yaml +++ b/packages/context/agent-instructions/README.i18n.yaml @@ -30,8 +30,8 @@ en: 724df7ff6d1d9fea zh: 91fb1013eb9178ec /deepseek-ai-dsh-agent-instructions/understand-the-implementation/design-concept: - en: 4708b51bf8fe86f7 - zh: 869fc4b1f78383ee + en: 566f3518b2d1d100 + zh: c7d0a22b19e7697a /deepseek-ai-dsh-agent-instructions/understand-the-implementation/source-map: en: cb4c1f929ef5a835 zh: 3f0fcb7f568a0a5c diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md index 25c48ba1ac..e935cedf8d 100644 --- a/packages/context/agent-instructions/README.md +++ b/packages/context/agent-instructions/README.md @@ -85,7 +85,7 @@ This section explains the design decisions behind the plugin; the observable beh ### Design concept -The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. +The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. The package exports `AgentInstructionSource` for typed consumers of these message sources. ### Source map diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md index a87cfae53a..cf193f2a28 100644 --- a/packages/context/agent-instructions/README.zh.md +++ b/packages/context/agent-instructions/README.zh.md @@ -85,7 +85,7 @@ export interface Config { ### 设计理念 -该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 +该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 本包导出 `AgentInstructionSource`,供这些消息来源的消费者进行类型检查。 ### 源码地图 diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index 74d6e5fa22..b32a5e6bb7 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -9,6 +9,7 @@ * @module @deepseek-ai/dsh-agent-instructions */ +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import { isDeepStrictEqual } from 'node:util' @@ -23,7 +24,6 @@ import { trimmedInstructionDigest } from './digest.ts' import { applyInstructionVersionUpdates, baselineInstructionState, - name, reconcileInstructionContext, agentInstructionsMessage, type InstructionVersionCache, @@ -31,9 +31,9 @@ import { } from './state.ts' import { instructionCandidateGroup, instructionScopeKey, scopeForDisplayPath, type AgentInstructionChange } from './render.ts' -export { Config, name } -/** Services required by workspace instruction projection. */ -export const inject = ['sessionProjections', 'workingDirectory'] +export { Config } +export { name } from './state.ts' +export type { AgentInstructionSource } from './state.ts' export { discoverBaselineInstructionFiles, loadBaselineInstructions, @@ -45,6 +45,8 @@ export type { export { renderAgentInstructions } from './render.ts' export type { RenderedAgentInstructions, TruncatedInstruction } from './render.ts' +export const inject = ['sessionProjections', 'workingDirectory'] + function visibleBaselineSource( agent: Agent, authorityMessages: readonly UserMessage[], @@ -83,7 +85,13 @@ function filePathFromExecution(exec: ToolExecution): string | undefined { return filePath.length > 0 ? filePath : undefined } -export function apply(ctx: Context, config: Config): void { +/** Register the agent-instructions plugin after its projections are ready. + * @param ctx - plugin context. + * @param config - workspace instruction policy. + * @returns when the plugin is ready. + */ +export async function apply(ctx: Context, config: Config): Promise { + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const resolved: ResolvedConfig = resolveConfig(config) const instructionVersions: InstructionVersionCache = new WeakMap() const baselinePreparations = new WeakMap { - const boundary = ctx.sessionProjections.stateOf(session, 'turnBoundary') - if (boundary === undefined) { - throw new Error('agent-instructions requires the turnBoundary session projection') - } + const boundary = turnBoundary.stateOf(session) return boundary.openTurnStartSeq !== null && boundary.lastStepBoundary?.kind === 'start' && boundary.lastStepBoundary.seq > boundary.openTurnStartSeq diff --git a/packages/context/agent-instructions/tests/agent-instructions.spec.ts b/packages/context/agent-instructions/tests/agent-instructions.spec.ts index 721866df73..a4df1f6d21 100644 --- a/packages/context/agent-instructions/tests/agent-instructions.spec.ts +++ b/packages/context/agent-instructions/tests/agent-instructions.spec.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' +import type { Config as AgentInstructionsConfig } from '@deepseek-ai/dsh-agent-instructions' import * as AgentInstructions from '@deepseek-ai/dsh-agent-instructions' import LlmRuntime, { createUserMessage, ToolCallId, type Message, type MessageSource, type StreamChunk } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -236,19 +237,19 @@ class BlockingReadFileSystem extends RecordingFileSystem { } } -async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountAgentInstructionsPlugin(ctx: Context, config: AgentInstructionsConfig): Promise>> { if (ctx.get('workingDirectory') === undefined) provideWorkingDirectoryFixture(ctx) if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) return ctx.plugin(AgentInstructions, config) } -async function mountAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> { await ctx.plugin(LocalFileSystem, { cwd: '/' }) return mountAgentInstructionsPlugin(ctx, config) } -async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructions.Config): Promise>> { +async function mountFileToolsAndAgentInstructions(ctx: Context, config: AgentInstructionsConfig): Promise>> { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(LocalFileSystem, { cwd: '/' }) @@ -1234,7 +1235,7 @@ describe('workspace context request injection', () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) - await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructions.Config)).rejects.toThrow(/maxBytes/) + await expect(mountAgentInstructionsPlugin(ctx, {} as AgentInstructionsConfig)).rejects.toThrow(/maxBytes/) }) it('mounts without requiring a filesystem provider', async () => { @@ -1251,28 +1252,6 @@ describe('workspace context request injection', () => { expect(AgentInstructions.inject).toEqual(['sessionProjections', 'workingDirectory']) }) - it('rejects a file-touch projection when the turn boundary unit is absent', async () => { - const ctx = new Context() - provideWorkingDirectoryFixture(ctx) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentInstructions, { maxBytes: 65536 }) - const exec = stubToolExecution({ - callId: ToolCallId('missing-turn-boundary'), - name: 'read', - arguments: { file_path: 'file.txt' }, - agent: await stubAgent('/virtual/repo'), - signal: testToolSignal, - }) - - expect(() => { - ctx.emit('tools/result', exec, { - content: [{ type: 'text', text: 'ok' }], - isError: false, - value: null, - }) - }).toThrow('agent-instructions requires the turnBoundary session projection') - }) - it('does not inject baseline context when no filesystem provider is present', async () => { const ctx = new Context() provideWorkingDirectoryFixture(ctx) diff --git a/packages/context/harness-dev-context/README.i18n.yaml b/packages/context/harness-dev-context/README.i18n.yaml index adc0b95c0e..5a9ee319b8 100644 --- a/packages/context/harness-dev-context/README.i18n.yaml +++ b/packages/context/harness-dev-context/README.i18n.yaml @@ -33,8 +33,8 @@ en: 4696b6c3a1f91f96 zh: 3a56d257ec768a5e /deepseek-ai-dsh-harness-dev-context/understand-the-implementation/main-flow: - en: f3844a1825b2e329 - zh: bdcafbcadbc6d316 + en: 55c4cdd475f9f851 + zh: ac4fb96e7be88810 /deepseek-ai-dsh-harness-dev-context/further-exploration: en: f291741c499581ac zh: 8ee0baef51e69ef0 diff --git a/packages/context/harness-dev-context/README.md b/packages/context/harness-dev-context/README.md index 04d3925181..3d4ca78246 100644 --- a/packages/context/harness-dev-context/README.md +++ b/packages/context/harness-dev-context/README.md @@ -57,7 +57,7 @@ A host-only session projection, `harnessDevLegacyPrompt`, folds every committed ### Main flow -Activation registers the projection unit, both sections, and both contexts. At each assembly the sections choose the admitted earlier paragraph or the current guidance; at each request admission the contexts read the advertised URL. Disposing the plugin fiber removes every registration. +Activation awaits the projection handle before registering both sections and both contexts. At each assembly the sections read the handle to choose the admitted earlier paragraph or the current guidance; at each request admission the contexts read the advertised URL. Disposing the plugin fiber removes every registration.
diff --git a/packages/context/harness-dev-context/README.zh.md b/packages/context/harness-dev-context/README.zh.md index e108c6f161..b25f5eea66 100644 --- a/packages/context/harness-dev-context/README.zh.md +++ b/packages/context/harness-dev-context/README.zh.md @@ -57,7 +57,7 @@ kind: "package-reference" ### 主流程 -激活时注册投影单元、两个段落与两个上下文。每次组装时,段落在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。 +激活时等待取得投影 handle,再注册两个段落与两个上下文。每次组装时,段落读取 handle,在已提交的早期段落与当前说明之间选择;每次请求准入时,上下文读取公告 URL。销毁插件 fiber 会移除全部注册。
diff --git a/packages/context/harness-dev-context/src/index.ts b/packages/context/harness-dev-context/src/index.ts index becb4a6005..d823735a2a 100644 --- a/packages/context/harness-dev-context/src/index.ts +++ b/packages/context/harness-dev-context/src/index.ts @@ -124,9 +124,10 @@ const ADMITS_NOTHING: LegacyPrompt = { source: null, web: null } * Register the two sections, their runtime-context values, and the * projection that keeps a Session's admitted paragraphs. * @param ctx - plugin context with every {@link inject} service. + * @returns after the projection reader and prompt registrations are ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register({ +export async function apply(ctx: Context): Promise { + const legacyPrompt = await ctx.sessionProjections.register({ key: LEGACY_PROMPT, stateVersion: 1, stateSchema: legacyPromptSchema, @@ -135,8 +136,7 @@ export function apply(ctx: Context): void { }) const admitted = (context: AssembleContext): LegacyPrompt => { const session = context.agent?.session - // This plugin registers the unit above, so its state exists for every Session. - return session === undefined ? ADMITS_NOTHING : ctx.sessionProjections.stateOf(session, LEGACY_PROMPT) as LegacyPrompt + return session === undefined ? ADMITS_NOTHING : legacyPrompt.stateOf(session) } ctx.systemPrompt.section({ name: HARNESS_SOURCE, diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 493630a811..d1049f4203 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -49,7 +49,6 @@ const timeContextStateSchema = zod.object({ /** Folded time-context readings. */ type TimeContextProjection = zod.infer -/** The agent registry that owns pre-step processing. */ export const inject = ['agents', 'sessionProjections'] /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -128,8 +127,9 @@ function validateRefreshInterval(refreshIntervalMs: number): void { * @param ctx - plugin context; the listener is disposed with it. * @param config - time zone and durable refresh scheduling configuration. * @throws when the refresh interval is invalid or the configured or process time zone cannot be resolved. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { const timeZone = config.timeZone const refreshIntervalMs = config.refreshIntervalMs ?? 600_000 validateRefreshInterval(refreshIntervalMs) @@ -154,7 +154,7 @@ export function apply(ctx: Context, config: Config): void { return created } - ctx.sessionProjections.register({ + const projection = await ctx.sessionProjections.register({ key: 'timeContext', stateVersion: 2, stateSchema: timeContextStateSchema, @@ -189,7 +189,7 @@ export function apply(ctx: Context, config: Config): void { const decision = await next() if (decision.kind === 'reject' || signal.aborted) return decision const now = Date.now() - const state = ctx.sessionProjections.stateOf(agent.session, 'timeContext') as TimeContextProjection + const state = projection.stateOf(agent.session) if (refreshIntervalMs > 0) { const lastInjection = state.lastInjectionTime if (lastInjection != null diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 92499007d1..a795935f2e 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -429,10 +429,10 @@ describe('time-context projection fold edges', () => { content: [{ type: 'text', text: 'reading' }], source: { kind: 'time-context' }, }), { surfaceOp: 'append' }) - expect(typeof ctx.sessionProjections.stateOf(session, 'timeContext')?.lastTurnInjectionTime).toBe('number') + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toHaveProperty('lastTurnInjectionTime', expect.any(Number)) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) session.append('turn/start', { turn: 2 }) - expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ lastTurnInjectionTime: null, }) }) @@ -441,7 +441,7 @@ describe('time-context projection fold edges', () => { const { ctx } = await mount() const session = Session.create(SessionId('end-without-start')) session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - expect(ctx.sessionProjections.stateOf(session, 'timeContext')).toMatchObject({ + expect(ctx.sessionProjections.checkpoint(session).timeContext?.val).toMatchObject({ lastTurnInjectionTime: null, }) }) diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index af6d636e93..fec71d8d69 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -36,11 +36,9 @@ declare module '@deepseek-ai/dsh-llm' { } } - /** Cordis plugin name used by loader diagnostics. */ export const name = 'tmux-context' -/** The agent registry that owns pre-step processing. */ export const inject = ['agents', 'sessionProjections'] /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -223,11 +221,15 @@ declare module '@deepseek-ai/dsh-session-projection/types' { } } -export function apply(ctx: Context, config: Config): void { +/** Register the tmux-context plugin after its projections are ready. + * @param ctx - plugin context. + * @returns when the plugin is ready. + */ +export async function apply(ctx: Context, config: Config): Promise { const refreshIntervalMs = config.refreshIntervalMs validateRefreshInterval(refreshIntervalMs) - ctx.sessionProjections.register({ + const projection = await ctx.sessionProjections.register({ key: 'tmuxContext', stateVersion: 1, stateSchema: tmuxContextStateSchema, @@ -251,7 +253,7 @@ export function apply(ctx: Context, config: Config): void { if (decision.kind === 'reject' || signal.aborted || step !== 1) return decision const bash = ctx.get('shell') if (bash === undefined) return decision - const previous = ctx.sessionProjections.stateOf(agent.session, 'tmuxContext') as TmuxContextState + const previous = projection.stateOf(agent.session) if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== null) { const now = Date.now() if (now >= previous.time && now - previous.time < refreshIntervalMs) return decision diff --git a/packages/deliverables/tool-present/src/index.ts b/packages/deliverables/tool-present/src/index.ts index 8b47748b03..afc6ab82a8 100644 --- a/packages/deliverables/tool-present/src/index.ts +++ b/packages/deliverables/tool-present/src/index.ts @@ -1,4 +1,5 @@ /** Scoped tool that declares filesystem deliveries in their owning Session. */ +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' @@ -9,7 +10,6 @@ import type {} from '@deepseek-ai/dsh-session-projection' import type { Session } from '@deepseek-ai/dsh-session' import type { PresentedFile } from './types.ts' -/** Stable Loader identity. */ export const name = 'tool-present' /** Per-call delivery limit. */ @@ -23,15 +23,16 @@ export const Config: z = z.object({ maxFiles: z.number().default(8), }) -/** Services used by the scoped delivery tool. */ export const inject = ['tools', 'fs', 'sessionProjections', 'workingDirectory'] /** * Register present with durable file references in its tool result. * @param ctx - agent-scoped services. * @param config - maximum files per call. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) if (!Number.isSafeInteger(config.maxFiles) || config.maxFiles < 1) { throw new Error('present requires a positive integer maxFiles') } @@ -76,8 +77,8 @@ export function apply(ctx: Context, config: Config): void { }, async execute(args, exec) { if (exec.agent === undefined) throw new Error('present requires an agent Session') - const boundary = ctx.sessionProjections.stateOf(exec.agent.session, 'turnBoundary') - if (boundary === undefined || boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') + const boundary = turnBoundary.stateOf(exec.agent.session) + if (boundary.openTurnStartSeq === null) throw new Error('present requires an open turn') if (args.files.length === 0 || args.files.length > config.maxFiles) throw new Error(`present accepts 1 to ${config.maxFiles} files`) const cwd = await ctx.workingDirectory.ensure(exec.agent, exec.signal) const options = { cwd, signal: exec.signal } diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts index e8b0bee060..7595872758 100644 --- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts +++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts @@ -16,7 +16,8 @@ import LocalFileSystem from './packages/fs/fs-local/lib/index.js' import SystemPrompt from './packages/core/system-prompt/lib/index.js' import ToolRuntime from './packages/core/tools/lib/index.js' import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js' -import * as Present from './packages/deliverables/tool-present/lib/index.js' +import Present from './packages/deliverables/tool-present/lib/index.js' +import SessionProjectionRegistry from './packages/session/session-projection/lib/index.js' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -28,10 +29,11 @@ try { await ctx.plugin(ToolRuntime) await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) - ctx.provide('sessionProjections', { stateOf() { return { openTurnStartSeq: 1, lastTurn: 1 } } }) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(Present, { maxFiles: 2 }) const scope = ctx.plugin(() => {}) const session = Session.create('built-present', [], { version: SESSION_FORMAT_VERSION, id: 'built-present', createdAt: 0, cwd: root, isSeeded: false }) + session.append('turn/start', { turn: 1 }) const owner = { id: 'built-present', session, ctx: scope.ctx, options: {}, status: 'idle' } await ctx.agents.register(owner) const events = [] diff --git a/packages/deliverables/tool-present/tests/present.spec.ts b/packages/deliverables/tool-present/tests/present.spec.ts index afbbf5958a..94fae8366f 100644 --- a/packages/deliverables/tool-present/tests/present.spec.ts +++ b/packages/deliverables/tool-present/tests/present.spec.ts @@ -62,7 +62,7 @@ async function setup() { await ctx.plugin(AgentRegistry) await ctx.plugin(LocalFileSystem, { cwd: root }) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const fiber = ctx.plugin(Present, { maxFiles: 2 }) await fiber const owner = await agent(ctx, root) @@ -149,9 +149,12 @@ describe('present', () => { }) -it('validates deployment limits before registering the tool', () => { +it('validates deployment limits before registering the tool', async () => { + const ctx = new Context() + cleanups.push(() => ctx.fiber.dispose()) + await ctx.plugin(SessionProjectionRegistry) for (const config of [{ maxFiles: 0 }, { maxFiles: 1.5 }, { maxFiles: Number.POSITIVE_INFINITY }]) { - expect(() => { Present.apply(new Context(), config) }).toThrow('positive integer maxFiles') + await expect(Present.apply(ctx, config)).rejects.toThrow('positive integer maxFiles') } }) diff --git a/packages/experimental/agent-team/README.i18n.yaml b/packages/experimental/agent-team/README.i18n.yaml index e4f3b0666e..0dc6d22bb4 100644 --- a/packages/experimental/agent-team/README.i18n.yaml +++ b/packages/experimental/agent-team/README.i18n.yaml @@ -63,14 +63,14 @@ en: 6922e307e18696b4 zh: 083f4d95587a844b /deepseek-ai-dsh-experimental-agent-team/understand-the-implementation/disposal: - en: a12fb358183cc6d8 - zh: 7fdb0434d3c407fe + en: f32086839d37c9a6 + zh: b40ab7140b314afb /deepseek-ai-dsh-experimental-agent-team/further-exploration: en: 020d07f96ef2077d zh: 9e771d38a02048e1 /deepseek-ai-dsh-experimental-agent-team/further-exploration/browser-projection: - en: 4b3033806e6db528 - zh: 53e0dfef7309840e + en: 5dfc9113cabec7cd + zh: 22eaee731ca3f1b3 /deepseek-ai-dsh-experimental-agent-team/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/experimental/agent-team/README.md b/packages/experimental/agent-team/README.md index 2d8abc7409..40872142cb 100644 --- a/packages/experimental/agent-team/README.md +++ b/packages/experimental/agent-team/README.md @@ -149,7 +149,7 @@ Mailbox projection and checkpoint admission preserve every decoded JSON field of ### Disposal -The runtime lifecycle tracks sends and complete creation transactions in one operation set. Disposal closes admission, aborts and awaits those operations, then releases the roster’s live children and descendants. Non-Team children remain untouched. Admitted operations share one `disposalTimeoutMs` settlement deadline; each subsequent Team child drain has its own deadline. This value is not a whole-service shutdown bound. Cleanup failures are reported. +The runtime lifecycle tracks sends and complete creation transactions in one operation set. Disposal closes admission, aborts and awaits those operations, then releases the roster’s live children and descendants. Non-Team children remain untouched. Admitted operations share one `disposalTimeoutMs` settlement deadline; each subsequent Team child drain has its own deadline. This value is not a whole-service shutdown bound. Cleanup failures are reported. Team shutdown is independent of projection unregistration; its retained reader follows Session events while admitted work settles. @@ -171,7 +171,7 @@ Read these pages when the package-level contract is not enough. They move from t ### Browser projection -The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. +The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types. A failed projection rejects subsequent Team reads and mutations without appending further Team events. The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types. diff --git a/packages/experimental/agent-team/README.zh.md b/packages/experimental/agent-team/README.zh.md index e60d93b255..815d41eb2b 100644 --- a/packages/experimental/agent-team/README.zh.md +++ b/packages/experimental/agent-team/README.zh.md @@ -149,7 +149,7 @@ Mailbox 投影与 checkpoint 准入保留本地声明的校验器之外获准内 ### Dispose -运行时生命周期用同一个操作集合跟踪发送和完整创建事务。dispose 关闭准入,中止并等待这些操作,然后释放 roster 中的 live child 及其后代。非 Team child 不受影响。已获准操作共享一个 `disposalTimeoutMs` 等待期限;随后每个 Team 的 child drain 各有独立期限。此值不是整个服务关闭的总时限。清理失败会明确报告。 +运行时生命周期用同一个操作集合跟踪发送和完整创建事务。dispose 关闭准入,中止并等待这些操作,然后释放 roster 中的 live child 及其后代。非 Team child 不受影响。已获准操作共享一个 `disposalTimeoutMs` 等待期限;随后每个 Team 的 child drain 各有独立期限。此值不是整个服务关闭的总时限。清理失败会明确报告。 Team 停机与投影注销独立;在已获准操作收尾期间,保留的 reader 仍会根据 Session 事件更新读取结果。 @@ -171,7 +171,7 @@ Mailbox 投影与 checkpoint 准入保留本地声明的校验器之外获准内 ### 浏览器投影 -`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 +`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。 投影失败后,后续 Team 读取和修改均报错,不再追加 Team 事件。 [Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。 diff --git a/packages/experimental/agent-team/src/index.ts b/packages/experimental/agent-team/src/index.ts index 2d35933776..fd1ea84a29 100644 --- a/packages/experimental/agent-team/src/index.ts +++ b/packages/experimental/agent-team/src/index.ts @@ -9,8 +9,8 @@ import { steerHostSubagentPrompt } from '@deepseek-ai/dsh-subagent/internal' import { TeamActivity } from './activity.ts' import { errorMessage, TeamError } from './error.ts' import { TeamJournal } from './journal.ts' -import { TeamRuntimeLifecycle } from './lifecycle.ts' import { teamProjectionDefinition } from './projection.ts' +import { TeamRuntimeLifecycle } from './lifecycle.ts' import { resolveActiveMember, TeamRoster } from './roster.ts' import type { TeamMembership } from './roster.ts' import { TeamTaskBoard } from './task-board.ts' @@ -68,9 +68,9 @@ export class TeamService extends Service { private readonly activity: TeamActivity private readonly lifecycle: TeamRuntimeLifecycle - private readonly journal: TeamJournal - private readonly roster: TeamRoster - private readonly tasks: TeamTaskBoard + private journal!: TeamJournal + private roster!: TeamRoster + private tasks!: TeamTaskBoard constructor(ctx: Context, config: Config = {}) { super(ctx, 'agentTeams') @@ -86,25 +86,21 @@ export class TeamService extends Service { this.activity = new TeamActivity() this.lifecycle = new TeamRuntimeLifecycle(this.config.disposalTimeoutMs) - this.journal = new TeamJournal(ctx, (root) => { this.activity.notify(TeamId(root.id)) }) + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + const projection = await ctx.sessionProjections.register(teamProjectionDefinition) + this.journal = new TeamJournal(ctx, projection, (root) => { this.activity.notify(TeamId(root.id)) }) this.roster = new TeamRoster(ctx, this.journal, this.lifecycle, this.config.maxMembers) this.tasks = new TeamTaskBoard(this.journal, this.config.maxTasks) + ctx.effect(() => () => this.disposeRuntime(), 'agentTeams.runtimeLifecycle()') ctx.on('agent/created', ({ agent }) => { this.scheduleRecovery(agent) }) ctx.on('agent/status', ({ agent }) => { const membership = this.roster.tryMembership(agent) if (membership !== undefined) this.activity.notify(membership.id) }) - ctx.effect(() => { - const disposeProjection = ctx.root.sessionProjections.register(teamProjectionDefinition) - return async () => { - try { - await this.disposeRuntime() - } finally { - disposeProjection() - } - } - }, 'agentTeams.runtimeLifecycle()') for (const agent of ctx.agents.list()) this.scheduleRecovery(agent) } diff --git a/packages/experimental/agent-team/src/journal.ts b/packages/experimental/agent-team/src/journal.ts index f69ac376b8..6a6002bb72 100644 --- a/packages/experimental/agent-team/src/journal.ts +++ b/packages/experimental/agent-team/src/journal.ts @@ -1,9 +1,10 @@ /** Serialized Team transactions over the exact live Lead Session log. */ import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import type { SessionEventMap, SessionId } from '@deepseek-ai/dsh-session' -import type { TeamEventType, TeamState } from './projection.ts' +import type { TeamEventType, TeamProjectionState, TeamState } from './projection.ts' type AppendTeamEvent = (type: T, data: SessionEventMap[T]) => void type MutableTeamEventType = 'team/member' | 'team/task' @@ -14,10 +15,12 @@ export class TeamJournal { /** * @param ctx - Team service context with the injected Session service. + * @param projection - Team state registration owned by the Team service. * @param onCommit - synchronous notification after the Team event flush succeeds. */ constructor( private readonly ctx: Context, + private readonly projection: ProjectionHandle, private readonly onCommit: (root: Agent) => void, ) {} @@ -25,10 +28,10 @@ export class TeamJournal { * Read authoritative Team state for one exact live Lead. * @param root - exact live Team Lead. * @returns current projected state selected by the Lead Team id. + * @throws when the durable Team projection has failed. */ state(root: Agent): TeamState { - const projection = this.ctx.sessionProjections.stateOf(root.session, 'agentTeam') - if (projection === undefined) throw new Error('Agent Teams projection is not registered') + const projection = this.projection.stateOf(root.session) if (projection.failure !== undefined) throw new Error(projection.failure) return projection } diff --git a/packages/experimental/agent-team/tests/projection-events.spec.ts b/packages/experimental/agent-team/tests/projection-events.spec.ts index 2266b41483..2de9c75ff4 100644 --- a/packages/experimental/agent-team/tests/projection-events.spec.ts +++ b/packages/experimental/agent-team/tests/projection-events.spec.ts @@ -90,7 +90,7 @@ describe('Agent Teams projection events', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + await ctx.sessionProjections.register(teamProjectionDefinition) const session = ctx.sessions.create(ROOT) const restore = (val: unknown) => ctx.sessionProjections.restore({ agentTeam: { ver: teamProjectionDefinition.stateVersion, seq: -1, val }, @@ -359,7 +359,7 @@ describe('Agent Teams projection events', () => { const registry = ctx.plugin(SessionProjectionRegistry) try { await registry - ctx.sessionProjections.register(teamProjectionDefinition) + await ctx.sessionProjections.register(teamProjectionDefinition) const extension = JSON.parse('{"type":"plugin/custom","__proto__":{"saved":true}}') as ContentBlock const queued = event('team/message/queued', { version: 2, teamId: TEAM, message: message({ content: [extension] }), @@ -546,7 +546,7 @@ describe('Agent Teams projection events', () => { onTestFinished(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.effect(() => ctx.sessionProjections.register(teamProjectionDefinition)) + await ctx.sessionProjections.register(teamProjectionDefinition) const published: Array<{ session: SessionId; key: string; value: unknown }> = [] ctx.sessionProjections.onChanged((session, key, value) => { published.push({ session: session.id, key, value }) diff --git a/packages/experimental/agent-team/tests/team.spec.ts b/packages/experimental/agent-team/tests/team.spec.ts index ec79684726..671cb281f7 100644 --- a/packages/experimental/agent-team/tests/team.spec.ts +++ b/packages/experimental/agent-team/tests/team.spec.ts @@ -1,3 +1,4 @@ +import { Service } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' @@ -141,19 +142,20 @@ async function waitRunning(ctx: Context, id: SessionId): Promise { } describe('Team identity and provisioning', () => { - it('rejects missing and failed authoritative Team projections', async () => { - const first = await setup([]) - const journal = teamInternals(first.ctx).journal - const stateOf = first.ctx.sessionProjections.stateOf.bind(first.ctx.sessionProjections) - const stateOfSpy = vi.spyOn(first.ctx.sessionProjections, 'stateOf').mockImplementation((session, key) => ( - key === 'agentTeam' ? undefined : stateOf(session, key) - )) - expect(() => journal.state(first.lead)).toThrow('Agent Teams projection is not registered') - stateOfSpy.mockImplementation((session, key) => key === 'agentTeam' - ? { ...teamProjectionDefinition.init(session.header), failure: 'failed Team projection' } - : stateOf(session, key)) - expect(() => journal.state(first.lead)).toThrow('failed Team projection') - stateOfSpy.mockRestore() + it('rejects reads and mutations after a Team projection fails', async () => { + const { ctx, lead } = await setup([]) + lead.session.append('team/message/delivered', { + version: 2, + teamId: TeamId(lead.id), + messageId: TeamMessageId('unqueued-message'), + targetId: lead.id, + }) + + expect(() => ctx.agentTeams.listMembers(lead)).toThrow('delivered before queueing') + const eventCount = lead.session.snapshotEvents().length + await expect(ctx.agentTeams.createTask(lead, { subject: 'blocked', description: 'blocked' })) + .rejects.toThrow('delivered before queueing') + expect(lead.session.snapshotEvents()).toHaveLength(eventCount) }) it('rejects deployment limits that are not positive safe integers', async () => { @@ -181,7 +183,11 @@ describe('Team identity and provisioning', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const lead = await ctx.agentLoop.create(SessionId('preexisting-lead'), {}) - const service = new TeamService(ctx) + class DirectTeamService extends TeamService { + initialize(): Promise { return this[Service.init]() } + } + const service = new DirectTeamService(ctx) + await service.initialize() expect(service.listMembers(lead)).toEqual([expect.objectContaining({ name: 'lead', @@ -1147,6 +1153,32 @@ describe('Team direct messages and waiting', () => { expect(ctx.get('agentTeams')).toBeUndefined() }) + it.each([false, true])('settles admitted work after projection unregistration (failure: %s)', async (fails) => { + const { ctx, lead, teamFiber } = await setup([]) + const internal = teamInternals(ctx) + const state = internal.journal.state(lead) + const finish = Promise.withResolvers() + let readDuringShutdown = false + void internal.lifecycle.track(finish.promise.then(() => { + expect(internal.journal.state(lead)).toBe(state) + readDuringShutdown = true + if (fails) throw new Error('admitted operation failed during shutdown') + })) + const disposal = teamFiber.dispose() + try { + await vi.waitFor(() => { + expect(internal.lifecycle.disposed).toBe(true) + expect(ctx.sessionProjections.checkpoint(lead.session)).not.toHaveProperty('agentTeam') + }) + expect(readDuringShutdown).toBe(false) + } finally { + finish.resolve(undefined) + await disposal + } + expect(readDuringShutdown).toBe(true) + expect(ctx.get('agentTeams')).toBeUndefined() + }) + it('closes creation admission and drains an in-flight spawn before unload completes', async () => { const { ctx, lead, teamFiber } = await setup(['hang']) const service = ctx.agentTeams diff --git a/packages/experimental/claude-code-mods/README.i18n.yaml b/packages/experimental/claude-code-mods/README.i18n.yaml index aa0a5e6116..68c4c306d6 100644 --- a/packages/experimental/claude-code-mods/README.i18n.yaml +++ b/packages/experimental/claude-code-mods/README.i18n.yaml @@ -9,8 +9,8 @@ en: bf54b493565f1624 zh: 6f28a518f7b0dea8 /deepseek-ai-dsh-experimental-claude-code-mods/summary: - en: 4ddb71e280b6b328 - zh: e4775593fd7f232c + en: e21a08cf7ad99a77 + zh: df7634372a42d9b2 /deepseek-ai-dsh-experimental-claude-code-mods/table-of-contents: en: d152484eb41ac6b4 zh: 09388d293f9be9cb diff --git a/packages/experimental/claude-code-mods/README.md b/packages/experimental/claude-code-mods/README.md index 4fd02d3ded..52dc778067 100644 --- a/packages/experimental/claude-code-mods/README.md +++ b/packages/experimental/claude-code-mods/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. +Run [Claude Code mods](https://code.claude.com/docs/en/plugins/mods/overview) inside agent runs: wrap a mod's `register(on, options)` with `defineMod`, mount it as a plugin after this bridge, and its hooks guard tool calls, rewrite prompts, add commands and tools, read session facts, and draw a band above the prompt through the same `$`, `e`, `next` chain. The bridge requires `dsh-working-directory` and `dsh-session-projection`, and each `$` call rides a composed harness service. It is an alpha interface-compatibility demonstration: an unserved event is reported at load, an unserved `$` member fails naming the gap, and [the compatibility page](../../../docs/subsystems/claude-code-mods.md) lists every difference. ## Table of Contents diff --git a/packages/experimental/claude-code-mods/README.zh.md b/packages/experimental/claude-code-mods/README.zh.md index 1a2f909626..6b11df8418 100644 --- a/packages/experimental/claude-code-mods/README.zh.md +++ b/packages/experimental/claude-code-mods/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 +在智能体运行中执行 [Claude Code 模组](https://code.claude.com/docs/en/plugins/mods/overview):用 `defineMod` 包装模组的 `register(on, options)`,在本桥接之后作为插件挂载,其钩子即可通过同一套 `$`、`e`、`next` 链守卫工具调用、改写提示词、添加命令与工具、读取会话事实,并在提示框上方绘制一条横幅。桥接需要 `dsh-working-directory` 和 `dsh-session-projection`,每次 `$` 调用都落在一个已组合的 harness 服务上。它是 alpha 阶段的接口兼容性演示:未服务的事件在加载时报告,未服务的 `$` 成员以指明缺口的消息失败,[兼容性页面](../../../docs/subsystems/claude-code-mods.zh.md) 列出了全部差异。 ## 目录 diff --git a/packages/experimental/claude-code-mods/src/host-ops.ts b/packages/experimental/claude-code-mods/src/host-ops.ts index 3d6363b463..7b61a18a4c 100644 --- a/packages/experimental/claude-code-mods/src/host-ops.ts +++ b/packages/experimental/claude-code-mods/src/host-ops.ts @@ -9,20 +9,17 @@ */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -// Type-only: activate the `ctx.get('subprocess')` and `ctx.get('sessionProjections')` Context declarations. -import type {} from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/dsh-session-projection' -import type { SessionProjectionStateMap } from '@deepseek-ai/dsh-session-projection/types' +import { turnBoundaryProjectionDefinition, type Agent } from '@deepseek-ai/dsh-agent' +// Type-only: activate the `ctx.get('subprocess')` Context declaration. +import type {} from '@deepseek-ai/dsh-subprocess' import type { CommandDefinition } from '@deepseek-ai/dsh-commands' import type { FileSystem, FsTarget } from '@deepseek-ai/dsh-fs' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { scopeOf } from '@deepseek-ai/dsh-scope' -import type { Session } from '@deepseek-ai/dsh-session' import type { WorkingDirectoryService } from '@deepseek-ai/dsh-working-directory' -// Type-only: merges the token-meter projection states into SessionProjectionStateMap. -import type {} from '@deepseek-ai/dsh-token-meter' +import { contextPressureProjectionDefinition } from '@deepseek-ai/dsh-token-meter' import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' import type { Domain } from '@deepseek-ai/dsh-storage-domain' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -161,12 +158,14 @@ function sizeOf(info: { size?: number } | undefined): number { } /** - * Build the op table over the composed harness services. + * Build the op table with the required session projection registry and composed harness services. * @param options - the context, aliases, limits, and registration tracking. - * @returns engine behaviors by event name. + * @returns engine behaviors by event name, after both session projections are registered. */ -export function createHostOps(options: HostOpsOptions): OpTable { +export async function createHostOps(options: HostOpsOptions): Promise> { const { ctx, aliases } = options + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + const contextPressure = await ctx.sessionProjections.register(contextPressureProjectionDefinition) let storeDomain: Promise> | undefined function store(): Promise> { @@ -225,11 +224,6 @@ export function createHostOps(options: HostOpsOptions): OpTable { if (agent !== undefined) options.redraw?.(agent.session.id) } - /** One projection's state, or undefined when the registry or the projection's owning plugin is not composed. */ - function projection(session: Session, key: K): SessionProjectionStateMap[K] | undefined { - return ctx.get('sessionProjections')?.stateOf(session, key) - } - return { // ---- ui: no mod drawing surface exists; log-style calls reach the host log ---- 'ui.log': (input, { mod }) => { @@ -411,16 +405,14 @@ export function createHostOps(options: HostOpsOptions): OpTable { }, 'session.turns': (_input, context) => { const agent = requireAgent(context, 'session.turns') - const boundary = projection(agent.session, 'turnBoundary') - if (boundary === undefined) throw new Error('$.session.turns needs the turnBoundary projection, which this deployment did not compose') - return boundary.lastTurn + return turnBoundary.stateOf(agent.session).lastTurn }, 'session.messages': (_input, context) => sessionMessages(requireAgent(context, 'session.messages').session.deriveMessages(), aliases), 'session.usage': (_input, context): SessionUsage => { const agent = requireAgent(context, 'session.usage') - const pressure = projection(agent.session, 'contextPressure') - const tokens = pressure?.pressureTokens - const window = pressure?.contextWindow ?? 0 + const pressure = contextPressure.stateOf(agent.session) + const tokens = pressure.pressureTokens + const window = pressure.contextWindow ?? 0 return { startedAt: agent.session.header.createdAt, context: { diff --git a/packages/experimental/claude-code-mods/src/index.ts b/packages/experimental/claude-code-mods/src/index.ts index 1c97870b73..f715a74d7f 100644 --- a/packages/experimental/claude-code-mods/src/index.ts +++ b/packages/experimental/claude-code-mods/src/index.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-experimental-claude-code-mods */ -import type { Context } from '@deepseek-ai/cordis' +import { Service, type Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-working-directory' import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' @@ -208,22 +208,25 @@ export class ClaudeCodeMods extends TypertRemoteService { bandRows: z.number().default(10), }) - // Session initialization requires its directory owner; other services are read on demand. - static inject = ['workingDirectory'] + // Session initialization requires its directory owner and projection registry; other services are read on demand. + static inject = ['workingDirectory', 'sessionProjections'] - private readonly engine: ModsEngine + private engine!: ModsEngine private readonly registrations = new Map void>>() - private readonly surfaces: SurfaceTable + private surfaces!: SurfaceTable - constructor(ctx: Context, config: Config) { + constructor(ctx: Context, private readonly config: Config) { super(ctx, 'claudeCodeMods', { namespace: 'claudeCodeMods' }) + for (const key of ['hookTimeoutMs', 'catchTimeoutMs', 'processTimeoutMs', 'bandColumns', 'bandRows'] as const) { + const value = config[key] + if (value !== undefined) assertPositive(key, value) + } + } + + protected async [Service.init](): Promise { + const { ctx, config } = this // The schema supplies every default; a direct construction passes the fields it needs. const { hookTimeoutMs = 10_000, catchTimeoutMs = 1_000, processTimeoutMs = 30_000, bandColumns = 120, bandRows = 10 } = config - assertPositive('hookTimeoutMs', hookTimeoutMs) - assertPositive('catchTimeoutMs', catchTimeoutMs) - assertPositive('processTimeoutMs', processTimeoutMs) - assertPositive('bandColumns', bandColumns) - assertPositive('bandRows', bandRows) const aliases = createToolNameAliases(config.toolAliases) const callOrigins = new Map() const modCommands = new Set() @@ -259,7 +262,7 @@ export class ClaudeCodeMods extends TypertRemoteService { }, 0).unref() } const modSubmissions = new Map() - const ops = createHostOps({ + const ops = await createHostOps({ ctx, aliases, processTimeoutMs, registrations: this.registrations, callOrigins, modCommands, modTools, redraw, submitted: (messageId, mod) => { modSubmissions.set(messageId, mod) }, }) diff --git a/packages/experimental/claude-code-mods/tests/bridge.spec.ts b/packages/experimental/claude-code-mods/tests/bridge.spec.ts index 4ee329ce99..7243febfc6 100644 --- a/packages/experimental/claude-code-mods/tests/bridge.spec.ts +++ b/packages/experimental/claude-code-mods/tests/bridge.spec.ts @@ -535,7 +535,7 @@ describe('the mods API over harness services', () => { expect(out.session).toEqual({ id: 'a1', cwd: h.workspace, root: h.workspace, model: 'mock', turns: 1, version: MODS_API_VERSION, - usage: { startedAt: agent.session.header.createdAt, context: { window: 0 }, rateLimits: [] }, + usage: { startedAt: agent.session.header.createdAt, context: { tokens: 10, window: 0 }, rateLimits: [] }, messages: [ { role: 'user', text: 'hello', toolUses: [] }, { role: 'user', text: `Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: ${JSON.stringify(h.workspace)}.`, toolUses: [] }, @@ -951,7 +951,7 @@ describe('loading diagnostics and configuration', () => { }) it('is a Service plugin the Loader keeps by its default export', () => { - expect(ClaudeCodeMods.inject).toEqual(['workingDirectory']) + expect(ClaudeCodeMods.inject).toEqual(['workingDirectory', 'sessionProjections']) const loader = Object.create(Loader.prototype) as Loader const unwrapped: unknown = loader.unwrapExports({ default: ClaudeCodeMods }) expect(unwrapped).toBe(ClaudeCodeMods) diff --git a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts index d4bc93702d..987c2db123 100644 --- a/packages/experimental/claude-code-mods/tests/define-mod.spec.ts +++ b/packages/experimental/claude-code-mods/tests/define-mod.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context, type Fiber } from '@deepseek-ai/cordis' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import ClaudeCodeMods, { defineMod } from '../src/index.ts' @@ -32,7 +33,10 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) - await ctx.plugin(ClaudeCodeMods, {}) + const bridge = ctx.plugin(ClaudeCodeMods, {}) + expect(ctx.get('claudeCodeMods')).toBeUndefined() + await ctx.plugin(SessionProjectionRegistry) + await bridge const fiber = await ctx.plugin(plugin, { history: 3 }) await fiber.await() expect(seen).toEqual([{ history: 3, unit: 'tokens' }]) @@ -52,6 +56,7 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(ClaudeCodeMods, {}) const fiber = ctx.plugin(slow, {}) await vi.waitFor(() => { expect(release).toBeDefined() }) @@ -69,6 +74,7 @@ describe('defineMod', () => { const ctx = new Context() fibers.push(ctx.fiber) provideWorkingDirectoryFixture(ctx) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(ClaudeCodeMods, {}) const fiber = await ctx.plugin(bare, {}) await fiber.await() diff --git a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts index 15400980f4..750a68adf9 100644 --- a/packages/experimental/claude-code-mods/tests/host-ops.spec.ts +++ b/packages/experimental/claude-code-mods/tests/host-ops.spec.ts @@ -6,7 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Message } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness, provideWorkingDirectoryFixture } from '@deepseek-ai/dsh-agent-loop-testkit' import CommandRuntime from '@deepseek-ai/dsh-commands' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' @@ -34,10 +35,11 @@ afterEach(async () => { const mod: LoadedMod = { name: 'unit-mod', version: undefined, root: '/mods/unit-mod', options: {}, order: 0 } -function setup(ctx = new Context()) { +async function setup(ctx = new Context()) { contexts.push(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) const registrations = new Map void>>() - const ops = createHostOps({ + const ops = await createHostOps({ ctx, aliases: createToolNameAliases(), processTimeoutMs: 5_000, @@ -72,12 +74,16 @@ function fakeAgent(ctx: Context, overrides: { cwd?: string; model?: string; requ id: 'fake', ctx, options: overrides.model === undefined ? {} : { model: overrides.model }, - session: { - id: 'fake', - header: { id: 'fake', createdAt: 1, ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd } }, + session: Object.assign(Session.create(SessionId('fake'), [], { + version: SESSION_FORMAT_VERSION, + id: SessionId('fake'), + createdAt: 1, + isSeeded: false, + ...overrides.cwd === undefined ? {} : { cwd: overrides.cwd }, + }), { requestHeader: () => overrides.requestModel === undefined ? undefined : { config: { provider: 'p', model: overrides.requestModel } }, deriveMessages: (): Message[] => [], - }, + }), } return facts as Agent } @@ -100,7 +106,7 @@ describe('values', () => { describe('services a deployment did not compose', () => { it('names the missing service in each call', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() const agent = fakeAgent(ctx) await expect(call('ui.ask', { question: 'q' }, agent)).rejects.toThrow(/dsh-user-questions/) await expect(call('command.register', { name: 'x', description: 'd' }, agent)).rejects.toThrow(/dsh-commands/) @@ -113,7 +119,7 @@ describe('services a deployment did not compose', () => { await expect(call('store.get', { key: 'k' })).rejects.toThrow(/dsh-storage-domain/) await expect(call('fs.read', { path: 'x' })).rejects.toThrow(/dsh-fs/) await expect(call('process.run', { argv: ['ls'] })).rejects.toThrow(/dsh-subprocess/) - await expect(call('session.turns', {}, agent)).rejects.toThrow(/turnBoundary projection/) + await expect(call('session.turns', {}, agent)).resolves.toBe(0) // Without a drawn surface the ui calls that would redraw the band are plain no-ops. await expect(call('ui.invalidate', {}, agent)).resolves.toBeUndefined() await expect(call('ui.invalidate', {})).resolves.toBeUndefined() @@ -123,7 +129,7 @@ describe('services a deployment did not compose', () => { }) it('requires a session for session-bound calls and validates mod input', async () => { - const { call } = setup() + const { call } = await setup() for (const op of ['ui.ask', 'command.run', 'command.list', 'prompt.submit', 'session.id', 'session.cwd', 'session.root', 'session.model', 'session.turns', 'session.messages', 'session.usage']) { await expect(call(op, {})).rejects.toThrow(`$.${op} needs a session, and this event has none`) } @@ -137,7 +143,7 @@ describe('services a deployment did not compose', () => { describe('session facts from the agent', () => { it('falls back from the logged route to the agent option to an empty model, and from the session cwd to the process cwd', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() expect(await call('session.model', {}, fakeAgent(ctx, { requestModel: 'logged', model: 'opt' }))).toBe('logged') expect(await call('session.model', {}, fakeAgent(ctx, { model: 'opt' }))).toBe('opt') expect(await call('session.model', {}, fakeAgent(ctx))).toBe('') @@ -157,7 +163,7 @@ describe('files through the filesystem seam', () => { dirs.push(root) const ctx = new Context() await ctx.plugin(LocalFileSystem, { cwd: root }) - const { call } = setup(ctx) + const { call } = await setup(ctx) provideWorkingDirectoryFixture(ctx) writeFileSync(join(root, 'small.txt'), 'small') writeFileSync(join(root, 'big.txt'), Buffer.alloc(FS_MAX_BYTES + 1, 97)) @@ -182,7 +188,7 @@ describe('processes through the subprocess seam', () => { it('runs argv with an explicit cwd and env, and reports a signal-terminated child', async () => { const ctx = new Context() await ctx.plugin(LocalSubprocessRuntime) - const { call } = setup(ctx) + const { call } = await setup(ctx) const cwd = mkdtempSync(join(tmpdir(), 'dsh-cc-mods-proc-')) dirs.push(cwd) const run = await call('process.run', { @@ -220,7 +226,7 @@ describe('the store limit', () => { close: () => Promise.resolve(), } ctx.provide('storageDomain', { open: () => Promise.resolve(domain) }) - const { call } = setup(ctx) + const { call } = await setup(ctx) expect(await call('store.keys', {})).toEqual([]) await call('store.set', { key: 'ok', value: 1 }) expect(await call('store.keys', {})).toEqual(['ok']) @@ -247,7 +253,7 @@ describe('registrations outside a session', () => { const ctx = new Context() await mountAgentLoopTestDependencies(ctx) await ctx.plugin(CommandRuntime) - const { call, registrations, engine } = setup(ctx) + const { call, registrations, engine } = await setup(ctx) await call('command.register', { name: 'Global', description: 'global command' }) await call('tool.register', { name: 'global_tool', description: 'global tool' }) expect(ctx.tools.schemas().map(tool => tool.name)).toContain('mcp__unit-mod__global_tool') @@ -264,7 +270,7 @@ describe('registrations outside a session', () => { describe('ui.log sinks', () => { it('writes transcript lines at info and debug lines at debug', async () => { - const { call, ctx } = setup() + const { call, ctx } = await setup() const info = vi.fn() const debug = vi.fn() ctx.logger.info = info as never @@ -293,7 +299,7 @@ describe('the committed directory owner', () => { await mountAgentLoopTestDependencies(ctx, { workingDirectory: true }) const harness = await mountAgentLoopTestHarness(ctx) await ctx.plugin(LocalSubprocessRuntime) - const { call } = setup(ctx) + const { call } = await setup(ctx) const agent = await harness.create(SessionId('directory-owner'), {}, { cwd: origin }) await ctx.workingDirectory.set(agent, current) @@ -322,7 +328,7 @@ describe('the committed directory owner', () => { }) it('fails explicitly when a session-bound operation has no directory owner', async () => { - const { ctx, call } = setup() + const { ctx, call } = await setup() await ctx.plugin(LocalFileSystem) await ctx.plugin(LocalSubprocessRuntime) const agent = fakeAgent(ctx) diff --git a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts index 534d6d3408..5ed24ad300 100644 --- a/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts +++ b/packages/experimental/client-ui-cot-translation/tests/activation.host.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from '@deepseek-ai/cordis' import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import { createSessionTestController } from '../../../api/session-controller/tests/test-remote.ts' import Translator from '@deepseek-ai/dsh-experimental-translator' import { SESSION_FORMAT_VERSION, SessionId, SessionSeq } from '@deepseek-ai/dsh-session' import JsonlPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -66,7 +66,9 @@ async function fixture(origin?: 'subagent') { saveSelection: async () => {}, } as never) ctx.provide('fileUploads', { registerAgentResolver: () => () => {} } as never) - const sessions = new SessionController(ctx, { nativeOpen: false }) + const sessions = await createSessionTestController(ctx, { + nativeOpen: false, cwd: root, defaultModelSelection: () => ({ provider: 'unused', model: 'unused' }), + }) await ctx.plugin(Translator, Translator.Config({ bingEndpoint: `http://127.0.0.1:${address.port}/bing` })) const ui = ctx.plugin(CotTranslationController, {}) await ui diff --git a/packages/experimental/hooks-claude-code/src/index.ts b/packages/experimental/hooks-claude-code/src/index.ts index afbc60de08..1c79429b80 100644 --- a/packages/experimental/hooks-claude-code/src/index.ts +++ b/packages/experimental/hooks-claude-code/src/index.ts @@ -8,12 +8,13 @@ * @module @deepseek-ai/dsh-hooks-claude-code */ +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent, PreStepDecision, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-projection' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-llm' { @@ -43,10 +44,11 @@ import type { SubagentRunId } from '@deepseek-ai/dsh-subagent' import { parseClaudeCodeConfig, type ClaudeCodeHookConfig } from './config.ts' export const name = 'hooks-claude-code' -// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest -// are read opportunistically via ctx.get so a deployment can omit them. + export const inject = ['shell', 'sessionProjections', 'workingDirectory'] +// `shell` runs hooks and `sessionProjections` supplies turn numbers; the rest +// are read opportunistically via ctx.get so a deployment can omit them. /** Plugin config: where the CC hook config lives + substitution roots. */ export interface Config { /** @@ -99,7 +101,12 @@ function assertPositiveInteger(name: string, value: number): void { } } -export function apply(ctx: Context, config: Config): void { +/** Register the hooks-claude-code plugin after its projections are ready. + * @param ctx - plugin context. + * @param config - hook configuration path and execution limits. + * @returns when the plugin is ready. + */ +export async function apply(ctx: Context, config: Config): Promise { // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) @@ -121,6 +128,8 @@ export function apply(ctx: Context, config: Config): void { return } + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + // Emit-shaped points run detached, so track their chains; disposal aborts // active hooks and drains continuations before resolving. const detached = createDetachedRuns() @@ -242,7 +251,7 @@ export function apply(ctx: Context, config: Config): void { // --- PreToolUse → PreToolDecision. Matcher subject is the tool name. --- ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(ctx, exec.agent) + const turn = lastTurn(exec.agent) const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} } @@ -251,7 +260,7 @@ export function apply(ctx: Context, config: Config): void { // --- PostToolUse → PostToolDecision. Matcher subject is the tool name. --- ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(ctx, exec.agent) + const turn = lastTurn(exec.agent) const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { @@ -301,6 +310,12 @@ export function apply(ctx: Context, config: Config): void { detached.track(runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child, directory: 'committed' } : {}, signal: detached.signal }) .catch((error: unknown) => { ctx.logger.warn(`hooks-claude-code: SubagentStop hook failed: ${String(error)}`) })) }) + + function lastTurn(agent: Agent | undefined): number { + if (!agent) return 0 + const boundary = turnBoundary.stateOf(agent.session) + return boundary.lastTurn + } } /** @@ -314,13 +329,6 @@ const SUBAGENT_TYPE = 'general-purpose' // --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's // hook input schema; this is the part a bridge owns. --- -/** The last open turn number in the agent's log, or 0 without an agent. */ -function lastTurn(ctx: Context, agent: Agent | undefined): number { - if (!agent) return 0 - const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') as TurnBoundaryProjection - return boundary.lastTurn -} - /** Flatten content blocks to the text a hook payload carries (the common case). */ function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') diff --git a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts index b77aa85c96..dec29257f9 100644 --- a/packages/experimental/hooks-claude-code/tests/coverage-cases.ts +++ b/packages/experimental/hooks-claude-code/tests/coverage-cases.ts @@ -381,7 +381,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // Direct apply with only configPath — bypasses schemastery's defaults, so // the bridge must run on the raw minimal config (the per-hook timeout is // the protocol lib's reference default, not a config knob). - HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) + await HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) diff --git a/packages/experimental/hooks-codex/src/index.ts b/packages/experimental/hooks-codex/src/index.ts index 139a99de6d..6159a90dda 100644 --- a/packages/experimental/hooks-codex/src/index.ts +++ b/packages/experimental/hooks-codex/src/index.ts @@ -11,12 +11,13 @@ // Each dialect bridge keeps its complete dependency list visible at the entry // point; a cross-package facade for imports alone would add indirection. /* jscpd:ignore-start */ +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-working-directory' import { readFileSync } from 'node:fs' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' -import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-session-projection' +import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' declare module '@deepseek-ai/dsh-llm' { @@ -41,11 +42,13 @@ import { type MergedHookOutcome, } from '@deepseek-ai/dsh-hook-protocol' import { parseCodexConfig, type CodexHookConfig } from './config.ts' -/* jscpd:ignore-end */ export const name = 'hooks-codex' + export const inject = ['shell', 'sessionProjections', 'workingDirectory'] +/* jscpd:ignore-end */ + /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ export interface Config { /** @@ -84,7 +87,12 @@ function assertPositiveInteger(name: string, value: number): void { } } -export function apply(ctx: Context, config: Config): void { +/** Register the hooks-codex plugin after its projections are ready. + * @param ctx - plugin context. + * @param config - hook configuration path and execution limits. + * @returns when the plugin is ready. + */ +export async function apply(ctx: Context, config: Config): Promise { // Validate before config parsing so a bad value cannot be hidden by its early return. const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) @@ -102,6 +110,7 @@ export function apply(ctx: Context, config: Config): void { return } + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const model = config.model ?? '' // SessionStart is the one emit-shaped (detached) point Codex has: track its @@ -195,7 +204,6 @@ export function apply(ctx: Context, config: Config): void { .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) }) detached.track(run) await run - /* jscpd:ignore-end */ }) // UserPromptSubmit → PreStepDecision. Codex supports reject, not rewrite or ask. @@ -226,18 +234,17 @@ export function apply(ctx: Context, config: Config): void { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { - const turn = lastTurn(ctx, exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(ctx, exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) - /* jscpd:ignore-end */ + const turn = lastTurn(exec.agent) + const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() }) // PostToolUse → PostToolDecision (block with feedback, or attach context). ctx.on('tools/post-execute', async (exec, result, next): Promise => { - const turn = lastTurn(ctx, exec.agent) + const turn = lastTurn(exec.agent) /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(ctx, exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -261,8 +268,7 @@ export function apply(ctx: Context, config: Config): void { // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { - const merged = await runPoint('Stop', '', { ...turnBase(ctx, agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) - /* jscpd:ignore-end */ + const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, // empty stderr) still forces it — fall back to a generic steering line @@ -271,6 +277,27 @@ export function apply(ctx: Context, config: Config): void { agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE })) } }) + + function lastTurn(agent: Agent | undefined): number { + if (!agent) return 0 + return turnBoundary.stateOf(agent.session).lastTurn + } + + function turnBase(agent: Agent | undefined, event: string, model: string): Record { + return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) } + } + + function preToolPayload(exec: ToolExecution, model: string): Record { + // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); + // a hardcoded constant would disagree with what the matcher tests and make a + // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` + // shape (its shell payload), derived from the call's `command` arg when present. + return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } + } + + function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record { + return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } + } } // --- Codex DIALECT payloads: snake_case, model on every event, turn_id on @@ -279,11 +306,6 @@ export function apply(ctx: Context, config: Config): void { // These small payload helpers intentionally remain next to the dialect shape; // sharing them would pull bridge-only agent/LLM dependencies into hook-protocol. /* jscpd:ignore-start */ -function lastTurn(ctx: Context, agent: Agent | undefined): number { - if (!agent) return 0 - /* v8 ignore next -- agent-present hook points run inside AgentLoop, which owns this projection. */ - return ctx.sessionProjections.stateOf(agent.session, 'turnBoundary')?.lastTurn ?? 0 -} function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') @@ -303,11 +325,6 @@ function base(agent: Agent | undefined, event: string, model: string): Record { - return { ...base(agent, event, model), turn_id: String(lastTurn(ctx, agent)) } -} - /** Extract a `command` string from a tool call's parsed arguments, else ''. */ function commandOf(args: unknown): string { if (typeof args === 'object' && args !== null && 'command' in args) { @@ -316,15 +333,3 @@ function commandOf(args: unknown): string { } return '' } - -function preToolPayload(ctx: Context, exec: ToolExecution, model: string): Record { - // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); - // a hardcoded constant would disagree with what the matcher tests and make a - // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` - // shape (its shell payload), derived from the call's `command` arg when present. - return { ...turnBase(ctx, exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } -} - -function postToolPayload(ctx: Context, exec: ToolExecution, result: ToolExecutionResult, model: string): Record { - return { ...turnBase(ctx, exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } -} diff --git a/packages/experimental/hooks-codex/tests/coverage-cases.ts b/packages/experimental/hooks-codex/tests/coverage-cases.ts index 499a4a8fc1..d05a64984e 100644 --- a/packages/experimental/hooks-codex/tests/coverage-cases.ts +++ b/packages/experimental/hooks-codex/tests/coverage-cases.ts @@ -326,7 +326,7 @@ export function defineCoverageCases(groups: CoverageGroup | readonly CoverageGro await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. - HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) + await HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = await ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })); await waitForIdle(ctx, agent) diff --git a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts index b2bf78ddca..282662b1a0 100644 --- a/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts +++ b/packages/experimental/session-title-all-prompts-llm/tests/provider.spec.ts @@ -92,7 +92,7 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter(script, reasoningEfforts) ctx.llm.registerAdapter(['title-route'], adapter) @@ -215,7 +215,7 @@ describe('all-messages LLM title provider', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) // A one-byte fallback cap cannot derive a title from the multibyte message, // so the service supplies no currentTitle to the provider. await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 1, maxTitleBytes: 80 }) diff --git a/packages/experimental/tool-session-query/src/index.ts b/packages/experimental/tool-session-query/src/index.ts index 61e18cc8f5..8bbf6c3bff 100644 --- a/packages/experimental/tool-session-query/src/index.ts +++ b/packages/experimental/tool-session-query/src/index.ts @@ -9,13 +9,11 @@ import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' import { toolInput } from './input.ts' -import { operations } from './operations.ts' +import { SessionQueryOperations } from './operations.ts' import { presentation } from './presentation.ts' -/** Cordis plugin name used by Loader diagnostics. */ export const name = 'tool-session-query' -/** Capability services required by the model-facing consumer. */ export const inject = ['tools', 'systemPrompt', 'sessionQuery', 'sessionProjections'] /** Default maximum number of authorized search hits returned by one call. */ @@ -53,8 +51,15 @@ const PROMPT_TEXT = + 'events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with ' + 'session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.' -/** Register all five tools and their shared model guidance. */ -export function apply(ctx: Context, config: Config): void { +/** + * Register all five tools and their shared model guidance. + * @param ctx - owning plugin context. + * @param config - search limits. + * @returns when the tools and their projection reader are ready. + */ +export async function apply(ctx: Context, config: Config): Promise { + const operations = new SessionQueryOperations(ctx) + await operations.initialize() const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:session-query', @@ -68,7 +73,7 @@ export function apply(ctx: Context, config: Config): void { parameters: toolInput.sessionSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeSessionSearch(ctx, args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeSessionSearch(args, exec, resolved.maxSearchResults), presentCall: presentation.presentSessionSearchCall, })) @@ -78,7 +83,7 @@ export function apply(ctx: Context, config: Config): void { parameters: toolInput.eventSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeEventSearch(ctx, args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeEventSearch(args, exec, resolved.maxSearchResults), presentCall: presentation.presentEventSearchCall, })) @@ -88,7 +93,7 @@ export function apply(ctx: Context, config: Config): void { parameters: toolInput.targetSessionParameter, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeSessionTrace(ctx, args, exec), + execute: (args, exec) => operations.executeSessionTrace(args, exec), presentCall: presentation.presentSessionTraceCall, })) @@ -101,7 +106,7 @@ export function apply(ctx: Context, config: Config): void { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventTrace(ctx, args, exec), + execute: (args, exec) => operations.executeEventTrace(args, exec), presentCall: args => presentation.presentEventTargetCall('Trace event', args), })) @@ -116,7 +121,7 @@ export function apply(ctx: Context, config: Config): void { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventRead(ctx, args, exec), + execute: (args, exec) => operations.executeEventRead(args, exec), presentCall: args => presentation.presentEventTargetCall('Read event', args), })) } diff --git a/packages/experimental/tool-session-query/src/operations.ts b/packages/experimental/tool-session-query/src/operations.ts index 3d5fa17f31..4c05480221 100644 --- a/packages/experimental/tool-session-query/src/operations.ts +++ b/packages/experimental/tool-session-query/src/operations.ts @@ -4,6 +4,8 @@ * @module @deepseek-ai/dsh-tool-session-query/operations */ +import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' @@ -52,193 +54,249 @@ interface SearchCollection { readonly capped: boolean } -async function executeSessionSearch( - ctx: Context, - args: SessionSearchArgs, - exec: ToolRunContext, - maxResults: number, -): Promise { - const caller = workspaceAccess.callerOf(exec, ctx) - const cwd = caller.header.cwd - if (cwd === undefined) { - throw new HarnessError( - 'cross-session search is unavailable because the caller session has no workspace', - 'SESSION_QUERY_TOOL_UNAUTHORIZED', - ) - } - const query = toolInput.normalizeQuery(args.query) - const sessionFilters = toolInput.buildSessionFilters(args) - const eventFilters = toolInput.buildEventFilters({ - seqFrom: args.event_seq_from, - seqTo: args.event_seq_to, - timeFrom: args.event_time_from, - timeTo: args.event_time_to, - eventTypes: args.event_types, - surfaces: args.event_surfaces, - }) - const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) - if (requestedParentIds !== undefined || args.include_root_sessions === true) { - const authorizedParentIds = requestedParentIds === undefined - ? new Set() - : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) - const parentValues: Array = requestedParentIds - ?.filter(id => authorizedParentIds.has(id)) ?? [] - if (args.include_root_sessions === true) parentValues.push(null) - if (parentValues.length === 0) return presentation.formatEmptySessionSearch() - sessionFilters.push({ kind: 'parent', values: parentValues }) - } - sessionFilters.push({ kind: 'cwd', values: [cwd] }) - const collected = await collectPages( - maxResults, - exec.signal, - cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => - ctx.sessionQuery.searchSessions({ - query, - sessionFilters, - eventFilters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })), - hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), - ) +/** Session-history tool operations over the caller’s current turn state. */ +export class SessionQueryOperations { + private turnBoundary!: ProjectionHandle - const parentIds = collected.items - .map(hit => hit.header.parentSession) - .filter((id): id is SessionId => id !== undefined) - const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) - const titles = await workspaceAccess.readTitles( - ctx, - caller, - collected.items.map(hit => hit.header.id), - exec.signal, - ) - return presentation.formatSessionSearch(collected, titles, authorizedParents) -} + constructor(private readonly ctx: Context) {} -async function executeEventSearch( - ctx: Context, - args: EventSearchArgs, - exec: ToolRunContext, - maxResults: number, -): Promise { - const caller = workspaceAccess.callerOf(exec, ctx) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const query = toolInput.normalizeQuery(args.query) - const range = toolInput.sequenceRange(args.seq_from, args.seq_to) - if (sessionId === caller.id) { - const stepStartSeq = caller.boundary?.lastStepStartSeq - if (stepStartSeq === undefined) { + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + this.turnBoundary = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + } + + private callerOf(exec: ToolRunContext) { + const agent = exec.agent + if (agent === undefined) { throw new HarnessError( - 'current-session search requires an active step boundary', - 'SESSION_QUERY_TOOL_NO_CURRENT_STEP', + 'session query tools require an agent-bound caller', + 'SESSION_QUERY_TOOL_MISSING_AGENT', ) } - // `null` (no step started yet) caps the range to a degenerate `to` the - // filter validation rejects — the loop never runs tools outside a step. - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) - } - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - if (range.from !== undefined && range.to !== undefined && range.from > range.to) { - return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) - } - const filters = toolInput.buildEventFilters({ - seqFrom: range.from, - seqTo: range.to, - timeFrom: args.time_from, - timeTo: args.time_to, - eventTypes: args.event_types, - surfaces: args.surfaces, - }) - const collected = await collectPages( - maxResults, - exec.signal, - async (cursor): Promise => { - const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => - ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) - return page - }, - () => true, - ) - return presentation.formatEventSearch(sessionId, title, collected) -} - -async function executeSessionTrace( - ctx: Context, - args: SessionTargetArgs, - exec: ToolRunContext, -): Promise { - const caller = workspaceAccess.callerOf(exec, ctx) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => - ctx.sessionQuery.traceSession(sessionId, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) - - const ancestors: SessionRecord[] = [] - let ancestorBoundary = false - for (const ancestor of trace.ancestors) { - if (!workspaceAccess.recordAuthorized(ancestor, caller)) { - ancestorBoundary = true - break + return { + id: agent.session.id, + header: agent.session.header, + boundary: this.turnBoundary.stateOf(agent.session), } - ancestors.push(ancestor) } - if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true - const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) - const visibleIds = [ - trace.target.header.id, - ...ancestors.map(record => record.header.id), - ...workspaceAccess.descendantIds(descendants), - ] - const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) - return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) -} -async function executeEventTrace( - ctx: Context, - args: EventTargetArgs, - exec: ToolRunContext, -): Promise { - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - const caller = workspaceAccess.callerOf(exec, ctx) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => - ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventTrace(sessionId, title, trace) -} + /** + * Search other Sessions within the caller’s authorized workspace. + * @param args - Session and event filters. + * @param exec - Agent-bound caller and cancellation signal. + * @param maxResults - Maximum number of authorized matches to return. + * @returns Formatted authorized Session matches and truncation status. + */ + async executeSessionSearch( + args: SessionSearchArgs, + exec: ToolRunContext, + maxResults: number, + ): Promise { + const ctx = this.ctx + const caller = this.callerOf(exec) + const cwd = caller.header.cwd + if (cwd === undefined) { + throw new HarnessError( + 'cross-session search is unavailable because the caller session has no workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) + } + const query = toolInput.normalizeQuery(args.query) + const sessionFilters = toolInput.buildSessionFilters(args) + const eventFilters = toolInput.buildEventFilters({ + seqFrom: args.event_seq_from, + seqTo: args.event_seq_to, + timeFrom: args.event_time_from, + timeTo: args.event_time_to, + eventTypes: args.event_types, + surfaces: args.event_surfaces, + }) + const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) + if (requestedParentIds !== undefined || args.include_root_sessions === true) { + const authorizedParentIds = requestedParentIds === undefined + ? new Set() + : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) + const parentValues: Array = requestedParentIds + ?.filter(id => authorizedParentIds.has(id)) ?? [] + if (args.include_root_sessions === true) parentValues.push(null) + if (parentValues.length === 0) return presentation.formatEmptySessionSearch() + sessionFilters.push({ kind: 'parent', values: parentValues }) + } + sessionFilters.push({ kind: 'cwd', values: [cwd] }) + const collected = await collectPages( + maxResults, + exec.signal, + cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => + ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })), + hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), + ) -async function executeEventRead( - ctx: Context, - args: EventReadArgs, - exec: ToolRunContext, -): Promise { - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) - if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) - const caller = workspaceAccess.callerOf(exec, ctx) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => - ctx.sessionQuery.readEvent({ - sessionId, - seq, - ...args.before === undefined ? {} : { before: args.before }, - ...args.after === undefined ? {} : { after: args.after }, - }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventRead(sessionId, title, window) + const parentIds = collected.items + .map(hit => hit.header.parentSession) + .filter((id): id is SessionId => id !== undefined) + const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) + const titles = await workspaceAccess.readTitles( + ctx, + caller, + collected.items.map(hit => hit.header.id), + exec.signal, + ) + return presentation.formatSessionSearch(collected, titles, authorizedParents) + } + + /** + * Search authorized Session events, excluding the caller’s current step. + * @param args - Target Session and event filters. + * @param exec - Agent-bound caller and cancellation signal. + * @param maxResults - Maximum number of authorized matches to return. + * @returns Formatted matching events and truncation status. + */ + async executeEventSearch( + args: EventSearchArgs, + exec: ToolRunContext, + maxResults: number, + ): Promise { + const ctx = this.ctx + const caller = this.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const query = toolInput.normalizeQuery(args.query) + const range = toolInput.sequenceRange(args.seq_from, args.seq_to) + if (sessionId === caller.id) { + const stepStartSeq = caller.boundary.lastStepStartSeq + // `null` (no step started yet) caps the range to a degenerate `to` the + // filter validation rejects — the loop never runs tools outside a step. + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) + } + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) + } + const filters = toolInput.buildEventFilters({ + seqFrom: range.from, + seqTo: range.to, + timeFrom: args.time_from, + timeTo: args.time_to, + eventTypes: args.event_types, + surfaces: args.surfaces, + }) + const collected = await collectPages( + maxResults, + exec.signal, + async (cursor): Promise => { + const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => + ctx.sessionQuery.searchEvents({ + sessionId, + query, + filters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, + () => true, + ) + return presentation.formatEventSearch(sessionId, title, collected) + } + + /** + * Trace Session lineage within the caller’s workspace permissions. + * @param args - Optional target Session id; defaults to the caller. + * @param exec - Agent-bound caller and cancellation signal. + * @returns Formatted authorized ancestors and descendants. + */ + async executeSessionTrace( + args: SessionTargetArgs, + exec: ToolRunContext, + ): Promise { + const ctx = this.ctx + const caller = this.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => + ctx.sessionQuery.traceSession(sessionId, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) + + const ancestors: SessionRecord[] = [] + let ancestorBoundary = false + for (const ancestor of trace.ancestors) { + if (!workspaceAccess.recordAuthorized(ancestor, caller)) { + ancestorBoundary = true + break + } + ancestors.push(ancestor) + } + if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true + const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) + const visibleIds = [ + trace.target.header.id, + ...ancestors.map(record => record.header.id), + ...workspaceAccess.descendantIds(descendants), + ] + const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) + return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) + } + + /** + * Trace an event after authorizing its owning Session. + * @param args - Target Session and event sequence. + * @param exec - Agent-bound caller and cancellation signal. + * @returns Formatted event lineage. + */ + async executeEventTrace( + args: EventTargetArgs, + exec: ToolRunContext, + ): Promise { + const ctx = this.ctx + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + const caller = this.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => + ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventTrace(sessionId, title, trace) + } + + /** + * Read an event window after authorizing its owning Session. + * @param args - Target event and surrounding event counts. + * @param exec - Agent-bound caller and cancellation signal. + * @returns Formatted event window. + */ + async executeEventRead( + args: EventReadArgs, + exec: ToolRunContext, + ): Promise { + const ctx = this.ctx + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) + if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) + const caller = this.callerOf(exec) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => + ctx.sessionQuery.readEvent({ + sessionId, + seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventRead(sessionId, title, window) + } } async function collectPages( @@ -275,12 +333,3 @@ async function collectPages( cursor = page.nextCursor } } - -/** Five model-facing session-query operation implementations. */ -export const operations = { - executeSessionSearch, - executeEventSearch, - executeSessionTrace, - executeEventTrace, - executeEventRead, -} diff --git a/packages/experimental/tool-session-query/src/workspace-access.ts b/packages/experimental/tool-session-query/src/workspace-access.ts index 9eabee4e46..4f11e64f38 100644 --- a/packages/experimental/tool-session-query/src/workspace-access.ts +++ b/packages/experimental/tool-session-query/src/workspace-access.ts @@ -6,7 +6,6 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' -import { HarnessError } from '@deepseek-ai/dsh-llm' import { type SessionHeader, type SessionId as SessionIdValue, @@ -16,15 +15,13 @@ import type { SessionLineageNode, SessionRecord, } from '@deepseek-ai/dsh-session-query' -import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { readonly id: SessionIdValue readonly header: SessionHeader /** The caller's own-session boundary fold (the `turnBoundary` projection). */ - readonly boundary: TurnBoundaryProjection | undefined + readonly boundary: TurnBoundaryProjection } interface TitleView { @@ -53,21 +50,6 @@ interface DescendantVisit { readonly next: DescendantVisit | undefined } -function callerOf(exec: ToolRunContext, ctx: Context): Caller { - const agent = exec.agent - if (agent === undefined) { - throw new HarnessError( - 'session query tools require an agent-bound caller', - 'SESSION_QUERY_TOOL_MISSING_AGENT', - ) - } - return { - id: agent.session.id, - header: agent.session.header, - boundary: ctx.sessionProjections.stateOf(agent.session, 'turnBoundary'), - } -} - function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue { return args.session_id === undefined ? caller.id : brandString(args.session_id) } @@ -242,7 +224,6 @@ function titleText(view: TitleView): string { /** Workspace-scoped caller authorization, title access, and lineage projection. */ export const workspaceAccess = { - callerOf, targetId, authorizeTarget, recordAuthorized, diff --git a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts index ed6402865f..d71079adb7 100644 --- a/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/experimental/tool-session-query/tests/sqlite-integration.spec.ts @@ -33,8 +33,8 @@ function fakeAgent(session: Session): Agent { return { id: session.id, session } as unknown as Agent } -function registerTurnBoundary(ctx: Context): void { - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function registerTurnBoundary(ctx: Context): Promise { + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) } describe('tool-session-query with the real SQLite provider', () => { @@ -45,7 +45,7 @@ describe('tool-session-query with the real SQLite provider', () => { contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) @@ -118,7 +118,7 @@ describe('tool-session-query with the real SQLite provider', () => { contexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) diff --git a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts index 3d11ad3f85..c5dd7fddfc 100644 --- a/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/experimental/tool-session-query/tests/tool-session-query.spec.ts @@ -31,6 +31,7 @@ import SessionQueryEngine, { } from '@deepseek-ai/dsh-session-query' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { Config as ToolSessionQueryConfig } from '@deepseek-ai/dsh-tool-session-query' import * as ToolSessionQuery from '@deepseek-ai/dsh-tool-session-query' declare module '@deepseek-ai/dsh-llm' { @@ -207,12 +208,12 @@ interface Mounted { call(name: string, args: unknown, options?: { agent?: Agent; signal?: AbortSignal }): Promise } -function registerTurnBoundary(ctx: Context): void { - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function registerTurnBoundary(ctx: Context): Promise { + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) } async function mount( - config: ToolSessionQuery.Config = {}, + config: ToolSessionQueryConfig = {}, callerCwd: string | null = '/work', enforceTimeout = false, ): Promise { @@ -220,7 +221,7 @@ async function mount( activeContexts.push(ctx) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - registerTurnBoundary(ctx) + await registerTurnBoundary(ctx) await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) if (enforceTimeout) await ctx.plugin(TimeoutPolicy) @@ -329,14 +330,14 @@ describe('registration and schemas', () => { it('fails invalid direct config before registering anything', async () => { const mounted = await mount() for (const maxSearchResults of [0, 1.5, Number.NaN]) { - expect(() => { ToolSessionQuery.apply(mounted.ctx, { maxSearchResults }) }) - .toThrow('maxSearchResults') + await expect(ToolSessionQuery.apply(mounted.ctx, { maxSearchResults })) + .rejects.toThrow('maxSearchResults') } for (const searchTimeoutMs of [0, 1.5, Number.POSITIVE_INFINITY, MAX_TIMER_DELAY_MS + 1]) { - expect(() => { ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs }) }) - .toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) + await expect(ToolSessionQuery.apply(mounted.ctx, { searchTimeoutMs })) + .rejects.toThrow(`no greater than ${MAX_TIMER_DELAY_MS}`) } - expect(() => { ToolSessionQuery.apply(new Context(), {}) }).toThrow() + await expect(ToolSessionQuery.apply(new Context(), {})).rejects.toThrow() }) it('expresses the complete Node timer range in the Loader config schema', () => { @@ -1652,7 +1653,7 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => expect(text(result)).toContain('Result cap reached') }) - it('rejects current-session search without the turnBoundary fold', async () => { + it('rejects current-session search before any step has started', async () => { const ctx = new Context() activeContexts.push(ctx) await ctx.plugin(SessionStore) @@ -1669,7 +1670,8 @@ describe('search paging, prior-history bounds, titles, and cancellation', () => signal: new AbortController().signal, agent: fakeAgent(session), }) - expect(errorCode(result)).toBe('SESSION_QUERY_TOOL_NO_CURRENT_STEP') + expect(ctx.sessionProjections.checkpoint(session).turnBoundary).toBeDefined() + expect(errorCode(result)).toBe('SESSION_QUERY_INVALID_FILTER') }) it('preserves base results when a title read fails, annotates the code, and logs the full error', async () => { diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index f495097df2..6d8c8e9698 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -841,7 +841,7 @@ describe('sandbox escalation API (write/edit)', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRuntime) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write' }) await ctx.plugin(SandboxingFakeFs) await ctx.plugin(FsPolicy) diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 48a02e485c..cbc0344b95 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,7 +5,7 @@ */ import { randomUUID } from 'node:crypto' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' @@ -14,7 +14,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionLogOffset } from '@deepseek-ai/dsh-session' import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { applyGoalEvent, @@ -247,15 +247,21 @@ export class GoalService extends TypertRemoteService { private readonly resolved: ResolvedConfig private readonly runtimeStates = new WeakMap() + private projection!: ProjectionHandle + constructor(ctx: Context, config: Config = {}) { super(ctx, 'goals') this.resolved = { defaultMaxGoalRounds: resolveMaxGoalRounds(config.defaultMaxGoalRounds ?? 256), } + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + this.projection = await ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('agent/created', ({ agent }) => { this.setActivation(agent.session, 'disarmed') }) - ctx.sessionProjections.register(goalProjectionDefinition) ctx.on('session/event', (session, event) => { if (event.type !== 'goal/change') return const runtime = this.runtimeState(session) @@ -474,8 +480,7 @@ export class GoalService extends TypertRemoteService { /** Read the current durable projection maintained by the registry. */ private state(session: Session): GoalProjection | null { - const state = this.ctx.sessionProjections.stateOf(session, 'goal') - if (state === undefined) throw new Error('goal projection is not registered') + const state = this.projection.stateOf(session) if (state.failure !== null) throw new Error(state.failure) return state.current } @@ -497,9 +502,7 @@ export class GoalService extends TypertRemoteService { const runtime = this.runtimeState(session) if (runtime.activation === activation) return runtime.activation = activation - const state = this.ctx.sessionProjections.stateOf(session, 'goal') - /* v8 ignore next -- static inject requires the projection registry before this service activates. */ - if (state === undefined) return + const state = this.projection.stateOf(session) if (state.failure !== null) return const goal = this.view(state.current, runtime) this.ctx.emit('goal/activation-changed', { diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index fe9c2d0030..462a0f59fa 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,3 +1,4 @@ +import { Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' @@ -174,8 +175,11 @@ describe('GoalService creation and replay', () => { await ctx.plugin(SessionProjectionRegistry) const stub = stubAgent('goal-direct-construction') await ctx.agents.register(stub.agent) - const goals = new GoalService(ctx) - await new Promise(resolve => setImmediate(resolve)) + class DirectGoalService extends GoalService { + initialize(): Promise { return this[Service.init]() } + } + const goals = new DirectGoalService(ctx) + await goals.initialize() expect(goals.create(stub.agent, { objective: 'direct' })).toMatchObject({ objective: 'direct', maxGoalRounds: 256, }) @@ -277,8 +281,8 @@ describe('GoalService creation and replay', () => { await fiber.dispose() expect(ctx.get('goals')).toBeUndefined() - expect(ctx.sessionProjections.stateOf(stub.session, 'goal')).toBeUndefined() - expect(() => first.get(stub.agent)).toThrow('goal projection is not registered') + expect(ctx.sessionProjections.checkpoint(stub.session).goal?.val).toBeUndefined() + expect(first.get(stub.agent)).toMatchObject({ id: goal.id }) await ctx.plugin(GoalService) expect(ctx.goals).not.toBe(first) diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 227da38ad8..64071e4038 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -235,9 +235,10 @@ describe('goal projection unit', () => { const bench = await harness(true) bench.ctx.goals.create(bench.agent, { objective: 'poisoned replay' }) const failure = 'goal replay failed at session event 0: invalid restored goal stream' - const state = bench.ctx.sessionProjections.stateOf(bench.session, 'goal') + const handle = await bench.ctx.sessionProjections.register(goalProjectionDefinition) + const state = handle.stateOf(bench.session) expect(state).toBeDefined() - Object.assign(state!, { failure }) + Object.assign(state, { failure }) await expect(agentEvents(bench.ctx, bench.agent).serial('agent/created', { source: 'resume' })) .resolves.toBeUndefined() diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 492ae8374e..1d97f69a1b 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/README.i18n.yaml @@ -30,8 +30,8 @@ en: 4e9fdb5a89f4baa3 zh: 50d182467ae1f3a7 /deepseek-ai-dsh-tool-goal/understand-the-implementation/design: - en: a30f36299c8107f7 - zh: 65e6e1e19aeb5281 + en: 905e5e105efdf7a8 + zh: 3704176a160aabb1 /deepseek-ai-dsh-tool-goal/understand-the-implementation/source-map: en: 4d5c648787409868 zh: b4938bfe660f18aa diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 158446e156..9ab458268d 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -69,6 +69,7 @@ This section explains how the tools enforce authority and render output; the obs ### Design - **Authority at execution.** Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. +- **Current-turn input projection.** A host-only projection retains human-input presence and admitted goal-round identifiers, resets at each turn boundary, and reconstructs these facts from committed events when registered. - **Host attestation of human input.** `{ kind: 'user' }` is assigned by `Agent.followup()` and `steer()` when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. - **System-prompt guidance with the configured threshold.** The package registers one `tool:goal` system-prompt section whose fixed text interpolates `blockedAfterConsecutiveRounds`; the same value is the hard lower bound enforced at execution. - **Wrap-up context for terminal rounds.** A successful autonomous `complete` or `blocked` defers a closing `` or `` instruction so the model addresses the user once before the turn ends; direct-human mutations never defer this context. diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 1ec0a8e1f8..9e868061c2 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -69,6 +69,7 @@ kind: "package-reference" ### 设计 - **执行时权限。** 每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 +- **当前轮输入投影。** 仅供 Host 使用的投影保存是否有人类输入及已接纳的目标轮次标识,在每个轮次边界重置,并在注册时从已提交事件重建这些事实。 - **人类输入的宿主证明。** `Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配 `{ kind: 'user' }`,因此插件、调度器与其他非人类生产方必须传入自己的 source,不能继承人类权限。 - **带配置阈值的系统提示词指引。** 本包注册一个 `tool:goal` 系统提示词章节,其固定文本插入 `blockedAfterConsecutiveRounds`;同一数值就是执行时强制执行的硬下限。 - **终局 Round 的结束上下文。** 成功的自主 `complete` 或 `blocked` 会延后一条 `` 或 `` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。 diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index e5c0e9d8e7..65a9e48f4b 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -39,7 +39,8 @@ "@deepseek-ai/dsh-session-projection": "workspace:*" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:~" + "@deepseek-ai/schemastery": "workspace:~", + "zod": "4.4.3" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index eadc0bd8f9..6214005230 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -2,17 +2,57 @@ import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { GoalView } from '@deepseek-ai/dsh-goal' +import { GoalId, type GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' -import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { z as zod } from 'zod' -/** The calling agent plus the immutable event cut and open-turn start seq used for authority checks. */ +const turnAuthoritySchema = zod.object({ + open: zod.boolean(), + directHuman: zod.boolean(), + goalRounds: zod.array(zod.object({ + goalId: zod.string().transform(GoalId), + revision: zod.number().int().positive(), + round: zod.number().int().positive(), + })), +}) + +/** Accepted input authority for the currently open turn, reset at both turn boundaries. */ +type TurnAuthority = zod.infer + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Input sources admitted in the current turn for goal-tool authorization. */ + goalToolAuthority: TurnAuthority + } +} + +const turnAuthorityProjection = { + key: 'goalToolAuthority', + stateVersion: 1, + stateSchema: turnAuthoritySchema, + init: (): TurnAuthority => ({ open: false, directHuman: false, goalRounds: [] }), + apply: (state, event) => { + if (event.type === 'turn/start' || event.type === 'turn/end') { + return { open: event.type === 'turn/start', directHuman: false, goalRounds: [] } + } + if (!state.open || event.type !== 'user/message') return state + const source = event.data.source + if (source.kind === 'user') return state.directHuman ? state : { ...state, directHuman: true } + if (source.kind === 'goal') { + const { goalId, revision, round } = source + return { ...state, goalRounds: [...state.goalRounds, { goalId, revision, round }] } + } + // Other message producers do not grant goal-tool authority. + return state + }, +} satisfies ProjectionDefinition<'goalToolAuthority', TurnAuthority> + +/** The authenticated caller and immutable authority facts captured for one tool call. */ export interface GoalToolExecution { readonly agent: Agent - readonly events: readonly SessionEvent[] - readonly openTurnStartSeq: SessionSeq + readonly authority: TurnAuthority } /** Hard authority granted to one state-changing call. */ @@ -25,51 +65,44 @@ function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { throw new HarnessError(message, code) } -/** Resolve the immutable event cut and open-turn boundary without copying the turn suffix. */ -function openTurnEvents( - ctx: Context, - agent: Agent, -): Pick { - // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. - const events = agent.session.snapshotEvents() - const boundary = ctx.sessionProjections.stateOf(agent.session, 'turnBoundary') - if (boundary === undefined || boundary.openTurnStartSeq === null) { - reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') - } - return { events, openTurnStartSeq: boundary.openTurnStartSeq } -} +/** Resolves goal-tool authority from the live driver and its committed turn. */ +export class GoalToolExecutionResolver { + private authority!: ProjectionHandle -/** - * Resolve and authenticate the calling agent and its driver boundary. - * @param ctx - Context carrying the live agent registry. - * @param exec - Tool execution metadata supplied by the registry. - * @returns The authenticated agent, immutable event cut, and open-turn boundary. - */ -export function goalToolExecution(ctx: Context, exec: ToolRunContext): GoalToolExecution { - const agent = exec.agent - if (agent === undefined) { - return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') - } - if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running' - || ctx.agents.currentInitiator() !== agent) { - return reject( - 'goal tools require the exact live calling agent inside its active driver', - 'GOAL_TOOL_DRIVER_REQUIRED', - ) - } - return { agent, ...openTurnEvents(ctx, agent) } -} + /** @param ctx - Context carrying the live agent registry and session projections. */ + constructor(private readonly ctx: Context) {} -/** Whether the captured open turn contains an event accepted by `predicate`. */ -function someOpenTurnEvent( - execution: GoalToolExecution, - predicate: (event: SessionEvent) => boolean, -): boolean { - for (let seq = execution.openTurnStartSeq + 1; seq < execution.events.length; seq += 1) { - const event = execution.events[seq] - if (event !== undefined && predicate(event)) return true + /** + * Acquire projection readers before exposing dependent operations. + * @returns when all required registrations are ready. + */ + async initialize(): Promise { + this.authority = await this.ctx.sessionProjections.register(turnAuthorityProjection) + } + + /** + * Authenticate the caller and capture its current turn's accepted input sources. + * @param exec - Tool execution carrying the live calling agent. + * @returns the caller and projected authority facts. + */ + resolve(exec: ToolRunContext): GoalToolExecution { + const agent = exec.agent + if (agent === undefined) { + return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') + } + if (this.ctx.agents.get(agent.id) !== agent || agent.status !== 'running' + || this.ctx.agents.currentInitiator() !== agent) { + return reject( + 'goal tools require the exact live calling agent inside its active driver', + 'GOAL_TOOL_DRIVER_REQUIRED', + ) + } + const authority = this.authority.stateOf(agent.session) + if (!authority.open) { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') + } + return { agent, authority } } - return false } /** @@ -79,17 +112,14 @@ function someOpenTurnEvent( */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { if (!ctx.agents.roots().includes(execution.agent)) return false - return someOpenTurnEvent(execution, event => - event.type === 'user/message' && event.data.source.kind === 'user') + return execution.authority.directHuman } /** Whether this turn is the current goal's exact admitted round. */ function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean { - return someOpenTurnEvent(execution, event => event.type === 'user/message' - && event.data.source.kind === 'goal' - && event.data.source.goalId === goal.id - && event.data.source.revision === goal.revision - && event.data.source.round === goal.roundsStarted) + return execution.authority.goalRounds.some(round => round.goalId === goal.id + && round.revision === goal.revision + && round.round === goal.roundsStarted) } /** diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index 9f47f1a77d..a364dec967 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -20,12 +20,13 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import { completionAuthority, - goalToolExecution, + GoalToolExecutionResolver, requireDirectHuman, } from './authority.ts' import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' + export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections'] /** Model policy and hard lower bounds for goal-state updates. */ @@ -184,7 +185,9 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen } /** Register the three Codex-shaped goal tools and their shared policy section. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { + const executionResolver = new GoalToolExecutionResolver(ctx) + await executionResolver.initialize() const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:goal', @@ -198,7 +201,7 @@ export function apply(ctx: Context, config: Config): void { parameters: {}, output: GOAL_OUTPUT, execute(_args, exec) { - const execution = goalToolExecution(ctx, exec) + const execution = executionResolver.resolve(exec) return Promise.resolve(goalValue(ctx.goals.get(execution.agent))) }, presentCall: () => present('Read current goal', 'read'), @@ -220,7 +223,7 @@ export function apply(ctx: Context, config: Config): void { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = goalToolExecution(ctx, exec) + const execution = executionResolver.resolve(exec) requireDirectHuman(ctx, execution) const goal = ctx.goals.create(execution.agent, { objective: args.objective, @@ -253,7 +256,7 @@ export function apply(ctx: Context, config: Config): void { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = goalToolExecution(ctx, exec) + const execution = executionResolver.resolve(exec) const ref = goalRef(args.goal_id, args.revision) const replacements = { ...hasText(args.objective) ? { objective: args.objective } : {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index ecc7822bce..0bb6a91a48 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -3,7 +3,6 @@ import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus, Inbox } from '@deepseek-ai/dsh-agent' -import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent-loop' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef } from '@deepseek-ai/dsh-goal' import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm' @@ -19,6 +18,7 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import * as GoalTools from '@deepseek-ai/dsh-tool-goal' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -104,9 +104,8 @@ async function harness(config: toolGoal.Config = {}) { await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(GoalService) - const fiber = await ctx.plugin(toolGoal, config) + const fiber = await ctx.plugin(GoalTools, config) const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx) await ctx.agents.register(root.agent) return { ctx, fiber, root } @@ -202,25 +201,27 @@ describe('goal tool registration and presentation', () => { it('fails invalid direct config before registering anything', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - expect(() => { - toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 }) - }).toThrow( + await expect(GoalTools.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })).rejects.toThrow( 'blockedAfterConsecutiveRounds must be a positive safe integer', ) expect(ctx.tools.get('get_goal')).toBeUndefined() }) - it('resolves the direct-apply default before registration', async () => { + it('resolves the direct-constructor default before registration', async () => { const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SystemPrompt) await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - toolGoal.apply(ctx, {}) + await GoalTools.apply(ctx, {}) const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') expect(section?.text).toContain('at least 3 consecutive goal rounds') }) @@ -331,7 +332,27 @@ describe('goal tool execution authority', () => { expect(malformed.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') }) - it('accepts direct human steering in a goal-sourced root turn', async () => { + it('does not carry human authority into a later non-human turn', async () => { + const { ctx, root } = await harness() + const turn = openTurn(root, { kind: 'user' }) + await execute(ctx, 'get_goal', {}, root.agent) + closeTurn(root, turn) + openTurn(root, { kind: 'test' }) + const result = await execute(ctx, 'create_goal', { objective: 'unauthorized' }, root.agent) + expect(result.error?.info?.code).toBe('GOAL_TOOL_AUTHORITY_REQUIRED') + expect(ctx.goals.get(root.agent)).toBeUndefined() + }) + + it('reconstructs current-turn authority when the tools mount after input was committed', async () => { + const { ctx, root, fiber } = await harness() + await fiber.dispose() + openTurn(root, { kind: 'user' }) + await ctx.plugin(GoalTools, {}) + const result = await execute(ctx, 'create_goal', { objective: 'accepted before tool load' }, root.agent) + expect(resultGoal(result)).toMatchObject({ objective: 'accepted before tool load' }) + }) + + it.each([1, 2])('accepts %i direct human steering messages in a goal-sourced root turn', async (messageCount) => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'steer me' }) @@ -339,10 +360,12 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1, }) - root.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'pause now' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) + for (let index = 0; index < messageCount; index++) { + root.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'pause now' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + } const paused = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 949ada7106..46968ede01 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/README.i18n.yaml @@ -39,14 +39,14 @@ en: b01849a5fa73aae9 zh: cbd7fcf50117502b /deepseek-ai-dsh-permission-presets/understand-the-implementation/read-side-and-custom: - en: c9ca792038f44654 - zh: 83cc2641c800c114 + en: 76deeb5fbcdf7525 + zh: e96ad7e107947c23 /deepseek-ai-dsh-permission-presets/understand-the-implementation/session-pinning-and-blank-reuse: en: 893cea88bce22903 zh: 2431e19ced670511 /deepseek-ai-dsh-permission-presets/understand-the-implementation/catalog-projection-and-optional-command: - en: 2e79bec74274b054 - zh: 256526ab1b4b55e9 + en: 341f80202edd66a3 + zh: bd8ebb9d011b547f /deepseek-ai-dsh-permission-presets/further-exploration: en: ca27c3e438cffdc5 zh: ae5d25ae55e5d503 diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index e21a41a053..d1179a9926 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -86,7 +86,7 @@ The observable behavior is covered in [Use this package](#use-this-package); thi ### Read side and `custom` -`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties, and a recorded Auto selection also matches the `never` approval policy; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. A missing projection key fails explicitly. +`current(session)` reads the required `permissions` projection, whose unit folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). The host state also retains whether `session/end-seed` has occurred, so session pinning distinguishes an explicitly empty restored seed from a genuinely fresh session without rescanning the log. A still-matching last selection wins shared-bundle ties, and a recorded Auto selection also matches the `never` approval policy; otherwise the first configured match wins; otherwise the derived `CUSTOM_PRESET` is returned. The service acquires its state reader during activation. `optionOf(name)` returns a configured entry, the live Auto entry, or the display-only `custom` entry. It throws only when the name matches none of those; a withdrawn Auto entry is unavailable. @@ -96,7 +96,7 @@ Mounting pins every live and future session: a genuinely fresh session gains the ### Catalog, projection, and optional command -The service requires `ctx.sessionProjections` and registers a `permissions` projection containing only `currentValue`. Its process-level `catalog()` Remote returns one complete selectable snapshot. Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification, so clients subscribe first and then re-read the catalog without appending a Session event, publishing a Session projection frame, or changing Session sequence. The `/permission` command registers only when a `ctx.commands` registry is composed. Calls that derive the current preset or pin an initial selection fail explicitly when the projection key is absent. +The service requires `ctx.sessionProjections` and awaits registration of `permissions`; its client view contains only `currentValue`. Its process-level `catalog()` Remote returns one complete selectable snapshot. Registering or removing Auto emits the payload-free `permission-presets/catalog-changed` notification, so clients subscribe first and then re-read the catalog without appending a Session event, publishing a Session projection frame, or changing Session sequence. The `/permission` command registers only when a `ctx.commands` registry is composed. Calls that derive the current preset or pin an initial selection use the retained projection handle. diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 0477b4197e..6afa0a0163 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -86,7 +86,7 @@ kind: "package-reference" ### 读取侧与 `custom` -`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出,已记录的 Auto 选择在 `never` 审批策略下也匹配;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。投影 key 缺失时会显式失败。 +`current(session)` 读取必需的 `permissions` 投影;该单元在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。host 状态还会保留 `session/end-seed` 是否已经出现,使会话固定无需重扫日志即可区分显式为空的恢复 seed 与真正的新会话。仍匹配的最近选择在共享捆绑时胜出,已记录的 Auto 选择在 `never` 审批策略下也匹配;否则配置表中的第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。该服务在激活期间取得状态读取句柄。 `optionOf(name)` 返回配置条目、存活的 Auto 条目或仅供显示的 `custom` 条目。只有名称不匹配这三者时才抛错;已撤回的 Auto 条目不可用。 @@ -96,7 +96,7 @@ kind: "package-reference" ### 目录、投影与可选命令 -该服务要求 `ctx.sessionProjections`,并注册一个只含 `currentValue` 的 `permissions` 投影。进程级 `catalog()` Remote 返回一份完整的可选快照。注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知,因此客户端先订阅再重新读取目录;该过程不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。派生当前预设或固定初始选择的调用会在投影 key 缺失时显式失败。 +该服务要求 `ctx.sessionProjections`,并等待 `permissions` 注册完成;其客户端视图只含 `currentValue`。进程级 `catalog()` Remote 返回一份完整的可选快照。注册或移除 Auto 会发出无 payload 的 `permission-presets/catalog-changed` 通知,因此客户端先订阅再重新读取目录;该过程不会追加 Session 事件、发布 Session 投影帧或改变 Session 序列。`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。派生当前预设或固定初始选择的调用使用保留的投影句柄。 diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index 8135d74282..a3df0f8d74 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -15,7 +15,7 @@ import type {} from '@deepseek-ai/dsh-settings' import type { Volatile } from '@deepseek-ai/cordis' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { CommandDefinitionId } from '@deepseek-ai/dsh-commands/brand' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' @@ -29,7 +29,7 @@ import type {} from '@deepseek-ai/dsh-shell' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' import { APPROVAL_POLICIES, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' // Type-only: resolves the required projection service and optional settings/command children. -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-commands' import type { PermissionCatalog, PermissionSelection, PresetOption } from './types.ts' @@ -204,6 +204,8 @@ export class PermissionPresetService extends TypertRemoteService { private autoAdmit: (() => void) | undefined private defaultSettings: () => PermissionSettings + private projection!: ProjectionHandle + constructor(ctx: Context, config: Config) { super(ctx, 'permissionPresets') @@ -230,11 +232,14 @@ export class PermissionPresetService extends TypertRemoteService { if (!Object.hasOwn(this.presets, defaultPreset)) throw new Error(`permission: unknown default preset "${defaultPreset}"`) return { defaultPreset } } + } + protected async [Service.init](): Promise { + const ctx = this.ctx const selectionSchema = zod.object({ currentValue: zod.string().min(1), }) as zod.ZodType - ctx.sessionProjections.register({ + this.projection = await ctx.sessionProjections.register({ key: 'permissions', stateVersion: 2, stateSchema: permissionStateSchema, @@ -326,8 +331,7 @@ export class PermissionPresetService extends TypertRemoteService { } private permissionState(session: Session): PermissionProjectionState { - const state = this.ctx.sessionProjections.stateOf(session, 'permissions') - if (state === undefined) throw new Error('permission: permissions session projection is not registered') + const state = this.projection.stateOf(session) return state } diff --git a/packages/interaction/permission-presets/tests/permission-presets.spec.ts b/packages/interaction/permission-presets/tests/permission-presets.spec.ts index 10de7a4f31..6641d2c259 100644 --- a/packages/interaction/permission-presets/tests/permission-presets.spec.ts +++ b/packages/interaction/permission-presets/tests/permission-presets.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, @@ -65,17 +65,17 @@ describe('permission preset fold', () => { it('folds the latest preset selection and steps over unrelated events', async () => { const ctx = await mounted() const session = freshSession('sess-fold') - const presetOf = () => ctx.sessionProjections.stateOf(session, 'permissions')?.preset ?? null - expect(presetOf()).toBeNull() + const presetOf = () => ctx.sessionProjections.checkpoint(session).permissions?.val + expect(presetOf()).toMatchObject({ preset: null }) session.append('permission/preset', { preset: 'danger-full-access' }) session.append('permission/preset', { preset: 'workspace-write' }) - expect(presetOf()).toBe('workspace-write') + expect(presetOf()).toMatchObject({ preset: 'workspace-write' }) // The knob fold steps over non-preset events to the latest selection. session.append('sandbox/mode', { mode: 'read-only' }) - expect(presetOf()).toBe('workspace-write') + expect(presetOf()).toMatchObject({ preset: 'workspace-write' }) const seeded = Session.create(SessionId('sess-fold-seeded'), []) - expect(ctx.sessionProjections.stateOf(seeded, 'permissions')?.seeded).toBe(true) + expect(ctx.sessionProjections.checkpoint(seeded).permissions?.val).toMatchObject({ seeded: true }) }) }) @@ -85,13 +85,6 @@ describe('PermissionPresetService', () => { expect(ctx.get('permissionPresets')).toBeUndefined() }) - it('fails when the permissions projection key is absent', async () => { - const ctx = await mounted() - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - expect(() => ctx.permissionPresets.current(freshSession('missing-permission-projection-key'))) - .toThrow('permission: permissions session projection is not registered') - }) - it('advertises the preset table in declaration order and resolves bundles', async () => { const ctx = await mounted() expect(ctx.permissionPresets.names).toEqual(['workspace-write', 'danger-full-access']) diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 06cb67b8b6..1a93231646 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -7,16 +7,16 @@ * @module @deepseek-ai/dsh-user-questions */ -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError, type MessageId, type ToolCallId, type UserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import type { Agent } from '@deepseek-ai/dsh-agent' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import z from '@deepseek-ai/schemastery' -import { userQuestionProjectionDefinition } from './projection.ts' +import { userQuestionProjectionDefinition, type UserQuestionProjectionState } from './projection.ts' import { TimedQuestionWait } from './timed-wait.ts' declare module '@deepseek-ai/cordis' { @@ -78,14 +78,12 @@ type QueuedReply = { messageId: MessageId; claimedTurn?: number } /** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */ export class UserQuestionService extends TypertRemoteService { static Config = z.object({}) + private projection: ProjectionHandle | undefined private readonly waits = new Map>() private readonly queuedReplies = new WeakMap>() constructor(ctx: Context) { super(ctx, 'userQuestions') - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(userQuestionProjectionDefinition) - }) ctx.effect(() => () => { for (const calls of this.waits.values()) { for (const wait of calls.values()) wait.close(abortedQuestion()) @@ -128,6 +126,14 @@ export class UserQuestionService extends TypertRemoteService { calls.delete(callId) } + protected async [Service.init](): Promise { + const binding = this.ctx.inject(['sessionProjections'], async (ctx) => { + this.projection = await ctx.sessionProjections.register(userQuestionProjectionDefinition) + return () => { this.projection = undefined } + }) + if (this.ctx.get('sessionProjections') !== undefined) await binding + } + private assertLiveRoot(agent: Agent): void { const agents = this.ctx.get('agents') if (agents === undefined || agents.get(agent.id) !== agent) { @@ -144,8 +150,7 @@ export class UserQuestionService extends TypertRemoteService { } private continued(agent: Agent): readonly PendingUserQuestion[] { - const state = this.ctx.get('sessionProjections')?.stateOf(agent.session, 'userQuestions') - return (state?.questions.active ?? []).filter(question => question.state === 'continued') + return this.projection?.stateOf(agent.session).questions.active.filter(question => question.state === 'continued') ?? [] } /** diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index f0f744259a..0cbb2a5031 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -895,7 +895,7 @@ describe('late replies', () => { error: { name: 'SessionFormatError', code: TOOL_OUTCOME_UNKNOWN }, }, { surfaceOp: 'append' }) - expect(ctx.sessionProjections.stateOf(agent.session, 'userQuestions')?.questions).toEqual({ active: [], settled: [] }) + expect(ctx.sessionProjections.checkpoint(agent.session).userQuestions?.val).toMatchObject({ questions: { active: [], settled: [] } }) expect(ctx.userQuestions.answer(agent, timedCallId, { answers: [{ id: 'scope', selected: [] }] })).toBe(false) expect(agent.steer).not.toHaveBeenCalled() await ctx.fiber.dispose() diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 89ffc14c40..60cca5ba2f 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -19,6 +19,7 @@ export type { LlmRetryEventData, LlmRetryStartedEventData } from './types.ts' export { RetryId } from './brand.ts' export const name = 'llm-retry' + export const inject = ['agents', 'sessionProjections'] /** This policy executor has no config; providers own `retryPolicy`. */ @@ -95,12 +96,6 @@ function cancellableDelay(delayMs: number, signal: AbortSignal): Promise { validateConfig(config) - ctx.sessionProjections.register({ + const projection = await ctx.sessionProjections.register({ key: 'llmRetry', stateVersion: 1, stateSchema: llmRetryStateSchema, @@ -217,7 +219,7 @@ export function apply(ctx: Context, config: Config = {}, internals: RetryInterna } const policyKey = retryPolicyKey(policy) - const retryState = ctx.sessionProjections.stateOf(agent.session, 'llmRetry') as LlmRetryState + const retryState = projection.stateOf(agent.session) const previous = retryState[retryStateKey(provider, policyKey)] const previousRetry = previous?.retry ?? 0 if (policy.mode === 'normal' && previousRetry >= policy.maxRetries) return next() diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 3adb88de05..43ead12c94 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -20,6 +20,7 @@ import ToolRuntime, { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import type { Config as retryConfig, RetryInternals as retryRetryInternals } from '../src/index.ts' import * as retry from '../src/index.ts' type ScriptEntry = Error | Iterable | AsyncIterable @@ -103,7 +104,7 @@ async function harness( adapter: ScriptedAdapter, policies: Readonly> = { mock: normalConfig() }, beforeRetry?: (ctx: Context) => void, - internals: retry.RetryInternals = {}, + internals: retryRetryInternals = {}, ): Promise<{ ctx: Context; retryFiber: Fiber; disposeAdapter: () => void }> { const ctx = new Context() await ctx.plugin(LlmRuntime) @@ -114,8 +115,8 @@ async function harness( await ctx.plugin(AgentRegistry) beforeRetry?.(ctx) adapter.configureRetryPolicies(policies) - const retryFiber = await ctx.plugin(Object.assign((inner: Context) => { - retry.apply(inner, {}, internals) + const retryFiber = await ctx.plugin(Object.assign(async (inner: Context) => { + await retry.apply(inner, {}, internals) }, { inject: retry.inject })) await ctx.plugin(AgentLoop, { agents: [] }) const disposeAdapter = ctx.llm.registerAdapter(['mock', 'other'], adapter) @@ -1047,23 +1048,21 @@ describe('provider-routed retry policy', () => { expect(vi.getTimerCount()).toBe(0) }) - it('rejects retry policy configured on the executor instead of a provider', () => { - expectTypeOf<{}>().toExtend() - expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() + it('rejects retry policy configured on the executor instead of a provider', async () => { + expectTypeOf<{}>().toExtend() + expectTypeOf<{ retryPolicy: { mode: 'always' } }>().not.toExtend() const ctx = new Context() - // `apply` registers its projection unit first; the registry is a required - // injection, so the direct-apply path must carry it too. new SessionProjectionRegistry(ctx) - expect(() => { - retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retry.Config) - }).toThrow(/retryPolicy belongs under each provider/) + await expect(async () => { + await retry.apply(ctx, { retryPolicy: { mode: 'always' } } as unknown as retryConfig) + }).rejects.toThrow(/retryPolicy belongs under each provider/) }) - it('rejects unknown executor config', () => { + it('rejects unknown executor config', async () => { const ctx = new Context() new SessionProjectionRegistry(ctx) - expect(() => { - retry.apply(ctx, { retryPolciy: {} } as unknown as retry.Config) - }).toThrow(/unknown key "retryPolciy"/) + await expect(async () => { + await retry.apply(ctx, { retryPolciy: {} } as unknown as retryConfig) + }).rejects.toThrow(/unknown key "retryPolciy"/) }) }) diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index f721d26219..7c1ac24432 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -38,6 +38,7 @@ import type { MeterSurfaceNode } from './surface-fold.ts' import { priceSurface } from './route-pricing.ts' export type * from './types.ts' +export { contextPressureProjectionDefinition } from './usage-projection.ts' // Module-edge re-export: forces the emitted index.d.ts to import the // projection-unit modules, so their SessionProjectionStateMap augmentations load // in aggregate programs that only import the package root. @@ -110,10 +111,13 @@ export class TokenMeter extends Service { constructor(ctx: Context, config: TokenMeterConfig = {}) { super(ctx, 'tokenMeter') validateConfigKeys(config) + } - ctx.sessionProjections.register(tokenUsageProjectionDefinition) - ctx.sessionProjections.register(contextPressureProjectionDefinition) - ctx.sessionProjections.register(contextBreakdownProjectionDefinition) + protected async [Service.init](): Promise { + const ctx = this.ctx + await ctx.sessionProjections.register(tokenUsageProjectionDefinition) + await ctx.sessionProjections.register(contextPressureProjectionDefinition) + await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) // Readers catch up independently, while eager observation bounds ordinary // read latency without creating state for sessions no consumer has read. diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index d6fee3cb04..8eba5db571 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -370,8 +370,9 @@ describe('contextBreakdown session projection', () => { const first = appendUser(session, 'the first of many messages') for (let index = 0; index < 24; index += 1) appendUser(session, `message number ${index} with some text`) const last = appendUser(session, 'the last message before compaction') + const projection = await ctx.sessionProjections.register(contextBreakdownProjectionDefinition) const state = () => { - const current = ctx.sessionProjections.stateOf(session, 'contextBreakdown') + const current = projection.stateOf(session) if (current === undefined) throw new Error('registered context breakdown has no state') return current } diff --git a/packages/llm/token-meter/tests/route-pricing.spec.ts b/packages/llm/token-meter/tests/route-pricing.spec.ts index bf11f1b039..08959f288e 100644 --- a/packages/llm/token-meter/tests/route-pricing.spec.ts +++ b/packages/llm/token-meter/tests/route-pricing.spec.ts @@ -1,5 +1,5 @@ import { imageOffloadProjection } from '@deepseek-ai/dsh-compaction-image-offload/projection' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, onTestFinished } from 'vitest' import { Context } from '@deepseek-ai/cordis' import { LlmRuntime, LlmAdapter, createMessage, createToolResultMessage, createUserMessage, projectFilesToText, ToolCallId, @@ -76,6 +76,7 @@ interface Harness { async function harness(pricing: (model: string) => LlmImageRequestPricing | undefined): Promise { const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) new SessionProjectionRegistry(ctx) ctx.provide('attachments', { fileHostPath: (ref: FileAttachmentRef) => `/host/${ref.name}`, @@ -85,7 +86,8 @@ async function harness(pricing: (model: string) => LlmImageRequestPricing | unde } as never) const llm = new LlmRuntime(ctx) llm.registerAdapter(['mock'], new PricingAdapter(pricing)) - const meter = new TokenMeter(ctx) + await ctx.plugin(TokenMeter) + const meter = ctx.tokenMeter return { ctx, meter, session: Session.create(SessionId('route-priced'), undefined, undefined, undefined, [imageOffloadProjection]) } } diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index f207905573..818fa45921 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -106,8 +106,7 @@ function appendSuccessfulCall( function meter(config: TokenMeterConfig = {}): TokenMeter { const ctx = new Context() - // The registry is a required injection of the service (its three projection - // units register in the constructor); mount it synchronously. + // Pure measurement tests use the service without mounting its projection contributions. new SessionProjectionRegistry(ctx) return new TokenMeter(ctx, config) } @@ -137,8 +136,13 @@ describe('TokenMeter configuration and registration', () => { await ctx.plugin(SessionProjectionRegistry) const fiber = await ctx.plugin(TokenMeter) expect(ctx.get('tokenMeter')).toBeInstanceOf(TokenMeter) + const session = ctx.sessions.create() + expect(Object.keys(ctx.sessionProjections.checkpoint(session)).sort()) + .toEqual(['contextBreakdown', 'contextPressure', 'tokenUsage']) await fiber.dispose() expect(ctx.get('tokenMeter')).toBeUndefined() + expect(ctx.sessionProjections.checkpoint(session)).toEqual({}) + await ctx.fiber.dispose() }) }) diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml index fcd9bc5201..5123d81ae9 100644 --- a/packages/plan/plan-mode/README.i18n.yaml +++ b/packages/plan/plan-mode/README.i18n.yaml @@ -39,8 +39,8 @@ en: 0acc88b042a99380 zh: e4cf66b77f2fc7ae /deepseek-ai-dsh-plan-mode/understand-the-implementation/durable-state-and-step-boundary-appends: - en: ff946ab59a7b4a0f - zh: b1c79937770f57ad + en: 24166a02b6e81553 + zh: 24313c2153c9a6a6 /deepseek-ai-dsh-plan-mode/understand-the-implementation/the-plan-command: en: a7bf35c2a9da52bc zh: dea9f4b3a41f1a6d @@ -48,8 +48,8 @@ en: 56798d0f45fb5775 zh: 282e4a522ce6dae0 /deepseek-ai-dsh-plan-mode/understand-the-implementation/session-projection-unit: - en: 74cd6bdf50af51f5 - zh: 67609cf7202ececb + en: 42189cbdbd1ef615 + zh: 711142761cc29491 /deepseek-ai-dsh-plan-mode/understand-the-implementation/source-map: en: fa97e858495a8105 zh: 8be1fbd7e420ff8a diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md index 35084ddb60..dce61a5e38 100644 --- a/packages/plan/plan-mode/README.md +++ b/packages/plan/plan-mode/README.md @@ -82,7 +82,7 @@ Plan mode is a product package, not a capability seam: there is no swappable bac ### Durable state and step-boundary appends -The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts) and read the registered `plan` projection; the first dependent access fails explicitly when the registry or key is absent. +The package persists one log-only whole-value event, `plan/mode`, and the last logged value is the state. A mode change appends immediately when no turn is open; during an open turn it stays pending until the next accepted in-turn pre-step — the only append point while an agent runs — and an append failure cannot block the turn. The `set`/`get` service methods and their exact return states live in [`src/index.ts`](src/index.ts) and read the retained `plan` projection handle, acquired during activation. ### The `/plan` command @@ -94,7 +94,7 @@ The command child activates only when a commands service is composed. It maps ba ### Session projection unit -When `ctx.sessionProjections` is composed, the package registers the `plan` unit through optional injection. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. Plan-mode reads require this unit and the `turnBoundary` unit, and fail explicitly if the registry or either key is absent. +The package requires `ctx.sessionProjections` and awaits registration of the `plan` and `turnBoundary` units during activation. The unit turns logged `/plan` command runs into a candidate target, commits the logged state on `plan/mode`, and derives `{ active, pending }` for `view`, where `pending` is true only while an unsettled or successful selection differs from the logged state — a pure replay quantity recoverable from the log alone. The key merges into `SessionProjectionMap` from [`src/types.ts`](src/types.ts); the framework drives the unit, and unloading the plugin fiber unregisters the key. State reads use the retained handles and remain available after unregistration. ### Source map diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md index 9d7023df69..b325573434 100644 --- a/packages/plan/plan-mode/README.zh.md +++ b/packages/plan/plan-mode/README.zh.md @@ -82,7 +82,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### 持久状态与步骤边界追加 -本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts),并读取已注册的 `plan` 投影;注册表或 key 缺失时,第一次依赖它们的访问会显式失败。 +本包持久化一条仅记日志、整值替换的事件 `plan/mode`,最后一条已记录值即为状态。没有轮次开启时,模式变更会立即追加;轮次开启期间,它保持待生效,直到下一个被接受的轮内 pre-step——agent 运行时唯一的追加点——且追加失败不能阻塞轮次。`set`/`get` 服务方法及其确切返回状态见 [`src/index.ts`](src/index.ts),并通过激活期间取得并保留的 `plan` 投影句柄读取状态。 ### `/plan` 命令 @@ -94,7 +94,7 @@ agent 完成计划后,会以 markdown 形式、从标题开头书写计划并 ### 会话投影单元 -组合了 `ctx.sessionProjections` 时,本包通过可选注入注册 `plan` 单元。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。plan-mode 读取要求该单元与 `turnBoundary` 单元存在;注册表或任一 key 缺失时都会显式失败。 +本包要求 `ctx.sessionProjections`,并在激活期间等待 `plan` 与 `turnBoundary` 单元注册完成。该单元把已记录的 `/plan` 命令运行转为候选目标,在 `plan/mode` 上提交已记录状态,并为 `view` 推导 `{ active, pending }`,其中 `pending` 仅在未结算或已成功的选择与已记录状态不同时为 true——这是仅凭日志即可恢复的纯回放量。key 由 [`src/types.ts`](src/types.ts) 的声明合并加入 `SessionProjectionMap`;框架负责驱动该单元,卸载插件 fiber 会注销该 key。状态读取使用保留的句柄,注销后仍然可用。 ### 源码地图 diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index a2385491ef..6f05c60d1d 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -25,6 +25,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import { z as zod } from 'zod' import type { ZodType } from 'zod' +import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextFormed } from '@deepseek-ai/dsh-llm' @@ -33,7 +34,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import { UserQuestionError, type AskUserQuestionAnswer } from '@deepseek-ai/dsh-user-questions' import type { CommandDefinitionId, CommandId } from '@deepseek-ai/dsh-commands' import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { PlanProjection, PlanUnitState } from './types.ts' declare module '@deepseek-ai/dsh-llm' { interface MessageSourceMap { @@ -177,6 +178,8 @@ export class PlanModeController extends Service { /** Validated deployment-owned guidance. */ private readonly section: string + private projection!: ProjectionHandle + private turnBoundary!: ProjectionHandle /** * Latest selection per session awaiting the next accepted in-turn pre-step. @@ -188,6 +191,12 @@ export class PlanModeController extends Service { constructor(ctx: Context, config: PlanModeConfig = { section: '' }) { super(ctx, 'planMode') this.section = resolveConfig(config).section + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + this.projection = await ctx.sessionProjections.register(planProjectionDefinition) + this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) let disposed = false // Pre-step is outside Session.append publication, so it can append the // log-only mode event inside an open turn without re-entering the session. @@ -223,7 +232,6 @@ export class PlanModeController extends Service { }, }) - ctx.sessionProjections.register(planProjectionDefinition) // The command child activates only when a command registry is composed. ctx.inject(['commands'], (commandCtx) => { @@ -377,8 +385,7 @@ export class PlanModeController extends Service { } private hasOpenTurn(session: Session): boolean { - const state = this.ctx.sessionProjections.stateOf(session, 'turnBoundary') - if (state === undefined) throw new Error('plan-mode requires the turnBoundary session projection') + const state = this.turnBoundary.stateOf(session) return state.openTurnStartSeq !== null } @@ -388,8 +395,7 @@ export class PlanModeController extends Service { /** Read the required plan projection state or fail at the first service access. */ private planState(session: Session): PlanUnitState { - const state = this.ctx.sessionProjections.stateOf(session, 'plan') - if (state === undefined) throw new Error('plan-mode requires the plan session projection') + const state = this.projection.stateOf(session) return state } diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 5c39a792e8..1068721786 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -56,8 +56,7 @@ function waitForIdle(ctx: Context, agent: Agent): Promise { /** Read the plan unit that backs the service in this full composition. */ function planActive(ctx: Context, agent: Agent): boolean { - const state = ctx.sessionProjections.stateOf(agent.session, 'plan') - if (state === undefined) throw new Error('plan projection is not registered') + const state = ctx.planMode.get(agent) return state.active } diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index 6024269b6d..2ba26b9a09 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -89,7 +89,7 @@ function foldPlanMode(events: readonly SessionEvent[], end = events.length): boo async function mountProjectionSeam(ctx: Context): Promise { await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) } async function setup(config: PlanModeConfig = PLAN_CONFIG): Promise { @@ -231,25 +231,6 @@ describe('ctx.planMode: get/set', () => { expect(ctx.get('planMode')).toBeUndefined() }) - it('fails when the required plan projection key is absent', async () => { - const ctx = await setup() - const agent = await agentWithSession(ctx, 'missing-plan-projection') - vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) - expect(() => ctx.planMode.get(agent)).toThrow('plan-mode requires the plan session projection') - }) - - it('registers plan state directly but requires turnBoundary state', async () => { - const ctx = new Context() - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRuntime) - const agent = await agentWithSession(ctx, 'missing-plan-projection-keys') - const planMode = new PlanModeController(ctx, PLAN_CONFIG) - await new Promise(resolve => setImmediate(resolve)) - expect(planMode.get(agent)).toEqual({ active: false }) - expect(() => planMode.set(agent, true)).toThrow('plan-mode requires the turnBoundary session projection') - }) - it('reads the folded state', async () => { const ctx = await setup() const agent = await agentWithSession(ctx) diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index 3c2fc36b5c..99c2b538ea 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -118,22 +118,22 @@ describe('plan projection unit', () => { const bench = await harness(true) commitPlanMode(bench.session, true, 0) expect(bench.values().plan).toEqual({ active: true, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBeNull() + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: null }) bench.session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'initial', }) expect(bench.values().plan).toEqual({ active: true, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: true }) commitPlanMode(bench.session, false, 1) expect(bench.values().plan).toEqual({ active: false, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(true) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: true }) bench.session.append('request/header', { header: { config: { provider: 'test', model: 'test-model' } }, reason: 'change', }) expect(bench.values().plan).toEqual({ active: false, pending: false }) - expect(bench.ctx.sessionProjections.stateOf(bench.session, 'plan')?.activeAtLastHeader).toBe(false) + expect(bench.ctx.sessionProjections.checkpoint(bench.session).plan?.val).toMatchObject({ activeAtLastHeader: false }) }) it('has no plan key when plan-mode is not composed', async () => { diff --git a/packages/preset/agent-preset-registry/src/index.ts b/packages/preset/agent-preset-registry/src/index.ts index 0500922904..9f870203b5 100644 --- a/packages/preset/agent-preset-registry/src/index.ts +++ b/packages/preset/agent-preset-registry/src/index.ts @@ -1,5 +1,7 @@ /** Declarative Agent capability sets, activation and session binding. */ -import { Context } from '@deepseek-ai/cordis' +import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' import { bindScopeParent, createScope, scopeOf, scopeParentOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' @@ -55,6 +57,7 @@ export class AgentPresetRegistry extends TypertRemoteService { default: z.string().required(), selectedDefault: z.string().volatile(), }) + private turnBoundary!: ProjectionHandle private readonly owner: Context private readonly definitions = new Map() private readonly generations = new Map() @@ -64,7 +67,12 @@ export class AgentPresetRegistry extends TypertRemoteService { constructor(ctx: Context, public config: Config) { super(ctx, 'agentPresets') this.owner = ctx - ctx.sessionProjections.register(agentPresetProjectionDefinition) + } + + protected async [Service.init](): Promise { + const ctx = this.owner + await ctx.sessionProjections.register(agentPresetProjectionDefinition) + this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) }) ctx.on('session/event', (session, event) => { if (event.type === 'agent-preset/selected') ctx.emit('agent-preset/selected', session.id, event.data.agentPreset) @@ -346,8 +354,8 @@ export class AgentPresetRegistry extends TypertRemoteService { @Remote('select') async select(agent: Agent, agentPreset: string): Promise { const turn = (this.switches.get(agent.id) ?? Promise.resolve()).then(async () => { - const boundary = this.owner.sessionProjections.stateOf(agent.session, 'turnBoundary') - if (boundary !== undefined && (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0)) { + const boundary = this.turnBoundary.stateOf(agent.session) + if (boundary.openTurnStartSeq !== null || boundary.lastTurn > 0) { throw new RemoteError('agent-preset/locked', 'This session has already started', { sessionId: agent.id, agentPreset }) } const preset = await this.recompose(agent.ctx, agentPreset) diff --git a/packages/preset/agent-preset-registry/tests/registry.spec.ts b/packages/preset/agent-preset-registry/tests/registry.spec.ts index 04ab9c0096..471998f689 100644 --- a/packages/preset/agent-preset-registry/tests/registry.spec.ts +++ b/packages/preset/agent-preset-registry/tests/registry.spec.ts @@ -108,7 +108,7 @@ describe('declarative preset revisions', () => { await declare(ctx, contribution('minimal')) const agent = await agentOn(ctx, 'selection') expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('minimal') agent.session.append('turn/start', { turn: 1 }) await expect(ctx.agentPresets.select(agent, 'standard')).rejects.toThrow('already started') }) @@ -276,7 +276,7 @@ it('serializes competing blank-session selections and recovers after a failed se ctx.agentPresets.select(agent, 'absent'), ctx.agentPresets.select(agent, 'minimal'), ctx.agentPresets.select(agent, 'standard'), ]) expect(results.map(row => row.status)).toEqual(['rejected', 'fulfilled', 'fulfilled']) - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('standard') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('standard') }) it('retains a newly acquired generation when a previous activation is replaced', async () => { @@ -305,7 +305,7 @@ it('does not lose the durable selection when a tools observer rejects notificati const agent = await agentOn(ctx, 'observer') ctx.on('tools/change', () => { throw new Error('observer refused') }) expect(await ctx.agentPresets.select(agent, 'minimal')).toBe('minimal') - expect(ctx.sessionProjections.stateOf(agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(agent.session).agentPreset?.val).toBe('minimal') }) it('inventories failed conditional entries without executing their expressions again', async () => { diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index e97796db5c..c966e6a4ea 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -27,7 +27,7 @@ import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' export { SANDBOX_MODES, setSandboxMode } from './session-mode.ts' @@ -122,6 +122,8 @@ export class SandboxPolicyService extends Service { readonly defaultMode: SandboxMode /** The absolute `workspace-write` fallback root for calls without a session cwd. */ readonly workspaceRoot: string + private projection!: ProjectionHandle + constructor(ctx: Context, config: Config) { super(ctx, 'sandboxPolicy') // schemastery (static Config) already filled `mode`; the cast records that @@ -129,8 +131,11 @@ export class SandboxPolicyService extends Service { // the process cwd is real branching, resolved absolute either way. this.defaultMode = config.mode as SandboxMode this.workspaceRoot = resolveWorkspaceRoot(config.workspaceRoot ?? process.cwd()) + } - ctx.sessionProjections.register({ + protected async [Service.init](): Promise { + const ctx = this.ctx + this.projection = await ctx.sessionProjections.register({ key: 'sandboxMode', stateVersion: 1, stateSchema: sandboxModeStateSchema, @@ -176,7 +181,7 @@ export class SandboxPolicyService extends Service { * @returns the last logged mode, or `undefined` without one. */ overrideOf(session: Session): SandboxMode | undefined { - return this.ctx.sessionProjections.stateOf(session, 'sandboxMode') ?? undefined + return this.projection.stateOf(session) ?? undefined } } diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index a6ed4d9fb4..e2279e7fd7 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -235,10 +235,10 @@ describe('the sandbox/mode session kit', () => { it('the sandboxMode projection folds to the last switch, or null without one', async () => { const ctx = await mounted() const session = Session.create(SessionId('sess-fold')) - expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBeNull() + expect(ctx.sessionProjections.checkpoint(session).sandboxMode?.val).toBeNull() setSandboxMode(session, 'workspace-write') setSandboxMode(session, 'read-only') - expect(ctx.sessionProjections.stateOf(session, 'sandboxMode')).toBe('read-only') + expect(ctx.sessionProjections.checkpoint(session).sandboxMode?.val).toBe('read-only') }) it('setSandboxMode appends exactly one sandbox/mode event per switch', () => { diff --git a/packages/session/session-stats/src/index.ts b/packages/session/session-stats/src/index.ts index 54679b6e78..6439070f01 100644 --- a/packages/session/session-stats/src/index.ts +++ b/packages/session/session-stats/src/index.ts @@ -14,16 +14,16 @@ import { sessionStatsProjectionDefinition } from './projection.ts' export type * from './types.ts' -/** Cordis plugin name. */ export const name = 'session-stats' -/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */ + export const inject = ['sessionProjections'] /** * Register the `sessionStats` unit; the registration is an effect on this * plugin's fiber, so unloading removes the key. * @param ctx - registrant context carrying the projection registry. + * @returns when the plugin is ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register(sessionStatsProjectionDefinition) +export async function apply(ctx: Context): Promise { + await ctx.sessionProjections.register(sessionStatsProjectionDefinition) } diff --git a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts index 35c297eddc..92565e1cc9 100644 --- a/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts +++ b/packages/session/session-title-first-prompt-llm/tests/provider.spec.ts @@ -76,7 +76,7 @@ async function harness( await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) const adapter = new RecordingAdapter(script, reasoningEfforts) ctx.llm.registerAdapter(['title-route'], adapter) @@ -249,7 +249,7 @@ describe('first-prompt LLM title provider', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, TITLE_CONFIG) // Delegate to the real registration while capturing it, to reach the guard // the service never produces. diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 6a7e9acdde..3d5dc3d7e0 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -3,6 +3,7 @@ * @module @deepseek-ai/dsh-session-title */ +import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' @@ -17,8 +18,7 @@ import type { } from '@deepseek-ai/dsh-session' import { SessionSeq } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-projection' -import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' -import type {} from '@deepseek-ai/dsh-agent' +import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' export type { SessionTitleEventData, SessionTitleModelIdentity, @@ -306,6 +306,8 @@ export class SessionTitleService extends Service { maxTitleBytes: z.number().step(1).min(1).required(), }) + private turnBoundary!: ProjectionHandle + private titleInput!: ProjectionHandle private readonly config: ResolvedConfig private readonly ownerFiber: Fiber private registration: ProviderRegistration | undefined @@ -328,6 +330,11 @@ export class SessionTitleService extends Service { throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes') } this.config = deepFreeze({ ...value }) + } + + protected async [Service.init](): Promise { + const ctx = this.ctx + this.turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.effect(() => async () => { this.lifetime.abort(new Error('session-title service disposed')) @@ -341,9 +348,9 @@ export class SessionTitleService extends Service { this.work.clear() }, 'sessionTitle lifecycle') - ctx.sessionProjections.register(titleProjectionDefinition) + await ctx.sessionProjections.register(titleProjectionDefinition) - ctx.sessionProjections.register<'titleInput', TitleInputState>({ + this.titleInput = await ctx.sessionProjections.register<'titleInput', TitleInputState>({ key: 'titleInput', stateVersion: 3, stateSchema: titleInputStateSchema, @@ -549,7 +556,7 @@ export class SessionTitleService extends Service { const state = session === undefined ? undefined : this.work.get(session) const pending = state?.pending if (session === undefined || state === undefined || pending === undefined) return - const boundary = this.ctx.sessionProjections.stateOf(session, 'turnBoundary')?.lastStepBoundary + const boundary = this.turnBoundary.stateOf(session).lastStepBoundary const route = session.requestHeader()?.config if (boundary?.kind !== 'start' || route?.provider !== options.provider @@ -730,7 +737,7 @@ export class SessionTitleService extends Service { } private titleInputOf(session: Session): TitleInputState { - return this.ctx.sessionProjections.stateOf(session, 'titleInput') as TitleInputState + return this.titleInput.stateOf(session) } /** Queue detached service work and retain it through service disposal. */ diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index df2b5f1f91..784df3c69d 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -90,10 +90,8 @@ describe('title projection unit', () => { } await ctx.plugin(SessionTitleService, CONFIG) - const state = ctx.sessionProjections.stateOf(session, 'titleInput') - expect(state?.count).toBe(5_000) - expect(state?.first?.text).toBe('message 0') - expect(state?.lastSeq).toBe(session.seq - 1) + const state = ctx.sessionProjections.checkpoint(session).titleInput?.val + expect(state).toMatchObject({ count: 5_000, first: { text: 'message 0' }, lastSeq: session.seq - 1 }) expect(ctx.sessionProjections.checkpoint(session).titleInput).toBeDefined() }) diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 8f22a930fc..f08002e145 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -452,7 +452,7 @@ describe('SessionTitleService Provider lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, CONFIG) const requests: SessionTitleProviderRequest[] = [] ctx.sessionTitle.register({ @@ -508,7 +508,7 @@ describe('SessionTitleService Provider lifecycle', () => { await ctx.plugin(LlmRuntime) await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SessionTitleService, CONFIG) const generate = vi.fn(async (request: SessionTitleProviderRequest): Promise => ({ title: 'Unexpected title', diff --git a/packages/session/session-title/tsconfig.json b/packages/session/session-title/tsconfig.json index 5755ac55e2..e056eda2bd 100644 --- a/packages/session/session-title/tsconfig.json +++ b/packages/session/session-title/tsconfig.json @@ -31,7 +31,6 @@ }, { "path": "../../core/agent" - } ] } diff --git a/packages/session/session-turn-outline/src/index.ts b/packages/session/session-turn-outline/src/index.ts index 0105e7c186..ab2c5f667e 100644 --- a/packages/session/session-turn-outline/src/index.ts +++ b/packages/session/session-turn-outline/src/index.ts @@ -14,16 +14,16 @@ import { turnOutlineProjectionDefinition } from './projection.ts' export type * from './types.ts' -/** Cordis plugin name. */ export const name = 'session-turn-outline' -/** The projection registry is the plugin's whole purpose; without it the fiber stays pending. */ + export const inject = ['sessionProjections'] /** * Register the `turnOutline` unit; the registration is an effect on this * plugin's fiber, so unloading removes the key. * @param ctx - registrant context carrying the projection registry. + * @returns when the plugin is ready. */ -export function apply(ctx: Context): void { - ctx.sessionProjections.register(turnOutlineProjectionDefinition) +export async function apply(ctx: Context): Promise { + await ctx.sessionProjections.register(turnOutlineProjectionDefinition) } diff --git a/packages/session/session-turn-outline/tests/projection.spec.ts b/packages/session/session-turn-outline/tests/projection.spec.ts index c01acf8aa5..8c1d26bc96 100644 --- a/packages/session/session-turn-outline/tests/projection.spec.ts +++ b/packages/session/session-turn-outline/tests/projection.spec.ts @@ -97,7 +97,7 @@ describe('turn outline projection unit', () => { appendPrompt(session, 'prompt') appendAssistant(session, 1, 1, 'streamed but unsettled') expect(outlineOf(ctx, session)[0]?.response).toBe('') - expect(ctx.sessionProjections.stateOf(session, 'turnOutline')?.draft).toBe('streamed but unsettled') + expect(ctx.sessionProjections.checkpoint(session).turnOutline?.val).toMatchObject({ draft: 'streamed but unsettled' }) endTurn(session, 1) expect(outlineOf(ctx, session)[0]?.response).toBe('streamed but unsettled') }) diff --git a/packages/session/working-directory/src/index.ts b/packages/session/working-directory/src/index.ts index 7346feb274..60c8ad9c2a 100644 --- a/packages/session/working-directory/src/index.ts +++ b/packages/session/working-directory/src/index.ts @@ -11,7 +11,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-fs' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { Session } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type {} from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { @@ -72,14 +72,19 @@ export class WorkingDirectoryService extends Service { private readonly operations = new Set>() private readonly lifecycle = new AbortController() + private projection!: ProjectionHandle + constructor(ctx: Context, config: Config) { super(ctx, 'workingDirectory') const directory = config.defaultDirectory ?? process.cwd() if (!isAbsolute(directory)) throw new Error('working-directory: defaultDirectory must be absolute') this.defaultDirectory = directory + } + protected async [Service.init](): Promise { + const ctx = this.ctx const directorySchema = zod.string().min(1).refine(isAbsolute).nullable() - ctx.sessionProjections.register({ + this.projection = await ctx.sessionProjections.register({ key: 'workingDirectory', stateVersion: 1, stateSchema: directorySchema, @@ -120,7 +125,7 @@ export class WorkingDirectoryService extends Service { * @returns its effective absolute directory. */ get(session: Session): string { - return this.ctx.sessionProjections.stateOf(session, 'workingDirectory') ?? this.defaultDirectory + return this.projection.stateOf(session) ?? this.defaultDirectory } /** @@ -139,7 +144,7 @@ export class WorkingDirectoryService extends Service { control.throwIfAborted() agent.ctx.fiber.assertActive() if (info?.type === 'directory') { - if (this.ctx.sessionProjections.stateOf(agent.session, 'workingDirectory') === null) { + if (this.projection.stateOf(agent.session) === null) { agent.session.append('working-directory/change', { cwd: current }) } return current diff --git a/packages/shell/tool-bash/tests/tools.spec.ts b/packages/shell/tool-bash/tests/tools.spec.ts index 1d01640cd0..1f2eb7d2e8 100644 --- a/packages/shell/tool-bash/tests/tools.spec.ts +++ b/packages/shell/tool-bash/tests/tools.spec.ts @@ -211,7 +211,7 @@ async function setupSandboxed(withApproval = false) { await ctx.plugin(LocalJobRegistry) await ctx.plugin(ToolJobs) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(RecordingSandboxExecutor) if (withApproval) await ctx.plugin(ApprovalService) diff --git a/packages/shell/tool-pwsh/tests/tools.spec.ts b/packages/shell/tool-pwsh/tests/tools.spec.ts index d033c52535..aaa82411c5 100644 --- a/packages/shell/tool-pwsh/tests/tools.spec.ts +++ b/packages/shell/tool-pwsh/tests/tools.spec.ts @@ -228,7 +228,7 @@ async function setupSandboxed(withApproval = false) { // The loop's turnBoundary unit (the open-turn fold) is not mounted in this // bench — the loop itself is not composed. Register its open-turn fold so // the approval service's turn-enclosure gate reads the seeded log shape. - ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) await ctx.plugin(SandboxPolicyService, {}) await ctx.plugin(ConfiningFakeBash) if (withApproval) await ctx.plugin(ApprovalService) diff --git a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts index cd30075474..2e348c865f 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/scoped-tool-subagent.ts @@ -15,8 +15,8 @@ export const inject = ['agents', 'subagentModelSelection'] */ export function apply(ctx: Context, config: Config): void { const install = (agent: Agent): void => { - agent.ctx.inject(ToolSubagent.inject, (runtimeCtx) => { - ToolSubagent.apply(runtimeCtx, { + agent.ctx.inject(ToolSubagent.inject, async (runtimeCtx) => { + await ToolSubagent.apply(runtimeCtx, { provider: config.provider, modelSelectionSettings: true, ...(config.toolName === undefined ? {} : { toolName: config.toolName }), diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index c4b1781f57..891e860b16 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -878,6 +878,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { it('requires an explicit absolute Harness home for nested dsh runtimes', async () => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', @@ -898,6 +899,7 @@ describe('dsh-subagent-dsh-sdk provider', () => { ])('rejects an invalid $field at load', async ({ field, override }) => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.plugin(sdk, { providerName: 'sdk', diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index dfb659c93b..c4fef66681 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/README.i18n.yaml @@ -18,8 +18,8 @@ en: 924b8c15f9529028 zh: 9fb0c399b5a79e3e /deepseek-ai-dsh-subagent/use-this-package/enabling-delegation: - en: 388ae5427c869173 - zh: aff830f4986cf1b3 + en: 025605eddae02478 + zh: 5659cba4340735d8 /deepseek-ai-dsh-subagent/use-this-package/delegation-settings: en: 86c982223fab1a3a zh: 3d220d8ed84eab9a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 2442af74c1..77e2e5cab9 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -31,7 +31,7 @@ This package is the contract every delegation setup shares. You enable it by mou ### Enabling delegation -Mount the service with a provider and the delegation tool. The provider registers under its configured name; each tool row selects one provider. A minimal setup: +Mount the service with `@deepseek-ai/dsh-session-projection`, a provider, and the delegation tool; the service waits for the projection registry before initializing. The provider registers under its configured name; each tool row selects one provider. A minimal setup: ```yaml - name: '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 7dccf24550..6506779e83 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" ### 启用委派 -挂载服务、提供方与委派工具。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下: +一起挂载本服务、`@deepseek-ai/dsh-session-projection`、提供方与委派工具;本服务等待投影注册表可用后才初始化。提供方按配置名称注册,每个工具行选择一个提供方。最小配置如下: ```yaml - name: '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 5a802075d1..5645775408 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -94,9 +94,6 @@ "@deepseek-ai/dsh-session-persistence": { "optional": true }, - "@deepseek-ai/dsh-session-projection": { - "optional": true - }, "@deepseek-ai/dsh-session-query": { "optional": true }, diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index edce6b7ae5..7484cdd5dc 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -29,7 +29,7 @@ */ import type { Volatile } from '@deepseek-ai/cordis' -import { Context } from '@deepseek-ai/cordis' +import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-working-directory' import type {} from '@deepseek-ai/dsh-attachment' @@ -194,7 +194,7 @@ export class SubagentRuntime extends TypertRemoteService { maxDepth: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(1).volatile(), maxActiveSubagents: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(8).volatile(), }) - static inject = ['workingDirectory'] + static inject = ['workingDirectory', 'sessionProjections'] private providers = new Map() private manager: SubagentManager | undefined /** @@ -222,16 +222,18 @@ export class SubagentRuntime extends TypertRemoteService { if (this.manager === manager) this.manager = undefined }, 'subagents.managerBinding()') }) - ctx.inject(['sessionProjections'], (projectionCtx) => { - projectionCtx.sessionProjections.register(subagentCatalogProjectionDefinition) - projectionCtx.sessionProjections.register(subagentTimingProjectionDefinition) - projectionCtx.sessionProjections.register(subagentIdentityProjectionDefinition) - }) // Archive admission: this runtime is the owner that knows which live // children descend from a Session and how a parent stops them. ctx.inject(['agents'], (agentsCtx: Context) => { installSubagentArchiveAdmission(agentsCtx) }) } + protected async [Service.init](): Promise { + const { sessionProjections } = this.ctx + await sessionProjections.register(subagentCatalogProjectionDefinition) + await sessionProjections.register(subagentTimingProjectionDefinition) + await sessionProjections.register(subagentIdentityProjectionDefinition) + } + /** * Resolve a delegation tool's depth policy against the current user setting. * @param configured - Explicit tool limit, or provider-managed for external delegation. diff --git a/packages/subagent/subagent/tests/catalog.spec.ts b/packages/subagent/subagent/tests/catalog.spec.ts index c242f1c423..ac576639fa 100644 --- a/packages/subagent/subagent/tests/catalog.spec.ts +++ b/packages/subagent/subagent/tests/catalog.spec.ts @@ -79,7 +79,7 @@ describe('subagent catalog projection', () => { try { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) const parent = ctx.sessions.create(header.id) const changes = vi.fn() ctx.sessionProjections.onChanged(changes) @@ -196,7 +196,7 @@ describe('subagent catalog projection', () => { try { await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) const events = [ fact(0, 'valid-child', 0, { mode: 'one-shot' }), { type: 'subagent/catalog', seq: SessionSeq(1), time: 0, data } as unknown as SessionEvent, diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts index e44c170dae..9a764ad01d 100644 --- a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -80,7 +80,7 @@ function policyEvents(events: readonly SessionEvent[]) { } function foldedSandboxMode(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { - return ctx.sessionProjections.stateOf(Session.create(id, events), 'sandboxMode') + return ctx.sandboxPolicy.overrideOf(Session.create(id, events)) ?? null } function foldedApprovalPolicy(ctx: Context, id: SessionId, events: readonly SessionEvent[]): unknown { diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index e4ee8e6c89..d7c4477c5b 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1,3 +1,4 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' @@ -100,6 +101,7 @@ async function setupWith( await ctx.plugin(AgentLoop, { agents: [] }) if (options.sessionQuery !== false) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) subagentConfigs.set(ctx, await liveConfig(ctx, SubagentRuntime, options.maxActiveSubagents === undefined ? {} : { maxActiveSubagents: options.maxActiveSubagents })) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) @@ -262,6 +264,7 @@ describe('continuable activation capacity', () => { const ctx = new Context() try { await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await expect(ctx.plugin(SubagentRuntime, { maxActiveSubagents })).rejects.toThrow() } finally { await ctx.fiber.dispose() @@ -272,6 +275,7 @@ describe('continuable activation capacity', () => { const ctx = new Context() try { await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) const live = await liveConfig(ctx, SubagentRuntime, { maxDepth: 4 }) subagentConfigs.set(ctx, live) const fiber = live.fiber @@ -3861,6 +3865,7 @@ describe('continuable errors', () => { }) await ctx.plugin(AgentLoop, { agents: [] }) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) const serviceFiber = await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) diff --git a/packages/subagent/subagent/tests/control.spec.ts b/packages/subagent/subagent/tests/control.spec.ts index b6df7c53ed..16263f5b10 100644 --- a/packages/subagent/subagent/tests/control.spec.ts +++ b/packages/subagent/subagent/tests/control.spec.ts @@ -1,3 +1,4 @@ +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' // The browser-facing prompt and interrupt controls and their stable failure // codes. Session Controller owns catalog observation and transport. @@ -25,6 +26,7 @@ const IMAGE_REF = { attachmentId: 'att', mediaType: 'image/png', bytes: 2, width async function bench(live?: Record) { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) if (live !== undefined) { ctx.provide('agents', { diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index dd5cf76799..f9983b580c 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -1,3 +1,4 @@ +import { subagentCatalogProjectionDefinition } from '../src/catalog.ts' import { mountWorkingDirectoryFixture } from './working-directory-fixture.ts' import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' @@ -49,6 +50,7 @@ async function setup( await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) await ctx.plugin(SubagentFork, { providerName: 'fork' }) @@ -191,6 +193,7 @@ describe('SubagentRuntime.listChildren', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const ancestor = ctx.sessions.create(SessionId('catalog-ancestor')) ancestor.append('turn/start', { turn: 1 }) @@ -221,6 +224,7 @@ describe('SubagentRuntime.listChildren', () => { await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(TestSessionQuery) await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) const parent = ctx.sessions.create(SessionId('chunk-parent')) for (let index = 0; index < 1_001; index += 1) { @@ -235,7 +239,8 @@ describe('SubagentRuntime.listChildren', () => { }) } const listSessions = vi.spyOn(ctx.sessionQuery, 'listSessions') - const state = ctx.sessionProjections.stateOf(parent, 'subagentCatalog') + const projection = await ctx.sessionProjections.register(subagentCatalogProjectionDefinition) + const state = projection.stateOf(parent) const chunkLengths: number[] = [] for (let chunk = state?.head; chunk !== undefined; chunk = chunk.previous) { chunkLengths.push(chunk.values.length) @@ -253,6 +258,7 @@ describe('SubagentRuntime.listChildren', () => { const withoutProjection = new Context() await withoutProjection.plugin(SessionStore) await mountWorkingDirectoryFixture(withoutProjection) + await withoutProjection.plugin(SessionProjectionRegistry) await withoutProjection.plugin(SubagentRuntime) const parent = withoutProjection.sessions.create(SessionId('parent')) await expect(withoutProjection.subagents.listChildren(parent.id)).rejects.toMatchObject({ @@ -441,6 +447,7 @@ describe('SubagentRuntime.listDescendants', () => { it('fails before reading when the Session store or query service is absent', async () => { const ctx = new Context() await mountWorkingDirectoryFixture(ctx) + if (ctx.get('sessionProjections') === undefined) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(SubagentRuntime) await expect(ctx.subagents.listDescendants(SessionId('root'))).rejects.toMatchObject({ code: 'SUBAGENT_CONTROL_SESSION_STORE_UNAVAILABLE' }) await ctx.plugin(SessionStore) diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 8025ac0060..30a6cfc932 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -70,8 +70,6 @@ const contexts = new WeakMap() async function service(withAgents = true): Promise<{ ctx: Context; subagents: SubagentRuntime }> { const ctx = new Context() - // The registry is a required injection of SubagentRuntime (its projection - // units register in the constructor). if (withAgents) { await mountAgentLoopTestDependencies(ctx) await ctx.plugin(AgentLoop, { agents: [] }) @@ -119,7 +117,7 @@ describe('SubagentRuntime', () => { await fiber.dispose() - expect(ctx.sessionProjections.stateOf(parent, 'subagentCatalog')).toBeUndefined() + expect(ctx.sessionProjections.snapshot(parent).values.subagentCatalog).toBeUndefined() }) it('registers, lists, looks up, starts, and removes providers', async () => { diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index 40b21431ec..dd8af27c69 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -38,12 +38,14 @@ describe('subagent timing projection', () => { expect(subagentIdentityProjectionDefinition.wire.view(state)).toEqual({ mode: 'one-shot', seq: 1 }) }) - it('registers with the optional session projection registry', async () => { + it('waits for the session projection registry and releases its projections on unload', async () => { const ctx = new Context() await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) await mountWorkingDirectoryFixture(ctx) - const serviceFiber = await ctx.plugin(SubagentRuntime) + const pending = ctx.plugin(SubagentRuntime) + expect(ctx.get('subagents')).toBeUndefined() + await ctx.plugin(SessionProjectionRegistry) + const serviceFiber = await pending const before = ctx.sessionProjections.snapshot(ctx.sessions.create()).values expect(before.subagentTiming).toEqual({ settledMs: 0 }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 968b68ebf3..11df129bb1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -33,11 +33,12 @@ import { registerListSubagentModels } from './list-models.ts' import type {} from './model-selection-settings.ts' import { recordSubagentModelSelection, - subagentModelSelectionProjectionDefinition, subagentModelSelectionPolicy, + subagentModelSelectionProjectionDefinition, } from './model-selection-state.ts' export const name = 'tool-subagent' + export const inject = ['tools', 'subagents', 'systemPrompt', 'sessionProjections'] // Definition identity keeps shared guidance scoped to the actual visible tools, @@ -159,8 +160,9 @@ function providerWording(inheritsConversation: boolean): { description: string; * @param ctx - Context that owns the registrations. * @param config - delegation-tool configuration. * @param session - unpublished Session supplied by a direct Agent setup; omit for a standing composition. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config, session?: Session): void { +export async function apply(ctx: Context, config: Config, session?: Session): Promise { // Direct apply() bypasses Schemastery's numeric constraints. A direct-apply // omission stays capless (the schema default only runs through the loader). if (config.maxDepth !== 'provider-managed') assertSubagentMaxDepth(config.maxDepth) @@ -171,7 +173,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const toolName = config.toolName ?? 'subagent' const modelSelectionCapable = config.modelSelectionSettings === true - ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + const modelSelectionProjection = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) const assertSubagentProviderConfiguration = (subagentProvider: SubagentProvider): void => { if (ctx.subagents.resolveMaxDepth(config.maxDepth) !== undefined && !subagentProvider.capabilities.depthLimit) { @@ -401,7 +403,7 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const freshSession = target.firstLiveSeq === 0 // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. && target.eventAt(SessionSeq(0))?.type !== 'session/end-seed' - let allowedModels = subagentModelSelectionPolicy(ctx.sessionProjections, target) + let allowedModels = subagentModelSelectionPolicy(modelSelectionProjection, target) if (allowedModels === undefined) { const parentId = target.header.origin === 'subagent' ? target.header.parentSession @@ -414,14 +416,14 @@ export function apply(ctx: Context, config: Config, session?: Session): void { const parent = sessions.get(parentId) allowedModels = parent === undefined ? undefined - : subagentModelSelectionPolicy(ctx.sessionProjections, parent) + : subagentModelSelectionPolicy(modelSelectionProjection, parent) } else if (freshSession) { const current = settings.current() allowedModels = current.enabled ? current.allowedModels : undefined } } if (allowedModels !== undefined) { - recordSubagentModelSelection(ctx.sessionProjections, target, allowedModels) + recordSubagentModelSelection(modelSelectionProjection, target, allowedModels) } return allowedModels === undefined ? undefined : { routes: allowedModels } } diff --git a/packages/subagent/tool-subagent/src/model-selection-state.ts b/packages/subagent/tool-subagent/src/model-selection-state.ts index d3b04b828b..9b51f384f7 100644 --- a/packages/subagent/tool-subagent/src/model-selection-state.ts +++ b/packages/subagent/tool-subagent/src/model-selection-state.ts @@ -2,7 +2,7 @@ import { z as zod } from 'zod' import type { Session } from '@deepseek-ai/dsh-session' -import type SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' import { assertAllowedModelRoutes, type AllowedModelRoute } from './model-selection.ts' @@ -52,29 +52,29 @@ export const subagentModelSelectionProjectionDefinition = { /** * Read the exact route list captured for a model-selectable definition. - * @param projections - registry that owns the policy projection. + * @param projection - registered policy state reader. * @param session - session whose durable decision is read. * @returns a detached route list, or undefined for the fixed-route definition. */ export function subagentModelSelectionPolicy( - projections: Pick, + projection: Pick, 'stateOf'>, session: Session, ): AllowedModelRoute[] | undefined { - return projections.stateOf(session, 'subagentModelSelectionPolicy')?.map(route => ({ ...route })) + return projection.stateOf(session)?.map(route => ({ ...route })) } /** * Append the route policy once, before its definition can reach a model request. - * @param projections - registry that owns the policy projection. + * @param projection - registered policy state reader. * @param session - session receiving the model-selectable definition. * @param allowedModels - exact routes the definition may select explicitly. */ export function recordSubagentModelSelection( - projections: Pick, + projection: Pick, 'stateOf'>, session: Session, allowedModels: readonly AllowedModelRoute[], ): void { - if (subagentModelSelectionPolicy(projections, session) !== undefined) return + if (subagentModelSelectionPolicy(projection, session) !== undefined) return session.append('subagent/model-selection-policy', { allowedModels: allowedModels.map(route => ({ ...route })), }) diff --git a/packages/subagent/tool-subagent/tests/harness.ts b/packages/subagent/tool-subagent/tests/harness.ts index 422d65ba0b..7547d77216 100644 --- a/packages/subagent/tool-subagent/tests/harness.ts +++ b/packages/subagent/tool-subagent/tests/harness.ts @@ -8,6 +8,7 @@ import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-test import SubagentRuntime from '@deepseek-ai/dsh-subagent' import { SessionId } from '@deepseek-ai/dsh-session' import * as mock from './scripted-provider.ts' +import type { Config as toolConfig } from '../src/index.ts' import * as tool from '../src/index.ts' import SubagentModelSelectionConfig from '../src/model-selection-settings.ts' @@ -32,7 +33,7 @@ const setupProviders = new WeakMap { - const fiber = agentCtx.inject(tool.inject, (runtimeCtx) => { - tool.apply(runtimeCtx, { ...config, modelSelectionSettings: true }, agent.session) + const fiber = agentCtx.inject(tool.inject, async (runtimeCtx) => { + await tool.apply(runtimeCtx, { ...config, modelSelectionSettings: true }, agent.session) }) await fiber.await() }, diff --git a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts index 31ab63b973..a20bbaa404 100644 --- a/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts +++ b/packages/subagent/tool-subagent/tests/model-selection-settings.spec.ts @@ -104,7 +104,7 @@ describe('SubagentModelSelectionConfig', () => { selectionConfigs.set(ctx, await liveConfig(ctx, SubagentModelSelectionConfig)) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) // A stored policy the service cannot apply fails when it is read, not when the profile row is written. await selectionConfigs.get(ctx)!.update({ allowedModels: [...ALLOWED_MODELS, ...ALLOWED_MODELS] }) @@ -122,13 +122,14 @@ describe('SubagentModelSelectionConfig', () => { const invalid = Session.create(SessionId('empty-policy')) invalid.append('subagent/model-selection-policy', { allowedModels: [] }) - expect(() => subagentModelSelectionPolicy(ctx.sessionProjections, invalid)).toThrow('requires at least one route') + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(() => subagentModelSelectionPolicy(policy, invalid)).toThrow('requires at least one route') const malformed = Session.create(SessionId('malformed-policy')) malformed.append('subagent/model-selection-policy', { allowedModels: [{ provider: 1, model: 'fast-model' }], } as never) - expect(() => subagentModelSelectionPolicy(ctx.sessionProjections, malformed)) + expect(() => subagentModelSelectionPolicy(policy, malformed)) .toThrow('requires non-empty provider and model ids') await ctx.fiber.dispose() }) @@ -137,14 +138,15 @@ describe('SubagentModelSelectionConfig', () => { const ctx = await boot() const disabled = await createAgent(ctx, 'disabled') expect(selectable(ctx, disabled)).toBe(false) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, disabled.session)).toBeUndefined() + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(subagentModelSelectionPolicy(policy, disabled.session)).toBeUndefined() await selectionConfigs.get(ctx)!.update({ enabled: true, allowedModels: ALLOWED_MODELS, }) const enabled = await createAgent(ctx, 'enabled') - expect(subagentModelSelectionPolicy(ctx.sessionProjections, enabled.session)).toEqual(ALLOWED_MODELS) + expect(subagentModelSelectionPolicy(policy, enabled.session)).toEqual(ALLOWED_MODELS) expect(selectable(ctx, enabled)).toBe(true) expect(selectable(ctx, disabled)).toBe(false) @@ -171,8 +173,8 @@ describe('SubagentModelSelectionConfig', () => { sessionId: SessionId('direct-agent-setup'), setup: async (agentCtx, agent) => { prepared = agent - const fiber = agentCtx.inject(tool.inject, (runtimeCtx) => { - tool.apply(runtimeCtx, { + const fiber = agentCtx.inject(tool.inject, async (runtimeCtx) => { + await tool.apply(runtimeCtx, { provider: 'spawn', modelSelectionSettings: true, }, agent.session) @@ -336,7 +338,8 @@ describe('SubagentModelSelectionConfig', () => { meta: { parentSession: parent.id, origin: 'subagent' }, }) expect(selectable(ctx, child)).toBe(true) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, child.session)).toEqual(ALLOWED_MODELS) + const policy = await ctx.sessionProjections.register(subagentModelSelectionProjectionDefinition) + expect(subagentModelSelectionPolicy(policy, child.session)).toEqual(ALLOWED_MODELS) const orphan = await createAgent(ctx, 'orphan', { meta: { parentSession: SessionId('missing-parent'), origin: 'subagent' }, @@ -355,11 +358,11 @@ describe('SubagentModelSelectionConfig', () => { }) const resumedEmpty = await createAgent(ctx, 'resumed-empty', { seed: [] }) expect(selectable(ctx, resumedEmpty)).toBe(false) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedEmpty.session)).toBeUndefined() + expect(subagentModelSelectionPolicy(policy, resumedEmpty.session)).toBeUndefined() const resumedDisabled = await createAgent(ctx, 'resumed-disabled', { seed: oldSeed.snapshotEvents() }) expect(selectable(ctx, resumedDisabled)).toBe(false) - expect(subagentModelSelectionPolicy(ctx.sessionProjections, resumedDisabled.session)).toBeUndefined() + expect(subagentModelSelectionPolicy(policy, resumedDisabled.session)).toBeUndefined() await ctx.fiber.dispose() }) @@ -368,22 +371,22 @@ describe('SubagentModelSelectionConfig', () => { await mountAgentLoopTestDependencies(withoutSettings) await mountWorkingDirectoryFixture(withoutSettings) await withoutSettings.plugin(SubagentRuntime) - expect(() => { - tool.apply(withoutSettings, { + await expect(async () => { + await tool.apply(withoutSettings, { provider: 'missing', modelSelectionSettings: true, maxDepth: 'provider-managed', }) - }).toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings') + }).rejects.toThrow('requires @deepseek-ai/dsh-tool-subagent/model-selection-settings') await withoutSettings.fiber.dispose() const withoutAgent = await boot(false) - expect(() => { - tool.apply(withoutAgent, { + await expect(async () => { + await tool.apply(withoutAgent, { provider: 'spawn', modelSelectionSettings: true, }) - }).toThrow('requires a scoped preset Context') + }).rejects.toThrow('requires a scoped preset Context') await withoutAgent.fiber.dispose() }) @@ -405,13 +408,13 @@ describe('SubagentModelSelectionConfig', () => { parentSession: SessionId('missing-parent'), }) - expect(() => { - tool.apply(ctx, { + await expect(async () => { + await tool.apply(ctx, { provider: 'missing', modelSelectionSettings: true, maxDepth: 'provider-managed', }, child) - }).toThrow('child model-selection inheritance requires the Session registry') + }).rejects.toThrow('child model-selection inheritance requires the Session registry') } finally { await ctx.fiber.dispose() } diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index ea090fa0a3..193b7bece0 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -18,6 +18,7 @@ import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn-in-process' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { loadStoredSession } from '../../subagent/tests/persistence-helpers.ts' import * as mock from './scripted-provider.ts' +import type { Config as toolConfig } from '../src/index.ts' import * as tool from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' import { @@ -325,7 +326,7 @@ describe('dsh-tool-subagent', () => { }, }) // Direct apply with only `provider` — no toolName, no agentOptions. - tool.apply(ctx, { maxDepth: 'provider-managed', provider: 'bare' }) + await tool.apply(ctx, { maxDepth: 'provider-managed', provider: 'bare' }) await new Promise(r => setTimeout(r, 10)) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) @@ -347,7 +348,7 @@ describe('dsh-tool-subagent', () => { // Tool first: no provider yet — the tool must be absent, not broken. // Direct apply (schema bypass): also covers the waiting-note's default // toolName fallback, which validated config pre-fills. - tool.apply(ctx, { provider: 'mock' }) + await tool.apply(ctx, { provider: 'mock' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) // Backend arrives (as a delayed sibling fiber would): the tool appears. await mock.mountScriptedProvider(ctx, { name: 'mock', reply: 'late but fine' }) @@ -360,7 +361,7 @@ describe('dsh-tool-subagent', () => { const ctx = await projectedContext() await mountWorkingDirectoryFixture(ctx) await ctx.plugin(SubagentRuntime) - tool.apply(ctx, { + await tool.apply(ctx, { provider: 'later-continuable', maxDepth: 'provider-managed', }) @@ -575,7 +576,6 @@ describe('dsh-tool-subagent', () => { expect(typeof unwrapped.apply).toBe('function') expect(unwrapped.Config).toBeDefined() }) - it('passes persona/toolFilter/maxDepth config through to the start request', async () => { let seen: { persona?: string; toolFilter?: unknown; maxDepth?: number } | undefined const ctx = await projectedContext() @@ -621,14 +621,14 @@ describe('dsh-tool-subagent', () => { .rejects.toThrow() }) - it('validates maxDepth when apply() is invoked directly without Schemastery', async () => { + it('validates maxDepth during initialization without Schemastery', async () => { const ctx = await projectedContext() - expect(() => { - tool.apply(ctx, { + await expect(async () => { + await tool.apply(ctx, { provider: 'unused', maxDepth: Number.NaN, }) - }).toThrow('subagent maxDepth must be a non-negative safe integer') + }).rejects.toThrow('subagent maxDepth must be a non-negative safe integer') }) it('a partial toolFilter (deny only) does not materialize an empty allow-list (deny-all trap)', async () => { @@ -806,7 +806,7 @@ describe('dsh-tool-subagent local activation', () => { return {} }, }) - tool.apply(ctx, { + await tool.apply(ctx, { provider: 'gated', toolName: 'subagent_gated', maxDepth: 3, @@ -850,7 +850,7 @@ describe('dsh-tool-subagent local activation', () => { describe('depth budget configuration', () => { /** Mount the tool over a request-capturing provider with full capabilities. */ - async function captureSetup(config: Omit = {}) { + async function captureSetup(config: Omit = {}) { const requests: SubagentStartRequest[] = [] const ctx = await projectedContext() await mountWorkingDirectoryFixture(ctx) diff --git a/packages/terminal/terminal-bash/src/index.ts b/packages/terminal/terminal-bash/src/index.ts index 131e75b553..4b05eeb668 100644 --- a/packages/terminal/terminal-bash/src/index.ts +++ b/packages/terminal/terminal-bash/src/index.ts @@ -24,13 +24,12 @@ export type { Config as TerminalLocalConfig } from './config.ts' /** Cordis plugin name. */ export const name = 'terminal-bash' -/** Required services: terminal registry, shared confinement policy, projection registry, and process substrate. */ -export const inject = ['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess', 'workingDirectory'] +/** Required services: terminal registry, shared confinement policy, process substrate, and working directory. */ +export const inject = ['terminals', 'sandboxPolicy', 'subprocess', 'workingDirectory'] interface SandboxModeFenceState { pty: Context['terminals'] sandboxPolicy: Context['sandboxPolicy'] - sessionProjections: Context['sessionProjections'] } const sandboxModeFences = new WeakMap() @@ -40,20 +39,18 @@ function ensureSandboxModeFence(ctx: Context, owner: Agent): void { if (existing !== undefined) { existing.pty = ctx.terminals existing.sandboxPolicy = ctx.sandboxPolicy - existing.sessionProjections = ctx.sessionProjections return } const state: SandboxModeFenceState = { pty: ctx.terminals, sandboxPolicy: ctx.sandboxPolicy, - sessionProjections: ctx.sessionProjections, } sandboxModeFences.set(owner, state) owner.ctx.on('internal/dispatch', (_mode, eventName, args) => { if (eventName !== 'session/event') return const [session, event] = args as [Session, SessionEvent] if (session !== owner.session || event.type !== 'sandbox/mode') return - const folded = state.sessionProjections.stateOf(session, 'sandboxMode') ?? null + const folded = state.sandboxPolicy.overrideOf(session) ?? null const currentMode = folded ?? state.sandboxPolicy.defaultMode if (event.data.mode === currentMode || !state.pty.hasOwnerActivity(owner)) return throw new Error( diff --git a/packages/terminal/terminal-bash/tests/index.spec.ts b/packages/terminal/terminal-bash/tests/index.spec.ts index a68c798321..9ed1aeeaeb 100644 --- a/packages/terminal/terminal-bash/tests/index.spec.ts +++ b/packages/terminal/terminal-bash/tests/index.spec.ts @@ -624,7 +624,7 @@ describe('terminal-bash plugin shape', () => { const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(ptyLocal) as Record expect(unwrapped.name).toBe('terminal-bash') - expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'sessionProjections', 'subprocess', 'workingDirectory']) + expect(unwrapped.inject).toEqual(['terminals', 'sandboxPolicy', 'subprocess', 'workingDirectory']) expect(unwrapped.Config).toBeDefined() }) diff --git a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index 932912b4e4..7cdc154c72 100644 --- a/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/test-support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -110,7 +110,7 @@ describe('dsh-agent-loop-testkit', () => { time: 1, data: { target: 'next-turn', start: 99, inserted: [] }, }]) - expect(() => ctx.sessionProjections.stateOf(invalid, 'inbox')) + expect(() => ctx.sessionProjections.checkpoint(invalid).inbox?.val) .toThrow(/invalid persisted inbox splice/) expect(harness.claim(agent, 'next-turn', 3)).toEqual([step, turn]) expect(claimed).toEqual([ diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index f345045c93..5521005375 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -20,6 +20,7 @@ import type {} from '@deepseek-ai/dsh-session-projection' export type * from './types.ts' export const name = 'tool-todo' + export const inject = ['tools', 'sessionProjections'] /** The valid {@link TodoItem} statuses, as a runtime set for input narrowing. */ @@ -113,14 +114,15 @@ const todosProjectionSchema: ZodType = zod.union([ * `ctx.sessionProjections`. * @param ctx - registrant context carrying the tool and session-projection registries. * @param config - deployment's explicit todo policy. + * @returns when the plugin is ready. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { const allowParallel = config.allowParallelInProgress // Standing-plan fold: latest whole todo/write list, cleared by the next // turn/start (turn/end keeps the finished checklist visible); null before the // first write or after a later turn begins; every other event returns the // same state reference. - ctx.sessionProjections.register<'todos', TodoItem[] | null>({ + await ctx.sessionProjections.register<'todos', TodoItem[] | null>({ key: 'todos', stateSchema: todosProjectionSchema, init: () => null, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a9b79453b..3656df5da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6783,6 +6783,10 @@ importers: version: link:../../core/system-prompt packages/core/agent: + dependencies: + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -9836,6 +9840,9 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + zod: + specifier: 4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ From 7ac8f92b700468f324d894b39e4e1d0d7dc028c6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 12:03:37 +0800 Subject: [PATCH 026/287] test: adapt application and benchmark fixtures to async activation --- ...09-28-session-corpus-performance.i18n.yaml | 4 +-- .../2026-09-28-session-corpus-performance.md | 2 ++ ...026-09-28-session-corpus-performance.zh.md | 2 ++ apps/cli/tests/web-agent-presets.e2e.ts | 6 ++-- apps/web/tests/agent-preset-selection.e2e.ts | 6 ++-- apps/web/tests/blank-writer-reuse.e2e.ts | 2 +- .../session-archive-active/dialog.expected.md | 2 +- apps/web/tests/session-archive-active.e2e.ts | 2 +- apps/web/tests/subagent-interrupt.e2e.ts | 4 +-- .../session-corpus/projection-list.worker.ts | 7 ++-- .../session-corpus/session-controller.ts | 32 +++++++++++++++++++ .../session-corpus/session-corpus.worker.ts | 7 ++-- .../session-open/session-open.worker.ts | 8 ++--- 13 files changed, 62 insertions(+), 22 deletions(-) create mode 100644 benchmarks/session-corpus/session-controller.ts diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml index 38607ee2df..4a98c2f555 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.i18n.yaml @@ -18,8 +18,8 @@ en: 1310a89c351e5bfe zh: e6386ae10b893c2d /agent-note-performance-baselines-for-large-session-corpora/decision/measured-endpoints: - en: b4d81575ac574f6f - zh: f4f291ab7ce83094 + en: 11937dfa4a349a19 + zh: b91a136890fe92e8 /agent-note-performance-baselines-for-large-session-corpora/calibration-evidence: en: f934c95748dcbfe0 zh: df6a68286c21510f diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md index 233491ebe0..2efe66ead2 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.md @@ -37,6 +37,8 @@ The projection cache is part of the list endpoint: each cold row carries cached Workers are compiled plain-Node processes, each started fresh against a seeded root. They compose the production Session store, projection registry, JSONL persistence, SQLite Session query with `openAt: first-search` on an in-memory index, JSON storage and projection cache, the Web projection set, AgentLoop, and the real `SessionController`. Transport lookups, upload registration, default model selection, and the Workspace registry are inert providers, because no measured path depends on them. +The shared corpus and projection-list controller fixture awaits controller activation and supplies the fixed attachment image policy. Its snapshots therefore include the production `imageLimits` unit; attachment I/O remains outside these workloads. Event corpora, measurement endpoints, and budgets are unchanged. + | Case | Corpus | Samples | Endpoint | |---|---|---:|---| | List | 3,000 | 3 | Host boot; first `session.list`; repeated `session.list` in the same process | diff --git a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md index 4ed0283df7..f964d640b8 100644 --- a/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md +++ b/.agents/notes/implemented/testing/2026-09-28-session-corpus-performance.zh.md @@ -37,6 +37,8 @@ Status: implemented worker 是编译后的纯 Node 进程,每次都针对已播种根目录全新启动。它们组合生产 Session store、投影注册表、JSONL 持久化、使用 `openAt: first-search` 和内存索引的 SQLite Session query、JSON 存储和投影缓存、Web 投影集合、AgentLoop 以及真实的 `SessionController`。传输查找、上传注册、默认模型选择和 Workspace 注册表使用惰性提供者,因为没有被测路径依赖它们。 +语料与 projection-list 共用的控制器夹具等待控制器激活,并提供固定附件图片策略。因此快照包含生产 `imageLimits` 单元;附件 I/O 仍不在这些负载中。事件语料、测量端点和预算保持不变。 + | 用例 | 语料 | 样本数 | 终点 | |---|---|---:|---| | 列表 | 3,000 | 3 | Host 启动;首次 `session.list`;同一进程中重复的 `session.list` | diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index db0d8e26e3..bf1b9ffb9e 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -1,3 +1,4 @@ +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' import { createRequire } from 'node:module' import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises' @@ -730,7 +731,7 @@ describe('a switch survives the session', () => { // The header keeps the creation fact; the log carries what it runs. expect(handle.agent.session.header.agentPreset).toBe('standard') - expect(ctx.sessionProjections.stateOf(handle.agent.session, 'agentPreset')).toBe('minimal') + expect(ctx.sessionProjections.checkpoint(handle.agent.session).agentPreset?.val).toBe('minimal') } finally { await handle.dispose() } @@ -745,7 +746,8 @@ describe('a forked session', () => { meta: { agentPreset: 'minimal' }, setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) - const inherited = ctx.sessionProjections.stateOf(parent.agent.session, 'agentPreset') ?? undefined + const projection = await ctx.sessionProjections.register(agentPresetProjectionDefinition) + const inherited = projection.stateOf(parent.agent.session) ?? undefined const child = await ctx.agents.create({ sessionId: SessionId('preset-fork-child'), seed: [], diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 6fc12aba02..d695e52818 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -465,7 +465,7 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without expect(await composer.innerText()).toBe(draft) expect(await picker.getAttribute('aria-haspopup')).toBe('menu') expect(await page.getByRole('dialog', { name: 'Add plugin', exact: true }).count()).toBe(0) - expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis') expect(scaffold.ctx.agentPresets.composedPreset(creator.ctx)).toBe('cordis') expect(scaffold.ctx.tools.schemas(creator).map(tool => tool.name)) .toEqual(expect.arrayContaining(['cordis_inspect_list', 'cordis_inspect_query', 'plugin_manager'])) @@ -490,9 +490,9 @@ it.each([false, true])('starts Creator from Plugins with Coding Tools=%s without const next = scaffold.ctx.agents.list().find(agent => agent.id !== creator.id)! expect(next.id).not.toBe(creator.id) expect(next.session.header.agentPreset).toBe('standard') - expect(scaffold.ctx.sessionProjections.stateOf(next.session, 'agentPreset')).toBe('standard') + expect(scaffold.ctx.sessionProjections.checkpoint(next.session).agentPreset?.val).toBe('standard') expect(scaffold.ctx.agentPresets.composedPreset(next.ctx)).toBe('standard') - expect(scaffold.ctx.sessionProjections.stateOf(creator.session, 'agentPreset')).toBe('cordis') + expect(scaffold.ctx.sessionProjections.checkpoint(creator.session).agentPreset?.val).toBe('cordis') await page.getByRole('button', { name: 'Standard mode', exact: true }).waitFor() expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toMatchObject({ enabled: developerTools }) expect(scaffold.ctx.agentPresets.defaultId).toBe('standard') diff --git a/apps/web/tests/blank-writer-reuse.e2e.ts b/apps/web/tests/blank-writer-reuse.e2e.ts index 99b2be07e3..74cb108982 100644 --- a/apps/web/tests/blank-writer-reuse.e2e.ts +++ b/apps/web/tests/blank-writer-reuse.e2e.ts @@ -72,7 +72,7 @@ describe.each([false, true])('web e2e: selected blank writer held: %s', (held) = if (!held) { expect(selectedId).toBe(HELD) const reclaimed = scaffold.ctx.agents.get(HELD)! - expect(scaffold.ctx.sessionProjections.stateOf(reclaimed.session, 'plan')).toMatchObject({ active: true }) + expect(scaffold.ctx.sessionProjections.checkpoint(reclaimed.session).plan?.val).toMatchObject({ active: true }) await expect(scaffold.ctx.sessionPersistence.open(HELD, 'write')) .rejects.toMatchObject({ name: 'SessionAlreadyOwnedError' }) } diff --git a/apps/web/tests/expected/session-archive-active/dialog.expected.md b/apps/web/tests/expected/session-archive-active/dialog.expected.md index 2a6f4d638f..4604467eeb 100644 --- a/apps/web/tests/expected/session-archive-active/dialog.expected.md +++ b/apps/web/tests/expected/session-archive-active/dialog.expected.md @@ -3,7 +3,7 @@ - button "Close" - paragraph: “ARCHIVE_ACTIVE run the long command” still has work in progress. Archiving stops it first; you can restore the session later from the “All conversations (show archived)” filter in the sidebar, and the stopped work will not resume on its own. - list "Work that will be stopped": - - listitem: The turn in progress - listitem: "1 background job: node -e \"const fs=require('node:fs'); fs.writeFileSync('started.txt', 'started'); setInterval(() => { fs.appendFileSync('heartbeat.txt', '.') }, 200); setTimeout(() => { fs.writeFileSync('finished.txt', 'finished') }, 60000)\"" + - listitem: The turn in progress - button "Cancel" - button "Stop and archive" diff --git a/apps/web/tests/session-archive-active.e2e.ts b/apps/web/tests/session-archive-active.e2e.ts index d192d3d207..8360f937d8 100644 --- a/apps/web/tests/session-archive-active.e2e.ts +++ b/apps/web/tests/session-archive-active.e2e.ts @@ -162,7 +162,7 @@ describe.skipIf(MODE === 'record')('web e2e: archiving a running Session stops i // A foreground Bash command is a registered job from its start, so the // Host reports it beside the turn; stopping the turn kills it. await expect.poll(() => dialog.getByRole('list', { name: 'Work that will be stopped' }).locator('li').allTextContents()) - .toEqual(['The turn in progress', `1 background job: ${WAIT_COMMAND}`]) + .toEqual([`1 background job: ${WAIT_COMMAND}`, 'The turn in progress']) // Asking is not archiving: the Host set is untouched, the turn and its // Bash child keep running, and the row stays in the sidebar. expect([...scaffold.ctx.workspaceRegistry.archivedSessionIds]).toEqual([]) diff --git a/apps/web/tests/subagent-interrupt.e2e.ts b/apps/web/tests/subagent-interrupt.e2e.ts index 7a35bcfcb9..a1b1284a19 100644 --- a/apps/web/tests/subagent-interrupt.e2e.ts +++ b/apps/web/tests/subagent-interrupt.e2e.ts @@ -43,8 +43,8 @@ async function remote( } /** POST one generated Session Remote unary through the API Gateway carrier. */ -function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { - return remote(scaffold, `session/${method}`, { request }) +async function sessionRemote(scaffold: WebScaffold, method: string, request: unknown): Promise> { + return await remote(scaffold, `session/${method}`, { request }) } /** Poll a synchronous condition (hook-safe; expect.poll is test-body only). */ diff --git a/benchmarks/session-corpus/projection-list.worker.ts b/benchmarks/session-corpus/projection-list.worker.ts index 78a60b5c06..c00f9dd771 100644 --- a/benchmarks/session-corpus/projection-list.worker.ts +++ b/benchmarks/session-corpus/projection-list.worker.ts @@ -7,7 +7,7 @@ import { monitorEventLoopDelay, performance } from 'node:perf_hooks' import { scheduler, setTimeout as delay } from 'node:timers/promises' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import type SessionController from '@deepseek-ai/dsh-api-session-controller' import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, type SessionHeader } from '@deepseek-ai/dsh-session' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' @@ -20,6 +20,7 @@ import { z } from 'zod' import { generationLogPath, sessionDir, toHeaderLine } from '../../packages/session/session-persistence-jsonl/src/format.ts' import { compressZstdFrame } from '../../packages/session/session-persistence-jsonl/src/zstd.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' +import { createBenchmarkSessionController } from './session-controller.ts' const KEY = 'syntheticProjectionList' const CWD = '/synthetic-projection-benchmark' @@ -171,7 +172,7 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionProjectionRegistry) - ctx.sessionProjections.register({ + await ctx.sessionProjections.register({ key: KEY, stateVersion: 1, stateSchema, init: header => makeState(Number(header.id.slice('projection-bench-'.length)), workload.baseRows), apply: state => state, @@ -200,7 +201,7 @@ async function mountHost(ctx: Context, root: string, workload: typeof WORKLOADS[ ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => ({ provider: 'synthetic', model: 'generated-model' }) } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) - return new SessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }, { canOpenPath: () => false }) + return createBenchmarkSessionController(ctx, { nativeOpen: false, ...(listWorkSliceMs === undefined ? {} : { listWorkSliceMs }) }) } async function memory(): Promise { diff --git a/benchmarks/session-corpus/session-controller.ts b/benchmarks/session-corpus/session-controller.ts new file mode 100644 index 0000000000..f5b3cac97e --- /dev/null +++ b/benchmarks/session-corpus/session-controller.ts @@ -0,0 +1,32 @@ +/** Session-controller initialization for synthetic corpus workers without attachment I/O. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import SessionController, { type Config } from '@deepseek-ai/dsh-api-session-controller' + +class BenchmarkSessionController extends SessionController { + initialize(): Promise { + return this[Service.init]() + } +} + +/** + * Initialize the controller against the worker's partial Host services. + * @param ctx - worker context owning service disposal. + * @param config - native-opener and list-scheduling settings. + * @returns controller ready for list, search, and fork calls. + */ +export async function createBenchmarkSessionController(ctx: Context, config: Config): Promise { + ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'], + }, + } as never) + const controller = new BenchmarkSessionController(ctx, config, { canOpenPath: () => false }) + await controller.initialize() + return controller +} diff --git a/benchmarks/session-corpus/session-corpus.worker.ts b/benchmarks/session-corpus/session-corpus.worker.ts index 8dabac10ca..ec56b55e44 100644 --- a/benchmarks/session-corpus/session-corpus.worker.ts +++ b/benchmarks/session-corpus/session-corpus.worker.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-preset-registry' -import SessionController from '@deepseek-ai/dsh-api-session-controller' +import type SessionController from '@deepseek-ai/dsh-api-session-controller' import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections' import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -27,6 +27,7 @@ import TokenMeter from '@deepseek-ai/dsh-token-meter' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { serializeRecord } from '../../packages/storage/storage-json/src/format.ts' import { ANCHOR_COUNT } from './corpus-shape.ts' +import { createBenchmarkSessionController } from './session-controller.ts' import { anchorHeader, corpusSessionId, @@ -140,7 +141,7 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl await ctx.plugin(SessionProjectionCache, { writeEveryEvents: 200, writeIntervalMs: 5_000 }) // Content search is opt-in; enabled deployments defer the in-memory index to the first search. await ctx.plugin(SqliteSessionQueryEngine, { path: ':memory:', openAt: 'first-search' }) - ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 }) await ctx.plugin(SessionStatsPlugin) await ctx.plugin(SessionTurnOutlinePlugin) @@ -152,7 +153,7 @@ async function mountHost(root: string): Promise<{ readonly ctx: Context; readonl ctx.provide('fileUploads', { registerAgentResolver: () => dispose } as never) ctx.provide('agentDefaultModel', { currentSelection: () => BENCH_MODEL } as never) ctx.provide('workspaceRegistry', { list: () => [], archivedSessionIds: [] } as never) - const controller = new SessionController(ctx, { nativeOpen: false }, { canOpenPath: () => false }) + const controller = await createBenchmarkSessionController(ctx, { nativeOpen: false }) return { ctx, controller } } diff --git a/benchmarks/session-open/session-open.worker.ts b/benchmarks/session-open/session-open.worker.ts index b1e2bfec1d..91a37ac8ae 100644 --- a/benchmarks/session-open/session-open.worker.ts +++ b/benchmarks/session-open/session-open.worker.ts @@ -30,7 +30,7 @@ import * as SessionTurnOutlinePlugin from '@deepseek-ai/dsh-session-turn-outline import TokenMeter from '@deepseek-ai/dsh-token-meter' // These Host-only adapters have no public Node export and are compiled into the benchmark worker. import { SessionHistoryController } from '../../packages/api/session-controller/src/history.ts' -import { installModelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts' +import { modelSelectionProjection } from '../../packages/api/session-controller/src/model-selection-projection.ts' import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts' import { SYNTHETIC_SESSION_ID } from './session-open.constants.ts' @@ -130,9 +130,9 @@ function memoryDelta( async function installProjectionSet(ctx: Context, agentLoopOwnsBoundary: boolean): Promise { // Mirrors projection owners mounted by the base and web-app bundles without timing profile boot. - if (!agentLoopOwnsBoundary) ctx.sessionProjections.register(turnBoundaryProjectionDefinition) - ctx.sessionProjections.register(agentPresetProjectionDefinition) - installModelSelectionProjection(ctx) + if (!agentLoopOwnsBoundary) await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) + await ctx.sessionProjections.register(agentPresetProjectionDefinition) + await ctx.sessionProjections.register(modelSelectionProjection) await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, From ce5c38791ef531459916df9c6f4c48609fd8b4dc Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 12:03:37 +0800 Subject: [PATCH 027/287] docs: update projection lifecycle guidance and generated catalogs --- ...ession-projection-mandatory-seam.i18n.yaml | 12 ++--- ...08-19-session-projection-mandatory-seam.md | 12 ++--- ...19-session-projection-mandatory-seam.zh.md | 12 ++--- ...-projection-registration-handles.i18n.yaml | 19 +++++++ ...session-projection-registration-handles.md | 31 +++++++++++ ...sion-projection-registration-handles.zh.md | 31 +++++++++++ AGENTS.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 2 +- docs/architecture.zh.md | 2 +- docs/config-catalog.md | 30 +++++------ docs/config-catalog.zh.md | 30 +++++------ docs/defensive-patterns/remote-and-api.md | 8 +-- docs/defensive-patterns/remote-and-api.zh.md | 8 +-- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/persistence-catalog.i18n.yaml | 8 +-- docs/persistence-catalog.md | 4 +- docs/persistence-catalog.zh.md | 4 +- docs/persistence-schema.json | 2 +- docs/subsystems/plan.i18n.yaml | 4 +- docs/subsystems/plan.md | 2 +- docs/subsystems/plan.zh.md | 2 +- docs/subsystems/session-projection.i18n.yaml | 12 ++--- docs/subsystems/session-projection.md | 52 +++++++++++-------- docs/subsystems/session-projection.zh.md | 52 +++++++++++-------- .../guide.i18n.yaml | 16 ++++++ .../claude-code-mods-projections/guide.md | 17 ++++++ .../claude-code-mods-projections/guide.zh.md | 17 ++++++ .../subagent-activations/guide.i18n.yaml | 8 +-- .../subagent-activations/guide.md | 6 ++- .../subagent-activations/guide.zh.md | 6 ++- .../guide.i18n.yaml | 16 ++++++ .../session-projection-handles/guide.md | 21 ++++++++ .../session-projection-handles/guide.zh.md | 21 ++++++++ .../extensions/tool-cordis/src/api-catalog.ts | 24 ++++----- scripts/gen-config-catalog.spec.ts | 25 +++++++++ scripts/gen-config-catalog.ts | 2 +- scripts/gen-cordis-catalog.ts | 1 + scripts/type-equiv.manifest.json | 5 ++ 40 files changed, 386 insertions(+), 148 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md create mode 100644 .agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml index 24f76c7cb0..f391fe518e 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.i18n.yaml @@ -6,14 +6,14 @@ en: 41f30d473ff53ce2 zh: 6851c4414f5331c1 /agent-note-session-projections-as-a-required-reader-seam/problem: - en: c0c779c717cad546 - zh: 5d2916ac86bf5d84 + en: fca00eeb7a8d9abd + zh: d7a9766f5322dff9 /agent-note-session-projections-as-a-required-reader-seam/decision: - en: a1c50d2f43334a88 - zh: d7736994733454b4 + en: dd550c8f4c2aa263 + zh: 9e60b0e465697e77 /agent-note-session-projections-as-a-required-reader-seam/alternatives-considered: en: aea3da939c90d240 zh: 21e91c481cdd23b7 /agent-note-session-projections-as-a-required-reader-seam/consequences: - en: 73325513ec70fbb2 - zh: 610bf34b9f9db420 + en: ab45eb87f7c22cdb + zh: e65a97991a45164f diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md index e1c6835311..5ab32c0b2c 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md @@ -6,19 +6,17 @@ English | [中文](2026-08-19-session-projection-mandatory-seam.zh.md) ## Problem -An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. Some contributor sites intentionally retain optional `ctx.inject` registration, so their readers need an explicit missing-state rule. +An optional projection registry lets a plugin whose host behavior reads projected state activate without that state. Unless the reader rejects the missing registry or key, the host behavior or subagent catalog fields can silently disappear. Batch-only reads also materialize every client view when a consumer needs one host value. ## Decision This decision builds on the split between host projection state and client views in [Session projection state and client views](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md). -Every host reader treats the projection registry and its required key as mandatory state. A plugin either declares `sessionProjections` as a required injection or resolves the registry and key explicitly and throws on the first dependent access. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. +Every host reader treats the projection registry and its required key as mandatory state. Plugins declare `sessionProjections` as a required injection and await acquisition before exposing readers. Official compositions mount the registry before those plugins. `ApiProxyService` follows the required-injection form; the lower-level `createApiProxy` factory remains tolerant for isolated tests and diagnostics. -A domain contributor may register its unit through `ctx.inject(['sessionProjections'], ...)`. Optional registration controls the child lifecycle only; it does not authorize a reader to substitute a default when the registry or key is absent. +Readers acquire state through [awaited registration and retained handles](2026-10-12-session-projection-registration-handles.md). That decision supersedes optional first-access acquisition and returning a bare disposer. It preserves the required-state rule here: a missing registry cannot silently produce default host state. Client views still contain only consumed fields; typed handle reads provide richer host state. -The registry provides `stateOf(session, key)` for one typed host state and keeps `snapshot()` for batch carriers. Client views contain only consumed fields; host readers use `stateOf` for richer state. - -`onChanged` publishes client-visible value changes only. Unit registration and removal remain effect-scoped registry lifecycle; `register()` returns the exact Cordis disposer so a composite domain owner can finish cleanup against projected state before removing its unit. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. +`onChanged` publishes client-visible value changes only. Registration changes do not create a second Host event stream or client tombstone protocol. A later authoritative history or list baseline reflects the active key set. ## Alternatives considered @@ -30,6 +28,6 @@ The registry provides `stateOf(session, key)` for one typed host state and keeps ## Consequences -- Missing projection composition fails either during activation or at the first dependent host access; it never degrades to a default value. +- Missing projection composition fails during activation; it never degrades to a default value. - Host consumers avoid repeated whole-registry snapshots and log scans. - Wire payloads exclude host-only fields and per-key watermark wrappers; ordinary baselines communicate the active key set. diff --git a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md index 5d6a5169a5..0ab43fd5c0 100644 --- a/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md @@ -6,19 +6,17 @@ Status: implemented ## 问题 -可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。部分贡献位置有意保留可选的 `ctx.inject` 注册形式,因此其读取方需要明确的缺失状态规则。 +可选的投影注册表会让 host 行为读取投影状态的插件在缺少该状态时仍然激活。除非读取方拒绝缺失的注册表或 key,否则 host 行为或 subagent 目录字段可能静默消失。只有批量读取也会在消费方只需要一个 host 值时物化每个客户端 view。 ## 决策 本决策建立在[会话投影的 host 状态与客户端视图](../../archived/architecture/2026-08-19-session-projection-state-and-client-views.md)所定义的拆分之上。 -每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件要么把 `sessionProjections` 声明为必需注入,要么显式解析注册表与 key,并在第一次依赖它们的访问时抛错。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 +每个 host 读取方都把投影注册表及其所需 key 视为必需状态。插件将 `sessionProjections` 声明为必需注入,并在暴露读取方前等待获取完成。正式组合在这些插件之前挂载注册表。`ApiProxyService` 采用必需注入形式;较低层的 `createApiProxy` factory 对隔离测试和诊断保持容错。 -领域贡献方可以通过 `ctx.inject(['sessionProjections'], ...)` 注册单元。可选注册只控制子功能生命周期;它并不允许读取方在注册表或 key 缺失时替换为默认值。 +读取方通过[等待注册与保留句柄](2026-10-12-session-projection-registration-handles.zh.md)获取状态。该决策取代可选首次访问获取和返回裸释放函数的方式,保留这里的必需状态规则:缺少注册表不能静默产生默认 host 状态。客户端视图仍只包含消费字段;类型化句柄读取提供更丰富的 host 状态。 -注册表提供 `stateOf(session, key)` 来读取一个类型化 host 状态,并为批量 carrier 保留 `snapshot()`。客户端 view 只包含消费方使用的字段;host 读取方通过 `stateOf` 取得更丰富的状态。 - -`onChanged` 只发布客户端可见值的变化。单元注册和移除仍是绑定 effect 的注册表生命周期;`register()` 返回 Cordis 的原始 disposer,使组合式领域 owner 可以先依据投影状态完成清理,再移除自身单元。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 +`onChanged` 只发布客户端可见值的变化。注册变化不会创建第二条 Host 事件流或客户端 tombstone 协议。后续权威 history 或 list 基线会反映活跃 key 集。 ## 考虑过的替代方案 @@ -30,6 +28,6 @@ Status: implemented ## 后果 -- 缺少投影组合会在激活期间或第一次依赖它的 host 访问时失败,绝不会降级为默认值。 +- 缺少投影组合会在激活期间失败,绝不会降级为默认值。 - host 消费方避免重复的全注册表快照和日志扫描。 - 协议负载排除 host 内部字段和逐 key 水位包装;普通基线会传达活跃 key 集。 diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml new file mode 100644 index 0000000000..c22458657b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-12-session-projection-registration-handles.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-10-12-session-projection-registration-handles.md +/agent-note-asynchronous-projection-registration-and-retained-readers: + en: 693349255afbaec8 + zh: 13bcbf3f40b93b2b +/agent-note-asynchronous-projection-registration-and-retained-readers/problem: + en: 47ba548b869be190 + zh: c2e5038a56fe741c +/agent-note-asynchronous-projection-registration-and-retained-readers/decision: + en: 19064b5aecb26f57 + zh: 075bb87c6a5299ae +/agent-note-asynchronous-projection-registration-and-retained-readers/alternatives-considered: + en: ee5b0cd715ed1fef + zh: 6b1478dcde21916d +/agent-note-asynchronous-projection-registration-and-retained-readers/consequences: + en: 4b5e4bb446872545 + zh: 9fb2cc8ae77fb823 diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md new file mode 100644 index 0000000000..ea16e10828 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.md @@ -0,0 +1,31 @@ +# Agent Note: Asynchronous projection registration and retained readers + +Status: implemented + +English | [中文](2026-10-12-session-projection-registration-handles.zh.md) + +## Problem + +Host readers coupled to registry key lookup lose access when a plugin unloads, although an Agent may still need projected state while its asynchronous cleanup drains. Repeated batch snapshots materialize unrelated client views. A synchronous registration API also makes every consumer assume that acquisition can never require asynchronous initialization. + +## Decision + +`SessionProjectionRegistry.register` returns a promise for a typed handle. Consumers await it during activation before exposing dependent operations. Function plugins retain their namespace exports and use asynchronous `apply`; services use their initialization hook. Definitions remain with their domain owner, and each reader registers the required definition instead of looking up a possibly absent key. This supersedes the optional first-access acquisition and disposer-return details in [the mandatory-reader decision](2026-08-19-session-projection-mandatory-seam.md), while retaining its mandatory-state and cropped-client-view rules. + +A handle owns one reference to a shared definition and its per-Session cells. Its disposer releases global participation exactly once; the final release removes the unit from live drives, snapshots, and checkpoints. Its reader remains usable after release, folding unseen events with the original definition. A later registration at a different state version does not replace an older handle's definition. Registration checks owner liveness after its asynchronous handoff and rejects if the owner unloaded before receiving the handle. + +AgentLoop acquires inbox and turn-boundary handles once before publishing its factory. Each Agent receives those readers during synchronous construction. Cleanup can use them after the loop fiber removes global registrations, so projection release need not wait for every Agent teardown. This preserves the existing creation/setup sequence without per-Agent registration or an extra initialization state. + +## Alternatives considered + +**Keep registry-level lookup and delay unregistering.** This couples unrelated plugin disposal order to every reader's final access. Retained readers make cleanup independent of that order. + +**Return a synchronous handle.** This preserves callers' immediate reads but commits the acquisition API to synchronous initialization. Awaited acquisition leaves that timing explicit while state reads remain synchronous after readiness. + +**Register the same units for each Agent.** The definitions already contain per-Session cells. Additional references and asynchronous Agent initialization add a creation race without providing independent data ownership. + +**Convert function plugins to classes.** Cordis already awaits promises returned by `apply`. Preserving namespace exports avoids a published entry-point and diagnostic-name break. + +## Consequences + +Consumers must migrate acquisition and retain handles. Released handles retain their definitions and weakly keyed cells for as long as the caller keeps them; they do not restore global wire participation. Registration-reader tests cover post-release folding, version replacement, unrelated-unit isolation, repeated disposal, concurrent owner disposal, and owner unload during acquisition. Agent lifecycle tests cover cleanup failures, scope-minting races, and background child shutdown. diff --git a/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md new file mode 100644 index 0000000000..28974a237b --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-12-session-projection-registration-handles.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 异步投影注册与保留读取句柄 + +Status: implemented + +[English](2026-10-12-session-projection-registration-handles.md) | 中文 + +## Problem + +依赖注册表键查询的 Host 读取方会在插件卸载时失去访问能力,但 Agent 可能仍需在异步清理排空期间读取投影状态。重复的批量快照会具象化无关 Client 视图。同步注册 API 也使每个消费者假定获取过程永远不需要异步初始化。 + +## Decision + +`SessionProjectionRegistry.register` 返回类型化句柄的 Promise。消费者在激活期间等待它,再暴露依赖操作。函数插件保留命名空间导出并使用异步 `apply`;服务使用初始化钩子。定义仍归领域拥有方,每个读取方注册所需定义,不再查询可能不存在的键。这取代了[必需读取方决策](2026-08-19-session-projection-mandatory-seam.zh.md)中的可选首次访问获取方式与返回释放函数的细节,同时保留必需状态和裁剪 Client 视图的规则。 + +句柄拥有共享定义及其逐 Session 单元的一次引用。其释放函数恰好一次地解除全局参与;最后一次释放会从实时推进、快照和检查点中移除单元。读取句柄在释放后仍可使用原定义折叠未读取事件。后续注册的其他状态版本不会替换旧句柄的定义。注册在异步交接后检查 owner 是否存活;如果 owner 在收到句柄前卸载,注册拒绝。 + +AgentLoop 在发布工厂前只获取一次 inbox 与轮次边界句柄。每个 Agent 在同步构造时收到这些读取句柄。loop fiber 移除全局注册后,清理过程仍可使用它们,因此投影释放无需等待每个 Agent teardown。这保留既有创建与 setup 顺序,无需逐 Agent 注册或增加初始化状态。 + +## Alternatives considered + +**保留注册表级查询并延迟注销。** 这使无关插件的释放顺序依赖每个读取方的最后一次访问。保留读取句柄使清理不再依赖该顺序。 + +**返回同步句柄。** 这保留消费者的立即读取,却将获取 API 固定为同步初始化。等待获取过程使时序显式,同时就绪后的状态读取仍同步。 + +**为每个 Agent 注册相同单元。** 定义已经包含逐 Session 单元。额外引用和异步 Agent 初始化只会引入创建竞态,不提供独立数据所有权。 + +**将函数插件改为类。** Cordis 已等待 `apply` 返回的 Promise。保留命名空间导出可避免发布入口与诊断名称的破坏性变更。 + +## Consequences + +消费者必须迁移获取方式并保留句柄。只要调用方持有已释放句柄,其定义和弱键单元就仍存在;句柄不会恢复全局 wire 参与。注册读取测试覆盖释放后折叠、版本替换、无关单元隔离、重复释放、并发 owner 释放以及获取期间 owner 卸载。Agent 生命周期测试覆盖清理失败、作用域创建竞态与后台子 Agent 停止。 diff --git a/AGENTS.md b/AGENTS.md index 724faca086..f830b6d8ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,7 +130,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - Packages use `@deepseek-ai/dsh-`; vendor is [rescoped](docs/rescope.md) and `private: true`. Harness packages declare `@deepseek-ai/cordis` in `peerDependencies`/`devDependencies`. Workspace dependency sections use DSH `workspace:*`, vendor/native `workspace:~` ([rules](.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. -- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. +- **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer, directly or through its documented registration handle. - **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`. Public service methods document parameters and non-void returns. `SessionEventMap` members are required-on-read unless the envelope carries `ignorable: true`. Bump `SESSION_FORMAT_VERSION` only when existing discriminators cannot prevent unsafe interpretation; same-version changes require safe reader behavior ([review](.agents/notes/implemented/process/2026-10-08-session-reader-compatibility-review.md)). - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 8206f3e6dc..5b82618b52 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -27,8 +27,8 @@ en: e6402b28c0ed15fa zh: 6c07be9047b42056 /deepseek-harness-architecture/session-log: - en: a2e35c43438fec6d - zh: e27bdac8e9feff32 + en: 35fcd01052084a07 + zh: e16743dac147cb6e /deepseek-harness-architecture/capability-seams: en: a5bc20c3f2a1b370 zh: 91773a281410b2f4 diff --git a/docs/architecture.md b/docs/architecture.md index 03d88148c7..b821b9fa92 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,7 +123,7 @@ Session consumers know only the current logical format. Header-only `stat` and ` **Model-visible means logged.** Every model request must be reconstructable from the log. New model-visible inputs require session events. Plugins changing existing message content register [pure message projections](subsystems/session.md#plugin-owned-message-projections); detached readers supply the same definitions explicitly. Tool changes are capability-independent; [Session tool history](../packages/core/session/README.md) supplies provider declarations. -**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, host consumers read one typed state with `stateOf()`, and carriers batch cropped client views with `snapshot()`. A host reader either requires this service during activation or fails explicitly when the registry or required key is absent. Contributors may retain `ctx.inject(['sessionProjections'], ...)` registration without silently defaulting a missing host value. The agent loop registers shared `turnBoundary` state for its readers ([decision](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.md)). +**Projection seam.** `dsh-session-projection` owns `ctx.sessionProjections`: registered units fold committed events incrementally, and carriers batch cropped client views with `snapshot()`. Host readers require the registry during activation, await registration, and retain a handle for synchronous state reads. Releasing registration removes global participation; retained readers continue folding Session events. The shared `turnBoundary` definition belongs to `dsh-agent` ([registration lifecycle](../packages/session/session-projection/README.md)). [Working directories](subsystems/working-directory.md) supply user context and execution paths without changing original project identity, sandbox write roots, or existing process directories. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 870fa1c996..403c4f3063 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -127,7 +127,7 @@ Session 消费方只了解当前逻辑格式。仅 header 的 `stat` 与 `list` **模型可见即已记录。** 每个模型请求都必须能从日志重建。新增模型可见输入需要会话事件。修改现有消息内容的插件注册[纯消息投影](subsystems/session.zh.md#plugin-owned-message-projections),独立读取器显式传入相同的处理器。 工具变更不依赖能力;[Session 工具历史](../packages/core/session/README.zh.md)提供提供方声明。 -**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,host 消费方通过 `stateOf()` 读取单个类型化状态,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方要么在激活时要求该服务,要么在注册表或必需 key 缺席时明确失败。贡献方可以保留 `ctx.inject(['sessionProjections'], ...)` 注册,但不能为缺失的 host 值静默提供默认值。agent loop 为读取方注册共享的 `turnBoundary` 状态([决策](../.agents/notes/implemented/architecture/2026-08-19-session-projection-mandatory-seam.zh.md))。 +**投影 seam。** `dsh-session-projection` 提供 `ctx.sessionProjections`:已注册单元增量折叠已提交事件,载体通过 `snapshot()` 批量取得裁剪后的客户端视图。host 读取方在激活时要求注册表,等待注册完成,并保留句柄以同步读取状态。释放注册会移除全局参与关系;已有读取方仍可继续折叠 Session 事件。共享的 `turnBoundary` 定义归 `dsh-agent` 所有([注册生命周期](../packages/session/session-projection/README.zh.md))。 [工作目录](subsystems/working-directory.zh.md) 提供用户上下文和执行路径,不改变原始项目标识、沙箱写入根目录或已有进程目录。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 00bad2b532..eb2a6a1ea2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -92,7 +92,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) · [`SessionId`](subsystems/core.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:254`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -220,7 +220,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -950,7 +950,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog @@ -1458,7 +1458,7 @@ export interface HmrConfig extends ChokidarOptions { ## `@deepseek-ai/dsh-hooks-claude-code` - `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:51`](../packages/experimental/hooks-claude-code/src/index.ts) +- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:53`](../packages/experimental/hooks-claude-code/src/index.ts) ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -1497,7 +1497,7 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` - `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-codex/src/index.ts:50`](../packages/experimental/hooks-codex/src/index.ts) +- `source`: [`packages/experimental/hooks-codex/src/index.ts:53`](../packages/experimental/hooks-codex/src/index.ts) ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -2098,7 +2098,7 @@ export interface ReplayModelConfig { ## `@deepseek-ai/dsh-llm-retry` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) +- `source`: [`packages/llm/llm-retry/src/index.ts:26`](../packages/llm/llm-retry/src/index.ts) ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ @@ -2372,7 +2372,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` - `inject`: `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +- `source`: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -3259,7 +3259,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ## `@deepseek-ai/dsh-subagent` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) @@ -3511,7 +3511,7 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -- `inject`: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` · `workingDirectory` +- `inject`: `terminals` · `sandboxPolicy` · `subprocess` · `workingDirectory` - `source`: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ```ts config-catalog @@ -3572,7 +3572,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:55`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3591,7 +3591,7 @@ export interface Config { ## `@deepseek-ai/dsh-tmux-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/tmux-context/src/index.ts:47`](../packages/context/tmux-context/src/index.ts) +- `source`: [`packages/context/tmux-context/src/index.ts:45`](../packages/context/tmux-context/src/index.ts) ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -3753,7 +3753,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` - `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts) +- `source`: [`packages/goal/tool-goal/src/index.ts:33`](../packages/goal/tool-goal/src/index.ts) ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -3921,7 +3921,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -3977,7 +3977,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4054,7 +4054,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-todo` - `inject`: `tools` · `sessionProjections` -- `source`: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) +- `source`: [`packages/todo/tool-todo/src/index.ts:30`](../packages/todo/tool-todo/src/index.ts) ```ts config-catalog /** Model-facing todo tool configuration. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 3d17bcb06a..aacdae8e58 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -94,7 +94,7 @@ export interface Config { - `inject`: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) · [`SessionId`](subsystems/core.zh.md) · `Volatile` (`@deepseek-ai/cosmokit`) -- `source`: [`packages/core/agent-loop/src/index.ts:303`](../packages/core/agent-loop/src/index.ts) +- `source`: [`packages/core/agent-loop/src/index.ts:254`](../packages/core/agent-loop/src/index.ts) ```ts config-catalog /** Agent-loop plugin configuration. */ @@ -222,7 +222,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` - `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` -- `source`: [`packages/api/session-controller/src/index.ts:80`](../packages/api/session-controller/src/index.ts) +- `source`: [`packages/api/session-controller/src/index.ts:79`](../packages/api/session-controller/src/index.ts) ```ts config-catalog /** Session Controller deployment policy. */ @@ -952,7 +952,7 @@ export interface StagehandModelConfig { ## `@deepseek-ai/dsh-experimental-claude-code-mods` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `source`: [`packages/experimental/claude-code-mods/src/index.ts:53`](../packages/experimental/claude-code-mods/src/index.ts) ```ts config-catalog @@ -1460,7 +1460,7 @@ export interface HmrConfig extends ChokidarOptions { ## `@deepseek-ai/dsh-hooks-claude-code` - `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:51`](../packages/experimental/hooks-claude-code/src/index.ts) +- `source`: [`packages/experimental/hooks-claude-code/src/index.ts:53`](../packages/experimental/hooks-claude-code/src/index.ts) ```ts config-catalog /** Plugin config: where the CC hook config lives + substitution roots. */ @@ -1499,7 +1499,7 @@ export interface Config { ## `@deepseek-ai/dsh-hooks-codex` - `inject`: `shell` · `sessionProjections` · `workingDirectory` -- `source`: [`packages/experimental/hooks-codex/src/index.ts:50`](../packages/experimental/hooks-codex/src/index.ts) +- `source`: [`packages/experimental/hooks-codex/src/index.ts:53`](../packages/experimental/hooks-codex/src/index.ts) ```ts config-catalog /** Plugin config: where the Codex hooks.json lives + the model name for payloads. */ @@ -2100,7 +2100,7 @@ export interface ReplayModelConfig { ## `@deepseek-ai/dsh-llm-retry` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/llm/llm-retry/src/index.ts:25`](../packages/llm/llm-retry/src/index.ts) +- `source`: [`packages/llm/llm-retry/src/index.ts:26`](../packages/llm/llm-retry/src/index.ts) ```ts config-catalog /** This policy executor has no config; providers own `retryPolicy`. */ @@ -2374,7 +2374,7 @@ export interface Config { ## `@deepseek-ai/dsh-plan-mode` - `inject`: `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/plan/plan-mode/src/index.ts:69`](../packages/plan/plan-mode/src/index.ts) +- `source`: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/src/index.ts) ```ts config-catalog /** Deployment-owned plan guidance. */ @@ -3261,7 +3261,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ## `@deepseek-ai/dsh-subagent` -- `inject`: `workingDirectory` +- `inject`: `workingDirectory` · `sessionProjections` - `refs`: `Volatile` (`@deepseek-ai/cordis`) - `source`: [`packages/subagent/subagent/src/index.ts:184`](../packages/subagent/subagent/src/index.ts) @@ -3513,7 +3513,7 @@ export interface Config { ## `@deepseek-ai/dsh-terminal-bash` -- `inject`: `terminals` · `sandboxPolicy` · `sessionProjections` · `subprocess` · `workingDirectory` +- `inject`: `terminals` · `sandboxPolicy` · `subprocess` · `workingDirectory` - `source`: [`packages/terminal/terminal-bash/src/config.ts:10`](../packages/terminal/terminal-bash/src/config.ts) ```ts config-catalog @@ -3574,7 +3574,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:55`](../packages/context/time-context/src/index.ts) ```ts config-catalog /** Request-preparation clock formatting and append scheduling. Invalid values fail plugin load. */ @@ -3593,7 +3593,7 @@ export interface Config { ## `@deepseek-ai/dsh-tmux-context` - `inject`: `agents` · `sessionProjections` -- `source`: [`packages/context/tmux-context/src/index.ts:47`](../packages/context/tmux-context/src/index.ts) +- `source`: [`packages/context/tmux-context/src/index.ts:45`](../packages/context/tmux-context/src/index.ts) ```ts config-catalog /** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ @@ -3755,7 +3755,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-goal` - `inject`: `agents` · `goals` · `tools` · `systemPrompt` · `sessionProjections` -- `source`: [`packages/goal/tool-goal/src/index.ts:32`](../packages/goal/tool-goal/src/index.ts) +- `source`: [`packages/goal/tool-goal/src/index.ts:33`](../packages/goal/tool-goal/src/index.ts) ```ts config-catalog /** Model policy and hard lower bounds for goal-state updates. */ @@ -3923,7 +3923,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:28`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ @@ -3979,7 +3979,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ @@ -4056,7 +4056,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-todo` - `inject`: `tools` · `sessionProjections` -- `source`: [`packages/todo/tool-todo/src/index.ts:29`](../packages/todo/tool-todo/src/index.ts) +- `source`: [`packages/todo/tool-todo/src/index.ts:30`](../packages/todo/tool-todo/src/index.ts) ```ts config-catalog /** Model-facing todo tool configuration. */ diff --git a/docs/defensive-patterns/remote-and-api.md b/docs/defensive-patterns/remote-and-api.md index 25281e96fb..df98cdde2a 100644 --- a/docs/defensive-patterns/remote-and-api.md +++ b/docs/defensive-patterns/remote-and-api.md @@ -92,16 +92,18 @@ The owning package decides and registers its unit, and the controller only reads ```ts ignore-check // packages/workspace//src/index.ts: the rule lives with the feature it serves. -ctx.sessionProjections.register({ +export const durablePageProjectionDefinition = { key: 'durablePage', stateSchema: durablePageState, init: () => ({ durable: false }), apply: applyDurablePage, stateVersion: 1, -}) +} +await ctx.sessionProjections.register(durablePageProjectionDefinition) // packages/api/session-controller: generic orchestration reads the projected value. -const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +const durablePage = await ctx.sessionProjections.register(durablePageProjectionDefinition) +const { durable } = durablePage.stateOf(session) ``` The wrong version decides the rule inside the shared layer: diff --git a/docs/defensive-patterns/remote-and-api.zh.md b/docs/defensive-patterns/remote-and-api.zh.md index 1a9c291d9c..79691a940a 100644 --- a/docs/defensive-patterns/remote-and-api.zh.md +++ b/docs/defensive-patterns/remote-and-api.zh.md @@ -92,16 +92,18 @@ type SessionRemote = { list(): ...; rename(id: string): ... } // hand-written ```ts ignore-check // packages/workspace//src/index.ts: the rule lives with the feature it serves. -ctx.sessionProjections.register({ +export const durablePageProjectionDefinition = { key: 'durablePage', stateSchema: durablePageState, init: () => ({ durable: false }), apply: applyDurablePage, stateVersion: 1, -}) +} +await ctx.sessionProjections.register(durablePageProjectionDefinition) // packages/api/session-controller: generic orchestration reads the projected value. -const { durable } = ctx.sessionProjections.stateOf(session, 'durablePage') +const durablePage = await ctx.sessionProjections.register(durablePageProjectionDefinition) +const { durable } = durablePage.stateOf(session) ``` 反例把这条规则的决定放进共享层: diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0f0d3dbdcf..fd1fcb14a8 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:200`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 16b47c76cb..ad79919624 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:249`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:200`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | | `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index e61666b542..342e85df6d 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -144,8 +144,8 @@ en: 4294f78d26ed985b zh: 4294f78d26ed985b /session-persistence-event-catalog/events/plan/plan-mode-log-only: - en: 862175bfe8692ef7 - zh: a8ab123bf674cf77 + en: 9ae5537eeae238de + zh: 6be761c7fd83fdce /session-persistence-event-catalog/events/request: en: a8df82440ebdf5f8 zh: a8df82440ebdf5f8 @@ -1347,8 +1347,8 @@ en: a74e3dfa51c987af zh: 6c3c87974d7a7260 /session-persistence-event-catalog/resolved-persistence-types/active~2: - en: a3de6a6fe9672c7a - zh: a357481e27b3b81f + en: c861888d6239b700 + zh: a9b2271d3c6b8737 /session-persistence-event-catalog/resolved-persistence-types/agentpreset: en: b8a0f8aac9708056 zh: f5f004bd205b41e1 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 3d8b0b77db..eb4ebe099b 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -700,7 +700,7 @@ Source: [`packages/interaction/permission-presets/src/index.ts:59`](../packages/ 'plan/mode': { active: boolean } ``` -Source: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -5556,7 +5556,7 @@ Array of `unknown` (opaque). SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` -Sources: [`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +Sources: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 76af401f6b..5b1def70af 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -702,7 +702,7 @@ export type SessionEvent = { 'plan/mode': { active: boolean } ``` -来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) ### `request/*` @@ -5558,7 +5558,7 @@ SHA-256: `b1664fa89f8eab49a14e7e3168d8bc4da4aa9fd76332f6c4a053d839e5276ae7` SHA-256: `20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5` -来源:[`packages/plan/plan-mode/src/index.ts:52`](../packages/plan/plan-mode/src/index.ts) +来源:[`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/src/index.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 729e58948d..1da9491a5f 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -22518,7 +22518,7 @@ "digest": "20a57544bbf204dacd1ae6beb2ab29444365c3c869f2afdf489dcdcbc3d9c8c5", "names": [], "sources": [ - "packages/plan/plan-mode/src/index.ts:52" + "packages/plan/plan-mode/src/index.ts:53" ] }, { diff --git a/docs/subsystems/plan.i18n.yaml b/docs/subsystems/plan.i18n.yaml index e5ce0f0902..a55599bd51 100644 --- a/docs/subsystems/plan.i18n.yaml +++ b/docs/subsystems/plan.i18n.yaml @@ -6,8 +6,8 @@ en: 234f6e4c49714997 zh: add95d36252b572d /plan-mode/logged-state-and-recovery: - en: dd1e8ba31e6fd535 - zh: a22dbccc60c73bc9 + en: 4c83897d7a6d8a67 + zh: 22dcc171a439ed19 /plan-mode/pending-selections-and-the-pre-step-append: en: 8d7e5b7824921ed5 zh: 910fad43593f29f4 diff --git a/docs/subsystems/plan.md b/docs/subsystems/plan.md index beba872f61..56af5f14d2 100644 --- a/docs/subsystems/plan.md +++ b/docs/subsystems/plan.md @@ -8,7 +8,7 @@ Source: [`packages/plan/plan-mode/src/index.ts`](../../packages/plan/plan-mode/s ## Logged state and recovery -`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. The optionally registered `plan` unit folds committed mode, command settlement, and the mode recorded at the latest request header. `ctx.planMode` reads that state through `stateOf()`; the first dependent access fails if the registry, `plan` key, or `turnBoundary` key is absent. Clients receive only `{ active, pending }`; resume, fork, and compaction recover both from the log. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). +`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace [session event](session.md): durable and replayable, never in the model transcript. The `plan` unit folds committed mode, command settlement, and the mode recorded at the latest request header. `ctx.planMode` requires the registry and awaits registration of `plan` and `turnBoundary` during activation, then reads through their retained handles. Clients receive only `{ active, pending }`; resume, fork, and compaction recover both from the log. The complete event declaration is in the [persistence log event catalog](../persistence-catalog.md). ## Pending selections and the pre-step append diff --git a/docs/subsystems/plan.zh.md b/docs/subsystems/plan.zh.md index 63463f0105..f0bb4a0d99 100644 --- a/docs/subsystems/plan.zh.md +++ b/docs/subsystems/plan.zh.md @@ -8,7 +8,7 @@ ## 已记录状态与恢复 -`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.zh.md):持久且可回放,绝不进入模型 transcript(文本记录)。可选注册的 `plan` 单元折叠已提交模式、命令结算结果和最近一次请求头记录的模式。`ctx.planMode` 通过 `stateOf()` 读取该状态;注册表、`plan` key 或 `turnBoundary` key 缺失时,第一次依赖它们的访问会失败。客户端只接收 `{ active, pending }`;恢复、fork 与压缩(compaction)都能从日志恢复两者。完整事件声明见[持久化日志事件目录](../persistence-catalog.zh.md)。 +`plan/mode`(`{ active: boolean }`)是仅记日志、整值替换的[会话事件](session.zh.md):持久且可回放,绝不进入模型 transcript(文本记录)。`plan` 单元折叠已提交模式、命令结算结果和最近一次请求头记录的模式。`ctx.planMode` 要求注册表存在,并在激活期间等待 `plan` 和 `turnBoundary` 注册完成,之后通过保留的句柄读取状态。客户端只接收 `{ active, pending }`;恢复、fork 与压缩(compaction)都能从日志恢复两者。完整事件声明见[持久化日志事件目录](../persistence-catalog.zh.md)。 ## 待生效选择与 pre-step 追加 diff --git a/docs/subsystems/session-projection.i18n.yaml b/docs/subsystems/session-projection.i18n.yaml index 8d53d7ed36..5f008d2f02 100644 --- a/docs/subsystems/session-projection.i18n.yaml +++ b/docs/subsystems/session-projection.i18n.yaml @@ -3,14 +3,14 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session-projection.md /session-projections: - en: a6ab5134ca1771fd - zh: 523039a9d84e3f97 + en: e9330151d3eb579a + zh: 53e47acd1d810da9 /session-projections/the-unit: en: fad897af8b8839ff zh: 3c68c5256973bc39 /session-projections/the-snapshot-and-the-change-feed: - en: a49a57c600c3cbd0 - zh: c92f00b8a9d71bad + en: 630c1041f41f89bf + zh: a3361bdead116cd7 /session-projections/the-registry-ctx-sessionprojections: - en: 50e79c1bc16abb63 - zh: 200ecbf0e4ab19d2 + en: 67fe0f49dee90e25 + zh: c23de3a2803040f0 diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 8580b94c4d..5da8044825 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -2,10 +2,27 @@ English | [中文](session-projection.zh.md) -The session-projection seam — a [capability seam](../capability-seams.md) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`), domain contributors (each registering one pure unit), and carriers ([dsh-session-controller](../../packages/api/session-controller)'s history tail page and `session/projection` push frame). It is one optional capability, not part of the agent-loop spine. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). +The session-projection seam — a [capability seam](../capability-seams.md) through which domain host plugins serve whole current values of log-derived per-session state to client carriers: the Service Definition and registry ([dsh-session-projection](../../packages/session/session-projection), `ctx.sessionProjections`), domain contributors (each registering one pure unit), and carriers ([dsh-session-controller](../../packages/api/session-controller)'s history tail page and `session/projection` push frame). Host state readers require the registry during activation. The framework drives, the domain computes: the registry subscribes to `session/event` once and folds every committed event through every unit; domains hold no subscriptions and clients never fold domain events — they receive finished values. Design authority: the [session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.md); drive/cache/feed contracts: the [package README](../../packages/session/session-projection/README.md). Source: [`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) +A `ProjectionHandle` retains its synchronous reader after unregistration. `stateOf(session)` folds newly committed events with the original unit; it does not rejoin global snapshots, checkpoints, or notifications. Registration must still finish before the handle can be used. See the [registration lifecycle](../../packages/session/session-projection/README.md). + +```ts type-equiv +/** An effect-owned registration with a reader retained independently of registration. */ +interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** Unregister from global drives and snapshots without invalidating this reader. */ + dispose(): void | Promise +} +``` + ## The unit `SessionProjectionStateMap` is the merge-extensible table of host fold states, while `SessionProjectionMap` retains the client-visible whole values. A domain contributes one `ProjectionDefinition` per state key; a `wire` block makes that key client-visible, and rendering belongs to the slot system, never this layer: @@ -99,11 +116,11 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `stateOf(session, key)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. +`snapshot(session)` is fully synchronous: a carrier reads it in the same tick as its page slice, so `asOfSeq` covers both reads at one sequence number. It returns only client views, and every value passes its unit's `viewSchema` before return. `handle.stateOf(session)` reads one live host state without computing unrelated views; callers must not mutate the borrowed reference. A state-reference change computes one cached raw view, and the change feed fires only when that result changes by `Object.is`; an object-valued view must preserve its reference to suppress publication across internal-only state changes. ## The registry: `ctx.sessionProjections` -`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect whose disposer rides the calling fiber: an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots, and clients read that as capability absence; a duplicate key with a different `stateVersion` throws, while same-version registrants share one unit and are counted. Domain plugins register under `ctx.inject(['sessionProjections'], …)` so headless assemblies without the registry stay unaffected. +`SessionProjectionRegistry` ([signatures](#ctxsessionprojections--sessionprojectionregistry)) owns the drive: one `session/event` subscription, eager `apply` over every registered unit, and per-session per-unit watermark cells. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Domains await `register(definition)` during activation and retain its handle. Registration belongs to the calling fiber: its last release removes the key from global drives and snapshots, while retained handles remain readable. Same-key, same-version registrants share one unit; incompatible versions reject registration. @@ -204,26 +221,27 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): () => void +register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): () => void +register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): Promise> /** * Subscribe to the change feed. The registration is an effect on the @@ -233,16 +251,6 @@ register< K extends Exclude void -/** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ -stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index b98d348a36..c2c952b8fc 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -2,10 +2,27 @@ [English](session-projection.md) | 中文 -会话投影 seam 是一项[能力 seam](../capability-seams.zh.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-session-controller](../../packages/api/session-controller) 的历史尾页与 `session/projection` 推送帧)。它是一项可选能力,不属于 agent loop(智能体循环)主干。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.zh.md)。 +会话投影 seam 是一项[能力 seam](../capability-seams.zh.md):领域 host 插件经由它向客户端载体供给按会话的日志派生状态的当前全量值;三方分别是 Service Definition 与注册表([dsh-session-projection](../../packages/session/session-projection),`ctx.sessionProjections`)、领域贡献方(每个领域注册一个纯单元)与载体([dsh-session-controller](../../packages/api/session-controller) 的历史尾页与 `session/projection` 推送帧)。Host 状态读取方在激活期间要求注册表存在。框架负责驱动,领域负责计算:注册表只订阅一次 `session/event`,并把每个已提交事件折叠进每个单元;领域不持有任何订阅,客户端也从不折叠领域事件——它们收到的是成品值。设计权威:[session-projection RFC](../../.agents/notes/proposed/architecture/2026-07-27-session-projection-and-command-log.zh.md);驱动、缓存与变更流约定:[包 README](../../packages/session/session-projection/README.zh.md)。 源码:[`packages/session/session-projection/src/index.ts`](../../packages/session/session-projection/src/index.ts) +`ProjectionHandle` 在注销后仍保留同步 reader。`stateOf(session)` 用原有单元折叠新提交的事件,不会重新加入全局快照、检查点或通知。必须等待注册完成才能使用 handle。参见[注册生命周期](../../packages/session/session-projection/README.zh.md)。 + +```ts type-equiv +/** An effect-owned registration with a reader retained independently of registration. */ +interface ProjectionHandle { + /** + * Read the current state, including after unregistering, by folding new + * Session events with this handle's original unit. Do not mutate the live value. + * @param session - Session whose committed events are folded. + * @returns the projection state at the Session's current cursor. + */ + stateOf(session: Session): S + /** Unregister from global drives and snapshots without invalidating this reader. */ + dispose(): void | Promise +} +``` + ## 投影单元 `SessionProjectionStateMap` 是 host 侧折叠状态的 merge-extensible 类型表,`SessionProjectionMap` 则继续表示客户端可见的全量值。领域为每个状态 key 贡献一个 `ProjectionDefinition`;`wire` 块使该 key 对客户端可见,渲染归 slot 体系管,永远不归本层: @@ -99,11 +116,11 @@ type ProjectionChangeListener = ( ) => void ``` -`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`stateOf(session, key)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 +`snapshot(session)` 完全同步:载体在切出页面切片的同一 tick 内读取它,因此 `asOfSeq` 使两次读取使用同一个序号。它只返回客户端视图,并在返回前通过各单元的 `viewSchema` 校验。`handle.stateOf(session)` 可在不计算无关视图的情况下读取一份实时 host 状态;调用方不得修改这一借用引用。state 引用变化时,注册表计算并缓存一次原始 view;只有该结果通过 `Object.is` 判定为变化时才触发变更流,对象 view 若要在仅内部 state 变化时抑制发布就必须保留引用。 ## 注册表:`ctx.sessionProjections` -`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动权:一份 `session/event` 订阅、对每个已注册单元即时调用 `apply`,以及每会话每单元的水位线(watermark)cell。cell 惰性构建:在事件流过之后才注册的单元,或比注册表更早的会话,都在首次触达(事件或读取)时从 `init` 出发在内存日志上折叠。注册是一个 effect,其 disposer 随调用方 fiber 走:领域插件卸载后,其 key(连同缓存的 cell)从后续驱动与快照中消失,客户端将其读作能力缺失;key 以不同 `stateVersion` 重复时直接 throw,同版本注册方则共享一个单元并被计数。领域插件在 `ctx.inject(['sessionProjections'], …)` 下注册,因此不带注册表的 headless 组装完全不受影响。 +`SessionProjectionRegistry`([签名](#ctxsessionprojections--sessionprojectionregistry))拥有驱动逻辑:一个 `session/event` 订阅、对所有已注册单元立即执行的 `apply`,以及按会话、按单元保存的水位线缓存。缓存单元惰性构建:若单元在事件产生后才注册,或会话早于注册表存在,第一次访问(事件或读取)会从 `init` 开始折叠内存日志。领域在激活期间等待 `register(definition)`,并保留返回的 handle(句柄)。注册归调用 fiber 所有:最后一次释放会从全局驱动和快照中移除该 key,已有句柄仍可读取。同 key、同版本的注册者共享一个单元;版本不兼容时注册失败。 @@ -204,26 +221,27 @@ Source: [`packages/session/session-projection-cache/src/index.ts`](../../package ### `ctx.sessionProjections` — `SessionProjectionRegistry` -`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin's key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject(['sessionProjections'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads. +`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit's `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit. ```ts cordis-catalog /** - * Register one domain's unit. The registration is an effect on the calling - * context's fiber: disposing the fiber (or calling the returned disposer) - * removes the key — and the unit's cached cells — from subsequent drives - * and snapshots. + * Register one domain's unit on the calling context's fiber. Fiber or handle + * disposal releases one reference; the final release removes live participation. + * Retained handles remain readable after release. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): () => void +register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'> & { wire: NonNullable['wire']> }, ): Promise> /** * Register one host-only unit. Its state is omitted from client snapshots * and always checkpointed like every other unit. + * Callers must await registration before publishing consumers; registration may perform asynchronous work. * @param definition - key, state schema, pure unit functions, and stateVersion. - * @returns the exact disposer that unregisters this unit. + * @returns a ready handle with synchronous state reads and the effect disposer. */ -register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): () => void +register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, 'wire'>, ): Promise> /** * Subscribe to the change feed. The registration is an effect on the @@ -233,16 +251,6 @@ register< K extends Exclude void -/** - * Read one unit's current host state after materializing every registered - * unit at the Session cursor. Unrelated wire views are not produced. - * The returned value is live; callers must not mutate it. - * @param session - the session whose state is read. - * @param key - the registered unit key. - * @returns current state, or `undefined` when the key is not registered. - */ -stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined - /** * One consistent cut over every registered client-visible unit for one session, read from * the watermark cache (missing cells fold lazily over the in-memory log). diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml new file mode 100644 index 0000000000..6d6f45fd15 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md +/: + en: 2ca1b8bc4d168c3f + zh: 2257728847687363 +/compose-session-projections-for-claude-code-mods: + en: 58c229d213b33d7b + zh: d6d539f921e59af2 +/compose-session-projections-for-claude-code-mods/change: + en: 3a76b4c79776f847 + zh: e9c7d258a2b76611 +/compose-session-projections-for-claude-code-mods/migration: + en: 6b9ecb1a635ccb44 + zh: 978dad0bdcd69d57 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md new file mode 100644 index 0000000000..3c97f66652 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "The Claude Code mods bridge requires the session projection registry at initialization." +--- + +# Compose session projections for Claude Code mods + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-experimental-claude-code-mods` requires `sessionProjections` to initialize. Custom compositions that omit this service no longer start the bridge. The bridge registers the turn and context-pressure projections before serving mods. + +## Migration + +1. Add `@deepseek-ai/dsh-session-projection` to the `cordis.yml` or profile overlay that composes the bridge, unless it is already composed. +2. Start the profile and confirm the bridge and its mod plugins load successfully. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md new file mode 100644 index 0000000000..753d5ca266 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/claude-code-mods-projections/guide.zh.md @@ -0,0 +1,17 @@ +--- +kind: upgrade-guide +description: "Claude Code 模组桥接在初始化时要求会话投影注册表。" +--- + +# 为 Claude Code 模组组合会话投影 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-experimental-claude-code-mods` 需要 `sessionProjections` 才能初始化。未提供此服务的自定义组合不再启动桥接。桥接在服务模组前注册轮次和上下文压力投影。 + +## 迁移 + +1. 在组合桥接的 `cordis.yml` 或配置覆盖文件中添加 `@deepseek-ai/dsh-session-projection`;已组合此服务时无需重复添加。 +2. 启动配置,确认桥接及其模组插件成功加载。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml index c1938c0f53..9407c83aff 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.i18n.yaml @@ -9,8 +9,8 @@ en: fcf5f06084f989d9 zh: 5e3e6a05786390dd /migrate-delegation-to-managed-subagent-activations/change: - en: 17add29d9f80674e - zh: 4d4050e99f019cca + en: e56eb4dee1cf246a + zh: f5c5c62c815c140d /migrate-delegation-to-managed-subagent-activations/migration: - en: d4269b2c58484a3d - zh: 0ae39476ecd330e6 + en: e8455cb8d6ecf66b + zh: 1b48e06d6aac7d9f diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md index 5a88ff2ee3..1ef7d07119 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md @@ -11,7 +11,9 @@ English | [中文](guide.zh.md) `@deepseek-ai/dsh-tool-subagent` removes `backgroundMode`, `enableRunInBackground`, and the model argument `run_in_background`. Every delegation now returns `{ kind: 'activation', subagentId }` after accepting the child, and the runtime delivers its final answer in a parent completion notice. Foreground results and `{ kind: 'background', jobId }` results are removed; subagents are no longer collected or stopped through `job_output` or `job_kill`. -The published `@deepseek-ai/dsh-subagent-in-process-driver` package is removed. Local providers contribute creation inputs through `prepareContinuable()`; the subagent service owns execution. Spawn and Fork children can receive later messages, including after settlement when persistence and Session query are available. External providers execute once and do not accept follow-up messages. +`@deepseek-ai/dsh-subagent-in-process-driver` is removed. Local providers contribute creation inputs through `prepareContinuable()`; the subagent service owns execution. Spawn and Fork children can receive later messages, including after settlement when persistence and Session query are available. External providers execute once and do not accept follow-up messages. + +Subagent initialization requires `@deepseek-ai/dsh-session-projection`. ## Migration @@ -24,7 +26,7 @@ The published `@deepseek-ai/dsh-subagent-in-process-driver` package is removed. toolName: subagent ``` - Local tool delegation requires Session persistence. Compose it with the [subagent service and provider](../../../../packages/subagent/subagent/README.md#use-this-package). + Mount `@deepseek-ai/dsh-session-projection`. Local tool delegation requires Session persistence. Compose it with the [subagent service and provider](../../../../packages/subagent/subagent/README.md#use-this-package). 2. Remove `run_in_background` from tool calls and update result consumers to read `subagentId`. Collect the completion notice instead of foreground output or a Job result. Use `interrupt_agent` to stop the current execution; use `send_message` only for local children when those control tools are composed. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md index aa83610496..1f2e80d7cf 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md @@ -11,7 +11,9 @@ description: "受管理的子 agent activation 替代前台与 Job 后台委派 `@deepseek-ai/dsh-tool-subagent` 移除了 `backgroundMode`、`enableRunInBackground` 和模型参数 `run_in_background`。每次委派现在都会在接纳子 agent(智能体)后返回 `{ kind: 'activation', subagentId }`,运行时通过父级完成通知交付最终答案。前台结果和 `{ kind: 'background', jobId }` 结果已移除;子 agent 不再通过 `job_output` 或 `job_kill` 收集结果或停止。 -已发布的 `@deepseek-ai/dsh-subagent-in-process-driver` 包被移除。本地提供方通过 `prepareContinuable()` 提供创建输入,由子 agent 服务拥有执行过程。Spawn 和 Fork 子级可以接收后续消息;组合了持久化和 Session 查询服务时,结算后也能接收。外部提供方只执行一次,不接受后续消息。 +`@deepseek-ai/dsh-subagent-in-process-driver` 被移除。本地提供方通过 `prepareContinuable()` 提供创建输入,由子 agent 服务拥有执行过程。Spawn 和 Fork 子级可以接收后续消息;组合了持久化和 Session 查询服务时,结算后也能接收。外部提供方只执行一次,不接受后续消息。 + +子 agent 初始化需要 `@deepseek-ai/dsh-session-projection`。 ## 迁移 @@ -24,7 +26,7 @@ description: "受管理的子 agent activation 替代前台与 Job 后台委派 toolName: subagent ``` - 本地工具委派需要 Session 持久化。将其与[子 agent 服务及提供方](../../../../packages/subagent/subagent/README.zh.md#use-this-package)一起组合。 + 挂载 `@deepseek-ai/dsh-session-projection`。本地工具委派需要 Session 持久化。将其与[子 agent 服务及提供方](../../../../packages/subagent/subagent/README.zh.md#use-this-package)一起组合。 2. 从工具调用中删除 `run_in_background`,并将结果消费方改为读取 `subagentId`。收集完成通知,替代前台输出或 Job 结果。组合了相应控制工具时,使用 `interrupt_agent` 停止当前执行;`send_message` 仅用于本地子级。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml new file mode 100644 index 0000000000..7d864c5be2 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md +/: + en: b3f54a7eadb3398a + zh: 6815b996b2cebc6f +/session-projection-registration-handles: + en: 727968e582c6eee6 + zh: e01dc83384747012 +/session-projection-registration-handles/change: + en: 06c3d4ee9031fb7e + zh: cf2b664bfdafe837 +/session-projection-registration-handles/migration: + en: 0619d4d16272fa0c + zh: c3b0bb2c9d3ecb57 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md new file mode 100644 index 0000000000..f746d9170d --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session projection registration is asynchronous and returns a retained reader instead of a disposer." +--- +# Session projection registration handles + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-session-projection` changes `SessionProjectionRegistry.register(definition)` from a synchronous disposer to `Promise>`. Registry-level `stateOf(session, key)` is removed. Registration errors now reject the returned promise. After the last registration is disposed, the unit leaves snapshots, checkpoints, and live notifications, but retained handles can still fold Session events on demand. + +`@deepseek-ai/dsh-subagent` now requires `sessionProjections`. `@deepseek-ai/dsh-api-session-controller` removes `installModelSelectionProjection`; consumers register `modelSelectionProjection` instead. The controller also registers `agentPreset`, whose null value means the Session has no recorded preset; that key does not establish that a preset registry is available. `turnBoundaryProjectionDefinition` is exported by `@deepseek-ai/dsh-agent`; its `agent-loop` re-export remains available. + +## Migration + +1. Declare `sessionProjections` as a required plugin injection and mount `SessionProjectionRegistry` before consumers, including `SubagentRuntime` in custom compositions. +2. In an asynchronous function-plugin `apply()` or service `[Service.init]()`, await `ctx.sessionProjections.register(definition)` and retain the returned handle before publishing dependent operations. Keep existing function-plugin namespace exports. +3. Replace `registry.stateOf(session, key)` with `handle.stateOf(session)`. Import the owning package's projection definition when a reader needs to acquire a handle itself. Replace stored unregister callbacks with `handle.dispose`; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. +4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`. +5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md new file mode 100644 index 0000000000..d6a19c5631 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Session 投影注册改为异步,并返回保留读取能力的句柄而不是释放函数。" +--- +# Session 投影注册句柄 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-session-projection` 的 `SessionProjectionRegistry.register(definition)` 从同步释放函数改为返回 `Promise>`。注册表级 `stateOf(session, key)` 已移除,注册错误现在使返回的 Promise 拒绝。最后一次注册释放后,该投影单元不再参与快照、检查点和实时通知,但保留的句柄仍可按需折叠 Session 事件。 + +`@deepseek-ai/dsh-subagent` 现在必需注入 `sessionProjections`。`@deepseek-ai/dsh-api-session-controller` 移除了 `installModelSelectionProjection`;消费者改为注册 `modelSelectionProjection`。控制器也注册 `agentPreset`,其 null 值表示 Session 未记录预设;该键不代表预设注册表可用。`turnBoundaryProjectionDefinition` 由 `@deepseek-ai/dsh-agent` 导出;`agent-loop` 的再导出仍然可用。 + +## 迁移 + +1. 将 `sessionProjections` 声明为插件的必需注入,并在消费者之前挂载 `SessionProjectionRegistry`,包括自定义组合中的 `SubagentRuntime`。 +2. 在函数插件的异步 `apply()` 或服务的 `[Service.init]()` 中,等待 `ctx.sessionProjections.register(definition)`,在发布依赖操作前保留返回的句柄。保留既有的函数插件命名空间导出。 +3. 将 `registry.stateOf(session, key)` 替换为 `handle.stateOf(session)`。读取方需要自行取得句柄时,导入归属包的投影定义。将保存的注销回调替换为 `handle.dispose`;fiber 释放仍会自动解除注册。处理初始化错误时须等待注册完成。 +4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。 +5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 7da873bea6..d2ded420f8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -2204,19 +2204,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'sessionProjections', summary: '`ctx.sessionProjections`: the projection unit table and its drive.', - description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Registration is an effect (disposer rides the calling fiber): an unloaded domain plugin\'s key disappears from snapshots and clients read it as capability absence. A host reader either declares `sessionProjections` in its plugin `inject` or fails explicitly when the registry or required key is absent. Contributors may preserve optional registration through `ctx.inject([\'sessionProjections\'], ...)`. Registrants sharing a key share one unit and are counted: the same tool package mounted in N agent presets registers N times, and the key survives until the last one unloads.', + description: '`ctx.sessionProjections`: the projection unit table and its drive. The service subscribes to `session/event` once; every committed event passes every registered unit\'s `apply` (eager drive). A changed state reference computes the next client view; the change feed is notified only when its raw result changes by `Object.is`. Cells build lazily — a unit registered after events flowed, or a session older than the registry, folds `init` over the in-memory log on first touch (event or read). Consumers declare `sessionProjections` as a required injection, await registration during activation, and retain its ready handle. Registrants sharing a key share one unit and are counted. The last release removes the unit from live drives, checkpoints, and client snapshots; retained handles still fold new events on demand using the original unit.', methods: [ { - signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): () => void', - description: 'Register one domain\'s unit. The registration is an effect on the calling context\'s fiber: disposing the fiber (or calling the returned disposer) removes the key — and the unit\'s cached cells — from subsequent drives and snapshots.', + signature: 'register< K extends keyof SessionProjectionMap, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'> & { wire: NonNullable[\'wire\']> }, ): Promise>', + description: 'Register one domain\'s unit on the calling context\'s fiber. Fiber or handle disposal releases one reference; the final release removes live participation. Retained handles remain readable after release. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], - returns: 'the exact disposer that unregisters this unit.', + returns: 'a ready handle with synchronous state reads and the effect disposer.', }, { - signature: 'register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'>, ): () => void', - description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit.', + signature: 'register< K extends Exclude, S extends SessionProjectionStateMap[K], >( definition: Omit, \'wire\'>, ): Promise>', + description: 'Register one host-only unit. Its state is omitted from client snapshots and always checkpointed like every other unit. Callers must await registration before publishing consumers; registration may perform asynchronous work.', parameters: [{ name: 'definition', description: 'key, state schema, pure unit functions, and stateVersion.' }], - returns: 'the exact disposer that unregisters this unit.', + returns: 'a ready handle with synchronous state reads and the effect disposer.', }, { signature: 'onChanged(listener: ProjectionChangeListener): () => void', @@ -2224,12 +2224,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [{ name: 'listener', description: 'called once per client-visible unit whose raw view changed by `Object.is`, per committed event.' }], returns: 'the exact disposer that unsubscribes.', }, - { - signature: 'stateOf( session: Session, key: K, ): SessionProjectionStateMap[K] | undefined', - description: 'Read one unit\'s current host state after materializing every registered unit at the Session cursor. Unrelated wire views are not produced. The returned value is live; callers must not mutate it.', - parameters: [{ name: 'session', description: 'the session whose state is read.' }, { name: 'key', description: 'the registered unit key.' }], - returns: 'current state, or `undefined` when the key is not registered.', - }, { signature: 'snapshot( session: Session, keys?: readonly Extract[], ): ProjectionSnapshot', description: 'One consistent cut over every registered client-visible unit for one session, read from the watermark cache (missing cells fold lazily over the in-memory log). Fully synchronous — every value and `asOfSeq` reflect the same log position. Each value passes its unit\'s `viewSchema` before leaving.', @@ -6336,6 +6330,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ProjectionDefinition', declaration: 'export interface ProjectionDefinition {\n key: K;\n stateSchema: ZodType;\n init(header: SessionHeader, inheritedEventCount: SessionLogOffset): NoInfer;\n apply(state: NoInfer, event: SessionEvent): NoInfer;\n wire?: K extends keyof SessionProjectionMap ? {\n viewSchema: ZodType;\n view(state: NoInfer): SessionProjectionMap[K];\n } : never;\n stateVersion: number;\n}', }, + { + name: 'ProjectionHandle', + declaration: 'export interface ProjectionHandle {\n stateOf(session: Session): S;\n dispose(): void | Promise;\n}', + }, { name: 'ProjectionSnapshot', declaration: 'export interface ProjectionSnapshot {\n asOfSeq: SessionSeqCursor;\n values: Partial;\n}', diff --git a/scripts/gen-config-catalog.spec.ts b/scripts/gen-config-catalog.spec.ts index 7248df7b98..4aed5f9782 100644 --- a/scripts/gen-config-catalog.spec.ts +++ b/scripts/gen-config-catalog.spec.ts @@ -97,6 +97,31 @@ export function apply(ctx: unknown, config: Config): void {} expect(new Set(provider?.schemaKeys)).toEqual(new Set(['mode', 'headless', 'endpoint'])) }) + it.each([false, true])('checks a class Config imported from a sibling module (invalid field: %s)', (invalid) => { + const { root, write } = fixture() + write('packages/test/provider/src/config.ts', ` +import Schema from '@deepseek-ai/schemastery' +export interface Config { + /** Maximum result count. */ + limit: number +} +export const Config = Schema.object({ ${invalid ? 'hidden' : 'limit'}: Schema.number() }) +`) + write('packages/test/provider/src/index.ts', ` +import { Config } from './config.ts' +export default class Provider { + static Config = Config + constructor(ctx: unknown, config: Config) {} +} +`) + if (invalid) { + expect(() => collectConfigCatalog(root)).toThrow("schema validates key 'hidden' but config type 'Config' declares no such member") + } else { + const provider = collectConfigCatalog(root).find(entry => entry.pkg === '@test/provider') + expect(provider?.schemaKeys).toEqual(['limit']) + } + }) + it('rejects type-only imports used as runtime schemas', () => { const { root, write } = fixture() write('packages/test/provider/src/index.ts', ` diff --git a/scripts/gen-config-catalog.ts b/scripts/gen-config-catalog.ts index 60a31c9fdb..df0ac078d4 100644 --- a/scripts/gen-config-catalog.ts +++ b/scripts/gen-config-catalog.ts @@ -504,7 +504,7 @@ function unwrapExpr(expr: ts.Expression): ts.Expression { * packages whose schemas an intersect composes. A key path is the top-level * key or a nested path through object/array compositions (`agents[].id`). * Handles object/union calls, chained refinements, named const schema imports - * through public workspace source paths, and `z.intersect([X.Config, …])`; errors on + * through package-relative or public workspace source paths, and `z.intersect([X.Config, …])`; errors on * anything else, so a schema the walk cannot see fails the gate instead of * silently thinning it. Nested values that are neither `object` nor `array` * compositions (primitives, unions, dynamic-key dicts) contribute no paths. diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 89b1432bc4..0113d16160 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -805,6 +805,7 @@ export const LINK_MAP: Readonly> = { WorkflowRunInfo: 'workflow.md', WorkflowStartRequest: 'workflow.md', ProjectionDefinition: 'session-projection.md', + ProjectionHandle: 'session-projection.md', SessionProjectionMap: 'session-projection.md', SessionProjectionStateMap: 'session-projection.md', ProjectionChangeListener: 'session-projection.md', diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 37f21e08c1..4218ec3c8c 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2116,6 +2116,11 @@ "symbol": "PresetSpec", "source": "packages/interaction/permission-presets/src/index.ts" }, + { + "doc": "docs/subsystems/session-projection.md", + "symbol": "ProjectionHandle", + "source": "packages/session/session-projection/src/index.ts" + }, { "doc": "docs/subsystems/session-projection.md", "symbol": "ProjectionDefinition", From 99f36601205c48817eb86fa2b684d7bd4a076c31 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:08:14 +0800 Subject: [PATCH 028/287] refactor(hooks-codex): simplify hook payload construction --- .../experimental/hooks-codex/src/index.ts | 64 +++++++++++-------- 1 file changed, 39 insertions(+), 25 deletions(-) diff --git a/packages/experimental/hooks-codex/src/index.ts b/packages/experimental/hooks-codex/src/index.ts index 6159a90dda..356e64393d 100644 --- a/packages/experimental/hooks-codex/src/index.ts +++ b/packages/experimental/hooks-codex/src/index.ts @@ -28,7 +28,7 @@ declare module '@deepseek-ai/dsh-llm' { import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import type { UserMessage } from '@deepseek-ai/dsh-session' -import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' +import type { PostToolDecision, PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' import { appendHookInvoked, appendHookResult, @@ -113,6 +113,10 @@ export async function apply(ctx: Context, config: Config): Promise { const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const model = config.model ?? '' + function lastTurn(agent: Agent | undefined): number { + return agent === undefined ? 0 : turnBoundary.stateOf(agent.session).lastTurn + } + // SessionStart is the one emit-shaped (detached) point Codex has: track its // run chains so disposal aborts a still-running hook process and drains the // continuation (docs/defensive-patterns/general.md: dispose must reach quiescence). @@ -235,7 +239,10 @@ export async function apply(ctx: Context, config: Config): Promise { // PreToolUse → PreToolDecision. Codex blocks only (no allow/ask honored). ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) - const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + const payload = toolPayload(exec, 'PreToolUse', model, turn) + const merged = await runPoint('PreToolUse', exec.name, payload, { + ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal, + }) if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() }) @@ -243,8 +250,14 @@ export async function apply(ctx: Context, config: Config): Promise { // PostToolUse → PostToolDecision (block with feedback, or attach context). ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) + const payload = { + ...toolPayload(exec, 'PostToolUse', model, turn), + tool_response: blocksToText(result.content), + } /* jscpd:ignore-start */ - const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal }) + const merged = await runPoint('PostToolUse', exec.name, payload, { + ...exec.agent ? { agent: exec.agent } : {}, turn, signal: exec.signal, + }) const context = contextFrom(merged) if (merged.decision === 'deny') { return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContexts: [context] } : {} } @@ -268,7 +281,13 @@ export async function apply(ctx: Context, config: Config): Promise { // avoid continuing the same turn indefinitely. It is always false here, so an // unconditionally blocking hook force-continues every step until it self-limits. ctx.on('agent/turn-stopping', async ({ agent, turn, signal }): Promise => { - const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn, signal }) + const payload = { + ...base(agent, 'Stop', model), + turn_id: String(lastTurn(agent)), + stop_hook_active: false, + last_assistant_message: null, + } + const merged = await runPoint('Stop', '', payload, { agent, turn, signal }) if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, // empty stderr) still forces it — fall back to a generic steering line @@ -277,27 +296,6 @@ export async function apply(ctx: Context, config: Config): Promise { agent.steer(createUserMessage({ content: [{ type: 'text', text }], source: CONTEXT_SOURCE })) } }) - - function lastTurn(agent: Agent | undefined): number { - if (!agent) return 0 - return turnBoundary.stateOf(agent.session).lastTurn - } - - function turnBase(agent: Agent | undefined, event: string, model: string): Record { - return { ...base(agent, event, model), turn_id: String(lastTurn(agent)) } - } - - function preToolPayload(exec: ToolExecution, model: string): Record { - // `tool_name` is the REAL tool name (matching the `exec.name` matcher subject); - // a hardcoded constant would disagree with what the matcher tests and make a - // config's tool matcher never fire. `tool_input` keeps Codex's `{ command }` - // shape (its shell payload), derived from the call's `command` arg when present. - return { ...turnBase(exec.agent, 'PreToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId } - } - - function postToolPayload(exec: ToolExecution, result: ToolExecutionResult, model: string): Record { - return { ...turnBase(exec.agent, 'PostToolUse', model), tool_name: exec.name, tool_input: { command: commandOf(exec.arguments) }, tool_use_id: exec.callId, tool_response: blocksToText(result.content) } - } } // --- Codex DIALECT payloads: snake_case, model on every event, turn_id on @@ -325,6 +323,22 @@ function base(agent: Agent | undefined, event: string, model: string): Record { + return { + ...base(exec.agent, event, model), + turn_id: String(turn), + tool_name: exec.name, + tool_input: { command: commandOf(exec.arguments) }, + tool_use_id: exec.callId, + } +} + /** Extract a `command` string from a tool call's parsed arguments, else ''. */ function commandOf(args: unknown): string { if (typeof args === 'object' && args !== null && 'command' in args) { From 5b401b27a5a7ed2596e6294bbd64603d4d57669d Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:08:18 +0800 Subject: [PATCH 029/287] refactor(tool-session-query): pass projection handles to operations --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- .../tool-session-query/src/index.ts | 16 +- .../tool-session-query/src/operations.ts | 420 ++++++++---------- .../src/workspace-access.ts | 19 + 5 files changed, 215 insertions(+), 244 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index eb2a6a1ea2..8460cc7395 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3921,7 +3921,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:27`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index aacdae8e58..ffd0313631 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3923,7 +3923,7 @@ export interface Config { ## `@deepseek-ai/dsh-tool-session-query` - `inject`: `tools` · `systemPrompt` · `sessionQuery` · `sessionProjections` -- `source`: [`packages/experimental/tool-session-query/src/index.ts:26`](../packages/experimental/tool-session-query/src/index.ts) +- `source`: [`packages/experimental/tool-session-query/src/index.ts:27`](../packages/experimental/tool-session-query/src/index.ts) ```ts config-catalog /** Deployment-owned search count and timeout bounds. */ diff --git a/packages/experimental/tool-session-query/src/index.ts b/packages/experimental/tool-session-query/src/index.ts index 8bbf6c3bff..2337b43402 100644 --- a/packages/experimental/tool-session-query/src/index.ts +++ b/packages/experimental/tool-session-query/src/index.ts @@ -5,11 +5,12 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' import { toolInput } from './input.ts' -import { SessionQueryOperations } from './operations.ts' +import { operations } from './operations.ts' import { presentation } from './presentation.ts' export const name = 'tool-session-query' @@ -58,8 +59,7 @@ const PROMPT_TEXT = * @returns when the tools and their projection reader are ready. */ export async function apply(ctx: Context, config: Config): Promise { - const operations = new SessionQueryOperations(ctx) - await operations.initialize() + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) const resolved = resolveConfig(config) ctx.systemPrompt.section({ name: 'tool:session-query', @@ -73,7 +73,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.sessionSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeSessionSearch(args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeSessionSearch(ctx, turnBoundary, args, exec, resolved.maxSearchResults), presentCall: presentation.presentSessionSearchCall, })) @@ -83,7 +83,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.eventSearchParameters, output: TEXT_OUTPUT, timeoutMs: resolved.searchTimeoutMs, - execute: (args, exec) => operations.executeEventSearch(args, exec, resolved.maxSearchResults), + execute: (args, exec) => operations.executeEventSearch(ctx, turnBoundary, args, exec, resolved.maxSearchResults), presentCall: presentation.presentEventSearchCall, })) @@ -93,7 +93,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: toolInput.targetSessionParameter, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeSessionTrace(args, exec), + execute: (args, exec) => operations.executeSessionTrace(ctx, turnBoundary, args, exec), presentCall: presentation.presentSessionTraceCall, })) @@ -106,7 +106,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventTrace(args, exec), + execute: (args, exec) => operations.executeEventTrace(ctx, turnBoundary, args, exec), presentCall: args => presentation.presentEventTargetCall('Trace event', args), })) @@ -121,7 +121,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: TEXT_OUTPUT, isConcurrencySafe: () => true, - execute: (args, exec) => operations.executeEventRead(args, exec), + execute: (args, exec) => operations.executeEventRead(ctx, turnBoundary, args, exec), presentCall: args => presentation.presentEventTargetCall('Read event', args), })) } diff --git a/packages/experimental/tool-session-query/src/operations.ts b/packages/experimental/tool-session-query/src/operations.ts index 4c05480221..2727447537 100644 --- a/packages/experimental/tool-session-query/src/operations.ts +++ b/packages/experimental/tool-session-query/src/operations.ts @@ -4,9 +4,9 @@ * @module @deepseek-ai/dsh-tool-session-query/operations */ -import { turnBoundaryProjectionDefinition, type TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' -import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import type { Context } from '@deepseek-ai/cordis' +import type { TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionSeq } from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -54,249 +54,192 @@ interface SearchCollection { readonly capped: boolean } -/** Session-history tool operations over the caller’s current turn state. */ -export class SessionQueryOperations { - private turnBoundary!: ProjectionHandle - - constructor(private readonly ctx: Context) {} - - /** - * Acquire projection readers before exposing dependent operations. - * @returns when all required registrations are ready. - */ - async initialize(): Promise { - this.turnBoundary = await this.ctx.sessionProjections.register(turnBoundaryProjectionDefinition) +async function executeSessionSearch( + ctx: Context, + turnBoundary: ProjectionHandle, + args: SessionSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const cwd = caller.header.cwd + if (cwd === undefined) { + throw new HarnessError( + 'cross-session search is unavailable because the caller session has no workspace', + 'SESSION_QUERY_TOOL_UNAUTHORIZED', + ) } - - private callerOf(exec: ToolRunContext) { - const agent = exec.agent - if (agent === undefined) { - throw new HarnessError( - 'session query tools require an agent-bound caller', - 'SESSION_QUERY_TOOL_MISSING_AGENT', - ) - } - return { - id: agent.session.id, - header: agent.session.header, - boundary: this.turnBoundary.stateOf(agent.session), - } + const query = toolInput.normalizeQuery(args.query) + const sessionFilters = toolInput.buildSessionFilters(args) + const eventFilters = toolInput.buildEventFilters({ + seqFrom: args.event_seq_from, + seqTo: args.event_seq_to, + timeFrom: args.event_time_from, + timeTo: args.event_time_to, + eventTypes: args.event_types, + surfaces: args.event_surfaces, + }) + const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) + if (requestedParentIds !== undefined || args.include_root_sessions === true) { + const authorizedParentIds = requestedParentIds === undefined + ? new Set() + : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) + const parentValues: Array = requestedParentIds + ?.filter(id => authorizedParentIds.has(id)) ?? [] + if (args.include_root_sessions === true) parentValues.push(null) + if (parentValues.length === 0) return presentation.formatEmptySessionSearch() + sessionFilters.push({ kind: 'parent', values: parentValues }) } + sessionFilters.push({ kind: 'cwd', values: [cwd] }) + const collected = await collectPages( + maxResults, + exec.signal, + cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => + ctx.sessionQuery.searchSessions({ + query, + sessionFilters, + eventFilters, + ...cursor === undefined ? {} : { cursor }, + }, { signal: exec.signal })), + hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), + ) - /** - * Search other Sessions within the caller’s authorized workspace. - * @param args - Session and event filters. - * @param exec - Agent-bound caller and cancellation signal. - * @param maxResults - Maximum number of authorized matches to return. - * @returns Formatted authorized Session matches and truncation status. - */ - async executeSessionSearch( - args: SessionSearchArgs, - exec: ToolRunContext, - maxResults: number, - ): Promise { - const ctx = this.ctx - const caller = this.callerOf(exec) - const cwd = caller.header.cwd - if (cwd === undefined) { - throw new HarnessError( - 'cross-session search is unavailable because the caller session has no workspace', - 'SESSION_QUERY_TOOL_UNAUTHORIZED', - ) - } - const query = toolInput.normalizeQuery(args.query) - const sessionFilters = toolInput.buildSessionFilters(args) - const eventFilters = toolInput.buildEventFilters({ - seqFrom: args.event_seq_from, - seqTo: args.event_seq_to, - timeFrom: args.event_time_from, - timeTo: args.event_time_to, - eventTypes: args.event_types, - surfaces: args.event_surfaces, - }) - const requestedParentIds = toolInput.materializeParentSessionIds(args.parent_session_ids) - if (requestedParentIds !== undefined || args.include_root_sessions === true) { - const authorizedParentIds = requestedParentIds === undefined - ? new Set() - : await workspaceAccess.authorizeSessionIds(ctx, caller, requestedParentIds, exec.signal) - const parentValues: Array = requestedParentIds - ?.filter(id => authorizedParentIds.has(id)) ?? [] - if (args.include_root_sessions === true) parentValues.push(null) - if (parentValues.length === 0) return presentation.formatEmptySessionSearch() - sessionFilters.push({ kind: 'parent', values: parentValues }) - } - sessionFilters.push({ kind: 'cwd', values: [cwd] }) - const collected = await collectPages( - maxResults, - exec.signal, - cursor => serviceBoundary.call(ctx, exec.signal, 'session search', () => - ctx.sessionQuery.searchSessions({ + const parentIds = collected.items + .map(hit => hit.header.parentSession) + .filter((id): id is SessionId => id !== undefined) + const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) + const titles = await workspaceAccess.readTitles( + ctx, + caller, + collected.items.map(hit => hit.header.id), + exec.signal, + ) + return presentation.formatSessionSearch(collected, titles, authorizedParents) +} + +async function executeEventSearch( + ctx: Context, + turnBoundary: ProjectionHandle, + args: EventSearchArgs, + exec: ToolRunContext, + maxResults: number, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const query = toolInput.normalizeQuery(args.query) + const range = toolInput.sequenceRange(args.seq_from, args.seq_to) + if (sessionId === caller.id) { + const stepStartSeq = caller.boundary.lastStepStartSeq + // `null` (no step started yet) caps the range to a degenerate `to` the + // filter validation rejects — the loop never runs tools outside a step. + range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) + } + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + if (range.from !== undefined && range.to !== undefined && range.from > range.to) { + return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) + } + const filters = toolInput.buildEventFilters({ + seqFrom: range.from, + seqTo: range.to, + timeFrom: args.time_from, + timeTo: args.time_to, + eventTypes: args.event_types, + surfaces: args.surfaces, + }) + const collected = await collectPages( + maxResults, + exec.signal, + async (cursor): Promise => { + const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => + ctx.sessionQuery.searchEvents({ + sessionId, query, - sessionFilters, - eventFilters, + filters, ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })), - hit => hit.header.id !== caller.id && workspaceAccess.recordAuthorized(hit, caller), - ) + }, { signal: exec.signal })) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) + return page + }, + () => true, + ) + return presentation.formatEventSearch(sessionId, title, collected) +} - const parentIds = collected.items - .map(hit => hit.header.parentSession) - .filter((id): id is SessionId => id !== undefined) - const authorizedParents = await workspaceAccess.authorizeSessionIds(ctx, caller, parentIds, exec.signal) - const titles = await workspaceAccess.readTitles( - ctx, - caller, - collected.items.map(hit => hit.header.id), - exec.signal, - ) - return presentation.formatSessionSearch(collected, titles, authorizedParents) - } +async function executeSessionTrace( + ctx: Context, + turnBoundary: ProjectionHandle, + args: SessionTargetArgs, + exec: ToolRunContext, +): Promise { + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => + ctx.sessionQuery.traceSession(sessionId, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) - /** - * Search authorized Session events, excluding the caller’s current step. - * @param args - Target Session and event filters. - * @param exec - Agent-bound caller and cancellation signal. - * @param maxResults - Maximum number of authorized matches to return. - * @returns Formatted matching events and truncation status. - */ - async executeEventSearch( - args: EventSearchArgs, - exec: ToolRunContext, - maxResults: number, - ): Promise { - const ctx = this.ctx - const caller = this.callerOf(exec) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const query = toolInput.normalizeQuery(args.query) - const range = toolInput.sequenceRange(args.seq_from, args.seq_to) - if (sessionId === caller.id) { - const stepStartSeq = caller.boundary.lastStepStartSeq - // `null` (no step started yet) caps the range to a degenerate `to` the - // filter validation rejects — the loop never runs tools outside a step. - range.to = Math.min(range.to ?? Number.MAX_SAFE_INTEGER, (stepStartSeq ?? 0) - 1) + const ancestors: SessionRecord[] = [] + let ancestorBoundary = false + for (const ancestor of trace.ancestors) { + if (!workspaceAccess.recordAuthorized(ancestor, caller)) { + ancestorBoundary = true + break } - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - if (range.from !== undefined && range.to !== undefined && range.from > range.to) { - return presentation.formatEventSearch(sessionId, title, { items: [], capped: false }) - } - const filters = toolInput.buildEventFilters({ - seqFrom: range.from, - seqTo: range.to, - timeFrom: args.time_from, - timeTo: args.time_to, - eventTypes: args.event_types, - surfaces: args.surfaces, - }) - const collected = await collectPages( - maxResults, - exec.signal, - async (cursor): Promise => { - const page = await serviceBoundary.call(ctx, exec.signal, 'event search', () => - ctx.sessionQuery.searchEvents({ - sessionId, - query, - filters, - ...cursor === undefined ? {} : { cursor }, - }, { signal: exec.signal })) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, page.session) - return page - }, - () => true, - ) - return presentation.formatEventSearch(sessionId, title, collected) + ancestors.push(ancestor) } + if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true + const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) + const visibleIds = [ + trace.target.header.id, + ...ancestors.map(record => record.header.id), + ...workspaceAccess.descendantIds(descendants), + ] + const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) + return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) +} - /** - * Trace Session lineage within the caller’s workspace permissions. - * @param args - Optional target Session id; defaults to the caller. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted authorized ancestors and descendants. - */ - async executeSessionTrace( - args: SessionTargetArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - const caller = this.callerOf(exec) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await serviceBoundary.call(ctx, exec.signal, 'session lineage trace', () => - ctx.sessionQuery.traceSession(sessionId, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.target.header) +async function executeEventTrace( + ctx: Context, + turnBoundary: ProjectionHandle, + args: EventTargetArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => + ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventTrace(sessionId, title, trace) +} - const ancestors: SessionRecord[] = [] - let ancestorBoundary = false - for (const ancestor of trace.ancestors) { - if (!workspaceAccess.recordAuthorized(ancestor, caller)) { - ancestorBoundary = true - break - } - ancestors.push(ancestor) - } - if (ancestors.length === trace.ancestors.length && !trace.complete) ancestorBoundary = true - const descendants = workspaceAccess.authorizeDescendants(trace.descendants, caller) - const visibleIds = [ - trace.target.header.id, - ...ancestors.map(record => record.header.id), - ...workspaceAccess.descendantIds(descendants), - ] - const titles = await workspaceAccess.readTitles(ctx, caller, visibleIds, exec.signal) - return presentation.formatSessionTrace(trace, ancestors, ancestorBoundary, descendants, titles) - } - - /** - * Trace an event after authorizing its owning Session. - * @param args - Target Session and event sequence. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted event lineage. - */ - async executeEventTrace( - args: EventTargetArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - const caller = this.callerOf(exec) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await serviceBoundary.call(ctx, exec.signal, 'event trace', () => - ctx.sessionQuery.traceEvent({ sessionId, seq }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, trace.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventTrace(sessionId, title, trace) - } - - /** - * Read an event window after authorizing its owning Session. - * @param args - Target event and surrounding event counts. - * @param exec - Agent-bound caller and cancellation signal. - * @returns Formatted event window. - */ - async executeEventRead( - args: EventReadArgs, - exec: ToolRunContext, - ): Promise { - const ctx = this.ctx - toolInput.assertNonNegativeSafeInteger('seq', args.seq) - const seq = SessionSeq(args.seq) - if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) - if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) - const caller = this.callerOf(exec) - const sessionId = workspaceAccess.targetId(args, caller) - await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) - const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => - ctx.sessionQuery.readEvent({ - sessionId, - seq, - ...args.before === undefined ? {} : { before: args.before }, - ...args.after === undefined ? {} : { after: args.after }, - }, exec.signal)) - workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) - const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) - return presentation.formatEventRead(sessionId, title, window) - } +async function executeEventRead( + ctx: Context, + turnBoundary: ProjectionHandle, + args: EventReadArgs, + exec: ToolRunContext, +): Promise { + toolInput.assertNonNegativeSafeInteger('seq', args.seq) + const seq = SessionSeq(args.seq) + if (args.before !== undefined) toolInput.assertNonNegativeSafeInteger('before', args.before) + if (args.after !== undefined) toolInput.assertNonNegativeSafeInteger('after', args.after) + const caller = workspaceAccess.callerOf(exec, turnBoundary) + const sessionId = workspaceAccess.targetId(args, caller) + await workspaceAccess.authorizeTarget(ctx, caller, sessionId, exec.signal) + const window = await serviceBoundary.call(ctx, exec.signal, 'event read', () => + ctx.sessionQuery.readEvent({ + sessionId, + seq, + ...args.before === undefined ? {} : { before: args.before }, + ...args.after === undefined ? {} : { after: args.after }, + }, exec.signal)) + workspaceAccess.assertObservedTargetAuthorized(caller, sessionId, window.session) + const title = await workspaceAccess.readTitle(ctx, caller, sessionId, exec.signal) + return presentation.formatEventRead(sessionId, title, window) } async function collectPages( @@ -333,3 +276,12 @@ async function collectPages( cursor = page.nextCursor } } + +/** Five model-facing session-query operation implementations. */ +export const operations = { + executeSessionSearch, + executeEventSearch, + executeSessionTrace, + executeEventTrace, + executeEventRead, +} diff --git a/packages/experimental/tool-session-query/src/workspace-access.ts b/packages/experimental/tool-session-query/src/workspace-access.ts index 4f11e64f38..836763cb3e 100644 --- a/packages/experimental/tool-session-query/src/workspace-access.ts +++ b/packages/experimental/tool-session-query/src/workspace-access.ts @@ -6,6 +6,7 @@ import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' +import { HarnessError } from '@deepseek-ai/dsh-llm' import { type SessionHeader, type SessionId as SessionIdValue, @@ -15,6 +16,8 @@ import type { SessionLineageNode, SessionRecord, } from '@deepseek-ai/dsh-session-query' +import type { ToolRunContext } from '@deepseek-ai/dsh-tools' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { serviceBoundary } from './service-boundary.ts' interface Caller { @@ -50,6 +53,21 @@ interface DescendantVisit { readonly next: DescendantVisit | undefined } +function callerOf(exec: ToolRunContext, turnBoundary: ProjectionHandle): Caller { + const agent = exec.agent + if (agent === undefined) { + throw new HarnessError( + 'session query tools require an agent-bound caller', + 'SESSION_QUERY_TOOL_MISSING_AGENT', + ) + } + return { + id: agent.session.id, + header: agent.session.header, + boundary: turnBoundary.stateOf(agent.session), + } +} + function targetId(args: { readonly session_id?: string }, caller: Caller): SessionIdValue { return args.session_id === undefined ? caller.id : brandString(args.session_id) } @@ -224,6 +242,7 @@ function titleText(view: TitleView): string { /** Workspace-scoped caller authorization, title access, and lineage projection. */ export const workspaceAccess = { + callerOf, targetId, authorizeTarget, recordAuthorized, From a9bbe0c220e398cceb036c346c4b93e323f8bce6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:08:25 +0800 Subject: [PATCH 030/287] fix(session-projection): always return a promise from handle disposal --- docs/subsystems/session-projection.md | 7 +++++-- docs/subsystems/session-projection.zh.md | 7 +++++-- .../session-projection-handles/guide.i18n.yaml | 4 ++-- .../session-projection-handles/guide.md | 2 +- .../session-projection-handles/guide.zh.md | 2 +- packages/extensions/tool-cordis/src/api-catalog.ts | 2 +- packages/session/session-projection/README.i18n.yaml | 4 ++-- packages/session/session-projection/README.md | 2 +- packages/session/session-projection/README.zh.md | 2 +- packages/session/session-projection/src/index.ts | 11 ++++++++--- .../tests/registration-reader.spec.ts | 7 ++++--- 11 files changed, 31 insertions(+), 19 deletions(-) diff --git a/docs/subsystems/session-projection.md b/docs/subsystems/session-projection.md index 5da8044825..b7d8fb2bad 100644 --- a/docs/subsystems/session-projection.md +++ b/docs/subsystems/session-projection.md @@ -18,8 +18,11 @@ interface ProjectionHandle { * @returns the projection state at the Session's current cursor. */ stateOf(session: Session): S - /** Unregister from global drives and snapshots without invalidating this reader. */ - dispose(): void | Promise + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise } ``` diff --git a/docs/subsystems/session-projection.zh.md b/docs/subsystems/session-projection.zh.md index c2c952b8fc..224a5dfdb6 100644 --- a/docs/subsystems/session-projection.zh.md +++ b/docs/subsystems/session-projection.zh.md @@ -18,8 +18,11 @@ interface ProjectionHandle { * @returns the projection state at the Session's current cursor. */ stateOf(session: Session): S - /** Unregister from global drives and snapshots without invalidating this reader. */ - dispose(): void | Promise + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise } ``` diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml index 7d864c5be2..5d03bdc8c8 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.i18n.yaml @@ -12,5 +12,5 @@ en: 06c3d4ee9031fb7e zh: cf2b664bfdafe837 /session-projection-registration-handles/migration: - en: 0619d4d16272fa0c - zh: c3b0bb2c9d3ecb57 + en: 50cde783ecaa35e8 + zh: 42e5574f58b049d6 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md index f746d9170d..20d11a2b03 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.md @@ -16,6 +16,6 @@ English | [中文](guide.zh.md) 1. Declare `sessionProjections` as a required plugin injection and mount `SessionProjectionRegistry` before consumers, including `SubagentRuntime` in custom compositions. 2. In an asynchronous function-plugin `apply()` or service `[Service.init]()`, await `ctx.sessionProjections.register(definition)` and retain the returned handle before publishing dependent operations. Keep existing function-plugin namespace exports. -3. Replace `registry.stateOf(session, key)` with `handle.stateOf(session)`. Import the owning package's projection definition when a reader needs to acquire a handle itself. Replace stored unregister callbacks with `handle.dispose`; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. +3. Replace `registry.stateOf(session, key)` with `handle.stateOf(session)`. Import the owning package's projection definition when a reader needs to acquire a handle itself. Replace stored unregister callbacks with `handle.dispose`, which always returns `Promise`, and await explicit disposal; fiber disposal still releases registrations automatically. Await registration when handling initialization failures. 4. Replace `installModelSelectionProjection(ctx)` with `await ctx.sessionProjections.register(modelSelectionProjection)`, importing the definition from `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts`. 5. Typecheck custom plugins, then verify activation, a Session state read, and owner disposal. A released reader remains usable, while the released unit disappears from new snapshots once its final registration is gone. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md index d6a19c5631..fd52f33077 100644 --- a/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md +++ b/docs/upgrade-guide/v0.2.1-alpha.2/session-projection-handles/guide.zh.md @@ -16,6 +16,6 @@ description: "Session 投影注册改为异步,并返回保留读取能力的 1. 将 `sessionProjections` 声明为插件的必需注入,并在消费者之前挂载 `SessionProjectionRegistry`,包括自定义组合中的 `SubagentRuntime`。 2. 在函数插件的异步 `apply()` 或服务的 `[Service.init]()` 中,等待 `ctx.sessionProjections.register(definition)`,在发布依赖操作前保留返回的句柄。保留既有的函数插件命名空间导出。 -3. 将 `registry.stateOf(session, key)` 替换为 `handle.stateOf(session)`。读取方需要自行取得句柄时,导入归属包的投影定义。将保存的注销回调替换为 `handle.dispose`;fiber 释放仍会自动解除注册。处理初始化错误时须等待注册完成。 +3. 将 `registry.stateOf(session, key)` 替换为 `handle.stateOf(session)`。读取方需要自行取得句柄时,导入归属包的投影定义。将保存的注销回调替换为始终返回 `Promise` 的 `handle.dispose`,并等待显式释放完成;fiber 释放仍会自动解除注册。处理初始化错误时须等待注册完成。 4. 将 `installModelSelectionProjection(ctx)` 替换为 `await ctx.sessionProjections.register(modelSelectionProjection)`,从 `@deepseek-ai/dsh-api-session-controller/src/model-selection-projection.ts` 导入该定义。 5. 对自定义插件执行类型检查,再验证激活、Session 状态读取与 owner 释放。已释放的读取句柄仍可使用,而投影单元在最后一次注册解除后会从新快照中消失。 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 4e5b3862ed..ceac4dd3a7 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -6332,7 +6332,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ProjectionHandle', - declaration: 'export interface ProjectionHandle {\n stateOf(session: Session): S;\n dispose(): void | Promise;\n}', + declaration: 'export interface ProjectionHandle {\n stateOf(session: Session): S;\n dispose(): Promise;\n}', }, { name: 'ProjectionSnapshot', diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index cf0fd8eeeb..90321e2e39 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/README.i18n.yaml @@ -24,8 +24,8 @@ en: 09758d1475fad86e zh: 473637cb34aebe72 /deepseek-ai-dsh-session-projection/use-this-package/register-and-read: - en: e10e44c46035dbdb - zh: dff312b52f4ec2ce + en: 52120bc6e18a8da8 + zh: c45ca6a83a6fcd4e /deepseek-ai-dsh-session-projection/use-this-package/persisted-checkpoints: en: 45f1c05fb7e08180 zh: f8d62c749899651a diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 61e2677e47..9a1330ed60 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -55,7 +55,7 @@ const definition = { ### Register and read -`await register(definition)` installs the unit and returns a handle with synchronous `stateOf(session)` reads and `dispose()`. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. +`await register(definition)` installs the unit and returns a handle with synchronous `stateOf(session)` reads and `dispose(): Promise`. Await `dispose()` to release the registration; repeated calls are safe. Registrants with the same key and `stateVersion` share cells; incompatible or invalid versions reject registration. Registration belongs to the calling fiber. The last unregister removes the unit from global event drives, snapshots, checkpoints, and change notifications. Existing handles remain readable: each retains its original unit and folds new Session events on demand, even if another version later registers the same key. Reading a handle does not initialize unrelated units or register it again. Carriers use `snapshot(session)` for a consistent client-view cut and `onChanged(listener)` for changes to registered units. ```text const projection = await ctx.sessionProjections.register(definition) diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 2a0ac169d7..865d9e8537 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -55,7 +55,7 @@ const definition = { ### 注册与读取 -`await register(definition)` 安装单元,返回提供同步 `stateOf(session)` 读取和 `dispose()` 的 handle。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 +`await register(definition)` 安装单元,返回提供同步 `stateOf(session)` 读取和 `dispose(): Promise` 的 handle。等待 `dispose()` 完成即可释放注册;重复调用是安全的。具有相同 key 和 `stateVersion` 的注册方共享 cell;版本不兼容或非法时注册会拒绝。注册归调用方 fiber 所有。最后一个注册方注销后,单元退出全局事件驱动、快照、检查点和变更通知。已有 handle 仍可读取:它保留原有单元,按需折叠新增的 Session 事件,即使同一 key 后来注册了其他版本也不受影响。读取 handle 不会初始化无关单元,也不会重新注册。载体用 `snapshot(session)` 读取一致的客户端视图切面,用 `onChanged(listener)` 订阅已注册单元的变更。 ```text const projection = await ctx.sessionProjections.register(definition) diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 9a27c2b98a..1790882394 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -46,8 +46,11 @@ export interface ProjectionHandle { * @returns the projection state at the Session's current cursor. */ stateOf(session: Session): S - /** Unregister from global drives and snapshots without invalidating this reader. */ - dispose(): void | Promise + /** + * Unregister from global drives and snapshots without invalidating this reader. + * @returns a promise that resolves when this registration is released. + */ + dispose(): Promise } /** @@ -303,7 +306,9 @@ export class SessionProjectionRegistry extends Service { await Promise.resolve() this.ctx.fiber.assertActive() return { - dispose, + async dispose() { + await dispose() + }, stateOf: (session) => { return this.cellFor(registration, session).state as S }, diff --git a/packages/session/session-projection/tests/registration-reader.spec.ts b/packages/session/session-projection/tests/registration-reader.spec.ts index 3ee393b85a..ab287d6110 100644 --- a/packages/session/session-projection/tests/registration-reader.spec.ts +++ b/packages/session/session-projection/tests/registration-reader.spec.ts @@ -98,10 +98,11 @@ describe('retained projection readers', () => { const retained = await ctx.sessionProjections.register(definition) const handle = await owner.ctx.sessionProjections.register(definition) if (release === 'twice') { - await handle.dispose() - await handle.dispose() + await expect(handle.dispose()).resolves.toBeUndefined() + await expect(handle.dispose()).resolves.toBeUndefined() } else { - await Promise.all([owner.dispose(), Promise.resolve(handle.dispose())]) + await Promise.all([owner.dispose(), expect(handle.dispose()).resolves.toBeUndefined()]) + await expect(handle.dispose()).resolves.toBeUndefined() } session.append('turn/start', { turn: 1 }) expect(ctx.sessionProjections.snapshot(session).values).toEqual({ 'test/reader': 1 }) From 96784dae411619183484de7db2816f6b67844aa6 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 13:20:04 +0800 Subject: [PATCH 031/287] refactor(tool-goal): keep authority checks on the original event scan --- packages/goal/tool-goal/README.i18n.yaml | 4 +- packages/goal/tool-goal/README.md | 3 +- packages/goal/tool-goal/README.zh.md | 3 +- packages/goal/tool-goal/package.json | 3 +- packages/goal/tool-goal/src/authority.ts | 147 ++++++++---------- packages/goal/tool-goal/src/index.ts | 13 +- .../goal/tool-goal/tests/tool-goal.spec.ts | 23 ++- pnpm-lock.yaml | 3 - 8 files changed, 82 insertions(+), 117 deletions(-) diff --git a/packages/goal/tool-goal/README.i18n.yaml b/packages/goal/tool-goal/README.i18n.yaml index 1d97f69a1b..1006276ef8 100644 --- a/packages/goal/tool-goal/README.i18n.yaml +++ b/packages/goal/tool-goal/README.i18n.yaml @@ -30,8 +30,8 @@ en: 4e9fdb5a89f4baa3 zh: 50d182467ae1f3a7 /deepseek-ai-dsh-tool-goal/understand-the-implementation/design: - en: 905e5e105efdf7a8 - zh: 3704176a160aabb1 + en: 3be550d69f7167f1 + zh: 0becdbd7b7395e40 /deepseek-ai-dsh-tool-goal/understand-the-implementation/source-map: en: 4d5c648787409868 zh: b4938bfe660f18aa diff --git a/packages/goal/tool-goal/README.md b/packages/goal/tool-goal/README.md index 9ab458268d..23b457e6c8 100644 --- a/packages/goal/tool-goal/README.md +++ b/packages/goal/tool-goal/README.md @@ -68,8 +68,7 @@ This section explains how the tools enforce authority and render output; the obs ### Design -- **Authority at execution.** Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. -- **Current-turn input projection.** A host-only projection retains human-input presence and admitted goal-round identifiers, resets at each turn boundary, and reconstructs these facts from committed events when registered. +- **Authority at execution.** The plugin awaits its turn-boundary reader before registering tools. Every call resolves the exact live agent, its inherited `AgentRegistry` initiator, running status, and an open turn; `create`, `edit`, `pause`, and `resume` additionally require an accepted `{ kind: 'user' }` message or steering event in a runtime-root agent's current turn. A durable paused goal fails the `resume` action with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. Durable fork lineage does not demote a resumed root; live subagent ownership does. - **Host attestation of human input.** `{ kind: 'user' }` is assigned by `Agent.followup()` and `steer()` when their caller omits a source, so plugins, schedulers, and other non-human producers must pass their own source rather than inheriting human authority. - **System-prompt guidance with the configured threshold.** The package registers one `tool:goal` system-prompt section whose fixed text interpolates `blockedAfterConsecutiveRounds`; the same value is the hard lower bound enforced at execution. - **Wrap-up context for terminal rounds.** A successful autonomous `complete` or `blocked` defers a closing `` or `` instruction so the model addresses the user once before the turn ends; direct-human mutations never defer this context. diff --git a/packages/goal/tool-goal/README.zh.md b/packages/goal/tool-goal/README.zh.md index 9e868061c2..e7d67715c4 100644 --- a/packages/goal/tool-goal/README.zh.md +++ b/packages/goal/tool-goal/README.zh.md @@ -68,8 +68,7 @@ kind: "package-reference" ### 设计 -- **执行时权限。** 每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 -- **当前轮输入投影。** 仅供 Host 使用的投影保存是否有人类输入及已接纳的目标轮次标识,在每个轮次边界重置,并在注册时从已提交事件重建这些事实。 +- **执行时权限。** 插件等待轮次边界读取句柄就绪后才注册工具。每次调用都解析精确活跃 agent、其继承的 `AgentRegistry` initiator、running 状态与开放轮次;`create`、`edit`、`pause` 和 `resume` 还要求运行时根 agent 的当前轮次中存在已接受的 `{ kind: 'user' }` 消息或 steering 事件。持久的 paused goal 会让 `resume` 以 `GOAL_TOOL_RESUME_PAUSED` 失败;面向用户的命令或 Web 控件拥有该转换。持久 fork 谱系不会降低已恢复根 agent 的等级;活跃 subagent 所有权会降低。 - **人类输入的宿主证明。** `Agent.followup()` 与 `steer()` 会在调用方省略 source 时分配 `{ kind: 'user' }`,因此插件、调度器与其他非人类生产方必须传入自己的 source,不能继承人类权限。 - **带配置阈值的系统提示词指引。** 本包注册一个 `tool:goal` 系统提示词章节,其固定文本插入 `blockedAfterConsecutiveRounds`;同一数值就是执行时强制执行的硬下限。 - **终局 Round 的结束上下文。** 成功的自主 `complete` 或 `blocked` 会延后一条 `` 或 `` 结束指令,让模型在轮次结束前向用户做一次交代;人类直接变更绝不会延后该上下文。 diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 65a9e48f4b..e5c0e9d8e7 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -39,8 +39,7 @@ "@deepseek-ai/dsh-session-projection": "workspace:*" }, "dependencies": { - "@deepseek-ai/schemastery": "workspace:~", - "zod": "4.4.3" + "@deepseek-ai/schemastery": "workspace:~" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:~", diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index 6214005230..07de4e68ae 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -1,58 +1,18 @@ /** Execution-time authority checks for the model-facing goal tools. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { GoalId, type GoalView } from '@deepseek-ai/dsh-goal' +import type { Agent, TurnBoundaryProjection } from '@deepseek-ai/dsh-agent' +import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionSeq } from '@deepseek-ai/dsh-session' import type { ToolRunContext } from '@deepseek-ai/dsh-tools' -import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection' -import { z as zod } from 'zod' +import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' -const turnAuthoritySchema = zod.object({ - open: zod.boolean(), - directHuman: zod.boolean(), - goalRounds: zod.array(zod.object({ - goalId: zod.string().transform(GoalId), - revision: zod.number().int().positive(), - round: zod.number().int().positive(), - })), -}) - -/** Accepted input authority for the currently open turn, reset at both turn boundaries. */ -type TurnAuthority = zod.infer - -declare module '@deepseek-ai/dsh-session-projection/types' { - interface SessionProjectionStateMap { - /** Input sources admitted in the current turn for goal-tool authorization. */ - goalToolAuthority: TurnAuthority - } -} - -const turnAuthorityProjection = { - key: 'goalToolAuthority', - stateVersion: 1, - stateSchema: turnAuthoritySchema, - init: (): TurnAuthority => ({ open: false, directHuman: false, goalRounds: [] }), - apply: (state, event) => { - if (event.type === 'turn/start' || event.type === 'turn/end') { - return { open: event.type === 'turn/start', directHuman: false, goalRounds: [] } - } - if (!state.open || event.type !== 'user/message') return state - const source = event.data.source - if (source.kind === 'user') return state.directHuman ? state : { ...state, directHuman: true } - if (source.kind === 'goal') { - const { goalId, revision, round } = source - return { ...state, goalRounds: [...state.goalRounds, { goalId, revision, round }] } - } - // Other message producers do not grant goal-tool authority. - return state - }, -} satisfies ProjectionDefinition<'goalToolAuthority', TurnAuthority> - -/** The authenticated caller and immutable authority facts captured for one tool call. */ +/** The calling agent plus the immutable event cut and open-turn start seq used for authority checks. */ export interface GoalToolExecution { readonly agent: Agent - readonly authority: TurnAuthority + readonly events: readonly SessionEvent[] + readonly openTurnStartSeq: SessionSeq } /** Hard authority granted to one state-changing call. */ @@ -65,44 +25,56 @@ function reject(message: string, code = 'GOAL_TOOL_AUTHORITY_REQUIRED'): never { throw new HarnessError(message, code) } -/** Resolves goal-tool authority from the live driver and its committed turn. */ -export class GoalToolExecutionResolver { - private authority!: ProjectionHandle - - /** @param ctx - Context carrying the live agent registry and session projections. */ - constructor(private readonly ctx: Context) {} - - /** - * Acquire projection readers before exposing dependent operations. - * @returns when all required registrations are ready. - */ - async initialize(): Promise { - this.authority = await this.ctx.sessionProjections.register(turnAuthorityProjection) +/** Resolve the immutable event cut and open-turn boundary without copying the turn suffix. */ +function openTurnEvents( + turnBoundary: ProjectionHandle, + agent: Agent, +): Pick { + // oxlint-disable-next-line typescript/no-deprecated -- Existing Session history read; migration deferred. + const events = agent.session.snapshotEvents() + const boundary = turnBoundary.stateOf(agent.session) + if (boundary.openTurnStartSeq === null) { + reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') } + return { events, openTurnStartSeq: boundary.openTurnStartSeq } +} - /** - * Authenticate the caller and capture its current turn's accepted input sources. - * @param exec - Tool execution carrying the live calling agent. - * @returns the caller and projected authority facts. - */ - resolve(exec: ToolRunContext): GoalToolExecution { - const agent = exec.agent - if (agent === undefined) { - return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') - } - if (this.ctx.agents.get(agent.id) !== agent || agent.status !== 'running' - || this.ctx.agents.currentInitiator() !== agent) { - return reject( - 'goal tools require the exact live calling agent inside its active driver', - 'GOAL_TOOL_DRIVER_REQUIRED', - ) - } - const authority = this.authority.stateOf(agent.session) - if (!authority.open) { - reject('goal tools require an open model turn', 'GOAL_TOOL_DRIVER_REQUIRED') - } - return { agent, authority } +/** + * Resolve and authenticate the calling agent and its driver boundary. + * @param ctx - Context carrying the live agent registry. + * @param exec - Tool execution metadata supplied by the registry. + * @param turnBoundary - Ready reader for the calling Session's turn boundary. + * @returns The authenticated agent, immutable event cut, and open-turn boundary. + */ +export function goalToolExecution( + ctx: Context, + exec: ToolRunContext, + turnBoundary: ProjectionHandle, +): GoalToolExecution { + const agent = exec.agent + if (agent === undefined) { + return reject('goal tools require a calling agent', 'GOAL_TOOL_AGENT_REQUIRED') } + if (ctx.agents.get(agent.id) !== agent || agent.status !== 'running' + || ctx.agents.currentInitiator() !== agent) { + return reject( + 'goal tools require the exact live calling agent inside its active driver', + 'GOAL_TOOL_DRIVER_REQUIRED', + ) + } + return { agent, ...openTurnEvents(turnBoundary, agent) } +} + +/** Whether the captured open turn contains an event accepted by `predicate`. */ +function someOpenTurnEvent( + execution: GoalToolExecution, + predicate: (event: SessionEvent) => boolean, +): boolean { + for (let seq = execution.openTurnStartSeq + 1; seq < execution.events.length; seq += 1) { + const event = execution.events[seq] + if (event !== undefined && predicate(event)) return true + } + return false } /** @@ -112,14 +84,17 @@ export class GoalToolExecutionResolver { */ function hasDirectHumanInput(ctx: Context, execution: GoalToolExecution): boolean { if (!ctx.agents.roots().includes(execution.agent)) return false - return execution.authority.directHuman + return someOpenTurnEvent(execution, event => + event.type === 'user/message' && event.data.source.kind === 'user') } /** Whether this turn is the current goal's exact admitted round. */ function isMatchingGoalRound(execution: GoalToolExecution, goal: GoalView): boolean { - return execution.authority.goalRounds.some(round => round.goalId === goal.id - && round.revision === goal.revision - && round.round === goal.roundsStarted) + return someOpenTurnEvent(execution, event => event.type === 'user/message' + && event.data.source.kind === 'goal' + && event.data.source.goalId === goal.id + && event.data.source.revision === goal.revision + && event.data.source.round === goal.roundsStarted) } /** diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index a364dec967..962d0b8f98 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -5,6 +5,7 @@ */ import type { Context } from '@deepseek-ai/cordis' +import { turnBoundaryProjectionDefinition } from '@deepseek-ai/dsh-agent' import z from '@deepseek-ai/schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' @@ -20,13 +21,12 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView } from '@deepseek-ai/dsh-tools' import { completionAuthority, - GoalToolExecutionResolver, + goalToolExecution, requireDirectHuman, } from './authority.ts' import { renderWrapupContext } from './wrapup.ts' export const name = 'tool-goal' - export const inject = ['agents', 'goals', 'tools', 'systemPrompt', 'sessionProjections'] /** Model policy and hard lower bounds for goal-state updates. */ @@ -186,9 +186,8 @@ function present(title: string, kind: 'read' | 'other', rawInput?: unknown): Gen /** Register the three Codex-shaped goal tools and their shared policy section. */ export async function apply(ctx: Context, config: Config): Promise { - const executionResolver = new GoalToolExecutionResolver(ctx) - await executionResolver.initialize() const resolved = resolveConfig(config) + const turnBoundary = await ctx.sessionProjections.register(turnBoundaryProjectionDefinition) ctx.systemPrompt.section({ name: 'tool:goal', order: ctx.systemPrompt.getSectionOrder('TOOL_GOAL'), @@ -201,7 +200,7 @@ export async function apply(ctx: Context, config: Config): Promise { parameters: {}, output: GOAL_OUTPUT, execute(_args, exec) { - const execution = executionResolver.resolve(exec) + const execution = goalToolExecution(ctx, exec, turnBoundary) return Promise.resolve(goalValue(ctx.goals.get(execution.agent))) }, presentCall: () => present('Read current goal', 'read'), @@ -223,7 +222,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = executionResolver.resolve(exec) + const execution = goalToolExecution(ctx, exec, turnBoundary) requireDirectHuman(ctx, execution) const goal = ctx.goals.create(execution.agent, { objective: args.objective, @@ -256,7 +255,7 @@ export async function apply(ctx: Context, config: Config): Promise { }, output: GOAL_OUTPUT, execute(args, exec) { - const execution = executionResolver.resolve(exec) + const execution = goalToolExecution(ctx, exec, turnBoundary) const ref = goalRef(args.goal_id, args.revision) const replacements = { ...hasText(args.objective) ? { objective: args.objective } : {}, diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 0bb6a91a48..08d5e46589 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -18,7 +18,6 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import * as GoalTools from '@deepseek-ai/dsh-tool-goal' import * as toolGoal from '@deepseek-ai/dsh-tool-goal' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -105,7 +104,7 @@ async function harness(config: toolGoal.Config = {}) { await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - const fiber = await ctx.plugin(GoalTools, config) + const fiber = await ctx.plugin(toolGoal, config) const root = stubAgent(`goal-tool-root-${Math.random()}`, undefined, ctx) await ctx.agents.register(root.agent) return { ctx, fiber, root } @@ -207,13 +206,13 @@ describe('goal tool registration and presentation', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - await expect(GoalTools.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })).rejects.toThrow( + await expect(toolGoal.apply(ctx, { blockedAfterConsecutiveRounds: 1.5 })).rejects.toThrow( 'blockedAfterConsecutiveRounds must be a positive safe integer', ) expect(ctx.tools.get('get_goal')).toBeUndefined() }) - it('resolves the direct-constructor default before registration', async () => { + it('resolves the direct-apply default before registration', async () => { const ctx = new Context() await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) @@ -221,7 +220,7 @@ describe('goal tool registration and presentation', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(ToolRuntime) await ctx.plugin(GoalService) - await GoalTools.apply(ctx, {}) + await toolGoal.apply(ctx, {}) const section = (await ctx.systemPrompt.assemble()).sections.find(item => item.name === 'tool:goal') expect(section?.text).toContain('at least 3 consecutive goal rounds') }) @@ -347,12 +346,12 @@ describe('goal tool execution authority', () => { const { ctx, root, fiber } = await harness() await fiber.dispose() openTurn(root, { kind: 'user' }) - await ctx.plugin(GoalTools, {}) + await ctx.plugin(toolGoal, {}) const result = await execute(ctx, 'create_goal', { objective: 'accepted before tool load' }, root.agent) expect(resultGoal(result)).toMatchObject({ objective: 'accepted before tool load' }) }) - it.each([1, 2])('accepts %i direct human steering messages in a goal-sourced root turn', async (messageCount) => { + it('accepts direct human steering in a goal-sourced root turn', async () => { const { ctx, root } = await harness() const humanTurn = openTurn(root, { kind: 'user' }) const created = ctx.goals.create(root.agent, { objective: 'steer me' }) @@ -360,12 +359,10 @@ describe('goal tool execution authority', () => { openTurn(root, { kind: 'goal', goalId: created.id, revision: created.revision, round: 1, }) - for (let index = 0; index < messageCount; index++) { - root.session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'pause now' }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) - } + root.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'pause now' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) const paused = await execute(ctx, 'update_goal', { goal_id: created.id, revision: created.revision, action: 'pause', }, root.agent) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 06d99d6378..3ffe9577da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9843,9 +9843,6 @@ importers: '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery - zod: - specifier: 4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ From 409ed3318384b3d3cbd9b052234373c2397acc7e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 14:04:28 +0800 Subject: [PATCH 032/287] test(tool-present): import the built plugin namespace --- packages/deliverables/tool-present/tests/built-errors.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/deliverables/tool-present/tests/built-errors.e2e.ts b/packages/deliverables/tool-present/tests/built-errors.e2e.ts index 7595872758..2fe7201def 100644 --- a/packages/deliverables/tool-present/tests/built-errors.e2e.ts +++ b/packages/deliverables/tool-present/tests/built-errors.e2e.ts @@ -16,7 +16,7 @@ import LocalFileSystem from './packages/fs/fs-local/lib/index.js' import SystemPrompt from './packages/core/system-prompt/lib/index.js' import ToolRuntime from './packages/core/tools/lib/index.js' import { Session, SESSION_FORMAT_VERSION } from './packages/core/session/lib/index.js' -import Present from './packages/deliverables/tool-present/lib/index.js' +import * as Present from './packages/deliverables/tool-present/lib/index.js' import SessionProjectionRegistry from './packages/session/session-projection/lib/index.js' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' From 5705bf12f0e176b80f307dfef6dfde08590a0293 Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 12 Oct 2026 15:41:37 +0800 Subject: [PATCH 033/287] refactor(agent-instructions): remove unused source type re-export --- packages/context/agent-instructions/README.i18n.yaml | 4 ++-- packages/context/agent-instructions/README.md | 2 +- packages/context/agent-instructions/README.zh.md | 2 +- packages/context/agent-instructions/src/index.ts | 1 - 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/context/agent-instructions/README.i18n.yaml b/packages/context/agent-instructions/README.i18n.yaml index 818bcda27b..9fc0cb6eae 100644 --- a/packages/context/agent-instructions/README.i18n.yaml +++ b/packages/context/agent-instructions/README.i18n.yaml @@ -30,8 +30,8 @@ en: 724df7ff6d1d9fea zh: 91fb1013eb9178ec /deepseek-ai-dsh-agent-instructions/understand-the-implementation/design-concept: - en: 566f3518b2d1d100 - zh: c7d0a22b19e7697a + en: 4708b51bf8fe86f7 + zh: 869fc4b1f78383ee /deepseek-ai-dsh-agent-instructions/understand-the-implementation/source-map: en: cb4c1f929ef5a835 zh: 3f0fcb7f568a0a5c diff --git a/packages/context/agent-instructions/README.md b/packages/context/agent-instructions/README.md index e935cedf8d..25c48ba1ac 100644 --- a/packages/context/agent-instructions/README.md +++ b/packages/context/agent-instructions/README.md @@ -85,7 +85,7 @@ This section explains the design decisions behind the plugin; the observable beh ### Design concept -The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. The package exports `AgentInstructionSource` for typed consumers of these message sources. +The plugin is built on one principle: workspace instructions are durable conversation content, owned per agent and per session. Baseline and refresh messages are ordinary sourced `user/message` events, so they replay, compact, and resume exactly like other history, and model-visible state is always reconstructable from the session log. The plugin owns the complete `` framing and every injected message reaches the model verbatim. ### Source map diff --git a/packages/context/agent-instructions/README.zh.md b/packages/context/agent-instructions/README.zh.md index cf193f2a28..a87cfae53a 100644 --- a/packages/context/agent-instructions/README.zh.md +++ b/packages/context/agent-instructions/README.zh.md @@ -85,7 +85,7 @@ export interface Config { ### 设计理念 -该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 本包导出 `AgentInstructionSource`,供这些消息来源的消费者进行类型检查。 +该插件建立在一个原则上:工作区指令是持久的对话内容,按 agent 与会话分别归属。基线消息与刷新消息都是普通的带来源 `user/message` 事件,因此与其他历史一样可回放、可压缩(compaction)、可恢复,模型可见状态总能从会话日志重建。插件拥有完整的 `` 框架,每条注入消息都原样到达模型。 ### 源码地图 diff --git a/packages/context/agent-instructions/src/index.ts b/packages/context/agent-instructions/src/index.ts index b32a5e6bb7..31070a9102 100644 --- a/packages/context/agent-instructions/src/index.ts +++ b/packages/context/agent-instructions/src/index.ts @@ -33,7 +33,6 @@ import { instructionCandidateGroup, instructionScopeKey, scopeForDisplayPath, ty export { Config } export { name } from './state.ts' -export type { AgentInstructionSource } from './state.ts' export { discoverBaselineInstructionFiles, loadBaselineInstructions, From fc41f012a98409d2ddfcc6631e07ba2aeeb5aa1e Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 13 Oct 2026 11:32:36 +0800 Subject: [PATCH 034/287] docs: refresh config catalog after merging master --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a31297183e..188bf83918 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -3992,7 +3992,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ed71c85653..bcde75fe3d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -3994,7 +3994,7 @@ export interface Config { - `inject`: `tools` · `subagents` · `systemPrompt` · `sessionProjections` - `refs`: [`AgentOptions`](subsystems/core.zh.md) -- `source`: [`packages/subagent/tool-subagent/src/index.ts:49`](../packages/subagent/tool-subagent/src/index.ts) +- `source`: [`packages/subagent/tool-subagent/src/index.ts:50`](../packages/subagent/tool-subagent/src/index.ts) ```ts config-catalog /** Config: which registered provider this tool delegates to, plus child defaults. */ From c25806f422f4491d4b8524497d046e498c0c4bea Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 13 Oct 2026 20:14:43 +0800 Subject: [PATCH 035/287] fix(ui-chat): settle folds with final sibling spacing --- packages/client/ui-chat/README.i18n.yaml | 4 +-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../src/client/chat/use-chat-viewport.ts | 5 ++- .../ui-chat/tests/chat-scroll.client.spec.tsx | 31 +++++++++++++++---- 5 files changed, 33 insertions(+), 11 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index dd9033ae18..f971449ddb 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -36,8 +36,8 @@ en: 81d7ca7595c34d39 zh: 29eb8248c75e9ea6 /deepseek-ai-dsh-client-ui-chat/scroll-ownership: - en: 3f4e6c161b4583bb - zh: e4265bb2bce8eb1c + en: 27f0323883ced61d + zh: 33ee7115231140c4 /deepseek-ai-dsh-client-ui-chat/model-experience: en: c17b4dd43a61c569 zh: f7414309810c3a08 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index f04957566b..05ca3efc62 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -138,7 +138,7 @@ The transcript root uses `overflow-x: visible; overflow-y: clip`: vertical overf Outer transcript following and each open capped group's following are independent. Native animation progress retains follow intent; only that scrollport's own `scrollend` settles its animation. Browsers without `scrollend` may retain the native follow target until a later bottom-aligned update or reader gesture. A reader gesture interrupts the animation, and actual movement determines whether following remains enabled. Scroll chaining can move the outer transcript, which then applies its own distance threshold. The back-to-bottom button restores only outer following. -Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. +Next-message folding combines row closure and tail following in one cancellable task, with browser scroll anchoring disabled during the fold. The closing frame includes final sibling spacing in its scroll position and reserved room. If the removed height exceeds the new content's height, the difference remains as bottom space rather than reducing the scroll offset to chase the unpadded content floor. This applies even when the conversation already exceeds the viewport. New content fills the retained space before following advances again; a smaller fold leaves only the remaining forward distance to scroll. Reader gestures cancel the task and active native following; resize cannot revive a cancelled task in the same fold, but new submitted input can. Off-bottom readers retain their position, and acknowledged scrolling releases only space that can be removed without clamping them. Unloading Chat cancels waiting callbacks and row transitions. Reduced-motion users receive immediate visibility and positioning changes. Changing Work details clears reserved bottom space after active folds finish and reconciles the scroll range with the selected mode. Readers already following the tail stay at the new bottom; other readers are not forced to follow. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 064607a1a3..3b55b0d16a 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -138,7 +138,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且 外层文本记录与每个已展开限高组的跟随状态彼此独立。原生动画的中间滚动保留跟随意图;只有该滚动容器自身的 `scrollend` 才结束其动画。不支持 `scrollend` 的浏览器可能保留原生跟随目标,直到后续贴底更新或读者手势将其结束。读者手势中断动画,是否继续跟随由实际位移决定。滚动传递可以带动外层,外层再按自身离底距离判断。回到底部按钮只恢复外层跟随。 -下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 +下次消息触发的折叠与跟尾属于同一个可取消任务,折叠期间禁用浏览器滚动锚定。收尾帧按最终兄弟节点间距确定滚动位置与预留空间。如果移除的高度大于新增内容的高度,差值保留为底部空间,不通过减小滚动偏移追到未含预留空间的内容底部;对话总高度已经超过视口时也遵循此规则。后续新增内容先填充保留区域,填满后才继续跟尾;折叠高度较小时,仅滚动剩余的向前距离。读者手势取消该任务及进行中的原生跟随;同一次折叠中的尺寸变化不能恢复已取消的任务,但新提交的输入可以。离底读者保留位置,已确认的滚动只释放不会造成位置钳制的多余空间。卸载 Chat 时取消等待回调和行过渡。启用减少动态效果偏好时,可见性和定位立即变化。 切换工作步骤展示档位时,当前折叠结束后清空底部预留空间,并按所选档位调整滚动范围。原本跟随底部的读者保持在新的底部;其他读者不会被强制拉回底部。 diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index ad6de91901..e33fe2b90c 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -243,7 +243,7 @@ export class ChatViewport { finish: (cancelled) => { if (this.elements !== elements) return // Commit the final sibling margins while their input-fold transition override still applies. - void elements.column.offsetHeight + const finalHeight = Math.max(elements.column.offsetHeight, elements.scroller.scrollHeight - this.spacerHeight()) delete elements.column.dataset.inputFold if (cancelled) { const metrics = scrollMetrics(elements.scroller) @@ -252,7 +252,10 @@ export class ChatViewport { this.invalidate() return } + target = Math.max(base, finalHeight - elements.scroller.clientHeight, 0) + finalRoom = target === 0 ? 0 : Math.max(0, target + elements.scroller.clientHeight - finalHeight) this.setSpacer(finalRoom) + elements.scroller.scrollTop = target const metrics = scrollMetrics(elements.scroller) const landing = { metrics, position: null, turn: this.latestTurn } this.observation = { top: metrics.top, landing } diff --git a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx index b212f91940..1340e7d815 100644 --- a/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-scroll.client.spec.tsx @@ -87,10 +87,13 @@ function mountScroll(deferCompletedTurns = true, initial: Partial) }, - startFold: () => { + startFold: (finalHeight?: number) => { const motion = callbacks.motion if (motion === undefined) throw new Error('Viewport motion callbacks are not bound') - act(() => { motion.collapse(row, () => { row.hidden = true }) }) + act(() => { motion.collapse(row, () => { + row.hidden = true + if (finalHeight !== undefined) height = finalHeight + }) }) }, finishFold: () => { const event = new Event('transitionend', { bubbles: true }) @@ -117,7 +120,7 @@ const visibleFoldCases = [ { name: 'small viewport', earlier: 200, steps: 900, body: 100, viewport: 300, top: 900, input: 30, target: 200, room: 170 }, ] as const -function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolean) { +function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolean, finalSiblingGrowth = 0) { let inputHeight = 0 let submission = 0 let top: number = test.top @@ -142,7 +145,7 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea const rowHeight = () => row.hidden ? 0 : (row.style.transition !== '' && row.style.transition !== 'none') || row.style.height === '' ? test.steps : Number.parseFloat(row.style.height) const room = () => Number.parseFloat(spacer.style.height) || 0 - const contentHeight = () => test.earlier + rowHeight() + test.body + inputHeight + const contentHeight = () => test.earlier + rowHeight() + test.body + inputHeight + (row.hidden ? finalSiblingGrowth : 0) const floor = () => Math.max(0, contentHeight() + room() - test.viewport) const scrollTo = vi.fn((options: ScrollToOptions) => { scroller.scrollTop = options.top ?? scroller.scrollTop @@ -211,6 +214,22 @@ function mountVisibleFold(test: typeof visibleFoldCases[number], reduced: boolea } describe.each([false, true])('visible input-fold matrix (reduced=%s)', (reduced) => { + it.each(['larger input', 'T/B'])('lands on final sibling spacing in the closing frame (%s)', async (name) => { + const test = visibleFoldCases.find(test => test.name === name)! + const h = mountVisibleFold(test, reduced, 4) + h.submit() + await act(async () => { await Promise.resolve() }) + for (let frame = 0; frame < 24 && !h.row.hidden; frame++) { + act(() => { vi.advanceTimersByTime(16) }) + } + expect(h.row.hidden).toBe(true) + const target = test.target + (test.room === 0 ? 4 : 0) + expect(h.scroller.scrollTop).toBe(target) + expect(h.room()).toBe(Math.max(0, test.room - 4)) + act(() => { vi.advanceTimersByTime(500) }) + expect(h.scroller.scrollTop).toBe(target) + }) + it.each(visibleFoldCases)('$name lands with only the required visible room', async (test) => { const h = mountVisibleFold(test, reduced) const positions = [h.bodyTop()] @@ -363,7 +382,7 @@ describe('Chat scroll collapse timing', () => { it('coalesces submitted inputs into one frame-owned fold', async () => { const h = mountScroll() - h.startFold() + h.startFold(920) h.update({ submissionId: 'first' }) h.update({ submissionId: 'second' }) expect(h.scrollTo).not.toHaveBeenCalled() @@ -467,7 +486,7 @@ describe('Chat scroll collapse timing', () => { it.each([false, true])('keeps a fold cancelled through resize until new input owns follow (new input=%s)', async (newInput) => { const h = mountScroll() h.scroller.scrollTop = 500 - h.startFold() + h.startFold(920) h.update({ submissionId: 'first' }) await act(async () => { await Promise.resolve() }) fireEvent.wheel(h.scroller, { deltaY: -80 }) From f50bab696ad1860aaf392c88ea5aa6a94bc097eb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Tue, 13 Oct 2026 20:14:59 +0800 Subject: [PATCH 036/287] test(web): sample input folds on a controlled frame clock --- apps/web/tests/chat-scroll-contract.e2e.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/chat-scroll-contract.e2e.ts b/apps/web/tests/chat-scroll-contract.e2e.ts index 955d251ab7..49675a473a 100644 --- a/apps/web/tests/chat-scroll-contract.e2e.ts +++ b/apps/web/tests/chat-scroll-contract.e2e.ts @@ -593,7 +593,7 @@ describe('web e2e: long Chat scroll contract', () => { const artifactRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/visible-fold/', import.meta.url)) await mkdir(artifactRoot, { recursive: true }) const evidence = await mkdtemp(join(artifactRoot, `${scenario.name}-`)) - const controlledClock = scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold' + const controlledClock = !scenario.reduced await withScrollWorld({ failureShot: `visible-fold-${scenario.name}`, controlledClock, @@ -760,17 +760,19 @@ describe('web e2e: long Chat scroll contract', () => { try { settled = world.scaffold.whenTurnSettled(30_000) if (controlledClock) { - // Advance every animation frame so the race cannot miss the intermediate fold height. + // Sample each animation frame, including when the CI browser misses real-time frames. await page.clock.pauseAt(await page.evaluate(() => Date.now() + 1000)) try { await page.getByRole('button', { name: 'Send message', exact: true }).evaluate((button) => { if (!(button instanceof HTMLButtonElement)) throw new Error('Send control is not a button') button.click() }) - await expect.poll(async () => { - await page.clock.runFor(16) - return page.locator('[data-fold-race-applied]').count() - }, { timeout: 10_000 }).toBe(1) + if (scenario.name === 'content-growth-during-fold' || scenario.name === 'cancel-all-during-fold') { + await expect.poll(async () => { + await page.clock.runFor(16) + return page.locator('[data-fold-race-applied]').count() + }, { timeout: 10_000 }).toBe(1) + } await expect.poll(async () => { await page.clock.runFor(16) return page.locator('[data-chat-motion="collapse"]').count() From 7618ccec69717a06dec0d2ecbe250b05f5a73cd1 Mon Sep 17 00:00:00 2001 From: Shigma Date: Wed, 14 Oct 2026 15:01:18 +0800 Subject: [PATCH 037/287] =?UTF-8?q?chore(cordis):=20=E5=90=8C=E6=AD=A5=20T?= =?UTF-8?q?imer=201.1.4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cordis-client-runner/README.i18n.yaml | 4 +- .../extensions/cordis-client-runner/README.md | 2 +- .../cordis-client-runner/README.zh.md | 2 +- .../src/client/api-catalog.ts | 4 +- .../cordis-client-runner/src/client/timer.ts | 44 ++++---- .../tests/timer.client.spec.ts | 53 +++++++++ .../extensions/tool-cordis/src/api-catalog.ts | 4 +- scripts/cordis-timer.spec.ts | 101 ++++++++++++++++++ scripts/gen-cordis-catalog.ts | 4 +- vendor/README.md | 4 +- vendor/timer/README.md | 10 +- vendor/timer/src/index.ts | 38 ++++--- 12 files changed, 219 insertions(+), 51 deletions(-) create mode 100644 packages/extensions/cordis-client-runner/tests/timer.client.spec.ts create mode 100644 scripts/cordis-timer.spec.ts diff --git a/packages/extensions/cordis-client-runner/README.i18n.yaml b/packages/extensions/cordis-client-runner/README.i18n.yaml index f68b557073..7352fc1585 100644 --- a/packages/extensions/cordis-client-runner/README.i18n.yaml +++ b/packages/extensions/cordis-client-runner/README.i18n.yaml @@ -33,8 +33,8 @@ en: df635b29eaef982c zh: ddaa4bd98359bd30 /deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map: - en: cbd46fb5821cfcc7 - zh: 392e5cace2db6194 + en: 22a43e93fd123200 + zh: f8305186a748369b /deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs: en: 33f14a7fff7835e6 zh: 53838ac8d4a79600 diff --git a/packages/extensions/cordis-client-runner/README.md b/packages/extensions/cordis-client-runner/README.md index 07b371528a..90dfd95b97 100644 --- a/packages/extensions/cordis-client-runner/README.md +++ b/packages/extensions/cordis-client-runner/README.md @@ -64,7 +64,7 @@ The browser half is built on one principle: a dynamic package must ride the same | [`src/client/guard.ts`](src/client/guard.ts) | The whitelisting `ctx` façade for loaded browser halves | | [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Providers and the pending-query router | | [`src/client/providers.ts`](src/client/providers.ts) | First-party client Inspect Providers (slots, theme, events) | -| [`src/client/timer.ts`](src/client/timer.ts) | The client timer service dynamic packages inject | +| [`src/client/timer.ts`](src/client/timer.ts) | Client timers with ordered concurrent interval reads and void-returning throttle/debounce wrappers that become inert on disposal | ### Inspecting slot inputs diff --git a/packages/extensions/cordis-client-runner/README.zh.md b/packages/extensions/cordis-client-runner/README.zh.md index 3753a9beca..c4eea99ce7 100644 --- a/packages/extensions/cordis-client-runner/README.zh.md +++ b/packages/extensions/cordis-client-runner/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" | [`src/client/guard.ts`](src/client/guard.ts) | 已装载浏览器半收到的白名单 `ctx` façade | | [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Provider 与待答查询路由器 | | [`src/client/providers.ts`](src/client/providers.ts) | 第一方 client Inspect Provider(slots、theme、events) | -| [`src/client/timer.ts`](src/client/timer.ts) | 动态包注入的 client 定时器服务 | +| [`src/client/timer.ts`](src/client/timer.ts) | client 定时器按顺序完成并发 interval 读取;throttle/debounce 包装函数返回 void,释放后不再执行 | ### 查询槽位输入 diff --git a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts index c4887547ab..e8b3248d47 100644 --- a/packages/extensions/cordis-client-runner/src/client/api-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/api-catalog.ts @@ -319,12 +319,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'throttle void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }', + signature: 'throttle any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters) => void) & { dispose: () => void }', description: 'Return a throttled function whose timer is disposed with the current fiber.', parameters: [], }, { - signature: 'debounce void>(callback: F, delay: number): F & { dispose: () => void }', + signature: 'debounce any>(callback: F, delay: number): ((...args: Parameters) => void) & { dispose: () => void }', description: 'Return a debounced function whose timer is disposed with the current fiber.', parameters: [], }, diff --git a/packages/extensions/cordis-client-runner/src/client/timer.ts b/packages/extensions/cordis-client-runner/src/client/timer.ts index e2b07c8472..2c87fb978e 100644 --- a/packages/extensions/cordis-client-runner/src/client/timer.ts +++ b/packages/extensions/cordis-client-runner/src/client/timer.ts @@ -21,7 +21,7 @@ declare module '@deepseek-ai/cordis' { } } -type WithDispose = T & { dispose: () => void } +type Scheduled any> = ((...args: Parameters) => void) & { dispose: () => void } // These `any` positions mirror the Host TimerService's overload erasure: generic callback tuples and async-iterator // return/rejection values must pass through without narrowing them to one caller's invocation. @@ -104,7 +104,7 @@ export class ClientTimerService extends Service { /** * Iterate over timer ticks. * @param delay - interval in milliseconds. - * @returns async iterator of ticks. + * @returns Iterator completing concurrent reads in order, one per tick; disposal rejects all pending reads. */ interval(delay: number): AsyncIterableIterator interval(...args: any[]): any { @@ -118,33 +118,37 @@ export class ClientTimerService extends Service { } let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined - let nextTask: PromiseWithResolvers> | undefined + const nextTasks: PromiseWithResolvers>[] = [] const dispose = this.ctx.effect(() => { const timer = globalThis.setInterval(() => { - nextTask?.resolve({ done: false, value: undefined }) + nextTasks.shift()?.resolve({ done: false, value: undefined }) }, delay) return () => { globalThis.clearInterval(timer) if (done !== undefined) return done = { kind: 'throw', reason: new Error('Context has been disposed') } - nextTask?.reject(done.reason) + for (const task of nextTasks.splice(0)) task.reject(done.reason) } }, 'ctx.interval()') return { next: () => { - if (done === undefined) return (nextTask = Promise.withResolvers()).promise + if (done === undefined) { + const task = Promise.withResolvers>() + nextTasks.push(task) + return task.promise + } if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value }) return Promise.reject(done.reason) }, return: (value: any) => { if (done === undefined) done = { kind: 'return', value } - nextTask?.resolve({ done: true, value }) + for (const task of nextTasks.splice(0)) task.resolve({ done: true, value }) void dispose() return Promise.resolve({ done: true, value }) }, throw: (reason: any) => { if (done === undefined) done = { kind: 'throw', reason } - nextTask?.reject(reason) + for (const task of nextTasks.splice(0)) task.reject(reason) void dispose() return Promise.resolve({ done: true, value: undefined }) }, @@ -155,15 +159,18 @@ export class ClientTimerService extends Service { } /** Build a delayed wrapper whose pending callback belongs to the calling Fiber. */ - private schedule(label: string, trigger: (args: any[], disposed: boolean) => number | undefined, disposed = false): any { + private schedule(label: string, trigger: (args: any[]) => number | undefined): any { let timer: number | undefined + let disposed = false const dispose = this.ctx.effect(() => () => { disposed = true globalThis.clearTimeout(timer) + timer = undefined }, label) const wrapper: any = (...args: any[]): void => { + if (disposed) return globalThis.clearTimeout(timer) - timer = trigger(args, disposed) + timer = trigger(args) } wrapper.dispose = dispose return wrapper @@ -174,33 +181,32 @@ export class ClientTimerService extends Service { * @param callback - Function to throttle. * @param delay - Minimum interval between calls in milliseconds. * @param noTrailing - Whether to suppress a delayed trailing call. - * @returns Throttled function with an early disposer. + * @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert. */ - throttle void>(callback: F, delay: number, noTrailing?: boolean): WithDispose { + throttle any>(callback: F, delay: number, noTrailing?: boolean): Scheduled { let lastCall = -Infinity const execute = (...args: Parameters): void => { lastCall = Date.now() callback(...args) } - return this.schedule('ctx.throttle()', (args, disposed) => { + return this.schedule('ctx.throttle()', (args) => { const remaining = delay - Date.now() + lastCall if (remaining <= 0) { execute(...args as Parameters) - } else if (!disposed) { + } else if (!noTrailing) { return globalThis.setTimeout(execute, remaining, ...args) } - }, noTrailing) + }) } /** * Return a debounced function whose timer is disposed with the calling Fiber. * @param callback - Function to debounce. * @param delay - Quiet period in milliseconds. - * @returns Debounced function with an early disposer. + * @returns Void-returning wrapper; disposal cancels pending work and makes later calls inert. */ - debounce void>(callback: F, delay: number): WithDispose { - return this.schedule('ctx.debounce()', (args, disposed) => { - if (disposed) return + debounce any>(callback: F, delay: number): Scheduled { + return this.schedule('ctx.debounce()', (args) => { return globalThis.setTimeout(callback, delay, ...args) }) } diff --git a/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts b/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts new file mode 100644 index 0000000000..9ad26bccc7 --- /dev/null +++ b/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts @@ -0,0 +1,53 @@ +/** @vitest-environment jsdom */ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' +import { ClientTimerService } from '../src/client/timer.ts' + +describe('client Timer', () => { + let ctx: Context + + beforeEach(async () => { + vi.useFakeTimers() + ctx = new Context() + await ctx.plugin(ClientTimerService) + }) + + afterEach(async () => { + try { + await ctx.fiber.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('delivers concurrent reads in order and rejects the remaining reads on teardown', async () => { + const ticks = ctx.interval(20) + const outcomes: string[] = [] + for (let index = 0; index < 3; index++) { + void ticks.next().then(() => outcomes.push(`tick ${index}`), () => outcomes.push(`disposed ${index}`)) + } + await vi.advanceTimersByTimeAsync(20) + expect(outcomes).toEqual(['tick 0']) + await ctx.fiber.dispose() + expect(outcomes).toEqual(['tick 0', 'disposed 1', 'disposed 2']) + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps wrapper calls void and inert after disposal', async () => { + const work = vi.fn((value: string) => value.length) + const throttle = ctx.throttle(work, 20) + const debounce = ctx.debounce(work, 20) + expectTypeOf(throttle).returns.toBeVoid() + expectTypeOf(debounce).returns.toBeVoid() + throttle('leading') + throttle('trailing') + debounce('delayed') + throttle.dispose() + debounce.dispose() + await vi.advanceTimersByTimeAsync(40) + throttle('disposed') + debounce('disposed') + expect(work).toHaveBeenCalledExactlyOnceWith('leading') + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 798e310219..402286ff4c 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -3305,12 +3305,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ parameters: [], }, { - signature: 'throttle void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }', + signature: 'throttle any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters) => void) & { dispose: () => void }', description: 'Return a throttled function whose timer is disposed with the current fiber.', parameters: [], }, { - signature: 'debounce void>(callback: F, delay: number): F & { dispose: () => void }', + signature: 'debounce any>(callback: F, delay: number): ((...args: Parameters) => void) & { dispose: () => void }', description: 'Return a debounced function whose timer is disposed with the current fiber.', parameters: [], }, diff --git a/scripts/cordis-timer.spec.ts b/scripts/cordis-timer.spec.ts new file mode 100644 index 0000000000..6f6822853f --- /dev/null +++ b/scripts/cordis-timer.spec.ts @@ -0,0 +1,101 @@ +/** Timer iterator ordering and cancellation on the vendored Cordis lifecycle. */ +import { Context } from '@deepseek-ai/cordis' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import { afterEach, beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest' + +describe('Cordis Timer', () => { + let ctx: Context + + beforeEach(async () => { + vi.useFakeTimers() + ctx = new Context() + await ctx.plugin(Timer) + }) + + afterEach(async () => { + try { + await ctx.fiber.dispose() + } finally { + vi.useRealTimers() + } + }) + + it('completes concurrent interval reads in order, one per tick', async () => { + const iterator = ctx.interval(10) + const completed: number[] = [] + const reads = [0, 1, 2].map(index => iterator.next().then( + (value) => { completed.push(index); return value }, + () => undefined, + )) + for (let index = 0; index < reads.length; index++) { + await vi.advanceTimersByTimeAsync(10) + expect(completed).toEqual(Array.from({ length: index + 1 }, (_, i) => i)) + } + expect(await Promise.all(reads)).toEqual(Array(3).fill({ done: false, value: undefined })) + await iterator.return!() + expect(vi.getTimerCount()).toBe(0) + }) + + it.each(['return', 'throw', 'dispose'] as const)('settles all pending interval reads on %s', async (operation) => { + const iterator = ctx.interval(10) + const completed: PromiseSettledResult>[] = [] + for (let index = 0; index < 3; index++) { + void iterator.next().then( + value => completed.push({ status: 'fulfilled', value }), + (reason: unknown) => completed.push({ status: 'rejected', reason }), + ) + } + const error = new Error('stopped') + if (operation === 'return') await iterator.return!('finished') + else if (operation === 'throw') await iterator.throw!(error) + else await ctx.fiber.dispose() + await vi.advanceTimersByTimeAsync(0) + expect(completed).toHaveLength(3) + if (operation === 'return') { + expect(completed).toEqual(Array(3).fill({ status: 'fulfilled', value: { done: true, value: 'finished' } })) + await expect(iterator.next()).resolves.toEqual({ done: true, value: 'finished' }) + } else { + const reason = operation === 'throw' ? error : new Error('Context has been disposed') + expect(completed).toEqual(Array(3).fill({ status: 'rejected', reason })) + await expect(iterator.next()).rejects.toThrow(reason.message) + } + expect(vi.getTimerCount()).toBe(0) + }) + + it.each([false, true])('stops throttle calls and trailing work after disposal (noTrailing=%s)', async (noTrailing) => { + const callback = vi.fn((value: string) => value.length) + const throttled = ctx.throttle(callback, 10, noTrailing) + expectTypeOf(throttled).parameters.toEqualTypeOf<[string]>() + expectTypeOf(throttled).returns.toBeVoid() + throttled('first') + throttled('second') + await vi.advanceTimersByTimeAsync(10) + expect(callback.mock.calls).toEqual(noTrailing ? [['first']] : [['first'], ['second']]) + throttled('pending') + const calls = callback.mock.calls.length + throttled.dispose() + await vi.advanceTimersByTimeAsync(20) + throttled('after disposal') + expect(callback).toHaveBeenCalledTimes(calls) + expect(vi.getTimerCount()).toBe(0) + }) + + it('cancels debounce work and makes both wrappers inert after owner disposal', async () => { + const callback = vi.fn((value: string) => value.length) + const debounced = ctx.debounce(callback, 10) + const throttled = ctx.throttle(callback, 10) + expectTypeOf(debounced).parameters.toEqualTypeOf<[string]>() + expectTypeOf(debounced).returns.toBeVoid() + debounced('first') + debounced('last') + await vi.advanceTimersByTimeAsync(10) + expect(callback).toHaveBeenCalledExactlyOnceWith('last') + debounced('pending') + await ctx.fiber.dispose() + debounced('disposed') + throttled('disposed') + await vi.advanceTimersByTimeAsync(10) + expect(callback).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index d734035f5a..25bfc749c9 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -1007,11 +1007,11 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = { jsDoc: '/** Return an async iterator of timer ticks. */', }, { - signature: 'throttle void>(callback: F, delay: number, noTrailing?: boolean): F & { dispose: () => void }', + signature: 'throttle any>(callback: F, delay: number, noTrailing?: boolean): ((...args: Parameters) => void) & { dispose: () => void }', jsDoc: '/** Return a throttled function whose timer is disposed with the current fiber. */', }, { - signature: 'debounce void>(callback: F, delay: number): F & { dispose: () => void }', + signature: 'debounce any>(callback: F, delay: number): ((...args: Parameters) => void) & { dispose: () => void }', jsDoc: '/** Return a debounced function whose timer is disposed with the current fiber. */', }, ], diff --git a/vendor/README.md b/vendor/README.md index 654276201d..b076fb4443 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -20,7 +20,7 @@ Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspac | `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | | `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | | `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.4 | https://github.com/cordiverse/cordis (`packages/timer`) | `0b96d9fd517b998e44e2209560c9e5f0671ce933` | | `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | | `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | @@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. -7. **Cordis and Loader JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. +7. **Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`. 9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). diff --git a/vendor/timer/README.md b/vendor/timer/README.md index 3a1dcb1533..9735030a27 100644 --- a/vendor/timer/README.md +++ b/vendor/timer/README.md @@ -18,8 +18,7 @@ const dispose = root.timeout(() => { dispose() ``` -Timer handles are registered on the current fiber, so they are cleared -automatically when the plugin that created them is disposed. +Timer handles are registered on the current fiber, so they are cleared automatically when the plugin that created them is disposed. ## API @@ -32,5 +31,8 @@ automatically when the plugin that created them is disposed. | `ctx.throttle(callback, delay, noTrailing?)` | Return a throttled function with `.dispose()`. | | `ctx.debounce(callback, delay)` | Return a debounced function with `.dispose()`. | -`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for -`ctx.timeout()` and `ctx.interval()`. +`ctx.setTimeout()` and `ctx.setInterval()` are kept as deprecated aliases for `ctx.timeout()` and `ctx.interval()`. + +Concurrent `next()` calls on an interval iterator complete in request order, one per tick. Returning from or throwing into the iterator settles every pending read; disposing the owning fiber rejects every pending read. Ticks without a pending read are discarded. + +Throttle and debounce wrappers return `void`, regardless of the callback return type. Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later calls inert. `noTrailing` suppresses delayed throttle calls while retaining leading calls until disposal. diff --git a/vendor/timer/src/index.ts b/vendor/timer/src/index.ts index 009d14a5d4..8d133e5c7f 100644 --- a/vendor/timer/src/index.ts +++ b/vendor/timer/src/index.ts @@ -6,7 +6,7 @@ declare module '@deepseek-ai/cordis' { } } -type WithDispose = T & { dispose: () => void } +type Scheduled any> = ((...args: Parameters) => void) & { dispose: () => void } /** Disposable timer helpers mixed into Cordis contexts. */ export class TimerService extends Service { @@ -66,33 +66,37 @@ export class TimerService extends Service { }, 'ctx.interval()') } else { let done: { kind: 'return'; value: any } | { kind: 'throw'; reason: any } | undefined - let nextTask: PromiseWithResolvers> | undefined + const nextTasks: PromiseWithResolvers>[] = [] const dispose = this.ctx.effect(() => { const timer = setInterval(() => { - nextTask?.resolve({ done: false, value: undefined }) + nextTasks.shift()?.resolve({ done: false, value: undefined }) }, delay) return () => { clearInterval(timer) if (done) return done = { kind: 'throw', reason: new Error('Context has been disposed') } - nextTask?.reject(done.reason) + for (const task of nextTasks.splice(0)) task.reject(done.reason) } }, 'ctx.interval()') return { next: () => { - if (!done) return (nextTask = Promise.withResolvers()).promise + if (!done) { + const task = Promise.withResolvers>() + nextTasks.push(task) + return task.promise + } if (done.kind === 'return') return Promise.resolve({ done: true, value: done.value }) return Promise.reject(done.reason) }, return: (value) => { if (!done) done = { kind: 'return', value } - nextTask?.resolve({ done: true, value }) + for (const task of nextTasks.splice(0)) task.resolve({ done: true, value }) dispose() return Promise.resolve({ done: true, value }) }, throw: (reason) => { if (!done) done = { kind: 'throw', reason } - nextTask?.reject(reason) + for (const task of nextTasks.splice(0)) task.reject(reason) dispose() return Promise.resolve({ done: true, value: undefined }) }, @@ -103,42 +107,44 @@ export class TimerService extends Service { } } - private _schedule(label: string, trigger: (args: any[], isDisposed: boolean) => any, isDisposed = false) { + private _schedule(label: string, trigger: (args: any[]) => number | NodeJS.Timeout | undefined) { let timer: number | NodeJS.Timeout | undefined + let isDisposed = false const dispose = this.ctx.effect(() => () => { isDisposed = true clearTimeout(timer) + timer = undefined }, label) const wrapper: any = (...args: any[]) => { + if (isDisposed) return clearTimeout(timer) - timer = trigger(args, isDisposed) + timer = trigger(args) } wrapper.dispose = dispose return wrapper } /** Return a throttled function whose timer is disposed with the current fiber. */ - throttle void>(callback: F, delay: number, noTrailing?: boolean): WithDispose { + throttle any>(callback: F, delay: number, noTrailing?: boolean): Scheduled { let lastCall = -Infinity const execute = (...args: any[]) => { lastCall = Date.now() callback(...args) } - return this._schedule('ctx.throttle()', (args, isDisposed) => { + return this._schedule('ctx.throttle()', (args) => { const now = Date.now() const remaining = delay - now + lastCall if (remaining <= 0) { execute(...args) - } else if (!isDisposed) { + } else if (!noTrailing) { return setTimeout(execute, remaining, ...args) } - }, noTrailing) + }) } /** Return a debounced function whose timer is disposed with the current fiber. */ - debounce void>(callback: F, delay: number): WithDispose { - return this._schedule('ctx.debounce()', (args, isDisposed) => { - if (isDisposed) return + debounce any>(callback: F, delay: number): Scheduled { + return this._schedule('ctx.debounce()', (args) => { return setTimeout(callback, delay, ...args) }) } From c949deb1cab37f41b00c85f41fba945c7214302b Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 14 Oct 2026 15:27:15 +0800 Subject: [PATCH 038/287] 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 f10be1f63abd2bc07eedc0d3ed5f74bf5a4ab5ca Mon Sep 17 00:00:00 2001 From: Shigma Date: Thu, 15 Oct 2026 16:47:43 +0800 Subject: [PATCH 039/287] docs(cordis): document Timer wrapper migration and local docs --- .../cordis-timer-wrappers/guide.i18n.yaml | 16 ++++++++++++++ .../cordis-timer-wrappers/guide.md | 21 +++++++++++++++++++ .../cordis-timer-wrappers/guide.zh.md | 21 +++++++++++++++++++ .../cordis-client-runner/README.i18n.yaml | 4 ++-- .../extensions/cordis-client-runner/README.md | 2 +- .../cordis-client-runner/README.zh.md | 2 +- .../tests/timer.client.spec.ts | 16 ++++++++++++++ scripts/cordis-timer.spec.ts | 2 +- vendor/README.md | 2 +- vendor/cordis-rc11-upgrade-plan.md | 2 +- 10 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.zh.md diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.i18n.yaml new file mode 100644 index 0000000000..05552c8917 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md +/: + en: a1e092dfacd2aed8 + zh: 838448e5bf0f9d93 +/timer-wrapper-return-types-and-disposal: + en: 8b8de9b0fc2e3fc3 + zh: ffa20b7ffa7540ef +/timer-wrapper-return-types-and-disposal/change: + en: 978657a23b852a1e + zh: 1402cc87410cfc7a +/timer-wrapper-return-types-and-disposal/migration: + en: c07c4d66ff9fd1ff + zh: ecebc12b08a9be47 diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md new file mode 100644 index 0000000000..f76b1aeba7 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Timer wrappers return void in TypeScript and ignore calls after disposal." +--- + +# Timer wrapper return types and disposal + +English | [中文](guide.zh.md) + +## Change + +The functions returned by `ctx.throttle()` and `ctx.debounce()` now declare a `void` return type. They previously inherited the callback's return type, although they already returned `undefined` at runtime. TypeScript code that uses the wrapper's return value or assigns it to the callback's type may need updating. This applies to Host Timer and browser ClientTimerService. + +Calling `.dispose()` or disposing the owning fiber cancels pending work and makes later wrapper calls inert. A disposed throttle wrapper previously could still execute a leading call; debounce wrappers already ignored calls after disposal. + +## Migration + +1. Let TypeScript infer the wrapper type, or declare its call signature with a `void` return type while retaining `.dispose()` where needed. +2. Consume callback results inside the callback. Awaiting the wrapper does not wait for an asynchronous callback to finish. +3. Move required work before disposal, or create a new wrapper in an active plugin. +4. Run your extension's type check and verify that calls after disposal neither invoke the callback nor schedule work. diff --git a/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.zh.md new file mode 100644 index 0000000000..15672e374e --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.2/cordis-timer-wrappers/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "Timer 包装函数的 TypeScript 返回类型为 void,释放后忽略后续调用。" +--- + +# Timer 包装函数的返回类型与资源释放 + +[English](guide.md) | 中文 + +## 变更 + +`ctx.throttle()` 和 `ctx.debounce()` 返回的函数现在声明 `void` 返回类型。此前这些函数继承回调的返回类型,但运行时已经返回 `undefined`。使用包装函数返回值或将包装函数赋给回调类型的 TypeScript 代码可能需要更新。此变更适用于 Host Timer 和浏览器 ClientTimerService。 + +调用 `.dispose()` 或释放所属 fiber 会取消待执行的工作,并使后续包装函数调用不再执行。此前已释放的 throttle 包装函数仍可能执行前沿调用;debounce 包装函数在释放后已经会忽略调用。 + +## 迁移 + +1. 让 TypeScript 推断包装函数的类型,或将其调用签名声明为返回 `void`,并按需保留 `.dispose()`。 +2. 在回调内部使用回调结果。对包装函数使用 await 不会等待异步回调完成。 +3. 将必须执行的工作移到资源释放之前,或在活动插件中创建新的包装函数。 +4. 运行扩展的类型检查,并验证释放后的调用既不会执行回调,也不会安排工作。 diff --git a/packages/extensions/cordis-client-runner/README.i18n.yaml b/packages/extensions/cordis-client-runner/README.i18n.yaml index 7352fc1585..fb20ce928e 100644 --- a/packages/extensions/cordis-client-runner/README.i18n.yaml +++ b/packages/extensions/cordis-client-runner/README.i18n.yaml @@ -33,8 +33,8 @@ en: df635b29eaef982c zh: ddaa4bd98359bd30 /deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/source-map: - en: 22a43e93fd123200 - zh: f8305186a748369b + en: f69f511aa85127f2 + zh: 651ea63d81bbd648 /deepseek-ai-dsh-cordis-client-runner/understand-the-implementation/inspecting-slot-inputs: en: 33f14a7fff7835e6 zh: 53838ac8d4a79600 diff --git a/packages/extensions/cordis-client-runner/README.md b/packages/extensions/cordis-client-runner/README.md index 90dfd95b97..f1e0ea134c 100644 --- a/packages/extensions/cordis-client-runner/README.md +++ b/packages/extensions/cordis-client-runner/README.md @@ -64,7 +64,7 @@ The browser half is built on one principle: a dynamic package must ride the same | [`src/client/guard.ts`](src/client/guard.ts) | The whitelisting `ctx` façade for loaded browser halves | | [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Providers and the pending-query router | | [`src/client/providers.ts`](src/client/providers.ts) | First-party client Inspect Providers (slots, theme, events) | -| [`src/client/timer.ts`](src/client/timer.ts) | Client timers with ordered concurrent interval reads and void-returning throttle/debounce wrappers that become inert on disposal | +| [`src/client/timer.ts`](src/client/timer.ts) | Browser timer Service matching the Host Timer API | ### Inspecting slot inputs diff --git a/packages/extensions/cordis-client-runner/README.zh.md b/packages/extensions/cordis-client-runner/README.zh.md index c4eea99ce7..1440caf92e 100644 --- a/packages/extensions/cordis-client-runner/README.zh.md +++ b/packages/extensions/cordis-client-runner/README.zh.md @@ -64,7 +64,7 @@ kind: "package-reference" | [`src/client/guard.ts`](src/client/guard.ts) | 已装载浏览器半收到的白名单 `ctx` façade | | [`src/client/inspect-registry.ts`](src/client/inspect-registry.ts) | Client Inspect Provider 与待答查询路由器 | | [`src/client/providers.ts`](src/client/providers.ts) | 第一方 client Inspect Provider(slots、theme、events) | -| [`src/client/timer.ts`](src/client/timer.ts) | client 定时器按顺序完成并发 interval 读取;throttle/debounce 包装函数返回 void,释放后不再执行 | +| [`src/client/timer.ts`](src/client/timer.ts) | 与 Host Timer API 一致的浏览器定时器 Service | ### 查询槽位输入 diff --git a/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts b/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts index 9ad26bccc7..c672e9d9fe 100644 --- a/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts +++ b/packages/extensions/cordis-client-runner/tests/timer.client.spec.ts @@ -50,4 +50,20 @@ describe('client Timer', () => { expect(work).toHaveBeenCalledExactlyOnceWith('leading') expect(vi.getTimerCount()).toBe(0) }) + + it('retains leading calls and suppresses trailing calls with noTrailing', async () => { + const work = vi.fn() + const throttle = ctx.throttle(work, 20, true) + throttle('first') + throttle('suppressed') + await vi.advanceTimersByTimeAsync(20) + expect(work).toHaveBeenCalledExactlyOnceWith('first') + throttle('next window') + expect(work.mock.calls).toEqual([['first'], ['next window']]) + throttle.dispose() + await vi.advanceTimersByTimeAsync(20) + throttle('disposed') + expect(work).toHaveBeenCalledTimes(2) + expect(vi.getTimerCount()).toBe(0) + }) }) diff --git a/scripts/cordis-timer.spec.ts b/scripts/cordis-timer.spec.ts index 6f6822853f..f2eb258352 100644 --- a/scripts/cordis-timer.spec.ts +++ b/scripts/cordis-timer.spec.ts @@ -71,7 +71,7 @@ describe('Cordis Timer', () => { throttled('second') await vi.advanceTimersByTimeAsync(10) expect(callback.mock.calls).toEqual(noTrailing ? [['first']] : [['first'], ['second']]) - throttled('pending') + throttled('before disposal') const calls = callback.mock.calls.length throttled.dispose() await vi.advanceTimersByTimeAsync(20) diff --git a/vendor/README.md b/vendor/README.md index b076fb4443..6d22829ab9 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -38,7 +38,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. Type-only dependencies use `import type` or inline `type` modifiers so ESM output does not retain erased interfaces. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. -7. **Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. +7. **Cordis, Loader and Timer JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), module re-exports, shared utility helpers, `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Loader documentation covers `loader/src/{index,internal,config/entry,config/group,config/isolate,config/tree,config/utils}.ts`, including entry ownership, tree mutation methods, and the `!!js` discriminator. Timer retains its local class and helper summaries; its README is local documentation because the upstream package has none. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. 8. **`include/src/index.ts` resilient file refresh and patch reapplication**: [#514](https://github.com/deepseek-harness/deepseek-harness/pull/514) validates a top-level entry array before caching parsed content, logs refresh failures, reapplies patches after file or Include-config edits, updates the Include config when it vetoes a restart, and uses `initial` only after `ENOENT`. These parse protections retain the running tree after an invalid file; plugin activation failures can leave a partially applied tree. Loader entry/group/tree mutations follow the pinned eager, non-transactional implementation and do not restore previous plugins or options. Covered by `packages/boot/app-boot/tests/{config-reload,user-patches}.spec.ts`. 9. **`hmr/src/index.ts` module-watcher readiness and native paths**: realpath the existing watch base, classify framework modules and attach listeners before reporting readiness, and compare config paths using both canonical and configured spellings. This preserves Node module-cache identity across Windows short-name paths and filesystem aliases. The main watcher uses `ignoreInitial: true` so startup reads do not trigger another refresh; exact profile patch watching is owned by `packages/boot/hmr/src/watch-config.ts`. Covered by `packages/boot/hmr/tests/watch-config.spec.ts`. 10. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`. Conditional exports select `.mjs` for import and `.cjs` for require; without them, concurrent imports under module-hook hosts reach the CJS entry and can race its require of ESM Cosmokit (`ERR_REQUIRE_ESM_RACE_CONDITION`). diff --git a/vendor/cordis-rc11-upgrade-plan.md b/vendor/cordis-rc11-upgrade-plan.md index ac53067459..5669425a68 100644 --- a/vendor/cordis-rc11-upgrade-plan.md +++ b/vendor/cordis-rc11-upgrade-plan.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -The [vendor manifest](README.md) records Cordis core 4.0.0-rc.7, Loader 1.0.0-rc.5, Include 1.0.4, HMR 1.0.15 and Timer 1.1.2, with 24 local-modification entries. [Cordis v4.0.0-rc.11](https://github.com/cordiverse/cordis/releases/tag/v4.0.0-rc.11) incorporates some fixes and replaces other mechanisms. Updating every package and retiring every patch in one change would combine configuration, persistence, lifecycle and module-replacement decisions. +The upgrade starts from the [vendored](README.md) baselines Cordis core 4.0.0-rc.7, Loader 1.0.0-rc.5, Include 1.0.4, HMR 1.0.15 and Timer 1.1.2, with 24 local-modification entries. [Cordis v4.0.0-rc.11](https://github.com/cordiverse/cordis/releases/tag/v4.0.0-rc.11) incorporates some fixes and replaces other mechanisms. Updating every package and retiring every patch in one change would combine configuration, persistence, lifecycle and module-replacement decisions. DSH also has consumers that upstream Cordis does not own. Volatile configuration fields are stable references updated without restarting plugins; the settings UI reads their live values. A profile selects ordered plugin bundles and configuration overrides. [DSH HMR](../packages/boot/hmr/README.md) coordinates module replacement and profile changes through `ctx.hmr`. An entry's module namespace is its raw import result, retained before selecting `default` or `apply` so HMR can identify the module already loaded. These consumers must remain usable at every merge point. From 585d20fc7755c24d971dda4603005d6d3efe2f66 Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 15 Oct 2026 17:47:02 +0800 Subject: [PATCH 040/287] fix(ui-chat): pause the running clock while reconnecting The Chat running indicator kept counting while the Client connection was disconnected or reconnecting, which read as the agent still working. The flow now reads the connection state and holds the clock until the connection is back. --- packages/client/ui-chat/package.json | 2 ++ packages/client/ui-chat/src/client/apply.ts | 9 ++++-- .../ui-chat/src/client/chat/ChatFlow.tsx | 5 ++-- .../ui-chat/src/client/chat/RunningStatus.tsx | 10 ++++--- .../ui-chat/src/client/contract/slots.ts | 10 +++++-- .../conversation-nodes/README.i18n.yaml | 4 +-- .../src/client/conversation-nodes/README.md | 2 +- .../client/conversation-nodes/README.zh.md | 2 +- .../tests/apply-inject.client.spec.tsx | 3 ++ .../ui-chat/tests/chat-apply.client.spec.tsx | 1 + .../ui-chat/tests/chat-view.client.spec.tsx | 30 +++++++++++++++++-- .../tests/quota-notice.client.spec.tsx | 1 + .../tests/running-status.client.spec.tsx | 17 +++++++++-- packages/client/ui-chat/tsconfig.client.json | 3 ++ .../tests/quota-notice-hold.client.spec.tsx | 1 + .../tests/assembly-surfaces.client.spec.tsx | 1 + .../tests/chat-ptc-subcalls.client.spec.tsx | 1 + .../tests/toolview-slot.client.spec.tsx | 2 ++ pnpm-lock.yaml | 3 ++ 19 files changed, 87 insertions(+), 20 deletions(-) diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index d43d47801c..492b4b4b39 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -32,6 +32,7 @@ "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-input-trigger", @@ -62,6 +63,7 @@ "@deepseek-ai/dsh-api-workspace-files": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-approval": "workspace:*", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index d51a29d316..6432e3e09e 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ChatFlowDataInjected, ChatFlowInjected, ChatNodeInjected, ChatScrollPosition, ChatViewInjected, QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps, @@ -74,7 +75,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ 'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale', - 'configForms', 'remote', 'remote.session', 'sidebarRight', + 'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', ] /** @@ -203,7 +204,8 @@ export function apply(ctx: Context): void { }), }, TranscriptViewRow)) - const nodeSources = (binding: SessionBinding): ChatFlowDataInjected => { + const connectionState = (ctx.get('connection') as ConnectionHandle).state + const nodeSources = (binding: SessionBinding): Pick => { const chat = chatSource(binding) const conversation = ctx.uiConversation.binding(binding) return { @@ -225,7 +227,8 @@ export function apply(ctx: Context): void { inject: (sessionId: SessionId): ChatFlowDataInjected => { const binding = ctx.sessions.binding(sessionId) if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) - return nodeSources(binding) + const sources = nodeSources(binding) + return { ...sources, hooks: { ...sources.hooks, connectionState } } }, }, ChatFlow)) diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx index 224d5c7a4e..561632a671 100644 --- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx @@ -25,12 +25,13 @@ function isTranscriptEcho(item: PendingInput): boolean { /** Render the flow without adding a DOM parent or changing keyed row positions. */ export const ChatFlow = memo(function ChatFlow({ entries, pendingInputs, lastInputTurn, deferCollapse, - useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, + useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState, useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, }: ChatFlowSlotProps) { const nodeStore = useChat(snapshot => snapshot.nodes) const running = useSession(snapshot => snapshot.running) + const disconnected = useConnectionState(state => state === 'disconnected' || state === 'connecting') const latestTurnAnchor = useChat(snapshot => snapshot.navigation.items().at(-1)?.anchorKey) const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => { const location = node?.location @@ -85,7 +86,7 @@ export const ChatFlow = memo(function ChatFlow({ // Rows enter before the keyed status and echoes after it, so React never moves its DOM node. return [ ...rows, ...leadingRows, ...openingRows, - ...running ? [] : [], + ...running ? [] : [], ...pendingRows, ] }) diff --git a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx index fd145b6324..36a202c973 100644 --- a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx +++ b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx @@ -9,22 +9,24 @@ import css from './ChatView.module.css' interface RunningStatusProps { readonly startTime: number | undefined + /** Freeze the shown duration while the Client cannot observe the Session. */ + readonly paused: boolean readonly t: ChatViewSlotProps['t'] } /** * Show live elapsed time after the current Turn's content without announcing ticks. - * @param props - Current Turn start time and localized copy. + * @param props - Current Turn start time, pause state, and localized copy. * @returns the blue running indicator; mount only while the Session is running. */ -export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) { +export const RunningStatus = memo(function RunningStatus({ startTime, paused, t }: RunningStatusProps) { const [now, setNow] = useState(Date.now) useEffect(() => { - if (startTime === undefined) return + if (startTime === undefined || paused) return setNow(Date.now()) const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) return () => { clearInterval(timer) } - }, [startTime]) + }, [startTime, paused]) const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', { duration: formatRunDuration(Math.max(1000, now - startTime), t).map(part => part.text).join(''), }) diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index a85e28798d..4bd88840f6 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -15,6 +15,7 @@ import type { PropsRenderFactories, PropsStore, SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ConnectionStateSource } from '@deepseek-ai/dsh-client-connection/client' import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read. @@ -325,8 +326,13 @@ export interface ChatViewInjected { fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } -/** Existing Session-owned node sources shared by the view and its flow renderer. */ -export type ChatFlowDataInjected = Pick +/** Session-owned node sources shared with the view, plus the flow's connection state. */ +export type ChatFlowDataInjected = Pick & { + hooks: ChatViewInjected['hooks'] & { + /** Client connection lifecycle; the running clock pauses while it is not connected. */ + connectionState: ConnectionStateSource + } +} /** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */ export interface ChatFlowOwnerProps extends Pick