feat(producer): freezePlan snapshots PRODUCER_RUNTIME_* env vars

Part of Phase 2 of the distributed rendering plan (determinism hardening).
See DISTRIBUTED-RENDERING-PLAN.md §4.3 (LockedRenderConfig.runtimeEnv) and
§5.2 (RENDER_SEEK_MODE row).

`fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*`
env vars at module-load time (RENDER_SEEK_MODE, RENDER_SEEK_STEP,
RENDER_SEEK_OFFSET_FRACTION, …) and bakes them into the served HTML's
RENDER_MODE_SCRIPT. Distributed chunk workers are separate processes that
may inherit a different environment, so the plan needs to freeze a
snapshot.

Adds `snapshotRuntimeEnv(env = process.env)` in
packages/producer/src/services/render/stages/freezePlan.ts. Captures keys
matching `PRODUCER_RUNTIME_` or `PRODUCER_RENDER_` prefixes into a fresh
plain object, ignoring everything else. Phase 3's `renderChunk` will
materialize the snapshot back into `process.env` before launching its
file server.

Also exports `RUNTIME_ENV_SNAPSHOT_PREFIXES` so the chunk-worker side can
apply the same prefix filter (asymmetric handling would leak stale
controller env into worker behavior).

The freezePlan function body remains a skeleton — Phase 3 owns the full
implementation. The snapshot helper is exported on its own so this gate's
unit test can pin the behavior without depending on the not-yet-written
freezePlan body.

In-process behavior is unchanged: no in-process caller invokes
freezePlan or snapshotRuntimeEnv yet.

9 unit tests at packages/producer/src/services/render/stages/
freezePlan.test.ts cover: prefix matches (both families), non-matching
keys ignored, undefined values skipped, fresh-object contract, and
default-to-process.env behavior.

