waygraph 0.12.13 → 0.12.15

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/README.md CHANGED
@@ -226,7 +226,18 @@ FFCompose into its inner Blocks so a broken login step is visible as its own pan
226
226
  Former FF inners keep blitz pacing so wall-clock stays comparable to opaque FF (same
227
227
  real acts). `--ff-expand` is the older expand-only flag (same flatten). Saucedemo
228
228
  `checkoutFlow` uses `ff-owner-auth` for the login prefix; `loginFlow` stays expanded for
229
- Sign In narration. See `docs/proposals/traverse-ffcompose-rfc.md`.
229
+ Sign In narration.
230
+
231
+ `waygraph traverse` walks the Block graph (Phase B+D). `--parallel N` (default
232
+ `--session clone`) bootstraps one context, forks N workers with `storageState` + mem
233
+ snapshot, partitions edges by hash, and claims via in-process edge leases (audit under
234
+ `.waygraph-traverse/`). `--session inherit` is refused when `parallel > 1`. See
235
+ `docs/proposals/traverse-ffcompose-rfc.md`.
236
+
237
+ **Richer demo narration:** automation rings are **gray**; authored stubs/slides can use
238
+ `tone: "info" | "warning" | "danger" | "success"` (iconified captions). Episode pacing:
239
+ `withDemoPace(flow, "fast" | "slow" | "normal" | "blitz")` or `withBlockPace(block, ...)`
240
+ (FFCompose stays blitz). Living objectives: `docs/proposals/OBJECTIVES.md`.
230
241
  `spawnTab(entry, page, mem)` drives a genuinely separate second tab through its own
231
242
  Block/Flow, in the same browser context an existing `page` already belongs to - the
