@glubean/runner 0.5.0 → 0.8.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 (83) hide show
  1. package/dist/engine-bridge.d.ts +55 -0
  2. package/dist/engine-bridge.d.ts.map +1 -0
  3. package/dist/engine-bridge.js +219 -0
  4. package/dist/engine-bridge.js.map +1 -0
  5. package/dist/executor.d.ts +70 -2
  6. package/dist/executor.d.ts.map +1 -1
  7. package/dist/executor.js +21 -226
  8. package/dist/executor.js.map +1 -1
  9. package/dist/generate_summary.d.ts +15 -0
  10. package/dist/generate_summary.d.ts.map +1 -1
  11. package/dist/generate_summary.js +52 -1
  12. package/dist/generate_summary.js.map +1 -1
  13. package/dist/harness.js +257 -21
  14. package/dist/harness.js.map +1 -1
  15. package/dist/index.d.ts +24 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +24 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/load/continuation-pool.d.ts +82 -0
  20. package/dist/load/continuation-pool.d.ts.map +1 -0
  21. package/dist/load/continuation-pool.js +154 -0
  22. package/dist/load/continuation-pool.js.map +1 -0
  23. package/dist/load/execute-iteration.d.ts +126 -0
  24. package/dist/load/execute-iteration.d.ts.map +1 -0
  25. package/dist/load/execute-iteration.js +367 -0
  26. package/dist/load/execute-iteration.js.map +1 -0
  27. package/dist/load/histogram.d.ts +63 -0
  28. package/dist/load/histogram.d.ts.map +1 -0
  29. package/dist/load/histogram.js +149 -0
  30. package/dist/load/histogram.js.map +1 -0
  31. package/dist/load/orchestrator.d.ts +55 -0
  32. package/dist/load/orchestrator.d.ts.map +1 -0
  33. package/dist/load/orchestrator.js +571 -0
  34. package/dist/load/orchestrator.js.map +1 -0
  35. package/dist/load/reducer.d.ts +109 -0
  36. package/dist/load/reducer.d.ts.map +1 -0
  37. package/dist/load/reducer.js +718 -0
  38. package/dist/load/reducer.js.map +1 -0
  39. package/dist/load/route-key.d.ts +38 -0
  40. package/dist/load/route-key.d.ts.map +1 -0
  41. package/dist/load/route-key.js +107 -0
  42. package/dist/load/route-key.js.map +1 -0
  43. package/dist/load/samples.d.ts +83 -0
  44. package/dist/load/samples.d.ts.map +1 -0
  45. package/dist/load/samples.js +269 -0
  46. package/dist/load/samples.js.map +1 -0
  47. package/dist/load/sink.d.ts +127 -0
  48. package/dist/load/sink.d.ts.map +1 -0
  49. package/dist/load/sink.js +351 -0
  50. package/dist/load/sink.js.map +1 -0
  51. package/dist/load/subprocess.d.ts +83 -0
  52. package/dist/load/subprocess.d.ts.map +1 -0
  53. package/dist/load/subprocess.js +229 -0
  54. package/dist/load/subprocess.js.map +1 -0
  55. package/dist/load/threshold.d.ts +44 -0
  56. package/dist/load/threshold.d.ts.map +1 -0
  57. package/dist/load/threshold.js +197 -0
  58. package/dist/load/threshold.js.map +1 -0
  59. package/dist/load/timeline.d.ts +36 -0
  60. package/dist/load/timeline.d.ts.map +1 -0
  61. package/dist/load/timeline.js +158 -0
  62. package/dist/load/timeline.js.map +1 -0
  63. package/dist/load-harness.d.ts +2 -0
  64. package/dist/load-harness.d.ts.map +1 -0
  65. package/dist/load-harness.js +105 -0
  66. package/dist/load-harness.js.map +1 -0
  67. package/dist/resolve.d.ts +10 -11
  68. package/dist/resolve.d.ts.map +1 -1
  69. package/dist/resolve.js +28 -9
  70. package/dist/resolve.js.map +1 -1
  71. package/dist/runner-resolve.d.ts +53 -0
  72. package/dist/runner-resolve.d.ts.map +1 -0
  73. package/dist/runner-resolve.js +264 -0
  74. package/dist/runner-resolve.js.map +1 -0
  75. package/dist/workflow/event-timeline.d.ts +3 -0
  76. package/dist/workflow/event-timeline.d.ts.map +1 -0
  77. package/dist/workflow/event-timeline.js +72 -0
  78. package/dist/workflow/event-timeline.js.map +1 -0
  79. package/dist/workflow/execute.d.ts +267 -0
  80. package/dist/workflow/execute.d.ts.map +1 -0
  81. package/dist/workflow/execute.js +1475 -0
  82. package/dist/workflow/execute.js.map +1 -0
  83. package/package.json +8 -4
