@nextcommerce/campaigns-os 1.50.0 → 1.53.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +474 -0
  2. package/agents/claude/CLAUDE.md +4 -2
  3. package/agents/codex/AGENTS.md +3 -1
  4. package/agents/copilot/copilot-instructions.md +3 -1
  5. package/agents/cursor/campaigns-os.mdc +3 -1
  6. package/campaign-spec/dist/rules/campaign-metadata.d.ts +5 -1
  7. package/campaign-spec/dist/rules/campaign-metadata.js +9 -2
  8. package/campaign-spec/dist/rules/design-source-shape.js +13 -3
  9. package/campaign-spec/dist/rules/sdk-version.js +2 -1
  10. package/compatibility.json +1 -1
  11. package/contracts/commerce-surface-catalog.json +26 -46
  12. package/contracts/effects.v1.json +282 -14
  13. package/contracts/release-ledger.json +1233 -0
  14. package/contracts/supported-surface.json +2 -2
  15. package/contracts/template-brand-contract.shared-commerce.v0.json +2 -2
  16. package/contracts/template-slot-manifest.shared-content-core.v0.json +24 -0
  17. package/docs/brand-theme-bridge.md +2 -0
  18. package/docs/build-packet.md +148 -23
  19. package/docs/campaign-build-brief.md +57 -2
  20. package/docs/effects.md +14 -0
  21. package/docs/local-setup.md +1 -1
  22. package/docs/orientation-contract-reference.md +1 -1
  23. package/docs/polish-evidence.md +12 -0
  24. package/docs/progress-snapshots.md +2 -4
  25. package/docs/qa-and-test-orders.md +47 -4
  26. package/docs/readback.md +19 -2
  27. package/docs/runtime-readiness.md +1 -1
  28. package/docs/sdk-storage-compatibility.md +1 -1
  29. package/docs/skills-revision.md +10 -10
  30. package/package.json +1 -1
  31. package/skills/campaign-lifecycle-orientation/SKILL.md +3 -3
  32. package/skills/campaign-readback-classification/SKILL.md +3 -3
  33. package/skills/campaign-run-evidence/SKILL.md +3 -3
  34. package/skills/contribution-intake/SKILL.md +3 -3
  35. package/skills/next-campaigns-build/SKILL.md +5 -3
  36. package/skills/next-campaigns-os/SKILL.md +11 -20
  37. package/skills/next-campaigns-os/references/session-intake.md +17 -9
  38. package/skills/next-campaigns-os-setup/SKILL.md +4 -3
  39. package/skills/next-campaigns-polish/SKILL.md +7 -5
  40. package/skills/next-campaigns-qa/SKILL.md +8 -5
  41. package/skills.json +10 -10
  42. package/src/adapter-decision-contract.mjs +1 -1
  43. package/src/brand-theme.mjs +33 -20
  44. package/src/brief-summary.mjs +275 -0
  45. package/src/build-brief.mjs +229 -30
  46. package/src/built-site-scope.mjs +39 -6
  47. package/src/built-smoke-qc.mjs +1117 -0
  48. package/src/campaign-identity.mjs +36 -2
  49. package/src/cart-placeholders.mjs +730 -0
  50. package/src/cli.mjs +467 -143
  51. package/src/commercial-journey.mjs +65 -4
  52. package/src/commercial-parity.mjs +6 -1
  53. package/src/contrast.mjs +649 -0
  54. package/src/design-source-publication.mjs +110 -3
  55. package/src/doctor/checks.mjs +415 -33
  56. package/src/doctor/inspect.mjs +53 -2
  57. package/src/doctor/next-step.mjs +16 -7
  58. package/src/input-currency.mjs +394 -0
  59. package/src/input-refresh.mjs +708 -0
  60. package/src/invocation.mjs +5 -2
  61. package/src/local-preview-policy.mjs +1 -1
  62. package/src/local-proof.mjs +4 -1
  63. package/src/polish-browser.mjs +660 -141
  64. package/src/polish-capture.mjs +1 -1
  65. package/src/polish-deadline.mjs +7 -0
  66. package/src/polish-media-weight.mjs +492 -0
  67. package/src/polish-node.mjs +400 -28
  68. package/src/polish-readability.mjs +828 -0
  69. package/src/progress-node.mjs +19 -3
  70. package/src/qa-browser.mjs +439 -180
  71. package/src/qa-content-params.mjs +889 -0
  72. package/src/qa-node.mjs +116 -13
  73. package/src/qa-order-bump.mjs +22 -1
  74. package/src/qa-policy-links.mjs +1019 -0
  75. package/src/qa-tracking-params.mjs +1389 -0
  76. package/src/qa-url-privacy.mjs +168 -0
  77. package/src/qc-accept.mjs +482 -0
  78. package/src/qc-check-registry.mjs +91 -0
  79. package/src/qc-results.mjs +1341 -0
  80. package/src/readback.mjs +58 -10
  81. package/src/sdk-attribute-index.mjs +71 -0
  82. package/src/sdk-markup.mjs +2 -2
  83. package/src/sdk-storage-compatibility.mjs +63 -3
  84. package/src/source-prep.mjs +37 -7
  85. package/src/stage-ledger.mjs +235 -44
  86. package/src/stage-record.mjs +165 -33
  87. package/src/tooling-setup.mjs +1 -1
