@ggui-ai/ui-gen 0.23.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/adapter-Du7P_5Fx.d.ts +201 -0
  2. package/dist/adapters/generation-dispatch.d.ts +33 -12
  3. package/dist/adapters/generation-dispatch.js +510 -128
  4. package/dist/adapters/generation-dispatch.js.map +1 -1
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +5 -3
  7. package/dist/advanced/index.js +551 -142
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/blueprint-validator.js +153 -59
  10. package/dist/blueprint-validator.js.map +1 -1
  11. package/dist/boilerplate.js.map +1 -1
  12. package/dist/check/index.d.ts +2 -1
  13. package/dist/check/index.js +47 -1
  14. package/dist/check/index.js.map +1 -1
  15. package/dist/{create-ui-generator-D0kbWwln.d.ts → create-ui-generator-GIhzx08x.d.ts} +34 -10
  16. package/dist/{dispatch-wxaIMwZt.d.ts → dispatch-BnAH56p4.d.ts} +1 -1
  17. package/dist/evaluation/axis-checks/index.d.ts +4 -3
  18. package/dist/evaluation/axis-checks/index.js.map +1 -1
  19. package/dist/evaluation/axis-checks/registry.d.ts +2 -1
  20. package/dist/evaluation/index.d.ts +4 -3
  21. package/dist/evaluation/index.js.map +1 -1
  22. package/dist/harness/check/runtime-render/index.d.ts +29 -10
  23. package/dist/harness/check/runtime-render/index.js +203 -60
  24. package/dist/harness/check/runtime-render/index.js.map +1 -1
  25. package/dist/harness/check/runtime-render/render-check-worker.d.ts +8 -1
  26. package/dist/harness/check/runtime-render/render-check-worker.js +72 -32
  27. package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
  28. package/dist/harness/index.d.ts +2 -1
  29. package/dist/harness/index.js.map +1 -1
  30. package/dist/harness/prompts.js.map +1 -1
  31. package/dist/harness/result-types.d.ts +2 -1
  32. package/dist/harness/runtime.d.ts +2 -1
  33. package/dist/harness/runtime.js.map +1 -1
  34. package/dist/harness/types-public.d.ts +11 -2
  35. package/dist/harness/types-public.js.map +1 -1
  36. package/dist/harness/validator-trace-sink.d.ts +2 -1
  37. package/dist/index.d.ts +38 -3
  38. package/dist/index.js +553 -143
  39. package/dist/index.js.map +1 -1
  40. package/dist/patch.d.ts +2 -1
  41. package/dist/policy.d.ts +7 -4
  42. package/dist/policy.js.map +1 -1
  43. package/dist/tools/render-check-worker.d.ts +7 -1
  44. package/dist/tools/render-check-worker.js +55 -23
  45. package/dist/tools/render-check-worker.js.map +1 -1
  46. package/dist/{types-public-Ow-tNtFE.d.ts → types-public-Bk6d1WLN.d.ts} +77 -11
  47. package/dist/workflows.d.ts +2 -1
  48. package/package.json +12 -12
  49. package/src/adapters/generation-dispatch.ts +137 -96
  50. package/src/check/run-tier0.ts +80 -1
  51. package/src/create-ui-generator.ts +185 -95
  52. package/src/evaluation/types-public.ts +73 -11
  53. package/src/harness/check/runtime-render/adapter.ts +221 -75
  54. package/src/harness/check/runtime-render/index.ts +75 -2
  55. package/src/harness/check/runtime-render/render-check-host.ts +82 -19
  56. package/src/harness/check/runtime-render/render-check-worker.ts +83 -29
  57. package/src/harness/check/runtime-render/render-check.ts +154 -117
  58. package/src/harness/coding/generate-task-runner.ts +57 -4
  59. package/src/harness/coding/init-session.ts +13 -0
  60. package/src/harness/coding/run-eval-round.ts +238 -15
  61. package/src/harness/policy.ts +11 -0
  62. package/src/harness/primitive-slice.ts +104 -0
  63. package/src/harness/types-public.ts +9 -1
  64. package/src/index.ts +4 -0
  65. package/src/policy.ts +7 -4
  66. package/src/tools/render-check-worker.ts +74 -23
  67. package/dist/render-check-Blwj8gB6.d.ts +0 -105
@@ -6,81 +6,205 @@
6
6
 
7
7
  import type { EvalIssue } from "../../../evaluation/types-public.js";
8
8
  import type { RuntimeRenderCheck } from "../../types-public.js";
