@beyondwork/docx-react-component 1.0.59 → 1.0.60

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@beyondwork/docx-react-component",
3
3
  "publisher": "beyondwork",
4
- "version": "1.0.59",
4
+ "version": "1.0.60",
5
5
  "description": "Embeddable React Word (docx) editor with review, comments, tracked changes, and round-trip OOXML fidelity.",
6
6
  "packageManager": "pnpm@10.30.3",
7
7
  "type": "module",
@@ -1257,6 +1257,7 @@ export type EditorWarningCode =
1257
1257
  | "export_roundtrip_risk"
1258
1258
  | "comment_anchor_detached"
1259
1259
  | "revision_anchor_detached"
1260
+ | "workflow_scope_invalidated"
1260
1261
  | "large_document_degraded"
1261
1262
  | "font_substitution"
1262
1263
  | "image_missing"
@@ -1350,6 +1351,7 @@ export type EditorDiagnosticCode =
1350
1351
  | "export.roundtrip_risk"
1351
1352
  | "review.comment_anchor_detached"
1352
1353
  | "review.revision_anchor_detached"
1354
+ | "runtime.workflow_scope_invalidated"
1353
1355
  | "runtime.large_document_degraded"
1354
1356
  | "runtime.font_substitution"
1355
1357
  | "runtime.image_missing"
@@ -31,6 +31,7 @@ export type EditorWarningCode =
31
31
  | "export_roundtrip_risk"
32
32
  | "comment_anchor_detached"
33
33
  | "revision_anchor_detached"
34
+ | "workflow_scope_invalidated"
34
35
  | "large_document_degraded"
35
36
  | "font_substitution"
36
37
  | "image_missing"
@@ -51,6 +52,7 @@ export interface EditorWarning {
51
52
  affectedAnchor?: EditorAnchorProjection;
52
53
  featureEntryId?: string;
53
54
  details?: Record<string, unknown>;
55
+ diagnostic?: import("../../api/public-types.ts").EditorDiagnostic;
54
56
  }
55
57
 