@@ -10,6 +10,14 @@ import {
10
10
  normalizePageLoadRoute,
11
11
  plainHttpDependencyFailures,
12
12
  } from "./polish-capture.mjs";
13
+ import {
14
+ buildMediaWeightCell,
15
+ buildMediaWeightRecord,
16
+ isProbeClock,
17
+ MEDIA_PROBE_LIMITS,
18
+ MEDIA_WEIGHT_PRODUCER,
19
+ } from "./polish-media-weight.mjs";
20
+ import { MEDIA_WEIGHT_SCHEMA } from "./qc-results.mjs";
13
21
  import {
14
22
  buildPolishPageLoadEvidence,
15
23
  evaluateHiddenEagerMediaCheckpoint,
@@ -27,8 +35,22 @@ import {
27
35
  POLISH_PRODUCER_TIMEOUT_ERROR_CODE,
28
36
  polishProducerCleanupError,
29
37
  polishProducerTimeoutError,
38
+ READABILITY_ADDED_RUN_MS,
39
+ READABILITY_CROP_RUN_MS,
40
+ READABILITY_PROBE_CELL_MS,
41
+ READABILITY_PROBE_RUN_MS,
30
42
  runWithPolishProducerDeadline,
31
43
  } from "./polish-deadline.mjs";
44
+ import {
45
+ buildReadabilityCell,
46
+ buildReadabilityRecord,
47
+ readabilityLimits,
48
+ readabilityObservationOk,
49
+ readabilityRoutes,
50
+ READABILITY_PRODUCER,
51
+ READABILITY_SCHEMA,
52
+ writeReadabilityCrop,
53
+ } from "./polish-readability.mjs";
32
54
  import {
33
55
  assemblySourcePackageMaterialFingerprint,
34
56
  currentSourcePackageMaterialFingerprint,
@@ -96,6 +118,9 @@ function mappedSpecRoute(value, pageId) {
96
118
 
97
119
  const NO_CAPTURABLE_ROUTES_ERROR = "no_capturable_routes";
98
120
 
121
+ // Whether planPolishCapture refused because every mapping is template stock.
122
+ export const isNoCapturableRoutesError = (error) => error?.code === NO_CAPTURABLE_ROUTES_ERROR;
123
+
99
124
  export function planPolishCapture({ packet, baseUrl } = {}) {
100
125
  if (!isPlainObject(packet) || !Array.isArray(packet?.source_html?.pages) || packet.source_html.pages.length === 0) {
101
126
  throw new Error("polish capture requires packet.source_html.pages mappings.");
@@ -285,9 +310,20 @@ function canonicalJson(value) {
285
310
  return JSON.stringify(canonicalize(value));
286
311
  }
287
312
 
313
+ // Covers every package-owned capture key: a page_load, media_weight or
314
+ // readability record that changes during the browser pass refuses the
315
+ // attachment.
288
316
  function conflictToken(visualReview) {
289
- if (!Object.hasOwn(visualReview, "page_load")) return "absent";
290
- return `sha256:${createHash("sha256").update(canonicalJson({ value: visualReview.page_load })).digest("hex")}`;
317
+ const hasPageLoad = Object.hasOwn(visualReview, "page_load");
318
+ const hasMediaWeight = Object.hasOwn(visualReview, "media_weight");
319
+ const hasReadability = Object.hasOwn(visualReview, "readability");
320
+ if (!hasPageLoad && !hasMediaWeight && !hasReadability) return "absent";
321
+ const value = {
322
+ ...(hasPageLoad ? { value: visualReview.page_load } : {}),
323
+ ...(hasMediaWeight ? { media_weight: visualReview.media_weight } : {}),
324
+ ...(hasReadability ? { readability: visualReview.readability } : {}),
325
+ };
326
+ return `sha256:${createHash("sha256").update(canonicalJson(value)).digest("hex")}`;
291
327
  }
292
328
 
293
329
  function captureReportAncestors(report) {
@@ -339,6 +375,8 @@ function bindingPlanProjection(plan) {
339
375
  };
340
376
  }
341
377
 
378
+ // `plan` is null for a campaign with no capturable page_load routes (every
379
+ // mapping is template stock), where the capture measures readability only.
342
380
  export function createPolishCaptureBinding({ packet, report, plan, packetPath, targetRepo } = {}) {
343
381
  if (!isPlainObject(packet) || packet.schema_version !== "campaign-runtime-build-packet/v0") {
344
382
  throw new Error("polish capture requires a current campaign-runtime-build-packet/v0 packet.");
@@ -408,10 +446,23 @@ export function createPolishCaptureBinding({ packet, report, plan, packetPath, t
408
446
  current_source_package_material_fingerprint: currentSourcePackageMaterialFingerprint(report),
409
447
  page_load_conflict_token: conflictToken(visualReview),
410
448
  },
411
- plan: bindingPlanProjection(plan),
449
+ plan: plan === null ? null : bindingPlanProjection(plan),
450
+ readability_routes: readabilityRouteProjection(resolvedTargetRepo, slug),
412
451
  });
413
452
  }
414
453
 
454
+ // The built routes readability captures, enumerated again for each binding,
455
+ // so a route set that changes during the browser pass refuses the attachment.
456
+ function readabilityRouteProjection(targetRepo, slug) {
457
+ const built = readabilityRoutes(targetRepo, slug);
458
+ return {
459
+ ok: built.ok === true,
460
+ routes: built.routes,
461
+ uncaptured_routes: built.uncaptured_routes,
462
+ route_enumeration_capped: built.route_enumeration_capped,
463
+ };
464
+ }
465
+
415
466
  export function assertPolishCaptureBindingUnchanged(initial, current) {
416
467
  if (canonicalJson(initial) !== canonicalJson(current)) {
417
468
  throw new Error(
@@ -446,6 +497,88 @@ export function mergePolishPageLoadEvidence(report, pageLoad) {
446
497
  };
447
498
  }
448
499
 
500
+ // Attaches the records of one capture. page_load attaches as
501
+ // mergePolishPageLoadEvidence does, stamped with `captured_at` when the
502
+ // command passes its clock `now` (the time of the merge, outside every
503
+ // capture's integrity). Its sibling media_weight attaches under the same
504
+ // producer and schema checks, bound to the same page_load subject; a capture
505
+ // without media_weight (an adapter that ran no image probe) removes any
506
+ // earlier media_weight. With no pageLoad (a campaign with no capturable
507
+ // page_load routes), page_load and media_weight stay exactly as they are.
508
+ // readability attaches on its own: only a package-produced record bound to
509
+ // the current build, whose every shared cell carries the integrity of the
510
+ // matching page_load capture; any other value, or none, removes an earlier
511
+ // one.
512
+ export function mergePolishCaptureEvidence(report, { pageLoad = null, mediaWeight = null, readability = null, now = null } = {}) {
513
+ let merged;
514
+ if (pageLoad === null || pageLoad === undefined) {
515
+ const { visualReview } = captureReportAncestors(report);
516
+ merged = {
517
+ ...report,
518
+ stages: {
519
+ ...report.stages,
520
+ polish: {
521
+ ...report.stages.polish,
522
+ evidence: { ...report.stages.polish.evidence, visual_review: { ...visualReview } },
523
+ },
524
+ },
525
+ };
526
+ } else {
527
+ merged = mergePolishPageLoadEvidence(report, now === null || now === undefined ? pageLoad : { ...pageLoad, captured_at: new Date(now).toISOString() });
528
+ delete merged.stages.polish.evidence.visual_review.media_weight;
529
+ if (mediaWeight !== null && mediaWeight !== undefined) {
530
+ if (!isPlainObject(mediaWeight)
531
+ || mediaWeight.schema_version !== MEDIA_WEIGHT_SCHEMA
532
+ || mediaWeight.performed_by !== MEDIA_WEIGHT_PRODUCER
533
+ || canonicalJson(mediaWeight.subject) !== canonicalJson(pageLoad.subject)) {
534
+ throw new Error("polish capture can attach only package-produced media_weight evidence for the same page_load capture.");
535
+ }
536
+ merged.stages.polish.evidence.visual_review.media_weight = mediaWeight;
537
+ }
538
+ }
539
+ const visualReview = merged.stages.polish.evidence.visual_review;
540
+ delete visualReview.readability;
541
+ if (readabilityAttaches(readability, { buildFingerprint: currentBuildFingerprint(report), pageLoad: visualReview.page_load })) {
542
+ visualReview.readability = readability;
543
+ }
544
+ return merged;
545
+ }
546
+
547
+ function readabilityAttaches(record, { buildFingerprint, pageLoad }) {
548
+ if (!isPlainObject(record)
549
+ || record.schema_version !== READABILITY_SCHEMA
550
+ || record.performed_by !== READABILITY_PRODUCER
551
+ || !buildFingerprint
552
+ || record.subject?.build_fingerprint !== buildFingerprint
553
+ || !Array.isArray(record.cells)) return false;
554
+ const captures = Array.isArray(pageLoad?.captures) ? pageLoad.captures : [];
555
+ return record.cells.every((cell) => cell?.page_load_integrity === null || captures.some((capture) => capture?.subject?.requested_route === cell.route
556
+ && capture?.subject?.viewport === cell.viewport
557
+ && capture?.integrity?.projection_fingerprint === cell.page_load_integrity));
558
+ }
559
+
560
+ // The spec pages the plan skips (no source mapping), for media_weight's
561
+ // page_not_captured results: the public route of each one that has a
562
+ // resolvable route, and the page id of each one that has none (a skipped
563
+ // mapping usually carries no page_kit), so no skipped page is left out.
564
+ function uncapturedPages(packet, plan) {
565
+ const captured = new Set(plan.routes.map((route) => route.requested_route));
566
+ const routes = [];
567
+ const pageIds = [];
568
+ for (const mapping of packet.source_html.pages) {
569
+ if (!nonemptyString(mapping?.skip_reason)) continue;
570
+ let route;
571
+ try {
572
+ route = mappedPublicRoute(mapping?.page_kit?.public_route, mapping?.page_id);
573
+ } catch {
574
+ pageIds.push(mapping.page_id);
575
+ continue;
576
+ }
577
+ if (!captured.has(route)) routes.push(route);
578
+ }
579
+ return { routes, pageIds };
580
+ }
581
+
449
582
  // The fingerprint of the built output the capture binds to. Every way of not
450
583
  // having one is a named refusal, parallel to the missing recorded value: no
451
584
  // built route root (build has not run here), an output the walk cannot read
@@ -493,9 +626,41 @@ function captureCampaignSlug(packet, report) {
493
626
  return packetSlug;
494
627
  }
495
628
 
496
- export async function capturePolishPageLoad({
629
+ export async function capturePolishPageLoad(options = {}) {
630
+ const plan = planPolishCapture({ packet: options.packet, baseUrl: options.baseUrl });
631
+ return runPolishCapture({ ...options, plan });
632
+ }
633
+
634
+ // The readability-only capture of a campaign with no capturable page_load
635
+ // routes (every mapping is template stock): every built route is a
636
+ // readability-only cell; page_load and media_weight are not produced. A
637
+ // browser that cannot start is recorded on every cell.
638
+ export async function capturePolishReadability(options = {}) {
639
+ captureBaseUrl(options.baseUrl);
640
+ return runPolishCapture({ ...options, plan: null });
641
+ }
642
+
643
+ // Readability for one run: the built routes (readabilityRoutes) when the
644
+ // adapter has a readability probe or did not start, else null (no record is
645
+ // produced).
646
+ function readabilityScope({ adapter, startupFailed, targetRepo, slug, plan, baseUrl }) {
647
+ if (!startupFailed && typeof adapter?.probeReadabilityRoute !== "function") return null;
648
+ if (!nonemptyString(targetRepo)) return null;
649
+ const built = readabilityRoutes(resolve(targetRepo), slug);
650
+ if (!built.ok) return null;
651
+ const base = captureBaseUrl(baseUrl);
652
+ const shared = new Set((plan?.routes || []).map((route) => route.requested_route));
653
+ return {
654
+ ...built,
655
+ shared,
656
+ only: built.routes.filter((route) => !shared.has(route)).map((route) => ({ route, url: new URL(route, base).href })),
657
+ };
658
+ }
659
+
660
+ async function runPolishCapture({
497
661
  packet,
498
662
  report,
663
+ plan,
499
664
  baseUrl,
500
665
  headed = false,
501
666
  authCookie = null,
@@ -503,8 +668,10 @@ export async function capturePolishPageLoad({
503
668
  adapterStartupDeadlineMs,
504
669
  captureCellDeadlineMs,
505
670
  adapterCloseDeadlineMs,
671
+ probeClock = null,
672
+ targetRepo = null,
673
+ now = new Date(),
506
674
  } = {}) {
507
- const plan = planPolishCapture({ packet, baseUrl });
508
675
  const slug = captureCampaignSlug(packet, report);
509
676
  const buildFingerprint = currentBuildFingerprint(report);
510
677
  if (!buildFingerprint) throw new Error("polish capture requires a current Assembly Report build fingerprint.");
@@ -522,6 +689,9 @@ export async function capturePolishPageLoad({
522
689
  );
523
690
 
524
691
  let adapter = null;
692
+ // Failure is tracked apart from its reason: a rejection may carry any value,
693
+ // including null or undefined.
694
+ let adapterStartupFailed = false;
525
695
  let adapterStartupError = null;
526
696
  let startupTimedOut = false;
527
697
  const adapterPromise = Promise.resolve().then(() => createBrowserAdapter({
@@ -544,14 +714,56 @@ export async function capturePolishPageLoad({
544
714
  throw new Error("polish capture browser adapter must provide captureRoute() and close().");
545
715
  }
546
716
  } catch (error) {
547
- adapterStartupError = error;
717
+ adapterStartupFailed = true;
718
+ adapterStartupError = error ?? new Error("polish capture browser adapter failed to start.");
548
719
  }
720
+ // A browser that did not start leaves every readability-only cell
721
+ // unmeasured: producer_timeout for a startup timeout, else navigation_failed
722
+ // (the status a shared cell reads for the same page_load failure).
723
+ const startupStatus = !adapterStartupFailed ? null
724
+ : adapterStartupError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE ? "producer_timeout" : "navigation_failed";
725
+
726
+ const scope = readabilityScope({ adapter, startupFailed: adapterStartupFailed, targetRepo, slug, plan, baseUrl });
727
+ const clock = isProbeClock(probeClock) ? probeClock : { now: () => performance.now() };
728
+ // The readability budgets, charged on `clock`: probe time, crop time, and
729
+ // all readability work (shared-cell probes and crops, and readability-only
730
+ // cells end to end). Probe and crop time are subtotals of the added work, so
731
+ // a shared cell's probe and crop time is charged once to its own budget and
732
+ // once to the added budget.
733
+ const spentMs = { probe: 0, crop: 0, added: 0 };
734
+ const readabilityProbe = () => ({
735
+ ...(isProbeClock(probeClock) ? { clock: probeClock } : {}),
736
+ cellBoundMs: READABILITY_PROBE_CELL_MS,
737
+ probeRemainingMs: READABILITY_PROBE_RUN_MS - spentMs.probe,
738
+ cropRemainingMs: READABILITY_CROP_RUN_MS - spentMs.crop,
739
+ addedRemainingMs: READABILITY_ADDED_RUN_MS - spentMs.added,
740
+ elementCap: readabilityLimits().elements_per_cell,
741
+ cropsPerCell: readabilityLimits().crops_per_cell,
742
+ });
743
+ const chargeReadability = (observation, addedMs) => {
744
+ if (Number.isFinite(observation?.probe_ms) && observation.probe_ms > 0) spentMs.probe += observation.probe_ms;
745
+ if (Number.isFinite(observation?.crop_ms) && observation.crop_ms > 0) spentMs.crop += observation.crop_ms;
746
+ if (Number.isFinite(addedMs) && addedMs > 0) spentMs.added += addedMs;
747
+ };
549
748
 
550
749
  const captures = [];
750
+ // Per cell, beside its capture: the adapter's observation and image probe,
751
+ // for the media_weight record.
752
+ const cellInputs = [];
753
+ // Readability-only cells, each { route, viewport, status, observation }.
754
+ const readabilityOnly = [];
755
+ let probeSpentMs = 0;
756
+ let probed = false;
757
+ // As for startup, a close failure is tracked apart from its reason.
758
+ let adapterCloseFailed = false;
551
759
  let adapterCloseError = null;
552
760
  let adapterPoisonProblem = null;
553
761
  let observedProducerTimeout = false;
554
762
  let adapterClosePromise = null;
763
+ // Whether page_load's cells were all run when the adapter failed to close:
764
+ // a close failure after readability-only cells never changes page_load.
765
+ let closeFailedAfterGrid = false;
766
+ let gridDone = false;
555
767
  const closeAdapter = async () => {
556
768
  if (!adapter || typeof adapter.close !== "function") return;
557
769
  if (!adapterClosePromise) {
@@ -561,20 +773,51 @@ export async function capturePolishPageLoad({
561
773
  }
562
774
  return adapterClosePromise;
563
775
  };
776
+ // A producer timeout or cleanup failure poisons the adapter for every later
777
+ // cell, and closes it.
778
+ const poison = async (error) => {
779
+ const producerTimedOut = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
780
+ const producerCleanupFailed = error?.code === POLISH_PRODUCER_CLEANUP_ERROR_CODE;
781
+ if (producerTimedOut) observedProducerTimeout = true;
782
+ if ((producerTimedOut || producerCleanupFailed) && adapter && !adapterPoisonProblem) {
783
+ adapterPoisonProblem = producerTimedOut ? "producer_timeout" : "producer_failed";
784
+ try {
785
+ await closeAdapter();
786
+ } catch (closeError) {
787
+ adapterCloseFailed = true;
788
+ adapterCloseError = closeError;
789
+ closeFailedAfterGrid = gridDone;
790
+ }
791
+ }
792
+ };
564
793
  try {
565
- for (const route of plan.routes) {
794
+ for (const route of plan?.routes || []) {
566
795
  for (const viewport of plan.viewports) {
567
796
  let observation;
797
+ let cellFailed = false;
798
+ const readabilityShared = scope?.shared.has(route.requested_route) && scope.routes.includes(route.requested_route);
568
799
  try {
569
- if (adapterStartupError) throw adapterStartupError;
800
+ if (adapterStartupFailed) throw adapterStartupError;
570
801
  if (adapterPoisonProblem) {
571
802
  throw adapterPoisonProblem === "producer_timeout"
572
803
  ? polishProducerTimeoutError()
573
804
  : polishProducerCleanupError();
574
805
  }
575
806
  const abortController = new AbortController();
807
+ const imageProbe = {
808
+ ...(isProbeClock(probeClock) ? { clock: probeClock } : {}),
809
+ remainingMs: MEDIA_PROBE_LIMITS.runBudgetMs - probeSpentMs,
810
+ cellBoundMs: MEDIA_PROBE_LIMITS.cellBoundMs,
811
+ imageCap: MEDIA_PROBE_LIMITS.imageCap,
812
+ };
576
813
  observation = await runWithPolishProducerDeadline(
577
- () => adapter.captureRoute({ url: route.url, viewport, signal: abortController.signal }),
814
+ () => adapter.captureRoute({
815
+ url: route.url,
816
+ viewport,
817
+ signal: abortController.signal,
818
+ imageProbe,
819
+ ...(readabilityShared ? { readabilityProbe: readabilityProbe() } : {}),
820
+ }),
578
821
  {
579
822
  timeoutMs: cellDeadlineMs,
580
823
  onTimeout() { abortController.abort(); },
@@ -584,16 +827,8 @@ export async function capturePolishPageLoad({
584
827
  } catch (error) {
585
828
  const browserUnavailable = error?.code === POLISH_BROWSER_UNAVAILABLE_ERROR_CODE;
586
829
  const producerTimedOut = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE;
587
- const producerCleanupFailed = error?.code === POLISH_PRODUCER_CLEANUP_ERROR_CODE;
588
- if (producerTimedOut) observedProducerTimeout = true;
589
- if ((producerTimedOut || producerCleanupFailed) && adapter && !adapterPoisonProblem) {
590
- adapterPoisonProblem = producerTimedOut ? "producer_timeout" : "producer_failed";
591
- try {
592
- await closeAdapter();
593
- } catch (closeError) {
594
- adapterCloseError = closeError;
595
- }
596
- }
830
+ await poison(error);
831
+ cellFailed = true;
597
832
  observation = {
598
833
  finalDocumentUrl: route.url,
599
834
  responseCollectionStatus: "failed",
@@ -603,6 +838,11 @@ export async function capturePolishPageLoad({
603
838
  : producerTimedOut ? "producer_timeout" : "producer_failed",
604
839
  };
605
840
  }
841
+ const probe = !cellFailed && isPlainObject(observation.imageProbe) ? observation.imageProbe : null;
842
+ if (probe) probed = true;
843
+ if (Number.isFinite(probe?.spent_ms) && probe.spent_ms > 0) probeSpentMs += probe.spent_ms;
844
+ if (readabilityShared && !cellFailed) chargeReadability(observation.readability, (observation.readability?.probe_ms || 0) + (observation.readability?.crop_ms || 0));
845
+ cellInputs.push({ route: route.requested_route, viewport: viewport.key, observation: cellFailed ? null : observation, probe });
606
846
  captures.push(buildPageLoadCapture({
607
847
  buildFingerprint,
608
848
  slug,
@@ -619,23 +859,77 @@ export async function capturePolishPageLoad({
619
859
  }));
620
860
  }
621
861
  }
862
+ gridDone = true;
863
+
864
+ // Readability-only cells, after every page_load cell, each in a fresh
865
+ // context, under the producer cell deadline and the run budgets. A cell
866
+ // that starts once the added budget is spent reads run_budget_exhausted;
867
+ // after a producer timeout, every later cell reads producer_timeout and
868
+ // none is tried again.
869
+ for (const { route, url } of scope?.only || []) {
870
+ for (const viewport of POLISH_CAPTURE_VIEWPORTS) {
871
+ const cell = { route, viewport: viewport.key, status: null, observation: null };
872
+ readabilityOnly.push(cell);
873
+ if (startupStatus) {
874
+ cell.status = startupStatus;
875
+ continue;
876
+ }
877
+ if (adapterPoisonProblem) {
878
+ cell.status = adapterPoisonProblem === "producer_timeout" ? "producer_timeout" : "navigation_failed";
879
+ continue;
880
+ }
881
+ if (!(READABILITY_ADDED_RUN_MS - spentMs.added > 0)) {
882
+ cell.status = "run_budget_exhausted";
883
+ continue;
884
+ }
885
+ const started = clock.now();
886
+ let observation = null;
887
+ try {
888
+ const abortController = new AbortController();
889
+ observation = await runWithPolishProducerDeadline(
890
+ () => adapter.probeReadabilityRoute({ route, url }, viewport, { signal: abortController.signal, probe: readabilityProbe() }),
891
+ {
892
+ timeoutMs: cellDeadlineMs,
893
+ onTimeout() { abortController.abort(); },
894
+ },
895
+ );
896
+ } catch (error) {
897
+ await poison(error);
898
+ cell.status = error?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE ? "producer_timeout" : "navigation_failed";
899
+ }
900
+ const elapsedMs = Math.max(0, clock.now() - started);
901
+ chargeReadability(observation, elapsedMs);
902
+ if (cell.status) continue;
903
+ // An observation outside the record vocabulary is no measurement: its
904
+ // probe ran out its bound on the clock, or returned nothing.
905
+ if (readabilityObservationOk(observation)) {
906
+ cell.status = observation.status;
907
+ cell.observation = observation;
908
+ } else {
909
+ cell.status = elapsedMs >= READABILITY_PROBE_CELL_MS ? "probe_timeout" : "document_changed";
910
+ }
911
+ }
912
+ }
622
913
  } finally {
623
- if (adapter && !adapterCloseError) {
914
+ if (adapter && !adapterCloseFailed) {
624
915
  try {
625
916
  await closeAdapter();
626
917
  } catch (error) {
918
+ adapterCloseFailed = true;
627
919
  adapterCloseError = error;
920
+ closeFailedAfterGrid = gridDone && readabilityOnly.length > 0;
628
921
  }
629
922
  }
630
923
  }
631
924
 
632
- if (adapterCloseError) {
633
- const producerProblem = observedProducerTimeout
634
- || adapterCloseError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE
635
- ? "producer_timeout"
636
- : "producer_failed";
925
+ const closeProblem = observedProducerTimeout
926
+ || adapterCloseError?.code === POLISH_PRODUCER_TIMEOUT_ERROR_CODE
927
+ ? "producer_timeout"
928
+ : "producer_failed";
929
+ if (adapterCloseFailed && !closeFailedAfterGrid) {
637
930
  captures.length = 0;
638
- for (const route of plan.routes) {
931
+ for (const input of cellInputs) Object.assign(input, { observation: null, probe: null });
932
+ for (const route of plan?.routes || []) {
639
933
  for (const viewport of plan.viewports) {
640
934
  captures.push(buildPageLoadCapture({
641
935
  buildFingerprint,
@@ -646,12 +940,29 @@ export async function capturePolishPageLoad({
646
940
  finalDocumentUrl: route.url,
647
941
  responseCollectionStatus: "failed",
648
942
  networkidle: { status: "invalid", duration_ms: null },
649
- producerProblem,
943
+ producerProblem: closeProblem,
650
944
  }));
651
945
  }
652
946
  }
653
947
  }
654
948
 
949
+ const readability = scope ? buildRunReadability({
950
+ scope,
951
+ cellInputs,
952
+ readabilityOnly,
953
+ captures,
954
+ // A browser that did not close cleanly leaves every readability cell
955
+ // unmeasured.
956
+ closeStatus: adapterCloseFailed ? (closeProblem === "producer_timeout" ? "producer_timeout" : "navigation_failed") : null,
957
+ report,
958
+ buildFingerprint,
959
+ slug,
960
+ targetRepo,
961
+ now,
962
+ }) : null;
963
+ const withReadability = (result) => (readability ? { ...result, readability } : result);
964
+ if (plan === null) return withReadability({ plan: null });
965
+
655
966
  const routes = plan.routes.map((route) => route.requested_route);
656
967
  const viewports = plan.viewports.map((viewport) => viewport.key);
657
968
  const pageLoad = buildPolishPageLoadEvidence({
@@ -662,5 +973,66 @@ export async function capturePolishPageLoad({
662
973
  viewports,
663
974
  captures,
664
975
  });
665
- return { plan, page_load: pageLoad };
976
+ // The image probe's sibling record, when the adapter ran the probe. Each
977
+ // cell is stamped with the integrity of its page_load capture.
978
+ if (!probed) return withReadability({ plan, page_load: pageLoad });
979
+ const uncaptured = uncapturedPages(packet, plan);
980
+ const mediaWeight = buildMediaWeightRecord({
981
+ pageLoad,
982
+ cells: cellInputs.map((input) => buildMediaWeightCell({
983
+ ...input,
984
+ capture: pageLoad.captures.find((capture) => capture.subject.requested_route === input.route
985
+ && capture.subject.viewport === input.viewport),
986
+ })),
987
+ uncapturedRoutes: uncaptured.routes,
988
+ uncapturedPageIds: uncaptured.pageIds,
989
+ });
990
+ return withReadability({ plan, page_load: pageLoad, media_weight: mediaWeight });
991
+ }
992
+
993
+ // The readability record of one run: one cell per captured built route ×
994
+ // viewport. A shared cell (a route in page_load's grid) carries its probe from
995
+ // the page_load cell and that capture's integrity; it reads producer_timeout
996
+ // or navigation_failed when its page_load cell failed, and document_changed
997
+ // when the cell returned no readability read. Readability-only cells carry
998
+ // their own status.
999
+ function buildRunReadability({ scope, cellInputs, readabilityOnly, captures, closeStatus, report, buildFingerprint, slug, targetRepo, now }) {
1000
+ const writeCrop = (bytes) => writeReadabilityCrop(resolve(targetRepo), bytes);
1001
+ const cells = [];
1002
+ for (const route of scope.routes) {
1003
+ for (const viewport of POLISH_CAPTURE_VIEWPORTS) {
1004
+ const key = viewport.key;
1005
+ if (scope.shared.has(route)) {
1006
+ const input = cellInputs.find((entry) => entry.route === route && entry.viewport === key);
1007
+ const capture = captures.find((entry) => entry.subject.requested_route === route && entry.subject.viewport === key);
1008
+ const failed = (Array.isArray(capture?.problems) ? capture.problems : []).find((problem) => ["producer_timeout", "producer_failed", "browser_unavailable"].includes(problem?.code));
1009
+ const observation = input?.observation?.readability;
1010
+ const status = closeStatus
1011
+ ?? (failed ? (failed.code === "producer_timeout" ? "producer_timeout" : "navigation_failed")
1012
+ : readabilityObservationOk(observation) ? observation.status : "document_changed");
1013
+ cells.push(buildReadabilityCell({
1014
+ route,
1015
+ viewport: key,
1016
+ pageLoadIntegrity: capture?.integrity?.projection_fingerprint ?? null,
1017
+ status,
1018
+ observation: status === "measured" ? observation : null,
1019
+ writeCrop,
1020
+ }));
1021
+ continue;
1022
+ }
1023
+ const only = readabilityOnly.find((entry) => entry.route === route && entry.viewport === key);
1024
+ const status = closeStatus ?? only?.status ?? "navigation_failed";
1025
+ cells.push(buildReadabilityCell({ route, viewport: key, status, observation: status === "measured" ? only.observation : null, writeCrop }));
1026
+ }
1027
+ }
1028
+ return buildReadabilityRecord({
1029
+ buildFingerprint,
1030
+ sourceFingerprint: currentSourcePackageMaterialFingerprint(report),
1031
+ slug,
1032
+ routes: scope.routes,
1033
+ uncapturedRoutes: scope.uncaptured_routes,
1034
+ routeEnumerationCapped: scope.route_enumeration_capped,
1035
+ cells,
1036
+ measuredAt: now,
1037
+ });
666
1038
  }