waygraph 0.12.12 → 0.12.14

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
@@ -218,10 +218,21 @@ so it drops straight into `defineFlow([start, ..., composed, ..., end])` like an
218
218
  `fastForwardComposeBlock(name, steps)` is the same shape marked for **wall-clock
219
219
  fast-forward**: one opaque demo step that **blitzes** (no per-inner gate, no smooth
220
220
  cursor theater, Playwright slowMo off for that flow unless `WAYGRAPH_SLOWMO` is set)
221
- unless `waygraph demo --ff-expand`. Put **several** FF units in one Flow when you need
222
- to race past auth, then a heavy dashboard settle, then watch the interesting middle.
223
- Saucedemo `checkoutFlow` uses `ff-owner-auth` for the login prefix; `loginFlow` stays
224
- expanded for Sign In narration. See `docs/proposals/traverse-ffcompose-rfc.md`.
221
+ unless expanded. Put **several** FF units in one Flow when you need to race past auth,
222
+ then a heavy dashboard settle, then watch the interesting middle.
223
+
224
+ **Dispute / disable FF:** `waygraph demo --ff-disabled` (alias `--no-ff`) expands every
225
+ FFCompose into its inner Blocks so a broken login step is visible as its own panel row.
226
+ Former FF inners keep blitz pacing so wall-clock stays comparable to opaque FF (same
227
+ real acts). `--ff-expand` is the older expand-only flag (same flatten). Saucedemo
228
+ `checkoutFlow` uses `ff-owner-auth` for the login prefix; `loginFlow` stays expanded for
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`.
225
236
  `spawnTab(entry, page, mem)` drives a genuinely separate second tab through its own
226
237
  Block/Flow, in the same browser context an existing `page` already belongs to - the
227
238
  pattern `observe()` could already reach for (it's the only phase allowed to touch
@@ -251,6 +251,8 @@ export async function buildExploreMenu(page, graph, library, here) {
251
251
  if (!(await navRunnable(page, entry)))
252
252
  continue;
253
253
  if (here === null) {
254
+ // Bootstrap only: URL deep-links as "Start here". Click-nav needs a live
255
+ // control so it cannot seed an unknown page.
254
256
  if (!isUrlNav(entry))
255
257
  continue;
256
258
  push(navigate, edge);
@@ -258,6 +260,12 @@ export async function buildExploreMenu(page, graph, library, here) {
258
260
  }
259
261
  if (edge.to === here)
260
262
  continue;
263
+ // Known screen: do NOT dump every defineNavBlock({ url }) under Navigate.
264
+ // Graph still marks NavBlocks from:"*" for pathfinding/traverse; auto menu
265
+ // only offers click-nav whose control is visible on this page (navRunnable).
266
+ // Otherwise LoginForm lists every app route + mailpit as if legal moves.
267
+ if (isUrlNav(entry))
268
+ continue;
261
269
  if (isBackEdge(edge) && (edge.from === here || edge.from === "*")) {
262
270
  push(back, edge);
263
271
  }
package/dist/cli.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Primary verbs (less is more):
6
6
  * auto Explore picker; `.flow.ts` / `--blocks From To` = run that path
7
- * demo Watch (step overlay); `--blocks` `--data` `--auto-next` `--fast` `--full` `--ff-expand`
7
+ * demo Watch (step overlay); `--blocks` `--data` `--auto-next` `--fast` `--full` `--ff-expand` `--ff-disabled`
8
8
  * run Execute; `--blocks` `--data` `--non-headless` `--video`
9
9
  *
10
10
  * Also: list / nav / validate / check / graph / init / agent-dive / traverse / try
package/dist/cli.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Primary verbs (less is more):
6
6
  * auto Explore picker; `.flow.ts` / `--blocks From To` = run that path
7
- * demo Watch (step overlay); `--blocks` `--data` `--auto-next` `--fast` `--full` `--ff-expand`
7
+ * demo Watch (step overlay); `--blocks` `--data` `--auto-next` `--fast` `--full` `--ff-expand` `--ff-disabled`
8
8
  * run Execute; `--blocks` `--data` `--non-headless` `--video`
9
9
  *
10
10
  * Also: list / nav / validate / check / graph / init / agent-dive / traverse / try
@@ -2170,11 +2170,11 @@ async function runStepMode(engine, start, end, context, page, mem, resolved, slo
2170
2170
  if ((clearSession || r.resetSession) && i > 0) {
2171
2171
  await resetPageState(context, page, baseURL);
2172
2172
  }
2173
- // gatesFast: --fast (whole run) or FFCompose opaque steps (one short gate).
2174
- // skipTheater: WAYGRAPH_FAST_BLOCKS **or** FFCompose — wall-clock blitz
2175
- // (overlay delays). --fast alone never sets skipTheater.
2176
- pacing.gatesFast = demoFast || !!r.block.fastForward;
2177
- pacing.skipTheater = fastBlockNames.has(r.block.name) || !!r.block.fastForward;
2173
+ // 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;
2176
+ pacing.skipTheater =
2177
+ fastBlockNames.has(r.block.name) || !!r.block.fastForward || !!r.wasFastForward;
2178
2178
  const fixtures = r.highlightFixtures;
2179
2179
  stubBeforeRef.current = resolveHighlightSlots(r.block, "stubBefore", { fixtures });
2180
2180
  const isNavBlock = r.block.__waygraphKind === "nav";
@@ -2484,7 +2484,9 @@ async function main() {
2484
2484
  // truly unexpected failure outside runJsonReportMode's own try/catch)
2485
2485
  // reaches stdout.
2486
2486
  const jsonReport = process.env.WAYGRAPH_JSON === "1";
2487
- const ffExpand = process.env.WAYGRAPH_FF_EXPAND === "1";
2487
+ const ffExpand =
2488
+ process.env.WAYGRAPH_FF_EXPAND === "1" || process.env.WAYGRAPH_FF_DISABLED === "1";
2489
+ const ffDisabled = process.env.WAYGRAPH_FF_DISABLED === "1";
2488
2490
  /** Expand or keep fastForwardComposeBlock units for demo/run step lists. */
2489
2491
  const flattenBlockInfos = (blockInfos) => {
2490
2492
  const out = [];
@@ -2497,6 +2499,10 @@ async function main() {
2497
2499
  block: step.block,
2498
2500
  ...(step.routes ? { routes: step.routes } : {}),
2499
2501
  ...(bi.resetSessionBefore ? { resetSessionBefore: true } : {}),
2502
+ // Dispute / --ff-disabled: keep blitz pacing on former FF inners so
2503
+ // wall-clock stays comparable to opaque FF (same real acts).
2504
+ wasFastForward: true,
2505
+ ffSource: b.name,
2500
2506
  });
2501
2507
  }
2502
2508
  } else {
@@ -2529,14 +2535,20 @@ async function main() {
2529
2535
  flow.expectedFailureReason && idx === blockInfos.length - 1
2530
2536
  ? flow.expectedFailureReason
2531
2537
  : undefined,
2538
+ ...(bi.wasFastForward ? { wasFastForward: true, ffSource: bi.ffSource } : {}),
2532
2539
  }));
2533
2540
  chainFlows = [flow];
2534
2541
  if (!jsonReport) {
2535
2542
  console.log(
2536
- "waygraph: running existing flow \\"" + bareRef + "\\" - " +
2543
+ 'waygraph: running existing flow "' + bareRef + '" - ' +
2537
2544
  resolved.map((r) => r.block.name).join(" -> ") + " (" + resolved.length + " block" +
2538
2545
  (resolved.length === 1 ? "" : "s") + ", no chain spec needed)",
2539
2546
  );
2547
+ if (ffDisabled) {
2548
+ console.error(
2549
+ "waygraph demo: --ff-disabled - FFCompose expanded to inners (dispute / step locus); blitz pacing kept on former FF steps",
2550
+ );
2551
+ }
2540
2552
  }
2541
2553
  }
2542
2554
  }
@@ -2623,6 +2635,7 @@ async function main() {
2623
2635
  expectedFailureReason:
2624
2636
  meta.expectedFailureReason && remainingInFlow === 1 ? meta.expectedFailureReason : undefined,
2625
2637
  seedMem: isFirstOfSegment ? meta.seedMem : undefined,
2638
+ ...(bi.wasFastForward ? { wasFastForward: true, ffSource: bi.ffSource } : {}),
2626
2639
  };
2627
2640
  remainingInFlow -= 1;
2628
2641
  isFirstOfSegment = false;
@@ -2644,21 +2657,27 @@ async function main() {
2644
2657
  // nobody has to watch a live window for it to be right).
2645
2658
  const headed =
2646
2659
  process.env.WAYGRAPH_HEADED === "0" ? false : process.env.WAYGRAPH_HEADED === "1" || step;
2647
- // Step mode defaults to a visible Playwright pace (slowMo 350) UNLESS the
2648
- // flow has an opaque fastForwardComposeBlock — then default slowMo 0 so FF
2649
- // auth/seed actually blitzes (<10s target) instead of only hiding the cursor
2650
- // while every click still pays 350ms. Non-FF steps keep overlay theater
2651
- // (cursor/pop) for watchability. Explicit WAYGRAPH_SLOWMO always wins.
2652
- const hasOpaqueFf = resolved.some(
2653
- (r) => r && r.block && r.block.fastForward === true,
2660
+ // Step mode: blitz Playwright slowMo when any opaque FF **or** former-FF
2661
+ // inners (--ff-disabled) are in the resolved list. Explicit WAYGRAPH_SLOWMO wins.
2662
+ const hasFfBlitz = resolved.some(
2663
+ (r) =>
2664
+ r &&
2665
+ ((r.block && r.block.fastForward === true) || r.wasFastForward === true),
2654
2666
  );
2655
2667
  const slowMo = process.env.WAYGRAPH_SLOWMO !== undefined
2656
2668
  ? Number(process.env.WAYGRAPH_SLOWMO)
2657
2669
  : step
2658
- ? hasOpaqueFf
2670
+ ? hasFfBlitz
2659
2671
  ? 0
2660
2672
  : 350
2661
2673
  : undefined;
2674
+ if (step && hasFfBlitz && process.env.WAYGRAPH_SLOWMO === undefined && !jsonReport) {
2675
+ console.error(
2676
+ 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",
2679
+ );
2680
+ }
2662
2681
  const baseURL = process.env.WAYGRAPH_BASE_URL;
2663
2682
  const title = process.env.WAYGRAPH_TITLE;
2664
2683
  // Comma-separated Block names (their real .name, e.g. "login") that
@@ -3355,6 +3374,10 @@ function parseRunFlags(argv) {
3355
3374
  else if (a === "--ff-expand") {
3356
3375
  out.ffExpand = true;
3357
3376
  }
3377
+ else if (a === "--ff-disabled" || a === "--no-ff") {
3378
+ out.ffDisabled = true;
3379
+ out.ffExpand = true;
3380
+ }
3358
3381
  else if (a === "--non-headless") {
3359
3382
  out.nonHeadless = true;
3360
3383
  }
@@ -3516,6 +3539,10 @@ function applyRunFlags(flags, opts) {
3516
3539
  if (flags.ffExpand) {
3517
3540
  process.env.WAYGRAPH_FF_EXPAND = "1";
3518
3541
  }
3542
+ if (flags.ffDisabled) {
3543
+ process.env.WAYGRAPH_FF_DISABLED = "1";
3544
+ process.env.WAYGRAPH_FF_EXPAND = "1";
3545
+ }
3519
3546
  }
3520
3547
  /**
3521
3548
  * BASE_URL when neither --base-url nor WAYGRAPH_BASE_URL is set:
@@ -3674,6 +3701,7 @@ Primary (less is more):
3674
3701
  --fast Shorter auto-next / Next gates (keeps smooth cursor)
3675
3702
  --full Classic wrap-all block chips (default: carousel)
3676
3703
  --ff-expand Expand fastForwardComposeBlock inners as separate steps
3704
+ --ff-disabled Dispute: expand FF (alias --no-ff); blitz kept on those inners
3677
3705
  --auto-play-video Unattended + recorded: --auto-next + --video (+ step); headless by default
3678
3706
  --auto-play-video-head Same, but keep the browser visible (--non-headless)
3679
3707
  --video-viewport WxH Recording size (default demo: 1920x1080)
@@ -3684,11 +3712,14 @@ Primary (less is more):
3684
3712
  --video [dir] Record .webm
3685
3713
  --video-viewport WxH Recording size (default run: 1280x720)
3686
3714
  --ff-expand Same as demo (expand FFCompose inners)
3715
+ --ff-disabled Same as demo (dispute: expand FF)
3687
3716
 
3688
3717
  Also:
3689
3718
  waygraph list | nav | validate | check | graph | init <name>
3690
- waygraph traverse [project] Serial graph crawl (Phase B+)
3719
+ waygraph traverse [project] Graph crawl (Phase B+D)
3691
3720
  --blocks <glob|/re/|sub> Phase C: filter *.block.ts discovery
3721
+ --parallel N Phase D: N clone workers (max 4)
3722
+ --session clone|inherit Phase D: default clone
3692
3723
  --from <Checkpoint> Seed / start checkpoint
3693
3724
  --data '{...}' Mem seed
3694
3725
  --max-steps N Cap Block runs (default 50)
@@ -3714,6 +3745,7 @@ Examples:
3714
3745
  waygraph auto --blocks LoginPage OrderComplete
3715
3746
  waygraph auto --blocks '/mailpit/' # Phase C filtered explore
3716
3747
  waygraph traverse --blocks '**/mailpit/**/*.block.ts' # Phase C filtered crawl
3748
+ waygraph traverse --parallel 2 --session clone # Phase D clone workers
3717
3749
  waygraph run --blocks "loginFlow then add-all-to-cart" --data '{...}' --video
3718
3750
 
3719
3751
  Aliases (compat): \`chain <spec>\` -> run --blocks; \`chain auto A B\` -> auto --blocks A B;
@@ -3808,6 +3840,8 @@ async function main() {
3808
3840
  let headed = false;
3809
3841
  let baseUrl;
3810
3842
  let blocksFilter;
3843
+ let parallel;
3844
+ let session;
3811
3845
  let projectDir = process.cwd();
3812
3846
  for (let i = 0; i < rest.length; i++) {
3813
3847
  const a = rest[i];
@@ -3833,6 +3867,20 @@ async function main() {
3833
3867
  }
3834
3868
  continue;
3835
3869
  }
3870
+ if (a === "--parallel" || a.startsWith("--parallel=")) {
3871
+ const v = a.startsWith("--parallel=") ? a.slice("--parallel=".length) : rest[++i];
3872
+ parallel = Number(v);
3873
+ continue;
3874
+ }
3875
+ if (a === "--session" || a.startsWith("--session=")) {
3876
+ const v = a.startsWith("--session=") ? a.slice("--session=".length) : rest[++i];
3877
+ if (v !== "clone" && v !== "inherit") {
3878
+ console.error('waygraph traverse: --session must be "clone" or "inherit"');
3879
+ process.exit(1);
3880
+ }
3881
+ session = v;
3882
+ continue;
3883
+ }
3836
3884
  if (a === "--max-steps" || a.startsWith("--max-steps=")) {
3837
3885
  const v = a.startsWith("--max-steps=") ? a.slice(12) : rest[++i];
3838
3886
  maxSteps = Number(v);
@@ -3861,13 +3909,15 @@ async function main() {
3861
3909
  if (a === "--help" || a === "-h") {
3862
3910
  console.log(`waygraph traverse [project] [flags]
