Merge remote-tracking branch 'origin/master' into codex/agent-session-jsonl-location
# Conflicts: # docs/config-catalog.md # packages/hooks/hooks-codex/src/index.ts # packages/ui/acp-agent/src/index.ts # packages/ui/stdio-agent/src/index.ts
This commit is contained in:
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Build the single-file SDK runtime executables
|
||||
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
*
|
||||
* Every settled decision is hardcoded — the PoC judged @yao-pkg/pkg's
|
||||
* standard mode unusable for this architecture (its ESM→CJS transform breaks
|
||||
* every runtime `import()`), so the pipeline is fixed on `--sea` mode, plain
|
||||
* ESM entry, plain-source assets, and a hoisted (symlink-free) staged tree.
|
||||
*
|
||||
* Pipeline — every step fails loud with the command it ran:
|
||||
*
|
||||
* 1. `pnpm run build` — all packages emit `lib/` (skippable via --skip-build).
|
||||
* 2. `pnpm --filter dsh-jsonrpc-agent-pkg deploy` — materialize the
|
||||
* closure-manifest package (python/sdk-runtime/package.json — the single
|
||||
* source of truth for the exe's plugin set) into the staging dir
|
||||
* (cleared first; pnpm refuses a non-empty deploy target). Flags, all
|
||||
* verified against pnpm 11.7: `--legacy` because the workspace does not
|
||||
* set `inject-workspace-packages=true`; `node-linker=hoisted` for a plain
|
||||
* file tree with zero symlinks (the safe shape for pkg's VFS, and it
|
||||
* physically guarantees a single cordis copy); `auto-install-peers=false`
|
||||
* so transitive `^0.0.x` peers on unpublished packages never hit the
|
||||
* registry; `link-workspace-packages=true` so the closure resolves to
|
||||
* workspace/vendor sources.
|
||||
* 3. Inject the pkg config into the staged package.json: `bin` = the ESM
|
||||
* `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` (SEA mode
|
||||
* hands it to Node's default ESM loader — no CJS shim), plus whole-tree
|
||||
* asset globs. The cordis Loader resolves plugins
|
||||
* through runtime dynamic `import()` of bare package names, so pkg's
|
||||
* static analysis discovers none of them — the entire staged tree must be
|
||||
* globbed in explicitly.
|
||||
* 4. `pnpm dlx @yao-pkg/pkg@<pinned> <staging> --sea --targets <t> --output
|
||||
* <out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>` — once per target (SEA mode
|
||||
* packs a single target per invocation), so each product gets its
|
||||
* canonical name directly.
|
||||
* 5. Sync into the Python runtime package
|
||||
* (python/sdk-runtime/src/deepseek_harness_runtime/runtime/,
|
||||
* created if missing): each product under its canonical filename (exe
|
||||
* mode), plus the whole staged closure into runtime/node/ (node mode —
|
||||
* `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`
|
||||
* runs it directly; the injected pkg
|
||||
* fields are harmless to node). dist-exe/ keeps the originals for CI
|
||||
* artifact upload.
|
||||
*
|
||||
* `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` → host-platform exe into dist-exe/
|
||||
* `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64`
|
||||
* `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --dry-run` → print the plan without executing
|
||||
*/
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, statSync } from 'node:fs'
|
||||
import { copyFile, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { basename, join, resolve, sep } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/**
|
||||
* The deploy root: the closure-manifest package (python/sdk-runtime) whose
|
||||
* dependencies define the exe's contents; the runnable entry inside the
|
||||
* closure is {@link ENTRY_BIN}.
|
||||
*/
|
||||
const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg'
|
||||
/** The bin entry inside the deployed closure (the dsh-jsonrpc-agent app bin). */
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js'
|
||||
/** Basename of every product; the canonical name appends `-<platform>-<arch>`. */
|
||||
const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg'
|
||||
/** Default exe Node major; SEA mode requires >= node22, the repo tracks node24. */
|
||||
const DEFAULT_NODE_RANGE = 'node24'
|
||||
/** Pinned pkg version (the one the PoC and acceptance ran on) for reproducible builds. */
|
||||
const PKG_SPEC = '@yao-pkg/pkg@6.21.0'
|
||||
/** Staging dir for the deployed closure — cleared on every run (gitignored). */
|
||||
// (No external staging dir: the deploy target IS the Python runtime's
|
||||
// node-mode carrier — see PYTHON_RUNTIME_DIR/PYTHON_NODE_SUBDIR.)
|
||||
/** Product output dir (gitignored). */
|
||||
const OUT_DIR = 'dist-exe'
|
||||
/**
|
||||
* Python runtime package dir the products are synced into. A parallel change
|
||||
* owns the directory and its .gitignore; this script's only contract is the
|
||||
* destination path, so a missing dir is created, never an error.
|
||||
*/
|
||||
const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime'
|
||||
/** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */
|
||||
const PYTHON_NODE_SUBDIR = 'node'
|
||||
/** Deploy-root documentation is not runtime input and violates the generated-directory i18n exclusion if retained. */
|
||||
const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml']
|
||||
|
||||
/**
|
||||
* Whole-tree asset globs. The cordis Loader dynamic-imports bare package names
|
||||
* at runtime, invisible to pkg's static analysis, so every runtime file in the
|
||||
* closure is listed; SEA mode ships them as plain source in the VFS. Every
|
||||
* package.json must ride along — bare-name resolution dies without them (the
|
||||
* json glob would already match, but the manifests are resolution-critical, so
|
||||
* they get their own explicit entry).
|
||||
*/
|
||||
const ASSET_GLOBS = [
|
||||
'package.json',
|
||||
'node_modules/**/*.js',
|
||||
'node_modules/**/*.cjs',
|
||||
'node_modules/**/*.mjs',
|
||||
'node_modules/**/package.json',
|
||||
'node_modules/**/*.json',
|
||||
'node_modules/**/*.node',
|
||||
'node_modules/**/*.wasm',
|
||||
]
|
||||
|
||||
const PLATFORMS = ['linux', 'macos'] as const
|
||||
const ARCHES = ['x64', 'arm64'] as const
|
||||
type Platform = (typeof PLATFORMS)[number]
|
||||
type Arch = (typeof ARCHES)[number]
|
||||
|
||||
/** True when `value` is a supported pkg platform tag. */
|
||||
function isPlatform(value: string): value is Platform {
|
||||
return (PLATFORMS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** True when `value` is a supported pkg CPU tag. */
|
||||
function isArch(value: string): value is Arch {
|
||||
return (ARCHES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* One pkg target triple, e.g. `node24-linux-x64`, as an immutable value.
|
||||
* Construction goes through {@link Target.parse} (a `--targets` entry) or
|
||||
* {@link Target.host} (the default), which own all validation.
|
||||
*/
|
||||
class Target {
|
||||
private constructor(
|
||||
/** pkg Node range (`node<major>`); pins the official base binary pkg pulls. */
|
||||
readonly nodeRange: string,
|
||||
/**
|
||||
* pkg platform tag. Windows is a documented non-goal
|
||||
* (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md).
|
||||
*/
|
||||
readonly platform: Platform,
|
||||
/** pkg CPU tag. */
|
||||
readonly arch: Arch,
|
||||
) {}
|
||||
|
||||
/** The pkg `--targets` spec string `<nodeRange>-<platform>-<arch>`. */
|
||||
get spec(): string {
|
||||
return `${this.nodeRange}-${this.platform}-${this.arch}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate one target spec; throws on any malformed component.
|
||||
* @param spec - the raw triple, e.g. `node24-linux-x64`.
|
||||
* @returns the parsed target.
|
||||
*/
|
||||
static parse(spec: string): Target {
|
||||
const parts = spec.split('-')
|
||||
const [nodeRange, platform, arch] = parts
|
||||
if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be <nodeRange>-<platform>-<arch>, e.g. node24-linux-x64.`)
|
||||
}
|
||||
if (!/^node\d+$/.test(nodeRange)) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`)
|
||||
}
|
||||
if (!isPlatform(platform)) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')} (Windows is a docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md non-goal), got ${JSON.stringify(platform)}.`)
|
||||
}
|
||||
if (!isArch(arch)) {
|
||||
throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`)
|
||||
}
|
||||
return new Target(nodeRange, platform, arch)
|
||||
}
|
||||
|
||||
/**
|
||||
* The default target when --targets is omitted: the host platform on node24.
|
||||
* @returns the host target; throws on an unsupported host platform or arch.
|
||||
*/
|
||||
static host(): Target {
|
||||
const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined
|
||||
if (platform === undefined) {
|
||||
throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`)
|
||||
}
|
||||
const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined
|
||||
if (arch === undefined) {
|
||||
throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`)
|
||||
}
|
||||
return new Target(DEFAULT_NODE_RANGE, platform, arch)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed CLI configuration. {@link BuildCli.parse} is the only constructor
|
||||
* path — it owns flag parsing, target validation, and the --help / bad-flag
|
||||
* process exits, so an instance always holds a valid plan.
|
||||
*/
|
||||
class BuildCli {
|
||||
private constructor(
|
||||
/** Build targets; defaults to the host platform only. */
|
||||
readonly targets: readonly Target[],
|
||||
/** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */
|
||||
readonly skipBuild: boolean,
|
||||
/** Print every command and config patch instead of executing. */
|
||||
readonly dryRun: boolean,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Parse argv into a validated configuration. Exits the process for --help
|
||||
* (code 0, usage) and for unknown/malformed flags (code 1, usage on
|
||||
* stderr); throws on invalid or colliding targets.
|
||||
* @param argv - the raw arguments (`process.argv.slice(2)`).
|
||||
* @returns the parsed, validated configuration.
|
||||
*/
|
||||
static parse(argv: string[]): BuildCli {
|
||||
let values: ReturnType<typeof BuildCli.parseRaw>
|
||||
try {
|
||||
values = BuildCli.parseRaw(argv)
|
||||
} catch (error) {
|
||||
console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||
console.error(BuildCli.usage())
|
||||
process.exit(1)
|
||||
}
|
||||
if (values.help) {
|
||||
console.log(BuildCli.usage())
|
||||
process.exit(0)
|
||||
}
|
||||
const targets = values.targets === undefined
|
||||
? [Target.host()]
|
||||
: values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec))
|
||||
if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.')
|
||||
const seen = new Set<string>()
|
||||
for (const target of targets) {
|
||||
const key = `${target.platform}-${target.arch}`
|
||||
if (seen.has(key)) {
|
||||
throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`)
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
return new BuildCli(targets, values['skip-build'], values['dry-run'])
|
||||
}
|
||||
|
||||
/** The flag grammar in one place; parseArgs throws on any unknown flag. */
|
||||
private static parseRaw(argv: string[]) {
|
||||
return parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
'targets': { type: 'string' },
|
||||
'skip-build': { type: 'boolean', default: false },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
'help': { type: 'boolean', default: false },
|
||||
},
|
||||
}).values
|
||||
}
|
||||
|
||||
/** The --help text; also printed under flag-parse errors. */
|
||||
private static usage(): string {
|
||||
return [
|
||||
'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]',
|
||||
'',
|
||||
' --targets=<t1,t2,...> pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.',
|
||||
' Default: the host platform only (on node24).',
|
||||
' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
|
||||
' --dry-run print every command and config patch without executing.',
|
||||
' --help print this help.',
|
||||
'',
|
||||
'Settled decisions are hardcoded (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md): pkg runs in --sea mode',
|
||||
`(standard mode breaks runtime import()), pinned to ${PKG_SPEC}; the deploy tree is`,
|
||||
`hoisted/symlink-free; the closure deploys straight into ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and products land in ${OUT_DIR}/.`,
|
||||
].join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
/** The pnpm executable name for the host OS. */
|
||||
function pnpmBin(): string {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a command line for logs and error messages, quoting arguments that
|
||||
* contain spaces.
|
||||
* @param command - the executable.
|
||||
* @param args - its arguments.
|
||||
* @returns the printable command line.
|
||||
*/
|
||||
function formatCommand(command: string, args: string[]): string {
|
||||
return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The four-step build pipeline over one parsed CLI. Steps are sequential
|
||||
* async methods; every subprocess inherits stdio and fails loud with the
|
||||
* exact command it ran. In --dry-run the command/filesystem layer prints
|
||||
* what it would do instead of executing.
|
||||
*/
|
||||
class SingleExeBuild {
|
||||
/**
|
||||
* Absolute staging dir — the Python runtime's node-mode carrier: step 2
|
||||
* deploys the closure DIRECTLY here (cleared first; it is a pure build
|
||||
* product, the checked-in default `cordis.yml` lives one level up), step 4
|
||||
* reads it as the pkg input, and node mode runs it in place.
|
||||
*/
|
||||
readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR)
|
||||
/** Absolute product output dir. */
|
||||
private readonly outDir = resolve(root, OUT_DIR)
|
||||
|
||||
constructor(private readonly cli: BuildCli) {}
|
||||
|
||||
/** Gate the manifest before spending time compiling or packaging it. */
|
||||
async verifyClosure(): Promise<void> {
|
||||
await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure'])
|
||||
}
|
||||
|
||||
/** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */
|
||||
async build(): Promise<void> {
|
||||
if (this.cli.skipBuild) {
|
||||
console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)')
|
||||
return
|
||||
}
|
||||
await this.run('build', pnpmBin(), ['run', 'build'])
|
||||
}
|
||||
|
||||
/** Step 2: clear the staging dir and deploy the bridge closure into it. */
|
||||
async deployStaging(): Promise<void> {
|
||||
if (this.staging === root || root.startsWith(this.staging + sep)) {
|
||||
throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`)
|
||||
}
|
||||
if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`)
|
||||
else await rm(this.staging, { recursive: true, force: true })
|
||||
await this.run('deploy', pnpmBin(), [
|
||||
'--filter',
|
||||
DEPLOY_ROOT_PACKAGE,
|
||||
'deploy',
|
||||
'--legacy',
|
||||
'--prod',
|
||||
'--config.node-linker=hoisted',
|
||||
'--config.auto-install-peers=false',
|
||||
'--config.link-workspace-packages=true',
|
||||
this.staging,
|
||||
])
|
||||
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 {
|
||||
await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true })))
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */
|
||||
async injectPkgConfig(): Promise<void> {
|
||||
const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } }
|
||||
const manifestPath = join(this.staging, 'package.json')
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`)
|
||||
return
|
||||
}
|
||||
if (!existsSync(manifestPath)) {
|
||||
throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`)
|
||||
}
|
||||
if (!existsSync(join(this.staging, ENTRY_BIN))) {
|
||||
throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`)
|
||||
}
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
|
||||
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
|
||||
console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4: run @yao-pkg/pkg over the staged tree for ONE target (SEA mode
|
||||
* packs a single target per invocation) and return the product path.
|
||||
* @param target - the pkg target triple to build.
|
||||
* @returns the canonical product path `<out>/dsh-jsonrpc-agent-pkg-<platform>-<arch>`.
|
||||
*/
|
||||
async pack(target: Target): Promise<string> {
|
||||
const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`)
|
||||
if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true })
|
||||
await this.run(`pkg ${target.spec}`, pnpmBin(), [
|
||||
'dlx',
|
||||
PKG_SPEC,
|
||||
this.staging,
|
||||
'--sea',
|
||||
'--targets',
|
||||
target.spec,
|
||||
'--output',
|
||||
product,
|
||||
])
|
||||
if (!this.cli.dryRun && !existsSync(product)) {
|
||||
throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`)
|
||||
}
|
||||
return product
|
||||
}
|
||||
|
||||
/**
|
||||
* Print each product path (and size, when it exists on disk).
|
||||
* @param products - the product paths returned by {@link pack}.
|
||||
*/
|
||||
printProducts(products: string[]): void {
|
||||
console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:')
|
||||
for (const product of products) {
|
||||
if (this.cli.dryRun) {
|
||||
console.log(` ${product}`)
|
||||
continue
|
||||
}
|
||||
const megabytes = statSync(product).size / (1024 * 1024)
|
||||
console.log(` ${product} (${megabytes.toFixed(1)} MB)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 5: copy every product into the Python runtime package under its
|
||||
* canonical filename (exe mode). The node-mode carrier needs no sync — step
|
||||
* 2 deployed the closure into it directly. dist-exe/ keeps the originals
|
||||
* for CI artifact upload; the destination dir is created if missing.
|
||||
* @param products - the product paths returned by {@link pack}.
|
||||
*/
|
||||
async syncToPythonRuntime(products: string[]): Promise<void> {
|
||||
const destDir = resolve(root, PYTHON_RUNTIME_DIR)
|
||||
if (this.cli.dryRun) {
|
||||
for (const product of products) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
mkdirSync(destDir, { recursive: true })
|
||||
for (const product of products) {
|
||||
const destination = join(destDir, basename(product))
|
||||
await copyFile(product, destination)
|
||||
console.log(`build-exe-for-python-sdk: synced ${destination}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one pipeline step as a subprocess with inherited stdio; reject —
|
||||
* carrying the printable command — on spawn failure and non-zero exit
|
||||
* alike. In --dry-run, print the command instead of executing.
|
||||
* @param label - the step name used in logs and error messages.
|
||||
* @param command - the executable.
|
||||
* @param args - its arguments.
|
||||
*/
|
||||
private async run(label: string, command: string, args: string[]): Promise<void> {
|
||||
const printable = formatCommand(command, args)
|
||||
if (this.cli.dryRun) {
|
||||
console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`)
|
||||
return
|
||||
}
|
||||
console.log(`build-exe-for-python-sdk: ${label}: ${printable}`)
|
||||
await new Promise<void>((resolvePromise, reject) => {
|
||||
const child = spawn(command, args, { cwd: root, stdio: 'inherit' })
|
||||
child.once('error', (error) => {
|
||||
reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`))
|
||||
})
|
||||
child.once('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolvePromise()
|
||||
return
|
||||
}
|
||||
const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}`
|
||||
reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Entry point: parse the CLI, then await each pipeline step in order. */
|
||||
async function main(): Promise<void> {
|
||||
const cli = BuildCli.parse(process.argv.slice(2))
|
||||
const pipeline = new SingleExeBuild(cli)
|
||||
console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`)
|
||||
console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`)
|
||||
await pipeline.verifyClosure()
|
||||
await pipeline.build()
|
||||
await pipeline.deployStaging()
|
||||
await pipeline.injectPkgConfig()
|
||||
const products: string[] = []
|
||||
for (const target of cli.targets) products.push(await pipeline.pack(target))
|
||||
pipeline.printProducts(products)
|
||||
await pipeline.syncToPythonRuntime(products)
|
||||
}
|
||||
|
||||
await main()
|
||||
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage and build one Python wheel at the repository version."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import email
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
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"),
|
||||
"macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"),
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--package", choices=("sdk", "runtime"), required=True)
|
||||
parser.add_argument(
|
||||
"--tag",
|
||||
help="optional python-vX.Y.Z release tag; it must match package.json",
|
||||
)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--platform", choices=tuple(PLATFORMS))
|
||||
parser.add_argument("--runtime-exe", type=Path)
|
||||
args = parser.parse_args()
|
||||
version = repository_version()
|
||||
validate_release_tag(args.tag, 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):
|
||||
parser.error("SDK builds do not accept --platform or --runtime-exe")
|
||||
|
||||
output_dir = args.output_dir.resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary:
|
||||
staging = Path(temporary) / args.package
|
||||
if args.package == "sdk":
|
||||
stage_sdk(staging, version)
|
||||
environment = None
|
||||
expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl"
|
||||
else:
|
||||
platform_tag, executable_name = PLATFORMS[args.platform]
|
||||
stage_runtime(staging, 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"
|
||||
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])
|
||||
print(expected)
|
||||
|
||||
|
||||
def repository_version(root: Path = ROOT) -> str:
|
||||
package_json = root / "package.json"
|
||||
try:
|
||||
payload = json.loads(package_json.read_text())
|
||||
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:
|
||||
raise ValueError(
|
||||
f"{package_json} version must be stable X.Y.Z, got {version!r}"
|
||||
)
|
||||
return version
|
||||
|
||||
|
||||
def validate_release_tag(tag: str | None, version: str) -> None:
|
||||
if tag is None:
|
||||
return
|
||||
expected = f"python-v{version}"
|
||||
if tag != expected:
|
||||
raise ValueError(
|
||||
f"release tag must match repository version: expected {expected!r}, got {tag!r}"
|
||||
)
|
||||
|
||||
|
||||
def copy_package(source: Path, destination: Path) -> None:
|
||||
shutil.copytree(
|
||||
source,
|
||||
destination,
|
||||
ignore=shutil.ignore_patterns(
|
||||
".venv",
|
||||
".pytest_cache",
|
||||
"__pycache__",
|
||||
"*.pyc",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"dsh-jsonrpc-agent-pkg-*",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def rewrite_version(pyproject: Path, version: str) -> None:
|
||||
text, count = re.subn(
|
||||
r'^version = "[^"]+"$',
|
||||
f'version = "{version}"',
|
||||
pyproject.read_text(),
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError(f"could not rewrite version in {pyproject}")
|
||||
pyproject.write_text(text)
|
||||
|
||||
|
||||
def stage_sdk(destination: Path, version: str) -> None:
|
||||
copy_package(ROOT / "python" / "sdk", destination)
|
||||
pyproject = destination / "pyproject.toml"
|
||||
rewrite_version(pyproject, version)
|
||||
text, count = re.subn(
|
||||
r'"deepseek-harness-runtime-bin==[^"]+"',
|
||||
f'"deepseek-harness-runtime-bin=={version}"',
|
||||
pyproject.read_text(),
|
||||
count=1,
|
||||
)
|
||||
if count != 1:
|
||||
raise RuntimeError("SDK must contain exactly one runtime dependency pin")
|
||||
pyproject.write_text(text)
|
||||
|
||||
|
||||
def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None:
|
||||
if not executable.is_file():
|
||||
raise FileNotFoundError(f"runtime executable does not exist: {executable}")
|
||||
if executable.stat().st_mode & stat.S_IXUSR == 0:
|
||||
raise PermissionError(f"runtime executable is not executable: {executable}")
|
||||
copy_package(ROOT / "python" / "sdk-runtime", destination)
|
||||
rewrite_version(destination / "pyproject.toml", version)
|
||||
runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination_executable = runtime_dir / executable_name
|
||||
shutil.copyfile(executable, destination_executable)
|
||||
destination_executable.chmod(executable.stat().st_mode & 0o777)
|
||||
|
||||
|
||||
def verify_wheel(
|
||||
wheel: Path,
|
||||
package: str,
|
||||
version: str,
|
||||
platform: tuple[str, str] | None,
|
||||
) -> None:
|
||||
expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}"
|
||||
with zipfile.ZipFile(wheel) as archive:
|
||||
wheel_metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL"))
|
||||
metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA"))
|
||||
wheel_metadata = email.message_from_bytes(archive.read(wheel_metadata_path))
|
||||
metadata = email.message_from_bytes(archive.read(metadata_path))
|
||||
if wheel_metadata.get_all("Tag") != [expected_tag]:
|
||||
raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}")
|
||||
if metadata.get("Version") != version:
|
||||
raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}")
|
||||
executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name]
|
||||
if package == "runtime":
|
||||
assert platform is not None
|
||||
if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"):
|
||||
raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}")
|
||||
mode = archive.getinfo(executables[0]).external_attr >> 16
|
||||
if mode & stat.S_IXUSR == 0:
|
||||
raise RuntimeError(f"{wheel} runtime executable lost its executable bit")
|
||||
elif executables:
|
||||
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}")
|
||||
if package == "sdk":
|
||||
requirements = metadata.get_all("Requires-Dist") or []
|
||||
expected_requirement = f"deepseek-harness-runtime-bin=={version}"
|
||||
if expected_requirement not in requirements:
|
||||
raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -61,6 +61,9 @@ function readJson(path: string): PackageManifest {
|
||||
return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
|
||||
}
|
||||
|
||||
const rootManifest = readJson(join(root, 'package.json'))
|
||||
const repositoryVersion = rootManifest.version
|
||||
|
||||
/** Repo-relative dirs holding a package.json, walked to the configured depth. */
|
||||
function packageDirs(base: string, depth: number): string[] {
|
||||
if (depth === 1) {
|
||||
@@ -78,7 +81,7 @@ function packageDirs(base: string, depth: number): string[] {
|
||||
|
||||
function workspaceManifests(): WorkspaceManifest[] {
|
||||
const manifests: WorkspaceManifest[] = [
|
||||
{ dir: '.', manifest: readJson(join(root, 'package.json')) },
|
||||
{ dir: '.', manifest: rootManifest },
|
||||
]
|
||||
|
||||
for (const { dir: base, depth } of workspaceGlobs) {
|
||||
@@ -107,7 +110,7 @@ const dshBinPackageFiles = [
|
||||
|
||||
const dshWorkerPackageFiles = [
|
||||
'lib/index.js',
|
||||
'lib/worker.js',
|
||||
'lib/worker.cjs',
|
||||
'lib/types/**/*.d.ts',
|
||||
'lib/types/**/*.d.ts.map',
|
||||
'src',
|
||||
@@ -147,8 +150,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] {
|
||||
if (peer && dev && peer !== dev) {
|
||||
errors.push(`${label}: cordis peer (${peer}) and dev (${dev}) ranges must match`)
|
||||
}
|
||||
if (manifest.version !== '0.0.1') {
|
||||
errors.push(`${label}: package.json must set "version": "0.0.1"`)
|
||||
if (manifest.version !== repositoryVersion) {
|
||||
errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`)
|
||||
}
|
||||
if (manifest.type !== 'module') {
|
||||
errors.push(`${label}: package.json must set "type": "module"`)
|
||||
@@ -205,7 +208,16 @@ function checkHierarchyShape(): string[] {
|
||||
return errors
|
||||
}
|
||||
|
||||
const errors = [...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape()]
|
||||
function checkRepositoryVersion(): string[] {
|
||||
if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return []
|
||||
return ['package.json: version must be stable X.Y.Z']
|
||||
}
|
||||
|
||||
const errors = [
|
||||
...checkRepositoryVersion(),
|
||||
...workspaceManifests().flatMap(checkWorkspace),
|
||||
...checkHierarchyShape(),
|
||||
]
|
||||
if (errors.length > 0) {
|
||||
console.error(errors.join('\n'))
|
||||
process.exitCode = 1
|
||||
|
||||
@@ -22,22 +22,15 @@ import { existsSync, globSync, mkdirSync, readFileSync, writeFileSync } from 'no
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { collectEvents, collectServices } from './gen-cordis-catalog.ts'
|
||||
import {
|
||||
collectPackageGraph,
|
||||
escapeMermaidLabel as escLabel,
|
||||
graphNodeId as nodeId,
|
||||
type PackageGraphNode,
|
||||
} from './package-graph.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const SCOPE = '@deepseek-ai/dsh-'
|
||||
|
||||
interface PkgJson {
|
||||
name: string
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
interface Pkg {
|
||||
short: string
|
||||
name: string
|
||||
group: string
|
||||
rel: string
|
||||
deps: string[]
|
||||
}
|
||||
type Pkg = PackageGraphNode
|
||||
|
||||
interface GraphDoc {
|
||||
rel: string
|
||||
@@ -319,62 +312,6 @@ function linkFromDoc(docRel: string, targetRel: string): string {
|
||||
return relative(dirname(docRel), targetRel).replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
function collectPackages(): Pkg[] {
|
||||
const pkgs: Pkg[] = []
|
||||
for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as PkgJson
|
||||
if (!json.name.startsWith(SCOPE)) continue
|
||||
const [, group, leaf] = rel.split('/')
|
||||
if (group === undefined || leaf === undefined) throw new Error(`gen-doc-graphs: unexpected package path ${rel}`)
|
||||
const deps = Object.keys(json.peerDependencies ?? {})
|
||||
.filter(dep => dep.startsWith(SCOPE))
|
||||
.map(dep => dep.slice(SCOPE.length))
|
||||
.sort()
|
||||
pkgs.push({
|
||||
short: json.name.slice(SCOPE.length),
|
||||
name: json.name,
|
||||
group,
|
||||
rel: dirname(rel),
|
||||
deps,
|
||||
})
|
||||
}
|
||||
return topoSort(pkgs)
|
||||
}
|
||||
|
||||
function topoSort(pkgs: Pkg[]): Pkg[] {
|
||||
const remaining = new Map(pkgs.map(p => [p.short, p]))
|
||||
const placed = new Set<string>()
|
||||
const out: Pkg[] = []
|
||||
while (remaining.size > 0) {
|
||||
const ready = [...remaining.values()]
|
||||
.filter(pkg => pkg.deps.every(dep => placed.has(dep)))
|
||||
.sort(comparePackages)
|
||||
if (ready.length === 0) throw new Error(`gen-doc-graphs: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
||||
for (const pkg of ready) {
|
||||
out.push(pkg)
|
||||
placed.add(pkg.short)
|
||||
remaining.delete(pkg.short)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function comparePackages(a: Pkg, b: Pkg): number {
|
||||
const groupA = GROUP_ORDER.indexOf(a.group)
|
||||
const groupB = GROUP_ORDER.indexOf(b.group)
|
||||
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
||||
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
||||
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
||||
}
|
||||
|
||||
function nodeId(prefix: string, value: string): string {
|
||||
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
}
|
||||
|
||||
function escLabel(value: string): string {
|
||||
return value.replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function mermaidCode(value: string): string {
|
||||
return `<code>${value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`
|
||||
}
|
||||
@@ -851,7 +788,7 @@ function renderSnapshotReplay(): string {
|
||||
}
|
||||
|
||||
function renderDocs(): GraphDoc[] {
|
||||
const pkgs = collectPackages()
|
||||
const pkgs = collectPackageGraph(root, GROUP_ORDER, 'gen-doc-graphs')
|
||||
const docs: GraphDoc[] = [
|
||||
{ rel: 'docs/capability-seams.md', content: renderCapabilitySeams(pkgs) },
|
||||
...APP_EXAMPLES.map(example => ({ rel: example.rel, content: renderAppComposition(example) })),
|
||||
|
||||
+10
-76
@@ -18,23 +18,18 @@
|
||||
* is stale (CI / pre-push gate)
|
||||
*/
|
||||
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
collectPackageGraph,
|
||||
escapeMermaidLabel as escLabel,
|
||||
graphNodeId as nodeId,
|
||||
type PackageGraphNode,
|
||||
} from './package-graph.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/module-graph.md'
|
||||
const SCOPE = '@deepseek-ai/dsh-'
|
||||
|
||||
interface Pkg {
|
||||
/** Short name, `@deepseek-ai/dsh-` prefix stripped (e.g. `agent-loop`). */
|
||||
short: string
|
||||
/** Package group from `packages/<group>/<pkg>`. */
|
||||
group: string
|
||||
/** Repo-relative package directory. */
|
||||
rel: string
|
||||
/** Short names of this package's in-repo peer dependencies, sorted. */
|
||||
deps: string[]
|
||||
}
|
||||
type Pkg = PackageGraphNode
|
||||
|
||||
const GROUP_ORDER = [
|
||||
'util',
|
||||
@@ -55,67 +50,6 @@ const GROUP_ORDER = [
|
||||
'ui',
|
||||
]
|
||||
|
||||
/** Read every workspace package and its `@deepseek-ai/dsh-*` peer edges. */
|
||||
function collect(): Pkg[] {
|
||||
const pkgs: Pkg[] = []
|
||||
for (const rel of globSync('packages/*/*/package.json', { cwd: root })) {
|
||||
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
|
||||
name: string
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
if (!json.name.startsWith(SCOPE)) continue
|
||||
const deps = Object.keys(json.peerDependencies ?? {})
|
||||
.filter(d => d.startsWith(SCOPE))
|
||||
.map(d => d.slice(SCOPE.length))
|
||||
.sort()
|
||||
const [, group, leaf] = rel.split('/')
|
||||
if (group === undefined || leaf === undefined) throw new Error(`gen-module-graph: unexpected package path ${rel}`)
|
||||
pkgs.push({ short: json.name.slice(SCOPE.length), group, rel: dirname(rel), deps })
|
||||
}
|
||||
return topoSort(pkgs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Order packages low-level → high-level: a package appears only after every
|
||||
* package it depends on. Kahn-style layering with an alphabetical tiebreak
|
||||
* within each layer, so the output stays deterministic (the freshness check
|
||||
* compares whole-file). The graph is a DAG, so this always terminates; a cycle
|
||||
* would leave nodes unplaced and throw.
|
||||
*/
|
||||
function topoSort(pkgs: Pkg[]): Pkg[] {
|
||||
const remaining = new Map(pkgs.map(p => [p.short, p]))
|
||||
const placed = new Set<string>()
|
||||
const out: Pkg[] = []
|
||||
while (remaining.size > 0) {
|
||||
const ready = [...remaining.values()]
|
||||
.filter(p => p.deps.every(d => placed.has(d)))
|
||||
.sort(comparePackages)
|
||||
if (ready.length === 0) throw new Error(`gen-module-graph: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
||||
for (const p of ready) {
|
||||
out.push(p)
|
||||
placed.add(p.short)
|
||||
remaining.delete(p.short)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function comparePackages(a: Pkg, b: Pkg): number {
|
||||
const groupA = GROUP_ORDER.indexOf(a.group)
|
||||
const groupB = GROUP_ORDER.indexOf(b.group)
|
||||
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
||||
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
||||
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
||||
}
|
||||
|
||||
function nodeId(prefix: string, value: string): string {
|
||||
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
}
|
||||
|
||||
function escLabel(value: string): string {
|
||||
return value.replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function packageLink(pkg: Pkg): string {
|
||||
return `[\`${pkg.short}\`](../${pkg.rel})`
|
||||
}
|
||||
@@ -170,7 +104,7 @@ function render(pkgs: Pkg[]): string {
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const content = render(collect())
|
||||
const content = render(collectPackageGraph(root, GROUP_ORDER, 'gen-module-graph'))
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'docs/persistence-catalog.md'
|
||||
@@ -95,12 +96,6 @@ export interface AnnotatedLogEventEntry extends LogEventEntry {
|
||||
surface: boolean
|
||||
}
|
||||
|
||||
/** Repo-relative source pointer `file:line` for a node's first character. */
|
||||
function pointer(rel: string, sf: ts.SourceFile, node: ts.Node): string {
|
||||
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
|
||||
return `${rel}:${line + 1}`
|
||||
}
|
||||
|
||||
const printer = ts.createPrinter({ removeComments: true })
|
||||
|
||||
/**
|
||||
@@ -118,87 +113,6 @@ function payloadText(type: ts.TypeNode, sf: ts.SourceFile): string {
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** The raw `/** … */` JSDoc block immediately preceding a node, or '' if none. */
|
||||
function rawJsDoc(text: string, node: ts.Node): string {
|
||||
const ranges = ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []
|
||||
const jsdoc = ranges.filter(r => text.slice(r.pos, r.pos + 3) === '/**').at(-1)
|
||||
return jsdoc ? text.slice(jsdoc.pos, jsdoc.end) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw JSDoc block into description prose, flagging whether any `@mode`
|
||||
* tag is present (forbidden on log events). Output obeys the repo's markdown
|
||||
* conventions so the generated file passes verify-md-wrap: each prose paragraph
|
||||
* collapses to ONE physical line, and a `-` bullet list is preserved with each
|
||||
* item on its own single line (continuation lines folded in). `{@link Foo}`
|
||||
* unwraps to `Foo`. Description prose ends at the FIRST block tag (standard
|
||||
* JSDoc semantics): tag lines and their continuation lines are never prose.
|
||||
*/
|
||||
function parseJsDoc(raw: string): { doc: string; hasMode: boolean } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
let hasMode = false
|
||||
let inTags = false
|
||||
const blocks: string[] = []
|
||||
let para: string[] = []
|
||||
let list: string[] = []
|
||||
let item: string[] = []
|
||||
const join = (parts: string[]): string => parts.join(' ').replace(/\s+/g, ' ').trim()
|
||||
const flushItem = (): void => {
|
||||
if (item.length) list.push(join(item))
|
||||
item = []
|
||||
}
|
||||
const flushList = (): void => {
|
||||
flushItem()
|
||||
if (list.length) blocks.push(list.join('\n')) // one block, items on own lines
|
||||
list = []
|
||||
}
|
||||
const flushPara = (): void => {
|
||||
flushList()
|
||||
if (para.length) blocks.push(join(para))
|
||||
para = []
|
||||
}
|
||||
for (const line of inner) {
|
||||
// Tag detection runs on the trimmed line: the normalization above strips at
|
||||
// most one post-`*` space, so an extra-indented `* @mode` still reaches
|
||||
// here with leading whitespace and must not leak into prose.
|
||||
const tagLine = line.trimStart()
|
||||
if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
|
||||
if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
|
||||
if (inTags) continue // block-tag territory: continuations are never prose
|
||||
if (line.trim() === '') { flushPara(); continue }
|
||||
if (/^-\s+/.test(line)) {
|
||||
// A list item starts: a pending paragraph (e.g. an intro line directly
|
||||
// above the list, no blank between) flushes FIRST so it renders above.
|
||||
flushItem()
|
||||
if (para.length) { blocks.push(join(para)); para = [] }
|
||||
item.push(line)
|
||||
continue
|
||||
}
|
||||
if (item.length) { item.push(line); continue } // continuation of current item
|
||||
para.push(line)
|
||||
}
|
||||
flushPara()
|
||||
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
|
||||
return { doc, hasMode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw one aggregate error for every completeness violation a walk collected.
|
||||
* Aggregation is deliberate: a remediation pass sees the whole list at once
|
||||
* instead of replaying the gate once per offender.
|
||||
*/
|
||||
function reportViolations(violations: string[]): void {
|
||||
if (violations.length === 0) return
|
||||
throw new Error(
|
||||
`gen-persistence-catalog: ${violations.length} JSDoc completeness violation(s):\n`
|
||||
+ violations.map(v => ` ${v}`).join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `interface SessionEventMap` declaration in a source file: the owning
|
||||
* top-level declaration (in `@deepseek-ai/dsh-session`) and any declaration
|
||||
@@ -323,7 +237,7 @@ export function collectLogEvents(scanRoot: string = root): LogEventEntry[] {
|
||||
}
|
||||
}
|
||||
}
|
||||
reportViolations(violations)
|
||||
reportViolations('gen-persistence-catalog', violations)
|
||||
return entries
|
||||
}
|
||||
|
||||
|
||||
+18
-15
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* Shared JSDoc parsing and completeness-check helpers for the documentation
|
||||
* gates: the cordis catalog generator (`scripts/gen-cordis-catalog.ts` — the
|
||||
* events + `ctx.<key>` service surface), the plugin config catalog generator
|
||||
* (`scripts/gen-config-catalog.ts`, which renders the parsed prose), and the
|
||||
* export-surface gate (`scripts/verify-export-jsdoc.ts` — every module-level
|
||||
* export). One home for the mechanics so "documented" means the same thing on
|
||||
* every gated surface: description prose ends at the first block tag; every
|
||||
* checkable parameter needs a non-empty `@param`; a non-void ANNOTATED return
|
||||
* needs a non-empty `@returns`; a stale `@param` naming no real parameter
|
||||
* errors.
|
||||
* gates: the cordis and persistence catalog generators
|
||||
* (`scripts/gen-cordis-catalog.ts` / `scripts/gen-persistence-catalog.ts`),
|
||||
* the plugin config catalog generator (`scripts/gen-config-catalog.ts`), and
|
||||
* the export-surface gate (`scripts/verify-export-jsdoc.ts`). One home for the
|
||||
* mechanics so "documented" means the same thing on every gated surface:
|
||||
* description prose ends at the first block tag; every checkable parameter
|
||||
* needs a non-empty `@param`; a non-void ANNOTATED return needs a non-empty
|
||||
* `@returns`; a stale `@param` naming no real parameter errors.
|
||||
*/
|
||||
|
||||
import ts from 'typescript'
|
||||
@@ -39,15 +38,17 @@ export type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
|
||||
* tag lines and their continuation lines are never prose, so `@param` /
|
||||
* `@returns` blocks are invisible to the rendered catalog.
|
||||
* @param raw - the raw comment text including the JSDoc delimiters.
|
||||
* @returns the collapsed description prose plus the parsed `@mode` (or null).
|
||||
* @returns the collapsed description prose, parsed valid `@mode` (or null),
|
||||
* and whether any `@mode` tag was present.
|
||||
*/
|
||||
export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
export function parseJsDoc(raw: string): { doc: string; mode: Mode | null; hasMode: boolean } {
|
||||
const inner = raw
|
||||
.replace(/^\/\*\*/, '')
|
||||
.replace(/\*\/$/, '')
|
||||
.split('\n')
|
||||
.map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, ''))
|
||||
let mode: Mode | null = null
|
||||
let hasMode = false
|
||||
let inTags = false
|
||||
const blocks: string[] = []
|
||||
let para: string[] = []
|
||||
@@ -69,9 +70,11 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
para = []
|
||||
}
|
||||
for (const line of inner) {
|
||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line)
|
||||
if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue }
|
||||
if (line.startsWith('@')) { flushPara(); inTags = true; continue }
|
||||
const tagLine = line.trimStart()
|
||||
const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(tagLine)
|
||||
if (m) { mode = m[1] as Mode; hasMode = true; flushPara(); inTags = true; continue }
|
||||
if (/^@mode\b/.test(tagLine)) { hasMode = true; flushPara(); inTags = true; continue }
|
||||
if (tagLine.startsWith('@')) { flushPara(); inTags = true; continue }
|
||||
if (inTags) continue // block-tag territory: continuations are never prose
|
||||
if (line.trim() === '') { flushPara(); continue }
|
||||
if (/^-\s+/.test(line)) {
|
||||
@@ -87,7 +90,7 @@ export function parseJsDoc(raw: string): { doc: string; mode: Mode | null } {
|
||||
}
|
||||
flushPara()
|
||||
const doc = blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim()
|
||||
return { doc, mode }
|
||||
return { doc, mode, hasMode }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Shared Markdown parsing and depth-first traversal for documentation gates. */
|
||||
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
|
||||
/** Parse GitHub-flavored Markdown with the repository's standard extensions. */
|
||||
export function parseMarkdown(source: string): Nodes {
|
||||
return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Visit a Markdown tree depth-first; returning false prunes a node's children.
|
||||
* @param node - current tree node.
|
||||
* @param visitor - callback invoked before each node's children.
|
||||
*/
|
||||
export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | void): void {
|
||||
if (visitor(node) === false) return
|
||||
if ('children' in node) {
|
||||
for (const child of node.children) visitMarkdown(child, visitor)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Shared workspace-package graph discovery and Mermaid identifier helpers for
|
||||
* the generated module graph and relationship-diagram generators. Each caller
|
||||
* supplies its own group ordering because the documents use different visual
|
||||
* priorities; manifest parsing and dependency-safe ordering have one owner.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
|
||||
const SCOPE = '@deepseek-ai/dsh-'
|
||||
|
||||
/** One harness package and its in-repo peer-dependency edges. */
|
||||
export interface PackageGraphNode {
|
||||
/** Package name with the `@deepseek-ai/dsh-` prefix removed. */
|
||||
short: string
|
||||
/** Full npm package name. */
|
||||
name: string
|
||||
/** Package group from `packages/<group>/<pkg>`. */
|
||||
group: string
|
||||
/** Repo-relative package directory. */
|
||||
rel: string
|
||||
/** Short names of in-repo peer dependencies, sorted. */
|
||||
deps: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every harness package manifest and return dependency-safe graph nodes.
|
||||
* @param root - absolute repository root.
|
||||
* @param groupOrder - caller-specific tiebreak order for packages in the same dependency layer.
|
||||
* @param gate - command name used in structural error messages.
|
||||
* @returns package nodes ordered after all of their in-repo dependencies.
|
||||
*/
|
||||
export function collectPackageGraph(root: string, groupOrder: readonly string[], gate: string): PackageGraphNode[] {
|
||||
const packages: PackageGraphNode[] = []
|
||||
for (const rel of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
const json = JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as {
|
||||
name: string
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
if (!json.name.startsWith(SCOPE)) continue
|
||||
const [, group, leaf] = rel.split('/')
|
||||
if (group === undefined || leaf === undefined) throw new Error(`${gate}: unexpected package path ${rel}`)
|
||||
const deps = Object.keys(json.peerDependencies ?? {})
|
||||
.filter(dep => dep.startsWith(SCOPE))
|
||||
.map(dep => dep.slice(SCOPE.length))
|
||||
.sort()
|
||||
packages.push({
|
||||
short: json.name.slice(SCOPE.length),
|
||||
name: json.name,
|
||||
group,
|
||||
rel: dirname(rel),
|
||||
deps,
|
||||
})
|
||||
}
|
||||
return topoSort(packages, groupOrder, gate)
|
||||
}
|
||||
|
||||
function topoSort(packages: PackageGraphNode[], groupOrder: readonly string[], gate: string): PackageGraphNode[] {
|
||||
const remaining = new Map(packages.map(pkg => [pkg.short, pkg]))
|
||||
const placed = new Set<string>()
|
||||
const out: PackageGraphNode[] = []
|
||||
while (remaining.size > 0) {
|
||||
const ready = [...remaining.values()]
|
||||
.filter(pkg => pkg.deps.every(dep => placed.has(dep)))
|
||||
.sort((a, b) => comparePackages(a, b, groupOrder))
|
||||
if (ready.length === 0) throw new Error(`${gate}: dependency cycle among ${[...remaining.keys()].join(', ')}`)
|
||||
for (const pkg of ready) {
|
||||
out.push(pkg)
|
||||
placed.add(pkg.short)
|
||||
remaining.delete(pkg.short)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function comparePackages(a: PackageGraphNode, b: PackageGraphNode, groupOrder: readonly string[]): number {
|
||||
const groupA = groupOrder.indexOf(a.group)
|
||||
const groupB = groupOrder.indexOf(b.group)
|
||||
const normA = groupA === -1 ? Number.MAX_SAFE_INTEGER : groupA
|
||||
const normB = groupB === -1 ? Number.MAX_SAFE_INTEGER : groupB
|
||||
return normA - normB || a.group.localeCompare(b.group) || a.short.localeCompare(b.short)
|
||||
}
|
||||
|
||||
/** Stable Mermaid id for a graph value. */
|
||||
export function graphNodeId(prefix: string, value: string): string {
|
||||
return `${prefix}_${value.replace(/[^a-zA-Z0-9_]/g, '_')}`
|
||||
}
|
||||
|
||||
/** Escape a value embedded in a quoted Mermaid label. */
|
||||
export function escapeMermaidLabel(value: string): string {
|
||||
return value.replace(/"/g, '\\"')
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Shared repository file discovery and line-oriented reference scanning. */
|
||||
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
|
||||
/** One authored path plus its canonical target for symlink deduplication. */
|
||||
export interface RepoFile {
|
||||
/** Absolute path matched by the caller's glob. */
|
||||
abs: string
|
||||
/** Absolute canonical path used only for deduplication. */
|
||||
real: string
|
||||
}
|
||||
|
||||
/** A rejected line-oriented repository reference. */
|
||||
export interface ReferenceViolation {
|
||||
/** Repo-relative file containing the reference. */
|
||||
file: string
|
||||
/** 1-based line containing the reference. */
|
||||
line: number
|
||||
/** Normalized reference text. */
|
||||
ref: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand repository-relative globs and deduplicate symlinked files.
|
||||
* @param root - absolute repository root.
|
||||
* @param patterns - repository-relative glob patterns, processed in order.
|
||||
* @param isExcluded - optional predicate over each matched relative path.
|
||||
* @returns matched files in stable first-seen order.
|
||||
*/
|
||||
export function uniqueRepoFiles(
|
||||
root: string,
|
||||
patterns: readonly string[],
|
||||
isExcluded: (relativePath: string) => boolean = () => false,
|
||||
): RepoFile[] {
|
||||
const seen = new Set<string>()
|
||||
const files: RepoFile[] = []
|
||||
for (const pattern of patterns) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
const abs = resolve(root, match)
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
files.push({ abs, real })
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan regex matches line by line and return the normalized matches rejected by
|
||||
* a caller predicate.
|
||||
* @param root - absolute repository root used for violation paths.
|
||||
* @param absPath - absolute text-file path to scan.
|
||||
* @param pattern - global regex matched independently against each line.
|
||||
* @param normalize - maps raw regex text to the reference the gate evaluates.
|
||||
* @param isViolation - returns true when the normalized reference is invalid.
|
||||
* @returns every rejected reference in source order.
|
||||
*/
|
||||
export function findReferenceViolations(
|
||||
root: string,
|
||||
absPath: string,
|
||||
pattern: RegExp,
|
||||
normalize: (raw: string) => string,
|
||||
isViolation: (ref: string) => boolean,
|
||||
): ReferenceViolation[] {
|
||||
const file = relative(root, absPath)
|
||||
const out: ReferenceViolation[] = []
|
||||
const lines = readFileSync(absPath, 'utf8').split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === undefined) continue
|
||||
for (const match of line.matchAll(pattern)) {
|
||||
const ref = normalize(match[0])
|
||||
if (isViolation(ref)) out.push({ file, line: i + 1, ref })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -129,6 +129,7 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
case 'ci-lint':
|
||||
return [
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
]
|
||||
case 'ci-coverage':
|
||||
return [
|
||||
@@ -151,7 +152,9 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
]
|
||||
case 'pre-push':
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
pnpmScript('build', 'build'),
|
||||
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
||||
@@ -163,9 +166,11 @@ function gatesForMode(selected: Mode): Gate[] {
|
||||
|
||||
function ciPrimaryGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
lintGate(),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
coverageGate(),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
demoSmokeGate({ needs: ['lint'] }),
|
||||
@@ -184,6 +189,7 @@ function ciPrimaryGates(): Gate[] {
|
||||
|
||||
function ciStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
demoSmokeGate(),
|
||||
...docSyncLeafGates(),
|
||||
@@ -327,7 +333,7 @@ function builtBinSmokeGate(): Gate {
|
||||
'packages/ui/stdio-agent/tests/built-bin.e2e.ts',
|
||||
'packages/ui/acp-agent/tests/built-bin.e2e.ts',
|
||||
// The worker-entry packages' built bundles: the only automated proof
|
||||
// that lib/index.js resolves its sibling lib/worker.js under plain node
|
||||
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
||||
// (the e2e lane runs unbuilt, so these files self-skip there).
|
||||
'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts',
|
||||
'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts',
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Keyless full-turn and snapshot smoke for the Python SDK runtime."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import difflib
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from deepseek_harness import TurnResult
|
||||
|
||||
|
||||
EXPECTED_TEXT = "runtime smoke ok"
|
||||
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"
|
||||
SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario."
|
||||
SNAPSHOT_SESSION_ID = "advanced-executable"
|
||||
SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else."
|
||||
SNAPSHOT_WORKFLOW_CHILD_PROMPT = "Reply with exactly WORKFLOW_CHILD_OK and nothing else."
|
||||
SNAPSHOT_FINAL_TEXT = "ADVANCED_EXECUTABLE_OK"
|
||||
SNAPSHOT_MOUNT_CODE = """\
|
||||
return (ctx) => {
|
||||
harness.registerTool(ctx, harness.defineTool({
|
||||
name: 'snapshot_double',
|
||||
description: 'Double a number for executable snapshot verification.',
|
||||
parameters: { value: { type: 'number', required: true } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: String(args.value * 2) }]
|
||||
}
|
||||
}))
|
||||
}
|
||||
"""
|
||||
SNAPSHOT_WORKFLOW_SCRIPT = (
|
||||
"phase('Delegate')\n"
|
||||
f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n"
|
||||
"return { reply }"
|
||||
)
|
||||
SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced"
|
||||
)
|
||||
SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl")
|
||||
CUSTOM_CORDIS = """\
|
||||
- id: jsonrpc
|
||||
name: '@deepseek-ai/dsh-jsonrpc'
|
||||
- id: agent-core
|
||||
name: '@deepseek-ai/dsh-agent-core'
|
||||
config:
|
||||
tools:
|
||||
mode: both
|
||||
- id: sessions
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js process.env.DSH_SESSION_ROOT
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
cwd: !!js process.env.DSH_CWD
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||
- id: subagents
|
||||
name: '@deepseek-ai/dsh-subagent'
|
||||
- id: subagent-spawn
|
||||
name: '@deepseek-ai/dsh-subagent-spawn'
|
||||
config:
|
||||
providerName: spawn
|
||||
- id: subagent-tool
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: spawn
|
||||
- id: workflow-engine
|
||||
name: '@deepseek-ai/dsh-workflow-workerthread'
|
||||
config:
|
||||
provider: spawn
|
||||
- id: workflow-tool
|
||||
name: '@deepseek-ai/dsh-tool-workflow'
|
||||
- id: cordis-tool
|
||||
name: '@deepseek-ai/dsh-tool-cordis'
|
||||
"""
|
||||
|
||||
|
||||
class MockModelHandler(BaseHTTPRequestHandler):
|
||||
"""Return deterministic text, worker, and orchestration completions."""
|
||||
|
||||
requests: list[dict[str, object]] = []
|
||||
|
||||
def do_POST(self) -> None:
|
||||
content_length = int(self.headers.get("content-length", "0"))
|
||||
body = json.loads(self.rfile.read(content_length))
|
||||
self.requests.append(body)
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "text/event-stream")
|
||||
self.end_headers()
|
||||
chunks = completion_chunks(body)
|
||||
for chunk in chunks:
|
||||
self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
def log_message(self, _format: str, *_args: object) -> None:
|
||||
return
|
||||
|
||||
|
||||
def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
|
||||
"""Choose the next deterministic model response from request history."""
|
||||
messages = body.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
raise AssertionError(f"model request has no messages: {body}")
|
||||
latest = messages[-1]
|
||||
if not isinstance(latest, dict):
|
||||
raise AssertionError(f"model request has an invalid latest message: {body}")
|
||||
|
||||
if latest.get("role") == "tool":
|
||||
call_id, tool_name = latest_tool_call(messages)
|
||||
tool_text = message_text(latest.get("content"))
|
||||
advanced = advanced_tool_followup(body, call_id, tool_name, tool_text)
|
||||
if advanced is not None:
|
||||
return advanced
|
||||
if "42" not in tool_text:
|
||||
raise AssertionError(f"{tool_name} worker returned no expected value: {latest}")
|
||||
if tool_name == "run_code":
|
||||
return text_chunks(CODE_WORKER_TEXT)
|
||||
if tool_name == "workflow":
|
||||
return text_chunks(WORKFLOW_WORKER_TEXT)
|
||||
raise AssertionError(f"unexpected tool follow-up: {tool_name}")
|
||||
|
||||
prompt = message_text(latest.get("content"))
|
||||
if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT:
|
||||
return text_chunks("DIRECT_CHILD_OK")
|
||||
if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT:
|
||||
return text_chunks("WORKFLOW_CHILD_OK")
|
||||
if prompt == SNAPSHOT_PROMPT:
|
||||
assert_advertised_tool(body, "cordis_mount")
|
||||
return tool_call_chunks(
|
||||
"advanced-mount",
|
||||
"cordis_mount",
|
||||
{"code": SNAPSHOT_MOUNT_CODE},
|
||||
)
|
||||
if prompt == CODE_PROMPT:
|
||||
assert_advertised_tool(body, "run_code")
|
||||
return tool_call_chunks("call-code-worker", "run_code", {"code": "return 6 * 7"})
|
||||
if prompt == WORKFLOW_PROMPT:
|
||||
assert_advertised_tool(body, "workflow")
|
||||
return tool_call_chunks(
|
||||
"call-workflow-worker",
|
||||
"workflow",
|
||||
{
|
||||
"script": "return 6 * 7",
|
||||
"meta": {
|
||||
"name": "pkg-worker-smoke",
|
||||
"description": "exercise the packaged workflow worker",
|
||||
},
|
||||
},
|
||||
)
|
||||
return text_chunks(EXPECTED_TEXT)
|
||||
|
||||
|
||||
def advanced_tool_followup(
|
||||
body: dict[str, object],
|
||||
call_id: str,
|
||||
tool_name: str,
|
||||
tool_text: str,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""Advance the executable snapshot's deterministic parent tool chain."""
|
||||
if not call_id.startswith("advanced-"):
|
||||
return None
|
||||
if call_id == "advanced-mount" and tool_name == "cordis_mount":
|
||||
if "mounted dyn-1" not in tool_text:
|
||||
raise AssertionError(f"cordis_mount returned no mount id: {tool_text}")
|
||||
assert_advertised_tool(body, "run_code")
|
||||
assert_advertised_tool(body, "snapshot_double")
|
||||
return tool_call_chunks(
|
||||
"advanced-code",
|
||||
"run_code",
|
||||
{"code": "return await tools.snapshot_double({ value: 21 })"},
|
||||
)
|
||||
if call_id == "advanced-code" and tool_name == "run_code":
|
||||
if "42" not in tool_text:
|
||||
raise AssertionError(f"run_code returned no dynamic-tool value: {tool_text}")
|
||||
assert_advertised_tool(body, "subagent")
|
||||
return tool_call_chunks(
|
||||
"advanced-direct-child",
|
||||
"subagent",
|
||||
{
|
||||
"description": "Check direct child",
|
||||
"prompt": SNAPSHOT_DIRECT_CHILD_PROMPT,
|
||||
},
|
||||
)
|
||||
if call_id == "advanced-direct-child" and tool_name == "subagent":
|
||||
if "DIRECT_CHILD_OK" not in tool_text:
|
||||
raise AssertionError(f"subagent returned no expected child value: {tool_text}")
|
||||
assert_advertised_tool(body, "workflow")
|
||||
return tool_call_chunks(
|
||||
"advanced-workflow",
|
||||
"workflow",
|
||||
{
|
||||
"script": SNAPSHOT_WORKFLOW_SCRIPT,
|
||||
"meta": {
|
||||
"name": "advanced-exe-snapshot",
|
||||
"description": "exercise one packaged workflow child",
|
||||
},
|
||||
},
|
||||
)
|
||||
if call_id == "advanced-workflow" and tool_name == "workflow":
|
||||
if "WORKFLOW_CHILD_OK" not in tool_text:
|
||||
raise AssertionError(f"workflow returned no expected child value: {tool_text}")
|
||||
assert_advertised_tool(body, "cordis_unmount")
|
||||
return tool_call_chunks(
|
||||
"advanced-unmount",
|
||||
"cordis_unmount",
|
||||
{"id": "dyn-1"},
|
||||
)
|
||||
if call_id == "advanced-unmount" and tool_name == "cordis_unmount":
|
||||
if "unmounted dyn-1" not in tool_text:
|
||||
raise AssertionError(f"cordis_unmount returned no disposal result: {tool_text}")
|
||||
if "snapshot_double" in advertised_tool_names(body):
|
||||
raise AssertionError("snapshot_double remained advertised after cordis_unmount")
|
||||
return text_chunks(SNAPSHOT_FINAL_TEXT)
|
||||
raise AssertionError(f"unexpected advanced tool follow-up: {call_id} {tool_name}: {tool_text}")
|
||||
|
||||
|
||||
def text_chunks(text: str) -> list[dict[str, object]]:
|
||||
"""Build a complete streaming text response."""
|
||||
return [
|
||||
{"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
|
||||
{"choices": [{"delta": {"content": text}}]},
|
||||
{
|
||||
"choices": [{"delta": {"content": ""}, "finish_reason": "stop"}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 3},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> list[dict[str, object]]:
|
||||
"""Build a complete streaming function-call response."""
|
||||
return [
|
||||
{"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
|
||||
{
|
||||
"choices": [{
|
||||
"delta": {
|
||||
"tool_calls": [{
|
||||
"index": 0,
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": name, "arguments": json.dumps(arguments)},
|
||||
}],
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
"choices": [{"delta": {"content": ""}, "finish_reason": "tool_calls"}],
|
||||
"usage": {"prompt_tokens": 3, "completion_tokens": 3},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def latest_tool_call(messages: list[object]) -> tuple[str, str]:
|
||||
"""Find the assistant call id and name paired with the latest tool result."""
|
||||
for message in reversed(messages[:-1]):
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
calls = message.get("tool_calls")
|
||||
if not isinstance(calls, list):
|
||||
continue
|
||||
for call in reversed(calls):
|
||||
if not isinstance(call, dict):
|
||||
continue
|
||||
function = call.get("function")
|
||||
call_id = call.get("id")
|
||||
if (
|
||||
isinstance(call_id, str)
|
||||
and isinstance(function, dict)
|
||||
and isinstance(function.get("name"), str)
|
||||
):
|
||||
return call_id, function["name"]
|
||||
raise AssertionError(f"tool result has no preceding assistant tool call: {messages}")
|
||||
|
||||
|
||||
def message_text(content: object) -> str:
|
||||
"""Read OpenAI text content in either string or block-list form."""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "".join(
|
||||
block.get("text", "")
|
||||
for block in content
|
||||
if isinstance(block, dict) and isinstance(block.get("text"), str)
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def advertised_tool_names(body: dict[str, object]) -> set[str]:
|
||||
"""Return the model-facing tool names advertised on one request."""
|
||||
tools = body.get("tools")
|
||||
if not isinstance(tools, list):
|
||||
raise AssertionError(f"model request advertised no tools: {body}")
|
||||
names: set[str] = set()
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
continue
|
||||
function = tool.get("function")
|
||||
if isinstance(function, dict) and isinstance(function.get("name"), str):
|
||||
names.add(function["name"])
|
||||
return names
|
||||
|
||||
|
||||
def assert_advertised_tool(body: dict[str, object], expected: str) -> None:
|
||||
"""Require the packaged deployment to expose the requested tool."""
|
||||
names = advertised_tool_names(body)
|
||||
if expected not in names:
|
||||
raise AssertionError(f"model request did not advertise {expected}: {names}")
|
||||
|
||||
|
||||
class MockModel:
|
||||
def __enter__(self) -> "MockModel":
|
||||
MockModelHandler.requests.clear()
|
||||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), MockModelHandler)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
||||
self.thread.start()
|
||||
host, port = self.server.server_address
|
||||
self.url = f"http://{host}:{port}"
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "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-snapshot", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, 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():
|
||||
parser.error(f"runtime executable does not exist: {args.exe}")
|
||||
|
||||
with MockModel() as model:
|
||||
if args.scenario in {"all", "sdk-default"}:
|
||||
smoke_sdk_default(model.url)
|
||||
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-snapshot"}:
|
||||
assert args.exe is not None
|
||||
smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots)
|
||||
if args.scenario in {"all", "direct"}:
|
||||
assert args.exe is not None
|
||||
smoke_direct(model.url, args.exe.resolve())
|
||||
if not MockModelHandler.requests:
|
||||
raise AssertionError("mock model endpoint received no requests")
|
||||
print(f"smoke-python-runtime: {args.scenario} passed")
|
||||
|
||||
|
||||
def smoke_sdk_default(base_url: str) -> None:
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
with DeepSeekHarness(
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
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_session_log(sessions, root, EXPECTED_TEXT)
|
||||
|
||||
|
||||
def smoke_sdk_custom(base_url: str, executable: Path) -> None:
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
cordis=str(cordis),
|
||||
runtime_bin=str(executable),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
request_timeout_seconds=60,
|
||||
) as harness:
|
||||
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_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
"""Drive and compare the advanced SDK/executable behavioral snapshot."""
|
||||
from deepseek_harness import DeepSeekHarness
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-sdk-snapshot-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
with DeepSeekHarness(
|
||||
model="smoke-model",
|
||||
cwd=str(root),
|
||||
session_root=str(sessions),
|
||||
cordis=str(cordis),
|
||||
runtime_bin=str(executable),
|
||||
api_key="sk-keyless-smoke",
|
||||
base_url=base_url,
|
||||
request_timeout_seconds=60,
|
||||
) 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:
|
||||
raise AssertionError(f"advanced snapshot emitted unexpected subagent lifecycle: {methods}")
|
||||
if not any(event.get("type") == "tool/code-dispatch" for event in result.events):
|
||||
raise AssertionError("advanced snapshot emitted no tool/code-dispatch event")
|
||||
|
||||
logs = read_session_logs(sessions)
|
||||
child_ids = snapshot_child_ids(result)
|
||||
expected_ids = {SNAPSHOT_SESSION_ID, *child_ids}
|
||||
if set(logs) != expected_ids:
|
||||
raise AssertionError(f"advanced snapshot expected parent plus two child logs: {sorted(logs)}")
|
||||
if "DIRECT_CHILD_OK" not in render_jsonl(logs[child_ids[0]]):
|
||||
raise AssertionError("first advanced child log has no direct-subagent result")
|
||||
if "WORKFLOW_CHILD_OK" not in render_jsonl(logs[child_ids[1]]):
|
||||
raise AssertionError("second advanced child log has no workflow-subagent result")
|
||||
|
||||
files = build_snapshot_files(result, logs, child_ids, root)
|
||||
compare_snapshot_files(files, update_snapshots)
|
||||
|
||||
|
||||
def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
sessions = root / "sessions"
|
||||
cordis = root / "cordis.yml"
|
||||
cordis.write_text(CUSTOM_CORDIS)
|
||||
environment = {
|
||||
**os.environ,
|
||||
"DSH_CORDIS_CONFIG": str(cordis),
|
||||
"DSH_SESSION_ROOT": str(sessions),
|
||||
"DSH_CWD": str(root),
|
||||
"DEEPSEEK_API_KEY": "sk-keyless-smoke",
|
||||
"DEEPSEEK_BASE_URL": base_url,
|
||||
}
|
||||
peer = RuntimePeer([str(executable)], root, environment)
|
||||
try:
|
||||
peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}})
|
||||
peer.read_until(lambda message: message.get("id") == "initialize")
|
||||
peer.send({
|
||||
"jsonrpc": "2.0",
|
||||
"id": "prompt",
|
||||
"method": "session/prompt",
|
||||
"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"))
|
||||
event_text = json.dumps(messages)
|
||||
if EXPECTED_TEXT not in event_text:
|
||||
raise AssertionError(f"direct runtime emitted no final response: {messages}")
|
||||
peer.send({"jsonrpc": "2.0", "id": "shutdown", "method": "shutdown"})
|
||||
peer.read_until(lambda message: message.get("id") == "shutdown")
|
||||
finally:
|
||||
peer.close()
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT)
|
||||
|
||||
|
||||
class RuntimePeer:
|
||||
def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None:
|
||||
self.process = subprocess.Popen(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=environment,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
bufsize=1,
|
||||
)
|
||||
self.stdout: queue.Queue[str | None] = queue.Queue()
|
||||
self.stderr: list[str] = []
|
||||
threading.Thread(target=self._read_stdout, daemon=True).start()
|
||||
threading.Thread(target=self._read_stderr, daemon=True).start()
|
||||
|
||||
def send(self, message: dict[str, object]) -> None:
|
||||
if self.process.stdin is None:
|
||||
raise RuntimeError("runtime stdin is unavailable")
|
||||
self.process.stdin.write(json.dumps(message) + "\n")
|
||||
self.process.stdin.flush()
|
||||
|
||||
def read_until(self, predicate: Callable[[dict[str, object]], bool]) -> list[dict[str, object]]:
|
||||
deadline = time.monotonic() + 60
|
||||
messages: list[dict[str, object]] = []
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
line = self.stdout.get(timeout=min(0.25, deadline - time.monotonic()))
|
||||
except queue.Empty:
|
||||
continue
|
||||
if line is None:
|
||||
raise RuntimeError(f"runtime exited before expected message; stderr: {''.join(self.stderr)}")
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
messages.append(message)
|
||||
if predicate(message):
|
||||
return messages
|
||||
raise TimeoutError(f"runtime timed out; messages={messages}; stderr={''.join(self.stderr)}")
|
||||
|
||||
def close(self) -> None:
|
||||
if self.process.stdin is not None and not self.process.stdin.closed:
|
||||
self.process.stdin.close()
|
||||
try:
|
||||
self.process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.process.kill()
|
||||
self.process.wait()
|
||||
if self.process.returncode not in {0, -15}:
|
||||
raise RuntimeError(f"runtime exited {self.process.returncode}; stderr: {''.join(self.stderr)}")
|
||||
|
||||
def _read_stdout(self) -> None:
|
||||
assert self.process.stdout is not None
|
||||
for line in self.process.stdout:
|
||||
self.stdout.put(line)
|
||||
self.stdout.put(None)
|
||||
|
||||
def _read_stderr(self) -> None:
|
||||
assert self.process.stderr is not None
|
||||
self.stderr.extend(self.process.stderr)
|
||||
|
||||
|
||||
def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None:
|
||||
logs = list(sessions.rglob("*.jsonl"))
|
||||
if len(logs) != 1:
|
||||
raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}")
|
||||
lines = logs[0].read_text().splitlines()
|
||||
header = json.loads(lines[0])
|
||||
if header.get("cwd") != str(cwd):
|
||||
raise AssertionError(f"session header cwd is not absolute/canonical: {header}")
|
||||
rendered = "\n".join(lines)
|
||||
for expected in expected_texts:
|
||||
if expected not in rendered:
|
||||
raise AssertionError(f"session log has no {expected!r} response: {logs[0]}")
|
||||
|
||||
|
||||
def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]:
|
||||
"""Parse every persisted JSONL session into a map keyed by header id."""
|
||||
logs: dict[str, list[dict[str, object]]] = {}
|
||||
for path in sorted(sessions.rglob("*.jsonl")):
|
||||
records = [
|
||||
json.loads(line)
|
||||
for line in path.read_text(encoding="utf-8").splitlines()
|
||||
if line
|
||||
]
|
||||
if not records or records[0].get("type") != "session":
|
||||
raise AssertionError(f"session log has no header: {path}")
|
||||
session_id = records[0].get("id")
|
||||
if not isinstance(session_id, str):
|
||||
raise AssertionError(f"session log header has no string id: {path}")
|
||||
if session_id in logs:
|
||||
raise AssertionError(f"duplicate persisted session id: {session_id}")
|
||||
logs[session_id] = records
|
||||
return logs
|
||||
|
||||
|
||||
def snapshot_child_ids(result: "TurnResult") -> list[str]:
|
||||
"""Return the two child session ids in their SDK notification order."""
|
||||
child_ids: list[str] = []
|
||||
for notification in result.notifications:
|
||||
if notification.method != "subagent.started":
|
||||
continue
|
||||
payload = notification.payload
|
||||
if payload.get("parentSessionId") != SNAPSHOT_SESSION_ID:
|
||||
continue
|
||||
child_id = payload.get("childSessionId")
|
||||
if isinstance(child_id, str) and child_id not in child_ids:
|
||||
child_ids.append(child_id)
|
||||
if len(child_ids) != 2:
|
||||
raise AssertionError(f"advanced snapshot expected two child session ids: {child_ids}")
|
||||
return child_ids
|
||||
|
||||
|
||||
def build_snapshot_files(
|
||||
result: "TurnResult",
|
||||
logs: dict[str, list[dict[str, object]]],
|
||||
child_ids: list[str],
|
||||
cwd: Path,
|
||||
) -> dict[str, str]:
|
||||
"""Render the SDK result and three persisted logs into stable goldens."""
|
||||
replacements = [(str(cwd), "{{cwd}}"), (SNAPSHOT_SESSION_ID, "{{parent}}")]
|
||||
for index, child_id in enumerate(child_ids, start=1):
|
||||
replacements.append((child_id, f"{{{{child-{index}}}}}"))
|
||||
agent_id = snapshot_agent_id(result, child_id)
|
||||
replacements.append((agent_id, f"{{{{agent-{index}}}}}"))
|
||||
replacements.sort(key=lambda pair: len(pair[0]), reverse=True)
|
||||
|
||||
result_value = {
|
||||
"session_id": result.session_id,
|
||||
"status": result.status,
|
||||
"final_response": result.final_response,
|
||||
"events": result.events,
|
||||
"notifications": [
|
||||
{"method": notification.method, "payload": notification.payload}
|
||||
for notification in result.notifications
|
||||
],
|
||||
"session_root": result.session_root,
|
||||
}
|
||||
normalized_result = normalize_snapshot_value(result_value, replacements)
|
||||
files = {
|
||||
"result.json": json.dumps(normalized_result, indent=2, ensure_ascii=False) + "\n",
|
||||
"session.jsonl": render_jsonl(
|
||||
[normalize_snapshot_value(record, replacements) for record in logs[SNAPSHOT_SESSION_ID]]
|
||||
),
|
||||
}
|
||||
for index, child_id in enumerate(child_ids, start=1):
|
||||
files[f"session.{index}.jsonl"] = render_jsonl(
|
||||
[normalize_snapshot_value(record, replacements) for record in logs[child_id]]
|
||||
)
|
||||
if tuple(files) != SNAPSHOT_FILENAMES:
|
||||
raise AssertionError(f"advanced snapshot file set drifted: {tuple(files)}")
|
||||
return files
|
||||
|
||||
|
||||
def snapshot_agent_id(result: "TurnResult", child_id: str) -> str:
|
||||
"""Find the successful subagent id paired with one child session."""
|
||||
for notification in result.notifications:
|
||||
if notification.method != "subagent.finished":
|
||||
continue
|
||||
payload = notification.payload
|
||||
if payload.get("childSessionId") != child_id:
|
||||
continue
|
||||
if payload.get("provider") != "spawn" or payload.get("status") != "ok":
|
||||
raise AssertionError(f"advanced child did not finish successfully: {payload}")
|
||||
agent_id = payload.get("agentId")
|
||||
if isinstance(agent_id, str):
|
||||
return agent_id
|
||||
raise AssertionError(f"advanced snapshot has no finished agent for child {child_id}")
|
||||
|
||||
|
||||
def normalize_snapshot_value(
|
||||
value: object,
|
||||
replacements: list[tuple[str, str]],
|
||||
) -> object:
|
||||
"""Scrub volatile values and bulky request headers without losing behavior."""
|
||||
if isinstance(value, str):
|
||||
normalized = value
|
||||
for actual, token in replacements:
|
||||
normalized = normalized.replace(actual, token)
|
||||
return normalized
|
||||
if isinstance(value, list):
|
||||
return [normalize_snapshot_value(item, replacements) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
normalized = {
|
||||
key: normalize_snapshot_value(item, replacements)
|
||||
for key, item in value.items()
|
||||
}
|
||||
if normalized.get("type") == "session" and "createdAt" in normalized:
|
||||
normalized["createdAt"] = 0
|
||||
if "seq" in normalized and "time" in normalized:
|
||||
normalized["time"] = 0
|
||||
scrub_snapshot_header(normalized)
|
||||
return normalized
|
||||
|
||||
|
||||
def scrub_snapshot_header(value: dict[object, object]) -> None:
|
||||
"""Tokenize request-header bulk while retaining delta tool names."""
|
||||
data = value.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return
|
||||
if value.get("type") == "request/header":
|
||||
header = data.get("header")
|
||||
if not isinstance(header, dict):
|
||||
return
|
||||
if "system" in header:
|
||||
header["system"] = "{{system}}"
|
||||
tools = header.get("tools")
|
||||
if isinstance(tools, list):
|
||||
header["tools"] = [
|
||||
tool.get("name") if isinstance(tool, dict) else "{{tools}}"
|
||||
for tool in tools
|
||||
]
|
||||
if isinstance(header.get("messagePrefix"), list):
|
||||
header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]]
|
||||
return
|
||||
if value.get("type") != "request/header-delta":
|
||||
return
|
||||
system = data.get("system")
|
||||
if isinstance(system, dict) and isinstance(system.get("insert"), list):
|
||||
system["insert"] = ["{{system}}" for _ in system["insert"]]
|
||||
tools = data.get("tools")
|
||||
if isinstance(tools, dict):
|
||||
for key in ("added", "changed"):
|
||||
if isinstance(tools.get(key), list):
|
||||
tools[key] = [scrub_snapshot_tool_schema(tool) for tool in tools[key]]
|
||||
if isinstance(data.get("messagePrefix"), list):
|
||||
data["messagePrefix"] = ["{{messagePrefix}}" for _ in data["messagePrefix"]]
|
||||
|
||||
|
||||
def scrub_snapshot_tool_schema(value: object) -> object:
|
||||
"""Keep a changed tool's name while tokenizing its schema bulk."""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
return {
|
||||
key: item if key == "name" else "{{tools}}"
|
||||
for key, item in value.items()
|
||||
}
|
||||
|
||||
|
||||
def render_jsonl(records: list[object]) -> str:
|
||||
"""Render parsed JSON values as compact, newline-terminated JSONL."""
|
||||
return "".join(
|
||||
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
for record in records
|
||||
)
|
||||
|
||||
|
||||
def compare_snapshot_files(files: dict[str, str], update: bool) -> None:
|
||||
"""Write or exactly compare the advanced executable snapshot files."""
|
||||
if update:
|
||||
SNAPSHOT_DIRECTORY.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in files.items():
|
||||
(SNAPSHOT_DIRECTORY / name).write_text(content, encoding="utf-8")
|
||||
print(f"smoke-python-runtime: updated snapshots in {SNAPSHOT_DIRECTORY}")
|
||||
|
||||
existing = {
|
||||
path.name
|
||||
for path in SNAPSHOT_DIRECTORY.iterdir()
|
||||
if path.is_file()
|
||||
} if SNAPSHOT_DIRECTORY.is_dir() else set()
|
||||
expected = set(SNAPSHOT_FILENAMES)
|
||||
if existing != expected:
|
||||
raise AssertionError(
|
||||
"advanced snapshot files differ: "
|
||||
f"missing={sorted(expected - existing)}, unexpected={sorted(existing - expected)}"
|
||||
)
|
||||
for name, actual in files.items():
|
||||
expected_text = (SNAPSHOT_DIRECTORY / name).read_text(encoding="utf-8")
|
||||
if actual == expected_text:
|
||||
continue
|
||||
diff = "".join(difflib.unified_diff(
|
||||
expected_text.splitlines(keepends=True),
|
||||
actual.splitlines(keepends=True),
|
||||
fromfile=f"expected/{name}",
|
||||
tofile=f"actual/{name}",
|
||||
))
|
||||
raise AssertionError(
|
||||
f"advanced executable snapshot mismatch in {name}; "
|
||||
"rerun with --update-snapshots after reviewing the behavior\n"
|
||||
f"{diff}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"}
|
||||
{"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"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,13 @@
|
||||
{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"}
|
||||
{"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"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}}
|
||||
{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,66 @@
|
||||
{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}"}
|
||||
{"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"}},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":5,"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 async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}
|
||||
{"type":"assistant/chunk","seq":6,"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 async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"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 async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":10,"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 async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}
|
||||
{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"<anonymous>\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"fallback"}}
|
||||
{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":16,"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 })\"}"}}}
|
||||
{"type":"assistant/chunk","seq":17,"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 })\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}
|
||||
{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}}
|
||||
{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[21],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":27,"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":28,"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":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"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.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":32,"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":33,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":37,"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":38,"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":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"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\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":42,"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":43,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[42],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":44,"time":0,"data":{"turn":1,"step":4}}
|
||||
{"type":"step/start","seq":45,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
|
||||
{"type":"assistant/chunk","seq":47,"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":48,"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":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":51,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}
|
||||
{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"<anonymous>\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}}
|
||||
{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"request/header-delta","seq":56,"time":0,"data":{"system":{"keepStart":62,"keepEnd":34,"insert":[]},"tools":{"added":[],"removed":["snapshot_double"],"changed":[]}}}
|
||||
{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}}
|
||||
{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}}
|
||||
{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}
|
||||
{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":62,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}}
|
||||
{"type":"turn/end","seq":64,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -4,7 +4,11 @@
|
||||
"docs/development.md",
|
||||
"docs/i18n/README.md",
|
||||
"docs/i18n/translation-rules.md",
|
||||
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md"
|
||||
"docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md",
|
||||
"docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md",
|
||||
"python/README.md",
|
||||
"python/sdk-runtime/README.md",
|
||||
"python/sdk/README.md"
|
||||
],
|
||||
"excluded": [
|
||||
"docs/AGENTS.md",
|
||||
@@ -13,6 +17,7 @@
|
||||
"docs/tool-catalog.md",
|
||||
"docs/persistence-catalog.md",
|
||||
"docs/cordis-catalog/",
|
||||
"docs/i18n/terminology.md"
|
||||
"docs/i18n/terminology.md",
|
||||
"python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/"
|
||||
]
|
||||
}
|
||||
@@ -26,8 +26,9 @@
|
||||
* Run: `tsx scripts/verify-doc-refs.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -46,42 +47,14 @@ const isExcluded = (p: string): boolean =>
|
||||
*/
|
||||
const DOC_REF = /\bdocs\/[A-Za-z0-9._/-]+\.md/g
|
||||
|
||||
/** A broken doc reference: a root-relative `docs/….md` token with no file. */
|
||||
interface Violation {
|
||||
file: string
|
||||
/** 1-based line where the reference appears. */
|
||||
line: number
|
||||
ref: string
|
||||
}
|
||||
|
||||
/** Find every broken `docs/….md` reference in one TypeScript file. */
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const out: Violation[] = []
|
||||
const lines = source.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === undefined) continue
|
||||
for (const m of line.matchAll(DOC_REF)) {
|
||||
const ref = m[0]
|
||||
if (!existsSync(resolve(root, ref))) {
|
||||
out.push({ file, line: i + 1, ref })
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
return findReferenceViolations(root, absPath, DOC_REF, ref => ref, ref => !existsSync(resolve(root, ref)))
|
||||
}
|
||||
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
checked++
|
||||
all.push(...findViolations(resolve(root, match)))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
|
||||
const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-doc-refs: ${checked} file(s) checked, all docs/*.md references resolve.`)
|
||||
|
||||
@@ -32,12 +32,11 @@
|
||||
* Run: `tsx scripts/verify-md-links.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { dirname, relative, resolve } from 'node:path'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -103,7 +102,7 @@ function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const dir = dirname(absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
const tree = parseMarkdown(source)
|
||||
const out: Violation[] = []
|
||||
|
||||
const check = (url: string, node: Nodes): void => {
|
||||
@@ -117,33 +116,17 @@ function findViolations(absPath: string): Violation[] {
|
||||
}
|
||||
}
|
||||
|
||||
const visit = (node: Nodes): void => {
|
||||
visitMarkdown(tree, (node: Nodes): void => {
|
||||
if ((node.type === 'link' || node.type === 'image' || node.type === 'definition') && 'url' in node) {
|
||||
check(node.url, node)
|
||||
}
|
||||
if ('children' in node) {
|
||||
for (const child of node.children) visit(child)
|
||||
}
|
||||
}
|
||||
visit(tree)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const abs = resolve(root, match)
|
||||
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
|
||||
// matched twice (or via symlink) is checked once.
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(abs))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS)
|
||||
const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
|
||||
|
||||
+10
-27
@@ -27,12 +27,11 @@
|
||||
* Run: `tsx scripts/verify-md-wrap.ts`.
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { fromMarkdown } from 'mdast-util-from-markdown'
|
||||
import { gfmFromMarkdown } from 'mdast-util-gfm'
|
||||
import { gfm } from 'micromark-extension-gfm'
|
||||
import type { Nodes } from 'mdast'
|
||||
import { parseMarkdown, visitMarkdown } from './markdown.ts'
|
||||
import { uniqueRepoFiles } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -61,10 +60,10 @@ interface Violation {
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const tree = fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
|
||||
const tree = parseMarkdown(source)
|
||||
const out: Violation[] = []
|
||||
|
||||
const visit = (node: Nodes): void => {
|
||||
visitMarkdown(tree, (node: Nodes): boolean | void => {
|
||||
if (node.type === 'paragraph' && node.position) {
|
||||
const { start, end } = node.position
|
||||
if (end.line > start.line) {
|
||||
@@ -73,31 +72,15 @@ function findViolations(absPath: string): Violation[] {
|
||||
}
|
||||
// A paragraph's children are inline (text/emphasis/…); no nested
|
||||
// paragraphs to find, so don't descend.
|
||||
return
|
||||
return false
|
||||
}
|
||||
if ('children' in node) {
|
||||
for (const child of node.children) visit(child)
|
||||
}
|
||||
}
|
||||
visit(tree)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
const abs = resolve(root, match)
|
||||
// CLAUDE.md symlinks resolve onto AGENTS.md; dedupe by real path so a file
|
||||
// matched twice (or via symlink) is checked once.
|
||||
const real = realpathSync(abs)
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(abs))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS)
|
||||
const all = files.flatMap(file => findViolations(file.abs))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-md-wrap: ${checked} file(s) checked, no hard-wrapped prose paragraphs.`)
|
||||
|
||||
@@ -39,8 +39,9 @@
|
||||
* Run: `tsx scripts/verify-package-paths.ts`.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { relative, resolve } from 'node:path'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { findReferenceViolations, uniqueRepoFiles, type ReferenceViolation as Violation } from './repo-files.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -89,12 +90,22 @@ const packageNames = realPackageNames()
|
||||
*/
|
||||
const PKG_REF = /\bpackages\/[A-Za-z0-9._/-]+/g
|
||||
|
||||
/** A broken package reference: a stale root-relative `packages/…` path. */
|
||||
interface Violation {
|
||||
file: string
|
||||
/** 1-based line where the reference appears. */
|
||||
line: number
|
||||
ref: string
|
||||
function isDriftedPackageReference(ref: string): boolean {
|
||||
if (existsSync(resolve(root, ref))) return false
|
||||
// A reference INTO a package's built `lib/` is a build OUTPUT, not an
|
||||
// authored-source location: it does not exist until `pnpm run build` emits
|
||||
// it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when
|
||||
// the `packages/<group>/<pkg>` ROOT it sits under is real and on disk, so
|
||||
// `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is
|
||||
// exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the
|
||||
// exact moved-package drift this gate exists to catch) still flags. A bare
|
||||
// `lib` segment is not a blanket escape hatch.
|
||||
const parts = ref.split('/')
|
||||
const libAt = parts.indexOf('lib')
|
||||
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) return false
|
||||
// Only a stale path to a REAL (moved) package is a violation; a segment
|
||||
// matching a live package name is the drift signal.
|
||||
return ref.split('/').slice(1).some(segment => packageNames.has(segment))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,54 +116,20 @@ interface Violation {
|
||||
* skeletons whose segment is not a package.
|
||||
*/
|
||||
function findViolations(absPath: string): Violation[] {
|
||||
const file = relative(root, absPath)
|
||||
const source = readFileSync(absPath, 'utf8')
|
||||
const out: Violation[] = []
|
||||
const lines = source.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line === undefined) continue
|
||||
for (const m of line.matchAll(PKG_REF)) {
|
||||
// Trim a trailing path separator or sentence punctuation that the greedy
|
||||
// class may have swallowed (`packages/core/tools.` / `…/tools/`).
|
||||
const ref = m[0].replace(/[./]+$/, '')
|
||||
if (existsSync(resolve(root, ref))) continue
|
||||
// A reference INTO a package's built `lib/` is a build OUTPUT, not an
|
||||
// authored-source location: it does not exist until `pnpm run build` emits
|
||||
// it, and CI runs this gate BEFORE the build step. Skip it — but ONLY when
|
||||
// the `packages/<group>/<pkg>` ROOT it sits under is real and on disk, so
|
||||
// `packages/ui/acp-agent/lib/bin.js` (correct, just not yet built) is
|
||||
// exempt while a stale `packages/acp-agent/lib/bin.js` (group-less, the
|
||||
// exact moved-package drift this gate exists to catch) still flags. A bare
|
||||
// `lib` segment is not a blanket escape hatch.
|
||||
const parts = ref.split('/')
|
||||
const libAt = parts.indexOf('lib')
|
||||
if (libAt === 3 && existsSync(resolve(root, parts.slice(0, 3).join('/')))) continue
|
||||
// Only a stale path to a REAL (moved) package is a violation; a segment
|
||||
// matching a live package name is the drift signal.
|
||||
const segments = ref.split('/').slice(1)
|
||||
if (segments.some(seg => packageNames.has(seg))) {
|
||||
out.push({ file, line: i + 1, ref })
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
return findReferenceViolations(
|
||||
root,
|
||||
absPath,
|
||||
PKG_REF,
|
||||
// Trim a trailing path separator or sentence punctuation that the greedy
|
||||
// class may have swallowed (`packages/core/tools.` / `…/tools/`).
|
||||
ref => ref.replace(/[./]+$/, ''),
|
||||
isDriftedPackageReference,
|
||||
)
|
||||
}
|
||||
|
||||
const all: Violation[] = []
|
||||
let checked = 0
|
||||
const seen = new Set<string>()
|
||||
for (const pattern of PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (isExcluded(match)) continue
|
||||
// Dedup by real path: the root/packages CLAUDE.md are symlinks to AGENTS.md.
|
||||
const real = realpathSync(resolve(root, match))
|
||||
if (seen.has(real)) continue
|
||||
seen.add(real)
|
||||
checked++
|
||||
all.push(...findViolations(real))
|
||||
}
|
||||
}
|
||||
const files = uniqueRepoFiles(root, PATTERNS, isExcluded)
|
||||
const all = files.flatMap(file => findViolations(file.real))
|
||||
const checked = files.length
|
||||
|
||||
if (all.length === 0) {
|
||||
console.log(`verify-package-paths: ${checked} file(s) checked, all packages/* references resolve.`)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Verify that the Python single-exe deploy manifest explicitly supplies every
|
||||
* required workspace peer of every workspace package in its dependency graph.
|
||||
*
|
||||
* `pnpm deploy --config.auto-install-peers=false` cannot repair an incomplete
|
||||
* runtime root. Keeping the peer at the root also prevents a successful build
|
||||
* from producing an executable that fails only when Cordis loads the plugin.
|
||||
*/
|
||||
import { readFile, readdir } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
interface PackageManifest {
|
||||
name?: string
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>
|
||||
}
|
||||
|
||||
interface WorkspacePackage {
|
||||
path: string
|
||||
manifest: PackageManifest
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const { values } = parseArgs({
|
||||
args: process.argv.slice(2),
|
||||
options: { manifest: { type: 'string' } },
|
||||
})
|
||||
const runtimeManifestPath = resolve(root, values.manifest ?? 'python/sdk-runtime/package.json')
|
||||
const runtimeManifest = await loadManifest(runtimeManifestPath)
|
||||
const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime'
|
||||
const workspace = await loadWorkspacePackages()
|
||||
const runtimeDependencies = runtimeManifest.dependencies ?? {}
|
||||
const parents = new Map<string, string | undefined>()
|
||||
const queue: string[] = []
|
||||
|
||||
for (const dependency of Object.keys(runtimeDependencies).sort()) {
|
||||
if (!workspace.has(dependency)) continue
|
||||
parents.set(dependency, undefined)
|
||||
queue.push(dependency)
|
||||
}
|
||||
|
||||
const failures: string[] = []
|
||||
for (let index = 0; index < queue.length; index += 1) {
|
||||
const packageName = queue[index]
|
||||
if (packageName === undefined) continue
|
||||
const current = workspace.get(packageName)
|
||||
if (current === undefined) continue
|
||||
const peers = current.manifest.peerDependencies ?? {}
|
||||
const peerMeta = current.manifest.peerDependenciesMeta ?? {}
|
||||
for (const peer of Object.keys(peers).sort()) {
|
||||
if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue
|
||||
if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue
|
||||
failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`)
|
||||
}
|
||||
const dependencies = {
|
||||
...current.manifest.dependencies,
|
||||
...current.manifest.optionalDependencies,
|
||||
}
|
||||
for (const dependency of Object.keys(dependencies).sort()) {
|
||||
if (!workspace.has(dependency) || parents.has(dependency)) continue
|
||||
parents.set(dependency, packageName)
|
||||
queue.push(dependency)
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-runtime-closure: required workspace peers are missing from python/sdk-runtime dependencies:')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`)
|
||||
|
||||
async function loadWorkspacePackages(): Promise<Map<string, WorkspacePackage>> {
|
||||
const paths: string[] = []
|
||||
for (const group of await childDirectories(join(root, 'packages'))) {
|
||||
for (const packageDir of await childDirectories(join(root, 'packages', group))) {
|
||||
paths.push(join(root, 'packages', group, packageDir, 'package.json'))
|
||||
}
|
||||
}
|
||||
for (const packageDir of await childDirectories(join(root, 'vendor'))) {
|
||||
paths.push(join(root, 'vendor', packageDir, 'package.json'))
|
||||
}
|
||||
const result = new Map<string, WorkspacePackage>()
|
||||
for (const path of paths) {
|
||||
const manifest = await loadManifest(path)
|
||||
if (manifest.name !== undefined) result.set(manifest.name, { path, manifest })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function childDirectories(path: string): Promise<string[]> {
|
||||
const entries = await readdir(path, { withFileTypes: true })
|
||||
return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort()
|
||||
}
|
||||
|
||||
async function loadManifest(path: string): Promise<PackageManifest> {
|
||||
return JSON.parse(await readFile(path, 'utf8')) as PackageManifest
|
||||
}
|
||||
|
||||
function formatChain(
|
||||
runtimeName: string,
|
||||
packageName: string,
|
||||
parents: ReadonlyMap<string, string | undefined>,
|
||||
): string {
|
||||
const chain = [packageName]
|
||||
let parent = parents.get(packageName)
|
||||
while (parent !== undefined) {
|
||||
chain.unshift(parent)
|
||||
parent = parents.get(parent)
|
||||
}
|
||||
return [runtimeName, ...chain].join(' -> ')
|
||||
}
|
||||
@@ -55,8 +55,8 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const listMode = process.argv.includes('--list')
|
||||
const writeMode = process.argv.includes('--write')
|
||||
|
||||
/** Scope of the bilingual contract: the root README and the docs tree. */
|
||||
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml']
|
||||
/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */
|
||||
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml']
|
||||
|
||||
/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */
|
||||
interface Manifest {
|
||||
|
||||
Reference in New Issue
Block a user