@enricai/barnacle 1.0.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 +21 -0
- package/README.md +958 -0
- package/dist/api/errors.d.ts +88 -0
- package/dist/api/errors.d.ts.map +1 -0
- package/dist/api/errors.js +164 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/helpers/envelope.d.ts +13 -0
- package/dist/api/helpers/envelope.d.ts.map +1 -0
- package/dist/api/helpers/envelope.js +20 -0
- package/dist/api/helpers/envelope.js.map +1 -0
- package/dist/api/helpers/reply.d.ts +9 -0
- package/dist/api/helpers/reply.d.ts.map +1 -0
- package/dist/api/helpers/reply.js +14 -0
- package/dist/api/helpers/reply.js.map +1 -0
- package/dist/api/plugins/auth.d.ts +27 -0
- package/dist/api/plugins/auth.d.ts.map +1 -0
- package/dist/api/plugins/auth.js +111 -0
- package/dist/api/plugins/auth.js.map +1 -0
- package/dist/api/plugins/error-handler.d.ts +12 -0
- package/dist/api/plugins/error-handler.d.ts.map +1 -0
- package/dist/api/plugins/error-handler.js +72 -0
- package/dist/api/plugins/error-handler.js.map +1 -0
- package/dist/api/plugins/request-context.d.ts +17 -0
- package/dist/api/plugins/request-context.d.ts.map +1 -0
- package/dist/api/plugins/request-context.js +61 -0
- package/dist/api/plugins/request-context.js.map +1 -0
- package/dist/api/routes/health.d.ts +55 -0
- package/dist/api/routes/health.d.ts.map +1 -0
- package/dist/api/routes/health.js +137 -0
- package/dist/api/routes/health.js.map +1 -0
- package/dist/api/routes/plugins-introspection.d.ts +17 -0
- package/dist/api/routes/plugins-introspection.d.ts.map +1 -0
- package/dist/api/routes/plugins-introspection.js +13 -0
- package/dist/api/routes/plugins-introspection.js.map +1 -0
- package/dist/api/schemas/common.d.ts +114 -0
- package/dist/api/schemas/common.d.ts.map +1 -0
- package/dist/api/schemas/common.js +109 -0
- package/dist/api/schemas/common.js.map +1 -0
- package/dist/api/schemas/telemetry.d.ts +66 -0
- package/dist/api/schemas/telemetry.d.ts.map +1 -0
- package/dist/api/schemas/telemetry.js +52 -0
- package/dist/api/schemas/telemetry.js.map +1 -0
- package/dist/cache/keyed-ttl-cache.d.ts +45 -0
- package/dist/cache/keyed-ttl-cache.d.ts.map +1 -0
- package/dist/cache/keyed-ttl-cache.js +69 -0
- package/dist/cache/keyed-ttl-cache.js.map +1 -0
- package/dist/cache/response-cache.d.ts +37 -0
- package/dist/cache/response-cache.d.ts.map +1 -0
- package/dist/cache/response-cache.js +114 -0
- package/dist/cache/response-cache.js.map +1 -0
- package/dist/config.d.ts +258 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +138 -0
- package/dist/config.js.map +1 -0
- package/dist/lib/applicant-payload.d.ts +28 -0
- package/dist/lib/applicant-payload.d.ts.map +1 -0
- package/dist/lib/applicant-payload.js +20 -0
- package/dist/lib/applicant-payload.js.map +1 -0
- package/dist/lib/application-address.d.ts +16 -0
- package/dist/lib/application-address.d.ts.map +1 -0
- package/dist/lib/application-address.js +20 -0
- package/dist/lib/application-address.js.map +1 -0
- package/dist/lib/application-answers.d.ts +151 -0
- package/dist/lib/application-answers.d.ts.map +1 -0
- package/dist/lib/application-answers.js +95 -0
- package/dist/lib/application-answers.js.map +1 -0
- package/dist/lib/application-identity.d.ts +18 -0
- package/dist/lib/application-identity.d.ts.map +1 -0
- package/dist/lib/application-identity.js +20 -0
- package/dist/lib/application-identity.js.map +1 -0
- package/dist/lib/application-resume.d.ts +21 -0
- package/dist/lib/application-resume.d.ts.map +1 -0
- package/dist/lib/application-resume.js +23 -0
- package/dist/lib/application-resume.js.map +1 -0
- package/dist/lib/bedrock.d.ts +22 -0
- package/dist/lib/bedrock.d.ts.map +1 -0
- package/dist/lib/bedrock.js +35 -0
- package/dist/lib/bedrock.js.map +1 -0
- package/dist/lib/case-insensitive-headers.d.ts +15 -0
- package/dist/lib/case-insensitive-headers.d.ts.map +1 -0
- package/dist/lib/case-insensitive-headers.js +29 -0
- package/dist/lib/case-insensitive-headers.js.map +1 -0
- package/dist/lib/chromium-client-hints.d.ts +11 -0
- package/dist/lib/chromium-client-hints.d.ts.map +1 -0
- package/dist/lib/chromium-client-hints.js +17 -0
- package/dist/lib/chromium-client-hints.js.map +1 -0
- package/dist/lib/cookie-value.d.ts +8 -0
- package/dist/lib/cookie-value.d.ts.map +1 -0
- package/dist/lib/cookie-value.js +14 -0
- package/dist/lib/cookie-value.js.map +1 -0
- package/dist/lib/datadog.d.ts +8 -0
- package/dist/lib/datadog.d.ts.map +1 -0
- package/dist/lib/datadog.js +27 -0
- package/dist/lib/datadog.js.map +1 -0
- package/dist/lib/dd-metrics.d.ts +28 -0
- package/dist/lib/dd-metrics.d.ts.map +1 -0
- package/dist/lib/dd-metrics.js +62 -0
- package/dist/lib/dd-metrics.js.map +1 -0
- package/dist/lib/dispatch-metrics.d.ts +19 -0
- package/dist/lib/dispatch-metrics.d.ts.map +1 -0
- package/dist/lib/dispatch-metrics.js +65 -0
- package/dist/lib/dispatch-metrics.js.map +1 -0
- package/dist/lib/env.d.ts +37 -0
- package/dist/lib/env.d.ts.map +1 -0
- package/dist/lib/env.js +70 -0
- package/dist/lib/env.js.map +1 -0
- package/dist/lib/errors.d.ts +15 -0
- package/dist/lib/errors.d.ts.map +1 -0
- package/dist/lib/errors.js +20 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/http.d.ts +7 -0
- package/dist/lib/http.d.ts.map +1 -0
- package/dist/lib/http.js +14 -0
- package/dist/lib/http.js.map +1 -0
- package/dist/lib/job-tracking.d.ts +17 -0
- package/dist/lib/job-tracking.d.ts.map +1 -0
- package/dist/lib/job-tracking.js +19 -0
- package/dist/lib/job-tracking.js.map +1 -0
- package/dist/lib/llm/anthropic-client.d.ts +12 -0
- package/dist/lib/llm/anthropic-client.d.ts.map +1 -0
- package/dist/lib/llm/anthropic-client.js +23 -0
- package/dist/lib/llm/anthropic-client.js.map +1 -0
- package/dist/lib/llm/judge.d.ts +57 -0
- package/dist/lib/llm/judge.d.ts.map +1 -0
- package/dist/lib/llm/judge.js +123 -0
- package/dist/lib/llm/judge.js.map +1 -0
- package/dist/lib/llm/judges/error-messages.d.ts +38 -0
- package/dist/lib/llm/judges/error-messages.d.ts.map +1 -0
- package/dist/lib/llm/judges/error-messages.js +72 -0
- package/dist/lib/llm/judges/error-messages.js.map +1 -0
- package/dist/lib/llm/judges/invalid-fields.d.ts +43 -0
- package/dist/lib/llm/judges/invalid-fields.d.ts.map +1 -0
- package/dist/lib/llm/judges/invalid-fields.js +80 -0
- package/dist/lib/llm/judges/invalid-fields.js.map +1 -0
- package/dist/lib/llm/judges/modal-priority.d.ts +41 -0
- package/dist/lib/llm/judges/modal-priority.d.ts.map +1 -0
- package/dist/lib/llm/judges/modal-priority.js +74 -0
- package/dist/lib/llm/judges/modal-priority.js.map +1 -0
- package/dist/lib/llm/judges/select-option.d.ts +49 -0
- package/dist/lib/llm/judges/select-option.d.ts.map +1 -0
- package/dist/lib/llm/judges/select-option.js +90 -0
- package/dist/lib/llm/judges/select-option.js.map +1 -0
- package/dist/lib/llm/judges/verify-submit.d.ts +68 -0
- package/dist/lib/llm/judges/verify-submit.d.ts.map +1 -0
- package/dist/lib/llm/judges/verify-submit.js +89 -0
- package/dist/lib/llm/judges/verify-submit.js.map +1 -0
- package/dist/lib/llm/schemas.d.ts +220 -0
- package/dist/lib/llm/schemas.d.ts.map +1 -0
- package/dist/lib/llm/schemas.js +239 -0
- package/dist/lib/llm/schemas.js.map +1 -0
- package/dist/lib/logging.d.ts +39 -0
- package/dist/lib/logging.d.ts.map +1 -0
- package/dist/lib/logging.js +219 -0
- package/dist/lib/logging.js.map +1 -0
- package/dist/lib/multipart.d.ts +34 -0
- package/dist/lib/multipart.d.ts.map +1 -0
- package/dist/lib/multipart.js +55 -0
- package/dist/lib/multipart.js.map +1 -0
- package/dist/lib/option-matcher.d.ts +15 -0
- package/dist/lib/option-matcher.d.ts.map +1 -0
- package/dist/lib/option-matcher.js +69 -0
- package/dist/lib/option-matcher.js.map +1 -0
- package/dist/lib/phone.d.ts +11 -0
- package/dist/lib/phone.d.ts.map +1 -0
- package/dist/lib/phone.js +17 -0
- package/dist/lib/phone.js.map +1 -0
- package/dist/lib/random.d.ts +22 -0
- package/dist/lib/random.d.ts.map +1 -0
- package/dist/lib/random.js +33 -0
- package/dist/lib/random.js.map +1 -0
- package/dist/lib/statsd.d.ts +11 -0
- package/dist/lib/statsd.d.ts.map +1 -0
- package/dist/lib/statsd.js +68 -0
- package/dist/lib/statsd.js.map +1 -0
- package/dist/lib/telemetry/call-capture.d.ts +89 -0
- package/dist/lib/telemetry/call-capture.d.ts.map +1 -0
- package/dist/lib/telemetry/call-capture.js +190 -0
- package/dist/lib/telemetry/call-capture.js.map +1 -0
- package/dist/lib/telemetry/call-types.d.ts +65 -0
- package/dist/lib/telemetry/call-types.d.ts.map +1 -0
- package/dist/lib/telemetry/call-types.js +68 -0
- package/dist/lib/telemetry/call-types.js.map +1 -0
- package/dist/lib/telemetry/run-state.d.ts +15 -0
- package/dist/lib/telemetry/run-state.d.ts.map +1 -0
- package/dist/lib/telemetry/run-state.js +23 -0
- package/dist/lib/telemetry/run-state.js.map +1 -0
- package/dist/lib/telemetry/s3-sink.d.ts +48 -0
- package/dist/lib/telemetry/s3-sink.d.ts.map +1 -0
- package/dist/lib/telemetry/s3-sink.js +229 -0
- package/dist/lib/telemetry/s3-sink.js.map +1 -0
- package/dist/lib/telemetry/submission-capture.d.ts +45 -0
- package/dist/lib/telemetry/submission-capture.d.ts.map +1 -0
- package/dist/lib/telemetry/submission-capture.js +57 -0
- package/dist/lib/telemetry/submission-capture.js.map +1 -0
- package/dist/lib/telemetry/telemetry-paths.d.ts +59 -0
- package/dist/lib/telemetry/telemetry-paths.d.ts.map +1 -0
- package/dist/lib/telemetry/telemetry-paths.js +103 -0
- package/dist/lib/telemetry/telemetry-paths.js.map +1 -0
- package/dist/lib/tracking-click.d.ts +20 -0
- package/dist/lib/tracking-click.d.ts.map +1 -0
- package/dist/lib/tracking-click.js +75 -0
- package/dist/lib/tracking-click.js.map +1 -0
- package/dist/lib/url-capture.d.ts +8 -0
- package/dist/lib/url-capture.d.ts.map +1 -0
- package/dist/lib/url-capture.js +17 -0
- package/dist/lib/url-capture.js.map +1 -0
- package/dist/lib/us-states.d.ts +20 -0
- package/dist/lib/us-states.d.ts.map +1 -0
- package/dist/lib/us-states.js +86 -0
- package/dist/lib/us-states.js.map +1 -0
- package/dist/lib/zod-multipart.d.ts +18 -0
- package/dist/lib/zod-multipart.d.ts.map +1 -0
- package/dist/lib/zod-multipart.js +35 -0
- package/dist/lib/zod-multipart.js.map +1 -0
- package/dist/plugins/config-plugin.d.ts +77 -0
- package/dist/plugins/config-plugin.d.ts.map +1 -0
- package/dist/plugins/config-plugin.js +216 -0
- package/dist/plugins/config-plugin.js.map +1 -0
- package/dist/plugins/discover.d.ts +72 -0
- package/dist/plugins/discover.d.ts.map +1 -0
- package/dist/plugins/discover.js +265 -0
- package/dist/plugins/discover.js.map +1 -0
- package/dist/plugins/json-schema-to-zod.d.ts +47 -0
- package/dist/plugins/json-schema-to-zod.d.ts.map +1 -0
- package/dist/plugins/json-schema-to-zod.js +85 -0
- package/dist/plugins/json-schema-to-zod.js.map +1 -0
- package/dist/plugins/loader.d.ts +34 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +348 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/plugin-api-version.d.ts +8 -0
- package/dist/plugins/plugin-api-version.d.ts.map +1 -0
- package/dist/plugins/plugin-api-version.js +11 -0
- package/dist/plugins/plugin-api-version.js.map +1 -0
- package/dist/plugins/plugin-manifest-envelope.d.ts +16 -0
- package/dist/plugins/plugin-manifest-envelope.d.ts.map +1 -0
- package/dist/plugins/plugin-manifest-envelope.js +19 -0
- package/dist/plugins/plugin-manifest-envelope.js.map +1 -0
- package/dist/scraper/behavioral-signals.d.ts +16 -0
- package/dist/scraper/behavioral-signals.d.ts.map +1 -0
- package/dist/scraper/behavioral-signals.js +30 -0
- package/dist/scraper/behavioral-signals.js.map +1 -0
- package/dist/scraper/errors.d.ts +193 -0
- package/dist/scraper/errors.d.ts.map +1 -0
- package/dist/scraper/errors.js +226 -0
- package/dist/scraper/errors.js.map +1 -0
- package/dist/scraper/fixtures.d.ts +23 -0
- package/dist/scraper/fixtures.d.ts.map +1 -0
- package/dist/scraper/fixtures.js +48 -0
- package/dist/scraper/fixtures.js.map +1 -0
- package/dist/scraper/flow-runner.d.ts +1319 -0
- package/dist/scraper/flow-runner.d.ts.map +1 -0
- package/dist/scraper/flow-runner.js +5637 -0
- package/dist/scraper/flow-runner.js.map +1 -0
- package/dist/scraper/graphql-client.d.ts +30 -0
- package/dist/scraper/graphql-client.d.ts.map +1 -0
- package/dist/scraper/graphql-client.js +14 -0
- package/dist/scraper/graphql-client.js.map +1 -0
- package/dist/scraper/http-client.d.ts +75 -0
- package/dist/scraper/http-client.d.ts.map +1 -0
- package/dist/scraper/http-client.js +189 -0
- package/dist/scraper/http-client.js.map +1 -0
- package/dist/scraper/http-status-classifier.d.ts +12 -0
- package/dist/scraper/http-status-classifier.d.ts.map +1 -0
- package/dist/scraper/http-status-classifier.js +29 -0
- package/dist/scraper/http-status-classifier.js.map +1 -0
- package/dist/scraper/metrics.d.ts +50 -0
- package/dist/scraper/metrics.d.ts.map +1 -0
- package/dist/scraper/metrics.js +85 -0
- package/dist/scraper/metrics.js.map +1 -0
- package/dist/scraper/navigate.d.ts +20 -0
- package/dist/scraper/navigate.d.ts.map +1 -0
- package/dist/scraper/navigate.js +30 -0
- package/dist/scraper/navigate.js.map +1 -0
- package/dist/scraper/oracle-sentinels.d.ts +22 -0
- package/dist/scraper/oracle-sentinels.d.ts.map +1 -0
- package/dist/scraper/oracle-sentinels.js +40 -0
- package/dist/scraper/oracle-sentinels.js.map +1 -0
- package/dist/scraper/parse-json-response.d.ts +16 -0
- package/dist/scraper/parse-json-response.d.ts.map +1 -0
- package/dist/scraper/parse-json-response.js +37 -0
- package/dist/scraper/parse-json-response.js.map +1 -0
- package/dist/scraper/pool.d.ts +37 -0
- package/dist/scraper/pool.d.ts.map +1 -0
- package/dist/scraper/pool.js +105 -0
- package/dist/scraper/pool.js.map +1 -0
- package/dist/scraper/rate-limited-json-client.d.ts +35 -0
- package/dist/scraper/rate-limited-json-client.d.ts.map +1 -0
- package/dist/scraper/rate-limited-json-client.js +29 -0
- package/dist/scraper/rate-limited-json-client.js.map +1 -0
- package/dist/scraper/raw-fetch.d.ts +49 -0
- package/dist/scraper/raw-fetch.d.ts.map +1 -0
- package/dist/scraper/raw-fetch.js +40 -0
- package/dist/scraper/raw-fetch.js.map +1 -0
- package/dist/scraper/require-response-field.d.ts +23 -0
- package/dist/scraper/require-response-field.d.ts.map +1 -0
- package/dist/scraper/require-response-field.js +42 -0
- package/dist/scraper/require-response-field.js.map +1 -0
- package/dist/scraper/retry.d.ts +33 -0
- package/dist/scraper/retry.d.ts.map +1 -0
- package/dist/scraper/retry.js +122 -0
- package/dist/scraper/retry.js.map +1 -0
- package/dist/scraper/session-browserbase.d.ts +35 -0
- package/dist/scraper/session-browserbase.d.ts.map +1 -0
- package/dist/scraper/session-browserbase.js +198 -0
- package/dist/scraper/session-browserbase.js.map +1 -0
- package/dist/scraper/session-shared.d.ts +29 -0
- package/dist/scraper/session-shared.d.ts.map +1 -0
- package/dist/scraper/session-shared.js +35 -0
- package/dist/scraper/session-shared.js.map +1 -0
- package/dist/scraper/session-steel.d.ts +31 -0
- package/dist/scraper/session-steel.d.ts.map +1 -0
- package/dist/scraper/session-steel.js +140 -0
- package/dist/scraper/session-steel.js.map +1 -0
- package/dist/scraper/session-warmup.d.ts +31 -0
- package/dist/scraper/session-warmup.d.ts.map +1 -0
- package/dist/scraper/session-warmup.js +46 -0
- package/dist/scraper/session-warmup.js.map +1 -0
- package/dist/scraper/session.d.ts +21 -0
- package/dist/scraper/session.d.ts.map +1 -0
- package/dist/scraper/session.js +28 -0
- package/dist/scraper/session.js.map +1 -0
- package/dist/scraper/stagehand-guard.d.ts +128 -0
- package/dist/scraper/stagehand-guard.d.ts.map +1 -0
- package/dist/scraper/stagehand-guard.js +337 -0
- package/dist/scraper/stagehand-guard.js.map +1 -0
- package/dist/scraper/throttle.d.ts +21 -0
- package/dist/scraper/throttle.d.ts.map +1 -0
- package/dist/scraper/throttle.js +45 -0
- package/dist/scraper/throttle.js.map +1 -0
- package/dist/scripts/hca-inbox-poll.d.ts +19 -0
- package/dist/scripts/hca-inbox-poll.d.ts.map +1 -0
- package/dist/scripts/hca-inbox-poll.js +151 -0
- package/dist/scripts/hca-inbox-poll.js.map +1 -0
- package/dist/scripts/judge-llm-batch.d.ts +98 -0
- package/dist/scripts/judge-llm-batch.d.ts.map +1 -0
- package/dist/scripts/judge-llm-batch.js +350 -0
- package/dist/scripts/judge-llm-batch.js.map +1 -0
- package/dist/scripts/llm-heal.d.ts +165 -0
- package/dist/scripts/llm-heal.d.ts.map +1 -0
- package/dist/scripts/llm-heal.js +595 -0
- package/dist/scripts/llm-heal.js.map +1 -0
- package/dist/scripts/migrate-telemetry-dir-names.d.ts +57 -0
- package/dist/scripts/migrate-telemetry-dir-names.d.ts.map +1 -0
- package/dist/scripts/migrate-telemetry-dir-names.js +156 -0
- package/dist/scripts/migrate-telemetry-dir-names.js.map +1 -0
- package/dist/scripts/probe-setinputfiles.d.ts +28 -0
- package/dist/scripts/probe-setinputfiles.d.ts.map +1 -0
- package/dist/scripts/probe-setinputfiles.js +95 -0
- package/dist/scripts/probe-setinputfiles.js.map +1 -0
- package/dist/scripts/recon-browser.d.ts +304 -0
- package/dist/scripts/recon-browser.d.ts.map +1 -0
- package/dist/scripts/recon-browser.js +1833 -0
- package/dist/scripts/recon-browser.js.map +1 -0
- package/dist/scripts/recon-generate.d.ts +182 -0
- package/dist/scripts/recon-generate.d.ts.map +1 -0
- package/dist/scripts/recon-generate.js +2572 -0
- package/dist/scripts/recon-generate.js.map +1 -0
- package/dist/scripts/recon-heal.d.ts +176 -0
- package/dist/scripts/recon-heal.d.ts.map +1 -0
- package/dist/scripts/recon-heal.js +584 -0
- package/dist/scripts/recon-heal.js.map +1 -0
- package/dist/scripts/recon-http.d.ts +19 -0
- package/dist/scripts/recon-http.d.ts.map +1 -0
- package/dist/scripts/recon-http.js +377 -0
- package/dist/scripts/recon-http.js.map +1 -0
- package/dist/scripts/recon-replay-jobs.d.ts +21 -0
- package/dist/scripts/recon-replay-jobs.d.ts.map +1 -0
- package/dist/scripts/recon-replay-jobs.js +221 -0
- package/dist/scripts/recon-replay-jobs.js.map +1 -0
- package/dist/scripts/recon-shared.d.ts +52 -0
- package/dist/scripts/recon-shared.d.ts.map +1 -0
- package/dist/scripts/recon-shared.js +68 -0
- package/dist/scripts/recon-shared.js.map +1 -0
- package/dist/scripts/recon-summarize.d.ts +15 -0
- package/dist/scripts/recon-summarize.d.ts.map +1 -0
- package/dist/scripts/recon-summarize.js +233 -0
- package/dist/scripts/recon-summarize.js.map +1 -0
- package/dist/scripts/smoke-test.d.ts +22 -0
- package/dist/scripts/smoke-test.d.ts.map +1 -0
- package/dist/scripts/smoke-test.js +236 -0
- package/dist/scripts/smoke-test.js.map +1 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +189 -0
- package/dist/server.js.map +1 -0
- package/dist/site-plugin.d.ts +226 -0
- package/dist/site-plugin.d.ts.map +1 -0
- package/dist/site-plugin.js +13 -0
- package/dist/site-plugin.js.map +1 -0
- package/dist/testing/answers-fixture.d.ts +13 -0
- package/dist/testing/answers-fixture.d.ts.map +1 -0
- package/dist/testing/answers-fixture.js +37 -0
- package/dist/testing/answers-fixture.js.map +1 -0
- package/dist/testing/batch-email-confirmation.d.ts +58 -0
- package/dist/testing/batch-email-confirmation.d.ts.map +1 -0
- package/dist/testing/batch-email-confirmation.js +62 -0
- package/dist/testing/batch-email-confirmation.js.map +1 -0
- package/dist/testing/batch-report.d.ts +33 -0
- package/dist/testing/batch-report.d.ts.map +1 -0
- package/dist/testing/batch-report.js +30 -0
- package/dist/testing/batch-report.js.map +1 -0
- package/dist/testing/contract-parity-suite.d.ts +55 -0
- package/dist/testing/contract-parity-suite.d.ts.map +1 -0
- package/dist/testing/contract-parity-suite.js +39 -0
- package/dist/testing/contract-parity-suite.js.map +1 -0
- package/dist/testing/coverage-guard-suite.d.ts +46 -0
- package/dist/testing/coverage-guard-suite.d.ts.map +1 -0
- package/dist/testing/coverage-guard-suite.js +39 -0
- package/dist/testing/coverage-guard-suite.js.map +1 -0
- package/dist/testing/fixtures/resume.pdf +54 -0
- package/dist/testing/integration-runner.d.ts +62 -0
- package/dist/testing/integration-runner.d.ts.map +1 -0
- package/dist/testing/integration-runner.js +46 -0
- package/dist/testing/integration-runner.js.map +1 -0
- package/dist/testing/mock-fetch-response.d.ts +7 -0
- package/dist/testing/mock-fetch-response.d.ts.map +1 -0
- package/dist/testing/mock-fetch-response.js +16 -0
- package/dist/testing/mock-fetch-response.js.map +1 -0
- package/dist/testing/persona-fixture.d.ts +77 -0
- package/dist/testing/persona-fixture.d.ts.map +1 -0
- package/dist/testing/persona-fixture.js +83 -0
- package/dist/testing/persona-fixture.js.map +1 -0
- package/dist/testing/replay-integration-suite.d.ts +50 -0
- package/dist/testing/replay-integration-suite.d.ts.map +1 -0
- package/dist/testing/replay-integration-suite.js +45 -0
- package/dist/testing/replay-integration-suite.js.map +1 -0
- package/dist/testing/resume-fixture.d.ts +39 -0
- package/dist/testing/resume-fixture.d.ts.map +1 -0
- package/dist/testing/resume-fixture.js +38 -0
- package/dist/testing/resume-fixture.js.map +1 -0
- package/dist/testmail/client.d.ts +61 -0
- package/dist/testmail/client.d.ts.map +1 -0
- package/dist/testmail/client.js +190 -0
- package/dist/testmail/client.js.map +1 -0
- package/dist/testmail/errors.d.ts +18 -0
- package/dist/testmail/errors.d.ts.map +1 -0
- package/dist/testmail/errors.js +29 -0
- package/dist/testmail/errors.js.map +1 -0
- package/dist/types/dispatch-metrics.d.ts +46 -0
- package/dist/types/dispatch-metrics.d.ts.map +1 -0
- package/dist/types/dispatch-metrics.js +25 -0
- package/dist/types/dispatch-metrics.js.map +1 -0
- package/dist/types/logging.d.ts +11 -0
- package/dist/types/logging.d.ts.map +1 -0
- package/dist/types/logging.js +3 -0
- package/dist/types/logging.js.map +1 -0
- package/package.json +283 -0
|
@@ -0,0 +1,1833 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Phase 1 recon: drives a real browser through a user-defined flow while
|
|
4
|
+
* wiretapping every network response. Captures are written to
|
|
5
|
+
* /tmp/recon/graphql/<NNN>-<phase>-<operationName>.json — one file per call,
|
|
6
|
+
* diffable and greppable.
|
|
7
|
+
*
|
|
8
|
+
* Recovery model — each flow step runs through a 4-attempt self-healing cascade
|
|
9
|
+
* (act → observe+act → observe+act with ignoreSelectors → Anthropic-SDK rephrase),
|
|
10
|
+
* verified by "did the network counter advance OR did the URL change". On terminal
|
|
11
|
+
* cascade failure the step is dumped to /tmp/recon/step-failures/ and the script's
|
|
12
|
+
* main() loop attempts up to MAX_REPLANS=2 global replans, where Claude rewrites
|
|
13
|
+
* the remaining flow tail given the failure context. Bedrock-only deployments
|
|
14
|
+
* skip the LLM-rephrase attempt and the replan loop with a startup warn. See
|
|
15
|
+
* docs/playbook.md sections 1c–1e for the full design.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* pnpm tsx src/scripts/recon-browser.ts \
|
|
19
|
+
* --url https://example.com \
|
|
20
|
+
* --flow '["click the category filter", "open the first product"]'
|
|
21
|
+
*
|
|
22
|
+
* # Or load the flow from a committed file (preferred — makes recon re-runnable):
|
|
23
|
+
* pnpm tsx src/scripts/recon-browser.ts \
|
|
24
|
+
* --url https://example.com \
|
|
25
|
+
* --flow-file src/sites/my-site/recon-flow.json
|
|
26
|
+
*
|
|
27
|
+
* # Captures every network response — no URL-shape filtering. Use grep
|
|
28
|
+
* # against /tmp/recon/graphql/ if you only want specific endpoints.
|
|
29
|
+
* pnpm tsx src/scripts/recon-browser.ts --url https://example.com
|
|
30
|
+
*
|
|
31
|
+
* The script needs STEEL_API_KEY and either ANTHROPIC_API_KEY or USE_BEDROCK=true
|
|
32
|
+
* in the environment (same vars as the main server).
|
|
33
|
+
*
|
|
34
|
+
* Runtime: varies — ~20–40 min for a full flow (STEP_PAUSE_MS × N steps + LLM latency
|
|
35
|
+
* per act; healing and replans push the upper bound higher on flaky sites).
|
|
36
|
+
*/
|
|
37
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
38
|
+
exports.writeFixtureToTempFile = exports.windowHasTransitionBody = exports.windowHasAdvanceTransition = exports.waitForTransitionBody = exports.verifyFillReadback = exports.shouldVetoFallbackAdvance = exports.shouldSkipTechnique = exports.selectRadioGroupOption = exports.selectBodyExcerpt = exports.resetBillingErrorFlagForTests = exports.rephraseWithLLM = exports.renderUnfocusedObserve = exports.renderLeafInvalidFields = exports.probeStepBeforeAttempts = exports.probeLeafInvalidContainers = exports.pollEnumerate = exports.parseSelectStep = exports.parseRadioStep = exports.parseCaptureIndex = exports.pairInvalidWithErrors = exports.normalizeDateValue = exports.narrowInvalidFormControl = exports.logBillingErrorIfPresent = exports.latestCaptureIndex = exports.isWizardExitAction = exports.isUploadAffordanceLabel = exports.isSubmitRevealedInvalid = exports.isDomOnlyAdvanceVerified = exports.isAdvanceStep = exports.isAdvanceStalled = exports.hasBillingErrorBeenLogged = exports.formatValidationRejectedReason = exports.findRecentPageTransition = exports.findRecentBackendError = exports.fillHtml5DateTimeInput = exports.extractSubmitFailureEvidence = exports.extractGaEventEvidence = exports.describeAttemptEffectSignals = exports.chooseRequiredSelectOption = exports.capturesAfterIndex = exports.buildRadioIdXPath = void 0;
|
|
39
|
+
exports.detectRejectionInResponseBody = detectRejectionInResponseBody;
|
|
40
|
+
exports.findWizardRestartSignal = findWizardRestartSignal;
|
|
41
|
+
exports.isStructurallyBlocked = isStructurallyBlocked;
|
|
42
|
+
exports.summarizeReplanFailureKinds = summarizeReplanFailureKinds;
|
|
43
|
+
exports.isReplanCycle = isReplanCycle;
|
|
44
|
+
exports.filterCompletedFromReplan = filterCompletedFromReplan;
|
|
45
|
+
exports.isReplanReproposingFailedStep = isReplanReproposingFailedStep;
|
|
46
|
+
exports.renderStepWindow = renderStepWindow;
|
|
47
|
+
exports.countSlugPrefixMatches = countSlugPrefixMatches;
|
|
48
|
+
exports.dedupeConsecutiveIdentical = dedupeConsecutiveIdentical;
|
|
49
|
+
exports.denormalizeStep = denormalizeStep;
|
|
50
|
+
exports.persistReplannedFlow = persistReplannedFlow;
|
|
51
|
+
exports.readFailureDumpEvidence = readFailureDumpEvidence;
|
|
52
|
+
exports.replanRemainingFlow = replanRemainingFlow;
|
|
53
|
+
const node_crypto_1 = require("node:crypto");
|
|
54
|
+
const node_fs_1 = require("node:fs");
|
|
55
|
+
const node_path_1 = require("node:path");
|
|
56
|
+
const zod_1 = require("@anthropic-ai/sdk/helpers/zod");
|
|
57
|
+
const date_fns_1 = require("date-fns");
|
|
58
|
+
const v4_1 = require("zod/v4");
|
|
59
|
+
const errors_1 = require("../lib/errors");
|
|
60
|
+
const http_1 = require("../lib/http");
|
|
61
|
+
const anthropic_client_1 = require("../lib/llm/anthropic-client");
|
|
62
|
+
const error_messages_1 = require("../lib/llm/judges/error-messages");
|
|
63
|
+
const invalid_fields_1 = require("../lib/llm/judges/invalid-fields");
|
|
64
|
+
const verify_submit_1 = require("../lib/llm/judges/verify-submit");
|
|
65
|
+
const schemas_1 = require("../lib/llm/schemas");
|
|
66
|
+
const logging_1 = require("../lib/logging");
|
|
67
|
+
const call_capture_1 = require("../lib/telemetry/call-capture");
|
|
68
|
+
const call_types_1 = require("../lib/telemetry/call-types");
|
|
69
|
+
const telemetry_paths_1 = require("../lib/telemetry/telemetry-paths");
|
|
70
|
+
const errors_2 = require("../scraper/errors");
|
|
71
|
+
const flow_runner_1 = require("../scraper/flow-runner");
|
|
72
|
+
const session_1 = require("../scraper/session");
|
|
73
|
+
const stagehand_guard_1 = require("../scraper/stagehand-guard");
|
|
74
|
+
const judge_llm_batch_1 = require("../scripts/judge-llm-batch");
|
|
75
|
+
const recon_shared_1 = require("../scripts/recon-shared");
|
|
76
|
+
const client_1 = require("../testmail/client");
|
|
77
|
+
(0, http_1.configureHttpDispatcher)();
|
|
78
|
+
const logger = (0, logging_1.getScriptLogger)("recon-browser");
|
|
79
|
+
/**
|
|
80
|
+
* Identical-proposal threshold for {@link isReplanCycle}. Set to a value
|
|
81
|
+
* high enough that one repeat plus one retry under the page-state guard
|
|
82
|
+
* doesn't trip — only a sustained fixed point should. Below 3, legitimate
|
|
83
|
+
* "same proposal under slightly-different state" retries can produce false
|
|
84
|
+
* cycle detections.
|
|
85
|
+
*/
|
|
86
|
+
const REPLAN_CYCLE_THRESHOLD = 3;
|
|
87
|
+
/**
|
|
88
|
+
* Maximum bodyHtmlLength delta (in chars) at which two replan attempts are
|
|
89
|
+
* treated as targeting the same page state. Separates incidental DOM churn
|
|
90
|
+
* (framework attribute updates, focus rings, single-element re-renders;
|
|
91
|
+
* typically single-to-low-double-digit char deltas) from real page
|
|
92
|
+
* transitions (typically kilobyte-scale once new sections render).
|
|
93
|
+
*/
|
|
94
|
+
const HTML_STATIC_TOLERANCE = 100;
|
|
95
|
+
/**
|
|
96
|
+
* Thin CLI wrapper over {@link wireSignalCapture}: owns the on-disk capture
|
|
97
|
+
* layout (writes each capture — and its decoded-params sidecar — under
|
|
98
|
+
* `CAPTURES_DIR`) while delegating all in-memory capture bookkeeping to the
|
|
99
|
+
* shared flow-runner engine. Kept here so the persistence policy stays with
|
|
100
|
+
* the recon entry-point and `flow-runner.ts` remains filesystem-agnostic.
|
|
101
|
+
*/
|
|
102
|
+
function wireNetworkCapture(page, counter, signalCounter, recentCaptures, recentCaptureMeta, getCurrentPhase, getCurrentPageOrigin) {
|
|
103
|
+
return (0, flow_runner_1.wireSignalCapture)(page, {
|
|
104
|
+
counter,
|
|
105
|
+
signalCounter,
|
|
106
|
+
recentCaptures,
|
|
107
|
+
recentCaptureMeta,
|
|
108
|
+
getCurrentPhase,
|
|
109
|
+
getCurrentPageOrigin,
|
|
110
|
+
onCapture: (capture, filename) => {
|
|
111
|
+
// Defense in depth: a future edge case (NFS, symlink loop, full disk)
|
|
112
|
+
// shouldn't crash the whole recon run. Drop the capture, log loudly,
|
|
113
|
+
// and let the cascade continue. The capture is forensic-only — the
|
|
114
|
+
// happy-path doesn't read these files until something else fails.
|
|
115
|
+
try {
|
|
116
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(recon_shared_1.CAPTURES_DIR, filename), JSON.stringify(capture, null, 2));
|
|
117
|
+
if (capture.decodedParams !== null && capture.decodedParams !== capture.requestPostData) {
|
|
118
|
+
const decodedFilename = filename.replace(/\.json$/, ".decoded.json");
|
|
119
|
+
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(recon_shared_1.CAPTURES_DIR, decodedFilename), JSON.stringify(capture.decodedParams, null, 2));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
logger.warn(`capture-write skipped for ${capture.url.slice(0, 80)}: ${err instanceof Error ? err.message : String(err)}`);
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
const MAX_PROBE_REPLANS = 5;
|
|
129
|
+
/**
|
|
130
|
+
* Replans triggered by the full self-healing cascade exhausting all 4
|
|
131
|
+
* attempts (expensive: 4 attempts × backoff + LLM rephrase + observe
|
|
132
|
+
* calls). Counted separately from probe replans so cheap recoveries don't
|
|
133
|
+
* consume the budget reserved for expensive recoveries.
|
|
134
|
+
*/
|
|
135
|
+
const MAX_CASCADE_REPLANS = 5;
|
|
136
|
+
function detectRejectionInResponseBody(body) {
|
|
137
|
+
if (!body || typeof body !== "object")
|
|
138
|
+
return { rejected: false, reason: null };
|
|
139
|
+
const rec = body;
|
|
140
|
+
if (rec.not_qualified === true) {
|
|
141
|
+
return {
|
|
142
|
+
rejected: true,
|
|
143
|
+
reason: typeof rec.error === "string" ? rec.error : "not_qualified",
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
if (rec.rejected === true) {
|
|
147
|
+
return {
|
|
148
|
+
rejected: true,
|
|
149
|
+
reason: typeof rec.reason === "string" ? rec.reason : "rejected",
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (rec.qualified === false) {
|
|
153
|
+
return {
|
|
154
|
+
rejected: true,
|
|
155
|
+
reason: typeof rec.reason === "string" ? rec.reason : "qualified=false",
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (typeof rec.status === "string" && rec.status === "rejected") {
|
|
159
|
+
return {
|
|
160
|
+
rejected: true,
|
|
161
|
+
reason: typeof rec.reason === "string" ? rec.reason : "status=rejected",
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
return { rejected: false, reason: null };
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Normalize a capture's responseBody to the parsed-object shape that
|
|
168
|
+
* `detectRejectionInResponseBody` expects. Capture writer at
|
|
169
|
+
* recon-browser.ts:240 stores the field as either:
|
|
170
|
+
* - a parsed OBJECT (the common case — JSON.parse succeeded at capture time)
|
|
171
|
+
* - a STRING (fallback when JSON.parse failed — raw text body)
|
|
172
|
+
* - null (CDP body fetch failed; binary; unavailable)
|
|
173
|
+
*
|
|
174
|
+
* Previous version of this helper assumed string-only and returned null for
|
|
175
|
+
* the object case — causing Q1's audit to miss 100% of rejection envelopes
|
|
176
|
+
* because AppCast (and any JSON-serving ATS) lands in the object case.
|
|
177
|
+
* Verified 2026-06-15 against capture 122-...-a4ab5256.json:
|
|
178
|
+
* `jq '.responseBody | type'` returned `"object"`, the broken helper
|
|
179
|
+
* returned null, the audit declared "PASSED" while the body contained
|
|
180
|
+
* `{"not_qualified": true}`.
|
|
181
|
+
*
|
|
182
|
+
* Site-agnostic: every JSON-serving REST endpoint produces the object
|
|
183
|
+
* case; the string-as-JSON fallback covers rare cases where the capture
|
|
184
|
+
* writer stored a parseable JSON string for some reason.
|
|
185
|
+
*/
|
|
186
|
+
function normalizeResponseBodyForAudit(data) {
|
|
187
|
+
const body = data.responseBody;
|
|
188
|
+
if (body && typeof body === "object")
|
|
189
|
+
return body;
|
|
190
|
+
if (typeof body === "string") {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(body);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* End-of-run audit: scan ALL captures written by this run for any 2xx
|
|
202
|
+
* whose URL matches the flow's submitEndpointPattern AND whose response
|
|
203
|
+
* body does NOT indicate a rejection envelope. Returns true when NO clean
|
|
204
|
+
* 2xx match is found — i.e. the run completed without an accepted
|
|
205
|
+
* submission landing. Caller exits non-zero so silent-pass states surface
|
|
206
|
+
* as real failures.
|
|
207
|
+
*
|
|
208
|
+
* The audit is independent of the per-step verifier — the verifier may
|
|
209
|
+
* have accepted a DOM-fallback or URL-change signal as proof, but if
|
|
210
|
+
* the configured submit endpoint never returned 2xx, OR returned 2xx
|
|
211
|
+
* with a rejection envelope (e.g. AppCast's `not_qualified: true`), the
|
|
212
|
+
* application data didn't actually reach the employer's ATS.
|
|
213
|
+
*
|
|
214
|
+
* Site-agnostic: rejection detection is via `detectRejectionInResponseBody`
|
|
215
|
+
* which knows the union of common ATS rejection-envelope shapes
|
|
216
|
+
* (AppCast/Greenhouse/Lever/Workday). New ATSs extend the union.
|
|
217
|
+
*
|
|
218
|
+
* Pre-existing AppCast-specific equivalent: readJobOutcome in
|
|
219
|
+
* recon-replay-jobs.ts. This is the agnostic engine-side version using
|
|
220
|
+
* the flow file's declared pattern.
|
|
221
|
+
*/
|
|
222
|
+
function auditFinalSubmitMatch(params) {
|
|
223
|
+
const { ownBackendHostnames, capturesDir, logger } = params;
|
|
224
|
+
if (ownBackendHostnames.length === 0)
|
|
225
|
+
return { auditFailed: true, rejectionReason: null };
|
|
226
|
+
let entries;
|
|
227
|
+
try {
|
|
228
|
+
entries = (0, node_fs_1.readdirSync)(capturesDir);
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
logger.warn(`end-of-run audit: could not read captures dir ${capturesDir}: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
232
|
+
return { auditFailed: true, rejectionReason: null };
|
|
233
|
+
}
|
|
234
|
+
// Collect the most recent rejection reason in case we don't find a clean
|
|
235
|
+
// 2xx — surfaces "we DID submit but the server rejected" rather than
|
|
236
|
+
// "we never submitted" so the operator knows whether to fix the engine
|
|
237
|
+
// or fix the application content.
|
|
238
|
+
let lastRejectionReason = null;
|
|
239
|
+
for (const f of entries) {
|
|
240
|
+
try {
|
|
241
|
+
const data = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(capturesDir, f), "utf8"));
|
|
242
|
+
if (typeof data.url === "string" &&
|
|
243
|
+
typeof data.status === "number" &&
|
|
244
|
+
data.status >= 200 &&
|
|
245
|
+
data.status < 300) {
|
|
246
|
+
let hostname;
|
|
247
|
+
try {
|
|
248
|
+
hostname = new URL(data.url).hostname;
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (!ownBackendHostnames.includes(hostname))
|
|
254
|
+
continue;
|
|
255
|
+
const parsedBody = normalizeResponseBodyForAudit(data);
|
|
256
|
+
const rejection = detectRejectionInResponseBody(parsedBody);
|
|
257
|
+
if (rejection.rejected) {
|
|
258
|
+
lastRejectionReason = rejection.reason;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
return { auditFailed: false, rejectionReason: null };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// Ignore unparseable capture files — they're either malformed or
|
|
266
|
+
// a different shape (e.g. resource captures that don't have status/url).
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { auditFailed: true, rejectionReason: lastRejectionReason };
|
|
270
|
+
}
|
|
271
|
+
function findWizardRestartSignal(params) {
|
|
272
|
+
const { preIdx, restartSignalUrlPatterns } = params;
|
|
273
|
+
if (restartSignalUrlPatterns.length === 0)
|
|
274
|
+
return null;
|
|
275
|
+
const capturesDir = params.capturesDir ?? recon_shared_1.CAPTURES_DIR;
|
|
276
|
+
for (const filename of (0, flow_runner_1.capturesAfterIndex)(preIdx, capturesDir)) {
|
|
277
|
+
let url;
|
|
278
|
+
try {
|
|
279
|
+
const raw = (0, node_fs_1.readFileSync)((0, node_path_1.join)(capturesDir, filename), "utf8");
|
|
280
|
+
const capture = JSON.parse(raw);
|
|
281
|
+
if (typeof capture.url !== "string")
|
|
282
|
+
continue;
|
|
283
|
+
url = capture.url;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
for (const pattern of restartSignalUrlPatterns) {
|
|
289
|
+
if (url.includes(pattern))
|
|
290
|
+
return url;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
function isStructurallyBlocked(attempts) {
|
|
296
|
+
if (attempts.length === 0)
|
|
297
|
+
return false;
|
|
298
|
+
return attempts.every((a) => a.triedSelectors.length === 0 && a.verifiedBy === null);
|
|
299
|
+
}
|
|
300
|
+
function summarizeReplanFailureKinds(params) {
|
|
301
|
+
const { callsNdjsonPath, callType, tailCount = 10 } = params;
|
|
302
|
+
let ndjsonContent;
|
|
303
|
+
try {
|
|
304
|
+
ndjsonContent = (0, node_fs_1.readFileSync)(callsNdjsonPath, "utf8");
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return "";
|
|
308
|
+
}
|
|
309
|
+
const matching = (0, judge_llm_batch_1.filterByCallType)((0, judge_llm_batch_1.parseSamples)(ndjsonContent), callType);
|
|
310
|
+
const failures = matching.filter((s) => s.success === false).slice(-tailCount);
|
|
311
|
+
if (failures.length === 0)
|
|
312
|
+
return "";
|
|
313
|
+
const counts = {};
|
|
314
|
+
for (const s of failures) {
|
|
315
|
+
const kind = s.failureKind ?? "unknown";
|
|
316
|
+
counts[kind] = (counts[kind] ?? 0) + 1;
|
|
317
|
+
}
|
|
318
|
+
const parts = Object.entries(counts)
|
|
319
|
+
.sort((a, b) => b[1] - a[1])
|
|
320
|
+
.map(([kind, n]) => `${n}× ${kind}`);
|
|
321
|
+
return `${failures.length} recent ${callType} failure(s): ${parts.join(", ")}`;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Detect a true replan cycle: the same multi-step instruction sequence
|
|
325
|
+
* proposed REPLAN_CYCLE_THRESHOLD times in a row under page state that
|
|
326
|
+
* hasn't materially advanced. The page-state guard (URL equality + bounded
|
|
327
|
+
* htmlLength delta) is essential — a re-proposal under genuinely different
|
|
328
|
+
* page state is a valid retry, not a cycle. Without the guard we'd block
|
|
329
|
+
* legitimate "the page advanced; the same step now works" recoveries.
|
|
330
|
+
*/
|
|
331
|
+
function isReplanCycle(priorReplans, newSteps, currentState) {
|
|
332
|
+
if (priorReplans.length < REPLAN_CYCLE_THRESHOLD)
|
|
333
|
+
return false;
|
|
334
|
+
const newSig = newSteps.map((s) => s.instruction).join("|||");
|
|
335
|
+
let identicalCount = 0;
|
|
336
|
+
for (const prior of priorReplans) {
|
|
337
|
+
const priorSig = prior.replanSteps.map((s) => s.instruction).join("|||");
|
|
338
|
+
if (priorSig !== newSig)
|
|
339
|
+
continue;
|
|
340
|
+
const urlSame = prior.pageState.url === currentState.url;
|
|
341
|
+
const htmlStatic = Math.abs(prior.pageState.htmlLength - currentState.htmlLength) < HTML_STATIC_TOLERANCE;
|
|
342
|
+
if (urlSame && htmlStatic)
|
|
343
|
+
identicalCount++;
|
|
344
|
+
}
|
|
345
|
+
return identicalCount >= REPLAN_CYCLE_THRESHOLD;
|
|
346
|
+
}
|
|
347
|
+
const RECON_FLOW_SCHEMA = v4_1.z.array(schemas_1.RECON_FLOW_STEP_SCHEMA).min(1);
|
|
348
|
+
/**
|
|
349
|
+
* Two on-disk shapes are accepted:
|
|
350
|
+
*
|
|
351
|
+
* 1. Legacy bare array — every existing site flow file uses this.
|
|
352
|
+
* 2. Object form — adds optional `submitEndpointPattern` regex and
|
|
353
|
+
* optional `submittedStateSelectors` array.
|
|
354
|
+
* - `submitEndpointPattern`: the final step's verifier additionally
|
|
355
|
+
* requires at least one same-origin capture in its mutation window
|
|
356
|
+
* whose URL matches the pattern. Without that match `verified=false`,
|
|
357
|
+
* even if the click produced a DOM mutation — which is the only way
|
|
358
|
+
* the self-healing cascade can detect "the click fired tracking but
|
|
359
|
+
* not the real submit XHR."
|
|
360
|
+
* - `submittedStateSelectors`: DOM-level fallback when the submit POST
|
|
361
|
+
* lands outside the per-attempt capture window. SPAs (e.g. AppCast)
|
|
362
|
+
* can swap the form for a thank-you component (`<uapp-universal-
|
|
363
|
+
* submitted-page>`) faster than the network capture pipeline records
|
|
364
|
+
* the POST. If any selector in this list matches via
|
|
365
|
+
* document.querySelector at verifier time, the submit is treated as
|
|
366
|
+
* verified-by-DOM regardless of the network capture.
|
|
367
|
+
*
|
|
368
|
+
* Both forms route through `parseReconFlow` into a shared internal record.
|
|
369
|
+
*/
|
|
370
|
+
const RECON_FLOW_FILE_SCHEMA = v4_1.z.union([
|
|
371
|
+
RECON_FLOW_SCHEMA,
|
|
372
|
+
v4_1.z.object({
|
|
373
|
+
steps: RECON_FLOW_SCHEMA,
|
|
374
|
+
submitEndpointPattern: v4_1.z.string().min(1).optional(),
|
|
375
|
+
submittedStateSelectors: v4_1.z.array(v4_1.z.string().min(1)).optional(),
|
|
376
|
+
/**
|
|
377
|
+
* When true, the final-step verifier accepts ONLY a `submitEndpointPattern`
|
|
378
|
+
* network capture as proof of submission — `submittedStateSelectors` DOM
|
|
379
|
+
* matches become a tiebreaker, not a standalone fallback. Use this for
|
|
380
|
+
* SPAs where the success-route component renders optimistically even if
|
|
381
|
+
* the server-side submit was blocked (e.g. by a bot-management WAF), so
|
|
382
|
+
* the DOM marker would otherwise false-positive a failed submission.
|
|
383
|
+
* Default false preserves the lenient pre-existing behavior for sites
|
|
384
|
+
* that genuinely rely on DOM-only verification.
|
|
385
|
+
*/
|
|
386
|
+
requireSubmitEndpointMatch: v4_1.z.boolean().optional(),
|
|
387
|
+
/**
|
|
388
|
+
* URL path fragments that indicate a successful submit transition.
|
|
389
|
+
* Surfaced to the Haiku verifySubmit judge as evidence — when the page
|
|
390
|
+
* URL after the click contains any of these, that's one of the strong
|
|
391
|
+
* signals required for verified=true. Examples: ["/applied",
|
|
392
|
+
* "/applyboard/applied", "/confirmation", "/thank-you"]. Site-specific
|
|
393
|
+
* since URL conventions vary across AppCast tenants and ClearCompany
|
|
394
|
+
* tenants.
|
|
395
|
+
*/
|
|
396
|
+
successUrlFragments: v4_1.z.array(v4_1.z.string().min(1)).optional(),
|
|
397
|
+
/**
|
|
398
|
+
* Page-title substrings that indicate a successful submit. Same role as
|
|
399
|
+
* successUrlFragments — strong signal for the Haiku verifySubmit judge.
|
|
400
|
+
* Examples: ["Thank you", "Application submitted", "Submitted
|
|
401
|
+
* successfully", "Confirmation"]. English-only is fine; the judge can
|
|
402
|
+
* still reason about non-English variants from the DOM signals.
|
|
403
|
+
*/
|
|
404
|
+
successPageTitleHints: v4_1.z.array(v4_1.z.string().min(1)).optional(),
|
|
405
|
+
/**
|
|
406
|
+
* Hostnames considered "the site's own backend." The Haiku
|
|
407
|
+
* verifySubmit judge treats a 2xx POST/PUT/DELETE to one of these
|
|
408
|
+
* hostnames as a corroborating network signal — anything else (e.g.
|
|
409
|
+
* analytics, third-party trackers, CDNs) is ignored. Without this
|
|
410
|
+
* list the judge falls back to the URL alone, which is weaker.
|
|
411
|
+
* Examples: ["apply.appcast.io"], ["careers.clearcompany.com",
|
|
412
|
+
* "<tenant>.clearcompany.com"].
|
|
413
|
+
*/
|
|
414
|
+
ownBackendHostnames: v4_1.z.array(v4_1.z.string().min(1)).optional(),
|
|
415
|
+
/**
|
|
416
|
+
* Optional site-specific class-name prefixes that wrap form/error
|
|
417
|
+
* state. The Haiku invalid-fields judge uses these as additional
|
|
418
|
+
* structural evidence beyond framework-conventional patterns
|
|
419
|
+
* (ng-invalid, aria-invalid, data-invalid). Examples for AppCast:
|
|
420
|
+
* ["uapp-", "app-"]. When omitted, the judge falls back to framework
|
|
421
|
+
* conventions alone.
|
|
422
|
+
*/
|
|
423
|
+
knownErrorClassPrefixes: v4_1.z.array(v4_1.z.string().min(1)).optional(),
|
|
424
|
+
/**
|
|
425
|
+
* Optional site-specific labels for wizard-exit controls (save-and-exit,
|
|
426
|
+
* cancel, restart) — appended to the engine's built-in
|
|
427
|
+
* WIZARD_EXIT_ACTION_LABELS. An "advance / click Next" step whose resolved
|
|
428
|
+
* action description matches one of these is rejected so the cascade never
|
|
429
|
+
* clicks a destructive control. Only add unambiguously-destructive labels.
|
|
430
|
+
*/
|
|
431
|
+
wizardExitButtonLabels: v4_1.z.array(v4_1.z.string().min(1)).optional(),
|
|
432
|
+
/**
|
|
433
|
+
* Optional URL substrings that signal a multi-page wizard RESTART / backward
|
|
434
|
+
* navigation (e.g. `init-apply`, `application_canceled=true`). When any
|
|
435
|
+
* recent capture URL matches after a step, the run aborts with a
|
|
436
|
+
* `wizard-regression` error instead of running later steps against the reset
|
|
437
|
+
* page or replanning against the restarted wizard.
|
|
438
|
+
*/
|
|
439
|
+
restartSignalUrlPatterns: v4_1.z.array(v4_1.z.string().min(1)).optional(),
|
|
440
|
+
/**
|
|
441
|
+
* Optional regex matched against the REQUEST BODY of same-window network
|
|
442
|
+
* captures to prove an interior (non-submit) "advance"/"Next" step actually
|
|
443
|
+
* moved the wizard forward — not merely fired some other same-origin POST.
|
|
444
|
+
* Needed for SPAs where advance and non-advance mutations share one endpoint
|
|
445
|
+
* URL (e.g. Talemetry's `/gq`: a real page advance is a `TransitionWorklet`
|
|
446
|
+
* mutation, while `EditQuestionItem` is just a field edit — byte-identical
|
|
447
|
+
* URLs, only the body differs). When set, an advance step's `networkFired`
|
|
448
|
+
* signal is trusted ONLY if a capture body in the step's window matches this
|
|
449
|
+
* pattern. Opt-in: sites that don't set it keep today's behavior (any
|
|
450
|
+
* network/url/dom signal verifies), so no cross-site regression.
|
|
451
|
+
*/
|
|
452
|
+
advanceTransitionBodyPattern: v4_1.z.string().min(1).optional(),
|
|
453
|
+
}),
|
|
454
|
+
]);
|
|
455
|
+
function normalizeFlow(steps) {
|
|
456
|
+
return steps.map((s) => typeof s === "string"
|
|
457
|
+
? { instruction: s, optional: false, upload: false, origin: "original" }
|
|
458
|
+
: {
|
|
459
|
+
instruction: s.step,
|
|
460
|
+
optional: s.optional,
|
|
461
|
+
upload: s.upload,
|
|
462
|
+
submitStep: s.submitStep,
|
|
463
|
+
payloadField: s.payloadField,
|
|
464
|
+
payloadFieldNone: s.payloadFieldNone,
|
|
465
|
+
origin: "original",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Substitute `${VAR_NAME}` tokens in each step's instruction with
|
|
470
|
+
* `process.env[VAR_NAME]`. Unset variables stay literal so the missing
|
|
471
|
+
* allocation surfaces in Stagehand's act() instruction logs (the literal
|
|
472
|
+
* `${VAR_NAME}` token appears verbatim in the rendered instruction)
|
|
473
|
+
* rather than silently filling with an empty string. Only env keys
|
|
474
|
+
* matching `[A-Z_][A-Z0-9_]*` are substituted — anything else is left
|
|
475
|
+
* as-is, including the JS template literal syntax the flow file may
|
|
476
|
+
* legitimately use elsewhere.
|
|
477
|
+
*/
|
|
478
|
+
function substituteFlowEnvVars(steps) {
|
|
479
|
+
const pattern = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
|
|
480
|
+
return steps.map((s) => ({
|
|
481
|
+
...s,
|
|
482
|
+
instruction: s.instruction.replace(pattern, (match, name) => {
|
|
483
|
+
const value = process.env[name];
|
|
484
|
+
return value === undefined ? match : value;
|
|
485
|
+
}),
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Inverse of normalizeFlow: maps an internal `NormalizedStep` back to the
|
|
490
|
+
* on-disk union shape. Bare string for the common case (required,
|
|
491
|
+
* non-upload); object with only the truthy flags otherwise. Round-trip is
|
|
492
|
+
* lossless against `RECON_FLOW_SCHEMA` for any value the parser accepted.
|
|
493
|
+
*/
|
|
494
|
+
function denormalizeStep(step) {
|
|
495
|
+
const hasSplicerHint = step.payloadField !== undefined || step.payloadFieldNone === true;
|
|
496
|
+
if (!step.optional && !step.upload && !step.submitStep && !hasSplicerHint) {
|
|
497
|
+
return step.instruction;
|
|
498
|
+
}
|
|
499
|
+
const out = {
|
|
500
|
+
step: step.instruction,
|
|
501
|
+
};
|
|
502
|
+
if (step.optional)
|
|
503
|
+
out.optional = true;
|
|
504
|
+
if (step.upload)
|
|
505
|
+
out.upload = true;
|
|
506
|
+
if (step.submitStep)
|
|
507
|
+
out.submitStep = true;
|
|
508
|
+
if (step.payloadField !== undefined)
|
|
509
|
+
out.payloadField = step.payloadField;
|
|
510
|
+
if (step.payloadFieldNone === true)
|
|
511
|
+
out.payloadFieldNone = true;
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Collapse any consecutive run of structurally-identical denormalized
|
|
516
|
+
* steps into a single entry. Used by `persistReplannedFlow` before write-
|
|
517
|
+
* back so cumulative-replan noise (each cascade-exhausted replan adds the
|
|
518
|
+
* same recovery bridge to the tail; after 5 replans the persisted file
|
|
519
|
+
* carries 5 stacked copies) doesn't pollute the on-disk flow.
|
|
520
|
+
*
|
|
521
|
+
* Comparison is structural via JSON-stringify equality:
|
|
522
|
+
* - Two strings with the same value collapse.
|
|
523
|
+
* - Two objects with the same {step, optional, upload} collapse.
|
|
524
|
+
* - A string and an object with the same instruction do NOT collapse — they
|
|
525
|
+
* are semantically different (bare = required, object = could be optional
|
|
526
|
+
* or upload).
|
|
527
|
+
*
|
|
528
|
+
* Only CONSECUTIVE duplicates collapse. A non-adjacent repeat is preserved
|
|
529
|
+
* since flow authors sometimes intentionally re-fill a field after a
|
|
530
|
+
* downstream interaction (e.g. re-fill First Name after the resume upload
|
|
531
|
+
* triggers an Angular re-render). Adjacent duplicates are almost always
|
|
532
|
+
* accumulation noise from successive replans converging on the same idea.
|
|
533
|
+
*/
|
|
534
|
+
function dedupeConsecutiveIdentical(items) {
|
|
535
|
+
if (items.length < 2)
|
|
536
|
+
return [...items];
|
|
537
|
+
const out = [items[0]];
|
|
538
|
+
for (let i = 1; i < items.length; i++) {
|
|
539
|
+
const prev = JSON.stringify(out[out.length - 1]);
|
|
540
|
+
const curr = JSON.stringify(items[i]);
|
|
541
|
+
if (prev !== curr)
|
|
542
|
+
out.push(items[i]);
|
|
543
|
+
}
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Resume-from-failure filter for replan output. The global replanner should emit
|
|
548
|
+
* only a recovery BRIDGE from the failure point (the original remaining tail is
|
|
549
|
+
* re-appended by the driver), but it sometimes re-emits already-completed steps
|
|
550
|
+
* — re-filling name/email/etc. after a later step fails — which inflates the
|
|
551
|
+
* plan and wastes wall-clock + replan budget re-doing done work. Drop any bridge
|
|
552
|
+
* step whose instruction matches a completed step, EXCEPT a re-emission of the
|
|
553
|
+
* failed step itself (a legitimate no-op bridge the replan prompt allows).
|
|
554
|
+
*/
|
|
555
|
+
function filterCompletedFromReplan(newSteps, completedSteps, failedStep) {
|
|
556
|
+
const completed = new Set(completedSteps);
|
|
557
|
+
return newSteps.filter((s) => s.instruction === failedStep || !completed.has(s.instruction));
|
|
558
|
+
}
|
|
559
|
+
/** Whitespace/case-insensitive normalization for comparing step instructions. */
|
|
560
|
+
function normalizeInstruction(instruction) {
|
|
561
|
+
return instruction.replace(/\s+/g, " ").trim().toLowerCase();
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Detect a replan that only re-proposes the step that JUST terminally failed —
|
|
565
|
+
* i.e. after {@link filterCompletedFromReplan} the sole surviving bridge step is
|
|
566
|
+
* byte-identical (whitespace/case-normalized) to the failed instruction. The
|
|
567
|
+
* replan prompt allows a no-op re-emission of the failed step, but a bridge that
|
|
568
|
+
* is NOTHING but the failed step is a guaranteed re-fail: resuming re-runs the
|
|
569
|
+
* whole 5-attempt cascade on the exact click that just exhausted it (~1m40s
|
|
570
|
+
* wasted) before the cycle detector — which needs REPLAN_CYCLE_THRESHOLD repeats
|
|
571
|
+
* under a static page — even engages. This catches it on the FIRST occurrence.
|
|
572
|
+
* Pure; returns false whenever the bridge adds any genuinely new step.
|
|
573
|
+
*/
|
|
574
|
+
function isReplanReproposingFailedStep(newSteps, failedStep) {
|
|
575
|
+
if (newSteps.length === 0)
|
|
576
|
+
return false;
|
|
577
|
+
const failedNorm = normalizeInstruction(failedStep);
|
|
578
|
+
return newSteps.every((s) => normalizeInstruction(s.instruction) === failedNorm);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Write-back at the end of a successful recon: back up the original file
|
|
582
|
+
* bytes verbatim (so a subtle denormalization bug can never lose the user's
|
|
583
|
+
* hand-authored flow), then write the in-memory plan back out and log a
|
|
584
|
+
* summary of each replan event. No-op when `--no-save-replan` was passed or
|
|
585
|
+
* when no replans fired.
|
|
586
|
+
*
|
|
587
|
+
* Backup path encodes the timestamp + .bak so accumulated backups are easy
|
|
588
|
+
* to sort and prune. Uses synchronous writes — the recon is single-purpose
|
|
589
|
+
* and we want the failure mode "write the bytes, then exit" rather than
|
|
590
|
+
* "exit with the write half-flushed."
|
|
591
|
+
*/
|
|
592
|
+
function persistReplannedFlow(params) {
|
|
593
|
+
const { flowFile, finalPlan, replanEvents, logger, originalShape = "array", submitEndpointPattern = null, submittedStateSelectors = [], requireSubmitEndpointMatch = false, successUrlFragments = [], successPageTitleHints = [], ownBackendHostnames = [], knownErrorClassPrefixes = [], } = params;
|
|
594
|
+
// Timestamp format chosen to be filesystem-safe (no colons or dots that
|
|
595
|
+
// break common tooling on macOS/Windows).
|
|
596
|
+
const timestamp = (0, date_fns_1.format)(new Date(), "yyyy-MM-dd'T'HH-mm-ss");
|
|
597
|
+
const backupPath = flowFile.replace(/\.json$/, `.${timestamp}.bak.json`);
|
|
598
|
+
// Read ORIGINAL bytes from disk (not a re-serialization of the parsed
|
|
599
|
+
// structure) so the backup is byte-identical to whatever the user had.
|
|
600
|
+
let originalBytes;
|
|
601
|
+
try {
|
|
602
|
+
originalBytes = (0, node_fs_1.readFileSync)(flowFile);
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
logger.error(`persistReplannedFlow: failed to read original ${flowFile}: ${(0, errors_1.toErrorMessage)(err)} — skipping write-back (${replanEvents.length} replan event(s) left in memory)`);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
(0, node_fs_1.writeFileSync)(backupPath, originalBytes);
|
|
609
|
+
// Coerce replan-origin steps to optional before persistence: when a job
|
|
610
|
+
// cascade-exhausts on an employer-specific field, the replan emits a
|
|
611
|
+
// recovery bridge tied to that employer. Persisting it as required would
|
|
612
|
+
// cascade-exhaust every subsequent run on a different employer trying to
|
|
613
|
+
// fill a question that doesn't exist. Optional means the probe-absent-
|
|
614
|
+
// skip path handles employers where the question isn't on the form;
|
|
615
|
+
// cascade still fires for the original employer when the persisted flow
|
|
616
|
+
// is replayed. Required to keep cross-employer sweeps from regressing
|
|
617
|
+
// across runs.
|
|
618
|
+
const denormalizedSteps = finalPlan.map((step) => denormalizeStep(step.origin === "replan" && !step.optional ? { ...step, optional: true } : step));
|
|
619
|
+
// Cumulative-replan dedupe: each cascade-exhausted replan appends a
|
|
620
|
+
// recovery bridge to the tail; after several replans the persisted flow
|
|
621
|
+
// would carry stacked copies of the same bridge. Collapse them so the
|
|
622
|
+
// user reviewing the diff sees only the distinct LLM-discovered steps.
|
|
623
|
+
const dedupedSteps = dedupeConsecutiveIdentical(denormalizedSteps);
|
|
624
|
+
// 2-space indent + trailing newline matches the existing on-disk style
|
|
625
|
+
// (verified against site recon-flow.json files).
|
|
626
|
+
const payload = originalShape === "object"
|
|
627
|
+
? {
|
|
628
|
+
steps: dedupedSteps,
|
|
629
|
+
...(submitEndpointPattern !== null ? { submitEndpointPattern } : {}),
|
|
630
|
+
...(submittedStateSelectors.length > 0 ? { submittedStateSelectors } : {}),
|
|
631
|
+
...(requireSubmitEndpointMatch ? { requireSubmitEndpointMatch } : {}),
|
|
632
|
+
...(successUrlFragments.length > 0 ? { successUrlFragments } : {}),
|
|
633
|
+
...(successPageTitleHints.length > 0 ? { successPageTitleHints } : {}),
|
|
634
|
+
...(ownBackendHostnames.length > 0 ? { ownBackendHostnames } : {}),
|
|
635
|
+
...(knownErrorClassPrefixes.length > 0 ? { knownErrorClassPrefixes } : {}),
|
|
636
|
+
}
|
|
637
|
+
: dedupedSteps;
|
|
638
|
+
(0, node_fs_1.writeFileSync)(flowFile, `${JSON.stringify(payload, null, 2)}\n`);
|
|
639
|
+
if (dedupedSteps.length !== denormalizedSteps.length) {
|
|
640
|
+
logger.info(`dedupe collapsed ${denormalizedSteps.length - dedupedSteps.length} consecutive identical step(s) before write-back`);
|
|
641
|
+
}
|
|
642
|
+
// Summary log block — emit each line through the Pino logger so the dev
|
|
643
|
+
// transport renders it nicely and the prod JSON output stays structured.
|
|
644
|
+
// Lines call out the failed instruction string verbatim + the bridge that
|
|
645
|
+
// took its place so a reviewer can diff intent without opening both files.
|
|
646
|
+
logger.info("── flow.json updated ──────────────────────────────────────");
|
|
647
|
+
logger.info(`original backed up: ${backupPath}`);
|
|
648
|
+
logger.info(`replacements (${replanEvents.length}):`);
|
|
649
|
+
for (const ev of replanEvents) {
|
|
650
|
+
logger.info(` - replan #${ev.replanIndex} (${ev.cause}) at step ${ev.indexAtFailure + 1} @ ${ev.timestamp}`);
|
|
651
|
+
logger.info(` failed: ${ev.failedInstruction}`);
|
|
652
|
+
logger.info(` replaced with ${ev.replanSteps.length} step(s):`);
|
|
653
|
+
for (const s of ev.replanSteps) {
|
|
654
|
+
logger.info(` • ${s.instruction}${s.optional ? " (optional)" : ""}${s.upload ? " (upload)" : ""}`);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
logger.info(`run \`diff ${backupPath} ${flowFile}\` to inspect.`);
|
|
658
|
+
logger.info("───────────────────────────────────────────────────────────");
|
|
659
|
+
}
|
|
660
|
+
function renderStepWindow(steps, options = {}) {
|
|
661
|
+
const { head = 0, tail = 10 } = options;
|
|
662
|
+
if (steps.length === 0)
|
|
663
|
+
return "(none)";
|
|
664
|
+
const headSteps = head > 0 ? steps.slice(0, head) : [];
|
|
665
|
+
const tailSteps = tail > 0 ? steps.slice(-tail) : [];
|
|
666
|
+
const elided = steps.length - headSteps.length - tailSteps.length;
|
|
667
|
+
const lines = [];
|
|
668
|
+
for (const [i, s] of headSteps.entries()) {
|
|
669
|
+
lines.push(`${i + 1}. ${s}`);
|
|
670
|
+
}
|
|
671
|
+
if (elided > 0) {
|
|
672
|
+
lines.push(`... (${elided} steps elided for prompt budget) ...`);
|
|
673
|
+
}
|
|
674
|
+
const tailStart = steps.length - tailSteps.length + 1;
|
|
675
|
+
for (const [i, s] of tailSteps.entries()) {
|
|
676
|
+
lines.push(`${tailStart + i}. ${s}`);
|
|
677
|
+
}
|
|
678
|
+
return lines.join("\n");
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Detect a "false-premise loop" — the current cascade-exhausted step
|
|
682
|
+
* shares a slug-prefix with at least N prior replans' failed steps,
|
|
683
|
+
* suggesting the flow's element model for THIS widget family doesn't
|
|
684
|
+
* match the actual DOM. Slug derivation mirrors the `currentPhase`
|
|
685
|
+
* pattern at recon-browser.ts:5001 (24-char alphanumeric prefix of
|
|
686
|
+
* normalized instruction). When the threshold is exceeded, callers
|
|
687
|
+
* inject an ELEMENT MODEL CHECK section into the replan prompt so the
|
|
688
|
+
* LLM is nudged to reconsider whether the failed-step's element
|
|
689
|
+
* description matches anything on the page.
|
|
690
|
+
*
|
|
691
|
+
* Research grounding: Reflexion (Shinn et al., 2023) demonstrates +22%
|
|
692
|
+
* improvement on AlfWorld via verbal-reinforcement feedback on prior
|
|
693
|
+
* failures. Our existing replan prompt's PRIOR REPLAN HISTORY section
|
|
694
|
+
* is Reflexion-style; this helper adds a quantified meta-signal when
|
|
695
|
+
* the same element pattern fails N+ times in a row.
|
|
696
|
+
*/
|
|
697
|
+
function countSlugPrefixMatches(currentFailedStep, priorReplans) {
|
|
698
|
+
const slugOf = (s) => s
|
|
699
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
700
|
+
.toLowerCase()
|
|
701
|
+
.replace(/^-|-$/g, "")
|
|
702
|
+
.slice(0, 24);
|
|
703
|
+
const currentSlug = slugOf(currentFailedStep);
|
|
704
|
+
if (currentSlug.length === 0)
|
|
705
|
+
return 0;
|
|
706
|
+
let matches = 0;
|
|
707
|
+
for (const ev of priorReplans) {
|
|
708
|
+
if (slugOf(ev.failedInstruction) === currentSlug)
|
|
709
|
+
matches++;
|
|
710
|
+
}
|
|
711
|
+
return matches;
|
|
712
|
+
}
|
|
713
|
+
async function readFailureDumpEvidence(failureDumpPath, options) {
|
|
714
|
+
try {
|
|
715
|
+
const dump = JSON.parse((0, node_fs_1.readFileSync)(failureDumpPath, "utf8"));
|
|
716
|
+
const rawBody = dump.bodyOuterHtml;
|
|
717
|
+
const bodyExcerpt = typeof rawBody === "string" ? (0, flow_runner_1.selectBodyExcerpt)(rawBody) : "";
|
|
718
|
+
const client = options?.client ?? null;
|
|
719
|
+
const knownErrorClassPrefixes = options?.knownErrorClassPrefixes ?? [];
|
|
720
|
+
const captureFn = options?.captureFn;
|
|
721
|
+
const page = options?.page;
|
|
722
|
+
// Deterministic-first when the live page is available: probe the LIVE
|
|
723
|
+
// DOM for leaf invalid containers via CSS `:has()`. Falls back to the
|
|
724
|
+
// dump-based Haiku judge only when the live probe returns empty or
|
|
725
|
+
// when no page is in scope (tests). See `probeLeafInvalidContainers`
|
|
726
|
+
// docs for the rationale.
|
|
727
|
+
const leafFields = page ? await (0, flow_runner_1.probeLeafInvalidContainers)(page) : [];
|
|
728
|
+
const [unfocusedList, invalidVerdict, errorVerdict] = await Promise.all([
|
|
729
|
+
(0, flow_runner_1.renderUnfocusedObserve)(dump.unfocusedObserve ?? [], { client, captureFn }),
|
|
730
|
+
leafFields.length > 0
|
|
731
|
+
? Promise.resolve(null)
|
|
732
|
+
: (0, invalid_fields_1.judgeInvalidFieldsWithLLM)({
|
|
733
|
+
client,
|
|
734
|
+
input: { bodyHtmlExcerpt: bodyExcerpt, knownErrorClassPrefixes },
|
|
735
|
+
captureFn,
|
|
736
|
+
}),
|
|
737
|
+
(0, error_messages_1.judgeErrorMessagesWithLLM)({
|
|
738
|
+
client,
|
|
739
|
+
input: { bodyHtmlExcerpt: bodyExcerpt },
|
|
740
|
+
captureFn,
|
|
741
|
+
}),
|
|
742
|
+
]);
|
|
743
|
+
const invalidFieldList = leafFields.length > 0
|
|
744
|
+
? (0, flow_runner_1.renderLeafInvalidFields)(leafFields)
|
|
745
|
+
: (() => {
|
|
746
|
+
const invalidLines = invalidVerdict?.fields.map((f) => {
|
|
747
|
+
const label = f.label ?? "(unlabeled)";
|
|
748
|
+
return `${label} [${f.framework} ${f.markerKind}] ${f.containerXpath}`;
|
|
749
|
+
}) ?? [];
|
|
750
|
+
return invalidLines.map((e, i) => `${i + 1}. ${e}`).join("\n");
|
|
751
|
+
})();
|
|
752
|
+
const errorLines = errorVerdict?.messages.map((m) => {
|
|
753
|
+
const field = m.fieldHint ? `[${m.fieldHint}] ` : "";
|
|
754
|
+
return `${field}${m.severity}: ${m.text}`;
|
|
755
|
+
}) ?? [];
|
|
756
|
+
const errorTextList = errorLines.map((e, i) => `${i + 1}. ${e}`).join("\n");
|
|
757
|
+
// Trailing slice of attempt error messages. The dump's per-attempt
|
|
758
|
+
// errorMessage carries the verifier's structured reason (e.g.
|
|
759
|
+
// `submit-endpoint-not-matched: pattern …`), which currently only the
|
|
760
|
+
// rephrase prompt sees. Surfacing them here gives the replan LLM the
|
|
761
|
+
// same context.
|
|
762
|
+
const recentFailureReasons = (dump.attempts ?? [])
|
|
763
|
+
.map((a) => (typeof a.errorMessage === "string" ? a.errorMessage.trim() : ""))
|
|
764
|
+
.filter((r) => r.length > 0)
|
|
765
|
+
.slice(-5);
|
|
766
|
+
return { bodyExcerpt, unfocusedList, invalidFieldList, errorTextList, recentFailureReasons };
|
|
767
|
+
}
|
|
768
|
+
catch {
|
|
769
|
+
// Swallowed by design: a missing dump must not fail the replan.
|
|
770
|
+
return {
|
|
771
|
+
bodyExcerpt: "",
|
|
772
|
+
unfocusedList: "",
|
|
773
|
+
invalidFieldList: "",
|
|
774
|
+
errorTextList: "",
|
|
775
|
+
recentFailureReasons: [],
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Global fallback after a step terminally fails all healing attempts: rewrites
|
|
781
|
+
* only the un-run tail of the flow so already-verified steps are not disturbed.
|
|
782
|
+
* Exported so tests can inject a fake capture sink without a live browser.
|
|
783
|
+
*/
|
|
784
|
+
async function replanRemainingFlow(params) {
|
|
785
|
+
const { client, originalFlow, completedSteps, failedStep, remainingSteps, failureDumpPath, page, stagehand, captureFn = call_capture_1.captureLlmCall, recentCaptures = [], ownBackendHostnames = [], knownErrorClassPrefixes: replanKnownErrorClassPrefixes = [], trajectory = [], priorReplans = [], } = params;
|
|
786
|
+
const trajectoryList = trajectory.length > 0
|
|
787
|
+
? trajectory
|
|
788
|
+
.slice(-5)
|
|
789
|
+
.map((t) => `step ${t.stepIndex + 1} verified via ${t.verifiedBy ?? "(no signal recorded)"}`)
|
|
790
|
+
.join("; ")
|
|
791
|
+
: "";
|
|
792
|
+
const priorReplanList = priorReplans.length > 0
|
|
793
|
+
? priorReplans
|
|
794
|
+
.map((ev) => `replan #${ev.replanIndex} (failed on: ${ev.failedInstruction})\nproposed:\n${ev.replanSteps
|
|
795
|
+
.map((s, i) => ` ${i + 1}. ${s.instruction}`)
|
|
796
|
+
.join("\n")}`)
|
|
797
|
+
.join("\n\n")
|
|
798
|
+
: "";
|
|
799
|
+
const candidates = await (0, stagehand_guard_1.guardedObserve)(stagehand, undefined, { timeout: flow_runner_1.STEP_WATCHDOG_MS }, captureFn).catch(() => []);
|
|
800
|
+
const candidateList = await (0, flow_runner_1.renderUnfocusedObserve)(candidates, { client, captureFn });
|
|
801
|
+
const pageTitle = await page.title().catch(() => "");
|
|
802
|
+
// Without raw DOM in the prompt, the LLM only sees stagehand.observe()'s
|
|
803
|
+
// filtered candidate list and hallucinates about surrounding state
|
|
804
|
+
// (auth-wall reset, closed-message interstitial, etc.).
|
|
805
|
+
const { bodyExcerpt, unfocusedList, invalidFieldList, errorTextList, recentFailureReasons } = await readFailureDumpEvidence(failureDumpPath, {
|
|
806
|
+
client,
|
|
807
|
+
knownErrorClassPrefixes: replanKnownErrorClassPrefixes,
|
|
808
|
+
captureFn,
|
|
809
|
+
page,
|
|
810
|
+
});
|
|
811
|
+
const failureReasonList = recentFailureReasons.map((r, i) => `${i + 1}. ${r}`).join("\n");
|
|
812
|
+
const submitFailureList = (0, flow_runner_1.extractSubmitFailureEvidence)(recentCaptures, ownBackendHostnames);
|
|
813
|
+
const gaEventList = (0, flow_runner_1.extractGaEventEvidence)(recentCaptures);
|
|
814
|
+
// False-premise meta-signal: when 2+ prior replans failed on a step
|
|
815
|
+
// sharing the same 24-char slug-prefix as the current failed step,
|
|
816
|
+
// inject an ELEMENT MODEL CHECK section. Reflexion-grounded
|
|
817
|
+
// (verbal-reinforcement on repeated failures); intended to break the
|
|
818
|
+
// degenerate "Click Continue" / "Fill Address" loop the smoke run
|
|
819
|
+
// showed when the cascade got anchored to a non-existent element
|
|
820
|
+
// ("Click the Year spinbutton" steps targeting an HTML5 <input
|
|
821
|
+
// type='date'>).
|
|
822
|
+
const slugPriorMatches = countSlugPrefixMatches(failedStep, priorReplans);
|
|
823
|
+
const elementModelCheck = slugPriorMatches >= 2
|
|
824
|
+
? `ELEMENT MODEL CHECK — The cascade has now failed ${slugPriorMatches + 1} times on steps matching the same element pattern. When the same element family fails repeatedly, the failed-step's element description may NOT match the actual DOM (e.g. the step asks for "spinbutton" but the page only has <input type="date">; asks for "Select dropdown" but the page has a custom autocomplete; asks for a "Click" target that's a label-only with no clickable child). Inspect PAGE BODY HTML AT FAILURE: if the actual widget in the DOM differs from the failed step's element description, propose a STRUCTURALLY DIFFERENT recovery that targets the actual widget visible on the page (e.g. "Fill in the date input field with today's date" instead of "Click the Month spinbutton") rather than restating the failed step's premise.`
|
|
825
|
+
: "";
|
|
826
|
+
// Structural-block meta-signal: when NO attempt ever resolved a selector for
|
|
827
|
+
// the failed step (every attempt found nothing to act on), the step's target
|
|
828
|
+
// widget/control was never present-and-drivable — rewording the same premise
|
|
829
|
+
// will fail identically. Tell the LLM to change approach or mark impossible,
|
|
830
|
+
// not paraphrase. Read the persisted attempt records from the failure dump.
|
|
831
|
+
const structurallyBlocked = (() => {
|
|
832
|
+
try {
|
|
833
|
+
const dump = JSON.parse((0, node_fs_1.readFileSync)(failureDumpPath, "utf8"));
|
|
834
|
+
const attempts = (dump.attempts ?? []).map((a) => ({
|
|
835
|
+
triedSelectors: a.triedSelectors ?? [],
|
|
836
|
+
verifiedBy: a.verifiedBy ?? null,
|
|
837
|
+
}));
|
|
838
|
+
return isStructurallyBlocked(attempts);
|
|
839
|
+
}
|
|
840
|
+
catch {
|
|
841
|
+
return false;
|
|
842
|
+
}
|
|
843
|
+
})();
|
|
844
|
+
const structuralBlockCheck = structurallyBlocked
|
|
845
|
+
? `STRUCTURAL BLOCK — Every cascade attempt on the failed step resolved NO element (observe found no candidate; nothing was clicked or filled). The step's target is not present-and-drivable on this page as described. Do NOT merely reword or paraphrase the same premise — it will fail identically. Either (a) propose a STRUCTURALLY DIFFERENT step targeting a control that actually exists in PAGE BODY HTML AT FAILURE / the observed candidates, or (b) if the required control genuinely isn't reachable, return outcome=impossible rather than a cosmetic rewrite.`
|
|
846
|
+
: "";
|
|
847
|
+
const prompt = `You are helping a browser automation agent recover from a failed flow step.
|
|
848
|
+
|
|
849
|
+
ORIGINAL FLOW SUMMARY: ${originalFlow.length} total steps; ${completedSteps.length} executed, ${remainingSteps.length} remaining after the failed step. The completed-tail and remaining-head windows below give you the local context — that's the only flow context replan needs.
|
|
850
|
+
|
|
851
|
+
STEPS ALREADY COMPLETED (these succeeded — do NOT re-emit any of them; the head shows early fills like name/email so you don't repeat them):
|
|
852
|
+
${renderStepWindow(completedSteps, { head: 8, tail: 10 })}
|
|
853
|
+
|
|
854
|
+
THE STEP THAT JUST FAILED (after exhausting its per-step healing cascade):
|
|
855
|
+
${failedStep}
|
|
856
|
+
|
|
857
|
+
REMAINING UNEXECUTED STEPS (head of what comes after the failed step; the driver will auto-append the FULL remaining tail after your bridge so do not re-emit these):
|
|
858
|
+
${renderStepWindow(remainingSteps, { head: 15, tail: 0 })}
|
|
859
|
+
|
|
860
|
+
CURRENT BROWSER STATE:
|
|
861
|
+
URL: ${page.url()}
|
|
862
|
+
Title: ${pageTitle}
|
|
863
|
+
|
|
864
|
+
${elementModelCheck ? `${elementModelCheck}\n\n` : ""}${structuralBlockCheck ? `${structuralBlockCheck}\n\n` : ""}WHY VERIFICATION FAILED (latest attempt reasons from the cascade — read these carefully, they explain WHY the step is being declared failed):
|
|
865
|
+
${failureReasonList || "(none)"}
|
|
866
|
+
|
|
867
|
+
PAGE TRANSITION + VALIDATOR TELEMETRY (parsed from Google Analytics Measurement Protocol beacons (POSTs to google-analytics.com/g/collect) captured during the failed step's attempt window — this is the SPA's own telemetry telling you what state it thinks it's in. Watch for: en=view_secondPage / en=view_thirdPage indicating the SPA advanced to a later form page WITHOUT firing Page.frameNavigated (so URL stays the same but questions changed); en=view_thankYouPage indicating the application SUBMITTED SUCCESSFULLY (a stronger success signal than network captures because the /integrated_apply POST is sometimes debounced); epn.validationErrorsCount=N indicating the site's own client validator counts N unfilled required fields. When validationErrorsCount > 0, prefer steps that target unfilled fields over re-clicking Submit/Continue. When view_thankYouPage appears, the application already submitted — do not propose more form-fill steps):
|
|
868
|
+
${gaEventList || "(none)"}
|
|
869
|
+
|
|
870
|
+
FORM FIELDS CURRENTLY MARKED INVALID (text + class signature for any element whose class matches the framework-agnostic invalid pattern — ng-invalid, mat-form-field-invalid, is-invalid, etc.):
|
|
871
|
+
${invalidFieldList || "(none)"}
|
|
872
|
+
|
|
873
|
+
VISIBLE ERROR / REQUIRED-FIELD MESSAGES ON THE PAGE (extracted text from error-class containers — error-message, mat-error, field-error, validation-error, invalid-feedback, etc.):
|
|
874
|
+
${errorTextList || "(none)"}
|
|
875
|
+
|
|
876
|
+
STRUCTURED SERVER-SIDE VALIDATION ERRORS (parsed from captured 4xx responses to the submit endpoint — when populated, the form's submit DID fire and the server rejected it with specific feedback):
|
|
877
|
+
${submitFailureList || "(none)"}
|
|
878
|
+
|
|
879
|
+
PRIOR REPLAN HISTORY (proposals from previous replans for this same failure; the cascade executed each one and verification still failed at the time it ran. Between replans, the page may have advanced — a step that failed earlier could potentially work now if intervening steps filled missing prerequisites or dismissed blockers. But re-proposing the EXACT same multi-step sequence that already failed is unlikely to produce a different outcome. Prefer structurally different recovery paths. If you re-use a prior step, pair it with new context that addresses why it failed before):
|
|
880
|
+
${priorReplanList || "(none)"}
|
|
881
|
+
|
|
882
|
+
PRIOR STEP TRAJECTORY (how the last few completed steps verified — url / submitted-state-dom signal pages that visibly transitioned; network / dom signal pages that stayed static. Use this to distinguish "page has been advancing through the flow" from "page has been static and the form is still in front of us"; avoid proposing regression-style steps if the trajectory shows recent transitions):
|
|
883
|
+
${trajectoryList || "(none)"}
|
|
884
|
+
|
|
885
|
+
ELEMENTS CURRENTLY VISIBLE ON THE PAGE (stagehand.observe with the failed instruction):
|
|
886
|
+
${candidateList || "(no candidates returned by observe)"}
|
|
887
|
+
|
|
888
|
+
UNFOCUSED OBSERVE (what Stagehand sees on the page without any instruction filter):
|
|
889
|
+
${unfocusedList || "(none)"}
|
|
890
|
+
|
|
891
|
+
PAGE BODY HTML AT FAILURE (truncated to 8KB — use this to detect interstitials, error messages, auth walls, or unexpected page states that the observe lists miss):
|
|
892
|
+
${bodyExcerpt || "(missing)"}
|
|
893
|
+
|
|
894
|
+
DIAGNOSTIC DUMP FILE (for reference):
|
|
895
|
+
${failureDumpPath}
|
|
896
|
+
|
|
897
|
+
Emit ONLY the RECOVERY BRIDGE steps from the failure point back to where the
|
|
898
|
+
original flow's remaining tail can resume. The driver will automatically append
|
|
899
|
+
the original REMAINING UNEXECUTED STEPS (shown above) AFTER your bridge steps,
|
|
900
|
+
so you do NOT need to re-emit them. Your job is just the recovery — the few
|
|
901
|
+
steps needed to get the SPA from its current state into a shape where the
|
|
902
|
+
original tail can run.
|
|
903
|
+
|
|
904
|
+
Concretely:
|
|
905
|
+
- If the page is at an intermediate state the original flow didn't anticipate,
|
|
906
|
+
emit the steps that bridge to where the original flow can pick back up.
|
|
907
|
+
- If the failed step's effect actually happened despite our verifier saying it
|
|
908
|
+
didn't (e.g. SPA-internal navigation that produced no observable signal),
|
|
909
|
+
the bridge may be EMPTY — just re-emit the failed step (or nothing if the
|
|
910
|
+
state already advanced) and the original tail picks up after.
|
|
911
|
+
- Do NOT try to drive the form to completion yourself — the original tail
|
|
912
|
+
covers that. Just unstick the cascade and let it resume.
|
|
913
|
+
|
|
914
|
+
CRITICAL: single-action steps only.
|
|
915
|
+
Each step in your output array MUST invoke exactly ONE DOM action:
|
|
916
|
+
- one \`fill\` on one input, OR
|
|
917
|
+
- one \`click\` on one button/radio/checkbox, OR
|
|
918
|
+
- one \`selectOption\` on one dropdown, OR
|
|
919
|
+
- one observable trigger like "upload the resume PDF".
|
|
920
|
+
|
|
921
|
+
The underlying agent (Stagehand \`act()\`) can only execute one action per step.
|
|
922
|
+
Multi-action steps silently drop all but one action and corrupt downstream form state.
|
|
923
|
+
|
|
924
|
+
WRONG (multi-action — DO NOT emit steps like these):
|
|
925
|
+
"Fill in First Name 'Reginald', Last Name 'Reconaldo', Email '...'"
|
|
926
|
+
"If Street is visible fill Street; if City is visible fill City; then click Continue"
|
|
927
|
+
"Fill the signature field with 'Name'; check the I agree box; click Submit"
|
|
928
|
+
|
|
929
|
+
RIGHT (single-action — emit steps like these):
|
|
930
|
+
"Fill in the First Name field with 'Reginald'"
|
|
931
|
+
"Fill in the Last Name field with 'Reconaldo'"
|
|
932
|
+
"Fill in the Email field with '...'"
|
|
933
|
+
"If a Street Address field is visible, fill it with '123 Test Lane'"
|
|
934
|
+
"If a City field is visible, fill it with 'Austin'"
|
|
935
|
+
"Click the Continue button"
|
|
936
|
+
"Fill the signature field with 'Name'"
|
|
937
|
+
"Check the I agree checkbox"
|
|
938
|
+
"Click the Submit button"
|
|
939
|
+
|
|
940
|
+
A step CAN combine ONE conditional + ONE action ("If X is visible, do Y") — that
|
|
941
|
+
counts as single-action because the conditional only gates whether the action
|
|
942
|
+
runs. But NEVER combine multiple actions even when they're each conditional.
|
|
943
|
+
|
|
944
|
+
Constraints:
|
|
945
|
+
- Do NOT include the already-completed steps in your output.
|
|
946
|
+
- Do NOT include the original REMAINING UNEXECUTED STEPS in your output — they will be appended automatically after your bridge.
|
|
947
|
+
- Emit ONLY the recovery bridge steps that get the SPA from its current state to where the original tail can resume.
|
|
948
|
+
- If you can recover the flow, return outcome="replan" with the steps array (can be just 1-2 steps if that's all that's needed).
|
|
949
|
+
- If the user's intent is unreachable from this page state, return outcome="impossible" with a brief reason.
|
|
950
|
+
- Maximum ${schemas_1.REPLAN_MAX_STEPS} bridge steps.
|
|
951
|
+
- Each step is a single DOM action (see CRITICAL section above).
|
|
952
|
+
|
|
953
|
+
OPTIONAL STEPS:
|
|
954
|
+
Each step entry can be a bare string (required step — cascade fails if the
|
|
955
|
+
target is missing) OR an object \`{step: "...", optional: true}\` (cascade
|
|
956
|
+
skips cleanly if Stagehand observes no candidates).
|
|
957
|
+
|
|
958
|
+
Use optional ONLY when the action is genuinely conditional on the page having
|
|
959
|
+
a specific element. Examples:
|
|
960
|
+
- "If a 'Currently employed here' checkbox is visible, check it" → emit as
|
|
961
|
+
\`{step: "Check the 'Currently employed here' checkbox", optional: true}\`
|
|
962
|
+
- "If an 'Add Experience' button is visible, click it" → emit as
|
|
963
|
+
\`{step: "Click the 'Add Experience' button", optional: true}\`
|
|
964
|
+
|
|
965
|
+
Do NOT mark required actions optional (form fills the user needs filled, the
|
|
966
|
+
Continue/Submit button at the end of a section, etc.) — that would silently
|
|
967
|
+
skip them when the cascade can't see them, leaving the form half-filled.
|
|
968
|
+
|
|
969
|
+
UPLOAD STEPS:
|
|
970
|
+
A step that uploads a file to a file input MUST be emitted as
|
|
971
|
+
\`{step: "...", upload: true}\` so the cascade routes it to the file-upload
|
|
972
|
+
primitive (which handles widgets that hide the real <input type=file> behind
|
|
973
|
+
a styled button Stagehand can't click).
|
|
974
|
+
|
|
975
|
+
Do NOT mark non-upload actions as upload — even if their description mentions
|
|
976
|
+
"upload" or "resume". Examples:
|
|
977
|
+
- "Upload the test resume PDF when the upload screen appears" → emit as
|
|
978
|
+
\`{step: "Upload the test resume PDF", upload: true}\`
|
|
979
|
+
- "Click Continue past the resume upload screen" → emit as the bare string
|
|
980
|
+
"Click Continue past the resume upload screen" (NOT upload — it's a click).
|
|
981
|
+
- "Click the Remove button to delete the previous resume" → bare string
|
|
982
|
+
(NOT upload — it's a click).
|
|
983
|
+
|
|
984
|
+
A step CAN be both upload and optional (object form with both fields set),
|
|
985
|
+
but that's rare — most upload steps are required.`;
|
|
986
|
+
const model = (0, flow_runner_1.anthropicModelName)();
|
|
987
|
+
const t0 = performance.now();
|
|
988
|
+
try {
|
|
989
|
+
const response = await client.messages.parse({
|
|
990
|
+
model,
|
|
991
|
+
max_tokens: 2000,
|
|
992
|
+
messages: [{ role: "user", content: prompt }],
|
|
993
|
+
output_config: {
|
|
994
|
+
format: (0, zod_1.zodOutputFormat)(schemas_1.REPLAN_RESPONSE_SCHEMA),
|
|
995
|
+
},
|
|
996
|
+
});
|
|
997
|
+
const latencyMs = performance.now() - t0;
|
|
998
|
+
// Structured output: SDK throws on JSON or schema-validation failure, so
|
|
999
|
+
// reaching this point means parsed_output is the validated object. The
|
|
1000
|
+
// typings keep T | null in the signature for the "no format supplied"
|
|
1001
|
+
// branch — guard so the discriminated-union narrows cleanly below.
|
|
1002
|
+
const parsed = response.parsed_output;
|
|
1003
|
+
if (parsed === null) {
|
|
1004
|
+
throw new Error("structured-output enabled but parsed_output is null");
|
|
1005
|
+
}
|
|
1006
|
+
const textBlock = response.content.find((b) => b.type === "text");
|
|
1007
|
+
const rawText = textBlock?.type === "text" ? textBlock.text : "";
|
|
1008
|
+
await captureFn({
|
|
1009
|
+
callId: (0, node_crypto_1.randomUUID)(),
|
|
1010
|
+
callType: call_types_1.CALL_TYPE_RECON_REPLAN,
|
|
1011
|
+
model,
|
|
1012
|
+
systemPrompt: null,
|
|
1013
|
+
userContent: prompt,
|
|
1014
|
+
responseContent: rawText,
|
|
1015
|
+
parsedOk: true,
|
|
1016
|
+
inputTokens: response.usage?.input_tokens ?? null,
|
|
1017
|
+
outputTokens: response.usage?.output_tokens ?? null,
|
|
1018
|
+
latencyMs,
|
|
1019
|
+
success: true,
|
|
1020
|
+
errorMessage: null,
|
|
1021
|
+
failureKind: null,
|
|
1022
|
+
});
|
|
1023
|
+
if (parsed.outcome === "replan")
|
|
1024
|
+
return normalizeFlow(parsed.steps);
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1029
|
+
(0, flow_runner_1.logBillingErrorIfPresent)(err);
|
|
1030
|
+
await captureFn({
|
|
1031
|
+
callId: (0, node_crypto_1.randomUUID)(),
|
|
1032
|
+
callType: call_types_1.CALL_TYPE_RECON_REPLAN,
|
|
1033
|
+
model,
|
|
1034
|
+
systemPrompt: null,
|
|
1035
|
+
userContent: prompt,
|
|
1036
|
+
responseContent: null,
|
|
1037
|
+
parsedOk: false,
|
|
1038
|
+
inputTokens: null,
|
|
1039
|
+
outputTokens: null,
|
|
1040
|
+
latencyMs: performance.now() - t0,
|
|
1041
|
+
success: false,
|
|
1042
|
+
errorMessage: message,
|
|
1043
|
+
failureKind: (0, call_capture_1.classifyLlmCallFailure)(err),
|
|
1044
|
+
});
|
|
1045
|
+
return null;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
function dumpReplanRecord(params) {
|
|
1049
|
+
(0, node_fs_1.mkdirSync)(recon_shared_1.STEP_FAILURES_DIR, { recursive: true });
|
|
1050
|
+
const idx = String(params.stepIndex).padStart(3, "0");
|
|
1051
|
+
const filename = `${idx}-${params.phase}.replan.json`;
|
|
1052
|
+
const target = (0, node_path_1.join)(recon_shared_1.STEP_FAILURES_DIR, filename);
|
|
1053
|
+
(0, node_fs_1.writeFileSync)(target, JSON.stringify({
|
|
1054
|
+
timestamp: new Date().toISOString(),
|
|
1055
|
+
stepIndex: params.stepIndex,
|
|
1056
|
+
phase: params.phase,
|
|
1057
|
+
replanIndex: params.replanIndex,
|
|
1058
|
+
completedSteps: params.completedSteps,
|
|
1059
|
+
originalRemaining: params.originalRemaining,
|
|
1060
|
+
newRemaining: params.newRemaining,
|
|
1061
|
+
}, null, 2));
|
|
1062
|
+
return target;
|
|
1063
|
+
}
|
|
1064
|
+
function dumpStepFailure(params) {
|
|
1065
|
+
(0, node_fs_1.mkdirSync)(recon_shared_1.STEP_FAILURES_DIR, { recursive: true });
|
|
1066
|
+
const idx = String(params.stepIndex).padStart(3, "0");
|
|
1067
|
+
const filename = `${idx}-${params.phase}.json`;
|
|
1068
|
+
const bundle = {
|
|
1069
|
+
timestamp: new Date().toISOString(),
|
|
1070
|
+
stepIndex: params.stepIndex,
|
|
1071
|
+
phase: params.phase,
|
|
1072
|
+
originalStep: params.originalStep,
|
|
1073
|
+
pageUrl: params.pageUrl,
|
|
1074
|
+
pageTitle: params.pageTitle,
|
|
1075
|
+
attempts: params.attempts,
|
|
1076
|
+
finalObserve: params.finalObserve,
|
|
1077
|
+
unfocusedObserve: params.unfocusedObserve,
|
|
1078
|
+
bodyOuterHtml: params.bodyOuterHtml,
|
|
1079
|
+
recentCaptures: params.recentCaptures.slice(-5),
|
|
1080
|
+
};
|
|
1081
|
+
const target = (0, node_path_1.join)(recon_shared_1.STEP_FAILURES_DIR, filename);
|
|
1082
|
+
(0, node_fs_1.writeFileSync)(target, JSON.stringify(bundle, null, 2));
|
|
1083
|
+
return target;
|
|
1084
|
+
}
|
|
1085
|
+
const DEFAULT_RESUME_FIXTURE_PATH = "src/testing/fixtures/resume.pdf";
|
|
1086
|
+
function parseCli() {
|
|
1087
|
+
const args = process.argv.slice(2);
|
|
1088
|
+
let url = "";
|
|
1089
|
+
let rawFlow = null;
|
|
1090
|
+
let flowFile = null;
|
|
1091
|
+
let provider;
|
|
1092
|
+
// Precedence: --resume-fixture flag > RESUME_FIXTURE_PATH env > default path.
|
|
1093
|
+
let resumeFixturePath = process.env.RESUME_FIXTURE_PATH || DEFAULT_RESUME_FIXTURE_PATH;
|
|
1094
|
+
let saveReplan = true;
|
|
1095
|
+
let advancedStealth = false;
|
|
1096
|
+
let dumpDomBeforeStep = null;
|
|
1097
|
+
let allocateEmailEnvVar = null;
|
|
1098
|
+
for (let i = 0; i < args.length; i++) {
|
|
1099
|
+
if (args[i] === "--url" && args[i + 1]) {
|
|
1100
|
+
url = args[++i];
|
|
1101
|
+
}
|
|
1102
|
+
else if (args[i] === "--flow" && args[i + 1]) {
|
|
1103
|
+
rawFlow = JSON.parse(args[++i]);
|
|
1104
|
+
}
|
|
1105
|
+
else if (args[i] === "--flow-file" && args[i + 1]) {
|
|
1106
|
+
flowFile = (0, node_path_1.resolve)(args[++i]);
|
|
1107
|
+
}
|
|
1108
|
+
else if (args[i] === "--provider" && args[i + 1]) {
|
|
1109
|
+
const raw = args[++i].toLowerCase();
|
|
1110
|
+
if (raw !== "browserbase" && raw !== "steel") {
|
|
1111
|
+
logger.error(`--provider must be "browserbase" or "steel" (got ${JSON.stringify(raw)})`);
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}
|
|
1114
|
+
provider = raw;
|
|
1115
|
+
}
|
|
1116
|
+
else if (args[i] === "--resume-fixture" && args[i + 1]) {
|
|
1117
|
+
resumeFixturePath = args[++i];
|
|
1118
|
+
}
|
|
1119
|
+
else if (args[i] === "--no-save-replan") {
|
|
1120
|
+
saveReplan = false;
|
|
1121
|
+
}
|
|
1122
|
+
else if (args[i] === "--advanced-stealth") {
|
|
1123
|
+
advancedStealth = true;
|
|
1124
|
+
}
|
|
1125
|
+
else if (args[i] === "--dump-dom-before-step" && args[i + 1]) {
|
|
1126
|
+
const n = Number.parseInt(args[++i], 10);
|
|
1127
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
1128
|
+
logger.error(`--dump-dom-before-step must be a positive integer (got ${JSON.stringify(args[i])})`);
|
|
1129
|
+
process.exit(1);
|
|
1130
|
+
}
|
|
1131
|
+
dumpDomBeforeStep = n;
|
|
1132
|
+
}
|
|
1133
|
+
else if (args[i] === "--allocate-email" && args[i + 1]) {
|
|
1134
|
+
const name = args[++i];
|
|
1135
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) {
|
|
1136
|
+
logger.error(`--allocate-email value must be an UPPERCASE_SNAKE_CASE env var name (got ${JSON.stringify(name)})`);
|
|
1137
|
+
process.exit(1);
|
|
1138
|
+
}
|
|
1139
|
+
allocateEmailEnvVar = name;
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
if (!url) {
|
|
1143
|
+
logger.error('usage: recon-browser.ts --url <url> [--flow \'["step1","step2"]\'] [--flow-file <path>] [--provider browserbase|steel] [--resume-fixture <path>] [--no-save-replan] [--advanced-stealth] [--dump-dom-before-step <N>] [--allocate-email <ENV_VAR_NAME>]');
|
|
1144
|
+
process.exit(1);
|
|
1145
|
+
}
|
|
1146
|
+
if (flowFile) {
|
|
1147
|
+
if (rawFlow !== null) {
|
|
1148
|
+
logger.warn("recon-browser: --flow-file takes precedence over --flow");
|
|
1149
|
+
}
|
|
1150
|
+
try {
|
|
1151
|
+
rawFlow = JSON.parse((0, node_fs_1.readFileSync)(flowFile, "utf8"));
|
|
1152
|
+
}
|
|
1153
|
+
catch (err) {
|
|
1154
|
+
logger.error(`failed to read --flow-file ${flowFile}: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
1155
|
+
process.exit(1);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
if (rawFlow === null) {
|
|
1159
|
+
return {
|
|
1160
|
+
url,
|
|
1161
|
+
flow: [],
|
|
1162
|
+
flowFile,
|
|
1163
|
+
provider,
|
|
1164
|
+
resumeFixturePath,
|
|
1165
|
+
saveReplan,
|
|
1166
|
+
advancedStealth,
|
|
1167
|
+
dumpDomBeforeStep,
|
|
1168
|
+
allocateEmailEnvVar,
|
|
1169
|
+
submitEndpointPattern: null,
|
|
1170
|
+
submittedStateSelectors: [],
|
|
1171
|
+
requireSubmitEndpointMatch: false,
|
|
1172
|
+
advanceTransitionBodyPattern: null,
|
|
1173
|
+
successUrlFragments: [],
|
|
1174
|
+
successPageTitleHints: [],
|
|
1175
|
+
ownBackendHostnames: [],
|
|
1176
|
+
knownErrorClassPrefixes: [],
|
|
1177
|
+
wizardExitButtonLabels: [],
|
|
1178
|
+
restartSignalUrlPatterns: [],
|
|
1179
|
+
originalShape: "array",
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
const parsed = RECON_FLOW_FILE_SCHEMA.safeParse(rawFlow);
|
|
1183
|
+
if (!parsed.success) {
|
|
1184
|
+
logger.error(`flow file/arg failed schema validation: ${parsed.error.message}`);
|
|
1185
|
+
process.exit(1);
|
|
1186
|
+
}
|
|
1187
|
+
const stepsRaw = Array.isArray(parsed.data) ? parsed.data : parsed.data.steps;
|
|
1188
|
+
const submitEndpointPattern = Array.isArray(parsed.data)
|
|
1189
|
+
? null
|
|
1190
|
+
: (parsed.data.submitEndpointPattern ?? null);
|
|
1191
|
+
const submittedStateSelectors = Array.isArray(parsed.data)
|
|
1192
|
+
? []
|
|
1193
|
+
: (parsed.data.submittedStateSelectors ?? []);
|
|
1194
|
+
const requireSubmitEndpointMatch = Array.isArray(parsed.data)
|
|
1195
|
+
? false
|
|
1196
|
+
: (parsed.data.requireSubmitEndpointMatch ?? false);
|
|
1197
|
+
const advanceTransitionBodyPattern = Array.isArray(parsed.data)
|
|
1198
|
+
? null
|
|
1199
|
+
: (parsed.data.advanceTransitionBodyPattern ?? null);
|
|
1200
|
+
const successUrlFragments = Array.isArray(parsed.data)
|
|
1201
|
+
? []
|
|
1202
|
+
: (parsed.data.successUrlFragments ?? []);
|
|
1203
|
+
const successPageTitleHints = Array.isArray(parsed.data)
|
|
1204
|
+
? []
|
|
1205
|
+
: (parsed.data.successPageTitleHints ?? []);
|
|
1206
|
+
const ownBackendHostnames = Array.isArray(parsed.data)
|
|
1207
|
+
? []
|
|
1208
|
+
: (parsed.data.ownBackendHostnames ?? []);
|
|
1209
|
+
const knownErrorClassPrefixes = Array.isArray(parsed.data)
|
|
1210
|
+
? []
|
|
1211
|
+
: (parsed.data.knownErrorClassPrefixes ?? []);
|
|
1212
|
+
const wizardExitButtonLabels = Array.isArray(parsed.data)
|
|
1213
|
+
? []
|
|
1214
|
+
: (parsed.data.wizardExitButtonLabels ?? []);
|
|
1215
|
+
const restartSignalUrlPatterns = Array.isArray(parsed.data)
|
|
1216
|
+
? []
|
|
1217
|
+
: (parsed.data.restartSignalUrlPatterns ?? []);
|
|
1218
|
+
const isArrayShape = Array.isArray(parsed.data);
|
|
1219
|
+
// Validate regex compiles eagerly so a malformed pattern fails the run
|
|
1220
|
+
// at startup, not deep in a per-step verifier.
|
|
1221
|
+
if (submitEndpointPattern !== null) {
|
|
1222
|
+
try {
|
|
1223
|
+
new RegExp(submitEndpointPattern);
|
|
1224
|
+
}
|
|
1225
|
+
catch (err) {
|
|
1226
|
+
logger.error(`flow file: submitEndpointPattern is not a valid regex: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
1227
|
+
process.exit(1);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return {
|
|
1231
|
+
url,
|
|
1232
|
+
flow: normalizeFlow(stepsRaw),
|
|
1233
|
+
flowFile,
|
|
1234
|
+
provider,
|
|
1235
|
+
resumeFixturePath,
|
|
1236
|
+
saveReplan,
|
|
1237
|
+
advancedStealth,
|
|
1238
|
+
dumpDomBeforeStep,
|
|
1239
|
+
allocateEmailEnvVar,
|
|
1240
|
+
submitEndpointPattern,
|
|
1241
|
+
submittedStateSelectors,
|
|
1242
|
+
requireSubmitEndpointMatch,
|
|
1243
|
+
advanceTransitionBodyPattern,
|
|
1244
|
+
successUrlFragments,
|
|
1245
|
+
successPageTitleHints,
|
|
1246
|
+
ownBackendHostnames,
|
|
1247
|
+
knownErrorClassPrefixes,
|
|
1248
|
+
wizardExitButtonLabels,
|
|
1249
|
+
restartSignalUrlPatterns,
|
|
1250
|
+
originalShape: isArrayShape ? "array" : "object",
|
|
1251
|
+
};
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Loads the resume fixture from disk at startup so the upload primitive
|
|
1255
|
+
* doesn't re-read the file from disk for every recon step. Returns null
|
|
1256
|
+
* when the file doesn't exist — the primitive then falls through to the
|
|
1257
|
+
* regular cascade and the recon continues unchanged (so flows that don't
|
|
1258
|
+
* involve resume uploads aren't affected by a missing fixture).
|
|
1259
|
+
*/
|
|
1260
|
+
function loadResumeFixture(path) {
|
|
1261
|
+
try {
|
|
1262
|
+
const buffer = (0, node_fs_1.readFileSync)(path);
|
|
1263
|
+
const name = path.split("/").pop() ?? "resume.pdf";
|
|
1264
|
+
// Conservative: every site we've targeted accepts PDF; if we ever ship a
|
|
1265
|
+
// .docx fixture we'd extend this map. Default keeps the primitive safe.
|
|
1266
|
+
const mimeType = name.toLowerCase().endsWith(".pdf")
|
|
1267
|
+
? "application/pdf"
|
|
1268
|
+
: "application/octet-stream";
|
|
1269
|
+
return { buffer, name, mimeType };
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
logger.warn(`resume fixture not loaded from ${path}: ${(0, errors_1.toErrorMessage)(err)} — upload primitive will fall through`);
|
|
1273
|
+
return null;
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
async function main() {
|
|
1277
|
+
const { url, flow: rawFlow, flowFile, provider, resumeFixturePath, saveReplan, advancedStealth, dumpDomBeforeStep, allocateEmailEnvVar, submitEndpointPattern, submittedStateSelectors, requireSubmitEndpointMatch, advanceTransitionBodyPattern, successUrlFragments, successPageTitleHints, ownBackendHostnames, knownErrorClassPrefixes, wizardExitButtonLabels, restartSignalUrlPatterns, originalShape, } = parseCli();
|
|
1278
|
+
// Allocate a fresh testmail.app inbox + bind it to the requested env var
|
|
1279
|
+
// BEFORE substituting placeholders in the flow. Subsequent ${ENV_VAR}
|
|
1280
|
+
// tokens anywhere in the flow's instruction strings resolve to the
|
|
1281
|
+
// freshly-allocated address.
|
|
1282
|
+
if (allocateEmailEnvVar) {
|
|
1283
|
+
const inbox = (0, client_1.allocateTestmailInbox)();
|
|
1284
|
+
process.env[allocateEmailEnvVar] = inbox.address;
|
|
1285
|
+
logger.info(`bound allocated testmail address to env var ${allocateEmailEnvVar}=${inbox.address}`);
|
|
1286
|
+
}
|
|
1287
|
+
const flow = substituteFlowEnvVars(rawFlow);
|
|
1288
|
+
(0, node_fs_1.mkdirSync)(recon_shared_1.CAPTURES_DIR, { recursive: true });
|
|
1289
|
+
const resumeFixture = loadResumeFixture(resumeFixturePath);
|
|
1290
|
+
// Per-URL partition under the flow file's site directory. Without a flow
|
|
1291
|
+
// file (inline --flow mode), telemetry has no durable home and is dropped
|
|
1292
|
+
// — captureFn becomes a no-op so dev one-offs don't crash and don't
|
|
1293
|
+
// pollute the global sink.
|
|
1294
|
+
const siteTelemetryDir = (0, telemetry_paths_1.resolveSiteTelemetryDir)(flowFile);
|
|
1295
|
+
// Capture the run-start timestamp once so the calls.ndjson and url.txt
|
|
1296
|
+
// sidecar resolve to the same directory.
|
|
1297
|
+
const runTimestampMs = Date.now();
|
|
1298
|
+
const callsNdjsonPath = siteTelemetryDir !== null ? (0, telemetry_paths_1.resolveRunCallsPath)(siteTelemetryDir, runTimestampMs, url) : null;
|
|
1299
|
+
if (siteTelemetryDir !== null && callsNdjsonPath !== null) {
|
|
1300
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(callsNdjsonPath), { recursive: true });
|
|
1301
|
+
(0, node_fs_1.writeFileSync)((0, telemetry_paths_1.resolveRunUrlPath)(siteTelemetryDir, runTimestampMs, url), `${url}\n`);
|
|
1302
|
+
logger.info(`telemetry: per-URL partition at ${callsNdjsonPath}`);
|
|
1303
|
+
}
|
|
1304
|
+
else {
|
|
1305
|
+
logger.info("telemetry: no flow file path — call capture disabled for this run");
|
|
1306
|
+
}
|
|
1307
|
+
const captureFn = callsNdjsonPath !== null
|
|
1308
|
+
? (input) => (0, call_capture_1.captureLlmCall)(input, { sinkPath: callsNdjsonPath })
|
|
1309
|
+
: async () => { };
|
|
1310
|
+
logger.info(`recon-browser: target=${url} flow_steps=${flow.length} provider=${provider ?? "(config-default)"} advancedStealth=${advancedStealth} resume_fixture=${resumeFixture ? `${resumeFixturePath} (${resumeFixture.buffer.length}b)` : "(missing)"} out=${recon_shared_1.CAPTURES_DIR}`);
|
|
1311
|
+
const session = await (0, session_1.createBrowserSession)({ provider, advancedStealth });
|
|
1312
|
+
// `counter` indexes captures on disk (filenames must stay unique).
|
|
1313
|
+
// `signalCounter` drives the verifier — only non-GET methods increment
|
|
1314
|
+
// it so coincident polling/page-load GETs don't falsely "verify" a
|
|
1315
|
+
// click that produced no real effect. See the onFinished comment in
|
|
1316
|
+
// wireNetworkCapture for the rationale.
|
|
1317
|
+
const counter = { n: 0 };
|
|
1318
|
+
const signalCounter = { n: 0 };
|
|
1319
|
+
const recentCaptures = [];
|
|
1320
|
+
// Parallel tracker of recent non-GET captures' method + status. Used by
|
|
1321
|
+
// the Tier 1 trailing-optional-step grace: a verification failure on an
|
|
1322
|
+
// optional trailing step is treated as a benign no-op when a recent
|
|
1323
|
+
// mutation returned 2xx (i.e. the SPA's "real work" already completed
|
|
1324
|
+
// server-side and the trailing step is a redundant tail that the flow
|
|
1325
|
+
// file may have included for sites where it's actually needed). GETs are
|
|
1326
|
+
// filtered at the push site so the window isn't washed out by SPA chunk
|
|
1327
|
+
// loads — see the comment in wireNetworkCapture's onFinished.
|
|
1328
|
+
const recentCaptureMeta = [];
|
|
1329
|
+
// Hoisted out of the try block so the finally can run the replan
|
|
1330
|
+
// write-back even when the cascade throws — replan-discovered steps
|
|
1331
|
+
// accumulated up to the failure point should survive a cascade-exhausted
|
|
1332
|
+
// exit so the user can review them and the next run starts where this
|
|
1333
|
+
// one left off. The "only on success" gate before was the reason most
|
|
1334
|
+
// recon discoveries got thrown away on failed runs.
|
|
1335
|
+
const plan = [];
|
|
1336
|
+
const replanEvents = [];
|
|
1337
|
+
try {
|
|
1338
|
+
const stagehand = session.stagehand;
|
|
1339
|
+
const page = await stagehand.context.awaitActivePage();
|
|
1340
|
+
// Phase label is mutated between flow steps so the single CDP listener
|
|
1341
|
+
// always tags captures with the currently active phase.
|
|
1342
|
+
let currentPhase = "home";
|
|
1343
|
+
const stopCapture = wireNetworkCapture(page, counter, signalCounter, recentCaptures, recentCaptureMeta, () => currentPhase, () => {
|
|
1344
|
+
// Live read so SPA history navigation between captures stays accurate.
|
|
1345
|
+
// Fallback to the initial url on parse failure (e.g. about:blank early
|
|
1346
|
+
// in the goto cycle) so we don't accidentally mark a capture as
|
|
1347
|
+
// cross-origin and miss the user-action signal.
|
|
1348
|
+
try {
|
|
1349
|
+
return new URL(page.url()).origin;
|
|
1350
|
+
}
|
|
1351
|
+
catch {
|
|
1352
|
+
return new URL(url).origin;
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
logger.info(`navigating to ${url}`);
|
|
1356
|
+
await page.goto(url, { waitUntil: "networkidle", timeoutMs: flow_runner_1.GOTO_TIMEOUT_MS });
|
|
1357
|
+
const SPA_READINESS_TIMEOUT_MS = 15_000;
|
|
1358
|
+
const SPA_READINESS_POLL_MS = 500;
|
|
1359
|
+
const SPA_MIN_BODY_LENGTH = 5_000;
|
|
1360
|
+
const spaDeadline = Date.now() + SPA_READINESS_TIMEOUT_MS;
|
|
1361
|
+
let bodyLength = await page
|
|
1362
|
+
.evaluate("document.body ? document.body.outerHTML.length : 0")
|
|
1363
|
+
.catch(() => 0);
|
|
1364
|
+
if (typeof bodyLength === "number" && bodyLength < SPA_MIN_BODY_LENGTH) {
|
|
1365
|
+
logger.info(`spa readiness: body ${bodyLength} chars < ${SPA_MIN_BODY_LENGTH} threshold — waiting for SPA to render`);
|
|
1366
|
+
while (Date.now() < spaDeadline) {
|
|
1367
|
+
await new Promise((r) => setTimeout(r, SPA_READINESS_POLL_MS));
|
|
1368
|
+
bodyLength = await page
|
|
1369
|
+
.evaluate("document.body ? document.body.outerHTML.length : 0")
|
|
1370
|
+
.catch(() => 0);
|
|
1371
|
+
if (typeof bodyLength === "number" && bodyLength >= SPA_MIN_BODY_LENGTH) {
|
|
1372
|
+
logger.info(`spa readiness: body grew to ${bodyLength} chars — SPA rendered`);
|
|
1373
|
+
break;
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
if (typeof bodyLength === "number" && bodyLength < SPA_MIN_BODY_LENGTH) {
|
|
1377
|
+
logger.warn(`spa readiness: body still ${bodyLength} chars after ${SPA_READINESS_TIMEOUT_MS}ms — proceeding with possibly incomplete page`);
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
const anthropic = (0, anthropic_client_1.buildAnthropicClient)();
|
|
1381
|
+
if (!anthropic) {
|
|
1382
|
+
logger.warn("bedrock-only deployment: attempt-4 llm rephrase and global replan will be skipped on step failures");
|
|
1383
|
+
}
|
|
1384
|
+
plan.push(...flow);
|
|
1385
|
+
const completedSteps = [];
|
|
1386
|
+
const trajectory = [];
|
|
1387
|
+
let probeReplansUsed = 0;
|
|
1388
|
+
let cascadeReplansUsed = 0;
|
|
1389
|
+
const STUCK_SKIP_THRESHOLD = 5;
|
|
1390
|
+
let consecutiveStaleSkips = 0;
|
|
1391
|
+
let lastSuccessNetworkCount = signalCounter.n;
|
|
1392
|
+
let lastSuccessUrl = page.url();
|
|
1393
|
+
// Track the page origin so a cross-origin navigation mid-flow (e.g. the
|
|
1394
|
+
// Apply click taking careers.hcahealthcare.com → apply.talemetry.com) can
|
|
1395
|
+
// re-gate on SPA hydration. The initial goto's readiness gate only covers
|
|
1396
|
+
// the landing page; the wizard app boots on a DIFFERENT origin with no gate,
|
|
1397
|
+
// so its first steps would otherwise probe an un-hydrated shell and skip.
|
|
1398
|
+
const originOf = (u) => {
|
|
1399
|
+
try {
|
|
1400
|
+
return new URL(u).origin;
|
|
1401
|
+
}
|
|
1402
|
+
catch {
|
|
1403
|
+
return "";
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
let lastOrigin = originOf(page.url());
|
|
1407
|
+
for (let i = 0; i < plan.length; i++) {
|
|
1408
|
+
const step = plan[i];
|
|
1409
|
+
currentPhase =
|
|
1410
|
+
step.instruction
|
|
1411
|
+
.replace(/[^a-z0-9]+/gi, "-")
|
|
1412
|
+
.toLowerCase()
|
|
1413
|
+
.replace(/^-|-$/g, "")
|
|
1414
|
+
.slice(0, 24) || `step-${i}`;
|
|
1415
|
+
logger.info(`step ${i + 1}/${plan.length} [${currentPhase}]${step.optional ? " (optional)" : ""}: ${step.instruction}`);
|
|
1416
|
+
// Re-gate on SPA hydration when the origin changed since the last step —
|
|
1417
|
+
// the wizard SPA (e.g. apply.talemetry.com after the Apply click) boots on
|
|
1418
|
+
// a new origin the initial-goto readiness gate never covered, so wait for
|
|
1419
|
+
// its body to render before probing rather than skipping a shell page.
|
|
1420
|
+
const currentOrigin = originOf(page.url());
|
|
1421
|
+
if (currentOrigin !== "" && currentOrigin !== lastOrigin) {
|
|
1422
|
+
logger.info(`origin changed ${lastOrigin || "(none)"} → ${currentOrigin}; re-gating on SPA hydration`);
|
|
1423
|
+
await (0, flow_runner_1.waitForSpaReady)(page, logger);
|
|
1424
|
+
lastOrigin = currentOrigin;
|
|
1425
|
+
}
|
|
1426
|
+
// Debug: dump the full DOM right before this step's cascade runs. Lets
|
|
1427
|
+
// a triager see the page state exactly as the cascade sees it, without
|
|
1428
|
+
// re-running. One-shot per recon run via --dump-dom-before-step.
|
|
1429
|
+
if (dumpDomBeforeStep !== null && i + 1 === dumpDomBeforeStep) {
|
|
1430
|
+
try {
|
|
1431
|
+
const html = await page.evaluate("document.documentElement ? document.documentElement.outerHTML : ''");
|
|
1432
|
+
if (typeof html === "string" && html.length > 0) {
|
|
1433
|
+
const dumpPath = (0, node_path_1.join)(recon_shared_1.CAPTURES_DIR, `..`, `dom-dump-step-${i + 1}.html`);
|
|
1434
|
+
(0, node_fs_1.writeFileSync)(dumpPath, html);
|
|
1435
|
+
logger.info(`step ${i + 1}: wrote DOM dump (${html.length} bytes) to ${dumpPath}`);
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
logger.warn(`step ${i + 1}: DOM dump returned empty content; skipping write`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
catch (err) {
|
|
1442
|
+
logger.warn(`step ${i + 1}: DOM dump failed: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
// Baseline for wizard-restart detection: capture the highest capture
|
|
1446
|
+
// INDEX before the step so findWizardRestartSignal scans only URLs that
|
|
1447
|
+
// landed during THIS step's processing (eviction-proof disk scan).
|
|
1448
|
+
const preCaptureIdxBeforeStep = (0, flow_runner_1.latestCaptureIndex)(recentCaptures);
|
|
1449
|
+
try {
|
|
1450
|
+
const stepOutcome = await (0, flow_runner_1.executeStepWithHealing)({
|
|
1451
|
+
stagehand,
|
|
1452
|
+
page,
|
|
1453
|
+
step: step.instruction,
|
|
1454
|
+
optional: step.optional,
|
|
1455
|
+
upload: step.upload,
|
|
1456
|
+
submitStep: step.submitStep === true,
|
|
1457
|
+
stepIndex: i,
|
|
1458
|
+
phase: currentPhase,
|
|
1459
|
+
signalCounter,
|
|
1460
|
+
recentCaptures,
|
|
1461
|
+
recentCaptureMeta,
|
|
1462
|
+
anthropic,
|
|
1463
|
+
logger,
|
|
1464
|
+
resumeFixture,
|
|
1465
|
+
isFinalStep: i === plan.length - 1,
|
|
1466
|
+
submitEndpointPattern,
|
|
1467
|
+
submittedStateSelectors,
|
|
1468
|
+
requireSubmitEndpointMatch,
|
|
1469
|
+
advanceTransitionBodyPattern,
|
|
1470
|
+
successUrlFragments,
|
|
1471
|
+
successPageTitleHints,
|
|
1472
|
+
ownBackendHostnames,
|
|
1473
|
+
knownErrorClassPrefixes,
|
|
1474
|
+
wizardExitButtonLabels,
|
|
1475
|
+
trajectory,
|
|
1476
|
+
captureFn,
|
|
1477
|
+
onStepFailure: dumpStepFailure,
|
|
1478
|
+
});
|
|
1479
|
+
// Wizard-restart detection: if a configured restart-signal URL (e.g.
|
|
1480
|
+
// Talemetry's `init-apply?...&application_canceled=true`) landed during
|
|
1481
|
+
// this step, the multi-page wizard reset to page 1. Remaining steps now
|
|
1482
|
+
// target a reset page and replanning against the restarted wizard is
|
|
1483
|
+
// futile — abort with a diagnostic instead of silently cycling.
|
|
1484
|
+
const restartUrl = findWizardRestartSignal({
|
|
1485
|
+
preIdx: preCaptureIdxBeforeStep,
|
|
1486
|
+
restartSignalUrlPatterns,
|
|
1487
|
+
});
|
|
1488
|
+
if (restartUrl !== null) {
|
|
1489
|
+
throw new errors_2.StepVerificationError(`step ${i + 1} (${step.instruction.slice(0, 60)}) triggered a wizard restart (${restartUrl.slice(0, 120)}) — the application reset to the first page; aborting`, "wizard-regression");
|
|
1490
|
+
}
|
|
1491
|
+
if (stepOutcome === "skipped") {
|
|
1492
|
+
const pageStagnant = signalCounter.n === lastSuccessNetworkCount && page.url() === lastSuccessUrl;
|
|
1493
|
+
if (pageStagnant) {
|
|
1494
|
+
consecutiveStaleSkips++;
|
|
1495
|
+
}
|
|
1496
|
+
if (consecutiveStaleSkips >= STUCK_SKIP_THRESHOLD) {
|
|
1497
|
+
logger.warn(`stuck detection: ${consecutiveStaleSkips} consecutive optional steps skipped with no page advancement (url=${lastSuccessUrl}, networkCount=${lastSuccessNetworkCount}) — treating as probe-absent failure to trigger replan`);
|
|
1498
|
+
consecutiveStaleSkips = 0;
|
|
1499
|
+
throw new errors_2.StepVerificationError(`step ${i + 1} (${step.instruction.slice(0, 60)}) stuck: ${STUCK_SKIP_THRESHOLD}+ consecutive optional skips with stagnant page`, "probe-absent");
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
else {
|
|
1503
|
+
consecutiveStaleSkips = 0;
|
|
1504
|
+
lastSuccessNetworkCount = signalCounter.n;
|
|
1505
|
+
lastSuccessUrl = page.url();
|
|
1506
|
+
}
|
|
1507
|
+
completedSteps.push(step.instruction);
|
|
1508
|
+
}
|
|
1509
|
+
catch (err) {
|
|
1510
|
+
if (!(err instanceof errors_2.StepVerificationError))
|
|
1511
|
+
throw err;
|
|
1512
|
+
// Unrecoverable backend-error short-circuit. When the cascade
|
|
1513
|
+
// detected a same-window 5xx on the submit endpoint, no amount of
|
|
1514
|
+
// replan or rephrase can heal a server crash. Propagate the error
|
|
1515
|
+
// out of the flow loop so main()'s outer try/catch reports the
|
|
1516
|
+
// diagnostic — bypasses the trailing-grace and replan paths
|
|
1517
|
+
// entirely. (The cascade-exhausted and probe-absent kinds fall
|
|
1518
|
+
// through to the existing dispatcher below.)
|
|
1519
|
+
if (err.kind === "backend-error-unrecoverable") {
|
|
1520
|
+
logger.error(`backend error unrecoverable: ${err.message}; aborting run`);
|
|
1521
|
+
throw err;
|
|
1522
|
+
}
|
|
1523
|
+
if (err.kind === "wizard-regression") {
|
|
1524
|
+
// The wizard restarted; replanning against a reset page cannot recover
|
|
1525
|
+
// the lost progress. Bypass the replan dispatcher and abort.
|
|
1526
|
+
logger.error(`wizard regression: ${err.message}; aborting run`);
|
|
1527
|
+
throw err;
|
|
1528
|
+
}
|
|
1529
|
+
// Tier 1 — trailing-optional-step grace: when an OPTIONAL step at
|
|
1530
|
+
// trailing position fails verification AND a recent non-GET capture
|
|
1531
|
+
// returned 2xx, treat as a benign no-op exit. The flow's "real work"
|
|
1532
|
+
// already completed server-side (recent successful POST proves it);
|
|
1533
|
+
// the trailing step is a redundant tail that the cascade can't make
|
|
1534
|
+
// meaningful progress on (e.g. resume re-upload after the workflow
|
|
1535
|
+
// already ended, final Continue when the Submit button is in
|
|
1536
|
+
// "Saving..." state). Uses only flow-position metadata + capture HTTP
|
|
1537
|
+
// metadata — no content matching, no open-set patterns.
|
|
1538
|
+
//
|
|
1539
|
+
// When the flow declares submitEndpointPattern, the "recent 2xx" must
|
|
1540
|
+
// match it. Without this gate, the heuristic latches onto every
|
|
1541
|
+
// non-GET 2xx — including pre-submit interruption_check POSTs and
|
|
1542
|
+
// third-party analytics tracking pixels (Google Analytics, DoubleClick,
|
|
1543
|
+
// googletagmanager) that fire on form interaction events. Those look
|
|
1544
|
+
// exactly like real submissions by HTTP signal but don't represent the
|
|
1545
|
+
// actual application landing. Verified by reading captures from
|
|
1546
|
+
// /tmp/recon/graphql/ during a sweep: Presbyterian jobs hit this exact
|
|
1547
|
+
// failure — only interruption_check + GA tracking POSTs fired, no
|
|
1548
|
+
// integrated_apply, yet trailing-grace declared success.
|
|
1549
|
+
if (step.optional && i >= plan.length - flow_runner_1.TRAILING_GRACE_WINDOW) {
|
|
1550
|
+
// Trailing-grace check: did the submit actually land somewhere in
|
|
1551
|
+
// the recent capture history? Ask the same Haiku judge — it has
|
|
1552
|
+
// multi-signal reasoning to distinguish real submit POSTs from
|
|
1553
|
+
// analytics/tracking 2xx that look submission-shaped.
|
|
1554
|
+
const pageTitle = await page.title().catch(() => "");
|
|
1555
|
+
const trailingGraceVerdict = await (0, verify_submit_1.verifySubmitWithLLM)({
|
|
1556
|
+
client: anthropic,
|
|
1557
|
+
input: {
|
|
1558
|
+
pageUrl: page.url(),
|
|
1559
|
+
pageTitle,
|
|
1560
|
+
unfocusedObserve: [],
|
|
1561
|
+
networkCaptures: recentCaptureMeta,
|
|
1562
|
+
invalidMarkerCount: 0,
|
|
1563
|
+
ownBackendHostnames,
|
|
1564
|
+
successUrlFragments,
|
|
1565
|
+
successPageTitleHints,
|
|
1566
|
+
submittedStateSelectors,
|
|
1567
|
+
},
|
|
1568
|
+
captureFn,
|
|
1569
|
+
});
|
|
1570
|
+
if (trailingGraceVerdict?.verified) {
|
|
1571
|
+
logger.info(`step ${i + 1} optional + trailing position; judge verified recent submit (${trailingGraceVerdict.rationale}) — treating verification failure as benign no-op; recon complete`);
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (!anthropic)
|
|
1576
|
+
throw err;
|
|
1577
|
+
// Cause-based replan budget. Probe replans are cheap (one observe +
|
|
1578
|
+
// one LLM call to detect "wrong page"), cascade replans are expensive
|
|
1579
|
+
// (four attempts × backoff + observe + LLM rephrase before we know
|
|
1580
|
+
// the step is unrecoverable). Separate budgets so cheap recoveries
|
|
1581
|
+
// don't eat into the budget reserved for expensive ones.
|
|
1582
|
+
const isProbe = err.kind === "probe-absent";
|
|
1583
|
+
const budget = isProbe ? MAX_PROBE_REPLANS : MAX_CASCADE_REPLANS;
|
|
1584
|
+
const usedSoFar = isProbe ? probeReplansUsed : cascadeReplansUsed;
|
|
1585
|
+
if (usedSoFar >= budget) {
|
|
1586
|
+
const kindsSummary = callsNdjsonPath !== null
|
|
1587
|
+
? summarizeReplanFailureKinds({
|
|
1588
|
+
callsNdjsonPath,
|
|
1589
|
+
callType: call_types_1.CALL_TYPE_RECON_REPLAN,
|
|
1590
|
+
tailCount: budget * 2,
|
|
1591
|
+
})
|
|
1592
|
+
: "";
|
|
1593
|
+
const kindsSuffix = kindsSummary ? ` — ${kindsSummary}` : "";
|
|
1594
|
+
logger.error(`step ${i + 1} ${err.kind} replan budget exhausted (${usedSoFar}/${budget}); aborting${kindsSuffix}`);
|
|
1595
|
+
throw err;
|
|
1596
|
+
}
|
|
1597
|
+
const replanIndex = replanEvents.length + 1;
|
|
1598
|
+
const originalRemaining = plan.slice(i + 1);
|
|
1599
|
+
const dumpMatch = err.message.match(/see (\/[^\s]+)$/);
|
|
1600
|
+
const dumpPath = dumpMatch ? dumpMatch[1] : "";
|
|
1601
|
+
logger.warn(`step ${i + 1} terminally failed (${err.kind}); attempting global replan #${replanIndex} (${isProbe ? "probe" : "cascade"} budget ${usedSoFar + 1}/${budget})`);
|
|
1602
|
+
const rawNewSteps = await replanRemainingFlow({
|
|
1603
|
+
client: anthropic,
|
|
1604
|
+
originalFlow: flow.map((s) => s.instruction),
|
|
1605
|
+
completedSteps,
|
|
1606
|
+
failedStep: step.instruction,
|
|
1607
|
+
remainingSteps: originalRemaining.map((s) => s.instruction),
|
|
1608
|
+
failureDumpPath: dumpPath,
|
|
1609
|
+
page,
|
|
1610
|
+
stagehand,
|
|
1611
|
+
captureFn,
|
|
1612
|
+
recentCaptures,
|
|
1613
|
+
ownBackendHostnames,
|
|
1614
|
+
trajectory,
|
|
1615
|
+
priorReplans: replanEvents,
|
|
1616
|
+
});
|
|
1617
|
+
if (!rawNewSteps) {
|
|
1618
|
+
logger.error(`replan #${replanIndex} returned outcome=impossible or unparseable output; aborting`);
|
|
1619
|
+
throw err;
|
|
1620
|
+
}
|
|
1621
|
+
// Resume-from-failure: drop any replan bridge step that re-runs an
|
|
1622
|
+
// already-completed step (see filterCompletedFromReplan). Keeps the
|
|
1623
|
+
// failed step's re-emission. originalRemaining is re-appended below.
|
|
1624
|
+
const newSteps = filterCompletedFromReplan(rawNewSteps, completedSteps, step.instruction);
|
|
1625
|
+
const droppedCompleted = rawNewSteps.length - newSteps.length;
|
|
1626
|
+
if (droppedCompleted > 0) {
|
|
1627
|
+
logger.info(`replan #${replanIndex}: dropped ${droppedCompleted} bridge step(s) that re-ran already-completed steps`);
|
|
1628
|
+
}
|
|
1629
|
+
if (newSteps.length === 0) {
|
|
1630
|
+
logger.error(`replan #${replanIndex} produced only already-completed steps (nothing new to bridge); aborting`);
|
|
1631
|
+
throw err;
|
|
1632
|
+
}
|
|
1633
|
+
// Immediate no-progress guard: if the replan's only bridge is a
|
|
1634
|
+
// re-emission of the step that just failed, resuming re-runs the whole
|
|
1635
|
+
// cascade on the identical click that just exhausted it. Abort now
|
|
1636
|
+
// instead of waiting REPLAN_CYCLE_THRESHOLD repeats for the cycle
|
|
1637
|
+
// detector — that many dead cascades cost minutes of wall-clock.
|
|
1638
|
+
if (isReplanReproposingFailedStep(newSteps, step.instruction)) {
|
|
1639
|
+
const noProgressMessage = `replan #${replanIndex} re-proposed only the just-failed step ("${step.instruction.slice(0, 60)}") with no new bridge; resuming would re-fail identically — aborting`;
|
|
1640
|
+
logger.error(noProgressMessage);
|
|
1641
|
+
throw new errors_2.StepVerificationError(noProgressMessage, "replan-cycle-detected");
|
|
1642
|
+
}
|
|
1643
|
+
const currentPageState = await (0, flow_runner_1.snapshotPage)(page, signalCounter).catch(() => ({
|
|
1644
|
+
url: page.url(),
|
|
1645
|
+
bodyHtmlLength: 0,
|
|
1646
|
+
}));
|
|
1647
|
+
if (isReplanCycle(replanEvents, newSteps, {
|
|
1648
|
+
url: currentPageState.url,
|
|
1649
|
+
htmlLength: currentPageState.bodyHtmlLength,
|
|
1650
|
+
})) {
|
|
1651
|
+
const cycleMessage = `replan cycle detected: identical proposal × ${REPLAN_CYCLE_THRESHOLD} under static page state; aborting`;
|
|
1652
|
+
logger.error(cycleMessage);
|
|
1653
|
+
throw new errors_2.StepVerificationError(cycleMessage, "replan-cycle-detected");
|
|
1654
|
+
}
|
|
1655
|
+
if (isProbe) {
|
|
1656
|
+
probeReplansUsed++;
|
|
1657
|
+
}
|
|
1658
|
+
else {
|
|
1659
|
+
cascadeReplansUsed++;
|
|
1660
|
+
}
|
|
1661
|
+
// err.kind narrowed to the two replan-bearing variants here: the
|
|
1662
|
+
// backend-error-unrecoverable dispatcher above throws out, and the
|
|
1663
|
+
// cycle-detected variant is only constructed at the throw site just
|
|
1664
|
+
// above this push — never caught back here.
|
|
1665
|
+
replanEvents.push({
|
|
1666
|
+
replanIndex,
|
|
1667
|
+
cause: err.kind,
|
|
1668
|
+
indexAtFailure: i,
|
|
1669
|
+
failedInstruction: step.instruction,
|
|
1670
|
+
replanSteps: newSteps,
|
|
1671
|
+
timestamp: (0, date_fns_1.formatISO)(new Date()),
|
|
1672
|
+
pageState: {
|
|
1673
|
+
url: currentPageState.url,
|
|
1674
|
+
htmlLength: currentPageState.bodyHtmlLength,
|
|
1675
|
+
},
|
|
1676
|
+
});
|
|
1677
|
+
const replanPath = dumpReplanRecord({
|
|
1678
|
+
stepIndex: i,
|
|
1679
|
+
phase: currentPhase,
|
|
1680
|
+
replanIndex,
|
|
1681
|
+
completedSteps,
|
|
1682
|
+
originalRemaining: originalRemaining.map((s) => s.instruction),
|
|
1683
|
+
newRemaining: newSteps.map((s) => s.instruction),
|
|
1684
|
+
});
|
|
1685
|
+
logger.info(`replan #${replanIndex} produced ${newSteps.length} new step(s); resuming (record: ${replanPath})`);
|
|
1686
|
+
for (const [j, s] of newSteps.entries()) {
|
|
1687
|
+
logger.info(` replanned step ${j + 1}${s.optional ? " (optional)" : ""}: ${s.instruction}`);
|
|
1688
|
+
}
|
|
1689
|
+
// Prepend recovery steps before the original remaining tail — the
|
|
1690
|
+
// replanner emits bridge steps from the failure point back to where
|
|
1691
|
+
// the original flow can resume. Idempotent fills/clicks on already-
|
|
1692
|
+
// satisfied form fields cost a few seconds each but keep the rest of
|
|
1693
|
+
// the original intent (page-0 Continue, page-1 sections, resume
|
|
1694
|
+
// upload, final submit) intact instead of replacing them with the
|
|
1695
|
+
// replanner's necessarily-truncated tail (capped at REPLAN_MAX_STEPS).
|
|
1696
|
+
// Tag replan-discovered steps with origin so persistReplannedFlow
|
|
1697
|
+
// can force them optional on write-back. originalRemaining keeps its
|
|
1698
|
+
// origin: "original" — that's what protects the canonical final
|
|
1699
|
+
// submit from being silently demoted to optional across replans.
|
|
1700
|
+
const taggedNewSteps = newSteps.map((s) => ({ ...s, origin: "replan" }));
|
|
1701
|
+
plan.splice(i, plan.length - i, ...taggedNewSteps, ...originalRemaining);
|
|
1702
|
+
i--;
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
stopCapture();
|
|
1706
|
+
// End-of-run audit: when the flow declared submitEndpointPattern AND
|
|
1707
|
+
// opted into requireSubmitEndpointMatch=true, scan ALL captures from
|
|
1708
|
+
// this run for a pattern-matching 200 before declaring success. If no
|
|
1709
|
+
// match, the run "succeeded" by the verifier's lights but the actual
|
|
1710
|
+
// submission didn't land. Exit non-zero so the caller (test harness,
|
|
1711
|
+
// CI, or production runner) can distinguish silent-pass from real
|
|
1712
|
+
// success. This closes the loop the silent-pass bug exposed on 2026-
|
|
1713
|
+
// 06-09: per-step verifier accepted DOM-fallback as proof; run-level
|
|
1714
|
+
// audit catches that the network proof never actually arrived.
|
|
1715
|
+
if (requireSubmitEndpointMatch && ownBackendHostnames.length > 0) {
|
|
1716
|
+
const { auditFailed, rejectionReason } = auditFinalSubmitMatch({
|
|
1717
|
+
ownBackendHostnames,
|
|
1718
|
+
capturesDir: recon_shared_1.CAPTURES_DIR,
|
|
1719
|
+
logger,
|
|
1720
|
+
});
|
|
1721
|
+
if (auditFailed) {
|
|
1722
|
+
const reasonSuffix = rejectionReason
|
|
1723
|
+
? ` — server REJECTED submission with rejection envelope (reason: "${rejectionReason}"); HTTP layer succeeded but application was not accepted`
|
|
1724
|
+
: ` — no captured 2xx had hostname in ${JSON.stringify(ownBackendHostnames)} — submission did not land despite verifier success`;
|
|
1725
|
+
logger.error(`end-of-run audit FAILED${reasonSuffix}`);
|
|
1726
|
+
// Exit non-zero so the runner counts this as a real failure rather
|
|
1727
|
+
// than rolling silent-pass forward as success.
|
|
1728
|
+
process.exit(1);
|
|
1729
|
+
}
|
|
1730
|
+
logger.info(`end-of-run audit PASSED: at least one captured 2xx matched submitEndpointPattern with clean response body`);
|
|
1731
|
+
}
|
|
1732
|
+
logger.info(`recon complete — ${counter.n} captures written to ${recon_shared_1.CAPTURES_DIR}`);
|
|
1733
|
+
}
|
|
1734
|
+
finally {
|
|
1735
|
+
// Replay-the-discovered-path: if any replan fired and the user provided
|
|
1736
|
+
// a flow file, write the improved plan back so the next run starts
|
|
1737
|
+
// where this one ended up. Runs INSIDE finally so the cascade's
|
|
1738
|
+
// discoveries survive cascade-exhausted exits too — the persistence
|
|
1739
|
+
// mechanism is the way recon self-heals the flow across runs, so it
|
|
1740
|
+
// needs to fire on failure as much as on success. Skipped on
|
|
1741
|
+
// --no-save-replan (diagnostic dry-runs) and when --flow was used
|
|
1742
|
+
// inline (no file to write back to).
|
|
1743
|
+
if (replanEvents.length > 0) {
|
|
1744
|
+
if (!saveReplan) {
|
|
1745
|
+
logger.info(`run done with ${replanEvents.length} replan event(s); --no-save-replan, leaving flow.json unchanged`);
|
|
1746
|
+
}
|
|
1747
|
+
else if (!flowFile) {
|
|
1748
|
+
logger.info(`run done with ${replanEvents.length} replan event(s); --flow used (no file to write back to)`);
|
|
1749
|
+
}
|
|
1750
|
+
else {
|
|
1751
|
+
logger.info(`run done; writing flow.json with ${replanEvents.length} replan event(s)`);
|
|
1752
|
+
try {
|
|
1753
|
+
persistReplannedFlow({
|
|
1754
|
+
flowFile,
|
|
1755
|
+
finalPlan: plan,
|
|
1756
|
+
replanEvents,
|
|
1757
|
+
logger,
|
|
1758
|
+
originalShape,
|
|
1759
|
+
submitEndpointPattern,
|
|
1760
|
+
submittedStateSelectors,
|
|
1761
|
+
requireSubmitEndpointMatch,
|
|
1762
|
+
successUrlFragments,
|
|
1763
|
+
successPageTitleHints,
|
|
1764
|
+
ownBackendHostnames,
|
|
1765
|
+
knownErrorClassPrefixes,
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
catch (err) {
|
|
1769
|
+
// Persistence is best-effort in the finally block — a write
|
|
1770
|
+
// failure here must not eat the original cascade error.
|
|
1771
|
+
logger.error(`persistReplannedFlow threw in finally: ${(0, errors_1.toErrorMessage)(err)}`);
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
await session.close();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (process.argv[1] !== undefined &&
|
|
1779
|
+
(process.argv[1].endsWith("recon-browser.ts") || process.argv[1].endsWith("recon-browser.js"))) {
|
|
1780
|
+
main().catch((err) => {
|
|
1781
|
+
// StagehandDefaultError wraps the real cause with a verbose "Hey! We're sorry..." banner.
|
|
1782
|
+
// Unwrap it so the log shows just the meaningful error message.
|
|
1783
|
+
const message = err instanceof Error && err.cause instanceof Error ? err.cause.message : (0, errors_1.toErrorMessage)(err);
|
|
1784
|
+
logger.error(`recon-browser failed: ${message}`);
|
|
1785
|
+
process.exit(1);
|
|
1786
|
+
});
|
|
1787
|
+
}
|
|
1788
|
+
// Re-export shim: the step-execution engine now lives in `@/scraper/flow-runner`
|
|
1789
|
+
// (extracted from this module). Recon's tests and any downstream importer keep
|
|
1790
|
+
// importing these symbols from `@/scripts/recon-browser` unchanged.
|
|
1791
|
+
var flow_runner_2 = require("../scraper/flow-runner");
|
|
1792
|
+
Object.defineProperty(exports, "buildRadioIdXPath", { enumerable: true, get: function () { return flow_runner_2.buildRadioIdXPath; } });
|
|
1793
|
+
Object.defineProperty(exports, "capturesAfterIndex", { enumerable: true, get: function () { return flow_runner_2.capturesAfterIndex; } });
|
|
1794
|
+
Object.defineProperty(exports, "chooseRequiredSelectOption", { enumerable: true, get: function () { return flow_runner_2.chooseRequiredSelectOption; } });
|
|
1795
|
+
Object.defineProperty(exports, "describeAttemptEffectSignals", { enumerable: true, get: function () { return flow_runner_2.describeAttemptEffectSignals; } });
|
|
1796
|
+
Object.defineProperty(exports, "extractGaEventEvidence", { enumerable: true, get: function () { return flow_runner_2.extractGaEventEvidence; } });
|
|
1797
|
+
Object.defineProperty(exports, "extractSubmitFailureEvidence", { enumerable: true, get: function () { return flow_runner_2.extractSubmitFailureEvidence; } });
|
|
1798
|
+
Object.defineProperty(exports, "fillHtml5DateTimeInput", { enumerable: true, get: function () { return flow_runner_2.fillHtml5DateTimeInput; } });
|
|
1799
|
+
Object.defineProperty(exports, "findRecentBackendError", { enumerable: true, get: function () { return flow_runner_2.findRecentBackendError; } });
|
|
1800
|
+
Object.defineProperty(exports, "findRecentPageTransition", { enumerable: true, get: function () { return flow_runner_2.findRecentPageTransition; } });
|
|
1801
|
+
Object.defineProperty(exports, "formatValidationRejectedReason", { enumerable: true, get: function () { return flow_runner_2.formatValidationRejectedReason; } });
|
|
1802
|
+
Object.defineProperty(exports, "hasBillingErrorBeenLogged", { enumerable: true, get: function () { return flow_runner_2.hasBillingErrorBeenLogged; } });
|
|
1803
|
+
Object.defineProperty(exports, "isAdvanceStalled", { enumerable: true, get: function () { return flow_runner_2.isAdvanceStalled; } });
|
|
1804
|
+
Object.defineProperty(exports, "isAdvanceStep", { enumerable: true, get: function () { return flow_runner_2.isAdvanceStep; } });
|
|
1805
|
+
Object.defineProperty(exports, "isDomOnlyAdvanceVerified", { enumerable: true, get: function () { return flow_runner_2.isDomOnlyAdvanceVerified; } });
|
|
1806
|
+
Object.defineProperty(exports, "isSubmitRevealedInvalid", { enumerable: true, get: function () { return flow_runner_2.isSubmitRevealedInvalid; } });
|
|
1807
|
+
Object.defineProperty(exports, "isUploadAffordanceLabel", { enumerable: true, get: function () { return flow_runner_2.isUploadAffordanceLabel; } });
|
|
1808
|
+
Object.defineProperty(exports, "isWizardExitAction", { enumerable: true, get: function () { return flow_runner_2.isWizardExitAction; } });
|
|
1809
|
+
Object.defineProperty(exports, "latestCaptureIndex", { enumerable: true, get: function () { return flow_runner_2.latestCaptureIndex; } });
|
|
1810
|
+
Object.defineProperty(exports, "logBillingErrorIfPresent", { enumerable: true, get: function () { return flow_runner_2.logBillingErrorIfPresent; } });
|
|
1811
|
+
Object.defineProperty(exports, "narrowInvalidFormControl", { enumerable: true, get: function () { return flow_runner_2.narrowInvalidFormControl; } });
|
|
1812
|
+
Object.defineProperty(exports, "normalizeDateValue", { enumerable: true, get: function () { return flow_runner_2.normalizeDateValue; } });
|
|
1813
|
+
Object.defineProperty(exports, "pairInvalidWithErrors", { enumerable: true, get: function () { return flow_runner_2.pairInvalidWithErrors; } });
|
|
1814
|
+
Object.defineProperty(exports, "parseCaptureIndex", { enumerable: true, get: function () { return flow_runner_2.parseCaptureIndex; } });
|
|
1815
|
+
Object.defineProperty(exports, "parseRadioStep", { enumerable: true, get: function () { return flow_runner_2.parseRadioStep; } });
|
|
1816
|
+
Object.defineProperty(exports, "parseSelectStep", { enumerable: true, get: function () { return flow_runner_2.parseSelectStep; } });
|
|
1817
|
+
Object.defineProperty(exports, "pollEnumerate", { enumerable: true, get: function () { return flow_runner_2.pollEnumerate; } });
|
|
1818
|
+
Object.defineProperty(exports, "probeLeafInvalidContainers", { enumerable: true, get: function () { return flow_runner_2.probeLeafInvalidContainers; } });
|
|
1819
|
+
Object.defineProperty(exports, "probeStepBeforeAttempts", { enumerable: true, get: function () { return flow_runner_2.probeStepBeforeAttempts; } });
|
|
1820
|
+
Object.defineProperty(exports, "renderLeafInvalidFields", { enumerable: true, get: function () { return flow_runner_2.renderLeafInvalidFields; } });
|
|
1821
|
+
Object.defineProperty(exports, "renderUnfocusedObserve", { enumerable: true, get: function () { return flow_runner_2.renderUnfocusedObserve; } });
|
|
1822
|
+
Object.defineProperty(exports, "rephraseWithLLM", { enumerable: true, get: function () { return flow_runner_2.rephraseWithLLM; } });
|
|
1823
|
+
Object.defineProperty(exports, "resetBillingErrorFlagForTests", { enumerable: true, get: function () { return flow_runner_2.resetBillingErrorFlagForTests; } });
|
|
1824
|
+
Object.defineProperty(exports, "selectBodyExcerpt", { enumerable: true, get: function () { return flow_runner_2.selectBodyExcerpt; } });
|
|
1825
|
+
Object.defineProperty(exports, "selectRadioGroupOption", { enumerable: true, get: function () { return flow_runner_2.selectRadioGroupOption; } });
|
|
1826
|
+
Object.defineProperty(exports, "shouldSkipTechnique", { enumerable: true, get: function () { return flow_runner_2.shouldSkipTechnique; } });
|
|
1827
|
+
Object.defineProperty(exports, "shouldVetoFallbackAdvance", { enumerable: true, get: function () { return flow_runner_2.shouldVetoFallbackAdvance; } });
|
|
1828
|
+
Object.defineProperty(exports, "verifyFillReadback", { enumerable: true, get: function () { return flow_runner_2.verifyFillReadback; } });
|
|
1829
|
+
Object.defineProperty(exports, "waitForTransitionBody", { enumerable: true, get: function () { return flow_runner_2.waitForTransitionBody; } });
|
|
1830
|
+
Object.defineProperty(exports, "windowHasAdvanceTransition", { enumerable: true, get: function () { return flow_runner_2.windowHasAdvanceTransition; } });
|
|
1831
|
+
Object.defineProperty(exports, "windowHasTransitionBody", { enumerable: true, get: function () { return flow_runner_2.windowHasTransitionBody; } });
|
|
1832
|
+
Object.defineProperty(exports, "writeFixtureToTempFile", { enumerable: true, get: function () { return flow_runner_2.writeFixtureToTempFile; } });
|
|
1833
|
+
//# sourceMappingURL=recon-browser.js.map
|