feat: gate below-baseline harnesses to fallback data with an upgrade modal
At apply time the host probes the running harness's version (the dshHomePath-anchored profiles mirror first, the plugin's module anchor second) and compares it against BASELINE_DSH_VERSION 0.1.2-rc.1 — with release > rc > beta > alpha ordering at equal X.Y.Z. A harness below the baseline gets fallback projection units (both registry contract generations) that fold nothing and serve zeroed data plus an `unsupported` wire record; the client keeps rendering the blank cards and pops a per-session-dismissable modal naming both versions and urging the update. A detection failure or an unparseable version fails open, so a probe misfire can never blank a working deployment.
This commit is contained in:
1 parent
fbf32ecc67
commit
8b434f2dbe
26 files changed
+1077
-3
No files matched your search
@@ -61,6 +61,7 @@ The plugin lives off data it does not own: the durable session log (event shapes
|
||||
- MUST be able to install and work correctly on `@deepseek-ai/dsh` **0.1.2-rc1+** — no regressions in runtime dependencies, message parsing, or any user-visible behavior. Older lines (0.1.0-rc.x, 0.1.1-rc.x, 0.1.2-alpha.x) are out of the support matrix.
|
||||
- Check carefully in depth for the compatibility of the plugin with all supported dsh version, investigate and dive deep into details of dsh source code and its dependencies (run pnpm install in dsh source code folder).
|
||||
- Low-level logic (e.g. token counting) should track the implementation of the newest supported dsh version.
|
||||
- The runtime baseline gate (host side, see `src/host/version.ts` / `src/host/fallback.ts` / `src/shared/version.ts`): at apply time the host probes the RUNNING harness's version (the `dshHomePath`-anchored profiles mirror first, the plugin's own module anchor second) and compares it against `BASELINE_DSH_VERSION` (channel order: release > rc > beta > alpha). A harness below the baseline gets fallback projection units that fold nothing and serve zeroed data plus an `unsupported` wire record — the client keeps rendering the (blank) cards and pops a modal naming both versions and urging the update. Detection failure or an unparseable version FAILS OPEN (the gate never trips), so a probe misfire can never blank a working deployment.
|
||||
|
||||
|
||||
### Compatibility verification mechanism
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ContextEventRecord, RequestRecord, SurfaceNode } from '../../share
|
||||
import { briefNodes, briefOf } from '../brief'
|
||||
import { headlineOf } from '../headline'
|
||||
import type { SessionStandardProps } from '../services'
|
||||
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, numOf, timelineOf, tokenUsageOf } from '../services'
|
||||
import { contextBreakdownOf, contextPressureOf, conversationNodesOf, headersOf, imageLoaderOf, numOf, timelineOf, tokenUsageOf, unsupportedOf } from '../services'
|
||||
import type { ClientCtx, ConversationNodeLike } from '../services'
|
||||
import { makeContentFetcher, makeHeaderFetcher } from '../historyPage'
|
||||
import { canOpenPathsOf, openPathVia, workspaceOf } from '../services'
|
||||
@@ -23,6 +23,7 @@ import { makeCurrentComposition } from './currentComposition'
|
||||
import { makeEventList } from './events'
|
||||
import { makeFileCard } from './fileCard'
|
||||
import { makePluginInfo } from './pluginInfo'
|
||||
import { makeUpgradeGate } from './upgradeGate'
|
||||
import { makeRequestDetail } from './requestDetail'
|
||||
import { makeStatsContext } from './statsContext'
|
||||
import { makeStatsTiming } from './statsTiming'
|
||||
@@ -61,6 +62,7 @@ export function makeContextView(
|
||||
const StatsTiming = makeStatsTiming(kit, Donut)
|
||||
const StatsTokens = makeStatsTokens(kit, Donut)
|
||||
const PluginInfo = makePluginInfo(kit)
|
||||
const UpgradeGate = makeUpgradeGate(kit)
|
||||
const ContextBrowser = makeContextBrowser(kit, StackedBar)
|
||||
const AgentGraph = makeAgentGraph(ctx, kit)
|
||||
const ErrorBoundary = makeErrorBoundary(t)
|
||||
@@ -324,6 +326,10 @@ export function makeContextView(
|
||||
? localeSvc.getLocale().active
|
||||
: 'en'
|
||||
const subtitle = (data.model ?? '') + (data.provider ? ' · ' + data.provider : '')
|
||||
// The host's baseline gate (host/fallback.ts): the cards below render
|
||||
// the fallback's zeroed data while the modal names both versions and
|
||||
// urges the upgrade. Absent record = a supported harness, no gate.
|
||||
const gate = unsupportedOf(data.unsupported)
|
||||
|
||||
return (
|
||||
<div className="lc-root" ref={rootRef}>
|
||||
@@ -466,6 +472,10 @@ export function makeContextView(
|
||||
/>
|
||||
|
||||
<div className="lc-foot">{t('footer')}</div>
|
||||
|
||||
{gate !== null && (
|
||||
<UpgradeGate sessionId={sessionId} current={gate.current} minimum={gate.minimum} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The baseline-gate modal: when the host reports the running harness below
|
||||
* the supported baseline (the `unsupported` record on the pushed
|
||||
* `contextTimeline` value — see host/fallback.ts), the Context tab keeps
|
||||
* rendering its (zeroed) cards and this centered dialog names the detected
|
||||
* harness version against the required minimum and urges the upgrade.
|
||||
*
|
||||
* Dismissal is remembered per session in a module-level ledger: tab remounts
|
||||
* do not re-pop it, while a fresh app launch does — the gate stays visible
|
||||
* until the harness is actually updated.
|
||||
*/
|
||||
|
||||
import type * as ReactNS from 'react'
|
||||
import type { ViewKit } from '../viewkit'
|
||||
|
||||
import { React } from '../react'
|
||||
|
||||
/** Sessions whose gate the user already dismissed this browser session. */
|
||||
const dismissed = new Set<string>()
|
||||
|
||||
export interface UpgradeGateProps {
|
||||
sessionId?: string
|
||||
/** The detected harness version (displayed verbatim, already re-proved). */
|
||||
current: string
|
||||
/** The plugin's minimum supported baseline. */
|
||||
minimum: string
|
||||
}
|
||||
|
||||
export function makeUpgradeGate(kit: ViewKit): (props: UpgradeGateProps) => ReactNS.ReactElement | null {
|
||||
const { t } = kit
|
||||
return function UpgradeGate(props: UpgradeGateProps): ReactNS.ReactElement | null {
|
||||
const sessionId = typeof props.sessionId === 'string' ? props.sessionId : ''
|
||||
// `closedFor` re-renders on dismiss within one mount; the ledger covers
|
||||
// remounts and in-place session switches.
|
||||
const [closedFor, setClosedFor] = React.useState<string | null>(null)
|
||||
const closed = closedFor === sessionId || dismissed.has(sessionId)
|
||||
const close = React.useCallback(() => {
|
||||
dismissed.add(sessionId)
|
||||
setClosedFor(sessionId)
|
||||
}, [sessionId])
|
||||
|
||||
// Same Escape contract as the /context modal: capture-phase, so the
|
||||
// composer's own key handling never swallows it first.
|
||||
React.useEffect(() => {
|
||||
if (closed) return undefined
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== 'Escape') return
|
||||
ev.preventDefault()
|
||||
ev.stopPropagation()
|
||||
close()
|
||||
}
|
||||
window.addEventListener('keydown', onKey, true)
|
||||
return () => { window.removeEventListener('keydown', onKey, true) }
|
||||
}, [closed, close])
|
||||
|
||||
if (closed) return null
|
||||
|
||||
return (
|
||||
<div className="lc-modal-backdrop" onClick={close}>
|
||||
<div className="lc-modal-card lc-gate-card" onClick={(ev) => { ev.stopPropagation() }}>
|
||||
<div className="lc-modal-head">
|
||||
<span className="lc-modal-title">{t('gate.title')}</span>
|
||||
<button className="lc-modal-close" aria-label={t('cmd.close')} onClick={close}>×</button>
|
||||
</div>
|
||||
<div className="lc-gate-body">{t('gate.body', { minimum: props.minimum })}</div>
|
||||
<div className="lc-gate-versions">
|
||||
<span className="lc-gate-version">
|
||||
<span className="lc-gate-version-label">{t('gate.current')}</span>
|
||||
<span className="lc-gate-version-value">v{props.current}</span>
|
||||
</span>
|
||||
<span className="lc-gate-version">
|
||||
<span className="lc-gate-version-label">{t('gate.minimum')}</span>
|
||||
<span className="lc-gate-version-value">
|
||||
v{props.minimum}
|
||||
<span className="lc-gate-or-newer">{t('gate.orNewer')}</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="lc-gate-actions">
|
||||
<button type="button" className="lc-gate-ok" onClick={close}>{t('gate.ok')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,12 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'loading': '正在读取会话日志…',
|
||||
'error': '上下文数据读取失败:',
|
||||
'error.retry': '重试',
|
||||
'gate.title': '请更新 DeepSeek Harness 版本',
|
||||
'gate.body': 'dsh-context 插件支持 DeepSeek Harness v{minimum} 或更新版本。',
|
||||
'gate.current': '当前版本',
|
||||
'gate.minimum': '最低要求',
|
||||
'gate.orNewer': '及更新的版本',
|
||||
'gate.ok': '知道了',
|
||||
'footer': '估算口径:与 dsh 内置 tokenMeter 相同的固定密度启发式(约 4 字符 ≈ 1 token);「实际」为供应商上报用量。',
|
||||
'tip.step': '第 {t} 轮 · 第{s}步',
|
||||
'tip.turn': '第 {t} 轮 · 共 {n} 步',
|
||||
@@ -355,6 +361,12 @@ export const DICT_EN: Record<string, string> = {
|
||||
'loading': 'Reading the session log…',
|
||||
'error': 'Failed to read context data: ',
|
||||
'error.retry': 'Retry',
|
||||
'gate.title': 'Update DeepSeek Harness',
|
||||
'gate.body': 'dsh-context supports DeepSeek Harness v{minimum} or newer.',
|
||||
'gate.current': 'Current',
|
||||
'gate.minimum': 'Minimum required',
|
||||
'gate.orNewer': 'or newer',
|
||||
'gate.ok': 'Got it',
|
||||
'footer': 'Estimate: same fixed-density heuristic as dsh’s built-in tokenMeter (~4 chars ≈ 1 token); “actual” is provider-reported usage.',
|
||||
'tip.step': 'Turn {t} · Step {s}',
|
||||
'tip.turn': 'Turn {t} · {n} steps',
|
||||
|
||||
@@ -196,6 +196,18 @@ function objectsOf<T>(value: unknown): T[] {
|
||||
return value.filter((v): v is T => v !== null && typeof v === 'object')
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a delivered `unsupported` gate record (the host's baseline gate —
|
||||
* see host/fallback.ts): both version strings re-proved, anything else
|
||||
* degrades to null (no gate shown) instead of rendering garbage.
|
||||
*/
|
||||
export function unsupportedOf(value: unknown): { current: string; minimum: string } | null {
|
||||
const data = asRecord(value)
|
||||
if (data === null) return null
|
||||
if (typeof data.current !== 'string' || typeof data.minimum !== 'string') return null
|
||||
return { current: data.current, minimum: data.minimum }
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a delivered projection value to a RENDER-SAFE context timeline —
|
||||
* the client's no-white-screen guarantee against backend/parse failures.
|
||||
@@ -234,8 +246,12 @@ export function timelineOf(value: unknown): ContextTimeline | null {
|
||||
? data.cost as ContextTimeline['cost']
|
||||
: undefined
|
||||
const timing = timingOf(data.timing)
|
||||
// The baseline-gate record survives sanitizing: a fallback payload that
|
||||
// somehow fails the fast path must still pop the gate modal.
|
||||
const unsupported = unsupportedOf(data.unsupported)
|
||||
return {
|
||||
ok: true,
|
||||
...(unsupported !== null ? { unsupported } : {}),
|
||||
...(typeof data.model === 'string' ? { model: data.model } : {}),
|
||||
...(typeof data.provider === 'string' ? { provider: data.provider } : {}),
|
||||
...(typeof data.contextWindow === 'number' ? { contextWindow: data.contextWindow } : {}),
|
||||
|
||||
@@ -7,3 +7,16 @@
|
||||
.lc-modal-title { font-weight: 600; font-size: 14px; }
|
||||
.lc-modal-close { margin-left: auto; border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 18px; line-height: 1; padding: 2px 6px; border-radius: 6px; cursor: pointer; font-family: inherit; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-modal-close:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-bg-layer-2); }
|
||||
|
||||
/* ---- the baseline-gate dialog (upgradeGate.tsx): narrower card, version rows, one primary action ---- */
|
||||
.lc-gate-card { width: min(440px, calc(100vw - 48px)); }
|
||||
.lc-gate-body { color: var(--dsw-alias-label-secondary); line-height: 1.7; }
|
||||
.lc-gate-versions { display: flex; gap: 24px; margin-top: 14px; }
|
||||
.lc-gate-version { display: flex; flex-direction: column; gap: 2px; }
|
||||
.lc-gate-version-label { font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
||||
.lc-gate-version-value { font-weight: 600; font-family: var(--dsw-font-family-mono, monospace); color: var(--dsw-alias-label-primary); }
|
||||
/* The muted suffix beside the required version: recovers the body face and de-emphasizes against the mono value. */
|
||||
.lc-gate-or-newer { margin-left: 6px; font-family: inherit; font-weight: 400; font-size: 11px; color: var(--dsw-alias-label-secondary); }
|
||||
.lc-gate-actions { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.lc-gate-ok { border: 0; border-radius: 6px; padding: 6px 16px; font-size: 12px; font-family: inherit; cursor: pointer; background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); transition: filter var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
|
||||
.lc-gate-ok:hover { filter: brightness(1.1); }
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The baseline gate's fallback projection units (see host/index.ts).
|
||||
*
|
||||
* On a harness below the supported baseline (shared/version.ts) the plugin
|
||||
* registers these INSTEAD of the real folds: the log's event shapes on such
|
||||
* a harness are outside the compat matrix, so nothing is parsed at all —
|
||||
* `apply` is the identity over an opaque empty state, and `view` serves a
|
||||
* fixed value whose timeline snapshot carries the gate record (`unsupported`:
|
||||
* the detected harness version and the baseline). The client's cards render
|
||||
* the blank data and its gate modal urges the upgrade; the registry pipeline
|
||||
* (fold, cache, push feed) keeps working end to end, so nothing hangs on a
|
||||
* loading screen.
|
||||
*
|
||||
* The definition carries BOTH registry contract generations: the modern
|
||||
* `stateSchema` + `wire` block (dsh 0.1.1-rc.1+, the realistic below-baseline
|
||||
* case) and the pre-0.1.1 top-level `schema` + `view` aliases that line's
|
||||
* registry reads instead. Each registry ignores the other generation's
|
||||
* fields, so one definition serves the gate on every harness that can
|
||||
* deliver projections to clients at all.
|
||||
*
|
||||
* `stateVersion` is pinned at 1. Downgrade/upgrade cache choreography: a
|
||||
* downgrade to a gated harness refolds the timeline key from scratch (no
|
||||
* ver-1 rows exist for it) and seeds the headers key from the real unit's
|
||||
* ver-1 rows — stripped to the empty state, which the gate never reads. An
|
||||
* upgrade back discards the fallback's ver-1 timeline rows and REJECTS its
|
||||
* ver-1 headers rows at the real unit's stricter state schema, refolding
|
||||
* both from the log. Nothing stale survives in either direction.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
|
||||
import type { ProjectionDefinition } from './compat'
|
||||
import { BASELINE_DSH_VERSION } from '../shared/version'
|
||||
import { contextHeadersSchema } from './headers'
|
||||
import { contextTimelineSchema } from './timeline'
|
||||
|
||||
/**
|
||||
* The opaque fold state: the gate folds nothing, so any cached row seeds it
|
||||
* (strip-mode object — never a discard, never a throw), and the plain-JSON
|
||||
* cache-write gate trivially holds.
|
||||
*/
|
||||
const fallbackStateSchema = z.object({})
|
||||
type FallbackState = z.infer<typeof fallbackStateSchema>
|
||||
|
||||
/** The pre-0.1.1 registry contract's fields (see the header note). */
|
||||
interface LegacyDefinitionShape<V> {
|
||||
schema: z.ZodType<V>
|
||||
view(state: FallbackState): V
|
||||
}
|
||||
|
||||
/** One gate unit: identity fold over the opaque state, constant view, both contract generations. */
|
||||
function fallbackDefinition<K extends 'contextTimeline' | 'contextHeaders'>(
|
||||
key: K,
|
||||
wireSchema: z.ZodType<SessionProjectionMap[K]>,
|
||||
value: SessionProjectionMap[K],
|
||||
): ProjectionDefinition<K, FallbackState> & LegacyDefinitionShape<SessionProjectionMap[K]> {
|
||||
const view = (): SessionProjectionMap[K] => value
|
||||
return {
|
||||
key,
|
||||
stateSchema: fallbackStateSchema,
|
||||
init: () => ({}),
|
||||
apply: state => state,
|
||||
wire: { viewSchema: wireSchema, view },
|
||||
schema: wireSchema,
|
||||
view,
|
||||
stateVersion: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback `contextTimeline` unit: serves the fixed zeroed snapshot
|
||||
* naming the detected harness `current` version against the baseline.
|
||||
*/
|
||||
export function createFallbackTimelineDefinition(current: string) {
|
||||
return fallbackDefinition('contextTimeline', contextTimelineSchema, {
|
||||
ok: true,
|
||||
unsupported: { current, minimum: BASELINE_DSH_VERSION },
|
||||
current: { system: 0, tools: 0, user: 0, inject: 0, assistant: 0, tool: 0, total: 0 },
|
||||
requests: [],
|
||||
events: [],
|
||||
nodes: [],
|
||||
droppedNodes: 0,
|
||||
archive: [],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The fallback `contextHeaders` unit: an empty epoch list — the browser's
|
||||
* header sections degrade to their metadata-free rendering.
|
||||
*/
|
||||
export function createFallbackHeadersDefinition() {
|
||||
return fallbackDefinition('contextHeaders', contextHeadersSchema, { headers: [] })
|
||||
}
|
||||
+2
-1
@@ -99,7 +99,8 @@ const headerToolWireSchema = z.object({
|
||||
plugin: z.string().optional(),
|
||||
}).strict()
|
||||
|
||||
const contextHeadersSchema = z.object({
|
||||
/** Exported for the fallback unit (fallback.ts): one wire contract, one schema. */
|
||||
export const contextHeadersSchema = z.object({
|
||||
headers: z.array(z.object({
|
||||
seq: z.number(),
|
||||
time: z.number(),
|
||||
|
||||
@@ -20,9 +20,12 @@
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { createToolAttribution } from './attribution'
|
||||
import { Config } from './config'
|
||||
import { createFallbackHeadersDefinition, createFallbackTimelineDefinition } from './fallback'
|
||||
import { createContextHeadersDefinition } from './headers'
|
||||
import { installSettings } from './settings'
|
||||
import { createContextTimelineDefinition } from './timeline'
|
||||
import { detectHarnessVersion } from './version'
|
||||
import { meetsBaseline } from '../shared/version'
|
||||
|
||||
export const name = 'dsh-context'
|
||||
|
||||
@@ -35,6 +38,21 @@ export const inject = ['sessionProjections']
|
||||
export { Config } from './config'
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// The baseline gate: a harness BELOW the supported baseline (detected at
|
||||
// apply time — see version.ts) never gets the real folds, since its log
|
||||
// shapes and seam faces are outside the compat matrix. Fallback units
|
||||
// serve the client zeroed data plus the gate record instead. An
|
||||
// UNDETECTABLE version is not a gate: detection failure fails open into
|
||||
// the normal composition below.
|
||||
const harnessVersion = detectHarnessVersion(ctx)
|
||||
if (harnessVersion !== undefined && !meetsBaseline(harnessVersion)) {
|
||||
// The dts register() constrains a unit's state to the declared
|
||||
// SessionProjectionStateMap entry; the gate's opaque empty state is
|
||||
// deliberately neither (nothing is folded) — cast through.
|
||||
ctx.sessionProjections.register(createFallbackTimelineDefinition(harnessVersion) as never)
|
||||
ctx.sessionProjections.register(createFallbackHeadersDefinition() as never)
|
||||
return
|
||||
}
|
||||
// Tool-to-plugin attribution (see attribution.ts): the static chain from
|
||||
// toolSources.ts stays the backbone, the runtime register() hook adds
|
||||
// third-party / agent-scoped / dynamic tools on top. Strictly additive — an
|
||||
|
||||
@@ -115,8 +115,16 @@ const timingTotalsSchema = z.object({
|
||||
tools: z.record(z.string(), toolTimingSchema),
|
||||
}).strict()
|
||||
|
||||
const contextTimelineSchema = z.object({
|
||||
/** The baseline-gate record the fallback unit serves (see fallback.ts). */
|
||||
const unsupportedSchema = z.object({
|
||||
current: z.string(),
|
||||
minimum: z.string(),
|
||||
}).strict()
|
||||
|
||||
/** Exported for the fallback unit (fallback.ts): one wire contract, one schema. */
|
||||
export const contextTimelineSchema = z.object({
|
||||
ok: z.literal(true),
|
||||
unsupported: unsupportedSchema.optional(),
|
||||
model: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
contextWindow: z.number().optional(),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Runtime harness-version probe behind the baseline gate (host/index.ts).
|
||||
*
|
||||
* The probe never imports a harness package for its version; it reads
|
||||
* manifests through Node resolution from two anchors, in order:
|
||||
*
|
||||
* 1. The harness home's healed `profiles/node_modules` mirror (located via
|
||||
* the app-boot `dshHomePath` service) — it symlinks/proxies the running
|
||||
* installation's dependency closure, so it names the true release even
|
||||
* when this plugin is a `link:`-installed dev checkout.
|
||||
* 2. This module itself — in a real profile install the plugin sits inside
|
||||
* the profile's node_modules and walk-up resolution reaches the same
|
||||
* mirror; this anchor also covers harnesses too old to provide the home
|
||||
* service.
|
||||
*
|
||||
* The `@deepseek-ai/dsh` CLI package is probed ONLY through the home anchor:
|
||||
* the plugin never depends on it, so a hit from the module anchor can only
|
||||
* be an ambient install above the plugin's tree (e.g. a global copy under
|
||||
* ~/node_modules) — not necessarily the RUNNING harness.
|
||||
*
|
||||
* Every step is guarded: any failure (absent service, unresolvable package,
|
||||
* unreadable/invalid manifest, non-string version) degrades to `undefined`,
|
||||
* and the gate treats an unknown version as SATISFIED — a probe misfire must
|
||||
* never blank a working deployment.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
|
||||
/**
|
||||
* Library packages whose manifest version IS the harness release version
|
||||
* (the dsh monorepo versions every package in lockstep). From the plugin's
|
||||
* own tree they resolve to its pinned devDependencies in dev and to the
|
||||
* healed profiles mirror in real installs — both correct — so they are safe
|
||||
* to probe from either anchor.
|
||||
*/
|
||||
const LIBRARY_PROBE_PACKAGES = ['@deepseek-ai/dsh-session-projection', '@deepseek-ai/dsh-session'] as const
|
||||
|
||||
/** Home-anchor probe order: the user-facing CLI version first, then the libraries. */
|
||||
const HOME_PROBE_PACKAGES = ['@deepseek-ai/dsh', ...LIBRARY_PROBE_PACKAGES] as const
|
||||
|
||||
type Resolve = (specifier: string) => string
|
||||
|
||||
/** One manifest's `version`, or undefined on any read/shape failure. */
|
||||
function versionOfManifest(manifestPath: string, expectedName?: string): string | undefined {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||
if (parsed === null || typeof parsed !== 'object') return undefined
|
||||
const record = parsed as { name?: unknown; version?: unknown }
|
||||
if (expectedName !== undefined && record.name !== expectedName) return undefined
|
||||
return typeof record.version === 'string' && record.version !== '' ? record.version : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Probe the package's published `./package.json` subpath. */
|
||||
function versionViaManifest(resolve: Resolve, packageName: string): string | undefined {
|
||||
try {
|
||||
return versionOfManifest(resolve(packageName + '/package.json'))
|
||||
} catch {
|
||||
// The export map publishes no such subpath (or the package is absent).
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe via the package's entry point, ascending to its owning manifest.
|
||||
* Covers the packaged-executable module proxies: their generated manifests
|
||||
* carry the real version but export only entry stubs, and Node's exports
|
||||
* gate refuses the direct `./package.json` subpath.
|
||||
*/
|
||||
function versionViaEntry(resolve: Resolve, packageName: string): string | undefined {
|
||||
let dir: string
|
||||
try {
|
||||
dir = dirname(resolve(packageName))
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
for (;;) {
|
||||
const version = versionOfManifest(join(dir, 'package.json'), packageName)
|
||||
if (version !== undefined) return version
|
||||
const parent = dirname(dir)
|
||||
if (parent === dir) return undefined
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
/** One anchor's answer: the first probe package that yields a version. */
|
||||
function probeAnchor(resolve: Resolve, packageNames: readonly string[]): string | undefined {
|
||||
for (const packageName of packageNames) {
|
||||
const version = versionViaManifest(resolve, packageName) ?? versionViaEntry(resolve, packageName)
|
||||
if (version !== undefined) return version
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The running harness's version string, or undefined when nothing answers
|
||||
* (the gate fails open on it — see the header note).
|
||||
* @param selfUrl - the module anchor (injectable for hermetic tests).
|
||||
*/
|
||||
export function detectHarnessVersion(ctx: Context, selfUrl: string = import.meta.url): string | undefined {
|
||||
try {
|
||||
// The app-boot home resolver (`dshHomePath(...segments)`); an absent or
|
||||
// hostile service leaves the module anchor as the only probe.
|
||||
const homePath = ctx.get('dshHomePath') as unknown
|
||||
if (typeof homePath === 'function') {
|
||||
const req = createRequire((homePath as (...segments: string[]) => string)('profiles', 'dsh-context-version-probe.cjs'))
|
||||
const home = probeAnchor(specifier => req.resolve(specifier), HOME_PROBE_PACKAGES)
|
||||
if (home !== undefined) return home
|
||||
}
|
||||
} catch { /* the module anchor below still answers */ }
|
||||
try {
|
||||
const req = createRequire(selfUrl)
|
||||
return probeAnchor(specifier => req.resolve(specifier), LIBRARY_PROBE_PACKAGES)
|
||||
} catch {
|
||||
// A non-file self URL: no anchor answered.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,17 @@ export type Category = 'user' | 'inject' | 'assistant' | 'tool'
|
||||
|
||||
export interface Snapshot {
|
||||
ok: boolean
|
||||
/**
|
||||
* The host's baseline-gate record, present ONLY when the running harness
|
||||
* is below the plugin's supported baseline: the host then folds nothing
|
||||
* and every figure in this snapshot is zero/empty. The client keeps
|
||||
* rendering the (blank) cards and pops the upgrade gate modal naming
|
||||
* `current` (the detected harness version) and `minimum` (the baseline).
|
||||
*/
|
||||
unsupported?: {
|
||||
current: string
|
||||
minimum: string
|
||||
}
|
||||
model?: string
|
||||
provider?: string
|
||||
contextWindow?: number
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* The harness-version gate's shared arithmetic — the supported dsh baseline
|
||||
* and the version compare behind it. Runtime code shared by BOTH halves (the
|
||||
* host probes and gates; the client displays what the wire record carries),
|
||||
* so this module must stay dependency-free.
|
||||
*
|
||||
* The baseline mirrors the support matrix (AGENTS.md "Compatibility" and the
|
||||
* package's `dsh.compatibility.dshReleases` declaration): the oldest dsh
|
||||
* release this plugin works on. A harness BELOW it gets the fallback units
|
||||
* (host/fallback.ts) instead of the real folds.
|
||||
*/
|
||||
|
||||
/** The oldest supported dsh release (see the matrix note above). */
|
||||
export const BASELINE_DSH_VERSION = '0.1.2-rc.1'
|
||||
|
||||
/**
|
||||
* Release-channel rank at an equal X.Y.Z: a final release outranks its
|
||||
* release candidates, which outrank betas, which outrank alphas
|
||||
* (正式版 > RC > Beta > Alpha).
|
||||
*/
|
||||
function channelRank(channel: 'alpha' | 'beta' | 'rc'): number {
|
||||
return channel === 'rc' ? 3 : channel === 'beta' ? 2 : 1
|
||||
}
|
||||
const RELEASE_RANK = 4
|
||||
|
||||
export interface ParsedVersion {
|
||||
major: number
|
||||
minor: number
|
||||
patch: number
|
||||
/** Channel rank: RELEASE_RANK when there is no prerelease channel. */
|
||||
rank: number
|
||||
/** The prerelease serial within the channel (`rc.2` → 2); 0 when absent. */
|
||||
serial: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `v?[major].[minor].[patch][-(alpha|beta|rc)[.N]][+build]`, or null
|
||||
* when the string is not that shape. Channels other than alpha/beta/rc
|
||||
* (nightly, dev, …) do not parse — the gate fails open on them.
|
||||
*/
|
||||
export function parseVersion(version: string): ParsedVersion | null {
|
||||
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-(alpha|beta|rc)(?:\.(\d+))?)?(?:\+[0-9a-z.-]+)?$/i.exec(version.trim())
|
||||
if (match === null) return null
|
||||
const channel = match[4]?.toLowerCase() as 'alpha' | 'beta' | 'rc' | undefined
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
rank: channel === undefined ? RELEASE_RANK : channelRank(channel),
|
||||
// The serial group is optional (typed `string` either way): truthiness
|
||||
// covers both the absent group and the impossible empty string.
|
||||
serial: match[5] ? Number(match[5]) : 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Total order over parsed versions: X.Y.Z numerically first, then the
|
||||
* channel rank, then the prerelease serial.
|
||||
*/
|
||||
function compareParsed(a: ParsedVersion, b: ParsedVersion): number {
|
||||
if (a.major !== b.major) return a.major - b.major
|
||||
if (a.minor !== b.minor) return a.minor - b.minor
|
||||
if (a.patch !== b.patch) return a.patch - b.patch
|
||||
if (a.rank !== b.rank) return a.rank - b.rank
|
||||
return a.serial - b.serial
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two version strings (negative / zero / positive). An unparseable
|
||||
* side compares EQUAL — the gate only ever acts on a strict, proven
|
||||
* below-baseline result.
|
||||
*/
|
||||
export function compareVersions(a: string, b: string): number {
|
||||
const x = parseVersion(a)
|
||||
const y = parseVersion(b)
|
||||
if (x === null || y === null) return 0
|
||||
return compareParsed(x, y)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `version` satisfies the supported baseline. FAIL OPEN by design: a
|
||||
* version that cannot be parsed (a dev/nightly harness build) must not blank
|
||||
* a working deployment, so it passes — the gate trips only on a proven
|
||||
* below-baseline release.
|
||||
*/
|
||||
export function meetsBaseline(version: string, baseline: string = BASELINE_DSH_VERSION): boolean {
|
||||
const v = parseVersion(version)
|
||||
const b = parseVersion(baseline)
|
||||
if (v === null || b === null) return true
|
||||
return compareParsed(v, b) >= 0
|
||||
}
|
||||
@@ -182,6 +182,42 @@ describe('ContextView — projection guards', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContextView — baseline gate', () => {
|
||||
test('a gated host renders the zeroed cards under the upgrade modal', async () => {
|
||||
const View = makeView(new TestClientCtx())
|
||||
const m = await mount(h(View, {
|
||||
sessionId: 'sv-gated',
|
||||
useProjection: projectionsFor(timeline({ unsupported: { current: '0.1.1-rc.2', minimum: '0.1.2-rc.1' } })),
|
||||
}))
|
||||
// The modal pops over the tab, naming both versions.
|
||||
assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null)
|
||||
const card = query(m.container, '.lc-gate-card')
|
||||
assert.ok(text(card).includes(DICT_EN['gate.title']))
|
||||
assert.ok(text(card).includes('v0.1.1-rc.2'))
|
||||
assert.ok(text(card).includes('v0.1.2-rc.1'))
|
||||
// The cards keep rendering the fallback's zeroed data behind it.
|
||||
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
||||
assert.ok(text(m.container).includes(DICT_EN['trend.empty']))
|
||||
assert.ok(text(m.container).includes(DICT_EN['events.empty']))
|
||||
// Dismissal reveals the (blank) cards.
|
||||
await click(buttonByText(m.container, DICT_EN['gate.ok']))
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
assert.ok(text(m.container).includes(DICT_EN['stats.title']))
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a malformed gate record never opens the modal', async () => {
|
||||
const View = makeView(new TestClientCtx())
|
||||
const m = await mount(h(View, {
|
||||
sessionId: 'sv-gate-junk',
|
||||
useProjection: projectionsFor(timeline({ unsupported: { current: 7 } })),
|
||||
}))
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
assert.ok(text(m.container).includes(DICT_EN['overview.title']))
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
/** The rich tab: full projection (stats, chart, markers, browser headers) over two turns plus a turn-less step. */
|
||||
async function mountRich(sessionId: string) {
|
||||
const View = makeView(new TestClientCtx())
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// UpgradeGate (src/client/components/upgradeGate.tsx) — the baseline-gate
|
||||
// modal rendered for real in jsdom: version rows, backdrop/card/Escape/OK/×
|
||||
// dismissal, and the per-session dismissal ledger across remounts and
|
||||
// in-place session switches.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { h } from '../../../src/client/react'
|
||||
import { makeUpgradeGate } from '../../../src/client/components/upgradeGate'
|
||||
import { click, keydown, makeKit, mount, query, text } from '../helpers/kit'
|
||||
|
||||
const kit = makeKit()
|
||||
const kitZh = makeKit('zh')
|
||||
const Gate = makeUpgradeGate(kit)
|
||||
|
||||
function render(sessionId: string | undefined, extra: { current?: string; minimum?: string } = {}) {
|
||||
return mount(h(Gate, { sessionId, current: extra.current ?? '0.1.1-rc.2', minimum: extra.minimum ?? '0.1.2-rc.1' }))
|
||||
}
|
||||
|
||||
describe('UpgradeGate', () => {
|
||||
test('renders both versions and the upgrade prompt (en and zh)', async () => {
|
||||
const en = await render('s-en')
|
||||
try {
|
||||
assert.match(text(en.container), /Update DeepSeek Harness/)
|
||||
assert.match(text(en.container), /dsh-context supports DeepSeek Harness v0\.1\.2-rc\.1 or newer\./)
|
||||
assert.match(text(en.container), /v0\.1\.1-rc\.2/)
|
||||
assert.match(text(en.container), /Current/)
|
||||
assert.match(text(en.container), /Minimum required/)
|
||||
} finally {
|
||||
await en.unmount()
|
||||
}
|
||||
const ZhGate = makeUpgradeGate(kitZh)
|
||||
const zh = await mount(h(ZhGate, { sessionId: 's-zh', current: '0.1.0', minimum: '0.1.2-rc.1' }))
|
||||
try {
|
||||
assert.match(text(zh.container), /请更新 DeepSeek Harness 版本/)
|
||||
assert.match(text(zh.container), /dsh-context 插件支持 DeepSeek Harness v0\.1\.2-rc\.1 或更新版本。/)
|
||||
assert.match(text(zh.container), /v0\.1\.2-rc\.1及更新的版本/)
|
||||
assert.match(text(zh.container), /v0\.1\.0/)
|
||||
} finally {
|
||||
await zh.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('the OK button dismisses and the dismissal survives a remount', async () => {
|
||||
const first = await render('s-ok')
|
||||
await click(query(first.container, '.lc-gate-ok'))
|
||||
assert.equal(first.container.querySelector('.lc-modal-backdrop'), null)
|
||||
await first.unmount()
|
||||
|
||||
const second = await render('s-ok')
|
||||
try {
|
||||
assert.equal(second.container.querySelector('.lc-modal-backdrop'), null, 'the ledger keeps it closed')
|
||||
} finally {
|
||||
await second.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('the × button dismisses', async () => {
|
||||
const m = await render('s-x')
|
||||
await click(query(m.container, '.lc-modal-close'))
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a backdrop click dismisses; a card click does not', async () => {
|
||||
const m = await render('s-backdrop')
|
||||
await click(query(m.container, '.lc-gate-card'))
|
||||
assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null, 'card clicks stay inside')
|
||||
await click(query(m.container, '.lc-modal-backdrop'))
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('Escape dismisses; other keys do not', async () => {
|
||||
const m = await render('s-esc')
|
||||
await keydown('Enter')
|
||||
assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null)
|
||||
await keydown('Escape')
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
// A closed gate does not react to further keys (listener detached).
|
||||
await keydown('Escape')
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('an in-place session switch re-evaluates the ledger', async () => {
|
||||
const m = await render('s-switch-a')
|
||||
await click(query(m.container, '.lc-gate-ok'))
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
// Switching to a fresh session shows its gate again on the same mount.
|
||||
await m.update(h(Gate, { sessionId: 's-switch-b', current: '0.1.0', minimum: '0.1.2-rc.1' }))
|
||||
assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null)
|
||||
// Switching back to the dismissed session stays closed.
|
||||
await m.update(h(Gate, { sessionId: 's-switch-a', current: '0.1.0', minimum: '0.1.2-rc.1' }))
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
await m.unmount()
|
||||
})
|
||||
|
||||
test('a missing sessionId still renders and dismisses', async () => {
|
||||
const m = await render(undefined)
|
||||
assert.ok(m.container.querySelector('.lc-modal-backdrop') !== null)
|
||||
await click(query(m.container, '.lc-gate-ok'))
|
||||
assert.equal(m.container.querySelector('.lc-modal-backdrop'), null)
|
||||
await m.unmount()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
timelineOf,
|
||||
timingOf,
|
||||
tokenUsageOf,
|
||||
unsupportedOf,
|
||||
workspaceOf,
|
||||
} from '../../src/client/services'
|
||||
import type { ClientCtx, ConversationNodeLike } from '../../src/client/services'
|
||||
@@ -198,6 +199,35 @@ describe('timelineOf', () => {
|
||||
assert.equal(timelineOf({ current: 1, droppedNodes: 4 })?.droppedNodes, 4)
|
||||
assert.equal(timelineOf({ current: 1, droppedNodes: 'x' })?.droppedNodes, 0)
|
||||
})
|
||||
|
||||
test('the unsupported gate record survives the sanitizing slow path only when well-formed', () => {
|
||||
const kept = timelineOf({ current: 1, unsupported: { current: '0.1.1-rc.2', minimum: '0.1.2-rc.1' } })
|
||||
assert.ok(kept !== null)
|
||||
assert.deepEqual(kept.unsupported, { current: '0.1.1-rc.2', minimum: '0.1.2-rc.1' })
|
||||
for (const bad of ['x', null, {}, { current: 1, minimum: 'm' }, { current: 'c' }]) {
|
||||
const out = timelineOf({ current: 1, unsupported: bad })
|
||||
assert.ok(out !== null)
|
||||
assert.ok(!('unsupported' in out), JSON.stringify(bad))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('unsupportedOf', () => {
|
||||
test('well-formed records pass through as plain data', () => {
|
||||
assert.deepEqual(unsupportedOf({ current: '0.1.1-rc.2', minimum: '0.1.2-rc.1' }), { current: '0.1.1-rc.2', minimum: '0.1.2-rc.1' })
|
||||
})
|
||||
|
||||
test('non-records and wrong-typed fields degrade to null', () => {
|
||||
assert.equal(unsupportedOf(null), null)
|
||||
assert.equal(unsupportedOf(undefined), null)
|
||||
assert.equal(unsupportedOf('x'), null)
|
||||
assert.equal(unsupportedOf(5), null)
|
||||
assert.equal(unsupportedOf({}), null)
|
||||
assert.equal(unsupportedOf({ current: 'c' }), null)
|
||||
assert.equal(unsupportedOf({ minimum: 'm' }), null)
|
||||
assert.equal(unsupportedOf({ current: 1, minimum: 'm' }), null)
|
||||
assert.equal(unsupportedOf({ current: 'c', minimum: null }), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('contextPressureOf', () => {
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// The baseline gate's fallback units (src/host/fallback.ts), driven over the
|
||||
// REAL session-projection registry: the gate's value arrives through the
|
||||
// ordinary snapshot cut no matter what the log carries, both registry
|
||||
// contract generations read their own shape off the definition, and cached
|
||||
// rows from either direction seed harmlessly.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import { createFallbackHeadersDefinition, createFallbackTimelineDefinition } from '../../src/host/fallback'
|
||||
import { BASELINE_DSH_VERSION } from '../../src/shared/version'
|
||||
|
||||
const CURRENT = '0.1.1-rc.2'
|
||||
|
||||
async function boot() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
ctx.sessionProjections.register(createFallbackTimelineDefinition(CURRENT) as never)
|
||||
ctx.sessionProjections.register(createFallbackHeadersDefinition() as never)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function appendAnything(session: Session): void {
|
||||
session.append('request/header', {
|
||||
header: { config: { model: 'deepseek-v4-flash', provider: 'deepseek' }, system: 'sys', tools: [] },
|
||||
reason: 'initial',
|
||||
})
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'hi' }],
|
||||
source: { kind: 'user' },
|
||||
} as never, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('fallback units over the real registry', () => {
|
||||
test('the gate value arrives through the snapshot cut regardless of the log', async () => {
|
||||
const ctx = await boot()
|
||||
const session = ctx.sessions.create()
|
||||
appendAnything(session)
|
||||
|
||||
const timeline = ctx.sessionProjections.snapshot(session).values.contextTimeline
|
||||
assert.ok(timeline !== undefined)
|
||||
assert.equal(timeline.ok, true)
|
||||
assert.deepEqual(timeline.unsupported, { current: CURRENT, minimum: BASELINE_DSH_VERSION })
|
||||
assert.equal(timeline.current.total, 0, 'nothing is folded')
|
||||
assert.equal(timeline.requests.length, 0)
|
||||
assert.equal(timeline.nodes.length, 0)
|
||||
assert.equal(timeline.archive.length, 0)
|
||||
assert.equal(timeline.droppedNodes, 0)
|
||||
assert.equal(timeline.model, undefined)
|
||||
|
||||
const headers = ctx.sessionProjections.snapshot(session).values.contextHeaders
|
||||
assert.ok(headers !== undefined)
|
||||
assert.deepEqual(headers.headers, [])
|
||||
})
|
||||
|
||||
test('the served views pass the real wire schemas', () => {
|
||||
const timeline = createFallbackTimelineDefinition(CURRENT)
|
||||
assert.equal(timeline.wire.viewSchema.safeParse(timeline.wire.view({})).success, true)
|
||||
const headers = createFallbackHeadersDefinition()
|
||||
assert.equal(headers.wire.viewSchema.safeParse(headers.wire.view({})).success, true)
|
||||
})
|
||||
|
||||
test('apply is a total identity over any event', () => {
|
||||
const timeline = createFallbackTimelineDefinition(CURRENT)
|
||||
const state = timeline.init()
|
||||
assert.deepEqual(state, {})
|
||||
assert.ok(timeline.apply(state, { type: 'user/message', seq: 1, time: 0, data: {} } as never) === state)
|
||||
assert.ok(timeline.apply(state, { type: 'bogus/event' } as never) === state, 'unknown event types pass through')
|
||||
const headers = createFallbackHeadersDefinition()
|
||||
assert.ok(headers.apply(state, { type: 'request/header', seq: 1, time: 0, data: null } as never) === state)
|
||||
})
|
||||
|
||||
test('the state schema strips any cached row instead of rejecting it', () => {
|
||||
const timeline = createFallbackTimelineDefinition(CURRENT)
|
||||
// A ver-1 row of the REAL headers unit ({headers: [...]}) or any other
|
||||
// persisted shape seeds the gate's fold stripped to the empty state.
|
||||
assert.deepEqual(timeline.stateSchema.parse({ headers: [{ seq: 1 }] }), {})
|
||||
assert.deepEqual(timeline.stateSchema.parse({ surface: [null], sums: {} }), {})
|
||||
assert.equal(timeline.stateVersion, 1)
|
||||
})
|
||||
|
||||
test('both registry contract generations read their shape off one definition', () => {
|
||||
const timeline = createFallbackTimelineDefinition(CURRENT)
|
||||
// Pre-0.1.1 aliases: top-level `schema` + `view`.
|
||||
assert.equal(timeline.schema.safeParse(timeline.view({})).success, true)
|
||||
assert.deepEqual(timeline.view({}).unsupported, { current: CURRENT, minimum: BASELINE_DSH_VERSION })
|
||||
const headers = createFallbackHeadersDefinition()
|
||||
assert.equal(headers.schema.safeParse(headers.view({})).success, true)
|
||||
assert.deepEqual(headers.view({}), { headers: [] })
|
||||
})
|
||||
})
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"name":"@deepseek-ai/dsh","version":"0.1.2-rc.1"}
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"name":"@deepseek-ai/dsh","version":"0.0.0-dev"}
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"name":"@deepseek-ai/dsh","version":"0.2.0"}
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"name":"@deepseek-ai/dsh-session-projection","version":"0.1.2-rc.1"}
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"name":"@deepseek-ai/dsh","version":"0.0.1"}
|
||||
Generated
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"name":"@deepseek-ai/dsh","version":"0.1.1-rc.2"}
|
||||
@@ -5,6 +5,8 @@
|
||||
// cut and the change feed.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, test } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
@@ -12,11 +14,25 @@ import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import { apply, inject, name } from '../../src/host/index'
|
||||
import { createContextTimelineDefinition } from '../../src/host/timeline'
|
||||
import { BASELINE_DSH_VERSION } from '../../src/shared/version'
|
||||
import type {} from '../../src/shared/types'
|
||||
|
||||
const plugin = { name, inject, apply } as never
|
||||
const noConfig = {} as never
|
||||
|
||||
/** The committed version-probe homes (see version.spec.ts). */
|
||||
const HOMES = fileURLToPath(new URL('./fixtures/version/homes', import.meta.url))
|
||||
|
||||
/** Boot with the probe's home anchor pointing at one fixture harness home. */
|
||||
async function bootWithHome(home: string) {
|
||||
const ctx = new Context()
|
||||
ctx.provide('dshHomePath', (...segments: string[]) => join(HOMES, home, ...segments))
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const fiber = await ctx.plugin(plugin, noConfig)
|
||||
return { ctx, fiber }
|
||||
}
|
||||
|
||||
/** Poll until the pending plugin fiber has started and folded the log. */
|
||||
async function until<T>(read: () => T | undefined, message: string): Promise<T> {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
@@ -186,3 +202,44 @@ describe('dsh-context host plugin', () => {
|
||||
await fiber
|
||||
})
|
||||
})
|
||||
|
||||
describe('the baseline gate', () => {
|
||||
test('a below-baseline harness gets the fallback units instead of the folds', async () => {
|
||||
const { ctx } = await bootWithHome('old')
|
||||
const session = ctx.sessions.create()
|
||||
appendRealEnvelopes(session)
|
||||
|
||||
const timeline = ctx.sessionProjections.snapshot(session).values.contextTimeline
|
||||
assert.ok(timeline !== undefined, 'the fallback unit still delivers (no eternal loading)')
|
||||
assert.deepEqual(timeline.unsupported, { current: '0.1.1-rc.2', minimum: BASELINE_DSH_VERSION })
|
||||
assert.equal(timeline.current.total, 0, 'the log is NOT folded below the baseline')
|
||||
assert.equal(timeline.nodes.length, 0, 'real appends leave no surface nodes')
|
||||
assert.equal(timeline.model, undefined, 'no model metadata either')
|
||||
|
||||
const headers = ctx.sessionProjections.snapshot(session).values.contextHeaders
|
||||
assert.ok(headers !== undefined)
|
||||
assert.deepEqual(headers.headers, [], 'the header epoch is not folded either')
|
||||
})
|
||||
|
||||
test('the baseline itself and newer compose the real units', async () => {
|
||||
for (const home of ['baseline', 'future']) {
|
||||
const { ctx } = await bootWithHome(home)
|
||||
const session = ctx.sessions.create()
|
||||
appendRealEnvelopes(session)
|
||||
const timeline = ctx.sessionProjections.snapshot(session).values.contextTimeline
|
||||
assert.ok(timeline !== undefined, home)
|
||||
assert.equal(timeline.unsupported, undefined, home)
|
||||
assert.ok(timeline.current.total > 0, `the real fold runs on ${home}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('an unparseable harness version fails open into the real units', async () => {
|
||||
const { ctx } = await bootWithHome('dev')
|
||||
const session = ctx.sessions.create()
|
||||
appendRealEnvelopes(session)
|
||||
const timeline = ctx.sessionProjections.snapshot(session).values.contextTimeline
|
||||
assert.ok(timeline !== undefined)
|
||||
assert.equal(timeline.unsupported, undefined)
|
||||
assert.ok(timeline.current.total > 0, 'a dev-channel harness build is never gated')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
// The runtime harness-version probe (src/host/version.ts): both resolution
|
||||
// anchors, both probe paths (manifest subpath / entry ascend), and every
|
||||
// degradation arm — the gate's fail-open promise rests on ALL of these
|
||||
// returning undefined instead of throwing.
|
||||
//
|
||||
// Two fixture roots:
|
||||
// - tests/host/fixtures/version/homes/* — committed homes whose packages
|
||||
// RESOLVE at the fixture level (no walk-up), shared with index.spec.ts.
|
||||
// - a per-run tree under os.tmpdir() — the FAILURE cases. They must sit
|
||||
// outside the repository: a failing probe inside the repo tree would walk
|
||||
// up into the repo's own node_modules and answer with the pinned
|
||||
// devDependencies instead of failing.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, test } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { detectHarnessVersion } from '../../src/host/version'
|
||||
|
||||
const HOMES = fileURLToPath(new URL('./fixtures/version/homes', import.meta.url))
|
||||
|
||||
/** A `dshHomePath`-shaped resolver rooted at one fixture home under `root`. */
|
||||
const resolverIn = (root: string) => (...segments: string[]): string => join(root, ...segments)
|
||||
const homeResolver = (home: string): (...segments: string[]) => string => resolverIn(join(HOMES, home))
|
||||
|
||||
/** A ctx carrying only the given `dshHomePath` service value. */
|
||||
function ctxWithHome(homePath: unknown): Context {
|
||||
const ctx = new Context()
|
||||
ctx.provide('dshHomePath', homePath)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** A selfUrl anchored inside the given fixture home's profiles dir. */
|
||||
function selfUrlInto(home: string): string {
|
||||
return pathToFileURL(join(HOMES, home, 'profiles', 'probe.cjs')).href
|
||||
}
|
||||
|
||||
const BOGUS_SELF_URL = 'file:///nonexistent/dsh-context-version-probe.cjs'
|
||||
|
||||
let scratch = ''
|
||||
|
||||
/**
|
||||
* Write one scratch probe home. `manifest` is the literal package.json
|
||||
* content of the given package; `entry` (when set) becomes the file the
|
||||
* manifest's `exports['.']` points at, with `decoy` an optional mismatched
|
||||
* manifest placed beside it.
|
||||
*/
|
||||
function writeScratchHome(
|
||||
home: string,
|
||||
packageName: string,
|
||||
manifest: string,
|
||||
entry?: { path: string; decoyManifest?: string },
|
||||
): string {
|
||||
const dir = join(scratch, home, 'profiles', 'node_modules', packageName)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'package.json'), manifest)
|
||||
if (entry !== undefined) {
|
||||
const entryPath = join(dir, entry.path)
|
||||
mkdirSync(dirname(entryPath), { recursive: true })
|
||||
writeFileSync(entryPath, '')
|
||||
if (entry.decoyManifest !== undefined) {
|
||||
writeFileSync(join(dirname(entryPath), 'package.json'), entry.decoyManifest)
|
||||
}
|
||||
}
|
||||
return join(scratch, home)
|
||||
}
|
||||
|
||||
const scratchResolver = (home: string): (...segments: string[]) => string => resolverIn(join(scratch, home))
|
||||
|
||||
beforeAll(() => {
|
||||
scratch = mkdtempSync(join(tmpdir(), 'dsh-context-version-'))
|
||||
// The CLI-row-absent fall-through lives in scratch: inside the repo tree a
|
||||
// missing CLI package could resolve to an ambient install above the repo
|
||||
// (e.g. a global copy under ~/node_modules), making the answer machine-
|
||||
// dependent; outside it the walk-up is provably clean.
|
||||
writeScratchHome('library-only', '@deepseek-ai/dsh-session-projection',
|
||||
JSON.stringify({ name: '@deepseek-ai/dsh-session-projection', version: '0.1.1-rc.2' }))
|
||||
writeScratchHome('entry-only', '@deepseek-ai/dsh',
|
||||
JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.1.1-rc.1', exports: { '.': './lib/index.js' } }),
|
||||
{ path: 'lib/index.js' })
|
||||
writeScratchHome('entry-nested', '@deepseek-ai/dsh',
|
||||
JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.1.1-rc.4', exports: { '.': './a/b/index.js' } }),
|
||||
{ path: 'a/b/index.js', decoyManifest: JSON.stringify({ name: '@deepseek-ai/dsh-decoy', version: '9.9.9' }) })
|
||||
// Odd manifests sit as DECOYS on the ascend path: only the direct
|
||||
// manifest read (not a resolve) ever surfaces them, and the probe must
|
||||
// skip each to reach the owning package's real version.
|
||||
writeScratchHome('entry-badjson', '@deepseek-ai/dsh',
|
||||
JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.1.1-rc.5', exports: { '.': './a/index.js' } }),
|
||||
{ path: 'a/index.js', decoyManifest: 'not json{' })
|
||||
writeScratchHome('entry-null', '@deepseek-ai/dsh',
|
||||
JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.1.1-rc.6', exports: { '.': './a/index.js' } }),
|
||||
{ path: 'a/index.js', decoyManifest: 'null' })
|
||||
writeScratchHome('entry-primitive', '@deepseek-ai/dsh',
|
||||
JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.1.1-rc.7', exports: { '.': './a/index.js' } }),
|
||||
{ path: 'a/index.js', decoyManifest: '"oops"' })
|
||||
writeScratchHome('entry-orphan', '@deepseek-ai/dsh',
|
||||
JSON.stringify({ name: '@deepseek-ai/dsh-orphan', version: '1.0.0', exports: { '.': './index.js' } }),
|
||||
{ path: 'index.js' })
|
||||
writeScratchHome('nonstring-version', '@deepseek-ai/dsh', JSON.stringify({ name: '@deepseek-ai/dsh', version: 42 }))
|
||||
writeScratchHome('empty-version', '@deepseek-ai/dsh', JSON.stringify({ name: '@deepseek-ai/dsh', version: '' }))
|
||||
mkdirSync(join(scratch, 'empty', 'profiles'), { recursive: true })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('detectHarnessVersion — home anchor', () => {
|
||||
test('reads the CLI package manifest of the running installation', () => {
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(homeResolver('old'))), '0.1.1-rc.2')
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(homeResolver('baseline'))), '0.1.2-rc.1')
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(homeResolver('future'))), '0.2.0')
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(homeResolver('dev'))), '0.0.0-dev')
|
||||
})
|
||||
|
||||
test('falls through to the library packages when the CLI row is absent', () => {
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(scratchResolver('library-only'))), '0.1.1-rc.2')
|
||||
})
|
||||
|
||||
test('ascends from the entry point when the manifest subpath is not exported', () => {
|
||||
const ctx = ctxWithHome(scratchResolver('entry-only'))
|
||||
assert.equal(detectHarnessVersion(ctx), '0.1.1-rc.1')
|
||||
})
|
||||
|
||||
test('skips mismatched manifests while ascending to the owning package', () => {
|
||||
const ctx = ctxWithHome(scratchResolver('entry-nested'))
|
||||
assert.equal(detectHarnessVersion(ctx), '0.1.1-rc.4')
|
||||
})
|
||||
|
||||
test('skips invalid, null, and primitive manifests met while ascending', () => {
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(scratchResolver('entry-badjson'))), '0.1.1-rc.5')
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(scratchResolver('entry-null'))), '0.1.1-rc.6')
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(scratchResolver('entry-primitive'))), '0.1.1-rc.7')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectHarnessVersion — module anchor', () => {
|
||||
test('never probes the CLI package from the module anchor', () => {
|
||||
// The fixture pins the CLI at 0.0.1 but the library at the baseline:
|
||||
// only the library answer may come back.
|
||||
assert.equal(detectHarnessVersion(new Context(), selfUrlInto('module-skips-cli')), '0.1.2-rc.1')
|
||||
})
|
||||
|
||||
test('the default self anchor answers with the plugin’s own pinned peers', () => {
|
||||
const version = detectHarnessVersion(new Context())
|
||||
assert.match(version ?? '', /^\d+\.\d+\.\d+/, 'the repo’s pinned devDependencies answer in tests')
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectHarnessVersion — degradation arms (fail open)', () => {
|
||||
test('no home service and an unresolvable module anchor → undefined', () => {
|
||||
assert.equal(detectHarnessVersion(new Context(), BOGUS_SELF_URL), undefined)
|
||||
})
|
||||
|
||||
test('a non-function dshHomePath is ignored', () => {
|
||||
assert.equal(detectHarnessVersion(ctxWithHome(42), BOGUS_SELF_URL), undefined)
|
||||
})
|
||||
|
||||
test('a home resolver that throws falls through to the module anchor', () => {
|
||||
const ctx = ctxWithHome(() => { throw new Error('hostile home') })
|
||||
const selfUrl = pathToFileURL(join(scratch, 'library-only', 'profiles', 'probe.cjs')).href
|
||||
assert.equal(detectHarnessVersion(ctx, selfUrl), '0.1.1-rc.2')
|
||||
})
|
||||
|
||||
test('a hostile ctx.get falls through to the module anchor', () => {
|
||||
const hostile = { get() { throw new Error('hostile ctx') } } as unknown as Context
|
||||
const selfUrl = pathToFileURL(join(scratch, 'library-only', 'profiles', 'probe.cjs')).href
|
||||
assert.equal(detectHarnessVersion(hostile, selfUrl), '0.1.1-rc.2')
|
||||
})
|
||||
|
||||
test('a non-file self URL leaves the probe without anchors', () => {
|
||||
const ctx = ctxWithHome(scratchResolver('empty'))
|
||||
assert.equal(detectHarnessVersion(ctx, 'https://example.invalid/probe.js'), undefined)
|
||||
})
|
||||
|
||||
test('a package whose manifest never matches while ascending → undefined', () => {
|
||||
const ctx = ctxWithHome(scratchResolver('entry-orphan'))
|
||||
assert.equal(detectHarnessVersion(ctx, BOGUS_SELF_URL), undefined)
|
||||
})
|
||||
|
||||
test('unreadable/invalid/odd manifests all degrade to undefined', () => {
|
||||
for (const home of ['nonstring-version', 'empty-version', 'empty']) {
|
||||
const ctx = ctxWithHome(scratchResolver(home))
|
||||
assert.equal(detectHarnessVersion(ctx, BOGUS_SELF_URL), undefined, home)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,71 @@
|
||||
// The version gate's arithmetic (src/shared/version.ts): parsing, the total
|
||||
// order (release > rc > beta > alpha at equal X.Y.Z), and the fail-open
|
||||
// baseline check.
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, test } from 'vitest'
|
||||
import { BASELINE_DSH_VERSION, compareVersions, meetsBaseline, parseVersion } from '../../src/shared/version'
|
||||
|
||||
describe('parseVersion', () => {
|
||||
test('parses plain releases, channels, serials, v-prefixes and build metadata', () => {
|
||||
assert.deepEqual(parseVersion('0.1.2-rc.1'), { major: 0, minor: 1, patch: 2, rank: 3, serial: 1 })
|
||||
assert.deepEqual(parseVersion('1.2.3'), { major: 1, minor: 2, patch: 3, rank: 4, serial: 0 })
|
||||
assert.deepEqual(parseVersion('v2.0.0-alpha'), { major: 2, minor: 0, patch: 0, rank: 1, serial: 0 })
|
||||
assert.deepEqual(parseVersion('0.1.2-beta.3'), { major: 0, minor: 1, patch: 2, rank: 2, serial: 3 })
|
||||
assert.deepEqual(parseVersion(' 0.1.2-RC.2 '), { major: 0, minor: 1, patch: 2, rank: 3, serial: 2 })
|
||||
assert.deepEqual(parseVersion('1.0.0+build.5'), { major: 1, minor: 0, patch: 0, rank: 4, serial: 0 })
|
||||
})
|
||||
|
||||
test('rejects non-semver strings', () => {
|
||||
for (const bad of ['', 'abc', '1.2', '1.2.3.4', '1.2.3-nightly', '1.2.3-rc.x', '0.0.0-dev', 'v1.2.3.4-something']) {
|
||||
assert.equal(parseVersion(bad), null, bad)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareVersions', () => {
|
||||
test('orders numerically, then channel (release > rc > beta > alpha), then serial', () => {
|
||||
assert.ok(compareVersions('1.0.0', '0.9.9') > 0)
|
||||
assert.ok(compareVersions('0.2.0', '0.1.9') > 0)
|
||||
assert.ok(compareVersions('0.1.3', '0.1.2') > 0)
|
||||
assert.ok(compareVersions('0.1.2', '0.1.2-rc.9') > 0, 'final outranks rc')
|
||||
assert.ok(compareVersions('0.1.2-rc.1', '0.1.2-beta.9') > 0, 'rc outranks beta')
|
||||
assert.ok(compareVersions('0.1.2-beta.1', '0.1.2-alpha.9') > 0, 'beta outranks alpha')
|
||||
assert.ok(compareVersions('0.1.2-rc.2', '0.1.2-rc.1') > 0, 'higher serial wins')
|
||||
assert.equal(compareVersions('0.1.2-rc.1', '0.1.2-rc.1'), 0)
|
||||
assert.ok(compareVersions('0.1.3-alpha.1', '0.1.2') > 0, 'a newer X.Y.Z beats any older channel')
|
||||
})
|
||||
|
||||
test('an unparseable side compares equal', () => {
|
||||
assert.equal(compareVersions('dev-build', '0.1.2'), 0)
|
||||
assert.equal(compareVersions('0.1.2', 'dev-build'), 0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('meetsBaseline', () => {
|
||||
test(`the supported baseline is ${BASELINE_DSH_VERSION}`, () => {
|
||||
assert.equal(BASELINE_DSH_VERSION, '0.1.2-rc.1')
|
||||
})
|
||||
|
||||
test('baseline and above pass; anything below fails', () => {
|
||||
assert.equal(meetsBaseline('0.1.2-rc.1'), true, 'the baseline itself')
|
||||
assert.equal(meetsBaseline('0.1.2-rc.2'), true)
|
||||
assert.equal(meetsBaseline('0.1.2'), true, 'the final release of the baseline line')
|
||||
assert.equal(meetsBaseline('0.1.3-alpha.1'), true)
|
||||
assert.equal(meetsBaseline('0.1.1-rc.2'), false)
|
||||
assert.equal(meetsBaseline('0.1.2-beta.3'), false, 'beta is below rc at equal X.Y.Z')
|
||||
assert.equal(meetsBaseline('0.1.2-alpha.1'), false)
|
||||
assert.equal(meetsBaseline('0.0.9'), false)
|
||||
})
|
||||
|
||||
test('fails open: an unparseable version (or baseline) never gates', () => {
|
||||
assert.equal(meetsBaseline('0.0.0-dev'), true)
|
||||
assert.equal(meetsBaseline('not-a-version'), true)
|
||||
assert.equal(meetsBaseline('0.1.0', 'also-junk'), true)
|
||||
})
|
||||
|
||||
test('accepts an explicit baseline', () => {
|
||||
assert.equal(meetsBaseline('0.1.2-rc.1', '0.2.0'), false)
|
||||
assert.equal(meetsBaseline('0.2.0', '0.2.0-alpha.1'), true)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user