autokap 1.0.6 → 1.0.8
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/assets/chrome/ios-statusbar-comparison-reference.jpg +0 -0
- package/assets/chrome/ios-statusbar-dark-reference.jpg +0 -0
- package/assets/chrome/ios-statusbar-light-reference.jpg +0 -0
- package/assets/cursors/macos.svg +4 -0
- package/assets/cursors/windows.svg +15 -0
- package/assets/devices/ipad-pro-11-m4.json +52 -0
- package/assets/devices/iphone-16-pro.json +53 -0
- package/assets/devices/macbook-air-13.json +45 -0
- package/assets/frames/MacBook Air 13.svg +242 -0
- package/assets/frames/Status bar - iPhone.png +0 -0
- Menu bar- iPad.png +0 -0
- package/assets/frames/iPad Pro M4 11_.png +0 -0
- package/assets/frames/iPhone 16 Pro.png +0 -0
- package/assets/icons/Cellular Connection.svg +3 -0
- package/assets/icons/Union.svg +6 -0
- package/assets/icons/Wifi.svg +3 -0
- package/assets/icons/battery.svg +5 -0
- package/assets/icons/battery_charging.svg +8 -0
- package/assets/skill/OPCODE-REFERENCE.md +607 -0
- package/assets/skill/README.md +39 -0
- package/assets/skill/SKILL.md +453 -468
- package/assets/skill/STUDIO-SKILL.md +476 -0
- package/assets/skill/references/examples.md +104 -0
- package/assets/skill/references/interactive-demo.md +225 -0
- package/assets/skill/references/mock-data.md +178 -0
- package/dist/abort.d.ts +5 -0
- package/dist/abort.js +44 -0
- package/dist/action-verifier.d.ts +29 -0
- package/dist/action-verifier.js +133 -0
- package/dist/agent-action-recovery.d.ts +45 -0
- package/dist/agent-action-recovery.js +370 -0
- package/dist/agent-message-utils.d.ts +21 -0
- package/dist/agent-message-utils.js +77 -0
- package/dist/agent-url-utils.d.ts +30 -0
- package/dist/agent-url-utils.js +138 -0
- package/dist/agent.d.ts +226 -0
- package/dist/agent.js +6666 -0
- package/dist/ak-tree.d.ts +39 -0
- package/dist/ak-tree.js +368 -0
- package/dist/alt-text.d.ts +26 -0
- package/dist/alt-text.js +55 -0
- package/dist/auth-capture.d.ts +17 -0
- package/dist/auth-capture.js +164 -0
- package/dist/benchmark.d.ts +59 -0
- package/dist/benchmark.js +135 -0
- package/dist/billing-operation-logging.d.ts +38 -0
- package/dist/billing-operation-logging.js +248 -0
- package/dist/browser-bar.d.ts +48 -0
- package/dist/browser-bar.js +284 -0
- package/dist/browser-pool.d.ts +7 -0
- package/dist/browser-pool.js +15 -5
- package/dist/browser-utils.d.ts +31 -0
- package/dist/browser-utils.js +97 -0
- package/dist/browser.d.ts +76 -1
- package/dist/browser.js +1657 -39
- package/dist/capture-alt-text.d.ts +12 -0
- package/dist/capture-alt-text.js +52 -0
- package/dist/capture-encryption.d.ts +10 -0
- package/dist/capture-encryption.js +41 -0
- package/dist/capture-language-preflight.d.ts +41 -0
- package/dist/capture-language-preflight.js +300 -0
- package/dist/capture-llm-page-identity.d.ts +15 -0
- package/dist/capture-llm-page-identity.js +128 -0
- package/dist/capture-model-resolution.d.ts +9 -0
- package/dist/capture-model-resolution.js +21 -0
- package/dist/capture-page-identity.d.ts +7 -0
- package/dist/capture-page-identity.js +352 -0
- package/dist/capture-preset-credentials.d.ts +62 -0
- package/dist/capture-preset-credentials.js +184 -0
- package/dist/capture-request-plan.d.ts +58 -0
- package/dist/capture-request-plan.js +264 -0
- package/dist/capture-run-optimizer.d.ts +139 -0
- package/dist/capture-run-optimizer.js +863 -0
- package/dist/capture-selector-memory.d.ts +31 -0
- package/dist/capture-selector-memory.js +345 -0
- package/dist/capture-session-profile-encryption.d.ts +2 -0
- package/dist/capture-session-profile-encryption.js +22 -0
- package/dist/capture-step-timeout.d.ts +10 -0
- package/dist/capture-step-timeout.js +30 -0
- package/dist/capture-strategy.d.ts +36 -0
- package/dist/capture-strategy.js +95 -0
- package/dist/capture-studio-sync.d.ts +23 -0
- package/dist/capture-studio-sync.js +172 -0
- package/dist/capture-surface-contract.d.ts +36 -0
- package/dist/capture-surface-contract.js +299 -0
- package/dist/capture-transition-engine.d.ts +28 -0
- package/dist/capture-transition-engine.js +292 -0
- package/dist/capture-variant-state.d.ts +56 -0
- package/dist/capture-variant-state.js +182 -0
- package/dist/capture-verification.d.ts +35 -0
- package/dist/capture-verification.js +95 -0
- package/dist/capture-viewport-lock.d.ts +48 -0
- package/dist/capture-viewport-lock.js +74 -0
- package/dist/circuit-breaker.d.ts +42 -0
- package/dist/circuit-breaker.js +119 -0
- package/dist/cli-config.d.ts +8 -1
- package/dist/cli-config.js +62 -6
- package/dist/cli-contract.d.ts +15 -0
- package/dist/cli-contract.js +167 -0
- package/dist/cli-runner-local.d.ts +12 -0
- package/dist/cli-runner-local.js +102 -0
- package/dist/cli-runner.d.ts +34 -0
- package/dist/cli-runner.js +433 -0
- package/dist/cli-utils.d.ts +0 -1
- package/dist/cli-utils.js +2 -5
- package/dist/cli.js +1005 -252
- package/dist/clip-orchestrator.d.ts +148 -0
- package/dist/clip-orchestrator.js +957 -0
- package/dist/clip-postprocess.d.ts +42 -0
- package/dist/clip-postprocess.js +201 -0
- package/dist/cookie-dismiss.d.ts +2 -0
- package/dist/cookie-dismiss.js +48 -13
- package/dist/cost-logging.d.ts +35 -0
- package/dist/cost-logging.js +242 -0
- package/dist/cost-resolution-monitor.d.ts +16 -0
- package/dist/cost-resolution-monitor.js +34 -0
- package/dist/credential-templates.d.ts +5 -0
- package/dist/credential-templates.js +60 -0
- package/dist/cursor-overlay-script.d.ts +6 -0
- package/dist/cursor-overlay-script.js +169 -0
- package/dist/dom-css-purger.d.ts +65 -0
- package/dist/dom-css-purger.js +333 -0
- package/dist/dom-font-inliner.d.ts +45 -0
- package/dist/dom-font-inliner.js +148 -0
- package/dist/dom-patch-resolver.d.ts +52 -0
- package/dist/dom-patch-resolver.js +242 -0
- package/dist/dom-serializer.d.ts +82 -0
- package/dist/dom-serializer.js +378 -0
- package/dist/element-capture.d.ts +13 -0
- package/dist/element-capture.js +522 -0
- package/dist/env-validation.d.ts +5 -0
- package/dist/env-validation.js +29 -0
- package/dist/execution-schema.d.ts +4423 -0
- package/dist/execution-schema.js +507 -0
- package/dist/execution-types.d.ts +886 -0
- package/dist/execution-types.js +65 -0
- package/dist/fonts-loader.d.ts +14 -0
- package/dist/fonts-loader.js +55 -0
- package/dist/hybrid-navigator.d.ts +138 -0
- package/dist/hybrid-navigator.js +468 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +17 -0
- package/dist/legacy/agent-action-recovery.d.ts +45 -0
- package/dist/legacy/agent-action-recovery.js +370 -0
- package/dist/legacy/agent-message-utils.d.ts +21 -0
- package/dist/legacy/agent-message-utils.js +77 -0
- package/dist/legacy/agent-url-utils.d.ts +30 -0
- package/dist/legacy/agent-url-utils.js +138 -0
- package/dist/legacy/agent.d.ts +226 -0
- package/dist/legacy/agent.js +6666 -0
- package/dist/legacy/clip-orchestrator.d.ts +148 -0
- package/dist/legacy/clip-orchestrator.js +957 -0
- package/dist/legacy/credential-templates.d.ts +5 -0
- package/dist/legacy/credential-templates.js +60 -0
- package/dist/legacy/hybrid-navigator.d.ts +138 -0
- package/dist/legacy/hybrid-navigator.js +468 -0
- package/dist/legacy/llm-usage.d.ts +17 -0
- package/dist/legacy/llm-usage.js +45 -0
- package/dist/legacy/prompt-cache.d.ts +10 -0
- package/dist/legacy/prompt-cache.js +24 -0
- package/dist/legacy/prompts.d.ts +175 -0
- package/dist/legacy/prompts.js +1038 -0
- package/dist/legacy/tools.d.ts +4 -0
- package/dist/legacy/tools.js +216 -0
- package/dist/legacy/video-agent.d.ts +143 -0
- package/dist/legacy/video-agent.js +4788 -0
- package/dist/legacy/video-observation.d.ts +36 -0
- package/dist/legacy/video-observation.js +192 -0
- package/dist/legacy/video-planner.d.ts +12 -0
- package/dist/legacy/video-planner.js +501 -0
- package/dist/legacy/video-prompts.d.ts +37 -0
- package/dist/legacy/video-prompts.js +569 -0
- package/dist/legacy/video-tools.d.ts +3 -0
- package/dist/legacy/video-tools.js +59 -0
- package/dist/legacy/video-variant-state.d.ts +29 -0
- package/dist/legacy/video-variant-state.js +80 -0
- package/dist/legacy/vision-model.d.ts +17 -0
- package/dist/legacy/vision-model.js +74 -0
- package/dist/llm-healer.d.ts +63 -0
- package/dist/llm-healer.js +166 -0
- package/dist/llm-provider.d.ts +29 -0
- package/dist/llm-provider.js +80 -0
- package/dist/llm-usage.d.ts +17 -0
- package/dist/llm-usage.js +45 -0
- package/dist/logger.d.ts +6 -2
- package/dist/logger.js +15 -1
- package/dist/mockup-html.d.ts +119 -0
- package/dist/mockup-html.js +263 -0
- package/dist/mockup.d.ts +187 -0
- package/dist/mockup.js +869 -0
- package/dist/mouse-animation.d.ts +46 -0
- package/dist/mouse-animation.js +114 -0
- package/dist/opcode-actions.d.ts +42 -0
- package/dist/opcode-actions.js +511 -0
- package/dist/opcode-runner.d.ts +51 -0
- package/dist/opcode-runner.js +770 -0
- package/dist/openrouter-client.d.ts +40 -0
- package/dist/openrouter-client.js +16 -0
- package/dist/overlay-engine.d.ts +24 -0
- package/dist/overlay-engine.js +176 -0
- package/dist/overlay-utils.d.ts +14 -0
- package/dist/overlay-utils.js +13 -0
- package/dist/postcondition.d.ts +16 -0
- package/dist/postcondition.js +269 -0
- package/dist/posthog.d.ts +4 -0
- package/dist/posthog.js +26 -0
- package/dist/program-patcher.d.ts +25 -0
- package/dist/program-patcher.js +44 -0
- package/dist/prompt-cache.d.ts +10 -0
- package/dist/prompt-cache.js +24 -0
- package/dist/prompts.d.ts +175 -0
- package/dist/prompts.js +1038 -0
- package/dist/provider-config.d.ts +12 -0
- package/dist/provider-config.js +15 -0
- package/dist/recovery-chain.d.ts +37 -0
- package/dist/recovery-chain.js +350 -0
- package/dist/remote-browser.d.ts +215 -0
- package/dist/remote-browser.js +360 -0
- package/dist/safari-browser-bar.d.ts +15 -0
- package/dist/safari-browser-bar.js +95 -0
- package/dist/safari-toolbar-asset.d.ts +15 -0
- package/dist/safari-toolbar-asset.js +12 -0
- package/dist/security.d.ts +21 -0
- package/dist/security.js +608 -0
- package/dist/selector-resolver.d.ts +34 -0
- package/dist/selector-resolver.js +181 -0
- package/dist/semantic-resolver.d.ts +35 -0
- package/dist/semantic-resolver.js +161 -0
- package/dist/server-capture-runtime.d.ts +125 -0
- package/dist/server-capture-runtime.js +585 -0
- package/dist/server-credit-usage.d.ts +12 -0
- package/dist/server-credit-usage.js +41 -0
- package/dist/server-posthog.d.ts +2 -0
- package/dist/server-posthog.js +16 -0
- package/dist/server-project-webhooks.d.ts +59 -0
- package/dist/server-project-webhooks.js +123 -0
- package/dist/server-screenshot-watermark.d.ts +7 -0
- package/dist/server-screenshot-watermark.js +60 -0
- package/dist/session-profile.d.ts +86 -0
- package/dist/session-profile.js +1536 -0
- package/dist/sf-pro-fonts.d.ts +4 -0
- package/dist/sf-pro-fonts.js +7 -0
- package/dist/sf-pro-symbols.d.ts +1 -0
- package/dist/sf-pro-symbols.js +55 -0
- package/dist/skill-packaging.d.ts +28 -0
- package/dist/skill-packaging.js +169 -0
- package/dist/smart-wait.d.ts +27 -0
- package/dist/smart-wait.js +81 -0
- package/dist/status-bar-l10n.d.ts +14 -0
- package/dist/status-bar-l10n.js +177 -0
- package/dist/status-bar-render.d.ts +20 -0
- package/dist/status-bar-render.js +410 -0
- package/dist/status-bar.d.ts +53 -0
- package/dist/status-bar.js +620 -0
- package/dist/svg-browser-bar.d.ts +33 -0
- package/dist/svg-browser-bar.js +206 -0
- package/dist/svg-status-bar.d.ts +36 -0
- package/dist/svg-status-bar.js +597 -0
- package/dist/svg-text.d.ts +61 -0
- package/dist/svg-text.js +118 -0
- package/dist/tools.d.ts +4 -0
- package/dist/tools.js +216 -0
- package/dist/types.d.ts +240 -5
- package/dist/types.js +23 -1
- package/dist/v2/action-verifier.d.ts +29 -0
- package/dist/v2/action-verifier.js +133 -0
- package/dist/v2/alt-text.d.ts +26 -0
- package/dist/v2/alt-text.js +55 -0
- package/dist/v2/benchmark.d.ts +59 -0
- package/dist/v2/benchmark.js +135 -0
- package/dist/v2/capture-strategy.d.ts +30 -0
- package/dist/v2/capture-strategy.js +67 -0
- package/dist/v2/capture-verification.d.ts +35 -0
- package/dist/v2/capture-verification.js +95 -0
- package/dist/v2/circuit-breaker.d.ts +42 -0
- package/dist/v2/circuit-breaker.js +119 -0
- package/dist/v2/cli-runner-local.d.ts +11 -0
- package/dist/v2/cli-runner-local.js +91 -0
- package/dist/v2/cli-runner.d.ts +34 -0
- package/dist/v2/cli-runner.js +300 -0
- package/dist/v2/compiler-prompts.d.ts +27 -0
- package/dist/v2/compiler-prompts.js +123 -0
- package/dist/v2/compiler.d.ts +37 -0
- package/dist/v2/compiler.js +147 -0
- package/dist/v2/explorer.d.ts +41 -0
- package/dist/v2/explorer.js +56 -0
- package/dist/v2/index.d.ts +37 -0
- package/dist/v2/index.js +31 -0
- package/dist/v2/llm-healer.d.ts +62 -0
- package/dist/v2/llm-healer.js +166 -0
- package/dist/v2/llm-provider.d.ts +29 -0
- package/dist/v2/llm-provider.js +80 -0
- package/dist/v2/opcode-runner.d.ts +47 -0
- package/dist/v2/opcode-runner.js +634 -0
- package/dist/v2/overlay-engine.d.ts +24 -0
- package/dist/v2/overlay-engine.js +150 -0
- package/dist/v2/postcondition.d.ts +16 -0
- package/dist/v2/postcondition.js +249 -0
- package/dist/v2/program-patcher.d.ts +25 -0
- package/dist/v2/program-patcher.js +44 -0
- package/dist/v2/recovery-chain.d.ts +30 -0
- package/dist/v2/recovery-chain.js +368 -0
- package/dist/v2/schema.d.ts +2580 -0
- package/dist/v2/schema.js +295 -0
- package/dist/v2/selector-resolver.d.ts +34 -0
- package/dist/v2/selector-resolver.js +181 -0
- package/dist/v2/semantic-resolver.d.ts +35 -0
- package/dist/v2/semantic-resolver.js +161 -0
- package/dist/v2/smart-wait.d.ts +27 -0
- package/dist/v2/smart-wait.js +81 -0
- package/dist/v2/types.d.ts +444 -0
- package/dist/v2/types.js +19 -0
- package/dist/v2/web-playwright-local.d.ts +69 -0
- package/dist/v2/web-playwright-local.js +392 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +5 -0
- package/dist/video-agent.d.ts +143 -0
- package/dist/video-agent.js +4788 -0
- package/dist/video-observation.d.ts +36 -0
- package/dist/video-observation.js +192 -0
- package/dist/video-planner.d.ts +12 -0
- package/dist/video-planner.js +501 -0
- package/dist/video-prompts.d.ts +37 -0
- package/dist/video-prompts.js +554 -0
- package/dist/video-tools.d.ts +3 -0
- package/dist/video-tools.js +59 -0
- package/dist/video-variant-state.d.ts +29 -0
- package/dist/video-variant-state.js +80 -0
- package/dist/vision-model.d.ts +17 -0
- package/dist/vision-model.js +74 -0
- package/dist/web-playwright-local.d.ts +126 -0
- package/dist/web-playwright-local.js +819 -0
- package/dist/ws-auth.d.ts +20 -0
- package/dist/ws-auth.js +70 -0
- package/dist/ws-broadcast.d.ts +34 -0
- package/dist/ws-broadcast.js +85 -0
- package/dist/ws-connection-limits.d.ts +12 -0
- package/dist/ws-connection-limits.js +44 -0
- package/dist/ws-handler-utils.d.ts +32 -0
- package/dist/ws-handler-utils.js +139 -0
- package/dist/ws-handler.d.ts +10 -0
- package/dist/ws-handler.js +1793 -0
- package/dist/ws-metrics-server.d.ts +9 -0
- package/dist/ws-metrics-server.js +31 -0
- package/dist/ws-server.d.ts +9 -0
- package/dist/ws-server.js +92 -0
- package/package.json +142 -71
|
@@ -0,0 +1,1793 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WS handler — orchestrates a capture run for a single CLI connection.
|
|
3
|
+
*
|
|
4
|
+
* The CLI stays a thin RPC adapter around a local Playwright browser.
|
|
5
|
+
* The orchestration stays server-side and reuses shared capture helpers
|
|
6
|
+
* so the WS flow does not drift from the HTTP capture flow.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { chromium } from 'playwright';
|
|
12
|
+
import { replayAgent, runAgent, verifyCaptureReadiness } from './agent.js';
|
|
13
|
+
import { performDeterministicSessionRepair } from './session-profile.js';
|
|
14
|
+
import { captureIsolatedElement } from './element-capture.js';
|
|
15
|
+
import { getCapturePromptValidationError, getCaptureUrlHostname, resolveCaptureRequestPlan, sanitizeStorageSegment, } from './capture-request-plan.js';
|
|
16
|
+
import { lockCaptureViewport, takeViewportLockedScreenshot, } from './capture-viewport-lock.js';
|
|
17
|
+
import { CHROMIUM_ARGS } from './browser-pool.js';
|
|
18
|
+
import { insertCostLogs, resolveAllCosts, updateCostLogCaptureContext, } from './cost-logging.js';
|
|
19
|
+
import { insertScreenshotOperationLog, reconcilePendingBillingOperationCosts, } from './billing-operation-logging.js';
|
|
20
|
+
import { generateAltText } from './capture-alt-text.js';
|
|
21
|
+
import { ensureScreenshotVariantLanguage } from './capture-language-preflight.js';
|
|
22
|
+
import { inferPageIdentitiesWithLLM } from './capture-llm-page-identity.js';
|
|
23
|
+
import { resolveRunModels } from './capture-model-resolution.js';
|
|
24
|
+
import { decideCaptureTransition } from './capture-transition-engine.js';
|
|
25
|
+
import { extractSelectorUpdates, loadScreenshotSelectorMemory, persistScreenshotSelectorMemoryUpdates, } from './capture-selector-memory.js';
|
|
26
|
+
import { hydratePresetConfigFromStorage } from './capture-preset-credentials.js';
|
|
27
|
+
import { applySelectorMemoryUpdates, buildSessionBootstrapProfile, deriveRunSharedAuthProfile, getUrlOrigin, mergeSelectorMemory, resolveIsolatedElementAssignments, resolveScopedSelectorMemory, scopeSelectorMemoryUpdates, shouldNormalizeDialogsBeforeTargetReuse, urlMatchesCaptureTarget, } from './capture-run-optimizer.js';
|
|
28
|
+
import { decryptSessionField, encryptSessionField, } from './capture-session-profile-encryption.js';
|
|
29
|
+
import { buildVariantManifestContext, createVariantCaptureState, markVariantCaptureBlocked, markVariantCaptureInProgress, recordValidatedVariantCapture, validateVariantCaptureState, } from './capture-variant-state.js';
|
|
30
|
+
import { syncStudioVariantAfterCapture } from './capture-studio-sync.js';
|
|
31
|
+
import { applyDeviceFrame, invalidateDeviceConfigCache } from './mockup.js';
|
|
32
|
+
import { localizeStatusBar } from './status-bar-l10n.js';
|
|
33
|
+
import { logger, runWithLoggerCallbacks } from './logger.js';
|
|
34
|
+
import { createCaptureBroadcast } from './ws-broadcast.js';
|
|
35
|
+
import { APP_VERSION } from './version.js';
|
|
36
|
+
import { isTerminalAgentResultSuccess, isTerminalVerificationSuccess, } from './types.js';
|
|
37
|
+
import { buildHandoffContext, buildPersistableProfile, buildPreverifiedAgentResult, loadRunHints, makeFailedAgentResult, restorePreparedPageState, } from './ws-handler-utils.js';
|
|
38
|
+
import { getRemoteBrowserCompatibilityError, RemoteBrowser, } from './remote-browser.js';
|
|
39
|
+
import { recordCreditUsage, revokeCreditUsage } from './server-credit-usage.js';
|
|
40
|
+
import { cleanupExpiredCapturesForOwner, ensureCaptureConfigAllowed, ensureMonthlyCreditsQuota, ensureResourceNotLocked, getBillingAccountForUser, getCaptureOverageState, getBillingPlan, getIncrementalOverageCount, getLockedResourceIds, getOwnerBillingUsage, getProjectOwnerBillingContext, getRemainingOverageCredits, getSignupBonusCredits, getStripeOveragePriceIdForPlan, isYearlySubscription, PlanLimitError, recordStripeMeterEvent, SCREENSHOT_CREDIT_COST, shouldUseStripePlan, } from './server-capture-runtime.js';
|
|
41
|
+
import { buildCaptureWebhookPayload, dispatchProjectCaptureWebhook, getProjectWebhookConfig, } from './server-project-webhooks.js';
|
|
42
|
+
import { getServerPostHog } from './server-posthog.js';
|
|
43
|
+
import { applyPlanScreenshotWatermark } from './server-screenshot-watermark.js';
|
|
44
|
+
import { validateApiKey, requireScope, getSupabase } from './ws-auth.js';
|
|
45
|
+
import { getUserConnectionCount, trackUserConnection, untrackUserConnection, } from './ws-connection-limits.js';
|
|
46
|
+
const BUCKET = 'screenshots';
|
|
47
|
+
function getOpenRouterKey() {
|
|
48
|
+
const key = process.env.OPENROUTER_API_KEY ?? '';
|
|
49
|
+
if (!key)
|
|
50
|
+
logger.error('OPENROUTER_API_KEY is not set');
|
|
51
|
+
return key;
|
|
52
|
+
}
|
|
53
|
+
async function waitForClientHello(ws, timeoutMs = 3_000) {
|
|
54
|
+
return await new Promise((resolve) => {
|
|
55
|
+
let settled = false;
|
|
56
|
+
let timer = setTimeout(() => {
|
|
57
|
+
settled = true;
|
|
58
|
+
cleanup();
|
|
59
|
+
resolve(null);
|
|
60
|
+
}, timeoutMs);
|
|
61
|
+
const cleanup = () => {
|
|
62
|
+
if (timer) {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
timer = null;
|
|
65
|
+
}
|
|
66
|
+
ws.off('message', onMessage);
|
|
67
|
+
ws.off('close', onClose);
|
|
68
|
+
ws.off('error', onClose);
|
|
69
|
+
};
|
|
70
|
+
const onClose = () => {
|
|
71
|
+
if (settled)
|
|
72
|
+
return;
|
|
73
|
+
settled = true;
|
|
74
|
+
cleanup();
|
|
75
|
+
resolve(null);
|
|
76
|
+
};
|
|
77
|
+
const onMessage = (data) => {
|
|
78
|
+
if (settled)
|
|
79
|
+
return;
|
|
80
|
+
try {
|
|
81
|
+
const raw = typeof data === 'string' ? data : data.toString('utf-8');
|
|
82
|
+
const parsed = JSON.parse(raw);
|
|
83
|
+
if (parsed.type !== 'client_hello' || !parsed.client)
|
|
84
|
+
return;
|
|
85
|
+
settled = true;
|
|
86
|
+
cleanup();
|
|
87
|
+
resolve({ client: parsed.client, apiKey: parsed.apiKey });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Ignore non-JSON or non-hello messages until timeout.
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
ws.on('message', onMessage);
|
|
94
|
+
ws.on('close', onClose);
|
|
95
|
+
ws.on('error', onClose);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export async function handleConnection(ws, req) {
|
|
99
|
+
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
100
|
+
const presetId = url.searchParams.get('preset_id') ?? '';
|
|
101
|
+
const sendEvent = (type, data) => {
|
|
102
|
+
if (ws.readyState === ws.OPEN) {
|
|
103
|
+
ws.send(JSON.stringify({ type, ...data }));
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
// Wait for client_hello which now carries the API key (protocol v3+).
|
|
107
|
+
// Fall back to query param for older CLIs during transition.
|
|
108
|
+
const clientHello = await waitForClientHello(ws);
|
|
109
|
+
const rawKey = clientHello?.apiKey ?? url.searchParams.get('key') ?? '';
|
|
110
|
+
const auth = await validateApiKey(rawKey);
|
|
111
|
+
if (!auth) {
|
|
112
|
+
sendEvent('error', { message: 'Invalid API key' });
|
|
113
|
+
ws.close(4001, 'Invalid API key');
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
requireScope(auth.scopes, 'presets:execute');
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
sendEvent('error', { message: 'Missing scope: presets:execute' });
|
|
121
|
+
ws.close(4003, 'Forbidden');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!presetId) {
|
|
125
|
+
sendEvent('error', { message: 'Missing preset_id parameter' });
|
|
126
|
+
ws.close(4000, 'Missing preset_id');
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const supabase = getSupabase();
|
|
130
|
+
const { data: preset, error: presetError } = await supabase
|
|
131
|
+
.from('presets')
|
|
132
|
+
.select('id, project_id, name, config')
|
|
133
|
+
.eq('id', presetId)
|
|
134
|
+
.single();
|
|
135
|
+
if (presetError || !preset) {
|
|
136
|
+
sendEvent('error', { message: 'Preset not found' });
|
|
137
|
+
ws.close(4004, 'Preset not found');
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const { data: project, error: projectError } = await supabase
|
|
141
|
+
.from('projects')
|
|
142
|
+
.select('id, url, name, user_id')
|
|
143
|
+
.eq('id', preset.project_id)
|
|
144
|
+
.single();
|
|
145
|
+
if (projectError || !project) {
|
|
146
|
+
sendEvent('error', { message: 'Project not found' });
|
|
147
|
+
ws.close(4004, 'Project not found');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (project.user_id !== auth.userId) {
|
|
151
|
+
sendEvent('error', { message: 'Not authorized to run this preset' });
|
|
152
|
+
ws.close(4003, 'Forbidden');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const clientInfo = clientHello?.client ?? null;
|
|
156
|
+
const compatibilityError = getRemoteBrowserCompatibilityError(clientInfo);
|
|
157
|
+
if (compatibilityError) {
|
|
158
|
+
sendEvent('error', { message: compatibilityError });
|
|
159
|
+
ws.close(4000, 'CLI update required');
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
logger.info(`[capture] cli-version: ${clientInfo?.version ?? 'unknown'}, app-version: ${APP_VERSION}`);
|
|
163
|
+
const config = hydratePresetConfigFromStorage(preset.config);
|
|
164
|
+
const projectUrl = String(project.url ?? '');
|
|
165
|
+
const planInput = {
|
|
166
|
+
...config,
|
|
167
|
+
url: projectUrl,
|
|
168
|
+
prompt: String(config.prompt ?? ''),
|
|
169
|
+
};
|
|
170
|
+
const promptValidationError = getCapturePromptValidationError(planInput);
|
|
171
|
+
if (promptValidationError) {
|
|
172
|
+
sendEvent('error', { message: promptValidationError });
|
|
173
|
+
ws.close(4000, 'Invalid capture prompt config');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
invalidateDeviceConfigCache();
|
|
177
|
+
const { pageRuns: pages, langs, themes, variantPlan, targets, elements, outputScale, totalCaptures, } = await resolveCaptureRequestPlan(planInput);
|
|
178
|
+
sendEvent('progress', { message: `Loaded preset "${preset.name}" for ${projectUrl}` });
|
|
179
|
+
let resolvedModel = '';
|
|
180
|
+
let resolvedFallback;
|
|
181
|
+
let resolvedVisionModel;
|
|
182
|
+
let enableGeminiExplicitCache = false;
|
|
183
|
+
let enableCacheLayoutV2 = false;
|
|
184
|
+
let useVisionModelForAuxTasks = false;
|
|
185
|
+
const providerPreferences = {};
|
|
186
|
+
try {
|
|
187
|
+
const { data: cfgRows } = await supabase
|
|
188
|
+
.from('app_config')
|
|
189
|
+
.select('key, value')
|
|
190
|
+
.in('key', [
|
|
191
|
+
'default_model', 'fallback_model', 'vision_model',
|
|
192
|
+
'default_model_provider', 'fallback_model_provider', 'vision_model_provider',
|
|
193
|
+
'enable_gemini_explicit_cache', 'enable_cache_layout_v2', 'use_vision_model_for_aux_tasks',
|
|
194
|
+
]);
|
|
195
|
+
const cfg = {};
|
|
196
|
+
for (const row of cfgRows ?? [])
|
|
197
|
+
cfg[row.key] = row.value;
|
|
198
|
+
const resolved = resolveRunModels({
|
|
199
|
+
requestModel: config.model ?? null,
|
|
200
|
+
defaultModel: cfg.default_model,
|
|
201
|
+
fallbackModel: cfg.fallback_model,
|
|
202
|
+
presetId,
|
|
203
|
+
});
|
|
204
|
+
resolvedModel = resolved.model;
|
|
205
|
+
resolvedFallback = resolved.fallbackModel;
|
|
206
|
+
resolvedVisionModel = cfg.vision_model?.trim() || undefined;
|
|
207
|
+
for (const [cfgKey, modelVal] of [
|
|
208
|
+
['default_model_provider', resolvedModel],
|
|
209
|
+
['fallback_model_provider', resolvedFallback],
|
|
210
|
+
['vision_model_provider', resolvedVisionModel],
|
|
211
|
+
]) {
|
|
212
|
+
try {
|
|
213
|
+
const raw = cfg[cfgKey];
|
|
214
|
+
if (raw && modelVal)
|
|
215
|
+
providerPreferences[modelVal] = JSON.parse(raw);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
// Ignore invalid provider preference JSON.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
enableGeminiExplicitCache = cfg.enable_gemini_explicit_cache === '1';
|
|
222
|
+
enableCacheLayoutV2 = cfg.enable_cache_layout_v2 === '1';
|
|
223
|
+
useVisionModelForAuxTasks = cfg.use_vision_model_for_aux_tasks === '1';
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Non-blocking: the app_config table may be missing during bootstrap.
|
|
227
|
+
}
|
|
228
|
+
if (!resolvedModel) {
|
|
229
|
+
sendEvent('error', { message: 'No model configured. Set a default model in the admin dashboard.' });
|
|
230
|
+
ws.close(5000, 'No model');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
let plan = getBillingPlan('free');
|
|
234
|
+
let billingOwnerUserId = auth.userId;
|
|
235
|
+
let billingAccount = null;
|
|
236
|
+
let allowOverage = false;
|
|
237
|
+
let usedCreditsBefore = 0;
|
|
238
|
+
let remainingOverageCreditsBefore = null;
|
|
239
|
+
try {
|
|
240
|
+
const billingContext = await getProjectOwnerBillingContext(supabase, project.id);
|
|
241
|
+
plan = billingContext.plan;
|
|
242
|
+
billingOwnerUserId = billingContext.ownerUserId;
|
|
243
|
+
// Enforce per-user concurrent connection limit based on plan
|
|
244
|
+
const maxParallel = plan.entitlements.maxParallelCaptures;
|
|
245
|
+
const currentConns = getUserConnectionCount(auth.userId);
|
|
246
|
+
if (currentConns >= maxParallel) {
|
|
247
|
+
sendEvent('error', {
|
|
248
|
+
message: `Concurrent capture limit reached (${maxParallel}). Upgrade your plan for more parallel captures.`,
|
|
249
|
+
});
|
|
250
|
+
ws.close(4029, 'Concurrent capture limit reached');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
trackUserConnection(auth.userId, ws);
|
|
254
|
+
ws.on('close', () => untrackUserConnection(auth.userId, ws));
|
|
255
|
+
const lockState = await getLockedResourceIds(supabase, billingOwnerUserId, plan);
|
|
256
|
+
ensureResourceNotLocked(project.id, lockState.lockedProjectIds, 'project');
|
|
257
|
+
const lockedPresets = lockState.lockedPresetIdsByProject.get(project.id);
|
|
258
|
+
if (lockedPresets) {
|
|
259
|
+
ensureResourceNotLocked(presetId, lockedPresets, 'preset');
|
|
260
|
+
}
|
|
261
|
+
ensureCaptureConfigAllowed(plan, {
|
|
262
|
+
langs,
|
|
263
|
+
themes: [...themes],
|
|
264
|
+
elements,
|
|
265
|
+
});
|
|
266
|
+
const usage = await getOwnerBillingUsage(supabase, billingOwnerUserId);
|
|
267
|
+
usedCreditsBefore = usage.credits;
|
|
268
|
+
billingAccount = await getBillingAccountForUser(supabase, billingOwnerUserId);
|
|
269
|
+
const hasActiveStripeSub = !!billingAccount?.stripe_subscription_id
|
|
270
|
+
&& shouldUseStripePlan(billingAccount.stripe_subscription_status);
|
|
271
|
+
const overageLimitCents = billingAccount?.capture_overage_limit_cents ?? null;
|
|
272
|
+
const overageState = getCaptureOverageState({
|
|
273
|
+
planId: plan.id,
|
|
274
|
+
quota: plan.entitlements.creditsPerMonth,
|
|
275
|
+
usedCredits: usage.credits,
|
|
276
|
+
allowOverages: billingAccount?.allow_capture_overages ?? false,
|
|
277
|
+
overageLimitCents,
|
|
278
|
+
hasActiveSubscription: hasActiveStripeSub,
|
|
279
|
+
hasOveragePrice: !!getStripeOveragePriceIdForPlan(plan.id),
|
|
280
|
+
isYearlySubscription: isYearlySubscription(billingAccount),
|
|
281
|
+
});
|
|
282
|
+
allowOverage = overageState.eligible;
|
|
283
|
+
remainingOverageCreditsBefore = getRemainingOverageCredits({
|
|
284
|
+
planId: plan.id,
|
|
285
|
+
quota: plan.entitlements.creditsPerMonth,
|
|
286
|
+
usedCredits: usage.credits,
|
|
287
|
+
overageLimitCents,
|
|
288
|
+
});
|
|
289
|
+
const requestedCredits = totalCaptures * SCREENSHOT_CREDIT_COST;
|
|
290
|
+
const bonusCredits = getSignupBonusCredits(billingAccount, usage.billingPeriodStart);
|
|
291
|
+
ensureMonthlyCreditsQuota(plan, usage.credits, requestedCredits, allowOverage, overageLimitCents, bonusCredits);
|
|
292
|
+
}
|
|
293
|
+
catch (error) {
|
|
294
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
295
|
+
sendEvent('error', {
|
|
296
|
+
message,
|
|
297
|
+
...(error instanceof PlanLimitError ? { code: error.code } : {}),
|
|
298
|
+
});
|
|
299
|
+
ws.close(error instanceof PlanLimitError ? 4003 : 5000, 'Capture unavailable');
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const runHints = await loadRunHints(supabase, presetId);
|
|
303
|
+
const runId = crypto.randomUUID();
|
|
304
|
+
const runStartedAt = new Date().toISOString();
|
|
305
|
+
const credentials = config.credentials ?? undefined;
|
|
306
|
+
const maxIterations = Number(config.maxIterations ?? 60) || 60;
|
|
307
|
+
const { error: runInsertError } = await supabase
|
|
308
|
+
.from('capture_runs')
|
|
309
|
+
.insert({
|
|
310
|
+
id: runId,
|
|
311
|
+
user_id: auth.userId,
|
|
312
|
+
api_key_id: auth.apiKeyId,
|
|
313
|
+
preset_id: presetId,
|
|
314
|
+
project_id: project.id,
|
|
315
|
+
status: 'running',
|
|
316
|
+
progress_current: 0,
|
|
317
|
+
progress_total: totalCaptures,
|
|
318
|
+
captures: [],
|
|
319
|
+
credits_used: 0,
|
|
320
|
+
started_at: runStartedAt,
|
|
321
|
+
});
|
|
322
|
+
if (runInsertError) {
|
|
323
|
+
logger.error(`[capture] Failed to create run: ${runInsertError.message}`);
|
|
324
|
+
sendEvent('error', { message: 'Failed to create capture run' });
|
|
325
|
+
ws.close(5000, 'Internal error');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
// ── Live broadcast bridge ─────────────────────────────────────────
|
|
329
|
+
const broadcast = createCaptureBroadcast(runId, supabase);
|
|
330
|
+
const appUrl = process.env.NEXT_PUBLIC_APP_URL ?? process.env.APP_URL ?? '';
|
|
331
|
+
if (appUrl) {
|
|
332
|
+
sendEvent('live_url', { url: `${appUrl}/capture/live?runId=${runId}` });
|
|
333
|
+
}
|
|
334
|
+
broadcast.send({ type: 'run_id', data: { runId } });
|
|
335
|
+
/** Wrap an async function with logger callbacks that forward to the broadcast. */
|
|
336
|
+
function withBroadcastCallbacks(ctx, fn) {
|
|
337
|
+
return runWithLoggerCallbacks({
|
|
338
|
+
onLog: (entry) => {
|
|
339
|
+
broadcast.send({ type: 'log', data: { ...entry, lang: ctx.lang, theme: ctx.theme, targetId: ctx.targetId } });
|
|
340
|
+
if (entry.iteration !== undefined && entry.maxIterations !== undefined) {
|
|
341
|
+
broadcast.send({
|
|
342
|
+
type: 'progress',
|
|
343
|
+
data: {
|
|
344
|
+
iteration: entry.iteration,
|
|
345
|
+
maxIterations: entry.maxIterations,
|
|
346
|
+
lang: ctx.lang,
|
|
347
|
+
theme: ctx.theme,
|
|
348
|
+
targetId: ctx.targetId,
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
onScreenshot: (base64) => {
|
|
354
|
+
broadcast.sendScreenshot(base64, { lang: ctx.lang, theme: ctx.theme, targetId: ctx.targetId });
|
|
355
|
+
},
|
|
356
|
+
onReasoningDelta: (delta, messageId) => {
|
|
357
|
+
broadcast.send({
|
|
358
|
+
type: 'reasoning_delta',
|
|
359
|
+
data: { delta, messageId, lang: ctx.lang, theme: ctx.theme, targetId: ctx.targetId },
|
|
360
|
+
});
|
|
361
|
+
},
|
|
362
|
+
}, fn);
|
|
363
|
+
}
|
|
364
|
+
const remoteBrowser = new RemoteBrowser(ws);
|
|
365
|
+
const abortController = new AbortController();
|
|
366
|
+
let aborted = false;
|
|
367
|
+
// Idle timeout: close connection after 10 minutes of inactivity.
|
|
368
|
+
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
369
|
+
let idleTimer = setTimeout(() => {
|
|
370
|
+
logger.warn('[WS] Closing idle connection');
|
|
371
|
+
sendEvent('error', { message: 'Connection timed out due to inactivity' });
|
|
372
|
+
ws.close(4008, 'Idle timeout');
|
|
373
|
+
}, IDLE_TIMEOUT_MS);
|
|
374
|
+
const resetIdleTimer = () => {
|
|
375
|
+
clearTimeout(idleTimer);
|
|
376
|
+
idleTimer = setTimeout(() => {
|
|
377
|
+
logger.warn('[WS] Closing idle connection');
|
|
378
|
+
sendEvent('error', { message: 'Connection timed out due to inactivity' });
|
|
379
|
+
ws.close(4008, 'Idle timeout');
|
|
380
|
+
}, IDLE_TIMEOUT_MS);
|
|
381
|
+
};
|
|
382
|
+
ws.on('message', resetIdleTimer);
|
|
383
|
+
ws.on('close', () => {
|
|
384
|
+
clearTimeout(idleTimer);
|
|
385
|
+
aborted = true;
|
|
386
|
+
abortController.abort();
|
|
387
|
+
broadcast.close();
|
|
388
|
+
});
|
|
389
|
+
const mockupState = {
|
|
390
|
+
browser: null,
|
|
391
|
+
context: null,
|
|
392
|
+
};
|
|
393
|
+
const getMockupContext = async () => {
|
|
394
|
+
if (!mockupState.context) {
|
|
395
|
+
mockupState.browser = await chromium.launch({
|
|
396
|
+
headless: true,
|
|
397
|
+
args: CHROMIUM_ARGS,
|
|
398
|
+
});
|
|
399
|
+
mockupState.context = await mockupState.browser.newContext();
|
|
400
|
+
}
|
|
401
|
+
return mockupState.context;
|
|
402
|
+
};
|
|
403
|
+
const closeMockup = async () => {
|
|
404
|
+
try {
|
|
405
|
+
await mockupState.context?.close();
|
|
406
|
+
}
|
|
407
|
+
catch { /* ignore */ }
|
|
408
|
+
try {
|
|
409
|
+
await mockupState.browser?.close();
|
|
410
|
+
}
|
|
411
|
+
catch { /* ignore */ }
|
|
412
|
+
};
|
|
413
|
+
const selectorMemoryCache = new Map();
|
|
414
|
+
const sessionProfileCache = new Map();
|
|
415
|
+
const liveVariantActions = new Map();
|
|
416
|
+
const liveVariantReference = new Map();
|
|
417
|
+
const runSharedAuthProfiles = new Map();
|
|
418
|
+
const captureResults = [];
|
|
419
|
+
let runSharedStorageState;
|
|
420
|
+
let runSharedSessionStorage;
|
|
421
|
+
let successCount = 0;
|
|
422
|
+
let captureIndex = 0;
|
|
423
|
+
const rootOrigin = getUrlOrigin(projectUrl);
|
|
424
|
+
const updateRunProgress = async () => {
|
|
425
|
+
await supabase
|
|
426
|
+
.from('capture_runs')
|
|
427
|
+
.update({
|
|
428
|
+
progress_current: captureIndex,
|
|
429
|
+
credits_used: successCount,
|
|
430
|
+
})
|
|
431
|
+
.eq('id', runId);
|
|
432
|
+
};
|
|
433
|
+
const finalizeRunSideEffects = async () => {
|
|
434
|
+
try {
|
|
435
|
+
if (allowOverage && billingAccount?.stripe_customer_id && successCount > 0) {
|
|
436
|
+
const rawOverageCount = getIncrementalOverageCount({
|
|
437
|
+
quota: plan.entitlements.creditsPerMonth,
|
|
438
|
+
usedBefore: usedCreditsBefore,
|
|
439
|
+
completedCount: successCount * SCREENSHOT_CREDIT_COST,
|
|
440
|
+
});
|
|
441
|
+
const overageCount = remainingOverageCreditsBefore === null
|
|
442
|
+
? rawOverageCount
|
|
443
|
+
: Math.min(rawOverageCount, remainingOverageCreditsBefore);
|
|
444
|
+
if (overageCount > 0) {
|
|
445
|
+
await recordStripeMeterEvent({
|
|
446
|
+
customerId: billingAccount.stripe_customer_id,
|
|
447
|
+
value: overageCount,
|
|
448
|
+
identifier: runId,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// Non-blocking.
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
await cleanupExpiredCapturesForOwner(supabase, billingOwnerUserId);
|
|
458
|
+
}
|
|
459
|
+
catch {
|
|
460
|
+
// Non-blocking.
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
const logAgentUsage = async (params) => {
|
|
464
|
+
if (params.usage.length === 0) {
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const ids = await insertCostLogs(supabase, {
|
|
469
|
+
runId,
|
|
470
|
+
userId: auth.userId,
|
|
471
|
+
projectId: project.id,
|
|
472
|
+
presetId,
|
|
473
|
+
lang: params.lang,
|
|
474
|
+
theme: params.theme,
|
|
475
|
+
captureType: params.captureType,
|
|
476
|
+
elementName: params.elementName,
|
|
477
|
+
targetId: params.targetId,
|
|
478
|
+
viewportWidth: params.viewportWidth,
|
|
479
|
+
viewportHeight: params.viewportHeight,
|
|
480
|
+
}, params.usage);
|
|
481
|
+
if (ids.length > 0) {
|
|
482
|
+
void resolveAllCosts(supabase, ids.map((id, index) => ({
|
|
483
|
+
costLogId: id,
|
|
484
|
+
generationId: params.usage[index]?.generationId ?? null,
|
|
485
|
+
})), getOpenRouterKey(), supabase).catch((error) => {
|
|
486
|
+
logger.error(`Cost resolution failed: ${error.message}`);
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return ids;
|
|
490
|
+
}
|
|
491
|
+
catch (error) {
|
|
492
|
+
logger.error(`Failed to log agent usage: ${error.message}`);
|
|
493
|
+
return [];
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const loadSelectorMemoryForVariant = async (domain, lang, theme) => {
|
|
497
|
+
const cacheKey = `${domain}:${lang}:${theme}`;
|
|
498
|
+
if (selectorMemoryCache.has(cacheKey)) {
|
|
499
|
+
return selectorMemoryCache.get(cacheKey) ?? {};
|
|
500
|
+
}
|
|
501
|
+
const memory = await loadScreenshotSelectorMemory(supabase, {
|
|
502
|
+
projectId: project.id,
|
|
503
|
+
presetId,
|
|
504
|
+
domain,
|
|
505
|
+
lang,
|
|
506
|
+
theme,
|
|
507
|
+
}).catch(() => ({}));
|
|
508
|
+
selectorMemoryCache.set(cacheKey, memory);
|
|
509
|
+
return memory;
|
|
510
|
+
};
|
|
511
|
+
const loadSessionProfileForVariant = async (domain, lang, theme) => {
|
|
512
|
+
const cacheKey = `${domain}:${lang}:${theme}`;
|
|
513
|
+
if (sessionProfileCache.has(cacheKey)) {
|
|
514
|
+
return sessionProfileCache.get(cacheKey);
|
|
515
|
+
}
|
|
516
|
+
try {
|
|
517
|
+
const { data } = await supabase
|
|
518
|
+
.from('screenshot_session_profiles')
|
|
519
|
+
.select('storage_state, session_storage, auth_state, account_label, detected_lang, detected_theme, validated_start_url, last_known_url, summary, validation_status, last_used_at, profile_version')
|
|
520
|
+
.eq('project_id', project.id)
|
|
521
|
+
.eq('preset_id', presetId)
|
|
522
|
+
.eq('domain', domain)
|
|
523
|
+
.eq('lang', lang)
|
|
524
|
+
.eq('theme', theme)
|
|
525
|
+
.gt('expires_at', new Date().toISOString())
|
|
526
|
+
.limit(1)
|
|
527
|
+
.single();
|
|
528
|
+
if (!data?.storage_state) {
|
|
529
|
+
sessionProfileCache.set(cacheKey, undefined);
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
const profile = {
|
|
533
|
+
storageState: decryptSessionField(data.storage_state),
|
|
534
|
+
sessionStorage: decryptSessionField(data.session_storage) ?? undefined,
|
|
535
|
+
authState: data.auth_state ?? 'unknown',
|
|
536
|
+
accountLabel: data.account_label ?? null,
|
|
537
|
+
detectedLang: data.detected_lang ?? null,
|
|
538
|
+
detectedTheme: data.detected_theme ?? null,
|
|
539
|
+
validatedStartUrl: data.validated_start_url ?? null,
|
|
540
|
+
lastKnownUrl: data.last_known_url ?? null,
|
|
541
|
+
summary: data.summary ?? null,
|
|
542
|
+
validationStatus: data.validation_status ?? 'unknown',
|
|
543
|
+
lastUsedAt: data.last_used_at ?? null,
|
|
544
|
+
profileVersion: data.profile_version ?? 1,
|
|
545
|
+
};
|
|
546
|
+
sessionProfileCache.set(cacheKey, profile);
|
|
547
|
+
return profile;
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
sessionProfileCache.set(cacheKey, undefined);
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
const persistSessionProfile = async (domain, lang, theme, profile) => {
|
|
555
|
+
if (!profile.storageState)
|
|
556
|
+
return;
|
|
557
|
+
const cacheKey = `${domain}:${lang}:${theme}`;
|
|
558
|
+
const storedProfile = {
|
|
559
|
+
...profile,
|
|
560
|
+
lastUsedAt: new Date().toISOString(),
|
|
561
|
+
};
|
|
562
|
+
try {
|
|
563
|
+
await supabase
|
|
564
|
+
.from('screenshot_session_profiles')
|
|
565
|
+
.upsert({
|
|
566
|
+
project_id: project.id,
|
|
567
|
+
preset_id: presetId,
|
|
568
|
+
domain,
|
|
569
|
+
lang,
|
|
570
|
+
theme,
|
|
571
|
+
storage_state: encryptSessionField(storedProfile.storageState),
|
|
572
|
+
session_storage: encryptSessionField(storedProfile.sessionStorage) ?? null,
|
|
573
|
+
auth_state: storedProfile.authState,
|
|
574
|
+
account_label: storedProfile.accountLabel ?? null,
|
|
575
|
+
detected_lang: storedProfile.detectedLang ?? null,
|
|
576
|
+
detected_theme: storedProfile.detectedTheme ?? null,
|
|
577
|
+
validated_start_url: storedProfile.validatedStartUrl ?? null,
|
|
578
|
+
last_known_url: storedProfile.lastKnownUrl ?? null,
|
|
579
|
+
summary: storedProfile.summary ?? null,
|
|
580
|
+
validation_status: storedProfile.validationStatus,
|
|
581
|
+
last_validated_at: new Date().toISOString(),
|
|
582
|
+
last_used_at: storedProfile.lastUsedAt,
|
|
583
|
+
profile_version: storedProfile.profileVersion ?? 1,
|
|
584
|
+
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
585
|
+
}, {
|
|
586
|
+
onConflict: 'project_id,preset_id,domain,lang,theme',
|
|
587
|
+
});
|
|
588
|
+
sessionProfileCache.set(cacheKey, storedProfile);
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
logger.error(`Session profile persistence failed for ${domain}/${lang}/${theme}: ${error.message}`);
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
const getRunSharedAuthProfile = (startUrl) => {
|
|
595
|
+
const startOrigin = getUrlOrigin(startUrl);
|
|
596
|
+
if (startOrigin && runSharedAuthProfiles.has(startOrigin)) {
|
|
597
|
+
return runSharedAuthProfiles.get(startOrigin);
|
|
598
|
+
}
|
|
599
|
+
if (!startOrigin && rootOrigin && runSharedAuthProfiles.has(rootOrigin)) {
|
|
600
|
+
return runSharedAuthProfiles.get(rootOrigin);
|
|
601
|
+
}
|
|
602
|
+
if (!startOrigin && runSharedAuthProfiles.size === 1) {
|
|
603
|
+
return Array.from(runSharedAuthProfiles.values())[0];
|
|
604
|
+
}
|
|
605
|
+
return undefined;
|
|
606
|
+
};
|
|
607
|
+
const rememberRunSharedAuthProfile = (profile, startUrl) => {
|
|
608
|
+
const sharedProfile = deriveRunSharedAuthProfile(profile);
|
|
609
|
+
if (!sharedProfile)
|
|
610
|
+
return;
|
|
611
|
+
const keys = new Set();
|
|
612
|
+
const startOrigin = getUrlOrigin(startUrl);
|
|
613
|
+
const profileOrigin = getUrlOrigin(profile?.lastKnownUrl ?? profile?.validatedStartUrl ?? null);
|
|
614
|
+
if (startOrigin)
|
|
615
|
+
keys.add(startOrigin);
|
|
616
|
+
if (profileOrigin)
|
|
617
|
+
keys.add(profileOrigin);
|
|
618
|
+
if (rootOrigin)
|
|
619
|
+
keys.add(rootOrigin);
|
|
620
|
+
for (const key of keys) {
|
|
621
|
+
runSharedAuthProfiles.set(key, sharedProfile);
|
|
622
|
+
}
|
|
623
|
+
};
|
|
624
|
+
const persistFullPageCapture = async (params) => {
|
|
625
|
+
const { pageRun, target, lang, theme, agentResult, includeActions, includeWorkflowScreenshots } = params;
|
|
626
|
+
broadcast.send({ type: 'capture_start', data: { lang, theme, targetId: target.id, targetLabel: target.label } });
|
|
627
|
+
const pageSegment = sanitizeStorageSegment(pageRun.pageId);
|
|
628
|
+
let finalBuffer = Buffer.alloc(0);
|
|
629
|
+
let rawScreenshotUrl = null;
|
|
630
|
+
let browserBar;
|
|
631
|
+
try {
|
|
632
|
+
logger.info(`[persist] target=${target.id} viewport=${target.viewport.width}x${target.viewport.height} deviceScaleFactor=${outputScale} deviceFrame=${target.deviceFrame ?? 'none'}`);
|
|
633
|
+
const lockedScreenshot = await takeViewportLockedScreenshot(remoteBrowser, target.viewport, { deviceScaleFactor: outputScale });
|
|
634
|
+
finalBuffer = lockedScreenshot.buffer;
|
|
635
|
+
logger.info(`[persist] screenshot=${lockedScreenshot.screenshotSize.width}x${lockedScreenshot.screenshotSize.height} expected=${lockedScreenshot.expectedScreenshotSize.width}x${lockedScreenshot.expectedScreenshotSize.height} repaired=${lockedScreenshot.repaired}`);
|
|
636
|
+
if (lockedScreenshot.repaired) {
|
|
637
|
+
logger.info(`Viewport lock repaired before persisting ${target.id}: expected ${lockedScreenshot.expectedScreenshotSize.width}x${lockedScreenshot.expectedScreenshotSize.height}, got ${lockedScreenshot.screenshotSize.width}x${lockedScreenshot.screenshotSize.height}.`);
|
|
638
|
+
}
|
|
639
|
+
if (target.deviceFrame) {
|
|
640
|
+
const rawPath = `screenshots/${auth.userId}/${runId}/${pageSegment}/${target.id}_${lang}_${theme}_raw.png`;
|
|
641
|
+
const { error } = await supabase.storage
|
|
642
|
+
.from(BUCKET)
|
|
643
|
+
.upload(rawPath, finalBuffer, { contentType: 'image/png', upsert: true });
|
|
644
|
+
if (!error) {
|
|
645
|
+
rawScreenshotUrl = supabase.storage.from(BUCKET).getPublicUrl(rawPath).data.publicUrl;
|
|
646
|
+
}
|
|
647
|
+
try {
|
|
648
|
+
const pageUrl = remoteBrowser.currentPage.url();
|
|
649
|
+
const pageTitle = await remoteBrowser.currentPage.title();
|
|
650
|
+
const mockupOptions = target.mockupOptions ?? {};
|
|
651
|
+
browserBar = (mockupOptions.autoBrowserBar ?? true)
|
|
652
|
+
? {
|
|
653
|
+
url: pageUrl,
|
|
654
|
+
pageTitle,
|
|
655
|
+
tabIconUrl: pageUrl
|
|
656
|
+
? `https://www.google.com/s2/favicons?domain=${new URL(pageUrl).hostname}&sz=64`
|
|
657
|
+
: undefined,
|
|
658
|
+
}
|
|
659
|
+
: mockupOptions.browserBar;
|
|
660
|
+
const localizedOptions = {
|
|
661
|
+
...mockupOptions,
|
|
662
|
+
outputScale,
|
|
663
|
+
colorScheme: theme,
|
|
664
|
+
statusBar: localizeStatusBar(mockupOptions.statusBar, lang),
|
|
665
|
+
browserBar,
|
|
666
|
+
};
|
|
667
|
+
const mockupContext = await getMockupContext();
|
|
668
|
+
finalBuffer = await applyDeviceFrame(finalBuffer, target.deviceFrame, mockupContext, localizedOptions);
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
logger.error(`Mockup failed for ${target.deviceFrame}: ${error.message}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
finalBuffer = await applyPlanScreenshotWatermark(plan, finalBuffer);
|
|
675
|
+
const storagePath = `screenshots/${auth.userId}/${runId}/${pageSegment}/${target.id}_${lang}_${theme}.png`;
|
|
676
|
+
const { error: uploadError } = await supabase.storage
|
|
677
|
+
.from(BUCKET)
|
|
678
|
+
.upload(storagePath, finalBuffer, { contentType: 'image/png', upsert: true });
|
|
679
|
+
const screenshotUrl = uploadError
|
|
680
|
+
? null
|
|
681
|
+
: supabase.storage.from(BUCKET).getPublicUrl(storagePath).data.publicUrl;
|
|
682
|
+
const terminalAgentSuccess = isTerminalAgentResultSuccess(agentResult);
|
|
683
|
+
const persistedSuccess = terminalAgentSuccess && !!screenshotUrl;
|
|
684
|
+
const persistedAssessment = persistedSuccess
|
|
685
|
+
? agentResult.assessment
|
|
686
|
+
: terminalAgentSuccess
|
|
687
|
+
? 'Capture completed but screenshot upload failed.'
|
|
688
|
+
: agentResult.assessment || 'Capture failed';
|
|
689
|
+
captureResults.push({
|
|
690
|
+
url: pageRun.url,
|
|
691
|
+
lang,
|
|
692
|
+
theme,
|
|
693
|
+
captureType: 'fullpage',
|
|
694
|
+
elementName: pageRun.pageId ?? undefined,
|
|
695
|
+
targetId: target.id,
|
|
696
|
+
targetLabel: target.label,
|
|
697
|
+
deviceFrame: target.deviceFrame,
|
|
698
|
+
success: persistedSuccess,
|
|
699
|
+
assessment: persistedAssessment,
|
|
700
|
+
iterations: agentResult.iterations,
|
|
701
|
+
finalScreenshotUrl: screenshotUrl ?? undefined,
|
|
702
|
+
});
|
|
703
|
+
broadcast.send({
|
|
704
|
+
type: 'result',
|
|
705
|
+
data: {
|
|
706
|
+
url: pageRun.url,
|
|
707
|
+
lang,
|
|
708
|
+
theme,
|
|
709
|
+
targetId: target.id,
|
|
710
|
+
targetLabel: target.label,
|
|
711
|
+
success: persistedSuccess,
|
|
712
|
+
assessment: persistedAssessment,
|
|
713
|
+
iterations: agentResult.iterations,
|
|
714
|
+
finalScreenshotUrl: screenshotUrl ?? undefined,
|
|
715
|
+
// Omit finalScreenshot base64 — too large for broadcast.
|
|
716
|
+
finalScreenshot: '',
|
|
717
|
+
},
|
|
718
|
+
});
|
|
719
|
+
const costLogIds = await logAgentUsage({
|
|
720
|
+
usage: agentResult.usage,
|
|
721
|
+
lang,
|
|
722
|
+
theme,
|
|
723
|
+
captureType: 'fullpage',
|
|
724
|
+
elementName: pageRun.pageId ?? undefined,
|
|
725
|
+
targetId: target.id,
|
|
726
|
+
viewportWidth: target.viewport.width,
|
|
727
|
+
viewportHeight: target.viewport.height,
|
|
728
|
+
});
|
|
729
|
+
const captureInsert = await supabase
|
|
730
|
+
.from('captures')
|
|
731
|
+
.insert({
|
|
732
|
+
run_id: runId,
|
|
733
|
+
preset_id: presetId,
|
|
734
|
+
project_id: project.id,
|
|
735
|
+
target_id: target.id,
|
|
736
|
+
element_name: pageRun.pageId ?? null,
|
|
737
|
+
url: pageRun.url,
|
|
738
|
+
prompt: pageRun.prompt,
|
|
739
|
+
lang,
|
|
740
|
+
theme,
|
|
741
|
+
success: persistedSuccess,
|
|
742
|
+
assessment: persistedAssessment,
|
|
743
|
+
iterations: agentResult.iterations,
|
|
744
|
+
screenshot_url: screenshotUrl,
|
|
745
|
+
raw_screenshot_url: rawScreenshotUrl,
|
|
746
|
+
device_frame: target.deviceFrame ?? null,
|
|
747
|
+
capture_type: 'fullpage',
|
|
748
|
+
credits: persistedSuccess ? SCREENSHOT_CREDIT_COST : 0,
|
|
749
|
+
config: {
|
|
750
|
+
targets,
|
|
751
|
+
pageId: pageRun.pageId,
|
|
752
|
+
model: resolvedModel,
|
|
753
|
+
outputScale,
|
|
754
|
+
actions: includeActions ? agentResult.actions : [],
|
|
755
|
+
workflowScreenshots: includeWorkflowScreenshots ? agentResult.screenshots.length : 0,
|
|
756
|
+
},
|
|
757
|
+
})
|
|
758
|
+
.select('id')
|
|
759
|
+
.single();
|
|
760
|
+
const captureId = captureInsert.data?.id ?? null;
|
|
761
|
+
if (captureInsert.error) {
|
|
762
|
+
logger.error(`Capture insert failed: ${captureInsert.error.message}`);
|
|
763
|
+
}
|
|
764
|
+
if (persistedSuccess && captureId) {
|
|
765
|
+
successCount += 1;
|
|
766
|
+
await recordCreditUsage(supabase, {
|
|
767
|
+
userId: auth.userId,
|
|
768
|
+
projectId: project.id,
|
|
769
|
+
presetId,
|
|
770
|
+
runId,
|
|
771
|
+
type: 'screenshot',
|
|
772
|
+
credits: SCREENSHOT_CREDIT_COST,
|
|
773
|
+
sourceId: captureId,
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
if (captureId && costLogIds.length > 0) {
|
|
777
|
+
await updateCostLogCaptureContext(supabase, costLogIds, {
|
|
778
|
+
captureId,
|
|
779
|
+
targetId: target.id,
|
|
780
|
+
viewportWidth: target.viewport.width,
|
|
781
|
+
viewportHeight: target.viewport.height,
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
await insertScreenshotOperationLog(supabase, {
|
|
785
|
+
runId,
|
|
786
|
+
userId: auth.userId,
|
|
787
|
+
projectId: project.id,
|
|
788
|
+
presetId,
|
|
789
|
+
captureId,
|
|
790
|
+
captureType: 'fullpage',
|
|
791
|
+
lang,
|
|
792
|
+
theme,
|
|
793
|
+
elementName: pageRun.pageId ?? undefined,
|
|
794
|
+
targetId: target.id,
|
|
795
|
+
viewportWidth: target.viewport.width,
|
|
796
|
+
viewportHeight: target.viewport.height,
|
|
797
|
+
deviceFrame: target.deviceFrame ?? null,
|
|
798
|
+
}, {
|
|
799
|
+
outcome: persistedSuccess ? 'succeeded' : 'failed',
|
|
800
|
+
outcomeReason: persistedSuccess ? null : persistedAssessment,
|
|
801
|
+
billable: persistedSuccess && !!captureId,
|
|
802
|
+
creditsCharged: persistedSuccess && captureId ? SCREENSHOT_CREDIT_COST : 0,
|
|
803
|
+
costLogIds,
|
|
804
|
+
usage: agentResult.usage,
|
|
805
|
+
metadata: {
|
|
806
|
+
pageId: pageRun.pageId,
|
|
807
|
+
pageUrl: pageRun.url,
|
|
808
|
+
prompt: pageRun.prompt,
|
|
809
|
+
runtimeStrategy: agentResult.runtimeStrategy ?? null,
|
|
810
|
+
source: 'ws_remote_playwright',
|
|
811
|
+
workflowScreenshotsIncluded: includeWorkflowScreenshots,
|
|
812
|
+
actionsIncluded: includeActions,
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
void reconcilePendingBillingOperationCosts(supabase).catch(() => undefined);
|
|
816
|
+
if (!persistedSuccess) {
|
|
817
|
+
const errorType = terminalAgentSuccess
|
|
818
|
+
? 'storage_upload_failure'
|
|
819
|
+
: agentResult.actions.some((action) => action.action === 'give_up')
|
|
820
|
+
? 'give_up'
|
|
821
|
+
: agentResult.iterations >= maxIterations
|
|
822
|
+
? 'max_iterations'
|
|
823
|
+
: 'verification_failure';
|
|
824
|
+
getServerPostHog()?.capture({
|
|
825
|
+
distinctId: auth.userId,
|
|
826
|
+
event: 'capture_page_error',
|
|
827
|
+
properties: {
|
|
828
|
+
runId,
|
|
829
|
+
presetId,
|
|
830
|
+
projectId: project.id,
|
|
831
|
+
pageUrl: pageRun.url,
|
|
832
|
+
pageId: pageRun.pageId,
|
|
833
|
+
errorType,
|
|
834
|
+
lang,
|
|
835
|
+
theme,
|
|
836
|
+
iteration: agentResult.iterations,
|
|
837
|
+
assessment: persistedAssessment?.slice(0, 500) ?? null,
|
|
838
|
+
},
|
|
839
|
+
});
|
|
840
|
+
if (captureId) {
|
|
841
|
+
try {
|
|
842
|
+
await supabase.from('agent_errors').insert({
|
|
843
|
+
preset_id: presetId,
|
|
844
|
+
run_id: runId,
|
|
845
|
+
capture_id: captureId,
|
|
846
|
+
error_type: errorType,
|
|
847
|
+
message: persistedAssessment,
|
|
848
|
+
description: persistedAssessment,
|
|
849
|
+
screenshot_url: screenshotUrl,
|
|
850
|
+
agent_context: {
|
|
851
|
+
iterations: agentResult.iterations,
|
|
852
|
+
maxIterations,
|
|
853
|
+
captureTarget: {
|
|
854
|
+
lang,
|
|
855
|
+
theme,
|
|
856
|
+
targetLabel: target.label,
|
|
857
|
+
viewport: target.viewport,
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
catch {
|
|
863
|
+
// Non-blocking.
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
if (persistedSuccess && captureId && screenshotUrl) {
|
|
868
|
+
await syncStudioVariantAfterCapture({
|
|
869
|
+
supabase,
|
|
870
|
+
projectId: project.id,
|
|
871
|
+
presetId,
|
|
872
|
+
targetId: target.id,
|
|
873
|
+
lang,
|
|
874
|
+
theme,
|
|
875
|
+
elementName: pageRun.pageId,
|
|
876
|
+
captureId,
|
|
877
|
+
screenshotUrl,
|
|
878
|
+
rawScreenshotUrl,
|
|
879
|
+
deviceFrame: target.deviceFrame ?? null,
|
|
880
|
+
browserBar,
|
|
881
|
+
}).catch((error) => {
|
|
882
|
+
logger.error(`Studio sync failed: ${error.message}`);
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
if (captureId && screenshotUrl) {
|
|
886
|
+
const altText = await generateAltText({
|
|
887
|
+
url: pageRun.url,
|
|
888
|
+
prompt: pageRun.prompt,
|
|
889
|
+
lang,
|
|
890
|
+
theme,
|
|
891
|
+
targetLabel: target.label,
|
|
892
|
+
elementName: pageRun.pageId ?? undefined,
|
|
893
|
+
model: resolvedModel,
|
|
894
|
+
apiKey: getOpenRouterKey(),
|
|
895
|
+
}).catch(() => null);
|
|
896
|
+
if (altText) {
|
|
897
|
+
await supabase
|
|
898
|
+
.from('captures')
|
|
899
|
+
.update({ alt_text: altText })
|
|
900
|
+
.eq('id', captureId);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
getServerPostHog()?.capture({
|
|
904
|
+
distinctId: auth.userId,
|
|
905
|
+
event: 'capture_page_optimization',
|
|
906
|
+
properties: {
|
|
907
|
+
runId,
|
|
908
|
+
projectId: project.id,
|
|
909
|
+
presetId,
|
|
910
|
+
targetId: target.id,
|
|
911
|
+
pageId: pageRun.pageId,
|
|
912
|
+
lang,
|
|
913
|
+
theme,
|
|
914
|
+
success: persistedSuccess,
|
|
915
|
+
runtimeStrategy: agentResult.runtimeStrategy ?? null,
|
|
916
|
+
deterministicRecoveryUsed: agentResult.deterministicRecoveryUsed ?? false,
|
|
917
|
+
evaluatorUsed: agentResult.evaluatorUsed ?? false,
|
|
918
|
+
iterationCount: agentResult.iterations,
|
|
919
|
+
source: 'ws_remote_playwright',
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
finally {
|
|
924
|
+
captureIndex += 1;
|
|
925
|
+
await updateRunProgress();
|
|
926
|
+
sendEvent('progress', {
|
|
927
|
+
message: `[${captureIndex}/${totalCaptures}] ${lang}/${theme} · ${pageRun.pageId ?? 'main'} · ${target.label}`,
|
|
928
|
+
});
|
|
929
|
+
broadcast.send({ type: 'capture_complete', data: { lang, theme, targetId: target.id, success: isTerminalAgentResultSuccess(agentResult) } });
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
const persistElementCapture = async (params) => {
|
|
933
|
+
const { pageRun, element, lang, theme, result } = params;
|
|
934
|
+
try {
|
|
935
|
+
if (!result.success || !result.buffer) {
|
|
936
|
+
captureResults.push({
|
|
937
|
+
url: pageRun.url,
|
|
938
|
+
lang,
|
|
939
|
+
theme,
|
|
940
|
+
captureType: 'element',
|
|
941
|
+
elementName: element.name,
|
|
942
|
+
targetId: targets[0]?.id,
|
|
943
|
+
targetLabel: targets[0]?.label,
|
|
944
|
+
success: false,
|
|
945
|
+
assessment: result.assessment || 'Element capture failed.',
|
|
946
|
+
iterations: 0,
|
|
947
|
+
});
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const finalBuffer = await applyPlanScreenshotWatermark(plan, result.buffer);
|
|
951
|
+
const costLogIds = await logAgentUsage({
|
|
952
|
+
usage: result.usage,
|
|
953
|
+
lang,
|
|
954
|
+
theme,
|
|
955
|
+
captureType: 'element',
|
|
956
|
+
elementName: element.name,
|
|
957
|
+
targetId: targets[0]?.id,
|
|
958
|
+
viewportWidth: targets[0]?.viewport.width ?? null,
|
|
959
|
+
viewportHeight: targets[0]?.viewport.height ?? null,
|
|
960
|
+
});
|
|
961
|
+
const storagePath = `screenshots/${auth.userId}/${runId}/elements/${sanitizeStorageSegment(pageRun.pageId)}/${element.name}_${lang}_${theme}.png`;
|
|
962
|
+
const { error: uploadError } = await supabase.storage
|
|
963
|
+
.from(BUCKET)
|
|
964
|
+
.upload(storagePath, finalBuffer, { contentType: 'image/png', upsert: true });
|
|
965
|
+
if (uploadError) {
|
|
966
|
+
logger.error(`Element upload failed: ${uploadError.message}`);
|
|
967
|
+
captureResults.push({
|
|
968
|
+
url: pageRun.url,
|
|
969
|
+
lang,
|
|
970
|
+
theme,
|
|
971
|
+
captureType: 'element',
|
|
972
|
+
elementName: element.name,
|
|
973
|
+
targetId: targets[0]?.id,
|
|
974
|
+
targetLabel: targets[0]?.label,
|
|
975
|
+
success: false,
|
|
976
|
+
assessment: uploadError.message,
|
|
977
|
+
iterations: 0,
|
|
978
|
+
});
|
|
979
|
+
await insertScreenshotOperationLog(supabase, {
|
|
980
|
+
runId,
|
|
981
|
+
userId: auth.userId,
|
|
982
|
+
projectId: project.id,
|
|
983
|
+
presetId,
|
|
984
|
+
captureId: null,
|
|
985
|
+
captureType: 'element',
|
|
986
|
+
lang,
|
|
987
|
+
theme,
|
|
988
|
+
elementName: element.name,
|
|
989
|
+
targetId: targets[0]?.id,
|
|
990
|
+
viewportWidth: targets[0]?.viewport.width ?? null,
|
|
991
|
+
viewportHeight: targets[0]?.viewport.height ?? null,
|
|
992
|
+
deviceFrame: null,
|
|
993
|
+
}, {
|
|
994
|
+
outcome: 'failed',
|
|
995
|
+
outcomeReason: uploadError.message,
|
|
996
|
+
billable: false,
|
|
997
|
+
creditsCharged: 0,
|
|
998
|
+
costLogIds,
|
|
999
|
+
usage: result.usage,
|
|
1000
|
+
metadata: {
|
|
1001
|
+
pageId: pageRun.pageId,
|
|
1002
|
+
pageUrl: pageRun.url,
|
|
1003
|
+
prompt: pageRun.prompt,
|
|
1004
|
+
source: 'ws_remote_playwright',
|
|
1005
|
+
},
|
|
1006
|
+
});
|
|
1007
|
+
void reconcilePendingBillingOperationCosts(supabase).catch(() => undefined);
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
const screenshotUrl = supabase.storage.from(BUCKET).getPublicUrl(storagePath).data.publicUrl;
|
|
1011
|
+
const { data: captureRow, error } = await supabase
|
|
1012
|
+
.from('captures')
|
|
1013
|
+
.insert({
|
|
1014
|
+
run_id: runId,
|
|
1015
|
+
preset_id: presetId,
|
|
1016
|
+
project_id: project.id,
|
|
1017
|
+
target_id: targets[0]?.id ?? null,
|
|
1018
|
+
element_name: element.name,
|
|
1019
|
+
url: pageRun.url,
|
|
1020
|
+
prompt: pageRun.prompt,
|
|
1021
|
+
lang,
|
|
1022
|
+
theme,
|
|
1023
|
+
success: true,
|
|
1024
|
+
assessment: result.assessment,
|
|
1025
|
+
screenshot_url: screenshotUrl,
|
|
1026
|
+
capture_type: 'element',
|
|
1027
|
+
credits: SCREENSHOT_CREDIT_COST,
|
|
1028
|
+
config: {
|
|
1029
|
+
element,
|
|
1030
|
+
outputScale,
|
|
1031
|
+
pageId: pageRun.pageId,
|
|
1032
|
+
},
|
|
1033
|
+
})
|
|
1034
|
+
.select('id')
|
|
1035
|
+
.single();
|
|
1036
|
+
if (error) {
|
|
1037
|
+
logger.error(`Element capture insert failed: ${error.message}`);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
captureResults.push({
|
|
1041
|
+
url: pageRun.url,
|
|
1042
|
+
lang,
|
|
1043
|
+
theme,
|
|
1044
|
+
captureType: 'element',
|
|
1045
|
+
elementName: element.name,
|
|
1046
|
+
targetId: targets[0]?.id,
|
|
1047
|
+
targetLabel: targets[0]?.label,
|
|
1048
|
+
success: true,
|
|
1049
|
+
assessment: result.assessment,
|
|
1050
|
+
iterations: 0,
|
|
1051
|
+
finalScreenshotUrl: screenshotUrl,
|
|
1052
|
+
});
|
|
1053
|
+
successCount += 1;
|
|
1054
|
+
await recordCreditUsage(supabase, {
|
|
1055
|
+
userId: auth.userId,
|
|
1056
|
+
projectId: project.id,
|
|
1057
|
+
presetId,
|
|
1058
|
+
runId,
|
|
1059
|
+
type: 'screenshot',
|
|
1060
|
+
credits: SCREENSHOT_CREDIT_COST,
|
|
1061
|
+
sourceId: captureRow.id,
|
|
1062
|
+
});
|
|
1063
|
+
await updateCostLogCaptureContext(supabase, costLogIds, {
|
|
1064
|
+
captureId: captureRow.id,
|
|
1065
|
+
targetId: targets[0]?.id ?? null,
|
|
1066
|
+
viewportWidth: targets[0]?.viewport.width ?? null,
|
|
1067
|
+
viewportHeight: targets[0]?.viewport.height ?? null,
|
|
1068
|
+
});
|
|
1069
|
+
await insertScreenshotOperationLog(supabase, {
|
|
1070
|
+
runId,
|
|
1071
|
+
userId: auth.userId,
|
|
1072
|
+
projectId: project.id,
|
|
1073
|
+
presetId,
|
|
1074
|
+
captureId: captureRow.id,
|
|
1075
|
+
captureType: 'element',
|
|
1076
|
+
lang,
|
|
1077
|
+
theme,
|
|
1078
|
+
elementName: element.name,
|
|
1079
|
+
targetId: targets[0]?.id,
|
|
1080
|
+
viewportWidth: targets[0]?.viewport.width ?? null,
|
|
1081
|
+
viewportHeight: targets[0]?.viewport.height ?? null,
|
|
1082
|
+
deviceFrame: null,
|
|
1083
|
+
}, {
|
|
1084
|
+
outcome: 'succeeded',
|
|
1085
|
+
outcomeReason: null,
|
|
1086
|
+
billable: true,
|
|
1087
|
+
creditsCharged: SCREENSHOT_CREDIT_COST,
|
|
1088
|
+
costLogIds,
|
|
1089
|
+
usage: result.usage,
|
|
1090
|
+
metadata: {
|
|
1091
|
+
pageId: pageRun.pageId,
|
|
1092
|
+
pageUrl: pageRun.url,
|
|
1093
|
+
prompt: pageRun.prompt,
|
|
1094
|
+
source: 'ws_remote_playwright',
|
|
1095
|
+
},
|
|
1096
|
+
});
|
|
1097
|
+
void reconcilePendingBillingOperationCosts(supabase).catch(() => undefined);
|
|
1098
|
+
await syncStudioVariantAfterCapture({
|
|
1099
|
+
supabase,
|
|
1100
|
+
projectId: project.id,
|
|
1101
|
+
presetId,
|
|
1102
|
+
targetId: targets[0]?.id ?? null,
|
|
1103
|
+
lang,
|
|
1104
|
+
theme,
|
|
1105
|
+
elementName: element.name,
|
|
1106
|
+
captureId: captureRow.id,
|
|
1107
|
+
screenshotUrl,
|
|
1108
|
+
matchTargetId: false,
|
|
1109
|
+
}).catch(() => { });
|
|
1110
|
+
const altText = await generateAltText({
|
|
1111
|
+
url: pageRun.url,
|
|
1112
|
+
prompt: pageRun.prompt,
|
|
1113
|
+
lang,
|
|
1114
|
+
theme,
|
|
1115
|
+
targetLabel: targets[0]?.label,
|
|
1116
|
+
elementName: element.name,
|
|
1117
|
+
model: resolvedModel,
|
|
1118
|
+
apiKey: getOpenRouterKey(),
|
|
1119
|
+
}).catch(() => null);
|
|
1120
|
+
if (altText) {
|
|
1121
|
+
await supabase
|
|
1122
|
+
.from('captures')
|
|
1123
|
+
.update({ alt_text: altText })
|
|
1124
|
+
.eq('id', captureRow.id);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
finally {
|
|
1128
|
+
captureIndex += 1;
|
|
1129
|
+
await updateRunProgress();
|
|
1130
|
+
sendEvent('progress', {
|
|
1131
|
+
message: `[${captureIndex}/${totalCaptures}] ${lang}/${theme} · ${pageRun.pageId ?? 'main'} · element:${element.name}`,
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
const capturePlanPayload = {
|
|
1136
|
+
variants: variantPlan.map((variant) => ({
|
|
1137
|
+
key: variant.key,
|
|
1138
|
+
lang: variant.lang,
|
|
1139
|
+
theme: variant.theme,
|
|
1140
|
+
pages: variant.pages,
|
|
1141
|
+
})),
|
|
1142
|
+
totalCaptures,
|
|
1143
|
+
};
|
|
1144
|
+
sendEvent('capture_plan', capturePlanPayload);
|
|
1145
|
+
broadcast.send({ type: 'capture_plan', data: capturePlanPayload });
|
|
1146
|
+
const pageIdentityModel = (useVisionModelForAuxTasks && resolvedVisionModel) ? resolvedVisionModel : resolvedModel;
|
|
1147
|
+
const { identities: sharedPageIdentities } = await inferPageIdentitiesWithLLM(variantPlan[0]?.pages ?? [], pageIdentityModel, getOpenRouterKey(), Object.keys(providerPreferences).length > 0 ? providerPreferences : undefined);
|
|
1148
|
+
try {
|
|
1149
|
+
for (const [variantIndex, variant] of variantPlan.entries()) {
|
|
1150
|
+
if (aborted)
|
|
1151
|
+
throw new Error('Client disconnected');
|
|
1152
|
+
const { lang, theme, pages: variantPages } = variant;
|
|
1153
|
+
const firstTarget = targets[0];
|
|
1154
|
+
const firstPage = variantPages[0];
|
|
1155
|
+
const variantCaptureStartIndex = captureIndex;
|
|
1156
|
+
const expectedVariantCaptureCount = (variantPages.length * targets.length) + elements.length;
|
|
1157
|
+
const variantCaptureIds = [];
|
|
1158
|
+
try {
|
|
1159
|
+
const firstDomain = getCaptureUrlHostname(firstPage.url);
|
|
1160
|
+
const persistedSessionProfile = await loadSessionProfileForVariant(firstDomain, lang, theme);
|
|
1161
|
+
const bootstrapSelection = buildSessionBootstrapProfile({
|
|
1162
|
+
runAuthProfile: getRunSharedAuthProfile(firstPage.url),
|
|
1163
|
+
persistedVariantProfile: persistedSessionProfile,
|
|
1164
|
+
requestedLang: lang,
|
|
1165
|
+
requestedTheme: theme,
|
|
1166
|
+
startUrl: firstPage.url,
|
|
1167
|
+
});
|
|
1168
|
+
let bootstrapProfile = bootstrapSelection.profile;
|
|
1169
|
+
if (runSharedStorageState) {
|
|
1170
|
+
bootstrapProfile = bootstrapProfile
|
|
1171
|
+
? {
|
|
1172
|
+
...bootstrapProfile,
|
|
1173
|
+
storageState: runSharedStorageState,
|
|
1174
|
+
sessionStorage: runSharedSessionStorage ?? bootstrapProfile.sessionStorage,
|
|
1175
|
+
}
|
|
1176
|
+
: {
|
|
1177
|
+
storageState: runSharedStorageState,
|
|
1178
|
+
sessionStorage: runSharedSessionStorage,
|
|
1179
|
+
authState: 'authenticated',
|
|
1180
|
+
accountLabel: null,
|
|
1181
|
+
detectedLang: null,
|
|
1182
|
+
detectedTheme: null,
|
|
1183
|
+
validatedStartUrl: firstPage.url,
|
|
1184
|
+
lastKnownUrl: firstPage.url,
|
|
1185
|
+
summary: 'Reusing live storage state from the previous successful variant.',
|
|
1186
|
+
validationStatus: 'unknown',
|
|
1187
|
+
lastUsedAt: null,
|
|
1188
|
+
profileVersion: 1,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
sendEvent('progress', {
|
|
1192
|
+
message: `Variant ${variantIndex + 1}/${variantPlan.length}: ${lang}/${theme}`,
|
|
1193
|
+
});
|
|
1194
|
+
await remoteBrowser.recreateContext({
|
|
1195
|
+
viewport: firstTarget.viewport,
|
|
1196
|
+
deviceScaleFactor: outputScale,
|
|
1197
|
+
lang,
|
|
1198
|
+
colorScheme: theme,
|
|
1199
|
+
storageState: bootstrapProfile?.storageState,
|
|
1200
|
+
});
|
|
1201
|
+
await remoteBrowser.prepareSessionStorage(bootstrapProfile?.sessionStorage, { replace: false });
|
|
1202
|
+
let activeSessionProfile = bootstrapProfile;
|
|
1203
|
+
let carryoverContext;
|
|
1204
|
+
let variantState = createVariantCaptureState(variantPages, sharedPageIdentities);
|
|
1205
|
+
const elementAssignments = resolveIsolatedElementAssignments({
|
|
1206
|
+
elements,
|
|
1207
|
+
pageRuns: variantPages,
|
|
1208
|
+
});
|
|
1209
|
+
const variantStartSelectorMemory = resolveScopedSelectorMemory(await loadSelectorMemoryForVariant(firstDomain, lang, theme), {
|
|
1210
|
+
pageId: firstPage.pageId,
|
|
1211
|
+
pageIdentity: sharedPageIdentities[firstPage.pageId ?? 'main'] ?? null,
|
|
1212
|
+
pageUrl: firstPage.url,
|
|
1213
|
+
});
|
|
1214
|
+
if (!urlMatchesCaptureTarget(remoteBrowser.currentPage.url(), firstPage.url)) {
|
|
1215
|
+
await remoteBrowser.navigateTo(firstPage.url);
|
|
1216
|
+
await remoteBrowser.wait(500);
|
|
1217
|
+
await remoteBrowser.dismissOverlays().catch(() => undefined);
|
|
1218
|
+
}
|
|
1219
|
+
// Pre-authenticate before the language preflight when credentials are available.
|
|
1220
|
+
// Language controls are often locked behind login — without this the preflight
|
|
1221
|
+
// wastes all its iterations trying to switch language on the public page.
|
|
1222
|
+
// We force login (bypassing authMatches) because the persisted session profile
|
|
1223
|
+
// may report "authenticated" even though the current browser context has no
|
|
1224
|
+
// active session (cookies were restored but the server session expired).
|
|
1225
|
+
if (credentials?.email && credentials?.password && credentials.loginUrl) {
|
|
1226
|
+
try {
|
|
1227
|
+
await remoteBrowser.navigateTo(credentials.loginUrl);
|
|
1228
|
+
await remoteBrowser.wait(500);
|
|
1229
|
+
await performDeterministicSessionRepair(remoteBrowser, {
|
|
1230
|
+
startUrl: firstPage.url,
|
|
1231
|
+
requestedLang: lang,
|
|
1232
|
+
requestedTheme: theme,
|
|
1233
|
+
credentials,
|
|
1234
|
+
profile: activeSessionProfile,
|
|
1235
|
+
selectorMemory: variantStartSelectorMemory,
|
|
1236
|
+
});
|
|
1237
|
+
sendEvent('progress', { message: 'Pre-authenticated before language preflight.' });
|
|
1238
|
+
// Navigate back to the start page after login redirect
|
|
1239
|
+
if (!urlMatchesCaptureTarget(remoteBrowser.currentPage.url(), firstPage.url)) {
|
|
1240
|
+
await remoteBrowser.navigateTo(firstPage.url);
|
|
1241
|
+
await remoteBrowser.wait(500);
|
|
1242
|
+
await remoteBrowser.dismissOverlays().catch(() => undefined);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
catch {
|
|
1246
|
+
// Non-blocking: if pre-auth fails the language preflight will retry.
|
|
1247
|
+
if (!urlMatchesCaptureTarget(remoteBrowser.currentPage.url(), firstPage.url)) {
|
|
1248
|
+
await remoteBrowser.navigateTo(firstPage.url).catch(() => undefined);
|
|
1249
|
+
await remoteBrowser.wait(500);
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
const variantLanguagePreflight = await ensureScreenshotVariantLanguage({
|
|
1254
|
+
browser: remoteBrowser,
|
|
1255
|
+
requestedLang: lang,
|
|
1256
|
+
requestedTheme: theme,
|
|
1257
|
+
startUrl: firstPage.url,
|
|
1258
|
+
profile: activeSessionProfile,
|
|
1259
|
+
credentials,
|
|
1260
|
+
selectorMemory: variantStartSelectorMemory,
|
|
1261
|
+
langInstructions: config.langInstructions,
|
|
1262
|
+
themeInstructions: config.themeInstructions,
|
|
1263
|
+
onLog: (message) => {
|
|
1264
|
+
sendEvent('progress', { message });
|
|
1265
|
+
},
|
|
1266
|
+
rebaseToStartUrl: async () => {
|
|
1267
|
+
await remoteBrowser.navigateTo(firstPage.url);
|
|
1268
|
+
await remoteBrowser.wait(500);
|
|
1269
|
+
await remoteBrowser.dismissOverlays().catch(() => undefined);
|
|
1270
|
+
},
|
|
1271
|
+
runLanguageSwitchAgent: async ({ languageState, themeState }) => {
|
|
1272
|
+
return withBroadcastCallbacks({ lang, theme }, () => runAgent(remoteBrowser, {
|
|
1273
|
+
url: remoteBrowser.currentPage.url() || firstPage.url,
|
|
1274
|
+
prompt: [
|
|
1275
|
+
`Prepare the current page so the UI is rendered in "${lang}" and the theme is "${theme}".`,
|
|
1276
|
+
`Current inspection: language=${languageState?.detected ?? 'unknown'}; theme=${themeState?.detected ?? 'unknown'}.`,
|
|
1277
|
+
`Strategy: try navigate_to /settings first for language/theme controls; if the settings page has no such controls, look for an account/profile button (usually at the bottom of the sidebar — NOT the app logo) and open its dropdown menu. Do not click the same element more than once if it had no effect.`,
|
|
1278
|
+
config.langInstructions ? `Language switch hint: ${config.langInstructions}` : '',
|
|
1279
|
+
firstPage.prompt,
|
|
1280
|
+
].filter(Boolean).join(' '),
|
|
1281
|
+
dark: theme === 'dark',
|
|
1282
|
+
langs: [lang],
|
|
1283
|
+
outputDir: path.join(os.tmpdir(), 'autokap-ws'),
|
|
1284
|
+
headed: false,
|
|
1285
|
+
viewport: firstTarget.viewport,
|
|
1286
|
+
maxIterations: Math.min(maxIterations, 12),
|
|
1287
|
+
model: resolvedModel,
|
|
1288
|
+
fallbackModel: resolvedFallback,
|
|
1289
|
+
visionModel: resolvedVisionModel,
|
|
1290
|
+
providerPreferences: Object.keys(providerPreferences).length > 0 ? providerPreferences : undefined,
|
|
1291
|
+
credentials,
|
|
1292
|
+
currentLang: lang,
|
|
1293
|
+
currentTheme: theme,
|
|
1294
|
+
selectorMemory: Object.keys(variantStartSelectorMemory).length > 0 ? variantStartSelectorMemory : undefined,
|
|
1295
|
+
sessionProfile: activeSessionProfile,
|
|
1296
|
+
langInstructions: config.langInstructions,
|
|
1297
|
+
themeInstructions: config.themeInstructions,
|
|
1298
|
+
runHints: runHints && runHints.length > 0 ? runHints : undefined,
|
|
1299
|
+
runMode: 'language_preflight',
|
|
1300
|
+
abortSignal: abortController.signal,
|
|
1301
|
+
}, getOpenRouterKey()));
|
|
1302
|
+
},
|
|
1303
|
+
});
|
|
1304
|
+
if (variantLanguagePreflight.selectorUpdates.length > 0) {
|
|
1305
|
+
const scopedUpdates = scopeSelectorMemoryUpdates(variantLanguagePreflight.selectorUpdates, {
|
|
1306
|
+
pageId: firstPage.pageId,
|
|
1307
|
+
pageIdentity: sharedPageIdentities[firstPage.pageId ?? 'main'] ?? null,
|
|
1308
|
+
pageUrl: firstPage.url,
|
|
1309
|
+
});
|
|
1310
|
+
const cacheKey = `${firstDomain}:${lang}:${theme}`;
|
|
1311
|
+
selectorMemoryCache.set(cacheKey, applySelectorMemoryUpdates(selectorMemoryCache.get(cacheKey) ?? {}, scopedUpdates));
|
|
1312
|
+
void persistScreenshotSelectorMemoryUpdates(supabase, {
|
|
1313
|
+
projectId: project.id,
|
|
1314
|
+
presetId,
|
|
1315
|
+
domain: firstDomain,
|
|
1316
|
+
lang,
|
|
1317
|
+
theme,
|
|
1318
|
+
}, scopedUpdates).catch(() => undefined);
|
|
1319
|
+
}
|
|
1320
|
+
if (!variantLanguagePreflight.ok) {
|
|
1321
|
+
throw new Error(variantLanguagePreflight.reason
|
|
1322
|
+
?? `Language preflight failed before starting the main workflow for ${lang}/${theme}.`);
|
|
1323
|
+
}
|
|
1324
|
+
if (activeSessionProfile?.authState === 'authenticated'
|
|
1325
|
+
&& activeSessionProfile.validationStatus !== 'invalid') {
|
|
1326
|
+
rememberRunSharedAuthProfile(activeSessionProfile, remoteBrowser.currentPage.url());
|
|
1327
|
+
}
|
|
1328
|
+
for (const [pageIndex, pageRun] of variantPages.entries()) {
|
|
1329
|
+
if (aborted)
|
|
1330
|
+
throw new Error('Client disconnected');
|
|
1331
|
+
const pageId = pageRun.pageId ?? 'main';
|
|
1332
|
+
const pageDomain = getCaptureUrlHostname(pageRun.url);
|
|
1333
|
+
variantState = markVariantCaptureInProgress(variantState, pageRun.pageId);
|
|
1334
|
+
const variantManifest = buildVariantManifestContext({
|
|
1335
|
+
state: variantState,
|
|
1336
|
+
currentPageRun: pageRun,
|
|
1337
|
+
});
|
|
1338
|
+
const selectorMemory = resolveScopedSelectorMemory(mergeSelectorMemory(await loadSelectorMemoryForVariant(pageDomain, lang, theme), carryoverContext?.selectorMemory), {
|
|
1339
|
+
pageId: pageRun.pageId,
|
|
1340
|
+
pageIdentity: variantManifest.currentPageIdentity ?? null,
|
|
1341
|
+
pageUrl: pageRun.url,
|
|
1342
|
+
});
|
|
1343
|
+
let trustedHandoffContext;
|
|
1344
|
+
const buildConfig = (viewport = firstTarget.viewport) => {
|
|
1345
|
+
const nextConfig = {
|
|
1346
|
+
url: pageRun.url,
|
|
1347
|
+
prompt: pageRun.prompt,
|
|
1348
|
+
dark: theme === 'dark',
|
|
1349
|
+
langs: [lang],
|
|
1350
|
+
outputDir: path.join(os.tmpdir(), 'autokap-ws'),
|
|
1351
|
+
headed: false,
|
|
1352
|
+
viewport,
|
|
1353
|
+
maxIterations,
|
|
1354
|
+
model: resolvedModel,
|
|
1355
|
+
fallbackModel: resolvedFallback,
|
|
1356
|
+
visionModel: resolvedVisionModel,
|
|
1357
|
+
providerPreferences: Object.keys(providerPreferences).length > 0 ? providerPreferences : undefined,
|
|
1358
|
+
credentials,
|
|
1359
|
+
langInstructions: config.langInstructions,
|
|
1360
|
+
themeInstructions: config.themeInstructions,
|
|
1361
|
+
currentLang: lang,
|
|
1362
|
+
currentTheme: theme,
|
|
1363
|
+
reasoningLocale: config.reasoningLocale,
|
|
1364
|
+
reasoningEffort: config.reasoningEffort ?? 'medium',
|
|
1365
|
+
reasoningCapableModels: config.reasoningCapableModels,
|
|
1366
|
+
runHints: runHints && runHints.length > 0 ? runHints : undefined,
|
|
1367
|
+
selectorMemory: Object.keys(selectorMemory).length > 0 ? selectorMemory : undefined,
|
|
1368
|
+
sessionProfile: activeSessionProfile,
|
|
1369
|
+
handoffContext: trustedHandoffContext,
|
|
1370
|
+
variantManifest,
|
|
1371
|
+
runMode: 'capture',
|
|
1372
|
+
analyticsId: auth.userId,
|
|
1373
|
+
enableGeminiExplicitCache,
|
|
1374
|
+
enableCacheLayoutV2,
|
|
1375
|
+
useVisionModelForAuxTasks,
|
|
1376
|
+
enableDeterministicRecovery: true,
|
|
1377
|
+
enableRecoveryEvaluator: true,
|
|
1378
|
+
enableSalienceCompression: true,
|
|
1379
|
+
abortSignal: abortController.signal,
|
|
1380
|
+
};
|
|
1381
|
+
if (variantIndex > 1) {
|
|
1382
|
+
const reference = liveVariantReference.get(pageId);
|
|
1383
|
+
if (reference) {
|
|
1384
|
+
nextConfig.variantReference = reference;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return nextConfig;
|
|
1388
|
+
};
|
|
1389
|
+
sendEvent('progress', {
|
|
1390
|
+
message: `Preparing ${lang}/${theme} · ${pageId} (${pageIndex + 1}/${variantPages.length})`,
|
|
1391
|
+
});
|
|
1392
|
+
let primaryAgentResult = null;
|
|
1393
|
+
await remoteBrowser.dismissOverlays().catch(() => undefined);
|
|
1394
|
+
let baselineObservation = await remoteBrowser.captureObservation().catch(() => null);
|
|
1395
|
+
if (shouldNormalizeDialogsBeforeTargetReuse({
|
|
1396
|
+
currentDialogCount: baselineObservation?.dialogCount,
|
|
1397
|
+
pageIdentity: variantManifest.currentPageIdentity ?? null,
|
|
1398
|
+
})) {
|
|
1399
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1400
|
+
await remoteBrowser.pressKey('Escape').catch(() => undefined);
|
|
1401
|
+
await remoteBrowser.wait(180);
|
|
1402
|
+
baselineObservation = await remoteBrowser.captureObservation().catch(() => null);
|
|
1403
|
+
if (!shouldNormalizeDialogsBeforeTargetReuse({
|
|
1404
|
+
currentDialogCount: baselineObservation?.dialogCount,
|
|
1405
|
+
pageIdentity: variantManifest.currentPageIdentity ?? null,
|
|
1406
|
+
})) {
|
|
1407
|
+
break;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
await remoteBrowser.wait(250);
|
|
1412
|
+
baselineObservation = baselineObservation ?? await remoteBrowser.captureObservation().catch(() => null);
|
|
1413
|
+
const baselineSignals = await remoteBrowser.capturePageSignals({
|
|
1414
|
+
timeoutMs: 1_500,
|
|
1415
|
+
}).catch(() => null);
|
|
1416
|
+
const transition = decideCaptureTransition({
|
|
1417
|
+
pageIndex,
|
|
1418
|
+
currentUrl: remoteBrowser.currentPage.url(),
|
|
1419
|
+
targetUrl: pageRun.url,
|
|
1420
|
+
pageIdentity: variantManifest.currentPageIdentity ?? null,
|
|
1421
|
+
observation: baselineObservation,
|
|
1422
|
+
pageSignals: baselineSignals,
|
|
1423
|
+
profile: activeSessionProfile,
|
|
1424
|
+
handoffContext: carryoverContext,
|
|
1425
|
+
requestedLang: lang,
|
|
1426
|
+
requestedTheme: theme,
|
|
1427
|
+
});
|
|
1428
|
+
trustedHandoffContext = transition.allowFastPath ? carryoverContext : undefined;
|
|
1429
|
+
sendEvent('progress', {
|
|
1430
|
+
message: `Transition ${lang}/${theme} · ${pageId}: ${transition.mode}`
|
|
1431
|
+
+ (transition.reasons[0] ? ` (${transition.reasons[0]})` : ''),
|
|
1432
|
+
});
|
|
1433
|
+
if (transition.mode === 'dirty') {
|
|
1434
|
+
const storageState = await remoteBrowser.exportStorageState().catch(() => undefined)
|
|
1435
|
+
?? activeSessionProfile?.storageState;
|
|
1436
|
+
const sessionStorage = await remoteBrowser.exportSessionStorage().catch(() => undefined)
|
|
1437
|
+
?? activeSessionProfile?.sessionStorage;
|
|
1438
|
+
await remoteBrowser.recreateContext({
|
|
1439
|
+
viewport: firstTarget.viewport,
|
|
1440
|
+
deviceScaleFactor: outputScale,
|
|
1441
|
+
lang,
|
|
1442
|
+
colorScheme: theme,
|
|
1443
|
+
storageState,
|
|
1444
|
+
});
|
|
1445
|
+
await remoteBrowser.prepareSessionStorage(sessionStorage, { replace: false });
|
|
1446
|
+
await remoteBrowser.navigateTo(pageRun.url);
|
|
1447
|
+
await remoteBrowser.wait(500);
|
|
1448
|
+
await remoteBrowser.dismissOverlays().catch(() => undefined);
|
|
1449
|
+
trustedHandoffContext = undefined;
|
|
1450
|
+
}
|
|
1451
|
+
else if (transition.shouldNavigateToCanonical) {
|
|
1452
|
+
await remoteBrowser.navigateTo(pageRun.url);
|
|
1453
|
+
await remoteBrowser.wait(500);
|
|
1454
|
+
await remoteBrowser.dismissOverlays().catch(() => undefined);
|
|
1455
|
+
trustedHandoffContext = undefined;
|
|
1456
|
+
}
|
|
1457
|
+
if (transition.allowFastPath) {
|
|
1458
|
+
const readiness = await verifyCaptureReadiness(remoteBrowser, buildConfig(), getOpenRouterKey(), {
|
|
1459
|
+
assessment: 'Sequential handoff preflight: approve only if the current live page already satisfies this capture.',
|
|
1460
|
+
stepNumber: 0,
|
|
1461
|
+
}).catch(() => null);
|
|
1462
|
+
if (readiness && isTerminalVerificationSuccess(readiness)) {
|
|
1463
|
+
primaryAgentResult = buildPreverifiedAgentResult('Capture approved directly from sequential handoff state.', readiness);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
if (!primaryAgentResult && variantIndex > 0) {
|
|
1467
|
+
const recordedActions = liveVariantActions.get(pageId);
|
|
1468
|
+
if (recordedActions && recordedActions.length > 0) {
|
|
1469
|
+
sendEvent('progress', {
|
|
1470
|
+
message: `Replaying first successful variant for ${lang}/${theme} · ${pageId}`,
|
|
1471
|
+
});
|
|
1472
|
+
primaryAgentResult = await withBroadcastCallbacks({ lang, theme, targetId: firstTarget.id }, () => replayAgent(remoteBrowser, buildConfig(), getOpenRouterKey(), recordedActions, { allowFullAgentFallback: true })).catch((error) => makeFailedAgentResult(error.message));
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
if (!primaryAgentResult) {
|
|
1476
|
+
primaryAgentResult = await withBroadcastCallbacks({ lang, theme, targetId: firstTarget.id }, () => runAgent(remoteBrowser, buildConfig(), getOpenRouterKey())).catch((error) => makeFailedAgentResult(error.message));
|
|
1477
|
+
}
|
|
1478
|
+
const preparedPageUrl = remoteBrowser.currentPage.url() || pageRun.url;
|
|
1479
|
+
await persistFullPageCapture({
|
|
1480
|
+
pageRun,
|
|
1481
|
+
target: firstTarget,
|
|
1482
|
+
lang,
|
|
1483
|
+
theme,
|
|
1484
|
+
agentResult: primaryAgentResult,
|
|
1485
|
+
includeActions: true,
|
|
1486
|
+
includeWorkflowScreenshots: true,
|
|
1487
|
+
});
|
|
1488
|
+
for (const target of targets.slice(1)) {
|
|
1489
|
+
await lockCaptureViewport(remoteBrowser, target.viewport, { settleMs: 250 });
|
|
1490
|
+
// Re-navigate to force a full CSS re-layout at the new viewport width.
|
|
1491
|
+
// Without this, the page content stays rendered at firstTarget's width
|
|
1492
|
+
// and gets squeezed into the smaller viewport.
|
|
1493
|
+
const currentUrl = remoteBrowser.currentPage.url();
|
|
1494
|
+
if (currentUrl) {
|
|
1495
|
+
await remoteBrowser.navigateTo(currentUrl);
|
|
1496
|
+
await remoteBrowser.wait(500);
|
|
1497
|
+
}
|
|
1498
|
+
await remoteBrowser.dismissOverlays().catch(() => undefined);
|
|
1499
|
+
let targetResult;
|
|
1500
|
+
const targetConfig = buildConfig(target.viewport);
|
|
1501
|
+
const targetReadiness = await verifyCaptureReadiness(remoteBrowser, targetConfig, getOpenRouterKey(), {
|
|
1502
|
+
assessment: 'Same-session target recheck: approve only if the resized page still satisfies the capture.',
|
|
1503
|
+
stepNumber: 0,
|
|
1504
|
+
}).catch(() => null);
|
|
1505
|
+
if (targetReadiness && isTerminalVerificationSuccess(targetReadiness)) {
|
|
1506
|
+
targetResult = buildPreverifiedAgentResult('Capture approved after same-session target recheck.', targetReadiness);
|
|
1507
|
+
}
|
|
1508
|
+
else {
|
|
1509
|
+
targetResult = await withBroadcastCallbacks({ lang, theme, targetId: target.id }, () => replayAgent(remoteBrowser, targetConfig, getOpenRouterKey(), primaryAgentResult.actions, { allowFullAgentFallback: false })).catch((error) => makeFailedAgentResult(error.message));
|
|
1510
|
+
}
|
|
1511
|
+
await persistFullPageCapture({
|
|
1512
|
+
pageRun,
|
|
1513
|
+
target,
|
|
1514
|
+
lang,
|
|
1515
|
+
theme,
|
|
1516
|
+
agentResult: targetResult,
|
|
1517
|
+
includeActions: false,
|
|
1518
|
+
includeWorkflowScreenshots: false,
|
|
1519
|
+
});
|
|
1520
|
+
await restorePreparedPageState(remoteBrowser, firstTarget.viewport, preparedPageUrl);
|
|
1521
|
+
}
|
|
1522
|
+
if (isTerminalAgentResultSuccess(primaryAgentResult)) {
|
|
1523
|
+
const recorded = recordValidatedVariantCapture({
|
|
1524
|
+
state: variantState,
|
|
1525
|
+
capture: {
|
|
1526
|
+
pageId,
|
|
1527
|
+
prompt: pageRun.prompt,
|
|
1528
|
+
url: pageRun.url,
|
|
1529
|
+
assessment: primaryAgentResult.assessment,
|
|
1530
|
+
fingerprint: primaryAgentResult.verification?.pageFingerprint ?? null,
|
|
1531
|
+
identity: variantManifest.currentPageIdentity ?? null,
|
|
1532
|
+
},
|
|
1533
|
+
});
|
|
1534
|
+
variantState = recorded.state;
|
|
1535
|
+
if (recorded.duplicateOfPageId) {
|
|
1536
|
+
const blockingReason = `Duplicate capture blocked: page "${pageId}" matches previously validated page "${recorded.duplicateOfPageId}".`;
|
|
1537
|
+
variantState = markVariantCaptureBlocked({
|
|
1538
|
+
state: variantState,
|
|
1539
|
+
pageId,
|
|
1540
|
+
reason: blockingReason,
|
|
1541
|
+
});
|
|
1542
|
+
primaryAgentResult = {
|
|
1543
|
+
...primaryAgentResult,
|
|
1544
|
+
success: false,
|
|
1545
|
+
assessment: blockingReason,
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
if (isTerminalAgentResultSuccess(primaryAgentResult)) {
|
|
1550
|
+
if (primaryAgentResult.actions.length > 0 && !liveVariantActions.has(pageId)) {
|
|
1551
|
+
liveVariantActions.set(pageId, primaryAgentResult.actions);
|
|
1552
|
+
}
|
|
1553
|
+
if (!liveVariantReference.has(pageId)) {
|
|
1554
|
+
liveVariantReference.set(pageId, {
|
|
1555
|
+
finalUrl: remoteBrowser.currentPage.url() || pageRun.url,
|
|
1556
|
+
assessment: primaryAgentResult.assessment,
|
|
1557
|
+
pageTitle: await remoteBrowser.currentPage.title().catch(() => ''),
|
|
1558
|
+
actions: primaryAgentResult.actions,
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
if (primaryAgentResult.actions.length > 0) {
|
|
1562
|
+
const scopeContext = {
|
|
1563
|
+
pageId: pageRun.pageId,
|
|
1564
|
+
pageIdentity: variantManifest.currentPageIdentity ?? null,
|
|
1565
|
+
pageUrl: pageRun.url,
|
|
1566
|
+
};
|
|
1567
|
+
const selectorUpdates = scopeSelectorMemoryUpdates(extractSelectorUpdates(primaryAgentResult.actions), scopeContext);
|
|
1568
|
+
if (selectorUpdates.length > 0) {
|
|
1569
|
+
const cacheKey = `${pageDomain}:${lang}:${theme}`;
|
|
1570
|
+
selectorMemoryCache.set(cacheKey, applySelectorMemoryUpdates(selectorMemoryCache.get(cacheKey) ?? {}, selectorUpdates));
|
|
1571
|
+
void persistScreenshotSelectorMemoryUpdates(supabase, {
|
|
1572
|
+
projectId: project.id,
|
|
1573
|
+
presetId,
|
|
1574
|
+
domain: pageDomain,
|
|
1575
|
+
lang,
|
|
1576
|
+
theme,
|
|
1577
|
+
}, selectorUpdates).catch(() => undefined);
|
|
1578
|
+
}
|
|
1579
|
+
}
|
|
1580
|
+
const pageElements = elementAssignments
|
|
1581
|
+
.filter((assignment) => assignment.pageId === pageId)
|
|
1582
|
+
.map((assignment) => assignment.element);
|
|
1583
|
+
if (pageElements.length > 0) {
|
|
1584
|
+
await restorePreparedPageState(remoteBrowser, firstTarget.viewport, preparedPageUrl);
|
|
1585
|
+
await remoteBrowser.forceLoadLazyImages({ timeout: 8000 }).catch(() => undefined);
|
|
1586
|
+
}
|
|
1587
|
+
for (const element of pageElements) {
|
|
1588
|
+
const elementResult = await captureIsolatedElement(remoteBrowser, element, getOpenRouterKey(), resolvedModel).catch((error) => {
|
|
1589
|
+
logger.error(`Element "${element.name}" capture failed: ${error.message}`);
|
|
1590
|
+
return null;
|
|
1591
|
+
});
|
|
1592
|
+
if (elementResult) {
|
|
1593
|
+
await persistElementCapture({
|
|
1594
|
+
pageRun,
|
|
1595
|
+
element,
|
|
1596
|
+
lang,
|
|
1597
|
+
theme,
|
|
1598
|
+
result: elementResult,
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
else {
|
|
1602
|
+
captureResults.push({
|
|
1603
|
+
url: pageRun.url,
|
|
1604
|
+
lang,
|
|
1605
|
+
theme,
|
|
1606
|
+
captureType: 'element',
|
|
1607
|
+
elementName: element.name,
|
|
1608
|
+
targetId: targets[0]?.id,
|
|
1609
|
+
targetLabel: targets[0]?.label,
|
|
1610
|
+
success: false,
|
|
1611
|
+
assessment: `Element capture failed for ${element.name}.`,
|
|
1612
|
+
iterations: 0,
|
|
1613
|
+
});
|
|
1614
|
+
captureIndex += 1;
|
|
1615
|
+
await updateRunProgress();
|
|
1616
|
+
sendEvent('progress', {
|
|
1617
|
+
message: `[${captureIndex}/${totalCaptures}] ${lang}/${theme} · ${pageRun.pageId ?? 'main'} · element:${element.name} failed`,
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
const storageState = await remoteBrowser.exportStorageState().catch(() => undefined);
|
|
1622
|
+
const sessionStorage = await remoteBrowser.exportSessionStorage().catch(() => undefined);
|
|
1623
|
+
const currentUrl = remoteBrowser.currentPage.url() || preparedPageUrl;
|
|
1624
|
+
activeSessionProfile = buildPersistableProfile(activeSessionProfile, storageState, sessionStorage, currentUrl, lang, theme);
|
|
1625
|
+
if (activeSessionProfile.storageState) {
|
|
1626
|
+
runSharedStorageState = activeSessionProfile.storageState;
|
|
1627
|
+
runSharedSessionStorage = activeSessionProfile.sessionStorage;
|
|
1628
|
+
await persistSessionProfile(pageDomain, lang, theme, activeSessionProfile);
|
|
1629
|
+
}
|
|
1630
|
+
rememberRunSharedAuthProfile(activeSessionProfile, currentUrl);
|
|
1631
|
+
carryoverContext = await buildHandoffContext({
|
|
1632
|
+
browser: remoteBrowser,
|
|
1633
|
+
pageRun,
|
|
1634
|
+
profile: activeSessionProfile,
|
|
1635
|
+
lang,
|
|
1636
|
+
theme,
|
|
1637
|
+
selectorMemory,
|
|
1638
|
+
actions: primaryAgentResult.actions,
|
|
1639
|
+
agentResult: primaryAgentResult,
|
|
1640
|
+
});
|
|
1641
|
+
}
|
|
1642
|
+
else {
|
|
1643
|
+
variantState = markVariantCaptureBlocked({
|
|
1644
|
+
state: variantState,
|
|
1645
|
+
pageId,
|
|
1646
|
+
reason: primaryAgentResult.assessment || `Capture failed for ${pageId}.`,
|
|
1647
|
+
});
|
|
1648
|
+
carryoverContext = undefined;
|
|
1649
|
+
}
|
|
1650
|
+
// Always export storage state after each page attempt so subsequent
|
|
1651
|
+
// variants can reuse the authenticated session even if this capture failed.
|
|
1652
|
+
try {
|
|
1653
|
+
const latestStorageState = await remoteBrowser.exportStorageState();
|
|
1654
|
+
if (latestStorageState) {
|
|
1655
|
+
runSharedStorageState = latestStorageState;
|
|
1656
|
+
runSharedSessionStorage = await remoteBrowser.exportSessionStorage().catch(() => undefined);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
catch {
|
|
1660
|
+
// Best-effort: keep previous storage state
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
const variantValidation = validateVariantCaptureState(variantState);
|
|
1664
|
+
if (!variantValidation.ok) {
|
|
1665
|
+
sendEvent('progress', {
|
|
1666
|
+
message: `Variant ${lang}/${theme} completed with blocking issues: `
|
|
1667
|
+
+ [
|
|
1668
|
+
...variantValidation.missingPages.map((pageId) => `missing ${pageId}`),
|
|
1669
|
+
...variantValidation.blockedPages.map((entry) => `blocked ${entry.pageId}`),
|
|
1670
|
+
...variantValidation.duplicatePageIds.map((entry) => `duplicate ${entry.pageId}->${entry.duplicateOfPageId}`),
|
|
1671
|
+
].join(', '),
|
|
1672
|
+
});
|
|
1673
|
+
}
|
|
1674
|
+
sendEvent('variant_complete', {
|
|
1675
|
+
lang,
|
|
1676
|
+
theme,
|
|
1677
|
+
success: variantValidation.ok,
|
|
1678
|
+
reason: variantValidation.ok ? null : 'Variant completed with blocking issues.',
|
|
1679
|
+
});
|
|
1680
|
+
broadcast.send({ type: 'variant_complete', data: { lang, theme, success: variantValidation.ok, reason: variantValidation.ok ? undefined : 'Variant completed with blocking issues.' } });
|
|
1681
|
+
}
|
|
1682
|
+
catch (error) {
|
|
1683
|
+
if (aborted)
|
|
1684
|
+
throw error;
|
|
1685
|
+
const message = error.message;
|
|
1686
|
+
logger.error(`Variant ${lang}/${theme} failed: ${message}`);
|
|
1687
|
+
// Revoke credits for captures that were recorded during this failed variant
|
|
1688
|
+
for (const failedCaptureId of variantCaptureIds) {
|
|
1689
|
+
await revokeCreditUsage(supabase, failedCaptureId).catch((err) => logger.error(`Failed to revoke credit for capture ${failedCaptureId}: ${err.message}`));
|
|
1690
|
+
}
|
|
1691
|
+
const consumedCaptures = captureIndex - variantCaptureStartIndex;
|
|
1692
|
+
const remainingCaptures = Math.max(0, expectedVariantCaptureCount - consumedCaptures);
|
|
1693
|
+
if (remainingCaptures > 0) {
|
|
1694
|
+
captureIndex += remainingCaptures;
|
|
1695
|
+
await updateRunProgress();
|
|
1696
|
+
}
|
|
1697
|
+
getServerPostHog()?.capture({
|
|
1698
|
+
distinctId: auth.userId,
|
|
1699
|
+
event: 'capture_variant_error',
|
|
1700
|
+
properties: {
|
|
1701
|
+
runId,
|
|
1702
|
+
presetId,
|
|
1703
|
+
projectId: project.id,
|
|
1704
|
+
lang,
|
|
1705
|
+
theme,
|
|
1706
|
+
error: message.slice(0, 500),
|
|
1707
|
+
variantIndex,
|
|
1708
|
+
source: 'ws_remote_playwright',
|
|
1709
|
+
},
|
|
1710
|
+
});
|
|
1711
|
+
sendEvent('progress', {
|
|
1712
|
+
message: `Variant ${lang}/${theme} failed: ${message}`,
|
|
1713
|
+
});
|
|
1714
|
+
sendEvent('variant_complete', {
|
|
1715
|
+
lang,
|
|
1716
|
+
theme,
|
|
1717
|
+
success: false,
|
|
1718
|
+
reason: message,
|
|
1719
|
+
});
|
|
1720
|
+
broadcast.send({ type: 'variant_complete', data: { lang, theme, success: false, reason: message } });
|
|
1721
|
+
continue;
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
catch (error) {
|
|
1726
|
+
const message = error.message;
|
|
1727
|
+
logger.error(`Capture run failed: ${message}`);
|
|
1728
|
+
await supabase
|
|
1729
|
+
.from('capture_runs')
|
|
1730
|
+
.update({
|
|
1731
|
+
status: 'failed',
|
|
1732
|
+
error_message: message,
|
|
1733
|
+
completed_at: new Date().toISOString(),
|
|
1734
|
+
})
|
|
1735
|
+
.eq('id', runId);
|
|
1736
|
+
sendEvent('error', { message });
|
|
1737
|
+
broadcast.send({ type: 'error', data: { message } });
|
|
1738
|
+
broadcast.close();
|
|
1739
|
+
await finalizeRunSideEffects();
|
|
1740
|
+
remoteBrowser.destroy();
|
|
1741
|
+
await closeMockup();
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
await supabase
|
|
1745
|
+
.from('capture_runs')
|
|
1746
|
+
.update({
|
|
1747
|
+
status: 'completed',
|
|
1748
|
+
progress_current: totalCaptures,
|
|
1749
|
+
credits_used: successCount,
|
|
1750
|
+
completed_at: new Date().toISOString(),
|
|
1751
|
+
})
|
|
1752
|
+
.eq('id', runId);
|
|
1753
|
+
getServerPostHog()?.capture({
|
|
1754
|
+
distinctId: auth.userId,
|
|
1755
|
+
event: 'capture_run_optimization_summary',
|
|
1756
|
+
properties: {
|
|
1757
|
+
runId,
|
|
1758
|
+
presetId,
|
|
1759
|
+
projectId: project.id,
|
|
1760
|
+
successCount,
|
|
1761
|
+
totalCaptures,
|
|
1762
|
+
source: 'ws_remote_playwright',
|
|
1763
|
+
},
|
|
1764
|
+
});
|
|
1765
|
+
sendEvent('done', { summary: { successes: successCount, total: totalCaptures } });
|
|
1766
|
+
broadcast.send({ type: 'done', data: { totalCaptures, successCount } });
|
|
1767
|
+
broadcast.close();
|
|
1768
|
+
if (plan.entitlements.captureCompleteWebhook) {
|
|
1769
|
+
try {
|
|
1770
|
+
const webhookConfig = await getProjectWebhookConfig(supabase, project.id);
|
|
1771
|
+
if (webhookConfig) {
|
|
1772
|
+
await dispatchProjectCaptureWebhook({
|
|
1773
|
+
config: webhookConfig,
|
|
1774
|
+
payload: buildCaptureWebhookPayload({
|
|
1775
|
+
runId,
|
|
1776
|
+
projectId: project.id,
|
|
1777
|
+
presetId,
|
|
1778
|
+
results: captureResults,
|
|
1779
|
+
}),
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
// Non-blocking.
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
await finalizeRunSideEffects();
|
|
1788
|
+
remoteBrowser.destroy();
|
|
1789
|
+
await closeMockup();
|
|
1790
|
+
logger.success(`Run ${runId} completed: ${successCount}/${totalCaptures}`);
|
|
1791
|
+
}
|
|
1792
|
+
// Standalone utilities have been extracted to ws-handler-utils.ts
|
|
1793
|
+
//# sourceMappingURL=ws-handler.js.map
|