refactor(dev-infra): drop retained gate logs

This commit is contained in:
Tianyi Cui
2026-07-27 23:47:00 +08:00
parent 0fabbd72ad
commit 3f27434f38
6 changed files with 70 additions and 1019 deletions

View File

@@ -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-27-replayable-gate-plans.md
2026-07-27-replayable-gate-plans.md: 0b052352f54c98506c135077a471ffaf35c50009
2026-07-27-replayable-gate-plans.zh.md: 0c9f20d72361ab84a648094256e6d5cd9f4c77c5
2026-07-27-replayable-gate-plans.md: a312481a4da68f0d990e28c07cced4511c922b9b
2026-07-27-replayable-gate-plans.zh.md: c213068f5413110a2c5952ac05ebc326de12682c

View File

@@ -6,33 +6,37 @@ 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.
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.
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.
Operators also need the scheduler-owned environment and dependency context for a failed command. The Node 24 consumer job instead owned a separate shell process pool, duplicating commands, concurrency, environment, and failure collection while allowing restored build artifacts to be consumed before any command established that the download was 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.
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, so inspection never enumerates or bakes 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. Before spawning its dedicated [`gate-log-helper.mjs`](../../../../scripts/gate-log-helper.mjs) process, one prevalidation pass records the repository root's device and inode plus each repository-relative path component's identity or absence. The helper starts with the verified repository root as its process working directory, checks that every existing identity and missing state still matches, and descends to the log directory one component at a time. It creates an expected-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 root or component symlink, real-directory replacement, or unexpected directory introduced after validation therefore fails before mutation instead of redirecting an operation. 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 scheduler announces each start, buffers a child's stdout and stderr until that gate settles, and then emits one attributable result while unrelated gates continue. Failure blocks include the display command, redacted scheduler-owned environment operations, orthogonal exit and signal outcomes, complete child output, and the replay command; successful child output remains suppressed unless `DSH_GATE_VERBOSE=1`. Child output is not persisted.
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.
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 prior process 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.
## 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, a symlinked script entry remains executable, 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, repository-root and component real-directory replacements, expected-missing directory insertion, 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.
[`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, signal termination remains distinct from exit status, and a settled failure is observed before an unrelated gate finishes. Its consumer-plan case pins the seven-command inventory, worker default and ceiling, and restored-build validation dependencies. [`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.
**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 child output under the repository.** Runner-local files disappear with hosted CI jobs unless uploaded, can contain sensitive child data, and require a filesystem ownership and cleanup contract unrelated to plan replay. The console remains the authoritative diagnostic record.
**Stream concurrent child output live.** Unprefixed streams interleave and lose attribution. Emitting each complete block as soon as its gate settles preserves attribution without waiting for unrelated gates.
## Consequences
@@ -40,4 +44,4 @@ The scheduler owns a small CLI and a versioned JSON schema that must evolve deli
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.
Buffered output is coherent and attributable, but no progress from a long-running child appears until that child settles, and the runner retains no second copy after the console is lost. Operators trade live interleaving and durable local output for a smaller scheduler whose diagnostic state is the inspected plan, settlement block, and replay command.

View File

@@ -6,33 +6,37 @@ Status: implemented
## 问题
仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。故障排查者还需要失败命令的确切依赖上下文,以及由调度器掌管的环境设置;在 CI 故障期间从 [`scripts/run-gates.ts`](../../../../scripts/run-gates.ts) 还原这些信息既慢又容易出错。
仓库聚合任务的依赖图无效时,必须在开始执行前失败。若不验证,空聚合任务可能成功退出,重复的门禁 ID 可能覆盖调度器状态,缺失或成环的依赖则可能在无关任务已经运行后,以笼统的跳过状态出现。
Node 24 消费方作业自行管理一套独立的 shell 进程池,会使这个问题更加严重。此时,命令、并发度、环境和失败收集分别拥有两份可执行清单;而且在任何命令确认下载产物完整之前恢复后的构建产物就可能被消费
故障排查者还需要失败命令的依赖上下文和由调度器掌管的环境设置。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`,因此检查结果与失败元数据不会枚举或固化继承值;名称疑似机密项的值会被脱敏。
每种模式都支持确定性的 `--list` 输出,以及带版本标识且保持稳定的 `--list --json` 对象。机器消费方使用 `pnpm --silent run <owning-script> -- --list --json``--silent` 会去除 pnpm 外层的命令横幅,使 stdout 恰好只包含一个 JSON 对象。两种视图都公开规范的门禁顺序、ID、显示命令、依赖、阻塞属性、计划掌管的工作进程上限以及由调度器掌管的环境操作。环境覆盖在 spawn 之前保持声明式,因此检查结果不会枚举或固化继承值;名称疑似机密项的值会被脱敏。
`--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 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们
调度器会宣告每项门禁开始运行,将子进程的 stdout 和 stderr 缓冲到该门禁结束,再在无关门禁仍继续运行时输出一项归属明确的结果。失败块包含显示命令、经过脱敏且由调度器掌管的环境操作、彼此独立的退出码信号结果、完整的子进程输出,以及回放命令;成功运行的子进程输出默认仍不显示,只有设置 `DSH_GATE_VERBOSE=1` 时才会输出。子进程输出不会持久化
`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 和源码兼容性冒烟测试可以与它们并行。恢复后构建产物验证失败时,后续产物消费方会被跳过,但独立的源码诊断仍会运行。
`check:ci:consumers` 模式管理 Node 24 消费方作业的 7 条顶层命令,以及计划中可见的 7 个工作进程默认值和上限。即使主机报告的 CPU 数量更少,该默认值仍会保留原有进程池;`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`
[`scripts/run-gates.spec.ts`](../../../../scripts/run-gates.spec.ts) 证明无效计划无法触达注入的执行器、依赖闭包完整、列表顺序与 JSON 字段稳定、静默的包脚本入口只输出一个可解析的 JSON 对象、回放文本可跨平台使用、环境解析推迟到 spawn 时进行、信号终止与退出状态彼此独立,而且某项门禁失败结束后,无须等待无关门禁完成即可观察到该失败。消费方计划用例固定了 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 条命令的清单,又能让后续产物消费方获得两项检查,而无需增加一项可执行清单条目
**不公开调度器,只在工作流旁记录命令。** 这种方案会留下两份可能发生漂移的可执行清单,也无法揭示实际运行的计划。
**只增加验证,不提供计划检视或聚焦回放。** 这种方案消除了依赖图无效却仍然放行的缺陷,但故障排查者在事故期间仍须从 TypeScript 中还原依赖与隐藏的覆盖设置
**采用通用任务编排器。** 仓库调度器已经负责缓冲、依赖排序、跨平台且不依赖 shell 的进程启动,以及阻塞属性。替换它会增加一项依赖和一次迁移,却不能删除一个独立的本地抽象
**在仓库中持久化子进程输出。** 除非上传,否则运行器本地文件会在托管 CI 作业结束后消失;这些文件可能包含敏感的子进程数据,而且还需要一套与计划回放无关的文件系统所有权与清理契约。控制台仍是权威的诊断记录。
**实时流式输出并发子进程的内容。** 无前缀的流会相互交错并丧失归属。每项门禁结束便输出其完整块,既能保留归属,也无需等待无关门禁。
## 后果
@@ -40,4 +44,4 @@ Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使
后续产物消费方只在 publint 和已构建包不变式验证通过后才启动,因此当任一验证器速度较慢时,并发重叠会减少。独立的源码检查仍可与这两个阶段并行;缺失公开导出或已编译不变式闭包损坏时,系统会及早失败,避免产生误导性的下游结果。
保留输出以及彼此独立的退出码与信号元数据可以改善失败归因,但当子进程打印机密时,也会带来本地敏感数据暴露的代价。在 POSIX 上以仓库根目录为固定起点、每进入一级路径都核验身份的辅助进程加上经过验证且仅属主可访问的路径、排他创建、数量与字节双重上限、经过验证的显式清理命令以及工作流不上传日志共同约束了这项风险但并不声称输出本身是安全的。辅助进程和请求协议增加了本地机制但避免了让目录创建或破坏性操作依赖“先检查、后使用”的路径名。Windows 会有意放弃持久保留本地失败日志,因为 Node 文件模式无法在那里建立相同的隐私契约;其控制台输出仍保持完整
缓冲后的输出连贯且归属明确,但长时间运行的子进程结束前不会显示其进度,控制台内容丢失后运行器也不保留第二份副本。故障排查者接受不再实时交错输出、也不持久保留本地输出,以换取更小的调度器;其诊断状态只由检视后的计划、门禁结束时输出的块和回放命令组成

View File

@@ -1,251 +0,0 @@
#!/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 names = repository.relative.split(sep)
if (!Array.isArray(repository.components) || repository.components.length !== names.length) {
throw new Error('invalid gate-log path-component plan')
}
for (let index = 0; index < names.length; index += 1) {
const component = repository.components[index]
if (
typeof component !== 'object'
|| component === null
|| component.name !== names[index]
|| !('identity' in component)
) {
throw new Error('invalid gate-log path-component plan')
}
if (component.identity !== null) assertIdentity(component.identity, `path component ${component.name}`)
}
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')
}
return repository.components
}
async function enterLogDirectory(components, create) {
const traversed = []
for (const component of components) {
if (component.name === '' || component.name === '.' || component.name === '..') {
throw new Error(`invalid gate-log path component ${JSON.stringify(component.name)}`)
}
traversed.push(component.name)
let componentMetadata
let created = false
try {
componentMetadata = await lstat(component.name, { bigint: true })
} catch (error) {
if (errorCode(error) !== 'ENOENT') throw error
if (component.identity !== null) {
throw new Error(`gate-log path component disappeared after validation: ${traversed.join('/')}`)
}
if (!create) return undefined
try {
await mkdir(component.name, { mode: 0o700 })
} catch (mkdirError) {
if (errorCode(mkdirError) === 'EEXIST') {
throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`)
}
throw mkdirError
}
componentMetadata = await lstat(component.name, { bigint: true })
created = true
}
if (component.identity === null && !created) {
throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`)
}
if (component.identity !== null && !sameIdentity(componentMetadata, component.identity)) {
throw new Error(`gate-log path component identity changed after validation: ${traversed.join('/')}`)
}
const shown = traversed.join('/')
if (!componentMetadata.isDirectory() || componentMetadata.isSymbolicLink()) {
throw new Error(`gate-log path component is not a real directory: ${shown}`)
}
const expected = component.identity ?? identityOf(componentMetadata)
process.chdir(component.name)
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)
const components = await assertPinnedRepository(request.repository)
const directory = await enterLogDirectory(components, 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
}

View File

@@ -1,24 +1,14 @@
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,
@@ -27,15 +17,12 @@ import {
gatePlanForMode,
isMainModule,
listedGatePlan,
limitGateFailureLog,
parseCliRequest,
pruneGateLogs,
replayCommand,
resolveGateEnvironment,
resolvePlanConcurrency,
runGate,
validateGatePlan,
writeGateFailureLog,
type Gate,
type GatePlan,
type GateResult,
@@ -77,36 +64,12 @@ function resultFor(subject: Gate, status: GateResult['status'] = 'passed'): Gate
}
}
function temporaryRoot(prefix = 'dsh-gate-logs-'): string {
function temporaryRoot(prefix = 'dsh-run-gates-'): string {
const root = mkdtempSync(join(tmpdir(), prefix))
temporaryRoots.push(root)
return root
}
function invokeGateLogOperation(
operation: 'write' | 'prune' | 'clean',
subjectGate: Gate,
directory: string,
root: string,
beforeHelper: () => void,
): Promise<unknown> {
switch (operation) {
case 'write':
return writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
directory,
repositoryRoot: root,
retention: 1,
unique: operation,
platform: 'linux',
beforeHelper,
})
case 'prune':
return pruneGateLogs(directory, 0, root, beforeHelper)
case 'clean':
return cleanGateFailureLogs(directory, root, beforeHelper)
}
}
function withPnpmEntrypoint<T>(action: () => T): T {
const previous = process.env.npm_execpath
process.env.npm_execpath = '/private/pnpm.cjs'
@@ -167,6 +130,31 @@ describe('gate plan validation', () => {
expect(execute).not.toHaveBeenCalled()
})
it('reports a settled failure before an unrelated gate finishes', async () => {
const first = gate('first')
const second = gate('second')
const settle = new Map<string, (result: GateResult) => void>()
const observed: string[] = []
const execution = executeGatePlan(
plan([first, second]),
2,
subject => new Promise(resolve => settle.set(subject.id, resolve)),
result => observed.push(`${result.gate.id}:${result.status}`),
)
const settleFirst = settle.get(first.id)
const settleSecond = settle.get(second.id)
if (settleFirst === undefined || settleSecond === undefined) throw new Error('expected both gates to start')
settleFirst(resultFor(first, 'failed'))
await vi.waitFor(() => {
expect(observed).toEqual(['first:failed'])
})
settleSecond(resultFor(second))
await expect(execution).resolves.toHaveLength(2)
expect(observed).toEqual(['first:failed', 'second:passed'])
})
it('selects a target with its transitive dependencies in canonical plan order', () => {
const subject = plan([
gate('prepare'),
@@ -182,14 +170,13 @@ describe('gate plan validation', () => {
})
describe('gate plan inspection and replay', () => {
it('parses package-script separators, list JSON, focused runs, and cleanup', () => {
it('parses package-script separators, list JSON, and focused runs', () => {
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')
})
@@ -307,288 +294,6 @@ describe('gate plan inspection and replay', () => {
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')
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')
}
const invocation = invokeGateLogOperation(
operation,
subjectGate,
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.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => {
const subjectGate = gate('subject')
for (const operation of ['write', 'prune', 'clean'] as const) {
const auditRoot = temporaryRoot(`dsh-gate-${operation}-real-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')
const externalCache = join(external, 'cache')
mkdirSync(directory, { recursive: true })
mkdirSync(join(externalCache, 'gates'), { recursive: true })
writeFileSync(join(directory, 'old.log'), 'old private log\n')
const victim = operation === 'write' ? undefined : join(externalCache, 'gates/victim.log')
if (victim !== undefined) writeFileSync(victim, 'keep\n')
const swapAncestor = (): void => {
renameSync(cache, displacedCache)
renameSync(externalCache, cache)
}
const invocation = invokeGateLogOperation(
operation,
subjectGate,
directory,
repositoryRoot,
swapAncestor,
)
await expect(invocation).rejects.toThrow('gate-log helper')
if (victim === undefined) {
expect(readdirSync(join(cache, 'gates'))).toEqual([])
} else {
expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n')
}
expect(readFileSync(join(displacedCache, 'gates/old.log'), 'utf8')).toBe('old private log\n')
}
})
it.skipIf(process.platform === 'win32')('rejects a real directory introduced at a previously missing component', async () => {
const auditRoot = temporaryRoot('dsh-gate-missing-real-swap-')
const repositoryRoot = join(auditRoot, 'repository')
const externalCache = join(auditRoot, 'external-cache')
const cache = join(repositoryRoot, '.cache')
const directory = join(cache, 'gates')
mkdirSync(repositoryRoot)
mkdirSync(join(externalCache, 'gates'), { recursive: true })
const victim = join(externalCache, 'gates/victim.log')
writeFileSync(victim, 'keep\n')
const subjectGate = gate('subject')
const invocation = writeGateFailureLog(plan([subjectGate]), resultFor(subjectGate, 'failed'), {
directory,
repositoryRoot,
retention: 1,
unique: 'missing-swap',
platform: 'linux',
beforeHelper: () => {
renameSync(externalCache, cache)
},
})
await expect(invocation).rejects.toThrow('gate-log helper')
expect(readFileSync(join(cache, 'gates/victim.log'), 'utf8')).toBe('keep\n')
expect(readdirSync(join(cache, 'gates'))).toEqual(['victim.log'])
})
it.skipIf(process.platform === 'win32')('rejects a repository root replaced after validation', async () => {
const auditRoot = temporaryRoot('dsh-gate-root-swap-')
const repositoryRoot = join(auditRoot, 'repository')
const externalRoot = join(auditRoot, 'external-repository')
const displacedRoot = join(auditRoot, 'repository-pinned')
const directory = join(repositoryRoot, '.cache/gates')
mkdirSync(directory, { recursive: true })
mkdirSync(join(externalRoot, '.cache/gates'), { recursive: true })
writeFileSync(join(directory, 'old.log'), 'old private log\n')
writeFileSync(join(externalRoot, '.cache/gates/victim.log'), 'keep\n')
const invocation = cleanGateFailureLogs(directory, repositoryRoot, () => {
renameSync(repositoryRoot, displacedRoot)
renameSync(externalRoot, repositoryRoot)
})
await expect(invocation).rejects.toThrow('gate-log helper')
expect(readFileSync(join(repositoryRoot, '.cache/gates/victim.log'), 'utf8')).toBe('keep\n')
expect(readFileSync(join(displacedRoot, '.cache/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)
})
})

View File

@@ -2,15 +2,13 @@
* Construct, inspect, and run local and CI quality-gate plans with bounded scheduling.
*
* Package scripts own public aggregate names; this runner owns their validated
* dependency graphs, scheduler environment, replay diagnostics, and private logs.
* dependency graphs, scheduler environment, and replay diagnostics.
* @see ../.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md
*/
import { spawn } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { realpathSync } from 'node:fs'
import { lstat } from 'node:fs/promises'
import { availableParallelism } from 'node:os'
import { isAbsolute, relative, resolve, sep } from 'node:path'
import { resolve } from 'node:path'
import { performance } from 'node:perf_hooks'
import { pathToFileURL } from 'node:url'
@@ -74,8 +72,6 @@ export interface GateResult {
exitCode: number | null
signalCode: NodeJS.Signals | null
error?: string
logPath?: string
logError?: string
}
interface GateOutputChunk {
@@ -102,12 +98,6 @@ interface RunRequest {
only?: string
}
interface CleanLogsRequest {
kind: 'clean-logs'
}
type CliRequest = RunRequest | CleanLogsRequest
interface ListedEnvironmentOverride {
operation: GateEnvironmentOverride['operation']
value?: string
@@ -132,41 +122,10 @@ interface ListedPlan {
gates: ListedGate[]
}
interface GateLogDirectoryIdentity {
dev: string
ino: string
}
interface GateLogPathComponent {
name: string
identity: GateLogDirectoryIdentity | null
}
interface GateLogPathPlan {
repositoryIdentity: GateLogDirectoryIdentity
pathComponents: GateLogPathComponent[]
}
type GateLogHelperRequest =
| { operation: 'write'; filename: string; content: string; retention: number }
| { operation: 'prune'; retain: number }
| { operation: 'clean' }
interface GateLogHelperResult {
directory?: GateLogDirectoryIdentity
filename?: string
removed: string[]
}
type GateExecutor = (gate: Gate) => Promise<GateResult>
type ResultObserver = (result: GateResult) => Promise<void> | void
type ResultObserver = (result: GateResult) => void
const root = resolve(import.meta.dirname, '..')
const gateLogRoot = resolve(root, '.cache/gates')
const gateLogHelper = resolve(import.meta.dirname, 'gate-log-helper.mjs')
const GATE_LOG_RETENTION = 20
const GATE_LOG_MAX_BYTES = 1_048_576
const MIN_GATE_LOG_MAX_BYTES = 128
const MODE_SCRIPTS: Record<Mode, string> = {
'ci-primary': 'check:ci',
'ci-static': 'check:ci:static',
@@ -187,12 +146,6 @@ if (isMainModule()) process.exitCode = await main(process.argv.slice(2))
async function main(args: string[]): Promise<number> {
const request = parseCliRequest(args)
if (request.kind === 'clean-logs') {
await cleanGateFailureLogs()
console.log('run-gates: cleared retained logs in .cache/gates/.')
return 0
}
const completePlan = gatePlanForMode(request.mode)
validateGatePlan(completePlan)
if (request.list) {
@@ -212,8 +165,7 @@ async function main(args: string[]): Promise<number> {
const startedAt = performance.now()
console.log(`run-gates: ${request.mode} running ${plan.gates.length} gate(s) with ${maxConcurrency} worker(s) from ${concurrencySource}.`)
const results = await executeGatePlan(plan, maxConcurrency, runGate, async (result) => {
await attachFailureLog(completePlan, result)
const results = await executeGatePlan(plan, maxConcurrency, runGate, (result) => {
printResult(completePlan, result)
})
printSummary(completePlan, results, performance.now() - startedAt)
@@ -240,14 +192,9 @@ export function isMainModule(entry: string | undefined = process.argv[1]): boole
/**
* Parse one runner invocation without constructing or starting its plan.
* @param args - command-line arguments after the script entrypoint.
* @returns the validated run or cleanup request.
* @returns the validated run request.
*/
export function parseCliRequest(args: readonly string[]): CliRequest {
if (args[0] === '--clean-logs') {
if (args.length !== 1) throw new Error('run-gates: --clean-logs does not accept other arguments.')
return { kind: 'clean-logs' }
}
export function parseCliRequest(args: readonly string[]): RunRequest {
const mode = parseMode(args[0])
let list = false
let json = false
@@ -946,7 +893,7 @@ export function resolveGateEnvironment(gate: Gate, inherited: NodeJS.ProcessEnv)
* @param plan - complete or diagnostic plan to execute.
* @param maxActive - maximum concurrent child count.
* @param execute - child-process executor.
* @param observe - serialized result observer.
* @param observe - result observer invoked when each gate settles.
* @returns results in canonical plan order.
*/
export async function executeGatePlan(
@@ -965,358 +912,6 @@ export async function executeGatePlan(
return runGates(plan.gates, maxActive, execute, observe)
}
/**
* Format one private failure log without consulting or enumerating the inherited environment.
* @param plan - complete owning plan.
* @param result - failed child outcome.
* @returns attributable metadata and interleaved output.
*/
export function formatGateFailureLog(plan: GatePlan, result: GateResult): string {
const gate = listedGate(result.gate)
const lines = [
'run-gates failure log',
`mode: ${plan.mode}`,
`gate: ${gate.id}`,
`status: ${result.status}`,
`blocking: ${gate.blocking}`,
`command: ${gate.command}`,
`replay: ${replayCommand(plan, gate.id)}`,
`scheduler environment: ${JSON.stringify(gate.env)}`,
`exit code: ${result.exitCode === null ? 'none' : result.exitCode}`,
`signal: ${result.signalCode ?? 'none'}`,
]
if (result.error !== undefined) lines.push(`error: ${result.error}`)
lines.push('', 'interleaved output:')
for (const chunk of result.output) lines.push(`[${chunk.stream}]`, chunk.text)
return `${lines.join('\n')}\n`
}
/**
* Explain why retained logs are unavailable on a platform.
* @param platform - host platform to evaluate.
* @returns the console-fallback diagnostic, or `undefined` when POSIX retention is supported.
*/
export function failureLogUnavailableReason(platform: NodeJS.Platform = process.platform): string | undefined {
return platform === 'win32'
? 'retained failure logs are disabled on Windows because POSIX owner-only permissions are unavailable; complete output remains on the console'
: undefined
}
/**
* Bound a UTF-8 failure log while retaining its beginning, end, and explicit truncation metadata.
* @param content - complete formatted failure log.
* @param maxBytes - maximum encoded byte length.
* @returns the original log when it fits, otherwise a bounded prefix and suffix around a marker.
*/
export function limitGateFailureLog(content: string, maxBytes: number): string {
if (!Number.isSafeInteger(maxBytes) || maxBytes < MIN_GATE_LOG_MAX_BYTES) {
throw new Error(`run-gates: failure-log byte limit must be an integer of at least ${MIN_GATE_LOG_MAX_BYTES}, got ${JSON.stringify(maxBytes)}.`)
}
const originalBytes = Buffer.byteLength(content)
if (originalBytes <= maxBytes) return content
const marker = `\n[run-gates log truncated: original-bytes=${originalBytes}; max-bytes=${maxBytes}]\n`
const available = maxBytes - Buffer.byteLength(marker)
if (available < 0) throw new Error('run-gates: failure-log truncation marker exceeds the configured byte limit.')
const prefixBytes = Math.ceil(available / 2)
const suffixBytes = available - prefixBytes
return `${utf8Prefix(content, prefixBytes)}${marker}${utf8Suffix(content, suffixBytes)}`
}
function utf8Prefix(content: string, maxBytes: number): string {
const encoded = Buffer.from(content)
if (encoded.length <= maxBytes) return content
let end = maxBytes
while (end > 0) {
const byte = encoded[end]
if (byte === undefined || (byte & 0xc0) !== 0x80) break
end -= 1
}
return encoded.subarray(0, end).toString('utf8')
}
function utf8Suffix(content: string, maxBytes: number): string {
const encoded = Buffer.from(content)
if (encoded.length <= maxBytes) return content
let start = encoded.length - maxBytes
while (start < encoded.length) {
const byte = encoded[start]
if (byte === undefined || (byte & 0xc0) !== 0x80) break
start += 1
}
return encoded.subarray(start).toString('utf8')
}
/**
* Write one exclusive owner-only POSIX failure log and keep only the newest bounded set.
* @param plan - complete owning plan.
* @param result - failed child outcome.
* @param options - injectable storage, bound, clock, identity, and platform seams.
* @returns the absolute log path.
*/
export async function writeGateFailureLog(
plan: GatePlan,
result: GateResult,
options: {
directory?: string
repositoryRoot?: string
retention?: number
maxBytes?: number
unique?: string
now?: Date
platform?: NodeJS.Platform
beforeHelper?: () => Promise<void> | void
} = {},
): Promise<string> {
const directory = options.directory ?? gateLogRoot
const repositoryRoot = options.repositoryRoot ?? root
const retention = options.retention ?? GATE_LOG_RETENTION
const maxBytes = options.maxBytes ?? GATE_LOG_MAX_BYTES
const unique = options.unique ?? randomUUID()
const now = options.now ?? new Date()
const unavailable = failureLogUnavailableReason(options.platform)
if (unavailable !== undefined) throw new Error(`run-gates: ${unavailable}.`)
if (!Number.isSafeInteger(retention) || retention < 1) {
throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`)
}
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
const timestamp = now.toISOString().replaceAll(/[:.]/g, '-')
const safeUnique = unique.replaceAll(/[^a-zA-Z0-9-]/g, '')
if (safeUnique === '') throw new Error('run-gates: failure-log unique suffix is empty after sanitization.')
const safeGateId = result.gate.id.replaceAll(/[^a-zA-Z0-9-]/g, '-')
const filename = `${timestamp}-${plan.mode}-${safeGateId}-${safeUnique}.log`
const helperResult = await runGateLogHelper(
directory,
repositoryRoot,
repositoryIdentity,
pathComponents,
{
operation: 'write',
filename,
content: limitGateFailureLog(formatGateFailureLog(plan, result), maxBytes),
retention,
},
options.beforeHelper,
)
if (helperResult.filename !== filename) throw new Error('run-gates: gate-log helper returned the wrong filename.')
return resolve(directory, filename)
}
async function inspectRepoLocalLogPath(
repositoryRoot: string,
target: string,
): Promise<GateLogPathPlan> {
const relativeTarget = relative(repositoryRoot, target)
if (relativeTarget === '' || relativeTarget === '..' || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
throw new Error(`run-gates: gate-log path must be below the repository root: ${target}`)
}
const rootMetadata = await lstat(repositoryRoot, { bigint: true })
if (!rootMetadata.isDirectory() || rootMetadata.isSymbolicLink()) {
throw new Error(`run-gates: repository root is not a real directory: ${repositoryRoot}`)
}
const components: GateLogPathComponent[] = []
let current = repositoryRoot
let missing = false
for (const component of relativeTarget.split(sep)) {
current = resolve(current, component)
if (missing) {
components.push({ name: component, identity: null })
continue
}
let metadata
try {
metadata = await lstat(current, { bigint: true })
} catch (error: unknown) {
if (hasErrorCode(error, 'ENOENT')) {
missing = true
components.push({ name: component, identity: null })
continue
}
throw error
}
const shown = relative(repositoryRoot, current).split(sep).join('/')
if (metadata.isSymbolicLink()) {
throw new Error(`run-gates: gate-log path component is a symbolic link: ${shown}`)
}
if (!metadata.isDirectory()) {
throw new Error(`run-gates: gate-log path component is not a directory: ${shown}`)
}
components.push({ name: component, identity: { dev: String(metadata.dev), ino: String(metadata.ino) } })
}
return {
repositoryIdentity: { dev: String(rootMetadata.dev), ino: String(rootMetadata.ino) },
pathComponents: components,
}
}
function hasErrorCode(error: unknown, code: string): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === code
}
async function readDirectoryIdentity(directory: string): Promise<GateLogDirectoryIdentity | undefined> {
let metadata
try {
metadata = await lstat(directory, { bigint: true })
} catch (error: unknown) {
if (hasErrorCode(error, 'ENOENT')) return undefined
throw error
}
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
throw new Error(`run-gates: gate-log path is not a real directory: ${directory}`)
}
return { dev: String(metadata.dev), ino: String(metadata.ino) }
}
async function runGateLogHelper(
directory: string,
repositoryRoot: string,
repositoryIdentity: GateLogDirectoryIdentity,
pathComponents: GateLogPathComponent[],
request: GateLogHelperRequest,
beforeHelper: (() => Promise<void> | void) | undefined,
): Promise<GateLogHelperResult> {
await beforeHelper?.()
const payload = JSON.stringify({
...request,
repository: {
root: repositoryRoot,
relative: relative(repositoryRoot, directory),
identity: repositoryIdentity,
components: pathComponents,
},
})
const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => {
const child = spawn(process.execPath, [gateLogHelper], {
cwd: repositoryRoot,
env: {},
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (chunk: string) => {
stdout += chunk
})
child.stderr.on('data', (chunk: string) => {
stderr += chunk
})
child.on('error', reject)
child.on('close', (status) => {
resolveResult({ status, stdout, stderr })
})
child.stdin.on('error', (error: NodeJS.ErrnoException) => {
if (error.code !== 'EPIPE') reject(error)
})
child.stdin.end(payload)
})
if (result.status !== 0) {
throw new Error(`run-gates: gate-log helper failed: ${result.stderr.trim() || `exit status ${String(result.status)}`}`)
}
let parsed: unknown
try {
parsed = JSON.parse(result.stdout)
} catch {
throw new Error(`run-gates: gate-log helper returned invalid JSON: ${JSON.stringify(result.stdout)}`)
}
if (!isGateLogHelperResult(parsed)) throw new Error('run-gates: gate-log helper returned an invalid result.')
await inspectRepoLocalLogPath(repositoryRoot, directory)
const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot)
if (
currentRepositoryIdentity === undefined
|| currentRepositoryIdentity.dev !== repositoryIdentity.dev
|| currentRepositoryIdentity.ino !== repositoryIdentity.ino
) {
throw new Error('run-gates: repository root identity changed while the gate-log helper was running.')
}
if (parsed.directory !== undefined) {
const currentDirectoryIdentity = await readDirectoryIdentity(directory)
if (
currentDirectoryIdentity === undefined
|| currentDirectoryIdentity.dev !== parsed.directory.dev
|| currentDirectoryIdentity.ino !== parsed.directory.ino
) {
throw new Error('run-gates: gate-log directory identity changed while the helper was running.')
}
} else if (request.operation === 'write') {
throw new Error('run-gates: gate-log helper did not return the created directory identity.')
}
return parsed
}
function isGateLogHelperResult(value: unknown): value is GateLogHelperResult {
if (typeof value !== 'object' || value === null || !('removed' in value) || !Array.isArray(value.removed)) return false
if (!value.removed.every(entry => typeof entry === 'string')) return false
if ('filename' in value && value.filename !== undefined && typeof value.filename !== 'string') return false
return !('directory' in value)
|| value.directory === undefined
|| isGateLogDirectoryIdentity(value.directory)
}
function isGateLogDirectoryIdentity(value: unknown): value is GateLogDirectoryIdentity {
return typeof value === 'object'
&& value !== null
&& 'dev' in value
&& typeof value.dev === 'string'
&& 'ino' in value
&& typeof value.ino === 'string'
}
/** Clear retained logs through a subprocess that pins the repository and each path component before use. */
export async function cleanGateFailureLogs(
directory = gateLogRoot,
repositoryRoot = root,
beforeHelper?: () => Promise<void> | void,
): Promise<void> {
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
await runGateLogHelper(
directory,
repositoryRoot,
repositoryIdentity,
pathComponents,
{ operation: 'clean' },
beforeHelper,
)
}
/**
* Remove older scheduler log files until at most `retain` remain.
* @param directory - private log directory.
* @param retain - number of newest log files to preserve.
* @param repositoryRoot - repository boundary containing the log directory.
* @param beforeHelper - test seam invoked after identity capture and before subprocess spawn.
*/
export async function pruneGateLogs(
directory: string,
retain: number,
repositoryRoot = root,
beforeHelper?: () => Promise<void> | void,
): Promise<void> {
if (!Number.isSafeInteger(retain) || retain < 0) {
throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`)
}
const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory)
await runGateLogHelper(
directory,
repositoryRoot,
repositoryIdentity,
pathComponents,
{ operation: 'prune', retain },
beforeHelper,
)
}
async function attachFailureLog(plan: GatePlan, result: GateResult): Promise<void> {
if (result.status !== 'failed') return
try {
const path = await writeGateFailureLog(plan, result)
result.logPath = relative(root, path).split(sep).join('/')
} catch (error: unknown) {
result.logError = error instanceof Error ? error.message : String(error)
}
}
async function runGates(
allGates: Gate[],
maxActive: number,
@@ -1355,7 +950,7 @@ async function runGates(
}
states.set(gate.id, 'skipped')
results.set(gate.id, result)
await observe(result)
observe(result)
}
break
}
@@ -1365,7 +960,7 @@ async function runGates(
running.splice(running.indexOf(settled.item), 1)
states.set(settled.item.gate.id, settled.result.status)
results.set(settled.item.gate.id, settled.result)
await observe(settled.result)
observe(settled.result)
}
}
@@ -1477,11 +1072,6 @@ function printResult(plan: GatePlan, result: GateResult): void {
console.error(`command: ${result.gate.displayCommand}`)
if (Object.keys(environment).length > 0) console.error(`scheduler environment: ${JSON.stringify(environment)}`)
console.error(`replay: ${replayCommand(plan, result.gate.id)}`)
if (result.logPath !== undefined) {
console.error(`full log: ${result.logPath} (private; newest ${GATE_LOG_RETENTION} retained)`)
console.error('cleanup: pnpm exec tsx scripts/run-gates.ts --clean-logs')
}
if (result.logError !== undefined) console.error(`full log unavailable: ${result.logError}`)
}
printOutput(result.output)
if (result.error !== undefined) console.error(result.error)
@@ -1504,7 +1094,6 @@ function printSummary(plan: GatePlan, results: GateResult[], durationMs: number)
const disposition = result.gate.allowFailure === true ? 'NON-BLOCKING ' : ''
console.error(` - ${disposition}${result.status.toUpperCase()} ${result.gate.label} (${duration}s, ${reason})`)
console.error(` replay: ${replayCommand(plan, result.gate.id)}`)
if (result.logPath !== undefined) console.error(` full log: ${result.logPath}`)
}
}