@avocadostudio-ai/orchestrator-core 0.3.0 → 0.3.2

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.
@@ -19,7 +19,7 @@ import { scopedSessionKey, setPage, bumpVersion, getSiteConfig, setSiteConfig }
19
19
  import { listImages as listGdriveImages, downloadImage as downloadGdriveImage, isGdriveConfigured, resolveGdriveFolderId, fileNameToAlt, } from "../image/gdrive-client.js";
20
20
  import sharp from "sharp";
21
21
  import { sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars, validateAndCorrectProps, fixFooterLinks, analyzeCodebase, cloneRepo, detectSitePort, startAndWaitForDevServer, getDraftModeSecret, packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss, defaultsTs, editorApiRoute, pageTsx, hybridPageTsx, blocksRegisterTsx, samplePagesJson, defaultLogoSvg, faviconSvg, } from "./sites-agent-shared.js";
22
- /** Convert a package name like "villa-puravida-web" → "Villa Puravida Web" */
22
+ /** Convert a package name like "coastal-villa-web" → "Coastal Villa Web" */
23
23
  function humanizePkgName(name) {
24
24
  return name
25
25
  .replace(/^@[^/]+\//, "") // strip scope
@@ -1171,6 +1171,7 @@ export async function runChatPipeline(ctx, body, options) {
1171
1171
  blockId: entry.blockId,
1172
1172
  blockType: entry.blockType,
1173
1173
  propName: entry.propName,
1174
+ allowedProps: entry.allowedProps,
1174
1175
  plannerSource: source,
1175
1176
  modelKey,
1176
1177
  modelUsed
@@ -15,6 +15,12 @@ export type HallucinatedProp = {
15
15
  blockId: string;
16
16
  blockType: string;
17
17
  propName: string;
18
+ /**
19
+ * What the block *does* accept. Present so the log line that records a strip
20
+ * carries the answer next to the question — the integrator who hit this spent
21
+ * nine minutes proving the block was fine, and this is the line they read.
22
+ */
23
+ allowedProps: string[];
18
24
  };
19
25
  export type HallucinationValidationResult = {
20
26
  plan: EditPlan;
@@ -45,6 +45,22 @@ function findBlockType(args) {
45
45
  }
46
46
  return undefined;
47
47
  }
48
+ /*
49
+ * Does this key read as a visual/presentational one?
50
+ *
51
+ * Deliberately a small allow-list of stems rather than a clever rule: the
52
+ * default has to be "content", because the expensive mistake is telling
53
+ * somebody their content edit was a styling limitation, not the reverse.
54
+ */
55
+ const VISUAL_PROP_STEMS = [
56
+ "color", "colour", "background", "gradient", "animation", "animate", "shadow",
57
+ "font", "size", "spacing", "padding", "margin", "border", "radius", "opacity",
58
+ "align", "theme", "style", "variant", "width", "height", "position", "layout"
59
+ ];
60
+ function isVisualPropName(prop) {
61
+ const lower = prop.toLowerCase();
62
+ return VISUAL_PROP_STEMS.some((stem) => lower.includes(stem));
63
+ }
48
64
  function humanBlockName(blockType) {
49
65
  const meta = getBlockMeta(blockType);
50
66
  return meta?.displayName ?? blockType;
@@ -81,15 +97,33 @@ export function validateAndStripHallucinatedProps(args) {
81
97
  if (allowedKeys.has(key))
82
98
  continue;
83
99
  delete patchCandidate[key];
84
- hallucinatedProps.push({ blockId: op.blockId, blockType, propName: key });
100
+ hallucinatedProps.push({
101
+ blockId: op.blockId,
102
+ blockType,
103
+ propName: key,
104
+ allowedProps: [...allowedKeys].sort()
105
+ });
85
106
  }
86
107
  }
87
108
  if (hallucinatedProps.length > 0) {
88
- // Merge duplicates into a single readable note keyed by blockType. We
89
- // intentionally avoid echoing the raw prop name back to the user
90
- // doing so is (a) jargon-y (users don't think in prop keys), and (b)
91
- // makes the note trivially collide with eval banned-word checks that
92
- // try to prove the planner didn't promise the unsupported behavior.
109
+ /*
110
+ * Two different events used to share one sentence, and the wrong one was
111
+ * the default.
112
+ *
113
+ * "Some requested styling isn't available" is true when the planner
114
+ * promised a colour, a gradient or an animation the block has no field
115
+ * for. It is false — and actively misleading — when the planner simply
116
+ * used the wrong *name* for a field the block does have under another
117
+ * name: nothing about that is styling, and the sentence sends the reader
118
+ * to look at their design system. An integrator lost nine minutes to
119
+ * exactly that, on a block whose schema, registry and ops path were all
120
+ * correct.
121
+ *
122
+ * So classify the stripped key. A visual key keeps the original wording,
123
+ * because that is the case it was written for and the evals that check it
124
+ * are checking that case. A content key gets a sentence that names it,
125
+ * which is the one piece of information that ends the search.
126
+ */
93
127
  const byBlockType = new Map();
94
128
  for (const entry of hallucinatedProps) {
95
129
  const bucket = byBlockType.get(entry.blockType) ?? new Set();
@@ -97,9 +131,16 @@ export function validateAndStripHallucinatedProps(args) {
97
131
  byBlockType.set(entry.blockType, bucket);
98
132
  }
99
133
  const noteParts = [];
100
- for (const [blockType] of byBlockType) {
134
+ for (const [blockType, props] of byBlockType) {
101
135
  const name = humanBlockName(blockType);
102
- noteParts.push(`Some requested styling isn't available on the ${name} block — applied the supported parts.`);
136
+ const visual = [...props].filter(isVisualPropName);
137
+ const content = [...props].filter((prop) => !isVisualPropName(prop));
138
+ if (visual.length > 0) {
139
+ noteParts.push(`Some requested styling isn't available on the ${name} block — applied the supported parts.`);
140
+ }
141
+ for (const prop of content) {
142
+ noteParts.push(`The ${name} block has no “${prop}” field, so that part wasn't applied.`);
143
+ }
103
144
  }
104
145
  const note = noteParts.join(" ");
105
146
  const summary = plan.summary_for_user?.trimEnd() ?? "";
@@ -51,6 +51,7 @@ export interface CmsPublishContext {
51
51
  */
52
52
  export type CmsPublishResult = void | {
53
53
  ok: true;
54
+ written?: boolean;
54
55
  unsupported?: string[];
55
56
  } | {
56
57
  ok: false;
@@ -43,12 +43,36 @@ export interface CmsBootstrapCache {
43
43
  * clone and no extra adapter read. Re-reading upstream at publish time would
44
44
  * cost 45 sequential CMS calls on the integration that motivated this.
45
45
  *
46
- * Bounded and best-effort by construction. It is null after a restart that
47
- * reloaded the draft from SQLite (the seed never ran), and it is evicted
48
- * FIFO past `maxBaselines`, so a caller must treat null as "no baseline
49
- * available" and never as "the site was empty".
46
+ * Bounded and best-effort by construction. A restart that reloads the draft
47
+ * from SQLite skips the seed but not the baseline `ensure` re-reads the
48
+ * adapter for it, which is sound because nothing was published in between —
49
+ * but it is still evicted FIFO past `maxBaselines`, and still null when the
50
+ * adapter read failed. A caller must treat null as "no baseline available"
51
+ * and never as "the site was empty".
50
52
  */
51
53
  baselineFor(session: string): PageDoc[] | null;
54
+ /**
55
+ * Discard this session's draft and seed it again from the adapter.
56
+ *
57
+ * `ensure()` is deliberately once-per-session: it exists to not clobber a
58
+ * draft that SQLite already holds. That is right for every ordinary request
59
+ * and wrong for the one case an integrator hits constantly while building an
60
+ * adapter — they change how their projection shapes pages, and the
61
+ * orchestrator keeps serving the shapes it read before the change. The only
62
+ * way out used to be deleting `.data/` and restarting the process, which is
63
+ * a much larger hammer than the problem.
64
+ *
65
+ * **This destroys unpublished edits in the session draft**, which is what
66
+ * makes it a separate, explicitly-requested call rather than something
67
+ * `ensure()` could decide to do on its own.
68
+ *
69
+ * It discards nothing until the adapter has answered: a failed read rejects
70
+ * and leaves the draft, the baseline and the attempt marker untouched, so a
71
+ * reseed against a CMS that happens to be down cannot cost anyone their
72
+ * work. A read that succeeds and returns no pages *is* an answer, and empties
73
+ * the draft accordingly.
74
+ */
75
+ reseed(session: string, adapter: CmsAdapter | null | undefined, log: Logger): Promise<void>;
52
76
  /** Test helper — drop all remembered state. */
53
77
  reset(): void;
54
78
  /** Inspection — number of remembered attempts (post-eviction). */
@@ -129,7 +129,31 @@ export function createCmsBootstrapCache(opts = {}) {
129
129
  return existing;
130
130
  const promise = (async () => {
131
131
  const draft = getSessionDraft(session);
132
- if (draft.size > 0)
132
+ /*
133
+ * A draft that is already populated came from SQLite, not from us, and
134
+ * for a long time that was the end of the matter: seeding was the only
135
+ * thing this function did, so a loaded draft meant there was nothing to
136
+ * do. But the seed also produces the publish baseline, and that made the
137
+ * early return silently disable publishing. The draft survives a
138
+ * restart, so `ensure` returned here, so `rememberBaseline` never ran, so
139
+ * `context.published` was `undefined` for the rest of the process's life
140
+ * — and a publisher written the documented way (diff against the
141
+ * baseline, refuse when there is none) can then never publish again.
142
+ * Restarting to fix it is the one thing guaranteed not to.
143
+ *
144
+ * So the two jobs are now separate. A loaded draft still skips the seed;
145
+ * it no longer skips the baseline. Recovering it costs one
146
+ * `getPages()` — the same call the seed would have made, usually served
147
+ * from the warm cache — and it is correct to read upstream for it,
148
+ * because nothing has been published: the CMS still holds exactly the
149
+ * pre-edit content the lost baseline was a copy of.
150
+ *
151
+ * This is the same shape as the `setSessionCapabilities` hoist above.
152
+ * Anything the seed produces besides the draft has to survive a restart
153
+ * that reloads the draft.
154
+ */
155
+ const seeded = draft.size > 0;
156
+ if (seeded && baselines.has(session))
133
157
  return; // finally-block records the attempt
134
158
  try {
135
159
  const warmed = warmedPages(adapter);
@@ -139,13 +163,17 @@ export function createCmsBootstrapCache(opts = {}) {
139
163
  return;
140
164
  }
141
165
  /*
142
- * Keep the pre-edit copy. The pages are already being cloned into the
143
- * draft, so a baseline for the eventual publish diff costs one more
166
+ * Keep the pre-edit copy. When we are also seeding, the pages are
167
+ * already being cloned into the draft, so the baseline costs one more
144
168
  * clone and — the point — no second adapter read. Re-reading upstream
145
169
  * at publish time is 45 sequential CMS calls on the integration that
146
170
  * motivated this.
147
171
  */
148
172
  rememberBaseline(session, pages.map((page) => structuredClone(page)));
173
+ if (seeded) {
174
+ log.info({ session, adapter: adapter.id, count: pages.length }, "cms-bootstrap: recovered publish baseline for a draft reloaded from storage");
175
+ return;
176
+ }
149
177
  for (const page of pages) {
150
178
  const copy = structuredClone(page);
151
179
  ensureHeroImageProps(copy);
@@ -174,9 +202,48 @@ export function createCmsBootstrapCache(opts = {}) {
174
202
  inFlight.delete(session);
175
203
  }
176
204
  }
205
+ async function reseed(session, adapter, log) {
206
+ if (!adapter)
207
+ return;
208
+ setSessionCapabilities(session, resolveCapabilities(adapter, capabilityOverride));
209
+ // Wait out a seed already in flight, so the discard below cannot land
210
+ // between that seed's adapter read and its writes and leave half a draft.
211
+ const existing = inFlight.get(session);
212
+ if (existing)
213
+ await existing.catch(() => { });
214
+ /*
215
+ * Read before discarding, not after. Clearing the draft and then calling
216
+ * `ensure` reads well and fails badly: `ensure` swallows an adapter error
217
+ * and marks the session attempted on the way out, so an adapter that was
218
+ * merely down turned a stale draft into an empty one that nothing would
219
+ * ever retry. The caller asked to replace the draft with what the adapter
220
+ * says; if the adapter says nothing, there is no replacement to make and
221
+ * the right answer is to leave the session exactly as we found it.
222
+ *
223
+ * This deliberately does not consult the warmed list. That list is a
224
+ * cold-start bridge and may predate whatever change prompted the reseed —
225
+ * serving it here would return the exact staleness we were asked to clear.
226
+ */
227
+ const pages = await adapter.getPages({ perspective: "draft" });
228
+ const draft = getSessionDraft(session);
229
+ const discarded = draft.size;
230
+ draft.clear();
231
+ for (const page of pages) {
232
+ const copy = structuredClone(page);
233
+ ensureHeroImageProps(copy);
234
+ draft.set(copy.slug, copy);
235
+ }
236
+ rememberBaseline(session, pages.map((page) => structuredClone(page)));
237
+ markAttempted(session);
238
+ warmEntry = null;
239
+ bumpVersion(session);
240
+ schedulePersistState(log);
241
+ log.info({ session, adapter: adapter.id, discarded, count: pages.length }, "cms-bootstrap: reseeded session draft from the adapter");
242
+ }
177
243
  return {
178
244
  ensure,
179
245
  warm,
246
+ reseed,
180
247
  baselineFor(session) {
181
248
  return baselines.get(session) ?? null;
182
249
  },
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Smallest possible adapter. Suitable for sites that keep their content as
4
4
  // a checked-in JSON array (the shape the orchestrator + blocks already
5
- // understand). villa-puravida-web's lib/published-content.json is the
5
+ // understand). A consumer site's lib/published-content.json is the
6
6
  // canonical example.
7
7
  //
8
8
  // If `writeOnPublish` is true, onPublish() will overwrite the file with the
@@ -123,7 +123,7 @@ async function buildRuntime(config) {
123
123
  * Start the adapter's page read now rather than on the first request.
124
124
  *
125
125
  * `ensure()` is lazy, so without this the first caller pays for the whole
126
- * CMS read: Paintball Arena Bern's adapter is 45 sequential Sanity reads
126
+ * CMS read: a tri-lingual site's adapter is 45 sequential Sanity reads
127
127
  * behind a dev-server compile, and the `whoami` an MCP agent opens with took
128
128
  * four minutes — long enough that the host times out and the agent reports
129
129
  * the site as down. Fire-and-forget by contract: a failed warm logs and is
@@ -771,20 +771,31 @@ export function createOrchestrator(config = {}) {
771
771
  *
772
772
  * This is the copy the bootstrap already took, not a fresh read — a
773
773
  * second `getPages()` here is 45 sequential Sanity calls on the
774
- * integration that motivated it. It is therefore absent after a restart
775
- * that reloaded the draft from SQLite, which is why the contract says to
776
- * treat undefined as "no baseline" and never as "the site was empty".
774
+ * integration that motivated it. It used to be absent for the rest of a
775
+ * process's life after a restart that reloaded the draft from SQLite,
776
+ * which quietly disabled publishing for any adapter that refuses without
777
+ * a baseline; `ensure` now recovers it on the first request instead.
778
+ *
779
+ * It can still be undefined — the adapter read can fail, and baselines
780
+ * are evicted FIFO — so the contract is unchanged: treat undefined as
781
+ * "no baseline available" and never as "the site was empty".
777
782
  */
778
783
  const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
779
784
  const context = body.assets || published
780
785
  ? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
781
786
  : undefined;
782
787
  let unsupported = [];
788
+ // Default true: an adapter that does not mention `written` means what
789
+ // every adapter written before the field existed meant.
790
+ let written = true;
783
791
  try {
784
792
  const result = await runtime.adapter.onPublish(pages, config, context);
785
793
  if (result && typeof result === "object" && Array.isArray(result.unsupported)) {
786
794
  unsupported = result.unsupported;
787
795
  }
796
+ if (result && typeof result === "object" && result.ok === true && result.written === false) {
797
+ written = false;
798
+ }
788
799
  if (result && typeof result === "object" && result.ok === false) {
789
800
  const message = result.error ?? "adapter.onPublish returned not-ok";
790
801
  runtime.log.warn({ session: scopedSession, adapter: runtime.adapter.id, error: result.error }, "library-publish: adapter.onPublish() returned not-ok");
@@ -810,13 +821,15 @@ export function createOrchestrator(config = {}) {
810
821
  * image beside it — that is neither a plain success, which claims the
811
822
  * whole edit shipped, nor an error, which claims none of it did.
812
823
  */
813
- const summary = buildPublishSummary({ changedSlugs: [], removedSlugs: [], totalPages: pages.length, hasDiff: false });
824
+ const summary = written
825
+ ? buildPublishSummary({ changedSlugs: [], removedSlugs: [], totalPages: pages.length, hasDiff: false })
826
+ : `Computed ${pages.length} ${pages.length === 1 ? "page" : "pages"} — nothing written`;
814
827
  record(true, unsupported.length > 0
815
828
  ? `${summary} ${unsupported.length} change${unsupported.length === 1 ? "" : "s"} could not be published.`
816
829
  : summary);
817
830
  return jsonResponse({
818
831
  ok: true,
819
- written: true,
832
+ written,
820
833
  count: pages.length,
821
834
  ...(unsupported.length > 0 ? { unsupported } : {})
822
835
  }, { status: 200, cors });
@@ -872,10 +885,24 @@ export function createOrchestrator(config = {}) {
872
885
  await runtime.ready;
873
886
  const session = url.searchParams.get("session") ?? undefined;
874
887
  const siteId = url.searchParams.get("siteId") ?? undefined;
875
- const slug = url.searchParams.get("slug") ?? undefined;
876
- if (!session || !slug) {
888
+ const slug = url.searchParams.get("slug");
889
+ if (!session || slug === null) {
877
890
  return jsonResponse({ error: "session and slug are required" }, { status: 400, cors });
878
891
  }
892
+ /*
893
+ * `?slug=` is a parameter that is present and empty, and answering
894
+ * "required" sends the integrator to look for a bug in their query
895
+ * string. What it actually means is that their projection emitted bare
896
+ * paths: a slug is a leading-slash path and the home page's is `"/"`.
897
+ * Every other bare slug resolves by string equality, so the home page is
898
+ * the only one that fails, and it fails as the site's own 404 rendered
899
+ * inside the editor iframe — which reads as "my site is broken".
900
+ */
901
+ if (slug === "") {
902
+ return jsonResponse({
903
+ error: 'slug must not be empty: a page slug is a path beginning with "/", and the home page is "/" rather than ""'
904
+ }, { status: 400, cors });
905
+ }
879
906
  const scopedSession = scope(session, siteId);
880
907
  await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
881
908
  const page = getPage(scopedSession, slug);
@@ -915,9 +942,25 @@ export function createOrchestrator(config = {}) {
915
942
  })
916
943
  }, { status: 200, cors });
917
944
  }
918
- // Editor bootstrap probe. In library mode the adapter is the source of
919
- // truth (already seeded by ensure), so this is effectively a confirm — we
920
- // don't clobber the draft with the posted pages.
945
+ /*
946
+ * Editor bootstrap probe. In library mode the adapter is the source of
947
+ * truth (already seeded by ensure), so this is effectively a confirm — we
948
+ * don't clobber the draft with the posted pages.
949
+ *
950
+ * `force` was accepted by the standalone orchestrator and silently dropped
951
+ * here: the body was read as `{session, siteId}` and nothing else, so a
952
+ * caller asking to re-read the adapter got the cached page list back with
953
+ * a 200 and the word "bootstrapped" on it. That is worse than refusing,
954
+ * because the shape an integrator is in when they send it is "I changed my
955
+ * projection and the orchestrator is still serving the old shapes" — and
956
+ * the answer looks like confirmation that it re-read. The documented
957
+ * escape was deleting `.data/` and restarting.
958
+ *
959
+ * It now does what it says, including the destructive half: the session
960
+ * draft is discarded and rebuilt from the adapter, so unpublished edits in
961
+ * it are lost. `forced` comes back in the response so a caller can tell
962
+ * which of the two things happened.
963
+ */
921
964
  if (request.method === "POST" && path === "/draft/bootstrap") {
922
965
  const runtime = await getRuntime();
923
966
  await runtime.ready;
@@ -930,9 +973,31 @@ export function createOrchestrator(config = {}) {
930
973
  }
931
974
  const body = (raw ?? {});
932
975
  const scopedSession = scope(body.session, body.siteId);
933
- await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
976
+ const force = body.force === true;
977
+ if (force) {
978
+ try {
979
+ await runtime.bootstrapCache.reseed(scopedSession, runtime.adapter, runtime.log);
980
+ }
981
+ catch (err) {
982
+ /*
983
+ * A forced reseed reaches the adapter and can fail there, and the
984
+ * one thing this route must not do is answer "bootstrapped" when it
985
+ * did not bootstrap — that is the failure being fixed, in a new
986
+ * costume. The reseed discards nothing before the read succeeds, so
987
+ * the draft is intact and saying so is the useful half of the reply.
988
+ */
989
+ return jsonResponse({
990
+ error: "reseed failed: the adapter could not be read",
991
+ detail: err instanceof Error ? err.message : String(err),
992
+ draft: "unchanged"
993
+ }, { status: 502, cors });
994
+ }
995
+ }
996
+ else {
997
+ await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
998
+ }
934
999
  const pages = getSessionPages(scopedSession);
935
- return jsonResponse({ status: "bootstrapped", count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
1000
+ return jsonResponse({ status: "bootstrapped", forced: force, count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
936
1001
  }
937
1002
  // Site config — drives the page-level nav-label + SEO fields shown in the
938
1003
  // property panel when no block is selected.
@@ -19,7 +19,7 @@
19
19
  * `/preview-draft` is only the *default*, though, and it was hardcoded — which
20
20
  * meant this route worked on sites `create-ai-site-editor` scaffolded and on no
21
21
  * others. An existing site that wires Avocado into its own app has its own
22
- * draft route (Paintball Arena Bern's is `/avocado/<lang>/<slug>`), and the
22
+ * draft route (one such site's is `/avocado/<lang>/<slug>`), and the
23
23
  * screenshot an agent took to check its own work photographed that site's 404
24
24
  * page. So the path is declarable: per request, per registered site, or via
25
25
  * `createOrchestrator({ draftPath })`.
@@ -19,7 +19,7 @@
19
19
  * `/preview-draft` is only the *default*, though, and it was hardcoded — which
20
20
  * meant this route worked on sites `create-ai-site-editor` scaffolded and on no
21
21
  * others. An existing site that wires Avocado into its own app has its own
22
- * draft route (Paintball Arena Bern's is `/avocado/<lang>/<slug>`), and the
22
+ * draft route (one such site's is `/avocado/<lang>/<slug>`), and the
23
23
  * screenshot an agent took to check its own work photographed that site's 404
24
24
  * page. So the path is declarable: per request, per registered site, or via
25
25
  * `createOrchestrator({ draftPath })`.
@@ -31,7 +31,7 @@ export function promptFromPropKey(propKey, blockType) {
31
31
  return CLARIFICATION_LABELS[propKey];
32
32
  const human = blockType ? getPropDisplayName(blockType, propKey) : propKey;
33
33
  // Fall back to the registry-declared label so host-app overrides (e.g.
34
- // villa's TwoColumn adding `sectionId`) surface as "Edit Section id" rather
34
+ // a host site's TwoColumn adding `sectionId`) surface as "Edit Section id" rather
35
35
  // than leaking the raw camelCase key into the UI.
36
36
  return human === propKey ? `Edit ${propKey}` : `Edit ${human.toLowerCase()}`;
37
37
  }
@@ -1,6 +1,135 @@
1
- import { allowedBlockTypes, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
1
+ import { allowedBlockTypes, blockSchemas, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType, getBlockMeta } from "@avocadostudio-ai/shared";
2
2
  import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
3
3
  // ---------------------------------------------------------------------------
4
+ // Prop-name aliasing, asked of the registry rather than of a literal
5
+ // ---------------------------------------------------------------------------
6
+ /*
7
+ * Models confuse Avocado's own prop names — `heading` for a section that calls
8
+ * it `title`, `question`/`answer` for FAQ items that call them `q`/`a`. The
9
+ * aliases below exist to repair that, and they are worth keeping.
10
+ *
11
+ * They were applied by comparing the block type against the literal string
12
+ * "Hero", which is a name only Avocado's own catalogue has. Every other block
13
+ * in the world is `!== "Hero"`, so a site that brings its own blocks and names
14
+ * a text prop `heading` had that prop deleted here and replaced with `title` —
15
+ * a prop its schema does not have. The ops engine then stripped `title` as a
16
+ * hallucinated prop, the edit vanished, and the user was told that "some
17
+ * requested styling isn't available" on their block. Nothing in the chain was
18
+ * wrong about its own job; the first link was answering a question about a name
19
+ * instead of about a schema.
20
+ *
21
+ * So ask the registry. A rename now requires positive evidence in both
22
+ * directions: the block cannot take the key the planner used, and can take the
23
+ * one we would rewrite it to. Every other case — unknown block type, a block
24
+ * that accepts both, a block that accepts neither — leaves the value alone,
25
+ * which is the answer that loses no data.
26
+ */
27
+ function blockAcceptsProp(blockType, prop) {
28
+ if (!blockType)
29
+ return false;
30
+ const meta = getBlockMeta(blockType);
31
+ if (meta?.fields && prop in meta.fields)
32
+ return true;
33
+ // Manifest-registered blocks may carry a schema richer than their derived
34
+ // meta, so the schema gets the second look rather than the first refusal.
35
+ const shape = blockSchemas[blockType]?.shape;
36
+ return Boolean(shape && prop in shape);
37
+ }
38
+ /*
39
+ * Avocado's own `autoplay` / `loop` / `striped` are string enums ("true" /
40
+ * "false"), not booleans, so a model that emits a real boolean has to be
41
+ * coerced. That coercion ran on every block type, and a custom block whose
42
+ * `loop` is a genuine `z.boolean()` had the string `"true"` written into it —
43
+ * silently, and only on the chat path, so it looked like a CMS problem.
44
+ *
45
+ * Coerce only where the block actually declares the prop as a string enum.
46
+ */
47
+ const BOOLEAN_ENUM_PROPS = ["autoplay", "loop", "striped"];
48
+ /**
49
+ * Ask the block's own schema, which is the only thing that actually knows:
50
+ * rewrite a boolean to `"true"`/`"false"` exactly when the schema rejects the
51
+ * boolean and accepts the string. A block that takes a real boolean keeps it,
52
+ * a block that takes either keeps what the planner sent, and an unregistered
53
+ * type is left alone.
54
+ */
55
+ function coerceBooleanEnumProps(blockType, props) {
56
+ const shape = blockSchemas[blockType]?.shape;
57
+ if (!shape)
58
+ return;
59
+ for (const key of BOOLEAN_ENUM_PROPS) {
60
+ const value = props[key];
61
+ if (typeof value !== "boolean")
62
+ continue;
63
+ const field = shape[key];
64
+ if (typeof field?.safeParse !== "function")
65
+ continue;
66
+ const asString = value ? "true" : "false";
67
+ /*
68
+ * Round-trip, not merely "parses". Avocado's own `autoplay` is
69
+ * `z.enum(["true","false"]).default("false").catch("false")`, and `.catch`
70
+ * means a boolean parses *successfully* — into the wrong value. Accepting
71
+ * that as "the block wants a boolean" would leave the silent wrong answer
72
+ * in place. A schema genuinely wants a boolean only when it gives the
73
+ * boolean back unchanged.
74
+ */
75
+ const asBool = field.safeParse(value);
76
+ if (asBool.success && asBool.data === value)
77
+ continue;
78
+ const asStr = field.safeParse(asString);
79
+ if (!asStr.success || asStr.data !== asString)
80
+ continue;
81
+ props[key] = asString;
82
+ }
83
+ }
84
+ /** Rename `from`→`to` only when the block demonstrably wants `to` and not `from`. */
85
+ function shouldAliasProp(blockType, from, to) {
86
+ return !blockAcceptsProp(blockType, from) && blockAcceptsProp(blockType, to);
87
+ }
88
+ /*
89
+ * The same question for a key inside a list item. `listFields[key].itemFields`
90
+ * is the declared shape; a block with no declared list metadata answers "no"
91
+ * to both halves and is therefore left alone.
92
+ */
93
+ function listItemAcceptsKey(blockType, listKey, itemKey) {
94
+ if (!blockType)
95
+ return false;
96
+ const itemFields = getBlockMeta(blockType)?.listFields?.[listKey]?.itemFields;
97
+ return Boolean(itemFields && itemKey in itemFields);
98
+ }
99
+ function shouldAliasItemKey(blockType, listKey, from, to) {
100
+ return (!listItemAcceptsKey(blockType, listKey, from) && listItemAcceptsKey(blockType, listKey, to));
101
+ }
102
+ const ITEM_KEY_ALIASES = {
103
+ question: "q",
104
+ answer: "a",
105
+ testimonial: "quote",
106
+ review: "quote"
107
+ };
108
+ /**
109
+ * Apply the list-item aliases to one array prop, block-type aware.
110
+ * Shared by the `update_props` and `add_block` paths so they cannot drift.
111
+ */
112
+ function aliasListItems(blockType, listKey, value) {
113
+ return value.map((item) => {
114
+ if (!item || typeof item !== "object" || Array.isArray(item))
115
+ return item;
116
+ const entry = item;
117
+ let changed = false;
118
+ const mapped = {};
119
+ for (const [k, v] of Object.entries(entry)) {
120
+ const alias = ITEM_KEY_ALIASES[k.toLowerCase()];
121
+ if (alias && !(alias in entry) && shouldAliasItemKey(blockType, listKey, k.toLowerCase(), alias)) {
122
+ mapped[alias] = v;
123
+ changed = true;
124
+ }
125
+ else {
126
+ mapped[k] = v;
127
+ }
128
+ }
129
+ return changed ? mapped : entry;
130
+ });
131
+ }
132
+ // ---------------------------------------------------------------------------
4
133
  // Op reordering: create_page must precede ops targeting the same slug
5
134
  // ---------------------------------------------------------------------------
6
135
  function reorderCreatePageFirst(ops) {
@@ -886,38 +1015,17 @@ export function normalizePlanCandidate(input, args) {
886
1015
  const patch = raw.patch;
887
1016
  const targetBlock = args?.currentPage?.blocks.find((b) => b.id === raw.blockId);
888
1017
  const blockType = targetBlock?.type ?? "";
889
- if (blockType !== "Hero" && "heading" in patch && !("title" in patch)) {
1018
+ if ("heading" in patch && !("title" in patch) && shouldAliasProp(blockType, "heading", "title")) {
890
1019
  patch.title = patch.heading;
891
1020
  delete patch.heading;
892
1021
  }
893
- const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
894
1022
  for (const [propKey, propVal] of Object.entries(patch)) {
895
1023
  if (!Array.isArray(propVal))
896
1024
  continue;
897
- patch[propKey] = propVal.map((item) => {
898
- if (!item || typeof item !== "object" || Array.isArray(item))
899
- return item;
900
- const entry = item;
901
- let changed = false;
902
- const mapped = {};
903
- for (const [k, v] of Object.entries(entry)) {
904
- const alias = itemKeyAliases[k.toLowerCase()];
905
- if (alias && !(alias in entry)) {
906
- mapped[alias] = v;
907
- changed = true;
908
- }
909
- else {
910
- mapped[k] = v;
911
- }
912
- }
913
- return changed ? mapped : entry;
914
- });
1025
+ patch[propKey] = aliasListItems(blockType, propKey, propVal);
915
1026
  }
916
1027
  // Block-specific prop key remapping and type coercion (mirrors add_block path)
917
- for (const k of ["autoplay", "loop", "striped"]) {
918
- if (typeof patch[k] === "boolean")
919
- patch[k] = patch[k] ? "true" : "false";
920
- }
1028
+ coerceBooleanEnumProps(blockType, patch);
921
1029
  if (blockType === "Carousel" && Array.isArray(patch.slides) && !patch.items) {
922
1030
  patch.items = patch.slides;
923
1031
  delete patch.slides;
@@ -1153,36 +1261,20 @@ export function normalizePlanCandidate(input, args) {
1153
1261
  if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1154
1262
  const bProps = block.props;
1155
1263
  const blockType = typeof block.type === "string" ? block.type : "";
1156
- if (blockType !== "Hero" && "heading" in bProps && !("title" in bProps)) {
1264
+ if ("heading" in bProps && !("title" in bProps) && shouldAliasProp(blockType, "heading", "title")) {
1157
1265
  bProps.title = bProps.heading;
1158
1266
  delete bProps.heading;
1159
1267
  }
1160
1268
  }
1161
- // Remap list item keys inside add_block props (e.g., question→q, answer→a)
1269
+ // Remap list item keys inside add_block props (e.g., question→q, answer→a),
1270
+ // asking the block's own list metadata rather than rewriting every array.
1162
1271
  if (block.props && typeof block.props === "object" && !Array.isArray(block.props)) {
1163
1272
  const bProps = block.props;
1164
- const itemKeyAliases = { question: "q", answer: "a", testimonial: "quote", review: "quote" };
1273
+ const blockType = typeof block.type === "string" ? block.type : "";
1165
1274
  for (const [propKey, propVal] of Object.entries(bProps)) {
1166
1275
  if (!Array.isArray(propVal))
1167
1276
  continue;
1168
- bProps[propKey] = propVal.map((item) => {
1169
- if (!item || typeof item !== "object" || Array.isArray(item))
1170
- return item;
1171
- const entry = item;
1172
- let changed = false;
1173
- const mapped = {};
1174
- for (const [k, v] of Object.entries(entry)) {
1175
- const alias = itemKeyAliases[k.toLowerCase()];
1176
- if (alias && !(alias in entry)) {
1177
- mapped[alias] = v;
1178
- changed = true;
1179
- }
1180
- else {
1181
- mapped[k] = v;
1182
- }
1183
- }
1184
- return changed ? mapped : entry;
1185
- });
1277
+ bProps[propKey] = aliasListItems(blockType, propKey, propVal);
1186
1278
  }
1187
1279
  }
1188
1280
  // Block-specific prop key remapping and type coercion
@@ -1190,10 +1282,7 @@ export function normalizePlanCandidate(input, args) {
1190
1282
  const cProps = block.props;
1191
1283
  const bt = block.type;
1192
1284
  // Coerce boolean→string for "true"/"false" enum props (Carousel, Video, Table)
1193
- for (const k of ["autoplay", "loop", "striped"]) {
1194
- if (typeof cProps[k] === "boolean")
1195
- cProps[k] = cProps[k] ? "true" : "false";
1196
- }
1285
+ coerceBooleanEnumProps(typeof bt === "string" ? bt : "", cProps);
1197
1286
  // Carousel: slides→items
1198
1287
  if (bt === "Carousel" && Array.isArray(cProps.slides) && !cProps.items) {
1199
1288
  cProps.items = cProps.slides;
@@ -423,7 +423,7 @@ function _resolveBlockIndex(blocks, blockId, fuzzyMatches) {
423
423
  * Drop an adapter's provenance keys from a block that is being copied.
424
424
  *
425
425
  * A projected block carries the adapter's answer to "which upstream thing is
426
- * this", and the adapter routes the write by it: Paintball Arena Bern marks a
426
+ * this", and the adapter routes the write by it: a field-level-i18n CMS marks a
427
427
  * block with `props._sectionId` and publishes anything carrying one into the
428
428
  * *shared section document* rather than into the page. Copied verbatim onto a
429
429
  * duplicate, the first edit to that duplicate's hero is written back into the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -23,8 +23,8 @@
23
23
  "openai": "^4.87.1",
24
24
  "sharp": "^0.34.5",
25
25
  "zod": "^4.3.6",
26
- "@avocadostudio-ai/migration-sdk": "^0.3.0",
27
- "@avocadostudio-ai/shared": "^0.3.0"
26
+ "@avocadostudio-ai/migration-sdk": "^0.3.2",
27
+ "@avocadostudio-ai/shared": "^0.3.2"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@google/genai": "^1.46.0",
@@ -56,7 +56,7 @@
56
56
  "visual-editing"
57
57
  ],
58
58
  "license": "Apache-2.0",
59
- "homepage": "https://github.com/avocadostudio-ai/avocado/tree/main/packages/orchestrator-core#readme",
59
+ "homepage": "https://docs.avocadostudio.dev",
60
60
  "bugs": {
61
61
  "url": "https://github.com/avocadostudio-ai/avocado/issues"
62
62
  },