56
58
  export interface EditorError {
@@ -13,6 +13,7 @@ import type {
13
13
  WorkflowWorkItem,
14
14
  } from "../../api/public-types.ts";
15
15
  import type { EditorStateNamespace, EditorStateLocation } from "../../api/editor-state-types.ts";
16
+ import { sha256Hex } from "../source-package-provenance.ts";
16
17
  import {
17
18
  validateWorkflowPayloadEnvelope,
18
19
  type ValidatorIssue,
@@ -42,6 +43,8 @@ export interface EditorStatePayload {
42
43
  unknownNamespaces?: Array<{ name: string; rawXml: string }>;
43
44
  }
44
45
 
46
+ const workflowAnchorHashEncoder = new TextEncoder();
47
+
45
48
  // ---------------------------------------------------------------------------
46
49
  // Schema 1.1 parser helpers (fail-closed per spec §8.2)
47
50
  // ---------------------------------------------------------------------------
@@ -747,7 +750,9 @@ function createAnchorBindingHash(
747
750
  from: number,
748
751
  to: number,
749
752
  ): string {
750
- return `${serializeStoryTargetKey(storyTarget)}:${from}:${to}`;
753
+ return sha256Hex(
754
+ workflowAnchorHashEncoder.encode(`${serializeStoryTargetKey(storyTarget)}:${from}:${to}`),
755
+ );
751
756
  }
752
757
 
753
758
  function getPreservedExtensionsXml(sourcePackage: OpcPackage, payloadPartPath: string): string {
@@ -28,6 +28,7 @@ export type EditorWarningCode =
28
28
  | "export_roundtrip_risk"
29
29
  | "comment_anchor_detached"
30
30
  | "revision_anchor_detached"
31
+ | "workflow_scope_invalidated"
31
32
  | "large_document_degraded"
32
33
  | "font_substitution"
33
34
  | "image_missing"
@@ -235,6 +236,7 @@ const EDITOR_WARNING_CODES = new Set<EditorWarningCode>([
235
236
  "export_roundtrip_risk",
236
237
  "comment_anchor_detached",
237
238
  "revision_anchor_detached",
239
+ "workflow_scope_invalidated",
238
240
  "large_document_degraded",
239
241
  "font_substitution",
240
242
  "image_missing",
@@ -131,6 +131,7 @@ export function buildDiagnosticFromLegacyWarningCode(
131
131
  | "export_roundtrip_risk"
132
132
  | "comment_anchor_detached"
133
133
  | "revision_anchor_detached"
134
+ | "workflow_scope_invalidated"
134
135
  | "large_document_degraded"
135
136
  | "font_substitution"
136
137
  | "image_missing",
@@ -143,6 +144,7 @@ export function buildDiagnosticFromLegacyWarningCode(
143
144
  export_roundtrip_risk: "export.roundtrip_risk",
144
145
  comment_anchor_detached: "review.comment_anchor_detached",
145
146
  revision_anchor_detached: "review.revision_anchor_detached",
147
+ workflow_scope_invalidated: "runtime.workflow_scope_invalidated",
146
148
  large_document_degraded: "runtime.large_document_degraded",
147
149
  font_substitution: "runtime.font_substitution",
148
150
  image_missing: "runtime.image_missing",
@@ -80,6 +80,15 @@ export const CODE_METADATA_TABLE: Readonly<
80
80
  "review.revision_anchor_detached": row("review", "info",
81
81
  "A tracked change's anchor was invalidated by a structural edit.",
82
82
  "retry-safe", { kind: "none" }, ["review", "tracked-change"], "review-revision-anchor-detached"),
83
+ "runtime.workflow_scope_invalidated": row("runtime", "warning",
84
+ "A workflow scope lost its trusted anchor and is no longer enforcing against live text.",
85
+ "requires-input",
86
+ {
87
+ kind: "fallback",
88
+ suggestion:
89
+ "Use warning.details.scopeId + warning.details.lastKnownRange to relocate the intended text, then reapply the scope.",
90
+ },
91
+ ["runtime", "workflow", "scope"], "runtime-workflow-scope-invalidated"),
83
92
  "runtime.large_document_degraded": row("runtime", "warning",
84
93
  "The document is large enough that some rendering optimizations have been relaxed.",
85
94
  "retry-safe", { kind: "none" }, ["runtime", "perf"], "runtime-large-document-degraded"),
@@ -150,6 +150,7 @@ import {
150
150
  findScopesIntersecting,
151
151
  resolveScope,
152
152
  } from "./scope-resolver.ts";
153
+ import { buildDiagnosticFromLegacyWarningCode } from "./diagnostics/build-diagnostic.ts";
153
154
  import {
154
155
  projectScopeQueryResults,
155
156
  queryScopes as runQueryScopes,
@@ -872,6 +873,7 @@ export function createDocumentRuntime(
872
873
  options.initialSessionState?.workflowMetadata?.entries
873
874
  ?? options.initialSnapshot?.workflowMetadata?.entries
874
875
  ?? [];
876
+ let markerBackedScopeIds = new Set<string>();
875
877
  let hostAnnotationOverlay: HostAnnotationOverlay | null = null;
876
878
  // §C7 — local view-state for scope chrome visibility; never collab-replicated.
877
879
  let scopeChromeVisibilityState: ScopeChromeVisibilityState = { mode: "all" };
@@ -909,6 +911,32 @@ export function createDocumentRuntime(
909
911
  storySelections.set(storyTargetKey(MAIN_STORY_TARGET), state.selection);
910
912
  lastHeadingFingerprint = computeHeadingFingerprint(state.document);
911
913
 
914
+ function syncMarkerBackedScopeIds(
915
+ document: CanonicalDocumentEnvelope,
916
+ overlay: WorkflowOverlay | null,
917
+ ): void {
918
+ const presentScopeIds = new Set(collectScopeLocations(document).keys());
919
+ if (!overlay) {
920
+ markerBackedScopeIds = presentScopeIds;
921
+ return;
922
+ }
923
+ const overlayScopeIds = new Set(overlay.scopes.map((scope) => scope.scopeId));
924
+ const next = new Set<string>();
925
+ for (const scopeId of markerBackedScopeIds) {
926
+ if (overlayScopeIds.has(scopeId)) {
927
+ next.add(scopeId);
928
+ }
929
+ }
930
+ for (const scopeId of presentScopeIds) {
931
+ if (overlayScopeIds.has(scopeId)) {
932
+ next.add(scopeId);
933
+ }
934
+ }
935
+ markerBackedScopeIds = next;
936
+ }
937
+
938
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
939
+
912
940
  // Runtime-owned paginated layout engine (Phase 1+ of the layout facet work).
913
941
  // The engine caches graph + resolved-formatting + fragment mapper keyed on
914
942
  // (content, styles, subParts). It is the single internal source of truth
@@ -1924,19 +1952,37 @@ export function createDocumentRuntime(
1924
1952
  return scope;
1925
1953
  }
1926
1954
  const location = locations.get(scope.scopeId);
1955
+ const isMarkerBacked = markerBackedScopeIds.has(scope.scopeId);
1956
+ let nextAnchor: EditorAnchorProjection | null = null;
1927
1957
  if (
1928
- !location ||
1929
- location.startPos === undefined ||
1930
- location.endPos === undefined
1958
+ location &&
1959
+ location.startPos !== undefined &&
1960
+ location.endPos !== undefined
1931
1961
  ) {
1962
+ nextAnchor = {
1963
+ kind: "range",
1964
+ from: Math.min(location.startPos, location.endPos),
1965
+ to: Math.max(location.startPos, location.endPos),
1966
+ assoc: { start: -1, end: 1 },
1967
+ };
1968
+ } else if (isMarkerBacked) {
1969
+ const lastKnownRange =
1970
+ scope.anchor.kind === "range"
1971
+ ? { from: scope.anchor.from, to: scope.anchor.to }
1972
+ : scope.anchor.kind === "node"
1973
+ ? { from: scope.anchor.at, to: scope.anchor.at }
1974
+ : scope.anchor.lastKnownRange;
1975
+ nextAnchor = {
1976
+ kind: "detached",
1977
+ reason:
1978
+ location && (location.startPos !== undefined || location.endPos !== undefined)
1979
+ ? "deleted"
1980
+ : "invalidatedByStructureChange",
1981
+ lastKnownRange,
1982
+ };
1983
+ } else {
1932
1984
  return scope;
1933
1985
  }
1934
- const nextAnchor: EditorAnchorProjection = {
1935
- kind: "range",
1936
- from: Math.min(location.startPos, location.endPos),
1937
- to: Math.max(location.startPos, location.endPos),
1938
- assoc: { start: -1, end: 1 },
1939
- };
1940
1986
  if (workflowAnchorsEqual(scope.anchor, nextAnchor)) {
1941
1987
  return scope;
1942
1988
  }
@@ -1966,6 +2012,161 @@ export function createDocumentRuntime(
1966
2012
  return normalizeWorkflowOverlayForDocument(state.document, workflowOverlay);
1967
2013
  }
1968
2014
 
2015
+ function buildWarningSignature(warning: InternalEditorWarning): string {
2016
+ return JSON.stringify({
2017
+ code: warning.code,
2018
+ severity: warning.severity,
2019
+ message: warning.message,
2020
+ source: warning.source,
2021
+ featureEntryId: warning.featureEntryId ?? null,
2022
+ details: warning.details ?? null,
2023
+ affectedAnchor: warning.affectedAnchor ?? null,
2024
+ });
2025
+ }
2026
+
2027
+ function mergeDetachedWorkflowScopeWarnings(
2028
+ overlay: WorkflowOverlay | null,
2029
+ existingWarnings: readonly InternalEditorWarning[],
2030
+ ): {
2031
+ nextWarnings: InternalEditorWarning[];
2032
+ added: InternalEditorWarning[];
2033
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
2034
+ } {
2035
+ const detachedScopesById = new Map<string, WorkflowScope>();
2036
+ for (const scope of overlay?.scopes ?? []) {
2037
+ if (scope.anchor.kind === "detached") {
2038
+ detachedScopesById.set(scope.scopeId, scope);
2039
+ }
2040
+ }
2041
+
2042
+ const retainedWarnings = existingWarnings.filter(
2043
+ (warning) => warning.code !== "workflow_scope_invalidated",
2044
+ );
2045
+ const existingDetachedWarnings = existingWarnings.filter(
2046
+ (warning) => warning.code === "workflow_scope_invalidated",
2047
+ );
2048
+ const existingById = new Map(
2049
+ existingDetachedWarnings.map((warning) => [warning.warningId, warning] as const),
2050
+ );
2051
+ const desiredById = new Map(
2052
+ [...detachedScopesById.values()].map((scope) => {
2053
+ const warning = createInvalidatedWorkflowScopeWarning(scope);
2054
+ return [warning.warningId, warning] as const;
2055
+ }),
2056
+ );
2057
+
2058
+ const added: InternalEditorWarning[] = [];
2059
+ const cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }> = [];
2060
+
2061
+ for (const [warningId, existingWarning] of existingById) {
2062
+ const desiredWarning = desiredById.get(warningId);
2063
+ if (!desiredWarning) {
2064
+ cleared.push({ warningId, code: existingWarning.code });
2065
+ continue;
2066
+ }
2067
+ if (buildWarningSignature(existingWarning) !== buildWarningSignature(desiredWarning)) {
2068
+ cleared.push({ warningId, code: existingWarning.code });
2069
+ added.push(desiredWarning);
2070
+ }
2071
+ }
2072
+
2073
+ for (const [warningId, desiredWarning] of desiredById) {
2074
+ if (!existingById.has(warningId)) {
2075
+ added.push(desiredWarning);
2076
+ }
2077
+ }
2078
+
2079
+ return {
2080
+ nextWarnings: [...retainedWarnings, ...desiredById.values()],
2081
+ added,
2082
+ cleared,
2083
+ };
2084
+ }
2085
+
2086
+ function createInvalidatedWorkflowScopeWarning(
2087
+ scope: WorkflowScope,
2088
+ ): InternalEditorWarning {
2089
+ const anchor = scope.anchor.kind === "detached" ? scope.anchor : null;
2090
+ const subject = scope.label
2091
+ ? `Workflow scope "${scope.label}" (${scope.scopeId})`
2092
+ : `Workflow scope ${scope.scopeId}`;
2093
+ const reasonPhrase =
2094
+ anchor?.reason === "deleted"
2095
+ ? "its anchored text was deleted"
2096
+ : anchor?.reason === "invalidatedByStructureChange"
2097
+ ? "document structure changed around it"
2098
+ : "its anchor could not be resolved unambiguously";
2099
+ const modePhrase =
2100
+ scope.mode === "view"
2101
+ ? "read-only enforcement"
2102
+ : `${scope.mode} enforcement`;
2103
+
2104
+ return {
2105
+ warningId: `warning:workflow-scope-invalidated:${scope.scopeId}`,
2106
+ code: "workflow_scope_invalidated",
2107
+ severity: "warning",
2108
+ message: `${subject} was invalidated because ${reasonPhrase}. Reapply the scope before relying on ${modePhrase}.`,
2109
+ source: "runtime",
2110
+ affectedAnchor: anchor ? toInternalAnchorProjection(anchor) : undefined,
2111
+ diagnostic: buildDiagnosticFromLegacyWarningCode("workflow_scope_invalidated", {
2112
+ diagnosticId: `warning-diag:workflow-scope-invalidated:${scope.scopeId}`,
2113
+ technical: {
2114
+ message: `${subject} lost its trusted anchor and is now detached.`,
2115
+ source: "runtime",
2116
+ },
2117
+ details: {
2118
+ scopeId: scope.scopeId,
2119
+ label: scope.label,
2120
+ mode: scope.mode,
2121
+ reason: anchor?.reason,
2122
+ lastKnownRange: anchor?.lastKnownRange,
2123
+ storyTarget: scope.storyTarget,
2124
+ reapplySuggested: true,
2125
+ },
2126
+ affectedAnchor: anchor ? scope.anchor : undefined,
2127
+ llmMetadata: {
2128
+ userSummary: `${subject} is no longer attached to trusted document content. Reapply the scope before relying on it.`,
2129
+ remediation: {
2130
+ kind: "fallback",
2131
+ suggestion:
2132
+ "Locate the intended text using warning.details.scopeId and warning.details.lastKnownRange, then call addScope again for the repaired range.",
2133
+ },
2134
+ recoveryClass: "requires-input",
2135
+ echoedInput: {
2136
+ scopeId: scope.scopeId,
2137
+ lastKnownRange: anchor?.lastKnownRange,
2138
+ },
2139
+ },
2140
+ }),
2141
+ details: {
2142
+ scopeId: scope.scopeId,
2143
+ label: scope.label,
2144
+ mode: scope.mode,
2145
+ reason: anchor?.reason,
2146
+ lastKnownRange: anchor?.lastKnownRange,
2147
+ storyTarget: scope.storyTarget,
2148
+ reapplySuggested: true,
2149
+ actionabilityNote:
2150
+ "Resolve the intended text again, then reapply the scope; the previous anchor is no longer trusted.",
2151
+ },
2152
+ };
2153
+ }
2154
+
2155
+ function syncDetachedWorkflowScopeWarningsInState(): {
2156
+ added: InternalEditorWarning[];
2157
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
2158
+ } {
2159
+ const { nextWarnings, added, cleared } = mergeDetachedWorkflowScopeWarnings(
2160
+ getNormalizedWorkflowOverlay(),
2161
+ state.warnings,
2162
+ );
2163
+ if (added.length === 0 && cleared.length === 0) {
2164
+ return { added, cleared };
2165
+ }
2166
+ state = { ...state, warnings: nextWarnings };
2167
+ return { added, cleared };
2168
+ }
2169
+
1969
2170
  function deriveWorkflowScopeSnapshot(): WorkflowScopeSnapshot | null {
1970
2171
  const normalizedWorkflowOverlay = getNormalizedWorkflowOverlay();
1971
2172
  if (!normalizedWorkflowOverlay) return null;
@@ -2529,6 +2730,7 @@ export function createDocumentRuntime(
2529
2730
  return snapshot;
2530
2731
  }
2531
2732
 
2733
+ syncDetachedWorkflowScopeWarningsInState();
2532
2734
  let cachedRenderSnapshot = refreshRenderSnapshot();
2533
2735
 
2534
2736
  emit({
@@ -3351,17 +3553,29 @@ export function createDocumentRuntime(
3351
3553
  });
3352
3554
 
3353
3555
  if (params.persistence && params.persistence !== "runtime-only") {
3556
+ const requestedMetadata = params.metadata ?? {};
3557
+ const entryPersistence =
3558
+ requestedMetadata.metadataPersistence ??
3559
+ (params.persistence === "session" ? "external" : "internal");
3354
3560
  const entry: WorkflowMetadataEntry = {
3355
- entryId: `scope-metadata-${scopeId}`,
3356
- metadataId: "workflow.scope",
3561
+ entryId: requestedMetadata.entryId ?? `scope-metadata-${scopeId}`,
3562
+ metadataId: requestedMetadata.metadataId ?? "workflow.scope",
3357
3563
  anchor: publicAnchor,
3564
+ ...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
3358
3565
  scopeId,
3359
- value:
3360
- params.persistence === "document-metadata"
3361
- ? { ...(params.metadata?.value ?? {}), label: params.label }
3362
- : params.metadata?.value,
3363
- metadataPersistence:
3364
- params.persistence === "session" ? "external" : "internal",
3566
+ ...(requestedMetadata.workItemId ? { workItemId: requestedMetadata.workItemId } : {}),
3567
+ ...(requestedMetadata.value !== undefined
3568
+ ? { value: requestedMetadata.value }
3569
+ : params.persistence === "document-metadata" && params.label
3570
+ ? { value: { label: params.label } }
3571
+ : {}),
3572
+ metadataPersistence: entryPersistence,
3573
+ ...(requestedMetadata.storageRef !== undefined
3574
+ ? { storageRef: requestedMetadata.storageRef }
3575
+ : {}),
3576
+ ...(requestedMetadata.metadataVersion !== undefined
3577
+ ? { metadataVersion: requestedMetadata.metadataVersion }
3578
+ : {}),
3365
3579
  };
3366
3580
  this.dispatch({
3367
3581
  type: "workflow.set-metadata-entries",
@@ -3951,16 +4165,42 @@ export function createDocumentRuntime(
3951
4165
  overlay: workflowOverlay,
3952
4166
  entries: workflowMetadataEntries,
3953
4167
  document: state.document,
4168
+ markerBackedScopeIds,
3954
4169
  },
3955
4170
  filter,
3956
4171
  );
3957
4172
  },
3958
4173
  subscribeToScopeQuery(filter, callback) {
4174
+ const buildAnchorKey = (anchor: EditorAnchorProjection): string => {
4175
+ switch (anchor.kind) {
4176
+ case "range":
4177
+ return `range:${anchor.from}:${anchor.to}:${anchor.assoc.start}:${anchor.assoc.end}`;
4178
+ case "node":
4179
+ return `node:${anchor.at}`;
4180
+ case "detached":
4181
+ return `detached:${anchor.reason}:${anchor.lastKnownRange.from}:${anchor.lastKnownRange.to}`;
4182
+ default:
4183
+ return "unknown";
4184
+ }
4185
+ };
4186
+
3959
4187
  const buildKey = (results: ScopeQueryResult[]) =>
3960
4188
  results
3961
4189
  .map(
3962
4190
  (r) =>
3963
- `${r.scope.scopeId}:${r.scope.version ?? 0}:${r.scope.visibility ?? "visible"}`,
4191
+ [
4192
+ r.scope.scopeId,
4193
+ r.scope.version ?? 0,
4194
+ r.scope.visibility ?? "visible",
4195
+ buildAnchorKey(r.scope.anchor),
4196
+ r.workItem?.workItemId ?? "",
4197
+ r.entries
4198
+ .map(
4199
+ (entry) =>
4200
+ `${entry.entryId}:${entry.metadataVersion ?? 0}:${buildAnchorKey(entry.anchor)}`,
4201
+ )
4202
+ .join("|"),
4203
+ ].join(":"),
3964
4204
  )
3965
4205
  .join(",");
3966
4206
 
@@ -4027,7 +4267,12 @@ export function createDocumentRuntime(
4027
4267
  if (pos === null) return [];
4028
4268
  const hits = findAllScopesAt(state.document, pos);
4029
4269
  return projectScopeQueryResults(
4030
- { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
4270
+ {
4271
+ overlay: workflowOverlay,
4272
+ entries: workflowMetadataEntries,
4273
+ document: state.document,
4274
+ markerBackedScopeIds,
4275
+ },
4031
4276
  hits.map((h) => h.scopeId),
4032
4277
  options,
4033
4278
  );
@@ -4036,7 +4281,12 @@ export function createDocumentRuntime(
4036
4281
  if (range.kind !== "range") return [];
4037
4282
  const hits = findScopesIntersecting(state.document, range.from, range.to, options?.mode);
4038
4283
  return projectScopeQueryResults(
4039
- { overlay: workflowOverlay, entries: workflowMetadataEntries, document: state.document },
4284
+ {
4285
+ overlay: workflowOverlay,
4286
+ entries: workflowMetadataEntries,
4287
+ document: state.document,
4288
+ markerBackedScopeIds,
4289
+ },
4040
4290
  hits.map((h) => h.scopeId),
4041
4291
  options,
4042
4292
  );
@@ -4249,6 +4499,8 @@ export function createDocumentRuntime(
4249
4499
  state = finalizeState(transaction.nextState, transaction.markDirty, clock());
4250
4500
  perfCounters.increment("commit.finalizeState.us", Math.round((performance.now() - tFinalize0) * 1000));
4251
4501
  storySelections.set(storyTargetKey(activeStory), state.selection);
4502
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4503
+ const detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
4252
4504
 
4253
4505
  const tInvalidate0 = performance.now();
4254
4506
  if (transaction.markDirty && transaction.mapping.steps.length > 0) {
@@ -4319,7 +4571,20 @@ export function createDocumentRuntime(
4319
4571
  perfCounters.increment("commit.refresh.us", Math.round((performance.now() - tRefresh0) * 1000));
4320
4572
 
4321
4573
  const tNotify0 = performance.now();
4322
- notify(previous, state, transaction);
4574
+ notify(previous, state, {
4575
+ ...transaction,
4576
+ effects: {
4577
+ ...transaction.effects,
4578
+ warningsAdded: [
4579
+ ...transaction.effects.warningsAdded,
4580
+ ...detachedWorkflowScopeWarnings.added,
4581
+ ],
4582
+ warningsCleared: [
4583
+ ...transaction.effects.warningsCleared,
4584
+ ...detachedWorkflowScopeWarnings.cleared,
4585
+ ],
4586
+ },
4587
+ });
4323
4588
  perfCounters.increment("commit.notify.us", Math.round((performance.now() - tNotify0) * 1000));
4324
4589
  perfCounters.increment("commit.total.us", Math.round((performance.now() - tApply0) * 1000));
4325
4590
  }
@@ -4948,10 +5213,18 @@ export function createDocumentRuntime(
4948
5213
  function applyRuntimeStateOverlayCommand(
4949
5214
  command: RuntimeStateOverlayCommand,
4950
5215
  ): void {
5216
+ let detachedWorkflowScopeWarnings:
5217
+ | {
5218
+ added: InternalEditorWarning[];
5219
+ cleared: Array<{ warningId: string; code: InternalEditorWarning["code"] }>;
5220
+ }
5221
+ | null = null;
4951
5222
  switch (command.type) {
4952
5223
  case "workflow.set-overlay": {
4953
5224
  workflowOverlay = structuredClone(command.overlay);
5225
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4954
5226
  cachedNormalizedWorkflowOverlay = undefined;
5227
+ detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
4955
5228
  cachedRenderSnapshot = refreshRenderSnapshot();
4956
5229
  const snapshot = deriveWorkflowScopeSnapshot()!;
4957
5230
  emit({
@@ -4970,7 +5243,9 @@ export function createDocumentRuntime(
4970
5243
  }
4971
5244
  case "workflow.clear-overlay": {
4972
5245
  workflowOverlay = null;
5246
+ syncMarkerBackedScopeIds(state.document, workflowOverlay);
4973
5247
  cachedNormalizedWorkflowOverlay = undefined;
5248
+ detachedWorkflowScopeWarnings = syncDetachedWorkflowScopeWarningsInState();
4974
5249
  cachedRenderSnapshot = refreshRenderSnapshot();
4975
5250
  emit({
4976
5251
  type: "workflow_active_work_item_changed",
@@ -5045,6 +5320,25 @@ export function createDocumentRuntime(
5045
5320
  break;
5046
5321
  }
5047
5322
  }
5323
+ if (detachedWorkflowScopeWarnings) {
5324
+ for (const warning of detachedWorkflowScopeWarnings.added) {
5325
+ const publicWarning = toPublicWarning(warning);
5326
+ emit({
5327
+ type: "warning_added",
5328
+ documentId: state.documentId,
5329
+ warning: publicWarning,
5330
+ });
5331
+ options.onWarning?.(publicWarning);
5332
+ }
5333
+ for (const cleared of detachedWorkflowScopeWarnings.cleared) {
5334
+ emit({
5335
+ type: "warning_cleared",
5336
+ documentId: state.documentId,
5337
+ warningId: cleared.warningId,
5338
+ code: cleared.code,
5339
+ });
5340
+ }
5341
+ }
5048
5342
  for (const listener of listeners) {
5049
5343
  listener();
5050
5344
  }
@@ -5533,11 +5827,42 @@ function toPublicCompatibilityFeatureEntry(
5533
5827
  }
5534
5828
 
5535
5829
  function toPublicWarning(warning: InternalEditorWarning): EditorWarning {
5830
+ const diagnostic =
5831
+ warning.diagnostic ??
5832
+ (() => {
5833
+ switch (warning.code) {
5834
+ case "unsupported_ooxml_preserved":
5835
+ case "unsupported_ooxml_locked":
5836
+ case "import_normalized":
5837
+ case "export_roundtrip_risk":
5838
+ case "comment_anchor_detached":
5839
+ case "revision_anchor_detached":
5840
+ case "workflow_scope_invalidated":
5841
+ case "large_document_degraded":
5842
+ case "font_substitution":
5843
+ case "image_missing":
5844
+ return buildDiagnosticFromLegacyWarningCode(warning.code, {
5845
+ diagnosticId: `warning-diag:${warning.warningId}`,
5846
+ emittedAt: new Date(0).toISOString(),
5847
+ technical: {
5848
+ message: warning.message,
5849
+ source: warning.source,
5850
+ },
5851
+ details: warning.details,
5852
+ affectedAnchor: warning.affectedAnchor
5853
+ ? toPublicAnchorProjection(warning.affectedAnchor)
5854
+ : undefined,
5855
+ });
5856
+ default:
5857
+ return undefined;
5858
+ }
5859
+ })();
5536
5860
  return {
5537
5861
  ...warning,
5538
5862
  affectedAnchor: warning.affectedAnchor
5539
5863
  ? toPublicAnchorProjection(warning.affectedAnchor)
5540
5864
  : undefined,
5865
+ diagnostic,
5541
5866
  };
5542
5867
  }
5543
5868
 
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ EditorAnchorProjection,
2
3
  EditorStoryTarget,
3
4
  ScopeQueryFilter,
4
5
  ScopeQueryResult,
@@ -31,10 +32,97 @@ function storyTargetsEqual(a: EditorStoryTarget, b: EditorStoryTarget): boolean
31
32
 
32
33
  const MAIN_STORY: EditorStoryTarget = { kind: "main" };
33
34
 
35
+ function workflowAnchorsEqual(
36
+ left: EditorAnchorProjection,
37
+ right: EditorAnchorProjection,
38
+ ): boolean {
39
+ if (left.kind !== right.kind) return false;
40
+ switch (left.kind) {
41
+ case "range":
42
+ return (
43
+ right.kind === "range" &&
44
+ left.from === right.from &&
45
+ left.to === right.to &&
46
+ left.assoc.start === right.assoc.start &&
47
+ left.assoc.end === right.assoc.end
48
+ );
49
+ case "node":
50
+ return right.kind === "node" && left.at === right.at;
51
+ case "detached":
52
+ return (
53
+ right.kind === "detached" &&
54
+ left.reason === right.reason &&
55
+ left.lastKnownRange.from === right.lastKnownRange.from &&
56
+ left.lastKnownRange.to === right.lastKnownRange.to
57
+ );
58
+ default:
59
+ return false;
60
+ }
61
+ }
62
+
63
+ function buildScopeIdCounts(overlay: WorkflowOverlay): Map<string, number> {
64
+ const counts = new Map<string, number>();
65
+ for (const scope of overlay.scopes) {
66
+ counts.set(scope.scopeId, (counts.get(scope.scopeId) ?? 0) + 1);
67
+ }
68
+ return counts;
69
+ }
70
+
71
+ function normalizeScopeAnchor(
72
+ scope: WorkflowScope,
73
+ scopeIdCounts: ReadonlyMap<string, number>,
74
+ locations: ReadonlyMap<string, { startPos?: number; endPos?: number }>,
75
+ markerBackedScopeIds: ReadonlySet<string>,
76
+ ): WorkflowScope {
77
+ if ((scopeIdCounts.get(scope.scopeId) ?? 0) !== 1) {
78
+ return scope;
79
+ }
80
+
81
+ const location = locations.get(scope.scopeId);
82
+ let nextAnchor: EditorAnchorProjection | null = null;
83
+ if (
84
+ location &&
85
+ location.startPos !== undefined &&
86
+ location.endPos !== undefined
87
+ ) {
88
+ nextAnchor = {
89
+ kind: "range",
90
+ from: Math.min(location.startPos, location.endPos),
91
+ to: Math.max(location.startPos, location.endPos),
92
+ assoc: { start: -1, end: 1 },
93
+ };
94
+ } else if (markerBackedScopeIds.has(scope.scopeId)) {
95
+ const lastKnownRange =
96
+ scope.anchor.kind === "range"
97
+ ? { from: scope.anchor.from, to: scope.anchor.to }
98
+ : scope.anchor.kind === "node"
99
+ ? { from: scope.anchor.at, to: scope.anchor.at }
100
+ : scope.anchor.lastKnownRange;
101
+ nextAnchor = {
102
+ kind: "detached",
103
+ reason:
104
+ location && (location.startPos !== undefined || location.endPos !== undefined)
105
+ ? "deleted"
106
+ : "invalidatedByStructureChange",
107
+ lastKnownRange,
108
+ };
109
+ } else {
110
+ return scope;
111
+ }
112
+
113
+ return workflowAnchorsEqual(scope.anchor, nextAnchor)
114
+ ? scope
115
+ : {
116
+ ...scope,
117
+ anchor: nextAnchor,
118
+ };
119
+ }
120
+
34
121
  export interface ScopeQueryInputs {
35
122
  readonly overlay: WorkflowOverlay | null;
36
123
  readonly entries: readonly WorkflowMetadataEntry[];
37
124
  readonly document: Pick<CanonicalDocument, "content"> | CanonicalDocumentEnvelope;
125
+ readonly markerBackedScopeIds?: ReadonlySet<string>;
38
126
  }
39
127
 
40
128
  /**
@@ -53,9 +141,17 @@ export function projectScopeQueryResults(
53
141
  if (!overlay) return [];
54
142
  const includeHidden = options.includeHidden === true;
55
143
  const includeInvisible = options.includeInvisible === true;
144
+ const scopeIdCounts = buildScopeIdCounts(overlay);
145
+ const locations = collectScopeLocations(inputs.document);
146
+ const markerBackedScopeIds = inputs.markerBackedScopeIds ?? new Set<string>();
56
147
 
57
148
  const scopesById = new Map<string, WorkflowScope>();
58
- for (const scope of overlay.scopes) scopesById.set(scope.scopeId, scope);
149
+ for (const scope of overlay.scopes) {
150
+ scopesById.set(
151
+ scope.scopeId,
152
+ normalizeScopeAnchor(scope, scopeIdCounts, locations, markerBackedScopeIds),
153
+ );
154
+ }
59
155
 
60
156
  const entriesByScope = new Map<string, WorkflowMetadataEntry[]>();
61
157
  for (const entry of inputs.entries) {
@@ -128,6 +224,8 @@ export function queryScopes(
128
224
  }
129
225
 
130
226
  const locations = collectScopeLocations(inputs.document);
227
+ const scopeIdCounts = buildScopeIdCounts(overlay);
228
+ const markerBackedScopeIds = inputs.markerBackedScopeIds ?? new Set<string>();
131
229
 
132
230
  const candidates: Array<{ scope: WorkflowScope; startPos: number }> = [];
133
231
  for (const scope of overlay.scopes) {
@@ -162,7 +260,10 @@ export function queryScopes(
162
260
  const loc = locations.get(scope.scopeId);
163
261
  const startPos =
164
262
  loc?.startPos ?? loc?.endPos ?? Number.POSITIVE_INFINITY;
165
- candidates.push({ scope, startPos });
263
+ candidates.push({
264
+ scope: normalizeScopeAnchor(scope, scopeIdCounts, locations, markerBackedScopeIds),
265
+ startPos,
266
+ });
166
267
  }
167
268
 
168
269
  candidates.sort((a, b) => {