From 9bae613128a725cc44dc140cf629a57f74524f94 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 9 Sep 2026 17:31:16 +0800 Subject: [PATCH 01/17] ci: add an opt-in canary value to the CI pool failover switches The per-platform failover repository variables (DSH_CI_FAILOVER_LINUX and DSH_CI_FAILOVER_WINDOWS) accept a third value alongside the existing selfhosted routing. Set by a repository writer, the value retargets the always-on pull-request and master lanes onto the matching hosted-runner labels (16-core pools map to the 16-vCPU tier, standard legs to 4-vCPU); unset or any other value keeps today's runners, so the leg is dormant until a writer opts in - a canary path for capacity fallback, never the default. Participating jobs: the three enterprise Linux workers, the node-compat legs, the four Windows lanes, and the all-checks-passed verdict in ci.yml; expected-filenames; the sandbox bwrap leg; and the two manual benchmark matrices (4-32 vCPU tiers map to equivalents; 64/96-core rows keep the fleet labels). Everything pinned to its own runner by spec, credential, or host capability keeps it under every value, and the release-shaped chains stay entirely hosted. request-review.yml was retired on master while this work was in flight and the branch takes the deletion. Specs pin the new branches: ci-workflow.spec.ts asserts the labels appear in the extended selectors, and ci-compatible-selfhosted.spec.ts evaluates the routing mode. --- .github/workflows/ci-master.yml | 44 +++++++++++++++- .github/workflows/ci.yml | 41 ++++++++++----- .github/workflows/expected-filenames.yml | 4 +- .github/workflows/sandbox.yml | 11 +++- scripts/ci-compatible-selfhosted.spec.ts | 3 ++ scripts/ci-workflow.spec.ts | 64 ++++++++++++++++++++++++ 6 files changed, 151 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci-master.yml b/.github/workflows/ci-master.yml index df9f7f2bcc..daf8a6cb80 100644 --- a/.github/workflows/ci-master.yml +++ b/.github/workflows/ci-master.yml @@ -284,7 +284,15 @@ jobs: # The named pools are restricted at the organization level to this repository. larger-runner-benchmark: if: github.event_name == 'workflow_dispatch' && inputs.suite == 'larger-runner-benchmark' - runs-on: ${{ matrix.runner }} + # Default measures the repository's own fleet tiers; under the matching + # platform's blacksmith failover value the tiers Blacksmith offers (up to + # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core + # rows keep the fleet labels because Blacksmith has no such tier. + runs-on: >- + ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' + || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith') + && matrix.blacksmith + || matrix.runner }} timeout-minutes: 15 strategy: fail-fast: false @@ -294,50 +302,62 @@ jobs: - platform: linux cores: '4' runner: dsh-ubuntu-24-04-4core + blacksmith: blacksmith-4vcpu-ubuntu-2404 workload: typecheck - platform: linux cores: '8' runner: dsh-ubuntu-24-04-8core + blacksmith: blacksmith-8vcpu-ubuntu-2404 workload: typecheck - platform: linux cores: '16' runner: dsh-ubuntu-24-04-16core + blacksmith: blacksmith-16vcpu-ubuntu-2404 workload: typecheck - platform: linux cores: '32' runner: dsh-ubuntu-24-04-32core + blacksmith: blacksmith-32vcpu-ubuntu-2404 workload: typecheck - platform: linux cores: '64' runner: dsh-ubuntu-24-04-64core + blacksmith: '' workload: typecheck - platform: linux cores: '96' runner: dsh-ubuntu-24-04-96core + blacksmith: '' workload: typecheck - platform: windows cores: '4' runner: dsh-windows-2025-4core + blacksmith: blacksmith-4vcpu-windows-2025 workload: production-site - platform: windows cores: '8' runner: dsh-windows-2025-8core + blacksmith: blacksmith-8vcpu-windows-2025 workload: production-site - platform: windows cores: '16' runner: dsh-windows-2025-16core + blacksmith: blacksmith-16vcpu-windows-2025 workload: production-site - platform: windows cores: '32' runner: dsh-windows-2025-32core + blacksmith: blacksmith-32vcpu-windows-2025 workload: production-site - platform: windows cores: '64' runner: dsh-windows-2025-64core + blacksmith: '' workload: production-site - platform: windows cores: '96' runner: dsh-windows-2025-96core + blacksmith: '' workload: production-site steps: - uses: actions/checkout@v6 @@ -375,7 +395,15 @@ jobs: # Windows runs both blocking build targets concurrently through run-gates. consolidated-runner-benchmark: if: github.event_name == 'workflow_dispatch' && inputs.suite == 'consolidated-runner-benchmark' - runs-on: ${{ matrix.runner }} + # Default measures the repository's own fleet tiers; under the matching + # platform's blacksmith failover value the tiers Blacksmith offers (up to + # 32 vCPU) move onto their Blacksmith equivalents, while the 64/96-core + # rows keep the fleet labels because Blacksmith has no such tier. + runs-on: >- + ${{ (matrix.platform == 'linux' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' + || matrix.platform == 'windows' && vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith') + && matrix.blacksmith + || matrix.runner }} timeout-minutes: 15 strategy: fail-fast: false @@ -385,50 +413,62 @@ jobs: - platform: linux cores: '4' runner: dsh-ubuntu-24-04-4core + blacksmith: blacksmith-4vcpu-ubuntu-2404 workers: '4' - platform: linux cores: '8' runner: dsh-ubuntu-24-04-8core + blacksmith: blacksmith-8vcpu-ubuntu-2404 workers: '8' - platform: linux cores: '16' runner: dsh-ubuntu-24-04-16core + blacksmith: blacksmith-16vcpu-ubuntu-2404 workers: '16' - platform: linux cores: '32' runner: dsh-ubuntu-24-04-32core + blacksmith: blacksmith-32vcpu-ubuntu-2404 workers: '32' - platform: linux cores: '64' runner: dsh-ubuntu-24-04-64core + blacksmith: '' workers: '32' - platform: linux cores: '96' runner: dsh-ubuntu-24-04-96core + blacksmith: '' workers: '32' - platform: windows cores: '4' runner: dsh-windows-2025-4core + blacksmith: blacksmith-4vcpu-windows-2025 workers: '2' - platform: windows cores: '8' runner: dsh-windows-2025-8core + blacksmith: blacksmith-8vcpu-windows-2025 workers: '2' - platform: windows cores: '16' runner: dsh-windows-2025-16core + blacksmith: blacksmith-16vcpu-windows-2025 workers: '2' - platform: windows cores: '32' runner: dsh-windows-2025-32core + blacksmith: blacksmith-32vcpu-windows-2025 workers: '2' - platform: windows cores: '64' runner: dsh-windows-2025-64core + blacksmith: '' workers: '2' - platform: windows cores: '96' runner: dsh-windows-2025-96core + blacksmith: '' workers: '2' steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e05b444a4..04639ea7fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,10 @@ jobs: # repository state — not PR-editable, no merge required) retargets all # three onto the in-house # vm-backup pool and re-running the failed jobs is the entire switch — - # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The + # see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. + # Setting the variable to 'blacksmith' instead routes the same jobs onto + # Blacksmith's hosted runners at the matching vCPU size (see + # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md). The # in-house pool's readiness is re-proven on every master push by the # serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover # switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native @@ -39,7 +42,8 @@ jobs: node-24: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} @@ -105,7 +109,8 @@ jobs: node-24-coverage: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} @@ -226,7 +231,8 @@ jobs: node-24-consumers: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-16vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'dsh-ubuntu-24-04-16core' }} @@ -327,9 +333,12 @@ jobs: node-compat: if: github.event_name == 'pull_request' - # This job admits only repository-owned PR code to the persistent shared VM. + # Under the selfhosted leg this job admits only repository-owned PR code + # to the persistent shared VM; the blacksmith branch targets ephemeral + # runners and carries none of those predicates. runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.repo.fork == false && github.event.pull_request.user.login != 'dependabot[bot]' @@ -451,7 +460,8 @@ jobs: windows-build: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} @@ -495,7 +505,8 @@ jobs: windows-coverage: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} @@ -571,7 +582,8 @@ jobs: windows-native-tests: if: github.event_name == 'pull_request' runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} @@ -620,7 +632,8 @@ jobs: if: github.event_name == 'pull_request' continue-on-error: true runs-on: >- - ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025' + || vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') || 'dsh-windows-2025-16core' }} @@ -677,9 +690,13 @@ jobs: # the worker jobs it aggregates, so a standard-hosted outage cannot strand # the branch-protection verdict either. It retargets with the Linux switch # (DSH_CI_FAILOVER_LINUX), not the Windows one, because it aggregates the - # required Linux workers and runs on the vm-backup pool. + # required Linux workers and runs on the vm-backup pool. Under the + # 'blacksmith' value the verdict shares Blacksmith's pool with its workers + # through the same selector, so a Blacksmith outage strands both together — + # the accepted consequence of the explicit opt-in switch. runs-on: >- - ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && fromJSON('["self-hosted", "linux", "x64", "vm-backup"]') || 'ubuntu-latest' }} diff --git a/.github/workflows/expected-filenames.yml b/.github/workflows/expected-filenames.yml index 59320b9261..99c9886e35 100644 --- a/.github/workflows/expected-filenames.yml +++ b/.github/workflows/expected-filenames.yml @@ -18,7 +18,9 @@ env: jobs: expected-filenames: name: no golden filenames - runs-on: ubuntu-latest + runs-on: >- + ${{ vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' && 'blacksmith-4vcpu-ubuntu-2404' + || 'ubuntu-latest' }} steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index 97b7e298d7..713616f3b6 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -44,6 +44,12 @@ jobs: fail-fast: false matrix: include: + # Only the bwrap leg has a Blacksmith equivalent: the earlier + # migration dispatch runs measured that Blacksmith's Linux images do + # not enforce Landlock (the run-guard would turn the leg red) and + # its macOS image is unverified, so those legs stay on GitHub's + # images under every failover value — see + # .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md. - os: ubuntu-latest runner: bwrap - os: ubuntu-24.04 @@ -53,7 +59,10 @@ jobs: - os: macos-latest runner: seatbelt name: sandbox e2e (${{ matrix.runner }}, ${{ matrix.os }}) - runs-on: ${{ matrix.os }} + runs-on: >- + ${{ matrix.runner == 'bwrap' && vars.DSH_CI_FAILOVER_LINUX == 'blacksmith' + && 'blacksmith-4vcpu-ubuntu-2404' + || matrix.os }} timeout-minutes: 20 steps: - uses: actions/checkout@v6 diff --git a/scripts/ci-compatible-selfhosted.spec.ts b/scripts/ci-compatible-selfhosted.spec.ts index 3ef7a8b813..619ae192f9 100644 --- a/scripts/ci-compatible-selfhosted.spec.ts +++ b/scripts/ci-compatible-selfhosted.spec.ts @@ -57,6 +57,9 @@ 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') + // The blacksmith value routes the compatibility legs onto Blacksmith's + // standard Linux runner regardless of PR ownership (ephemeral runners). + expect(route({ mode: 'blacksmith' })).toBe('blacksmith-4vcpu-ubuntu-2404') 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') diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 46177da403..bfe95f28ef 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { runInNewContext } from 'node:vm' import * as yaml from 'js-yaml' import { describe, expect, it } from 'vitest' @@ -135,6 +136,7 @@ describe('CI workflow', () => { expect(job['runs-on']).toContain('self-hosted') expect(job['runs-on']).toContain('dsh-win-ci') expect(job['runs-on']).toContain('dsh-windows-2025-16core') + expect(job['runs-on']).toContain('blacksmith-16vcpu-windows-2025') expect(job.if).toBe("github.event_name == 'pull_request'") } @@ -273,10 +275,43 @@ describe('CI workflow', () => { expect(job['runs-on'], `${jobName} runs-on must use the Linux failover switch`).toContain('DSH_CI_FAILOVER_LINUX') expect(job['runs-on'], `${jobName} runs-on must not use the Windows failover switch`).not.toContain('DSH_CI_FAILOVER_WINDOWS') expect(job['runs-on']).toContain('vm-backup') + expect(job['runs-on']).toContain('blacksmith-16vcpu-ubuntu-2404') } expect(aggregate['runs-on']).toContain('DSH_CI_FAILOVER_LINUX') expect(aggregate['runs-on']).not.toContain('DSH_CI_FAILOVER_WINDOWS') expect(aggregate['runs-on']).toContain('vm-backup') + expect(aggregate['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404') + + // Evaluating the full selector, not just substring containment, proves the + // blacksmith branch is standalone: it must not fall through to the + // self-hosted pool when the two values are mutually exclusive. + const selectors = { + linux: node24['runs-on'] as string, + linuxAggregate: aggregate['runs-on'] as string, + windows: windowsBuild['runs-on'] as string, + } + const evaluate = (expression: string, vars: Record, login = 'maintainer'): unknown => { + const body = expression.trim().slice(3, -2) + return runInNewContext(body, { + vars, + fromJSON: JSON.parse, + github: { event: { pull_request: { user: { login } } } }, + }, { timeout: 1000 }) + } + for (const [name, selector, variable, pool, hosted] of [ + ['linux gates', selectors.linux, 'DSH_CI_FAILOVER_LINUX', ['self-hosted', 'linux', 'x64', 'vm-backup'], 'dsh-ubuntu-24-04-16core'], + ['linux aggregate', selectors.linuxAggregate, 'DSH_CI_FAILOVER_LINUX', ['self-hosted', 'linux', 'x64', 'vm-backup'], 'ubuntu-latest'], + ['windows lanes', selectors.windows, 'DSH_CI_FAILOVER_WINDOWS', ['self-hosted', 'dsh-win-ci', 'windows'], 'dsh-windows-2025-16core'], + ] as const) { + expect(evaluate(selector, { [variable]: 'blacksmith' }), `${name} blacksmith value`).toMatch(/^blacksmith-/) + expect(evaluate(selector, { [variable]: 'selfhosted' }), `${name} selfhosted value`).toEqual(pool) + // The blacksmith branch must not capture the selfhosted pool, and the + // dependabot exclusion applies to the pool, not to the blacksmith tier. + expect(evaluate(selector, { [variable]: 'selfhosted' }, 'dependabot[bot]'), `${name} dependabot on selfhosted`).toBe(hosted) + for (const mode of ['', 'hosted', 'unexpected']) { + expect(evaluate(selector, { [variable]: mode }), `${name} default on ${mode}`).toBe(hosted) + } + } // The run-gates aggregate lanes stop at the first blocking gate failure so // a red aggregate does not keep burning runner time on the remaining @@ -299,6 +334,35 @@ describe('CI workflow', () => { expect(windowsObservational.env).not.toMatchObject({ DSH_GATE_FAIL_FAST: '1' }) }) + it('gates standalone keyless blacksmith jobs and benchmark tiers on the failover variables', () => { + const expectedFilenames = workflowJob(loadWorkflow('.github/workflows/expected-filenames.yml'), 'expected-filenames') + const sandbox = workflowJob(loadWorkflow('.github/workflows/sandbox.yml'), 'sandbox-e2e') + expect(expectedFilenames['runs-on']).toContain('DSH_CI_FAILOVER_LINUX') + expect(expectedFilenames['runs-on']).toContain("== 'blacksmith'") + expect(expectedFilenames['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404') + expect(expectedFilenames['runs-on']).toContain("'ubuntu-latest'") + expect(sandbox['runs-on']).toContain("matrix.runner == 'bwrap'") + expect(sandbox['runs-on']).toContain('DSH_CI_FAILOVER_LINUX') + expect(sandbox['runs-on']).toContain('blacksmith-4vcpu-ubuntu-2404') + for (const name of ['larger-runner-benchmark', 'consolidated-runner-benchmark'] as const) { + const benchmark = workflowJob(loadWorkflow('.github/workflows/ci-master.yml'), name) + if (!isRecord(benchmark.strategy) || !isRecord(benchmark.strategy.matrix) || !Array.isArray(benchmark.strategy.matrix.include)) { + throw new TypeError(`${name} must define a matrix include list`) + } + expect(benchmark['runs-on']).toContain('matrix.blacksmith') + expect(benchmark['runs-on']).toContain('DSH_CI_FAILOVER_LINUX') + expect(benchmark['runs-on']).toContain('DSH_CI_FAILOVER_WINDOWS') + for (const row of benchmark.strategy.matrix.include as Array>) { + expect(typeof row.blacksmith, `${name} ${row.cores}-core row must declare a blacksmith label`).toBe('string') + if (row.cores === '64' || row.cores === '96') { + expect(row.blacksmith, `${name} ${row.cores}-core row has no Blacksmith tier`).toBe('') + } else { + expect(row.blacksmith).toContain(`blacksmith-${row.cores}vcpu`) + } + } + } + }) + it('runs required benchmarks on standard hosted Linux independently of failover', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') const benchmark = workflowJob(workflow, 'node-24-bench') From d79294f23a2fd62d37f7c997ec97e51fec9d905a Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 9 Sep 2026 17:31:28 +0800 Subject: [PATCH 02/17] fix(scripts): enumerate repo files with a dirent walker instead of node fs.glob Node's internal fs.glob, from some 24.x releases, lstat-probes / while expanding a ** pattern over a symlinked file and throws ENOTDIR instead of skipping (observed on node 24.13.0 scanning snapshots/acp/image-compaction's symlinked system-prompt.expected.md, which failed verify-md-wrap inside check:ci:static). uniqueRepoFiles now walks with dirent types, never probing a path under a file, and mirrors fs.glob's dot:false and follow:false semantics: the four repo-files gate pattern sets enumerate identically. Broken or cyclic symlinks fail loudly in the caller's realpathSync, and pattern forms the walker does not model (metacharacters, a trailing **, empty segments) are rejected up front. repo-files.spec.ts pins the symlink, broken-link, and rejection behavior. --- scripts/repo-files.spec.ts | 176 +++++++++++++++++++++++++++++++++++++ scripts/repo-files.ts | 174 +++++++++++++++++++++++++++++++++++- 2 files changed, 346 insertions(+), 4 deletions(-) create mode 100644 scripts/repo-files.spec.ts diff --git a/scripts/repo-files.spec.ts b/scripts/repo-files.spec.ts new file mode 100644 index 0000000000..60875341d7 --- /dev/null +++ b/scripts/repo-files.spec.ts @@ -0,0 +1,176 @@ +import { mkdtempSync, mkdirSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { describe, expect, it } from 'vitest' +import { uniqueRepoFiles } from './repo-files.ts' + +interface Tree { + root: string + clean: () => void +} + +function makeTree(): Tree { + const parent = mkdtempSync(join(tmpdir(), 'repo-files-')) + const root = join(parent, 'root') + const outside = join(parent, 'outside') // reachable only through a symlinked dir + mkdirSync(join(root, 'a'), { recursive: true }) + writeFileSync(join(root, 'a', 'snap.md'), 'real\n') + mkdirSync(join(root, 'd'), { recursive: true }) + symlinkSync(join(root, 'a', 'snap.md'), join(root, 'd', 'snap.md')) + mkdirSync(outside, { recursive: true }) + writeFileSync(join(outside, 'snap.md'), 'behind a symlinked dir\n') + symlinkSync(outside, join(root, 'linked-dir')) + mkdirSync(join(root, '.hidden'), { recursive: true }) + writeFileSync(join(root, '.hidden', 'snap.md'), 'hidden by dot\n') + return { root, clean: () => { rmSync(parent, { recursive: true, force: true }) } } +} + +describe('uniqueRepoFiles', () => { + it('enumerates ** matches without probing a symlinked file as a directory', () => { + const tree = makeTree() + try { + // Node's internal glob (from some 24.x releases) lstat-probes + // /snap.md while expanding `**/snap.md` and throws ENOTDIR. + // The walker must return the real files, deduplicated by canonical + // target, without throwing on any node version. + const files = uniqueRepoFiles(tree.root, ['**/snap.md']) + const rootReal = realpathSync(tree.root) + const reals = files.map(file => relative(rootReal, file.real)).sort() + expect(reals).toEqual([join('a', 'snap.md')]) + } finally { + tree.clean() + } + }) + + it('does not follow symlinked directories under ** or wildcard-match dot names', () => { + const tree = makeTree() + try { + const files = uniqueRepoFiles(tree.root, ['**/*.md']) + const paths = files.map(file => relative(tree.root, file.abs)).sort() + // The symlinked d/snap.md dedupes onto its a/snap.md target; the + // linked-dir and .hidden targets must not appear at all. + expect(paths).toEqual([join('a', 'snap.md')]) + } finally { + tree.clean() + } + }) + + it('follows a literal segment that names a symlinked directory', () => { + const tree = makeTree() + try { + // Node glob resolves literal segments with stat, so `linked-dir/**` and + // `linked-dir/*` enter the symlinked directory's target; `**` and + // wildcard segments resolve with dirent types and do not. + const files = uniqueRepoFiles(tree.root, ['linked-dir/**/*.md']) + expect(files.map(file => relative(tree.root, file.abs))).toEqual([join('linked-dir', 'snap.md')]) + expect(uniqueRepoFiles(tree.root, ['linked-dir/*.md']).map(file => relative(tree.root, file.abs))) + .toEqual([join('linked-dir', 'snap.md')]) + // A wildcard first segment never enters the symlinked directory. + expect(uniqueRepoFiles(tree.root, ['*/snap.md']).map(file => relative(tree.root, file.abs))) + .toEqual([join('a', 'snap.md')]) + } finally { + tree.clean() + } + }) + + it('follows repeated literal symlinked directories like node glob and terminates', () => { + const parent = mkdtempSync(join(tmpdir(), 'repo-files-cycle-')) + try { + const root = join(parent, 'root') + mkdirSync(join(root, 'a'), { recursive: true }) + writeFileSync(join(root, 'a', 'snap.md'), 'x\n') + symlinkSync(root, join(root, 'cyc')) + // Each literal `cyc` segment resolves through stat and enters the + // symlinked directory again, exactly as node glob does for a repeated + // literal; recursion stays bounded because each literal consumes one + // pattern segment and `**` only enters real directories. + expect(uniqueRepoFiles(root, ['cyc/**/snap.md']).map(file => relative(root, file.abs))) + .toEqual([join('cyc', 'a', 'snap.md')]) + expect(uniqueRepoFiles(root, ['cyc/cyc/**/snap.md']).map(file => relative(root, file.abs))) + .toEqual([join('cyc', 'cyc', 'a', 'snap.md')]) + expect(uniqueRepoFiles(root, ['cyc/cyc/cyc/**/snap.md']).map(file => relative(root, file.abs))) + .toEqual([join('cyc', 'cyc', 'cyc', 'a', 'snap.md')]) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('reports a matched file canonical target for downstream existence checks', () => { + const tree = makeTree() + try { + const files = uniqueRepoFiles(tree.root, ['a/*.md']) + expect(files).toHaveLength(1) + expect(files[0]!.real).toBe(join(realpathSync(tree.root), 'a', 'snap.md')) + } finally { + tree.clean() + } + }) + + it('fails loudly on a broken symlink instead of shrinking the corpus', () => { + const tree = makeTree() + try { + // A healthy tree with symlinked files must not throw. + expect(() => uniqueRepoFiles(tree.root, ['a/*.md'])).not.toThrow() + } finally { + tree.clean() + } + const parent = mkdtempSync(join(tmpdir(), 'repo-files-broken-')) + try { + const root = join(parent, 'root') + mkdirSync(root, { recursive: true }) + writeFileSync(join(root, 'gone.md'), 'x\n') + symlinkSync(join(root, 'gone.md'), join(root, 'broken-link.md')) + rmSync(join(root, 'gone.md')) + // realpathSync on the matched broken link must throw, exactly as it did + // under the node-glob implementation, instead of silently shrinking the + // scanned corpus. + expect(() => uniqueRepoFiles(root, ['*.md'])).toThrow() + // A broken symlink under a literal non-final segment matches nothing, + // again as node glob silently returns no match for it. + symlinkSync(join(root, 'gone-dir'), join(root, 'broken-dir')) + expect(uniqueRepoFiles(root, ['broken-dir/*.md'])).toEqual([]) + } finally { + rmSync(parent, { recursive: true, force: true }) + } + }) + + it('rejects glob syntax the walker does not model instead of matching nothing', () => { + const tree = makeTree() + try { + // Node glob would interpret the bracket class; the walker must fail + // loudly rather than expand the pattern as a literal and quietly match + // nothing. + expect(() => uniqueRepoFiles(tree.root, ['**/*.[cm]d'])).toThrow(/does not model glob syntax/) + } finally { + tree.clean() + } + }) + + it('rejects a trailing ** segment instead of silently returning nothing', () => { + const tree = makeTree() + try { + expect(() => uniqueRepoFiles(tree.root, ['a/**'])).toThrow(/trailing \*\*/) + // A trailing slash yields an empty final segment that can never match. + expect(() => uniqueRepoFiles(tree.root, ['a/**/'])).toThrow(/empty segments/) + } finally { + tree.clean() + } + }) + + it('folds a . segment and rejects a .. segment like node glob semantics', () => { + const tree = makeTree() + try { + // Node glob normalizes a `.` segment away, so `./a/snap.md` matches + // a/snap.md instead of looking for a directory named `.`. + expect(uniqueRepoFiles(tree.root, ['./a/snap.md']).map(file => relative(tree.root, file.abs))) + .toEqual([join('a', 'snap.md')]) + expect(uniqueRepoFiles(tree.root, ['a/./snap.md']).map(file => relative(tree.root, file.abs))) + .toEqual([join('a', 'snap.md')]) + // A `..` segment escapes the scanned root; the walker must fail loudly + // rather than silently match nothing. + expect(() => uniqueRepoFiles(tree.root, ['a/../snap.md'])).toThrow(/does not model \.\. segments/) + } finally { + tree.clean() + } + }) +}) diff --git a/scripts/repo-files.ts b/scripts/repo-files.ts index 7c7aca276d..56d9cbf9f7 100644 --- a/scripts/repo-files.ts +++ b/scripts/repo-files.ts @@ -1,7 +1,7 @@ /** Shared repository file discovery and line-oriented reference scanning. */ -import { globSync, readFileSync, realpathSync } from 'node:fs' -import { relative, resolve, sep } from 'node:path' +import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { join, relative, resolve, sep } from 'node:path' /** One authored path plus its canonical target for symlink deduplication. */ export interface RepoFile { @@ -26,6 +26,173 @@ export function isArchivedAgentNotePath(path: string): boolean { return path.replaceAll('\\', '/').startsWith('.agents/notes/archived/') } +/** + * Whether a pattern segment matches a directory or file name. Supports `*` and + * `?` inside a segment and mirrors node's glob `dot: false`: a segment whose + * first character is a wildcard does not match dot names. `**` is handled as a + * whole segment by the walker, never here. + */ +function segmentMatches(pattern: string, name: string): boolean { + if (name.startsWith('.') && (pattern.startsWith('*') || pattern.startsWith('?'))) return false + let expression = '' + for (const character of pattern) { + if (character === '*') expression += '.*' + else if (character === '?') expression += '.' + else expression += character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + } + return new RegExp(`^${expression}$`).test(name) +} + +/** + * Reject glob syntax the walker does not model. Node's `fs.glob` understands + * character classes, brace alternation, and extglobs; expanding those + * silently as literals would match nothing and quietly shrink a gate's + * corpus, so a pattern segment using them fails loudly instead. Pure literal + * segments must therefore contain none of the rejected metacharacters + * either, even where node glob would read them literally. + */ +const UNSUPPORTED_GLOB = /[[\]{}()!+@\\]/ + +function assertSupportedSegment(segment: string): void { + if (UNSUPPORTED_GLOB.test(segment)) { + throw new TypeError(`repo-files walker does not model glob syntax in segment: ${segment}`) + } +} + +/** + * Walk `root` matching one repository-relative glob without node's `fs.glob`. + * The repository's own walker exists because node's internal glob, from some + * 24.x releases, lstat-probes `/` for symlinked files + * while expanding `**` and throws ENOTDIR instead of skipping (observed on + * node 24.13.0 scanning `snapshots/acp/image-compaction`'s symlinked + * `system-prompt.expected.md`). The walker decides directoryhood from dirent + * types and stat results, never by probing a path under a file, so the same + * tree enumerates identically on every node version. + * + * Segment semantics: literal segments resolve through `stat`, so a literal + * naming a symlinked directory enters that directory exactly as node glob + * does — including a literal repeated across segments, which node glob + * resolves each time; wildcard segments and `**` resolve through dirent + * types, so they never enter symlinked directories and never match dot + * names. Node documents `follow: false` only for `**` expansion, so the + * wildcard-side behavior is this walker's own contract, pinned by + * repo-files.spec.ts, rather than a cross-version node guarantee; the four + * consuming gates' current patterns contain no wildcard segment over a + * symlinked directory, so the corpus is unchanged. `**` spans zero or more + * directories. The final segment matches files and symlinks; a broken or + * cyclic symlink then fails loudly in the caller's realpathSync rather than + * shrinking the scanned corpus, while a broken symlink under a literal + * non-final segment matches nothing, again as node glob does. Traversal + * terminates without a visited set: each literal segment consumes one pattern + * segment per recursion, and `**` recurses only into real directories, which + * form a finite tree because symlinked directories are never expanded by it. + * Pattern segments support `*`, `?`, and literals that contain none of + * `[]{}()!+@\`; other node-glob syntax, including a trailing `**`, `..`, and + * empty segments, is rejected loudly up front, while a `.` segment is folded + * away exactly as node glob normalizes it. Returns repository-relative slash + * paths, sorted. + */ +function expandGlob(root: string, pattern: string): string[] { + const segments: string[] = [] + for (const segment of pattern.split('/')) { + if (segment === '.') { + // Node glob normalizes a `.` segment away; dropping it here keeps + // `./README.md` and `a/./b` matching exactly as node glob does instead + // of silently matching nothing. + continue + } + if (segment === '..') { + // A `..` segment escapes the scanned root and interacts with `**` in + // ways node glob special-cases; no gate pattern uses one, so the walker + // rejects the form loudly instead of silently matching nothing. + throw new TypeError(`repo-files walker does not model .. segments in pattern: ${pattern}`) + } + if (segment === '') { + // A leading, doubled, or trailing slash yields an empty segment that + // can never match an entry; node glob would tolerate the form, so the + // walker must reject it loudly instead of silently returning nothing. + throw new TypeError(`repo-files walker does not model empty segments in pattern: ${pattern}`) + } + segments.push(segment) + } + if (segments.length === 0) { + throw new TypeError(`repo-files walker does not model a pattern with no segments: ${pattern}`) + } + for (const segment of segments) { + if (segment !== '**') assertSupportedSegment(segment) + } + // A trailing `**` would match files, directories, and symlinks below the + // prefix; the walker models `**` only as a directory-spanning segment, so + // it must reject the form loudly instead of silently returning nothing. + if (segments[segments.length - 1] === '**') { + throw new TypeError(`repo-files walker does not model a trailing ** segment in pattern: ${pattern}`) + } + const out: string[] = [] + + const visit = (dirAbs: string, dirRel: string, index: number): void => { + if (index >= segments.length) return + if (segments[index] !== '**') { + visitSegment(dirAbs, dirRel, index) + return + } + // `**` consumes zero directories here and one directory per recursion. + visitSegment(dirAbs, dirRel, index + 1) + for (const entry of readdirSync(dirAbs, { withFileTypes: true })) { + if (entry.name.startsWith('.') || !entry.isDirectory()) continue + visit(join(dirAbs, entry.name), dirRel === '.' ? entry.name : `${dirRel}/${entry.name}`, index) + } + } + + const visitSegment = (dirAbs: string, dirRel: string, index: number): void => { + if (index >= segments.length) return + const segment = segments[index] + if (segment === undefined) return + if (segment === '**') { + visit(dirAbs, dirRel, index) + return + } + const last = index === segments.length - 1 + for (const entry of readdirSync(dirAbs, { withFileTypes: true })) { + if (!segmentMatches(segment, entry.name)) continue + const childAbs = join(dirAbs, entry.name) + const childRel = dirRel === '.' ? entry.name : `${dirRel}/${entry.name}` + if (last) { + // Files and symlinks match; a broken or cyclic symlink then fails + // loudly in the caller's realpathSync exactly as node glob did, + // rather than silently shrinking the scanned corpus. + if (entry.isFile() || entry.isSymbolicLink()) out.push(childRel) + continue + } + if (entry.isDirectory()) { + visitSegment(childAbs, childRel, index + 1) + continue + } + // A literal non-final segment resolves through stat like node glob's, + // so it enters a symlinked directory; wildcard segments never reach + // this branch because they are matched from dirent types above. + if (!entry.isSymbolicLink() || hasWildcard(segment)) continue + let target: ReturnType + try { + target = statSync(childAbs) + } catch { + // A broken symlink matches nothing under a literal segment, as node + // glob silently returns no match for it. + continue + } + if (!target.isDirectory()) continue + visitSegment(childAbs, childRel, index + 1) + } + } + + visit(root, '.', 0) + return out.sort() +} + +/** Whether a pattern segment contains `*` or `?` and is therefore wildcard. */ +function hasWildcard(segment: string): boolean { + return segment.includes('*') || segment.includes('?') +} + /** * Expand repository-relative globs and deduplicate symlinked files. * @param root - absolute repository root. @@ -41,8 +208,7 @@ export function uniqueRepoFiles( const seen = new Set() const files: RepoFile[] = [] for (const pattern of patterns) { - for (const match of globSync(pattern, { cwd: root })) { - const repoPath = match.split(sep).join('/') + for (const repoPath of expandGlob(root, pattern)) { if (isExcluded(repoPath)) continue const abs = resolve(root, repoPath) const real = realpathSync(abs) From acc8535390e9e74f4be33b66298b7dfd28c618e0 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Wed, 9 Sep 2026 17:31:36 +0800 Subject: [PATCH 03/17] docs(ci): record the canary failover value and the glob fix across internal notes The failover runbook, the node-compatibility note, the native-Windows note, and the serial-reference note record the third variable value's scope and exclusions (bilingual, pairing re-recorded); a new implemented process note owns the value's participating set, the dormant-by-default canary posture, and the walker-fix rationale. .github/AGENTS.md points at both notes. --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +-- ...7-21-serial-cross-platform-ci-reference.md | 2 +- ...1-serial-cross-platform-ci-reference.zh.md | 2 +- .../2026-07-26-ci-failover-runbook.i18n.yaml | 4 +-- .../process/2026-07-26-ci-failover-runbook.md | 10 +++---- .../2026-07-26-ci-failover-runbook.zh.md | 10 +++---- ...8-native-windows-pull-request-ci.i18n.yaml | 4 +-- ...26-08-08-native-windows-pull-request-ci.md | 2 +- ...08-08-native-windows-pull-request-ci.zh.md | 2 +- ...06-node-compatibility-selfhosted.i18n.yaml | 4 +-- ...026-09-06-node-compatibility-selfhosted.md | 2 +- ...-09-06-node-compatibility-selfhosted.zh.md | 2 +- ...26-09-09-blacksmith-failover-leg.i18n.yaml | 6 +++++ .../2026-09-09-blacksmith-failover-leg.md | 27 +++++++++++++++++++ .../2026-09-09-blacksmith-failover-leg.zh.md | 27 +++++++++++++++++++ .github/AGENTS.md | 2 +- 16 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md create mode 100644 .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 889010ca8d..2ce7922815 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: edb81b643d0cef2e5bc807005a9016324b8430ab -2026-07-21-serial-cross-platform-ci-reference.zh.md: 41fd9c032038f2a312978acf995febfdab34aeaa +2026-07-21-serial-cross-platform-ci-reference.md: 24022fea271d677a4588bd5dc9c7cb5b417ca8b7 +2026-07-21-serial-cross-platform-ci-reference.zh.md: c9fbc84d8ff91803acc6bcd008607fc03136b832 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index edb81b643d..24022fea27 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The active serial references run on the self-hosted `vm-backup` (`serial / linux`) and `dsh-win-ci` (`serial / windows`) pools; the one remaining disabled hosted serial reference (`serial-macos`) uses `macos-latest`, and there is no standard-hosted `serial / linux` label. The master-only Wine job runs on `ubuntu-latest`, while the pull-request native jobs use the hosted `dsh-windows-2025-16core` runner under normal operation, the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under the `selfhosted` failover value, and Blacksmith's Windows runners under the `blacksmith` value (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); see the [failover runbook](2026-07-26-ci-failover-runbook.md)), with build and targeted process checks required under the [native Windows decision](2026-08-08-native-windows-pull-request-ci.md). Required pull-request jobs use portable standard capacity under the [required-CI decision](../../archived/process/2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 41fd9c0320..c9fbc84d8f 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +当前启用的参考流程运行在公司自有 `vm-backup`(`serial / linux`)与 `dsh-win-ci`(`serial / windows`)自托管池上;唯一剩余的禁用托管参考作业(`serial-macos`)使用 `macos-latest`,且不存在标准托管的 `serial / linux` 标签。仅 master 触发的 Wine 作业在 `ubuntu-latest` 上运行,而拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,在 `selfhosted` 故障切换取值下使用自托管 `[self-hosted, dsh-win-ci, windows]` 池,在 `blacksmith` 取值下使用 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md);另见[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)),依据[原生 Windows 决策](2026-08-08-native-windows-pull-request-ci.zh.md),其中构建与定向进程检查参与必需聚合流程。依据[必需 CI 决策](../../archived/process/2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index 90fdf49b49..ed606bb0ef 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: f24cb8b8239141cd1ccf468a566dba620dd3cfdd -2026-07-26-ci-failover-runbook.zh.md: 57c4a92a3af720d9b11b7a1ce7a1515b83c77339 +2026-07-26-ci-failover-runbook.md: a114d9bd25b2d07c51205f91b175eaa6f498c0e8 +2026-07-26-ci-failover-runbook.zh.md: 677e757d55307e2f07de7fb139ec95206c908066 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index f24cb8b823..a114d9bd25 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -10,7 +10,7 @@ The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.ym ## Decision -The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; otherwise they retain their workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain `ubuntu-latest` fallback. Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. +The three primary Linux jobs (`node-24`, `node-24-coverage`, `node-24-consumers`), the three `node-compat` matrix entries, and `all-checks-passed` resolve through `DSH_CI_FAILOVER_LINUX`; the native Windows jobs resolve through `DSH_CI_FAILOVER_WINDOWS`. A platform switch does not redirect the other platform. Set to `selfhosted` by a repository writer, the applicable trusted jobs select `vm-backup` or `dsh-win-ci`; the `blacksmith` value routes the participating jobs per the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md); unset or any other value retains the workflow-defined hosted fallbacks. Node compatibility jobs require a same-repository, non-fork head and a non-Dependabot author, use isolated runtime setup, and retain the `ubuntu-latest` fallback under unset and non-special values; the blacksmith branch carries none of those predicates. Under the `selfhosted` value, Linux failover bounds snapshot concurrency and skips hosted package-cache restores. The verdict follows its workers so it does not remain queued on an unavailable hosted pool. Each switch is writer-manageable repository state, not a merge, so it works while checks are red. The `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes re-prove the complete unsharded aggregates on master pushes. `ci-master.yml` exempts exactly one event from `cancel-in-progress` (`${{ github.event_name != 'push' }}`), so one master push does not cancel the drill still running from the previous one. Each drill runs its complete unsharded aggregate with one gate worker, which takes longer than the interval between master merges; under unconditional cancellation a drill is superseded before reaching a verdict and the lane yields no readiness evidence for a responder to check. @@ -36,9 +36,9 @@ The two switches are independent: flip only the one whose platform is degraded. 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER_LINUX` (Linux pool outage) or `DSH_CI_FAILOVER_WINDOWS` (Windows pool outage), value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. -3. That is the entire switch. Under Linux failover the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool. +3. That is the entire switch. Under the `selfhosted` Linux failover value the workflow also drops `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 for the shared VM and skips the hosted-path pnpm cache restores because the VM's persistent store serves warm installs. Coverage uses the same four single-worker instrumented partitions and two exempt workers on both Linux pools. The Windows switch has no concurrency or cache branches; it only retargets the native Windows jobs' pool. -**Dependabot exception.** Both switches' selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. +**Dependabot exception.** Both switches' `selfhosted` legs deliberately exclude `dependabot[bot]`: under self-hosted failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. The `blacksmith` value's branches carry no such exclusion, because Blacksmith runners are ephemeral (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). **Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so each switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner groups admit all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VMs by pushing a branch workflow. The boundary against untrusted code is repository membership; the variables only route work for members. @@ -49,11 +49,11 @@ Capacity includes the master standby, main-CI jobs, and three release-rehearsal ### Switch back -Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to anything other than `selfhosted`). New runs resolve back to their hosted pools. Remove any extra instances that were registered during the incident. +Delete the `DSH_CI_FAILOVER_LINUX` or `DSH_CI_FAILOVER_WINDOWS` variable (or set it to any value other than `selfhosted` or `blacksmith`). New runs resolve back to their hosted pools. Setting it to `blacksmith` keeps the jobs on Blacksmith until the value changes. Remove any extra instances that were registered during the incident. ### Trust boundary -The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the selectors), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access. +The variables are writer-manageable repository state; a pull request event itself can neither set them nor read a different value into effect, and the selector expressions live in workflow definitions. Note that under failover, `pull_request` runs execute the PR merge ref's own workflow definition — the boundary against untrusted code is repository membership (private, forking disabled, Dependabot excluded by the `selfhosted` legs; the `blacksmith` legs carry no exclusion), not the variable. Note on runner-group policy: pinning the runner group to the master-ref workflow is **incompatible** with this failover — the failover jobs, including the Node compatibility matrix, are `pull_request` runs evaluated from PR merge refs, and a master-pinned group leaves them queued (observed live on 2026-07-27; the group was widened to all workflows of this repository to unblock the switch). A stricter runner-side policy therefore costs PR failover; the shipped posture accepts repository-scoped, all-workflow group access. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 57c4a92a3a..677e757d55 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;否则保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并保留 `ubuntu-latest` 回退。Linux 故障切换限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 +三个主要 Linux 作业(`node-24`、`node-24-coverage`、`node-24-consumers`)、三个 `node-compat` 矩阵条目和 `all-checks-passed` 通过 `DSH_CI_FAILOVER_LINUX` 解析;原生 Windows 作业通过 `DSH_CI_FAILOVER_WINDOWS` 解析。一个平台的开关不会重定向另一个平台。仓库写者将变量设为 `selfhosted` 时,适用的可信作业选择 `vm-backup` 或 `dsh-win-ci`;`blacksmith` 取值按 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md) 路由参与切换的作业;未设置或任何其它值保留工作流定义的托管回退。Node 兼容性作业要求同仓库且非 fork 的头部以及非 Dependabot 作者,使用隔离运行时设置,并在未设置与非特殊值下保留 `ubuntu-latest` 回退;blacksmith 分支不带上述任何条件。在 `selfhosted` 取值下,Linux 故障切换会限制快照并发,并跳过托管软件包缓存恢复。判定作业跟随工作作业,避免继续在不可用的托管池排队。每个开关都是写者可管理的仓库状态而非一次合并,因此在检查失败时仍然有效。`serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道在 master 推送上重新验证完整的未分片聚合流程。 `ci-master.yml` 只豁免一个事件不做取消(`${{ github.event_name != 'push' }}`),因此一次 master 推送不会取消上一次推送留下的、仍在运行的演练。每次演练以单门禁工作进程执行完整的未分片聚合流程,耗时长于 master 合并的间隔;在无条件取消下,演练会在得出结论前被后续运行取代,该通道无法产出供响应者查看的就绪证据。 @@ -36,9 +36,9 @@ Status: implemented 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER_LINUX`(Linux 池故障)或 `DSH_CI_FAILOVER_WINDOWS`(Windows 池故障),值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 -3. 切换到此完成。Linux 故障切换状态下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 +3. 切换到此完成。在 `selfhosted` 的 Linux 故障切换取值下,工作流还会把 `DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12,以限制共享虚拟机上的争抢,并跳过托管路径的 pnpm 缓存恢复,因为虚拟机的持久 store 会直接提供热安装。覆盖率在两个 Linux 池上都使用 4 个单 worker 插桩分区与 2 个豁免 worker。Windows 开关没有并发或缓存分支;它只重定向原生 Windows 作业的运行器池。 -**Dependabot 例外。**两个开关的选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 +**Dependabot 例外。**两个开关的 `selfhosted` 腿都刻意排除 `dependabot[bot]`:自托管故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。`blacksmith` 取值下的分支不带此类排除,因为 Blacksmith 运行器是临时的(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。 **谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此每个开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成升权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。 @@ -49,11 +49,11 @@ Linux 开关启用期间,容量需覆盖 master 热备、主 CI 作业,以 ### 切回 -删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 以外的任何值),新的运行即解析回各自的托管池。若故障期间追加注册过实例,将其移除。 +删除 `DSH_CI_FAILOVER_LINUX` 或 `DSH_CI_FAILOVER_WINDOWS` 变量(或改为 `selfhosted` 与 `blacksmith` 之外的任何值),新的运行即解析回各自的托管池。设为 `blacksmith` 会让作业留在 Blacksmith,直到该值改变。若故障期间追加注册过实例,将其移除。 ### 信任边界 -这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、选择器排除 Dependabot),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。 +这些变量是写者可管理的仓库状态;`pull_request` 事件本身既不能设置它们,也不能让不同的值生效,选择器表达式存在于工作流定义中。需要注意:故障切换期间,`pull_request` 运行执行的是 PR merge 引用自带的工作流定义——抵御不可信代码的边界是仓库成员资格(私有、禁 fork、Dependabot 由 `selfhosted` 腿排除;`blacksmith` 腿不带排除),而非该变量。关于 runner group 策略的说明:把 runner group 绑定到 master 引用的工作流与本故障切换机制**不兼容**——包括 Node 兼容性矩阵在内的故障切换作业是从 PR merge 引用求值的 `pull_request` 运行,master 绑定的组会让它们持续排队(2026-07-27 实际故障中亲历;当时将组放宽为本仓库全部工作流才疏通了切换)。更严格的运行器侧策略以牺牲 PR 故障切换为代价;当前采用的形态是仓库范围、全工作流的组访问。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml index 56858d05b2..a542599011 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md -2026-08-08-native-windows-pull-request-ci.md: 690f8e6f9b13fa7e72240a42ff482bd83f9088b0 -2026-08-08-native-windows-pull-request-ci.zh.md: 9efa3cbcf33b6c12e4eed253b6a0546c79b768fe +2026-08-08-native-windows-pull-request-ci.md: e4fc7cab8c274148191632e8cc125ac75f2ec1d5 +2026-08-08-native-windows-pull-request-ci.zh.md: 27ad602c3748f2920e6b63f271a6a38b11c0ab77 diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md index 690f8e6f9b..e4fc7cab8c 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md @@ -14,7 +14,7 @@ A coverage audit found that stale branch state had restored temporary exclusions The master-only `windows` job in [ci-master.yml](../../../../.github/workflows/ci-master.yml) runs `windows node 24 / wine` on `ubuntu-latest`. It retains the checksum-verified Windows Node, Wine apt and pnpm caches, a hoisted install confined to a workspace snapshot, and the [shared Wine gate script](../../../../scripts/wine-windows-gates.sh) that runs the workspace build and production site. Node distribution transfers use bounded retries; when nodejs.org stalls on the large archive, a range-capable transport mirror resumes the same bytes, but nodejs.org remains the version and SHA-256 authority and the archive is never promoted before that checksum passes. Wine is outside the PR aggregate under the [master-only platform policy](2026-09-06-master-only-platform-ci.md). The [archived Wine experiment](../../archived/process/2026-07-27-wine-windows-gates-experiment.md) preserves its measured trade-offs, while this note owns the current dual topology. -Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable retargets all four jobs to the in-house pool. Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline. +Every pull request also starts four independent native jobs on the organization-owned `dsh-windows-2025-16core` runner: `windows-build`, `windows-coverage`, `windows-native-tests`, and `windows-observational`. Each job enables Developer Mode for workspace symlinks, provisions the repository-pinned pnpm through `pnpm/action-setup`, performs an immutable install without a transferred store archive, and runs its inventory under native PowerShell. The Windows failover variable (`DSH_CI_FAILOVER_WINDOWS`) retargets all four jobs to the in-house pool under `selfhosted` and to Blacksmith's Windows runners under `blacksmith` (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). Per-job deadlines range from 60 to 120 minutes and bound stuck work without treating a performance target as a correctness deadline. `windows-build` and `windows-native-tests` are dependencies of `all checks passed`; their workspace-build and targeted native-process results are blocking. `windows-coverage` remains an ordinary job but is absent from aggregate `needs`, so its 100%-per-file result stays red and visible without delaying the required verdict. `windows-observational` is also absent from aggregate `needs` and uses `continue-on-error` because Linux owns the blocking static, documentation, package, and built-artifact verdicts. diff --git a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md index 9efa3cbcf3..27ad602c37 100644 --- a/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.zh.md @@ -14,7 +14,7 @@ Wine 在 Linux 内核与区分大小写的 ext4 之上采用 hoisted 依赖布 [ci-master.yml](../../../../.github/workflows/ci-master.yml) 中仅 master 触发的 `windows` 作业在 `ubuntu-latest` 上运行 `windows node 24 / wine`。它保留经过校验和验证的 Windows Node、Wine apt 与 pnpm 缓存、仅限工作区快照的 hoisted 安装,以及运行工作区构建与生产网站的[共享 Wine 门禁脚本](../../../../scripts/wine-windows-gates.sh)。Node 分发文件传输采用有界重试;nodejs.org 的大文件传输停滞时,由支持范围请求的传输镜像续传相同字节,但版本和 SHA-256 权威仍属于 nodejs.org,归档通过该校验前绝不会投入使用。根据[仅 master 平台策略](2026-09-06-master-only-platform-ci.zh.md),Wine 不参与 PR 聚合。[已归档的 Wine 实验](../../archived/process/2026-07-27-wine-windows-gates-experiment.md)保留其实测取舍,而本文负责当前双通道拓扑。 -每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量会把这 4 个作业全部重定向到公司内部运行器池。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。 +每个拉取请求还会在组织自有的 `dsh-windows-2025-16core` 运行器上启动 4 个相互独立的原生作业:`windows-build`、`windows-coverage`、`windows-native-tests` 与 `windows-observational`。每个作业都会为工作区符号链接启用开发人员模式,通过 `pnpm/action-setup` 提供仓库固定版本的 pnpm,在不传输 store 归档的情况下执行不可变安装,并在原生 PowerShell 下运行自己的清单。Windows 故障切换变量(`DSH_CI_FAILOVER_WINDOWS`)在 `selfhosted` 下把这 4 个作业全部重定向到公司内部运行器池,在 `blacksmith` 下重定向到 Blacksmith 的 Windows 运行器(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。各作业采用 60 至 120 分钟的截止时间,以约束卡住的工作,同时不把性能目标当作正确性截止时间。 `windows-build` 与 `windows-native-tests` 是 `all checks passed` 的依赖项;其工作区构建和定向原生进程结果具有阻断性。`windows-coverage` 仍是常规作业,但不在聚合流程的 `needs` 中,因此逐文件 100% 覆盖率结果会保持红灯并可见,却不会延迟必需判定。`windows-observational` 同样不在聚合流程的 `needs` 中,并使用 `continue-on-error`,因为静态检查、文档、包与构建产物的阻断性判定由 Linux 负责。 diff --git a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml index 1cde51d814..397723916f 100644 --- a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md -2026-09-06-node-compatibility-selfhosted.md: c78092834123b837d100814be9beba52c1a41397 -2026-09-06-node-compatibility-selfhosted.zh.md: 6dcff8aa197c0995e4e90d2d56179340a41bc783 +2026-09-06-node-compatibility-selfhosted.md: c361d21d3e1093dd5c87bf2ba085bdd1acacb5d8 +2026-09-06-node-compatibility-selfhosted.zh.md: 80a9d8519d6084f5e01944b277882a51b2291e94 diff --git a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md index c780928341..c361d21d3e 100644 --- a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md +++ b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.md @@ -10,7 +10,7 @@ The Node 22.19, 24.9, and 26 compatibility jobs consume hosted Linux minutes eve ## Decision -[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration. +[CI](../../../../.github/workflows/ci.yml) applies the Linux failover variable to these three jobs, requiring a non-Dependabot author and a non-fork head repository matching the current repository. The standard hosted fallback remains available. These predicates constrain this job, not every workflow admitted to the pool. Both repository identity and fork status remain explicit to preserve its trust restriction if repository settings change; existing sibling selectors are outside this migration. The `blacksmith` failover value's branch drops those predicates: it targets ephemeral Blacksmith runners, so repository identity and fork status do not gate it (see the [blacksmith failover leg note](2026-09-09-blacksmith-failover-leg.md)). The temporary tool cache trades repeated Node downloads for isolation across concurrent runners and Node versions. A setup-node-only [ESM preload](../../../../scripts/ci-compatible-toolcache.mjs) assigns the cache inside the action process: the Actions runner overwrites reserved environment variables after reading step configuration. An executed path check rejects installations outside runner temp; compatibility processes do not inherit the preload. pnpm keeps its existing private setup destination and persistent content-addressed store. Compile caches and node-gyp headers use runner temp before the first pnpm invocation. No global Node symlink or system package changes are introduced. Hosted jobs retain their tool and package caching; self-hosted jobs do not restore or upload hosted package caches. The runner owns temporary-directory cleanup between jobs, and the shared image supplies native npm packages’ compiler and Python prerequisites. diff --git a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md index 6dcff8aa19..80a9d8519d 100644 --- a/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md +++ b/.agents/notes/implemented/process/2026-09-06-node-compatibility-selfhosted.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。 +[CI](../../../../.github/workflows/ci.yml) 将 Linux 故障切换变量应用于这三个作业,要求作者不是 Dependabot,且非 fork 的头部仓库与当前仓库相同。标准托管回退仍然可用。这些条件约束本作业,而非所有可进入该池的工作流。仓库身份和 fork 状态均显式保留,以便在仓库设置改变时保持本作业的信任限制;现有兄弟选择器不属于本次迁移范围。`blacksmith` 故障切换取值下的分支放弃这些条件:它面向临时的 Blacksmith 运行器,因此仓库身份与 fork 状态不参与门控(见 [blacksmith 故障切换支路笔记](2026-09-09-blacksmith-failover-leg.zh.md))。 临时工具缓存以重复下载 Node 为代价,换取并发运行器与 Node 版本之间的隔离。仅用于 setup-node 的 [ESM 预加载模块](../../../../scripts/ci-compatible-toolcache.mjs) 在 action 进程内指定缓存:Actions 运行器在读取步骤配置后会覆盖保留的环境变量。实际执行的路径检查拒绝运行器临时目录之外的安装;兼容性进程不继承预加载设置。pnpm 保留现有的私有安装目录和持久化内容寻址 store。编译缓存与 node-gyp 头文件在首次调用 pnpm 前就使用运行器临时目录。不引入全局 Node 符号链接或系统软件包变更。托管作业保留其工具与软件包缓存;自托管作业不恢复或上传托管软件包缓存。运行器负责作业之间的临时目录清理,共享镜像提供原生 npm 软件包所需的编译器和 Python 前置依赖。 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml new file mode 100644 index 0000000000..cc535290ee --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md +2026-09-09-blacksmith-failover-leg.md: eaaa6f4d5c8326ae7686dc9f485143781d065313 +2026-09-09-blacksmith-failover-leg.zh.md: 562aed96327a86c24e38b502f2ef4756a11b91e1 diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md new file mode 100644 index 0000000000..eaaa6f4d5c --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md @@ -0,0 +1,27 @@ +# Agent Note: Blacksmith as a failover leg for CI pools + +Status: implemented + +English | [中文](2026-09-09-blacksmith-failover-leg.zh.md) + +## Problem + +The [failover runbook](2026-07-26-ci-failover-runbook.md) routes degraded enterprise-pool traffic onto the in-house `vm-backup` and `dsh-win-ci` pools, which are proven but bounded standbys. Blacksmith sells GitHub-hosted-style runners on demand, and its migration wizard proposed replacing the repository's `runs-on` labels wholesale, which would put credential-holding, benchmark, and spec-pinned jobs on third-party infrastructure as the default. The wholesale replacement is not acceptable as a default: it breaks the workflow contract specs, distorts benchmark and performance-budget measurements, and moves trust-boundary jobs without a decision record. + +## Decision + +`DSH_CI_FAILOVER_LINUX` and `DSH_CI_FAILOVER_WINDOWS` additionally accept the value `blacksmith`. When any repository writer sets it, the platform's participating jobs retarget onto Blacksmith's hosted runners at the vCPU size matching the pool each job replaces; any other value (or unset) keeps today's runner, and `selfhosted` keeps routing to the in-house pools. Like the existing values this is writer-manageable repository state — no merge, re-run the affected jobs — and it costs money only while set, which is why it is an explicit outage or experiment choice, never the default. + +Participating jobs under `blacksmith`: the three enterprise Linux workers, the `node-compat` legs, the `all-checks-passed` verdict, and the four Windows lanes in [ci.yml](../../../../.github/workflows/ci.yml) (16-core pools map to `blacksmith-16vcpu-ubuntu-2404` and `blacksmith-16vcpu-windows-2025`, standard-hosted legs to `blacksmith-4vcpu-ubuntu-2404`); `expected-filenames`; the sandbox bwrap leg; and the two manual benchmark matrices, whose 4-32 vCPU tiers map to Blacksmith equivalents while the 64/96-core rows keep the fleet labels because Blacksmith offers no such tier. The blacksmith branches carry no Dependabot exclusion: Blacksmith runners are ephemeral, so the persistent-VM rationale that keeps Dependabot on the hosted pool does not apply. The python-runtime builder stays hosted in full: its PR and master callers (`ci.yml`'s and `ci-master.yml`'s `python-runtime` jobs) pass real-API credentials (`DEEPSEEK_API_KEY_EXTERNAL`) into the reusable workflow, its macOS and arm64 single-exe legs need GitHub's images, and its wheel output feeds the PyPI publication chain. The docs-pages workflow is a tag-dispatched release publication, so it stays hosted too. + +Everything else keeps its own runner under every value: jobs pinned to standard hosted runners by workflow spec (`node-24-bench`, `python-sdk`, the master Wine gate, `release-publish` and `release-vendor-publish`, the Cloudflare preview, the weighted-approval pair); jobs that hold credentials (the real-API e2e lanes, the issue App-token jobs, the npm and PyPI publishers); jobs that need a host capability Blacksmith lacks (the Landlock legs and their pack verification, the Seatbelt leg, the linux-arm64 single-exe leg); and the release-shaped chains — the npm publishers, the PyPI publishers, the node-addon-system release chain, and the Pages deployment workflow — which stay entirely hosted because their artifacts feed published registry output. The release rehearsal jobs in `release.yml` and `release-vendor.yml` (`dependencies`, `pack`) keep their existing pre-change routing unchanged: they carry only the pre-existing `selfhosted` branch, so under `blacksmith` they fall back to their hosted `ubuntu-24.04` default exactly as under an unset variable, and they gain no `blacksmith` branch. + +## Alternatives considered + +Adopting the migration wizard wholesale. Rejected because it breaks the workflow contract specs, changes benchmark semantics and performance-budget calibration, and moves credential-holding and trust-boundary jobs to third-party infrastructure as the default; the earlier wizard migration (closed PR #3053) was rejected for cost and the same review findings. + +Keeping the in-house pools as the only failover target. Rejected as the sole option because they are a bounded standby; Blacksmith adds elastic capacity for the outage and experiment cases where the fleet itself is the degraded resource. + +## Consequences + +The blacksmith legs stay dormant until the value is set, so they are exercised only during an actual failover or an explicit experiment. The mirror trial ([PR #3841](https://github.com/deepseek-harness/deepseek-harness/pull/3841), closed) ran the participating set hardcoded onto the Blacksmith labels and confirmed that `blacksmith-4vcpu-ubuntu-2404`, `blacksmith-16vcpu-ubuntu-2404`, and `blacksmith-16vcpu-windows-2025` schedule and execute. Blacksmith registers its runners as self-hosted, so under the `blacksmith` value the `node-compat` legs run their toolcache-isolation and isolated-installation verification steps (gated on `runner.environment == 'self-hosted'`) rather than skipping them; all three legs passed on the trial image ([run 34322356689](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34322356689)). The same trial surfaced one environment finding that this change fixes: while expanding `**`, node 24.13's internal `fs.glob` lstat-probes `/` for a symlinked file and throws ENOTDIR instead of skipping ([run 34319926270](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34319926270)), failing `verify-md-wrap` inside `check:ci:static` on Blacksmith until [repo-files.ts](../../../../scripts/repo-files.ts) replaced `globSync` with a dirent walker; the fixed static lane is green on the same image. The trigger is the lstat-probe of a path beneath a matched symlink, not `**` over symlinked trees in general: the other `globSync` `**` call sites that scan symlink-bearing trees stayed green on the same run and needed no change. The benchmark matrices' 8 and 32 vCPU labels (ubuntu-2404 and windows-2025) were not part of the trial and are inferred by naming symmetry from the verified 4 and 16 vCPU labels; confirm with a manual dispatch before first use. The Landlock, Seatbelt, and linux-arm64 exclusions still rest on the prose dispatch measurements of the earlier migration attempt (closed PR #3053), recorded here as a known gap; the trial could not exercise those legs by design. Workflow specs pin the new branches — `ci-workflow.spec.ts` asserts the Blacksmith labels appear in the extended selectors, and `ci-compatible-selfhosted.spec.ts` evaluates the `blacksmith` routing mode — so a future rewrite of the selectors fails the unit gates. Default cost posture is unchanged: nothing runs on Blacksmith unless the variable says so. diff --git a/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md new file mode 100644 index 0000000000..562aed9632 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.zh.md @@ -0,0 +1,27 @@ +# Agent Note: Blacksmith 作为 CI 池的故障切换支路 + +Status: implemented + +[English](2026-09-09-blacksmith-failover-leg.md) | 中文 + +## 问题 + +[故障切换手册](2026-07-26-ci-failover-runbook.zh.md)把降级的企业池流量路由到自有的 `vm-backup` 与 `dsh-win-ci` 池——它们是经过验证但容量有界的备用池。Blacksmith 按需出售类似 GitHub 托管的运行器,其迁移向导提议整体替换仓库的 `runs-on` 标签,这会把持有凭据、基准与 spec 钉死的作业默认放到第三方基础设施上。整体替换不适合作为默认:它会打破 workflow 契约 spec、扭曲基准与性能预算测量,并在没有决策记录的情况下搬动信任边界作业。 + +## 决策 + +`DSH_CI_FAILOVER_LINUX` 与 `DSH_CI_FAILOVER_WINDOWS` 额外接受值 `blacksmith`。任何仓库写者设置它时,该平台参与切换的作业按与被替换池匹配的 vCPU 档位重定向到 Blacksmith 托管运行器;任何其它值(或未设置)保持今天的运行器,`selfhosted` 继续路由到自有池。与既有取值一样,这是写者可管理的仓库状态——无需合并,重跑受影响的作业即可——并且只在设置期间产生费用,这正是它作为明确的故障或实验选择、而非默认的原因。 + +`blacksmith` 下参与切换的作业:[ci.yml](../../../../.github/workflows/ci.yml) 中三个企业级 Linux 工作作业、`node-compat` 各腿、`all-checks-passed` 判定作业与四条 Windows 通道(16 核池映射到 `blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025`,标准托管腿映射到 `blacksmith-4vcpu-ubuntu-2404`);`expected-filenames`;sandbox 的 bwrap 腿;以及两个手动基准矩阵——其 4-32 vCPU 档位映射到 Blacksmith 等价档,64/96 核行保留自有池标签,因为 Blacksmith 没有对应档位。blacksmith 分支不带 Dependabot 排除:Blacksmith 运行器是临时的,因此把 Dependabot 留在托管池的持久虚拟机理由不适用。python-runtime 构建器整体保持托管:其 PR 与 master 调用方(`ci.yml` 与 `ci-master.yml` 的 `python-runtime` 作业)会把真实 API 凭据(`DEEPSEEK_API_KEY_EXTERNAL`)传入该可复用工作流,其 macOS 与 arm64 单文件分支需要 GitHub 镜像,且其 wheel 产物供给 PyPI 发布链。docs-pages 工作流是 tag 派发的发布,因此同样保持托管。 + +其余作业在任何取值下都保留自己的运行器:被 workflow spec 钉死在标准托管运行器上的作业(`node-24-bench`、`python-sdk`、master 的 Wine 门禁、`release-publish` 与 `release-vendor-publish`、Cloudflare preview、weighted-approval 一对);持有凭据的作业(真实 API 的 e2e 通道、issue App token 作业、npm 与 PyPI 发布作业);需要 Blacksmith 缺失的宿主能力的作业(Landlock 各腿及其打包校验、Seatbelt 腿、linux-arm64 单文件分支);以及发布形态的链——npm 发布、PyPI 发布、node-addon-system 发布链与 Pages 部署工作流——它们整体保持托管,因为其产物供给已发布的 registry 输出。`release.yml` 与 `release-vendor.yml` 中的发布演练作业(`dependencies`、`pack`)保持改动前既有的路由不变:它们只带既有的 `selfhosted` 分支,因此在 `blacksmith` 取值下与变量未设置时一样回落到托管的 `ubuntu-24.04` 默认,并不新增 `blacksmith` 分支。 + +## 备选方案 + +整体采纳迁移向导。否决,因为它打破 workflow 契约 spec、改变基准语义与性能预算校准,并默认把持有凭据与信任边界的作业搬到第三方基础设施;更早的向导迁移(已关闭的 PR #3053)因成本与同样的 review 结论被否决。 + +只把自有池作为唯一故障切换目标。作为唯一选项被否决,因为它是容量有界的备用;Blacksmith 为「故障对象正是自有池本身」的故障与实验场景提供弹性容量。 + +## 后果 + +blacksmith 各腿在有人设置该值前保持休眠,因此只在真实故障切换或明确实验期间被使用。镜像试运行([PR #3841](https://github.com/deepseek-harness/deepseek-harness/pull/3841),已关闭)把参与集硬编码到 Blacksmith 标签上跑过,确认 `blacksmith-4vcpu-ubuntu-2404`、`blacksmith-16vcpu-ubuntu-2404` 与 `blacksmith-16vcpu-windows-2025` 可调度可执行。Blacksmith 将其运行器注册为 self-hosted,因此在 `blacksmith` 取值下 `node-compat` 各腿会真实执行 toolcache 隔离与隔离安装校验步骤(以 `runner.environment == 'self-hosted'` 为条件),而非全部跳过;三条腿都在试运行镜像上通过([run 34322356689](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34322356689))。同一试运行还暴露了一个由本改动修复的环境发现:在 `**` 展开中遇到 symlink 文件时,node 24.13 的内部 `fs.glob` 会 lstat-probe `/<下一段>` 并抛 ENOTDIR 而非跳过([run 34319926270](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34319926270)),使 `check:ci:static` 内的 `verify-md-wrap` 在 Blacksmith 上失败,直到 [repo-files.ts](../../../../scripts/repo-files.ts) 用 dirent walker 替换 `globSync`;修复后的 static 通道在同一镜像上转绿。触发条件是 probe 已匹配 symlink 之下的路径,而非笼统的「`**` 扫过含 symlink 的树」:同一 run 中其它扫描含 symlink 树的 `globSync` `**` 调用点保持绿色,无需改动。两个基准矩阵的 8 与 32 vCPU 标签(ubuntu-2404 与 windows-2025)不在试运行范围内,是按已实测的 4/16 vCPU 标签命名对称推定的;首次使用前请用一次手动 dispatch 确认。Landlock、Seatbelt 与 linux-arm64 的排除仍基于更早迁移尝试(已关闭的 PR #3053)的散文式 dispatch 实测,此处记录为已知缺口;试运行按设计无法触发这几条腿。workflow spec 钉住了新分支——`ci-workflow.spec.ts` 断言扩展后的 selector 中出现 Blacksmith 标签,`ci-compatible-selfhosted.spec.ts` 求值 `blacksmith` 路由模式——因此未来重写 selector 会使单元门禁变红。默认成本姿态不变:除非变量指明,否则没有任何东西跑在 Blacksmith 上。 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 100385608f..f4f3c5d555 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches and Dependabot hosted fallback ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md)). +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Windows build and process checks contribute to the pull-request `all checks passed` verdict; Wine runs Windows Node on hosted Linux only in `ci-master.yml`. Python runtime CI checks Linux/Windows x64 on pull requests and Linux ARM64 plus both macOS architectures on master pushes; releases retain all five targets ([platform policy](../.agents/notes/implemented/process/2026-09-06-master-only-platform-ci.md)). `ci.yml` is pull-request-only. Master-only platform checks, Linux/Windows self-hosted standbys, and manual runner benchmarks live in `ci-master.yml`, which listens to master pushes and `workflow_dispatch`, not `pull_request`; separating workflow triggers keeps master-only jobs out of PR check panels. The master standbys validate the self-hosted failover targets; preserve the existing per-platform switches (values `selfhosted` for the in-house standbys and `blacksmith` for Blacksmith's hosted runners) and the Dependabot hosted fallback under the `selfhosted` values ([failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md), [blacksmith failover leg note](../.agents/notes/implemented/process/2026-09-09-blacksmith-failover-leg.md)). From 220678e57d135e21817dc31bc621e6b8111005e3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 9 Sep 2026 21:03:44 +0800 Subject: [PATCH 04/17] feat(manifest): declare plugin format, tags, and host compatibility --- .../2026-09-05-package-manifest-types.i18n.yaml | 4 ++-- .../2026-09-05-package-manifest-types.md | 4 ++++ .../2026-09-05-package-manifest-types.zh.md | 4 ++++ packages/util/package-manifest/README.i18n.yaml | 4 ++-- packages/util/package-manifest/README.md | 16 +++++++++++++++- packages/util/package-manifest/README.zh.md | 16 +++++++++++++++- packages/util/package-manifest/src/index.ts | 1 + packages/util/package-manifest/src/types.ts | 12 ++++++++++++ 8 files changed, 55 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml index c4907f9030..a15fd90d6d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md -2026-09-05-package-manifest-types.md: 94317a9317ba12059e726612840e4a001be2a892 -2026-09-05-package-manifest-types.zh.md: 2c40facd2591921123b5eae71b80c516f3b1f697 +2026-09-05-package-manifest-types.md: 5e9834583a75a3fd4f1ec69379628c49d264e05b +2026-09-05-package-manifest-types.zh.md: 8a2399a19a097113d45f4915631d36ca30191dd5 diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md index 94317a9317..5e9834583a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md @@ -12,6 +12,8 @@ External packages need Harness manifest types without depending on boot or clien [`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. Author declarations and launcher-generated module fallback metadata are explicitly distinguished. +Author metadata stays under `package.json.dsh`: optional `manifestVersion` identifies format `1`, `categories` carries free-form discovery tags, and `engines.dsh` declares compatible host versions as a SemVer range. Npm package identity and Node requirements retain their top-level fields. Categories are extensible tags rather than a closed role enum, so labels such as `skills` and `tools` do not constrain package composition. + Readers import the shared declarations directly. Boot retains profile loading, raw JSON checks, defaults, and resolved runtime data. Client modules retain their normalized boot graph. The image packer resolves declared paths into directories. The Session catalog generator derives a read-only validated entry with a resolved import path; raw inputs and discovery rules remain local. App-boot declares a production dependency because its published declarations reference the shared types. Client modules, the private packer, and root scripts use development dependencies because their published APIs do not expose these types. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports. @@ -29,3 +31,5 @@ App-boot declares a production dependency because its published declarations ref Authors gain one public import path at the cost of a published package and explicit dependency edges. Existing app-boot manifest type imports must use the new package. The [profile composition design](2026-08-05-profile-plugin-bundles.md) continues to own runtime semantics; type extraction does not change configuration acceptance or model-visible behavior. Compiler and packaged NodeNext consumer checks cover public imports. Existing profile, client, image configuration, and Session catalog tests cover reader behavior; documentation checks cover the utility classification and generated package catalogs. Optional declaration fields still require deliberate consumer updates when added. + +Manifest format and host compatibility declarations have no enforcement in current installers or loaders. The type-only package supplies neither a SemVer parser nor an installation policy; its README records that limitation so an author declaration is not mistaken for a compatibility check. diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md index 2c40facd25..8a2399a19a 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md @@ -12,6 +12,8 @@ Status: implemented [`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。作者声明与启动器生成的模块后备元数据有明确区分。 +作者元数据保留在 `package.json.dsh` 下:可选的 `manifestVersion` 标识格式 `1`,`categories` 承载自由发现标签,`engines.dsh` 以 SemVer 范围声明兼容的宿主版本。npm 包身份和 Node 要求沿用顶层字段。Categories 是可扩展标签,而非封闭的角色枚举,因此 `skills`、`tools` 等标签不限制包的组合。 + 各读取方直接导入共享声明。启动器保留 profile 加载、原始 JSON 检查、默认值和解析后的运行时数据。客户端模块保留归一化的启动图。镜像打包器将声明路径解析为目录。Session 目录生成器派生带有已解析导入路径的只读校验结果;原始输入和发现规则仍由本地负责。 App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块、私有打包器和根脚本使用开发依赖,因为其发布 API 不暴露这些类型。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。 @@ -29,3 +31,5 @@ App-boot 声明生产依赖,因为其发布的声明文件引用共享类型 作者获得统一的公共导入路径,代价是一个发布包和明确的依赖边。已有的 app-boot manifest 类型导入需要改用新包。[Profile 组合设计](2026-08-05-profile-plugin-bundles.zh.md) 继续负责运行时语义;类型提取不改变配置接受范围或模型可见行为。 编译器与打包后的 NodeNext 消费方检查覆盖公共导入。已有 profile、客户端、镜像配置和 Session 目录测试覆盖读取行为;文档检查覆盖工具库分类与生成的包目录。新增可选声明字段时,仍需主动更新消费方。 + +当前安装器和加载器不强制检查 manifest 格式或宿主兼容性声明。纯类型包既不提供 SemVer 解析器,也不提供安装策略;其 README 记录此限制,避免将作者声明误认为兼容性检查。 diff --git a/packages/util/package-manifest/README.i18n.yaml b/packages/util/package-manifest/README.i18n.yaml index 6a8cad4326..f25db6d863 100644 --- a/packages/util/package-manifest/README.i18n.yaml +++ b/packages/util/package-manifest/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/package-manifest/README.md -README.md: 971df84d7db3205eb8be75ba895046d4151780d2 -README.zh.md: 8989cbe9bc29bbf8112d5465eac9806c211fa88d +README.md: 428ed05af9856d4a1a77be9c152130fa26464bb9 +README.zh.md: cdf6302b537a458c010f5cd8b3531131fc6a21ac diff --git a/packages/util/package-manifest/README.md b/packages/util/package-manifest/README.md index 971df84d7d..428ed05af9 100644 --- a/packages/util/package-manifest/README.md +++ b/packages/util/package-manifest/README.md @@ -32,12 +32,25 @@ import type { DshClientManifest, DshManifest } from '@deepseek-ai/dsh-package-ma const client: DshClientManifest = { platform: 'web' } const dsh: DshManifest = { + manifestVersion: 1, + categories: ['skills', 'tools'], + engines: { dsh: '0.1.5-alpha.1' }, bundle: { patch: './cordis.patch.yml' }, client, } ``` -`DshManifest` describes `bundle`, `profile`, `client`, `configTrees`, `sessionFormatMigration`, and `moduleFallback`, not the surrounding npm manifest. `moduleFallback` is launcher-generated metadata and is not an author configuration entry. TypeScript checks this object and erases `import type` during compilation; JSON files cannot import types, and this example does not write a `package.json`. See [`src/types.ts`](src/types.ts) for the declarations. +`DshManifest` describes `package.json.dsh`; npm fields such as `name`, `version`, and `engines.node` stay at the package root. TypeScript checks this object and erases `import type` during compilation; JSON files cannot import types, and this example does not write a `package.json`. + +All three author metadata fields are optional. Omitting them leaves the format version, tags, or compatible host versions undeclared; readers do not infer defaults. + +| Field | Meaning | +|---|---| +| `manifestVersion` | Manifest format identifier; the declared format is `1`, independent of the npm package version and Session format version. | +| `categories` | Free-form discovery tags such as `skills` and `tools`, with no fixed taxonomy or effect on plugin loading. | +| `engines.dsh` | Author-declared compatible DSH versions as a SemVer range. An exact prerelease version is valid; the `dsh` member is required when `engines` is present. | + +Composition and build declarations are defined in [`src/types.ts`](src/types.ts). `moduleFallback` is launcher-generated metadata and is not an author configuration entry. ----- @@ -73,6 +86,7 @@ Type declarations add no model input, so provider cache reuse is unaffected. - **Static typing only.** These declarations do not validate JSON, check file existence, or supply defaults. `configTrees` serves the experimental image packer, and `sessionFormatMigration` is discovered only for workspace migration packages; declaring them does not register external plugin behavior. +- **Compatibility is declarative.** Current installers and loaders do not enforce `manifestVersion` or `engines.dsh`; declaring a range does not reject incompatible hosts or validate SemVer syntax. ### Dev Note diff --git a/packages/util/package-manifest/README.zh.md b/packages/util/package-manifest/README.zh.md index 8989cbe9bc..cdf6302b53 100644 --- a/packages/util/package-manifest/README.zh.md +++ b/packages/util/package-manifest/README.zh.md @@ -32,12 +32,25 @@ import type { DshClientManifest, DshManifest } from '@deepseek-ai/dsh-package-ma const client: DshClientManifest = { platform: 'web' } const dsh: DshManifest = { + manifestVersion: 1, + categories: ['skills', 'tools'], + engines: { dsh: '0.1.5-alpha.1' }, bundle: { patch: './cordis.patch.yml' }, client, } ``` -`DshManifest` 描述 `bundle`、`profile`、`client`、`configTrees`、`sessionFormatMigration` 和 `moduleFallback`,不包含外层 npm manifest。`moduleFallback` 是启动器生成的元数据,不是作者配置项。TypeScript 检查该对象,并在编译时删除 `import type`;JSON 文件不能导入类型,此示例也不会写入 `package.json`。声明见 [`src/types.ts`](src/types.ts)。 +`DshManifest` 描述 `package.json.dsh`;`name`、`version`、`engines.node` 等 npm 字段仍位于包的顶层。TypeScript 检查该对象,并在编译时删除 `import type`;JSON 文件不能导入类型,此示例也不会写入 `package.json`。 + +这三个作者元数据字段均可选。省略时,格式版本、标签或兼容的宿主版本保持未声明状态;读取方不推断默认值。 + +| 字段 | 含义 | +|---|---| +| `manifestVersion` | manifest(元数据清单)格式标识;声明的格式为 `1`,独立于 npm 包版本和 Session 格式版本。 | +| `categories` | `skills`、`tools` 等自由发现标签,无固定分类体系,不影响插件加载。 | +| `engines.dsh` | 作者声明的兼容 DSH 版本,使用 SemVer 范围。可填写精确的预发布版本;存在 `engines` 时,必须填写 `dsh` 成员。 | + +组合与构建声明定义在 [`src/types.ts`](src/types.ts) 中。`moduleFallback` 是启动器生成的元数据,不是作者配置项。 ----- @@ -73,6 +86,7 @@ const dsh: DshManifest = { - **仅提供静态类型。** 这些声明不校验 JSON、不检查文件存在性,也不提供默认值。`configTrees` 服务于实验性镜像打包器,`sessionFormatMigration` 仅从工作区迁移包中发现;声明它们不会注册外部插件行为。 +- **兼容性仅作声明。** 当前安装器和加载器不强制检查 `manifestVersion` 或 `engines.dsh`;声明范围不会拒绝不兼容的宿主,也不会校验 SemVer 语法。 ### 开发备注 diff --git a/packages/util/package-manifest/src/index.ts b/packages/util/package-manifest/src/index.ts index 2ea0cfd07f..a13bb94274 100644 --- a/packages/util/package-manifest/src/index.ts +++ b/packages/util/package-manifest/src/index.ts @@ -7,6 +7,7 @@ export type { DshBundleManifest, DshClientManifest, DshConfigTreeDeclaration, + DshEnginesManifest, DshManifest, DshModuleFallbackManifest, DshProfileManifest, diff --git a/packages/util/package-manifest/src/types.ts b/packages/util/package-manifest/src/types.ts index 2c98c63dc3..0528f2c167 100644 --- a/packages/util/package-manifest/src/types.ts +++ b/packages/util/package-manifest/src/types.ts @@ -6,6 +6,12 @@ /** The `dsh` property of an npm manifest; a package may declare several roles. */ export interface DshManifest { + /** Manifest format version, independent of the npm package and Session format versions. */ + manifestVersion?: 1 + /** Free-form discovery tags, such as `skills` or `tools`; do not affect plugin loading. */ + categories?: string[] + /** Declared host compatibility; current readers do not enforce version ranges. */ + engines?: DshEnginesManifest /** Bundle metadata consumed by the profile launcher. */ bundle?: DshBundleManifest /** Profile metadata consumed by the profile launcher. */ @@ -23,6 +29,12 @@ export interface DshManifest { moduleFallback?: DshModuleFallbackManifest } +/** Host version requirements declared by the package author. */ +export interface DshEnginesManifest { + /** Compatible DSH versions as a SemVer range, including an exact version. */ + dsh: string +} + /** The configuration layer exported by a bundle package. */ export interface DshBundleManifest { /** Patch file path relative to the declaring package root. */ From 5c82aa9371124a3a07eed26c8d93d180513094b3 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 10 Sep 2026 10:28:47 +0800 Subject: [PATCH 05/17] refactor(manifest): separate package metadata from public DSH fields --- ...026-09-05-package-manifest-types.i18n.yaml | 4 +- .../2026-09-05-package-manifest-types.md | 6 +- .../2026-09-05-package-manifest-types.zh.md | 6 +- ...26-09-10-public-package-manifest.i18n.yaml | 6 ++ .../2026-09-10-public-package-manifest.md | 35 ++++++++ .../2026-09-10-public-package-manifest.zh.md | 35 ++++++++ package.json | 1 - packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/profile.ts | 13 +-- .../webworker-packer/README.i18n.yaml | 4 +- .../experimental/webworker-packer/README.md | 2 +- .../webworker-packer/README.zh.md | 2 +- .../webworker-packer/package.json | 3 +- .../webworker-packer/src/repository.ts | 13 ++- .../webworker-packer/tsconfig.json | 3 - .../util/package-manifest/README.i18n.yaml | 4 +- packages/util/package-manifest/README.md | 40 +++++---- packages/util/package-manifest/README.zh.md | 40 +++++---- packages/util/package-manifest/src/index.ts | 4 +- packages/util/package-manifest/src/types.ts | 87 +++++++------------ pnpm-lock.yaml | 6 -- scripts/gen-session-format-catalog.ts | 25 +++++- 24 files changed, 205 insertions(+), 142 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md create mode 100644 .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml index a15fd90d6d..1e9e2e658d 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md -2026-09-05-package-manifest-types.md: 5e9834583a75a3fd4f1ec69379628c49d264e05b -2026-09-05-package-manifest-types.zh.md: 8a2399a19a097113d45f4915631d36ca30191dd5 +2026-09-05-package-manifest-types.md: dc018ba019028b942a17cd016c8670f2405c3bc7 +2026-09-05-package-manifest-types.zh.md: 8fd323bfe6058a6a062e3834a8630c0181a203fc diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md index 5e9834583a..dc018ba019 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.md @@ -10,13 +10,11 @@ External packages need Harness manifest types without depending on boot or clien ## Decision -[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. Author declarations and launcher-generated module fallback metadata are explicitly distinguished. - -Author metadata stays under `package.json.dsh`: optional `manifestVersion` identifies format `1`, `categories` carries free-form discovery tags, and `engines.dsh` declares compatible host versions as a SemVer range. Npm package identity and Node requirements retain their top-level fields. Categories are extensible tags rather than a closed role enum, so labels such as `skills` and `tools` do not constrain package composition. +[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.md) owns `DshManifest` and its member declarations in one type-only file. The package belongs to the existing utility group and exports no runtime values. The [public package metadata decision](2026-09-10-public-package-manifest.md) owns the public field set and the separation from internal tool metadata. Readers import the shared declarations directly. Boot retains profile loading, raw JSON checks, defaults, and resolved runtime data. Client modules retain their normalized boot graph. The image packer resolves declared paths into directories. The Session catalog generator derives a read-only validated entry with a resolved import path; raw inputs and discovery rules remain local. -App-boot declares a production dependency because its published declarations reference the shared types. Client modules, the private packer, and root scripts use development dependencies because their published APIs do not expose these types. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports. +App-boot declares a production dependency because its published declarations reference the shared types. Client modules use a development dependency because their published APIs do not expose these types. Internal image-packer and Session catalog declarations stay with their readers. Every package consumer has a TypeScript project reference. External authors import from the utility package; app-boot provides no compatibility re-exports. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md index 8a2399a19a..8fd323bfe6 100644 --- a/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-05-package-manifest-types.zh.md @@ -10,13 +10,11 @@ Status: implemented ## 决策 -[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。作者声明与启动器生成的模块后备元数据有明确区分。 - -作者元数据保留在 `package.json.dsh` 下:可选的 `manifestVersion` 标识格式 `1`,`categories` 承载自由发现标签,`engines.dsh` 以 SemVer 范围声明兼容的宿主版本。npm 包身份和 Node 要求沿用顶层字段。Categories 是可扩展标签,而非封闭的角色枚举,因此 `skills`、`tools` 等标签不限制包的组合。 +[`@deepseek-ai/dsh-package-manifest`](../../../../packages/util/package-manifest/README.zh.md) 在一个纯类型文件中拥有 `DshManifest` 及其成员声明。本包属于现有工具库分组,不导出运行时值。[公共包元数据决策](2026-09-10-public-package-manifest.zh.md) 拥有公共字段范围及其与内部工具元数据的划分。 各读取方直接导入共享声明。启动器保留 profile 加载、原始 JSON 检查、默认值和解析后的运行时数据。客户端模块保留归一化的启动图。镜像打包器将声明路径解析为目录。Session 目录生成器派生带有已解析导入路径的只读校验结果;原始输入和发现规则仍由本地负责。 -App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块、私有打包器和根脚本使用开发依赖,因为其发布 API 不暴露这些类型。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。 +App-boot 声明生产依赖,因为其发布的声明文件引用共享类型。客户端模块使用开发依赖,因为其发布 API 不暴露这些类型。内部镜像打包器和 Session 目录声明保留在各自读取方。每个包消费方都有 TypeScript 项目引用。外部作者从工具包导入;app-boot 不提供兼容性再导出。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml new file mode 100644 index 0000000000..2dbaeb2625 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md +2026-09-10-public-package-manifest.md: 222cdb58af580678136f269b60aa8271024dc8ca +2026-09-10-public-package-manifest.zh.md: c3659586f340dfb5bcfbbd0cc8266772207e63de diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md new file mode 100644 index 0000000000..222cdb58af --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.md @@ -0,0 +1,35 @@ +# Agent Note: Public package manifest fields + +Status: implemented + +English | [中文](2026-09-10-public-package-manifest.zh.md) + +## Problem + +Plugin authors need npm identity, runtime requirements, and DSH declarations from one public import. Internal image-packaging, Session catalog, and generated proxy metadata do not define extension points for community plugins. Exposing those fields together makes internal mechanisms appear available to external authors. + +## Decision + +[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) describes the package.json fields DSH uses, with required `name` and `version`. Its optional `dsh` member uses `DshManifest` for public composition and author metadata. The type is a selected npm field set, not a complete package.json schema. App-boot adapts it with `Partial` for local profiles, which need no published identity. + +Runtime requirements live at top-level `engines`: `dsh`, `node`, and `npm` are optional version strings, and other engine names are allowed. `dsh.manifestVersion` identifies declaration format `1`; `dsh.categories` carries free-form discovery tags without restricting package composition. Format and DSH compatibility declarations are not enforced by current installers or loaders. + +The image packer owns `configTrees`, the workspace catalog generator owns Session migration declarations, and app-boot owns generated module-fallback metadata. Their existing on-disk keys remain readable by those internal tools, but the public manifest types do not expose them. This scope refines the [shared declaration ownership decision](2026-09-05-package-manifest-types.md), whose package placement and dependency rules remain active. + +Each consumer owns JSON parsing, field validation, default resolution, and adaptation to runtime data. Interfaces do not validate parsed JSON. A helper belongs in the shared package only when multiple consumers need the same validation or normalization; getters that repeat property access add no shared policy. + +## Alternatives considered + +**Keep internal metadata in the public declaration.** A workspace-only migration catalog and an experimental image packer cannot offer public plugin behavior merely because their metadata is discoverable. + +**Put DSH compatibility under `dsh.engines`.** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) places its host requirement in top-level `engines.vscode`. Top-level `engines.dsh` gives authors one location for runtime requirements; DSH still owns enforcement of its custom key. + +**Use peer dependencies as the sole host requirement.** Peer dependencies constrain installed npm packages, including the CLI package `@deepseek-ai/dsh`. They do not identify the currently running DSH process when plugins live in a separate profile project. + +**Parse every domain through one mandatory parser.** Existing readers consume different subsets and own different errors and defaults. Combining them would make a client reader validate unrelated profile declarations. The public types remain independent of filesystem access and parsing policy. + +## Consequences + +External authors gain a complete package-level declaration and a smaller DSH author API. Consumers of removed internal types must use their owning implementations. The packer and repository catalog no longer depend on the public declaration package; app-boot retains a production dependency because its published profile type references it. + +Compiler and built NodeNext import checks verify required package identity, partial profiles, top-level engine declarations, and the absence of internal fields from the public API. Existing profile, packer, and Session catalog tests retain coverage of their accepted files and malformed declarations. No Session format, plugin loading rule, or model-visible behavior changes. diff --git a/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md new file mode 100644 index 0000000000..c3659586f3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-public-package-manifest.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 公共 package manifest 字段 + +Status: implemented + +[English](2026-09-10-public-package-manifest.md) | 中文 + +## 问题 + +插件作者需要从统一的公共导入路径获取 npm 身份、运行时要求和 DSH 声明。内部镜像打包、Session 目录和生成的代理元数据不定义社区插件扩展点。将这些字段一起暴露,会让外部作者误以为内部机制也可供使用。 + +## 决策 + +[`DshPackageManifest`](../../../../packages/util/package-manifest/src/types.ts) 描述 DSH 使用的 package.json 字段,其中 `name` 和 `version` 必填。其可选的 `dsh` 成员使用 `DshManifest` 描述公共组合与作者元数据。该类型只选取所需 npm 字段,不是完整的 package.json schema(模式)。App-boot 通过 `Partial` 适配无需发布身份的本地 profile。 + +运行时要求位于顶层 `engines`:`dsh`、`node` 和 `npm` 均为可选版本字符串,也允许其他 engine 名称。`dsh.manifestVersion` 标识声明格式 `1`;`dsh.categories` 承载自由发现标签,不限制包的组合。当前安装器和加载器不强制检查格式与 DSH 兼容性声明。 + +镜像打包器拥有 `configTrees`,工作区目录生成器拥有 Session 迁移声明,app-boot 拥有生成的模块后备元数据。这些内部工具仍可读取既有磁盘字段,但公共 manifest 类型不暴露这些字段。此范围细化了[共享声明归属决策](2026-09-05-package-manifest-types.zh.md),后者的包位置与依赖规则仍然有效。 + +各消费方负责 JSON 解析、字段校验、默认值解析和运行时数据适配。接口不会校验已解析的 JSON。只有多个消费方需要相同校验或归一化时,helper 才属于共享包;重复属性访问的 getter 不提供共享策略。 + +## 考虑过的替代方案 + +**将内部元数据保留在公共声明中。** 仅限工作区的迁移目录和实验性镜像打包器,不会因为其元数据可被发现就提供公共插件行为。 + +**将 DSH 兼容性放在 `dsh.engines` 下。** [VS Code](https://code.visualstudio.com/api/references/extension-manifest) 将宿主要求放在顶层 `engines.vscode`。顶层 `engines.dsh` 让作者在同一位置声明运行时要求;自定义键的检查仍由 DSH 负责。 + +**仅用 peer dependency 声明宿主要求。** Peer dependency 约束已安装的 npm 包,包括 CLI 包 `@deepseek-ai/dsh`。插件位于独立 profile 项目时,它们无法标识当前运行的 DSH 进程。 + +**通过统一的强制解析器解析所有领域。** 现有读取方消费不同字段子集,并各自拥有错误与默认值。合并它们会让客户端读取方校验无关的 profile 声明。公共类型保持独立于文件系统访问和解析策略。 + +## 后果 + +外部作者获得完整的包级声明和更小的 DSH 作者 API。已移除内部类型的消费方必须使用各自负责的实现。打包器与仓库目录不再依赖公共声明包;app-boot 保留生产依赖,因为其发布的 profile 类型引用该包。 + +编译器和构建后的 NodeNext 导入检查验证包身份必填、部分 profile、顶层 engine 声明,以及公共 API 不含内部字段。现有 profile、打包器和 Session 目录测试继续覆盖其接受的文件与畸形声明。Session 格式、插件加载规则和模型可见行为均不改变。 diff --git a/package.json b/package.json index a09fa3f997..6bf6dea6c9 100644 --- a/package.json +++ b/package.json @@ -184,7 +184,6 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-package-manifest": "workspace:^", "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@deepseek-ai/dsh-web-fetch-http": "workspace:^", "@stylistic/eslint-plugin": "^5.10.0", diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 3d4666ea0d..3d70dc1c39 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: df386b64089f962960b09538381e9d4b4905feb0 -README.zh.md: 5a7246c52ea5bcc8d5322d9fac4a87ec2e2ef4cc +README.md: a525440f20879314bd8d12b647830f2c541f59b3 +README.zh.md: d194f191374e50811d4831933bf8e8b2b831cd40 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index df386b6408..a525440f20 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -45,7 +45,7 @@ With that entry point, success looks like a running app with every plugin active ### Profiles -Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot owns profile loading, JSON validation, and resolved runtime data. +Import profile and bundle declaration types from [`@deepseek-ai/dsh-package-manifest`](../../util/package-manifest/README.md). App-boot adapts `DshPackageManifest` to `ProfileManifest` with optional package identity because local profiles need no published version. App-boot owns profile loading, JSON validation, and resolved runtime data. A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh --profile --from-default-profile