@erdemtuna/doc-review 0.8.1 → 0.9.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.
package/src/state.js CHANGED
@@ -5,6 +5,9 @@ import { normalizeCommentAnchor } from "./comment-anchor.js";
5
5
  import { canonicalTarget, ensureStateDir, pageKey, realFile, statePath, targetKey } from "./paths.js";
6
6
  export { atomicWrite } from "./atomic-write.js";
7
7
  import { atomicWrite } from "./atomic-write.js";
8
+ import { RevisionStore } from "./revision-store.js";
9
+ import { CAPTURE_LEASE_MS, HISTORY_SCHEMA_VERSION, normalizeHistoryTargets, revisionError } from "./revision-schema.js";
10
+ import { historyRevisionReferences, retainHistory } from "./history-policy.js";
8
11
 
9
12
  /** Anything untouched this long is review debris, not work in progress. */
10
13
  const PRUNE_AGE_MS = 30 * 24 * 60 * 60 * 1000;
@@ -12,11 +15,50 @@ const DELIVERY_STATES = new Set(["queued", "possibly_delivered", "delivered"]);
12
15
 
13
16
  const fresh = (entry, now) => !!entry && now - (entry.updatedAt || 0) < PRUNE_AGE_MS;
14
17
  const batchId = () => `b_${crypto.randomBytes(12).toString("hex")}`;
15
- const emptyState = () => ({ pages: {}, batches: {}, receipts: {} });
18
+ const emptyState = () => ({ pages: {}, batches: {}, receipts: {}, histories: {} });
19
+ const historyId = (prefix) => `${prefix}_${crypto.randomBytes(12).toString("hex")}`;
20
+
21
+ function historyRound(data, entryKey, roundId) {
22
+ return data.histories[entryKey]?.rounds.find((round) => round.roundId === roundId) || null;
23
+ }
24
+
25
+ function batchRound(data, entryKey, id) {
26
+ return data.histories[entryKey]?.rounds.find((round) => round.batchId === id) || null;
27
+ }
28
+
29
+ function publicRound(round) {
30
+ if (!round) return null;
31
+ const copy = structuredClone(round);
32
+ copy.sentAt ||= new Date(copy.createdAt).toISOString();
33
+ for (const target of copy.targets) {
34
+ target.captureStatus = target.capture?.status || "pending";
35
+ target.ownerSessionId = target.capture?.ownerSessionId || target.ownerSessionId || null;
36
+ }
37
+ return copy;
38
+ }
39
+
40
+ function finishCapture(round) {
41
+ const statuses = round.targets.map((target) => target.capture?.status || "pending");
42
+ if (statuses.every((status) => status === "ready" || status === "unavailable")) {
43
+ const fullContent = round.targets.every((target) => target.capture.status === "ready" &&
44
+ target.baselineCoverage?.semantic && target.resultCoverage?.semantic);
45
+ const anyResult = statuses.some((status) => status === "ready") ||
46
+ round.targets.some((target) => target.sourceResultRevisionId);
47
+ round.captureStatus = fullContent ? "ready" : anyResult ? "partial" : "failed";
48
+ round.completedAt ||= Date.now();
49
+ } else {
50
+ round.captureStatus = statuses.some((status) => status === "failed") ? "failed" : "pending";
51
+ }
52
+ }
16
53
 
