Merge current subagent naming stack into Team session UI
This commit is contained in:
commit
80f70969d9
5594 files changed
+141677
-692708
No files matched your search
@@ -159,6 +159,19 @@ class Target {
|
||||
/**
|
||||
* Validated CLI configuration; construction owns help and parse-error exits.
|
||||
*/
|
||||
/** pkg's `--compress` values, whose build cost differs by two orders of magnitude. */
|
||||
const COMPRESS_TYPES = ['None', 'GZip', 'Brotli', 'Zstd'] as const
|
||||
type CompressType = typeof COMPRESS_TYPES[number]
|
||||
|
||||
/**
|
||||
* Narrow a raw `--compress` value to a supported pkg compressor.
|
||||
* @param value - the command-line value.
|
||||
* @returns whether pkg accepts the value.
|
||||
*/
|
||||
function isCompressType(value: string): value is CompressType {
|
||||
return (COMPRESS_TYPES as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
class BuildCli {
|
||||
private constructor(
|
||||
/** Build targets; defaults to the host platform only. */
|
||||
@@ -167,6 +180,8 @@ class BuildCli {
|
||||
readonly skipBuild: boolean,
|
||||
/** Build through `pnpm run build:artifacts`, which omits the aggregate test and script typechecks. */
|
||||
readonly buildArtifacts: boolean,
|
||||
/** pkg archive compression; Brotli is the smallest and by far the slowest. */
|
||||
readonly compress: CompressType,
|
||||
/** Print every command and config patch instead of executing. */
|
||||
readonly dryRun: boolean,
|
||||
) {}
|
||||
@@ -205,7 +220,11 @@ class BuildCli {
|
||||
}
|
||||
seen.add(key)
|
||||
}
|
||||
return new BuildCli(targets, values['skip-build'], values['build-artifacts'], values['dry-run'])
|
||||
const compress = values.compress ?? 'Brotli'
|
||||
if (!isCompressType(compress)) {
|
||||
throw new Error(`build-exe-for-python-sdk: --compress must be one of ${COMPRESS_TYPES.join(' | ')}, got ${JSON.stringify(compress)}.`)
|
||||
}
|
||||
return new BuildCli(targets, values['skip-build'], values['build-artifacts'], compress, values['dry-run'])
|
||||
}
|
||||
|
||||
private static parseRaw(argv: string[]) {
|
||||
@@ -215,6 +234,7 @@ class BuildCli {
|
||||
'targets': { type: 'string' },
|
||||
'skip-build': { type: 'boolean', default: false },
|
||||
'build-artifacts': { type: 'boolean', default: false },
|
||||
'compress': { type: 'string' },
|
||||
'dry-run': { type: 'boolean', default: false },
|
||||
'help': { type: 'boolean', default: false },
|
||||
},
|
||||
@@ -229,10 +249,11 @@ class BuildCli {
|
||||
' Default: the host platform only (on node24).',
|
||||
' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).',
|
||||
' --build-artifacts run `pnpm run build:artifacts` instead, omitting the aggregate test and script typechecks.',
|
||||
' --compress=<type> pkg archive compression: None | GZip | Brotli | Zstd. Default: Brotli (smallest, slowest).',
|
||||
' --dry-run print every command and config patch without executing.',
|
||||
' --help print this help.',
|
||||
'',
|
||||
'Build route: @yao-pkg/pkg --sea --compress Brotli (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
|
||||
'Build route: @yao-pkg/pkg --sea with the selected --compress type (root devDependency, pnpm-patched); see .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.',
|
||||
`Stages the node carrier in ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and writes executables to ${OUT_DIR}/.`,
|
||||
].join('\n')
|
||||
}
|
||||
@@ -400,7 +421,7 @@ class SingleExeBuild {
|
||||
this.staging,
|
||||
'--sea',
|
||||
'--compress',
|
||||
'Brotli',
|
||||
this.cli.compress,
|
||||
'--targets',
|
||||
target.spec,
|
||||
'--output',
|
||||
|
||||
+11
-1
@@ -60,7 +60,17 @@ describe('repository build CLI', () => {
|
||||
})
|
||||
|
||||
describe('repository build steps', () => {
|
||||
it('keeps artifact steps in serial dependency order', () => {
|
||||
it.each(['complete', 'check', 'typecheck'] as const)('gives Host compilers an 8 GiB heap in %s scope', (scope) => {
|
||||
const gates = buildGates(root, scope)
|
||||
for (const gate of gates.filter(gate => gate.id === 'host-src' || gate.id === 'host-check')) {
|
||||
expect(gate.args[0]).toBe('--max-old-space-size=8192')
|
||||
}
|
||||
for (const gate of gates.filter(gate => gate.id === 'client-src' || gate.id === 'client-check')) {
|
||||
expect(gate.args.some(arg => arg.startsWith('--max-old-space-size'))).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('finishes Host and Desktop readers before Client compilation rewrites shared outputs', () => {
|
||||
expect(graph(buildGates(root, 'artifacts'))).toEqual(artifactGraph)
|
||||
})
|
||||
|
||||
|
||||
@@ -328,6 +328,24 @@ describe('package payload constraints', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('publishes tools entry points and shared runtime chunks', () => {
|
||||
const dir = 'packages/core/tools'
|
||||
const manifest = JSON.parse(readFileSync(new URL(`../${dir}/package.json`, import.meta.url), 'utf8')) as WorkspaceManifest['manifest']
|
||||
expect(checkWorkspaceManifest({ dir, manifest })).toEqual([])
|
||||
for (const omitted of ['lib/pipeline.js', 'lib/shared-*.js']) {
|
||||
expect(checkWorkspaceManifest({ dir, manifest: {
|
||||
...manifest, files: manifest.files!.filter(file => file !== omitted),
|
||||
} })).toEqual([expect.stringContaining('package.json files must be')])
|
||||
}
|
||||
expect(checkWorkspaceManifest({ dir, manifest: {
|
||||
...manifest,
|
||||
exports: {
|
||||
...manifest.exports,
|
||||
'./pipeline': { types: './lib/types/pipeline.d.ts', default: './lib/index.js' },
|
||||
},
|
||||
} })).toContainEqual(expect.stringContaining('exports["./pipeline"] must expose'))
|
||||
})
|
||||
|
||||
it('publishes the directory of each locale detail document after the locale resources', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dsh-detail-files-'))
|
||||
onTestFinished(() => { rmSync(dir, { recursive: true, force: true }) })
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
import { hasTypertRemoteNavigation, isForbiddenPublicationFile } from './publication-payload.ts'
|
||||
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
|
||||
import { detailPath } from '../packages/util/plugin-detail/src/document.ts'
|
||||
import { OPTIONAL_BUNDLES, bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
|
||||
import { bundlePatchFiles } from '../packages/boot/app-boot/src/profile.ts'
|
||||
import { OPTIONAL_BUNDLES } from '../packages/util/official-bundles/src/bundles.ts'
|
||||
import {
|
||||
PACKAGE_ASSET_EXPORT,
|
||||
PACKAGE_ASSET_EXPORT_TARGET,
|
||||
@@ -184,6 +185,8 @@ export function readWorkspaceManifests(repositoryRoot: string): WorkspaceManifes
|
||||
}
|
||||
|
||||
const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
// Tools entry points share error classes and invocation stages through runtime chunks.
|
||||
'@deepseek-ai/dsh-tools': ['lib/pipeline.js', 'lib/shared-*.js'],
|
||||
// The embedded OAR implementation retains its upstream license.
|
||||
'@deepseek-ai/dsh-experimental-subagent-oar': ['LICENSE-OAR'],
|
||||
// CUA publishes its client API and attributed native source for local macOS builds.
|
||||
@@ -214,6 +217,10 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/dsh-experimental-inspector': ['lib/client.*.js', 'lib/worker.js', 'lib/devtools/**'],
|
||||
// Creator's composition guidance travels with the declaration package.
|
||||
'@deepseek-ai/dsh-agent-preset': ['skills'],
|
||||
// Personalized pages ship the location guidance used by their Creator.
|
||||
'@deepseek-ai/dsh-creation-recommendations': ['references/*.md', 'skills/*.md'],
|
||||
// Official-plugin recommendations ship the skill text their provider loads.
|
||||
'@deepseek-ai/dsh-plugin-recommendations': ['skills/*.md'],
|
||||
// The Web Host mounts the default-off settings owner independently of the
|
||||
// delegation-tool instances.
|
||||
'@deepseek-ai/dsh-tool-subagent': ['lib/model-selection-settings.js'],
|
||||
@@ -577,6 +584,10 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
|
||||
if (rootEntry?.default !== './lib/index.js') {
|
||||
errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`)
|
||||
}
|
||||
if (manifest.name === '@deepseek-ai/dsh-tools'
|
||||
&& !hasExportPair(manifest, './pipeline', './lib/types/pipeline.d.ts', './lib/pipeline.js')) {
|
||||
errors.push(`${label}: exports["./pipeline"] must expose lib/types/pipeline.d.ts and lib/pipeline.js`)
|
||||
}
|
||||
const expectedFiles = expectedDshPackageFiles(manifest, detailDocumentFiles(join(root, dir)))
|
||||
if (!sameStringList(manifest.files, expectedFiles)) {
|
||||
errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`)
|
||||
|
||||
@@ -25,7 +25,11 @@ interface CompatibilityJob {
|
||||
}
|
||||
|
||||
const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows/ci.yml'), 'utf8')) as {
|
||||
jobs: { 'node-compat': CompatibilityJob; 'python-sdk': { 'runs-on': string } }
|
||||
jobs: { 'node-compat': CompatibilityJob }
|
||||
}
|
||||
// Python keeps its own required verdict in ci-python.yml.
|
||||
const pythonWorkflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows/ci-python.yml'), 'utf8')) as {
|
||||
jobs: { 'python-sdk': { 'runs-on': string } }
|
||||
}
|
||||
const job = workflow.jobs['node-compat']
|
||||
const labels = ['self-hosted', 'linux', 'x64', 'vm-backup']
|
||||
@@ -86,7 +90,7 @@ describe('Node compatibility self-hosted routing', () => {
|
||||
expect(job.env.DSH_GATE_CONCURRENCY).toBe('${{ matrix.gate_concurrency }}')
|
||||
expect(job.steps.map(step => step.run)).toContain('pnpm run check:node-compat')
|
||||
expect(job.steps.map(step => step.run)).toContain('pnpm exec vitest run packages/boot/app-boot/tests/loader-shape.compat.spec.ts')
|
||||
expect(workflow.jobs['python-sdk']['runs-on']).toBe('ubuntu-latest')
|
||||
expect(pythonWorkflow.jobs['python-sdk']['runs-on']).toBe('ubuntu-latest')
|
||||
})
|
||||
|
||||
it('isolates version installs and enables hosted package caching only on hosted runners', () => {
|
||||
|
||||
+172
-61
@@ -14,12 +14,18 @@ const runnerPrivatePnpmDestination = /^\$\{\{ runner\.temp \}\}\/setup-pnpm-\$\{
|
||||
const nativeWindowsPnpmDestination = '${{ runner.temp }}/setup-pnpm-js-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}'
|
||||
|
||||
describe('CI workflow', () => {
|
||||
it('keeps at most ten worker jobs active while runtime targets follow the SDK suite', () => {
|
||||
it('keeps at most eleven worker jobs active across PR workflows while Python runtime targets follow the SDK suite', () => {
|
||||
const ci = loadWorkflow('.github/workflows/ci.yml')
|
||||
const ciWindows = loadWorkflow('.github/workflows/ci-windows.yml')
|
||||
const ciPython = loadWorkflow('.github/workflows/ci-python.yml')
|
||||
const compatibility = workflowJob(ci, 'node-compat')
|
||||
const runtime = workflowJob(ci, 'python-runtime')
|
||||
if (!isRecord(ci.jobs) || !isRecord(compatibility.strategy) || !isRecord(runtime.with)) {
|
||||
throw new TypeError('CI must define jobs, compatibility scheduling, and runtime inputs')
|
||||
const runtime = workflowJob(ciPython, 'python-runtime')
|
||||
if (!isRecord(ci.jobs)
|
||||
|| !isRecord(ciWindows.jobs)
|
||||
|| !isRecord(ciPython.jobs)
|
||||
|| !isRecord(compatibility.strategy)
|
||||
|| !isRecord(runtime.with)) {
|
||||
throw new TypeError('CI, CI Windows, and CI Python must define jobs, compatibility scheduling, and runtime inputs')
|
||||
}
|
||||
const compatibilityLimit = compatibility.strategy['max-parallel']
|
||||
const targets = runtime.with.targets
|
||||
@@ -34,17 +40,31 @@ describe('CI workflow', () => {
|
||||
})
|
||||
expect(workflowJob(ci, 'all-checks-passed').needs).toContain('node-compat')
|
||||
const ordinaryJobs = Object.entries(ci.jobs)
|
||||
.filter(([name]) => !['node-compat', 'python-runtime', 'all-checks-passed'].includes(name))
|
||||
.filter(([name]) => !['node-compat', 'all-checks-passed'].includes(name))
|
||||
for (const [name, job] of ordinaryJobs) {
|
||||
if (!isRecord(job)) throw new TypeError(`${name} must define a job`)
|
||||
expect(job).not.toHaveProperty('strategy')
|
||||
expect(job).not.toHaveProperty('uses')
|
||||
}
|
||||
expect(ordinaryJobs.map(([name]) => name)).toContain('python-sdk')
|
||||
// Python reports its own required verdict, so neither its jobs nor a
|
||||
// reusable workflow caller remain in the Linux or Windows workflow.
|
||||
expect(Object.keys(ci.jobs).filter(name => name.startsWith('python'))).toEqual([])
|
||||
expect(Object.keys(ciWindows.jobs)).not.toContain('python-runtime')
|
||||
|
||||
const pythonSdk = workflowJob(ciPython, 'python-sdk')
|
||||
expect(pythonSdk).not.toHaveProperty('strategy')
|
||||
expect(pythonSdk).not.toHaveProperty('uses')
|
||||
expect(runtime.needs).toBe('python-sdk')
|
||||
expect(runtime.strategy).toBeUndefined()
|
||||
expect(runtime.with).toMatchObject({ targets: 'node24-linux-x64,node24-win-x64' })
|
||||
expect(runtime.uses).toBe('./.github/workflows/build-exe-for-python-sdk.yml')
|
||||
const windowsJobs = Object.entries(ciWindows.jobs)
|
||||
.filter(([name]) => name !== 'windows-checks-passed')
|
||||
for (const [name, job] of windowsJobs) {
|
||||
if (!isRecord(job)) throw new TypeError(`${name} must define a job`)
|
||||
expect(job).not.toHaveProperty('strategy')
|
||||
expect(job).not.toHaveProperty('uses')
|
||||
}
|
||||
|
||||
const builder = loadWorkflow('.github/workflows/build-exe-for-python-sdk.yml')
|
||||
if (!isRecord(builder.jobs)) throw new TypeError('Python runtime builder must define jobs')
|
||||
@@ -63,19 +83,57 @@ describe('CI workflow', () => {
|
||||
if (!isRecord(build.strategy)) throw new TypeError('Python runtime build must define its target matrix')
|
||||
expect(build.strategy.matrix).toEqual({ include: '${{ fromJSON(needs.plan.outputs.matrix) }}' })
|
||||
|
||||
const beforeSdk = ordinaryJobs.length + compatibilityLimit
|
||||
const afterSdk = ordinaryJobs.length - 1 + compatibilityLimit + Math.max(1, targets.split(',').length)
|
||||
expect(beforeSdk).toBeLessThanOrEqual(10)
|
||||
expect(afterSdk).toBeLessThanOrEqual(10)
|
||||
// Python's runtime targets start only after the SDK suite; Windows starts
|
||||
// immediately. Plan and wheel stages precede the two-target matrix.
|
||||
const windowsActive = windowsJobs.length
|
||||
// The lint lane owns its own runner, so it raises the ceiling by one; the
|
||||
// self-hosted failover pool is still admitted only up to its own limit.
|
||||
const beforeSdk = ordinaryJobs.length + compatibilityLimit + windowsActive + 1
|
||||
const afterSdk = ordinaryJobs.length + compatibilityLimit + windowsActive + Math.max(1, targets.split(',').length)
|
||||
expect(beforeSdk).toBeLessThanOrEqual(11)
|
||||
expect(afterSdk).toBeLessThanOrEqual(11)
|
||||
})
|
||||
|
||||
it('keeps the union of required PR verdicts equal to the pre-split blocking set', () => {
|
||||
const verdicts = [
|
||||
['ci.yml', 'all-checks-passed'],
|
||||
['ci-windows.yml', 'windows-checks-passed'],
|
||||
['ci-python.yml', 'python-checks-passed'],
|
||||
] as const
|
||||
const blocking: string[] = []
|
||||
for (const [file, name] of verdicts) {
|
||||
const workflow = loadWorkflow('.github/workflows/' + file)
|
||||
const verdict = workflowJob(workflow, name)
|
||||
if (!isRecord(workflow.jobs) || !Array.isArray(verdict.needs)) {
|
||||
throw new TypeError(`${file} must define jobs and the ${name} verdict needs`)
|
||||
}
|
||||
const jobs = workflow.jobs
|
||||
expect(verdict.needs.every(id => typeof id === 'string' && id in jobs)).toBe(true)
|
||||
blocking.push(...(verdict.needs as string[]))
|
||||
}
|
||||
// Splitting the workflows only moves jobs between verdicts; the blocking
|
||||
// set itself must stay identical, or branch protection would accept a
|
||||
// failure it rejected before the split.
|
||||
expect(blocking.sort()).toEqual([
|
||||
'node-24',
|
||||
'node-24-bench',
|
||||
'node-24-consumers',
|
||||
'node-24-coverage',
|
||||
'node-24-lint',
|
||||
'node-compat',
|
||||
'python-runtime',
|
||||
'python-sdk',
|
||||
'windows-build',
|
||||
'windows-native-tests',
|
||||
])
|
||||
})
|
||||
|
||||
it('retains aggregate diagnostics in required builds and protected publication', () => {
|
||||
const ci = loadWorkflow('.github/workflows/ci.yml')
|
||||
for (const [name, command] of [
|
||||
['node-24-consumers', 'pnpm run check:ci:consumers'],
|
||||
['windows-build', 'pnpm run check:ci:windows-blocking'],
|
||||
for (const [file, name, command] of [
|
||||
['ci.yml', 'node-24-consumers', 'pnpm run check:ci:consumers'],
|
||||
['ci-windows.yml', 'windows-build', 'pnpm run check:ci:windows-blocking'],
|
||||
] as const) {
|
||||
const job = workflowJob(ci, name)
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/' + file), name)
|
||||
expect(job.steps).toContainEqual(expect.objectContaining({ run: command }))
|
||||
}
|
||||
for (const [file, command] of [
|
||||
@@ -88,7 +146,7 @@ describe('CI workflow', () => {
|
||||
const scripts = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { scripts: Record<string, string> }
|
||||
const hostSteps = scripts.scripts['build:lib:host']!.split(' && ')
|
||||
expect(hostSteps[0]).toBe('tsx scripts/compile-referenced-projects.ts host-libraries')
|
||||
expect(hostSteps).toContain('node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -p tsconfig.host.json')
|
||||
expect(hostSteps).toContain('node --max-old-space-size=8192 ./node_modules/typescript/bin/tsc -p tsconfig.host.json')
|
||||
expect(hostSteps.at(-1)).toBe('pnpm --filter @deepseek-ai/dsh-desktop run bundle')
|
||||
expect(scripts.scripts['build:lib:client']).toContain('tsc -b tsconfig.client.json')
|
||||
expect(scripts.scripts.typecheck).toBe('tsx scripts/build.ts --scope typecheck')
|
||||
@@ -182,7 +240,7 @@ describe('CI workflow', () => {
|
||||
})
|
||||
|
||||
it('skips coverage-history uploads on cancellation but retains Wine cleanup', () => {
|
||||
const coverage = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'windows-coverage')
|
||||
const coverage = workflowJob(loadWorkflow('.github/workflows/ci-windows.yml'), 'windows-coverage')
|
||||
const wine = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), 'windows')
|
||||
expect(coverage.steps).toContainEqual(expect.objectContaining({
|
||||
name: 'Save coverage duration history', if: '${{ !cancelled() }}',
|
||||
@@ -191,10 +249,10 @@ describe('CI workflow', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['node-24-coverage', 'Linux', 'LINUX', 'pnpm run check:ci:coverage'],
|
||||
['windows-coverage', 'Windows', 'WINDOWS', 'pnpm run check:ci:coverage'],
|
||||
])('reuses %s coverage timings only within its platform and runner pool', (jobName, platform, variable, command) => {
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), jobName)
|
||||
['node-24-coverage', 'ci.yml', 'Linux', 'LINUX', 'pnpm run check:ci:coverage'],
|
||||
['windows-coverage', 'ci-windows.yml', 'Windows', 'WINDOWS', 'pnpm run check:ci:coverage'],
|
||||
])('reuses %s coverage timings only within its platform and runner pool', (jobName, file, platform, variable, command) => {
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/' + file), jobName)
|
||||
if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`)
|
||||
const steps = job.steps.filter(isRecord)
|
||||
const checkout = steps.findIndex(step => step.uses === 'actions/[email protected]')
|
||||
@@ -219,7 +277,7 @@ describe('CI workflow', () => {
|
||||
})
|
||||
|
||||
it('isolates every pnpm action setup destination per runner', () => {
|
||||
const files = ['.github/workflows/ci.yml', '.github/workflows/ci-master.yml']
|
||||
const files = ['.github/workflows/ci.yml', '.github/workflows/ci-windows.yml', '.github/workflows/ci-master.yml']
|
||||
const setups: Array<{ jobName: string; step: unknown }> = []
|
||||
for (const file of files) {
|
||||
const workflow: unknown = yaml.load(readFileSync(resolve(root, file), 'utf8'))
|
||||
@@ -304,27 +362,32 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps split native Windows PR jobs with failover, plus a master-only standby', () => {
|
||||
it('keeps split native Windows PR jobs with failover in their own workflow, plus a master-only standby', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
const windowsWorkflow = loadWorkflow('.github/workflows/ci-windows.yml')
|
||||
const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml')
|
||||
if (!isRecord(workflow.jobs)
|
||||
|| !isRecord(workflow.jobs['windows-build'])
|
||||
|| !isRecord(workflow.jobs['windows-coverage'])
|
||||
|| !isRecord(workflow.jobs['windows-native-tests'])
|
||||
|| !isRecord(windowsWorkflow.jobs)
|
||||
|| !isRecord(windowsWorkflow.jobs['windows-build'])
|
||||
|| !isRecord(windowsWorkflow.jobs['windows-coverage'])
|
||||
|| !isRecord(windowsWorkflow.jobs['windows-native-tests'])
|
||||
|| !isRecord(windowsWorkflow.jobs['windows-checks-passed'])
|
||||
|| !isRecord(workflow.jobs['node-24'])
|
||||
|| !isRecord(workflow.jobs['node-24-coverage'])
|
||||
|| !isRecord(workflow.jobs['node-24-lint'])
|
||||
|| !isRecord(workflow.jobs['node-24-bench'])
|
||||
|| !isRecord(workflow.jobs['node-24-consumers'])
|
||||
|| !isRecord(workflow.jobs['node-compat'])
|
||||
|| !isRecord(workflow.jobs['all-checks-passed'])
|
||||
|| !isRecord(masterWorkflow.jobs)
|
||||
|| !isRecord(masterWorkflow.jobs['serial-windows'])) {
|
||||
throw new TypeError('CI workflow must define windows-build, windows-coverage, windows-native-tests, node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; ci-master must define serial-windows')
|
||||
throw new TypeError('CI workflow must define node-24, node-24-coverage, node-24-lint, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; CI Windows must define windows-build, windows-coverage, windows-native-tests, and windows-checks-passed; ci-master must define serial-windows')
|
||||
}
|
||||
|
||||
const windowsBuild = workflow.jobs['windows-build']
|
||||
const windowsCoverage = workflow.jobs['windows-coverage']
|
||||
const windowsNativeTests = workflow.jobs['windows-native-tests']
|
||||
const windowsBuild = windowsWorkflow.jobs['windows-build']
|
||||
const windowsCoverage = windowsWorkflow.jobs['windows-coverage']
|
||||
const windowsNativeTests = windowsWorkflow.jobs['windows-native-tests']
|
||||
const windowsAggregate = windowsWorkflow.jobs['windows-checks-passed']
|
||||
const serialWindows = masterWorkflow.jobs['serial-windows']
|
||||
const node24 = workflow.jobs['node-24']
|
||||
const node24Coverage = workflow.jobs['node-24-coverage']
|
||||
@@ -332,9 +395,10 @@ describe('CI workflow', () => {
|
||||
const node24Consumers = workflow.jobs['node-24-consumers']
|
||||
const nodeCompat = workflow.jobs['node-compat']
|
||||
const aggregate = workflow.jobs['all-checks-passed']
|
||||
if (!Array.isArray(aggregate.needs)) {
|
||||
throw new TypeError('CI aggregate must define needs')
|
||||
if (!Array.isArray(aggregate.needs) || !Array.isArray(windowsAggregate.needs)) {
|
||||
throw new TypeError('CI and CI Windows aggregates must define needs')
|
||||
}
|
||||
expect(Object.keys(workflow.jobs).filter(name => name.startsWith('windows'))).toEqual([])
|
||||
// The split native jobs all resolve their pool through the Windows switch.
|
||||
for (const [jobName, job] of [['windows-build', windowsBuild], ['windows-coverage', windowsCoverage], ['windows-native-tests', windowsNativeTests]] as const) {
|
||||
expect(typeof job['runs-on']).toBe('string')
|
||||
@@ -414,7 +478,7 @@ describe('CI workflow', () => {
|
||||
expect(nativeTestCommand).toContain('tool-pwsh/tests/loader.spec.ts')
|
||||
expect(nativeTestCommand).toContain('workflow-ptc.spec.ts')
|
||||
|
||||
expect(workflow.jobs['windows-observational']).toBeUndefined()
|
||||
expect(windowsWorkflow.jobs['windows-observational']).toBeUndefined()
|
||||
expect(windowsBuild['continue-on-error']).not.toBe(true)
|
||||
const observational = buildCommands.find(step => step.id === 'observational')
|
||||
expect(observational).toMatchObject({
|
||||
@@ -473,10 +537,13 @@ describe('CI workflow', () => {
|
||||
expect(serialGate).toBeDefined()
|
||||
expect(serialGate!.env).toMatchObject({ DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' })
|
||||
|
||||
expect(aggregate.needs).not.toContain('windows')
|
||||
// windows-coverage is temporarily non-blocking while Windows ACP
|
||||
// half-close tests are stabilized; observational stays out too.
|
||||
expect(aggregate.needs).not.toContain('windows')
|
||||
expect(aggregate.needs).toContain('windows-build')
|
||||
expect(windowsAggregate.needs).toEqual(['windows-build', 'windows-native-tests'])
|
||||
expect(windowsAggregate.name).toBe('windows checks passed')
|
||||
expect(windowsAggregate['runs-on']).toBe(aggregate['runs-on'])
|
||||
expect(windowsAggregate.if).toBe(aggregate.if)
|
||||
// The benchmark lane is a required verdict input and runs alone so its
|
||||
// wall-clock budgets never share a runner with a concurrent aggregate.
|
||||
expect(aggregate.needs).toContain('node-24-bench')
|
||||
@@ -492,10 +559,7 @@ describe('CI workflow', () => {
|
||||
env: { DSH_GATE_VERBOSE: '1' },
|
||||
run: 'pnpm run check:ci:bench',
|
||||
})
|
||||
expect(aggregate.needs).not.toContain('windows-coverage')
|
||||
expect(aggregate.needs).toContain('windows-native-tests')
|
||||
expect(aggregate.needs).not.toContain('windows-observational')
|
||||
expect(aggregate.needs).not.toContain('serial-windows')
|
||||
expect(windowsAggregate.needs).not.toContain('serial-windows')
|
||||
|
||||
// Linux failover is a separate switch: the three enterprise Linux workers
|
||||
// and the verdict job resolve their pool through DSH_CI_FAILOVER_LINUX,
|
||||
@@ -610,12 +674,19 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('runs required benchmarks on standard hosted Linux independently of failover', () => {
|
||||
it('runs required benchmarks on a four-vCPU Blacksmith runner and falls back to hosted Linux', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
const benchmark = workflowJob(workflow, 'node-24-bench')
|
||||
const aggregate = workflowJob(workflow, 'all-checks-passed')
|
||||
|
||||
expect(benchmark['runs-on']).toBe('ubuntu-24.04')
|
||||
// The shared self-hosted pool is not an idle runner, so only the
|
||||
// Blacksmith leg moves the benchmarks off hosted Linux.
|
||||
expect(evaluateRunsOn(benchmark['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: 'blacksmith' } }))
|
||||
.toBe('blacksmith-4vcpu-ubuntu-2404')
|
||||
for (const mode of ['', 'selfhosted', 'unexpected']) {
|
||||
expect(evaluateRunsOn(benchmark['runs-on'], { vars: { DSH_CI_FAILOVER_LINUX: mode } })).toBe('ubuntu-24.04')
|
||||
}
|
||||
expect(JSON.stringify(benchmark.steps)).not.toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(benchmark.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(benchmark.needs).toBeUndefined()
|
||||
expect(benchmark['continue-on-error']).toBeUndefined()
|
||||
@@ -678,7 +749,7 @@ describe('CI workflow', () => {
|
||||
'wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host',
|
||||
)
|
||||
const aggregate = wineGates.indexOf(
|
||||
'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false',
|
||||
'wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=8192 "$tsc_js" -p tsconfig.host.json --pretty false',
|
||||
)
|
||||
const metadata = wineGates.indexOf('wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts')
|
||||
expect(metadata).toBeGreaterThan(-1)
|
||||
@@ -712,6 +783,18 @@ describe('CI workflow', () => {
|
||||
}
|
||||
expect(Object.keys(workflow.on).sort()).toEqual(['push', 'workflow_dispatch'])
|
||||
expect(Object.keys(prWorkflow.on)).toEqual(['pull_request'])
|
||||
const windowsWorkflow = loadWorkflow('.github/workflows/ci-windows.yml')
|
||||
if (!isRecord(windowsWorkflow.on)) throw new TypeError('CI Windows workflow must define on')
|
||||
expect(Object.keys(windowsWorkflow.on)).toEqual(['pull_request'])
|
||||
expect(windowsWorkflow.concurrency).toEqual(workflow.concurrency)
|
||||
// Duplicated env must not drift: a Node bump on one lane only would run
|
||||
// Linux and Windows against different versions.
|
||||
expect(windowsWorkflow.env).toEqual(prWorkflow.env)
|
||||
const pythonWorkflow = loadWorkflow('.github/workflows/ci-python.yml')
|
||||
if (!isRecord(pythonWorkflow.on)) throw new TypeError('CI Python workflow must define on')
|
||||
expect(Object.keys(pythonWorkflow.on)).toEqual(['pull_request'])
|
||||
expect(pythonWorkflow.concurrency).toEqual(workflow.concurrency)
|
||||
expect(pythonWorkflow.env).toEqual(prWorkflow.env)
|
||||
|
||||
// Drills share the parent run’s supersession policy.
|
||||
for (const name of ['serial-linux-selfhosted', 'serial-windows']) {
|
||||
@@ -789,27 +872,29 @@ describe('CI workflow', () => {
|
||||
expect(config).not.toContain('packages/lsp/lsp-stdio/src/instance.ts')
|
||||
})
|
||||
|
||||
it('requires release-shaped Python runtime validation on Linux and Windows x64', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
it('requires release-shaped Python runtime validation on Linux and Windows x64 in CI Python', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci-python.yml')
|
||||
const pythonRuntime = workflowJob(workflow, 'python-runtime')
|
||||
const aggregate = workflowJob(workflow, 'all-checks-passed')
|
||||
const aggregate = workflowJob(workflow, 'python-checks-passed')
|
||||
if (!Array.isArray(aggregate.needs)) {
|
||||
throw new TypeError('CI aggregate must define required job dependencies')
|
||||
throw new TypeError('CI Python aggregate must define required job dependencies')
|
||||
}
|
||||
|
||||
expect(pythonRuntime).toMatchObject({
|
||||
if: "github.event_name == 'pull_request'",
|
||||
name: 'python runtime / release-shaped matrix',
|
||||
uses: './.github/workflows/build-exe-for-python-sdk.yml',
|
||||
with: {
|
||||
targets: 'node24-linux-x64,node24-win-x64',
|
||||
ci: true,
|
||||
},
|
||||
with: { targets: 'node24-linux-x64,node24-win-x64', ci: true },
|
||||
secrets: {
|
||||
DEEPSEEK_API_KEY_EXTERNAL: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}',
|
||||
},
|
||||
})
|
||||
expect(aggregate.needs).toContain('python-runtime')
|
||||
expect(aggregate.needs).toEqual(['python-sdk', 'python-runtime'])
|
||||
for (const file of ['ci.yml', 'ci-windows.yml']) {
|
||||
const other = loadWorkflow('.github/workflows/' + file)
|
||||
if (!isRecord(other.jobs)) throw new TypeError(`${file} must define jobs`)
|
||||
expect(other.jobs).not.toHaveProperty('python-runtime')
|
||||
}
|
||||
})
|
||||
|
||||
it('builds SSH executables only for explicit manual or release requests', () => {
|
||||
@@ -1004,7 +1089,7 @@ describe('DeepSeek e2e workflow', () => {
|
||||
if (!Array.isArray(e2e.steps)) throw new TypeError('DeepSeek e2e workflow must define steps')
|
||||
|
||||
const step = e2e.steps.filter(isRecord).find(candidate => candidate.name === 'E2E tests (real DeepSeek API)')
|
||||
expect(step).toMatchObject({ env: { DSH_E2E_MAX_WORKERS: 4 } })
|
||||
expect(step).toMatchObject({ env: { DSH_E2E_MAX_WORKERS: 6 } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1257,7 +1342,7 @@ describe('Weighted approval workflow', () => {
|
||||
if (!Array.isArray(recordJob.steps)) throw new TypeError('weighted-approval review event job must define steps')
|
||||
const steps = job.steps.filter(isRecord)
|
||||
const checkout = steps.find(step => step.name === 'Check out trusted approval policy')
|
||||
const publish = steps.find(step => step.name === 'Publish weighted approval decision')
|
||||
const publish = steps.find(step => step.name === 'Publish weighted approval status')
|
||||
const recordSteps = recordJob.steps.filter(isRecord)
|
||||
const record = recordSteps.find(step => step.name === 'Record review event')
|
||||
|
||||
@@ -1275,6 +1360,7 @@ describe('Weighted approval workflow', () => {
|
||||
checks: 'write',
|
||||
contents: 'read',
|
||||
'pull-requests': 'write',
|
||||
statuses: 'write',
|
||||
})
|
||||
expect(publisher.concurrency).toEqual({
|
||||
group: "weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}",
|
||||
@@ -1296,18 +1382,39 @@ describe('Weighted approval workflow', () => {
|
||||
'persist-credentials': false,
|
||||
},
|
||||
})
|
||||
const token = steps.find(step => step.id === 'decision-token')
|
||||
expect(token).toMatchObject({
|
||||
'continue-on-error': true,
|
||||
uses: 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1',
|
||||
with: {
|
||||
'client-id': '${{ vars.DSH_APPROVAL_APP_CLIENT_ID }}',
|
||||
'private-key': '${{ secrets.DSH_APPROVAL_APP_PRIVATE_KEY }}',
|
||||
owner: 'deepseek-harness',
|
||||
repositories: 'deepseek-harness',
|
||||
'permission-checks': 'write',
|
||||
},
|
||||
})
|
||||
const tokenIndex = steps.indexOf(token!)
|
||||
expect(tokenIndex).toBeGreaterThan(steps.indexOf(checkout!))
|
||||
expect(tokenIndex).toBeLessThan(steps.findIndex(step => step.id === 'revoke'))
|
||||
for (const step of steps.filter(step => typeof step.run === 'string' && step.run.startsWith('node .github/review-ownership/check-approval.mjs'))) {
|
||||
expect(step.env).toMatchObject({
|
||||
DECISION_TOKEN: '${{ steps.decision-token.outputs.token }}',
|
||||
DECISION_APP_SLUG: '${{ vars.DSH_APPROVAL_APP_SLUG }}',
|
||||
})
|
||||
}
|
||||
const setupIndex = steps.findIndex(step => typeof step.uses === 'string' && step.uses.startsWith('actions/setup-python@'))
|
||||
expect(steps[setupIndex]?.if).toBe("steps.revoke.outputs.active == 'true'")
|
||||
expect(steps[setupIndex]?.uses).toBe('actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1')
|
||||
const revokeIndex = steps.findIndex(step => step.id === 'revoke')
|
||||
expect(revokeIndex).toBeGreaterThan(steps.indexOf(checkout!))
|
||||
expect(revokeIndex).toBeLessThan(setupIndex)
|
||||
expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs revoke')
|
||||
expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs pending')
|
||||
expect(steps.at(-1)).toMatchObject({
|
||||
if: "(failure() || cancelled()) && steps.publish.outputs.concluded == ''",
|
||||
run: 'node .github/review-ownership/check-approval.mjs failure',
|
||||
if: "failure() && steps.revoke.outputs.active == 'true'",
|
||||
run: 'node .github/review-ownership/check-approval.mjs error',
|
||||
})
|
||||
const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk')
|
||||
const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci-python.yml'), 'python-sdk')
|
||||
expect(pythonJob.steps).toContainEqual({
|
||||
name: 'Test production blame scoring',
|
||||
run: "uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'",
|
||||
@@ -1317,7 +1424,6 @@ describe('Weighted approval workflow', () => {
|
||||
run: 'python3 -m pip install -r .github/review-ownership/requirements.txt',
|
||||
})
|
||||
expect(publish).toMatchObject({
|
||||
id: 'publish',
|
||||
if: "steps.revoke.outputs.active == 'true'",
|
||||
env: {
|
||||
GITHUB_TOKEN: '${{ github.token }}',
|
||||
@@ -1335,7 +1441,7 @@ describe('Weighted approval workflow', () => {
|
||||
expect(record?.run).toBe("echo 'Recorded a weighted approval review event.'")
|
||||
expect(recordSteps).toHaveLength(1)
|
||||
expect(JSON.stringify(publisher)).not.toContain('github.event.pull_request.head')
|
||||
expect(JSON.stringify(publisher)).not.toContain('secrets.')
|
||||
expect(JSON.stringify(publisher).match(/secrets\.[A-Za-z0-9_]+/gu)).toEqual(['secrets.DSH_APPROVAL_APP_PRIVATE_KEY'])
|
||||
expect(JSON.stringify(reviewEvent)).not.toContain('github.token')
|
||||
expect(JSON.stringify(reviewEvent)).not.toContain('secrets.')
|
||||
})
|
||||
@@ -1480,8 +1586,13 @@ describe('npm release workflows', () => {
|
||||
isRecord(step) && typeof step.run === 'string' ? [step.run] : [])
|
||||
|
||||
expect(Object.keys(workflow.on).sort()).toEqual(['pull_request', 'push', 'workflow_dispatch'])
|
||||
expect(commands).toContain('pnpm run verify-package-dependencies')
|
||||
expect(commands).toContain('pnpm run verify-npm-install-layout')
|
||||
// Both verifications run from one step so the lane pays their longer half.
|
||||
const combined = commands.find(command => command.includes('verify-package-dependencies'))
|
||||
expect(combined).toContain('verify-npm-install-layout')
|
||||
expect(combined).toContain('wait "$dependencies_pid"')
|
||||
expect(commands).not.toContain('pnpm run verify-package-dependencies')
|
||||
expect(commands).not.toContain('pnpm run verify-npm-install-layout')
|
||||
expect(String(dependencies['runs-on'])).toContain('blacksmith-4vcpu-ubuntu-2404')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+39
-1
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -27,6 +27,11 @@ function addProject(root: string, path: string, outDir = 'lib/types'): void {
|
||||
write(join(root, path, 'src/index.ts'), 'export {}\n')
|
||||
}
|
||||
|
||||
function link(target: string, path: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
symlinkSync(target, path, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
@@ -82,6 +87,39 @@ describe('RepositoryCleaner', () => {
|
||||
expect(existsSync(join(root, 'native/system/tsconfig.tsbuildinfo'))).toBe(false)
|
||||
})
|
||||
|
||||
it('removes pnpm links to deleted or cleaned packages and keeps live links', async () => {
|
||||
const root = fixture()
|
||||
addProject(root, 'packages/core/live')
|
||||
write(join(root, 'packages/core/live/package.json'), '{}')
|
||||
write(join(root, 'packages/hooks/stale/lib/index.js'))
|
||||
const hoisted = join(root, 'node_modules/.pnpm/node_modules')
|
||||
link(join(root, 'packages/core/live'), join(hoisted, '@deepseek-ai/dsh-live'))
|
||||
link(join(root, 'packages/hooks/gone'), join(hoisted, '@deepseek-ai/dsh-gone'))
|
||||
link(join(root, 'packages/hooks/stale'), join(hoisted, '@deepseek-ai/dsh-stale'))
|
||||
link(join(root, 'packages/hooks/plain'), join(hoisted, 'plain'))
|
||||
link(join(hoisted, 'loop'), join(hoisted, 'loop'))
|
||||
link(join(root, 'packages/hooks/top'), join(root, 'node_modules/@deepseek-ai/dsh-top'))
|
||||
|
||||
await new RepositoryCleaner(root).clean()
|
||||
|
||||
expect(readdirSync(join(hoisted, '@deepseek-ai'))).toEqual(['dsh-live'])
|
||||
expect(readdirSync(hoisted)).toEqual(['@deepseek-ai'])
|
||||
expect(readdirSync(join(root, 'node_modules/@deepseek-ai'))).toEqual([])
|
||||
expect(existsSync(join(hoisted, '@deepseek-ai/dsh-live/package.json'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not scan node_modules outside the repository', async () => {
|
||||
const root = fixture()
|
||||
const externalModules = fixture()
|
||||
addProject(root, 'products/shell')
|
||||
link(join(root, 'packages/hooks/gone'), join(externalModules, 'gone'))
|
||||
link(externalModules, join(root, 'node_modules'))
|
||||
|
||||
await new RepositoryCleaner(root).clean()
|
||||
|
||||
expect(readdirSync(externalModules)).toEqual(['gone'])
|
||||
})
|
||||
|
||||
it('refuses project outputs reached through a symlink outside the repository', async () => {
|
||||
const root = fixture()
|
||||
const externalProject = fixture()
|
||||
|
||||
+56
-6
@@ -1,3 +1,4 @@
|
||||
import { type Dirent } from 'node:fs'
|
||||
import { lstat, readdir, realpath, rm } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -6,8 +7,12 @@ import { repositoryConfigHost } from './ts-project.ts'
|
||||
|
||||
const knownOrphanEntries = new Set(['node_modules', 'lib', '.typecheck'])
|
||||
|
||||
function errorCode(error: unknown): unknown {
|
||||
return error instanceof Error && 'code' in error ? error.code : undefined
|
||||
}
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
return errorCode(error) === 'ENOENT'
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
@@ -20,16 +25,48 @@ async function exists(path: string): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
async function childDirectories(path: string): Promise<string[]> {
|
||||
async function directoryEntries(path: string): Promise<Dirent[]> {
|
||||
try {
|
||||
const entries = await readdir(path, { withFileTypes: true })
|
||||
return entries.filter(entry => entry.isDirectory()).map(entry => join(path, entry.name))
|
||||
return await readdir(path, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function childDirectories(path: string): Promise<string[]> {
|
||||
return (await directoryEntries(path)).filter(entry => entry.isDirectory()).map(entry => join(path, entry.name))
|
||||
}
|
||||
|
||||
/** Lists symbolic links directly in a node_modules directory and in its `@scope` directories. */
|
||||
async function moduleLinks(modulesDirectory: string): Promise<string[]> {
|
||||
const links = (directory: string, entries: Dirent[]): string[] =>
|
||||
entries.filter(entry => entry.isSymbolicLink()).map(entry => join(directory, entry.name))
|
||||
const entries = await directoryEntries(modulesDirectory)
|
||||
const result = links(modulesDirectory, entries)
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || !entry.name.startsWith('@')) continue
|
||||
const scope = join(modulesDirectory, entry.name)
|
||||
result.push(...links(scope, await directoryEntries(scope)))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Resolves a link's canonical target, or undefined when the target cannot be reached. */
|
||||
async function linkTarget(link: string): Promise<string | undefined> {
|
||||
try {
|
||||
return await realpath(link)
|
||||
} catch (error) {
|
||||
if (['ENOENT', 'ENOTDIR', 'ELOOP'].includes(String(errorCode(error)))) return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function isDescendant(root: string, path: string): boolean {
|
||||
const relativePath = relative(root, path)
|
||||
return relativePath !== '' && relativePath !== '..' && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath)
|
||||
}
|
||||
|
||||
function repositoryPath(root: string, path: string): string {
|
||||
return relative(root, path).split(sep).join('/')
|
||||
}
|
||||
@@ -108,6 +145,20 @@ export class RepositoryCleaner {
|
||||
}
|
||||
}
|
||||
|
||||
// pnpm install keeps links to deleted workspace packages. A link is residue when its
|
||||
// target is unreachable or is removed by this plan; node_modules outside the repository is not scanned.
|
||||
const removedPaths = (await Promise.all([...targets].map(linkTarget))).filter(path => path !== undefined)
|
||||
for (const modulesDirectory of ['node_modules', 'node_modules/.pnpm/node_modules']) {
|
||||
const canonicalModules = await linkTarget(join(this.root, modulesDirectory))
|
||||
if (canonicalModules === undefined || !isDescendant(canonicalRoot, canonicalModules)) continue
|
||||
for (const link of await moduleLinks(join(this.root, modulesDirectory))) {
|
||||
const target = await linkTarget(link)
|
||||
if (target === undefined || removedPaths.some(path => path === target || isDescendant(path, target))) {
|
||||
await this.addIfPresent(targets, link, canonicalRoot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (unsafeOrphans.length > 0) {
|
||||
throw new Error([
|
||||
'clean: refusing to remove package directories without package.json; unknown entries remain:',
|
||||
@@ -159,8 +210,7 @@ export class RepositoryCleaner {
|
||||
}
|
||||
|
||||
private assertDescendant(root: string, path: string, displayPath: string): void {
|
||||
const repositoryRelative = relative(root, path)
|
||||
if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`) || isAbsolute(repositoryRelative)) {
|
||||
if (!isDescendant(root, path)) {
|
||||
throw new Error(`clean: refusing deletion target outside repository: ${displayPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ const roots: string[] = []
|
||||
const dshBuildWorkflows = [
|
||||
'build-exe-for-python-sdk.yml',
|
||||
'ci.yml',
|
||||
'ci-python.yml',
|
||||
'ci-windows.yml',
|
||||
'e2e.yml',
|
||||
'release.yml',
|
||||
'release-publish.yml',
|
||||
|
||||
@@ -234,6 +234,14 @@ describe('client bundle purity gate', () => {
|
||||
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('inlines the stateless file-drag entry without admitting its subpaths or UI plugin', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-util-file-drag')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-util-file-drag/internal')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-client-ui-file-drag')).toThrow(/purity/)
|
||||
const deps = clientConfigs()[0]?.deps as { neverBundle: (specifier: string) => boolean }
|
||||
expect(deps.neverBundle('@deepseek-ai/dsh-util-file-drag')).toBe(false)
|
||||
})
|
||||
|
||||
it('inlines the browser-safe Timer entry while sharing Cordis through the module table', () => {
|
||||
const timer = '@deepseek-ai/cordis-plugin-timer'
|
||||
expect(resolveId(timer)).toBeNull()
|
||||
|
||||
@@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest'
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
function clientCssDeclarations(): string[] {
|
||||
const clientGroups = ['client', 'extensions']
|
||||
const clientGroups = ['client', 'extensions', 'experimental']
|
||||
return clientGroups.flatMap((group) => {
|
||||
const clientRoot = resolve(root, 'packages', group)
|
||||
return readdirSync(clientRoot, { withFileTypes: true })
|
||||
|
||||
@@ -48,7 +48,7 @@ export function runPnpmCommand(root: string, args: readonly string[]): void {
|
||||
* @param root - Repository root containing the aggregate tsconfig.
|
||||
* @param face - Independent compiler face whose package projects are emitted.
|
||||
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
|
||||
* @returns Arguments for `process.execPath`, including the heap limit the Host face needs.
|
||||
* @returns Arguments for `process.execPath`, including an 8 GiB old-space heap limit for the Host face.
|
||||
* @throws If the aggregate configuration is invalid, has no references, or can emit files.
|
||||
*/
|
||||
export function referencedProjectsCompilerArgs(
|
||||
@@ -109,7 +109,7 @@ export function aggregateBuildArgs(face: 'host' | 'client'): string[] {
|
||||
}
|
||||
|
||||
function hostHeapArgs(face: 'host' | 'client'): string[] {
|
||||
return face === 'host' ? ['--max-old-space-size=4096'] : []
|
||||
return face === 'host' ? ['--max-old-space-size=8192'] : []
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,10 +166,12 @@ function pnpmStep(id: LibraryStepId, label: string, args: readonly string[], nee
|
||||
/**
|
||||
* List the package-library build steps in Host, Desktop, then Client dependency order.
|
||||
* Source-only Typert generation supplies declarations before any package compilation.
|
||||
* Client compilation waits for Host and Desktop bundling because split compiler faces
|
||||
* rewrite shared lib/types files consumed by those bundles.
|
||||
* @param root - Repository root containing the compiler and bundle configurations.
|
||||
* @param faces - `host` lists Typert generation, Host compilation, and bundling; `all` adds Desktop and Client.
|
||||
* @param mode - Check package types by default; benchmark emission defers diagnostics to required full builds.
|
||||
* @returns The ordered steps; each step needs its predecessor.
|
||||
* @returns Steps with explicit compiler and bundle dependencies.
|
||||
* @throws If an aggregate configuration is invalid.
|
||||
*/
|
||||
export function libraryBuildSteps(root: string, faces: 'host' | 'all', mode: CompilationMode = 'checked'): LibraryStep[] {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/** Raw retention and candidate evaluation preserve the running plugin's config identity. */
|
||||
import { Context, type Fiber } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
|
||||
function root(): Context {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('Fiber configuration access', () => {
|
||||
it('keeps raw and running references unchanged while resolving valid or invalid candidates', async () => {
|
||||
const ctx = root()
|
||||
await ctx.plugin(Loader)
|
||||
const Config = z.object({ value: z.number().min(0).volatile() })
|
||||
const applied = vi.fn()
|
||||
ctx.loader.builtins.consumer = { Config, apply: applied }
|
||||
const raw = { value: { __jsExpr: '1 + 1' } }
|
||||
const id = await ctx.loader.create({ name: 'cordis:consumer', config: raw })
|
||||
const fiber = ctx.loader.resolve(id).fiber!
|
||||
await fiber.await()
|
||||
const running = applied.mock.calls[0]![1] as ReturnType<typeof Config>
|
||||
expect(fiber.rawConfig).toBe(raw)
|
||||
expect(fiber.effectiveConfig).toBe(running)
|
||||
const candidate = fiber.resolveConfig({ value: { __jsExpr: '2 + 2' } }) as ReturnType<typeof Config>
|
||||
expect(candidate.value.get()).toBe(4)
|
||||
expect(candidate.value).not.toBe(running.value)
|
||||
expect(() => fiber.resolveConfig({ value: -1 })).toThrow()
|
||||
expect(fiber.rawConfig).toBe(raw)
|
||||
expect(fiber.effectiveConfig).toBe(running)
|
||||
expect(running.value.get()).toBe(2)
|
||||
expect(applied).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the canonical fiber through registry wrappers and retains raw input without updating', async () => {
|
||||
const ctx = root()
|
||||
const initial = { value: 1 }
|
||||
const apply = vi.fn()
|
||||
const wrapper = ctx.plugin(apply, initial)
|
||||
await wrapper
|
||||
const fiber = wrapper.ctx.fiber
|
||||
const hook = vi.fn(function (this: Fiber, _config: unknown, next: () => unknown) {
|
||||
expect(this).toBe(fiber)
|
||||
return next()
|
||||
})
|
||||
fiber.ctx.on('internal/config', hook)
|
||||
const raw = { value: 2 }
|
||||
wrapper.rawConfig = raw
|
||||
expect(fiber.rawConfig).toBe(raw)
|
||||
expect(wrapper.effectiveConfig).toBe(initial)
|
||||
expect(wrapper.resolveConfig(raw)).toBe(raw)
|
||||
expect(hook).toHaveBeenCalledOnce()
|
||||
expect(fiber.effectiveConfig).toBe(initial)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
await wrapper.restart()
|
||||
expect(fiber.effectiveConfig).toBe(raw)
|
||||
expect(apply).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('publishes updates through registry wrappers to the canonical fiber', async () => {
|
||||
const ctx = root()
|
||||
const initial = { value: 1 }
|
||||
const apply = vi.fn()
|
||||
const wrapper = ctx.plugin(apply, initial)
|
||||
await wrapper
|
||||
const raw = { value: 2 }
|
||||
wrapper.update(raw)
|
||||
await wrapper.await()
|
||||
expect(apply).toHaveBeenLastCalledWith(wrapper.ctx, raw)
|
||||
expect(wrapper.rawConfig).toBe(raw)
|
||||
expect(wrapper.effectiveConfig).toBe(raw)
|
||||
expect(wrapper.ctx.fiber.rawConfig).toBe(raw)
|
||||
expect(wrapper.ctx.fiber.effectiveConfig).toBe(raw)
|
||||
expect(apply).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('retains a pending raw update until its injection is available', async () => {
|
||||
const ctx = root()
|
||||
ctx.provide('configAccessTest')
|
||||
const apply = vi.fn()
|
||||
const wrapper = ctx.plugin({ inject: ['configAccessTest'], apply }, { value: 1 })
|
||||
const raw = { value: 2 }
|
||||
wrapper.update(raw)
|
||||
expect(wrapper.rawConfig).toBe(raw)
|
||||
expect(wrapper.effectiveConfig).toBeUndefined()
|
||||
ctx.set('configAccessTest', {})
|
||||
await wrapper
|
||||
expect(wrapper.effectiveConfig).toBe(raw)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Loader resolves configuration imports in their project when Node internals are unavailable. */
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { existsSync } from 'node:fs'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { createResolve } from '../vendor/loader/src/resolve.ts'
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
const mkdir = vi.fn(actual.mkdir)
|
||||
return { ...actual, mkdir, default: { ...actual, mkdir } }
|
||||
})
|
||||
|
||||
async function fixture() {
|
||||
const root = await fs.mkdtemp(join(tmpdir(), 'dsh-cordis-resolve-'))
|
||||
onTestFinished(() => fs.rm(root, { recursive: true, force: true }))
|
||||
await fs.writeFile(join(root, 'package.json'), JSON.stringify({
|
||||
name: 'fixture-app', type: 'module',
|
||||
imports: { '#local': './local.js' }, exports: { './sub': './sub.js' },
|
||||
}))
|
||||
await fs.writeFile(join(root, 'local.js'), 'export const tag = "subpath-import"\n')
|
||||
await fs.writeFile(join(root, 'sub.js'), 'export const tag = "self-reference"\n')
|
||||
const dep = join(root, 'node_modules', 'fixture-dep')
|
||||
await fs.mkdir(dep, { recursive: true })
|
||||
await fs.writeFile(join(dep, 'package.json'), JSON.stringify({
|
||||
name: 'fixture-dep', type: 'module',
|
||||
exports: { '.': { import: './index.js' }, './sub': { import: './sub.js' } },
|
||||
}))
|
||||
await fs.writeFile(join(dep, 'index.js'), 'export const tag = "bare-dep"\n')
|
||||
await fs.writeFile(join(dep, 'sub.js'), 'export const tag = "dependency-subpath"\n')
|
||||
await fs.mkdir(join(root, 'src'))
|
||||
await fs.writeFile(join(root, 'src', 'plugin.js'), 'export const tag = "relative"\n')
|
||||
return { root, baseUrl: pathToFileURL(join(root, 'src', 'cordis.yml')).href }
|
||||
}
|
||||
|
||||
async function treeFor(baseUrl: string) {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(Loader, { baseUrl })
|
||||
ctx.loader.internal = undefined
|
||||
return ctx.loader.root.tree
|
||||
}
|
||||
|
||||
describe('Loader project resolution', () => {
|
||||
it('loads import-only dependencies, subpaths and package mappings from the config project', async () => {
|
||||
const { root, baseUrl } = await fixture()
|
||||
const tree = await treeFor(baseUrl)
|
||||
for (const [specifier, tag] of [
|
||||
['fixture-dep', 'bare-dep'], ['fixture-dep/sub', 'dependency-subpath'],
|
||||
['#local', 'subpath-import'], ['fixture-app/sub', 'self-reference'],
|
||||
] as const) {
|
||||
expect(await tree.import(specifier)).toMatchObject({ tag })
|
||||
}
|
||||
expect(existsSync(join(root, '.cordis', 'resolve.mjs'))).toBe(true)
|
||||
expect(existsSync(join(root, 'src', '.cordis'))).toBe(false)
|
||||
expect(await fs.readFile(join(root, '.cordis', '.gitignore'), 'utf8')).toContain('*')
|
||||
await expect(tree.import('fixture-dep/missing')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('keeps project scopes independent and shares concurrent resolution within one scope', async () => {
|
||||
const first = await fixture()
|
||||
const second = await fixture()
|
||||
const [a, b, c] = await Promise.all([
|
||||
createResolve(first.baseUrl),
|
||||
createResolve(pathToFileURL(join(first.root, 'cordis.yml')).href),
|
||||
createResolve(second.baseUrl),
|
||||
])
|
||||
expect(a).toBeTypeOf('function')
|
||||
expect(a).toBe(b)
|
||||
expect(a?.('fixture-dep')).not.toBe(c?.('fixture-dep'))
|
||||
})
|
||||
|
||||
it('imports relative, URL and builtin specifiers without changing their identity', async () => {
|
||||
const { root, baseUrl } = await fixture()
|
||||
const tree = await treeFor(baseUrl)
|
||||
expect(await tree.import('./plugin.js')).toMatchObject({ tag: 'relative' })
|
||||
expect(existsSync(join(root, '.cordis'))).toBe(false)
|
||||
expect(await tree.import(pathToFileURL(join(root, 'local.js')).href)).toMatchObject({ tag: 'subpath-import' })
|
||||
expect(await tree.import('node:path')).toMatchObject({ join })
|
||||
await expect(tree.import('./missing.js')).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('retains native import when helper creation is denied', async () => {
|
||||
const { root, baseUrl } = await fixture()
|
||||
const tree = await treeFor(baseUrl)
|
||||
const denied = vi.mocked(fs.mkdir).mockRejectedValueOnce(Object.assign(new Error('read-only project'), { code: 'EACCES' }))
|
||||
onTestFinished(() => { denied.mockReset() })
|
||||
expect(await createResolve(baseUrl)).toBeUndefined()
|
||||
expect(await tree.import('node:path')).toMatchObject({ join })
|
||||
await expect(tree.import('fixture-dep')).rejects.toThrow()
|
||||
expect(existsSync(join(root, '.cordis'))).toBe(false)
|
||||
})
|
||||
|
||||
it('retains native import without a file-backed package scope', async () => {
|
||||
expect(await createResolve(undefined)).toBeUndefined()
|
||||
expect(await createResolve('https://example.com/cordis.yml')).toBeUndefined()
|
||||
const tree = await treeFor('https://example.com/cordis.yml')
|
||||
expect(await tree.import('node:path')).toMatchObject({ join })
|
||||
})
|
||||
})
|
||||
@@ -10,9 +10,10 @@ const child = spawn(process.execPath, [
|
||||
'apps/cli/src/bin.ts',
|
||||
'--profile',
|
||||
'headless',
|
||||
'--patch',
|
||||
'apps/cli/config/examples/ptc.yml',
|
||||
task,
|
||||
], {
|
||||
stdio: 'inherit',
|
||||
env: { ...process.env, DSH_TOOLS_MODE: 'ptc' },
|
||||
})
|
||||
child.on('exit', (code, signal) => { process.exit(signal !== null ? 1 : code ?? 1) })
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"AGENTS.md": 2080,
|
||||
"docs/AGENTS.md": 1430,
|
||||
"docs/architecture.md": 2430,
|
||||
"docs/architecture.md": 2410,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/testing.md": 1350,
|
||||
"packages/AGENTS.md": 810,
|
||||
|
||||
@@ -55,9 +55,11 @@ const KIND_TEMPLATES: Readonly<Record<string, string>> = {
|
||||
* library; the check re-derives the entry shape so a stale entry fails loud.
|
||||
*/
|
||||
const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/util/file-drag': 'File naming and native transfer declarations without a plugin entry.',
|
||||
'packages/experimental/browser-use-runtime': 'Provider-owned browser resource management and MCP integration helpers; no plugin entry.',
|
||||
'packages/boot/app-boot': 'Boot library the app bins import; plain helper exports.',
|
||||
'packages/boot/cmdline': 'Command-line library the app bins import; plain module exports.',
|
||||
'packages/util/official-bundles': 'Bundle-list data library the launcher and plugin manager import; plain constant exports.',
|
||||
'packages/client/store': 'Browser-side state primitives; plain function/type exports.',
|
||||
'packages/client/ui-primitives': 'Browser-side UI component library; plain component exports.',
|
||||
'packages/client/ui-slots': 'Browser-side slot-map declarations; plain type exports.',
|
||||
@@ -66,6 +68,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/experimental/webworker-packer': 'Build-time VFS image packer and command library.',
|
||||
'packages/experimental/webworker-runtime': 'Browser worker runtime library with explicit host entry points.',
|
||||
'packages/experimental/hook-protocol': 'Shared wire-protocol library between the hook bridges.',
|
||||
'packages/experimental/liveshare-protocol': 'Shared wire frames and identity contract; plain module exports with no plugin entry.',
|
||||
'packages/identity/anonymous-user-id': 'Harness-home identity helper with no plugin registration.',
|
||||
'packages/sandbox/sandbox-windows-acl': 'Windows ACL sandbox library consumed by sandbox-local.',
|
||||
'packages/sdk/client': 'Client-process library; the spawned runtime owns plugin behavior.',
|
||||
@@ -81,6 +84,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
|
||||
'packages/subprocess/win32-process': 'Low-level Win32 process and Job Object primitives.',
|
||||
'packages/test-support/session-snapshot': 'Test infrastructure; mounts nothing into a product composition.',
|
||||
'packages/test-support/agent-loop-testkit': 'Test helper library; mounts nothing into a product composition.',
|
||||
'packages/test-support/tool-testkit': 'Tool fixture library; exports helpers without a plugin entry.',
|
||||
'packages/test-support/client-runtime': 'Browser-side test infrastructure.',
|
||||
'packages/test-support/llm-mock-server': 'Test server library; substitutes provider wire behavior.',
|
||||
'packages/test-support/loader-smoke': 'Test harness library; mounts nothing into a product composition.',
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { builtDeclarationPath, usesCurrentDocumentApi } from './doc-typecheck-paths.ts'
|
||||
import { builtDeclarationPath, isFrozenUpgradeGuidePath } from './doc-typecheck-paths.ts'
|
||||
|
||||
describe('frozen upgrade-guide examples', () => {
|
||||
it('excludes earlier release guides in both languages from current API compilation', () => {
|
||||
expect(isFrozenUpgradeGuidePath('docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.md', '0.2.1-rc.2')).toBe(true)
|
||||
expect(isFrozenUpgradeGuidePath('docs/upgrade-guide/v0.2.1-alpha.1/subagent-activations/guide.zh.md', '0.2.1-rc.2')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'docs/upgrade-guide/v0.2.1-rc.2/subagent-activations/guide.md',
|
||||
'docs/upgrade-guide/v0.2.1-rc.3/subagent-activations/guide.md',
|
||||
'docs/upgrade-guide/vinvalid/subagent-activations/guide.md',
|
||||
'docs/subsystems/subagent.md',
|
||||
])('keeps %s subject to current API compilation', (file) => {
|
||||
expect(isFrozenUpgradeGuidePath(file, '0.2.1-rc.2')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('builtDeclarationPath', () => {
|
||||
it('maps package source directories and exact entry files to built declarations', () => {
|
||||
@@ -17,11 +33,3 @@ describe('builtDeclarationPath', () => {
|
||||
.toThrow('cannot map workspace source path')
|
||||
})
|
||||
})
|
||||
|
||||
it('checks current guides and ordinary docs but preserves frozen release examples', () => {
|
||||
expect(usesCurrentDocumentApi('docs/upgrade-guide/v0.2.1-alpha.4/subagents/guide.md', '0.2.1-alpha.4')).toBe(true)
|
||||
expect(usesCurrentDocumentApi('docs/upgrade-guide/v0.2.1-alpha.1/subagents/guide.md', '0.2.1-alpha.4')).toBe(false)
|
||||
expect(usesCurrentDocumentApi('docs/upgrade-guide/v0.2.1-alpha.4/subagents/guide.zh.md', '0.2.1-alpha.4')).toBe(true)
|
||||
expect(usesCurrentDocumentApi('packages/subagent/subagent/README.md', '0.2.1-alpha.4')).toBe(true)
|
||||
expect(usesCurrentDocumentApi('docs\\upgrade-guide\\v0.2.0\\subagents\\guide.md', '0.2.1-alpha.4')).toBe(false)
|
||||
})
|
||||
@@ -1,3 +1,16 @@
|
||||
import { lt, valid } from 'semver'
|
||||
|
||||
/**
|
||||
* Identify frozen upgrade guides whose examples target an earlier release.
|
||||
* @param file - Repository-relative path normalized to forward slashes.
|
||||
* @param currentVersion - Current root package version.
|
||||
* @returns Whether the guide predates the current upgrade-guide directory.
|
||||
*/
|
||||
export function isFrozenUpgradeGuidePath(file: string, currentVersion: string): boolean {
|
||||
const version = /^docs\/upgrade-guide\/v([^/]+)\/[^/]+\/guide(?:\.zh)?\.md$/.exec(file)?.[1]
|
||||
return version !== undefined && valid(version) !== null && lt(version, currentVersion)
|
||||
}
|
||||
|
||||
/** Map one workspace source alias target to its declaration-build target. */
|
||||
export function builtDeclarationPath(candidate: string): string {
|
||||
// Two workspace path forms exist: whole-package entries end in /src, subpath
|
||||
@@ -22,14 +35,3 @@ export function builtDeclarationPath(candidate: string): string {
|
||||
}
|
||||
throw new Error(`doc-typecheck: cannot map workspace source path to built declarations: ${candidate}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude frozen release guides from checks against the current API.
|
||||
* @param path - repository-relative Markdown path, with either separator.
|
||||
* @param currentVersion - root package version whose upgrade guides remain editable.
|
||||
* @returns whether current workspace declarations apply to this document.
|
||||
*/
|
||||
export function usesCurrentDocumentApi(path: string, currentVersion: string): boolean {
|
||||
const guide = /^docs\/upgrade-guide\/v([^/]+)\//.exec(path.replaceAll('\\', '/'))
|
||||
return guide === null || guide[1] === currentVersion
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* Typecheck Markdown `ts` fences against the workspace API. `ignore-check` fences are reported as
|
||||
* opt-outs; generated catalog fragments and source-equivalence blocks are skipped here because their
|
||||
* owning gates verify them. Byte-identical `.zh.md` copies reuse their unsuffixed sibling's check. A
|
||||
* owning gates verify them. Frozen upgrade guides retain their release-specific examples.
|
||||
* Byte-identical `.zh.md` copies reuse their unsuffixed sibling's check. A
|
||||
* build-coordinated mode consumes existing declarations without emit.
|
||||
*/
|
||||
|
||||
@@ -9,7 +10,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import { builtDeclarationPath, usesCurrentDocumentApi } from './doc-typecheck-paths.ts'
|
||||
import { builtDeclarationPath, isFrozenUpgradeGuidePath } from './doc-typecheck-paths.ts'
|
||||
import { markdownFences } from './markdown.ts'
|
||||
import { partitionPairedMarkdownDerivatives } from './paired-markdown-derivatives.ts'
|
||||
import { isArchivedAgentNotePath } from './repo-files.ts'
|
||||
@@ -204,11 +205,12 @@ function remapBlockPaths(output: string, blocks: Block[]): string {
|
||||
|
||||
const markdownGlobs = ['README.md', '.agents/notes/**/*.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md']
|
||||
|
||||
const { version: currentVersion } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }
|
||||
const files: string[] = []
|
||||
const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string }
|
||||
for (const pattern of markdownGlobs) {
|
||||
for (const match of globSync(pattern, { cwd: root })) {
|
||||
if (!isArchivedAgentNotePath(match) && usesCurrentDocumentApi(match, currentVersion)) files.push(resolve(root, match))
|
||||
const file = match.replaceAll('\\', '/')
|
||||
if (!isArchivedAgentNotePath(file) && !isFrozenUpgradeGuidePath(file, version)) files.push(resolve(root, file))
|
||||
}
|
||||
}
|
||||
files.sort()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/** Experimental package identities, publication policy, and retained npm names. */
|
||||
|
||||
/** Explicit npm identities retained across experimental classification and package-role moves. */
|
||||
/**
|
||||
* Experimental packages whose npm names omit the experimental prefix, by directory: identities retained across
|
||||
* experimental classification and package-role moves, and packages named without the prefix so that leaving
|
||||
* experimental status keeps their names.
|
||||
*/
|
||||
export const EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
'packages/experimental/cua': '@deepseek-ai/dsh-cua',
|
||||
'packages/experimental/cua-bundle': '@deepseek-ai/dsh-cua-bundle',
|
||||
@@ -14,6 +18,13 @@ export const EXPERIMENTAL_PACKAGE_NAME_EXCEPTIONS: Readonly<Record<string, strin
|
||||
'packages/experimental/hooks-codex': '@deepseek-ai/dsh-hooks-codex',
|
||||
'packages/experimental/webhook': '@deepseek-ai/dsh-webhook',
|
||||
'packages/experimental/webhook-github': '@deepseek-ai/dsh-webhook-github',
|
||||
'packages/experimental/action-cards': '@deepseek-ai/dsh-action-cards',
|
||||
'packages/experimental/client-ui-action-cards': '@deepseek-ai/dsh-client-ui-action-cards',
|
||||
'packages/experimental/creation-recommendations': '@deepseek-ai/dsh-creation-recommendations',
|
||||
'packages/experimental/client-ui-creation-recommendations': '@deepseek-ai/dsh-client-ui-creation-recommendations',
|
||||
'packages/experimental/plugin-recommendations': '@deepseek-ai/dsh-plugin-recommendations',
|
||||
'packages/experimental/client-ui-plugin-recommendations': '@deepseek-ai/dsh-client-ui-plugin-recommendations',
|
||||
'packages/experimental/personalized-recommendations': '@deepseek-ai/dsh-personalized-recommendations',
|
||||
} satisfies Record<`packages/experimental/${string}`, `@deepseek-ai/dsh-${string}`>
|
||||
|
||||
/** npm namespace for experimental packages without retained-name exceptions. */
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
/** Add a native tool after a file read and unregister it after its first execution. */
|
||||
/** Activate a native tool after a file read and disable it after its first execution. */
|
||||
export const name = 'python-sdk-dynamic-tools'
|
||||
export const inject = ['tools']
|
||||
|
||||
/** @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-owned tool registry. */
|
||||
export function apply(ctx) {
|
||||
let removeTool
|
||||
let active = false
|
||||
ctx.tools.register({
|
||||
name: 'snapshot_ping',
|
||||
description: 'Return pong once.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) { return [{ type: 'text', text: value }] },
|
||||
},
|
||||
async execute() { return 'pong' },
|
||||
}, { when: async ({ modelSelection }) => active && modelSelection.provider === 'deepseek-official' })
|
||||
ctx.on('tools/post-execute', async (exec, result, next) => {
|
||||
const downstream = await next()
|
||||
if (result.isError) return downstream
|
||||
if (exec.name === 'read' && exec.arguments.file_path === 'dynamic-tool-task.txt') {
|
||||
removeTool = ctx.effect(() => ctx.tools.register({
|
||||
name: 'snapshot_ping',
|
||||
description: 'Return pong once.',
|
||||
parameters: { type: 'object', properties: {}, additionalProperties: false },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render(_args, value) { return [{ type: 'text', text: value }] },
|
||||
},
|
||||
async execute() { return 'pong' },
|
||||
}))
|
||||
active = true
|
||||
} else if (exec.name === 'snapshot_ping') {
|
||||
removeTool?.()
|
||||
removeTool = undefined
|
||||
active = false
|
||||
}
|
||||
return downstream
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-app-boot'
|
||||
import { PROFILE_TEMPLATES } from '@deepseek-ai/dsh-official-bundles'
|
||||
import {
|
||||
computeTranslationPairingRecord,
|
||||
renderTranslationPairingRecord,
|
||||
|
||||
@@ -48,9 +48,9 @@ const MAX_DECL_CHARS = 1200
|
||||
* share that hands down a subsystem instead of a share, and prose that grew
|
||||
* into a manual. Every report also lists one line per standard prop seat of its
|
||||
* scope, so the widest Session-scope slot (`tool.call.toolview`, with the
|
||||
* global and Session access seats) sets the current 122.
|
||||
* global and Session access seats) sets the current 123.
|
||||
*/
|
||||
const MAX_ENTRY_LINES = 122
|
||||
const MAX_ENTRY_LINES = 123
|
||||
|
||||
/** One register-call option as the catalog teaches it. */
|
||||
interface OptionDoc {
|
||||
|
||||
@@ -60,6 +60,8 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
productAnalytics: 'product-telemetry.md',
|
||||
connection: 'web-server.md',
|
||||
pluginManager: 'boot.md',
|
||||
actionCards: 'boot.md',
|
||||
pluginRecommendations: 'boot.md',
|
||||
pluginMarket: 'boot.md',
|
||||
pluginRegistryProbe: 'boot.md',
|
||||
configEditor: 'boot.md',
|
||||
@@ -67,7 +69,6 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
hmr: 'boot.md',
|
||||
mcpResources: 'mcp.md',
|
||||
agentLoop: 'core.md',
|
||||
agentController: 'core.md',
|
||||
agentDefaultModel: 'core.md',
|
||||
agentPresets: 'core.md',
|
||||
agents: 'core.md',
|
||||
@@ -128,6 +129,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
storage: 'storage.md',
|
||||
storageDomain: 'storage.md',
|
||||
subagentModelSelection: 'subagent.md',
|
||||
subagentNames: 'subagent.md',
|
||||
subagents: 'subagent.md',
|
||||
subprocess: 'subprocess.md',
|
||||
systemPrompt: 'system-prompt.md',
|
||||
@@ -194,10 +196,12 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
shortcuts: 'client-side interface-typed keyboard service — packages/client/shortcuts/README.md owns the API',
|
||||
userQuestionPanels: 'client-side slot-contract accessor (UserQuestionPanels) — packages/client/ui-tool/README.md owns the API',
|
||||
commandUi: 'client-side interface-typed browser service — packages/client/ui-commands/README.md owns the API',
|
||||
fileDrag: 'client-side file preparation and transfer service — packages/client/ui-file-drag/README.md owns the API',
|
||||
feedbackUi: 'client-side feedback dialog service — packages/client/ui-message-feedback/README.md owns the API',
|
||||
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API',
|
||||
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API',
|
||||
pluginNavigation: 'client-side bundle navigation — packages/client/ui-plugin-manager/README.md owns the API',
|
||||
pluginChanges: 'client-side read-only management activity — packages/client/ui-plugin-manager/README.md owns the API',
|
||||
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API',
|
||||
modelDirectories: 'client-side interface-typed browser service — packages/client/ui-model-selection/README.md owns the API',
|
||||
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the API',
|
||||
@@ -226,7 +230,6 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
'plugin-manager': 'boot.md',
|
||||
'plugin-market': 'boot.md',
|
||||
'agent': 'core.md',
|
||||
'agent-controller': 'core.md',
|
||||
'agent-loop': 'core.md',
|
||||
'agent-preset': 'core.md',
|
||||
'api-session': 'session.md',
|
||||
@@ -270,6 +273,7 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
|
||||
* exemption cannot mask another declaration in that scope.
|
||||
*/
|
||||
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
'agent/request': 'deprecated compatibility hook — packages/core/agent/README.md documents migration',
|
||||
'command/executed': 'client-face local command acknowledgment — packages/client/ui-commands/README.md owns the API',
|
||||
'connection/reset': 'client-face transport signal — packages/api/session-controller/README.md owns the API',
|
||||
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the API',
|
||||
@@ -354,9 +358,6 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
AllowedModelRoute: 'subagent.md',
|
||||
SubagentModelSelectionSettings: 'subagent.md',
|
||||
AgentOptions: 'core.md',
|
||||
AdmissionDecision: 'core.md',
|
||||
DeliveryMode: 'core.md',
|
||||
DeliveryRequest: 'core.md',
|
||||
TurnBoundaryProjection: 'core.md',
|
||||
AgentStatus: 'core.md',
|
||||
AssistantStreamFrame: 'core.md',
|
||||
@@ -470,6 +471,8 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
PromptFileBinding: 'attachment.md',
|
||||
FileUploadReceiptId: 'attachment.md',
|
||||
FileUploadValue: 'attachment.md',
|
||||
ImageUploadValue: 'attachment.md',
|
||||
StagedAttachment: 'attachment.md',
|
||||
SessionStartSource: 'core.md',
|
||||
ToolCallId: 'core.md',
|
||||
SessionLogSnapshot: 'session-query.md',
|
||||
@@ -546,6 +549,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ScheduleListRequest: 'schedule.md',
|
||||
ScheduleDeleteRequest: 'schedule.md',
|
||||
ScheduleDeleteResult: 'schedule.md',
|
||||
ScheduleDeleteInactiveResult: 'schedule.md',
|
||||
ScheduleTimingChange: 'schedule.md',
|
||||
ScheduleUpdateRequest: 'schedule.md',
|
||||
ScheduleUpdateResult: 'schedule.md',
|
||||
@@ -648,6 +652,11 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ContinuableCreateSpec: 'subagent.md',
|
||||
SubagentActivation: 'subagent.md',
|
||||
SubagentActivationSpec: 'subagent.md',
|
||||
SubagentNameReservation: 'subagent.md',
|
||||
SubagentNameBinding: 'subagent.md',
|
||||
SubagentNameError: 'subagent.md',
|
||||
SubagentNameErrorCode: 'subagent.md',
|
||||
SubagentNames: 'subagent.md',
|
||||
AgentMessageSource: 'subagent.md',
|
||||
SubagentCatalogEntry: 'subagent.md',
|
||||
SubagentCatalogState: 'subagent.md',
|
||||
@@ -743,9 +752,18 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ToolExecutionResult: 'tools.md',
|
||||
ToolExecutionToken: 'tools.md',
|
||||
ToolGuard: 'tools.md',
|
||||
ToolPresentationMode: 'tools.md',
|
||||
ToolRuntime: 'tools.md',
|
||||
ToolsService: 'tools.md',
|
||||
ToolExecutor: 'tools.md',
|
||||
ToolModelInput: 'tools.md',
|
||||
ToolPromptSection: 'tools.md',
|
||||
ToolBatchInput: 'tools.md',
|
||||
ToolCallResult: 'tools.md',
|
||||
ToolBatchResult: 'tools.md',
|
||||
ToolRestriction: 'tools.md',
|
||||
ToolActivationContext: 'tools.md',
|
||||
ToolRegistrationOptions: 'tools.md',
|
||||
ResolvedToolSet: 'tools.md',
|
||||
ToolSchema: 'tools.md',
|
||||
SettingsNamespace: 'settings.md',
|
||||
SettingsDescriptor: 'settings.md',
|
||||
@@ -802,6 +820,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
PresetOption: 'permission-presets.md',
|
||||
PresetSpec: 'permission-presets.md',
|
||||
WebRoute: 'web-server.md',
|
||||
WebResourceRoute: 'web-server.md',
|
||||
IndexInjection: 'web-server.md',
|
||||
StorageBackend: 'storage.md',
|
||||
StorageForms: 'storage.md',
|
||||
@@ -887,6 +906,11 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
|
||||
/** Project types deliberately documented outside the subsystems catalog. */
|
||||
export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
ActionCardDecision: 'packages/experimental/action-cards/README.md',
|
||||
ActionCardKind: 'packages/experimental/action-cards/README.md',
|
||||
ActionCardResult: 'packages/experimental/action-cards/README.md',
|
||||
ActionCardView: 'packages/experimental/action-cards/README.md',
|
||||
RecommendationDetails: 'packages/experimental/plugin-recommendations/README.md',
|
||||
ProductEvent: 'Desktop event fields are owned by packages/client/product-analytics/README.md and src/events.ts',
|
||||
ConnectionFetchHandler: 'shared Fetch dispatch is owned by packages/client/connection/src/rpc.ts',
|
||||
ConnectionRequestRejection: 'transport rejection status is owned by packages/client/connection/src/rpc.ts',
|
||||
@@ -974,6 +998,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
JsonValue: 'JSON value union is owned by packages/core/session/src/json.ts',
|
||||
KnobState: 'projection unit state fields are owned by packages/interaction/permission-presets/README.md',
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
PromptPresentation: 'presentation inputs are owned by packages/core/system-prompt/README.md',
|
||||
RequestRunId: 'dynamic-package payload contract is owned by packages/extensions/cordis-host-runner/src/types.ts',
|
||||
RpcReceipt: 'carrier-layer receipt is owned by packages/client/connection/src/rpc.ts',
|
||||
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
||||
|
||||
+37
-18
@@ -107,6 +107,22 @@ const GROUP_ORDER = [
|
||||
]
|
||||
|
||||
const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'actionCards',
|
||||
pkg: 'action-cards',
|
||||
title: 'Optional action cards',
|
||||
mode: 'service',
|
||||
consumers: ['creation-recommendations', 'client-ui-action-cards'],
|
||||
note: 'Owns the card tool, card state, user actions and status notices; card kinds register their actions and handlers.',
|
||||
},
|
||||
{
|
||||
key: 'pluginRecommendations',
|
||||
pkg: 'plugin-recommendations',
|
||||
title: 'Explicit-need plugin recommendations',
|
||||
mode: 'service',
|
||||
consumers: ['client-ui-plugin-recommendations'],
|
||||
note: 'Reads the current catalog text of an official plugin card and enables the plugin through the profile plugin manager after a click.',
|
||||
},
|
||||
{
|
||||
key: 'headlessStartup',
|
||||
pkg: 'headless',
|
||||
@@ -574,10 +590,10 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'tools',
|
||||
pkg: 'tools',
|
||||
title: 'Tool registry and guarded execution pipeline',
|
||||
title: 'Scoped tool registry and executor selection',
|
||||
mode: 'core',
|
||||
consumers: ['agent-loop', 'tool-ask-user', 'tool-bash', 'tool-cordis', 'tool-fs', 'tool-terminal', 'tool-skill', 'tool-subagent', 'tool-todo', 'tool-web'],
|
||||
note: 'Registers capabilities, owns PTC mode transport, and routes calls through pre-policy, monotonic guards, around dispatch, post-policy, and final-result observation.',
|
||||
note: 'Registers capabilities and scoped executors. Native and PTC providers own descriptions and complete raw call batches; their optional shared pipeline handles tool policy and dispatch.',
|
||||
},
|
||||
{
|
||||
key: 'userQuestions',
|
||||
@@ -641,14 +657,6 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['agent-loop', 'acp', 'subagent'],
|
||||
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
|
||||
},
|
||||
{
|
||||
key: 'agentController',
|
||||
pkg: 'agent-controller',
|
||||
title: 'Outside-message delivery',
|
||||
mode: 'core',
|
||||
consumers: ['api-session-controller'],
|
||||
note: 'Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone\'s behalf, one at a time per Agent.',
|
||||
},
|
||||
{
|
||||
key: 'agentDefaultModel',
|
||||
pkg: 'agent-default-model',
|
||||
@@ -763,8 +771,8 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'PTC execution seam',
|
||||
mode: 'seam',
|
||||
implementations: ['ptc-runtime-node', 'experimental-ptc-runtime-python'],
|
||||
consumers: ['tools', 'workflow-ptc'],
|
||||
note: 'Runs programs against host-provided async bindings; tools owns PTC presentation and workflow-ptc owns workflow orchestration.',
|
||||
consumers: ['tool-executor-ptc', 'workflow-ptc'],
|
||||
note: 'Runs programs against host-provided async bindings; tool-executor-ptc owns PTC presentation and workflow-ptc owns workflow orchestration.',
|
||||
},
|
||||
{
|
||||
key: 'fs',
|
||||
@@ -791,9 +799,17 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
title: 'Subagent provider and continuation service',
|
||||
mode: 'seam',
|
||||
implementations: ['subagent-spawn-in-process', 'subagent-fork-in-process', 'subagent-acp', 'subagent-codex', 'subagent-claude-code', 'subagent-dsh-sdk'],
|
||||
consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'],
|
||||
consumers: ['subagent-names', 'tool-subagent', 'tool-subagent-control', 'tool-ralph'],
|
||||
note: 'Providers prepare local children or execute external tasks; one activation manager owns both lifecycles. The delegation tool selects a backend, control tools deliver local follow-ups, and workflows await activation results.',
|
||||
},
|
||||
{
|
||||
key: 'subagentNames',
|
||||
pkg: 'subagent-names',
|
||||
title: 'Optional subagent names',
|
||||
mode: 'core',
|
||||
consumers: ['tool-subagent', 'tool-subagent-control', 'tool-agent-team', 'experimental-tool-subagent-oar'],
|
||||
note: 'Owns name reservations and durable name-to-id bindings in the root Session. Execution and authorization remain with the subagent service.',
|
||||
},
|
||||
{
|
||||
key: 'speechToText',
|
||||
pkg: 'experimental-speech-to-text',
|
||||
@@ -1585,7 +1601,8 @@ function renderLifecycle(): string {
|
||||
' Note over Agent,Driver: claim pending next-step input plus one queued prompt',
|
||||
` Driver-->>SDK: ${mermaidCode('agent/inbox/spliced')} pure deletion`,
|
||||
` Driver-->>SDK: ${mermaidCode('agent/inbox/claimed')} { message, turn } per message`,
|
||||
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall`,
|
||||
' Driver->>Tools: resolve({ agent, modelSelection, signal }) once per proposed step',
|
||||
` Driver->>Prompt: ${mermaidCode('system-prompt/assemble')} waterfall with the step tool set`,
|
||||
` Driver->>Hooks: ${mermaidCode('agent/pre-step')} waterfall`,
|
||||
' Hooks-->>Driver: authoritative reject or enter(messages)',
|
||||
' alt proposed step rejected, first batch empty, or pre-step failed',
|
||||
@@ -1625,6 +1642,7 @@ function renderLifecycle(): string {
|
||||
' end',
|
||||
' end',
|
||||
` Driver->>Session: ${mermaidCode('step/end')}`,
|
||||
' Driver->>Tools: dispose the step tool set',
|
||||
' opt natural stop and next-step inbox empty',
|
||||
` Driver->>Hooks: ${mermaidCode('agent/turn-stopping')} serial terminal checkpoint`,
|
||||
' end',
|
||||
@@ -1656,7 +1674,7 @@ function renderToolPipeline(): string {
|
||||
const maintenance = 'curated Mermaid flow; exact tool schemas and event signatures live in generated catalogs'
|
||||
return [
|
||||
...generatedHeader('Tool Execution Pipeline'),
|
||||
'This graph shows where policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering run without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward.',
|
||||
'Built-in executors own raw batch parsing, scheduling, cancellation, and Session call/result logging. This graph shows where their shared invocation pipeline applies policy, hooks, sandboxing, filesystem guards, result rewriting, final-outcome observation, and UI rendering without changing the loop. The `tools/pre-execute` waterfall runs first, monotonic guards run next, and the `tools/execute` and `tools/post-execute` waterfalls follow; the three waterfalls may transform a call. Definition-owned `projectContent` installs prepared content before post-execute; `finalizeContent` and `tools/result` run afterward.',
|
||||
'',
|
||||
'```mermaid',
|
||||
'flowchart TD',
|
||||
@@ -1673,14 +1691,15 @@ function renderToolPipeline(): string {
|
||||
` owned["Tool-owned session events<br/>${mermaidCode('todo/write')}, ${mermaidCode('fs/observed')}, ${mermaidCode('hook/invoked')}, ${mermaidCode('hook/result')}, ${mermaidCode('tool/ptc-dispatch')}"]`,
|
||||
' project["ToolDefinition.projectContent<br/>execution-prepared text and images"]',
|
||||
` post["${mermaidCode('tools/post-execute')} waterfall<br/>accept, block, replace, add context"]`,
|
||||
' normalized["Registry outer normalization<br/>pipeline/result snapshot throws become isError"]',
|
||||
' normalized["Invocation pipeline normalization<br/>pipeline/result snapshot throws become isError"]',
|
||||
' finalize["ToolDefinition.finalizeContent<br/>last content-only invariant"]',
|
||||
` final["${mermaidCode('tools/result')} synchronous notification<br/>frozen authoritative outcome"]`,
|
||||
' context["Active-batch additionalContexts FIFO<br/>injected user/message after recorded tool results"]',
|
||||
` toolResult["Session event: ${mermaidCode('tool/result')}<br/>single model-facing outcome"]`,
|
||||
' allResults["Tool batch settled<br/>recorded tool/result events complete"]',
|
||||
' presentResult["UI completed card<br/>presentResult(args, result)"]',
|
||||
' model --> toolCall',
|
||||
' executor["Selected ToolExecutor.execute<br/>raw batch, Agent, optional logging step"]',
|
||||
' model --> executor --> toolCall',
|
||||
' toolCall --> presentCall',
|
||||
' toolCall --> pre',
|
||||
' pre -->|allow| guards',
|
||||
@@ -1713,7 +1732,7 @@ function renderToolPipeline(): string {
|
||||
' allResults --> context',
|
||||
'```',
|
||||
'',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The registry losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its serialized sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'Filesystem read-before-edit checks stay below `tool-fs` on `fs/*` events. Generic pre/post waterfalls host hooks and approval policy; `ctx.approval` resolves asks before monotonic guards, and owner policy that must not be reordered remains a registered guard. Around-dispatch concerns such as timeouts wrap `tools/execute`. The pipeline losslessly snapshots the candidate result and normalizes a snapshot failure before the visible definition\'s snapshotted `finalizeContent` callback enforces its synchronous content-only invariant. `tools/result` then observes the immutable, lossless-JSON outcome. This lets hooks span tool families without coupling the tools to one policy service. PTC mode sends both the reserved `run_code` transport and its ordered sub-calls through the pipeline; sub-calls carry the parent token, log `tool/ptc-dispatch`, return denials as binding rejections, and omit `additionalContexts` to preserve call/result adjacency.',
|
||||
'',
|
||||
...maintenanceFooter(maintenance),
|
||||
].join('\n')
|
||||
|
||||
@@ -4,11 +4,13 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, uti
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundles.ts'
|
||||
import { ON_DEMAND_BUNDLES } from '../packages/util/official-bundles/src/index.ts'
|
||||
import { generateOfficialBundleCatalog, renderOfficialBundleCatalog, syncOfficialBundleCatalog } from './gen-official-bundle-catalog.ts'
|
||||
|
||||
import { SHIPPED_LOCALE_IDS } from './shipped-locales.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const output = 'packages/boot/app-boot/src/official-bundles.generated.ts'
|
||||
const output = 'packages/util/official-bundles/src/catalog.generated.ts'
|
||||
|
||||
function write(root: string, path: string, content: unknown): void {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true })
|
||||
@@ -31,6 +33,8 @@ function fixture(): string {
|
||||
write(root, `${dir}/icon.svg`, '<svg xmlns="http://www.w3.org/2000/svg"/>')
|
||||
write(root, `${dir}/locale/en.json`, { meta: { title: 'Native agent', description: 'Delegate a task.' } })
|
||||
write(root, `${dir}/locale/zh.json`, { meta: { title: '原生智能体', description: '委派任务。' } })
|
||||
write(root, `${dir}/locale/ja.json`, { meta: { title: 'ネイティブエージェント', description: 'タスクを委派します。' } })
|
||||
for (const language of ['es', 'fr', 'de', 'ru', 'pt']) write(root, `${dir}/locale/${language}.json`, { meta: { title: 'Native agent', description: 'Delegate a task.' } })
|
||||
}
|
||||
write(root, output, '')
|
||||
return root
|
||||
@@ -49,7 +53,8 @@ describe('Official bundle catalog generation', () => {
|
||||
const entries = generateOfficialBundleCatalog(fixture())
|
||||
expect(entries.map(entry => entry.packageName)).toEqual(ON_DEMAND_BUNDLES)
|
||||
expect(entries[0]?.meta).toEqual({
|
||||
title: { en: 'Native agent', zh: '原生智能体' }, description: { en: 'Delegate a task.', zh: '委派任务。' },
|
||||
title: { en: 'Native agent', zh: '原生智能体', ja: 'ネイティブエージェント', ...Object.fromEntries(['es', 'fr', 'de', 'ru', 'pt'].map(id => [id, 'Native agent'])) },
|
||||
description: { en: 'Delegate a task.', zh: '委派任务。', ja: 'タスクを委派します。', ...Object.fromEntries(['es', 'fr', 'de', 'ru', 'pt'].map(id => [id, 'Delegate a task.'])) },
|
||||
icon: `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"/>').toString('base64')}`,
|
||||
})
|
||||
expect(renderOfficialBundleCatalog(entries)).not.toContain('1.2.3')
|
||||
@@ -65,6 +70,23 @@ describe('Official bundle catalog generation', () => {
|
||||
expect(() => generateOfficialBundleCatalog(root)).toThrow(/public DSH bundle|duplicate workspace/)
|
||||
})
|
||||
|
||||
it('embeds the package platform declaration and detects changes before providers are installed', () => {
|
||||
const root = fixture()
|
||||
const name = ON_DEMAND_BUNDLES[0]!
|
||||
updateManifest(root, name, { dsh: { bundle: { patch: './cordis.patch.yml', platforms: ['darwin'] } } })
|
||||
expect(generateOfficialBundleCatalog(root)[0]?.platforms).toEqual(['darwin'])
|
||||
syncOfficialBundleCatalog(root, false)
|
||||
expect(readFileSync(join(root, output), 'utf8')).toContain("platforms: ['darwin']")
|
||||
updateManifest(root, name, { dsh: { bundle: { patch: './cordis.patch.yml', platforms: ['win32'] } } })
|
||||
expect(() => { syncOfficialBundleCatalog(root, true) }).toThrow('is stale')
|
||||
})
|
||||
|
||||
it.each([[], ['macos'], 'darwin'].map(platforms => ({ platforms })))('rejects invalid package platform metadata $platforms', ({ platforms }) => {
|
||||
const root = fixture()
|
||||
updateManifest(root, ON_DEMAND_BUNDLES[0]!, { dsh: { bundle: { patch: './cordis.patch.yml', platforms } } })
|
||||
expect(() => generateOfficialBundleCatalog(root)).toThrow('dsh.bundle.platforms')
|
||||
})
|
||||
|
||||
it.each([false, true])('rejects an on-demand provider in the installed dependency closure (indirect=%s)', (indirect) => {
|
||||
const root = fixture()
|
||||
const name = ON_DEMAND_BUNDLES[0]!
|
||||
@@ -79,14 +101,14 @@ describe('Official bundle catalog generation', () => {
|
||||
expect(() => generateOfficialBundleCatalog(root, [ON_DEMAND_BUNDLES[0]!, ON_DEMAND_BUNDLES[0]!])).toThrow('distinct package names')
|
||||
})
|
||||
|
||||
it.each(['icon', 'en', 'zh'])('rejects missing %s metadata', (resource) => {
|
||||
it.each(['icon', ...SHIPPED_LOCALE_IDS])('rejects missing %s metadata', (resource) => {
|
||||
const root = fixture()
|
||||
const dir = directory(ON_DEMAND_BUNDLES[0]!)
|
||||
rmSync(join(root, dir, resource === 'icon' ? 'icon.svg' : `locale/${resource}.json`))
|
||||
expect(() => generateOfficialBundleCatalog(root)).toThrow()
|
||||
})
|
||||
|
||||
it.each(['en', 'zh'])('requires explicit %s fields instead of metadata fallback text', (language) => {
|
||||
it.each(SHIPPED_LOCALE_IDS)('requires explicit %s fields instead of metadata fallback text', (language) => {
|
||||
const root = fixture()
|
||||
write(root, `${directory(ON_DEMAND_BUNDLES[0]!)}/locale/${language}.json`, { meta: { description: 'Only a description.' } })
|
||||
expect(() => generateOfficialBundleCatalog(root)).toThrow(`explicit ${language} title and description`)
|
||||
|
||||
@@ -4,12 +4,15 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
import type { LocalizedText } from '../packages/util/package-manifest/src/types.ts'
|
||||
import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundle-packages.ts'
|
||||
import type { OfficialBundleCatalogEntry } from '../packages/boot/app-boot/src/official-bundles.ts'
|
||||
import type { DshBundleManifest, LocalizedText } from '../packages/util/package-manifest/src/types.ts'
|
||||
import { ON_DEMAND_BUNDLES } from '../packages/util/official-bundles/src/bundles.ts'
|
||||
import type { OfficialBundleCatalogEntry } from '../packages/util/official-bundles/src/index.ts'
|
||||
import { readPluginMeta, resolvePluginResource } from '../packages/boot/app-boot/src/package-meta.ts'
|
||||
import { assertBundlePlatform, UnsupportedBundlePlatformError } from '../packages/boot/app-boot/src/bundle-platform.ts'
|
||||
|
||||
const OUTPUT = 'packages/boot/app-boot/src/official-bundles.generated.ts'
|
||||
import { SHIPPED_LOCALE_IDS } from './shipped-locales.ts'
|
||||
|
||||
const OUTPUT = 'packages/util/official-bundles/src/catalog.generated.ts'
|
||||
|
||||
/**
|
||||
* Read complete metadata without importing or activating any provider code.
|
||||
@@ -19,7 +22,7 @@ const OUTPUT = 'packages/boot/app-boot/src/official-bundles.generated.ts'
|
||||
*/
|
||||
export function generateOfficialBundleCatalog(root: string, names: readonly string[] = ON_DEMAND_BUNDLES): OfficialBundleCatalogEntry[] {
|
||||
if (names.length === 0 || new Set(names).size !== names.length) throw new Error('Official on-demand catalog must contain distinct package names')
|
||||
const manifests = new Map<string, { path: string; private: boolean; bundle: boolean; runtime: string[] }>()
|
||||
const manifests = new Map<string, { path: string; private: boolean; bundle: boolean; platforms?: DshBundleManifest['platforms']; runtime: string[] }>()
|
||||
for (const path of globSync(['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json'], { cwd: root }).map(path => path.replaceAll('\\', '/')).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, path), 'utf8')) as {
|
||||
name: string
|
||||
@@ -27,11 +30,12 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri
|
||||
dependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
dsh?: { bundle?: { patch?: unknown } }
|
||||
dsh?: { bundle?: { patch?: unknown; platforms?: DshBundleManifest['platforms'] } }
|
||||
}
|
||||
if (manifests.has(manifest.name)) throw new Error(`duplicate workspace package ${manifest.name}`)
|
||||
const runtime = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const
|
||||
manifests.set(manifest.name, { path, private: manifest.private === true, bundle: manifest.dsh?.bundle?.patch !== undefined,
|
||||
...manifest.dsh?.bundle?.platforms === undefined ? {} : { platforms: manifest.dsh.bundle.platforms },
|
||||
runtime: runtime.flatMap(section => Object.keys(manifest[section] ?? {})) })
|
||||
}
|
||||
const visited = new Set<string>()
|
||||
@@ -48,8 +52,12 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri
|
||||
if (entry === undefined || !entry.path.startsWith('packages/') || !packageName.startsWith('@deepseek-ai/dsh-') || entry.private || !entry.bundle) {
|
||||
throw new Error(`${packageName}: Official catalog entries must be public DSH bundle packages`)
|
||||
}
|
||||
try { assertBundlePlatform(entry.platforms, process.platform) } catch (error) {
|
||||
// Catalog generation validates every declaration, including bundles for another Host.
|
||||
if (!(error instanceof UnsupportedBundlePlatformError)) throw error
|
||||
}
|
||||
const parentURL = pathToFileURL(resolve(root, entry.path)).href
|
||||
for (const language of ['en', 'zh']) {
|
||||
for (const language of SHIPPED_LOCALE_IDS) {
|
||||
const file = resolvePluginResource(`${packageName}/locale/${language}.json`, parentURL)
|
||||
const dictionary = JSON.parse(readFileSync(file, 'utf8')) as { meta?: { title?: unknown; description?: unknown } }
|
||||
if (typeof dictionary.meta?.title !== 'string' || dictionary.meta.title.trim() === ''
|
||||
@@ -63,28 +71,30 @@ export function generateOfficialBundleCatalog(root: string, names: readonly stri
|
||||
throw new Error(`${packageName}: Official catalog entries require an icon and localized title and description`)
|
||||
}
|
||||
for (const value of [meta.title, meta.description]) {
|
||||
for (const language of ['en', 'zh']) {
|
||||
for (const language of SHIPPED_LOCALE_IDS) {
|
||||
if (typeof value[language] !== 'string' || value[language].trim() === '') {
|
||||
throw new Error(`${packageName}: Official metadata requires ${language} text`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { packageName, meta: { title: meta.title, description: meta.description, icon: meta.icon } }
|
||||
return { packageName, ...entry.platforms === undefined ? {} : { platforms: entry.platforms },
|
||||
meta: { title: meta.title, description: meta.description, icon: meta.icon } }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a source-plane catalog with no package imports or pinned runtime version.
|
||||
* @param entries - Validated metadata in catalog order.
|
||||
* @returns Deterministic TypeScript source for app-boot's generated module.
|
||||
* @returns Deterministic TypeScript source for the official-bundles generated module.
|
||||
*/
|
||||
export function renderOfficialBundleCatalog(entries: readonly OfficialBundleCatalogEntry[]): string {
|
||||
return '/** Generated offline Official bundle metadata. Run pnpm gen-official-bundle-catalog; do not edit. */\n\n'
|
||||
+ "import type { OfficialBundleCatalogEntry } from './official-bundles.ts'\n\n"
|
||||
+ "import type { OfficialBundleCatalogEntry } from './index.ts'\n\n"
|
||||
+ '/** Official on-demand entries, independent of installed packages and registry availability. */\n'
|
||||
+ 'export const OFFICIAL_ON_DEMAND_CATALOG: readonly OfficialBundleCatalogEntry[] = [\n'
|
||||
+ entries.map(entry => ' {\n'
|
||||
+ ` packageName: ${quote(entry.packageName)},\n`
|
||||
+ (entry.platforms === undefined ? '' : ` platforms: [${entry.platforms.map(quote).join(', ')}],\n`)
|
||||
+ ' meta: {\n'
|
||||
+ ` title: ${localized(entry.meta.title)},\n`
|
||||
+ ` description: ${localized(entry.meta.description)},\n`
|
||||
|
||||
@@ -21,7 +21,7 @@ import { collectPluginRecords, type PluginRecordEntry } from './plugin-record-ca
|
||||
|
||||
export { annotateSurface, collectEventEnvelopeTypes, collectLogEvents, collectSurfaceEventTypes } from './persistence-catalog-source.ts'
|
||||
export type { AnnotatedLogEventEntry, EventEnvelopeTypeEntry, LogEventEntry } from './persistence-catalog-source.ts'
|
||||
import { persistenceSchemaSnapshot, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { encodePersistenceSnapshot, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
@@ -225,7 +225,7 @@ export function persistenceCatalogArtifacts(scanRoot: string, schema: Persistenc
|
||||
...renderPersistencePair(scanRoot, OUT, render(events, envelope, schema), render(events, envelope, schema, 'zh')),
|
||||
...renderPersistencePair(scanRoot, OUT_PLUGIN_RECORDS, renderPluginRecordCatalog(pluginRecords), renderPluginRecordCatalog(pluginRecords, 'zh')),
|
||||
{ path: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) },
|
||||
{ path: OUT_SCHEMA, content: `${JSON.stringify(persistenceSchemaSnapshot(schema), null, 2)}\n` },
|
||||
{ path: OUT_SCHEMA, content: encodePersistenceSnapshot(schema) },
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
+62
-30
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { basename, resolve } from 'node:path'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LlmRuntime from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
@@ -20,7 +20,11 @@ import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import SqliteSessionQueryEngine from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRuntime, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
|
||||
import ToolRuntime from '@deepseek-ai/dsh-tools'
|
||||
import { NativeToolExecutor } from '@deepseek-ai/dsh-tool-executor-native'
|
||||
import { PtcExecutor } from '@deepseek-ai/dsh-tool-executor-ptc'
|
||||
import NodePtcRuntime from '@deepseek-ai/dsh-ptc-runtime-node'
|
||||
import LocalSandboxProvider from '@deepseek-ai/dsh-sandbox-local'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import * as BashEnvPlugin from '@deepseek-ai/dsh-shell-env'
|
||||
import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local'
|
||||
@@ -37,9 +41,12 @@ import WebRuntime from '@deepseek-ai/dsh-web'
|
||||
import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
|
||||
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-http'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import SubagentNames from '@deepseek-ai/dsh-subagent-names'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import SkillRegistry from '@deepseek-ai/dsh-skill'
|
||||
import ActionCards from '@deepseek-ai/dsh-action-cards'
|
||||
import RemoteAccess from '@deepseek-ai/dsh-api-remote-access'
|
||||
import * as SkillFileSystem from '@deepseek-ai/dsh-skill-filesystem'
|
||||
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
|
||||
import * as ToolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
@@ -180,13 +187,8 @@ export interface ToolPackage {
|
||||
mount: (ctx: Context) => Promise<void>
|
||||
/** Agent-like scope key whose tool view is catalogued instead of the global view. */
|
||||
scope?: (ctx: Context) => Agent
|
||||
/**
|
||||
* Config for the caller's `ToolRuntime` mount. The registry itself ships a
|
||||
* model-facing tool (`run_code`, registered under a non-native `mode`), so
|
||||
* ITS catalog entry boots the registry in the mode that exposes it;
|
||||
* every other entry uses the default (native) registry.
|
||||
*/
|
||||
toolsConfig?: ToolsConfig
|
||||
/** Select the program transport when harvesting the PTC package. */
|
||||
executor?: 'ptc'
|
||||
/**
|
||||
* A deployment note rendered after the package's tools, for a fact that
|
||||
* booting the package alone cannot show. The registered tool NAME can be a
|
||||
@@ -255,6 +257,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-action-cards',
|
||||
dir: 'action-cards',
|
||||
source: 'packages/experimental/action-cards/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.agents', 'ctx.sessionProjections', 'ctx.remoteAccess'],
|
||||
writes: ['tool/call', 'tool/result', 'plugin:action-cards/decision', 'user/message'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(RemoteAccess)
|
||||
await ctx.plugin(ActionCards)
|
||||
},
|
||||
note: 'Declared only to an Agent that loaded the skill of a registered card kind; the kind packages register their actions, and the Personalized recommendations bundle supplies the shipped kinds.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-ask-user',
|
||||
dir: 'tool-ask-user',
|
||||
@@ -269,18 +284,20 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
'ask_user_question keeps the original blocking behavior by default; set `mode: timed` to opt into a foreground timeout and pending result while the question remains answerable. In timed mode, `timeout: -1` keeps that call blocking indefinitely.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tools',
|
||||
dir: 'tools',
|
||||
source: 'packages/core/tools/src/ptc.ts',
|
||||
requires: ['ctx.workingDirectory (Agent executions)', 'ctx.tools', 'ctx.ptcRuntime (execution time)', 'ctx.systemPrompt'],
|
||||
pkg: '@deepseek-ai/dsh-tool-executor-ptc',
|
||||
dir: 'tool-executor-ptc',
|
||||
source: 'packages/core/tool-executor-ptc/src/ptc.ts',
|
||||
requires: ['ctx.workingDirectory (Agent executions)', 'ctx.tools', 'ctx.ptcRuntime', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call', 'tool/result'],
|
||||
// The registry's OWN tool: run_code exists only under a non-native mode
|
||||
// (the registry registers it in its constructor; the PTC runtime is read
|
||||
// at assembly/execution time, so the schema harvest needs none mounted).
|
||||
toolsConfig: { mode: 'ptc' },
|
||||
async mount() {},
|
||||
executor: 'ptc',
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(LocalSandboxProvider)
|
||||
await ctx.plugin(SandboxPolicy)
|
||||
await ctx.plugin(NodePtcRuntime)
|
||||
},
|
||||
note:
|
||||
'Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` (see the PTC mode Agent Note). Under `ptc` it is the registry\'s only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime\'s language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result.',
|
||||
'The PTC executor exposes only run_code to the model and declares other visible capabilities in a generated SDK in the runtime’s language. Program bindings enter the shared guarded tool pipeline in submission order; concurrency-safe bodies overlap up to maxParallelSubCalls, and nested results remain linked to the outer program.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-plan-mode',
|
||||
@@ -539,10 +556,12 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-experimental-tool-subagent-oar',
|
||||
dir: 'tool-subagent-oar',
|
||||
source: 'packages/experimental/tool-subagent-oar/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.subagents'],
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.subagentNames'],
|
||||
writes: ['tool/call', 'tool/result', 'parent subagent catalog and completion notices'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(SubagentNames)
|
||||
await ctx.plugin(ToolSubagentOar, {
|
||||
providers: { codex: 'oar-codex', claude: 'oar-claude' },
|
||||
})
|
||||
@@ -556,17 +575,19 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
list_subagent_models: 'packages/subagent/tool-subagent/src/list-models.ts',
|
||||
subagent: 'packages/subagent/tool-subagent/src/index.ts',
|
||||
},
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt', 'ctx.sessionProjections', 'ctx.llm for model discovery and selected-route validation'],
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.systemPrompt', 'ctx.sessionProjections', 'ctx.subagentNames', 'ctx.llm for model discovery and selected-route validation'],
|
||||
writes: ['tool/call', 'tool/result', 'child session events through the chosen provider'],
|
||||
shippedNames: ['subagent', 'subagent_fork'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(SubagentNames)
|
||||
await ctx.plugin(LlmRuntime)
|
||||
registerCatalogSubagentProvider(ctx, 'mock')
|
||||
await ctx.plugin(ToolSubagent, { provider: 'mock', modelDiscovery: true })
|
||||
},
|
||||
note:
|
||||
'The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. Every delegation returns a unique agent path promptly; local children accept follow-ups, while external backends report their single execution automatically.',
|
||||
'The load-time `toolName` selects the delegation name (default `subagent`); `modelSelection` adds optional route fields. `modelDiscovery` defaults off and registers shared discovery on one instance per scope. Web and Desktop presets enable spawn discovery and route fields and leave `subagent_fork` route fields disabled. The Host settings service records authorization for each new top-level Session; child Sessions inherit it. Without a recorded policy, discovery and explicit selection permit only the current parent route. The required naming plugin lets delegation accept a sibling name and return its full path alongside the child Session id. Local children accept follow-ups, while external backends report their single execution automatically.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
@@ -577,17 +598,18 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
wait_agent: 'packages/subagent/tool-subagent-control/src/wait-agent.ts',
|
||||
},
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.agents'],
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.agents', 'ctx.systemPrompt', 'ctx.subagentNames'],
|
||||
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(LocalJobRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SubagentNames)
|
||||
await ctx.plugin(ToolSubagentControl)
|
||||
},
|
||||
note:
|
||||
'The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools.',
|
||||
'The shared control tools discover and contact agents in the subagent tree, interrupt owned descendants, and wait for input in the caller’s inbox. Provider-bound `tool-subagent` instances supply delegation tools. Controls require the naming plugin and accept a name path or Session id through `name`; discovery adds available names while retaining Session ids.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-jobs',
|
||||
@@ -606,12 +628,13 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
pkg: '@deepseek-ai/dsh-tool-agent-team',
|
||||
dir: 'tool-agent-team',
|
||||
source: 'packages/subagent/tool-agent-team/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.agentTeams', 'ctx.subagents', 'an exact live Team member Agent'],
|
||||
requires: ['ctx.tools', 'ctx.systemPrompt', 'ctx.agentTeams', 'ctx.subagents', 'ctx.subagentNames', 'an exact live Team member Agent'],
|
||||
writes: ['tool/call', 'team/task', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SubagentRuntime)
|
||||
await ctx.plugin(SubagentNames)
|
||||
await ctx.plugin(CatalogSessionPersistence)
|
||||
await ctx.plugin(TeamService)
|
||||
const session = ctx.sessions.create(SessionId('tool-catalog-team-lead'))
|
||||
@@ -632,7 +655,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
},
|
||||
scope: ctx => catalogChildScopes.get(ctx) as Agent,
|
||||
note:
|
||||
'Four task-board tools share the subagent tree’s root Session. The optional Agent Teams profile adds task-board guidance and required Team identity derived from the subagent root, alongside standard subagent delegation and control tools.',
|
||||
'Four task-board tools share the subagent tree’s root Session. Enabling Team mode on the root Session adds task-board guidance and required Team identity alongside standard subagent delegation and control tools.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-todo',
|
||||
@@ -731,7 +754,7 @@ export type ToolCatalog = CatalogPackage[]
|
||||
|
||||
/**
|
||||
* Assert the boot manifest covers every shipped tool package on disk (a
|
||||
* `tool-*` leaf under `packages/`).
|
||||
* `tool-*` leaf under `packages/`, excluding test support).
|
||||
* Booting has no source declaration to enumerate, so this glob restores the
|
||||
* "a new tool cannot be silently undocumented" guarantee: an unlisted package
|
||||
* fails the generator (and the freshness gate) until it is added to
|
||||
@@ -740,7 +763,11 @@ export type ToolCatalog = CatalogPackage[]
|
||||
* `scanRoot` defaults to the repo root; a test may point it at a fixture tree.
|
||||
*/
|
||||
export function assertManifestComplete(packages: ToolPackage[] = TOOL_PACKAGES, scanRoot: string = root): void {
|
||||
const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot }).map(p => basename(p)).sort()
|
||||
// Native execution owns no tool definitions; PTC contributes run_code.
|
||||
const onDisk = globSync('packages/*/tool-*', { cwd: scanRoot })
|
||||
.filter(path => basename(dirname(path)) !== 'test-support')
|
||||
.map(p => basename(p))
|
||||
.filter(dir => dir !== 'tool-executor-native').sort()
|
||||
const listed = new Set(packages.map(p => p.dir))
|
||||
const missing = onDisk.filter(dir => !listed.has(dir))
|
||||
if (missing.length > 0) {
|
||||
@@ -793,9 +820,14 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(LocalFileSystem)
|
||||
await ctx.plugin(WorkingDirectory)
|
||||
await ctx.plugin(ToolRuntime, entry.toolsConfig ?? {})
|
||||
await ctx.plugin(ToolRuntime)
|
||||
const executor = entry.executor === 'ptc' ? new PtcExecutor(ctx) : new NativeToolExecutor(ctx)
|
||||
ctx.tools.setExecutor(executor)
|
||||
await entry.mount(ctx)
|
||||
const schemas = ctx.tools.schemas(entry.scope?.(ctx)).sort((a, b) => a.name.localeCompare(b.name))
|
||||
const scope = entry.scope?.(ctx)
|
||||
const described = executor.describe(ctx.tools.definitions(scope))
|
||||
const schemas = [...new Map([...ctx.tools.schemas(scope), ...described.schemas].map(schema => [schema.name, schema])).values()]
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
assertToolsHarvested(entry, schemas.length)
|
||||
catalog.push({
|
||||
pkg: entry.pkg,
|
||||
|
||||
@@ -47,9 +47,9 @@ describe('volatile Loader updates', () => {
|
||||
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
|
||||
owner.on('loader/volatile-update', events)
|
||||
} }, { title: 'first' })
|
||||
const reference = fiber.config as ReturnType<typeof Config>
|
||||
const reference = fiber.effectiveConfig as ReturnType<typeof Config>
|
||||
await update(fiber, { title: 'second' })
|
||||
expect(fiber.config).toBe(reference)
|
||||
expect(fiber.effectiveConfig).toBe(reference)
|
||||
expect(reference.get()).toEqual({ title: 'second' })
|
||||
expect(events).toHaveBeenCalledExactlyOnceWith([[]])
|
||||
})
|
||||
@@ -70,7 +70,7 @@ describe('volatile Loader updates', () => {
|
||||
await update(first, { title: 'next', nested: { count: 2 } })
|
||||
await first.await()
|
||||
expect(mount).toHaveBeenCalledTimes(2)
|
||||
expect(first.config).toBe(initial)
|
||||
expect(first.effectiveConfig).toBe(initial)
|
||||
expect(initial.title).toBe(title)
|
||||
expect(configs[1]!.title.get()).toBe('hello')
|
||||
expect(observed).toEqual([{ paths: [['title'], ['nested', 'count']], title: 'next', count: 2 }])
|
||||
@@ -117,9 +117,9 @@ describe('volatile Loader updates', () => {
|
||||
hooks.push(config.title.get())
|
||||
})
|
||||
} }, {})
|
||||
const initial = fiber.config as ReturnType<typeof Config>
|
||||
const initial = fiber.effectiveConfig as ReturnType<typeof Config>
|
||||
await update(fiber, { title: 'accepted' })
|
||||
expect(fiber.config).toBe(initial)
|
||||
expect(fiber.effectiveConfig).toBe(initial)
|
||||
expect(initial.title.get()).toBe('accepted')
|
||||
await update(fiber, { title: 'accepted' })
|
||||
expect(hooks).toEqual([])
|
||||
@@ -180,11 +180,11 @@ describe('volatile Loader updates', () => {
|
||||
const oldUrl = 'https://a.example/'
|
||||
const newUrl = 'https://b.example/'
|
||||
const fiber = await mountEntry(ctx, { Config: schema, apply }, { live: 'a', url: oldUrl })
|
||||
const before = fiber.config as ReturnType<typeof schema>
|
||||
const before = fiber.effectiveConfig as ReturnType<typeof schema>
|
||||
await update(fiber, { live: 'b', url: newUrl })
|
||||
await fiber.await()
|
||||
expect(apply).toHaveBeenCalledTimes(2)
|
||||
expect((fiber.config as ReturnType<typeof schema>).url.href).toBe(newUrl)
|
||||
expect((fiber.effectiveConfig as ReturnType<typeof schema>).url.href).toBe(newUrl)
|
||||
expect(before.live.get()).toBe('a')
|
||||
await update(fiber, { live: 'c', url: newUrl, data: 'first' })
|
||||
await update(fiber, { live: 'd', url: newUrl, data: 'second' })
|
||||
@@ -199,7 +199,7 @@ describe('volatile Loader updates', () => {
|
||||
owner.on('loader/volatile-update', events)
|
||||
} }, { values: [undefined] })
|
||||
await update(fiber, { values: [null] })
|
||||
expect((fiber.config as ReturnType<typeof schema>).values.get()).toEqual([null])
|
||||
expect((fiber.effectiveConfig as ReturnType<typeof schema>).values.get()).toEqual([null])
|
||||
expect(events).toHaveBeenCalledExactlyOnceWith([['values']])
|
||||
})
|
||||
})
|
||||
@@ -308,7 +308,7 @@ it('updates volatile children through Group and Include custom updates and saves
|
||||
expect(includeEntry.fiber).toBe(originalInclude)
|
||||
expect(group.fiber).toBe(originalGroup)
|
||||
expect(consumer.fiber).toBe(originalConsumer)
|
||||
expect(consumer.fiber!.config).toBe(initial)
|
||||
expect(consumer.fiber!.effectiveConfig).toBe(initial)
|
||||
expect(configs).toHaveLength(1)
|
||||
expect(notifications).toHaveBeenCalledTimes(2)
|
||||
expect(childUpdates).not.toHaveBeenCalled()
|
||||
@@ -318,7 +318,7 @@ it('updates volatile children through Group and Include custom updates and saves
|
||||
entry.fiber!.update({ title: 'custom-update', fixed: 'ordinary' })
|
||||
expect(childUpdates).toHaveBeenCalledOnce()
|
||||
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
|
||||
expect((entry.fiber!.config as ReturnType<typeof Config>).title.get()).toBe('hello')
|
||||
expect((entry.fiber!.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('hello')
|
||||
entry.fiber!.update({ title: 'ephemeral', fixed: 'ordinary' }, true)
|
||||
expect(childUpdates).toHaveBeenCalledTimes(2)
|
||||
expect(entry.options.config).toEqual({ title: 'custom-update', fixed: 'ordinary' })
|
||||
@@ -332,11 +332,11 @@ it('commits volatile edits in place and retains direct Fiber updates', async ()
|
||||
const notifications = vi.fn()
|
||||
const apply = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', notifications) })
|
||||
const fiber = await mountEntry(ctx, { Config, apply }, {})
|
||||
const original = fiber.config as ReturnType<typeof Config>
|
||||
const original = fiber.effectiveConfig as ReturnType<typeof Config>
|
||||
await ctx.loader.create({ name: 'cordis:disabled', disabled: true })
|
||||
await update(fiber, { title: 'enabled' })
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(fiber.config).toBe(original)
|
||||
expect(fiber.effectiveConfig).toBe(original)
|
||||
expect(original.title.get()).toBe('enabled')
|
||||
expect(notifications).toHaveBeenCalledExactlyOnceWith([['title']])
|
||||
await update(fiber, { title: 'invalid', nested: { count: -1 } })
|
||||
@@ -344,7 +344,7 @@ it('commits volatile edits in place and retains direct Fiber updates', async ()
|
||||
await update(fiber, { title: 'mixed', fixed: 'changed' })
|
||||
expect(apply).toHaveBeenCalledTimes(2)
|
||||
expect(original.title.get()).toBe('enabled')
|
||||
const current = fiber.config as ReturnType<typeof Config>
|
||||
const current = fiber.effectiveConfig as ReturnType<typeof Config>
|
||||
expect(current.title.get()).toBe('mixed')
|
||||
|
||||
const hook = vi.fn((_config: unknown, _noSave: boolean, next: () => void) => { next() })
|
||||
@@ -365,7 +365,7 @@ it('parses a volatile-only update through the config hook without patching the e
|
||||
const fiber = await mountEntry(ctx, { Config, apply(owner: Context) {
|
||||
owner.on('loader/volatile-update', () => { trace.push('notify') })
|
||||
} }, { block: { title: 'initial' } })
|
||||
const current = fiber.config as ReturnType<typeof Config>
|
||||
const current = fiber.effectiveConfig as ReturnType<typeof Config>
|
||||
const resolveHook = vi.fn((_raw: unknown, next: () => unknown) => next())
|
||||
fiber.ctx.on('internal/config', resolveHook)
|
||||
const contextPatch = vi.fn((_entry: unknown, next: () => void) => { next() })
|
||||
@@ -379,7 +379,7 @@ it('parses a volatile-only update through the config hook without patching the e
|
||||
expect(resolveHook).toHaveBeenCalledOnce()
|
||||
expect(trace).toEqual(['validate', 'notify'])
|
||||
expect(current.block.get()).toEqual({ title: 'changed' })
|
||||
expect(fiber._config).toBe(raw)
|
||||
expect(fiber.rawConfig).toBe(raw)
|
||||
})
|
||||
|
||||
it.each([true, false])('keeps old references untouched when isolation replaces a dependency (available: %s)', async (available) => {
|
||||
@@ -452,7 +452,7 @@ it.each(['group', 'include'] as const)('applies profile patches to a %s subtree
|
||||
await reconcileProfilePatches(ctx, patch('second'), 'volatile-test')
|
||||
expect(parent.fiber).toBe(parentFiber)
|
||||
expect(entry.fiber).toBe(childFiber)
|
||||
expect(entry.fiber!.config).toBe(original)
|
||||
expect(entry.fiber!.effectiveConfig).toBe(original)
|
||||
expect(original.title).toBe(reference)
|
||||
expect(reference.get()).toBe('second')
|
||||
expect(configs).toHaveLength(1)
|
||||
@@ -481,9 +481,9 @@ it('logs an invalid candidate and keeps its references while other entries commi
|
||||
await invalid.entry!.update({ config: { title: 'invalid', nested: { count: -1 } } })
|
||||
await healthy.entry!.update({ config: { title: 'healthy' } })
|
||||
expect(warning).toHaveBeenCalledTimes(2)
|
||||
expect((invalid.config as ReturnType<typeof Config>).title.get()).toBe('hello')
|
||||
expect(invalid._config).toEqual({ title: 'invalid', nested: { count: -1 } })
|
||||
expect((healthy.config as ReturnType<typeof Config>).title.get()).toBe('healthy')
|
||||
expect((invalid.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('hello')
|
||||
expect(invalid.rawConfig).toEqual({ title: 'invalid', nested: { count: -1 } })
|
||||
expect((healthy.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('healthy')
|
||||
expect(events).toHaveBeenCalledExactlyOnceWith([['title']])
|
||||
})
|
||||
|
||||
@@ -500,7 +500,7 @@ it.each([false, true])('retains raw expression edits for dependency restoration
|
||||
await fiber.entry!.update({ config: raw })
|
||||
await fiber.await()
|
||||
expect(values).toEqual(volatile ? ['same'] : ['same', 'same'])
|
||||
expect(fiber._config).toBe(raw)
|
||||
expect(fiber.rawConfig).toBe(raw)
|
||||
await provider.dispose()
|
||||
await provide('old-route', 'new-route')
|
||||
await fiber.await()
|
||||
@@ -540,10 +540,10 @@ it('compares ordinary URL transforms by value and remounts when an expression re
|
||||
const fixed = { __jsExpr: "ctx.get('volatile-transform-source').value" }
|
||||
const raw = { url: 'https://example.com/', fixed, live: 'old' }
|
||||
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
|
||||
const current = fiber.config as ReturnType<typeof schema>
|
||||
const current = fiber.effectiveConfig as ReturnType<typeof schema>
|
||||
await update(fiber, { ...raw, live: 'new' })
|
||||
expect(mount).toHaveBeenCalledOnce()
|
||||
expect(fiber.config).toBe(current)
|
||||
expect(fiber.effectiveConfig).toBe(current)
|
||||
expect(current.url.href).toBe(raw.url)
|
||||
expect(current.fixed).toBe('initial')
|
||||
expect(current.live.get()).toBe('new')
|
||||
@@ -554,7 +554,7 @@ it('compares ordinary URL transforms by value and remounts when an expression re
|
||||
expect(current.fixed).toBe('initial')
|
||||
expect(current.live.get()).toBe('new')
|
||||
expect(events).toHaveBeenCalledOnce()
|
||||
const replaced = fiber.config as ReturnType<typeof schema>
|
||||
const replaced = fiber.effectiveConfig as ReturnType<typeof schema>
|
||||
expect(replaced.fixed).toBe('changed')
|
||||
expect(replaced.live.get()).toBe('remounted')
|
||||
})
|
||||
@@ -568,13 +568,13 @@ it('remounts when an ordinary parent expression removes a live reference path',
|
||||
const mount = vi.fn(function (owner: Context) { owner.on('loader/volatile-update', events) })
|
||||
const raw = { block: { __jsExpr: "ctx.get('volatile-path-source').block" }, live: 'old' }
|
||||
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, raw)
|
||||
const current = fiber.config as ReturnType<typeof schema>
|
||||
const current = fiber.effectiveConfig as ReturnType<typeof schema>
|
||||
source.block = undefined
|
||||
await update(fiber, { ...raw, live: 'new' })
|
||||
expect(mount).toHaveBeenCalledTimes(2)
|
||||
expect(current.block.value.get()).toBe('old')
|
||||
expect(events).not.toHaveBeenCalled()
|
||||
expect((fiber.config as ReturnType<typeof schema>).block).toBeUndefined()
|
||||
expect((fiber.effectiveConfig as ReturnType<typeof schema>).block).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an ordinary plugin mounted when an absent object becomes its explicit default', async () => {
|
||||
@@ -582,13 +582,13 @@ it('keeps an ordinary plugin mounted when an absent object becomes its explicit
|
||||
const Config = z.object({ nested: z.object({ fixed: z.string() }).default({ fixed: 'default' }) })
|
||||
const mount = vi.fn()
|
||||
const fiber = await mountEntry(ctx, { Config, apply: mount }, {})
|
||||
const config: unknown = fiber.config
|
||||
const config: unknown = fiber.effectiveConfig
|
||||
await update(fiber, { nested: { fixed: 'default' } })
|
||||
expect(fiber.config).toBe(config)
|
||||
expect(fiber.effectiveConfig).toBe(config)
|
||||
expect(mount).toHaveBeenCalledTimes(1)
|
||||
await update(fiber, { nested: { fixed: 'changed' } })
|
||||
expect(mount).toHaveBeenCalledTimes(2)
|
||||
expect(fiber.config).toEqual({ nested: { fixed: 'changed' } })
|
||||
expect(fiber.effectiveConfig).toEqual({ nested: { fixed: 'changed' } })
|
||||
})
|
||||
|
||||
it('keeps a noSave volatile value as the pending raw config across a no-op entry update', async () => {
|
||||
@@ -596,11 +596,11 @@ it('keeps a noSave volatile value as the pending raw config across a no-op entry
|
||||
const fiber = await mountEntry(ctx, { Config, apply() {} }, { title: 'saved' })
|
||||
fiber.update({ title: 'ephemeral' }, true)
|
||||
await fiber.await()
|
||||
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
|
||||
expect((fiber.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
|
||||
expect(fiber.entry!.options.config).toEqual({ title: 'saved' })
|
||||
await fiber.entry!.update({ config: { title: 'saved' } })
|
||||
expect(fiber._config).toEqual({ title: 'ephemeral' })
|
||||
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
|
||||
expect(fiber.rawConfig).toEqual({ title: 'ephemeral' })
|
||||
expect((fiber.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('ephemeral')
|
||||
})
|
||||
|
||||
it('isolates a failing notification listener and dispatches with a Context receiver', async () => {
|
||||
@@ -613,11 +613,11 @@ it('isolates a failing notification listener and dispatches with a Context recei
|
||||
const warning = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {})
|
||||
onTestFinished(() => { warning.mockRestore() })
|
||||
await update(fiber, { title: 'committed' })
|
||||
expect((fiber.config as ReturnType<typeof Config>).title.get()).toBe('committed')
|
||||
expect((fiber.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('committed')
|
||||
expect(receivers).toEqual([fiber])
|
||||
expect(warning).toHaveBeenCalledWith(expect.objectContaining({ message: 'listener boom' }))
|
||||
await update(other, { title: 'unaffected' })
|
||||
expect((other.config as ReturnType<typeof Config>).title.get()).toBe('unaffected')
|
||||
expect((other.effectiveConfig as ReturnType<typeof Config>).title.get()).toBe('unaffected')
|
||||
})
|
||||
|
||||
it('remounts instead of committing when an ordinary field parses into a fresh class instance', async () => {
|
||||
@@ -626,9 +626,9 @@ it('remounts instead of committing when an ordinary field parses into a fresh cl
|
||||
const schema = z.object({ handle: z.transform(z.string(), value => new Handle(value)), live: z.string().volatile() })
|
||||
const mount = vi.fn()
|
||||
const fiber = await mountEntry(ctx, { Config: schema, apply: mount }, { handle: 'h', live: 'old' })
|
||||
const before = fiber.config as ReturnType<typeof schema>
|
||||
const before = fiber.effectiveConfig as ReturnType<typeof schema>
|
||||
await update(fiber, { handle: 'h', live: 'new' })
|
||||
expect(mount).toHaveBeenCalledTimes(2)
|
||||
expect(before.live.get()).toBe('old')
|
||||
expect((fiber.config as ReturnType<typeof schema>).live.get()).toBe('new')
|
||||
expect((fiber.effectiveConfig as ReturnType<typeof schema>).live.get()).toBe('new')
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import { ON_DEMAND_BUNDLES } from '../packages/boot/app-boot/src/official-bundle-packages.ts'
|
||||
import { ON_DEMAND_BUNDLES } from '../packages/util/official-bundles/src/bundles.ts'
|
||||
import { generateOfficialBundleCatalog } from './gen-official-bundle-catalog.ts'
|
||||
import {
|
||||
createRegistryManifest,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Gate for the invariant `FALLBACK_LOCALE` rests on: every shipped dictionary
|
||||
* declares the same keys in `zh` and `en`.
|
||||
* declares the same keys in every shipped language; translations also
|
||||
* preserve the English placeholders, command examples, and API field names.
|
||||
*
|
||||
* The locale runtime resolves a key through the active locale, then through
|
||||
* the single fallback locale (`en`), then surfaces the key itself. With
|
||||
@@ -26,6 +27,8 @@ import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters, type ShippedLocaleId } from './shipped-locales.ts'
|
||||
|
||||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
/** Repo-relative path with `/` separators, so messages and suffix tests match on every OS. */
|
||||
@@ -33,7 +36,7 @@ function relative(file: string): string {
|
||||
return file.slice(root.length).replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
/** Every `.ts` source file under each workspace package's `src`, excluding declarations. */
|
||||
/** Every TypeScript source file under each workspace package's `src`, excluding declarations. */
|
||||
function sourceFiles(): string[] {
|
||||
const files: string[] = []
|
||||
const packagesRoot = resolve(root, 'packages')
|
||||
@@ -70,7 +73,7 @@ function walk(dir: string, out: string[]): void {
|
||||
for (const entry of readEntries(dir)) {
|
||||
const full = resolve(dir, entry.name)
|
||||
if (entry.isDirectory()) walk(full, out)
|
||||
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) out.push(full)
|
||||
else if ((/\.tsx?$/u.test(entry.name)) && !entry.name.endsWith('.d.ts')) out.push(full)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +85,8 @@ interface Dictionary {
|
||||
name: string
|
||||
/** Declared keys, sorted. */
|
||||
keys: string[]
|
||||
/** Literal templates used to compare named placeholders. */
|
||||
templates: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,7 +103,7 @@ function dictionariesIn(file: string): Dictionary[] {
|
||||
// before parsing — the silent narrowing this gate exists to prevent. A bare
|
||||
// `\b(zh|en)\b` misses `zhSettings`/`accessZh`, because `\b` does not hold
|
||||
// between `h` and an uppercase letter.
|
||||
if (!/\b(zh|en)\b|\b(zh|en)[A-Z]|(Zh|En)\b/.test(text)) return []
|
||||
if (!/\b(zh|en|ja|es|fr|de|ru|pt)\b|\b(zh|en|ja|es|fr|de|ru|pt)[A-Z]|(Zh|En|Ja|Es|Fr|De|Ru|Pt)\b/.test(text)) return []
|
||||
const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true)
|
||||
const found: Dictionary[] = []
|
||||
const rel = relative(file)
|
||||
@@ -125,7 +130,7 @@ function dictionariesIn(file: string): Dictionary[] {
|
||||
const literal = unwrap(decl.initializer)
|
||||
if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
|
||||
if (localeOf(decl.name.text) === undefined) continue
|
||||
found.push({ file: rel, name: decl.name.text, keys: keysOf(literal) })
|
||||
found.push({ file: rel, name: decl.name.text, keys: keysOf(literal), templates: templatesOf(literal) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +155,7 @@ function dictionariesIn(file: string): Dictionary[] {
|
||||
if (name === 'register' && node.arguments.length >= 3) {
|
||||
const [ns, tag, dict] = node.arguments
|
||||
if (ns === undefined || tag === undefined || !ts.isStringLiteral(tag)) return
|
||||
if (tag.text !== 'zh' && tag.text !== 'en') return
|
||||
if (!SHIPPED_LOCALE_IDS.some(id => id === tag.text)) return
|
||||
const raw = unwrap(dict)
|
||||
const literal = raw !== undefined && ts.isIdentifier(raw)
|
||||
? (() => {
|
||||
@@ -171,10 +176,10 @@ function dictionariesIn(file: string): Dictionary[] {
|
||||
// The namespace expression's source text identifies the pair, so the
|
||||
// zh and en calls for one namespace meet and calls for different
|
||||
// namespaces stay apart.
|
||||
found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary) })
|
||||
found.push({ file: rel, name: `${tag.text}@register:${ns.getText(source)}`, keys: keysOf(dictionary), templates: templatesOf(dictionary) })
|
||||
}
|
||||
}
|
||||
if (ts.isArrayLiteralExpression(node) && node.elements.length === 2) {
|
||||
if (ts.isArrayLiteralExpression(node) && node.elements.length >= 2) {
|
||||
const site = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1
|
||||
for (const element of node.elements) {
|
||||
if (!ts.isArrayLiteralExpression(element) || element.elements.length !== 2) continue
|
||||
@@ -182,8 +187,8 @@ function dictionariesIn(file: string): Dictionary[] {
|
||||
const literal = unwrap(dict)
|
||||
if (tag === undefined || !ts.isStringLiteral(tag)) continue
|
||||
if (literal === undefined || !ts.isObjectLiteralExpression(literal)) continue
|
||||
if (tag.text !== 'zh' && tag.text !== 'en') continue
|
||||
found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal) })
|
||||
if (!SHIPPED_LOCALE_IDS.some(id => id === tag.text)) continue
|
||||
found.push({ file: rel, name: `${tag.text}@inline:${site}`, keys: keysOf(literal), templates: templatesOf(literal) })
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
@@ -192,6 +197,18 @@ function dictionariesIn(file: string): Dictionary[] {
|
||||
return found
|
||||
}
|
||||
|
||||
/** Literal templates; shared spread dictionaries are checked at their own declaration. */
|
||||
function templatesOf(literal: ts.ObjectLiteralExpression): Readonly<Record<string, string>> {
|
||||
const templates: Record<string, string> = {}
|
||||
for (const property of literal.properties) {
|
||||
if (!ts.isPropertyAssignment(property) || !ts.isStringLiteralLike(property.initializer)) continue
|
||||
if (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) {
|
||||
templates[property.name.text] = property.initializer.text
|
||||
}
|
||||
}
|
||||
return templates
|
||||
}
|
||||
|
||||
/** Declared property names of an object literal, sorted. */
|
||||
function keysOf(literal: ts.ObjectLiteralExpression): string[] {
|
||||
const keys: string[] = []
|
||||
@@ -224,9 +241,9 @@ function unwrap(node: ts.Expression | undefined): ts.Expression | undefined {
|
||||
* @param name - export name or synthetic inline name.
|
||||
* @returns locale plus pair key, or undefined when the name names no locale.
|
||||
*/
|
||||
function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefined {
|
||||
for (const locale of ['zh', 'en'] as const) {
|
||||
const other = locale === 'zh' ? 'Zh' : 'En'
|
||||
function localeOf(name: string): { locale: ShippedLocaleId; pair: string } | undefined {
|
||||
for (const locale of SHIPPED_LOCALE_IDS) {
|
||||
const other = locale[0]!.toUpperCase() + locale.slice(1)
|
||||
if (name === locale) return { locale, pair: '' }
|
||||
// Synthetic names for inline shapes carry their own pair key after the
|
||||
// first ':' (the enclosing array's line, or the namespace expression).
|
||||
@@ -239,8 +256,64 @@ function localeOf(name: string): { locale: 'zh' | 'en'; pair: string } | undefin
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that each object-form registration supplies every shipped-language dictionary.
|
||||
* @param file - absolute source path.
|
||||
* @returns registration sites missing a shipped language, or using an unresolved local identifier.
|
||||
*/
|
||||
function registrationProblems(file: string): string[] {
|
||||
const text = readFileSync(file, 'utf8')
|
||||
if (!text.includes('.register(')) return []
|
||||
const source = ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true)
|
||||
const locals = new Map<string, ts.Expression>()
|
||||
const imported = new Set<string>()
|
||||
const collect = (node: ts.Node): void => {
|
||||
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined) {
|
||||
locals.set(node.name.text, node.initializer)
|
||||
}
|
||||
if (ts.isImportSpecifier(node)) imported.add(node.name.text)
|
||||
ts.forEachChild(node, collect)
|
||||
}
|
||||
collect(source)
|
||||
const problems: string[] = []
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
|
||||
&& node.expression.name.text === 'register' && node.arguments.length === 2) {
|
||||
const argument = unwrap(node.arguments[1])
|
||||
const dicts = argument !== undefined && ts.isIdentifier(argument)
|
||||
? unwrap(locals.get(argument.text)) : argument
|
||||
if (dicts !== undefined && ts.isObjectLiteralExpression(dicts)) {
|
||||
const entries = new Map<string, ts.Expression>()
|
||||
for (const prop of dicts.properties) {
|
||||
if (ts.isShorthandPropertyAssignment(prop)) entries.set(prop.name.text, prop.name)
|
||||
else if (ts.isPropertyAssignment(prop) && (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name))) {
|
||||
entries.set(prop.name.text, prop.initializer)
|
||||
}
|
||||
}
|
||||
if (entries.has('zh') && entries.has('en')) {
|
||||
const site = `${relative(file)}:${source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1}`
|
||||
for (const language of SHIPPED_LOCALE_IDS) {
|
||||
const dictionary = entries.get(language)
|
||||
if (dictionary === undefined) problems.push(`${site}: registration has no ${language} dictionary`)
|
||||
else if (ts.isIdentifier(dictionary) && !imported.has(dictionary.text) && !locals.has(dictionary.text)) {
|
||||
problems.push(`${site}: ${language} dictionary ${dictionary.text} is not imported or declared`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
return problems
|
||||
}
|
||||
|
||||
describe('shipped locale dictionaries', () => {
|
||||
it('declares the same keys in zh and en, so the single fallback locale always resolves', () => {
|
||||
it('registers every shipped language alongside every object-form bilingual dictionary', () => {
|
||||
expect(sourceFiles().flatMap(registrationProblems)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps bilingual keys aligned and shipped-language keys and placeholders complete', () => {
|
||||
const files = sourceFiles()
|
||||
// Guard the discovery itself: an empty or narrowed sweep would pass every
|
||||
// assertion below while checking nothing.
|
||||
@@ -253,12 +326,18 @@ describe('shipped locale dictionaries', () => {
|
||||
const perFile = new Map<string, Dictionary[]>()
|
||||
for (const file of files) {
|
||||
const dicts = dictionariesIn(file)
|
||||
if (dicts.length > 0) perFile.set(relative(file), dicts)
|
||||
if (dicts.length > 0) {
|
||||
expect(hasInvisibleLocaleCharacters(readFileSync(file, 'utf8')), relative(file)).toBe(false)
|
||||
for (const dictionary of dicts) {
|
||||
for (const text of Object.values(dictionary.templates)) expect(hasInvisibleLocaleCharacters(text), relative(file)).toBe(false)
|
||||
}
|
||||
perFile.set(relative(file), dicts)
|
||||
}
|
||||
}
|
||||
|
||||
const groups = new Map<string, Map<'zh' | 'en', Dictionary>>()
|
||||
const place = (key: string, locale: 'zh' | 'en', dict: Dictionary): void => {
|
||||
const slot = groups.get(key) ?? new Map<'zh' | 'en', Dictionary>()
|
||||
const groups = new Map<string, Map<ShippedLocaleId, Dictionary>>()
|
||||
const place = (key: string, locale: ShippedLocaleId, dict: Dictionary): void => {
|
||||
const slot = groups.get(key) ?? new Map<ShippedLocaleId, Dictionary>()
|
||||
if (slot.has(locale)) {
|
||||
throw new Error(`two ${locale} dictionaries claim pair ${key}: ${slot.get(locale)?.file} and ${dict.file}`)
|
||||
}
|
||||
@@ -298,6 +377,26 @@ describe('shipped locale dictionaries', () => {
|
||||
const enOnly = en.keys.filter(k => !zh.keys.includes(k))
|
||||
if (zhOnly.length > 0) problems.push(`${zh.file} ${zh.name} has keys absent from ${en.name}: ${zhOnly.join(', ')}`)
|
||||
if (enOnly.length > 0) problems.push(`${en.file} ${en.name} has keys absent from ${zh.name}: ${enOnly.join(', ')}`)
|
||||
for (const language of SHIPPED_LOCALE_IDS.filter(id => id !== 'en' && id !== 'zh')) {
|
||||
const translatedDictionary = slot.get(language)
|
||||
if (translatedDictionary === undefined) {
|
||||
problems.push(`${key} has no ${language} dictionary`)
|
||||
} else {
|
||||
const missing = en.keys.filter(k => !translatedDictionary.keys.includes(k))
|
||||
const extra = translatedDictionary.keys.filter(k => !en.keys.includes(k))
|
||||
if (missing.length > 0 || extra.length > 0) {
|
||||
problems.push(`${translatedDictionary.file} ${translatedDictionary.name}: missing [${missing.join(', ')}], extra [${extra.join(', ')}]`)
|
||||
}
|
||||
for (const [name, text] of Object.entries(en.templates)) {
|
||||
const translated = translatedDictionary.templates[name]
|
||||
if (translated === undefined) continue
|
||||
const placeholders = (value: string): string => localeTokens(value).join(', ')
|
||||
if (placeholders(text) !== placeholders(translated)) {
|
||||
problems.push(`${translatedDictionary.file} ${translatedDictionary.name}.${name} changes placeholders or literal tokens from ${en.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The shipped dictionary count only grows; a collapse means discovery or
|
||||
|
||||
@@ -9,7 +9,16 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github
|
||||
concurrency: { group: string; 'cancel-in-progress': boolean }
|
||||
jobs: Record<string, {
|
||||
'runs-on': string[]
|
||||
steps: Array<{ id?: string; name?: string; if?: string; uses?: string; run?: string; with?: Record<string, unknown> }>
|
||||
env: Record<string, string>
|
||||
steps: Array<{
|
||||
id?: string
|
||||
name?: string
|
||||
if?: string
|
||||
uses?: string
|
||||
run?: string
|
||||
env?: Record<string, string>
|
||||
with?: Record<string, unknown>
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -29,6 +38,7 @@ it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silic
|
||||
expect(uploads[0]?.if).toBe("always() && steps.checkout.outcome == 'success'")
|
||||
expect(uploads[1]?.if).toBe('success()')
|
||||
expect(uploads[1]?.with?.path).toBe('${{ steps.retained.outputs.directory }}/*')
|
||||
expect(uploads[1]?.with?.['compression-level']).toBe(0)
|
||||
})
|
||||
|
||||
it('defaults to notarization and labels the explicit debugging option', () => {
|
||||
@@ -40,3 +50,21 @@ it('defaults to notarization and labels the explicit debugging option', () => {
|
||||
expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2)
|
||||
expect(steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ steps.settings.outputs.mode }}')
|
||||
})
|
||||
|
||||
it('retains build inputs outside checkout and run-specific pnpm installations', () => {
|
||||
const env = workflow.jobs.package!.env
|
||||
expect(JSON.stringify(env)).not.toContain('runner.')
|
||||
const steps = workflow.jobs.package!.steps
|
||||
const cache = steps.find(step => step.name === 'Configure persistent package caches')!
|
||||
expect(cache.env?.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache')
|
||||
expect(cache.run).toContain('PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store')
|
||||
expect(cache.run).toContain('DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR')
|
||||
expect(steps.indexOf(cache)).toBeLessThan(steps.findIndex(step => step.name === 'Install locked dependencies'))
|
||||
})
|
||||
|
||||
it('offers ZIP compression choices with a fast default', () => {
|
||||
expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { zip_compression_level: {
|
||||
type: 'choice', options: ['3', '7'], default: '3', required: true,
|
||||
} } })
|
||||
expect(workflow.jobs.package!.env.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL).toBe('${{ inputs.zip_compression_level }}')
|
||||
})
|
||||
@@ -64,7 +64,6 @@
|
||||
"187ad4724635a2d845592799c3a2600437c0d2fe17e0d1c3018018d5e8b0b863": 1
|
||||
},
|
||||
"apps/desktop/tests/update-dialog.spec.ts": {
|
||||
"3226b936a41e00f2cfd8b9563d3bfcc8eaef7cf04996abe74fb981169a07b91b": 1,
|
||||
"4000017f1f3dabd4bdf8eb91120cf4ff0dd09bad89d4683151f4785974d204a5": 1
|
||||
},
|
||||
"apps/desktop/tests/update-http-executor.spec.ts": {
|
||||
@@ -97,7 +96,6 @@
|
||||
"d0d869812e25f9b60a88de7207566804dc7d1f38c74e0c1d67a7b25fee5af3e9": 1
|
||||
},
|
||||
"apps/web/tests/shipped-composition.e2e.ts": {
|
||||
"5bb6193769cfbb9fc09abd705fed714917784de269546fde14ac1171fb00644b": 1,
|
||||
"c0a4d4dd9b52881eb8190c56fb8ea5a25a70590b78b1e4a0996defab767699f0": 1
|
||||
},
|
||||
"apps/web/tests/smoke-real.e2e.ts": {
|
||||
@@ -206,9 +204,6 @@
|
||||
"packages/api/session-controller/src/list.ts": {
|
||||
"a288679a4dcd49ed1fea620d9f3442c1f518406ad8d2d49dba1fafe8a45fbcbd": 1
|
||||
},
|
||||
"packages/api/session-controller/src/model-selection-projection.ts": {
|
||||
"921e80b20107c8b88b0fc3cb1ebc1fe52d465175fe782cf9e7a0b72e505ff427": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/agent.host.spec.ts": {
|
||||
"40b9b8efe71e65b5bddad33acf9b7595d58a7427f077e3aa9ccc488ebb23f4d0": 1,
|
||||
"4ddb5771e76b6c641d7049711207ce32902b1599afc4736514bab35c6e1fdf07": 1,
|
||||
@@ -219,21 +214,15 @@
|
||||
"e929814f19128d947cc0188ecfe39833ffd5cf3f2c7155ddfef94ba9740d2bfd": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts": {
|
||||
"246d9f25f7a12a2a3946993bc28f1c5a47d7dee57a4dab75bd44a10d39c5d6e4": 1,
|
||||
"8204ecbe96e5e470dde7a6dad4f8a0e6809c30bd8095af16a3a5d378e6c6c0c7": 4,
|
||||
"e9721557773cba96e50cea1b0c6737991a9fb2490bcd83e934311e46310d8b68": 1,
|
||||
"e9ce9370ab27e1dbff54a0ae9a250b10f0314069d41fef58c1822652747be8ff": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/commands-upload-file.host.spec.ts": {
|
||||
"ecd545e1cc141b29fc9da1988bc3ceab4135f376c0c4d12aaa1a1ab67e2d4b05": 1,
|
||||
"ff123c922407e6cb6d42b06534e1e7fb2da129cb09dc6a82d801b8d536540341": 1
|
||||
"e9721557773cba96e50cea1b0c6737991a9fb2490bcd83e934311e46310d8b68": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/control-queue.host.spec.ts": {
|
||||
"9b2258e1e99500b30d66b0b93e0f0bc5077278877903fc8cc6712167411f6109": 1,
|
||||
"f2fe8bd862649a2492b7f99f0c35e78ba051a248f339d248d0dcfab163c69752": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/controller.host.spec.ts": {
|
||||
"40725eab4d86929b16d87c88d5cf83168035def78b4d901613dd0369876f0731": 4,
|
||||
"40725eab4d86929b16d87c88d5cf83168035def78b4d901613dd0369876f0731": 3,
|
||||
"6667f55d27d315e8906a2ffdd1b7ab83c3785036fce5575f865b1dab9f3710b3": 2
|
||||
},
|
||||
"packages/api/session-controller/tests/event-script.client.ts": {
|
||||
@@ -255,11 +244,8 @@
|
||||
},
|
||||
"packages/api/session-controller/tests/session-cold.host.spec.ts": {
|
||||
"23129d43b519124d7c915c0f1dfd075c1d84579c176678fa8905d0ba02403406": 1,
|
||||
"36809c8a43a7b9240089e359618107af0a42e948791f0502d6366df65d43208f": 2,
|
||||
"4e05d4d65c076fff556c0dd813f53e90b8c98390825e7293033d8450a8f94ff0": 1,
|
||||
"526a22860f8bc330caf4a3f28a28948de5c04b4e17125f04b3738808cfbd4e3e": 1,
|
||||
"9d54088afe33da8176bd4898a775c5a5181111a52a8f682ca300ade28684feac": 1,
|
||||
"a6a04773270367c3ea0f54b577d3f766ff738afb6702e09caac1b3fa66a7397d": 1
|
||||
"526a22860f8bc330caf4a3f28a28948de5c04b4e17125f04b3738808cfbd4e3e": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/session-fork.host.spec.ts": {
|
||||
"dbbb38fac6189a3be6f7c29f8df6ef34679f5956c168d08d4f058b5d66b24089": 1
|
||||
@@ -267,18 +253,12 @@
|
||||
"packages/api/session-controller/tests/session-history-journal.host.spec.ts": {
|
||||
"4e6289bbcd36e12927fd2ac87dc09b4de8ac884f2a0041af58383a0144b8b9b1": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/session-models.host.spec.ts": {
|
||||
"7f2d35b6be247c2410599d4b91260905bc2ada88f62507af622fe067a9295f5b": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/session-pending-submissions.client.spec.ts": {
|
||||
"f35467810e1f7bd9ea7659c947c274c48d310411d0eabcf528366708ea3b7ca1": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/session-presets.host.spec.ts": {
|
||||
"c051663ae60f381f30367e71bae4da0ea88c490453bbc1d86230b71c0fde8d6f": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/session-remote-rejection.client.spec.ts": {
|
||||
"01c40d09d9157679ffe02d7cce2d1761ffd42d4a569a7176d5eeec838a09edc1": 1
|
||||
},
|
||||
"packages/api/session-controller/tests/session.client.spec.ts": {
|
||||
"52faf4558fcd91bd27dbdfa7e497843e8eeb75ea5f68ee231335018d5b3f2053": 3
|
||||
},
|
||||
@@ -422,7 +402,6 @@
|
||||
"packages/client/modules/tests/node-half.client.spec.ts": {
|
||||
"621628e4e895b24171e82d1b3fa3ecb12970e8eb0dfccab8bacc8c5784188a28": 2,
|
||||
"6c4ace8d135ab45685d0152eefb0f8156bbf95f66cefedd9a1a8da081d3cb180": 1,
|
||||
"6d4d3e6035bfeaac7068a832f0d5c89f1112961164709c0eeb18dd8937d74cac": 1,
|
||||
"8eb8ea3592cbd4e828a33b2cbe2876089d3923738996fb2259ded563811a1a85": 1,
|
||||
"c4f0b7f87a16d26a1e4e7ef7a07991e789f0bbcfdbe2240a97fcd879668b5b3f": 1,
|
||||
"f549d88ac2b1db67fb33f18a024c116051ff83d3f8ad0433ba4ceedc679e9044": 2
|
||||
@@ -443,9 +422,6 @@
|
||||
"packages/client/ui-attachment/tests/attachment-rail.client.spec.tsx": {
|
||||
"b42ab24a43e0f7542bf1deb8f7ac3a76698d29e098f8dc575af6c08774987c32": 1
|
||||
},
|
||||
"packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx": {
|
||||
"ab22ebe23f59b5aa76d42502a0421f3953d1d170e8a4f900a0c442daf6f1397d": 1
|
||||
},
|
||||
"packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts": {
|
||||
"a7d8cc8d42e37ad5cc4882241492ac572ed8163c7fb8663180e893c7178e906f": 1
|
||||
},
|
||||
@@ -480,9 +456,6 @@
|
||||
"packages/client/ui-commands/tests/browser-plugin.client.spec.ts": {
|
||||
"04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1
|
||||
},
|
||||
"packages/client/ui-conversation/src/client/apply.ts": {
|
||||
"4c73f40f3ce989f285848573d7154fb295886bba707ad829e2c8c089e326aeea": 1
|
||||
},
|
||||
"packages/client/ui-conversation/src/client/conversation/location-index.ts": {
|
||||
"a7d8cc8d42e37ad5cc4882241492ac572ed8163c7fb8663180e893c7178e906f": 1
|
||||
},
|
||||
@@ -767,9 +740,6 @@
|
||||
"packages/client/ui-workflow-run/tests/workflow-run.client.spec.tsx": {
|
||||
"9f5585ad6cebba08252165f2dcc63c1e16101e0306fab754afb641b417e7789d": 1
|
||||
},
|
||||
"packages/client/ui-workspace/tests/animated-rows.client.spec.tsx": {
|
||||
"a898fd26a24bbfa72e00b9b825779a95c1d1c2bd447ebf8f21e8b2d38c3fb08c": 1
|
||||
},
|
||||
"packages/client/ui-workspace/tests/apply.client.spec.ts": {
|
||||
"b467270b0c2a95d6b29939f6baf4d5c8e5031e99a1d15437a3060f35735d8d37": 1
|
||||
},
|
||||
@@ -929,16 +899,29 @@
|
||||
"packages/core/system-prompt/src/index.ts": {
|
||||
"7fe58280287ff63d40e979e72329f975e11015a95e9a8eec207a5a78748d3c89": 1
|
||||
},
|
||||
"packages/core/tools/src/index.ts": {
|
||||
"6061de0a1e01547eb1abf9d4e1b5fa79fda112fa35dda980775491123413dcce": 1
|
||||
"packages/core/tool-executor-ptc/tests/ptc.spec.ts": {
|
||||
"52faf4558fcd91bd27dbdfa7e497843e8eeb75ea5f68ee231335018d5b3f2053": 1
|
||||
},
|
||||
"packages/core/tool-executor-ptc/tests/py-types.spec.ts": {
|
||||
"0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 9,
|
||||
"23ebbf16be06365be256c1ceb77869576a026644f3514e1737030c4556849f4a": 1,
|
||||
"3ec1a0855f2cf4a5b3646725ea95c07addd33d5957ba6af530b944cd26f1ea8f": 1,
|
||||
"43155833aa226b066cf374829e013176403ddbd6acd313ccf90f6cfb92ceec06": 1,
|
||||
"5d1224b16a64bb138a708eb3e3e130df2ddf1f8f656617e3f1b3f2583250cf63": 1,
|
||||
"6aecd519658d0d2fb040f0289d6db823e3f6bbb26fcb2b118dc7f7f2c1d3eb06": 1,
|
||||
"9b2b04bd2ecde0c56b00ae050b721d56b3c29a1c834b982c9c2f6e62dbcd0a04": 1,
|
||||
"d334b0b55263b6cb2125317ce96b766155b11455d40d600590f5302f5e0543cc": 1,
|
||||
"d6650a0b52b5f7311afff190373b4a1cbd31ba44f9fa0dff09395f6f2901e32d": 1
|
||||
},
|
||||
"packages/core/tool-executor-ptc/tests/ts-types.spec.ts": {
|
||||
"054c87ca977d98749baf70bb5dcad1bb8d9e725d08a480b7804a181434633873": 1,
|
||||
"0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 1,
|
||||
"84a5877f154b369cc0528281904d1dc18e20fd3ad74a26c939301ba0db20ae02": 1,
|
||||
"d80a3368311f07e812a76ff265125b072175f58b6003d9f96a37d4f3e70e3dec": 1
|
||||
},
|
||||
"packages/core/tools/src/schema.ts": {
|
||||
"62870121f1e1644b67c62606e94a375bb40f880cc88edf7cd6baab0249e9da46": 1
|
||||
},
|
||||
"packages/core/tools/src/testing.ts": {
|
||||
"5be3dd49608bd752ae1371aae8c8d748a07158f7a597e2506af79a621f74f0b3": 1,
|
||||
"6fe9024d30e0d4757a348b83d246925b3f5aeaf2e79a0eff4bf12f3c9fb5826b": 1
|
||||
},
|
||||
"packages/core/tools/tests/execution-mode.spec.ts": {
|
||||
"33dec0693f265044c830377981b51852b773e1a8a765e0b3398a4542c646537b": 1,
|
||||
"f9c9afc4ff66b96c59303ffa5475e26719ef101dc45504fee4b56145ff4be02c": 1
|
||||
@@ -953,20 +936,6 @@
|
||||
"c21022b1c0c54a213857769a5d72bc249dce83dd63c9d6f2370139bbea3288c9": 1,
|
||||
"d1250fc465aac30c8474c1ee43ecbafc0a8c072d5e607961da1295acc710a257": 1
|
||||
},
|
||||
"packages/core/tools/tests/ptc.spec.ts": {
|
||||
"52faf4558fcd91bd27dbdfa7e497843e8eeb75ea5f68ee231335018d5b3f2053": 1
|
||||
},
|
||||
"packages/core/tools/tests/py-types.spec.ts": {
|
||||
"0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 9,
|
||||
"23ebbf16be06365be256c1ceb77869576a026644f3514e1737030c4556849f4a": 1,
|
||||
"3ec1a0855f2cf4a5b3646725ea95c07addd33d5957ba6af530b944cd26f1ea8f": 1,
|
||||
"43155833aa226b066cf374829e013176403ddbd6acd313ccf90f6cfb92ceec06": 1,
|
||||
"5d1224b16a64bb138a708eb3e3e130df2ddf1f8f656617e3f1b3f2583250cf63": 1,
|
||||
"6aecd519658d0d2fb040f0289d6db823e3f6bbb26fcb2b118dc7f7f2c1d3eb06": 1,
|
||||
"9b2b04bd2ecde0c56b00ae050b721d56b3c29a1c834b982c9c2f6e62dbcd0a04": 1,
|
||||
"d334b0b55263b6cb2125317ce96b766155b11455d40d600590f5302f5e0543cc": 1,
|
||||
"d6650a0b52b5f7311afff190373b4a1cbd31ba44f9fa0dff09395f6f2901e32d": 1
|
||||
},
|
||||
"packages/core/tools/tests/schema.spec.ts": {
|
||||
"2994ddac72096564a65b1d11cfe50c5b5f21de0cca775ed7b758d031b9a4df99": 1,
|
||||
"7724a969f2b794fc521b1a246d444c75616cf27e6ce073b00e8c8956b626634d": 1,
|
||||
@@ -990,12 +959,6 @@
|
||||
"d3478c9f3a88906a17d5daa6d19c3694a33618cfc000c7c0ee8f3e16f2697322": 1,
|
||||
"f9c9afc4ff66b96c59303ffa5475e26719ef101dc45504fee4b56145ff4be02c": 2
|
||||
},
|
||||
"packages/core/tools/tests/ts-types.spec.ts": {
|
||||
"054c87ca977d98749baf70bb5dcad1bb8d9e725d08a480b7804a181434633873": 1,
|
||||
"0d733b2c7c458f6a7c4d287ed741c321842339cbe5908a8720660883423474c0": 1,
|
||||
"84a5877f154b369cc0528281904d1dc18e20fd3ad74a26c939301ba0db20ae02": 1,
|
||||
"d80a3368311f07e812a76ff265125b072175f58b6003d9f96a37d4f3e70e3dec": 1
|
||||
},
|
||||
"packages/credentials/credentials-local/tests/drain.spec.ts": {
|
||||
"a96f4a3497a1c661f76e47ef5fda3c32d2759a7a5f9c519527fa46801a91bf79": 1
|
||||
},
|
||||
@@ -1017,9 +980,6 @@
|
||||
"bd3a42d7bc38a7deb366d7ad1364f2ab01be2af077aae04e49b9c8a61b9e1c7d": 1,
|
||||
"d8076a64f8d940fb0a5c3df15e9e1c8fb7ac05e84762a6198dbbfcffcf379821": 1
|
||||
},
|
||||
"packages/experimental/auto-review/tests/auto-review.spec.ts": {
|
||||
"5bb6193769cfbb9fc09abd705fed714917784de269546fde14ac1171fb00644b": 1
|
||||
},
|
||||
"packages/experimental/browser-use-runtime/src/index.ts": {
|
||||
"04bc8a44533574365dfedf563e2356458bcf95f990a00906938c977c1d683356": 1
|
||||
},
|
||||
@@ -1305,16 +1265,10 @@
|
||||
"d423bbcd1caff7aab2e2de52564efbc9061e775620a530157805a43d851c6fe9": 1
|
||||
},
|
||||
"packages/interaction/user-approval/tests/approval.spec.ts": {
|
||||
"3261a2af17649cc35cf8698e3b7278101044ed1a07efce757615616e0f918d3a": 1,
|
||||
"4dd1f265ec710503bb27f057a582099a1e9d8b5db80fb45f148e32fd2afb5325": 2,
|
||||
"744b30e13b6390439c34f4970441a292a07f4ac3d26f25e9be75d1b5c18f4f33": 1,
|
||||
"971ec45a1146ab52f2fb13f75b782d5d35c0fb2ddba1fb9ccab14cc54f7291d5": 1,
|
||||
"a6a2f2761dfd14ff3624a7f8684b025d6aa733d41cac4381db7c83f2a38f0c35": 1,
|
||||
"f33c4a795e2cda267cb28d2937c632d56134b8e6438d21e4bf74da2735b1c154": 1
|
||||
},
|
||||
"packages/interaction/user-questions/tests/user-questions.spec.ts": {
|
||||
"60655aea47d3d8667f90341b58e3faaf64d12670b9592af837aa40ecf7b83583": 1
|
||||
},
|
||||
"packages/jobs/jobs-local/tests/jobs.spec.ts": {
|
||||
"52e6bc7e2a6c58a465a5e1c7a1e92caa4f771235b19ebf4642df5c0845de72aa": 1
|
||||
},
|
||||
@@ -1439,10 +1393,6 @@
|
||||
"d2c3345795169400442563b4923671c090a724eacaaae7f0c190297b967c1c4d": 1,
|
||||
"dfa30a521aae411e70eee81fc4c0511178589d0656dea01c1c97131bfe097793": 1
|
||||
},
|
||||
"packages/ptc-runtime/ptc-runtime-node/tests/host-failures.spec.ts": {
|
||||
"aa5de2d4958ec64219565d53ec5a9d7f40f1f60037a2d52d1a9c3c65db034d73": 1,
|
||||
"be027af1ef5cb3bfe41eeb19b54dbb176ec7c23ae367d709059839f9851833d5": 1
|
||||
},
|
||||
"packages/ptc-runtime/ptc-runtime-node/tests/output-json.spec.ts": {
|
||||
"8c4c486bbc27559714f4e0aaf376c72c49ac9157539b03c6c12853b8d3fc6e74": 1
|
||||
},
|
||||
@@ -1521,10 +1471,6 @@
|
||||
"cb524acd0159b942e7974d76b581b1f419b8653d1734946a3f154dda8e8f4762": 1,
|
||||
"f10c6a0a3a762be54c2de87ab5d787e37a21a89c9d468bf81d0658c2bf15d581": 1
|
||||
},
|
||||
"packages/session-query/session-log-export/tests/client-apply.client.spec.tsx": {
|
||||
"04984a2ce1486250236e638e0197da0a1a840855ed3975c59130a85358c7e5fe": 1,
|
||||
"eee35b4d8641824c6eaacfe03ed60d353d81d5257d1111132f34d46a7b13ef35": 1
|
||||
},
|
||||
"packages/session-query/session-log-export/tests/controller.client.spec.ts": {
|
||||
"746ebc5aade01a50cc9a74fc07b1023addf021aef80b62c069a437ac140c9e64": 1,
|
||||
"c920d88f6eb309ef5991608642a1ab0fd972690538b33c34d86c68d07ddd6edf": 1
|
||||
@@ -1532,10 +1478,6 @@
|
||||
"packages/session-query/session-log-export/tests/dialog.client.spec.tsx": {
|
||||
"a9b1969d76db0b05f87c862956f8ff45c52f1f4a5b32a206339de0c9fee99a77": 1
|
||||
},
|
||||
"packages/session-query/session-log-export/tests/header-action.client.spec.tsx": {
|
||||
"238a292bbe3a61acbc4f2d96fff474df5fba1577357219a88c914813786b56a7": 1,
|
||||
"47d3a14a42ef7142ea22071677b42265f02fba5a2a0cf4210f4f9c7c5a876d10": 1
|
||||
},
|
||||
"packages/session-query/session-log-export/tests/loader-composition.host.spec.ts": {
|
||||
"59c63f734e8bba862325f776e3fcbebd78a84cfb4161f8df41c216950d9bf351": 1,
|
||||
"e12d49327a6708ae3ace371e28ed0deb471dc8895c584ba94a86af344c15cec3": 1
|
||||
@@ -1824,8 +1766,8 @@
|
||||
"3e704e331ed2fc5d4c0855e2455b31b82924c525f2e267416e393cdbd36c251b": 1,
|
||||
"fdc5fcade0ba1abbc426ccccb07a1ad4998253799b8561815047d95178441cdf": 1
|
||||
},
|
||||
"packages/subagent/subagent/src/internal.ts": {
|
||||
"89b33a57a7c77261a02116dd015c08d9e6a5b1efb5ba0ebf49bc68a21e7f120a": 1
|
||||
"packages/subagent/subagent/src/projection.ts": {
|
||||
"0cd01f87cd1ae24b558cfe5427d45cc016b499579e5ddd3eab125517339b7c71": 1
|
||||
},
|
||||
"packages/subagent/subagent/tests/assistant-output.spec.ts": {
|
||||
"120a0abb7d737528475c728dd74afab470b668b57e8f5c98d7d4144101547823": 1,
|
||||
@@ -1839,10 +1781,6 @@
|
||||
"packages/subagent/subagent/tests/continuation.spec.ts": {
|
||||
"ae5776af8c1e9965a4eca94bcbc07b65da8d2e9e24e4570d2141b34d60b4f90f": 1
|
||||
},
|
||||
"packages/subagent/subagent/tests/control.spec.ts": {
|
||||
"52faf4558fcd91bd27dbdfa7e497843e8eeb75ea5f68ee231335018d5b3f2053": 1,
|
||||
"764cefa4ddc93e5b865cade70e780d8d471ef51e58148e838681ed4cf0bd7b4a": 1
|
||||
},
|
||||
"packages/subagent/subagent/tests/service.spec.ts": {
|
||||
"41a1501a36d265ae52c5893b26e9e92eacc1012a379afb2948a31d153bcbac2a": 1,
|
||||
"514d368a1007506da97412cee4b14191025685d1693f2c68442bb466b5054e02": 1,
|
||||
@@ -1982,11 +1920,6 @@
|
||||
"da6ccb5610150a9169b5c3949017d7f5e0d60eadd999615e231db00470aa00e7": 1,
|
||||
"f772961d38eac5032c547a0485c4b941da2faa044c428f788934798230fc389a": 1
|
||||
},
|
||||
"packages/test-support/loader-smoke/tests/agent-turn.spec.ts": {
|
||||
"432811d8f7105d0bf1934d2b52456aeb8f202b334486810dc1b6c3d87349b5b1": 1,
|
||||
"efbd107cd9aad870467dc06957b0d0298798245cf063c9f8e2a8b46c10cb3189": 1,
|
||||
"effb170ab353dc903b037ff2e208a85ba87ff1da08f40306f2e292f78609c6c9": 1
|
||||
},
|
||||
"packages/test-support/session-snapshot/src/harness.ts": {
|
||||
"04bc8a44533574365dfedf563e2356458bcf95f990a00906938c977c1d683356": 1
|
||||
},
|
||||
@@ -2078,11 +2011,10 @@
|
||||
"ebe146aded1e6f3e71f166a7f88720e560b336bbb92d19cea0b2bdab58c46f8a": 1
|
||||
},
|
||||
"scripts/persistence-changes.ts": {
|
||||
"2c01874686204d2e1723ae4a9c54f15c98a53cc1e88682b0b432f79f91dd28ac": 3,
|
||||
"2c01874686204d2e1723ae4a9c54f15c98a53cc1e88682b0b432f79f91dd28ac": 1,
|
||||
"eff6e18c764150f86c2db750beb6df4dc99d4ae44bbd9c745d092afda3f45982": 1
|
||||
},
|
||||
"scripts/persistence-developer.spec.ts": {
|
||||
"325d28707ea86aa06f716bd3c4bd21d9c4e91727a778513fe4271bfd2c04c3c5": 1,
|
||||
"908e86b966e23795a88c1adc8a8b0326a1dbbd40f03c48104ca7acb3f4f08157": 1
|
||||
},
|
||||
"scripts/persistence-releases.ts": {
|
||||
|
||||
@@ -8,7 +8,8 @@ import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { loadOverlayPatches } from '../packages/boot/app-boot/src/index.ts'
|
||||
import { readPluginMeta } from '../packages/boot/app-boot/src/package-meta.ts'
|
||||
import { OPTIONAL_BUNDLES, PROFILE_TEMPLATES, bundlePatchPaths, composeEntries, initProfile, readProfileManifest } from '../packages/boot/app-boot/src/profile.ts'
|
||||
import { bundlePatchPaths, composeEntries, initProfile, readProfileManifest } from '../packages/boot/app-boot/src/profile.ts'
|
||||
import { OPTIONAL_BUNDLES, PROFILE_TEMPLATES } from '../packages/util/official-bundles/src/bundles.ts'
|
||||
import type { DshBundleManifest } from '../packages/util/package-manifest/src/types.ts'
|
||||
import { createPluginProfile } from '../apps/desktop/src/project-manager.ts'
|
||||
|
||||
@@ -91,6 +92,9 @@ describe.each(['web', 'desktop'] as const)('%s optional bundle overrides', (surf
|
||||
})
|
||||
|
||||
describe('optional bundles', () => {
|
||||
it('admits Computer Use only on macOS Hosts', () => {
|
||||
expect(bundles.get('@deepseek-ai/dsh-cua-bundle')?.manifest.dsh?.bundle?.platforms).toEqual(['darwin'])
|
||||
})
|
||||
const shipped = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'].map(name => bundle(name).patches)
|
||||
|
||||
it('ships at least one bundle switched off', () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/** Worker entry: collect the source package uses of one slice of workspace packages. */
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import { readAllSourceUses } from './source-parse-uses.ts'
|
||||
import type { PackageDependencyManifest } from './verify-package-dependencies.ts'
|
||||
|
||||
/** One package whose `src` tree this worker walks. */
|
||||
interface SourceUsesTask {
|
||||
/** Position of the package in the caller's selection, used to restore that order. */
|
||||
readonly index: number
|
||||
/** Package directory relative to the repository root. */
|
||||
readonly dir: string
|
||||
/** Package name, reported back so the caller can key the result. */
|
||||
readonly name: string
|
||||
/** Workspace manifest of the package, which decides @types-versus-runtime usage. */
|
||||
readonly manifest: PackageDependencyManifest
|
||||
}
|
||||
|
||||
/** Work one worker receives when it starts. */
|
||||
export interface SourceUsesWorkerRequest {
|
||||
/** Repository root containing the workspace sources. */
|
||||
readonly root: string
|
||||
/** Packages this worker walks, in caller order. */
|
||||
readonly tasks: readonly SourceUsesTask[]
|
||||
}
|
||||
|
||||
/** Source uses collected for one package. */
|
||||
export interface SourceUsesWorkerResult {
|
||||
/** Package name of the analysed package. */
|
||||
readonly name: string
|
||||
/** Bare package name to the repository paths whose source references it. */
|
||||
readonly uses: Map<string, string[]>
|
||||
}
|
||||
|
||||
const request = workerData as SourceUsesWorkerRequest
|
||||
const results: SourceUsesWorkerResult[] = request.tasks.map(task => ({
|
||||
name: task.name,
|
||||
uses: readAllSourceUses(request.root, {
|
||||
dir: task.dir,
|
||||
manifestPath: `${task.dir}/package.json`,
|
||||
manifest: task.manifest,
|
||||
name: task.name,
|
||||
}),
|
||||
}))
|
||||
parentPort?.postMessage(results)
|
||||
@@ -5,7 +5,7 @@ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSyn
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, posix, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaProperty, SourceCompatibility } from './persistence-schema-model.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
|
||||
@@ -25,7 +25,7 @@ function runPersistenceChanges(
|
||||
args: readonly string[], root: string, extract: (root: string) => PersistenceSchemaInventory,
|
||||
): string {
|
||||
return executePersistenceChanges(args, root, extract, (_root, current) => [{
|
||||
path: 'docs/persistence-schema.json', content: JSON.stringify(current, null, 2) + '\n',
|
||||
path: 'docs/persistence-schema.json', content: encodePersistenceSnapshot(current),
|
||||
}])
|
||||
}
|
||||
|
||||
@@ -102,6 +102,25 @@ function entry(
|
||||
}
|
||||
}
|
||||
|
||||
interface SnapshotText {
|
||||
formatVersion: number
|
||||
roots: Record<string, unknown>[]
|
||||
types: Record<string, unknown>[]
|
||||
nodes: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
/** Encode a snapshot, then apply one invalid edit to its committed JSON fields. */
|
||||
function tamper(snapshot: PersistenceSchemaInventory, change: (value: SnapshotText) => void): string {
|
||||
const value = JSON.parse(encodePersistenceSnapshot(snapshot)) as SnapshotText
|
||||
change(value)
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/** Replace the node that the first root graph starts from. */
|
||||
function replaceRootNode(node: Record<string, unknown>): (value: SnapshotText) => void {
|
||||
return (value) => { value.nodes[String(value.roots[0]!.digest).slice(0, 16)] = node }
|
||||
}
|
||||
|
||||
const BASE_ID = '2026-09-11-baseline'
|
||||
const NEXT_ID = '2026-09-11-change'
|
||||
|
||||
@@ -126,7 +145,7 @@ describe('historical persistence snapshot parsing', () => {
|
||||
const root = snapshot.roots[0]!
|
||||
const sources = ['packages/core/example/src/types.ts']
|
||||
const complete = { ...snapshot, types: [{ schema: root.schema, digest: root.digest, names: ['Example'], sources }] }
|
||||
expect(parseHistoricalPersistenceSnapshot(complete)).toEqual(complete)
|
||||
expect(parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(complete))).toEqual(complete)
|
||||
})
|
||||
|
||||
it.each([':8', ':8:3', '#L8', '#L8-L12'])('rejects source coordinates %s only in historical inventories', (suffix) => {
|
||||
@@ -134,29 +153,31 @@ describe('historical persistence snapshot parsing', () => {
|
||||
const root = snapshot.roots[0]!
|
||||
const sources = [`packages/core/example/src/types.ts${suffix}`]
|
||||
const complete = { ...snapshot, types: [{ schema: root.schema, digest: root.digest, names: [], sources }] }
|
||||
expect(() => parseHistoricalPersistenceSnapshot(complete)).toThrow('historical schema sources must omit line numbers')
|
||||
expect(parsePersistenceSnapshot(complete)).toEqual(complete)
|
||||
expect(() => parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(complete))).toThrow('historical schema sources must omit line numbers')
|
||||
expect(parsePersistenceSnapshot(encodePersistenceSnapshot(complete))).toEqual(complete)
|
||||
})
|
||||
|
||||
it('admits optional surface operations only through the historical parser', () => {
|
||||
const snapshot = historicalSurface('optional')
|
||||
expect(() => parsePersistenceSnapshot(snapshot)).toThrow('surface metadata')
|
||||
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(snapshot)
|
||||
expect(parsePersistenceSnapshot(historicalSurface('required'))).toEqual(historicalSurface('required'))
|
||||
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot(snapshot))).toThrow('surface metadata')
|
||||
expect(parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(snapshot))).toEqual(snapshot)
|
||||
expect(parsePersistenceSnapshot(encodePersistenceSnapshot(historicalSurface('required')))).toEqual(historicalSurface('required'))
|
||||
})
|
||||
|
||||
it('still rejects absent operations, mismatched event tags, and invalid roots', () => {
|
||||
expect(() => parseHistoricalPersistenceSnapshot(historicalSurface('absent'))).toThrow('surface metadata')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(historicalSurface('optional', 'wrong/tag'))).toThrow('type does not match')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(historicalSurface('absent')))).toThrow('surface metadata')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(encodePersistenceSnapshot(historicalSurface('optional', 'wrong/tag')))).toThrow('type does not match')
|
||||
const snapshot = historicalSurface('optional')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, key: 'event:wrong/key' })) })).toThrow('invalid event root key')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, surface: false })) })).toThrow('surface metadata')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(tamper(snapshot, (value) => { value.roots[0]!.key = 'event:wrong/key' }))).toThrow('invalid event root key')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(tamper(snapshot, (value) => { value.roots[0]!.surface = false }))).toThrow('surface metadata')
|
||||
})
|
||||
|
||||
it('preserves graph and digest validation in historical mode', () => {
|
||||
const snapshot = historicalSurface('optional')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, digest: 'f'.repeat(64) })) })).toThrow('digest mismatch')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, roots: snapshot.roots.map(root => ({ ...root, schema: { root: 0, nodes: [{ kind: 'array', element: 9 }] } })) })).toThrow('unknown schema node')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(tamper(snapshot, (value) => {
|
||||
value.roots[0]!.digest = String(value.roots[0]!.digest).slice(0, 16) + 'f'.repeat(48)
|
||||
}))).toThrow('digest mismatch')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(tamper(snapshot, replaceRootNode({ kind: 'array', element: '9'.repeat(16) })))).toThrow('unknown schema node')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -312,10 +333,10 @@ describe('accepted persistence baseline', () => {
|
||||
const current = inventory({ value: version === 4 ? 'number' : 'boolean' }, version)
|
||||
if (version === 5) runPersistenceChanges(['--record', FUTURE_ID, '--prose', proseFile(root)], root, () => current)
|
||||
const snapshotPath = join(root, `docs/persistence-changes/${FINALIZED_ID}.schema.json`)
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf8')) as PersistenceSchemaInventory
|
||||
const snapshot = parsePersistenceSnapshot(readFileSync(snapshotPath, 'utf8'))
|
||||
const replacement = typeRoot('event:example/value', { value: 'number', extra: 'string' })
|
||||
const original = snapshot.roots.find(root => root.key === replacement.key)!
|
||||
writeFileSync(snapshotPath, JSON.stringify({ ...snapshot,
|
||||
writeFileSync(snapshotPath, encodePersistenceSnapshot({ ...snapshot,
|
||||
roots: snapshot.roots.map(root => root.key === replacement.key ? replacement : root) }))
|
||||
for (const suffix of ['.md', '.zh.md']) {
|
||||
const path = join(root, `docs/persistence-changes/${FINALIZED_ID}${suffix}`)
|
||||
@@ -375,7 +396,7 @@ function finishDocuments(root: string, id: string): void {
|
||||
}
|
||||
|
||||
function commitCurrent(root: string, schema: PersistenceSchemaInventory): void {
|
||||
writeFileSync(join(root, 'docs/persistence-schema.json'), JSON.stringify(schema, null, 2) + '\n')
|
||||
writeFileSync(join(root, 'docs/persistence-schema.json'), encodePersistenceSnapshot(schema))
|
||||
}
|
||||
|
||||
function baseline(root: string, schema = inventory()): void {
|
||||
@@ -624,15 +645,12 @@ describe('persistence history verification', () => {
|
||||
|
||||
it('rejects malformed references, unknown schema variants, digest tampering, and extra snapshot roots', () => {
|
||||
const schema = inventory()
|
||||
const malformed: { roots: readonly { schema: { nodes: readonly unknown[] }; digest: string }[] } = structuredClone(schema)
|
||||
malformed.roots[0]!.schema.nodes = [{ kind: 'array', element: 99 }]
|
||||
expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node')
|
||||
malformed.roots[0]!.schema.nodes = [{ kind: 'future' }]
|
||||
expect(() => parsePersistenceSnapshot(malformed)).toThrow('unknown schema node kind')
|
||||
const tampered = structuredClone(schema)
|
||||
Object.assign(tampered.roots[0]!, { digest: '0'.repeat(64) })
|
||||
expect(() => parsePersistenceSnapshot(tampered)).toThrow('digest mismatch')
|
||||
expect(() => parsePersistenceSnapshot({ ...schema, formatVersion: 3 })).toThrow('normalization version')
|
||||
expect(() => parsePersistenceSnapshot(tamper(schema, replaceRootNode({ kind: 'array', element: '9'.repeat(16) })))).toThrow('unknown schema node')
|
||||
expect(() => parsePersistenceSnapshot(tamper(schema, replaceRootNode({ kind: 'future' })))).toThrow('unknown schema node kind')
|
||||
expect(() => parsePersistenceSnapshot(tamper(schema, (value) => {
|
||||
value.roots[0]!.digest = String(value.roots[0]!.digest).slice(0, 16) + '0'.repeat(48)
|
||||
}))).toThrow('digest mismatch')
|
||||
expect(() => parsePersistenceSnapshot(tamper(schema, (value) => { value.formatVersion = 3 }))).toThrow('normalization version')
|
||||
const next = entry(NEXT_ID, onlyEvent(inventory({ value: 'string', 'label?': 'string' })), BASE_ID)
|
||||
expect(() => validatePersistenceHistory([entry(BASE_ID, schema, null, true), { ...next, snapshot: inventory() }])).toThrow('snapshot roots')
|
||||
})
|
||||
@@ -951,7 +969,7 @@ describe('recorded source compatibility policy', () => {
|
||||
expect(after.digest).not.toBe(before.digest)
|
||||
expect(classifyPersistenceChange(before, after)).toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false })])
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [after], types: [] }
|
||||
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))).toEqual(snapshot)
|
||||
expect(parsePersistenceSnapshot(encodePersistenceSnapshot(snapshot))).toEqual(snapshot)
|
||||
})
|
||||
|
||||
it('allows ordinary optional fields in an existing group alongside a qualified addition', () => {
|
||||
@@ -1017,25 +1035,30 @@ describe('recorded source compatibility policy', () => {
|
||||
it.each(['auto-review', 'compact-basic', 'ptc-mode'])('rejects saved qualification of reserved producer kind %s', (kind) => {
|
||||
const root = attributedRoot([EXISTING_SOURCE, { kind }], { policy: attributionPolicy([kind]) })
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [root], types: [] }
|
||||
expect(() => parsePersistenceSnapshot(snapshot)).toThrow('invalid source compatibility')
|
||||
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot(snapshot))).toThrow('invalid source compatibility')
|
||||
})
|
||||
|
||||
it('rejects old-format policy fields, policy tampering, unsupported promises and invalid bindings', () => {
|
||||
const root = attributedRoot([EXISTING_SOURCE])
|
||||
const snapshot: PersistenceSchemaInventory = { formatVersion: 2, roots: [root], types: [] }
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, formatVersion: 1 })).toThrow('unknown field compatibility')
|
||||
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot({ ...snapshot, formatVersion: 1 }))).toThrow('unknown field compatibility')
|
||||
const patchPolicy = (patch: Record<string, unknown>) => (value: SnapshotText): void => {
|
||||
const properties = Object.values(value.nodes).flatMap(node => node.kind === 'object' ? node.properties as Record<string, unknown>[] : [])
|
||||
Object.assign(properties.find(property => property.compatibility !== undefined)!.compatibility as object, patch)
|
||||
}
|
||||
for (const patch of [{ version: 2 }, { policy: 'arbitrary' }, { discriminator: 'type' }, { unknownKinds: 'discard' }]) {
|
||||
const tampered = JSON.parse(JSON.stringify(snapshot)) as PersistenceSchemaInventory
|
||||
const property = tampered.roots[0]!.schema.nodes.flatMap(node => node.kind === 'object' ? node.properties : []).find(property => property.compatibility !== undefined)!
|
||||
Object.assign(property.compatibility!, patch)
|
||||
expect(() => parsePersistenceSnapshot(tampered)).toThrow('unsupported source compatibility')
|
||||
expect(() => parsePersistenceSnapshot(tamper(snapshot, patchPolicy(patch)))).toThrow('unsupported source compatibility')
|
||||
}
|
||||
const wrongRole = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([], 'developer') })
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [wrongRole] })).toThrow('invalid source compatibility')
|
||||
const changedQualification = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy([EXISTING_SOURCE.kind]) })
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [{ ...changedQualification, digest: root.digest }] })).toThrow('digest mismatch')
|
||||
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot({ ...snapshot, roots: [wrongRole] }))).toThrow('invalid source compatibility')
|
||||
expect(() => parsePersistenceSnapshot(tamper(snapshot, patchPolicy({ attributionKinds: [EXISTING_SOURCE.kind] })))).toThrow('key does not match')
|
||||
const qualified: PersistenceSchemaInventory = { formatVersion: 2, types: [],
|
||||
roots: [attributedRoot([EXISTING_SOURCE, NEW_SOURCE, { kind: 'other-attribution' }], { policy: attributionPolicy([NEW_SOURCE.kind, 'other-attribution']) })] }
|
||||
for (const kinds of [['other-attribution', NEW_SOURCE.kind], [NEW_SOURCE.kind, NEW_SOURCE.kind, 'other-attribution']]) {
|
||||
expect(() => parsePersistenceSnapshot(tamper(qualified, patchPolicy({ attributionKinds: kinds })))).toThrow('node is not canonical')
|
||||
}
|
||||
const unknownKind = attributedRoot([EXISTING_SOURCE], { policy: attributionPolicy(['absent']) })
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, roots: [unknownKind] })).toThrow('invalid source compatibility')
|
||||
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot({ ...snapshot, roots: [unknownKind] }))).toThrow('invalid source compatibility')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
+142
-130
@@ -4,9 +4,15 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { JSON_SCHEMA, load } from 'js-yaml'
|
||||
import { canonicalizeSchema, reachableSchemaTypes, schemaDigest } from './persistence-schema-model.ts'
|
||||
import {
|
||||
SCHEMA_NODE_KEY_LENGTH, canonicalizeSchema, encodePersistenceSnapshot, renderPersistenceSnapshot,
|
||||
schemaChildren, schemaDigest, schemaTableEntry, serializeSchemaNode,
|
||||
} from './persistence-schema-model.ts'
|
||||
import { matchingSourceCompatibility, sourceKindGroups, validSourceCompatibility } from './persistence-source-policy.ts'
|
||||
import type { CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, SchemaNode, SchemaTupleElement } from './persistence-schema-model.ts'
|
||||
import type {
|
||||
CanonicalSchema, PersistenceRoot, PersistenceSchemaInventory, PersistenceType,
|
||||
SchemaNode, SchemaProperty, SchemaTupleElement, SourceCompatibility,
|
||||
} from './persistence-schema-model.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { persistenceCatalogArtifacts } from './gen-persistence-catalog.ts'
|
||||
import { renderPersistencePair } from './persistence-artifacts.ts'
|
||||
@@ -169,128 +175,138 @@ function digest(value: unknown, label: string): string {
|
||||
return result
|
||||
}
|
||||
|
||||
function reference(value: unknown, count: number, label: string): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 0 || (value as number) >= count) throw new Error(`${label} references an unknown schema node`)
|
||||
return value as number
|
||||
function parseNode(value: unknown, label: string, formatVersion: 1 | 2, ref: (value: unknown) => number): SchemaNode {
|
||||
const node = record(value, label)
|
||||
switch (node.kind) {
|
||||
case 'primitive':
|
||||
keys(node, ['kind', 'type'], label)
|
||||
if (!['null', 'boolean', 'number', 'string', 'never'].includes(String(node.type))) throw new Error(`${label}: invalid primitive`)
|
||||
return { kind: 'primitive', type: node.type as Extract<SchemaNode, { kind: 'primitive' }>['type'] }
|
||||
case 'literal':
|
||||
keys(node, ['kind', 'value'], label)
|
||||
if (!['string', 'boolean', 'number'].includes(typeof node.value)
|
||||
|| typeof node.value === 'number' && !Number.isFinite(node.value)) throw new Error(`${label}: invalid literal`)
|
||||
return { kind: 'literal', value: node.value as string | number | boolean }
|
||||
case 'opaque':
|
||||
keys(node, ['kind', 'reason'], label)
|
||||
if (!['any', 'unknown'].includes(String(node.reason))) throw new Error(`${label}: invalid opaque reason`)
|
||||
return { kind: 'opaque', reason: node.reason as 'any' | 'unknown' }
|
||||
case 'array':
|
||||
keys(node, ['kind', 'element'], label)
|
||||
return { kind: 'array', element: ref(node.element) }
|
||||
case 'tuple':
|
||||
keys(node, ['kind', 'elements'], label)
|
||||
return { kind: 'tuple', elements: array(node.elements, label).map((rawElement): SchemaTupleElement => {
|
||||
const element = record(rawElement, label)
|
||||
keys(element, ['type', 'optional', 'rest'], label)
|
||||
return { type: ref(element.type), optional: bool(element.optional, label), rest: bool(element.rest, label) }
|
||||
}) }
|
||||
case 'object': {
|
||||
keys(node, ['kind', 'properties', 'indices'], label)
|
||||
const names = new Set<string>()
|
||||
const properties = array(node.properties, label).map((rawProperty): SchemaProperty => {
|
||||
const property = record(rawProperty, label)
|
||||
keys(property, ['name', 'type', 'optional'], label, formatVersion === 2 ? ['compatibility'] : [])
|
||||
if (typeof property.name !== 'string') throw new Error(`${label}: property name must be a string`)
|
||||
const name = property.name
|
||||
if (names.has(name)) throw new Error(`${label}: duplicate property ${name}`)
|
||||
names.add(name)
|
||||
return { name, type: ref(property.type), optional: bool(property.optional, label),
|
||||
...(property.compatibility === undefined ? {} : { compatibility: parseCompatibility(property.compatibility, label) }) }
|
||||
})
|
||||
const indices = array(node.indices, label).map((rawIndex) => {
|
||||
const entry = record(rawIndex, label)
|
||||
keys(entry, ['key', 'value'], label)
|
||||
return { key: ref(entry.key), value: ref(entry.value) }
|
||||
})
|
||||
return { kind: 'object', properties, indices }
|
||||
}
|
||||
case 'union':
|
||||
keys(node, ['kind', 'types'], label)
|
||||
if (array(node.types, label).length === 0) throw new Error(`${label}: empty union`)
|
||||
return { kind: 'union', types: (node.types as unknown[]).map(ref) }
|
||||
default:
|
||||
throw new Error(`${label}: unknown schema node kind ${String(node.kind)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseSchema(value: unknown, label: string, formatVersion: 1 | 2): CanonicalSchema {
|
||||
const input = record(value, label)
|
||||
keys(input, ['root', 'nodes'], label)
|
||||
if (input.root !== 0) throw new Error(`${label}.root must be zero`)
|
||||
const nodes = array(input.nodes, `${label}.nodes`)
|
||||
if (nodes.length === 0) throw new Error(`${label}.nodes must not be empty`)
|
||||
const ref = (value: unknown): number => reference(value, nodes.length, label)
|
||||
for (const [index, raw] of nodes.entries()) {
|
||||
const node = record(raw, `${label}.nodes[${index}]`)
|
||||
switch (node.kind) {
|
||||
case 'primitive':
|
||||
keys(node, ['kind', 'type'], label)
|
||||
if (!['null', 'boolean', 'number', 'string', 'never'].includes(String(node.type))) throw new Error(`${label}: invalid primitive`)
|
||||
break
|
||||
case 'literal':
|
||||
keys(node, ['kind', 'value'], label)
|
||||
if (!['string', 'boolean', 'number'].includes(typeof node.value)
|
||||
|| typeof node.value === 'number' && !Number.isFinite(node.value)) throw new Error(`${label}: invalid literal`)
|
||||
break
|
||||
case 'opaque':
|
||||
keys(node, ['kind', 'reason'], label)
|
||||
if (!['any', 'unknown'].includes(String(node.reason))) throw new Error(`${label}: invalid opaque reason`)
|
||||
break
|
||||
case 'array':
|
||||
keys(node, ['kind', 'element'], label)
|
||||
ref(node.element)
|
||||
break
|
||||
case 'tuple':
|
||||
keys(node, ['kind', 'elements'], label)
|
||||
for (const rawElement of array(node.elements, label)) {
|
||||
const element = record(rawElement, label)
|
||||
keys(element, ['type', 'optional', 'rest'], label)
|
||||
ref(element.type)
|
||||
bool(element.optional, label)
|
||||
bool(element.rest, label)
|
||||
}
|
||||
break
|
||||
case 'object': {
|
||||
keys(node, ['kind', 'properties', 'indices'], label)
|
||||
const names = new Set<string>()
|
||||
for (const rawProperty of array(node.properties, label)) {
|
||||
const property = record(rawProperty, label)
|
||||
keys(property, ['name', 'type', 'optional'], label, formatVersion === 2 ? ['compatibility'] : [])
|
||||
if (property.compatibility !== undefined) {
|
||||
const policy = record(property.compatibility, `${label} source compatibility`)
|
||||
keys(policy, ['version', 'policy', 'binding', 'discriminator', 'unknownKinds', 'attributionKinds'], label)
|
||||
if (policy.version !== 1 || policy.policy !== 'session-source-attribution'
|
||||
|| !['session.user-message.source', 'session.developer-message.source'].includes(String(policy.binding))
|
||||
|| policy.discriminator !== 'kind' || policy.unknownKinds !== 'preserve') {
|
||||
throw new Error(`${label}: unsupported source compatibility policy`)
|
||||
}
|
||||
for (const kind of array(policy.attributionKinds, label)) textValue(kind, label)
|
||||
}
|
||||
if (typeof property.name !== 'string') throw new Error(`${label}: property name must be a string`)
|
||||
const name = property.name
|
||||
if (names.has(name)) throw new Error(`${label}: duplicate property ${name}`)
|
||||
names.add(name)
|
||||
ref(property.type)
|
||||
bool(property.optional, label)
|
||||
}
|
||||
for (const rawIndex of array(node.indices, label)) {
|
||||
const entry = record(rawIndex, label)
|
||||
keys(entry, ['key', 'value'], label)
|
||||
ref(entry.key)
|
||||
ref(entry.value)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'union':
|
||||
keys(node, ['kind', 'types'], label)
|
||||
if (array(node.types, label).length === 0) throw new Error(`${label}: empty union`)
|
||||
for (const item of node.types as unknown[]) ref(item)
|
||||
break
|
||||
default:
|
||||
throw new Error(`${label}: unknown schema node kind ${String(node.kind)}`)
|
||||
}
|
||||
function parseCompatibility(value: unknown, label: string): SourceCompatibility {
|
||||
const policy = record(value, `${label} source compatibility`)
|
||||
keys(policy, ['version', 'policy', 'binding', 'discriminator', 'unknownKinds', 'attributionKinds'], label)
|
||||
if (policy.version !== 1 || policy.policy !== 'session-source-attribution'
|
||||
|| (policy.binding !== 'session.user-message.source' && policy.binding !== 'session.developer-message.source')
|
||||
|| policy.discriminator !== 'kind' || policy.unknownKinds !== 'preserve') {
|
||||
throw new Error(`${label}: unsupported source compatibility policy`)
|
||||
}
|
||||
const schema = input as unknown as CanonicalSchema
|
||||
for (const node of schema.nodes) {
|
||||
if (node.kind !== 'object') continue
|
||||
for (const property of node.properties) {
|
||||
if (property.compatibility !== undefined && !validSourceCompatibility(schema.nodes, node, property)) {
|
||||
throw new Error(`${label}: invalid source compatibility binding or attribution kinds`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const canonical = canonicalizeSchema(schema.nodes, schema.root)
|
||||
if (JSON.stringify(canonical) !== JSON.stringify(schema)) throw new Error(`${label}: schema is not canonical`)
|
||||
return schema
|
||||
return { version: 1, policy: 'session-source-attribution', binding: policy.binding, discriminator: 'kind', unknownKinds: 'preserve',
|
||||
attributionKinds: array(policy.attributionKinds, label).map(kind => textValue(kind, label)) }
|
||||
}
|
||||
|
||||
/** Parse full or compact inventories, restoring type graphs and rejecting invalid digests.
|
||||
* @param value - JSON read from the current inventory or an enforced acknowledgement snapshot.
|
||||
* @returns the validated inventory.
|
||||
/** Parse the committed current inventory or an enforced acknowledgement snapshot.
|
||||
* @param text - JSON text in the layout written by encodePersistenceSnapshot.
|
||||
* @returns the validated inventory with canonical graphs.
|
||||
*/
|
||||
export function parsePersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
|
||||
return parseSnapshot(value, false)
|
||||
export function parsePersistenceSnapshot(text: string): PersistenceSchemaInventory {
|
||||
return parseSnapshot(text, false)
|
||||
}
|
||||
|
||||
/** Parse a historical inventory with path-only source references and optional surface operations.
|
||||
* @param value - JSON captured from a historical source tree.
|
||||
* @param text - JSON text captured from a historical source tree.
|
||||
* @returns the validated inventory; current acknowledgements use the strict parser.
|
||||
*/
|
||||
export function parseHistoricalPersistenceSnapshot(value: unknown): PersistenceSchemaInventory {
|
||||
return parseSnapshot(value, true)
|
||||
export function parseHistoricalPersistenceSnapshot(text: string): PersistenceSchemaInventory {
|
||||
return parseSnapshot(text, true)
|
||||
}
|
||||
|
||||
function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaInventory {
|
||||
const input = record(value, 'schema inventory')
|
||||
keys(input, ['formatVersion', 'roots', 'types'], 'schema inventory')
|
||||
function parseSnapshot(text: string, historical: boolean): PersistenceSchemaInventory {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
const input = record(parsed, 'schema inventory')
|
||||
keys(input, ['formatVersion', 'roots', 'types', 'nodes'], 'schema inventory')
|
||||
const formatVersion = input.formatVersion
|
||||
if (formatVersion !== 1 && formatVersion !== 2) throw new Error('unsupported persistence schema normalization version')
|
||||
const table = record(input.nodes, 'schema nodes')
|
||||
const nodeKeys = Object.keys(table)
|
||||
const positions = new Map(nodeKeys.map((key, index) => [key, index]))
|
||||
const position = (value: unknown, label: string): number => {
|
||||
const found = typeof value === 'string' ? positions.get(value) : undefined
|
||||
if (found === undefined) throw new Error(`${label} references an unknown schema node`)
|
||||
return found
|
||||
}
|
||||
const graph = nodeKeys.map(key =>
|
||||
parseNode(table[key], `schema node ${key}`, formatVersion, value => position(value, `schema node ${key}`)))
|
||||
for (const [index, key] of nodeKeys.entries()) {
|
||||
const node = graph[index] as SchemaNode
|
||||
if (node.kind === 'object') {
|
||||
for (const property of node.properties) {
|
||||
if (property.compatibility !== undefined && !validSourceCompatibility(graph, node, property)) {
|
||||
throw new Error(`schema node ${key}: invalid source compatibility binding or attribution kinds`)
|
||||
}
|
||||
}
|
||||
}
|
||||
const [canonicalKey, canonical] = schemaTableEntry(graph, index)
|
||||
if (canonicalKey !== key) throw new Error(`schema node ${key}: key does not match its subgraph digest`)
|
||||
if (JSON.stringify(canonical) !== JSON.stringify(table[key])) throw new Error(`schema node ${key}: node is not canonical`)
|
||||
}
|
||||
const used = new Set<number>()
|
||||
const graphOf = (graphDigest: string, label: string): CanonicalSchema => {
|
||||
const prefix = graphDigest.slice(0, SCHEMA_NODE_KEY_LENGTH)
|
||||
const index = positions.get(prefix)
|
||||
if (index === undefined) throw new Error(`${label}: no schema node for digest prefix ${prefix}`)
|
||||
const pending = [index]
|
||||
while (pending.length > 0) {
|
||||
const next = pending.pop() as number
|
||||
if (used.has(next)) continue
|
||||
used.add(next)
|
||||
pending.push(...schemaChildren(graph[next] as SchemaNode))
|
||||
}
|
||||
const schema = canonicalizeSchema(graph, index)
|
||||
if (graphDigest !== schemaDigest(schema)) throw new Error(`${label}: schema digest mismatch`)
|
||||
return schema
|
||||
}
|
||||
const names = new Set<string>()
|
||||
const schemas: CanonicalSchema[] = []
|
||||
for (const rawRoot of array(input.roots, 'schema roots')) {
|
||||
const roots = array(input.roots, 'schema roots').map((rawRoot): PersistenceRoot => {
|
||||
const root = record(rawRoot, 'schema root')
|
||||
keys(root, ['key', 'kind', 'digest', 'schema'], 'schema root', ['event', 'surface'])
|
||||
keys(root, ['key', 'kind', 'digest'], 'schema root', ['event', 'surface'])
|
||||
const key = textValue(root.key, 'root key')
|
||||
if (names.has(key)) throw new Error(`duplicate schema root ${key}`)
|
||||
names.add(key)
|
||||
@@ -300,26 +316,17 @@ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaIn
|
||||
} else if ((root.kind !== 'header' || !['SessionHeader', 'JsonlHeaderLine'].includes(key))
|
||||
&& (root.kind !== 'envelope' || key !== 'SessionEventEnvelope')) throw new Error(`invalid schema root ${key}`)
|
||||
if (root.kind !== 'event' && (root.event !== undefined || root.surface !== undefined)) throw new Error(`${key}: non-event metadata`)
|
||||
const schema = parseSchema(root.schema, key, formatVersion)
|
||||
const schema = graphOf(digest(root.digest, `${key} digest`), key)
|
||||
if (root.kind === 'event') validateEventMetadata(schema, String(root.event), root.surface === true, historical)
|
||||
if (digest(root.digest, `${key} digest`) !== schemaDigest(schema)) throw new Error(`${key}: schema digest mismatch`)
|
||||
schemas.push(schema)
|
||||
}
|
||||
let reachable: Map<string, CanonicalSchema> | undefined
|
||||
const types = array(input.types, 'schema types').map((rawType) => {
|
||||
return root.kind === 'event'
|
||||
? { key, kind: 'event', event: root.event as string, surface: root.surface as boolean, digest: root.digest as string, schema }
|
||||
: { key, kind: root.kind, digest: root.digest as string, schema }
|
||||
})
|
||||
const types = array(input.types, 'schema types').map((rawType): PersistenceType => {
|
||||
const type = record(rawType, 'schema type')
|
||||
keys(type, ['digest', 'names', 'sources'], 'schema type', ['schema'])
|
||||
keys(type, ['digest', 'names', 'sources'], 'schema type')
|
||||
const typeDigest = digest(type.digest, 'shared digest')
|
||||
let schema: CanonicalSchema
|
||||
if ('schema' in type) {
|
||||
schema = parseSchema(type.schema, 'shared schema', formatVersion)
|
||||
if (typeDigest !== schemaDigest(schema)) throw new Error('shared schema digest mismatch')
|
||||
} else {
|
||||
reachable ??= reachableSchemaTypes(schemas)
|
||||
const found = reachable.get(typeDigest)
|
||||
if (found === undefined) throw new Error(`shared schema digest ${typeDigest} is not reachable from roots`)
|
||||
schema = found
|
||||
}
|
||||
const schema = graphOf(typeDigest, `shared schema ${typeDigest}`)
|
||||
const typeNames = array(type.names, 'type names').map(name => textValue(name, 'type name'))
|
||||
const sources = array(type.sources, 'type sources').map((source) => {
|
||||
const location = textValue(source, 'type source')
|
||||
@@ -330,7 +337,13 @@ function parseSnapshot(value: unknown, historical: boolean): PersistenceSchemaIn
|
||||
})
|
||||
return { digest: typeDigest, schema, names: typeNames, sources }
|
||||
})
|
||||
return { ...input as unknown as PersistenceSchemaInventory, types }
|
||||
const orphan = nodeKeys.find((_, index) => !used.has(index))
|
||||
if (orphan !== undefined) throw new Error(`schema node ${orphan} is not reachable from any root or type`)
|
||||
const inventory: PersistenceSchemaInventory = { formatVersion, roots, types }
|
||||
const serialized = new Map(nodeKeys.map((key, index) =>
|
||||
[key, serializeSchemaNode(graph[index] as SchemaNode, child => nodeKeys[child] as string)]))
|
||||
if (renderPersistenceSnapshot(inventory, serialized) !== text) throw new Error('schema inventory is not in canonical layout')
|
||||
return inventory
|
||||
}
|
||||
|
||||
function validateEventMetadata(schema: CanonicalSchema, event: string, surface: boolean, historical: boolean): void {
|
||||
@@ -654,7 +667,7 @@ function readPersistenceEntries(root: string, allowIncompleteId?: string): Persi
|
||||
const change = parseDocument(source, filename, allowIncomplete)
|
||||
const snapshotName = `${change.id}.schema.json`
|
||||
if (!snapshots.delete(snapshotName)) throw new Error(`${filename}: missing schema snapshot ${snapshotName}`)
|
||||
const snapshot = parsePersistenceSnapshot(JSON.parse(readFileSync(join(directory, snapshotName), 'utf8')))
|
||||
const snapshot = parsePersistenceSnapshot(readFileSync(join(directory, snapshotName), 'utf8').replaceAll('\r\n', '\n'))
|
||||
const translatedName = `${change.id}.zh.md`
|
||||
if (!files.includes(translatedName)) throw new Error(`${filename}: missing Chinese counterpart`)
|
||||
const translated = readFileSync(join(directory, translatedName), 'utf8').replaceAll('\r\n', '\n')
|
||||
@@ -705,8 +718,7 @@ export function verifyPersistenceChanges(root: string, current: PersistenceSchem
|
||||
if (!existsSync(committedPath)) {
|
||||
throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is missing; regenerate the persistence catalog`, 'generated-artifact-missing', differences, transitions)
|
||||
}
|
||||
const committed = parsePersistenceSnapshot(JSON.parse(readFileSync(committedPath, 'utf8')))
|
||||
if (JSON.stringify(committed) !== JSON.stringify(current)) {
|
||||
if (readFileSync(committedPath, 'utf8') !== encodePersistenceSnapshot(current)) {
|
||||
throw new PersistenceChangeFailure(`${CURRENT_SCHEMA} is stale; regenerate the persistence catalog`, 'stale-artifacts', differences, transitions)
|
||||
}
|
||||
if (differences.length !== 0) {
|
||||
@@ -812,7 +824,7 @@ function executeCommand(
|
||||
const operation = commandOperation(args)
|
||||
if (operation === 'check' && values.prose !== undefined) throw new Error('--prose requires --baseline, --record, or --update')
|
||||
const prose = values.prose === undefined ? undefined : parsePersistenceProse(JSON.parse(readFileSync(resolve(root, values.prose), 'utf8')))
|
||||
const current = parsePersistenceSnapshot(extract(root))
|
||||
const current = parsePersistenceSnapshot(encodePersistenceSnapshot(extract(root)))
|
||||
if (operation === 'check') {
|
||||
const history = verifyPersistenceChanges(root, current)
|
||||
return { schemaVersion: 1, ok: true, operation,
|
||||
@@ -867,7 +879,7 @@ function executeCommand(
|
||||
parseDocument(chinese, `${id}.md`, prose === undefined && existing === undefined)
|
||||
const recordFiles = [
|
||||
...renderPersistencePair(root, `${HISTORY_DIRECTORY}/${id}.md`, english, chinese),
|
||||
{ path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: JSON.stringify(snapshot, null, 2) + '\n' },
|
||||
{ path: `${HISTORY_DIRECTORY}/${id}.schema.json`, content: encodePersistenceSnapshot(snapshot) },
|
||||
]
|
||||
if (!update && recordFiles.some(file => existsSync(join(root, file.path)))) throw new Error(`${id}: acknowledgement file already exists`)
|
||||
const outputs = [...artifacts(root, current), ...recordFiles]
|
||||
|
||||
@@ -9,7 +9,7 @@ import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import { classifyPersistenceChange, parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
|
||||
function toolAdditionSchema() {
|
||||
const inventory = parsePersistenceSnapshot(JSON.parse(readFileSync(new URL('../docs/persistence-schema.json', import.meta.url), 'utf8')) as unknown)
|
||||
const inventory = parsePersistenceSnapshot(readFileSync(new URL('../docs/persistence-schema.json', import.meta.url), 'utf8'))
|
||||
const before = inventory.roots.find(root => root.key === 'event:developer/message')!
|
||||
const nodes = [...structuredClone(before.schema.nodes)]
|
||||
const blockIndex = nodes.findIndex(node => node.kind === 'object' && node.properties.some((property) => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { dump } from 'js-yaml'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { loadPersistenceFormats, runPersistenceFormats } from './persistence-formats.ts'
|
||||
import { parseHistoricalPersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory, PersistenceType, SchemaNode } from './persistence-schema-model.ts'
|
||||
|
||||
const temporary: string[] = []
|
||||
@@ -57,7 +57,12 @@ function schemaPath(version: number, currentVersion: number): string {
|
||||
}
|
||||
|
||||
function replaceSnapshot(root: string, version: number, currentVersion: number, snapshot: PersistenceSchemaInventory): void {
|
||||
write(root, schemaPath(version, currentVersion), JSON.stringify(snapshot))
|
||||
write(root, schemaPath(version, currentVersion), encodePersistenceSnapshot(snapshot))
|
||||
}
|
||||
|
||||
/** Keep a digest's node-table prefix while changing the full digest. */
|
||||
function driftDigest(digest: string): string {
|
||||
return digest.slice(0, 16) + '0'.repeat(48)
|
||||
}
|
||||
|
||||
function saveReference(root: string, version: number, currentVersion: number, snapshot = inventory(version)): void {
|
||||
@@ -125,14 +130,14 @@ describe('archive current persistence format', () => {
|
||||
|
||||
expect(runPersistenceFormats(['--archive', '3'], root)).toBe('Archived Session format v3 to docs/persistence-changes/historical-formats/v3.schema.json.')
|
||||
const serialized = readFileSync(join(root, 'docs/persistence-changes/historical-formats/v3.schema.json'), 'utf8')
|
||||
const archived = parseHistoricalPersistenceSnapshot(JSON.parse(serialized))
|
||||
const archived = parseHistoricalPersistenceSnapshot(serialized)
|
||||
expect(archived).toEqual({
|
||||
...original,
|
||||
types: types.map(type => ({ ...type, sources: [
|
||||
'packages/example/src/types.ts', 'packages/other/src/types.ts', 'packages/plain/src/types.ts', 'packages/fragment/src/types.ts',
|
||||
] })),
|
||||
})
|
||||
expect(serialized).toBe(JSON.stringify(archived, null, 2) + '\n')
|
||||
expect(serialized).toBe(encodePersistenceSnapshot(archived))
|
||||
expect(readFileSync(join(root, 'docs/persistence-schema.json'), 'utf8')).toBe(before)
|
||||
expect(existsSync(join(root, 'docs/persistence-changes/historical-formats/v3.md'))).toBe(false)
|
||||
|
||||
@@ -146,7 +151,7 @@ describe('archive current persistence format', () => {
|
||||
const root = fixture(0)
|
||||
rmSync(join(root, 'docs/persistence-changes/historical-formats'), { recursive: true })
|
||||
expect(runPersistenceFormats(['--root', root, '--archive', '0'])).toBe('Archived Session format v0 to docs/persistence-changes/historical-formats/v0.schema.json.')
|
||||
expect(JSON.parse(readFileSync(join(root, 'docs/persistence-changes/historical-formats/v0.schema.json'), 'utf8'))).toEqual(inventory(0))
|
||||
expect(parseHistoricalPersistenceSnapshot(readFileSync(join(root, 'docs/persistence-changes/historical-formats/v0.schema.json'), 'utf8'))).toEqual(inventory(0))
|
||||
})
|
||||
|
||||
it.each(['-1', '1.5', '01', '3junk', '9007199254740992'])('rejects an invalid archive version %s', (version) => {
|
||||
@@ -178,7 +183,7 @@ describe('archive current persistence format', () => {
|
||||
const root = fixture()
|
||||
const original = inventory(3)
|
||||
if (variant === 'invalid-json') write(root, 'docs/persistence-schema.json', '{')
|
||||
else if (variant === 'digest-drift') replaceSnapshot(root, 3, 3, { ...original, roots: [{ ...original.roots[0]!, digest: '0'.repeat(64) }, ...original.roots.slice(1)] })
|
||||
else if (variant === 'digest-drift') replaceSnapshot(root, 3, 3, { ...original, roots: [{ ...original.roots[0]!, digest: driftDigest(original.roots[0]!.digest) }, ...original.roots.slice(1)] })
|
||||
else if (variant === 'missing-type') replaceSnapshot(root, 3, 3, { ...original, types: original.types.slice(1) })
|
||||
else if (variant === 'missing-source-metadata') edit(root, 'docs/persistence-schema.json', ',"sources":[]', '')
|
||||
else replaceSnapshot(root, 3, 3, inventory(2))
|
||||
@@ -378,11 +383,14 @@ describe('complete persistence format references', () => {
|
||||
const root = fixture()
|
||||
const original = inventory(1)
|
||||
const first = original.roots[0]!
|
||||
const changed = variant === 'duplicate-root' ? { ...original, roots: [...original.roots, first] }
|
||||
: variant === 'invalid-digest' ? { ...original, roots: [{ ...first, digest: '0'.repeat(64) }, ...original.roots.slice(1)] }
|
||||
: variant === 'invalid-graph' ? { ...original, roots: [{ ...first, schema: { root: 0, nodes: [{ kind: 'array', element: 99 }] } }, ...original.roots.slice(1)] }
|
||||
: { ...original, extra: true }
|
||||
write(root, schemaPath(1, 3), JSON.stringify(changed))
|
||||
if (variant === 'duplicate-root') replaceSnapshot(root, 1, 3, { ...original, roots: [...original.roots, first] })
|
||||
else if (variant === 'invalid-digest') replaceSnapshot(root, 1, 3, { ...original, roots: [{ ...first, digest: driftDigest(first.digest) }, ...original.roots.slice(1)] })
|
||||
else {
|
||||
const changed = JSON.parse(encodePersistenceSnapshot(original)) as { nodes: Record<string, unknown>; extra?: boolean }
|
||||
if (variant === 'invalid-graph') changed.nodes[first.digest.slice(0, 16)] = { kind: 'array', element: '9'.repeat(16) }
|
||||
else changed.extra = true
|
||||
write(root, schemaPath(1, 3), JSON.stringify(changed))
|
||||
}
|
||||
expect(() => loadPersistenceFormats(root)).toThrow(variant === 'duplicate-root' ? 'duplicate schema root' : variant === 'invalid-digest' ? 'schema digest mismatch' : variant === 'invalid-graph' ? 'unknown schema node' : 'unknown field extra')
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { JSON_SCHEMA, load } from 'js-yaml'
|
||||
import { readCurrentSessionFormatVersion } from './gen-session-format-catalog.ts'
|
||||
import { parseHistoricalPersistenceSnapshot, parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { persistenceFormatFactArtifacts } from './persistence-format-facts.ts'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { withoutPersistenceSourceLines } from './persistence-source-metadata.ts'
|
||||
|
||||
@@ -166,7 +166,7 @@ export function loadPersistenceFormats(root: string): PersistenceFormats {
|
||||
if (block !== machineBlock(chinese, document.replace(/\.md$/u, '.zh.md'))) throw new Error(`v${version}: bilingual machine records differ`)
|
||||
record = parseRecord(block, version)
|
||||
}
|
||||
const inventory = (current ? parsePersistenceSnapshot : parseHistoricalPersistenceSnapshot)(JSON.parse(read(schemaPath)))
|
||||
const inventory = (current ? parsePersistenceSnapshot : parseHistoricalPersistenceSnapshot)(read(schemaPath))
|
||||
validateInventory(inventory, version, current)
|
||||
const recordedRoots = record?.roots
|
||||
if (recordedRoots !== undefined && (recordedRoots.size !== inventory.roots.length
|
||||
@@ -190,11 +190,11 @@ function archiveCurrentInventory(root: string, requestedVersion: string): string
|
||||
if (version !== currentVersion) throw new Error(`Cannot archive v${version}: current writer is v${currentVersion}`)
|
||||
const path = `${DIRECTORY}/v${version}.schema.json`
|
||||
if (existsSync(join(root, path))) throw new Error(`Cannot archive v${version}: ${path} already exists`)
|
||||
const inventory = parsePersistenceSnapshot(JSON.parse(readFileSync(join(root, CURRENT_SCHEMA), 'utf8')))
|
||||
const inventory = parsePersistenceSnapshot(readFileSync(join(root, CURRENT_SCHEMA), 'utf8'))
|
||||
const reachable = validateInventory(inventory, version, true)
|
||||
const archive = withoutPersistenceSourceLines({ ...inventory, types: inventory.types.filter(type => reachable.has(type.digest)) })
|
||||
mkdirSync(join(root, DIRECTORY), { recursive: true })
|
||||
writeFileSync(join(root, path), JSON.stringify(archive, null, 2) + '\n', { flag: 'wx' })
|
||||
writeFileSync(join(root, path), encodePersistenceSnapshot(archive), { flag: 'wx' })
|
||||
return `Archived Session format v${version} to ${path}.`
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { join } from 'node:path'
|
||||
import { dump } from 'js-yaml'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, schemaDigest } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest } from './persistence-schema-model.ts'
|
||||
import type { PersistenceRoot, PersistenceSchemaInventory, PersistenceType, SchemaNode } from './persistence-schema-model.ts'
|
||||
import { loadPersistenceReleases, runPersistenceReleases } from './persistence-releases.ts'
|
||||
import type { PersistenceReleaseManifest, PersistenceReleaseRecord } from './persistence-releases.ts'
|
||||
@@ -108,14 +108,14 @@ function fixture(): Fixture {
|
||||
writeFileSync(join(directory, 'manifest.json'), JSON.stringify(manifest))
|
||||
for (const [index, record] of records.entries()) {
|
||||
saveRecord(directory, record)
|
||||
writeFileSync(join(directory, record.tag + '.schema.json'), JSON.stringify(snapshots[index]))
|
||||
writeFileSync(join(directory, record.tag + '.schema.json'), encodePersistenceSnapshot(snapshots[index]!))
|
||||
writeFileSync(join(directory, record.tag + '.i18n.yaml'), 'schemaVersion: 1\n')
|
||||
}
|
||||
return { root, directory, manifest, records, snapshots }
|
||||
}
|
||||
|
||||
function replaceSnapshot(fixture: Fixture, index: number, snapshot: PersistenceSchemaInventory): void {
|
||||
writeFileSync(join(fixture.directory, TAGS[index]! + '.schema.json'), JSON.stringify(snapshot))
|
||||
writeFileSync(join(fixture.directory, TAGS[index]! + '.schema.json'), encodePersistenceSnapshot(snapshot))
|
||||
}
|
||||
|
||||
describe('pinned persistence releases', () => {
|
||||
@@ -148,7 +148,7 @@ describe('pinned persistence releases', () => {
|
||||
expect(archive.entries[1]!.differences).toEqual([expect.objectContaining({ root: 'event:example/value', kind: 'type-changed', requiresCompatibilityReview: true })])
|
||||
expect(archive.entries[2]!.roots).toEqual(archive.entries[1]!.roots)
|
||||
expect(archive.entries[2]!.differences).toEqual([])
|
||||
expect(() => parsePersistenceSnapshot(data.snapshots[1])).toThrow('surface metadata')
|
||||
expect(() => parsePersistenceSnapshot(encodePersistenceSnapshot(data.snapshots[1]!))).toThrow('surface metadata')
|
||||
})
|
||||
|
||||
it.each(['.md', '.zh.md', '.i18n.yaml', '.schema.json'])('rejects a missing manifest release companion %s', (suffix) => {
|
||||
@@ -369,7 +369,7 @@ describe('release facts', () => {
|
||||
const data = fixture()
|
||||
const snapshotPath = join(data.directory, TAGS[2] + '.schema.json')
|
||||
const snapshot = readFileSync(snapshotPath, 'utf8')
|
||||
writeFileSync(snapshotPath, snapshot.replace('"formatVersion":1', '"formatVersion":3'))
|
||||
writeFileSync(snapshotPath, snapshot.replace('"formatVersion": 1', '"formatVersion": 3'))
|
||||
const corrupt = directoryBytes(data.directory)
|
||||
expect(() => runPersistenceReleases(['--write'], data.root)).toThrow('normalization version')
|
||||
expect(directoryBytes(data.directory)).toEqual(corrupt)
|
||||
|
||||
@@ -206,7 +206,7 @@ export function loadPersistenceReleases(root: string): PersistenceReleases {
|
||||
const chinese = machineBlock(read('.zh.md'), `${release.tag}.zh.md`)
|
||||
if (english !== chinese) throw new Error(`${release.tag}: bilingual machine records differ`)
|
||||
const record = parseRecord(english, release, entries.at(-1)?.release.tag ?? null)
|
||||
const snapshot = parseHistoricalPersistenceSnapshot(JSON.parse(read('.schema.json')))
|
||||
const snapshot = parseHistoricalPersistenceSnapshot(read('.schema.json'))
|
||||
validateTypes(snapshot, release.tag)
|
||||
const afterRoots = new Map(snapshot.roots.map(root => [root.key, root]))
|
||||
const expectedRoots = record.changes.filter(change => change.after !== null).map(change => change.root).sort()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyPersistenceChange } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, schemaDigest, type PersistenceSchemaInventory, type SchemaNode, type SchemaProperty } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest, type PersistenceSchemaInventory, type SchemaNode, type SchemaProperty } from './persistence-schema-model.ts'
|
||||
import { renderPersistenceReview, reviewPersistenceSchemas, runPersistenceReview } from './persistence-review.ts'
|
||||
|
||||
function fixture(options: {
|
||||
@@ -116,8 +116,8 @@ describe('persistence review', () => {
|
||||
try {
|
||||
const before = join(directory, 'before.json')
|
||||
const after = join(directory, 'after.json')
|
||||
const oldText = JSON.stringify(fixture())
|
||||
const newText = JSON.stringify(fixture({ extraKind: true }))
|
||||
const oldText = encodePersistenceSnapshot(fixture())
|
||||
const newText = encodePersistenceSnapshot(fixture({ extraKind: true }))
|
||||
writeFileSync(before, oldText)
|
||||
writeFileSync(after, newText)
|
||||
const args = ['--before', before, '--after', after]
|
||||
|
||||
@@ -215,7 +215,7 @@ export function renderPersistenceReview(report: PersistenceReview): string {
|
||||
export function runPersistenceReview(args: readonly string[]): string {
|
||||
const { values } = parseArgs({ args: [...args], options: { before: { type: 'string' }, after: { type: 'string' }, json: { type: 'boolean' } }, strict: true, allowPositionals: false })
|
||||
if (values.before === undefined || values.after === undefined) throw new Error('persistence review: --before and --after inventory paths are required')
|
||||
const read = (path: string): PersistenceSchemaInventory => parsePersistenceSnapshot(JSON.parse(readFileSync(path, 'utf8')))
|
||||
const read = (path: string): PersistenceSchemaInventory => parsePersistenceSnapshot(readFileSync(path, 'utf8'))
|
||||
const report = reviewPersistenceSchemas(read(values.before), read(values.after))
|
||||
return values.json ? JSON.stringify(report, null, 2) + '\n' : renderPersistenceReview(report)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Source-independent JSON type graphs and canonical persistence fingerprints.
|
||||
* Numeric references are local to one graph; names and source locations are metadata.
|
||||
* Numeric references are local to one in-memory graph; serialized snapshots reference nodes by digest prefix.
|
||||
* Names and source locations are metadata.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
|
||||
/** Recorded reader promise for explicitly attributed source additions. */
|
||||
export interface SourceCompatibility {
|
||||
@@ -72,51 +72,108 @@ export interface PersistenceSchemaInventory {
|
||||
readonly types: readonly PersistenceType[]
|
||||
}
|
||||
|
||||
/** Type metadata with an explicit graph when roots cannot reconstruct it exactly. */
|
||||
export interface PersistenceTypeIndex extends Omit<PersistenceType, 'schema'> {
|
||||
readonly schema?: CanonicalSchema
|
||||
}
|
||||
/** Serialized node: the node fields with every reference replaced by a node key. */
|
||||
export type SerializedSchemaNode =
|
||||
| Extract<SchemaNode, { kind: 'primitive' | 'literal' | 'opaque' }>
|
||||
| { readonly kind: 'array'; readonly element: string }
|
||||
| { readonly kind: 'tuple'; readonly elements: readonly (Omit<SchemaTupleElement, 'type'> & { readonly type: string })[] }
|
||||
| {
|
||||
readonly kind: 'object'
|
||||
readonly properties: readonly (Omit<SchemaProperty, 'type'> & { readonly type: string })[]
|
||||
readonly indices: readonly { readonly key: string; readonly value: string }[]
|
||||
}
|
||||
| { readonly kind: 'union'; readonly types: readonly string[] }
|
||||
|
||||
/** Compact inventory representation; formatVersion still pins normalization. */
|
||||
export interface PersistenceSchemaSnapshot {
|
||||
readonly formatVersion: PersistenceSchemaInventory['formatVersion']
|
||||
readonly roots: readonly PersistenceRoot[]
|
||||
readonly types: readonly PersistenceTypeIndex[]
|
||||
/** Length of the digest prefix that names a node in a serialized node table. */
|
||||
export const SCHEMA_NODE_KEY_LENGTH = 16
|
||||
|
||||
/**
|
||||
* Name a node by the digest prefix of the canonical subgraph it roots.
|
||||
* @param nodes - graph containing the node.
|
||||
* @param index - node to name.
|
||||
* @returns key shared by every bisimilar node in any graph.
|
||||
*/
|
||||
export function schemaNodeKey(nodes: readonly SchemaNode[], index: number): string {
|
||||
return schemaDigest(canonicalizeSchema(nodes, index)).slice(0, SCHEMA_NODE_KEY_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* Index every normalized type reachable from the supplied graphs.
|
||||
* @param schemas - complete canonical root graphs.
|
||||
* @returns canonical subgraphs keyed by their structural digest.
|
||||
* Produce the node-table entry for one node from its canonical subgraph.
|
||||
* @param nodes - graph containing the node; its node may be non-canonical.
|
||||
* @param index - node to serialize.
|
||||
* @returns the node key and canonical serialized node.
|
||||
*/
|
||||
export function reachableSchemaTypes(schemas: readonly CanonicalSchema[]): Map<string, CanonicalSchema> {
|
||||
const types = new Map<string, CanonicalSchema>()
|
||||
for (const schema of schemas) {
|
||||
for (const [index] of schema.nodes.entries()) {
|
||||
const type = canonicalizeSchema(schema.nodes, index)
|
||||
types.set(schemaDigest(type), type)
|
||||
export function schemaTableEntry(nodes: readonly SchemaNode[], index: number): readonly [string, SerializedSchemaNode] {
|
||||
const canonical = canonicalizeSchema(nodes, index)
|
||||
const node = serializeSchemaNode(canonical.nodes[0] as SchemaNode, child => schemaNodeKey(canonical.nodes, child))
|
||||
return [schemaDigest(canonical).slice(0, SCHEMA_NODE_KEY_LENGTH), node]
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace node references with node keys, ordering unordered references by key.
|
||||
* @param node - graph node.
|
||||
* @param key - key of a referenced node index.
|
||||
* @returns the serialized node.
|
||||
*/
|
||||
export function serializeSchemaNode(node: SchemaNode, key: (index: number) => string): SerializedSchemaNode {
|
||||
switch (node.kind) {
|
||||
case 'array': return { kind: 'array', element: key(node.element) }
|
||||
case 'tuple': return { kind: 'tuple', elements: node.elements.map(element => ({ type: key(element.type), optional: element.optional, rest: element.rest })) }
|
||||
case 'object': return {
|
||||
kind: 'object',
|
||||
properties: node.properties.map(property => ({ name: property.name, type: key(property.type), optional: property.optional,
|
||||
...(property.compatibility === undefined ? {} : { compatibility: property.compatibility }) })),
|
||||
indices: node.indices.map(index => ({ key: key(index.key), value: key(index.value) }))
|
||||
.sort((left, right) => compare(left.key, right.key) || compare(left.value, right.value)),
|
||||
}
|
||||
case 'union': return { kind: 'union', types: node.types.map(key).sort(compare) }
|
||||
case 'primitive':
|
||||
case 'literal':
|
||||
case 'opaque': return node
|
||||
default: return assertNever(node)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize an inventory with one node table keyed by subgraph digest prefix.
|
||||
* Roots and types locate their graph through their digest prefix,
|
||||
* so a type change rewrites only the changed node, its ancestors, and their digests.
|
||||
* @param inventory - validated inventory with canonical graphs.
|
||||
* @returns JSON text with one root, type, or node per line.
|
||||
*/
|
||||
export function encodePersistenceSnapshot(inventory: PersistenceSchemaInventory): string {
|
||||
const table = new Map<string, SerializedSchemaNode>()
|
||||
for (const { schema } of [...inventory.roots, ...inventory.types]) {
|
||||
for (const index of schema.nodes.keys()) {
|
||||
const [key, node] = schemaTableEntry(schema.nodes, index)
|
||||
const existing = table.get(key)
|
||||
if (existing !== undefined && JSON.stringify(existing) !== JSON.stringify(node)) {
|
||||
throw new Error(`persistence schema: node key collision ${key}`)
|
||||
}
|
||||
table.set(key, node)
|
||||
}
|
||||
}
|
||||
return types
|
||||
return renderPersistenceSnapshot(inventory, table)
|
||||
}
|
||||
|
||||
/**
|
||||
* Omit type graphs exactly reconstructable from roots, retaining all other graphs.
|
||||
* @param inventory - expanded inventory, including types erased from roots by normalization.
|
||||
* @returns a lossless snapshot preserving root graphs, type order, and declaration metadata.
|
||||
* Render inventory metadata and a node table in the committed text layout.
|
||||
* @param inventory - root and type metadata; their graphs are not read.
|
||||
* @param table - every node reachable from the roots and types.
|
||||
* @returns JSON text with keys sorted and one root, type, or node per line.
|
||||
*/
|
||||
export function persistenceSchemaSnapshot(inventory: PersistenceSchemaInventory): PersistenceSchemaSnapshot {
|
||||
const reachable = reachableSchemaTypes(inventory.roots.map(root => root.schema))
|
||||
return {
|
||||
formatVersion: inventory.formatVersion,
|
||||
roots: inventory.roots,
|
||||
types: inventory.types.map(({ digest, schema, names, sources }) => ({
|
||||
digest,
|
||||
...(isDeepStrictEqual(schema, reachable.get(digest)) ? {} : { schema }),
|
||||
names,
|
||||
sources,
|
||||
})),
|
||||
}
|
||||
export function renderPersistenceSnapshot(inventory: PersistenceSchemaInventory, table: ReadonlyMap<string, SerializedSchemaNode>): string {
|
||||
const lines = (values: readonly string[], open: string, close: string): string =>
|
||||
values.length === 0 ? open + close : `${open}\n${values.map(value => ` ${value}`).join(',\n')}\n ${close}`
|
||||
const roots = inventory.roots.map(root => JSON.stringify({ key: root.key, kind: root.kind,
|
||||
...(root.event === undefined ? {} : { event: root.event }),
|
||||
...(root.surface === undefined ? {} : { surface: root.surface }), digest: root.digest }))
|
||||
const types = inventory.types.map(type => JSON.stringify({ digest: type.digest, names: type.names, sources: type.sources }))
|
||||
const nodes = [...table].sort(([left], [right]) => compare(left, right)).map(([key, node]) => `${JSON.stringify(key)}: ${JSON.stringify(node)}`)
|
||||
return `{\n "formatVersion": ${String(inventory.formatVersion)},\n`
|
||||
+ ` "roots": ${lines(roots, '[', ']')},\n`
|
||||
+ ` "types": ${lines(types, '[', ']')},\n`
|
||||
+ ` "nodes": ${lines(nodes, '{', '}')}\n}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/** Compact inventories retain root fingerprints and restore shared recursive type graphs. */
|
||||
/** Snapshot text stores one node table keyed by subgraph digest and restores every root and type graph. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseHistoricalPersistenceSnapshot, parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, persistenceSchemaSnapshot, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, encodePersistenceSnapshot, schemaDigest, type PersistenceSchemaInventory, type SchemaNode } from './persistence-schema-model.ts'
|
||||
import { reviewPersistenceSchemas } from './persistence-review.ts'
|
||||
import { renderPersistenceSchemaDefinitions, renderPersistenceSchemaIndex } from './render-persistence-schema.ts'
|
||||
import { renderPersistenceSchemaDefinitions } from './render-persistence-schema.ts'
|
||||
|
||||
function inventory(): PersistenceSchemaInventory {
|
||||
const schema = canonicalizeSchema([
|
||||
@@ -22,22 +22,51 @@ function inventory(): PersistenceSchemaInventory {
|
||||
}
|
||||
}
|
||||
|
||||
describe('compact persistence snapshots', () => {
|
||||
it('stores only root graphs and restores shared recursive definitions without changing reports', () => {
|
||||
interface SnapshotJson {
|
||||
roots: Record<string, unknown>[]
|
||||
types: Record<string, unknown>[]
|
||||
nodes: Record<string, Record<string, unknown>>
|
||||
}
|
||||
|
||||
function edit(text: string, change: (value: SnapshotJson) => void): string {
|
||||
const value = JSON.parse(text) as SnapshotJson
|
||||
change(value)
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
describe('persistence snapshot text', () => {
|
||||
it('stores each shared node once and restores graphs without changing reports', () => {
|
||||
const original = inventory()
|
||||
const snapshot = persistenceSchemaSnapshot(original)
|
||||
expect(snapshot.roots).toEqual(original.roots)
|
||||
expect(snapshot.types).toEqual(original.types.map(({ digest, names, sources }) => ({ digest, names, sources })))
|
||||
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
|
||||
const text = encodePersistenceSnapshot(original)
|
||||
const parsed = JSON.parse(text) as SnapshotJson
|
||||
expect(Object.keys(parsed.nodes)).toHaveLength(3)
|
||||
expect(parsed.roots[0]).toEqual({ key: 'SessionHeader', kind: 'header', digest: original.roots[0]!.digest })
|
||||
expect(text.split('\n').every(line => !/"type": \d/u.test(line))).toBe(true)
|
||||
const restored = parsePersistenceSnapshot(text)
|
||||
expect(restored).toEqual(original)
|
||||
expect(reviewPersistenceSchemas(original, restored)).toEqual(reviewPersistenceSchemas(original, original))
|
||||
for (const rendering of ['current', 'historical'] as const) {
|
||||
expect(renderPersistenceSchemaIndex(restored, 'en', undefined, 2, rendering))
|
||||
.toBe(renderPersistenceSchemaIndex(original, 'en', undefined, 2, rendering))
|
||||
expect(renderPersistenceSchemaDefinitions(restored, 'en', undefined, 2, rendering))
|
||||
.toBe(renderPersistenceSchemaDefinitions(original, 'en', undefined, 2, rendering))
|
||||
expect(parseHistoricalPersistenceSnapshot(text)).toEqual(original)
|
||||
expect(encodePersistenceSnapshot(restored)).toBe(text)
|
||||
})
|
||||
|
||||
it('changes only the new node and its ancestors when a union gains a variant', () => {
|
||||
const graph = (variants: readonly string[]): PersistenceSchemaInventory => {
|
||||
const nodes: SchemaNode[] = [
|
||||
{ kind: 'object', properties: [{ name: 'other', type: 1, optional: false }, { name: 'source', type: 2, optional: false }], indices: [] },
|
||||
{ kind: 'object', properties: [{ name: 'value', type: 3, optional: false }], indices: [] },
|
||||
{ kind: 'union', types: variants.map((_, index) => 4 + index) },
|
||||
{ kind: 'primitive', type: 'number' },
|
||||
...variants.map((value): SchemaNode => ({ kind: 'literal', value })),
|
||||
]
|
||||
const schema = canonicalizeSchema(nodes, 0)
|
||||
return { formatVersion: 1, roots: [{ key: 'SessionHeader', kind: 'header', digest: schemaDigest(schema), schema }], types: [] }
|
||||
}
|
||||
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
|
||||
const keys = (value: PersistenceSchemaInventory): string[] =>
|
||||
Object.keys((JSON.parse(encodePersistenceSnapshot(value)) as SnapshotJson).nodes)
|
||||
const before = new Set(keys(graph(['a', 'c'])))
|
||||
const after = keys(graph(['a', 'b', 'c']))
|
||||
expect(after.filter(key => !before.has(key))).toHaveLength(3)
|
||||
expect([...before].filter(key => !after.includes(key))).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([true, false])('retains independent recursive types with root graphs=%s', (withRoots) => {
|
||||
@@ -48,45 +77,57 @@ describe('compact persistence snapshots', () => {
|
||||
], 0)
|
||||
const independent = { digest: schemaDigest(schema), schema, names: ['example.ts#Independent', 'example.ts#Alias'], sources: ['example.ts'] }
|
||||
const original = { ...base, roots: withRoots ? base.roots : [], types: [independent, ...base.types] }
|
||||
const snapshot = persistenceSchemaSnapshot(original)
|
||||
expect(snapshot.types[0]).toEqual(independent)
|
||||
if (withRoots) expect(snapshot.types.slice(1).every(type => type.schema === undefined)).toBe(true)
|
||||
else expect(snapshot.types).toEqual(original.types)
|
||||
const restored = parsePersistenceSnapshot(JSON.parse(JSON.stringify(snapshot)))
|
||||
const restored = parsePersistenceSnapshot(encodePersistenceSnapshot(original))
|
||||
expect(restored).toEqual(original)
|
||||
expect(parseHistoricalPersistenceSnapshot(snapshot)).toEqual(original)
|
||||
expect(persistenceSchemaSnapshot(restored)).toEqual(snapshot)
|
||||
expect(renderPersistenceSchemaDefinitions(restored)).toBe(renderPersistenceSchemaDefinitions(original))
|
||||
})
|
||||
|
||||
it('reads legacy full graphs and keeps partial acknowledgement inventories empty', () => {
|
||||
const original = inventory()
|
||||
expect(parsePersistenceSnapshot(JSON.parse(JSON.stringify(original)))).toEqual(original)
|
||||
const partial = { ...original, types: [] }
|
||||
expect(parsePersistenceSnapshot(persistenceSchemaSnapshot(partial))).toEqual(partial)
|
||||
it('rejects tampered node keys, unknown references, orphan nodes, and digest mismatches', () => {
|
||||
const text = encodePersistenceSnapshot(inventory())
|
||||
const rootKey = inventory().roots[0]!.digest.slice(0, 16)
|
||||
const second = Object.keys((JSON.parse(text) as SnapshotJson).nodes).find(key => key !== rootKey)!
|
||||
expect(() => parsePersistenceSnapshot(edit(text, (value) => {
|
||||
value.nodes = Object.fromEntries(Object.entries(value.nodes).map(([key, node]) => [key === rootKey ? 'f'.repeat(16) : key, node]))
|
||||
}))).toThrow('key does not match')
|
||||
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.nodes[rootKey] = { kind: 'array', element: '0'.repeat(16) } })))
|
||||
.toThrow('unknown schema node')
|
||||
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.nodes[second] = { kind: 'primitive', type: 'number' } })))
|
||||
.toThrow('key does not match')
|
||||
expect(() => parsePersistenceSnapshot(edit(encodePersistenceSnapshot({ ...inventory(), roots: [] }), (value) => { value.types = [] })))
|
||||
.toThrow('not reachable from any root or type')
|
||||
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.roots[0]!.digest = 'f'.repeat(64) }))).toThrow('no schema node for digest prefix')
|
||||
})
|
||||
|
||||
it('rejects compact digests that do not identify a root subgraph', () => {
|
||||
const snapshot = persistenceSchemaSnapshot(inventory())
|
||||
const schema = canonicalizeSchema([{ kind: 'literal', value: 'unreachable' }], 0)
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ digest: schemaDigest(schema), names: [], sources: [] }] }))
|
||||
.toThrow('is not reachable from roots')
|
||||
it('rejects node entries whose properties or union members are not in canonical order', () => {
|
||||
const text = encodePersistenceSnapshot(inventory())
|
||||
const nodes = (JSON.parse(text) as SnapshotJson).nodes
|
||||
const [objectKey] = Object.entries(nodes).find(([, node]) => ((node.properties as unknown[] | undefined)?.length ?? 0) > 1)!
|
||||
expect(() => parsePersistenceSnapshot(edit(text, (value) => {
|
||||
(value.nodes[objectKey]!.properties as unknown[]).reverse()
|
||||
}))).toThrow('node is not canonical')
|
||||
const unionSchema = canonicalizeSchema([
|
||||
{ kind: 'union', types: [1, 2] }, { kind: 'literal', value: 'a' }, { kind: 'literal', value: 'b' },
|
||||
], 0)
|
||||
const union = encodePersistenceSnapshot({ formatVersion: 1, roots: [],
|
||||
types: [{ digest: schemaDigest(unionSchema), schema: unionSchema, names: [], sources: [] }] })
|
||||
const [unionKey] = Object.entries((JSON.parse(union) as SnapshotJson).nodes).find(([, node]) => node.kind === 'union')!
|
||||
expect(() => parsePersistenceSnapshot(edit(union, (value) => {
|
||||
const types = value.nodes[unionKey]!.types as string[]
|
||||
value.nodes[unionKey]!.types = [types[0], ...types]
|
||||
}))).toThrow('node is not canonical')
|
||||
})
|
||||
|
||||
it('still validates explicit legacy graphs instead of replacing them with root graphs', () => {
|
||||
const original = inventory()
|
||||
const schema = canonicalizeSchema([{ kind: 'primitive', type: 'number' }], 0)
|
||||
expect(() => parsePersistenceSnapshot({ ...original, types: original.types.map(type => ({ ...type, schema })) }))
|
||||
.toThrow('shared schema digest mismatch')
|
||||
it('rejects valid content outside the canonical text layout', () => {
|
||||
const text = encodePersistenceSnapshot(inventory())
|
||||
expect(() => parsePersistenceSnapshot(JSON.stringify(JSON.parse(text), null, 2) + '\n')).toThrow('canonical layout')
|
||||
})
|
||||
|
||||
it('rejects invalid metadata and malformed explicit schemas in compact inventories', () => {
|
||||
const snapshot = persistenceSchemaSnapshot(inventory())
|
||||
const type = snapshot.types[0]!
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, names: [3] }] })).toThrow('type name')
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, sources: [3] }] })).toThrow('type source')
|
||||
expect(() => parsePersistenceSnapshot({ ...snapshot, types: [{ ...type, schema: null }] })).toThrow('shared schema')
|
||||
expect(() => parseHistoricalPersistenceSnapshot({ ...snapshot, types: [{ ...type, sources: ['types.ts:3'] }] }))
|
||||
it('rejects invalid type metadata', () => {
|
||||
const text = encodePersistenceSnapshot(inventory())
|
||||
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.types[0]!.names = [3] }))).toThrow('type name')
|
||||
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.types[0]!.sources = [3] }))).toThrow('type source')
|
||||
expect(() => parsePersistenceSnapshot(edit(text, (value) => { value.types[0]!.schema = {} }))).toThrow('unknown field schema')
|
||||
expect(() => parseHistoricalPersistenceSnapshot(edit(text, (value) => { value.types[0]!.sources = ['types.ts:3'] })))
|
||||
.toThrow('must omit line numbers')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { collectLogEvents } from './gen-persistence-catalog.ts'
|
||||
import { extractPersistenceSchema } from './persistence-schema.ts'
|
||||
import { classifyPersistenceChange, parsePersistenceSnapshot } from './persistence-changes.ts'
|
||||
import { canonicalizeSchema, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
import { canonicalizeSchema, encodePersistenceSnapshot, isArbitraryJsonSchema, schemaDigest, type PersistenceSchemaInventory } from './persistence-schema-model.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => {
|
||||
@@ -63,7 +63,7 @@ describe('persistent source type extraction', () => {
|
||||
expect(inventory.types.some(type => type.names.includes('packages/domain/payload/src/types.ts#Section'))).toBe(true)
|
||||
expect(inventory.types.some(type => type.names.length === 0 && type.sources.includes('packages/domain/payload/src/types.ts:2'))).toBe(true)
|
||||
expect(inventory.types.some(type => type.schema.nodes[0]?.kind === 'array' && type.names.length === 0)).toBe(true)
|
||||
expect(parsePersistenceSnapshot(inventory)).toEqual(inventory)
|
||||
expect(parsePersistenceSnapshot(encodePersistenceSnapshot(inventory))).toEqual(inventory)
|
||||
})
|
||||
|
||||
it('ignores alias names, files, documentation, readonly, brands and property order', () => {
|
||||
@@ -421,7 +421,7 @@ describe('source compatibility authoring', () => {
|
||||
discriminator: 'kind', unknownKinds: 'preserve', attributionKinds: ['attribution'] })
|
||||
expect(classifyPersistenceChange(before.roots.find(root => root.kind === 'event')!, root))
|
||||
.toEqual([expect.objectContaining({ kind: 'attribution-kind-added', requiresCompatibilityReview: false })])
|
||||
expect(parsePersistenceSnapshot({ ...after, types: [] })).toEqual({ ...after, types: [] })
|
||||
expect(parsePersistenceSnapshot(encodePersistenceSnapshot({ ...after, types: [] }))).toEqual({ ...after, types: [] })
|
||||
})
|
||||
|
||||
it('binds inherited source declarations only on explicitly eligible required roles', () => {
|
||||
|
||||
@@ -10,28 +10,48 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github
|
||||
env: Record<string, string>
|
||||
jobs: Record<'preview', {
|
||||
'runs-on': string
|
||||
steps: Array<{ name?: string; uses?: string; run?: string; with?: Record<string, unknown>; env?: Record<string, string> }>
|
||||
steps: Array<{ name?: string; uses?: string; run?: string; if?: string; 'continue-on-error'?: boolean; with?: Record<string, unknown>; env?: Record<string, string> }>
|
||||
}>
|
||||
}
|
||||
const preview = workflow.jobs.preview
|
||||
|
||||
describe('PR preview workflow', () => {
|
||||
it('keeps every PR author on the selected GitHub-hosted runner', () => {
|
||||
it('keeps every PR author on the selected runner', () => {
|
||||
expect(Object.keys(workflow.jobs)).toEqual(['preview'])
|
||||
expect(preview['runs-on']).toBe('ubuntu-24.04')
|
||||
// The required outcome is the build, so the lane runs on four vCPUs; the
|
||||
// hosted label stays as the fallback when the Blacksmith pool is not chosen.
|
||||
expect(preview['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404')
|
||||
expect(preview['runs-on']).toContain("'ubuntu-24.04'")
|
||||
expect(preview['runs-on']).toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(workflow.on).toEqual({ pull_request: { types: ['opened', 'synchronize', 'reopened'] } })
|
||||
expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'write' })
|
||||
expect(preview.steps.find(step => step.uses === 'actions/[email protected]')?.with).toEqual({ 'persist-credentials': false })
|
||||
})
|
||||
|
||||
it('fails the check only for build failures and warns about publication', () => {
|
||||
const byName = (name: string) => preview.steps.find(step => step.name === name)
|
||||
for (const name of ['Build workspace and check aggregate programs', 'Build the preview page and pack the VFS image', 'Shape the upload']) {
|
||||
expect(byName(name)?.['continue-on-error'], `${name} must stay blocking`).toBeUndefined()
|
||||
}
|
||||
for (const name of ['Upload to Cloudflare Pages', 'Verify the protected deployment serves the image', 'Comment the preview URL']) {
|
||||
expect(byName(name)?.['continue-on-error'], `${name} must warn instead of failing`).toBe(true)
|
||||
}
|
||||
const report = byName('Report preview publication failures')
|
||||
expect(report?.if).toBe("steps.upload.outcome == 'failure' || steps.verify.outcome == 'failure' || steps.comment.outcome == 'failure'")
|
||||
expect(report?.run).toContain('::warning::')
|
||||
})
|
||||
|
||||
it('keeps the immutable complete artifact build and restore-only dependency cache', () => {
|
||||
expect(workflow.env.PRIMARY_NODE_VERSION).toBe('24')
|
||||
expect(workflow.env.DSH_TELEMETRY_DISABLED).toBe('1')
|
||||
const commands = preview.steps.map(step => step.run)
|
||||
expect(commands).toContain('pnpm install --frozen-lockfile')
|
||||
expect(commands).toContain('pnpm run build:artifacts')
|
||||
expect(commands).toContain('pnpm run build')
|
||||
expect(commands).toContain('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview')
|
||||
expect(commands.indexOf('pnpm run build:artifacts')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview'))
|
||||
expect(commands.indexOf('pnpm run build')).toBeLessThan(commands.indexOf('pnpm --filter @deepseek-ai/dsh-web-frontend run build:preview'))
|
||||
// The complete build also runs the aggregate type checks the required
|
||||
// consumers dropped.
|
||||
expect(commands).not.toContain('pnpm run build:check')
|
||||
expect(preview.steps.filter(step => step.uses?.startsWith('actions/cache'))).toHaveLength(1)
|
||||
expect(preview.steps.find(step => step.uses === 'actions/cache/[email protected]')?.with).toMatchObject({
|
||||
key: "${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}",
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ProductPackagePolicy {
|
||||
* Optional integrations may be reached by an optional bundle without becoming default capabilities.
|
||||
*/
|
||||
export const PRODUCT_PACKAGE_POLICY: Readonly<Record<string, ProductPackagePolicy>> = {
|
||||
'packages/core/agent-tool-presentation': { category: 'optional', reason: 'Deprecated selector retained for existing executor configurations.' },
|
||||
'packages/browser-use/browser-use': { category: 'optional', reason: 'Service definition used by explicitly selected browser providers.' },
|
||||
'packages/computer-use/computer-use': { category: 'optional', reason: 'Service definition used by explicitly selected computer providers.' },
|
||||
'packages/lsp/lsp': { category: 'optional', reason: 'Service definition for deployment-configured language servers.' },
|
||||
@@ -38,6 +39,7 @@ export const PRODUCT_PACKAGE_POLICY: Readonly<Record<string, ProductPackagePolic
|
||||
'packages/sdk/client': { category: 'sdk', reason: 'Published TypeScript embedding client runs in the consumer process.' },
|
||||
'packages/typert/generator': { category: 'build', reason: 'Build-time generation of type metadata.' },
|
||||
'packages/test-support/agent-loop-testkit': { category: 'test', reason: 'Published agent-loop test harness.' },
|
||||
'packages/test-support/tool-testkit': { category: 'test', reason: 'Shared tool definition and execution fixtures for tests and benchmarks.' },
|
||||
'packages/test-support/client-runtime': { category: 'test', reason: 'Published browser plugin test harness.' },
|
||||
'packages/test-support/llm-mock-server': { category: 'test', reason: 'Published model-provider test server.' },
|
||||
'packages/test-support/llm-replay': { category: 'test', reason: 'Published recorded-model replay harness.' },
|
||||
|
||||
@@ -44,6 +44,7 @@ async function harness(direct = false) {
|
||||
runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId,
|
||||
})
|
||||
const step = (overrides = {}) => agentEvents(ctx, agent).waterfall('agent/pre-step', {
|
||||
modelSelection: { provider: 'mock', model: 'mock' },
|
||||
turn: 1, step: 1, messages, signal: controller.signal, ...overrides,
|
||||
}, next)
|
||||
return { ctx, fiber, parent, other, session, agent, controller, decision, next, start, step }
|
||||
@@ -76,9 +77,9 @@ describe('advanced Python snapshot workflow ordering', () => {
|
||||
await assembly
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const parent = await ctx.agentLoop.create(SessionId(config.parentSessionId), { provider: 'mock', model: 'mock' })
|
||||
const run = await ctx.subagents.startActivation({ name: 'workflow-child',
|
||||
const run = await ctx.subagents.startActivation({
|
||||
provider: 'spawn', label: 'workflow-child', delivery: 'caller',
|
||||
request: { parent, prompt: [{ type: 'text', text: config.prompt }] }, signal: new AbortController().signal,
|
||||
parent, prompt: [{ type: 'text', text: config.prompt }], signal: new AbortController().signal,
|
||||
})
|
||||
cleanups.push(() => run.dispose())
|
||||
const child = await entered.promise
|
||||
|
||||
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { officialClientBuildEnvironment, writeClientBuildRecord } from '../client-build-environment.ts'
|
||||
import { ON_DEMAND_BUNDLES } from '../../packages/boot/app-boot/src/official-bundles.ts'
|
||||
import { ON_DEMAND_BUNDLES } from '../../packages/util/official-bundles/src/bundles.ts'
|
||||
import { releaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { compareVersions, nextVendorVersion, planShared, reachesPayload } from './bump.ts'
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('release families', () => {
|
||||
expect(members
|
||||
.filter(member => member.directory.startsWith('packages/experimental/'))
|
||||
.map(member => member.name)).toEqual([
|
||||
'@deepseek-ai/dsh-action-cards',
|
||||
'@deepseek-ai/dsh-experimental-api-speech-to-text',
|
||||
'@deepseek-ai/dsh-experimental-auto-review',
|
||||
'@deepseek-ai/dsh-experimental-badge-skill-bundle',
|
||||
@@ -57,12 +58,16 @@ describe('release families', () => {
|
||||
'@deepseek-ai/dsh-experimental-browser-use-runtime',
|
||||
'@deepseek-ai/dsh-experimental-browser-use-stagehand-native',
|
||||
'@deepseek-ai/dsh-experimental-claude-code-mods',
|
||||
'@deepseek-ai/dsh-client-ui-action-cards',
|
||||
'@deepseek-ai/dsh-experimental-client-ui-claude-code-mods',
|
||||
'@deepseek-ai/dsh-experimental-client-ui-cot-translation',
|
||||
'@deepseek-ai/dsh-client-ui-creation-recommendations',
|
||||
'@deepseek-ai/dsh-client-ui-plugin-recommendations',
|
||||
'@deepseek-ai/dsh-experimental-client-ui-voice-input',
|
||||
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp',
|
||||
'@deepseek-ai/dsh-experimental-computer-use-cua-driver-native',
|
||||
'@deepseek-ai/dsh-experimental-cot-translation-bundle',
|
||||
'@deepseek-ai/dsh-creation-recommendations',
|
||||
'@deepseek-ai/dsh-cua-bundle',
|
||||
'@deepseek-ai/dsh-experimental-cua-macos',
|
||||
'@deepseek-ai/dsh-cua',
|
||||
@@ -71,7 +76,10 @@ describe('release families', () => {
|
||||
'@deepseek-ai/dsh-hooks-codex',
|
||||
'@deepseek-ai/dsh-experimental-inspector-profile',
|
||||
'@deepseek-ai/dsh-experimental-inspector',
|
||||
'@deepseek-ai/dsh-experimental-liveshare-protocol',
|
||||
'@deepseek-ai/dsh-personalized-recommendations',
|
||||
'@deepseek-ai/dsh-experimental-plugin-market-bundle',
|
||||
'@deepseek-ai/dsh-plugin-recommendations',
|
||||
'@deepseek-ai/dsh-experimental-ptc-runtime-python',
|
||||
'@deepseek-ai/dsh-experimental-ralph-bundle',
|
||||
'@deepseek-ai/dsh-experimental-session-inspector',
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
readClientBuildRecord,
|
||||
} from '../client-build-environment.ts'
|
||||
import { validateTarballPayload } from '../publication-payload.ts'
|
||||
import { ON_DEMAND_BUNDLES } from '../../packages/boot/app-boot/src/official-bundle-packages.ts'
|
||||
import { ON_DEMAND_BUNDLES } from '../../packages/util/official-bundles/src/bundles.ts'
|
||||
|
||||
/**
|
||||
* Dependency sections a consumer must publish after, because npm resolves them
|
||||
|
||||
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { hasExperimentalPackageReference } from '../experimental-package-policy.ts'
|
||||
import { OPTIONAL_BUNDLES } from '../../packages/boot/app-boot/src/profile.ts'
|
||||
import { OPTIONAL_BUNDLES } from '../../packages/util/official-bundles/src/bundles.ts'
|
||||
|
||||
interface InstalledManifest {
|
||||
name: string
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
/** Recorded npm evidence stays intact while authored files and exact edits remain checked. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { exactEditState, isRescopeExcluded } from './rescope-vendor.ts'
|
||||
import { exactEditState, isRescopeExcluded, rescopeText } from './rescope-vendor.ts'
|
||||
|
||||
const ANCHOR = '\n## Sync procedure'
|
||||
const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}`
|
||||
|
||||
function expectRoundTrip(file: string, text: string, scoped: string, lines: number): void {
|
||||
expect(rescopeText(text, file)).toEqual({ text: scoped, lines })
|
||||
expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 })
|
||||
expect(rescopeText(scoped, file, true)).toEqual({ text, lines })
|
||||
}
|
||||
|
||||
describe('rescope file selection', () => {
|
||||
it('preserves the recorded npm resolution', () => {
|
||||
expect(isRescopeExcluded('scripts/dependency-catalog/package-lock.json')).toBe(true)
|
||||
@@ -51,3 +58,141 @@ describe('exactEditState', () => {
|
||||
expect(exactEditState('x\n', 'a = 1', 'b = 2', 1)).toBe('invalid')
|
||||
})
|
||||
})
|
||||
|
||||
describe('product identifiers', () => {
|
||||
it.each([
|
||||
'apps/cli/tests/optional-bundle-transitions.e2e.ts',
|
||||
'apps/web/tests/agent-preset-selection.e2e.ts',
|
||||
'apps/web/tests/developer-tools-settings.e2e.ts',
|
||||
'docs/subsystems/schedule.md',
|
||||
'docs/subsystems/schedule.zh.md',
|
||||
'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.md',
|
||||
'docs/upgrade-guide/v0.2.0-rc.2/schedule-bundle-retired/guide.zh.md',
|
||||
'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.md',
|
||||
'docs/upgrade-guide/v0.2.1-alpha.1/subagent-preset-composition/guide.zh.md',
|
||||
'docs/user/guide/schedule.md',
|
||||
'docs/user/guide/schedule.zh.md',
|
||||
'packages/bundle/web-app/cordis.patch.yml',
|
||||
'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx',
|
||||
'packages/client/ui-agent-preset/src/client/analytics.ts',
|
||||
'packages/client/ui-agent-preset/tests/analytics.client.spec.ts',
|
||||
'packages/client/ui-agent-preset/tests/components.client.spec.tsx',
|
||||
'packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx',
|
||||
'packages/client/ui-agent-preset/tests/section-store.client.spec.ts',
|
||||
'packages/client/ui-conversation/src/client/input/submission-analytics.ts',
|
||||
'packages/client/ui-conversation/tests/service-orchestration.client.spec.ts',
|
||||
'packages/client/ui-entry-customization/src/client/index.ts',
|
||||
'packages/client/ui-entry-customization/tests/menu.client.spec.tsx',
|
||||
'packages/client/ui-workspace/tests/workspaces-service.client.spec.ts',
|
||||
'packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts',
|
||||
'snapshots/session/cordis-inspect-liveness/client-fixture.mjs',
|
||||
'snapshots/session/cordis-inspect-timeout/client-fixture.mjs',
|
||||
])('preserves the product values in %s', (file) => {
|
||||
const text = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
|
||||
expect(text).toMatch(/(['"`])cordis(?:\/inspect-query(?:-resolved)?)?\1/)
|
||||
expect(isRescopeExcluded(file)).toBe(false)
|
||||
expect(rescopeText(text, file)).toEqual({ text, lines: 0 })
|
||||
})
|
||||
|
||||
it('still renames other libraries in a preset consumer and reverses them', () => {
|
||||
const file = 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx'
|
||||
const text = "const preset = 'cordis'\nimport utils from 'cosmokit'\nimport Schema from 'schemastery'\n"
|
||||
const scoped = "const preset = 'cordis'\nimport utils from '@deepseek-ai/cosmokit'\nimport Schema from '@deepseek-ai/schemastery'\n"
|
||||
expect(rescopeText(text, file)).toEqual({ text: scoped, lines: 2 })
|
||||
expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 })
|
||||
expect(rescopeText(scoped, file, true)).toEqual({ text, lines: 2 })
|
||||
})
|
||||
|
||||
it('keeps unlisted package references subject to residue detection', () => {
|
||||
const file = 'packages/example/src/index.ts'
|
||||
const text = "import { Context } from 'cordis'\nimport type Schema from 'schemastery'\n"
|
||||
const scoped = "import { Context } from '@deepseek-ai/cordis'\nimport type Schema from '@deepseek-ai/schemastery'\n"
|
||||
expect(rescopeText(text, file)).toEqual({ text: scoped, lines: 2 })
|
||||
expect(rescopeText(scoped, file)).toEqual({ text: scoped, lines: 0 })
|
||||
expect(rescopeText(scoped, file, true)).toEqual({ text, lines: 2 })
|
||||
expect(rescopeText("ctx.emit('cordis/inspect-query')", file).text).toBe("ctx.emit('@deepseek-ai/cordis/inspect-query')")
|
||||
})
|
||||
|
||||
it.each([
|
||||
['import', "import { Context } from 'cordis'", "import { Context } from '@deepseek-ai/cordis'"],
|
||||
['multiline import', "import {\n Context,\n} from 'cordis'", "import {\n Context,\n} from '@deepseek-ai/cordis'"],
|
||||
['side-effect import', "import 'cordis'", "import '@deepseek-ai/cordis'"],
|
||||
['export', "export { Context } from 'cordis/subpath'", "export { Context } from '@deepseek-ai/cordis/subpath'"],
|
||||
['import equals', "import Cordis = require('cordis')", "import Cordis = require('@deepseek-ai/cordis')"],
|
||||
['dynamic import', 'const cordis = import(`cordis`)', 'const cordis = import(`@deepseek-ai/cordis`)'],
|
||||
['require', 'const cordis = require("cordis/subpath")', 'const cordis = require("@deepseek-ai/cordis/subpath")'],
|
||||
['import type', "type Context = import('cordis').Context", "type Context = import('@deepseek-ai/cordis').Context"],
|
||||
['module declaration', "declare module 'cordis' {}", "declare module '@deepseek-ai/cordis' {}"],
|
||||
['JSDoc type', "/** @param {import('cordis').Context} ctx */\nfunction apply(ctx) {}", "/** @param {import('@deepseek-ai/cordis').Context} ctx */\nfunction apply(ctx) {}"],
|
||||
['JSDoc import', "/** @import { Context } from 'cordis' */\nfunction apply(ctx) {}", "/** @import { Context } from '@deepseek-ai/cordis' */\nfunction apply(ctx) {}"],
|
||||
['same-line imports', "import 'cordis'; import 'cordis/subpath'", "import '@deepseek-ai/cordis'; import '@deepseek-ai/cordis/subpath'"],
|
||||
])('rewrites %s without changing same-file product data', (_label, source, target) => {
|
||||
const file = 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx'
|
||||
const product = "const preset = 'cordis'\nctx.emit('cordis/inspect-query')\nconst fixture = \"import 'cordis'\"\n"
|
||||
const text = `${product}${source}\n`
|
||||
const scoped = `${product}${target}\n`
|
||||
expectRoundTrip(file, text, scoped, 1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['apps/cli/tests/optional-bundle-transitions.e2e.ts', 1],
|
||||
['packages/client/ui-conversation/src/client/input/submission-analytics.ts', 1],
|
||||
['packages/client/ui-conversation/tests/service-orchestration.client.spec.ts', 1],
|
||||
['packages/client/ui-entry-customization/src/client/index.ts', 1],
|
||||
['packages/client/ui-entry-customization/tests/menu.client.spec.tsx', 1],
|
||||
['packages/client/ui-workspace/tests/workspaces-service.client.spec.ts', 1],
|
||||
['packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts', 2],
|
||||
['snapshots/session/cordis-inspect-liveness/client-fixture.mjs', 1],
|
||||
['snapshots/session/cordis-inspect-timeout/client-fixture.mjs', 2],
|
||||
] as const)('reverses real package references in %s and restores them', (file, lines) => {
|
||||
const scoped = readFileSync(new URL(`../${file}`, import.meta.url), 'utf8')
|
||||
const text = scoped.replaceAll("'@deepseek-ai/cordis'", "'cordis'")
|
||||
expect(text).not.toBe(scoped)
|
||||
expectRoundTrip(file, text, scoped, lines)
|
||||
expect(rescopeText(text, file, true)).toEqual({ text, lines: 0 })
|
||||
})
|
||||
|
||||
it('rewrites multiline fenced imports while preserving prose and fenced product data', () => {
|
||||
const file = 'docs/subsystems/schedule.md'
|
||||
const text = "The `cordis` preset.\n```ts\nconst preset = 'cordis'\nimport {\n Context,\n} from 'cordis'\n```\n"
|
||||
const scoped = "The `cordis` preset.\n```ts\nconst preset = 'cordis'\nimport {\n Context,\n} from '@deepseek-ai/cordis'\n```\n"
|
||||
expectRoundTrip(file, text, scoped, 1)
|
||||
})
|
||||
|
||||
it.each(['cordis', "'cordis'", '"cordis/subpath"'])('rewrites the YAML name %s without changing product comments', (name) => {
|
||||
const text = `# The 'cordis' preset.\n- name: ${name} # 'cordis' stays product data\n`
|
||||
const scoped = `# The 'cordis' preset.\n- name: ${name.replace('cordis', '@deepseek-ai/cordis')} # 'cordis' stays product data\n`
|
||||
for (const [file, wrap] of [
|
||||
['packages/bundle/web-app/cordis.patch.yml', (value: string) => value],
|
||||
['docs/subsystems/schedule.md', (value: string) => `The \`cordis\` preset.\n\`\`\`yaml\n${value}\`\`\`\n`],
|
||||
] as const) {
|
||||
expectRoundTrip(file, wrap(text), wrap(scoped), 1)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['scripts/rescope-vendor.spec.ts', 'scripts/gen-third-party-notices.spec.ts'])('keeps both package spellings in the fixture owner %s', (file) => {
|
||||
const text = "import 'cordis'\nimport '@deepseek-ai/cordis'\nconst fixture = \"import 'cosmokit'\"\n"
|
||||
expect(rescopeText(text, file)).toEqual({ text, lines: 0 })
|
||||
expect(rescopeText(text, file, true)).toEqual({ text, lines: 0 })
|
||||
})
|
||||
|
||||
it('keeps schema runtime identifiers while rewriting their same-file import', () => {
|
||||
const file = 'vendor/loader/src/config/diff.ts'
|
||||
const text = "const key = Symbol.for('schemastery')\nconst metadata = { vendor: 'schemastery' }\nimport Schema from 'schemastery'\n"
|
||||
const scoped = "const key = Symbol.for('schemastery')\nconst metadata = { vendor: 'schemastery' }\nimport Schema from '@deepseek-ai/schemastery'\n"
|
||||
expectRoundTrip(file, text, scoped, 1)
|
||||
})
|
||||
|
||||
it('rewrites declarations in an older event-name exception', () => {
|
||||
const file = 'packages/extensions/cordis-host-runner/src/index.ts'
|
||||
const text = "ctx.emit('cordis/inspect-query')\nimport { Context } from 'cordis'\ndeclare module 'cordis' {}\n"
|
||||
const scoped = "ctx.emit('cordis/inspect-query')\nimport { Context } from '@deepseek-ai/cordis'\ndeclare module '@deepseek-ai/cordis' {}\n"
|
||||
expectRoundTrip(file, text, scoped, 2)
|
||||
})
|
||||
|
||||
it.each(['```ts\n```\n', "```ts\nimport 'cordis'\n"])('keeps fence line counts without requiring a closing fence', (text) => {
|
||||
const file = 'docs/subsystems/schedule.md'
|
||||
const scoped = text.replace("import 'cordis'", "import '@deepseek-ai/cordis'")
|
||||
expectRoundTrip(file, text, scoped, scoped === text ? 0 : 1)
|
||||
})
|
||||
})
|
||||
+114
-34
@@ -12,9 +12,9 @@
|
||||
* `cordis.yml`, the Loader's `cordis:` builtin prefix, `cordis-config-entry`,
|
||||
* `@deepseek-ai/dsh-tool-cordis`, and `cordiverse/cordis`, and makes the
|
||||
* rewrite idempotent because the scoped name's `cordis` is preceded by `/`.
|
||||
* Markdown follows the rename inside every fence, and in `docs/` prose too:
|
||||
* a tutorial that teaches an unresolvable name is wrong, while prose elsewhere
|
||||
* records what was true when it was written.
|
||||
* Markdown follows the token rule in fences and `docs/` prose. Audited product
|
||||
* names stay intact; their source module references and YAML plugin names still
|
||||
* follow the rename. Prose elsewhere records what was true when it was written.
|
||||
*
|
||||
* Sites the token rule cannot express (dot-notation access, unquoted object
|
||||
* keys, regex literals, the vendored-manifest table) are listed in
|
||||
@@ -31,6 +31,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import ts from 'typescript'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
@@ -66,13 +67,14 @@ interface ExactEdit {
|
||||
}
|
||||
|
||||
/**
|
||||
* A file where an upstream name also appears as a vendor DIRECTORY name or an
|
||||
* upstream runtime identifier: the generic pass is disabled for the listed
|
||||
* names and {@link EXACT_EDITS} renames the real package-name occurrences.
|
||||
* A file where an upstream name also identifies runtime metadata, directories,
|
||||
* presets, events or locale keys. Its module references and YAML plugin names
|
||||
* still change; fixture owners retain both package spellings throughout.
|
||||
*/
|
||||
interface GenericSkip {
|
||||
readonly file: string
|
||||
readonly upstream: readonly string[]
|
||||
readonly fixture?: true
|
||||
}
|
||||
|
||||
const GENERIC_SKIPS: readonly GenericSkip[] = [
|
||||
@@ -83,8 +85,10 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
|
||||
// Native schema detection and its fixture share the vendored runtime's Symbol.for identifier.
|
||||
{ file: 'packages/boot/app-boot/src/config-schema/native.ts', upstream: ['schemastery'] },
|
||||
{ file: 'packages/boot/app-boot/tests/config-schema.spec.ts', upstream: ['schemastery'] },
|
||||
// The codemod's positive and negative fixtures contain both package spellings.
|
||||
{ file: 'scripts/rescope-vendor.spec.ts', upstream: RENAMES.map(rename => rename.upstream), fixture: true },
|
||||
// Asserts the vendored-manifest table, which gains an upstream-name column.
|
||||
{ file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) },
|
||||
{ file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream), fixture: true },
|
||||
// `cordis` is also an agent-preset id, so in these files the bare name is
|
||||
// product data, not a package reference. Renaming it changed which preset
|
||||
// the creator flow stages and which id the roster reports.
|
||||
@@ -98,6 +102,10 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
|
||||
{ file: 'apps/cli/tests/profiles/web/tests/fixtures/creator-plugin-manager.mjs', upstream: ['cordis'] },
|
||||
{ file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/preset/agent-preset-registry/tests/session.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/experimental/creation-recommendations/src/index.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/experimental/creation-recommendations/tests/creation.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/experimental/creation-recommendations/tests/loader-composition.spec.ts', upstream: ['cordis'] },
|
||||
// Preset selection, reminder policy and UI copy use the same product id.
|
||||
{ file: 'apps/cli/tests/optional-bundle-transitions.e2e.ts', upstream: ['cordis'] },
|
||||
{ file: 'apps/web/tests/agent-preset-selection.e2e.ts', upstream: ['cordis'] },
|
||||
{ file: 'apps/web/tests/developer-tools-settings.e2e.ts', upstream: ['cordis'] },
|
||||
@@ -111,9 +119,16 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
|
||||
{ file: 'docs/user/guide/schedule.zh.md', upstream: ['cordis'] },
|
||||
{ file: 'packages/bundle/web-app/cordis.patch.yml', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/src/client/CreatePluginMenuItem.tsx', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/src/client/analytics.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/analytics.client.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/components.client.spec.tsx', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/create-plugin-menu-item.client.spec.tsx', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-agent-preset/tests/section-store.client.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-conversation/src/client/input/submission-analytics.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-conversation/tests/service-orchestration.client.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-entry-customization/src/client/index.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-entry-customization/tests/menu.client.spec.tsx', upstream: ['cordis'] },
|
||||
{ file: 'packages/client/ui-workspace/tests/workspaces-service.client.spec.ts', upstream: ['cordis'] },
|
||||
// The preset-roster loop names the `cordis` preset id, not a package.
|
||||
{ file: 'apps/cli/tests/windows-shell.spec.ts', upstream: ['cordis'] },
|
||||
// GROUP_ORDER holds `packages/<group>/` directory names, not package names.
|
||||
@@ -140,6 +155,7 @@ const GENERIC_SKIPS: readonly GenericSkip[] = [
|
||||
{ file: 'packages/extensions/tool-cordis/src/providers.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/extensions/ui-cordis/src/client/index.ts', upstream: ['cordis'] },
|
||||
{ file: 'packages/extensions/ui-cordis/src/client/inventory.ts', upstream: ['cordis'] },
|
||||
// Inspector fixtures use the same extension event domain.
|
||||
{ file: 'packages/extensions/cordis-host-runner/tests/inspect-registry.spec.ts', upstream: ['cordis'] },
|
||||
{ file: 'snapshots/session/cordis-inspect-liveness/client-fixture.mjs', upstream: ['cordis'] },
|
||||
{ file: 'snapshots/session/cordis-inspect-timeout/client-fixture.mjs', upstream: ['cordis'] },
|
||||
@@ -489,7 +505,7 @@ function patterns(reverse: boolean): Pattern[] {
|
||||
.map(rename => ({
|
||||
...rename,
|
||||
token: new RegExp(`(['"\`])${escapeRegExp(rename.from)}((?:/[^'"\`\\s]*)?)\\1`, 'g'),
|
||||
yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)${escapeRegExp(rename.from)}([ \\t]*(?:#.*)?)$`, 'gm'),
|
||||
yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)(['"]?)${escapeRegExp(rename.from)}((?:/[^\\s'"]*)?)\\2([ \\t]*(?:#.*)?)$`, 'gm'),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -497,45 +513,109 @@ function skipped(file: string, pattern: Pattern): boolean {
|
||||
return GENERIC_SKIPS.some(skip => skip.file === file && skip.upstream.includes(pattern.upstream))
|
||||
}
|
||||
|
||||
function rewriteLine(line: string, file: string, all: readonly Pattern[]): string {
|
||||
function rewriteLine(line: string, file: string, all: readonly Pattern[], yaml = false): string {
|
||||
let out = line
|
||||
for (const pattern of all) {
|
||||
if (skipped(file, pattern)) continue
|
||||
out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`)
|
||||
out = out.replace(pattern.yamlName, (_match, prefix: string, suffix: string) => `${prefix}${pattern.to}${suffix}`)
|
||||
const preserve = skipped(file, pattern)
|
||||
if (!preserve) out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`)
|
||||
if (!preserve || yaml) {
|
||||
out = out.replace(pattern.yamlName, (_match, prefix: string, quote: string, subpath: string, suffix: string) => `${prefix}${quote}${pattern.to}${subpath}${quote}${suffix}`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Rewrite only module literals whose other same-file uses are product data. */
|
||||
function rewriteModules(text: string, file: string, all: readonly Pattern[], sourceName = file): string {
|
||||
const skip = GENERIC_SKIPS.find(entry => entry.file === file)
|
||||
if (!skip || skip.fixture) return text
|
||||
const active = all.filter(pattern => skip.upstream.includes(pattern.upstream))
|
||||
const source = ts.createSourceFile(sourceName, text, ts.ScriptTarget.Latest, true)
|
||||
const edits = new Map<number, { end: number; value: string }>()
|
||||
const visited = new Set<ts.Node>()
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (visited.has(node)) return
|
||||
visited.add(node)
|
||||
let literal: ts.Node | undefined
|
||||
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node) || ts.isJSDocImportTag(node)) {
|
||||
literal = node.moduleSpecifier
|
||||
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
||||
literal = node.moduleReference.expression
|
||||
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
literal = node.argument.literal
|
||||
} else if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
|
||||
literal = node.arguments[0]
|
||||
} else if (ts.isModuleDeclaration(node)) {
|
||||
literal = node.name
|
||||
}
|
||||
if (literal && ts.isStringLiteralLike(literal)) {
|
||||
const start = literal.getStart(source) + 1
|
||||
const end = literal.getEnd() - 1
|
||||
const value = text.slice(start, end)
|
||||
const pattern = active.find(entry => value === entry.from || value.startsWith(`${entry.from}/`))
|
||||
if (pattern) edits.set(start, { end, value: pattern.to + value.slice(pattern.from.length) })
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
for (const doc of ts.getJSDocCommentsAndTags(node)) visit(doc)
|
||||
}
|
||||
visit(source)
|
||||
let out = text
|
||||
for (const [start, edit] of [...edits].sort(([left], [right]) => right - left)) {
|
||||
out = out.slice(0, start) + edit.value + out.slice(edit.end)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a file's eligible lines.
|
||||
*
|
||||
* Markdown splits in two. Every fence is code a reader copies or a
|
||||
* configuration they mount, so every fence follows the rename regardless of its
|
||||
* info string. Prose follows it only under `docs/`, where a sentence quoting
|
||||
* `` `cordis` `` teaches an unresolved package name; elsewhere
|
||||
* prose is a record of what was true when it was written, and the same spelling
|
||||
* can mean something else entirely — the Python SDK's `cordis` option, or the
|
||||
* unvendored `@cordisjs/plugin-http`.
|
||||
* Rewrite eligible source, fences and docs prose. Audited names in source and
|
||||
* TypeScript/JavaScript fences change only in module syntax; YAML names remain
|
||||
* eligible too. Other names retain the token rule. Prose outside docs is kept.
|
||||
*/
|
||||
function rewrite(text: string, file: string, all: readonly Pattern[]): { text: string; lines: number } {
|
||||
const markdown = file.endsWith('.md')
|
||||
const prose = markdown && file.startsWith('docs/')
|
||||
let insideFence = false
|
||||
let lines = 0
|
||||
const out = text.split('\n').map((line) => {
|
||||
const original = text.split('\n')
|
||||
let source = text
|
||||
if (!markdown && /\.(ts|tsx|js|mjs|cjs|tpl)$/.test(file)) source = rewriteModules(text, file, all)
|
||||
let fenceStart: number | undefined
|
||||
let language = ''
|
||||
const out = source.split('\n')
|
||||
const finishFence = (end: number): void => {
|
||||
if (fenceStart === undefined || fenceStart === end || !/^(?:[cm]?[jt]sx?|typescript|javascript)?$/.test(language)) return
|
||||
const extension = language === 'jsx' || language === 'tsx' ? 'tsx' : language === 'js' || language === 'javascript' || language === 'mjs' || language === 'cjs' ? 'js' : 'ts'
|
||||
const body = out.slice(fenceStart, end).join('\n')
|
||||
out.splice(fenceStart, end - fenceStart, ...rewriteModules(body, file, all, `fence.${extension}`).split('\n'))
|
||||
}
|
||||
for (const [index, line] of out.entries()) {
|
||||
if (markdown) {
|
||||
if (/^\s*```/.test(line)) {
|
||||
insideFence = !insideFence
|
||||
return line
|
||||
const fence = /^\s*```(\S*)/.exec(line)
|
||||
if (fence) {
|
||||
if (fenceStart === undefined) {
|
||||
fenceStart = index + 1
|
||||
language = fence[1] ?? ''
|
||||
} else {
|
||||
finishFence(index)
|
||||
fenceStart = undefined
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!insideFence && !prose) return line
|
||||
if (fenceStart === undefined && !prose) continue
|
||||
}
|
||||
const next = rewriteLine(line, file, all)
|
||||
if (next !== line) lines += 1
|
||||
return next
|
||||
})
|
||||
return { text: out.join('\n'), lines }
|
||||
out[index] = rewriteLine(line, file, all, /\.ya?ml$/.test(file) || (fenceStart !== undefined && (language === 'yaml' || language === 'yml')))
|
||||
}
|
||||
if (fenceStart !== undefined) finishFence(out.length)
|
||||
return { text: out.join('\n'), lines: out.filter((line, index) => line !== original[index]).length }
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the CLI's generic package rename with the audited name exceptions.
|
||||
* @param text - Complete source text.
|
||||
* @param file - Repository-relative path with forward slash separators.
|
||||
* @param reverse - Restore upstream package names when true.
|
||||
* @returns Rewritten text and the number of changed lines.
|
||||
*/
|
||||
export function rescopeText(text: string, file: string, reverse = false): { text: string; lines: number } {
|
||||
return rewrite(text, file, patterns(reverse))
|
||||
}
|
||||
|
||||
function classify(file: string): string {
|
||||
@@ -587,7 +667,7 @@ function main(): void {
|
||||
const mode = args.includes('--apply') ? 'apply' : args.includes('--check') ? 'check' : 'dry'
|
||||
const reverse = args.includes('--reverse')
|
||||
const all = patterns(reverse)
|
||||
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' })
|
||||
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
||||
.split('\0')
|
||||
.filter(file => file !== '' && !isRescopeExcluded(file))
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/** Run the benchmark suite in the two phases its measurements tolerate. */
|
||||
import { resolve } from 'node:path'
|
||||
import { runPnpmCommand } from './compile-referenced-projects.ts'
|
||||
|
||||
/**
|
||||
* Cases whose wall-clock endpoints cannot absorb a sibling: they drive a
|
||||
* browser page, compare animation frames or paced streams, or spawn fresh
|
||||
* children and budget their startup. Corpus cases tolerate a sibling.
|
||||
*/
|
||||
export const SERIAL_BENCH_FILES = [
|
||||
'benchmarks/long-session-browser/long-session.bench.ts',
|
||||
'benchmarks/user-text/user-text.bench.client.ts',
|
||||
'benchmarks/conversation-fold/conversation-fold.bench.client.ts',
|
||||
'benchmarks/active-stream-reconnect/reconnect.bench.client.ts',
|
||||
// Fresh-child reopen case: its endpoints budget 63 ms and 120 ms, and a
|
||||
// sibling process pushed them to 76 ms and 131 ms.
|
||||
'benchmarks/session-open/session-open.bench.ts',
|
||||
// Request-history budgets 297 ms against a 212 ms healthy median; a sibling
|
||||
// pushed it to 342 ms.
|
||||
'benchmarks/agent-continuation/agent-continuation.bench.ts',
|
||||
] as const
|
||||
|
||||
const CONFIG = ['--config', 'vitest.bench.config.ts'] as const
|
||||
|
||||
/**
|
||||
* Run one phase of the suite.
|
||||
* @param root - Repository root containing the Vitest configuration.
|
||||
* @param phase - `first` clears the report and runs the CPU cases two at a time;
|
||||
* `last` runs the browser cases one at a time and renders the report.
|
||||
* @throws If the phase fails.
|
||||
*/
|
||||
export function runBenchPhase(root: string, phase: 'first' | 'last'): void {
|
||||
process.env.DSH_BENCH_REPORT_PHASE = phase
|
||||
try {
|
||||
if (phase === 'first') {
|
||||
runPnpmCommand(root, [
|
||||
'exec', 'vitest', 'run', ...CONFIG,
|
||||
...SERIAL_BENCH_FILES.map(file => `--exclude=${file}`),
|
||||
])
|
||||
return
|
||||
}
|
||||
runPnpmCommand(root, [
|
||||
'exec', 'vitest', 'run', ...CONFIG,
|
||||
'--no-file-parallelism', '--maxWorkers=1',
|
||||
...SERIAL_BENCH_FILES,
|
||||
])
|
||||
} finally {
|
||||
delete process.env.DSH_BENCH_REPORT_PHASE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run both phases, so a first-phase failure still reports every later case.
|
||||
* @param root - Repository root containing the Vitest configuration.
|
||||
* @returns the process exit code: 1 when either phase failed.
|
||||
*/
|
||||
export function runBenchSuite(root: string): number {
|
||||
let failed = false
|
||||
for (const phase of ['first', 'last'] as const) {
|
||||
try {
|
||||
runBenchPhase(root, phase)
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
return failed ? 1 : 0
|
||||
}
|
||||
|
||||
if (import.meta.main) process.exitCode = runBenchSuite(resolve(import.meta.dirname, '..'))
|
||||
+23
-11
@@ -282,7 +282,7 @@ describe('gate graph validation', () => {
|
||||
'npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts',
|
||||
)
|
||||
expect(scripts['build:native-system']).toBe('tsx native/system/scripts/build.ts --host-addon-only')
|
||||
expect(scripts['test:bench:built']).toBe('vitest run --config vitest.bench.config.ts')
|
||||
expect(scripts['test:bench:built']).toBe('tsx scripts/run-bench-suite.ts')
|
||||
})
|
||||
|
||||
it('checks all maintained repository references locally and in CI', () => {
|
||||
@@ -370,12 +370,22 @@ describe('gate graph validation', () => {
|
||||
expect(ids).toEqual([
|
||||
'rescope-vendor', 'publint', 'constraints', 'default-product-isolation', 'product-use', 'official-bundle-catalog', 'provider-data', 'package-dependencies', 'application-entrypoints',
|
||||
'dsh-package-licenses', 'node-next-types',
|
||||
'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
|
||||
'optional-dependency-imports', 'client-packages', 'installer-locales', 'locale-metadata', 'client-ui-i18n', 'client-route-resolution', 'no-bare-dispatcher',
|
||||
'plugin-record-callers', 'no-unknown-casts', 'no-top-level-await',
|
||||
'cordis-config', 'runtime-closure',
|
||||
])
|
||||
})
|
||||
|
||||
it('includes JSON resource checks in local and CI aggregates', () => {
|
||||
for (const mode of ['i18n', 'check-all', 'ci-static', 'hygiene'] as const) {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(gate => gate.id))
|
||||
expect(ids).toEqual(expect.arrayContaining(['installer-locales', 'locale-metadata']))
|
||||
if (mode !== 'i18n') expect(ids).not.toContain('locale-dictionaries')
|
||||
}
|
||||
expect(withPnpmEntrypoint(() => gatesForMode('i18n').map(gate => gate.id)))
|
||||
.toEqual(['installer-locales', 'locale-metadata', 'locale-dictionaries'])
|
||||
})
|
||||
|
||||
it('caps the local hygiene aggregate at eight workers', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('hygiene').map(subject => subject.id))
|
||||
|
||||
@@ -827,10 +837,12 @@ describe('Typert contract preparation', () => {
|
||||
it('reuses contracts from the validated consumer build', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
|
||||
|
||||
expect(subject.find(item => item.id === 'lint-and-duplication')).toMatchObject({
|
||||
displayCommand: 'pnpm run check:ci:lint:contracts-ready',
|
||||
args: ['/private/pnpm.cjs', 'run', 'check:ci:lint:contracts-ready'],
|
||||
})
|
||||
// Lint and duplication run in their own lane; the consumers graph only
|
||||
// keeps the readers that consume built artifacts.
|
||||
expect(subject.map(item => item.id)).not.toContain('lint-and-duplication')
|
||||
expect(withPnpmEntrypoint(() => gatesForMode('ci-lint-contracts-ready'))).toContainEqual(expect.objectContaining({
|
||||
id: 'lint',
|
||||
}))
|
||||
expect(subject.find(item => item.id === 'doc-typecheck')).toMatchObject({
|
||||
displayCommand: 'pnpm run doc-typecheck:contracts-ready',
|
||||
args: ['/private/pnpm.cjs', 'run', 'doc-typecheck:contracts-ready'],
|
||||
@@ -946,7 +958,6 @@ describe('Node 24 lane ownership', () => {
|
||||
'build',
|
||||
'node-compat',
|
||||
'publint',
|
||||
'lint-and-duplication',
|
||||
'snapshot',
|
||||
'expected-output',
|
||||
'web-snapshot',
|
||||
@@ -955,13 +966,15 @@ describe('Node 24 lane ownership', () => {
|
||||
'built-bin-smoke',
|
||||
])
|
||||
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
|
||||
expect(subject.find(item => item.id === 'build')?.env).toEqual({
|
||||
DSH_BUILD_CLIENT_PROFILE: 'official',
|
||||
// The consumers lane emits artifacts only: the aggregate test and script
|
||||
// type checks belong to the master lanes, not to every pull request.
|
||||
expect(subject.find(item => item.id === 'build')).toMatchObject({
|
||||
displayCommand: 'pnpm run build:artifacts',
|
||||
env: { DSH_BUILD_CLIENT_PROFILE: 'official' },
|
||||
})
|
||||
expect(subject.find(item => item.id === 'node-compat')?.env).toEqual({
|
||||
DSH_BUILD_CLIENT_PROFILE: 'official',
|
||||
})
|
||||
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['build'])
|
||||
for (const id of [
|
||||
'snapshot',
|
||||
'expected-output',
|
||||
@@ -993,7 +1006,6 @@ describe('Node 24 lane ownership', () => {
|
||||
env: { DSH_SNAPSHOT: 'replay' },
|
||||
after: [
|
||||
'publint',
|
||||
'lint-and-duplication',
|
||||
'snapshot',
|
||||
'expected-output',
|
||||
'doc-typecheck',
|
||||
|
||||
+27
-7
@@ -35,6 +35,7 @@ export type Mode =
|
||||
| 'hygiene'
|
||||
| 'doc-sync'
|
||||
| 'doc-quick'
|
||||
| 'i18n'
|
||||
|
||||
type GateResultStatus = 'passed' | 'failed' | 'skipped'
|
||||
type GateState = 'pending' | 'running' | GateResultStatus
|
||||
@@ -169,11 +170,12 @@ function parseMode(raw: string | undefined): Mode {
|
||||
case 'check-all':
|
||||
case 'hygiene':
|
||||
case 'doc-sync':
|
||||
case 'i18n':
|
||||
case 'doc-quick':
|
||||
return raw
|
||||
default:
|
||||
throw new Error(
|
||||
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-unit | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick, got ${JSON.stringify(raw)}.`,
|
||||
`run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-unit | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick | i18n, got ${JSON.stringify(raw)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -277,6 +279,17 @@ function ciBuildGate(id = 'build', options: Partial<Gate> = {}): Gate {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit the artifacts every artifact consumer reads, without the aggregate
|
||||
* test and script type checks that the master lanes own.
|
||||
*/
|
||||
function ciArtifactBuildGate(id = 'build', options: Partial<Gate> = {}): Gate {
|
||||
return pnpmScript(id, 'build:artifacts', {
|
||||
...options,
|
||||
env: { ...options.env, [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
|
||||
})
|
||||
}
|
||||
|
||||
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
|
||||
return {
|
||||
id,
|
||||
@@ -363,6 +376,8 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
return docSyncLeafGates()
|
||||
case 'doc-quick':
|
||||
return docQuickLeafGates()
|
||||
case 'i18n':
|
||||
return i18nGates(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -385,12 +400,21 @@ function ciSharedStaticGates(): Gate[] {
|
||||
]
|
||||
}
|
||||
|
||||
function i18nGates(includeDictionaries: boolean): Gate[] {
|
||||
return [
|
||||
pnpmScript('installer-locales', 'verify-installer-locales', { label: 'Windows installer locales' }),
|
||||
pnpmScript('locale-metadata', 'verify-locale-metadata', { label: 'plugin locale metadata' }),
|
||||
...includeDictionaries ? [pnpmExec('locale-dictionaries', ['vitest', 'run', 'scripts/locale-dictionary-parity.spec.ts', 'scripts/shipped-locales.spec.ts', 'packages/client/locale/tests/apply.client.spec.ts'], { label: 'locale dictionaries' })] : [],
|
||||
]
|
||||
}
|
||||
|
||||
function sharedHygieneGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('optional-dependency-imports', 'verify-optional-dependency-imports', {
|
||||
label: 'optional dependency imports',
|
||||
}),
|
||||
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
|
||||
...i18nGates(false),
|
||||
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
|
||||
pnpmScript('client-route-resolution', 'verify-client-route-resolution', { label: 'client route resolution' }),
|
||||
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
|
||||
@@ -517,7 +541,6 @@ function ciConsumerGates(): Gate[] {
|
||||
// writer starts; `after` preserves the web diagnostic even if a reader fails.
|
||||
const buildArtifactReaders = [
|
||||
'publint',
|
||||
'lint-and-duplication',
|
||||
'snapshot',
|
||||
'expected-output',
|
||||
'doc-typecheck',
|
||||
@@ -525,16 +548,12 @@ function ciConsumerGates(): Gate[] {
|
||||
'built-bin-smoke',
|
||||
]
|
||||
return [
|
||||
ciBuildGate(),
|
||||
ciArtifactBuildGate(),
|
||||
pnpmScript('node-compat', 'check:node-compat', {
|
||||
label: 'Node compatibility',
|
||||
env: { [CLIENT_BUILD_PROFILE_SELECTOR]: 'official' },
|
||||
}),
|
||||
pnpmScript('publint', 'publint', { needs: builtTree }),
|
||||
pnpmScript('lint-and-duplication', 'check:ci:lint:contracts-ready', {
|
||||
label: 'lint and duplication',
|
||||
needs: builtTree,
|
||||
}),
|
||||
snapshotGate(builtTree),
|
||||
expectedOutputGate(builtTree),
|
||||
webSnapshotGate(builtTree, buildArtifactReaders),
|
||||
@@ -860,6 +879,7 @@ function docSyncLeafGates(options: {
|
||||
'website/tests/mermaid-viewer.spec.ts',
|
||||
'website/tests/image-viewer.spec.ts',
|
||||
'website/tests/code-groups.spec.ts',
|
||||
'website/tests/table-filter.spec.ts',
|
||||
'website/tests/page-markdown-actions.spec.ts', 'website/tests/raw-markdown.spec.ts',
|
||||
], {
|
||||
label: 'documentation site checks',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import * as desktop from '../apps/desktop/src/locale.ts'
|
||||
import { readInstallerLocales } from '../apps/desktop/scripts/installer-locales.mjs'
|
||||
/** Translations preserve executable command examples and named interpolation fields. */
|
||||
import { expect, it } from 'vitest'
|
||||
import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters } from './shipped-locales.ts'
|
||||
|
||||
it('retains repeated placeholders, commands, and API identifiers', () => {
|
||||
expect(localeTokens('Use /plan off, /compact and /THEME=dark with {count}, {count} and reasoning_effort.'))
|
||||
.toEqual(['/THEME=dark', '/compact', '/plan off', 'reasoning_effort', '{count}', '{count}'])
|
||||
})
|
||||
|
||||
it('ignores translated prose and non-command slashes', () => {
|
||||
expect(localeTokens('Cmd/Ctrl+Enter · tok/s · https://example.test/v1')).toEqual([])
|
||||
expect(localeTokens('Используйте /plan off и {count}'))
|
||||
.toEqual(localeTokens('Use /plan off and {count}'))
|
||||
})
|
||||
|
||||
it.each([['/plan off', '/plan désactivé'], ['/THEME=light', '/THEME=claro'], ['reasoning_effort', 'Reasoning_effort'], ['{count}', '{other}']])('detects a change from %s to %s', (source, translated) => {
|
||||
expect(localeTokens(translated)).not.toEqual(localeTokens(source))
|
||||
})
|
||||
|
||||
it('recognizes commands at the start of copy or after CJK punctuation', () => {
|
||||
expect(localeTokens('/THEME=auto、/THEME=light')).toEqual(['/THEME=auto', '/THEME=light'])
|
||||
})
|
||||
|
||||
it.each(['\u200b', '\ufeff'])('rejects an invisible translation character', (character) => {
|
||||
expect(hasInvisibleLocaleCharacters(`text${character}text`)).toBe(true)
|
||||
expect(hasInvisibleLocaleCharacters('normal text')).toBe(false)
|
||||
})
|
||||
|
||||
it('matches Desktop dictionaries, resolution and installer resources to the shipped catalog', () => {
|
||||
const dictionaries = Object.keys(desktop).filter(name => /^[a-z]{2}$/u.test(name)).sort()
|
||||
expect(dictionaries).toEqual([...SHIPPED_LOCALE_IDS].sort())
|
||||
for (const id of SHIPPED_LOCALE_IDS) {
|
||||
expect(desktop.resolveDesktopLocale(id).id).toBe(id === 'zh' ? 'zh-CN' : id)
|
||||
expect(desktop.resolveDesktopStartupLocale(null, [id]).id).toBe(id === 'zh' ? 'zh-CN' : id)
|
||||
}
|
||||
expect(readInstallerLocales().map(locale => locale.id.split('_')[0]).sort()).toEqual(dictionaries)
|
||||
})
|
||||
|
||||
it('rejects invisible characters in the Desktop locale source', () => {
|
||||
expect(hasInvisibleLocaleCharacters(readFileSync(new URL('../apps/desktop/src/locale.ts', import.meta.url), 'utf8'))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Languages whose UI dictionaries and package metadata ship with Harness. */
|
||||
export const SHIPPED_LOCALE_IDS = ['en', 'zh', 'ja', 'es', 'fr', 'de', 'ru', 'pt'] as const
|
||||
|
||||
/** Locale id required by repository translation checks. */
|
||||
export type ShippedLocaleId = typeof SHIPPED_LOCALE_IDS[number]
|
||||
|
||||
/**
|
||||
* Extract copy tokens that translations must preserve verbatim.
|
||||
* @param text - Dictionary text containing placeholders, slash commands or API field names.
|
||||
* @returns Sorted tokens, retaining repeated occurrences.
|
||||
*/
|
||||
export function localeTokens(text: string): string[] {
|
||||
return (text.match(/\{\w+\}|(?<![A-Za-z0-9/])\/[A-Za-z][\w-]*(?:=[\w-]+| (?:on|off))?|\b[a-z]+_[a-z_]+\b/gu) ?? []).sort()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect translation-service artifacts that must not enter locale resources.
|
||||
* @param text - Raw resource or localized text.
|
||||
* @returns Whether the text contains a zero-width space or byte-order mark.
|
||||
*/
|
||||
export function hasInvisibleLocaleCharacters(text: string): boolean {
|
||||
return /[\u200b\ufeff]/u.test(text)
|
||||
}
|
||||
@@ -150,7 +150,7 @@ RESTART_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "restart"
|
||||
)
|
||||
RESTART_SNAPSHOT_FILENAMES = (
|
||||
"result.json", "requests.json", "session.1.v4.jsonl", "session.2.v4.jsonl",
|
||||
"result.json", "requests.json", "session.1.v4.jsonl", "session.2.v4.jsonl", "resume.json",
|
||||
)
|
||||
RECOVERY_SNAPSHOT_DIRECTORY = (
|
||||
Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "scheduler-recovery"
|
||||
@@ -258,7 +258,8 @@ def write_advanced_profile_patch(
|
||||
) -> Path:
|
||||
"""Write the shared custom, snapshot, and restart profile patch."""
|
||||
return write_profile_patch(root, name, sessions, [
|
||||
{"id": "tools", "config": {"mode": mode}},
|
||||
{"id": "tool-executor-native", "disabled": mode != "native"},
|
||||
{"id": "tool-executor-ptc", "disabled": mode != "ptc"},
|
||||
{
|
||||
"id": "system-prompt",
|
||||
"config": {
|
||||
@@ -666,7 +667,6 @@ def advanced_tool_followup(
|
||||
"advanced-direct-child",
|
||||
"subagent",
|
||||
{
|
||||
"name": "direct-child",
|
||||
"description": "Check direct child",
|
||||
"prompt": SNAPSHOT_DIRECT_CHILD_PROMPT,
|
||||
},
|
||||
@@ -1767,10 +1767,10 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
|
||||
patch = write_advanced_profile_patch(root, "restart.patch.yml", sessions)
|
||||
first_request = len(MockModelHandler.requests)
|
||||
|
||||
def connect() -> DeepSeekHarness:
|
||||
def connect(model: str = "smoke-model") -> DeepSeekHarness:
|
||||
return DeepSeekHarness(
|
||||
provider="deepseek-official",
|
||||
model="smoke-model",
|
||||
model=model,
|
||||
cwd=str(root),
|
||||
dsh_bin=str(executable),
|
||||
dsh_home=str(dsh_home),
|
||||
@@ -1818,13 +1818,8 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
|
||||
root,
|
||||
sessions,
|
||||
)
|
||||
compare_snapshot_files(
|
||||
files, update_snapshots, RESTART_SNAPSHOT_DIRECTORY, RESTART_SNAPSHOT_FILENAMES,
|
||||
native_writer_output=True,
|
||||
)
|
||||
|
||||
before_resume = logs[RESTART_FIRST_SESSION_ID]
|
||||
with connect() as harness:
|
||||
with connect("resumed-smoke-model") as harness:
|
||||
session = harness.resume_session(RESTART_FIRST_SESSION_ID)
|
||||
if session.id != RESTART_FIRST_SESSION_ID:
|
||||
raise AssertionError(f"resume changed the saved identity: {session.id}")
|
||||
@@ -1862,6 +1857,28 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots
|
||||
if RESUME_TEXT not in render_jsonl(restored):
|
||||
raise AssertionError("resumed response was not appended to the saved session")
|
||||
|
||||
selection_events = [
|
||||
{"type": record["type"], "data": record["data"]}
|
||||
for record in restored[len(before_resume):]
|
||||
if record.get("type") == "model/selection"
|
||||
]
|
||||
if selection_events != [{"type": "model/selection", "data": {
|
||||
"provider": "deepseek-official", "model": "resumed-smoke-model",
|
||||
}}]:
|
||||
raise AssertionError(f"resume did not persist the initialized model: {selection_events}")
|
||||
if requests[0].get("model") != "resumed-smoke-model":
|
||||
raise AssertionError(f"resumed request ignored the initialized model: {requests[0]}")
|
||||
files["resume.json"] = json.dumps({
|
||||
"model": requests[0].get("model"),
|
||||
"final_response": resumed.final_response,
|
||||
"eventTypes": [event.get("type") for event in resumed.events],
|
||||
"selectionEvents": selection_events,
|
||||
}, indent=2, ensure_ascii=False) + "\n"
|
||||
compare_snapshot_files(
|
||||
files, update_snapshots, RESTART_SNAPSHOT_DIRECTORY, RESTART_SNAPSHOT_FILENAMES,
|
||||
native_writer_output=True,
|
||||
)
|
||||
|
||||
|
||||
def smoke_sdk_scheduler_recovery(base_url: str, executable: Path, update_snapshots: bool) -> None:
|
||||
"""Keep a failed tool turn usable through the real Messages serializer and SDK."""
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Serial agent creation completed."
|
||||
"text": "Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
@@ -1156,7 +1156,7 @@
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Serial agent creation completed."
|
||||
"text": "Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."}],"source":{"kind":"plugin:serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"python-snapshot-image-offload"}},"surfaceOp":"append"}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Serial agent creation completed."
|
||||
"text": "Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
@@ -763,7 +763,7 @@
|
||||
"type": "tool-call",
|
||||
"id": "advanced-direct-child",
|
||||
"name": "subagent",
|
||||
"arguments": "{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
"arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
@@ -818,7 +818,7 @@
|
||||
"dt": [],
|
||||
"id": "advanced-direct-child",
|
||||
"args": [
|
||||
"{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -831,7 +831,7 @@
|
||||
"type": "tool-call",
|
||||
"id": "advanced-direct-child",
|
||||
"name": "subagent",
|
||||
"arguments": "{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
"arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -882,7 +882,7 @@
|
||||
"step": 3,
|
||||
"callId": "advanced-direct-child",
|
||||
"name": "subagent",
|
||||
"arguments": "{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
"arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -894,13 +894,22 @@
|
||||
"childId": "{{child-1}}",
|
||||
"childCreatedAt": 0,
|
||||
"mode": "continuable",
|
||||
"label": "Check direct child",
|
||||
"name": "direct-child"
|
||||
"label": "Check direct child"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "subagent-name/bound",
|
||||
"seq": 38,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"version": 1,
|
||||
"childId": "{{child-1}}",
|
||||
"name": "lead/agent-1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool/result",
|
||||
"seq": 38,
|
||||
"seq": 39,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -915,7 +924,7 @@
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "started subagent lead/direct-child"
|
||||
"text": "started subagent {{child-1}}\nname: lead/agent-1"
|
||||
}
|
||||
],
|
||||
"isError": false,
|
||||
@@ -929,7 +938,7 @@
|
||||
},
|
||||
{
|
||||
"type": "step/end",
|
||||
"seq": 39,
|
||||
"seq": 40,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -938,7 +947,7 @@
|
||||
},
|
||||
{
|
||||
"type": "step/start",
|
||||
"seq": 40,
|
||||
"seq": 41,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -947,17 +956,17 @@
|
||||
},
|
||||
{
|
||||
"type": "session-log-deepseek/delivery-accepted",
|
||||
"seq": 41,
|
||||
"seq": 42,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
"sessionFormatVersion": 4,
|
||||
"throughSeq": 40
|
||||
"throughSeq": 41
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "agent/inbox/spliced",
|
||||
"seq": 42,
|
||||
"seq": 43,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"target": "next-step",
|
||||
@@ -967,7 +976,7 @@
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Background subagent lead/direct-child finished and will do no further work unless you send it more."
|
||||
"text": "Background subagent {{child-1}} finished and will do no further work unless you send it more."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -981,7 +990,7 @@
|
||||
"source": {
|
||||
"kind": "subagent-settled",
|
||||
"form": "notice",
|
||||
"summary": "Background subagent lead/direct-child finished and will do no further work unless you send it more.",
|
||||
"summary": "Background subagent {{child-1}} finished and will do no further work unless you send it more.",
|
||||
"senderSessionId": "{{child-1}}"
|
||||
},
|
||||
"role": "user",
|
||||
@@ -992,7 +1001,7 @@
|
||||
},
|
||||
{
|
||||
"type": "assistant/message",
|
||||
"seq": 43,
|
||||
"seq": 44,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -1116,7 +1125,7 @@
|
||||
},
|
||||
{
|
||||
"type": "tool/call",
|
||||
"seq": 44,
|
||||
"seq": 45,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -1128,7 +1137,7 @@
|
||||
},
|
||||
{
|
||||
"type": "tool-workflow/run-start",
|
||||
"seq": 45,
|
||||
"seq": 46,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"runId": "{{workflow-run}}",
|
||||
@@ -1137,20 +1146,19 @@
|
||||
},
|
||||
{
|
||||
"type": "subagent/catalog",
|
||||
"seq": 46,
|
||||
"seq": 47,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"version": 0,
|
||||
"childId": "{{child-2}}",
|
||||
"childCreatedAt": 0,
|
||||
"mode": "continuable",
|
||||
"label": "advanced-exe-snapshot child 1",
|
||||
"name": "agent-1"
|
||||
"label": "advanced-exe-snapshot child 1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "tool-workflow/agent-start",
|
||||
"seq": 47,
|
||||
"seq": 48,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"runId": "{{workflow-run}}",
|
||||
@@ -1162,7 +1170,7 @@
|
||||
},
|
||||
{
|
||||
"type": "tool-workflow/agent-end",
|
||||
"seq": 48,
|
||||
"seq": 49,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"runId": "{{workflow-run}}",
|
||||
@@ -1172,7 +1180,7 @@
|
||||
},
|
||||
{
|
||||
"type": "tool-workflow/run-end",
|
||||
"seq": 49,
|
||||
"seq": 50,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"runId": "{{workflow-run}}",
|
||||
@@ -1181,7 +1189,7 @@
|
||||
},
|
||||
{
|
||||
"type": "tool/result",
|
||||
"seq": 50,
|
||||
"seq": 51,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -1204,13 +1212,13 @@
|
||||
}
|
||||
},
|
||||
"sourceEventSeqs": [
|
||||
44
|
||||
45
|
||||
],
|
||||
"surfaceOp": "append"
|
||||
},
|
||||
{
|
||||
"type": "step/end",
|
||||
"seq": 51,
|
||||
"seq": 52,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -1219,7 +1227,7 @@
|
||||
},
|
||||
{
|
||||
"type": "agent/inbox/spliced",
|
||||
"seq": 52,
|
||||
"seq": 53,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"target": "next-step",
|
||||
@@ -1230,7 +1238,7 @@
|
||||
},
|
||||
{
|
||||
"type": "step/start",
|
||||
"seq": 53,
|
||||
"seq": 54,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -1239,13 +1247,13 @@
|
||||
},
|
||||
{
|
||||
"type": "user/message",
|
||||
"seq": 54,
|
||||
"seq": 55,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Background subagent lead/direct-child finished and will do no further work unless you send it more."
|
||||
"text": "Background subagent {{child-1}} finished and will do no further work unless you send it more."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -1259,7 +1267,7 @@
|
||||
"source": {
|
||||
"kind": "subagent-settled",
|
||||
"form": "notice",
|
||||
"summary": "Background subagent lead/direct-child finished and will do no further work unless you send it more.",
|
||||
"summary": "Background subagent {{child-1}} finished and will do no further work unless you send it more.",
|
||||
"senderSessionId": "{{child-1}}"
|
||||
},
|
||||
"role": "user",
|
||||
@@ -1269,17 +1277,17 @@
|
||||
},
|
||||
{
|
||||
"type": "session-log-deepseek/delivery-accepted",
|
||||
"seq": 55,
|
||||
"seq": 56,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
"sessionFormatVersion": 4,
|
||||
"throughSeq": 54
|
||||
"throughSeq": 55
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "assistant/message",
|
||||
"seq": 56,
|
||||
"seq": 57,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -1387,7 +1395,7 @@
|
||||
},
|
||||
{
|
||||
"type": "step/end",
|
||||
"seq": 57,
|
||||
"seq": 58,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -1396,7 +1404,7 @@
|
||||
},
|
||||
{
|
||||
"type": "command/run",
|
||||
"seq": 58,
|
||||
"seq": 59,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"commandId": "{{command:1}}",
|
||||
@@ -1408,7 +1416,7 @@
|
||||
},
|
||||
{
|
||||
"type": "feedback/record",
|
||||
"seq": 59,
|
||||
"seq": 60,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"text": "The session needs a clearer explanation."
|
||||
@@ -1416,7 +1424,7 @@
|
||||
},
|
||||
{
|
||||
"type": "command/done",
|
||||
"seq": 60,
|
||||
"seq": 61,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"commandId": "{{command:1}}",
|
||||
@@ -1426,7 +1434,7 @@
|
||||
},
|
||||
{
|
||||
"type": "feedback/record",
|
||||
"seq": 61,
|
||||
"seq": 62,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"category": "other"
|
||||
@@ -1434,7 +1442,7 @@
|
||||
},
|
||||
{
|
||||
"type": "feedback/message-put",
|
||||
"seq": 62,
|
||||
"seq": 63,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
@@ -1451,7 +1459,7 @@
|
||||
},
|
||||
{
|
||||
"type": "feedback/message-put",
|
||||
"seq": 63,
|
||||
"seq": 64,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
@@ -1467,7 +1475,7 @@
|
||||
},
|
||||
{
|
||||
"type": "feedback/message-delete",
|
||||
"seq": 64,
|
||||
"seq": 65,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
@@ -1476,7 +1484,7 @@
|
||||
},
|
||||
{
|
||||
"type": "turn/end",
|
||||
"seq": 65,
|
||||
"seq": 66,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -1630,7 +1638,7 @@
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Serial agent creation completed."
|
||||
"text": "Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
@@ -2445,7 +2453,7 @@
|
||||
"type": "tool-call",
|
||||
"id": "advanced-direct-child",
|
||||
"name": "subagent",
|
||||
"arguments": "{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
"arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
@@ -2500,7 +2508,7 @@
|
||||
"dt": [],
|
||||
"id": "advanced-direct-child",
|
||||
"args": [
|
||||
"{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -2513,7 +2521,7 @@
|
||||
"type": "tool-call",
|
||||
"id": "advanced-direct-child",
|
||||
"name": "subagent",
|
||||
"arguments": "{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
"arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -2570,7 +2578,7 @@
|
||||
"step": 3,
|
||||
"callId": "advanced-direct-child",
|
||||
"name": "subagent",
|
||||
"arguments": "{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
"arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2582,6 +2590,70 @@
|
||||
"childSessionId": "{{child-1}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{child-1}}",
|
||||
"event": {
|
||||
"type": "subagent/descriptor",
|
||||
"seq": 0,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"version": 3,
|
||||
"mode": "continuable",
|
||||
"provider": "spawn",
|
||||
"label": "Check direct child",
|
||||
"agentProvider": "deepseek-official",
|
||||
"agentModel": "smoke-model",
|
||||
"agentReasoningEffort": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{child-1}}",
|
||||
"event": {
|
||||
"type": "sandbox/mode",
|
||||
"seq": 1,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"mode": "danger-full-access",
|
||||
"source": "delegation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{child-1}}",
|
||||
"event": {
|
||||
"type": "approval/policy",
|
||||
"seq": 2,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"policy": "never",
|
||||
"source": "delegation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{child-1}}",
|
||||
"event": {
|
||||
"type": "permission/preset",
|
||||
"seq": 3,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"preset": "danger-full-access"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
@@ -2629,6 +2701,10 @@
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Reply with exactly DIRECT_CHILD_OK and nothing else."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
@@ -2710,8 +2786,23 @@
|
||||
"childId": "{{child-1}}",
|
||||
"childCreatedAt": 0,
|
||||
"mode": "continuable",
|
||||
"label": "Check direct child",
|
||||
"name": "direct-child"
|
||||
"label": "Check direct child"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "subagent-name/bound",
|
||||
"seq": 38,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"version": 1,
|
||||
"childId": "{{child-1}}",
|
||||
"name": "lead/agent-1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2722,7 +2813,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "tool/result",
|
||||
"seq": 38,
|
||||
"seq": 39,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -2737,7 +2828,7 @@
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "started subagent lead/direct-child"
|
||||
"text": "started subagent {{child-1}}\nname: lead/agent-1"
|
||||
}
|
||||
],
|
||||
"isError": false,
|
||||
@@ -2757,7 +2848,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "step/end",
|
||||
"seq": 39,
|
||||
"seq": 40,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -2772,7 +2863,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "step/start",
|
||||
"seq": 40,
|
||||
"seq": 41,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -2787,12 +2878,12 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "session-log-deepseek/delivery-accepted",
|
||||
"seq": 41,
|
||||
"seq": 42,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
"sessionFormatVersion": 4,
|
||||
"throughSeq": 40
|
||||
"throughSeq": 41
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2879,6 +2970,10 @@
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Reply with exactly DIRECT_CHILD_OK and nothing else."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."
|
||||
}
|
||||
],
|
||||
"source": {
|
||||
@@ -3186,7 +3281,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "agent/inbox/spliced",
|
||||
"seq": 42,
|
||||
"seq": 43,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"target": "next-step",
|
||||
@@ -3196,7 +3291,7 @@
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Background subagent lead/direct-child finished and will do no further work unless you send it more."
|
||||
"text": "Background subagent {{child-1}} finished and will do no further work unless you send it more."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -3210,7 +3305,7 @@
|
||||
"source": {
|
||||
"kind": "subagent-settled",
|
||||
"form": "notice",
|
||||
"summary": "Background subagent lead/direct-child finished and will do no further work unless you send it more.",
|
||||
"summary": "Background subagent {{child-1}} finished and will do no further work unless you send it more.",
|
||||
"senderSessionId": "{{child-1}}"
|
||||
},
|
||||
"role": "user",
|
||||
@@ -3244,7 +3339,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "assistant/message",
|
||||
"seq": 43,
|
||||
"seq": 44,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -3374,7 +3469,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "tool/call",
|
||||
"seq": 44,
|
||||
"seq": 45,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -3392,7 +3487,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "tool-workflow/run-start",
|
||||
"seq": 45,
|
||||
"seq": 46,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"runId": "{{workflow-run}}",
|
||||
@@ -3408,6 +3503,70 @@
|
||||
"childSessionId": "{{child-2}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{child-2}}",
|
||||
"event": {
|
||||
"type": "subagent/descriptor",
|
||||
"seq": 0,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"version": 3,
|
||||
"mode": "continuable",
|
||||
"provider": "spawn",
|
||||
"label": "advanced-exe-snapshot child 1",
|
||||
"agentProvider": "deepseek-official",
|
||||
"agentModel": "smoke-model",
|
||||
"agentReasoningEffort": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{child-2}}",
|
||||
"event": {
|
||||
"type": "sandbox/mode",
|
||||
"seq": 1,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"mode": "danger-full-access",
|
||||
"source": "delegation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{child-2}}",
|
||||
"event": {
|
||||
"type": "approval/policy",
|
||||
"seq": 2,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"policy": "never",
|
||||
"source": "delegation"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
"sessionId": "{{child-2}}",
|
||||
"event": {
|
||||
"type": "permission/preset",
|
||||
"seq": 3,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"preset": "danger-full-access"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"method": "session.event",
|
||||
"payload": {
|
||||
@@ -3529,15 +3688,14 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "subagent/catalog",
|
||||
"seq": 46,
|
||||
"seq": 47,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"version": 0,
|
||||
"childId": "{{child-2}}",
|
||||
"childCreatedAt": 0,
|
||||
"mode": "continuable",
|
||||
"label": "advanced-exe-snapshot child 1",
|
||||
"name": "agent-1"
|
||||
"label": "advanced-exe-snapshot child 1"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3548,7 +3706,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "tool-workflow/agent-start",
|
||||
"seq": 47,
|
||||
"seq": 48,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"runId": "{{workflow-run}}",
|
||||
@@ -3966,7 +4124,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "tool-workflow/agent-end",
|
||||
"seq": 48,
|
||||
"seq": 49,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"runId": "{{workflow-run}}",
|
||||
@@ -3982,7 +4140,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "tool-workflow/run-end",
|
||||
"seq": 49,
|
||||
"seq": 50,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"runId": "{{workflow-run}}",
|
||||
@@ -3997,7 +4155,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "tool/result",
|
||||
"seq": 50,
|
||||
"seq": 51,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -4020,7 +4178,7 @@
|
||||
}
|
||||
},
|
||||
"sourceEventSeqs": [
|
||||
44
|
||||
45
|
||||
],
|
||||
"surfaceOp": "append"
|
||||
}
|
||||
@@ -4032,7 +4190,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "step/end",
|
||||
"seq": 51,
|
||||
"seq": 52,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -4047,7 +4205,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "agent/inbox/spliced",
|
||||
"seq": 52,
|
||||
"seq": 53,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"target": "next-step",
|
||||
@@ -4064,7 +4222,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "step/start",
|
||||
"seq": 53,
|
||||
"seq": 54,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -4079,13 +4237,13 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "user/message",
|
||||
"seq": 54,
|
||||
"seq": 55,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Background subagent lead/direct-child finished and will do no further work unless you send it more."
|
||||
"text": "Background subagent {{child-1}} finished and will do no further work unless you send it more."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
@@ -4099,7 +4257,7 @@
|
||||
"source": {
|
||||
"kind": "subagent-settled",
|
||||
"form": "notice",
|
||||
"summary": "Background subagent lead/direct-child finished and will do no further work unless you send it more.",
|
||||
"summary": "Background subagent {{child-1}} finished and will do no further work unless you send it more.",
|
||||
"senderSessionId": "{{child-1}}"
|
||||
},
|
||||
"role": "user",
|
||||
@@ -4115,12 +4273,12 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "session-log-deepseek/delivery-accepted",
|
||||
"seq": 55,
|
||||
"seq": 56,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
"sessionFormatVersion": 4,
|
||||
"throughSeq": 54
|
||||
"throughSeq": 55
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4131,7 +4289,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "assistant/message",
|
||||
"seq": 56,
|
||||
"seq": 57,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -4245,7 +4403,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "step/end",
|
||||
"seq": 57,
|
||||
"seq": 58,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
@@ -4260,7 +4418,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "command/run",
|
||||
"seq": 58,
|
||||
"seq": 59,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"commandId": "{{command:1}}",
|
||||
@@ -4278,7 +4436,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "feedback/record",
|
||||
"seq": 59,
|
||||
"seq": 60,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"text": "The session needs a clearer explanation."
|
||||
@@ -4292,7 +4450,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "command/done",
|
||||
"seq": 60,
|
||||
"seq": 61,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"commandId": "{{command:1}}",
|
||||
@@ -4308,7 +4466,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "feedback/record",
|
||||
"seq": 61,
|
||||
"seq": 62,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"category": "other"
|
||||
@@ -4322,7 +4480,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "feedback/message-put",
|
||||
"seq": 62,
|
||||
"seq": 63,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
@@ -4345,7 +4503,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "feedback/message-put",
|
||||
"seq": 63,
|
||||
"seq": 64,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
@@ -4367,7 +4525,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "feedback/message-delete",
|
||||
"seq": 64,
|
||||
"seq": 65,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"sessionId": "{{parent}}",
|
||||
@@ -4382,7 +4540,7 @@
|
||||
"sessionId": "{{parent}}",
|
||||
"event": {
|
||||
"type": "turn/end",
|
||||
"seq": 65,
|
||||
"seq": 66,
|
||||
"time": 0,
|
||||
"data": {
|
||||
"turn": 1,
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
{"type":"approval/policy","data":{"policy":"never","source":"delegation"}}
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."},{"type":"text","text":"Your final answer is delivered automatically if your parent is still online. Include the result and relevant evidence in that answer; the parent does not receive your full transcript, tool output, or reasoning. If your last action is a message to your parent, the completion notice omits your final answer; include the result and relevant evidence in that message."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nDelegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"tool:subagent:depth","text":"Delegation depth for `subagent`: current 1, maximum 1, remaining levels 0. Do not call `subagent` at this depth; carry out the work directly."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","reasoningEffort":"high","maxTokens":256000},"adapterDefaults":{"maxTokens":true},"tools":["cordis_inspect_list","cordis_inspect_query","job_kill","job_list","job_output","snapshot_double","subagent","workflow","working_directory"]},"reason":"initial"}}
|
||||
{"type":"request/context","data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}}
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
|
||||
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
|
||||
{"type":"approval/policy","data":{"policy":"never"}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."}],"source":{"kind":"plugin:serial-created-fixture"}}]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"turn/start","data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":1}}
|
||||
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{messageId}}"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"text","text":"Serial agent creation completed. Ordinary and nested forks are independent; historical fork metadata remains readable."}],"source":{"kind":"plugin:serial-created-fixture"}},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"user/message","data":{"id":"{{messageId}}","role":"user","content":[{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","width":1,"height":1,"bytes":69}}],"source":{"kind":"python-snapshot-image-offload"}},"surfaceOp":"append"}
|
||||
@@ -34,27 +34,28 @@
|
||||
{"type":"step/end","data":{"turn":1,"step":2}}
|
||||
{"type":"step/start","data":{"turn":1,"step":3}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":33}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","args":["{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"name\": \"direct-child\", \"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
|
||||
{"type":"subagent/catalog","data":{"version":0,"childId":"{{child-1}}","childCreatedAt":0,"mode":"continuable","label":"Check direct child","name":"direct-child"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-direct-child"},"toolCallId":"advanced-direct-child","content":[{"type":"text","text":"started subagent lead/direct-child"}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[36],"surfaceOp":"append"}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","name":"subagent","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-direct-child","args":["{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}
|
||||
{"type":"subagent/catalog","data":{"version":0,"childId":"{{child-1}}","childCreatedAt":0,"mode":"continuable","label":"Check direct child"}}
|
||||
{"type":"subagent-name/bound","data":{"version":1,"childId":"{{child-1}}","name":"lead/agent-1"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":3,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-direct-child"},"toolCallId":"advanced-direct-child","content":[{"type":"text","text":"started subagent {{child-1}}\nname: lead/agent-1"}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[36],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":3}}
|
||||
{"type":"step/start","data":{"turn":1,"step":4}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":40}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent lead/direct-child finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent lead/direct-child finished and will do no further work unless you send it more.","senderSessionId":"{{child-1}}"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":41}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"Background subagent {{child-1}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{child-1}} finished and will do no further work unless you send it more.","senderSessionId":"{{child-1}}"},"role":"user","id":"{{messageId}}"}]}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","name":"workflow","args":[""]},{"type":"tool-call-chunks","time0":0,"index":0,"dt":[],"id":"advanced-workflow","args":["{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"tool-calls"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"tool-call"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"tool/call","data":{"turn":1,"step":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-workflow/run-start","data":{"runId":"{{workflow-run}}","name":"advanced-exe-snapshot"}}
|
||||
{"type":"subagent/catalog","data":{"version":0,"childId":"{{child-2}}","childCreatedAt":0,"mode":"continuable","label":"advanced-exe-snapshot child 1","name":"agent-1"}}
|
||||
{"type":"subagent/catalog","data":{"version":0,"childId":"{{child-2}}","childCreatedAt":0,"mode":"continuable","label":"advanced-exe-snapshot child 1"}}
|
||||
{"type":"tool-workflow/agent-start","data":{"runId":"{{workflow-run}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{child-2}}"}}
|
||||
{"type":"tool-workflow/agent-end","data":{"runId":"{{workflow-run}}","seq":1,"outcome":"completed"}}
|
||||
{"type":"tool-workflow/run-end","data":{"runId":"{{workflow-run}}","stopReason":"completed"}}
|
||||
{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-workflow"},"toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"}
|
||||
{"type":"tool/result","data":{"turn":1,"step":4,"message":{"role":"tool","source":{"kind":"tool","callId":"advanced-workflow"},"toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false,"id":"{{messageId}}"}},"sourceEventSeqs":[45],"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":4}}
|
||||
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","data":{"turn":1,"step":5}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent lead/direct-child finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent lead/direct-child finished and will do no further work unless you send it more.","senderSessionId":"{{child-1}}"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":54}}
|
||||
{"type":"user/message","data":{"content":[{"type":"text","text":"Background subagent {{child-1}} finished and will do no further work unless you send it more."},{"type":"text","text":"Its closing message:"},{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"subagent-settled","form":"notice","summary":"Background subagent {{child-1}} finished and will do no further work unless you send it more.","senderSessionId":"{{child-1}}"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"}
|
||||
{"type":"session-log-deepseek/delivery-accepted","data":{"sessionId":"{{parent}}","sessionFormatVersion":4,"throughSeq":55}}
|
||||
{"type":"assistant/message","data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model","replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}},"id":"{{message:1}}"},"usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6},"stream":[{"type":"chunk","time":0,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"text-chunks","time0":0,"index":0,"dt":[],"texts":["ADVANCED_EXECUTABLE_OK"]},{"type":"chunk","time":0,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}},{"type":"chunk","time":0,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3,"totalTokens":6}}},{"type":"chunk","time":0,"chunk":{"type":"finish","reason":{"kind":"stop"},"replayState":{"response":{"kind":"deepseek-messages","version":1,"model":"smoke-model"},"blocks":[{"type":"text"}]}}}]},"surfaceOp":"append"}
|
||||
{"type":"step/end","data":{"turn":1,"step":5}}
|
||||
{"type":"command/run","data":{"commandId":"{{command:1}}","name":"feedback","source":{"kind":"user"}}}
|
||||
|
||||
@@ -48,7 +48,6 @@
|
||||
"ask_user_question",
|
||||
"create_goal",
|
||||
"edit",
|
||||
"exit_plan_mode",
|
||||
"get_goal",
|
||||
"glob",
|
||||
"grep",
|
||||
@@ -111,7 +110,6 @@
|
||||
"ask_user_question",
|
||||
"create_goal",
|
||||
"edit",
|
||||
"exit_plan_mode",
|
||||
"get_goal",
|
||||
"glob",
|
||||
"grep",
|
||||
@@ -189,7 +187,6 @@
|
||||
"ask_user_question",
|
||||
"create_goal",
|
||||
"edit",
|
||||
"exit_plan_mode",
|
||||
"get_goal",
|
||||
"glob",
|
||||
"grep",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"model": "resumed-smoke-model",
|
||||
"final_response": "RESUMED_PROCESS_OK",
|
||||
"eventTypes": [
|
||||
"agent/inbox/spliced",
|
||||
"turn/start",
|
||||
"agent/inbox/spliced",
|
||||
"step/start",
|
||||
"user/message",
|
||||
"request/header",
|
||||
"request/context",
|
||||
"session-log-deepseek/delivery-accepted",
|
||||
"assistant/message",
|
||||
"step/end",
|
||||
"turn/end"
|
||||
],
|
||||
"selectionEvents": [
|
||||
{
|
||||
"type": "model/selection",
|
||||
"data": {
|
||||
"provider": "deepseek-official",
|
||||
"model": "resumed-smoke-model"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/** Source-text parsing shared by the package dependency and client package gates. */
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { isBuiltin } from 'node:module'
|
||||
import { resolve, sep } from 'node:path'
|
||||
import ts from 'typescript'
|
||||
import type { PackageDependencyManifest, WorkspacePackageManifest } from './verify-package-dependencies.ts'
|
||||
|
||||
export type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
|
||||
|
||||
export const ROOT_TYPE_PACKAGES: ReadonlySet<string> = new Set(['@types/react', '@types/react-dom'])
|
||||
|
||||
/**
|
||||
* Report whether a specifier names a bare package rather than a path or subpath import.
|
||||
* @param specifier - module specifier text.
|
||||
* @returns whether the specifier resolves outside this package.
|
||||
*/
|
||||
function isBareSpecifier(specifier: string): boolean {
|
||||
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the leading package segments of one bare module specifier.
|
||||
* @param specifier - module specifier text that already passed {@link isBareSpecifier}.
|
||||
* @returns the package name this specifier belongs to.
|
||||
*/
|
||||
function packageNameForUses(specifier: string): string {
|
||||
const segments = specifier.split('/')
|
||||
return segments.slice(0, specifier.startsWith('@') ? 2 : 1).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the bare package name of one module specifier.
|
||||
* @param specifier - module specifier text.
|
||||
* @returns the package name, or undefined for paths, builtins, and non-package specifiers.
|
||||
*/
|
||||
export function packageNameOf(specifier: string): string | undefined {
|
||||
if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
|
||||
|| specifier.includes(':') || specifier.includes('*')) {
|
||||
return undefined
|
||||
}
|
||||
const parts = specifier.split('/')
|
||||
return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package manifests used for scope discovery and workspace-name checks.
|
||||
* @param root - Repository containing the workspace manifests.
|
||||
* @returns Every workspace package, the non-experimental release packages, and the packages the policy covers.
|
||||
*/
|
||||
export function addUse(target: Map<string, string[]>, name: string, path: string): void {
|
||||
const paths = target.get(name) ?? []
|
||||
if (!paths.includes(path)) paths.push(path)
|
||||
target.set(name, paths)
|
||||
}
|
||||
|
||||
export function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
return manifest[name] ?? {}
|
||||
}
|
||||
|
||||
export function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
manifest[name] ??= {}
|
||||
return manifest[name]
|
||||
}
|
||||
|
||||
export function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
|
||||
return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
|
||||
.filter(sectionName => section(manifest, sectionName)[name] !== undefined)
|
||||
}
|
||||
|
||||
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
|
||||
const clause = node.importClause
|
||||
if (clause === undefined) return true
|
||||
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
|
||||
const bindings = clause.namedBindings
|
||||
return clause.name !== undefined
|
||||
|| bindings === undefined
|
||||
|| ts.isNamespaceImport(bindings)
|
||||
|| bindings.elements.length === 0
|
||||
|| bindings.elements.some(element => !element.isTypeOnly)
|
||||
}
|
||||
|
||||
function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
|
||||
if (node.isTypeOnly) return false
|
||||
const clause = node.exportClause
|
||||
if (clause === undefined || ts.isNamespaceExport(clause)) return true
|
||||
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
|
||||
}
|
||||
|
||||
export function collectSourceFileUses(
|
||||
sourceFile: ts.SourceFile,
|
||||
runtimeOnly: boolean,
|
||||
key: 'local' | 'package' | 'specifier',
|
||||
includeRootRelative = false,
|
||||
): Set<string> {
|
||||
const uses = new Set<string>()
|
||||
|
||||
const add = (specifier: ts.Expression | undefined): void => {
|
||||
if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
|
||||
if (key === 'local') {
|
||||
if (specifier.text.startsWith('.') || includeRootRelative && specifier.text.startsWith('/')) uses.add(specifier.text)
|
||||
return
|
||||
}
|
||||
if (!isBareSpecifier(specifier.text)) return
|
||||
uses.add(key === 'package' ? packageNameForUses(specifier.text) : specifier.text)
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
|
||||
} else if (ts.isExportDeclaration(node)) {
|
||||
if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
|
||||
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
||||
if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
|
||||
} else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
add(node.argument.literal)
|
||||
} else if (ts.isCallExpression(node)
|
||||
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
|
||||
add(node.arguments[0])
|
||||
} else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
|
||||
add(node.name)
|
||||
} else if (key !== 'local'
|
||||
&& (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
|
||||
uses.add('react')
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return uses
|
||||
}
|
||||
|
||||
/**
|
||||
* Read browser-module declarations from workspace manifests.
|
||||
* @param root - Absolute repository root.
|
||||
* @returns Declarations and malformed dsh.client fields.
|
||||
*/
|
||||
export function collectSourcePackageUses(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'package')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect bare packages whose values one production source file reaches at runtime.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Bare package names retained by runtime imports, exports, requires, or JSX.
|
||||
*/
|
||||
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'package')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect exact bare specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Exact specifiers retained by runtime imports, exports, requires, or JSX.
|
||||
*/
|
||||
export function collectRuntimeSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'specifier')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect relative module specifiers used to follow one source entry's local closure.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative imports, exports, requires, and import types.
|
||||
*/
|
||||
export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'local')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect local module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
|
||||
* @returns Local imports, exports, and requires that survive compilation.
|
||||
*/
|
||||
export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect package-asset module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
|
||||
*/
|
||||
/**
|
||||
* Normalize a repository path to forward slashes.
|
||||
* @param path - platform path inside the repository.
|
||||
* @returns the same path with POSIX separators.
|
||||
*/
|
||||
export function normalizePath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
export function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
|
||||
const uses = new Map<string, string[]>()
|
||||
for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
|
||||
const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
|
||||
const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
|
||||
let runtimeUses: Set<string> | undefined
|
||||
for (const specifier of collectSourcePackageUses(sourcePath, source)) {
|
||||
const name = packageNameOf(specifier)
|
||||
if (name === undefined) continue
|
||||
const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
|
||||
if (!ROOT_TYPE_PACKAGES.has(typesName)
|
||||
&& declaredSections(pkg.manifest, name).length === 0
|
||||
&& declaredSections(pkg.manifest, typesName).length > 0) {
|
||||
runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
|
||||
if (!runtimeUses.has(name)) {
|
||||
addUse(uses, typesName, displayPath)
|
||||
continue
|
||||
}
|
||||
}
|
||||
addUse(uses, name, displayPath)
|
||||
}
|
||||
}
|
||||
return uses
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import { spawn } from 'node:child_process'
|
||||
import { Duplex, type Readable, type Writable } from 'node:stream'
|
||||
import { join } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { JsonChannel } from '../../packages/ptc-runtime/ptc-runtime-node/src/channel.ts'
|
||||
import { createProcessRpc } from '../../packages/ptc-runtime/ptc-runtime-node/src/rpc.ts'
|
||||
import type { HostMethods, ProgramMethods } from '../../packages/ptc-runtime/ptc-runtime-node/src/protocol.ts'
|
||||
import { decodePtcJsonWire } from '../../packages/ptc-runtime/ptc-runtime-node/src/json-wire.ts'
|
||||
import { SUBPROCESS_CONTROL_ENV, SUBPROCESS_CONTROL_FD } from '../../packages/subprocess/subprocess/src/control.ts'
|
||||
|
||||
@@ -40,18 +41,17 @@ export async function probeRestrictedWorker(
|
||||
const streams: ReadonlyArray<Readable | Writable | null | undefined> = child.stdio
|
||||
const control = streams[SUBPROCESS_CONTROL_FD]
|
||||
assert(control instanceof Duplex)
|
||||
const channel = new JsonChannel(control, 1_048_576, (raw) => {
|
||||
const message = z.object({ type: z.string(), value: z.unknown().optional(), error: z.unknown().optional() }).parse(raw)
|
||||
if (message.type === 'ready') {
|
||||
const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};`
|
||||
void channel.send({ type: 'boot', data: { code, namespaces: [], maxOutputBytes: 65_536 } })
|
||||
.catch((error: unknown) => { completed.reject(error) })
|
||||
} else if (message.type === 'done') {
|
||||
if (message.error !== undefined) completed.reject(new Error(JSON.stringify(message.error)))
|
||||
else completed.resolve(decodePtcJsonWire(message.value))
|
||||
channel.close()
|
||||
} else completed.reject(new Error(`Unexpected restricted-worker message: ${message.type}`))
|
||||
}, (error) => { completed.reject(error) })
|
||||
const code = `const fs=await import('node:fs/promises'); const denied=[]; for(const path of ${JSON.stringify([join(checkout, 'package.json'), hostNode])}) { try { await fs.readFile(path); denied.push('readable') } catch(error) { denied.push(error.code) } } return {executable:process.execPath,env:Object.keys(process.env),denied};`
|
||||
// The probe program calls no bindings and prints nothing, so the host serves only readiness.
|
||||
const channel = createProcessRpc<ProgramMethods, Pick<HostMethods, 'ready'>>(control, 1_048_576, {
|
||||
ready() {
|
||||
void channel.run(workspace, { code, namespaces: [], maxOutputBytes: 65_536 }).then((result) => {
|
||||
if (result.error !== undefined) completed.reject(new Error(JSON.stringify(result.error)))
|
||||
else completed.resolve(decodePtcJsonWire(result.value))
|
||||
channel.$close()
|
||||
}, (error: unknown) => { completed.reject(error) })
|
||||
},
|
||||
}, [], (error) => { completed.reject(error) })
|
||||
const force = setTimeout(() => { child.kill('SIGKILL') }, 20_000)
|
||||
try {
|
||||
const result = z.object({ executable: z.string(), env: z.array(z.string()), denied: z.array(z.enum(['EPERM', 'EACCES'])) }).parse(await completed.promise)
|
||||
@@ -60,7 +60,7 @@ export async function probeRestrictedWorker(
|
||||
assert.equal(result.denied.length, 2)
|
||||
await exited
|
||||
} finally {
|
||||
channel.close()
|
||||
channel.$close()
|
||||
child.kill('SIGTERM')
|
||||
try { await exited } finally { clearTimeout(force) }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Unit suites cannot reach the official DeepSeek model listing unless they answer `fetch` themselves. */
|
||||
import { createServer } from 'node:http'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import type { Server } from 'node:http'
|
||||
|
||||
const servers: Server[] = []
|
||||
afterEach(async () => {
|
||||
await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['string', () => fetch('https://api.deepseek.com/models')],
|
||||
['URL', () => fetch(new URL('https://api.deepseek.com/models'))],
|
||||
['Request', () => fetch(new Request('https://api.deepseek.com/models', { headers: { 'x-api-key': 'fixture-key' } }))],
|
||||
])('rejects the official model listing given as a %s', async (_kind, request) => {
|
||||
await expect(request()).rejects.toThrow('unit suites do not reach https://api.deepseek.com/models; answer it with a fetch stub')
|
||||
})
|
||||
|
||||
it('passes every other request to the ambient fetch', async () => {
|
||||
const server = createServer((_request, response) => { response.end('local') })
|
||||
servers.push(server)
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
const address = server.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('no port')
|
||||
await expect((await fetch(`http://127.0.0.1:${address.port}/models`)).text()).resolves.toBe('local')
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Keep unit suites from requesting the official DeepSeek model listing.
|
||||
*
|
||||
* The API-key and account routes request `GET https://api.deepseek.com/models` whenever they hold a
|
||||
* credential for the official endpoint, and suites routinely boot those routes with a dummy key or
|
||||
* inherit a developer's real one. Such a request would reach production and, on a network that never
|
||||
* answers, hold the route's first catalog access for its whole listing deadline. Here it fails at once,
|
||||
* so the route serves its built-in catalog; a suite that exercises the listing replaces `fetch` itself.
|
||||
* @module
|
||||
*/
|
||||
|
||||
/** The listing endpoint of the official DeepSeek API. */
|
||||
const OFFICIAL_LISTING = 'https://api.deepseek.com/models'
|
||||
|
||||
const ambientFetch = globalThis.fetch
|
||||
globalThis.fetch = (input, init) => {
|
||||
const url = input instanceof Request ? input.url : input instanceof URL ? input.href : input
|
||||
if (url === OFFICIAL_LISTING) {
|
||||
return Promise.reject(new TypeError(`unit suites do not reach ${OFFICIAL_LISTING}; answer it with a fetch stub`))
|
||||
}
|
||||
return ambientFetch(input, init)
|
||||
}
|
||||
@@ -49,10 +49,12 @@ function evaluateCondition(expression: string, cancelled: boolean, results: stri
|
||||
}, { timeout: 1000 }) as boolean
|
||||
}
|
||||
|
||||
const verdicts = [['ci.yml', 'all-checks-passed'], ['ci-windows.yml', 'windows-checks-passed'], ['ci-python.yml', 'python-checks-passed']] as const
|
||||
|
||||
describe('master-only platform scheduling', () => {
|
||||
it.each(['success', 'failure', 'skipped', 'cancelled'])(
|
||||
'reports %s dependencies in active runs but never starts a cancelled-run verdict', (result) => {
|
||||
const aggregate = workflow('ci.yml').jobs['all-checks-passed']!
|
||||
it.each(verdicts.flatMap(([file, job]) => ['success', 'failure', 'skipped', 'cancelled'].map(result => [file, job, result] as const)))(
|
||||
'%s %s reports %s dependencies in active runs but never starts a cancelled-run verdict', (file, job, result) => {
|
||||
const aggregate = workflow(file).jobs[job]!
|
||||
const results = aggregate.needs!.map(() => 'success')
|
||||
results[0] = result
|
||||
const condition = aggregate.if as string
|
||||
@@ -72,8 +74,8 @@ describe('master-only platform scheduling', () => {
|
||||
expect(evaluateCondition(workflow('ci.yml').jobs['all-checks-passed']!.if as string, true, ['success'])).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps only Linux and Windows x64 runtimes in required PR CI', () => {
|
||||
const pr = workflow('ci.yml')
|
||||
it('keeps the required Python PR verdict and its two x64 targets in CI Python', () => {
|
||||
const pr = workflow('ci-python.yml')
|
||||
expect(Object.keys(pr.on)).toEqual(['pull_request'])
|
||||
expect(pr.jobs['python-runtime']).toMatchObject({
|
||||
if: "github.event_name == 'pull_request'",
|
||||
@@ -82,7 +84,7 @@ describe('master-only platform scheduling', () => {
|
||||
})
|
||||
expect(pr.jobs.windows).toBeUndefined()
|
||||
expect(JSON.stringify(pr.jobs)).not.toMatch(/wine-windows-gates|check:windows-wine/)
|
||||
const aggregate = pr.jobs['all-checks-passed']!
|
||||
const aggregate = pr.jobs['python-checks-passed']!
|
||||
expect(aggregate.needs).toContain('python-runtime')
|
||||
expect(aggregate.needs).not.toContain('windows')
|
||||
expect(aggregate.needs!.every(id => id in pr.jobs)).toBe(true)
|
||||
@@ -90,6 +92,9 @@ describe('master-only platform scheduling', () => {
|
||||
expect(aggregate.steps).toContainEqual(expect.objectContaining({
|
||||
if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')",
|
||||
}))
|
||||
for (const file of ['ci.yml', 'ci-windows.yml']) {
|
||||
expect(workflow(file).jobs['python-runtime']).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('runs all three deferred carriers on master pushes with fail-loud API credentials', () => {
|
||||
|
||||
@@ -139,8 +139,10 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea
|
||||
it('retains the dependency and pack verification commands', () => {
|
||||
const commands = job.steps.flatMap(step => step.run === undefined ? [] : [step.run])
|
||||
if (jobId === 'dependencies') {
|
||||
expect(commands).toContain('pnpm run verify-package-dependencies')
|
||||
expect(commands).toContain('pnpm run verify-npm-install-layout')
|
||||
// Both verifications share one step so the lane pays the longer half.
|
||||
const combined = commands.find(command => command.includes('verify-package-dependencies'))
|
||||
expect(combined).toContain('verify-npm-install-layout')
|
||||
expect(combined).toContain('wait "$layout_pid"')
|
||||
} else {
|
||||
const family = file === 'release.yml' ? 'dsh' : 'vendor'
|
||||
const output = family === 'dsh' ? 'dist/npm' : 'dist/npm-vendor'
|
||||
|
||||
@@ -146,6 +146,11 @@
|
||||
"symbol": "Inbox",
|
||||
"source": "packages/core/agent/src/runtime-types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "Model",
|
||||
"source": "packages/core/agent/src/runtime-types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "InboxTarget",
|
||||
@@ -302,6 +307,11 @@
|
||||
"symbol": "LegacyScheduleRecord",
|
||||
"source": "packages/schedule/schedule/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/schedule.md",
|
||||
"symbol": "ScheduleDeleteInactiveResult",
|
||||
"source": "packages/schedule/schedule/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/schedule.md",
|
||||
"symbol": "LocalAtInput",
|
||||
@@ -909,12 +919,12 @@
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolOutputDefinition",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolDefinition",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
@@ -944,82 +954,97 @@
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolExecutionToken",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolExecutionInput",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolExecution",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolDispatchExecution",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolExecutionMode",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "PtcDispatchLog",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tool-executor-ptc/src/runtime-types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolRunContext",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolGuard",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolErrorInfo",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolRestriction",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolActivationContext",
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolRegistrationOptions",
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ResolvedToolSet",
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolFailure",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolExecutionSuccess",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolExecutionFailure",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolExecutionResult",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "PreToolDecision",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "PostToolDecision",
|
||||
"source": "packages/core/tools/src/index.ts"
|
||||
"source": "packages/core/tools/src/contracts.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
@@ -1926,6 +1951,16 @@
|
||||
"symbol": "WebRoute",
|
||||
"source": "packages/host/webserver/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/web-server.md",
|
||||
"symbol": "WebResource",
|
||||
"source": "packages/host/webserver/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/web-server.md",
|
||||
"symbol": "WebResourceRoute",
|
||||
"source": "packages/host/webserver/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/web-server.md",
|
||||
"symbol": "Config",
|
||||
@@ -2482,6 +2517,46 @@
|
||||
"symbol": "PendingModule",
|
||||
"source": "packages/boot/hmr/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolModelInput",
|
||||
"source": "packages/core/tools/src/executor.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolBatchInput",
|
||||
"source": "packages/core/tools/src/executor.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolCallResult",
|
||||
"source": "packages/core/tools/src/executor.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolBatchResult",
|
||||
"source": "packages/core/tools/src/executor.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/tools.md",
|
||||
"symbol": "ToolExecutor",
|
||||
"source": "packages/core/tools/src/executor.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "ModelSelection",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "ModelSelectionProjectionState",
|
||||
"source": "packages/core/agent/src/model-selection.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "ModelSelectionProjection",
|
||||
"source": "packages/core/agent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "TurnBoundaryProjection",
|
||||
@@ -2521,21 +2596,6 @@
|
||||
"doc": "docs/subsystems/session-projection.md",
|
||||
"symbol": "ProjectionKeySelection",
|
||||
"source": "packages/session/session-projection/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "DeliveryMode",
|
||||
"source": "packages/core/agent-controller/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "DeliveryRequest",
|
||||
"source": "packages/core/agent-controller/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "AdmissionDecision",
|
||||
"source": "packages/core/agent-controller/src/types.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
} from '../packages/client/modules/src/assets.ts'
|
||||
import { TypeScriptProject } from './ts-project.ts'
|
||||
|
||||
export {
|
||||
collectLocalSourceSpecifiers,
|
||||
collectRuntimeLocalSourceSpecifiers,
|
||||
collectRuntimeSourcePackageUses,
|
||||
collectRuntimeSourceSpecifiers,
|
||||
collectSourcePackageUses,
|
||||
} from './source-parse-uses.ts'
|
||||
import { collectSourceFileUses } from './source-parse-uses.ts'
|
||||
const GATE = 'verify-client-packages'
|
||||
const CLIENT_MANIFEST_GLOB = 'packages/client/*/package.json'
|
||||
const MANIFEST_GLOBS = ['packages/*/*/package.json', 'apps/*/package.json', 'vendor/*/package.json']
|
||||
@@ -89,62 +97,6 @@ export interface ClientDeclarations {
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Bare package names referenced by imports, declarations, or JSX.
|
||||
*/
|
||||
export function collectSourcePackageUses(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'package')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect bare packages whose values one production source file reaches at runtime.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Bare package names retained by runtime imports, exports, requires, or JSX.
|
||||
*/
|
||||
export function collectRuntimeSourcePackageUses(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'package')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect exact bare specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Exact specifiers retained by runtime imports, exports, requires, or JSX.
|
||||
*/
|
||||
export function collectRuntimeSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'specifier')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect relative module specifiers used to follow one source entry's local closure.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative imports, exports, requires, and import types.
|
||||
*/
|
||||
export function collectLocalSourceSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, false, 'local')
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect local module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @param includeRootRelative - Include absolute paths, such as Vite's Web-root imports.
|
||||
* @returns Local imports, exports, and requires that survive compilation.
|
||||
*/
|
||||
export function collectRuntimeLocalSourceSpecifiers(path: string, source: string, includeRootRelative = false): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return collectSourceFileUses(sourceFile, true, 'local', includeRootRelative)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect package-asset module specifiers retained by one production source file.
|
||||
* @param path - File path used to select TypeScript's parser mode.
|
||||
* @param source - Source text to inspect.
|
||||
* @returns Relative runtime imports, re-exports, and requires whose extension has a package-asset media type.
|
||||
*/
|
||||
export function collectSourceAssetSpecifiers(path: string, source: string): Set<string> {
|
||||
const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true)
|
||||
return new Set([...collectSourceFileUses(sourceFile, true, 'local')].filter(isAssetSpecifier))
|
||||
@@ -227,73 +179,6 @@ export function readStylesheetAssetReferences(
|
||||
}
|
||||
}
|
||||
|
||||
function importCarriesRuntimeValue(node: ts.ImportDeclaration): boolean {
|
||||
const clause = node.importClause
|
||||
if (clause === undefined) return true
|
||||
if (clause.phaseModifier === ts.SyntaxKind.TypeKeyword) return false
|
||||
const bindings = clause.namedBindings
|
||||
return clause.name !== undefined
|
||||
|| bindings === undefined
|
||||
|| ts.isNamespaceImport(bindings)
|
||||
|| bindings.elements.length === 0
|
||||
|| bindings.elements.some(element => !element.isTypeOnly)
|
||||
}
|
||||
|
||||
function exportCarriesRuntimeValue(node: ts.ExportDeclaration): boolean {
|
||||
if (node.isTypeOnly) return false
|
||||
const clause = node.exportClause
|
||||
if (clause === undefined || ts.isNamespaceExport(clause)) return true
|
||||
return clause.elements.length === 0 || clause.elements.some(element => !element.isTypeOnly)
|
||||
}
|
||||
|
||||
function collectSourceFileUses(
|
||||
sourceFile: ts.SourceFile,
|
||||
runtimeOnly: boolean,
|
||||
key: 'local' | 'package' | 'specifier',
|
||||
includeRootRelative = false,
|
||||
): Set<string> {
|
||||
const uses = new Set<string>()
|
||||
|
||||
const add = (specifier: ts.Expression | undefined): void => {
|
||||
if (specifier === undefined || !ts.isStringLiteralLike(specifier)) return
|
||||
if (key === 'local') {
|
||||
if (specifier.text.startsWith('.') || includeRootRelative && specifier.text.startsWith('/')) uses.add(specifier.text)
|
||||
return
|
||||
}
|
||||
if (!isBareSpecifier(specifier.text)) return
|
||||
uses.add(key === 'package' ? packageNameOf(specifier.text) : specifier.text)
|
||||
}
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isImportDeclaration(node)) {
|
||||
if (!runtimeOnly || importCarriesRuntimeValue(node)) add(node.moduleSpecifier)
|
||||
} else if (ts.isExportDeclaration(node)) {
|
||||
if (!runtimeOnly || exportCarriesRuntimeValue(node)) add(node.moduleSpecifier)
|
||||
} else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) {
|
||||
if (!runtimeOnly || !node.isTypeOnly) add(node.moduleReference.expression)
|
||||
} else if (!runtimeOnly && ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
||||
add(node.argument.literal)
|
||||
} else if (ts.isCallExpression(node)
|
||||
&& (node.expression.kind === ts.SyntaxKind.ImportKeyword
|
||||
|| ts.isIdentifier(node.expression) && node.expression.text === 'require')) {
|
||||
add(node.arguments[0])
|
||||
} else if (!runtimeOnly && key !== 'local' && ts.isModuleDeclaration(node) && ts.isStringLiteral(node.name)) {
|
||||
add(node.name)
|
||||
} else if (key !== 'local'
|
||||
&& (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node))) {
|
||||
uses.add('react')
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
|
||||
visit(sourceFile)
|
||||
return uses
|
||||
}
|
||||
|
||||
/**
|
||||
* Read browser-module declarations from workspace manifests.
|
||||
* @param root - Absolute repository root.
|
||||
* @returns Declarations and malformed dsh.client fields.
|
||||
*/
|
||||
export function readClientDeclarations(root: string): ClientDeclarations {
|
||||
const malformed: string[] = []
|
||||
const declarations = globSync(MANIFEST_GLOBS, { cwd: root })
|
||||
@@ -853,10 +738,6 @@ function rowPackageOf(specifier: string, rows: ReadonlySet<string>): string | un
|
||||
return rows.has(stripped) ? stripped : undefined
|
||||
}
|
||||
|
||||
function isBareSpecifier(specifier: string): boolean {
|
||||
return !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('#')
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@ export function findConcreteTermViolations(file: string, source: string): Concre
|
||||
}
|
||||
|
||||
function trackedFiles(repoRoot: string): string[] {
|
||||
const files = execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8' })
|
||||
const files = execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
||||
.split('\0')
|
||||
.filter(file => file !== '')
|
||||
if (!files.includes('AGENTS.md')
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
bundleManifestPaths,
|
||||
bundlePluginDependencyErrors,
|
||||
exclusivePlaneRowIds,
|
||||
metadataExpressionErrors,
|
||||
packageTestFixtureDependencyErrors,
|
||||
packageTestPluginDependencyErrors,
|
||||
@@ -48,6 +49,17 @@ describe('verify-cordis-config metadata expressions', () => {
|
||||
})
|
||||
|
||||
describe('workspace Bundle discovery and product dependency closures', () => {
|
||||
it('allows scoped executors on both planes while retaining other plugin rows', () => {
|
||||
expect([...exclusivePlaneRowIds([{ insert: [
|
||||
{ id: 'native', name: '@deepseek-ai/dsh-tool-executor-native' },
|
||||
{ id: 'ptc', name: '@deepseek-ai/dsh-tool-executor-ptc' },
|
||||
{ id: 'tools', name: '@deepseek-ai/dsh-tools' },
|
||||
{ id: 'group', name: 'cordis:group', group: true, config: [
|
||||
{ id: 'tool-executor-ptc', name: '@deepseek-ai/dsh-tool-fs' },
|
||||
] },
|
||||
] }])]).toEqual(['tools', 'group', 'tool-executor-ptc'])
|
||||
})
|
||||
|
||||
it('discovers a Bundle outside packages/bundle from its manifest declaration', () => {
|
||||
const fixture = mkdtempSync(join(tmpdir(), 'dsh-bundle-discovery-'))
|
||||
try {
|
||||
|
||||
@@ -119,7 +119,8 @@ function validateClientHalvesDeclared(): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* No shipped agent preset may repeat a row the host composition still runs.
|
||||
* No shipped agent preset may repeat an unscoped row the host composition still runs.
|
||||
* Tool executors select through the mounting scope and may appear on both planes.
|
||||
*
|
||||
* A preset contributes what ONE session adds to the host's registries. A row
|
||||
* active on both planes is therefore mounted twice — once per process and once
|
||||
@@ -157,7 +158,7 @@ function validatePresetPlaneSeparation(): string[] {
|
||||
const active = new Set([...rowIds(hostFile), ...overlayRows].filter(id => !disabled.has(id)))
|
||||
for (const file of overlayFiles) {
|
||||
for (const definition of presetDefinitions(loadEntries(file))) {
|
||||
for (const id of collectRowIds(definition.plugins)) {
|
||||
for (const id of exclusivePlaneRowIds(definition.plugins)) {
|
||||
if (active.has(id)) problems.push(`${file}#${definition.id}: row "${id}" is also active in the host composition; a row belongs to exactly one plane`)
|
||||
}
|
||||
}
|
||||
@@ -178,10 +179,15 @@ function loadEntries(file: string): unknown[] {
|
||||
* @returns the declared ids.
|
||||
*/
|
||||
function rowIds(file: string): Set<string> {
|
||||
return collectRowIds(loadEntries(file))
|
||||
return exclusivePlaneRowIds(loadEntries(file))
|
||||
}
|
||||
|
||||
function collectRowIds(rows: unknown): Set<string> {
|
||||
/**
|
||||
* Collect rows that cannot be shared between the host and a preset.
|
||||
* @param rows - parsed Loader entries, including inserts and groups.
|
||||
* @returns ids excluding the executor plugins whose registrations are scope-owned.
|
||||
*/
|
||||
export function exclusivePlaneRowIds(rows: unknown): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
const walk = (value: unknown): void => {
|
||||
if (isUnknownArray(value)) {
|
||||
@@ -189,7 +195,9 @@ function collectRowIds(rows: unknown): Set<string> {
|
||||
return
|
||||
}
|
||||
if (!isRecord(value)) return
|
||||
if (typeof value.id === 'string' && typeof value.name === 'string') ids.add(value.id)
|
||||
if (typeof value.id === 'string' && typeof value.name === 'string'
|
||||
&& value.name !== '@deepseek-ai/dsh-tool-executor-native'
|
||||
&& value.name !== '@deepseek-ai/dsh-tool-executor-ptc') ids.add(value.id)
|
||||
walk(value.insert)
|
||||
if (isCordisGroupEntry(value)) walk(value.config)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ const roots: string[] = []
|
||||
const experimental = '@deepseek-ai/dsh-experimental-prototype'
|
||||
const core = '@deepseek-ai/dsh-core'
|
||||
const base = '@deepseek-ai/dsh-base'
|
||||
const profile = 'packages/boot/app-boot/src/profile.ts'
|
||||
const profile = 'packages/util/official-bundles/src/bundles.ts'
|
||||
const preset = 'packages/bundle/web-app/presets/standard.patch.yml'
|
||||
const patch = 'packages/bundle/base/cordis.patch.yml'
|
||||
|
||||
@@ -488,6 +488,6 @@ describe('default product isolation', () => {
|
||||
])('rejects absent, empty, or uninspectable default profile definitions', (source) => {
|
||||
const root = fixture()
|
||||
write(root, profile, source)
|
||||
expect(() => verifyDefaultProductIsolation(root)).toThrow('profile')
|
||||
expect(() => verifyDefaultProductIsolation(root)).toThrow(`${profile}: `)
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { isExperimentalPackageName } from './experimental-package-policy.ts'
|
||||
// The independently published entry package owns platform-engine dependencies.
|
||||
const EXTERNAL_KIT_PACKAGES = new Set(['@deepseek-ai/libreoffice-kit'])
|
||||
const PROFILE_SOURCE = 'packages/boot/app-boot/src/profile.ts'
|
||||
const PROFILE_SOURCE = 'packages/util/official-bundles/src/bundles.ts'
|
||||
const PRESET_PATTERN = 'packages/bundle/web-app/presets/*.patch.yml'
|
||||
const RUNTIME_SECTIONS = ['dependencies', 'optionalDependencies', 'peerDependencies'] as const
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/** Metadata coverage rejects missing translations through the same checker used by CI. */
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { localeMetadataProblems } from './verify-locale-metadata.ts'
|
||||
|
||||
import { SHIPPED_LOCALE_IDS } from './shipped-locales.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
function fixture(ja: unknown = { meta: { title: '名前 {name}', description: '説明' } }): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-locale-meta-'))
|
||||
roots.push(root)
|
||||
const directory = join(root, 'packages/demo/plugin/locale')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(join(directory, 'en.json'), JSON.stringify({ meta: { title: 'Name {name}' } }))
|
||||
writeFileSync(join(directory, 'zh.json'), JSON.stringify({ meta: { title: '名称 {name}', description: '描述' } }))
|
||||
writeFileSync(join(directory, 'ja.json'), JSON.stringify(ja))
|
||||
for (const language of ['es', 'fr', 'de', 'ru', 'pt']) {
|
||||
writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: 'Localized description.' } }))
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
it('accepts Japanese fields from the union of English and Chinese metadata', () => {
|
||||
expect(localeMetadataProblems(fixture())).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects missing Japanese files, including newly added packages', () => {
|
||||
const root = fixture()
|
||||
rmSync(join(root, 'packages/demo/plugin/locale/ja.json'))
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain('ja.json')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ meta: { title: '名前 {name}' } }, 'missing meta.description'],
|
||||
[{ meta: { title: '名前 {other}', description: '説明' } }, 'changes placeholders'],
|
||||
[{ meta: { title: '', description: '説明' } }, 'expected non-empty text'],
|
||||
[{ meta: { title: '名前 {name}', description: '説明', extra: '余分' } }, 'unexpected meta.extra'],
|
||||
])('rejects incomplete or inconsistent Japanese metadata', (ja, diagnostic) => {
|
||||
expect(localeMetadataProblems(fixture(ja)).join('\n')).toContain(diagnostic)
|
||||
})
|
||||
|
||||
it('checks referenced detail documents', () => {
|
||||
const root = fixture()
|
||||
const directory = join(root, 'packages/demo/plugin/locale')
|
||||
const meta = { title: 'Name {name}', detail: 'docs/DETAIL.md' }
|
||||
for (const language of SHIPPED_LOCALE_IDS) writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta }))
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain('missing detail resource')
|
||||
mkdirSync(join(directory, '../docs'))
|
||||
writeFileSync(join(directory, '../docs/DETAIL.md'), '# Detail\n')
|
||||
expect(localeMetadataProblems(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not mistake package.json in the locale service package for a translation', () => {
|
||||
const root = fixture()
|
||||
const directory = join(root, 'packages/client/locale')
|
||||
mkdirSync(directory, { recursive: true })
|
||||
writeFileSync(join(directory, 'package.json'), '{"name":"locale"}')
|
||||
mkdirSync(join(directory, 'src'))
|
||||
writeFileSync(join(directory, 'src/index.ts'), 'export {}')
|
||||
expect(localeMetadataProblems(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects an empty discovery', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-locale-empty-'))
|
||||
roots.push(root)
|
||||
expect(localeMetadataProblems(root)).toEqual(['no package locale metadata found'])
|
||||
})
|
||||
|
||||
it('rejects loadable plugins without any locale directory', () => {
|
||||
const root = fixture()
|
||||
const directory = join(root, 'packages/demo/new-plugin')
|
||||
mkdirSync(join(directory, 'src'), { recursive: true })
|
||||
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-new-plugin' }))
|
||||
writeFileSync(join(directory, 'src/index.ts'), 'export function apply() {}')
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain('new-plugin/locale')
|
||||
})
|
||||
|
||||
it('checks nested subpath locale resources and package-relative detail files', () => {
|
||||
const root = fixture()
|
||||
const directory = join(root, 'packages/demo/plugin/locale/tools')
|
||||
mkdirSync(directory)
|
||||
for (const language of ['en', 'zh']) writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta: { title: 'Tools', detail: 'docs/TOOLS.md' } }))
|
||||
mkdirSync(join(root, 'packages/demo/plugin/docs'))
|
||||
writeFileSync(join(root, 'packages/demo/plugin/docs/TOOLS.md'), '# Tools\n')
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain('locale/tools')
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain('ja.json')
|
||||
writeFileSync(join(directory, 'ja.json'), JSON.stringify({ meta: { title: 'ツール', detail: 'docs/TOOLS.md' } }))
|
||||
for (const language of ['es', 'fr', 'de', 'ru', 'pt']) writeFileSync(join(directory, `${language}.json`), JSON.stringify({ meta: { title: 'Tools', detail: 'docs/TOOLS.md' } }))
|
||||
expect(localeMetadataProblems(root)).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['../../other/README.md', 'docs\\DETAIL.md', 'docs/DETAIL.txt', '/README.md'])('rejects runtime-invalid detail path %s', (detail) => {
|
||||
const root = fixture({ meta: { title: '名前 {name}', description: '説明', detail } })
|
||||
expect(localeMetadataProblems(root).join('\n')).toMatch(/must (?:be a relative path|name a Markdown file)/u)
|
||||
})
|
||||
|
||||
it('requires sentence punctuation for English descriptions', () => {
|
||||
const root = fixture()
|
||||
const file = join(root, 'packages/demo/plugin/locale/en.json')
|
||||
writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'Description' } }))
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain('sentence punctuation')
|
||||
writeFileSync(file, JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } }))
|
||||
expect(localeMetadataProblems(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('permits missing Chinese resources for loadable plugins', () => {
|
||||
const root = fixture({ meta: { title: '名前 {name}' } })
|
||||
const directory = join(root, 'packages/demo/plugin')
|
||||
mkdirSync(join(directory, 'src'))
|
||||
writeFileSync(join(directory, 'src/index.ts'), 'export function apply() {}')
|
||||
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-demo' }))
|
||||
rmSync(join(directory, 'locale/zh.json'))
|
||||
for (const language of ['es', 'fr', 'de', 'ru', 'pt']) writeFileSync(join(directory, `locale/${language}.json`), JSON.stringify({ meta: { title: 'Name {name}' } }))
|
||||
expect(localeMetadataProblems(root)).toEqual([])
|
||||
})
|
||||
|
||||
it('excludes packages formally classified as test helpers', () => {
|
||||
const root = fixture()
|
||||
const directory = join(root, 'packages/test-support/llm-replay')
|
||||
mkdirSync(join(directory, 'src'), { recursive: true })
|
||||
writeFileSync(join(directory, 'src/index.ts'), 'export function apply() {}')
|
||||
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-llm-replay' }))
|
||||
expect(localeMetadataProblems(root)).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects missing %s metadata', (language) => {
|
||||
const root = fixture()
|
||||
rmSync(join(root, `packages/demo/plugin/locale/${language}.json`))
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain(`${language}.json`)
|
||||
})
|
||||
|
||||
it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects untranslated Chinese descriptions in %s metadata', (language) => {
|
||||
const root = fixture()
|
||||
writeFileSync(join(root, 'packages/demo/plugin/locale/en.json'), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } }))
|
||||
writeFileSync(join(root, `packages/demo/plugin/locale/${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: '描述' } }))
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain(`${language}.json: meta.description contains untranslated source text`)
|
||||
})
|
||||
|
||||
it.each(['es', 'fr', 'de', 'ru', 'pt'])('rejects copied English descriptions in %s metadata', (language) => {
|
||||
const root = fixture()
|
||||
writeFileSync(join(root, 'packages/demo/plugin/locale/en.json'), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } }))
|
||||
writeFileSync(join(root, `packages/demo/plugin/locale/${language}.json`), JSON.stringify({ meta: { title: 'Name {name}', description: 'English description.' } }))
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain(`${language}.json: meta.description contains untranslated source text`)
|
||||
})
|
||||
|
||||
it.each(['\u200b', '\ufeff', String.raw`\u200b`, String.raw`\ufeff`])('rejects invisible characters in JSON resources', (character) => {
|
||||
const root = fixture()
|
||||
const file = join(root, 'packages/demo/plugin/locale/es.json')
|
||||
writeFileSync(file, readFileSync(file, 'utf8').replace('Localized description.', `Localized${character} description.`))
|
||||
expect(localeMetadataProblems(root).join('\n')).toContain('invisible locale character')
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
/** Check shipped-language coverage of package metadata and plugin templates without loading plugins. */
|
||||
import { existsSync, globSync, readFileSync } from 'node:fs'
|
||||
import { basename, posix, join, resolve } from 'node:path'
|
||||
import { detailPath } from '../packages/util/plugin-detail/src/document.ts'
|
||||
import { PRODUCT_PACKAGE_POLICY } from './product-package-policy.ts'
|
||||
import { SHIPPED_LOCALE_IDS, localeTokens, hasInvisibleLocaleCharacters } from './shipped-locales.ts'
|
||||
import { collectConfigCatalog } from './gen-config-catalog.ts'
|
||||
|
||||
/** Flatten metadata strings and reject values that cannot be translated. */
|
||||
function strings(value: unknown, key = ''): Record<string, string> {
|
||||
if (typeof value === 'string' && value.trim() !== '') return { [key]: value }
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${key}: expected non-empty text`)
|
||||
return Object.fromEntries(Object.entries(value).flatMap(([name, child]) => Object.entries(strings(child, key ? `${key}.${name}` : name))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare shipped-language metadata with every field supplied by English or Chinese.
|
||||
* @param root - repository or fixture root.
|
||||
* @returns actionable missing-file, field, placeholder and resource diagnostics.
|
||||
*/
|
||||
export function localeMetadataProblems(root: string): string[] {
|
||||
const directories = new Set(globSync('packages/**/locale/**/*.json', {
|
||||
cwd: root, exclude: ['packages/**/node_modules/**', 'packages/**/lib/**', 'packages/**/tests/**'],
|
||||
}).map(file => file.replaceAll('\\', '/')).filter(file => SHIPPED_LOCALE_IDS.some(language => basename(file) === `${language}.json`)).map(file => posix.dirname(file)))
|
||||
for (const entry of collectConfigCatalog(root)) {
|
||||
const dir = entry.dir.replaceAll('\\', '/')
|
||||
if (PRODUCT_PACKAGE_POLICY[dir]?.category === 'test') continue
|
||||
if (entry.kind !== 'config' && entry.kind !== 'no-config') continue
|
||||
const directory = `${dir}/locale`
|
||||
directories.add(directory)
|
||||
}
|
||||
const problems: string[] = []
|
||||
if (directories.size === 0) return ['no package locale metadata found']
|
||||
for (const directory of [...directories].sort()) {
|
||||
try {
|
||||
const read = (language: string): Record<string, string> => {
|
||||
const file = join(root, directory, `${language}.json`)
|
||||
const source = readFileSync(file, 'utf8')
|
||||
if (hasInvisibleLocaleCharacters(source) || /\\u(?:200b|feff)/iu.test(source)) throw new Error(`${language}.json: invisible locale character`)
|
||||
const value: unknown = JSON.parse(source)
|
||||
const dictionary = strings(value)
|
||||
const detail = detailPath(dictionary['meta.detail'], `${directory}/${language}.json: meta.detail`)
|
||||
const packageDir = directory.slice(0, directory.lastIndexOf('/locale'))
|
||||
if (detail !== undefined && !existsSync(resolve(root, packageDir, detail))) {
|
||||
throw new Error(`missing detail resource ${detail}`)
|
||||
}
|
||||
return dictionary
|
||||
}
|
||||
const en = read('en')
|
||||
const zh = existsSync(join(root, directory, 'zh.json')) ? read('zh') : {}
|
||||
const description = en['meta.description']
|
||||
if (description !== undefined && !/[.!?]$/u.test(description)) problems.push(`${directory}/en.json: meta.description must end with sentence punctuation`)
|
||||
const expected = { ...zh, ...en }
|
||||
for (const language of SHIPPED_LOCALE_IDS.filter(id => id !== 'en' && id !== 'zh')) {
|
||||
const translated = read(language)
|
||||
for (const key of new Set([...Object.keys(expected), ...Object.keys(translated)])) {
|
||||
if (translated[key] === undefined) problems.push(`${directory}/${language}.json: missing ${key}`)
|
||||
else if (expected[key] === undefined) problems.push(`${directory}/${language}.json: unexpected ${key}`)
|
||||
else {
|
||||
if (language !== 'ja' && key === 'meta.description' && ((zh[key] !== undefined && /\p{Script=Han}/u.test(zh[key]) && translated[key] === zh[key])
|
||||
|| (en[key] !== undefined && translated[key] === en[key]))) {
|
||||
problems.push(`${directory}/${language}.json: ${key} contains untranslated source text`)
|
||||
}
|
||||
const placeholders = (text: string): string => localeTokens(text).join(',')
|
||||
if (placeholders(translated[key]) !== placeholders(expected[key])) problems.push(`${directory}/${language}.json: ${key} changes placeholders or literal tokens`)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
problems.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
if (import.meta.filename === resolve(process.argv[1] ?? '')) {
|
||||
const problems = localeMetadataProblems(resolve(import.meta.dirname, '..'))
|
||||
for (const problem of problems) console.error(problem)
|
||||
if (problems.length > 0) process.exitCode = 1
|
||||
else console.log('verify-locale-metadata: all package and template metadata has complete shipped-language translations.')
|
||||
}
|
||||
@@ -5,9 +5,10 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { isBuiltin } from 'node:module'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { dirname, extname, join, normalize, relative, resolve, sep } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import ts from 'typescript'
|
||||
import { WorkspaceTypertGenerator } from '../packages/typert/generator/src/workspace.ts'
|
||||
import { PACKAGE_ASSET_EXPORT } from '../packages/client/modules/src/assets.ts'
|
||||
@@ -17,11 +18,22 @@ import {
|
||||
PACKAGE_DEPENDENCY_POLICY,
|
||||
type PackageDependencyPolicy,
|
||||
} from './package-dependency-policy.ts'
|
||||
import type {
|
||||
SourceUsesWorkerRequest,
|
||||
SourceUsesWorkerResult,
|
||||
} from './package-source-uses.worker.ts'
|
||||
import {
|
||||
ROOT_TYPE_PACKAGES,
|
||||
addUse,
|
||||
normalizePath,
|
||||
collectRuntimeLocalSourceSpecifiers,
|
||||
collectRuntimeSourcePackageUses,
|
||||
collectSourcePackageUses,
|
||||
} from './verify-client-packages.ts'
|
||||
declaredSections,
|
||||
mutableSection,
|
||||
packageNameOf,
|
||||
readAllSourceUses,
|
||||
section,
|
||||
type DependencySection,
|
||||
} from './source-parse-uses.ts'
|
||||
|
||||
const GATE = 'verify-package-dependencies'
|
||||
const CORDIS = '@deepseek-ai/cordis'
|
||||
@@ -29,7 +41,6 @@ const CORDIS = '@deepseek-ai/cordis'
|
||||
* Type packages declared only in the root devDependencies. Each package-local pnpm link is one more
|
||||
* location that TypeScript scans when it computes module specifiers for every JSX file.
|
||||
*/
|
||||
const ROOT_TYPE_PACKAGES: ReadonlySet<string> = new Set(['@types/react', '@types/react-dom'])
|
||||
function workspaceRange(name: string): 'workspace:*' | 'workspace:~' {
|
||||
return name === '@deepseek-ai/dsh' || name.startsWith('@deepseek-ai/dsh-') ? 'workspace:*' : 'workspace:~'
|
||||
}
|
||||
@@ -42,9 +53,15 @@ const WORKSPACE_MANIFEST_GLOBS = [
|
||||
'vendor/*/package.json',
|
||||
]
|
||||
|
||||
type DependencySection = 'dependencies' | 'devDependencies' | 'optionalDependencies' | 'peerDependencies'
|
||||
|
||||
export type PackageDependencyRole = 'client-only' | 'client-host' | 'configured-host'
|
||||
|
||||
/** One workspace package the dependency policy covers, with its resolved role. */
|
||||
export type SelectedWorkspacePackage = WorkspacePackageManifest & { readonly role: PackageDependencyRole }
|
||||
|
||||
/** Upper bound on source-walk workers: each one pays its own TypeScript module load. */
|
||||
export const MAX_DEPENDENCY_WORKERS = 16
|
||||
|
||||
/** Manifest fields read and repaired by the package dependency policy. */
|
||||
export interface PackageDependencyManifest {
|
||||
name?: string
|
||||
@@ -104,24 +121,6 @@ export interface ExpectedPackageDependency {
|
||||
readonly origins: readonly string[]
|
||||
}
|
||||
|
||||
function normalizePath(path: string): string {
|
||||
return path.split(sep).join('/')
|
||||
}
|
||||
|
||||
function packageNameOf(specifier: string): string | undefined {
|
||||
if (isBuiltin(specifier) || specifier.startsWith('.') || specifier.startsWith('/') || specifier.startsWith('#')
|
||||
|| specifier.includes(':') || specifier.includes('*')) {
|
||||
return undefined
|
||||
}
|
||||
const parts = specifier.split('/')
|
||||
return specifier.startsWith('@') ? parts.length >= 2 ? `${parts[0]}/${parts[1]}` : undefined : parts[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package manifests used for scope discovery and workspace-name checks.
|
||||
* @param root - Repository containing the workspace manifests.
|
||||
* @returns Every workspace package, the non-experimental release packages, and the packages the policy covers.
|
||||
*/
|
||||
export function readWorkspacePackageManifests(root: string): {
|
||||
all: WorkspacePackageManifest[]
|
||||
release: WorkspacePackageManifest[]
|
||||
@@ -211,12 +210,6 @@ export function discoverPackageDependencyScope(
|
||||
}
|
||||
}
|
||||
|
||||
function addUse(target: Map<string, string[]>, name: string, path: string): void {
|
||||
const paths = target.get(name) ?? []
|
||||
if (!paths.includes(path)) paths.push(path)
|
||||
target.set(name, paths)
|
||||
}
|
||||
|
||||
const NAMESPACE_RUNTIME_EXPORT = '*'
|
||||
const SIDE_EFFECT_RUNTIME_EXPORT = '(side effect)'
|
||||
|
||||
@@ -519,31 +512,6 @@ function readHostRuntimeUses(
|
||||
}
|
||||
}
|
||||
|
||||
function readAllSourceUses(root: string, pkg: WorkspacePackageManifest): Map<string, string[]> {
|
||||
const uses = new Map<string, string[]>()
|
||||
for (const sourcePath of globSync('src/**/*.{ts,tsx,mts,cts}', { cwd: resolve(root, pkg.dir) }).sort()) {
|
||||
const source = readFileSync(resolve(root, pkg.dir, sourcePath), 'utf8')
|
||||
const displayPath = `${pkg.dir}/${normalizePath(sourcePath)}`
|
||||
let runtimeUses: Set<string> | undefined
|
||||
for (const specifier of collectSourcePackageUses(sourcePath, source)) {
|
||||
const name = packageNameOf(specifier)
|
||||
if (name === undefined) continue
|
||||
const typesName = `@types/${name.replace(/^@/, '').replace('/', '__')}`
|
||||
if (!ROOT_TYPE_PACKAGES.has(typesName)
|
||||
&& declaredSections(pkg.manifest, name).length === 0
|
||||
&& declaredSections(pkg.manifest, typesName).length > 0) {
|
||||
runtimeUses ??= collectRuntimeSourcePackageUses(sourcePath, source)
|
||||
if (!runtimeUses.has(name)) {
|
||||
addUse(uses, typesName, displayPath)
|
||||
continue
|
||||
}
|
||||
}
|
||||
addUse(uses, name, displayPath)
|
||||
}
|
||||
}
|
||||
return uses
|
||||
}
|
||||
|
||||
/**
|
||||
* Read authored and generated source usage for one already-classified package.
|
||||
* @param root - Repository containing source files and face tsconfigs.
|
||||
@@ -562,13 +530,14 @@ export async function readPackageDependencyFacts(
|
||||
workspaceNames: ReadonlySet<string>,
|
||||
policy: PackageDependencyPolicy = PACKAGE_DEPENDENCY_POLICY,
|
||||
generatedHostSource?: string,
|
||||
precomputedSourceUses?: Map<string, string[]>,
|
||||
): Promise<PackageDependencyFacts> {
|
||||
const inject = pkg.manifest.dsh?.client?.inject ?? []
|
||||
const hostRuntime = role === 'client-only'
|
||||
? { packageUses: new Map<string, string[]>(), exportUses: [] }
|
||||
: readHostRuntimeUses(root, pkg, await readHostBuildEntries(root, pkg), generatedHostSource)
|
||||
const servicePeers = policy.requiredServicePeers?.[pkg.name] ?? []
|
||||
const allSourceUses = readAllSourceUses(root, pkg)
|
||||
const allSourceUses = precomputedSourceUses ?? readAllSourceUses(root, pkg)
|
||||
for (const name of servicePeers) {
|
||||
if (!workspaceNames.has(name) || !allSourceUses.has(name)) {
|
||||
throw new Error(`${pkg.manifestPath}: requiredServicePeers must name a referenced workspace package: ${name}`)
|
||||
@@ -676,10 +645,7 @@ export async function readPackageDependencyState(
|
||||
const workspaceNames = new Set(packages.all.map(pkg => pkg.name))
|
||||
const discovered = discoverPackageDependencyScope(packages.policy, policy)
|
||||
const generated = generatedHostSources(root, discovered.selected.filter(pkg => pkg.role !== 'client-only'))
|
||||
const facts: PackageDependencyFacts[] = []
|
||||
for (const pkg of discovered.selected) {
|
||||
facts.push(await readPackageDependencyFacts(root, pkg, pkg.role, workspaceNames, policy, generated.get(pkg.name)))
|
||||
}
|
||||
const facts = await readPackageDependencyFactsConcurrently(root, discovered.selected, workspaceNames, policy, generated)
|
||||
const selectedNames = new Set(facts.map(fact => fact.manifest.name))
|
||||
return {
|
||||
facts,
|
||||
@@ -696,6 +662,117 @@ export async function readPackageDependencyState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read dependency facts for every selected package, walking package sources in parallel.
|
||||
*
|
||||
* Walking a package's `src` tree with the TypeScript parser is the expensive part of
|
||||
* this gate and shares no state across packages, so those walks run in worker threads
|
||||
* while the caller's package order is preserved for the policy checks.
|
||||
* @param root - Repository containing the workspace sources and build configs.
|
||||
* @param packages - Selected packages, in policy order.
|
||||
* @param workspaceNames - Identities of every workspace package.
|
||||
* @param policy - Reviewed scope overrides and Host export classifications.
|
||||
* @param generated - Host modules the batched Typert pass emitted in memory, keyed by package name.
|
||||
* @param workers - Worker count for the source walk; defaults to the host CPU count, capped at {@link MAX_DEPENDENCY_WORKERS}.
|
||||
* @returns Facts in the same order as `packages`.
|
||||
*/
|
||||
export async function readPackageDependencyFactsConcurrently(
|
||||
root: string,
|
||||
packages: readonly SelectedWorkspacePackage[],
|
||||
workspaceNames: ReadonlySet<string>,
|
||||
policy: PackageDependencyPolicy,
|
||||
generated: ReadonlyMap<string, string>,
|
||||
workers = Math.min(availableParallelism(), packages.length, MAX_DEPENDENCY_WORKERS),
|
||||
): Promise<PackageDependencyFacts[]> {
|
||||
const sourceUses = await readSourceUses(root, packages, workers)
|
||||
const facts: PackageDependencyFacts[] = []
|
||||
for (const pkg of packages) {
|
||||
facts.push(await readPackageDependencyFacts(
|
||||
root,
|
||||
pkg,
|
||||
pkg.role,
|
||||
workspaceNames,
|
||||
policy,
|
||||
generated.get(pkg.name),
|
||||
sourceUses.get(pkg.dir),
|
||||
))
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
/** Source uses per package directory, collected either in-process or by worker threads. */
|
||||
async function readSourceUses(
|
||||
root: string,
|
||||
packages: readonly SelectedWorkspacePackage[],
|
||||
workers: number,
|
||||
): Promise<Map<string, Map<string, string[]>>> {
|
||||
if (workers < 2 || packages.length < 2) {
|
||||
return new Map(packages.map(pkg => [pkg.dir, readAllSourceUses(root, pkg)]))
|
||||
}
|
||||
const sizes = sliceSizes(packages.length, workers)
|
||||
const tasks = packages.map((pkg, index) => ({ index, dir: pkg.dir, name: pkg.name, manifest: pkg.manifest }))
|
||||
const slices: typeof tasks[] = []
|
||||
let offset = 0
|
||||
for (const size of sizes) {
|
||||
slices.push(tasks.slice(offset, offset + size))
|
||||
offset += size
|
||||
}
|
||||
const collected = (await Promise.all(slices.map(async slice => runSourceUsesWorker(root, slice)))).flat()
|
||||
const byName = new Map(collected.map(result => [result.name, result.uses]))
|
||||
return new Map(packages.map(pkg => [pkg.dir, byName.get(pkg.name) ?? new Map<string, string[]>()]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one slice of packages in a worker thread.
|
||||
* @param root - Repository containing the workspace sources.
|
||||
* @param tasks - Packages this worker walks.
|
||||
* @returns one result per task.
|
||||
* @throws If the worker crashes or exits before reporting.
|
||||
*/
|
||||
function runSourceUsesWorker(root: string, tasks: readonly SourceUsesTaskForWorker[]): Promise<SourceUsesWorkerResult[]> {
|
||||
const request: SourceUsesWorkerRequest = { root, tasks }
|
||||
const url = new URL('./package-source-uses.worker.ts', import.meta.url)
|
||||
return new Promise((resolveResults, rejectResults) => {
|
||||
const worker = new Worker(url, { workerData: request })
|
||||
let settled = false
|
||||
worker.once('message', (results: SourceUsesWorkerResult[]) => {
|
||||
settled = true
|
||||
void worker.terminate()
|
||||
resolveResults(results)
|
||||
})
|
||||
worker.once('error', (error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
rejectResults(error)
|
||||
})
|
||||
worker.once('exit', (code) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
rejectResults(new Error(`${GATE}: source-uses worker exited with ${String(code)} before reporting`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface SourceUsesTaskForWorker {
|
||||
readonly index: number
|
||||
readonly dir: string
|
||||
readonly name: string
|
||||
readonly manifest: PackageDependencyManifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute a task count over a worker count as evenly as possible.
|
||||
* @param tasks - Number of tasks to place.
|
||||
* @param workers - Number of workers that must stay busy.
|
||||
* @returns One slice size per non-empty slice.
|
||||
*/
|
||||
export function sliceSizes(tasks: number, workers: number): number[] {
|
||||
const slices = Math.min(tasks, Math.max(1, workers))
|
||||
const base = Math.floor(tasks / slices)
|
||||
const remainder = tasks % slices
|
||||
return Array.from({ length: slices }, (_, index) => base + (index < remainder ? 1 : 0))
|
||||
}
|
||||
|
||||
/** Derive the required npm section for each relationship owned by the policy. */
|
||||
export function expectedPackageDependencies(
|
||||
facts: PackageDependencyFacts,
|
||||
@@ -790,20 +867,6 @@ export function formatPeerRequiredRuntimeDependencies(state: PackageDependencySt
|
||||
]
|
||||
}
|
||||
|
||||
function section(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
return manifest[name] ?? {}
|
||||
}
|
||||
|
||||
function mutableSection(manifest: PackageDependencyManifest, name: DependencySection): Record<string, string> {
|
||||
manifest[name] ??= {}
|
||||
return manifest[name]
|
||||
}
|
||||
|
||||
function declaredSections(manifest: PackageDependencyManifest, name: string): DependencySection[] {
|
||||
return (['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const)
|
||||
.filter(sectionName => section(manifest, sectionName)[name] !== undefined)
|
||||
}
|
||||
|
||||
function describeSections(sections: readonly DependencySection[]): string {
|
||||
return sections.length === 0 ? 'no dependency section' : sections.join(' + ')
|
||||
}
|
||||
|
||||
@@ -30,12 +30,15 @@ interface SentenceContract {
|
||||
* so an absent section cannot be mistaken for forgotten documentation.
|
||||
*/
|
||||
const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
'packages/util/file-drag': 'The package resolves export names and declares native file operations; it does not construct model input.',
|
||||
'packages/core/scope': 'The package is a model-agnostic registration and lifecycle primitive; model-facing consumers own any context selection.',
|
||||
'packages/experimental/liveshare-protocol': 'The package only defines wire frames and identity values that its consumers exchange; it registers nothing model-facing.',
|
||||
'packages/util/brand': 'The package only constructs plain string values and registers nothing model-facing.',
|
||||
'packages/util/home-paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
|
||||
'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.',
|
||||
'packages/util/workspace-path': 'The package only formats Workspace paths for browser UI; it never constructs model input.',
|
||||
'packages/util/values': 'The package only validates, snapshots, compares, freezes, or rejects caller-owned values; consumers own every model-facing use.',
|
||||
'packages/util/official-bundles': 'The package only lists bundle package names and their display metadata; the loaded plugin tree owns model context.',
|
||||
'packages/util/code-language': 'The package only maps a filename suffix to a syntax-highlighting language id; the read consumer owns the persisted hint.',
|
||||
}
|
||||
|
||||
@@ -45,6 +48,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
|
||||
* blocks. A package moves on or off this list with its context behavior.
|
||||
*/
|
||||
const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/subagent/subagent-names': { kind: 'indirect', reason: 'Stores durable name-to-id bindings; delegation and control tools own their model-facing rendering.' },
|
||||
'packages/client/ui-entry-customization': { kind: 'none', reason: 'Controls browser launchers and prepares a reviewable Creator draft without submitting model input.' },
|
||||
'packages/experimental/client-ui-cot-translation': { kind: 'indirect', reason: 'The translator owns optional paid queries; normal Session activation delegates startup context to the preset.' },
|
||||
'packages/experimental/cot-translation-bundle': { kind: 'indirect', reason: 'The GUI consumer delegates paid query prompts to the translator and normal activation context to the Session preset.' },
|
||||
@@ -54,6 +58,10 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/experimental/speech-to-text-sensevoice': { kind: 'none', reason: 'Local recognition returns transient text without modifying model context.' },
|
||||
'packages/experimental/client-ui-voice-input': { kind: 'none', reason: 'Inserts reviewable text into the unsent draft without submitting to the Agent.' },
|
||||
'packages/experimental/plugin-market-bundle': { kind: 'indirect', reason: 'Mounts the pluginMarket service, which lets the Creator preset register the plugin_market tool documented in packages/boot/plugin-market.' },
|
||||
'packages/experimental/client-ui-action-cards': { kind: 'indirect', reason: 'Card actions go to the Host card service, which owns the logged tool, decisions and status notice.' },
|
||||
'packages/experimental/client-ui-creation-recommendations': { kind: 'indirect', reason: 'The page card body starts a Creator through the Host page kind; the Host owns its logged task.' },
|
||||
'packages/experimental/client-ui-plugin-recommendations': { kind: 'indirect', reason: 'The plugin card body enables through the Host plugin kind; the Host card service owns the logged tool, decisions and status notice.' },
|
||||
'packages/experimental/personalized-recommendations': { kind: 'indirect', reason: 'The bundle composes the card service and both card kinds; their packages own the skills, the logged tool and the status notice.' },
|
||||
'packages/experimental/voice-input-bundle': { kind: 'none', reason: 'Composes dictation and preparation plugins without adding any model-facing contribution.' },
|
||||
'packages/telemetry/otel': { kind: 'none', reason: 'The service delivers caller-selected records without changing model context.' },
|
||||
'packages/host/product-telemetry-otel': { kind: 'none', reason: 'The plugin exports explicit analytics records without contributing model context.' },
|
||||
@@ -97,7 +105,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/util/plugin-market-index': { kind: 'indirect', reason: 'Pure index validation; the plugin_market tool owns the entries it returns to the model.' },
|
||||
'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' },
|
||||
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
|
||||
'packages/core/agent-controller': { kind: 'indirect', reason: 'The controller appends sender-prepared messages to the inbox; the Agent loop owns how they enter a model request.' },
|
||||
'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' },
|
||||
'packages/preset/agent-preset': { kind: 'indirect', reason: 'The declaration selects plugins that own model-visible contributions.' },
|
||||
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
|
||||
@@ -112,6 +119,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/experimental/inspector-profile': { kind: 'none', reason: 'The bundle mounts developer inspection plugins without contributing model context.' },
|
||||
'packages/experimental/session-inspector': { kind: 'none', reason: 'The conversation view entries add no model-facing input.' },
|
||||
'packages/client/ui-slots': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-file-drag': { kind: 'none', reason: 'Browser-side file transfers and transient feedback; registers nothing model-facing.' },
|
||||
'packages/client/ui-attachment': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-primitives': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/store': { kind: 'none', reason: 'Browser-side state primitives; register nothing model-facing.' },
|
||||
@@ -225,6 +233,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' },
|
||||
'packages/test-support/session-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' },
|
||||
'packages/test-support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' },
|
||||
'packages/test-support/tool-testkit': { kind: 'none', reason: 'Fixture helpers define and execute test tools without making model requests.' },
|
||||
'packages/test-support/loader-smoke': { kind: 'none', reason: 'The test harness submits an ordinary user task but delegates prompt and tool composition to the loaded tree.' },
|
||||
'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
|
||||
'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
|
||||
|
||||
@@ -13,8 +13,7 @@ const base = '@deepseek-ai/dsh-base'
|
||||
const core = '@deepseek-ai/dsh-core'
|
||||
const candidate = '@deepseek-ai/dsh-candidate'
|
||||
const candidateDir = 'packages/core/candidate'
|
||||
const profile = 'packages/boot/app-boot/src/profile.ts'
|
||||
const catalog = 'packages/boot/app-boot/src/official-bundle-packages.ts'
|
||||
const profile = 'packages/util/official-bundles/src/bundles.ts'
|
||||
const patch = 'packages/bundle/base/cordis.patch.yml'
|
||||
|
||||
function pkg(root: string, directory: string, name: string, fields: Record<string, unknown> = {}): void {
|
||||
@@ -29,10 +28,10 @@ function pkg(root: string, directory: string, name: string, fields: Record<strin
|
||||
}
|
||||
|
||||
function profiles(root: string, optional: string[] = [], onDemand: string[] = []): void {
|
||||
write(root, catalog, `export const ON_DEMAND_BUNDLES = ${JSON.stringify(onDemand)}\n`)
|
||||
write(root, profile, `export const PROFILE_TEMPLATES = ${JSON.stringify(Object.fromEntries(
|
||||
['web', 'acp', 'headless', 'sdk', 'sdk-minimal'].map(name => [name, { bundles: [base] }]),
|
||||
))}\nexport const DEFAULT_PROFILE_BUNDLES = ['${base}']\nexport const OPTIONAL_BUNDLES = ${JSON.stringify(optional)}\n`)
|
||||
))}\nexport const DEFAULT_PROFILE_BUNDLES = ['${base}']\nexport const OPTIONAL_BUNDLES = ${JSON.stringify(optional)}\n`
|
||||
+ `export const ON_DEMAND_BUNDLES = ${JSON.stringify(onDemand)}\n`)
|
||||
}
|
||||
|
||||
function fixture(): string {
|
||||
@@ -142,7 +141,7 @@ describe('product package use', () => {
|
||||
const root = fixture()
|
||||
pkg(root, candidateDir, candidate)
|
||||
const children = [{ name: candidate }]
|
||||
if (!initial) write(root, 'packages/boot/app-boot/src/included.yml', children)
|
||||
if (!initial) write(root, 'packages/util/official-bundles/src/included.yml', children)
|
||||
write(root, patch, [{ insert: [{ name: core }, {
|
||||
name: 'cordis:include', disabled: true, config: { path: './included.yml', ...(initial ? { initial: children } : {}) },
|
||||
}] }])
|
||||
@@ -162,12 +161,12 @@ describe('product package use', () => {
|
||||
it('applies Include patches before collecting runtime mounts and uses files ahead of initial entries', () => {
|
||||
const root = fixture()
|
||||
pkg(root, candidateDir, candidate)
|
||||
write(root, 'packages/boot/app-boot/src/included.yml', [{ id: 'child', name: candidate }])
|
||||
write(root, 'packages/util/official-bundles/src/included.yml', [{ id: 'child', name: candidate }])
|
||||
write(root, patch, [{ insert: [{ name: core }, { name: 'cordis:include', config: {
|
||||
path: './included.yml', initial: [{ name: candidate }], patches: [{ id: 'child', disabled: true }],
|
||||
} }] }])
|
||||
expect(failures(root)).toContain('no product runtime use')
|
||||
write(root, 'packages/boot/app-boot/src/included.yml', [])
|
||||
write(root, 'packages/util/official-bundles/src/included.yml', [])
|
||||
expect(failures(root)).toContain('no product runtime use')
|
||||
})
|
||||
|
||||
|
||||
@@ -14,8 +14,7 @@ import { isAgentPresetEntry, isCordisGroupEntry, loadCordisYaml } from './cordis
|
||||
import { PRODUCT_PACKAGE_POLICY, type ProductPackagePolicy } from './product-package-policy.ts'
|
||||
import { collectRuntimeLocalSourceSpecifiers, collectRuntimeSourceSpecifiers } from './verify-client-packages.ts'
|
||||
|
||||
const PROFILE_SOURCE = 'packages/boot/app-boot/src/profile.ts'
|
||||
const ON_DEMAND_SOURCE = 'packages/boot/app-boot/src/official-bundle-packages.ts'
|
||||
const PROFILE_SOURCE = 'packages/util/official-bundles/src/bundles.ts'
|
||||
const PROFILES = ['acp', 'web', 'headless', 'sdk', 'sdk-minimal']
|
||||
const PICKER_SOURCE = 'packages/host/directory-picker-auto/src/index.ts'
|
||||
const DYNAMIC_MOUNTS: Readonly<Record<string, readonly string[]>> = {
|
||||
@@ -188,11 +187,9 @@ export function verifyProductUse(
|
||||
const profileFile = ts.createSourceFile(profilePath, readFileSync(profilePath, 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
const templates = literalDeclaration(profileFile, 'PROFILE_TEMPLATES')
|
||||
const fallback = stringList(literalDeclaration(profileFile, 'DEFAULT_PROFILE_BUNDLES'), PROFILE_SOURCE)
|
||||
const catalogPath = resolve(root, ON_DEMAND_SOURCE)
|
||||
const catalogFile = ts.createSourceFile(catalogPath, readFileSync(catalogPath, 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
const optionals = [
|
||||
...stringList(literalDeclaration(profileFile, 'OPTIONAL_BUNDLES'), PROFILE_SOURCE),
|
||||
...stringList(literalDeclaration(catalogFile, 'ON_DEMAND_BUNDLES'), ON_DEMAND_SOURCE),
|
||||
...stringList(literalDeclaration(profileFile, 'ON_DEMAND_BUNDLES'), PROFILE_SOURCE),
|
||||
]
|
||||
if (!isRecord(templates)) throw new Error(`${PROFILE_SOURCE}: PROFILE_TEMPLATES must be a literal object`)
|
||||
for (const name of PROFILES) if (!isRecord(templates[name])) failures.push(`${PROFILE_SOURCE}: missing shipped ${name} profile`)
|
||||
|
||||
@@ -66,7 +66,7 @@ export function findUnavailableRepositoryReferences(file: string, source: string
|
||||
}
|
||||
|
||||
function trackedFiles(repoRoot: string): string[] {
|
||||
return execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8' })
|
||||
return execFileSync('git', ['ls-files', '-z'], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 })
|
||||
.split('\0')
|
||||
.filter(file => file !== '')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import { prepareWindowsBuildWorkspace, removeWindowsBuildWorkspace } from '../apps/desktop/scripts/windows-build-workspace.mjs'
|
||||
|
||||
it('clones the exact clean commit and removes only its run-owned checkout', () => {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'package-workspace-')))
|
||||
try {
|
||||
const sourceDirectory = join(root, 'original')
|
||||
mkdirSync(sourceDirectory)
|
||||
const git = (...args: string[]) => execFileSync('git', args, { cwd: sourceDirectory, encoding: 'utf8', stdio: 'pipe' }).trim()
|
||||
git('init')
|
||||
writeFileSync(join(sourceDirectory, 'source.txt'), 'selected source')
|
||||
writeFileSync(join(sourceDirectory, '.gitignore'), 'private.env\n')
|
||||
git('add', '.')
|
||||
git('-c', 'user.name=Fixture', '-c', '[email protected]', 'commit', '-m', 'fixture')
|
||||
writeFileSync(join(sourceDirectory, 'private.env'), 'ignored fixture')
|
||||
const options = { sourceDirectory, buildRoot: join(root, 'builds'), runId: '123', runAttempt: '1', sourceSha: git('rev-parse', 'HEAD') }
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: sourceDirectory })).toThrow('outside existing Git')
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: 'relative' })).toThrow('absolute')
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, runId: '../other' })).toThrow('identity')
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, sourceSha: '0'.repeat(40) })).toThrow('match')
|
||||
writeFileSync(join(sourceDirectory, 'source.txt'), 'dirty')
|
||||
expect(() => prepareWindowsBuildWorkspace(options)).toThrow('clean')
|
||||
writeFileSync(join(sourceDirectory, 'source.txt'), 'selected source')
|
||||
const output = join(root, 'actions-output')
|
||||
execFileSync(process.execPath, [resolve(import.meta.dirname, '../apps/desktop/scripts/windows-build-workspace.mjs')], {
|
||||
cwd: sourceDirectory,
|
||||
env: {
|
||||
...process.env,
|
||||
GITHUB_WORKSPACE: sourceDirectory,
|
||||
PACKAGE_BUILD_ROOT: options.buildRoot,
|
||||
GITHUB_RUN_ID: options.runId,
|
||||
GITHUB_RUN_ATTEMPT: options.runAttempt,
|
||||
PACKAGE_SOURCE_SHA: options.sourceSha,
|
||||
GITHUB_OUTPUT: output,
|
||||
},
|
||||
stdio: 'pipe',
|
||||
})
|
||||
const directory = join(options.buildRoot, 'windows-package-123-1', 'source')
|
||||
const temporaryDirectory = join(options.buildRoot, 'windows-package-123-1', 'temp')
|
||||
expect(readFileSync(output, 'utf8')).toBe(`directory=${directory}\ntemporaryDirectory=${temporaryDirectory}\n`)
|
||||
writeFileSync(join(temporaryDirectory, 'temporary.txt'), 'owned temporary data')
|
||||
expect(readFileSync(join(directory, 'source.txt'), 'utf8')).toBe('selected source')
|
||||
expect(existsSync(join(directory, 'private.env'))).toBe(false)
|
||||
expect(() => prepareWindowsBuildWorkspace(options)).toThrow()
|
||||
expect(() => { removeWindowsBuildWorkspace({ ...options, sourceSha: '0'.repeat(40) }) }).toThrow('ownership')
|
||||
expect(existsSync(directory)).toBe(true)
|
||||
const external = join(root, 'external')
|
||||
mkdirSync(external)
|
||||
writeFileSync(join(external, 'keep.txt'), 'keep')
|
||||
symlinkSync(external, join(directory, 'node_modules'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
removeWindowsBuildWorkspace(options)
|
||||
expect(existsSync(directory)).toBe(false)
|
||||
expect(existsSync(temporaryDirectory)).toBe(false)
|
||||
expect(readFileSync(join(external, 'keep.txt'), 'utf8')).toBe('keep')
|
||||
expect(readFileSync(join(sourceDirectory, 'source.txt'), 'utf8')).toBe('selected source')
|
||||
const linked = join(root, 'linked')
|
||||
symlinkSync(external, linked, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
expect(() => prepareWindowsBuildWorkspace({ ...options, buildRoot: join(linked, 'builds') })).toThrow('links')
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -1,13 +1,13 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { parse, resolve } from 'node:path'
|
||||
import yaml from 'js-yaml'
|
||||
import { expect, it } from 'vitest'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows-package.yml'), 'utf8')) as {
|
||||
on: Record<string, { inputs: Record<string, { required?: boolean; default?: string; description?: string }> }>
|
||||
on: Record<string, { inputs: Record<string, { type?: string; required?: boolean; default?: string; description?: string }> }>
|
||||
permissions: Record<string, string>
|
||||
concurrency: { group: string; 'cancel-in-progress': boolean }
|
||||
jobs: Record<string, {
|
||||
@@ -19,6 +19,7 @@ const workflow = yaml.load(readFileSync(resolve(root, '.github/workflows/windows
|
||||
uses?: string
|
||||
run?: string
|
||||
if?: string
|
||||
'working-directory'?: string
|
||||
env?: Record<string, string>
|
||||
with?: Record<string, unknown>
|
||||
}>
|
||||
@@ -61,12 +62,17 @@ it('routes manual packaging to the dedicated runner with explicit preset and sig
|
||||
expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
|
||||
expect(workflow.jobs.package).not.toHaveProperty('name')
|
||||
expect(job['runs-on']).toBe('dsh-win-package-trial')
|
||||
expect(JSON.stringify(job.env)).not.toContain('runner.')
|
||||
expect(job.steps.find(step => step.name === 'Configure persistent pnpm store')?.env?.DSH_DESKTOP_PACKAGE_CACHE_DIR)
|
||||
.toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache')
|
||||
expect(workflow.concurrency).toEqual({ group: 'windows-desktop-package', 'cancel-in-progress': false })
|
||||
expect(workflow.permissions).toEqual({ contents: 'read' })
|
||||
expect(Object.keys(workflow.on.workflow_dispatch!.inputs).sort()).toEqual([
|
||||
'build_version', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode',
|
||||
'build_root', 'build_version', 'cache_namespace', 'compression_level', 'preset', 'signing', 'source_ref', 'test_release_id', 'update_channel', 'version_mode',
|
||||
])
|
||||
expect(workflow.on.workflow_dispatch!.inputs.signing!.default).toBe('signed')
|
||||
expect(workflow.on.workflow_dispatch!.inputs.compression_level).toMatchObject({ type: 'choice', options: ['3', '7'], default: '3', required: true })
|
||||
expect(job.env.PACKAGE_WINDOWS_COMPRESSION_LEVEL).toBe('${{ inputs.compression_level }}')
|
||||
expect(workflow.on.workflow_dispatch!.inputs.build_version!.required).toBe(false)
|
||||
expect(workflow.on.workflow_dispatch!.inputs.version_mode!.default).toBe('default')
|
||||
expect(workflow.on.workflow_dispatch!.inputs.source_ref!.default).toBe('master')
|
||||
@@ -101,9 +107,182 @@ it('retains failure diagnostics and uploads only the selected successful install
|
||||
const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))
|
||||
expect(uploads).toHaveLength(3)
|
||||
expect(uploads[0]!.if).toBe("always() && steps.checkout.outcome == 'success'")
|
||||
expect(uploads[0]!.with).toMatchObject({ path: 'apps/desktop/.desktop-build/packaging-runs/', 'include-hidden-files': true })
|
||||
expect(uploads[0]!.with).toMatchObject({
|
||||
path: '${{ steps.build_workspace.outputs.directory || github.workspace }}/apps/desktop/.desktop-build/packaging-runs/',
|
||||
'include-hidden-files': true,
|
||||
})
|
||||
for (const mode of ['unsigned', 'signed']) {
|
||||
const upload = uploads.find(step => step.if === `success() && inputs.signing == '${mode}'`)!
|
||||
expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error' })
|
||||
expect(upload.with).toMatchObject({ path: '${{ steps.retained.outputs.directory }}/*', 'if-no-files-found': 'error', 'compression-level': 0 })
|
||||
}
|
||||
})
|
||||
|
||||
it('routes all packaging consumers to the selected workspace and removes it only after successful uploads', () => {
|
||||
expect(workflow.on.workflow_dispatch!.inputs.build_root).toMatchObject({ type: 'string', default: '', required: false })
|
||||
expect(job.env.PACKAGE_BUILD_ROOT).toBe('${{ inputs.build_root }}')
|
||||
const selected = job.steps.find(step => step.id === 'build_workspace')!
|
||||
expect(selected.name).toBe('Select packaging workspace')
|
||||
expect(selected.env).toEqual({ PACKAGE_SOURCE_SHA: '${{ steps.checkout.outputs.commit }}' })
|
||||
expect(selected['working-directory']).toBeUndefined()
|
||||
expect(selected.run).toContain('if (!$env:PACKAGE_BUILD_ROOT)')
|
||||
expect(selected.run).toContain('"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT')
|
||||
expect(selected.run).toContain('} else {')
|
||||
expect(selected.run).toContain('node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs"')
|
||||
expect(selected.run).toContain('exit $LASTEXITCODE')
|
||||
const index = job.steps.indexOf(selected)
|
||||
expect(index).toBeGreaterThan(job.steps.findIndex(step => step.uses?.startsWith('actions/setup-node@')))
|
||||
expect(index).toBeLessThan(job.steps.findIndex(step => step.name === 'Configure persistent pnpm store'))
|
||||
const consumers = [
|
||||
'Install locked dependencies',
|
||||
'Validate preset and resolve build version',
|
||||
'Check packaging configuration and toolchain',
|
||||
'Build installer and verify packaged runtime',
|
||||
'Retain complete package outside the checkout',
|
||||
]
|
||||
for (const name of consumers) {
|
||||
const step = job.steps.find(entry => entry.name === name)!
|
||||
expect(step['working-directory']).toBe('${{ steps.build_workspace.outputs.directory }}')
|
||||
expect(job.steps.indexOf(step)).toBeGreaterThan(index)
|
||||
}
|
||||
expect(job.steps.filter(step => step['working-directory'] === '${{ steps.build_workspace.outputs.directory }}'))
|
||||
.toHaveLength(consumers.length)
|
||||
const store = job.steps.find(step => step.name === 'Configure persistent pnpm store')!
|
||||
expect(store.run).toContain('[IO.Path]::GetPathRoot($env:GITHUB_WORKSPACE)')
|
||||
expect(store['working-directory']).toBeUndefined()
|
||||
const cleanup = job.steps.at(-1)!
|
||||
expect(cleanup).toMatchObject({
|
||||
name: 'Remove isolated packaging workspace',
|
||||
if: "success() && inputs.build_root != ''",
|
||||
env: { PACKAGE_SOURCE_SHA: '${{ steps.checkout.outputs.commit }}' },
|
||||
run: 'node "$env:PACKAGE_CI_HELPERS/windows-build-workspace.mjs" --remove',
|
||||
})
|
||||
expect(cleanup['working-directory']).toBeUndefined()
|
||||
for (const upload of job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))) {
|
||||
expect(job.steps.indexOf(upload)).toBeLessThan(job.steps.indexOf(cleanup))
|
||||
}
|
||||
})
|
||||
|
||||
it('normalizes priority in the dependency and packaging shells before launching their children', () => {
|
||||
const command = '& "$env:PACKAGE_CI_HELPERS/windows-packaging-priority.ps1" -BestEffort'
|
||||
expect(job.steps.find(entry => entry.name === 'Validate selected source and adapter support')!.run)
|
||||
.not.toContain('windows-packaging-priority.ps1')
|
||||
for (const name of ['Install locked dependencies', 'Build installer and verify packaged runtime']) {
|
||||
const step = job.steps.find(entry => entry.name === name)!
|
||||
expect(step.run!.trimStart().startsWith(command + '\n')).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes the optional temporary directory to installer building without changing Actions or later steps', () => {
|
||||
const build = job.steps.find(step => step.name === 'Build installer and verify packaged runtime')!
|
||||
expect(build.env?.PACKAGE_BUILD_TEMP).toBe('${{ steps.build_workspace.outputs.temporaryDirectory }}')
|
||||
expect(build.run).toContain([
|
||||
'if ($env:PACKAGE_BUILD_TEMP) {',
|
||||
' $env:TEMP = $env:PACKAGE_BUILD_TEMP',
|
||||
' $env:TMP = $env:PACKAGE_BUILD_TEMP',
|
||||
'}',
|
||||
].join('\n'))
|
||||
expect(build.run!.indexOf('if ($env:PACKAGE_BUILD_TEMP)')).toBeLessThan(build.run!.indexOf('$packageArgs ='))
|
||||
expect(build.run).not.toContain('GITHUB_ENV')
|
||||
expect(build.run).not.toContain('RUNNER_TEMP')
|
||||
expect(job.env).not.toHaveProperty('TEMP')
|
||||
expect(job.env).not.toHaveProperty('TMP')
|
||||
expect(job.env).not.toHaveProperty('PACKAGE_BUILD_TEMP')
|
||||
for (const step of job.steps.filter(step => step !== build)) {
|
||||
expect(step.env ?? {}).not.toHaveProperty('PACKAGE_BUILD_TEMP')
|
||||
expect(step.env ?? {}).not.toHaveProperty('TEMP')
|
||||
expect(step.env ?? {}).not.toHaveProperty('TMP')
|
||||
expect(step.run ?? '').not.toMatch(/\$env:(?:TEMP|TMP)\s*=/u)
|
||||
}
|
||||
const selected = job.steps.find(step => step.id === 'build_workspace')!
|
||||
const defaultBranch = selected.run!.split('} else {')[0]!
|
||||
expect(defaultBranch).toContain('"directory=$env:GITHUB_WORKSPACE" >> $env:GITHUB_OUTPUT')
|
||||
expect(defaultBranch).not.toContain('temporaryDirectory')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('keeps the dependency store stable across run-specific pnpm homes', () => {
|
||||
const directory = mkdtempSync(resolve(tmpdir(), 'windows-package-store-'))
|
||||
try {
|
||||
const workspace = resolve(directory, 'runner', 'repo')
|
||||
const packageCache = resolve(directory, 'tool-cache', 'desktop')
|
||||
const store = resolve(parse(directory).root, '.pnpm-store')
|
||||
const step = job.steps.find(entry => entry.name === 'Configure persistent pnpm store')!
|
||||
expect(job.steps.indexOf(step)).toBeLessThan(job.steps.findIndex(entry => entry.name === 'Install locked dependencies'))
|
||||
const script = resolve(directory, 'store.ps1')
|
||||
writeFileSync(script, "$ErrorActionPreference = 'Stop'\n" + step.run!)
|
||||
const outputs = ['first', 'second'].map((run) => {
|
||||
const environmentFile = resolve(directory, run + '.env')
|
||||
execFileSync('pwsh', ['-NoProfile', '-File', script], {
|
||||
env: { ...process.env, GITHUB_WORKSPACE: workspace,
|
||||
PACKAGE_CACHE_NAMESPACE: '', DSH_DESKTOP_PACKAGE_CACHE_DIR: packageCache,
|
||||
PNPM_HOME: resolve(directory, run, 'pnpm'), GITHUB_ENV: environmentFile },
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return readFileSync(environmentFile, 'utf8').trim()
|
||||
})
|
||||
expect(outputs.map(output => output.split(/\r?\n/u))).toEqual(Array.from({ length: 2 }, () => [
|
||||
`DSH_DESKTOP_PACKAGE_CACHE_DIR=${packageCache}`, `PNPM_CONFIG_STORE_DIR=${store}`,
|
||||
]))
|
||||
const runNamespace = (namespace: string) => {
|
||||
const output = resolve(directory, 'namespace.env')
|
||||
rmSync(output, { force: true })
|
||||
execFileSync('pwsh', ['-NoProfile', '-File', script], {
|
||||
env: { ...process.env, GITHUB_WORKSPACE: workspace,
|
||||
PACKAGE_CACHE_NAMESPACE: namespace, DSH_DESKTOP_PACKAGE_CACHE_DIR: packageCache, GITHUB_ENV: output },
|
||||
stdio: 'pipe',
|
||||
})
|
||||
return readFileSync(output, 'utf8').trim().split(/\r?\n/u)
|
||||
}
|
||||
expect(runNamespace('perf-1')).toEqual([
|
||||
`DSH_DESKTOP_PACKAGE_CACHE_DIR=${resolve(packageCache, 'perf-1')}`, `PNPM_CONFIG_STORE_DIR=${resolve(store, 'perf-1')}`,
|
||||
])
|
||||
for (const invalid of ['../escape', 'Upper', 'has space', 'con', 'perf\n', 'a'.repeat(65)]) {
|
||||
expect(() => runNamespace(invalid)).toThrow()
|
||||
}
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 3 })
|
||||
}
|
||||
})
|
||||
|
||||
it('pins environment helpers to the workflow commit before selecting product source', () => {
|
||||
const helper = job.steps.find(step => step.name === 'Checkout workflow helpers')!
|
||||
const stage = job.steps.find(step => step.name === 'Stage workflow helpers')!
|
||||
const source = job.steps.find(step => step.id === 'checkout')!
|
||||
expect(helper.with).toMatchObject({ ref: '${{ github.workflow_sha }}', 'persist-credentials': false })
|
||||
expect(job.steps.indexOf(helper)).toBeLessThan(job.steps.indexOf(stage))
|
||||
expect(job.steps.indexOf(stage)).toBeLessThan(job.steps.indexOf(source))
|
||||
expect(stage.run).toContain('RUNNER_TEMP')
|
||||
expect(stage.run).toContain('windows-build-workspace.mjs')
|
||||
expect(stage.run).toContain('windows-packaging-priority.ps1')
|
||||
for (const step of job.steps.filter(step => step.name !== 'Stage workflow helpers')) {
|
||||
expect(step.run ?? '').not.toContain('./apps/desktop/scripts/windows-packaging-priority.ps1')
|
||||
expect(step.run ?? '').not.toContain('node apps/desktop/scripts/windows-build-workspace.mjs')
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')('stages helpers that remain usable when product source lacks them', () => {
|
||||
const directory = mkdtempSync(resolve(tmpdir(), 'workflow-helpers-'))
|
||||
try {
|
||||
const source = resolve(directory, 'source')
|
||||
const scripts = resolve(source, 'apps/desktop/scripts')
|
||||
const temporary = resolve(directory, 'temporary')
|
||||
mkdirSync(scripts, { recursive: true })
|
||||
mkdirSync(temporary)
|
||||
for (const name of ['windows-packaging-priority.ps1', 'windows-build-workspace.mjs']) {
|
||||
writeFileSync(resolve(scripts, name), readFileSync(resolve(root, 'apps/desktop/scripts', name)))
|
||||
}
|
||||
const stage = resolve(directory, 'stage.ps1')
|
||||
writeFileSync(stage, job.steps.find(step => step.name === 'Stage workflow helpers')!.run!)
|
||||
const env = { ...process.env, RUNNER_TEMP: temporary, GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '1', GITHUB_ENV: resolve(directory, 'env') }
|
||||
execFileSync('pwsh', ['-NoProfile', '-File', stage], { cwd: source, env, stdio: 'pipe' })
|
||||
rmSync(scripts, { recursive: true })
|
||||
const helper = resolve(temporary, 'packaging-helpers-123-1/windows-packaging-priority.ps1')
|
||||
expect(execFileSync('pwsh', ['-NoProfile', '-File', helper, '-InspectOnly'], { cwd: source, stdio: 'pipe' }).toString())
|
||||
.toContain('packaging-process-priority')
|
||||
expect(existsSync(resolve(temporary, 'packaging-helpers-123-1/windows-build-workspace.mjs'))).toBe(true)
|
||||
expect(() => execFileSync('pwsh', ['-NoProfile', '-File', stage], {
|
||||
cwd: source, env: { ...env, GITHUB_RUN_ATTEMPT: '2' }, stdio: 'pipe',
|
||||
})).toThrow()
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true, maxRetries: 3 })
|
||||
}
|
||||
})
|
||||
@@ -253,7 +253,7 @@ grep -q '^smoke: win32 x64' "$scratch/logs/smoke.log" || { echo 'wine-windows-ga
|
||||
build_gate() {
|
||||
wine_node "$scratch/logs/host-typert.log" --import tsx/esm scripts/generate-typert.ts || return $?
|
||||
wine_node "$scratch/logs/host-references.log" --import tsx/esm scripts/compile-referenced-projects.ts host || return $?
|
||||
wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=4096 "$tsc_js" -p tsconfig.host.json --pretty false || return $?
|
||||
wine_node "$scratch/logs/host-tsc.log" --max-old-space-size=8192 "$tsc_js" -p tsconfig.host.json --pretty false || return $?
|
||||
wine_node "$scratch/logs/host-tsdown.log" "$tsdown_js" --env.DSH_BUILD_FACE host || return $?
|
||||
(cd apps/desktop && wine_node "$scratch/logs/desktop-tsdown.log" "../../$tsdown_js") || return $?
|
||||
wine_node "$scratch/logs/client-tsc.log" "$tsc_js" -b tsconfig.client.json --pretty false || return $?
|
||||
|
||||
Reference in new issue
Block a user