refactor(dev-infra): drop retained gate logs
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user