232
243
  pattern `observe()` could already reach for (it's the only phase allowed to touch
package/dist/cli.js CHANGED
@@ -211,6 +211,9 @@ import {
211
211
  formatHighlightCaption,
212
212
  hasAuthoredStubAfter,
213
213
  hasAuthoredStubOnError,
214
+ normalizeHighlightTone,
215
+ normalizeDemoPace,
216
+ resolveStepDemoPace,
214
217
  } from "waygraph";
215
218
 
216
219
  function walkDir(dir, pattern) {
@@ -399,9 +402,13 @@ const RING_CSS =
399
402
  "border:2.5px solid #7C3AED;border-radius:10px;" +
400
403
  "box-shadow:0 0 0 4px rgba(124,58,237,.16);transition:opacity .3s ease,border-color .15s,box-shadow .15s;}" +
401
404
  // Planned (stubs / fixtures / YAP / instruction.highlights) = purple.
402
- // Automation (verify fallback, unmatched fill/click) = yellow - so operators
403
- // can ignore engine checks and only watch purple narration.
404
- "#wg-ring[data-tone=auto]{border-color:#EAB308;box-shadow:0 0 0 4px rgba(234,179,8,.22);}" +
405
+ // Automation (verify fallback, unmatched fill/click) = gray - operators
406
+ // can ignore engine checks and watch purple + semantic tones.
407
+ "#wg-ring[data-tone=auto]{border-color:#9CA3AF;box-shadow:0 0 0 4px rgba(156,163,175,.28);}" +
408
+ "#wg-ring[data-tone=info]{border-color:#3B82F6;box-shadow:0 0 0 4px rgba(59,130,246,.22);}" +
409
+ "#wg-ring[data-tone=warning]{border-color:#EAB308;box-shadow:0 0 0 4px rgba(234,179,8,.22);}" +
410
+ "#wg-ring[data-tone=danger]{border-color:#EF4444;box-shadow:0 0 0 4px rgba(239,68,68,.22);}" +
411
+ "#wg-ring[data-tone=success]{border-color:#22C55E;box-shadow:0 0 0 4px rgba(34,197,94,.22);}" +
405
412
  // A real element, not a ::after pseudo-element - a pseudo-element's
406
413
  // position is CSS-relative to the ring's own box (left:0 always meant
407
414
  // "the ring's own left edge"), so it had no way to be clamped back onto
@@ -413,7 +420,11 @@ const RING_CSS =
413
420
  "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
414
421
  "max-width:min(360px,70vw);white-space:normal;padding:6px 10px;border-radius:7px;background:#7C3AED;color:#fff;" +
415
422
  "font:600 12px/1.35 system-ui,sans-serif;transition:opacity .3s ease,background .15s,color .15s;}" +
416
- "#wg-ring-label[data-tone=auto]{background:#EAB308;color:#1c1917;}" +
423
+ "#wg-ring-label[data-tone=auto]{background:#6B7280;color:#fff;}" +
424
+ "#wg-ring-label[data-tone=info]{background:#2563EB;color:#fff;}" +
425
+ "#wg-ring-label[data-tone=warning]{background:#EAB308;color:#1c1917;}" +
426
+ "#wg-ring-label[data-tone=danger]{background:#DC2626;color:#fff;}" +
427
+ "#wg-ring-label[data-tone=success]{background:#16A34A;color:#fff;}" +
417
428
  // Mouse cursor icon that travels to a target before it's acted on, plus a
418
429
  // quick expanding ripple at the moment of a click - same idea as
419
430
  // help-center-clip-engine's #clip-cursor/#clip-ring (video-pipeline). The
@@ -431,7 +442,11 @@ const RING_CSS =
431
442
  "#wg-click-pulse{position:fixed;z-index:2147483647;width:14px;height:14px;" +
432
443
  "margin-left:-7px;margin-top:-7px;border-radius:50%;pointer-events:none;opacity:0;" +
433
444
  "border:2px solid #7C3AED;background:rgba(124,58,237,.25);}" +
434
- "#wg-click-pulse[data-tone=auto]{border-color:#EAB308;background:rgba(234,179,8,.28);}" +
445
+ "#wg-click-pulse[data-tone=auto]{border-color:#9CA3AF;background:rgba(156,163,175,.28);}" +
446
+ "#wg-click-pulse[data-tone=info]{border-color:#3B82F6;background:rgba(59,130,246,.28);}" +
447
+ "#wg-click-pulse[data-tone=warning]{border-color:#EAB308;background:rgba(234,179,8,.28);}" +
448
+ "#wg-click-pulse[data-tone=danger]{border-color:#EF4444;background:rgba(239,68,68,.28);}" +
449
+ "#wg-click-pulse[data-tone=success]{border-color:#22C55E;background:rgba(34,197,94,.28);}" +
435
450
  "#wg-click-pulse.wg-pulse{animation:wg-pulse .5s ease-out;}" +
436
451
  "@keyframes wg-pulse{0%{opacity:.9;transform:scale(.4);}100%{opacity:0;transform:scale(2.4);}}" +
437
452
  "#wg-panel{position:fixed;z-index:2147483647;left:50%;bottom:12px;transform:translateX(-50%);" +
@@ -651,7 +666,11 @@ async function installOverlay(page, title) {
651
666
  const ring = document.getElementById("wg-ring");
652
667
  const ringLabel = document.getElementById("wg-ring-label");
653
668
  if (!ring || !ringLabel) return;
654
- const t = tone === "auto" ? "auto" : "planned";
669
+ const raw = (tone || "planned") + "";
670
+ const t =
671
+ raw === "auto" || raw === "info" || raw === "warning" || raw === "danger" || raw === "success"
672
+ ? raw
673
+ : "planned";
655
674
  ring.dataset.tone = t;
656
675
  ringLabel.dataset.tone = t;
657
676
  const margin = 6;
@@ -688,7 +707,11 @@ async function installOverlay(page, title) {
688
707
  window.__wgClickPulse = (x, y, tone) => {
689
708
  const pulse = document.getElementById("wg-click-pulse");
690
709
  if (!pulse) return;
691
- pulse.dataset.tone = tone === "auto" ? "auto" : "planned";
710
+ const raw = (tone || "planned") + "";
711
+ pulse.dataset.tone =
712
+ raw === "auto" || raw === "info" || raw === "warning" || raw === "danger" || raw === "success"
713
+ ? raw
714
+ : "planned";
692
715
  pulse.style.left = x + "px";
693
716
  pulse.style.top = y + "px";
694
717
  pulse.classList.remove("wg-pulse");
@@ -1303,7 +1326,7 @@ function resolveDeclaredHighlights(block, resultTag) {
1303
1326
  if (!Array.isArray(highlights)) return [];
1304
1327
  return highlights.filter((h) => h && typeof h.selector === "string" && typeof h.label === "string").map((h) => ({
1305
1328
  ...h,
1306
- tone: h.tone === "auto" ? "auto" : "planned",
1329
+ tone: normalizeHighlightTone(h.tone),
1307
1330
  }));
1308
1331
  }
1309
1332
 
@@ -1438,14 +1461,22 @@ async function matchStubForLocator(page, locator, stubs) {
1438
1461
 
1439
1462
  async function captionForLocator(page, locator, stubs, fallback) {
1440
1463
  const matched = await matchStubForLocator(page, locator, stubs);
1441
- if (matched) return { label: formatHighlightCaption(matched), tone: "planned" };
1464
+ if (matched) {
1465
+ return {
1466
+ label: formatHighlightCaption(matched),
1467
+ tone: normalizeHighlightTone(matched.tone),
1468
+ };
1469
+ }
1442
1470
  return { label: fallback, tone: "auto" };
1443
1471
  }
1444
1472
 
1445
1473
  async function dwellMatchedStub(page, locator, stubs, pacing) {
1446
1474
  const matched = await matchStubForLocator(page, locator, stubs);
1447
1475
  if (!matched) return;
1448
- const ms = resolveFixtureDwellMs(matched, { gatesFast: !!(pacing && pacing.gatesFast) });
1476
+ const ms = resolveFixtureDwellMs(matched, {
1477
+ gatesFast: !!(pacing && pacing.gatesFast),
1478
+ pace: pacing && pacing.demoPace,
1479
+ });
1449
1480
  if (ms != null && ms > 0) {
1450
1481
  await new Promise((res) => setTimeout(res, ms));
1451
1482
  }
@@ -1466,22 +1497,26 @@ async function presentSlides(page, slides, _gate, opts) {
1466
1497
  const episodeNumber = opts && opts.episodeNumber;
1467
1498
  const episodeTitle = (opts && opts.episodeTitle) || "";
1468
1499
  const gatesFast = !!(opts && opts.fast);
1500
+ const demoPace = normalizeDemoPace((opts && opts.pace) || (gatesFast ? "fast" : "normal"));
1469
1501
  const autoplayMs =
1470
1502
  opts && opts.autoplayMs
1471
1503
  ? Number(opts.autoplayMs)
1472
1504
  : process.env.WAYGRAPH_AUTOPLAY_MS
1473
1505
  ? Number(process.env.WAYGRAPH_AUTOPLAY_MS)
1474
- : 1800;
1506
+ : demoPace === "slow"
1507
+ ? 3500
1508
+ : 1800;
1475
1509
  for (let i = 0; i < slides.length; i++) {
1476
1510
  const s = slides[i];
1477
1511
  const caption = formatHighlightCaption(s);
1512
+ const slideTone = normalizeHighlightTone(s.tone);
1478
1513
  const isLast = i === slides.length - 1;
1479
- const dwellMs = resolveFixtureDwellMs(s, { gatesFast });
1514
+ const dwellMs = resolveFixtureDwellMs(s, { gatesFast, pace: demoPace });
1480
1515
  await installOverlay(page, title);
1481
1516
  if (s.selector) {
1482
1517
  try {
1483
1518
  const box = await page.locator(s.selector).first().boundingBox();
1484
- if (box) await showRing(page, box, caption, "planned");
1519
+ if (box) await showRing(page, box, caption, slideTone);
1485
1520
  } catch {
1486
1521
  await hideRing(page);
1487
1522
  }
@@ -1996,7 +2031,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
1996
2031
  // inside the FF (that was the "30s login / cursor gone but still slow" bug).
1997
2032
  const demoFast = process.env.WAYGRAPH_DEMO_FAST === "1";
1998
2033
  const stepperMode = process.env.WAYGRAPH_STEPPER === "full" ? "full" : "carousel";
1999
- const pacing = { gatesFast: demoFast, skipTheater: false };
2034
+ const pacing = { gatesFast: demoFast, gatesSlow: false, skipTheater: false, demoPace: "normal" };
2000
2035
  const stubBeforeRef = { current: [] };
2001
2036
  instrumentInteractionHighlighting(page, mem, slowMo, pacing, stubBeforeRef);
2002
2037
  // Force the panel checkbox from this process's flags/env at run start.
@@ -2087,7 +2122,9 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2087
2122
  ? Math.min(80, autoplayMs)
2088
2123
  : pacing.gatesFast
2089
2124
  ? Math.min(400, autoplayMs)
2090
- : autoplayMs;
2125
+ : pacing.gatesSlow
2126
+ ? Math.max(autoplayMs * 2, 3500)
2127
+ : autoplayMs;
2091
2128
  let elapsed = 0;
2092
2129
  for (;;) {
2093
2130
  // Re-read the checkbox EVERY loop tick, not once up front - a human
@@ -2171,10 +2208,33 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2171
2208
  await resetPageState(context, page, baseURL);
2172
2209
  }
2173
2210
  // gatesFast / skipTheater: opaque FF, former-FF inners (--ff-disabled/--ff-expand),
2174
- // or WAYGRAPH_FAST_BLOCKS. --fast alone = shorter gates only (keeps theater).
2175
- pacing.gatesFast = demoFast || !!r.block.fastForward || !!r.wasFastForward;
2211
+ // WAYGRAPH_FAST_BLOCKS, or withDemoPace/withBlockPace. --fast alone = shorter
2212
+ // gates only (keeps theater). Episode pace: block > flow > CLI --fast.
2213
+ const stepPace = resolveStepDemoPace({
2214
+ blockPace: r.block.demoPace,
2215
+ flowPace: r.demoPace,
2216
+ fastForward: !!r.block.fastForward,
2217
+ wasFastForward: !!r.wasFastForward,
2218
+ });
2219
+ const cliFast = demoFast || fastBlockNames.has(r.block.name);
2220
+ pacing.demoPace =
2221
+ stepPace === "normal" && cliFast
2222
+ ? fastBlockNames.has(r.block.name)
2223
+ ? "blitz"
2224
+ : "fast"
2225
+ : stepPace;
2226
+ pacing.gatesFast =
2227
+ pacing.demoPace === "blitz" ||
2228
+ pacing.demoPace === "fast" ||
2229
+ !!r.block.fastForward ||
2230
+ !!r.wasFastForward ||
2231
+ demoFast;
2232
+ pacing.gatesSlow = pacing.demoPace === "slow";
2176
2233
  pacing.skipTheater =
2177
- fastBlockNames.has(r.block.name) || !!r.block.fastForward || !!r.wasFastForward;
2234
+ pacing.demoPace === "blitz" ||
2235
+ fastBlockNames.has(r.block.name) ||
2236
+ !!r.block.fastForward ||
2237
+ !!r.wasFastForward;
2178
2238
  const fixtures = r.highlightFixtures;
2179
2239
  stubBeforeRef.current = resolveHighlightSlots(r.block, "stubBefore", { fixtures });
2180
2240
  const isNavBlock = r.block.__waygraphKind === "nav";
@@ -2273,7 +2333,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2273
2333
  label: formatHighlightCaption(h),
2274
2334
  duration: h.duration,
2275
2335
  fastMode: h.fastMode,
2276
- tone: "planned",
2336
+ tone: normalizeHighlightTone(h.tone),
2277
2337
  }));
2278
2338
  await cycleHighlightRings(page, errHighlights, !!pacing.gatesFast, {
2279
2339
  defaultHoldMs: 2000,
@@ -2333,6 +2393,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2333
2393
  title,
2334
2394
  blockName: r.block.name,
2335
2395
  fast: pacing.gatesFast,
2396
+ pace: pacing.demoPace,
2336
2397
  episodeNumber: r.episodeNumber,
2337
2398
  episodeTitle: r.episodeTitle,
2338
2399
  autoplayMs,
@@ -2346,7 +2407,7 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2346
2407
  label: formatHighlightCaption(h),
2347
2408
  duration: h.duration,
2348
2409
  fastMode: h.fastMode,
2349
- tone: "planned",
2410
+ tone: normalizeHighlightTone(h.tone),
2350
2411
  }),
2351
2412
  );
2352
2413
  } else {
@@ -2531,6 +2592,7 @@ async function main() {
2531
2592
  exportName: bi.name,
2532
2593
  seedMem: idx === 0 ? seedMem : undefined,
2533
2594
  highlightFixtures: flow.highlightFixtures,
2595
+ demoPace: flow.demoPace,
2534
2596
  expectedFailureReason:
2535
2597
  flow.expectedFailureReason && idx === blockInfos.length - 1
2536
2598
  ? flow.expectedFailureReason
@@ -2599,6 +2661,7 @@ async function main() {
2599
2661
  episodeTitle: flow.title || seg.ref,
2600
2662
  expectedFailureReason: flow.expectedFailureReason,
2601
2663
  highlightFixtures: flow.highlightFixtures,
2664
+ demoPace: flow.demoPace,
2602
2665
  seedMem: () => seedMemForFlow(mem, flow.blocks(), seg.json, seg.ref),
2603
2666
  });
2604
2667
  } else {
@@ -2608,6 +2671,7 @@ async function main() {
2608
2671
  episodeNumber: undefined,
2609
2672
  episodeTitle: undefined,
2610
2673
  highlightFixtures: undefined,
2674
+ demoPace: undefined,
2611
2675
  seedMem: () => seedMemForBlock(mem, r, seg.json),
2612
2676
  });
2613
2677
  }
@@ -2632,6 +2696,7 @@ async function main() {
2632
2696
  episodeNumber: meta.episodeNumber,
2633
2697
  episodeTitle: meta.episodeTitle,
2634
2698
  highlightFixtures: meta.highlightFixtures,
2699
+ demoPace: meta.demoPace,
2635
2700
  expectedFailureReason:
2636
2701
  meta.expectedFailureReason && remainingInFlow === 1 ? meta.expectedFailureReason : undefined,
2637
2702
  seedMem: isFirstOfSegment ? meta.seedMem : undefined,
@@ -2674,8 +2739,8 @@ async function main() {
2674
2739
  if (step && hasFfBlitz && process.env.WAYGRAPH_SLOWMO === undefined && !jsonReport) {
2675
2740
  console.error(
2676
2741
  ffDisabled
2677
- ? "waygraph demo: --ff-disabled (expanded) → Playwright slowMo=0 on former FF steps (blitz; same acts as opaque FF)"
2678
- : "waygraph demo: opaque FFCompose in flow → Playwright slowMo=0 (blitz); set WAYGRAPH_SLOWMO to override",
2742
+ ? "waygraph demo: --ff-disabled (expanded) -> Playwright slowMo=0 on former FF steps (blitz; same acts as opaque FF)"
2743
+ : "waygraph demo: opaque FFCompose in flow -> Playwright slowMo=0 (blitz); set WAYGRAPH_SLOWMO to override",
2679
2744
  );
2680
2745
  }
2681
2746
  const baseURL = process.env.WAYGRAPH_BASE_URL;
@@ -3716,8 +3781,10 @@ Primary (less is more):
3716
3781
 
3717
3782
  Also:
3718
3783
  waygraph list | nav | validate | check | graph | init <name>
3719
- waygraph traverse [project] Serial graph crawl (Phase B+)
3784
+ waygraph traverse [project] Graph crawl (Phase B+D)
3720
3785
  --blocks <glob|/re/|sub> Phase C: filter *.block.ts discovery
3786
+ --parallel N Phase D: N clone workers (max 4)
3787
+ --session clone|inherit Phase D: default clone
3721
3788
  --from <Checkpoint> Seed / start checkpoint
3722
3789
  --data '{...}' Mem seed
3723
3790
  --max-steps N Cap Block runs (default 50)
@@ -3743,6 +3810,7 @@ Examples:
3743
3810
  waygraph auto --blocks LoginPage OrderComplete
3744
3811
  waygraph auto --blocks '/mailpit/' # Phase C filtered explore
3745
3812
  waygraph traverse --blocks '**/mailpit/**/*.block.ts' # Phase C filtered crawl
3813
+ waygraph traverse --parallel 2 --session clone # Phase D clone workers
3746
3814
  waygraph run --blocks "loginFlow then add-all-to-cart" --data '{...}' --video
3747
3815
 
3748
3816
  Aliases (compat): \`chain <spec>\` -> run --blocks; \`chain auto A B\` -> auto --blocks A B;
@@ -3837,6 +3905,8 @@ async function main() {
3837
3905
  let headed = false;
3838
3906
  let baseUrl;
3839
3907
  let blocksFilter;
3908
+ let parallel;
3909
+ let session;
3840
3910
  let projectDir = process.cwd();
3841
3911
  for (let i = 0; i < rest.length; i++) {
3842
3912
  const a = rest[i];
@@ -3862,6 +3932,20 @@ async function main() {
3862
3932
  }
3863
3933
  continue;
3864
3934
  }
3935
+ if (a === "--parallel" || a.startsWith("--parallel=")) {
3936
+ const v = a.startsWith("--parallel=") ? a.slice("--parallel=".length) : rest[++i];
3937
+ parallel = Number(v);
3938
+ continue;
3939
+ }
3940
+ if (a === "--session" || a.startsWith("--session=")) {
3941
+ const v = a.startsWith("--session=") ? a.slice("--session=".length) : rest[++i];
3942
+ if (v !== "clone" && v !== "inherit") {
3943
+ console.error('waygraph traverse: --session must be "clone" or "inherit"');
3944
+ process.exit(1);
3945
+ }
3946
+ session = v;
3947
+ continue;
3948
+ }
3865
3949
  if (a === "--max-steps" || a.startsWith("--max-steps=")) {
3866
3950
  const v = a.startsWith("--max-steps=") ? a.slice(12) : rest[++i];
3867
3951
  maxSteps = Number(v);
@@ -3890,13 +3974,15 @@ async function main() {
3890
3974
  if (a === "--help" || a === "-h") {
3891
3975
  console.log(`waygraph traverse [project] [flags]
3892
3976
 
3893
- Serial graph crawl (RFC Phase B). Walks unused legal edges until a leaf,
3894
- budget kill, or first broken edge.
3977
+ Graph crawl (RFC Phase B+D). Walks unused legal edges until a leaf,
3978
+ budget kill, or first broken edge. --parallel N uses session clone + edge leases.
3895
3979
 
3896
3980
  --blocks <glob|/regex/|substr> Phase C: filter *.block.ts discovery
3981
+ --parallel N Phase D: N clone workers (default 1, max 4)
3982
+ --session clone|inherit Phase D: default clone; inherit refused if parallel>1
3897
3983
  --from <Checkpoint> start checkpoint (optional)
3898
3984
  --data '{...}' Mem seed JSON
3899
- --max-steps N default 50
3985
+ --max-steps N default 50 (per worker)
3900
3986
  --max-visits N per-node visit cap (default 2)
3901
3987
  --max-visits-per-edge N default 1
3902
3988
  --non-headless show browser
@@ -3904,7 +3990,7 @@ budget kill, or first broken edge.
3904
3990
 
3905
3991
  Examples:
3906
3992
  waygraph traverse --blocks '**/mailpit/**/*.block.ts'
3907
- waygraph traverse --blocks '/mailpit|login/'
3993
+ waygraph traverse --parallel 2 --session clone --max-steps 20
3908
3994
 
3909
3995
  PASS: [Reached Leaf Node[traverse-1] at=... steps=N]
3910
3996
  FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
@@ -3935,6 +4021,8 @@ FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
3935
4021
  headed,
3936
4022
  ...(baseUrl ? { baseURL: baseUrl } : {}),
3937
4023
  ...(blocksSelect ? { blocksSelect } : {}),
4024
+ ...(parallel !== undefined && Number.isFinite(parallel) ? { parallel } : {}),
4025
+ ...(session ? { session } : {}),
3938
4026
  });
3939
4027
  process.exit(code);
3940
4028
  }
package/dist/engine.d.ts CHANGED
@@ -186,6 +186,11 @@ export interface Flow<Out extends Checkpoint<string>> {
186
186
  * block.name. `run()` never reads this; step/`demo` merges onto block stubs.
187
187
  */
188
188
  readonly highlightFixtures?: import("./highlights.js").HighlightFixtureMap;
189
+ /**
190
+ * Demo pacing for this Flow's episode (`withDemoPace`).
191
+ * `run()` ignores; step/demo applies per Block in the episode.
192
+ */
193
+ readonly demoPace?: import("./highlights.js").DemoPace;
189
194
  /**
190
195
  * This Flow's constituent Blocks, in order between `start`/`end`, as plain
191
196
  * data - for introspection/visualization tools, without running anything.
@@ -253,6 +258,18 @@ export declare function withSessionReset<Out extends Checkpoint<string>>(flow: F
253
258
  * @example const ticket123 = withTitle(loginFlow, "Ticket #123 - Sales rep signs in");
254
259
  */
255
260
  export declare function withTitle<Out extends Checkpoint<string>>(flow: Flow<Out>, title: string): Flow<Out>;
261
+ /**
262
+ * Demo pacing for a whole Flow episode (fast review vs slow gap/wrong walkthrough).
263
+ * Non-destructive. `fastForwardComposeBlock` is always blitz for its own step;
264
+ * use this when an entire episode should be fast or slow.
265
+ * @example withDemoPace(withTitle(gapFlow, "Episode 2 - gaps"), "slow")
266
+ */
267
+ export declare function withDemoPace<Out extends Checkpoint<string>>(flow: Flow<Out>, pace: import("./highlights.js").DemoPace): Flow<Out>;
268
+ /**
269
+ * Demo pacing for one Block or compose unit (not FF - use fastForwardComposeBlock for blitz).
270
+ * @example withBlockPace(composeBlock("gap-review", [...]), "slow")
271
+ */
272
+ export declare function withBlockPace<In extends Checkpoint<string>, Out extends Checkpoint<string>>(block: Block<In, Out>, pace: import("./highlights.js").DemoPace): Block<In, Out>;
256
273
  /**
257
274
  * Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
258
275
  * login attempt genuinely failing, demonstrating the app's own real
package/dist/engine.js CHANGED
@@ -351,6 +351,31 @@ export function withTitle(flow, title) {
351
351
  modBlockVerify: (block, nameOrIndex, newCheck) => withTitle(flow.modBlockVerify(block, nameOrIndex, newCheck), title),
352
352
  };
353
353
  }
354
+ /**
355
+ * Demo pacing for a whole Flow episode (fast review vs slow gap/wrong walkthrough).
356
+ * Non-destructive. `fastForwardComposeBlock` is always blitz for its own step;
357
+ * use this when an entire episode should be fast or slow.
358
+ * @example withDemoPace(withTitle(gapFlow, "Episode 2 - gaps"), "slow")
359
+ */
360
+ export function withDemoPace(flow, pace) {
361
+ const demoPace = pace;
362
+ return {
363
+ ...flow,
364
+ demoPace,
365
+ withBlockVerify: (block, verify) => withDemoPace(flow.withBlockVerify(block, verify), demoPace),
366
+ modBlockVerify: (block, nameOrIndex, newCheck) => withDemoPace(flow.modBlockVerify(block, nameOrIndex, newCheck), demoPace),
367
+ };
368
+ }
369
+ /**
370
+ * Demo pacing for one Block or compose unit (not FF - use fastForwardComposeBlock for blitz).
371
+ * @example withBlockPace(composeBlock("gap-review", [...]), "slow")
372
+ */
373
+ export function withBlockPace(block, pace) {
374
+ return {
375
+ ...block,
376
+ demoPace: pace,
377
+ };
378
+ }
354
379
  /**
355
380
  * Marks a Flow as one that's SUPPOSED to throw - e.g. a locked-out user's
356
381
  * login attempt genuinely failing, demonstrating the app's own real
@@ -33,9 +33,16 @@ export interface WaygraphHighlightStub extends FixtureDurationFields {
33
33
  detail?: string;
34
34
  /** Short badge, e.g. AC / BUG / GATE / YAP. */
35
35
  tag?: string;
36
+ /**
37
+ * Demo ring color + icon:
38
+ * - `planned` purple (authored narration, default)
39
+ * - `auto` gray (engine automation / verify fallback)
40
+ * - `info` blue · `warning` yellow · `danger` red · `success` green
41
+ */
42
+ tone?: HighlightTone;
36
43
  }
37
44
  /** Flow fixture patch: label required; selector optional (inherits from stub). */
38
- export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag"> & FixtureDurationFields;
45
+ export type WaygraphHighlightFixture = Partial<Pick<WaygraphHighlightStub, "selector">> & Required<Pick<WaygraphHighlightStub, "label">> & Pick<WaygraphHighlightStub, "detail" | "tag" | "tone"> & FixtureDurationFields;
39
46
  export type HighlightStubPhase = Record<string, WaygraphHighlightStub>;
40
47
  export type HighlightStubPhaseOrFn<Out extends Checkpoint<string>> = HighlightStubPhase | ((out: Out) => HighlightStubPhase);
41
48
  /**
@@ -49,11 +56,32 @@ export interface WaygraphSlide extends FixtureDurationFields {
49
56
  tag?: string;
50
57
  /** Optional ring while this slide is showing. */
51
58
  selector?: string;
59
+ tone?: HighlightTone;
52
60
  }
61
+ /**
62
+ * Demo ring tones (iconified).
63
+ * Automation (`auto`) is gray so authored planned/info/warning/danger/success stand out.
64
+ */
65
+ export type HighlightTone = "planned" | "auto" | "info" | "warning" | "danger" | "success";
66
+ /** Map author aliases (`error` -> danger) to a canonical tone. */
67
+ export declare function normalizeHighlightTone(raw: unknown): HighlightTone;
68
+ /** Prefix label with a short ASCII icon for semantic tones. */
69
+ export declare function toneIconPrefix(tone: HighlightTone): string;
70
+ /**
71
+ * Demo pacing for a Flow episode or a Block/compose group.
72
+ * - `blitz` - FF-like: skip theater + short gates (same as fastForwardComposeBlock)
73
+ * - `fast` - shorter gates, keep cursor theater
74
+ * - `normal` - default demo pace
75
+ * - `slow` - longer autoplay + fixture dwell (gaps / wrongs review)
76
+ */
77
+ export type DemoPace = "blitz" | "fast" | "normal" | "slow";
78
+ export declare function normalizeDemoPace(raw: unknown): DemoPace;
53
79
  /** Default fixture dwell when `duration: true`. */
54
80
  export declare const FIXTURE_DURATION_MS_DEFAULT = 2000;
55
81
  /** Default fixture dwell under `--fast` when duration is enabled. */
56
82
  export declare const FIXTURE_DURATION_FAST_MS_DEFAULT = 600;
83
+ /** Default fixture dwell under demo pace `slow` (gaps / wrongs review). */
84
+ export declare const FIXTURE_DURATION_SLOW_MS_DEFAULT = 4000;
57
85
  /** @deprecated Prefer FIXTURE_DURATION_MS_DEFAULT */
58
86
  export declare const SLIDE_DURATION_MS_DEFAULT = 2000;
59
87
  /** @deprecated Prefer FIXTURE_DURATION_FAST_MS_DEFAULT */
@@ -64,11 +92,23 @@ export declare const SLIDE_DURATION_FAST_MS_DEFAULT = 600;
64
92
  */
65
93
  export declare function resolveFixtureDwellMs(fixture: FixtureDurationFields, opts?: {
66
94
  gatesFast?: boolean;
95
+ pace?: DemoPace;
67
96
  }): number | null;
68
97
  /** @deprecated Prefer {@link resolveFixtureDwellMs} */
69
98
  export declare function resolveSlideDwellMs(slide: FixtureDurationFields, opts?: {
70
99
  gatesFast?: boolean;
100
+ pace?: DemoPace;
71
101
  }): number | null;
102
+ /**
103
+ * Effective demo pace for a step: block override > flow episode > normal.
104
+ * Opaque FF / wasFastForward always win as blitz.
105
+ */
106
+ export declare function resolveStepDemoPace(opts: {
107
+ blockPace?: DemoPace | string | null;
108
+ flowPace?: DemoPace | string | null;
109
+ fastForward?: boolean;
110
+ wasFastForward?: boolean;
111
+ }): DemoPace;
72
112
  export type WaygraphSlidesOrFn<Out extends Checkpoint<string>> = readonly WaygraphSlide[] | ((out: Out) => readonly WaygraphSlide[]);
73
113
  /** Demo stub phase names (block instruction + flow fixtures). */
74
114
  export type HighlightStubPhaseName = "stubBefore" | "stubAfter" | "stubOnError";
@@ -85,6 +125,7 @@ export type HighlightFixtureMap = Record<string, {
85
125
  export interface WaygraphHighlight {
86
126
  selector: string;
87
127
  label: string;
128
+ tone?: HighlightTone;
88
129
  }
89
130
  export type ResolvedHighlight = WaygraphHighlightStub;
90
131
  /**
@@ -104,12 +145,13 @@ export declare function resolveSlides(block: Block<any, any>, opts?: {
104
145
  out?: Checkpoint<string>;
105
146
  fixtures?: HighlightFixtureMap;
106
147
  }): WaygraphSlide[];
107
- /** Caption for ring / slide UI (label|caption + optional detail/tag). */
148
+ /** Caption for ring / slide UI (label|caption + optional detail/tag + tone icon). */
108
149
  export declare function formatHighlightCaption(h: {
109
150
  label?: string;
110
151
  caption?: string;
111
152
  detail?: string;
112
153
  tag?: string;
154
+ tone?: HighlightTone | string;
113
155
  }): string;
114
156
  /**
115
157
  * True when the block authored a non-empty stubAfter (after resolve + shim),
@@ -8,10 +8,56 @@
8
8
  * All fixture shapes share optional {@link FixtureDurationFields} so demo dwell
9
9
  * before advancing is authored the same way on stubs, flow fixtures, and slides.
10
10
  */
11
+ const TONE_ICONS = {
12
+ planned: "",
13
+ auto: "",
14
+ info: "i",
15
+ warning: "!",
16
+ danger: "x",
17
+ success: "+",
18
+ };
19
+ /** Map author aliases (`error` -> danger) to a canonical tone. */
20
+ export function normalizeHighlightTone(raw) {
21
+ if (typeof raw !== "string")
22
+ return "planned";
23
+ const t = raw.trim().toLowerCase();
24
+ if (t === "auto" || t === "automation" || t === "engine")
25
+ return "auto";
26
+ if (t === "info" || t === "blue")
27
+ return "info";
28
+ if (t === "warning" || t === "warn" || t === "yellow")
29
+ return "warning";
30
+ if (t === "danger" || t === "error" || t === "red")
31
+ return "danger";
32
+ if (t === "success" || t === "ok" || t === "green")
33
+ return "success";
34
+ if (t === "planned" || t === "purple" || t === "authored")
35
+ return "planned";
36
+ return "planned";
37
+ }
38
+ /** Prefix label with a short ASCII icon for semantic tones. */
39
+ export function toneIconPrefix(tone) {
40
+ const icon = TONE_ICONS[tone];
41
+ return icon ? `[${icon}] ` : "";
42
+ }
43
+ export function normalizeDemoPace(raw) {
44
+ if (typeof raw !== "string")
45
+ return "normal";
46
+ const t = raw.trim().toLowerCase();
47
+ if (t === "blitz" || t === "ff")
48
+ return "blitz";
49
+ if (t === "fast" || t === "quick")
50
+ return "fast";
51
+ if (t === "slow" || t === "careful" || t === "review")
52
+ return "slow";
53
+ return "normal";
54
+ }
11
55
  /** Default fixture dwell when `duration: true`. */
12
56
  export const FIXTURE_DURATION_MS_DEFAULT = 2000;
13
57
  /** Default fixture dwell under `--fast` when duration is enabled. */
14
58
  export const FIXTURE_DURATION_FAST_MS_DEFAULT = 600;
59
+ /** Default fixture dwell under demo pace `slow` (gaps / wrongs review). */
60
+ export const FIXTURE_DURATION_SLOW_MS_DEFAULT = 4000;
15
61
  /** @deprecated Prefer FIXTURE_DURATION_MS_DEFAULT */
16
62
  export const SLIDE_DURATION_MS_DEFAULT = FIXTURE_DURATION_MS_DEFAULT;
17
63
  /** @deprecated Prefer FIXTURE_DURATION_FAST_MS_DEFAULT */
@@ -23,23 +69,40 @@ export const SLIDE_DURATION_FAST_MS_DEFAULT = FIXTURE_DURATION_FAST_MS_DEFAULT;
23
69
  export function resolveFixtureDwellMs(fixture, opts) {
24
70
  if (fixture.duration === undefined || fixture.duration === false)
25
71
  return null;
26
- const fast = !!opts?.gatesFast;
72
+ const pace = normalizeDemoPace(opts?.pace ?? (opts?.gatesFast ? "fast" : "normal"));
27
73
  const fastMs = typeof fixture.fastMode === "number" && Number.isFinite(fixture.fastMode) && fixture.fastMode >= 0
28
74
  ? fixture.fastMode
29
75
  : FIXTURE_DURATION_FAST_MS_DEFAULT;
30
- if (fast)
76
+ if (pace === "blitz" || pace === "fast" || opts?.gatesFast)
31
77
  return fastMs;
32
- if (fixture.duration === true)
33
- return FIXTURE_DURATION_MS_DEFAULT;
34
- if (typeof fixture.duration === "number" && Number.isFinite(fixture.duration) && fixture.duration >= 0) {
35
- return fixture.duration;
36
- }
37
- return FIXTURE_DURATION_MS_DEFAULT;
78
+ const normalMs = fixture.duration === true
79
+ ? FIXTURE_DURATION_MS_DEFAULT
80
+ : typeof fixture.duration === "number" && Number.isFinite(fixture.duration) && fixture.duration >= 0
81
+ ? fixture.duration
82
+ : FIXTURE_DURATION_MS_DEFAULT;
83
+ if (pace === "slow")
84
+ return Math.max(normalMs, FIXTURE_DURATION_SLOW_MS_DEFAULT);
85
+ return normalMs;
38
86
  }
39
87
  /** @deprecated Prefer {@link resolveFixtureDwellMs} */
40
88
  export function resolveSlideDwellMs(slide, opts) {
41
89
  return resolveFixtureDwellMs(slide, opts);
42
90
  }
91
+ /**
92
+ * Effective demo pace for a step: block override > flow episode > normal.
93
+ * Opaque FF / wasFastForward always win as blitz.
94
+ */
95
+ export function resolveStepDemoPace(opts) {
96
+ if (opts.fastForward || opts.wasFastForward)
97
+ return "blitz";
98
+ if (opts.blockPace != null && opts.blockPace !== "") {
99
+ return normalizeDemoPace(opts.blockPace);
100
+ }
101
+ if (opts.flowPace != null && opts.flowPace !== "") {
102
+ return normalizeDemoPace(opts.flowPace);
103
+ }
104
+ return "normal";
105
+ }
43
106
  function isStubPhase(v) {
44
107
  return !!v && typeof v === "object" && !Array.isArray(v);
45
108
  }
@@ -65,6 +128,7 @@ function mergeSlot(base, patch) {
65
128
  label: patch.label,
66
129
  ...(patch.detail ? { detail: patch.detail } : {}),
67
130
  ...(patch.tag ? { tag: patch.tag } : {}),
131
+ ...(patch.tone ? { tone: normalizeHighlightTone(patch.tone) } : {}),
68
132
  ...dwell,
69
133
  };
70
134
  }
@@ -79,6 +143,11 @@ function mergeSlot(base, patch) {
79
143
  ? { detail: base.detail }
80
144
  : {}),
81
145
  ...(patch.tag !== undefined ? { tag: patch.tag } : base.tag ? { tag: base.tag } : {}),
146
+ ...(patch.tone !== undefined
147
+ ? { tone: normalizeHighlightTone(patch.tone) }
148
+ : base.tone
149
+ ? { tone: normalizeHighlightTone(base.tone) }
150
+ : {}),
82
151
  ...dwell,
83
152
  };
84
153
  }
