@avocadostudio-ai/orchestrator-core 0.3.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/agent/agent-logger.js +2 -1
  2. package/dist/chat/chat-pipeline.js +16 -1
  3. package/dist/chat/prompts.d.ts +5 -0
  4. package/dist/chat/prompts.js +92 -9
  5. package/dist/checks/field-walk.d.ts +18 -1
  6. package/dist/checks/field-walk.js +46 -0
  7. package/dist/checks/rules-draft.js +79 -15
  8. package/dist/checks/run-checks.d.ts +11 -1
  9. package/dist/checks/run-checks.js +11 -4
  10. package/dist/checks/session-runner.js +4 -0
  11. package/dist/checks/types.d.ts +44 -0
  12. package/dist/cms/adapter.d.ts +74 -1
  13. package/dist/cms/adapter.js +1 -0
  14. package/dist/cms/index.d.ts +1 -1
  15. package/dist/cms/index.js +1 -1
  16. package/dist/cms/media-sources.d.ts +29 -1
  17. package/dist/cms/media-sources.js +188 -7
  18. package/dist/handler/create-orchestrator.js +247 -34
  19. package/dist/handler/library-mount.d.ts +6 -0
  20. package/dist/handler/library-mount.js +45 -0
  21. package/dist/http/history-actions.d.ts +43 -0
  22. package/dist/http/history-actions.js +122 -0
  23. package/dist/http/publish-actions.d.ts +11 -0
  24. package/dist/http/publish-actions.js +3 -3
  25. package/dist/image/image-helpers.js +3 -2
  26. package/dist/index.d.ts +2 -2
  27. package/dist/index.js +1 -1
  28. package/dist/nlp/intent-detection.d.ts +16 -0
  29. package/dist/nlp/intent-detection.js +15 -1
  30. package/dist/nlp/plan-normalizer.js +12 -26
  31. package/dist/publish/publish-helpers.d.ts +12 -2
  32. package/dist/publish/publish-helpers.js +12 -4
  33. package/dist/publish/publish-selection.d.ts +84 -0
  34. package/dist/publish/publish-selection.js +113 -0
  35. package/dist/publish/targets/git.js +2 -2
  36. package/dist/state/data-dir.d.ts +6 -0
  37. package/dist/state/data-dir.js +35 -0
  38. package/dist/state/site-assets.d.ts +41 -0
  39. package/dist/state/site-assets.js +40 -0
  40. package/dist/state/sqlite-store-singleton.js +3 -17
  41. package/package.json +3 -3
@@ -30,7 +30,7 @@ import { runChatPipeline, collectMentionedSlugsFromOps } from "../chat/chat-pipe
30
30
  import { createChatTelemetryStore } from "../telemetry/chat-telemetry.js";
31
31
  import { createToolRuntime } from "../tools/runtime.js";
32
32
  import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState, normalizeSiteId, publishStatusBySession, pushPublishLogEntry, persistenceHealth, persistenceWarning } from "../state/session-state.js";
33
- import { historyStatus, historyLog, historyUndoAction, historyRedoAction, historyRestoreAction } from "../http/history-actions.js";
33
+ import { historyStatus, historyLog, historyUndoAction, historyRedoAction, historyRestoreAction, historyDiscardAction } from "../http/history-actions.js";
34
34
  import { whoamiAction } from "../http/session-actions.js";
35
35
  import { blocksManifestAction } from "../http/blocks-actions.js";
36
36
  import { screenshotAction } from "../http/screenshot-actions.js";