17
54
  function pruneData(data, now = Date.now()) {
18
- let changed = false;
55
+ let changed = retainHistory(data);
19
56
  for (const [key, page] of Object.entries(data.pages)) {
57
+ const protectedPage = page.comments?.length || page.edits?.length || page.revisionRefs?.length ||
58
+ data.batches[key] || Object.values(data.batches).some((record) => record.cleanup.some((item) => item.key === key)) ||
59
+ Object.values(data.histories).some((history) => history.rounds.some((round) =>
60
+ round.targets.some((target) => target.key === key)));
61
+ if (protectedPage) continue;
20
62
  const missingFile = page.kind !== "url" && !fs.existsSync(page.file);
21
63
  if (!fresh(page, now) || missingFile) {
22
64
  delete data.pages[key];
@@ -24,13 +66,8 @@ function pruneData(data, now = Date.now()) {
24
66
  changed = true;
25
67
  }
26
68
  }
27
- for (const [key, batch] of Object.entries(data.batches)) {
28
- if (!fresh(batch, now)) {
29
- delete data.batches[key];
30
- changed = true;
31
- }
32
- }
33
69
  for (const [id, receipt] of Object.entries(data.receipts)) {
70
+ if (Object.values(data.histories).some((history) => history.rounds.some((round) => round.batchId === id))) continue;
34
71
  if (!fresh(receipt, now)) {
35
72
  delete data.receipts[id];
36
73
  changed = true;
@@ -47,8 +84,25 @@ function normalizeState(parsed, makeBatchId) {
47
84
  pages: parsed.pages,
48
85
  batches: parsed.batches && typeof parsed.batches === "object" ? parsed.batches : {},
49
86
  receipts: parsed.receipts && typeof parsed.receipts === "object" ? parsed.receipts : {},
87
+ histories: parsed.histories && typeof parsed.histories === "object" ? parsed.histories : {},
50
88
  };
51
89
  let changed = !parsed.batches || !parsed.receipts;
90
+ for (const history of Object.values(data.histories)) {
91
+ if (history?.version !== HISTORY_SCHEMA_VERSION || !Array.isArray(history.rounds) ||
92
+ !Number.isSafeInteger(history.nextOrdinal)) throw new Error("Invalid doc-review history.");
93
+ for (const round of history.rounds) {
94
+ if (!round?.roundId || !round.batchId || !Array.isArray(round.targets)) throw new Error("Invalid doc-review round.");
95
+ for (const target of round.targets) {
96
+ if (target.capture?.status === "running") {
97
+ target.capture = {
98
+ ...target.capture, captureId: historyId("cap"), status: "pending",
99
+ ownerSessionId: null, generation: null, leaseExpiresAt: 0, error: "context_lost",
100
+ };
101
+ changed = true;
102
+ }
103
+ }
104
+ }
105
+ }
52
106
  const normalizeAnchor = (comment) => {
53
107
  if (!comment || comment.anchor == null) return;
54
108
  const normalized = normalizeCommentAnchor(comment.kind === "element" ? "element" : "selection", comment.anchor);
@@ -109,10 +163,11 @@ function normalizeState(parsed, makeBatchId) {
109
163
  * means "your feedback is safe" stays true across server restarts.
110
164
  */
111
165
  export class Store {
112
- constructor({ write = atomicWrite, makeBatchId = batchId } = {}) {
166
+ constructor({ write = atomicWrite, makeBatchId = batchId, revisions = new RevisionStore() } = {}) {
113
167
  this.data = emptyState();
114
168
  this.write = write;
115
169
  this.makeBatchId = makeBatchId;
170
+ this.revisions = revisions;
116
171
  this.load();
117
172
  }
118
173
 
@@ -392,27 +447,61 @@ export class Store {
392
447
  return this.data.batches;
393
448
  }
394
449
 
395
- setBatch(entryKey, { batch, cleanup, deliveryState = "queued" }) {
450
+ setBatch(entryKey, { batch, cleanup, deliveryState = "queued", history } = {}, options = {}) {
451
+ const optionHistory = options.history || (options.targets ? options : undefined);
452
+ if (history && optionHistory) throw revisionError("Specify history context only once.");
453
+ history ||= optionHistory;
396
454
  if (!DELIVERY_STATES.has(deliveryState)) throw new Error(`Unknown delivery state: ${deliveryState}`);
455
+ const targets = history ? normalizeHistoryTargets(history.targets) : null;
456
+ for (const target of targets || []) {
457
+ if (!this.page(target.key)) throw revisionError("Unknown history document.");
458
+ if (target.baselineRevisionId) {
459
+ const manifest = this.revisions.verify(target.baselineRevisionId, target.key);
460
+ target.baselineCoverage = { source: !!manifest.source, semantic: !!manifest.semantic };
461
+ }
462
+ }
397
463
  return this.transaction((draft) => {
398
464
  const existing = draft.batches[entryKey];
465
+ const superseded = existing ? batchRound(draft, entryKey, existing.batch_id) : null;
466
+ if (superseded) {
467
+ superseded.feedbackStatus = "superseded";
468
+ superseded.supersededAt = Date.now();
469
+ superseded.captureStatus = "cancelled";
470
+ }
399
471
  if (existing && existing.delivery_state !== "queued") {
400
472
  draft.receipts[existing.batch_id] = {
401
473
  cleanup: existing.cleanup,
402
474
  delivery_state: existing.delivery_state,
475
+ batch: structuredClone(existing.batch),
403
476
  updatedAt: Date.now(),
404
477
  };
405
478
  }
406
479
  const id = batch.batch_id || this.makeBatchId();
407
- const storedBatch = { ...batch, batch_id: id };
480
+ const storedBatch = { ...structuredClone(batch), batch_id: id };
408
481
  const record = {
409
482
  batch_id: id,
410
483
  batch: storedBatch,
411
- cleanup,
484
+ cleanup: structuredClone(cleanup),
412
485
  delivery_state: deliveryState,
413
486
  updatedAt: Date.now(),
414
487
  };
415
488
  draft.batches[entryKey] = record;
489
+ if (targets) {
490
+ const historyState = draft.histories[entryKey] ||= {
491
+ version: HISTORY_SCHEMA_VERSION, entryKey, nextOrdinal: 1, rounds: [],
492
+ };
493
+ const round = {
494
+ roundId: historyId("round"), entryKey, ordinal: historyState.nextOrdinal++,
495
+ batchId: id, createdAt: Date.now(),
496
+ sentAt: new Date().toISOString(),
497
+ feedbackStatus: deliveryState === "delivered" ? "delivered" : "queued",
498
+ captureStatus: "pending",
499
+ targets: targets.map((target) => ({ ...target, resultRevisionId: null, capture: null })),
500
+ };
501
+ if (deliveryState === "delivered") round.deliveredFeedback = structuredClone(storedBatch);
502
+ historyState.rounds.push(round);
503
+ record.round_id = round.roundId;
504
+ }
416
505
  return record;
417
506
  });
418
507
  }
@@ -423,6 +512,12 @@ export class Store {
423
512
  const record = draft.batches[entryKey];
424
513
  record.delivery_state = "delivered";
425
514
  record.updatedAt = Date.now();
515
+ const round = batchRound(draft, entryKey, record.batch_id);
516
+ if (round) {
517
+ round.feedbackStatus = "delivered";
518
+ round.deliveredAt ||= Date.now();
519
+ round.deliveredFeedback ||= structuredClone(record.batch);
520
+ }
426
521
  return record;
427
522
  });
428
523
  }
@@ -442,13 +537,26 @@ export class Store {
442
537
  draft.receipts[id] = {
443
538
  cleanup: record.cleanup,
444
539
  delivery_state: "acknowledged",
540
+ batch: structuredClone(record.batch),
445
541
  updatedAt: Date.now(),
446
542
  };
543
+ const round = batchRound(draft, entryKey, id);
544
+ if (round) {
545
+ round.feedbackStatus = "acknowledged";
546
+ round.acknowledgedAt = Date.now();
547
+ round.deliveredFeedback ||= structuredClone(record.batch);
548
+ for (const target of round.targets) {
549
+ target.capture = {
550
+ captureId: historyId("cap"), status: "pending", ownerSessionId: null,
551
+ generation: null, attempt: 0, leaseExpiresAt: 0,
552
+ };
553
+ }
554
+ }
447
555
  const staged = [];
448
- const keys = [];
556
+ const keys = round ? round.targets.map((target) => target.key) : [];
449
557
  for (const { key, ids, staged: assets = [], sentAt } of record.cleanup) {
450
558
  staged.push(...assets);
451
- keys.push(key);
559
+ if (!keys.includes(key)) keys.push(key);
452
560
  const page = draft.pages[key];
453
561
  if (!page) continue;
454
562
  const drop = new Set(ids);
@@ -459,10 +567,127 @@ export class Store {
459
567
  : [];
460
568
  page.updatedAt = Date.now();
461
569
  }
462
- return { acknowledged: true, staged, keys };
570
+ return { acknowledged: true, staged, keys, ...(round ? { roundId: round.roundId } : {}) };
463
571
  });
464
572
  }
465
573
 
574
+ listHistory(entryKey) {
575
+ return (this.data.histories[entryKey]?.rounds || []).map(publicRound).sort((a, b) => b.ordinal - a.ordinal);
576
+ }
577
+
578
+ getRound(entryKey, roundId) {
579
+ return publicRound(historyRound(this.data, entryKey, roundId));
580
+ }
581
+
582
+ recordSourceResult(entryKey, roundId, key, { revisionId, unavailable } = {}) {
583
+ if (revisionId && unavailable) throw revisionError("Source result availability is ambiguous.");
584
+ if (revisionId) {
585
+ const manifest = this.revisions.verify(revisionId, key);
586
+ if (!manifest.source) throw revisionError("Source result must contain a source snapshot.");
587
+ } else if (typeof unavailable !== "string" || !unavailable || unavailable.length > 500) {
588
+ throw revisionError("Source result needs a revision or unavailable reason.");
589
+ }
590
+ const existing = historyRound(this.data, entryKey, roundId);
591
+ const existingTarget = existing?.targets.find((target) => target.key === key);
592
+ if (existing?.feedbackStatus !== "acknowledged" || !existingTarget) throw revisionError("Source capture is not pending.");
593
+ if (existingTarget.sourceResultRevisionId) {
594
+ if (existingTarget.sourceResultRevisionId === revisionId) return { accepted: false, duplicate: true, round: publicRound(existing) };
595
+ throw revisionError("The source result is already frozen.", "CAPTURE_FINALIZED");
596
+ }
597
+ if (existing.completedAt || existingTarget.resultRevisionId || existingTarget.capture?.status === "unavailable") {
598
+ throw revisionError("The target capture is already finalized.", "CAPTURE_FINALIZED");
599
+ }
600
+ const result = this.transaction((draft) => {
601
+ const round = historyRound(draft, entryKey, roundId);
602
+ const target = round.targets.find((item) => item.key === key);
603
+ if (revisionId) {
604
+ target.sourceResultRevisionId = revisionId;
605
+ delete target.sourceResultUnavailable;
606
+ } else target.sourceResultUnavailable = unavailable;
607
+ return round;
608
+ });
609
+ return { accepted: true, round: publicRound(result) };
610
+ }
611
+
612
+ claimCapture(entryKey, roundId, key, { ownerSessionId, generation, leaseMs = CAPTURE_LEASE_MS } = {}) {
613
+ if (typeof ownerSessionId !== "string" || !ownerSessionId || ownerSessionId.length > 200 ||
614
+ !Number.isSafeInteger(generation) || generation < 0 ||
615
+ !Number.isSafeInteger(leaseMs) || leaseMs <= 0 || leaseMs > 5 * CAPTURE_LEASE_MS) {
616
+ throw revisionError("Invalid capture ownership.");
617
+ }
618
+ const result = this.transaction((draft) => {
619
+ const round = historyRound(draft, entryKey, roundId);
620
+ const target = round?.targets.find((item) => item.key === key);
621
+ if (round?.feedbackStatus !== "acknowledged" || !target?.capture) throw revisionError("Capture is not pending.");
622
+ if (target.capture.status === "ready" || target.capture.status === "unavailable") throw revisionError("Capture is already finalized.", "CAPTURE_FINALIZED");
623
+ if (target.capture.status === "running" && target.capture.leaseExpiresAt > Date.now()) {
624
+ if (target.capture.ownerSessionId === ownerSessionId && target.capture.generation === generation) return target.capture;
625
+ throw revisionError("Another frame owns this capture.", "CAPTURE_CONFLICT");
626
+ }
627
+ target.capture = {
628
+ captureId: historyId("cap"), status: "running", ownerSessionId, generation,
629
+ attempt: target.capture.attempt + 1, leaseExpiresAt: Date.now() + leaseMs,
630
+ };
631
+ round.captureStatus = "pending";
632
+ return target.capture;
633
+ });
634
+ return structuredClone(result);
635
+ }
636
+
637
+ recordCaptureResult(entryKey, roundId, key, { captureId, ownerSessionId, generation, revisionId } = {}) {
638
+ const manifest = this.revisions.verify(revisionId, key);
639
+ const result = this.transaction((draft) => {
640
+ const round = historyRound(draft, entryKey, roundId);
641
+ const target = round?.targets.find((item) => item.key === key);
642
+ const capture = target?.capture;
643
+ if (capture?.status === "ready" && capture.captureId === captureId &&
644
+ capture.ownerSessionId === ownerSessionId && capture.generation === generation &&
645
+ target.resultRevisionId === revisionId) return { accepted: false, duplicate: true, round };
646
+ this.assertCapture(round, capture, { captureId, ownerSessionId, generation });
647
+ target.resultRevisionId = revisionId;
648
+ target.resultCoverage = { source: !!manifest.source, semantic: !!manifest.semantic };
649
+ capture.status = "ready";
650
+ capture.capturedAt = Date.now();
651
+ capture.leaseExpiresAt = 0;
652
+ finishCapture(round);
653
+ return { accepted: true, round };
654
+ });
655
+ return structuredClone(result);
656
+ }
657
+
658
+ markCaptureUnavailable(entryKey, roundId, key, { captureId, ownerSessionId, generation, reason, final = false } = {}) {
659
+ if (typeof reason !== "string" || !reason || reason.length > 500) throw revisionError("Invalid capture failure reason.");
660
+ const result = this.transaction((draft) => {
661
+ const round = historyRound(draft, entryKey, roundId);
662
+ const target = round?.targets.find((item) => item.key === key);
663
+ const capture = target?.capture;
664
+ const unowned = round?.feedbackStatus === "acknowledged" &&
665
+ (capture?.status === "pending" || capture?.status === "failed") &&
666
+ !capture.ownerSessionId && capture.captureId === captureId &&
667
+ !ownerSessionId && generation == null;
668
+ if (!unowned) this.assertCapture(round, capture, { captureId, ownerSessionId, generation });
669
+ capture.status = final ? "unavailable" : "failed";
670
+ capture.error = reason;
671
+ capture.leaseExpiresAt = 0;
672
+ if (final) target.resultUnavailable = reason;
673
+ finishCapture(round);
674
+ return { accepted: true, round };
675
+ });
676
+ return structuredClone(result);
677
+ }
678
+
679
+ assertCapture(round, capture, { captureId, ownerSessionId, generation }) {
680
+ if (round?.feedbackStatus !== "acknowledged" || capture?.status !== "running" ||
681
+ capture.captureId !== captureId || capture.ownerSessionId !== ownerSessionId ||
682
+ capture.generation !== generation || capture.leaseExpiresAt <= Date.now()) {
683
+ throw revisionError("Capture response is stale or belongs to another frame.", "CAPTURE_CONFLICT");
684
+ }
685
+ }
686
+
687
+ collectHistoryGarbage(options) {
688
+ return this.revisions.collectGarbage(historyRevisionReferences(this.data), options);
689
+ }
690
+
466
691
  clearBatch(entryKey) {
467
692
  if (!this.batch(entryKey)) return null;
468
693
  return this.transaction((draft) => {
@@ -0,0 +1,99 @@
1
+ const MAX_TABS = 32;
2
+ const MAX_ID = 256;
3
+ const unknown = (reason) => ({ version: 1, status: "unverified", tabs: [], reason });
4
+
5
+ export function normalizeView(value) {
6
+ if (value === undefined || value === null) return unknown("View identity was not recorded.");
7
+ const invalid = () => { throw Object.assign(new Error("Invalid captured view identity."), { code: "INVALID_REVISION", status: 400 }); };
8
+ if (value.version !== 1 || !["identified", "unverified"].includes(value.status) ||
9
+ !Array.isArray(value.tabs) || value.tabs.length > MAX_TABS) invalid();
10
+ if (value.status === "unverified") {
11
+ if (value.tabs.length || (value.reason !== undefined && (typeof value.reason !== "string" || value.reason.length > 500))) invalid();
12
+ return unknown(value.reason || "View identity could not be verified.");
13
+ }
14
+ if (!value.tabs.length) invalid();
15
+ const groups = new Set();
16
+ const tabs = value.tabs.map((tab) => {
17
+ if (!tab || typeof tab !== "object") invalid();
18
+ for (const key of ["groupId", "tabId", "panelId", "label"]) {
19
+ if (typeof tab[key] !== "string" || !tab[key] || tab[key].length > MAX_ID) invalid();
20
+ }
21
+ if (groups.has(tab.groupId)) invalid();
22
+ groups.add(tab.groupId);
23
+ return { groupId: tab.groupId, tabId: tab.tabId, panelId: tab.panelId, label: tab.label };
24
+ }).sort((a, b) => a.groupId.localeCompare(b.groupId));
25
+ return { version: 1, status: "identified", tabs };
26
+ }
27
+
28
+ function viewKey(view) {
29
+ const normalized = normalizeView(view);
30
+ return JSON.stringify([normalized.status, normalized.tabs.map(({ groupId, tabId, panelId }) => [groupId, tabId, panelId])]);
31
+ }
32
+
33
+ export function sameObservedView(before, after) {
34
+ return viewKey(before) === viewKey(after);
35
+ }
36
+
37
+ export function compareCapturedViews(before, after) {
38
+ const left = normalizeView(before);
39
+ const right = normalizeView(after);
40
+ if (left.status === "identified") {
41
+ if (right.status === "identified" && sameObservedView(left, right)) {
42
+ return { status: "matched", message: `Matching visible view: ${left.tabs.map((tab) => tab.label).join(" / ")}.` };
43
+ }
44
+ return {
45
+ status: "mismatch",
46
+ message: `Return to ${left.tabs.map((tab) => tab.label).join(" / ")} to capture the result.${right.status === "unverified" ? " The current tab identity could not be verified." : ""}`,
47
+ };
48
+ }
49
+ return { status: "unverified", message: "Visible content only; matching view not verified." };
50
+ }
51
+
52
+ export function observeView(document) {
53
+ const started = Date.now();
54
+ let visited = 0;
55
+ let limited = false;
56
+ const visible = (element) => {
57
+ for (let node = element; node; node = node.parentElement) {
58
+ if (++visited > 5000 || Date.now() - started > 500) {
59
+ limited = true;
60
+ return false;
61
+ }
62
+ if (node.hidden || node.getAttribute("aria-hidden") === "true" || node.hasAttribute("data-eh-ui")) return false;
63
+ const style = document.defaultView.getComputedStyle(node);
64
+ if (style.display === "none" || style.visibility === "hidden") return false;
65
+ }
66
+ return true;
67
+ };
68
+ const uniqueId = (element) => {
69
+ if (!element?.id || element.id.length > MAX_ID) return false;
70
+ const escaped = element.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/[\n\r\f]/g, "");
71
+ return document.querySelectorAll(`[id="${escaped}"]`).length === 1;
72
+ };
73
+ const candidates = document.querySelectorAll('[role="tablist"]');
74
+ if (candidates.length > MAX_TABS) return unknown("Too many tab groups to identify reliably.");
75
+ const groups = [...candidates].filter(visible);
76
+ if (limited) return unknown("View identity observation exceeded its limits.");
77
+ if (!groups.length) return unknown("No reliably identifiable tab groups were found.");
78
+ if (groups.length > MAX_TABS) return unknown("Too many tab groups to identify reliably.");
79
+ const tabs = [];
80
+ for (const group of groups) {
81
+ const members = [...group.querySelectorAll('[role="tab"]')].filter((tab) => tab.closest('[role="tablist"]') === group);
82
+ if (members.length > 128 || Date.now() - started > 500) return unknown("View identity observation exceeded its limits.");
83
+ const selected = members.filter((tab) => tab.getAttribute("aria-selected") === "true");
84
+ if (selected.length !== 1) return unknown("A tab group does not have exactly one selected tab.");
85
+ const tab = selected[0];
86
+ const panelId = tab.getAttribute("aria-controls");
87
+ const panel = panelId && document.getElementById(panelId);
88
+ if (!visible(tab) || !uniqueId(tab) || !uniqueId(panel) || panel.getAttribute("role") !== "tabpanel" || !visible(panel)) {
89
+ return unknown("Selected tabs and visible panels do not have unique stable identifiers.");
90
+ }
91
+ // A complete set of authored tab IDs identifies an otherwise unnamed tablist.
92
+ if (!members.length || members.some((member) => !uniqueId(member))) return unknown("Tab identifiers are incomplete.");
93
+ const groupId = uniqueId(group) ? `id:${group.id}` : `tabs:${JSON.stringify(members.map((member) => member.id).sort())}`;
94
+ if (groupId.length > MAX_ID) return unknown("Tab group identity exceeds the capture limit.");
95
+ const label = (tab.getAttribute("aria-label") || tab.textContent || tab.id).replace(/\s+/g, " ").trim().slice(0, MAX_ID);
96
+ tabs.push({ groupId, tabId: tab.id, panelId, label: label || tab.id });
97
+ }
98
+ return normalizeView({ version: 1, status: "identified", tabs });
99
+ }