@dikshanty94/webnav 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +283 -0
  3. package/bin/webnav +18 -0
  4. package/dist/agent/loop.d.ts +59 -0
  5. package/dist/agent/loop.js +267 -0
  6. package/dist/agent/server.d.ts +60 -0
  7. package/dist/agent/server.js +254 -0
  8. package/dist/cli-help.d.ts +3 -0
  9. package/dist/cli-help.js +111 -0
  10. package/dist/cli-spec.d.ts +24 -0
  11. package/dist/cli-spec.js +460 -0
  12. package/dist/cli.d.ts +208 -0
  13. package/dist/cli.js +2558 -0
  14. package/dist/contract.d.ts +20 -0
  15. package/dist/contract.js +15 -0
  16. package/dist/creds.d.ts +46 -0
  17. package/dist/creds.js +126 -0
  18. package/dist/dashboard/server.d.ts +137 -0
  19. package/dist/dashboard/server.js +395 -0
  20. package/dist/dashboard/shell.d.ts +1 -0
  21. package/dist/dashboard/shell.js +1343 -0
  22. package/dist/explorer/analyse.d.ts +25 -0
  23. package/dist/explorer/analyse.js +39 -0
  24. package/dist/explorer/diff.d.ts +11 -0
  25. package/dist/explorer/diff.js +22 -0
  26. package/dist/explorer/draft.d.ts +81 -0
  27. package/dist/explorer/draft.js +1658 -0
  28. package/dist/explorer/fingerprint-page.d.ts +11 -0
  29. package/dist/explorer/fingerprint-page.js +19 -0
  30. package/dist/explorer/fingerprint.d.ts +14 -0
  31. package/dist/explorer/fingerprint.js +18 -0
  32. package/dist/explorer/infer.d.ts +85 -0
  33. package/dist/explorer/infer.js +370 -0
  34. package/dist/explorer/pattern-propose.d.ts +27 -0
  35. package/dist/explorer/pattern-propose.js +81 -0
  36. package/dist/explorer/patterns.d.ts +49 -0
  37. package/dist/explorer/patterns.js +290 -0
  38. package/dist/explorer/shadow.d.ts +3 -0
  39. package/dist/explorer/shadow.js +95 -0
  40. package/dist/graph/coverage.d.ts +33 -0
  41. package/dist/graph/coverage.js +155 -0
  42. package/dist/graph/edit.d.ts +47 -0
  43. package/dist/graph/edit.js +211 -0
  44. package/dist/graph/frontier.d.ts +24 -0
  45. package/dist/graph/frontier.js +95 -0
  46. package/dist/graph/seed.d.ts +23 -0
  47. package/dist/graph/seed.js +29 -0
  48. package/dist/graph/show.d.ts +8 -0
  49. package/dist/graph/show.js +6 -0
  50. package/dist/graph/teach.d.ts +8 -0
  51. package/dist/graph/teach.js +10 -0
  52. package/dist/hosted.d.ts +36 -0
  53. package/dist/hosted.js +69 -0
  54. package/dist/index.d.ts +2 -0
  55. package/dist/index.js +4 -0
  56. package/dist/mapstore/record.d.ts +128 -0
  57. package/dist/mapstore/record.js +224 -0
  58. package/dist/mapstore/schema.sql +47 -0
  59. package/dist/mapstore/store.d.ts +108 -0
  60. package/dist/mapstore/store.js +399 -0
  61. package/dist/mapstore/types.d.ts +87 -0
  62. package/dist/mapstore/types.js +29 -0
  63. package/dist/mcp/server.d.ts +46 -0
  64. package/dist/mcp/server.js +163 -0
  65. package/dist/paths.d.ts +10 -0
  66. package/dist/paths.js +33 -0
  67. package/dist/playwright/adapter.d.ts +62 -0
  68. package/dist/playwright/adapter.js +145 -0
  69. package/dist/playwright/ax-adapter.d.ts +33 -0
  70. package/dist/playwright/ax-adapter.js +76 -0
  71. package/dist/playwright/capture.d.ts +2 -0
  72. package/dist/playwright/capture.js +10 -0
  73. package/dist/playwright/fingerprint.d.ts +40 -0
  74. package/dist/playwright/fingerprint.js +139 -0
  75. package/dist/playwright/profile-lock.d.ts +15 -0
  76. package/dist/playwright/profile-lock.js +51 -0
  77. package/dist/playwright/sessions.d.ts +72 -0
  78. package/dist/playwright/sessions.js +229 -0
  79. package/dist/playwright/snapshot.d.ts +10 -0
  80. package/dist/playwright/snapshot.js +52 -0
  81. package/dist/playwright/throttle.d.ts +14 -0
  82. package/dist/playwright/throttle.js +76 -0
  83. package/dist/playwright/video.d.ts +13 -0
  84. package/dist/playwright/video.js +32 -0
  85. package/dist/protocol.d.ts +49 -0
  86. package/dist/protocol.js +3 -0
  87. package/dist/recorder/agent-session.d.ts +59 -0
  88. package/dist/recorder/agent-session.js +230 -0
  89. package/dist/recorder/capture-loop.d.ts +19 -0
  90. package/dist/recorder/capture-loop.js +40 -0
  91. package/dist/recorder/coverage.d.ts +22 -0
  92. package/dist/recorder/coverage.js +49 -0
  93. package/dist/recorder/hover-probe.d.ts +35 -0
  94. package/dist/recorder/hover-probe.js +100 -0
  95. package/dist/recorder/ingest.d.ts +39 -0
  96. package/dist/recorder/ingest.js +138 -0
  97. package/dist/recorder/live-record.d.ts +41 -0
  98. package/dist/recorder/live-record.js +255 -0
  99. package/dist/recorder/live.d.ts +53 -0
  100. package/dist/recorder/live.js +400 -0
  101. package/dist/recorder/probe.d.ts +14 -0
  102. package/dist/recorder/probe.js +33 -0
  103. package/dist/recorder/replay.d.ts +60 -0
  104. package/dist/recorder/replay.js +401 -0
  105. package/dist/recorder/review.d.ts +73 -0
  106. package/dist/recorder/review.js +171 -0
  107. package/dist/recorder/snapshot-dom.d.ts +7 -0
  108. package/dist/recorder/snapshot-dom.js +25 -0
  109. package/dist/router/auth-status.d.ts +20 -0
  110. package/dist/router/auth-status.js +55 -0
  111. package/dist/router/browse.d.ts +108 -0
  112. package/dist/router/browse.js +214 -0
  113. package/dist/router/catalog.d.ts +18 -0
  114. package/dist/router/catalog.js +6 -0
  115. package/dist/router/extract-content.d.ts +13 -0
  116. package/dist/router/extract-content.js +87 -0
  117. package/dist/router/live-extension-browser.d.ts +35 -0
  118. package/dist/router/live-extension-browser.js +175 -0
  119. package/dist/router/path.d.ts +7 -0
  120. package/dist/router/path.js +55 -0
  121. package/dist/router/read.d.ts +26 -0
  122. package/dist/router/read.js +30 -0
  123. package/dist/router/readiness.d.ts +9 -0
  124. package/dist/router/readiness.js +55 -0
  125. package/dist/router/replay.d.ts +14 -0
  126. package/dist/router/replay.js +23 -0
  127. package/dist/router/resolve.d.ts +21 -0
  128. package/dist/router/resolve.js +33 -0
  129. package/dist/router/search-live.d.ts +21 -0
  130. package/dist/router/search-live.js +98 -0
  131. package/dist/router/search-providers.d.ts +5 -0
  132. package/dist/router/search-providers.js +12 -0
  133. package/dist/router/search.d.ts +6 -0
  134. package/dist/router/search.js +62 -0
  135. package/dist/router/suite.d.ts +69 -0
  136. package/dist/router/suite.js +226 -0
  137. package/dist/router/tokens.d.ts +13 -0
  138. package/dist/router/tokens.js +26 -0
  139. package/dist/router/walk-live.d.ts +57 -0
  140. package/dist/router/walk-live.js +308 -0
  141. package/dist/router/walk-session.d.ts +49 -0
  142. package/dist/router/walk-session.js +90 -0
  143. package/dist/router/walk.d.ts +48 -0
  144. package/dist/router/walk.js +384 -0
  145. package/package.json +59 -0
