@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.
- package/dist/adapter-Du7P_5Fx.d.ts +201 -0
- package/dist/adapters/generation-dispatch.d.ts +33 -12
- package/dist/adapters/generation-dispatch.js +510 -128
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +5 -3
- package/dist/advanced/index.js +551 -142
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +153 -59
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +2 -1
- package/dist/check/index.js +47 -1
- package/dist/check/index.js.map +1 -1
- package/dist/{create-ui-generator-D0kbWwln.d.ts → create-ui-generator-GIhzx08x.d.ts} +34 -10
- package/dist/{dispatch-wxaIMwZt.d.ts → dispatch-BnAH56p4.d.ts} +1 -1
- package/dist/evaluation/axis-checks/index.d.ts +4 -3
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +2 -1
- package/dist/evaluation/index.d.ts +4 -3
- package/dist/evaluation/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +29 -10
- package/dist/harness/check/runtime-render/index.js +203 -60
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/check/runtime-render/render-check-worker.d.ts +8 -1
- package/dist/harness/check/runtime-render/render-check-worker.js +72 -32
- package/dist/harness/check/runtime-render/render-check-worker.js.map +1 -1
- package/dist/harness/index.d.ts +2 -1
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +2 -1
- package/dist/harness/runtime.d.ts +2 -1
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +11 -2
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +2 -1
- package/dist/index.d.ts +38 -3
- package/dist/index.js +553 -143
- package/dist/index.js.map +1 -1
- package/dist/patch.d.ts +2 -1
- package/dist/policy.d.ts +7 -4
- package/dist/policy.js.map +1 -1
- package/dist/tools/render-check-worker.d.ts +7 -1
- package/dist/tools/render-check-worker.js +55 -23
- package/dist/tools/render-check-worker.js.map +1 -1
- package/dist/{types-public-Ow-tNtFE.d.ts → types-public-Bk6d1WLN.d.ts} +77 -11
- package/dist/workflows.d.ts +2 -1
- package/package.json +12 -12
- package/src/adapters/generation-dispatch.ts +137 -96
- package/src/check/run-tier0.ts +80 -1
- package/src/create-ui-generator.ts +185 -95
- package/src/evaluation/types-public.ts +73 -11
- package/src/harness/check/runtime-render/adapter.ts +221 -75
- package/src/harness/check/runtime-render/index.ts +75 -2
- package/src/harness/check/runtime-render/render-check-host.ts +82 -19
- package/src/harness/check/runtime-render/render-check-worker.ts +83 -29
- package/src/harness/check/runtime-render/render-check.ts +154 -117
- package/src/harness/coding/generate-task-runner.ts +57 -4
- package/src/harness/coding/init-session.ts +13 -0
- package/src/harness/coding/run-eval-round.ts +238 -15
- package/src/harness/policy.ts +11 -0
- package/src/harness/primitive-slice.ts +104 -0
- package/src/harness/types-public.ts +9 -1
- package/src/index.ts +4 -0
- package/src/policy.ts +7 -4
- package/src/tools/render-check-worker.ts +74 -23
- 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 {
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
})
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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 =
|
|
268
|
-
|
|
269
|
-
|
|
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 {
|
|
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<{
|
|
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
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
130
|
-
*
|
|
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
|
|
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:
|
|
268
|
+
timeoutMs: bounds.timeoutMs,
|
|
269
|
+
gracePeriodMs: bounds.gracePeriodMs,
|
|
207
270
|
maxStdoutBytes: CHECK_STDOUT_CAP,
|
|
208
|
-
nodeHeapMb:
|
|
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
|
}
|