@web-auto/camo 0.2.0 → 0.2.2

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 (114) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +586 -586
  3. package/bin/browser-service.mjs +11 -11
  4. package/bin/camo.mjs +22 -22
  5. package/package.json +48 -48
  6. package/scripts/build.mjs +19 -19
  7. package/scripts/bump-version.mjs +34 -34
  8. package/scripts/check-file-size.mjs +80 -80
  9. package/scripts/file-size-policy.json +12 -2
  10. package/scripts/install.mjs +76 -76
  11. package/scripts/release.sh +54 -54
  12. package/src/autoscript/action-providers/index.mjs +6 -6
  13. package/src/autoscript/impact-engine.mjs +78 -78
  14. package/src/autoscript/runtime.mjs +1017 -1017
  15. package/src/autoscript/schema.mjs +376 -376
  16. package/src/cli.mjs +405 -405
  17. package/src/commands/attach.mjs +141 -141
  18. package/src/commands/autoscript.mjs +1011 -1011
  19. package/src/commands/browser.mjs +1255 -1255
  20. package/src/commands/container.mjs +401 -401
  21. package/src/commands/cookies.mjs +69 -69
  22. package/src/commands/create.mjs +98 -98
  23. package/src/commands/devtools.mjs +349 -349
  24. package/src/commands/events.mjs +152 -152
  25. package/src/commands/highlight-mode.mjs +24 -24
  26. package/src/commands/init.mjs +68 -68
  27. package/src/commands/lifecycle.mjs +275 -275
  28. package/src/commands/mouse.mjs +45 -45
  29. package/src/commands/profile.mjs +46 -46
  30. package/src/commands/record.mjs +115 -115
  31. package/src/commands/system.mjs +14 -14
  32. package/src/commands/window.mjs +123 -123
  33. package/src/container/change-notifier.mjs +362 -362
  34. package/src/container/element-filter.mjs +143 -143
  35. package/src/container/index.mjs +3 -3
  36. package/src/container/runtime-core/checkpoint.mjs +209 -209
  37. package/src/container/runtime-core/index.mjs +21 -21
  38. package/src/container/runtime-core/operations/index.mjs +774 -774
  39. package/src/container/runtime-core/operations/selector-scripts.mjs +277 -277
  40. package/src/container/runtime-core/operations/tab-pool.mjs +746 -746
  41. package/src/container/runtime-core/operations/viewport.mjs +189 -189
  42. package/src/container/runtime-core/search.mjs +190 -190
  43. package/src/container/runtime-core/subscription.mjs +224 -224
  44. package/src/container/runtime-core/utils.mjs +94 -94
  45. package/src/container/runtime-core/validation.mjs +127 -127
  46. package/src/container/runtime-core.mjs +1 -1
  47. package/src/container/subscription-registry.mjs +459 -459
  48. package/src/core/actions.mjs +561 -561
  49. package/src/core/browser.mjs +266 -266
  50. package/src/core/index.mjs +52 -52
  51. package/src/core/utils.mjs +91 -91
  52. package/src/events/daemon-entry.mjs +33 -33
  53. package/src/events/daemon.mjs +80 -80
  54. package/src/events/progress-log.mjs +109 -109
  55. package/src/events/ws-server.mjs +239 -239
  56. package/src/lib/client.mjs +200 -200
  57. package/src/lifecycle/cleanup.mjs +83 -83
  58. package/src/lifecycle/lock.mjs +126 -126
  59. package/src/lifecycle/session-registry.mjs +279 -279
  60. package/src/lifecycle/session-view.mjs +76 -76
  61. package/src/lifecycle/session-watchdog.mjs +281 -281
  62. package/src/services/browser-service/index.js +671 -671
  63. package/src/services/browser-service/internal/BrowserSession.input.test.js +389 -389
  64. package/src/services/browser-service/internal/BrowserSession.js +325 -304
  65. package/src/services/browser-service/internal/ElementRegistry.js +60 -60
  66. package/src/services/browser-service/internal/ProfileLock.js +84 -84
  67. package/src/services/browser-service/internal/SessionManager.js +184 -184
  68. package/src/services/browser-service/internal/SessionManager.test.js +39 -39
  69. package/src/services/browser-service/internal/browser-session/cookies.js +144 -144
  70. package/src/services/browser-service/internal/browser-session/input-ops.js +222 -222
  71. package/src/services/browser-service/internal/browser-session/input-pipeline.js +144 -144
  72. package/src/services/browser-service/internal/browser-session/logging.js +46 -46
  73. package/src/services/browser-service/internal/browser-session/navigation.js +38 -38
  74. package/src/services/browser-service/internal/browser-session/page-hooks.js +442 -442
  75. package/src/services/browser-service/internal/browser-session/page-management.js +302 -302
  76. package/src/services/browser-service/internal/browser-session/page-management.test.js +148 -148
  77. package/src/services/browser-service/internal/browser-session/recording.js +198 -198
  78. package/src/services/browser-service/internal/browser-session/runtime-events.js +61 -61
  79. package/src/services/browser-service/internal/browser-session/session-core.js +84 -84
  80. package/src/services/browser-service/internal/browser-session/session-state.js +38 -38
  81. package/src/services/browser-service/internal/browser-session/types.js +14 -14
  82. package/src/services/browser-service/internal/browser-session/utils.js +95 -95
  83. package/src/services/browser-service/internal/browser-session/viewport-manager.js +46 -46
  84. package/src/services/browser-service/internal/browser-session/viewport.js +215 -215
  85. package/src/services/browser-service/internal/container-matcher.js +851 -851
  86. package/src/services/browser-service/internal/container-registry.js +182 -182
  87. package/src/services/browser-service/internal/engine-manager.js +259 -259
  88. package/src/services/browser-service/internal/fingerprint.js +203 -203
  89. package/src/services/browser-service/internal/heartbeat.js +137 -137
  90. package/src/services/browser-service/internal/logging.js +46 -46
  91. package/src/services/browser-service/internal/page-runtime/runtime.js +1317 -1317
  92. package/src/services/browser-service/internal/pageRuntime.js +28 -28
  93. package/src/services/browser-service/internal/runtimeInjector.js +31 -31
  94. package/src/services/browser-service/internal/service-process-logger.js +140 -140
  95. package/src/services/browser-service/internal/state-bus.js +45 -45
  96. package/src/services/browser-service/internal/storage-paths.js +42 -42
  97. package/src/services/browser-service/internal/ws-server.js +1194 -1194
  98. package/src/services/browser-service/internal/ws-server.test.js +58 -58
  99. package/src/services/browser-service/server.mjs +6 -6
  100. package/src/services/controller/cli-bridge.js +93 -93
  101. package/src/services/controller/container-index.js +50 -50
  102. package/src/services/controller/container-storage.js +36 -36
  103. package/src/services/controller/controller-actions.js +207 -207
  104. package/src/services/controller/controller.js +1138 -1138
  105. package/src/services/controller/selectors.js +54 -54
  106. package/src/services/controller/transport.js +125 -125
  107. package/src/utils/args.mjs +26 -26
  108. package/src/utils/browser-service.mjs +544 -544
  109. package/src/utils/command-log.mjs +64 -64
  110. package/src/utils/config.mjs +214 -214
  111. package/src/utils/fingerprint.mjs +181 -181
  112. package/src/utils/help.mjs +216 -216
  113. package/src/utils/js-policy.mjs +13 -13
  114. package/src/utils/ws-client.mjs +30 -30