3863
3911
 
3864
- Serial graph crawl (RFC Phase B). Walks unused legal edges until a leaf,
3865
- budget kill, or first broken edge.
3912
+ Graph crawl (RFC Phase B+D). Walks unused legal edges until a leaf,
3913
+ budget kill, or first broken edge. --parallel N uses session clone + edge leases.
3866
3914
 
3867
3915
  --blocks <glob|/regex/|substr> Phase C: filter *.block.ts discovery
3916
+ --parallel N Phase D: N clone workers (default 1, max 4)
3917
+ --session clone|inherit Phase D: default clone; inherit refused if parallel>1
3868
3918
  --from <Checkpoint> start checkpoint (optional)
3869
3919
  --data '{...}' Mem seed JSON
3870
- --max-steps N default 50
3920
+ --max-steps N default 50 (per worker)
3871
3921
  --max-visits N per-node visit cap (default 2)
3872
3922
  --max-visits-per-edge N default 1
3873
3923
  --non-headless show browser
@@ -3875,7 +3925,7 @@ budget kill, or first broken edge.
3875
3925
 
3876
3926
  Examples:
3877
3927
  waygraph traverse --blocks '**/mailpit/**/*.block.ts'
3878
- waygraph traverse --blocks '/mailpit|login/'
3928
+ waygraph traverse --parallel 2 --session clone --max-steps 20
3879
3929
 
