Merge remote-tracking branch 'origin/master' into worktree/model-provider-entry-ux-dde717

This commit is contained in:
Yichen Jiang committed 2026-09-21 13:57:38 +08:00
commit ed0700ffb5
2591 files changed
+248270 -65915

No files matched your search

+2
View File
@@ -101,6 +101,8 @@ describe('Python runtime executable builder CLI', () => {
expect(result.status).toBe(0)
expect(result.stdout).toContain('exec pkg')
expect(result.stdout).toContain('--sea --targets node24-macos-x64')
expect(result.stdout).toContain('prepare Python and Office skills for mac-x64')
expect(result.stdout).toContain(join(root, 'dist-exe', 'macos-x64'))
})
it('rejects a Windows arm64 product before any build step', () => {
+16 -4
View File
@@ -9,10 +9,12 @@
import { spawn } from 'node:child_process'
import { existsSync, statSync } from 'node:fs'
import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, extname, join, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util'
import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './build-exe-for-python-sdk-native-pty.ts'
import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts'
import { preparePrimaryRuntime, smokePrimaryRuntime, type PrimaryRuntimeTarget } from './primary-runtime/prepare.ts'
const root = resolve(import.meta.dirname, '..')
@@ -419,7 +421,7 @@ class SingleExeBuild {
/**
* Package one target; SEA mode accepts one target per invocation.
* @param target - the pkg target triple to build.
* @returns the executable, Office directory, ripgrep, and required macOS spawn helper paths.
* @returns the executable, resource directories, ripgrep, and required macOS spawn helper paths.
*/
async pack(target: Target): Promise<string[]> {
const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
@@ -448,7 +450,17 @@ class SingleExeBuild {
console.log(`build-exe-for-python-sdk: copied ${packages.length} Office packages to ${office}`)
}
const ripgrep = await this.copyRipgrepSidecar(target, product)
if (target.platform !== 'macos') return [product, ripgrep, office]
const resources = join(this.outDir, `${target.platform}-${target.arch}`)
const runtimeTarget = `${target.platform === 'macos' ? 'mac' : target.platform}-${target.arch}` as PrimaryRuntimeTarget
if (this.cli.dryRun) {
console.log(`build-exe-for-python-sdk: [dry-run] prepare Python and Office skills for ${runtimeTarget} in ${resources}`)
} else {
const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version: string }
await preparePrimaryRuntime({ target: runtimeTarget, output: resources,
cache: join(tmpdir(), 'dsh-primary-runtime-downloads'), version, pythonOnly: true })
smokePrimaryRuntime(join(resources, 'primary-runtime'))
}
if (target.platform !== 'macos') return [product, ripgrep, office, resources]
const spawnHelper = `${product}-spawn-helper`
const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper')
if (this.cli.dryRun) {
@@ -457,7 +469,7 @@ class SingleExeBuild {
await copyFile(source, spawnHelper)
await chmod(spawnHelper, 0o755)
}
return [product, ripgrep, spawnHelper, office]
return [product, ripgrep, spawnHelper, office, resources]
}
/** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */
@@ -548,7 +560,7 @@ class SingleExeBuild {
continue
}
if (statSync(path).isDirectory()) {
console.log(` ${path} (Office dependency directory)`)
console.log(` ${path} (resource directory)`)
continue
}
const megabytes = statSync(path).size / (1024 * 1024)
+25 -14
View File
@@ -8,6 +8,7 @@ import email
import json
import os
import re
import runpy
import shutil
import stat
import subprocess
@@ -148,18 +149,21 @@ def validate_release_tag(tag: str | None, version: str) -> None:
def copy_package(source: Path, destination: Path) -> None:
generated_parent = source / "src" / "deepseek_harness_runtime"
ignore_files = shutil.ignore_patterns(
".venv", ".pytest_cache", "__pycache__", "*.pyc", "dist", "node_modules",
)
def ignore(directory: str, names: list[str]) -> set[str]:
ignored = ignore_files(directory, names)
if Path(directory) == generated_parent:
ignored.add("runtime")
return ignored
shutil.copytree(
source,
destination,
ignore=shutil.ignore_patterns(
".venv",
".pytest_cache",
"__pycache__",
"*.pyc",
"dist",
"node_modules",
"deepseek-harness-sdk-runtime-*",
),
ignore=ignore,
)
@@ -227,6 +231,8 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_
shutil.copy2(source_directory / filename, runtime_dir / filename)
office = office_sidecar_name(executable_name)
shutil.copytree(source_directory / office, runtime_dir / office)
resources = executable_name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe")
shutil.copytree(source_directory / resources, runtime_dir / resources)
def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platform_tag: str) -> None:
@@ -272,16 +278,17 @@ def verify_wheel(
platform: tuple[str, str] | None,
) -> None:
expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}"
expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION
dist_info = f"{expected_distribution.replace('-', '_')}-{version}.dist-info"
with zipfile.ZipFile(wheel) as archive:
wheel_metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL"))
metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
wheel_metadata_path = f"{dist_info}/WHEEL"
metadata_path = f"{dist_info}/METADATA"
wheel_metadata = email.message_from_bytes(archive.read(wheel_metadata_path))
metadata = email.message_from_bytes(archive.read(metadata_path))
if wheel_metadata.get_all("Tag") != [expected_tag]:
raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}")
if metadata.get("Version") != version:
raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}")
expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION
if metadata.get("Name") != expected_distribution:
raise RuntimeError(
f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}"
@@ -297,17 +304,21 @@ def verify_wheel(
f"{wheel} has license files {license_files}, expected {expected_license_files}"
)
runtime_payload = [
name for name in archive.namelist() if "/runtime/deepseek-harness-sdk-runtime-" in name
name for name in archive.namelist() if name.startswith("deepseek_harness_runtime/runtime/")
]
if package == "runtime":
assert platform is not None
office = office_sidecar_name(platform[1])
expected_files = sorted((*runtime_filenames(platform[1]), office))
resources = platform[1].removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe")
expected_files = sorted((*runtime_filenames(platform[1]), office, resources))
found_files = sorted({name.split("/runtime/", 1)[1].split("/", 1)[0] for name in runtime_payload})
if found_files != expected_files:
raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}")
office_modules = f"deepseek_harness_runtime/runtime/{office}/node_modules"
verify_office_payload(archive, office_modules, platform[0])
validate = runpy.run_path(str(ROOT / "python/sdk-runtime/src/deepseek_harness_runtime/_resources.py"))["validate_resources"]
target = next(name for name, value in PLATFORMS.items() if value == platform)
validate(zipfile.Path(archive, f"deepseek_harness_runtime/runtime/{resources}/"), target)
for runtime_file in runtime_payload:
if "/" in runtime_file.split("/runtime/", 1)[1]:
continue
@@ -164,6 +164,65 @@ describe('dsh family version coherence', () => {
})
describe('package payload constraints', () => {
it.each(['./art/icon.svg', 'art/icon.svg'])('includes declared icon %s in the canonical payload', (icon) => {
expect(expectedDshPackageFiles({ icon, exports: { './locale/*.json': './locale/*.json' } })).toEqual([
'art/icon.svg', 'locale/*.json', 'lib/index.js', 'lib/types/**/*.d.ts',
])
})
it('accepts the Agent Team icon payload and rejects its omission', () => {
const dir = 'packages/experimental/agent-team-profile'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, files: manifest.files!.filter(file => file !== 'icon.svg') } }))
.toEqual([expect.stringContaining('package.json files must be')])
})
it.each([
{ exports: { './locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] },
{ exports: { './search/locale/*.json': './resources/search/*.json' }, resources: ['resources/search/*.json'] },
{ exports: { './locale/en.json': './locale/en.json', './locale/zh.json': './locale/zh.json' }, resources: ['locale/en.json', 'locale/zh.json'] },
{ exports: { './locale/*.json': { default: './locale/*.json' } }, resources: ['locale/*.json'] },
{ exports: { './locale/*.json': './locale/*.json', './search/locale/*.json': './locale/*.json' }, resources: ['locale/*.json'] },
{ exports: { './search/locale/*.json': './z/*.json', './locale/*.json': './a/*.json' }, resources: ['a/*.json', 'z/*.json'] },
])('includes declared locale resources in the canonical payload: $exports', ({ exports, resources }) => {
expect(expectedDshPackageFiles({ name: '@deepseek-ai/dsh-localized', exports })).toEqual([
...resources, 'lib/index.js', 'lib/types/**/*.d.ts',
])
})
it('does not infer locale payloads from unrelated or non-JSON exports', () => {
expect(expectedDshPackageFiles({
exports: {
'./config.json': './config.json',
'./locale/README.md': './locale/README.md',
'./locale/en.json': './metadata.js',
'./locale/zh.json': null,
'./locale/fr.json': { types: './locale/fr.d.ts' },
},
})).toEqual(['lib/index.js', 'lib/types/**/*.d.ts'])
})
it.each(['agent-team', 'agent-team-profile', 'auto-review', 'client-ui-agent-team', 'tool-agent-team'])(
'accepts the published locale files for %s and rejects their omission', (name) => {
const dir = `packages/experimental/${name}`
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
expect(checkWorkspaceManifest({ dir, manifest: {
...manifest, files: manifest.files!.filter(file => file !== 'locale/*.json'),
} })).toEqual([expect.stringContaining('package.json files must be')])
},
)
it('rejects locale publication entries without their resource exports', () => {
const dir = 'packages/experimental/auto-review'
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
const exports = { ...manifest.exports }
delete exports['./locale/*.json']
expect(checkWorkspaceManifest({ dir, manifest: { ...manifest, exports } }))
.toEqual([expect.stringContaining('package.json files must be')])
})
it('includes a declared profile patch without a package-name allowlist', () => {
expect(expectedDshPackageFiles({
name: '@deepseek-ai/dsh-private-profile',
@@ -173,6 +232,15 @@ describe('package payload constraints', () => {
'cordis.patch.yml',
'lib/types/**/*.d.ts',
])
expect(expectedDshPackageFiles({
name: '@deepseek-ai/dsh-private-profile',
dsh: { bundle: { patch: ['./cordis.patch.yml', './layers/web.patch.yml'] } },
})).toEqual([
'lib/index.js',
'cordis.patch.yml',
'layers/web.patch.yml',
'lib/types/**/*.d.ts',
])
})
it.each([
+19 -6
View File
@@ -13,7 +13,8 @@ import {
PRIVATE_EXPERIMENTAL_PACKAGE_DIRECTORIES,
} from './experimental-package-policy.ts'
import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
import { OPTIONAL_BUNDLES } from '../packages/boot/app-boot/src/profile.ts'
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
import { collectProjectReferenceFaceViolations } from './project-reference-faces.ts'
const root = resolve(import.meta.dirname, '..')
@@ -90,6 +91,7 @@ export interface PackageManifest {
| undefined
>
files?: string[]
icon?: string
publishConfig?: { access?: string }
repository?: { type?: string; url?: string; directory?: string }
peerDependencies?: Record<string, string>
@@ -97,9 +99,7 @@ export interface PackageManifest {
dependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
dsh?: {
bundle?: {
patch?: string
}
bundle?: DshBundleManifest
}
}
@@ -209,14 +209,27 @@ function sameStringList(actual: readonly string[] | undefined, expected: readonl
return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index])
}
/**
* Compute canonical publication patterns, including the declared icon and exported locale JSON resources.
* @param manifest - workspace package manifest.
* @returns the icon and deduplicated locale targets followed by runtime and declaration payloads.
*/
export function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
const declaredPatch = manifest.dsh?.bundle?.patch
const bundleFiles = declaredPatch === undefined ? [] : [declaredPatch.replace(/^\.\//, '')]
const localeFiles = new Set<string>()
for (const resource of Object.keys(manifest.exports ?? {})) {
if (!/^\.\/(?:.+\/)?locale\/[^/]+\.json$/u.test(resource)) continue
const target = exportDefault(manifest, resource)
if (target?.startsWith('./') && target.endsWith('.json')) localeFiles.add(target.slice(2))
}
const bundle = manifest.dsh?.bundle
const bundleFiles = bundle === undefined ? [] : bundlePatchFiles(bundle).map(file => file.replace(/^\.\//, ''))
const extras = [
...bundleFiles,
...(manifest.name ? packageFileExtras[manifest.name] ?? [] : []),
]
return [
...typeof manifest.icon === 'string' ? [manifest.icon.replace(/^\.\//u, '')] : [],
...[...localeFiles].sort(),
'lib/index.js',
// Packages with an invariant export publish its runtime as a separate
// bundle; the package-invariant gate validates the source/export pairing.
+1 -1
View File
@@ -35,7 +35,7 @@ function evaluate(expression: string, context: Record<string, unknown>): unknown
const body = expression.trim().slice(3, -2)
return runInNewContext(body, {
...context, fromJSON: JSON.parse,
}, { timeout: 1000 }) as unknown
}, { timeout: 1000 })
}
function route(options: { mode?: string; author?: string; repository?: string; fork?: boolean; actor?: string } = {}): unknown {
+13
View File
@@ -659,6 +659,19 @@ describe('Runtime and LLM e2e Blacksmith routing', () => {
})
})
describe('bubblewrap preparation script', () => {
it('pins bubblewrap to a recorded Launchpad build and excludes Ubuntu pool downloads', () => {
const script = readFileSync(resolve(root, 'scripts/prepare-ci-bubblewrap.sh'), 'utf8')
const url = /^readonly BUBBLEWRAP_URL="([^"]+)"$/mu.exec(script)?.[1]
expect(url).toMatch(
/^https:\/\/launchpad\.net\/ubuntu\/\+source\/bubblewrap\/[^/]+\/\+build\/\d+\/\+files\/bubblewrap_[^/]+_amd64\.deb$/u,
)
// Ubuntu removes superseded versions from its live package pool.
expect(script).not.toMatch(/https?:\/\/[^/'"\s]+\/ubuntu(?:-ports)?\/pool\//u)
})
})
describe('DeepSeek e2e workflow', () => {
it('prepares bubblewrap from the pinned payload without a package transaction', () => {
const workflow = loadWorkflow('.github/workflows/e2e.yml')
+2
View File
@@ -141,6 +141,8 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
expect(resolveId('@deepseek-ai/dsh-host-open-in-app/shared')).toBeNull()
expect(resolveId('@deepseek-ai/dsh-native-command/types')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-native-command')).toThrow('client bundle purity')
expect(() => resolveId('@deepseek-ai/dsh-host-open-in-app')).toThrow(/purity/)
expect(resolveId('@deepseek-ai/dsh-plugin-manager/registry')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-plugin-manager')).toThrow(/purity/)
+2 -1
View File
@@ -347,7 +347,8 @@ function mergePartitionRecords(
const map = coverageLibrary.createCoverageMap({})
for (const record of records) {
if (canonicalize) canonicalizeEndOfLineColumns({ data: record })
map.merge(JSON.parse(JSON.stringify(record)) as unknown)
const serializedRecord: unknown = JSON.parse(JSON.stringify(record))
map.merge(serializedRecord)
}
return map.fileCoverageFor(CANONICALIZED_FILE)
}
+98 -1
View File
@@ -1,15 +1,112 @@
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { TsdownBundle } from 'tsdown'
import { writeClientBuildRecord } from './client-build-environment.ts'
import {
devWebBuildEnvironment,
discoverLibraryDirs,
discoverPluginDirs,
parseDevWebArguments,
StageSupervisor,
watchClientPlugins,
} from './dev-web.ts'
import type { StageHandle } from './dev-web.ts'
/** A stage double that exits only on the listed signals or an explicit exit code. */
function fakeStage(name: string, exitOn: readonly NodeJS.Signals[]): {
handle: StageHandle
signals: NodeJS.Signals[]
exit: (code: number) => void
} {
const exit = Promise.withResolvers<number | null>()
const signals: NodeJS.Signals[] = []
return {
handle: {
name,
exited: exit.promise,
kill(signal) {
signals.push(signal)
if (exitOn.includes(signal)) exit.resolve(null)
},
},
signals,
exit: (code) => { exit.resolve(code) },
}
}
describe('StageSupervisor', () => {
it('reports a stage that exits on its own', async () => {
const stale: [string, number | null][] = []
const supervisor = new StageSupervisor((name, code) => { stale.push([name, code]) })
const tsc = fakeStage('tsc', ['SIGTERM'])
supervisor.add(tsc.handle)
tsc.exit(2)
await tsc.handle.exited
expect(stale).toEqual([['tsc', 2]])
})
it('forwards one signal, escalates survivors after the grace period, and reports none of them', async () => {
const stale: string[] = []
const supervisor = new StageSupervisor((name) => { stale.push(name) })
const polite = fakeStage('vite', ['SIGTERM'])
const stubborn = fakeStage('dsh web', ['SIGKILL'])
supervisor.add(polite.handle)
supervisor.add(stubborn.handle)
await supervisor.stop({ signal: 'SIGTERM', graceMs: 20 })
expect(polite.signals).toEqual(['SIGTERM'])
expect(stubborn.signals).toEqual(['SIGTERM', 'SIGKILL'])
expect(stale).toEqual([])
})
it('waits for stages the terminal already interrupted before signaling survivors', async () => {
const supervisor = new StageSupervisor(() => { throw new Error('unexpected stale report') })
const interrupted = fakeStage('tsdown', [])
const survivor = fakeStage('dsh web', ['SIGTERM'])
supervisor.add(interrupted.handle)
supervisor.add(survivor.handle)
const stopping = supervisor.stop({ graceMs: 20 })
interrupted.exit(130)
await stopping
expect(interrupted.signals).toEqual([])
expect(survivor.signals).toEqual(['SIGTERM'])
})
})
describe('parseDevWebArguments', () => {
it('builds, serves, and watches natively by default', () => {
expect(parseDevWebArguments([])).toEqual({ skipBuild: false, serve: true, pollInterval: undefined, appArgs: [] })
})
it('reads the polling interval with its 500ms default', () => {
expect(parseDevWebArguments(['--poll']).pollInterval).toBe(500)
expect(parseDevWebArguments(['--poll=250']).pollInterval).toBe(250)
})
it.each(['--poll=abc', '--poll=0', '--poll=-5', '--poll=1.5'])('rejects the polling interval %s', (flag) => {
expect(() => parseDevWebArguments([flag])).toThrow(`invalid --poll interval "${flag}"`)
})
it('separates its own flags from the arguments forwarded to dsh web', () => {
expect(parseDevWebArguments(['--skip-build', '--poll', '--no-open', '--port', '8080'])).toEqual({
skipBuild: true, serve: true, pollInterval: 500, appArgs: ['--no-open', '--port', '8080'],
})
})
it('runs only the rebuild watchers with --no-serve', () => {
expect(parseDevWebArguments(['--no-serve', '--skip-build'])).toMatchObject({ serve: false, skipBuild: true })
})
it('ignores the separator pnpm run forwards verbatim', () => {
expect(parseDevWebArguments(['--', '--no-open']).appArgs).toEqual(['--no-open'])
expect(parseDevWebArguments(['--no-serve', '--'])).toMatchObject({ serve: false, appArgs: [] })
})
it('rejects dsh web arguments when no server is started', () => {
expect(() => parseDevWebArguments(['--no-serve', '--no-open'])).toThrow('--no-serve leaves no dsh web process for --no-open')
})
})
it('samples one local environment at startup without validating watcher outputs', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-environment-'))
+249 -76
View File
@@ -1,28 +1,38 @@
/**
* Watch-build for the web dev loop: rebuilds every artifact the browser reads
* from a source edit. Reload signaling is not this script's business — the host
* webserver stat-polls the bundles it serves and broadcasts `rebuilt` frames
* itself (`dsh web`), so any process that rewrites `lib/client.js` files
* triggers reloads; this script is merely the convenient way to keep them all
* rebuilt on source change.
* The Web development loop: build once, serve through `dsh web`, and keep every
* browser-side artifact rebuilt on source edits, in one terminal.
*
* Three stages, because the compile shell links built lib products rather than
* sources: `tsc -b tsconfig.client.json` emits `lib/types` (the tsdown lib
* entries are that emit, not `src`), tsdown bundles `lib/index.js` and
* `lib/client.js`, and `vite build` rewrites `apps/web/dist`, which `dsh web`
* serves. A missing stage does not fail — it silently shows the previous
* artifact, so an edit appears to do nothing.
* Stages, in order: `pnpm run build` (skipped by `--skip-build`), three
* long-lived watchers, then `dsh web` (skipped by `--no-serve`). The watchers
* exist because the compile shell links built lib products rather than
* sources: `tsc -b tsconfig.client.json --watch` emits `lib/types` (the tsdown
* lib entries are that emit, not `src`), tsdown watch bundles `lib/index.js`
* and `lib/client.js`, and `vite build --watch` rewrites `apps/web/dist`, which
* `dsh web` serves. A missing watcher does not fail — it silently shows the
* previous artifact, so an edit appears to do nothing; any stage exiting on its
* own therefore stops the loop with exit code 1.
*
* MUST NOT run concurrently with `pnpm run build`: both write the same
* `lib/` and `apps/web/dist/` trees.
* Reload signaling is not this script's business — the host webserver
* stat-polls the bundles it serves and broadcasts `rebuilt` frames itself, so
* any process that rewrites `lib/client.js` files triggers reloads.
*
* Usage: `pnpm exec tsx scripts/dev-web.ts [--poll[=ms]]`. Requires one prior
* `pnpm run build`: every stage is incremental over the previous stage's output
* and none of them bootstraps a missing tree. `--poll` switches the source
* watchers to polling (default 500ms): network mounts (weka) deliver no inotify
* events, so native watching sees the initial build only and never a source
* change. Polling has to reach tsc too — a native-watching tsc never re-emits
* `lib/types`, which strands the other two stages on stale input.
* MUST NOT run beside `pnpm run build`: both write the same `lib/` and
* `apps/web/dist/` trees. The build stage here finishes before any watcher starts.
*
* Usage: `pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]`.
* `--skip-build` requires the artifact tree from a prior complete build: every
* watcher is incremental over the previous stage's output and none of them
* bootstraps a missing tree. `--no-serve` keeps only the watchers, for a
* `dsh web` started elsewhere. `--poll` switches the source watchers to polling
* (default 500ms): network mounts (weka) deliver no inotify events, so native
* watching sees the initial build only and never a source change. Polling has
* to reach tsc too — a native-watching tsc never re-emits `lib/types`, which
* strands the other two stages on stale input.
*
* Shutdown: Ctrl+C reaches every stage through the terminal's process group, so
* the script only waits for them and escalates survivors; SIGTERM is addressed
* to this process alone and is forwarded once. The exit code is 130 after
* SIGINT, 0 after SIGTERM, and 1 when a stage exited on its own.
*
* Each package keeps its own tsdown.config.ts untouched: this script layers
* `watch` through API-level inline config (tsdown workspace mode fills inline
@@ -39,6 +49,7 @@ import {
clientBuildProcessEnvironment,
repositoryClientBuildEnvironment,
} from './client-build-environment.ts'
import { pnpmInvocation } from './pnpm-invocation.ts'
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
@@ -67,6 +78,51 @@ export function devWebBuildEnvironment(
return clientBuildProcessEnvironment(environment, repositoryClientBuildEnvironment(root, environment))
}
/** Resolved `dev-web` command line: the script's own flags plus the arguments forwarded to `dsh web`. */
export interface DevWebArguments {
/** Skip the complete `pnpm run build` that otherwise precedes the watchers. */
readonly skipBuild: boolean
/** Start `dsh web`; false keeps only the rebuild watchers beside an already running server. */
readonly serve: boolean
/** Source-watcher polling interval in milliseconds; undefined selects native watching. */
readonly pollInterval: number | undefined
/** Arguments forwarded verbatim to `dsh web`, in order. */
readonly appArgs: readonly string[]
}
/** Polling interval selected by a bare `--poll`. */
const DEFAULT_POLL_INTERVAL = 500
/**
* Parse the script's command line. `--skip-build`, `--no-serve`, and `--poll[=ms]`
* belong to this script; a bare `--` is dropped; every other token is forwarded
* to `dsh web`.
* @param argv - arguments after the script path.
* @returns the resolved flags and forwarded arguments.
* @throws Error when `--poll` carries a non-positive or non-integer interval, or
* when `--no-serve` leaves forwarded arguments without a `dsh web` process.
*/
export function parseDevWebArguments(argv: readonly string[]): DevWebArguments {
let skipBuild = false
let serve = true
let pollInterval: number | undefined
const appArgs: string[] = []
for (const arg of argv) {
// `pnpm run` forwards a `--` separator verbatim; it carries no meaning here.
if (arg === '--') continue
if (arg === '--skip-build') skipBuild = true
else if (arg === '--no-serve') serve = false
else if (arg === '--poll' || arg.startsWith('--poll=')) {
pollInterval = arg === '--poll' ? DEFAULT_POLL_INTERVAL : Number(arg.slice('--poll='.length))
if (!Number.isInteger(pollInterval) || pollInterval <= 0) throw new Error(`dev-web: invalid --poll interval "${arg}"`)
} else appArgs.push(arg)
}
if (!serve && appArgs.length > 0) {
throw new Error(`dev-web: --no-serve leaves no dsh web process for ${appArgs[0] ?? ''}`)
}
return { skipBuild, serve, pollInterval, appArgs }
}
/**
* Discover the watch workspace by declaration: every packages/<group>/<name>
* whose package.json carries `dsh.client` with platform "web" is a client
@@ -153,46 +209,166 @@ export async function watchClientPlugins(
return bundles
}
/**
* Live watcher processes to terminate when this script is interrupted. Stages
* register themselves as they start, so the set is complete from the first
* spawn: an interrupt during a later stage's startup still tears down the
* earlier ones instead of orphaning them.
*/
const stages: StageHandle[] = []
/** One long-lived stage process as the supervisor observes it. */
export interface StageHandle {
/** Command label used in diagnostics. */
readonly name: string
/** Settles with the exit code, or null when a signal ended the stage. */
readonly exited: Promise<number | null>
/**
* Deliver one signal; a no-op once the stage has exited.
* @param signal - the signal to send.
*/
kill(signal: NodeJS.Signals): void
}
/** How a requested stop reaches the stages. */
export interface StageStop {
/** Signal sent to every live stage first; omitted when the terminal already delivered SIGINT to them. */
readonly signal?: NodeJS.Signals | undefined
/** Milliseconds to wait for exits before escalating to SIGTERM, and again before SIGKILL. */
readonly graceMs: number
}
/**
* Spawn one watcher stage, inheriting stdio, registering it for teardown, and
* failing loud if it ever exits: a dead stage leaves the artifact chain silently
* stale, which reads as "my edit did nothing" — the one failure this script
* exists to prevent.
* @param stage - command label used in the exit diagnostic.
* @param command - executable, resolved from the workspace bin when local.
* Own the live stages: report a stage that exits before a stop was requested,
* and on a requested stop signal, wait, and escalate until every stage has exited.
*/
export class StageSupervisor {
private readonly live = new Set<StageHandle>()
private stopping = false
/**
* @param onStale - receives the stage name and exit code when a stage exits before a stop was requested.
*/
constructor(private readonly onStale: (stage: string, code: number | null) => void) {}
/**
* Track one started stage until it exits.
* @param stage - the stage to supervise.
*/
add(stage: StageHandle): void {
this.live.add(stage)
void stage.exited.then((code) => {
this.live.delete(stage)
if (!this.stopping) this.onStale(stage.name, code)
})
}
/**
* Stop every live stage and resolve once all of them have exited.
* @param stop - initial signal and the grace period between escalation steps.
*/
async stop(stop: StageStop): Promise<void> {
this.stopping = true
const escalation: (NodeJS.Signals | undefined)[] = [stop.signal]
if (stop.signal !== 'SIGTERM') escalation.push('SIGTERM')
for (const signal of escalation) {
if (this.live.size === 0) return
if (signal !== undefined) for (const stage of this.live) stage.kill(signal)
await this.settled(stop.graceMs)
}
for (const stage of this.live) stage.kill('SIGKILL')
await Promise.all([...this.live].map(stage => stage.exited))
}
private async settled(graceMs: number): Promise<void> {
let timer: NodeJS.Timeout | undefined
await Promise.race([
Promise.all([...this.live].map(stage => stage.exited)),
new Promise<void>((resolve) => { timer = setTimeout(resolve, graceMs) }),
])
clearTimeout(timer)
}
}
/**
* Grace period per escalation step. `dsh web` gives its plugin tree five seconds
* to dispose after the first signal; escalating earlier would turn the graceful
* drain into a forced exit.
*/
const STOP_GRACE_MS = 6_000
/**
* Spawn one long-lived stage with inherited stdio and hand it to the supervisor.
* @param supervisor - owner that reports an unexpected exit and stops the stage on shutdown.
* @param name - command label used in diagnostics.
* @param command - executable, resolved from the workspace bins when `local` is set.
* @param args - command arguments.
* @param local - whether to resolve `command` from the workspace's installed bins.
*/
function spawnStage(stage: string, command: string, args: readonly string[], local: boolean): void {
function spawnStage(
supervisor: StageSupervisor,
name: string,
command: string,
args: readonly string[],
local: boolean,
): void {
const child = execa(command, [...args], {
cwd: repoRoot,
stdio: 'inherit',
preferLocal: local,
reject: false,
})
stages.push({ kill: () => { child.kill() } })
void child.then((result) => {
console.error(`dev-web: ${stage} exited (code ${String(result.exitCode)}); the artifact chain is now stale`)
process.exit(1)
supervisor.add({
name,
exited: child.then(result => result.exitCode ?? null),
kill(signal) { child.kill(signal) },
})
}
/** The only capability this script needs from a live watcher process. */
interface StageHandle {
readonly kill: () => void
/**
* Run the complete repository build with the launch environment untouched:
* `scripts/build.ts` samples the public client values itself.
* @returns the build's exit code, or null when a signal ended it.
*/
async function runBuild(): Promise<number | null> {
const invocation = pnpmInvocation(['run', 'build'])
const result = await execa(invocation.command, invocation.args, { cwd: repoRoot, stdio: 'inherit', reject: false })
return result.exitCode ?? null
}
const invokedPath = process.argv[1]
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
if (isMain) {
let options: DevWebArguments
try {
options = parseDevWebArguments(process.argv.slice(2))
} catch (error) {
console.error(error instanceof Error ? error.message : error)
console.error('dev-web: usage: pnpm run dev:web [--skip-build] [--no-serve] [--poll[=ms]] [dsh web arguments]')
process.exit(1)
}
// Shutdown is requested once, by a terminal signal or by a stage exiting on
// its own; `signal` stays unset for SIGINT because the terminal's process
// group already delivered it to every stage.
const shutdown = Promise.withResolvers<number>()
const requested: { code?: number; signal?: NodeJS.Signals } = {}
const shutdownRequested = (): boolean => requested.code !== undefined
const requestShutdown = (code: number, signal: NodeJS.Signals | undefined): void => {
if (shutdownRequested()) return
requested.code = code
if (signal !== undefined) requested.signal = signal
shutdown.resolve(code)
}
const supervisor = new StageSupervisor((stage, code) => {
console.error(`dev-web: ${stage} exited (code ${String(code)}); stopping the other stages`)
requestShutdown(1, 'SIGTERM')
})
// Persistent listeners, not `once`: the tsdown watchers bundle signal-exit,
// which re-raises a signal whenever it finds no other listener left and would
// terminate this process in the middle of the teardown below.
process.on('SIGINT', () => { requestShutdown(130, undefined) })
process.on('SIGTERM', () => { requestShutdown(0, 'SIGTERM') })
const buildExit = options.skipBuild ? 0 : await runBuild()
if (shutdownRequested()) process.exit(await shutdown.promise)
if (buildExit !== 0) {
console.error(`dev-web: pnpm run build exited (code ${String(buildExit)})`)
process.exit(1)
}
const buildEnvironment = devWebBuildEnvironment(repoRoot, process.env)
for (const name of Object.keys(process.env)) {
if (name === CLIENT_BUILD_PROFILE_SELECTOR || name.startsWith('DSH_CLIENT_')) {
@@ -214,32 +390,14 @@ if (isMain) {
process.exit(1)
}
const args = process.argv.slice(2)
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
if (args.some(a => a !== pollArg)) {
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
process.exit(1)
}
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
process.exit(1)
}
// Registered before any stage starts: `stages` is read at signal time, so an
// interrupt during tsdown's initial builds still kills whatever is running.
const stop = (): void => { for (const stage of stages) stage.kill() }
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
// tsc has no polling interval flag, so `--poll` selects its fixed-interval
// watchers rather than an interval. Dropping that translation leaves tsc
// natively watching on a network mount where inotify never fires: it stops
// re-emitting lib/types, and the two later stages then rebuild forever from
// stale input without printing anything.
spawnStage(`tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
spawnStage(supervisor, `tsc -b ${CLIENT_TYPE_PROGRAM} --watch`, 'tsc', [
'-b', CLIENT_TYPE_PROGRAM, '--watch', '--preserveWatchOutput',
...pollInterval !== undefined
...options.pollInterval !== undefined
? ['--watchFile', 'fixedPollingInterval', '--watchDirectory', 'fixedPollingInterval']
: [],
], true)
@@ -248,19 +406,34 @@ if (isMain) {
// first build reads current lib bundles rather than whatever the last full
// build left. Its own watch then covers later lib rewrites — those files are
// in its module graph.
await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], pollInterval)
// Through the shell's own `watch` script rather than vite's API: vite is not a
// repository-root dependency, and more importantly the vite root is its
// working directory — `resolve.dedupe` resolves react from that root, so
// running vite from anywhere but apps/web silently switches which react copy
// the bundle gets.
spawnStage('vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
const bundles = await watchClientPlugins(repoRoot, [...pluginDirs, ...libraryDirs], options.pollInterval)
if (!shutdownRequested()) {
// Through the shell's own `watch` script rather than vite's API: vite is not a
// repository-root dependency, and more importantly the vite root is its
// working directory — `resolve.dedupe` resolves react from that root, so
// running vite from anywhere but apps/web silently switches which react copy
// the bundle gets.
spawnStage(supervisor, 'vite build --watch', 'pnpm', ['--filter', SHELL_PACKAGE, 'run', 'watch'], false)
// The same launch vector as the root `dsh` script, so the served Host runs
// from source exactly as `pnpm dsh web` would.
if (options.serve) {
spawnStage(supervisor, 'dsh web', process.execPath, [
'--import', 'tsx/esm', 'apps/cli/src/bin.ts', 'web', ...options.appArgs,
], false)
}
console.log(
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
+ ` and ${String(libraryDirs.length)} statically linked library packages`
+ (options.pollInterval !== undefined ? ` (polling ${String(options.pollInterval)}ms)` : '')
+ `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build`
+ (options.serve ? ', serving through dsh web' : '')
+ ':\n '
+ [...pluginDirs, ...libraryDirs].join('\n '),
)
}
console.log(
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
+ ` and ${String(libraryDirs.length)} statically linked library packages`
+ (pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : '')
+ `, plus tsc -b ${CLIENT_TYPE_PROGRAM} and the ${SHELL_PACKAGE} dist build:\n `
+ [...pluginDirs, ...libraryDirs].join('\n '),
)
const exitCode = await shutdown.promise
await supervisor.stop({ signal: requested.signal, graceMs: STOP_GRACE_MS })
for (const bundle of bundles) await bundle[Symbol.asyncDispose]()
process.exit(exitCode)
}
+1
View File
@@ -73,6 +73,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
'packages/session/session-format-catalog': 'Generated build-static Session format inventory with no plugin registration.',
'packages/session/session-format-v0-to-v1': 'Pure released-v0 codec and adjacent migration library.',
'packages/session/session-format-v2-to-v3': 'Pure released-v2 codec and adjacent migration library.',
'packages/session/session-format-v3-to-v4': 'Released V3 codec reuse and adjacent migration library.',
'packages/session/session-telemetry': 'Telemetry Service Definition and capture library; providers mount the backend.',
'packages/session/session-title-llm': 'Shared LLM title-provider registration and request policy.',
'packages/subagent/subagent-in-process-driver': 'Shared one-shot child-agent driver used by provider plugins.',
@@ -24,7 +24,7 @@ export function apply(ctx, config) {
injected = true
return {
...decision,
messages: [...decision.messages, { id: 'python-snapshot-image', role: 'user', content, source: { kind: 'plugin', plugin: name } }],
messages: [...decision.messages, { id: 'python-snapshot-image', role: 'user', content, source: { kind: name } }],
}
})
ctx.on('llm/stream', async function* (options, next) {
+7
View File
@@ -183,6 +183,13 @@ describe('client slot projection', () => {
expect(entry?.example).toContain("ctx.slots.inject('demo.seat'")
expect(entry?.example).toContain("id: 'my-entry'")
})
it('uses an authored example when a slot interaction needs more than generic markup', () => {
const [entry] = resolveSlotEntries([
declaration({ jsDoc: '/** A seat.\n * @example\n * return { custom: true }\n */' }),
], [], OWNER_TYPES, kits)
expect(entry?.example).toBe('return { custom: true }')
})
})
describe('the per-slot report budget', () => {
+17
View File
@@ -336,6 +336,8 @@ function keyDomainOf(declaration: SlotDeclaration, occupants: readonly SlotRegis
/** A runnable minimal registration for one slot, per cardinality. */
function exampleOf(declaration: SlotDeclaration): string {
const authored = blockTag(declaration.jsDoc, 'example')
if (authored !== undefined) return authored
const options = [`name: '${declaration.key}'`, ...KIND_EXAMPLE[declaration.kind] ?? []].join(', ')
return [
'return {',
@@ -350,6 +352,21 @@ function exampleOf(declaration: SlotDeclaration): string {
].join('\n')
}
/** One multiline JSDoc block tag, without comment decoration. */
function blockTag(jsDoc: string, name: string): string | undefined {
const lines = jsDoc.replace(/^\/\*\*/, '').replace(/\*\/$/, '').split('\n')
.map(line => line.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
const start = lines.findIndex(line => line === `@${name}` || line.startsWith(`@${name} `))
if (start < 0) return undefined
const body = [lines[start]?.slice(name.length + 1).trimStart() ?? '']
for (const line of lines.slice(start + 1)) {
if (line.startsWith('@')) break
body.push(line)
}
const value = body.join('\n').trim()
return value === '' ? undefined : value
}
/** Extra example options per cardinality. */
const KIND_EXAMPLE: Readonly<Record<string, readonly string[]>> = {
single: [],
+46 -18
View File
@@ -119,6 +119,7 @@ export const SERVICE_PAGE: Record<string, string> = {
subprocess: 'subprocess.md',
systemPrompt: 'system-prompt.md',
jobs: 'jobs.md',
jobController: 'jobs.md',
sessionTelemetry: 'session-telemetry.md',
agentTeams: 'agent-team.md',
tokenMeter: 'token-meter.md',
@@ -157,6 +158,7 @@ export const SERVICE_PAGE: Record<string, string> = {
* to a model as `cordis_runtime_inspect what:"client"`).
*/
export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
invocation: 'not a service: per-call accessor (RemoteInvocation | undefined) the Gateway derives for each Remote call — packages/api/gateway/README.md owns the contract',
webTerminals: 'client-side terminal view models — packages/api/terminal-controller/README.md owns the API',
appReady: 'not a service: launcher-provided successful-startup signal — packages/boot/cmdline/README.md owns the launcher contract',
appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract',
@@ -379,6 +381,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ModelCatalog: 'session.md',
SessionOpenWorkspacePathRequest: 'session.md',
SessionOpenWorkspacePathValue: 'session.md',
SessionWorkspacePathApplication: 'session.md',
SessionModels: 'session.md',
SessionModelsRequest: 'session.md',
SessionPage: 'session.md',
@@ -560,7 +563,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ContinuableStart: 'subagent.md',
ContinuableStartSpec: 'subagent.md',
AgentMessageSource: 'subagent.md',
SubagentCatalog: 'subagent.md',
SubagentCatalogEntry: 'subagent.md',
SubagentCatalogState: 'subagent.md',
SessionProjectionsRequest: 'session.md',
SessionProjectionsValue: 'session.md',
SubagentDescendantListEntry: 'subagent.md',
SubagentSendMessageOptions: 'subagent.md',
SubagentInterruptAuthority: 'subagent.md',
@@ -579,12 +585,28 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
PromptSectionOrderName: 'system-prompt.md',
SystemPrompt: 'system-prompt.md',
ToolProviderResult: 'system-prompt.md',
JobDoneListener: 'jobs.md',
JobId: 'jobs.md',
JobFollowFrame: 'jobs.md',
JobFollowRequest: 'jobs.md',
JobListFrame: 'jobs.md',
JobListRequest: 'jobs.md',
JobRead: 'jobs.md',
JobSnapshot: 'jobs.md',
JobStart: 'jobs.md',
JobsChangedListener: 'jobs.md',
JobView: 'jobs.md',
JobChunk: 'jobs.md',
JobSpec: 'jobs.md',
JobHandle: 'jobs.md',
JobHooks: 'jobs.md',
JobOutcome: 'jobs.md',
JobOutputSource: 'jobs.md',
JobSourceRead: 'jobs.md',
JobOutputRead: 'jobs.md',
JobAppendOptions: 'jobs.md',
JobChannel: 'jobs.md',
JobEvent: 'jobs.md',
JobEventFilter: 'jobs.md',
JobEventListener: 'jobs.md',
JobEvents: 'jobs.md',
JobSettleCause: 'jobs.md',
CreateTeamTaskRequest: 'agent-team.md',
SendTeamMessageRequest: 'agent-team.md',
SendTeamMessageResult: 'agent-team.md',
@@ -678,6 +700,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
WorkspaceArchiveSessionRequest: 'workspace.md',
WorkspaceArchiveValue: 'workspace.md',
WorkspaceCreateRequest: 'workspace.md',
WorkspaceInitializeDefaultRequest: 'workspace.md',
WorkspaceCreateValue: 'workspace.md',
WorkspaceDeleteRequest: 'workspace.md',
WorkspaceDeleteValue: 'workspace.md',
@@ -748,6 +771,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
ConnectionFetchHandler: 'shared Fetch dispatch is owned by packages/client/connection/src/rpc.ts',
ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts',
ConnectionTrustRequest: 'transport authentication input is owned by packages/client/connection/src/rpc.ts',
PeerAdmission: 'Peer admission outcome is owned by packages/client/connection/src/rpc.ts',
PeerScope: 'Peer scope contract is owned by packages/typert/protocol/src/types.ts',
ConnectionIndexRequest: 'frontend authentication request is owned by packages/client/connection/src/rpc.ts',
ConnectionIndexResponse: 'frontend authentication response is owned by packages/client/connection/src/rpc.ts',
Profile: 'resolved profile layers are owned by packages/boot/app-boot/README.md',
@@ -830,6 +855,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
WorkspaceFileScope: 'Host workspace file lookup contract is owned by packages/api/workspace-files/README.md',
WorkspaceByteReadOptions: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
WorkspaceByteRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
WorkspaceDirectoryListing: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
WorkspaceFileBytes: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
@@ -888,19 +914,21 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
],
}],
inheritedEvents: [
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:328' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:330' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:332' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:334' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:336' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:338' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:27' },
{ name: 'internal/plugin', summary: 'A plugin fiber was created.', source: 'vendor/cordis/src/events.ts:331' },
{ name: 'internal/status', summary: 'A fiber changed lifecycle state.', source: 'vendor/cordis/src/events.ts:333' },
{ name: 'internal/service', summary: 'Interception hook for a service binding (no core producer).', source: 'vendor/cordis/src/events.ts:341' },
{ name: 'internal/update', summary: 'Waterfall: a fiber config update is being applied.', source: 'vendor/cordis/src/events.ts:343' },
{ name: 'internal/config', summary: 'Waterfall: a fiber config is being resolved before validation.', source: 'vendor/cordis/src/events.ts:339' },
{ name: 'internal/get', summary: 'Waterfall: a service is being read from the store.', source: 'vendor/cordis/src/events.ts:345' },
{ name: 'internal/set', summary: 'Waterfall: a service is being written to the store.', source: 'vendor/cordis/src/events.ts:347' },
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:349' },
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:351' },
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:25' },
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:26' },
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:27' },
{ name: 'loader/partial-dispose', summary: 'An entry is being partially disposed on reload.', source: 'vendor/loader/src/index.ts:28' },
{ name: 'loader/patch-context', summary: 'A context is being patched during a reload.', source: 'vendor/loader/src/index.ts:41' },
{ name: 'loader/volatile-update', summary: 'Volatile config values committed into the running fiber without a remount; dispatched to the owning fiber only.', source: 'vendor/loader/src/index.ts:34' },
],
inheritedServices: [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
+1 -1
View File
@@ -12,7 +12,7 @@ const CLIENT_OUT = 'packages/extensions/cordis-client-runner/src/client/api-cata
const CLIENT_SERVICES: Readonly<Record<string, readonly string[]>> = {
layout: ['selectPanel', 'beginNavigation', 'toggleSidebar', 'openRightbar', 'closeRightbar'],
locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'addLanguage', 'register', 'bind'],
sessions: ['retain', 'using', 'retainInfo', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'],
sessions: ['retain', 'using', 'retainInfo', 'refreshProjections', 'search', 'fork', 'scope', 'binding'],
slots: ['register', 'registerFactory', 'inject'],
theme: ['getTheme', 'setTheme', 'setFontSize', 'register', 'overrideTokens'],
uiWorkspace: [
+9 -2
View File
@@ -243,6 +243,13 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core',
note: 'Lists the Session composition\'s user-invocable skills without activating a cold Agent.',
},
{
key: 'jobController',
pkg: 'api-job-controller',
title: 'Host job Remote controller',
mode: 'core',
note: 'Streams one background job\'s observation record over the generated Remote namespace; the roster stays on the session control stream.',
},
{
key: 'credentialsController',
pkg: 'api-settings-controller',
@@ -677,8 +684,8 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Background job registry',
mode: 'seam',
implementations: ['jobs-local'],
consumers: ['tool-bash', 'tool-terminal', 'tool-subagent', 'tool-jobs'],
note: 'Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.',
consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs', 'api-job-controller'],
note: 'Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry.',
},
{
key: 'web',
+2 -2
View File
@@ -102,7 +102,7 @@ export function render(
'',
text.envelopeIntro,
'',
...(schema ? [renderPersistenceSchemaIndex(schema, locale)] : []),
...(schema ? [renderPersistenceSchemaIndex(schema, locale, undefined, 2, 'current')] : []),
`## ${text.envelope}`,
'',
'```' + FENCE,
@@ -121,7 +121,7 @@ export function render(
lines.push(...renderEvent(e, locale))
}
}
if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale))
if (schema) lines.push(renderPersistenceSchemaDefinitions(schema, locale, undefined, 2, 'current'))
return lines.join('\n')
}
+13 -5
View File
@@ -203,15 +203,20 @@ export function renderSessionFormatCatalog(
'',
"import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'",
"import { createSessionFormatCatalog } from '@deepseek-ai/dsh-session-format'",
"import type { SessionFormatCatalogOptions } from '@deepseek-ai/dsh-session-format'",
"import { validateInstalledCurrentSessionArtifact, validateInstalledCurrentSessionHeader } from './current.ts'",
...imports,
'',
'/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */',
'export const sessionFormatCatalog = createSessionFormatCatalog({',
'/** Static assembly shared by current reads and parent-specific historical restoration. */',
'export const sessionFormatCatalogOptions: SessionFormatCatalogOptions = {',
` currentVersion: ${currentVersion},`,
` codecs: [${codecs.join(', ')}],`,
' codecs: [',
...codecs.map(codec => ` ${codec},`),
' ],',
` currentEncoder: ${currentCodec},`,
` migrations: [${declarations.map(item => item.migration).join(', ')}],`,
' migrations: [',
...declarations.map(item => ` ${item.migration},`),
' ],',
' restoreCurrent(artifact) {',
` const restored = ${restorer}(artifact, KNOWN_SESSION_EVENT_TYPES)`,
' validateInstalledCurrentSessionArtifact(restored)',
@@ -225,7 +230,10 @@ export function renderSessionFormatCatalog(
' validateInstalledCurrentSessionHeader(header)',
' return header',
' },',
'})',
'}',
'',
'/** Physical codec dispatch and complete adjacent chain, independent of mounted plugins. */',
'export const sessionFormatCatalog = createSessionFormatCatalog(sessionFormatCatalogOptions)',
'',
].join('\n')
}
+15 -15
View File
@@ -2,13 +2,13 @@ import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSyn
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' }
import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' }
import {
CLAUDE_AGENT_SDK_PACKAGE,
assertRuntimeLicenses,
claudeDistributionFromManifest,
collectPythonDependencies,
collectDesktopPythonDependencies,
collectBundledPythonDependencies,
isOwnerAuthorizedRuntime,
isPermissive,
type Manifest,
@@ -33,7 +33,7 @@ describe('THIRD_PARTY_NOTICES.md', () => {
}, async () => {
const generated = await render()
expect(generated).toContain('It depends on the third-party software listed below.')
expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${desktopRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`)
expect(generated).toContain(`| [\`numpy\`](https://github.com/numpy/numpy) | ${primaryRuntimeLock.pythonPackages.numpy} | BSD-3-Clause |`)
expect(generated).toContain('## LibreOffice conversion kit')
expect(generated).toContain('Recipients must have access to those corresponding sources and notices.')
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(generated)
@@ -323,22 +323,22 @@ describe('collectPythonDependencies', () => {
})
})
describe('collectDesktopPythonDependencies', () => {
it('discloses the committed Desktop closure with its exact locked versions', () => {
const dependencies = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages)
expect(dependencies).toHaveLength(Object.keys(desktopRuntimeLock.pythonPackages).length)
describe('collectBundledPythonDependencies', () => {
it('discloses the committed bundled Python closure with its exact locked versions', () => {
const dependencies = collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages)
expect(dependencies).toHaveLength(Object.keys(primaryRuntimeLock.pythonPackages).length)
expect(dependencies).toContainEqual({
name: 'pillow', version: desktopRuntimeLock.pythonPackages.Pillow,
name: 'pillow', version: primaryRuntimeLock.pythonPackages.Pillow,
license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow',
})
expect(dependencies).toContainEqual({
name: 'typing-extensions', version: desktopRuntimeLock.pythonPackages.typing_extensions,
name: 'typing-extensions', version: primaryRuntimeLock.pythonPackages.typing_extensions,
license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions',
})
})
it('normalizes names while preserving pinned version strings', () => {
expect(collectDesktopPythonDependencies({ 'typing_extensions': '4.16.0', 'Pillow': '12.3.0' }))
expect(collectBundledPythonDependencies({ 'typing_extensions': '4.16.0', 'Pillow': '12.3.0' }))
.toEqual([
{ name: 'pillow', version: '12.3.0', license: 'MIT-CMU', repo: 'https://github.com/python-pillow/Pillow' },
{ name: 'typing-extensions', version: '4.16.0', license: 'PSF-2.0', repo: 'https://github.com/python/typing_extensions' },
@@ -346,18 +346,18 @@ describe('collectDesktopPythonDependencies', () => {
})
it('rejects duplicate normalized distribution names with the same locked version', () => {
expect(() => collectDesktopPythonDependencies({ typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' }))
expect(() => collectBundledPythonDependencies({ typing_extensions: '4.16.0', 'typing.extensions': '4.16.0' }))
.toThrow('duplicate normalized names')
})
it('rejects missing distribution metadata and conflicting normalized versions', () => {
expect(() => collectDesktopPythonDependencies({ missing: '1.0' })).toThrow('missing from PYTHON_METADATA')
expect(() => collectDesktopPythonDependencies({ Pillow: '12.3.0', pillow: '12.4.0' })).toThrow('conflicting locked versions')
expect(() => collectBundledPythonDependencies({ missing: '1.0' })).toThrow('missing from PYTHON_METADATA')
expect(() => collectBundledPythonDependencies({ Pillow: '12.3.0', pillow: '12.4.0' })).toThrow('conflicting locked versions')
})
it('applies the runtime license check to bundled Python distributions', () => {
expect(() => { assertRuntimeLicenses(collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages)) }).not.toThrow()
const dependencies = collectDesktopPythonDependencies({ 'copyleft-wheel': '1.0' }, {
expect(() => { assertRuntimeLicenses(collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages)) }).not.toThrow()
const dependencies = collectBundledPythonDependencies({ 'copyleft-wheel': '1.0' }, {
'copyleft-wheel': { license: 'GPL-3.0-only', repo: 'https://example.com/project' },
})
expect(() => { assertRuntimeLicenses(dependencies) }).toThrow('copyleft-wheel (GPL-3.0-only)')
+15 -15
View File
@@ -1,7 +1,7 @@
/**
* Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every
* external dependency named by a workspace `package.json`, the vendored-package
* manifest in `vendor/README.md`, the Python `pyproject.toml` files, the Desktop
* manifest in `vendor/README.md`, the Python `pyproject.toml` files, the shared
* Python distribution lock, and the pnpm patch list. npm metadata comes from the installed
* store, so the tree must be installed. `--check` verifies the committed
* artifact. Tier policy and ownership live in
@@ -13,7 +13,7 @@ import { dirname, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
import parseSpdx from 'spdx-expression-parse'
import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' }
import primaryRuntimeLock from './primary-runtime/lock.json' with { type: 'json' }
import { browserBundledExternals } from './browser-bundled-externals.ts'
const root = resolve(import.meta.dirname, '..')
@@ -93,7 +93,7 @@ const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
/**
* Python metadata is recorded from the distributions' license and project
* fields; generation does not require installing their wheels. Both Python
* manifests and the Desktop lock reject names absent from this map.
* manifests and the shared runtime lock reject names absent from this map.
*/
const PYTHON_METADATA: Record<string, { license: string; repo: string; role?: string }> = {
pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' },
@@ -616,12 +616,12 @@ function collectPython(): { name: string; license: string; repo: string; role: s
}
/**
* Disclose every Desktop wheel distribution using its locked version, rejecting duplicate normalized names.
* @param packages - Distribution names and exact versions from the Desktop runtime lock.
* Disclose every bundled wheel distribution using its locked version, rejecting duplicate normalized names.
* @param packages - Distribution names and exact versions from the shared runtime lock.
* @param metadata - License and source metadata for every locked distribution.
* @returns Normalized, sorted distribution identities with versions and licenses.
*/
export function collectDesktopPythonDependencies(
export function collectBundledPythonDependencies(
packages: Readonly<Record<string, string>>,
metadata: PythonMetadata = PYTHON_METADATA,
): { name: string; version: string; license: string; repo: string }[] {
@@ -630,14 +630,14 @@ export function collectDesktopPythonDependencies(
const name = normalizePythonDistributionName(distribution)
const previous = normalized.get(name)
if (previous !== undefined && previous !== version) {
throw new Error(`gen-third-party-notices: Desktop python distribution ${name} has conflicting locked versions.`)
throw new Error(`gen-third-party-notices: bundled Python distribution ${name} has conflicting locked versions.`)
}
if (previous !== undefined) throw new Error(`gen-third-party-notices: Desktop python distribution ${name} has duplicate normalized names.`)
if (previous !== undefined) throw new Error(`gen-third-party-notices: bundled Python distribution ${name} has duplicate normalized names.`)
normalized.set(name, version)
}
return [...normalized].sort(([a], [b]) => a.localeCompare(b)).map(([name, version]) => {
const entry = metadata[name]
if (entry === undefined) throw new Error(`gen-third-party-notices: Desktop python distribution ${name} is missing from PYTHON_METADATA.`)
if (entry === undefined) throw new Error(`gen-third-party-notices: bundled Python distribution ${name} is missing from PYTHON_METADATA.`)
return { name, version, license: entry.license, repo: entry.repo }
})
}
@@ -753,7 +753,7 @@ export async function render(): Promise<string> {
const kitRuntime = runtimeDeps.some(dep => dep.name === LIBREOFFICE_KIT_PACKAGE)
const vendored = collectVendored()
const python = collectPython()
const desktopPython = collectDesktopPythonDependencies(desktopRuntimeLock.pythonPackages)
const bundledPython = collectBundledPythonDependencies(primaryRuntimeLock.pythonPackages)
const patched = collectPatched()
const claudeDistribution = runtimeDeps.some(
dep => dep.name === CLAUDE_AGENT_SDK_PACKAGE,
@@ -762,7 +762,7 @@ export async function render(): Promise<string> {
: undefined
const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
assertRuntimeLicenses(runtimeDeps)
assertRuntimeLicenses(desktopPython)
assertRuntimeLicenses(bundledPython)
const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
@@ -772,7 +772,7 @@ export async function render(): Promise<string> {
DeepSeek Harness is licensed under [MIT](LICENSE). It depends on the third-party software listed below. Each project remains under its own license; nothing in this file changes those terms.
This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Desktop bundled Python distributions. It is generated by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
This file lists **direct** dependencies declared by the workspace, the explicitly disclosed official Claude Code platform payload closure, and the Bundled Python distributions. It is generated by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
The complete npm transitive closure, including the Landlock launcher workspace, is recorded with exact pinned versions in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python SDK closure is recorded separately in [\`python/sdk/uv.lock\`](python/sdk/uv.lock).
@@ -817,13 +817,13 @@ Direct dependencies of the \`pyproject.toml\` manifests, plus \`uv\` as the deve
${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.role} |`).join('\n')}
| [\`uv\`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
## Desktop bundled Python distributions
## Bundled Python distributions
The [Desktop runtime lock](apps/desktop/scripts/primary-runtime-lock.json) records each distribution version and the wheel download hashes. The table includes every entry in \`pythonPackages\`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels.
The [shared runtime lock](scripts/primary-runtime/lock.json) records each distribution version and the wheel download hashes. The table includes every entry in \`pythonPackages\`, including transitive dependencies. Wheel extraction preserves distribution metadata and the license and notice files supplied by each archive. Project licenses below do not enumerate the separate licenses of native libraries bundled inside wheels.
| Distribution | Locked version | Project license |
| --- | --- | --- |
${desktopPython.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.version} | ${dep.license} |`).join('\n')}
${bundledPython.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.version} | ${dep.license} |`).join('\n')}
## First-party native packages
+12
View File
@@ -75,6 +75,7 @@ import WorkflowEngine from '@deepseek-ai/dsh-workflow'
import type { WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph'
import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow'
import * as ToolWorkspaceDependencies from '@deepseek-ai/dsh-tool-workspace-dependencies'
import { githubSlug } from './verify-md-links.ts'
/** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */
@@ -637,6 +638,17 @@ const TOOL_PACKAGES: ToolPackage[] = [
await ctx.plugin(ToolWorkflow)
},
},
{
pkg: '@deepseek-ai/dsh-tool-workspace-dependencies',
dir: 'tool-workspace-dependencies',
source: 'packages/skill/tool-workspace-dependencies/src/index.ts',
requires: ['ctx.tools'],
writes: ['tool/call', 'tool/result'],
async mount(ctx) {
// Schema harvest never prepares a payload; the directory need not exist.
await ctx.plugin(ToolWorkspaceDependencies, { source: resolve(root, '.tmp/tool-catalog/primary-runtime') })
},
},
{
pkg: '@deepseek-ai/dsh-tool-web',
dir: 'tool-web',
+17
View File
@@ -0,0 +1,17 @@
/** Identify checked generated schema lines in canonical historical Session format references. */
/**
* Locate the generated schema content protected by one exact marker pair.
* @param file - repository-relative path with slash separators.
* @param source - complete Markdown source, including authored prose and markers.
* @returns the zero-based, end-exclusive content line range, or undefined for other paths or malformed markers.
*/
export function historicalSchemaRegion(file: string, source: string): readonly [number, number] | undefined {
if (!/^docs\/persistence-changes\/historical-formats\/v(?:0|[1-9]\d*)(?:\.zh)?\.md$/u.test(file)) return undefined
if (source.match(/<!--\s*persistence-format-schema\b/giu)?.length !== 2) return undefined
const lines = source.split(/\r?\n/u)
const start = lines.indexOf('<!-- persistence-format-schema:start -->')
const end = lines.indexOf('<!-- persistence-format-schema:end -->')
// The format gate verifies this content against the frozen historical schema inventory.
return start >= 0 && end > start ? [start + 1, end] : undefined
}
+18 -4
View File
@@ -378,21 +378,30 @@ function releaseInstallLock(lockPath, ownedRecord, ownedStat) {
}
}
/** Hold one opt-in fixture stage until its parent explicitly releases it. */
async function waitForLockTestBarrier(path) {
writeFileSync(`${path}.ready`, '')
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
while (!existsSync(`${path}.release`)) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for installer test barrier ${path}`)
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
}
}
async function acquireInstallLock(commonDirectory) {
const lockPath = join(commonDirectory, INSTALL_LOCK)
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
let initializingLock
let observeBarrier = process.env.DSH_TEST_LEFTHOOK_LOCK_OBSERVE_BARRIER
while (true) {
try {
const lockHandle = openSync(lockPath, 'wx', 0o600)
let ownedStat
try {
ownedStat = fstatSync(lockHandle)
const writeDelay = Number(process.env.DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS ?? 0)
if (writeDelay > 0) {
await new Promise(resolveWait => setTimeout(resolveWait, writeDelay))
}
const publicationBarrier = process.env.DSH_TEST_LEFTHOOK_LOCK_PUBLISH_BARRIER
if (publicationBarrier !== undefined) await waitForLockTestBarrier(publicationBarrier)
writeFileSync(lockHandle, ownedRecord)
} finally {
closeSync(lockHandle)
@@ -428,6 +437,11 @@ async function acquireInstallLock(commonDirectory) {
if (!installLockRecordMayBeIncomplete(existingRecord)) {
throw manualLockRecoveryError(lockPath, 'invalid')
}
if (observeBarrier !== undefined) {
const barrier = observeBarrier
observeBarrier = undefined
await waitForLockTestBarrier(barrier)
}
const now = Date.now()
if (
initializingLock === undefined
+24 -9
View File
@@ -348,17 +348,32 @@ describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => {
it('waits for a concurrent installer to finish publishing its lock record', async () => {
const fixture = createFixture()
const lockPath = installLockPath(fixture)
const publicationBarrier = join(fixture.container, 'lock-publication')
const observationBarrier = join(fixture.container, 'lock-observation')
const publishing = runInstaller(fixture, fixture.main, {
DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS: '200',
DSH_TEST_LEFTHOOK_LOCK_PUBLISH_BARRIER: publicationBarrier,
})
await waitForPath(lockPath)
expect(readFileSync(lockPath, 'utf8')).toBe('')
const waiting = runInstaller(fixture, fixture.linked)
const results = await Promise.all([publishing, waiting])
for (const result of results) expect(result.status, result.stderr).toBe(0)
expect(existsSync(lockPath)).toBe(false)
let waiting: Promise<CommandResult> | undefined
const release = (): void => {
writeFileSync(`${publicationBarrier}.release`, '')
writeFileSync(`${observationBarrier}.release`, '')
}
try {
await waitForPath(`${publicationBarrier}.ready`)
expect(readFileSync(lockPath, 'utf8')).toBe('')
waiting = runInstaller(fixture, fixture.linked, {
DSH_TEST_LEFTHOOK_LOCK_OBSERVE_BARRIER: observationBarrier,
})
await waitForPath(`${observationBarrier}.ready`)
expect(readFileSync(lockPath, 'utf8')).toBe('')
release()
const results = await Promise.all([publishing, waiting])
for (const result of results) expect(result.status, result.stderr).toBe(0)
expect(existsSync(lockPath)).toBe(false)
} finally {
release()
await Promise.allSettled([publishing, ...waiting === undefined ? [] : [waiting]])
}
})
it('repairs its owned absolute hook path after the checkout moves', async () => {
+109
View File
@@ -0,0 +1,109 @@
/** Raw config comparison reads schema metadata without resolving config or committing references. */
import { expect, it, vi } from 'vitest'
import z from '@deepseek-ai/schemastery'
import { equalExceptVolatile as equal } from '../vendor/loader/src/config/diff.ts'
it('ignores volatile fields in frozen raw inputs without running schema callbacks', () => {
const validate = vi.fn((value: string) => value.trim())
const schema = z.object({ title: z.transform(z.string(), validate).volatile(), fixed: z.string() })
Object.freeze(schema.meta)
Object.freeze(schema.dict)
const previous = Object.freeze({ title: 'old', fixed: 'same' })
const next = Object.freeze({ title: { __jsExpr: 'throw new Error("must not run")' }, fixed: 'same' })
expect(equal(previous, next, schema)).toBe(true)
expect(equal(previous, next, schema)).toBe(true)
expect(validate).not.toHaveBeenCalled()
expect(previous.title).toBe('old')
expect(next.title).toEqual({ __jsExpr: 'throw new Error("must not run")' })
})
it('ignores absent, nested and whole-object/array/union volatile fields', () => {
const schema = z.object({
nested: z.object({ value: z.string().volatile() }),
block: z.object({ title: z.string() }).volatile(),
list: z.array(z.string()).volatile(),
choice: z.union([z.string(), z.number()]).volatile(),
})
const next = { nested: { value: 'new' }, block: { title: 'new' }, list: ['new'], choice: 1 }
expect(equal({}, next, schema)).toBe(true)
expect(equal(next, {}, schema)).toBe(true)
expect(equal('old', 'new', z.string().volatile())).toBe(true)
const restored = new z<unknown>(JSON.parse(JSON.stringify(schema)) as z<unknown>)
expect(equal({}, next, restored)).toBe(true)
})
it('preserves ordinary and unknown fields, object defaults and parent expressions', () => {
const schema = z.object({ nested: z.object({ live: z.string().volatile(), fixed: z.string() }).default({ fixed: 'default' }) })
expect(equal({}, { nested: { live: 'new', fixed: 'default' } }, schema)).toBe(true)
expect(equal({}, { nested: { live: 'new' } }, schema)).toBe(false)
expect(equal({}, { unknown: 'new' }, schema)).toBe(false)
expect(equal({ nested: { __jsExpr: 'source.a' } }, { nested: { __jsExpr: 'source.b' } }, schema)).toBe(false)
expect(equal({ nested: 'invalid' }, { nested: { live: 'new' } }, schema)).toBe(false)
expect(equal({ nested: new Date(0) }, { nested: new Date(1) }, schema)).toBe(false)
expect(equal({ nested: [] }, { nested: ['new'] }, schema)).toBe(false)
})
it('keeps ordinary raw changes even if schema validation would normalize them to equal values', () => {
const normalize = vi.fn(() => 'same')
const schema = z.object({ fixed: z.transform(z.string(), normalize), live: z.string().volatile() })
expect(equal({ fixed: 'first' }, { fixed: 'second' }, schema)).toBe(false)
expect(normalize).not.toHaveBeenCalled()
const other = { '~standard': { version: 1 as const, vendor: 'other', validate: vi.fn() } }
expect(equal('first', 'second', other)).toBe(false)
expect(other['~standard'].validate).not.toHaveBeenCalled()
expect(equal('first', 'second', undefined)).toBe(false)
})
it('compares raw values strictly without a schema', () => {
expect(equal([undefined], [null], undefined)).toBe(false)
expect(equal(undefined, null, undefined)).toBe(false)
expect(equal({ fixed: 'same' }, { fixed: 'same' }, undefined)).toBe(true)
expect(equal({ list: Array(1) }, { list: ['new'] }, undefined)).toBe(false)
})
it('compares recursive schemas conservatively and visits shared schema nodes at each path', () => {
const recursive = z.object({ value: z.string() }).extra('default', undefined)
recursive.set('child', recursive)
expect(equal({ value: 'old' }, { value: 'new' }, recursive)).toBe(false)
const shared = z.object({ value: z.string().volatile() })
const schema = z.object({ first: shared, second: shared })
expect(equal({}, { first: { value: 'one' }, second: { value: 'two' } }, schema)).toBe(true)
})
it('equates absent objects with defaults while preserving ordinary field differences', () => {
const schema = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) })
for (const absent of [undefined, null]) {
expect(equal(absent, {}, schema)).toBe(true)
expect(equal({}, absent, schema)).toBe(true)
expect(equal({ nested: absent }, { nested: { fixed: 'default' } }, schema)).toBe(true)
}
expect(equal({}, { nested: { fixed: 'changed' } }, schema)).toBe(false)
expect(equal({}, { nested: {} }, schema)).toBe(false)
})
it('preserves parent expression edits even when their marker matches a volatile field', () => {
const schema = z.object({ __jsExpr: z.string().volatile() })
expect(equal({ __jsExpr: 'first' }, { __jsExpr: 'second' }, schema)).toBe(false)
})
it('detects ordinary sparse array edits in both directions', () => {
const schema = z.object({ list: z.array(z.string()), live: z.string().volatile() })
const before = { list: Array(1), live: 'before' }
const after = { list: ['new'], live: 'after' }
expect(equal(before, after, schema)).toBe(false)
expect(equal(after, before, schema)).toBe(false)
})
it('compares unknown fields whose names match Object prototype properties', () => {
const schema = z.object({ live: z.string().volatile() })
for (const key of ['constructor', 'toString', '__proto__']) {
const previous = { [key]: 'first', live: 'old' }
expect(equal(previous, { [key]: 'second', live: 'new' }, schema)).toBe(false)
expect(equal(previous, { [key]: 'first', live: 'new' }, schema)).toBe(true)
}
})
it('compares metadata-free serialized schema nodes', () => {
const schema = new z<unknown>(JSON.parse('{"type":"object","dict":{"nested":{"type":"object","dict":{"enabled":{"type":"boolean"}}}}}') as z<unknown>)
expect(equal({ nested: { enabled: true } }, { nested: { enabled: false } }, schema)).toBe(false)
})
+634
View File
@@ -0,0 +1,634 @@
/** Loader commits volatile config values into running fibers without remounting; ordinary changes keep the update lifecycle. */
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { describe, expect, it, onTestFinished, vi } from 'vitest'
import { Context, type Fiber, type Plugin } from '@deepseek-ai/cordis'
import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
import Include from '@deepseek-ai/cordis-plugin-include'
import Timer from '@deepseek-ai/cordis-plugin-timer'
import z from '@deepseek-ai/schemastery'
import { Config } from './volatile-config.fixture.ts'
import Hmr from '@deepseek-ai/dsh-hmr'
import { boot, reconcileProfilePatches } from '@deepseek-ai/dsh-app-boot'
function context() {
const ctx = new Context()
onTestFinished(() => ctx.fiber.dispose())
return ctx
}
async function runtimeContext() {
const ctx = context()
await ctx.plugin(Loader)
return ctx
}
async function mountEntry(ctx: Context, plugin: Plugin, config: unknown = {}): Promise<Fiber> {
const name = `consumer-${Object.keys(ctx.loader.builtins).length}`
ctx.loader.builtins[name] = plugin
const id = await ctx.loader.create({ name: `cordis:${name}`, config })
const fiber = ctx.loader.resolve(id).fiber!
await fiber.await()
return fiber
}
async function update(fiber: Fiber, config: unknown): Promise<void> {
await fiber.entry!.update({ config })
await fiber.await()
}
describe('volatile Loader updates', () => {
it('updates a whole-config reference and reports the root path', async () => {
const ctx = await runtimeContext()
const Config = z.object({ title: z.string() }).volatile()
const events = vi.fn()
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('loader/volatile-update', events)
} }, { title: 'first' })
const reference = fiber.config as ReturnType<typeof Config>
await update(fiber, { title: 'second' })
expect(fiber.config).toBe(reference)
expect(reference.get()).toEqual({ title: 'second' })
expect(events).toHaveBeenCalledExactlyOnceWith([[]])
})
it('commits all references before one local event without remounting', async () => {
const ctx = await runtimeContext()
const configs: ReturnType<typeof Config>[] = []
const observed: unknown[] = []
const mount = vi.fn(function (owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', paths => observed.push({ paths, title: config.title.get(), count: config.nested.count.get() }))
})
const first = await mountEntry(ctx, { Config, apply: mount }, {})
await mountEntry(ctx, { Config, apply: mount }, {})
const initial = configs[0]!
const title = initial.title
await update(first, { title: 'hello' })
expect(observed).toEqual([])
await update(first, { title: 'next', nested: { count: 2 } })
await first.await()
expect(mount).toHaveBeenCalledTimes(2)
expect(first.config).toBe(initial)
expect(initial.title).toBe(title)
expect(configs[1]!.title.get()).toBe('hello')
expect(observed).toEqual([{ paths: [['title'], ['nested', 'count']], title: 'next', count: 2 }])
await update(first, { title: 'next', nested: { count: 2 } })
expect(observed).toHaveLength(1)
await update(first, { optional: 'added' })
expect(initial.optional.get()).toBe('added')
await update(first, {})
expect(initial.optional.get()).toBeUndefined()
})
it('keeps references on invalid updates and replaces them on mixed updates', async () => {
const ctx = await runtimeContext()
const notifications = vi.fn()
const disposed = vi.fn()
const configs: ReturnType<typeof Config>[] = []
const plugin = { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', notifications)
owner.effect(() => disposed)
} }
const fiber = await mountEntry(ctx, plugin, {})
const before = configs[0]!
await update(fiber, { title: 'invalid candidate', nested: { count: -1 } })
expect(before.title.get()).toBe('hello')
expect(notifications).not.toHaveBeenCalled()
await update(fiber, { title: 'new', fixed: 'restart' })
await fiber.await()
expect(configs).toHaveLength(2)
expect(disposed).toHaveBeenCalledOnce()
expect(notifications).not.toHaveBeenCalled()
expect(before.title.get()).toBe('hello')
expect(configs[1]!.title.get()).toBe('new')
expect(configs[1]!.title).not.toBe(before.title)
})
it('bypasses custom update hooks for volatile changes and retains them for mixed changes', async () => {
const ctx = await runtimeContext()
const hooks: string[] = []
const globalHook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
ctx.on('internal/update', globalHook, { global: true })
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('internal/update', (config: ReturnType<typeof Config>) => {
hooks.push(config.title.get())
})
} }, {})
const initial = fiber.config as ReturnType<typeof Config>
await update(fiber, { title: 'accepted' })
expect(fiber.config).toBe(initial)
expect(initial.title.get()).toBe('accepted')
await update(fiber, { title: 'accepted' })
expect(hooks).toEqual([])
expect(globalHook).not.toHaveBeenCalled()
await update(fiber, { title: 'custom', fixed: 'changed' })
expect(hooks).toEqual(['custom'])
expect(initial.title.get()).toBe('accepted')
})
it('compares ordinary dates and arrays and retains the existing nonvolatile restart behavior', async () => {
const ctx = await runtimeContext()
const schema = z.object({ live: z.string().volatile(), date: z.date(), list: z.array(z.string()) })
const apply = vi.fn(function () {})
const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', date: '2026-01-01', list: ['a'] })
await update(fiber, { live: 'b', date: '2026-01-01', list: ['a'] })
await fiber.await()
expect(apply).toHaveBeenCalledOnce()
await update(fiber, { live: 'c', date: '2026-01-02', list: ['a'] })
await fiber.await()
expect(apply).toHaveBeenCalledTimes(2)
await update(fiber, { live: 'd', date: '2026-01-02', list: ['b'] })
await fiber.await()
expect(apply).toHaveBeenCalledTimes(3)
const ordinary = await mountEntry(ctx, { Config: z.object({ value: z.string() }), apply() { apply() } }, { value: 'same' })
ordinary.update({ value: 'same' })
await ordinary.await()
expect(apply).toHaveBeenCalledTimes(5)
})
it('resolves pending updates and creates fresh references after dependency replacement', async () => {
const ctx = await runtimeContext()
const configs: ReturnType<typeof Config>[] = []
const consumer = await mountEntry(ctx, { Config, inject: ['volatile-test-provider'], apply(_ctx: Context, config: ReturnType<typeof Config>) {
configs.push(config)
} }, {})
await update(consumer, { title: 'pending' })
const provider = await ctx.plugin({ apply(owner: Context) { owner.provide('volatile-test-provider', {}) } })
await consumer.await()
expect(configs[0]!.title.get()).toBe('pending')
await update(consumer, { title: 'live' })
await provider.dispose()
const before = configs[0]!.title
await ctx.plugin({ apply(owner: Context) { owner.provide('volatile-test-provider', {}) } })
await consumer.await()
expect(configs).toHaveLength(2)
expect(configs[1]!.title.get()).toBe('live')
expect(configs[1]!.title).not.toBe(before)
})
it('remounts for changed class instances and cyclic ordinary config', async () => {
const ctx = await runtimeContext()
const schema = z.object({
live: z.string().volatile(),
url: z.transform(z.string(), value => new URL(value)),
data: z.transform(z.string(), () => { const data: Record<string, unknown> = {}; data.self = data; return data }),
})
const apply = vi.fn(function () {})
const oldUrl = 'https://a.example/'
const newUrl = 'https://b.example/'
const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', url: oldUrl })
const before = fiber.config as ReturnType<typeof schema>
await update(fiber, { live: 'b', url: newUrl })
await fiber.await()
expect(apply).toHaveBeenCalledTimes(2)
expect((fiber.config as ReturnType<typeof schema>).url.href).toBe(newUrl)
expect(before.live.get()).toBe('a')
await update(fiber, { live: 'c', url: newUrl, data: 'first' })
await update(fiber, { live: 'd', url: newUrl, data: 'second' })
expect(apply).toHaveBeenCalledTimes(4)
})
it('distinguishes undefined from null inside volatile arrays', async () => {
const ctx = await runtimeContext()
const schema = z.object({ values: z.array(z.any()).volatile() })
const events = vi.fn()
const fiber = await mountEntry(ctx, { Config: schema, apply(owner: Context) {
owner.on('loader/volatile-update', events)
} }, { values: [undefined] })
await update(fiber, { values: [null] })
expect((fiber.config as ReturnType<typeof schema>).values.get()).toEqual([null])
expect(events).toHaveBeenCalledExactlyOnceWith([['values']])
})
})
it('reloads real YAML and persists plain values independently of custom update hooks', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-'))
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
const configs: ReturnType<typeof Config>[] = []
ctx.loader.builtins.include = Include
ctx.loader.builtins.group = Group
const updates = vi.fn()
const consumer = { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('internal/update', updates)
} }
ctx.loader.builtins['volatile-test'] = consumer
const filename = join(root, 'cordis.yml')
await writeFile(filename, '- id: group\n name: cordis:group\n group: true\n config:\n - id: consumer\n name: cordis:volatile-test\n config:\n title: initial\n')
const rootId = await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(filename).href } })
await ctx.loader.await()
const include = ctx.loader.resolve(rootId).subtree as Include
const group = include.store.group!.fiber!
const groupUid = group.uid
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'consumer')!
expect(configs[0]!.title.get()).toBe('initial')
await ctx.plugin(Timer)
// An exact-file config watch mirrors the HMR root-watch Include path, which packages/boot/hmr covers with a mocked watcher.
await ctx.plugin(Hmr, { root: [], ignored: [], debounce: 0 })
const stopWatching = await ctx.hmr.watchConfig(filename, async () => {
await include.refresh()
await ctx.loader.await()
})
await writeFile(filename, '- id: group\n name: cordis:group\n group: true\n config:\n - id: consumer\n name: cordis:volatile-test\n config:\n title: file-update\n')
// The config watcher stabilizes writes for two seconds before publishing a change.
await vi.waitFor(() => { expect(configs[0]!.title.get()).toBe('file-update') }, { timeout: 10_000 })
await stopWatching()
expect(configs).toHaveLength(1)
expect(include.store.group!.fiber).toBe(group)
expect(group.uid).toBe(groupUid)
expect(configs[0]!.title.get()).toBe('file-update')
entry.fiber!.update({ title: 'program-update', group: { list: ['item'] } })
expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } })
entry.fiber!.update({ title: 'ephemeral' }, true)
expect(entry.options.config).toEqual({ title: 'program-update', group: { list: ['item'] } })
await ctx.fiber.dispose()
const saved = await readFile(filename, 'utf8')
expect(saved).toContain('program-update')
expect(saved).not.toContain('get:')
expect(saved).not.toContain('ephemeral')
expect(updates).toHaveBeenCalledTimes(2)
const restored = context()
restored.baseUrl = ctx.baseUrl
await restored.plugin(Loader)
restored.loader.builtins.include = Include
restored.loader.builtins.group = Group
restored.loader.builtins['volatile-test'] = consumer
await restored.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(filename).href } })
await restored.loader.await()
expect(configs).toHaveLength(2)
expect(configs[1]!.title.get()).toBe('program-update')
expect(configs[1]!.group.get()).toEqual({ list: ['item'] })
await restored.fiber.dispose()
})
it('updates volatile children through Group and Include custom updates and saves ordinary config', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-tree-'))
const ctx = new Context()
onTestFinished(async () => { await ctx.fiber.dispose(); await rm(root, { recursive: true, force: true }) })
ctx.baseUrl = pathToFileURL(root).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
ctx.loader.builtins.group = Group
const configs: ReturnType<typeof Config>[] = []
const notifications = vi.fn()
const childUpdates = vi.fn()
ctx.loader.builtins['volatile-test'] = { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', notifications)
owner.on('internal/update', childUpdates)
} }
const child = (title: string) => ({ id: 'consumer', name: 'cordis:volatile-test', config: { title } })
const filename = join(root, 'tree.json')
const includePath = pathToFileURL(filename).href
await writeFile(filename, JSON.stringify([{ id: 'group', name: 'cordis:group', group: true, config: [child('initial')] }]))
const rootId = await ctx.loader.create({ name: 'cordis:include', config: { path: includePath } })
await ctx.loader.await()
const includeEntry = ctx.loader.resolve(rootId)
const include = includeEntry.subtree as Include
const group = include.store.group!
const consumer = include.store.consumer!
const originalInclude = includeEntry.fiber
const originalGroup = group.fiber
const originalConsumer = consumer.fiber
const initial = configs[0]!
group.fiber!.update([child('group-update')])
await ctx.loader.await()
expect(initial.title.get()).toBe('group-update')
expect(group.options.config).toEqual([child('group-update')])
includeEntry.fiber!.update({ path: includePath, patches: [{ id: 'consumer', config: { title: 'include-update' } }] })
await ctx.loader.await()
expect(initial.title.get()).toBe('include-update')
expect(includeEntry.fiber).toBe(originalInclude)
expect(group.fiber).toBe(originalGroup)
expect(consumer.fiber).toBe(originalConsumer)
expect(consumer.fiber!.config).toBe(initial)
expect(configs).toHaveLength(1)
expect(notifications).toHaveBeenCalledTimes(2)
expect(childUpdates).not.toHaveBeenCalled()
const ordinary = await ctx.loader.create({ name: 'cordis:volatile-test', config: {} })
const entry = ctx.loader.resolve(ordinary)
entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' })
expect(childUpdates).toHaveBeenCalledOnce()
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
expect((entry.fiber!.config as ReturnType<typeof Config>).title.get()).toBe('hello')
entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true)
expect(childUpdates).toHaveBeenCalledTimes(2)
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
expect(() => { entry.fiber!.update({ nested: { count: -1 } }) }).toThrow()
expect(childUpdates).toHaveBeenCalledTimes(2)
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
})
it('commits volatile edits in place and retains direct Fiber updates', async () => {
const ctx = await runtimeContext()
const notifications = vi.fn()
const apply = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', notifications) })
const fiber = await mountEntry(ctx, { Config, apply }, {})
const original = fiber.config as ReturnType<typeof Config>
await ctx.loader.create({ name: 'cordis:disabled', disabled: true })
await update(fiber, { title: 'enabled' })
expect(apply).toHaveBeenCalledOnce()
expect(fiber.config).toBe(original)
expect(original.title.get()).toBe('enabled')
expect(notifications).toHaveBeenCalledExactlyOnceWith([['title']])
await update(fiber, { title: 'invalid', nested: { count: -1 } })
expect(original.title.get()).toBe('enabled')
await update(fiber, { title: 'mixed', fixed: 'changed' })
expect(apply).toHaveBeenCalledTimes(2)
expect(original.title.get()).toBe('enabled')
const current = fiber.config as ReturnType<typeof Config>
expect(current.title.get()).toBe('mixed')
const hook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
fiber.ctx.on('internal/update', hook)
fiber.update({ title: 'direct' })
await fiber.await()
expect(hook).toHaveBeenCalledOnce()
expect(apply).toHaveBeenCalledTimes(3)
expect(current.title.get()).toBe('mixed')
expect(notifications).toHaveBeenCalledOnce()
})
it('parses a volatile-only update through the config hook without patching the entry context', async () => {
const ctx = await runtimeContext()
const trace: string[] = []
const trim = vi.fn((value: string) => { trace.push('validate'); return value.trim() })
const Config = z.object({ block: z.object({ title: z.transform(z.string(), trim, true) }).volatile() })
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('loader/volatile-update', () => { trace.push('notify') })
} }, { block: { title: 'initial' } })
const current = fiber.config as ReturnType<typeof Config>
const resolveHook = vi.fn((_raw: unknown, next: () => unknown) => next())
fiber.ctx.on('internal/config', resolveHook)
const contextPatch = vi.fn((_entry: unknown, next: () => void) => { next() })
ctx.on('loader/patch-context', contextPatch)
trim.mockClear()
trace.length = 0
const raw = { block: { title: ' changed ' } }
await fiber.entry!.update({ config: raw })
expect(contextPatch).not.toHaveBeenCalled()
expect(trim).toHaveBeenCalledExactlyOnceWith(' changed ')
expect(resolveHook).toHaveBeenCalledOnce()
expect(trace).toEqual(['validate', 'notify'])
expect(current.block.get()).toEqual({ title: 'changed' })
expect(fiber._config).toBe(raw)
})
it.each([true, false])('keeps old references untouched when isolation replaces a dependency (available: %s)', async (available) => {
const ctx = await runtimeContext()
const configs: ReturnType<typeof Config>[] = []
const notifications = vi.fn()
ctx.loader.builtins.provider = { apply(owner: Context) { owner.provide('volatile-isolated-service', {}) } }
ctx.loader.builtins.consumer = {
inject: ['volatile-isolated-service'], Config,
apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', notifications)
},
}
const provider = (realm: string) => ctx.loader.create({
name: 'cordis:provider', isolate: { 'volatile-isolated-service': realm },
})
await provider('a')
if (available) await provider('b')
const id = await ctx.loader.create({
name: 'cordis:consumer', isolate: { 'volatile-isolated-service': 'a' }, config: { title: 'old' },
})
await ctx.loader.await()
const original = configs[0]!
await ctx.loader.resolve(id).update({ isolate: { 'volatile-isolated-service': 'b' }, config: { title: 'new' } })
await ctx.loader.await()
expect(configs).toHaveLength(available ? 2 : 1)
expect(original.title.get()).toBe('old')
expect(notifications).not.toHaveBeenCalled()
if (!available) {
await provider('b')
await ctx.loader.await()
}
expect(configs).toHaveLength(2)
expect(configs[1]!.title.get()).toBe('new')
})
it.each(['group', 'include'] as const)('applies profile patches to a %s subtree without HMR and keeps mixed updates on the normal lifecycle', async (kind) => {
const root = await mkdtemp(join(tmpdir(), 'dsh-volatile-profile-'))
onTestFinished(() => rm(root, { recursive: true, force: true }))
const configs: ReturnType<typeof Config>[] = []
const events = vi.fn()
const dispose = vi.fn()
const consumer = { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', events)
owner.effect(() => dispose)
} }
const child = (title: string, fixed = 'fixed') => ({ id: 'consumer', name: 'cordis:consumer', config: { title, fixed } })
const nested = join(root, 'nested.json')
await writeFile(nested, JSON.stringify([child('initial')]))
const carrierConfig = (title: string, fixed = 'fixed') => kind === 'group'
? [child(title, fixed)]
: { path: pathToFileURL(nested).href, patches: [{ id: 'consumer', config: { title, fixed } }] }
const filename = join(root, 'cordis.yml')
await writeFile(filename, JSON.stringify([{ id: 'parent', name: `cordis:${kind}`, group: kind === 'group', config: carrierConfig('initial') }]))
const ctx = await boot('volatile-test', filename, undefined, async (ctx) => {
ctx.loader.builtins.consumer = consumer
})
onTestFinished(() => ctx.fiber.dispose())
expect(ctx.get('hmr')).toBeUndefined()
const parent = [...ctx.loader.entries()].find(entry => entry.options.id === 'parent')!
const entry = [...ctx.loader.entries()].find(entry => entry.options.id === 'consumer')!
const parentFiber = parent.fiber
const childFiber = entry.fiber
const original = configs[0]!
const reference = original.title
const patch = (title: string, fixed = 'fixed') => [{ id: 'parent', config: carrierConfig(title, fixed) }]
await reconcileProfilePatches(ctx, patch('first'), 'volatile-test')
await reconcileProfilePatches(ctx, patch('second'), 'volatile-test')
expect(parent.fiber).toBe(parentFiber)
expect(entry.fiber).toBe(childFiber)
expect(entry.fiber!.config).toBe(original)
expect(original.title).toBe(reference)
expect(reference.get()).toBe('second')
expect(configs).toHaveLength(1)
expect(dispose).not.toHaveBeenCalled()
expect(events).toHaveBeenCalledTimes(2)
expect(events).toHaveBeenLastCalledWith([['title']])
await reconcileProfilePatches(ctx, patch('second'), 'volatile-test')
expect(events).toHaveBeenCalledTimes(2)
await reconcileProfilePatches(ctx, patch('mixed', 'changed'), 'volatile-test')
expect(parent.fiber).toBe(parentFiber)
expect(configs).toHaveLength(2)
expect(dispose).toHaveBeenCalledOnce()
expect(reference.get()).toBe('second')
expect(configs[1]!.title.get()).toBe('mixed')
expect(events).toHaveBeenCalledTimes(2)
})
it('logs an invalid candidate and keeps its references while other entries commit', async () => {
const ctx = await runtimeContext()
const events = vi.fn()
const plugin = { Config, apply(owner: Context) { owner.on('loader/volatile-update', events) } }
const invalid = await mountEntry(ctx, plugin)
const healthy = await mountEntry(ctx, plugin)
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warning.mockRestore() })
await invalid.entry!.update({ config: { title: 'invalid', nested: { count: -1 } } })
await healthy.entry!.update({ config: { title: 'healthy' } })
expect(warning).toHaveBeenCalledTimes(2)
expect((invalid.config as ReturnType<typeof Config>).title.get()).toBe('hello')
expect(invalid._config).toEqual({ title: 'invalid', nested: { count: -1 } })
expect((healthy.config as ReturnType<typeof Config>).title.get()).toBe('healthy')
expect(events).toHaveBeenCalledExactlyOnceWith([['title']])
})
it.each([false, true])('retains raw expression edits for dependency restoration (volatile: %s)', async (volatile) => {
const ctx = await runtimeContext()
const schema = z.object({ value: volatile ? z.string().volatile() : z.string() })
const values: unknown[] = []
const provide = (a: string, b: string) => ctx.plugin({ apply(owner: Context) { owner.provide('volatile-expression-source', { a, b }) } })
const provider = await provide('same', 'same')
const fiber = await mountEntry(ctx, { Config: schema, inject: ['volatile-expression-source'], apply(_owner: Context, config: ReturnType<typeof schema>) {
values.push(typeof config.value === 'string' ? config.value : config.value.get())
} }, { value: { __jsExpr: "ctx.get('volatile-expression-source').a" } })
const raw = { value: { __jsExpr: "ctx.get('volatile-expression-source').b" } }
await fiber.entry!.update({ config: raw })
await fiber.await()
expect(values).toEqual(volatile ? ['same'] : ['same', 'same'])
expect(fiber._config).toBe(raw)
await provider.dispose()
await provide('old-route', 'new-route')
await fiber.await()
expect(values).toEqual(volatile ? ['same', 'new-route'] : ['same', 'same', 'new-route'])
})
it('remounts when changed expressions resolve to ordinary values different from the live config', async () => {
const ctx = await runtimeContext()
const source = { a: 'initial', b: 'initial' }
ctx.provide('volatile-expression-source', source)
const configs: ReturnType<typeof Config>[] = []
const events = vi.fn()
const fiber = await mountEntry(ctx, { Config, apply(owner: Context, config: ReturnType<typeof Config>) {
configs.push(config)
owner.on('loader/volatile-update', events)
} }, { fixed: { __jsExpr: "ctx.get('volatile-expression-source').a" }, title: 'old' })
source.a = source.b = 'changed'
await update(fiber, { fixed: { __jsExpr: "ctx.get('volatile-expression-source').b" }, title: 'new' })
expect(configs).toHaveLength(2)
expect(configs[0]!.title.get()).toBe('old')
expect(configs[1]!.fixed).toBe('changed')
expect(configs[1]!.title.get()).toBe('new')
expect(events).not.toHaveBeenCalled()
})
it('compares ordinary URL transforms by value and remounts when an expression result changes', async () => {
const ctx = await runtimeContext()
const source = { value: 'initial' }
ctx.provide('volatile-transform-source', source)
const schema = z.object({
url: z.transform(z.string(), value => new URL(value), true),
fixed: z.string(),
live: z.string().volatile(),
})
const events = vi.fn()
const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) })
const fixed = { __jsExpr: "ctx.get('volatile-transform-source').value" }
const raw = { url: 'https://example.com/', fixed, live: 'old' }
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
const current = fiber.config as ReturnType<typeof schema>
await update(fiber, { ...raw, live: 'new' })
expect(mount).toHaveBeenCalledOnce()
expect(fiber.config).toBe(current)
expect(current.url.href).toBe(raw.url)
expect(current.fixed).toBe('initial')
expect(current.live.get()).toBe('new')
expect(events).toHaveBeenCalledExactlyOnceWith([['live']])
source.value = 'changed'
await update(fiber, { ...raw, live: 'remounted' })
expect(mount).toHaveBeenCalledTimes(2)
expect(current.fixed).toBe('initial')
expect(current.live.get()).toBe('new')
expect(events).toHaveBeenCalledOnce()
const replaced = fiber.config as ReturnType<typeof schema>
expect(replaced.fixed).toBe('changed')
expect(replaced.live.get()).toBe('remounted')
})
it('remounts when an ordinary parent expression removes a live reference path', async () => {
const ctx = await runtimeContext()
const source: { block: { value: string } | undefined } = { block: { value: 'old' } }
ctx.provide('volatile-path-source', source)
const schema = z.object({ block: z.object({ value: z.string().volatile() }).extra('default', undefined), live: z.string().volatile() })
const events = vi.fn()
const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) })
const raw = { block: { __jsExpr: "ctx.get('volatile-path-source').block" }, live: 'old' }
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
const current = fiber.config as ReturnType<typeof schema>
source.block = undefined
await update(fiber, { ...raw, live: 'new' })
expect(mount).toHaveBeenCalledTimes(2)
expect(current.block.value.get()).toBe('old')
expect(events).not.toHaveBeenCalled()
expect((fiber.config as ReturnType<typeof schema>).block).toBeUndefined()
})
it('keeps an ordinary plugin mounted when an absent object becomes its explicit default', async () => {
const ctx = await runtimeContext()
const Config = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) })
const mount = vi.fn()
const fiber = await mountEntry(ctx, { Config, apply: mount }, {})
const config: unknown = fiber.config
await update(fiber, { nested: { fixed: 'default' } })
expect(fiber.config).toBe(config)
expect(mount).toHaveBeenCalledTimes(1)
await update(fiber, { nested: { fixed: 'changed' } })
expect(mount).toHaveBeenCalledTimes(2)
expect(fiber.config).toEqual({ nested: { fixed: 'changed' } })
})
it('keeps a noSave volatile value as the pending raw config across a no-op entry update', async () => {
const ctx = await runtimeContext()
const fiber = await mountEntry(ctx, { Config, apply() {} }, { title: 'saved' })
fiber.update({ title: 'ephemeral' }, true)
await fiber.await()
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
expect(fiber.entry!.options.config).toEqual({ title: 'saved' })
await fiber.entry!.update({ config: { title: 'saved' } })
expect(fiber._config).toEqual({ title: 'ephemeral' })
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
})
it('isolates a failing notification listener and dispatches with a Context receiver', async () => {
const ctx = await runtimeContext()
const receivers: unknown[] = []
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
owner.on('loader/volatile-update', function (this: Context) { receivers.push(this.fiber); throw new Error('listener boom') })
} }, {})
const other = await mountEntry(ctx, { Config, apply() {} }, {})
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
onTestFinished(() => { warning.mockRestore() })
await update(fiber, { title: 'committed' })
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('committed')
expect(receivers).toEqual([fiber])
expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' }))
await update(other, { title: 'unaffected' })
expect((other.config as ReturnType<typeof Config>).title.get()).toBe('unaffected')
})
it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => {
const ctx = await runtimeContext()
class Handle { constructor(public readonly name: string) {} }
const schema = z.object({ handle: z.transform(z.string(), value => new Handle(value)), live: z.string().volatile() })
const mount = vi.fn()
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, { handle: 'h', live: 'old' })
const before = fiber.config as ReturnType<typeof schema>
await update(fiber, { handle: 'h', live: 'new' })
expect(mount).toHaveBeenCalledTimes(2)
expect(before.live.get()).toBe('old')
expect((fiber.config as ReturnType<typeof schema>).live.get()).toBe('new')
})
+441
View File
@@ -0,0 +1,441 @@
/** The contributor command uses real persistence on private temporary corpora. */
import { existsSync, mkdtempSync, readFileSync, statSync } from 'node:fs'
import { mkdir, readFile, symlink, unlink, writeFile } from 'node:fs/promises'
import { availableParallelism, tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { execa } from 'execa'
import type { SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
import { encodeSegment, generationLogFilename, type JsonlCompression } from '../packages/session/session-persistence-jsonl/src/format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from '../packages/session/session-persistence-jsonl/src/zstd.ts'
import { runMigrationJobs } from './migrate-sessions-to-v4.ts'
import { removeFixtureSafely } from './test-fixture-cleanup.ts'
const repository = resolve(import.meta.dirname, '..')
const script = join(repository, 'scripts/migrate-sessions-to-v4.ts')
const directories = new Set<string>()
const stopProcesses: Array<() => Promise<void>> = []
function temporaryRoot(): string {
const directory = mkdtempSync(join(tmpdir(), 'dsh-migrate-v4-test-'))
directories.add(directory)
return directory
}
async function runAt(entrypoint: string, ...args: string[]) {
const child = execa(process.execPath, ['--import', 'tsx', entrypoint, ...args], {
cwd: repository, reject: false,
})
stopProcesses.push(async () => { child.kill('SIGKILL'); await child })
const result = await child
const logPath = [...result.stdout.matchAll(/^Full log: (.+)$/gmu)].at(-1)?.[1]
const summaryPath = [...result.stdout.matchAll(/^JSON summary: (.+)$/gmu)].at(-1)?.[1]
if (logPath !== undefined) directories.add(dirname(logPath))
expect(result.timedOut, result.stderr).toBe(false)
expect(result.signal, result.stderr).toBeUndefined()
const summary: unknown = summaryPath === undefined ? undefined : JSON.parse(readFileSync(summaryPath, 'utf8'))
return { stdout: result.stdout, stderr: result.stderr, status: result.exitCode, logPath, summaryPath, summary }
}
function run(...args: string[]) {
return runAt(script, ...args)
}
afterEach(async () => {
await Promise.all(stopProcesses.splice(0).map(stop => stop()))
for (const directory of directories) removeFixtureSafely(directory)
directories.clear()
})
const toolTurn: readonly SessionFormatJsonObject[] = [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'step/start', data: { turn: 1, step: 1 } },
{ type: 'request/header', data: { reason: 'initial', header: { config: { provider: 'mock', model: 'mock' } } } },
{ type: 'assistant/message', surfaceOp: 'append', data: { turn: 1, step: 1, stream: [], message: {
id: 'assistant', role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' },
content: [{ type: 'tool-call', id: 'call', name: 'read', arguments: '{}' }],
} } },
{ type: 'tool/call', data: { turn: 1, step: 1, callId: 'call', name: 'read', arguments: '{}' } },
{ type: 'tool/result', surfaceOp: 'append', data: { turn: 1, step: 1, message: {
id: 'result', role: 'user', source: { kind: 'tool', callId: 'call' },
content: [{ type: 'tool-result', toolCallId: 'call', isError: false, content: [{ type: 'text', text: 'saved output' }] }],
} } },
{ type: 'step/end', data: { turn: 1, step: 1 } },
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
]
async function fixture(
root: string, id: string, version: number, compression: JsonlCompression, events: readonly SessionFormatJsonObject[] = [],
headerFields: SessionFormatJsonObject = {},
) {
const directory = join(root, '_no-cwd', encodeSegment(id))
await mkdir(directory, { recursive: true })
const header = JSON.stringify({ type: 'session', version, id, createdAt: 1, delegationDepth: 0,
...version >= 2 ? { isSeeded: false } : {},
...headerFields,
}) + '\n'
const body = events.map((event, seq) => JSON.stringify({ ...event, seq, time: seq + 2 }) + '\n').join('')
const bytes = compression === 'none' ? Buffer.from(header + body) : Buffer.concat([
await compressZstdFrame(header), ...body === '' ? [] : [await compressZstdFrame(body)],
])
const path = join(directory, generationLogFilename(version, compression))
await writeFile(path, bytes)
return { path, bytes, directory }
}
describe('one-time V4 migration command', () => {
it('bounds active jobs and retries changed-source inputs only after the initial pass drains', async () => {
const entered = Array.from({ length: 4 }, () => Promise.withResolvers<undefined>())
const release = Array.from({ length: 4 }, () => Promise.withResolvers<undefined>())
const initial: number[] = []
const retries: number[] = []
let active = 0
let peak = 0
const task = runMigrationJobs(4, 2, async (index, retry) => {
if (retry) {
expect(active).toBe(0)
expect(initial.toSorted()).toEqual([0, 1, 2, 3])
retries.push(index)
return false
}
active += 1
peak = Math.max(peak, active)
entered[index]!.resolve(undefined)
await release[index]!.promise
active -= 1
initial.push(index)
return index === 0
})
try {
await Promise.all([entered[0]!.promise, entered[1]!.promise])
expect(active).toBe(2)
release[1]!.resolve(undefined)
await entered[2]!.promise
expect(active).toBe(2)
release[0]!.resolve(undefined)
await entered[3]!.promise
expect(active).toBe(2)
expect(retries).toEqual([])
release[2]!.resolve(undefined)
release[3]!.resolve(undefined)
await task
expect(peak).toBe(2)
expect(retries).toEqual([0])
} finally {
for (const barrier of release) barrier.resolve(undefined)
await task
}
})
it('runs each input to completion before starting the next with one job', async () => {
const order: string[] = []
await runMigrationJobs(3, 1, async (index, retry) => {
expect(retry).toBe(false)
order.push(`start ${index}`)
await Promise.resolve()
order.push(`end ${index}`)
return false
})
expect(order).toEqual(['start 0', 'end 0', 'start 1', 'end 1', 'start 2', 'end 2'])
})
it('drains ten thousand immediate inputs without exceeding the worker limit', async () => {
const visits = new Uint8Array(10_000)
let active = 0
let peak = 0
await runMigrationJobs(visits.length, 16, async (index, retry) => {
expect(retry).toBe(false)
visits[index]! += 1
active += 1
peak = Math.max(peak, active)
await Promise.resolve()
active -= 1
return false
})
expect(visits.every(count => count === 1)).toBe(true)
expect(peak).toBe(16)
expect(active).toBe(0)
})
it.each(['none', 'zstd'] as const)('publishes %s successors, preserves sources and current bytes on rerun', async (compression) => {
const root = temporaryRoot()
const old = await fixture(root, 'old', 0, compression)
const older = await fixture(root, 'tool/session~名', 0, compression)
const source = await fixture(root, 'tool/session~名', 3, compression, toolTurn)
const current = await fixture(root, 'current', 4, compression)
await mkdir(join(root, '_no-cwd', 'empty'))
const first = await run('--sessions-dir', root)
expect(first.status, first.stdout + first.stderr).toBe(0)
expect(first.stdout).toContain(`Session jobs: ${Math.min(availableParallelism(), 16)}`)
expect(first.stdout).toContain('converted=2, already-V4=1, failed=0, skipped=1')
expect(first.stdout).toContain('START session.v3.jsonl')
expect(first.stdout).toContain('V3 -> V4: session.v4.jsonl')
const checkoutCommit = /^Git HEAD: ([0-9a-f]{40})$/mu.exec(first.stdout)?.[1]
expect(checkoutCommit).toBeDefined()
expect(first.summary).toMatchObject({
schemaVersion: 1,
targetVersion: 4,
sessionRoot: root,
inputCount: 4,
jobs: Math.min(availableParallelism(), 16),
checkoutCommit,
runtime: { node: process.version, platform: process.platform, arch: process.arch },
textLogPath: first.logPath,
summaryPath: first.summaryPath,
totals: { converted: 2, alreadyV4: 1, failed: 0, skipped: 1 },
bySourceVersion: {
'0': { converted: 1, alreadyV4: 0, failed: 0, skipped: 0 },
'3': { converted: 1, alreadyV4: 0, failed: 0, skipped: 0 },
'4': { converted: 0, alreadyV4: 1, failed: 0, skipped: 0 },
unknown: { converted: 0, alreadyV4: 0, failed: 0, skipped: 1 },
},
failureGroups: [],
})
const target = join(source.directory, generationLogFilename(4, compression))
const targetBytes = await readFile(target)
const decoded = compression === 'none' ? targetBytes : Buffer.concat(await Promise.all(
scanZstdFrames(targetBytes).frames.map(frame => decompressZstdFrame(targetBytes.subarray(frame.start, frame.end))),
))
expect(decoded.toString()).toContain('"role":"tool"')
expect(decoded.toString()).toContain('"text":"saved output"')
expect(decoded.toString()).not.toContain('"type":"tool-result"')
const second = await run('--sessions-dir', root)
expect(second.status, second.stdout + second.stderr).toBe(0)
expect(second.stdout).toContain('converted=0, already-V4=3, failed=0, skipped=1')
expect(second.summary).toMatchObject({
totals: { converted: 0, alreadyV4: 3, failed: 0, skipped: 1 },
bySourceVersion: { '4': { converted: 0, alreadyV4: 3, failed: 0, skipped: 0 } },
failureGroups: [],
})
expect(await readFile(target)).toEqual(targetBytes)
for (const original of [old, older, source, current]) expect(await readFile(original.path)).toEqual(original.bytes)
expect(first.logPath).toBeDefined()
const log = readFileSync(first.logPath!, 'utf8')
expect(log).toContain('Git HEAD: ')
expect(log).toContain(`Node: ${process.version}; platform: ${process.platform}/${process.arch}`)
expect(log).toContain(first.stdout.trim())
expect(first.summaryPath).toBe(join(dirname(first.logPath!), 'summary.json'))
const json = readFileSync(first.summaryPath!, 'utf8')
expect(first.stdout.endsWith(json.trimEnd())).toBe(true)
expect(log.endsWith(json)).toBe(true)
if (process.platform !== 'win32') {
expect(statSync(first.logPath!).mode & 0o777).toBe(0o600)
expect(statSync(first.summaryPath!).mode & 0o777).toBe(0o600)
}
})
it.each(['none', 'zstd'] as const)('preserves %s parent/child history and catalog with serial or parallel jobs', async (compression) => {
const outputs: Buffer[][] = []
for (const jobs of [1, 2]) {
const root = temporaryRoot()
const parent = await fixture(root, 'a-parent', 3, compression)
const child = await fixture(root, 'z-child', 3, compression, [
{ type: 'subagent/descriptor', data: { version: 3, mode: 'continuable', provider: 'spawn', label: 'saved child' } },
], { origin: 'subagent', parentSession: 'a-parent', createdAt: 2, delegationDepth: 1 })
const result = await run('--sessions-dir', root, '--jobs', String(jobs))
expect(result.status, result.stdout + result.stderr).toBe(0)
expect(result.stdout).toContain('converted=2, already-V4=0, failed=0, skipped=0')
expect(result.stdout).toContain('completed=2/2')
const current: Buffer[] = []
for (const original of [parent, child]) {
expect(await readFile(original.path)).toEqual(original.bytes)
const bytes = await readFile(join(original.directory, generationLogFilename(4, compression)))
const decoded = compression === 'none' ? bytes : Buffer.concat(await Promise.all(
scanZstdFrames(bytes).frames.map(frame => decompressZstdFrame(bytes.subarray(frame.start, frame.end))),
))
current.push(decoded)
}
expect(current[0]!.toString().trim().split('\n').slice(1).map((line): unknown => JSON.parse(line))).toMatchObject([
{ type: 'subagent/catalog', data: { childId: 'z-child', childCreatedAt: 2, mode: 'continuable', label: 'saved child' } },
])
outputs.push(current)
}
expect(outputs[1]).toEqual(outputs[0])
})
it('reports a bad Session and still migrates a later good Session', async () => {
const root = temporaryRoot()
const bad = await fixture(root, 'a-bad', 3, 'none', [{ type: 'unrecognized/required', data: {} }])
const good = await fixture(root, 'z-good', 3, 'none', toolTurn)
const result = await run('--sessions-dir', root)
expect(result.status, result.stdout + result.stderr).toBe(1)
expect(result.stdout, readFileSync(result.logPath!, 'utf8')).toContain('converted=1, already-V4=0, failed=1, skipped=0')
expect(result.stdout).toMatch(/\[1\/2\].*FAILED/u)
expect(result.stdout).toMatch(/\[2\/2\].*V3 -> V4/u)
expect(result.stdout).not.toContain('DEFERRED')
expect(result.stdout).not.toContain('RETRY')
expect(result.stdout).toContain('Failures (full stacks and causes are in the log):')
expect(result.stdout).toContain(bad.path)
expect(readFileSync(result.logPath!, 'utf8')).toContain('unrecognized/required')
expect(await readFile(bad.path)).toEqual(bad.bytes)
expect(existsSync(join(bad.directory, 'session.v4.jsonl'))).toBe(false)
expect(existsSync(join(good.directory, 'session.v4.jsonl'))).toBe(true)
})
it('groups matching failures by source version while retaining each input diagnostic', async () => {
const root = temporaryRoot()
const event = { type: 'unrecognized/required', data: {} }
const first = await fixture(root, 'a-v3', 3, 'none', [event])
const second = await fixture(root, 'b-v3', 3, 'none', [event])
const legacy = await fixture(root, 'c-v0', 0, 'none', [event])
const result = await run('--sessions-dir', root)
expect(result.status, result.stdout + result.stderr).toBe(1)
const anyString: unknown = expect.any(String)
expect(result.summary).toMatchObject({
inputCount: 3,
totals: { converted: 0, alreadyV4: 0, failed: 3, skipped: 0 },
bySourceVersion: {
'0': { converted: 0, alreadyV4: 0, failed: 1, skipped: 0 },
'3': { converted: 0, alreadyV4: 0, failed: 2, skipped: 0 },
},
failureGroups: [
{ sourceVersion: 0, reason: 'unknown_event', eventType: 'unrecognized/required', count: 1,
items: [{ inputPath: legacy.path, reason: 'unknown_event', errorName: anyString, message: anyString }] },
{ sourceVersion: 3, reason: 'unknown_event', eventType: 'unrecognized/required', count: 2,
items: [
{ inputPath: first.path, reason: 'unknown_event', errorName: anyString, message: anyString },
{ inputPath: second.path, reason: 'unknown_event', errorName: anyString, message: anyString },
] },
],
})
for (const source of [first, second, legacy]) expect(await readFile(source.path)).toEqual(source.bytes)
})
it('groups sequence failures without dropping their distinct sequence coordinates', async () => {
const root = temporaryRoot()
const items: Array<{ inputPath: string; expectedSeq: number; actualSeq: number }> = []
for (const [id, actualSeq] of [['a-gap', 2], ['b-gap', 4]] as const) {
const source = await fixture(root, id, 2, 'none', [
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'step/start', data: { turn: 1, step: 1 } },
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
])
await writeFile(source.path, source.bytes.toString().replace('"seq":1', `"seq":${actualSeq}`))
items.push({ inputPath: source.path, expectedSeq: 1, actualSeq })
}
const result = await run('--sessions-dir', root)
expect(result.status, result.stdout + result.stderr).toBe(1)
expect(result.summary).toMatchObject({
inputCount: 2,
totals: { converted: 0, alreadyV4: 0, failed: 2, skipped: 0 },
bySourceVersion: { '2': { converted: 0, alreadyV4: 0, failed: 2, skipped: 0 } },
failureGroups: [{ sourceVersion: 2, reason: 'sequence_gap', count: 2, items }],
})
})
it('reports a source-change deferral and retries after the other input completes', async () => {
const root = temporaryRoot()
const parent = await fixture(root, 'a-parent', 3, 'none')
const child = await fixture(root, 'b-child', 3, 'none', [{
type: 'subagent/descriptor', data: { version: 3, mode: 'continuable', provider: 'spawn', label: 'fixture child' },
}], { origin: 'subagent', parentSession: 'a-parent', createdAt: 2, delegationDepth: 1 })
const entrypoint = join(temporaryRoot(), 'retry-once.mjs')
const backendUrl = pathToFileURL(join(repository, 'packages/session/session-persistence-jsonl/src/index.ts')).href
const generationUrl = pathToFileURL(join(repository, 'packages/session/session-persistence-jsonl/src/generation.ts')).href
await writeFile(entrypoint, `
const { default: Backend } = await import(${JSON.stringify(backendUrl)});
const { JsonlGenerationSourceChangedError } = await import(${JSON.stringify(generationUrl)});
const open = Backend.prototype.open;
let changed = false;
Backend.prototype.open = async function(id, access, options) {
if (id === 'a-parent' && access === 'write' && !changed) {
changed = true;
throw new JsonlGenerationSourceChangedError(${JSON.stringify(child.path)});
}
return open.call(this, id, access, options);
};
process.argv[1] = ${JSON.stringify(script)};
await import(${JSON.stringify(pathToFileURL(script).href)});
`)
const result = await runAt(entrypoint, '--sessions-dir', root, '--jobs', '2')
expect(result.status, result.stdout + result.stderr).toBe(0)
const lines = result.stdout.split('\n')
const deferred = lines.findIndex(line => line.includes('[1/2]') && line.includes('DEFERRED'))
const childDone = lines.findIndex(line => line.includes('[2/2]') && line.includes('completed=1/2'))
const retry = lines.findIndex(line => line.includes('[1/2]') && line.includes('RETRY'))
expect(deferred).toBeGreaterThanOrEqual(0)
expect(childDone).toBeGreaterThan(deferred)
expect(retry).toBeGreaterThan(childDone)
expect(lines.filter(line => line.includes('RETRY'))).toHaveLength(1)
expect(result.summary).toMatchObject({ totals: { converted: 2, alreadyV4: 0, failed: 0, skipped: 0 }, failureGroups: [] })
const target = await readFile(join(parent.directory, 'session.v4.jsonl'), 'utf8')
expect(target).toContain('"type":"subagent/catalog"')
for (const source of [parent, child]) expect(await readFile(source.path)).toEqual(source.bytes)
})
it('opens an existing V4 torn tail without repairing its bytes', async () => {
const root = temporaryRoot()
const current = await fixture(root, 'current', 4, 'none')
const torn = Buffer.concat([current.bytes, Buffer.from('{"unfinished"')])
await writeFile(current.path, torn)
const result = await run('--sessions-dir', root)
expect(result.status, result.stdout + result.stderr).toBe(0)
expect(result.stdout).toContain('already V4 (opened successfully)')
expect(await readFile(current.path)).toEqual(torn)
})
it('reports unsupported root-level logs and does not traverse directory links', async () => {
const root = temporaryRoot()
const outside = temporaryRoot()
const original = await fixture(outside, 'outside', 3, 'none')
await writeFile(join(root, 'legacy.jsonl'), original.bytes)
await symlink(outside, join(root, 'linked-project'), process.platform === 'win32' ? 'junction' : 'dir')
const result = await run('--sessions-dir', root)
expect(result.status).toBe(1)
expect(result.stdout).toContain('unsupported flat-file layout')
expect(result.stdout).toContain('project symbolic links are not traversed')
expect(existsSync(join(original.directory, 'session.v4.jsonl'))).toBe(false)
})
it('shows help, rejects unknown arguments, and logs a missing root failure', async () => {
const help = await run('--help')
expect(help.stdout).toContain('Defaults to ~/.dsh/sessions')
expect(help.stdout).toContain('CPU count capped at 16')
expect((await run('--unknown')).status).toBe(1)
const missing = join(temporaryRoot(), 'missing')
const result = await run('--sessions-dir', missing)
expect(result.status).toBe(1)
expect(result.stdout).toContain('converted=0, already-V4=0, failed=1, skipped=0')
expect(result.logPath).toBeDefined()
expect(result.summary).toMatchObject({
inputCount: 0,
totals: { converted: 0, alreadyV4: 0, failed: 1, skipped: 0 },
bySourceVersion: { unknown: { converted: 0, alreadyV4: 0, failed: 1, skipped: 0 } },
failureGroups: [{ sourceVersion: 'unknown', count: 1, items: [{ inputPath: missing }] }],
})
})
it.each(['32', '1e2', '+1', '01', ' 1'])('accepts positive safe-integer --jobs %s', async (jobs) => {
const result = await run('--sessions-dir', temporaryRoot(), '--jobs', jobs)
expect(result.status, result.stdout + result.stderr).toBe(0)
expect(result.stdout).toContain(`Session jobs: ${Number(jobs)}`)
expect(result.stdout).toContain('converted=0, already-V4=0, failed=0, skipped=0')
expect(result.summary).toMatchObject({
inputCount: 0,
totals: { converted: 0, alreadyV4: 0, failed: 0, skipped: 0 },
bySourceVersion: {},
failureGroups: [],
})
})
it('runs the entrypoint through a symbolic link to the checkout', async () => {
const checkout = join(temporaryRoot(), 'checkout')
await symlink(repository, checkout, process.platform === 'win32' ? 'junction' : 'dir')
try {
const result = await runAt(join(checkout, 'scripts/migrate-sessions-to-v4.ts'), '--help')
expect(result.status, result.stderr).toBe(0)
expect(result.stdout).toContain('Usage: pnpm run migrate:sessions-to-v4')
} finally {
await unlink(checkout)
}
})
it.each(['0', '-1', '1.5', 'NaN', '9007199254740992'])('rejects invalid --jobs %s before opening a corpus', async (jobs) => {
const result = await run('--sessions-dir', join(temporaryRoot(), 'missing'), `--jobs=${jobs}`)
expect(result.status).toBe(1)
expect(result.stderr).toContain('--jobs must be a positive safe integer')
expect(result.stdout).not.toContain('Session migration')
expect(result.logPath).toBeUndefined()
})
})
+303
View File
@@ -0,0 +1,303 @@
/** One-time contributor command to publish V4 successors through JSONL persistence. */
import { execFileSync } from 'node:child_process'
import assert from 'node:assert/strict'
import { appendFileSync, closeSync, mkdtempSync, openSync, realpathSync, writeFileSync } from 'node:fs'
import { readdir, stat } from 'node:fs/promises'
import { availableParallelism, homedir, tmpdir } from 'node:os'
import { basename, join, relative, resolve } from 'node:path'
import { inspect, parseArgs } from 'node:util'
import { Context } from '@deepseek-ai/cordis'
import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
import { encodeSegment, generationLogFilename, parseGenerationLogFilename, type JsonlCompression } from '../packages/session/session-persistence-jsonl/src/format.ts'
import { JsonlGenerationSourceChangedError } from '../packages/session/session-persistence-jsonl/src/generation.ts'
import { classifyMigrationFailure, type MigrationFailureDiagnostic } from './migration-failure-summary.ts'
const usage = `Usage: pnpm run migrate:sessions-to-v4 [--sessions-dir PATH] [--jobs N]
Publish V4 successors beside unchanged historical Session generations.
Defaults to ~/.dsh/sessions. Already-V4 Sessions are opened read-only.
No model or API key is used. Failures do not stop subsequent Sessions.
The text log and final JSON summary are saved in a private OS temporary directory.
Options:
--sessions-dir PATH Session root to migrate
--jobs N Concurrent Sessions, positive integer (default: CPU count capped at 16)
--help Show this help
`
interface Candidate {
readonly directory: string
readonly source?: { readonly filename: string; readonly version: number; readonly compression: JsonlCompression; readonly bytes: number }
readonly error?: unknown
}
interface OutcomeCounts {
converted: number
alreadyV4: number
failed: number
skipped: number
}
type SourceVersion = number | 'unknown'
interface Failure extends MigrationFailureDiagnostic {
inputPath: string
sourceVersion: SourceVersion
}
interface FailureGroup extends Pick<MigrationFailureDiagnostic, 'reason' | 'errorName' | 'eventType' | 'member' | 'child'> {
sourceVersion: SourceVersion
count: number
items: Array<MigrationFailureDiagnostic & { inputPath: string }>
}
function emptyCounts(): OutcomeCounts {
return { converted: 0, alreadyV4: 0, failed: 0, skipped: 0 }
}
function summarizeFailures(failures: readonly Failure[]): FailureGroup[] {
const groups = new Map<string, FailureGroup>()
for (const { sourceVersion, ...item } of failures) {
const key = JSON.stringify([
String(sourceVersion), item.reason, item.errorName, item.eventType ?? null, item.member ?? null, item.child ?? false,
])
let group = groups.get(key)
if (group === undefined) {
group = {
sourceVersion, reason: item.reason, errorName: item.errorName,
...item.eventType === undefined ? {} : { eventType: item.eventType },
...item.member === undefined ? {} : { member: item.member },
...item.child === undefined ? {} : { child: item.child },
count: 0, items: [],
}
groups.set(key, group)
}
group.count += 1
group.items.push(item)
}
return [...groups].sort(([left], [right]) => left.localeCompare(right)).map(([, group]) => ({
...group, items: group.items.sort((left, right) => left.inputPath.localeCompare(right.inputPath)),
}))
}
async function inspectDirectory(directory: string): Promise<Candidate> {
try {
const entries = await readdir(directory)
const generations = entries.flatMap(filename => (['none', 'zstd'] as const).flatMap((compression) => {
const version = parseGenerationLogFilename(filename, compression)
return version === undefined ? [] : [{ filename, version, compression }]
})).sort((left, right) => right.version - left.version || left.filename.localeCompare(right.filename))
const selected = generations[0]
if (selected === undefined) return { directory }
const metadata = await stat(join(directory, selected.filename))
return { directory, source: { ...selected, bytes: metadata.size } }
} catch (error: unknown) {
return { directory, error }
}
}
async function discover(root: string): Promise<Candidate[]> {
const candidates: Candidate[] = []
const projects = await readdir(root, { withFileTypes: true })
for (const project of projects.sort((a, b) => a.name.localeCompare(b.name))) {
const directory = join(root, project.name)
if (project.isSymbolicLink()) {
candidates.push({ directory, error: new Error('project symbolic links are not traversed') })
continue
}
if (!project.isDirectory()) {
if (project.name.endsWith('.jsonl') || project.name.endsWith('.jsonl.zstd')) {
candidates.push({ directory, error: new Error('unsupported flat-file layout; expected project/session/generation') })
}
continue
}
try {
const entries = await readdir(directory, { withFileTypes: true })
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const path = join(directory, entry.name)
if (entry.isSymbolicLink()) candidates.push({ directory: path, error: new Error('Session symbolic links are not traversed') })
else if (entry.isDirectory()) candidates.push(await inspectDirectory(path))
else if (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.zstd')) {
candidates.push({ directory: path, error: new Error('unsupported flat-file layout; expected project/session/generation') })
}
}
} catch (error: unknown) {
candidates.push({ directory, error })
}
}
return candidates
}
function directoryId(directory: string): SessionId {
const segment = basename(directory)
const id = segment.replace(/~([0-9A-F]{4})/gu, (_match, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
if (encodeSegment(id) !== segment) throw new Error(`noncanonical Session directory name ${JSON.stringify(segment)}`)
return SessionId(id)
}
/**
* Run bounded Session jobs, then retry deferred inputs once after every initial job settles.
* An unexpected visit rejection drains active workers and aborts the retry pass.
* @param count - Number of discovered inputs.
* @param jobs - Positive number of concurrent initial operations.
* @param visit - Attempt an input; return true to defer its first attempt until the serial retry pass.
* @returns Completion after all active operations have settled, including on unexpected rejection.
*/
export async function runMigrationJobs(
count: number,
jobs: number,
visit: (index: number, retry: boolean) => Promise<boolean>,
): Promise<void> {
let next = 0
const deferred: number[] = []
const workers = Array.from({ length: Math.min(jobs, count) }, async () => {
while (next < count) {
const index = next++
if (await visit(index, false)) deferred.push(index)
}
})
const results = await Promise.allSettled(workers)
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
for (const index of deferred.sort((a, b) => a - b)) await visit(index, true)
}
async function migrate(root: string, jobs: number): Promise<number> {
const reportDirectory = mkdtempSync(join(tmpdir(), 'dsh-migrate-v4-'))
const logPath = join(reportDirectory, 'migration.log')
const summaryPath = join(reportDirectory, 'summary.json')
const log = openSync(logPath, 'wx', 0o600)
const write = (line: string): void => {
appendFileSync(log, `${line}\n`)
console.log(line)
}
const totals = emptyCounts()
const bySourceVersion: Record<string, OutcomeCounts> = {}
const recordOutcome = (version: SourceVersion, outcome: keyof OutcomeCounts): void => {
totals[outcome] += 1
const counts = bySourceVersion[String(version)] ??= emptyCounts()
counts[outcome] += 1
}
const failures: Failure[] = []
const fail = (inputPath: string, error: unknown, sourceVersion: SourceVersion = 'unknown'): void => {
const diagnostic = classifyMigrationFailure(error)
failures.push({ inputPath, sourceVersion, ...diagnostic })
recordOutcome(sourceVersion, 'failed')
write(`ERROR ${JSON.stringify(inputPath)}: ${diagnostic.message}`)
appendFileSync(log, `${inspect(error, { depth: null, colors: false })}\n`)
}
const startedAt = new Date().toISOString()
let inputCount = 0
let checkoutCommit: string | null = null
try {
write(`Session migration to V4 started ${startedAt}`)
write(`Root: ${root}`)
write(`Session jobs: ${jobs}`)
write(`Node: ${process.version}; platform: ${process.platform}/${process.arch}`)
checkoutCommit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: resolve(import.meta.dirname, '..'), encoding: 'utf8' }).trim()
write(`Git HEAD: ${checkoutCommit}`)
write(`Full log: ${logPath}`)
assert.equal(SESSION_FORMAT_VERSION, 4, 'this one-time command requires a V4 Session writer')
assert.equal(sessionFormatCatalog.currentVersion, 4, 'this one-time command requires a V4 format catalog')
const candidates = await discover(root)
inputCount = candidates.length
write(`Discovered ${candidates.length} Session directories or invalid layout entries.`)
const compression = candidates.find(candidate => candidate.source !== undefined)?.source?.compression ?? 'zstd'
const ctx = new Context()
try {
await ctx.plugin(JsonlSessionPersistence, { root, compression })
let completed = 0
await runMigrationJobs(candidates.length, jobs, async (index, retry) => {
const candidate = candidates[index]
assert(candidate !== undefined, 'migration job index must name a discovered input')
const { directory, source } = candidate
const label = `[${index + 1}/${candidates.length}] ${JSON.stringify(relative(root, directory))}`
const started = performance.now()
write(`${label} ${retry ? 'RETRY' : 'START'} ${source === undefined ? 'inspect entry' : `${source.filename} (V${source.version}, ${source.bytes} bytes)`}`)
const finish = (status: string): void => {
completed += 1
write(`${label} ${status}; completed=${completed}/${candidates.length}`)
}
try {
if (candidate.error !== undefined) {
throw candidate.error instanceof Error ? candidate.error : new Error('directory inspection failed', { cause: candidate.error })
}
if (source === undefined) {
recordOutcome('unknown', 'skipped')
finish('SKIPPED: no canonical Session generation')
return false
}
const id = directoryId(directory)
const handle = await ctx.sessionPersistence.open(id, source.version === 4 ? 'read' : 'write')
await handle.close()
recordOutcome(source.version, source.version === 4 ? 'alreadyV4' : 'converted')
finish(`${source.version === 4 ? 'already V4 (opened successfully)' : `V${source.version} -> V4: ${generationLogFilename(4, source.compression)}`} (${((performance.now() - started) / 1000).toFixed(2)}s)`)
} catch (error: unknown) {
if (!retry && error instanceof JsonlGenerationSourceChangedError) {
write(`${label} DEFERRED: ${error.message}; retry once after initial jobs finish`)
appendFileSync(log, `${inspect(error, { depth: null, colors: false })}\n`)
return true
}
finish(`FAILED (${((performance.now() - started) / 1000).toFixed(2)}s)`)
fail(source === undefined ? directory : join(directory, source.filename), error, source?.version)
}
return false
})
} finally {
await ctx.fiber.dispose()
}
} catch (error: unknown) {
fail(root, error)
} finally {
try {
write(`Summary: converted=${totals.converted}, already-V4=${totals.alreadyV4}, failed=${totals.failed}, skipped=${totals.skipped}`)
if (failures.length > 0) {
write('Failures (full stacks and causes are in the log):')
for (const failure of failures) write(`- ${JSON.stringify(failure.inputPath)}: ${failure.message}`)
}
write(`Full log: ${logPath}`)
const summary = JSON.stringify({
schemaVersion: 1,
targetVersion: 4,
sessionRoot: root,
inputCount,
jobs,
startedAt,
finishedAt: new Date().toISOString(),
checkoutCommit,
runtime: { node: process.version, platform: process.platform, arch: process.arch },
textLogPath: logPath,
summaryPath,
totals,
bySourceVersion,
failureGroups: summarizeFailures(failures),
}, null, 2)
writeFileSync(summaryPath, `${summary}\n`, { flag: 'wx', mode: 0o600 })
write(`JSON summary: ${summaryPath}`)
write(summary)
} finally {
closeSync(log)
}
}
return totals.failed > 0 ? 1 : 0
}
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(import.meta.filename)) {
try {
const { values } = parseArgs({ options: { 'sessions-dir': { type: 'string' }, jobs: { type: 'string' }, help: { type: 'boolean' } }, strict: true })
if (values.help) console.log(usage)
else {
const jobs = values.jobs === undefined ? Math.min(availableParallelism(), 16) : Number(values.jobs)
if (!Number.isSafeInteger(jobs) || jobs < 1) {
throw new Error('--jobs must be a positive safe integer')
}
process.exitCode = await migrate(resolve(values['sessions-dir'] ?? join(homedir(), '.dsh', 'sessions')), jobs)
}
} catch (error: unknown) {
console.error(error instanceof Error ? error.message : String(error))
console.error(usage)
process.exitCode = 1
}
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { classifyMigrationFailure } from './migration-failure-summary.ts'
function failure(name: string, message: string): Error {
return Object.assign(new Error(message), { name })
}
describe('migration failure report facts', () => {
it('distinguishes a direct field refusal from a child prerequisite failure', () => {
const message = '@deepseek-ai/dsh-session-format-v0-to-v1 refuses this format v0 Session: '
+ 'permission/preset 0 data has unexpected member "origin"; source v0 artifact remains unchanged'
const direct = classifyMigrationFailure(failure('SessionFormatUnsupportedError', message))
expect(direct).toEqual({ reason: 'unexpected_member', errorName: 'SessionFormatUnsupportedError',
message, eventType: 'permission/preset', member: 'origin' })
const child = classifyMigrationFailure(failure('SessionFormatUnsupportedError', `child Session fixture-child: ${message}`))
expect(child).toMatchObject({ reason: 'unexpected_member', child: true, eventType: direct.eventType, member: direct.member })
})
it.each(['historical ', ''])('extracts unknown %sevent names', (qualifier) => {
const message = `format v0 contains unknown ${qualifier}event type "fixture/event" at seq 3`
expect(classifyMigrationFailure(failure('SessionFormatUnsupportedError', message)))
.toEqual({ reason: 'unknown_event', errorName: 'SessionFormatUnsupportedError', message, eventType: 'fixture/event' })
})
it('retains sequence values without diagnosing the producer or repairing the log', () => {
const message = 'session "fixture": stored log is corrupt: SessionFormatError: released v2 row 8 has seq gap (expected 8, got 5)'
expect(classifyMigrationFailure(failure('SessionPersistenceCorruptionError', message)))
.toEqual({ reason: 'sequence_gap', errorName: 'SessionPersistenceCorruptionError', message, expectedSeq: 8, actualSeq: 5 })
})
it.each([
['JsonlGenerationSourceChangedError', 'source_changed'],
['SessionAlreadyOwnedError', 'write_locked'],
['SessionFormatUnsupportedError', 'unsupported_input'],
['SessionPersistenceCorruptionError', 'corrupt_log'],
['Error', 'other'],
])('preserves unrecognized text for %s', (name, reason) => {
expect(classifyMigrationFailure(failure(name, 'unrecognized diagnostic')))
.toEqual({ reason, errorName: name, message: 'unrecognized diagnostic' })
})
it('keeps non-Error failures in the report', () => {
expect(classifyMigrationFailure('cancelled')).toEqual({ reason: 'other', errorName: 'NonError', message: 'cancelled' })
})
})
+43
View File
@@ -0,0 +1,43 @@
/** Structured facts extracted from existing migration diagnostics for the batch report. */
import { inspect } from 'node:util'
/** One failure's diagnostic fields; classification never changes migration admission. */
export interface MigrationFailureDiagnostic {
readonly reason: 'unexpected_member' | 'unknown_event' | 'sequence_gap' | 'source_changed'
| 'write_locked' | 'unsupported_input' | 'corrupt_log' | 'other'
readonly errorName: string
readonly message: string
readonly eventType?: string
readonly member?: string
readonly child?: true
readonly expectedSeq?: number
readonly actualSeq?: number
}
/**
* Classify recognized diagnostic text without inferring its historical cause.
* @param error - Failure returned by discovery or persistence.
* @returns Original diagnostic plus any recognized event, field, and sequence facts.
*/
export function classifyMigrationFailure(error: unknown): MigrationFailureDiagnostic {
const errorName = error instanceof Error ? error.name : 'NonError'
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : inspect(error)
const base = { errorName, message, ...(/^child Session /u.test(message) ? { child: true as const } : {}) }
const member = /has unexpected member "([^"]+)"/u.exec(message)?.[1]
if (member !== undefined) {
const eventType = /([^\s]+) \d+ data has unexpected member /u.exec(message)?.[1]
return { ...base, reason: 'unexpected_member', member,
...(eventType === undefined ? {} : { eventType }) }
}
const eventType = /unknown (?:historical )?event type "([^"]+)"/u.exec(message)?.[1]
if (eventType !== undefined) return { ...base, reason: 'unknown_event', eventType }
const gap = /has seq gap \(expected (\d+), got (\d+)\)/u.exec(message)
if (gap !== null) return { ...base, reason: 'sequence_gap', expectedSeq: Number(gap[1]), actualSeq: Number(gap[2]) }
const reason = errorName === 'JsonlGenerationSourceChangedError' ? 'source_changed'
: errorName === 'SessionAlreadyOwnedError' ? 'write_locked'
: errorName === 'SessionFormatUnsupportedError' ? 'unsupported_input'
: errorName === 'SessionPersistenceCorruptionError' ? 'corrupt_log'
: 'other'
return { ...base, reason }
}
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -160,7 +160,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
if (result.error !== undefined) {
throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
}
const parsed = result.config as unknown
const parsed: unknown = result.config
if (!isRecord(parsed) || !isUnknownArray(parsed.overrides)) {
throw new Error('.oxlintrc.json must contain an overrides array')
}
@@ -208,7 +208,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
})
it('keeps repository lint workflows Oxlint-only', async () => {
const packageJson = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as unknown
const packageJson: unknown = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8'))
if (!isRecord(packageJson) || !isRecord(packageJson.scripts) || !isRecord(packageJson.devDependencies)) {
throw new Error('package.json must contain scripts and devDependencies objects')
}
@@ -364,7 +364,7 @@ export function unrelatedRead(): void {
if (result.error !== undefined) {
throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
}
const stagedConfig = result.config as unknown
const stagedConfig: unknown = result.config
if (!isRecord(stagedConfig)) throw new Error('.oxlintrc.staged.json must contain a config object')
expect(stagedConfig).toMatchObject({
extends: ['./.oxlintrc.json'],
+2 -1
View File
@@ -55,8 +55,9 @@ const SAFE_HOST_DEPENDENCY_EXPORTS = {
/** Runtime exports that require every consumer to resolve the provider's shared peer instance. */
const PEER_REQUIRED_HOST_EXPORTS = {
'@deepseek-ai/dsh-client-connection': ['OperatorPeer'],
'@deepseek-ai/dsh-subprocess': ['SubprocessExecutableNotFoundError'],
'@deepseek-ai/dsh-scope': ['carrierKeyOf', 'scopeOf', 'scopeTarget'],
'@deepseek-ai/dsh-scope': ['carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget'],
'@deepseek-ai/dsh-session': ['SESSION_FORMAT_VERSION'],
'@deepseek-ai/dsh-session-persistence': ['SessionPersistenceNotFoundError'],
} as const satisfies HostDependencyExports
+6
View File
@@ -19,6 +19,9 @@ const english = {
optional: 'optional', required: 'required', rest: 'rest', index: 'index signature',
emptyObject: 'Object with no declared properties.', arrayPrefix: 'Array of ', arraySuffix: '.',
oneOf: 'One of:', opaque: ' (opaque)', opaqueExplanation: ": the declaration does not expose the stored value's internal fields.",
sourceCompatibility: 'Source compatibility: ', attributionAdditions: 'Attribution-only additions: ',
sourceColumns: '| kind | Form property | Other required fields | Full definition |',
notDeclared: 'not declared', none: 'none',
}
const chinese: Record<keyof typeof english, string> = {
@@ -37,6 +40,9 @@ const chinese: Record<keyof typeof english, string> = {
optional: '可选', required: '必需', rest: '剩余项', index: '索引签名',
emptyObject: '无已声明属性的对象。', arrayPrefix: '', arraySuffix: ' 的数组。',
oneOf: '以下类型之一:', opaque: '(不透明)', opaqueExplanation: ':此声明未暴露存储值的内部字段。',
sourceCompatibility: '消息源兼容策略:', attributionAdditions: '仅表示归属的新增 kind:',
sourceColumns: '| kind | form 属性 | 其他必需字段 | 完整定义 |',
notDeclared: '未声明', none: '无',
}
/** Complete translated prose; adding an English key requires its Chinese counterpart. */
+411 -8
View File
@@ -3,12 +3,13 @@
import { spawnSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { join, posix, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty } from './persistence-schema-model.ts'
import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty, SourceCompatibility } from './persistence-schema-model.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
import { createPersistenceFinalizationCheckpoint, loadPersistenceFinalization } from './persistence-finalization.ts'
import {
classifyPersistenceChange,
loadPersistenceHistory,
@@ -18,7 +19,7 @@ import {
validatePersistenceHistory,
verifyPersistenceChanges,
} from './persistence-changes.ts'
import type { PersistenceChangeRecord, PersistenceHistoryEntry } from './persistence-changes.ts'
import type { PersistenceChangeRecord, PersistenceHistoryEntry, PersistenceTypeChange } from './persistence-changes.ts'
function runPersistenceChanges(
args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
@@ -159,6 +160,208 @@ describe('historical persistence snapshot parsing', () => {
})
})
const FINALIZED_ID = '2026-09-18-finalized-v4'
const COMPATIBLE_ID = '2026-09-19-compatible-v4'
const FUTURE_ID = '2026-09-19-future-v5'
function finalize(root: string, current = inventory({ value: 'number' }, 4)): void {
baseline(root)
runPersistenceChanges(['--record', FINALIZED_ID, '--prose', proseFile(root)], root, () => current)
const checkpoint = createPersistenceFinalizationCheckpoint(loadPersistenceHistory(root), current)
mkdirSync(join(root, 'docs/persistence-changes/finalized'))
writeFileSync(join(root, 'docs/persistence-changes/finalized/v4.json'), JSON.stringify(checkpoint))
for (const suffix of ['.md', '.zh.md']) {
writeFileSync(join(root, `docs/session-format-status${suffix}`), '```yaml session-format-finalization\nlatestFinalizedVersion: 4\n```\n')
}
}
function contents(root: string): Record<string, string> {
const files: Record<string, string> = {}
const visit = (directory: string): void => {
for (const entry of readdirSync(join(root, directory), { withFileTypes: true })) {
const path = posix.join(directory, entry.name)
if (entry.isDirectory()) visit(path)
else files[path] = readFileSync(join(root, path), 'utf8')
}
}
visit('')
return files
}
describe('accepted persistence baseline', () => {
it('keeps metadata-only catalog regeneration and checkpoint capture independent of publication', () => {
const root = fixture()
const current = inventory({ value: 'number' }, 4)
finalize(root, current)
const metadata = { ...current, roots: [...current.roots].reverse(), types: [{
...current.roots[2]!, names: ['RenamedAlias'], sources: ['packages/example/src/types.ts:99'],
}].map(({ schema, digest, names, sources }) => ({ schema, digest, names, sources })) }
commitCurrent(root, metadata)
expect(runPersistenceChanges(['--check'], root, () => metadata)).toContain('roots match')
expect(createPersistenceFinalizationCheckpoint(loadPersistenceHistory(root), metadata))
.toEqual(JSON.parse(readFileSync(join(root, 'docs/persistence-changes/finalized/v4.json'), 'utf8')))
expect(contents(root)['docs/session-format-status.md']).not.toContain('latestReleasedVersion')
})
it.each(['optional field', 'ordinary event'] as const)('allows a V4 %s through a new same-version acknowledgement', (kind) => {
const root = fixture()
const current = inventory({ value: 'number' }, 4)
finalize(root, current)
const after = kind === 'ordinary event'
? { ...current, roots: [...current.roots, typeRoot('event:example/new', {})] }
: inventory({ value: 'number', 'label?': 'string' }, 4)
const before = contents(root)
commitCurrent(root, after)
expect(() => verifyPersistenceChanges(root, after)).toThrow('unacknowledged persistence type changes')
runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after)
expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
const history = loadPersistenceHistory(root)
expect(history.entries.find(entry => entry.record.id === COMPATIBLE_ID)?.record.changes)
.toEqual([expect.objectContaining({ decision: 'same-version' })])
expect(loadPersistenceFinalization(root, history)?.acceptedRecords.has(COMPATIBLE_ID)).toBe(false)
for (const [path, value] of Object.entries(before).filter(([path]) => path.startsWith('docs/persistence-changes/'))) {
expect(readFileSync(join(root, path), 'utf8')).toBe(value)
}
})
it.each(['required field', 'changed type'] as const)('refuses a breaking V4 %s before rendering or writing', (kind) => {
const root = fixture()
finalize(root)
const after = inventory(kind === 'required field' ? { value: 'number', label: 'string' } : { value: 'boolean' }, 4)
const before = contents(root)
let rendered = false
const result = JSON.parse(executePersistenceChanges(['--record', FUTURE_ID, '--json'], root, () => after, () => {
rendered = true
return []
})) as { ok: boolean; code: string; changes: PersistenceTypeChange[] }
expect(result).toMatchObject({ ok: false, code: 'finalized-format-changed' })
expect(result.changes.length).toBeGreaterThan(0)
expect(result.changes.some(change => change.requiresVersionBump)).toBe(true)
expect(rendered).toBe(false)
expect(contents(root)).toEqual(before)
commitCurrent(root, after)
expect(() => verifyPersistenceChanges(root, after)).toThrow('Breaking changes relative to the accepted Session format 4 baseline')
})
it('allows qualified V4 attribution additions through a new same-version acknowledgement', () => {
const root = fixture()
const source = attributedRoot([EXISTING_SOURCE])
const current: PersistenceSchemaInventory = { ...inventory({ value: 'number' }, 4), formatVersion: 2,
roots: [...inventory({ value: 'number' }, 4).roots, source] }
finalize(root, current)
const added = attributedRoot([EXISTING_SOURCE, { kind: 'new-attribution' }], { policy: attributionPolicy(['new-attribution']) })
const after = { ...current, roots: current.roots.map(root => root.key === source.key ? added : root) }
expect(classifyPersistenceChange(source, added)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
const before = contents(root)
runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after)
expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
expect(loadPersistenceHistory(root).entries.find(entry => entry.record.id === COMPATIBLE_ID)?.record.changes)
.toEqual([expect.objectContaining({ decision: 'same-version' })])
for (const [path, value] of Object.entries(before).filter(([path]) => path.startsWith('docs/persistence-changes/'))) {
expect(readFileSync(join(root, path), 'utf8')).toBe(value)
}
})
it('keeps later unaccepted compatible records editable and rejects a breaking successor without its own version transition', () => {
const root = fixture()
finalize(root)
const first = inventory({ value: 'number', 'label?': 'string' }, 4)
runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => first)
const amended = inventory({ value: 'number', 'label?': 'string', 'extra?': 'boolean' }, 4)
runPersistenceChanges(['--update', COMPATIBLE_ID], root, () => amended)
expect(runPersistenceChanges(['--check'], root, () => amended)).toContain('roots match')
expect(loadPersistenceFinalization(root, loadPersistenceHistory(root))?.acceptedRecords.has(COMPATIBLE_ID)).toBe(false)
const breaking = inventory({ value: 'number', 'label?': 'number', 'extra?': 'boolean' }, 4)
const accepted = inventory({ value: 'number' }, 4).roots[2]!
expect(classifyPersistenceChange(accepted, breaking.roots[2]!).every(change => !change.requiresVersionBump)).toBe(true)
const before = contents(root)
expect(() => runPersistenceChanges(['--record', '2026-09-20-breaking-v4', '--prose', proseFile(root)], root, () => breaking))
.toThrow("this record's own increasing SessionHeader.version")
expect(contents(root)).toEqual(before)
})
it.each(['compatible', 'breaking'] as const)('refuses a %s update to an accepted acknowledgement', (kind) => {
const root = fixture()
finalize(root)
const after = inventory(kind === 'compatible' ? { value: 'number', 'label?': 'string' } : { value: 'boolean' }, 4)
const before = contents(root)
expect(() => runPersistenceChanges(['--update', FINALIZED_ID], root, () => after))
.toThrow('cannot update finalized acknowledgement')
expect(contents(root)).toEqual(before)
})
it('allows a fresh V5 transition while retaining finalized history', () => {
const root = fixture()
finalize(root)
const before = contents(root)
const after = inventory({ value: 'boolean' }, 5)
runPersistenceChanges(['--record', FUTURE_ID, '--prose', proseFile(root)], root, () => after)
expect(runPersistenceChanges(['--check'], root, () => after)).toContain('roots match')
for (const [path, value] of Object.entries(before).filter(([path]) => path.startsWith('docs/persistence-changes/'))) {
expect(readFileSync(join(root, path), 'utf8')).toBe(value)
}
expect(() => runPersistenceChanges(['--update', FINALIZED_ID], root, () => after)).toThrow('cannot update finalized acknowledgement')
const later = inventory({ value: 'string' }, 5)
expect(() => runPersistenceChanges(['--record', '2026-09-20-without-version', '--prose', proseFile(root)], root, () => later))
.toThrow("this record's own increasing SessionHeader.version")
})
it.each([4, 5])('detects a consistently rewritten accepted record while the writer is V%s', (version) => {
const root = fixture()
finalize(root)
const current = inventory({ value: version === 4 ? 'number' : 'boolean' }, version)
if (version === 5) runPersistenceChanges(['--record', FUTURE_ID, '--prose', proseFile(root)], root, () => current)
const snapshotPath = join(root, `docs/persistence-changes/${FINALIZED_ID}.schema.json`)
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8')) as PersistenceSchemaInventory
const replacement = typeRoot('event:example/value', { value: 'number', extra: 'string' })
const original = snapshot.roots.find(root => root.key === replacement.key)!
writeFileSync(snapshotPath, JSON.stringify({ ...snapshot,
roots: snapshot.roots.map(root => root.key === replacement.key ? replacement : root) }))
for (const suffix of ['.md', '.zh.md']) {
const path = join(root, `docs/persistence-changes/${FINALIZED_ID}${suffix}`)
writeFileSync(path, readFileSync(path, 'utf8').replace(original.digest, replacement.digest))
}
expect(() => loadPersistenceHistory(root)).not.toThrow()
expect(() => verifyPersistenceChanges(root, current)).toThrow(`finalized acknowledgement ${FINALIZED_ID} was removed or changed`)
const before = contents(root)
expect(() => runPersistenceChanges(['--record', '2026-09-20-later'], root, () => inventory({ value: 'string' }, 6)))
.toThrow('finalized acknowledgement')
expect(contents(root)).toEqual(before)
})
it.each(['missing checkpoint', 'missing status', 'unpaired status', 'duplicate status', 'invalid checkpoint', 'incomplete roots', 'future checkpoint'] as const)
('rejects %s instead of silently disabling finalization', (kind) => {
const root = fixture()
finalize(root)
const checkpoint = join(root, 'docs/persistence-changes/finalized/v4.json')
const status = join(root, 'docs/session-format-status.md')
if (kind === 'missing checkpoint') rmSync(checkpoint)
else if (kind === 'missing status') {
rmSync(status)
rmSync(join(root, 'docs/session-format-status.zh.md'))
} else if (kind === 'unpaired status') writeFileSync(status, readFileSync(status, 'utf8').replace(': 4', ': 5'))
else if (kind === 'duplicate status') writeFileSync(status, readFileSync(status, 'utf8').repeat(2))
else if (kind === 'future checkpoint') writeFileSync(join(root, 'docs/persistence-changes/finalized/v5.json'), readFileSync(checkpoint))
else {
const data = JSON.parse(readFileSync(checkpoint, 'utf8')) as Record<string, unknown>
if (kind === 'invalid checkpoint') data.schemaVersion = 2
else data.roots = {}
writeFileSync(checkpoint, JSON.stringify(data))
}
const before = contents(root)
expect(() => runPersistenceChanges(['--check'], root, () => inventory({ value: 'number' }, 4))).toThrow(/finaliz|checkpoint/u)
expect(() => runPersistenceChanges(['--record', FUTURE_ID], root, () => inventory({ value: 'boolean' }, 5)))
.toThrow(/finaliz|checkpoint/u)
expect(contents(root)).toEqual(before)
})
it('keeps older fixture roots valid when neither finalization status nor checkpoints exist', () => {
const root = fixture()
baseline(root)
expect(loadPersistenceFinalization(root, loadPersistenceHistory(root))).toBeUndefined()
})
})
function onlyEvent(schema: PersistenceSchemaInventory): PersistenceSchemaInventory {
return { ...schema, roots: schema.roots.filter(root => root.kind === 'event') }
}
@@ -194,7 +397,63 @@ function unionBody(arms: readonly (readonly SchemaProperty[])[]): PersistenceRoo
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
}
function versionedEvent(variants: readonly { version: number; mode: string; extra?: boolean }[]): PersistenceRoot {
const nodes: SchemaNode[] = [
{ kind: 'object', indices: [], properties: [
{ name: 'data', type: variants.length === 1 ? 3 : 1, optional: false },
{ name: 'type', type: 3 + variants.length * 3, optional: false },
] },
{ kind: 'union', types: variants.map((_, index) => 3 + index * 3) },
{ kind: 'primitive', type: 'string' },
]
for (const variant of variants) {
const index = nodes.length
nodes.push({ kind: 'object', indices: [], properties: [
{ name: 'version', type: index + 1, optional: false },
{ name: 'mode', type: index + 2, optional: false },
...(variant.extra ? [{ name: 'extra', type: 2, optional: false }] : []),
] }, { kind: 'literal', value: variant.version }, { kind: 'literal', value: variant.mode })
}
nodes.push({ kind: 'literal', value: 'example/value' })
const schema = canonicalizeSchema(nodes, 0)
return { ...typeRoot('event:example/value', {}), schema, digest: schemaDigest(schema) }
}
describe('persistence change classification', () => {
it.each([1, 2])('acknowledges a higher payload version while preserving %s old variant(s) and the V4 checkpoint', (count) => {
const root = fixture()
const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }].slice(0, count)
const oldEvent = versionedEvent(variants)
const nextEvent = versionedEvent([...variants, { version: 1, mode: 'unknown', extra: true }])
const before = { ...inventory({}, 4), roots: [...inventory({}, 4).roots.filter(root => root.kind !== 'event'), oldEvent] }
finalize(root, before)
const after = { ...before, roots: before.roots.map(root => root.kind === 'event' ? nextEvent : root) }
expect(classifyPersistenceChange(oldEvent, nextEvent))
.toEqual([expect.objectContaining({ kind: 'payload-version-added', requiresVersionBump: false })])
runPersistenceChanges(['--record', COMPATIBLE_ID, '--prose', proseFile(root)], root, () => after)
verifyPersistenceChanges(root, after)
})
it('rejects changes to old payloads, missing versions, non-increasing versions, and removal of old readers', () => {
const variants = [{ version: 0, mode: 'one-shot' }, { version: 0, mode: 'continuable' }]
const before = versionedEvent(variants)
for (const after of [
versionedEvent([...variants, { version: 0, mode: 'unknown' }]),
versionedEvent([...variants, { version: -1, mode: 'unknown' }]),
versionedEvent([...variants, { version: 0.5, mode: 'unknown' }]),
versionedEvent([{ version: 0, mode: 'one-shot', extra: true }, variants[1]!, { version: 1, mode: 'unknown' }]),
versionedEvent([{ version: 1, mode: 'unknown' }]),
]) expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
const expanded = versionedEvent([...variants, { version: 1, mode: 'unknown' }])
expect(classifyPersistenceChange(expanded, before).some(change => change.requiresVersionBump)).toBe(true)
expect(classifyPersistenceChange({ ...before, surface: true }, { ...expanded, surface: true })
.some(change => change.requiresVersionBump)).toBe(true)
expect(classifyPersistenceChange({ ...before, kind: 'header' }, { ...expanded, kind: 'header' })
.some(change => change.requiresVersionBump)).toBe(true)
const unversioned = unionBody([[{ name: 'mode', type: 2, optional: false }]])
expect(classifyPersistenceChange(unversioned, expanded).some(change => change.requiresVersionBump)).toBe(true)
})
it('treats a new optional payload subtree as one additive change even with required descendants', () => {
const before = typeRoot('event:example/value', { value: 'string' })
const after = typeRoot('event:example/value', { value: 'string', 'details?': { name: 'string', count: 'number' } })
@@ -363,7 +622,7 @@ describe('persistence history verification', () => {
it('rejects malformed references, unknown schema variants, digest tampering, and extra snapshot roots', () => {
const schema = inventory()
const malformed = structuredClone(schema) as unknown as { roots: Array<{ schema: { nodes: unknown[] }; digest: string }> }
const malformed: { roots: readonly { schema: { nodes: readonly unknown[] }; digest: string }[] } = structuredClone(schema)
malformed.roots[0]!.schema.nodes = [{ kind: 'array', element: 99 }]
expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node')
malformed.roots[0]!.schema.nodes = [{ kind: 'future' }]
@@ -371,7 +630,7 @@ describe('persistence history verification', () => {
const tampered = structuredClone(schema)
Object.assign(tampered.roots[0]!, { digest: '0'.repeat(64) })
expect(() => parsePersistenceSnapshot(tampered)).toThrow('digest mismatch')
expect(() => parsePersistenceSnapshot({ ...schema, formatVersion: 2 })).toThrow('normalization version')
expect(() => parsePersistenceSnapshot({ ...schema, formatVersion: 3 })).toThrow('normalization version')
const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
expect(() => validatePersistenceHistory([entry(BASE_ID, schema, null, true), { ...next, snapshot: inventory() }])).toThrow('snapshot roots')
})
@@ -580,14 +839,16 @@ describe('persistence changes current-tree commands', () => {
expect(authored.error).toBeUndefined()
expect(authored.signal).toBeNull()
expect(authored.status, String(authored.stderr)).toBe(0)
expect(JSON.parse(String(authored.stdout)) as unknown).toMatchObject({ ok: true, operation: 'record' })
const authoredResult: unknown = JSON.parse(String(authored.stdout))
expect(authoredResult).toMatchObject({ ok: true, operation: 'record' })
const beforeUpdate = readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8')
writeFileSync(join(session, 'types.ts'), optional.replace('label?: string', 'label?: string; extra?: number'))
const updated = cli('--update', NEXT_ID, '--json')
expect(updated.error).toBeUndefined()
expect(updated.signal).toBeNull()
expect(updated.status, String(updated.stderr)).toBe(0)
expect(JSON.parse(String(updated.stdout)) as unknown).toMatchObject({ ok: true, operation: 'update' })
const updatedResult: unknown = JSON.parse(String(updated.stdout))
expect(updatedResult).toMatchObject({ ok: true, operation: 'update' })
expect(readFileSync(join(root, `docs/persistence-changes/${NEXT_ID}.md`), 'utf8').replace(/```yaml persistence-change[\s\S]*?```/u, ''))
.toBe(beforeUpdate.replace(/```yaml persistence-change[\s\S]*?```/u, ''))
const generated = extractPersistenceSchema(root)
@@ -604,8 +865,150 @@ describe('persistence changes current-tree commands', () => {
expect(structured.error).toBeUndefined()
expect(structured.signal).toBeNull()
expect(structured.status).toBe(1)
expect(JSON.parse(String(structured.stdout)) as unknown).toMatchObject({
const structuredResult: unknown = JSON.parse(String(structured.stdout))
expect(structuredResult).toMatchObject({
ok: false, code: 'unacknowledged-changes', changes: [expect.objectContaining({ kind: 'type-changed', requiresVersionBump: true })],
})
})
})
interface SourceAlternative {
readonly kind: string
readonly value?: 'string' | 'number'
readonly extra?: 'required' | 'optional'
}
function attributedRoot(
alternatives: readonly SourceAlternative[],
options: { policy?: SourceCompatibility | null; role?: 'user' | 'developer'; extra?: boolean } = {},
): PersistenceRoot {
const role = options.role ?? 'user'
const policy: SourceCompatibility | undefined = options.policy === null ? undefined : options.policy ?? {
version: 1, policy: 'session-source-attribution', binding: `session.${role}-message.source`,
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: [],
}
const nodes: SchemaNode[] = [
{ kind: 'object', indices: [], properties: [{ name: 'type', type: 2, optional: false }, { name: 'data', type: 1, optional: false }] },
{ kind: 'object', indices: [], properties: [
{ name: 'role', type: 4, optional: false }, { name: 'source', type: 3, optional: false, ...(policy === undefined ? {} : { compatibility: policy }) },
...(options.extra ? [{ name: 'unrelated', type: 5, optional: false }] : []),
] },
{ kind: 'literal', value: 'example/source' },
{ kind: 'primitive', type: 'never' },
{ kind: 'literal', value: role },
{ kind: 'primitive', type: 'string' },
{ kind: 'primitive', type: 'number' },
]
const types = alternatives.map((alternative) => {
const index = nodes.length
nodes.push({ kind: 'object', indices: [], properties: [
{ name: 'kind', type: index + 1, optional: false },
...(alternative.value === undefined ? [] : [{ name: 'value', type: alternative.value === 'string' ? 5 : 6, optional: false }]),
...(alternative.extra === undefined ? [] : [{ name: 'extra', type: 5, optional: alternative.extra === 'optional' }]),
] }, { kind: 'literal', value: alternative.kind })
return index
})
nodes[3] = { kind: 'union', types }
const schema = canonicalizeSchema(nodes, 0)
return { key: 'event:example/source', kind: 'event', event: 'example/source', surface: false, schema, digest: schemaDigest(schema) }
}
function attributionPolicy(kinds: readonly string[], role: 'user' | 'developer' = 'user'): SourceCompatibility {
return { version: 1, policy: 'session-source-attribution', binding: `session.${role}-message.source`,
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: kinds }
}
const EXISTING_SOURCE: SourceAlternative = { kind: 'semantic', value: 'string' }
const NEW_SOURCE: SourceAlternative = { kind: 'new-attribution', value: 'number' }
describe('recorded source compatibility policy', () => {
it.each(['user', 'developer'] as const)('accepts a qualified new %s source and records its changed digest', (role) => {
const before = attributedRoot([EXISTING_SOURCE], { role })
const after = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { role, policy: attributionPolicy([NEW_SOURCE.kind], role) })
expect(after.digest).not.toBe(before.digest)
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [after], types: [] }
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))).toEqual(snapshot)
})
it('allows ordinary optional fields in an existing group alongside a qualified addition', () => {
const before = attributedRoot([EXISTING_SOURCE])
const after = attributedRoot([{ ...EXISTING_SOURCE, extra: 'optional' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
const changes = classifyPersistenceChange(before, after)
expect(changes.map(change => change.kind).sort()).toEqual(['attribution-kind-added', 'optional-property-added'])
expect(changes.every(change => !change.requiresVersionBump)).toBe(true)
})
it.each([
['unmarked addition', [EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([]) }],
['required existing field', [{ ...EXISTING_SOURCE, extra: 'required' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
['changed existing field', [{ ...EXISTING_SOURCE, value: 'number' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
['removed existing kind', [NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
['renamed existing kind', [{ ...EXISTING_SOURCE, kind: 'renamed' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) }],
['unrelated required payload', [EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]), extra: true }],
['removed promise', [EXISTING_SOURCE, NEW_SOURCE], { policy: null }],
] as const)('keeps %s breaking despite qualified additions', (_name, sources, options) => {
const changes = classifyPersistenceChange(attributedRoot([EXISTING_SOURCE]), attributedRoot(sources, options))
expect(changes.some(change => change.requiresVersionBump)).toBe(true)
})
it('keeps variants within an existing wire-kind group strict', () => {
const before = attributedRoot([EXISTING_SOURCE])
const after = attributedRoot([EXISTING_SOURCE, { ...EXISTING_SOURCE, value: 'number' }, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
expect(classifyPersistenceChange(before, after)).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'union-variants-changed', requiresVersionBump: true }),
expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false }),
]))
})
it('treats qualification changes for an existing kind as policy changes', () => {
const before = attributedRoot([EXISTING_SOURCE])
const after = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([EXISTING_SOURCE.kind]) })
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'source-policy-changed', requiresVersionBump: true })])
})
it('keeps legacy source transitions strict and never infers promises from the successor', () => {
const before = attributedRoot([EXISTING_SOURCE], { policy: null })
const after = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })
expect(classifyPersistenceChange(before, after).some(change => change.requiresVersionBump)).toBe(true)
const legacyAfter = attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { policy: null })
const snapshots = [before, legacyAfter].map(root => ({ formatVersion: 1 as const, roots: [root], types: [] }))
expect(() => validatePersistenceHistory([
entry(BASE_ID, snapshots[0]!, null, true), entry(NEXT_ID, snapshots[1]!, BASE_ID),
])).toThrow('requires a format version bump')
})
it('validates policy-bearing history without source names or type records', () => {
const before: PersistenceSchemaInventory = { formatVersion: 2, types: [],
roots: [...inventory().roots.filter(root => root.kind !== 'event'), attributedRoot([EXISTING_SOURCE])],
}
const after: PersistenceSchemaInventory = { formatVersion: 2, types: [],
roots: [attributedRoot([EXISTING_SOURCE, NEW_SOURCE], { policy: attributionPolicy([NEW_SOURCE.kind]) })],
}
expect(validatePersistenceHistory([entry(BASE_ID, before, null, true), entry(NEXT_ID, after, BASE_ID)]).tips.get('event:example/source')?.root).toEqual(after.roots[0])
})
it.each(['auto-review', 'compact-basic', 'ptc-mode'])('rejects saved qualification of reserved producer kind %s', (kind) => {
const root = attributedRoot([EXISTING_SOURCE, { kind }], { policy: attributionPolicy([kind]) })
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [root], types: [] }
expect(() => parsePersistenceSnapshot(snapshot)).toThrow('invalid source compatibility')
})
it('rejects old-format policy fields, policy tampering, unsupported promises and invalid bindings', () => {
const root = attributedRoot([EXISTING_SOURCE])
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [root], types: [] }
expect(() => parsePersistenceSnapshot({ ...snapshot, formatVersion: 1 })).toThrow('unknown field compatibility')
for (const patch of [{ version: 2 }, { policy: 'arbitrary' }, { discriminator: 'type' }, { unknownKinds: 'discard' }]) {
const tampered = JSON.parse(JSON.stringify(snapshot)) as PersistenceSchemaInventory
const property = tampered.roots[0]!.schema.nodes.flatMap(node => node.kind === 'object' ? node.properties : []).find(property => property.compatibility !== undefined)!
Object.assign(property.compatibility!, patch)
expect(() => parsePersistenceSnapshot(tampered)).toThrow('unsupported source compatibility')
}
const wrongRole = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([], 'developer') })
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [wrongRole] })).toThrow('invalid source compatibility')
const changedQualification = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([EXISTING_SOURCE.kind]) })
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [{ ...changedQualification, digest: root.digest }] })).toThrow('digest mismatch')
const unknownKind = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy(['absent']) })
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [unknownKind] })).toThrow('invalid source compatibility')
})
})
+125 -19
View File
@@ -5,10 +5,12 @@ import { basename, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { JSON_SCHEMA, load } from 'js-yaml'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import { matchingSourceCompatibility, sourceKindGroups, validSourceCompatibility } from './persistence-source-policy.ts'
import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
import { renderPersistencePair } from './persistence-artifacts.ts'
import { loadPersistenceFinalization } from './persistence-finalization.ts'
import type { PersistenceArtifact } from './persistence-artifacts.ts'
const HISTORY_DIRECTORY = 'docs/persistence-changes'
@@ -64,7 +66,10 @@ const CHANGE_DESCRIPTIONS = {
'index-signature-changed': 'index signature changed',
'tuple-length-changed': 'tuple length changed',
'tuple-element-cardinality-changed': 'tuple element cardinality changed',
'payload-version-added': 'event payload version added',
'union-variants-changed': 'union variants changed',
'source-policy-changed': 'source compatibility policy changed',
'attribution-kind-added': 'attribution-only source kind added',
} as const
/** Stable structural classification independent of diagnostic prose. */
@@ -169,7 +174,7 @@ function reference(value: unknown, count: number, label: string): number {
return value as number
}
function parseSchema(value: unknown, label: string): CanonicalSchema {
function parseSchema(value: unknown, label: string, formatVersion: 1 | 2): CanonicalSchema {
const input = record(value, label)
keys(input, ['root', 'nodes'], label)
if (input.root !== 0) throw new Error(`${label}.root must be zero`)
@@ -211,7 +216,17 @@ function parseSchema(value: unknown, label: string): CanonicalSchema {
const names = new Set<string>()
for (const rawProperty of array(node.properties, label)) {
const property = record(rawProperty, label)
keys(property, ['name', 'type', 'optional'], label)
keys(property, ['name', 'type', 'optional'], label, formatVersion === 2 ? ['compatibility'] : [])
if (property.compatibility !== undefined) {
const policy = record(property.compatibility, `${label} source compatibility`)
keys(policy, ['version', 'policy', 'binding', 'discriminator', 'unknownKinds', 'attributionKinds'], label)
if (policy.version !== 1 || policy.policy !== 'session-source-attribution'
|| !['session.user-message.source', 'session.developer-message.source'].includes(String(policy.binding))
|| policy.discriminator !== 'kind' || policy.unknownKinds !== 'preserve') {
throw new Error(`${label}: unsupported source compatibility policy`)
}
for (const kind of array(policy.attributionKinds, label)) textValue(kind, label)
}
if (typeof property.name !== 'string') throw new Error(`${label}: property name must be a string`)
const name = property.name
if (names.has(name)) throw new Error(`${label}: duplicate property ${name}`)
@@ -237,6 +252,14 @@ function parseSchema(value: unknown, label: string): CanonicalSchema {
}
}
const schema = input as unknown as CanonicalSchema
for (const node of schema.nodes) {
if (node.kind !== 'object') continue
for (const property of node.properties) {
if (property.compatibility !== undefined && !validSourceCompatibility(schema.nodes, node, property)) {
throw new Error(`${label}: invalid source compatibility binding or attribution kinds`)
}
}
}
const canonical = canonicalizeSchema(schema.nodes, schema.root)
if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`)
return schema
@@ -261,7 +284,7 @@ export function parseHistoricalPersistenceSnapshot(value: unknown): PersistenceS
function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
const input = record(value, 'schema inventory')
keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
if (input.formatVersion !== 1) throw new Error('unsupported persistence schema normalization version')
if (input.formatVersion !== 1 && input.formatVersion !== 2) throw new Error('unsupported persistence schema normalization version')
const names = new Set<string>()
for (const rawRoot of array(input.roots, 'schema roots')) {
const root = record(rawRoot, 'schema root')
@@ -275,14 +298,14 @@ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaIn
} else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
&& (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
const schema = parseSchema(root.schema, key)
const schema = parseSchema(root.schema, key, input.formatVersion)
if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true, historical)
if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
}
for (const rawType of array(input.types, 'schema types')) {
const type = record(rawType, 'schema type')
keys(type, ['digest', 'schema', 'names', 'sources'], 'schema type')
const schema = parseSchema(type.schema, 'shared schema')
const schema = parseSchema(type.schema, 'shared schema', input.formatVersion)
if (digest(type.digest, 'shared digest') !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
for (const name of array(type.names, 'type names')) textValue(name, 'type name')
for (const source of array(type.sources, 'type sources')) {
@@ -339,7 +362,7 @@ function matchUnionVariants(candidates: readonly (readonly number[])[]): number[
return matches
}
/** Classify structural differences; only optional payload properties and ordinary event additions are additive.
/** Classify structural differences using the reader promises saved with each schema.
* @param before - predecessor root, or absence for an addition.
* @param after - successor root, or absence for deletion.
* @returns concrete changes and their format-bump requirement.
@@ -383,6 +406,27 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after:
}
const oldNode = oldRoot.schema.nodes[oldIndex] as SchemaNode
const newNode = newRoot.schema.nodes[newIndex] as SchemaNode
if (oldRoot.kind === 'event' && oldRoot.surface === false && scope === 'body' && path === `${key}.data`) {
const oldTypes = oldNode.kind === 'union' ? oldNode.types : [oldIndex]
const newTypes = newNode.kind === 'union' ? newNode.types : [newIndex]
const payloadVersion = (schema: CanonicalSchema, index: number): number | undefined => {
const node = schema.nodes[index]
if (node?.kind !== 'object') return undefined
const property = node.properties.find(property => property.name === 'version' && !property.optional)
const version = schema.nodes[property?.type ?? -1]
return version?.kind === 'literal' && typeof version.value === 'number'
&& Number.isSafeInteger(version.value) && version.value >= 0 ? version.value : undefined
}
const versions = oldTypes.map(index => payloadVersion(oldRoot.schema, index))
const oldHashes = new Set(oldTypes.map(index => fingerprint(oldRoot.schema, index, 0)))
const newHashes = new Set(newTypes.map(index => fingerprint(newRoot.schema, index, 1)))
const added = newTypes.filter(index => !oldHashes.has(fingerprint(newRoot.schema, index, 1)))
if (versions.every(version => version !== undefined) && added.length > 0
&& [...oldHashes].every(hash => newHashes.has(hash))
&& added.every(index => (payloadVersion(newRoot.schema, index) ?? -1) > Math.max(...versions))) {
return [describe(path, 'payload-version-added', false)]
}
}
if (oldNode.kind !== newNode.kind) return [describe(path, 'type-changed')]
if (oldNode.kind === 'object' && newNode.kind === 'object') {
const oldProps = new Map(oldNode.properties.map(property => [property.name, property]))
@@ -392,7 +436,30 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after:
const child = `${path}.${name}`
if (next === undefined) { add(child, 'property-removed'); continue }
if (property.optional !== next.optional) add(child, next.optional ? 'property-made-optional' : 'property-made-required', scope !== 'body' || !next.optional)
descend(property.type, next.type, child, scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict')
const childScope = scope === 'body' || scope === 'event' && name === 'data' ? 'body' : 'strict'
if (property.compatibility !== undefined || next.compatibility !== undefined) {
const oldGroups = sourceKindGroups(oldRoot.schema.nodes, property.type)
const newGroups = sourceKindGroups(newRoot.schema.nodes, next.type)
if (childScope === 'body' && property.compatibility !== undefined && next.compatibility !== undefined
&& oldGroups !== undefined && newGroups !== undefined
&& validSourceCompatibility(oldRoot.schema.nodes, oldNode, property)
&& validSourceCompatibility(newRoot.schema.nodes, newNode, next)
&& matchingSourceCompatibility(property.compatibility, next.compatibility, oldGroups.keys())) {
for (const [kind, oldTypes] of oldGroups) {
const newTypes = newGroups.get(kind)
if (newTypes === undefined) add(`${child}[kind=${JSON.stringify(kind)}]`, 'union-variants-changed')
else differences.push(...compareAlternatives(oldTypes, newTypes, `${child}[kind=${JSON.stringify(kind)}]`, childScope, active))
}
for (const kind of newGroups.keys()) {
if (!oldGroups.has(kind)) add(`${child}[kind=${JSON.stringify(kind)}]`,
next.compatibility.attributionKinds.includes(kind) ? 'attribution-kind-added' : 'union-variants-changed',
!next.compatibility.attributionKinds.includes(kind))
}
continue
}
if (JSON.stringify(property.compatibility) !== JSON.stringify(next.compatibility)) add(child, 'source-policy-changed')
}
descend(property.type, next.type, child, childScope)
}
for (const [name, property] of newProps) {
if (!oldProps.has(name)) add(`${path}.${name}`, property.optional ? 'optional-property-added' : 'required-property-added', scope !== 'body' || !property.optional)
@@ -419,20 +486,25 @@ export function classifyPersistenceChange(before: PersistenceRoot | null, after:
return differences
}
if (oldNode.kind === 'union' && newNode.kind === 'union') {
if (oldNode.types.length !== newNode.types.length) return [describe(path, 'union-variants-changed')]
const candidates = oldNode.types.map(oldType => newNode.types.map(newType => compare(oldType, newType, path, scope, active)))
const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
const oldTypes = new Map(oldNode.types.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
const newTypes = new Map(newNode.types.map(index => [fingerprint(newRoot.schema, index, 1), index]))
const removed = [...oldTypes].filter(([hash]) => !newTypes.has(hash)).map(([, index]) => index)
const added = [...newTypes].filter(([hash]) => !oldTypes.has(hash)).map(([, index]) => index)
if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
return [describe(path, 'union-variants-changed')]
return compareAlternatives(oldNode.types, newNode.types, path, scope, active)
}
return [describe(path, 'type-changed')]
}
function compareAlternatives(
oldTypes: readonly number[], newTypes: readonly number[], path: string, scope: Scope, active: ReadonlySet<string>,
): PersistenceTypeChange[] {
if (oldTypes.length !== newTypes.length) return [describe(path, 'union-variants-changed')]
const candidates = oldTypes.map(oldType => newTypes.map(newType => compare(oldType, newType, path, scope, active)))
const matching = matchUnionVariants(candidates.map(row => row.flatMap((candidate, index) =>
candidate.every(change => !change.requiresVersionBump) ? [index] : [])))
if (matching !== undefined) return matching.flatMap((next, previous) => candidates[previous]?.[next] ?? [])
const oldByHash = new Map(oldTypes.map(index => [fingerprint(oldRoot.schema, index, 0), index]))
const newByHash = new Map(newTypes.map(index => [fingerprint(newRoot.schema, index, 1), index]))
const removed = [...oldByHash].filter(([hash]) => !newByHash.has(hash)).map(([, index]) => index)
const added = [...newByHash].filter(([hash]) => !oldByHash.has(hash)).map(([, index]) => index)
if (removed.length === 1 && added.length === 1) return compare(removed[0] as number, added[0] as number, path, scope, active)
return [describe(path, 'union-variants-changed')]
}
changes.push(...compare(0, 0, key, before.kind === 'event' ? 'event' : 'strict', new Set()))
if (changes.length === 0) changes.push(describe(key, 'type-changed'))
return [...new Map(changes.map(change => [JSON.stringify([change.path, change.kind, change.requiresVersionBump]), change])).values()]
@@ -591,6 +663,38 @@ function currentDifferences(history: PersistenceHistory, current: PersistenceSch
return differences.sort()
}
function verifyFinalization(
root: string, entries: readonly PersistenceHistoryEntry[], current: PersistenceSchemaInventory, updateId?: string,
): void {
const finalized = loadPersistenceFinalization(root, { entries })
if (finalized === undefined) return
if (updateId !== undefined && finalized.acceptedRecords.has(updateId)) {
throw new PersistenceChangeFailure(`cannot update finalized acknowledgement ${updateId}; create a successor record`, 'finalized-record-update')
}
const writer = headerVersion(current.roots.find(root => root.key === 'SessionHeader') ?? null)
if (writer === undefined || writer < finalized.version) {
throw new PersistenceChangeFailure(`Session writer must not precede finalized format ${finalized.version}`, 'finalized-version-order')
}
if (writer > finalized.version) return
const afterRoots = new Map(current.roots.map(root => [root.key, root]))
const changes: ReportedChange[] = []
const transitions: RootTransition[] = []
for (const key of [...new Set([...finalized.roots.keys(), ...afterRoots.keys()])].sort()) {
const before = finalized.roots.get(key) ?? null
const after = afterRoots.get(key) ?? null
const differences = classifyPersistenceChange(before, after)
if (differences.length === 0) continue
changes.push(...differences.map(change => ({ root: key, ...change })))
transitions.push(rootTransition(before, after))
}
if (changes.some(change => change.requiresVersionBump)) {
throw new PersistenceChangeFailure(
`Breaking changes relative to the accepted Session format ${writer} baseline require format ${writer + 1} or later`,
'finalized-format-changed', changes, transitions,
)
}
}
/** Verify current generated output and acknowledgement tips together.
* @param root - checkout or isolated fixture root.
* @param current - freshly extracted current-source inventory.
@@ -598,6 +702,7 @@ function currentDifferences(history: PersistenceHistory, current: PersistenceSch
*/
export function verifyPersistenceChanges(root: string, current: PersistenceSchemaInventory): PersistenceHistory {
const history = loadPersistenceHistory(root)
verifyFinalization(root, history.entries, current)
const differences = reportedDifferences(history, current)
const transitions = rootTransitions(history, current)
const committedPath = join(root, CURRENT_SCHEMA)
@@ -726,6 +831,7 @@ function executeCommand(
throw new Error('--decision must be same-version or version-bump')
}
const entries = baseline ? [] : readPersistenceEntries(root, update ? id : undefined)
verifyFinalization(root, entries, current, update ? id : undefined)
const existing = update ? entries.find(entry => entry.record.id === id) : undefined
if (update && existing === undefined) throw new Error(`${id}: cannot update a missing acknowledgement`)
if (existing?.record.baseline === true) throw new Error('cannot update the persistence baseline')
@@ -743,7 +849,7 @@ function executeCommand(
root: key, previous: history?.tips.get(key)?.id ?? null,
after: roots.find(root => root.key === key)?.digest ?? null, decision,
})) }
const snapshot: PersistenceSchemaInventory = { formatVersion: 1, roots, types: [] }
const snapshot: PersistenceSchemaInventory = { formatVersion: current.formatVersion, roots, types: [] }
validatePersistenceHistory([...prior, { record: change, snapshot }])
const document = (chinese: boolean): string => {
const supplied = chinese ? prose?.zh : prose?.en
+68
View File
@@ -0,0 +1,68 @@
/** Developer field classification matches native admission and metadata preservation. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
import { Session, SessionId, SessionLogOffset } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import { classifyPersistenceChange, parsePersistenceSnapshot } from './persistence-changes.ts'
function toolAdditionSchema() {
const inventory = parsePersistenceSnapshot(JSON.parse(readFileSync(new URL('../docs/persistence-schema.json', import.meta.url), 'utf8')) as unknown)
const before = inventory.roots.find(root => root.key === 'event:developer/message')!
const nodes = [...structuredClone(before.schema.nodes)]
const blockIndex = nodes.findIndex(node => node.kind === 'object' && node.properties.some((property) => {
const type = nodes[property.type]
return property.name === 'type' && type?.kind === 'literal' && type.value === 'tool-addition'
}))
const block = nodes[blockIndex]!
if (block.kind !== 'object') throw new Error('missing tool-addition schema')
const stringIndex = nodes.findIndex(node => node.kind === 'primitive' && node.type === 'string')
return { before, nodes, blockIndex, block, stringIndex }
}
describe('developer field compatibility', () => {
it('requires a version bump before a future declaration can use the rejected inline tool field', () => {
const { before, nodes, blockIndex, block, stringIndex } = toolAdditionSchema()
nodes[blockIndex] = { ...block, properties: [
...block.properties.filter(property => property.name !== 'tool'),
{ name: 'tool', type: stringIndex, optional: true },
] }
const schema = canonicalizeSchema(nodes, 0)
expect(classifyPersistenceChange(before, { ...before, schema, digest: schemaDigest(schema) }))
.toContainEqual(expect.objectContaining({ requiresVersionBump: true }))
const reserved = block.properties.find(property => property.name === 'tool')
expect(reserved).toMatchObject({ optional: true })
expect(before.schema.nodes[reserved!.type]).toEqual({ kind: 'primitive', type: 'never' })
const event: SessionFormatEvent = { type: 'developer/message', seq: 3, time: 4, surfaceOp: 'append', data: {
turn: 1, step: 1, headerSeq: 2, message: { id: 'legacy-inline', role: 'developer', source: { kind: 'tool-registry' },
content: [{ type: 'tool-addition', toolName: 'search', tool: 'optional metadata' }],
},
} }
expect(() => sessionFormatCatalog.encodeCurrentEvent(event)).toThrow('omit inline tool definitions')
})
it('accepts and preserves a tool-addition field classified as optional', () => {
const { before, nodes, blockIndex, block, stringIndex } = toolAdditionSchema()
nodes[blockIndex] = { ...block, properties: [...block.properties, { name: 'traceId', type: stringIndex, optional: true }] }
const schema = canonicalizeSchema(nodes, 0)
expect(classifyPersistenceChange(before, { ...before, schema, digest: schemaDigest(schema) })).toMatchObject([{ kind: 'optional-property-added', requiresVersionBump: false }])
const header = { version: 4, id: 'optional-developer-field', createdAt: 1, isSeeded: false, delegationDepth: 0 }
const message = { id: 'developer', role: 'developer', source: { kind: 'tool-registry', extra: true },
content: [{ type: 'tool-addition', toolName: 'search', traceId: 'retained' }], extra: true }
const events: SessionFormatEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'request/header', seq: 2, time: 3, data: { reason: 'initial', header: { config: { provider: 'test', model: 'test' }, tools: [{ name: 'search', description: 'Search', parameters: {} }] } } },
{ type: 'developer/message', seq: 3, time: 4, surfaceOp: 'append', data: { turn: 1, step: 1, headerSeq: 2, message, extra: true } },
]
const reader = sessionFormatCatalog.createRestore(sessionFormatCatalog.encodeCurrentHeader(header, 0), { recovery: 'strict', validation: 'current' })
for (const event of events) reader.decodeRow(sessionFormatCatalog.encodeCurrentEvent(event))
const artifact = reader.finish()
expect(artifact.events).toEqual(events)
const restored = Session.fromRestore(SessionId(header.id), artifact.events as SessionEvent[],
artifact.header as unknown as SessionHeader, SessionLogOffset(0), 'detached')
expect(restored.deriveMessages()).toEqual([message])
})
})
+48
View File
@@ -0,0 +1,48 @@
/** The generated request-header schema retains the native reader's retired-field refusal. */
import { resolve } from 'node:path'
import { expect, it } from 'vitest'
import { assertV4RowAdmission } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { classifyPersistenceChange } from './persistence-changes.ts'
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
import type { SchemaNode } from './persistence-schema-model.ts'
function property(nodes: readonly SchemaNode[], index: number, name: string): number {
const node = nodes[index]
if (node?.kind !== 'object') throw new Error(`expected an object containing ${name}`)
const field = node.properties.find(candidate => candidate.name === name)
if (field === undefined) throw new Error(`generated schema omits ${name}`)
return field.type
}
it('requires a version bump before request headers can carry retired system text', { timeout: 60_000 }, () => {
const inventory = extractPersistenceSchema(resolve(import.meta.dirname, '..'))
const before = inventory.roots.find(root => root.key === 'event:request/header')
if (before === undefined) throw new Error('generated schema omits request/header')
const nodes = [...before.schema.nodes]
const dataIndex = property(nodes, before.schema.root, 'data')
const headerIndex = property(nodes, dataIndex, 'header')
const header = nodes[headerIndex]
if (header?.kind !== 'object') throw new Error('generated header schema is not an object')
const reserved = header.properties.find(field => field.name === 'system')
expect(reserved).toMatchObject({ optional: true })
if (reserved === undefined) throw new Error('generated header omits the retired system reservation')
expect(nodes[reserved.type]).toEqual({ kind: 'primitive', type: 'never' })
const stringIndex = nodes.length
nodes.push({ kind: 'primitive', type: 'string' })
nodes[headerIndex] = {
...header,
properties: header.properties.map(field => field.name === 'system' ? { ...field, type: stringIndex } : field),
}
const schema = canonicalizeSchema(nodes, before.schema.root)
const after = { ...before, schema, digest: schemaDigest(schema) }
expect(classifyPersistenceChange(before, after)).toEqual([{
path: 'event:request/header.data.header.system',
kind: 'type-changed',
description: 'type changed',
requiresVersionBump: true,
}])
expect(() => { assertV4RowAdmission({ type: 'request/header', data: { header: { system: 'retired text' } } }) })
.toThrow('format v4 request/header rejects retired header.system')
})
+172
View File
@@ -0,0 +1,172 @@
/** Accepted declared Session baselines and immutable acknowledgements, independent of publication evidence. */
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { JSON_SCHEMA, load } from 'js-yaml'
import type { PersistenceHistory, PersistenceHistoryEntry } from './persistence-changes.ts'
import type { PersistenceRoot, PersistenceSchemaInventory } from './persistence-schema-model.ts'
const DIRECTORY = 'docs/persistence-changes/finalized'
const DIGEST = /^[a-f0-9]{64}$/u
const RECORD_ID = /^\d{4}-\d{2}-\d{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/u
type RootIdentity = Pick<PersistenceRoot, 'kind' | 'digest' | 'event' | 'surface'>
/** Complete accepted baseline and semantic identities of its acknowledged history. */
export interface PersistenceFinalizationCheckpoint {
readonly schemaVersion: 1
readonly sessionFormatVersion: number
readonly roots: Readonly<Record<string, RootIdentity>>
readonly acceptedRecords: Readonly<Record<string, string>>
}
/** Latest accepted baseline schemas and the record ids locked by retained checkpoints. */
export interface PersistenceFinalization {
readonly version: number
readonly roots: ReadonlyMap<string, PersistenceRoot>
readonly acceptedRecords: ReadonlySet<string>
}
function identity(root: PersistenceRoot): RootIdentity {
return { kind: root.kind, digest: root.digest,
...(root.event === undefined ? {} : { event: root.event }),
...(root.surface === undefined ? {} : { surface: root.surface }) }
}
function compare(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
function rootMap(roots: readonly PersistenceRoot[]): Record<string, RootIdentity> {
return Object.fromEntries([...roots].sort((a, b) => compare(a.key, b.key)).map(root => [root.key, identity(root)]))
}
function recordDigest(entry: PersistenceHistoryEntry): string {
const record = entry.record
const semantic = { record: { schemaVersion: record.schemaVersion, id: record.id, baseline: record.baseline,
changes: [...record.changes].sort((a, b) => compare(a.root, b.root)).map(change => ({
root: change.root, previous: change.previous, after: change.after, decision: change.decision,
})) }, snapshot: { formatVersion: entry.snapshot.formatVersion,
roots: [...entry.snapshot.roots].sort((a, b) => compare(a.key, b.key)).map(root => ({
key: root.key, ...identity(root), schema: root.schema,
})) } }
return createHash('sha256').update('dsh-persistence-finalization-record-v1\n').update(JSON.stringify(semantic)).digest('hex')
}
function writerVersion(roots: readonly PersistenceRoot[]): number {
const schema = roots.find(root => root.key === 'SessionHeader')?.schema
const node = schema?.nodes[0]
const property = node?.kind === 'object' ? node.properties.find(property => property.name === 'version') : undefined
const version = property === undefined ? undefined : schema?.nodes[property.type]
if (version?.kind !== 'literal' || typeof version.value !== 'number' || !Number.isSafeInteger(version.value) || version.value < 0) {
throw new Error('persistence finalization requires a literal non-negative SessionHeader.version')
}
return version.value
}
/**
* Capture a verified history without copying its self-contained schemas or recording a publication claim.
* @param history - validated acknowledgement history matching the current inventory.
* @param current - validated current-source inventory.
* @returns checkpoint data for a new version-named file; existing checkpoints must be retained.
*/
export function createPersistenceFinalizationCheckpoint(
history: PersistenceHistory, current: PersistenceSchemaInventory,
): PersistenceFinalizationCheckpoint {
const recorded = [...history.tips.values()].flatMap(tip => tip.root === null ? [] : [tip.root])
if (JSON.stringify(rootMap(recorded)) !== JSON.stringify(rootMap(current.roots))) {
throw new Error('persistence finalization requires current schemas to match acknowledged history')
}
return { schemaVersion: 1, sessionFormatVersion: writerVersion(current.roots), roots: rootMap(current.roots),
acceptedRecords: Object.fromEntries([...history.entries].sort((a, b) => compare(a.record.id, b.record.id))
.map(entry => [entry.record.id, recordDigest(entry)])) }
}
function object(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${label}: expected an object`)
return value as Record<string, unknown>
}
function status(root: string, path: string): number | undefined {
if (!existsSync(join(root, path))) return undefined
const source = readFileSync(join(root, path), 'utf8').replaceAll('\r\n', '\n')
const openings = [...source.matchAll(/^```yaml session-format-finalization[ \t]*$/gmu)]
if (openings.length === 0) return undefined
const blocks = [...source.matchAll(/^```yaml session-format-finalization[ \t]*\n([\s\S]*?)^```[ \t]*$/gmu)]
if (openings.length !== 1 || blocks.length !== 1) throw new Error(`${path}: expected one closed session-format-finalization record`)
const value = object(load((blocks[0] as RegExpMatchArray)[1] as string, { schema: JSON_SCHEMA }), path)
const version = value.latestFinalizedVersion
if (Object.keys(value).join(',') !== 'latestFinalizedVersion'
|| typeof version !== 'number' || !Number.isSafeInteger(version) || version < 0) {
throw new Error(`${path}: expected only a non-negative latestFinalizedVersion`)
}
return version
}
/**
* Validate retained checkpoints against accepted history and reconstruct the latest accepted baseline.
* @param root - checkout or isolated fixture root.
* @param history - parsed acknowledgement entries, including later successors.
* @returns finalization state, or undefined for a checkout with neither status nor checkpoints.
*/
export function loadPersistenceFinalization(root: string, history: Pick<PersistenceHistory, 'entries'>): PersistenceFinalization | undefined {
const directory = join(root, DIRECTORY)
const files = existsSync(directory) ? readdirSync(directory).filter(file => file.endsWith('.json')).sort() : []
const version = status(root, 'docs/session-format-status.md')
const translated = status(root, 'docs/session-format-status.zh.md')
if (version === undefined && translated === undefined && files.length === 0) return undefined
if (version === undefined || translated !== version) throw new Error('persistence finalization status is missing or differs between languages')
if (!files.includes(`v${version}.json`)) throw new Error(`missing persistence finalization checkpoint v${version}.json`)
const entries = new Map(history.entries.map(entry => [entry.record.id, entry]))
const acceptedRecords = new Set<string>()
let latestRoots: Map<string, PersistenceRoot> | undefined
for (const file of files) {
const match = /^v(0|[1-9]\d*)\.json$/u.exec(file)
const capturedVersion = match === null ? NaN : Number(match[1])
if (!Number.isSafeInteger(capturedVersion) || capturedVersion > version) throw new Error(`unexpected persistence finalization checkpoint ${file}`)
const checkpoint = object(JSON.parse(readFileSync(join(directory, file), 'utf8')), file)
if (Object.keys(checkpoint).sort().join(',') !== 'acceptedRecords,roots,schemaVersion,sessionFormatVersion'
|| checkpoint.schemaVersion !== 1 || checkpoint.sessionFormatVersion !== capturedVersion) {
throw new Error(`${file}: invalid persistence finalization checkpoint metadata`)
}
const locked = object(checkpoint.acceptedRecords, `${file} acceptedRecords`)
const selected: PersistenceHistoryEntry[] = []
for (const [id, expected] of Object.entries(locked)) {
if (!RECORD_ID.test(id) || typeof expected !== 'string' || !DIGEST.test(expected)) throw new Error(`${file}: invalid accepted record ${id}`)
const entry = entries.get(id)
if (entry === undefined || recordDigest(entry) !== expected) throw new Error(`${file}: finalized acknowledgement ${id} was removed or changed`)
selected.push(entry)
acceptedRecords.add(id)
}
if (selected.length === 0 || !selected.some(entry => entry.record.baseline)) throw new Error(`${file}: accepted records must include the persistence baseline`)
const predecessors = new Set<string>()
for (const entry of selected) for (const change of entry.record.changes) {
if (change.previous !== null) {
if (!Object.hasOwn(locked, change.previous)) throw new Error(`${file}: accepted history omits predecessor ${change.previous}`)
predecessors.add(JSON.stringify([change.previous, change.root]))
}
}
const tips = new Map<string, PersistenceRoot>()
for (const entry of selected) for (const change of entry.record.changes) {
if (!predecessors.has(JSON.stringify([entry.record.id, change.root])) && change.after !== null) {
const schema = entry.snapshot.roots.find(root => root.key === change.root)
if (schema === undefined) throw new Error(`${file}: accepted history omits schema ${change.root}`)
tips.set(change.root, schema)
}
}
const expectedRoots = rootMap([...tips.values()])
const declaredRoots = object(checkpoint.roots, `${file} roots`)
if (Object.keys(declaredRoots).length !== tips.size || Object.entries(expectedRoots).some(([key, expected]) => {
const actual = declaredRoots[key]
if (actual === undefined) return true
const value = object(actual, `${file} ${key}`)
return Object.keys(value).sort().join(',') !== Object.keys(expected).sort().join(',')
|| Object.entries(expected).some(([field, expectedValue]) => value[field] !== expectedValue)
})) throw new Error(`${file}: finalized roots do not match complete accepted history`)
if (writerVersion([...tips.values()]) !== capturedVersion) throw new Error(`${file}: accepted SessionHeader.version does not match finalized version`)
if (capturedVersion === version) latestRoots = tips
}
if (latestRoots === undefined) throw new Error(`missing finalized schemas for Session format ${version}`)
return { version, roots: latestRoots, acceptedRecords }
}
+1 -1
View File
@@ -367,7 +367,7 @@ describe('release facts', () => {
const data = fixture()
const snapshotPath = join(data.directory, TAGS[2] + '.schema.json')
const snapshot = readFileSync(snapshotPath, 'utf8')
writeFileSync(snapshotPath, snapshot.replace('"formatVersion":1', '"formatVersion":2'))
writeFileSync(snapshotPath, snapshot.replace('"formatVersion":1', '"formatVersion":3'))
const corrupt = directoryBytes(data.directory)
expect(() => runPersistenceReleases(['--write'], data.root)).toThrow('normalization version')
expect(directoryBytes(data.directory)).toEqual(corrupt)
+135
View File
@@ -0,0 +1,135 @@
/** Semantic review regressions independent of traversal ordinals and display names. */
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { classifyPersistenceChange } from './persistence-changes.ts'
import { canonicalizeSchema, schemaDigest, type PersistenceSchemaInventory, type SchemaNode, type SchemaProperty } from './persistence-schema-model.ts'
import { renderPersistenceReview, reviewPersistenceSchemas, runPersistenceReview } from './persistence-review.ts'
function fixture(options: {
extraKind?: boolean
changed?: boolean
ambiguous?: boolean
reorder?: boolean
tuple?: boolean
recursive?: boolean
} = {}): PersistenceSchemaInventory {
const nodes: SchemaNode[] = []
const add = (node: SchemaNode): number => nodes.push(node) - 1
const literal = (value: string): number => add({ kind: 'literal', value })
const text = add({ kind: 'primitive', type: 'string' })
const number = add({ kind: 'primitive', type: 'number' })
const object = (properties: SchemaProperty[]): number => add({ kind: 'object', properties, indices: [] })
const property = (name: string, type: number, optional = false): SchemaProperty => ({ name, type, optional })
const snapshot = object([property('kind', literal('hooks-codex')), property('form', literal('snapshot')), property('sections', add({ kind: 'array', element: text }))])
const notice = object([property('kind', literal('hooks-codex')), property('form', literal('notice')), property('summary', text)])
const changing = object([property('kind', literal('changing')), property('value', options.changed ? number : text)])
const variants = [snapshot, notice, changing]
if (options.ambiguous) variants.push(object([property('kind', literal('changing')), property('extra', text)]))
if (options.extraKind) variants.unshift(object([property('kind', literal('tool-registry'))]))
if (options.reorder) variants.reverse()
const source = add({ kind: 'union', types: variants })
const payloadProperties = [property('source', source)]
if (options.tuple) payloadProperties.push(property('tuple', add({ kind: 'tuple', elements: (options.changed ? [number, text] : [text, number]).map(type => ({ type, optional: false, rest: false })) })))
if (options.recursive) payloadProperties.push(property('next', nodes.length, true))
const payload = object(payloadProperties)
const roots = ['agent/inbox/spliced', 'user/message'].map((event) => {
const data = event === 'agent/inbox/spliced' ? object([property('inserted', add({ kind: 'array', element: payload }))]) : payload
const index = object([property('type', literal(event)), property('data', data)])
const schema = canonicalizeSchema(nodes, index)
return { key: `event:${event}`, event, kind: 'event' as const, surface: false, digest: schemaDigest(schema), schema }
})
const types = [...new Map(nodes.map((_, index) => {
const schema = canonicalizeSchema(nodes, index)
const digest = schemaDigest(schema)
const ordinal = index === snapshot ? options.extraKind ? 107 : 106 : index === notice ? options.extraKind ? 108 : 107 : undefined
return [digest, { digest, schema, names: ordinal === undefined ? [] : [`event:agent/inbox/spliced.data.inserted[0].source[${String(ordinal)}]`], sources: [] }]
})).values()]
return { formatVersion: 1, roots, types }
}
describe('persistence review', () => {
it('reports a new kind once across containing roots and never renames unchanged ordinal 107 alternatives', () => {
const before = fixture()
const after = fixture({ extraKind: true })
const saved = JSON.stringify([before, after])
const old107 = before.types.find(type => type.names.some(name => name.endsWith('[107]')))
const new107 = after.types.find(type => type.names.some(name => name.endsWith('[107]')))
expect(old107?.digest).not.toBe(new107?.digest)
expect(after.types.find(type => type.digest === old107?.digest)?.names[0]).toMatch(/\[108\]$/u)
const report = reviewPersistenceSchemas(before, after)
expect(report.evidence).toHaveLength(1)
expect(report.evidence[0]).toMatchObject({ kind: 'kind-added', before: null, locations: [
{ root: 'event:agent/inbox/spliced', path: 'event:agent/inbox/spliced.data.inserted[].source[kind="tool-registry"]' },
{ root: 'event:user/message', path: 'event:user/message.data.source[kind="tool-registry"]' },
] })
const markdown = renderPersistenceReview(report)
expect(markdown).not.toContain('hooks-codex')
expect(markdown).not.toMatch(/source\[\d+\]/u)
for (const root of report.roots) expect(root.changes).toEqual(classifyPersistenceChange(
before.roots.find(item => item.key === root.root)!, after.roots.find(item => item.key === root.root)!,
))
expect(JSON.stringify([before, after])).toBe(saved)
expect(reviewPersistenceSchemas(before, {
...fixture({ extraKind: true, reorder: true }), roots: [...after.roots].reverse(), types: [...after.types].reverse(),
})).toEqual(report)
})
it('shows existing kind changes alongside additions without weakening their compatibility result', () => {
const report = reviewPersistenceSchemas(fixture(), fixture({ extraKind: true, changed: true }))
expect(report.evidence.map(item => item.kind).sort()).toEqual(['kind-added', 'type-changed'])
expect(report.evidence.find(item => item.kind === 'type-changed')?.locations[0]?.path).toBe('event:agent/inbox/spliced.data.inserted[].source[kind="changing"].value')
expect(report.roots.every(root => root.changes.some(change => change.requiresVersionBump))).toBe(true)
})
it('keeps ambiguous kind matches as additions and removals even when another arm is unchanged', () => {
const report = reviewPersistenceSchemas(fixture({ ambiguous: true }), fixture({ ambiguous: true, changed: true }))
expect(report.evidence.map(item => item.kind).sort()).toEqual(['variant-added', 'variant-removed'])
expect(report.evidence.every(item => item.locations.every(location => !location.path.endsWith('.value')))).toBe(true)
})
it('retains numeric tuple positions and stops recursion without losing sibling evidence', () => {
const report = reviewPersistenceSchemas(
fixture({ tuple: true, recursive: true }), fixture({ changed: true, tuple: true, recursive: true }),
)
const paths = report.evidence.flatMap(item => item.locations.map(location => location.path))
expect(paths).toContain('event:user/message.data.tuple[0]')
expect(paths).toContain('event:user/message.data.tuple[1]')
expect(paths.every(path => path.length < 200)).toBe(true)
})
it('separates new roots from structural definitions and preserves classifier diagnostics', () => {
const before = fixture()
const after = { ...before, roots: before.roots.slice(1) }
const report = reviewPersistenceSchemas(before, after)
expect(report.roots).toHaveLength(1)
expect(report.roots[0]?.changes).toEqual(classifyPersistenceChange(before.roots[0]!, null))
expect(report.evidence).toEqual([])
expect(renderPersistenceReview(report)).toContain('Root removed: `event:agent/inbox/spliced`')
})
it('reads explicit files without writes and rejects invalid inventories and arguments', () => {
const directory = mkdtempSync(join(tmpdir(), 'persistence-review-'))
try {
const before = join(directory, 'before.json')
const after = join(directory, 'after.json')
const oldText = JSON.stringify(fixture())
const newText = JSON.stringify(fixture({ extraKind: true }))
writeFileSync(before, oldText)
writeFileSync(after, newText)
const args = ['--before', before, '--after', after]
expect(JSON.parse(runPersistenceReview([...args, '--json']))).toEqual(reviewPersistenceSchemas(fixture(), fixture({ extraKind: true })))
expect(runPersistenceReview(args)).toContain('# Persistence schema review')
expect(readFileSync(before, 'utf8')).toBe(oldText)
expect(readFileSync(after, 'utf8')).toBe(newText)
expect(() => runPersistenceReview(['--before', before])).toThrow(/--after/u)
expect(() => runPersistenceReview([...args, '--write'])).toThrow()
writeFileSync(after, '{"formatVersion":99}')
expect(() => runPersistenceReview(args)).toThrow()
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
})
+230
View File
@@ -0,0 +1,230 @@
/** Read-only structural explanations alongside the authoritative persistence classification. */
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import { classifyPersistenceChange, parsePersistenceSnapshot, type PersistenceTypeChange } from './persistence-changes.ts'
import { canonicalizeSchema, schemaDigest, type CanonicalSchema, type PersistenceSchemaInventory, type SchemaNode } from './persistence-schema-model.ts'
import { sourceKindGroups } from './persistence-source-policy.ts'
/** One structural observation, shared across its containing roots. */
export interface PersistenceReviewEvidence {
readonly kind: string
readonly field?: string
readonly before: string | null
readonly after: string | null
readonly locations: readonly { readonly root: string; readonly path: string }[]
}
/** Human explanations cannot override the unchanged classifier results carried per root. */
export interface PersistenceReview {
readonly schemaVersion: 1
readonly roots: readonly {
readonly root: string
readonly before: string | null
readonly after: string | null
readonly changes: readonly PersistenceTypeChange[]
}[]
readonly evidence: readonly PersistenceReviewEvidence[]
readonly unchangedTypes: number
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0
}
function selector(schema: CanonicalSchema, index: number): string | undefined {
const node = schema.nodes[index]
if (node?.kind !== 'object') return undefined
const parts = ['kind', 'type', 'role', 'form'].flatMap((name) => {
const property = node.properties.find(property => property.name === name && !property.optional)
const value = property === undefined ? undefined : schema.nodes[property.type]
return value?.kind === 'literal' && typeof value.value === 'string' ? [`[${name}=${JSON.stringify(value.value)}]`] : []
})
return parts.length === 0 ? undefined : parts.join('')
}
function propertyPath(path: string, name: string): string {
return /^[A-Za-z_$][\w$]*$/u.test(name) ? `${path}.${name}` : `${path}[${JSON.stringify(name)}]`
}
/**
* Compare explicit inventories without editing records or changing compatibility decisions.
* @param before - parsed inventory from the chosen base.
* @param after - parsed inventory from the chosen head.
* @returns deduplicated structural evidence and exact classifier output for every changed root.
*/
export function reviewPersistenceSchemas(before: PersistenceSchemaInventory, after: PersistenceSchemaInventory): PersistenceReview {
const oldRoots = new Map(before.roots.map(root => [root.key, root]))
const newRoots = new Map(after.roots.map(root => [root.key, root]))
const evidence = new Map<string, Omit<PersistenceReviewEvidence, 'locations'> & { locations: Map<string, { root: string; path: string }> }>()
const roots = [...new Set([...oldRoots.keys(), ...newRoots.keys()])].sort().flatMap((root) => {
const oldRoot = oldRoots.get(root) ?? null
const newRoot = newRoots.get(root) ?? null
const changes = classifyPersistenceChange(oldRoot, newRoot)
if (changes.length === 0) return []
if (oldRoot !== null && newRoot !== null) explain(oldRoot.schema, newRoot.schema, root)
return [{ root, before: oldRoot?.digest ?? null, after: newRoot?.digest ?? null, changes }]
})
function explain(oldSchema: CanonicalSchema, newSchema: CanonicalSchema, root: string): void {
const oldHashes = new Map<number, string>()
const newHashes = new Map<number, string>()
const digest = (side: 'before' | 'after', index: number): string => {
const cache = side === 'before' ? oldHashes : newHashes
let hash = cache.get(index)
if (hash === undefined) {
hash = schemaDigest(canonicalizeSchema((side === 'before' ? oldSchema : newSchema).nodes, index))
cache.set(index, hash)
}
return hash
}
const emit = (kind: string, left: number | null, right: number | null, path: string, field?: string): void => {
const change = { kind, ...(field === undefined ? {} : { field }), before: left === null ? null : digest('before', left), after: right === null ? null : digest('after', right) }
const key = JSON.stringify(change)
const item = evidence.get(key) ?? { ...change, locations: new Map() }
item.locations.set(JSON.stringify([root, path]), { root, path })
evidence.set(key, item)
}
const walk = (left: number, right: number, path: string, ancestors: ReadonlySet<string>): void => {
if (digest('before', left) === digest('after', right)) return
const pair = `${left}:${right}`
if (ancestors.has(pair)) return
const active = new Set(ancestors).add(pair)
const oldNode = oldSchema.nodes[left] as SchemaNode
const newNode = newSchema.nodes[right] as SchemaNode
if (oldNode.kind === 'object' && newNode.kind === 'object') {
const oldProperties = new Map(oldNode.properties.map(property => [property.name, property]))
const newProperties = new Map(newNode.properties.map(property => [property.name, property]))
for (const name of [...new Set([...oldProperties.keys(), ...newProperties.keys()])].sort()) {
const previous = oldProperties.get(name)
const next = newProperties.get(name)
const child = propertyPath(path, name)
if (previous === undefined || next === undefined) {
emit(previous === undefined ? 'property-added' : 'property-removed', left, right, child, name)
continue
}
if (previous.optional !== next.optional) emit(next.optional ? 'property-made-optional' : 'property-made-required', left, right, child, name)
if (JSON.stringify(previous.compatibility) !== JSON.stringify(next.compatibility)) emit('source-policy-changed', left, right, child, name)
walk(previous.type, next.type, child, active)
}
const oldIndices = new Map(oldNode.indices.map(index => [digest('before', index.key), index]))
const newIndices = new Map(newNode.indices.map(index => [digest('after', index.key), index]))
if (oldIndices.size !== newIndices.size || [...oldIndices.keys()].some(key => !newIndices.has(key))) emit('index-signature-changed', left, right, path)
for (const [key, index] of oldIndices) {
const next = newIndices.get(key)
if (next !== undefined) walk(index.value, next.value, `${path}[*]`, active)
}
} else if (oldNode.kind === 'array' && newNode.kind === 'array') {
walk(oldNode.element, newNode.element, `${path}[]`, active)
} else if (oldNode.kind === 'tuple' && newNode.kind === 'tuple') {
if (oldNode.elements.length !== newNode.elements.length) emit('tuple-length-changed', left, right, path)
for (const [position, previous] of oldNode.elements.entries()) {
const next = newNode.elements[position]
if (next === undefined) continue
const child = `${path}[${String(position)}]`
if (previous.optional !== next.optional || previous.rest !== next.rest) emit('tuple-element-cardinality-changed', left, right, child)
walk(previous.type, next.type, child, active)
}
} else if (oldNode.kind === 'union' && newNode.kind === 'union') {
const oldByHash = new Map(oldNode.types.map(index => [digest('before', index), index]))
const newByHash = new Map(newNode.types.map(index => [digest('after', index), index]))
const removed = [...oldByHash].filter(([hash]) => !newByHash.has(hash)).map(([, index]) => index)
const added = [...newByHash].filter(([hash]) => !oldByHash.has(hash)).map(([, index]) => index)
// Uniqueness includes unchanged arms: a shared discriminator cannot identify a changed sibling.
const unique = (schema: CanonicalSchema, indices: readonly number[]): Map<string, number> => {
const candidates = new Map<string, number[]>()
for (const index of indices) {
const key = selector(schema, index)
if (key !== undefined) candidates.set(key, [...candidates.get(key) ?? [], index])
}
return new Map([...candidates].flatMap(([key, values]) => values.length === 1 ? [[key, values[0] as number]] : []))
}
const oldSelectors = unique(oldSchema, oldNode.types)
const newSelectors = unique(newSchema, newNode.types)
const matchedOld = new Set<number>()
const matchedNew = new Set<number>()
for (const [key, previous] of oldSelectors) {
const next = newSelectors.get(key)
if (next === undefined || !removed.includes(previous) || !added.includes(next)) continue
walk(previous, next, path + key, active)
matchedOld.add(previous)
matchedNew.add(next)
}
const oldKinds = sourceKindGroups(oldSchema.nodes, left)
const newKinds = sourceKindGroups(newSchema.nodes, right)
for (const [side, indices, matched, schema] of [
['before', removed, matchedOld, oldSchema], ['after', added, matchedNew, newSchema],
] as const) {
for (const index of indices) {
if (matched.has(index)) continue
const ownKinds = side === 'before' ? oldKinds : newKinds
const otherKinds = side === 'before' ? newKinds : oldKinds
const kind = [...ownKinds ?? []].find(([, members]) => members.includes(index))?.[0]
const wholeKind = kind !== undefined && otherKinds !== undefined && !otherKinds.has(kind)
const suffix = selector(schema, index) ?? ''
emit(`${wholeKind ? 'kind' : 'variant'}-${side === 'before' ? 'removed' : 'added'}`,
side === 'before' ? index : null, side === 'after' ? index : null, path + suffix)
}
}
} else emit('type-changed', left, right, path)
}
walk(0, 0, root, new Set())
}
const oldTypes = new Set(before.types.map(type => type.digest))
return { schemaVersion: 1, roots, unchangedTypes: after.types.filter(type => oldTypes.has(type.digest)).length,
evidence: [...evidence].sort(([a], [b]) => compareText(a, b)).map(([, item]) => ({ ...item,
locations: [...item.locations.values()].sort((a, b) => compareText(a.root, b.root) || compareText(a.path, b.path)),
})) }
}
function code(value: string): string {
return '`' + value.replaceAll('`', '\\`').replaceAll('|', '\\|') + '`'
}
/**
* Render concise evidence separately from every authoritative root diagnostic.
* @param report - result of the explicit inventory comparison.
* @returns Markdown with full fingerprints in expandable evidence.
*/
export function renderPersistenceReview(report: PersistenceReview): string {
const lines = ['# Persistence schema review', '', `${String(report.roots.length)} changed roots; ${String(report.unchangedTypes)} unchanged type fingerprints.`, '',
'## Structural evidence', '', 'Display paths use literal discriminators. Ambiguous alternatives remain separate additions and removals; this explanation does not decide compatibility.', '']
for (const root of report.roots) {
if (root.before === null || root.after === null) lines.push(`- Root ${root.before === null ? 'added' : 'removed'}: ${code(root.root)}.`)
}
for (const item of report.evidence) {
lines.push('', `<details><summary>${item.kind}${item.field === undefined ? '' : `: ${item.field.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')}`}</summary>`, '',
`Before: ${item.before === null ? 'absent' : code(item.before)}; after: ${item.after === null ? 'absent' : code(item.after)}.`, '',
...item.locations.map(location => `- ${code(location.path)}`), '', '</details>')
}
lines.push('', '## Authoritative compatibility results', '')
for (const root of report.roots) {
lines.push(`### ${code(root.root)}`, '', `Decision: **${root.changes.some(change => change.requiresVersionBump) ? 'version-bump' : 'same-version'}**.`, '',
...root.changes.map(change => `- ${code(change.path)}: ${change.description} (${code(change.kind)}; requiresVersionBump: ${String(change.requiresVersionBump)}).`), '')
}
return lines.join('\n').trimEnd() + '\n'
}
/**
* Read two explicit inventory files and return Markdown or JSON without mutating either.
* @param args - --before and --after paths, and optional --json.
* @returns structural evidence and exact compatibility results.
*/
export function runPersistenceReview(args: readonly string[]): string {
const { values } = parseArgs({ args: [...args], options: { before: { type: 'string' }, after: { type: 'string' }, json: { type: 'boolean' } }, strict: true, allowPositionals: false })
if (values.before === undefined || values.after === undefined) throw new Error('persistence review: --before and --after inventory paths are required')
const read = (path: string): PersistenceSchemaInventory => parsePersistenceSnapshot(JSON.parse(readFileSync(path, 'utf8')))
const report = reviewPersistenceSchemas(read(values.before), read(values.after))
return values.json ? JSON.stringify(report, null, 2) + '\n' : renderPersistenceReview(report)
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
try {
process.stdout.write(runPersistenceReview(process.argv.slice(2)))
} catch (error: unknown) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type SchemaNode } from './persistence-schema-model.ts'
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type SchemaNode, type SourceCompatibility } from './persistence-schema-model.ts'
const string: SchemaNode = { kind: 'primitive', type: 'string' }
const number: SchemaNode = { kind: 'primitive', type: 'number' }
@@ -87,3 +87,23 @@ describe('canonical persisted type graphs', () => {
expect(() => canonicalizeSchema([{ kind: 'union', types: [0] }], 0)).toThrow('union cycle')
})
})
describe('policy-aware graph normalization', () => {
it('normalizes recorded policy fields and kind order without changing unrelated graph digests', () => {
const policy: SourceCompatibility = { version: 1, policy: 'session-source-attribution', binding: 'session.user-message.source',
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['b', 'a'] }
const object = (compatibility: SourceCompatibility): SchemaNode => ({ kind: 'object', indices: [], properties: [
{ name: 'source', type: 1, optional: false, compatibility },
] })
const reordered = { attributionKinds: ['a', 'b', 'a'], unknownKinds: policy.unknownKinds, discriminator: policy.discriminator,
binding: policy.binding, policy: policy.policy, version: policy.version }
const left = canonicalizeSchema([object(policy), string], 0)
const right = canonicalizeSchema([object(reordered), string], 0)
expect(left).toEqual(right)
expect(schemaDigest(left)).toBe(schemaDigest(right))
const unannotated = canonicalizeSchema([{ kind: 'object', indices: [], properties: [{ name: 'source', type: 1, optional: false }] }, string], 0)
expect(schemaDigest(left)).not.toBe(schemaDigest(unannotated))
expect(schemaDigest(canonicalizeSchema(left.nodes, 1))).toBe(schemaDigest(canonicalizeSchema([string], 0)))
})
})
+30 -3
View File
@@ -5,11 +5,22 @@
import { createHash } from 'node:crypto'
/** Recorded reader promise for explicitly attributed source additions. */
export interface SourceCompatibility {
readonly version: 1
readonly policy: 'session-source-attribution'
readonly binding: 'session.user-message.source' | 'session.developer-message.source'
readonly discriminator: 'kind'
readonly unknownKinds: 'preserve'
readonly attributionKinds: readonly string[]
}
/** One JSON property, with absence represented independently of its value type. */
export interface SchemaProperty {
readonly name: string
readonly type: number
readonly optional: boolean
readonly compatibility?: SourceCompatibility
}
/** One positional tuple element. */
@@ -55,7 +66,7 @@ export interface PersistenceType {
/** Complete current-source persistence inventory; version pins normalization too. */
export interface PersistenceSchemaInventory {
readonly formatVersion: 1
readonly formatVersion: 1 | 2
readonly roots: readonly PersistenceRoot[]
readonly types: readonly PersistenceType[]
}
@@ -85,7 +96,13 @@ function mapNode(node: SchemaNode, ref: (id: number) => number): SchemaNode {
case 'object': return {
kind: 'object',
properties: [...node.properties].sort((left, right) => compare(left.name, right.name))
.map(property => ({ name: property.name, type: ref(property.type), optional: property.optional })),
.map(property => ({ name: property.name, type: ref(property.type), optional: property.optional,
...(property.compatibility === undefined ? {} : { compatibility: {
version: property.compatibility.version, policy: property.compatibility.policy, binding: property.compatibility.binding,
discriminator: property.compatibility.discriminator, unknownKinds: property.compatibility.unknownKinds,
attributionKinds: [...new Set(property.compatibility.attributionKinds)].sort(compare),
} }),
})),
indices: node.indices.map(index => ({ key: ref(index.key), value: ref(index.value) }))
.sort((left, right) => left.key - right.key || left.value - right.value),
}
@@ -215,7 +232,17 @@ function normalizeUnions(input: readonly SchemaNode[]): SchemaNode[] {
* @returns lowercase hexadecimal digest.
*/
export function schemaDigest(schema: CanonicalSchema): string {
return createHash('sha256').update('dsh-persistence-schema-v1\n').update(JSON.stringify(schema)).digest('hex')
const version = schemaHasCompatibility(schema) ? 2 : 1
return createHash('sha256').update(`dsh-persistence-schema-v${String(version)}\n`).update(JSON.stringify(schema)).digest('hex')
}
/**
* Identify graphs that require the policy-aware fingerprint domain.
* @param schema - resolved persisted type.
* @returns whether any reachable property records compatibility metadata.
*/
export function schemaHasCompatibility(schema: CanonicalSchema): boolean {
return schema.nodes.some(node => node.kind === 'object' && node.properties.some(property => property.compatibility !== undefined))
}
/**
+225
View File
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectLogEvents } from './gen-persistence-catalog.ts'
import { extractPersistenceSchema } from './persistence-schema.ts'
import { classifyPersistenceChange, parsePersistenceSnapshot } from './persistence-changes.ts'
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
const roots: string[] = []
@@ -55,6 +56,16 @@ function event(inventory: PersistenceSchemaInventory, name = 'test/record'): str
}
describe('persistent source type extraction', () => {
it('keeps authored names and anonymous declaration locations without synthesized traversal names', () => {
const root = fixture('interface Section { text: string }\nexport interface Payload { inserted: { source: { kind: "hooks-codex"; sections: Section[] } }[] }')
const inventory = extractPersistenceSchema(root)
expect(inventory.types.flatMap(type => type.names).every(name => name.includes('#'))).toBe(true)
expect(inventory.types.some(type => type.names.includes('packages/domain/payload/src/types.ts#Section'))).toBe(true)
expect(inventory.types.some(type => type.names.length === 0 && type.sources.includes('packages/domain/payload/src/types.ts:2'))).toBe(true)
expect(inventory.types.some(type => type.schema.nodes[0]?.kind === 'array' && type.names.length === 0)).toBe(true)
expect(parsePersistenceSnapshot(inventory)).toEqual(inventory)
})
it('ignores alias names, files, documentation, readonly, brands and property order', () => {
const left = fixture('declare const brand: unique symbol; type Id = string & {readonly [brand]: "Id"}; export interface Payload { readonly id: Id; value?: number }')
const right = fixture('type Renamed = string; /** Different documentation. */ export type Payload = {value?: number; id: Renamed}')
@@ -322,3 +333,217 @@ interface SessionEventMap {
}
})
})
describe('explicitly reserved JSON properties', () => {
function extractedEvent(source: string) {
const model = extractPersistenceSchema(fixture(source))
const root = model.roots.find(root => root.event === 'test/record')
if (root === undefined) throw new Error('missing reserved-property fixture event')
return root
}
it('classifies a value added to a reserved field as a changed type', () => {
const before = extractedEvent('export interface Payload { id: string;\n/** @persistenceReserved */\ntool?: never }')
const after = extractedEvent('export interface Payload { id: string; tool?: string }')
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({
path: 'event:test/record.data.tool', kind: 'type-changed', requiresVersionBump: true,
})])
})
it.each(['never', 'undefined', 'never | undefined'])('retains a marked optional %s property as never', (value) => {
const root = extractedEvent(`interface Reserved {\n/** @persistenceReserved */\ntool?: ${value}\n}\nexport interface Payload extends Reserved { id: string }`)
const reserved = root.schema.nodes.flatMap(node => node.kind === 'object'
? node.properties.filter(property => property.name === 'tool').map(property => ({ optional: property.optional, value: root.schema.nodes[property.type] }))
: [])
expect(reserved).toEqual([{ optional: true, value: { kind: 'primitive', type: 'never' } }])
})
it('keeps unmarked optional never and undefined fields erased', () => {
const before = extractedEvent('export interface Payload { id: string; tool?: never; missing?: undefined }')
const after = extractedEvent('export interface Payload { id: string }')
expect(before).toEqual(after)
})
it.each([
'Required<Reserved>',
'{ [Key in keyof Reserved]?: string }',
])('rejects a reserved property rewritten by %s', (mapped) => {
expect(() => extractedEvent(`interface Reserved {\n/** @persistenceReserved */\ntool?: never\n}\nexport type Payload = ${mapped} & {id: string}`))
.toThrow('@persistenceReserved must remain an optional never or undefined property')
})
it.each([
['required field', 'export interface Payload { id: string;\n/** @persistenceReserved */\ntool: never }'],
['string field', 'export interface Payload { id: string;\n/** @persistenceReserved */\ntool?: string }'],
['nullable field', 'export interface Payload { id: string;\n/** @persistenceReserved */\ntool?: null }'],
['unknown field', 'export interface Payload { id: string;\n/** @persistenceReserved */\ntool?: unknown }'],
['tag arguments', 'export interface Payload { id: string;\n/** @persistenceReserved value */\ntool?: never }'],
['duplicate tag', 'export interface Payload { id: string;\n/** @persistenceReserved\n * @persistenceReserved */\ntool?: never }'],
['non-property tag', '/** @persistenceReserved */\nexport interface Payload { id: string }'],
['unreachable misuse', 'interface Unused {\n/** @persistenceReserved */\ntool?: string }\nexport interface Payload { id: string }'],
])('rejects %s', (_name, source) => {
expect(() => extractedEvent(source)).toThrow('@persistenceReserved requires one argument-free marker on an optional never or undefined property')
})
})
function sourceFixture(producers: string, role: 'user' | 'developer' = 'user'): string {
const root = fixture(`export type Payload = import('../../../llm/llm/src/message.js').${role === 'user' ? 'UserMessage' : 'DeveloperMessage'}`)
put(root, 'packages/llm/llm/src/message.ts', `
export interface MessageSourceMap {
semantic: {kind: 'semantic'; value: string}
${producers}
}
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
export interface MessageBase {
/** @persistenceSource user developer */
source: MessageSource
}
export interface UserMessage extends MessageBase {
role: 'user'
}
export interface DeveloperMessage extends MessageBase {
role: 'developer'
}
`)
return root
}
const ATTRIBUTION = '/** @persistenceAttribution */'
describe('source compatibility authoring', () => {
it.each(['user', 'developer'] as const)('records the %s binding and qualifies complete wire-kind groups', (role) => {
const before = extractPersistenceSchema(sourceFixture('', role))
const after = extractPersistenceSchema(sourceFixture(`${ATTRIBUTION} unrelatedMapKey: {kind: 'attribution'} & ({form?: never} | {form: 'notice'; summary: string})`, role))
expect(after.formatVersion).toBe(2)
const root = after.roots.find(root => root.kind === 'event')!
const property = root.schema.nodes.flatMap(node => node.kind === 'object' ? node.properties : []).find(property => property.compatibility !== undefined)!
expect(property.compatibility).toEqual({ version: 1, policy: 'session-source-attribution', binding: `session.${role}-message.source`,
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['attribution'] })
expect(classifyPersistenceChange(before.roots.find(root => root.kind === 'event')!, root))
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
expect(parsePersistenceSnapshot({ ...after, types: [] })).toEqual({ ...after, types: [] })
})
it('binds inherited source declarations only on explicitly eligible required roles', () => {
const root = sourceFixture("system: {kind: 'system-prompt'}; model: {kind: 'model'}; tool: {kind: 'tool'}")
const core = 'packages/llm/llm/src/message.ts'
put(root, core, readFileSync(join(root, core), 'utf8') + `
export interface SystemMessage extends MessageBase {role: 'system'; source: MessageSourceMap['system']}
export interface AssistantMessage extends MessageBase {role: 'assistant'; source: MessageSourceMap['model']}
export interface ToolMessage extends MessageBase {role: 'tool'; source: MessageSourceMap['tool']}
export interface UnlistedRole extends MessageBase {role: 'other'}
export interface OptionalRole extends MessageBase {role?: 'user'}
export interface BroadRole extends MessageBase {role: 'user' | 'developer'}
export interface OverriddenUser extends MessageBase {role: 'user'; source: MessageSource}
`)
put(root, 'packages/domain/payload/src/types.ts', `
import type * as M from '../../../llm/llm/src/message.js'
export interface Payload {
user: M.UserMessage
developer: M.DeveloperMessage
system: M.SystemMessage
assistant: M.AssistantMessage
tool: M.ToolMessage
bare: M.MessageBase
unlisted: M.UnlistedRole
optional: M.OptionalRole
broad: M.BroadRole
override: M.OverriddenUser
unrelated: {role: 'user'; source: M.MessageSource}
}
`)
const schema = extractPersistenceSchema(root).roots.find(root => root.kind === 'event')!.schema
const payload = schema.nodes.find(node => node.kind === 'object' && node.properties.some(property => property.name === 'unrelated'))!
if (payload.kind !== 'object') throw new Error('missing payload fixture')
const bindings = Object.fromEntries(payload.properties.map((property) => {
const message = schema.nodes[property.type]
if (message?.kind !== 'object') throw new Error('missing message fixture')
return [property.name, message.properties.find(property => property.name === 'source')?.compatibility?.binding]
}))
expect(bindings).toEqual({
user: 'session.user-message.source', developer: 'session.developer-message.source',
system: undefined, assistant: undefined, tool: undefined, bare: undefined, unlisted: undefined,
optional: undefined, broad: undefined, override: undefined, unrelated: undefined,
})
})
it('keeps roles outside the annotation list strict', () => {
const root = sourceFixture('', 'developer')
const core = 'packages/llm/llm/src/message.ts'
put(root, core, readFileSync(join(root, core), 'utf8').replace('@persistenceSource user developer', '@persistenceSource user'))
const schema = extractPersistenceSchema(root)
expect(schema.formatVersion).toBe(1)
expect(schema.roots.flatMap(root => root.schema.nodes).flatMap(node => node.kind === 'object' ? node.properties : [])
.every(property => property.compatibility === undefined)).toBe(true)
})
it.each(['', 'user user', 'assistant', 'user developer tool'])('rejects invalid eligible role list %s', (roles) => {
const root = sourceFixture('')
const core = 'packages/llm/llm/src/message.ts'
put(root, core, readFileSync(join(root, core), 'utf8').replace('@persistenceSource user developer', `@persistenceSource ${roles}`))
expect(() => extractPersistenceSchema(root)).toThrow('invalid @persistenceSource binding')
})
it('keeps qualification independent of map-key spelling, source order and union-arm order', () => {
const left = sourceFixture(`${ATTRIBUTION} left: {kind: 'attribution'} & ({form?: never} | {form: 'notice'; summary: string})`)
const right = sourceFixture(`${ATTRIBUTION} right: ({summary: string; form: 'notice'} | {form?: never}) & {kind: 'attribution'}`)
expect(extractPersistenceSchema(left).roots).toEqual(extractPersistenceSchema(right).roots)
})
it('finds producer-owned qualifications in independently compiled module augmentations', () => {
const root = sourceFixture('')
const before = extractPersistenceSchema(root)
put(root, 'packages/llm/llm/src/producer.ts', `import './message.js'
declare module './message.js' {
interface MessageSourceMap {
${ATTRIBUTION}
catalogKey: {kind: 'external-producer'}
}
}`)
const after = extractPersistenceSchema(root)
expect(classifyPersistenceChange(before.roots.find(root => root.kind === 'event')!, after.roots.find(root => root.kind === 'event')!))
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresVersionBump: false })])
})
it('does not qualify unmarked additions or structurally equal unbound fields', () => {
const before = sourceFixture('')
const after = sourceFixture("unmarked: {kind: 'new'}")
expect(classifyPersistenceChange(extractPersistenceSchema(before).roots.find(root => root.kind === 'event')!,
extractPersistenceSchema(after).roots.find(root => root.kind === 'event')!).some(change => change.requiresVersionBump)).toBe(true)
const left = fixture("export interface Payload { source: {kind: 'old'} }")
const right = fixture("export interface Payload { source: {kind: 'old'} | {kind: 'new'} }")
expect(classifyPersistenceChange(extractPersistenceSchema(left).roots.find(root => root.kind === 'event')!,
extractPersistenceSchema(right).roots.find(root => root.kind === 'event')!).some(change => change.requiresVersionBump)).toBe(true)
})
it.each([
[`${ATTRIBUTION} broad: {kind: string}`, 'one literal wire kind'],
[`${ATTRIBUTION} optional: {kind?: 'new'}`, 'one literal wire kind'],
[`${ATTRIBUTION} ambiguous: {kind: 'a'} | {kind: 'b'}`, 'one literal wire kind'],
[`${ATTRIBUTION} duplicate: {kind: 'semantic'}`, 'conflicting attribution'],
[`${ATTRIBUTION} first: {kind: 'same'};\n${ATTRIBUTION} second: {kind: 'same'; value: number}`, 'conflicting attribution'],
['broadUnmarked: {kind: string}', 'invalid source compatibility'],
...['model', 'tool', 'system-prompt', 'compact-checkpoint', 'dsh-session-title-llm', 'tool-registry', 'runtime-context', 'plugin']
.map(kind => [`${ATTRIBUTION} forbidden: {kind: '${kind}'}`, 'invalid source compatibility']),
["/** @persistenceAttribution yes */ invalid: {kind: 'new'}", 'invalid @persistenceAttribution'],
['/** @persistenceAttribution\n * @persistenceAttribution */ duplicateTag: {kind: \'new\'}', 'invalid @persistenceAttribution'],
])('rejects invalid producer declaration %s', (producer, error) => {
expect(() => extractPersistenceSchema(sourceFixture(producer))).toThrow(error)
})
it.each(['auto-review', 'compact-basic', 'ptc-mode'])('rejects reserved producer kind %s as new attribution', (kind) => {
for (const role of ['user', 'developer'] as const) {
const root = sourceFixture(`${ATTRIBUTION} reintroduced: {kind: '${kind}'}`, role)
expect(() => extractPersistenceSchema(root)).toThrow('invalid source compatibility')
}
})
it('rejects attribution and binding annotations outside their declared owners', () => {
const misplaced = sourceFixture('')
put(misplaced, 'packages/domain/payload/src/types.ts', `${ATTRIBUTION} export interface Payload {kind: 'new'}`)
expect(() => extractPersistenceSchema(misplaced)).toThrow('must annotate a MessageSourceMap producer property')
const source = sourceFixture('')
put(source, 'packages/domain/payload/src/types.ts', "export interface Payload {role: 'user';\n/** @persistenceSource user */\nsource: {kind: 'new'}}")
expect(() => extractPersistenceSchema(source)).toThrow('invalid @persistenceSource binding')
})
})
+70 -20
View File
@@ -4,15 +4,19 @@ import { readFileSync } from 'node:fs'
import { dirname, extname, relative, resolve, sep } from 'node:path'
import ts from 'typescript'
import { collectLogEvents } from './persistence-catalog-source.ts'
import { sourceCompatibilityAnnotations } from './persistence-source-annotations.ts'
import { validSourceCompatibility } from './persistence-source-policy.ts'
import {
canonicalizeSchema,
schemaChildren,
schemaDigest,
schemaHasCompatibility,
type PersistenceRoot,
type PersistenceSchemaInventory,
type PersistenceType,
type SchemaNode,
type SchemaProperty,
type SourceCompatibility,
} from './persistence-schema-model.ts'
interface DeclarationMetadata {
@@ -201,12 +205,41 @@ function validateReachableDeclarations(
return definitions
}
function isNeverOrUndefined(type: ts.Type): boolean {
return (type.isUnion() ? type.types : [type])
.every(member => (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0)
}
function reservedPropertyAnnotations(root: string, program: ts.Program): ReadonlySet<ts.Declaration> {
const checker = program.getTypeChecker()
const reserved = new Set<ts.Declaration>()
for (const file of program.getSourceFiles()) {
const path = slash(relative(root, file.fileName))
if (!path.startsWith('packages/') || path.includes('/node_modules/')) continue
const visit = (node: ts.Node): void => {
const tags = ts.getJSDocTags(node).filter(tag => tag.tagName.text === 'persistenceReserved')
if (tags.length > 0) {
if (tags.length !== 1 || tags[0]?.comment !== undefined || !ts.isPropertySignature(node)
|| node.questionToken === undefined || !isNeverOrUndefined(checker.getTypeAtLocation(node))) {
throw new PersistenceSchemaError(`persistence schema: ${path}: @persistenceReserved requires one argument-free marker on an optional never or undefined property`)
}
reserved.add(node)
}
ts.forEachChild(node, visit)
}
visit(file)
}
return reserved
}
class SchemaExtractor {
readonly nodes: SchemaNode[] = []
private readonly cache = new Map<ts.Type, number>()
private readonly declarationMetadata = new Map<number, DeclarationMetadata>()
private readonly checker: ts.TypeChecker
private readonly sourcePolicies: ReadonlyMap<ts.Declaration, readonly SourceCompatibility[]>
private readonly reservedProperties: ReadonlySet<ts.Declaration>
constructor(
private readonly root: string,
@@ -214,6 +247,8 @@ class SchemaExtractor {
private readonly declarationSources: ReadonlyMap<ts.Type, readonly ts.Node[]>,
) {
this.checker = program.getTypeChecker()
this.sourcePolicies = sourceCompatibilityAnnotations(root, program)
this.reservedProperties = reservedPropertyAnnotations(root, program)
}
convert(type: ts.Type, site: ts.Node): number {
@@ -291,10 +326,17 @@ class SchemaExtractor {
const declaration = property.valueDeclaration ?? property.declarations?.[0] ?? site
const propertyType = this.checker.getTypeOfSymbolAtLocation(property, declaration)
const optional = (property.flags & ts.SymbolFlags.Optional) !== 0
if (optional && (propertyType.isUnion() ? propertyType.types : [propertyType])
.every(member => (member.flags & (ts.TypeFlags.Undefined | ts.TypeFlags.Never)) !== 0)) continue
const reserved = property.declarations?.some(declaration => this.reservedProperties.has(declaration)) === true
if (reserved && (!optional || !isNeverOrUndefined(propertyType))) {
this.fail(type, declaration, '@persistenceReserved must remain an optional never or undefined property')
}
if (optional && isNeverOrUndefined(propertyType) && !reserved) continue
const child = this.valueType(propertyType, declaration, optional)
properties.push({ name: property.getName(), type: child, optional })
const policies = this.propertyPolicies(type, property, declaration)
if (policies.length > 1) this.fail(type, declaration, 'ambiguous source compatibility binding')
properties.push({ name: property.getName(), type: child, optional,
...(policies[0] === undefined ? {} : { compatibility: policies[0] }),
})
}
const indices = this.checker.getIndexInfosOfType(type).map(info => ({
key: this.convert(info.keyType, site),
@@ -304,6 +346,19 @@ class SchemaExtractor {
return { kind: 'object', properties, indices }
}
private propertyPolicies(type: ts.Type, property: ts.Symbol, site: ts.Node): readonly SourceCompatibility[] {
const declared = (property.declarations ?? []).flatMap(declaration => this.sourcePolicies.get(declaration) ?? [])
if (declared.length === 0) return []
const roleProperty = this.checker.getPropertyOfType(type, 'role')
if (roleProperty === undefined || (roleProperty.flags & ts.SymbolFlags.Optional) !== 0) return []
const role = this.checker.getTypeOfSymbolAtLocation(roleProperty, site)
if (!(role.flags & ts.TypeFlags.StringLiteral)) return []
const binding = (role as ts.StringLiteralType).value === 'user' ? 'session.user-message.source'
: (role as ts.StringLiteralType).value === 'developer' ? 'session.developer-message.source'
: undefined
return declared.filter(policy => policy.binding === binding)
}
private rejectClass(type: ts.Type, site: ts.Node): void {
const symbol = type.getSymbol()
if (symbol?.declarations?.some(declaration => ts.isClassDeclaration(declaration) || ts.isClassExpression(declaration))) {
@@ -354,6 +409,14 @@ class SchemaExtractor {
}
inventory(inputs: readonly RootInput[]): PersistenceSchemaInventory {
for (const node of this.nodes) {
if (node.kind !== 'object') continue
for (const property of node.properties) {
if (property.compatibility !== undefined && !validSourceCompatibility(this.nodes, node, property)) {
throw new PersistenceSchemaError('persistence schema: invalid source compatibility binding or attribution kinds')
}
}
}
const roots = inputs.map((input) => {
const schema = canonicalizeSchema(this.nodes, input.node)
return {
@@ -366,22 +429,13 @@ class SchemaExtractor {
}
})
const found = new Set<number>()
const paths = new Map<number, Set<string>>()
const visit = (id: number, path: string): void => {
const names = paths.get(id) ?? new Set<string>()
names.add(path)
paths.set(id, names)
const visit = (id: number): void => {
if (found.has(id)) return
found.add(id)
const node = this.nodes[id] as SchemaNode
if (node.kind === 'object') {
for (const property of node.properties) visit(property.type, `${path}.${property.name}`)
for (const index of node.indices) { visit(index.key, `${path}.[key]`); visit(index.value, `${path}.[value]`) }
} else {
for (const [index, child] of schemaChildren(node).entries()) visit(child, `${path}[${String(index)}]`)
}
for (const child of schemaChildren(node)) visit(child)
}
for (const input of inputs) visit(input.node, input.key)
for (const input of inputs) visit(input.node)
const types = new Map<string, { schema: PersistenceType['schema']; names: Set<string>; sources: Set<string> }>()
for (const id of found) {
const schema = canonicalizeSchema(this.nodes, id)
@@ -390,14 +444,10 @@ class SchemaExtractor {
const declarationMetadata = this.declarationMetadata.get(id)
for (const name of declarationMetadata?.names ?? []) item.names.add(name)
for (const source of declarationMetadata?.sources ?? []) item.sources.add(source)
const kind = schema.nodes[0]?.kind
if (kind !== 'primitive' && kind !== 'literal' && (declarationMetadata === undefined || declarationMetadata.names.size === 0)) {
for (const path of paths.get(id) ?? []) item.names.add(path)
}
types.set(digest, item)
}
return {
formatVersion: 1,
formatVersion: roots.some(root => schemaHasCompatibility(root.schema)) ? 2 : 1,
roots,
types: [...types].sort(([left], [right]) => compare(left, right)).map(([digest, item]) => ({
digest,
+81
View File
@@ -0,0 +1,81 @@
/** Resolve producer opt-ins and core-owned source bindings before schema extraction. */
import { relative, resolve, sep } from 'node:path'
import ts from 'typescript'
import type { SourceCompatibility } from './persistence-schema-model.ts'
const CORE_SOURCE = 'packages/llm/llm/src/message.ts'
/**
* Resolve explicit authoring annotations without using type names in recorded history.
* @param root - repository root.
* @param program - the complete source-only persistence compiler program.
* @returns policies keyed by the original source property declaration.
*/
export function sourceCompatibilityAnnotations(
root: string, program: ts.Program,
): ReadonlyMap<ts.Declaration, readonly SourceCompatibility[]> {
const checker = program.getTypeChecker()
const bindings = new Map<ts.Declaration, readonly ('user' | 'developer')[]>()
const qualifications = new Set<ts.Node>()
const core = program.getSourceFile(resolve(root, CORE_SOURCE))
for (const file of program.getSourceFiles()) {
const path = relative(root, file.fileName).split(sep).join('/')
if (!path.startsWith('packages/') || path.includes('/node_modules/')) continue
const visit = (node: ts.Node): void => {
for (const tag of ts.getJSDocTags(node)) {
if (tag.tagName.text === 'persistenceSource') {
const roles = typeof tag.comment === 'string' ? tag.comment.trim().split(/\s+/u) : []
if (!ts.isPropertySignature(node) || file !== core || node.name.getText() !== 'source'
|| !ts.isInterfaceDeclaration(node.parent) || node.parent.name.text !== 'MessageBase'
|| roles.length === 0 || roles.some(role => role !== 'user' && role !== 'developer')
|| new Set(roles).size !== roles.length || bindings.has(node)) {
throw new Error(`persistence schema: invalid @persistenceSource binding in ${path}`)
}
bindings.set(node, roles as ('user' | 'developer')[])
}
if (tag.tagName.text === 'persistenceAttribution') {
if (tag.comment !== undefined || qualifications.has(node)) throw new Error(`persistence schema: invalid @persistenceAttribution in ${path}`)
qualifications.add(node)
}
}
ts.forEachChild(node, visit)
}
visit(file)
}
const sourceMap = core?.statements.find((node): node is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(node) && node.name.text === 'MessageSourceMap')
const byKind = new Map<string, boolean>()
if (sourceMap !== undefined) {
for (const property of checker.getPropertiesOfType(checker.getTypeAtLocation(sourceMap))) {
const declarations = property.declarations ?? []
const marked = declarations.filter(declaration => qualifications.has(declaration))
for (const declaration of marked) qualifications.delete(declaration)
if (marked.length !== 0 && marked.length !== declarations.length) throw new Error(`persistence schema: conflicting attribution for ${property.name}`)
const site = property.valueDeclaration ?? declarations[0] ?? sourceMap
const type = checker.getTypeOfSymbolAtLocation(property, site)
const kinds = new Set<string>()
for (const alternative of type.isUnion() ? type.types : [type]) {
const discriminator = alternative.getProperty('kind')
const kind = discriminator === undefined ? undefined : checker.getTypeOfSymbolAtLocation(discriminator, site)
if (discriminator === undefined || (discriminator.flags & ts.SymbolFlags.Optional) !== 0 || kind === undefined
|| !(kind.flags & ts.TypeFlags.StringLiteral) || (kind as ts.StringLiteralType).value.length === 0) {
if (marked.length > 0) throw new Error(`persistence schema: attribution ${property.name} requires one literal wire kind`)
continue
}
kinds.add((kind as ts.StringLiteralType).value)
}
if (marked.length > 0 && kinds.size !== 1) throw new Error(`persistence schema: attribution ${property.name} requires one literal wire kind`)
for (const kind of kinds) {
const qualified = marked.length > 0
if (byKind.has(kind) && (byKind.get(kind) === true || qualified)) throw new Error(`persistence schema: conflicting attribution for wire kind ${kind}`)
byKind.set(kind, qualified)
}
}
}
if (qualifications.size > 0) throw new Error('persistence schema: @persistenceAttribution must annotate a MessageSourceMap producer property')
const attributionKinds = [...byKind].filter(([, qualified]) => qualified).map(([kind]) => kind).sort()
return new Map([...bindings].map(([declaration, roles]) => [declaration, roles.map(role => ({
version: 1, policy: 'session-source-attribution', binding: role === 'user' ? 'session.user-message.source' : 'session.developer-message.source',
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds,
}))]))
}
+69
View File
@@ -0,0 +1,69 @@
/** Literal source groups and the supported reader promise saved in schema history. */
import type { SchemaNode, SchemaProperty, SourceCompatibility } from './persistence-schema-model.ts'
// Reserve role and shared-consumer identities, frozen rename targets, and request-only producer identities against reuse.
// Existing kinds outside this set also reject retroactive qualification during comparison.
const RESERVED_SOURCE_KINDS = new Set([
'user', 'model', 'tool', 'system-prompt', 'compact-checkpoint', 'dsh-session-title-llm', 'tool-registry', 'runtime-context', 'plugin',
'auto-review', 'compact-basic', 'ptc-mode',
])
/**
* Group source alternatives by their required literal wire discriminator.
* @param nodes - self-contained type graph.
* @param index - source property value.
* @returns kind groups, or undefined when any alternative is not a closed source record.
*/
export function sourceKindGroups(nodes: readonly SchemaNode[], index: number): Map<string, number[]> | undefined {
const source = nodes[index]
const alternatives = source?.kind === 'union' ? source.types : [index]
const groups = new Map<string, number[]>()
for (const alternative of alternatives) {
const node = nodes[alternative]
if (node?.kind !== 'object') return undefined
const property = node.properties.find(property => property.name === 'kind')
const kind = property === undefined ? undefined : nodes[property.type]
if (property?.optional !== false || kind?.kind !== 'literal' || typeof kind.value !== 'string' || kind.value.length === 0) return undefined
const group = groups.get(kind.value) ?? []
group.push(alternative)
groups.set(kind.value, group)
}
return groups
}
/**
* Validate the supported policy and its containing message/source declaration.
* @param nodes - self-contained graph.
* @param owner - object carrying the explicitly bound source property.
* @param property - bound source property.
* @returns whether the metadata authorizes source-group comparison.
*/
export function validSourceCompatibility(
nodes: readonly SchemaNode[], owner: SchemaNode, property: SchemaProperty,
): boolean {
const policy = property.compatibility
if (policy === undefined || property.name !== 'source' || property.optional || owner.kind !== 'object') return false
const roleProperty = owner.properties.find(property => property.name === 'role')
const role = roleProperty === undefined ? undefined : nodes[roleProperty.type]
if (roleProperty?.optional !== false || role?.kind !== 'literal'
|| !(role.value === 'user' && policy.binding === 'session.user-message.source'
|| role.value === 'developer' && policy.binding === 'session.developer-message.source')) return false
const groups = sourceKindGroups(nodes, property.type)
return groups !== undefined && policy.attributionKinds.every(kind => groups.has(kind) && !RESERVED_SOURCE_KINDS.has(kind))
}
/**
* Check that both recorded promises agree and no existing kind changes qualification.
* Both inputs are validated v1 policies with fixed policy, discriminator, and preservation fields.
* @param before - predecessor promise.
* @param after - successor promise.
* @param existingKinds - predecessor wire kinds, including semantic groups.
* @returns whether the additive rule applies to this transition.
*/
export function matchingSourceCompatibility(
before: SourceCompatibility, after: SourceCompatibility, existingKinds: Iterable<string>,
): boolean {
return before.binding === after.binding
&& [...existingKinds].every(kind => before.attributionKinds.includes(kind) === after.attributionKinds.includes(kind))
}
+180
View File
@@ -0,0 +1,180 @@
{
"nodeVersion": "24.21.0",
"pythonVersion": "3.12.14",
"pythonRelease": "20260901",
"targets": {
"win-x64": {
"nodeArchive": "win-x64.zip",
"nodeSha256": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541",
"pythonTarget": "x86_64-pc-windows-msvc",
"pythonSha256": "7c45c9622400d578709a9b2cddbe8124cc21d382409d9f13406d706d28e31b14",
"wheels": [
{
"url": "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl",
"sha256": "86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa"
},
{
"url": "https://files.pythonhosted.org/packages/75/08/67cc404b3a966b6df27b38370ddd96b3b023030b572283d035181854aac5/pandas-3.0.1-cp312-cp312-win_amd64.whl",
"sha256": "536232a5fe26dd989bd633e7a0c450705fdc86a207fec7254a55e9a22950fe43"
},
{
"url": "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl",
"sha256": "a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09"
},
{
"url": "https://files.pythonhosted.org/packages/3a/5b/6ed903e4e6278a020c8a6f0dbbe78030d041840a6b4a64ea441a1e414077/lxml-6.1.3-cp312-cp312-win_amd64.whl",
"sha256": "3e9a00d1c2c30936f7add097c41afc5da6556c580909104aafd382cac92a855c"
}
]
},
"mac-arm64": {
"nodeArchive": "darwin-arm64.tar.gz",
"nodeSha256": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057",
"pythonTarget": "aarch64-apple-darwin",
"pythonSha256": "81a359f1cfadd4da11766534c5913791cea55f26e1bb902cacd2a531bb1e4b2b",
"wheels": [
{
"url": "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl",
"sha256": "ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769"
},
{
"url": "https://files.pythonhosted.org/packages/7c/f1/e2567ffc8951ab371db2e40b2fe068e36b81d8cf3260f06ae508700e5504/pandas-3.0.1-cp312-cp312-macosx_11_0_arm64.whl",
"sha256": "0ab749dfba921edf641d4036c4c21c0b3ea70fea478165cb98a998fb2a261955"
},
{
"url": "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl",
"sha256": "ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7"
},
{
"url": "https://files.pythonhosted.org/packages/dd/1f/a180b57d9eeabaab77f9d5aa30356898ea749c4795596a8f66d1eb6bef2e/lxml-6.1.3-cp312-cp312-macosx_10_13_universal2.whl",
"sha256": "0c0710ac085a157b593c38fbcacd950f15c4afa8e2057527185875ab302752bc"
}
]
},
"mac-x64": {
"nodeArchive": "darwin-x64.tar.gz",
"nodeSha256": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097",
"pythonTarget": "x86_64-apple-darwin",
"pythonSha256": "65b195c9cedc1fef6767f044f9822069adbd1bd9204d424ece4628776fdc04bb",
"wheels": [
{
"url": "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl",
"sha256": "74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e"
},
{
"url": "https://files.pythonhosted.org/packages/37/51/b467209c08dae2c624873d7491ea47d2b47336e5403309d433ea79c38571/pandas-3.0.1-cp312-cp312-macosx_10_13_x86_64.whl",
"sha256": "476f84f8c20c9f5bc47252b66b4bb25e1a9fc2fa98cead96744d8116cb85771d"
},
{
"url": "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl",
"sha256": "ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965"
},
{
"url": "https://files.pythonhosted.org/packages/a8/25/070c92013a1c029a602b03560d68772313d918268667fa993da7961759c9/lxml-6.1.3-cp312-cp312-macosx_10_13_x86_64.whl",
"sha256": "623c8799c17128753c65699f1c3aa32402657393a9ad6db09ed8b98ddf76611d"
}
]
},
"linux-x64": {
"nodeArchive": "linux-x64.tar.gz",
"nodeSha256": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff",
"pythonTarget": "x86_64-unknown-linux-gnu",
"pythonSha256": "72748da13197c1fb161e3afeef20a6a385ff24f2165e6e2758e47008e7faba4c",
"wheels": [
{
"url": "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl",
"sha256": "0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28"
},
{
"url": "https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl",
"sha256": "532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4"
},
{
"url": "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl",
"sha256": "78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91"
},
{
"url": "https://files.pythonhosted.org/packages/db/36/aa413bc214dc4f785ad2b2ddd8cc99aae7062d49ab155e91e6011af00daf/lxml-6.1.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl",
"sha256": "379f8a75cf6eb7eef0af074b55f49ab73b868388a98de14646abcdfa4564bb11"
}
]
},
"linux-arm64": {
"nodeArchive": "linux-arm64.tar.gz",
"nodeSha256": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5",
"pythonTarget": "aarch64-unknown-linux-gnu",
"pythonSha256": "577b4bec0793ad1ff0cbff9adbd0df078eddde38a4c41bf5d83ad381a85ee39d",
"wheels": [
{
"url": "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl",
"sha256": "8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d"
},
{
"url": "https://files.pythonhosted.org/packages/d7/39/327802e0b6d693182403c144edacbc27eb82907b57062f23ef5a4c4a5ea7/pandas-3.0.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl",
"sha256": "b8e36891080b87823aff3640c78649b91b8ff6eea3c0d70aeabd72ea43ab069b"
},
{
"url": "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl",
"sha256": "d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9"
},
{
"url": "https://files.pythonhosted.org/packages/1e/1c/722e88883173097a1a375153e3c2447eba3060d0231522cf6596e99f4195/lxml-6.1.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl",
"sha256": "f683dc6300317700025e41d89a43e0276692ded16113a3c43eab704d605c58e5"
}
]
}
},
"wheels": [
{
"url": "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl",
"sha256": "a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
},
{
"url": "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl",
"sha256": "4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"
},
{
"url": "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl",
"sha256": "1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"
},
{
"url": "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl",
"sha256": "3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7"
},
{
"url": "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl",
"sha256": "160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba"
},
{
"url": "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl",
"sha256": "5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"
},
{
"url": "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl",
"sha256": "9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3"
},
{
"url": "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl",
"sha256": "481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"
},
{
"url": "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl",
"sha256": "7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"
}
],
"pythonPackages": {
"numpy": "2.3.5",
"pandas": "3.0.1",
"python-dateutil": "2.9.0.post0",
"six": "1.17.0",
"tzdata": "2025.2",
"python-docx": "1.2.0",
"python-pptx": "1.0.2",
"openpyxl": "3.1.5",
"Pillow": "12.3.0",
"lxml": "6.1.3",
"XlsxWriter": "3.2.9",
"typing_extensions": "4.16.0",
"et_xmlfile": "2.0.0"
}
}
+154
View File
@@ -0,0 +1,154 @@
import { readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, join } from 'node:path'
import { zipSync } from 'fflate'
import { expect, it } from 'vitest'
import { downloadPrimaryRuntimeAsset, prepareOfficeSkillAssets, primaryRuntimePayloadDigest, smokePrimaryRuntime, unpackPrimaryRuntimeWheel } from './prepare.ts'
import lock from './lock.json' with { type: 'json' }
it('covers every SDK wheel target with the shared interpreter lock', () => {
const platforms = JSON.parse(readFileSync(new URL('../../python/sdk-runtime/platforms.json', import.meta.url), 'utf8')) as Record<string, unknown>
expect(Object.keys(lock.targets).sort()).toEqual(Object.keys(platforms).map(target => target.replace('macos-', 'mac-')).sort())
})
const libraryWheel = Buffer.from('UEsDBAoAAAAAAASeLl0sYMPjDAAAAAwAAAAJAAAAc2FtcGxlLnB5c2FtcGxlID0gNDIKUEsBAh4DCgAAAAAABJ4uXSxgw+MMAAAADAAAAAkAAAAAAAAAAQAAAKSBAAAAAHNhbXBsZS5weVBLBQYAAAAAAQABADcAAAAzAAAAAAA=', 'base64')
const relocatedWheel = Buffer.from('UEsDBAoAAAAAAASeLl3x0Nj9FAAAABQAAAAeAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlcmVxdWlyZXMgcmVsb2NhdGlvbgpQSwECHgMKAAAAAAAEni5d8dDY/RQAAAAUAAAAHgAAAAAAAAABAAAApIEAAAAAc2FtcGxlLTEuMC5kYXRhL3NjcmlwdHMvc2FtcGxlUEsFBgAAAAABAAEATAAAAFAAAAAAAA==', 'base64')
const externalLibraryWheel = Buffer.from('UEsDBBQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAc2FtcGxlLTEuMC5kYXRhL3B1cmVsaWIvc2FtcGxlLnB5cmVxdWlyZXMgbGlicmFyeSByZWxvY2F0aW9uClBLAQIUAxQAAAAAAAAAIVyBOE8OHAAAABwAAAAhAAAAAAAAAAAAAACAAQAAAABzYW1wbGUtMS4wLmRhdGEvcHVyZWxpYi9zYW1wbGUucHlQSwUGAAAAAAEAAQBPAAAAWwAAAAAA', 'base64')
it.each(Object.entries(lock.targets))('records every locked wheel distribution and version for %s', (_target, artifact) => {
const normalize = (name: string): string => name.toLowerCase().replace(/[-_.]+/gu, '-')
const distributions = [...artifact.wheels, ...lock.wheels].map(({ url }) => {
const [name, version] = basename(new URL(url).pathname).split('-')
return [normalize(name!), version] as const
})
const declared = Object.entries(lock.pythonPackages).map(([name, version]) => [normalize(name), version] as const)
expect(new Set(distributions.map(([name]) => name)).size).toBe(distributions.length)
expect(new Set(declared.map(([name]) => name)).size).toBe(declared.length)
expect(Object.fromEntries(distributions)).toEqual(Object.fromEntries(declared))
})
it('keeps a target payload identity independent of other target archives', () => {
const changed = structuredClone(lock)
changed.targets['win-x64'].wheels[0]!.sha256 = 'a'.repeat(64)
expect(primaryRuntimePayloadDigest('mac-arm64', changed, '11.7.0')).toBe(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0'))
expect(primaryRuntimePayloadDigest('win-x64', changed, '11.7.0')).not.toBe(primaryRuntimePayloadDigest('win-x64', lock, '11.7.0'))
})
it('invalidates payload identity for shared wheels, package versions and package-manager changes', () => {
const wheel = structuredClone(lock), distribution = structuredClone(lock)
wheel.wheels[0]!.sha256 = 'a'.repeat(64)
distribution.pythonPackages['python-docx'] = '1.2.1'
const original = primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.0')
expect(primaryRuntimePayloadDigest('mac-arm64', wheel, '11.7.0')).not.toBe(original)
expect(primaryRuntimePayloadDigest('mac-arm64', distribution, '11.7.0')).not.toBe(original)
expect(primaryRuntimePayloadDigest('mac-arm64', lock, '11.7.1')).not.toBe(original)
})
it('reports missing distribution metadata before trying to execute a stale native payload', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-stale-runtime-'))
try {
await writeFile(join(root, 'runtime.json'), JSON.stringify({ desktopVersion: '1.0.0', platform: process.platform, arch: process.arch,
components: { python: '3.12.14', numpy: '2.3.5', pandas: '3.0.1' } }))
expect(() => { smokePrimaryRuntime(root) }).toThrow('missing Python distribution versions; prepare the payload')
} finally { await rm(root, { recursive: true, force: true }) }
})
it('extracts a hash-verified cached library without a Python installer or network request', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-'))
try {
const hash = createHash('sha256').update(libraryWheel).digest('hex')
const archive = join(root, hash)
await writeFile(archive, libraryWheel)
expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive)
await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))
expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n')
await writeFile(archive, 'corrupt archive')
await expect(downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).rejects.toThrow('checksum mismatch')
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('fully extracts a large deflate-compressed wheel entry', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-large-wheel-'))
try {
const expected = Buffer.alloc(1024 * 1024)
// Seeded xorshift bytes keep the compressed entry larger than the stream buffers.
let state = 26
for (let index = 0; index < expected.length; index++) {
state ^= state << 13
state ^= state >>> 17
state ^= state << 5
expected[index] = state & 255
}
const archive = join(root, 'large.whl')
await writeFile(archive, zipSync({ 'large.bin': expected }, { level: 6 }))
await unpackPrimaryRuntimeWheel(archive, root)
expect(await readFile(join(root, 'large.bin'))).toEqual(expected)
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('retains auxiliary wheel scripts without generating command wrappers', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-'))
try {
const archive = join(root, 'relocated.whl')
await writeFile(archive, relocatedWheel)
await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))
expect(await readFile(join(root, 'site-packages/sample-1.0.data/scripts/sample'), 'utf8')).toBe('requires relocation\n')
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('rejects library files requiring an unsupported installation scheme', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-wheel-'))
try {
const archive = join(root, 'relocated.whl')
await writeFile(archive, externalLibraryWheel)
await expect(unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages'))).rejects.toThrow('unsupported installation paths')
} finally {
await rm(root, { recursive: true, force: true })
}
})
it('copies complete Office resources outside the application archive and removes obsolete assets', async () => {
const root = await mkdtemp(join(tmpdir(), 'desktop-office-assets-'))
try {
const source = join(root, 'package', 'assets')
const destination = join(root, 'Contents', 'Resources', 'runtime', 'office-skills')
await mkdir(join(source, 'scripts'), { recursive: true })
await writeFile(join(source, 'scripts', 'check_office.py'), 'print("checker")\n')
for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
await mkdir(join(source, name))
await writeFile(join(source, name, 'SKILL.md'), `# ${name}\n`)
}
await prepareOfficeSkillAssets(source, destination)
await writeFile(join(destination, 'obsolete.py'), 'old helper')
await prepareOfficeSkillAssets(source, destination)
for (const name of ['office-docx', 'office-pptx', 'office-xlsx']) {
expect(await readFile(join(destination, name, 'SKILL.md'), 'utf8')).toBe(`# ${name}\n`)
}
expect(await readFile(join(destination, 'scripts', 'check_office.py'), 'utf8')).toBe('print("checker")\n')
await expect(readFile(join(destination, 'obsolete.py'))).rejects.toMatchObject({ code: 'ENOENT' })
} finally { await rm(root, { recursive: true, force: true }) }
})
it('gives Python-only payloads a distinct identity', () => {
expect(primaryRuntimePayloadDigest('linux-x64', lock, undefined)).not.toBe(primaryRuntimePayloadDigest('linux-x64', lock, '11.7.0'))
})
it('keeps carrier pnpm versions aligned with the shared payload build', () => {
const root = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as {
packageManager: string
devDependencies: { pnpm: string }
}
const desktop = JSON.parse(readFileSync(new URL('../../apps/desktop/package.json', import.meta.url), 'utf8')) as {
devDependencies: { pnpm: string }
}
expect(desktop.devDependencies.pnpm).toBe(root.devDependencies.pnpm)
expect(root.packageManager).toBe(`pnpm@${root.devDependencies.pnpm}`)
})
+207
View File
@@ -0,0 +1,207 @@
/** Prepare pinned, relocatable script interpreters without installing into the build host. */
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { cp } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { parseArgs } from 'node:util'
import extractZip from 'extract-zip'
import { x as extractTar } from 'tar'
import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../packages/skill/tool-workspace-dependencies/src/index.ts'
import lock from './lock.json' with { type: 'json' }
/**
* Download or reuse an archive only when its bytes match the release lock.
* @param url - Locked archive URL.
* @param sha256 - Expected SHA-256 digest.
* @param cache - Download cache directory.
* @returns Verified local archive path.
*/
export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise<string> {
const destination = join(cache, sha256)
let bytes: Buffer
try { bytes = readFileSync(destination) } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
const response = await fetch(url)
if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`)
bytes = Buffer.from(await response.arrayBuffer())
}
if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`)
writeFileSync(destination, bytes)
return destination
}
async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise<string> {
const artifact = lock.targets[target]
const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz`
return downloadPrimaryRuntimeAsset(`https://github.com/astral-sh/python-build-standalone/releases/download/${lock.pythonRelease}/${encodeURIComponent(filename)}`, artifact.pythonSha256, cache)
}
/**
* Identify the inputs that assemble one target's payload, excluding unrelated target locks.
* @param target - Runtime target whose archives are installed.
* @param runtimeLock - Locked interpreter and wheel inputs.
* @param pnpmVersion - Package-manager version copied into the payload.
* @returns SHA-256 payload identity for installation reuse.
*/
export function primaryRuntimePayloadDigest(
target: keyof typeof lock.targets, runtimeLock: typeof lock, pnpmVersion: string | undefined,
): string {
const { pythonVersion, pythonRelease, nodeVersion, wheels, pythonPackages } = runtimeLock
// Identity preserves key order within the selected target, wheel records and distribution map, plus wheel-entry order.
// Bump format when extraction or assembly changes payload bytes without changing locked inputs.
return createHash('sha256').update(JSON.stringify({
format: 4, target, pythonVersion, pythonRelease, nodeVersion: pnpmVersion === undefined ? undefined : nodeVersion,
artifact: runtimeLock.targets[target], wheels, pythonPackages, pnpm: pnpmVersion,
})).digest('hex')
}
/**
* Unpack a locked library wheel, retaining auxiliary scripts in its distribution data directory.
* @param archive - Hash-verified wheel archive.
* @param destination - Absolute site-packages directory.
* @returns Resolves after extraction without command wrappers; rejects other wheel installation schemes.
*/
export async function unpackPrimaryRuntimeWheel(archive: string, destination: string): Promise<void> {
await extractZip(archive, {
dir: destination,
onEntry: (entry) => {
const [directory, scheme] = entry.fileName.split('/')
if (directory?.endsWith('.data') && scheme !== '' && scheme !== 'scripts') {
throw new Error(`primary runtime: wheel requires unsupported installation paths: ${entry.fileName}`)
}
},
})
}
/**
* Copy the skill package's complete asset tree to ordinary filesystem resources.
* @param source - The package's assets directory.
* @param destination - External Office skill resource directory.
* @returns Resolves after replacing the external assets with the complete package tree.
*/
export async function prepareOfficeSkillAssets(source: string, destination: string): Promise<void> {
rmSync(destination, { recursive: true, force: true })
await cp(source, destination, { recursive: true, dereference: true })
}
/** A locked interpreter and wheel target. */
export type PrimaryRuntimeTarget = keyof typeof lock.targets
/** Build-only inputs shared by Desktop and SDK carriers. */
export interface PreparePrimaryRuntimeOptions {
/** Target whose archives and wheels are downloaded. */
readonly target: PrimaryRuntimeTarget
/** Resource directory receiving primary-runtime/ and office-skills/. */
readonly output: string
/** SHA-256-addressed archive cache. */
readonly cache: string
/** Carrier release recorded in the legacy desktopVersion manifest field. */
readonly version: string
/** Omit Node.js and pnpm for carriers providing only Python. */
readonly pythonOnly?: boolean
}
/**
* Materialize locked interpreters, libraries and Office resources without executing target code.
* @param options - Explicit target and carrier-owned output locations.
* @returns Resolves after the complete payload and skills have been copied to the output directory.
*/
export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOptions): Promise<void> {
const { target } = options
const paths = { runtime: resolve(options.output), downloads: resolve(options.cache) }
const artifact = lock.targets[target]
mkdirSync(paths.runtime, { recursive: true })
mkdirSync(paths.downloads, { recursive: true })
const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-'))
try {
const output = join(staging, 'payload')
const dependencies = join(output, 'dependencies')
mkdirSync(dependencies, { recursive: true })
let pnpmVersion: string | undefined
if (!options.pythonOnly) {
const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}`
const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads)
const unpackedNode = join(staging, 'node')
mkdirSync(unpackedNode)
if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode })
else await extractTar({ file: nodeArchive, cwd: unpackedNode })
const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, ''))
mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true })
mkdirSync(join(dependencies, 'node', 'node_modules'))
writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n')
cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])),
join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node'))
cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE'))
const require = createRequire(import.meta.url)
const pnpmManifest = require.resolve('pnpm')
pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version
await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true })
}
await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies })
const manifest: PrimaryRuntimeManifest = {
desktopVersion: options.version,
platform: target === 'win-x64' ? 'win32' : target.startsWith('linux-') ? 'linux' : 'darwin',
arch: target.endsWith('-arm64') ? 'arm64' : 'x64',
payloadDigest: primaryRuntimePayloadDigest(target, lock, pnpmVersion),
python: lock.pythonVersion,
...(pnpmVersion === undefined ? {} : { node: lock.nodeVersion, pnpm: pnpmVersion }),
pythonPackages: lock.pythonPackages,
}
const entries = workspaceDependencyPaths(output, manifest)
for (const wheel of [...artifact.wheels, ...lock.wheels]) {
await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages)
}
writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`)
const destination = join(paths.runtime, 'primary-runtime')
rmSync(destination, { recursive: true, force: true })
await cp(output, destination, { recursive: true, dereference: true })
} finally {
rmSync(staging, { recursive: true, force: true })
}
const require = createRequire(import.meta.url)
await prepareOfficeSkillAssets(join(dirname(require.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'),
join(paths.runtime, 'office-skills'))
}
/**
* Execute the native payload's interpreters, package manager and Python libraries.
* @param root - Final payload directory, including any platform signatures.
* @param environment - Scrubbed subprocess environment; defaults to excluding credential-shaped names.
*/
export function smokePrimaryRuntime(root: string, environment: NodeJS.ProcessEnv = Object.fromEntries(
Object.entries(process.env).filter(([name]) => !/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(name)),
)): void {
const manifest = parsePrimaryRuntime(JSON.parse(readFileSync(join(root, 'runtime.json'), 'utf8')))
if (manifest.platform !== process.platform || manifest.arch !== process.arch) return
if (Object.keys(manifest.pythonPackages).length === 0) throw new Error('primary runtime: missing Python distribution versions; prepare the payload before running its smoke checks.')
const entries = workspaceDependencyPaths(root, manifest)
const options = { stdio: 'inherit', timeout: 120_000, env: environment } as const
execFileSync(entries.python, ['-I', '-B', '-c', 'import decimal, xml.parsers.expat, lzma, uuid, numpy, pandas; assert numpy.arange(4).sum() == 6; assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3'], options)
execFileSync(entries.python, ['-I', '-B', join(import.meta.dirname, 'smoke.py'), JSON.stringify(manifest.pythonPackages),
manifest.python, join(dirname(root), 'office-skills', 'scripts', 'check_office.py')], options)
execFileSync(entries.python, ['-I', '-B', '-m', 'pip', 'check'], options)
if (entries.node !== undefined) execFileSync(entries.node, ['-e', `if (process.versions.node !== ${JSON.stringify(manifest.node)}) process.exit(1)`], options)
if (entries.pnpm !== undefined && entries.node !== undefined) execFileSync(entries.node, [entries.pnpm, '--version'], options)
}
if (import.meta.main) {
const { values } = parseArgs({ options: {
target: { type: 'string' }, output: { type: 'string' }, cache: { type: 'string' },
'python-only': { type: 'boolean', default: false },
} })
if (!values.target || !Object.hasOwn(lock.targets, values.target) || !values.output) {
throw new Error(`Usage: pnpm run prepare:primary-runtime --target <${Object.keys(lock.targets).join('|')}> --output <directory> [--cache <directory>] [--python-only]`)
}
const { version } = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version: string }
const output = resolve(values.output)
await preparePrimaryRuntime({
target: values.target as PrimaryRuntimeTarget, output,
cache: values.cache ?? join(tmpdir(), 'dsh-primary-runtime-downloads'), version,
pythonOnly: values['python-only'],
})
smokePrimaryRuntime(join(output, 'primary-runtime'))
}
+99
View File
@@ -0,0 +1,99 @@
"""Exercise the relocated Office payload with its own isolated interpreter."""
import importlib.metadata
import json
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import numpy
import pandas
from docx import Document
from docx.shared import Inches as DocxInches
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font
from PIL import Image
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.enum.chart import XL_CHART_TYPE
from pptx.util import Inches
def main():
"""Check installed versions and read back editable Office documents."""
assert sys.version_info[:3] == tuple(map(int, sys.argv[2].split("."))), sys.version
versions = json.loads(sys.argv[1])
# Only pip belongs to the interpreter baseline; all other distributions must be declared.
# Each target's native release-host smoke must confirm this baseline.
expected_names = {re.sub(r"[-_.]+", "-", name).lower() for name in versions} | {"pip"}
installed_names = {
re.sub(r"[-_.]+", "-", distribution.metadata["Name"]).lower()
for distribution in importlib.metadata.distributions()
}
assert installed_names == expected_names, {"unexpected": sorted(installed_names - expected_names), "missing": sorted(expected_names - installed_names)}
for name, expected in versions.items():
actual = importlib.metadata.version(name)
assert actual == expected, (name, actual, expected)
assert numpy.arange(4).sum() == 6
assert pandas.DataFrame({"n": [1, 2]}).n.sum() == 3
with tempfile.TemporaryDirectory(prefix="dsh-office-smoke-") as directory:
root = Path(directory)
image = root / "chart.png"
Image.new("RGB", (80, 40), "#2878bc").save(image)
with Image.open(image) as restored:
assert restored.size == (80, 40)
document = Document()
document.add_heading("Office 文档", 0)
document.add_paragraph("Editable text")
document.add_table(rows=2, cols=2).cell(1, 1).text = "42"
document.add_picture(str(image), width=DocxInches(1))
document.save(root / "document.docx")
reopened_document = Document(root / "document.docx")
assert reopened_document.tables[0].cell(1, 1).text == "42"
assert reopened_document.paragraphs[0].text == "Office 文档"
presentation = Presentation()
slide = presentation.slides.add_slide(presentation.slide_layouts[6])
slide.shapes.add_textbox(Inches(1), Inches(1), Inches(4), Inches(1)).text = "Office 演示"
slide.shapes.add_picture(str(image), Inches(1), Inches(2))
chart_data = CategoryChartData()
chart_data.categories = ["A", "B"]
chart_data.add_series("Values", [2, 4])
slide.shapes.add_chart(XL_CHART_TYPE.COLUMN_CLUSTERED, Inches(3), Inches(2), Inches(4), Inches(3), chart_data)
presentation.save(root / "presentation.pptx")
reopened_presentation = Presentation(root / "presentation.pptx")
assert len(reopened_presentation.slides) == 1
chart = next(shape.chart for shape in reopened_presentation.slides[0].shapes if shape.has_chart)
assert list(chart.series[0].values) == [2.0, 4.0]
workbook = Workbook()
sheet = workbook.active
sheet.append(["Value", "Formula"])
sheet.append([42, "=A2*2"])
sheet["A1"].font = Font(bold=True)
workbook.save(root / "workbook.xlsx")
reopened_workbook = load_workbook(root / "workbook.xlsx")
try:
assert reopened_workbook.active["A2"].value == 42
assert reopened_workbook.active["B2"].value == "=A2*2"
assert reopened_workbook.active["A1"].font.bold
finally:
reopened_workbook.close()
assert pandas.read_excel(root / "workbook.xlsx")["Value"].iloc[0] == 42
for file, arguments in [
("document.docx", ["--contains", "Office 文档"]),
("presentation.pptx", ["--contains", "Office 演示", "--count", "1"]),
("workbook.xlsx", ["--contains", "Value", "--count", "1"]),
]:
checked = subprocess.run([sys.executable, "-I", "-B", sys.argv[3], str(root / file), *arguments],
check=False, capture_output=True, text=True, timeout=30)
assert checked.returncode == 0 and json.loads(checked.stdout)["verdict"] == "pass", checked.stdout + checked.stderr
print("Office runtime versions and document round trips passed.")
if __name__ == "__main__":
main()
+2 -2
View File
@@ -768,9 +768,9 @@ interface InspectedTarball {
}
function inspectTarball(path: string, runner: CommandRunner): InspectedTarball {
const manifest = JSON.parse(
const manifest: unknown = JSON.parse(
runner.capture('tar', ['-xOf', path, 'package/package.json'], dirname(path)),
) as unknown
)
if (!isRecord(manifest)) throw new Error(`${path} contains an invalid package.json`)
return {
name: expectString(manifest, 'name', path),
+268 -15
View File
@@ -1,8 +1,35 @@
/** Linked catalog regressions for transitive changes and shared definitions. */
import { describe, expect, it } from 'vitest'
import { canonicalizeSchema, schemaDigest, type PersistenceSchemaInventory, type PersistenceType, type SchemaNode } from './persistence-schema-model.ts'
import { canonicalizeSchema, schemaDigest, type PersistenceRoot, type PersistenceSchemaInventory, type PersistenceType, type SchemaNode } from './persistence-schema-model.ts'
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
import { render } from './gen-persistence-catalog.ts'
import { classifyPersistenceChange } from './persistence-changes.ts'
function eventRoot(
event: string,
nodes: readonly SchemaNode[],
types: Map<string, PersistenceType>,
metadata: (index: number) => Pick<PersistenceType, 'names' | 'sources'>,
): PersistenceRoot {
nodes.forEach((_, index) => {
const schema = canonicalizeSchema(nodes, index)
const digest = schemaDigest(schema)
if (!types.has(digest)) types.set(digest, { digest, schema, ...metadata(index) })
})
const schema = canonicalizeSchema(nodes, 0)
return { key: `event:${event}`, kind: 'event', event, surface: false, digest: schemaDigest(schema), schema }
}
function recursiveFixture(sources: readonly string[] = []): PersistenceSchemaInventory {
const schema = canonicalizeSchema([{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }], indices: [] }], 0)
const digest = schemaDigest(schema)
return {
formatVersion: 1,
roots: [{ key: 'SessionHeader', kind: 'header', digest, schema }],
types: [{ digest, schema, names: ['Recursive'], sources }],
}
}
function fixture(optional = true): PersistenceSchemaInventory {
const types = new Map<string, PersistenceType>()
@@ -14,13 +41,9 @@ function fixture(optional = true): PersistenceSchemaInventory {
{ kind: 'object', properties: [{ name: 'reason', type: 4, optional }], indices: [] },
{ kind: 'primitive', type: 'string' },
]
nodes.forEach((_, index) => {
const schema = canonicalizeSchema(nodes, index)
const digest = schemaDigest(schema)
if (!types.has(digest)) types.set(digest, { digest, schema, names: index === 3 ? ['ErrorInfo'] : [], sources: index === 3 ? ['packages/core/example/src/types.ts:8'] : [] })
})
const schema = canonicalizeSchema(nodes, 0)
return { key: `event:${event}`, kind: 'event' as const, event, surface: false, digest: schemaDigest(schema), schema }
return eventRoot(event, nodes, types, index => ({
names: index === 3 ? ['ErrorInfo'] : [], sources: index === 3 ? ['packages/core/example/src/types.ts:8'] : [],
}))
})
return { formatVersion: 1, roots, types: [...types.values()] }
}
@@ -71,13 +94,7 @@ describe('persistence schema catalog', () => {
})
it('renders a recursive definition through a finite self-reference', () => {
const schema = canonicalizeSchema([{ kind: 'object', properties: [{ name: 'next', type: 0, optional: true }], indices: [] }], 0)
const digest = schemaDigest(schema)
const inventory: PersistenceSchemaInventory = {
formatVersion: 1,
roots: [{ key: 'SessionHeader', kind: 'header', digest, schema }],
types: [{ digest, schema, names: ['Recursive'], sources: [] }],
}
const inventory = recursiveFixture()
expect(renderPersistenceSchemaDefinitions(inventory)).toContain('| `next` | optional | [`Recursive`](#persistence-type-recursive) |')
})
@@ -94,3 +111,239 @@ describe('persistence schema catalog', () => {
expect(rendered).not.toContain('### `SessionHeader.agentPreset`')
})
})
function producerFixture(kinds: readonly string[], events: readonly string[]): PersistenceSchemaInventory {
const types = new Map<string, PersistenceType>()
const roots = events.map((event) => {
const nodes: SchemaNode[] = [
{ kind: 'object', properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }], indices: [] },
{ kind: 'literal', value: event },
{ kind: 'object', properties: [{ name: 'source', type: 3, optional: false }], indices: [] },
{ kind: 'union', types: kinds.map((_, index) => 4 + index * 2) },
...kinds.flatMap((kind, index): SchemaNode[] => [
{ kind: 'object', properties: [{ name: 'kind', type: 5 + index * 2, optional: false }], indices: [] },
{ kind: 'literal', value: kind },
]),
]
return eventRoot(event, nodes, types, index => ({
names: index >= 4 && index % 2 === 0 ? [`event:${event}.data.source[${String((index - 4) / 2)}]`] : [], sources: [],
}))
})
return { formatVersion: 1, roots, types: [...types.values()] }
}
function currentDefinitions(inventory: PersistenceSchemaInventory): string {
return renderPersistenceSchemaDefinitions(inventory, 'en', undefined, 2, 'current')
}
function assertLinkedOnce(markdown: string): void {
const anchors = [...markdown.matchAll(/<a id="([^"]+)"><\/a>/gu)].map(match => match[1])
expect(new Set(anchors).size).toBe(anchors.length)
for (const match of markdown.matchAll(/\]\(#(persistence-type-[^)]+)\)/gu)) {
expect(anchors, `missing target ${match[1]}`).toContain(match[1])
}
}
describe('current persistence schema anchors', () => {
it('names shared arrays by their declared elements independent of the first reaching root', () => {
const nodes: SchemaNode[] = [
{ kind: 'array', element: 1 },
{ kind: 'object', properties: [{ name: 'text', type: 2, optional: false }], indices: [] },
{ kind: 'primitive', type: 'string' },
]
const types = new Map<string, PersistenceType>()
const root = eventRoot('second/event', nodes, types, index => ({ names: index === 1 ? ['ContextSnapshotSection'] : [], sources: [] }))
const inventory: PersistenceSchemaInventory = { formatVersion: 1, roots: [root], types: [...types.values()] }
const rendered = currentDefinitions(inventory)
expect(rendered).toContain('### `ContextSnapshotSection[]`')
const withEarlierRoot = { ...inventory, roots: [{ ...root, key: 'event:first/event' }, root] }
expect(currentDefinitions(withEarlierRoot)).toContain('### `ContextSnapshotSection[]`')
expect(rendered).not.toContain('### `event:second/event`')
assertLinkedOnce(currentDefinitions(withEarlierRoot))
})
it('shows every kind/form arm, including absent, optional, broad and colliding forms', () => {
const nodes: SchemaNode[] = [
{ kind: 'union', types: [1, 2, 3, 4, 5] },
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }], indices: [] },
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }, { name: 'form', type: 7, optional: false }, { name: 'summary', type: 8, optional: false }], indices: [] },
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }, { name: 'form', type: 7, optional: false }, { name: 'other', type: 8, optional: false }], indices: [] },
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }, { name: 'form', type: 8, optional: true }], indices: [] },
{ kind: 'object', properties: [{ name: 'kind', type: 6, optional: false }, { name: 'form', type: 9, optional: false }], indices: [{ key: 8, value: 8 }] },
{ kind: 'literal', value: 'hook|"quoted' },
{ kind: 'literal', value: 'notice' },
{ kind: 'primitive', type: 'string' },
{ kind: 'union', types: [7, 10] },
{ kind: 'literal', value: 'snapshot' },
]
const types = new Map<string, PersistenceType>()
const root = eventRoot('source/event', nodes, types, () => ({ names: [], sources: [] }))
const inventory: PersistenceSchemaInventory = { formatVersion: 1, roots: [root], types: [...types.values()] }
const rendered = currentDefinitions(inventory)
expect(rendered).toContain('| `hook\\|"quoted` | not declared | none |')
expect(rendered).toContain('| `hook\\|"quoted` | `string` (optional) | none |')
expect(rendered.match(/\| `hook\\\|"quoted` \|/gu)).toHaveLength(5)
expect(rendered).toContain('index signature')
expect(rendered).toContain('other }`')
expect(rendered).toContain('summary }`')
assertLinkedOnce(rendered)
const reordered = { ...inventory, types: [...inventory.types].reverse() }
expect(currentDefinitions(reordered)).toBe(rendered)
})
it('falls back to complete unions for optional or broad kind discriminators', () => {
for (const optional of [true, false]) {
const nodes: SchemaNode[] = [
{ kind: 'union', types: [1, 2] },
{ kind: 'object', properties: [{ name: 'kind', type: optional ? 3 : 4, optional }], indices: [] },
{ kind: 'object', properties: [{ name: 'kind', type: 3, optional: false }], indices: [] },
{ kind: 'literal', value: 'example' },
{ kind: 'primitive', type: 'string' },
]
const types = new Map<string, PersistenceType>()
const root = eventRoot('broad/event', nodes, types, () => ({ names: [], sources: [] }))
const rendered = currentDefinitions({ formatVersion: 1, roots: [root], types: [...types.values()] })
expect(rendered).toContain('One of:')
expect(rendered).not.toContain('| kind | Form property |')
assertLinkedOnce(rendered)
}
})
it('bounds labels for anonymous recursive arrays', () => {
const schema = canonicalizeSchema([{ kind: 'array', element: 0 }], 0)
const digest = schemaDigest(schema)
const rendered = currentDefinitions({ formatVersion: 1, roots: [], types: [{ schema, digest, names: [], sources: [] }] })
expect(rendered).toContain('### `array[]`')
assertLinkedOnce(rendered)
expect(rendered.length).toBeLessThan(1000)
})
it('labels empty objects without padding while preserving structural links and historical labels', () => {
const nodes: SchemaNode[] = [
{ kind: 'object', properties: [{ name: 'type', type: 1, optional: false }, { name: 'data', type: 2, optional: false }], indices: [] },
{ kind: 'literal', value: 'empty/event' },
{ kind: 'array', element: 3 },
{ kind: 'object', properties: [], indices: [] },
]
const types = new Map<string, PersistenceType>()
const root = eventRoot('empty/event', nodes, types, () => ({ names: [], sources: [] }))
const inventory: PersistenceSchemaInventory = { formatVersion: 1, roots: [root], types: [...types.values()] }
const saved = JSON.stringify(inventory)
const emptyDigest = schemaDigest(canonicalizeSchema(nodes, 3))
const current = currentDefinitions(inventory)
expect(current).toContain('### `{}`')
expect(current).not.toContain('{ }')
expect(current).toContain('[`{}`](#persistence-type-sha256-' + emptyDigest + ')')
assertLinkedOnce(current)
const historical = renderPersistenceSchemaDefinitions(inventory)
expect(historical).toContain('### `event:empty/event.data.item`')
expect(historical).toContain('Array of [`event:empty/event.data.item`](#persistence-type-eventemptyeventdataitem).')
expect(JSON.stringify(inventory)).toBe(saved)
expect(schemaDigest(root.schema)).toBe(root.digest)
})
it('keeps anonymous producer references when siblings and the first owning root change', () => {
const before = producerFixture(['alpha', 'beta'], ['second/event'])
const after = producerFixture(['new', 'beta', 'alpha'], ['first/event', 'second/event'])
const alphaSchema = canonicalizeSchema([
{ kind: 'object', properties: [{ name: 'kind', type: 1, optional: false }], indices: [] },
{ kind: 'literal', value: 'alpha' },
], 0)
const alphaDigest = schemaDigest(alphaSchema)
const link = '[`{ kind: "alpha" }`](#persistence-type-sha256-' + alphaDigest + ')'
expect(currentDefinitions(before)).toContain(link)
expect(currentDefinitions(after)).toContain(link)
expect(currentDefinitions(after)).not.toContain('.source[')
assertLinkedOnce(currentDefinitions(after))
const reordered = { ...after, roots: [...after.roots].reverse(), types: [...after.types].reverse() }
expect(currentDefinitions(reordered)).toBe(currentDefinitions(after))
})
it('retains logical root and named aliases while structural links track changed content', () => {
const before = fixture()
const after = fixture(false)
const old = currentDefinitions(before)
const updated = currentDefinitions(after)
for (const definitions of [old, updated]) {
expect(definitions).toContain('<a id="persistence-type-errorinfo"></a>')
expect(definitions).toContain('<a id="persistence-type-eventfirstevent"></a>')
}
expect(old).not.toBe(updated)
const document = render([], [], after)
expect(document).toContain('](#persistence-type-sha256-' + after.roots[0]!.digest + ')')
assertLinkedOnce(document)
})
it('omits ambiguous aliases and keeps colliding labels linked to distinct definitions', () => {
const complete = fixture()
const first = complete.types[0] as PersistenceType
const second = complete.types[1] as PersistenceType
const inventory = { ...complete, types: complete.types.map(type => type === first
? { ...type, names: ['A.B', 'sha256-' + second.digest] }
: type === second ? { ...type, names: ['AB'] } : type) }
const definitions = currentDefinitions(inventory)
expect(definitions).not.toContain('<a id="persistence-type-ab"></a>')
expect(definitions).toContain('<a id="persistence-type-sha256-' + first.digest + '"></a>')
expect(definitions).toContain('<a id="persistence-type-sha256-' + second.digest + '"></a>')
assertLinkedOnce(definitions)
})
it('renders recursion once and leaves inventories, digests and classifications unchanged', () => {
const inventory = recursiveFixture()
const { schema, digest } = inventory.roots[0]!
const saved = JSON.stringify(inventory)
const before = fixture()
const after = fixture(false)
const classification = classifyPersistenceChange(before.roots[0]!, after.roots[0]!)
const rendered = currentDefinitions(inventory)
expect(rendered.match(/### `Recursive`/gu)).toHaveLength(1)
expect(rendered).toContain('[`Recursive`](#persistence-type-sha256-' + digest + ')')
assertLinkedOnce(rendered)
currentDefinitions(after)
expect(JSON.stringify(inventory)).toBe(saved)
expect(schemaDigest(schema)).toBe(digest)
expect(classifyPersistenceChange(before.roots[0]!, after.roots[0]!)).toEqual(classification)
})
it('shows recorded source policies only in the current catalog and preserves their fields', () => {
const schema = canonicalizeSchema([
{ kind: 'object', properties: [{ name: 'source', type: 1, optional: false, compatibility: {
version: 1, policy: 'session-source-attribution', binding: 'session.user-message.source',
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['build-context'],
} }], indices: [] },
{ kind: 'object', properties: [{ name: 'kind', type: 2, optional: false }], indices: [] },
{ kind: 'literal', value: 'build-context' },
], 0)
const types = schema.nodes.map((_, index) => {
const nested = canonicalizeSchema(schema.nodes, index)
return { digest: schemaDigest(nested), schema: nested, names: [], sources: [] }
})
const inventory: PersistenceSchemaInventory = { formatVersion: 2, roots: [], types }
const saved = JSON.stringify(inventory)
expect(currentDefinitions(inventory)).toContain('Source compatibility: `source` — `session-source-attribution` v1; `session.user-message.source`; `kind`; `preserve`.')
expect(currentDefinitions(inventory)).toContain('Attribution-only additions: `build-context`.')
expect(renderPersistenceSchemaDefinitions(inventory, 'zh', undefined, 2, 'current')).toContain('仅表示归属的新增 kind:`build-context`.')
expect(renderPersistenceSchemaDefinitions(inventory)).not.toContain('Source compatibility:')
expect(JSON.stringify(inventory)).toBe(saved)
})
it('keeps the captured historical Markdown byte-for-byte in both historical call forms', () => {
const inventory = recursiveFixture(['packages/core/example/src/types.ts'])
const expected = [
'### Persistence type fingerprints', '', 'Frozen reference.', '',
'| Root | Kind | SHA-256 | Resolved type |', '|---|---|---|---|',
'| `SessionHeader` | header | `c900da63847d1d27572277817e727f3a471d08f0abaf0b61b4984c32ffc6a332` | [`Recursive`](#persistence-type-recursive) |', '',
'### Resolved persistence types', '',
'Each definition appears once. References preserve sharing and recursion; the digest beside a definition includes its complete reachable structure. Source names and locations identify its declarations but are excluded from its digest.', '',
'<a id="persistence-type-recursive"></a>', '', '#### `Recursive`', '',
'SHA-256: `c900da63847d1d27572277817e727f3a471d08f0abaf0b61b4984c32ffc6a332`', '',
'Sources: `packages/core/example/src/types.ts`', '',
'| Property | Presence | Type |', '|---|---|---|',
'| `next` | optional | [`Recursive`](#persistence-type-recursive) |', '',
].join('\n')
const index = renderPersistenceSchemaIndex(inventory, 'en', ['Frozen reference.'], 3)
expect(index + '\n' + renderPersistenceSchemaDefinitions(inventory, 'en', () => undefined, 3)).toBe(expected)
expect(renderPersistenceSchemaIndex(inventory, 'en', ['Frozen reference.'], 3, 'historical') + '\n'
+ renderPersistenceSchemaDefinitions(inventory, 'en', () => undefined, 3, 'historical')).toBe(expected)
})
})
+127 -10
View File
@@ -2,6 +2,7 @@
import { githubSlug } from './verify-md-links.ts'
import { persistenceCatalogText, type PersistenceCatalogLocale } from './persistence-catalog-text.ts'
import { sourceKindGroups } from './persistence-source-policy.ts'
import {
canonicalizeSchema,
schemaChildren,
@@ -16,8 +17,12 @@ interface TypeDisplay {
readonly type: PersistenceType
readonly label: string
readonly anchor: string
readonly aliases: readonly string[]
}
/** Current catalogs use structural links; historical references retain their original labels and anchors. */
export type PersistenceSchemaRendering = 'current' | 'historical'
function code(text: string): string {
return '`' + text.replaceAll('`', '\\`').replaceAll('|', '\\|') + '`'
}
@@ -45,7 +50,7 @@ function childPath(node: SchemaNode, position: number): string {
}
}
function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
function historicalDisplays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
const paths = new Map<string, string>()
for (const root of inventory.roots) {
const seen = new Set<number>()
@@ -78,10 +83,87 @@ function displays(inventory: PersistenceSchemaInventory): Map<string, TypeDispla
let anchor = base
for (let suffix = 2; used.has(anchor); suffix += 1) anchor = `${base}-${String(suffix)}`
used.add(anchor)
return [type.digest, { type, label, anchor }]
return [type.digest, { type, label, anchor, aliases: [] }]
}))
}
function structuralLabel(schema: CanonicalSchema, includeFields = false): string {
const node = nodeAt(schema, 0)
switch (node.kind) {
case 'primitive': return node.type
case 'literal': return JSON.stringify(node.value)
case 'opaque': return node.reason
case 'object': {
const tags = ['type', 'kind', 'role', 'form'].flatMap((name) => {
const property = node.properties.find(candidate => candidate.name === name && !candidate.optional)
const value = property === undefined ? undefined : nodeAt(schema, property.type)
return value?.kind === 'literal' ? [`${name}: ${JSON.stringify(value.value)}`] : []
})
const fields = tags.length > 0 && !includeFields ? tags : node.properties.map((property) => {
const tag = tags.find(tag => tag.startsWith(property.name + ':'))
return tag ?? `${property.name}${property.optional ? '?' : ''}`
})
if (fields.length === 0) return '{}'
return `{ ${fields.slice(0, 4).join(', ')}${fields.length > 4 ? ', …' : ''} }`
}
case 'array': return 'array'
case 'tuple': return `tuple (${String(node.elements.length)} positions)`
case 'union': return `union (${String(node.types.length)} variants)`
default: return assertNever(node)
}
}
function currentDisplays(inventory: PersistenceSchemaInventory): Map<string, TypeDisplay> {
const roots = new Map<string, string[]>()
for (const root of inventory.roots) roots.set(root.digest, [...roots.get(root.digest) ?? [], root.key])
const aliases = new Map<string, Set<string>>()
const namesByDigest = new Map(inventory.types.map((type) => {
const names = type.names.filter(name => !inventory.roots.some(root => name.startsWith(root.key + '.') || name.startsWith(root.key + '[')))
const logicalNames = [...new Set([...names, ...roots.get(type.digest) ?? []])].sort()
for (const name of logicalNames) {
for (const value of new Set([name, name.slice(name.lastIndexOf('#') + 1)])) {
const alias = `persistence-type-${githubSlug(value)}`
const owners = aliases.get(alias) ?? new Set<string>()
owners.add(type.digest)
aliases.set(alias, owners)
}
}
return [type.digest, names.filter(name => !roots.get(type.digest)?.includes(name)).sort()]
}))
const types = new Map(inventory.types.map(type => [type.digest, type]))
const labelFor = (schema: CanonicalSchema, seen: ReadonlySet<string>, includeFields = false): string => {
const digest = schemaDigest(schema)
const node = nodeAt(schema, 0)
if (node.kind === 'primitive' || node.kind === 'literal' || node.kind === 'opaque') return structuralLabel(schema)
const named = namesByDigest.get(digest)?.find(name => aliases.get(`persistence-type-${githubSlug(name.slice(name.lastIndexOf('#') + 1))}`)?.size === 1)
if (named !== undefined) return named.slice(named.lastIndexOf('#') + 1)
if (seen.has(digest) || seen.size >= 4) return structuralLabel(schema, includeFields)
if (node.kind === 'array') {
const element = canonicalizeSchema(schema.nodes, node.element)
const label = labelFor(types.get(schemaDigest(element))?.schema ?? element, new Set(seen).add(digest), includeFields)
return `${label}[]`
}
return structuralLabel(schema, includeFields)
}
const labels = inventory.types.map(type => ({ type, label: labelFor(type.schema, new Set()), anchor: `persistence-type-sha256-${type.digest}` }))
const counts = new Map<string, number>()
for (const entry of labels) counts.set(entry.label, (counts.get(entry.label) ?? 0) + 1)
for (const entry of labels) {
if ((counts.get(entry.label) ?? 0) > 1) entry.label = labelFor(entry.type.schema, new Set(), true)
}
const structuralAnchors = new Set(labels.map(entry => entry.anchor))
return new Map(labels.map(entry => [entry.type.digest, {
...entry,
// A logical name shared by different structures has no unambiguous target.
aliases: [...aliases].filter(([alias, owners]) => owners.size === 1 && owners.has(entry.type.digest) && !structuralAnchors.has(alias))
.map(([alias]) => alias).sort(),
}]))
}
function displays(inventory: PersistenceSchemaInventory, rendering: PersistenceSchemaRendering): Map<string, TypeDisplay> {
return rendering === 'current' ? currentDisplays(inventory) : historicalDisplays(inventory)
}
function reference(digest: string, entries: ReadonlyMap<string, TypeDisplay>): string {
const entry = entries.get(digest)
if (!entry) throw new Error(`persistence catalog: reachable type ${digest} is absent from the inventory`)
@@ -107,11 +189,12 @@ function definition(
locale: PersistenceCatalogLocale,
sourceLink: (source: string) => string | undefined,
headingLevel: number,
rendering: PersistenceSchemaRendering,
): string[] {
const text = persistenceCatalogText[locale]
const schema = entry.type.schema
const node = nodeAt(schema, 0)
const lines = [`<a id="${entry.anchor}"></a>`, '', `${'#'.repeat(headingLevel)} ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
const lines = [...[entry.anchor, ...entry.aliases].flatMap(anchor => [`<a id="${anchor}"></a>`, '']), `${'#'.repeat(headingLevel)} ${code(entry.label)}`, '', `SHA-256: ${code(entry.type.digest)}`, '']
if (entry.type.sources.length > 0) {
lines.push(`${text.sources}${entry.type.sources.map((source) => {
const href = sourceLink(sourcePath(source))
@@ -129,6 +212,14 @@ function definition(
}
for (const index of node.indices) lines.push(`| [${expression(index.key)}] | ${text.index} | ${expression(index.value)} |`)
lines.push('')
if (rendering === 'current') {
for (const property of node.properties) {
const policy = property.compatibility
if (policy === undefined) continue
lines.push(`${text.sourceCompatibility}${code(property.name)} — ${code(policy.policy)} v${String(policy.version)}; ${code(policy.binding)}; ${code(policy.discriminator)}; ${code(policy.unknownKinds)}.`, '',
`${text.attributionAdditions}${policy.attributionKinds.map(code).join(', ') || code('[]')}.`, '')
}
}
}
break
case 'array': lines.push(`${text.arrayPrefix}${expression(node.element)}${text.arraySuffix}`, ''); break
@@ -140,9 +231,28 @@ function definition(
})
lines.push('')
break
case 'union':
lines.push(text.oneOf, '', ...node.types.map(index => `- ${expression(index)}`), '')
case 'union': {
const groups = rendering === 'current' ? sourceKindGroups(schema.nodes, 0) : undefined
if (groups !== undefined) {
lines.push(text.sourceColumns, '|---|---|---|---|')
for (const [kind, alternatives] of [...groups].sort(([a], [b]) => a.localeCompare(b))) {
const rows = alternatives.map((index) => {
const variant = nodeAt(schema, index)
if (variant.kind !== 'object') throw new Error('persistence catalog: source group must contain objects')
const form = variant.properties.find(property => property.name === 'form')
const formLabel = form === undefined ? text.notDeclared
: `${expression(form.type)}${form.optional ? ` (${text.optional})` : ''}`
const fields = variant.properties.filter(property => property.name !== 'kind' && property.name !== 'form' && !property.optional)
.map(property => `${code(property.name)}: ${expression(property.type)}`)
if (variant.indices.length > 0) fields.push(text.index)
return { index, formLabel, fields, digest: schemaDigest(canonicalizeSchema(schema.nodes, index)) }
}).sort((a, b) => a.formLabel.localeCompare(b.formLabel) || a.digest.localeCompare(b.digest))
for (const row of rows) lines.push(`| ${code(kind)} | ${row.formLabel} | ${row.fields.join('; ') || text.none} | ${expression(row.index)} |`)
}
lines.push('')
} else lines.push(text.oneOf, '', ...node.types.map(index => `- ${expression(index)}`), '')
break
}
case 'primitive': lines.push(code(node.type), ''); break
case 'literal': lines.push(code(JSON.stringify(node.value)), ''); break
case 'opaque': lines.push(`${code(node.reason)}${text.opaqueExplanation}`, ''); break
@@ -157,6 +267,7 @@ function definition(
* @param locale - generated document language.
* @param introduction - paragraphs before the root table; defaults to current-source links.
* @param headingLevel - section depth within the containing reference.
* @param rendering - explicit current mode selects structural links; omission preserves historical output.
* @returns Markdown index including the history and contributor workflow links.
*/
export function renderPersistenceSchemaIndex(
@@ -164,8 +275,9 @@ export function renderPersistenceSchemaIndex(
locale: PersistenceCatalogLocale = 'en',
introduction: readonly string[] = [persistenceCatalogText[locale].fingerprintsIntro, persistenceCatalogText[locale].historyIntro],
headingLevel: 2 | 3 = 2,
rendering: PersistenceSchemaRendering = 'historical',
): string {
const entries = displays(inventory)
const entries = displays(inventory, rendering)
const text = persistenceCatalogText[locale]
return [
`${'#'.repeat(headingLevel)} ${text.fingerprints}`, '', ...introduction.flatMap(paragraph => [paragraph, '']),
@@ -180,20 +292,25 @@ export function renderPersistenceSchemaIndex(
* @param locale - generated document language.
* @param sourceLink - source path to URL; undefined keeps historical locations as text.
* @param headingLevel - section depth; individual definitions use the next heading level.
* @returns Markdown definitions whose anchors use names or owning paths instead of hashes.
* @param rendering - explicit current mode selects structural links and unambiguous logical aliases.
* @returns linked Markdown definitions; historical mode preserves the original named and owning-path anchors.
*/
export function renderPersistenceSchemaDefinitions(
inventory: PersistenceSchemaInventory,
locale: PersistenceCatalogLocale = 'en',
sourceLink: (source: string) => string | undefined = source => `../${source}`,
headingLevel: 2 | 3 = 2,
rendering: PersistenceSchemaRendering = 'historical',
): string {
const entries = displays(inventory)
const entries = displays(inventory, rendering)
const text = persistenceCatalogText[locale]
const sorted = [...entries.values()].sort((left, right) => left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0)
const sorted = [...entries.values()].sort((left, right) => {
if (rendering === 'current' && left.label !== right.label) return left.label < right.label ? -1 : 1
return left.anchor < right.anchor ? -1 : left.anchor > right.anchor ? 1 : 0
})
return [
`${'#'.repeat(headingLevel)} ${text.definitions}`, '', text.definitionsIntro, '',
...sorted.flatMap(entry => definition(entry, entries, locale, sourceLink, headingLevel + 1)),
...sorted.flatMap(entry => definition(entry, entries, locale, sourceLink, headingLevel + 1, rendering)),
].join('\n')
}
+6 -1
View File
@@ -228,6 +228,7 @@ export function uniqueRepoFiles(
* @param pattern - global regex matched independently against each line.
* @param normalize - maps raw regex text to the reference the gate evaluates.
* @param isViolation - returns true when the normalized reference is invalid.
* @param excludedRange - optional selector of a zero-based, end-exclusive line range to leave unscanned.
* @returns every rejected reference in source order.
*/
export function findReferenceViolations(
@@ -236,11 +237,15 @@ export function findReferenceViolations(
pattern: RegExp,
normalize: (raw: string) => string,
isViolation: (ref: string) => boolean,
excludedRange?: (file: string, source: string) => readonly [number, number] | undefined,
): ReferenceViolation[] {
const file = relative(root, absPath).split(sep).join('/')
const out: ReferenceViolation[] = []
const lines = readFileSync(absPath, 'utf8').split('\n')
const source = readFileSync(absPath, 'utf8')
const lines = source.split('\n')
const excluded = excludedRange?.(file, source)
for (let i = 0; i < lines.length; i++) {
if (excluded !== undefined && i >= excluded[0] && i < excluded[1]) continue
const line = lines[i]
if (line === undefined) continue
for (const match of line.matchAll(pattern)) {
+112
View File
@@ -0,0 +1,112 @@
/** Compile request-only inputs against the actual LLM and durable write interfaces. */
import { resolve } from 'node:path'
import ts from 'typescript'
import { expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '..')
const probePath = resolve(import.meta.dirname, '__request_input_probe.ts')
const probe = `
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Session } from '@deepseek-ai/dsh-session'
import type { SessionTitleLlmRequestEventData } from '@deepseek-ai/dsh-session-title-llm'
import { createUserMessage, projectFilesToText, projectImagesForTextModel, projectOffloadedImages } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, Message, MessageId, MessageSource, RequestMessage, RequestUserInput } from '@deepseek-ai/dsh-llm'
declare const session: Session
declare const agent: Agent
declare const durable: Message
declare const id: MessageId
declare const source: MessageSource
declare const title: SessionTitleLlmRequestEventData
const input: RequestUserInput = { role: 'user', content: [{ type: 'text', text: 'request' }] }
const request: GenerateOptions = { provider: 'test', model: 'test', messages: [durable, input] }
const files: readonly RequestMessage[] = projectFilesToText([input], () => undefined)
const images: readonly RequestMessage[] = projectImagesForTextModel([input])
const offloaded: readonly RequestMessage[] = projectOffloadedImages([input], () => 'offloaded')
const durableProjection: readonly Message[] = projectImagesForTextModel([durable])
const existing: RequestMessage = durable
type IsAny<T> = 0 extends (1 & T) ? true : false
const requestIsAny: IsAny<RequestMessage> = false
const messageIsAny: IsAny<Message> = false
const inputIsAny: IsAny<RequestUserInput> = false
// @ts-expect-error -- request inputs cannot provide identity without attribution.
const onlyId: RequestMessage = { role: 'user', content: [], id }
// @ts-expect-error -- request inputs cannot provide attribution without identity.
const onlySource: RequestMessage = { role: 'user', content: [], source }
// @ts-expect-error -- exact optional fields reject explicitly undefined identity.
const undefinedId: RequestUserInput = { ...input, id: undefined }
// @ts-expect-error -- exact optional fields reject explicitly undefined attribution.
const undefinedSource: RequestUserInput = { ...input, source: undefined }
// @ts-expect-error -- only user inputs have a request-only alternative.
const assistant: RequestMessage = { role: 'assistant', content: [] }
// @ts-expect-error -- system prompts retain their durable identity and source.
const system: RequestMessage = { role: 'system', content: [] }
// @ts-expect-error -- developer instructions retain their durable identity and source.
const developer: RequestMessage = { role: 'developer', content: [] }
// @ts-expect-error -- tool results retain their durable identity and correlation.
const tool: RequestMessage = { role: 'tool', content: [] }
// @ts-expect-error -- constructing a durable user message requires its source.
createUserMessage(input)
// @ts-expect-error -- Session writes require a durable user message.
session.append('user/message', input, { surfaceOp: 'append' })
// @ts-expect-error -- followups enter durable Session history.
agent.followup(input)
// @ts-expect-error -- injected context enters durable Session history.
agent.inject(input)
// @ts-expect-error -- title requests record their exact durable messages.
const titleMessages: SessionTitleLlmRequestEventData['messages'] = [input]
// @ts-expect-error -- the title event cannot write a request-only input.
session.append('session/title-llm-request', { ...title, messages: [input] })
// @ts-expect-error -- image projections retain request-only inputs and cannot manufacture identity.
const projectedDurable: Message = images[0]!
// @ts-expect-error -- adding an id to a request input does not create a durable message.
session.append('user/message', { ...input, id }, { surfaceOp: 'append' })
// @ts-expect-error -- adding a source to a request input does not create a durable message.
session.append('user/message', { ...input, source }, { surfaceOp: 'append' })
const narrowed = [{ role: 'user', content: [{ type: 'text', text: 'fixed' }] }] as const
// @ts-expect-error -- projections may replace content and cannot preserve narrower content types.
const narrowedProjection: typeof narrowed = projectImagesForTextModel(narrowed)
void [request, files, images, offloaded, durableProjection, existing]
`
it('accepts mixed requests and rejects lightweight inputs at durable message writes', { timeout: 60_000 }, () => {
const parsed = ts.getParsedCommandLineOfConfigFile(resolve(root, 'tsconfig.host.json'), {}, {
...ts.sys,
onUnRecoverableConfigFileDiagnostic(diagnostic) {
throw new Error(ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n'))
},
})
expect(parsed).toBeDefined()
const options: ts.CompilerOptions = {
...parsed!.options,
composite: false,
incremental: false,
noEmit: true,
declaration: false,
declarationMap: false,
noUnusedLocals: false,
noUnusedParameters: false,
}
const base = ts.createCompilerHost(options, true)
const host: ts.CompilerHost = {
...base,
fileExists: path => resolve(path) === probePath || base.fileExists(path),
readFile: path => resolve(path) === probePath ? probe : base.readFile(path),
getSourceFile(path, languageVersion, onError, shouldCreateNewSourceFile) {
return resolve(path) === probePath
? ts.createSourceFile(path, probe, languageVersion, true)
: base.getSourceFile(path, languageVersion, onError, shouldCreateNewSourceFile)
},
}
const program = ts.createProgram([probePath], options, host)
for (const path of ['packages/llm/llm/src/types.ts', 'packages/core/session/src/index.ts', 'packages/session/session-title-llm/src/index.ts']) {
expect(program.getSourceFile(resolve(root, path)), path).toBeDefined()
}
const source = program.getSourceFile(probePath)!
const diagnostics = [...program.getSyntacticDiagnostics(source), ...program.getSemanticDiagnostics(source)]
expect(diagnostics.map((diagnostic) => {
const line = source.getLineAndCharacterOfPosition(diagnostic.start ?? 0).line + 1
return `${line}: ${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`
})).toEqual([])
})
+9
View File
@@ -78,6 +78,8 @@ interface GenericSkip {
const GENERIC_SKIPS: readonly GenericSkip[] = [
// `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers.
{ file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] },
// Narrows a Standard Schema by the same upstream `vendor:` identifier.
{ file: 'vendor/loader/src/config/diff.ts', upstream: ['schemastery'] },
// Asserts the vendored-manifest table, which gains an upstream-name column.
{ file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) },
// `cordis` is also an agent-preset id — the directory name under
@@ -172,6 +174,13 @@ const POSTCONDITIONS: readonly PostCondition[] = [
* quote a neighbouring line the generic pass would rewrite.
*/
const EXACT_EDITS: readonly ExactEdit[] = [
{
id: 'loader-diff-schemastery-import',
file: 'vendor/loader/src/config/diff.ts',
find: "import type Schema from 'schemastery'",
replace: "import type Schema from '@deepseek-ai/schemastery'",
expect: 1,
},
{
id: 'cordis-walk-merge-head',
file: 'scripts/cordis-walk.ts',
+13
View File
@@ -319,6 +319,7 @@ describe('gate graph validation', () => {
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'package-dependencies', 'application-entrypoints',
'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types',
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
'no-unknown-casts',
'cordis-config', 'runtime-closure',
])
})
@@ -360,6 +361,16 @@ describe('gate graph validation', () => {
},
)
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
'rejects new unknown casts in %s',
(mode) => {
const gate = withPnpmEntrypoint(() => gatesForMode(mode).find(subject => subject.id === 'no-unknown-casts'))
expect(gate?.args).toContain('verify-no-unknown-casts')
expect(gate?.allowFailure).not.toBe(true)
},
)
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
'keeps package dependency enforcement in %s',
(mode) => {
@@ -752,11 +763,13 @@ describe('Node 24 lane ownership', () => {
}
expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
expect(subject.find(item => item.id === 'expected-output')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
expect(subject.find(item => item.id === 'built-bin-smoke')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
expect(subject.find(item => item.id === 'doc-typecheck')?.env).toEqual({
DSH_DOC_TYPECHECK_USE_BUILD_OUTPUT: '1',
})
expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual(
expect.arrayContaining([
'apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts',
'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
+3
View File
@@ -347,6 +347,7 @@ function ciSharedStaticGates(): Gate[] {
pnpmScript('package-dependencies', 'verify-package-dependencies', { label: 'package dependencies' }),
pnpmScript('dsh-package-licenses', 'verify-dsh-package-licenses', { label: 'DSH package licenses' }),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
pnpmScript('package-meta', 'verify-package-meta', { label: 'package metadata' }),
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
...sharedHygieneGates(),
pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
@@ -363,6 +364,7 @@ function sharedHygieneGates(): Gate[] {
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }),
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
pnpmScript('no-unknown-casts', 'verify-no-unknown-casts', { label: 'no new unknown casts' }),
]
}
@@ -849,6 +851,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
'--config',
'vitest.e2e.config.ts',
'apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts',
'apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts',
'apps/cli/tests/built-bin.e2e.ts',
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
'packages/sdk/server/tests/built-scope-carrier.e2e.ts',
+28 -8
View File
@@ -2,7 +2,8 @@ import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { createAssistantMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay'
import { parseSessionLog, prepareSessionSnapshotFixtureForComparison } from '@deepseek-ai/dsh-llm-replay'
import { scrubSessionSnapshot } from '@deepseek-ai/dsh-session-snapshot'
import {
canonicalSessionFixture,
inspectSessionFixtureLayouts,
@@ -11,6 +12,7 @@ import {
const HEADER = ` {"type":"session","version":${SESSION_FORMAT_VERSION},"id":"fixture","createdAt":1,"isSeeded":false,"delegationDepth":0} `
const root = resolve(import.meta.dirname, '..')
const historicalVersions = Array.from({ length: SESSION_FORMAT_VERSION }, (_, version) => version)
const FIXTURE_MESSAGE = createAssistantMessage({
content: [{ type: 'text', text: 'part-0part-1part-2part-3' }],
source: { provider: 'mock', model: 'mock' },
@@ -58,6 +60,24 @@ function decodedBody(content: string): SessionEvent[] {
}
describe('canonicalSessionFixture', () => {
it.each([
{ sources: [0, 1, 2] },
{ sources: [0, 2] },
{ sources: [2, 0, 1] },
])('writes canonical snapshots preserving source reference order: $sources', ({ sources }) => {
const event = {
type: 'user/message', seq: 3, time: 15,
data: { id: 'fixture-user', role: 'user', source: { kind: 'user' }, content: [] },
sourceEventSeqs: sources, surfaceOp: 'append',
}
const raw = [HEADER, ...[...fixtureEvents(), event].map(value => JSON.stringify(value)), ''].join('\n')
const written = scrubSessionSnapshot(prepareSessionSnapshotFixtureForComparison(raw))
expect(canonicalSessionFixture(written)).toBe(written)
expect(decodedBody(written).at(-1)?.sourceEventSeqs).toEqual(sources)
expect(scrubSessionSnapshot(written)).toBe(written)
})
it('preserves the header line and nested compact stream losslessly', () => {
const canonical = canonicalSessionFixture(unpackedFixture(), 'fixture.jsonl')
expect(canonical).toBeDefined()
@@ -130,11 +150,11 @@ describe('canonicalSessionFixture', () => {
].join('\n')
const canonical = canonicalSessionFixture(source)
expect(canonical).toBe(source)
expect(() => decodedBody(canonical!)).toThrow(/session snapshot line 3:.*empty optional header fields must be omitted/)
expect(() => decodedBody(canonical!)).toThrow(/session snapshot line 1:.*seed request\/header.*empty tools/)
})
it.each([0, 1, 2])('preserves physically valid v%i bytes without requiring migration to current', (version) => {
const header = { type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version === 2 ? { isSeeded: false } : {}) }
it.each(historicalVersions)('preserves physically valid v%i bytes without requiring migration to current', (version) => {
const header = { type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version >= 2 ? { isSeeded: false } : {}) }
const content = [
JSON.stringify(header),
JSON.stringify({ type: 'user/message', data: { role: 'user', id: 'historical-user', source: { kind: 'user' }, content: [] }, surfaceOp: 'append' }),
@@ -143,16 +163,16 @@ describe('canonicalSessionFixture', () => {
expect(canonicalSessionFixture(content)).toBe(content)
})
it.each([0, 1, 2])('rejects v%i sequence gaps and invalid source-event ranges with source line diagnostics', (version) => {
const header = JSON.stringify({ type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version === 2 ? { isSeeded: false } : {}) })
it.each(historicalVersions)('rejects v%i sequence gaps and invalid source-event ranges with source line diagnostics', (version) => {
const header = JSON.stringify({ type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version >= 2 ? { isSeeded: false } : {}) })
expect(() => canonicalSessionFixture(`${header}\n{"type":"feedback/record","seq":3,"data":{"text":"gap"}}\n`, 'gap.jsonl'))
.toThrow(/gap\.jsonl: session snapshot line 2:.*seq/)
expect(() => canonicalSessionFixture(`${header}\n{"type":"feedback/record","data":{},"sourceEventSeqs":[[2,0]]}\n`, 'range.jsonl'))
.toThrow(/range\.jsonl: session snapshot line 2:/)
})
it.each([0, 1, 2])('finalizes the v%i source inherited cut', (version) => {
const header = { type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version === 2 ? { isSeeded: true } : { seedLength: 1 }) }
it.each(historicalVersions)('finalizes the v%i source inherited cut', (version) => {
const header = { type: 'session', version, id: 'historical', createdAt: 1, delegationDepth: 0, ...(version >= 2 ? { isSeeded: true } : { seedLength: 1 }) }
expect(() => canonicalSessionFixture(`${JSON.stringify(header)}\n`, 'cut.jsonl'))
.toThrow(/cut\.jsonl: session snapshot line 1:.*(?:inherited|seed)/)
})
+5 -3
View File
@@ -14,9 +14,11 @@ import { sessionFormatCatalog } from '@deepseek-ai/dsh-session-format-catalog'
import { SessionFormatEventCollector, type SessionFormatArtifactDecoder, type SessionFormatCodec } from '@deepseek-ai/dsh-session-format'
import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v0-to-v1'
import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2'
import { releasedV3SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v2-to-v3'
const historicalCodecs: readonly SessionFormatCodec[] = [
releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec,
releasedV3SessionFormatCodec,
]
/** Physical persistence artifacts validated by the WebWorker runtime fixture spec. */
@@ -69,7 +71,7 @@ function renderFixture(headerLine: string, events: readonly SessionEvent[]): str
return [
headerLine,
...events.map((event) => {
const record = { ...event } as unknown as Record<string, unknown>
const record: Record<string, unknown> = { ...event }
delete record.seq
delete record.time
return JSON.stringify(record)
@@ -89,7 +91,7 @@ function projectedRowCardinality(record: Readonly<Record<string, unknown>>): num
function parseFixtureObjectLine(line: string, lineNumber: number): Record<string, unknown> {
let value: unknown
try {
value = JSON.parse(line) as unknown
value = JSON.parse(line)
} catch (error) {
throw new Error(`session snapshot line ${lineNumber} contains invalid JSON`, { cause: error })
}
@@ -222,7 +224,7 @@ export function canonicalSessionFixture(content: string, label = '<session-fixtu
let headerValue: unknown
try {
headerValue = JSON.parse(headerLine) as unknown
headerValue = JSON.parse(headerLine)
} catch {
return undefined
}
+40 -19
View File
@@ -11,17 +11,18 @@ const completeV0 = {
},
} as const
const adjacent = Array.from({ length: SESSION_FORMAT_VERSION - 1 }, (_, index) => ({
key: `sdk/v${index + 1}`,
selectedVersions: [index + 1],
retained: { version: index + 1, coverage: ['adjacent-migration'] as const },
const adjacent = [1, 2].map(version => ({
key: `sdk/v${version}`,
selectedVersions: [version],
retained: { version, coverage: ['adjacent-migration'] as const },
}))
const baseline = { key: 'session/v3', selectedVersions: Array<number>(8).fill(3) }
const current = { key: 'session/current', selectedVersions: Array<number>(8).fill(SESSION_FORMAT_VERSION) }
describe('recorded-session corpus policy', () => {
it('accepts a current majority and complete bounded migration coverage', () => {
it('keeps a V3 majority as migration input without requiring current-generation successors', () => {
expect(assertSnapshotCorpusPolicy([
current,
baseline,
{
key: 'session/multi-hop',
selectedVersions: [0, 0, 0],
@@ -30,7 +31,17 @@ describe('recorded-session corpus policy', () => {
{ key: 'session/packed', selectedVersions: [0], retained: { version: 0, coverage: ['packed-row'] } },
{ key: 'session/retry', selectedVersions: [0], retained: { version: 0, coverage: ['retry-failure'] } },
...adjacent,
])).toEqual({ currentRoles: 8, retainedRoles: 5 + adjacent.length, retainedScenarios: 3 + adjacent.length })
])).toEqual({ currentRoles: 0, baselineRoles: 8, retainedRoles: 5 + adjacent.length, retainedScenarios: 3 + adjacent.length })
})
it('accepts current-writer owners alongside retained V3 replay input', () => {
expect(assertSnapshotCorpusPolicy([baseline, current, completeV0, ...adjacent]))
.toEqual({ currentRoles: 8, baselineRoles: 8, retainedRoles: 3, retainedScenarios: 3 })
})
it('rejects refreshing away every direct V3 migration input', () => {
expect(() => assertSnapshotCorpusPolicy([current, completeV0, ...adjacent]))
.toThrow('Session corpus lacks v3 coverage: adjacent-migration')
})
it('retains retired tools in the current format without claiming migration coverage', () => {
@@ -38,7 +49,7 @@ describe('recorded-session corpus policy', () => {
key: 'web/retired', selectedVersions: [SESSION_FORMAT_VERSION],
retained: { version: SESSION_FORMAT_VERSION, coverage: ['retired-tools'] as const },
}
expect(assertSnapshotCorpusPolicy([current, completeV0, ...adjacent, retired]).retainedScenarios)
expect(assertSnapshotCorpusPolicy([current, baseline, completeV0, ...adjacent, retired]).retainedScenarios)
.toBe(2 + adjacent.length)
expect(() => assertSnapshotCorpusPolicy([current, completeV0, retired]))
.toThrow('coverage: adjacent-migration')
@@ -49,19 +60,19 @@ describe('recorded-session corpus policy', () => {
})
it('requires v0 coverage from v0 fixtures', () => {
expect(() => assertSnapshotCorpusPolicy([current, ...adjacent]))
expect(() => assertSnapshotCorpusPolicy([baseline, ...adjacent]))
.toThrow('Session corpus lacks v0 coverage')
})
it.each(adjacent)('requires direct coverage from $key', (fixture) => {
expect(() => assertSnapshotCorpusPolicy([
current, completeV0, ...adjacent.filter(other => other !== fixture),
baseline, completeV0, ...adjacent.filter(other => other !== fixture),
])).toThrow(`Session corpus lacks v${fixture.retained.version} coverage: adjacent-migration`)
})
it('does not let one retained generation claim another edge\'s coverage', () => {
expect(() => assertSnapshotCorpusPolicy([
current,
baseline,
completeV0,
{
key: 'sdk/adjacent',
@@ -78,9 +89,16 @@ describe('recorded-session corpus policy', () => {
expect(() => assertSnapshotCorpusPolicy([{ key: 'session/empty', selectedVersions: [] }]))
.toThrow('session/empty: scenario owns no selected Session role')
expect(() => assertSnapshotCorpusPolicy([{ key: 'session/old', selectedVersions: [1] }]))
.toThrow(`session/old: selected Session generation v1 does not match expected v${SESSION_FORMAT_VERSION}`)
.toThrow(`session/old: selected Session generation v1 must be retained baseline v3 or current v${SESSION_FORMAT_VERSION}`)
expect(() => assertSnapshotCorpusPolicy([{ ...completeV0, selectedVersions: [0, SESSION_FORMAT_VERSION] }]))
.toThrow(`session/v0: selected Session generation v${SESSION_FORMAT_VERSION} does not match expected v0`)
expect(() => assertSnapshotCorpusPolicy([{ ...baseline, selectedVersions: [3, SESSION_FORMAT_VERSION] }]))
.toThrow(`session/v3: selected Session generation v${SESSION_FORMAT_VERSION} does not match expected v3`)
})
it('rejects future unpinned generations', () => {
expect(() => assertSnapshotCorpusPolicy([{ key: 'session/future', selectedVersions: [SESSION_FORMAT_VERSION + 1] }]))
.toThrow(`session/future: selected Session generation v${SESSION_FORMAT_VERSION + 1} must be retained baseline v3 or current v${SESSION_FORMAT_VERSION}`)
})
it.each([SESSION_FORMAT_VERSION, SESSION_FORMAT_VERSION + 1, -1, 0.5])(
@@ -92,14 +110,17 @@ describe('recorded-session corpus policy', () => {
},
)
it('bounds historical roles and requires a current majority', () => {
it('bounds explicitly retained roles and requires a baseline/current majority', () => {
expect(assertSnapshotCorpusPolicy([
baseline, current,
{ ...completeV0, selectedVersions: Array<number>(9).fill(0) }, ...adjacent,
]).retainedRoles).toBe(11)
expect(() => assertSnapshotCorpusPolicy([
{ ...current, selectedVersions: Array<number>(20).fill(SESSION_FORMAT_VERSION) },
{ ...completeV0, selectedVersions: Array<number>(11).fill(0) },
...adjacent,
])).toThrow(`Session corpus retains ${11 + adjacent.length} historical roles; maximum is 10`)
baseline, current,
{ ...completeV0, selectedVersions: Array<number>(10).fill(0) }, ...adjacent,
])).toThrow('Session corpus retains 12 historical roles; maximum is 11')
expect(() => assertSnapshotCorpusPolicy([
{ ...current, selectedVersions: [SESSION_FORMAT_VERSION] }, completeV0, ...adjacent,
])).toThrow(`Session corpus requires a current majority; current=1, retained=${1 + adjacent.length}`)
{ ...baseline, selectedVersions: [3] }, completeV0, ...adjacent,
])).toThrow(`Session corpus requires a baseline/current majority; baseline=1, current=0, retained=${1 + adjacent.length}`)
})
})
+23 -10
View File
@@ -1,4 +1,4 @@
/** Enforced current-writer majority and retained migration coverage. */
/** Retained V3 replay inputs, current-writer fixtures, and bounded older migration coverage. */
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import type { SnapshotSessionFormatManifest } from '@deepseek-ai/dsh-session-snapshot'
@@ -16,11 +16,13 @@ export interface SnapshotCorpusScenarioGenerations {
/** Counts returned after the corpus policy accepts the inventory. */
export interface SnapshotCorpusGenerationSummary {
readonly currentRoles: number
readonly baselineRoles: number
readonly retainedRoles: number
readonly retainedScenarios: number
}
const MAX_RETAINED_ROLES = 10
const RETAINED_BASELINE_VERSION = 3
const MAX_RETAINED_ROLES = 11
const REQUIRED_V0_COVERAGE = new Set([
'multi-hop',
'packed-row',
@@ -30,24 +32,26 @@ const REQUIRED_V0_COVERAGE = new Set([
const REQUIRED_ADJACENT_COVERAGE = new Set(['adjacent-migration'])
/**
* Require a current-writer majority and bounded coverage of each released migration source.
* Require a baseline/current majority and direct coverage of each historical migration source.
*
* @param scenarios - Every owning top-level recorded-session scenario.
* @returns Accepted current and retained role counts.
* @returns Accepted current, baseline, and explicitly retained role counts.
*/
export function assertSnapshotCorpusPolicy(
scenarios: readonly SnapshotCorpusScenarioGenerations[],
): SnapshotCorpusGenerationSummary {
let currentRoles = 0
let baselineRoles = 0
let retainedRoles = 0
let retainedScenarios = 0
const coverageByVersion = new Map<number, Set<string>>()
for (const scenario of scenarios) {
if (scenario.selectedVersions.length === 0) {
const selectedVersion = scenario.selectedVersions[0]
if (selectedVersion === undefined) {
throw new Error(`${scenario.key}: scenario owns no selected Session role`)
}
const expectedVersion = scenario.retained?.version ?? SESSION_FORMAT_VERSION
const expectedVersion = scenario.retained?.version ?? selectedVersion
const mismatched = scenario.selectedVersions.find(version => version !== expectedVersion)
if (mismatched !== undefined) {
throw new Error(
@@ -55,7 +59,15 @@ export function assertSnapshotCorpusPolicy(
)
}
if (scenario.retained === undefined) {
currentRoles += scenario.selectedVersions.length
if (selectedVersion === RETAINED_BASELINE_VERSION) {
baselineRoles += scenario.selectedVersions.length
} else if (selectedVersion === SESSION_FORMAT_VERSION) {
currentRoles += scenario.selectedVersions.length
} else {
throw new Error(
`${scenario.key}: selected Session generation v${selectedVersion} must be retained baseline v${RETAINED_BASELINE_VERSION} or current v${SESSION_FORMAT_VERSION}`,
)
}
continue
}
const retiredTools = scenario.retained.coverage.length === 1 && scenario.retained.coverage[0] === 'retired-tools'
@@ -79,6 +91,7 @@ export function assertSnapshotCorpusPolicy(
for (const item of scenario.retained.coverage) coverage.add(item)
}
if (baselineRoles > 0) coverageByVersion.set(RETAINED_BASELINE_VERSION, new Set(REQUIRED_ADJACENT_COVERAGE))
for (let version = 0; version < SESSION_FORMAT_VERSION; version += 1) {
const required = version === 0 ? REQUIRED_V0_COVERAGE : REQUIRED_ADJACENT_COVERAGE
const coverage = coverageByVersion.get(version)
@@ -90,10 +103,10 @@ export function assertSnapshotCorpusPolicy(
if (retainedRoles > MAX_RETAINED_ROLES) {
throw new Error(`Session corpus retains ${retainedRoles} historical roles; maximum is ${MAX_RETAINED_ROLES}`)
}
if (currentRoles <= retainedRoles) {
if (baselineRoles + currentRoles <= retainedRoles) {
throw new Error(
`Session corpus requires a current majority; current=${currentRoles}, retained=${retainedRoles}`,
`Session corpus requires a baseline/current majority; baseline=${baselineRoles}, current=${currentRoles}, retained=${retainedRoles}`,
)
}
return { currentRoles, retainedRoles, retainedScenarios }
return { currentRoles, baselineRoles, retainedRoles, retainedScenarios }
}
+5 -3
View File
@@ -191,7 +191,7 @@ it('keeps every recorded session owned, pinned, redacted, and header-scrubbed',
}
})
it('keeps a current-writer majority plus bounded declared historical migration coverage', async () => {
it('keeps V3 replay input plus bounded declared historical migration coverage', async () => {
const owners = (await scenarios()).filter(scenario => scenario.manifest.session === undefined)
const inventory = await Promise.all(owners.map(async scenario => ({
key: scenario.key,
@@ -201,8 +201,10 @@ it('keeps a current-writer majority plus bounded declared historical migration c
: { retained: scenario.manifest.sessionFormat }),
})))
expect(assertSnapshotCorpusPolicy(inventory)).toMatchObject({
retainedRoles: 10,
const summary = assertSnapshotCorpusPolicy(inventory)
expect(summary.baselineRoles).toBeGreaterThan(0)
expect(summary).toMatchObject({
retainedRoles: 11,
retainedScenarios: 8,
})
})
+140 -11
View File
@@ -44,6 +44,7 @@ MCP_TEXT = "MCP client smoke ok"
PROFILE_PLUGIN_PROMPT = "Verify the Python-installed dsh profile plugin."
PROFILE_PLUGIN_TEXT = "profile plugin smoke ok"
PROFILE_PLUGIN_MARKER = "PYTHON_INSTALLED_DSH_PROFILE_PLUGIN"
AUTHORING_PROMPT = "Query the packaged Python environment for Office authoring."
IS_WINDOWS = sys.platform == "win32"
MINIMAL_SHELL_TOOL = "pwsh" if IS_WINDOWS else "bash"
MINIMAL_SHELL_COMMAND = (
@@ -77,6 +78,8 @@ LEGACY_CUSTOM_DISABLED_ROWS = (
"plan-mode",
"skill",
"skill-filesystem",
"skill-office",
"workspace-dependencies",
"tool-fs",
"tool-fs-search",
"tool-goal",
@@ -315,6 +318,10 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
result = tool_results[-1]
call_id, tool_name = latest_tool_call(messages, result.get("tool_use_id"))
tool_text = message_text(result.get("content"))
if tool_name == "load_workspace_dependencies":
if result.get("is_error") or "python" not in json.loads(tool_text):
raise AssertionError(f"packaged Python query failed: {tool_text}")
return text_chunks(EXPECTED_TEXT)
mcp = mcp_tool_followup(call_id, tool_name, tool_text)
if mcp is not None:
return mcp
@@ -366,6 +373,7 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
RESTART_FIRST_PROMPT,
RESTART_SECOND_PROMPT,
PROFILE_PLUGIN_PROMPT,
AUTHORING_PROMPT,
}
prompt = next(
(candidate for candidate in user_prompts if candidate in scenario_prompts),
@@ -373,6 +381,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
)
if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
return text_chunks("DIRECT_CHILD_OK")
if prompt == AUTHORING_PROMPT:
assert_advertised_tool(body, "load_workspace_dependencies")
return tool_call_chunks("authoring-runtime", "load_workspace_dependencies", {})
if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
return text_chunks("WORKFLOW_CHILD_OK")
if prompt == SNAPSHOT_PROMPT:
@@ -709,7 +720,7 @@ def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--scenario",
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-live", "runner", "direct"),
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-office", "sdk-authoring", "sdk-live", "runner", "direct"),
default="all",
)
parser.add_argument("--exe", type=Path)
@@ -728,10 +739,10 @@ def main() -> None:
parser.error("--scenario sdk-profile-plugin requires --installed-wheel")
if args.installed_wheel:
args.exe = assert_installed_wheel_environment()
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "runner", "direct"} and args.exe is None:
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-minimal-in-history", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "sdk-office", "sdk-authoring", "runner", "direct"} and args.exe is None:
parser.error("--exe is required for custom, minimal, fs-search, spawn-node, snapshot, restart, office, runner, and direct scenarios")
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-snapshot", "sdk-restart"}:
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-snapshot, sdk-restart, or all")
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-minimal-in-history", "sdk-snapshot", "sdk-restart", "sdk-authoring"}:
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-minimal-in-history, sdk-snapshot, sdk-restart, sdk-authoring, or all")
if args.exe is not None and not args.exe.is_file():
parser.error(f"runtime executable does not exist: {args.exe}")
@@ -755,6 +766,9 @@ def main() -> None:
return
with MockModel() as model:
if args.scenario in {"all", "sdk-authoring"}:
assert args.exe is not None
smoke_sdk_authoring(model.url, args.exe.resolve(), args.update_snapshots)
if args.scenario in {"all", "sdk-default"}:
smoke_sdk_default(model.url)
if args.scenario in {"all", "sdk-custom"}:
@@ -790,6 +804,65 @@ def main() -> None:
print(f"smoke-python-runtime: {args.scenario} passed")
def smoke_sdk_authoring(base_url: str, executable: Path, update_snapshots: bool) -> None:
"""Query the bundled Python and switch skills without replacing that environment."""
from deepseek_harness import DeepSeekHarness
resources = executable.with_name(executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe"))
manifest = json.loads((resources / "primary-runtime/runtime.json").read_text())
for mode in ("default", "replacement", "disabled"):
with tempfile.TemporaryDirectory(prefix="dsh-sdk-authoring-") as temporary:
root = Path(temporary).resolve()
home = root / "home"
if mode == "replacement":
skill = home / "skills/office-docx/SKILL.md"
skill.parent.mkdir(parents=True)
skill.write_text("---\nname: office-docx\ndescription: CUSTOM_OFFICE_DOCX\n---\nUse the bundled Python for custom document work.\n")
patch = root / "skills.patch.yml"
patch.write_text(json.dumps([{"id": "skill-office", "disabled": True}] if mode == "disabled" else []))
first = len(MockModelHandler.requests)
with DeepSeekHarness(
provider="deepseek-official", model="smoke-model", cwd=str(root),
dsh_bin=str(executable), dsh_home=str(home), patches=(str(patch),),
api_key="sk-keyless-smoke", base_url=base_url,
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
request_timeout_seconds=60,
) as harness:
result = harness.run(AUTHORING_PROMPT, session_id="authoring")
assert result.final_response == EXPECTED_TEXT, result.final_response
requests = MockModelHandler.requests[first:]
assert len(requests) == 2, requests
prompt = json.dumps(requests[0], ensure_ascii=False)
for name in ("office-docx", "office-pptx", "office-xlsx"):
assert (name in prompt) == (mode != "disabled"), (mode, name)
assert ("CUSTOM_OFFICE_DOCX" in prompt) == (mode == "replacement"), mode
tool_result = next(block for message in requests[1]["messages"]
for block in message.get("content", [])
if isinstance(block, dict) and block.get("tool_use_id") == "authoring-runtime")
dependencies = json.loads(message_text(tool_result["content"]))
python = Path(dependencies["python"])
assert python.is_relative_to(resources), dependencies
assert "node" not in dependencies and "pnpm" not in dependencies, dependencies
assert dependencies["pythonDistributions"] == manifest["pythonPackages"], dependencies
assert not (home / "dsh-runtimes").exists()
if mode == "default":
subprocess.run([
str(python), "-I", "-B", str(Path(__file__).parent / "primary-runtime/smoke.py"),
json.dumps(manifest["pythonPackages"]), manifest["python"],
str(resources / "office-skills/scripts/check_office.py"),
], check=True, timeout=120,
env={name: value for name, value in os.environ.items()
if not re.search(r"KEY|SECRET|TOKEN|PASSWORD", name, re.I)})
schema = next(tool for tool in requests[0]["tools"] if tool.get("name") == "load_workspace_dependencies")
visible = {"tool": schema, "result": {**dependencies, "python": "{{python}}", "pythonPackages": "{{site-packages}}"}}
compare_snapshot_files(
{"model-visible.json": json.dumps(visible, indent=2, ensure_ascii=False) + "\n"},
update_snapshots, Path(__file__).parent / "snapshots/python-sdk-single-exe/authoring",
("model-visible.json",),
)
print("smoke-python-runtime: bundled Python, default skills, replacement and disabled skills passed")
def smoke_sdk_office(executable: Path) -> None:
"""Relocate the wheel payload and convert a real DOCX with the target platform engine."""
from deepseek_harness import DeepSeekHarness
@@ -798,7 +871,8 @@ def smoke_sdk_office(executable: Path) -> None:
root = Path(temporary).resolve()
relocated = root / executable.name
stem = executable.name.removesuffix(".exe")
for source in executable.parent.glob(f"{stem}*"):
resources = executable.with_name(stem.removeprefix("deepseek-harness-sdk-runtime-"))
for source in [*executable.parent.glob(f"{stem}*"), resources]:
destination = root / source.name
if source.is_dir():
shutil.copytree(source, destination)
@@ -843,6 +917,8 @@ def smoke_sdk_office(executable: Path) -> None:
api_key="sk-keyless-smoke",
base_url="http://127.0.0.1:9",
env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"},
# The startup plugin awaits a converter with a 120-second deadline before JSON-RPC is ready.
initialize_timeout_seconds=180,
request_timeout_seconds=180,
):
pass
@@ -1423,6 +1499,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
files = build_snapshot_files(result, logs, child_ids, root)
compare_snapshot_files(
files, update_snapshots, ADVANCED_SNAPSHOT_DIRECTORY, ADVANCED_SNAPSHOT_FILENAMES,
native_writer_output=True,
)
@@ -1489,6 +1566,7 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
)
compare_snapshot_files(
files, update_snapshots, RESTART_SNAPSHOT_DIRECTORY, RESTART_SNAPSHOT_FILENAMES,
native_writer_output=True,
)
@@ -2227,7 +2305,7 @@ def normalize_snapshot_value(
dt = member.get("dt")
if isinstance(dt, list):
member["dt"] = [0] * len(dt)
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "system", "user"):
if isinstance(normalized.get("id"), str) and normalized.get("role") in ("assistant", "system", "user", "tool", "developer"):
if not normalized["id"].startswith("{{message:"):
normalized["id"] = "{{messageId}}"
if normalized.get("type") in ("feedback/message-put", "feedback/message-delete"):
@@ -2292,6 +2370,7 @@ def project_session_snapshot(records: list[dict[str, object]]) -> list[dict[str,
SESSION_FORMAT_TOKEN = "{{sessionFormatVersion}}"
NATIVE_DELIVERY_FORMAT_TOKEN = "{{sourceSessionFormatVersion}}"
def expand_snapshot_stream_member(member: object) -> list[dict[str, object]]:
@@ -2414,7 +2493,31 @@ def normalize_session_format_comparison(
return normalized
def normalize_snapshot_comparison_text(name: str, content: str) -> str:
def normalize_native_delivery_record(value: object, source_version: int) -> object:
"""Tokenize a native delivery qualifier in one Session event or SDK notification."""
if not isinstance(value, dict):
return value
if value.get("method") == "session.event":
for key in ("payload", "params"):
wrapper = value.get(key)
if isinstance(wrapper, dict) and "event" in wrapper:
return {**value, key: {
**wrapper,
"event": normalize_native_delivery_record(wrapper["event"], source_version),
}}
data = value.get("data")
if value.get("type") != "session-log-deepseek/delivery-accepted" or not isinstance(data, dict):
return value
if data.get("sessionFormatVersion") != source_version:
return value
return {**value, "data": {**data, "sessionFormatVersion": NATIVE_DELIVERY_FORMAT_TOKEN}}
def normalize_snapshot_comparison_text(
name: str,
content: str,
native_writer_version: int | None = None,
) -> str:
"""Normalize Session generation metadata only while comparing committed expected outputs."""
if name.startswith("session") and name.endswith(".jsonl"):
parsed = [json.loads(line) for line in content.splitlines() if line]
@@ -2422,6 +2525,8 @@ def normalize_snapshot_comparison_text(name: str, content: str) -> str:
source_version = header.get("version") if isinstance(header, dict) else None
if not isinstance(source_version, int):
raise AssertionError(f"{name}: snapshot Session header has no integer format version")
if native_writer_version is not None:
parsed = [normalize_native_delivery_record(record, native_writer_version) for record in parsed]
records = [
normalize_session_format_comparison(expanded, source_version)
for record in parsed
@@ -2429,8 +2534,18 @@ def normalize_snapshot_comparison_text(name: str, content: str) -> str:
]
return render_jsonl(records)
if name.endswith(".json"):
value = json.loads(content)
if name == "result.json" and native_writer_version is not None and isinstance(value, dict):
value = {
**value,
**{
key: [normalize_native_delivery_record(record, native_writer_version) for record in value[key]]
for key in ("events", "notifications")
if isinstance(value.get(key), list)
},
}
return json.dumps(
normalize_session_format_comparison(json.loads(content)),
normalize_session_format_comparison(value),
indent=2,
ensure_ascii=False,
) + "\n"
@@ -2442,8 +2557,10 @@ def compare_snapshot_files(
update: bool,
directory: Path,
filenames: tuple[str, ...],
*,
native_writer_output: bool = False,
) -> None:
"""Compare ordered artifact roles and Session content across generations, or write generated filenames."""
"""Compare artifact roles and content, optionally matching each side's native delivery generation."""
scenario = directory.name
def role_name(name: str) -> str:
@@ -2495,12 +2612,24 @@ def compare_snapshot_files(
f"{scenario} snapshot Session roles differ: "
f"expected={sorted(selected_expected)}, actual={sorted(actual_sessions)}",
)
actual_native_version = expected_native_version = None
if native_writer_output:
def common_generation(contents: list[tuple[str, str]]) -> int:
versions = {session_header_version(content, name) for name, content in contents}
if len(versions) != 1:
raise AssertionError(f"{scenario}: native writer comparison requires one Session generation across all roles")
return versions.pop()
actual_native_version = common_generation(list(actual_sessions.values()))
expected_native_version = common_generation([
(path.name, path.read_text(encoding="utf-8")) for path in selected_expected.values()
])
for name, actual in files.items():
parsed = parse_snapshot_session_filename(name)
expected_path = directory / name if parsed is None else selected_expected[parsed[0]]
expected_text = expected_path.read_text(encoding="utf-8")
compared_actual = normalize_snapshot_comparison_text(name, actual)
compared_expected = normalize_snapshot_comparison_text(expected_path.name, expected_text)
compared_actual = normalize_snapshot_comparison_text(name, actual, actual_native_version)
compared_expected = normalize_snapshot_comparison_text(expected_path.name, expected_text, expected_native_version)
if compared_actual == compared_expected:
continue
diff = "".join(difflib.unified_diff(
@@ -81,8 +81,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "@deepseek-ai/dsh-system-prompt"
"kind": "system-prompt"
},
"id": "{{messageId}}"
}
@@ -103,8 +102,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "serial-created-fixture"
"kind": "plugin:serial-created-fixture"
}
},
"surfaceOp": "append"
@@ -140,8 +138,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "@deepseek-ai/dsh-system-prompt",
"kind": "runtime-context",
"form": "snapshot",
"sections": [
{
@@ -179,8 +176,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "python-snapshot-image-offload"
"kind": "python-snapshot-image-offload"
}
},
"surfaceOp": "append"
@@ -318,7 +314,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 19
}
},
@@ -501,24 +497,19 @@
"turn": 1,
"step": 1,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-code"
},
"toolCallId": "advanced-code",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-code",
"content": [
{
"type": "text",
"text": "42"
}
],
"isError": false
"type": "text",
"text": "42"
}
],
"role": "user",
"isError": false,
"id": "{{messageId}}"
}
},
@@ -551,7 +542,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 27
}
},
@@ -699,24 +690,19 @@
"turn": 1,
"step": 2,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-denied-native"
},
"toolCallId": "advanced-denied-native",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-denied-native",
"content": [
{
"type": "text",
"text": "Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"
}
],
"isError": true
"type": "text",
"text": "Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"
}
],
"role": "user",
"isError": true,
"id": "{{messageId}}"
},
"error": {
@@ -754,7 +740,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 33
}
},
@@ -942,24 +928,19 @@
"turn": 1,
"step": 3,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-denied-ptc"
},
"toolCallId": "advanced-denied-ptc",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-denied-ptc",
"content": [
{
"type": "text",
"text": "Auto review rejected tool \"snapshot_double\"; its body was not executed"
}
],
"isError": false
"type": "text",
"text": "Auto review rejected tool \"snapshot_double\"; its body was not executed"
}
],
"role": "user",
"isError": false,
"id": "{{messageId}}"
}
},
@@ -992,7 +973,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 41
}
},
@@ -1152,24 +1133,19 @@
"turn": 1,
"step": 4,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-direct-child"
},
"toolCallId": "advanced-direct-child",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-direct-child",
"content": [
{
"type": "text",
"text": "DIRECT_CHILD_OK"
}
],
"isError": false
"type": "text",
"text": "DIRECT_CHILD_OK"
}
],
"role": "user",
"isError": false,
"id": "{{messageId}}"
}
},
@@ -1202,7 +1178,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 48
}
},
@@ -1401,24 +1377,19 @@
"turn": 1,
"step": 5,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-workflow"
},
"toolCallId": "advanced-workflow",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-workflow",
"content": [
{
"type": "text",
"text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"
}
],
"isError": false
"type": "text",
"text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"
}
],
"role": "user",
"isError": false,
"id": "{{messageId}}"
}
},
@@ -1451,7 +1422,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 59
}
},
@@ -1785,8 +1756,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "@deepseek-ai/dsh-system-prompt"
"kind": "system-prompt"
},
"id": "{{messageId}}"
}
@@ -1813,8 +1783,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "serial-created-fixture"
"kind": "plugin:serial-created-fixture"
}
},
"surfaceOp": "append"
@@ -1862,8 +1831,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "@deepseek-ai/dsh-system-prompt",
"kind": "runtime-context",
"form": "snapshot",
"sections": [
{
@@ -1907,8 +1875,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "python-snapshot-image-offload"
"kind": "python-snapshot-image-offload"
}
},
"surfaceOp": "append"
@@ -2088,7 +2055,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 19
}
}
@@ -2301,24 +2268,19 @@
"turn": 1,
"step": 1,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-code"
},
"toolCallId": "advanced-code",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-code",
"content": [
{
"type": "text",
"text": "42"
}
],
"isError": false
"type": "text",
"text": "42"
}
],
"role": "user",
"isError": false,
"id": "{{messageId}}"
}
},
@@ -2369,7 +2331,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 27
}
}
@@ -2535,24 +2497,19 @@
"turn": 1,
"step": 2,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-denied-native"
},
"toolCallId": "advanced-denied-native",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-denied-native",
"content": [
{
"type": "text",
"text": "Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"
}
],
"isError": true
"type": "text",
"text": "Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"
}
],
"role": "user",
"isError": true,
"id": "{{messageId}}"
},
"error": {
@@ -2608,7 +2565,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 33
}
}
@@ -2826,24 +2783,19 @@
"turn": 1,
"step": 3,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-denied-ptc"
},
"toolCallId": "advanced-denied-ptc",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-denied-ptc",
"content": [
{
"type": "text",
"text": "Auto review rejected tool \"snapshot_double\"; its body was not executed"
}
],
"isError": false
"type": "text",
"text": "Auto review rejected tool \"snapshot_double\"; its body was not executed"
}
],
"role": "user",
"isError": false,
"id": "{{messageId}}"
}
},
@@ -2894,7 +2846,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 41
}
}
@@ -3077,8 +3029,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "serial-created-fixture"
"kind": "plugin:serial-created-fixture"
}
}
]
@@ -3241,8 +3192,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "@deepseek-ai/dsh-system-prompt"
"kind": "system-prompt"
},
"id": "{{messageId}}"
}
@@ -3269,8 +3219,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "serial-created-fixture"
"kind": "plugin:serial-created-fixture"
}
},
"surfaceOp": "append"
@@ -3318,8 +3267,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "@deepseek-ai/dsh-system-prompt",
"kind": "runtime-context",
"form": "snapshot",
"sections": [
{
@@ -3425,7 +3373,7 @@
"time": 0,
"data": {
"sessionId": "{{child-1}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 16
}
}
@@ -3613,24 +3561,19 @@
"turn": 1,
"step": 4,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-direct-child"
},
"toolCallId": "advanced-direct-child",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-direct-child",
"content": [
{
"type": "text",
"text": "DIRECT_CHILD_OK"
}
],
"isError": false
"type": "text",
"text": "DIRECT_CHILD_OK"
}
],
"role": "user",
"isError": false,
"id": "{{messageId}}"
}
},
@@ -3681,7 +3624,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 48
}
}
@@ -3879,8 +3822,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "serial-created-fixture"
"kind": "plugin:serial-created-fixture"
}
}
]
@@ -4059,8 +4001,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "@deepseek-ai/dsh-system-prompt"
"kind": "system-prompt"
},
"id": "{{messageId}}"
}
@@ -4087,8 +4028,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "serial-created-fixture"
"kind": "plugin:serial-created-fixture"
}
},
"surfaceOp": "append"
@@ -4136,8 +4076,7 @@
}
],
"source": {
"kind": "plugin",
"plugin": "@deepseek-ai/dsh-system-prompt",
"kind": "runtime-context",
"form": "snapshot",
"sections": [
{
@@ -4243,7 +4182,7 @@
"time": 0,
"data": {
"sessionId": "{{child-2}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 16
}
}
@@ -4462,24 +4401,19 @@
"turn": 1,
"step": 5,
"message": {
"role": "tool",
"source": {
"kind": "tool",
"callId": "advanced-workflow"
},
"toolCallId": "advanced-workflow",
"content": [
{
"type": "tool-result",
"toolCallId": "advanced-workflow",
"content": [
{
"type": "text",
"text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"
}
],
"isError": false
"type": "text",
"text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"
}
],
"role": "user",
"isError": false,
"id": "{{messageId}}"
}
},
@@ -4530,7 +4464,7 @@
"time": 0,
"data": {
"sessionId": "{{parent}}",
"sessionFormatVersion": 3,
"sessionFormatVersion": 4,
"throughSeq": 59
}
}
@@ -0,0 +1,22 @@
{"type":"session","version":4,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","isSeeded":false,"origin":"subagent","delegationDepth":1}
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn","label":"Check direct child"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
{"type":"session/title","data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[12],"source":{"kind":"fallback"}}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-1}}","sessionFormatVersion":4,"throughSeq":16}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["DIRECT_CHILD_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,22 @@
{"type":"session","version":4,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","isSeeded":false,"origin":"subagent","delegationDepth":1}
{"type":"sandbox/mode","data":{"mode":"danger-full-access","source":"delegation"}}
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"subagent/descriptor","data":{"version":3,"mode":"one-shot","provider":"spawn"}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
{"type":"session/title","data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[12],"source":{"kind":"fallback"}}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{child-2}}","sessionFormatVersion":4,"throughSeq":16}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["WORKFLOW_CHILD_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,72 @@
{"type":"session","version":4,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"python-snapshot-image-offload"}},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
{"type":"session/title","data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[11],"source":{"kind":"fallback"}}}
{"type":"assistant/attempt","data":{"turn":1,"step":1,"stream":[{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"code":"IMAGE_OFFLOAD_REQUIRED","message":"authored image budget failure","offloadImages":1}}}}]}}
{"type":"image/offload","data":{"targets":[{"seq":13,"imageIndexes":[0]}]}}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","snapshot_double","subagent","workflow"]},"reason":"series"}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":19}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Call the configured Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-code","name":"run_code","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-code","args":["{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Call the configured Plugin tool\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Call the configured Plugin tool\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Call the configured Plugin tool\"}"}}
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"snapshot_double","arguments":{"value":21}}}
{"type":"tool/ptc-dispatch","data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:ptc:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-code"},"toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":27}}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-native","name":"snapshot_double","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-native","args":["{\"value\": -1}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":2,"callId":"advanced-denied-native","name":"snapshot_double","arguments":"{\"value\": -1}"}}
{"type":"tool/result","data":{"turn":1,"step":2,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-denied-native"},"toolCallId":"advanced-denied-native","content":[{"type":"text","text":"Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"}],"isError":true,"id":"{{messageId}}"},"error":{"name":"AutoReviewDeniedError","code":"AUTO_REVIEW_DENIED","reason":" transport raw\r\nreason "}},"sourceEventSeqs":[30],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"step/start","data":{"turn":1,"step":3}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":33}}
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-ptc","name":"run_code","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-denied-ptc","args":["{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-denied-ptc","name":"run_code","arguments":"{\"code\": \"try { await tools.snapshot_double({ value: -1 }) } catch (error) { return error.message }\", \"description\": \"Catch a structured inner tool denial\"}"}}
{"type":"tool/ptc-dispatch-start","data":{"rootCallId":"advanced-denied-ptc","parentCallId":"advanced-denied-ptc","subCallId":"advanced-denied-ptc:ptc:1","name":"snapshot_double","arguments":{"value":-1}}}
{"type":"tool/ptc-dispatch","data":{"rootCallId":"advanced-denied-ptc","parentCallId":"advanced-denied-ptc","subCallId":"advanced-denied-ptc:ptc:1","name":"snapshot_double","arguments":{"value":-1},"isError":true,"error":{"name":"AutoReviewDeniedError","code":"AUTO_REVIEW_DENIED","reason":" transport raw\r\nreason "},"content":[{"type":"text","text":"Error: Auto review rejected tool \"snapshot_double\"; its body was not executed"}]}}
{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-denied-ptc"},"toolCallId":"advanced-denied-ptc","content":[{"type":"text","text":"Auto review rejected tool \"snapshot_double\"; its body was not executed"}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[36],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":3}}
{"type":"step/start","data":{"turn":1,"step":4}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":41}}
{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","args":["{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":4,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
{"type":"subagent/catalog","data":{"version":0,"childId":"{{child-1}}","childCreatedAt":0,"mode":"one-shot","label":"Check direct child"}}
{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-direct-child"},"toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":4}}
{"type":"step/start","data":{"turn":1,"step":5}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":48}}
{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","name":"workflow","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","args":["{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":5,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}
{"type":"tool-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}}
{"type":"subagent/catalog","data":{"version":0,"childId":"{{child-2}}","childCreatedAt":0,"mode":"one-shot"}}
{"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}}
{"type":"tool-workflow/agent-end","data":{"runId":"{{workflow-run}}","seq":1,"outcome":"completed"}}
{"type":"tool-workflow/run-end","data":{"runId":"{{workflow-run}}","stopReason":"completed"}}
{"type":"tool/result","data":{"turn":1,"step":5,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-workflow"},"toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[51],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":5}}
{"type":"step/start","data":{"turn":1,"step":6}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":59}}
{"type":"assistant/message","data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{message:1}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["ADVANCED_EXECUTABLE_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":6}}
{"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}}
{"type":"feedback/record","data":{"text":"The session needs a clearer explanation."}}
{"type":"command/done","data":{"commandId":"{{command:1}}","kind":"success","text":"Feedback recorded for session {{parent}}\nAnonymous user: {{anonymous-user}}."}}
{"type":"feedback/record","data":{"category":"other"}}
{"type":"feedback/message-put","data":{"sessionId":"{{parent}}","item":{"messageId":"{{message:1}}","rating":"negative","note":"Explain the result.","category":"task-result","version":"{{feedback-version:1}}","createdAt":0,"updatedAt":0}}}
{"type":"feedback/message-put","data":{"sessionId":"{{parent}}","item":{"messageId":"{{message:1}}","rating":"positive","note":"The explanation is clear now.","version":"{{feedback-version:2}}","createdAt":0,"updatedAt":0}}}
{"type":"feedback/message-delete","data":{"sessionId":"{{parent}}","messageId":"{{message:1}}"}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,29 @@
{
"tool": {
"name": "load_workspace_dependencies",
"description": "Get absolute paths to bundled Python and library directories, plus bundled Python distribution versions. Node.js and pnpm paths are included when the payload provides them. Python includes numpy, pandas, python-docx, python-pptx, openpyxl, Pillow, lxml, and XlsxWriter. Use these libraries for Office files unless the user or workspace instructions select another environment. When Node.js and pnpm paths are returned, run pnpm with that Node executable and pnpm script path. This does not change PATH or package-manager settings.",
"input_schema": {
"type": "object",
"properties": {}
}
},
"result": {
"python": "{{python}}",
"pythonPackages": "{{site-packages}}",
"pythonDistributions": {
"numpy": "2.3.5",
"pandas": "3.0.1",
"python-dateutil": "2.9.0.post0",
"six": "1.17.0",
"tzdata": "2025.2",
"python-docx": "1.2.0",
"python-pptx": "1.0.2",
"openpyxl": "3.1.5",
"Pillow": "12.3.0",
"lxml": "6.1.3",
"XlsxWriter": "3.2.9",
"typing_extensions": "4.16.0",
"et_xmlfile": "2.0.0"
}
}
}
@@ -0,0 +1,18 @@
{"type":"session","version":4,"id":"{{session-1}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Complete the first isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Complete the first isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
{"type":"session/title","data":{"title":"Complete the first isolated Python","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-1}}","sessionFormatVersion":4,"throughSeq":12}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_ONE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PROCESS_ONE_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_ONE_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,18 @@
{"type":"session","version":4,"id":"{{session-2}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Complete the second isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Complete the second isolated Python SDK process turn."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","run_code","subagent","workflow"]},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
{"type":"session/title","data":{"title":"Complete the second isolated Python","messageSeqs":[8],"source":{"kind":"fallback"}}}
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{session-2}}","sessionFormatVersion":4,"throughSeq":12}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"PROCESS_TWO_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["PROCESS_TWO_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PROCESS_TWO_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
+1 -1
View File
@@ -35,7 +35,7 @@ function evaluate(expression: string, context: Record<string, string | boolean>)
const source = expression.trim().replace(/^\$\{\{|\}\}$/g, '')
.replace(/\b(?:github|vars|runner)(?:\.[a-zA-Z_][a-zA-Z_0-9]*)+/g,
key => JSON.stringify(context[key] ?? ''))
return runInNewContext(source, { fromJSON: JSON.parse }, { timeout: 1000 }) as unknown
return runInNewContext(source, { fromJSON: JSON.parse }, { timeout: 1000 })
}
function assertSharedPersistentStore(run: string | undefined): void {
+3
View File
@@ -319,6 +319,8 @@ describe('translation scope discovery', () => {
'.agents/notes/proposed/feature.md',
'docs/guide.md',
'python/guide.md',
'python/sdk-runtime/README.md',
'python/sdk-runtime/src/deepseek_harness_runtime/README.md',
])('includes %s', (file) => {
expect(isTranslationScopeFile(file)).toBe(true)
})
@@ -336,6 +338,7 @@ describe('translation scope discovery', () => {
'coverage/report/README.md',
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-macos-arm64/README.md',
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/README.md',
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/macos-arm64/office-skills/office-docx/SKILL.md',
])('excludes non-source or non-README path %s', (file) => {
expect(isTranslationScopeFile(file)).toBe(false)
})
+2 -4
View File
@@ -165,8 +165,7 @@ export const TRANSLATION_SCOPE_GLOB_EXCLUDES = [
'**/.pytest_cache/**',
'apps/web/dist/**',
'.artifacts/**',
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-*/**',
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/**',
'python/sdk-runtime/src/deepseek_harness_runtime/runtime/**',
'vendor/**',
]
@@ -177,8 +176,7 @@ function isTranslationSourceExcluded(file: string): boolean {
|| segment.startsWith('.doc-typecheck-')
|| segment.startsWith('.node-next-types-'))
|| file.startsWith('apps/web/dist/')
|| file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/deepseek-harness-sdk-runtime-')
|| file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/')
|| file.startsWith('python/sdk-runtime/src/deepseek_harness_runtime/runtime/')
}
/** Whether one discovered Markdown or sidecar path belongs to the bilingual source corpus. */
+67 -2
View File
@@ -101,6 +101,16 @@
"symbol": "LlmResolvedModelInfo",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/subsystems/llm-streaming.md",
"symbol": "RequestUserInput",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/subsystems/llm-streaming.md",
"symbol": "RequestMessage",
"source": "packages/llm/llm/src/types.ts"
},
{
"doc": "docs/subsystems/llm-streaming.md",
"symbol": "GenerateOptions",
@@ -1059,11 +1069,16 @@
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobKindMap",
"source": "packages/jobs/jobs/src/view.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobSpec",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobStart",
"symbol": "JobHandle",
"source": "packages/jobs/jobs/src/types.ts"
},
{
@@ -1078,14 +1093,44 @@
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobSnapshot",
"symbol": "JobOutputSource",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobSourceRead",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobChunk",
"source": "packages/jobs/jobs/src/view.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobOutputRead",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobView",
"source": "packages/jobs/jobs/src/view.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobRead",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobEvent",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/jobs.md",
"symbol": "JobEventFilter",
"source": "packages/jobs/jobs/src/types.ts"
},
{
"doc": "docs/subsystems/terminal.md",
"symbol": "TerminalWaitReason",
@@ -1906,6 +1951,26 @@
"symbol": "InvocationDescriptor",
"source": "packages/typert/protocol/src/types.ts"
},
{
"doc": "docs/subsystems/typert.md",
"symbol": "RemoteStream",
"source": "packages/typert/protocol/src/types.ts"
},
{
"doc": "docs/subsystems/typert.md",
"symbol": "RemoteStreamHandle",
"source": "packages/typert/protocol/src/types.ts"
},
{
"doc": "docs/subsystems/typert.md",
"symbol": "PeerScope",
"source": "packages/typert/protocol/src/types.ts"
},
{
"doc": "docs/subsystems/typert.md",
"symbol": "RemoteInvocation",
"source": "packages/typert/protocol/src/types.ts"
},
{
"doc": "docs/subsystems/typert.md",
"symbol": "TypertRegistryContract",
+22 -2
View File
@@ -99,8 +99,28 @@ describe('application entrypoints', () => {
write(root, 'scripts/demo-ptc.mjs', "spawn('node', ['packages/example/app/src/bin.ts'])\n")
expect(applicationEntrypointViolations(root)).toEqual([
'scripts/demo-ptc.mjs: application demo wrapper must launch apps/cli/src/bin.ts',
'scripts/demo-ptc.mjs: application demo wrapper must not launch a package entry directly',
'scripts/demo-ptc.mjs: application launcher wrapper must launch apps/cli/src/bin.ts',
'scripts/demo-ptc.mjs: application launcher wrapper must not launch a package entry directly',
])
})
it('rejects a start:web script that bypasses the dsh launcher', () => {
const root = fixture()
write(root, 'package.json', JSON.stringify({ scripts: { 'start:web': 'node packages/example/app/src/bin.ts web' } }))
expect(applicationEntrypointViolations(root)).toEqual([
'package.json scripts.start:web: application launcher script must launch apps/cli/src/bin.ts',
'package.json scripts.start:web: application launcher script must not launch a package entry directly',
])
})
it('rejects a dev:web wrapper that serves without the dsh launcher', () => {
const root = fixture()
write(root, 'package.json', JSON.stringify({ scripts: { 'dev:web': 'tsx scripts/dev-web.ts --poll' } }))
write(root, 'scripts/dev-web.ts', "execa('vite', ['build', '--watch'])\n")
expect(applicationEntrypointViolations(root)).toEqual([
'scripts/dev-web.ts: application launcher wrapper must launch apps/cli/src/bin.ts',
])
})
+21 -14
View File
@@ -18,7 +18,7 @@ interface RootManifest {
readonly scripts?: Record<string, unknown>
}
interface DemoPolicy {
interface LauncherPolicy {
readonly kind: 'dsh-direct' | 'dsh-wrapper'
readonly wrapper?: string
}
@@ -48,10 +48,16 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
['python/sdk-runtime/runtime-bootstrap.mjs', 'private packaging-only runtime dispatcher'],
])
/** Root demos are application wrappers and therefore must visibly select dsh. */
const ROOT_DEMO_POLICIES = new Map<string, DemoPolicy>([
/**
* Root scripts that start an application must visibly select dsh: every `demo:*`
* script needs a row here, and the Web start and development scripts are pinned
* by name.
*/
const ROOT_LAUNCHER_POLICIES = new Map<string, LauncherPolicy>([
['demo:ptc', { kind: 'dsh-wrapper', wrapper: 'scripts/demo-ptc.mjs' }],
['demo:inspector', { kind: 'dsh-direct' }],
['start:web', { kind: 'dsh-direct' }],
['dev:web', { kind: 'dsh-wrapper', wrapper: 'scripts/dev-web.ts' }],
])
const SOURCE_PATTERNS = [
@@ -131,22 +137,23 @@ function referencesPackageEntry(source: string): boolean {
return /packages\/[^/\s'"`]+\/[^/\s'"`]+\/(?:src|lib)\/[^\s'"`]+/.test(source)
}
function rootDemoViolations(root: string): string[] {
function rootLauncherViolations(root: string): string[] {
const manifestPath = resolve(root, 'package.json')
if (!existsSync(manifestPath)) return []
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as RootManifest
const failures: string[] = []
for (const [name, commandValue] of Object.entries(manifest.scripts ?? {}).sort(([left], [right]) => left.localeCompare(right))) {
if (!name.startsWith('demo:')) continue
const command = typeof commandValue === 'string' ? commandValue : ''
const policy = ROOT_DEMO_POLICIES.get(name)
const policy = ROOT_LAUNCHER_POLICIES.get(name)
if (policy === undefined) {
failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`)
if (name.startsWith('demo:')) {
failures.push(`package.json scripts.${name}: demo launcher has no explicit dsh or in-process classification`)
}
continue
}
const command = typeof commandValue === 'string' ? commandValue : ''
if (policy.kind === 'dsh-direct') {
if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application demo must launch apps/cli/src/bin.ts`)
if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application demo must not launch a package entry directly`)
if (!referencesDshCli(command)) failures.push(`package.json scripts.${name}: application launcher script must launch apps/cli/src/bin.ts`)
if (referencesPackageEntry(command)) failures.push(`package.json scripts.${name}: application launcher script must not launch a package entry directly`)
continue
}
const wrapper = policy.wrapper
@@ -156,12 +163,12 @@ function rootDemoViolations(root: string): string[] {
}
const wrapperPath = resolve(root, wrapper)
if (!existsSync(wrapperPath)) {
failures.push(`${wrapper}: classified demo wrapper is missing`)
failures.push(`${wrapper}: classified launcher wrapper is missing`)
continue
}
const source = readFileSync(wrapperPath, 'utf8')
if (!referencesDshCli(source)) failures.push(`${wrapper}: application demo wrapper must launch apps/cli/src/bin.ts`)
if (referencesPackageEntry(source)) failures.push(`${wrapper}: application demo wrapper must not launch a package entry directly`)
if (!referencesDshCli(source)) failures.push(`${wrapper}: application launcher wrapper must launch apps/cli/src/bin.ts`)
if (referencesPackageEntry(source)) failures.push(`${wrapper}: application launcher wrapper must not launch a package entry directly`)
}
return failures
}
@@ -175,7 +182,7 @@ export function applicationEntrypointViolations(root: string): string[] {
return [
...manifestBinViolations(root),
...executableSourceViolations(root),
...rootDemoViolations(root),
...rootLauncherViolations(root),
]
}
+3 -11
View File
@@ -4,6 +4,7 @@ import { execFileSync } from 'node:child_process'
import { lstatSync, readFileSync, readlinkSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { historicalSchemaRegion } from './historical-schema-region.ts'
const root = resolve(import.meta.dirname, '..')
const blockedTerm = 'prove' + 'nance'
@@ -28,15 +29,6 @@ function containsBlockedTerm(value: string): boolean {
return value.normalize('NFKC').toLowerCase().includes(blockedTerm)
}
function historicalSchemaRegion(file: string, source: string, lines: readonly string[]): readonly [number, number] | undefined {
if (!/^docs\/persistence-changes\/historical-formats\/v(?:0|[1-9]\d*)(?:\.zh)?\.md$/u.test(file)) return undefined
if (source.match(/<!--\s*persistence-format-schema\b/giu)?.length !== 2) return undefined
const start = lines.indexOf('<!-- persistence-format-schema:start -->')
const end = lines.indexOf('<!-- persistence-format-schema:end -->')
// The format gate checks these generated lines against their historical schema inventory.
return start >= 0 && end > start ? [start, end] : undefined
}
/**
* Find the blocked term in one maintained tracked file.
* @param file - repository-relative tracked path.
@@ -48,9 +40,9 @@ export function findConcreteTermViolations(file: string, source: string): Concre
const violations: ConcreteTermViolation[] = []
if (containsBlockedTerm(file)) violations.push({ file, line: null })
const lines = source.split(/\r?\n/u)
const schemaRegion = historicalSchemaRegion(file, source, lines)
const schemaRegion = historicalSchemaRegion(file, source)
for (const [index, line] of lines.entries()) {
if (schemaRegion !== undefined && index > schemaRegion[0] && index < schemaRegion[1]) continue
if (schemaRegion !== undefined && index >= schemaRegion[0] && index < schemaRegion[1]) continue
if (containsBlockedTerm(line)) violations.push({ file, line: index + 1 })
}
return violations
+13 -10
View File
@@ -14,6 +14,8 @@ import { globSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import { Script } from 'node:vm'
import ts from 'typescript'
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
import { bundlePatchPaths } from '../packages/boot/app-boot/src/profile.ts'
import { cordisConfigFiles } from './cordis-config-files.ts'
import { isCordisGroupEntry, isJsExpr, loadCordisYaml } from './cordis-yaml.ts'
@@ -22,7 +24,7 @@ export interface PackageManifest {
dependencies?: Record<string, string>
devDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
dsh?: { bundle?: { patch?: string } }
dsh?: { bundle?: DshBundleManifest }
}
export interface PluginReference {
@@ -228,13 +230,13 @@ function validateAppResolution(): string[] {
const violations: string[] = []
const bundleManifests = bundleManifestPaths()
// App overlays (and any config left under apps/cli/config) resolve from the
// dsh app's own dependency surface — the profile module fallback mirrors it.
// dsh app's own dependency surface — the runtime resolution mirrors it.
const appManifest = readManifest('apps/cli/package.json')
const appDependencies = {
...appManifest.dependencies,
// The fallback also links every in-box bundle's own dependencies
// (healProfilesModuleFallback). Optional Profile bundles stay outside the
// app installation until that Profile installs them.
// Runtime resolution includes every in-box bundle's own dependencies.
// Optional Profile bundles stay outside the app installation until that
// Profile installs them.
...Object.fromEntries(globSync('packages/bundle/*/package.json', { cwd: root })
.flatMap(file => Object.entries(readManifest(file).dependencies ?? {}))),
}
@@ -257,10 +259,11 @@ function validateAppResolution(): string[] {
for (const manifestPath of bundleManifests) {
const bundleDir = manifestPath.replace(/\/package\.json$/, '')
const manifest = readManifest(manifestPath)
const patch = manifest.dsh?.bundle?.patch
if (typeof patch !== 'string') continue
const patchFile = relative(root, resolve(root, bundleDir, patch)).replaceAll('\\', '/')
const references = pluginReferences.filter(reference => reference.file === patchFile)
const bundle = manifest.dsh?.bundle
if (bundle === undefined) continue
const patchFiles = new Set(bundlePatchPaths(resolve(root, bundleDir), bundle)
.map(file => relative(root, file).replaceAll('\\', '/')))
const references = pluginReferences.filter(reference => patchFiles.has(reference.file))
violations.push(...bundlePluginDependencyErrors(manifestPath, manifest, references))
}
return violations
@@ -362,7 +365,7 @@ function packageTestManifestPath(file: string): string | undefined {
*/
export function bundleManifestPaths(repoRoot: string = root): string[] {
return globSync('packages/*/*/package.json', { cwd: repoRoot })
.filter(path => typeof readManifest(path, repoRoot).dsh?.bundle?.patch === 'string')
.filter(path => readManifest(path, repoRoot).dsh?.bundle?.patch !== undefined)
.map(path => path.replaceAll('\\', '/'))
.sort()
}
+7 -6
View File
@@ -12,7 +12,8 @@ import ts from 'typescript'
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
import { loadOverlayPatches } from '../packages/boot/app-boot/src/index.ts'
import { composeEntries } from '../packages/boot/app-boot/src/profile.ts'
import { bundlePatchPaths, composeEntries } from '../packages/boot/app-boot/src/profile.ts'
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
import { isCordisGroupEntry, loadCordisYaml } from './cordis-yaml.ts'
import {
collectRuntimeLocalSourceSpecifiers,
@@ -32,7 +33,7 @@ interface Manifest {
optionalDependencies?: Record<string, string>
peerDependencies?: Record<string, string>
devDependencies?: Record<string, string>
dsh?: { bundle?: { patch?: string }; configTrees?: Array<{ path: string }> }
dsh?: { bundle?: DshBundleManifest; configTrees?: Array<{ path: string }> }
}
interface Package {
@@ -238,9 +239,9 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
}
const webLayers = selection.webBundles.flatMap((name) => {
const pkg = packages.get(name)
const patch = pkg?.manifest.dsh?.bundle?.patch
if (pkg === undefined || patch === undefined) return []
return [loadOverlayPatches('verify-default-product-isolation', resolve(pkg.directory, patch))]
const bundle = pkg?.manifest.dsh?.bundle
if (pkg === undefined || bundle === undefined) return []
return [bundlePatchPaths(pkg.directory, bundle).flatMap(file => loadOverlayPatches('verify-default-product-isolation', file))]
})
if (webLayers.length !== selection.webBundles.length) {
failures.push(`${PROFILE_SOURCE}: default Web bundle layers are incomplete`)
@@ -285,7 +286,7 @@ export function verifyDefaultProductIsolation(root: string): ProductIsolationRes
dependency(name, range, pkg, `${manifest.name} ${section}`)
}
}
if (manifest.dsh?.bundle?.patch !== undefined) scanConfig(resolve(pkg.directory, manifest.dsh.bundle.patch))
for (const file of manifest.dsh?.bundle === undefined ? [] : bundlePatchPaths(pkg.directory, manifest.dsh.bundle)) scanConfig(file)
for (const tree of manifest.dsh?.configTrees ?? []) {
const treePath = resolve(pkg.directory, tree.path)
const files = existsSync(treePath) && statSync(treePath).isDirectory()
+263
View File
@@ -0,0 +1,263 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { countUnknownCasts, findUnknownCasts, scanUnknownCasts, verifyNoUnknownCasts } from './verify-no-unknown-casts.ts'
const roots: string[] = []
const baselinePath = 'scripts/no-unknown-casts.baseline.json'
const sourcePath = 'packages/example/src/index.ts'
const assertion = 'const result = value as unknown\n'
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function write(root: string, file: string, contents: string): void {
const path = join(root, file)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, contents)
}
function git(root: string, ...args: string[]): void {
execFileSync('git', args, { cwd: root, stdio: 'pipe' })
}
function fixture(): string {
const root = mkdtempSync(join(tmpdir(), 'dsh-unknown-casts-'))
roots.push(root)
git(root, 'init', '--quiet')
for (const area of ['packages', 'apps', 'scripts', 'website']) {
write(root, `${area}/seed.ts`, 'export {}\n')
}
write(root, baselinePath, '{}\n')
git(root, 'add', '.')
return root
}
function recordBaseline(root: string): string {
const text = `${JSON.stringify(countUnknownCasts(scanUnknownCasts(root)), null, 2)}\n`
write(root, baselinePath, text)
return text
}
describe('unknown assertion syntax', () => {
it.each([
['standalone', 'input.ts', 'value as unknown', 1],
['double assertion', 'input.ts', 'value as unknown as Result', 1],
['nested assertions', 'input.ts', '(value as unknown) as unknown', 2],
['multiline', 'input.ts', 'value as\nunknown', 1],
['comment-separated', 'input.ts', 'value as /* explanation */ unknown', 1],
['parenthesized type', 'input.ts', 'value as ((unknown))', 1],
['angle assertion', 'input.ts', '<unknown>value', 1],
['parenthesized angle type', 'input.ts', '<(unknown)>value', 1],
['union', 'input.ts', 'value as unknown | string', 1],
['union with trailing unknown', 'input.ts', 'value as string | unknown', 1],
['parenthesized union', 'input.ts', 'value as (unknown | never)', 1],
['nested union', 'input.ts', 'value as string | (unknown | number)', 1],
['angle union assertion', 'input.ts', '<unknown | string>value', 1],
['escaped keyword', 'input.ts', String.raw`value as \u0075nknown`, 1],
['TSX expression', 'input.tsx', '<View value={input as unknown} />', 1],
['template interpolation', 'input.ts', '`text ${value as unknown}`', 1],
])('finds %s', (_name, file, text, count) => {
expect(findUnknownCasts(file, text)).toHaveLength(count)
})
it.each([
'// value as unknown\n/* <unknown>value */',
'const text = "value as unknown"',
'const text = `value as unknown`',
'const result = value as unknown[]',
'const result = value as Record<string, unknown>',
'const result = value as unknown[] | string',
'const result = value as unknown & { id: string }',
'const result = value as string | (unknown & { id: string })',
'const result = <unknown[]>value',
'const result: unknown = value',
'type Input = unknown; const result = value as Input',
'type Input = unknown; const result = value as Input | string',
])('accepts source without a direct assertion: %s', (text) => {
expect(findUnknownCasts('input.ts', text)).toEqual([])
})
it('accepts JSX text without ignoring assertions in expressions', () => {
expect(findUnknownCasts('input.tsx', '<View>as unknown</View>')).toEqual([])
expect(findUnknownCasts('input.tsx', '<View>{value as unknown}</View>')).toHaveLength(1)
})
it('excludes JavaScript JSDoc assertions', () => {
expect(findUnknownCasts('input.js', 'const result = /** @type {unknown} */ (value)')).toEqual([])
})
it('reports normalized paths and the asserted type line', () => {
const casts = findUnknownCasts('packages\\example\\index.ts', '\nvalue as\nunknown')
expect(casts).toHaveLength(1)
expect(casts[0]).toMatchObject({ file: 'packages/example/index.ts', line: 3 })
expect(casts[0]?.fingerprint).toMatch(/^[a-f0-9]{64}$/u)
})
it('keeps syntax fingerprints through whitespace and comment edits', () => {
const original = findUnknownCasts('input.ts', 'call(value, "text") as unknown')
const formatted = findUnknownCasts('input.ts', '\ncall /* call */ (value,\n "text") as /* type */ unknown')
expect(formatted.map(cast => cast.fingerprint)).toEqual(original.map(cast => cast.fingerprint))
expect(findUnknownCasts('input.ts', 'call(value, "other") as unknown').map(cast => cast.fingerprint))
.not.toEqual(original.map(cast => cast.fingerprint))
})
it.each([
['value as unknown', '6fe9024d30e0d4757a348b83d246925b3f5aeaf2e79a0eff4bf12f3c9fb5826b'],
['call() as unknown', '49f7ac9934e1a7fc08876cdf6f900bd05c74d7d25801b6ec2d4eed02c3221f8d'],
])('preserves the recorded fingerprint for %s', (text, fingerprint) => {
expect(findUnknownCasts('input.ts', text)[0]?.fingerprint).toBe(fingerprint)
})
})
describe('unknown assertion inventory', () => {
it('accepts recorded assertions after formatting without rewriting the baseline', () => {
const root = fixture()
write(root, sourcePath, assertion)
const baseline = recordBaseline(root)
write(root, sourcePath, 'const result = value as\n /* reason */ unknown\n')
expect(verifyNoUnknownCasts(root)).toBe(1)
expect(verifyNoUnknownCasts(root, true)).toBe(1)
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe(baseline)
})
it.each([
['additional assertion', `${assertion}const other = input as unknown\n`],
['duplicate assertion', `${assertion}${assertion}`],
['replacement assertion', 'const result = other as unknown\n'],
['union assertion', 'const result = value as unknown | string\n'],
])('rejects an %s without changing the baseline', (_name, text) => {
const root = fixture()
write(root, sourcePath, assertion)
const baseline = recordBaseline(root)
write(root, sourcePath, text)
expect(() => verifyNoUnknownCasts(root)).toThrow(/new assertions to unknown/u)
expect(() => verifyNoUnknownCasts(root, true)).toThrow(/new assertions to unknown/u)
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe(baseline)
})
it.each([
assertion,
'const result = value as (unknown | never)\n',
'const result = value as string | (unknown | number)\n',
String.raw`const result = value as \u0075nknown`,
])('rejects a new assertion when the inventory is empty: %s', (text) => {
const root = fixture()
write(root, sourcePath, text)
expect(() => verifyNoUnknownCasts(root)).toThrow(`${sourcePath}:1`)
})
it('requires pruning retired counts while preserving the remaining assertion', () => {
const root = fixture()
write(root, sourcePath, `${assertion}${assertion}`)
write(root, 'apps/retired.ts', 'const retired = input as unknown\n')
recordBaseline(root)
write(root, sourcePath, assertion)
unlinkSync(join(root, 'apps/retired.ts'))
expect(() => verifyNoUnknownCasts(root)).toThrow(/remove retired baseline entries/u)
expect(verifyNoUnknownCasts(root, true)).toBe(1)
expect(JSON.parse(readFileSync(join(root, baselinePath), 'utf8'))).toEqual(
countUnknownCasts(findUnknownCasts(sourcePath, assertion)),
)
expect(verifyNoUnknownCasts(root)).toBe(1)
})
it('prunes a deleted tracked file without treating it as a discovery failure', () => {
const root = fixture()
write(root, sourcePath, assertion)
git(root, 'add', sourcePath)
recordBaseline(root)
unlinkSync(join(root, sourcePath))
expect(() => verifyNoUnknownCasts(root)).toThrow(/remove retired baseline entries/u)
expect(verifyNoUnknownCasts(root, true)).toBe(0)
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe('{}\n')
})
it.each([
['not an object', '[]'],
['null', 'null'],
['invalid JSON', '{'],
['non-source path', '{"input.json":{"HASH":1}}'],
['absolute path', '{"/input.ts":{"HASH":1}}'],
['parent path', '{"../input.ts":{"HASH":1}}'],
['backslash path', JSON.stringify({ 'packages\\input.ts': { HASH: 1 } })],
['excluded path', '{"vendor/input.ts":{"HASH":1}}'],
['empty file counts', '{"input.ts":{}}'],
['array counts', '{"input.ts":[]}'],
['invalid fingerprint', '{"input.ts":{"invalid":1}}'],
['zero count', '{"input.ts":{"HASH":0}}'],
['negative count', '{"input.ts":{"HASH":-1}}'],
['fractional count', '{"input.ts":{"HASH":1.5}}'],
['unsafe count', '{"input.ts":{"HASH":9007199254740992}}'],
['string count', '{"input.ts":{"HASH":"1"}}'],
])('rejects a malformed baseline: %s', (_name, text) => {
const root = fixture()
const contents = text.replaceAll('HASH', 'a'.repeat(64))
write(root, baselinePath, contents)
expect(() => verifyNoUnknownCasts(root, true)).toThrow()
expect(readFileSync(join(root, baselinePath), 'utf8')).toBe(contents)
})
})
describe('unknown assertion source discovery', () => {
it('checks tracked source across areas, extensions, tests and root configuration', () => {
const root = fixture()
const files = [
'packages/example/src/input.ts',
'apps/example/input.tsx',
'scripts/input.mts',
'website/input.cts',
'packages/example/tests/input.js',
'apps/example/input.jsx',
'scripts/input.mjs',
'website/input.cjs',
'config.ts',
]
for (const file of files) write(root, file, assertion)
git(root, 'add', '.')
expect(scanUnknownCasts(root).map(cast => cast.file)).toEqual(files.toSorted())
expect(() => verifyNoUnknownCasts(root)).toThrow(/new assertions to unknown/u)
})
it('includes untracked source while excluding ignored files, vendor, archives and other extensions', () => {
const root = fixture()
write(root, '.gitignore', 'ignored/\n')
for (const file of ['ignored/input.ts', 'vendor/input.ts', '.agents/notes/archived/input.ts', 'docs/input.md']) {
write(root, file, assertion)
}
git(root, 'add', '.')
write(root, 'scripts/new.ts', assertion)
expect(scanUnknownCasts(root).map(cast => cast.file)).toEqual(['scripts/new.ts'])
expect(() => verifyNoUnknownCasts(root)).toThrow('scripts/new.ts:1')
unlinkSync(join(root, 'scripts/new.ts'))
expect(verifyNoUnknownCasts(root)).toBe(0)
})
it.each(['packages', 'apps', 'scripts', 'website'])('rejects discovery missing %s', (area) => {
const root = fixture()
unlinkSync(join(root, `${area}/seed.ts`))
expect(() => verifyNoUnknownCasts(root)).toThrow(`source discovery omitted ${area}/`)
})
it('rejects an empty source corpus', () => {
const root = fixture()
for (const area of ['packages', 'apps', 'scripts', 'website']) unlinkSync(join(root, `${area}/seed.ts`))
expect(() => verifyNoUnknownCasts(root)).toThrow(/source discovery omitted/u)
})
// Creating file symlinks requires privileges that Windows CI does not guarantee.
it.skipIf(process.platform === 'win32')('rejects a source symlink', () => {
const root = fixture()
write(root, 'target.txt', assertion)
const link = join(root, 'scripts/linked.ts')
symlinkSync(join(root, 'target.txt'), link)
try {
expect(() => verifyNoUnknownCasts(root)).toThrow('source symlink is unsupported: scripts/linked.ts')
} finally {
unlinkSync(link)
}
})
})
+186
View File
@@ -0,0 +1,186 @@
/** Reject new assertions to unknown while retiring the recorded existing assertions. */
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { lstatSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import ts from 'typescript'
const root = resolve(import.meta.dirname, '..')
const baselinePath = 'scripts/no-unknown-casts.baseline.json'
/** Existing assertion counts, keyed by repository path and syntax fingerprint. */
export type UnknownCastBaseline = Record<string, Record<string, number>>
/** One direct assertion to unknown in authored source. */
export interface UnknownCast {
/** Repository-relative path with forward slashes. */
file: string
/** One-based source line. */
line: number
/** SHA-256 of the assertion's syntax tokens, excluding comments and whitespace. */
fingerprint: string
}
function syntaxFingerprint(node: ts.Node, source: ts.SourceFile): string {
const tokens: string[] = []
const visit = (child: ts.Node): void => {
if (ts.isJSDoc(child)) return
if (ts.isToken(child)) tokens.push(child.getText(source))
else child.getChildren(source).forEach(visit)
}
visit(node)
return createHash('sha256').update(JSON.stringify(tokens)).digest('hex')
}
function assertsUnknown(type: ts.TypeNode): boolean {
if (ts.isParenthesizedTypeNode(type)) return assertsUnknown(type.type)
return type.kind === ts.SyntaxKind.UnknownKeyword
|| (ts.isUnionTypeNode(type) && type.types.some(assertsUnknown))
}
/**
* Find assertions to unknown, including angle syntax and unknown members of asserted unions.
* @param file - repository-relative source path; its extension selects TS or TSX parsing.
* @param text - source contents.
* @returns assertions in AST traversal order; comments, strings, aliases and unknown containers do not match.
*/
export function findUnknownCasts(file: string, text: string): UnknownCast[] {
const normalized = file.replaceAll('\\', '/')
const source = ts.createSourceFile(normalized, text, ts.ScriptTarget.Latest, true)
const casts: UnknownCast[] = []
const visit = (node: ts.Node): void => {
if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
if (assertsUnknown(node.type)) {
casts.push({
file: normalized,
line: source.getLineAndCharacterOfPosition(node.type.getStart(source)).line + 1,
fingerprint: syntaxFingerprint(node, source),
})
}
}
ts.forEachChild(node, visit)
}
visit(source)
return casts
}
function isSource(file: string): boolean {
return /\.(?:[cm]?[jt]s|[jt]sx)$/u.test(file)
&& !file.startsWith('vendor/')
&& !file.startsWith('.agents/notes/archived/')
}
/**
* Scan tracked source and non-ignored new source, including tests and configuration files.
* @param repoRoot - repository root containing the Git index.
* @returns every direct unknown assertion in the current working tree.
* @throws if discovery omits a required source area or an eligible path is a symlink.
*/
export function scanUnknownCasts(repoRoot: string): UnknownCast[] {
const files = [...new Set(execFileSync('git', ['ls-files', '--cached', '--others', '--exclude-standard', '-z'], {
cwd: repoRoot,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
}).split('\0').map(file => file.replaceAll('\\', '/')).filter(isSource))].sort()
const present = files.filter((file) => {
const stat = lstatSync(resolve(repoRoot, file), { throwIfNoEntry: false })
if (stat?.isSymbolicLink()) throw new Error(`verify-no-unknown-casts: source symlink is unsupported: ${file}`)
return stat?.isFile() === true
})
for (const area of ['packages/', 'apps/', 'scripts/', 'website/']) {
if (!present.some(file => file.startsWith(area))) {
throw new Error(`verify-no-unknown-casts: source discovery omitted ${area}`)
}
}
return present.flatMap(file => findUnknownCasts(file, readFileSync(resolve(repoRoot, file), 'utf8')))
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function readBaseline(repoRoot: string): UnknownCastBaseline {
const value: unknown = JSON.parse(readFileSync(resolve(repoRoot, baselinePath), 'utf8'))
if (!isRecord(value)) throw new Error('verify-no-unknown-casts: baseline must be a file-to-counts object')
const baseline: UnknownCastBaseline = {}
for (const [file, counts] of Object.entries(value)) {
if (!isSource(file) || file.startsWith('/') || file.includes('\\') || file.split('/').includes('..')
|| !isRecord(counts) || Object.keys(counts).length === 0) {
throw new Error(`verify-no-unknown-casts: invalid baseline file: ${file}`)
}
const parsed: Record<string, number> = {}
for (const [fingerprint, count] of Object.entries(counts)) {
if (!/^[a-f0-9]{64}$/u.test(fingerprint) || typeof count !== 'number' || !Number.isSafeInteger(count) || count <= 0) {
throw new Error(`verify-no-unknown-casts: invalid baseline count: ${file}:${fingerprint}`)
}
parsed[fingerprint] = count
}
baseline[file] = parsed
}
return baseline
}
/**
* Count exact assertions without granting a file-wide allowance.
* @param casts - observed assertions.
* @returns path and fingerprint counts in stable order for comparison and pruning.
*/
export function countUnknownCasts(casts: readonly UnknownCast[]): UnknownCastBaseline {
const baseline: UnknownCastBaseline = {}
for (const { file, fingerprint } of casts) {
const counts = baseline[file] ??= {}
counts[fingerprint] = (counts[fingerprint] ?? 0) + 1
}
return Object.fromEntries(Object.entries(baseline).sort(([a], [b]) => a.localeCompare(b))
.map(([file, counts]) => [file, Object.fromEntries(Object.entries(counts).sort(([a], [b]) => a.localeCompare(b)))]))
}
/**
* Enforce the recorded assertion inventory, optionally deleting retired allowances.
* @param repoRoot - repository root to scan.
* @param prune - remove stale entries only after establishing that no new assertion exists.
* @returns the number of existing assertions still present.
* @throws for new assertions, malformed inventories, or stale entries without pruning.
*/
export function verifyNoUnknownCasts(repoRoot: string, prune = false): number {
const baseline = readBaseline(repoRoot)
const casts = scanUnknownCasts(repoRoot)
const current = countUnknownCasts(casts)
const remaining = structuredClone(baseline)
const added: UnknownCast[] = []
for (const cast of casts) {
const counts = remaining[cast.file]
const allowance = counts?.[cast.fingerprint] ?? 0
if (counts !== undefined && allowance > 0) counts[cast.fingerprint] = allowance - 1
else added.push(cast)
}
if (added.length > 0) {
throw new Error('verify-no-unknown-casts: new assertions to unknown are forbidden. Use a typed value or narrow the input.\n'
+ added.map(cast => ` ${cast.file}:${String(cast.line)} (${cast.fingerprint})`).join('\n'))
}
const stale = Object.entries(remaining).flatMap(([file, counts]) => Object.entries(counts)
.filter(([, count]) => count > 0).map(([fingerprint]) => ` ${file} (${fingerprint})`))
if (stale.length > 0) {
if (!prune) {
throw new Error('verify-no-unknown-casts: remove retired baseline entries with pnpm run verify-no-unknown-casts --prune.\n'
+ stale.join('\n'))
}
writeFileSync(resolve(repoRoot, baselinePath), `${JSON.stringify(current, null, 2)}\n`)
}
return casts.length
}
if (import.meta.filename === resolve(process.argv[1] ?? '')) {
try {
const args = process.argv.slice(2)
if (args.length > 1 || (args.length === 1 && args[0] !== '--prune')) {
throw new Error('Usage: pnpm run verify-no-unknown-casts [--prune]')
}
const count = verifyNoUnknownCasts(root, args[0] === '--prune')
console.log(`verify-no-unknown-casts: no new assertions; ${String(count)} existing assertions remain.`)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
+1 -1
View File
@@ -214,7 +214,7 @@ describe('package dependency scope', () => {
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-session/types']).toBeUndefined()
expect(PACKAGE_DEPENDENCY_POLICY.safeHostDependencyExports['@deepseek-ai/dsh-typert-protocol']).toBeUndefined()
expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-scope']).toEqual([
'carrierKeyOf', 'scopeOf', 'scopeTarget',
'carrierKeyOf', 'createScope', 'scopeOf', 'scopeTarget',
])
expect(PACKAGE_DEPENDENCY_POLICY.peerRequiredHostExports['@deepseek-ai/dsh-typert-protocol']).toBeUndefined()
})
+491
View File
@@ -0,0 +1,491 @@
/** Plugin metadata checks use private source-only packages and real resource exports. */
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { packageMetaProblems } from './verify-package-meta.ts'
let root: string
let dir: string
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'dsh-meta-gate-'))
dir = join(root, 'packages', 'test', 'plugin')
mkdirSync(dir, { recursive: true })
})
afterEach(() => { rmSync(root, { recursive: true, force: true }) })
function file(path: string, contents: string): void {
mkdirSync(dirname(join(dir, path)), { recursive: true })
writeFileSync(join(dir, path), contents)
}
function json(path: string, contents: unknown): void {
file(path, JSON.stringify(contents))
}
function manifest(fields: object = {}): void {
json('package.json', {
name: '@test/plugin',
description: 'Package description',
type: 'module',
exports: { '.': './lib/index.js', './package.json': './package.json', './locale/*.json': './locale/*.json' },
files: ['lib', 'locale'],
...fields,
})
}
it('rejects an empty workspace package corpus', () => {
expect(packageMetaProblems(root).join('\n')).toContain('no workspace package manifests')
})
it('does not require locale resources for exported package name and description', () => {
manifest({ exports: { '.': './lib/index.js', './package.json': './package.json' }, files: ['lib'] })
expect(packageMetaProblems(root)).toEqual([])
})
it.each([undefined, ['art'], ['art/*.svg'], ['./art/icon.svg']])('accepts a published package icon without locale resources: %j', (files) => {
manifest({ icon: './art/icon.svg', files })
file('art/icon.svg', '<svg/>')
expect(packageMetaProblems(root)).toEqual([])
})
it.each([{ files: ['lib'] }, { files: ['art', '!art/icon.svg'] }])('rejects an icon omitted from package publication: %j', ({ files }) => {
manifest({ icon: './art/icon.svg', files })
file('art/icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('files must include art/icon.svg')
})
it.each([null, false, 1, '', './icon.gif', '/tmp/icon.svg', './missing.png', '../outside.svg'])('validates icon declarations without locale resources: %j', (icon) => {
manifest({ icon })
file('../outside.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('Plugin metadata for @test/plugin:')
})
it('requires the root icon declaration to be exported', () => {
manifest({ icon: './icon.svg', exports: { '.': './entry.js' }, files: ['icon.svg'] })
file('icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('exports must expose its icon declaration through @test/plugin/package.json')
})
it.each([
{ './search/package.json': './resources/search/manifest.json' },
{ './*/package.json': './resources/*/manifest.json' },
{ './*': './resources/*' },
])('validates icons of independent exported manifests: %j', (exports) => {
manifest({ exports, files: ['resources'] })
const document = './*' in exports ? 'resources/search/package.json' : 'resources/search/manifest.json'
json(document, { icon: './icon.webp' })
file('resources/search/icon.webp', 'webp')
expect(packageMetaProblems(root)).toEqual([])
json(document, { icon: './missing.png' })
expect(packageMetaProblems(root).join('\n')).toContain('missing.png')
})
it('requires publication of both an exported icon manifest and its image', () => {
manifest({ exports: { './search/package.json': './resources/search.json' }, files: ['resources/icon.svg'] })
json('resources/search.json', { icon: './icon.svg' })
file('resources/icon.svg', '<svg/>')
expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/search.json')
})
it('ignores icon-like fields in unrelated source JSON', () => {
manifest()
json('data.json', { icon: false })
expect(packageMetaProblems(root)).toEqual([])
})
it('excludes tests, installed dependencies, and build output from metadata discovery', () => {
manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] })
json('tests/fixtures/locale/en.json', { meta: { title: false } })
json('node_modules/dependency/locale/en.json', { meta: { title: false } })
file('lib/locale/en.json', '{')
expect(packageMetaProblems(root)).toEqual([])
})
it('ignores unrelated locale content without requiring metadata exports', () => {
manifest({ exports: { '.': './lib/index.js' }, files: ['lib'] })
json('locale/zh.json', { buttons: { save: '保存' }, enabled: true, other: null })
expect(packageMetaProblems(root)).toEqual([])
})
it('ignores ordinary JSON metadata outside locale directories and resource exports', () => {
manifest({ exports: { '.': './lib/index.js', './data.json': './data.json' } })
json('data.json', { meta: { title: false, schemaVersion: 1 } })
json('resources/search/en.json', { meta: { description: 'Business data' } })
expect(packageMetaProblems(root)).toEqual([])
})
it('accepts direct root metadata and per-field English fallback without built entries', () => {
manifest()
json('locale/en.json', { meta: { title: 'Plugin', description: '%literal%' }, buttons: { save: 'Save' } })
json('locale/zh.json', { meta: { title: '插件' }, nested: [1, null] })
json('locale/pt-BR.json', { meta: { description: 'Descrição' } })
file('locale/README.txt', 'Not a JSON resource.')
expect(packageMetaProblems(root)).toEqual([])
expect(existsSync(join(dir, 'lib'))).toBe(false)
})
it.each(['en', 'zh'])('rejects %s metadata resolved into build output without reading that JSON', (language) => {
manifest({
exports: {
'./locale/*.json': './locale/*.json',
[`./locale/${language}.json`]: `./lib/locale/${language}.json`,
},
})
json('locale/en.json', { meta: { title: 'Plugin' } })
json('locale/zh.json', { meta: { title: '插件' } })
file(`lib/locale/${language}.json`, '{')
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain(`${language}.json`)
expect(problems).toContain('source JSON')
expect(problems).not.toContain('Plugin metadata for')
})
it('does not read built locale JSON while checking a source icon declaration', () => {
manifest({
icon: './icon.svg', files: ['icon.svg'],
exports: { './package.json': './package.json', './locale/en.json': './lib/locale/en.json' },
})
file('icon.svg', '<svg/>')
file('lib/locale/en.json', '{')
json('locale/en.json', { meta: { title: 'Plugin' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('exports must expose locale/en.json')
expect(problems).not.toContain('Plugin metadata for')
})
it.each(['lib', 'tests'])('rejects an icon declaration when its only locale is excluded source: %s', (directory) => {
manifest({
icon: './missing.svg', files: ['lib'],
exports: { './package.json': './package.json', './locale/en.json': `./${directory}/locale/en.json` },
})
file(`${directory}/locale/en.json`, '{}')
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('@test/plugin: icon metadata requires locale resources to resolve to source JSON')
expect(problems).not.toContain('Plugin metadata for')
})
it('reads static metadata without evaluating the plugin entry', () => {
manifest({ exports: { '.': './entry.js', './locale/*.json': './locale/*.json' } })
file('entry.js', "import { writeFileSync } from 'node:fs'; writeFileSync(new URL('./executed', import.meta.url), 'yes'); throw new Error('plugin entry executed')")
json('locale/en.json', { meta: { title: 'Plugin' } })
expect(packageMetaProblems(root)).toEqual([])
expect(existsSync(join(dir, 'executed'))).toBe(false)
})
it('accepts metadata from an unscoped package self-reference', () => {
manifest({ name: 'plugin' })
json('locale/en.json', { meta: { title: 'Plugin' } })
expect(packageMetaProblems(root)).toEqual([])
})
it('validates root and multiple exported plugins independently', () => {
manifest({
exports: {
'.': './lib/index.js',
'./search': './lib/search.js',
'./write': './lib/write.js',
'./locale/*.json': './locale/root/*.json',
'./search/locale/*.json': './locale/search/*.json',
'./write/locale/*.json': './locale/write/*.json',
},
})
json('locale/root/en.json', { meta: { title: 'Bundle' } })
json('locale/search/en.json', { meta: { title: 'Search' } })
json('locale/search/zh.json', { meta: { title: '搜索' } })
json('locale/write/en.json', { meta: { description: 'Write files' } })
json('locale/write/zh.json', { meta: { description: '写入文件' } })
expect(packageMetaProblems(root)).toEqual([])
})
it('accepts missing English fields in independently exported plugin metadata', () => {
manifest({
exports: {
'.': './lib/index.js',
'./search': './lib/search.js',
'./locale/*.json': './locale/root/*.json',
'./search/locale/*.json': './locale/search/*.json',
},
})
json('locale/root/en.json', { meta: { description: 'Bundle description' } })
json('locale/search/en.json', { meta: { title: 'Search' } })
json('locale/search/zh.json', { meta: { description: '搜索文件' } })
expect(packageMetaProblems(root)).toEqual([])
})
it.each([
{ '.': './lib/index.js' },
{ '.': './lib/index.js', './locale/*.json': null },
{ '.': './lib/index.js', './locale/*.json': { require: './locale/*.json' } },
{ '.': './lib/index.js', './locale/*.json': './locale/*.json', './locale/en.json': null },
])('rejects a locale file that package exports do not expose: %j', (exports) => {
manifest({ exports: { ...exports, './package.json': './package.json' } })
json('locale/en.json', { meta: { title: 'Plugin' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('locale/en.json')
expect(problems).toContain('exports')
})
it('requires exports for every translated resource', () => {
manifest({ exports: { '.': './lib/index.js', './locale/en.json': './locale/en.json' } })
json('locale/en.json', { meta: { title: 'Plugin' } })
json('locale/zh.json', { meta: { title: '插件' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('locale/zh.json')
expect(problems).toContain('exports')
})
it.each(['locale/search', 'search/locale'])('discovers child metadata without its resource export: %s', (directory) => {
manifest({ exports: { './search': './lib/search.js' }, files: [directory] })
json(`${directory}/en.json`, { meta: { title: 'Search' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain(`${directory}/en.json`)
expect(problems).toContain('exports')
})
it('rejects an inaccessible resource declared outside a locale directory', () => {
manifest({
exports: { './search/locale/*.json': { require: './resources/search/*.json' } },
files: ['resources/search'],
})
json('resources/search/en.json', { meta: { title: 'Search' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('resources/search/en.json')
expect(problems).toContain('exports')
})
it.each([
'./resources/search/*.json',
{ types: './missing/*.json', import: './resources/search/*.json', require: './missing/*.json' },
{ node: { import: './resources/search/*.json' }, default: './missing/*.json' },
['./resources/search/*.json'],
])('accepts resource exports to an independent physical directory: %j', (target) => {
manifest({
exports: { './search': './lib/search.js', './search/locale/*.json': target },
files: ['resources/search'],
})
json('resources/search/en.json', { meta: { title: 'Search' } })
json('resources/search/zh.json', { meta: { title: '搜索' } })
expect(packageMetaProblems(root)).toEqual([])
})
it('accepts individually exported resources in an independent physical directory', () => {
manifest({
exports: {
'./search': './lib/search.js',
'./search/locale/en.json': './resources/search/en.json',
'./search/locale/zh.json': './resources/search/zh.json',
},
files: ['resources'],
})
json('resources/search/en.json', { meta: { title: 'Search' } })
json('resources/search/zh.json', { meta: { title: '搜索' } })
expect(packageMetaProblems(root)).toEqual([])
})
it('ignores metadata in an unselected exports condition', () => {
manifest({
exports: { './locale/*.json': { import: './esm/*.json', require: './cjs/*.json' } },
files: ['esm'],
})
json('esm/en.json', { meta: { title: 'ESM plugin' } })
json('cjs/en.json', { meta: { title: false } })
expect(packageMetaProblems(root)).toEqual([])
})
it.each([
{ default: './locale/*.json', import: './missing/*.json' },
[null, './locale/*.json'],
['../invalid/*.json', './locale/*.json'],
])('uses Node condition order and export-array fallback: %j', (target) => {
manifest({ exports: { './locale/*.json': target } })
json('locale/en.json', { meta: { title: 'Plugin' } })
expect(packageMetaProblems(root)).toEqual([])
})
it('does not fall through a valid exports target just because its file is missing', () => {
manifest({ exports: { './locale/*.json': ['./missing/*.json', './locale/*.json'] } })
json('locale/en.json', { meta: { title: 'Plugin' } })
expect(packageMetaProblems(root).join('\n')).toContain('locale/en.json')
})
it('discovers root and child locale resources through a general export wildcard', () => {
manifest({ exports: { './*': './resources/*' }, files: ['resources'] })
json('resources/locale/en.json', { meta: { title: 'Bundle' } })
json('resources/search/locale/en.json', { meta: { title: 'Search' } })
json('resources/review/locale/en.json', { meta: { title: 'Review' } })
expect(packageMetaProblems(root)).toEqual([])
})
it('rejects an exact resource exclusion under a general export wildcard', () => {
manifest({ exports: { './*': './resources/*', './search/locale/en.json': null }, files: ['resources'] })
json('resources/locale/en.json', { meta: { title: 'Bundle' } })
json('resources/search/locale/en.json', { meta: { title: 'Search' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('search/locale/en.json')
expect(problems).toContain('exports')
})
it('discovers plugin wildcards with repeated target substitutions', () => {
manifest({ exports: { './*/locale/en.json': './resources/*/copy-*/en.json' }, files: ['resources'] })
json('resources/search/copy-search/en.json', { meta: { title: 'Search' } })
json('resources/review/copy-review/en.json', { meta: { title: 'Review' } })
expect(packageMetaProblems(root)).toEqual([])
})
it('rejects files that do not share the repeated export-target substitution', () => {
manifest({ exports: { './*/locale/en.json': './locale/*/copy-*/en.json' }, files: ['locale'] })
json('locale/search/copy-other/en.json', { meta: { title: 'Search' } })
expect(packageMetaProblems(root).join('\n')).toContain('locale/search/copy-other/en.json')
})
it('rejects a missing exported locale target even when a conventional locale file exists', () => {
manifest({ exports: { '.': './lib/index.js', './locale/*.json': './missing/*.json' } })
json('locale/en.json', { meta: { title: 'Plugin' } })
expect(packageMetaProblems(root).join('\n')).toContain('en.json')
})
it('rejects language resources mapped outside the English resource directory', () => {
manifest({
exports: {
'.': './lib/index.js',
'./locale/en.json': './locale/en.json',
'./locale/zh.json': './elsewhere/zh.json',
},
files: ['locale', 'elsewhere'],
})
json('locale/en.json', { meta: { title: 'Plugin' } })
json('locale/zh.json', { meta: { title: '插件' } })
json('elsewhere/zh.json', { meta: { title: '其他' } })
expect(packageMetaProblems(root).join('\n')).toContain('zh.json')
})
it.each([undefined, ['.'], ['./'], ['locale'], ['locale/'], ['./locale'], ['locale/*.json'], ['locale/**'],
['locale/en.json', 'locale/zh.json']])(
'accepts published locale resources: %j',
(files) => {
manifest({ files })
json('locale/en.json', { meta: { title: 'Plugin' } })
json('locale/zh.json', { meta: { title: '插件' } })
expect(packageMetaProblems(root)).toEqual([])
},
)
it.each([
[[], 'en'],
[['lib'], 'en'],
[['locale/en.json'], 'zh'],
[['locale', '!locale/en.json'], 'en'],
[['locale', '!locale/*.json'], 'en'],
[['locale', '!locale'], 'en'],
] as const)('rejects omitted or excluded publication files: %j', (files, language) => {
manifest({ files })
json('locale/en.json', { meta: { title: 'Plugin' } })
json('locale/zh.json', { meta: { title: '插件' } })
expect(packageMetaProblems(root).join('\n')).toContain(`files must include locale/${language}.json`)
})
it.each([{ files: ['locale'] }, { files: ['resources', '!resources/search/zh.json'] }])(
'checks publication paths after resolving resource exports: %j',
({ files }) => {
manifest({
exports: { './search': './lib/search.js', './search/locale/*.json': './resources/search/*.json' },
files,
})
json('resources/search/en.json', { meta: { title: 'Search' } })
json('resources/search/zh.json', { meta: { title: '搜索' } })
expect(packageMetaProblems(root).join('\n')).toContain('files must include resources/search/')
},
)
it.each(['locale', 'resources/search'])('rejects translated metadata without an English resource: %s', (directory) => {
manifest({
exports: { '.': './lib/index.js', './package.json': './package.json', './locale/*.json': `./${directory}/*.json` },
files: [directory],
})
json(`${directory}/zh.json`, { meta: { title: '插件' } })
expect(packageMetaProblems(root).join('\n')).toContain('en.json')
})
it.each([
{ field: 'title', packageInfo: true },
{ field: 'description', packageInfo: true },
{ field: 'title', packageInfo: false },
{ field: 'description', packageInfo: false },
])('accepts a missing English $field with package metadata $packageInfo', ({ field, packageInfo }) => {
manifest({
exports: {
'.': './lib/index.js',
'./locale/*.json': './locale/*.json',
...packageInfo ? { './package.json': './package.json' } : {},
},
})
json('locale/en.json', { meta: {} })
json('locale/zh.json', { meta: { [field]: '译文' } })
expect(packageMetaProblems(root)).toEqual([])
})
it.each([null, [], false, 1, 'text'])('rejects malformed meta: %j', (meta) => {
manifest()
json('locale/en.json', { meta })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('en.json')
expect(problems).toContain('meta')
})
it.each([null, [], false, 1, 'text'])('rejects a non-object English locale document: %j', (contents) => {
manifest()
json('locale/en.json', contents)
expect(packageMetaProblems(root).join('\n')).toContain('en.json')
})
it.each([null, false, 1, {}, [], '', ' '])('rejects a malformed display field: %j', (value) => {
manifest()
json('locale/en.json', { meta: { title: 'Plugin', description: 'About it' } })
json('locale/zh.json', { meta: { title: value } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('zh.json')
expect(problems).toContain('title')
})
it.each(['en', 'zh'])('rejects malformed JSON in %s.json', (language) => {
manifest()
json('locale/en.json', { meta: { title: 'Plugin' } })
file(`locale/${language}.json`, '{')
expect(packageMetaProblems(root).join('\n')).toContain(`${language}.json`)
})
it('rejects invalid language filenames', () => {
manifest()
json('locale/en.json', { meta: { title: 'Plugin' } })
json('locale/not_a_language.json', { meta: { title: 'Other' } })
expect(packageMetaProblems(root).join('\n')).toContain('not_a_language.json')
})
it('rejects case-equivalent language files when the filesystem can hold both', (context) => {
manifest()
json('locale/en.json', { meta: { title: 'Plugin' } })
json('locale/EN.json', { meta: { title: 'Other' } })
if (readdirSync(join(dir, 'locale')).length !== 2) context.skip()
expect(packageMetaProblems(root).join('\n')).toContain('duplicates locale en')
})
it('accepts locale files without metadata alongside an English introduction', () => {
manifest()
json('locale/en.json', { meta: { title: 'Plugin' } })
json('locale/zh.json', { buttons: { save: '保存' }, other: null })
expect(packageMetaProblems(root)).toEqual([])
})
it('reports metadata failures from more than one workspace package', () => {
manifest()
json('locale/en.json', { meta: { title: false } })
dir = join(root, 'packages', 'another', 'second')
manifest({ name: '@test/second' })
json('locale/en.json', { meta: { title: 'Second' } })
json('locale/zh.json', { meta: { description: '' } })
const problems = packageMetaProblems(root).join('\n')
expect(problems).toContain('@test/plugin')
expect(problems).toContain('@test/second')
})
+218
View File
@@ -0,0 +1,218 @@
/** Validate source locale metadata, Node resource exports, and package publication selections. */
import { globSync, readFileSync, realpathSync } from 'node:fs'
import { basename, dirname, join, matchesGlob, relative, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { readPluginMeta, resolvePluginResource } from '../packages/boot/app-boot/src/package-meta.ts'
interface Manifest {
name: string
exports?: unknown
files?: string[]
icon?: unknown
}
interface SourceJson {
file: string
metadata: boolean
invalid: boolean
icon?: unknown
}
function sourceJsonFiles(dir: string): SourceJson[] {
return globSync('**/*.json', { cwd: dir, exclude: ['node_modules', 'lib', 'tests'] }).map((path) => {
const file = path.replaceAll('\\', '/')
let contents: unknown
try {
contents = JSON.parse(readFileSync(join(dir, file), 'utf8'))
} catch (_error) {
return { file, metadata: false, invalid: true }
}
if (typeof contents !== 'object' || contents === null || Array.isArray(contents)) {
return { file, metadata: false, invalid: true }
}
return { file, metadata: Object.hasOwn(contents, 'meta'), invalid: false, icon: 'icon' in contents ? contents.icon : undefined }
})
}
function targetsOf(value: unknown): string[] {
if (typeof value === 'string') return [value]
if (typeof value !== 'object' || value === null) return []
return Object.values(value).flatMap(targetsOf)
}
// Target matching discovers resource requests; Node selects conditions, arrays, and export precedence.
function substitution(pattern: string, file: string): string | undefined {
const index = pattern.indexOf('*')
if (index < 0) return pattern === file ? '' : undefined
const count = pattern.split('*').length - 1
const width = (file.length - pattern.length + count) / count
if (width < 0 || !Number.isInteger(width)) return undefined
const value = file.slice(index, index + width)
return pattern.replaceAll('*', value) === file ? value : undefined
}
function pluginOf(resource: string): string | undefined {
return /^(\..*)\/locale\/[^/]+\.json$/u.exec(resource)?.[1]
}
function published(file: string, files: string[]): boolean {
const covered = (pattern: string): boolean => {
const normalized = pattern.replaceAll('\\', '/').replace(/^\.\//u, '').replace(/\/+$/u, '')
return normalized === '.' || normalized === ''
|| matchesGlob(file, normalized) || matchesGlob(file, `${normalized}/**`)
}
return files.some(pattern => !pattern.startsWith('!') && covered(pattern))
&& !files.some(pattern => pattern.startsWith('!') && covered(pattern.slice(1)))
}
function packageProblems(manifestPath: string): string[] {
const problems: string[] = []
const dir = realpathSync(dirname(manifestPath))
const pkg = JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest
const documents = sourceJsonFiles(dir)
const byPath = new Map(documents.map(document => [join(dir, document.file), document]))
const parentURL = pathToFileURL(join(dir, 'package.json')).href
const specifierOf = (plugin: string): string => pkg.name + (plugin === '.' ? '' : plugin.slice(1))
const candidates = new Set(['.'])
const alternatives = new Map<string, Set<string>>()
const exported = typeof pkg.exports === 'object' && pkg.exports !== null ? Object.entries(pkg.exports) : []
for (const [key, target] of exported) {
if (!key.startsWith('.')) continue
if (!key.includes('*')) {
candidates.add(key)
if (key.endsWith('/package.json')) candidates.add(key.slice(0, -'/package.json'.length))
}
const plugin = pluginOf(key)
if (plugin !== undefined && !plugin.includes('*')) candidates.add(plugin)
for (const pattern of targetsOf(target)) {
for (const document of documents) {
const value = substitution(pattern, `./${document.file}`)
if (value === undefined) continue
const request = key.replaceAll('*', value)
if (request.endsWith('/package.json')) candidates.add(request.slice(0, -'/package.json'.length))
const owner = pluginOf(request)
if (owner === undefined) continue
candidates.add(owner)
let requests = alternatives.get(document.file)
if (requests === undefined) alternatives.set(document.file, requests = new Set())
requests.add(pkg.name + request.slice(1))
}
}
}
const resolved = new Map<string, string | undefined>()
const lookup = (resource: string): string | undefined => {
if (!resolved.has(resource)) {
try {
resolved.set(resource, resolvePluginResource(resource, parentURL))
} catch (_error) {
// Missing exports and files are diagnosed against their source metadata below.
resolved.set(resource, undefined)
}
}
return resolved.get(resource)
}
const filenames = new Set(['en.json', ...documents.map(document => basename(document.file))])
const claimed = new Set<string>()
for (const candidate of candidates) {
const specifier = specifierOf(candidate)
const resourceOf = (filename: string): string => `${specifier}/locale/${filename}`
const resources = [...filenames].flatMap((filename) => {
const file = lookup(resourceOf(filename))
return file === undefined ? [] : [{ filename, file }]
})
const iconManifest = lookup(`${specifier}/package.json`)
const iconDocument = iconManifest === undefined ? undefined : byPath.get(iconManifest)
let iconChecked = false
if (candidate === '.' && pkg.icon !== undefined && iconManifest !== join(dir, 'package.json')) {
problems.push(`${manifestPath}: exports must expose its icon declaration through ${pkg.name}/package.json`)
}
if (iconDocument?.icon !== undefined && resources.every(({ file }) => byPath.has(file))) {
iconChecked = true
const meta = readPluginMeta(specifier, parentURL)
if (meta?.error !== undefined) problems.push(meta.error)
if (meta?.icon !== undefined && typeof iconDocument.icon === 'string') {
const iconFile = relative(dir, resolve(dirname(join(dir, iconDocument.file)), iconDocument.icon)).replaceAll('\\', '/')
if (pkg.files !== undefined && !published(iconFile, pkg.files)) problems.push(`${manifestPath}: files must include ${iconFile}`)
if (iconDocument.file !== 'package.json' && pkg.files !== undefined && !published(iconDocument.file, pkg.files)) {
problems.push(`${manifestPath}: files must include ${iconDocument.file}`)
}
}
} else if (iconDocument?.icon !== undefined) {
problems.push(`${specifier}: icon metadata requires locale resources to resolve to source JSON`)
}
if (!resources.some(({ file }) => byPath.get(file)?.metadata || byPath.get(file)?.invalid)) continue
for (const { file } of resources) claimed.add(file)
const english = lookup(resourceOf('en.json'))
if (english === undefined) {
problems.push(`${manifestPath}: exports must provide ${resourceOf('en.json')} as the locale discovery baseline`)
continue
}
if (!byPath.has(english)) {
problems.push(`${resourceOf('en.json')}: English metadata must resolve to source JSON, received ${english}`)
continue
}
const directory = dirname(english)
let sourceResources = true
for (const { filename, file } of resources) {
if (!byPath.has(file)) {
problems.push(`${resourceOf(filename)}: locale metadata must resolve to source JSON, received ${file}`)
sourceResources = false
}
if (dirname(file) !== directory) {
problems.push(`${resourceOf(filename)}: ${file} must share the English locale directory ${directory}`)
}
}
if (sourceResources && !iconChecked) {
const meta = readPluginMeta(specifier, parentURL)
if (meta?.error !== undefined) problems.push(meta.error)
}
for (const document of documents.filter(document => dirname(join(dir, document.file)) === directory)) {
const resource = resourceOf(basename(document.file))
const file = lookup(resource)
if (file === undefined) {
problems.push(`${manifestPath}: exports must expose ${resource} (${document.file})`)
} else if (file !== join(dir, document.file)) {
problems.push(`${manifestPath}: exports for ${resource} must resolve to ${document.file}, received ${relative(dir, file)}`)
}
if (pkg.files !== undefined && !published(document.file, pkg.files)) {
problems.push(`${manifestPath}: files must include ${document.file}`)
}
}
}
for (const document of documents) {
const requests = alternatives.get(document.file)
if (!document.file.split('/').includes('locale') && requests === undefined) continue
if (!document.metadata && !document.invalid) continue
if (claimed.has(join(dir, document.file))) continue
if (requests !== undefined && [...requests].some((resource) => {
const selected = lookup(resource)
return selected !== undefined && byPath.has(selected)
})) continue
problems.push(`${manifestPath}: exports must expose ${document.file} as a plugin locale resource with an en.json discovery baseline`)
}
return problems
}
/**
* Check static plugin locale metadata and resource publication without evaluating plugin entries or reading built output.
* @param root - repository or fixture root.
* @returns diagnostics for an empty package corpus, invalid metadata, inaccessible resources, or omitted publication files.
*/
export function packageMetaProblems(root: string): string[] {
const manifests = globSync('packages/*/*/package.json', { cwd: root }).map(path => path.replaceAll('\\', '/'))
if (manifests.length === 0) return [`${root}: no workspace package manifests found`]
return manifests.flatMap(path => packageProblems(join(root, path)))
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const problems = packageMetaProblems(resolve(import.meta.dirname, '..'))
if (problems.length > 0) {
console.error(problems.join('\n'))
process.exitCode = 1
} else {
console.log('verify-package-meta: plugin locale metadata, resource exports, and publication files are valid.')
}
}
+73
View File
@@ -0,0 +1,73 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { findPackagePathViolations } from './verify-package-paths.ts'
const historicalPath = 'docs/persistence-changes/historical-formats/v3.md'
const removedReference = 'packages/fs/tool-present/src/types.ts'
const currentReference = 'packages/deliverables/tool-present/src/types.ts'
const start = '<!-- persistence-format-schema:start -->'
const end = '<!-- persistence-format-schema:end -->'
function scan(file: string, source: string) {
const root = mkdtempSync(join(tmpdir(), 'dsh-package-paths-'))
try {
const current = join(root, currentReference)
mkdirSync(dirname(current), { recursive: true })
writeFileSync(current, 'export {}\n')
const path = join(root, file)
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, source)
return findPackagePathViolations(root, path, new Set(['tool-present']))
} finally {
rmSync(root, { recursive: true, force: true })
}
}
describe('historical package references', () => {
it.each(['v0.md', 'v3.md', 'v10.zh.md'])('preserves checked historical source paths in %s', (name) => {
const file = `docs/persistence-changes/historical-formats/${name}`
for (const newline of ['\n', '\r\n']) {
expect(scan(file, [start, removedReference, end].join(newline))).toEqual([])
}
})
it.each(['md', 'zh.md'])('checks authored prose around the generated %s region at its original lines', (suffix) => {
const file = `docs/persistence-changes/historical-formats/v3.${suffix}`
expect(scan(file, [removedReference, start, removedReference, end, removedReference].join('\r\n')))
.toEqual([{ file, line: 1, ref: removedReference }, { file, line: 5, ref: removedReference }])
})
it.each([
'docs/persistence-catalog.md',
'docs/persistence-catalog.zh.md',
'docs/persistence-changes/historical-formats/README.md',
'docs/persistence-changes/historical-formats/v03.md',
'docs/persistence-changes/historical-formats/V3.md',
'docs/persistence-changes/historical-formats-extra/v3.md',
])('keeps generated-looking content strict in %s', (file) => {
expect(scan(file, [start, removedReference, end].join('\n')))
.toEqual([{ file, line: 2, ref: removedReference }])
})
it.each([
['missing end', [start, removedReference]],
['missing start', [end, removedReference]],
['reversed markers', [end, removedReference, start]],
['duplicate start', [start, removedReference, start, end]],
['inline markers', [`${start}${end}`, removedReference]],
['indented marker', [` ${start}`, removedReference, end]],
['malformed marker', ['<!-- persistence-format-schema:start', removedReference, end]],
['split nested marker', [start, removedReference, '<!--\npersistence-format-schema:start -->', end]],
])('rejects stale package paths with %s', (_name, lines) => {
expect(scan(historicalPath, lines.join('\n')))
.toEqual([{ file: historicalPath, line: 2, ref: removedReference }])
})
it('retains existing-path and hypothetical-package handling outside historical content', () => {
const file = 'docs/current.md'
expect(scan(file, [currentReference, 'packages/hypothetical/unpublished/src/types.ts', removedReference].join('\n')))
.toEqual([{ file, line: 3, ref: removedReference }])
})
})
+34 -25
View File
@@ -7,6 +7,8 @@
import { existsSync, globSync } from 'node:fs'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { historicalSchemaRegion } from './historical-schema-region.ts'
import {
findReferenceViolations,
isArchivedAgentNotePath,
@@ -38,16 +40,14 @@ const isExcluded = (p: string): boolean =>
* what scopes the gate to DRIFT (a moved real package) rather than typos or
* not-yet-existing packages named in a proposal.
*/
function realPackageNames(): Set<string> {
function realPackageNames(repoRoot: string): Set<string> {
const names = new Set<string>()
for (const pkg of globSync('packages/*/*', { cwd: root, withFileTypes: true })) {
for (const pkg of globSync('packages/*/*', { cwd: repoRoot, withFileTypes: true })) {
if (pkg.isDirectory()) names.add(pkg.name)
}
return names
}
const packageNames = realPackageNames()
/**
* Match a `packages/<path>` reference token. The character class is plain path
* characters only, so a glob (`*`), placeholder (`<`, `>`), or brace expansion
@@ -57,48 +57,57 @@ const packageNames = realPackageNames()
*/
const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
function isDriftedPackageReference(ref: string): boolean {
if (existsSync(resolve(root, ref))) return false
function isDriftedPackageReference(repoRoot: string, packageNames: ReadonlySet<string>, ref: string): boolean {
if (existsSync(resolve(repoRoot, ref))) return false
// Ignore unbuilt `lib/` paths only under an existing depth-two package root:
// CI runs this gate before build, while stale group-less paths must still fail.
const parts = ref.split('/')
const libAt = parts.indexOf('lib')
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false
if (libAt === 3 && existsSync(resolve(repoRoot, parts.slice(0, 3).join('/')))) return false
// A missing reference is drift only when a path segment names a live package.
// A leading segment that is itself an existing group directory is explained by
// the group, not by a relocated leaf sharing its name (`client` is both the
// client-modules group and the sdk leaf), so only later segments count.
const segments = ref.split('/').slice(1)
const [group] = segments
const scanned = group !== undefined && segments.length > 1 && existsSync(resolve(root, 'packages', group))
const scanned = group !== undefined && segments.length > 1 && existsSync(resolve(repoRoot, 'packages', group))
? segments.slice(1)
: segments
return scanned.some(segment => packageNames.has(segment))
}
/** Find missing package references whose path names a live package; bare paths, typos, and illustrative skeletons do not count. */
function findViolations(absPath: string): Violation[] {
/**
* Find moved-package references outside checked historical schema content.
* @param repoRoot - absolute repository root used to resolve references.
* @param absPath - absolute source file to inspect.
* @param packageNames - current package leaf directory names.
* @returns missing references naming a live package, with original source line numbers.
*/
export function findPackagePathViolations(repoRoot: string, absPath: string, packageNames: ReadonlySet<string>): Violation[] {
return findReferenceViolations(
root,
repoRoot,
absPath,
PKG_REF,
// Remove trailing separators or sentence punctuation matched greedily.
ref => ref.replace(/[./]+$/, ''),
isDriftedPackageReference,
ref => isDriftedPackageReference(repoRoot, packageNames, ref),
historicalSchemaRegion,
)
}
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
const all = files.flatMap(file => findViolations(file.real))
const checked = files.length
if (all.length === 0) {
console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
process.exit(0)
const invokedPath = process.argv[1]
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
if (isMain) {
const packageNames = realPackageNames(root)
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
const all = files.flatMap(file => findPackagePathViolations(root, file.real, packageNames))
if (all.length === 0) {
console.log(`verify-package-paths: ${files.length} file(s) checked, all maintained packages/* references resolve.`)
} else {
console.error('verify-package-paths: broken packages/* references found (target does not exist):')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.ref}`)
}
process.exitCode = 1
}
}
console.error('verify-package-paths: broken packages/* references found (target does not exist):')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.ref}`)
}
process.exit(1)
@@ -56,6 +56,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/document/office-to-pdf': { kind: 'none', reason: 'The provider returns PDF bytes without constructing model input.' },
'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' },
'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' },
'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side presentation of host-observed output; touches no prompt, message, schema, stream, or tool result.' },
'packages/shell/shell': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' },
'packages/shell/shell-env': { kind: 'indirect', reason: 'The env service exposes managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' },
'packages/shell/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' },
@@ -104,7 +105,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-chat': { kind: 'none', reason: 'Browser-side Chat presentation; registers nothing model-facing.' },
'packages/client/ui-message-feedback': { kind: 'none', reason: 'Browser-side controls over log-only message feedback; ratings and notes never enter model context or change model-input tokens.' },
'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' },
'packages/client/ui-jobs': { kind: 'none', reason: 'Browser-side read-only projection of ctx.jobs records; dsh-tool-jobs owns the model-facing behavior.' },
'packages/client/ui-schedule': { kind: 'none', reason: 'Browser-side read-only projection of active Schedule records; dsh-schedule owns the model-facing tools and delivery.' },
'packages/client/ui-workflow-run': { kind: 'none', reason: 'Browser-side UI plugin layer; renders durable workflow records without changing model context.' },
'packages/client/ui-input-trigger': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
@@ -188,6 +188,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/test-support/remote-mock': { kind: 'none', reason: 'Browser-side test infrastructure (Remote endpoint mock); registers nothing model-facing.' },
'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
'packages/api/job-controller': { kind: 'none', reason: 'Job observation is browser and Host control state; it registers no prompt, tool, or session event.' },
'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' },
'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' },
'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' },
+146
View File
@@ -0,0 +1,146 @@
import { execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { RELEASED_V3_EVENT_TYPES } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { verifyV3EventVocabulary } from './verify-v3-event-vocabulary.ts'
const roots: string[] = []
const writerPath = 'packages/core/session/src/types.ts'
const eventsPath = 'packages/core/session/src/known-event-types.ts'
const names = [...RELEASED_V3_EVENT_TYPES]
function write(root: string, path: string, content: string): void {
const target = join(root, path)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, content)
}
function git(root: string, args: readonly string[], environment: NodeJS.ProcessEnv = process.env): string {
const env = Object.fromEntries(Object.entries(environment).filter(([key]) =>
!['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'].includes(key.toUpperCase())))
return execFileSync('git', [
'-c', 'user.name=V3 vocabulary fixture', '-c', '[email protected]',
'-c', 'commit.gpgsign=false', '-c', `core.hooksPath=${join(root, 'empty-hooks')}`, ...args,
], { cwd: root, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim()
}
function fixture(options: { writer?: string; events?: string } = {}): { root: string; sourceRef: string } {
const root = mkdtempSync(join(tmpdir(), 'dsh-v3-vocabulary-'))
roots.push(root)
write(root, writerPath, options.writer ?? 'export const SESSION_FORMAT_VERSION = 3 as const\n')
write(root, eventsPath, options.events ?? `export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set(${JSON.stringify(names)})\n`)
git(root, ['init', '--quiet'])
git(root, ['add', '.'])
git(root, ['commit', '--quiet', '-m', 'V3 source fixture'])
return { root, sourceRef: git(root, ['rev-parse', 'HEAD']) }
}
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
describe('explicit V3 source vocabulary verification', () => {
it('keeps fixture writes inside their own repository despite inherited Git location variables', () => {
const target = fixture()
const foreign = fixture()
write(target.root, 'target-only.txt', 'target')
write(foreign.root, 'foreign-only.txt', 'foreign')
const before = git(foreign.root, ['status', '--porcelain'])
const environment = {
...process.env,
GIT_DIR: join(foreign.root, '.git'),
GIT_WORK_TREE: foreign.root,
GIT_INDEX_FILE: join(foreign.root, '.git', 'foreign-index'),
}
git(target.root, ['add', '--all'], environment)
git(target.root, ['commit', '--quiet', '-m', 'Target fixture update'], environment)
expect(git(foreign.root, ['rev-parse', 'HEAD'])).toBe(foreign.sourceRef)
expect(git(foreign.root, ['status', '--porcelain'])).toBe(before)
expect(git(target.root, ['show', 'HEAD:target-only.txt'])).toBe('target')
expect(environment.GIT_DIR).toBe(join(foreign.root, '.git'))
})
it('checks a pinned V3 writer independently of later V4 commits', () => {
const { root, sourceRef } = fixture()
write(root, writerPath, 'export const SESSION_FORMAT_VERSION = 4 as const\n')
write(root, eventsPath, `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'developer/message'])})\n`)
git(root, ['add', '.'])
git(root, ['commit', '--quiet', '-m', 'Later V4 writer with developer events'])
expect(verifyV3EventVocabulary(root, sourceRef)).toEqual({ sourceRef, eventCount: names.length })
})
it('rejects new V3 names even when the target writer has not classified them yet', () => {
const { root, sourceRef } = fixture({
events: `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'new-v3/required', 'new-v3/ignorable'])})\n`,
})
expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow('Missing V3 names: new-v3/ignorable, new-v3/required')
expect(verifyV3EventVocabulary(root, sourceRef, new Set([...names, 'new-v3/required', 'new-v3/ignorable'])).eventCount)
.toBe(names.length + 2)
})
it('rejects a V4 source instead of treating developer/message as a V3 event', () => {
const { root, sourceRef } = fixture({
writer: 'export const SESSION_FORMAT_VERSION = 4 as const\n',
events: `export const KNOWN_SESSION_EVENT_TYPES = new Set(${JSON.stringify([...names, 'developer/message'])})\n`,
})
expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow('must be a V3 writer')
})
it('rejects a V4-only name accidentally copied into the migration-owned V3 set', () => {
const { root, sourceRef } = fixture()
expect(() => verifyV3EventVocabulary(root, sourceRef, new Set([...names, 'developer/message'])))
.toThrow('Extra migration names: developer/message')
})
it.each(['HEAD', 'origin/master', 'dsh-v0.1.5-alpha.1', 'a'.repeat(10), '', '--help'])('rejects a mutable or incomplete source pin %j', (ref) => {
expect(() => verifyV3EventVocabulary(tmpdir(), ref)).toThrow('full immutable commit id')
})
it('reports a source object missing from the local repository without fetching', () => {
const { root } = fixture()
expect(() => verifyV3EventVocabulary(root, '0'.repeat(40))).toThrow('not available locally')
})
it('rejects tree objects and reports missing generated files at the source pin', () => {
const { root, sourceRef } = fixture()
expect(() => verifyV3EventVocabulary(root, git(root, ['rev-parse', `${sourceRef}^{tree}`])))
.toThrow('must name a commit object')
git(root, ['rm', eventsPath])
git(root, ['commit', '--quiet', '-m', 'Source without generated event inventory'])
expect(() => verifyV3EventVocabulary(root, git(root, ['rev-parse', 'HEAD']))).toThrow('not available locally')
})
it('accepts harmless parentheses and reordered generated event names', () => {
const { root, sourceRef } = fixture({
writer: 'export const SESSION_FORMAT_VERSION = (3 as const)\n',
events: `export const KNOWN_SESSION_EVENT_TYPES = (new Set(${JSON.stringify([...names].reverse())}))\n`,
})
expect(verifyV3EventVocabulary(root, sourceRef).eventCount).toBe(names.length)
})
it.each([
'const SESSION_FORMAT_VERSION = 3\n',
'export const SESSION_FORMAT_VERSION: number\n',
'export const SESSION_FORMAT_VERSION = 3, SESSION_FORMAT_VERSION = 3\n',
'export const SESSION_FORMAT_VERSION = "3"\n',
])('rejects an ambiguous or nonliteral writer declaration %j', (writer) => {
const { root, sourceRef } = fixture({ writer })
expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow()
})
it.each([
'export const OTHER = new Set([])\n',
'export const KNOWN_SESSION_EVENT_TYPES = []\n',
'export const KNOWN_SESSION_EVENT_TYPES = new Map([])\n',
'export const KNOWN_SESSION_EVENT_TYPES = new Set()\n',
'export const KNOWN_SESSION_EVENT_TYPES = new Set(undefined)\n',
'export const KNOWN_SESSION_EVENT_TYPES = new Set([...otherNames])\n',
'export const KNOWN_SESSION_EVENT_TYPES = new Set([computeName()])\n',
'export const KNOWN_SESSION_EVENT_TYPES = new Set(["duplicate", "duplicate"])\n',
])('rejects unsupported generated declarations without evaluating them: %j', (events) => {
const { root, sourceRef } = fixture({ events })
expect(() => verifyV3EventVocabulary(root, sourceRef)).toThrow()
})
})
+105
View File
@@ -0,0 +1,105 @@
/** Compare the V3 migration vocabulary with an explicitly pinned local V3 writer. */
import { execFileSync } from 'node:child_process'
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import ts from 'typescript'
import { RELEASED_V3_EVENT_TYPES } from '@deepseek-ai/dsh-session-format-v3-to-v4'
import { isEntry } from './release/process.ts'
const WRITER_PATH = 'packages/core/session/src/types.ts'
const EVENTS_PATH = 'packages/core/session/src/known-event-types.ts'
const COMMIT_ID = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/u
function sourceAt(root: string, sourceRef: string, path: string): string {
try {
return execFileSync('git', ['show', `${sourceRef}:${path}`], {
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_NO_LAZY_FETCH: '1' },
})
} catch (error) {
throw new Error(`V3 source ${sourceRef}:${path} is not available locally; prepare the verified source pin before running this check`, { cause: error })
}
}
function initializer(source: string, path: string, name: string): ts.Expression {
const file = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
const declarations = file.statements.flatMap(statement =>
ts.isVariableStatement(statement) && statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)
? statement.declarationList.declarations.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name)
: [])
let value = declarations.length === 1 ? declarations[0]?.initializer : undefined
if (value === undefined) throw new Error(`${path}: expected one initialized export ${name}`)
while (ts.isAsExpression(value) || ts.isParenthesizedExpression(value)) value = value.expression
return value
}
function sourceEventTypes(source: string): ReadonlySet<string> {
const value = initializer(source, EVENTS_PATH, 'KNOWN_SESSION_EVENT_TYPES')
const entries = ts.isNewExpression(value) && value.arguments?.length === 1 ? value.arguments[0] : undefined
if (!ts.isNewExpression(value) || !ts.isIdentifier(value.expression) || value.expression.text !== 'Set'
|| entries === undefined || !ts.isArrayLiteralExpression(entries)) {
throw new Error(`${EVENTS_PATH}: KNOWN_SESSION_EVENT_TYPES must be a literal Set`)
}
const names = entries.elements.map((element) => {
if (!ts.isStringLiteral(element)) throw new Error(`${EVENTS_PATH}: event names must be string literals`)
return element.text
})
const result = new Set(names)
if (result.size !== names.length) throw new Error(`${EVENTS_PATH}: duplicate event name`)
return result
}
/** Result tied to the exact historical commit that was inspected. */
export interface V3EventVocabularyCheck {
readonly sourceRef: string
readonly eventCount: number
}
/**
* Verify the complete source vocabulary without consulting remote or current-writer event lists.
* @param root - Git checkout containing the already available source commit.
* @param sourceRef - full immutable commit id for the V3 writer used by the release integration.
* @param expected - migration-owned V3 event names to check.
* @returns the checked source pin and event count; does not establish remote freshness.
* @throws when the pin is missing, does not write V3, or its event names differ.
*/
export function verifyV3EventVocabulary(
root: string, sourceRef: string, expected: ReadonlySet<string> = RELEASED_V3_EVENT_TYPES,
): V3EventVocabularyCheck {
if (!COMMIT_ID.test(sourceRef)) throw new Error('--source-ref requires a full immutable commit id, not a branch, tag, or abbreviated id')
let objectType: string
try {
objectType = execFileSync('git', ['cat-file', '-t', sourceRef], {
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_NO_LAZY_FETCH: '1' },
}).trim()
} catch (error) {
throw new Error(`V3 source commit ${sourceRef} is not available locally; prepare the verified source pin before running this check`, { cause: error })
}
if (objectType !== 'commit') throw new Error('--source-ref must name a commit object')
const writer = initializer(sourceAt(root, sourceRef, WRITER_PATH), WRITER_PATH, 'SESSION_FORMAT_VERSION')
if (!ts.isNumericLiteral(writer) || Number(writer.text) !== 3) {
throw new Error(`source ${sourceRef} must be a V3 writer; keep V4-only events out of RELEASED_V3_EVENT_TYPES`)
}
const actual = sourceEventTypes(sourceAt(root, sourceRef, EVENTS_PATH))
const missing = [...actual].filter(name => !expected.has(name)).sort()
const extra = [...expected].filter(name => !actual.has(name)).sort()
if (missing.length > 0 || extra.length > 0) {
throw new Error([
`V3 event vocabulary differs from source ${sourceRef}; review source payload conversion before updating the migration-owned set.`,
`Missing V3 names: ${missing.join(', ') || '(none)'}`,
`Extra migration names: ${extra.join(', ') || '(none)'}`,
].join('\n'))
}
return { sourceRef, eventCount: actual.size }
}
function main(): void {
const { values } = parseArgs({ options: { 'source-ref': { type: 'string' } }, allowPositionals: false })
if (values['source-ref'] === undefined) throw new Error('usage: verify-v3-event-vocabulary --source-ref <full-V3-writer-commit-id>')
const result = verifyV3EventVocabulary(resolve(process.cwd()), values['source-ref'])
console.log(`verify-v3-event-vocabulary: ${result.eventCount} names match V3 writer ${result.sourceRef}; remote freshness was not checked.`)
}
if (isEntry(import.meta.url)) main()
+11
View File
@@ -0,0 +1,11 @@
/** Shared volatile schema fixture for the schema and Loader volatile specs. */
import z from '@deepseek-ai/schemastery'
/** Mixed ordinary, volatile, nested and whole-object volatile fields. */
export const Config = z.object({
fixed: z.string().default('fixed'),
title: z.string().default('hello').volatile(),
optional: z.string().volatile(),
nested: z.object({ count: z.number().min(0).volatile().default(1) }).default({}),
group: z.object({ list: z.array(z.string()) }).default({ list: [] }).volatile(),
})
+151
View File
@@ -0,0 +1,151 @@
/** Schemastery and Cordis config reference behavior through direct and Loader updates. */
import { describe, expect, expectTypeOf, it } from 'vitest'
import { type Volatile } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { Config } from './volatile-config.fixture.ts'
import { createVolatile, deepEqual, volatileEntries } from '@deepseek-ai/cosmokit'
import { redactSecrets } from '../packages/settings/settings/src/redact.ts'
describe('volatile schemas', () => {
it('parses references with defaults and absent values while preserving schema metadata', () => {
const parsed = Config({})
expect(parsed.title.get()).toBe('hello')
expect(parsed.optional.get()).toBeUndefined()
expect(parsed.nested.count.get()).toBe(1)
expectTypeOf(parsed.title).toEqualTypeOf<Volatile<string>>()
expectTypeOf(parsed.optional).toEqualTypeOf<Volatile<string | undefined>>()
expectTypeOf(parsed.nested.count).toEqualTypeOf<Volatile<number>>()
expectTypeOf(z.string().volatile().description('Live text').default('text')().get()).toEqualTypeOf<string>()
expectTypeOf(z.string().required().volatile()('text').get()).toEqualTypeOf<string>()
expect(Config.dict?.title?.type).toBe('string')
expect(Config.dict?.title?.meta.default).toBe('hello')
expect(Config.simplify(parsed)).toBeNull()
const reconstructed = new z<unknown>(JSON.parse(JSON.stringify(Config.toJSON())) as z<unknown>)
expect((reconstructed({ title: 'changed' }) as ReturnType<typeof Config>).title.get()).toBe('changed')
expect(reconstructed.simplify(reconstructed({ title: 'changed' }))).toEqual({ title: 'changed' })
expect(z.string().volatile().required()('present').get()).toBe('present')
expect(() => z.string().volatile().required()()).toThrow('missing required value')
expect(() => Config({ nested: { count: -1 } })).toThrow()
expect(Config({ nested: { count: -1 } }, { autofix: true }).nested.count.get()).toBe(1)
})
it('copies and freezes data snapshots without freezing the input', () => {
const input = { group: { list: ['a'] } }
const parsed = Config(input)
input.group.list.push('b')
expect(parsed.group.get()).toEqual({ list: ['a'] })
expect(Object.isFrozen(parsed.group.get())).toBe(true)
expect(Object.isFrozen(parsed.group.get().list)).toBe(true)
expect(() => (parsed.group.get().list as string[]).push('c')).toThrow()
expect(Object.keys(parsed.group)).toEqual(['get'])
expect(() => z.any().volatile()(new Date())).toThrow('plain objects')
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(() => z.any().volatile()(cyclic)).toThrow('cycles')
})
it.each([
['array', () => z.object({ items: z.array(z.string().volatile()) }), 'items'],
['dict', () => z.object({ items: z.dict(z.string().volatile()) }), 'items'],
['union', () => z.union([z.object({ title: z.string().volatile() }), z.string()]), 'title'],
['tuple', () => z.object({ items: z.tuple([z.string().volatile()]) }), 'items'],
['intersect', () => z.intersect([z.object({ title: z.string().volatile() })]), 'title'],
['transform', () => z.transform(z.object({ title: z.string().volatile() }), value => value), 'title'],
['nested', () => z.object({ block: z.object({ title: z.string().volatile() }).volatile() }), 'block'],
])('rejects %s placements even when input omits the field', (_name, schema, path) => {
expect(() => schema()({})).toThrow(path)
})
it('rejects repeat wrapping and supports an entire array or union as a value', () => {
expect(() => z.string().volatile().volatile()).toThrow('already wrapped')
expect(z.array(z.string()).volatile()(['a']).get()).toEqual(['a'])
expect(z.union([z.string(), z.number()]).volatile()(1).get()).toBe(1)
const schema = z.string().volatile()
expect(schema('first')).not.toBe(schema('first'))
})
it('retains ordinary fields and secret traversal in serialized schemas', () => {
const schema = z.object({ block: z.object({ apiKey: z.string().role('secret'), title: z.string() }).volatile() })
const restored = new z<unknown>(JSON.parse(JSON.stringify(schema.toJSON())) as z<unknown>)
expect(restored.dict?.block?.type).toBe('object')
expect(restored.dict?.block?.dict?.apiKey?.meta.role).toBe('secret')
expect(redactSecrets(restored as z<never>, { block: { apiKey: 'secret', title: 'public' } }).value)
.toEqual({ block: { title: 'public' } })
})
it('accepts metadata-free serialized nodes and checks their nested volatile declarations', () => {
const schema = new z<{ enabled: boolean }>(JSON.parse('{"type":"object","dict":{"enabled":{"type":"boolean"}}}') as z<{ enabled: boolean }>)
expect(schema({ enabled: true })).toEqual({ enabled: true })
const dynamic = new z<unknown>(JSON.parse(
'{"type":"object","dict":{"items":{"type":"array","inner":{"type":"string","meta":{"volatile":true}}}}}',
) as z<unknown>)
expect(() => dynamic({ items: [] })).toThrow('items')
})
it('resolves recursive lazy schemas only for the supplied data', () => {
interface Tree { name: string; children?: Tree[] }
function tree(): z<Tree> {
return z.object({ name: z.string(), children: z.array(z.lazy(tree)).default([]) })
}
expect(tree()({ name: 'root' })).toEqual({ name: 'root', children: [] })
expect(tree()({ name: 'root', children: [{ name: 'leaf' }] }))
.toEqual({ name: 'root', children: [{ name: 'leaf', children: [] }] })
const schema = z.object({ lazy: z.lazy(() => z.object({ live: z.string().volatile() })) })
expect(() => schema({ lazy: { live: 'value' } })).toThrow('lazy')
})
})
describe('volatile comparison semantics', () => {
it('ignores reference contents while retaining ordinary differences', () => {
const first = Config({ title: 'a' })
const second = Config({ title: 'b' })
expect(deepEqual(first, second, true)).toBe(true)
expect(deepEqual(first, Config({ fixed: 'changed' }), true)).toBe(false)
expect(deepEqual(first.title, 'a', true)).toBe(false)
expect(deepEqual('a', first.title, true)).toBe(false)
expect(deepEqual([undefined], [null], true)).toBe(false)
expect(deepEqual([undefined], [null])).toBe(true)
expect(deepEqual(new Date(0), new Date(0), true)).toBe(true)
expect(deepEqual(/a/g, /a/i, true)).toBe(false)
expect(deepEqual(new URL('https://a.example'), new URL('https://b.example'), true)).toBe(false)
expect(deepEqual(new URL('https://EXAMPLE.com:443/'), new URL('https://example.com'), true)).toBe(true)
expect(deepEqual(new URL('https://example.com'), {}, true)).toBe(false)
expect(deepEqual({}, new URL('https://example.com'), true)).toBe(false)
expect(deepEqual(new Uint8Array([1]).buffer, new Uint8Array([2]).buffer, true)).toBe(false)
const firstCycle: Record<string, unknown> = {}; firstCycle.self = firstCycle
const secondCycle: Record<string, unknown> = {}; secondCycle.self = secondCycle
expect(deepEqual(firstCycle, secondCycle, true)).toBe(false)
})
})
it('enumerates references through shared config containers without descending into snapshots or cycles', () => {
const ref = createVolatile({ nested: 'value' })
const shared = Object.assign(Object.create(null), { live: ref }) as Record<string, unknown>
const config: Record<string, unknown> = { list: [shared, shared], opaque: new URL('https://example.com'), plain: 'value' }
config.self = config
expect(volatileEntries(config)).toEqual([
{ path: ['list', '0', 'live'], ref },
{ path: ['list', '1', 'live'], ref },
])
expect(volatileEntries(ref)).toEqual([{ path: [], ref }])
})
it('compares sparse array slots as undefined in both directions', () => {
for (const strict of [true, false]) {
expect(deepEqual(Array(1), [undefined], strict)).toBe(true)
expect(deepEqual([undefined], Array(1), strict)).toBe(true)
expect(deepEqual(Array(1), ['new'], strict)).toBe(false)
expect(deepEqual(['new'], Array(1), strict)).toBe(false)
expect(deepEqual(Array(1), [], strict)).toBe(false)
expect(deepEqual([], Array(1), strict)).toBe(false)
}
expect(deepEqual(Array(1), [null], true)).toBe(false)
expect(deepEqual([null], Array(1), true)).toBe(false)
})
it('reports unsupported volatile values as validation issues with their path', () => {
const schema = z.object({ block: z.object({ when: z.date().volatile() }) })
expect(() => schema({ block: { when: new Date(0) } })).toThrow(z.ValidationError)
expect(() => schema({ block: { when: new Date(0) } })).toThrow('$.block.when')
expect(schema({ block: {} }).block.when.get()).toBeUndefined()
})
+5 -6
View File
@@ -90,12 +90,11 @@ describe('default Web bundle input isolation', () => {
})
it('allows experimental code in the separately emitted preview entry', async () => {
await expect(fixture().run()).resolves.toMatchObject({
output: expect.arrayContaining([
expect.objectContaining({ type: 'asset', fileName: 'index.html' }),
expect.objectContaining({ type: 'chunk', name: 'bootstrap' }),
]) as unknown,
})
const output: unknown = expect.arrayContaining([
expect.objectContaining({ type: 'asset', fileName: 'index.html' }),
expect.objectContaining({ type: 'chunk', name: 'bootstrap' }),
])
await expect(fixture().run()).resolves.toMatchObject({ output })
})
it.each([