@@ -0,0 +1,308 @@
1
+ import { PlaywrightAdapter } from '../playwright/adapter.js';
2
+ import { MapStore } from '../mapstore/store.js';
3
+ import { parseSnapshot, findByRoleAndName } from '../playwright/snapshot.js';
4
+ import { makeState, makeAffordance } from '../mapstore/types.js';
5
+ import { walkRoute } from './walk.js';
6
+ /**
7
+ * Seed the `www.saucedemo.com` page-states + navigation edges inline (STRUCTURE
8
+ * ONLY, principle #6 — no credentials/names/zips; those are runtime, supplied by
9
+ * the live browser closure). This replaces the old hand-seeded saucedemo skeleton:
10
+ * saucedemo is now an agent-built `www.saucedemo.com` graph, and the multi-step
11
+ * walk seeds its interior inline here for the live wiring + gated e2es.
12
+ *
13
+ * Idempotent: upsertState (ON CONFLICT id) and upsertEdge (UNIQUE from,to,step).
14
+ */
15
+ export function seedSaucedemoForWalk(store) {
16
+ const N = 'www.saucedemo.com';
17
+ store.transaction(() => {
18
+ // The site-node row (so getNode works — needed by the dashboard, the hosted
19
+ // map pack, and /api/maps). The interior states below are its skeleton.
20
+ store.upsertNode({ id: N, homeUrl: 'https://www.saucedemo.com/' });
21
+ // Each state's REPERTOIRE is the source of truth (spec §8). navigate/reveal
22
+ // affordances with a toState project into the walk's edges; mutate/input never
23
+ // route (they're fired by the agent at a pause if it wants them). The "Finish"
24
+ // affordance is commit:true → it projects as a commit-point and is NEVER fired (#2).
25
+ const states = [
26
+ makeState({ id: `${N}:login`, nodeId: N, semanticName: `${N}:login`,
27
+ urlPattern: 'https://www.saucedemo.com/', role: 'detail',
28
+ fingerprint: ['textbox:Username', 'button:Login'],
29
+ affordances: [
30
+ makeAffordance({ id: 'aff_username', label: 'enter Username', kind: 'input' }),
31
+ makeAffordance({ id: 'aff_password', label: 'enter Password', kind: 'input' }),
32
+ makeAffordance({ id: 'aff_login', label: 'log in by clicking "Login"', kind: 'navigate',
33
+ toState: `${N}:inventory`, needs: ['aff_username', 'aff_password'], acceptsInput: 'credentials', core: true }),
34
+ ] }),
35
+ makeState({ id: `${N}:inventory`, nodeId: N, semanticName: `${N}:inventory`,
36
+ urlPattern: '*inventory*', role: 'detail', fingerprint: ['button:Add to cart'],
37
+ affordances: [
38
+ makeAffordance({ id: 'aff_cart', label: 'open the shopping cart', kind: 'navigate',
39
+ toState: `${N}:cart`, addressableUrl: 'https://www.saucedemo.com/cart.html', core: true }),
40
+ makeAffordance({ id: 'aff_sort', label: 'sort products', kind: 'mutate' }),
41
+ makeAffordance({ id: 'aff_addcart', label: 'add an item to the cart', kind: 'mutate' }),
42
+ makeAffordance({ id: 'aff_menu', label: 'open the burger menu', kind: 'reveal', children: [
43
+ makeAffordance({ id: 'aff_allitems', label: 'All Items', kind: 'navigate', toState: `${N}:inventory` }),
44
+ makeAffordance({ id: 'aff_about', label: 'About', kind: 'navigate', toState: null }), // unexplored (offsite)
45
+ makeAffordance({ id: 'aff_logout', label: 'Logout', kind: 'navigate', toState: `${N}:login` }),
46
+ makeAffordance({ id: 'aff_reset', label: 'Reset App State', kind: 'mutate' }),
47
+ ] }),
48
+ ] }),
49
+ makeState({ id: `${N}:cart`, nodeId: N, semanticName: `${N}:cart`,
50
+ urlPattern: '*cart*', role: 'detail', fingerprint: ['button:Checkout'],
51
+ affordances: [
52
+ makeAffordance({ id: 'aff_checkout', label: 'click "Checkout"', kind: 'navigate',
53
+ toState: `${N}:checkout-info`, core: true }),
54
+ makeAffordance({ id: 'aff_continue_shopping', label: 'Continue Shopping', kind: 'navigate',
55
+ toState: `${N}:inventory` }),
56
+ ] }),
57
+ makeState({ id: `${N}:checkout-info`, nodeId: N, semanticName: `${N}:checkout-info`,
58
+ urlPattern: '*checkout-step-one*', role: 'detail', fingerprint: ['textbox:First Name', 'button:Continue'],
59
+ affordances: [
60
+ makeAffordance({ id: 'aff_first', label: 'enter First Name', kind: 'input' }),
61
+ makeAffordance({ id: 'aff_last', label: 'enter Last Name', kind: 'input' }),
62
+ makeAffordance({ id: 'aff_zip', label: 'enter Zip/Postal Code', kind: 'input' }),
63
+ makeAffordance({ id: 'aff_continue', label: 'click "Continue"', kind: 'navigate',
64
+ toState: `${N}:checkout-overview`, needs: ['aff_first', 'aff_last', 'aff_zip'], acceptsInput: 'shipping', core: true }),
65
+ ] }),
66
+ makeState({ id: `${N}:checkout-overview`, nodeId: N, semanticName: `${N}:checkout-overview`,
67
+ urlPattern: '*checkout-step-two*', role: 'detail', fingerprint: ['button:Finish'],
68
+ affordances: [
69
+ makeAffordance({ id: 'aff_finish', label: 'click "Finish"', kind: 'navigate',
70
+ toState: `${N}:purchase-complete`, commit: true }),
71
+ ] }),
72
+ ];
73
+ for (const s of states)
74
+ store.upsertState(s);
75
+ });
76
+ }
77
+ /**
78
+ * Build a live WalkBrowser over a playwright adapter, resolving each edge's input
79
+ * slot from `inputs` at fill time. `inputs` is held only in memory here — never
80
+ * persisted. Shared by runWalkLive and the walk / walk-resume CLI verbs.
81
+ *
82
+ * `browserOpts` (the SAME launch opts — incl. `profile` — the caller opened
83
+ * `adapter` with) enables the fresh-session SSO-wall retry (design item 2):
84
+ * when supplied, the returned WalkBrowser gets `currentUrl`/`reopenFresh` so
85
+ * walkRoute can close this session and open a brand-new one under the SAME
86
+ * profile on a wall. Omit it (as the saucedemo dry-run wiring below does) to
87
+ * leave those capabilities off — the walk simply never attempts the retry.
88
+ */
89
+ export function makeLiveWalkBrowser(adapter, inputs, browserOpts, initialSessionId) {
90
+ let live = adapter;
91
+ let liveSessionId = initialSessionId ?? '';
92
+ let lastSnapshot = '';
93
+ async function fieldRef(name) {
94
+ let nodes = parseSnapshot(lastSnapshot);
95
+ let node = findByRoleAndName(nodes, 'textbox', name);
96
+ if (!node || !node.ref) {
97
+ lastSnapshot = await live.snapshot();
98
+ nodes = parseSnapshot(lastSnapshot);
99
+ node = findByRoleAndName(nodes, 'textbox', name);
100
+ }
101
+ if (!node || !node.ref)
102
+ throw new Error('walk: could not resolve textbox "' + name + '"');
103
+ return node.ref;
104
+ }
105
+ const browser = {
106
+ snapshot: async () => {
107
+ lastSnapshot = await live.snapshot();
108
+ return lastSnapshot;
109
+ },
110
+ callCount: () => live.callCount,
111
+ goto: async (url) => { await live.goto(url); },
112
+ waitMs: (ms) => new Promise((r) => setTimeout(r, ms)),
113
+ act: async (ref, inputSlot) => {
114
+ if (inputSlot === 'credentials') {
115
+ await live.fill(await fieldRef('Username'), inputs.username);
116
+ await live.fill(await fieldRef('Password'), inputs.password);
117
+ await live.click(ref);
118
+ return;
119
+ }
120
+ if (inputSlot === 'shipping') {
121
+ await live.fill(await fieldRef('First Name'), inputs.firstName ?? 'A');
122
+ await live.fill(await fieldRef('Last Name'), inputs.lastName ?? 'B');
123
+ await live.fill(await fieldRef('Zip/Postal Code'), inputs.zip);
124
+ await live.click(ref);
125
+ return;
126
+ }
127
+ await live.click(ref);
128
+ },
129
+ };
130
+ if (browserOpts) {
131
+ browser.currentUrl = () => live.currentUrl();
132
+ browser.close = () => live.close().then(() => undefined);
133
+ browser.sessionId = () => liveSessionId;
134
+ // Close the walled session, open a FRESH one under the SAME profile (same
135
+ // identity/cookies — equivalent to the user reopening a tab; NOT evasion),
136
+ // land back on `url`. Never throws on the close (best-effort cleanup).
137
+ browser.reopenFresh = async (url) => {
138
+ await live.close().catch(() => { });
139
+ liveSessionId = 'w-' + Date.now();
140
+ live = new PlaywrightAdapter(liveSessionId, undefined, undefined, browserOpts);
141
+ await live.open(url);
142
+ lastSnapshot = await live.snapshot();
143
+ return lastSnapshot;
144
+ };
145
+ }
146
+ return browser;
147
+ }
148
+ /**
149
+ * Live wiring for the saucedemo multi-step walk (increment W2).
150
+ *
151
+ * Drives the REAL PlaywrightAdapter against live saucedemo.com through the SAME
152
+ * async `walkRoute` loop the unit test exercises (zero duplicated loop logic).
153
+ *
154
+ * The walk is runtime-value-free: it only passes each edge's `acceptsInput` slot
155
+ * NAME to `act`. THIS closure owns the `inputs` map and resolves slot -> value(s)
156
+ * when filling fields (principle #6: inputs are runtime, never stored as map).
157
+ *
158
+ * Goal is `www.saucedemo.com:checkout-overview` (a PASS-THROUGH state), so the walk
159
+ * HALTS there and returns `done` WITHOUT ever attempting the next edge — the
160
+ * unclassified "Finish" commit point is never fired (principle #2).
161
+ */
162
+ export async function runWalkLive(inputs, dbPath) {
163
+ // 1. File-backed MapStore; DB is authoritative — the saucedemo interior is written
164
+ // inline here (saucedemo is no longer part of the seeded graph). If it's absent,
165
+ // the walk seeds it once (the single bootstrap).
166
+ const store = new MapStore(dbPath);
167
+ if (!store.getState('www.saucedemo.com:checkout-overview')) {
168
+ seedSaucedemoForWalk(store);
169
+ }
170
+ // 2. Open a real browser session on the saucedemo login page.
171
+ const adapter = new PlaywrightAdapter('sd-' + Date.now());
172
+ await adapter.open('https://www.saucedemo.com/');
173
+ // Cache the most recent snapshot so `act` can resolve input fields by role+name
174
+ // WITHOUT an extra playwright call. The walk always snapshots immediately before
175
+ // calling act() (the top-of-loop read), so `lastSnapshot` is the current page.
176
+ let lastSnapshot = '';
177
+ // Resolve a textbox ref by its accessible name on the current (cached) page.
178
+ // Falls back to a fresh snapshot if the cache is somehow empty (counts as a call).
179
+ async function fieldRef(name) {
180
+ let nodes = parseSnapshot(lastSnapshot);
181
+ let node = findByRoleAndName(nodes, 'textbox', name);
182
+ if (!node || !node.ref) {
183
+ lastSnapshot = await adapter.snapshot();
184
+ nodes = parseSnapshot(lastSnapshot);
185
+ node = findByRoleAndName(nodes, 'textbox', name);
186
+ }
187
+ if (!node || !node.ref) {
188
+ throw new Error('walk-live: could not resolve textbox "' + name + '" on current page');
189
+ }
190
+ return node.ref;
191
+ }
192
+ // 3. Build the live WalkBrowser. `act` performs the per-edge field-fills BEFORE
193
+ // the resolved click. saucedemo steps are multi-field, so a single edge's act
194
+ // fans out into several adapter calls — the walk stays a single linear loop.
195
+ const browser = {
196
+ snapshot: async () => {
197
+ lastSnapshot = await adapter.snapshot();
198
+ return lastSnapshot;
199
+ },
200
+ callCount: () => adapter.callCount,
201
+ waitMs: (ms) => new Promise((r) => setTimeout(r, ms)),
202
+ // Tier-1 addressable jump: the walk calls this for an edge with addressableUrl
203
+ // (the cart link is icon-only / unstable on saucedemo, but cart.html is canonical).
204
+ goto: async (url) => { await adapter.goto(url); },
205
+ act: async (ref, inputSlot) => {
206
+ if (inputSlot === 'credentials') {
207
+ // LOGIN edge: replayStep resolved `ref` to the "Login" button. Fill
208
+ // Username + Password first, then click.
209
+ const userRef = await fieldRef('Username');
210
+ const passRef = await fieldRef('Password');
211
+ await adapter.fill(userRef, inputs.username);
212
+ await adapter.fill(passRef, inputs.password);
213
+ await adapter.click(ref);
214
+ return;
215
+ }
216
+ if (inputSlot === 'shipping') {
217
+ // SHIPPING edge (checkout-step-one): fill the three address fields, then
218
+ // click `ref` (the resolved "Continue" button).
219
+ const firstRef = await fieldRef('First Name');
220
+ const lastRef = await fieldRef('Last Name');
221
+ const zipRef = await fieldRef('Zip/Postal Code');
222
+ await adapter.fill(firstRef, inputs.firstName);
223
+ await adapter.fill(lastRef, inputs.lastName);
224
+ await adapter.fill(zipRef, inputs.zip);
225
+ await adapter.click(ref);
226
+ return;
227
+ }
228
+ // Plain-click edge (e.g. cart -> checkout-info "Checkout"): click the resolved ref.
229
+ await adapter.click(ref);
230
+ },
231
+ };
232
+ // 4. Walk login -> checkout-overview through the ONE async loop. Note: `inputs`
233
+ // is captured by the browser closure above, NOT passed into walkRoute (it was
234
+ // removed from WalkArgs — the walk only forwards the acceptsInput slot name).
235
+ const result = await walkRoute({
236
+ goalName: 'complete-checkout-dryrun',
237
+ startStateId: 'www.saucedemo.com:login',
238
+ goalStateId: 'www.saucedemo.com:checkout-overview',
239
+ store,
240
+ states: store.statesForNode('www.saucedemo.com'),
241
+ browser,
242
+ });
243
+ // 5. Always close the browser session, then return the walk result.
244
+ await adapter.close();
245
+ return result;
246
+ }
247
+ /**
248
+ * Seed the saucedemo interior WITH the post-commit `checkout-complete` state, so a
249
+ * walk can go all the way through the Finish commit to the "Thank you" page. The
250
+ * Finish affordance is commit:true → the walk still HALTS there (needs-classification)
251
+ * unless the agent classifies it safe (which runWalkLiveComplete does, because
252
+ * saucedemo is a demo with no real payment).
253
+ */
254
+ export function seedSaucedemoComplete(store) {
255
+ const N = 'www.saucedemo.com';
256
+ seedSaucedemoForWalk(store); // login..checkout-overview + menu
257
+ store.transaction(() => {
258
+ // Re-point Finish at a REAL checkout-complete state and add that state.
259
+ store.upsertState(makeState({ id: `${N}:checkout-overview`, nodeId: N, semanticName: `${N}:checkout-overview`,
260
+ urlPattern: '*checkout-step-two*', role: 'detail', fingerprint: ['button:Finish'],
261
+ affordances: [
262
+ makeAffordance({ id: 'aff_finish', label: 'click "Finish"', kind: 'navigate',
263
+ toState: `${N}:checkout-complete`, commit: true, core: true }),
264
+ ] }));
265
+ store.upsertState(makeState({ id: `${N}:checkout-complete`, nodeId: N, semanticName: `${N}:checkout-complete`,
266
+ urlPattern: '*checkout-complete*', role: 'detail', fingerprint: ['heading:Thank you for your order!', 'button:Back Home'],
267
+ affordances: [
268
+ makeAffordance({ id: 'aff_back_home', label: 'Back Home', kind: 'navigate', toState: `${N}:inventory` }),
269
+ ] }));
270
+ });
271
+ }
272
+ /**
273
+ * Live wiring for the FULL order completion (increment R5): walk login → … →
274
+ * checkout-overview → (Finish commit, classified SAFE because saucedemo is a demo)
275
+ * → checkout-complete. Proves the resume loop fires a commit point end-to-end on a
276
+ * real site after an explicit agent "safe" verdict — the only path that fires a commit.
277
+ */
278
+ export async function runWalkLiveComplete(inputs, dbPath) {
279
+ const N = 'www.saucedemo.com';
280
+ const store = new MapStore(dbPath);
281
+ if (!store.getState(`${N}:checkout-complete`))
282
+ seedSaucedemoComplete(store);
283
+ const adapter = new PlaywrightAdapter('sdc' + (Date.now() % 100000));
284
+ await adapter.open('https://www.saucedemo.com/');
285
+ const browser = makeLiveWalkBrowser(adapter, inputs);
286
+ const states = store.statesForNode(N);
287
+ const args = {
288
+ goalName: 'complete-checkout', startStateId: `${N}:login`, goalStateId: `${N}:checkout-complete`,
289
+ store, states, browser,
290
+ };
291
+ try {
292
+ // Leg 1: walk to the goal. It will HALT at the Finish commit (needs-classification).
293
+ let result = await walkRoute(args);
294
+ // Leg 2: agent decides the demo Finish is SAFE → resume; the walk fires it and
295
+ // continues to checkout-complete. (R5 resume loop.)
296
+ if (result.status === 'needs-classification') {
297
+ result = await walkRoute({
298
+ ...args,
299
+ startStateId: `${N}:checkout-overview`, // resume from where it halted
300
+ answer: { kind: 'classify', verdict: 'safe' },
301
+ });
302
+ }
303
+ return result;
304
+ }
305
+ finally {
306
+ await adapter.close().catch(() => { });
307
+ }
308
+ }
@@ -0,0 +1,49 @@
1
+ import Database from 'better-sqlite3';
2
+ export interface WalkSession {
3
+ sessionId: string;
4
+ startState: string;
5
+ goalState: string;
6
+ path: string[];
7
+ pos: number;
8
+ browserSession: string;
9
+ status: string;
10
+ profile?: string;
11
+ observe: string[];
12
+ observeDynamic: boolean;
13
+ observed: string[];
14
+ pauseKind?: string;
15
+ }
16
+ /** Persists a PAUSED walk's position + the playwright session NAME (the browser
17
+ * itself survives across CLI processes, so we don't serialize browser state).
18
+ * No inputs are ever stored — credentials/form values are runtime-only. */
19
+ export declare class WalkSessionStore {
20
+ private db;
21
+ constructor(path?: string);
22
+ static fromDatabase(db: Database.Database): WalkSessionStore;
23
+ private migrate;
24
+ create(args: {
25
+ startState: string;
26
+ goalState: string;
27
+ path: string[];
28
+ browserSession: string;
29
+ profile?: string;
30
+ nowMs?: number;
31
+ observe?: string[];
32
+ observeDynamic?: boolean;
33
+ }): string;
34
+ load(sessionId: string): WalkSession | null;
35
+ advance(sessionId: string, pos: number): void;
36
+ /** Persist checkpoint fire-once state + the pause kind this call returned, so a
37
+ * subsequent walk-resume (a) doesn't re-fire an already-observed checkpoint and
38
+ * (b) can reject `--continue` unless it's actually answering a checkpoint pause. */
39
+ setPause(sessionId: string, observed: string[], pauseKind: string): void;
40
+ /** Repoint a paused session at a DIFFERENT live browser — a wall retry (design
41
+ * item 2) may rotate to a brand-new session mid-walk; the session_id the agent
42
+ * already has (and resumes against) stays stable, only the browser it reattaches
43
+ * to changes. */
44
+ rebrowser(sessionId: string, browserSession: string): void;
45
+ close(sessionId: string): void;
46
+ /** browser_session ids of paused walks older than `maxAgeMs` (abandoned pauses whose live
47
+ * browser nothing else reaps — the ceiling pre-check closes these). */
48
+ staleBrowserSessions(maxAgeMs: number, nowMs?: number): string[];
49
+ }
@@ -0,0 +1,90 @@
1
+ import Database from 'better-sqlite3';
2
+ import { readFileSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, join } from 'node:path';
5
+ import { dbPath } from '../paths.js';
6
+ const SCHEMA = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'mapstore', 'schema.sql'), 'utf8');
7
+ /** Persists a PAUSED walk's position + the playwright session NAME (the browser
8
+ * itself survives across CLI processes, so we don't serialize browser state).
9
+ * No inputs are ever stored — credentials/form values are runtime-only. */
10
+ export class WalkSessionStore {
11
+ db;
12
+ constructor(path = dbPath()) {
13
+ this.db = new Database(path);
14
+ this.db.exec(SCHEMA);
15
+ this.migrate();
16
+ }
17
+ static fromDatabase(db) {
18
+ const s = Object.create(WalkSessionStore.prototype);
19
+ s.db = db;
20
+ db.exec(SCHEMA);
21
+ s.migrate();
22
+ return s;
23
+ }
24
+ // profile = the named browser profile this walk is running under. Persisted so
25
+ // walk-resume can rebuild the SAME BrowserOpts the original `walk` used (needed
26
+ // for the fresh-session SSO-wall retry, which requires knowing the profile to
27
+ // reopen under). Additive column, same pattern as record_sessions.profile.
28
+ migrate() {
29
+ const cols = new Set(this.db.prepare('PRAGMA table_info(walk_sessions)').all().map((c) => c.name));
30
+ if (!cols.has('profile'))
31
+ this.db.exec('ALTER TABLE walk_sessions ADD COLUMN profile TEXT');
32
+ if (!cols.has('observe'))
33
+ this.db.exec('ALTER TABLE walk_sessions ADD COLUMN observe TEXT');
34
+ if (!cols.has('observe_dynamic'))
35
+ this.db.exec('ALTER TABLE walk_sessions ADD COLUMN observe_dynamic INTEGER');
36
+ if (!cols.has('observed'))
37
+ this.db.exec('ALTER TABLE walk_sessions ADD COLUMN observed TEXT');
38
+ if (!cols.has('pause_kind'))
39
+ this.db.exec('ALTER TABLE walk_sessions ADD COLUMN pause_kind TEXT');
40
+ }
41
+ create(args) {
42
+ const id = `walk-${args.browserSession}`;
43
+ this.db.prepare(`INSERT INTO walk_sessions (session_id,start_state,goal_state,path,pos,browser_session,status,created_at,profile,observe,observe_dynamic,observed,pause_kind)
44
+ VALUES (?,?,?,?,0,?,'paused',?,?,?,?,?,?)
45
+ ON CONFLICT(session_id) DO UPDATE SET start_state=excluded.start_state, goal_state=excluded.goal_state,
46
+ path=excluded.path, pos=0, browser_session=excluded.browser_session, status='paused', created_at=excluded.created_at,
47
+ profile=excluded.profile, observe=excluded.observe, observe_dynamic=excluded.observe_dynamic,
48
+ observed=excluded.observed, pause_kind=excluded.pause_kind`)
49
+ .run(id, args.startState, args.goalState, JSON.stringify(args.path), args.browserSession, args.nowMs ?? Date.now(), args.profile ?? null, JSON.stringify(args.observe ?? []), args.observeDynamic ? 1 : 0, JSON.stringify([]), null);
50
+ return id;
51
+ }
52
+ load(sessionId) {
53
+ const r = this.db.prepare("SELECT * FROM walk_sessions WHERE session_id=? AND status='paused'").get(sessionId);
54
+ if (!r)
55
+ return null;
56
+ return { sessionId: r.session_id, startState: r.start_state, goalState: r.goal_state,
57
+ path: JSON.parse(r.path), pos: r.pos, browserSession: r.browser_session, status: r.status,
58
+ profile: r.profile ?? undefined,
59
+ observe: r.observe ? JSON.parse(r.observe) : [],
60
+ observeDynamic: !!r.observe_dynamic,
61
+ observed: r.observed ? JSON.parse(r.observed) : [],
62
+ pauseKind: r.pause_kind ?? undefined };
63
+ }
64
+ advance(sessionId, pos) {
65
+ this.db.prepare('UPDATE walk_sessions SET pos=? WHERE session_id=?').run(pos, sessionId);
66
+ }
67
+ /** Persist checkpoint fire-once state + the pause kind this call returned, so a
68
+ * subsequent walk-resume (a) doesn't re-fire an already-observed checkpoint and
69
+ * (b) can reject `--continue` unless it's actually answering a checkpoint pause. */
70
+ setPause(sessionId, observed, pauseKind) {
71
+ this.db.prepare('UPDATE walk_sessions SET observed=?, pause_kind=? WHERE session_id=?')
72
+ .run(JSON.stringify(observed), pauseKind, sessionId);
73
+ }
74
+ /** Repoint a paused session at a DIFFERENT live browser — a wall retry (design
75
+ * item 2) may rotate to a brand-new session mid-walk; the session_id the agent
76
+ * already has (and resumes against) stays stable, only the browser it reattaches
77
+ * to changes. */
78
+ rebrowser(sessionId, browserSession) {
79
+ this.db.prepare('UPDATE walk_sessions SET browser_session=? WHERE session_id=?').run(browserSession, sessionId);
80
+ }
81
+ close(sessionId) {
82
+ this.db.prepare("UPDATE walk_sessions SET status='done' WHERE session_id=?").run(sessionId);
83
+ }
84
+ /** browser_session ids of paused walks older than `maxAgeMs` (abandoned pauses whose live
85
+ * browser nothing else reaps — the ceiling pre-check closes these). */
86
+ staleBrowserSessions(maxAgeMs, nowMs = Date.now()) {
87
+ const rows = this.db.prepare("SELECT browser_session, created_at FROM walk_sessions WHERE status='paused'").all();
88
+ return rows.filter((r) => nowMs - r.created_at >= maxAgeMs).map((r) => r.browser_session);
89
+ }
90
+ }
@@ -0,0 +1,48 @@
1
+ import type { State } from '../mapstore/types.js';
2
+ import type { MapStore } from '../mapstore/store.js';
3
+ import type { RecallResponse } from '../protocol.js';
4
+ export interface WalkBrowser {
5
+ snapshot(): Promise<string>;
6
+ act(ref: string, inputSlot: string | null): Promise<void>;
7
+ typeText?(ref: string, text: string): Promise<void>;
8
+ goto?(url: string, inputSlot: string | null): Promise<void>;
9
+ waitMs?(ms: number): Promise<void>;
10
+ scroll?(dy: number): Promise<void>;
11
+ callCount(): number;
12
+ currentUrl?(): Promise<string>;
13
+ reopenFresh?(url: string): Promise<string>;
14
+ close?(): Promise<void>;
15
+ sessionId?(): string;
16
+ }
17
+ export type WalkAnswer = {
18
+ kind: 'ref';
19
+ ref: string;
20
+ } | {
21
+ kind: 'classify';
22
+ verdict: 'safe' | 'commit';
23
+ } | {
24
+ kind: 'continue';
25
+ };
26
+ export interface WalkArgs {
27
+ goalName: string;
28
+ startStateId: string;
29
+ goalStateId: string;
30
+ store: MapStore;
31
+ states: State[];
32
+ browser: WalkBrowser;
33
+ path?: string[];
34
+ answer?: WalkAnswer;
35
+ profile?: string;
36
+ observe?: string[];
37
+ observeDynamic?: boolean;
38
+ observed?: string[];
39
+ }
40
+ /**
41
+ * The interactive multi-step walk (design §3). Walks a linear route edge-by-edge
42
+ * from `startStateId` toward `goalStateId`, verifying every step (prediction vs
43
+ * observation) and escalating to the agent on drift or at a commit point.
44
+ *
45
+ * Zero LLM: replayStep resolves deterministically (cached ref, then role+name);
46
+ * any decision webnav isn't allowed to make is handed back as a `needs-*` response.
47
+ */
48
+ export declare function walkRoute(args: WalkArgs): Promise<RecallResponse>;