@@ -88,7 +157,11 @@ function phaseFromLegacyHighlights(highlights) {
88
157
  return out;
89
158
  highlights.forEach((h, i) => {
90
159
  if (h && typeof h.selector === "string" && typeof h.label === "string") {
91
- out[String(i)] = { selector: h.selector, label: h.label };
160
+ out[String(i)] = {
161
+ selector: h.selector,
162
+ label: h.label,
163
+ ...(h.tone ? { tone: normalizeHighlightTone(h.tone) } : {}),
164
+ };
92
165
  }
93
166
  });
94
167
  return out;
@@ -169,13 +242,15 @@ export function resolveSlides(block, opts) {
169
242
  return [];
170
243
  return raw.filter((s) => s && typeof s.caption === "string" && s.caption.length > 0);
171
244
  }
172
- /** Caption for ring / slide UI (label|caption + optional detail/tag). */
245
+ /** Caption for ring / slide UI (label|caption + optional detail/tag + tone icon). */
173
246
  export function formatHighlightCaption(h) {
174
- const primary = h.caption ?? h.label ?? "";
247
+ const tone = normalizeHighlightTone(h.tone);
248
+ const icon = toneIconPrefix(tone);
249
+ const primary = (h.label ?? h.caption ?? "").trim();
175
250
  const tag = h.tag ? `[${h.tag}] ` : "";
176
251
  if (h.detail)
177
- return `${tag}${primary} - ${h.detail}`;
178
- return `${tag}${primary}`;
252
+ return `${icon}${tag}${primary} - ${h.detail}`;
253
+ return `${icon}${tag}${primary}`;
179
254
  }
180
255
  /**
181
256
  * True when the block authored a non-empty stubAfter (after resolve + shim),
package/dist/index.d.ts CHANGED
@@ -2,12 +2,15 @@ export type { Checkpoint, Instruction, Block, DefinedBlock, Start, WaygraphInsta
2
2
  export { connect, checkpoint } from "./types.js";
3
3
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
4
4
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
5
- export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
6
- export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
7
- export type { WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, } from "./highlights.js";
5
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withDemoPace, withBlockPace, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
6
+ export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, toneIconPrefix, resolveStepDemoPace, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
7
+ export type { WaygraphHighlightStub, WaygraphHighlightFixture, WaygraphSlide, FixtureDurationFields, HighlightStubPhase, HighlightStubPhaseName, HighlightFixtureMap, ResolvedHighlight, WaygraphHighlight, HighlightTone, DemoPace, } from "./highlights.js";
8
8
  export type { Flow, EngineConfig, ComposedBlock, FastForwardComposedBlock, RunGraphOptions, BlockInfo, NavBlock, NavBlockOptions, NavClickBlock, NavClickBlockOptions, MethodBlock, ActionBlock, PageBlock, PageBlockOptions, PageMethodEntry, EffectBlock, MemEffectBlock, MemNavBlock, } from "./engine.js";
9
9
  export type { ActionPage } from "./types.js";
10
10
  export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./graph.js";
11
11
  export type { WaygraphGraph, WaygraphNode, WaygraphEdge, SkippedBlock, OrphanBlock } from "./graph.js";
12
12
  export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp, } from "./blocks-select.js";
13
13
  export type { BlocksSelect, BlocksSelectKind } from "./blocks-select.js";
14
+ export { EdgeLeaseCoordinator } from "./traverse-lease.js";
15
+ export { runTraverse } from "./traverse-run.js";
16
+ export type { TraverseOptions, TraverseSessionMode } from "./traverse-run.js";
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export { connect, checkpoint } from "./types.js";
2
2
  export { MemKey, MemPage, key, keyGroup } from "./mem-page.js";
3
3
  export { Trait, urlMatches, textEquals, visible } from "./trait.js";
4
- export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
5
- export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
4
+ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, modVerify, modVerifyAll, defineBlock, defineNavBlock, defineNavClickBlock, defineMethodBlock, defineActionBlock, definePageBlock, defineEffectBlock, defineMemEffectBlock, defineMemNavBlock, locate, composeBlock, fastForwardComposeBlock, isFastForwardBlock, preflight, withSessionReset, withTitle, withDemoPace, withBlockPace, withExpectedFailure, withHighlightFixtures, chainFlow, } from "./engine.js";
5
+ export { resolveHighlightSlots, resolveSlides, resolveFixtureDwellMs, resolveSlideDwellMs, formatHighlightCaption, hasAuthoredStubAfter, hasAuthoredStubOnError, normalizeHighlightTone, normalizeDemoPace, toneIconPrefix, resolveStepDemoPace, FIXTURE_DURATION_MS_DEFAULT, FIXTURE_DURATION_FAST_MS_DEFAULT, FIXTURE_DURATION_SLOW_MS_DEFAULT, SLIDE_DURATION_MS_DEFAULT, SLIDE_DURATION_FAST_MS_DEFAULT, } from "./highlights.js";
6
6
  export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./graph.js";
7
7
  export { isFileSelectToken, parseBlocksSelect, matchBlocksSelect, globToRegExp, } from "./blocks-select.js";
8
+ export { EdgeLeaseCoordinator } from "./traverse-lease.js";
9
+ export { runTraverse } from "./traverse-run.js";
@@ -3,7 +3,7 @@ import type { MemPage } from "./mem-page.js";
3
3
  export declare function installDemoChrome(page: Page, title?: string, options?: {
4
4
  banner?: boolean;
5
5
  }): Promise<void>;
6
- /** Demo cursor + ring on fill/click during auto explore headful runs (automation = yellow). */
6
+ /** Demo cursor + ring on fill/click during auto explore headful runs (automation = gray). */
7
7
  export declare function instrumentInteractionHighlighting(page: Page, mem: MemPage, slowMo: number): void;
8
8
  declare global {
9
9
  interface Window {
@@ -1,11 +1,20 @@
1
1
  const RING_CSS = "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
2
2
  "border:2.5px solid #7C3AED;border-radius:10px;box-shadow:0 0 0 4px rgba(124,58,237,.16);" +
3
3
  "transition:opacity .3s ease,border-color .15s,box-shadow .15s;}" +
4
- "#wg-ring[data-tone=auto]{border-color:#EAB308;box-shadow:0 0 0 4px rgba(234,179,8,.22);}" +
4
+ // Automation = gray; semantic tones for authored highlights.
5
+ "#wg-ring[data-tone=auto]{border-color:#9CA3AF;box-shadow:0 0 0 4px rgba(156,163,175,.28);}" +
6
+ "#wg-ring[data-tone=info]{border-color:#3B82F6;box-shadow:0 0 0 4px rgba(59,130,246,.22);}" +
7
+ "#wg-ring[data-tone=warning]{border-color:#EAB308;box-shadow:0 0 0 4px rgba(234,179,8,.22);}" +
8
+ "#wg-ring[data-tone=danger]{border-color:#EF4444;box-shadow:0 0 0 4px rgba(239,68,68,.22);}" +
9
+ "#wg-ring[data-tone=success]{border-color:#22C55E;box-shadow:0 0 0 4px rgba(34,197,94,.22);}" +
5
10
  "#wg-ring-label{position:fixed;z-index:2147483646;pointer-events:none;opacity:0;" +
6
11
  "white-space:nowrap;padding:4px 9px;border-radius:7px;background:#7C3AED;color:#fff;" +
7
12
  "font:600 12px/1.2 system-ui,sans-serif;transition:opacity .3s ease,background .15s,color .15s;}" +
8
- "#wg-ring-label[data-tone=auto]{background:#EAB308;color:#1c1917;}" +
13
+ "#wg-ring-label[data-tone=auto]{background:#6B7280;color:#fff;}" +
14
+ "#wg-ring-label[data-tone=info]{background:#2563EB;color:#fff;}" +
15
+ "#wg-ring-label[data-tone=warning]{background:#EAB308;color:#1c1917;}" +
16
+ "#wg-ring-label[data-tone=danger]{background:#DC2626;color:#fff;}" +
17
+ "#wg-ring-label[data-tone=success]{background:#16A34A;color:#fff;}" +
9
18
  "#wg-cursor{position:fixed;z-index:2147483647;width:24px;height:24px;pointer-events:none;" +
10
19
  "left:0;top:0;opacity:0;margin:0;" +
11
20
  "transition:transform var(--wg-cursor-ms,600ms) cubic-bezier(.22,1,.36,1),opacity .2s ease;" +
@@ -13,7 +22,11 @@ const RING_CSS = "#wg-ring{position:fixed;z-index:2147483646;pointer-events:none
13
22
  "#wg-click-pulse{position:fixed;z-index:2147483647;width:14px;height:14px;" +
14
23
  "margin-left:-7px;margin-top:-7px;border-radius:50%;pointer-events:none;opacity:0;" +
15
24
  "border:2px solid #7C3AED;background:rgba(124,58,237,.25);}" +
16
- "#wg-click-pulse[data-tone=auto]{border-color:#EAB308;background:rgba(234,179,8,.28);}" +
25
+ "#wg-click-pulse[data-tone=auto]{border-color:#9CA3AF;background:rgba(156,163,175,.28);}" +
26
+ "#wg-click-pulse[data-tone=info]{border-color:#3B82F6;background:rgba(59,130,246,.28);}" +
27
+ "#wg-click-pulse[data-tone=warning]{border-color:#EAB308;background:rgba(234,179,8,.28);}" +
28
+ "#wg-click-pulse[data-tone=danger]{border-color:#EF4444;background:rgba(239,68,68,.28);}" +
29
+ "#wg-click-pulse[data-tone=success]{border-color:#22C55E;background:rgba(34,197,94,.28);}" +
17
30
  "#wg-click-pulse.wg-pulse{animation:wg-pulse .5s ease-out;}" +
18
31
  "@keyframes wg-pulse{0%{opacity:.9;transform:scale(.4);}100%{opacity:0;transform:scale(2.4);}}" +
19
32
  "#wg-banner{position:fixed;z-index:2147483647;top:14px;left:14px;" +
@@ -65,7 +78,15 @@ export async function installDemoChrome(page, title = "waygraph auto", options =
65
78
  const pulse = document.getElementById("wg-click-pulse");
66
79
  if (!pulse)
67
80
  return;
68
- pulse.dataset.tone = tone === "auto" ? "auto" : "planned";
81
+ const raw = (tone || "planned") + "";
82
+ pulse.dataset.tone =
83
+ raw === "auto" ||
84
+ raw === "info" ||
85
+ raw === "warning" ||
86
+ raw === "danger" ||
87
+ raw === "success"
88
+ ? raw
89
+ : "planned";
69
90
  pulse.style.left = x + "px";
70
91
  pulse.style.top = y + "px";
71
92
  pulse.classList.remove("wg-pulse");
@@ -77,7 +98,14 @@ export async function installDemoChrome(page, title = "waygraph auto", options =
77
98
  const ringLabel = document.getElementById("wg-ring-label");
78
99
  if (!ring || !ringLabel)
79
100
  return;
80
- const t = tone === "auto" ? "auto" : "planned";
101
+ const raw = (tone || "planned") + "";
102
+ const t = raw === "auto" ||
103
+ raw === "info" ||
104
+ raw === "warning" ||
105
+ raw === "danger" ||
106
+ raw === "success"
107
+ ? raw
108
+ : "planned";
81
109
  ring.dataset.tone = t;
82
110
  ringLabel.dataset.tone = t;
83
111
  ring.style.left = box.x - 6 + "px";
@@ -136,7 +164,7 @@ async function clickPulseAt(page, x, y, tone = "auto") {
136
164
  .evaluate(({ x, y, tone }) => window.__wgClickPulse?.(x, y, tone), { x, y, tone })
137
165
  .catch(() => { });
138
166
  }
139
- /** Demo cursor + ring on fill/click during auto explore headful runs (automation = yellow). */
167
+ /** Demo cursor + ring on fill/click during auto explore headful runs (automation = gray). */
140
168
  export function instrumentInteractionHighlighting(page, mem, slowMo) {
141
169
  const typeDelay = () => (slowMo ? 0 : 30);
142
170
  const clickPrePop = () => (slowMo ? 300 : 700);
@@ -0,0 +1,18 @@
1
+ export interface EdgeLeaseRecord {
2
+ workerId: string;
3
+ claimedAt: number;
4
+ }
5
+ export declare class EdgeLeaseCoordinator {
6
+ private readonly held;
7
+ private readonly dir;
8
+ constructor(opts?: {
9
+ projectDir?: string;
10
+ persist?: boolean;
11
+ });
12
+ /** True if this worker now owns `edgeKey` (or already owned it). */
13
+ tryClaim(edgeKey: string, workerId: string): boolean;
14
+ release(edgeKey: string, workerId: string): void;
15
+ /** Stable partition: same key always maps to the same worker index. */
16
+ static partitionIndex(edgeKey: string, parallel: number): number;
17
+ private flush;
18
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Phase D: edge leases so parallel traverse workers never claim the same edge.
3
+ * In-process Map is authoritative for `--parallel` (same Node process).
4
+ * Optional JSON under `.waygraph-traverse/` is an audit trail only.
5
+ */
6
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ export class EdgeLeaseCoordinator {
9
+ held = new Map();
10
+ dir;
11
+ constructor(opts) {
12
+ if (opts?.persist && opts.projectDir) {
13
+ this.dir = join(opts.projectDir, ".waygraph-traverse");
14
+ if (!existsSync(this.dir))
15
+ mkdirSync(this.dir, { recursive: true });
16
+ }
17
+ else {
18
+ this.dir = null;
19
+ }
20
+ }
21
+ /** True if this worker now owns `edgeKey` (or already owned it). */
22
+ tryClaim(edgeKey, workerId) {
23
+ const cur = this.held.get(edgeKey);
24
+ if (cur && cur.workerId !== workerId)
25
+ return false;
26
+ this.held.set(edgeKey, { workerId, claimedAt: Date.now() });
27
+ this.flush();
28
+ return true;
29
+ }
30
+ release(edgeKey, workerId) {
31
+ const cur = this.held.get(edgeKey);
32
+ if (cur && cur.workerId === workerId) {
33
+ this.held.delete(edgeKey);
34
+ this.flush();
35
+ }
36
+ }
37
+ /** Stable partition: same key always maps to the same worker index. */
38
+ static partitionIndex(edgeKey, parallel) {
39
+ if (parallel <= 1)
40
+ return 0;
41
+ let h = 2166136261;
42
+ for (let i = 0; i < edgeKey.length; i++) {
43
+ h ^= edgeKey.charCodeAt(i);
44
+ h = Math.imul(h, 16777619);
45
+ }
46
+ return (h >>> 0) % parallel;
47
+ }
48
+ flush() {
49
+ if (!this.dir)
50
+ return;
51
+ try {
52
+ const payload = {
53
+ updatedAt: new Date().toISOString(),
54
+ leases: Object.fromEntries(this.held),
55
+ };
56
+ writeFileSync(join(this.dir, "leases.json"), JSON.stringify(payload, null, 2));
57
+ }
58
+ catch {
59
+ /* audit only */
60
+ }
61
+ }
62
+ }
@@ -1,3 +1,4 @@
1
+ export type TraverseSessionMode = "clone" | "inherit";
1
2
  export interface TraverseOptions {
2
3
  baseURL?: string;
3
4
  startUrl?: string;
@@ -19,8 +20,12 @@ export interface TraverseOptions {
19
20
  traverseId?: string;
20
21
  /** Phase C: glob / regex / bare `--blocks` file select. */
21
22
  blocksSelect?: import("./blocks-select.js").BlocksSelect;
23
+ /** Phase D: parallel workers (default 1). Cap 4. */
24
+ parallel?: number;
25
+ /** Phase D: clone (default) or inherit. Inherit refused when parallel > 1. */
26
+ session?: TraverseSessionMode;
22
27
  }
23
28
  /**
24
- * Serial graph crawl. Prints greppable PASS/FAIL lines; non-zero exit on break.
29
+ * Serial or parallel graph crawl. Prints greppable PASS/FAIL lines; non-zero on break.
25
30
  */
26
31
  export declare function runTraverse(projectDir: string, options?: TraverseOptions): Promise<number>;
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Phase B: serial graph traverse (RFC traverse-ffcompose).
3
- * Walk unused legal edges until leaf / budget / break. No parallel yet.
2
+ * Phase B+D: graph traverse (serial or --parallel with --session clone).
3
+ * Walk unused legal edges until leaf / budget / break.
4
4
  */
5
5
  import { readFileSync, existsSync } from "node:fs";
6
6
  import { join } from "node:path";
@@ -8,6 +8,7 @@ import { Engine, start, end, locate } from "./engine.js";
8
8
  import { MemPage } from "./mem-page.js";
9
9
  import { buildExploreContext, buildExploreMenu, } from "./auto-explore.js";
10
10
  import { defaultMemValueForKey } from "./auto-explore-run.js";
11
+ import { EdgeLeaseCoordinator } from "./traverse-lease.js";
11
12
  function resolveBaseUrl(projectDir) {
12
13
  if (process.env.WAYGRAPH_BASE_URL)
13
14
  return process.env.WAYGRAPH_BASE_URL;
@@ -47,6 +48,32 @@ function seedMem(library, mem, dataJson) {
47
48
  }
48
49
  }
49
50
  }
51
+ function snapshotMem(library, mem) {
52
+ const out = {};
53
+ for (const entry of library.values()) {
54
+ for (const k of entry.block.requires ?? []) {
55
+ if (mem.has(k) && !(k.name in out)) {
56
+ try {
57
+ out[k.name] = mem.get(k);
58
+ }
59
+ catch {
60
+ /* skip */
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return out;
66
+ }
67
+ function restoreMem(library, mem, snap, dataJson) {
68
+ seedMem(library, mem, dataJson);
69
+ for (const entry of library.values()) {
70
+ for (const k of entry.block.requires ?? []) {
71
+ if (k.name in snap) {
72
+ mem.set(k, snap[k.name]);
73
+ }
74
+ }
75
+ }
76
+ }
50
77
  function missingMemKeys(entry, mem) {
51
78
  return (entry.block.requires ?? []).filter((k) => !mem.has(k));
52
79
  }
@@ -94,56 +121,18 @@ async function runOneBlock(engine, entry, context, page, mem) {
94
121
  clearTimeout(timer);
95
122
  }
96
123
  }
97
- /**
98
- * Serial graph crawl. Prints greppable PASS/FAIL lines; non-zero exit on break.
99
- */
100
- export async function runTraverse(projectDir, options = {}) {
101
- const traverseId = options.traverseId ?? "traverse-1";
102
- const maxSteps = options.maxSteps ?? 50;
103
- const maxVisitsPerNode = options.maxVisitsPerNode ?? 2;
104
- const maxVisitsPerEdge = options.maxVisitsPerEdge ?? 1;
105
- const timeoutMs = options.timeoutMs ?? 15 * 60_000;
106
- const headed = options.headed === true || process.env.WAYGRAPH_HEADED === "1";
107
- const baseURL = options.baseURL ?? resolveBaseUrl(projectDir) ?? process.env.WAYGRAPH_BASE_URL;
108
- const startUrl = options.startUrl ?? baseURL;
109
- const { graph, library } = await buildExploreContext(projectDir, options.blocksSelect ? { blocksSelect: options.blocksSelect } : undefined);
110
- if (options.blocksSelect) {
111
- console.error(`waygraph traverse: --blocks ${options.blocksSelect.raw} → ` +
112
- `${library.byName.size} block(s), ${graph.edges.length} edge(s)`);
113
- }
114
- const mem = new MemPage();
115
- seedMem(library.byName, mem, options.data);
116
- const engine = new Engine({ headless: !headed, slowMo: headed ? 100 : 0 });
117
- const { chromium } = await import("@playwright/test");
118
- const executablePath = process.env.CHROME_PATH || process.env.CHROMIUM_PATH;
119
- const launchOpts = {
120
- headless: !headed,
121
- };
122
- if (executablePath)
123
- launchOpts.executablePath = executablePath;
124
- const browser = await chromium.launch(launchOpts);
125
- const contextOpts = {
126
- viewport: { width: 1280, height: 720 },
127
- };
128
- if (baseURL)
129
- contextOpts.baseURL = baseURL;
130
- const context = await browser.newContext(contextOpts);
131
- let page = await context.newPage();
124
+ async function runTraverseWorker(opts) {
125
+ const { traverseId, workerIndex, parallel, maxSteps, maxVisitsPerNode, maxVisitsPerEdge, timeoutMs, graph, library, engine, context, mem, leases, } = opts;
126
+ let page = opts.page;
127
+ const startUrl = opts.startUrl;
132
128
  const nodeVisits = new Map();
133
129
  const edgeVisits = new Map();
134
130
  const edgesHit = new Set();
135
131
  let steps = 0;
136
132
  const startedAt = Date.now();
137
- let lastHere = options.from ?? null;
133
+ let lastHere = opts.from ?? null;
138
134
  let exitCode = 0;
139
- console.error(`waygraph traverse[${traverseId}]: ${graph.nodes.length} node(s), ${graph.edges.length} edge(s)` +
140
- (baseURL ? ` base=${baseURL}` : "") +
141
- ` maxSteps=${maxSteps} maxVisits/node=${maxVisitsPerNode} maxVisits/edge=${maxVisitsPerEdge}`);
142
135
  try {
143
- if (startUrl) {
144
- await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
145
- await page.waitForLoadState("load").catch(() => { });
146
- }
147
136
  for (;;) {
148
137
  if (Date.now() - startedAt > timeoutMs) {
149
138
  console.log(`[Killed timeout[${traverseId}] node=${lastHere ?? "?"} wallMs=${timeoutMs}]`);
@@ -162,7 +151,7 @@ export async function runTraverse(projectDir, options = {}) {
162
151
  }
163
152
  }
164
153
  const detected = await detectHere(page, library.navBlocks);
165
- const here = lastHere ?? detected ?? options.from ?? null;
154
+ const here = lastHere ?? detected ?? opts.from ?? null;
166
155
  if (here && (nodeVisits.get(here) ?? 0) >= maxVisitsPerNode) {
167
156
  console.log(`[Killed loop[${traverseId}] node=${here} visits=${nodeVisits.get(here)} max=${maxVisitsPerNode}]`);
168
157
  exitCode = 1;
@@ -171,7 +160,14 @@ export async function runTraverse(projectDir, options = {}) {
171
160
  const menu = await buildExploreMenu(page, graph, library, here);
172
161
  const candidates = menu.flat.filter((edge) => {
173
162
  const key = edgeKey(edge);
174
- return (edgeVisits.get(key) ?? 0) < maxVisitsPerEdge;
163
+ if ((edgeVisits.get(key) ?? 0) >= maxVisitsPerEdge)
164
+ return false;
165
+ if (parallel > 1) {
166
+ if (EdgeLeaseCoordinator.partitionIndex(key, parallel) !== workerIndex) {
167
+ return false;
168
+ }
169
+ }
170
+ return true;
175
171
  });
176
172
  if (candidates.length === 0) {
177
173
  const label = here ?? detected ?? "unknown";
@@ -186,6 +182,12 @@ export async function runTraverse(projectDir, options = {}) {
186
182
  return aWild - bWild;
187
183
  return edgeKey(a).localeCompare(edgeKey(b));
188
184
  })[0];
185
+ const key = edgeKey(pick);
186
+ if (!leases.tryClaim(key, traverseId)) {
187
+ // Another worker won a race (instance fan-out); treat as unavailable.
188
+ edgeVisits.set(key, maxVisitsPerEdge);
189
+ continue;
190
+ }
189
191
  const entry = library.byName.get(pick.block);
190
192
  if (!entry) {
191
193
  console.log(`[Broke at edge[${traverseId}] block=${pick.block} from=${here ?? "?"} to=${pick.to} reason=not-loaded]`);
@@ -204,7 +206,6 @@ export async function runTraverse(projectDir, options = {}) {
204
206
  const observed = typeof out === "object" && out && "__state" in out
205
207
  ? String(out.__state)
206
208
  : pick.to;
207
- const key = edgeKey(pick);
208
209
  edgeVisits.set(key, (edgeVisits.get(key) ?? 0) + 1);
209
210
  edgesHit.add(key);
210
211
  if (here)
@@ -224,8 +225,153 @@ export async function runTraverse(projectDir, options = {}) {
224
225
  }
225
226
  }
226
227
  finally {
227
- await browser.close().catch(() => { });
228
+ console.error(`waygraph traverse[${traverseId}]: done exit=${exitCode} steps=${steps} edgesHit=${edgesHit.size}`);
228
229
  }
229
- console.error(`waygraph traverse[${traverseId}]: done exit=${exitCode} steps=${steps} edgesHit=${edgesHit.size}`);
230
230
  return exitCode;
231
231
  }
232
+ /**
233
+ * Serial or parallel graph crawl. Prints greppable PASS/FAIL lines; non-zero on break.
234
+ */
235
+ export async function runTraverse(projectDir, options = {}) {
236
+ let parallel = Math.max(1, Math.floor(options.parallel ?? 1));
237
+ if (parallel > 4) {
238
+ console.error(`waygraph traverse: --parallel ${parallel} capped to 4`);
239
+ parallel = 4;
240
+ }
241
+ const session = options.session ?? (parallel > 1 ? "clone" : "clone");
242
+ if (parallel > 1 && session === "inherit") {
243
+ console.error("waygraph traverse: --session inherit is refused with --parallel > 1 (races); use --session clone");
244
+ return 1;
245
+ }
246
+ const maxSteps = options.maxSteps ?? 50;
247
+ const maxVisitsPerNode = options.maxVisitsPerNode ?? 2;
248
+ const maxVisitsPerEdge = options.maxVisitsPerEdge ?? 1;
249
+ const timeoutMs = options.timeoutMs ?? 15 * 60_000;
250
+ const headed = options.headed === true || process.env.WAYGRAPH_HEADED === "1";
251
+ const baseURL = options.baseURL ?? resolveBaseUrl(projectDir) ?? process.env.WAYGRAPH_BASE_URL;
252
+ const startUrl = options.startUrl ?? baseURL;
253
+ const { graph, library } = await buildExploreContext(projectDir, options.blocksSelect ? { blocksSelect: options.blocksSelect } : undefined);
254
+ if (options.blocksSelect) {
255
+ console.error(`waygraph traverse: --blocks ${options.blocksSelect.raw} -> ` +
256
+ `${library.byName.size} block(s), ${graph.edges.length} edge(s)`);
257
+ }
258
+ const leases = new EdgeLeaseCoordinator({ projectDir, persist: parallel > 1 });
259
+ const engine = new Engine({ headless: !headed, slowMo: headed ? 100 : 0 });
260
+ const { chromium } = await import("@playwright/test");
261
+ const executablePath = process.env.CHROME_PATH || process.env.CHROMIUM_PATH;
262
+ const launchOpts = {
263
+ headless: !headed,
264
+ };
265
+ if (executablePath)
266
+ launchOpts.executablePath = executablePath;
267
+ const browser = await chromium.launch(launchOpts);
268
+ console.error(`waygraph traverse: ${graph.nodes.length} node(s), ${graph.edges.length} edge(s)` +
269
+ (baseURL ? ` base=${baseURL}` : "") +
270
+ ` parallel=${parallel} session=${session}` +
271
+ ` maxSteps=${maxSteps} maxVisits/node=${maxVisitsPerNode} maxVisits/edge=${maxVisitsPerEdge}`);
272
+ try {
273
+ if (parallel <= 1) {
274
+ const contextOpts = {
275
+ viewport: { width: 1280, height: 720 },
276
+ };
277
+ if (baseURL)
278
+ contextOpts.baseURL = baseURL;
279
+ const context = await browser.newContext(contextOpts);
280
+ const page = await context.newPage();
281
+ const mem = new MemPage();
282
+ seedMem(library.byName, mem, options.data);
283
+ if (startUrl) {
284
+ await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
285
+ await page.waitForLoadState("load").catch(() => { });
286
+ }
287
+ const code = await runTraverseWorker({
288
+ traverseId: options.traverseId ?? "traverse-1",
289
+ workerIndex: 0,
290
+ parallel: 1,
291
+ maxSteps,
292
+ maxVisitsPerNode,
293
+ maxVisitsPerEdge,
294
+ timeoutMs,
295
+ ...(options.from ? { from: options.from } : {}),
296
+ ...(startUrl ? { startUrl } : {}),
297
+ ...(baseURL ? { baseURL } : {}),
298
+ ...(options.data ? { data: options.data } : {}),
299
+ graph,
300
+ library,
301
+ engine,
302
+ context,
303
+ page,
304
+ mem,
305
+ leases,
306
+ });
307
+ await context.close().catch(() => { });
308
+ return code;
309
+ }
310
+ // Phase D: bootstrap one context for storageState, then N clones.
311
+ const bootOpts = {
312
+ viewport: { width: 1280, height: 720 },
313
+ };
314
+ if (baseURL)
315
+ bootOpts.baseURL = baseURL;
316
+ const bootContext = await browser.newContext(bootOpts);
317
+ const bootPage = await bootContext.newPage();
318
+ const bootMem = new MemPage();
319
+ seedMem(library.byName, bootMem, options.data);
320
+ if (startUrl) {
321
+ await bootPage.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
322
+ await bootPage.waitForLoadState("load").catch(() => { });
323
+ }
324
+ const storageState = await bootContext.storageState();
325
+ const memSnap = snapshotMem(library.byName, bootMem);
326
+ await bootContext.close().catch(() => { });
327
+ console.error(`waygraph traverse: forked ${parallel} clone workers (storageState + mem snapshot)`);
328
+ const codes = await Promise.all(Array.from({ length: parallel }, async (_, i) => {
329
+ const traverseId = `traverse-${i + 1}`;
330
+ const ctxOpts = {
331
+ viewport: { width: 1280, height: 720 },
332
+ storageState,
333
+ };
334
+ if (baseURL)
335
+ ctxOpts.baseURL = baseURL;
336
+ const context = await browser.newContext(ctxOpts);
337
+ const page = await context.newPage();
338
+ const mem = new MemPage();
339
+ restoreMem(library.byName, mem, memSnap, options.data);
340
+ if (startUrl) {
341
+ await page.goto(startUrl, { waitUntil: "domcontentloaded" }).catch(() => { });
342
+ }
343
+ try {
344
+ return await runTraverseWorker({
345
+ traverseId,
346
+ workerIndex: i,
347
+ parallel,
348
+ maxSteps,
349
+ maxVisitsPerNode,
350
+ maxVisitsPerEdge,
351
+ timeoutMs,
352
+ ...(options.from ? { from: options.from } : {}),
353
+ ...(startUrl ? { startUrl } : {}),
354
+ ...(baseURL ? { baseURL } : {}),
355
+ ...(options.data ? { data: options.data } : {}),
356
+ graph,
357
+ library,
358
+ engine,
359
+ context,
360
+ page,
361
+ mem,
362
+ leases,
363
+ });
364
+ }
365
+ finally {
366
+ await context.close().catch(() => { });
367
+ }
368
+ }));
369
+ const worst = codes.reduce((a, b) => Math.max(a, b), 0);
370
+ const pass = codes.filter((c) => c === 0).length;
371
+ console.error(`waygraph traverse: suite parallel=${parallel} pass=${pass}/${parallel} exit=${worst}`);
372
+ return worst;
373
+ }
374
+ finally {
375
+ await browser.close().catch(() => { });
376
+ }
377
+ }
package/dist/types.d.ts CHANGED
@@ -209,6 +209,11 @@ export interface Block<In extends Checkpoint<string>, Out extends Checkpoint<str
209
209
  * @example requires: [LoginCreds.key]
210
210
  */
211
211
  requires?: readonly MemKey<any>[];
212
+ /**
213
+ * Demo pacing for this Block or compose group (`withBlockPace`).
214
+ * Demo/step only - `runGraph` ignores. Prefer `fastForwardComposeBlock` for blitz.
215
+ */
216
+ demoPace?: import("./highlights.js").DemoPace;
212
217
  /**
213
218
  * Optional. When this Block's real choices depend on the live page
214
219
  * (which of several items to add to cart, which of several rows to
@@ -0,0 +1,29 @@
1
+ {
2
+ "updatedAt": "2026-09-17T11:18:26.658Z",
3
+ "leases": {
4
+ "submit-login": {
5
+ "workerId": "traverse-2",
6
+ "claimedAt": 1789643826618
7
+ },
8
+ "add-all-to-cart": {
9
+ "workerId": "traverse-2",
10
+ "claimedAt": 1789643827327
11
+ },
12
+ "nav-cart": {
13
+ "workerId": "traverse-2",
14
+ "claimedAt": 1789643829570
15
+ },
16
+ "nav-continue-shopping": {
17
+ "workerId": "traverse-2",
18
+ "claimedAt": 1789643855282
19
+ },
20
+ "nav-item-detail::sauce-labs-backpack": {
21
+ "workerId": "traverse-2",
22
+ "claimedAt": 1789643857049
23
+ },
24
+ "nav-back-to-inventory": {
25
+ "workerId": "traverse-2",
26
+ "claimedAt": 1789643906658
27
+ }
28
+ }
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waygraph",
3
- "version": "0.12.13",
3
+ "version": "0.12.15",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",