browser-debugging-daemon 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,803 @@
1
+ import crypto from "crypto";
2
+ import { EventEmitter } from "events";
3
+ import { BrowserRuntime } from "../runtime/BrowserRuntime.js";
4
+ import { BrowserSubagent } from "../subagent/BrowserSubagent.js";
5
+ import { TaskRunStore } from "./TaskRunStore.js";
6
+ import { RunTemplateStore } from "./RunTemplateStore.js";
7
+
8
+ function nowIso() {
9
+ return new Date().toISOString();
10
+ }
11
+
12
+ function formatTimeoutDuration(milliseconds) {
13
+ if (milliseconds < 1000) {
14
+ return `${milliseconds}ms`;
15
+ }
16
+
17
+ const seconds = Math.ceil(milliseconds / 1000);
18
+ return `${seconds} seconds`;
19
+ }
20
+
21
+ const RUN_HISTORY_LIMIT_ENV = Number.parseInt(process.env.BROWSER_RUN_HISTORY_LIMIT, 10);
22
+ const RULE_KINDS = new Set(["url_includes", "title_includes", "text_includes"]);
23
+
24
+ function normalizePositiveInteger(value, fallback = null, minimum = 1) {
25
+ const parsed = Number.parseInt(value, 10);
26
+ if (Number.isFinite(parsed) && parsed >= minimum) {
27
+ return parsed;
28
+ }
29
+ return fallback;
30
+ }
31
+
32
+ function normalizeTemplateRule(input = {}, index = 0, defaultPrefix = "Rule") {
33
+ const kind = typeof input.kind === "string" ? input.kind.trim().toLowerCase() : "";
34
+ const expected = typeof input.expected === "string"
35
+ ? input.expected.trim()
36
+ : typeof input.value === "string"
37
+ ? input.value.trim()
38
+ : "";
39
+ if (!RULE_KINDS.has(kind) || !expected) {
40
+ return null;
41
+ }
42
+ return {
43
+ id: typeof input.id === "string" && input.id.trim()
44
+ ? input.id.trim()
45
+ : `rule-${index + 1}`,
46
+ name: typeof input.name === "string" && input.name.trim()
47
+ ? input.name.trim()
48
+ : `${defaultPrefix} ${index + 1}`,
49
+ kind,
50
+ expected,
51
+ required: input.required !== false,
52
+ };
53
+ }
54
+
55
+ function normalizeTemplateRules(input, defaultPrefix) {
56
+ if (!Array.isArray(input)) {
57
+ return [];
58
+ }
59
+ return input
60
+ .map((rule, index) => normalizeTemplateRule(rule, index, defaultPrefix))
61
+ .filter(Boolean);
62
+ }
63
+
64
+ function normalizeTemplateInput(templateInput = {}, currentTemplate = null) {
65
+ const now = nowIso();
66
+ const existing = currentTemplate || null;
67
+ const timeoutPolicyInput = templateInput.timeoutPolicy || {};
68
+ const existingTimeoutPolicy = existing?.timeoutPolicy || {};
69
+ const timeoutPolicy = {
70
+ maxSteps: normalizePositiveInteger(
71
+ timeoutPolicyInput.maxSteps,
72
+ normalizePositiveInteger(existingTimeoutPolicy.maxSteps, 12),
73
+ 1
74
+ ),
75
+ handoffTimeoutMs: normalizePositiveInteger(
76
+ timeoutPolicyInput.handoffTimeoutMs,
77
+ normalizePositiveInteger(existingTimeoutPolicy.handoffTimeoutMs, 5 * 60 * 1000),
78
+ 1000
79
+ ),
80
+ };
81
+
82
+ const normalized = {
83
+ id: existing?.id || crypto.randomUUID(),
84
+ name: typeof templateInput.name === "string" && templateInput.name.trim()
85
+ ? templateInput.name.trim()
86
+ : existing?.name || "",
87
+ description: typeof templateInput.description === "string"
88
+ ? templateInput.description.trim()
89
+ : existing?.description || "",
90
+ taskInstruction: typeof templateInput.taskInstruction === "string"
91
+ ? templateInput.taskInstruction.trim()
92
+ : existing?.taskInstruction || "",
93
+ browserSource: typeof templateInput.browserSource === "string"
94
+ ? templateInput.browserSource.trim().toLowerCase()
95
+ : existing?.browserSource || "auto",
96
+ cdpEndpoint: typeof templateInput.cdpEndpoint === "string"
97
+ ? templateInput.cdpEndpoint.trim()
98
+ : existing?.cdpEndpoint || null,
99
+ startUrl: typeof templateInput.startUrl === "string"
100
+ ? templateInput.startUrl.trim()
101
+ : existing?.startUrl || "",
102
+ preLoginChecks: normalizeTemplateRules(
103
+ templateInput.preLoginChecks ?? existing?.preLoginChecks ?? [],
104
+ "Login Check"
105
+ ),
106
+ assertionRules: normalizeTemplateRules(
107
+ templateInput.assertionRules ?? existing?.assertionRules ?? [],
108
+ "Assertion"
109
+ ),
110
+ timeoutPolicy,
111
+ createdAt: existing?.createdAt || now,
112
+ updatedAt: now,
113
+ };
114
+
115
+ if (!normalized.name) {
116
+ throw new Error("Template name is required.");
117
+ }
118
+ if (!normalized.taskInstruction && !normalized.startUrl) {
119
+ throw new Error("Template requires taskInstruction or startUrl.");
120
+ }
121
+
122
+ return normalized;
123
+ }
124
+
125
+ function buildTemplatedInstruction(taskInstruction, template) {
126
+ if (!template) {
127
+ return taskInstruction;
128
+ }
129
+
130
+ const parts = [];
131
+ const baseTaskInstruction = taskInstruction || template.taskInstruction || "";
132
+ if (baseTaskInstruction) {
133
+ parts.push(baseTaskInstruction);
134
+ }
135
+ if (template.startUrl) {
136
+ parts.push(`Always start by navigating to this URL: ${template.startUrl}`);
137
+ }
138
+ if (template.preLoginChecks?.length) {
139
+ const checks = template.preLoginChecks
140
+ .map((rule, index) => `${index + 1}. [${rule.kind}] ${rule.expected} (${rule.name})`)
141
+ .join("\n");
142
+ parts.push(
143
+ `Before executing the main task, verify these login checks and ask_main_agent immediately if any required check fails:\n${checks}`
144
+ );
145
+ }
146
+ if (template.assertionRules?.length) {
147
+ const assertions = template.assertionRules
148
+ .map((rule, index) => `${index + 1}. [${rule.kind}] ${rule.expected} (${rule.name})`)
149
+ .join("\n");
150
+ parts.push(`Treat the run as complete only when all assertions pass:\n${assertions}`);
151
+ }
152
+
153
+ return parts.filter(Boolean).join("\n\n");
154
+ }
155
+
156
+ function evaluateRule(rule, page) {
157
+ const safePage = page || {};
158
+ const sources = {
159
+ url_includes: safePage.url || "",
160
+ title_includes: safePage.title || "",
161
+ text_includes: safePage.textPreview || "",
162
+ };
163
+ const source = String(sources[rule.kind] || "");
164
+ const passed = source.toLowerCase().includes(String(rule.expected).toLowerCase());
165
+ return {
166
+ id: rule.id,
167
+ name: rule.name,
168
+ kind: rule.kind,
169
+ expected: rule.expected,
170
+ required: rule.required !== false,
171
+ actual: source.slice(0, 500),
172
+ passed,
173
+ };
174
+ }
175
+
176
+ function evaluateTemplate(template, result) {
177
+ if (!template) {
178
+ return null;
179
+ }
180
+
181
+ const page = result?.page || null;
182
+ const loginChecks = (template.preLoginChecks || []).map((rule) => evaluateRule(rule, page));
183
+ const assertions = (template.assertionRules || []).map((rule) => evaluateRule(rule, page));
184
+ const failures = [...loginChecks, ...assertions].filter((item) => item.required && !item.passed);
185
+
186
+ return {
187
+ templateId: template.id,
188
+ templateName: template.name,
189
+ evaluatedAt: nowIso(),
190
+ page: page ? {
191
+ url: page.url || "",
192
+ title: page.title || "",
193
+ } : null,
194
+ loginChecks,
195
+ assertions,
196
+ passed: failures.length === 0,
197
+ failureMessages: failures.map((item) => `${item.name} (${item.kind}) expected "${item.expected}"`),
198
+ };
199
+ }
200
+
201
+ export class TaskRunner {
202
+ constructor(baseDir, options = {}) {
203
+ this.runtime = options.runtime || new BrowserRuntime(baseDir);
204
+ this.subagent = options.subagent || new BrowserSubagent(this.runtime, options.plannerOptions || {});
205
+ this.store = new TaskRunStore(baseDir);
206
+ this.templateStore = new RunTemplateStore(baseDir);
207
+ this.runs = this.store.load();
208
+ this.templates = this.templateStore.load().filter((template) => typeof template?.id === "string");
209
+ this.processing = false;
210
+ this.events = new EventEmitter();
211
+ this.pendingReplies = new Map();
212
+ this.runControls = new Map();
213
+ this.handoffTimeoutMs = options.handoffTimeoutMs || 5 * 60 * 1000;
214
+ this.runHistoryLimit = Number.isFinite(RUN_HISTORY_LIMIT_ENV) && RUN_HISTORY_LIMIT_ENV > 0
215
+ ? Math.floor(RUN_HISTORY_LIMIT_ENV)
216
+ : 200;
217
+ this.recoverPersistedRuns();
218
+ }
219
+
220
+ trimRunHistory() {
221
+ const terminalStatuses = new Set(["completed", "failed", "aborted"]);
222
+ if (!Number.isFinite(this.runHistoryLimit) || this.runHistoryLimit <= 0 || this.runs.length <= this.runHistoryLimit) {
223
+ return;
224
+ }
225
+
226
+ const activeCount = this.runs.filter((run) => !terminalStatuses.has(run.status)).length;
227
+ const allowedTerminalCount = Math.max(0, this.runHistoryLimit - activeCount);
228
+ let keptTerminalCount = 0;
229
+ const trimmed = [];
230
+
231
+ for (const run of this.runs) {
232
+ if (!terminalStatuses.has(run.status)) {
233
+ trimmed.push(run);
234
+ continue;
235
+ }
236
+
237
+ if (keptTerminalCount < allowedTerminalCount) {
238
+ trimmed.push(run);
239
+ keptTerminalCount += 1;
240
+ }
241
+ }
242
+
243
+ this.runs = trimmed;
244
+ }
245
+
246
+ persistRuns() {
247
+ this.trimRunHistory();
248
+ this.store.persist(this.runs);
249
+ }
250
+
251
+ recoverPersistedRuns() {
252
+ const interruptedStatuses = new Set([
253
+ "running",
254
+ "aborting",
255
+ "waiting_for_instruction",
256
+ "manual_control_requested",
257
+ "manual_control",
258
+ ]);
259
+ let changed = false;
260
+
261
+ this.runs = this.runs.filter((run) => typeof run?.id === "string" && run.id.length > 0);
262
+
263
+ for (const run of this.runs) {
264
+ if (interruptedStatuses.has(run.status)) {
265
+ const interruptionSummary = "Run interrupted because the daemon restarted before completion.";
266
+ run.status = "failed";
267
+ run.summary = interruptionSummary;
268
+ run.pendingInput = null;
269
+ run.finishedAt = run.finishedAt || nowIso();
270
+ run.error = run.error || {
271
+ message: interruptionSummary,
272
+ stack: null,
273
+ };
274
+ if (run.result) {
275
+ run.result.status = "failed";
276
+ run.result.summary = interruptionSummary;
277
+ run.result.pendingInput = null;
278
+ }
279
+ changed = true;
280
+ }
281
+
282
+ if (run.status === "queued") {
283
+ this.runControls.set(run.id, {
284
+ aborted: false,
285
+ reason: null,
286
+ manualRequest: null,
287
+ });
288
+ }
289
+ }
290
+
291
+ if (changed) {
292
+ this.trimRunHistory();
293
+ this.store.persist(this.runs);
294
+ }
295
+
296
+ if (this.runs.some((run) => run.status === "queued")) {
297
+ void this.processQueue();
298
+ }
299
+ }
300
+
301
+ createRun(taskInstruction, options = {}) {
302
+ const templateSnapshot = options.templateSnapshot || null;
303
+ const resolvedTaskInstruction = typeof taskInstruction === "string" ? taskInstruction.trim() : "";
304
+ const fallbackTemplateTask = templateSnapshot?.taskInstruction || "";
305
+ const finalTaskInstruction = resolvedTaskInstruction || fallbackTemplateTask;
306
+ if (!finalTaskInstruction && !templateSnapshot?.startUrl) {
307
+ throw new Error("taskInstruction is required when template has no startUrl.");
308
+ }
309
+
310
+ const resolvedMaxSteps = normalizePositiveInteger(
311
+ options.maxSteps,
312
+ normalizePositiveInteger(templateSnapshot?.timeoutPolicy?.maxSteps, 12),
313
+ 1
314
+ );
315
+ const resolvedHandoffTimeoutMs = normalizePositiveInteger(
316
+ options.handoffTimeoutMs,
317
+ normalizePositiveInteger(templateSnapshot?.timeoutPolicy?.handoffTimeoutMs, this.handoffTimeoutMs),
318
+ 1000
319
+ );
320
+ const run = {
321
+ id: crypto.randomUUID(),
322
+ taskInstruction: finalTaskInstruction,
323
+ maxSteps: resolvedMaxSteps,
324
+ browserSource: options.browserSource || "auto",
325
+ cdpEndpoint: options.cdpEndpoint || null,
326
+ handoffTimeoutMs: resolvedHandoffTimeoutMs,
327
+ template: templateSnapshot ? {
328
+ ...templateSnapshot,
329
+ timeoutPolicy: {
330
+ ...templateSnapshot.timeoutPolicy,
331
+ maxSteps: resolvedMaxSteps,
332
+ handoffTimeoutMs: resolvedHandoffTimeoutMs,
333
+ },
334
+ } : null,
335
+ status: "queued",
336
+ createdAt: nowIso(),
337
+ startedAt: null,
338
+ finishedAt: null,
339
+ summary: "",
340
+ error: null,
341
+ result: null,
342
+ artifacts: null,
343
+ reports: null,
344
+ pendingInput: null,
345
+ templateEvaluation: null,
346
+ };
347
+
348
+ this.runs.unshift(run);
349
+ this.trimRunHistory();
350
+ this.runControls.set(run.id, {
351
+ aborted: false,
352
+ reason: null,
353
+ manualRequest: null,
354
+ });
355
+ this.store.persist(this.runs);
356
+ this.emitUpdate("run_created", run);
357
+ void this.processQueue();
358
+ return run;
359
+ }
360
+
361
+ listTemplates(limit = 100) {
362
+ return this.templates.slice(0, Math.max(1, limit));
363
+ }
364
+
365
+ getTemplate(id) {
366
+ return this.templates.find((template) => template.id === id) || null;
367
+ }
368
+
369
+ saveTemplate(templateInput) {
370
+ const current = typeof templateInput?.id === "string" ? this.getTemplate(templateInput.id) : null;
371
+ const template = normalizeTemplateInput(templateInput || {}, current);
372
+ const existingIndex = this.templates.findIndex((item) => item.id === template.id);
373
+ if (existingIndex >= 0) {
374
+ this.templates[existingIndex] = template;
375
+ } else {
376
+ this.templates.unshift(template);
377
+ }
378
+ this.templateStore.persist(this.templates);
379
+ this.emitUpdate("template_saved", null);
380
+ return template;
381
+ }
382
+
383
+ deleteTemplate(id) {
384
+ const existing = this.getTemplate(id);
385
+ if (!existing) {
386
+ throw new Error(`Template not found: ${id}`);
387
+ }
388
+ this.templates = this.templates.filter((template) => template.id !== id);
389
+ this.templateStore.persist(this.templates);
390
+ this.emitUpdate("template_deleted", null);
391
+ return existing;
392
+ }
393
+
394
+ createRunFromTemplate(templateId, overrides = {}) {
395
+ const template = this.getTemplate(templateId);
396
+ if (!template) {
397
+ throw new Error(`Template not found: ${templateId}`);
398
+ }
399
+
400
+ return this.createRun(overrides.taskInstruction || template.taskInstruction || "", {
401
+ maxSteps: normalizePositiveInteger(overrides.maxSteps, template.timeoutPolicy?.maxSteps, 1),
402
+ browserSource: overrides.browserSource || template.browserSource || "auto",
403
+ cdpEndpoint: overrides.cdpEndpoint || template.cdpEndpoint || null,
404
+ handoffTimeoutMs: normalizePositiveInteger(overrides.handoffTimeoutMs, template.timeoutPolicy?.handoffTimeoutMs, 1000),
405
+ templateSnapshot: template,
406
+ });
407
+ }
408
+
409
+ compareTemplateRuns(templateId, options = {}) {
410
+ const limit = normalizePositiveInteger(options.limit, 8, 1);
411
+ const runs = this.runs
412
+ .filter((run) => run.template?.id === templateId)
413
+ .slice(0, limit);
414
+
415
+ const comparisons = [];
416
+ for (let index = 0; index < runs.length - 1; index += 1) {
417
+ const current = runs[index];
418
+ const previous = runs[index + 1];
419
+ const currentPassed = current.templateEvaluation?.passed ?? null;
420
+ const previousPassed = previous.templateEvaluation?.passed ?? null;
421
+ comparisons.push({
422
+ currentRunId: current.id,
423
+ previousRunId: previous.id,
424
+ statusChanged: current.status !== previous.status,
425
+ assertionPassedChanged: currentPassed !== previousPassed,
426
+ summaryChanged: current.summary !== previous.summary,
427
+ currentDurationSeconds: current.startedAt && current.finishedAt
428
+ ? Math.max(0, Math.round((new Date(current.finishedAt).getTime() - new Date(current.startedAt).getTime()) / 1000))
429
+ : null,
430
+ previousDurationSeconds: previous.startedAt && previous.finishedAt
431
+ ? Math.max(0, Math.round((new Date(previous.finishedAt).getTime() - new Date(previous.startedAt).getTime()) / 1000))
432
+ : null,
433
+ });
434
+ }
435
+
436
+ return {
437
+ template: this.getTemplate(templateId),
438
+ runs,
439
+ comparisons,
440
+ };
441
+ }
442
+
443
+ listRuns(limit = 20) {
444
+ return this.runs.slice(0, limit);
445
+ }
446
+
447
+ getRun(id) {
448
+ return this.runs.find((run) => run.id === id) || null;
449
+ }
450
+
451
+ getRunControl(id) {
452
+ if (!this.runControls.has(id)) {
453
+ this.runControls.set(id, {
454
+ aborted: false,
455
+ reason: null,
456
+ manualRequest: null,
457
+ });
458
+ }
459
+ return this.runControls.get(id);
460
+ }
461
+
462
+ replyToRun(id, instruction) {
463
+ return this.resumeRun(id, instruction);
464
+ }
465
+
466
+ async resumeRun(id, instruction = "Manual control complete. Continue from the current page.") {
467
+ const run = this.getRun(id);
468
+ if (!run) {
469
+ throw new Error(`Run not found: ${id}`);
470
+ }
471
+
472
+ if (!["waiting_for_instruction", "manual_control"].includes(run.status) || !run.pendingInput) {
473
+ throw new Error(`Run ${id} is not waiting for a resume instruction.`);
474
+ }
475
+
476
+ const resolver = this.pendingReplies.get(id);
477
+ if (!resolver) {
478
+ throw new Error(`Run ${id} does not have a pending reply channel.`);
479
+ }
480
+
481
+ const wasManualControl = run.status === "manual_control" || run.pendingInput?.mode === "manual_control";
482
+ const resolvedInput = {
483
+ ...run.pendingInput,
484
+ response: instruction,
485
+ respondedAt: nowIso(),
486
+ };
487
+ if (wasManualControl) {
488
+ await this.runtime.exitManualControl();
489
+ }
490
+ run.summary = "Instruction received. Resuming browser task.";
491
+ run.status = "running";
492
+ run.pendingInput = null;
493
+ if (run.result) {
494
+ run.result.status = "running";
495
+ run.result.summary = run.summary;
496
+ run.result.pendingInput = null;
497
+ }
498
+
499
+ this.store.persist(this.runs);
500
+ this.emitUpdate("run_resumed", run);
501
+ clearTimeout(resolver.timeoutId);
502
+ this.pendingReplies.delete(id);
503
+ resolver.resolve({
504
+ instruction,
505
+ respondedAt: resolvedInput.respondedAt,
506
+ });
507
+
508
+ return run;
509
+ }
510
+
511
+ async requestManualControl(id, reason = "Manual control requested by operator.") {
512
+ const run = this.getRun(id);
513
+ if (!run) {
514
+ throw new Error(`Run not found: ${id}`);
515
+ }
516
+
517
+ if (["completed", "failed", "aborted"].includes(run.status)) {
518
+ throw new Error(`Run ${id} is already finished.`);
519
+ }
520
+
521
+ if (run.status === "queued") {
522
+ throw new Error(`Run ${id} has not started yet, so manual control is not available.`);
523
+ }
524
+
525
+ const control = this.getRunControl(id);
526
+ const pendingInput = {
527
+ step: run.result?.step || 0,
528
+ mode: "manual_control",
529
+ question: "Manual control is active. Use the live session controls, then resume when you are ready.",
530
+ details: `${reason} Use the daemon endpoints like /observe, /click, /type, /keypress, and /scroll against the same live browser session.`,
531
+ suggestedReply: "Manual control complete. Continue from the current page.",
532
+ };
533
+
534
+ this.runtime.recordEvent("task_runner_manual_control_requested", {
535
+ runId: id,
536
+ reason,
537
+ });
538
+
539
+ if (run.status === "waiting_for_instruction") {
540
+ await this.runtime.enterManualControl();
541
+
542
+ const resolver = this.pendingReplies.get(id);
543
+ if (resolver?.timeoutId) {
544
+ clearTimeout(resolver.timeoutId);
545
+ resolver.timeoutId = null;
546
+ }
547
+ if (resolver) {
548
+ resolver.mode = "manual_control";
549
+ }
550
+
551
+ run.status = "manual_control";
552
+ run.summary = pendingInput.question;
553
+ run.pendingInput = pendingInput;
554
+ if (run.result) {
555
+ run.result.status = "manual_control";
556
+ run.result.summary = pendingInput.question;
557
+ run.result.pendingInput = pendingInput;
558
+ }
559
+ this.store.persist(this.runs);
560
+ this.emitUpdate("run_manual_control", run);
561
+ return run;
562
+ }
563
+
564
+ if (run.status === "manual_control") {
565
+ return run;
566
+ }
567
+
568
+ control.manualRequest = {
569
+ ...pendingInput,
570
+ requestedAt: nowIso(),
571
+ };
572
+ run.status = "manual_control_requested";
573
+ run.summary = "Manual control requested. Waiting for the next safe pause.";
574
+ this.store.persist(this.runs);
575
+ this.emitUpdate("run_manual_control_requested", run);
576
+ return run;
577
+ }
578
+
579
+ abortRun(id, reason = "Run aborted by operator.") {
580
+ const run = this.getRun(id);
581
+ if (!run) {
582
+ throw new Error(`Run not found: ${id}`);
583
+ }
584
+
585
+ if (["completed", "failed", "aborted"].includes(run.status)) {
586
+ throw new Error(`Run ${id} is already finished.`);
587
+ }
588
+
589
+ const control = this.getRunControl(id);
590
+ control.aborted = true;
591
+ control.reason = reason;
592
+ this.runtime.recordEvent("task_runner_abort_requested", {
593
+ runId: id,
594
+ reason,
595
+ });
596
+
597
+ if (run.status === "queued") {
598
+ run.status = "aborted";
599
+ run.summary = reason;
600
+ run.finishedAt = nowIso();
601
+ run.pendingInput = null;
602
+ run.error = null;
603
+ this.runControls.delete(id);
604
+ this.trimRunHistory();
605
+ this.store.persist(this.runs);
606
+ this.emitUpdate("run_aborted", run);
607
+ return run;
608
+ }
609
+
610
+ run.status = "aborting";
611
+ run.summary = reason;
612
+ this.store.persist(this.runs);
613
+ this.emitUpdate("run_aborting", run);
614
+
615
+ const pendingReply = this.pendingReplies.get(id);
616
+ if (pendingReply) {
617
+ clearTimeout(pendingReply.timeoutId);
618
+ this.pendingReplies.delete(id);
619
+ pendingReply.resolve({
620
+ abort: true,
621
+ reason,
622
+ });
623
+ }
624
+
625
+ return run;
626
+ }
627
+
628
+ subscribe(listener) {
629
+ this.events.on("update", listener);
630
+ return () => this.events.off("update", listener);
631
+ }
632
+
633
+ emitUpdate(type, run) {
634
+ this.events.emit("update", {
635
+ type,
636
+ runId: run?.id || null,
637
+ timestamp: nowIso(),
638
+ });
639
+ }
640
+
641
+ async processQueue() {
642
+ if (this.processing) return;
643
+ this.processing = true;
644
+
645
+ try {
646
+ while (true) {
647
+ const nextRun = this.runs.find((run) => run.status === "queued");
648
+ if (!nextRun) break;
649
+ await this.executeRun(nextRun);
650
+ }
651
+ } finally {
652
+ this.processing = false;
653
+ }
654
+ }
655
+
656
+ async executeRun(run) {
657
+ run.status = "running";
658
+ run.startedAt = nowIso();
659
+ run.summary = "Starting browser task...";
660
+ run.result = {
661
+ status: "running",
662
+ step: 0,
663
+ summary: run.summary,
664
+ history: [],
665
+ artifacts: null,
666
+ page: null,
667
+ verification: null,
668
+ operatorMessages: [],
669
+ pendingInput: null,
670
+ debug: null,
671
+ templateEvaluation: null,
672
+ };
673
+ this.store.persist(this.runs);
674
+ this.emitUpdate("run_started", run);
675
+
676
+ try {
677
+ const effectiveTaskInstruction = buildTemplatedInstruction(run.taskInstruction, run.template);
678
+ const runHandoffTimeoutMs = normalizePositiveInteger(run.handoffTimeoutMs, this.handoffTimeoutMs, 1000);
679
+ const result = await this.subagent.delegateTask(effectiveTaskInstruction, {
680
+ maxSteps: run.maxSteps,
681
+ startOptions: {
682
+ source: run.browserSource,
683
+ cdpEndpoint: run.cdpEndpoint || undefined,
684
+ },
685
+ onProgress: async (progress) => {
686
+ run.summary = progress.summary || run.summary;
687
+ if (!["aborting", "aborted"].includes(run.status)) {
688
+ if (["waiting_for_instruction", "manual_control", "manual_control_requested"].includes(progress.status)) {
689
+ run.status = progress.status;
690
+ }
691
+ }
692
+ run.result = progress;
693
+ run.artifacts = progress.artifacts || run.artifacts;
694
+ run.pendingInput = progress.pendingInput || null;
695
+ this.store.persist(this.runs);
696
+ this.emitUpdate("run_updated", run);
697
+ },
698
+ onNeedsInput: async (pendingInput) => {
699
+ const waitingStatus = pendingInput.mode === "manual_control" ? "manual_control" : "waiting_for_instruction";
700
+ if (pendingInput.mode === "manual_control") {
701
+ await this.runtime.enterManualControl();
702
+ }
703
+ run.status = waitingStatus;
704
+ run.pendingInput = {
705
+ ...pendingInput,
706
+ requestedAt: nowIso(),
707
+ };
708
+ run.summary = pendingInput.question || "Waiting for instruction.";
709
+ if (run.result) {
710
+ run.result.status = waitingStatus;
711
+ run.result.pendingInput = run.pendingInput;
712
+ }
713
+ this.store.persist(this.runs);
714
+ this.emitUpdate(waitingStatus === "manual_control" ? "run_manual_control" : "run_waiting", run);
715
+
716
+ return await new Promise((resolve) => {
717
+ let timeoutId = null;
718
+ if (pendingInput.mode !== "manual_control") {
719
+ timeoutId = setTimeout(() => {
720
+ this.pendingReplies.delete(run.id);
721
+ const timeoutReason = `Timed out waiting for instruction after ${formatTimeoutDuration(runHandoffTimeoutMs)}.`;
722
+ const control = this.getRunControl(run.id);
723
+ control.aborted = true;
724
+ control.reason = timeoutReason;
725
+ resolve({
726
+ abort: true,
727
+ reason: timeoutReason,
728
+ });
729
+ }, runHandoffTimeoutMs);
730
+ }
731
+
732
+ this.pendingReplies.set(run.id, {
733
+ resolve,
734
+ timeoutId,
735
+ mode: pendingInput.mode || "guidance",
736
+ });
737
+ });
738
+ },
739
+ shouldAbort: () => this.getRunControl(run.id).aborted,
740
+ getAbortReason: () => this.getRunControl(run.id).reason || "Browser task aborted.",
741
+ pullHandoffRequest: () => {
742
+ const control = this.getRunControl(run.id);
743
+ const request = control.manualRequest;
744
+ control.manualRequest = null;
745
+ return request;
746
+ },
747
+ });
748
+
749
+ const templateEvaluation = evaluateTemplate(run.template, result);
750
+ if (templateEvaluation) {
751
+ result.templateEvaluation = templateEvaluation;
752
+ run.templateEvaluation = templateEvaluation;
753
+ if (!templateEvaluation.passed) {
754
+ result.status = "failed";
755
+ const firstFailure = templateEvaluation.failureMessages[0] || "Template checks failed.";
756
+ result.summary = `Template checks failed: ${firstFailure}`;
757
+ }
758
+ }
759
+
760
+ run.status = result.status === "completed" ? "completed" : result.status === "aborted" ? "aborted" : "failed";
761
+ run.summary = result.summary;
762
+ run.result = result;
763
+ run.artifacts = result.artifacts;
764
+ run.reports = result.reports;
765
+ run.pendingInput = result.pendingInput || null;
766
+ run.error = null;
767
+ this.emitUpdate("run_updated", run);
768
+ } catch (error) {
769
+ run.status = "failed";
770
+ run.summary = error.message;
771
+ run.error = {
772
+ message: error.message,
773
+ stack: error.stack || null,
774
+ };
775
+ this.emitUpdate("run_updated", run);
776
+ } finally {
777
+ const stopResult = await this.runtime.stop().catch(() => null);
778
+ if (stopResult?.artifacts) {
779
+ run.artifacts = stopResult.artifacts;
780
+ if (run.result) {
781
+ run.result.artifacts = stopResult.artifacts;
782
+ try {
783
+ const refreshedReports = this.subagent.writeReports(run.result);
784
+ run.result.reports = refreshedReports;
785
+ run.reports = refreshedReports;
786
+ } catch (error) {
787
+ // Keep original reports if post-stop refresh fails.
788
+ }
789
+ }
790
+ }
791
+ run.finishedAt = nowIso();
792
+ const pendingReply = this.pendingReplies.get(run.id);
793
+ if (pendingReply) {
794
+ clearTimeout(pendingReply.timeoutId);
795
+ this.pendingReplies.delete(run.id);
796
+ }
797
+ this.runControls.delete(run.id);
798
+ this.trimRunHistory();
799
+ this.store.persist(this.runs);
800
+ this.emitUpdate("run_finished", run);
801
+ }
802
+ }
803
+ }