mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(dev-infra): make gate plans inspectable and replayable
This commit is contained in:
@@ -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-07-27-replayable-gate-plans.md
|
||||||
|
2026-07-27-replayable-gate-plans.md: 604fb87ebe91f8ebd606d128e8927535502b6ea9
|
||||||
|
2026-07-27-replayable-gate-plans.zh.md: e24d77ff6245bac6a06cd9f9ea8e849dafc95051
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Agent Note: Validated, self-describing, replayable gate plans
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
English | [中文](2026-07-27-replayable-gate-plans.zh.md)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Repository aggregates need to fail before execution when their dependency graph is invalid. Without validation, an empty aggregate can succeed, duplicate gate IDs can overwrite scheduler state, and missing or cyclic dependencies can appear as generic skips after unrelated work has already run. Operators also need the exact scheduler-owned environment and dependency context for a failed command; reconstructing it from [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) is slow and error-prone during a CI incident.
|
||||||
|
|
||||||
|
The Node 24 consumer job compounds this problem when it owns a separate shell process pool. Commands, concurrency, environment, and failure collection then have two executable inventories, while a restored build can be consumed before any command establishes that the downloaded artifacts are complete.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
[`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) constructs a complete `GatePlan` before execution and validates that it is non-empty, every ID is unique and replay-safe, every dependency exists, and the graph is acyclic. `executeGatePlan()` repeats validation at the process boundary, so an invalid injected plan cannot start a child. The empty `pre-push` mode is absent; Git hooks retain their separate narrow contract.
|
||||||
|
|
||||||
|
Every mode supports deterministic `--list` output and a versioned stable `--list --json` object. Machine consumers invoke `pnpm --silent run <owning-script> -- --list --json`; `--silent` removes pnpm's outer command banner so stdout is exactly one JSON object. Both views expose canonical gate order, IDs, display commands, dependencies, blocking disposition, the plan-owned worker ceiling, and scheduler-owned environment operations. Environment overrides remain declarative until spawn (`set`, `unset`, or `append`), so inspection and failure metadata never enumerate or bake in inherited values; values under secret-like names are redacted.
|
||||||
|
|
||||||
|
`--only <gate-id>` runs the named gate with its complete transitive dependency closure in canonical plan order. Its banner identifies the run as partial diagnostic evidence and names the complete owning package script. Every failed or skipped gate prints the cross-platform replay command `pnpm run <owning-script> -- --only <gate-id>`, which restores dependency and environment semantics through the scheduler.
|
||||||
|
|
||||||
|
On POSIX hosts, failed child output is retained under ignored `.cache/gates/` in a unique exclusively-created file. Every repository-relative path component must be a verified real directory before it can anchor a mutation. A dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process starts with the verified repository root as its process working directory, checks the pinned device and inode, and descends to the log directory one component at a time. It creates a missing component only with a non-recursive `mkdir` relative to an already pinned parent, then enters and identity-checks that child before proceeding; every direct open, permission change, prune, and cleanup is relative to the final pinned directory. A concurrent ancestor replacement therefore fails before the next mutation or leaves operations anchored to a verified directory instead of redirecting them. The directory is owner-only, each file is owner-readable and owner-writable, the newest 20 logs are retained, and each log is bounded to 1 MiB with byte counts in an explicit truncation marker. Metadata contains the mode, gate, display command, replay command, blocking status, scheduler-owned redacted environment operations, exit code, signal, and interleaved output; it does not serialize the inherited process environment. `pnpm exec tsx scripts/run-gates.ts --clean-logs` clears retained log files through the same pinned helper and leaves the private directory in place. Windows cannot establish the POSIX owner-only contract through Node file modes, so it retains no file and prints an explicit console-fallback diagnostic; the complete failure output remains on the console on every platform. Output itself may contain sensitive child data, which is why retained logs remain private and are not uploaded by the workflow.
|
||||||
|
|
||||||
|
The `check:ci:consumers` mode owns the Node 24 consumer job's seven top-level commands and a plan-visible seven-worker default and ceiling. That default preserves the shell pool even on a host reporting fewer CPUs; `DSH_GATE_CONCURRENCY` may request fewer workers but cannot exceed the plan ceiling. Publint first validates the manifest-declared public artifact view, including the existence of exported files; `verify-built-package-invariants` then depends on publint and validates every compiled invariant plus its declared runtime closure and the restored Loader bundle. Snapshot, NodeNext type checks, and built-bin smokes depend on both stages through `verify-built-package-invariants`, while source lint and source compatibility smokes may overlap them. A failed restored-build validation skips later artifact consumers but does not suppress independent source diagnostics.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) proves invalid plans cannot reach the injected executor, dependency closure is complete, list order and JSON fields are stable, the silent package-script entry emits one parseable JSON object, replay text is portable, environment resolution is deferred to spawn, inherited and scheduler-owned secrets are absent from metadata, and signal termination remains distinct from exit status. Its storage cases prove pre-existing symlinks and deterministic write/prune/cleanup ancestor swaps cannot create an external log directory or reach an external victim, UTF-8 logs and control-heavy JSON requests obey their bounds, and Windows selects the console fallback before creating a directory. The consumer-plan case pins the seven-command inventory, seven-worker default and ceiling even on a four-CPU host, and two-stage restored-build validation. [`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) proves a missing public export fails the first stage. The CI workflow invokes only `pnpm run check:ci:consumers` for that process pool.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Keep the scheduler internal and document commands beside the workflow.** This leaves two executable inventories to drift and cannot reveal the plan that actually ran.
|
||||||
|
- **Add validation without discovery or focused replay.** This closes fail-open graph defects, but operators still have to reconstruct dependencies and hidden overrides from TypeScript during an incident.
|
||||||
|
- **Adopt a general-purpose task orchestrator.** The repository scheduler already owns buffering, dependency ordering, cross-platform shell-free spawning, and blocking disposition. Replacing it adds a dependency and migration without deleting a distinct local abstraction.
|
||||||
|
- **Persist the complete child environment for exact replay.** Ambient runner state is incidental and can contain credentials. Replay instead records only scheduler-owned operations and reconstructs inherited state at execution time.
|
||||||
|
- **Add an eighth archive-manifest validator to the consumer plan.** Publint already rejects missing manifest-declared public exports, while the built-package invariant verifier loads every package's compiled invariant, its declared runtime chunks, and the restored Loader bundle. Chaining those existing commands keeps the seven-command inventory and gives later artifact consumers both checks without another executable inventory entry.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The scheduler owns a small CLI and a versioned JSON schema that must evolve deliberately with the gate model. Focused replay is faster to diagnose but is not complete evidence, so the CLI labels it explicitly and always names the owning aggregate.
|
||||||
|
|
||||||
|
Later artifact consumers start only after publint and built-package invariant validation, reducing their overlap when either verifier is slow. Independent source checks still overlap both stages; a missing public export or broken compiled-invariant closure fails before it can produce misleading downstream results.
|
||||||
|
|
||||||
|
Retained output and orthogonal exit/signal metadata improve failure attribution at the cost of local sensitive-data exposure when a child prints a secret. On POSIX, a repository-pinned helper that identity-checks each path descent, validated owner-only paths, exclusive creation, count and byte bounds, an explicit validated cleanup command, and exclusion from workflow uploads contain that risk without claiming the output itself is safe. The helper process and request protocol are additional local machinery, but they avoid relying on a check-then-use pathname for directory creation or destructive operations. Windows deliberately gives up durable local failure logs because Node file modes cannot establish the same privacy contract there; its console output remains complete.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Agent Note: 经过验证、自描述、可回放的门禁计划
|
||||||
|
|
||||||
|
Status: implemented
|
||||||
|
|
||||||
|
[English](2026-07-27-replayable-gate-plans.md) | 中文
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
|
||||||
|
仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。故障排查者还需要失败命令的确切依赖上下文,以及由调度器掌管的环境设置;在 CI 故障期间从 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 还原这些信息既慢又容易出错。
|
||||||
|
|
||||||
|
Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使这个问题更加严重。此时,命令、并发度、环境和失败收集分别拥有两份可执行清单;而且在任何命令确认下载产物完整之前,恢复后的构建产物就可能被消费。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
[`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 在执行前构造完整的 `GatePlan`,并验证计划不为空、每个 ID 唯一且可安全用于回放、每项依赖都存在、依赖图无环。`executeGatePlan()` 在进程边界再次执行验证,因此注入的无效计划无法启动子进程。空的 `pre-push` 模式不存在;Git 钩子仍遵循独立的狭窄契约。
|
||||||
|
|
||||||
|
每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run <owning-script> -- --list --json`;`--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限,以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式(`set`、`unset` 或 `append`),因此检查结果与失败元数据不会枚举或固化继承值;名称疑似机密项的值会被脱敏。
|
||||||
|
|
||||||
|
`--only <gate-id>` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run <owning-script> -- --only <gate-id>`,该命令通过调度器还原依赖与环境语义。
|
||||||
|
|
||||||
|
在 POSIX 主机上,失败子进程的输出保留在已被忽略的 `.cache/gates/` 目录中,每次写入一个以排他方式创建的唯一文件。相对于仓库的每一级路径都必须是经过验证的真实目录,才能作为修改操作的固定起点。专用 [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) 辅助进程以经过验证的仓库根目录作为进程工作目录启动,确认固定目录的设备号和 inode,再逐级进入日志目录。辅助进程只会相对于已经固定的父目录,使用非递归 `mkdir` 创建缺失的子目录;随后进入该子目录并核验其身份,才会继续处理。直接打开、权限修改、裁剪与清理全都相对于最终固定的目录执行。因此,并发替换上层目录时,操作要么在下一次修改前失败,要么仍限定在经过验证的目录内,而不会被重定向。目录仅属主可访问,每个文件仅属主可读写,最多保留最新 20 份日志,每份日志不超过 1 MiB,发生截断时还会用显式标记记录字节数。元数据包含模式、门禁、显示命令、回放命令、阻塞状态、由调度器掌管且经过脱敏的环境操作、退出码、信号及交错输出;其中不会序列化继承的进程环境。`pnpm exec tsx scripts/run-gates.ts --clean-logs` 会通过同一个固定目录辅助进程清除保留的日志文件,并保留私有目录。Windows 无法通过 Node 文件模式建立 POSIX 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。
|
||||||
|
|
||||||
|
`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有 shell 进程池;`DSH_GATE_CONCURRENCY` 可以请求更少的工作进程,但不能超过计划上限。publint 首先验证 manifest(元数据清单)所声明的公开产物视图,包括导出文件是否存在;`verify-built-package-invariants` 随后依赖 publint,验证每个已编译不变式、其声明的运行时闭包以及恢复后的 Loader bundle。快照、NodeNext 类型检查和已构建二进制文件的冒烟测试都通过 `verify-built-package-invariants` 依赖这两个阶段,而源码 lint 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、继承的机密值和由调度器掌管的机密值都不会进入元数据,而且信号终止与退出状态彼此独立。存储用例证明预先存在的符号链接以及确定性触发的写入、裁剪和清理上层目录替换都无法创建外部日志目录或触达外部受害文件,UTF-8 日志与含大量控制字符的 JSON 请求均遵守各自上限,Windows 则会在创建目录前选择控制台回退。消费方计划用例固定了 7 条命令的清单、即使主机只有 4 个 CPU 仍采用的 7 个工作进程默认值与上限,以及两阶段的恢复后构建产物验证。[`scripts/publint-all.spec.ts`](../../../../scripts/publint-all.spec.ts) 证明缺失公开导出时第一阶段会失败。CI 工作流只为该进程池调用 `pnpm run check:ci:consumers`。
|
||||||
|
|
||||||
|
## 曾考虑的替代方案
|
||||||
|
|
||||||
|
- **不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。
|
||||||
|
- **只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置。
|
||||||
|
- **采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象。
|
||||||
|
- **为精确回放而持久化完整的子进程环境。** 运行器的环境状态只是偶然因素,其中可能包含凭据。回放只记录由调度器掌管的操作,并在执行时重建继承状态。
|
||||||
|
- **在消费方计划中增加第 8 条归档 manifest 验证命令。** publint 已经能拒绝缺失 manifest 所声明公开导出的情况,而已构建包不变式验证器会加载每个包的已编译不变式、声明的运行时分片和恢复后的 Loader bundle。串联这两条现有命令,既能保持 7 条命令的清单,又能让后续产物消费方获得两项检查,而无需增加另一项可执行清单条目。
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
调度器负责维护一个小型 CLI(命令行界面)以及一套带版本的 JSON schema,两者都必须随门禁模型有意演进。聚焦回放可以更快地诊断问题,但不构成完整证据,因此 CLI 会明确标记这一点,并始终给出所属的完整聚合任务。
|
||||||
|
|
||||||
|
后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。
|
||||||
|
|
||||||
|
保留输出以及彼此独立的退出码与信号元数据可以改善失败归因,但当子进程打印机密时,也会带来本地敏感数据暴露的代价。在 POSIX 上,以仓库根目录为固定起点、每进入一级路径都核验身份的辅助进程,加上经过验证且仅属主可访问的路径、排他创建、数量与字节双重上限、经过验证的显式清理命令以及工作流不上传日志,共同约束了这项风险,但并不声称输出本身是安全的。辅助进程和请求协议增加了本地机制,但避免了让目录创建或破坏性操作依赖“先检查、后使用”的路径名。Windows 会有意放弃持久保留的本地失败日志,因为 Node 文件模式无法在那里建立相同的隐私契约;其控制台输出仍保持完整。
|
||||||
39
.github/workflows/ci.yml
vendored
39
.github/workflows/ci.yml
vendored
@@ -240,44 +240,7 @@ jobs:
|
|||||||
exit "$sandbox_status"
|
exit "$sandbox_status"
|
||||||
|
|
||||||
- name: Run compatibility, snapshot, and artifact gates
|
- name: Run compatibility, snapshot, and artifact gates
|
||||||
run: |
|
run: pnpm run check:ci:consumers
|
||||||
pnpm run check:ci:lint &
|
|
||||||
lint_pid=$!
|
|
||||||
pnpm run check:node-compat &
|
|
||||||
compat_pid=$!
|
|
||||||
DSH_EXAMPLE_MODE=lib pnpm run test:snapshot &
|
|
||||||
snapshot_pid=$!
|
|
||||||
pnpm run publint &
|
|
||||||
publint_pid=$!
|
|
||||||
pnpm run verify-node-next-types &
|
|
||||||
node_next_pid=$!
|
|
||||||
pnpm run verify-built-package-invariants &
|
|
||||||
built_invariants_pid=$!
|
|
||||||
DSH_EXAMPLE_MODE=lib pnpm exec vitest run --config vitest.e2e.config.ts \
|
|
||||||
examples/headless-agent/tests/keyless-smoke.e2e.ts \
|
|
||||||
examples/tui-agent/tests/tui-keyless-smoke.e2e.ts \
|
|
||||||
packages/examples/cli-demo/tests/built-bin.e2e.ts \
|
|
||||||
packages/examples/acp-demo/tests/built-bin.e2e.ts \
|
|
||||||
packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts \
|
|
||||||
packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts \
|
|
||||||
packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts &
|
|
||||||
built_bin_pid=$!
|
|
||||||
|
|
||||||
final_status=0
|
|
||||||
capture_status() {
|
|
||||||
local child_status=0
|
|
||||||
wait "$1" || child_status=$?
|
|
||||||
if (( final_status == 0 && child_status != 0 )); then
|
|
||||||
final_status=$child_status
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
for child_pid in \
|
|
||||||
"$lint_pid" "$compat_pid" "$snapshot_pid" \
|
|
||||||
"$publint_pid" "$node_next_pid" "$built_invariants_pid" "$built_bin_pid"
|
|
||||||
do
|
|
||||||
capture_status "$child_pid"
|
|
||||||
done
|
|
||||||
exit "$final_status"
|
|
||||||
|
|
||||||
|
|
||||||
node-compat:
|
node-compat:
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage",
|
"check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage",
|
||||||
"check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot",
|
"check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot",
|
||||||
"check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts",
|
"check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts",
|
||||||
|
"check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers",
|
||||||
"check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
|
"check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking",
|
||||||
"check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
|
"check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete",
|
||||||
"check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
|
"check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational",
|
||||||
|
|||||||
220
scripts/gate-log-helper.mjs
Normal file
220
scripts/gate-log-helper.mjs
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/** Pin the repository and each log-path component before creating or operating on private logs. */
|
||||||
|
|
||||||
|
import { constants } from 'node:fs'
|
||||||
|
import { chmod, lstat, mkdir, open, readdir, stat, unlink } from 'node:fs/promises'
|
||||||
|
import { isAbsolute, sep } from 'node:path'
|
||||||
|
|
||||||
|
const MAX_REQUEST_BYTES = 8 * 1024 * 1024
|
||||||
|
const LOG_NAME = /^[a-zA-Z0-9][a-zA-Z0-9.-]*\.log$/
|
||||||
|
|
||||||
|
function errorCode(error) {
|
||||||
|
return typeof error === 'object' && error !== null && 'code' in error
|
||||||
|
? error.code
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readRequest() {
|
||||||
|
const chunks = []
|
||||||
|
let bytes = 0
|
||||||
|
for await (const chunk of process.stdin) {
|
||||||
|
bytes += chunk.length
|
||||||
|
if (bytes > MAX_REQUEST_BYTES) throw new Error('request exceeds the gate-log helper limit')
|
||||||
|
chunks.push(chunk)
|
||||||
|
}
|
||||||
|
return JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertInteger(value, label, minimum) {
|
||||||
|
if (!Number.isSafeInteger(value) || value < minimum) {
|
||||||
|
throw new Error(`${label} must be an integer of at least ${minimum}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertLogName(name) {
|
||||||
|
if (typeof name !== 'string' || !LOG_NAME.test(name)) {
|
||||||
|
throw new Error(`invalid gate-log filename ${JSON.stringify(name)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRequest(request) {
|
||||||
|
if (typeof request !== 'object' || request === null) throw new Error('gate-log request must be an object')
|
||||||
|
switch (request.operation) {
|
||||||
|
case 'write':
|
||||||
|
assertLogName(request.filename)
|
||||||
|
assertInteger(request.retention, 'retention', 1)
|
||||||
|
if (typeof request.content !== 'string') throw new Error('gate-log content must be a string')
|
||||||
|
return
|
||||||
|
case 'prune':
|
||||||
|
assertInteger(request.retain, 'retain', 0)
|
||||||
|
return
|
||||||
|
case 'clean':
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIdentity(value, label) {
|
||||||
|
if (
|
||||||
|
typeof value !== 'object'
|
||||||
|
|| value === null
|
||||||
|
|| typeof value.dev !== 'string'
|
||||||
|
|| typeof value.ino !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(`missing expected ${label} identity`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function identityOf(metadata) {
|
||||||
|
return { dev: String(metadata.dev), ino: String(metadata.ino) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIdentity(metadata, expected) {
|
||||||
|
return String(metadata.dev) === expected.dev && String(metadata.ino) === expected.ino
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertPinnedRepository(repository) {
|
||||||
|
if (
|
||||||
|
typeof repository !== 'object'
|
||||||
|
|| repository === null
|
||||||
|
|| typeof repository.root !== 'string'
|
||||||
|
|| !isAbsolute(repository.root)
|
||||||
|
|| typeof repository.relative !== 'string'
|
||||||
|
|| repository.relative === ''
|
||||||
|
|| repository.relative === '..'
|
||||||
|
|| repository.relative.startsWith(`..${sep}`)
|
||||||
|
|| isAbsolute(repository.relative)
|
||||||
|
) {
|
||||||
|
throw new Error('invalid repository-relative gate-log path')
|
||||||
|
}
|
||||||
|
assertIdentity(repository.identity, 'repository')
|
||||||
|
const pinnedMetadata = await stat('.', { bigint: true })
|
||||||
|
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, repository.identity)) {
|
||||||
|
throw new Error('gate-log repository identity changed before the helper started')
|
||||||
|
}
|
||||||
|
const rootMetadata = await lstat(repository.root, { bigint: true })
|
||||||
|
if (
|
||||||
|
!rootMetadata.isDirectory()
|
||||||
|
|| rootMetadata.isSymbolicLink()
|
||||||
|
|| !sameIdentity(rootMetadata, repository.identity)
|
||||||
|
) {
|
||||||
|
throw new Error('gate-log repository root is not a real directory')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enterLogDirectory(relativePath, create) {
|
||||||
|
const traversed = []
|
||||||
|
for (const component of relativePath.split(sep)) {
|
||||||
|
if (component === '' || component === '.' || component === '..') {
|
||||||
|
throw new Error(`invalid gate-log path component ${JSON.stringify(component)}`)
|
||||||
|
}
|
||||||
|
traversed.push(component)
|
||||||
|
let componentMetadata
|
||||||
|
try {
|
||||||
|
componentMetadata = await lstat(component, { bigint: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (errorCode(error) !== 'ENOENT') throw error
|
||||||
|
if (!create) return undefined
|
||||||
|
try {
|
||||||
|
await mkdir(component, { mode: 0o700 })
|
||||||
|
} catch (mkdirError) {
|
||||||
|
if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError
|
||||||
|
}
|
||||||
|
componentMetadata = await lstat(component, { bigint: true })
|
||||||
|
}
|
||||||
|
const shown = traversed.join('/')
|
||||||
|
if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) {
|
||||||
|
throw new Error(`gate-log path component is not a real directory: ${shown}`)
|
||||||
|
}
|
||||||
|
const expected = identityOf(componentMetadata)
|
||||||
|
process.chdir(component)
|
||||||
|
const pinnedMetadata = await stat('.', { bigint: true })
|
||||||
|
if (!pinnedMetadata.isDirectory() || !sameIdentity(pinnedMetadata, expected)) {
|
||||||
|
throw new Error(`gate-log path component identity changed before pinning: ${shown}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await chmod('.', 0o700)
|
||||||
|
return identityOf(await stat('.', { bigint: true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeOldLogs(retain, newest) {
|
||||||
|
assertInteger(retain, 'retain', 0)
|
||||||
|
const entries = await readdir('.', { withFileTypes: true })
|
||||||
|
const logs = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !LOG_NAME.test(entry.name)) continue
|
||||||
|
let metadata
|
||||||
|
try {
|
||||||
|
metadata = await lstat(entry.name, { bigint: true })
|
||||||
|
} catch (error) {
|
||||||
|
if (errorCode(error) === 'ENOENT') continue
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (!metadata.isFile() || metadata.isSymbolicLink()) continue
|
||||||
|
logs.push({ name: entry.name, mtimeNs: metadata.mtimeNs })
|
||||||
|
}
|
||||||
|
logs.sort((left, right) => {
|
||||||
|
if (left.name === newest) return 1
|
||||||
|
if (right.name === newest) return -1
|
||||||
|
if (left.mtimeNs < right.mtimeNs) return -1
|
||||||
|
if (left.mtimeNs > right.mtimeNs) return 1
|
||||||
|
return left.name.localeCompare(right.name)
|
||||||
|
})
|
||||||
|
const removed = []
|
||||||
|
for (const entry of logs.slice(0, Math.max(0, logs.length - retain))) {
|
||||||
|
try {
|
||||||
|
await unlink(entry.name)
|
||||||
|
removed.push(entry.name)
|
||||||
|
} catch (error) {
|
||||||
|
if (errorCode(error) !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeLog(request) {
|
||||||
|
assertLogName(request.filename)
|
||||||
|
assertInteger(request.retention, 'retention', 1)
|
||||||
|
if (typeof request.content !== 'string') throw new Error('gate-log content must be a string')
|
||||||
|
const handle = await open(
|
||||||
|
request.filename,
|
||||||
|
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW,
|
||||||
|
0o600,
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
await handle.writeFile(request.content, 'utf8')
|
||||||
|
await handle.chmod(0o600)
|
||||||
|
} finally {
|
||||||
|
await handle.close()
|
||||||
|
}
|
||||||
|
const removed = await removeOldLogs(request.retention, request.filename)
|
||||||
|
return { filename: request.filename, removed }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const request = await readRequest()
|
||||||
|
assertRequest(request)
|
||||||
|
await assertPinnedRepository(request.repository)
|
||||||
|
const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write')
|
||||||
|
if (directory === undefined) return { removed: [] }
|
||||||
|
switch (request.operation) {
|
||||||
|
case 'write': {
|
||||||
|
const result = await writeLog(request)
|
||||||
|
return { ...result, directory }
|
||||||
|
}
|
||||||
|
case 'prune':
|
||||||
|
return { directory, removed: await removeOldLogs(request.retain) }
|
||||||
|
case 'clean':
|
||||||
|
return { directory, removed: await removeOldLogs(0) }
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported gate-log operation ${JSON.stringify(request.operation)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
process.stdout.write(`${JSON.stringify(await main())}\n`)
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`gate-log-helper: ${error instanceof Error ? error.message : String(error)}\n`)
|
||||||
|
process.exitCode = 1
|
||||||
|
}
|
||||||
@@ -58,4 +58,10 @@ describe('publint package runner', () => {
|
|||||||
expect(result.status).toBe(1)
|
expect(result.status).toBe(1)
|
||||||
expect(result.stdout).toContain('unpublished.js')
|
expect(result.stdout).toContain('unpublished.js')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects a public export whose built file is missing', () => {
|
||||||
|
const result = run(fixture('./lib/missing.js'))
|
||||||
|
expect(result.status).toBe(1)
|
||||||
|
expect(result.stdout).toContain('missing.js')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
523
scripts/run-gates.spec.ts
Normal file
523
scripts/run-gates.spec.ts
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
statSync,
|
||||||
|
symlinkSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from 'node:fs'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import {
|
||||||
|
cleanGateFailureLogs,
|
||||||
|
executeGatePlan,
|
||||||
|
failureLogUnavailableReason,
|
||||||
|
formatGateFailureLog,
|
||||||
|
formatGatePlanJson,
|
||||||
|
formatGatePlanList,
|
||||||
|
formatGateResultReason,
|
||||||
|
formatOnlyNotice,
|
||||||
|
gateDependencyClosure,
|
||||||
|
gatePlanForMode,
|
||||||
|
listedGatePlan,
|
||||||
|
limitGateFailureLog,
|
||||||
|
parseCliRequest,
|
||||||
|
pruneGateLogs,
|
||||||
|
replayCommand,
|
||||||
|
resolveGateEnvironment,
|
||||||
|
resolvePlanConcurrency,
|
||||||
|
runGate,
|
||||||
|
validateGatePlan,
|
||||||
|
writeGateFailureLog,
|
||||||
|
type Gate,
|
||||||
|
type GatePlan,
|
||||||
|
type GateResult,
|
||||||
|
} from './run-gates.ts'
|
||||||
|
|
||||||
|
const temporaryRoots: string[] = []
|
||||||
|
const repositoryRoot = join(import.meta.dirname, '..')
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
function gate(id: string, options: Partial<Gate> = {}): Gate {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label: id,
|
||||||
|
displayCommand: `run ${id}`,
|
||||||
|
command: process.execPath,
|
||||||
|
args: ['-e', ''],
|
||||||
|
...options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function plan(gates: Gate[]): GatePlan {
|
||||||
|
return { mode: 'check-all', script: 'check:all', gates }
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): GateResult {
|
||||||
|
return {
|
||||||
|
gate: subject,
|
||||||
|
status,
|
||||||
|
durationMs: 10,
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
output: [],
|
||||||
|
exitCode: status === 'passed' ? 0 : 1,
|
||||||
|
signalCode: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function temporaryRoot(prefix = 'dsh-gate-logs-'): string {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), prefix))
|
||||||
|
temporaryRoots.push(root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPnpmEntrypoint<T>(action: () => T): T {
|
||||||
|
const previous = process.env.npm_execpath
|
||||||
|
process.env.npm_execpath = '/private/pnpm.cjs'
|
||||||
|
try {
|
||||||
|
return action()
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) Reflect.deleteProperty(process.env, 'npm_execpath')
|
||||||
|
else process.env.npm_execpath = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('gate plan validation', () => {
|
||||||
|
it.each([
|
||||||
|
'ci-primary',
|
||||||
|
'ci-static',
|
||||||
|
'ci-lint',
|
||||||
|
'ci-coverage',
|
||||||
|
'ci-snapshot',
|
||||||
|
'ci-artifacts',
|
||||||
|
'ci-consumers',
|
||||||
|
'ci-windows-blocking',
|
||||||
|
'ci-windows-complete',
|
||||||
|
'ci-windows-observational',
|
||||||
|
'node-compat',
|
||||||
|
'check-all',
|
||||||
|
'doc-sync',
|
||||||
|
] as const)('constructs a valid non-empty %s plan', (mode) => {
|
||||||
|
const subject = withPnpmEntrypoint(() => gatePlanForMode(mode))
|
||||||
|
expect(() => {
|
||||||
|
validateGatePlan(subject)
|
||||||
|
}).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['empty', plan([]), /plan has no gates/],
|
||||||
|
['duplicate ids', plan([gate('same'), gate('same')]), /duplicate gate id "same"/],
|
||||||
|
['unknown dependencies', plan([gate('subject', { needs: ['missing'] })]), /depends on unknown gate "missing"/],
|
||||||
|
['cycles', plan([gate('first', { needs: ['second'] }), gate('second', { needs: ['first'] })]), /dependency cycle: first -> second -> first/],
|
||||||
|
])('rejects %s before starting a child', async (_label, invalid, message) => {
|
||||||
|
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
|
||||||
|
await expect(executeGatePlan(invalid, 1, execute)).rejects.toThrow(message)
|
||||||
|
expect(execute).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an invalid plan worker bound', () => {
|
||||||
|
expect(() => {
|
||||||
|
validateGatePlan({ ...plan([gate('subject')]), maxWorkers: 0 })
|
||||||
|
}).toThrow(
|
||||||
|
'maxWorkers must be a positive integer',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an executor request above the plan worker ceiling before starting a child', async () => {
|
||||||
|
const execute = vi.fn(async (subject: Gate) => resultFor(subject))
|
||||||
|
await expect(executeGatePlan({ ...plan([gate('subject')]), maxWorkers: 1 }, 2, execute)).rejects.toThrow(
|
||||||
|
'exceeds the check-all plan ceiling 1',
|
||||||
|
)
|
||||||
|
expect(execute).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('selects a target with its transitive dependencies in canonical plan order', () => {
|
||||||
|
const subject = plan([
|
||||||
|
gate('prepare'),
|
||||||
|
gate('build', { needs: ['prepare'] }),
|
||||||
|
gate('snapshot', { needs: ['build'], env: { DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' } } }),
|
||||||
|
gate('unrelated'),
|
||||||
|
])
|
||||||
|
expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual(['prepare', 'build', 'snapshot'])
|
||||||
|
expect(gateDependencyClosure(subject, 'snapshot').at(-1)?.env).toEqual({
|
||||||
|
DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gate plan inspection and replay', () => {
|
||||||
|
it('parses package-script separators, list JSON, focused runs, and cleanup', () => {
|
||||||
|
expect(parseCliRequest(['check-all', '--', '--list', '--json'])).toEqual({
|
||||||
|
kind: 'run', mode: 'check-all', list: true, json: true,
|
||||||
|
})
|
||||||
|
expect(parseCliRequest(['check-all', '--only', 'snapshot'])).toEqual({
|
||||||
|
kind: 'run', mode: 'check-all', list: false, json: false, only: 'snapshot',
|
||||||
|
})
|
||||||
|
expect(parseCliRequest(['--clean-logs'])).toEqual({ kind: 'clean-logs' })
|
||||||
|
expect(() => parseCliRequest(['check-all', '--json'])).toThrow('--json requires --list')
|
||||||
|
expect(() => parseCliRequest(['pre-push'])).toThrow('expected mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders deterministic human and stable JSON fields without inherited environment values', () => {
|
||||||
|
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret')
|
||||||
|
const subject = plan([
|
||||||
|
gate('prepare'),
|
||||||
|
gate('subject', {
|
||||||
|
needs: ['prepare'],
|
||||||
|
allowFailure: true,
|
||||||
|
env: {
|
||||||
|
Z_MODE: { operation: 'set', value: 'lib' },
|
||||||
|
ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' },
|
||||||
|
NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const json = formatGatePlanJson(subject)
|
||||||
|
expect(formatGatePlanJson(subject)).toBe(json)
|
||||||
|
expect(json).not.toContain('ambient-secret')
|
||||||
|
expect(json).not.toContain('scheduler-secret')
|
||||||
|
expect(JSON.parse(json)).toEqual({
|
||||||
|
version: 1,
|
||||||
|
mode: 'check-all',
|
||||||
|
script: 'check:all',
|
||||||
|
scope: 'complete',
|
||||||
|
maxWorkers: null,
|
||||||
|
gates: [
|
||||||
|
{ id: 'prepare', label: 'prepare', command: 'run prepare', needs: [], env: {}, blocking: true },
|
||||||
|
{
|
||||||
|
id: 'subject',
|
||||||
|
label: 'subject',
|
||||||
|
command: 'run subject',
|
||||||
|
needs: ['prepare'],
|
||||||
|
env: {
|
||||||
|
ACCESS_TOKEN: { operation: 'set', value: '<redacted>' },
|
||||||
|
NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' },
|
||||||
|
Z_MODE: { operation: 'set', value: 'lib' },
|
||||||
|
},
|
||||||
|
blocking: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(formatGatePlanList(subject)).toContain('- subject [non-blocking] subject')
|
||||||
|
expect(formatGatePlanList(subject)).toContain('needs: prepare')
|
||||||
|
expect(formatGatePlanList(subject)).toContain('max workers: (host and gate count)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits one clean JSON object through the documented silent package-script entry', () => {
|
||||||
|
const result = spawnSync('pnpm', [
|
||||||
|
'--silent',
|
||||||
|
'run',
|
||||||
|
'check:ci:consumers',
|
||||||
|
'--',
|
||||||
|
'--list',
|
||||||
|
'--json',
|
||||||
|
], {
|
||||||
|
cwd: repositoryRoot,
|
||||||
|
encoding: 'utf8',
|
||||||
|
shell: process.platform === 'win32',
|
||||||
|
timeout: 10_000,
|
||||||
|
})
|
||||||
|
if (result.error !== undefined) throw result.error
|
||||||
|
expect(result.status, result.stderr).toBe(0)
|
||||||
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
||||||
|
version: 1,
|
||||||
|
mode: 'ci-consumers',
|
||||||
|
script: 'check:ci:consumers',
|
||||||
|
scope: 'complete',
|
||||||
|
maxWorkers: 7,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a cross-platform scheduler replay and labels focused evidence', () => {
|
||||||
|
const subject = plan([gate('snapshot')])
|
||||||
|
expect(replayCommand(subject, 'snapshot')).toBe('pnpm run check:all -- --only snapshot')
|
||||||
|
expect(formatOnlyNotice(subject, 'snapshot')).toBe(
|
||||||
|
'run-gates: --only snapshot is partial diagnostic evidence; the complete owning mode is pnpm run check:all.',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves append, set, and unset operations only when spawning', () => {
|
||||||
|
const resolved = resolveGateEnvironment(gate('subject', {
|
||||||
|
env: {
|
||||||
|
NODE_OPTIONS: { operation: 'append', value: '--max-old-space-size=8192' },
|
||||||
|
MODE: { operation: 'set', value: 'lib' },
|
||||||
|
REMOVE_ME: { operation: 'unset' },
|
||||||
|
},
|
||||||
|
}), { NODE_OPTIONS: '--trace-warnings', REMOVE_ME: 'yes', INHERITED: 'kept' })
|
||||||
|
expect(resolved).toEqual({
|
||||||
|
NODE_OPTIONS: '--trace-warnings --max-old-space-size=8192',
|
||||||
|
MODE: 'lib',
|
||||||
|
INHERITED: 'kept',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skipIf(process.platform === 'win32')('reports signal termination as an orthogonal real-process outcome', async () => {
|
||||||
|
const subjectGate = gate('terminated', {
|
||||||
|
args: ['-e', "process.kill(process.pid, 'SIGTERM')"],
|
||||||
|
})
|
||||||
|
const result = await runGate(subjectGate)
|
||||||
|
|
||||||
|
expect(result.status).toBe('failed')
|
||||||
|
expect(result.exitCode).toBeNull()
|
||||||
|
expect(result.signalCode).toBe('SIGTERM')
|
||||||
|
expect(formatGateResultReason(result)).toBe('signal SIGTERM')
|
||||||
|
expect(formatGateFailureLog(plan([subjectGate]), result)).toContain('signal: SIGTERM')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('gate failure logs', () => {
|
||||||
|
it('records attributable scheduler metadata without inherited secrets', () => {
|
||||||
|
vi.stubEnv('DEEPSEEK_API_KEY', 'ambient-secret')
|
||||||
|
const subjectGate = gate('snapshot', {
|
||||||
|
env: {
|
||||||
|
DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' },
|
||||||
|
ACCESS_TOKEN: { operation: 'set', value: 'scheduler-secret' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const subject = plan([subjectGate])
|
||||||
|
const failure: GateResult = {
|
||||||
|
...resultFor(subjectGate, 'failed'),
|
||||||
|
output: [{ stream: 'stderr', text: 'failure details\n' }],
|
||||||
|
stderr: 'failure details\n',
|
||||||
|
}
|
||||||
|
const log = formatGateFailureLog(subject, failure)
|
||||||
|
expect(log).toContain('replay: pnpm run check:all -- --only snapshot')
|
||||||
|
expect(log).toContain('DSH_EXAMPLE_MODE')
|
||||||
|
expect(log).toContain('<redacted>')
|
||||||
|
expect(log).toContain('[stderr]\nfailure details')
|
||||||
|
expect(log).not.toContain('ambient-secret')
|
||||||
|
expect(log).not.toContain('scheduler-secret')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skipIf(process.platform === 'win32')('uses private exclusive files and bounds retention', async () => {
|
||||||
|
const repositoryRoot = temporaryRoot()
|
||||||
|
const directory = join(repositoryRoot, '.cache/gates')
|
||||||
|
const subjectGate = gate('subject')
|
||||||
|
const subject = plan([subjectGate])
|
||||||
|
const failure = resultFor(subjectGate, 'failed')
|
||||||
|
|
||||||
|
const first = await writeGateFailureLog(subject, failure, {
|
||||||
|
directory, repositoryRoot, retention: 2, unique: 'first', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux',
|
||||||
|
})
|
||||||
|
const second = await writeGateFailureLog(subject, failure, {
|
||||||
|
directory, repositoryRoot, retention: 2, unique: 'second', now: new Date('2026-07-27T00:00:01Z'), platform: 'linux',
|
||||||
|
})
|
||||||
|
const third = await writeGateFailureLog(subject, failure, {
|
||||||
|
directory, repositoryRoot, retention: 2, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(readdirSync(directory).sort()).toEqual([second, third].map(path => path.slice(directory.length + 1)).sort())
|
||||||
|
expect(readFileSync(third, 'utf8')).toContain('run-gates failure log')
|
||||||
|
expect(statSync(directory).mode & 0o777).toBe(0o700)
|
||||||
|
expect(statSync(third).mode & 0o777).toBe(0o600)
|
||||||
|
expect(() => statSync(first)).toThrow()
|
||||||
|
await expect(writeGateFailureLog(subject, failure, {
|
||||||
|
directory, repositoryRoot, retention: 3, unique: 'third', now: new Date('2026-07-27T00:00:02Z'), platform: 'linux',
|
||||||
|
})).rejects.toThrow('EEXIST')
|
||||||
|
await cleanGateFailureLogs(directory, repositoryRoot)
|
||||||
|
expect(readdirSync(directory)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skipIf(process.platform === 'win32')('uses cross-platform filenames for replay-safe gate ids', async () => {
|
||||||
|
const repositoryRoot = temporaryRoot()
|
||||||
|
const directory = join(repositoryRoot, '.cache/gates')
|
||||||
|
const subjectGate = gate('build:web')
|
||||||
|
const path = await writeGateFailureLog(
|
||||||
|
plan([subjectGate]),
|
||||||
|
resultFor(subjectGate, 'failed'),
|
||||||
|
{
|
||||||
|
directory, repositoryRoot, retention: 1, unique: 'unique', now: new Date('2026-07-27T00:00:00Z'), platform: 'linux',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(path.slice(directory.length + 1)).toContain('-build-web-')
|
||||||
|
expect(path.slice(directory.length + 1)).not.toContain(':')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skipIf(process.platform === 'win32')('bounds retained UTF-8 output with explicit truncation metadata', async () => {
|
||||||
|
const repositoryRoot = temporaryRoot()
|
||||||
|
const directory = join(repositoryRoot, '.cache/gates')
|
||||||
|
const subjectGate = gate('subject')
|
||||||
|
const failure: GateResult = {
|
||||||
|
...resultFor(subjectGate, 'failed'),
|
||||||
|
output: [{ stream: 'stderr', text: `${'界'.repeat(200)}\nlast detail\n` }],
|
||||||
|
}
|
||||||
|
const path = await writeGateFailureLog(plan([subjectGate]), failure, {
|
||||||
|
directory,
|
||||||
|
repositoryRoot,
|
||||||
|
retention: 1,
|
||||||
|
maxBytes: 256,
|
||||||
|
unique: 'bounded',
|
||||||
|
now: new Date('2026-07-27T00:00:00Z'),
|
||||||
|
platform: 'linux',
|
||||||
|
})
|
||||||
|
const content = readFileSync(path, 'utf8')
|
||||||
|
|
||||||
|
expect(Buffer.byteLength(content)).toBeLessThanOrEqual(256)
|
||||||
|
expect(content).toContain('[run-gates log truncated: original-bytes=')
|
||||||
|
expect(content).toContain('max-bytes=256')
|
||||||
|
expect(content).toContain('last detail')
|
||||||
|
expect(content).not.toContain('\uFFFD')
|
||||||
|
expect(limitGateFailureLog('x'.repeat(256), 256)).toBe('x'.repeat(256))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skipIf(process.platform === 'win32')('accepts the worst-case JSON expansion of a bounded log', async () => {
|
||||||
|
const repositoryRoot = temporaryRoot()
|
||||||
|
const directory = join(repositoryRoot, '.cache/gates')
|
||||||
|
const subjectGate = gate('subject')
|
||||||
|
const failure: GateResult = {
|
||||||
|
...resultFor(subjectGate, 'failed'),
|
||||||
|
output: [{ stream: 'stderr', text: '\0'.repeat(400_000) }],
|
||||||
|
}
|
||||||
|
const path = await writeGateFailureLog(plan([subjectGate]), failure, {
|
||||||
|
directory,
|
||||||
|
repositoryRoot,
|
||||||
|
retention: 1,
|
||||||
|
maxBytes: 400_000,
|
||||||
|
unique: 'control-heavy',
|
||||||
|
platform: 'linux',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(statSync(path).size).toBeLessThanOrEqual(400_000)
|
||||||
|
expect(readFileSync(path, 'utf8')).not.toContain('\uFFFD')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects symlinked repository cache components before writing, pruning, or cleanup', async () => {
|
||||||
|
const auditRoot = temporaryRoot('dsh-gate-symlink-')
|
||||||
|
const repositoryRoot = join(auditRoot, 'repository')
|
||||||
|
const external = join(auditRoot, 'external')
|
||||||
|
const directory = join(repositoryRoot, '.cache/gates')
|
||||||
|
mkdirSync(repositoryRoot)
|
||||||
|
mkdirSync(join(external, 'gates'), { recursive: true })
|
||||||
|
const victim = join(external, 'gates/victim.log')
|
||||||
|
writeFileSync(victim, 'keep\n')
|
||||||
|
symlinkSync(external, join(repositoryRoot, '.cache'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||||
|
const subjectGate = gate('subject')
|
||||||
|
const message = 'gate-log path component is a symbolic link: .cache'
|
||||||
|
|
||||||
|
await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
|
||||||
|
directory, repositoryRoot, retention: 1, unique: 'safe', platform: 'linux',
|
||||||
|
})).rejects.toThrow(message)
|
||||||
|
await expect(pruneGateLogs(directory, 0, repositoryRoot)).rejects.toThrow(message)
|
||||||
|
await expect(cleanGateFailureLogs(directory, repositoryRoot)).rejects.toThrow(message)
|
||||||
|
expect(existsSync(victim)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.skipIf(process.platform === 'win32')('pins write, prune, and cleanup before a concurrent ancestor swap', async () => {
|
||||||
|
const subjectGate = gate('subject')
|
||||||
|
const subject = plan([subjectGate])
|
||||||
|
|
||||||
|
for (const operation of ['write', 'prune', 'clean'] as const) {
|
||||||
|
const auditRoot = temporaryRoot(`dsh-gate-${operation}-swap-`)
|
||||||
|
const repositoryRoot = join(auditRoot, 'repository')
|
||||||
|
const external = join(auditRoot, 'external')
|
||||||
|
const cache = join(repositoryRoot, '.cache')
|
||||||
|
const directory = join(cache, 'gates')
|
||||||
|
const displacedCache = join(repositoryRoot, '.cache-pinned')
|
||||||
|
mkdirSync(directory, { recursive: true })
|
||||||
|
mkdirSync(external)
|
||||||
|
writeFileSync(join(directory, 'old.log'), 'old private log\n')
|
||||||
|
const victim = operation === 'write' ? undefined : join(external, 'gates/victim.log')
|
||||||
|
if (victim !== undefined) {
|
||||||
|
mkdirSync(join(external, 'gates'))
|
||||||
|
writeFileSync(victim, 'keep\n')
|
||||||
|
}
|
||||||
|
const swapAncestor = (): void => {
|
||||||
|
renameSync(cache, displacedCache)
|
||||||
|
symlinkSync(external, cache, 'dir')
|
||||||
|
}
|
||||||
|
|
||||||
|
let invocation: Promise<unknown>
|
||||||
|
if (operation === 'write') {
|
||||||
|
invocation = writeGateFailureLog(subject, resultFor(subjectGate, 'failed'), {
|
||||||
|
directory,
|
||||||
|
repositoryRoot,
|
||||||
|
retention: 1,
|
||||||
|
unique: operation,
|
||||||
|
platform: 'linux',
|
||||||
|
beforeHelper: swapAncestor,
|
||||||
|
})
|
||||||
|
} else if (operation === 'prune') {
|
||||||
|
invocation = pruneGateLogs(directory, 0, repositoryRoot, swapAncestor)
|
||||||
|
} else {
|
||||||
|
invocation = cleanGateFailureLogs(directory, repositoryRoot, swapAncestor)
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(invocation).rejects.toThrow('gate-log helper')
|
||||||
|
if (victim === undefined) {
|
||||||
|
expect(existsSync(join(external, 'gates'))).toBe(false)
|
||||||
|
} else {
|
||||||
|
expect(readFileSync(victim, 'utf8')).toBe('keep\n')
|
||||||
|
expect(readdirSync(join(external, 'gates'))).toEqual(['victim.log'])
|
||||||
|
}
|
||||||
|
expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses a console-only fallback on Windows before creating a retention directory', async () => {
|
||||||
|
const repositoryRoot = temporaryRoot()
|
||||||
|
const directory = join(repositoryRoot, '.cache/gates')
|
||||||
|
const subjectGate = gate('subject')
|
||||||
|
expect(failureLogUnavailableReason('win32')).toContain('complete output remains on the console')
|
||||||
|
expect(failureLogUnavailableReason('linux')).toBeUndefined()
|
||||||
|
|
||||||
|
await expect(writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
|
||||||
|
directory, repositoryRoot, platform: 'win32',
|
||||||
|
})).rejects.toThrow('retained failure logs are disabled on Windows')
|
||||||
|
expect(existsSync(directory)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Node 24 consumer plan', () => {
|
||||||
|
it('owns the same seven-worker command pool and orders restored-artifact validation before dependent consumers', () => {
|
||||||
|
const subject = withPnpmEntrypoint(() => gatePlanForMode('ci-consumers'))
|
||||||
|
validateGatePlan(subject)
|
||||||
|
expect(subject.maxWorkers).toBe(7)
|
||||||
|
expect(listedGatePlan(subject).maxWorkers).toBe(7)
|
||||||
|
expect(resolvePlanConcurrency(subject, undefined, 4)).toEqual({
|
||||||
|
workers: 7,
|
||||||
|
source: 'ci-consumers plan default 7',
|
||||||
|
})
|
||||||
|
expect(resolvePlanConcurrency(subject, '4', 32)).toEqual({
|
||||||
|
workers: 4,
|
||||||
|
source: '$DSH_GATE_CONCURRENCY',
|
||||||
|
})
|
||||||
|
expect(resolvePlanConcurrency(subject, '8', 32)).toEqual({
|
||||||
|
workers: 7,
|
||||||
|
source: '$DSH_GATE_CONCURRENCY, ci-consumers plan cap 7',
|
||||||
|
})
|
||||||
|
expect(subject.gates.map(item => item.id)).toEqual([
|
||||||
|
'lint-and-duplication',
|
||||||
|
'node-compat',
|
||||||
|
'snapshot',
|
||||||
|
'publint',
|
||||||
|
'node-next-types',
|
||||||
|
'built-package-invariants',
|
||||||
|
'built-bin-smoke',
|
||||||
|
])
|
||||||
|
expect(subject.gates.find(item => item.id === 'publint')?.needs).toBeUndefined()
|
||||||
|
expect(subject.gates.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
|
||||||
|
for (const id of ['snapshot', 'node-next-types', 'built-bin-smoke']) {
|
||||||
|
expect(subject.gates.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
|
||||||
|
}
|
||||||
|
expect(gateDependencyClosure(subject, 'snapshot').map(item => item.id)).toEqual([
|
||||||
|
'snapshot',
|
||||||
|
'publint',
|
||||||
|
'built-package-invariants',
|
||||||
|
])
|
||||||
|
expect(listedGatePlan(subject).gates.find(item => item.id === 'snapshot')?.env).toEqual({
|
||||||
|
DSH_EXAMPLE_MODE: { operation: 'set', value: 'lib' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
1011
scripts/run-gates.ts
1011
scripts/run-gates.ts
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user