baa-conductor


baa-conductor / apps / conductor-daemon / src / artifacts
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;