@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.
@@ -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
@@ -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
@@ -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") ?? undefined;
876
- if (!session || !slug) {
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
- // 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.
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
- await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
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 (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 })`.
@@ -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.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.0",
27
- "@avocadostudio-ai/shared": "^0.3.0"
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://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
  },