fix(relay): COPY missing _seed-envelope-source + _seed-contract — chokepointFlows stale 32h (#3132)

* fix(relay): COPY _seed-envelope-source + _seed-contract into Dockerfile.relay

Root cause of chokepointFlows STALE_SEED (1911min stale, maxStaleMin=720):
since 2026-04-14 (PR #3097/#3101 landing), scripts/_seed-utils.mjs imports
_seed-envelope-source.mjs and _seed-contract.mjs. Dockerfile.relay COPY'd
_seed-utils.mjs but NOT its new transitive dependencies, so every execFile
invocation of seed-chokepoint-flows.mjs, seed-climate-news.mjs, and
seed-ember-electricity.mjs crashed at import with ERR_MODULE_NOT_FOUND.
The ais-relay loop kept firing every 6h but each child died instantly —
no visible error because execFile only surfaces child stderr to the
parent relay's log stream.

Local repro: node scripts/seed-chokepoint-flows.mjs runs fine in 3.6s
and writes 7 records. Same command inside the relay container would
throw at the import line because the file doesn't exist.

Fix:
1. Add COPY scripts/_seed-envelope-source.mjs and
   COPY scripts/_seed-contract.mjs to Dockerfile.relay.
2. Add a static guard test (tests/dockerfile-relay-imports.test.mjs)
   that BFS's the transitive-import graph from every COPY'd entrypoint
   and fails if any reached scripts/*.mjs|cjs isn't also COPY'd. This
   would have caught the original regression.

Matches feedback_dockerfile_relay_explicit_copy.md — we now have a test
enforcing it.

* fix(test): scanner also covers require() and createRequire(...)(...) — greptile P2

Review finding on PR #3132: collectRelativeImports only matched ESM
import/export syntax, so require('./x.cjs') in ais-relay.cjs and
createRequire(import.meta.url)('./x.cjs') in _seed-utils.mjs were
invisible to the guard. No active bug (_proxy-utils.cjs is already
COPY'd) but a future createRequire pointing at a new uncopied helper
would slip through.

Two regexes now cover both forms:
- cjsRe: direct require('./x') — with a non-identifier lookbehind so
  'thisrequire(' or 'foorequire(' can't match.
- createRequireRe: createRequire(...)('./x') chained-call — the outer
  call is applied to createRequire's return value, not to a 'require('
  token, so the first regex misses it on its own.

Added a unit test asserting both forms resolve on known sites
(_seed-utils.mjs and ais-relay.cjs) so the next edit to this file
can't silently drop coverage.
This commit is contained in:
Elie Habib
2026-04-16 17:28:16 +04:00
committed by GitHub
parent d1a3fdffed
commit c31662c3c9
2 changed files with 120 additions and 0 deletions

View File

@@ -22,6 +22,15 @@ RUN npm ci --prefix scripts --omit=dev
COPY scripts/ais-relay.cjs ./scripts/ais-relay.cjs
COPY scripts/_proxy-utils.cjs ./scripts/_proxy-utils.cjs
COPY scripts/_seed-utils.mjs ./scripts/_seed-utils.mjs
# _seed-envelope-source.mjs and _seed-contract.mjs are transitively imported
# by _seed-utils.mjs (lines 9-10) and by seed-chokepoint-flows.mjs /
# seed-ember-electricity.mjs directly. Missing them here = silent
# ERR_MODULE_NOT_FOUND on every execFile invocation, which looks like a hung
# Railway cron (the initial-seed path throws, the 6h setInterval keeps firing
# but each child dies on import). tests/dockerfile-relay-imports.test.mjs
# guards this COPY list against future regressions.
COPY scripts/_seed-envelope-source.mjs ./scripts/_seed-envelope-source.mjs
COPY scripts/_seed-contract.mjs ./scripts/_seed-contract.mjs
COPY scripts/_country-resolver.mjs ./scripts/_country-resolver.mjs
COPY scripts/seed-climate-news.mjs ./scripts/seed-climate-news.mjs
COPY scripts/seed-chokepoint-flows.mjs ./scripts/seed-chokepoint-flows.mjs

View File

@@ -0,0 +1,111 @@
// Static guard: every scripts/*.mjs COPY'd into the relay container must
// have ALL its relative-path imports ALSO COPY'd. A missing transitive
// import looks like a silent Railway cron hang — the child process dies
// on ERR_MODULE_NOT_FOUND with output only on the parent's stderr, which
// is easy to miss when the relay handles many other messages.
//
// Historical failures this test would have caught:
// - 2026-04-14 to 2026-04-16: _seed-envelope-source.mjs added to
// _seed-utils.mjs but not COPY'd, breaking chokepoint-flows for 32h
// (fixed alongside PR #3128 port-activity work).
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { dirname, resolve, basename } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
function readCopyList(dockerfilePath) {
const src = readFileSync(dockerfilePath, 'utf-8');
const copied = new Set();
// Matches: COPY scripts/foo.mjs ./scripts/foo.mjs
const re = /^COPY\s+(scripts\/[^\s]+\.(mjs|cjs))\s+/gm;
for (const m of src.matchAll(re)) copied.add(m[1]);
return copied;
}
function collectRelativeImports(filePath) {
const src = readFileSync(filePath, 'utf-8');
const imports = new Set();
// ESM: import ... from './x.mjs' | export ... from './x.mjs'
const esmRe = /(?:^|\s|;)(?:import|export)\s+(?:[\s\S]*?\s+from\s+)?['"](\.[^'"]+)['"]/g;
for (const m of src.matchAll(esmRe)) imports.add(m[1]);
// CJS direct: require('./x.cjs')
const cjsRe = /(?:^|[^a-zA-Z0-9_$])require\s*\(\s*['"](\.[^'"]+)['"]/g;
for (const m of src.matchAll(cjsRe)) imports.add(m[1]);
// CJS chained: createRequire(import.meta.url)('./x.cjs')
// — the final `('./x')` argument is applied to createRequire's return,
// not to a `require(` token, so the cjsRe above misses it.
const createRequireRe = /createRequire\s*\([^)]*\)\s*\(\s*['"](\.[^'"]+)['"]/g;
for (const m of src.matchAll(createRequireRe)) imports.add(m[1]);
return imports;
}
function resolveImport(fromFile, relImport) {
const abs = resolve(dirname(fromFile), relImport);
if (existsSync(abs)) return abs;
for (const ext of ['.mjs', '.cjs', '.js']) {
if (existsSync(abs + ext)) return abs + ext;
}
return null;
}
describe('Dockerfile.relay — transitive-import closure', () => {
const dockerfile = resolve(root, 'Dockerfile.relay');
const copied = readCopyList(dockerfile);
const entrypoints = [...copied].filter(p => p.endsWith('.mjs') || p.endsWith('.cjs'));
it('COPY list is non-empty (sanity)', () => {
assert.ok(copied.size > 0, 'Dockerfile.relay has no COPY scripts/*.mjs|cjs lines');
});
it('scanner catches both ESM imports and CJS require/createRequire', () => {
// Regression guard for the scanner itself: _seed-utils.mjs has both
// `import { ... } from './_seed-envelope-source.mjs'` (ESM) AND
// `createRequire(import.meta.url)('./_proxy-utils.cjs')` (CJS). If
// collectRelativeImports ever stops picking up either, a future
// createRequire/require pointing at a new uncopied helper would slip
// past the BFS test below without anyone noticing.
const seedUtils = resolve(root, 'scripts/_seed-utils.mjs');
const imports = collectRelativeImports(seedUtils);
assert.ok(imports.has('./_seed-envelope-source.mjs'), 'ESM import not detected');
assert.ok(imports.has('./_proxy-utils.cjs'), 'CJS createRequire not detected');
const relayCjs = resolve(root, 'scripts/ais-relay.cjs');
const relayImports = collectRelativeImports(relayCjs);
assert.ok(relayImports.has('./_proxy-utils.cjs'), 'CJS require not detected');
});
// BFS the import graph from each COPY'd entrypoint. Every .mjs/.cjs reached
// via a relative import must itself be COPY'd.
it('every transitively-imported scripts/*.mjs|cjs is also COPY\'d', () => {
const missing = [];
const visited = new Set();
const queue = entrypoints.map(p => resolve(root, p));
while (queue.length) {
const file = queue.shift();
if (visited.has(file)) continue;
visited.add(file);
if (!existsSync(file)) continue;
for (const rel of collectRelativeImports(file)) {
const resolved = resolveImport(file, rel);
if (!resolved) continue;
const relToRoot = resolved.startsWith(root + '/') ? resolved.slice(root.length + 1) : null;
if (!relToRoot || !relToRoot.startsWith('scripts/')) continue;
if (!copied.has(relToRoot)) {
missing.push(`${relToRoot} (imported by ${file.slice(root.length + 1)})`);
}
queue.push(resolved);
}
}
assert.deepEqual(
missing,
[],
`Dockerfile.relay is missing COPY lines for:\n ${missing.join('\n ')}\n` +
`Add a 'COPY <path> ./<path>' line per missing file.`,
);
});
});