fix(time-context): validate durable zone authority
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
deriveClientTimeZoneContext,
|
||||
renderTimeZoneContext,
|
||||
} from './request-zone.ts'
|
||||
import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
|
||||
|
||||
export type { ClientTimeZoneContext } from './request-zone.ts'
|
||||
export { deriveClientTimeZoneContext } from './request-zone.ts'
|
||||
@@ -38,17 +39,6 @@ export const Config: z<Config> = z.object({
|
||||
refreshIntervalMs: z.number(),
|
||||
})
|
||||
|
||||
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
|
||||
|
||||
/** Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone. */
|
||||
function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
|
||||
const parts = Object.fromEntries(
|
||||
formatter.formatToParts(now).map(part => [part.type, part.value]),
|
||||
) as Record<TimestampPart, string>
|
||||
const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3)
|
||||
return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]`
|
||||
}
|
||||
|
||||
/** Format a non-negative elapsed millisecond count as compact whole-second units. */
|
||||
function formatDuration(elapsedMs: number): string {
|
||||
let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
|
||||
@@ -160,20 +150,9 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
const timeZone = config.timeZone
|
||||
const refreshIntervalMs = config.refreshIntervalMs
|
||||
validateRefreshInterval(refreshIntervalMs)
|
||||
const createFormatter = (selectedTimeZone?: string): Intl.DateTimeFormat => new Intl.DateTimeFormat('en-US', {
|
||||
...(selectedTimeZone === undefined ? {} : { timeZone: selectedTimeZone }),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
timeZoneName: 'longOffset',
|
||||
})
|
||||
let fallbackFormatter: Intl.DateTimeFormat
|
||||
try {
|
||||
fallbackFormatter = createFormatter(timeZone)
|
||||
fallbackFormatter = createTimestampFormatter(timeZone)
|
||||
} catch (error: unknown) {
|
||||
const message = timeZone === undefined
|
||||
? 'time-context: failed to resolve the system time zone'
|
||||
@@ -190,7 +169,7 @@ export function apply(ctx: Context, config: Config): () => void {
|
||||
if (existing !== undefined) return existing
|
||||
let created: Intl.DateTimeFormat
|
||||
try {
|
||||
created = createFormatter(selectedTimeZone)
|
||||
created = createTimestampFormatter(selectedTimeZone)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`time-context: invalid Session time zone ${JSON.stringify(selectedTimeZone)}`, { cause: error })
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { deriveClientTimeZoneContext, renderTimeZoneContext } from './request-zone.ts'
|
||||
import { createTimestampFormatter, formatTimestamp } from './timestamp.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-time-context'
|
||||
const SOURCE_NAME = 'time-context'
|
||||
@@ -140,6 +141,22 @@ function validateReading(
|
||||
|| event.time < renderedTime) {
|
||||
fail('time-context rendered timestamp must parse and not postdate its durable event')
|
||||
}
|
||||
const sessionTimeZone = session.header.timeZone
|
||||
if (sessionTimeZone !== undefined) {
|
||||
let expectedTimestamp: string
|
||||
try {
|
||||
expectedTimestamp = formatTimestamp(
|
||||
renderedTime,
|
||||
createTimestampFormatter(sessionTimeZone),
|
||||
sessionTimeZone,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
fail(`time-context Session time zone cannot format its durable timestamp: ${String(error)}`)
|
||||
}
|
||||
if (rendered !== expectedTimestamp) {
|
||||
fail('time-context rendered timestamp does not match the Session time zone')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
|
||||
@@ -12,6 +12,8 @@ export type ClientTimeZoneContext =
|
||||
function clientTimeZone(message: UserMessage): string | undefined {
|
||||
const source = message.source
|
||||
return source.kind === 'user'
|
||||
&& 'rpcId' in source
|
||||
&& typeof source.rpcId === 'string'
|
||||
&& 'clientTimeZone' in source
|
||||
&& typeof source.clientTimeZone === 'string'
|
||||
? source.clientTimeZone
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/** ISO-shaped time-context timestamp formatting shared by production and replay validation. */
|
||||
|
||||
type TimestampPart = 'day' | 'hour' | 'minute' | 'month' | 'second' | 'timeZoneName' | 'year'
|
||||
|
||||
/**
|
||||
* Create the exact formatter used by durable time-context readings.
|
||||
* @param timeZone - Explicit display zone, or `undefined` for the process fallback.
|
||||
* @returns A formatter with stable numeric local fields and long numeric offset.
|
||||
*/
|
||||
export function createTimestampFormatter(timeZone?: string): Intl.DateTimeFormat {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
...(timeZone === undefined ? {} : { timeZone }),
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
timeZoneName: 'longOffset',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an epoch millisecond value as an ISO-shaped timestamp with offset and IANA zone.
|
||||
* @param now - Epoch milliseconds to display.
|
||||
* @param formatter - Formatter created for `timeZone`.
|
||||
* @param timeZone - Canonical zone label carried in brackets.
|
||||
* @returns The durable timestamp text.
|
||||
*/
|
||||
export function formatTimestamp(now: number, formatter: Intl.DateTimeFormat, timeZone: string): string {
|
||||
const parts = Object.fromEntries(
|
||||
formatter.formatToParts(now).map(part => [part.type, part.value]),
|
||||
) as Record<TimestampPart, string>
|
||||
const offset = parts.timeZoneName.replace(/^GMT$/, 'GMT+00:00').slice(3)
|
||||
return `${parts['year']}-${parts['month']}-${parts['day']}T${parts['hour']}:${parts['minute']}:${parts['second']}${offset}[${timeZone}]`
|
||||
}
|
||||
Reference in New Issue
Block a user