@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.
- package/LICENSE +202 -0
- package/README.md +283 -0
- package/bin/webnav +18 -0
- package/dist/agent/loop.d.ts +59 -0
- package/dist/agent/loop.js +267 -0
- package/dist/agent/server.d.ts +60 -0
- package/dist/agent/server.js +254 -0
- package/dist/cli-help.d.ts +3 -0
- package/dist/cli-help.js +111 -0
- package/dist/cli-spec.d.ts +24 -0
- package/dist/cli-spec.js +460 -0
- package/dist/cli.d.ts +208 -0
- package/dist/cli.js +2558 -0
- package/dist/contract.d.ts +20 -0
- package/dist/contract.js +15 -0
- package/dist/creds.d.ts +46 -0
- package/dist/creds.js +126 -0
- package/dist/dashboard/server.d.ts +137 -0
- package/dist/dashboard/server.js +395 -0
- package/dist/dashboard/shell.d.ts +1 -0
- package/dist/dashboard/shell.js +1343 -0
- package/dist/explorer/analyse.d.ts +25 -0
- package/dist/explorer/analyse.js +39 -0
- package/dist/explorer/diff.d.ts +11 -0
- package/dist/explorer/diff.js +22 -0
- package/dist/explorer/draft.d.ts +81 -0
- package/dist/explorer/draft.js +1658 -0
- package/dist/explorer/fingerprint-page.d.ts +11 -0
- package/dist/explorer/fingerprint-page.js +19 -0
- package/dist/explorer/fingerprint.d.ts +14 -0
- package/dist/explorer/fingerprint.js +18 -0
- package/dist/explorer/infer.d.ts +85 -0
- package/dist/explorer/infer.js +370 -0
- package/dist/explorer/pattern-propose.d.ts +27 -0
- package/dist/explorer/pattern-propose.js +81 -0
- package/dist/explorer/patterns.d.ts +49 -0
- package/dist/explorer/patterns.js +290 -0
- package/dist/explorer/shadow.d.ts +3 -0
- package/dist/explorer/shadow.js +95 -0
- package/dist/graph/coverage.d.ts +33 -0
- package/dist/graph/coverage.js +155 -0
- package/dist/graph/edit.d.ts +47 -0
- package/dist/graph/edit.js +211 -0
- package/dist/graph/frontier.d.ts +24 -0
- package/dist/graph/frontier.js +95 -0
- package/dist/graph/seed.d.ts +23 -0
- package/dist/graph/seed.js +29 -0
- package/dist/graph/show.d.ts +8 -0
- package/dist/graph/show.js +6 -0
- package/dist/graph/teach.d.ts +8 -0
- package/dist/graph/teach.js +10 -0
- package/dist/hosted.d.ts +36 -0
- package/dist/hosted.js +69 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +4 -0
- package/dist/mapstore/record.d.ts +128 -0
- package/dist/mapstore/record.js +224 -0
- package/dist/mapstore/schema.sql +47 -0
- package/dist/mapstore/store.d.ts +108 -0
- package/dist/mapstore/store.js +399 -0
- package/dist/mapstore/types.d.ts +87 -0
- package/dist/mapstore/types.js +29 -0
- package/dist/mcp/server.d.ts +46 -0
- package/dist/mcp/server.js +163 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +33 -0
- package/dist/playwright/adapter.d.ts +62 -0
- package/dist/playwright/adapter.js +145 -0
- package/dist/playwright/ax-adapter.d.ts +33 -0
- package/dist/playwright/ax-adapter.js +76 -0
- package/dist/playwright/capture.d.ts +2 -0
- package/dist/playwright/capture.js +10 -0
- package/dist/playwright/fingerprint.d.ts +40 -0
- package/dist/playwright/fingerprint.js +139 -0
- package/dist/playwright/profile-lock.d.ts +15 -0
- package/dist/playwright/profile-lock.js +51 -0
- package/dist/playwright/sessions.d.ts +72 -0
- package/dist/playwright/sessions.js +229 -0
- package/dist/playwright/snapshot.d.ts +10 -0
- package/dist/playwright/snapshot.js +52 -0
- package/dist/playwright/throttle.d.ts +14 -0
- package/dist/playwright/throttle.js +76 -0
- package/dist/playwright/video.d.ts +13 -0
- package/dist/playwright/video.js +32 -0
- package/dist/protocol.d.ts +49 -0
- package/dist/protocol.js +3 -0
- package/dist/recorder/agent-session.d.ts +59 -0
- package/dist/recorder/agent-session.js +230 -0
- package/dist/recorder/capture-loop.d.ts +19 -0
- package/dist/recorder/capture-loop.js +40 -0
- package/dist/recorder/coverage.d.ts +22 -0
- package/dist/recorder/coverage.js +49 -0
- package/dist/recorder/hover-probe.d.ts +35 -0
- package/dist/recorder/hover-probe.js +100 -0
- package/dist/recorder/ingest.d.ts +39 -0
- package/dist/recorder/ingest.js +138 -0
- package/dist/recorder/live-record.d.ts +41 -0
- package/dist/recorder/live-record.js +255 -0
- package/dist/recorder/live.d.ts +53 -0
- package/dist/recorder/live.js +400 -0
- package/dist/recorder/probe.d.ts +14 -0
- package/dist/recorder/probe.js +33 -0
- package/dist/recorder/replay.d.ts +60 -0
- package/dist/recorder/replay.js +401 -0
- package/dist/recorder/review.d.ts +73 -0
- package/dist/recorder/review.js +171 -0
- package/dist/recorder/snapshot-dom.d.ts +7 -0
- package/dist/recorder/snapshot-dom.js +25 -0
- package/dist/router/auth-status.d.ts +20 -0
- package/dist/router/auth-status.js +55 -0
- package/dist/router/browse.d.ts +108 -0
- package/dist/router/browse.js +214 -0
- package/dist/router/catalog.d.ts +18 -0
- package/dist/router/catalog.js +6 -0
- package/dist/router/extract-content.d.ts +13 -0
- package/dist/router/extract-content.js +87 -0
- package/dist/router/live-extension-browser.d.ts +35 -0
- package/dist/router/live-extension-browser.js +175 -0
- package/dist/router/path.d.ts +7 -0
- package/dist/router/path.js +55 -0
- package/dist/router/read.d.ts +26 -0
- package/dist/router/read.js +30 -0
- package/dist/router/readiness.d.ts +9 -0
- package/dist/router/readiness.js +55 -0
- package/dist/router/replay.d.ts +14 -0
- package/dist/router/replay.js +23 -0
- package/dist/router/resolve.d.ts +21 -0
- package/dist/router/resolve.js +33 -0
- package/dist/router/search-live.d.ts +21 -0
- package/dist/router/search-live.js +98 -0
- package/dist/router/search-providers.d.ts +5 -0
- package/dist/router/search-providers.js +12 -0
- package/dist/router/search.d.ts +6 -0
- package/dist/router/search.js +62 -0
- package/dist/router/suite.d.ts +69 -0
- package/dist/router/suite.js +226 -0
- package/dist/router/tokens.d.ts +13 -0
- package/dist/router/tokens.js +26 -0
- package/dist/router/walk-live.d.ts +57 -0
- package/dist/router/walk-live.js +308 -0
- package/dist/router/walk-session.d.ts +49 -0
- package/dist/router/walk-session.js +90 -0
- package/dist/router/walk.d.ts +48 -0
- package/dist/router/walk.js +384 -0
- package/package.json +59 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,2558 @@
|
|
|
1
|
+
import { topLevelHelp, commandHelp } from './cli-help.js';
|
|
2
|
+
import { VERSION, COMMANDS } from './cli-spec.js';
|
|
3
|
+
import { wireSessionName } from './playwright/adapter.js';
|
|
4
|
+
import { dbPath } from './paths.js';
|
|
5
|
+
// Pull the value following a flag (or one of its aliases) out of an arg list.
|
|
6
|
+
function flagValue(args, ...names) {
|
|
7
|
+
for (const name of names) {
|
|
8
|
+
const i = args.indexOf(name);
|
|
9
|
+
if (i !== -1)
|
|
10
|
+
return args[i + 1];
|
|
11
|
+
}
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
// Collect EVERY value of a repeated flag (e.g. `--session a --session b`). Used by
|
|
15
|
+
// graph-analyse to draft from MULTIPLE recording sessions of one site in one pass.
|
|
16
|
+
function flagValues(args, name) {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 0; i < args.length; i++)
|
|
19
|
+
if (args[i] === name && args[i + 1] !== undefined)
|
|
20
|
+
out.push(args[i + 1]);
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
// Browser launch flags shared by the verbs that open a browser (read / navigate /
|
|
24
|
+
// walk). Default is HEADED — a real visible window — so every run is watchable;
|
|
25
|
+
// pass `--headless` to opt out (CI / gated live tests). `--headed` is still
|
|
26
|
+
// accepted as an explicit no-op for back-compat. `--persistent` / `--profile
|
|
27
|
+
// <dir>` reuse a logged-in profile; `--browser chrome|firefox|webkit|msedge`
|
|
28
|
+
// picks the engine.
|
|
29
|
+
function browserOpts(args) {
|
|
30
|
+
const has = (f) => args.includes(f);
|
|
31
|
+
const o = {};
|
|
32
|
+
o.headed = !has('--headless'); // headed by default; --headless opts out
|
|
33
|
+
if (has('--persistent'))
|
|
34
|
+
o.persistent = true;
|
|
35
|
+
const profile = flagValue(args, '--profile');
|
|
36
|
+
if (profile) {
|
|
37
|
+
o.profile = profile;
|
|
38
|
+
o.persistent = true;
|
|
39
|
+
} // a profile implies persistent
|
|
40
|
+
const browser = flagValue(args, '--browser');
|
|
41
|
+
if (browser)
|
|
42
|
+
o.browser = browser;
|
|
43
|
+
return o;
|
|
44
|
+
}
|
|
45
|
+
// Collect repeated `--input slot=value` flags into a map. Runtime-only values
|
|
46
|
+
// (credentials, form fields) — the walk forwards slot NAMES, never stores values.
|
|
47
|
+
function inputFlags(args) {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
if (args[i] === '--input' && args[i + 1]) {
|
|
51
|
+
const [k, ...rest] = args[i + 1].split('=');
|
|
52
|
+
out[k] = rest.join('=');
|
|
53
|
+
i++;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
const KNOWN_VERBS = new Set([...COMMANDS.map((c) => c.name), 'read']);
|
|
59
|
+
export function parseArgs(argv) {
|
|
60
|
+
// Global help/version and empty argv are checked BEFORE the verb switch.
|
|
61
|
+
if (argv.length === 0)
|
|
62
|
+
return { cmd: 'help' };
|
|
63
|
+
const first = argv[0];
|
|
64
|
+
if (first === '--help' || first === '-h')
|
|
65
|
+
return { cmd: 'help' };
|
|
66
|
+
if (first === '--version' || first === '-V')
|
|
67
|
+
return { cmd: 'version' };
|
|
68
|
+
const [cmd, ...rest] = argv;
|
|
69
|
+
// Per-command trailing help: `webnav <verb> --help` → help for that verb.
|
|
70
|
+
// Checked after identifying the verb so `recall --help` doesn't try to run.
|
|
71
|
+
if (KNOWN_VERBS.has(cmd) && (rest.includes('--help') || rest.includes('-h'))) {
|
|
72
|
+
return { cmd: 'help', command: cmd };
|
|
73
|
+
}
|
|
74
|
+
if (cmd === 'list')
|
|
75
|
+
return { cmd };
|
|
76
|
+
if (cmd === 'read') {
|
|
77
|
+
// First non-flag positional is the URL, so `read --raw <url>` and
|
|
78
|
+
// `read <url> --raw` both work (agents write the flag in either order).
|
|
79
|
+
const url = rest.find((a) => !a.startsWith('--')) ?? '';
|
|
80
|
+
return { cmd, url, raw: rest.includes('--raw'), browser: browserOpts(rest) };
|
|
81
|
+
}
|
|
82
|
+
if (cmd === 'capture')
|
|
83
|
+
return { cmd, url: rest[0], out: rest[1] };
|
|
84
|
+
if (cmd === 'use') {
|
|
85
|
+
const sub = rest[0];
|
|
86
|
+
if (!sub || sub === '--help' || sub === '-h')
|
|
87
|
+
return { cmd: 'use-help' };
|
|
88
|
+
return parseArgs([sub, ...rest.slice(1)]);
|
|
89
|
+
}
|
|
90
|
+
if (cmd === 'dev') {
|
|
91
|
+
const sub = rest[0];
|
|
92
|
+
if (!sub || sub === '--help' || sub === '-h')
|
|
93
|
+
return { cmd: 'dev-help' };
|
|
94
|
+
return parseArgs([sub, ...rest.slice(1)]);
|
|
95
|
+
}
|
|
96
|
+
if (cmd === 'search') {
|
|
97
|
+
const query = rest[0];
|
|
98
|
+
const top = rest.includes('--top') ? Number(rest[rest.indexOf('--top') + 1]) : 3;
|
|
99
|
+
return { cmd, query, top };
|
|
100
|
+
}
|
|
101
|
+
if (cmd === 'node-add') {
|
|
102
|
+
return { cmd, id: rest[0], url: flagValue(rest, '--url') ?? '' };
|
|
103
|
+
}
|
|
104
|
+
if (cmd === 'eval') {
|
|
105
|
+
const pos = rest.filter((a) => !a.startsWith('--'));
|
|
106
|
+
return { cmd, url: pos[0], js: pos[1] };
|
|
107
|
+
}
|
|
108
|
+
if (cmd === 'network') {
|
|
109
|
+
const pos = rest.filter((a) => !a.startsWith('--'));
|
|
110
|
+
return { cmd, url: pos[0] };
|
|
111
|
+
}
|
|
112
|
+
if (cmd === 'go-back')
|
|
113
|
+
return { cmd, session: flagValue(rest, '--session') };
|
|
114
|
+
if (cmd === 'reload')
|
|
115
|
+
return { cmd, session: flagValue(rest, '--session') };
|
|
116
|
+
if (cmd === 'close')
|
|
117
|
+
return { cmd, session: flagValue(rest, '--session') ?? rest.find((a) => !a.startsWith('--')) ?? '' };
|
|
118
|
+
if (cmd === 'session')
|
|
119
|
+
return { cmd, session: flagValue(rest, '--session') ?? '', url: flagValue(rest, '--url') ?? rest.find((a) => !a.startsWith('--')) ?? 'about:blank', browser: browserOpts(rest), profile: flagValue(rest, '--profile') };
|
|
120
|
+
if (cmd === 'record-start')
|
|
121
|
+
return { cmd, session: flagValue(rest, '--session') ?? '' };
|
|
122
|
+
if (cmd === 'record-stop')
|
|
123
|
+
return { cmd, session: flagValue(rest, '--session') ?? '' };
|
|
124
|
+
if (cmd === 'record-live')
|
|
125
|
+
return { cmd, session: flagValue(rest, '--session') ?? '', url: flagValue(rest, '--url') ?? '', interval: Number(flagValue(rest, '--interval') ?? 500), browser: browserOpts(rest) };
|
|
126
|
+
// session comes from --session, falling back to the first positional — both humans
|
|
127
|
+
// and agents naturally type `graph-analyse <id> --draft`, and the flag-only parse
|
|
128
|
+
// silently queried session '' and reported "empty" (live-acceptance trap).
|
|
129
|
+
// sessions: repeated --session flags, or a single positional; --host <h> auto-includes
|
|
130
|
+
// every recorded session for that host (drafts the whole site from all its drives at once).
|
|
131
|
+
if (cmd === 'graph-analyse') {
|
|
132
|
+
const multi = flagValues(rest, '--session');
|
|
133
|
+
const host = flagValue(rest, '--host');
|
|
134
|
+
// positional session fallback (`graph-analyse <id>`) ONLY when no --session/--host given —
|
|
135
|
+
// else `--host x` would grab `x` as a positional. Skip a value that follows a value-flag.
|
|
136
|
+
const flagVals = new Set(['--host', '--session', '--browser', '--profile'].flatMap((f) => { const i = rest.indexOf(f); return i >= 0 ? [rest[i + 1]] : []; }));
|
|
137
|
+
const pos = (!multi.length && !host) ? rest.find((a) => !a.startsWith('--') && !flagVals.has(a)) : undefined;
|
|
138
|
+
const sessions = multi.length ? multi : (pos ? [pos] : []);
|
|
139
|
+
return { cmd, sessions, host, draft: rest.includes('--draft'), skipReviewGate: rest.includes('--skip-review-gate') };
|
|
140
|
+
}
|
|
141
|
+
if (cmd === 'graph-edit')
|
|
142
|
+
return { cmd, node: flagValue(rest, '--node') ?? '', graph: flagValue(rest, '--graph') ?? '' };
|
|
143
|
+
if (cmd === 'graph-show')
|
|
144
|
+
return { cmd, node: flagValue(rest, '--node') ?? '' };
|
|
145
|
+
if (cmd === 'node-clear')
|
|
146
|
+
return { cmd, node: flagValue(rest, '--node') ?? '' };
|
|
147
|
+
if (cmd === 'node-rm')
|
|
148
|
+
return { cmd, node: flagValue(rest, '--node') ?? '' };
|
|
149
|
+
if (cmd === 'import-map')
|
|
150
|
+
return { cmd, file: flagValue(rest, '--file') ?? rest[0] ?? '' };
|
|
151
|
+
if (cmd === 'pattern-propose') {
|
|
152
|
+
return { cmd, fromUnknown: flagValue(rest, '--from-unknown') ?? '', name: flagValue(rest, '--name') ?? '', lint: flagValue(rest, '--lint') };
|
|
153
|
+
}
|
|
154
|
+
if (cmd === 'export-map')
|
|
155
|
+
return { cmd, node: flagValue(rest, '--node') ?? rest[0] ?? '' };
|
|
156
|
+
// outline/mermaid take the site as a positional OR --node (ergonomic: `outline <site>`).
|
|
157
|
+
if (cmd === 'outline')
|
|
158
|
+
return { cmd, node: flagValue(rest, '--node') ?? rest[0] ?? '' };
|
|
159
|
+
if (cmd === 'mermaid')
|
|
160
|
+
return { cmd, node: flagValue(rest, '--node') ?? rest[0] ?? '' };
|
|
161
|
+
// frontier: --node (or positional) + repeatable --exclude <label> (caller's hard "never click" list).
|
|
162
|
+
if (cmd === 'frontier') {
|
|
163
|
+
const excludeVals = new Set(flagValues(rest, '--exclude'));
|
|
164
|
+
const node = flagValue(rest, '--node') ?? rest.find((a) => !a.startsWith('--') && !excludeVals.has(a)) ?? '';
|
|
165
|
+
return { cmd, node, exclude: [...excludeVals] };
|
|
166
|
+
}
|
|
167
|
+
if (cmd === 'effects')
|
|
168
|
+
return { cmd, session: flagValue(rest, '--session') ?? '' };
|
|
169
|
+
if (cmd === 'record-rename')
|
|
170
|
+
return { cmd, from: flagValue(rest, '--from') ?? '', to: flagValue(rest, '--to') ?? '' };
|
|
171
|
+
if (cmd === 'review')
|
|
172
|
+
return { cmd, session: flagValue(rest, '--session') ?? rest.find((a) => !a.startsWith('--')) ?? '', model: flagValue(rest, '--model') ?? 'sonnet', instructions: flagValue(rest, '--instructions') };
|
|
173
|
+
if (cmd === 'capture-loop')
|
|
174
|
+
return { cmd, objective: flagValue(rest, '--objective') ?? '', exploreCmd: flagValue(rest, '--explore-cmd') ?? '', sessionPrefix: flagValue(rest, '--session-prefix') ?? 'cl', maxRounds: Number(flagValue(rest, '--max-rounds') ?? 5), model: flagValue(rest, '--model') ?? 'sonnet' };
|
|
175
|
+
if (cmd === 'verify')
|
|
176
|
+
return { cmd, node: flagValue(rest, '--node') ?? '', session: flagValue(rest, '--session') ?? '' };
|
|
177
|
+
if (cmd === 'hover-probe')
|
|
178
|
+
return { cmd, session: flagValue(rest, '--session') ?? '', limit: Number(flagValue(rest, '--limit') ?? 12), rightClick: rest.includes('--right-click') };
|
|
179
|
+
if (cmd === 'profile-status') {
|
|
180
|
+
return { cmd, profile: flagValue(rest, '--profile') ?? '', site: flagValue(rest, '--site') ?? '', url: flagValue(rest, '--url') };
|
|
181
|
+
}
|
|
182
|
+
if (cmd === 'sessions') {
|
|
183
|
+
const maxAge = flagValue(rest, '--max-age-hours');
|
|
184
|
+
return { cmd, sub: rest.find((a) => !a.startsWith('--')) ?? 'list',
|
|
185
|
+
all: rest.includes('--all'), maxAgeHours: maxAge ? Number(maxAge) : undefined };
|
|
186
|
+
}
|
|
187
|
+
if (cmd === 'mcp')
|
|
188
|
+
return { cmd };
|
|
189
|
+
if (cmd === 'dashboard') {
|
|
190
|
+
const portFlag = flagValue(rest, '--port');
|
|
191
|
+
const port = Number(portFlag ?? process.env.WEBNAV_PORT ?? 7777);
|
|
192
|
+
return { cmd, port, open: rest.includes('--open') };
|
|
193
|
+
}
|
|
194
|
+
if (cmd === 'ingest')
|
|
195
|
+
return { cmd, port: Number(flagValue(rest, '--port') ?? 7778) };
|
|
196
|
+
if (cmd === 'agent-serve')
|
|
197
|
+
return { cmd, port: Number(flagValue(rest, '--port') ?? 7779), token: flagValue(rest, '--token') };
|
|
198
|
+
if (cmd === 'walk') {
|
|
199
|
+
return { cmd, start: flagValue(rest, '--start') ?? '', goal: flagValue(rest, '--goal') ?? '',
|
|
200
|
+
inputs: inputFlags(rest), browser: browserOpts(rest), hosted: rest.includes('--hosted'),
|
|
201
|
+
observe: flagValues(rest, '--observe'), observeDynamic: rest.includes('--observe-dynamic') };
|
|
202
|
+
}
|
|
203
|
+
if (cmd === 'test') {
|
|
204
|
+
return { cmd, suite: flagValue(rest, '--suite') ?? rest.find((a) => !a.startsWith('--')) ?? '', browser: browserOpts(rest) };
|
|
205
|
+
}
|
|
206
|
+
if (cmd === 'walk-resume') {
|
|
207
|
+
return { cmd, session: rest.find((a) => !a.startsWith('--')) ?? '',
|
|
208
|
+
ref: flagValue(rest, '--ref'), classify: flagValue(rest, '--classify'),
|
|
209
|
+
continue: rest.includes('--continue'),
|
|
210
|
+
inputs: inputFlags(rest) };
|
|
211
|
+
}
|
|
212
|
+
if (cmd === 'login') {
|
|
213
|
+
return { cmd, key: rest.find((a) => !a.startsWith('--')) ?? '' };
|
|
214
|
+
}
|
|
215
|
+
if (cmd === 'creds') {
|
|
216
|
+
// creds set <site> key=value... | creds list | creds rm <site> [key]
|
|
217
|
+
const sub = rest[0] ?? '';
|
|
218
|
+
const pos = rest.slice(1).filter((a) => !a.startsWith('--') && !a.includes('='));
|
|
219
|
+
const values = {};
|
|
220
|
+
for (const a of rest.slice(1)) {
|
|
221
|
+
if (a.includes('=') && !a.startsWith('--')) {
|
|
222
|
+
const [k, ...v] = a.split('=');
|
|
223
|
+
values[k] = v.join('=');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { cmd, sub, site: pos[0], key: pos[1], values };
|
|
227
|
+
}
|
|
228
|
+
if (cmd === 'navigate') {
|
|
229
|
+
const pos = rest.filter((a) => !a.startsWith('--'));
|
|
230
|
+
return { cmd, url: pos[0] ?? '', session: flagValue(rest, '--session') ?? '', browser: browserOpts(rest) };
|
|
231
|
+
}
|
|
232
|
+
if (cmd === 'snapshot')
|
|
233
|
+
return { cmd, session: flagValue(rest, '--session') ?? '' };
|
|
234
|
+
if (cmd === 'click') {
|
|
235
|
+
const pos = rest.filter((a) => !a.startsWith('--'));
|
|
236
|
+
return { cmd, ref: pos[0] ?? '', session: flagValue(rest, '--session') ?? '' };
|
|
237
|
+
}
|
|
238
|
+
if (cmd === 'type') {
|
|
239
|
+
const sessionVal = flagValue(rest, '--session');
|
|
240
|
+
const pos = rest.filter((a) => !a.startsWith('--') && a !== sessionVal);
|
|
241
|
+
return { cmd, ref: pos[0] ?? '', text: pos[1] ?? '', session: sessionVal ?? '' };
|
|
242
|
+
}
|
|
243
|
+
throw new Error(`unknown command: ${cmd}\nRun \`webnav --help\` to see available commands.`);
|
|
244
|
+
}
|
|
245
|
+
async function main() {
|
|
246
|
+
const rawArgs = process.argv.slice(2);
|
|
247
|
+
// --json is a global output-mode flag detected directly in main() rather than
|
|
248
|
+
// threaded through the ParsedArgs union (which would complicate every variant).
|
|
249
|
+
// Under --json, ONLY pure JSON is written to stdout; all diagnostics → stderr.
|
|
250
|
+
const json = rawArgs.includes('--json');
|
|
251
|
+
const args = parseArgs(rawArgs);
|
|
252
|
+
if (args.cmd === 'help') {
|
|
253
|
+
// Help is informational: pure stdout, exit 0.
|
|
254
|
+
console.log(args.command ? commandHelp(args.command) : topLevelHelp());
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (args.cmd === 'version') {
|
|
258
|
+
console.log(VERSION);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (args.cmd === 'list') {
|
|
262
|
+
// "what's on this map?" — the sites webnav has a map for + their state counts.
|
|
263
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
264
|
+
const { listCoverage } = await import('./router/catalog.js');
|
|
265
|
+
console.log(JSON.stringify(listCoverage(new MapStore(dbPath())), null, 2));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (args.cmd === 'read') {
|
|
269
|
+
const { readUrl } = await import('./router/read.js');
|
|
270
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
271
|
+
const adapter = new PlaywrightAdapter(`read-${Date.now()}`, undefined, undefined, args.browser);
|
|
272
|
+
// open then snapshot WITH READINESS RETRY — a JS SPA renders after the first paint, so a
|
|
273
|
+
// bare immediate snapshot catches an unfinished shell and read wrongly reports
|
|
274
|
+
// `blocked: loading`. snapshotReady waits for the page to actually render.
|
|
275
|
+
const fetchSnapshot = async (u) => { await adapter.open(u); return adapter.snapshotReady(); };
|
|
276
|
+
const r = await readUrl(args.url, fetchSnapshot, { raw: args.raw });
|
|
277
|
+
await adapter.close().catch(() => { });
|
|
278
|
+
console.log(JSON.stringify(r, null, 2));
|
|
279
|
+
if (r.status !== 'done')
|
|
280
|
+
process.exitCode = 3;
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (args.cmd === 'use-help') {
|
|
284
|
+
console.log(topLevelHelp());
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (args.cmd === 'dev-help') {
|
|
288
|
+
const { devHelp } = await import('./cli-help.js');
|
|
289
|
+
console.log(devHelp());
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (args.cmd === 'capture') {
|
|
293
|
+
const { capture } = await import('./playwright/capture.js');
|
|
294
|
+
await capture(args.url, args.out);
|
|
295
|
+
// Under --json, emit a clean JSON receipt instead of human prose so stdout
|
|
296
|
+
// stays pure JSON. Otherwise the human-friendly progress line goes to stderr.
|
|
297
|
+
if (json) {
|
|
298
|
+
console.log(JSON.stringify({ captured: args.url, out: args.out }));
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
console.error(`captured ${args.url} -> ${args.out}`);
|
|
302
|
+
}
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (args.cmd === 'search') {
|
|
306
|
+
// search: open-web search — search engine → top-N results → visit + extract
|
|
307
|
+
// answer-evidence. Prints a SearchGatherResult JSON for the calling agent.
|
|
308
|
+
const { runSearchLive } = await import('./router/search-live.js');
|
|
309
|
+
const response = await runSearchLive(args.query, args.top);
|
|
310
|
+
console.log(JSON.stringify(response, null, 2));
|
|
311
|
+
// "ran fine but found nothing / blocked" → exit 3 so an agent's shell can
|
|
312
|
+
// distinguish a clean empty result from a crash.
|
|
313
|
+
if (isEmptyOrFailed(response))
|
|
314
|
+
process.exitCode = 3;
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (args.cmd === 'node-add') {
|
|
318
|
+
// node-add: teach webnav a new site (persisted; the viz UI reads the same store).
|
|
319
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
320
|
+
const { ensureSeeded } = await import('./graph/seed.js');
|
|
321
|
+
const { addNode } = await import('./graph/teach.js');
|
|
322
|
+
const store = new MapStore();
|
|
323
|
+
ensureSeeded(store);
|
|
324
|
+
const node = addNode(store, { id: args.id, homeUrl: args.url });
|
|
325
|
+
console.log(JSON.stringify(node, null, 2));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (args.cmd === 'eval') {
|
|
329
|
+
const { runEval } = await import('./router/browse.js');
|
|
330
|
+
const r = await runEval(args.url, args.js);
|
|
331
|
+
console.log(JSON.stringify(r, null, 2));
|
|
332
|
+
if (r.status !== 'done')
|
|
333
|
+
process.exitCode = 3;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (args.cmd === 'network') {
|
|
337
|
+
const { runNetwork } = await import('./router/browse.js');
|
|
338
|
+
const r = await runNetwork(args.url);
|
|
339
|
+
console.log(JSON.stringify(r, null, 2));
|
|
340
|
+
if (r.status !== 'done')
|
|
341
|
+
process.exitCode = 3;
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
if (args.cmd === 'session') {
|
|
345
|
+
// Interactive long-lived agent session: ONE process owns the browser + video +
|
|
346
|
+
// record loop, reads JSON-line commands on stdin, writes JSON results on stdout,
|
|
347
|
+
// closes cleanly on quit/EOF. This is the shape that lets video span the whole
|
|
348
|
+
// session and leaks nothing (spec: 2026-07-09-interactive-agent-session-design.md).
|
|
349
|
+
if (!args.session) {
|
|
350
|
+
console.log(JSON.stringify({ ok: false, error: 'usage: webnav use session --session <S> [--url <U>] [--profile <name>]' }));
|
|
351
|
+
process.exitCode = 2;
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
355
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
356
|
+
const { runAgentSession, OVERLAY_ON_JS } = await import('./recorder/agent-session.js');
|
|
357
|
+
const { parseSnapshot } = await import('./playwright/snapshot.js');
|
|
358
|
+
const { recoverFingerprint } = await import('./playwright/fingerprint.js');
|
|
359
|
+
const { homedir } = await import('node:os');
|
|
360
|
+
const { join } = await import('node:path');
|
|
361
|
+
const { mkdirSync } = await import('node:fs');
|
|
362
|
+
const readline = await import('node:readline');
|
|
363
|
+
const store = new RecordStore(dbPath());
|
|
364
|
+
const videosRoot = join(homedir(), '.webnav', 'recordings');
|
|
365
|
+
const profilesRoot = join(homedir(), '.webnav', 'profiles');
|
|
366
|
+
// profile resolve + prep (same discipline as every other launch path)
|
|
367
|
+
const sbrowser = { ...args.browser };
|
|
368
|
+
// Maximized-window config for HEADED capture sessions (repo-root playwright-cli.json;
|
|
369
|
+
// adapter applies it only when headed). Resolved from THIS module so CWD doesn't matter.
|
|
370
|
+
if (sbrowser.headed) {
|
|
371
|
+
const { fileURLToPath } = await import('node:url');
|
|
372
|
+
sbrowser.configPath = join(fileURLToPath(new URL('.', import.meta.url)), '..', 'playwright-cli.json');
|
|
373
|
+
}
|
|
374
|
+
let profName = null;
|
|
375
|
+
if (args.profile) {
|
|
376
|
+
const { resolveProfile } = await import('./playwright/adapter.js');
|
|
377
|
+
const { prepProfile } = await import('./playwright/profile-lock.js');
|
|
378
|
+
sbrowser.profile = resolveProfile(args.profile, profilesRoot);
|
|
379
|
+
profName = sbrowser.profile.split('/').pop() ?? null;
|
|
380
|
+
try {
|
|
381
|
+
mkdirSync(sbrowser.profile, { recursive: true });
|
|
382
|
+
}
|
|
383
|
+
catch { /* */ }
|
|
384
|
+
prepProfile(sbrowser.profile);
|
|
385
|
+
}
|
|
386
|
+
const adapter = new PlaywrightAdapter(args.session, undefined, undefined, sbrowser);
|
|
387
|
+
// realtime → dashboard: best-effort POST to /api/notify so it pushes SSE. The
|
|
388
|
+
// dashboard is a SEPARATE process; this is the cross-process bridge.
|
|
389
|
+
const dashPort = Number(process.env.WEBNAV_DASHBOARD_PORT ?? 7777);
|
|
390
|
+
const notify = (kind, line) => {
|
|
391
|
+
const body = JSON.stringify({ kind, line });
|
|
392
|
+
// fire-and-forget; a missing dashboard is fine (store still has the truth)
|
|
393
|
+
import('node:http').then(({ request }) => {
|
|
394
|
+
const req = request({ host: '127.0.0.1', port: dashPort, path: '/api/notify', method: 'POST', headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) } }, (res) => res.resume());
|
|
395
|
+
req.on('error', () => { });
|
|
396
|
+
req.write(body);
|
|
397
|
+
req.end();
|
|
398
|
+
}).catch(() => { });
|
|
399
|
+
};
|
|
400
|
+
try {
|
|
401
|
+
await adapter.open(args.url);
|
|
402
|
+
if (sbrowser.profile && args.url && args.url !== 'about:blank') {
|
|
403
|
+
try {
|
|
404
|
+
await adapter.goto(args.url);
|
|
405
|
+
}
|
|
406
|
+
catch { /* past restored tab */ }
|
|
407
|
+
}
|
|
408
|
+
await adapter.evalJs(OVERLAY_ON_JS).catch(() => { }); // best-effort: video overlay on the starting page
|
|
409
|
+
store.start(args.session);
|
|
410
|
+
store.setOrigin(args.session, 'agent');
|
|
411
|
+
if (profName)
|
|
412
|
+
store.setProfile(args.session, profName);
|
|
413
|
+
if (args.url && args.url !== 'about:blank')
|
|
414
|
+
store.setStartUrl(args.session, args.url);
|
|
415
|
+
const rl = readline.createInterface({ input: process.stdin });
|
|
416
|
+
const lines = [];
|
|
417
|
+
const waiters = [];
|
|
418
|
+
let ended = false;
|
|
419
|
+
rl.on('line', (l) => { const w = waiters.shift(); if (w)
|
|
420
|
+
w(l);
|
|
421
|
+
else
|
|
422
|
+
lines.push(l); });
|
|
423
|
+
rl.on('close', () => { ended = true; while (waiters.length)
|
|
424
|
+
waiters.shift()(null); });
|
|
425
|
+
const readLine = () => new Promise((resolve) => {
|
|
426
|
+
if (lines.length)
|
|
427
|
+
return resolve(lines.shift());
|
|
428
|
+
if (ended)
|
|
429
|
+
return resolve(null);
|
|
430
|
+
waiters.push(resolve);
|
|
431
|
+
});
|
|
432
|
+
await runAgentSession({
|
|
433
|
+
sessionId: args.session, adapter: adapter, store: store,
|
|
434
|
+
recover: (snap, ref) => {
|
|
435
|
+
const nodes = parseSnapshot(snap);
|
|
436
|
+
const chosen = nodes.find((n) => n.ref === ref);
|
|
437
|
+
const elementFp = recoverFingerprint(nodes, ref);
|
|
438
|
+
return { action: chosen ? { role: chosen.role, name: chosen.name, ref, elementFp } : { role: '', name: null, ref } };
|
|
439
|
+
},
|
|
440
|
+
readLine, write: (l) => process.stdout.write(l + '\n'), notify,
|
|
441
|
+
startVideo: async () => {
|
|
442
|
+
try {
|
|
443
|
+
await adapter.videoStart();
|
|
444
|
+
notify('log', 'video: recording started');
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
notify('log', 'video: START FAILED');
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
stopVideo: async () => {
|
|
451
|
+
const dir = join(videosRoot, args.session);
|
|
452
|
+
try {
|
|
453
|
+
mkdirSync(dir, { recursive: true });
|
|
454
|
+
}
|
|
455
|
+
catch { /* */ }
|
|
456
|
+
const file = join(dir, 'take-' + Date.now() + '.webm');
|
|
457
|
+
const ok = await adapter.videoStop(file).catch(() => false);
|
|
458
|
+
const { existsSync } = await import('node:fs');
|
|
459
|
+
// video-stop can return before the .webm is fully flushed; poll briefly so
|
|
460
|
+
// the file is present before this process exits (else the write is lost).
|
|
461
|
+
for (let i = 0; ok && i < 25 && !existsSync(file); i++)
|
|
462
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
463
|
+
if (ok && existsSync(file)) {
|
|
464
|
+
notify('log', 'video: saved ' + file);
|
|
465
|
+
return file;
|
|
466
|
+
}
|
|
467
|
+
notify('log', 'video: no frames captured');
|
|
468
|
+
return null;
|
|
469
|
+
},
|
|
470
|
+
startUrl: args.url,
|
|
471
|
+
});
|
|
472
|
+
rl.close();
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
475
|
+
store.stop(args.session);
|
|
476
|
+
notify('sessions');
|
|
477
|
+
}
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (args.cmd === 'close') {
|
|
481
|
+
// Explicit teardown for a `use` session (agent's "I'm done" — the `use` verbs
|
|
482
|
+
// keep the browser alive between calls, so SOMETHING must close it; this is it).
|
|
483
|
+
if (!args.session) {
|
|
484
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav use close --session <S>' }, null, 2));
|
|
485
|
+
process.exitCode = 2;
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const { closeByName } = await import('./playwright/sessions.js');
|
|
489
|
+
let closed = false;
|
|
490
|
+
try {
|
|
491
|
+
closed = await closeByName(args.session);
|
|
492
|
+
}
|
|
493
|
+
catch { /* already gone */ }
|
|
494
|
+
console.log(JSON.stringify({ status: closed ? 'closed' : 'not-found', session: args.session }, null, 2));
|
|
495
|
+
if (!closed)
|
|
496
|
+
process.exitCode = 3;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
if (args.cmd === 'go-back' || args.cmd === 'reload') {
|
|
500
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
501
|
+
// These only make sense against an EXISTING session the agent has been
|
|
502
|
+
// driving (a fresh session has no page to go back to). --session names it;
|
|
503
|
+
// default 'webnav-nav' is the convenience session for a quick standalone step.
|
|
504
|
+
const adapter = new PlaywrightAdapter(args.session ?? 'webnav-nav');
|
|
505
|
+
try {
|
|
506
|
+
const out = args.cmd === 'go-back' ? await adapter.goBack() : await adapter.reload();
|
|
507
|
+
console.log(JSON.stringify({ status: 'done', action: args.cmd, out: out.trim() }, null, 2));
|
|
508
|
+
}
|
|
509
|
+
catch (e) {
|
|
510
|
+
console.log(JSON.stringify({ status: 'failed', action: args.cmd, reason: String(e) }, null, 2));
|
|
511
|
+
process.exitCode = 3;
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (args.cmd === 'record-start') {
|
|
516
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
517
|
+
const rec = new RecordStore(dbPath());
|
|
518
|
+
const session = args.session || `map-${Date.now()}`;
|
|
519
|
+
rec.start(session);
|
|
520
|
+
rec.setOrigin(session, 'agent');
|
|
521
|
+
console.log(JSON.stringify({ status: 'recording', session }, null, 2));
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (args.cmd === 'record-stop') {
|
|
525
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
526
|
+
new RecordStore(dbPath()).stop(args.session);
|
|
527
|
+
// Close the browser too — record-stop is the end of an agent session, so its
|
|
528
|
+
// window must not leak (the `use` verbs keep the session alive between calls;
|
|
529
|
+
// record-stop is the sanctioned teardown). A long-lived `record-live` owns its
|
|
530
|
+
// own adapter and closes on its own loop end — this closeByName is a no-op there.
|
|
531
|
+
const { closeByName } = await import('./playwright/sessions.js');
|
|
532
|
+
let closed = false;
|
|
533
|
+
try {
|
|
534
|
+
closed = await closeByName(args.session);
|
|
535
|
+
}
|
|
536
|
+
catch { /* already gone */ }
|
|
537
|
+
console.log(JSON.stringify({ status: 'stopped', session: args.session, closed }, null, 2));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
if (args.cmd === 'record-live') {
|
|
541
|
+
// Human-driven recording (vs. record-start's agent-driven `use` loop): opens a headed
|
|
542
|
+
// browser, then runs Task 3's poll loop until Ctrl-C or `record-stop` flips isActive off.
|
|
543
|
+
// Long-lived like `ingest`/`dashboard` — does NOT print-and-exit until stopped.
|
|
544
|
+
if (!args.session || !args.url) {
|
|
545
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev record-live --session <S> --url <U>' }, null, 2));
|
|
546
|
+
process.exitCode = 2;
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const { runLiveRecord } = await import('./recorder/live-record.js');
|
|
550
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
551
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
552
|
+
const { homedir } = await import('node:os');
|
|
553
|
+
const { join } = await import('node:path');
|
|
554
|
+
const store = new RecordStore(dbPath());
|
|
555
|
+
store.start(args.session);
|
|
556
|
+
store.setOrigin(args.session, 'manual');
|
|
557
|
+
const videosRoot = join(homedir(), '.webnav', 'recordings');
|
|
558
|
+
// --profile resolve + prep (parity with the dashboard/agent paths): one
|
|
559
|
+
// long-lived process OWNS the session, so video capture survives the whole span.
|
|
560
|
+
const rlBrowser = { ...args.browser };
|
|
561
|
+
if (rlBrowser.profile) {
|
|
562
|
+
const { resolveProfile } = await import('./playwright/adapter.js');
|
|
563
|
+
const { prepProfile } = await import('./playwright/profile-lock.js');
|
|
564
|
+
rlBrowser.profile = resolveProfile(rlBrowser.profile, join(homedir(), '.webnav', 'profiles'));
|
|
565
|
+
try {
|
|
566
|
+
(await import('node:fs')).mkdirSync(rlBrowser.profile, { recursive: true });
|
|
567
|
+
}
|
|
568
|
+
catch { /* */ }
|
|
569
|
+
prepProfile(rlBrowser.profile);
|
|
570
|
+
store.setProfile(args.session, rlBrowser.profile.split('/').pop());
|
|
571
|
+
}
|
|
572
|
+
store.setStartUrl(args.session, args.url);
|
|
573
|
+
// finally-guard: a throw anywhere below (adapter.open on a dead URL, the loop
|
|
574
|
+
// itself) must not leave the record session dangling active=1 in the DB.
|
|
575
|
+
try {
|
|
576
|
+
const adapter = new PlaywrightAdapter(args.session, undefined, undefined, rlBrowser);
|
|
577
|
+
await adapter.open(args.url);
|
|
578
|
+
// Video: started here, stopped in finally — SAME process owns the session the
|
|
579
|
+
// whole time, so playwright-cli actually records it (the split-process agent
|
|
580
|
+
// flow could not — this long-lived process is how agent sessions get video).
|
|
581
|
+
// Video: started here, stopped in onBeforeClose (while the session is still
|
|
582
|
+
// open — after adapter.close() playwright-cli saves nothing). One long-lived
|
|
583
|
+
// process owns the session, so unlike the split agent CLI calls this CAN record.
|
|
584
|
+
const { mkdirSync } = await import('node:fs');
|
|
585
|
+
let videoStarted = false;
|
|
586
|
+
await adapter.videoStart().then(() => { videoStarted = true; process.stderr.write('video: recording started\n'); }, () => process.stderr.write('video: START FAILED\n'));
|
|
587
|
+
let stopped = false;
|
|
588
|
+
process.on('SIGINT', () => { stopped = true; });
|
|
589
|
+
process.stderr.write(`recording — drive the browser (human clicks or agent \`use\` on session ${args.session}); stop with Ctrl-C or \`webnav dev record-stop --session ${args.session}\`\n`);
|
|
590
|
+
const res = await runLiveRecord({
|
|
591
|
+
adapter, store, sessionId: args.session, intervalMs: args.interval,
|
|
592
|
+
log: (l) => process.stderr.write(l + '\n'), isStopped: () => stopped,
|
|
593
|
+
onBeforeClose: async () => {
|
|
594
|
+
if (!videoStarted)
|
|
595
|
+
return;
|
|
596
|
+
const dir = join(videosRoot, args.session);
|
|
597
|
+
try {
|
|
598
|
+
mkdirSync(dir, { recursive: true });
|
|
599
|
+
}
|
|
600
|
+
catch { /* */ }
|
|
601
|
+
const file = join(dir, 'take-' + Date.now() + '.webm');
|
|
602
|
+
const ok = await adapter.videoStop(file).catch(() => false);
|
|
603
|
+
const { existsSync } = await import('node:fs');
|
|
604
|
+
process.stderr.write(ok && existsSync(file) ? 'video: saved ' + file + '\n' : 'video: no frames captured\n');
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
console.log(JSON.stringify({
|
|
608
|
+
status: 'stopped', session: args.session, appended: res.appended,
|
|
609
|
+
next: `webnav dev graph-analyse --session ${args.session} --draft`,
|
|
610
|
+
}, null, 2));
|
|
611
|
+
if (res.appended === 0)
|
|
612
|
+
process.exitCode = 3;
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
store.stop(args.session);
|
|
616
|
+
}
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (args.cmd === 'graph-analyse') {
|
|
620
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
621
|
+
const store = new RecordStore(dbPath());
|
|
622
|
+
// Resolve the session set: explicit --session (repeatable), or every recorded session
|
|
623
|
+
// for --host. MULTIPLE sessions of one site fold into ONE draft: their effects are
|
|
624
|
+
// concatenated in order and stable-pathname keying merges same-page visits automatically.
|
|
625
|
+
let sessionIds = args.sessions;
|
|
626
|
+
if (args.host) {
|
|
627
|
+
sessionIds = store.listSessions().filter((s) => s.site === args.host && s.steps > 0)
|
|
628
|
+
.map((s) => s.sessionId);
|
|
629
|
+
}
|
|
630
|
+
if (sessionIds.length === 0) {
|
|
631
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev graph-analyse --session <S> [--session <S2> …] [--host <h>] [--draft]' }, null, 2));
|
|
632
|
+
process.exitCode = 2;
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
// APPROVAL GATE: only build the map from sessions whose capture-review PASSED. A failed OR
|
|
636
|
+
// never-reviewed session is NOT trusted — training the graph on it would bake in whatever the
|
|
637
|
+
// review flagged (a missed step, a broken capture). --skip-review-gate bypasses for a
|
|
638
|
+
// deliberate raw build. Excluded sessions are reported, not silently dropped.
|
|
639
|
+
const excluded = [];
|
|
640
|
+
if (!args.skipReviewGate) {
|
|
641
|
+
const kept = [];
|
|
642
|
+
for (const id of sessionIds) {
|
|
643
|
+
const rev = store.reviewOf(id);
|
|
644
|
+
if (rev?.approved)
|
|
645
|
+
kept.push(id);
|
|
646
|
+
else
|
|
647
|
+
excluded.push({ session: id, reason: rev ? `review failed (${rev.gaps} gap${rev.gaps === 1 ? '' : 's'})` : 'never reviewed' });
|
|
648
|
+
}
|
|
649
|
+
sessionIds = kept;
|
|
650
|
+
if (sessionIds.length === 0) {
|
|
651
|
+
console.log(JSON.stringify({ status: 'error', reason: 'no APPROVED sessions to build from', excluded,
|
|
652
|
+
hint: 'run `webnav dev review --session <S>` until it passes, or pass --skip-review-gate to build from raw sessions anyway' }, null, 2));
|
|
653
|
+
process.exitCode = 2;
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const effects = sessionIds.flatMap((id) => store.actionEffects(id));
|
|
658
|
+
if (args.draft) {
|
|
659
|
+
// --draft: fold the recorded walk-through(s) into a ready, SELF-VERIFIED {node,states,edges}
|
|
660
|
+
// graph-edit spec (absolute URLs, uniqueness fingerprints, resolvable edges) so learning
|
|
661
|
+
// is "drive → accept", not hand-author. The agent pipes it straight to graph-edit.
|
|
662
|
+
const { draftFromEffects } = await import('./explorer/draft.js');
|
|
663
|
+
const draft = draftFromEffects(effects);
|
|
664
|
+
console.log(JSON.stringify({ status: draft.states.length ? 'done' : 'empty', sessions: sessionIds,
|
|
665
|
+
...(excluded.length ? { excludedUnverified: excluded } : {}), ...draft }, null, 2));
|
|
666
|
+
if (draft.states.length === 0)
|
|
667
|
+
process.exitCode = 3;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const { analyseActionEffects } = await import('./explorer/analyse.js');
|
|
671
|
+
const result = analyseActionEffects(effects);
|
|
672
|
+
console.log(JSON.stringify({ ...result, ...(excluded.length ? { excludedUnverified: excluded } : {}) }, null, 2));
|
|
673
|
+
if (result.sites.length === 0)
|
|
674
|
+
process.exitCode = 3;
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
if (args.cmd === 'ingest') {
|
|
678
|
+
// Long-lived localhost receiver (like `dashboard`/`mcp`): does NOT print-and-exit.
|
|
679
|
+
// The webnav-extension Chrome extension POSTs recorded sessions here; they land
|
|
680
|
+
// in webnav.db as ActionEffects via `serveIngest` -> `ingest` (Task 2).
|
|
681
|
+
const { serveIngest } = await import('./recorder/ingest.js');
|
|
682
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
683
|
+
const server = serveIngest(args.port, new RecordStore(dbPath()));
|
|
684
|
+
process.stderr.write(`webnav ingest listening on http://127.0.0.1:${args.port}/ingest\n`);
|
|
685
|
+
console.log(JSON.stringify({ status: 'listening', port: args.port }));
|
|
686
|
+
await new Promise(() => { }); // run until killed
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (args.cmd === 'agent-serve') {
|
|
690
|
+
// Long-lived localhost receiver (like `ingest`/`dashboard`): the Chrome extension
|
|
691
|
+
// sidePanel's local server. Streams AgentEvent over SSE, accepts a goal, runs the
|
|
692
|
+
// agent loop over a real AgentChannel that drives the extension's tab, and mounts
|
|
693
|
+
// /ingest-ax so a live goal run is recorded through the same path human/agent
|
|
694
|
+
// recordings use (Task 3).
|
|
695
|
+
const { serveAgent } = await import('./agent/server.js');
|
|
696
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
697
|
+
const { runAgentGoal } = await import('./agent/loop.js');
|
|
698
|
+
const { makeLiveExtensionBrowser } = await import('./router/live-extension-browser.js');
|
|
699
|
+
const { ingestAX } = await import('./recorder/ingest.js');
|
|
700
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
701
|
+
const { ensureSeeded } = await import('./graph/seed.js');
|
|
702
|
+
// One RecordStore for the server's lifetime: it backs BOTH the /ingest-ax route AND
|
|
703
|
+
// the end-of-run flush below, so an agent run lands in the same webnav.db `dev
|
|
704
|
+
// dashboard` reads and run-2 recall learns from.
|
|
705
|
+
const recordStore = new RecordStore(dbPath());
|
|
706
|
+
// ponytail: one MapStore for the server's lifetime — same seeded map the `walk`
|
|
707
|
+
// verb uses, so check_route/walkRoute route against the real states. Extra sites
|
|
708
|
+
// recorded via /ingest-ax build the RecordStore, not this map; that's fine — the
|
|
709
|
+
// loop drives manually when the live page isn't a known state.
|
|
710
|
+
const mapStore = new MapStore();
|
|
711
|
+
ensureSeeded(mapStore);
|
|
712
|
+
const states = mapStore.allStates();
|
|
713
|
+
// onGoal: a goal POST runs the loop. Inputs = {} for v1 — the goal body carries no
|
|
714
|
+
// site/start-state to key CredStore by; the extension user is already logged in on
|
|
715
|
+
// the live tab, and creds-injection is a walk-verb concern. Real SDK query runs.
|
|
716
|
+
const onGoal = async (goal, channel, emit, awaitApproval, signal, resumeSessionId, onSdkSession) => {
|
|
717
|
+
const browser = makeLiveExtensionBrowser(channel, {});
|
|
718
|
+
// /stop aborts `signal`; runAgentGoal bridges it to the SDK query's abortController
|
|
719
|
+
// (loop.ts), so /stop cancels the in-flight turn — not just the next browser command.
|
|
720
|
+
try {
|
|
721
|
+
await runAgentGoal({
|
|
722
|
+
goal: goal.goal,
|
|
723
|
+
sessionId: goal.sessionId,
|
|
724
|
+
mode: goal.mode === 'ask' ? 'ask' : 'act',
|
|
725
|
+
model: goal.model,
|
|
726
|
+
browser,
|
|
727
|
+
store: mapStore,
|
|
728
|
+
states,
|
|
729
|
+
emit,
|
|
730
|
+
awaitApproval,
|
|
731
|
+
signal,
|
|
732
|
+
// Conversation continuity: resume the SDK session the server remembered for this
|
|
733
|
+
// panel conversation, and hand the captured session id back so the NEXT goal
|
|
734
|
+
// resumes it too. No re-listing routes / re-orienting on follow-ups.
|
|
735
|
+
resumeSessionId,
|
|
736
|
+
onSdkSession,
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
// RECORD the run into the map on END (done / error / stop). Housekeeping never
|
|
741
|
+
// breaks the command (guardrail-sweep rule): a record failure is a warning, never
|
|
742
|
+
// a thrown goal. Empty runs (no action taken) record nothing.
|
|
743
|
+
try {
|
|
744
|
+
const steps = browser.getRecordedSteps();
|
|
745
|
+
if (steps.length) {
|
|
746
|
+
ingestAX({ sessionId: goal.sessionId, steps }, recordStore);
|
|
747
|
+
recordStore.setOrigin(goal.sessionId, 'extension'); // tag: driven by the Chrome extension
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
catch (e) {
|
|
751
|
+
process.stderr.write(`webnav agent-serve: recording flush failed (run unaffected): ${String(e)}\n`);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
// Per-run auth secret: the extension must present it on every /api/agent/* call
|
|
756
|
+
// (header on POSTs, ?token= on the SSE GET). Without it any web page (DNS-rebind /
|
|
757
|
+
// localhost fetch) or local process could POST a goal and drive the user's browser.
|
|
758
|
+
// `--token <hex>` pins a stable token across restarts (paste once into the panel);
|
|
759
|
+
// omitted → a fresh random token every run, as before.
|
|
760
|
+
// Assemble a session video from the extension's CDP screencast frames. Frames are
|
|
761
|
+
// base64 JPEGs with ms-relative timestamps; the concat demuxer sets per-frame durations
|
|
762
|
+
// and `-r 15 -fps_mode cfr` re-times to a CONSTANT frame rate — without CFR the output
|
|
763
|
+
// has irregular PTS and no duration metadata, which browsers show as an unseekable video
|
|
764
|
+
// that jumps 0→end on play (the observed bug). Idle gaps are capped so a "thinking" pause
|
|
765
|
+
// doesn't become a frozen minute. Writes to ~/.webnav/recordings/<sessionId>/, exactly
|
|
766
|
+
// where the dashboard serves videos. Best-effort: failures log, never break the run.
|
|
767
|
+
const onScreencast = async (sessionId, frames) => {
|
|
768
|
+
if (!frames.length)
|
|
769
|
+
return;
|
|
770
|
+
const { homedir, tmpdir } = await import('node:os');
|
|
771
|
+
const { join } = await import('node:path');
|
|
772
|
+
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import('node:fs');
|
|
773
|
+
const { spawn } = await import('node:child_process');
|
|
774
|
+
const tmp = mkdtempSync(join(tmpdir(), 'webnav-scr-'));
|
|
775
|
+
const MAX_HOLD_S = 1.5; // cap a single frame's on-screen time (idle/thinking gaps)
|
|
776
|
+
try {
|
|
777
|
+
const lines = [];
|
|
778
|
+
for (let i = 0; i < frames.length; i++) {
|
|
779
|
+
const f = join(tmp, 'f' + String(i).padStart(5, '0') + '.jpg');
|
|
780
|
+
writeFileSync(f, Buffer.from(frames[i].data, 'base64'));
|
|
781
|
+
const nextMs = i + 1 < frames.length ? frames[i + 1].timestampMs : frames[i].timestampMs + 500;
|
|
782
|
+
const dur = Math.min(MAX_HOLD_S, Math.max(0.06, (nextMs - frames[i].timestampMs) / 1000));
|
|
783
|
+
lines.push("file '" + f + "'", 'duration ' + dur.toFixed(3));
|
|
784
|
+
}
|
|
785
|
+
lines.push("file '" + join(tmp, 'f' + String(frames.length - 1).padStart(5, '0') + '.jpg') + "'"); // concat quirk: repeat last
|
|
786
|
+
const listPath = join(tmp, 'frames.txt');
|
|
787
|
+
writeFileSync(listPath, lines.join('\n'));
|
|
788
|
+
const outDir = join(homedir(), '.webnav', 'recordings', sessionId);
|
|
789
|
+
mkdirSync(outDir, { recursive: true });
|
|
790
|
+
const outFile = join(outDir, 'take-' + Date.now() + '.webm');
|
|
791
|
+
await new Promise((resolve, reject) => {
|
|
792
|
+
const ff = spawn('ffmpeg', ['-y', '-f', 'concat', '-safe', '0', '-i', listPath,
|
|
793
|
+
'-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p',
|
|
794
|
+
'-r', '15', '-fps_mode', 'cfr', '-c:v', 'libvpx-vp9', '-b:v', '1M', outFile], { stdio: 'ignore' });
|
|
795
|
+
ff.on('error', reject);
|
|
796
|
+
ff.on('exit', (code) => code === 0 ? resolve() : reject(new Error('ffmpeg exit ' + code)));
|
|
797
|
+
});
|
|
798
|
+
process.stderr.write('video: saved ' + outFile + ' (' + frames.length + ' frames)\n');
|
|
799
|
+
}
|
|
800
|
+
catch (e) {
|
|
801
|
+
process.stderr.write('video: assembly failed (run unaffected): ' + String(e) + '\n');
|
|
802
|
+
}
|
|
803
|
+
finally {
|
|
804
|
+
try {
|
|
805
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
806
|
+
}
|
|
807
|
+
catch { /* */ }
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
const { randomBytes } = await import('node:crypto');
|
|
811
|
+
const token = args.token || randomBytes(16).toString('hex');
|
|
812
|
+
const tokenMode = args.token ? 'pinned via --token' : 'random per-run';
|
|
813
|
+
const server = serveAgent(args.port, recordStore, { onGoal, token, onScreencast });
|
|
814
|
+
process.stderr.write(`webnav agent-serve on http://127.0.0.1:${args.port} token: ${token} (${tokenMode}) (paste this into the extension panel settings)\n`);
|
|
815
|
+
console.log(JSON.stringify({ status: 'listening', port: args.port, token }));
|
|
816
|
+
await new Promise(() => { }); // run until killed
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (args.cmd === 'record-rename') {
|
|
820
|
+
// Rename a recording's id (DB row + its observations) AND move its on-disk video/review
|
|
821
|
+
// dirs, so a session reads as what it captures (reports-list) not an ad-hoc id (s1final).
|
|
822
|
+
if (!args.from || !args.to) {
|
|
823
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev record-rename --from <id> --to <id>' }, null, 2));
|
|
824
|
+
process.exitCode = 2;
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
828
|
+
const { homedir } = await import('node:os');
|
|
829
|
+
const { join } = await import('node:path');
|
|
830
|
+
const { renameSync, existsSync } = await import('node:fs');
|
|
831
|
+
const ok = new RecordStore(dbPath()).renameSession(args.from, args.to);
|
|
832
|
+
if (!ok) {
|
|
833
|
+
console.log(JSON.stringify({ status: 'error', reason: `cannot rename: '${args.from}' unknown or '${args.to}' already exists` }, null, 2));
|
|
834
|
+
process.exitCode = 2;
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
// move on-disk recordings/ + reviews/ dirs to match (best-effort; DB is the source of truth)
|
|
838
|
+
for (const root of ['recordings', 'reviews']) {
|
|
839
|
+
const src = join(homedir(), '.webnav', root, args.from);
|
|
840
|
+
const dst = join(homedir(), '.webnav', root, args.to);
|
|
841
|
+
if (existsSync(src) && !existsSync(dst)) {
|
|
842
|
+
try {
|
|
843
|
+
renameSync(src, dst);
|
|
844
|
+
}
|
|
845
|
+
catch { /* */ }
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
console.log(JSON.stringify({ status: 'done', from: args.from, to: args.to }, null, 2));
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (args.cmd === 'review') {
|
|
852
|
+
// Audit ONE recorded session's VIDEO against its captured STEPS (Sonnet over ffmpeg
|
|
853
|
+
// frames) → capture gaps. Writes a review verdict tag on the session: APPROVED (zero
|
|
854
|
+
// gaps → graph-ready) or needs-fix (gaps listed). This is the per-session gate the
|
|
855
|
+
// user asked for. Reuses runSessionReview (the same call capture-loop makes).
|
|
856
|
+
if (!args.session) {
|
|
857
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev review --session <S> [--model sonnet]' }, null, 2));
|
|
858
|
+
process.exitCode = 2;
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
862
|
+
const { runSessionReview } = await import('./recorder/review.js');
|
|
863
|
+
const { homedir } = await import('node:os');
|
|
864
|
+
const { join } = await import('node:path');
|
|
865
|
+
const store = new RecordStore(dbPath());
|
|
866
|
+
const fx = store.actionEffects(args.session);
|
|
867
|
+
if (fx.length === 0) {
|
|
868
|
+
console.log(JSON.stringify({ status: 'empty', session: args.session, hint: 'no captured steps — nothing to review' }, null, 2));
|
|
869
|
+
process.exitCode = 3;
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const steps = fx.map((e) => ({
|
|
873
|
+
seq: e.seq,
|
|
874
|
+
kind: e.action ? (e.action.hover ? 'hover' : e.navigated ? 'navigate' : e.action.role === 'textbox' ? 'input' : 'click') : (e.navigated ? 'jump' : 'observe'),
|
|
875
|
+
label: e.action?.name ?? e.toUrl, value: e.action?.value, capturedAt: e.capturedAt,
|
|
876
|
+
}));
|
|
877
|
+
const { coverage, landingStructure } = await import('./recorder/coverage.js');
|
|
878
|
+
const cov = coverage(store.events(args.session));
|
|
879
|
+
const videosRoot = join(homedir(), '.webnav', 'recordings');
|
|
880
|
+
const reviewsRoot = join(homedir(), '.webnav', 'reviews');
|
|
881
|
+
const res = await runSessionReview(args.session, {
|
|
882
|
+
videosDir: join(videosRoot, args.session), outDir: join(reviewsRoot, args.session),
|
|
883
|
+
steps, logs: [], log: (l) => process.stderr.write(l + '\n'),
|
|
884
|
+
claudeModel: args.model, instructions: args.instructions, structured: true,
|
|
885
|
+
knownDrops: cov.dropped, coverage: cov, structure: landingStructure(fx),
|
|
886
|
+
});
|
|
887
|
+
const gaps = typeof res === 'string' ? [] : res.gaps;
|
|
888
|
+
const approved = gaps.length === 0;
|
|
889
|
+
const at = Date.now();
|
|
890
|
+
store.setReview(args.session, { approved, gaps: gaps.length, at, model: args.model,
|
|
891
|
+
reason: approved ? 'all on-screen actions captured' : `${gaps.length} capture gap(s)` });
|
|
892
|
+
console.log(JSON.stringify({ status: approved ? 'approved' : 'needs-fix', session: args.session,
|
|
893
|
+
approved, gaps, coverage: cov, report: join(reviewsRoot, args.session, 'review.md') }, null, 2));
|
|
894
|
+
if (!approved)
|
|
895
|
+
process.exitCode = 3;
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
if (args.cmd === 'effects') {
|
|
899
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
900
|
+
const effects = new RecordStore(dbPath()).actionEffects(args.session);
|
|
901
|
+
console.log(JSON.stringify({ status: effects.length ? 'done' : 'empty', session: args.session, effects }, null, 2));
|
|
902
|
+
if (effects.length === 0)
|
|
903
|
+
process.exitCode = 3;
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (args.cmd === 'capture-loop') {
|
|
907
|
+
// Self-improving capture loop: each round runs --explore-cmd (which drives ONE
|
|
908
|
+
// agent exploration of the objective via `use session`, recording session
|
|
909
|
+
// $WEBNAV_LOOP_SESSION), then a STRUCTURED review audits video-vs-steps for gaps.
|
|
910
|
+
// Converges on a clean audit; else exits 3 with the gaps for a recorder-code fix.
|
|
911
|
+
// webnav stays zero-LLM: the exploring AGENT lives in --explore-cmd (caller wires
|
|
912
|
+
// a Haiku driver), not here. spec: 2026-07-09-capture-improvement-loop-design.md
|
|
913
|
+
if (!args.objective || !args.exploreCmd) {
|
|
914
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev capture-loop --objective "<text>" --explore-cmd "<cmd that drives $WEBNAV_LOOP_SESSION>" [--max-rounds N] [--model sonnet]' }, null, 2));
|
|
915
|
+
process.exitCode = 2;
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
const { runCaptureLoop } = await import('./recorder/capture-loop.js');
|
|
919
|
+
const { runSessionReview } = await import('./recorder/review.js');
|
|
920
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
921
|
+
const { execSync } = await import('node:child_process');
|
|
922
|
+
const { homedir } = await import('node:os');
|
|
923
|
+
const { join } = await import('node:path');
|
|
924
|
+
const store = new RecordStore(dbPath());
|
|
925
|
+
const videosRoot = join(homedir(), '.webnav', 'recordings');
|
|
926
|
+
const reviewsRoot = join(homedir(), '.webnav', 'reviews');
|
|
927
|
+
const result = await runCaptureLoop({
|
|
928
|
+
objective: args.objective, maxRounds: args.maxRounds,
|
|
929
|
+
log: (l) => process.stderr.write(l + '\n'),
|
|
930
|
+
explore: async (round) => {
|
|
931
|
+
// SHORT session name: the playwright-cli daemon socket path embeds it and
|
|
932
|
+
// macOS caps socket paths at ~104 chars (learned before). base36 seconds + round.
|
|
933
|
+
const stamp = Math.floor(Date.now() / 1000).toString(36); // ~6 chars
|
|
934
|
+
const session = (args.sessionPrefix + 'r' + round + stamp).slice(0, 14);
|
|
935
|
+
try {
|
|
936
|
+
execSync(args.exploreCmd, { stdio: 'inherit', timeout: 5 * 60_000,
|
|
937
|
+
env: { ...process.env, WEBNAV_LOOP_SESSION: session, WEBNAV_LOOP_OBJECTIVE: args.objective } });
|
|
938
|
+
}
|
|
939
|
+
catch { /* explore-cmd non-zero → treat as recorded-what-it-could */ }
|
|
940
|
+
// re-open the store to see the subprocess's committed writes (a long-lived
|
|
941
|
+
// connection can hold a stale snapshot across another process's commit).
|
|
942
|
+
const fresh = new RecordStore(dbPath());
|
|
943
|
+
return fresh.actionEffects(session).length ? session : null;
|
|
944
|
+
},
|
|
945
|
+
review: async (session) => {
|
|
946
|
+
const fresh = new RecordStore(dbPath());
|
|
947
|
+
const fx = fresh.actionEffects(session);
|
|
948
|
+
const steps = fx.map((e) => ({
|
|
949
|
+
seq: e.seq, kind: e.action ? (e.action.hover ? 'hover' : e.navigated ? 'navigate' : e.action.role === 'textbox' ? 'input' : 'click') : (e.navigated ? 'jump' : 'observe'),
|
|
950
|
+
label: e.action?.name ?? e.toUrl, value: e.action?.value, capturedAt: e.capturedAt,
|
|
951
|
+
}));
|
|
952
|
+
const { coverage, landingStructure } = await import('./recorder/coverage.js');
|
|
953
|
+
const cov = coverage(fresh.events(session));
|
|
954
|
+
const res = await runSessionReview(session, { videosDir: join(videosRoot, session), outDir: join(reviewsRoot, session),
|
|
955
|
+
steps, logs: [], log: (l) => process.stderr.write(l + '\n'), claudeModel: args.model, structured: true,
|
|
956
|
+
knownDrops: cov.dropped, coverage: cov, structure: landingStructure(fx) });
|
|
957
|
+
return typeof res === 'string' ? [] : res.gaps;
|
|
958
|
+
},
|
|
959
|
+
});
|
|
960
|
+
console.log(JSON.stringify({ status: result.status,
|
|
961
|
+
rounds: result.rounds.map((r) => ({ round: r.round, session: r.session, gaps: r.gaps.length })),
|
|
962
|
+
gaps: result.gaps }, null, 2));
|
|
963
|
+
if (result.status !== 'clean')
|
|
964
|
+
process.exitCode = 3; // needs-fix / max-rounds
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (args.cmd === 'verify') {
|
|
968
|
+
// Phase 5: check a hand-authored map's affordance fingerprints resolve UNIQUELY against
|
|
969
|
+
// the live page the session is currently on. graph-edit is offline; this is the only
|
|
970
|
+
// place an authored elementFp is validated against real bytes. v1 = single page (the
|
|
971
|
+
// state the browser is on); multi-state drive-through is a follow-up.
|
|
972
|
+
if (!args.node || !args.session) {
|
|
973
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev verify --node <id> --session <S>' }, null, 2));
|
|
974
|
+
process.exitCode = 2;
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
978
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
979
|
+
const { parseSnapshot } = await import('./playwright/snapshot.js');
|
|
980
|
+
const { matchState } = await import('./explorer/fingerprint.js');
|
|
981
|
+
const { resolveByFingerprint } = await import('./playwright/fingerprint.js');
|
|
982
|
+
const store = new MapStore(dbPath());
|
|
983
|
+
const nodes = parseSnapshot(await new PlaywrightAdapter(args.session).snapshot());
|
|
984
|
+
const matched = matchState(nodes, store.statesForNode(args.node));
|
|
985
|
+
if (matched.status !== 'matched') {
|
|
986
|
+
console.log(JSON.stringify({ status: 'no-match', node: args.node, reason: matched.status }, null, 2));
|
|
987
|
+
process.exitCode = 3;
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
const state = matched.state;
|
|
991
|
+
const checks = [];
|
|
992
|
+
const walkAffs = (affs) => {
|
|
993
|
+
for (const a of affs) {
|
|
994
|
+
if (a.elementFp && (a.kind === 'navigate' || a.kind === 'reveal' || a.kind === 'input')) {
|
|
995
|
+
const ref = resolveByFingerprint(a.elementFp, nodes);
|
|
996
|
+
const all = nodes.filter((n) => n.ref && n.role === a.elementFp.role && n.name === a.elementFp.name).map((n) => n.ref);
|
|
997
|
+
checks.push({ id: a.id, label: a.label, unique: ref !== null, matchedRefs: ref ? [ref] : all });
|
|
998
|
+
}
|
|
999
|
+
if (a.children)
|
|
1000
|
+
walkAffs(a.children);
|
|
1001
|
+
}
|
|
1002
|
+
};
|
|
1003
|
+
walkAffs(state.affordances ?? []);
|
|
1004
|
+
const allUnique = checks.every((c) => c.unique);
|
|
1005
|
+
console.log(JSON.stringify({ status: allUnique ? 'done' : 'non-unique', node: args.node, state: state.id, affordances: checks }, null, 2));
|
|
1006
|
+
if (!allUnique)
|
|
1007
|
+
process.exitCode = 3;
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (args.cmd === 'hover-probe') {
|
|
1011
|
+
// X2 (spec 2026-07-16 §2): an OPT-IN pass over the CURRENT page of a LIVE recording
|
|
1012
|
+
// session. Hover (or --right-click) each structural candidate, diff the reveal, and
|
|
1013
|
+
// append a reveal ActionEffect to the same recording. Attaches by session name (the
|
|
1014
|
+
// `dev verify --session` shape) and appends like the agent-session loop.
|
|
1015
|
+
if (!args.session) {
|
|
1016
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev hover-probe --session <S> [--limit N] [--right-click]' }, null, 2));
|
|
1017
|
+
process.exitCode = 2;
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
1021
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
1022
|
+
const { runHoverProbe } = await import('./recorder/hover-probe.js');
|
|
1023
|
+
const store = new RecordStore(dbPath());
|
|
1024
|
+
// The probe WRITES effects; a session that isn't recording would silently drop them
|
|
1025
|
+
// (appendEvent/appendActionEffect are isActive-gated). That's dishonest, so refuse.
|
|
1026
|
+
if (!store.isActive(args.session)) {
|
|
1027
|
+
console.log(JSON.stringify({ status: 'error', session: args.session, hint: `session '${args.session}' is not recording — start it with 'dev record-start --session ${args.session}' and drive it to the page first` }, null, 2));
|
|
1028
|
+
process.exitCode = 2;
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
const adapter = new PlaywrightAdapter(args.session);
|
|
1032
|
+
const { probed, revealed } = await runHoverProbe({
|
|
1033
|
+
adapter, store, sessionId: args.session, limit: args.limit, rightClick: args.rightClick,
|
|
1034
|
+
log: (l) => process.stderr.write(l + '\n'),
|
|
1035
|
+
});
|
|
1036
|
+
console.log(JSON.stringify({ status: revealed ? 'done' : 'empty', session: args.session, probed, revealed }, null, 2));
|
|
1037
|
+
if (revealed === 0)
|
|
1038
|
+
process.exitCode = 3;
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (args.cmd === 'profile-status') {
|
|
1042
|
+
// "Am I logged in?" checked BEFORE walking/recording an authed site, instead of
|
|
1043
|
+
// guessing and hitting a stale-login wall mid-walk. One polite headless load of
|
|
1044
|
+
// the site's entry url (map homeUrl, or --url override), settled + classified
|
|
1045
|
+
// against the map's own fingerprints (the oracle) — then the session this verb
|
|
1046
|
+
// opened is ALWAYS reaped, success or failure.
|
|
1047
|
+
if (!args.profile || !args.site) {
|
|
1048
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav dev profile-status --profile <p> --site <host> [--url <u>]' }, null, 2));
|
|
1049
|
+
process.exitCode = 2;
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1053
|
+
const store = new MapStore(dbPath());
|
|
1054
|
+
const node = store.getNode(args.site);
|
|
1055
|
+
const url = args.url ?? node?.homeUrl;
|
|
1056
|
+
if (!url) {
|
|
1057
|
+
console.log(JSON.stringify({ status: 'error', hint: `no map for site '${args.site}' yet (no homeUrl) — pass --url, or map the site first with dev record-start` }, null, 2));
|
|
1058
|
+
process.exitCode = 2;
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const { homedir: homedir3 } = await import('node:os');
|
|
1062
|
+
const { join: join3 } = await import('node:path');
|
|
1063
|
+
const { resolveProfile, PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
1064
|
+
const { prepProfile } = await import('./playwright/profile-lock.js');
|
|
1065
|
+
const { settleSnapshot } = await import('./router/browse.js');
|
|
1066
|
+
const { classifyAuthLanding } = await import('./router/auth-status.js');
|
|
1067
|
+
const profilesRoot3 = join3(homedir3(), '.webnav', 'profiles');
|
|
1068
|
+
const profileDir = resolveProfile(args.profile, profilesRoot3);
|
|
1069
|
+
try {
|
|
1070
|
+
(await import('node:fs')).mkdirSync(profileDir, { recursive: true });
|
|
1071
|
+
}
|
|
1072
|
+
catch { /* */ }
|
|
1073
|
+
prepProfile(profileDir);
|
|
1074
|
+
// SHORT session id: the playwright-cli daemon socket path embeds it and macOS
|
|
1075
|
+
// caps socket paths at ~104 chars (same constraint as capture-loop's session name).
|
|
1076
|
+
const session = 'pchk-' + Math.random().toString(36).slice(2, 6);
|
|
1077
|
+
const adapter = new PlaywrightAdapter(session, undefined, undefined, { headed: false, persistent: true, profile: profileDir });
|
|
1078
|
+
try {
|
|
1079
|
+
await adapter.open(url);
|
|
1080
|
+
const landedUrl = await adapter.currentUrl();
|
|
1081
|
+
const snapshot = await settleSnapshot(() => adapter.snapshot());
|
|
1082
|
+
const states = store.statesForNode(args.site);
|
|
1083
|
+
const { auth, loginUrl } = classifyAuthLanding(landedUrl, snapshot, args.site, states);
|
|
1084
|
+
console.log(JSON.stringify({
|
|
1085
|
+
status: 'ok', auth, site: args.site, profile: args.profile,
|
|
1086
|
+
checkedAt: new Date().toISOString(), ...(loginUrl ? { loginUrl } : {}),
|
|
1087
|
+
}, null, 2));
|
|
1088
|
+
// The verb ran fine even when auth is stale — needs-login is exit 0 (a normal,
|
|
1089
|
+
// useful answer), not exit 3 (ran-but-empty/failed). Documented in cli-spec help.
|
|
1090
|
+
}
|
|
1091
|
+
catch (e) {
|
|
1092
|
+
console.log(JSON.stringify({ status: 'failed', reason: String(e) }, null, 2));
|
|
1093
|
+
process.exitCode = 2;
|
|
1094
|
+
}
|
|
1095
|
+
finally {
|
|
1096
|
+
await adapter.close().catch(() => { });
|
|
1097
|
+
}
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
if (args.cmd === 'sessions') {
|
|
1101
|
+
const { listSessions, reapSessions } = await import('./playwright/sessions.js');
|
|
1102
|
+
const now = Date.now();
|
|
1103
|
+
if (args.sub === 'reap') {
|
|
1104
|
+
const maxAgeMs = args.maxAgeHours !== undefined ? args.maxAgeHours * 3600_000 : undefined;
|
|
1105
|
+
const closed = await reapSessions(now, { all: args.all, maxAgeMs });
|
|
1106
|
+
console.log(JSON.stringify({ status: closed.length ? 'done' : 'empty', reaped: closed }, null, 2));
|
|
1107
|
+
if (closed.length === 0)
|
|
1108
|
+
process.exitCode = 3;
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
// default: list
|
|
1112
|
+
const sessions = (await listSessions(now)).map((s) => ({
|
|
1113
|
+
name: s.name, live: s.live,
|
|
1114
|
+
ageHours: s.ageMs === Infinity ? null : Math.round(s.ageMs / 3600_000 * 10) / 10,
|
|
1115
|
+
}));
|
|
1116
|
+
console.log(JSON.stringify({ status: sessions.length ? 'done' : 'empty', sessions }, null, 2));
|
|
1117
|
+
if (sessions.length === 0)
|
|
1118
|
+
process.exitCode = 3;
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (args.cmd === 'mcp') {
|
|
1122
|
+
// Server mode: stdout carries JSON-RPC (the MCP stdio transport) until
|
|
1123
|
+
// stdin closes — the documented exception to one-JSON-object-stdout.
|
|
1124
|
+
const { startMcpServer } = await import('./mcp/server.js');
|
|
1125
|
+
await startMcpServer();
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
if (args.cmd === 'graph-edit') {
|
|
1129
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1130
|
+
const { editGraph } = await import('./graph/edit.js');
|
|
1131
|
+
const store = new MapStore(dbPath());
|
|
1132
|
+
const graph = JSON.parse(args.graph);
|
|
1133
|
+
console.log(JSON.stringify(editGraph(store, args.node, graph), null, 2));
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
if (args.cmd === 'graph-show') {
|
|
1137
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1138
|
+
const { showInterior } = await import('./graph/show.js');
|
|
1139
|
+
console.log(JSON.stringify(showInterior(new MapStore(dbPath()), args.node), null, 2));
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (args.cmd === 'node-clear') {
|
|
1143
|
+
// Wipe a node's interior so it can be re-learned through webnav (never raw sqlite).
|
|
1144
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1145
|
+
if (!args.node) {
|
|
1146
|
+
console.log(JSON.stringify({ status: 'error', hint: 'pass --node <site-id>' }));
|
|
1147
|
+
process.exitCode = 2;
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
const store = new MapStore(dbPath());
|
|
1151
|
+
const before = store.statesForNode(args.node).length;
|
|
1152
|
+
store.clearNode(args.node);
|
|
1153
|
+
console.log(JSON.stringify({ status: before ? 'done' : 'empty', node: args.node, statesCleared: before }, null, 2));
|
|
1154
|
+
if (!before)
|
|
1155
|
+
process.exitCode = 3;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (args.cmd === 'node-rm') {
|
|
1159
|
+
// Fully delete a node (row + states + edges + node-edges) — never raw sqlite.
|
|
1160
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1161
|
+
if (!args.node) {
|
|
1162
|
+
console.log(JSON.stringify({ status: 'error', hint: 'pass --node <site-id>' }));
|
|
1163
|
+
process.exitCode = 2;
|
|
1164
|
+
return;
|
|
1165
|
+
}
|
|
1166
|
+
const store = new MapStore(dbPath());
|
|
1167
|
+
const existed = store.getNode(args.node) !== null;
|
|
1168
|
+
const states = store.statesForNode(args.node).length;
|
|
1169
|
+
store.removeNode(args.node);
|
|
1170
|
+
console.log(JSON.stringify({ status: existed ? 'done' : 'empty', node: args.node, removed: existed, statesRemoved: states }, null, 2));
|
|
1171
|
+
if (!existed)
|
|
1172
|
+
process.exitCode = 3;
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
if (args.cmd === 'export-map') {
|
|
1176
|
+
// Emit a site's full MAP PACK { node, states } as JSON — the unit a hosted
|
|
1177
|
+
// service publishes/imports. Skeleton only; credentials are never in the map.
|
|
1178
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1179
|
+
const store = new MapStore(dbPath());
|
|
1180
|
+
const node = store.getNode(args.node);
|
|
1181
|
+
const states = store.statesForNode(args.node);
|
|
1182
|
+
if (!node || states.length === 0) {
|
|
1183
|
+
console.log(JSON.stringify({ status: 'empty', node: args.node, hint: 'no map for this site in the local db — build/seed it first' }, null, 2));
|
|
1184
|
+
process.exitCode = 3;
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
console.log(JSON.stringify({ node, states }, null, 2));
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (args.cmd === 'import-map') {
|
|
1191
|
+
// Load a map pack (export-map's JSON) into the local map — import a site someone
|
|
1192
|
+
// else mapped, no re-learning. Skeleton only; creds are set separately.
|
|
1193
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1194
|
+
const { importMapPack } = await import('./hosted.js');
|
|
1195
|
+
const { readFileSync } = await import('node:fs');
|
|
1196
|
+
if (!args.file) {
|
|
1197
|
+
console.log(JSON.stringify({ status: 'error', hint: 'pass a map-pack file path (or --file)' }));
|
|
1198
|
+
process.exitCode = 2;
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
let pack;
|
|
1202
|
+
try {
|
|
1203
|
+
pack = JSON.parse(readFileSync(args.file, 'utf8'));
|
|
1204
|
+
}
|
|
1205
|
+
catch (e) {
|
|
1206
|
+
console.log(JSON.stringify({ status: 'error', hint: `could not read/parse ${args.file}: ${e.message}` }));
|
|
1207
|
+
process.exitCode = 2;
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
if (!pack || !pack.node || !Array.isArray(pack.states)) {
|
|
1211
|
+
console.log(JSON.stringify({ status: 'error', hint: 'not a map pack — expected {node, states:[...]} (from `dev export-map`)' }));
|
|
1212
|
+
process.exitCode = 2;
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
const store = new MapStore(dbPath());
|
|
1216
|
+
importMapPack(store, pack);
|
|
1217
|
+
console.log(JSON.stringify({ status: 'done', node: pack.node.id, statesImported: pack.states.length,
|
|
1218
|
+
hint: `set login creds with: webnav dev creds set ${pack.node.id} username=… password=…` }, null, 2));
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
if (args.cmd === 'pattern-propose') {
|
|
1222
|
+
const { readFileSync, writeFileSync, existsSync, mkdirSync } = await import('node:fs');
|
|
1223
|
+
const { dirname, join } = await import('node:path');
|
|
1224
|
+
const { lintPackEntry } = await import('./explorer/patterns.js');
|
|
1225
|
+
// RE-CHECK PATH (no standalone `pattern-lint` verb — --lint <file> IS the re-check the plan
|
|
1226
|
+
// asks for): re-runs lint on an existing pack file after the agent filled in the TODO trigger.
|
|
1227
|
+
// Independent of --from-unknown/--name — just lints whatever file is named.
|
|
1228
|
+
if (args.lint) {
|
|
1229
|
+
let raw;
|
|
1230
|
+
try {
|
|
1231
|
+
raw = JSON.parse(readFileSync(args.lint, 'utf8'));
|
|
1232
|
+
}
|
|
1233
|
+
catch (e) {
|
|
1234
|
+
console.log(JSON.stringify({ status: 'error', hint: `could not read/parse ${args.lint}: ${e.message}` }, null, 2));
|
|
1235
|
+
process.exitCode = 2;
|
|
1236
|
+
return;
|
|
1237
|
+
}
|
|
1238
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
1239
|
+
const results = entries.map((e, i) => ({ index: i, reasons: lintPackEntry(e) }));
|
|
1240
|
+
const allClean = results.every((r) => r.reasons.length === 0);
|
|
1241
|
+
console.log(JSON.stringify({
|
|
1242
|
+
status: allClean ? 'done' : 'error', file: args.lint, results,
|
|
1243
|
+
hint: allClean
|
|
1244
|
+
? `lint clean. Next: re-run graph-analyse --draft on the site that surfaced this gap and confirm the unknown is gone, then add a grammar fixture test, then: gh pr create ...`
|
|
1245
|
+
: `lint failed — fix the reasons above (usually: fill trigger.contains with >=1 { role, min?, attr? } predicate) and re-run --lint`,
|
|
1246
|
+
}, null, 2));
|
|
1247
|
+
if (!allClean)
|
|
1248
|
+
process.exitCode = 2;
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (!args.fromUnknown || !args.name) {
|
|
1252
|
+
console.log(JSON.stringify({ status: 'error',
|
|
1253
|
+
hint: 'usage: webnav dev pattern-propose --from-unknown <graph-analyse-draft.json>#<index> --name <slug> (or: --lint <pack-file> to re-check)' }, null, 2));
|
|
1254
|
+
process.exitCode = 2;
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
const { parseFromUnknownArg, proposeFromUnknown, ghPrCommand } = await import('./explorer/pattern-propose.js');
|
|
1258
|
+
let path, index;
|
|
1259
|
+
try {
|
|
1260
|
+
({ path, index } = parseFromUnknownArg(args.fromUnknown));
|
|
1261
|
+
}
|
|
1262
|
+
catch (e) {
|
|
1263
|
+
console.log(JSON.stringify({ status: 'error', hint: e.message }, null, 2));
|
|
1264
|
+
process.exitCode = 2;
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
let draft;
|
|
1268
|
+
try {
|
|
1269
|
+
draft = JSON.parse(readFileSync(path, 'utf8'));
|
|
1270
|
+
}
|
|
1271
|
+
catch (e) {
|
|
1272
|
+
console.log(JSON.stringify({ status: 'error', hint: `could not read/parse ${path}: ${e.message}` }, null, 2));
|
|
1273
|
+
process.exitCode = 2;
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
const unknowns = draft?.unknowns;
|
|
1277
|
+
if (!Array.isArray(unknowns) || !unknowns[index]) {
|
|
1278
|
+
console.log(JSON.stringify({ status: 'error',
|
|
1279
|
+
hint: `${path} has no unknowns[${index}] — pass a graph-analyse --draft JSON file (its "unknowns" array) and a valid index (0..${(unknowns?.length ?? 1) - 1})` }, null, 2));
|
|
1280
|
+
process.exitCode = 2;
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
const unknown = unknowns[index];
|
|
1284
|
+
const result = proposeFromUnknown(unknown, args.name);
|
|
1285
|
+
if (!result.pack) {
|
|
1286
|
+
// core-design boundary: no pack schema field expresses this gap — the honest answer is a
|
|
1287
|
+
// core-design issue with the fixture, never a pack (and never a hand-patch). No file written.
|
|
1288
|
+
console.log(JSON.stringify({ status: 'declined', kind: unknown.kind, extensionPoint: unknown.extensionPoint,
|
|
1289
|
+
reason: result.coreDesignBoundary, evidence: unknown.evidence, context: unknown.context }, null, 2));
|
|
1290
|
+
process.exitCode = 3;
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const outPath = join('packs', 'patterns', 'proposed', `${args.name}.json`);
|
|
1294
|
+
if (existsSync(outPath)) {
|
|
1295
|
+
console.log(JSON.stringify({ status: 'error', hint: `${outPath} already exists — pick a different --name or edit it directly` }, null, 2));
|
|
1296
|
+
process.exitCode = 2;
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
1300
|
+
writeFileSync(outPath, JSON.stringify(result.pack, null, 2) + '\n');
|
|
1301
|
+
// lint is EXPECTED to fail here — the trigger is a deliberate TODO (empty `contains`). Printing
|
|
1302
|
+
// the failure IS the agent's next step, per the plan ("prints the failure as the agent's next step").
|
|
1303
|
+
console.log(JSON.stringify({
|
|
1304
|
+
status: 'scaffolded',
|
|
1305
|
+
file: outPath,
|
|
1306
|
+
pack: result.pack,
|
|
1307
|
+
lint: result.lintReasons.length ? { clean: false, reasons: result.lintReasons } : { clean: true },
|
|
1308
|
+
checklist: [
|
|
1309
|
+
`1. Fill trigger.contains in ${outPath} with >=1 { role, min?, attr? } predicate derived from the evidence above (role/attr names only — hostnames/URLs/text are rejected by lint).`,
|
|
1310
|
+
`2. Re-check: webnav dev pattern-propose --lint ${outPath}`,
|
|
1311
|
+
`3. Re-run graph-analyse --draft on the site that surfaced this gap — this unknown should now be GONE from the report.`,
|
|
1312
|
+
`4. Add a grammar fixture test proving the resolved shape (tests/grammar/*.test.ts — see pickers.test.ts for the idiom).`,
|
|
1313
|
+
`5. Once green: ${ghPrCommand(result.pack, unknown)}`,
|
|
1314
|
+
` (moves ${outPath} to packs/patterns/core/ for upstream review — NO auto-PR; this command is printed, never run for you.)`,
|
|
1315
|
+
],
|
|
1316
|
+
}, null, 2));
|
|
1317
|
+
// exit 0: the SCAFFOLD succeeded (file written) — the TODO trigger's expected lint failure is
|
|
1318
|
+
// reported IN the JSON body (lint.clean:false + reasons), not as a process failure, so an agent
|
|
1319
|
+
// scripting this loop doesn't misread "scaffolded, here's your next step" as an error.
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (args.cmd === 'outline' || args.cmd === 'mermaid') {
|
|
1323
|
+
// Human-scannable completeness views of a site's interior (no UI needed).
|
|
1324
|
+
// Per the CLI contract, the text view is carried as a `text` field on the
|
|
1325
|
+
// JSON stdout object (never bare); the human reads `text`, the coverage
|
|
1326
|
+
// summary (counts / unexplored / dead-ends / orphans) rides alongside.
|
|
1327
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1328
|
+
const { analyseCoverage, toOutline, toMermaid } = await import('./graph/coverage.js');
|
|
1329
|
+
const store = new MapStore(dbPath());
|
|
1330
|
+
const states = store.statesForNode(args.node);
|
|
1331
|
+
if (!states.length) {
|
|
1332
|
+
console.log(JSON.stringify({ status: 'empty', node: args.node,
|
|
1333
|
+
hint: `no interior captured for "${args.node}" — map it with the record/teach flow` }, null, 2));
|
|
1334
|
+
process.exitCode = 3;
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
const coverage = analyseCoverage(args.node, states);
|
|
1338
|
+
const text = args.cmd === 'outline' ? toOutline(args.node, states) : toMermaid(args.node, states);
|
|
1339
|
+
console.log(JSON.stringify({ status: 'ok', node: args.node, coverage, text }, null, 2));
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
if (args.cmd === 'frontier') {
|
|
1343
|
+
// The UNEXPLORED FRONTIER: declared affordances the map never followed to a
|
|
1344
|
+
// resolved state. Measures "is exploration complete?" instead of guessing.
|
|
1345
|
+
// Exit 3 (ran-fine-but-incomplete) when the frontier is non-empty; 0 when
|
|
1346
|
+
// empty (fully explored, minus the caller's hard exclusions). Same
|
|
1347
|
+
// ran-but-empty/incomplete convention as outline/mermaid.
|
|
1348
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1349
|
+
const { computeFrontier } = await import('./graph/frontier.js');
|
|
1350
|
+
const store = new MapStore(dbPath());
|
|
1351
|
+
const states = store.statesForNode(args.node);
|
|
1352
|
+
if (!states.length) {
|
|
1353
|
+
console.log(JSON.stringify({ status: 'empty', node: args.node,
|
|
1354
|
+
hint: `no interior captured for "${args.node}" — map it with the record/teach flow` }, null, 2));
|
|
1355
|
+
process.exitCode = 3;
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
const result = computeFrontier(args.node, states, args.exclude);
|
|
1359
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1360
|
+
// frontier non-empty = work remains; exit 3 (ran fine, incomplete).
|
|
1361
|
+
if (result.total > 0)
|
|
1362
|
+
process.exitCode = 3;
|
|
1363
|
+
return;
|
|
1364
|
+
}
|
|
1365
|
+
if (args.cmd === 'creds') {
|
|
1366
|
+
// Local credential store (~/.webnav/credentials.json, chmod 600). Values are
|
|
1367
|
+
// NEVER printed (list shows key NAMES only) and never stored in the map.
|
|
1368
|
+
const { CredStore, credsPath } = await import('./creds.js');
|
|
1369
|
+
const cs = new CredStore();
|
|
1370
|
+
if (args.sub === 'set') {
|
|
1371
|
+
if (!args.site || Object.keys(args.values).length === 0) {
|
|
1372
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav creds set <site> key=value [key=value...]' }, null, 2));
|
|
1373
|
+
process.exitCode = 2;
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
const keys = cs.set(args.site, args.values);
|
|
1377
|
+
console.log(JSON.stringify({ status: 'ok', site: args.site, keys, file: credsPath() }, null, 2));
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1380
|
+
if (args.sub === 'list') {
|
|
1381
|
+
console.log(JSON.stringify({ status: 'ok', sites: cs.list(), file: credsPath() }, null, 2));
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
if (args.sub === 'rm') {
|
|
1385
|
+
if (!args.site) {
|
|
1386
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav creds rm <site> [key]' }, null, 2));
|
|
1387
|
+
process.exitCode = 2;
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const removed = cs.remove(args.site, args.key);
|
|
1391
|
+
console.log(JSON.stringify({ status: removed ? 'ok' : 'empty', site: args.site, key: args.key, removed }, null, 2));
|
|
1392
|
+
if (!removed)
|
|
1393
|
+
process.exitCode = 3;
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
console.log(JSON.stringify({ status: 'error', hint: 'webnav creds set|list|rm' }, null, 2));
|
|
1397
|
+
process.exitCode = 2;
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (args.cmd === 'dashboard') {
|
|
1401
|
+
// Long-lived LOCAL operator UI (not a one-shot JSON verb): start the server,
|
|
1402
|
+
// print the URL to stderr (keeps stdout clean per CLI rules), auto-open the
|
|
1403
|
+
// browser, then stay alive until Ctrl-C. Reads ./webnav.db + the creds file.
|
|
1404
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
1405
|
+
const { ensureSeeded } = await import('./graph/seed.js');
|
|
1406
|
+
const { CredStore } = await import('./creds.js');
|
|
1407
|
+
const { startDashboard } = await import('./dashboard/server.js');
|
|
1408
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
1409
|
+
const { runLiveRecord } = await import('./recorder/live-record.js');
|
|
1410
|
+
const { MODE_JS } = await import('./recorder/live.js');
|
|
1411
|
+
const { ReplayController, runReplay, runLedgerReplay } = await import('./recorder/replay.js');
|
|
1412
|
+
const { coverage } = await import('./recorder/coverage.js');
|
|
1413
|
+
const { draftFromEffects } = await import('./explorer/draft.js');
|
|
1414
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
1415
|
+
const { join } = await import('node:path');
|
|
1416
|
+
const { homedir } = await import('node:os');
|
|
1417
|
+
const { rmSync, mkdirSync, readdirSync, readFileSync, writeFileSync, statSync, renameSync, existsSync: existsSync2 } = await import('node:fs');
|
|
1418
|
+
const { execSync } = await import('node:child_process');
|
|
1419
|
+
const { listSessions: listPwSessions } = await import('./playwright/sessions.js');
|
|
1420
|
+
const store = new MapStore(dbPath());
|
|
1421
|
+
ensureSeeded(store);
|
|
1422
|
+
const creds = new CredStore();
|
|
1423
|
+
const port = args.port;
|
|
1424
|
+
// Recordings deps for the dashboard's Recordings tab — record by clicking,
|
|
1425
|
+
// replay to verify. ONE driven browser at a time (CLAUDE.md rule); `busy`
|
|
1426
|
+
// tracks it so a second open/replay while one is up gets a clear 409-style error.
|
|
1427
|
+
const recordStore = new RecordStore(dbPath());
|
|
1428
|
+
// Reconcile stale-active rows: a session left active=1 by a crashed/killed
|
|
1429
|
+
// recorder OR by an agent `use session` process that ended (its /api/notify
|
|
1430
|
+
// can race the SSE tab, or get missed) shows "recording" forever in an
|
|
1431
|
+
// already-open tab. Any active session with NO live browser process is stale
|
|
1432
|
+
// → stop it. Uses REAL process liveness (listPwSessions), never the dashboard's
|
|
1433
|
+
// own `busy`, so it never touches a session legitimately active in another
|
|
1434
|
+
// live process. Run at boot AND on every `list()` read, so even a missed SSE
|
|
1435
|
+
// event self-heals on the next poll/refresh (ground truth, not just a push).
|
|
1436
|
+
const reconcileStale = async () => {
|
|
1437
|
+
try {
|
|
1438
|
+
const live = new Set((await listPwSessions(Date.now())).map((s) => s.name));
|
|
1439
|
+
for (const x of recordStore.listSessions()) {
|
|
1440
|
+
if (x.active && !live.has(x.sessionId))
|
|
1441
|
+
recordStore.stop(x.sessionId);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
catch { /* liveness probe failed → leave flags as-is */ }
|
|
1445
|
+
};
|
|
1446
|
+
await reconcileStale();
|
|
1447
|
+
// Per-(profile,site) auth-status cache — checks are on-demand only (no background
|
|
1448
|
+
// polling, per the design doc's non-goals), so this is just last-result memory for
|
|
1449
|
+
// the chip; key is 'profile\x00site' (both are already validated [\w.-]+ elsewhere,
|
|
1450
|
+
// but the separator is NUL to rule out any collision regardless).
|
|
1451
|
+
const statusCache = new Map();
|
|
1452
|
+
let busy = null;
|
|
1453
|
+
let activeAdapter = null; // for instant overlay updates
|
|
1454
|
+
// realtime push hub: SSE subscribers get 'sessions' | 'step' | 'replay' | 'log' pings
|
|
1455
|
+
const sseListeners = new Set();
|
|
1456
|
+
const emit = (t) => { for (const f of sseListeners)
|
|
1457
|
+
f(t); };
|
|
1458
|
+
// dashboard log stream: everything the operator should see, timestamped + pushed
|
|
1459
|
+
const logBuf = [];
|
|
1460
|
+
const dlog = (line) => {
|
|
1461
|
+
logBuf.push({ t: Date.now(), line });
|
|
1462
|
+
if (logBuf.length > 500)
|
|
1463
|
+
logBuf.shift();
|
|
1464
|
+
process.stderr.write(line + '\n');
|
|
1465
|
+
emit('log');
|
|
1466
|
+
};
|
|
1467
|
+
// Session VIDEO: recording-active spans are captured as .webm takes (ground
|
|
1468
|
+
// truth to verify the step capture against, and a session recording artifact).
|
|
1469
|
+
const videosRoot = join(homedir(), '.webnav', 'recordings');
|
|
1470
|
+
const reviewsRoot = join(homedir(), '.webnav', 'reviews');
|
|
1471
|
+
const profilesRoot = join(homedir(), '.webnav', 'profiles'); // STABLE per-session browser profile
|
|
1472
|
+
const profileDir = (id) => join(profilesRoot, id.replace(/[^\w.-]/g, '_'));
|
|
1473
|
+
// A Chrome profile dir opens in ONE process only; an orphan webnav Chrome leaves
|
|
1474
|
+
// a SingletonLock and holds the dir → next launch fails / hands off to the orphan
|
|
1475
|
+
// (the dashboard-vs-reality desync cluster). prepProfile reaps a live webnav-owned
|
|
1476
|
+
// holder + clears stale locks before each persistent launch. (src/playwright/profile-lock.ts)
|
|
1477
|
+
const { prepProfile } = await import('./playwright/profile-lock.js');
|
|
1478
|
+
const dirSizeMb = (dir) => {
|
|
1479
|
+
// cheap recursive size; profiles are small (cookies+cache), so a sync walk is fine
|
|
1480
|
+
let total = 0;
|
|
1481
|
+
const walk = (d) => {
|
|
1482
|
+
let ents = [];
|
|
1483
|
+
try {
|
|
1484
|
+
ents = readdirSync(d);
|
|
1485
|
+
}
|
|
1486
|
+
catch {
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
for (const e of ents) {
|
|
1490
|
+
const fp = join(d, e);
|
|
1491
|
+
try {
|
|
1492
|
+
const st = statSync(fp);
|
|
1493
|
+
if (st.isDirectory())
|
|
1494
|
+
walk(fp);
|
|
1495
|
+
else
|
|
1496
|
+
total += st.size;
|
|
1497
|
+
}
|
|
1498
|
+
catch { /* skip */ }
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
walk(dir);
|
|
1502
|
+
return Math.round(total / 1e5) / 10; // MB, 1 decimal
|
|
1503
|
+
};
|
|
1504
|
+
let reviewBusy = null;
|
|
1505
|
+
const { DEFAULT_INSTRUCTIONS: reviewDefaultInstructions } = await import('./recorder/review.js');
|
|
1506
|
+
const { makeVideoSync } = await import('./playwright/video.js');
|
|
1507
|
+
const videoSync = makeVideoSync(() => activeAdapter, { videosRoot, log: dlog, onSaved: () => emit('sessions') });
|
|
1508
|
+
let activeCtl = null;
|
|
1509
|
+
const shotsRoot = join(homedir(), '.webnav', 'replays');
|
|
1510
|
+
const rec = {
|
|
1511
|
+
list: async () => {
|
|
1512
|
+
await reconcileStale(); // ground truth on every read: self-heals a missed/raced SSE 'sessions' event
|
|
1513
|
+
return recordStore.listSessions().map((x) => {
|
|
1514
|
+
const profile = recordStore.profileOf(x.sessionId);
|
|
1515
|
+
let videoCount = 0;
|
|
1516
|
+
try {
|
|
1517
|
+
videoCount = readdirSync(join(videosRoot, x.sessionId)).filter((f) => f.endsWith('.webm')).length;
|
|
1518
|
+
}
|
|
1519
|
+
catch { /* none */ }
|
|
1520
|
+
return { ...x, profile, hasProfile: !!profile && existsSync2(profileDir(profile)),
|
|
1521
|
+
startUrl: recordStore.startUrl(x.sessionId), videoCount, origin: recordStore.originOf(x.sessionId) };
|
|
1522
|
+
});
|
|
1523
|
+
},
|
|
1524
|
+
steps: (id) => recordStore.actionEffects(id).map((e) => ({ seq: e.seq,
|
|
1525
|
+
label: e.action?.name ?? (e.navigated ? new URL(e.toUrl).pathname : 'observe'),
|
|
1526
|
+
kind: e.action ? (e.navigated ? 'navigate' : e.action.role === 'textbox' ? 'input' : 'click') : (e.navigated ? 'jump' : 'observe'),
|
|
1527
|
+
fromUrl: e.fromUrl, toUrl: e.toUrl, value: e.action?.value, capturedAt: e.capturedAt })),
|
|
1528
|
+
del: (id) => {
|
|
1529
|
+
recordStore.deleteSession(id);
|
|
1530
|
+
emit('sessions');
|
|
1531
|
+
// remove the session's replay screenshots too (spec: shots die with the recording).
|
|
1532
|
+
// The same guard as shotPath: '.'/'..' here would rmSync ~/.webnav recursively.
|
|
1533
|
+
if (/^[\w.-]+$/.test(id) && id !== '.' && id !== '..') {
|
|
1534
|
+
try {
|
|
1535
|
+
rmSync(join(shotsRoot, id), { recursive: true, force: true });
|
|
1536
|
+
}
|
|
1537
|
+
catch { /* decoration */ }
|
|
1538
|
+
try {
|
|
1539
|
+
rmSync(join(videosRoot, id), { recursive: true, force: true });
|
|
1540
|
+
}
|
|
1541
|
+
catch { /* decoration */ }
|
|
1542
|
+
try {
|
|
1543
|
+
rmSync(join(reviewsRoot, id), { recursive: true, force: true });
|
|
1544
|
+
}
|
|
1545
|
+
catch { /* decoration */ }
|
|
1546
|
+
}
|
|
1547
|
+
},
|
|
1548
|
+
draft: (id) => draftFromEffects(recordStore.actionEffects(id)),
|
|
1549
|
+
open: async (url, session, persistent, armedOnly, profile) => {
|
|
1550
|
+
if (busy)
|
|
1551
|
+
return { ok: false, error: 'a driven browser is already open (' + busy + ')' };
|
|
1552
|
+
busy = session;
|
|
1553
|
+
try {
|
|
1554
|
+
// persistent → a NAMED, SHARED profile dir (default 'default'), so a login
|
|
1555
|
+
// done once in a profile is reused by EVERY session under it (and by walks
|
|
1556
|
+
// via --profile). Without an explicit dir, playwright-cli uses a random temp
|
|
1557
|
+
// dir per launch and the login evaporates (live finding).
|
|
1558
|
+
const profName = persistent ? (profile && /^[\w.-]+$/.test(profile) ? profile : 'default') : null;
|
|
1559
|
+
if (profName) {
|
|
1560
|
+
try {
|
|
1561
|
+
mkdirSync(profileDir(profName), { recursive: true });
|
|
1562
|
+
}
|
|
1563
|
+
catch { /* */ }
|
|
1564
|
+
prepProfile(profileDir(profName));
|
|
1565
|
+
}
|
|
1566
|
+
const adapter = new PlaywrightAdapter(session, undefined, undefined, profName ? { headed: true, persistent: true, profile: profileDir(profName) } : { headed: true });
|
|
1567
|
+
await adapter.open(url);
|
|
1568
|
+
// A persistent profile RESTORES its previous tab on launch (e.g. the Google
|
|
1569
|
+
// account page after an auth bounce). Force-navigate to the requested URL so
|
|
1570
|
+
// the window lands on the product, not the restored tab (live bug).
|
|
1571
|
+
if (persistent && url && url !== 'about:blank') {
|
|
1572
|
+
try {
|
|
1573
|
+
await adapter.goto(url);
|
|
1574
|
+
}
|
|
1575
|
+
catch { /* */ }
|
|
1576
|
+
}
|
|
1577
|
+
// ONE CLICK = OPEN + RECORD (live feedback: asking to press Record again
|
|
1578
|
+
// after "Open window" was a redundant second intent). Stop still returns
|
|
1579
|
+
// the window to the armed/grey state for another take.
|
|
1580
|
+
activeAdapter = adapter;
|
|
1581
|
+
if (armedOnly) {
|
|
1582
|
+
// armed reopen from a recording's detail: window only; Record is a
|
|
1583
|
+
// separate intent there (live feedback #1). Row stays visible.
|
|
1584
|
+
recordStore.start(session);
|
|
1585
|
+
recordStore.stop(session);
|
|
1586
|
+
recordStore.setOrigin(session, 'manual');
|
|
1587
|
+
dlog('window opened (armed) for ' + session);
|
|
1588
|
+
}
|
|
1589
|
+
else {
|
|
1590
|
+
recordStore.start(session);
|
|
1591
|
+
recordStore.setOrigin(session, 'manual');
|
|
1592
|
+
videoSync(session, true);
|
|
1593
|
+
dlog('recording STARTED: ' + session);
|
|
1594
|
+
}
|
|
1595
|
+
// record the session's profile + intended start url (row now exists)
|
|
1596
|
+
if (profName)
|
|
1597
|
+
recordStore.setProfile(session, profName);
|
|
1598
|
+
if (url && url !== 'about:blank')
|
|
1599
|
+
recordStore.setStartUrl(session, url);
|
|
1600
|
+
emit('sessions');
|
|
1601
|
+
// window liveness = the DAEMON still has a Chromium child (the daemon itself
|
|
1602
|
+
// outlives the window — probing it via evals is what resurrected the window).
|
|
1603
|
+
let daemonPid;
|
|
1604
|
+
try {
|
|
1605
|
+
daemonPid = (await listPwSessions(Date.now())).find((x) => x.name === session)?.pid;
|
|
1606
|
+
}
|
|
1607
|
+
catch { /* fallback below */ }
|
|
1608
|
+
const browserAlive = daemonPid === undefined ? undefined : () => {
|
|
1609
|
+
try {
|
|
1610
|
+
return execSync(`ps -axo ppid=,comm= | awk '$1==${daemonPid}'`, { encoding: 'utf8' })
|
|
1611
|
+
.toLowerCase().includes('chrom');
|
|
1612
|
+
}
|
|
1613
|
+
catch {
|
|
1614
|
+
return true;
|
|
1615
|
+
} // ps hiccup ≠ dead
|
|
1616
|
+
};
|
|
1617
|
+
void runLiveRecord({ adapter, store: recordStore, sessionId: session, intervalMs: 200, armed: true,
|
|
1618
|
+
tickExtras: { port, session },
|
|
1619
|
+
onEvent: emit,
|
|
1620
|
+
browserAlive,
|
|
1621
|
+
onToggle: (recording) => videoSync(session, recording),
|
|
1622
|
+
// fire the instant the loop ends (window closed OR external stop): stop capture,
|
|
1623
|
+
// save the video, and force-reap the daemon so it can't RESURRECT the window
|
|
1624
|
+
// (live: split-second reopen). Runs before the graceful close in finally.
|
|
1625
|
+
onEnd: (reason) => {
|
|
1626
|
+
videoSync(session, false);
|
|
1627
|
+
recordStore.stop(session);
|
|
1628
|
+
// GRACEFUL close only (the loop's finally calls adapter.close() → Chrome
|
|
1629
|
+
// exits cleanly, releasing the profile lock and leaving NO "restore pages"
|
|
1630
|
+
// bubble). A lingering orphan is handled by prepProfile on the NEXT launch,
|
|
1631
|
+
// not an ungraceful pkill here (that was the cause of the restore bubble +
|
|
1632
|
+
// reopen loop — advisor finding). Force-kill the daemon GROUP only if it's
|
|
1633
|
+
// truly wedged AND the window is gone (resurrection guard).
|
|
1634
|
+
if (reason === 'closed') {
|
|
1635
|
+
setTimeout(() => {
|
|
1636
|
+
try {
|
|
1637
|
+
if (daemonPid !== undefined && execSync('ps -axo ppid=,comm= | awk \'$1==' + daemonPid + '\'', { encoding: 'utf8' }).toLowerCase().includes('chrom')) {
|
|
1638
|
+
execSync('pkill -f ' + JSON.stringify('-s=' + wireSessionName(session)));
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
catch { /* gone */ }
|
|
1642
|
+
}, 1500);
|
|
1643
|
+
}
|
|
1644
|
+
busy = null;
|
|
1645
|
+
activeAdapter = null;
|
|
1646
|
+
dlog('window session ended (' + reason + '): ' + session);
|
|
1647
|
+
emit('sessions');
|
|
1648
|
+
},
|
|
1649
|
+
log: dlog, isStopped: () => false })
|
|
1650
|
+
.catch(() => { });
|
|
1651
|
+
return { ok: true };
|
|
1652
|
+
}
|
|
1653
|
+
catch (e) {
|
|
1654
|
+
busy = null; // final-review #1: an open() throw (session ceiling, bad URL) wedged the guard forever
|
|
1655
|
+
const msg = String(e);
|
|
1656
|
+
if (/EINVAL/.test(msg) && /\.sock/.test(msg)) {
|
|
1657
|
+
return { ok: false, error: 'session name too long — macOS caps the daemon socket path (~104 chars); use a shorter name (≤ ~14 chars is safe)' };
|
|
1658
|
+
}
|
|
1659
|
+
return { ok: false, error: msg };
|
|
1660
|
+
}
|
|
1661
|
+
},
|
|
1662
|
+
// instant overlay update: don't wait for the loop's next tick (live finding: lag)
|
|
1663
|
+
record: (id) => { recordStore.start(id); videoSync(id, true); dlog('recording STARTED: ' + id); emit('sessions'); void activeAdapter?.evalJs(MODE_JS(true)).catch(() => { }); return true; },
|
|
1664
|
+
stop: (id) => { recordStore.stop(id); videoSync(id, false); dlog('recording STOPPED: ' + id); emit('sessions'); void activeAdapter?.evalJs(MODE_JS(false)).catch(() => { }); return true; },
|
|
1665
|
+
// the window pill's realtime channel (POSTed directly from the page).
|
|
1666
|
+
// `desired` (from the pill) is IDEMPOTENT — a stale visual can't double-toggle.
|
|
1667
|
+
toggle: (id, desired) => {
|
|
1668
|
+
const was = recordStore.isActive(id);
|
|
1669
|
+
const next = desired ?? !was;
|
|
1670
|
+
if (next === was)
|
|
1671
|
+
return { recording: was };
|
|
1672
|
+
if (next)
|
|
1673
|
+
recordStore.start(id);
|
|
1674
|
+
else
|
|
1675
|
+
recordStore.stop(id);
|
|
1676
|
+
videoSync(id, next);
|
|
1677
|
+
dlog('recording ' + (next ? 'STARTED' : 'STOPPED') + ' (pill): ' + id);
|
|
1678
|
+
emit('sessions');
|
|
1679
|
+
void activeAdapter?.evalJs(MODE_JS(next)).catch(() => { });
|
|
1680
|
+
return { recording: next };
|
|
1681
|
+
},
|
|
1682
|
+
videos: (id) => {
|
|
1683
|
+
if (!/^[\w.-]+$/.test(id) || id === '.' || id === '..')
|
|
1684
|
+
return [];
|
|
1685
|
+
try {
|
|
1686
|
+
return readdirSync(join(videosRoot, id)).filter((f) => f.endsWith('.webm')).sort();
|
|
1687
|
+
}
|
|
1688
|
+
catch {
|
|
1689
|
+
return [];
|
|
1690
|
+
}
|
|
1691
|
+
},
|
|
1692
|
+
videoPath: (session, file) => /^[\w.-]+$/.test(session) && session !== '.' && session !== '..' && /^take-\d+\.webm$/.test(file)
|
|
1693
|
+
? join(videosRoot, session, file) : null,
|
|
1694
|
+
subscribe: (cb) => { sseListeners.add(cb); return () => sseListeners.delete(cb); },
|
|
1695
|
+
activeWindow: () => (busy && !busy.startsWith('replay:') ? busy : null),
|
|
1696
|
+
logs: () => ({ now: Date.now(), lines: logBuf.slice(-200) }),
|
|
1697
|
+
// cross-process realtime bridge: a `use session` process POSTs /api/notify →
|
|
1698
|
+
// this appends its log line to our buffer and pushes the SSE event, so the
|
|
1699
|
+
// dashboard streams an agent session's steps/logs live just like the human one.
|
|
1700
|
+
notify: (kind, line) => {
|
|
1701
|
+
if (line)
|
|
1702
|
+
dlog(line); // dlog already emits('log')
|
|
1703
|
+
if (kind === 'step' || kind === 'sessions')
|
|
1704
|
+
emit(kind);
|
|
1705
|
+
},
|
|
1706
|
+
review: (id, opts) => {
|
|
1707
|
+
if (reviewBusy)
|
|
1708
|
+
return { ok: false, error: 'a review is already running (' + reviewBusy + ')' };
|
|
1709
|
+
if (!/^[\w.-]+$/.test(id) || id === '.' || id === '..')
|
|
1710
|
+
return { ok: false, error: 'bad session id' };
|
|
1711
|
+
const model = /^[\w.-]{1,40}$/.test(opts?.model ?? '') ? opts.model : undefined;
|
|
1712
|
+
const instructions = typeof opts?.instructions === 'string' && opts.instructions.trim() ? opts.instructions.slice(0, 8000) : undefined;
|
|
1713
|
+
// persist last-used config so the next run (and the config GET) starts from it
|
|
1714
|
+
try {
|
|
1715
|
+
mkdirSync(reviewsRoot, { recursive: true });
|
|
1716
|
+
writeFileSync(join(reviewsRoot, 'config.json'), JSON.stringify({ model: model ?? 'sonnet', instructions }, null, 2));
|
|
1717
|
+
}
|
|
1718
|
+
catch { /* decoration */ }
|
|
1719
|
+
reviewBusy = id;
|
|
1720
|
+
emit('sessions');
|
|
1721
|
+
const outDir = join(reviewsRoot, id);
|
|
1722
|
+
void (async () => {
|
|
1723
|
+
const { runSessionReview } = await import('./recorder/review.js');
|
|
1724
|
+
const steps = recordStore.actionEffects(id).map((e) => ({
|
|
1725
|
+
seq: e.seq,
|
|
1726
|
+
kind: e.action ? (e.navigated ? 'navigate' : e.action.role === 'textbox' ? 'input' : 'click') : (e.navigated ? 'jump' : 'observe'),
|
|
1727
|
+
label: e.action?.name ?? e.toUrl, value: e.action?.value, capturedAt: e.capturedAt,
|
|
1728
|
+
}));
|
|
1729
|
+
await runSessionReview(id, { videosDir: join(videosRoot, id), outDir,
|
|
1730
|
+
steps, logs: logBuf.slice(-200), log: dlog, claudeModel: model, instructions });
|
|
1731
|
+
})().finally(() => { reviewBusy = null; emit('sessions'); });
|
|
1732
|
+
return { ok: true };
|
|
1733
|
+
},
|
|
1734
|
+
reviewReport: (id) => {
|
|
1735
|
+
if (!/^[\w.-]+$/.test(id) || id === '.' || id === '..')
|
|
1736
|
+
return null;
|
|
1737
|
+
try {
|
|
1738
|
+
const f = join(reviewsRoot, id, 'review.md');
|
|
1739
|
+
const verdict = recordStore.reviewOf(id); // stored {approved,gaps,...} → verdict banner
|
|
1740
|
+
return { report: readFileSync(f, 'utf8'), at: statSync(f).mtimeMs, verdict };
|
|
1741
|
+
}
|
|
1742
|
+
catch {
|
|
1743
|
+
return null;
|
|
1744
|
+
}
|
|
1745
|
+
},
|
|
1746
|
+
reviewRunning: () => reviewBusy,
|
|
1747
|
+
reviewConfig: () => {
|
|
1748
|
+
let saved = {};
|
|
1749
|
+
try {
|
|
1750
|
+
saved = JSON.parse(readFileSync(join(reviewsRoot, 'config.json'), 'utf8'));
|
|
1751
|
+
}
|
|
1752
|
+
catch { /* defaults */ }
|
|
1753
|
+
// DEFAULT_INSTRUCTIONS import is dynamic-only elsewhere; inline require here is fine at runtime
|
|
1754
|
+
return { model: saved.model ?? 'sonnet', instructions: saved.instructions ?? reviewDefaultInstructions };
|
|
1755
|
+
},
|
|
1756
|
+
reviewFramePath: (session, file) => /^[\w.-]+$/.test(session) && session !== '.' && session !== '..' && /^[\w-]+\.png$/.test(file)
|
|
1757
|
+
? (readdirSync(join(reviewsRoot, session)).filter((d) => d.startsWith('frames-'))
|
|
1758
|
+
.map((d) => join(reviewsRoot, session, d, file)).find((f) => existsSync2(f)) ?? null)
|
|
1759
|
+
: null,
|
|
1760
|
+
// Profiles are NAMED, shared logged-in browser states (dir name = profile
|
|
1761
|
+
// name). Metadata (site, last recording url) is derived from the sessions
|
|
1762
|
+
// that use the profile — profiles themselves store only cookies on disk.
|
|
1763
|
+
profiles: () => {
|
|
1764
|
+
let dirs = [];
|
|
1765
|
+
try {
|
|
1766
|
+
dirs = readdirSync(profilesRoot);
|
|
1767
|
+
}
|
|
1768
|
+
catch {
|
|
1769
|
+
return [];
|
|
1770
|
+
}
|
|
1771
|
+
return dirs.filter((d) => { try {
|
|
1772
|
+
return statSync(join(profilesRoot, d)).isDirectory();
|
|
1773
|
+
}
|
|
1774
|
+
catch {
|
|
1775
|
+
return false;
|
|
1776
|
+
} })
|
|
1777
|
+
.map((name) => {
|
|
1778
|
+
const dir = join(profilesRoot, name);
|
|
1779
|
+
let lastUsed = 0;
|
|
1780
|
+
try {
|
|
1781
|
+
lastUsed = statSync(dir).mtimeMs;
|
|
1782
|
+
}
|
|
1783
|
+
catch { /* */ }
|
|
1784
|
+
const sessions = recordStore.sessionsUsingProfile(name);
|
|
1785
|
+
// site = host of the most-recent using-session's start url (best-effort)
|
|
1786
|
+
let site = null;
|
|
1787
|
+
for (const sid of sessions) {
|
|
1788
|
+
const u = recordStore.startUrl(sid);
|
|
1789
|
+
if (u) {
|
|
1790
|
+
try {
|
|
1791
|
+
site = new URL(u).host;
|
|
1792
|
+
break;
|
|
1793
|
+
}
|
|
1794
|
+
catch { /* */ }
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
const status = site ? statusCache.get(name + '\x00' + site) : undefined;
|
|
1798
|
+
return { name, site, sessions: sessions.length, sizeMb: dirSizeMb(dir), lastUsed, open: busy === 'relogin-' + name, ...(status ? { status } : {}) };
|
|
1799
|
+
}).sort((a, b) => b.lastUsed - a.lastUsed);
|
|
1800
|
+
},
|
|
1801
|
+
profileNew: (name) => {
|
|
1802
|
+
if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
|
|
1803
|
+
return { ok: false, error: 'name must be letters/numbers/-._' };
|
|
1804
|
+
const dir = profileDir(name);
|
|
1805
|
+
if (existsSync2(dir))
|
|
1806
|
+
return { ok: false, error: 'profile "' + name + '" already exists' };
|
|
1807
|
+
try {
|
|
1808
|
+
mkdirSync(dir, { recursive: true });
|
|
1809
|
+
dlog('profile created: ' + name);
|
|
1810
|
+
emit('sessions');
|
|
1811
|
+
return { ok: true };
|
|
1812
|
+
}
|
|
1813
|
+
catch (e) {
|
|
1814
|
+
return { ok: false, error: String(e) };
|
|
1815
|
+
}
|
|
1816
|
+
},
|
|
1817
|
+
profileOpen: async (name) => {
|
|
1818
|
+
if (busy)
|
|
1819
|
+
return { ok: false, error: 'a driven browser is already open (' + busy + ')' };
|
|
1820
|
+
if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
|
|
1821
|
+
return { ok: false, error: 'bad profile name' };
|
|
1822
|
+
const dir = profileDir(name);
|
|
1823
|
+
try {
|
|
1824
|
+
mkdirSync(dir, { recursive: true });
|
|
1825
|
+
}
|
|
1826
|
+
catch { /* */ } // new profile: create on first login
|
|
1827
|
+
prepProfile(dir); // reap any orphan holding the lock + clear stale Singleton*
|
|
1828
|
+
const winId = 'relogin-' + name;
|
|
1829
|
+
busy = winId;
|
|
1830
|
+
// re-login window: open the profile headed at the site of a session that uses
|
|
1831
|
+
// it (or blank) so the human can refresh an expired Cloudflare/2FA login. NOT
|
|
1832
|
+
// recording — closes on window close; state persists back to the profile dir.
|
|
1833
|
+
let startUrl = 'about:blank';
|
|
1834
|
+
for (const sid of recordStore.sessionsUsingProfile(name)) {
|
|
1835
|
+
const u = recordStore.startUrl(sid);
|
|
1836
|
+
if (u) {
|
|
1837
|
+
startUrl = u;
|
|
1838
|
+
break;
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
try {
|
|
1842
|
+
const adapter = new PlaywrightAdapter(winId, undefined, undefined, { headed: true, persistent: true, profile: dir });
|
|
1843
|
+
activeAdapter = adapter;
|
|
1844
|
+
await adapter.open(startUrl);
|
|
1845
|
+
if (startUrl !== 'about:blank') {
|
|
1846
|
+
try {
|
|
1847
|
+
await adapter.goto(startUrl);
|
|
1848
|
+
}
|
|
1849
|
+
catch { /* */ }
|
|
1850
|
+
} // past the restored tab
|
|
1851
|
+
dlog('re-login window opened for profile ' + name + ' — log in by hand, then close the window');
|
|
1852
|
+
emit('sessions');
|
|
1853
|
+
void (async () => {
|
|
1854
|
+
const { listSessions: listPw } = await import('./playwright/sessions.js');
|
|
1855
|
+
let pid;
|
|
1856
|
+
try {
|
|
1857
|
+
pid = (await listPw(Date.now())).find((x) => x.name === winId)?.pid;
|
|
1858
|
+
}
|
|
1859
|
+
catch { /* */ }
|
|
1860
|
+
const alive = () => { if (pid === undefined)
|
|
1861
|
+
return true; try {
|
|
1862
|
+
return execSync('ps -axo ppid=,comm= | awk \'$1==' + pid + '\'', { encoding: 'utf8' }).toLowerCase().includes('chrom');
|
|
1863
|
+
}
|
|
1864
|
+
catch {
|
|
1865
|
+
return true;
|
|
1866
|
+
} };
|
|
1867
|
+
while (alive()) {
|
|
1868
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
1869
|
+
if (busy !== winId)
|
|
1870
|
+
break;
|
|
1871
|
+
}
|
|
1872
|
+
})().finally(() => { busy = null; activeAdapter = null; dlog('re-login window closed: ' + name); emit('sessions'); });
|
|
1873
|
+
return { ok: true };
|
|
1874
|
+
}
|
|
1875
|
+
catch (e) {
|
|
1876
|
+
busy = null;
|
|
1877
|
+
return { ok: false, error: String(e) };
|
|
1878
|
+
}
|
|
1879
|
+
},
|
|
1880
|
+
profileRename: (from, to) => {
|
|
1881
|
+
const bad = (n) => !/^[\w.-]+$/.test(n) || n === '.' || n === '..';
|
|
1882
|
+
if (bad(from) || bad(to))
|
|
1883
|
+
return { ok: false, error: 'names must be letters/numbers/-._' };
|
|
1884
|
+
if (!existsSync2(profileDir(from)))
|
|
1885
|
+
return { ok: false, error: 'no profile "' + from + '"' };
|
|
1886
|
+
if (existsSync2(profileDir(to)))
|
|
1887
|
+
return { ok: false, error: '"' + to + '" already exists' };
|
|
1888
|
+
try {
|
|
1889
|
+
renameSync(profileDir(from), profileDir(to));
|
|
1890
|
+
recordStore.renameProfileRefs(from, to);
|
|
1891
|
+
dlog('profile renamed: ' + from + ' → ' + to);
|
|
1892
|
+
emit('sessions');
|
|
1893
|
+
return { ok: true };
|
|
1894
|
+
}
|
|
1895
|
+
catch (e) {
|
|
1896
|
+
return { ok: false, error: String(e) };
|
|
1897
|
+
}
|
|
1898
|
+
},
|
|
1899
|
+
profileDelete: (name) => {
|
|
1900
|
+
if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
|
|
1901
|
+
return { ok: false };
|
|
1902
|
+
try {
|
|
1903
|
+
rmSync(profileDir(name), { recursive: true, force: true });
|
|
1904
|
+
dlog('profile deleted (logged out): ' + name);
|
|
1905
|
+
emit('sessions');
|
|
1906
|
+
return { ok: true };
|
|
1907
|
+
}
|
|
1908
|
+
catch {
|
|
1909
|
+
return { ok: false };
|
|
1910
|
+
}
|
|
1911
|
+
},
|
|
1912
|
+
// Status chip engine (Task C item 1) — the SAME flow as `dev profile-status`
|
|
1913
|
+
// (Task A), reused rather than duplicated: one polite headless load of the
|
|
1914
|
+
// site's map homeUrl, settle, classifyAuthLanding against the site's own
|
|
1915
|
+
// fingerprints. Cached per (profile,site) so the tab can show a last-checked
|
|
1916
|
+
// time without re-checking on every render.
|
|
1917
|
+
profileStatus: async (name, site) => {
|
|
1918
|
+
if (busy)
|
|
1919
|
+
return { ok: false, error: 'a driven browser is already open (' + busy + ')' };
|
|
1920
|
+
if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
|
|
1921
|
+
return { ok: false, error: 'bad profile name' };
|
|
1922
|
+
const node = store.getNode(site);
|
|
1923
|
+
const url = node?.homeUrl;
|
|
1924
|
+
if (!url)
|
|
1925
|
+
return { ok: false, error: 'no map for site "' + site + '" yet (no homeUrl)' };
|
|
1926
|
+
const dir = profileDir(name);
|
|
1927
|
+
try {
|
|
1928
|
+
mkdirSync(dir, { recursive: true });
|
|
1929
|
+
}
|
|
1930
|
+
catch { /* */ }
|
|
1931
|
+
prepProfile(dir);
|
|
1932
|
+
const winId = 'pchk-' + name;
|
|
1933
|
+
busy = winId;
|
|
1934
|
+
const session = 'pchk-' + Math.random().toString(36).slice(2, 6);
|
|
1935
|
+
const { settleSnapshot } = await import('./router/browse.js');
|
|
1936
|
+
const { classifyAuthLanding } = await import('./router/auth-status.js');
|
|
1937
|
+
const adapter = new PlaywrightAdapter(session, undefined, undefined, { headed: false, persistent: true, profile: dir });
|
|
1938
|
+
try {
|
|
1939
|
+
await adapter.open(url);
|
|
1940
|
+
const landedUrl = await adapter.currentUrl();
|
|
1941
|
+
const snapshot = await settleSnapshot(() => adapter.snapshot());
|
|
1942
|
+
const states = store.statesForNode(site);
|
|
1943
|
+
const { auth, loginUrl } = classifyAuthLanding(landedUrl, snapshot, site, states);
|
|
1944
|
+
const checkedAt = new Date().toISOString();
|
|
1945
|
+
statusCache.set(name + '\x00' + site, { auth, ...(loginUrl ? { loginUrl } : {}), checkedAt });
|
|
1946
|
+
emit('sessions');
|
|
1947
|
+
return { ok: true, auth, ...(loginUrl ? { loginUrl } : {}), checkedAt };
|
|
1948
|
+
}
|
|
1949
|
+
catch (e) {
|
|
1950
|
+
return { ok: false, error: String(e) };
|
|
1951
|
+
}
|
|
1952
|
+
finally {
|
|
1953
|
+
await adapter.close().catch(() => { });
|
|
1954
|
+
busy = null;
|
|
1955
|
+
}
|
|
1956
|
+
},
|
|
1957
|
+
// Reset profile (Task C item 4): per-origin cookie clearing was investigated —
|
|
1958
|
+
// playwright-cli's cookie-clear/cookie-delete are process-wide (no --domain
|
|
1959
|
+
// filter, unlike cookie-list's read-only filter), so deleting a same-named
|
|
1960
|
+
// cookie risks wiping an unrelated site sharing this profile. Not cleanly
|
|
1961
|
+
// reachable → recreate the profile dir in place instead (same name, empty):
|
|
1962
|
+
// honest "log in with a different account", logs out every site in it.
|
|
1963
|
+
profileReset: (name) => {
|
|
1964
|
+
if (!/^[\w.-]+$/.test(name) || name === '.' || name === '..')
|
|
1965
|
+
return { ok: false, error: 'bad profile name' };
|
|
1966
|
+
const dir = profileDir(name);
|
|
1967
|
+
if (busy === 'relogin-' + name)
|
|
1968
|
+
return { ok: false, error: 'close the login window first' };
|
|
1969
|
+
try {
|
|
1970
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1971
|
+
mkdirSync(dir, { recursive: true });
|
|
1972
|
+
for (const key of statusCache.keys()) {
|
|
1973
|
+
if (key.startsWith(name + '\x00'))
|
|
1974
|
+
statusCache.delete(key);
|
|
1975
|
+
}
|
|
1976
|
+
dlog('profile reset (logged out of all sites): ' + name);
|
|
1977
|
+
emit('sessions');
|
|
1978
|
+
return { ok: true };
|
|
1979
|
+
}
|
|
1980
|
+
catch (e) {
|
|
1981
|
+
return { ok: false, error: String(e) };
|
|
1982
|
+
}
|
|
1983
|
+
},
|
|
1984
|
+
events: (id) => {
|
|
1985
|
+
const evs = recordStore.events(id);
|
|
1986
|
+
return { events: evs, coverage: coverage(evs) };
|
|
1987
|
+
},
|
|
1988
|
+
replay: async (id, mode = 'steps') => {
|
|
1989
|
+
if (busy)
|
|
1990
|
+
return { ok: false, error: 'a driven browser is already open (' + busy + ')' };
|
|
1991
|
+
if (mode === 'ledger') {
|
|
1992
|
+
const events = recordStore.events(id);
|
|
1993
|
+
if (!events.length)
|
|
1994
|
+
return { ok: false, error: 'no ledger — recorded before the ledger existed; use steps replay' };
|
|
1995
|
+
const site = (() => { try {
|
|
1996
|
+
return new URL(String(events[0].descriptor.url ?? events[0].descriptor.fromUrl)).host;
|
|
1997
|
+
}
|
|
1998
|
+
catch {
|
|
1999
|
+
return '';
|
|
2000
|
+
} })();
|
|
2001
|
+
const label = (e) => {
|
|
2002
|
+
const d = e.descriptor;
|
|
2003
|
+
return String(d.name ?? d.ariaLabel ?? d.leafText ?? d.placeholder ?? e.kind);
|
|
2004
|
+
};
|
|
2005
|
+
busy = 'replay:' + id;
|
|
2006
|
+
const ctl = new ReplayController(id, events.map((e) => ({ seq: e.seq, label: label(e) })));
|
|
2007
|
+
activeCtl = ctl;
|
|
2008
|
+
const adapter = new PlaywrightAdapter('replay-' + id, undefined, undefined, { headed: true });
|
|
2009
|
+
void runLedgerReplay(events, ctl, { adapter, creds, site, shotsDir: join(shotsRoot, id) })
|
|
2010
|
+
.catch(() => { })
|
|
2011
|
+
.finally(() => { busy = null; });
|
|
2012
|
+
return { ok: true };
|
|
2013
|
+
}
|
|
2014
|
+
const effects = recordStore.actionEffects(id);
|
|
2015
|
+
if (!effects.length)
|
|
2016
|
+
return { ok: false, error: 'empty recording' };
|
|
2017
|
+
busy = 'replay:' + id;
|
|
2018
|
+
const site = (() => { try {
|
|
2019
|
+
return new URL(effects[0].fromUrl).host;
|
|
2020
|
+
}
|
|
2021
|
+
catch {
|
|
2022
|
+
return '';
|
|
2023
|
+
} })();
|
|
2024
|
+
const ctl = new ReplayController(id, effects.map((e) => ({ seq: e.seq, label: e.action?.name ?? (e.navigated ? 'jump' : 'observe') })));
|
|
2025
|
+
activeCtl = ctl;
|
|
2026
|
+
const adapter = new PlaywrightAdapter('replay-' + id, undefined, undefined, { headed: true });
|
|
2027
|
+
void runReplay(effects, ctl, { adapter, creds, site, shotsDir: join(shotsRoot, id) })
|
|
2028
|
+
.catch(() => { })
|
|
2029
|
+
.finally(() => { busy = null; });
|
|
2030
|
+
return { ok: true };
|
|
2031
|
+
},
|
|
2032
|
+
replayState: () => activeCtl?.state ?? null,
|
|
2033
|
+
replayControl: (action, p) => {
|
|
2034
|
+
if (!activeCtl)
|
|
2035
|
+
return false;
|
|
2036
|
+
if (action === 'supply')
|
|
2037
|
+
return activeCtl.supply(p.value ?? '', !!p.save);
|
|
2038
|
+
if (action === 'confirm')
|
|
2039
|
+
return activeCtl.confirm(!!p.fire);
|
|
2040
|
+
return activeCtl.control(action);
|
|
2041
|
+
},
|
|
2042
|
+
shotPath: (session, file) =>
|
|
2043
|
+
// review finding: '.'/'..' pass [\w.-]+ (dots are in the class) → one-level traversal
|
|
2044
|
+
/^[\w.-]+$/.test(session) && session !== '.' && session !== '..' && /^step-\d+\.png$/.test(file)
|
|
2045
|
+
? join(shotsRoot, session, file) : null,
|
|
2046
|
+
};
|
|
2047
|
+
startDashboard(store, creds, { port }, rec);
|
|
2048
|
+
const url = `http://127.0.0.1:${port}`;
|
|
2049
|
+
process.stderr.write(`webnav dashboard running at ${url}\n(reads ./webnav.db + ${process.env.WEBNAV_CREDS ?? '~/.webnav/credentials.json'}; Ctrl-C to stop)\n`);
|
|
2050
|
+
// Open a browser tab ONLY when asked (--open). Default is quiet: auto-opening a
|
|
2051
|
+
// tab on every start floods the user's Chrome — the URL is printed to click.
|
|
2052
|
+
if (args.open && process.platform === 'darwin') {
|
|
2053
|
+
const { exec } = await import('node:child_process');
|
|
2054
|
+
exec(`open ${url}`, () => { });
|
|
2055
|
+
}
|
|
2056
|
+
// Keep the process alive (the server holds the event loop; nothing else to do).
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
if (args.cmd === 'login') {
|
|
2060
|
+
// Save the hosted-route API key to ~/.webnav/config.json. This file holds ONLY
|
|
2061
|
+
// the service key — never site credentials (those stay in credentials.json).
|
|
2062
|
+
const { saveConfig } = await import('./hosted.js');
|
|
2063
|
+
const { configPath } = await import('./paths.js');
|
|
2064
|
+
if (!args.key) {
|
|
2065
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav login <api-key>' }, null, 2));
|
|
2066
|
+
process.exitCode = 2;
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
saveConfig({ apiKey: args.key });
|
|
2070
|
+
console.log(JSON.stringify({ status: 'ok', saved: configPath(), note: 'hosted route enabled — use `webnav walk --hosted ...`' }, null, 2));
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (args.cmd === 'test') {
|
|
2074
|
+
const { readFileSync } = await import('node:fs');
|
|
2075
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
2076
|
+
const { parseSuite, runSuite, SuiteConfigError } = await import('./router/suite.js');
|
|
2077
|
+
const { makeLiveWalkBrowser } = await import('./router/walk-live.js');
|
|
2078
|
+
const { PlaywrightAdapter, resolveProfile } = await import('./playwright/adapter.js');
|
|
2079
|
+
const { ensureCanOpen } = await import('./playwright/sessions.js');
|
|
2080
|
+
const { settleSnapshot } = await import('./router/browse.js');
|
|
2081
|
+
const { classifyAuthLanding } = await import('./router/auth-status.js');
|
|
2082
|
+
const { CredStore } = await import('./creds.js');
|
|
2083
|
+
const { homedir } = await import('node:os');
|
|
2084
|
+
const { join } = await import('node:path');
|
|
2085
|
+
if (!args.suite) {
|
|
2086
|
+
console.log(JSON.stringify({ status: 'error', hint: 'usage: webnav test --suite <file.suite.json> [--headless]' }, null, 2));
|
|
2087
|
+
process.exitCode = 2;
|
|
2088
|
+
return;
|
|
2089
|
+
}
|
|
2090
|
+
let suite;
|
|
2091
|
+
try {
|
|
2092
|
+
suite = parseSuite(JSON.parse(readFileSync(args.suite, 'utf8')));
|
|
2093
|
+
}
|
|
2094
|
+
catch (e) {
|
|
2095
|
+
const hint = e instanceof SuiteConfigError ? e.message : 'could not read/parse suite: ' + String(e.message);
|
|
2096
|
+
console.log(JSON.stringify({ status: 'error', hint }, null, 2));
|
|
2097
|
+
process.exitCode = 2;
|
|
2098
|
+
return;
|
|
2099
|
+
}
|
|
2100
|
+
const store = new MapStore(dbPath());
|
|
2101
|
+
const node = store.getNode(suite.site);
|
|
2102
|
+
if (!node) {
|
|
2103
|
+
console.log(JSON.stringify({ status: 'error', hint: `no map for site '${suite.site}' — map it first (dev record-start) then re-run` }, null, 2));
|
|
2104
|
+
process.exitCode = 2;
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
const states = store.statesForNode(suite.site);
|
|
2108
|
+
// A suite always runs headless (a release check should never pop windows; the
|
|
2109
|
+
// suite opens a fresh browser per case). --headless is documented as the
|
|
2110
|
+
// recommended flag but the runner enforces headless regardless of the default.
|
|
2111
|
+
const profilesRoot = join(homedir(), '.webnav', 'profiles');
|
|
2112
|
+
const profileDir = suite.profile ? resolveProfile(suite.profile, profilesRoot) : undefined;
|
|
2113
|
+
const bopts = { headed: false, ...(profileDir ? { persistent: true, profile: profileDir } : {}) };
|
|
2114
|
+
const siteCreds = new CredStore().get(suite.site);
|
|
2115
|
+
const progress = [];
|
|
2116
|
+
const shortId = () => 'tst-' + Math.random().toString(36).slice(2, 6);
|
|
2117
|
+
const res = await runSuite(suite, {
|
|
2118
|
+
store, states,
|
|
2119
|
+
onProgress: (l) => { progress.push(l); process.stderr.write(l + '\n'); },
|
|
2120
|
+
// AUTH PRE-FLIGHT (once, before case 1): the SAME check as `dev profile-status`
|
|
2121
|
+
// — one headless load of the site homeUrl, settle, classify against the map's
|
|
2122
|
+
// own fingerprints (the oracle). needs-login fails the whole run fast.
|
|
2123
|
+
preflight: async () => {
|
|
2124
|
+
const session = shortId();
|
|
2125
|
+
const adapter = new PlaywrightAdapter(session, undefined, undefined, bopts);
|
|
2126
|
+
try {
|
|
2127
|
+
await adapter.open(node.homeUrl ?? 'about:blank');
|
|
2128
|
+
const landedUrl = await adapter.currentUrl();
|
|
2129
|
+
const snapshot = await settleSnapshot(() => adapter.snapshot());
|
|
2130
|
+
const { auth, loginUrl } = classifyAuthLanding(landedUrl, snapshot, suite.site, states);
|
|
2131
|
+
return { auth, loginUrl };
|
|
2132
|
+
}
|
|
2133
|
+
finally {
|
|
2134
|
+
await adapter.close().catch(() => { });
|
|
2135
|
+
}
|
|
2136
|
+
},
|
|
2137
|
+
// A FRESH browser session per case (the CF first-load pattern), reaped after.
|
|
2138
|
+
openCase: async (startUrl) => {
|
|
2139
|
+
const session = shortId();
|
|
2140
|
+
const gate = await ensureCanOpen(session, []);
|
|
2141
|
+
if (!gate.ok)
|
|
2142
|
+
throw new Error(gate.reason);
|
|
2143
|
+
const adapter = new PlaywrightAdapter(session, undefined, undefined, bopts);
|
|
2144
|
+
await adapter.open(startUrl);
|
|
2145
|
+
const browser = makeLiveWalkBrowser(adapter, { ...siteCreds }, bopts, session);
|
|
2146
|
+
return { browser, close: async () => { await adapter.close().catch(() => { }); } };
|
|
2147
|
+
},
|
|
2148
|
+
});
|
|
2149
|
+
console.log(JSON.stringify(res, null, 2));
|
|
2150
|
+
process.exitCode = res.status === 'ok' ? 0 : 3;
|
|
2151
|
+
return;
|
|
2152
|
+
}
|
|
2153
|
+
if (args.cmd === 'walk') {
|
|
2154
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
2155
|
+
const { ensureSeeded } = await import('./graph/seed.js');
|
|
2156
|
+
const { findPath } = await import('./router/path.js');
|
|
2157
|
+
const { walkRoute } = await import('./router/walk.js');
|
|
2158
|
+
const { WalkSessionStore } = await import('./router/walk-session.js');
|
|
2159
|
+
const { makeLiveWalkBrowser } = await import('./router/walk-live.js');
|
|
2160
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
2161
|
+
const store = new MapStore();
|
|
2162
|
+
if (args.hosted) {
|
|
2163
|
+
// HOSTED ROUTE: fetch the site's map LIVE from the service and import it,
|
|
2164
|
+
// instead of using the local seed. The site id is the start state's prefix
|
|
2165
|
+
// (e.g. www.saucedemo.com:login -> www.saucedemo.com). Credentials are still
|
|
2166
|
+
// loaded LOCALLY below (CredStore) — the hosted route never sees them.
|
|
2167
|
+
const site = args.start.includes(':') ? args.start.slice(0, args.start.lastIndexOf(':')) : args.start;
|
|
2168
|
+
try {
|
|
2169
|
+
const { fetchHostedMap, importMapPack } = await import('./hosted.js');
|
|
2170
|
+
const pack = await fetchHostedMap(site);
|
|
2171
|
+
importMapPack(store, pack);
|
|
2172
|
+
}
|
|
2173
|
+
catch (e) {
|
|
2174
|
+
console.log(JSON.stringify({ status: 'failed', reason: String(e.message) }, null, 2));
|
|
2175
|
+
process.exitCode = 2;
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
else {
|
|
2180
|
+
ensureSeeded(store);
|
|
2181
|
+
}
|
|
2182
|
+
if (!store.getState(args.start)) {
|
|
2183
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'unknown state ' + args.start }, null, 2));
|
|
2184
|
+
process.exitCode = 2;
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
if (!store.getState(args.goal)) {
|
|
2188
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'unknown state ' + args.goal }, null, 2));
|
|
2189
|
+
process.exitCode = 2;
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
const path = findPath(store, args.start, args.goal);
|
|
2193
|
+
if (!path) {
|
|
2194
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'no route from ' + args.start + ' to ' + args.goal }, null, 2));
|
|
2195
|
+
process.exitCode = 3;
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
const startState = store.getState(args.start);
|
|
2199
|
+
const states = store.statesForNode(startState.nodeId ?? '');
|
|
2200
|
+
// --observe <label>: resolved against the goal's node like --start/--goal (a
|
|
2201
|
+
// state id, or its bare semanticName) — same scope, so `--observe inventory`
|
|
2202
|
+
// finds `www.saucedemo.com:inventory` without repeating the site prefix. A
|
|
2203
|
+
// label that resolves to nothing errors HERE, loudly, BEFORE any browser
|
|
2204
|
+
// opens — a silent drop would return a normal done with zero checkpoints and
|
|
2205
|
+
// no signal that the typo'd observe never armed (same posture as a bad
|
|
2206
|
+
// --start/--goal).
|
|
2207
|
+
const observe = [];
|
|
2208
|
+
for (const label of args.observe) {
|
|
2209
|
+
const id = resolveObserveLabel(states, label);
|
|
2210
|
+
if (!id) {
|
|
2211
|
+
console.log(JSON.stringify({ status: 'error',
|
|
2212
|
+
hint: 'unknown --observe state "' + label + '" — known states for ' + (startState.nodeId ?? args.start)
|
|
2213
|
+
+ ': ' + states.map((s) => s.semanticName).join(', ') }, null, 2));
|
|
2214
|
+
process.exitCode = 2;
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
observe.push(id);
|
|
2218
|
+
}
|
|
2219
|
+
const browserSession = 'w-' + Date.now();
|
|
2220
|
+
// Live-session CEILING (prevents the browser-count explosion). First frees orphans +
|
|
2221
|
+
// abandoned paused-walk browsers older than 1h (the real leak — a needs-* pause leaves a
|
|
2222
|
+
// live daemon nothing else reaps), then refuses if still at the cap. Never breaks a walk
|
|
2223
|
+
// on a housekeeping error (ensureCanOpen degrades to ok).
|
|
2224
|
+
const { ensureCanOpen } = await import('./playwright/sessions.js');
|
|
2225
|
+
const staleWalks = new WalkSessionStore().staleBrowserSessions(60 * 60 * 1000);
|
|
2226
|
+
const gate = await ensureCanOpen(browserSession, staleWalks);
|
|
2227
|
+
if (!gate.ok) {
|
|
2228
|
+
console.log(JSON.stringify({ status: 'error', reason: gate.reason }, null, 2));
|
|
2229
|
+
process.exitCode = 2;
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
// --profile may be a session NAME (reuse a dashboard hand-login) or an absolute
|
|
2233
|
+
// path. A bare name resolves to ~/.webnav/profiles/<name> — the same dir the
|
|
2234
|
+
// dashboard's persistent recording wrote, so a Cloudflare/2FA login done by
|
|
2235
|
+
// hand once carries into every walk.
|
|
2236
|
+
const bopts = { ...args.browser };
|
|
2237
|
+
if (bopts.profile) {
|
|
2238
|
+
const { homedir } = await import('node:os');
|
|
2239
|
+
const { join } = await import('node:path');
|
|
2240
|
+
const { resolveProfile } = await import('./playwright/adapter.js');
|
|
2241
|
+
bopts.profile = resolveProfile(bopts.profile, join(homedir(), '.webnav', 'profiles'));
|
|
2242
|
+
}
|
|
2243
|
+
const adapter = new PlaywrightAdapter(browserSession, undefined, undefined, bopts);
|
|
2244
|
+
await adapter.open(startState.urlPattern || 'about:blank');
|
|
2245
|
+
// Inputs = stored creds for this site (if any) overlaid with any --input flags
|
|
2246
|
+
// (flags win). Lets `walk` run without re-typing credentials each time, while
|
|
2247
|
+
// a one-off --input still overrides. Creds live in ~/.webnav/credentials.json,
|
|
2248
|
+
// never in the map.
|
|
2249
|
+
const { CredStore } = await import('./creds.js');
|
|
2250
|
+
const siteCreds = startState.nodeId ? new CredStore().get(startState.nodeId) : {};
|
|
2251
|
+
const inputs = { ...siteCreds, ...args.inputs };
|
|
2252
|
+
// Pass bopts through so the walk can retry a detected SSO wall in a FRESH
|
|
2253
|
+
// session under the SAME profile (design item 2) — omitted (no retry
|
|
2254
|
+
// capability) when the walk isn't running under a named profile.
|
|
2255
|
+
const browser = makeLiveWalkBrowser(adapter, inputs, bopts, browserSession);
|
|
2256
|
+
const res = await walkRoute({ goalName: 'walk:' + args.goal, startStateId: args.start, goalStateId: args.goal, store, states, browser, path, profile: args.browser.profile, observe, observeDynamic: args.observeDynamic });
|
|
2257
|
+
// needs-auth: the wall persisted through the fresh-session retry (or no retry was
|
|
2258
|
+
// possible). Fail-fast, NOT a resumable pause — a stale login can't be fixed by
|
|
2259
|
+
// replaying the same route, so leaving a paused walk-session + a live daemon
|
|
2260
|
+
// around would just be a dangling browser nothing resumes. Close it here; the
|
|
2261
|
+
// consumer contract is: the agent tells the user to re-login, then re-runs `walk`.
|
|
2262
|
+
if (res.status === 'needs-auth') {
|
|
2263
|
+
await browser.close?.().catch(() => { });
|
|
2264
|
+
console.log(JSON.stringify(res, null, 2));
|
|
2265
|
+
process.exitCode = 2;
|
|
2266
|
+
return;
|
|
2267
|
+
}
|
|
2268
|
+
if (res.status === 'needs-navigation' || res.status === 'needs-classification' || res.status === 'checkpoint') {
|
|
2269
|
+
const sessions = new WalkSessionStore();
|
|
2270
|
+
// A wall retry may have rotated to a brand-new browser session — read it
|
|
2271
|
+
// back from the browser (not the original `browserSession` var) so a paused
|
|
2272
|
+
// session id the agent resumes against actually points at the live browser.
|
|
2273
|
+
const liveSession = browser.sessionId?.() || browserSession;
|
|
2274
|
+
const id = sessions.create({ startState: args.start, goalState: args.goal, path, browserSession: liveSession, profile: bopts.profile, observe, observeDynamic: args.observeDynamic });
|
|
2275
|
+
// pos points at the state the walk paused ON, so resume restarts there.
|
|
2276
|
+
const pausedAt = res.at;
|
|
2277
|
+
if (typeof pausedAt === 'number')
|
|
2278
|
+
sessions.advance(id, pausedAt);
|
|
2279
|
+
// Fire-once tracking: a checkpoint pause adds its OWN state to `observed` so a
|
|
2280
|
+
// resume doesn't re-fire it; pauseKind gates walk-resume's `--continue` to only
|
|
2281
|
+
// ever answer a checkpoint (never a needs-navigation/needs-classification pause).
|
|
2282
|
+
const observedNow = res.status === 'checkpoint' ? [res.state] : [];
|
|
2283
|
+
sessions.setPause(id, observedNow, res.status);
|
|
2284
|
+
// Expose browserSession so the agent can act on the LIVE paused browser
|
|
2285
|
+
// (e.g. fire an in-page affordance) via `use <verb> --session <browserSession>`
|
|
2286
|
+
// before calling walk-resume.
|
|
2287
|
+
console.log(JSON.stringify({ ...res, session: id, browserSession: liveSession }, null, 2));
|
|
2288
|
+
}
|
|
2289
|
+
else {
|
|
2290
|
+
if (browser.close)
|
|
2291
|
+
await browser.close().catch(() => { });
|
|
2292
|
+
else
|
|
2293
|
+
await adapter.close().catch(() => { });
|
|
2294
|
+
console.log(JSON.stringify(res, null, 2));
|
|
2295
|
+
if (res.status === 'failed')
|
|
2296
|
+
process.exitCode = 3;
|
|
2297
|
+
}
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
if (args.cmd === 'walk-resume') {
|
|
2301
|
+
const { MapStore } = await import('./mapstore/store.js');
|
|
2302
|
+
const { walkRoute } = await import('./router/walk.js');
|
|
2303
|
+
const { WalkSessionStore } = await import('./router/walk-session.js');
|
|
2304
|
+
const { makeLiveWalkBrowser } = await import('./router/walk-live.js');
|
|
2305
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
2306
|
+
const store = new MapStore();
|
|
2307
|
+
const sessions = new WalkSessionStore();
|
|
2308
|
+
const w = sessions.load(args.session);
|
|
2309
|
+
if (!w) {
|
|
2310
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'no active walk-session ' + args.session }, null, 2));
|
|
2311
|
+
process.exitCode = 2;
|
|
2312
|
+
return;
|
|
2313
|
+
}
|
|
2314
|
+
// --continue only ever answers a checkpoint pause — --ref/--classify answer the
|
|
2315
|
+
// OTHER pause kinds. Reject early with a hint rather than silently misapplying it
|
|
2316
|
+
// (walkRoute would otherwise just fall through to a normal step, masking the mistake).
|
|
2317
|
+
if (args.continue && w.pauseKind !== 'checkpoint') {
|
|
2318
|
+
console.log(JSON.stringify({ status: 'failed',
|
|
2319
|
+
reason: '--continue answers a checkpoint pause, but this session paused on ' + (w.pauseKind ?? 'unknown')
|
|
2320
|
+
+ ' — use --ref (needs-navigation) or --classify (needs-classification) instead' }, null, 2));
|
|
2321
|
+
process.exitCode = 2;
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
const answer = args.continue ? { kind: 'continue' }
|
|
2325
|
+
: args.ref ? { kind: 'ref', ref: args.ref }
|
|
2326
|
+
: args.classify ? { kind: 'classify', verdict: args.classify }
|
|
2327
|
+
: undefined;
|
|
2328
|
+
if (!answer) {
|
|
2329
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'supply --ref, --classify, or --continue' }, null, 2));
|
|
2330
|
+
process.exitCode = 2;
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
const resumeFrom = w.path[w.pos] ?? w.startState;
|
|
2334
|
+
const adapter = new PlaywrightAdapter(w.browserSession); // reattach the live browser
|
|
2335
|
+
const startState = store.getState(resumeFrom) ?? store.getState(w.startState);
|
|
2336
|
+
// Rebuild the SAME stored creds the original `walk` used, so input steps
|
|
2337
|
+
// encountered AFTER the pause (e.g. checkout's firstName/lastName/zip) still
|
|
2338
|
+
// auto-fill. Keying is by node, so it covers any input step on the route, not
|
|
2339
|
+
// just login. Without this the resume hits unfillable fields and fails to
|
|
2340
|
+
// resolve them — the bug that forced a `use` fallback.
|
|
2341
|
+
// One-off `--input` values are runtime-only (never persisted in the walk
|
|
2342
|
+
// session), so the agent re-supplies them here; flags win over stored creds,
|
|
2343
|
+
// same overlay as `walk`.
|
|
2344
|
+
const { CredStore } = await import('./creds.js');
|
|
2345
|
+
const siteCreds = startState.nodeId ? new CredStore().get(startState.nodeId) : {};
|
|
2346
|
+
const inputs = { ...siteCreds, ...args.inputs };
|
|
2347
|
+
// w.profile is the ALREADY-RESOLVED profile dir the original `walk` stored —
|
|
2348
|
+
// reusing it here (walk-resume takes no --profile of its own) is what lets a
|
|
2349
|
+
// wall retry reopen under the SAME profile mid-resume.
|
|
2350
|
+
const rbopts = w.profile ? { profile: w.profile } : undefined;
|
|
2351
|
+
const browser = makeLiveWalkBrowser(adapter, inputs, rbopts, w.browserSession);
|
|
2352
|
+
const states = store.statesForNode(startState.nodeId ?? '');
|
|
2353
|
+
const res = await walkRoute({ goalName: 'walk:' + w.goalState, startStateId: resumeFrom, goalStateId: w.goalState, store, states, browser, path: w.path, answer, profile: w.profile,
|
|
2354
|
+
observe: w.observe, observeDynamic: w.observeDynamic, observed: w.observed });
|
|
2355
|
+
if (res.status === 'needs-auth') {
|
|
2356
|
+
sessions.close(args.session);
|
|
2357
|
+
await (browser.close ? browser.close() : adapter.close()).catch(() => { });
|
|
2358
|
+
console.log(JSON.stringify(res, null, 2));
|
|
2359
|
+
process.exitCode = 2;
|
|
2360
|
+
}
|
|
2361
|
+
else if (res.status === 'needs-navigation' || res.status === 'needs-classification' || res.status === 'checkpoint') {
|
|
2362
|
+
// walkRoute's `at` is RELATIVE to resumeFrom (it starts each call at 0), but
|
|
2363
|
+
// the session `pos` is ABSOLUTE over the full path. resumeFrom sits at w.pos,
|
|
2364
|
+
// so absolute = w.pos + at. A single resume can traverse several states before
|
|
2365
|
+
// halting, so this keeps the session synced (the desync bug that restarted the
|
|
2366
|
+
// next resume at the wrong step).
|
|
2367
|
+
const relAt = res.at;
|
|
2368
|
+
const absPos = typeof relAt === 'number' ? w.pos + relAt : w.pos + 1;
|
|
2369
|
+
sessions.advance(args.session, absPos);
|
|
2370
|
+
// A wall retry may have rotated to a brand-new browser session mid-resume;
|
|
2371
|
+
// repoint the (stable) session_id at it so the NEXT walk-resume reattaches
|
|
2372
|
+
// to the live daemon instead of the one that was just closed.
|
|
2373
|
+
const liveSession = browser.sessionId?.() || w.browserSession;
|
|
2374
|
+
if (liveSession !== w.browserSession)
|
|
2375
|
+
sessions.rebrowser(args.session, liveSession);
|
|
2376
|
+
// Fire-once tracking carries forward: append this checkpoint's state (if any)
|
|
2377
|
+
// to the ALREADY-observed set from the original walk / prior resumes.
|
|
2378
|
+
const observedNow = res.status === 'checkpoint' ? [...w.observed, res.state] : w.observed;
|
|
2379
|
+
sessions.setPause(args.session, observedNow, res.status);
|
|
2380
|
+
console.log(JSON.stringify({ ...res, session: args.session, browserSession: liveSession }, null, 2));
|
|
2381
|
+
}
|
|
2382
|
+
else {
|
|
2383
|
+
sessions.close(args.session);
|
|
2384
|
+
await (browser.close ? browser.close() : adapter.close()).catch(() => { });
|
|
2385
|
+
console.log(JSON.stringify(res, null, 2));
|
|
2386
|
+
}
|
|
2387
|
+
return;
|
|
2388
|
+
}
|
|
2389
|
+
if (args.cmd === 'navigate') {
|
|
2390
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
2391
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
2392
|
+
// Live-session ceiling. ensureCanOpen excludes args.session, so REATTACHING an existing
|
|
2393
|
+
// session (the common record-flow case) is never refused — only a genuinely new session
|
|
2394
|
+
// past the cap is. It also frees orphans + stale paused-walk browsers first.
|
|
2395
|
+
const { ensureCanOpen } = await import('./playwright/sessions.js');
|
|
2396
|
+
const { WalkSessionStore } = await import('./router/walk-session.js');
|
|
2397
|
+
const gate = await ensureCanOpen(args.session, new WalkSessionStore().staleBrowserSessions(60 * 60 * 1000));
|
|
2398
|
+
if (!gate.ok) {
|
|
2399
|
+
console.log(JSON.stringify({ status: 'error', reason: gate.reason }, null, 2));
|
|
2400
|
+
process.exitCode = 2;
|
|
2401
|
+
return;
|
|
2402
|
+
}
|
|
2403
|
+
const { homedir: homedir2 } = await import('node:os');
|
|
2404
|
+
const { join: join2 } = await import('node:path');
|
|
2405
|
+
const profilesRoot2 = join2(homedir2(), '.webnav', 'profiles');
|
|
2406
|
+
// Agent path parity with the dashboard: a bare --profile NAME → the shared
|
|
2407
|
+
// profiles dir (+ prepProfile to clear an orphan lock) so an agent runs under
|
|
2408
|
+
// the same login a human would. resolveProfile leaves absolute paths untouched.
|
|
2409
|
+
const nbrowser = { ...args.browser };
|
|
2410
|
+
if (nbrowser.profile) {
|
|
2411
|
+
const { resolveProfile } = await import('./playwright/adapter.js');
|
|
2412
|
+
const { prepProfile } = await import('./playwright/profile-lock.js');
|
|
2413
|
+
nbrowser.profile = resolveProfile(nbrowser.profile, profilesRoot2);
|
|
2414
|
+
try {
|
|
2415
|
+
(await import('node:fs')).mkdirSync(nbrowser.profile, { recursive: true });
|
|
2416
|
+
}
|
|
2417
|
+
catch { /* */ }
|
|
2418
|
+
prepProfile(nbrowser.profile);
|
|
2419
|
+
}
|
|
2420
|
+
const adapter = new PlaywrightAdapter(args.session, undefined, undefined, nbrowser);
|
|
2421
|
+
try {
|
|
2422
|
+
// `open` creates the session if new AND navigates; it also works to
|
|
2423
|
+
// re-navigate an existing session (whereas `goto` requires the session to
|
|
2424
|
+
// already exist, which fails on the first navigate of a fresh session).
|
|
2425
|
+
await adapter.open(args.url);
|
|
2426
|
+
const rec = new RecordStore(dbPath());
|
|
2427
|
+
// Auto-start recording so an agent session is a first-class, dashboard-visible
|
|
2428
|
+
// recording (steps + video) without a separate record-start. Video capture
|
|
2429
|
+
// starts on the first navigate and is stopped by `dev record-stop`.
|
|
2430
|
+
const fresh = !rec.isActive(args.session);
|
|
2431
|
+
if (fresh) {
|
|
2432
|
+
rec.start(args.session);
|
|
2433
|
+
const profName = nbrowser.profile ? nbrowser.profile.split('/').pop() : null;
|
|
2434
|
+
if (profName)
|
|
2435
|
+
rec.setProfile(args.session, profName);
|
|
2436
|
+
rec.setStartUrl(args.session, args.url);
|
|
2437
|
+
// NOTE: no video here. playwright-cli video capture does NOT survive across
|
|
2438
|
+
// separate CLI processes (proven: video-stop in a later process → "No videos
|
|
2439
|
+
// were recorded"). Agent video needs a long-lived process owning the session
|
|
2440
|
+
// (like the human live-loop) — tracked as a follow-up, NOT faked here.
|
|
2441
|
+
}
|
|
2442
|
+
// Settle + record via the shared seam (same gate as agent-session/runActionRecorded):
|
|
2443
|
+
// a client-side redirect otherwise records a pre-render shell as the page, and the
|
|
2444
|
+
// requestedUrl (what the agent ASKED for) is the draft's redirect-alias evidence.
|
|
2445
|
+
const { recordNavigateEffect, classifyNavigateWall } = await import('./router/browse.js');
|
|
2446
|
+
const { toUrl, toSnapshot } = await recordNavigateEffect(args.url, args.session, rec, adapter);
|
|
2447
|
+
// Wall check (design item 2, NO auto-retry here — a recording captures what
|
|
2448
|
+
// actually happened, judgment-free). Classify against the target site's own
|
|
2449
|
+
// map states (the map is the oracle, same as profile-status/walk) and surface
|
|
2450
|
+
// authWall so the driving agent knows immediately instead of guessing from a
|
|
2451
|
+
// bare snapshot.
|
|
2452
|
+
let site = null;
|
|
2453
|
+
try {
|
|
2454
|
+
site = new URL(args.url).host;
|
|
2455
|
+
}
|
|
2456
|
+
catch { /* unparseable url */ }
|
|
2457
|
+
const { MapStore: MapStoreForWall } = await import('./mapstore/store.js');
|
|
2458
|
+
const wallStates = site ? new MapStoreForWall(dbPath()).statesForNode(site) : [];
|
|
2459
|
+
const wall = classifyNavigateWall(args.url, toUrl, toSnapshot, wallStates);
|
|
2460
|
+
console.log(JSON.stringify({
|
|
2461
|
+
status: 'done', url: toUrl, recorded: true,
|
|
2462
|
+
...(wall.authWall ? { authWall: true, loginUrl: wall.loginUrl } : {}),
|
|
2463
|
+
}, null, 2));
|
|
2464
|
+
}
|
|
2465
|
+
catch (e) {
|
|
2466
|
+
console.log(JSON.stringify({ status: 'failed', reason: String(e) }, null, 2));
|
|
2467
|
+
process.exitCode = 2;
|
|
2468
|
+
}
|
|
2469
|
+
return;
|
|
2470
|
+
}
|
|
2471
|
+
if (args.cmd === 'snapshot') {
|
|
2472
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
2473
|
+
const adapter = new PlaywrightAdapter(args.session);
|
|
2474
|
+
try {
|
|
2475
|
+
// Uniform JSON: every verb's stdout is structured. The page YAML (which the
|
|
2476
|
+
// agent reads for element refs) is carried as the `snapshot` field.
|
|
2477
|
+
const yaml = await adapter.snapshot();
|
|
2478
|
+
console.log(JSON.stringify({ status: 'done', snapshot: yaml }, null, 2));
|
|
2479
|
+
}
|
|
2480
|
+
catch (e) {
|
|
2481
|
+
console.log(JSON.stringify({ status: 'failed', reason: 'no live page for session ' + args.session + ' — run `use navigate` first' }, null, 2));
|
|
2482
|
+
process.exitCode = 2;
|
|
2483
|
+
}
|
|
2484
|
+
return;
|
|
2485
|
+
}
|
|
2486
|
+
if (args.cmd === 'click' || args.cmd === 'type') {
|
|
2487
|
+
const { PlaywrightAdapter } = await import('./playwright/adapter.js');
|
|
2488
|
+
const { RecordStore } = await import('./mapstore/record.js');
|
|
2489
|
+
const { runActionRecorded } = await import('./router/browse.js');
|
|
2490
|
+
const adapter = new PlaywrightAdapter(args.session);
|
|
2491
|
+
try {
|
|
2492
|
+
const fromSnapshot = await adapter.snapshot();
|
|
2493
|
+
const fromUrl = await adapter.currentUrl();
|
|
2494
|
+
// Recover a DURABLE fingerprint for the clicked element from the snapshot we just
|
|
2495
|
+
// took (the ephemeral ref is reassigned per snapshot; role+name+near survive). This
|
|
2496
|
+
// is carried through graph-analyse → graph-edit so an authored map gets fingerprints
|
|
2497
|
+
// without hand-writing them (Phase 4).
|
|
2498
|
+
const { parseSnapshot } = await import('./playwright/snapshot.js');
|
|
2499
|
+
const { recoverFingerprint } = await import('./playwright/fingerprint.js');
|
|
2500
|
+
const nodes = parseSnapshot(fromSnapshot);
|
|
2501
|
+
const chosen = nodes.find((n) => n.ref === args.ref);
|
|
2502
|
+
const elementFp = recoverFingerprint(nodes, args.ref);
|
|
2503
|
+
const action = chosen
|
|
2504
|
+
? { role: chosen.role, name: chosen.name, ref: args.ref, elementFp }
|
|
2505
|
+
: { role: '', name: null, ref: args.ref };
|
|
2506
|
+
const r = await runActionRecorded({
|
|
2507
|
+
sessionId: args.session, recordStore: new RecordStore(dbPath()),
|
|
2508
|
+
fromUrl, fromSnapshot,
|
|
2509
|
+
action,
|
|
2510
|
+
text: args.cmd === 'type' ? args.text : undefined,
|
|
2511
|
+
adapter: adapter,
|
|
2512
|
+
});
|
|
2513
|
+
console.log(JSON.stringify(r, null, 2));
|
|
2514
|
+
if (r.status === 'failed')
|
|
2515
|
+
process.exitCode = 2;
|
|
2516
|
+
}
|
|
2517
|
+
catch (e) {
|
|
2518
|
+
console.log(JSON.stringify({ status: 'failed', reason: String(e) }, null, 2));
|
|
2519
|
+
process.exitCode = 2;
|
|
2520
|
+
}
|
|
2521
|
+
return;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
// --observe <label>: resolved the same way --start/--goal already are — a full
|
|
2525
|
+
// state id, or its bare semanticName within the node's states (so `--observe
|
|
2526
|
+
// inventory` finds `www.saucedemo.com:inventory` without repeating the prefix).
|
|
2527
|
+
// null = unknown label; the walk handler errors on it (exit 2 + a hint listing
|
|
2528
|
+
// the node's known states) BEFORE opening a browser — same loud posture as a
|
|
2529
|
+
// bad --start/--goal, so a typo never silently disarms the checkpoint.
|
|
2530
|
+
export function resolveObserveLabel(states, label) {
|
|
2531
|
+
const byId = states.find((s) => s.id === label);
|
|
2532
|
+
if (byId)
|
|
2533
|
+
return byId.id;
|
|
2534
|
+
const byName = states.find((s) => s.semanticName === label);
|
|
2535
|
+
return byName ? byName.id : null;
|
|
2536
|
+
}
|
|
2537
|
+
// A result that "ran fine but found nothing/blocked/failed" → exit code 3.
|
|
2538
|
+
function isEmptyOrFailed(response) {
|
|
2539
|
+
if (!response || typeof response !== 'object')
|
|
2540
|
+
return false;
|
|
2541
|
+
const r = response;
|
|
2542
|
+
if (r.status === 'failed')
|
|
2543
|
+
return true;
|
|
2544
|
+
// search: zero results gathered.
|
|
2545
|
+
if (Array.isArray(r.results) && r.results.length === 0)
|
|
2546
|
+
return true;
|
|
2547
|
+
if (Array.isArray(r.evidence) && r.evidence.length === 0)
|
|
2548
|
+
return true;
|
|
2549
|
+
return false;
|
|
2550
|
+
}
|
|
2551
|
+
// Only run when invoked directly, not when imported by tests.
|
|
2552
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
2553
|
+
// Thrown errors (bad args, unknown verb, crash) → stderr + exit code 2.
|
|
2554
|
+
main().catch((e) => {
|
|
2555
|
+
console.error(e instanceof Error ? e.message : String(e));
|
|
2556
|
+
process.exit(2);
|
|
2557
|
+
});
|
|
2558
|
+
}
|