@avocadostudio-ai/orchestrator-core 0.1.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 (196) hide show
  1. package/LICENSE +201 -0
  2. package/dist/agent/agent-context.d.ts +19 -0
  3. package/dist/agent/agent-context.js +67 -0
  4. package/dist/agent/agent-logger.d.ts +5 -0
  5. package/dist/agent/agent-logger.js +22 -0
  6. package/dist/agent/agent-loop-openai.d.ts +8 -0
  7. package/dist/agent/agent-loop-openai.js +172 -0
  8. package/dist/agent/agent-loop.d.ts +56 -0
  9. package/dist/agent/agent-loop.js +167 -0
  10. package/dist/agent/agent-provider.d.ts +28 -0
  11. package/dist/agent/agent-provider.js +63 -0
  12. package/dist/agent/agent-tools.d.ts +28 -0
  13. package/dist/agent/agent-tools.js +899 -0
  14. package/dist/agent/context/editing-guidelines.md +46 -0
  15. package/dist/agent/context/role.md +39 -0
  16. package/dist/agent/integration-prompt.d.ts +9 -0
  17. package/dist/agent/integration-prompt.js +154 -0
  18. package/dist/agent/sites-agent-context.d.ts +12 -0
  19. package/dist/agent/sites-agent-context.js +316 -0
  20. package/dist/agent/sites-agent-shared.d.ts +161 -0
  21. package/dist/agent/sites-agent-shared.js +1101 -0
  22. package/dist/agent/sites-agent-tools.d.ts +18 -0
  23. package/dist/agent/sites-agent-tools.js +1227 -0
  24. package/dist/chat/anthropic-cache.d.ts +20 -0
  25. package/dist/chat/anthropic-cache.js +54 -0
  26. package/dist/chat/anthropic-planner.d.ts +98 -0
  27. package/dist/chat/anthropic-planner.js +1012 -0
  28. package/dist/chat/changelog-coverage-validator.d.ts +37 -0
  29. package/dist/chat/changelog-coverage-validator.js +215 -0
  30. package/dist/chat/chat-pipeline-context.d.ts +211 -0
  31. package/dist/chat/chat-pipeline-context.js +249 -0
  32. package/dist/chat/chat-pipeline-deterministic.d.ts +61 -0
  33. package/dist/chat/chat-pipeline-deterministic.js +407 -0
  34. package/dist/chat/chat-pipeline-image.d.ts +86 -0
  35. package/dist/chat/chat-pipeline-image.js +897 -0
  36. package/dist/chat/chat-pipeline-shared.d.ts +69 -0
  37. package/dist/chat/chat-pipeline-shared.js +212 -0
  38. package/dist/chat/chat-pipeline-translation.d.ts +27 -0
  39. package/dist/chat/chat-pipeline-translation.js +417 -0
  40. package/dist/chat/chat-pipeline-ui.d.ts +14 -0
  41. package/dist/chat/chat-pipeline-ui.js +244 -0
  42. package/dist/chat/chat-pipeline.d.ts +99 -0
  43. package/dist/chat/chat-pipeline.js +3999 -0
  44. package/dist/chat/decomposer.d.ts +21 -0
  45. package/dist/chat/decomposer.js +65 -0
  46. package/dist/chat/gemini-planner.d.ts +70 -0
  47. package/dist/chat/gemini-planner.js +541 -0
  48. package/dist/chat/hallucination-validator.d.ts +36 -0
  49. package/dist/chat/hallucination-validator.js +110 -0
  50. package/dist/chat/locale-strings.d.ts +47 -0
  51. package/dist/chat/locale-strings.js +100 -0
  52. package/dist/chat/plan-json-schema.d.ts +133 -0
  53. package/dist/chat/plan-json-schema.js +112 -0
  54. package/dist/chat/planner-types.d.ts +120 -0
  55. package/dist/chat/planner-types.js +66 -0
  56. package/dist/chat/planner.d.ts +148 -0
  57. package/dist/chat/planner.js +1361 -0
  58. package/dist/chat/prompts.d.ts +67 -0
  59. package/dist/chat/prompts.js +356 -0
  60. package/dist/chat/provider-routing.d.ts +14 -0
  61. package/dist/chat/provider-routing.js +27 -0
  62. package/dist/chat/variation-pipeline.d.ts +135 -0
  63. package/dist/chat/variation-pipeline.js +837 -0
  64. package/dist/chat/vision-alt-generator.d.ts +35 -0
  65. package/dist/chat/vision-alt-generator.js +152 -0
  66. package/dist/cms/adapter.d.ts +62 -0
  67. package/dist/cms/adapter.js +1 -0
  68. package/dist/cms/bootstrap.d.ts +17 -0
  69. package/dist/cms/bootstrap.js +85 -0
  70. package/dist/cms/editor-api-adapter.d.ts +23 -0
  71. package/dist/cms/editor-api-adapter.js +71 -0
  72. package/dist/cms/index.d.ts +4 -0
  73. package/dist/cms/index.js +3 -0
  74. package/dist/cms/json-file-adapter.d.ts +11 -0
  75. package/dist/cms/json-file-adapter.js +62 -0
  76. package/dist/demo-mode.d.ts +59 -0
  77. package/dist/demo-mode.js +201 -0
  78. package/dist/errors.d.ts +67 -0
  79. package/dist/errors.js +129 -0
  80. package/dist/http/chat-stream-resumable.d.ts +108 -0
  81. package/dist/http/chat-stream-resumable.js +290 -0
  82. package/dist/http/chat-stream.d.ts +99 -0
  83. package/dist/http/chat-stream.js +92 -0
  84. package/dist/image/gdrive-client.d.ts +22 -0
  85. package/dist/image/gdrive-client.js +215 -0
  86. package/dist/image/image-helpers.d.ts +95 -0
  87. package/dist/image/image-helpers.js +488 -0
  88. package/dist/index.d.ts +1 -0
  89. package/dist/index.js +1 -0
  90. package/dist/jira/jira-approval.d.ts +22 -0
  91. package/dist/jira/jira-approval.js +51 -0
  92. package/dist/jira/jira-client.d.ts +44 -0
  93. package/dist/jira/jira-client.js +313 -0
  94. package/dist/jira/jira-poller.d.ts +46 -0
  95. package/dist/jira/jira-poller.js +184 -0
  96. package/dist/jira/jira-processor.d.ts +103 -0
  97. package/dist/jira/jira-processor.js +1085 -0
  98. package/dist/jira/jira-types.d.ts +117 -0
  99. package/dist/jira/jira-types.js +38 -0
  100. package/dist/logger.d.ts +12 -0
  101. package/dist/logger.js +28 -0
  102. package/dist/migration/mcp-server-stdio.d.ts +8 -0
  103. package/dist/migration/mcp-server-stdio.js +672 -0
  104. package/dist/migration/migration-prompt.d.ts +7 -0
  105. package/dist/migration/migration-prompt.js +197 -0
  106. package/dist/migration/migration-tools.d.ts +17 -0
  107. package/dist/migration/migration-tools.js +159 -0
  108. package/dist/migration/scrape-cache.d.ts +9 -0
  109. package/dist/migration/scrape-cache.js +19 -0
  110. package/dist/nlp/deterministic-planner-context.d.ts +141 -0
  111. package/dist/nlp/deterministic-planner-context.js +362 -0
  112. package/dist/nlp/deterministic-planner-pages.d.ts +26 -0
  113. package/dist/nlp/deterministic-planner-pages.js +170 -0
  114. package/dist/nlp/deterministic-planner-patches.d.ts +80 -0
  115. package/dist/nlp/deterministic-planner-patches.js +508 -0
  116. package/dist/nlp/deterministic-planner-refs.d.ts +33 -0
  117. package/dist/nlp/deterministic-planner-refs.js +164 -0
  118. package/dist/nlp/deterministic-planner-suggestions.d.ts +49 -0
  119. package/dist/nlp/deterministic-planner-suggestions.js +579 -0
  120. package/dist/nlp/deterministic-planner.d.ts +85 -0
  121. package/dist/nlp/deterministic-planner.js +1631 -0
  122. package/dist/nlp/intent-detection.d.ts +309 -0
  123. package/dist/nlp/intent-detection.js +730 -0
  124. package/dist/nlp/intent-helpers.d.ts +15 -0
  125. package/dist/nlp/intent-helpers.js +243 -0
  126. package/dist/nlp/intent-patterns.d.ts +40 -0
  127. package/dist/nlp/intent-patterns.js +223 -0
  128. package/dist/nlp/plan-normalizer.d.ts +41 -0
  129. package/dist/nlp/plan-normalizer.js +1537 -0
  130. package/dist/ops/destructive-action-gate.d.ts +44 -0
  131. package/dist/ops/destructive-action-gate.js +90 -0
  132. package/dist/ops/ops-engine.d.ts +151 -0
  133. package/dist/ops/ops-engine.js +1394 -0
  134. package/dist/publish/diff-engine.d.ts +18 -0
  135. package/dist/publish/diff-engine.js +305 -0
  136. package/dist/publish/publish-helpers.d.ts +87 -0
  137. package/dist/publish/publish-helpers.js +521 -0
  138. package/dist/publish/publish-target-registry.d.ts +7 -0
  139. package/dist/publish/publish-target-registry.js +61 -0
  140. package/dist/publish/publish-target.d.ts +81 -0
  141. package/dist/publish/publish-target.js +1 -0
  142. package/dist/publish/targets/deploy-hook.d.ts +13 -0
  143. package/dist/publish/targets/deploy-hook.js +123 -0
  144. package/dist/publish/targets/git.d.ts +13 -0
  145. package/dist/publish/targets/git.js +55 -0
  146. package/dist/publish/targets/site-contract.d.ts +19 -0
  147. package/dist/publish/targets/site-contract.js +124 -0
  148. package/dist/state/content-source.d.ts +17 -0
  149. package/dist/state/content-source.js +1 -0
  150. package/dist/state/in-memory-content-source.d.ts +27 -0
  151. package/dist/state/in-memory-content-source.js +51 -0
  152. package/dist/state/session-lock.d.ts +13 -0
  153. package/dist/state/session-lock.js +29 -0
  154. package/dist/state/session-state.d.ts +310 -0
  155. package/dist/state/session-state.js +1083 -0
  156. package/dist/state/sqlite-store-singleton.d.ts +31 -0
  157. package/dist/state/sqlite-store-singleton.js +170 -0
  158. package/dist/state/sqlite-store.d.ts +135 -0
  159. package/dist/state/sqlite-store.js +421 -0
  160. package/dist/telemetry/chat-telemetry.d.ts +105 -0
  161. package/dist/telemetry/chat-telemetry.js +247 -0
  162. package/dist/telemetry/eval-candidate-store.d.ts +50 -0
  163. package/dist/telemetry/eval-candidate-store.js +120 -0
  164. package/dist/telemetry/feedback-store.d.ts +34 -0
  165. package/dist/telemetry/feedback-store.js +76 -0
  166. package/dist/telemetry/jira-telemetry.d.ts +57 -0
  167. package/dist/telemetry/jira-telemetry.js +68 -0
  168. package/dist/telemetry/migration-telemetry.d.ts +35 -0
  169. package/dist/telemetry/migration-telemetry.js +40 -0
  170. package/dist/telemetry/usage.d.ts +24 -0
  171. package/dist/telemetry/usage.js +80 -0
  172. package/dist/tools/builtin-registrations.d.ts +12 -0
  173. package/dist/tools/builtin-registrations.js +33 -0
  174. package/dist/tools/builtins/gdrive-browse.d.ts +3 -0
  175. package/dist/tools/builtins/gdrive-browse.js +68 -0
  176. package/dist/tools/builtins/image-generate.d.ts +3 -0
  177. package/dist/tools/builtins/image-generate.js +211 -0
  178. package/dist/tools/builtins/unsplash-get-by-id.d.ts +23 -0
  179. package/dist/tools/builtins/unsplash-get-by-id.js +119 -0
  180. package/dist/tools/builtins/unsplash-search.d.ts +3 -0
  181. package/dist/tools/builtins/unsplash-search.js +74 -0
  182. package/dist/tools/executor.d.ts +23 -0
  183. package/dist/tools/executor.js +169 -0
  184. package/dist/tools/index.d.ts +5 -0
  185. package/dist/tools/index.js +5 -0
  186. package/dist/tools/registry.d.ts +21 -0
  187. package/dist/tools/registry.js +75 -0
  188. package/dist/tools/runtime.d.ts +27 -0
  189. package/dist/tools/runtime.js +48 -0
  190. package/dist/tools/schema-validator.d.ts +24 -0
  191. package/dist/tools/schema-validator.js +88 -0
  192. package/dist/tools/types.d.ts +86 -0
  193. package/dist/tools/types.js +1 -0
  194. package/dist/variation-images.d.ts +19 -0
  195. package/dist/variation-images.js +12 -0
  196. package/package.json +78 -0
