diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml index 3966caf9cf..4b12a533b2 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md -2026-07-27-replayable-gate-plans.md: 604fb87ebe91f8ebd606d128e8927535502b6ea9 -2026-07-27-replayable-gate-plans.zh.md: e24d77ff6245bac6a06cd9f9ea8e849dafc95051 +2026-07-27-replayable-gate-plans.md: 0b052352f54c98506c135077a471ffaf35c50009 +2026-07-27-replayable-gate-plans.zh.md: 0c9f20d72361ab84a648094256e6d5cd9f4c77c5 diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md index 604fb87ebe..0b052352f5 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.md @@ -18,13 +18,13 @@ Every mode supports deterministic `--list` output and a versioned stable `--list `--only ` 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 -- --only `, 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. +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 `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. +[`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. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md index e24d77ff62..0c9f20d723 100644 --- a/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-replayable-gate-plans.zh.md @@ -18,13 +18,13 @@ Node 24 消费方作业若自行管理一套独立的 shell 进程池,会使 `--only ` 按规范的计划顺序运行指定门禁及其完整的传递依赖闭包。启动横幅明确标记本次运行只构成局部诊断证据,并给出所属的完整包(package)脚本。每个失败或跳过的门禁都打印跨平台回放命令 `pnpm run -- --only `,该命令通过调度器还原依赖与环境语义。 -在 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 的仅属主访问契约,因此不会保留文件,而是打印明确的控制台回退诊断;每个平台的完整失败输出仍会写到控制台。输出本身可能包含来自子进程的敏感数据,因此保留的日志保持私有,工作流不会上传它们。 +在 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`。 +[`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`。 ## 曾考虑的替代方案 diff --git a/scripts/gate-log-helper.mjs b/scripts/gate-log-helper.mjs index 363b538f70..0c1e4fa3af 100644 --- a/scripts/gate-log-helper.mjs +++ b/scripts/gate-log-helper.mjs @@ -89,6 +89,22 @@ async function assertPinnedRepository(repository) { 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') @@ -101,34 +117,49 @@ async function assertPinnedRepository(repository) { ) { throw new Error('gate-log repository root is not a real directory') } + return repository.components } -async function enterLogDirectory(relativePath, create) { +async function enterLogDirectory(components, 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)}`) + 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) + traversed.push(component.name) let componentMetadata + let created = false try { - componentMetadata = await lstat(component, { bigint: true }) + 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, { mode: 0o700 }) + await mkdir(component.name, { mode: 0o700 }) } catch (mkdirError) { - if (errorCode(mkdirError) !== 'EEXIST') throw mkdirError + if (errorCode(mkdirError) === 'EEXIST') { + throw new Error(`gate-log path component appeared after validation: ${traversed.join('/')}`) + } + throw mkdirError } - componentMetadata = await lstat(component, { bigint: true }) + 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 = identityOf(componentMetadata) - process.chdir(component) + 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}`) @@ -195,8 +226,8 @@ async function writeLog(request) { async function main() { const request = await readRequest() assertRequest(request) - await assertPinnedRepository(request.repository) - const directory = await enterLogDirectory(request.repository.relative, request.operation === 'write') + const components = await assertPinnedRepository(request.repository) + const directory = await enterLogDirectory(components, request.operation === 'write') if (directory === undefined) return { removed: [] } switch (request.operation) { case 'write': { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 49c1aab675..46d41a5df8 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -25,6 +25,7 @@ import { formatOnlyNotice, gateDependencyClosure, gatePlanForMode, + isMainModule, listedGatePlan, limitGateFailureLog, parseCliRequest, @@ -240,6 +241,15 @@ describe('gate plan inspection and replay', () => { }) }) + it.skipIf(process.platform === 'win32')('recognizes a symlinked script entry path', () => { + const temporary = temporaryRoot('dsh-run-gates-entry-') + const entry = join(temporary, 'run-gates.ts') + symlinkSync(join(repositoryRoot, 'scripts/run-gates.ts'), entry) + + expect(isMainModule(entry)).toBe(true) + expect(isMainModule(join(temporary, 'missing.ts'))).toBe(false) + }) + 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') @@ -465,6 +475,103 @@ describe('gate failure logs', () => { } }) + it.skipIf(process.platform === 'win32')('rejects a real-directory ancestor moved into place after validation', async () => { + const subjectGate = gate('subject') + const subject = plan([subjectGate]) + + 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) + } + + let invocation: Promise + 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(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') diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 268c2ffd0f..08a524556e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -7,6 +7,7 @@ */ 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' @@ -136,6 +137,16 @@ interface GateLogDirectoryIdentity { 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 } @@ -211,9 +222,19 @@ async function main(args: string[]): Promise { : 0 } -function isMainModule(): boolean { - const entry = process.argv[1] - return entry !== undefined && import.meta.url === pathToFileURL(resolve(entry)).href +/** + * Decide whether this module is the process entry, including through a symlinked path. + * @param entry - process entry path to compare with this module. + * @returns Whether the entry resolves to this module. + */ +export function isMainModule(entry: string | undefined = process.argv[1]): boolean { + if (entry === undefined) return false + if (import.meta.url === pathToFileURL(resolve(entry)).href) return true + try { + return import.meta.url === pathToFileURL(realpathSync(entry)).href + } catch { + return false + } } /** @@ -1058,9 +1079,7 @@ export async function writeGateFailureLog( if (!Number.isSafeInteger(retention) || retention < 1) { throw new Error(`run-gates: log retention must be a positive integer, got ${JSON.stringify(retention)}.`) } - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) throw new Error(`run-gates: repository root disappeared: ${repositoryRoot}`) + 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.') @@ -1070,6 +1089,7 @@ export async function writeGateFailureLog( directory, repositoryRoot, repositoryIdentity, + pathComponents, { operation: 'write', filename, @@ -1082,24 +1102,37 @@ export async function writeGateFailureLog( return resolve(directory, filename) } -async function assertRepoLocalLogPath(repositoryRoot: string, target: string): Promise { +async function inspectRepoLocalLogPath( + repositoryRoot: string, + target: string, +): Promise { 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) + 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) + metadata = await lstat(current, { bigint: true }) } catch (error: unknown) { - if (hasErrorCode(error, 'ENOENT')) return + if (hasErrorCode(error, 'ENOENT')) { + missing = true + components.push({ name: component, identity: null }) + continue + } throw error } const shown = relative(repositoryRoot, current).split(sep).join('/') @@ -1109,6 +1142,11 @@ async function assertRepoLocalLogPath(repositoryRoot: string, target: string): P 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, } } @@ -1134,6 +1172,7 @@ async function runGateLogHelper( directory: string, repositoryRoot: string, repositoryIdentity: GateLogDirectoryIdentity, + pathComponents: GateLogPathComponent[], request: GateLogHelperRequest, beforeHelper: (() => Promise | void) | undefined, ): Promise { @@ -1144,6 +1183,7 @@ async function runGateLogHelper( root: repositoryRoot, relative: relative(repositoryRoot, directory), identity: repositoryIdentity, + components: pathComponents, }, }) const result = await new Promise<{ status: number | null; stdout: string; stderr: string }>((resolveResult, reject) => { @@ -1181,7 +1221,7 @@ async function runGateLogHelper( 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 assertRepoLocalLogPath(repositoryRoot, directory) + await inspectRepoLocalLogPath(repositoryRoot, directory) const currentRepositoryIdentity = await readDirectoryIdentity(repositoryRoot) if ( currentRepositoryIdentity === undefined @@ -1229,10 +1269,15 @@ export async function cleanGateFailureLogs( repositoryRoot = root, beforeHelper?: () => Promise | void, ): Promise { - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) return - await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'clean' }, beforeHelper) + const { pathComponents, repositoryIdentity } = await inspectRepoLocalLogPath(repositoryRoot, directory) + await runGateLogHelper( + directory, + repositoryRoot, + repositoryIdentity, + pathComponents, + { operation: 'clean' }, + beforeHelper, + ) } /** @@ -1251,10 +1296,15 @@ export async function pruneGateLogs( if (!Number.isSafeInteger(retain) || retain < 0) { throw new Error(`run-gates: retained log count must be a non-negative integer, got ${JSON.stringify(retain)}.`) } - await assertRepoLocalLogPath(repositoryRoot, directory) - const repositoryIdentity = await readDirectoryIdentity(repositoryRoot) - if (repositoryIdentity === undefined) return - await runGateLogHelper(directory, repositoryRoot, repositoryIdentity, { operation: 'prune', retain }, beforeHelper) + 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 {