Merge branch 'feat/plugin-mgmt-1-boot' into feat/plugin-mgmt-2-manager
# Conflicts: # apps/cli/src/bin.ts # docs/config-catalog.i18n.yaml # docs/config-catalog.md # docs/config-catalog.zh.md # packages/api/remotes/src/client/index.ts # packages/boot/app-boot/src/index.ts # packages/boot/app-boot/src/profile.ts # packages/host/README.i18n.yaml # packages/host/README.md # packages/host/README.zh.md # scripts/verify-package-readme-model-experience.ts
This commit is contained in:
commit
bb408abe28
2137 files changed
+73685
-9705
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -23,6 +23,32 @@ function run(env: NodeJS.ProcessEnv, ...args: string[]) {
|
||||
}
|
||||
|
||||
describe('Python runtime executable builder CLI', () => {
|
||||
it('keeps the single-file dispatcher on the Python packaging surface', () => {
|
||||
const bootstrapPath = resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs')
|
||||
const bootstrap = readFileSync(bootstrapPath, 'utf8')
|
||||
const cliConfig = readFileSync(resolve(root, 'apps/cli/tsdown.config.ts'), 'utf8')
|
||||
const cliTsconfig = readFileSync(resolve(root, 'apps/cli/tsconfig.json'), 'utf8')
|
||||
const cliManifest = JSON.parse(readFileSync(resolve(root, 'apps/cli/package.json'), 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
}
|
||||
const runtimeManifest = JSON.parse(readFileSync(resolve(root, 'python/sdk-runtime/package.json'), 'utf8')) as {
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
expect(existsSync(resolve(root, 'apps/cli/src/runtime-bootstrap.ts'))).toBe(false)
|
||||
expect(cliConfig).not.toContain('runtime-bootstrap')
|
||||
expect(cliConfig).toContain("clean: ['lib/*.js']")
|
||||
expect(cliTsconfig).not.toContain('packages/subprocess/subprocess-local')
|
||||
expect(cliManifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subprocess-local')
|
||||
expect(cliManifest.devDependencies).toHaveProperty('@deepseek-ai/dsh-subprocess-local')
|
||||
expect(runtimeManifest.dependencies).toHaveProperty('@deepseek-ai/dsh-subprocess-local')
|
||||
expect(bootstrap).toContain("import('@deepseek-ai/dsh/lib/bin.js')")
|
||||
expect(bootstrap).toContain('await runCli()')
|
||||
expect(bootstrap).toContain("import('@deepseek-ai/dsh-subprocess-local/runner')")
|
||||
expect(bootstrap).toContain('await runSelectedSubprocessRunner(selection)')
|
||||
})
|
||||
|
||||
it('runs pnpm through its JavaScript entrypoint without a command shell', () => {
|
||||
const result = run(
|
||||
{ npm_execpath: 'C:\\tools\\pnpm.cjs' },
|
||||
@@ -34,6 +60,8 @@ describe('Python runtime executable builder CLI', () => {
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs run verify-runtime-closure`)
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs --filter dsh-python-runtime-closure deploy`)
|
||||
expect(result.stdout).not.toContain(resolve(root, 'python/sdk-runtime/runtime-bootstrap.mjs'))
|
||||
expect(result.stdout).toContain('"bin":"runtime-bootstrap.mjs"')
|
||||
expect(result.stdout).toContain(`${process.execPath} C:\\tools\\pnpm.cjs exec pkg`)
|
||||
expect(result.stdout).not.toMatch(/pnpm\.cmd/i)
|
||||
})
|
||||
|
||||
@@ -17,8 +17,8 @@ const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** The closure manifest whose dependencies define the executable. */
|
||||
const DEPLOY_ROOT_PACKAGE = 'dsh-python-runtime-closure'
|
||||
/** The sole application launcher inside the deployed closure. */
|
||||
const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh/lib/bin.js'
|
||||
/** The sole executable entry inside the deployed closure. */
|
||||
const ENTRY_BIN = 'runtime-bootstrap.mjs'
|
||||
/** Python-visible executable basename. */
|
||||
const OUTPUT_BASENAME = 'deepseek-harness-sdk-runtime'
|
||||
/** Default Node major; SEA mode requires at least Node 22. */
|
||||
@@ -406,7 +406,7 @@ class SingleExeBuild {
|
||||
throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`)
|
||||
}
|
||||
if (!existsSync(join(this.staging, ENTRY_BIN))) {
|
||||
throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`)
|
||||
throw new Error(`build-exe-for-python-sdk: staged bootstrap ${join(this.staging, ENTRY_BIN)} is missing.`)
|
||||
}
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record<string, unknown>
|
||||
await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`)
|
||||
|
||||
@@ -53,10 +53,16 @@ const experimentalPackageDirectory = /^packages\/experimental\/[^/]+$/
|
||||
/** npm namespace reserved for private experimental packages. */
|
||||
const experimentalPackageNamePrefix = '@deepseek-ai/dsh-experimental-'
|
||||
/** Directories whose packages this repository publishes: one release member each. */
|
||||
const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/
|
||||
const releaseMemberDirectory = /^(?:packages\/(?!experimental\/)[^/]+\/[^/]+|apps\/(?!desktop(?:-host)?$)[^/]+|vendor\/[^/]+)$/
|
||||
/** Installable application assembled by electron-builder rather than published to npm. */
|
||||
const desktopApplicationDirectory = 'apps/desktop'
|
||||
const localArtifactDirs = new Set(['node_modules'])
|
||||
const appPackageFiles: Readonly<Record<string, readonly string[]>> = {
|
||||
'@deepseek-ai/dsh': ['lib/*.js'],
|
||||
'@deepseek-ai/dsh-desktop-host': [
|
||||
'lib/index.js',
|
||||
'config/desktop.cordis.patch.yml',
|
||||
],
|
||||
// Sourcemaps stay out by payload policy; the worker-preview surface
|
||||
// (dist/preview.html and dist/preview/) backs private experimental
|
||||
// packages and is not published.
|
||||
@@ -147,6 +153,7 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
// The glob covers whichever sheets a package emits; sourcemaps stay
|
||||
// unpublished, as everywhere else in the repository.
|
||||
'@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
|
||||
'@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'],
|
||||
'@deepseek-ai/dsh-client-web': ['lib/**/*.css'],
|
||||
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
|
||||
// The CPython side ships as source .py files, published as-is rather than built.
|
||||
@@ -164,11 +171,17 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
|
||||
// also shares its generated FFI code through a hashed runtime chunk.
|
||||
'@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'],
|
||||
'@deepseek-ai/dsh-skill-badge': ['assets'],
|
||||
// Ordinary native containment ships a path-loaded runner and its shared
|
||||
// runner chunk beside the existing node-pty permission repair.
|
||||
'@deepseek-ai/dsh-subprocess-local': [
|
||||
'lib/runner.js',
|
||||
'lib/runner-*.js',
|
||||
'scripts/ensure-spawn-helper.mjs',
|
||||
],
|
||||
// tsdown shares the repository/pack code between the lib entry and the bin
|
||||
// through a hashed chunk. The committed bin.js is the link target pnpm can
|
||||
// resolve at install time, before the build produces lib/bin.js.
|
||||
'@deepseek-ai/dsh-experimental-webworker-packer': ['bin.js', 'lib/repository-*.js'],
|
||||
'@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'],
|
||||
// The package probe's child process entry, path-loaded beside the lib.
|
||||
'@deepseek-ai/dsh-app-boot': ['lib/probe-child.js', 'lib/patch-file.js'],
|
||||
}
|
||||
@@ -355,7 +368,7 @@ export function checkWorkspaceManifest({ dir, manifest }: WorkspaceManifest): st
|
||||
}
|
||||
}
|
||||
|
||||
if (dir.startsWith('apps/') && manifest.name?.startsWith('@deepseek-ai/')) {
|
||||
if (dir.startsWith('apps/') && dir !== desktopApplicationDirectory && manifest.name?.startsWith('@deepseek-ai/')) {
|
||||
const expectedFiles = appPackageFiles[manifest.name]
|
||||
if (expectedFiles === undefined) {
|
||||
errors.push(`${label}: app package has no publication files policy`)
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
interface Step {
|
||||
name?: string
|
||||
uses?: string
|
||||
if?: string
|
||||
run?: string
|
||||
env?: Record<string, string>
|
||||
with?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface CompatibilityJob {
|
||||
'runs-on': string
|
||||
if: string
|
||||
env: Record<string, string>
|
||||
strategy: { 'fail-fast': boolean; matrix: { include: Array<{ node: string | number; name: string; runner: string; gate_concurrency: string }> } }
|
||||
steps: Step[]
|
||||
}
|
||||
|
||||
const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows/ci.yml'), 'utf8')) as {
|
||||
jobs: { 'node-compat': CompatibilityJob; 'python-sdk': { 'runs-on': string } }
|
||||
}
|
||||
const job = workflow.jobs['node-compat']
|
||||
const labels = ['self-hosted', 'linux', 'x64', 'vm-backup']
|
||||
|
||||
// This wiring check uses equal-typed, canonical-case fixtures. Actions compares
|
||||
// strings case-insensitively; JavaScript does not. This is not an Actions evaluator.
|
||||
function evaluate(expression: string, context: Record<string, unknown>): unknown {
|
||||
const body = expression.trim().slice(3, -2)
|
||||
return runInNewContext(body, {
|
||||
...context, fromJSON: JSON.parse,
|
||||
}, { timeout: 1000 }) as unknown
|
||||
}
|
||||
|
||||
function route(options: { mode?: string; author?: string; repository?: string; fork?: boolean; actor?: string } = {}): unknown {
|
||||
return evaluate(job['runs-on'], {
|
||||
vars: { DSH_CI_FAILOVER_LINUX: options.mode ?? 'selfhosted' },
|
||||
github: {
|
||||
repository: 'deepseek-harness/deepseek-harness',
|
||||
actor: options.actor ?? 'maintainer',
|
||||
event: { pull_request: {
|
||||
user: { login: options.author ?? 'maintainer' },
|
||||
head: { repo: { full_name: options.repository ?? 'deepseek-harness/deepseek-harness', fork: options.fork ?? false } },
|
||||
} },
|
||||
},
|
||||
matrix: { runner: 'ubuntu-latest' },
|
||||
})
|
||||
}
|
||||
|
||||
describe('Node compatibility self-hosted routing', () => {
|
||||
it('uses the Linux pool only for opted-in repository-owned PRs', () => {
|
||||
expect(route()).toEqual(labels)
|
||||
for (const mode of ['', 'hosted', 'unexpected']) expect(route({ mode })).toBe('ubuntu-latest')
|
||||
expect(route({ author: 'dependabot[bot]', actor: 'maintainer' })).toBe('ubuntu-latest')
|
||||
expect(route({ repository: 'outsider/fork', fork: true })).toBe('ubuntu-latest')
|
||||
expect(route({ repository: 'outsider/fork', fork: false })).toBe('ubuntu-latest')
|
||||
expect(route({ fork: true })).toBe('ubuntu-latest')
|
||||
expect(route({ repository: '' })).toBe('ubuntu-latest')
|
||||
})
|
||||
|
||||
it('preserves all three required version jobs and their concurrency', () => {
|
||||
expect(job.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(job.strategy['fail-fast']).toBe(false)
|
||||
expect(job.strategy.matrix.include).toEqual([
|
||||
{ node: '22.19', name: 'node 22.19', runner: 'ubuntu-latest', gate_concurrency: '1' },
|
||||
{ node: '24.9', name: 'node 24.9', runner: 'ubuntu-latest', gate_concurrency: '1' },
|
||||
{ node: 26, name: 'node 26', runner: 'ubuntu-latest', gate_concurrency: '1' },
|
||||
])
|
||||
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')
|
||||
})
|
||||
|
||||
it('isolates version installs and enables hosted package caching only on hosted runners', () => {
|
||||
const setup = job.steps.find(step => step.uses === 'actions/setup-node@v6')!
|
||||
expect(setup.env).toEqual({
|
||||
NODE_OPTIONS: "${{ runner.environment == 'self-hosted' && '--import=./scripts/ci-compatible-toolcache.mjs' || '' }}",
|
||||
})
|
||||
expect(setup.with?.['node-version']).toBe('${{ matrix.node }}')
|
||||
expect(setup.with?.['package-manager-cache']).toBe(false)
|
||||
for (const [environment, cache] of [['github-hosted', 'pnpm'], ['self-hosted', '']]) {
|
||||
const context = { runner: { environment, temp: '/runner/temp', tool_cache: '/runner/toolcache' } }
|
||||
expect(evaluate(setup.with?.cache as string, context)).toBe(cache)
|
||||
expect(evaluate(setup.env!.NODE_OPTIONS!, context)).toBe(
|
||||
environment === 'self-hosted' ? '--import=./scripts/ci-compatible-toolcache.mjs' : '',
|
||||
)
|
||||
}
|
||||
expect(job.steps[0]?.with).toEqual({ 'persist-credentials': false })
|
||||
expect(job.steps.some(step => step.uses?.startsWith('actions/cache/'))).toBe(false)
|
||||
})
|
||||
|
||||
it('overrides runner exports inside setup-node without affecting later Node processes', () => {
|
||||
const setup = job.steps.find(step => step.uses === 'actions/setup-node@v6')!
|
||||
const nodeOptions = evaluate(setup.env!.NODE_OPTIONS!, { runner: { environment: 'self-hosted' } }) as string
|
||||
const root = mkdtempSync(join(tmpdir(), 'ci-compatible preload-'))
|
||||
try {
|
||||
const env = { PATH: process.env.PATH, RUNNER_TEMP: root, RUNNER_TOOL_CACHE: join(root, 'persistent') }
|
||||
const probe = (options: Record<string, string | undefined>) => {
|
||||
const child = spawnSync(process.execPath, ['-p', 'process.env.RUNNER_TOOL_CACHE'], {
|
||||
cwd: resolve(import.meta.dirname, '..'), env: options, encoding: 'utf8', timeout: 10_000,
|
||||
})
|
||||
expect(child.error).toBeUndefined()
|
||||
expect(child.signal).toBeNull()
|
||||
return child
|
||||
}
|
||||
const setupChild = probe({ ...env, NODE_OPTIONS: nodeOptions })
|
||||
expect(setupChild.status, setupChild.stderr).toBe(0)
|
||||
expect(setupChild.stdout.trim()).toBe(join(root, 'node-compat-toolcache'))
|
||||
const normalChild = probe(env)
|
||||
expect(normalChild.status, normalChild.stderr).toBe(0)
|
||||
expect(normalChild.stdout.trim()).toBe(env.RUNNER_TOOL_CACHE)
|
||||
const missingTemp = probe({ ...env, RUNNER_TEMP: undefined, NODE_OPTIONS: nodeOptions })
|
||||
expect(missingTemp.status).not.toBe(0)
|
||||
expect(missingTemp.stderr).toContain('requires an absolute RUNNER_TEMP')
|
||||
expect(job.env).not.toHaveProperty('NODE_OPTIONS')
|
||||
expect(job.steps.filter(step => step.env?.NODE_OPTIONS)).toEqual([setup])
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects a Node executable outside its runner temporary installation', () => {
|
||||
const step = job.steps.find(candidate => candidate.name === 'Verify isolated Node installation')!
|
||||
expect(step.if).toBe("runner.environment == 'self-hosted'")
|
||||
for (const [executable, status] of [
|
||||
['/runner temp/node-compat-toolcache/node/24.9.0/x64/bin/node', 0],
|
||||
['/shared/toolcache/node/24.9.0/x64/bin/node', 1],
|
||||
['/runner temp/node-compat-toolcache-other/node', 1],
|
||||
] as const) {
|
||||
const child = spawnSync('bash', ['-e', '-u', '-o', 'pipefail', '-c', 'node() { printf "%s" "$TEST_EXECUTABLE"; }; ' + step.run!], {
|
||||
env: { PATH: process.env.PATH, RUNNER_TEMP: '/runner temp', TEST_EXECUTABLE: executable }, encoding: 'utf8', timeout: 10_000,
|
||||
})
|
||||
expect(child.error).toBeUndefined()
|
||||
expect(child.signal).toBeNull()
|
||||
expect(child.status, child.stderr).toBe(status)
|
||||
}
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('configures generated caches before pnpm without changing HOME or global links', () => {
|
||||
const index = job.steps.findIndex(step => step.name === 'Isolate compatibility caches')
|
||||
const step = job.steps[index]!
|
||||
expect(index).toBeGreaterThan(0)
|
||||
expect(index).toBeLessThan(job.steps.findIndex(candidate => candidate.uses === 'pnpm/action-setup@v4'))
|
||||
expect(step.if).toBe("runner.environment == 'self-hosted'")
|
||||
const root = mkdtempSync(join(tmpdir(), 'ci-compatible-selfhosted-'))
|
||||
try {
|
||||
const outputs = ['runner-a', 'runner-b'].map((runner) => {
|
||||
const envFile = join(root, runner + '.env')
|
||||
const temp = join(root, runner)
|
||||
const child = spawnSync('bash', ['-e', '-u', '-o', 'pipefail', '-c', step.run!], {
|
||||
env: { PATH: process.env.PATH, HOME: join(root, 'shared home'), RUNNER_TEMP: temp, GITHUB_ENV: envFile },
|
||||
encoding: 'utf8', timeout: 10_000,
|
||||
})
|
||||
expect(child.error).toBeUndefined()
|
||||
expect(child.signal).toBeNull()
|
||||
expect(child.status, child.stderr).toBe(0)
|
||||
const output = readFileSync(envFile, 'utf8')
|
||||
expect(output).toBe([
|
||||
'NODE_COMPILE_CACHE=' + temp + '/node-compile-cache',
|
||||
'npm_config_devdir=' + temp + '/node-gyp',
|
||||
'PNPM_CONFIG_STORE_DIR=' + join(root, 'shared home') + '/.local/share/pnpm/store',
|
||||
'',
|
||||
].join('\n'))
|
||||
return output
|
||||
})
|
||||
expect(outputs[0]).not.toBe(outputs[1])
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
|
||||
// The Actions runner exports RUNNER_TOOL_CACHE after step env. Run inside the
|
||||
// setup-node process so version installs use runner temp rather than shared state.
|
||||
const temp = process.env.RUNNER_TEMP
|
||||
assert(temp && isAbsolute(temp), 'Node compatibility setup requires an absolute RUNNER_TEMP')
|
||||
process.env.RUNNER_TOOL_CACHE = join(temp, 'node-compat-toolcache')
|
||||
+129
-30
@@ -36,6 +36,45 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['node-24', 'node-24-coverage', 'node-24-consumers'])(
|
||||
'%s keeps tool and fixture temporary files under runner cleanup',
|
||||
(jobName) => {
|
||||
const job = workflowJob(loadWorkflow('.github/workflows/ci.yml'), jobName)
|
||||
if (!Array.isArray(job.steps)) throw new TypeError(`${jobName} must define steps`)
|
||||
expect(job.steps[0]).toEqual({
|
||||
name: 'Use runner-owned temporary storage',
|
||||
run: [
|
||||
'echo "TMPDIR=${{ runner.temp }}" >> "$GITHUB_ENV"',
|
||||
...(jobName === 'node-24-consumers'
|
||||
? ['echo "PLAYWRIGHT_BROWSERS_PATH=${RUNNER_TEMP%/*}/ms-playwright" >> "$GITHUB_ENV"']
|
||||
: []),
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
if (jobName === 'node-24-consumers') {
|
||||
const browserCache: unknown = job.steps.find(step => isRecord(step) && isRecord(step.with)
|
||||
&& step.with.path === '${{ env.PLAYWRIGHT_BROWSERS_PATH }}')
|
||||
expect(browserCache).toMatchObject({ uses: 'actions/cache/restore@v4' })
|
||||
}
|
||||
const store: unknown = job.steps.find(step => isRecord(step) && step.name === 'Configure pnpm store path')
|
||||
expect(store).toMatchObject({
|
||||
run: [
|
||||
'store_root="$HOME/.local/share/pnpm/store"',
|
||||
'echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"',
|
||||
'store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)',
|
||||
'echo "path=$store_path" >> "$GITHUB_OUTPUT"',
|
||||
'',
|
||||
].join('\n'),
|
||||
})
|
||||
for (const step of job.steps) {
|
||||
if (isRecord(step) && isRecord(step.env)) {
|
||||
expect(step.env.TMPDIR).toBeUndefined()
|
||||
expect(step.env.npm_config_cache).toBeUndefined()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('isolates the python SDK exe pnpm setup destination per job', () => {
|
||||
const workflow: unknown = yaml.load(readFileSync(resolve(root, '.github/workflows/build-exe-for-python-sdk.yml'), 'utf8'))
|
||||
if (!isRecord(workflow) || !isRecord(workflow.jobs)) throw new TypeError('build-exe-for-python-sdk.yml must define jobs')
|
||||
@@ -55,11 +94,10 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps required Wine and split native Windows jobs with failover, plus a master-only standby', () => {
|
||||
it('keeps split native Windows PR jobs with failover, plus a master-only standby', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml')
|
||||
if (!isRecord(workflow.jobs)
|
||||
|| !isRecord(workflow.jobs.windows)
|
||||
|| !isRecord(workflow.jobs['windows-build'])
|
||||
|| !isRecord(workflow.jobs['windows-coverage'])
|
||||
|| !isRecord(workflow.jobs['windows-native-tests'])
|
||||
@@ -71,17 +109,14 @@ describe('CI workflow', () => {
|
||||
|| !isRecord(workflow.jobs['node-compat'])
|
||||
|| !isRecord(workflow.jobs['all-checks-passed'])
|
||||
|| !isRecord(masterWorkflow.jobs)
|
||||
|| !isRecord(masterWorkflow.jobs['wine-apt-cache'])
|
||||
|| !isRecord(masterWorkflow.jobs['serial-windows'])) {
|
||||
throw new TypeError('CI workflow must define windows, windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; ci-master must define wine-apt-cache and serial-windows')
|
||||
throw new TypeError('CI workflow must define windows-build, windows-coverage, windows-native-tests, windows-observational, node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, and all-checks-passed; ci-master must define serial-windows')
|
||||
}
|
||||
|
||||
const windows = workflow.jobs.windows
|
||||
const windowsBuild = workflow.jobs['windows-build']
|
||||
const windowsCoverage = workflow.jobs['windows-coverage']
|
||||
const windowsNativeTests = workflow.jobs['windows-native-tests']
|
||||
const windowsObservational = workflow.jobs['windows-observational']
|
||||
const wineAptCache = masterWorkflow.jobs['wine-apt-cache']
|
||||
const serialWindows = masterWorkflow.jobs['serial-windows']
|
||||
const node24 = workflow.jobs['node-24']
|
||||
const node24Coverage = workflow.jobs['node-24-coverage']
|
||||
@@ -89,19 +124,9 @@ 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(windows.steps) || !Array.isArray(aggregate.needs)) {
|
||||
throw new TypeError('Windows job must define steps and the aggregate must define needs')
|
||||
if (!Array.isArray(aggregate.needs)) {
|
||||
throw new TypeError('CI aggregate must define needs')
|
||||
}
|
||||
const commandSteps = windows.steps.filter((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && typeof step.run === 'string'
|
||||
))
|
||||
|
||||
// Required PR job: Wine on ubuntu-latest, runs wine-windows-gates.sh.
|
||||
expect(windows['runs-on']).toBe('ubuntu-latest')
|
||||
expect(windows.name).toBe('windows node 24 / wine blocking')
|
||||
expect(windows.if).toBe("github.event_name == 'pull_request'")
|
||||
expect(commandSteps.some(step => step.run.includes('wine-windows-gates.sh'))).toBe(true)
|
||||
|
||||
// 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], ['windows-observational', windowsObservational]] as const) {
|
||||
expect(typeof job['runs-on']).toBe('string')
|
||||
@@ -183,10 +208,6 @@ describe('CI workflow', () => {
|
||||
expect(windowsObservational.name).toBe('windows node 24 / observational')
|
||||
expect(windowsObservational['continue-on-error']).toBe(true)
|
||||
|
||||
// wine-apt-cache: master-only, seeds the Wine apt cache, lives in ci-master.
|
||||
expect(wineAptCache.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
|
||||
expect(wineAptCache['runs-on']).toBe('ubuntu-latest')
|
||||
|
||||
// serial-windows: master-only standby, self-hosted, non-blocking, lives in ci-master.
|
||||
expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
|
||||
expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows'])
|
||||
@@ -220,16 +241,25 @@ describe('CI workflow', () => {
|
||||
expect(serialGate).toBeDefined()
|
||||
expect(serialGate!.env).toMatchObject({ DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' })
|
||||
|
||||
// Aggregate: Wine and the required split native jobs are needed;
|
||||
// windows-coverage is temporarily non-blocking while Windows ACP
|
||||
// half-close tests are stabilized; observational stays out too.
|
||||
expect(aggregate.needs).toContain('windows')
|
||||
expect(aggregate.needs).not.toContain('windows')
|
||||
expect(aggregate.needs).toContain('windows-build')
|
||||
// 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')
|
||||
expect(node24Bench.name).toBe('node 24 / benchmarks')
|
||||
expect(node24Bench.env).toBeUndefined()
|
||||
expect(node24Bench.steps).toContainEqual({
|
||||
name: 'Install benchmark browser and hosted dependencies',
|
||||
run: 'pnpm --filter @deepseek-ai/dsh-benchmarks exec playwright install --with-deps chromium',
|
||||
})
|
||||
expect(JSON.stringify(node24Bench.steps)).not.toContain('DSH_CI_FAILOVER_LINUX')
|
||||
expect(node24Bench.steps).toContainEqual({
|
||||
name: 'Run performance benchmarks',
|
||||
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')
|
||||
@@ -362,9 +392,7 @@ describe('CI workflow', () => {
|
||||
expect(job.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'")
|
||||
}
|
||||
|
||||
// What bounds the cost of exempting push: a master push may only carry the
|
||||
// cache seeder and the two drills. Any job reachable on push would start
|
||||
// accumulating uncancelled runs, so the set is pinned here.
|
||||
// Pin the post-merge runtime, Wine, and standby inventory.
|
||||
const NOT_PUSH_REACHABLE = new Set([
|
||||
"github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark'",
|
||||
"github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark'",
|
||||
@@ -379,7 +407,7 @@ describe('CI workflow', () => {
|
||||
})
|
||||
.map(([name]) => name)
|
||||
.sort()
|
||||
expect(pushReachable).toEqual(['serial-linux-selfhosted', 'serial-windows', 'wine-apt-cache'])
|
||||
expect(pushReachable).toEqual(['python-runtime', 'serial-linux-selfhosted', 'serial-windows', 'windows'])
|
||||
|
||||
// Why workflow_dispatch must keep cancelling: each benchmark fans out to a
|
||||
// dozen larger runners at once, in this same group on master. If it stopped
|
||||
@@ -395,6 +423,36 @@ describe('CI workflow', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('redirects the Node compile cache to the data-volume runner temp before the first pnpm call', () => {
|
||||
const prWorkflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
const masterWorkflow = loadWorkflow('.github/workflows/ci-master.yml')
|
||||
const redirectLanes = [
|
||||
[prWorkflow, 'node-24'],
|
||||
[prWorkflow, 'node-24-coverage'],
|
||||
[prWorkflow, 'node-24-consumers'],
|
||||
[masterWorkflow, 'serial-linux-selfhosted'],
|
||||
] as const
|
||||
for (const [workflow, jobKey] of redirectLanes) {
|
||||
const job = workflowJob(workflow, jobKey)
|
||||
if (!Array.isArray(job.steps)) throw new TypeError(`${jobKey} must define steps`)
|
||||
const redirectStepIndex = job.steps.findIndex((step): step is Record<string, unknown> & { run: string } => (
|
||||
isRecord(step) && typeof step.run === 'string'
|
||||
&& step.run.includes('NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache')
|
||||
&& step.run.includes('"$GITHUB_ENV"')
|
||||
))
|
||||
// Removing this injection would send every pnpm call in the lane (setup,
|
||||
// store-path probe, install, and the gate) back to the root partition's
|
||||
// /tmp; rationale in
|
||||
// .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
|
||||
expect(redirectStepIndex, `${jobKey} must inject NODE_COMPILE_CACHE into GITHUB_ENV`).toBeGreaterThan(-1)
|
||||
const pnpmSetupIndex = job.steps.findIndex((step): step is Record<string, unknown> & { uses: string } => (
|
||||
isRecord(step) && typeof step.uses === 'string' && step.uses.includes('pnpm/action-setup')
|
||||
))
|
||||
expect(pnpmSetupIndex, `${jobKey} must run pnpm/action-setup`).toBeGreaterThan(-1)
|
||||
expect(redirectStepIndex, `${jobKey} must redirect before pnpm/action-setup runs pnpm`).toBeLessThan(pnpmSetupIndex)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps supported LSP source under native Windows coverage', () => {
|
||||
const config = readFileSync(resolve(root, 'vitest.config.ts'), 'utf8')
|
||||
|
||||
@@ -403,7 +461,7 @@ describe('CI workflow', () => {
|
||||
expect(config).not.toContain('packages/lsp/lsp-stdio/src/instance.ts')
|
||||
})
|
||||
|
||||
it('requires release-shaped Python runtime validation on every published target', () => {
|
||||
it('requires release-shaped Python runtime validation on Linux and Windows x64', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/ci.yml')
|
||||
const pythonRuntime = workflowJob(workflow, 'python-runtime')
|
||||
const aggregate = workflowJob(workflow, 'all-checks-passed')
|
||||
@@ -416,7 +474,7 @@ describe('CI workflow', () => {
|
||||
name: 'python runtime / release-shaped matrix',
|
||||
uses: './.github/workflows/build-exe-for-python-sdk.yml',
|
||||
with: {
|
||||
targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64',
|
||||
targets: 'node24-linux-x64,node24-win-x64',
|
||||
ci: true,
|
||||
},
|
||||
secrets: {
|
||||
@@ -716,6 +774,47 @@ describe('Python release workflows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Request review workflow', () => {
|
||||
it('runs trusted routing on pull request review-state updates', () => {
|
||||
const workflow = loadWorkflow('.github/workflows/request-review.yml')
|
||||
const event = workflowEvent(workflow, 'pull_request_target')
|
||||
const job = workflowJob(workflow, 'request-review')
|
||||
if (!isRecord(workflow.on)) throw new TypeError('request-review workflow must define events')
|
||||
if (!Array.isArray(job.steps)) throw new TypeError('request-review job must define steps')
|
||||
const steps = job.steps.filter(isRecord)
|
||||
const checkout = steps.find(step => step.name === 'Check out trusted review policy')
|
||||
const request = steps.find(step => step.name === 'Request reviewers')
|
||||
|
||||
expect(workflow.name).toBe('request-review')
|
||||
expect(Object.keys(workflow.on)).toEqual(['pull_request_target'])
|
||||
expect(event.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft'])
|
||||
expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'write' })
|
||||
expect(workflow.concurrency).toEqual({
|
||||
group: 'request-review-${{ github.event.pull_request.number }}',
|
||||
'cancel-in-progress': true,
|
||||
})
|
||||
expect(job).toMatchObject({
|
||||
name: 'request-review',
|
||||
'runs-on': 'ubuntu-latest',
|
||||
'timeout-minutes': 5,
|
||||
})
|
||||
expect(job).not.toHaveProperty('if')
|
||||
expect(checkout).toMatchObject({
|
||||
uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1',
|
||||
with: {
|
||||
ref: '${{ github.event.repository.default_branch }}',
|
||||
'persist-credentials': false,
|
||||
},
|
||||
})
|
||||
expect(request).toMatchObject({
|
||||
env: { GITHUB_TOKEN: '${{ github.token }}' },
|
||||
run: 'node .github/review-ownership/request-review.mjs',
|
||||
})
|
||||
expect(JSON.stringify(workflow)).not.toContain('github.event.pull_request.head')
|
||||
expect(JSON.stringify(workflow)).not.toContain('secrets.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue lifecycle workflow', () => {
|
||||
it('runs the lifecycle job on every PR/review event but gates token and board steps', () => {
|
||||
const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
|
||||
|
||||
@@ -68,6 +68,7 @@ export class RepositoryCleaner {
|
||||
const canonicalRoot = await realpath(this.root)
|
||||
|
||||
await this.addIfPresent(targets, join(this.root, '.dsh-build'), canonicalRoot)
|
||||
await this.addIfPresent(targets, join(this.root, 'apps/desktop/.desktop-build'), canonicalRoot)
|
||||
|
||||
// These checks cover legacy root-level incremental state emitted by older configs.
|
||||
await this.addIfPresent(targets, join(this.root, '.typecheck'), canonicalRoot)
|
||||
|
||||
@@ -99,6 +99,8 @@ describe('client bundle purity gate', () => {
|
||||
expect(resolveId('@deepseek-ai/dsh-token-meter/client')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-token-meter')).toThrow(/purity/)
|
||||
expect(() => resolveId('@deepseek-ai/dsh-token-meter/client/internal')).toThrow(/purity/)
|
||||
expect(resolveId('@deepseek-ai/dsh-host-open-in-app/shared')).toBeNull()
|
||||
expect(() => resolveId('@deepseek-ai/dsh-host-open-in-app')).toThrow(/purity/)
|
||||
})
|
||||
|
||||
it('admits only the pure spill notice entry, not its Host policy', () => {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Hold the advanced workflow child's first step until its parent records membership. */
|
||||
export const name = 'python-snapshot-workflow-order'
|
||||
|
||||
/**
|
||||
* @param {import('@deepseek-ai/cordis').Context} ctx - Scenario-local host context.
|
||||
* @param {{ parentSessionId: string, prompt: string }} config - Exact advanced scenario identities.
|
||||
*/
|
||||
export function apply(ctx, config) {
|
||||
const started = new Set()
|
||||
const pending = new Map()
|
||||
let disposed = false
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
disposed = true
|
||||
const waits = [...pending.values()]
|
||||
for (const wait of waits) wait.reject(new Error('workflow snapshot barrier disposed'))
|
||||
await Promise.allSettled(waits.map(wait => wait.done))
|
||||
started.clear()
|
||||
})
|
||||
ctx.on('session/event', (session, event) => {
|
||||
if (disposed || session.id !== config.parentSessionId || event.type !== 'tool-workflow/agent-start') return
|
||||
started.add(event.data.childId)
|
||||
pending.get(event.data.childId)?.resolve()
|
||||
})
|
||||
ctx.on('agent/pre-step', async ({ agent, messages, turn, step, signal }, next) => {
|
||||
if (agent.session.header.parentSession !== config.parentSessionId || turn !== 1 || step !== 1
|
||||
|| !messages.some(message => message.content.some(block => block.type === 'text' && block.text === config.prompt))) {
|
||||
return next()
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (disposed) throw new Error('workflow snapshot barrier disposed')
|
||||
if (!started.has(agent.id)) {
|
||||
const wait = Promise.withResolvers()
|
||||
const abort = () => { wait.reject(signal.reason) }
|
||||
signal.addEventListener('abort', abort, { once: true })
|
||||
wait.done = wait.promise.finally(() => {
|
||||
signal.removeEventListener('abort', abort)
|
||||
pending.delete(agent.id)
|
||||
})
|
||||
pending.set(agent.id, wait)
|
||||
await wait.done
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
if (disposed) throw new Error('workflow snapshot barrier disposed')
|
||||
return next()
|
||||
})
|
||||
}
|
||||
@@ -125,6 +125,7 @@ export const SERVICE_PAGE: Record<string, string> = {
|
||||
webhookRuntime: 'webhook.md',
|
||||
workspaceRegistry: 'workspace.md',
|
||||
workspaceController: 'workspace.md',
|
||||
workspaceFiles: 'workspace.md',
|
||||
directoryPickerController: 'workspace.md',
|
||||
}
|
||||
|
||||
@@ -178,6 +179,9 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
|
||||
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
|
||||
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
|
||||
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
|
||||
resources: 'client-side resource model (protocol providers, pins, live sources) — packages/client/resources/README.md owns the API',
|
||||
sidebarRight: 'client-side right-Sidebar navigation face — packages/client/ui-sidebar-right/README.md owns the API',
|
||||
sidebarRightTabs: 'client-side right-Sidebar tab-type registry — packages/client/ui-sidebar-right/README.md owns the API',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -257,6 +261,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
ContentBlock: 'llm-streaming.md',
|
||||
CreateAgentOptions: 'core.md',
|
||||
GenerateOptions: 'llm-streaming.md',
|
||||
Inbox: 'core.md',
|
||||
InboxItem: 'core.md',
|
||||
InboxPlacement: 'core.md',
|
||||
InspectorJsonValue: 'extensions.md',
|
||||
@@ -411,6 +416,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
CreateGoalRequest: 'goal.md',
|
||||
EditGoalRequest: 'goal.md',
|
||||
GoalBlockReason: 'goal.md',
|
||||
GoalActivationChanged: 'goal.md',
|
||||
GoalChanged: 'goal.md',
|
||||
GoalRef: 'goal.md',
|
||||
GoalView: 'goal.md',
|
||||
@@ -675,8 +681,11 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet<string> = new Set([
|
||||
'Promise',
|
||||
'Record',
|
||||
'Readonly',
|
||||
'Set',
|
||||
'ReadonlyMap',
|
||||
'ReadonlySet',
|
||||
'Request',
|
||||
'Response',
|
||||
'Set',
|
||||
'Uint8Array',
|
||||
])
|
||||
|
||||
@@ -767,6 +776,14 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkspaceByteRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceDirectoryListing: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileBytes: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileChange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileWatchFrame: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileRange: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileStat: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
WorkspaceFileText: 'Host workspace file endpoint contract is owned by packages/api/workspace-files/README.md',
|
||||
}
|
||||
|
||||
/** Repository data policy consumed by the Cordis catalog projector. */
|
||||
|
||||
@@ -10,7 +10,7 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const CLIENT_OUT = 'packages/extensions/cordis-client-runner/src/client/api-catalog.ts'
|
||||
|
||||
const CLIENT_SERVICES: Readonly<Record<string, readonly string[]>> = {
|
||||
layout: ['toggleSidebar', 'openDetails', 'closeDetails'],
|
||||
layout: ['toggleSidebar', 'openRightbar', 'closeRightbar'],
|
||||
locale: ['getLocale', 'getSnapshot', 'subscribe', 'setLocale', 'addLanguage', 'register', 'bind'],
|
||||
sessions: ['open', 'openSubagent', 'setSubagentCatalogOpen', 'refreshSubagents', 'search', 'fork', 'scope', 'binding'],
|
||||
slots: ['register', 'inject'],
|
||||
|
||||
@@ -192,6 +192,13 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
mode: 'core',
|
||||
note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.',
|
||||
},
|
||||
{
|
||||
key: 'workspaceFiles',
|
||||
pkg: 'api-workspace-files',
|
||||
title: 'Host workspace file Remote service',
|
||||
mode: 'core',
|
||||
note: 'Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session\'s workspace root, confined by lstat, containment, and a stat re-check.',
|
||||
},
|
||||
{
|
||||
key: 'workspaceController',
|
||||
pkg: 'api-workspace-controller',
|
||||
|
||||
@@ -450,7 +450,7 @@ describe('docsPages locale routes', () => {
|
||||
const translated = rootPages.filter(page => page.contentLocale === 'zh-CN')
|
||||
const fallbacks = rootPages.filter(page => page.contentLocale === 'en-US')
|
||||
|
||||
expect(translated).toHaveLength(46)
|
||||
expect(translated).toHaveLength(48)
|
||||
expect(translated.every(page => page.source.endsWith('.zh.md'))).toBe(true)
|
||||
expect(fallbacks).toEqual([])
|
||||
})
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { getEventListeners } from 'node:events'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { agentEvents, type Agent, type PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { WorkflowRunId } from '@deepseek-ai/dsh-workflow'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import SubagentRuntime from '@deepseek-ai/dsh-subagent'
|
||||
import * as spawn from '@deepseek-ai/dsh-subagent-spawn-in-process'
|
||||
import { MockAdapter, textResponse } from '../packages/core/agent-loop/tests/mock-adapter.ts'
|
||||
import type {} from '@deepseek-ai/dsh-tool-workflow'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
// @ts-expect-error Scenario plugins are runtime JavaScript without declaration artifacts.
|
||||
import * as fixtureModule from './fixtures/python-snapshot-workflow-order.mjs'
|
||||
|
||||
const config = { parentSessionId: 'advanced-parent', prompt: 'workflow child prompt' }
|
||||
const fixture = fixtureModule as unknown as {
|
||||
name: string
|
||||
apply(ctx: Context, config: { parentSessionId: string; prompt: string }): void
|
||||
}
|
||||
const cleanups: (() => Promise<unknown>)[] = []
|
||||
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
|
||||
|
||||
async function harness() {
|
||||
const ctx = new Context()
|
||||
const store = ctx.plugin(SessionStore)
|
||||
await store
|
||||
cleanups.push(() => store.dispose())
|
||||
const fiber = ctx.plugin(fixture, config)
|
||||
await fiber
|
||||
cleanups.push(() => fiber.dispose())
|
||||
const parent = ctx.sessions.create(SessionId(config.parentSessionId))
|
||||
const other = ctx.sessions.create(SessionId('other-parent'))
|
||||
const session = ctx.sessions.create(SessionId('workflow-child'), { meta: { parentSession: parent.id } })
|
||||
// The dispatcher needs only the subject identity; the fixture reads its Session.
|
||||
const agent = { id: session.id, session } as Agent
|
||||
const controller = new AbortController()
|
||||
const messages = [createUserMessage({ content: [{ type: 'text', text: config.prompt }], source: { kind: 'user' } })]
|
||||
const decision: PreStepDecision = { kind: 'enter', messages }
|
||||
const next = vi.fn(async () => decision)
|
||||
const start = (owner = parent, childId = agent.id) => owner.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId,
|
||||
})
|
||||
const step = (overrides = {}) => agentEvents(ctx, agent).waterfall('agent/pre-step', {
|
||||
turn: 1, step: 1, messages, signal: controller.signal, ...overrides,
|
||||
}, next)
|
||||
return { ctx, fiber, parent, other, session, agent, controller, decision, next, start, step }
|
||||
}
|
||||
|
||||
describe('advanced Python snapshot workflow ordering', () => {
|
||||
it('blocks a real spawned child before its descriptor and first model request', async () => {
|
||||
const ctx = new Context()
|
||||
const entered = Promise.withResolvers<Agent>()
|
||||
const order: string[] = []
|
||||
const adapter = new MockAdapter([textResponse('child complete')])
|
||||
const assembly = ctx.plugin({
|
||||
name: 'workflow-order-driver-test',
|
||||
async apply(inner: Context) {
|
||||
await mountAgentLoopTestDependencies(inner)
|
||||
await inner.plugin(AgentLoop, { agents: [] })
|
||||
await inner.plugin(SubagentRuntime)
|
||||
await inner.plugin(spawn, { providerName: 'spawn' })
|
||||
inner.on('agent/pre-step', ({ agent }, next) => {
|
||||
if (agent.session.header.parentSession === config.parentSessionId) entered.resolve(agent)
|
||||
return next()
|
||||
})
|
||||
inner.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool-workflow/agent-start' || event.type === 'subagent/descriptor') order.push(event.type)
|
||||
})
|
||||
await inner.plugin(fixture, config)
|
||||
},
|
||||
})
|
||||
cleanups.push(() => assembly.dispose())
|
||||
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.start('spawn', {
|
||||
parent, prompt: [{ type: 'text', text: config.prompt }], signal: new AbortController().signal,
|
||||
})
|
||||
cleanups.push(() => run.dispose())
|
||||
const child = await entered.promise
|
||||
expect(child.id).toBe(run.id)
|
||||
expect(adapter.requests).toHaveLength(0)
|
||||
expect(child.session.snapshotEvents().some(event => event.type === 'subagent/descriptor')).toBe(false)
|
||||
parent.session.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('run'), seq: 1, label: 'workflow-child', childId: child.id,
|
||||
})
|
||||
expect((await run.result).output).toEqual([{ type: 'text', text: 'child complete' }])
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
expect(order).toEqual(['tool-workflow/agent-start', 'subagent/descriptor'])
|
||||
})
|
||||
|
||||
it('holds the child until the exact parent records the exact member', async () => {
|
||||
const h = await harness()
|
||||
const pending = h.step()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(1)
|
||||
h.start(h.other)
|
||||
h.start(h.parent, SessionId('other-child'))
|
||||
h.parent.append('tool-workflow/run-start', { runId: WorkflowRunId('run'), name: 'workflow' })
|
||||
await Promise.resolve()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
h.start()
|
||||
expect(await pending).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('retains a start recorded before the child reaches its first step', async () => {
|
||||
const h = await harness()
|
||||
h.start()
|
||||
expect(await h.step()).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it.each(['prompt', 'parent', 'turn', 'step'])('does not hold an unrelated %s', async (difference) => {
|
||||
const h = await harness()
|
||||
const overrides = difference === 'prompt' ? { messages: [] }
|
||||
: difference === 'turn' ? { turn: 2 }
|
||||
: difference === 'step' ? { step: 2 } : {}
|
||||
if (difference === 'parent') {
|
||||
const session = h.ctx.sessions.create(SessionId('unrelated-child'), { meta: { parentSession: h.other.id } })
|
||||
Object.assign(h.agent, { session })
|
||||
}
|
||||
expect(await h.step(overrides)).toBe(h.decision)
|
||||
expect(h.next).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([false, true])('rejects cancellation and detaches the waiter (already aborted: %s)', async (alreadyAborted) => {
|
||||
const h = await harness()
|
||||
const reason = new Error('cancelled child')
|
||||
if (alreadyAborted) h.controller.abort(reason)
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toBe(reason)
|
||||
h.controller.abort(reason)
|
||||
await rejected
|
||||
h.start()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not admit a cancelled child when start and cancellation share a tick', async () => {
|
||||
const h = await harness()
|
||||
const reason = new Error('cancelled after membership')
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toBe(reason)
|
||||
h.start()
|
||||
h.controller.abort(reason)
|
||||
await rejected
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('settles pending waits before disposal completes and removes both listeners', async () => {
|
||||
const h = await harness()
|
||||
const pending = h.step()
|
||||
const rejected = expect(pending).rejects.toThrow('workflow snapshot barrier disposed')
|
||||
await h.fiber.dispose()
|
||||
await rejected
|
||||
h.start()
|
||||
expect(h.next).not.toHaveBeenCalled()
|
||||
expect(getEventListeners(h.controller.signal, 'abort')).toHaveLength(0)
|
||||
expect(await h.step()).toBe(h.decision)
|
||||
})
|
||||
})
|
||||
@@ -48,7 +48,7 @@ interface PlannedVersion {
|
||||
readonly tag: string | undefined
|
||||
}
|
||||
|
||||
/** One private dsh package whose version follows the publishable family. */
|
||||
/** One private dsh workspace whose version follows the publishable family. */
|
||||
interface PrivateDshVersion {
|
||||
/** Repository-relative manifest path. */
|
||||
readonly manifestPath: string
|
||||
@@ -243,13 +243,13 @@ function rootVersion(root: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover private package manifests that share the dsh version without joining
|
||||
* its publish set.
|
||||
* Discover private package and application manifests that share the dsh version
|
||||
* without joining its publish set.
|
||||
* @param root - repository root.
|
||||
* @returns Private package manifests sorted by path.
|
||||
* @returns Private workspace manifests sorted by path.
|
||||
*/
|
||||
function privateDshVersions(root: string): PrivateDshVersion[] {
|
||||
return globSync('packages/*/*/package.json', { cwd: root })
|
||||
return globSync(['apps/*/package.json', 'packages/*/*/package.json'], { cwd: root })
|
||||
.map(path => path.replaceAll('\\', '/'))
|
||||
.sort()
|
||||
.flatMap((manifestPath) => {
|
||||
|
||||
@@ -49,10 +49,20 @@ describe('release families', () => {
|
||||
expect(members.map(member => member.name)).not.toContain('@deepseek-ai/dsh-experimental-agent-team')
|
||||
})
|
||||
|
||||
it('bumps private dsh packages without adding release tags', () => {
|
||||
it('excludes private applications from the publish set', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-release-private-'))
|
||||
roots.push(root)
|
||||
write(join(root, 'apps/public/package.json'), '{"name":"@deepseek-ai/dsh-public","version":"0.0.1"}\n')
|
||||
write(join(root, 'apps/private/package.json'), '{"name":"@deepseek-ai/dsh-private","version":"0.0.1","private":true}\n')
|
||||
|
||||
expect(releaseFamily('dsh').members(root).map(entry => entry.name)).toEqual(['@deepseek-ai/dsh-public'])
|
||||
})
|
||||
|
||||
it('bumps private dsh workspaces without adding release tags', () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-release-version-'))
|
||||
roots.push(root)
|
||||
write(join(root, 'package.json'), '{"version":"0.0.1"}\n')
|
||||
write(join(root, 'apps/desktop/package.json'), '{"version":"0.0.1","private":true}\n')
|
||||
write(join(root, 'packages/experimental/prototype/package.json'), '{"version":"0.0.1","private":true}\n')
|
||||
write(join(root, 'packages/core/unselected/package.json'), '{"version":"0.0.1"}\n')
|
||||
|
||||
@@ -63,6 +73,7 @@ describe('release families', () => {
|
||||
expect(planned.map(entry => ({ path: entry.manifestPath, tag: entry.tag }))).toEqual([
|
||||
{ path: 'package.json', tag: undefined },
|
||||
{ path: 'packages/core/published/package.json', tag: 'dsh-v0.0.2' },
|
||||
{ path: 'apps/desktop/package.json', tag: undefined },
|
||||
{ path: 'packages/experimental/prototype/package.json', tag: undefined },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -117,7 +117,7 @@ export abstract class ReleaseFamily {
|
||||
/**
|
||||
* Discover this family's members.
|
||||
* @param root - repository root.
|
||||
* @returns Members sorted by directory, with names validated and deduplicated.
|
||||
* @returns Publishable members sorted by directory, with names validated and deduplicated.
|
||||
*/
|
||||
members(root: string): ReleaseMember[] {
|
||||
const manifestPaths = globSync([...this.patterns], { cwd: root }).sort()
|
||||
@@ -128,6 +128,7 @@ export abstract class ReleaseFamily {
|
||||
for (const manifestPath of manifestPaths) {
|
||||
const normalized = manifestPath.replaceAll('\\', '/')
|
||||
const manifest = readManifest(resolve(root, manifestPath))
|
||||
if (manifest.private === true) continue
|
||||
const name = requireString(manifest, 'name', normalized)
|
||||
const version = requireString(manifest, 'version', normalized)
|
||||
if (name === WORKSPACE_ROOT_PACKAGE) throw new Error(`${normalized} selected the workspace root`)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { pnpmInvocation } from '../pnpm-invocation.ts'
|
||||
import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts'
|
||||
import { isEntry, runConcurrent } from './process.ts'
|
||||
import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts'
|
||||
@@ -25,7 +26,8 @@ const DEFAULT_OUTPUT = 'dist/npm'
|
||||
* @returns The tarball filename.
|
||||
*/
|
||||
async function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): Promise<string> {
|
||||
await runConcurrent('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination])
|
||||
const invocation = pnpmInvocation(['--dir', member.directory, 'pack', '--pack-destination', destination])
|
||||
await runConcurrent(invocation.command, invocation.args)
|
||||
|
||||
const filename = tarballName(member)
|
||||
const tarball = join(destination, filename)
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface PackedIdentity {
|
||||
* @returns Every path inside the archive.
|
||||
*/
|
||||
export function tarballFiles(tarball: string): string[] {
|
||||
return capture('tar', ['-tzf', tarball]).split('\n').filter(line => line !== '')
|
||||
return capture('tar', ['-tzf', tarball]).split(/\r?\n/u).filter(line => line !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -172,6 +172,12 @@ describe('gate graph validation', () => {
|
||||
expect(ids).toContain('subsystem-pages')
|
||||
})
|
||||
|
||||
it('keeps the package README Summary limit in the documentation gate', () => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode('doc-sync').map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('package-readme-summaries')
|
||||
})
|
||||
|
||||
it('derives the quick documentation aggregate from marked doc-sync leaves', () => {
|
||||
const full = withPnpmEntrypoint(() => gatesForMode('doc-sync'))
|
||||
const quick = withPnpmEntrypoint(() => gatesForMode('doc-quick'))
|
||||
@@ -240,6 +246,15 @@ describe('gate graph validation', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
|
||||
'keeps review request policy tests in %s',
|
||||
(mode) => {
|
||||
const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
|
||||
|
||||
expect(ids).toContain('request-review')
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['ci-primary', 'ci-static', 'check-all', 'hygiene'] as const)(
|
||||
'keeps hard-coded Client UI copy enforcement in %s',
|
||||
(mode) => {
|
||||
@@ -548,6 +563,7 @@ describe('Node 24 lane ownership', () => {
|
||||
})
|
||||
expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual(
|
||||
expect.arrayContaining([
|
||||
'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
'packages/experimental/agent-team/tests/built-lib.e2e.ts',
|
||||
|
||||
@@ -267,6 +267,7 @@ export function gatesForMode(selected: Mode): Gate[] {
|
||||
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
|
||||
pnpmScript('duplication', 'duplication'),
|
||||
snapshotGate(),
|
||||
expectedOutputGate(),
|
||||
@@ -310,6 +311,7 @@ function ciSharedStaticGates(): Gate[] {
|
||||
pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
|
||||
pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
|
||||
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
|
||||
pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -745,6 +747,7 @@ function docSyncLeafGates(options: {
|
||||
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
|
||||
pnpmScript('tsconfig-paths', 'verify-tsconfig-paths', { label: 'tsconfig paths' }),
|
||||
pnpmScript('config-source-ownership', 'verify-config-source-ownership', { label: 'config source ownership' }),
|
||||
pnpmScript('package-readme-summaries', 'verify-package-readme-summaries', { label: 'package README Summaries', quick: true }),
|
||||
pnpmScript('package-readme-model-experience', 'verify-package-readme-model-experience', { label: 'package README model experience', quick: true }),
|
||||
pnpmScript('agent-note-classification', 'verify-agent-note-classification', { label: 'agent note classification', quick: true }),
|
||||
pnpmScript('agent-note-format', 'verify-agent-note-format', { label: 'agent note format', quick: true }),
|
||||
@@ -782,6 +785,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
||||
'apps/cli/tests/built-bin.e2e.ts',
|
||||
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
|
||||
'packages/sdk/server/tests/built-scope-carrier.e2e.ts',
|
||||
'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts',
|
||||
'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts',
|
||||
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
|
||||
'packages/api/remotes/tests/built-lib.e2e.ts',
|
||||
|
||||
+149
-25
@@ -11,6 +11,7 @@ import json
|
||||
import os
|
||||
import queue
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -769,7 +770,7 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--scenario",
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-live", "direct"),
|
||||
choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-spawn-node", "sdk-mcp", "sdk-snapshot", "sdk-restart", "sdk-profile-plugin", "sdk-live", "runner", "direct"),
|
||||
default="all",
|
||||
)
|
||||
parser.add_argument("--exe", type=Path)
|
||||
@@ -788,13 +789,20 @@ def main() -> None:
|
||||
parser.error("--scenario sdk-profile-plugin requires --installed-wheel")
|
||||
if args.installed_wheel:
|
||||
args.exe = assert_installed_wheel_environment()
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios")
|
||||
if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-fs-search", "sdk-spawn-node", "sdk-snapshot", "sdk-restart", "runner", "direct"} and args.exe is None:
|
||||
parser.error("--exe is required for custom, minimal, fs-search, spawn-node, snapshot, restart, runner, and direct scenarios")
|
||||
if args.update_snapshots and args.scenario not in {"all", "sdk-minimal", "sdk-snapshot", "sdk-restart"}:
|
||||
parser.error("--update-snapshots requires --scenario sdk-minimal, sdk-snapshot, sdk-restart, or all")
|
||||
if args.exe is not None and not args.exe.is_file():
|
||||
parser.error(f"runtime executable does not exist: {args.exe}")
|
||||
|
||||
if args.scenario in {"all", "runner"}:
|
||||
assert args.exe is not None
|
||||
smoke_packaged_runner(args.exe.resolve())
|
||||
if args.scenario == "runner":
|
||||
print("smoke-python-runtime: runner passed")
|
||||
return
|
||||
|
||||
if args.scenario == "sdk-live":
|
||||
smoke_sdk_live()
|
||||
print("smoke-python-runtime: sdk-live passed")
|
||||
@@ -908,8 +916,9 @@ def smoke_sdk_live() -> None:
|
||||
f"containing {LIVE_API_SENTINEL}. Then reply with exactly {LIVE_API_SENTINEL}.\n{marker}"
|
||||
)
|
||||
verify_prompt = (
|
||||
"Use a tool to read the file created in the previous turn. "
|
||||
f"If its only line is {LIVE_API_SENTINEL}, reply with exactly {LIVE_API_SENTINEL}."
|
||||
"The file created in the previous turn now contains a new value written by the test. "
|
||||
"Use a tool to read its current contents without modifying it. "
|
||||
"Reply with exactly its current single line, not the value from the previous turn."
|
||||
)
|
||||
with DeepSeekHarness(
|
||||
provider="deepseek-official",
|
||||
@@ -925,34 +934,46 @@ def smoke_sdk_live() -> None:
|
||||
request_timeout_seconds=180,
|
||||
) as harness:
|
||||
created = harness.run(create_prompt, session_id=session_id)
|
||||
assert_live_turn("create", created, LIVE_API_SENTINEL)
|
||||
if not marker.is_file():
|
||||
raise AssertionError(f"real-model tool turn did not create {marker}")
|
||||
if marker.read_text(encoding="utf-8").splitlines() != [LIVE_API_SENTINEL]:
|
||||
raise AssertionError(f"real-model tool turn wrote unexpected text to {marker}")
|
||||
# Only a fresh filesystem observation can reveal the verification answer.
|
||||
verification_value = secrets.token_hex(16)
|
||||
marker.write_text(verification_value + "\n", encoding="utf-8")
|
||||
verified = harness.run(verify_prompt, session_id=session_id)
|
||||
assert_live_turn("verify", verified, verification_value)
|
||||
|
||||
for label, result in (("create", created), ("verify", verified)):
|
||||
if result.finish_reason != "completed":
|
||||
event_types = [event.get("type") for event in result.events]
|
||||
turn_end_data = next(
|
||||
(event.get("data") for event in reversed(result.events) if event.get("type") == "turn/end"),
|
||||
None,
|
||||
)
|
||||
turn_end = safe_turn_end(turn_end_data)
|
||||
raise AssertionError(
|
||||
f"{label} turn ended with {result.finish_reason!r}; "
|
||||
f"final={result.final_response!r}; turn_end={turn_end!r}; events={event_types}"
|
||||
)
|
||||
if not any(event.get("type") == "tool/call" for event in result.events):
|
||||
raise AssertionError(
|
||||
f"{label} turn made no model-requested tool call; "
|
||||
f"final={result.final_response!r}"
|
||||
)
|
||||
if result.final_response.strip() != LIVE_API_SENTINEL:
|
||||
raise AssertionError(f"{label} turn returned {result.final_response!r}")
|
||||
if not marker.is_file():
|
||||
raise AssertionError(f"real-model tool turn did not create {marker}")
|
||||
if marker.read_text(encoding="utf-8").splitlines() != [LIVE_API_SENTINEL]:
|
||||
if marker.read_text(encoding="utf-8").splitlines() != [verification_value]:
|
||||
raise AssertionError(f"real-model tool turn wrote unexpected text to {marker}")
|
||||
assert_zstd_session_log(sessions)
|
||||
|
||||
|
||||
def assert_live_turn(label: str, result: RunResult, expected: str) -> None:
|
||||
"""Require a completed model-requested tool turn with the expected response."""
|
||||
if result.finish_reason != "completed":
|
||||
event_types = [event.get("type") for event in result.events]
|
||||
turn_end_data = next(
|
||||
(event.get("data") for event in reversed(result.events) if event.get("type") == "turn/end"),
|
||||
None,
|
||||
)
|
||||
turn_end = safe_turn_end(turn_end_data)
|
||||
raise AssertionError(
|
||||
f"{label} turn ended with {result.finish_reason!r}; "
|
||||
f"final={result.final_response!r}; turn_end={turn_end!r}; events={event_types}"
|
||||
)
|
||||
if not any(event.get("type") == "tool/call" for event in result.events):
|
||||
raise AssertionError(
|
||||
f"{label} turn made no model-requested tool call; "
|
||||
f"final={result.final_response!r}"
|
||||
)
|
||||
if result.final_response.strip() != expected:
|
||||
raise AssertionError(f"{label} turn returned {result.final_response!r}")
|
||||
|
||||
|
||||
def safe_turn_end(value: object) -> object:
|
||||
"""Project a live-provider failure without retaining credential-bearing text."""
|
||||
if not isinstance(value, dict):
|
||||
@@ -1271,6 +1292,11 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool)
|
||||
sessions = dsh_home / "sessions"
|
||||
patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions)
|
||||
feedback_patch = write_profile_patch(root, "feedback.patch.yml", sessions, [{"insert": [
|
||||
{"id": "snapshot-workflow-order", "name": (
|
||||
Path(__file__).resolve().parent / "fixtures/python-snapshot-workflow-order.mjs"
|
||||
).as_uri(), "config": {
|
||||
"parentSessionId": SNAPSHOT_SESSION_ID, "prompt": SNAPSHOT_WORKFLOW_CHILD_PROMPT,
|
||||
}},
|
||||
{"id": "snapshot-message-feedback", "name": "@deepseek-ai/dsh-message-feedback",
|
||||
"config": {"maxNoteBytes": 1024}},
|
||||
{"id": "snapshot-feedback-producer", "name": (
|
||||
@@ -1428,6 +1454,104 @@ def smoke_direct(base_url: str, executable: Path) -> None:
|
||||
assert_session_log(sessions, root, EXPECTED_TEXT)
|
||||
|
||||
|
||||
def smoke_packaged_runner(executable: Path) -> None:
|
||||
"""Exercise the private subprocess runner through the single-file entry."""
|
||||
with tempfile.TemporaryDirectory(prefix="dsh-packaged-runner-") as temporary:
|
||||
root = Path(temporary).resolve()
|
||||
target_script = (
|
||||
"import os,sys; "
|
||||
"ok = (os.getcwd() == os.environ['PACKAGED_RUNNER_EXPECTED_CWD'] "
|
||||
"and os.environ.get('DSH_SUBPROCESS_RUNNER') == 'target-collision-restored'); "
|
||||
"sys.exit(7 if ok else 9)"
|
||||
)
|
||||
if not IS_WINDOWS:
|
||||
request_path = root / "launch-request.json"
|
||||
target_env = dict(os.environ)
|
||||
target_env["DSH_SUBPROCESS_RUNNER"] = "target-collision-restored"
|
||||
target_env["PACKAGED_RUNNER_EXPECTED_CWD"] = str(root)
|
||||
request_path.write_text(
|
||||
json.dumps({"cwd": str(root), "env": target_env}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
request_path.chmod(0o600)
|
||||
environment = dict(os.environ)
|
||||
environment["DSH_SUBPROCESS_RUNNER"] = str(request_path)
|
||||
result = subprocess.run(
|
||||
[str(executable), "--", sys.executable, "-c", target_script],
|
||||
cwd=root,
|
||||
env=environment,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 7 or request_path.exists() or (root / "startup-error.json").exists():
|
||||
raise AssertionError(
|
||||
"packaged POSIX runner failed: "
|
||||
f"exit={result.returncode}; stdout={result.stdout!r}; stderr={result.stderr!r}"
|
||||
)
|
||||
return
|
||||
|
||||
node = shutil.which("node")
|
||||
if node is None:
|
||||
raise AssertionError("packaged Windows runner smoke requires node on PATH")
|
||||
helper = root / "windows-runner-smoke.mjs"
|
||||
helper.write_text(
|
||||
"""import { spawn } from 'node:child_process'
|
||||
const [runtime, target, cwd, targetScript] = process.argv.slice(2)
|
||||
const child = spawn(runtime, ['--', target, '-c', targetScript], {
|
||||
cwd,
|
||||
env: { ...process.env, DSH_SUBPROCESS_RUNNER: 'windows' },
|
||||
stdio: ['ignore', 'ignore', 'ignore', 'ipc', 'pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const messages = []
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdio[4].destroy()
|
||||
child.stdio[5].on('data', chunk => { stdout += chunk.toString() })
|
||||
child.stdio[6].on('data', chunk => { stderr += chunk.toString() })
|
||||
child.on('message', message => { messages.push(message) })
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('spawn', () => {
|
||||
child.send({
|
||||
type: 'start',
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
DSH_SUBPROCESS_RUNNER: 'target-collision-restored',
|
||||
PACKAGED_RUNNER_EXPECTED_CWD: cwd,
|
||||
},
|
||||
}, error => { if (error) reject(error) })
|
||||
})
|
||||
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
||||
})
|
||||
process.stdout.write(JSON.stringify({ ...result, messages, stdout, stderr }))
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
helper_result = subprocess.run(
|
||||
[node, str(helper), str(executable), sys.executable, str(root), target_script],
|
||||
cwd=root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if helper_result.returncode != 0:
|
||||
raise AssertionError(f"packaged Windows runner helper failed: {helper_result.stderr}")
|
||||
observed = json.loads(helper_result.stdout)
|
||||
expected = {
|
||||
"exitCode": 0,
|
||||
"signal": None,
|
||||
"messages": [{"type": "target-exit", "exitCode": 7}],
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
}
|
||||
if observed != expected:
|
||||
raise AssertionError(f"packaged Windows runner returned unexpected facts: {observed}")
|
||||
|
||||
|
||||
def is_idle_notification(message: dict[str, object]) -> bool:
|
||||
"""Return whether a JSON-RPC notification marks a session idle."""
|
||||
params = message.get("params")
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as adapter from './snapshot-shell-path.ts'
|
||||
|
||||
it.skipIf(process.platform === 'win32')('translates only the exact fixture command and preserves real shell failures', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'snapshot-shell-'))
|
||||
const ctx = new Context()
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
try {
|
||||
const subprocess = ctx.plugin(LocalSubprocessRuntime)
|
||||
disposers.push(() => subprocess.dispose())
|
||||
await subprocess
|
||||
const bash = ctx.plugin(LocalBashExecutor)
|
||||
disposers.push(() => bash.dispose())
|
||||
await bash
|
||||
const command = "printf 'actual bytes' > /fixture/recorded.txt"
|
||||
const livePath = join(root, "space and 'quote.txt")
|
||||
const fork = ctx.plugin(adapter, { command, recordedPath: '/fixture/recorded.txt', livePath })
|
||||
disposers.push(() => fork.dispose())
|
||||
await fork
|
||||
const outcome = await ctx.shell.run(ctx.shell.resolve({ command }))
|
||||
expect(outcome.timedOut).toBe(false)
|
||||
expect(outcome.exitCode).toBe(0)
|
||||
expect(await readFile(livePath, 'utf8')).toBe('actual bytes')
|
||||
const untouched = await ctx.shell.run(ctx.shell.resolve({ command: "printf '/fixture/recorded.txt'; exit 7" }))
|
||||
expect(untouched.exitCode).toBe(7)
|
||||
expect(untouched.stdout.text).toBe('/fixture/recorded.txt')
|
||||
await fork.dispose()
|
||||
await rm(livePath)
|
||||
const restored = await ctx.shell.run(ctx.shell.resolve({ command }))
|
||||
expect(restored.exitCode).not.toBe(0)
|
||||
await expect(readFile(livePath)).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
} finally {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Exact recorded shell-command path translation; execution and reported outcomes remain real. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-shell'
|
||||
|
||||
export const name = 'snapshot-shell-path'
|
||||
export const inject = ['shell']
|
||||
|
||||
/** One recorded command and its isolated live filesystem target. */
|
||||
export interface Config {
|
||||
command: string
|
||||
recordedPath: string
|
||||
livePath: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate one exact fixture command after tool logging and approval.
|
||||
* @param ctx - profile context with its real shell executor.
|
||||
* @param config - recorded command/path and allocated live target.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const shell = ctx.shell
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const run = shell.run
|
||||
ctx.effect(() => {
|
||||
shell.run = spec => run.call(shell, spec.command === config.command
|
||||
? { ...spec, command: spec.command.replaceAll(config.recordedPath, "'" + config.livePath.replaceAll("'", "'\"'\"'") + "'") }
|
||||
: spec)
|
||||
return () => { shell.run = run }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { LocalSpillStore } from '@deepseek-ai/dsh-spill-local'
|
||||
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import * as locators from './snapshot-spill-locators.ts'
|
||||
|
||||
it.each([false, true])('keeps concurrent physical spill files private while retaining logical locator length and bytes (reverse allocation=%s)', async (reverseAllocation) => {
|
||||
const roots: string[] = []
|
||||
const disposers: (() => Promise<void>)[] = []
|
||||
const secondAllocated = Promise.withResolvers<undefined>()
|
||||
try {
|
||||
const runs = await Promise.all([0, 1].map(async (index) => {
|
||||
if (reverseAllocation && index === 0) await secondAllocated.promise
|
||||
const root = await mkdtemp(join(tmpdir(), 'snapshot-locator-'))
|
||||
roots.push(root)
|
||||
if (index === 1) secondAllocated.resolve(undefined)
|
||||
const ctx = new Context()
|
||||
const storeFiber = ctx.plugin(LocalSpillStore, { root, cleanupPeriodDays: 0 })
|
||||
disposers.push(() => storeFiber.dispose())
|
||||
await storeFiber
|
||||
const fsFiber = ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
disposers.push(() => fsFiber.dispose())
|
||||
await fsFiber
|
||||
|
||||
|
||||
const locatorRoot = resolve('/tmp/dsh-acp-snap-123456789')
|
||||
const fork = ctx.plugin(locators, { root, locatorRoot })
|
||||
disposers.push(() => fork.dispose())
|
||||
await fork
|
||||
const content = `physical UTF-8 内容 ${index}`
|
||||
const ref = await ctx.spillStore.saveText({
|
||||
owner: { sessionId: SessionId('same-session') },
|
||||
source: { kind: 'tool', toolName: 'bash', callId: ToolCallId('same-call'), label: 'result' },
|
||||
suggestedName: 'bash.txt', content,
|
||||
})
|
||||
expect(ref.bytes).toBe(Buffer.byteLength(content))
|
||||
expect(ref.locator.startsWith(locatorRoot)).toBe(true)
|
||||
const target = await ctx.fs.resolve(ref.locator)
|
||||
expect(target.displayPath).toBe(ref.locator)
|
||||
const physicalPath = ctx.fs.processPath(target)
|
||||
expect(physicalPath.startsWith(await realpath(root))).toBe(true)
|
||||
expect(await readFile(physicalPath, 'utf8')).toBe(content)
|
||||
expect(await ctx.fs.readText(target)).toBe(content)
|
||||
await expect(ctx.fs.resolve(join(locatorRoot, 'missing.txt'))).rejects.toThrow('not saved by this run')
|
||||
const ordinary = join(root, 'ordinary.txt')
|
||||
await writeFile(ordinary, 'ordinary')
|
||||
expect(await ctx.fs.readText(await ctx.fs.resolve(ordinary))).toBe('ordinary')
|
||||
await fork.dispose()
|
||||
const restored = await ctx.fs.resolve(ref.locator)
|
||||
expect(ctx.fs.processPath(restored)).not.toBe(physicalPath)
|
||||
expect(await ctx.fs.stat(restored)).toBeUndefined()
|
||||
return { root, physicalPath, locator: ref.locator, content }
|
||||
}))
|
||||
expect(runs[0]?.physicalPath).not.toBe(runs[1]?.physicalPath)
|
||||
expect(runs[0]?.locator.length).toBe(runs[1]?.locator.length)
|
||||
// Promise.all preserves input order; allocation completion order may differ.
|
||||
await rm(runs[0]!.root, { recursive: true, force: true })
|
||||
expect(await readFile(runs[1]?.physicalPath as string, 'utf8')).toBe(runs[1]?.content)
|
||||
} finally {
|
||||
for (const dispose of disposers.reverse()) await dispose()
|
||||
await Promise.all(roots.map(root => rm(root, { recursive: true, force: true })))
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/** Fixture-only logical locators over real local spill files; preview budgets retain recorded path lengths. */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { join, relative, resolve, sep } from 'node:path'
|
||||
import type { SpillLocator } from '@deepseek-ai/dsh-spill'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
|
||||
export const name = 'snapshot-spill-locators'
|
||||
export const inject = ['spillStore', 'fs']
|
||||
|
||||
/** Live storage and recorded locator prefixes supplied by the snapshot owner. */
|
||||
export interface Config {
|
||||
root: string
|
||||
locatorRoot: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate only locators returned by this fixture's real spill backend.
|
||||
* @param ctx - profile context with real spill and filesystem providers.
|
||||
* @param config - per-run storage and stable logical prefix.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const root = resolve(config.root)
|
||||
const locatorRoot = resolve(config.locatorRoot)
|
||||
const paths = new Map<string, string>()
|
||||
const store = ctx.spillStore
|
||||
const fs = ctx.fs
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const saveText = store.saveText
|
||||
// oxlint-disable-next-line typescript/unbound-method -- preserve method identity for restoration; calls bind the receiver.
|
||||
const resolvePath = fs.resolve
|
||||
ctx.effect(() => {
|
||||
store.saveText = async (input) => {
|
||||
const saved = await saveText.call(store, input)
|
||||
const suffix = relative(root, saved.locator)
|
||||
if (suffix.startsWith('..') || resolve(root, suffix) !== saved.locator) {
|
||||
throw new Error('snapshot spill backend returned a locator outside its live root')
|
||||
}
|
||||
const locator = join(locatorRoot, suffix) as SpillLocator
|
||||
paths.set(locator, saved.locator)
|
||||
return { ...saved, locator }
|
||||
}
|
||||
fs.resolve = async (path, opts) => {
|
||||
const live = paths.get(path)
|
||||
if (live !== undefined) {
|
||||
const target = await resolvePath.call(fs, live, opts)
|
||||
return { ...target, displayPath: path }
|
||||
}
|
||||
if (path.startsWith(locatorRoot + sep)) {
|
||||
throw new Error('snapshot spill locator was not saved by this run')
|
||||
}
|
||||
return resolvePath.call(fs, path, opts)
|
||||
}
|
||||
return () => {
|
||||
store.saveText = saveText
|
||||
fs.resolve = resolvePath
|
||||
paths.clear()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink } from 'node:fs/promises'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, join, parse } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { SandboxedFileSystem } from '@deepseek-ai/dsh-fs-sandbox'
|
||||
import { canonicalPath } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assertWorkspaceOutsideTemp, outsideTempWorkspaceParent } from './snapshot-workspace-parent.ts'
|
||||
|
||||
// Host disk exhaustion is not simulated: placement and the real write fence are the regression oracles.
|
||||
describe('snapshot workspace parent', () => {
|
||||
// Windows directory permissions and root bypass do not enforce POSIX write bits.
|
||||
it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)('uses home when the temp parent is not writable', async () => {
|
||||
const base = await mkdtemp(join(outsideTempWorkspaceParent(), '.dsh-snapshot-readonly-'))
|
||||
try {
|
||||
const temporary = join(base, '_temp')
|
||||
await mkdir(temporary)
|
||||
await chmod(base, 0o500)
|
||||
expect(outsideTempWorkspaceParent(temporary)).toBe(homedir())
|
||||
} finally {
|
||||
await chmod(base, 0o700)
|
||||
await rm(base, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('uses home when a temp sibling would require a system directory or inherit its grant', () => {
|
||||
expect(outsideTempWorkspaceParent('/tmp')).toBe(homedir())
|
||||
expect(outsideTempWorkspaceParent(parse(tmpdir()).root)).toBe(homedir())
|
||||
expect(outsideTempWorkspaceParent(join(canonicalPath('/tmp'), 'runner', '_temp'))).toBe(homedir())
|
||||
})
|
||||
|
||||
it('rejects automatically writable temporary workspaces, including symlink aliases', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-snapshot-parent-'))
|
||||
try {
|
||||
expect(() => { assertWorkspaceOutsideTemp(root) }).toThrow('must be outside temporary writable root')
|
||||
const alias = join(root, 'alias')
|
||||
await symlink(tmpdir(), alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
expect(() => { assertWorkspaceOutsideTemp(alias) }).toThrow('must be outside temporary writable root')
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('allows the allocated workspace but denies sibling writes and cleans the complete tree', async () => {
|
||||
const base = await mkdtemp(join(outsideTempWorkspaceParent(), 'dsh-snapshot-parent-'))
|
||||
const ctx = new Context()
|
||||
const fibers: Awaited<ReturnType<Context['plugin']>>[] = []
|
||||
try {
|
||||
const temporary = join(base, '_temp')
|
||||
await mkdir(temporary)
|
||||
expect(outsideTempWorkspaceParent(temporary)).toBe(canonicalPath(base))
|
||||
const workspace = await mkdtemp(join(outsideTempWorkspaceParent(temporary), 'workspace-'))
|
||||
const outside = join(base, 'outside.txt')
|
||||
assertWorkspaceOutsideTemp(workspace)
|
||||
expect(dirname(workspace)).toBe(canonicalPath(base))
|
||||
expect((await stat(workspace)).dev).toBe((await stat(temporary)).dev)
|
||||
fibers.push(await ctx.plugin(SessionProjectionRegistry))
|
||||
fibers.push(await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: workspace }))
|
||||
fibers.push(await ctx.plugin(SandboxedFileSystem, { cwd: workspace }))
|
||||
const inside = join(workspace, 'inside.txt')
|
||||
await ctx.fs.writeText(await ctx.fs.resolve(inside), 'inside')
|
||||
expect(await readFile(inside, 'utf8')).toBe('inside')
|
||||
await expect(ctx.fs.writeText(await ctx.fs.resolve(outside), 'outside'))
|
||||
.rejects.toMatchObject({ code: 'FS_SANDBOX_DENIED' })
|
||||
expect(existsSync(outside)).toBe(false)
|
||||
} finally {
|
||||
try {
|
||||
for (const fiber of fibers.reverse()) await fiber.dispose()
|
||||
} finally {
|
||||
await rm(base, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
expect(existsSync(base)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/** Workspace placement for snapshots that must not inherit temporary-directory write grants. */
|
||||
|
||||
import { accessSync, constants } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { dirname, isAbsolute, parse, relative, sep } from 'node:path'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
|
||||
function contains(root: string, path: string): boolean {
|
||||
const suffix = relative(root, path)
|
||||
return suffix === '' || suffix !== '..' && !suffix.startsWith('..' + sep) && !isAbsolute(suffix)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a writable temp sibling parent, or home when that parent is unavailable for writes.
|
||||
* The caller atomically allocates and owns cleanup of the generated workspace.
|
||||
* @param tempRoot - platform temporary directory.
|
||||
* @param home - fallback when temp siblings require a system directory or a non-writable parent.
|
||||
* @returns existing parent outside the automatic temporary write grants.
|
||||
*/
|
||||
export function outsideTempWorkspaceParent(tempRoot = tmpdir(), home = homedir()): string {
|
||||
const temporary = canonicalPath(tempRoot)
|
||||
const systemTemporary = canonicalPath('/tmp')
|
||||
const parent = dirname(temporary)
|
||||
if (temporary === systemTemporary || parent === parse(parent).root || contains(systemTemporary, parent)) return home
|
||||
try {
|
||||
accessSync(parent, constants.W_OK)
|
||||
} catch (error) {
|
||||
// A non-writable parent cannot host siblings; allocation failures still propagate from mkdtemp.
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') return home
|
||||
throw error
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a workspace whose write could succeed without the session's workspace grant.
|
||||
* @param cwd - allocated workspace to check, with symlinks resolved before comparison.
|
||||
* @returns nothing; throws when an automatic temporary write grant contains the workspace.
|
||||
*/
|
||||
export function assertWorkspaceOutsideTemp(cwd: string): void {
|
||||
const path = canonicalPath(cwd)
|
||||
for (const root of writableRoots({ mode: 'workspace-write', workspaceRoot: '/tmp' })) {
|
||||
if (contains(root, path)) throw new Error('snapshot workspace ' + cwd + ' must be outside temporary writable root ' + root)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/** Scheduling policy for post-merge native runtime carriers and Wine. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { load } from 'js-yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { gatesForMode } from '../run-gates.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const masterPush = "github.event_name == 'push' && github.ref == 'refs/heads/master'"
|
||||
const runtimeBuilder = './.github/workflows/build-exe-for-python-sdk.yml'
|
||||
|
||||
interface Job {
|
||||
if?: string | boolean
|
||||
uses?: string
|
||||
needs?: string[]
|
||||
with?: Record<string, unknown>
|
||||
secrets?: Record<string, string>
|
||||
steps?: Array<{ name?: string; run?: string; if?: string; uses?: string; with?: Record<string, unknown> }>
|
||||
'runs-on'?: string | string[]
|
||||
'continue-on-error'?: boolean
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
on: Record<string, unknown>
|
||||
jobs: Record<string, Job>
|
||||
concurrency?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function workflow(name: string): Workflow {
|
||||
return load(readFileSync(resolve(root, '.github/workflows', name), 'utf8')) as Workflow
|
||||
}
|
||||
|
||||
function commands(job: Job): string[] {
|
||||
return (job.steps ?? []).flatMap(step => step.run ? [step.run] : [])
|
||||
}
|
||||
|
||||
describe('master-only platform scheduling', () => {
|
||||
it('keeps only Linux and Windows x64 runtimes in required PR CI', () => {
|
||||
const pr = workflow('ci.yml')
|
||||
expect(Object.keys(pr.on)).toEqual(['pull_request'])
|
||||
expect(pr.jobs['python-runtime']).toMatchObject({
|
||||
if: "github.event_name == 'pull_request'",
|
||||
uses: runtimeBuilder,
|
||||
with: { ci: true, targets: 'node24-linux-x64,node24-win-x64' },
|
||||
})
|
||||
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']!
|
||||
expect(aggregate.needs).toContain('python-runtime')
|
||||
expect(aggregate.needs).not.toContain('windows')
|
||||
expect(aggregate.needs!.every(id => id in pr.jobs)).toBe(true)
|
||||
expect(aggregate.if).toBe("always() && github.event_name == 'pull_request'")
|
||||
expect(aggregate.steps).toContainEqual(expect.objectContaining({
|
||||
if: "contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')",
|
||||
}))
|
||||
})
|
||||
|
||||
it('runs all three deferred carriers on master pushes with fail-loud API credentials', () => {
|
||||
const master = workflow('ci-master.yml')
|
||||
expect(master.on.push).toEqual({ branches: ['master'] })
|
||||
expect(Object.keys(master.on).sort()).toEqual(['push', 'workflow_dispatch'])
|
||||
const runtime = master.jobs['python-runtime']!
|
||||
expect(runtime).toMatchObject({
|
||||
if: masterPush,
|
||||
uses: runtimeBuilder,
|
||||
with: { ci: true, targets: 'node24-linux-arm64,node24-macos-arm64,node24-macos-x64' },
|
||||
secrets: { DEEPSEEK_API_KEY_EXTERNAL: '${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}' },
|
||||
})
|
||||
expect(runtime.needs).toBeUndefined()
|
||||
expect(runtime['continue-on-error']).toBeUndefined()
|
||||
const builder = workflow('build-exe-for-python-sdk.yml')
|
||||
expect(builder.concurrency?.['cancel-in-progress']).toBe(
|
||||
"${{ github.event_name != 'push' || github.ref != 'refs/heads/master' }}",
|
||||
)
|
||||
const build = builder.jobs.build!
|
||||
const preflight = build.steps!.find(step => step.name === 'Preflight installed-wheel real API test (POSIX)')!
|
||||
expect(preflight.if).toContain('inputs.ci')
|
||||
expect(preflight.if).toContain("github.event_name != 'pull_request'")
|
||||
expect(preflight.if).toContain('github.event.pull_request.head.repo.fork')
|
||||
expect(preflight.if).toContain("github.event.pull_request.user.login == 'dependabot[bot]'")
|
||||
expect(preflight.run).toContain('exit 1')
|
||||
})
|
||||
|
||||
it('runs Wine once on hosted master CI and seeds its own apt cache', () => {
|
||||
const master = workflow('ci-master.yml')
|
||||
const wine = master.jobs.windows!
|
||||
expect(wine).toMatchObject({ if: masterPush, 'runs-on': 'ubuntu-latest' })
|
||||
expect(wine.needs).toBeUndefined()
|
||||
expect(wine['continue-on-error']).toBeUndefined()
|
||||
expect(master.jobs['wine-apt-cache']).toBeUndefined()
|
||||
expect(Object.values(master.jobs).flatMap(commands).filter(command => command.includes('wine-windows-gates.sh')))
|
||||
.toEqual(['bash scripts/wine-windows-gates.sh'])
|
||||
expect(wine.steps).toContainEqual(expect.objectContaining({
|
||||
uses: 'actions/cache@v4', with: { path: '~/wine-debs', key: '${{ steps.wine-cache-key.outputs.key }}' },
|
||||
}))
|
||||
expect(commands(wine).join('\n')).toContain('--download-only wine')
|
||||
expect(wine.steps).toContainEqual(expect.objectContaining({ name: 'Shut down wineserver', if: 'always()' }))
|
||||
// Graph construction needs a pnpm entrypoint but never launches it.
|
||||
const previous = process.env.npm_execpath
|
||||
process.env.npm_execpath = '/test/pnpm.cjs'
|
||||
try {
|
||||
for (const mode of ['ci-linux-primary', 'ci-windows-complete'] as const) {
|
||||
expect(gatesForMode(mode).map(gate => gate.displayCommand).join('\n')).not.toMatch(/wine/i)
|
||||
}
|
||||
} finally {
|
||||
if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath')
|
||||
else process.env.npm_execpath = previous
|
||||
}
|
||||
expect(process.env.npm_execpath).toBe(previous)
|
||||
})
|
||||
|
||||
it('retains the complete release matrix independently of CI scheduling', () => {
|
||||
const release = workflow('python-release.yml')
|
||||
const calls = Object.values(release.jobs).filter(job => job.uses === runtimeBuilder)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.with).toMatchObject({
|
||||
release: true,
|
||||
targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -38,6 +38,12 @@ function evaluate(expression: string, context: Record<string, string | boolean>)
|
||||
return runInNewContext(source, { fromJSON: JSON.parse }, { timeout: 1000 }) as unknown
|
||||
}
|
||||
|
||||
function assertSharedPersistentStore(run: string | undefined): void {
|
||||
expect(run).toContain('store_root="$HOME/.local/share/pnpm/store"')
|
||||
expect(run).toContain('echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"')
|
||||
expect(run).toContain('store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)')
|
||||
}
|
||||
|
||||
const trustedPr = {
|
||||
'vars.DSH_CI_FAILOVER_LINUX': 'selfhosted',
|
||||
'github.repository': repository,
|
||||
@@ -108,8 +114,18 @@ for (const [file, jobIds] of [['release.yml', ['dependencies', 'pack']], ['relea
|
||||
.toBe('${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }}')
|
||||
expect(job.steps.find(step => step.name === 'Install (immutable)')?.run).toBe('pnpm install --frozen-lockfile')
|
||||
})
|
||||
it('uses the persistent store without remote cache reads or writes on self-hosted', () => {
|
||||
expect(job.steps.find(step => step.name === 'Configure pnpm store path')?.run).toContain('store_root="$HOME/.local/share/pnpm/store"')
|
||||
it('retains the configured shared npm cache', () => {
|
||||
expect(JSON.stringify(release)).not.toMatch(/npm_config_cache/i)
|
||||
})
|
||||
it.each(['', 'store_root="${RUNNER_TEMP%/*}/pnpm-store"', 'store_root="$RUNNER_TEMP/pnpm-store"'])(
|
||||
'rejects missing, runner-private, or job-temporary store placement: %s', (replacement) => {
|
||||
const run = job.steps.find(step => step.name === 'Configure pnpm store path')?.run
|
||||
?.replace('store_root="$HOME/.local/share/pnpm/store"', replacement)
|
||||
expect(() => { assertSharedPersistentStore(run) }).toThrow()
|
||||
},
|
||||
)
|
||||
it('uses the shared persistent store without remote cache reads or writes on self-hosted', () => {
|
||||
assertSharedPersistentStore(job.steps.find(step => step.name === 'Configure pnpm store path')?.run)
|
||||
const caches = job.steps.filter(step => step.uses?.startsWith('actions/cache'))
|
||||
expect(caches.map(step => step.uses)).toEqual(['actions/cache/restore@v4'])
|
||||
for (const step of caches) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
".agents/notes/AGENTS.md",
|
||||
".agents/notes/implemented/AGENTS.md",
|
||||
".agents/notes/implemented/CLAUDE.md",
|
||||
".agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md",
|
||||
".github/review-ownership/README.md",
|
||||
"docs/AGENTS.md",
|
||||
"docs/cordis-api/inherited.md",
|
||||
"docs/i18n/style-samples.md",
|
||||
|
||||
@@ -116,6 +116,11 @@
|
||||
"symbol": "LlmCallConfigAdapterDefaults",
|
||||
"source": "packages/llm/llm/src/call-config.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "Inbox",
|
||||
"source": "packages/core/agent/src/runtime-types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/core.md",
|
||||
"symbol": "InboxTarget",
|
||||
@@ -202,6 +207,11 @@
|
||||
"symbol": "GoalView",
|
||||
"source": "packages/goal/goal/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/goal.md",
|
||||
"symbol": "GoalActivationChanged",
|
||||
"source": "packages/goal/goal/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/goal.md",
|
||||
"symbol": "GoalSnapshotChangeMeta",
|
||||
@@ -1349,22 +1359,22 @@
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
"symbol": "AgentMessageSource",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
"source": "packages/subagent/subagent/src/continuation-messages.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
"symbol": "SubagentSettledMessageSource",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
"source": "packages/subagent/subagent/src/continuation-messages.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
"symbol": "SubagentSendMessageOptions",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
"symbol": "SubagentInterruptAuthority",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
@@ -1374,7 +1384,7 @@
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
"symbol": "ContinuableStart",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/subagent.md",
|
||||
|
||||
@@ -74,6 +74,15 @@ describe('application entrypoints', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a packaging dispatcher owned by the CLI workspace', () => {
|
||||
const root = fixture()
|
||||
write(root, 'apps/cli/src/runtime-bootstrap.ts', '#!/usr/bin/env node\n')
|
||||
|
||||
expect(applicationEntrypointViolations(root)).toEqual([
|
||||
'apps/cli/src/runtime-bootstrap.ts: executable source has no application/build/test classification',
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects a private Python application carrier outside dsh', () => {
|
||||
const root = fixture()
|
||||
write(root, 'packages/sdk/rogue-python-runtime/package.json', JSON.stringify({ private: true }))
|
||||
|
||||
@@ -29,7 +29,7 @@ const MANIFEST_BIN_ALLOWLIST = new Map<string, ManifestBin>([
|
||||
['packages/experimental/webworker-packer/package.json', { 'dsh-pack-vfs-image': './bin.js' }],
|
||||
])
|
||||
|
||||
/** Every executable in a Node application workspace has one explicit role. */
|
||||
/** Every JavaScript executable in an application or packaging workspace has one explicit role. */
|
||||
const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
|
||||
['apps/cli/src/bin.ts', 'supported dsh application launcher'],
|
||||
['packages/context/time-context/tests/fixtures/driver.ts', 'test-only subprocess driver'],
|
||||
@@ -44,6 +44,7 @@ const EXECUTABLE_SOURCE_ALLOWLIST = new Map<string, string>([
|
||||
['packages/subagent/subagent-dsh-sdk/tests/fixtures/loader/driver.ts', 'test-only subprocess driver'],
|
||||
['packages/test-support/loader-smoke/tests/fixtures/headless-driver.ts', 'test-only subprocess driver'],
|
||||
['packages/test-support/llm-mock-server/src/bin.ts', 'test-only model server'],
|
||||
['python/sdk-runtime/runtime-bootstrap.mjs', 'private packaging-only runtime dispatcher'],
|
||||
])
|
||||
|
||||
/** Root demos are application wrappers and therefore must visibly select dsh. */
|
||||
@@ -65,6 +66,7 @@ const SOURCE_PATTERNS = [
|
||||
'packages/**/*.js',
|
||||
'packages/**/*.mjs',
|
||||
'packages/**/*.cjs',
|
||||
'python/sdk-runtime/*.mjs',
|
||||
]
|
||||
|
||||
const SOURCE_EXCLUDES = [
|
||||
|
||||
@@ -62,4 +62,20 @@ describe('Client UI i18n source check', () => {
|
||||
'export const en = { title: "Hard-coded by design" }',
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects Electron dialog, title, prompt, and DOM copy outside locale owners', () => {
|
||||
const source = `
|
||||
dialog.showMessageBox({ title: 'Update available', message: 'Install it now?' })
|
||||
window.setTitle('Desktop plugins')
|
||||
window.prompt('Target version')
|
||||
status.textContent = 'Finished'
|
||||
`
|
||||
expect(findUiI18nViolations('apps/desktop/src/main.ts', source).map(row => row.text)).toEqual([
|
||||
'Update available',
|
||||
'Install it now?',
|
||||
'Desktop plugins',
|
||||
'Target version',
|
||||
'Finished',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -114,7 +114,7 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi
|
||||
sourceText,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
file.endsWith('.tsx') ? ts.ScriptKind.TSX : file.endsWith('.js') ? ts.ScriptKind.JS : ts.ScriptKind.TS,
|
||||
)
|
||||
const violations = new Map<number, UiI18nViolation>()
|
||||
|
||||
@@ -254,13 +254,27 @@ export function findUiI18nViolations(file: string, sourceText: string): UiI18nVi
|
||||
&& (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))
|
||||
) collectExpression(node.expression, 'JSX child')
|
||||
|
||||
if (file.endsWith('.tsx') && ts.isPropertyAssignment(node)) {
|
||||
if ((file.endsWith('.tsx') || file.startsWith('apps/desktop/')) && ts.isPropertyAssignment(node)) {
|
||||
const name = propertyName(node.name)
|
||||
if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
|
||||
collectExpression(node.initializer, `${name} property`)
|
||||
}
|
||||
}
|
||||
|
||||
if (file.startsWith('apps/desktop/') && ts.isBinaryExpression(node)
|
||||
&& node.operatorToken.kind === ts.SyntaxKind.EqualsToken
|
||||
&& ts.isPropertyAccessExpression(node.left)
|
||||
&& (node.left.name.text === 'textContent' || node.left.name.text === 'innerText')) {
|
||||
collectExpression(node.right, `${node.left.name.text} assignment`)
|
||||
}
|
||||
|
||||
if (file.startsWith('apps/desktop/') && ts.isCallExpression(node)
|
||||
&& ts.isPropertyAccessExpression(node.expression)
|
||||
&& (node.expression.name.text === 'setTitle' || node.expression.name.text === 'prompt')) {
|
||||
const copy = node.arguments[0]
|
||||
if (copy !== undefined) collectExpression(copy, `${node.expression.name.text} argument`)
|
||||
}
|
||||
|
||||
if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
|
||||
const name = propertyName(node.name)
|
||||
if (name !== undefined && (COPY_NAME.test(name) || COPY_SUFFIX.test(name))) {
|
||||
@@ -314,6 +328,8 @@ function sourceFiles(): string[] {
|
||||
...[...clientComponentRoots].flatMap(clientRoot =>
|
||||
globSync(`${clientRoot}/**/*.{ts,tsx}`, { cwd: root })),
|
||||
...globSync('apps/web/src/**/*.{ts,tsx}', { cwd: root }),
|
||||
...globSync('apps/desktop/src/{main,update-coordinator}.{ts,tsx}', { cwd: root }),
|
||||
...globSync('apps/desktop/renderer/*.js', { cwd: root }),
|
||||
])]
|
||||
.map(file => file.replaceAll('\\', '/'))
|
||||
.filter(file => !file.endsWith('.d.ts'))
|
||||
|
||||
@@ -84,6 +84,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/experimental/agent-team-web-profile': { kind: 'indirect', reason: 'The private Web layer delegates model composition to the Host-side Agent Teams profile.' },
|
||||
'packages/experimental/client-ui-agent-team': { kind: 'none', reason: 'The browser projection and task controls register no model-facing input.' },
|
||||
'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-dockkit': { kind: 'none', reason: 'Browser-side docking layout engine and components; registers nothing model-facing.' },
|
||||
'packages/client/ui-sidebar-right': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-brand-official': { kind: 'none', reason: 'Browser-side presentation occupants; registers nothing model-facing.' },
|
||||
'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
@@ -109,6 +111,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/client/ui-directory-picker-browse': { kind: 'none', reason: 'Browser-side directory-browsing surface; registers nothing model-facing.' },
|
||||
'packages/client/ui-directory-picker-native': { kind: 'none', reason: 'Browser-side surface driving the host OS chooser; registers nothing model-facing.' },
|
||||
'packages/client/ui-theme': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-sidebar-textpreview': { kind: 'none', reason: 'Browser-only text viewer; registers no tool, prompt section, or session event, and what the user reads never enters a model request.' },
|
||||
'packages/client/ui-sidebar-files': { kind: 'none', reason: 'Browser-only workspace file tree; registers no tool, prompt section, or session event.' },
|
||||
'packages/client/resources': { kind: 'none', reason: 'Browser-side resource model (providers, pinning, useResource); registers nothing model-facing.' },
|
||||
'packages/client/ui-settings': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-settings-general': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
'packages/client/ui-settings-models': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' },
|
||||
@@ -130,6 +135,8 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/host/plugin-inventory': { kind: 'none', reason: 'Host-side read-only Loader projection; registers nothing model-facing.' },
|
||||
'packages/host/plugin-manager': { kind: 'none', reason: 'The plugins Remote over the shared plugin manager; the rows the manager composes own every model-facing registration.' },
|
||||
'packages/boot/plugin-manager': { kind: 'none', reason: 'Plugin management over the profile; the rows it composes own every model-facing registration.' },
|
||||
'packages/host/open-in-app': { kind: 'none', reason: 'Host routes opening desktop applications for a human; registers nothing model-facing.' },
|
||||
'packages/client/ui-open-in-app': { kind: 'none', reason: 'Browser-side split button opening the workspace directory for a human; registers nothing model-facing.' },
|
||||
'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model-facing behavior.' },
|
||||
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' },
|
||||
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
|
||||
@@ -173,6 +180,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' },
|
||||
'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' },
|
||||
'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' },
|
||||
'packages/api/workspace-files': { kind: 'none', reason: 'Workspace file read API and its Client resource provider; it registers no prompt, tool, or session event.' },
|
||||
'packages/typert/protocol': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' },
|
||||
'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' },
|
||||
'packages/jobs/jobs': { kind: 'indirect', reason: 'Producer and controller plugins own all model rendering over the job registry.' },
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
MAX_PACKAGE_README_SUMMARY_WORDS,
|
||||
packageReadmeSummaryErrors,
|
||||
} from './verify-package-readme-summaries.ts'
|
||||
|
||||
function readme(summary: string, kind = 'package-reference'): string {
|
||||
return `---\nkind: "${kind}"\n---\n# Example\n\n## Summary\n\n${summary}\n\n## Table of Contents\n`
|
||||
}
|
||||
|
||||
describe('package README Summary limit', () => {
|
||||
it('accepts exactly 100 whitespace-delimited words', () => {
|
||||
const summary = Array.from({ length: MAX_PACKAGE_README_SUMMARY_WORDS }, () => 'word').join(' ')
|
||||
|
||||
expect(packageReadmeSummaryErrors('packages/example/example/README.md', readme(summary))).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'package-group',
|
||||
'package-reference',
|
||||
'package-library',
|
||||
'package-bundle',
|
||||
])('rejects 101 words and directs the author to the skill and %s template', (kind) => {
|
||||
const summary = Array.from({ length: MAX_PACKAGE_README_SUMMARY_WORDS + 1 }, () => 'word').join(' ')
|
||||
|
||||
expect(packageReadmeSummaryErrors('packages/example/example/README.md', readme(summary, kind))).toEqual([
|
||||
`packages/example/example/README.md: Summary has 101 words; the limit is 100. Read .agents/skills/dsh-doc/SKILL.md and .agents/skills/dsh-doc/templates/${kind}.md before rewriting it.`,
|
||||
])
|
||||
})
|
||||
|
||||
it('counts only the Summary body', () => {
|
||||
const laterSection = Array.from({ length: 101 }, () => 'detail').join(' ')
|
||||
|
||||
expect(packageReadmeSummaryErrors(
|
||||
'packages/example/example/README.md',
|
||||
`${readme('Short summary.')}\n${laterSection}`,
|
||||
)).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a missing Summary instead of silently narrowing the corpus', () => {
|
||||
expect(packageReadmeSummaryErrors(
|
||||
'packages/example/example/README.md',
|
||||
'---\nkind: "package-reference"\n---\n# Example\n',
|
||||
)).toEqual(['packages/example/example/README.md: missing `## Summary`'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
/** Enforce the English package README Summary entry-length limit. */
|
||||
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
/** Maximum `wc -w`-style length of an English package README Summary. */
|
||||
export const MAX_PACKAGE_README_SUMMARY_WORDS = 100
|
||||
|
||||
const PACKAGE_README_PATTERNS = [
|
||||
'packages/README.md',
|
||||
'packages/*/README.md',
|
||||
'packages/*/*/README.md',
|
||||
] as const
|
||||
|
||||
/** `wc -w` equivalent used by the documentation budget gate. */
|
||||
function countWords(text: string): number {
|
||||
return text.split(/\s+/u).filter(Boolean).length
|
||||
}
|
||||
|
||||
/** Extract one H2 section body without consuming the next H2. */
|
||||
function h2Body(source: string, heading: string): string | undefined {
|
||||
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')
|
||||
const match = new RegExp(`^## ${escaped}\\s*\\n([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, 'mu').exec(source)
|
||||
return match?.[1]?.trim()
|
||||
}
|
||||
|
||||
/** Read the package README kind for a diagnostic template link. */
|
||||
function readKind(source: string): string | undefined {
|
||||
return /^kind:\s*["']?([a-z-]+)["']?\s*$/mu.exec(source)?.[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Report Summary length violations for one English package README.
|
||||
* @param file - Repository-relative README path.
|
||||
* @param source - Complete README source.
|
||||
* @returns Diagnostics for a missing or oversized Summary.
|
||||
*/
|
||||
export function packageReadmeSummaryErrors(file: string, source: string): string[] {
|
||||
const summary = h2Body(source, 'Summary')
|
||||
if (summary === undefined) return [`${file}: missing \`## Summary\``]
|
||||
|
||||
const words = countWords(summary)
|
||||
if (words <= MAX_PACKAGE_README_SUMMARY_WORDS) return []
|
||||
|
||||
const kind = readKind(source)
|
||||
const template = kind === undefined
|
||||
? '.agents/skills/dsh-doc/templates/'
|
||||
: `.agents/skills/dsh-doc/templates/${kind}.md`
|
||||
return [
|
||||
`${file}: Summary has ${String(words)} words; the limit is ${String(MAX_PACKAGE_README_SUMMARY_WORDS)}. Read .agents/skills/dsh-doc/SKILL.md and ${template} before rewriting it.`,
|
||||
]
|
||||
}
|
||||
|
||||
/** Find every authored English package README covered by the kind templates. */
|
||||
function packageReadmes(): string[] {
|
||||
return PACKAGE_README_PATTERNS
|
||||
.flatMap(pattern => globSync(pattern, { cwd: root, exclude: ['**/node_modules/**'] }))
|
||||
.map(file => file.replaceAll('\\', '/'))
|
||||
.sort()
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const files = packageReadmes()
|
||||
const failures = files.length === 0
|
||||
? ['no English package READMEs found; the scan is empty or narrowed']
|
||||
: files.flatMap(file => packageReadmeSummaryErrors(file, readFileSync(resolve(root, file), 'utf8')))
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-package-readme-summaries: violations found:')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exitCode = 1
|
||||
} else {
|
||||
console.log(`verify-package-readme-summaries: ${String(files.length)} English package README Summaries are within ${String(MAX_PACKAGE_README_SUMMARY_WORDS)} words.`)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the blocking Windows gates (workspace build, production site) with real
|
||||
# win-x64 Node.js under Wine — the same script the pull-request `windows` job
|
||||
# in ci.yml executes and the optional local gate `pnpm run check:windows-wine`
|
||||
# win-x64 Node.js under Wine — the same script the master-only `windows` job
|
||||
# in ci-master.yml executes and the optional local gate `pnpm run check:windows-wine`
|
||||
# wraps. Owning rationale and fidelity limits:
|
||||
# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
|
||||
#
|
||||
|
||||
Reference in new issue
Block a user