@@ -1,1017 +1,1017 @@
1
- import crypto from 'node:crypto';
2
- import {
3
- captureCheckpoint,
4
- executeOperation,
5
- restoreCheckpoint,
6
- validateOperation,
7
- watchSubscriptions,
8
- } from '../container/runtime-core.mjs';
9
- import { executeAutoscriptAction } from './action-providers/index.mjs';
10
- import { ImpactEngine } from './impact-engine.mjs';
11
-
12
- function sleep(ms) {
13
- return new Promise((resolve) => setTimeout(resolve, ms));
14
- }
15
-
16
- function nowIso() {
17
- return new Date().toISOString();
18
- }
19
-
20
- function createRunId() {
21
- return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
22
- }
23
-
24
- function withTimeout(promise, timeoutMs, onTimeout) {
25
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
26
- let timer = null;
27
- return Promise.race([
28
- promise.finally(() => {
29
- if (timer) clearTimeout(timer);
30
- }),
31
- new Promise((resolve) => {
32
- timer = setTimeout(() => {
33
- resolve(onTimeout());
34
- }, timeoutMs);
35
- }),
36
- ]);
37
- }
38
-
39
- function extractTerminalDoneCode(result) {
40
- const text = `${result?.code || ''} ${result?.message || ''}`;
41
- const matched = text.match(/AUTOSCRIPT_DONE_[A-Z_]+/);
42
- return matched ? matched[0] : null;
43
- }
44
-
45
- function normalizeExecutionResult(result) {
46
- if (result && typeof result === 'object' && typeof result.ok === 'boolean') {
47
- return {
48
- ok: result.ok,
49
- code: result.code || (result.ok ? 'OPERATION_DONE' : 'OPERATION_FAILED'),
50
- message: result.message || (result.ok ? 'operation done' : 'operation failed'),
51
- data: result.data ?? result.result ?? null,
52
- };
53
- }
54
- return {
55
- ok: false,
56
- code: 'OPERATION_FAILED',
57
- message: 'invalid operation result payload',
58
- data: { resultType: typeof result },
59
- };
60
- }
61
-
62
- function mapToPlainObject(map) {
63
- return Object.fromEntries(map.entries());
64
- }
65
-
66
- function plainObjectToMap(value) {
67
- if (!value || typeof value !== 'object') return new Map();
68
- if (value instanceof Map) return new Map(value.entries());
69
- return new Map(Object.entries(value));
70
- }
71
-
72
- export class AutoscriptRunner {
73
- constructor(script, options = {}) {
74
- this.script = script;
75
- this.profileId = options.profileId || script.profileId || null;
76
- this.logger = options.log || ((payload) => console.log(JSON.stringify(payload)));
77
- this.runId = options.runId || createRunId();
78
- this.state = {
79
- active: false,
80
- reason: null,
81
- startedAt: null,
82
- stoppedAt: null,
83
- };
84
-
85
- this.impactEngine = new ImpactEngine();
86
- this.subscriptionState = new Map();
87
- this.operationState = new Map();
88
- this.operationQueue = Promise.resolve();
89
- this.pendingOperations = new Set();
90
- this.watchHandle = null;
91
- this.donePromise = null;
92
- this.resolveDone = null;
93
- this.operationScheduleState = new Map();
94
- this.runtimeContext = {
95
- vars: {},
96
- tabPool: null,
97
- currentTab: null,
98
- };
99
- this.lastNavigationAt = 0;
100
- this.random = typeof options.random === 'function' ? options.random : Math.random;
101
- this.executeExternalOperation = options.executeExternalOperation === false
102
- ? null
103
- : (typeof options.executeExternalOperation === 'function'
104
- ? options.executeExternalOperation
105
- : executeAutoscriptAction);
106
- this.executeMockOperation = typeof options.executeMockOperation === 'function'
107
- ? options.executeMockOperation
108
- : null;
109
- this.skipValidation = options.skipValidation === true;
110
- this.mockEvents = Array.isArray(options.mockEvents) ? options.mockEvents : null;
111
- this.mockEventBaseDelayMs = Math.max(0, Number(options.mockEventBaseDelayMs ?? 0) || 0);
112
- this.stopWhenMockEventsExhausted = options.stopWhenMockEventsExhausted !== false;
113
- this.forceRunOperationIds = new Set(
114
- Array.isArray(options.forceRunOperationIds)
115
- ? options.forceRunOperationIds.map((item) => String(item || '').trim()).filter(Boolean)
116
- : [],
117
- );
118
-
119
- for (const subscription of script.subscriptions || []) {
120
- this.subscriptionState.set(subscription.id, {
121
- exists: false,
122
- appearCount: 0,
123
- lastEventAt: null,
124
- version: 0,
125
- });
126
- }
127
- for (const operation of script.operations || []) {
128
- this.operationState.set(operation.id, {
129
- status: 'pending',
130
- runs: 0,
131
- lastError: null,
132
- updatedAt: null,
133
- result: null,
134
- });
135
- this.operationScheduleState.set(operation.id, {
136
- lastScheduledAt: null,
137
- lastStartedAt: null,
138
- lastEventAt: null,
139
- lastTriggerKey: null,
140
- lastScheduledAppearCount: null,
141
- lastCompletedAppearCount: null,
142
- });
143
- }
144
- this.applyInitialState(options.initialState || null);
145
- }
146
-
147
- applyInitialState(initialState) {
148
- if (!initialState || typeof initialState !== 'object') return;
149
- const root = initialState.state && typeof initialState.state === 'object'
150
- ? initialState.state
151
- : initialState;
152
- const subState = plainObjectToMap(root.subscriptionState);
153
- for (const [key, value] of subState.entries()) {
154
- if (!key) continue;
155
- if (!value || typeof value !== 'object') continue;
156
- const prev = this.subscriptionState.get(key) || {};
157
- this.subscriptionState.set(key, {
158
- exists: value.exists === true,
159
- appearCount: Math.max(0, Number(value.appearCount ?? prev.appearCount ?? 0) || 0),
160
- lastEventAt: value.lastEventAt || prev.lastEventAt || null,
161
- version: Math.max(0, Number(value.version ?? prev.version ?? 0) || 0),
162
- });
163
- }
164
-
165
- const opState = plainObjectToMap(root.operationState);
166
- for (const [key, value] of opState.entries()) {
167
- if (!key) continue;
168
- if (!value || typeof value !== 'object') continue;
169
- const prev = this.operationState.get(key) || {};
170
- this.operationState.set(key, {
171
- status: String(value.status || prev.status || 'pending'),
172
- runs: Math.max(0, Number(value.runs ?? prev.runs ?? 0) || 0),
173
- lastError: value.lastError || prev.lastError || null,
174
- updatedAt: value.updatedAt || prev.updatedAt || null,
175
- result: value.result ?? prev.result ?? null,
176
- });
177
- }
178
-
179
- const scheduleState = plainObjectToMap(root.operationScheduleState);
180
- for (const [key, value] of scheduleState.entries()) {
181
- if (!key) continue;
182
- if (!value || typeof value !== 'object') continue;
183
- this.operationScheduleState.set(key, {
184
- lastScheduledAt: value.lastScheduledAt ?? null,
185
- lastStartedAt: value.lastStartedAt ?? null,
186
- lastEventAt: value.lastEventAt ?? null,
187
- lastTriggerKey: value.lastTriggerKey ?? null,
188
- lastScheduledAppearCount: value.lastScheduledAppearCount ?? null,
189
- lastCompletedAppearCount: value.lastCompletedAppearCount ?? null,
190
- });
191
- }
192
-
193
- if (root.runtimeContext && typeof root.runtimeContext === 'object') {
194
- this.runtimeContext = {
195
- ...this.runtimeContext,
196
- ...root.runtimeContext,
197
- vars: root.runtimeContext.vars && typeof root.runtimeContext.vars === 'object'
198
- ? { ...root.runtimeContext.vars }
199
- : { ...this.runtimeContext.vars },
200
- };
201
- }
202
- if (Number.isFinite(Number(root.lastNavigationAt))) {
203
- this.lastNavigationAt = Number(root.lastNavigationAt);
204
- }
205
- }
206
-
207
- createSnapshot(reason = 'runtime_snapshot') {
208
- return {
209
- kind: 'autoscript_snapshot',
210
- version: 1,
211
- reason,
212
- createdAt: nowIso(),
213
- runId: this.runId,
214
- profileId: this.profileId,
215
- scriptName: this.script?.name || null,
216
- state: {
217
- state: this.state,
218
- subscriptionState: mapToPlainObject(this.subscriptionState),
219
- operationState: mapToPlainObject(this.operationState),
220
- operationScheduleState: mapToPlainObject(this.operationScheduleState),
221
- runtimeContext: this.runtimeContext,
222
- lastNavigationAt: this.lastNavigationAt,
223
- },
224
- };
225
- }
226
-
227
- log(event, payload = {}) {
228
- this.logger({
229
- runId: this.runId,
230
- profileId: this.profileId,
231
- event,
232
- ts: nowIso(),
233
- ...payload,
234
- });
235
- }
236
-
237
- isDependencySatisfied(operation) {
238
- const deps = operation.dependsOn || [];
239
- for (const dep of deps) {
240
- const depState = this.operationState.get(dep);
241
- if (depState?.status !== 'done' && depState?.status !== 'skipped') return false;
242
- }
243
- return true;
244
- }
245
-
246
- isConditionSatisfied(condition) {
247
- if (condition.type === 'operation_done') {
248
- return this.operationState.get(condition.operationId)?.status === 'done';
249
- }
250
- if (condition.type === 'subscription_exist') {
251
- return this.subscriptionState.get(condition.subscriptionId)?.exists === true;
252
- }
253
- if (condition.type === 'subscription_appear') {
254
- return Number(this.subscriptionState.get(condition.subscriptionId)?.appearCount || 0) > 0;
255
- }
256
- return false;
257
- }
258
-
259
- areConditionsSatisfied(operation) {
260
- for (const condition of operation.conditions || []) {
261
- if (!this.isConditionSatisfied(condition)) return false;
262
- }
263
- return true;
264
- }
265
-
266
- isTriggered(operation, event) {
267
- const trigger = operation.trigger || { type: 'startup' };
268
- if (trigger.type === 'startup') return event.type === 'startup';
269
- if (trigger.type === 'manual') return event.type === 'manual';
270
- if (trigger.type !== 'subscription_event') return false;
271
- return (
272
- event.subscriptionId === trigger.subscriptionId
273
- && event.type === trigger.event
274
- );
275
- }
276
-
277
- isTriggerStillValid(operation) {
278
- const trigger = operation?.trigger || { type: 'startup' };
279
- if (trigger.type !== 'subscription_event') return true;
280
- const state = this.subscriptionState.get(trigger.subscriptionId);
281
- const exists = state?.exists === true;
282
- if (trigger.event === 'exist' || trigger.event === 'appear') {
283
- return exists;
284
- }
285
- if (trigger.event === 'disappear') {
286
- return !exists;
287
- }
288
- return true;
289
- }
290
-
291
- shouldTreatAsStaleValidationSkip(operation, result) {
292
- if (!operation || !result || result.ok) return false;
293
- const phase = String(result?.data?.phase || '').trim().toLowerCase();
294
- if (phase !== 'pre') return false;
295
- const code = String(result?.code || '').toUpperCase();
296
- if (!code.includes('VALIDATION')) return false;
297
- return !this.isTriggerStillValid(operation);
298
- }
299
-
300
- resolvePacing(operation) {
301
- const scriptPacing = this.script?.defaults?.pacing || {};
302
- const opPacing = operation?.pacing || {};
303
- const timeoutRaw = operation?.timeoutMs ?? opPacing.timeoutMs ?? this.script?.defaults?.timeoutMs ?? scriptPacing.timeoutMs;
304
- return {
305
- operationMinIntervalMs: Math.max(0, Number(opPacing.operationMinIntervalMs ?? scriptPacing.operationMinIntervalMs ?? 0) || 0),
306
- eventCooldownMs: Math.max(0, Number(opPacing.eventCooldownMs ?? scriptPacing.eventCooldownMs ?? 0) || 0),
307
- jitterMs: Math.max(0, Number(opPacing.jitterMs ?? scriptPacing.jitterMs ?? 0) || 0),
308
- navigationMinIntervalMs: Math.max(0, Number(opPacing.navigationMinIntervalMs ?? scriptPacing.navigationMinIntervalMs ?? 0) || 0),
309
- timeoutMs: timeoutRaw === null || timeoutRaw === undefined ? null : Math.max(0, Number(timeoutRaw) || 0),
310
- };
311
- }
312
-
313
- isNavigationAction(action) {
314
- const normalized = String(action || '').trim().toLowerCase();
315
- return [
316
- 'goto',
317
- 'back',
318
- 'new_page',
319
- 'switch_page',
320
- 'ensure_tab_pool',
321
- 'tab_pool_switch_next',
322
- 'tab_pool_switch_slot',
323
- ].includes(normalized);
324
- }
325
-
326
- getDefaultTimeoutMs(operation) {
327
- const action = String(operation?.action || '').trim().toLowerCase();
328
- if (action === 'wait') {
329
- const ms = Math.max(0, Number(operation?.params?.ms ?? operation?.params?.value ?? 0) || 0);
330
- return Math.max(30_000, ms + 5_000);
331
- }
332
- if ([
333
- 'evaluate',
334
- 'goto',
335
- 'new_page',
336
- 'switch_page',
337
- 'ensure_tab_pool',
338
- 'tab_pool_switch_next',
339
- 'tab_pool_switch_slot',
340
- 'sync_window_viewport',
341
- 'verify_subscriptions',
342
- ].includes(action)) {
343
- return 45_000;
344
- }
345
- if (['click', 'type', 'back', 'scroll_into_view', 'scroll', 'press_key', 'get_current_url', 'raise_error'].includes(action)) {
346
- return 30_000;
347
- }
348
- return 20_000;
349
- }
350
-
351
- resolveTimeoutMs(operation) {
352
- const pacing = this.resolvePacing(operation);
353
- if (Number.isFinite(pacing.timeoutMs) && pacing.timeoutMs > 0) return pacing.timeoutMs;
354
- return this.getDefaultTimeoutMs(operation);
355
- }
356
-
357
- resolvePlatform(operation) {
358
- const candidate = operation?.params?.platform
359
- ?? operation?.platform
360
- ?? this.script?.defaults?.platform
361
- ?? 'generic';
362
- return String(candidate || 'generic').trim().toLowerCase() || 'generic';
363
- }
364
-
365
- buildTriggerKey(operation, event) {
366
- const trigger = operation?.trigger || { type: 'startup' };
367
- if (trigger.type === 'startup') return 'startup';
368
- if (trigger.type === 'manual') {
369
- return `manual:${event?.timestamp || event?.type || 'event'}`;
370
- }
371
- if (trigger.type !== 'subscription_event') {
372
- return `${trigger.type || 'unknown'}:${event?.timestamp || event?.type || 'event'}`;
373
- }
374
-
375
- const subState = this.subscriptionState.get(trigger.subscriptionId);
376
- if (trigger.event === 'exist') {
377
- return `${trigger.subscriptionId}:exist:a${Number(subState?.appearCount || 0)}`;
378
- }
379
- if (trigger.event === 'appear') {
380
- return `${trigger.subscriptionId}:appear:n${Number(subState?.appearCount || 0)}`;
381
- }
382
- return `${trigger.subscriptionId}:${trigger.event}:v${Number(subState?.version || 0)}`;
383
- }
384
-
385
- getTriggerAppearCount(operation) {
386
- const trigger = operation?.trigger || {};
387
- if (trigger.type !== 'subscription_event') return null;
388
- if (!trigger.subscriptionId) return null;
389
- const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
390
- const appearCount = Number(subState?.appearCount || 0);
391
- if (!Number.isFinite(appearCount) || appearCount <= 0) return null;
392
- return appearCount;
393
- }
394
-
395
- shouldSchedule(operation, event) {
396
- const forceRun = this.forceRunOperationIds.has(operation.id);
397
- if (!operation.enabled) return false;
398
- if (!forceRun && !this.isTriggered(operation, event)) return false;
399
- if (operation.once && this.operationState.get(operation.id)?.status === 'done') return false;
400
- if (!this.isDependencySatisfied(operation)) return false;
401
- if (!this.areConditionsSatisfied(operation)) return false;
402
- if (!this.impactEngine.canRunOperation(operation, event)) return false;
403
- if (this.pendingOperations.has(operation.id)) return false;
404
-
405
- const scheduleState = this.operationScheduleState.get(operation.id) || {};
406
- const pacing = this.resolvePacing(operation);
407
- const now = Date.now();
408
- const appearCount = this.getTriggerAppearCount(operation);
409
-
410
- if (pacing.operationMinIntervalMs > 0 && scheduleState.lastStartedAt) {
411
- if ((now - scheduleState.lastStartedAt) < pacing.operationMinIntervalMs) return false;
412
- }
413
-
414
- if (pacing.eventCooldownMs > 0 && scheduleState.lastEventAt) {
415
- if ((now - scheduleState.lastEventAt) < pacing.eventCooldownMs) return false;
416
- }
417
-
418
- if (
419
- operation?.oncePerAppear === true
420
- && Number.isFinite(appearCount)
421
- && appearCount > 0
422
- && (
423
- Number(scheduleState.lastScheduledAppearCount || 0) === appearCount
424
- || Number(scheduleState.lastCompletedAppearCount || 0) === appearCount
425
- )
426
- ) {
427
- return false;
428
- }
429
-
430
- const triggerKey = forceRun ? `force:${operation.id}` : this.buildTriggerKey(operation, event);
431
- const trigger = operation?.trigger || {};
432
- const allowExistReschedule = trigger?.type === 'subscription_event'
433
- && trigger?.event === 'exist'
434
- && operation?.once === false
435
- && operation?.oncePerAppear !== true
436
- && (pacing.operationMinIntervalMs > 0 || pacing.eventCooldownMs > 0);
437
- if (!forceRun && !allowExistReschedule && triggerKey && scheduleState.lastTriggerKey === triggerKey) {
438
- return false;
439
- }
440
- return true;
441
- }
442
-
443
- resetCycleOperationsForSubscription(subscriptionId) {
444
- if (!subscriptionId) return;
445
- for (const operation of this.script.operations || []) {
446
- if (operation?.oncePerAppear !== true) continue;
447
- const trigger = operation?.trigger || {};
448
- if (trigger.type !== 'subscription_event') continue;
449
- if (trigger.subscriptionId !== subscriptionId) continue;
450
-
451
- const prevState = this.operationState.get(operation.id);
452
- if (!prevState || prevState.status === 'pending') continue;
453
- this.operationState.set(operation.id, {
454
- ...prevState,
455
- status: 'pending',
456
- lastError: null,
457
- result: null,
458
- updatedAt: nowIso(),
459
- });
460
- }
461
- }
462
-
463
- scheduleReadyOperations(event) {
464
- for (const operation of this.script.operations || []) {
465
- if (!this.shouldSchedule(operation, event)) continue;
466
- this.enqueueOperation(operation, event);
467
- }
468
- }
469
-
470
- async runValidation(operation, phase, context) {
471
- if (this.skipValidation) {
472
- return {
473
- ok: true,
474
- code: 'VALIDATION_SKIPPED',
475
- message: 'validation skipped in mock/resume mode',
476
- data: { phase },
477
- };
478
- }
479
- const platform = this.resolvePlatform(operation);
480
- const validation = operation.validation || {};
481
- return validateOperation({
482
- profileId: this.profileId,
483
- validationSpec: validation,
484
- phase,
485
- context,
486
- platform,
487
- });
488
- }
489
-
490
- async executeOnce(operation, context) {
491
- if (this.executeMockOperation) {
492
- const mocked = await this.executeMockOperation({
493
- operation,
494
- context,
495
- profileId: this.profileId,
496
- });
497
- if (mocked !== undefined) {
498
- return normalizeExecutionResult(mocked);
499
- }
500
- }
501
-
502
- const pre = await this.runValidation(operation, 'pre', context);
503
- if (!pre.ok) {
504
- return { ok: false, code: pre.code || 'VALIDATION_FAILED', message: pre.message || 'pre validation failed', data: { phase: 'pre', detail: pre } };
505
- }
506
-
507
- const execution = await executeOperation({
508
- profileId: this.profileId,
509
- operation,
510
- context: {
511
- ...context,
512
- executeExternalOperation: this.executeExternalOperation,
513
- },
514
- });
515
- if (!execution.ok) {
516
- return { ok: false, code: execution.code || 'OPERATION_FAILED', message: execution.message || 'operation failed', data: { phase: 'execute', detail: execution } };
517
- }
518
-
519
- const post = await this.runValidation(operation, 'post', context);
520
- if (!post.ok) {
521
- return { ok: false, code: post.code || 'VALIDATION_FAILED', message: post.message || 'post validation failed', data: { phase: 'post', detail: post } };
522
- }
523
-
524
- return { ok: true, code: 'OPERATION_DONE', message: 'operation done', data: execution.data || execution };
525
- }
526
-
527
- async applyPacingBeforeAttempt(operation, attempt) {
528
- const pacing = this.resolvePacing(operation);
529
- if (this.isNavigationAction(operation?.action) && pacing.navigationMinIntervalMs > 0 && this.lastNavigationAt > 0) {
530
- const elapsed = Date.now() - this.lastNavigationAt;
531
- if (elapsed < pacing.navigationMinIntervalMs) {
532
- const waitMs = pacing.navigationMinIntervalMs - elapsed;
533
- if (waitMs > 0) {
534
- this.log('autoscript:pacing_wait', {
535
- operationId: operation.id,
536
- action: operation.action,
537
- attempt,
538
- reason: 'navigation_cooldown',
539
- waitMs,
540
- });
541
- await sleep(waitMs);
542
- }
543
- }
544
- }
545
-
546
- if (pacing.jitterMs > 0) {
547
- const waitMs = Math.floor(this.random() * (pacing.jitterMs + 1));
548
- if (waitMs > 0) {
549
- this.log('autoscript:pacing_wait', {
550
- operationId: operation.id,
551
- action: operation.action,
552
- attempt,
553
- reason: 'jitter',
554
- waitMs,
555
- });
556
- await sleep(waitMs);
557
- }
558
- }
559
- }
560
-
561
- async runRecovery(operation, event, failure) {
562
- const checkpoint = operation.checkpoint || {};
563
- const recovery = checkpoint.recovery || {};
564
- const actions = Array.isArray(recovery.actions) ? recovery.actions : [];
565
- const attempts = Math.max(0, Number(recovery.attempts) || 0);
566
- if (attempts <= 0 || actions.length === 0) {
567
- return { ok: false, code: 'RECOVERY_NOT_CONFIGURED', message: 'recovery not configured' };
568
- }
569
-
570
- const platform = this.resolvePlatform(operation);
571
- const checkpointDoc = await captureCheckpoint({
572
- profileId: this.profileId,
573
- containerId: checkpoint.containerId || null,
574
- selector: operation.params?.selector || null,
575
- platform,
576
- });
577
- const baseCheckpoint = checkpointDoc?.data || {};
578
-
579
- for (let i = 1; i <= attempts; i += 1) {
580
- let allActionOk = true;
581
- for (const action of actions) {
582
- const restored = await restoreCheckpoint({
583
- profileId: this.profileId,
584
- checkpoint: baseCheckpoint,
585
- action,
586
- containerId: checkpoint.containerId || null,
587
- selector: operation.params?.selector || null,
588
- targetCheckpoint: checkpoint.targetCheckpoint || null,
589
- platform,
590
- });
591
- this.log('autoscript:recovery_action', {
592
- operationId: operation.id,
593
- subscriptionId: event.subscriptionId || null,
594
- action,
595
- attempt: i,
596
- ok: restored.ok,
597
- code: restored.code || null,
598
- message: restored.message || null,
599
- });
600
- if (!restored.ok) {
601
- allActionOk = false;
602
- }
603
- }
604
- if (allActionOk) {
605
- return { ok: true, code: 'RECOVERY_DONE', message: 'recovery done', data: { attempts: i } };
606
- }
607
- }
608
-
609
- return {
610
- ok: false,
611
- code: 'RECOVERY_EXHAUSTED',
612
- message: 'recovery attempts exhausted',
613
- data: { operationId: operation.id, failure },
614
- };
615
- }
616
-
617
- async runOperation(operation, event) {
618
- const retry = operation.retry || {};
619
- const maxAttempts = Math.max(1, Number(retry.attempts) || 1);
620
- const backoffMs = Math.max(0, Number(retry.backoffMs) || 0);
621
-
622
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
623
- const context = {
624
- runId: this.runId,
625
- event,
626
- attempt,
627
- maxAttempts,
628
- runtime: this.runtimeContext,
629
- };
630
-
631
- await this.applyPacingBeforeAttempt(operation, attempt);
632
-
633
- if (!this.isTriggerStillValid(operation)) {
634
- const trigger = operation?.trigger || {};
635
- const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
636
- this.operationState.set(operation.id, {
637
- status: 'skipped',
638
- runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
639
- lastError: null,
640
- updatedAt: nowIso(),
641
- result: {
642
- code: 'OPERATION_SKIPPED_STALE_TRIGGER',
643
- trigger,
644
- currentSubscriptionState: subState,
645
- },
646
- });
647
- this.log('autoscript:operation_skipped', {
648
- operationId: operation.id,
649
- action: operation.action,
650
- attempt,
651
- reason: 'stale_trigger',
652
- trigger,
653
- currentSubscriptionState: subState,
654
- });
655
- return {
656
- ok: true,
657
- terminalState: 'skipped_stale',
658
- result: {
659
- ok: true,
660
- code: 'OPERATION_SKIPPED_STALE_TRIGGER',
661
- message: 'operation skipped because trigger is no longer valid',
662
- data: {
663
- trigger,
664
- currentSubscriptionState: subState,
665
- },
666
- },
667
- };
668
- }
669
-
670
- this.log('autoscript:operation_start', {
671
- operationId: operation.id,
672
- action: operation.action,
673
- attempt,
674
- maxAttempts,
675
- trigger: operation.trigger,
676
- subscriptionId: event.subscriptionId || null,
677
- });
678
-
679
- const startedAt = Date.now();
680
- const timeoutMs = this.resolveTimeoutMs(operation);
681
- const result = await withTimeout(
682
- this.executeOnce(operation, context),
683
- timeoutMs,
684
- () => ({
685
- ok: false,
686
- code: 'OPERATION_TIMEOUT',
687
- message: `operation timed out after ${timeoutMs}ms`,
688
- data: { timeoutMs },
689
- }),
690
- );
691
- const latencyMs = Date.now() - startedAt;
692
- if (result.ok) {
693
- if (this.isNavigationAction(operation?.action)) {
694
- this.lastNavigationAt = Date.now();
695
- }
696
- this.operationState.set(operation.id, {
697
- status: 'done',
698
- runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
699
- lastError: null,
700
- updatedAt: nowIso(),
701
- result: result.data || null,
702
- });
703
- this.log('autoscript:operation_done', {
704
- operationId: operation.id,
705
- action: operation.action,
706
- attempt,
707
- latencyMs,
708
- result: result.data || null,
709
- });
710
- // Re-evaluate graph on the same event so dependencies can continue in one trigger chain.
711
- this.scheduleReadyOperations(event);
712
- return { ok: true, terminalState: 'done', result };
713
- }
714
-
715
- if (this.shouldTreatAsStaleValidationSkip(operation, result)) {
716
- const trigger = operation?.trigger || {};
717
- const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
718
- this.operationState.set(operation.id, {
719
- status: 'skipped',
720
- runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
721
- lastError: null,
722
- updatedAt: nowIso(),
723
- result: {
724
- code: 'OPERATION_SKIPPED_STALE_TRIGGER_PRE_VALIDATION',
725
- trigger,
726
- currentSubscriptionState: subState,
727
- },
728
- });
729
- this.log('autoscript:operation_skipped', {
730
- operationId: operation.id,
731
- action: operation.action,
732
- attempt,
733
- reason: 'stale_trigger_pre_validation',
734
- trigger,
735
- currentSubscriptionState: subState,
736
- validation: result.data?.detail || null,
737
- });
738
- return {
739
- ok: true,
740
- terminalState: 'skipped_stale_pre_validation',
741
- result: {
742
- ok: true,
743
- code: 'OPERATION_SKIPPED_STALE_TRIGGER_PRE_VALIDATION',
744
- message: 'operation skipped after pre-validation because trigger is no longer valid',
745
- data: {
746
- trigger,
747
- currentSubscriptionState: subState,
748
- },
749
- },
750
- };
751
- }
752
-
753
- const terminalDoneCode = extractTerminalDoneCode(result);
754
- if (terminalDoneCode) {
755
- this.operationState.set(operation.id, {
756
- status: 'done',
757
- runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
758
- lastError: null,
759
- updatedAt: nowIso(),
760
- result: { terminalDoneCode },
761
- });
762
- this.log('autoscript:operation_terminal', {
763
- operationId: operation.id,
764
- action: operation.action,
765
- attempt,
766
- latencyMs,
767
- code: terminalDoneCode,
768
- });
769
- this.stop('script_complete');
770
- return {
771
- ok: true,
772
- terminalState: 'done_terminal',
773
- result: {
774
- ok: true,
775
- code: terminalDoneCode,
776
- message: 'autoscript completed',
777
- data: { terminalDoneCode },
778
- },
779
- };
780
- }
781
-
782
- this.operationState.set(operation.id, {
783
- status: 'failed',
784
- runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
785
- lastError: result.message || 'operation failed',
786
- updatedAt: nowIso(),
787
- result: null,
788
- });
789
- this.log('autoscript:operation_error', {
790
- operationId: operation.id,
791
- action: operation.action,
792
- attempt,
793
- latencyMs,
794
- code: result.code || 'OPERATION_FAILED',
795
- message: result.message || 'operation failed',
796
- });
797
-
798
- const recoveryResult = await this.runRecovery(operation, event, result);
799
- if (recoveryResult.ok) {
800
- this.log('autoscript:operation_recovered', {
801
- operationId: operation.id,
802
- action: operation.action,
803
- attempt,
804
- code: recoveryResult.code,
805
- });
806
- } else {
807
- this.log('autoscript:operation_recovery_failed', {
808
- operationId: operation.id,
809
- action: operation.action,
810
- attempt,
811
- code: recoveryResult.code,
812
- message: recoveryResult.message,
813
- });
814
- }
815
-
816
- if (attempt < maxAttempts) {
817
- if (backoffMs > 0) await sleep(backoffMs);
818
- continue;
819
- }
820
-
821
- const impact = this.impactEngine.applyFailure({ operation, event });
822
- this.log('autoscript:impact', {
823
- operationId: operation.id,
824
- action: operation.action,
825
- scope: impact.scope,
826
- scriptStopped: impact.scriptStopped,
827
- blockedSubscriptions: impact.blockedSubscriptions,
828
- blockedOperations: impact.blockedOperations,
829
- });
830
-
831
- if (impact.scriptStopped) {
832
- this.stop('script_failure');
833
- }
834
- return { ok: false, terminalState: 'failed', result };
835
- }
836
-
837
- return { ok: false, terminalState: 'failed', result: null };
838
- }
839
-
840
- enqueueOperation(operation, event) {
841
- if (this.pendingOperations.has(operation.id)) return;
842
- if (!this.state.active) return;
843
-
844
- const scheduleState = this.operationScheduleState.get(operation.id) || {};
845
- scheduleState.lastScheduledAt = Date.now();
846
- scheduleState.lastEventAt = Date.now();
847
- scheduleState.lastTriggerKey = this.buildTriggerKey(operation, event);
848
- const scheduledAppearCount = this.getTriggerAppearCount(operation);
849
- if (Number.isFinite(scheduledAppearCount) && scheduledAppearCount > 0) {
850
- scheduleState.lastScheduledAppearCount = scheduledAppearCount;
851
- }
852
- this.operationScheduleState.set(operation.id, scheduleState);
853
- this.forceRunOperationIds.delete(operation.id);
854
-
855
- this.pendingOperations.add(operation.id);
856
- this.operationQueue = this.operationQueue
857
- .then(async () => {
858
- if (!this.state.active) return;
859
- const innerState = this.operationScheduleState.get(operation.id) || {};
860
- innerState.lastStartedAt = Date.now();
861
- this.operationScheduleState.set(operation.id, innerState);
862
- const outcome = await this.runOperation(operation, event);
863
- if (
864
- outcome
865
- && operation?.oncePerAppear === true
866
- && Number.isFinite(scheduledAppearCount)
867
- && scheduledAppearCount > 0
868
- && outcome.terminalState !== 'skipped_stale'
869
- && outcome.terminalState !== 'skipped_stale_pre_validation'
870
- ) {
871
- const completedState = this.operationScheduleState.get(operation.id) || {};
872
- completedState.lastCompletedAppearCount = scheduledAppearCount;
873
- this.operationScheduleState.set(operation.id, completedState);
874
- }
875
- })
876
- .finally(() => {
877
- this.pendingOperations.delete(operation.id);
878
- });
879
- }
880
-
881
- async handleEvent(event) {
882
- if (!this.state.active) return;
883
- if (event.subscriptionId) {
884
- const prev = this.subscriptionState.get(event.subscriptionId) || { exists: false, appearCount: 0, lastEventAt: null };
885
- const next = { ...prev, lastEventAt: event.timestamp || nowIso() };
886
- if (event.type === 'appear') {
887
- next.exists = true;
888
- next.appearCount = Number(prev.appearCount || 0) + 1;
889
- next.version = Number(prev.version || 0) + 1;
890
- this.resetCycleOperationsForSubscription(event.subscriptionId);
891
- } else if (event.type === 'disappear') {
892
- next.exists = false;
893
- next.version = Number(prev.version || 0) + 1;
894
- } else if (event.type === 'exist') {
895
- next.exists = true;
896
- } else if (event.type === 'change') {
897
- next.exists = Number(event.count || 0) > 0 || prev.exists === true;
898
- next.version = Number(prev.version || 0) + 1;
899
- }
900
- this.subscriptionState.set(event.subscriptionId, next);
901
- }
902
-
903
- this.scheduleReadyOperations(event);
904
- }
905
-
906
- async start() {
907
- if (this.state.active) {
908
- throw new Error('Autoscript runtime already running');
909
- }
910
- if (!this.profileId) {
911
- throw new Error('profileId is required');
912
- }
913
- this.state.active = true;
914
- this.state.reason = null;
915
- this.state.startedAt = nowIso();
916
- this.donePromise = new Promise((resolve) => {
917
- this.resolveDone = resolve;
918
- });
919
-
920
- this.log('autoscript:start', {
921
- name: this.script.name,
922
- subscriptions: this.script.subscriptions.length,
923
- operations: this.script.operations.length,
924
- throttle: this.script.throttle,
925
- });
926
-
927
- if (this.mockEvents) {
928
- this.watchHandle = { stop: () => {} };
929
- const events = this.mockEvents.map((item) => {
930
- if (!item || typeof item !== 'object') return null;
931
- const type = String(item.type || '').trim();
932
- if (!type) return null;
933
- return {
934
- type,
935
- subscriptionId: item.subscriptionId ? String(item.subscriptionId) : null,
936
- selector: item.selector ? String(item.selector) : null,
937
- count: item.count ?? null,
938
- timestamp: item.timestamp || nowIso(),
939
- delayMs: Math.max(0, Number(item.delayMs ?? this.mockEventBaseDelayMs) || this.mockEventBaseDelayMs),
940
- };
941
- }).filter(Boolean);
942
-
943
- (async () => {
944
- for (const event of events) {
945
- if (!this.state.active) return;
946
- if (event.delayMs > 0) await sleep(event.delayMs);
947
- this.log('autoscript:event', {
948
- type: event.type,
949
- subscriptionId: event.subscriptionId || null,
950
- selector: event.selector || null,
951
- count: event.count ?? null,
952
- });
953
- await this.handleEvent(event);
954
- }
955
- if (this.stopWhenMockEventsExhausted && this.state.active) {
956
- // Allow startup-trigger scheduling to enqueue operations before drain check.
957
- await Promise.resolve();
958
- await this.operationQueue;
959
- if (this.state.active) this.stop('mock_events_exhausted');
960
- }
961
- })().catch((err) => {
962
- this.log('autoscript:watch_error', {
963
- code: 'MOCK_EVENT_FEED_FAILED',
964
- message: err?.message || String(err),
965
- });
966
- if (this.state.active) this.stop('mock_event_feed_failure');
967
- });
968
- } else {
969
- this.watchHandle = await watchSubscriptions({
970
- profileId: this.profileId,
971
- subscriptions: this.script.subscriptions,
972
- throttle: this.script.throttle,
973
- filterMode: this.script?.defaults?.filterMode || 'strict',
974
- onEvent: async (event) => {
975
- this.log('autoscript:event', {
976
- type: event.type,
977
- subscriptionId: event.subscriptionId || null,
978
- selector: event.selector || null,
979
- count: event.count ?? null,
980
- });
981
- await this.handleEvent(event);
982
- },
983
- onError: (err) => {
984
- this.log('autoscript:watch_error', {
985
- code: 'SUBSCRIPTION_WATCH_FAILED',
986
- message: err?.message || String(err),
987
- });
988
- },
989
- });
990
- }
991
-
992
- await this.handleEvent({ type: 'startup', timestamp: nowIso() });
993
- return {
994
- runId: this.runId,
995
- stop: (reason = 'stopped') => this.stop(reason),
996
- done: this.donePromise,
997
- };
998
- }
999
-
1000
- stop(reason = 'stopped') {
1001
- if (!this.state.active) return;
1002
- this.state.active = false;
1003
- this.state.reason = reason;
1004
- this.state.stoppedAt = nowIso();
1005
- if (this.watchHandle?.stop) this.watchHandle.stop();
1006
- this.log('autoscript:stop', { reason });
1007
- if (this.resolveDone) {
1008
- this.resolveDone({
1009
- runId: this.runId,
1010
- reason,
1011
- startedAt: this.state.startedAt,
1012
- stoppedAt: this.state.stoppedAt,
1013
- });
1014
- this.resolveDone = null;
1015
- }
1016
- }
1017
- }
1
+ import crypto from 'node:crypto';
2
+ import {
3
+ captureCheckpoint,
4
+ executeOperation,
5
+ restoreCheckpoint,
6
+ validateOperation,
7
+ watchSubscriptions,
8
+ } from '../container/runtime-core.mjs';
9
+ import { executeAutoscriptAction } from './action-providers/index.mjs';
10
+ import { ImpactEngine } from './impact-engine.mjs';
11
+
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+
16
+ function nowIso() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function createRunId() {
21
+ return crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
22
+ }
23
+
24
+ function withTimeout(promise, timeoutMs, onTimeout) {
25
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise;
26
+ let timer = null;
27
+ return Promise.race([
28
+ promise.finally(() => {
29
+ if (timer) clearTimeout(timer);
30
+ }),
31
+ new Promise((resolve) => {
32
+ timer = setTimeout(() => {
33
+ resolve(onTimeout());
34
+ }, timeoutMs);
35
+ }),
36
+ ]);
37
+ }
38
+
39
+ function extractTerminalDoneCode(result) {
40
+ const text = `${result?.code || ''} ${result?.message || ''}`;
41
+ const matched = text.match(/AUTOSCRIPT_DONE_[A-Z_]+/);
42
+ return matched ? matched[0] : null;
43
+ }
44
+
45
+ function normalizeExecutionResult(result) {
46
+ if (result && typeof result === 'object' && typeof result.ok === 'boolean') {
47
+ return {
48
+ ok: result.ok,
49
+ code: result.code || (result.ok ? 'OPERATION_DONE' : 'OPERATION_FAILED'),
50
+ message: result.message || (result.ok ? 'operation done' : 'operation failed'),
51
+ data: result.data ?? result.result ?? null,
52
+ };
53
+ }
54
+ return {
55
+ ok: false,
56
+ code: 'OPERATION_FAILED',
57
+ message: 'invalid operation result payload',
58
+ data: { resultType: typeof result },
59
+ };
60
+ }
61
+
62
+ function mapToPlainObject(map) {
63
+ return Object.fromEntries(map.entries());
64
+ }
65
+
66
+ function plainObjectToMap(value) {
67
+ if (!value || typeof value !== 'object') return new Map();
68
+ if (value instanceof Map) return new Map(value.entries());
69
+ return new Map(Object.entries(value));
70
+ }
71
+
72
+ export class AutoscriptRunner {
73
+ constructor(script, options = {}) {
74
+ this.script = script;
75
+ this.profileId = options.profileId || script.profileId || null;
76
+ this.logger = options.log || ((payload) => console.log(JSON.stringify(payload)));
77
+ this.runId = options.runId || createRunId();
78
+ this.state = {
79
+ active: false,
80
+ reason: null,
81
+ startedAt: null,
82
+ stoppedAt: null,
83
+ };
84
+
85
+ this.impactEngine = new ImpactEngine();
86
+ this.subscriptionState = new Map();
87
+ this.operationState = new Map();
88
+ this.operationQueue = Promise.resolve();
89
+ this.pendingOperations = new Set();
90
+ this.watchHandle = null;
91
+ this.donePromise = null;
92
+ this.resolveDone = null;
93
+ this.operationScheduleState = new Map();
94
+ this.runtimeContext = {
95
+ vars: {},
96
+ tabPool: null,
97
+ currentTab: null,
98
+ };
99
+ this.lastNavigationAt = 0;
100
+ this.random = typeof options.random === 'function' ? options.random : Math.random;
101
+ this.executeExternalOperation = options.executeExternalOperation === false
102
+ ? null
103
+ : (typeof options.executeExternalOperation === 'function'
104
+ ? options.executeExternalOperation
105
+ : executeAutoscriptAction);
106
+ this.executeMockOperation = typeof options.executeMockOperation === 'function'
107
+ ? options.executeMockOperation
108
+ : null;
109
+ this.skipValidation = options.skipValidation === true;
110
+ this.mockEvents = Array.isArray(options.mockEvents) ? options.mockEvents : null;
111
+ this.mockEventBaseDelayMs = Math.max(0, Number(options.mockEventBaseDelayMs ?? 0) || 0);
112
+ this.stopWhenMockEventsExhausted = options.stopWhenMockEventsExhausted !== false;
113
+ this.forceRunOperationIds = new Set(
114
+ Array.isArray(options.forceRunOperationIds)
115
+ ? options.forceRunOperationIds.map((item) => String(item || '').trim()).filter(Boolean)
116
+ : [],
117
+ );
118
+
119
+ for (const subscription of script.subscriptions || []) {
120
+ this.subscriptionState.set(subscription.id, {
121
+ exists: false,
122
+ appearCount: 0,
123
+ lastEventAt: null,
124
+ version: 0,
125
+ });
126
+ }
127
+ for (const operation of script.operations || []) {
128
+ this.operationState.set(operation.id, {
129
+ status: 'pending',
130
+ runs: 0,
131
+ lastError: null,
132
+ updatedAt: null,
133
+ result: null,
134
+ });
135
+ this.operationScheduleState.set(operation.id, {
136
+ lastScheduledAt: null,
137
+ lastStartedAt: null,
138
+ lastEventAt: null,
139
+ lastTriggerKey: null,
140
+ lastScheduledAppearCount: null,
141
+ lastCompletedAppearCount: null,
142
+ });
143
+ }
144
+ this.applyInitialState(options.initialState || null);
145
+ }
146
+
147
+ applyInitialState(initialState) {
148
+ if (!initialState || typeof initialState !== 'object') return;
149
+ const root = initialState.state && typeof initialState.state === 'object'
150
+ ? initialState.state
151
+ : initialState;
152
+ const subState = plainObjectToMap(root.subscriptionState);
153
+ for (const [key, value] of subState.entries()) {
154
+ if (!key) continue;
155
+ if (!value || typeof value !== 'object') continue;
156
+ const prev = this.subscriptionState.get(key) || {};
157
+ this.subscriptionState.set(key, {
158
+ exists: value.exists === true,
159
+ appearCount: Math.max(0, Number(value.appearCount ?? prev.appearCount ?? 0) || 0),
160
+ lastEventAt: value.lastEventAt || prev.lastEventAt || null,
161
+ version: Math.max(0, Number(value.version ?? prev.version ?? 0) || 0),
162
+ });
163
+ }
164
+
165
+ const opState = plainObjectToMap(root.operationState);
166
+ for (const [key, value] of opState.entries()) {
167
+ if (!key) continue;
168
+ if (!value || typeof value !== 'object') continue;
169
+ const prev = this.operationState.get(key) || {};
170
+ this.operationState.set(key, {
171
+ status: String(value.status || prev.status || 'pending'),
172
+ runs: Math.max(0, Number(value.runs ?? prev.runs ?? 0) || 0),
173
+ lastError: value.lastError || prev.lastError || null,
174
+ updatedAt: value.updatedAt || prev.updatedAt || null,
175
+ result: value.result ?? prev.result ?? null,
176
+ });
177
+ }
178
+
179
+ const scheduleState = plainObjectToMap(root.operationScheduleState);
180
+ for (const [key, value] of scheduleState.entries()) {
181
+ if (!key) continue;
182
+ if (!value || typeof value !== 'object') continue;
183
+ this.operationScheduleState.set(key, {
184
+ lastScheduledAt: value.lastScheduledAt ?? null,
185
+ lastStartedAt: value.lastStartedAt ?? null,
186
+ lastEventAt: value.lastEventAt ?? null,
187
+ lastTriggerKey: value.lastTriggerKey ?? null,
188
+ lastScheduledAppearCount: value.lastScheduledAppearCount ?? null,
189
+ lastCompletedAppearCount: value.lastCompletedAppearCount ?? null,
190
+ });
191
+ }
192
+
193
+ if (root.runtimeContext && typeof root.runtimeContext === 'object') {
194
+ this.runtimeContext = {
195
+ ...this.runtimeContext,
196
+ ...root.runtimeContext,
197
+ vars: root.runtimeContext.vars && typeof root.runtimeContext.vars === 'object'
198
+ ? { ...root.runtimeContext.vars }
199
+ : { ...this.runtimeContext.vars },
200
+ };
201
+ }
202
+ if (Number.isFinite(Number(root.lastNavigationAt))) {
203
+ this.lastNavigationAt = Number(root.lastNavigationAt);
204
+ }
205
+ }
206
+
207
+ createSnapshot(reason = 'runtime_snapshot') {
208
+ return {
209
+ kind: 'autoscript_snapshot',
210
+ version: 1,
211
+ reason,
212
+ createdAt: nowIso(),
213
+ runId: this.runId,
214
+ profileId: this.profileId,
215
+ scriptName: this.script?.name || null,
216
+ state: {
217
+ state: this.state,
218
+ subscriptionState: mapToPlainObject(this.subscriptionState),
219
+ operationState: mapToPlainObject(this.operationState),
220
+ operationScheduleState: mapToPlainObject(this.operationScheduleState),
221
+ runtimeContext: this.runtimeContext,
222
+ lastNavigationAt: this.lastNavigationAt,
223
+ },
224
+ };
225
+ }
226
+
227
+ log(event, payload = {}) {
228
+ this.logger({
229
+ runId: this.runId,
230
+ profileId: this.profileId,
231
+ event,
232
+ ts: nowIso(),
233
+ ...payload,
234
+ });
235
+ }
236
+
237
+ isDependencySatisfied(operation) {
238
+ const deps = operation.dependsOn || [];
239
+ for (const dep of deps) {
240
+ const depState = this.operationState.get(dep);
241
+ if (depState?.status !== 'done' && depState?.status !== 'skipped') return false;
242
+ }
243
+ return true;
244
+ }
245
+
246
+ isConditionSatisfied(condition) {
247
+ if (condition.type === 'operation_done') {
248
+ return this.operationState.get(condition.operationId)?.status === 'done';
249
+ }
250
+ if (condition.type === 'subscription_exist') {
251
+ return this.subscriptionState.get(condition.subscriptionId)?.exists === true;
252
+ }
253
+ if (condition.type === 'subscription_appear') {
254
+ return Number(this.subscriptionState.get(condition.subscriptionId)?.appearCount || 0) > 0;
255
+ }
256
+ return false;
257
+ }
258
+
259
+ areConditionsSatisfied(operation) {
260
+ for (const condition of operation.conditions || []) {
261
+ if (!this.isConditionSatisfied(condition)) return false;
262
+ }
263
+ return true;
264
+ }
265
+
266
+ isTriggered(operation, event) {
267
+ const trigger = operation.trigger || { type: 'startup' };
268
+ if (trigger.type === 'startup') return event.type === 'startup';
269
+ if (trigger.type === 'manual') return event.type === 'manual';
270
+ if (trigger.type !== 'subscription_event') return false;
271
+ return (
272
+ event.subscriptionId === trigger.subscriptionId
273
+ && event.type === trigger.event
274
+ );
275
+ }
276
+
277
+ isTriggerStillValid(operation) {
278
+ const trigger = operation?.trigger || { type: 'startup' };
279
+ if (trigger.type !== 'subscription_event') return true;
280
+ const state = this.subscriptionState.get(trigger.subscriptionId);
281
+ const exists = state?.exists === true;
282
+ if (trigger.event === 'exist' || trigger.event === 'appear') {
283
+ return exists;
284
+ }
285
+ if (trigger.event === 'disappear') {
286
+ return !exists;
287
+ }
288
+ return true;
289
+ }
290
+
291
+ shouldTreatAsStaleValidationSkip(operation, result) {
292
+ if (!operation || !result || result.ok) return false;
293
+ const phase = String(result?.data?.phase || '').trim().toLowerCase();
294
+ if (phase !== 'pre') return false;
295
+ const code = String(result?.code || '').toUpperCase();
296
+ if (!code.includes('VALIDATION')) return false;
297
+ return !this.isTriggerStillValid(operation);
298
+ }
299
+
300
+ resolvePacing(operation) {
301
+ const scriptPacing = this.script?.defaults?.pacing || {};
302
+ const opPacing = operation?.pacing || {};
303
+ const timeoutRaw = operation?.timeoutMs ?? opPacing.timeoutMs ?? this.script?.defaults?.timeoutMs ?? scriptPacing.timeoutMs;
304
+ return {
305
+ operationMinIntervalMs: Math.max(0, Number(opPacing.operationMinIntervalMs ?? scriptPacing.operationMinIntervalMs ?? 0) || 0),
306
+ eventCooldownMs: Math.max(0, Number(opPacing.eventCooldownMs ?? scriptPacing.eventCooldownMs ?? 0) || 0),
307
+ jitterMs: Math.max(0, Number(opPacing.jitterMs ?? scriptPacing.jitterMs ?? 0) || 0),
308
+ navigationMinIntervalMs: Math.max(0, Number(opPacing.navigationMinIntervalMs ?? scriptPacing.navigationMinIntervalMs ?? 0) || 0),
309
+ timeoutMs: timeoutRaw === null || timeoutRaw === undefined ? null : Math.max(0, Number(timeoutRaw) || 0),
310
+ };
311
+ }
312
+
313
+ isNavigationAction(action) {
314
+ const normalized = String(action || '').trim().toLowerCase();
315
+ return [
316
+ 'goto',
317
+ 'back',
318
+ 'new_page',
319
+ 'switch_page',
320
+ 'ensure_tab_pool',
321
+ 'tab_pool_switch_next',
322
+ 'tab_pool_switch_slot',
323
+ ].includes(normalized);
324
+ }
325
+
326
+ getDefaultTimeoutMs(operation) {
327
+ const action = String(operation?.action || '').trim().toLowerCase();
328
+ if (action === 'wait') {
329
+ const ms = Math.max(0, Number(operation?.params?.ms ?? operation?.params?.value ?? 0) || 0);
330
+ return Math.max(30_000, ms + 5_000);
331
+ }
332
+ if ([
333
+ 'evaluate',
334
+ 'goto',
335
+ 'new_page',
336
+ 'switch_page',
337
+ 'ensure_tab_pool',
338
+ 'tab_pool_switch_next',
339
+ 'tab_pool_switch_slot',
340
+ 'sync_window_viewport',
341
+ 'verify_subscriptions',
342
+ ].includes(action)) {
343
+ return 45_000;
344
+ }
345
+ if (['click', 'type', 'back', 'scroll_into_view', 'scroll', 'press_key', 'get_current_url', 'raise_error'].includes(action)) {
346
+ return 30_000;
347
+ }
348
+ return 20_000;
349
+ }
350
+
351
+ resolveTimeoutMs(operation) {
352
+ const pacing = this.resolvePacing(operation);
353
+ if (Number.isFinite(pacing.timeoutMs) && pacing.timeoutMs > 0) return pacing.timeoutMs;
354
+ return this.getDefaultTimeoutMs(operation);
355
+ }
356
+
357
+ resolvePlatform(operation) {
358
+ const candidate = operation?.params?.platform
359
+ ?? operation?.platform
360
+ ?? this.script?.defaults?.platform
361
+ ?? 'generic';
362
+ return String(candidate || 'generic').trim().toLowerCase() || 'generic';
363
+ }
364
+
365
+ buildTriggerKey(operation, event) {
366
+ const trigger = operation?.trigger || { type: 'startup' };
367
+ if (trigger.type === 'startup') return 'startup';
368
+ if (trigger.type === 'manual') {
369
+ return `manual:${event?.timestamp || event?.type || 'event'}`;
370
+ }
371
+ if (trigger.type !== 'subscription_event') {
372
+ return `${trigger.type || 'unknown'}:${event?.timestamp || event?.type || 'event'}`;
373
+ }
374
+
375
+ const subState = this.subscriptionState.get(trigger.subscriptionId);
376
+ if (trigger.event === 'exist') {
377
+ return `${trigger.subscriptionId}:exist:a${Number(subState?.appearCount || 0)}`;
378
+ }
379
+ if (trigger.event === 'appear') {
380
+ return `${trigger.subscriptionId}:appear:n${Number(subState?.appearCount || 0)}`;
381
+ }
382
+ return `${trigger.subscriptionId}:${trigger.event}:v${Number(subState?.version || 0)}`;
383
+ }
384
+
385
+ getTriggerAppearCount(operation) {
386
+ const trigger = operation?.trigger || {};
387
+ if (trigger.type !== 'subscription_event') return null;
388
+ if (!trigger.subscriptionId) return null;
389
+ const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
390
+ const appearCount = Number(subState?.appearCount || 0);
391
+ if (!Number.isFinite(appearCount) || appearCount <= 0) return null;
392
+ return appearCount;
393
+ }
394
+
395
+ shouldSchedule(operation, event) {
396
+ const forceRun = this.forceRunOperationIds.has(operation.id);
397
+ if (!operation.enabled) return false;
398
+ if (!forceRun && !this.isTriggered(operation, event)) return false;
399
+ if (operation.once && this.operationState.get(operation.id)?.status === 'done') return false;
400
+ if (!this.isDependencySatisfied(operation)) return false;
401
+ if (!this.areConditionsSatisfied(operation)) return false;
402
+ if (!this.impactEngine.canRunOperation(operation, event)) return false;
403
+ if (this.pendingOperations.has(operation.id)) return false;
404
+
405
+ const scheduleState = this.operationScheduleState.get(operation.id) || {};
406
+ const pacing = this.resolvePacing(operation);
407
+ const now = Date.now();
408
+ const appearCount = this.getTriggerAppearCount(operation);
409
+
410
+ if (pacing.operationMinIntervalMs > 0 && scheduleState.lastStartedAt) {
411
+ if ((now - scheduleState.lastStartedAt) < pacing.operationMinIntervalMs) return false;
412
+ }
413
+
414
+ if (pacing.eventCooldownMs > 0 && scheduleState.lastEventAt) {
415
+ if ((now - scheduleState.lastEventAt) < pacing.eventCooldownMs) return false;
416
+ }
417
+
418
+ if (
419
+ operation?.oncePerAppear === true
420
+ && Number.isFinite(appearCount)
421
+ && appearCount > 0
422
+ && (
423
+ Number(scheduleState.lastScheduledAppearCount || 0) === appearCount
424
+ || Number(scheduleState.lastCompletedAppearCount || 0) === appearCount
425
+ )
426
+ ) {
427
+ return false;
428
+ }
429
+
430
+ const triggerKey = forceRun ? `force:${operation.id}` : this.buildTriggerKey(operation, event);
431
+ const trigger = operation?.trigger || {};
432
+ const allowExistReschedule = trigger?.type === 'subscription_event'
433
+ && trigger?.event === 'exist'
434
+ && operation?.once === false
435
+ && operation?.oncePerAppear !== true
436
+ && (pacing.operationMinIntervalMs > 0 || pacing.eventCooldownMs > 0);
437
+ if (!forceRun && !allowExistReschedule && triggerKey && scheduleState.lastTriggerKey === triggerKey) {
438
+ return false;
439
+ }
440
+ return true;
441
+ }
442
+
443
+ resetCycleOperationsForSubscription(subscriptionId) {
444
+ if (!subscriptionId) return;
445
+ for (const operation of this.script.operations || []) {
446
+ if (operation?.oncePerAppear !== true) continue;
447
+ const trigger = operation?.trigger || {};
448
+ if (trigger.type !== 'subscription_event') continue;
449
+ if (trigger.subscriptionId !== subscriptionId) continue;
450
+
451
+ const prevState = this.operationState.get(operation.id);
452
+ if (!prevState || prevState.status === 'pending') continue;
453
+ this.operationState.set(operation.id, {
454
+ ...prevState,
455
+ status: 'pending',
456
+ lastError: null,
457
+ result: null,
458
+ updatedAt: nowIso(),
459
+ });
460
+ }
461
+ }
462
+
463
+ scheduleReadyOperations(event) {
464
+ for (const operation of this.script.operations || []) {
465
+ if (!this.shouldSchedule(operation, event)) continue;
466
+ this.enqueueOperation(operation, event);
467
+ }
468
+ }
469
+
470
+ async runValidation(operation, phase, context) {
471
+ if (this.skipValidation) {
472
+ return {
473
+ ok: true,
474
+ code: 'VALIDATION_SKIPPED',
475
+ message: 'validation skipped in mock/resume mode',
476
+ data: { phase },
477
+ };
478
+ }
479
+ const platform = this.resolvePlatform(operation);
480
+ const validation = operation.validation || {};
481
+ return validateOperation({
482
+ profileId: this.profileId,
483
+ validationSpec: validation,
484
+ phase,
485
+ context,
486
+ platform,
487
+ });
488
+ }
489
+
490
+ async executeOnce(operation, context) {
491
+ if (this.executeMockOperation) {
492
+ const mocked = await this.executeMockOperation({
493
+ operation,
494
+ context,
495
+ profileId: this.profileId,
496
+ });
497
+ if (mocked !== undefined) {
498
+ return normalizeExecutionResult(mocked);
499
+ }
500
+ }
501
+
502
+ const pre = await this.runValidation(operation, 'pre', context);
503
+ if (!pre.ok) {
504
+ return { ok: false, code: pre.code || 'VALIDATION_FAILED', message: pre.message || 'pre validation failed', data: { phase: 'pre', detail: pre } };
505
+ }
506
+
507
+ const execution = await executeOperation({
508
+ profileId: this.profileId,
509
+ operation,
510
+ context: {
511
+ ...context,
512
+ executeExternalOperation: this.executeExternalOperation,
513
+ },
514
+ });
515
+ if (!execution.ok) {
516
+ return { ok: false, code: execution.code || 'OPERATION_FAILED', message: execution.message || 'operation failed', data: { phase: 'execute', detail: execution } };
517
+ }
518
+
519
+ const post = await this.runValidation(operation, 'post', context);
520
+ if (!post.ok) {
521
+ return { ok: false, code: post.code || 'VALIDATION_FAILED', message: post.message || 'post validation failed', data: { phase: 'post', detail: post } };
522
+ }
523
+
524
+ return { ok: true, code: 'OPERATION_DONE', message: 'operation done', data: execution.data || execution };
525
+ }
526
+
527
+ async applyPacingBeforeAttempt(operation, attempt) {
528
+ const pacing = this.resolvePacing(operation);
529
+ if (this.isNavigationAction(operation?.action) && pacing.navigationMinIntervalMs > 0 && this.lastNavigationAt > 0) {
530
+ const elapsed = Date.now() - this.lastNavigationAt;
531
+ if (elapsed < pacing.navigationMinIntervalMs) {
532
+ const waitMs = pacing.navigationMinIntervalMs - elapsed;
533
+ if (waitMs > 0) {
534
+ this.log('autoscript:pacing_wait', {
535
+ operationId: operation.id,
536
+ action: operation.action,
537
+ attempt,
538
+ reason: 'navigation_cooldown',
539
+ waitMs,
540
+ });
541
+ await sleep(waitMs);
542
+ }
543
+ }
544
+ }
545
+
546
+ if (pacing.jitterMs > 0) {
547
+ const waitMs = Math.floor(this.random() * (pacing.jitterMs + 1));
548
+ if (waitMs > 0) {
549
+ this.log('autoscript:pacing_wait', {
550
+ operationId: operation.id,
551
+ action: operation.action,
552
+ attempt,
553
+ reason: 'jitter',
554
+ waitMs,
555
+ });
556
+ await sleep(waitMs);
557
+ }
558
+ }
559
+ }
560
+
561
+ async runRecovery(operation, event, failure) {
562
+ const checkpoint = operation.checkpoint || {};
563
+ const recovery = checkpoint.recovery || {};
564
+ const actions = Array.isArray(recovery.actions) ? recovery.actions : [];
565
+ const attempts = Math.max(0, Number(recovery.attempts) || 0);
566
+ if (attempts <= 0 || actions.length === 0) {
567
+ return { ok: false, code: 'RECOVERY_NOT_CONFIGURED', message: 'recovery not configured' };
568
+ }
569
+
570
+ const platform = this.resolvePlatform(operation);
571
+ const checkpointDoc = await captureCheckpoint({
572
+ profileId: this.profileId,
573
+ containerId: checkpoint.containerId || null,
574
+ selector: operation.params?.selector || null,
575
+ platform,
576
+ });
577
+ const baseCheckpoint = checkpointDoc?.data || {};
578
+
579
+ for (let i = 1; i <= attempts; i += 1) {
580
+ let allActionOk = true;
581
+ for (const action of actions) {
582
+ const restored = await restoreCheckpoint({
583
+ profileId: this.profileId,
584
+ checkpoint: baseCheckpoint,
585
+ action,
586
+ containerId: checkpoint.containerId || null,
587
+ selector: operation.params?.selector || null,
588
+ targetCheckpoint: checkpoint.targetCheckpoint || null,
589
+ platform,
590
+ });
591
+ this.log('autoscript:recovery_action', {
592
+ operationId: operation.id,
593
+ subscriptionId: event.subscriptionId || null,
594
+ action,
595
+ attempt: i,
596
+ ok: restored.ok,
597
+ code: restored.code || null,
598
+ message: restored.message || null,
599
+ });
600
+ if (!restored.ok) {
601
+ allActionOk = false;
602
+ }
603
+ }
604
+ if (allActionOk) {
605
+ return { ok: true, code: 'RECOVERY_DONE', message: 'recovery done', data: { attempts: i } };
606
+ }
607
+ }
608
+
609
+ return {
610
+ ok: false,
611
+ code: 'RECOVERY_EXHAUSTED',
612
+ message: 'recovery attempts exhausted',
613
+ data: { operationId: operation.id, failure },
614
+ };
615
+ }
616
+
617
+ async runOperation(operation, event) {
618
+ const retry = operation.retry || {};
619
+ const maxAttempts = Math.max(1, Number(retry.attempts) || 1);
620
+ const backoffMs = Math.max(0, Number(retry.backoffMs) || 0);
621
+
622
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
623
+ const context = {
624
+ runId: this.runId,
625
+ event,
626
+ attempt,
627
+ maxAttempts,
628
+ runtime: this.runtimeContext,
629
+ };
630
+
631
+ await this.applyPacingBeforeAttempt(operation, attempt);
632
+
633
+ if (!this.isTriggerStillValid(operation)) {
634
+ const trigger = operation?.trigger || {};
635
+ const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
636
+ this.operationState.set(operation.id, {
637
+ status: 'skipped',
638
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
639
+ lastError: null,
640
+ updatedAt: nowIso(),
641
+ result: {
642
+ code: 'OPERATION_SKIPPED_STALE_TRIGGER',
643
+ trigger,
644
+ currentSubscriptionState: subState,
645
+ },
646
+ });
647
+ this.log('autoscript:operation_skipped', {
648
+ operationId: operation.id,
649
+ action: operation.action,
650
+ attempt,
651
+ reason: 'stale_trigger',
652
+ trigger,
653
+ currentSubscriptionState: subState,
654
+ });
655
+ return {
656
+ ok: true,
657
+ terminalState: 'skipped_stale',
658
+ result: {
659
+ ok: true,
660
+ code: 'OPERATION_SKIPPED_STALE_TRIGGER',
661
+ message: 'operation skipped because trigger is no longer valid',
662
+ data: {
663
+ trigger,
664
+ currentSubscriptionState: subState,
665
+ },
666
+ },
667
+ };
668
+ }
669
+
670
+ this.log('autoscript:operation_start', {
671
+ operationId: operation.id,
672
+ action: operation.action,
673
+ attempt,
674
+ maxAttempts,
675
+ trigger: operation.trigger,
676
+ subscriptionId: event.subscriptionId || null,
677
+ });
678
+
679
+ const startedAt = Date.now();
680
+ const timeoutMs = this.resolveTimeoutMs(operation);
681
+ const result = await withTimeout(
682
+ this.executeOnce(operation, context),
683
+ timeoutMs,
684
+ () => ({
685
+ ok: false,
686
+ code: 'OPERATION_TIMEOUT',
687
+ message: `operation timed out after ${timeoutMs}ms`,
688
+ data: { timeoutMs },
689
+ }),
690
+ );
691
+ const latencyMs = Date.now() - startedAt;
692
+ if (result.ok) {
693
+ if (this.isNavigationAction(operation?.action)) {
694
+ this.lastNavigationAt = Date.now();
695
+ }
696
+ this.operationState.set(operation.id, {
697
+ status: 'done',
698
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
699
+ lastError: null,
700
+ updatedAt: nowIso(),
701
+ result: result.data || null,
702
+ });
703
+ this.log('autoscript:operation_done', {
704
+ operationId: operation.id,
705
+ action: operation.action,
706
+ attempt,
707
+ latencyMs,
708
+ result: result.data || null,
709
+ });
710
+ // Re-evaluate graph on the same event so dependencies can continue in one trigger chain.
711
+ this.scheduleReadyOperations(event);
712
+ return { ok: true, terminalState: 'done', result };
713
+ }
714
+
715
+ if (this.shouldTreatAsStaleValidationSkip(operation, result)) {
716
+ const trigger = operation?.trigger || {};
717
+ const subState = this.subscriptionState.get(trigger.subscriptionId) || null;
718
+ this.operationState.set(operation.id, {
719
+ status: 'skipped',
720
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
721
+ lastError: null,
722
+ updatedAt: nowIso(),
723
+ result: {
724
+ code: 'OPERATION_SKIPPED_STALE_TRIGGER_PRE_VALIDATION',
725
+ trigger,
726
+ currentSubscriptionState: subState,
727
+ },
728
+ });
729
+ this.log('autoscript:operation_skipped', {
730
+ operationId: operation.id,
731
+ action: operation.action,
732
+ attempt,
733
+ reason: 'stale_trigger_pre_validation',
734
+ trigger,
735
+ currentSubscriptionState: subState,
736
+ validation: result.data?.detail || null,
737
+ });
738
+ return {
739
+ ok: true,
740
+ terminalState: 'skipped_stale_pre_validation',
741
+ result: {
742
+ ok: true,
743
+ code: 'OPERATION_SKIPPED_STALE_TRIGGER_PRE_VALIDATION',
744
+ message: 'operation skipped after pre-validation because trigger is no longer valid',
745
+ data: {
746
+ trigger,
747
+ currentSubscriptionState: subState,
748
+ },
749
+ },
750
+ };
751
+ }
752
+
753
+ const terminalDoneCode = extractTerminalDoneCode(result);
754
+ if (terminalDoneCode) {
755
+ this.operationState.set(operation.id, {
756
+ status: 'done',
757
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
758
+ lastError: null,
759
+ updatedAt: nowIso(),
760
+ result: { terminalDoneCode },
761
+ });
762
+ this.log('autoscript:operation_terminal', {
763
+ operationId: operation.id,
764
+ action: operation.action,
765
+ attempt,
766
+ latencyMs,
767
+ code: terminalDoneCode,
768
+ });
769
+ this.stop('script_complete');
770
+ return {
771
+ ok: true,
772
+ terminalState: 'done_terminal',
773
+ result: {
774
+ ok: true,
775
+ code: terminalDoneCode,
776
+ message: 'autoscript completed',
777
+ data: { terminalDoneCode },
778
+ },
779
+ };
780
+ }
781
+
782
+ this.operationState.set(operation.id, {
783
+ status: 'failed',
784
+ runs: Number(this.operationState.get(operation.id)?.runs || 0) + 1,
785
+ lastError: result.message || 'operation failed',
786
+ updatedAt: nowIso(),
787
+ result: null,
788
+ });
789
+ this.log('autoscript:operation_error', {
790
+ operationId: operation.id,
791
+ action: operation.action,
792
+ attempt,
793
+ latencyMs,
794
+ code: result.code || 'OPERATION_FAILED',
795
+ message: result.message || 'operation failed',
796
+ });
797
+
798
+ const recoveryResult = await this.runRecovery(operation, event, result);
799
+ if (recoveryResult.ok) {
800
+ this.log('autoscript:operation_recovered', {
801
+ operationId: operation.id,
802
+ action: operation.action,
803
+ attempt,
804
+ code: recoveryResult.code,
805
+ });
806
+ } else {
807
+ this.log('autoscript:operation_recovery_failed', {
808
+ operationId: operation.id,
809
+ action: operation.action,
810
+ attempt,
811
+ code: recoveryResult.code,
812
+ message: recoveryResult.message,
813
+ });
814
+ }
815
+
816
+ if (attempt < maxAttempts) {
817
+ if (backoffMs > 0) await sleep(backoffMs);
818
+ continue;
819
+ }
820
+
821
+ const impact = this.impactEngine.applyFailure({ operation, event });
822
+ this.log('autoscript:impact', {
823
+ operationId: operation.id,
824
+ action: operation.action,
825
+ scope: impact.scope,
826
+ scriptStopped: impact.scriptStopped,
827
+ blockedSubscriptions: impact.blockedSubscriptions,
828
+ blockedOperations: impact.blockedOperations,
829
+ });
830
+
831
+ if (impact.scriptStopped) {
832
+ this.stop('script_failure');
833
+ }
834
+ return { ok: false, terminalState: 'failed', result };
835
+ }
836
+
837
+ return { ok: false, terminalState: 'failed', result: null };
838
+ }
839
+
840
+ enqueueOperation(operation, event) {
841
+ if (this.pendingOperations.has(operation.id)) return;
842
+ if (!this.state.active) return;
843
+
844
+ const scheduleState = this.operationScheduleState.get(operation.id) || {};
845
+ scheduleState.lastScheduledAt = Date.now();
846
+ scheduleState.lastEventAt = Date.now();
847
+ scheduleState.lastTriggerKey = this.buildTriggerKey(operation, event);
848
+ const scheduledAppearCount = this.getTriggerAppearCount(operation);
849
+ if (Number.isFinite(scheduledAppearCount) && scheduledAppearCount > 0) {
850
+ scheduleState.lastScheduledAppearCount = scheduledAppearCount;
851
+ }
852
+ this.operationScheduleState.set(operation.id, scheduleState);
853
+ this.forceRunOperationIds.delete(operation.id);
854
+
855
+ this.pendingOperations.add(operation.id);
856
+ this.operationQueue = this.operationQueue
857
+ .then(async () => {
858
+ if (!this.state.active) return;
859
+ const innerState = this.operationScheduleState.get(operation.id) || {};
860
+ innerState.lastStartedAt = Date.now();
861
+ this.operationScheduleState.set(operation.id, innerState);
862
+ const outcome = await this.runOperation(operation, event);
863
+ if (
864
+ outcome
865
+ && operation?.oncePerAppear === true
866
+ && Number.isFinite(scheduledAppearCount)
867
+ && scheduledAppearCount > 0
868
+ && outcome.terminalState !== 'skipped_stale'
869
+ && outcome.terminalState !== 'skipped_stale_pre_validation'
870
+ ) {
871
+ const completedState = this.operationScheduleState.get(operation.id) || {};
872
+ completedState.lastCompletedAppearCount = scheduledAppearCount;
873
+ this.operationScheduleState.set(operation.id, completedState);
874
+ }
875
+ })
876
+ .finally(() => {
877
+ this.pendingOperations.delete(operation.id);
878
+ });
879
+ }
880
+
881
+ async handleEvent(event) {
882
+ if (!this.state.active) return;
883
+ if (event.subscriptionId) {
884
+ const prev = this.subscriptionState.get(event.subscriptionId) || { exists: false, appearCount: 0, lastEventAt: null };
885
+ const next = { ...prev, lastEventAt: event.timestamp || nowIso() };
886
+ if (event.type === 'appear') {
887
+ next.exists = true;
888
+ next.appearCount = Number(prev.appearCount || 0) + 1;
889
+ next.version = Number(prev.version || 0) + 1;
890
+ this.resetCycleOperationsForSubscription(event.subscriptionId);
891
+ } else if (event.type === 'disappear') {
892
+ next.exists = false;
893
+ next.version = Number(prev.version || 0) + 1;
894
+ } else if (event.type === 'exist') {
895
+ next.exists = true;
896
+ } else if (event.type === 'change') {
897
+ next.exists = Number(event.count || 0) > 0 || prev.exists === true;
898
+ next.version = Number(prev.version || 0) + 1;
899
+ }
900
+ this.subscriptionState.set(event.subscriptionId, next);
901
+ }
902
+
903
+ this.scheduleReadyOperations(event);
904
+ }
905
+
906
+ async start() {
907
+ if (this.state.active) {
908
+ throw new Error('Autoscript runtime already running');
909
+ }
910
+ if (!this.profileId) {
911
+ throw new Error('profileId is required');
912
+ }
913
+ this.state.active = true;
914
+ this.state.reason = null;
915
+ this.state.startedAt = nowIso();
916
+ this.donePromise = new Promise((resolve) => {
917
+ this.resolveDone = resolve;
918
+ });
919
+
920
+ this.log('autoscript:start', {
921
+ name: this.script.name,
922
+ subscriptions: this.script.subscriptions.length,
923
+ operations: this.script.operations.length,
924
+ throttle: this.script.throttle,
925
+ });
926
+
927
+ if (this.mockEvents) {
928
+ this.watchHandle = { stop: () => {} };
929
+ const events = this.mockEvents.map((item) => {
930
+ if (!item || typeof item !== 'object') return null;
931
+ const type = String(item.type || '').trim();
932
+ if (!type) return null;
933
+ return {
934
+ type,
935
+ subscriptionId: item.subscriptionId ? String(item.subscriptionId) : null,
936
+ selector: item.selector ? String(item.selector) : null,
937
+ count: item.count ?? null,
938
+ timestamp: item.timestamp || nowIso(),
939
+ delayMs: Math.max(0, Number(item.delayMs ?? this.mockEventBaseDelayMs) || this.mockEventBaseDelayMs),
940
+ };
941
+ }).filter(Boolean);
942
+
943
+ (async () => {
944
+ for (const event of events) {
945
+ if (!this.state.active) return;
946
+ if (event.delayMs > 0) await sleep(event.delayMs);
947
+ this.log('autoscript:event', {
948
+ type: event.type,
949
+ subscriptionId: event.subscriptionId || null,
950
+ selector: event.selector || null,
951
+ count: event.count ?? null,
952
+ });
953
+ await this.handleEvent(event);
954
+ }
955
+ if (this.stopWhenMockEventsExhausted && this.state.active) {
956
+ // Allow startup-trigger scheduling to enqueue operations before drain check.
957
+ await Promise.resolve();
958
+ await this.operationQueue;
959
+ if (this.state.active) this.stop('mock_events_exhausted');
960
+ }
961
+ })().catch((err) => {
962
+ this.log('autoscript:watch_error', {
963
+ code: 'MOCK_EVENT_FEED_FAILED',
964
+ message: err?.message || String(err),
965
+ });
966
+ if (this.state.active) this.stop('mock_event_feed_failure');
967
+ });
968
+ } else {
969
+ this.watchHandle = await watchSubscriptions({
970
+ profileId: this.profileId,
971
+ subscriptions: this.script.subscriptions,
972
+ throttle: this.script.throttle,
973
+ filterMode: this.script?.defaults?.filterMode || 'strict',
974
+ onEvent: async (event) => {
975
+ this.log('autoscript:event', {
976
+ type: event.type,
977
+ subscriptionId: event.subscriptionId || null,
978
+ selector: event.selector || null,
979
+ count: event.count ?? null,
980
+ });
981
+ await this.handleEvent(event);
982
+ },
983
+ onError: (err) => {
984
+ this.log('autoscript:watch_error', {
985
+ code: 'SUBSCRIPTION_WATCH_FAILED',
986
+ message: err?.message || String(err),
987
+ });
988
+ },
989
+ });
990
+ }
991
+
992
+ await this.handleEvent({ type: 'startup', timestamp: nowIso() });
993
+ return {
994
+ runId: this.runId,
995
+ stop: (reason = 'stopped') => this.stop(reason),
996
+ done: this.donePromise,
997
+ };
998
+ }
999
+
1000
+ stop(reason = 'stopped') {
1001
+ if (!this.state.active) return;
1002
+ this.state.active = false;
1003
+ this.state.reason = reason;
1004
+ this.state.stoppedAt = nowIso();
1005
+ if (this.watchHandle?.stop) this.watchHandle.stop();
1006
+ this.log('autoscript:stop', { reason });
1007
+ if (this.resolveDone) {
1008
+ this.resolveDone({
1009
+ runId: this.runId,
1010
+ reason,
1011
+ startedAt: this.state.startedAt,
1012
+ stoppedAt: this.state.stoppedAt,
1013
+ });
1014
+ this.resolveDone = null;
1015
+ }
1016
+ }
1017
+ }