This is part of a stack of 10 PRs; this is PR 5 of 10.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-13 04:36:11 +00:00
co-authored by Claude Opus 4.7
parent 8bac7ba0d1
commit 1d189aa26b
3 changed files with 166 additions and 2 deletions
@@ -0,0 +1,101 @@
/**
* Tests for the `snapshotRuntimeEnv` helper that backs the
* `LockedRenderConfig.runtimeEnv` field.
*/
import { describe, expect, it } from "bun:test";
import { RUNTIME_ENV_PREFIXES, snapshotRuntimeEnv } from "./runtimeEnvSnapshot.js";
describe("snapshotRuntimeEnv", () => {
it("captures PRODUCER_RUNTIME_* keys", () => {
const env = {
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION: "0.5",
};
expect(snapshotRuntimeEnv(env)).toEqual({
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RUNTIME_RENDER_SEEK_OFFSET_FRACTION: "0.5",
});
});
it("captures PRODUCER_RENDER_* keys", () => {
const env = {
PRODUCER_RENDER_SEEK_STEP: "0.008",
};
expect(snapshotRuntimeEnv(env)).toEqual({
PRODUCER_RENDER_SEEK_STEP: "0.008",
});
});
it("captures both prefix families in a single snapshot", () => {
const env = {
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RENDER_SEEK_STEP: "0.008",
};
expect(snapshotRuntimeEnv(env)).toEqual({
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RENDER_SEEK_STEP: "0.008",
});
});
it("ignores keys that don't match either prefix", () => {
const env = {
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
HOME: "/home/ci",
PATH: "/usr/bin:/bin",
NODE_ENV: "production",
// Off-by-one prefix variants — must NOT be captured.
PRODUCER_RUNTIM_FOO: "x",
PRODUCER_RENDR_BAR: "y",
PRODUCER_DEBUG_SEEK_DIAGNOSTICS: "true",
};
const snapshot = snapshotRuntimeEnv(env);
expect(snapshot).toEqual({
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
});
expect(Object.keys(snapshot)).toHaveLength(1);
});
it("skips keys whose value is undefined", () => {
const env = {
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
PRODUCER_RUNTIME_OTHER: undefined,
};
expect(snapshotRuntimeEnv(env)).toEqual({
PRODUCER_RUNTIME_RENDER_SEEK_MODE: "strict-boundary",
});
});
it("returns an empty object when no keys match", () => {
expect(snapshotRuntimeEnv({ HOME: "/home/ci", PATH: "/usr/bin" })).toEqual({});
});
it("returns a NEW object each call (no live reference to process.env)", () => {
const env = { PRODUCER_RUNTIME_X: "v1" };
const first = snapshotRuntimeEnv(env);
env.PRODUCER_RUNTIME_X = "v2";
const second = snapshotRuntimeEnv(env);
expect(first.PRODUCER_RUNTIME_X).toBe("v1");
expect(second.PRODUCER_RUNTIME_X).toBe("v2");
expect(first).not.toBe(second);
});
it("defaults to process.env when no argument is passed", () => {
const key = `PRODUCER_RUNTIME_FREEZEPLAN_TEST_${Date.now()}`;
const sentinel = "freezeplan-test-value";
process.env[key] = sentinel;
try {
const snapshot = snapshotRuntimeEnv();
expect(snapshot[key]).toBe(sentinel);
} finally {
delete process.env[key];
}
});
it("exports the prefix list for chunk-worker materialization", () => {
// Chunk workers must apply the SAME prefix filter when reading the
// snapshot back; asymmetric handling would let stale controller env
// leak into chunk-worker behavior.
expect(RUNTIME_ENV_PREFIXES).toEqual(["PRODUCER_RUNTIME_", "PRODUCER_RENDER_"]);
});
});
@@ -0,0 +1,53 @@
/**
* runtimeEnvSnapshot — capture / re-apply the env vars that drive in-page
* render behavior.
*
* `fileServer.ts` reads several `PRODUCER_RUNTIME_*` and `PRODUCER_RENDER_*`
* variables at module-load time and bakes them into the served HTML's
* `RENDER_MODE_SCRIPT`. Distributed chunk workers are separate processes
* that may inherit a different environment, so the plan freezes a snapshot
* of the controller's env. The chunk worker then materializes the snapshot
* back into `process.env` before launching its file server, which keeps the
* served HTML byte-identical to what the controller would have served.
*
* Used by `freezePlan` (capture side) and the chunked render worker
* (re-apply side). Kept here as a standalone utility because it has no
* dependency on the plan-freeze pipeline.
*/
/**
* Env-var name prefixes captured by {@link snapshotRuntimeEnv}. Exported so
* the chunk-worker side can apply the same filter when materializing a
* snapshot — asymmetric handling would leak stale controller env into
* worker behavior.
*/
export const RUNTIME_ENV_PREFIXES: readonly string[] = [
"PRODUCER_RUNTIME_",
"PRODUCER_RENDER_",
] as const;
/**
* Snapshot `process.env` keys that match any of {@link RUNTIME_ENV_PREFIXES}
* into a plain string→string record. Returns a NEW object each call (never a
* live reference to `process.env`) so subsequent mutations of the process
* env do not retroactively change a frozen plan.
*
* Pass an optional `env` for tests that don't want to mutate the real
* process env. The default reads `process.env`.
*/
export function snapshotRuntimeEnv(
env: Record<string, string | undefined> = process.env,
): Record<string, string> {
const snapshot: Record<string, string> = {};
for (const key of Object.keys(env)) {
const matches = RUNTIME_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
if (!matches) continue;
const value = env[key];
// Skip undefined / non-string values. `process.env` only ever returns
// strings, but `Record<string, string | undefined>` lets tests pass an
// env object with explicit `undefined` slots (e.g. after `delete`).
if (typeof value !== "string") continue;
snapshot[key] = value;
}
return snapshot;
}
@@ -89,13 +89,23 @@ export interface FreezePlanResult {
planHash: string;
}
/**
* Re-export the runtime-env snapshot helper for backward compatibility with
* earlier imports from `./freezePlan`. The implementation lives in
* `../runtimeEnvSnapshot.ts` — chunk workers re-apply the snapshot during
* boot, so it needs to be importable without dragging in the freeze pipeline.
*/
export { snapshotRuntimeEnv, RUNTIME_ENV_PREFIXES } from "../runtimeEnvSnapshot.js";
/**
* Freeze a plan directory: write `meta/*.json` + top-level `plan.json`, then
* compute `planHash` over the canonicalized contents.
*
* Skeleton — body lands when the distributed-render primitives compose the
* stage functions.
* stage functions. The body will resolve `input.encoder.runtimeEnv ||=
* snapshotRuntimeEnv()` so callers can optionally pre-populate the field,
* with the live env as the default.
*/
export async function freezePlan(_input: FreezePlanInput): Promise<FreezePlanResult> {
throw new Error("freezePlan is not implemented yet — see DISTRIBUTED-RENDERING-PLAN.md §11.");
throw new Error("freezePlan is not implemented yet.");
}