Merge remote-tracking branch 'origin/master' into feat/read-image-context
# Conflicts: # apps/cli/tests/web-agent-presets.e2e.ts # packages/fs/tool-fs/package.json
This commit is contained in:
@@ -8,7 +8,7 @@
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve, sep } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
@@ -16,8 +16,8 @@ const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The closure manifest whose dependencies define the executable. */
|
||||
const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
|
||||
/** The app entry inside the deployed closure. */
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js'
|
||||
/** The closed-runtime app entry inside the deployed closure. */
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js'
|
||||
const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
|
||||
/** Default Node major; SEA mode requires at least Node 22. */
|
||||
const DEFAULT_NODE_RANGE = 'node24'
|
||||
@@ -28,6 +28,8 @@ const OUT_DIR = 'dist-exe'
|
||||
const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime'
|
||||
/** The deployed closure doubles as the node-mode carrier. */
|
||||
const PYTHON_NODE_SUBDIR = 'node'
|
||||
/** Legacy deploy may hoist peer-specialized workspace packages back here. */
|
||||
const DEPLOY_SOURCE_NODE_MODULES = 'python/sdk-runtime/node_modules'
|
||||
/** Documentation excluded from the generated runtime directory. */
|
||||
const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
|
||||
|
||||
@@ -256,6 +258,8 @@ class SingleExeBuild {
|
||||
'--config.link-workspace-packages=true',
|
||||
this.staging,
|
||||
])
|
||||
await this.restoreLegacyHoists()
|
||||
await this.materializeStagedLinks()
|
||||
if (this.cli.dryRun) {
|
||||
for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`)
|
||||
} else {
|
||||
@@ -263,6 +267,94 @@ class SingleExeBuild {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore direct packages that pnpm's legacy hoister places beside the deploy
|
||||
* source instead of in the target. The runtime manifest supplies every peer,
|
||||
* so package-local node_modules trees are omitted to preserve one flat Cordis
|
||||
* instance and a symlink-free packaged payload.
|
||||
*/
|
||||
private async restoreLegacyHoists(): Promise<void> {
|
||||
if (this.cli.dryRun) {
|
||||
console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy')
|
||||
return
|
||||
}
|
||||
const manifestPath = join(this.staging, 'package.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES)
|
||||
const restored: string[] = []
|
||||
for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) {
|
||||
const destination = join(this.staging, 'node_modules', dependency)
|
||||
if (existsSync(destination)) continue
|
||||
const source = join(sourceNodeModules, dependency)
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(
|
||||
`build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`,
|
||||
)
|
||||
}
|
||||
await mkdir(dirname(destination), { recursive: true })
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
restored.push(dependency)
|
||||
}
|
||||
const stillMissing = Object.keys(manifest.dependencies ?? {})
|
||||
.filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency)))
|
||||
if (stillMissing.length > 0) {
|
||||
throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`)
|
||||
}
|
||||
if (restored.length > 0) {
|
||||
console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Replace deploy-time package links with files and reject any remaining link. */
|
||||
private async materializeStagedLinks(): Promise<void> {
|
||||
if (this.cli.dryRun) {
|
||||
console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links')
|
||||
return
|
||||
}
|
||||
const nodeModules = join(this.staging, 'node_modules')
|
||||
let remaining = await this.findSymlink(nodeModules)
|
||||
while (remaining !== undefined) {
|
||||
const segments = remaining.slice(nodeModules.length + 1).split(sep)
|
||||
const binIndex = segments.lastIndexOf('.bin')
|
||||
if (binIndex >= 0) {
|
||||
await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true })
|
||||
remaining = await this.findSymlink(nodeModules)
|
||||
continue
|
||||
}
|
||||
const destination = remaining
|
||||
const source = await realpath(destination)
|
||||
const nestedNodeModules = join(source, 'node_modules')
|
||||
await rm(destination, { recursive: true, force: true })
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep),
|
||||
})
|
||||
remaining = await this.findSymlink(nodeModules)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the first symbolic link below a directory, if one exists. */
|
||||
private async findSymlink(directory: string): Promise<string | undefined> {
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = join(directory, entry.name)
|
||||
const metadata = await lstat(path)
|
||||
if (metadata.isSymbolicLink()) return path
|
||||
if (metadata.isDirectory()) {
|
||||
const nested = await this.findSymlink(path)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Add the executable entry and pkg assets to the staged manifest. */
|
||||
async injectPkgConfig(): Promise<void> {
|
||||
const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
|
||||
|
||||
@@ -17,6 +17,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SDK_DISTRIBUTION = "deepseek-harness-sdk"
|
||||
RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin"
|
||||
PLATFORMS = {
|
||||
"linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"),
|
||||
"linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"),
|
||||
@@ -41,6 +43,8 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
version = repository_version()
|
||||
validate_release_tag(args.tag, version)
|
||||
# Wheels carry the PEP 440 spelling; the tag keeps the repository spelling.
|
||||
wheel_version = pep440_version(version)
|
||||
if args.package == "runtime" and (args.platform is None or args.runtime_exe is None):
|
||||
parser.error("runtime builds require --platform and --runtime-exe")
|
||||
if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None):
|
||||
@@ -51,19 +55,19 @@ def main() -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary:
|
||||
staging = Path(temporary) / args.package
|
||||
if args.package == "sdk":
|
||||
stage_sdk(staging, version)
|
||||
stage_sdk(staging, wheel_version)
|
||||
environment = None
|
||||
expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl"
|
||||
expected = output_dir / f"deepseek_harness_sdk-{wheel_version}-py3-none-any.whl"
|
||||
else:
|
||||
platform_tag, executable_name = PLATFORMS[args.platform]
|
||||
stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name)
|
||||
stage_runtime(staging, wheel_version, args.runtime_exe.resolve(), executable_name)
|
||||
environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag}
|
||||
expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl"
|
||||
expected = output_dir / f"deepseek_harness_runtime_bin-{wheel_version}-py3-none-{platform_tag}.whl"
|
||||
command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)]
|
||||
subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True)
|
||||
if not expected.is_file():
|
||||
raise RuntimeError(f"build did not produce expected wheel: {expected}")
|
||||
verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform])
|
||||
verify_wheel(expected, args.package, wheel_version, None if args.platform is None else PLATFORMS[args.platform])
|
||||
print(expected)
|
||||
|
||||
|
||||
@@ -74,13 +78,35 @@ def repository_version(root: Path = ROOT) -> str:
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise ValueError(f"could not read repository version from {package_json}") from error
|
||||
version = payload.get("version") if isinstance(payload, dict) else None
|
||||
if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+", version) is None:
|
||||
if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?", version) is None:
|
||||
raise ValueError(
|
||||
f"{package_json} version must be stable X.Y.Z, got {version!r}"
|
||||
f"{package_json} version must be X.Y.Z with an optional prerelease segment, got {version!r}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def pep440_version(version: str) -> str:
|
||||
"""The Python spelling of a repository version.
|
||||
|
||||
A release candidate is `0.0.1-rc.1` in the repository and `0.0.1rc1` under
|
||||
PEP 440. Build backends normalize to the latter, so the wheel filename and
|
||||
metadata carry it: comparing them against the repository spelling would
|
||||
reject every prerelease build.
|
||||
"""
|
||||
stable, separator, prerelease = version.partition("-")
|
||||
if not separator:
|
||||
return stable
|
||||
match = re.fullmatch(r"(a|b|c|rc|alpha|beta|pre|preview)\.?(\d+)", prerelease)
|
||||
if match is None:
|
||||
raise ValueError(
|
||||
f"prerelease segment {prerelease!r} has no PEP 440 spelling; use rc.N, alpha.N, or beta.N"
|
||||
)
|
||||
identifier = {"alpha": "a", "beta": "b", "c": "rc", "pre": "rc", "preview": "rc"}.get(
|
||||
match.group(1), match.group(1)
|
||||
)
|
||||
return f"{stable}{identifier}{match.group(2)}"
|
||||
|
||||
|
||||
def validate_release_tag(tag: str | None, version: str) -> None:
|
||||
if tag is None:
|
||||
return
|
||||
@@ -160,6 +186,11 @@ def verify_wheel(
|
||||
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}"
|
||||
)
|
||||
runtime_files = [
|
||||
name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name
|
||||
]
|
||||
@@ -177,7 +208,7 @@ def verify_wheel(
|
||||
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}")
|
||||
if package == "sdk":
|
||||
requirements = metadata.get_all("Requires-Dist") or []
|
||||
expected_requirement = f"deepseek-harness-runtime-bin=={version}"
|
||||
expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}"
|
||||
if expected_requirement not in requirements:
|
||||
raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}")
|
||||
|
||||
|
||||
@@ -21,15 +21,15 @@ const workspaceGlobs = [
|
||||
{ dir: 'apps', depth: 1 },
|
||||
] as const
|
||||
const vendoredPackages = new Set([
|
||||
'cordis',
|
||||
'cosmokit',
|
||||
'schemastery',
|
||||
'@cordisjs/plugin-loader',
|
||||
'@cordisjs/plugin-include',
|
||||
'@cordisjs/plugin-group',
|
||||
'@cordisjs/plugin-timer',
|
||||
'@cordisjs/plugin-hmr',
|
||||
'@cordisjs/plugin-logger-console',
|
||||
'@deepseek-ai/cordis',
|
||||
'@deepseek-ai/cosmokit',
|
||||
'@deepseek-ai/schemastery',
|
||||
'@deepseek-ai/cordis-plugin-loader',
|
||||
'@deepseek-ai/cordis-plugin-include',
|
||||
'@deepseek-ai/cordis-plugin-group',
|
||||
'@deepseek-ai/cordis-plugin-timer',
|
||||
'@deepseek-ai/cordis-plugin-hmr',
|
||||
'@deepseek-ai/cordis-plugin-logger-console',
|
||||
])
|
||||
const publicLandlockPackages = new Set([
|
||||
'@deepseek-ai/node-addon-landlock-run',
|
||||
@@ -41,6 +41,14 @@ const publicationSourceAllowlist: Readonly<Record<string, readonly string[]>> =
|
||||
'@deepseek-ai/node-addon-landlock-run': ['src/main.c'],
|
||||
}
|
||||
const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git'
|
||||
/**
|
||||
* Source home the published packages point consumers at. It differs from
|
||||
* {@link repositoryUrl}, which the Landlock packages keep because npm resolves
|
||||
* their trusted publishing against the repository that runs the workflow.
|
||||
*/
|
||||
const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git'
|
||||
/** Directories whose packages this repository publishes: one release member each. */
|
||||
const releaseMemberDirectory = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/
|
||||
|
||||
const localArtifactDirs = new Set(['node_modules'])
|
||||
const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
|
||||
@@ -72,6 +80,8 @@ interface PackageManifest {
|
||||
repository?: { type?: string; url?: string; directory?: string }
|
||||
peerDependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
/** One workspace manifest and its repo-relative path. */
|
||||
@@ -126,9 +136,13 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/dsh-headless': ['cordis.patch.yml'],
|
||||
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
|
||||
'@deepseek-ai/dsh-helper': ['lib/assets'],
|
||||
// The Python runtime uses a distinct closed-resolution bin; the public CLI
|
||||
// keeps config-owned bare-package resolution through lib/bin.js.
|
||||
'@deepseek-ai/dsh-jsonrpc-demo': ['lib/packaged-bin.js'],
|
||||
// The argv-prefix runner entry ships beside the lib as its own bundle;
|
||||
// sandbox-local resolves it through the package's ./runner export.
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'],
|
||||
// sandbox-local resolves it through the package's ./runner export. tsdown
|
||||
// also shares its generated FFI code through a hashed runtime chunk.
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
|
||||
'@deepseek-ai/dsh-skill-badge': ['assets'],
|
||||
'@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'],
|
||||
'@deepseek-ai/dsh-scripts': [
|
||||
@@ -161,6 +175,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] {
|
||||
...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [],
|
||||
// web-react's store subpath ships its own bundle (single-entry builds; no shared chunk).
|
||||
...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [],
|
||||
// A surface bundle's startup row is its own bundle: the Loader imports it
|
||||
// as a row module, so it cannot ride inside the package entry.
|
||||
...exportDefault(manifest, './startup') === './lib/startup.js' ? ['lib/startup.js'] : [],
|
||||
...extras,
|
||||
// Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js —
|
||||
// browser-safe source channels rehomed off src so plain Node can import
|
||||
@@ -225,8 +242,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
if (manifest.private === true) {
|
||||
errors.push(`${label}: published Landlock package must not set "private": true`)
|
||||
}
|
||||
if (manifest.publishConfig?.access !== 'public') {
|
||||
errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`)
|
||||
if (manifest.publishConfig?.access !== 'restricted') {
|
||||
errors.push(`${label}: published Landlock package must set publishConfig.access to "restricted"`)
|
||||
}
|
||||
const expectedDirectory = dir
|
||||
if (manifest.repository?.type !== 'git'
|
||||
@@ -234,6 +251,21 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
|| manifest.repository.directory !== expectedDirectory) {
|
||||
errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`)
|
||||
}
|
||||
} else if (releaseMemberDirectory.test(dir)) {
|
||||
// Release members state that they are publishable: npm refuses a private
|
||||
// package, the scope is published privately, and the repository field is
|
||||
// how a consumer of a private package finds its source.
|
||||
if (manifest.private === true) {
|
||||
errors.push(`${label}: release member must not set "private": true`)
|
||||
}
|
||||
if (manifest.publishConfig?.access !== 'restricted') {
|
||||
errors.push(`${label}: release member must set publishConfig.access to "restricted"`)
|
||||
}
|
||||
if (manifest.repository?.type !== 'git'
|
||||
|| manifest.repository.url !== publishedRepositoryUrl
|
||||
|| manifest.repository.directory !== dir) {
|
||||
errors.push(`${label}: release member repository must use ${publishedRepositoryUrl} with directory ${dir}`)
|
||||
}
|
||||
} else if (manifest.private !== true) {
|
||||
errors.push(`${label}: package.json must set "private": true`)
|
||||
}
|
||||
@@ -271,13 +303,13 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
}
|
||||
|
||||
if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) {
|
||||
const peer = manifest.peerDependencies?.cordis
|
||||
const dev = manifest.devDependencies?.cordis
|
||||
const peer = manifest.peerDependencies?.['@deepseek-ai/cordis']
|
||||
const dev = manifest.devDependencies?.['@deepseek-ai/cordis']
|
||||
|
||||
if (!peer) errors.push(`${label}: cordis must be a peerDependency`)
|
||||
if (!dev) errors.push(`${label}: cordis must also be a devDependency`)
|
||||
if (!peer) errors.push(`${label}: @deepseek-ai/cordis must be a peerDependency`)
|
||||
if (!dev) errors.push(`${label}: @deepseek-ai/cordis must also be a devDependency`)
|
||||
if (peer && dev && peer !== dev) {
|
||||
errors.push(`${label}: cordis peer (${peer}) and dev (${dev}) ranges must match`)
|
||||
errors.push(`${label}: @deepseek-ai/cordis peer (${peer}) and dev (${dev}) ranges must match`)
|
||||
}
|
||||
if (manifest.version !== repositoryVersion) {
|
||||
errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`)
|
||||
@@ -346,13 +378,44 @@ function checkHierarchyShape(): string[] {
|
||||
}
|
||||
|
||||
function checkRepositoryVersion(): string[] {
|
||||
if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return []
|
||||
return ['package.json: version must be stable X.Y.Z']
|
||||
// The root carries the dsh release family's version, so a prerelease such as
|
||||
// 0.0.1-rc.1 is a valid state between `release:dsh` and its publication.
|
||||
if (repositoryVersion && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(repositoryVersion)) return []
|
||||
return ['package.json: version must be X.Y.Z with an optional prerelease segment']
|
||||
}
|
||||
|
||||
/** Dependency sections whose ranges reach a published tarball or a local install. */
|
||||
const dependencySections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const
|
||||
|
||||
/**
|
||||
* Require the `workspace:` protocol for every reference to a workspace member.
|
||||
*
|
||||
* A hand-written range says nothing about the version the workspace actually
|
||||
* carries, and `pnpm pack` leaves it alone: `^0.0.1` published from version
|
||||
* `0.0.2` names a version that does not exist. The protocol makes pack
|
||||
* substitute the member's real version, so no release step rewrites ranges.
|
||||
* @param manifests - every workspace manifest.
|
||||
* @returns One error per reference that names a workspace member without the protocol.
|
||||
*/
|
||||
function checkWorkspaceProtocol(manifests: readonly WorkspaceManifest[]): string[] {
|
||||
const members = new Set(manifests.map(entry => entry.manifest.name).filter(name => name !== undefined))
|
||||
const errors: string[] = []
|
||||
for (const { dir, manifest } of manifests) {
|
||||
for (const section of dependencySections) {
|
||||
for (const [name, range] of Object.entries(manifest[section] ?? {})) {
|
||||
if (!members.has(name) || range.startsWith('workspace:')) continue
|
||||
errors.push(`${manifest.name ?? dir}: ${section}.${name} must use the workspace: protocol, got ${range}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
const manifests = workspaceManifests()
|
||||
const errors = [
|
||||
...checkRepositoryVersion(),
|
||||
...workspaceManifests().flatMap(checkWorkspace),
|
||||
...manifests.flatMap(checkWorkspace),
|
||||
...checkWorkspaceProtocol(manifests),
|
||||
...checkHierarchyShape(),
|
||||
...collectProjectReferenceFaceViolations(root),
|
||||
]
|
||||
|
||||
@@ -97,9 +97,9 @@ describe('client bundle purity gate', () => {
|
||||
|
||||
it('carries exactly one documented temporary exemption: runtime/client (store engine pending rehoming)', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-client-runtime/client')).toBeNull()
|
||||
const dshClientChannels = CLIENT_EXTERNALS.filter(
|
||||
const clientChannels = CLIENT_EXTERNALS.filter(
|
||||
entry => entry.startsWith('@deepseek-ai/') && entry.endsWith('/client'))
|
||||
expect(dshClientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
|
||||
expect(clientChannels).toEqual(['@deepseek-ai/dsh-client-runtime/client'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ import ts from 'typescript'
|
||||
|
||||
/** Cheap textual prefilter for a cordis module merge, quote-style agnostic
|
||||
* (the AST match below reads `stmt.name.text` and never sees the quotes). */
|
||||
const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/
|
||||
const MERGE_HEAD = /declare module ['"](?:@deepseek-ai\/cordis|\.\/context\.ts)['"]/
|
||||
|
||||
/**
|
||||
* Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized)
|
||||
* that textually contains a cordis module merge, yielding one entry per merge
|
||||
* BLOCK — a file may legally hold several `declare module 'cordis'` blocks
|
||||
* BLOCK — a file may legally hold several `declare module '@deepseek-ai/cordis'` blocks
|
||||
* (the Typert analyzer reads them all), so the exhaustiveness scan must too.
|
||||
* Files without a merge are skipped.
|
||||
* @param scanRoot - Repository root the patterns are resolved against.
|
||||
@@ -39,14 +39,14 @@ export function contextMergeFiles(
|
||||
return out
|
||||
}
|
||||
|
||||
/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness
|
||||
/** Every cordis module-merge body in `sf`: `declare module '@deepseek-ai/cordis'` (harness
|
||||
* packages) or `declare module './context.ts'` (vendor core), in source order.
|
||||
* Module-local: consumers walk blocks through {@link contextMergeFiles}. */
|
||||
function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
|
||||
const bodies: ts.ModuleBlock[] = []
|
||||
for (const stmt of sf.statements) {
|
||||
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
|
||||
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.name.text !== '@deepseek-ai/cordis' && stmt.name.text !== './context.ts') continue
|
||||
if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body)
|
||||
}
|
||||
return bodies
|
||||
@@ -60,7 +60,7 @@ export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `key: Type` property a `declare module 'cordis'` Context merge
|
||||
* Every `key: Type` property a `declare module '@deepseek-ai/cordis'` Context merge
|
||||
* declares in one module body.
|
||||
* @param body - The cordis module augmentation block.
|
||||
* @param sf - Owning source file (for text extraction).
|
||||
@@ -79,7 +79,7 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri
|
||||
}
|
||||
|
||||
/**
|
||||
* Every event name a `declare module 'cordis'` Events merge declares in one
|
||||
* Every event name a `declare module '@deepseek-ai/cordis'` Events merge declares in one
|
||||
* module body. Names are the literal member keys (`'agent/created'`), read
|
||||
* from method and property members alike so a declaration form the projector
|
||||
* would reject still enters the exhaustiveness scan.
|
||||
|
||||
+21
-2
@@ -1,9 +1,28 @@
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
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 type { TsdownBundle } from 'tsdown'
|
||||
import { watchClientPlugins } from './dev-web.ts'
|
||||
import { discoverPluginDirs, watchClientPlugins } from './dev-web.ts'
|
||||
|
||||
it('discovers dsh.client packages with sibling roles', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-discovery-'))
|
||||
try {
|
||||
const current = join(root, 'packages', 'client', 'current')
|
||||
await mkdir(current, { recursive: true })
|
||||
await writeFile(join(current, 'package.json'), JSON.stringify({
|
||||
dsh: {
|
||||
bundle: { patch: './cordis.patch.yml' },
|
||||
client: { platform: 'web' },
|
||||
profile: { bundles: [] },
|
||||
},
|
||||
}))
|
||||
|
||||
expect(discoverPluginDirs(root)).toEqual(['packages/client/current'])
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rebuilds a client-plugin bundle after its source changes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
|
||||
|
||||
+8
-6
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Watch-build for client-plugin HMR: runs every dshClient plugin package
|
||||
* Watch-build for client-plugin HMR: runs every `dsh.client` plugin package
|
||||
* through the tsdown JS API in watch mode. Reload signaling is not this
|
||||
* script's business — the host webserver stat-polls the bundles it serves and
|
||||
* broadcasts `rebuilt` frames itself (`dsh web --dev`), so any process that
|
||||
@@ -27,7 +27,7 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/**
|
||||
* Discover the watch workspace by declaration: every packages/<group>/<name>
|
||||
* whose package.json carries `dshClient` with platform "web" is a client
|
||||
* whose package.json carries `dsh.client` with platform "web" is a client
|
||||
* plugin bundle emitter. Scanned once at startup — a package added while
|
||||
* watching means restarting this script.
|
||||
* @param root - repository root containing the grouped package directories.
|
||||
@@ -36,8 +36,10 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
export function discoverPluginDirs(root = repoRoot): string[] {
|
||||
const dirs: string[] = []
|
||||
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
|
||||
if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
|
||||
const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as {
|
||||
dsh?: { client?: { platform?: unknown } }
|
||||
}
|
||||
if (manifest.dsh?.client?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
@@ -88,7 +90,7 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re
|
||||
if (isMain) {
|
||||
const pluginDirs = discoverPluginDirs()
|
||||
if (pluginDirs.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
console.error('dev-web: no dsh.client (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -106,7 +108,7 @@ if (isMain) {
|
||||
|
||||
await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
|
||||
console.log(
|
||||
`dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages`
|
||||
`dev-web: watching ${String(pluginDirs.length)} dsh.client plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ describe('cordis-walk scan reach', () => {
|
||||
const dir = join(root, 'packages/client/ui-x/src/client')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(join(dir, 'index.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
"declare module '@deepseek-ai/cordis' {",
|
||||
' interface Events {',
|
||||
" 'x/changed'(): void",
|
||||
' }',
|
||||
@@ -153,12 +153,12 @@ describe('cordis-walk scan reach', () => {
|
||||
// backstop must not stop at the first one, skip the double-quoted legal
|
||||
// form, or ignore .tsx sources.
|
||||
writeFileSync(join(dir, 'split.ts'), [
|
||||
"declare module 'cordis' {",
|
||||
"declare module '@deepseek-ai/cordis' {",
|
||||
' interface Context {',
|
||||
' first: FirstService',
|
||||
' }',
|
||||
'}',
|
||||
'declare module "cordis" {',
|
||||
'declare module "@deepseek-ai/cordis" {',
|
||||
' interface Events {',
|
||||
" 'second/changed'(): void",
|
||||
' }',
|
||||
@@ -167,7 +167,7 @@ describe('cordis-walk scan reach', () => {
|
||||
'',
|
||||
].join('\n'))
|
||||
writeFileSync(join(dir, 'view.tsx'), [
|
||||
"declare module 'cordis' {",
|
||||
"declare module '@deepseek-ai/cordis' {",
|
||||
' interface Context {',
|
||||
' fromTsx: TsxService',
|
||||
' }',
|
||||
@@ -189,7 +189,7 @@ describe('cordis-walk scan reach', () => {
|
||||
|
||||
it('reads string-literal and identifier member names from an Events merge', () => {
|
||||
const sf = ts.createSourceFile('x.ts', [
|
||||
"declare module 'cordis' {",
|
||||
"declare module '@deepseek-ai/cordis' {",
|
||||
' interface Events {',
|
||||
" 'scope/list'(items: string[]): void",
|
||||
' plain(): void',
|
||||
|
||||
@@ -99,7 +99,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
/**
|
||||
* Context keys declared in `interface Context` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The scan that enforces this list reads EVERY `declare module 'cordis'`
|
||||
* The scan that enforces this list reads EVERY `declare module '@deepseek-ai/cordis'`
|
||||
* Context merge under `packages/x/x/src/**` — any depth, not only root
|
||||
* `index.ts` files with a same-named service class — so a new service can
|
||||
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
|
||||
@@ -111,6 +111,8 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
*/
|
||||
export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle',
|
||||
appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract',
|
||||
cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract',
|
||||
configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract',
|
||||
launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract',
|
||||
dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract',
|
||||
@@ -130,7 +132,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
|
||||
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
|
||||
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
|
||||
sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
|
||||
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',
|
||||
@@ -168,7 +169,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
* Event names declared in `interface Events` merges that the rendering
|
||||
* projection cannot see, each with the reason and its documentation owner.
|
||||
* The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent
|
||||
* scan reads EVERY `declare module 'cordis'` Events merge under
|
||||
* scan reads EVERY `declare module '@deepseek-ai/cordis'` Events merge under
|
||||
* `packages/x/x/src/**`, so a declared event either renders onto a subsystems
|
||||
* page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes
|
||||
* silently. Keys are full event names, not scopes: client-face events share
|
||||
|
||||
@@ -512,7 +512,7 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Client plugin graph host',
|
||||
mode: 'core',
|
||||
consumers: ['hmr'],
|
||||
note: 'Composes the __DSH_BOOT__ entry graph from an incremental dshClient scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.',
|
||||
note: 'Composes the __DSH_BOOT__ entry graph from an incremental dsh.client scan, serves plugin bundles, and notifies rebuilt/graph-changed subscribers.',
|
||||
},
|
||||
{
|
||||
key: 'workflows',
|
||||
|
||||
@@ -309,14 +309,14 @@ class ScopedEventGenerator {
|
||||
}
|
||||
}
|
||||
|
||||
/** Return whether an Events interface is inside declare module 'cordis'. */
|
||||
/** Return whether an Events interface is inside declare module '@deepseek-ai/cordis'. */
|
||||
function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean {
|
||||
const block = node.parent
|
||||
const declaration = block.parent
|
||||
return ts.isModuleBlock(block)
|
||||
&& ts.isModuleDeclaration(declaration)
|
||||
&& ts.isStringLiteral(declaration.name)
|
||||
&& declaration.name.text === 'cordis'
|
||||
&& declaration.name.text === '@deepseek-ai/cordis'
|
||||
}
|
||||
|
||||
/** Return whether a parameter is the explicit TypeScript this receiver. */
|
||||
|
||||
@@ -134,13 +134,17 @@ describe('parseVendoredRows', () => {
|
||||
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' })
|
||||
expect(rows).toContainEqual({
|
||||
npmName: '@deepseek-ai/cordis',
|
||||
upstreamName: 'cordis',
|
||||
upstream: 'https://github.com/cordiverse/cordis',
|
||||
})
|
||||
// The upstream column carries a trailing package path for some rows; it is not part of the URL.
|
||||
expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true)
|
||||
})
|
||||
|
||||
it('yields nothing when the table columns change, so the generator fails loud', () => {
|
||||
expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
|
||||
expect(parseVendoredRows('| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('covers every vendored directory, so no package can drop out of the notices', () => {
|
||||
@@ -227,7 +231,7 @@ describe('collectPythonDependencies', () => {
|
||||
it('excludes normalized local project names without exempting a third-party prefix', () => {
|
||||
const pyprojects = [
|
||||
'[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
|
||||
'[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
|
||||
'[project]\nname = "deepseek-harness-sdk"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
|
||||
]
|
||||
expect(() => collectPythonDependencies(pyprojects)).toThrow(
|
||||
'python dependency deepseek-unrelated is missing from PYTHON_METADATA',
|
||||
|
||||
@@ -83,7 +83,7 @@ const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
|
||||
* the generator fails when a manifest names a package this map misses.
|
||||
*/
|
||||
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`' },
|
||||
pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' },
|
||||
hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' },
|
||||
pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' },
|
||||
}
|
||||
@@ -387,6 +387,8 @@ export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<st
|
||||
/** A vendored package row parsed out of the `vendor/README.md` manifest table. */
|
||||
export interface VendoredRow {
|
||||
npmName: string
|
||||
/** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */
|
||||
upstreamName: string
|
||||
upstream: string
|
||||
}
|
||||
|
||||
@@ -398,11 +400,12 @@ export interface VendoredRow {
|
||||
export function parseVendoredRows(text: string): VendoredRow[] {
|
||||
const rows: VendoredRow[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
const match = /^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \S+ \| (https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$/.exec(line)
|
||||
const match = new RegExp(String.raw`^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \x60([^\x60]+)\x60 \| \S+ \| `
|
||||
+ String.raw`(https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$`).exec(line)
|
||||
if (match === null) continue
|
||||
const [, npmName, upstream] = match
|
||||
if (npmName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstream })
|
||||
const [, npmName, upstreamName, upstream] = match
|
||||
if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstreamName, upstream })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -696,11 +699,11 @@ The complete npm transitive closure, including the Landlock launcher workspace,
|
||||
|
||||
## Vendored source (\`vendor/\`)
|
||||
|
||||
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md).
|
||||
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \`@deepseek-ai\` scope. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md).
|
||||
|
||||
| Package | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
|
||||
| Package | Upstream name | Upstream | License |
|
||||
| --- | --- | --- | --- |
|
||||
${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
|
||||
|
||||
## Runtime npm dependencies
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
@@ -49,7 +49,7 @@ function fixture(options: {
|
||||
},
|
||||
files: ['lib/index.js', 'lib/invariant.js'],
|
||||
peerDependencies: options.invariantDependency === false ? {} : {
|
||||
'@deepseek-ai/dsh-invariants': '^0.0.1',
|
||||
'@deepseek-ai/dsh-invariants': 'workspace:^',
|
||||
},
|
||||
devDependencies: options.invariantDependency === false ? {} : {
|
||||
'@deepseek-ai/dsh-invariants': 'workspace:^',
|
||||
|
||||
@@ -96,11 +96,11 @@ function checkManifest(
|
||||
addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js')
|
||||
}
|
||||
if (owner.packageName === '@deepseek-ai/dsh-invariants') return
|
||||
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') {
|
||||
if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
|
||||
addViolation(
|
||||
violations,
|
||||
owner.manifestPath,
|
||||
'@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency',
|
||||
'@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency',
|
||||
)
|
||||
}
|
||||
if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') {
|
||||
|
||||
@@ -258,7 +258,9 @@ class WorkspacePackageSet {
|
||||
const name = expectString(manifest, 'name', manifestPath)
|
||||
const version = expectString(manifest, 'version', manifestPath)
|
||||
const isVendored = manifestPath.startsWith('vendor/')
|
||||
if (!isVendored && !name.startsWith('@deepseek-ai/')) {
|
||||
// Vendored packages are rescoped too (vendor/README.md), so publication
|
||||
// never carries an upstream name that would squat it on the registry.
|
||||
if (!name.startsWith('@deepseek-ai/')) {
|
||||
throw new Error(`${manifestPath} must name an @deepseek-ai package`)
|
||||
}
|
||||
if (name === '@deepseek-ai/dsh-root') {
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Bump one release family's version and commit it, so the published version is
|
||||
* readable from the repository rather than derived inside CI
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
* The dsh family shares one version across its members and the workspace root:
|
||||
* `major`, `minor`, `patch`, or an explicit `x.y.z` (including a prerelease such
|
||||
* as `0.0.1-rc.1`). The vendored family has one version line per package and
|
||||
* publishes only what changed since that package's own `vendor-<package>-v*`
|
||||
* tag, which is the record of the commit it last published from.
|
||||
*
|
||||
* The version lands in the manifests, the lockfile follows, and a human creates
|
||||
* the tag after the commit merges. CI never writes to the repository.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { join, matchesGlob } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { attempt, capture, isEntry } from './process.ts'
|
||||
|
||||
/** Files npm publishes whether or not `files` lists them. */
|
||||
const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const
|
||||
|
||||
/**
|
||||
* Inputs that decide what a built payload contains. A package whose `files`
|
||||
* selects `lib/` publishes build output that git does not track, so a change to
|
||||
* the sources or the build configuration changes the tarball while no published
|
||||
* path appears in the diff.
|
||||
*/
|
||||
const BUILD_INPUTS = ['src/**', 'tsconfig*.json', 'tsdown.config.*', 'build.config.*'] as const
|
||||
|
||||
/** Release types the dsh family accepts besides an explicit version. */
|
||||
const RELEASE_TYPES = ['major', 'minor', 'patch'] as const
|
||||
|
||||
/** The workspace root manifest, which carries the dsh family's version. */
|
||||
const ROOT_MANIFEST = 'package.json'
|
||||
|
||||
/** One manifest the bump rewrites, and the tag its new version will carry. */
|
||||
interface PlannedVersion {
|
||||
/** Repository-relative manifest path. */
|
||||
readonly manifestPath: string
|
||||
/** Label for the log line. */
|
||||
readonly label: string
|
||||
/** The version the manifest currently carries. */
|
||||
readonly from: string
|
||||
/** The version to write. */
|
||||
readonly to: string
|
||||
/** The tag this version publishes from, or undefined for the workspace root. */
|
||||
readonly tag: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a version into its release numbers, discarding any prerelease segment.
|
||||
* @param version - the current version.
|
||||
* @returns Major, minor, and patch.
|
||||
*/
|
||||
function releaseNumbers(version: string): [number, number, number] {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version)
|
||||
if (match === null) throw new Error(`cannot read release numbers from version ${version}`)
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
||||
}
|
||||
|
||||
/**
|
||||
* Order two versions by their release numbers alone.
|
||||
* @param left - one version.
|
||||
* @param right - the other version.
|
||||
* @returns Negative when `left` is lower, positive when higher, zero when equal.
|
||||
*/
|
||||
function compareReleaseNumbers(left: string, right: string): number {
|
||||
const [leftMajor, leftMinor, leftPatch] = releaseNumbers(left)
|
||||
const [rightMajor, rightMinor, rightPatch] = releaseNumbers(right)
|
||||
return leftMajor - rightMajor || leftMinor - rightMinor || leftPatch - rightPatch
|
||||
}
|
||||
|
||||
/**
|
||||
* The prerelease segment of a version, or undefined when it has none.
|
||||
* @param version - the version to read.
|
||||
* @returns The segment after the first `-`.
|
||||
*/
|
||||
function prereleaseOf(version: string): string | undefined {
|
||||
const index = version.indexOf('-')
|
||||
return index === -1 ? undefined : version.slice(index + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Order two versions by semver precedence.
|
||||
*
|
||||
* Git's version sort cannot stand in for this: `--sort=v:refname` places
|
||||
* `4.0.1-rc.1` above `4.0.1`, while semver gives a prerelease lower precedence
|
||||
* than the release it precedes. Prerelease identifiers compare field by field,
|
||||
* numeric fields numerically, so `rc.10` outranks `rc.1`.
|
||||
* @param left - one version.
|
||||
* @param right - the other version.
|
||||
* @returns Negative when `left` is lower, positive when higher, zero when equal.
|
||||
*/
|
||||
export function compareVersions(left: string, right: string): number {
|
||||
const numbers = compareReleaseNumbers(left, right)
|
||||
if (numbers !== 0) return numbers
|
||||
const leftPre = prereleaseOf(left)
|
||||
const rightPre = prereleaseOf(right)
|
||||
if (leftPre === undefined || rightPre === undefined) {
|
||||
if (leftPre === rightPre) return 0
|
||||
return leftPre === undefined ? 1 : -1
|
||||
}
|
||||
const leftFields = leftPre.split('.')
|
||||
const rightFields = rightPre.split('.')
|
||||
for (let index = 0; index < Math.max(leftFields.length, rightFields.length); index += 1) {
|
||||
const leftField = leftFields[index]
|
||||
const rightField = rightFields[index]
|
||||
// A shorter identifier list has lower precedence when all its fields match.
|
||||
if (leftField === undefined) return -1
|
||||
if (rightField === undefined) return 1
|
||||
if (leftField === rightField) continue
|
||||
const leftNumeric = /^\d+$/.test(leftField)
|
||||
const rightNumeric = /^\d+$/.test(rightField)
|
||||
if (leftNumeric && rightNumeric) return Number(leftField) - Number(rightField)
|
||||
// Numeric fields have lower precedence than alphanumeric ones.
|
||||
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1
|
||||
return leftField < rightField ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The next dsh version.
|
||||
* @param current - the family's current shared version.
|
||||
* @param request - `major`, `minor`, `patch`, or an explicit version.
|
||||
* @returns The target version.
|
||||
*/
|
||||
function nextSharedVersion(current: string, request: string): string {
|
||||
if (!RELEASE_TYPES.includes(request as typeof RELEASE_TYPES[number])) {
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(request)) {
|
||||
throw new Error(`usage: release:dsh <major|minor|patch|x.y.z>, got ${request}`)
|
||||
}
|
||||
return request
|
||||
}
|
||||
const [major, minor, patch] = releaseNumbers(current)
|
||||
if (request === 'major') return `${String(major + 1)}.0.0`
|
||||
if (request === 'minor') return `${String(major)}.${String(minor + 1)}.0`
|
||||
return `${String(major)}.${String(minor)}.${String(patch + 1)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* The version a vendored package publishes next.
|
||||
*
|
||||
* The baseline is the higher of the manifest version and the last published
|
||||
* version: a vendor re-sync restores upstream's version, which is lower than
|
||||
* what this repository already published, and incrementing that would name a
|
||||
* version the registry already carries.
|
||||
*
|
||||
* A prerelease does not consume its own release numbers. Publishing
|
||||
* `4.0.1-rc.1` leaves `4.0.1` free, so the next stable version is `4.0.1`
|
||||
* rather than `4.0.2`, and a second prerelease keeps those numbers too.
|
||||
* @param current - the package's manifest version.
|
||||
* @param published - the version its newest tag names, when it has one.
|
||||
* @param prerelease - prerelease identifier to append, for a rehearsal publication.
|
||||
* @returns The target version.
|
||||
*/
|
||||
export function nextVendorVersion(
|
||||
current: string,
|
||||
published: string | undefined,
|
||||
prerelease?: string,
|
||||
): string {
|
||||
const ahead = published !== undefined && compareReleaseNumbers(published, current) > 0
|
||||
const baseline = ahead ? published : current
|
||||
const [major, minor, patch] = releaseNumbers(baseline)
|
||||
// Reuse the numbers when the published version that set them is a prerelease
|
||||
// of them; increment when a stable release already holds them.
|
||||
const reuse = ahead && published.includes('-')
|
||||
const numbers = reuse
|
||||
? `${String(major)}.${String(minor)}.${String(patch)}`
|
||||
: `${String(major)}.${String(minor)}.${String(patch + 1)}`
|
||||
return prerelease === undefined ? numbers : `${numbers}-${prerelease}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a repository-relative path reaches the member's published payload.
|
||||
* @param member - the member the path belongs to.
|
||||
* @param path - repository-relative path.
|
||||
* @returns True when `files`, npm's always-published set, or a build input selects it.
|
||||
*/
|
||||
export function reachesPayload(member: ReleaseMember, path: string): boolean {
|
||||
const relative = path.slice(member.directory.length + 1)
|
||||
const files = member.manifest.files
|
||||
const selected = Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : []
|
||||
const built = selected.some(pattern => pattern.startsWith('lib'))
|
||||
const patterns = [...ALWAYS_PUBLISHED, ...selected, ...built ? BUILD_INPUTS : []]
|
||||
return patterns.some(pattern =>
|
||||
matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern)
|
||||
}
|
||||
|
||||
/**
|
||||
* The newest version a member published, read from its tags.
|
||||
* @param family - the member's family.
|
||||
* @param member - the member.
|
||||
* @returns The version, or undefined when the member never published.
|
||||
*/
|
||||
function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined {
|
||||
const prefix = family.tagPrefixFor(member)
|
||||
const versions = capture('git', ['tag', '--list', `${prefix}*`])
|
||||
.split('\n').filter(line => line !== '').map(tag => tag.slice(prefix.length))
|
||||
if (versions.length === 0) return undefined
|
||||
return versions.reduce((newest, candidate) => compareVersions(candidate, newest) > 0 ? candidate : newest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm the registry carries the version a tag names.
|
||||
*
|
||||
* A tag is a commit pointer, not proof of publication: a tag pushed for a
|
||||
* publication that then failed would otherwise read as "already published" and
|
||||
* skip the package indefinitely. Querying a private package needs credentials,
|
||||
* so an unauthenticated machine reports the gap instead of failing.
|
||||
* @param name - package name.
|
||||
* @param version - the version the tag names.
|
||||
*/
|
||||
function confirmPublished(name: string, version: string): void {
|
||||
const result = attempt('npm', ['view', `${name}@${version}`, 'version'])
|
||||
if (result.status === 0) return
|
||||
const output = `${result.stdout}${result.stderr}`
|
||||
if (output.includes('ENEEDAUTH') || output.includes('E401') || output.includes('E403')) {
|
||||
console.log(`release bump: cannot reach the registry for ${name}@${version}; skipping the tag check`)
|
||||
return
|
||||
}
|
||||
if (output.includes('E404') || output.includes('404 Not Found')) {
|
||||
throw new Error(
|
||||
`${name}@${version} is tagged but absent from the registry.`
|
||||
+ '\nThe tag was pushed for a publication that did not complete: re-run that publish, or delete the tag.',
|
||||
)
|
||||
}
|
||||
throw new Error(`npm view ${name}@${version} failed:\n${output}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a version into a manifest, preserving formatting and key order.
|
||||
* @param root - repository root.
|
||||
* @param manifestPath - repository-relative manifest path.
|
||||
* @param from - the version the manifest currently carries.
|
||||
* @param to - the target version.
|
||||
*/
|
||||
function writeVersion(root: string, manifestPath: string, from: string, to: string): void {
|
||||
const path = join(root, manifestPath)
|
||||
const text = readFileSync(path, 'utf8')
|
||||
const line = `"version": "${from}"`
|
||||
if (!text.includes(line)) throw new Error(`${manifestPath}: cannot locate ${line}`)
|
||||
writeFileSync(path, text.replace(line, `"version": "${to}"`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the workspace root version.
|
||||
* @param root - repository root.
|
||||
* @returns The root manifest version.
|
||||
*/
|
||||
function rootVersion(root: string): string {
|
||||
const manifest: unknown = JSON.parse(readFileSync(join(root, ROOT_MANIFEST), 'utf8'))
|
||||
const version = (manifest as Record<string, unknown>).version
|
||||
if (typeof version !== 'string') throw new Error('package.json must declare a string version')
|
||||
return version
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the dsh family's rewrite: one version for every member and the root.
|
||||
* @param family - the dsh family.
|
||||
* @param root - repository root.
|
||||
* @param members - the family's members.
|
||||
* @param request - `major`, `minor`, `patch`, or an explicit version.
|
||||
* @returns The manifests to rewrite and the shared target version.
|
||||
*/
|
||||
function planShared(
|
||||
family: ReleaseFamily,
|
||||
root: string,
|
||||
members: readonly ReleaseMember[],
|
||||
request: string,
|
||||
): { planned: PlannedVersion[]; version: string } {
|
||||
const [first] = members
|
||||
if (first === undefined) throw new Error(`release family ${family.id} has no members`)
|
||||
const version = nextSharedVersion(first.version, request)
|
||||
// The workspace root carries the family version too: the workspace constraint
|
||||
// requires every member's version to equal the root's.
|
||||
const planned: PlannedVersion[] = [
|
||||
{ manifestPath: ROOT_MANIFEST, label: ROOT_MANIFEST, from: rootVersion(root), to: version, tag: undefined },
|
||||
]
|
||||
for (const member of members) {
|
||||
planned.push({
|
||||
manifestPath: join(member.directory, 'package.json'),
|
||||
label: member.directory,
|
||||
from: member.version,
|
||||
to: version,
|
||||
tag: family.tagFor({ ...member, version }),
|
||||
})
|
||||
}
|
||||
return { planned, version }
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the vendored family's rewrite: every package whose payload changed since
|
||||
* it last published.
|
||||
* @param family - the vendored family.
|
||||
* @param members - the family's members.
|
||||
* @param prerelease - prerelease identifier to append, for a rehearsal publication.
|
||||
* @returns The manifests to rewrite.
|
||||
*/
|
||||
function planPerPackage(
|
||||
family: ReleaseFamily,
|
||||
members: readonly ReleaseMember[],
|
||||
prerelease: string | undefined,
|
||||
): PlannedVersion[] {
|
||||
const planned: PlannedVersion[] = []
|
||||
for (const member of members) {
|
||||
const published = lastPublishedVersion(family, member)
|
||||
if (published !== undefined) {
|
||||
confirmPublished(member.name, published)
|
||||
const since = `${family.tagPrefixFor(member)}${published}`
|
||||
const changed = capture('git', ['diff', '--name-only', `${since}..HEAD`, '--', member.directory])
|
||||
.split('\n').filter(line => line !== '')
|
||||
if (!changed.some(path => reachesPayload(member, path))) continue
|
||||
}
|
||||
const to = nextVendorVersion(member.version, published, prerelease)
|
||||
planned.push({
|
||||
manifestPath: join(member.directory, 'package.json'),
|
||||
label: member.directory,
|
||||
from: member.version,
|
||||
to,
|
||||
tag: family.tagFor({ ...member, version: to }),
|
||||
})
|
||||
}
|
||||
return planned
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the family named by `--family` and commit; `--dry-run` only reports the
|
||||
* plan. `--prerelease rc.1` makes the vendored family publish a rehearsal
|
||||
* version, which never takes the stable dist-tag.
|
||||
*/
|
||||
function main(): void {
|
||||
const { values, positionals } = parseArgs({
|
||||
options: {
|
||||
family: { type: 'string' },
|
||||
prerelease: { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
},
|
||||
allowPositionals: true,
|
||||
})
|
||||
if (values.family === undefined) throw new Error('usage: bump.ts --family <dsh|vendor> [version]')
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const root = process.cwd()
|
||||
const members = family.members(root)
|
||||
family.verifyVersions(members)
|
||||
|
||||
let planned: PlannedVersion[]
|
||||
let sharedVersion: string | undefined
|
||||
if (family.id === 'dsh') {
|
||||
const request = positionals[0]
|
||||
if (request === undefined) throw new Error('usage: release:dsh <major|minor|patch|x.y.z>')
|
||||
if (values.prerelease !== undefined) {
|
||||
throw new Error('release:dsh takes the prerelease in its version argument, as in 0.0.1-rc.1')
|
||||
}
|
||||
const shared = planShared(family, root, members, request)
|
||||
planned = shared.planned
|
||||
sharedVersion = shared.version
|
||||
} else {
|
||||
if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch')
|
||||
if (values.prerelease !== undefined && !/^[0-9A-Za-z.-]+$/.test(values.prerelease)) {
|
||||
throw new Error(`--prerelease must be a semver prerelease identifier, got ${values.prerelease}`)
|
||||
}
|
||||
planned = planPerPackage(family, members, values.prerelease)
|
||||
}
|
||||
|
||||
if (planned.length === 0) {
|
||||
console.log(`release bump: family ${family.id}, nothing changed since publication`)
|
||||
return
|
||||
}
|
||||
|
||||
const dryRun = values['dry-run']
|
||||
if (!dryRun) {
|
||||
for (const entry of planned) writeVersion(root, entry.manifestPath, entry.from, entry.to)
|
||||
capture('pnpm', ['install', '--lockfile-only'])
|
||||
}
|
||||
|
||||
const summary = sharedVersion
|
||||
?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ')
|
||||
console.log(`release bump: family ${family.id} -> ${summary}`)
|
||||
for (const entry of planned) console.log(` ${entry.label}: ${entry.from} -> ${entry.to}`)
|
||||
|
||||
if (dryRun) {
|
||||
console.log('release bump: dry run, nothing written')
|
||||
return
|
||||
}
|
||||
capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)])
|
||||
capture('git', ['commit', '-m', `release(${family.id}): ${summary}`])
|
||||
console.log('release bump: committed. After this merges to master, tag it:')
|
||||
for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) {
|
||||
console.log(` git tag ${tag} <merge commit> && git push origin ${tag}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,176 @@
|
||||
/** Release family discovery, publish order, tag naming, and the bump judgements. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { releaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts'
|
||||
|
||||
/**
|
||||
* A release member standing in for a manifest on disk.
|
||||
* @param directory - repository-relative package directory.
|
||||
* @param name - package name.
|
||||
* @param manifest - manifest fields the subject reads.
|
||||
* @returns The member.
|
||||
*/
|
||||
function member(directory: string, name: string, manifest: Record<string, unknown> = {}): ReleaseMember {
|
||||
return { directory, name, version: '0.0.1', manifest }
|
||||
}
|
||||
|
||||
describe('release families', () => {
|
||||
it('names one tag for the whole dsh family and one per vendored package', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const vendor = releaseFamily('vendor')
|
||||
const cli = member('apps/cli', '@deepseek-ai/dsh')
|
||||
const cordis = { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' }
|
||||
|
||||
expect(dsh.tagFor(cli)).toBe('dsh-v0.0.1')
|
||||
expect(vendor.tagFor(cordis)).toBe('vendor-cordis-v4.0.1')
|
||||
// The prefix is constructed, not recovered from a tag: a version with a
|
||||
// hyphen would defeat any suffix-stripping.
|
||||
expect(vendor.tagPrefixFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v')
|
||||
expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7')
|
||||
})
|
||||
|
||||
it('rejects a family whose members disagree on the shared version', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-frontend'), version: '0.0.2' }]
|
||||
|
||||
expect(() => { dsh.verifyVersions(members) }).toThrow(/must share one version/)
|
||||
expect(() => { dsh.verifyVersions([members[0]!]) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('accepts independent vendored versions and rejects an unpublishable one', () => {
|
||||
const vendor = releaseFamily('vendor')
|
||||
const members = [
|
||||
{ ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' },
|
||||
{ ...member('vendor/cosmokit', '@deepseek-ai/cosmokit'), version: '1.8.2' },
|
||||
]
|
||||
|
||||
expect(() => { vendor.verifyVersions(members) }).not.toThrow()
|
||||
expect(() => { vendor.verifyVersions([{ ...members[0]!, version: 'latest' }]) }).toThrow(/unpublishable version/)
|
||||
})
|
||||
|
||||
it('publishes a dependency before its consumer, and orders ties by name', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { dependencies: { '@deepseek-ai/dsh-library': 'workspace:^' } }),
|
||||
member('packages/a/library', '@deepseek-ai/dsh-library'),
|
||||
member('packages/a/zebra', '@deepseek-ai/dsh-zebra'),
|
||||
]
|
||||
|
||||
expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([
|
||||
'@deepseek-ai/dsh-library',
|
||||
'@deepseek-ai/dsh-consumer',
|
||||
'@deepseek-ai/dsh-zebra',
|
||||
])
|
||||
})
|
||||
|
||||
it('reports a runtime dependency cycle instead of emitting an arbitrary order', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const members = [
|
||||
member('packages/a/left', '@deepseek-ai/dsh-left', { dependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }),
|
||||
member('packages/a/right', '@deepseek-ai/dsh-right', { dependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }),
|
||||
]
|
||||
|
||||
expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/)
|
||||
})
|
||||
|
||||
it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => {
|
||||
const dsh = releaseFamily('dsh')
|
||||
const vendor = releaseFamily('vendor')
|
||||
const harness = member('packages/a/library', '@deepseek-ai/dsh-library')
|
||||
const vendored = member('vendor/cordis', '@deepseek-ai/cordis')
|
||||
|
||||
expect(() => { dsh.validatePayload(harness, ['package/lib/index.js', 'package/src/index.ts']) })
|
||||
.toThrow(/publishes source file/)
|
||||
expect(() => { vendor.validatePayload(vendored, ['package/lib/index.js', 'package/src/index.ts']) }).not.toThrow()
|
||||
expect(() => { vendor.validatePayload(vendored, []) }).toThrow(/empty tarball/)
|
||||
})
|
||||
|
||||
it('drives the installed entry only for the family that publishes one', () => {
|
||||
expect(releaseFamily('dsh').installedEntry).toEqual({ packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' })
|
||||
expect(releaseFamily('vendor').installedEntry).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an unknown family identifier', () => {
|
||||
expect(() => { releaseFamily('native') }).toThrow(/unknown release family/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('vendored version baseline', () => {
|
||||
it('drops an upstream prerelease segment and increments the patch', () => {
|
||||
expect(nextVendorVersion('4.0.0-rc.7', undefined)).toBe('4.0.1')
|
||||
expect(nextVendorVersion('1.0.0-rc.5', undefined)).toBe('1.0.1')
|
||||
expect(nextVendorVersion('1.8.1', undefined)).toBe('1.8.2')
|
||||
})
|
||||
|
||||
it('increments from the last published version when a re-sync restored a lower one', () => {
|
||||
// Upstream moved rc.7 -> rc.8 after this repository published 4.0.1;
|
||||
// incrementing the manifest alone would name 4.0.1 a second time.
|
||||
expect(nextVendorVersion('4.0.0-rc.8', '4.0.1')).toBe('4.0.2')
|
||||
expect(nextVendorVersion('4.1.0', '4.0.1')).toBe('4.1.1')
|
||||
})
|
||||
|
||||
it('appends a rehearsal prerelease without consuming its release numbers', () => {
|
||||
// A rehearsal burns 4.0.1-rc.1 and leaves 4.0.1 free, so the stable release
|
||||
// that follows takes those same numbers instead of skipping to 4.0.2.
|
||||
expect(nextVendorVersion('4.0.0-rc.7', undefined, 'rc.1')).toBe('4.0.1-rc.1')
|
||||
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1', 'rc.2')).toBe('4.0.1-rc.2')
|
||||
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1')).toBe('4.0.1')
|
||||
expect(nextVendorVersion('4.0.0-rc.7', '4.0.1')).toBe('4.0.2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('version precedence', () => {
|
||||
it('ranks a release above the prerelease it follows', () => {
|
||||
// git --sort=v:refname disagrees, placing 4.0.1-rc.1 above 4.0.1, which is
|
||||
// why the newest published version is chosen here rather than by git.
|
||||
expect(compareVersions('4.0.1', '4.0.1-rc.1')).toBeGreaterThan(0)
|
||||
expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('compares numeric prerelease fields numerically', () => {
|
||||
expect(compareVersions('4.0.1-rc.10', '4.0.1-rc.1')).toBeGreaterThan(0)
|
||||
expect(compareVersions('4.0.1-rc.2', '4.0.1-rc.10')).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('ranks a numeric field below an alphanumeric one, and a shorter list below a longer', () => {
|
||||
expect(compareVersions('4.0.1-1', '4.0.1-alpha')).toBeLessThan(0)
|
||||
expect(compareVersions('4.0.1-rc', '4.0.1-rc.1')).toBeLessThan(0)
|
||||
expect(compareVersions('4.0.2', '4.0.1')).toBeGreaterThan(0)
|
||||
expect(compareVersions('4.0.1-rc.1', '4.0.1-rc.1')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('payload change judgement', () => {
|
||||
const sourceShipping = member('vendor/cosmokit', '@deepseek-ai/cosmokit', {
|
||||
files: ['lib/index.js', 'lib/types/**/*.d.ts', 'src'],
|
||||
})
|
||||
const buildOutputOnly = member('vendor/cordis', '@deepseek-ai/cordis', {
|
||||
files: ['lib/index.js', 'lib/types/**/*.d.ts', 'bin.js'],
|
||||
})
|
||||
|
||||
it('counts the manifest and the files npm always publishes', () => {
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/package.json')).toBe(true)
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.md')).toBe(true)
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/src/index.ts')).toBe(true)
|
||||
})
|
||||
|
||||
it('counts build inputs for a package whose payload is build output', () => {
|
||||
// cordis publishes lib/ only, and lib/ is not tracked: without this, a real
|
||||
// source change reads as "nothing changed" and the next publish fails on a
|
||||
// version whose bytes moved.
|
||||
expect(reachesPayload(buildOutputOnly, 'vendor/cordis/src/context.ts')).toBe(true)
|
||||
expect(reachesPayload(buildOutputOnly, 'vendor/cordis/tsconfig.json')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores paths no tarball carries', () => {
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/tests/unit.spec.ts')).toBe(false)
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/CHANGELOG.md')).toBe(false)
|
||||
// The README pattern is deliberately loose: over-reporting a change costs one
|
||||
// unnecessary patch bump, while under-reporting fails the next publish on a
|
||||
// version whose bytes moved.
|
||||
expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.i18n.yaml')).toBe(true)
|
||||
expect(reachesPayload(member('packages/a/library', '@deepseek-ai/dsh-library', { files: ['lib/index.js'] }),
|
||||
'packages/a/library/tests/library.spec.ts')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* The three independent publish sequences this repository releases from
|
||||
* (`packages/` + `apps/`, `vendor/`, and `native/`) and the two this module
|
||||
* owns: `dsh` and `vendor`. Each family carries its own version baseline, tag
|
||||
* naming, and publish set, so releasing one never republishes another
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
* The family dimension lives here only. A new sequence adds a subclass and a
|
||||
* `releaseFamilies()` entry; nothing else in the release scripts branches on it.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { hasTypeRTRemoteNavigation, validateTarballPayload } from '../publication-payload.ts'
|
||||
|
||||
/** Dependency sections that constrain publish order: a consumer must publish after its dependency. */
|
||||
const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const
|
||||
|
||||
/** The workspace root manifest, which is never a release member. */
|
||||
const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root'
|
||||
|
||||
/** One publishable package of a release family. */
|
||||
export interface ReleaseMember {
|
||||
/** Repository-relative package directory, for example `packages/core/session`. */
|
||||
readonly directory: string
|
||||
/** Package name from its manifest. */
|
||||
readonly name: string
|
||||
/** Package version from its manifest. */
|
||||
readonly version: string
|
||||
/** The parsed manifest, for payload policy and publication checks. */
|
||||
readonly manifest: Readonly<Record<string, unknown>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and parse a JSON file.
|
||||
* @param path - absolute file path.
|
||||
* @returns The parsed object.
|
||||
*/
|
||||
function readManifest(path: string): Record<string, unknown> {
|
||||
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error(`${path} is not a JSON object`)
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a required string field.
|
||||
* @param manifest - parsed manifest.
|
||||
* @param field - field name.
|
||||
* @param context - manifest path for the error message.
|
||||
* @returns The field value.
|
||||
*/
|
||||
function requireString(manifest: Record<string, unknown>, field: string, context: string): string {
|
||||
const value = manifest[field]
|
||||
if (typeof value !== 'string' || value === '') throw new Error(`${context} must declare a string ${field}`)
|
||||
return value
|
||||
}
|
||||
|
||||
/** The executable a family's installed artifacts are driven through. */
|
||||
export interface InstalledEntry {
|
||||
/** Package that carries the executable. */
|
||||
readonly packageName: string
|
||||
/** Path to the executable inside that package. */
|
||||
readonly binPath: string
|
||||
}
|
||||
|
||||
/** A release sequence: its members, its version baseline, and its tag naming. */
|
||||
export abstract class ReleaseFamily {
|
||||
/** Workflow-facing identifier, also the `--family` argument. */
|
||||
abstract readonly id: string
|
||||
|
||||
/** Glob patterns, relative to the repository root, that select this family's manifests. */
|
||||
abstract readonly patterns: readonly string[]
|
||||
|
||||
/** Git tag prefix this family publishes from. */
|
||||
abstract readonly tagPrefix: string
|
||||
|
||||
/**
|
||||
* Discover this family's members.
|
||||
* @param root - repository root.
|
||||
* @returns Members sorted by directory, with names validated and deduplicated.
|
||||
*/
|
||||
members(root: string): ReleaseMember[] {
|
||||
const manifestPaths = globSync([...this.patterns], { cwd: root }).sort()
|
||||
if (manifestPaths.length === 0) throw new Error(`release family ${this.id} matched no manifests`)
|
||||
|
||||
const members: ReleaseMember[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const manifestPath of manifestPaths) {
|
||||
const normalized = manifestPath.replaceAll('\\', '/')
|
||||
const manifest = readManifest(resolve(root, manifestPath))
|
||||
const name = requireString(manifest, 'name', normalized)
|
||||
const version = requireString(manifest, 'version', normalized)
|
||||
if (name === WORKSPACE_ROOT_PACKAGE) throw new Error(`${normalized} selected the workspace root`)
|
||||
if (!name.startsWith('@deepseek-ai/')) throw new Error(`${normalized} must name an @deepseek-ai package`)
|
||||
if (seen.has(name)) throw new Error(`${name} appears twice in release family ${this.id}`)
|
||||
seen.add(name)
|
||||
members.push({
|
||||
directory: normalized.slice(0, normalized.length - '/package.json'.length),
|
||||
name,
|
||||
version,
|
||||
manifest,
|
||||
})
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
/**
|
||||
* Order members so every package publishes after the family members it depends on.
|
||||
* @param members - this family's members.
|
||||
* @returns The same members in publish order; ties break by name for determinism.
|
||||
*/
|
||||
publishOrder(members: readonly ReleaseMember[]): ReleaseMember[] {
|
||||
const byName = new Map(members.map(member => [member.name, member]))
|
||||
const ordered: ReleaseMember[] = []
|
||||
const placed = new Set<string>()
|
||||
const visiting = new Set<string>()
|
||||
|
||||
const visit = (member: ReleaseMember, path: readonly string[]): void => {
|
||||
if (placed.has(member.name)) return
|
||||
if (visiting.has(member.name)) {
|
||||
throw new Error(`dependency cycle in release family ${this.id}: ${[...path, member.name].join(' -> ')}`)
|
||||
}
|
||||
visiting.add(member.name)
|
||||
for (const dependency of this.orderEdges(member, byName)) {
|
||||
visit(dependency, [...path, member.name])
|
||||
}
|
||||
visiting.delete(member.name)
|
||||
placed.add(member.name)
|
||||
ordered.push(member)
|
||||
}
|
||||
|
||||
for (const member of [...members].sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
visit(member, [])
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
/**
|
||||
* The family members one member depends on at runtime.
|
||||
* @param member - the dependent member.
|
||||
* @param byName - every family member by package name.
|
||||
* @returns Dependencies inside this family, sorted by name.
|
||||
*/
|
||||
private orderEdges(member: ReleaseMember, byName: ReadonlyMap<string, ReleaseMember>): ReleaseMember[] {
|
||||
const edges: ReleaseMember[] = []
|
||||
for (const section of ORDER_SECTIONS) {
|
||||
const dependencies = member.manifest[section]
|
||||
if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue
|
||||
for (const name of Object.keys(dependencies)) {
|
||||
const dependency = byName.get(name)
|
||||
if (dependency !== undefined && dependency.name !== member.name) edges.push(dependency)
|
||||
}
|
||||
}
|
||||
return edges.sort((left, right) => left.name.localeCompare(right.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert this family's version baseline holds across its members.
|
||||
* @param members - this family's members.
|
||||
*/
|
||||
abstract verifyVersions(members: readonly ReleaseMember[]): void
|
||||
|
||||
/**
|
||||
* The tag prefix a member's versions are tagged under. Every tag for that
|
||||
* member starts with it, which is how the last published version is found.
|
||||
* @param member - the member being published.
|
||||
* @returns The prefix, ending in `-v`.
|
||||
*/
|
||||
abstract tagPrefixFor(member: ReleaseMember): string
|
||||
|
||||
/**
|
||||
* The tag a member publishes from.
|
||||
* @param member - the member being published.
|
||||
* @returns The full tag name, without `refs/tags/`.
|
||||
*/
|
||||
tagFor(member: ReleaseMember): string {
|
||||
return `${this.tagPrefixFor(member)}${member.version}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Check what a member's packed tarball carries.
|
||||
* @param member - the packed member.
|
||||
* @param files - every path inside its tarball.
|
||||
*/
|
||||
abstract validatePayload(member: ReleaseMember, files: readonly string[]): void
|
||||
|
||||
/**
|
||||
* The executable that proves this family's artifacts install and run, or
|
||||
* `undefined` for a family that publishes no executable.
|
||||
*/
|
||||
abstract readonly installedEntry: InstalledEntry | undefined
|
||||
}
|
||||
|
||||
/** `packages/*` and `apps/*`: one shared version across the whole family. */
|
||||
class DshFamily extends ReleaseFamily {
|
||||
readonly id = 'dsh'
|
||||
readonly patterns = ['packages/*/*/package.json', 'apps/*/package.json'] as const
|
||||
readonly tagPrefix = 'dsh-v'
|
||||
|
||||
/**
|
||||
* Require one version across the family, the way a single tag can name it.
|
||||
* @param members - this family's members.
|
||||
*/
|
||||
verifyVersions(members: readonly ReleaseMember[]): void {
|
||||
const versions = new Set(members.map(member => member.version))
|
||||
if (versions.size !== 1) {
|
||||
const detail = members.map(member => `${member.directory}: ${member.version}`).join('\n')
|
||||
throw new Error(`dsh release members must share one version:\n${detail}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single family prefix: every member shares one version, so one tag names it.
|
||||
* @returns `dsh-v`.
|
||||
*/
|
||||
tagPrefixFor(): string {
|
||||
return this.tagPrefix
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject source and declaration-map members, the repository's publication policy.
|
||||
* @param member - the packed member.
|
||||
* @param files - every path inside its tarball.
|
||||
*/
|
||||
validatePayload(member: ReleaseMember, files: readonly string[]): void {
|
||||
validateTarballPayload(files, member.name, {
|
||||
typeRTRemoteNavigation: hasTypeRTRemoteNavigation(member.manifest),
|
||||
})
|
||||
}
|
||||
|
||||
readonly installedEntry = { packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' }
|
||||
}
|
||||
|
||||
/** `vendor/*`: every package keeps its own version line, so every package has its own tag. */
|
||||
class VendorFamily extends ReleaseFamily {
|
||||
readonly id = 'vendor'
|
||||
readonly patterns = ['vendor/*/package.json'] as const
|
||||
readonly tagPrefix = 'vendor-'
|
||||
|
||||
/**
|
||||
* Accept independent versions; only reject a version this repository cannot publish.
|
||||
* @param members - this family's members.
|
||||
*/
|
||||
verifyVersions(members: readonly ReleaseMember[]): void {
|
||||
for (const member of members) {
|
||||
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(member.version)) {
|
||||
throw new Error(`${member.directory} has an unpublishable version: ${member.version}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefix per member, because one vendor release can carry several versions.
|
||||
* @param member - the member being published.
|
||||
* @returns `vendor-<unscoped name>-v`.
|
||||
*/
|
||||
tagPrefixFor(member: ReleaseMember): string {
|
||||
return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v`
|
||||
}
|
||||
|
||||
/**
|
||||
* Require the payload the vendored manifest declares, including upstream's
|
||||
* `src` tree and declaration maps.
|
||||
*
|
||||
* The harness policy that rejects both does not apply here: these manifests
|
||||
* export `./src/*` for source navigation, so dropping `src` would publish a
|
||||
* package whose export map points at absent files. What must hold instead is
|
||||
* that every path the manifest selects is present, which `files` already
|
||||
* decides and `pnpm pack` already enforces.
|
||||
* @param member - the packed member.
|
||||
* @param files - every path inside its tarball.
|
||||
*/
|
||||
validatePayload(member: ReleaseMember, files: readonly string[]): void {
|
||||
if (files.length === 0) throw new Error(`${member.name} packed an empty tarball`)
|
||||
}
|
||||
|
||||
/** No installed-entry probe: these are libraries a consumer imports, with no executable. */
|
||||
readonly installedEntry = undefined
|
||||
}
|
||||
|
||||
/** Every release family this module owns, in workflow order. */
|
||||
function releaseFamilies(): readonly ReleaseFamily[] {
|
||||
return [new DshFamily(), new VendorFamily()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a family by its `--family` identifier.
|
||||
* @param id - family identifier.
|
||||
* @returns The family.
|
||||
*/
|
||||
export function releaseFamily(id: string): ReleaseFamily {
|
||||
const family = releaseFamilies().find(candidate => candidate.id === id)
|
||||
if (family === undefined) {
|
||||
const known = releaseFamilies().map(candidate => candidate.id).join(', ')
|
||||
throw new Error(`unknown release family ${id}; expected one of ${known}`)
|
||||
}
|
||||
return family
|
||||
}
|
||||
|
||||
/**
|
||||
* The npm tarball filename `pnpm pack` writes for a member.
|
||||
* @param member - the packed member.
|
||||
* @returns The tarball filename.
|
||||
*/
|
||||
export function tarballName(member: ReleaseMember): string {
|
||||
const unscoped = member.name.startsWith('@') ? member.name.slice(1).replace('/', '-') : member.name
|
||||
return `${unscoped}-${member.version}.tgz`
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Pack one release family's whole publish set into a single directory, in
|
||||
* publish order, and record that order for the publish step.
|
||||
*
|
||||
* The pack step is the release boundary: it runs without credentials, produces
|
||||
* every tarball from one commit, and hands the publish step exactly those bytes
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { isEntry, run } from './process.ts'
|
||||
import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts'
|
||||
|
||||
/** Where pack output lands when `--out` is omitted. */
|
||||
const DEFAULT_OUTPUT = 'dist/npm'
|
||||
|
||||
/**
|
||||
* Pack one member and check what its tarball carries.
|
||||
* @param family - the release family being packed.
|
||||
* @param member - the member to pack.
|
||||
* @param destination - absolute output directory.
|
||||
* @returns The tarball filename.
|
||||
*/
|
||||
function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string {
|
||||
run('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination])
|
||||
|
||||
const filename = tarballName(member)
|
||||
const tarball = join(destination, filename)
|
||||
if (!existsSync(tarball)) throw new Error(`${member.name} produced no tarball at ${tarball}`)
|
||||
family.validatePayload(member, tarballFiles(tarball))
|
||||
return filename
|
||||
}
|
||||
|
||||
/** Pack the family named by `--family` into `--out`. */
|
||||
function main(): void {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, out: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined) throw new Error('usage: pack.ts --family <dsh|vendor> [--out dist/npm]')
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const root = process.cwd()
|
||||
const destination = resolve(root, values.out ?? DEFAULT_OUTPUT)
|
||||
const members = family.publishOrder(family.members(root))
|
||||
family.verifyVersions(members)
|
||||
|
||||
rmSync(destination, { recursive: true, force: true })
|
||||
mkdirSync(destination, { recursive: true })
|
||||
|
||||
const order: string[] = []
|
||||
for (const member of members) order.push(packMember(family, member, destination))
|
||||
writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`)
|
||||
|
||||
console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Process helpers shared by the release scripts: the release steps drive `git`,
|
||||
* `pnpm`, `npm`, and `tar`, and each needs one of three failure behaviours.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
/** Where and with what environment a release step runs a command. */
|
||||
export interface RunOptions {
|
||||
/** Working directory; defaults to the current one. */
|
||||
readonly cwd?: string
|
||||
/** Child environment; defaults to this process's. */
|
||||
readonly env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
/** What a command produced, for a caller that decides what a failure means. */
|
||||
export interface CommandResult {
|
||||
/** Exit status, or null when a signal ended the process. */
|
||||
readonly status: number | null
|
||||
/** Captured standard output. */
|
||||
readonly stdout: string
|
||||
/** Captured standard error. */
|
||||
readonly stderr: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and capture its output without judging the exit status.
|
||||
* @param command - executable name.
|
||||
* @param args - command arguments.
|
||||
* @param options - working directory and environment.
|
||||
* @returns The exit status and captured streams.
|
||||
*/
|
||||
export function attempt(command: string, args: readonly string[], options: RunOptions = {}): CommandResult {
|
||||
const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, encoding: 'utf8' })
|
||||
if (result.error !== undefined) throw result.error
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command, capture its standard output, and fail on a non-zero exit.
|
||||
* @param command - executable name.
|
||||
* @param args - command arguments.
|
||||
* @param options - working directory and environment.
|
||||
* @returns The trimmed standard output.
|
||||
*/
|
||||
export function capture(command: string, args: readonly string[], options: RunOptions = {}): string {
|
||||
const result = attempt(command, args, options)
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}:\n${result.stdout}\n${result.stderr}`)
|
||||
}
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command with inherited streams, so its progress reaches the log, and
|
||||
* fail on a non-zero exit.
|
||||
* @param command - executable name.
|
||||
* @param args - command arguments.
|
||||
* @param options - working directory and environment.
|
||||
*/
|
||||
export function run(command: string, args: readonly string[], options: RunOptions = {}): void {
|
||||
const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' })
|
||||
if (result.error !== undefined) throw result.error
|
||||
if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this module is the process entry point.
|
||||
*
|
||||
* The release scripts are both commands and modules: a test imports their pure
|
||||
* logic, and importing a module runs its body, so an unguarded `main()` would
|
||||
* run the wrong command with the wrong arguments.
|
||||
* @param moduleUrl - the caller's `import.meta.url`.
|
||||
* @returns True when Node started this module.
|
||||
*/
|
||||
export function isEntry(moduleUrl: string): boolean {
|
||||
const invoked = process.argv[1]
|
||||
if (invoked === undefined) return false
|
||||
return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl))
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Publish one packed release family from the tarballs the pack step produced.
|
||||
*
|
||||
* Publication is decided per package against the registry, never from a list of
|
||||
* "what this release includes": a version the registry lacks is published, a
|
||||
* version whose published tarball has the same integrity is skipped, and a
|
||||
* version whose published tarball differs fails the run — that last case means
|
||||
* the content changed without a version bump
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
* Skipping on identical integrity is what makes re-running the publish step over
|
||||
* the same artifact safe.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { attempt, isEntry, run } from './process.ts'
|
||||
import { packedIdentity, readPublishOrder } from './tarball.ts'
|
||||
|
||||
/** npm access level for every package this repository publishes. */
|
||||
const ACCESS = 'restricted'
|
||||
|
||||
/** What the registry knows about one version. */
|
||||
type RegistryState =
|
||||
| { readonly kind: 'absent' }
|
||||
| { readonly kind: 'present'; readonly integrity: string }
|
||||
|
||||
/**
|
||||
* The subresource integrity string npm records for a tarball.
|
||||
* @param tarball - absolute tarball path.
|
||||
* @returns A `sha512-<base64>` string.
|
||||
*/
|
||||
function integrityOf(tarball: string): string {
|
||||
return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the registry whether a version exists, and with what integrity.
|
||||
* @param name - package name.
|
||||
* @param version - package version.
|
||||
* @returns The registry state for that version.
|
||||
*/
|
||||
function registryState(name: string, version: string): RegistryState {
|
||||
const result = attempt('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'])
|
||||
if (result.status !== 0) {
|
||||
const output = `${result.stdout}${result.stderr}`
|
||||
if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' }
|
||||
throw new Error(`npm view ${name}@${version} failed:\n${output}`)
|
||||
}
|
||||
const parsed: unknown = JSON.parse(result.stdout)
|
||||
if (typeof parsed !== 'string' || parsed === '') {
|
||||
throw new Error(`registry reported no dist.integrity for ${name}@${version}`)
|
||||
}
|
||||
return { kind: 'present', integrity: parsed }
|
||||
}
|
||||
|
||||
/** Publish the family named by `--family` from the directory named by `--from`. */
|
||||
function main(): void {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, from: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined || values.from === undefined) {
|
||||
throw new Error('usage: publish.ts --family <dsh|vendor> --from <packed directory>')
|
||||
}
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const directory = resolve(process.cwd(), values.from)
|
||||
|
||||
let published = 0
|
||||
let skipped = 0
|
||||
for (const filename of readPublishOrder(directory)) {
|
||||
const tarball = join(directory, filename)
|
||||
const { name, version } = packedIdentity(tarball)
|
||||
const state = registryState(name, version)
|
||||
if (state.kind === 'present') {
|
||||
const local = integrityOf(tarball)
|
||||
if (state.integrity !== local) {
|
||||
throw new Error(
|
||||
`${name}@${version} is already published with different content`
|
||||
+ `\n registry: ${state.integrity}\n packed: ${local}`
|
||||
+ '\nBump the version, or investigate why the build is not reproducible.',
|
||||
)
|
||||
}
|
||||
console.log(`release publish: ${name}@${version} already published, skipping`)
|
||||
skipped += 1
|
||||
continue
|
||||
}
|
||||
// A prerelease version never takes the latest dist-tag.
|
||||
const tagArgs = version.includes('-') ? ['--tag', 'next'] : []
|
||||
run('npm', ['publish', tarball, '--access', ACCESS, ...tagArgs])
|
||||
published += 1
|
||||
}
|
||||
|
||||
console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Reading packed npm tarballs and the order file that accompanies them.
|
||||
*
|
||||
* The release steps after pack treat a directory of tarballs as the unit of
|
||||
* work, so they read what a tarball declares rather than what the checkout
|
||||
* currently says.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { capture } from './process.ts'
|
||||
|
||||
/** Name of the file recording the order in which a packed family uploads. */
|
||||
export const PUBLISH_ORDER_FILE = 'publish-order.txt'
|
||||
|
||||
/** What a packed tarball calls itself. */
|
||||
export interface PackedIdentity {
|
||||
/** Package name from the packed manifest. */
|
||||
readonly name: string
|
||||
/** Package version from the packed manifest. */
|
||||
readonly version: string
|
||||
}
|
||||
|
||||
/**
|
||||
* List a tarball's members.
|
||||
* @param tarball - absolute tarball path.
|
||||
* @returns Every path inside the archive.
|
||||
*/
|
||||
export function tarballFiles(tarball: string): string[] {
|
||||
return capture('tar', ['-tzf', tarball]).split('\n').filter(line => line !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a packed tarball's own manifest.
|
||||
* @param tarball - absolute tarball path.
|
||||
* @returns The name and version the tarball declares.
|
||||
*/
|
||||
export function packedIdentity(tarball: string): PackedIdentity {
|
||||
const manifest: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json']))
|
||||
if (manifest === null || typeof manifest !== 'object') throw new Error(`${tarball} has no manifest`)
|
||||
const { name, version } = manifest as Record<string, unknown>
|
||||
if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`)
|
||||
return { name, version }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a packed directory's upload order.
|
||||
* @param directory - absolute path of a pack output directory.
|
||||
* @returns Tarball filenames in upload order.
|
||||
*/
|
||||
export function readPublishOrder(directory: string): string[] {
|
||||
return readFileSync(join(directory, PUBLISH_ORDER_FILE), 'utf8').split('\n').filter(line => line !== '')
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Install packed tarballs into a throwaway consumer outside the repository and
|
||||
* drive the installed executable with plain Node.
|
||||
*
|
||||
* Every tarball the installed tree needs comes from `--from`, so the only
|
||||
* registry traffic is for external dependencies. That matters beyond hermetic
|
||||
* verification: the harness packages declare the vendored framework as a peer,
|
||||
* and those packages live in another release sequence that this credential-free
|
||||
* job cannot fetch from a private registry — so a dsh verification passes the
|
||||
* vendored family's pack output too, while publishing only its own
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*
|
||||
* What this proves is that `files` selected a complete payload and that the
|
||||
* published dependency ranges resolve. A workspace link or a stale `lib/` in the
|
||||
* checkout cannot stand in for a missing file here.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { releaseFamily } from './families.ts'
|
||||
import { capture, isEntry } from './process.ts'
|
||||
import { packedIdentity } from './tarball.ts'
|
||||
|
||||
/**
|
||||
* Environment for the installed artifact: no host Node hooks, no host DeepSeek
|
||||
* Harness home, and no ambient npm user agent that would confuse npm.
|
||||
* @param consumerRoot - the throwaway consumer directory.
|
||||
* @returns The child environment.
|
||||
*/
|
||||
function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv {
|
||||
const environment = { ...process.env }
|
||||
delete environment.npm_config_user_agent
|
||||
delete environment.NPM_CONFIG_USER_AGENT
|
||||
delete environment.NODE_OPTIONS
|
||||
delete environment.NODE_PATH
|
||||
environment.DSH_HOME = resolve(consumerRoot, '.dsh')
|
||||
environment.DSH_AGENTS_HOME = resolve(consumerRoot, '.agents')
|
||||
environment.DSH_TELEMETRY_DISABLED = '1'
|
||||
return environment
|
||||
}
|
||||
|
||||
/**
|
||||
* Every packed tarball in the given directories, as `file:` dependency entries.
|
||||
*
|
||||
* The directories are read by their contents rather than a pack order file: a
|
||||
* directory here can hold tarballs packed only to satisfy a cross-sequence
|
||||
* dependency, which no release order describes.
|
||||
* @param directories - absolute directories holding packed tarballs.
|
||||
* @returns Package name to tarball file URL, and the version each carries.
|
||||
*/
|
||||
function packedDependencies(directories: readonly string[]): Map<string, { url: string; version: string }> {
|
||||
const dependencies = new Map<string, { url: string; version: string }>()
|
||||
for (const directory of directories) {
|
||||
const tarballs = readdirSync(directory).filter(name => name.endsWith('.tgz')).sort()
|
||||
if (tarballs.length === 0) throw new Error(`${directory} holds no packed tarball`)
|
||||
for (const filename of tarballs) {
|
||||
const tarball = join(directory, filename)
|
||||
const { name, version } = packedIdentity(tarball)
|
||||
dependencies.set(name, { url: pathToFileURL(tarball).href, version })
|
||||
}
|
||||
}
|
||||
return dependencies
|
||||
}
|
||||
|
||||
/** Install every tarball under `--from` and drive the `--family` entry. */
|
||||
function main(): void {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' }, from: { type: 'string', multiple: true } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined || values.from === undefined || values.from.length === 0) {
|
||||
throw new Error('usage: verify-packed-install.ts --family <dsh|vendor> --from <packed directory> [--from ...]')
|
||||
}
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const entry = family.installedEntry
|
||||
if (entry === undefined) {
|
||||
console.log(`release verify-packed-install: family ${family.id} publishes no executable, nothing to drive`)
|
||||
return
|
||||
}
|
||||
|
||||
const root = process.cwd()
|
||||
const packed = packedDependencies(values.from.map(directory => resolve(root, directory)))
|
||||
const expected = packed.get(entry.packageName)
|
||||
if (expected === undefined) throw new Error(`${entry.packageName} is not among the packed tarballs`)
|
||||
|
||||
const consumerRoot = mkdtempSync(join(tmpdir(), `dsh-packed-${family.id}-`))
|
||||
try {
|
||||
writeFileSync(join(consumerRoot, 'package.json'), `${JSON.stringify({
|
||||
name: `dsh-packed-install-${family.id}`,
|
||||
version: '0.0.0',
|
||||
private: true,
|
||||
dependencies: Object.fromEntries([...packed].map(([name, entryPacked]) => [name, entryPacked.url])),
|
||||
}, null, 2)}\n`)
|
||||
|
||||
const environment = consumerEnvironment(consumerRoot)
|
||||
console.log(`release verify-packed-install: installing ${String(packed.size)} tarball(s) into ${consumerRoot}`)
|
||||
// Optional dependencies are omitted: the Landlock platform packages behind
|
||||
// them need a musl toolchain and one build per architecture, and a consumer
|
||||
// that cannot install them must still start — which is what optional means
|
||||
// here. Their entry package is a plain dependency of dsh-sandbox-local, so
|
||||
// its tarball is supplied through --from.
|
||||
capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional'],
|
||||
{ cwd: consumerRoot, env: environment })
|
||||
|
||||
const bin = join(consumerRoot, 'node_modules', ...entry.packageName.split('/'), entry.binPath)
|
||||
const version = capture(process.execPath, [bin, '--version'], { cwd: consumerRoot, env: environment })
|
||||
if (version !== expected.version) {
|
||||
throw new Error(`installed ${entry.packageName} --version reported ${JSON.stringify(version)}, expected ${expected.version}`)
|
||||
}
|
||||
console.log(`release verify-packed-install: installed ${entry.packageName} reports ${version}`)
|
||||
} finally {
|
||||
rmSync(consumerRoot, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Verify a release family's version baseline, and — when publishing — that the
|
||||
* run comes from the family's tag and its members are publishable.
|
||||
*
|
||||
* Publication happens only from GitHub Actions, so the tag and publishability
|
||||
* checks are gates on the workflow, not advisory local warnings
|
||||
* ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)).
|
||||
*/
|
||||
|
||||
import { parseArgs } from 'node:util'
|
||||
import { isEntry } from './process.ts'
|
||||
import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
|
||||
/**
|
||||
* Assert every member may be published: npm refuses a `private` package.
|
||||
* @param members - the family's members.
|
||||
*/
|
||||
function verifyPublishable(members: readonly ReleaseMember[]): void {
|
||||
const priv = members.filter(member => member.manifest.private === true)
|
||||
if (priv.length > 0) {
|
||||
throw new Error(`publishing requires removing "private": true from:\n${priv.map(member => member.directory).join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the workflow runs from a tag this family publishes from, and that the
|
||||
* tag names a version the family actually carries.
|
||||
* @param family - the release family.
|
||||
* @param members - the family's members.
|
||||
* @param ref - the `GITHUB_REF` value.
|
||||
*/
|
||||
function verifyTag(family: ReleaseFamily, members: readonly ReleaseMember[], ref: string): void {
|
||||
const prefix = 'refs/tags/'
|
||||
if (!ref.startsWith(prefix)) {
|
||||
throw new Error(`publishing release family ${family.id} requires running from a ${family.tagPrefix}* tag, got ${ref || '(no ref)'}`)
|
||||
}
|
||||
const tag = ref.slice(prefix.length)
|
||||
if (!tag.startsWith(family.tagPrefix)) {
|
||||
throw new Error(`tag ${tag} does not belong to release family ${family.id} (expected ${family.tagPrefix}*)`)
|
||||
}
|
||||
const expected = members.map(member => family.tagFor(member))
|
||||
if (!expected.includes(tag)) {
|
||||
throw new Error(`tag ${tag} names no version this family carries; its members would tag as:\n${[...new Set(expected)].join('\n')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the verification for the family named by `--family`. */
|
||||
function main(): void {
|
||||
const { values } = parseArgs({
|
||||
options: { family: { type: 'string' } },
|
||||
allowPositionals: false,
|
||||
})
|
||||
if (values.family === undefined) throw new Error('usage: verify.ts --family <dsh|vendor>')
|
||||
|
||||
const family = releaseFamily(values.family)
|
||||
const members = family.members(process.cwd())
|
||||
family.verifyVersions(members)
|
||||
|
||||
const publishing = process.env.RELEASE_PUBLISH === 'true'
|
||||
if (publishing) {
|
||||
verifyPublishable(members)
|
||||
verifyTag(family, members, process.env.GITHUB_REF ?? '')
|
||||
}
|
||||
|
||||
const versions = [...new Set(members.map(member => member.version))]
|
||||
const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions`
|
||||
console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) main()
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Acceptance-path coverage for the rescope codemod's exact-edit classifier: a
|
||||
* duplicated insertion — what a non-idempotent apply produces — must be
|
||||
* rejected rather than applied again.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { exactEditState } from './rescope-vendor.ts'
|
||||
|
||||
const ANCHOR = '\n## Sync procedure'
|
||||
const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}`
|
||||
|
||||
describe('exactEditState', () => {
|
||||
it('classifies an insertion by its target form, so a duplicate is invalid', () => {
|
||||
expect(exactEditState(`log\n${ANCHOR}\n`, ANCHOR, INSERTED, 1)).toBe('pending')
|
||||
expect(exactEditState(`log${INSERTED}\n`, ANCHOR, INSERTED, 1)).toBe('applied')
|
||||
// The anchor survives an insertion, so counting the source form would have
|
||||
// called this pending and inserted the entry a second time.
|
||||
expect(exactEditState(`log${INSERTED}${INSERTED}\n`, ANCHOR, INSERTED, 1)).toBe('invalid')
|
||||
expect(exactEditState('log\n', ANCHOR, INSERTED, 1)).toBe('invalid')
|
||||
})
|
||||
|
||||
it('classifies a deletion by its source form, and requires its remainder to survive', () => {
|
||||
const remainder = 'exclude:\n'
|
||||
const withEntries = 'exclude:\n - cordis@4\n'
|
||||
expect(exactEditState(withEntries, withEntries, remainder, 1)).toBe('pending')
|
||||
expect(exactEditState(remainder, withEntries, remainder, 1)).toBe('applied')
|
||||
// Upstream dropped the whole field: the source form is gone, but so is the
|
||||
// remainder, so this is a moved site rather than a completed deletion.
|
||||
expect(exactEditState('unrelated:\n', withEntries, remainder, 1)).toBe('invalid')
|
||||
})
|
||||
|
||||
it('requires a replacement to leave no source form and the exact target count', () => {
|
||||
expect(exactEditState('a = 1\n', 'a = 1', 'b = 2', 1)).toBe('pending')
|
||||
expect(exactEditState('b = 2\n', 'a = 1', 'b = 2', 1)).toBe('applied')
|
||||
expect(exactEditState('b = 2\nb = 2\n', 'a = 1', 'b = 2', 1)).toBe('invalid')
|
||||
// A moved or partially applied site: neither state is complete.
|
||||
expect(exactEditState('a = 1\nb = 2\n', 'a = 1', 'b = 2', 1)).toBe('invalid')
|
||||
expect(exactEditState('x\n', 'a = 1', 'b = 2', 1)).toBe('invalid')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,771 @@
|
||||
/**
|
||||
* Rescope the vendored Cordis packages into the `@deepseek-ai` scope, and undo
|
||||
* that rescope with `--reverse`. Every harness package declares `cordis` as a
|
||||
* peer dependency, so publication carries this framework layer too; publishing
|
||||
* it under the upstream names would squat them on the registry
|
||||
* ([rationale](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md),
|
||||
* [name mapping](../docs/rescope.md)).
|
||||
*
|
||||
* The generic pass rewrites ONLY delimited, complete package-name tokens:
|
||||
* `'old'` / `"old"` / `` `old` `` / `'old/subpath'`, plus a YAML `name: old`
|
||||
* scalar. A match needs a quote (or `name: `) immediately left and the matching
|
||||
* quote — optionally after a `/subpath` — immediately right, which excludes
|
||||
* `cordis.yml`, the Loader's `cordis:` builtin prefix, `cordis-config-entry`,
|
||||
* `@deepseek-ai/dsh-tool-cordis`, and `cordiverse/cordis`, and makes the
|
||||
* rewrite idempotent because the scoped name's `cordis` is preceded by `/`.
|
||||
* Markdown follows the rename inside every fence, and in `docs/` prose too:
|
||||
* a tutorial that teaches an unresolvable name is wrong, while prose elsewhere
|
||||
* records what was true when it was written.
|
||||
*
|
||||
* Sites the token rule cannot express (dot-notation access, unquoted object
|
||||
* keys, regex literals, the vendored-manifest table) are listed in
|
||||
* {@link EXACT_EDITS} with an exact hit count, so an upstream change to one of
|
||||
* them fails loudly instead of being silently skipped.
|
||||
*
|
||||
* Usage: `pnpm run rescope-vendor [--apply|--check] [--reverse]`. Without a
|
||||
* mode it reports what would change. `--check` asserts the post-state: no
|
||||
* residue, every exact edit landed, every postcondition holds, and a second
|
||||
* `--apply` would be a no-op.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** One vendored package's directory, upstream npm name, and rescoped name. */
|
||||
interface Rename {
|
||||
readonly directory: string
|
||||
readonly upstream: string
|
||||
readonly scoped: string
|
||||
}
|
||||
|
||||
/** The mapping this codemod applies; `vendor/README.md` carries the same table. */
|
||||
const RENAMES: readonly Rename[] = [
|
||||
{ directory: 'cordis', upstream: 'cordis', scoped: '@deepseek-ai/cordis' },
|
||||
{ directory: 'cosmokit', upstream: 'cosmokit', scoped: '@deepseek-ai/cosmokit' },
|
||||
{ directory: 'schemastery', upstream: 'schemastery', scoped: '@deepseek-ai/schemastery' },
|
||||
{ directory: 'loader', upstream: '@cordisjs/plugin-loader', scoped: '@deepseek-ai/cordis-plugin-loader' },
|
||||
{ directory: 'include', upstream: '@cordisjs/plugin-include', scoped: '@deepseek-ai/cordis-plugin-include' },
|
||||
{ directory: 'group', upstream: '@cordisjs/plugin-group', scoped: '@deepseek-ai/cordis-plugin-group' },
|
||||
{ directory: 'timer', upstream: '@cordisjs/plugin-timer', scoped: '@deepseek-ai/cordis-plugin-timer' },
|
||||
{ directory: 'hmr', upstream: '@cordisjs/plugin-hmr', scoped: '@deepseek-ai/cordis-plugin-hmr' },
|
||||
{ directory: 'logger-console', upstream: '@cordisjs/plugin-logger-console', scoped: '@deepseek-ai/cordis-plugin-logger-console' },
|
||||
]
|
||||
|
||||
const EXTENSIONS = ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.tpl', '.json', '.yml', '.yaml', '.md'] as const
|
||||
|
||||
/** An exact-string edit the token rule cannot express, with its required hit count. */
|
||||
interface ExactEdit {
|
||||
readonly id: string
|
||||
readonly file: string
|
||||
readonly find: string
|
||||
readonly replace: string
|
||||
readonly expect: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A file where an upstream name also appears as a vendor DIRECTORY name or an
|
||||
* upstream runtime identifier: the generic pass is disabled for the listed
|
||||
* names and {@link EXACT_EDITS} renames the real package-name occurrences.
|
||||
*/
|
||||
interface GenericSkip {
|
||||
readonly file: string
|
||||
readonly upstream: readonly string[]
|
||||
}
|
||||
|
||||
const GENERIC_SKIPS: readonly GenericSkip[] = [
|
||||
// `vendorPackages` lists vendor/ directory names, joined with 'vendor' below it.
|
||||
{ file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', upstream: ['cordis', 'cosmokit', 'schemastery'] },
|
||||
// Mixes join(root, 'vendor', 'cordis') paths with real manifest names.
|
||||
{ file: 'packages/scaffold/helper/tests/documents.spec.ts', upstream: ['cordis'] },
|
||||
// `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers.
|
||||
{ file: 'vendor/schemastery/src/index.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
|
||||
// apps/cli/config/agent-presets/ — so in these files the bare name is
|
||||
// product data, not a package reference. Renaming it changed which preset
|
||||
// the creator flow stages and which id the roster reports.
|
||||
{ file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/section.spec.tsx', upstream: ['cordis'] },
|
||||
{ file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] },
|
||||
{ file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] },
|
||||
// The preset's own composition: its header comment and its system prompt name
|
||||
// the preset a model mounts, so the scoped name would send the model after an
|
||||
// id no roster reports.
|
||||
{ file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] },
|
||||
// GROUP_ORDER holds `packages/<group>/` directory names, not package names.
|
||||
{ file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] },
|
||||
{ file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] },
|
||||
]
|
||||
|
||||
/** A string that must appear exactly `count` times once the rescope has run. */
|
||||
interface PostCondition {
|
||||
readonly file: string
|
||||
readonly text: string
|
||||
readonly count: number
|
||||
}
|
||||
|
||||
const POSTCONDITIONS: readonly PostCondition[] = [
|
||||
{ file: 'vendor/cordis/package.json', text: '"name": "@deepseek-ai/cordis"', count: 1 },
|
||||
{ file: 'vendor/hmr/package.json', text: '"name": "@deepseek-ai/cordis-plugin-hmr"', count: 1 },
|
||||
{ file: 'scripts/cordis-walk.ts', text: '@deepseek-ai\\/cordis', count: 1 },
|
||||
{ file: 'scripts/cordis-walk.ts', text: '!== \'@deepseek-ai/cordis\'', count: 1 },
|
||||
{ file: 'scripts/gen-scoped-events.ts', text: '=== \'@deepseek-ai/cordis\'', count: 1 },
|
||||
{ file: 'packages/typert/generator/src/analyzer.ts', text: '!== \'@deepseek-ai/cordis\'', count: 2 },
|
||||
{ file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 },
|
||||
{ file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts', text: '\'@deepseek-ai/cordis\': \'^4.0.0-rc.7\'', count: 1 },
|
||||
{ file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts', text: '\'@deepseek-ai/cordis\': cordisSpec', count: 2 },
|
||||
{ file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 },
|
||||
{ file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 },
|
||||
// One insertion, once: a duplicated log entry is what a non-idempotent apply produced.
|
||||
{ file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 },
|
||||
{ file: 'knip.json', text: '@cordisjs', count: 0 },
|
||||
{ file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 },
|
||||
// The preset ids in this table are product data, not package names.
|
||||
{ file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 },
|
||||
// The preset id the shipped composition documents to its own model.
|
||||
{ file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 },
|
||||
{ file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 },
|
||||
// The vendor-directory paths in these fixtures must survive the rename.
|
||||
{ file: 'packages/scaffold/helper/tests/documents.spec.ts', text: 'join(root, \'vendor\', \'cordis\')', count: 2 },
|
||||
{ file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', text: '\'cordis\', \'loader\', \'include\', \'timer\', \'hmr\', \'logger-console\',', count: 1 },
|
||||
]
|
||||
|
||||
/**
|
||||
* Every exact edit, in application order. Each `find` is written against the
|
||||
* PRE-rename text because these run before the generic pass, so no `find` may
|
||||
* quote a neighbouring line the generic pass would rewrite.
|
||||
*/
|
||||
const EXACT_EDITS: readonly ExactEdit[] = [
|
||||
{
|
||||
id: 'cordis-walk-merge-head',
|
||||
file: 'scripts/cordis-walk.ts',
|
||||
find: 'const MERGE_HEAD = /declare module [\'"](?:cordis|\\.\\/context\\.ts)[\'"]/',
|
||||
replace: 'const MERGE_HEAD = /declare module [\'"](?:@deepseek-ai\\/cordis|\\.\\/context\\.ts)[\'"]/',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'constraints-manifest-lookup',
|
||||
file: 'scripts/check-workspace-constraints.ts',
|
||||
find: ` const peer = manifest.peerDependencies?.cordis
|
||||
const dev = manifest.devDependencies?.cordis
|
||||
|
||||
if (!peer) errors.push(\`\${label}: cordis must be a peerDependency\`)
|
||||
if (!dev) errors.push(\`\${label}: cordis must also be a devDependency\`)
|
||||
if (peer && dev && peer !== dev) {
|
||||
errors.push(\`\${label}: cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`,
|
||||
replace: ` const peer = manifest.peerDependencies?.['@deepseek-ai/cordis']
|
||||
const dev = manifest.devDependencies?.['@deepseek-ai/cordis']
|
||||
|
||||
if (!peer) errors.push(\`\${label}: @deepseek-ai/cordis must be a peerDependency\`)
|
||||
if (!dev) errors.push(\`\${label}: @deepseek-ai/cordis must also be a devDependency\`)
|
||||
if (peer && dev && peer !== dev) {
|
||||
errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'scaffold-dependency-policy',
|
||||
file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts',
|
||||
find: ' cordis: \'^4.0.0-rc.7\',',
|
||||
replace: ' \'@deepseek-ai/cordis\': \'^4.0.0-rc.7\',',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'scaffold-plugin-blueprint',
|
||||
file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts',
|
||||
find: ` cordis: cordisSpec,
|
||||
},
|
||||
devDependencies: {
|
||||
cordis: cordisSpec,
|
||||
},`,
|
||||
replace: ` '@deepseek-ai/cordis': cordisSpec,
|
||||
},
|
||||
devDependencies: {
|
||||
'@deepseek-ai/cordis': cordisSpec,
|
||||
},`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'scaffold-link-workspace-lookup',
|
||||
file: 'packages/scaffold/create-sdk/tests/link-workspace.e2e.ts',
|
||||
find: 'manifest.dependencies.cordis',
|
||||
replace: 'manifest.dependencies[\'@deepseek-ai/cordis\']',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'documents-spec-manifest-name',
|
||||
file: 'packages/scaffold/helper/tests/documents.spec.ts',
|
||||
find: 'JSON.stringify({ name: \'cordis\' })',
|
||||
replace: 'JSON.stringify({ name: \'@deepseek-ai/cordis\' })',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'documents-spec-peer-key',
|
||||
file: 'packages/scaffold/helper/tests/documents.spec.ts',
|
||||
find: 'peerDependencies: { cordis: \'^4\' },',
|
||||
replace: 'peerDependencies: { \'@deepseek-ai/cordis\': \'^4\' },',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'documents-spec-closure-order',
|
||||
file: 'packages/scaffold/helper/tests/documents.spec.ts',
|
||||
find: ' \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\', \'cordis\',',
|
||||
replace: ' \'@deepseek-ai/cordis\', \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\',',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'documents-spec-lookups',
|
||||
file: 'packages/scaffold/helper/tests/documents.spec.ts',
|
||||
find: ` expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/)
|
||||
expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false')
|
||||
expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis'))
|
||||
expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')`,
|
||||
replace: ` expect(manifest.npmDependency('@deepseek-ai/cordis')?.spec).toMatch(/^link:/)
|
||||
expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false')
|
||||
expect(workspace.packageDirectory('@deepseek-ai/cordis')).toBe(join(root, 'vendor', 'cordis'))
|
||||
expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis')`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'documents-spec-policy-lookup',
|
||||
file: 'packages/scaffold/helper/tests/documents.spec.ts',
|
||||
find: ' expect(resolveNpmDependency(\'cordis\', \'devDependencies\', \'0.0.1\')).toEqual({',
|
||||
replace: ' expect(resolveNpmDependency(\'@deepseek-ai/cordis\', \'devDependencies\', \'0.0.1\')).toEqual({',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it.
|
||||
id: 'knip-logger-console',
|
||||
file: 'knip.json',
|
||||
find: ` "ignoreDependencies": [
|
||||
"@cordisjs/plugin-logger-console",
|
||||
"@deepseek-ai/.+"
|
||||
]
|
||||
},
|
||||
"packages/util/home": {`,
|
||||
replace: ` "ignoreDependencies": [
|
||||
"@deepseek-ai/.+"
|
||||
]
|
||||
},
|
||||
"packages/util/home": {`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'knip-bundle-base',
|
||||
file: 'knip.json',
|
||||
find: ` "packages/bundle/base": {
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/.+",
|
||||
"@cordisjs/.+"
|
||||
]`,
|
||||
replace: ` "packages/bundle/base": {
|
||||
"ignoreDependencies": [
|
||||
"@deepseek-ai/.+"
|
||||
]`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// Rescoped packages are never fetched from a registry, so the exclusion is dead config.
|
||||
id: 'pnpm-release-age',
|
||||
file: 'pnpm-workspace.yaml',
|
||||
find: `minimumReleaseAgeExclude:
|
||||
# Cordis release candidates are source-vendored and pinned in vendor/README.md
|
||||
# during the same-day sync that updates package manifests and the lockfile.
|
||||
- '@cordisjs/plugin-loader@1.0.0-rc.5'
|
||||
- cordis@4.0.0-rc.7
|
||||
`,
|
||||
replace: 'minimumReleaseAgeExclude:\n',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'publication-set-scope-assertion',
|
||||
file: 'scripts/publish-npm-baseline.ts',
|
||||
find: ' if (!isVendored && !name.startsWith(\'@deepseek-ai/\')) {',
|
||||
replace: ` // Vendored packages are rescoped too (vendor/README.md), so publication
|
||||
// never carries an upstream name that would squat it on the registry.
|
||||
if (!name.startsWith('@deepseek-ai/')) {`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-preamble',
|
||||
file: 'vendor/README.md',
|
||||
find: 'All vendored packages keep their **original npm names** and are marked `private: true` — they are never published from this repo. `pnpm-workspace.yaml#linkWorkspacePackages` makes matching upstream semver ranges resolve these pinned workspaces, including imports from built `lib/`; disabling it substitutes npm copies behind the same names.',
|
||||
replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-<x>` → `@deepseek-ai/cordis-plugin-<x>`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-schemastery-note',
|
||||
file: 'vendor/README.md',
|
||||
find: 'whose lazy `require(\'cosmokit\')` can race',
|
||||
replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-table-head',
|
||||
file: 'vendor/README.md',
|
||||
find: '| Directory | npm name | Version | Upstream repo | Commit |\n|---|---|---|---|---|',
|
||||
replace: '| Directory | npm name | Upstream name | Version | Upstream repo | Commit |\n|---|---|---|---|---|---|',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendor-readme-local-modification-log',
|
||||
file: 'vendor/README.md',
|
||||
find: '\n## Sync procedure',
|
||||
replace: '17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// A plain fence listing the bundle's mounted tree: a bare token, no quotes.
|
||||
id: 'agent-spine-demo-mounted-tree',
|
||||
file: 'packages/examples/agent-spine-demo/README.md',
|
||||
find: '@cordisjs/plugin-timer timer service',
|
||||
replace: '@deepseek-ai/cordis-plugin-timer timer service',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'agent-spine-demo-mounted-tree-zh',
|
||||
file: 'packages/examples/agent-spine-demo/README.zh.md',
|
||||
find: '@cordisjs/plugin-timer timer service',
|
||||
replace: '@deepseek-ai/cordis-plugin-timer timer service',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The root contract claimed vendored packages keep their upstream names.
|
||||
id: 'root-agents-vendored-name-contract',
|
||||
file: 'AGENTS.md',
|
||||
find: 'vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.',
|
||||
replace: 'vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package.',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The client purity gate reads `@deepseek-ai/` as "another plugin package".
|
||||
// The rescope moves the vendored framework and its libraries into that
|
||||
// namespace, where the gate would reject the library imports client
|
||||
// bundles have always inlined, so it needs their names.
|
||||
id: 'client-purity-vendored-libraries',
|
||||
file: 'packages/client/tsdown.client.ts',
|
||||
find: '/** Generated descriptor/codec contribution with no shared runtime identity. */',
|
||||
replace: `/**
|
||||
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
|
||||
* would read them as plugin packages. They carry no cross-plugin runtime
|
||||
* identity to share — the framework itself is a platform module (external),
|
||||
* while these are ordinary libraries a browser bundle inlines.
|
||||
*/
|
||||
const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/
|
||||
|
||||
/** Generated descriptor/codec contribution with no shared runtime identity. */`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'client-purity-vendored-libraries-predicate',
|
||||
file: 'packages/client/tsdown.client.ts',
|
||||
find: ' if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point',
|
||||
replace: ` if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity
|
||||
if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The step-1 file tree told the reader to keep the upstream name, one
|
||||
// paragraph above the invariant that says to rescope it.
|
||||
id: 'vendoring-cookbook-tree-comment',
|
||||
file: 'docs/cookbook/adding-a-vendored-package.md',
|
||||
find: ' package.json # from upstream; set "private": true, keep name/exports/type',
|
||||
replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendoring-cookbook-tree-comment-zh',
|
||||
file: 'docs/cookbook/adding-a-vendored-package.zh.md',
|
||||
find: ' package.json # from upstream; set "private": true, keep name/exports/type',
|
||||
replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The checklist told the next vendoring to keep upstream's name.
|
||||
id: 'vendoring-cookbook-name-invariant',
|
||||
file: 'docs/cookbook/adding-a-vendored-package.md',
|
||||
find: "keep upstream's `name`/`version`/`exports`/`type`",
|
||||
replace: "rescope the `name` ([mapping](../rescope.md)) while keeping upstream's `version`/`exports`/`type`",
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'vendoring-cookbook-name-invariant-zh',
|
||||
file: 'docs/cookbook/adding-a-vendored-package.zh.md',
|
||||
find: '保留上游的 `name`/`version`/`exports`/`type`',
|
||||
replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The real package references in files whose other `cordis` strings are preset ids.
|
||||
id: 'agent-preset-spec-framework-import',
|
||||
file: 'packages/client/ui-agent-preset/tests/apply.spec.ts',
|
||||
find: "import { Context } from 'cordis'",
|
||||
replace: "import { Context } from '@deepseek-ai/cordis'",
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'web-agent-presets-e2e-framework-import',
|
||||
file: 'apps/cli/tests/web-agent-presets.e2e.ts',
|
||||
find: "import { Context } from 'cordis'",
|
||||
replace: "import { Context } from '@deepseek-ai/cordis'",
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-vendored-row-type',
|
||||
file: 'scripts/gen-third-party-notices.ts',
|
||||
find: `export interface VendoredRow {
|
||||
npmName: string
|
||||
upstream: string
|
||||
}`,
|
||||
replace: `export interface VendoredRow {
|
||||
npmName: string
|
||||
/** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */
|
||||
upstreamName: string
|
||||
upstream: string
|
||||
}`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-vendored-row-parse',
|
||||
file: 'scripts/gen-third-party-notices.ts',
|
||||
find: ` const match = /^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| (https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$/.exec(line)
|
||||
if (match === null) continue
|
||||
const [, npmName, upstream] = match
|
||||
if (npmName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstream })`,
|
||||
replace: ` const match = new RegExp(String.raw\`^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| \`
|
||||
+ String.raw\`(https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$\`).exec(line)
|
||||
if (match === null) continue
|
||||
const [, npmName, upstreamName, upstream] = match
|
||||
if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstreamName, upstream })`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-vendored-section',
|
||||
file: 'scripts/gen-third-party-notices.ts',
|
||||
find: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed',
|
||||
replace: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \\`@deepseek-ai\\` scope. All are MIT-licensed',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-vendored-table',
|
||||
file: 'scripts/gen-third-party-notices.ts',
|
||||
find: `| Package | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`,
|
||||
replace: `| Package | Upstream name | Upstream | License |
|
||||
| --- | --- | --- | --- |
|
||||
\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | \\\`\${row.upstreamName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-spec-row-fixture',
|
||||
file: 'scripts/gen-third-party-notices.spec.ts',
|
||||
find: ' expect(rows).toContainEqual({ npmName: \'cordis\', upstream: \'https://github.com/cordiverse/cordis\' })',
|
||||
replace: ` expect(rows).toContainEqual({
|
||||
npmName: '@deepseek-ai/cordis',
|
||||
upstreamName: 'cordis',
|
||||
upstream: 'https://github.com/cordiverse/cordis',
|
||||
})`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'notices-spec-shape-fixture',
|
||||
file: 'scripts/gen-third-party-notices.spec.ts',
|
||||
find: 'parseVendoredRows(\'| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')',
|
||||
replace: 'parseVendoredRows(\'| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')',
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
// The framework peer is no longer a registry name, so the rehearsal must install this
|
||||
// repository's vendored copies; cosmokit comes along as cordis's own dependency.
|
||||
id: 'packed-install-vendored-peer',
|
||||
file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
|
||||
find: ` 'packages/support/invariants',
|
||||
]`,
|
||||
replace: ` 'packages/support/invariants',
|
||||
// The framework and the vendored packages the closure declares outright:
|
||||
// rescoped into @deepseek-ai, so the consumer installs this repository's
|
||||
// copies. Schemastery is a hard dependency of three members above, not a
|
||||
// peer, so npm resolves it while installing them.
|
||||
'vendor/cordis',
|
||||
'vendor/cosmokit',
|
||||
'vendor/schemastery',
|
||||
]`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'packed-install-registry-spec',
|
||||
file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
|
||||
find: ` // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional
|
||||
// dependencies because the launcher selects its OS/CPU package through one.
|
||||
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
|
||||
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], {`,
|
||||
replace: ` // Peer ranges resolve to the tarballs, the framework peer included. Do not omit optional
|
||||
// dependencies because the launcher selects its OS/CPU package through one.
|
||||
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
|
||||
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], {`,
|
||||
expect: 1,
|
||||
},
|
||||
{
|
||||
id: 'packed-install-module-doc',
|
||||
file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts',
|
||||
find: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current
|
||||
* repository's Landlock entry/platform packages, then installs those exact tarballs in an external
|
||||
* plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy,
|
||||
* tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost
|
||||
* executable modes.`,
|
||||
replace: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework
|
||||
* peer, and the current repository's Landlock entry/platform packages, then installs those exact
|
||||
* tarballs in an external plain-Node consumer. The host launcher comes from the exact local tarballs,
|
||||
* so no registry copy, tsx, path mapping, or workspace resolution can hide missing files, dependency
|
||||
* errors, or lost executable modes.`,
|
||||
expect: 1,
|
||||
},
|
||||
// The manifest table's name column plus the new upstream-name column, one edit per row.
|
||||
...RENAMES.map(rename => ({
|
||||
id: `vendor-readme-row-${rename.directory}`,
|
||||
file: 'vendor/README.md',
|
||||
find: `| \`${rename.directory}/\` | \`${rename.upstream}\` | `,
|
||||
replace: `| \`${rename.directory}/\` | \`${rename.scoped}\` | \`${rename.upstream}\` | `,
|
||||
expect: 1,
|
||||
})),
|
||||
]
|
||||
|
||||
/** Files the rescope must never rewrite. */
|
||||
function excluded(file: string): boolean {
|
||||
if (file === 'scripts/rescope-vendor.ts') return true // the mapping itself
|
||||
if (file.startsWith('.agents/notes/')) return true // notes record what was true when written
|
||||
// Recorded model payloads quote documentation verbatim, so they must mirror the
|
||||
// sources on disk — including the notes this rescope leaves alone.
|
||||
if (file.startsWith('scripts/snapshots/')) return true
|
||||
// The mapping documents state both names on purpose.
|
||||
if (file === 'docs/rescope.md' || file === 'docs/rescope.zh.md') return true
|
||||
if (file.endsWith('.i18n.yaml')) return true // blob-hash records, re-recorded by the pairing gate
|
||||
if (file === 'pnpm-lock.yaml') return true // regenerated by pnpm install
|
||||
if (/^vendor\/[^/]+\/(README\.md|LICENSE)$/.test(file)) return true // upstream files kept verbatim
|
||||
return !EXTENSIONS.some(extension => file.endsWith(extension))
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
/** One name's rewrite, precompiled for both delimited forms. */
|
||||
interface Pattern {
|
||||
readonly upstream: string
|
||||
readonly from: string
|
||||
readonly to: string
|
||||
readonly token: RegExp
|
||||
readonly yamlName: RegExp
|
||||
}
|
||||
|
||||
function patterns(reverse: boolean): Pattern[] {
|
||||
return RENAMES
|
||||
.map(rename => ({
|
||||
upstream: rename.upstream,
|
||||
from: reverse ? rename.scoped : rename.upstream,
|
||||
to: reverse ? rename.upstream : rename.scoped,
|
||||
}))
|
||||
.sort((left, right) => right.from.length - left.from.length)
|
||||
.map(rename => ({
|
||||
...rename,
|
||||
token: new RegExp(`(['"\`])${escapeRegExp(rename.from)}((?:/[^'"\`\\s]*)?)\\1`, 'g'),
|
||||
yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)${escapeRegExp(rename.from)}([ \\t]*(?:#.*)?)$`, 'gm'),
|
||||
}))
|
||||
}
|
||||
|
||||
function skipped(file: string, pattern: Pattern): boolean {
|
||||
return GENERIC_SKIPS.some(skip => skip.file === file && skip.upstream.includes(pattern.upstream))
|
||||
}
|
||||
|
||||
function rewriteLine(line: string, file: string, all: readonly Pattern[]): string {
|
||||
let out = line
|
||||
for (const pattern of all) {
|
||||
if (skipped(file, pattern)) continue
|
||||
out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`)
|
||||
out = out.replace(pattern.yamlName, (_match, prefix: string, suffix: string) => `${prefix}${pattern.to}${suffix}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a file's eligible lines.
|
||||
*
|
||||
* Markdown splits in two. Every fence is code a reader copies or a
|
||||
* configuration they mount, so every fence follows the rename regardless of its
|
||||
* info string. Prose follows it only under `docs/`, where a sentence quoting
|
||||
* `` `cordis` `` teaches a name this repository no longer resolves; elsewhere
|
||||
* prose is a record of what was true when it was written, and the same spelling
|
||||
* can mean something else entirely — the Python SDK's `cordis` option, or the
|
||||
* unvendored `@cordisjs/plugin-http`.
|
||||
*/
|
||||
function rewrite(text: string, file: string, all: readonly Pattern[]): { text: string; lines: number } {
|
||||
const markdown = file.endsWith('.md')
|
||||
const prose = markdown && file.startsWith('docs/')
|
||||
let insideFence = false
|
||||
let lines = 0
|
||||
const out = text.split('\n').map((line) => {
|
||||
if (markdown) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
insideFence = !insideFence
|
||||
return line
|
||||
}
|
||||
if (!insideFence && !prose) return line
|
||||
}
|
||||
const next = rewriteLine(line, file, all)
|
||||
if (next !== line) lines += 1
|
||||
return next
|
||||
})
|
||||
return { text: out.join('\n'), lines }
|
||||
}
|
||||
|
||||
function classify(file: string): string {
|
||||
if (/^vendor\/[^/]+\/package\.json$/.test(file)) return 'vendor manifest name'
|
||||
if (file.endsWith('package.json')) return 'package.json dependencies'
|
||||
if (/\.(ts|tsx|js|mjs|cjs|tpl)$/.test(file)) return 'code specifiers'
|
||||
if (/\.(yml|yaml)$/.test(file)) return 'YAML plugin names'
|
||||
if (file.endsWith('.json')) return 'JSON configuration'
|
||||
return 'Markdown fences and docs prose'
|
||||
}
|
||||
|
||||
/**
|
||||
* One exact edit's state in the text it targets. `pending` means the source
|
||||
* form is present and the target form absent; `applied` means the reverse;
|
||||
* anything else — a partial application, a moved site, or a DUPLICATED
|
||||
* insertion — is `invalid`, so it fails the run instead of being applied again.
|
||||
*/
|
||||
export type ExactEditState = 'pending' | 'applied' | 'invalid'
|
||||
|
||||
/**
|
||||
* Classify one exact edit against its target text.
|
||||
*
|
||||
* An insertion keeps its anchor (`replace` contains `find`) and a deletion
|
||||
* keeps its remainder (`find` contains `replace`), so neither can be judged by
|
||||
* the source form alone: the surviving side counts the target form instead.
|
||||
* @param text - the complete current text of the edited file.
|
||||
* @param find - the source form, already oriented for the running direction.
|
||||
* @param replace - the target form, already oriented for the running direction.
|
||||
* @param expect - how many occurrences one complete application produces.
|
||||
* @returns Whether the edit is pending, already applied, or invalid.
|
||||
*/
|
||||
export function exactEditState(text: string, find: string, replace: string, expect: number): ExactEditState {
|
||||
const hits = text.split(find).length - 1
|
||||
const landed = text.split(replace).length - 1
|
||||
if (replace.includes(find)) {
|
||||
if (landed === expect) return 'applied'
|
||||
return landed === 0 && hits === expect ? 'pending' : 'invalid'
|
||||
}
|
||||
if (find.includes(replace)) {
|
||||
if (hits === 0) return landed === expect ? 'applied' : 'invalid'
|
||||
return hits === expect ? 'pending' : 'invalid'
|
||||
}
|
||||
if (hits === 0 && landed === expect) return 'applied'
|
||||
return hits === expect && landed === 0 ? 'pending' : 'invalid'
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2)
|
||||
const mode = args.includes('--apply') ? 'apply' : args.includes('--check') ? 'check' : 'dry'
|
||||
const reverse = args.includes('--reverse')
|
||||
const all = patterns(reverse)
|
||||
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
|
||||
.split('\0')
|
||||
.filter(file => file !== '' && !excluded(file))
|
||||
|
||||
const counts = new Map<string, { files: number; lines: number }>()
|
||||
const failures: string[] = []
|
||||
const outstanding: string[] = []
|
||||
|
||||
// Classify every exact edit before writing anything: a single invalid site
|
||||
// means the mapping and the tree disagree, and a half-applied tree is worse
|
||||
// than an untouched one.
|
||||
const planned: { edit: ExactEdit; path: string; find: string; replace: string }[] = []
|
||||
for (const edit of EXACT_EDITS) {
|
||||
const path = resolve(root, edit.file)
|
||||
const before = readFileSync(path, 'utf8')
|
||||
const find = reverse ? edit.replace : edit.find
|
||||
const replace = reverse ? edit.find : edit.replace
|
||||
const state = exactEditState(before, find, replace, edit.expect)
|
||||
if (state === 'invalid') {
|
||||
failures.push(`exact edit ${edit.id}: ${edit.file} is neither pending nor cleanly applied (duplicated, partial, or moved)`)
|
||||
continue
|
||||
}
|
||||
if (mode === 'check') {
|
||||
if (state !== 'applied') failures.push(`exact edit ${edit.id} did not land in ${edit.file}`)
|
||||
continue
|
||||
}
|
||||
if (state === 'pending') planned.push({ edit, path, find, replace })
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) console.error(`rescope-vendor: ${failure}`)
|
||||
console.error(`rescope-vendor: ${String(failures.length)} problem(s); nothing was written.`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
if (mode === 'apply') {
|
||||
// Re-read per edit: two edits can target one file, and a stale snapshot
|
||||
// would let the second write discard the first.
|
||||
for (const { path, find, replace } of planned) {
|
||||
writeFileSync(path, readFileSync(path, 'utf8').split(find).join(replace))
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const path = resolve(root, file)
|
||||
const before = readFileSync(path, 'utf8')
|
||||
const { text: after, lines } = rewrite(before, file, all)
|
||||
if (after === before) continue
|
||||
outstanding.push(file)
|
||||
const kind = classify(file)
|
||||
const current = counts.get(kind) ?? { files: 0, lines: 0 }
|
||||
counts.set(kind, { files: current.files + 1, lines: current.lines + lines })
|
||||
if (mode === 'apply') writeFileSync(path, after)
|
||||
}
|
||||
|
||||
console.log(`rescope-vendor: ${mode}${reverse ? ' --reverse' : ''} over ${String(files.length)} tracked files`)
|
||||
for (const kind of [...counts.keys()].sort()) {
|
||||
const { files: count, lines } = counts.get(kind) ?? { files: 0, lines: 0 }
|
||||
console.log(` ${kind.padEnd(24)} ${String(count).padStart(4)} file(s), ${String(lines)} line(s)`)
|
||||
}
|
||||
|
||||
if (mode !== 'dry') {
|
||||
for (const check of POSTCONDITIONS) {
|
||||
if (reverse) break
|
||||
const path = resolve(root, check.file)
|
||||
const hits = existsSync(path) ? readFileSync(path, 'utf8').split(check.text).length - 1 : -1
|
||||
if (hits !== check.count) {
|
||||
failures.push(`postcondition: ${check.file} has ${String(hits)} occurrence(s) of ${JSON.stringify(check.text)}, expected ${String(check.count)}`)
|
||||
}
|
||||
}
|
||||
// The generic pass above already told us which files would still change,
|
||||
// which in check mode is exactly the residue-and-idempotency signal.
|
||||
if (mode === 'check') {
|
||||
for (const file of outstanding) failures.push(`residue: ${file} still carries a pre-rescope name token`)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) console.error(`rescope-vendor: ${failure}`)
|
||||
console.error(`rescope-vendor: ${String(failures.length)} problem(s); the mapping or an upstream site moved.`)
|
||||
process.exitCode = 1
|
||||
} else if (mode === 'check') {
|
||||
console.log('rescope-vendor: post-state verified — no residue, every exact edit landed, idempotent.')
|
||||
} else if (mode === 'apply') {
|
||||
console.log('rescope-vendor: applied. Run `pnpm install`, `pnpm run gen-third-party-notices`, and re-record the touched bilingual pairs.')
|
||||
}
|
||||
}
|
||||
|
||||
// Importing this module for its exported classifier must not run the codemod.
|
||||
if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
||||
main()
|
||||
}
|
||||
@@ -227,7 +227,7 @@ describe('Node 24 lane ownership', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
|
||||
|
||||
expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
|
||||
workers: 11,
|
||||
workers: 10,
|
||||
source: 'ci-consumers gate count',
|
||||
})
|
||||
expect(subject.map(item => item.id)).toEqual([
|
||||
@@ -241,7 +241,6 @@ describe('Node 24 lane ownership', () => {
|
||||
'doc-typecheck',
|
||||
'node-next-types',
|
||||
'built-bin-smoke',
|
||||
'github-repository-plugin-e2e',
|
||||
])
|
||||
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
|
||||
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
|
||||
@@ -252,7 +251,6 @@ describe('Node 24 lane ownership', () => {
|
||||
'doc-typecheck',
|
||||
'node-next-types',
|
||||
'built-bin-smoke',
|
||||
'github-repository-plugin-e2e',
|
||||
]) {
|
||||
expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
|
||||
}
|
||||
@@ -266,16 +264,6 @@ describe('Node 24 lane ownership', () => {
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
]),
|
||||
)
|
||||
const githubRepositoryPlugin = subject.find(item => item.id === 'github-repository-plugin-e2e')
|
||||
expect(githubRepositoryPlugin).toMatchObject({
|
||||
label: 'GitHub repository Plugin dsh run',
|
||||
env: {
|
||||
DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1',
|
||||
},
|
||||
})
|
||||
expect(githubRepositoryPlugin?.args).toEqual(
|
||||
expect.arrayContaining(['apps/cli/tests/github-repository-plugin.built.e2e.ts']),
|
||||
)
|
||||
expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
|
||||
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
|
||||
env: { DSH_SNAPSHOT: 'replay' },
|
||||
|
||||
+2
-16
@@ -406,7 +406,6 @@ function ciConsumerGates(): Gate[] {
|
||||
needs: validatedBuild,
|
||||
}),
|
||||
builtBinSmokeGate(validatedBuild),
|
||||
githubRepositoryPluginE2eGate(validatedBuild),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -518,7 +517,7 @@ function coverageGates(): Gate[] {
|
||||
}
|
||||
|
||||
// Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node,
|
||||
// plugins via real exports); repository-script snapshots execute their real source entry path.
|
||||
// plugins via real exports); script snapshots execute their real source entry path.
|
||||
// Callers wait either on `build` or on a validation gate that transitively owns that build.
|
||||
function snapshotGate(needs: string[] = ['build']): Gate {
|
||||
return pnpmScript('snapshot', 'test:snapshot', {
|
||||
@@ -554,6 +553,7 @@ function flagEnabled(envName: string): boolean {
|
||||
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
|
||||
return [
|
||||
pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
@@ -639,20 +639,6 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
})
|
||||
}
|
||||
|
||||
function githubRepositoryPluginE2eGate(needs: string[]): Gate {
|
||||
return pnpmExec('github-repository-plugin-e2e', [
|
||||
'vitest',
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'apps/cli/tests/github-repository-plugin.built.e2e.ts',
|
||||
], {
|
||||
label: 'GitHub repository Plugin dsh run',
|
||||
needs,
|
||||
env: { DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a gate list whose graph cannot be executed unambiguously.
|
||||
* @param gates - complete aggregate to validate.
|
||||
|
||||
+79
-100
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepseek_harness import TurnResult
|
||||
from deepseek_harness import RunResult
|
||||
|
||||
|
||||
EXPECTED_TEXT = "runtime smoke ok"
|
||||
@@ -25,10 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value."
|
||||
CODE_WORKER_TEXT = "code worker smoke ok"
|
||||
WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents."
|
||||
WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
|
||||
PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor."
|
||||
PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok"
|
||||
PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: "
|
||||
PERSISTENT_BASH_COMMAND = (
|
||||
MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor."
|
||||
MINIMAL_TEXT = "minimal agent smoke ok"
|
||||
MINIMAL_EDITOR_PATH_PREFIX = "Editor path: "
|
||||
MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant."
|
||||
MINIMAL_CORDIS = (
|
||||
Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml"
|
||||
)
|
||||
MINIMAL_BASH_COMMAND = (
|
||||
"counter=$(( ${counter:-0} + 1 )); export counter; "
|
||||
"printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; "
|
||||
"if [ \"$counter\" -eq 1 ]; then cd /tmp; fi"
|
||||
@@ -103,51 +107,6 @@ CUSTOM_CORDIS = """\
|
||||
- id: cordis-tool
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
"""
|
||||
PERSISTENT_TOOLS_CORDIS = """\
|
||||
- id: jsonrpc
|
||||
name: '@deepseek-ai/dsh-jsonrpc'
|
||||
- id: llm
|
||||
name: '@deepseek-ai/dsh-llm-deepseek'
|
||||
config:
|
||||
apiKey: !!js process.env.DEEPSEEK_API_KEY
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL
|
||||
- id: sandbox
|
||||
name: '@deepseek-ai/dsh-sandbox-local'
|
||||
- id: sandbox-policy
|
||||
name: '@deepseek-ai/dsh-sandbox-policy'
|
||||
config:
|
||||
mode: danger-full-access
|
||||
workspaceRoot: !!js process.env.DSH_CWD
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
- id: pty-local
|
||||
name: '@deepseek-ai/dsh-pty-local'
|
||||
- id: fs
|
||||
name: '@deepseek-ai/dsh-fs-local'
|
||||
config:
|
||||
cwd: !!js process.env.DSH_CWD
|
||||
- id: agent-core
|
||||
name: '@deepseek-ai/dsh-agent-spine-demo'
|
||||
config:
|
||||
includeHarnessIdentity: false
|
||||
persona: 'You are a helpful software engineer assistant.'
|
||||
workspaceContext: false
|
||||
skills:
|
||||
enabled: false
|
||||
toolBash: false
|
||||
toolTasks: false
|
||||
- id: sessions
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js process.env.DSH_SESSION_ROOT
|
||||
compression: 'none'
|
||||
- id: persistent-bash
|
||||
name: '@deepseek-ai/dsh-tool-bash-persistent'
|
||||
- id: str-replace-editor
|
||||
name: '@deepseek-ai/dsh-tool-str-replace-editor'
|
||||
"""
|
||||
|
||||
|
||||
class MockModelHandler(BaseHTTPRequestHandler):
|
||||
"""Return deterministic text, worker, and orchestration completions."""
|
||||
|
||||
@@ -182,9 +141,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
if latest.get("role") == "tool":
|
||||
call_id, tool_name = latest_tool_call(messages)
|
||||
tool_text = message_text(latest.get("content"))
|
||||
persistent = persistent_tool_followup(body, call_id, tool_name, tool_text)
|
||||
if persistent is not None:
|
||||
return persistent
|
||||
minimal = minimal_tool_followup(body, call_id, tool_name, tool_text)
|
||||
if minimal is not None:
|
||||
return minimal
|
||||
advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
|
||||
if advanced is not None:
|
||||
return advanced
|
||||
@@ -196,16 +155,35 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
return text_chunks(WORKFLOW_WORKER_TEXT)
|
||||
raise AssertionError(f"unexpected tool follow-up: {tool_name}")
|
||||
|
||||
prompt = message_text(latest.get("content"))
|
||||
if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"):
|
||||
minimal_prompt = next(
|
||||
(
|
||||
message_text(message.get("content"))
|
||||
for message in reversed(messages)
|
||||
if isinstance(message, dict)
|
||||
and message.get("role") == "user"
|
||||
and message_text(message.get("content")).startswith(
|
||||
f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}"
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if minimal_prompt is not None:
|
||||
names = advertised_tool_names(body)
|
||||
if names != {"bash", "str_replace_editor"}:
|
||||
raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}")
|
||||
raise AssertionError(f"minimal agent smoke advertised unexpected tools: {names}")
|
||||
system_prompts = [
|
||||
message_text(message.get("content"))
|
||||
for message in messages
|
||||
if isinstance(message, dict) and message.get("role") == "system"
|
||||
]
|
||||
if system_prompts != [MINIMAL_SYSTEM_PROMPT]:
|
||||
raise AssertionError(f"minimal agent smoke assembled unexpected system prompts: {system_prompts}")
|
||||
return tool_call_chunks(
|
||||
"persistent-bash-1",
|
||||
"minimal-bash-1",
|
||||
"bash",
|
||||
{"command": PERSISTENT_BASH_COMMAND},
|
||||
{"command": MINIMAL_BASH_COMMAND},
|
||||
)
|
||||
prompt = message_text(latest.get("content"))
|
||||
if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
|
||||
return text_chunks("DIRECT_CHILD_OK")
|
||||
if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
|
||||
@@ -240,24 +218,24 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
return text_chunks(EXPECTED_TEXT)
|
||||
|
||||
|
||||
def persistent_tool_followup(
|
||||
def minimal_tool_followup(
|
||||
body: dict[str, object],
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool_text: str,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""Verify packaged PTY persistence, then invoke the packaged editor."""
|
||||
if not call_id.startswith("persistent-"):
|
||||
"""Verify the checked-in minimal composition's PTY and editor."""
|
||||
if not call_id.startswith("minimal-"):
|
||||
return None
|
||||
if call_id == "persistent-bash-1" and tool_name == "bash":
|
||||
if call_id == "minimal-bash-1" and tool_name == "bash":
|
||||
if "COUNT=1" not in tool_text:
|
||||
raise AssertionError(f"first persistent bash call lost its output: {tool_text}")
|
||||
return tool_call_chunks(
|
||||
"persistent-bash-2",
|
||||
"minimal-bash-2",
|
||||
"bash",
|
||||
{"command": PERSISTENT_BASH_COMMAND},
|
||||
{"command": MINIMAL_BASH_COMMAND},
|
||||
)
|
||||
if call_id == "persistent-bash-2" and tool_name == "bash":
|
||||
if call_id == "minimal-bash-2" and tool_name == "bash":
|
||||
if "COUNT=2 CWD=/tmp" not in tool_text:
|
||||
raise AssertionError(f"persistent bash did not retain state: {tool_text}")
|
||||
messages = body.get("messages")
|
||||
@@ -265,18 +243,18 @@ def persistent_tool_followup(
|
||||
raise AssertionError("persistent editor smoke request has no messages")
|
||||
editor_path = next(
|
||||
(
|
||||
text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip()
|
||||
text.split(MINIMAL_EDITOR_PATH_PREFIX, 1)[1].strip()
|
||||
for message in messages
|
||||
if isinstance(message, dict) and message.get("role") == "user"
|
||||
for text in [message_text(message.get("content"))]
|
||||
if PERSISTENT_EDITOR_PATH_PREFIX in text
|
||||
if MINIMAL_EDITOR_PATH_PREFIX in text
|
||||
),
|
||||
None,
|
||||
)
|
||||
if editor_path is None:
|
||||
raise AssertionError("persistent editor smoke prompt has no editor path")
|
||||
return tool_call_chunks(
|
||||
"persistent-editor",
|
||||
"minimal-editor",
|
||||
"str_replace_editor",
|
||||
{
|
||||
"command": "create",
|
||||
@@ -284,11 +262,11 @@ def persistent_tool_followup(
|
||||
"file_text": "created by packaged editor\n",
|
||||
},
|
||||
)
|
||||
if call_id == "persistent-editor" and tool_name == "str_replace_editor":
|
||||
if call_id == "minimal-editor" and tool_name == "str_replace_editor":
|
||||
if "New file created successfully" not in tool_text:
|
||||
raise AssertionError(f"packaged editor did not create its file: {tool_text}")
|
||||
return text_chunks(PERSISTENT_TOOLS_TEXT)
|
||||
raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}")
|
||||
return text_chunks(MINIMAL_TEXT)
|
||||
raise AssertionError(f"unexpected minimal-agent follow-up: {call_id} {tool_name}: {tool_text}")
|
||||
|
||||
|
||||
def advanced_tool_followup(
|
||||
@@ -470,14 +448,14 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"),
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"),
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
parser.add_argument("--update-snapshots", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios")
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-snapshot or all")
|
||||
if args.exe is not None and not args.exe.is_file():
|
||||
@@ -489,9 +467,9 @@ def main() -> None:
|
||||
if args.scenario in {"all", "sdk-custom"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_custom(model.url, args.exe.resolve())
|
||||
if args.scenario in {"all", "sdk-persistent"}:
|
||||
if args.scenario in {"all", "sdk-minimal"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_persistent_tools(model.url, args.exe.resolve())
|
||||
smoke_sdk_minimal(model.url, args.exe.resolve())
|
||||
if args.scenario in {"all", "sdk-snapshot"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
@@ -519,7 +497,6 @@ def smoke_sdk_default(base_url: str) -> None:
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
result = harness.run("reply with the smoke text", session_id="default-smoke")
|
||||
assert result.status == "ok", result
|
||||
assert result.final_response == EXPECTED_TEXT, result.final_response
|
||||
assert_zstd_session_log(sessions)
|
||||
|
||||
@@ -546,46 +523,40 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None:
|
||||
text_result = harness.run("reply with the smoke text", session_id="custom-smoke")
|
||||
code_result = harness.run(CODE_PROMPT, session_id="custom-smoke")
|
||||
workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke")
|
||||
assert text_result.status == "ok", text_result
|
||||
assert text_result.final_response == EXPECTED_TEXT, text_result.final_response
|
||||
assert code_result.status == "ok", code_result
|
||||
assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response
|
||||
assert workflow_result.status == "ok", workflow_result
|
||||
assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
|
||||
|
||||
|
||||
def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None:
|
||||
"""Exercise native PTY state and the editor through the packaged executable."""
|
||||
def smoke_sdk_minimal(base_url: str, executable: Path) -> None:
|
||||
"""Exercise the checked-in minimal composition through the packaged executable."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
editor_path = root / "created.txt"
|
||||
prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}"
|
||||
prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}"
|
||||
sessions = root / "sessions"
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(PERSISTENT_TOOLS_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek",
|
||||
provider="deepseek-official",
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
cordis=str(cordis),
|
||||
cordis=str(MINIMAL_CORDIS),
|
||||
runtime_bin=str(executable),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
result = harness.run(prompt, session_id="persistent-tools-smoke")
|
||||
result = harness.run(prompt, session_id="minimal-agent-smoke")
|
||||
|
||||
assert result.status == "ok", result
|
||||
event_text = json.dumps(result.events)
|
||||
if PERSISTENT_TOOLS_TEXT not in event_text:
|
||||
raise AssertionError(f"packaged tools run emitted no final response: {result.events}")
|
||||
if MINIMAL_TEXT not in event_text:
|
||||
raise AssertionError(f"minimal agent run emitted no final response: {result.events}")
|
||||
if editor_path.read_text() != "created by packaged editor\n":
|
||||
raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}")
|
||||
assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
|
||||
assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp")
|
||||
|
||||
|
||||
def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
@@ -610,7 +581,6 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
|
||||
) as harness:
|
||||
result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID)
|
||||
|
||||
assert result.status == "ok", result
|
||||
assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response
|
||||
methods = [notification.method for notification in result.notifications]
|
||||
if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2:
|
||||
@@ -657,8 +627,8 @@ def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
"params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]},
|
||||
})
|
||||
messages = peer.read_until(lambda message: message.get("id") == "prompt")
|
||||
if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages):
|
||||
messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished"))
|
||||
if not any(is_idle_notification(message) for message in messages):
|
||||
messages.extend(peer.read_until(is_idle_notification))
|
||||
event_text = json.dumps(messages)
|
||||
if EXPECTED_TEXT not in event_text:
|
||||
raise AssertionError(f"direct runtime emitted no final response: {messages}")
|
||||
@@ -669,6 +639,16 @@ def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT)
|
||||
|
||||
|
||||
def is_idle_notification(message: dict[str, object]) -> bool:
|
||||
"""Return whether a JSON-RPC notification marks a session idle."""
|
||||
params = message.get("params")
|
||||
return (
|
||||
message.get("method") == "session.status"
|
||||
and isinstance(params, dict)
|
||||
and params.get("status") == "idle"
|
||||
)
|
||||
|
||||
|
||||
class RuntimePeer:
|
||||
def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None:
|
||||
self.process = subprocess.Popen(
|
||||
@@ -776,7 +756,7 @@ def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]:
|
||||
return logs
|
||||
|
||||
|
||||
def snapshot_child_ids(result: "TurnResult") -> list[str]:
|
||||
def snapshot_child_ids(result: "RunResult") -> list[str]:
|
||||
"""Return the two child session ids in their SDK notification order."""
|
||||
child_ids: list[str] = []
|
||||
for notification in result.notifications:
|
||||
@@ -794,7 +774,7 @@ def snapshot_child_ids(result: "TurnResult") -> list[str]:
|
||||
|
||||
|
||||
def build_snapshot_files(
|
||||
result: "TurnResult",
|
||||
result: "RunResult",
|
||||
logs: dict[str, list[dict[str, object]]],
|
||||
child_ids: list[str],
|
||||
cwd: Path,
|
||||
@@ -809,7 +789,6 @@ def build_snapshot_files(
|
||||
|
||||
result_value = {
|
||||
"session_id": result.session_id,
|
||||
"status": result.status,
|
||||
"final_response": result.final_response,
|
||||
"events": result.events,
|
||||
"notifications": [
|
||||
@@ -834,7 +813,7 @@ def build_snapshot_files(
|
||||
return files
|
||||
|
||||
|
||||
def snapshot_agent_id(result: "TurnResult", child_id: str) -> str:
|
||||
def snapshot_agent_id(result: "RunResult", child_id: str) -> str:
|
||||
"""Find the successful subagent id paired with one child session."""
|
||||
for notification in result.notifications:
|
||||
if notification.method != "subagent.finished":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,18 @@
|
||||
{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":0,"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","seq":1,"time":0,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}}
|
||||
{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -1,14 +1,18 @@
|
||||
{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":0,"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","seq":1,"time":0,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn"}}
|
||||
{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -1,68 +1,71 @@
|
||||
{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0}
|
||||
{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}}
|
||||
{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":0,"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","seq":1,"time":0,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}
|
||||
{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"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\": \"Run the temporary Plugin tool\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}}
|
||||
{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}}
|
||||
{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"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":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"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","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
|
||||
{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"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":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"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":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"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","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"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/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"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}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
|
||||
{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":5,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}}
|
||||
{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}
|
||||
{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}
|
||||
{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"<anonymous>\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","seq":18,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}
|
||||
{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"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\": \"Run the temporary Plugin tool\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}
|
||||
{"type":"tool/code-dispatch-start","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}}
|
||||
{"type":"tool/code-dispatch","seq":27,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}}
|
||||
{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}
|
||||
{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"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":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":36,"time":0,"data":{"turn":1,"step":3,"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"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":37,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
|
||||
{"type":"tool/result","seq":38,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[37],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":39,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":40,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"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":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"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":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":46,"time":0,"data":{"turn":1,"step":4,"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"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":47,"time":0,"data":{"turn":1,"step":4,"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/result","seq":48,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"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}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[47],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":49,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","seq":50,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}}
|
||||
{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":57,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[57],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":59,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":60,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"request/header","seq":61,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}}
|
||||
{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
|
||||
{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":67,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[62,63,64,65,66],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":68,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"turn/end","seq":69,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -8,11 +8,11 @@
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n"
|
||||
"content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/<name>`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow <a href=\"https://x.com/Deepseekharness\">DeepSeek Harness on Twitter</a> for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n"
|
||||
"content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone <repo-url>\ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/<name>` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add <package> # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 <a href=\"https://wj.qq.com/s2/27234598/03eb/\">DeepSeek Harness 微信社区申请页面</a> 申请加入。\n\n<p>\n <img src=\"assets/community-wecom-survey.png\" alt=\"DeepSeek Harness 微信社区二维码\" width=\"240\">\n</p>\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, FiberState, Service, ValidationError } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import z from 'schemastery'
|
||||
import { Context, FiberState, Service, ValidationError } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { packageInvariantOwners } from './package-invariants.ts'
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
usesManualInvariantTree,
|
||||
} from './test-invariants.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
testInvariantProbe: TestInvariantProbe
|
||||
}
|
||||
@@ -39,18 +39,6 @@ function requiredConfig() {
|
||||
})
|
||||
}
|
||||
|
||||
function queuedReadinessConfig(
|
||||
ctx: Context,
|
||||
onPublished: (dispose: () => void) => void,
|
||||
) {
|
||||
return z.transform(z.any(), () => {
|
||||
queueMicrotask(() => {
|
||||
onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true))
|
||||
})
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
function invalidConfigApply(): never {
|
||||
throw new Error('invalid plugin apply executed')
|
||||
}
|
||||
@@ -189,84 +177,55 @@ describe('global test invariant host', () => {
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => {
|
||||
it('disposes invalid config after delayed invariant readiness', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const apply = vi.fn(invalidConfigApply)
|
||||
let disposeQueuedReadiness: (() => void) | undefined
|
||||
const plugin = {
|
||||
apply,
|
||||
Config: z.intersect([
|
||||
queuedReadinessConfig(ctx, (dispose) => {
|
||||
disposeQueuedReadiness = dispose
|
||||
}),
|
||||
requiredConfig(),
|
||||
]),
|
||||
Config: requiredConfig(),
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin(plugin, {})
|
||||
const firstError = await rejectionOf(fiber)
|
||||
expectRequiredConfigValidation(firstError)
|
||||
expect(fiber.state).toBe(FiberState.DISPOSED)
|
||||
const returnedError = rejectionOf(fiber)
|
||||
await started
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
|
||||
await started
|
||||
if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published')
|
||||
disposeQueuedReadiness()
|
||||
release()
|
||||
await ctx.plugin(TestInvariantProbe)
|
||||
|
||||
const secondError = await rejectionOf(fiber)
|
||||
expect(secondError).toBe(firstError)
|
||||
expectRequiredConfigValidation(await returnedError)
|
||||
expect(fiber.state).toBe(FiberState.DISPOSED)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('retains a valid plugin failure when readiness wins the initial-probe race', async () => {
|
||||
it('retains a valid plugin failure after delayed invariant readiness', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const failure = new Error('valid plugin apply failed')
|
||||
const applied = deferred()
|
||||
const apply = vi.fn(function validConfigApply() {
|
||||
applied.resolve()
|
||||
throw failure
|
||||
})
|
||||
let disposeQueuedReadiness: (() => void) | undefined
|
||||
const plugin = {
|
||||
apply,
|
||||
Config: queuedReadinessConfig(ctx, (dispose) => {
|
||||
disposeQueuedReadiness = dispose
|
||||
}),
|
||||
Config: z.object({}),
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin(plugin, {})
|
||||
const returnedError = rejectionOf(fiber)
|
||||
try {
|
||||
await Promise.all([started, applied.promise])
|
||||
expect(fiber.state).toBe(FiberState.FAILED)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(ctx.registry.has(plugin)).toBe(true)
|
||||
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
|
||||
await started
|
||||
expect(fiber.state).toBe(FiberState.PENDING)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
|
||||
if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published')
|
||||
Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE)
|
||||
disposeQueuedReadiness()
|
||||
release()
|
||||
|
||||
expect(await returnedError).toBe(failure)
|
||||
expect(fiber.state).toBe(FiberState.FAILED)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(ctx.registry.has(plugin)).toBe(true)
|
||||
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
|
||||
} finally {
|
||||
Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE)
|
||||
disposeQueuedReadiness?.()
|
||||
release()
|
||||
}
|
||||
release()
|
||||
expect(await returnedError).toBe(failure)
|
||||
expect(fiber.state).toBe(FiberState.FAILED)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(ctx.registry.has(plugin)).toBe(true)
|
||||
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
+15
-12
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest'
|
||||
import { FiberState, Inject, RegistryService } from 'cordis'
|
||||
import type { Context, Plugin } from 'cordis'
|
||||
import { FiberState, Inject, RegistryService, ValidationError } from '@deepseek-ai/cordis'
|
||||
import type { Context, Plugin } from '@deepseek-ai/cordis'
|
||||
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type {
|
||||
ImageAttachmentLimits,
|
||||
@@ -248,22 +248,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi
|
||||
function joinInvariantStartup(
|
||||
fiber: PluginFiber,
|
||||
invariantReady: Promise<void>,
|
||||
disposeInitialFailure = false,
|
||||
disposePendingValidationFailure = false,
|
||||
): PluginFiber {
|
||||
// RegistryService returns a thenable wrapper whose context still points to
|
||||
// the raw Fiber. Calling inherited await() on the wrapper would return and
|
||||
// assimilate that thenable, accidentally following later plugin startup.
|
||||
const rawFiber = fiber.ctx.fiber
|
||||
const initialized = disposeInitialFailure
|
||||
? rawFiber.await().catch(async (error: unknown) => {
|
||||
// Config validation is the only failure recorded while a gated fiber
|
||||
// is initially PENDING. Dispose it even if queued readiness publication
|
||||
// changes its state before this rejection handler runs.
|
||||
await rawFiber.dispose()
|
||||
const readiness = invariantReady.then(async () => {
|
||||
try {
|
||||
return await rawFiber.await()
|
||||
} catch (error) {
|
||||
// Config resolves only after the readiness injection activates. Dispose
|
||||
// validation failures owned by an initially pending target; ordinary
|
||||
// callback failures remain inspectable.
|
||||
if (disposePendingValidationFailure && error instanceof ValidationError) {
|
||||
await rawFiber.dispose()
|
||||
}
|
||||
throw error
|
||||
})
|
||||
: Promise.resolve()
|
||||
const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await())
|
||||
}
|
||||
})
|
||||
const joined = Object.create(fiber) as PluginFiber
|
||||
joined.then = readiness.then.bind(readiness)
|
||||
return joined
|
||||
|
||||
@@ -165,7 +165,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
}
|
||||
recordPlugin(value, file)
|
||||
validateMetadata(value, file, path)
|
||||
if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) {
|
||||
if ((value.group === true || value.name === '@deepseek-ai/cordis-plugin-group') && isUnknownArray(value.config)) {
|
||||
for (let index = 0; index < value.config.length; index++) {
|
||||
validateEntry(value.config[index], file, `${path}.config[${index}]`)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ function validateEntry(value: unknown, file: string, path: string): void {
|
||||
validateEntry(value.insert[index], file, `${path}.insert[${index}]`)
|
||||
}
|
||||
}
|
||||
if (value.name !== '@cordisjs/plugin-include') return
|
||||
if (value.name !== '@deepseek-ai/cordis-plugin-include') return
|
||||
const config = value.config
|
||||
if (!isRecord(config) || !isUnknownArray(config.patches)) return
|
||||
for (let index = 0; index < config.patches.length; index++) {
|
||||
|
||||
@@ -133,7 +133,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' },
|
||||
'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' },
|
||||
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
|
||||
'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' },
|
||||
'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
|
||||
'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
|
||||
'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' },
|
||||
@@ -147,6 +146,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' },
|
||||
'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' },
|
||||
'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' },
|
||||
'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' },
|
||||
'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' },
|
||||
'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' },
|
||||
'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' },
|
||||
|
||||
Reference in New Issue
Block a user