package/dist/harness.js CHANGED
@@ -8,9 +8,17 @@
8
8
  import { parseArgs } from "node:util";
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
10
10
  import { inferJsonSchema, truncateDeep } from "./schema_inference.js";
11
+ import { USE_ENGINE, createEngineCore, runViaEngine, engineRoutesId } from "./engine-bridge.js";
11
12
  import { bootstrap } from "./bootstrap.js";
12
13
  import { loadProjectOverlays } from "@glubean/scanner";
13
14
  import { setRuntime, setExplicitInput, setBootstrapInput, setForceStandalone, } from "@glubean/sdk/internal";
15
+ // Workflow executor — now node-only and owned by this package (plan 0007). The host
16
+ // drives a built workflow via runWorkflow (invocation inversion) and attributes inline
17
+ // ctx.http traces to the active node scope via __activeWorkflowNodeCtx (the ALS rebind).
18
+ import { runWorkflow, WorkflowPhaseFailedError, __activeWorkflowNodeCtx, } from "./workflow/execute.js";
19
+ // workflow per-node evidence → first-class timeline events; co-located with the
20
+ // executor that emits those events (plan 0007), not in this generic harness.
21
+ import { workflowEventToTimeline } from "./workflow/event-timeline.js";
14
22
  import ky from "ky";
15
23
  import { isTestBranchStep, isTestPollStep } from "@glubean/sdk";
16
24
  import { Expectation } from "@glubean/sdk/expect";