@@ -0,0 +1,1394 @@
1
+ import { z } from "zod";
2
+ import { blockSchemas, operationSchema, validateBlockProps, validateByJsonSchemaLike, isChrome, mapSemanticThemeTokens, generateItemId } from "@avocadostudio-ai/shared";
3
+ import { normalizeRouteCandidate } from "../nlp/intent-helpers.js";
4
+ import { pageIdFromSlug, pageTitleFromSlug } from "../nlp/plan-normalizer.js";
5
+ import { OperationError, toErrorDetail as _unifiedToErrorDetail } from "../errors.js";
6
+ import { isDemoModeEnabled, enforceDemoOps } from "../demo-mode.js";
7
+ import { computePublishDiff } from "../publish/diff-engine.js";
8
+ import { acquireSessionLock } from "../state/session-lock.js";
9
+ import { getSessionDraft, orderSlugsHomeFirst, setPage, getPage, getSiteConfig, setSiteConfig } from "../state/session-state.js";
10
+ // ---------------------------------------------------------------------------
11
+ // Passthrough Zod schema cache — avoids allocating a new ZodObject per call
12
+ // ---------------------------------------------------------------------------
13
+ const _passthroughSchemaCache = new Map();
14
+ // Defensive sanitizer: imageAlt should be a description, never an imperative
15
+ // user instruction. Matches phrases like "add feta crumbles", "change the
16
+ // photo", "make it brighter". Keep in sync with looksLikeUserInstruction in
17
+ // chat-pipeline-ui.ts.
18
+ const _ALT_INSTRUCTION_PATTERN = /^\s*(add|change|turn|make|replace|swap|remove|update|set|use|show|put|insert|generate|create|find|search|pick|choose|give|need|want|please|let'?s|can you|could you|i want|i need)\b/i;
19
+ function _looksLikeUserInstruction(value) {
20
+ return _ALT_INSTRUCTION_PATTERN.test(value);
21
+ }
22
+ function _sanitizeListItemImageAlt(item) {
23
+ if (!item || typeof item !== "object" || Array.isArray(item))
24
+ return item;
25
+ const obj = item;
26
+ if (typeof obj.imageAlt === "string" && _looksLikeUserInstruction(obj.imageAlt)) {
27
+ const { imageAlt: _drop, ...rest } = obj;
28
+ return rest;
29
+ }
30
+ return obj;
31
+ }
32
+ function _getPassthroughSchema(blockType) {
33
+ const cached = _passthroughSchemaCache.get(blockType);
34
+ if (cached)
35
+ return cached;
36
+ const schema = blockSchemas[blockType];
37
+ if (!schema)
38
+ return undefined;
39
+ const pt = schema.passthrough();
40
+ _passthroughSchemaCache.set(blockType, pt);
41
+ return pt;
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // Route link rewriting (used by rename_page operation)
45
+ // ---------------------------------------------------------------------------
46
+ function remapRouteReference(value, fromSlug, toSlug) {
47
+ if (!value.startsWith("/"))
48
+ return value;
49
+ if (fromSlug === "/") {
50
+ if (value === "/")
51
+ return toSlug;
52
+ if (value.startsWith("/?") || value.startsWith("/#"))
53
+ return `${toSlug}${value.slice(1)}`;
54
+ return value;
55
+ }
56
+ if (value === fromSlug)
57
+ return toSlug;
58
+ if (value.startsWith(`${fromSlug}/`) || value.startsWith(`${fromSlug}?`) || value.startsWith(`${fromSlug}#`)) {
59
+ return `${toSlug}${value.slice(fromSlug.length)}`;
60
+ }
61
+ return value;
62
+ }
63
+ function rewriteRouteLinksInValue(input, fromSlug, toSlug) {
64
+ if (typeof input === "string") {
65
+ const mapped = remapRouteReference(input, fromSlug, toSlug);
66
+ return { value: mapped, changed: mapped !== input };
67
+ }
68
+ if (Array.isArray(input)) {
69
+ let changed = false;
70
+ const next = input.map((item) => {
71
+ const mapped = rewriteRouteLinksInValue(item, fromSlug, toSlug);
72
+ if (mapped.changed)
73
+ changed = true;
74
+ return mapped.value;
75
+ });
76
+ return { value: changed ? next : input, changed };
77
+ }
78
+ if (!input || typeof input !== "object")
79
+ return { value: input, changed: false };
80
+ const source = input;
81
+ let changed = false;
82
+ const next = {};
83
+ for (const [key, value] of Object.entries(source)) {
84
+ if (typeof value === "string" && key.toLowerCase().includes("href")) {
85
+ const mapped = remapRouteReference(value, fromSlug, toSlug);
86
+ if (mapped !== value)
87
+ changed = true;
88
+ next[key] = mapped;
89
+ continue;
90
+ }
91
+ if (typeof value === "string" && key === "body") {
92
+ const rewritten = value.replace(/\]\((\/[^)\s]+)\)/g, (full, routeCandidate) => {
93
+ const mapped = remapRouteReference(routeCandidate, fromSlug, toSlug);
94
+ if (mapped !== routeCandidate)
95
+ return `](${mapped})`;
96
+ return full;
97
+ });
98
+ if (rewritten !== value)
99
+ changed = true;
100
+ next[key] = rewritten;
101
+ continue;
102
+ }
103
+ const mapped = rewriteRouteLinksInValue(value, fromSlug, toSlug);
104
+ if (mapped.changed)
105
+ changed = true;
106
+ next[key] = mapped.value;
107
+ }
108
+ return { value: changed ? next : input, changed };
109
+ }
110
+ function rewriteLinksToRenamedPage(page, fromSlug, toSlug) {
111
+ let changed = false;
112
+ const nextBlocks = page.blocks.map((block) => {
113
+ const mapped = rewriteRouteLinksInValue(block.props, fromSlug, toSlug);
114
+ if (!mapped.changed)
115
+ return block;
116
+ changed = true;
117
+ return { ...block, props: mapped.value };
118
+ });
119
+ if (!changed)
120
+ return { changed: false, page };
121
+ return { changed: true, page: { ...page, blocks: nextBlocks, updatedAt: new Date().toISOString() } };
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Error helpers
125
+ // ---------------------------------------------------------------------------
126
+ // Re-export the canonical toErrorDetail from errors.ts for backward compat
127
+ export const toErrorDetail = _unifiedToErrorDetail;
128
+ export function isNoEffectiveChangeError(reason) {
129
+ // Matches every no-op message the engine throws with category "no_effective_change":
130
+ // "No effective prop change across plan…" / "No effective page change for…"
131
+ // "No effective meta change for…" / "Edit plan produced no changes"
132
+ return /no effective \w+ change/i.test(reason) || /produced no changes/i.test(reason);
133
+ }
134
+ export function classifyGuardrailError(reason) {
135
+ const lower = reason.toLowerCase();
136
+ if (isNoEffectiveChangeError(reason))
137
+ return "no_effective_change";
138
+ if (lower.includes("refused planning output"))
139
+ return "planner_refusal";
140
+ if (lower.includes("incomplete planning output") || lower.includes("returned no planning output"))
141
+ return "incomplete_output";
142
+ if (lower.includes("did not return json") ||
143
+ lower.includes("malformed json") ||
144
+ lower.includes("raw planner output shape is invalid") ||
145
+ lower.includes("expected ',' or '}'") ||
146
+ lower.includes("no number after minus") ||
147
+ lower.includes("unexpected token") ||
148
+ lower.includes("in json at position")) {
149
+ return "malformed_output";
150
+ }
151
+ if (lower.includes("page not found") ||
152
+ lower.includes("blockid") ||
153
+ lower.includes("afterblockid") ||
154
+ lower.includes("not found")) {
155
+ return "not_found";
156
+ }
157
+ if (lower.includes("ambiguous") || lower.includes("clarify") || lower.includes("unclear")) {
158
+ return "ambiguity";
159
+ }
160
+ if (lower.includes("invalid") ||
161
+ lower.includes("required") ||
162
+ lower.includes("unknown props") ||
163
+ lower.includes("out of range") ||
164
+ lower.includes("must be")) {
165
+ return "schema_violation";
166
+ }
167
+ return "internal_error";
168
+ }
169
+ export function formatValidationError(reason) {
170
+ return `${classifyGuardrailError(reason)}: ${reason}`;
171
+ }
172
+ export function isDeterministicRepairEligible(reason) {
173
+ return classifyGuardrailError(reason) === "schema_violation";
174
+ }
175
+ /**
176
+ * Extracts structured fields from a planner schema_violation reason so the
177
+ * repair prompt can tell the LLM exactly which path to fix and what kind of
178
+ * violation it was. The reason format produced by planner.ts is:
179
+ * `<zodMessage> at <path>. Parsed sample: <truncated json>`
180
+ *
181
+ * Falls back gracefully when fields are missing (e.g. non-zod failures that
182
+ * still classify as schema_violation via the keyword matcher).
183
+ */
184
+ export function parseSchemaViolationReason(reason) {
185
+ const sampleStripped = reason.replace(/\.\s*Parsed sample:.*$/s, "").trim();
186
+ const pathMatch = /\s+at\s+([^\s.]+(?:\.[^\s.]+)*)\s*$/.exec(sampleStripped);
187
+ const path = pathMatch ? pathMatch[1].trim() : null;
188
+ const zodMessage = (path ? sampleStripped.slice(0, pathMatch.index).trim() : sampleStripped)
189
+ .replace(/\.$/, "")
190
+ .trim();
191
+ const lower = zodMessage.toLowerCase();
192
+ let kind = "unknown";
193
+ if (lower.startsWith("unrecognized key") || lower.includes("unknown props"))
194
+ kind = "unknown_key";
195
+ else if (lower === "required" || lower.startsWith("required") || lower.includes("is required") || lower.includes("missing"))
196
+ kind = "missing_required";
197
+ else if (lower.startsWith("invalid discriminator"))
198
+ kind = "invalid_discriminator";
199
+ else if (lower.startsWith("invalid enum"))
200
+ kind = "invalid_enum";
201
+ else if (lower.startsWith("expected ") && lower.includes("received "))
202
+ kind = "type_mismatch";
203
+ else if (lower.includes("out of range") || lower.includes("greater than") || lower.includes("less than"))
204
+ kind = "out_of_range";
205
+ else if (lower.includes("must contain at least") ||
206
+ lower.includes("must contain at most") ||
207
+ lower.includes("too short") ||
208
+ lower.includes("too long"))
209
+ kind = "string_constraint";
210
+ return { path, zodMessage, kind };
211
+ }
212
+ export function buildDeterministicRepairFeedback(reason) {
213
+ const parsed = parseSchemaViolationReason(reason);
214
+ const pathLine = parsed.path ? `Violation path: ${parsed.path}.` : "";
215
+ const messageLine = parsed.zodMessage ? `Validator said: ${parsed.zodMessage}.` : "";
216
+ const kindGuidance = (() => {
217
+ switch (parsed.kind) {
218
+ case "unknown_key":
219
+ return "Action: remove the unknown key from the op's patch. Only use prop keys defined in that block type's blockContract. Do NOT invent new props (e.g. no 'color', 'colorful', 'style' unless they appear in the contract).";
220
+ case "missing_required":
221
+ return "Action: add the missing required field. If the op is update_props, required fields you omitted must either stay absent (no-op for that field) or be provided with a concrete value — do not set them to null or empty string unless the contract allows it.";
222
+ case "invalid_discriminator":
223
+ return "Action: fix the op type. Use one of the allowed operation names exactly: create_page, add_block, update_props, remove_block, move_block, duplicate_block, add_item, update_item, remove_item, move_item, rename_page, remove_page, move_page, duplicate_page, update_page_meta, update_site_config, update_theme.";
224
+ case "invalid_enum":
225
+ return "Action: replace the invalid value with one of the allowed enum values. Check the blockContract for the set of permitted values.";
226
+ case "type_mismatch":
227
+ return "Action: fix the type. Strings must be quoted strings, numbers bare numbers, booleans true/false. Do not wrap primitives in objects, do not pass arrays where strings are expected.";
228
+ case "out_of_range":
229
+ return "Action: adjust the index/number to be within bounds. Indices are zero-based and must be < array length. For insertions, use afterIndex = array.length - 1 to append.";
230
+ case "string_constraint":
231
+ return "Action: adjust the string length to satisfy the constraint. Most required text fields reject empty strings — provide actual content.";
232
+ default:
233
+ return "Action: re-read the block's contract and re-emit the plan with strictly valid shapes.";
234
+ }
235
+ })();
236
+ return [
237
+ "Repair strictly for schema compliance only. Do not change user intent, drop ops the user asked for, or rewrite copy semantics.",
238
+ pathLine,
239
+ messageLine,
240
+ kindGuidance,
241
+ "Keep every op the original plan had and every field/patch the user asked for — only fix the specific violation above.",
242
+ ]
243
+ .filter((line) => line.length > 0)
244
+ .join(" ");
245
+ }
246
+ export function isStructuralOperation(op) {
247
+ return (op.op === "add_block" ||
248
+ op.op === "remove_block" ||
249
+ op.op === "move_block" ||
250
+ op.op === "duplicate_block" ||
251
+ op.op === "add_item" ||
252
+ op.op === "remove_item" ||
253
+ op.op === "move_item");
254
+ }
255
+ // ---------------------------------------------------------------------------
256
+ // Helpers for atomic operation application (module-level for testability)
257
+ // ---------------------------------------------------------------------------
258
+ function _nextUniqueBlockId(blocks, preferred) {
259
+ const base = preferred.trim();
260
+ if (base.length > 0 && !blocks.some((b) => b.id === base))
261
+ return base;
262
+ const root = base.length > 0 ? base : "b_block_copy";
263
+ let i = 1;
264
+ while (blocks.some((b) => b.id === `${root}_${i}`))
265
+ i += 1;
266
+ return `${root}_${i}`;
267
+ }
268
+ /**
269
+ * Resolve a blockId to its index in the blocks array.
270
+ * Exact match first, then fuzzy fallback:
271
+ * 1. Strip trailing `_copy`, `_copy_N` suffix and retry exact match
272
+ * 2. Append `_copy` suffix and retry — supports update_props on a freshly
273
+ * duplicated page within the same atomic plan (LLM uses original IDs)
274
+ * 3. Match by block-type prefix — only when a single block matches (rejects ambiguous)
275
+ *
276
+ * Pushes a FuzzyMatch entry when a fallback strategy succeeds.
277
+ */
278
+ function _resolveBlockIndex(blocks, blockId, fuzzyMatches) {
279
+ // Exact match
280
+ const exact = blocks.findIndex((b) => b.id === blockId);
281
+ if (exact !== -1)
282
+ return exact;
283
+ // Fuzzy 1: strip _copy / _copy_N suffix and retry
284
+ const stripped = blockId.replace(/_copy(?:_\d+)?$/, "");
285
+ if (stripped !== blockId) {
286
+ const idx = blocks.findIndex((b) => b.id === stripped);
287
+ if (idx !== -1) {
288
+ fuzzyMatches?.push({ requestedId: blockId, resolvedId: blocks[idx].id, resolvedIndex: idx, strategy: "strip_copy_suffix" });
289
+ return idx;
290
+ }
291
+ }
292
+ // Fuzzy 1.5: append `_copy` suffix and retry. Symmetric with strategy 1 — when
293
+ // the LLM emits update_props against a duplicated page using the source page's
294
+ // block IDs (e.g. `b_hero` instead of `b_hero_copy`), match the duplicated
295
+ // block deterministically. Works even when multiple blocks of the same type
296
+ // exist (where the type-prefix fallback would reject as ambiguous).
297
+ if (!/_copy(?:_\d+)?$/.test(blockId)) {
298
+ const appended = `${blockId}_copy`;
299
+ const idx = blocks.findIndex((b) => b.id === appended);
300
+ if (idx !== -1) {
301
+ fuzzyMatches?.push({ requestedId: blockId, resolvedId: blocks[idx].id, resolvedIndex: idx, strategy: "append_copy_suffix" });
302
+ return idx;
303
+ }
304
+ }
305
+ // Fuzzy 2: match by block-type prefix — reject if ambiguous (>1 match)
306
+ const typeMatch = blockId.match(/^b_([a-z]+)/i);
307
+ if (typeMatch) {
308
+ const typePrefix = `b_${typeMatch[1].toLowerCase()}_`;
309
+ const matches = [];
310
+ for (let i = 0; i < blocks.length; i += 1) {
311
+ if (blocks[i].id.startsWith(typePrefix))
312
+ matches.push(i);
313
+ }
314
+ if (matches.length === 1) {
315
+ const idx = matches[0];
316
+ fuzzyMatches?.push({ requestedId: blockId, resolvedId: blocks[idx].id, resolvedIndex: idx, strategy: "type_prefix" });
317
+ return idx;
318
+ }
319
+ // Ambiguous: >1 block of same type — don't guess
320
+ }
321
+ return -1;
322
+ }
323
+ function _nextDuplicateSlug(candidateMap, sourceSlug) {
324
+ const base = sourceSlug === "/" ? "/home-copy" : `${sourceSlug.replace(/\/+$/, "")}-copy`;
325
+ if (!candidateMap.has(base))
326
+ return base;
327
+ let i = 2;
328
+ while (candidateMap.has(`${base}-${i}`))
329
+ i += 1;
330
+ return `${base}-${i}`;
331
+ }
332
+ function _rebuildOrderWithInserted(candidateMap, insertedSlug, afterPageSlug) {
333
+ const ordered = orderSlugsHomeFirst(Array.from(candidateMap.keys()));
334
+ const withoutInserted = ordered.filter((slug) => slug !== insertedSlug);
335
+ let insertIndex = 0;
336
+ if (afterPageSlug) {
337
+ if (afterPageSlug === "/")
338
+ insertIndex = 1;
339
+ else {
340
+ const anchorIdx = withoutInserted.findIndex((slug) => slug === afterPageSlug);
341
+ if (anchorIdx === -1)
342
+ throw new OperationError(`afterPageSlug ${afterPageSlug} not found`, { category: "not_found" });
343
+ insertIndex = anchorIdx + 1;
344
+ }
345
+ }
346
+ withoutInserted.splice(insertIndex, 0, insertedSlug);
347
+ return withoutInserted;
348
+ }
349
+ function _listValueForOp(block, listKey) {
350
+ const candidate = block.props[listKey];
351
+ if (!Array.isArray(candidate))
352
+ throw new OperationError(`List ${listKey} not found on ${block.id}`, { category: "not_found" });
353
+ return candidate;
354
+ }
355
+ function _itemIndexById(list, id) {
356
+ return list.findIndex((entry) => entry != null && typeof entry === "object" && !Array.isArray(entry) && entry.id === id);
357
+ }
358
+ /**
359
+ * Resolve the current array index of the list entry an item op targets. Prefers
360
+ * the stable `itemId` (robust across compound plans, where a numeric index
361
+ * reindexes the moment an earlier op mutates the list); falls back to positional
362
+ * `index` for direct-manipulation callers. Throws if neither resolves.
363
+ */
364
+ function _resolveItemIndex(list, op) {
365
+ if (typeof op.itemId === "string" && op.itemId.length > 0) {
366
+ const found = _itemIndexById(list, op.itemId);
367
+ if (found === -1)
368
+ throw new OperationError(`itemId ${op.itemId} not found in ${op.listKey} on ${op.blockId}`, { category: "not_found" });
369
+ return found;
370
+ }
371
+ if (typeof op.index === "number") {
372
+ if (op.index < 0 || op.index >= list.length)
373
+ throw new OperationError(`index ${op.index} is out of range for ${op.listKey}`, { category: "schema_violation" });
374
+ return op.index;
375
+ }
376
+ throw new OperationError(`item op on ${op.listKey} requires itemId or index`, { category: "schema_violation" });
377
+ }
378
+ /**
379
+ * Resolve the array index of the insert anchor (the item to insert/move after),
380
+ * or `undefined` when no anchor was given. Prefers `afterItemId` over the
381
+ * positional `afterIndex`. Resolved against the list as passed in.
382
+ */
383
+ function _resolveAfterIndex(list, op) {
384
+ if (typeof op.afterItemId === "string" && op.afterItemId.length > 0) {
385
+ const found = _itemIndexById(list, op.afterItemId);
386
+ if (found === -1)
387
+ throw new OperationError(`afterItemId ${op.afterItemId} not found in ${op.listKey} on ${op.blockId}`, { category: "not_found" });
388
+ return found;
389
+ }
390
+ if (typeof op.afterIndex === "number")
391
+ return op.afterIndex;
392
+ return undefined;
393
+ }
394
+ function _describeValidationIssue(error) {
395
+ const first = error.issues[0];
396
+ const path = first?.path?.length ? first.path.join(".") : "";
397
+ const message = first?.message ?? "Invalid value";
398
+ return path ? `${path}: ${message}` : message;
399
+ }
400
+ /**
401
+ * Inline edits commit every field value as a string (see inlineEditCommit). For
402
+ * manifest-driven custom blocks whose props are typed `boolean`/`number`, that
403
+ * raw string fails both the passthrough Zod parse and the JSON-schema type check.
404
+ * Coerce top-level scalar strings to the type the manifest schema declares so a
405
+ * boolean toggle ("true"/"false") or numeric input round-trips correctly. Only
406
+ * unambiguous values are coerced; anything else is left for validation to reject.
407
+ */
408
+ function _coerceScalarsToManifestSchema(propsSchema, props) {
409
+ const properties = propsSchema.properties;
410
+ if (!properties || typeof properties !== "object")
411
+ return props;
412
+ let next;
413
+ for (const [key, value] of Object.entries(props)) {
414
+ if (typeof value !== "string")
415
+ continue;
416
+ const prop = properties[key];
417
+ if (!prop || typeof prop !== "object" || Array.isArray(prop))
418
+ continue;
419
+ const type = prop.type;
420
+ let coercedValue;
421
+ if (type === "boolean" && (value === "true" || value === "false")) {
422
+ coercedValue = value === "true";
423
+ }
424
+ else if ((type === "number" || type === "integer") && value.trim() !== "" && Number.isFinite(Number(value))) {
425
+ coercedValue = Number(value);
426
+ }
427
+ else {
428
+ continue;
429
+ }
430
+ next ??= { ...props };
431
+ next[key] = coercedValue;
432
+ }
433
+ return next ?? props;
434
+ }
435
+ function _validateWithManifestIfPresent(manifestByType, blockType, nextProps) {
436
+ const manifestComponent = manifestByType.get(blockType);
437
+ if (manifestComponent) {
438
+ nextProps = _coerceScalarsToManifestSchema(manifestComponent.propsSchema, nextProps);
439
+ // Passthrough Zod keeps extra keys so site-specific fields survive coercion.
440
+ const ptSchema = _getPassthroughSchema(blockType);
441
+ let coerced = nextProps;
442
+ if (ptSchema) {
443
+ const result = ptSchema.safeParse(nextProps);
444
+ if (result.success)
445
+ coerced = result.data;
446
+ }
447
+ if (!validateByJsonSchemaLike(manifestComponent.propsSchema, coerced)) {
448
+ throw new OperationError(`Invalid props for ${blockType}: does not match block manifest schema`, { category: "schema_violation" });
449
+ }
450
+ return coerced;
451
+ }
452
+ // No manifest entry — validate against the registered schema, but keep the
453
+ // keys it does not declare.
454
+ //
455
+ // This used to return the stripped parse, which meant the two write paths
456
+ // did not preserve the same data: an edit from the property panel arrives
457
+ // with a manifest and keeps site-owned props (above), while an edit from
458
+ // chat arrives without one and silently deleted them. A block carrying its
459
+ // own bookkeeping — a CMS source snapshot, the locale it was read in, the
460
+ // origin slot a migration recorded — lost it to the first chat turn and
461
+ // could no longer be published, with the op reporting success either way.
462
+ //
463
+ // Validation is unchanged: the declared schema still decides whether the
464
+ // props are legal, and still produces the error message. Only the returned
465
+ // object differs, and only in keeping what the schema never spoke to.
466
+ const propCheck = validateBlockProps(blockType, nextProps);
467
+ if (!propCheck.success)
468
+ throw new OperationError(`Invalid props for ${blockType}: ${_describeValidationIssue(propCheck.error)}`, { category: "schema_violation" });
469
+ const ptSchema = _getPassthroughSchema(blockType);
470
+ if (ptSchema) {
471
+ const kept = ptSchema.safeParse(nextProps);
472
+ if (kept.success)
473
+ return kept.data;
474
+ }
475
+ return propCheck.data;
476
+ }
477
+ function _requireManifestComponent(manifestByType, blockType, operationName) {
478
+ if (manifestByType.size === 0)
479
+ return;
480
+ if (manifestByType.has(blockType))
481
+ return;
482
+ throw new OperationError(`Cannot ${operationName} for "${blockType}" because it is not declared in components manifest`, { category: "not_found" });
483
+ }
484
+ function _allowedPatchKeysFromManifest(manifestByType, blockType, fallbackKeys) {
485
+ const manifestComponent = manifestByType.get(blockType);
486
+ if (!manifestComponent)
487
+ return fallbackKeys;
488
+ const schema = manifestComponent.propsSchema;
489
+ const schemaType = typeof schema.type === "string" ? schema.type : "object";
490
+ if (schemaType !== "object")
491
+ return fallbackKeys;
492
+ const properties = schema.properties;
493
+ if (!properties || typeof properties !== "object" || Array.isArray(properties))
494
+ return fallbackKeys;
495
+ return Object.keys(properties);
496
+ }
497
+ function _withValidatedBlockProps(manifestByType, block, nextProps) {
498
+ return _validateWithManifestIfPresent(manifestByType, block.type, nextProps);
499
+ }
500
+ // ---------------------------------------------------------------------------
501
+ // Typed contract validation — validates Operation[] against Zod schema at
502
+ // the NLP → ops-engine boundary so post-planner transformations cannot
503
+ // silently introduce malformed operations.
504
+ // ---------------------------------------------------------------------------
505
+ const opsArraySchema = z.array(operationSchema);
506
+ /**
507
+ * Validate an array of operations against the canonical Zod schema.
508
+ * Returns the parsed (typed) operations or throws an `OperationError`
509
+ * with category `schema_violation`.
510
+ */
511
+ export function validateOperations(ops) {
512
+ const result = opsArraySchema.safeParse(ops);
513
+ if (!result.success) {
514
+ const first = result.error.issues[0];
515
+ const path = first?.path?.length ? ` at ops${first.path.map((p) => typeof p === "number" ? `[${p}]` : `.${String(p)}`).join("")}` : "";
516
+ const detail = first?.message ?? "Invalid operation";
517
+ throw new OperationError(`Operation contract violation${path}: ${detail}`, { category: "schema_violation" });
518
+ }
519
+ return result.data;
520
+ }
521
+ // ---------------------------------------------------------------------------
522
+ // Atomic operation application
523
+ // ---------------------------------------------------------------------------
524
+ export async function applyOpsAtomically(session, ops, options) {
525
+ const release = await acquireSessionLock(session);
526
+ try {
527
+ return await _applyOpsAtomicallyUnsafe(session, ops, options);
528
+ }
529
+ finally {
530
+ release();
531
+ }
532
+ }
533
+ async function _applyOpsAtomicallyUnsafe(session, ops, options) {
534
+ const manifestByType = new Map();
535
+ if (options?.componentsManifest) {
536
+ for (const component of options.componentsManifest.blocks) {
537
+ manifestByType.set(component.type, component);
538
+ }
539
+ }
540
+ const cs = options?.contentSource;
541
+ const staged = new Map();
542
+ if (cs) {
543
+ const pages = await cs.getSessionPages(session);
544
+ for (const page of pages)
545
+ staged.set(page.slug, structuredClone(page));
546
+ }
547
+ else {
548
+ const sessionDraft = getSessionDraft(session);
549
+ for (const [slug, page] of sessionDraft)
550
+ staged.set(slug, structuredClone(page));
551
+ }
552
+ const touchedSlugs = new Set();
553
+ const deletedSlugs = new Set();
554
+ const skippedOps = [];
555
+ const fuzzyMatches = [];
556
+ const duplicatedPages = [];
557
+ const opResults = [];
558
+ let orderChanged = false;
559
+ let configChanged = false;
560
+ const originalSiteConfig = cs ? await cs.getSiteConfig(session) : getSiteConfig(session);
561
+ let stagedSiteConfig = originalSiteConfig;
562
+ // Validate-only: keep a pristine pre-plan snapshot so we can diff before→after
563
+ // without ever committing. Cloned up front, before any op mutates `staged`.
564
+ const dryRun = options?.dryRun === true;
565
+ const allowNoEffectiveChange = options?.allowNoEffectiveChange === true;
566
+ const beforePages = dryRun ? new Map([...staged].map(([k, v]) => [k, structuredClone(v)])) : null;
567
+ const beforeSiteConfig = dryRun ? structuredClone(originalSiteConfig) : null;
568
+ // Pre-check: reject plans with duplicate add_block IDs
569
+ const addBlockIds = new Set();
570
+ for (const op of ops) {
571
+ if (op.op === "add_block") {
572
+ if (addBlockIds.has(op.block.id)) {
573
+ throw new OperationError(`Duplicate block id "${op.block.id}" in plan — each add_block must use a unique id`, { category: "schema_violation" });
574
+ }
575
+ addBlockIds.add(op.block.id);
576
+ }
577
+ }
578
+ // Demo-mode gate: when DEMO_MODE=1, only permit the narrow allow-list
579
+ // (defaults to `update_props` on `Hero` blocks). Throws OperationError if
580
+ // any op would fall outside the allow-list. Runs AFTER staging is built
581
+ // so we can resolve blockId → blockType from the current draft.
582
+ if (isDemoModeEnabled()) {
583
+ enforceDemoOps(ops, staged);
584
+ }
585
+ for (let opIndex = 0; opIndex < ops.length; opIndex += 1) {
586
+ const op = ops[opIndex];
587
+ // Per-op isolation for dry-run: snapshot everything this op could mutate so
588
+ // a failing op can be rolled back and reported (instead of aborting the
589
+ // plan), leaving a clean state for the ops that follow.
590
+ const skippedBefore = skippedOps.length;
591
+ const snap = dryRun
592
+ ? {
593
+ staged: new Map([...staged].map(([k, v]) => [k, structuredClone(v)])),
594
+ siteConfig: stagedSiteConfig,
595
+ touched: new Set(touchedSlugs),
596
+ deleted: new Set(deletedSlugs),
597
+ orderChanged,
598
+ configChanged,
599
+ fuzzyLen: fuzzyMatches.length,
600
+ dupLen: duplicatedPages.length,
601
+ }
602
+ : null;
603
+ let opFailed = false;
604
+ try {
605
+ if (op.op === "update_site_config") {
606
+ const merged = { ...stagedSiteConfig };
607
+ if (op.patch.name !== undefined)
608
+ merged.name = op.patch.name;
609
+ if (op.patch.logo !== undefined)
610
+ merged.logo = op.patch.logo;
611
+ if (op.patch.navLabels !== undefined) {
612
+ merged.navLabels = { ...(stagedSiteConfig.navLabels ?? {}), ...op.patch.navLabels };
613
+ }
614
+ if (op.patch.navGroups !== undefined) {
615
+ merged.navGroups = op.patch.navGroups; // full replacement — groups are atomic
616
+ }
617
+ stagedSiteConfig = merged;
618
+ configChanged = true;
619
+ continue;
620
+ }
621
+ if (op.op === "update_theme") {
622
+ // Expand semantic tokens (brandColor, headingFont, …) to CSS vars, then
623
+ // overlay the raw cssVars escape hatch so explicit vars win. Both are
624
+ // merge-patches over the existing overrides; an empty-string value clears
625
+ // that var so the theme default takes over again.
626
+ const incoming = { ...mapSemanticThemeTokens(op.patch), ...(op.cssVars ?? {}) };
627
+ const nextOverrides = { ...(stagedSiteConfig.themeOverrides ?? {}) };
628
+ let themeChanged = false;
629
+ for (const [cssVar, value] of Object.entries(incoming)) {
630
+ if (value === "") {
631
+ if (cssVar in nextOverrides) {
632
+ delete nextOverrides[cssVar];
633
+ themeChanged = true;
634
+ }
635
+ }
636
+ else if (nextOverrides[cssVar] !== value) {
637
+ nextOverrides[cssVar] = value;
638
+ themeChanged = true;
639
+ }
640
+ }
641
+ if (themeChanged) {
642
+ stagedSiteConfig = { ...stagedSiteConfig, themeOverrides: nextOverrides };
643
+ configChanged = true;
644
+ }
645
+ continue;
646
+ }
647
+ if (op.op === "create_page") {
648
+ // Normalize required PageDoc fields the caller may have omitted. Agent
649
+ // and chat callers only pass slug/title/blocks — the `id` and
650
+ // `updatedAt` fields are required by `pageDocSchemaLenient`, and if
651
+ // they're missing the site's draft fetcher rejects the page as
652
+ // malformed and renders "Draft unavailable". Backfilling here covers
653
+ // every create path (agent tool, chat planner, demo seeding, SDK).
654
+ const incoming = structuredClone(op.page);
655
+ const normalized = {
656
+ ...incoming,
657
+ id: incoming.id && incoming.id.length > 0 ? incoming.id : pageIdFromSlug(incoming.slug),
658
+ slug: incoming.slug,
659
+ title: incoming.title && incoming.title.trim().length > 0 ? incoming.title : pageTitleFromSlug(incoming.slug),
660
+ updatedAt: incoming.updatedAt && incoming.updatedAt.length > 0 ? incoming.updatedAt : new Date().toISOString(),
661
+ };
662
+ staged.set(normalized.slug, normalized);
663
+ touchedSlugs.add(normalized.slug);
664
+ continue;
665
+ }
666
+ if (op.op === "duplicate_page") {
667
+ const source = staged.get(op.pageSlug);
668
+ if (!source)
669
+ throw new OperationError(`Page not found for slug ${op.pageSlug}`, { category: "not_found" });
670
+ const nextSlug = normalizeRouteCandidate(op.newPageSlug) ?? _nextDuplicateSlug(staged, op.pageSlug);
671
+ if (staged.has(nextSlug))
672
+ throw new OperationError(`Target page slug already exists: ${nextSlug}`, { category: "schema_violation" });
673
+ op.newPageSlug = nextSlug;
674
+ const explicitNewTitle = typeof op.newTitle === "string" && op.newTitle.trim().length > 0 ? op.newTitle.trim() : undefined;
675
+ const blockIdMap = {};
676
+ const nextBlocks = source.blocks.map((block) => {
677
+ const newId = _nextUniqueBlockId(source.blocks, `${block.id}_copy`);
678
+ blockIdMap[block.id] = newId;
679
+ return { ...block, id: newId };
680
+ });
681
+ // source was already deep-cloned at entry into `staged`; spread is sufficient
682
+ // since all later mutations replace blocks/props wholesale rather than mutating in-place.
683
+ const copy = {
684
+ ...source,
685
+ id: pageIdFromSlug(nextSlug),
686
+ slug: nextSlug,
687
+ title: explicitNewTitle ?? `${source.title} Copy`,
688
+ updatedAt: new Date().toISOString(),
689
+ blocks: nextBlocks,
690
+ // When caller passes newTitle, keep meta.title in sync so SEO doesn't show
691
+ // the source page's English title on a translated copy. Other meta fields
692
+ // (description, ogImage) stay — caller can patch them with update_page_meta.
693
+ meta: explicitNewTitle && source.meta
694
+ ? { ...source.meta, title: explicitNewTitle }
695
+ : explicitNewTitle
696
+ ? { title: explicitNewTitle }
697
+ : source.meta
698
+ };
699
+ staged.set(nextSlug, copy);
700
+ touchedSlugs.add(nextSlug);
701
+ duplicatedPages.push({ slug: nextSlug, blockIdMap });
702
+ const finalOrder = _rebuildOrderWithInserted(staged, nextSlug, op.afterPageSlug ?? op.pageSlug);
703
+ const reordered = new Map();
704
+ for (const route of finalOrder) {
705
+ const page = staged.get(route);
706
+ if (page)
707
+ reordered.set(route, page);
708
+ }
709
+ staged.clear();
710
+ for (const [route, page] of reordered)
711
+ staged.set(route, page);
712
+ orderChanged = true;
713
+ continue;
714
+ }
715
+ if (op.op === "rename_page") {
716
+ const page = staged.get(op.pageSlug);
717
+ if (!page)
718
+ throw new OperationError(`Page not found for slug ${op.pageSlug}`, { category: "not_found" });
719
+ const incomingSlug = op.newPageSlug !== undefined ? normalizeRouteCandidate(op.newPageSlug) : undefined;
720
+ if (op.newPageSlug !== undefined && !incomingSlug) {
721
+ throw new OperationError(`Invalid newPageSlug ${op.newPageSlug}`, { category: "schema_violation" });
722
+ }
723
+ const trimmedTitle = typeof op.newTitle === "string" ? op.newTitle.trim() : undefined;
724
+ const slugChanged = incomingSlug !== undefined && incomingSlug !== op.pageSlug;
725
+ const titleChanged = trimmedTitle !== undefined && trimmedTitle.length > 0 && trimmedTitle !== page.title;
726
+ if (!slugChanged && !titleChanged) {
727
+ if (allowNoEffectiveChange) {
728
+ skippedOps.push({ index: opIndex + 1, op: op.op, reason: "unchanged_value", pageSlug: op.pageSlug });
729
+ continue;
730
+ }
731
+ const slugSegment = incomingSlug !== undefined
732
+ ? `newPageSlug=${JSON.stringify(incomingSlug)} matches current`
733
+ : "newPageSlug not provided";
734
+ const titleSegment = trimmedTitle !== undefined
735
+ ? `newTitle=${JSON.stringify(trimmedTitle)} matches current title ${JSON.stringify(page.title)}`
736
+ : `newTitle not provided (current title ${JSON.stringify(page.title)})`;
737
+ throw new OperationError(`No effective page change for ${op.pageSlug}: ${slugSegment}; ${titleSegment}. Provide newPageSlug different from ${JSON.stringify(op.pageSlug)} and/or newTitle different from ${JSON.stringify(page.title)}.`, { category: "no_effective_change" });
738
+ }
739
+ if (slugChanged) {
740
+ const nextSlug = incomingSlug;
741
+ if (staged.has(nextSlug))
742
+ throw new OperationError(`Target page slug already exists: ${nextSlug}`, { category: "schema_violation" });
743
+ deletedSlugs.add(op.pageSlug);
744
+ const renamedPage = {
745
+ ...page,
746
+ id: pageIdFromSlug(nextSlug),
747
+ slug: nextSlug,
748
+ title: titleChanged ? trimmedTitle : pageTitleFromSlug(nextSlug),
749
+ updatedAt: new Date().toISOString()
750
+ };
751
+ touchedSlugs.add(nextSlug);
752
+ // Rebuild the map to preserve the renamed page's position in nav order.
753
+ const entries = Array.from(staged.entries());
754
+ staged.clear();
755
+ for (const [slug, p] of entries) {
756
+ if (slug === op.pageSlug) {
757
+ staged.set(nextSlug, renamedPage);
758
+ }
759
+ else {
760
+ staged.set(slug, p);
761
+ }
762
+ }
763
+ // Keep route references consistent after a slug change.
764
+ for (const [slug, candidate] of staged) {
765
+ const rewritten = rewriteLinksToRenamedPage(candidate, op.pageSlug, nextSlug);
766
+ if (!rewritten.changed)
767
+ continue;
768
+ staged.set(slug, rewritten.page);
769
+ touchedSlugs.add(slug);
770
+ }
771
+ }
772
+ else {
773
+ // Title-only rename: same slug, new display title. No link rewriting needed.
774
+ const renamedPage = {
775
+ ...page,
776
+ title: trimmedTitle,
777
+ updatedAt: new Date().toISOString()
778
+ };
779
+ staged.set(op.pageSlug, renamedPage);
780
+ touchedSlugs.add(op.pageSlug);
781
+ }
782
+ continue;
783
+ }
784
+ if (op.op === "remove_page") {
785
+ if (op.pageSlug === "/")
786
+ throw new OperationError("Cannot remove the home page (/)", { category: "schema_violation" });
787
+ const page = staged.get(op.pageSlug);
788
+ if (!page)
789
+ throw new OperationError(`Page not found for slug ${op.pageSlug}`, { category: "not_found" });
790
+ if (staged.size <= 1)
791
+ throw new OperationError("Cannot remove the last remaining page", { category: "schema_violation" });
792
+ staged.delete(op.pageSlug);
793
+ deletedSlugs.add(op.pageSlug);
794
+ continue;
795
+ }
796
+ if (op.op === "move_page") {
797
+ if (op.pageSlug === "/")
798
+ throw new OperationError("Home page (/) cannot be moved", { category: "schema_violation" });
799
+ if (!staged.has(op.pageSlug))
800
+ throw new OperationError(`Page not found for slug ${op.pageSlug}`, { category: "not_found" });
801
+ const ordered = orderSlugsHomeFirst(Array.from(staged.keys()));
802
+ const movable = ordered.filter((route) => route !== "/");
803
+ const currentIdx = movable.findIndex((route) => route === op.pageSlug);
804
+ if (currentIdx === -1)
805
+ throw new OperationError(`Page not found for slug ${op.pageSlug}`, { category: "not_found" });
806
+ const nextMovable = movable.filter((route) => route !== op.pageSlug);
807
+ let insertIndex = 0;
808
+ if (op.afterPageSlug) {
809
+ if (op.afterPageSlug === "/")
810
+ insertIndex = 0;
811
+ else {
812
+ const anchorIdx = nextMovable.findIndex((route) => route === op.afterPageSlug);
813
+ if (anchorIdx === -1)
814
+ throw new OperationError(`afterPageSlug ${op.afterPageSlug} not found`, { category: "not_found" });
815
+ insertIndex = anchorIdx + 1;
816
+ }
817
+ }
818
+ nextMovable.splice(insertIndex, 0, op.pageSlug);
819
+ const finalOrder = ordered.includes("/") ? ["/", ...nextMovable] : nextMovable;
820
+ const reordered = new Map();
821
+ for (const route of finalOrder) {
822
+ const page = staged.get(route);
823
+ if (!page)
824
+ continue;
825
+ reordered.set(route, page);
826
+ }
827
+ staged.clear();
828
+ for (const [route, page] of reordered)
829
+ staged.set(route, page);
830
+ orderChanged = true;
831
+ continue;
832
+ }
833
+ if (op.op === "update_page_meta") {
834
+ const page = staged.get(op.pageSlug);
835
+ if (!page)
836
+ throw new OperationError(`Page not found for slug ${op.pageSlug}`, { category: "not_found" });
837
+ const patch = op.patch;
838
+ const patchKeys = Object.keys(patch).filter((k) => patch[k] !== undefined);
839
+ if (patchKeys.length === 0) {
840
+ if (allowNoEffectiveChange) {
841
+ skippedOps.push({ index: opIndex + 1, op: op.op, reason: "unchanged_value", pageSlug: op.pageSlug });
842
+ continue;
843
+ }
844
+ throw new OperationError(`No effective meta change for ${op.pageSlug}: patch contained no defined keys. Provide at least one of title, description, ogImage with a non-undefined value.`, { category: "no_effective_change" });
845
+ }
846
+ const current = page.meta ?? {};
847
+ const next = { ...current };
848
+ let changed = false;
849
+ for (const key of patchKeys) {
850
+ const value = patch[key];
851
+ if (typeof value === "string" && value.length === 0) {
852
+ if (current[key] !== undefined) {
853
+ delete next[key];
854
+ changed = true;
855
+ }
856
+ }
857
+ else {
858
+ if (current[key] !== value) {
859
+ next[key] = value;
860
+ changed = true;
861
+ }
862
+ }
863
+ }
864
+ if (!changed) {
865
+ if (allowNoEffectiveChange) {
866
+ skippedOps.push({ index: opIndex + 1, op: op.op, reason: "unchanged_value", pageSlug: op.pageSlug });
867
+ continue;
868
+ }
869
+ const incomingSummary = patchKeys
870
+ .map((k) => `${k}=${JSON.stringify(patch[k])}`)
871
+ .join(", ");
872
+ const currentSummary = patchKeys
873
+ .map((k) => `${k}=${JSON.stringify(current[k])}`)
874
+ .join(", ");
875
+ throw new OperationError(`No effective meta change for ${op.pageSlug}: provided ${incomingSummary} already matches current ${currentSummary}.`, { category: "no_effective_change" });
876
+ }
877
+ page.meta = Object.keys(next).length > 0 ? next : undefined;
878
+ page.updatedAt = new Date().toISOString();
879
+ touchedSlugs.add(page.slug);
880
+ continue;
881
+ }
882
+ const page = staged.get(op.pageSlug);
883
+ if (!page)
884
+ throw new OperationError(`Page not found for slug ${op.pageSlug}`, { category: "not_found" });
885
+ if (op.op === "add_block") {
886
+ if (isChrome(op.block.type))
887
+ throw new OperationError(`Cannot add chrome block type "${op.block.type}"`, { category: "schema_violation" });
888
+ _requireManifestComponent(manifestByType, op.block.type, "add block");
889
+ const validatedProps = _validateWithManifestIfPresent(manifestByType, op.block.type, op.block.props);
890
+ const alreadyExists = page.blocks.some((b) => b.id === op.block.id);
891
+ if (alreadyExists)
892
+ throw new OperationError(`Block id ${op.block.id} already exists`, { category: "schema_violation" });
893
+ if (!op.afterBlockId) {
894
+ page.blocks.push({ ...op.block, props: validatedProps });
895
+ }
896
+ else {
897
+ let idx = page.blocks.findIndex((b) => b.id === op.afterBlockId);
898
+ // Fuzzy fallback: LLM batch plans sometimes use inconsistent IDs for
899
+ // blocks added in earlier ops (e.g. "b_testimonials_about" vs
900
+ // "b_testimonials_1772…"). Match by block type when exact ID fails.
901
+ if (idx === -1) {
902
+ const typeMatch = op.afterBlockId.match(/^b_([a-z]+)/i);
903
+ if (typeMatch) {
904
+ const typePrefix = `b_${typeMatch[1].toLowerCase()}_`;
905
+ for (let i = page.blocks.length - 1; i >= 0; i -= 1) {
906
+ if (page.blocks[i].id.startsWith(typePrefix)) {
907
+ idx = i;
908
+ break;
909
+ }
910
+ }
911
+ }
912
+ }
913
+ if (idx === -1)
914
+ throw new OperationError(`afterBlockId ${op.afterBlockId} not found`, { category: "not_found" });
915
+ page.blocks.splice(idx + 1, 0, { ...op.block, props: validatedProps });
916
+ }
917
+ page.updatedAt = new Date().toISOString();
918
+ touchedSlugs.add(page.slug);
919
+ continue;
920
+ }
921
+ if (op.op === "duplicate_block") {
922
+ const idx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
923
+ if (idx === -1)
924
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
925
+ const source = page.blocks[idx];
926
+ if (isChrome(source.type))
927
+ throw new OperationError(`Cannot duplicate chrome block "${op.blockId}"`, { category: "schema_violation" });
928
+ _requireManifestComponent(manifestByType, source.type, "duplicate block");
929
+ const targetPageSlug = typeof op.toPageSlug === "string" && op.toPageSlug.length > 0 ? op.toPageSlug : op.pageSlug;
930
+ const targetPage = staged.get(targetPageSlug);
931
+ if (!targetPage)
932
+ throw new OperationError(`Target page not found for slug ${targetPageSlug}`, { category: "not_found" });
933
+ const nextId = _nextUniqueBlockId(targetPage.blocks, typeof op.newBlockId === "string" ? op.newBlockId : `${source.id}_copy`);
934
+ op.newBlockId = nextId;
935
+ // source is from `staged` (already deep-cloned at entry); spread suffices
936
+ const duplicate = { ...source, id: nextId };
937
+ if (!op.afterBlockId) {
938
+ if (targetPageSlug === op.pageSlug)
939
+ page.blocks.splice(idx + 1, 0, duplicate);
940
+ else
941
+ targetPage.blocks.push(duplicate);
942
+ }
943
+ else {
944
+ const anchorIdx = targetPage.blocks.findIndex((b) => b.id === op.afterBlockId);
945
+ if (anchorIdx === -1)
946
+ throw new OperationError(`afterBlockId ${op.afterBlockId} not found`, { category: "not_found" });
947
+ targetPage.blocks.splice(anchorIdx + 1, 0, duplicate);
948
+ }
949
+ targetPage.updatedAt = new Date().toISOString();
950
+ touchedSlugs.add(targetPage.slug);
951
+ continue;
952
+ }
953
+ if (op.op === "add_item") {
954
+ const blockIdx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
955
+ if (blockIdx === -1)
956
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
957
+ const block = page.blocks[blockIdx];
958
+ _requireManifestComponent(manifestByType, block.type, "add list items");
959
+ const list = _listValueForOp(block, op.listKey);
960
+ const nextList = [...list];
961
+ const anchor = _resolveAfterIndex(nextList, op);
962
+ const insertIndex = typeof anchor === "number" ? anchor + 1 : nextList.length;
963
+ if (insertIndex < 0 || insertIndex > nextList.length) {
964
+ throw new OperationError(`afterIndex ${op.afterIndex} is out of range for ${op.listKey}`, { category: "schema_violation" });
965
+ }
966
+ // Give the new item a stable id (callers may omit it) so later ops can
967
+ // target it without a fragile index. A supplied id that collides with an
968
+ // existing item in this list is replaced too — LLMs copy items verbatim
969
+ // (id included), and a duplicate id would make every later id lookup
970
+ // silently resolve to the first occurrence.
971
+ const newItem = structuredClone(op.item);
972
+ if (typeof newItem.id !== "string" ||
973
+ newItem.id.length === 0 ||
974
+ _itemIndexById(nextList, newItem.id) !== -1) {
975
+ newItem.id = generateItemId();
976
+ }
977
+ nextList.splice(insertIndex, 0, newItem);
978
+ const nextProps = { ...block.props, [op.listKey]: nextList };
979
+ page.blocks[blockIdx] = { ...block, props: _withValidatedBlockProps(manifestByType, block, nextProps) };
980
+ page.updatedAt = new Date().toISOString();
981
+ touchedSlugs.add(page.slug);
982
+ continue;
983
+ }
984
+ if (op.op === "update_item") {
985
+ const blockIdx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
986
+ if (blockIdx === -1)
987
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
988
+ const block = page.blocks[blockIdx];
989
+ _requireManifestComponent(manifestByType, block.type, "update list items");
990
+ const list = _listValueForOp(block, op.listKey);
991
+ const targetIndex = _resolveItemIndex(list, op);
992
+ const currentItem = list[targetIndex];
993
+ if (!currentItem || typeof currentItem !== "object" || Array.isArray(currentItem)) {
994
+ throw new OperationError(`List item ${op.listKey}[${targetIndex}] is not an object`, { category: "schema_violation" });
995
+ }
996
+ // A patch must never clobber the stable item id (it's the addressing key).
997
+ const itemPatch = { ...op.patch };
998
+ delete itemPatch.id;
999
+ const nextList = list.map((entry, idx) => {
1000
+ if (idx !== targetIndex)
1001
+ return entry;
1002
+ return { ...entry, ...itemPatch };
1003
+ });
1004
+ const nextProps = { ...block.props, [op.listKey]: nextList };
1005
+ page.blocks[blockIdx] = { ...block, props: _withValidatedBlockProps(manifestByType, block, nextProps) };
1006
+ page.updatedAt = new Date().toISOString();
1007
+ touchedSlugs.add(page.slug);
1008
+ continue;
1009
+ }
1010
+ if (op.op === "remove_item") {
1011
+ const blockIdx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
1012
+ if (blockIdx === -1)
1013
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
1014
+ const block = page.blocks[blockIdx];
1015
+ _requireManifestComponent(manifestByType, block.type, "remove list items");
1016
+ const list = _listValueForOp(block, op.listKey);
1017
+ const removeIndex = _resolveItemIndex(list, op);
1018
+ const nextList = [...list];
1019
+ nextList.splice(removeIndex, 1);
1020
+ const nextProps = { ...block.props, [op.listKey]: nextList };
1021
+ page.blocks[blockIdx] = { ...block, props: _withValidatedBlockProps(manifestByType, block, nextProps) };
1022
+ page.updatedAt = new Date().toISOString();
1023
+ touchedSlugs.add(page.slug);
1024
+ continue;
1025
+ }
1026
+ if (op.op === "move_item") {
1027
+ const blockIdx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
1028
+ if (blockIdx === -1)
1029
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
1030
+ const block = page.blocks[blockIdx];
1031
+ _requireManifestComponent(manifestByType, block.type, "reorder list items");
1032
+ const list = _listValueForOp(block, op.listKey);
1033
+ const nextList = [...list];
1034
+ const fromIndex = _resolveItemIndex(nextList, op);
1035
+ // Resolve the anchor against the pre-removal list so afterItemId/afterIndex
1036
+ // mean the same thing they did when the op was planned.
1037
+ const anchor = _resolveAfterIndex(nextList, op);
1038
+ const [item] = nextList.splice(fromIndex, 1);
1039
+ if (item === undefined)
1040
+ throw new OperationError(`item op on ${op.listKey} resolved to an empty slot`, { category: "schema_violation" });
1041
+ // Removing the item shifts down every later index by one.
1042
+ const normalizedAnchor = typeof anchor === "number" && anchor > fromIndex ? anchor - 1 : anchor;
1043
+ const insertIndex = typeof normalizedAnchor === "number" ? normalizedAnchor + 1 : 0;
1044
+ if (insertIndex < 0 || insertIndex > nextList.length) {
1045
+ throw new OperationError(`afterIndex ${op.afterIndex} is out of range for ${op.listKey}`, { category: "schema_violation" });
1046
+ }
1047
+ nextList.splice(insertIndex, 0, item);
1048
+ const nextProps = { ...block.props, [op.listKey]: nextList };
1049
+ page.blocks[blockIdx] = { ...block, props: _withValidatedBlockProps(manifestByType, block, nextProps) };
1050
+ page.updatedAt = new Date().toISOString();
1051
+ touchedSlugs.add(page.slug);
1052
+ continue;
1053
+ }
1054
+ if (op.op === "reorder_items") {
1055
+ const blockIdx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
1056
+ if (blockIdx === -1)
1057
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
1058
+ const block = page.blocks[blockIdx];
1059
+ _requireManifestComponent(manifestByType, block.type, "reorder list items");
1060
+ const list = _listValueForOp(block, op.listKey);
1061
+ if (op.order.length !== list.length) {
1062
+ throw new OperationError(`order must list all ${list.length} items of ${op.listKey} exactly once (got ${op.order.length})`, { category: "schema_violation" });
1063
+ }
1064
+ // Resolve each order entry (stable item id or current 0-based index) to a
1065
+ // current index, rejecting unknowns and duplicates so the result is a
1066
+ // strict permutation — a dropped or doubled item would be silent data loss.
1067
+ const used = new Set();
1068
+ const resolved = [];
1069
+ for (const entry of op.order) {
1070
+ let idx;
1071
+ if (typeof entry === "string") {
1072
+ idx = _itemIndexById(list, entry);
1073
+ if (idx === -1)
1074
+ throw new OperationError(`itemId ${entry} not found in ${op.listKey} on ${op.blockId}`, { category: "not_found" });
1075
+ }
1076
+ else {
1077
+ if (entry < 0 || entry >= list.length)
1078
+ throw new OperationError(`index ${entry} is out of range for ${op.listKey}`, { category: "schema_violation" });
1079
+ idx = entry;
1080
+ }
1081
+ if (used.has(idx)) {
1082
+ throw new OperationError(`order lists item ${typeof entry === "string" ? entry : `#${entry}`} of ${op.listKey} more than once`, { category: "schema_violation" });
1083
+ }
1084
+ used.add(idx);
1085
+ resolved.push(idx);
1086
+ }
1087
+ if (resolved.every((idx, pos) => idx === pos)) {
1088
+ // Same treatment as an unchanged update_props patch: skip, don't fail the plan.
1089
+ skippedOps.push({ index: opIndex + 1, op: op.op, reason: "unchanged_value", pageSlug: op.pageSlug, blockId: op.blockId });
1090
+ continue;
1091
+ }
1092
+ const nextList = resolved.map((idx) => list[idx]);
1093
+ const nextProps = { ...block.props, [op.listKey]: nextList };
1094
+ page.blocks[blockIdx] = { ...block, props: _withValidatedBlockProps(manifestByType, block, nextProps) };
1095
+ page.updatedAt = new Date().toISOString();
1096
+ touchedSlugs.add(page.slug);
1097
+ continue;
1098
+ }
1099
+ if (op.op === "reorder_blocks") {
1100
+ // Chrome blocks (SiteHeader/Footer) are structurally pinned: they keep
1101
+ // their absolute positions and must not appear in `order`. Everything
1102
+ // else is reordered as one strict permutation.
1103
+ const chromePinned = [];
1104
+ const movable = [];
1105
+ page.blocks.forEach((b, i) => {
1106
+ if (isChrome(b.type))
1107
+ chromePinned.push({ block: b, idx: i });
1108
+ else
1109
+ movable.push(b);
1110
+ });
1111
+ const chromeListed = op.order.find((id) => chromePinned.some((c) => c.block.id === id));
1112
+ if (chromeListed) {
1113
+ throw new OperationError(`Cannot reorder chrome block "${chromeListed}" — it is pinned; omit it from order`, { category: "schema_violation" });
1114
+ }
1115
+ if (op.order.length !== movable.length) {
1116
+ throw new OperationError(`order must list all ${movable.length} non-chrome blocks of ${op.pageSlug} exactly once (got ${op.order.length})`, { category: "schema_violation" });
1117
+ }
1118
+ const movableById = new Map(movable.map((b) => [b.id, b]));
1119
+ const seenIds = new Set();
1120
+ const newMovable = [];
1121
+ for (const id of op.order) {
1122
+ const block = movableById.get(id);
1123
+ if (!block)
1124
+ throw new OperationError(`blockId ${id} not found on ${op.pageSlug}`, { category: "not_found" });
1125
+ if (seenIds.has(id))
1126
+ throw new OperationError(`order lists block ${id} more than once`, { category: "schema_violation" });
1127
+ seenIds.add(id);
1128
+ newMovable.push(block);
1129
+ }
1130
+ if (newMovable.every((b, pos) => b.id === movable[pos].id)) {
1131
+ skippedOps.push({ index: opIndex + 1, op: op.op, reason: "unchanged_value", pageSlug: op.pageSlug });
1132
+ continue;
1133
+ }
1134
+ const result = new Array(page.blocks.length);
1135
+ for (const { block, idx } of chromePinned)
1136
+ result[idx] = block;
1137
+ let mi = 0;
1138
+ for (let i = 0; i < result.length; i += 1) {
1139
+ if (result[i] === undefined)
1140
+ result[i] = newMovable[mi++];
1141
+ }
1142
+ page.blocks = result;
1143
+ page.updatedAt = new Date().toISOString();
1144
+ touchedSlugs.add(page.slug);
1145
+ continue;
1146
+ }
1147
+ if (op.op === "update_props") {
1148
+ const idx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
1149
+ if (idx === -1)
1150
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
1151
+ const block = page.blocks[idx];
1152
+ _requireManifestComponent(manifestByType, block.type, "update props");
1153
+ const rawPatch = op.patch;
1154
+ const patchCandidate = rawPatch && typeof rawPatch.props === "object" && rawPatch.props !== null && !Array.isArray(rawPatch.props)
1155
+ ? rawPatch.props
1156
+ : rawPatch;
1157
+ const patchKeys = Object.keys(patchCandidate ?? {});
1158
+ const schemaForType = blockSchemas[block.type];
1159
+ const schemaShape = schemaForType && typeof schemaForType === "object" && "shape" in schemaForType
1160
+ ? schemaForType.shape
1161
+ : null;
1162
+ const fallbackAllowedKeys = schemaShape ? Object.keys(schemaShape) : Object.keys(block.props);
1163
+ const allowedPatchKeys = _allowedPatchKeysFromManifest(manifestByType, block.type, fallbackAllowedKeys);
1164
+ const invalidPatchKeys = patchKeys.filter((key) => !allowedPatchKeys.includes(key));
1165
+ if (invalidPatchKeys.length > 0) {
1166
+ throw new OperationError(`Patch for ${block.id} (${block.type}) used unknown props: ${invalidPatchKeys.join(", ")}. Allowed props: ${allowedPatchKeys.join(", ")}`, { category: "schema_violation" });
1167
+ }
1168
+ const prevProps = block.props;
1169
+ const nextProps = { ...prevProps };
1170
+ for (const key of patchKeys) {
1171
+ const oldVal = prevProps[key];
1172
+ const newVal = patchCandidate[key];
1173
+ // Reject prompt-style imageAlt values (e.g. "add white feta crumbles",
1174
+ // "change the photo") — these are user instructions, not alt text.
1175
+ // Keep the previous alt instead of overwriting with junk.
1176
+ if (key === "imageAlt" && typeof newVal === "string" && _looksLikeUserInstruction(newVal)) {
1177
+ continue;
1178
+ }
1179
+ // Deep-merge arrays of objects by index so partial items inherit existing fields
1180
+ if (Array.isArray(oldVal) && Array.isArray(newVal)) {
1181
+ nextProps[key] = newVal.map((item, i) => {
1182
+ const prev = oldVal[i];
1183
+ if (prev && typeof prev === "object" && !Array.isArray(prev) && item && typeof item === "object" && !Array.isArray(item)) {
1184
+ return _sanitizeListItemImageAlt({ ...prev, ...item });
1185
+ }
1186
+ return _sanitizeListItemImageAlt(item);
1187
+ });
1188
+ }
1189
+ else {
1190
+ nextProps[key] = newVal;
1191
+ }
1192
+ }
1193
+ const validatedProps = _validateWithManifestIfPresent(manifestByType, block.type, nextProps);
1194
+ if (patchKeys.length === 0) {
1195
+ skippedOps.push({
1196
+ index: opIndex + 1,
1197
+ op: op.op,
1198
+ reason: "empty_patch",
1199
+ pageSlug: op.pageSlug,
1200
+ blockId: op.blockId
1201
+ });
1202
+ continue;
1203
+ }
1204
+ const hasEffectivePatchKey = patchKeys.some((key) => JSON.stringify(block.props[key]) !== JSON.stringify(validatedProps[key]));
1205
+ if (!hasEffectivePatchKey) {
1206
+ // Treat unchanged patch values as no-op so one stale field does not fail the whole plan.
1207
+ skippedOps.push({
1208
+ index: opIndex + 1,
1209
+ op: op.op,
1210
+ reason: "unchanged_value",
1211
+ pageSlug: op.pageSlug,
1212
+ blockId: op.blockId
1213
+ });
1214
+ continue;
1215
+ }
1216
+ page.blocks[idx] = { ...block, props: validatedProps };
1217
+ page.updatedAt = new Date().toISOString();
1218
+ touchedSlugs.add(page.slug);
1219
+ continue;
1220
+ }
1221
+ if (op.op === "remove_block") {
1222
+ const idx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
1223
+ if (idx === -1)
1224
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
1225
+ if (isChrome(page.blocks[idx].type))
1226
+ throw new OperationError(`Cannot remove chrome block "${op.blockId}"`, { category: "schema_violation" });
1227
+ _requireManifestComponent(manifestByType, page.blocks[idx].type, "remove block");
1228
+ page.blocks.splice(idx, 1);
1229
+ page.updatedAt = new Date().toISOString();
1230
+ touchedSlugs.add(page.slug);
1231
+ continue;
1232
+ }
1233
+ if (op.op === "move_block") {
1234
+ const idx = _resolveBlockIndex(page.blocks, op.blockId, fuzzyMatches);
1235
+ if (idx === -1)
1236
+ throw new OperationError(`blockId ${op.blockId} not found`, { category: "not_found" });
1237
+ if (isChrome(page.blocks[idx].type))
1238
+ throw new OperationError(`Cannot move chrome block "${op.blockId}"`, { category: "schema_violation" });
1239
+ _requireManifestComponent(manifestByType, page.blocks[idx].type, "move block");
1240
+ const [block] = page.blocks.splice(idx, 1);
1241
+ if (!op.afterBlockId) {
1242
+ page.blocks.unshift(block);
1243
+ }
1244
+ else {
1245
+ const afterIdx = page.blocks.findIndex((b) => b.id === op.afterBlockId);
1246
+ if (afterIdx === -1)
1247
+ throw new OperationError(`afterBlockId ${op.afterBlockId} not found`, { category: "not_found" });
1248
+ page.blocks.splice(afterIdx + 1, 0, block);
1249
+ }
1250
+ page.updatedAt = new Date().toISOString();
1251
+ touchedSlugs.add(page.slug);
1252
+ }
1253
+ }
1254
+ catch (err) {
1255
+ opFailed = true;
1256
+ // Non-dry-run keeps the original all-or-nothing contract: rethrow and the
1257
+ // staged clone is discarded, leaving the real draft untouched.
1258
+ if (!dryRun)
1259
+ throw err;
1260
+ // Roll the op's partial mutations back out of the staged state.
1261
+ if (snap) {
1262
+ staged.clear();
1263
+ for (const [k, v] of snap.staged)
1264
+ staged.set(k, v);
1265
+ stagedSiteConfig = snap.siteConfig;
1266
+ touchedSlugs.clear();
1267
+ for (const s of snap.touched)
1268
+ touchedSlugs.add(s);
1269
+ deletedSlugs.clear();
1270
+ for (const s of snap.deleted)
1271
+ deletedSlugs.add(s);
1272
+ orderChanged = snap.orderChanged;
1273
+ configChanged = snap.configChanged;
1274
+ fuzzyMatches.length = snap.fuzzyLen;
1275
+ duplicatedPages.length = snap.dupLen;
1276
+ }
1277
+ opResults.push({
1278
+ index: opIndex + 1,
1279
+ op: op.op,
1280
+ status: "failed",
1281
+ reason: _unifiedToErrorDetail(err),
1282
+ category: err instanceof OperationError ? err.category : "internal_error",
1283
+ });
1284
+ }
1285
+ finally {
1286
+ if (!opFailed) {
1287
+ const skipped = skippedOps.length > skippedBefore;
1288
+ opResults.push({
1289
+ index: opIndex + 1,
1290
+ op: op.op,
1291
+ status: skipped ? "skipped" : "applied",
1292
+ reason: skipped ? skippedOps[skippedOps.length - 1]?.reason : undefined,
1293
+ });
1294
+ }
1295
+ }
1296
+ }
1297
+ // A dry-run never aborts on "nothing changed" — an empty preview is a valid,
1298
+ // useful answer ("this plan would do nothing"). Only the committing path
1299
+ // treats a wholly-ineffective plan as an error.
1300
+ if (!dryRun && !allowNoEffectiveChange && touchedSlugs.size === 0 && deletedSlugs.size === 0 && !orderChanged && !configChanged) {
1301
+ if (skippedOps.length > 0 && skippedOps.length === ops.length) {
1302
+ throw new OperationError("No effective prop change across plan. All update patches matched existing values.", { category: "no_effective_change" });
1303
+ }
1304
+ throw new OperationError("Edit plan produced no changes", { category: "no_effective_change" });
1305
+ }
1306
+ if (dryRun) {
1307
+ // Diff the pristine pre-plan snapshot against the staged result — same pure
1308
+ // diff used by publish review, just with the draft (not published) as the
1309
+ // baseline. Never commits.
1310
+ const orderedBefore = orderSlugsHomeFirst(Array.from(beforePages.keys())).map((s) => beforePages.get(s));
1311
+ const orderedAfter = orderSlugsHomeFirst(Array.from(staged.keys())).map((s) => staged.get(s));
1312
+ const preview = computePublishDiff(orderedAfter, orderedBefore, {
1313
+ draftSiteConfig: stagedSiteConfig,
1314
+ publishedSiteConfig: beforeSiteConfig,
1315
+ });
1316
+ return {
1317
+ appliedCount: opResults.filter((r) => r.status === "applied").length,
1318
+ skippedOps,
1319
+ fuzzyMatches,
1320
+ duplicatedPages,
1321
+ opResults,
1322
+ preview,
1323
+ dryRun: true,
1324
+ };
1325
+ }
1326
+ if (cs) {
1327
+ // Async write-back through ContentSource
1328
+ const currentSlugs = await cs.getSlugs(session);
1329
+ for (const slug of currentSlugs) {
1330
+ if (!staged.has(slug))
1331
+ await cs.removePage(session, slug);
1332
+ }
1333
+ for (const [, page] of staged)
1334
+ await cs.setPage(session, page);
1335
+ if (configChanged)
1336
+ await cs.setSiteConfig(session, stagedSiteConfig);
1337
+ }
1338
+ else {
1339
+ const sessionDraft = getSessionDraft(session);
1340
+ sessionDraft.clear();
1341
+ for (const [, page] of staged)
1342
+ setPage(session, page);
1343
+ if (configChanged)
1344
+ setSiteConfig(session, stagedSiteConfig);
1345
+ }
1346
+ return {
1347
+ appliedCount: Math.max(0, ops.length - skippedOps.length),
1348
+ skippedOps,
1349
+ fuzzyMatches,
1350
+ duplicatedPages,
1351
+ opResults,
1352
+ preview: undefined,
1353
+ dryRun: false,
1354
+ };
1355
+ }
1356
+ // ---------------------------------------------------------------------------
1357
+ // Post-apply helpers
1358
+ // ---------------------------------------------------------------------------
1359
+ export function pickFocusBlockId(ops) {
1360
+ const add = ops.find((op) => op.op === "add_block");
1361
+ if (add && add.op === "add_block")
1362
+ return add.block.id;
1363
+ const duplicate = ops.find((op) => op.op === "duplicate_block");
1364
+ if (duplicate && duplicate.op === "duplicate_block" && typeof duplicate.newBlockId === "string")
1365
+ return duplicate.newBlockId;
1366
+ const listOp = ops.find((op) => op.op === "add_item" || op.op === "update_item" || op.op === "remove_item" || op.op === "move_item");
1367
+ if (listOp && "blockId" in listOp && typeof listOp.blockId === "string")
1368
+ return listOp.blockId;
1369
+ const move = ops.find((op) => op.op === "move_block");
1370
+ if (move && move.op === "move_block")
1371
+ return move.blockId;
1372
+ const update = ops.find((op) => op.op === "update_props");
1373
+ if (update && update.op === "update_props")
1374
+ return update.blockId;
1375
+ return undefined;
1376
+ }
1377
+ export function pickUpdatedSlug(session, currentSlug, ops) {
1378
+ const createdPages = ops.filter((op) => op.op === "create_page");
1379
+ if (createdPages.length === 1 && createdPages[0].op === "create_page")
1380
+ return createdPages[0].page.slug;
1381
+ // multiple create_page ops → fall through, no auto-nav; mentionedSlugs handles navigation
1382
+ const duplicate = ops.find((op) => op.op === "duplicate_page" && op.pageSlug === currentSlug);
1383
+ if (duplicate && duplicate.op === "duplicate_page")
1384
+ return duplicate.newPageSlug;
1385
+ const rename = ops.find((op) => op.op === "rename_page" && op.pageSlug === currentSlug);
1386
+ if (rename && rename.op === "rename_page")
1387
+ return rename.newPageSlug ?? rename.pageSlug;
1388
+ const current = getPage(session, currentSlug);
1389
+ if (current)
1390
+ return undefined;
1391
+ const draft = getSessionDraft(session);
1392
+ const first = orderSlugsHomeFirst(Array.from(draft.keys()))[0];
1393
+ return first;
1394
+ }