autokap 1.0.6 → 1.0.7

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