@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.
- package/dist/agent/sites-agent-tools.js +1 -1
- package/dist/chat/chat-pipeline.js +1 -0
- package/dist/chat/hallucination-validator.d.ts +6 -0
- package/dist/chat/hallucination-validator.js +49 -8
- package/dist/cms/adapter.d.ts +1 -0
- package/dist/cms/bootstrap.d.ts +28 -4
- package/dist/cms/bootstrap.js +70 -3
- package/dist/cms/json-file-adapter.js +1 -1
- package/dist/handler/create-orchestrator.js +78 -13
- package/dist/http/screenshot-actions.d.ts +1 -1
- package/dist/http/screenshot-actions.js +1 -1
- package/dist/nlp/deterministic-planner-suggestions.js +1 -1
- package/dist/nlp/plan-normalizer.js +139 -50
- package/dist/ops/ops-engine.js +1 -1
- package/package.json +4 -4
|
@@ -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-
|
|
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({
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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() ?? "";
|
package/dist/cms/adapter.d.ts
CHANGED
package/dist/cms/bootstrap.d.ts
CHANGED
|
@@ -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.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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). */
|
package/dist/cms/bootstrap.js
CHANGED
|
@@ -129,7 +129,31 @@ export function createCmsBootstrapCache(opts = {}) {
|
|
|
129
129
|
return existing;
|
|
130
130
|
const promise = (async () => {
|
|
131
131
|
const draft = getSessionDraft(session);
|
|
132
|
-
|
|
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.
|
|
143
|
-
*
|
|
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).
|
|
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:
|
|
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
|
|
775
|
-
* that reloaded the draft from SQLite,
|
|
776
|
-
*
|
|
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 =
|
|
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
|
|
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")
|
|
876
|
-
if (!session ||
|
|
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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
//
|
|
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 (
|
|
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] =
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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] =
|
|
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
|
-
|
|
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;
|
package/dist/ops/ops-engine.js
CHANGED
|
@@ -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:
|
|
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.
|
|
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.
|
|
27
|
-
"@avocadostudio-ai/shared": "^0.3.
|
|
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://
|
|
59
|
+
"homepage": "https://docs.avocadostudio.dev",
|
|
60
60
|
"bugs": {
|
|
61
61
|
"url": "https://github.com/avocadostudio-ai/avocado/issues"
|
|
62
62
|
},
|