@@ -189,14 +197,57 @@ function incrAssertions(passed) {
189
197
  * Use this for all test-scoped event output to ensure concurrent events can be
190
198
  * attributed to the correct test.
191
199
  */
200
+ /** Events that STEER THE PARENT while the test is still running — the
201
+ * executor re-arms the subprocess timeout on `timeout_update`, and
202
+ * ProjectRunner forwards `session:set` to sibling files. Holding them in the
203
+ * parallel buffer until the test finishes would defeat them (a long test
204
+ * would be killed at the OLD deadline — codex S2.12 R24 P2), so they bypass
205
+ * buffering. Out-of-order arrival is fine: both are keyed/merged by the
206
+ * parent, not attributed to a contiguous test block. */
207
+ const CONTROL_EVENT_TYPES = new Set(["timeout_update", "session:set"]);
192
208
  function emitEvent(event) {
193
209
  const trc = currentTestCtx();
194
- if (trc) {
195
- console.log(JSON.stringify({ ...event, testId: trc.testId }));
210
+ const json = JSON.stringify(trc ? { ...event, testId: trc.testId } : event);
211
+ if (CONTROL_EVENT_TYPES.has(event.type)) {
212
+ console.log(json);
213
+ return;
196
214
  }
197
- else {
198
- console.log(JSON.stringify(event));
215
+ writeEventLine(json);
216
+ }
217
+ // ── Parallel-batch event buffering (codex S2.12 R22 P1) ─────────────────────
218
+ // Under batchConcurrency > 1, tests run concurrently and their stdout events
219
+ // would interleave — downstream collectors (CLI render, result JSON) keep
220
+ // per-test state that assumes each test's start..status block is CONTIGUOUS.
221
+ // Each parallel task runs inside this ALS with its own line buffer; the task
222
+ // flushes the whole buffer atomically (sync loop — no awaits) when it
223
+ // finishes. Sequential runs never enter the ALS: zero behavior change.
224
+ const parallelEventBuffer = new AsyncLocalStorage();
225
+ function writeEventLine(json) {
226
+ const buf = parallelEventBuffer.getStore();
227
+ if (buf)
228
+ buf.push(json);
229
+ else
230
+ console.log(json);
231
+ }
232
+ /**
233
+ * Write a wire event produced by the engine path (runner-on-engine, plan 0005).
234
+ * Mirrors emitEvent's control-event bypass, but the event already carries its
235
+ * testId (the engine path runs outside the testContext ALS, so testId is sourced
236
+ * from the engine event's own id, not currentTestCtx()).
237
+ */
238
+ function emitEngineWire(ev) {
239
+ // Mirror the legacy ctx.session.set: update the subprocess-local sessionData so
240
+ // sibling tests in this process see it (batch mode), in addition to forwarding
241
+ // the control event to the parent (codex P2).
242
+ if (ev.type === "session:set") {
243
+ sessionData[ev.key] = ev.value;
244
+ }
245
+ const json = JSON.stringify(ev);
246
+ if (CONTROL_EVENT_TYPES.has(ev.type)) {
247
+ console.log(json);
248
+ return;
199
249
  }
250
+ writeEventLine(json);
200
251
  }
201
252
  /**
202
253
  * Start monitoring memory usage.
@@ -552,8 +603,18 @@ const ctx = {
552
603
  ...(getStepIndex() !== null && { stepIndex: getStepIndex() }),
553
604
  });
554
605
  },
555
- // Structured event emission
606
+ // Structured event emission. The vNext workflow executor's per-node
607
+ // evidence (§17 #9) rides this channel as namespaced GlubeanEvents — the
608
+ // harness UNWRAPS the three known types into first-class timeline events
609
+ // (node id + grade reach generateSummary / the Cloud payload directly,
610
+ // not as a double-wrapped custom blob). Any other type — including other
611
+ // `workflow:*` names — stays a generic pass-through event.
556
612
  event: (ev) => {
613
+ const firstClass = workflowEventToTimeline(ev);
614
+ if (firstClass) {
615
+ emitEvent(firstClass);
616
+ return;
617
+ }
557
618
  emitEvent({
558
619
  type: "event",
559
620
  data: ev,
@@ -657,6 +718,24 @@ const ctx = {
657
718
  },
658
719
  };
659
720
  const requestTraceMap = new WeakMap();
721
+ // Capture the outgoing request body for full-trace mode. ky 2 no longer exposes
722
+ // `options.json`, so read it off a clone of the Request (parsed if JSON).
723
+ async function captureRequestBody(request) {
724
+ try {
725
+ const text = await request.clone().text();
726
+ if (!text)
727
+ return undefined;
728
+ try {
729
+ return JSON.parse(text);
730
+ }
731
+ catch {
732
+ return text;
733
+ }
734
+ }
735
+ catch {
736
+ return undefined;
737
+ }
738
+ }
660
739
  /** Max serialized body size (chars) to include in trace events. */
661
740
  const TRACE_BODY_MAX_SIZE = 1_048_576; // 1MB
662
741
  /**
@@ -722,18 +801,18 @@ const kyInstance = ky.create({
722
801
  retry: 0,
723
802
  hooks: {
724
803
  beforeRequest: [
725
- (_request, options) => {
726
- requestTraceMap.set(options, {
804
+ async ({ request, options }) => {
805
+ requestTraceMap.set(options.context, {
727
806
  startTime: performance.now(),
728
- body: emitFullTrace
729
- ? (options.json ?? options.body ?? undefined)
730
- : undefined,
807
+ body: emitFullTrace ? await captureRequestBody(request) : undefined,
731
808
  });
732
809
  },
733
810
  ],
734
811
  afterResponse: [
735
- async (request, _options, response) => {
736
- const trace = requestTraceMap.get(_options);
812
+ async ({ request, options, response }) => {
813
+ // `request` here is the final (possibly hook-replaced) request — correct
814
+ // for the trace target; the trace state is keyed by the stable context.
815
+ const trace = requestTraceMap.get(options.context);
737
816
  const duration = Math.round(performance.now() - (trace?.startTime ?? performance.now()));
738
817
  // Increment HTTP counters for summary
739
818
  {
@@ -807,17 +886,22 @@ const kyInstance = ky.create({
807
886
  }
808
887
  // Per-request state is on the options object; no global cleanup needed.
809
888
  }
810
- ctx.trace(traceData);
889
+ // Attribute to the active workflow node's scope when one is executing
890
+ // (the SDK's ctx.http rebind, §17 #10/#12): inline HTTP inside a
891
+ // workflow node promotes its grade and obeys the late-evidence
892
+ // quarantine. Outside a workflow node this is the closure ctx as ever.
893
+ const sink = __activeWorkflowNodeCtx() ?? ctx;
894
+ sink.trace(traceData);
811
895
  // Auto-metric for response time
812
896
  try {
813
897
  const pathname = new URL(request.url).pathname;
814
- ctx.metric("http_duration_ms", duration, {
898
+ sink.metric("http_duration_ms", duration, {
815
899
  unit: "ms",
816
900
  tags: { method: request.method, path: pathname },
817
901
  });
818
902
  }
819
903
  catch {
820
- ctx.metric("http_duration_ms", duration, {
904
+ sink.metric("http_duration_ms", duration, {
821
905
  unit: "ms",
822
906
  tags: { method: request.method },
823
907
  });
@@ -844,6 +928,13 @@ function normalizeOptions(options) {
844
928
  if (!options)
845
929
  return options;
846
930
  const normalized = { ...options };
931
+ // ky 2 renamed `prefixUrl` → `prefix` (with the same join semantics we rely on
932
+ // for "users" / "/users"). Glubean keeps `prefixUrl` as its public option and
933
+ // translates here at the ky boundary (codex ky2 P2-5; not `baseUrl`).
934
+ if (normalized.prefixUrl !== undefined) {
935
+ normalized.prefix = normalized.prefixUrl;
936
+ delete normalized.prefixUrl;
937
+ }
847
938
  // Remove empty searchParams so ky doesn't append a bare '?'
848
939
  if (normalized.searchParams != null) {
849
940
  if (normalized.searchParams instanceof URLSearchParams) {
@@ -947,7 +1038,7 @@ function wrapKy(instance, label = "base") {
947
1038
  const responseHeadersSchema = normalized?.schema?.responseHeaders;
948
1039
  if (responseHeadersSchema) {
949
1040
  const { schema, severity } = resolveSchemaEntry(responseHeadersSchema);
950
- const headersHook = (_req, _opts, response) => {
1041
+ const headersHook = ({ response }) => {
951
1042
  const headersObj = normalizeHeadersForValidation(response.headers);
952
1043
  runSchemaValidation(headersObj, schema, "response headers", severity);
953
1044
  };
@@ -1007,6 +1098,28 @@ function withEnvFallback(explicit) {
1007
1098
  },
1008
1099
  });
1009
1100
  }
1101
+ // runner-on-engine (plan 0005): when GLUBEAN_USE_ENGINE=1 the engine drives the
1102
+ // run-loop (executeNewTest delegates to RunnerCore). Construct it BEFORE
1103
+ // setRuntime so RunnerCore's ALS carrier is the one the SDK runtime fallback gets
1104
+ // set on — module-load configure() and the engine's runWithRuntime() then share a
1105
+ // single carrier (plan 0005 §接缝设计 / codex P1-5). Default OFF → no behavior change.
1106
+ const engineCore = USE_ENGINE
1107
+ ? createEngineCore(emitEngineWire, {
1108
+ // Pass the SAME fallback Proxies the legacy ctx/runtime use (.env →
1109
+ // process.env), so engine-mode ctx.vars/secrets keep the system-env fallback
1110
+ // (codex P2 / plan 0005 §E). The engine layers per-run input over these
1111
+ // without destroying the Proxy.
1112
+ vars: withEnvFallback(rawVars),
1113
+ secrets: withEnvFallback(rawSecrets),
1114
+ // RAW vars (un-proxied) so ctx.vars.all() returns {...rawVars} exactly — legacy
1115
+ // parity for empty/overlay vars (codex Phase-8 P2).
1116
+ varsRaw: rawVars,
1117
+ // Forward the trace policy so the engine's ky auto-trace capture matches legacy.
1118
+ emitFullTrace: !!emitFullTrace,
1119
+ inferSchema: !!inferSchema,
1120
+ truncateArrays: !!truncateArrays,
1121
+ })
1122
+ : undefined;
1010
1123
  setRuntime({
1011
1124
  vars: withEnvFallback(rawVars),
1012
1125
  secrets: withEnvFallback(rawSecrets),
@@ -1269,8 +1382,8 @@ try {
1269
1382
  testObj = findTestByExport(userModule, exportNamesMap[id]);
1270
1383
  }
1271
1384
  if (!testObj) {
1272
- console.log(JSON.stringify({ type: "start", id, name: id, testId: id }));
1273
- console.log(JSON.stringify({ type: "status", status: "failed", id, testId: id, error: `Test "${id}" not found in module` }));
1385
+ writeEventLine(JSON.stringify({ type: "start", id, name: id, testId: id }));
1386
+ writeEventLine(JSON.stringify({ type: "status", status: "failed", id, testId: id, error: `Test "${id}" not found in module` }));
1274
1387
  hasFailure = true;
1275
1388
  return;
1276
1389
  }
@@ -1279,12 +1392,12 @@ try {
1279
1392
  }
1280
1393
  catch (error) {
1281
1394
  if (error instanceof SkipError) {
1282
- console.log(JSON.stringify({ type: "status", status: "skipped", id, testId: id, reason: error.reason }));
1395
+ writeEventLine(JSON.stringify({ type: "status", status: "skipped", id, testId: id, reason: error.reason }));
1283
1396
  }
1284
1397
  else {
1285
1398
  hasFailure = true;
1286
1399
  const reason = classifyErrorReason(error);
1287
- console.log(JSON.stringify({
1400
+ writeEventLine(JSON.stringify({
1288
1401
  type: "status", status: "failed", id, testId: id,
1289
1402
  error: error instanceof Error ? error.message : String(error),
1290
1403
  stack: error instanceof Error ? error.stack : undefined,
@@ -1334,7 +1447,20 @@ try {
1334
1447
  const { default: PQueue } = await import("p-queue");
1335
1448
  const queue = new PQueue({ concurrency: batchConcurrency });
1336
1449
  for (const id of expandedIds) {
1337
- void queue.add(() => runOneTest(id));
1450
+ void queue.add(() => {
1451
+ const buf = [];
1452
+ return parallelEventBuffer.run(buf, async () => {
1453
+ try {
1454
+ await runOneTest(id);
1455
+ }
1456
+ finally {
1457
+ // Atomic flush: the loop is synchronous, so this test's whole
1458
+ // event block lands contiguously on stdout.
1459
+ for (const line of buf)
1460
+ console.log(line);
1461
+ }
1462
+ });
1463
+ });
1338
1464
  }
1339
1465
  await queue.onIdle();
1340
1466
  }
@@ -1483,7 +1609,96 @@ async function withFixtures(fixtures, baseCtx, runTest) {
1483
1609
  *
1484
1610
  * @param test The Test object to execute
1485
1611
  */
1612
+ /**
1613
+ * Structural gate for the engine path (plan 0005): Phase 0 routes ONLY simple
1614
+ * tests. Steps stay on legacy until the engine emits step_start/step_end + per-step
1615
+ * index/retry/timeout (Phase 1); branch/poll (Phase 2/3); test.extend() fixtures
1616
+ * and workflow later. Routing a steps test now would lose its step timeline and
1617
+ * skew summaries (codex P2). ctx-surface gaps (validate/metric/…) are managed by
1618
+ * only exercising migrated features under the flag.
1619
+ */
1620
+ function engineSupports(test) {
1621
+ if (test.fixtures && Object.keys(test.fixtures).length > 0)
1622
+ return false;
1623
+ // Built workflow / contract wrappers are simple-shaped Tests, but their fn emits
1624
+ // workflow:* events the LEGACY harness unwraps into node/poll timeline events (and
1625
+ // inbound contract is node-only) — the browser-safe engine doesn't, so keep them on
1626
+ // legacy even under route-all (codex Phase-3 P2; plan 0005 §scope). The SDK marks
1627
+ // these wrappers with __glubean_kind.
1628
+ if (test.__glubean_kind)
1629
+ return false;
1630
+ if (test.type === "simple")
1631
+ return true;
1632
+ if (test.type === "steps") {
1633
+ // Linear steps + retry/timeout (Phase 1) + branch (Phase 2) + poll (Phase 3).
1634
+ return stepsEngineSupported(test.steps ?? []);
1635
+ }
1636
+ return false;
1637
+ }
1638
+ function stepsEngineSupported(steps) {
1639
+ for (const step of steps) {
1640
+ // poll steps (Phase 3) are now engine-supported; recurse into branch cases for
1641
+ // any still-unsupported shape (none today — kept as a structural guard).
1642
+ if (isTestBranchStep(step)) {
1643
+ for (const c of step.branch.cases)
1644
+ if (!stepsEngineSupported(c.steps))
1645
+ return false;
1646
+ if (!stepsEngineSupported(step.branch.default ?? []))
1647
+ return false;
1648
+ }
1649
+ }
1650
+ return true;
1651
+ }
1486
1652
  async function executeNewTest(test) {
1653
+ // runner-on-engine (plan 0005): route to the engine only when (a) the engine is
1654
+ // active, (b) this test id is on the per-test allowlist (GLUBEAN_ENGINE_TESTIDS;
1655
+ // "*" = all at cutover) — so the flag never sends arbitrary production tests
1656
+ // through an incomplete ctx (codex), and (c) the test's shape is supported. The
1657
+ // engine owns its scope/carrier/per-event-id and runs OUTSIDE the legacy
1658
+ // testContext ALS (testId comes from each event's own id). Anything else → legacy.
1659
+ if (engineCore && engineRoutesId(test.meta.id) && engineSupports(test)) {
1660
+ // Wrap with the same memory monitoring as the legacy path so the final status
1661
+ // carries peakMemoryBytes/peakMemoryMB (codex P2).
1662
+ startMemoryMonitoring();
1663
+ const result = await runViaEngine(engineCore, test, { session: sessionData, retryCount });
1664
+ const peakBytes = stopMemoryMonitoring();
1665
+ // Status emission mirrors the legacy split: a throw re-raises so the dispatcher
1666
+ // reports "failed" + exit 1 (the engine swallows throws into a result); success
1667
+ // and skip emit their status here (plan 0005 / codex P2). A soft assertion
1668
+ // failure is NOT a throw → it "completed" (pass/fail is derived from the
1669
+ // assertion events downstream, as in the legacy path).
1670
+ if (result.threw) {
1671
+ const err = new Error(result.error ?? "test threw");
1672
+ // Re-raise with the user's original name + stack so classifyErrorReason() and
1673
+ // diagnostics match the legacy path (codex P2).
1674
+ if (result.errorName)
1675
+ err.name = result.errorName;
1676
+ if (result.errorStack)
1677
+ err.stack = result.errorStack;
1678
+ throw err;
1679
+ }
1680
+ if (result.stepsFailed) {
1681
+ // Node parity (harness.ts:2686): a steps test with any failed step throws after
1682
+ // teardown so the dispatcher reports failed + exit 1 — unlike a simple test's
1683
+ // soft assertion ("completes"). A branch-decision failure carries its own message.
1684
+ throw new Error(result.stepsFailMessage ?? "One or more steps failed");
1685
+ }
1686
+ if (result.status === "skipped") {
1687
+ // Re-raise a harness SkipError(reason) so the SAME dispatcher catch that
1688
+ // handles a legacy ctx.skip() emits the skipped status (with `reason`) — byte
1689
+ // parity with legacy, which throws skipRequest out to the dispatcher (plan 0005).
1690
+ throw new SkipError(result.skipReason);
1691
+ }
1692
+ emitEngineWire({
1693
+ type: "status",
1694
+ status: "completed",
1695
+ id: test.meta.id,
1696
+ testId: test.meta.id,
1697
+ peakMemoryBytes: peakBytes,
1698
+ peakMemoryMB: (peakBytes / 1024 / 1024).toFixed(2),
1699
+ });
1700
+ return;
1701
+ }
1487
1702
  const testTags = normalizeTestTags(test.meta.tags);
1488
1703
  const testMeta = { id: test.meta.id, tags: testTags };
1489
1704
  const trc = new TestRunContext(test.meta.id, testMeta);
@@ -1506,6 +1721,27 @@ async function executeNewTest(test) {
1506
1721
  // Core test body — receives the effective ctx (base or fixture-augmented)
1507
1722
  const runTestBody = async (effectiveCtx) => {
1508
1723
  if (test.type === "simple") {
1724
+ // Invocation inversion (plan 0007): a built workflow is a first-class DEF, not a
1725
+ // self-executing test. The SDK marks the wrapper `__glubean_kind === "workflow"`
1726
+ // and attaches the Workflow IR to `__glubean_workflow`; the host run-loop drives
1727
+ // it through the workflow executor here — like RunnerCore.run() dispatches simple
1728
+ // vs steps. This replaces the SDK's old wfTest.fn (which called runWorkflow
1729
+ // itself); the VERDICT mapping is identical, just host-owned: a skipped run →
1730
+ // ctx.skip(reason) (throws GlubeanSkipError, caught by the same dispatcher); a
1731
+ // failed run → rethrow the cause so the dispatcher reports failed + exit 1.
1732
+ const wfIr = test.__glubean_workflow;
1733
+ if (wfIr) {
1734
+ const result = await runWorkflow(wfIr, effectiveCtx);
1735
+ if (result.status === "skipped") {
1736
+ // Prefer the user-authored runtime ctx.skip(reason), then the authored
1737
+ // meta.skip, then a generic fallback (parity with the old wfTest.fn).
1738
+ effectiveCtx.skip(result.skipReason ?? wfIr.meta.skip ?? `workflow "${wfIr.meta.id}" skipped`);
1739
+ }
1740
+ if (result.status === "failed") {
1741
+ throw result.error ?? new WorkflowPhaseFailedError(wfIr.meta.id, "workflow");
1742
+ }
1743
+ return;
1744
+ }
1509
1745
  if (!test.fn) {
1510
1746
  throw new Error(`Invalid test "${test.meta.id}": missing fn`);
1511
1747
  }