3880
3930
  PASS: [Reached Leaf Node[traverse-1] at=... steps=N]
3881
3931
  FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
@@ -3906,6 +3956,8 @@ FAIL: [Broke at edge[traverse-1] block=... from=... to=...]
3906
3956
  headed,
3907
3957
  ...(baseUrl ? { baseURL: baseUrl } : {}),
3908
3958
  ...(blocksSelect ? { blocksSelect } : {}),
3959
+ ...(parallel !== undefined && Number.isFinite(parallel) ? { parallel } : {}),
3960
+ ...(session ? { session } : {}),
3909
3961
  });
3910
3962
  process.exit(code);
3911
3963
  }
@@ -4006,7 +4058,7 @@ Agents shipped: waygraph-planner, waygraph-author, waygraph-healer.
4006
4058
  console.error("waygraph demo: missing flow/spec — e.g.\n" +
4007
4059
  " waygraph demo src/flows/shop.flow.ts\n" +
4008
4060
  " waygraph demo --blocks shopFlow\n" +
4009
- " Flags: --blocks --data --auto-next --fast --full --ff-expand --auto-play-video --title --base-url --video");
4061
+ " Flags: --blocks --data --auto-next --fast --full --ff-expand --ff-disabled --auto-play-video --title --base-url --video");
4010
4062
  process.exit(1);
4011
4063
  }
4012
4064
  const proj = flags.blocks
package/dist/index.d.ts CHANGED
@@ -11,3 +11,6 @@ export { discoverGraph, toMermaid, findOrphanBlocks, findBlockPath } from "./gra
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
@@ -5,3 +5,5 @@ export { runGraph, spawnTab, narrate, Engine, start, end, branch, withVerify, mo
5
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";
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";
@@ -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
+ }
@@ -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.12",
3
+ "version": "0.12.14",
4
4
  "type": "module",
5
5
  "description": "A typed graph of reusable Blocks for driving a browser through E2E flows.",
6
6
  "license": "MIT",