@beyondwork/docx-react-component 1.0.82 → 1.0.84
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 +1 -1
- package/src/api/public-types.ts +48 -4
- package/src/api/v3/_runtime-handle.ts +4 -0
- package/src/api/v3/runtime/workflow.ts +154 -6
- package/src/io/export/serialize-main-document.ts +72 -6
- package/src/io/ooxml/workflow-payload-validator.ts +24 -0
- package/src/io/ooxml/workflow-payload.ts +12 -0
- package/src/runtime/document-runtime.ts +41 -14
- package/src/runtime/scopes/_scope-dependencies.ts +1 -0
- package/src/runtime/scopes/action-validation.ts +54 -45
- package/src/runtime/scopes/workflow-overlap.ts +41 -9
- package/src/runtime/workflow/coordinator.ts +66 -14
- package/src/runtime/workflow/scope-writer.ts +83 -5
- package/src/shell/session-bootstrap.ts +2 -0
- package/src/ui/WordReviewEditor.tsx +176 -1
- package/src/ui-tailwind/review-workspace/use-chrome-policy.ts +32 -6
- package/src/ui-tailwind/toolbar/tw-role-action-region.tsx +21 -0
- package/src/ui-tailwind/tw-review-workspace.tsx +1 -0
|
@@ -204,55 +204,64 @@ function collectGuardVerdict(
|
|
|
204
204
|
}
|
|
205
205
|
|
|
206
206
|
const guard = runtime.getInteractionGuardSnapshot();
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
207
|
+
// Scope-targeted writes target `scope.handle.scopeId`, not the current
|
|
208
|
+
// editor selection. The target scope's own overlay posture was handled
|
|
209
|
+
// above through `scope.workflow.effectiveMode`; this second guard read is
|
|
210
|
+
// only allowed to contribute global/session-wide blockers.
|
|
211
|
+
const isSelectionScopeMembershipReason = (reason: { readonly code: string; readonly scopeId?: string }): boolean => {
|
|
212
|
+
if (reason.code === "outside_workflow_scope") return true;
|
|
213
|
+
if (
|
|
214
|
+
(reason.code === "workflow_view_only" ||
|
|
215
|
+
reason.code === "workflow_comment_only") &&
|
|
216
|
+
typeof reason.scopeId === "string" &&
|
|
217
|
+
reason.scopeId.length > 0
|
|
218
|
+
) {
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
};
|
|
223
|
+
const rawReasons = guard.blockedReasons ?? [];
|
|
224
|
+
const nonSelectionScoped = rawReasons.filter(
|
|
225
|
+
(r) => !isSelectionScopeMembershipReason(r),
|
|
226
|
+
);
|
|
227
|
+
const pushTypedGuardBlocker = (code: string | undefined): void => {
|
|
228
|
+
const suffix = typeof code === "string" && code.length > 0
|
|
229
|
+
? code
|
|
230
|
+
: "unspecified";
|
|
231
|
+
const typedBlocker = `guard:block-${suffix}`;
|
|
232
|
+
if (!blockedReasons.some((existing) => existing === typedBlocker)) {
|
|
233
|
+
blockedReasons.push(typedBlocker);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
if (guard.effectiveMode === "view") {
|
|
237
|
+
const globalViewReason = nonSelectionScoped.find(
|
|
238
|
+
(reason) => reason.code === "workflow_view_only",
|
|
239
|
+
);
|
|
240
|
+
if (globalViewReason) {
|
|
241
|
+
pushTypedGuardBlocker(globalViewReason.code);
|
|
242
|
+
} else if (
|
|
243
|
+
rawReasons.length === 0 &&
|
|
244
|
+
!blockedReasons.includes("guard:view-mode-active")
|
|
245
|
+
) {
|
|
246
|
+
blockedReasons.push("guard:view-mode-active");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (guard.effectiveMode === "comment") {
|
|
250
|
+
const globalCommentReason = nonSelectionScoped.find(
|
|
251
|
+
(reason) => reason.code === "workflow_comment_only",
|
|
252
|
+
);
|
|
253
|
+
if (globalCommentReason) {
|
|
254
|
+
pushTypedGuardBlocker(globalCommentReason.code);
|
|
255
|
+
}
|
|
212
256
|
}
|
|
213
257
|
if (guard.effectiveMode === "blocked") {
|
|
214
258
|
// Coord-06 §13e — promote the bare `guard:blocked` blocker to a typed
|
|
215
|
-
// `guard:block-<reason>` suffix
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
// Scope-targeted-write carve-out (coord-09, TemplateViewer repro
|
|
221
|
-
// 2026-04-24): `applyReplacementScope`, `attachExplanation`, and
|
|
222
|
-
// `createIssue` target a scopeId, not the current editor selection.
|
|
223
|
-
// The scope's own `workflow.effectiveMode` already drove the
|
|
224
|
-
// scope-level arm of `collectGuardVerdict` above (lines 159–197).
|
|
225
|
-
// The selection-scoped coordinator guard, in contrast, evaluates
|
|
226
|
-
// against the live `state.selection` — which, for scope-targeted
|
|
227
|
-
// writes, may sit anywhere in the document. Reasons that depend on
|
|
228
|
-
// selection-scope membership (`outside_workflow_scope`,
|
|
229
|
-
// `workflow_view_only`, `workflow_comment_only`) are therefore
|
|
230
|
-
// double-counting and must not block. Globally-scoped reasons
|
|
231
|
-
// (`document_read_only`, `document_viewing_mode`) still apply — a
|
|
232
|
-
// read-only doc rejects every write, scope-targeted or not.
|
|
233
|
-
const SELECTION_SCOPE_MEMBERSHIP_CODES = new Set([
|
|
234
|
-
"outside_workflow_scope",
|
|
235
|
-
"workflow_view_only",
|
|
236
|
-
"workflow_comment_only",
|
|
237
|
-
]);
|
|
238
|
-
const rawReasons = guard.blockedReasons ?? [];
|
|
239
|
-
const nonSelectionScoped = rawReasons.filter(
|
|
240
|
-
(r) => !SELECTION_SCOPE_MEMBERSHIP_CODES.has(r.code),
|
|
241
|
-
);
|
|
242
|
-
// If every reason was selection-scope-membership for a scope-
|
|
243
|
-
// targeted write, emit no blocker — the scope-level arm above is
|
|
244
|
-
// authoritative. The defensive empty-array fallback
|
|
245
|
-
// (guard:block-unspecified) still fires when the coordinator
|
|
246
|
-
// produced effectiveMode:"blocked" without any reasons at all.
|
|
259
|
+
// `guard:block-<reason>` suffix. Selection-membership reasons are
|
|
260
|
+
// intentionally ignored here; global/session reasons such as read-only,
|
|
261
|
+
// protected ranges, shared workflow locks, and unsupported suggesting
|
|
262
|
+
// commands remain blockers.
|
|
247
263
|
if (nonSelectionScoped.length > 0 || rawReasons.length === 0) {
|
|
248
|
-
|
|
249
|
-
const suffix = typeof primaryCode === "string" && primaryCode.length > 0
|
|
250
|
-
? primaryCode
|
|
251
|
-
: "unspecified";
|
|
252
|
-
const typedBlocker = `guard:block-${suffix}`;
|
|
253
|
-
if (!blockedReasons.some((existing) => existing === typedBlocker)) {
|
|
254
|
-
blockedReasons.push(typedBlocker);
|
|
255
|
-
}
|
|
264
|
+
pushTypedGuardBlocker(nonSelectionScoped[0]?.code);
|
|
256
265
|
}
|
|
257
266
|
}
|
|
258
267
|
for (const reason of guard.blockedReasons ?? []) {
|
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Given a scope's canonical range and the workflow overlay, returns the
|
|
5
5
|
* `SemanticScopeWorkflow` projection: the ids of overlay scopes overlapping
|
|
6
|
-
* the range + the most-restrictive `effectiveMode` across
|
|
7
|
-
* `
|
|
6
|
+
* the range + the most-restrictive enforced `effectiveMode` across scopes
|
|
7
|
+
* whose `guardPolicy` participates in editing + `blockedReasons` when a
|
|
8
|
+
* read-only overlap blocks the scope.
|
|
8
9
|
*
|
|
9
10
|
* The most-restrictive rule matches layer 06's `InteractionGuardSnapshot`
|
|
10
|
-
* composition
|
|
11
|
+
* composition for guard-participating scopes (see
|
|
12
|
+
* `docs/architecture/06-workflow-review.md` §W3):
|
|
11
13
|
* view > comment > suggest > edit
|
|
12
14
|
*
|
|
13
15
|
* When no overlay is threaded, or no overlap exists, the returned shape
|
|
@@ -19,7 +21,11 @@
|
|
|
19
21
|
* overlap on top.
|
|
20
22
|
*/
|
|
21
23
|
|
|
22
|
-
import type {
|
|
24
|
+
import type {
|
|
25
|
+
WorkflowOverlay,
|
|
26
|
+
WorkflowScope,
|
|
27
|
+
WorkflowScopeGuardPolicy,
|
|
28
|
+
} from "./_scope-dependencies.ts";
|
|
23
29
|
|
|
24
30
|
import type { ScopePositionMap, ScopePositionRange } from "./position-map.ts";
|
|
25
31
|
import { rangesOverlap } from "./scope-range.ts";
|
|
@@ -27,6 +33,14 @@ import type { SemanticScopeWorkflow } from "./semantic-scope-types.ts";
|
|
|
27
33
|
|
|
28
34
|
type WorkflowMode = "edit" | "suggest" | "comment" | "view";
|
|
29
35
|
|
|
36
|
+
function getScopeGuardPolicy(scope: WorkflowScope): WorkflowScopeGuardPolicy {
|
|
37
|
+
return scope.guardPolicy ?? (scope.mode === "view" ? "read-only" : "none");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getScopeGuardMode(scope: WorkflowScope): WorkflowMode {
|
|
41
|
+
return getScopeGuardPolicy(scope) === "read-only" ? "view" : scope.mode;
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
function modeRank(mode: WorkflowMode): number {
|
|
31
45
|
// Higher = more restrictive (wins the merge).
|
|
32
46
|
switch (mode) {
|
|
@@ -45,6 +59,21 @@ function mergeModes(a: WorkflowMode, b: WorkflowMode): WorkflowMode {
|
|
|
45
59
|
return modeRank(a) >= modeRank(b) ? a : b;
|
|
46
60
|
}
|
|
47
61
|
|
|
62
|
+
function rangeForWorkflowScope(
|
|
63
|
+
scope: WorkflowScope,
|
|
64
|
+
positionMap: ScopePositionMap,
|
|
65
|
+
): ScopePositionRange | null {
|
|
66
|
+
const markerRange = positionMap.markerScopes.get(scope.scopeId);
|
|
67
|
+
if (markerRange) return markerRange;
|
|
68
|
+
if (scope.anchor.kind === "range") {
|
|
69
|
+
return { from: scope.anchor.from, to: scope.anchor.to };
|
|
70
|
+
}
|
|
71
|
+
if (scope.anchor.kind === "node") {
|
|
72
|
+
return { from: scope.anchor.at, to: scope.anchor.at };
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
export interface WorkflowOverlapInputs {
|
|
49
78
|
readonly overlay: WorkflowOverlay | null | undefined;
|
|
50
79
|
readonly positionMap: ScopePositionMap;
|
|
@@ -73,12 +102,15 @@ export function resolveWorkflowOverlap(
|
|
|
73
102
|
let mode: WorkflowMode = "edit";
|
|
74
103
|
for (const scope of overlay.scopes as readonly WorkflowScope[]) {
|
|
75
104
|
if (selfScopeIds && selfScopeIds.has(scope.scopeId)) continue;
|
|
76
|
-
const
|
|
77
|
-
if (!
|
|
78
|
-
if (!rangesOverlap(range,
|
|
105
|
+
const scopeRange = rangeForWorkflowScope(scope, positionMap);
|
|
106
|
+
if (!scopeRange) continue;
|
|
107
|
+
if (!rangesOverlap(range, scopeRange)) continue;
|
|
79
108
|
overlappingIds.push(scope.scopeId);
|
|
80
|
-
|
|
81
|
-
if (
|
|
109
|
+
const guardPolicy = getScopeGuardPolicy(scope);
|
|
110
|
+
if (guardPolicy === "none") continue;
|
|
111
|
+
const guardMode = getScopeGuardMode(scope);
|
|
112
|
+
mode = mergeModes(mode, guardMode);
|
|
113
|
+
if (guardMode === "view") {
|
|
82
114
|
blockedReasons.push(`workflow-scope-view:${scope.scopeId}`);
|
|
83
115
|
}
|
|
84
116
|
}
|
|
@@ -64,6 +64,7 @@ import type {
|
|
|
64
64
|
WorkflowMetadataSnapshot,
|
|
65
65
|
WorkflowOverlay,
|
|
66
66
|
WorkflowScope,
|
|
67
|
+
WorkflowScopeGuardPolicy,
|
|
67
68
|
WorkflowScopeMode,
|
|
68
69
|
WorkflowScopeSnapshot,
|
|
69
70
|
} from "../../api/public-types.ts";
|
|
@@ -227,6 +228,11 @@ export interface WorkflowCoordinator {
|
|
|
227
228
|
addInvisibleScope(params: AddScopeParams): AddScopeResult;
|
|
228
229
|
setScopeVisibility(scopeId: string, visibility: ScopeVisibility): void;
|
|
229
230
|
getScopeVisibility(scopeId: string): ScopeVisibility;
|
|
231
|
+
setScopeGuardPolicy(
|
|
232
|
+
scopeId: string,
|
|
233
|
+
guardPolicy: WorkflowScopeGuardPolicy,
|
|
234
|
+
): void;
|
|
235
|
+
getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy;
|
|
230
236
|
getScope(scopeId: string): WorkflowScope | null;
|
|
231
237
|
getMarkerBackedScopeIds(): ReadonlySet<string>;
|
|
232
238
|
/* --- scope chrome visibility (local view state) --- */
|
|
@@ -316,6 +322,18 @@ const MODE_RESTRICTIVENESS: Record<WorkflowScopeMode, number> = {
|
|
|
316
322
|
view: 3,
|
|
317
323
|
};
|
|
318
324
|
|
|
325
|
+
function resolveScopeGuardPolicy(scope: WorkflowScope): WorkflowScopeGuardPolicy {
|
|
326
|
+
return scope.guardPolicy ?? (scope.mode === "view" ? "read-only" : "none");
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function getScopeGuardMode(scope: WorkflowScope): WorkflowScopeMode {
|
|
330
|
+
return resolveScopeGuardPolicy(scope) === "read-only" ? "view" : scope.mode;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function participatesInInteractionGuard(scope: WorkflowScope): boolean {
|
|
334
|
+
return resolveScopeGuardPolicy(scope) !== "none";
|
|
335
|
+
}
|
|
336
|
+
|
|
319
337
|
export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordinator {
|
|
320
338
|
const { overlayStore, clock } = deps;
|
|
321
339
|
|
|
@@ -413,8 +431,7 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
413
431
|
};
|
|
414
432
|
const activeScopes = getEffectiveWorkflowScopes(overlay);
|
|
415
433
|
const matching = activeScopes.filter((scope) => {
|
|
416
|
-
|
|
417
|
-
if (scope.visibility === "invisible" && scope.mode !== "view") return false;
|
|
434
|
+
if (!participatesInInteractionGuard(scope)) return false;
|
|
418
435
|
if (scope.anchor.kind === "detached") return false;
|
|
419
436
|
const scopeFrom =
|
|
420
437
|
scope.anchor.kind === "range" ? scope.anchor.from : scope.anchor.at;
|
|
@@ -450,8 +467,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
450
467
|
if (stack.length === 0) return null;
|
|
451
468
|
// §C6 — most-restrictive-wins across overlapping scopes.
|
|
452
469
|
return stack.reduce((best, scope) =>
|
|
453
|
-
(MODE_RESTRICTIVENESS[scope
|
|
454
|
-
(MODE_RESTRICTIVENESS[best
|
|
470
|
+
(MODE_RESTRICTIVENESS[getScopeGuardMode(scope)] ?? 0) >
|
|
471
|
+
(MODE_RESTRICTIVENESS[getScopeGuardMode(best)] ?? 0)
|
|
455
472
|
? scope
|
|
456
473
|
: best,
|
|
457
474
|
);
|
|
@@ -463,7 +480,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
463
480
|
const mode = deps.getDocumentMode();
|
|
464
481
|
if (mode === "viewing" || mode === "commenting") return mode;
|
|
465
482
|
const matchingScope = getMatchingWorkflowScope(selection);
|
|
466
|
-
if (matchingScope
|
|
483
|
+
if (matchingScope && getScopeGuardMode(matchingScope) === "suggest") {
|
|
484
|
+
return "suggesting";
|
|
485
|
+
}
|
|
467
486
|
return mode;
|
|
468
487
|
}
|
|
469
488
|
|
|
@@ -536,17 +555,18 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
536
555
|
if (normalized) {
|
|
537
556
|
const matchingScope = getMatchingWorkflowScope(selection);
|
|
538
557
|
const activeScopes = getEffectiveWorkflowScopes(normalized);
|
|
539
|
-
const
|
|
540
|
-
(s) =>
|
|
558
|
+
const insertOnlyScopes = activeScopes.filter(
|
|
559
|
+
(s) => resolveScopeGuardPolicy(s) === "insert-only",
|
|
541
560
|
);
|
|
542
561
|
|
|
543
|
-
if (!matchingScope &&
|
|
562
|
+
if (!matchingScope && insertOnlyScopes.length > 0) {
|
|
544
563
|
reasons.push({
|
|
545
564
|
code: "outside_workflow_scope",
|
|
546
|
-
message: "Selection is outside any active workflow scope.",
|
|
565
|
+
message: "Selection is outside any active insert-only workflow scope.",
|
|
547
566
|
});
|
|
548
567
|
} else if (matchingScope) {
|
|
549
|
-
|
|
568
|
+
const guardMode = getScopeGuardMode(matchingScope);
|
|
569
|
+
if (guardMode === "comment") {
|
|
550
570
|
const isCommentCommand = commandType?.startsWith("comment.") ?? false;
|
|
551
571
|
if (!isCommentCommand) {
|
|
552
572
|
reasons.push({
|
|
@@ -556,7 +576,7 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
556
576
|
workItemId: matchingScope.workItemId,
|
|
557
577
|
});
|
|
558
578
|
}
|
|
559
|
-
} else if (
|
|
579
|
+
} else if (guardMode === "view") {
|
|
560
580
|
reasons.push({
|
|
561
581
|
code: "workflow_view_only",
|
|
562
582
|
message: `Scope "${matchingScope.label ?? matchingScope.scopeId}" is view-only.`,
|
|
@@ -618,6 +638,9 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
618
638
|
const matchingScope = getMatchingWorkflowScope(state.selection);
|
|
619
639
|
const scopeStack = buildMatchingScopeStack(state.selection);
|
|
620
640
|
const primaryBlockedReason = blockedReasons[0];
|
|
641
|
+
const matchingGuardMode = matchingScope
|
|
642
|
+
? getScopeGuardMode(matchingScope)
|
|
643
|
+
: undefined;
|
|
621
644
|
const effectiveMode = primaryBlockedReason
|
|
622
645
|
? primaryBlockedReason.code === "workflow_comment_only"
|
|
623
646
|
? "comment"
|
|
@@ -626,19 +649,19 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
626
649
|
: "blocked"
|
|
627
650
|
: getEffectiveDocumentMode(state.selection) === "suggesting"
|
|
628
651
|
? "suggest"
|
|
629
|
-
:
|
|
652
|
+
: matchingGuardMode ?? "edit";
|
|
630
653
|
const matchedScopeStack: InteractionGuardSnapshot["matchedScopeStack"] =
|
|
631
654
|
scopeStack.length > 0
|
|
632
655
|
? scopeStack.map((s) => ({
|
|
633
656
|
scopeId: s.scopeId,
|
|
634
|
-
mode: s
|
|
657
|
+
mode: getScopeGuardMode(s),
|
|
635
658
|
visibility: s.visibility ?? "visible",
|
|
636
659
|
}))
|
|
637
660
|
: undefined;
|
|
638
661
|
const snapshot: InteractionGuardSnapshot = {
|
|
639
662
|
effectiveMode,
|
|
640
663
|
...(matchingScope?.scopeId ? { matchedScopeId: matchingScope.scopeId } : {}),
|
|
641
|
-
...(
|
|
664
|
+
...(matchingGuardMode ? { matchedScopeMode: matchingGuardMode } : {}),
|
|
642
665
|
...(matchedScopeStack ? { matchedScopeStack } : {}),
|
|
643
666
|
targetAccess:
|
|
644
667
|
effectiveMode === "edit"
|
|
@@ -888,6 +911,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
888
911
|
anchor: publicAnchor,
|
|
889
912
|
...(params.storyTarget ? { storyTarget: params.storyTarget } : {}),
|
|
890
913
|
...(params.label ? { label: params.label } : {}),
|
|
914
|
+
...(params.visibility ? { visibility: params.visibility } : {}),
|
|
915
|
+
...(params.guardPolicy ? { guardPolicy: params.guardPolicy } : {}),
|
|
891
916
|
...(params.scopeMetadataFields && params.scopeMetadataFields.length > 0
|
|
892
917
|
? { metadata: [...params.scopeMetadataFields] }
|
|
893
918
|
: {}),
|
|
@@ -1004,6 +1029,31 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
1004
1029
|
return scope?.visibility ?? "visible";
|
|
1005
1030
|
}
|
|
1006
1031
|
|
|
1032
|
+
function setScopeGuardPolicy(
|
|
1033
|
+
scopeId: string,
|
|
1034
|
+
guardPolicy: WorkflowScopeGuardPolicy,
|
|
1035
|
+
): void {
|
|
1036
|
+
const overlay = overlayStore.getOverlay();
|
|
1037
|
+
if (!overlay) return;
|
|
1038
|
+
const idx = overlay.scopes.findIndex((s) => s.scopeId === scopeId);
|
|
1039
|
+
if (idx === -1) return;
|
|
1040
|
+
const nextScopes = overlay.scopes.map((s) =>
|
|
1041
|
+
s.scopeId === scopeId ? { ...s, guardPolicy } : s,
|
|
1042
|
+
);
|
|
1043
|
+
deps.dispatch({
|
|
1044
|
+
type: "workflow.set-overlay",
|
|
1045
|
+
overlay: { ...overlay, scopes: nextScopes },
|
|
1046
|
+
origin: { source: "api", at: clock() },
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function getScopeGuardPolicy(scopeId: string): WorkflowScopeGuardPolicy {
|
|
1051
|
+
const overlay = overlayStore.getOverlay();
|
|
1052
|
+
if (!overlay) return "none";
|
|
1053
|
+
const scope = overlay.scopes.find((s) => s.scopeId === scopeId);
|
|
1054
|
+
return scope ? resolveScopeGuardPolicy(scope) : "none";
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1007
1057
|
function getScope(scopeId: string): WorkflowScope | null {
|
|
1008
1058
|
const normalized = getNormalizedOverlay();
|
|
1009
1059
|
const fromOverlay = normalized?.scopes.find((s) => s.scopeId === scopeId);
|
|
@@ -1360,6 +1410,8 @@ export function createWorkflowCoordinator(deps: CoordinatorDeps): WorkflowCoordi
|
|
|
1360
1410
|
addInvisibleScope,
|
|
1361
1411
|
setScopeVisibility,
|
|
1362
1412
|
getScopeVisibility,
|
|
1413
|
+
setScopeGuardPolicy,
|
|
1414
|
+
getScopeGuardPolicy,
|
|
1363
1415
|
getScope,
|
|
1364
1416
|
getMarkerBackedScopeIds: () => overlayStore.getMarkerBackedScopeIds(),
|
|
1365
1417
|
setScopeChromeVisibility,
|
|
@@ -37,6 +37,8 @@ import type {
|
|
|
37
37
|
EditorAnchorProjection,
|
|
38
38
|
EditorStoryTarget,
|
|
39
39
|
RuntimeRenderSnapshot,
|
|
40
|
+
ScopeVisibility,
|
|
41
|
+
WorkflowScopeGuardPolicy,
|
|
40
42
|
WorkflowMetadataEntry,
|
|
41
43
|
WorkflowMetadataPersistence,
|
|
42
44
|
WorkflowScopeMetadataField,
|
|
@@ -62,6 +64,8 @@ export interface CreateScopeFromBlockIdInput {
|
|
|
62
64
|
readonly persistence?: WorkflowMetadataPersistence;
|
|
63
65
|
readonly metadata?: Partial<WorkflowMetadataEntry>;
|
|
64
66
|
readonly storyTarget?: EditorStoryTarget;
|
|
67
|
+
readonly visibility?: ScopeVisibility;
|
|
68
|
+
readonly guardPolicy?: WorkflowScopeGuardPolicy;
|
|
65
69
|
/**
|
|
66
70
|
* Coord-06 §13d — per-scope edge stickiness for the range anchor.
|
|
67
71
|
* Defaults to `{ start: 1, end: -1 }` (greedy — absorbs boundary
|
|
@@ -98,7 +102,23 @@ export interface CreateScopeFromBlockIdInput {
|
|
|
98
102
|
|
|
99
103
|
export type CreateScopeFromBlockIdResult =
|
|
100
104
|
| { readonly status: "created"; readonly scopeId: string; readonly anchor: EditorAnchorProjection }
|
|
101
|
-
| { readonly status: "block-not-found"; readonly blockId: string }
|
|
105
|
+
| { readonly status: "block-not-found"; readonly blockId: string }
|
|
106
|
+
| {
|
|
107
|
+
readonly status: "range-invalid";
|
|
108
|
+
readonly scopeId: "";
|
|
109
|
+
readonly reason:
|
|
110
|
+
| "range-exceeds-story-length"
|
|
111
|
+
| "non-paragraph-target"
|
|
112
|
+
| "empty-document";
|
|
113
|
+
readonly from: number;
|
|
114
|
+
readonly to: number;
|
|
115
|
+
readonly storyLength: number;
|
|
116
|
+
/** Non-paragraph target only — the offending block's index and kind. */
|
|
117
|
+
readonly blockIndex?: number;
|
|
118
|
+
readonly blockKind?: string;
|
|
119
|
+
readonly message: string;
|
|
120
|
+
readonly nextStep: string;
|
|
121
|
+
};
|
|
102
122
|
|
|
103
123
|
function inlineLength(node: InlineNode): number {
|
|
104
124
|
switch (node.type) {
|
|
@@ -190,8 +210,9 @@ export function createScopeFromBlockId(
|
|
|
190
210
|
runtime: ScopeWriterRuntime,
|
|
191
211
|
input: CreateScopeFromBlockIdInput,
|
|
192
212
|
): CreateScopeFromBlockIdResult {
|
|
213
|
+
const document = runtime.getCanonicalDocument();
|
|
193
214
|
const anchor = resolveBlockAnchorFromCanonical(
|
|
194
|
-
|
|
215
|
+
document,
|
|
195
216
|
input.blockId,
|
|
196
217
|
input.assoc,
|
|
197
218
|
);
|
|
@@ -216,10 +237,63 @@ export function createScopeFromBlockId(
|
|
|
216
237
|
metadata: input.metadata,
|
|
217
238
|
storyTarget: input.storyTarget,
|
|
218
239
|
label: input.label,
|
|
240
|
+
...(input.visibility ? { visibility: input.visibility } : {}),
|
|
241
|
+
...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
|
|
219
242
|
...(scopeMetadataFields.length > 0
|
|
220
243
|
? { scopeMetadataFields }
|
|
221
244
|
: {}),
|
|
222
245
|
});
|
|
246
|
+
if (result.plantStatus && result.plantStatus.planted === false) {
|
|
247
|
+
const ps = result.plantStatus;
|
|
248
|
+
const from = anchor.kind === "range" ? anchor.from : 0;
|
|
249
|
+
const to = anchor.kind === "range" ? anchor.to : from;
|
|
250
|
+
const storyLength = ps.storyLength ?? computeMainStoryLength(document);
|
|
251
|
+
if (ps.reason === "non-paragraph-target") {
|
|
252
|
+
return {
|
|
253
|
+
status: "range-invalid",
|
|
254
|
+
scopeId: "",
|
|
255
|
+
reason: "non-paragraph-target",
|
|
256
|
+
from,
|
|
257
|
+
to,
|
|
258
|
+
storyLength,
|
|
259
|
+
blockIndex: ps.blockIndex ?? -1,
|
|
260
|
+
blockKind: ps.blockKind ?? "unknown",
|
|
261
|
+
message:
|
|
262
|
+
`createScope refused blockId "${input.blockId}": it resolves to a ` +
|
|
263
|
+
`${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
|
|
264
|
+
`Marker-backed scopes only plant inside paragraphs today; choose a ` +
|
|
265
|
+
`paragraph block or create an overlay-only scope for table/SDT metadata.`,
|
|
266
|
+
nextStep: "pick-a-paragraph-block-or-use-overlay-only-scope",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (ps.reason === "range-out-of-bounds") {
|
|
270
|
+
return {
|
|
271
|
+
status: "range-invalid",
|
|
272
|
+
scopeId: "",
|
|
273
|
+
reason: "range-exceeds-story-length",
|
|
274
|
+
from,
|
|
275
|
+
to,
|
|
276
|
+
storyLength,
|
|
277
|
+
message:
|
|
278
|
+
`createScope refused blockId "${input.blockId}": resolved range ` +
|
|
279
|
+
`[${from}, ${to}] exceeds the current story length (${storyLength}). ` +
|
|
280
|
+
`Re-query block ids from the current render snapshot before retrying.`,
|
|
281
|
+
nextStep: "re-query-current-block-id",
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
status: "range-invalid",
|
|
286
|
+
scopeId: "",
|
|
287
|
+
reason: "empty-document",
|
|
288
|
+
from,
|
|
289
|
+
to,
|
|
290
|
+
storyLength,
|
|
291
|
+
message:
|
|
292
|
+
`createScope refused blockId "${input.blockId}": the target document ` +
|
|
293
|
+
`has no blocks, so scope markers cannot be planted.`,
|
|
294
|
+
nextStep: "initialize-document-before-creating-scopes",
|
|
295
|
+
};
|
|
296
|
+
}
|
|
223
297
|
return { status: "created", scopeId: result.scopeId, anchor: result.anchor };
|
|
224
298
|
}
|
|
225
299
|
|
|
@@ -251,6 +325,8 @@ export interface CreateScopeFromAnchorInput {
|
|
|
251
325
|
readonly scopeId?: string;
|
|
252
326
|
readonly persistence?: WorkflowMetadataPersistence;
|
|
253
327
|
readonly metadata?: Partial<WorkflowMetadataEntry>;
|
|
328
|
+
readonly visibility?: ScopeVisibility;
|
|
329
|
+
readonly guardPolicy?: WorkflowScopeGuardPolicy;
|
|
254
330
|
/**
|
|
255
331
|
* Per-scope edge stickiness for the range anchor. Defaults to
|
|
256
332
|
* `{ start: 1, end: -1 }` (greedy — absorbs boundary inserts). See
|
|
@@ -423,6 +499,8 @@ export function createScopeFromAnchor(
|
|
|
423
499
|
metadata: input.metadata,
|
|
424
500
|
storyTarget,
|
|
425
501
|
label: input.label,
|
|
502
|
+
...(input.visibility ? { visibility: input.visibility } : {}),
|
|
503
|
+
...(input.guardPolicy ? { guardPolicy: input.guardPolicy } : {}),
|
|
426
504
|
...(scopeMetadataFields.length > 0 ? { scopeMetadataFields } : {}),
|
|
427
505
|
});
|
|
428
506
|
|
|
@@ -448,9 +526,9 @@ export function createScopeFromAnchor(
|
|
|
448
526
|
message:
|
|
449
527
|
`createScopeFromAnchor refused: range [${from}, ${to}] targets a ` +
|
|
450
528
|
`${ps.blockKind ?? "non-paragraph"} block (index ${ps.blockIndex}). ` +
|
|
451
|
-
`Marker scopes only plant inside paragraphs today. Pick a
|
|
452
|
-
`target, or
|
|
453
|
-
`
|
|
529
|
+
`Marker-backed scopes only plant inside paragraphs today. Pick a ` +
|
|
530
|
+
`paragraph target, or create an overlay-only scope for table/SDT ` +
|
|
531
|
+
`metadata until structural marker support lands.`,
|
|
454
532
|
nextStep: "pick-a-paragraph-target",
|
|
455
533
|
};
|
|
456
534
|
}
|
|
@@ -1289,6 +1289,8 @@ function createLoadingRuntimeBridge(input: {
|
|
|
1289
1289
|
addInvisibleScope: () => ({ scopeId: "", anchor: { kind: "range", from: 0, to: 0, assoc: { start: -1, end: 1 } } }),
|
|
1290
1290
|
setScopeVisibility: () => undefined,
|
|
1291
1291
|
getScopeVisibility: () => "visible" as const,
|
|
1292
|
+
setScopeGuardPolicy: () => undefined,
|
|
1293
|
+
getScopeGuardPolicy: () => "none" as const,
|
|
1292
1294
|
setScopeChromeVisibility: () => undefined,
|
|
1293
1295
|
getScopeChromeVisibility: () => ({ mode: "all" as const }),
|
|
1294
1296
|
subscribeToScopeQuery: (_filter, _callback) => () => undefined,
|