@@ -41,6 +41,7 @@ import { formatVariationFrame, parseVariationRequest, scopeVariationSession, var
41
41
  import { opsDryRunAction, describeAppliedOps } from "../http/ops-actions.js";
42
42
  import { describeDraft } from "../http/draft-provenance.js";
43
43
  import { buildPublishSummary, publishDiffAction, publishLogAction, publishStatusAction } from "../http/publish-actions.js";
44
+ import { isSelectionFailure, parseSelectionSlugs, selectPagesForPublish } from "../publish/publish-selection.js";
44
45
  import { restoreSnapshotApply, restoreSnapshotDelete, restoreSnapshotsList } from "../http/restore-actions.js";
45
46
  import { unsplashSearchAction } from "../http/unsplash-actions.js";
46
47
  import { telemetryFeedbackListAction, telemetryFeedbackSubmitAction } from "../http/telemetry-feedback-actions.js";
@@ -50,7 +51,9 @@ import { createCmsBootstrapCache } from "../cms/bootstrap.js";
50
51
  import { resolveCapabilities } from "../cms/adapter.js";
51
52
  import { mediaSourceFromUnknown } from "../cms/media-sources.js";
52
53
  import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
54
+ import { declareLibraryMount, observeLibraryMount } from "./library-mount.js";
53
55
  import { checkAuth, resolveAuth } from "./auth.js";
56
+ import { setSiteAssetLister, invalidateSiteAssets } from "../state/site-assets.js";
54
57
  const defaultModelLookup = () => ({
55
58
  openai: {
56
59
  fast: process.env.OPENAI_MODEL_FAST ?? "gpt-4o-mini",
@@ -131,16 +134,70 @@ async function buildRuntime(config) {
131
134
  * forgotten, and `ensure()` still does its own fetch.
132
135
  */
133
136
  bootstrapCache.warm(config.adapter ?? null, log);
137
+ /*
138
+ * Tell the checker which documents this site has, if it can say.
139
+ *
140
+ * `content.file-link-unknown` is the rule that turns a hand-typed PDF path
141
+ * into something verifiable, and it needs the asset list. Registered here
142
+ * because this is the one place that holds the adapter and knows whether it
143
+ * implements the seam; a site with no `getMedia` registers nothing, and the
144
+ * rule stays silent rather than calling every document on the site missing.
145
+ */
146
+ const adapter = config.adapter ?? null;
147
+ if (typeof adapter?.getMedia === "function") {
148
+ const getMedia = adapter.getMedia.bind(adapter);
149
+ setSiteAssetLister(async () => {
150
+ const out = [];
151
+ /*
152
+ * Paged, and bounded. A media library can hold thousands of files, and a
153
+ * linter is not entitled to walk all of them on every publish — past the
154
+ * cap the rule sees a partial list, which is why it reports only links
155
+ * that match nothing *and* stops being useful rather than wrong: a file
156
+ * beyond the cap is simply not checked.
157
+ */
158
+ for (let page = 1; page <= ASSET_LIST_MAX_PAGES; page++) {
159
+ const result = await getMedia({ page, limit: ASSET_LIST_PAGE_SIZE, kind: "file" });
160
+ for (const item of result.items ?? []) {
161
+ if ((item.kind ?? "image") !== "file")
162
+ continue;
163
+ const path = item.url ?? item.imageUrl;
164
+ if (!path)
165
+ continue;
166
+ out.push({
167
+ path,
168
+ ...(item.name ? { name: item.name } : {}),
169
+ ...(item.contentType ? { contentType: item.contentType } : {}),
170
+ ...(item.size != null ? { size: item.size } : {})
171
+ });
172
+ }
173
+ if (page >= (result.totalPages ?? 1))
174
+ break;
175
+ }
176
+ return out;
177
+ });
178
+ }
179
+ else {
180
+ setSiteAssetLister(null);
181
+ }
134
182
  return {
135
183
  pipelineCtx,
136
184
  ready,
137
185
  resumableStore,
138
186
  log,
139
- adapter: config.adapter ?? null,
187
+ adapter,
140
188
  capabilities,
141
189
  bootstrapCache
142
190
  };
143
191
  }
192
+ /*
193
+ * A cap on one upload. Generous for a document — the largest PDF on the site
194
+ * that prompted this is 183KB — and small enough that a misdirected video does
195
+ * not sit in memory as a `Uint8Array` while the adapter decides what to do
196
+ * with it.
197
+ */
198
+ const MEDIA_UPLOAD_MAX_BYTES = 25_000_000;
199
+ const ASSET_LIST_PAGE_SIZE = 100;
200
+ const ASSET_LIST_MAX_PAGES = 10;
144
201
  /*
145
202
  * Takes the *resolved* CORS map rather than the raw Origin header. It used to
146
203
  * echo the origin directly, which meant every SSE response granted a
@@ -224,6 +281,7 @@ const SUPPORTED_ROUTES = [
224
281
  "POST /history/undo",
225
282
  "POST /history/redo",
226
283
  "POST /history/restore",
284
+ "POST /history/discard",
227
285
  "GET /whoami",
228
286
  "GET /blocks/manifest",
229
287
  "GET /sites",
@@ -236,6 +294,7 @@ const SUPPORTED_ROUTES = [
236
294
  "DELETE /restore/snapshot",
237
295
  "GET /unsplash/search",
238
296
  "POST /media/cms",
297
+ "POST /media/upload",
239
298
  "GET+POST /telemetry/chat/feedback",
240
299
  "POST /preview/screenshot",
241
300
  "POST /audio/transcribe",
@@ -319,6 +378,15 @@ export function createOrchestrator(config = {}) {
319
378
  if (config.blockTypes)
320
379
  declareBlockCatalogue(config.blockTypes);
321
380
  const scope = (session, bodySiteId) => scopedSessionKey(session, effectiveSiteId ?? bodySiteId);
381
+ /*
382
+ * The same resolution, for an action that scopes the session itself.
383
+ *
384
+ * `scope()` answers with a key, which is what a route needs when it holds the
385
+ * key. An action given `{ session, siteId }` computes its own, and every
386
+ * caller that forgot to fill `siteId` in silently addressed a different
387
+ * session than the route beside it — see `/checks/run`.
388
+ */
389
+ const withSiteId = (params) => effectiveSiteId ? { ...params, siteId: effectiveSiteId } : params;
322
390
  const imageDir = config.imageDir ?? resolve(process.cwd(), ".data/generated-images");
323
391
  /*
324
392
  * Built on first use rather than at mount: the store opens an append-only
@@ -349,16 +417,40 @@ export function createOrchestrator(config = {}) {
349
417
  const gateLogger = config.logger ?? consoleLogger();
350
418
  {
351
419
  const resolved = resolveAuth(config.auth);
352
- const line = `[auth] library mode: ${resolved.mode} — ${resolved.reason}`;
420
+ /*
421
+ * `next build` evaluates this route module to collect its exports, with
422
+ * `NODE_ENV=production` and none of the deployment's environment. With no
423
+ * credential configured the gate resolves to `closed` and this printed a
424
+ * red `[error]` in the middle of an otherwise clean, *successful* build —
425
+ * about a request that is not being served, on a machine that is not the
426
+ * deployment.
427
+ *
428
+ * It is still the only notice anyone gets of a real problem, so it is not
429
+ * silenced: during a build it is a warning that says which state it is
430
+ * describing. At runtime it stays an error, because then it means every
431
+ * request is actually being refused.
432
+ */
433
+ const building = process.env.NEXT_PHASE === "phase-production-build";
434
+ const line = building
435
+ ? `[auth] library mode: ${resolved.mode} — ${resolved.reason} ` +
436
+ `(evaluated during the build; set ACCESS_PASSWORD_HASH or ` +
437
+ `ORCHESTRATOR_ACCESS_TOKEN in the deployment's environment, not here)`
438
+ : `[auth] library mode: ${resolved.mode} — ${resolved.reason}`;
353
439
  if (resolved.mode === "closed")
354
- gateLogger.error(line);
440
+ building ? gateLogger.warn(line) : gateLogger.error(line);
355
441
  else if (resolved.mode === "open-dev")
356
442
  gateLogger.warn(line);
357
443
  else
358
444
  gateLogger.info(line);
359
445
  }
446
+ // Tell the SDK's draft fetch where we are, so it stops defaulting to the
447
+ // standalone orchestrator on :4200 that a library-mode site does not run.
448
+ // See `library-mount.ts` for why this is not simply a config value.
449
+ if (config.previewUrl)
450
+ declareLibraryMount(`${config.previewUrl.replace(/\/+$/, "")}${basePath}`);
360
451
  const handler = async function handler(request) {
361
452
  const url = new URL(request.url);
453
+ observeLibraryMount(`${url.origin}${basePath}`);
362
454
  const path = stripBasePath(url.pathname, basePath);
363
455
  const cors = corsHeadersFor(request, config);
364
456
  if (request.method === "OPTIONS") {
@@ -712,7 +804,48 @@ export function createOrchestrator(config = {}) {
712
804
  * publish (LM-04).
713
805
  */
714
806
  await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
715
- const pages = getSessionPages(scopedSession);
807
+ /*
808
+ * Hand the adapter the baseline it needs to diff.
809
+ *
810
+ * `onPublish(pages)` alone is a snapshot contract, and a snapshot is not
811
+ * invertible: every CMS read is a projection, so writing the projection
812
+ * back replaces an asset reference with a URL and a document reference
813
+ * with a dead href. A publisher has to compare against what it read, and
814
+ * it cannot compare against nothing.
815
+ *
816
+ * This is the copy the bootstrap already took, not a fresh read — a
817
+ * second `getPages()` here is 45 sequential Sanity calls on the
818
+ * integration that motivated it. It used to be absent for the rest of a
819
+ * process's life after a restart that reloaded the draft from SQLite,
820
+ * which quietly disabled publishing for any adapter that refuses without
821
+ * a baseline; `ensure` now recovers it on the first request instead.
822
+ *
823
+ * It can still be undefined — the adapter read can fail, and baselines
824
+ * are evicted FIFO — so the contract is unchanged: treat undefined as
825
+ * "no baseline available" and never as "the site was empty".
826
+ */
827
+ const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
828
+ /*
829
+ * A publish of some pages is the live site with those pages replaced,
830
+ * never the ticked pages on their own — see `publish-selection.ts`. The
831
+ * baseline is doing double duty here: the adapter diffs against it, and
832
+ * the merge fills the unselected slots from it, so an unticked page is
833
+ * byte-identical to what the adapter read and produces no writes.
834
+ */
835
+ const selection = selectPagesForPublish({
836
+ draft: getSessionPages(scopedSession),
837
+ published,
838
+ draftSiteConfig: getSiteConfig(scopedSession),
839
+ publishedSiteConfig: undefined,
840
+ selection: {
841
+ slugs: parseSelectionSlugs(body.slugs),
842
+ includeSiteConfig: body.includeSiteConfig
843
+ }
844
+ });
845
+ if (isSelectionFailure(selection)) {
846
+ return jsonResponse({ ok: false, error: selection.error }, { status: 400, cors });
847
+ }
848
+ const pages = selection.pages;
716
849
  const slugs = pages.map((p) => p.slug);
717
850
  /*
718
851
  * Every exit below records what happened, because the alternative was a
@@ -772,28 +905,7 @@ export function createOrchestrator(config = {}) {
772
905
  record(true, "Nothing written — the adapter has no onPublish");
773
906
  return jsonResponse({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
774
907
  }
775
- const config = getSiteConfig(scopedSession);
776
- /*
777
- * Hand the adapter the baseline it needs to diff.
778
- *
779
- * `onPublish(pages)` alone is a snapshot contract, and a snapshot is not
780
- * invertible: every CMS read is a projection, so writing the projection
781
- * back replaces an asset reference with a URL and a document reference
782
- * with a dead href. A publisher has to compare against what it read, and
783
- * it cannot compare against nothing.
784
- *
785
- * This is the copy the bootstrap already took, not a fresh read — a
786
- * second `getPages()` here is 45 sequential Sanity calls on the
787
- * integration that motivated it. It used to be absent for the rest of a
788
- * process's life after a restart that reloaded the draft from SQLite,
789
- * which quietly disabled publishing for any adapter that refuses without
790
- * a baseline; `ensure` now recovers it on the first request instead.
791
- *
792
- * It can still be undefined — the adapter read can fail, and baselines
793
- * are evicted FIFO — so the contract is unchanged: treat undefined as
794
- * "no baseline available" and never as "the site was empty".
795
- */
796
- const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
908
+ const config = selection.siteConfig;
797
909
  const context = body.assets || published
798
910
  ? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
799
911
  : undefined;
@@ -1201,19 +1313,30 @@ export function createOrchestrator(config = {}) {
1201
1313
  // would report every page as missing because there are none.
1202
1314
  const scoped = scope(body.session, body.siteId);
1203
1315
  await runtime.bootstrapCache.ensure(scoped, runtime.adapter, runtime.log);
1204
- return actionResponse(await runChecksAction(body, runtime.log), cors);
1316
+ /*
1317
+ * `siteId` has to be the *effective* one, not whatever the caller sent.
1318
+ *
1319
+ * The action scopes the session itself, and it was being handed a body
1320
+ * with no `siteId` — so a library-mode site bootstrapped the right
1321
+ * session here and then scanned `"<session>"` instead of
1322
+ * `"<siteId>::<session>"`. That key holds the demo seed, so the checker
1323
+ * reported on Avocado's own sample pages: a real tri-lingual site with
1324
+ * sixty pages got findings for `/olives` and `/blueberries`, and none of
1325
+ * its own content was ever examined.
1326
+ */
1327
+ return actionResponse(await runChecksAction(withSiteId(body), runtime.log), cors);
1205
1328
  }
1206
1329
  if (request.method === "GET" && path === "/checks/findings") {
1207
1330
  const runtime = await getRuntime();
1208
1331
  await runtime.ready;
1209
1332
  const query = Object.fromEntries(url.searchParams);
1210
- return actionResponse(await listFindingsAction(query, runtime.log), cors);
1333
+ return actionResponse(await listFindingsAction(withSiteId(query), runtime.log), cors);
1211
1334
  }
1212
1335
  if (request.method === "GET" && path === "/checks/runs") {
1213
1336
  const runtime = await getRuntime();
1214
1337
  await runtime.ready;
1215
1338
  const query = Object.fromEntries(url.searchParams);
1216
- return actionResponse(await listCheckRunsAction(query), cors);
1339
+ return actionResponse(await listCheckRunsAction(withSiteId(query)), cors);
1217
1340
  }
1218
1341
  if (request.method === "POST" && path === "/checks/findings/status") {
1219
1342
  const runtime = await getRuntime();
@@ -1225,7 +1348,7 @@ export function createOrchestrator(config = {}) {
1225
1348
  catch {
1226
1349
  return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1227
1350
  }
1228
- return actionResponse(await updateFindingAction((raw ?? {})), cors);
1351
+ return actionResponse(await updateFindingAction(withSiteId((raw ?? {}))), cors);
1229
1352
  }
1230
1353
  if (request.method === "GET" && path === "/history/status") {
1231
1354
  const runtime = await getRuntime();
@@ -1239,7 +1362,7 @@ export function createOrchestrator(config = {}) {
1239
1362
  const query = Object.fromEntries(url.searchParams);
1240
1363
  return actionResponse(historyLog(query), cors);
1241
1364
  }
1242
- if (request.method === "POST" && (path === "/history/undo" || path === "/history/redo" || path === "/history/restore")) {
1365
+ if (request.method === "POST" && (path === "/history/undo" || path === "/history/redo" || path === "/history/restore" || path === "/history/discard")) {
1243
1366
  const runtime = await getRuntime();
1244
1367
  await runtime.ready;
1245
1368
  let raw;
@@ -1259,6 +1382,8 @@ export function createOrchestrator(config = {}) {
1259
1382
  : null;
1260
1383
  if (action)
1261
1384
  return actionResponse(action(body, runtime.log), cors);
1385
+ if (path === "/history/discard")
1386
+ return actionResponse(historyDiscardAction(body, runtime.log), cors);
1262
1387
  return actionResponse(historyRestoreAction(body, runtime.log), cors);
1263
1388
  }
1264
1389
  /*
@@ -1553,6 +1678,7 @@ export function createOrchestrator(config = {}) {
1553
1678
  const query = typeof body.query === "string" ? body.query.trim() : "";
1554
1679
  const page = Math.max(1, Math.trunc(Number(body.page) || 1));
1555
1680
  const limit = Math.min(50, Math.max(1, Math.trunc(Number(body.limit) || 20)));
1681
+ const kind = body.kind === "file" ? "file" : "image";
1556
1682
  const source = typeof runtime.adapter?.getMedia === "function"
1557
1683
  ? runtime.adapter.getMedia.bind(runtime.adapter)
1558
1684
  : mediaSourceFromUnknown(body.config);
@@ -1562,14 +1688,101 @@ export function createOrchestrator(config = {}) {
1562
1688
  if (!source)
1563
1689
  return jsonResponse({ error: "CMS media not configured" }, { status: 404, cors });
1564
1690
  try {
1565
- const result = await source({ query: query || undefined, page, limit });
1566
- return jsonResponse(result, { cors });
1691
+ const result = await source({ query: query || undefined, page, limit, kind });
1692
+ /*
1693
+ * Filter by kind on the way out as well as asking for it on the way in.
1694
+ *
1695
+ * An adapter written before documents existed ignores `kind` and
1696
+ * returns its images to a request for files. Trusting the response
1697
+ * would offer those images as PDFs. Filtering here means such an
1698
+ * adapter degrades to an empty document tab — which is the truth about
1699
+ * it — instead of to a wrong one, and costs a correct adapter nothing.
1700
+ */
1701
+ const items = (result.items ?? []).filter((item) => (item.kind ?? "image") === kind);
1702
+ /*
1703
+ * Whether this site takes uploads rides back on the read.
1704
+ *
1705
+ * The editor asks for the file list before it can offer a link picker,
1706
+ * so answering "and you may add one" here costs no round trip — and it
1707
+ * keeps the control's existence tied to a method that exists, rather
1708
+ * than to an assumption the UI made.
1709
+ */
1710
+ const canUpload = typeof runtime.adapter?.uploadMedia === "function";
1711
+ return jsonResponse({ ...result, items, canUpload }, { cors });
1567
1712
  }
1568
1713
  catch (error) {
1569
1714
  runtime.log.warn?.({ err: error }, "[avocado] CMS media read failed");
1570
1715
  return jsonResponse({ items: [], totalPages: 0 }, { cors });
1571
1716
  }
1572
1717
  }
1718
+ /*
1719
+ * Adding a document (or an image) to the site's own media library.
1720
+ *
1721
+ * The counterpart of `/media/cms`. The site's adapter decides where the
1722
+ * bytes go, because only it knows: a static site writes into `public/`, a
1723
+ * Sanity site uploads an asset and gets back a CDN URL. Deliberately NOT
1724
+ * routed through `/image/upload`, which writes to the orchestrator's local
1725
+ * disk — that storage loses every file on an ephemeral redeploy, and
1726
+ * putting documents on it would spread a known defect instead of leaving it
1727
+ * contained to the one POC route that has it.
1728
+ *
1729
+ * No `uploadMedia` is 404, not 500: the editor asks once and hides the
1730
+ * control, exactly as it does for the read side.
1731
+ */
1732
+ if (request.method === "POST" && path === "/media/upload") {
1733
+ const runtime = await getRuntime();
1734
+ await runtime.ready;
1735
+ const upload = runtime.adapter?.uploadMedia;
1736
+ if (typeof upload !== "function") {
1737
+ return jsonResponse({ error: "this site does not accept uploads" }, { status: 404, cors });
1738
+ }
1739
+ let form;
1740
+ try {
1741
+ form = await request.formData();
1742
+ }
1743
+ catch {
1744
+ return jsonResponse({ error: "expected multipart/form-data" }, { status: 400, cors });
1745
+ }
1746
+ const file = form.get("file");
1747
+ if (!(file instanceof File)) {
1748
+ return jsonResponse({ error: "no file in the request" }, { status: 400, cors });
1749
+ }
1750
+ if (file.size > MEDIA_UPLOAD_MAX_BYTES) {
1751
+ return jsonResponse({ error: `file is larger than ${Math.floor(MEDIA_UPLOAD_MAX_BYTES / 1_000_000)}MB` }, { status: 413, cors });
1752
+ }
1753
+ const rawKind = form.get("kind");
1754
+ const kind = rawKind === "file" ? "file" : "image";
1755
+ try {
1756
+ const item = await upload.call(runtime.adapter, {
1757
+ // Untrusted, and passed on as such: the adapter is the only thing that
1758
+ // knows what a safe name is in its own store, and sanitising here
1759
+ // would be guessing on its behalf.
1760
+ filename: file.name,
1761
+ contentType: file.type || "",
1762
+ data: new Uint8Array(await file.arrayBuffer()),
1763
+ kind
1764
+ });
1765
+ /*
1766
+ * Drop the cached asset list, so the file somebody just added is
1767
+ * linkable in the same breath — by the picker, by the checker, and by
1768
+ * the planner's site context. Waiting out the 30-second cache would
1769
+ * make a fresh upload look like a broken link.
1770
+ */
1771
+ invalidateSiteAssets();
1772
+ return jsonResponse({ item }, { cors });
1773
+ }
1774
+ catch (error) {
1775
+ /*
1776
+ * The adapter's message reaches the editor. A refusal is usually a
1777
+ * sentence a person needs to read — "we only accept PDFs", "a file of
1778
+ * that name already exists" — and swallowing it would leave the upload
1779
+ * control saying only that something went wrong.
1780
+ */
1781
+ const reason = error instanceof Error ? error.message : String(error);
1782
+ runtime.log.warn?.({ err: reason }, "[avocado] media upload refused");
1783
+ return jsonResponse({ error: reason }, { status: 400, cors });
1784
+ }
1785
+ }
1573
1786
  /* The Unsplash tab of the image picker. Unconfigured answers 404, not an
1574
1787
  * empty result set — "no key" and "no matches" are different answers. */
1575
1788
  if (request.method === "GET" && path === "/unsplash/search") {
@@ -0,0 +1,6 @@
1
+ export declare const LIBRARY_MOUNT_KEY = "__avocado_library_mount__";
2
+ /** Called at construction, from `previewUrl` + `basePath`. */
3
+ export declare function declareLibraryMount(url: string): void;
4
+ /** Called on each request, from the URL the runtime actually received. */
5
+ export declare function observeLibraryMount(url: string): void;
6
+ export declare function getLibraryMount(): string | null;
@@ -0,0 +1,45 @@
1
+ /*
2
+ * Where this process's own orchestrator is mounted, if it has one.
3
+ *
4
+ * In library mode the orchestrator is a route inside the site's own Next app,
5
+ * and the SDK's draft fetch had no way to know that: with `ORCHESTRATOR_URL`
6
+ * unset it fell back to `http://127.0.0.1:4200`, the *standalone* orchestrator,
7
+ * which a library-mode site by definition does not run. The failure is silent
8
+ * when nothing is listening there (the draft fetch fails, the preview falls back
9
+ * to published content, and every edit appears to do nothing) and worse when
10
+ * something is — the fetch succeeds against a foreign process and the preview
11
+ * renders another project's pages. Anyone integrating is likely to have a :4200
12
+ * up, because that is what the standalone stack runs on.
13
+ *
14
+ * So the handler records its own address, and `getOrchestratorUrl()` in the SDK
15
+ * reads it through the same `globalThis` key. The key is a string on both sides
16
+ * rather than an import because `@avocadostudio-ai/orchestrator-core` is an
17
+ * *optional* peer of the SDK — a site that is not in library mode does not have
18
+ * it installed, and neither package may require the other.
19
+ *
20
+ * Two sources, in order of trust:
21
+ * - the origin of a real request this handler served, which cannot be wrong;
22
+ * - `config.previewUrl + basePath` at construction time, which is available
23
+ * before any request and is what covers a preview page that renders before
24
+ * the orchestrator route module has ever been evaluated.
25
+ */
26
+ export const LIBRARY_MOUNT_KEY = "__avocado_library_mount__";
27
+ function slot() {
28
+ const g = globalThis;
29
+ return g[LIBRARY_MOUNT_KEY] ?? (g[LIBRARY_MOUNT_KEY] = {});
30
+ }
31
+ /** Called at construction, from `previewUrl` + `basePath`. */
32
+ export function declareLibraryMount(url) {
33
+ slot().declared = url.replace(/\/+$/, "");
34
+ }
35
+ /** Called on each request, from the URL the runtime actually received. */
36
+ export function observeLibraryMount(url) {
37
+ const s = slot();
38
+ const next = url.replace(/\/+$/, "");
39
+ if (s.observed !== next)
40
+ s.observed = next;
41
+ }
42
+ export function getLibraryMount() {
43
+ const s = slot();
44
+ return s.observed ?? s.declared ?? null;
45
+ }
@@ -56,3 +56,46 @@ export declare function historyRestoreAction(body: {
56
56
  siteId?: string;
57
57
  targetVersion?: number;
58
58
  }, log: Logger): ActionResult;
59
+ /** One page rolled back, and how far. */
60
+ export type DiscardedPage = {
61
+ slug: string;
62
+ /** The earliest selected version on this page — the change being undone. */
63
+ fromVersion: number;
64
+ /** The version whose snapshot the page now holds, or null if it went away. */
65
+ toVersion: number | null;
66
+ /**
67
+ * Entries on this page at or after `fromVersion` that the rollback also
68
+ * takes out. A page has one timeline, so discarding a change in the middle
69
+ * of it necessarily discards what was built on top.
70
+ */
71
+ alsoDiscarded: number[];
72
+ };
73
+ /** A selected entry that could not be rolled back, and why. */
74
+ export type SkippedDiscard = {
75
+ version: number;
76
+ reason: string;
77
+ };
78
+ /**
79
+ * Throw away selected changes by rolling each affected page back to the state
80
+ * it held immediately before the earliest change selected on it.
81
+ *
82
+ * The version log is a per-page timeline, not a stack of independent patches:
83
+ * an entry records the page *after* the change, so "undo this one change and
84
+ * keep the later ones" is not a question the stored data can answer. What it
85
+ * can answer is "put this page back the way it was before change N", which is
86
+ * what discarding means here — and it is why the response names every later
87
+ * entry that goes with it. The caller shows that list before asking the user
88
+ * to confirm; this function reports it again for whatever actually happened.
89
+ *
90
+ * Selecting several entries on one page collapses to its earliest. Selecting
91
+ * entries across pages rolls each page back independently — untouched pages
92
+ * keep their edits.
93
+ *
94
+ * Like a restore, a discard is itself an edit: the current page goes onto the
95
+ * undo stack first, so a discard can be undone.
96
+ */
97
+ export declare function historyDiscardAction(body: {
98
+ session?: string;
99
+ siteId?: string;
100
+ versions?: unknown;
101
+ }, log: Logger): ActionResult;
@@ -167,3 +167,125 @@ export function historyRestoreAction(body, log) {
167
167
  }
168
168
  };
169
169
  }
170
+ /**
171
+ * Throw away selected changes by rolling each affected page back to the state
172
+ * it held immediately before the earliest change selected on it.
173
+ *
174
+ * The version log is a per-page timeline, not a stack of independent patches:
175
+ * an entry records the page *after* the change, so "undo this one change and
176
+ * keep the later ones" is not a question the stored data can answer. What it
177
+ * can answer is "put this page back the way it was before change N", which is
178
+ * what discarding means here — and it is why the response names every later
179
+ * entry that goes with it. The caller shows that list before asking the user
180
+ * to confirm; this function reports it again for whatever actually happened.
181
+ *
182
+ * Selecting several entries on one page collapses to its earliest. Selecting
183
+ * entries across pages rolls each page back independently — untouched pages
184
+ * keep their edits.
185
+ *
186
+ * Like a restore, a discard is itself an edit: the current page goes onto the
187
+ * undo stack first, so a discard can be undone.
188
+ */
189
+ export function historyDiscardAction(body, log) {
190
+ if (!body.session)
191
+ return badRequest("session is required");
192
+ const requested = Array.isArray(body.versions)
193
+ ? Array.from(new Set(body.versions.filter((v) => typeof v === "number" && Number.isFinite(v))))
194
+ : [];
195
+ if (requested.length === 0)
196
+ return badRequest("versions must be a non-empty array of version numbers");
197
+ const session = scopedSessionKey(body.session, body.siteId);
198
+ const entries = versionLog.get(session) ?? [];
199
+ const byVersion = new Map(entries.map((entry) => [entry.version, entry]));
200
+ const skipped = [];
201
+ /*
202
+ * Collapse to one rollback per page. A selection of three entries on the
203
+ * same page is one rollback to before the earliest of them; running three
204
+ * would have the second and third restore snapshots the first already
205
+ * invalidated.
206
+ */
207
+ const earliestBySlug = new Map();
208
+ for (const version of requested) {
209
+ const entry = byVersion.get(version);
210
+ if (!entry) {
211
+ skipped.push({ version, reason: "not in this session's version log" });
212
+ continue;
213
+ }
214
+ const current = earliestBySlug.get(entry.slug);
215
+ if (current === undefined || version < current)
216
+ earliestBySlug.set(entry.slug, version);
217
+ }
218
+ const discarded = [];
219
+ let previewVersion = versions.get(session) ?? 0;
220
+ let navigateToSlug;
221
+ for (const [slug, fromVersion] of earliestBySlug) {
222
+ /*
223
+ * The state before `fromVersion` is the snapshot of the newest earlier
224
+ * entry on the same page. An entry with no snapshot is a legacy row that
225
+ * cannot restore anything, so it does not count as the prior state.
226
+ */
227
+ const prior = entries
228
+ .filter((entry) => entry.slug === slug && entry.version < fromVersion && entry.snapshot !== undefined)
229
+ .at(-1);
230
+ if (!prior) {
231
+ /*
232
+ * Nothing recorded before this change — the log was capped, or this is
233
+ * the page's first entry. Guessing would mean inventing a state the
234
+ * page never had, so the entry stays and the caller is told why.
235
+ */
236
+ skipped.push({
237
+ version: fromVersion,
238
+ reason: "no recorded page state before this change"
239
+ });
240
+ continue;
241
+ }
242
+ const alsoDiscarded = entries
243
+ .filter((entry) => entry.slug === slug && entry.version >= fromVersion)
244
+ .map((entry) => entry.version)
245
+ .filter((version) => version !== fromVersion);
246
+ // Filtered above, so this only maps the impossible `undefined` onto the
247
+ // `null` that means "the page did not exist at this version".
248
+ const snapshot = prior.snapshot ?? null;
249
+ // pushUndo also clears redo, which is right: a discard starts a new branch.
250
+ pushUndo(session, slug, getPage(session, slug));
251
+ if (snapshot === null)
252
+ removePage(session, slug);
253
+ else
254
+ setPage(session, structuredClone(snapshot));
255
+ previewVersion = bumpVersion(session);
256
+ const count = alsoDiscarded.length + 1;
257
+ pushVersionEntry(session, {
258
+ version: previewVersion,
259
+ slug,
260
+ summary: `Discarded ${count} change${count === 1 ? "" : "s"} — back to v${prior.version}`,
261
+ opTypes: [],
262
+ opCount: 0,
263
+ source: "restore",
264
+ snapshot: snapshot === null ? null : structuredClone(snapshot)
265
+ });
266
+ discarded.push({ slug, fromVersion, toVersion: snapshot === null ? null : prior.version, alsoDiscarded });
267
+ navigateToSlug = slug;
268
+ }
269
+ if (discarded.length === 0) {
270
+ return {
271
+ code: 400,
272
+ body: { error: "nothing could be discarded", skipped }
273
+ };
274
+ }
275
+ schedulePersistState(log);
276
+ const touched = discarded[discarded.length - 1].slug;
277
+ const undoList = getHistoryMap(historyUndo, session).get(touched) ?? [];
278
+ const redoList = getHistoryMap(historyRedo, session).get(touched) ?? [];
279
+ return {
280
+ code: 200,
281
+ body: {
282
+ status: "applied",
283
+ previewVersion,
284
+ discarded,
285
+ skipped,
286
+ ...(navigateToSlug ? { navigateToSlug } : {}),
287
+ canUndo: undoList.length > 0,
288
+ canRedo: redoList.length > 0
289
+ }
290
+ };
291
+ }