9
- import { runRenderCheck, type RenderCheckIssue } from "./render-check.js";
9
+ import {
10
+ RENDER_CHECK_KINDS,
11
+ runRenderCheck,
12
+ type RenderCheckIssue,
13
+ type RenderCheckKind,
14
+ type RunRenderCheckOptions,
15
+ } from "./render-check.js";
16
+ import { resolveRenderCheckHostBounds } from "./render-check-host.js";
10
17
  import { prepareMockupProps } from "./prepare-mockup.js";
11
18
 
12
- export const DEFAULT_RUNTIME_RENDER_CHECK: RuntimeRenderCheck = {
13
- id: "runtime-render",
14
- run: async input => {
15
- const { sourceCode, compiledCode, contract, fixtureProps } = input;
19
+ /**
20
+ * How one runtime-render check instance runs its isolated worker
21
+ * (ggui#1380). A serving deployment sets the probe's wall-clock bound,
22
+ * worker heap and concurrency here; every field omitted is the evaluation
23
+ * lane's behaviour — the host's default bounds and no concurrency cap.
24
+ */
25
+ export interface RuntimeRenderProbeConfig {
26
+ /** Wall-clock bound of one isolated check, ms. Default: the host's (30 000). */
27
+ readonly timeoutMs?: number;
28
+ /** V8 heap cap of the worker, MB. Default: the host's (512). */
29
+ readonly heapMb?: number;
30
+ /**
31
+ * Live workers this instance may have at once. The (K+1)th check waits
32
+ * in FIFO order and spawns only when a slot frees; a check that throws
33
+ * releases its slot. Default: unbounded. Must be a positive integer.
34
+ */
35
+ readonly maxConcurrent?: number;
36
+ }
16
37
 
17
- // Nothing to render / no contract surface to verify — the probe has
18
- // no subject, which is different from the probe failing to run.
19
- if (compiledCode === null) {
20
- return { status: "not-applicable", issues: [], reason: "no compiled code" };
38
+ /**
39
+ * K-slot FIFO limiter. `undefined` slots = no limiter (the task runs at
40
+ * once). A slot is released in `finally`, so a rejected task never holds
41
+ * one.
42
+ */
43
+ function createSlotLimiter(
44
+ maxConcurrent: number | undefined
45
+ ): <T>(task: (waitedMs: number) => Promise<T>) => Promise<T> {
46
+ if (maxConcurrent === undefined) return (task) => task(0);
47
+ if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) {
48
+ throw new RangeError(
49
+ `createRuntimeRenderCheck: maxConcurrent must be a positive integer, got ${maxConcurrent}`
50
+ );
51
+ }
52
+ let active = 0;
53
+ const waiting: Array<() => void> = [];
54
+ /** Resolves with the time the caller actually WAITED for a slot: 0 when one was free. */
55
+ const acquire = (): Promise<number> => {
56
+ if (active < maxConcurrent) {
57
+ active += 1;
58
+ return Promise.resolve(0);
21
59
  }
22
- if (!contract) {
23
- return { status: "not-applicable", issues: [], reason: "no contract surface" };
60
+ const enqueuedAt = Date.now();
61
+ return new Promise<number>((resolve) => {
62
+ waiting.push(() => {
63
+ active += 1;
64
+ resolve(Date.now() - enqueuedAt);
65
+ });
66
+ });
67
+ };
68
+ const release = (): void => {
69
+ active -= 1;
70
+ const next = waiting.shift();
71
+ if (next !== undefined) next();
72
+ };
73
+ return async (task) => {
74
+ const waitedMs = await acquire();
75
+ try {
76
+ return await task(waitedMs);
77
+ } finally {
78
+ release();
24
79
  }
80
+ };
81
+ }
25
82
 
26
- const mockup = prepareMockupProps({ contract, fixtureProps });
83
+ /** `{ queuedMs }` when the check actually waited for a slot, `{}` when one was free. */
84
+ function queuedField(queuedMs: number): { readonly queuedMs?: number } {
85
+ return queuedMs > 0 ? { queuedMs } : {};
86
+ }
27
87
 
28
- let result;
29
- try {
30
- result = await runRenderCheck({
31
- sourceCode,
32
- mockupProps: mockup.props,
33
- contract,
34
- });
35
- } catch (e) {
36
- // Triad audit (2026-04-27): every error that escapes `runRenderCheck`
37
- // is an INFRA problem — happy-dom import failure, ESM/CJS interop
38
- // (`Dynamic require of "events"`), bundler name collision (`Window2
39
- // is not a constructor`), missing `@testing-library/react`, etc.
40
- // Component-level failures are caught and emitted as `RenderCheckIssue`
41
- // entries from inside `runRenderCheck`; they don't propagate out.
42
- //
43
- // An infra failure must never become an eval issue (the coding
44
- // agent can't fix the environment — pre-2026-04-27 that phantom
45
- // issue dragged every score-below-80 cell's eval-fix loop), but it
46
- // must also never be silent to scoring: ggui#403 found the bench
47
- // reporting `probe_pass 3/3` on cells where this branch fired on
48
- // every invocation. The `infra-skipped` status is the No-Silent-
49
- // Block channel — consumers surface it as did-not-run, never pass.
50
- const message = e instanceof Error ? e.message : String(e);
51
- console.warn(
52
- `[runtime-render] probe skipped — infra failure: ${message}`,
53
- );
54
- return { status: "infra-skipped", issues: [], reason: message };
55
- }
88
+ /** The `runRenderCheck` options a config maps to — `undefined` when no bound is set. */
89
+ function toRenderCheckOptions(config: RuntimeRenderProbeConfig): RunRenderCheckOptions | undefined {
90
+ if (config.timeoutMs === undefined && config.heapMb === undefined) return undefined;
91
+ return {
92
+ bounds: {
93
+ ...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
94
+ ...(config.heapMb !== undefined ? { heapMb: config.heapMb } : {}),
95
+ },
96
+ };
97
+ }
56
98
 
57
- const hostLoad = result.stats.hostLoad;
58
- const load = hostLoad !== undefined ? { hostLoad } : {};
99
+ /**
100
+ * Build a runtime-render check instance. One FIFO limiter per instance;
101
+ * the bounds reach `runRenderCheck` on every run. `createRuntimeRenderCheck()`
102
+ * with no config is {@link DEFAULT_RUNTIME_RENDER_CHECK}'s behaviour.
103
+ */
104
+ export function createRuntimeRenderCheck(
105
+ config: RuntimeRenderProbeConfig = {}
106
+ ): RuntimeRenderCheck {
107
+ const limit = createSlotLimiter(config.maxConcurrent);
108
+ const options = toRenderCheckOptions(config);
109
+ // A bad bound is refused here, at construction, never at the first probe.
110
+ if (options !== undefined) resolveRenderCheckHostBounds(options.bounds);
111
+ return {
112
+ id: "runtime-render",
113
+ run: async (input) => {
114
+ const { sourceCode, compiledCode, contract, fixtureProps } = input;
59
115
 
60
- // ggui#1299: a check that ran out of wall-clock time is not a verdict on
61
- // the component. It becomes the `timed-out` status (did-not-run, never a
62
- // pass, never a crash), never an eval issue — so the coding agent is never
63
- // told to fix a crash that did not happen.
64
- if (result.incomplete !== undefined) {
65
- const { elapsedMs, boundMs } = result.incomplete;
66
- const loadNote =
67
- hostLoad !== undefined
68
- ? `; host load ${hostLoad.start.toFixed(1)} → ${hostLoad.end.toFixed(1)} on ${hostLoad.cores} CPUs`
69
- : "";
70
- const reason = `render check did not finish within ${boundMs} ms (stopped at ${elapsedMs} ms)${loadNote}`;
71
- console.warn(`[runtime-render] probe timed out — ${reason}`);
72
- return { status: "timed-out", issues: [], reason, elapsedMs, ...load };
73
- }
116
+ // Nothing to render / no contract surface to verify — the probe has
117
+ // no subject, which is different from the probe failing to run.
118
+ if (compiledCode === null) {
119
+ return { status: "not-applicable", issues: [], reason: "no compiled code" };
120
+ }
121
+ if (!contract) {
122
+ return { status: "not-applicable", issues: [], reason: "no contract surface" };
123
+ }
74
124
 
75
- return {
76
- status: "ran",
77
- issues: result.issues
78
- .map(toEvalIssue)
79
- .filter((x): x is EvalIssue => x !== null),
80
- ...load,
81
- };
82
- },
83
- };
125
+ const mockup = prepareMockupProps({ contract, fixtureProps });
126
+
127
+ // ggui#1380 — the adapter's own clock around the check: every status
128
+ // that reached the check (`ran`, `timed-out`, `infra-skipped`) reports
129
+ // how long it took, so a reader of the probe meta can tell a 2 s probe
130
+ // from a 30 s one on the same status. The two not-applicable returns
131
+ // above carry nothing: nothing ran.
132
+ // The clock starts when the slot is acquired, so `elapsedMs` is the
133
+ // check's own wall-clock; the time the check actually WAITED for a slot
134
+ // is `queuedMs` (present only when it queued) — a latency reader needs
135
+ // the two apart exactly when a serving deployment queues more probes
136
+ // than it has slots. A check that found a slot free carries no key.
137
+ let queuedMs = 0;
138
+ let t0 = Date.now();
139
+ let result;
140
+ try {
141
+ result = await limit((waitedMs) => {
142
+ queuedMs = waitedMs;
143
+ t0 = Date.now();
144
+ return runRenderCheck(
145
+ {
146
+ sourceCode,
147
+ mockupProps: mockup.props,
148
+ contract,
149
+ },
150
+ options
151
+ );
152
+ });
153
+ } catch (e) {
154
+ // Triad audit (2026-04-27): every error that escapes `runRenderCheck`
155
+ // is an INFRA problem — happy-dom import failure, ESM/CJS interop
156
+ // (`Dynamic require of "events"`), bundler name collision (`Window2
157
+ // is not a constructor`), missing `@testing-library/react`, etc.
158
+ // Component-level failures are caught and emitted as `RenderCheckIssue`
159
+ // entries from inside `runRenderCheck`; they don't propagate out.
160
+ //
161
+ // An infra failure must never become an eval issue (the coding
162
+ // agent can't fix the environment — pre-2026-04-27 that phantom
163
+ // issue dragged every score-below-80 cell's eval-fix loop), but it
164
+ // must also never be silent to scoring: ggui#403 found the bench
165
+ // reporting `probe_pass 3/3` on cells where this branch fired on
166
+ // every invocation. The `infra-skipped` status is the No-Silent-
167
+ // Block channel — consumers surface it as did-not-run, never pass.
168
+ const message = e instanceof Error ? e.message : String(e);
169
+ console.warn(`[runtime-render] probe skipped — infra failure: ${message}`);
170
+ return { status: "infra-skipped", issues: [], reason: message, elapsedMs: Date.now() - t0, ...queuedField(queuedMs) };
171
+ }
172
+
173
+ const hostLoad = result.stats.hostLoad;
174
+ const load = hostLoad !== undefined ? { hostLoad } : {};
175
+
176
+ // ggui#1299: a check that ran out of wall-clock time is not a verdict on
177
+ // the component. It becomes the `timed-out` status (did-not-run, never a
178
+ // pass, never a crash), never an eval issue — so the coding agent is never
179
+ // told to fix a crash that did not happen.
180
+ if (result.incomplete !== undefined) {
181
+ const { elapsedMs, boundMs } = result.incomplete;
182
+ const loadNote =
183
+ hostLoad !== undefined
184
+ ? `; host load ${hostLoad.start.toFixed(1)} → ${hostLoad.end.toFixed(1)} on ${hostLoad.cores} CPUs`
185
+ : "";
186
+ const reason = `render check did not finish within ${boundMs} ms (stopped at ${elapsedMs} ms)${loadNote}`;
187
+ console.warn(`[runtime-render] probe timed out — ${reason}`);
188
+ // One clock for every status that reached the worker: the adapter's
189
+ // wall-clock around the check. The worker's own reading survives in
190
+ // `reason` ("stopped at N ms").
191
+ return { status: "timed-out", issues: [], reason, elapsedMs: Date.now() - t0, ...queuedField(queuedMs), ...load };
192
+ }
193
+
194
+ return {
195
+ status: "ran",
196
+ issues: result.issues.map(toEvalIssue).filter((x): x is EvalIssue => x !== null),
197
+ elapsedMs: Date.now() - t0,
198
+ ...queuedField(queuedMs),
199
+ renderMs: result.stats.renderMs,
200
+ ...load,
201
+ };
202
+ },
203
+ };
204
+ }
205
+
206
+ /** The evaluation lane's instance: the host's default bounds, no concurrency cap. */
207
+ export const DEFAULT_RUNTIME_RENDER_CHECK: RuntimeRenderCheck = createRuntimeRenderCheck();
84
208
 
85
209
  /**
86
210
  * Map a runtime-render crash reason to a class-specific fix string.
@@ -177,11 +301,7 @@ export function classifyRenderCrashFix(reason: string): string {
177
301
  "(b) check it's an array before iterating (`Array.isArray(x) && ...`)."
178
302
  );
179
303
  }
180
- if (
181
- r.includes("cannot read") ||
182
- r.includes("undefined is not") ||
183
- r.includes("null is not")
184
- ) {
304
+ if (r.includes("cannot read") || r.includes("undefined is not") || r.includes("null is not")) {
185
305
  return (
186
306
  "Null/undefined access. Add optional chaining (`obj?.field`) and " +
187
307
  "default values for optional props/state before reading nested fields. " +
@@ -195,7 +315,32 @@ export function classifyRenderCrashFix(reason: string): string {
195
315
  );
196
316
  }
197
317
 
198
- function toEvalIssue(issue: RenderCheckIssue): EvalIssue | null {
318
+ /** The prefix every probe issue's subcategory carries: `runtime:<check>[:<subject>]`. */
319
+ const RUNTIME_SUBCATEGORY_PREFIX = "runtime:";
320
+
321
+ /**
322
+ * The check a probe issue's subcategory names (ggui#1380) — the inverse of
323
+ * the `runtime:<check>[:<subject>]` form {@link toEvalIssue} writes, so a
324
+ * round can list WHICH checks failed from the issues alone. `undefined` for
325
+ * anything that is not a probe subcategory naming a member of
326
+ * {@link RENDER_CHECK_KINDS} (an axis check's subcategory, a did-not-run
327
+ * code such as `runtime:probe-timeout`, a bare check name).
328
+ */
329
+ export function parseRuntimeSubcategory(subcategory: string): RenderCheckKind | undefined {
330
+ if (!subcategory.startsWith(RUNTIME_SUBCATEGORY_PREFIX)) return undefined;
331
+ const rest = subcategory.slice(RUNTIME_SUBCATEGORY_PREFIX.length);
332
+ const separator = rest.indexOf(":");
333
+ const check = separator === -1 ? rest : rest.slice(0, separator);
334
+ return RENDER_CHECK_KINDS.find((kind) => kind === check);
335
+ }
336
+
337
+ /**
338
+ * One probe finding → the eval issue the loop feeds back and the metadata
339
+ * counts. Exported for the pin that every switch case below is a member of
340
+ * {@link RENDER_CHECK_KINDS} and round-trips through
341
+ * {@link parseRuntimeSubcategory}.
342
+ */
343
+ export function toEvalIssue(issue: RenderCheckIssue): EvalIssue | null {
199
344
  // verified → no issue (silent pass)
200
345
  if (issue.outcome === "verified" || issue.outcome === "skipped") return null;
201
346
 
@@ -228,7 +373,7 @@ function toEvalIssue(issue: RenderCheckIssue): EvalIssue | null {
228
373
  diagParts.push(
229
374
  "primed" in diag.inputPriming
230
375
  ? `input priming: ${diag.inputPriming.primed} control(s) filled before the click`
231
- : `input priming failed: ${diag.inputPriming.error}`,
376
+ : `input priming failed: ${diag.inputPriming.error}`
232
377
  );
233
378
  }
234
379
  const diagSuffix = diagParts.length ? ` [observed: ${diagParts.join("; ")}]` : "";
@@ -264,9 +409,10 @@ function toEvalIssue(issue: RenderCheckIssue): EvalIssue | null {
264
409
  };
265
410
 
266
411
  case "action-wiring": {
267
- const fix = issue.outcome === "unverified"
268
- ? `Source shows the action callback flowing into a non-native or custom-component prop. If wiring is intentional (e.g., Dropdown.onChange, drag-drop), this warn is informational — manual/browser verification is required to confirm. Otherwise wire to a native onClick={() => ${subject}(payload)} on <button> or design-system <Button>.`
269
- : `Wire ${subject}() to a native event prop. Common fix: <Button onClick={() => ${subject}(payload)}>Label</Button>. Source-AST analysis didn't find this wiring in your JSX.`;
412
+ const fix =
413
+ issue.outcome === "unverified"
414
+ ? `Source shows the action callback flowing into a non-native or custom-component prop. If wiring is intentional (e.g., Dropdown.onChange, drag-drop), this warn is informational — manual/browser verification is required to confirm. Otherwise wire to a native onClick={() => ${subject}(payload)} on <button> or design-system <Button>.`
415
+ : `Wire ${subject}() to a native event prop. Common fix: <Button onClick={() => ${subject}(payload)}>Label</Button>. Source-AST analysis didn't find this wiring in your JSX.`;
270
416
  return {
271
417
  tier: 0,
272
418
  result,
@@ -10,15 +10,26 @@
10
10
  // - declared stream events update the DOM when emitted (warn)
11
11
 
12
12
  import { installProductionActShim } from "./production-act-shim.js";
13
+ import type { RenderCheckHostBounds } from "./render-check-host.js";
13
14
 
14
- export { runRenderCheck, type RenderCheckResult, type RenderCheckIssue } from "./render-check.js";
15
+ export {
16
+ RENDER_CHECK_KINDS,
17
+ runRenderCheck,
18
+ type RenderCheckKind,
19
+ type RenderCheckResult,
20
+ type RenderCheckIssue,
21
+ } from "./render-check.js";
15
22
  export { createProbe, createProbeWireConfig, type Probe } from "./probe.js";
16
23
  export { prepareMockupProps, type MockupPropsResult } from "./prepare-mockup.js";
17
24
  export {
18
25
  DEFAULT_RUNTIME_RENDER_CHECK,
19
26
  classifyRenderCrashFix,
27
+ createRuntimeRenderCheck,
20
28
  isRecoverableRenderCrash,
29
+ parseRuntimeSubcategory,
30
+ type RuntimeRenderProbeConfig,
21
31
  } from "./adapter.js";
32
+ export type { RenderCheckHostBounds, RenderCheckHostOptions } from "./render-check-host.js";
22
33
 
23
34
  /**
24
35
  * Pre-warm the runtime-render probe's runtime dependencies so the first
@@ -41,7 +52,11 @@ export {
41
52
  *
42
53
  * Returns the wall-clock spent loading.
43
54
  */
44
- export async function warmupRuntimeRenderProbe(): Promise<{ ms: number; loaded: number; missing: number }> {
55
+ export async function warmupRuntimeRenderProbe(): Promise<{
56
+ ms: number;
57
+ loaded: number;
58
+ missing: number;
59
+ }> {
45
60
  const start = Date.now();
46
61
  let loaded = 0;
47
62
  let missing = 0;
@@ -67,3 +82,61 @@ export async function warmupRuntimeRenderProbe(): Promise<{ ms: number; loaded:
67
82
  ]);
68
83
  return { ms: Date.now() - start, loaded, missing };
69
84
  }
85
+
86
+ /**
87
+ * The component the boot warm renders: a constant element, an empty
88
+ * contract — nothing to click, nothing to cover, one compile and one
89
+ * render in the worker.
90
+ */
91
+ const WARMUP_COMPONENT = "export default function Warmup() { return <div>warm</div>; }";
92
+
93
+ /**
94
+ * Warm the PROCESS that runs every probe (ggui#1380): spawn one real check
95
+ * worker on a trivial component through the same host every probe uses.
96
+ * Afterwards the node binary, the worker's module tree and esbuild's
97
+ * service have all run once on this machine (OS page cache warm), and the
98
+ * result says whether that process can run a check at all — which
99
+ * `warmupRuntimeRenderProbe` (an import in THIS process) cannot tell.
100
+ *
101
+ * `bounds` are the subprocess bounds for the one run; a serving deployment
102
+ * passes the same bounds it will probe with. Never throws: a worker that
103
+ * cannot run is `{ ok: false, reason }`, so a boot sequence can log it and
104
+ * decide, instead of dying in a fire-and-forget.
105
+ */
106
+ export async function warmupRuntimeRenderWorker(
107
+ bounds?: Partial<RenderCheckHostBounds>
108
+ ): Promise<{ ok: true; ms: number } | { ok: false; ms: number; reason: string }> {
109
+ const start = Date.now();
110
+ try {
111
+ // Lazy: the host pulls `@ggui-ai/sandbox` (spawn machinery); keep it off
112
+ // the import graph of everything that only needs the in-process check.
113
+ const { runRenderCheckViaWorker } = await import("./render-check-host.js");
114
+ const result = await runRenderCheckViaWorker(
115
+ { sourceCode: WARMUP_COMPONENT, mockupProps: {}, contract: {} },
116
+ bounds !== undefined ? { bounds } : {}
117
+ );
118
+ const ms = Date.now() - start;
119
+ if (result.incomplete !== undefined) {
120
+ return {
121
+ ok: false,
122
+ ms,
123
+ reason: `the warm-up check did not finish within ${result.incomplete.boundMs} ms (stopped at ${result.incomplete.elapsedMs} ms)`,
124
+ };
125
+ }
126
+ const blocking = result.issues.find(
127
+ (i) => i.outcome === "failed" || i.outcome === "unverified"
128
+ );
129
+ if (blocking !== undefined) {
130
+ return { ok: false, ms, reason: `${blocking.check} ${blocking.outcome}: ${blocking.reason}` };
131
+ }
132
+ return { ok: true, ms };
133
+ } catch (err) {
134
+ // The host itself never throws for a worker fault; what reaches here is
135
+ // the spawn layer refusing to run at all (an invalid bound, no binary).
136
+ return {
137
+ ok: false,
138
+ ms: Date.now() - start,
139
+ reason: err instanceof Error ? err.message : String(err),
140
+ };
141
+ }
142
+ }
@@ -48,12 +48,70 @@ import type { ProbeHostLoad } from '../../../evaluation/types-public.js';
48
48
  import type { RenderCheckResult, RunRenderCheckInput } from './render-check.js';
49
49
 
50
50
  /**
51
- * Wall-clock bound for one isolated check. The rich probe renders,
52
- * clicks every declared action, and re-renders streams — slower than
53
- * the smoke worker's 12s budget; 30s bounds runaway components while
54
- * staying far under any generation-turn budget.
51
+ * The subprocess bounds of one isolated check (ggui#1380). They are
52
+ * INPUTS of the host, not constants: a serving deployment runs the probe
53
+ * under its own memory and latency budget and sets them per check
54
+ * instance (`createRuntimeRenderCheck`); the evaluation lane runs on the
55
+ * defaults below, which are the values the host always used.
55
56
  */
56
- const CHECK_TIMEOUT_MS = 30_000;
57
+ export interface RenderCheckHostBounds {
58
+ /**
59
+ * Wall-clock bound for one isolated check. The rich probe renders,
60
+ * clicks every declared action, and re-renders streams — slower than
61
+ * the smoke worker's 12s budget; 30s bounds runaway components while
62
+ * staying far under any generation-turn budget.
63
+ */
64
+ readonly timeoutMs: number;
65
+ /**
66
+ * V8 heap cap. The worker realm loads react-dom, happy-dom and
67
+ * testing-library on top of the component itself; 512 MB leaves
68
+ * headroom while still bounding pathological allocation.
69
+ */
70
+ readonly heapMb: number;
71
+ /**
72
+ * Grace between SIGTERM and SIGKILL once the bound is crossed. The
73
+ * effective value is `min(gracePeriodMs, floor(timeoutMs / 2))` —
74
+ * `runSandboxed` requires the grace to be strictly below the bound, so
75
+ * a short bound never trips its RangeError.
76
+ */
77
+ readonly gracePeriodMs: number;
78
+ }
79
+
80
+ export const DEFAULT_RENDER_CHECK_HOST_BOUNDS: RenderCheckHostBounds = {
81
+ timeoutMs: 30_000,
82
+ heapMb: 512,
83
+ gracePeriodMs: 2_000,
84
+ };
85
+
86
+ /**
87
+ * Fill the caller's partial bounds from the defaults and clamp the grace
88
+ * below the bound. Exported so the kill-path pin can run a fixture under
89
+ * exactly the numbers the host would pass for the same input.
90
+ */
91
+ export function resolveRenderCheckHostBounds(
92
+ bounds: Partial<RenderCheckHostBounds> = {},
93
+ ): RenderCheckHostBounds {
94
+ const timeoutMs = bounds.timeoutMs ?? DEFAULT_RENDER_CHECK_HOST_BOUNDS.timeoutMs;
95
+ const heapMb = bounds.heapMb ?? DEFAULT_RENDER_CHECK_HOST_BOUNDS.heapMb;
96
+ // Refused at construction, like `maxConcurrent`: a 0 / NaN / fractional
97
+ // bound would otherwise flow into the sandbox's flags as a nonsense number.
98
+ // `timeoutMs` needs room for a grace of at least 1 ms below it.
99
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 2) {
100
+ throw new RangeError(`render-check: timeoutMs must be an integer ≥ 2, got ${timeoutMs}`);
101
+ }
102
+ if (!Number.isInteger(heapMb) || heapMb < 1) {
103
+ throw new RangeError(`render-check: heapMb must be a positive integer, got ${heapMb}`);
104
+ }
105
+ const requestedGrace = bounds.gracePeriodMs ?? DEFAULT_RENDER_CHECK_HOST_BOUNDS.gracePeriodMs;
106
+ const gracePeriodMs = Math.max(1, Math.min(requestedGrace, Math.floor(timeoutMs / 2)));
107
+ return { timeoutMs, heapMb, gracePeriodMs };
108
+ }
109
+
110
+ /** Options of {@link runRenderCheckViaWorker}. */
111
+ export interface RenderCheckHostOptions {
112
+ /** Subprocess bounds; every field omitted falls to {@link DEFAULT_RENDER_CHECK_HOST_BOUNDS}. */
113
+ readonly bounds?: Partial<RenderCheckHostBounds>;
114
+ }
57
115
 
58
116
  /**
59
117
  * stdout cap. The verdict JSON carries issue lists with diagnostics
@@ -62,16 +120,14 @@ const CHECK_TIMEOUT_MS = 30_000;
62
120
  */
63
121
  const CHECK_STDOUT_CAP = 2 * 1024 * 1024;
64
122
 
65
- /**
66
- * V8 heap cap. The worker realm loads react-dom, happy-dom and
67
- * testing-library on top of the component itself; 512 MB leaves
68
- * headroom while still bounding pathological allocation.
69
- */
70
- const CHECK_NODE_HEAP_MB = 512;
71
-
72
123
  const WORKER_BASENAME = 'render-check-worker';
73
124
 
74
- function resolveWorkerSpawn(): { command: string; args: string[] } {
125
+ /**
126
+ * How the host spawns the worker from THIS location: the built dist sibling
127
+ * when one exists, else the source `.ts` through tsx. Exported so the entry
128
+ * guard can be pinned with the same spawn the host makes (no build needed).
129
+ */
130
+ export function resolveWorkerSpawn(): { command: string; args: string[] } {
75
131
  const jsCandidates = [
76
132
  // Sibling — when import.meta.url is already .../harness/check/runtime-render/.
77
133
  new URL(`./${WORKER_BASENAME}.js`, import.meta.url),
@@ -126,18 +182,20 @@ function unverified(reason: string, t0: number): RenderCheckResult {
126
182
  }
127
183
 
128
184
  /**
129
- * Map one sandbox result onto a `RenderCheckResult`. Exported for the
130
- * unit pins — the spawn itself is exercised by the integration test.
185
+ * Map one sandbox result onto a `RenderCheckResult`. `boundMs` is the
186
+ * wall-clock bound the run was given, reported on a timeout. Exported for
187
+ * the unit pins — the spawn itself is exercised by the integration test.
131
188
  */
132
189
  export function mapSandboxResultToCheckResult(
133
190
  result: SandboxResult,
134
191
  t0: number,
192
+ boundMs: number = DEFAULT_RENDER_CHECK_HOST_BOUNDS.timeoutMs,
135
193
  ): RenderCheckResult {
136
194
  if (result.outcome === 'timeout') {
137
195
  return {
138
196
  ok: false,
139
197
  issues: [],
140
- incomplete: { kind: 'timeout', elapsedMs: result.durationMs, boundMs: CHECK_TIMEOUT_MS },
198
+ incomplete: { kind: 'timeout', elapsedMs: result.durationMs, boundMs },
141
199
  stats: { actionsChecked: 0, streamsChecked: 0, renderMs: Date.now() - t0 },
142
200
  };
143
201
  }
@@ -187,11 +245,15 @@ export function mapSandboxResultToCheckResult(
187
245
 
188
246
  /**
189
247
  * Run the render check in an isolated subprocess. See module doc.
248
+ * `options.bounds` sets the subprocess bounds for this run; omitted
249
+ * fields are the evaluation lane's defaults.
190
250
  */
191
251
  export async function runRenderCheckViaWorker(
192
252
  input: RunRenderCheckInput,
253
+ options: RenderCheckHostOptions = {},
193
254
  ): Promise<RenderCheckResult> {
194
255
  const t0 = Date.now();
256
+ const bounds = resolveRenderCheckHostBounds(options.bounds);
195
257
  let spawn: { command: string; args: string[] };
196
258
  try {
197
259
  spawn = resolveWorkerSpawn();
@@ -203,9 +265,10 @@ export async function runRenderCheckViaWorker(
203
265
  const result = await runSandboxed({
204
266
  command: spawn.command,
205
267
  args: spawn.args,
206
- timeoutMs: CHECK_TIMEOUT_MS,
268
+ timeoutMs: bounds.timeoutMs,
269
+ gracePeriodMs: bounds.gracePeriodMs,
207
270
  maxStdoutBytes: CHECK_STDOUT_CAP,
208
- nodeHeapMb: CHECK_NODE_HEAP_MB,
271
+ nodeHeapMb: bounds.heapMb,
209
272
  stdin: JSON.stringify(input),
210
273
  // NODE_ENV steers React's production vs development build — keep
211
274
  // parity with the caller; everything else stays on the sandbox's
@@ -218,6 +281,6 @@ export async function runRenderCheckViaWorker(
218
281
  end: loadavg()[0] ?? 0,
219
282
  cores: availableParallelism(),
220
283
  };
221
- const mapped = mapSandboxResultToCheckResult(result, t0);
284
+ const mapped = mapSandboxResultToCheckResult(result, t0, bounds.timeoutMs);
222
285
  return { ...mapped, stats: { ...mapped.stats, hostLoad } };
223
286
  }