@avocadostudio-ai/orchestrator-core 0.3.0 → 0.3.1
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/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 +60 -8
- package/dist/http/screenshot-actions.d.ts +1 -1
- package/dist/http/screenshot-actions.js +1 -1
- 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
|
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
|
|
@@ -872,10 +872,24 @@ export function createOrchestrator(config = {}) {
|
|
|
872
872
|
await runtime.ready;
|
|
873
873
|
const session = url.searchParams.get("session") ?? undefined;
|
|
874
874
|
const siteId = url.searchParams.get("siteId") ?? undefined;
|
|
875
|
-
const slug = url.searchParams.get("slug")
|
|
876
|
-
if (!session ||
|
|
875
|
+
const slug = url.searchParams.get("slug");
|
|
876
|
+
if (!session || slug === null) {
|
|
877
877
|
return jsonResponse({ error: "session and slug are required" }, { status: 400, cors });
|
|
878
878
|
}
|
|
879
|
+
/*
|
|
880
|
+
* `?slug=` is a parameter that is present and empty, and answering
|
|
881
|
+
* "required" sends the integrator to look for a bug in their query
|
|
882
|
+
* string. What it actually means is that their projection emitted bare
|
|
883
|
+
* paths: a slug is a leading-slash path and the home page's is `"/"`.
|
|
884
|
+
* Every other bare slug resolves by string equality, so the home page is
|
|
885
|
+
* the only one that fails, and it fails as the site's own 404 rendered
|
|
886
|
+
* inside the editor iframe — which reads as "my site is broken".
|
|
887
|
+
*/
|
|
888
|
+
if (slug === "") {
|
|
889
|
+
return jsonResponse({
|
|
890
|
+
error: 'slug must not be empty: a page slug is a path beginning with "/", and the home page is "/" rather than ""'
|
|
891
|
+
}, { status: 400, cors });
|
|
892
|
+
}
|
|
879
893
|
const scopedSession = scope(session, siteId);
|
|
880
894
|
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
881
895
|
const page = getPage(scopedSession, slug);
|
|
@@ -915,9 +929,25 @@ export function createOrchestrator(config = {}) {
|
|
|
915
929
|
})
|
|
916
930
|
}, { status: 200, cors });
|
|
917
931
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
932
|
+
/*
|
|
933
|
+
* Editor bootstrap probe. In library mode the adapter is the source of
|
|
934
|
+
* truth (already seeded by ensure), so this is effectively a confirm — we
|
|
935
|
+
* don't clobber the draft with the posted pages.
|
|
936
|
+
*
|
|
937
|
+
* `force` was accepted by the standalone orchestrator and silently dropped
|
|
938
|
+
* here: the body was read as `{session, siteId}` and nothing else, so a
|
|
939
|
+
* caller asking to re-read the adapter got the cached page list back with
|
|
940
|
+
* a 200 and the word "bootstrapped" on it. That is worse than refusing,
|
|
941
|
+
* because the shape an integrator is in when they send it is "I changed my
|
|
942
|
+
* projection and the orchestrator is still serving the old shapes" — and
|
|
943
|
+
* the answer looks like confirmation that it re-read. The documented
|
|
944
|
+
* escape was deleting `.data/` and restarting.
|
|
945
|
+
*
|
|
946
|
+
* It now does what it says, including the destructive half: the session
|
|
947
|
+
* draft is discarded and rebuilt from the adapter, so unpublished edits in
|
|
948
|
+
* it are lost. `forced` comes back in the response so a caller can tell
|
|
949
|
+
* which of the two things happened.
|
|
950
|
+
*/
|
|
921
951
|
if (request.method === "POST" && path === "/draft/bootstrap") {
|
|
922
952
|
const runtime = await getRuntime();
|
|
923
953
|
await runtime.ready;
|
|
@@ -930,9 +960,31 @@ export function createOrchestrator(config = {}) {
|
|
|
930
960
|
}
|
|
931
961
|
const body = (raw ?? {});
|
|
932
962
|
const scopedSession = scope(body.session, body.siteId);
|
|
933
|
-
|
|
963
|
+
const force = body.force === true;
|
|
964
|
+
if (force) {
|
|
965
|
+
try {
|
|
966
|
+
await runtime.bootstrapCache.reseed(scopedSession, runtime.adapter, runtime.log);
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
/*
|
|
970
|
+
* A forced reseed reaches the adapter and can fail there, and the
|
|
971
|
+
* one thing this route must not do is answer "bootstrapped" when it
|
|
972
|
+
* did not bootstrap — that is the failure being fixed, in a new
|
|
973
|
+
* costume. The reseed discards nothing before the read succeeds, so
|
|
974
|
+
* the draft is intact and saying so is the useful half of the reply.
|
|
975
|
+
*/
|
|
976
|
+
return jsonResponse({
|
|
977
|
+
error: "reseed failed: the adapter could not be read",
|
|
978
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
979
|
+
draft: "unchanged"
|
|
980
|
+
}, { status: 502, cors });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
985
|
+
}
|
|
934
986
|
const pages = getSessionPages(scopedSession);
|
|
935
|
-
return jsonResponse({ status: "bootstrapped", count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
|
|
987
|
+
return jsonResponse({ status: "bootstrapped", forced: force, count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
|
|
936
988
|
}
|
|
937
989
|
// Site config — drives the page-level nav-label + SEO fields shown in the
|
|
938
990
|
// 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 })`.
|
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.1",
|
|
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.1",
|
|
27
|
+
"@avocadostudio-ai/shared": "^0.3.1"
|
|
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
|
},
|