@web-auto/camo 0.2.0 → 0.2.1

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,1011 +1,1011 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { getDefaultProfile, listProfiles } from '../utils/config.mjs';
4
- import { explainAutoscript, loadAndValidateAutoscript } from '../autoscript/schema.mjs';
5
- import { AutoscriptRunner } from '../autoscript/runtime.mjs';
6
- import { safeAppendProgressEvent } from '../events/progress-log.mjs';
7
-
8
- function readFlagValue(args, names) {
9
- for (let i = 0; i < args.length; i += 1) {
10
- if (!names.includes(args[i])) continue;
11
- const value = args[i + 1];
12
- if (!value || String(value).startsWith('-')) return null;
13
- return value;
14
- }
15
- return null;
16
- }
17
-
18
- function collectPositionals(args, startIndex = 2, valueFlags = new Set(['--profile', '-p'])) {
19
- const out = [];
20
- for (let i = startIndex; i < args.length; i += 1) {
21
- const arg = args[i];
22
- if (!arg) continue;
23
- if (valueFlags.has(arg)) {
24
- i += 1;
25
- continue;
26
- }
27
- if (String(arg).startsWith('-')) continue;
28
- out.push(arg);
29
- }
30
- return out;
31
- }
32
-
33
- function assertExistingProfile(profileId) {
34
- const id = String(profileId || '').trim();
35
- if (!id) {
36
- throw new Error('profileId is required');
37
- }
38
- const known = new Set(listProfiles());
39
- if (!known.has(id)) {
40
- throw new Error(`profile not found: ${id}. create it first with "camo profile create ${id}"`);
41
- }
42
- return id;
43
- }
44
-
45
- function appendJsonLine(filePath, payload) {
46
- if (!filePath) return;
47
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
- fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
49
- }
50
-
51
- function normalizeResultPayload(result) {
52
- if (!result || typeof result !== 'object') return null;
53
- if (result.result && typeof result.result === 'object' && !Array.isArray(result.result)) {
54
- return result.result;
55
- }
56
- return result;
57
- }
58
-
59
- function bumpCounter(target, key) {
60
- if (!target[key]) {
61
- target[key] = 1;
62
- return;
63
- }
64
- target[key] += 1;
65
- }
66
-
67
- function createRunSummaryTracker({ file, profileId, runId }) {
68
- return {
69
- file,
70
- profileId,
71
- runId,
72
- runName: null,
73
- startTs: null,
74
- stopTs: null,
75
- stopReason: null,
76
- counts: {
77
- operationStart: 0,
78
- operationDone: 0,
79
- operationError: 0,
80
- operationSkipped: 0,
81
- operationTerminal: 0,
82
- watchError: 0,
83
- },
84
- target: {
85
- visitedMax: 0,
86
- notesClosed: 0,
87
- harvestCount: 0,
88
- likeOps: 0,
89
- },
90
- comments: {
91
- total: 0,
92
- expectedTotal: 0,
93
- bottomReached: 0,
94
- recoveriesTotal: 0,
95
- coverageSum: 0,
96
- coverageCount: 0,
97
- exitReasonCounts: {},
98
- commentsPathsCount: 0,
99
- },
100
- likes: {
101
- scanned: 0,
102
- hit: 0,
103
- liked: 0,
104
- dedupSkipped: 0,
105
- alreadyLikedSkipped: 0,
106
- verifyFailed: 0,
107
- clickFailed: 0,
108
- missingLikeControl: 0,
109
- summaryCount: 0,
110
- },
111
- closeDetail: {
112
- rollbackTotal: 0,
113
- returnToSearchTotal: 0,
114
- searchCountMax: 0,
115
- pageExitReasonCounts: {},
116
- },
117
- terminal: {
118
- operationId: null,
119
- code: null,
120
- ts: null,
121
- },
122
- };
123
- }
124
-
125
- function applyRunSummaryEvent(tracker, payload) {
126
- if (!payload || typeof payload !== 'object') return;
127
- const { event, ts } = payload;
128
- if (event === 'autoscript:start') {
129
- tracker.startTs = tracker.startTs || ts || null;
130
- tracker.runId = tracker.runId || payload.runId || null;
131
- tracker.profileId = tracker.profileId || payload.profileId || null;
132
- tracker.runName = payload.name || tracker.runName;
133
- return;
134
- }
135
- if (event === 'autoscript:stop') {
136
- tracker.stopReason = payload.reason || tracker.stopReason;
137
- return;
138
- }
139
- if (event === 'autoscript:watch_error') {
140
- tracker.counts.watchError += 1;
141
- return;
142
- }
143
- if (event === 'autoscript:operation_start') {
144
- tracker.counts.operationStart += 1;
145
- return;
146
- }
147
- if (event === 'autoscript:operation_error') {
148
- tracker.counts.operationError += 1;
149
- return;
150
- }
151
- if (event === 'autoscript:operation_skipped') {
152
- tracker.counts.operationSkipped += 1;
153
- return;
154
- }
155
- if (event === 'autoscript:operation_terminal') {
156
- tracker.counts.operationTerminal += 1;
157
- tracker.terminal.operationId = payload.operationId || tracker.terminal.operationId;
158
- tracker.terminal.code = payload.code || tracker.terminal.code;
159
- tracker.terminal.ts = payload.ts || tracker.terminal.ts;
160
- return;
161
- }
162
- if (event !== 'autoscript:operation_done') return;
163
-
164
- tracker.counts.operationDone += 1;
165
- const result = normalizeResultPayload(payload.result);
166
- const opId = payload.operationId;
167
- if (!opId || !result || typeof result !== 'object') return;
168
-
169
- if (opId === 'open_first_detail' || opId === 'open_next_detail') {
170
- const visited = Number(result.visited);
171
- if (Number.isFinite(visited)) {
172
- tracker.target.visitedMax = Math.max(tracker.target.visitedMax, visited);
173
- }
174
- return;
175
- }
176
-
177
- if (opId === 'comments_harvest') {
178
- tracker.target.harvestCount += 1;
179
- tracker.comments.total += Number(result.collected || 0);
180
- tracker.comments.expectedTotal += Number(result.expectedCommentsCount || 0);
181
- tracker.comments.recoveriesTotal += Number(result.recoveries || 0);
182
- if (result.reachedBottom === true) tracker.comments.bottomReached += 1;
183
- if (result.commentsPath) tracker.comments.commentsPathsCount += 1;
184
- const coverage = Number(result.commentCoverageRate);
185
- if (Number.isFinite(coverage)) {
186
- tracker.comments.coverageCount += 1;
187
- tracker.comments.coverageSum += coverage;
188
- }
189
- bumpCounter(tracker.comments.exitReasonCounts, result.exitReason || 'unknown');
190
- return;
191
- }
192
-
193
- if (opId === 'comment_like') {
194
- tracker.target.likeOps += 1;
195
- tracker.likes.scanned += Number(result.scannedCount || 0);
196
- tracker.likes.hit += Number(result.hitCount || 0);
197
- tracker.likes.liked += Number(result.likedCount || 0);
198
- tracker.likes.dedupSkipped += Number(result.dedupSkipped || 0);
199
- tracker.likes.alreadyLikedSkipped += Number(result.alreadyLikedSkipped || 0);
200
- tracker.likes.verifyFailed += Number(result.verifyFailed || 0);
201
- tracker.likes.clickFailed += Number(result.clickFailed || 0);
202
- tracker.likes.missingLikeControl += Number(result.missingLikeControl || 0);
203
- if (result.summaryPath) tracker.likes.summaryCount += 1;
204
- return;
205
- }
206
-
207
- if (opId === 'close_detail') {
208
- tracker.target.notesClosed += 1;
209
- tracker.closeDetail.rollbackTotal += Number(result.rollbackCount || 0);
210
- tracker.closeDetail.returnToSearchTotal += Number(result.returnToSearchCount || 0);
211
- tracker.closeDetail.searchCountMax = Math.max(
212
- tracker.closeDetail.searchCountMax,
213
- Number(result.searchCount || 0),
214
- );
215
- bumpCounter(tracker.closeDetail.pageExitReasonCounts, result.pageExitReason || 'unknown');
216
- }
217
- }
218
-
219
- function buildRunSummary(tracker, fallbackStopReason = null) {
220
- const harvestCount = tracker.target.harvestCount;
221
- const coverageCount = tracker.comments.coverageCount;
222
- const durationSec = tracker.startTs && tracker.stopTs
223
- ? (new Date(tracker.stopTs).getTime() - new Date(tracker.startTs).getTime()) / 1000
224
- : null;
225
- return {
226
- runId: tracker.runId || null,
227
- profileId: tracker.profileId || null,
228
- file: tracker.file || null,
229
- runName: tracker.runName || null,
230
- startTs: tracker.startTs || null,
231
- stopTs: tracker.stopTs || null,
232
- durationSec,
233
- stopReason: tracker.stopReason || fallbackStopReason || null,
234
- counts: tracker.counts,
235
- target: tracker.target,
236
- comments: {
237
- total: tracker.comments.total,
238
- expectedTotal: tracker.comments.expectedTotal,
239
- avgPerNote: harvestCount > 0 ? tracker.comments.total / harvestCount : null,
240
- bottomReached: tracker.comments.bottomReached,
241
- bottomRate: harvestCount > 0 ? tracker.comments.bottomReached / harvestCount : null,
242
- recoveriesTotal: tracker.comments.recoveriesTotal,
243
- exitReasonCounts: tracker.comments.exitReasonCounts,
244
- coverageAvg: coverageCount > 0 ? tracker.comments.coverageSum / coverageCount : null,
245
- commentsPathsCount: tracker.comments.commentsPathsCount,
246
- },
247
- likes: tracker.likes,
248
- closeDetail: tracker.closeDetail,
249
- terminal: tracker.terminal.code
250
- ? tracker.terminal
251
- : null,
252
- };
253
- }
254
-
255
- function buildDefaultSummaryPath(jsonlPath) {
256
- if (!jsonlPath) return null;
257
- return jsonlPath.endsWith('.jsonl')
258
- ? `${jsonlPath.slice(0, -'.jsonl'.length)}.summary.json`
259
- : `${jsonlPath}.summary.json`;
260
- }
261
-
262
- function readJsonlEvents(filePath) {
263
- const resolved = path.resolve(filePath);
264
- if (!fs.existsSync(resolved)) {
265
- throw new Error(`JSONL file not found: ${resolved}`);
266
- }
267
- const raw = fs.readFileSync(resolved, 'utf8');
268
- const rows = raw
269
- .split('\n')
270
- .map((line) => line.trim())
271
- .filter(Boolean)
272
- .map((line, index) => {
273
- try {
274
- return JSON.parse(line);
275
- } catch (err) {
276
- throw new Error(`Invalid JSONL at line ${index + 1}: ${err?.message || String(err)}`);
277
- }
278
- });
279
- return { resolvedPath: resolved, rows };
280
- }
281
-
282
- function cloneObject(value, fallback = {}) {
283
- if (!value || typeof value !== 'object') return { ...fallback };
284
- return { ...fallback, ...value };
285
- }
286
-
287
- function reduceSubscriptionState(subscriptionState, event) {
288
- if (!event?.subscriptionId) return;
289
- const prev = subscriptionState[event.subscriptionId] || {
290
- exists: false,
291
- appearCount: 0,
292
- version: 0,
293
- lastEventAt: null,
294
- };
295
- const next = { ...prev, lastEventAt: event.ts || null };
296
- if (event.type === 'appear') {
297
- next.exists = true;
298
- next.appearCount = Number(prev.appearCount || 0) + 1;
299
- next.version = Number(prev.version || 0) + 1;
300
- } else if (event.type === 'exist') {
301
- next.exists = true;
302
- } else if (event.type === 'disappear') {
303
- next.exists = false;
304
- next.version = Number(prev.version || 0) + 1;
305
- } else if (event.type === 'change') {
306
- next.exists = Number(event.count || 0) > 0 || prev.exists === true;
307
- next.version = Number(prev.version || 0) + 1;
308
- }
309
- subscriptionState[event.subscriptionId] = next;
310
- }
311
-
312
- function buildSnapshotFromEvents({
313
- events,
314
- file,
315
- reason = 'log_snapshot',
316
- }) {
317
- const first = events[0] || null;
318
- const last = events[events.length - 1] || null;
319
- const tracker = createRunSummaryTracker({
320
- file,
321
- profileId: first?.profileId || null,
322
- runId: first?.runId || null,
323
- });
324
-
325
- const operationState = {};
326
- const subscriptionState = {};
327
- let state = { active: false, reason: null, startedAt: null, stoppedAt: null };
328
- let lastEvent = null;
329
-
330
- for (const event of events) {
331
- applyRunSummaryEvent(tracker, event);
332
-
333
- if (event.event === 'autoscript:start') {
334
- state = {
335
- active: true,
336
- reason: null,
337
- startedAt: event.ts || null,
338
- stoppedAt: null,
339
- };
340
- }
341
-
342
- if (event.event === 'autoscript:event') {
343
- reduceSubscriptionState(subscriptionState, event);
344
- lastEvent = {
345
- type: event.type || 'tick',
346
- subscriptionId: event.subscriptionId || null,
347
- selector: event.selector || null,
348
- count: event.count ?? null,
349
- timestamp: event.ts || null,
350
- };
351
- }
352
-
353
- if (event.event === 'autoscript:operation_start') {
354
- const prev = cloneObject(operationState[event.operationId], {
355
- status: 'pending',
356
- runs: 0,
357
- lastError: null,
358
- updatedAt: null,
359
- result: null,
360
- });
361
- operationState[event.operationId] = {
362
- ...prev,
363
- status: 'running',
364
- updatedAt: event.ts || prev.updatedAt,
365
- };
366
- } else if (event.event === 'autoscript:operation_done') {
367
- const prev = cloneObject(operationState[event.operationId], {
368
- status: 'pending',
369
- runs: 0,
370
- lastError: null,
371
- updatedAt: null,
372
- result: null,
373
- });
374
- operationState[event.operationId] = {
375
- ...prev,
376
- status: 'done',
377
- runs: Number(prev.runs || 0) + 1,
378
- lastError: null,
379
- updatedAt: event.ts || prev.updatedAt,
380
- result: event.result ?? null,
381
- };
382
- } else if (event.event === 'autoscript:operation_error') {
383
- const prev = cloneObject(operationState[event.operationId], {
384
- status: 'pending',
385
- runs: 0,
386
- lastError: null,
387
- updatedAt: null,
388
- result: null,
389
- });
390
- operationState[event.operationId] = {
391
- ...prev,
392
- status: 'failed',
393
- runs: Number(prev.runs || 0) + 1,
394
- lastError: event.message || event.code || 'operation failed',
395
- updatedAt: event.ts || prev.updatedAt,
396
- result: null,
397
- };
398
- } else if (event.event === 'autoscript:operation_skipped') {
399
- const prev = cloneObject(operationState[event.operationId], {
400
- status: 'pending',
401
- runs: 0,
402
- lastError: null,
403
- updatedAt: null,
404
- result: null,
405
- });
406
- operationState[event.operationId] = {
407
- ...prev,
408
- status: 'skipped',
409
- runs: Number(prev.runs || 0) + 1,
410
- lastError: null,
411
- updatedAt: event.ts || prev.updatedAt,
412
- result: {
413
- code: event.code || null,
414
- reason: event.reason || null,
415
- },
416
- };
417
- } else if (event.event === 'autoscript:operation_terminal') {
418
- const prev = cloneObject(operationState[event.operationId], {
419
- status: 'pending',
420
- runs: 0,
421
- lastError: null,
422
- updatedAt: null,
423
- result: null,
424
- });
425
- operationState[event.operationId] = {
426
- ...prev,
427
- status: 'done',
428
- runs: Number(prev.runs || 0) + 1,
429
- lastError: null,
430
- updatedAt: event.ts || prev.updatedAt,
431
- result: {
432
- terminalDoneCode: event.code || null,
433
- },
434
- };
435
- } else if (event.event === 'autoscript:stop') {
436
- state = {
437
- ...state,
438
- active: false,
439
- reason: event.reason || state.reason || null,
440
- stoppedAt: event.ts || state.stoppedAt || null,
441
- };
442
- }
443
- }
444
-
445
- tracker.stopTs = last?.ts || tracker.stopTs || null;
446
- const summary = buildRunSummary(tracker, state.reason || null);
447
- const snapshot = {
448
- kind: 'autoscript_snapshot',
449
- version: 1,
450
- reason,
451
- createdAt: new Date().toISOString(),
452
- sourceJsonl: file,
453
- runId: summary.runId || null,
454
- profileId: summary.profileId || null,
455
- scriptName: summary.runName || null,
456
- summary,
457
- state: {
458
- state,
459
- subscriptionState,
460
- operationState,
461
- operationScheduleState: {},
462
- runtimeContext: { vars: {}, tabPool: null, currentTab: null },
463
- lastNavigationAt: 0,
464
- lastEvent,
465
- },
466
- };
467
- return snapshot;
468
- }
469
-
470
- function toOperationOrder(script) {
471
- return Array.isArray(script?.operations)
472
- ? script.operations.map((op) => String(op?.id || '').trim()).filter(Boolean)
473
- : [];
474
- }
475
-
476
- function buildDescendantSet(script, nodeId) {
477
- const dependents = new Map();
478
- for (const op of script.operations || []) {
479
- for (const dep of op.dependsOn || []) {
480
- if (!dependents.has(dep)) dependents.set(dep, new Set());
481
- dependents.get(dep).add(op.id);
482
- }
483
- }
484
- const seen = new Set([nodeId]);
485
- const queue = [nodeId];
486
- while (queue.length > 0) {
487
- const current = queue.shift();
488
- for (const next of dependents.get(current) || []) {
489
- if (seen.has(next)) continue;
490
- seen.add(next);
491
- queue.push(next);
492
- }
493
- }
494
- return seen;
495
- }
496
-
497
- function buildResumeStateFromSnapshot(script, snapshot, fromNodeId = null) {
498
- const sourceState = snapshot?.state && typeof snapshot.state === 'object'
499
- ? snapshot.state
500
- : {};
501
- const sourceOps = sourceState.operationState && typeof sourceState.operationState === 'object'
502
- ? sourceState.operationState
503
- : {};
504
- const forceRunOperationIds = [];
505
- const operationState = {};
506
- const descendants = fromNodeId ? buildDescendantSet(script, fromNodeId) : null;
507
-
508
- for (const opId of toOperationOrder(script)) {
509
- const prev = sourceOps[opId] && typeof sourceOps[opId] === 'object'
510
- ? sourceOps[opId]
511
- : {
512
- status: 'pending',
513
- runs: 0,
514
- lastError: null,
515
- updatedAt: null,
516
- result: null,
517
- };
518
- if (!descendants) {
519
- operationState[opId] = {
520
- status: String(prev.status || 'pending'),
521
- runs: Math.max(0, Number(prev.runs || 0) || 0),
522
- lastError: prev.lastError || null,
523
- updatedAt: prev.updatedAt || null,
524
- result: prev.result ?? null,
525
- };
526
- continue;
527
- }
528
-
529
- if (descendants.has(opId)) {
530
- operationState[opId] = {
531
- status: 'pending',
532
- runs: Math.max(0, Number(prev.runs || 0) || 0),
533
- lastError: null,
534
- updatedAt: prev.updatedAt || null,
535
- result: null,
536
- };
537
- forceRunOperationIds.push(opId);
538
- } else {
539
- operationState[opId] = {
540
- status: 'done',
541
- runs: Math.max(1, Number(prev.runs || 1) || 1),
542
- lastError: null,
543
- updatedAt: prev.updatedAt || null,
544
- result: prev.result ?? { code: 'RESUME_SKIPPED_PREVIOUS_BRANCH' },
545
- };
546
- }
547
- }
548
-
549
- return {
550
- initialState: {
551
- state: {
552
- state: sourceState.state || {
553
- active: false,
554
- reason: null,
555
- startedAt: null,
556
- stoppedAt: null,
557
- },
558
- subscriptionState: sourceState.subscriptionState || {},
559
- operationState,
560
- operationScheduleState: sourceState.operationScheduleState || {},
561
- runtimeContext: sourceState.runtimeContext || { vars: {}, tabPool: null, currentTab: null },
562
- lastNavigationAt: Number(sourceState.lastNavigationAt || 0) || 0,
563
- },
564
- },
565
- forceRunOperationIds,
566
- };
567
- }
568
-
569
- function createMockOperationExecutor(fixture) {
570
- const source = fixture?.operations && typeof fixture.operations === 'object'
571
- ? fixture.operations
572
- : {};
573
- const queues = new Map(
574
- Object.entries(source).map(([key, value]) => [key, Array.isArray(value) ? [...value] : [value]]),
575
- );
576
- const defaultResult = fixture?.defaultResult && typeof fixture.defaultResult === 'object'
577
- ? fixture.defaultResult
578
- : { ok: true, code: 'OPERATION_DONE', message: 'mock operation done', data: { mock: true } };
579
-
580
- return ({ operation }) => {
581
- const key = String(operation?.id || '').trim();
582
- const queue = queues.get(key);
583
- const wildcard = queues.get('*');
584
- const next = queue && queue.length > 0
585
- ? queue.shift()
586
- : (wildcard && wildcard.length > 0 ? wildcard.shift() : defaultResult);
587
- return next;
588
- };
589
- }
590
-
591
- async function handleValidate(args) {
592
- const filePath = collectPositionals(args)[0];
593
- if (!filePath) {
594
- throw new Error('Usage: camo autoscript validate <file>');
595
- }
596
- const { sourcePath, validation } = loadAndValidateAutoscript(filePath);
597
- console.log(JSON.stringify({
598
- ok: validation.ok,
599
- file: sourcePath,
600
- errors: validation.errors,
601
- warnings: validation.warnings,
602
- operationOrder: validation.topologicalOrder,
603
- }, null, 2));
604
- if (!validation.ok) {
605
- process.exitCode = 1;
606
- }
607
- }
608
-
609
- async function handleExplain(args) {
610
- const filePath = collectPositionals(args)[0];
611
- if (!filePath) {
612
- throw new Error('Usage: camo autoscript explain <file>');
613
- }
614
- const { script, sourcePath } = loadAndValidateAutoscript(filePath);
615
- const explained = explainAutoscript(script);
616
- console.log(JSON.stringify({
617
- ok: explained.ok,
618
- file: sourcePath,
619
- ...explained,
620
- }, null, 2));
621
- }
622
-
623
- async function executeAutoscriptRuntime({
624
- commandName,
625
- script,
626
- sourcePath,
627
- profileId,
628
- jsonlPath,
629
- summaryPath,
630
- runnerOptions = {},
631
- extraStartPayload = {},
632
- }) {
633
- let jsonlWriteError = null;
634
- const summaryTracker = createRunSummaryTracker({
635
- file: sourcePath,
636
- profileId,
637
- runId: null,
638
- });
639
- const appendRunJsonl = (payload) => {
640
- if (!jsonlPath) return;
641
- if (jsonlWriteError) return;
642
- try {
643
- appendJsonLine(jsonlPath, payload);
644
- } catch (err) {
645
- jsonlWriteError = err;
646
- console.error(JSON.stringify({
647
- event: 'autoscript:jsonl_error',
648
- file: jsonlPath,
649
- message: err?.message || String(err),
650
- }));
651
- }
652
- };
653
-
654
- const runner = new AutoscriptRunner({
655
- ...script,
656
- profileId,
657
- }, {
658
- profileId,
659
- ...runnerOptions,
660
- log: (payload) => {
661
- console.log(JSON.stringify(payload));
662
- appendRunJsonl(payload);
663
- applyRunSummaryEvent(summaryTracker, payload);
664
- safeAppendProgressEvent({
665
- source: 'autoscript.runtime',
666
- mode: 'autoscript',
667
- profileId: payload.profileId || profileId,
668
- runId: payload.runId || null,
669
- event: payload.event || 'autoscript.log',
670
- payload,
671
- });
672
- },
673
- });
674
-
675
- const running = await runner.start();
676
- summaryTracker.runId = running.runId;
677
- safeAppendProgressEvent({
678
- source: 'autoscript.command',
679
- mode: 'autoscript',
680
- profileId,
681
- runId: running.runId,
682
- event: `${commandName}.start`,
683
- payload: {
684
- file: sourcePath,
685
- profileId,
686
- runId: running.runId,
687
- ...extraStartPayload,
688
- },
689
- });
690
- console.log(JSON.stringify({
691
- ok: true,
692
- command: commandName,
693
- file: sourcePath,
694
- profileId,
695
- runId: running.runId,
696
- message: 'Autoscript runtime started. Press Ctrl+C to stop.',
697
- ...extraStartPayload,
698
- }, null, 2));
699
- appendRunJsonl({
700
- runId: running.runId,
701
- profileId,
702
- event: `${commandName}:run_start`,
703
- ts: new Date().toISOString(),
704
- file: sourcePath,
705
- jsonlPath,
706
- ...extraStartPayload,
707
- });
708
-
709
- const onSigint = () => {
710
- running.stop('signal_interrupt');
711
- };
712
- process.once('SIGINT', onSigint);
713
-
714
- const done = await running.done.finally(() => {
715
- process.removeListener('SIGINT', onSigint);
716
- });
717
- summaryTracker.stopTs = new Date().toISOString();
718
- const summaryPayload = buildRunSummary(summaryTracker, done?.reason || null);
719
- if (summaryPath) {
720
- fs.mkdirSync(path.dirname(summaryPath), { recursive: true });
721
- fs.writeFileSync(summaryPath, `${JSON.stringify(summaryPayload, null, 2)}\n`, 'utf8');
722
- }
723
- console.log(JSON.stringify({
724
- event: 'autoscript:run_summary',
725
- runId: running.runId,
726
- profileId,
727
- summaryPath,
728
- summary: summaryPayload,
729
- }, null, 2));
730
- safeAppendProgressEvent({
731
- source: 'autoscript.command',
732
- mode: 'autoscript',
733
- profileId,
734
- runId: running.runId,
735
- event: `${commandName}.stop`,
736
- payload: {
737
- file: sourcePath,
738
- profileId,
739
- runId: running.runId,
740
- reason: done?.reason || null,
741
- ...extraStartPayload,
742
- },
743
- });
744
- appendRunJsonl({
745
- runId: running.runId,
746
- profileId,
747
- event: `${commandName}:run_stop`,
748
- ts: new Date().toISOString(),
749
- file: sourcePath,
750
- reason: done?.reason || null,
751
- ...extraStartPayload,
752
- });
753
- appendRunJsonl({
754
- runId: running.runId,
755
- profileId,
756
- event: `${commandName}:run_summary`,
757
- ts: new Date().toISOString(),
758
- summaryPath,
759
- summary: summaryPayload,
760
- ...extraStartPayload,
761
- });
762
-
763
- return {
764
- done,
765
- summaryPath,
766
- summary: summaryPayload,
767
- };
768
- }
769
-
770
- async function handleRun(args) {
771
- const filePath = collectPositionals(args)[0];
772
- if (!filePath) {
773
- throw new Error('Usage: camo autoscript run <file> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
774
- }
775
- const profileOverride = readFlagValue(args, ['--profile', '-p']);
776
- const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
777
- const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
778
- const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
779
- const summaryPath = summaryPathRaw
780
- ? path.resolve(summaryPathRaw)
781
- : buildDefaultSummaryPath(jsonlPath);
782
- const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
783
- if (!validation.ok) {
784
- console.log(JSON.stringify({
785
- ok: false,
786
- file: sourcePath,
787
- errors: validation.errors,
788
- warnings: validation.warnings,
789
- }, null, 2));
790
- process.exitCode = 1;
791
- return;
792
- }
793
-
794
- const profileId = profileOverride || script.profileId || getDefaultProfile();
795
- if (!profileId) {
796
- throw new Error('profileId is required. Set in script or pass --profile <id>');
797
- }
798
- assertExistingProfile(profileId);
799
-
800
- await executeAutoscriptRuntime({
801
- commandName: 'autoscript.run',
802
- script,
803
- sourcePath,
804
- profileId,
805
- jsonlPath,
806
- summaryPath,
807
- });
808
- }
809
-
810
- function readJsonFile(filePath) {
811
- const resolved = path.resolve(filePath);
812
- if (!fs.existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
813
- try {
814
- return {
815
- resolvedPath: resolved,
816
- payload: JSON.parse(fs.readFileSync(resolved, 'utf8')),
817
- };
818
- } catch (err) {
819
- throw new Error(`Invalid JSON file: ${resolved} (${err?.message || String(err)})`);
820
- }
821
- }
822
-
823
- async function handleSnapshot(args) {
824
- const valueFlags = new Set(['--out', '-o']);
825
- const filePath = collectPositionals(args, 2, valueFlags)[0];
826
- if (!filePath) {
827
- throw new Error('Usage: camo autoscript snapshot <jsonl-file> [--out <snapshot-file>]');
828
- }
829
- const outRaw = readFlagValue(args, ['--out', '-o']);
830
- const { resolvedPath, rows } = readJsonlEvents(filePath);
831
- const snapshot = buildSnapshotFromEvents({ events: rows, file: resolvedPath });
832
- const outputPath = outRaw
833
- ? path.resolve(outRaw)
834
- : (resolvedPath.endsWith('.jsonl')
835
- ? `${resolvedPath.slice(0, -'.jsonl'.length)}.snapshot.json`
836
- : `${resolvedPath}.snapshot.json`);
837
- fs.mkdirSync(path.dirname(outputPath), { recursive: true });
838
- fs.writeFileSync(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
839
- console.log(JSON.stringify({
840
- ok: true,
841
- command: 'autoscript.snapshot',
842
- input: resolvedPath,
843
- output: outputPath,
844
- runId: snapshot.runId || null,
845
- profileId: snapshot.profileId || null,
846
- summary: snapshot.summary,
847
- }, null, 2));
848
- }
849
-
850
- async function handleReplay(args) {
851
- const valueFlags = new Set(['--summary-file', '--summary']);
852
- const filePath = collectPositionals(args, 2, valueFlags)[0];
853
- if (!filePath) {
854
- throw new Error('Usage: camo autoscript replay <jsonl-file> [--summary-file <path>]');
855
- }
856
- const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
857
- const { resolvedPath, rows } = readJsonlEvents(filePath);
858
- const snapshot = buildSnapshotFromEvents({ events: rows, file: resolvedPath, reason: 'log_replay' });
859
- const summaryPath = summaryPathRaw
860
- ? path.resolve(summaryPathRaw)
861
- : buildDefaultSummaryPath(resolvedPath);
862
- if (summaryPath) {
863
- fs.mkdirSync(path.dirname(summaryPath), { recursive: true });
864
- fs.writeFileSync(summaryPath, `${JSON.stringify(snapshot.summary, null, 2)}\n`, 'utf8');
865
- }
866
- console.log(JSON.stringify({
867
- ok: true,
868
- command: 'autoscript.replay',
869
- input: resolvedPath,
870
- summaryPath,
871
- summary: snapshot.summary,
872
- }, null, 2));
873
- }
874
-
875
- async function handleResume(args) {
876
- const valueFlags = new Set(['--profile', '-p', '--snapshot', '--from-node', '--jsonl-file', '--jsonl', '--summary-file', '--summary']);
877
- const filePath = collectPositionals(args, 2, valueFlags)[0];
878
- if (!filePath) {
879
- throw new Error('Usage: camo autoscript resume <file> --snapshot <snapshot-file> [--from-node <nodeId>] [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
880
- }
881
- const snapshotPathRaw = readFlagValue(args, ['--snapshot']);
882
- if (!snapshotPathRaw) {
883
- throw new Error('autoscript resume requires --snapshot <snapshot-file>');
884
- }
885
- const fromNode = readFlagValue(args, ['--from-node']);
886
- const profileOverride = readFlagValue(args, ['--profile', '-p']);
887
- const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
888
- const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
889
- const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
890
- const summaryPath = summaryPathRaw ? path.resolve(summaryPathRaw) : buildDefaultSummaryPath(jsonlPath);
891
-
892
- const { payload: snapshot, resolvedPath: snapshotPath } = readJsonFile(snapshotPathRaw);
893
- const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
894
- if (!validation.ok) {
895
- console.log(JSON.stringify({
896
- ok: false,
897
- file: sourcePath,
898
- errors: validation.errors,
899
- warnings: validation.warnings,
900
- }, null, 2));
901
- process.exitCode = 1;
902
- return;
903
- }
904
- if (fromNode && !script.operations.some((op) => op.id === fromNode)) {
905
- throw new Error(`Unknown --from-node operation id: ${fromNode}`);
906
- }
907
-
908
- const profileId = profileOverride || snapshot?.profileId || script.profileId || getDefaultProfile();
909
- if (!profileId) {
910
- throw new Error('profileId is required. Set in script or pass --profile <id>');
911
- }
912
- assertExistingProfile(profileId);
913
- const resumeState = buildResumeStateFromSnapshot(script, snapshot, fromNode || null);
914
- await executeAutoscriptRuntime({
915
- commandName: 'autoscript.resume',
916
- script,
917
- sourcePath,
918
- profileId,
919
- jsonlPath,
920
- summaryPath,
921
- runnerOptions: {
922
- initialState: resumeState.initialState,
923
- forceRunOperationIds: resumeState.forceRunOperationIds,
924
- },
925
- extraStartPayload: {
926
- snapshotPath,
927
- fromNode: fromNode || null,
928
- },
929
- });
930
- }
931
-
932
- async function handleMockRun(args) {
933
- const valueFlags = new Set(['--profile', '-p', '--fixture', '--jsonl-file', '--jsonl', '--summary-file', '--summary']);
934
- const filePath = collectPositionals(args, 2, valueFlags)[0];
935
- if (!filePath) {
936
- throw new Error('Usage: camo autoscript mock-run <file> --fixture <fixture.json> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
937
- }
938
- const fixturePathRaw = readFlagValue(args, ['--fixture']);
939
- if (!fixturePathRaw) {
940
- throw new Error('autoscript mock-run requires --fixture <fixture.json>');
941
- }
942
- const profileOverride = readFlagValue(args, ['--profile', '-p']);
943
- const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
944
- const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
945
- const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
946
- const summaryPath = summaryPathRaw ? path.resolve(summaryPathRaw) : buildDefaultSummaryPath(jsonlPath);
947
-
948
- const { payload: fixture, resolvedPath: fixturePath } = readJsonFile(fixturePathRaw);
949
- const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
950
- if (!validation.ok) {
951
- console.log(JSON.stringify({
952
- ok: false,
953
- file: sourcePath,
954
- errors: validation.errors,
955
- warnings: validation.warnings,
956
- }, null, 2));
957
- process.exitCode = 1;
958
- return;
959
- }
960
- const profileId = profileOverride || fixture?.profileId || script.profileId || 'mock-profile';
961
- await executeAutoscriptRuntime({
962
- commandName: 'autoscript.mock_run',
963
- script,
964
- sourcePath,
965
- profileId,
966
- jsonlPath,
967
- summaryPath,
968
- runnerOptions: {
969
- skipValidation: fixture?.skipValidation !== false,
970
- mockEvents: Array.isArray(fixture?.events) ? fixture.events : [],
971
- mockEventBaseDelayMs: Math.max(0, Number(fixture?.mockEventBaseDelayMs ?? 0) || 0),
972
- stopWhenMockEventsExhausted: fixture?.stopWhenMockEventsExhausted !== false,
973
- executeMockOperation: createMockOperationExecutor(fixture),
974
- },
975
- extraStartPayload: {
976
- fixturePath,
977
- },
978
- });
979
- }
980
-
981
- export async function handleAutoscriptCommand(args) {
982
- const sub = args[1];
983
- switch (sub) {
984
- case 'validate':
985
- return handleValidate(args);
986
- case 'explain':
987
- return handleExplain(args);
988
- case 'snapshot':
989
- return handleSnapshot(args);
990
- case 'replay':
991
- return handleReplay(args);
992
- case 'run':
993
- return handleRun(args);
994
- case 'resume':
995
- return handleResume(args);
996
- case 'mock-run':
997
- return handleMockRun(args);
998
- default:
999
- console.log(`Usage: camo autoscript <validate|explain|snapshot|replay|run|resume|mock-run> [args]
1000
-
1001
- Commands:
1002
- validate <file> Validate autoscript schema and references
1003
- explain <file> Print normalized graph and defaults
1004
- snapshot <jsonl-file> [--out <snapshot-file>] Build resumable snapshot from run JSONL
1005
- replay <jsonl-file> [--summary-file <path>] Rebuild summary from run JSONL
1006
- run <file> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Run autoscript runtime
1007
- resume <file> --snapshot <snapshot-file> [--from-node <nodeId>] [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Resume from snapshot
1008
- mock-run <file> --fixture <fixture.json> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Run in mock replay mode
1009
- `);
1010
- }
1011
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getDefaultProfile, listProfiles } from '../utils/config.mjs';
4
+ import { explainAutoscript, loadAndValidateAutoscript } from '../autoscript/schema.mjs';
5
+ import { AutoscriptRunner } from '../autoscript/runtime.mjs';
6
+ import { safeAppendProgressEvent } from '../events/progress-log.mjs';
7
+
8
+ function readFlagValue(args, names) {
9
+ for (let i = 0; i < args.length; i += 1) {
10
+ if (!names.includes(args[i])) continue;
11
+ const value = args[i + 1];
12
+ if (!value || String(value).startsWith('-')) return null;
13
+ return value;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ function collectPositionals(args, startIndex = 2, valueFlags = new Set(['--profile', '-p'])) {
19
+ const out = [];
20
+ for (let i = startIndex; i < args.length; i += 1) {
21
+ const arg = args[i];
22
+ if (!arg) continue;
23
+ if (valueFlags.has(arg)) {
24
+ i += 1;
25
+ continue;
26
+ }
27
+ if (String(arg).startsWith('-')) continue;
28
+ out.push(arg);
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function assertExistingProfile(profileId) {
34
+ const id = String(profileId || '').trim();
35
+ if (!id) {
36
+ throw new Error('profileId is required');
37
+ }
38
+ const known = new Set(listProfiles());
39
+ if (!known.has(id)) {
40
+ throw new Error(`profile not found: ${id}. create it first with "camo profile create ${id}"`);
41
+ }
42
+ return id;
43
+ }
44
+
45
+ function appendJsonLine(filePath, payload) {
46
+ if (!filePath) return;
47
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
48
+ fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, 'utf8');
49
+ }
50
+
51
+ function normalizeResultPayload(result) {
52
+ if (!result || typeof result !== 'object') return null;
53
+ if (result.result && typeof result.result === 'object' && !Array.isArray(result.result)) {
54
+ return result.result;
55
+ }
56
+ return result;
57
+ }
58
+
59
+ function bumpCounter(target, key) {
60
+ if (!target[key]) {
61
+ target[key] = 1;
62
+ return;
63
+ }
64
+ target[key] += 1;
65
+ }
66
+
67
+ function createRunSummaryTracker({ file, profileId, runId }) {
68
+ return {
69
+ file,
70
+ profileId,
71
+ runId,
72
+ runName: null,
73
+ startTs: null,
74
+ stopTs: null,
75
+ stopReason: null,
76
+ counts: {
77
+ operationStart: 0,
78
+ operationDone: 0,
79
+ operationError: 0,
80
+ operationSkipped: 0,
81
+ operationTerminal: 0,
82
+ watchError: 0,
83
+ },
84
+ target: {
85
+ visitedMax: 0,
86
+ notesClosed: 0,
87
+ harvestCount: 0,
88
+ likeOps: 0,
89
+ },
90
+ comments: {
91
+ total: 0,
92
+ expectedTotal: 0,
93
+ bottomReached: 0,
94
+ recoveriesTotal: 0,
95
+ coverageSum: 0,
96
+ coverageCount: 0,
97
+ exitReasonCounts: {},
98
+ commentsPathsCount: 0,
99
+ },
100
+ likes: {
101
+ scanned: 0,
102
+ hit: 0,
103
+ liked: 0,
104
+ dedupSkipped: 0,
105
+ alreadyLikedSkipped: 0,
106
+ verifyFailed: 0,
107
+ clickFailed: 0,
108
+ missingLikeControl: 0,
109
+ summaryCount: 0,
110
+ },
111
+ closeDetail: {
112
+ rollbackTotal: 0,
113
+ returnToSearchTotal: 0,
114
+ searchCountMax: 0,
115
+ pageExitReasonCounts: {},
116
+ },
117
+ terminal: {
118
+ operationId: null,
119
+ code: null,
120
+ ts: null,
121
+ },
122
+ };
123
+ }
124
+
125
+ function applyRunSummaryEvent(tracker, payload) {
126
+ if (!payload || typeof payload !== 'object') return;
127
+ const { event, ts } = payload;
128
+ if (event === 'autoscript:start') {
129
+ tracker.startTs = tracker.startTs || ts || null;
130
+ tracker.runId = tracker.runId || payload.runId || null;
131
+ tracker.profileId = tracker.profileId || payload.profileId || null;
132
+ tracker.runName = payload.name || tracker.runName;
133
+ return;
134
+ }
135
+ if (event === 'autoscript:stop') {
136
+ tracker.stopReason = payload.reason || tracker.stopReason;
137
+ return;
138
+ }
139
+ if (event === 'autoscript:watch_error') {
140
+ tracker.counts.watchError += 1;
141
+ return;
142
+ }
143
+ if (event === 'autoscript:operation_start') {
144
+ tracker.counts.operationStart += 1;
145
+ return;
146
+ }
147
+ if (event === 'autoscript:operation_error') {
148
+ tracker.counts.operationError += 1;
149
+ return;
150
+ }
151
+ if (event === 'autoscript:operation_skipped') {
152
+ tracker.counts.operationSkipped += 1;
153
+ return;
154
+ }
155
+ if (event === 'autoscript:operation_terminal') {
156
+ tracker.counts.operationTerminal += 1;
157
+ tracker.terminal.operationId = payload.operationId || tracker.terminal.operationId;
158
+ tracker.terminal.code = payload.code || tracker.terminal.code;
159
+ tracker.terminal.ts = payload.ts || tracker.terminal.ts;
160
+ return;
161
+ }
162
+ if (event !== 'autoscript:operation_done') return;
163
+
164
+ tracker.counts.operationDone += 1;
165
+ const result = normalizeResultPayload(payload.result);
166
+ const opId = payload.operationId;
167
+ if (!opId || !result || typeof result !== 'object') return;
168
+
169
+ if (opId === 'open_first_detail' || opId === 'open_next_detail') {
170
+ const visited = Number(result.visited);
171
+ if (Number.isFinite(visited)) {
172
+ tracker.target.visitedMax = Math.max(tracker.target.visitedMax, visited);
173
+ }
174
+ return;
175
+ }
176
+
177
+ if (opId === 'comments_harvest') {
178
+ tracker.target.harvestCount += 1;
179
+ tracker.comments.total += Number(result.collected || 0);
180
+ tracker.comments.expectedTotal += Number(result.expectedCommentsCount || 0);
181
+ tracker.comments.recoveriesTotal += Number(result.recoveries || 0);
182
+ if (result.reachedBottom === true) tracker.comments.bottomReached += 1;
183
+ if (result.commentsPath) tracker.comments.commentsPathsCount += 1;
184
+ const coverage = Number(result.commentCoverageRate);
185
+ if (Number.isFinite(coverage)) {
186
+ tracker.comments.coverageCount += 1;
187
+ tracker.comments.coverageSum += coverage;
188
+ }
189
+ bumpCounter(tracker.comments.exitReasonCounts, result.exitReason || 'unknown');
190
+ return;
191
+ }
192
+
193
+ if (opId === 'comment_like') {
194
+ tracker.target.likeOps += 1;
195
+ tracker.likes.scanned += Number(result.scannedCount || 0);
196
+ tracker.likes.hit += Number(result.hitCount || 0);
197
+ tracker.likes.liked += Number(result.likedCount || 0);
198
+ tracker.likes.dedupSkipped += Number(result.dedupSkipped || 0);
199
+ tracker.likes.alreadyLikedSkipped += Number(result.alreadyLikedSkipped || 0);
200
+ tracker.likes.verifyFailed += Number(result.verifyFailed || 0);
201
+ tracker.likes.clickFailed += Number(result.clickFailed || 0);
202
+ tracker.likes.missingLikeControl += Number(result.missingLikeControl || 0);
203
+ if (result.summaryPath) tracker.likes.summaryCount += 1;
204
+ return;
205
+ }
206
+
207
+ if (opId === 'close_detail') {
208
+ tracker.target.notesClosed += 1;
209
+ tracker.closeDetail.rollbackTotal += Number(result.rollbackCount || 0);
210
+ tracker.closeDetail.returnToSearchTotal += Number(result.returnToSearchCount || 0);
211
+ tracker.closeDetail.searchCountMax = Math.max(
212
+ tracker.closeDetail.searchCountMax,
213
+ Number(result.searchCount || 0),
214
+ );
215
+ bumpCounter(tracker.closeDetail.pageExitReasonCounts, result.pageExitReason || 'unknown');
216
+ }
217
+ }
218
+
219
+ function buildRunSummary(tracker, fallbackStopReason = null) {
220
+ const harvestCount = tracker.target.harvestCount;
221
+ const coverageCount = tracker.comments.coverageCount;
222
+ const durationSec = tracker.startTs && tracker.stopTs
223
+ ? (new Date(tracker.stopTs).getTime() - new Date(tracker.startTs).getTime()) / 1000
224
+ : null;
225
+ return {
226
+ runId: tracker.runId || null,
227
+ profileId: tracker.profileId || null,
228
+ file: tracker.file || null,
229
+ runName: tracker.runName || null,
230
+ startTs: tracker.startTs || null,
231
+ stopTs: tracker.stopTs || null,
232
+ durationSec,
233
+ stopReason: tracker.stopReason || fallbackStopReason || null,
234
+ counts: tracker.counts,
235
+ target: tracker.target,
236
+ comments: {
237
+ total: tracker.comments.total,
238
+ expectedTotal: tracker.comments.expectedTotal,
239
+ avgPerNote: harvestCount > 0 ? tracker.comments.total / harvestCount : null,
240
+ bottomReached: tracker.comments.bottomReached,
241
+ bottomRate: harvestCount > 0 ? tracker.comments.bottomReached / harvestCount : null,
242
+ recoveriesTotal: tracker.comments.recoveriesTotal,
243
+ exitReasonCounts: tracker.comments.exitReasonCounts,
244
+ coverageAvg: coverageCount > 0 ? tracker.comments.coverageSum / coverageCount : null,
245
+ commentsPathsCount: tracker.comments.commentsPathsCount,
246
+ },
247
+ likes: tracker.likes,
248
+ closeDetail: tracker.closeDetail,
249
+ terminal: tracker.terminal.code
250
+ ? tracker.terminal
251
+ : null,
252
+ };
253
+ }
254
+
255
+ function buildDefaultSummaryPath(jsonlPath) {
256
+ if (!jsonlPath) return null;
257
+ return jsonlPath.endsWith('.jsonl')
258
+ ? `${jsonlPath.slice(0, -'.jsonl'.length)}.summary.json`
259
+ : `${jsonlPath}.summary.json`;
260
+ }
261
+
262
+ function readJsonlEvents(filePath) {
263
+ const resolved = path.resolve(filePath);
264
+ if (!fs.existsSync(resolved)) {
265
+ throw new Error(`JSONL file not found: ${resolved}`);
266
+ }
267
+ const raw = fs.readFileSync(resolved, 'utf8');
268
+ const rows = raw
269
+ .split('\n')
270
+ .map((line) => line.trim())
271
+ .filter(Boolean)
272
+ .map((line, index) => {
273
+ try {
274
+ return JSON.parse(line);
275
+ } catch (err) {
276
+ throw new Error(`Invalid JSONL at line ${index + 1}: ${err?.message || String(err)}`);
277
+ }
278
+ });
279
+ return { resolvedPath: resolved, rows };
280
+ }
281
+
282
+ function cloneObject(value, fallback = {}) {
283
+ if (!value || typeof value !== 'object') return { ...fallback };
284
+ return { ...fallback, ...value };
285
+ }
286
+
287
+ function reduceSubscriptionState(subscriptionState, event) {
288
+ if (!event?.subscriptionId) return;
289
+ const prev = subscriptionState[event.subscriptionId] || {
290
+ exists: false,
291
+ appearCount: 0,
292
+ version: 0,
293
+ lastEventAt: null,
294
+ };
295
+ const next = { ...prev, lastEventAt: event.ts || null };
296
+ if (event.type === 'appear') {
297
+ next.exists = true;
298
+ next.appearCount = Number(prev.appearCount || 0) + 1;
299
+ next.version = Number(prev.version || 0) + 1;
300
+ } else if (event.type === 'exist') {
301
+ next.exists = true;
302
+ } else if (event.type === 'disappear') {
303
+ next.exists = false;
304
+ next.version = Number(prev.version || 0) + 1;
305
+ } else if (event.type === 'change') {
306
+ next.exists = Number(event.count || 0) > 0 || prev.exists === true;
307
+ next.version = Number(prev.version || 0) + 1;
308
+ }
309
+ subscriptionState[event.subscriptionId] = next;
310
+ }
311
+
312
+ function buildSnapshotFromEvents({
313
+ events,
314
+ file,
315
+ reason = 'log_snapshot',
316
+ }) {
317
+ const first = events[0] || null;
318
+ const last = events[events.length - 1] || null;
319
+ const tracker = createRunSummaryTracker({
320
+ file,
321
+ profileId: first?.profileId || null,
322
+ runId: first?.runId || null,
323
+ });
324
+
325
+ const operationState = {};
326
+ const subscriptionState = {};
327
+ let state = { active: false, reason: null, startedAt: null, stoppedAt: null };
328
+ let lastEvent = null;
329
+
330
+ for (const event of events) {
331
+ applyRunSummaryEvent(tracker, event);
332
+
333
+ if (event.event === 'autoscript:start') {
334
+ state = {
335
+ active: true,
336
+ reason: null,
337
+ startedAt: event.ts || null,
338
+ stoppedAt: null,
339
+ };
340
+ }
341
+
342
+ if (event.event === 'autoscript:event') {
343
+ reduceSubscriptionState(subscriptionState, event);
344
+ lastEvent = {
345
+ type: event.type || 'tick',
346
+ subscriptionId: event.subscriptionId || null,
347
+ selector: event.selector || null,
348
+ count: event.count ?? null,
349
+ timestamp: event.ts || null,
350
+ };
351
+ }
352
+
353
+ if (event.event === 'autoscript:operation_start') {
354
+ const prev = cloneObject(operationState[event.operationId], {
355
+ status: 'pending',
356
+ runs: 0,
357
+ lastError: null,
358
+ updatedAt: null,
359
+ result: null,
360
+ });
361
+ operationState[event.operationId] = {
362
+ ...prev,
363
+ status: 'running',
364
+ updatedAt: event.ts || prev.updatedAt,
365
+ };
366
+ } else if (event.event === 'autoscript:operation_done') {
367
+ const prev = cloneObject(operationState[event.operationId], {
368
+ status: 'pending',
369
+ runs: 0,
370
+ lastError: null,
371
+ updatedAt: null,
372
+ result: null,
373
+ });
374
+ operationState[event.operationId] = {
375
+ ...prev,
376
+ status: 'done',
377
+ runs: Number(prev.runs || 0) + 1,
378
+ lastError: null,
379
+ updatedAt: event.ts || prev.updatedAt,
380
+ result: event.result ?? null,
381
+ };
382
+ } else if (event.event === 'autoscript:operation_error') {
383
+ const prev = cloneObject(operationState[event.operationId], {
384
+ status: 'pending',
385
+ runs: 0,
386
+ lastError: null,
387
+ updatedAt: null,
388
+ result: null,
389
+ });
390
+ operationState[event.operationId] = {
391
+ ...prev,
392
+ status: 'failed',
393
+ runs: Number(prev.runs || 0) + 1,
394
+ lastError: event.message || event.code || 'operation failed',
395
+ updatedAt: event.ts || prev.updatedAt,
396
+ result: null,
397
+ };
398
+ } else if (event.event === 'autoscript:operation_skipped') {
399
+ const prev = cloneObject(operationState[event.operationId], {
400
+ status: 'pending',
401
+ runs: 0,
402
+ lastError: null,
403
+ updatedAt: null,
404
+ result: null,
405
+ });
406
+ operationState[event.operationId] = {
407
+ ...prev,
408
+ status: 'skipped',
409
+ runs: Number(prev.runs || 0) + 1,
410
+ lastError: null,
411
+ updatedAt: event.ts || prev.updatedAt,
412
+ result: {
413
+ code: event.code || null,
414
+ reason: event.reason || null,
415
+ },
416
+ };
417
+ } else if (event.event === 'autoscript:operation_terminal') {
418
+ const prev = cloneObject(operationState[event.operationId], {
419
+ status: 'pending',
420
+ runs: 0,
421
+ lastError: null,
422
+ updatedAt: null,
423
+ result: null,
424
+ });
425
+ operationState[event.operationId] = {
426
+ ...prev,
427
+ status: 'done',
428
+ runs: Number(prev.runs || 0) + 1,
429
+ lastError: null,
430
+ updatedAt: event.ts || prev.updatedAt,
431
+ result: {
432
+ terminalDoneCode: event.code || null,
433
+ },
434
+ };
435
+ } else if (event.event === 'autoscript:stop') {
436
+ state = {
437
+ ...state,
438
+ active: false,
439
+ reason: event.reason || state.reason || null,
440
+ stoppedAt: event.ts || state.stoppedAt || null,
441
+ };
442
+ }
443
+ }
444
+
445
+ tracker.stopTs = last?.ts || tracker.stopTs || null;
446
+ const summary = buildRunSummary(tracker, state.reason || null);
447
+ const snapshot = {
448
+ kind: 'autoscript_snapshot',
449
+ version: 1,
450
+ reason,
451
+ createdAt: new Date().toISOString(),
452
+ sourceJsonl: file,
453
+ runId: summary.runId || null,
454
+ profileId: summary.profileId || null,
455
+ scriptName: summary.runName || null,
456
+ summary,
457
+ state: {
458
+ state,
459
+ subscriptionState,
460
+ operationState,
461
+ operationScheduleState: {},
462
+ runtimeContext: { vars: {}, tabPool: null, currentTab: null },
463
+ lastNavigationAt: 0,
464
+ lastEvent,
465
+ },
466
+ };
467
+ return snapshot;
468
+ }
469
+
470
+ function toOperationOrder(script) {
471
+ return Array.isArray(script?.operations)
472
+ ? script.operations.map((op) => String(op?.id || '').trim()).filter(Boolean)
473
+ : [];
474
+ }
475
+
476
+ function buildDescendantSet(script, nodeId) {
477
+ const dependents = new Map();
478
+ for (const op of script.operations || []) {
479
+ for (const dep of op.dependsOn || []) {
480
+ if (!dependents.has(dep)) dependents.set(dep, new Set());
481
+ dependents.get(dep).add(op.id);
482
+ }
483
+ }
484
+ const seen = new Set([nodeId]);
485
+ const queue = [nodeId];
486
+ while (queue.length > 0) {
487
+ const current = queue.shift();
488
+ for (const next of dependents.get(current) || []) {
489
+ if (seen.has(next)) continue;
490
+ seen.add(next);
491
+ queue.push(next);
492
+ }
493
+ }
494
+ return seen;
495
+ }
496
+
497
+ function buildResumeStateFromSnapshot(script, snapshot, fromNodeId = null) {
498
+ const sourceState = snapshot?.state && typeof snapshot.state === 'object'
499
+ ? snapshot.state
500
+ : {};
501
+ const sourceOps = sourceState.operationState && typeof sourceState.operationState === 'object'
502
+ ? sourceState.operationState
503
+ : {};
504
+ const forceRunOperationIds = [];
505
+ const operationState = {};
506
+ const descendants = fromNodeId ? buildDescendantSet(script, fromNodeId) : null;
507
+
508
+ for (const opId of toOperationOrder(script)) {
509
+ const prev = sourceOps[opId] && typeof sourceOps[opId] === 'object'
510
+ ? sourceOps[opId]
511
+ : {
512
+ status: 'pending',
513
+ runs: 0,
514
+ lastError: null,
515
+ updatedAt: null,
516
+ result: null,
517
+ };
518
+ if (!descendants) {
519
+ operationState[opId] = {
520
+ status: String(prev.status || 'pending'),
521
+ runs: Math.max(0, Number(prev.runs || 0) || 0),
522
+ lastError: prev.lastError || null,
523
+ updatedAt: prev.updatedAt || null,
524
+ result: prev.result ?? null,
525
+ };
526
+ continue;
527
+ }
528
+
529
+ if (descendants.has(opId)) {
530
+ operationState[opId] = {
531
+ status: 'pending',
532
+ runs: Math.max(0, Number(prev.runs || 0) || 0),
533
+ lastError: null,
534
+ updatedAt: prev.updatedAt || null,
535
+ result: null,
536
+ };
537
+ forceRunOperationIds.push(opId);
538
+ } else {
539
+ operationState[opId] = {
540
+ status: 'done',
541
+ runs: Math.max(1, Number(prev.runs || 1) || 1),
542
+ lastError: null,
543
+ updatedAt: prev.updatedAt || null,
544
+ result: prev.result ?? { code: 'RESUME_SKIPPED_PREVIOUS_BRANCH' },
545
+ };
546
+ }
547
+ }
548
+
549
+ return {
550
+ initialState: {
551
+ state: {
552
+ state: sourceState.state || {
553
+ active: false,
554
+ reason: null,
555
+ startedAt: null,
556
+ stoppedAt: null,
557
+ },
558
+ subscriptionState: sourceState.subscriptionState || {},
559
+ operationState,
560
+ operationScheduleState: sourceState.operationScheduleState || {},
561
+ runtimeContext: sourceState.runtimeContext || { vars: {}, tabPool: null, currentTab: null },
562
+ lastNavigationAt: Number(sourceState.lastNavigationAt || 0) || 0,
563
+ },
564
+ },
565
+ forceRunOperationIds,
566
+ };
567
+ }
568
+
569
+ function createMockOperationExecutor(fixture) {
570
+ const source = fixture?.operations && typeof fixture.operations === 'object'
571
+ ? fixture.operations
572
+ : {};
573
+ const queues = new Map(
574
+ Object.entries(source).map(([key, value]) => [key, Array.isArray(value) ? [...value] : [value]]),
575
+ );
576
+ const defaultResult = fixture?.defaultResult && typeof fixture.defaultResult === 'object'
577
+ ? fixture.defaultResult
578
+ : { ok: true, code: 'OPERATION_DONE', message: 'mock operation done', data: { mock: true } };
579
+
580
+ return ({ operation }) => {
581
+ const key = String(operation?.id || '').trim();
582
+ const queue = queues.get(key);
583
+ const wildcard = queues.get('*');
584
+ const next = queue && queue.length > 0
585
+ ? queue.shift()
586
+ : (wildcard && wildcard.length > 0 ? wildcard.shift() : defaultResult);
587
+ return next;
588
+ };
589
+ }
590
+
591
+ async function handleValidate(args) {
592
+ const filePath = collectPositionals(args)[0];
593
+ if (!filePath) {
594
+ throw new Error('Usage: camo autoscript validate <file>');
595
+ }
596
+ const { sourcePath, validation } = loadAndValidateAutoscript(filePath);
597
+ console.log(JSON.stringify({
598
+ ok: validation.ok,
599
+ file: sourcePath,
600
+ errors: validation.errors,
601
+ warnings: validation.warnings,
602
+ operationOrder: validation.topologicalOrder,
603
+ }, null, 2));
604
+ if (!validation.ok) {
605
+ process.exitCode = 1;
606
+ }
607
+ }
608
+
609
+ async function handleExplain(args) {
610
+ const filePath = collectPositionals(args)[0];
611
+ if (!filePath) {
612
+ throw new Error('Usage: camo autoscript explain <file>');
613
+ }
614
+ const { script, sourcePath } = loadAndValidateAutoscript(filePath);
615
+ const explained = explainAutoscript(script);
616
+ console.log(JSON.stringify({
617
+ ok: explained.ok,
618
+ file: sourcePath,
619
+ ...explained,
620
+ }, null, 2));
621
+ }
622
+
623
+ async function executeAutoscriptRuntime({
624
+ commandName,
625
+ script,
626
+ sourcePath,
627
+ profileId,
628
+ jsonlPath,
629
+ summaryPath,
630
+ runnerOptions = {},
631
+ extraStartPayload = {},
632
+ }) {
633
+ let jsonlWriteError = null;
634
+ const summaryTracker = createRunSummaryTracker({
635
+ file: sourcePath,
636
+ profileId,
637
+ runId: null,
638
+ });
639
+ const appendRunJsonl = (payload) => {
640
+ if (!jsonlPath) return;
641
+ if (jsonlWriteError) return;
642
+ try {
643
+ appendJsonLine(jsonlPath, payload);
644
+ } catch (err) {
645
+ jsonlWriteError = err;
646
+ console.error(JSON.stringify({
647
+ event: 'autoscript:jsonl_error',
648
+ file: jsonlPath,
649
+ message: err?.message || String(err),
650
+ }));
651
+ }
652
+ };
653
+
654
+ const runner = new AutoscriptRunner({
655
+ ...script,
656
+ profileId,
657
+ }, {
658
+ profileId,
659
+ ...runnerOptions,
660
+ log: (payload) => {
661
+ console.log(JSON.stringify(payload));
662
+ appendRunJsonl(payload);
663
+ applyRunSummaryEvent(summaryTracker, payload);
664
+ safeAppendProgressEvent({
665
+ source: 'autoscript.runtime',
666
+ mode: 'autoscript',
667
+ profileId: payload.profileId || profileId,
668
+ runId: payload.runId || null,
669
+ event: payload.event || 'autoscript.log',
670
+ payload,
671
+ });
672
+ },
673
+ });
674
+
675
+ const running = await runner.start();
676
+ summaryTracker.runId = running.runId;
677
+ safeAppendProgressEvent({
678
+ source: 'autoscript.command',
679
+ mode: 'autoscript',
680
+ profileId,
681
+ runId: running.runId,
682
+ event: `${commandName}.start`,
683
+ payload: {
684
+ file: sourcePath,
685
+ profileId,
686
+ runId: running.runId,
687
+ ...extraStartPayload,
688
+ },
689
+ });
690
+ console.log(JSON.stringify({
691
+ ok: true,
692
+ command: commandName,
693
+ file: sourcePath,
694
+ profileId,
695
+ runId: running.runId,
696
+ message: 'Autoscript runtime started. Press Ctrl+C to stop.',
697
+ ...extraStartPayload,
698
+ }, null, 2));
699
+ appendRunJsonl({
700
+ runId: running.runId,
701
+ profileId,
702
+ event: `${commandName}:run_start`,
703
+ ts: new Date().toISOString(),
704
+ file: sourcePath,
705
+ jsonlPath,
706
+ ...extraStartPayload,
707
+ });
708
+
709
+ const onSigint = () => {
710
+ running.stop('signal_interrupt');
711
+ };
712
+ process.once('SIGINT', onSigint);
713
+
714
+ const done = await running.done.finally(() => {
715
+ process.removeListener('SIGINT', onSigint);
716
+ });
717
+ summaryTracker.stopTs = new Date().toISOString();
718
+ const summaryPayload = buildRunSummary(summaryTracker, done?.reason || null);
719
+ if (summaryPath) {
720
+ fs.mkdirSync(path.dirname(summaryPath), { recursive: true });
721
+ fs.writeFileSync(summaryPath, `${JSON.stringify(summaryPayload, null, 2)}\n`, 'utf8');
722
+ }
723
+ console.log(JSON.stringify({
724
+ event: 'autoscript:run_summary',
725
+ runId: running.runId,
726
+ profileId,
727
+ summaryPath,
728
+ summary: summaryPayload,
729
+ }, null, 2));
730
+ safeAppendProgressEvent({
731
+ source: 'autoscript.command',
732
+ mode: 'autoscript',
733
+ profileId,
734
+ runId: running.runId,
735
+ event: `${commandName}.stop`,
736
+ payload: {
737
+ file: sourcePath,
738
+ profileId,
739
+ runId: running.runId,
740
+ reason: done?.reason || null,
741
+ ...extraStartPayload,
742
+ },
743
+ });
744
+ appendRunJsonl({
745
+ runId: running.runId,
746
+ profileId,
747
+ event: `${commandName}:run_stop`,
748
+ ts: new Date().toISOString(),
749
+ file: sourcePath,
750
+ reason: done?.reason || null,
751
+ ...extraStartPayload,
752
+ });
753
+ appendRunJsonl({
754
+ runId: running.runId,
755
+ profileId,
756
+ event: `${commandName}:run_summary`,
757
+ ts: new Date().toISOString(),
758
+ summaryPath,
759
+ summary: summaryPayload,
760
+ ...extraStartPayload,
761
+ });
762
+
763
+ return {
764
+ done,
765
+ summaryPath,
766
+ summary: summaryPayload,
767
+ };
768
+ }
769
+
770
+ async function handleRun(args) {
771
+ const filePath = collectPositionals(args)[0];
772
+ if (!filePath) {
773
+ throw new Error('Usage: camo autoscript run <file> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
774
+ }
775
+ const profileOverride = readFlagValue(args, ['--profile', '-p']);
776
+ const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
777
+ const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
778
+ const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
779
+ const summaryPath = summaryPathRaw
780
+ ? path.resolve(summaryPathRaw)
781
+ : buildDefaultSummaryPath(jsonlPath);
782
+ const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
783
+ if (!validation.ok) {
784
+ console.log(JSON.stringify({
785
+ ok: false,
786
+ file: sourcePath,
787
+ errors: validation.errors,
788
+ warnings: validation.warnings,
789
+ }, null, 2));
790
+ process.exitCode = 1;
791
+ return;
792
+ }
793
+
794
+ const profileId = profileOverride || script.profileId || getDefaultProfile();
795
+ if (!profileId) {
796
+ throw new Error('profileId is required. Set in script or pass --profile <id>');
797
+ }
798
+ assertExistingProfile(profileId);
799
+
800
+ await executeAutoscriptRuntime({
801
+ commandName: 'autoscript.run',
802
+ script,
803
+ sourcePath,
804
+ profileId,
805
+ jsonlPath,
806
+ summaryPath,
807
+ });
808
+ }
809
+
810
+ function readJsonFile(filePath) {
811
+ const resolved = path.resolve(filePath);
812
+ if (!fs.existsSync(resolved)) throw new Error(`File not found: ${resolved}`);
813
+ try {
814
+ return {
815
+ resolvedPath: resolved,
816
+ payload: JSON.parse(fs.readFileSync(resolved, 'utf8')),
817
+ };
818
+ } catch (err) {
819
+ throw new Error(`Invalid JSON file: ${resolved} (${err?.message || String(err)})`);
820
+ }
821
+ }
822
+
823
+ async function handleSnapshot(args) {
824
+ const valueFlags = new Set(['--out', '-o']);
825
+ const filePath = collectPositionals(args, 2, valueFlags)[0];
826
+ if (!filePath) {
827
+ throw new Error('Usage: camo autoscript snapshot <jsonl-file> [--out <snapshot-file>]');
828
+ }
829
+ const outRaw = readFlagValue(args, ['--out', '-o']);
830
+ const { resolvedPath, rows } = readJsonlEvents(filePath);
831
+ const snapshot = buildSnapshotFromEvents({ events: rows, file: resolvedPath });
832
+ const outputPath = outRaw
833
+ ? path.resolve(outRaw)
834
+ : (resolvedPath.endsWith('.jsonl')
835
+ ? `${resolvedPath.slice(0, -'.jsonl'.length)}.snapshot.json`
836
+ : `${resolvedPath}.snapshot.json`);
837
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
838
+ fs.writeFileSync(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, 'utf8');
839
+ console.log(JSON.stringify({
840
+ ok: true,
841
+ command: 'autoscript.snapshot',
842
+ input: resolvedPath,
843
+ output: outputPath,
844
+ runId: snapshot.runId || null,
845
+ profileId: snapshot.profileId || null,
846
+ summary: snapshot.summary,
847
+ }, null, 2));
848
+ }
849
+
850
+ async function handleReplay(args) {
851
+ const valueFlags = new Set(['--summary-file', '--summary']);
852
+ const filePath = collectPositionals(args, 2, valueFlags)[0];
853
+ if (!filePath) {
854
+ throw new Error('Usage: camo autoscript replay <jsonl-file> [--summary-file <path>]');
855
+ }
856
+ const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
857
+ const { resolvedPath, rows } = readJsonlEvents(filePath);
858
+ const snapshot = buildSnapshotFromEvents({ events: rows, file: resolvedPath, reason: 'log_replay' });
859
+ const summaryPath = summaryPathRaw
860
+ ? path.resolve(summaryPathRaw)
861
+ : buildDefaultSummaryPath(resolvedPath);
862
+ if (summaryPath) {
863
+ fs.mkdirSync(path.dirname(summaryPath), { recursive: true });
864
+ fs.writeFileSync(summaryPath, `${JSON.stringify(snapshot.summary, null, 2)}\n`, 'utf8');
865
+ }
866
+ console.log(JSON.stringify({
867
+ ok: true,
868
+ command: 'autoscript.replay',
869
+ input: resolvedPath,
870
+ summaryPath,
871
+ summary: snapshot.summary,
872
+ }, null, 2));
873
+ }
874
+
875
+ async function handleResume(args) {
876
+ const valueFlags = new Set(['--profile', '-p', '--snapshot', '--from-node', '--jsonl-file', '--jsonl', '--summary-file', '--summary']);
877
+ const filePath = collectPositionals(args, 2, valueFlags)[0];
878
+ if (!filePath) {
879
+ throw new Error('Usage: camo autoscript resume <file> --snapshot <snapshot-file> [--from-node <nodeId>] [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
880
+ }
881
+ const snapshotPathRaw = readFlagValue(args, ['--snapshot']);
882
+ if (!snapshotPathRaw) {
883
+ throw new Error('autoscript resume requires --snapshot <snapshot-file>');
884
+ }
885
+ const fromNode = readFlagValue(args, ['--from-node']);
886
+ const profileOverride = readFlagValue(args, ['--profile', '-p']);
887
+ const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
888
+ const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
889
+ const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
890
+ const summaryPath = summaryPathRaw ? path.resolve(summaryPathRaw) : buildDefaultSummaryPath(jsonlPath);
891
+
892
+ const { payload: snapshot, resolvedPath: snapshotPath } = readJsonFile(snapshotPathRaw);
893
+ const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
894
+ if (!validation.ok) {
895
+ console.log(JSON.stringify({
896
+ ok: false,
897
+ file: sourcePath,
898
+ errors: validation.errors,
899
+ warnings: validation.warnings,
900
+ }, null, 2));
901
+ process.exitCode = 1;
902
+ return;
903
+ }
904
+ if (fromNode && !script.operations.some((op) => op.id === fromNode)) {
905
+ throw new Error(`Unknown --from-node operation id: ${fromNode}`);
906
+ }
907
+
908
+ const profileId = profileOverride || snapshot?.profileId || script.profileId || getDefaultProfile();
909
+ if (!profileId) {
910
+ throw new Error('profileId is required. Set in script or pass --profile <id>');
911
+ }
912
+ assertExistingProfile(profileId);
913
+ const resumeState = buildResumeStateFromSnapshot(script, snapshot, fromNode || null);
914
+ await executeAutoscriptRuntime({
915
+ commandName: 'autoscript.resume',
916
+ script,
917
+ sourcePath,
918
+ profileId,
919
+ jsonlPath,
920
+ summaryPath,
921
+ runnerOptions: {
922
+ initialState: resumeState.initialState,
923
+ forceRunOperationIds: resumeState.forceRunOperationIds,
924
+ },
925
+ extraStartPayload: {
926
+ snapshotPath,
927
+ fromNode: fromNode || null,
928
+ },
929
+ });
930
+ }
931
+
932
+ async function handleMockRun(args) {
933
+ const valueFlags = new Set(['--profile', '-p', '--fixture', '--jsonl-file', '--jsonl', '--summary-file', '--summary']);
934
+ const filePath = collectPositionals(args, 2, valueFlags)[0];
935
+ if (!filePath) {
936
+ throw new Error('Usage: camo autoscript mock-run <file> --fixture <fixture.json> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>]');
937
+ }
938
+ const fixturePathRaw = readFlagValue(args, ['--fixture']);
939
+ if (!fixturePathRaw) {
940
+ throw new Error('autoscript mock-run requires --fixture <fixture.json>');
941
+ }
942
+ const profileOverride = readFlagValue(args, ['--profile', '-p']);
943
+ const jsonlPathRaw = readFlagValue(args, ['--jsonl-file', '--jsonl']);
944
+ const jsonlPath = jsonlPathRaw ? path.resolve(jsonlPathRaw) : null;
945
+ const summaryPathRaw = readFlagValue(args, ['--summary-file', '--summary']);
946
+ const summaryPath = summaryPathRaw ? path.resolve(summaryPathRaw) : buildDefaultSummaryPath(jsonlPath);
947
+
948
+ const { payload: fixture, resolvedPath: fixturePath } = readJsonFile(fixturePathRaw);
949
+ const { script, sourcePath, validation } = loadAndValidateAutoscript(filePath);
950
+ if (!validation.ok) {
951
+ console.log(JSON.stringify({
952
+ ok: false,
953
+ file: sourcePath,
954
+ errors: validation.errors,
955
+ warnings: validation.warnings,
956
+ }, null, 2));
957
+ process.exitCode = 1;
958
+ return;
959
+ }
960
+ const profileId = profileOverride || fixture?.profileId || script.profileId || 'mock-profile';
961
+ await executeAutoscriptRuntime({
962
+ commandName: 'autoscript.mock_run',
963
+ script,
964
+ sourcePath,
965
+ profileId,
966
+ jsonlPath,
967
+ summaryPath,
968
+ runnerOptions: {
969
+ skipValidation: fixture?.skipValidation !== false,
970
+ mockEvents: Array.isArray(fixture?.events) ? fixture.events : [],
971
+ mockEventBaseDelayMs: Math.max(0, Number(fixture?.mockEventBaseDelayMs ?? 0) || 0),
972
+ stopWhenMockEventsExhausted: fixture?.stopWhenMockEventsExhausted !== false,
973
+ executeMockOperation: createMockOperationExecutor(fixture),
974
+ },
975
+ extraStartPayload: {
976
+ fixturePath,
977
+ },
978
+ });
979
+ }
980
+
981
+ export async function handleAutoscriptCommand(args) {
982
+ const sub = args[1];
983
+ switch (sub) {
984
+ case 'validate':
985
+ return handleValidate(args);
986
+ case 'explain':
987
+ return handleExplain(args);
988
+ case 'snapshot':
989
+ return handleSnapshot(args);
990
+ case 'replay':
991
+ return handleReplay(args);
992
+ case 'run':
993
+ return handleRun(args);
994
+ case 'resume':
995
+ return handleResume(args);
996
+ case 'mock-run':
997
+ return handleMockRun(args);
998
+ default:
999
+ console.log(`Usage: camo autoscript <validate|explain|snapshot|replay|run|resume|mock-run> [args]
1000
+
1001
+ Commands:
1002
+ validate <file> Validate autoscript schema and references
1003
+ explain <file> Print normalized graph and defaults
1004
+ snapshot <jsonl-file> [--out <snapshot-file>] Build resumable snapshot from run JSONL
1005
+ replay <jsonl-file> [--summary-file <path>] Rebuild summary from run JSONL
1006
+ run <file> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Run autoscript runtime
1007
+ resume <file> --snapshot <snapshot-file> [--from-node <nodeId>] [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Resume from snapshot
1008
+ mock-run <file> --fixture <fixture.json> [--profile <id>] [--jsonl-file <path>] [--summary-file <path>] Run in mock replay mode
1009
+ `);
1010
+ }
1011
+ }