im_wower
·
2026-04-03
upload-session.ts
1import { DEFAULT_SUMMARY_LENGTH } from "../../../../packages/artifact-db/dist/index.js";
2import type { BrowserBridgeController } from "../browser-types.js";
3import {
4 DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS,
5 DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
6} from "../execution-timeouts.js";
7import {
8 type BaaInstructionExecutionResult,
9 sortBaaJsonValue,
10 type BaaInstructionProcessResult,
11 type BaaJsonValue
12} from "../instructions/types.js";
13
14import type {
15 BaaDeliveryRouteSnapshot,
16 BaaDeliveryBridgeSnapshot,
17 BaaDeliverySessionSnapshot
18} from "./types.js";
19
20const DEFAULT_COMPLETED_SESSION_TTL_MS = 10 * 60_000;
21export const DEFAULT_BAA_DELIVERY_LINE_LIMIT = 200;
22export const DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD = 2_000;
23export const DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH = DEFAULT_SUMMARY_LENGTH;
24const DEFAULT_DELIVERY_POLL_INTERVAL_MS = 150;
25const DEFAULT_DELIVERY_RETRY_ATTEMPTS = 2;
26const DEFAULT_DELIVERY_RETRY_DELAY_MS = 250;
27
28export interface BaaDeliveryMessageRenderResult {
29 executionCount: number;
30 messageCharCount: number;
31 messageLineCount: number;
32 messageLineLimit: number;
33 messageText: string;
34 messageTruncated: boolean;
35 sourceLineCount: number;
36}
37
38interface BaaDeliverySessionRecord {
39 expiresAt: number;
40 snapshot: BaaDeliverySessionSnapshot;
41 targetConnectionId: string | null;
42}
43
44export interface BaaBrowserDeliveryBridgeOptions {
45 bridge: BrowserBridgeController;
46 inlineThreshold?: number | null;
47 lineLimit?: number | null;
48 now?: () => number;
49 onChange?: (() => Promise<void> | void) | null;
50 summaryLength?: number | null;
51}
52
53export interface BaaBrowserDeliveryInput {
54 assistantMessageId: string;
55 autoSend?: boolean;
56 clientId?: string | null;
57 connectionId?: string | null;
58 conversationId?: string | null;
59 platform: string;
60 processResult: BaaInstructionProcessResult | null;
61 route?: BaaDeliveryRouteSnapshot | null;
62}
63
64function sanitizePathSegment(value: string): string {
65 const normalized = value.trim().toLowerCase().replace(/[^a-z0-9]+/gu, "-");
66 const collapsed = normalized.replace(/-+/gu, "-").replace(/^-|-$/gu, "");
67 return collapsed === "" ? "unknown" : collapsed;
68}
69
70function cloneRouteSnapshot(snapshot: BaaDeliveryRouteSnapshot): BaaDeliveryRouteSnapshot {
71 return {
72 assistantMessageId: snapshot.assistantMessageId,
73 conversationId: snapshot.conversationId,
74 observedAt: snapshot.observedAt,
75 organizationId: snapshot.organizationId,
76 pageTitle: snapshot.pageTitle,
77 pageUrl: snapshot.pageUrl,
78 platform: snapshot.platform,
79 shellPage: snapshot.shellPage,
80 tabId: snapshot.tabId
81 };
82}
83
84function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDeliverySessionSnapshot {
85 return {
86 autoSend: snapshot.autoSend,
87 clientId: snapshot.clientId,
88 completedAt: snapshot.completedAt,
89 connectionId: snapshot.connectionId,
90 conversationId: snapshot.conversationId,
91 createdAt: snapshot.createdAt,
92 mode: snapshot.mode,
93 executionCount: snapshot.executionCount,
94 failedAt: snapshot.failedAt,
95 failedReason: snapshot.failedReason,
96 injectCompletedAt: snapshot.injectCompletedAt,
97 injectRequestId: snapshot.injectRequestId,
98 injectStartedAt: snapshot.injectStartedAt,
99 messageCharCount: snapshot.messageCharCount,
100 messageLineCount: snapshot.messageLineCount,
101 messageLineLimit: snapshot.messageLineLimit,
102 messageTruncated: snapshot.messageTruncated,
103 planId: snapshot.planId,
104 platform: snapshot.platform,
105 proxyCompletedAt: snapshot.proxyCompletedAt,
106 proxyFailedReason: snapshot.proxyFailedReason,
107 proxyRequestId: snapshot.proxyRequestId,
108 proxyStartedAt: snapshot.proxyStartedAt,
109 roundId: snapshot.roundId,
110 sendCompletedAt: snapshot.sendCompletedAt,
111 sendRequestId: snapshot.sendRequestId,
112 sendStartedAt: snapshot.sendStartedAt,
113 sourceLineCount: snapshot.sourceLineCount,
114 stage: snapshot.stage,
115 targetOrganizationId: snapshot.targetOrganizationId,
116 targetPageTitle: snapshot.targetPageTitle,
117 targetPageUrl: snapshot.targetPageUrl,
118 targetShellPage: snapshot.targetShellPage,
119 targetTabId: snapshot.targetTabId,
120 traceId: snapshot.traceId
121 };
122}
123
124function normalizeDeliveryLines(value: string): string[] {
125 const normalized = value.replace(/\r\n?/gu, "\n").trimEnd();
126 return normalized === "" ? [] : normalized.split("\n");
127}
128
129function appendSection(lines: string[], title: string, value: string): void {
130 const normalized = value.trimEnd();
131
132 if (normalized === "") {
133 return;
134 }
135
136 lines.push("");
137 lines.push(title);
138 lines.push(...normalizeDeliveryLines(normalized));
139}
140
141function formatScalar(value: BaaJsonValue): string {
142 switch (typeof value) {
143 case "string":
144 return JSON.stringify(value);
145 case "number":
146 case "boolean":
147 return String(value);
148 default:
149 return value == null ? "null" : JSON.stringify(value);
150 }
151}
152
153function isSingleLineScalar(value: BaaJsonValue): boolean {
154 return value == null
155 || typeof value === "number"
156 || typeof value === "boolean"
157 || (typeof value === "string" && !value.includes("\n"));
158}
159
160function renderStructuredValueLines(value: BaaJsonValue, indent: number): string[] {
161 const padding = " ".repeat(indent);
162
163 if (Array.isArray(value)) {
164 if (value.length === 0) {
165 return [`${padding}[]`];
166 }
167
168 const lines: string[] = [];
169
170 for (const entry of value) {
171 if (isSingleLineScalar(entry)) {
172 lines.push(`${padding}- ${formatScalar(entry)}`);
173 continue;
174 }
175
176 lines.push(`${padding}-`);
177 lines.push(...renderStructuredValueLines(entry, indent + 2));
178 }
179
180 return lines;
181 }
182
183 if (value != null && typeof value === "object") {
184 const normalized = sortBaaJsonValue(value);
185 const entries = Object.entries(normalized);
186
187 if (entries.length === 0) {
188 return [`${padding}{}`];
189 }
190
191 const lines: string[] = [];
192
193 for (const [key, entry] of entries) {
194 if (isSingleLineScalar(entry)) {
195 lines.push(`${padding}${key}: ${formatScalar(entry)}`);
196 continue;
197 }
198
199 lines.push(`${padding}${key}:`);
200 lines.push(...renderStructuredValueLines(entry, indent + 2));
201 }
202
203 return lines;
204 }
205
206 if (typeof value === "string") {
207 const renderedLines = normalizeDeliveryLines(value);
208 return renderedLines.length === 0
209 ? [`${padding}""`]
210 : renderedLines.map((line) => `${padding}${line}`);
211 }
212
213 return [`${padding}${formatScalar(value)}`];
214}
215
216function appendJsonSection(lines: string[], title: string, value: BaaJsonValue | null): void {
217 if (value == null) {
218 return;
219 }
220
221 lines.push("");
222 lines.push(title);
223 lines.push(...renderStructuredValueLines(value, 2));
224}
225
226function buildExecutionSection(
227 processResult: BaaInstructionProcessResult,
228 executionIndex: number
229): string[] {
230 const execution = processResult.executions[executionIndex]!;
231 const instruction = processResult.instructions.find(
232 (candidate) => candidate.instructionId === execution.instructionId
233 ) ?? null;
234 const lines = [
235 `[执行 ${executionIndex + 1}]`,
236 `instruction_id: ${execution.instructionId}`,
237 `block_index: ${instruction?.blockIndex ?? "-"}`,
238 `tool: ${execution.tool}`,
239 `target: ${execution.target}`,
240 `route: ${execution.route.method} ${execution.route.path}`,
241 `route_key: ${execution.route.key}`,
242 `ok: ${String(execution.ok)}`,
243 `http_status: ${String(execution.httpStatus)}`,
244 `request_id: ${execution.requestId ?? "-"}`,
245 `dedupe_key: ${execution.dedupeKey}`
246 ];
247
248 if (execution.message != null) {
249 appendSection(lines, "message:", execution.message);
250 }
251
252 if (execution.error != null) {
253 appendSection(lines, "error:", execution.error);
254 }
255
256 appendJsonSection(lines, "data:", execution.data);
257 appendJsonSection(lines, "details:", execution.details);
258 return lines;
259}
260
261function appendArtifactUrlLine(lines: string[], artifactUrl: string | null): void {
262 if (artifactUrl == null) {
263 return;
264 }
265
266 lines.push("");
267 lines.push(`完整结果:${artifactUrl}`);
268}
269
270function buildExecutionResultText(execution: BaaInstructionExecutionResult): string | null {
271 const artifactResultText = execution.artifact?.resultText;
272
273 if (typeof artifactResultText === "string" && artifactResultText.trim() !== "") {
274 return artifactResultText;
275 }
276 const payload: Record<string, BaaJsonValue> = {
277 http_status: execution.httpStatus,
278 ok: execution.ok,
279 route: {
280 key: execution.route.key,
281 method: execution.route.method,
282 path: execution.route.path
283 }
284 };
285
286 if (execution.data != null) {
287 payload.data = execution.data;
288 }
289
290 if (execution.details != null) {
291 payload.details = execution.details;
292 }
293
294 if (execution.error != null) {
295 payload.error = execution.error;
296 }
297
298 if (execution.message != null) {
299 payload.message = execution.message;
300 }
301
302 if (execution.requestId != null) {
303 payload.request_id = execution.requestId;
304 }
305
306 return JSON.stringify(payload, null, 2);
307}
308
309function buildRenderedExecutionSection(
310 execution: BaaInstructionExecutionResult,
311 executionIndex: number,
312 inlineThreshold: number,
313 summaryLength: number
314): {
315 sourceLines: string[];
316 truncated: boolean;
317 visibleLines: string[];
318} {
319 const fullResultText = buildExecutionResultText(execution);
320 const summaryText =
321 fullResultText != null && fullResultText.length > inlineThreshold
322 ? fullResultText.slice(0, summaryLength)
323 : fullResultText;
324 const truncated = fullResultText != null && summaryText !== fullResultText;
325 const sourceLines = [
326 `[执行 ${executionIndex + 1}]`,
327 `instruction_id: ${execution.instructionId}`,
328 `tool: ${execution.tool}`,
329 `target: ${execution.target}`,
330 `status: ${execution.ok ? "ok" : "error"}`,
331 `http_status: ${String(execution.httpStatus)}`
332 ];
333 const visibleLines = [...sourceLines];
334 const sourceResultText = fullResultText ?? "(empty)";
335 const visibleResultText = summaryText ?? "(empty)";
336
337 appendSection(sourceLines, "result:", sourceResultText);
338 appendSection(visibleLines, "result:", visibleResultText);
339 appendArtifactUrlLine(sourceLines, execution.artifact?.url ?? null);
340 appendArtifactUrlLine(visibleLines, execution.artifact?.url ?? null);
341
342 return {
343 sourceLines,
344 truncated,
345 visibleLines
346 };
347}
348
349export function renderBaaDeliveryMessageText(
350 input: Pick<BaaBrowserDeliveryInput, "assistantMessageId" | "conversationId" | "platform" | "processResult">,
351 options: {
352 inlineThreshold?: number | null;
353 lineLimit?: number | null;
354 summaryLength?: number | null;
355 } = {}
356): BaaDeliveryMessageRenderResult {
357 const processResult = input.processResult;
358
359 if (processResult == null || processResult.executions.length === 0) {
360 return {
361 executionCount: 0,
362 messageCharCount: 0,
363 messageLineCount: 0,
364 messageLineLimit: normalizePositiveInteger(options.inlineThreshold, DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD),
365 messageText: "",
366 messageTruncated: false,
367 sourceLineCount: 0
368 };
369 }
370
371 const inlineThreshold = normalizePositiveInteger(options.inlineThreshold, DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD);
372 const summaryLength = normalizePositiveInteger(options.summaryLength, DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH);
373 const sourceLines = [
374 "[BAA 执行结果]",
375 `assistant_message_id: ${input.assistantMessageId}`,
376 `platform: ${input.platform}`,
377 `conversation_id: ${input.conversationId ?? "-"}`,
378 `execution_count: ${String(processResult.executions.length)}`
379 ];
380 const visibleLines = [...sourceLines];
381 let truncated = false;
382
383 for (let index = 0; index < processResult.executions.length; index += 1) {
384 const renderedSection = buildRenderedExecutionSection(
385 processResult.executions[index]!,
386 index,
387 inlineThreshold,
388 summaryLength
389 );
390
391 sourceLines.push("");
392 sourceLines.push(...renderedSection.sourceLines);
393 visibleLines.push("");
394 visibleLines.push(...renderedSection.visibleLines);
395 truncated = truncated || renderedSection.truncated;
396 }
397
398 const normalizedSourceLines = normalizeDeliveryLines(sourceLines.join("\n"));
399 const normalizedVisibleLines = normalizeDeliveryLines(visibleLines.join("\n"));
400 const messageText = normalizedVisibleLines.join("\n");
401 return {
402 executionCount: processResult.executions.length,
403 messageCharCount: messageText.length,
404 messageLineCount: normalizedVisibleLines.length,
405 messageLineLimit: inlineThreshold,
406 messageText,
407 messageTruncated: truncated,
408 sourceLineCount: normalizedSourceLines.length
409 };
410}
411
412function normalizePositiveInteger(value: unknown, fallback: number): number {
413 return typeof value === "number" && Number.isFinite(value) && value > 0
414 ? Math.max(1, Math.round(value))
415 : fallback;
416}
417
418function normalizeRoute(route: BaaDeliveryRouteSnapshot | null | undefined): BaaDeliveryRouteSnapshot | null {
419 return route == null ? null : cloneRouteSnapshot(route);
420}
421
422function buildMissingRouteReason(
423 input: Pick<BaaBrowserDeliveryInput, "assistantMessageId" | "conversationId" | "platform">
424): string {
425 return [
426 "delivery.route_missing: missing business-page delivery target",
427 `platform=${input.platform}`,
428 `conversation=${input.conversationId ?? "-"}`,
429 `assistant=${input.assistantMessageId}`
430 ].join(" ");
431}
432
433function buildInvalidRouteReason(route: BaaDeliveryRouteSnapshot): string {
434 if (route.shellPage) {
435 return [
436 "delivery.shell_page: delivery target resolves to shell page",
437 `platform=${route.platform}`,
438 `conversation=${route.conversationId ?? "-"}`,
439 `tab=${route.tabId ?? "-"}`
440 ].join(" ");
441 }
442
443 return [
444 "delivery.route_invalid: delivery target is missing a concrete page tab",
445 `platform=${route.platform}`,
446 `conversation=${route.conversationId ?? "-"}`,
447 `tab=${route.tabId ?? "-"}`
448 ].join(" ");
449}
450
451function hasDeliverableBusinessRoute(route: BaaDeliveryRouteSnapshot): boolean {
452 return route.shellPage !== true && (
453 route.conversationId != null
454 || route.pageUrl != null
455 || route.tabId != null
456 );
457}
458
459function resolvePreferredTargetTabId(route: BaaDeliveryRouteSnapshot): number | null {
460 return route.conversationId != null || route.pageUrl != null
461 ? null
462 : route.tabId;
463}
464
465function shouldFailClosedWithoutFallback(reason: string): boolean {
466 return reason.startsWith("delivery.route_")
467 || reason.startsWith("delivery.shell_page:")
468 || reason.startsWith("delivery.page_paused:")
469 || reason.startsWith("delivery.target_mismatch:")
470 || reason.startsWith("delivery.target_missing:")
471 || reason.startsWith("delivery.tab_missing:");
472}
473
474export class BaaBrowserDeliveryBridge {
475 private readonly bridge: BrowserBridgeController;
476 private readonly inlineThreshold: number;
477 private lastRoute: BaaDeliveryRouteSnapshot | null = null;
478 private lastSession: BaaDeliverySessionSnapshot | null = null;
479 private readonly lineLimit: number;
480 private readonly now: () => number;
481 private readonly onChange: (() => Promise<void> | void) | null;
482 private readonly summaryLength: number;
483 private readonly sessions = new Map<string, BaaDeliverySessionRecord>();
484
485 constructor(options: BaaBrowserDeliveryBridgeOptions) {
486 this.bridge = options.bridge;
487 this.inlineThreshold = normalizePositiveInteger(options.inlineThreshold, DEFAULT_BAA_DELIVERY_INLINE_THRESHOLD);
488 this.lineLimit = normalizePositiveInteger(options.lineLimit, DEFAULT_BAA_DELIVERY_LINE_LIMIT);
489 this.now = options.now ?? (() => Date.now());
490 this.onChange = options.onChange ?? null;
491 this.summaryLength = normalizePositiveInteger(options.summaryLength, DEFAULT_BAA_DELIVERY_SUMMARY_LENGTH);
492 }
493
494 getSnapshot(): BaaDeliveryBridgeSnapshot {
495 this.cleanupExpiredSessions();
496
497 return {
498 activeSessionCount: [...this.sessions.values()].filter((entry) =>
499 entry.snapshot.stage !== "completed" && entry.snapshot.stage !== "failed"
500 ).length,
501 lastRoute: this.lastRoute == null ? null : cloneRouteSnapshot(this.lastRoute),
502 lastSession: this.lastSession == null ? null : cloneSessionSnapshot(this.lastSession)
503 };
504 }
505
506 observeRoute(route: BaaDeliveryRouteSnapshot | null | undefined): void {
507 if (route == null) {
508 return;
509 }
510
511 this.lastRoute = cloneRouteSnapshot(route);
512 this.signalChange();
513 }
514
515 private resolveRoute(input: BaaBrowserDeliveryInput): BaaDeliveryRouteSnapshot | null {
516 const directRoute = normalizeRoute(input.route);
517
518 if (directRoute != null) {
519 return directRoute;
520 }
521
522 if (this.lastRoute == null || this.lastRoute.platform !== input.platform) {
523 return null;
524 }
525
526 if (this.lastRoute.assistantMessageId === input.assistantMessageId) {
527 return cloneRouteSnapshot(this.lastRoute);
528 }
529
530 if (input.conversationId != null && this.lastRoute.conversationId === input.conversationId) {
531 return cloneRouteSnapshot(this.lastRoute);
532 }
533
534 return null;
535 }
536
537 async deliver(input: BaaBrowserDeliveryInput): Promise<BaaDeliverySessionSnapshot | null> {
538 this.cleanupExpiredSessions();
539
540 if (input.processResult == null || input.processResult.executions.length === 0) {
541 return null;
542 }
543
544 const rendered = renderBaaDeliveryMessageText(input, {
545 inlineThreshold: this.inlineThreshold,
546 lineLimit: this.lineLimit,
547 summaryLength: this.summaryLength
548 });
549
550 if (rendered.messageText.trim() === "") {
551 return null;
552 }
553
554 const traceId = `delivery_${sanitizePathSegment(input.assistantMessageId)}`;
555 const roundId = `round_${this.now()}`;
556 const planId = `${traceId}_${roundId}`;
557 const createdAt = this.now();
558 const route = this.resolveRoute(input);
559 const session: BaaDeliverySessionSnapshot = {
560 autoSend: input.autoSend ?? true,
561 clientId: input.clientId ?? null,
562 completedAt: null,
563 connectionId: input.connectionId ?? null,
564 conversationId: input.conversationId ?? null,
565 createdAt,
566 mode: null,
567 executionCount: rendered.executionCount,
568 failedAt: null,
569 failedReason: null,
570 injectCompletedAt: null,
571 injectRequestId: null,
572 injectStartedAt: null,
573 messageCharCount: rendered.messageCharCount,
574 messageLineCount: rendered.messageLineCount,
575 messageLineLimit: rendered.messageLineLimit,
576 messageTruncated: rendered.messageTruncated,
577 planId,
578 platform: input.platform,
579 proxyCompletedAt: null,
580 proxyFailedReason: null,
581 proxyRequestId: null,
582 proxyStartedAt: null,
583 roundId,
584 sendCompletedAt: null,
585 sendRequestId: null,
586 sendStartedAt: null,
587 sourceLineCount: rendered.sourceLineCount,
588 stage: "proxying",
589 targetOrganizationId: route?.organizationId ?? null,
590 targetPageTitle: route?.pageTitle ?? null,
591 targetPageUrl: route?.pageUrl ?? null,
592 targetShellPage: route?.shellPage === true,
593 targetTabId: route?.tabId ?? null,
594 traceId
595 };
596 const record: BaaDeliverySessionRecord = {
597 expiresAt: createdAt + DEFAULT_COMPLETED_SESSION_TTL_MS,
598 snapshot: session,
599 targetConnectionId: input.connectionId ?? null
600 };
601
602 this.sessions.set(planId, record);
603 this.captureLastSession(record.snapshot);
604
605 if (route == null) {
606 this.failSession(record, buildMissingRouteReason(input));
607 return cloneSessionSnapshot(record.snapshot);
608 }
609
610 if (!hasDeliverableBusinessRoute(route)) {
611 this.failSession(record, buildInvalidRouteReason(route));
612 return cloneSessionSnapshot(record.snapshot);
613 }
614
615 const preferredTargetTabId = resolvePreferredTargetTabId(route);
616
617 try {
618 const proxyDispatch = this.bridge.proxyDelivery({
619 assistantMessageId: input.assistantMessageId,
620 clientId: input.clientId,
621 conversationId: route.conversationId,
622 messageText: rendered.messageText,
623 organizationId: route.organizationId,
624 pageTitle: route.pageTitle,
625 pageUrl: route.pageUrl,
626 planId,
627 platform: input.platform,
628 shellPage: route.shellPage,
629 tabId: preferredTargetTabId,
630 timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
631 });
632
633 record.snapshot.mode = "proxy";
634 record.snapshot.proxyRequestId = proxyDispatch.requestId;
635 record.snapshot.proxyStartedAt = proxyDispatch.dispatchedAt;
636 this.captureLastSession(record.snapshot);
637 const proxyResult = await proxyDispatch.result;
638
639 if (proxyResult.accepted !== true || proxyResult.failed === true) {
640 throw new Error(proxyResult.reason ?? "browser proxy delivery failed");
641 }
642
643 record.snapshot.proxyCompletedAt = this.now();
644 record.snapshot.completedAt = this.now();
645 record.snapshot.stage = "completed";
646 record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
647 this.captureLastSession(record.snapshot);
648 return cloneSessionSnapshot(record.snapshot);
649 } catch (error) {
650 const proxyFailureReason = error instanceof Error ? error.message : String(error);
651 record.snapshot.proxyFailedReason = proxyFailureReason;
652 this.captureLastSession(record.snapshot);
653
654 if (shouldFailClosedWithoutFallback(proxyFailureReason)) {
655 this.failSession(record, proxyFailureReason);
656 return cloneSessionSnapshot(record.snapshot);
657 }
658
659 try {
660 const injectDispatch = this.bridge.injectMessage({
661 clientId: input.clientId,
662 conversationId: route.conversationId,
663 messageText: rendered.messageText,
664 planId,
665 platform: input.platform,
666 pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
667 retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
668 retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
669 pageTitle: route.pageTitle,
670 pageUrl: route.pageUrl,
671 shellPage: route.shellPage,
672 tabId: preferredTargetTabId,
673 timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
674 });
675
676 record.snapshot.mode = "dom_fallback";
677 record.snapshot.injectRequestId = injectDispatch.requestId;
678 record.snapshot.injectStartedAt = injectDispatch.dispatchedAt;
679 record.snapshot.stage = "injecting";
680 this.captureLastSession(record.snapshot);
681 const injectResult = await injectDispatch.result;
682
683 if (injectResult.accepted !== true || injectResult.failed === true) {
684 throw new Error(injectResult.reason ?? "browser inject_message failed");
685 }
686
687 record.snapshot.injectCompletedAt = this.now();
688
689 if (record.snapshot.autoSend) {
690 const sendDispatch = this.bridge.sendMessage({
691 clientId: input.clientId,
692 conversationId: route.conversationId,
693 planId,
694 platform: input.platform,
695 pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
696 retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
697 retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
698 pageTitle: route.pageTitle,
699 pageUrl: route.pageUrl,
700 shellPage: route.shellPage,
701 tabId: preferredTargetTabId,
702 timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
703 });
704
705 record.snapshot.sendRequestId = sendDispatch.requestId;
706 record.snapshot.sendStartedAt = sendDispatch.dispatchedAt;
707 record.snapshot.stage = "sending";
708 this.captureLastSession(record.snapshot);
709 const sendResult = await sendDispatch.result;
710
711 if (sendResult.accepted !== true || sendResult.failed === true) {
712 throw new Error(sendResult.reason ?? "browser send_message failed");
713 }
714
715 record.snapshot.sendCompletedAt = this.now();
716 }
717
718 record.snapshot.completedAt = this.now();
719 record.snapshot.stage = "completed";
720 record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
721 this.captureLastSession(record.snapshot);
722 return cloneSessionSnapshot(record.snapshot);
723 } catch (fallbackError) {
724 this.failSession(
725 record,
726 fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
727 );
728 return cloneSessionSnapshot(record.snapshot);
729 }
730 }
731 }
732
733 handleConnectionClosed(connectionId: string, reason?: string | null): void {
734 for (const session of this.sessions.values()) {
735 if (session.targetConnectionId !== connectionId) {
736 continue;
737 }
738
739 if (session.snapshot.stage === "completed" || session.snapshot.stage === "failed") {
740 continue;
741 }
742
743 this.failSession(
744 session,
745 normalizeDeliveryReason(reason) ?? "firefox delivery client disconnected"
746 );
747 }
748 }
749
750 stop(): void {
751 for (const session of this.sessions.values()) {
752 if (session.snapshot.stage === "completed" || session.snapshot.stage === "failed") {
753 continue;
754 }
755
756 this.failSession(session, "browser delivery bridge stopped");
757 }
758
759 this.sessions.clear();
760 }
761
762 private captureLastSession(snapshot: BaaDeliverySessionSnapshot): void {
763 this.lastSession = cloneSessionSnapshot(snapshot);
764 this.signalChange();
765 }
766
767 private cleanupExpiredSessions(): void {
768 const now = this.now();
769
770 for (const [planId, session] of this.sessions.entries()) {
771 if (session.expiresAt > now) {
772 continue;
773 }
774
775 this.sessions.delete(planId);
776 }
777 }
778
779 private failSession(record: BaaDeliverySessionRecord, reason: string): void {
780 if (record.snapshot.stage === "completed" || record.snapshot.stage === "failed") {
781 return;
782 }
783
784 const failedAt = this.now();
785 record.snapshot.failedAt = failedAt;
786 record.snapshot.failedReason = reason;
787 record.snapshot.stage = "failed";
788 record.expiresAt = failedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
789 this.captureLastSession(record.snapshot);
790 }
791
792 private signalChange(): void {
793 if (this.onChange == null) {
794 return;
795 }
796
797 void this.onChange();
798 }
799}
800
801function normalizeDeliveryReason(value: string | null | undefined): string | null {
802 if (typeof value !== "string") {
803 return null;
804 }
805
806 const normalized = value.trim();
807 return normalized === "" ? null : normalized;
808}
809
810export const DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT = DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT_MS;