@avocadostudio-ai/orchestrator-core 0.3.3 → 0.4.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.
@@ -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";
@@ -51,6 +52,7 @@ 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";
53
54
  import { checkAuth, resolveAuth } from "./auth.js";
55
+ import { setSiteAssetLister, invalidateSiteAssets } from "../state/site-assets.js";
54
56
  const defaultModelLookup = () => ({
55
57
  openai: {
56
58
  fast: process.env.OPENAI_MODEL_FAST ?? "gpt-4o-mini",
@@ -131,16 +133,70 @@ async function buildRuntime(config) {
131
133
  * forgotten, and `ensure()` still does its own fetch.
132
134
  */
133
135
  bootstrapCache.warm(config.adapter ?? null, log);
136
+ /*
137
+ * Tell the checker which documents this site has, if it can say.
138
+ *
139
+ * `content.file-link-unknown` is the rule that turns a hand-typed PDF path
140
+ * into something verifiable, and it needs the asset list. Registered here
141
+ * because this is the one place that holds the adapter and knows whether it
142
+ * implements the seam; a site with no `getMedia` registers nothing, and the
143
+ * rule stays silent rather than calling every document on the site missing.
144
+ */
145
+ const adapter = config.adapter ?? null;
146
+ if (typeof adapter?.getMedia === "function") {
147
+ const getMedia = adapter.getMedia.bind(adapter);
148
+ setSiteAssetLister(async () => {
149
+ const out = [];
150
+ /*
151
+ * Paged, and bounded. A media library can hold thousands of files, and a
152
+ * linter is not entitled to walk all of them on every publish — past the
153
+ * cap the rule sees a partial list, which is why it reports only links
154
+ * that match nothing *and* stops being useful rather than wrong: a file
155
+ * beyond the cap is simply not checked.
156
+ */
157
+ for (let page = 1; page <= ASSET_LIST_MAX_PAGES; page++) {
158
+ const result = await getMedia({ page, limit: ASSET_LIST_PAGE_SIZE, kind: "file" });
159
+ for (const item of result.items ?? []) {
160
+ if ((item.kind ?? "image") !== "file")
161
+ continue;
162
+ const path = item.url ?? item.imageUrl;
163
+ if (!path)
164
+ continue;
165
+ out.push({
166
+ path,
167
+ ...(item.name ? { name: item.name } : {}),
168
+ ...(item.contentType ? { contentType: item.contentType } : {}),
169
+ ...(item.size != null ? { size: item.size } : {})
170
+ });
171
+ }
172
+ if (page >= (result.totalPages ?? 1))
173
+ break;
174
+ }
175
+ return out;
176
+ });
177
+ }
178
+ else {
179
+ setSiteAssetLister(null);
180
+ }
134
181
  return {
135
182
  pipelineCtx,
136
183
  ready,
137
184
  resumableStore,
138
185
  log,
139
- adapter: config.adapter ?? null,
186
+ adapter,
140
187
  capabilities,
141
188
  bootstrapCache
142
189
  };
143
190
  }
191
+ /*
192
+ * A cap on one upload. Generous for a document — the largest PDF on the site
193
+ * that prompted this is 183KB — and small enough that a misdirected video does
194
+ * not sit in memory as a `Uint8Array` while the adapter decides what to do
195
+ * with it.
196
+ */
197
+ const MEDIA_UPLOAD_MAX_BYTES = 25_000_000;
198
+ const ASSET_LIST_PAGE_SIZE = 100;
199
+ const ASSET_LIST_MAX_PAGES = 10;
144
200
  /*
145
201
  * Takes the *resolved* CORS map rather than the raw Origin header. It used to
146
202
  * echo the origin directly, which meant every SSE response granted a
@@ -224,6 +280,7 @@ const SUPPORTED_ROUTES = [
224
280
  "POST /history/undo",
225
281
  "POST /history/redo",
226
282
  "POST /history/restore",
283
+ "POST /history/discard",
227
284
  "GET /whoami",
228
285
  "GET /blocks/manifest",
229
286
  "GET /sites",
@@ -236,6 +293,7 @@ const SUPPORTED_ROUTES = [
236
293
  "DELETE /restore/snapshot",
237
294
  "GET /unsplash/search",
238
295
  "POST /media/cms",
296
+ "POST /media/upload",
239
297
  "GET+POST /telemetry/chat/feedback",
240
298
  "POST /preview/screenshot",
241
299
  "POST /audio/transcribe",
@@ -319,6 +377,15 @@ export function createOrchestrator(config = {}) {
319
377
  if (config.blockTypes)
320
378
  declareBlockCatalogue(config.blockTypes);
321
379
  const scope = (session, bodySiteId) => scopedSessionKey(session, effectiveSiteId ?? bodySiteId);
380
+ /*
381
+ * The same resolution, for an action that scopes the session itself.
382
+ *
383
+ * `scope()` answers with a key, which is what a route needs when it holds the
384
+ * key. An action given `{ session, siteId }` computes its own, and every
385
+ * caller that forgot to fill `siteId` in silently addressed a different
386
+ * session than the route beside it — see `/checks/run`.
387
+ */
388
+ const withSiteId = (params) => effectiveSiteId ? { ...params, siteId: effectiveSiteId } : params;
322
389
  const imageDir = config.imageDir ?? resolve(process.cwd(), ".data/generated-images");
323
390
  /*
324
391
  * Built on first use rather than at mount: the store opens an append-only
@@ -712,7 +779,48 @@ export function createOrchestrator(config = {}) {
712
779
  * publish (LM-04).
713
780
  */
714
781
  await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
715
- const pages = getSessionPages(scopedSession);
782
+ /*
783
+ * Hand the adapter the baseline it needs to diff.
784
+ *
785
+ * `onPublish(pages)` alone is a snapshot contract, and a snapshot is not
786
+ * invertible: every CMS read is a projection, so writing the projection
787
+ * back replaces an asset reference with a URL and a document reference
788
+ * with a dead href. A publisher has to compare against what it read, and
789
+ * it cannot compare against nothing.
790
+ *
791
+ * This is the copy the bootstrap already took, not a fresh read — a
792
+ * second `getPages()` here is 45 sequential Sanity calls on the
793
+ * integration that motivated it. It used to be absent for the rest of a
794
+ * process's life after a restart that reloaded the draft from SQLite,
795
+ * which quietly disabled publishing for any adapter that refuses without
796
+ * a baseline; `ensure` now recovers it on the first request instead.
797
+ *
798
+ * It can still be undefined — the adapter read can fail, and baselines
799
+ * are evicted FIFO — so the contract is unchanged: treat undefined as
800
+ * "no baseline available" and never as "the site was empty".
801
+ */
802
+ const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
803
+ /*
804
+ * A publish of some pages is the live site with those pages replaced,
805
+ * never the ticked pages on their own — see `publish-selection.ts`. The
806
+ * baseline is doing double duty here: the adapter diffs against it, and
807
+ * the merge fills the unselected slots from it, so an unticked page is
808
+ * byte-identical to what the adapter read and produces no writes.
809
+ */
810
+ const selection = selectPagesForPublish({
811
+ draft: getSessionPages(scopedSession),
812
+ published,
813
+ draftSiteConfig: getSiteConfig(scopedSession),
814
+ publishedSiteConfig: undefined,
815
+ selection: {
816
+ slugs: parseSelectionSlugs(body.slugs),
817
+ includeSiteConfig: body.includeSiteConfig
818
+ }
819
+ });
820
+ if (isSelectionFailure(selection)) {
821
+ return jsonResponse({ ok: false, error: selection.error }, { status: 400, cors });
822
+ }
823
+ const pages = selection.pages;
716
824
  const slugs = pages.map((p) => p.slug);
717
825
  /*
718
826
  * Every exit below records what happened, because the alternative was a
@@ -772,28 +880,7 @@ export function createOrchestrator(config = {}) {
772
880
  record(true, "Nothing written — the adapter has no onPublish");
773
881
  return jsonResponse({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
774
882
  }
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;
883
+ const config = selection.siteConfig;
797
884
  const context = body.assets || published
798
885
  ? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
799
886
  : undefined;
@@ -1201,19 +1288,30 @@ export function createOrchestrator(config = {}) {
1201
1288
  // would report every page as missing because there are none.
1202
1289
  const scoped = scope(body.session, body.siteId);
1203
1290
  await runtime.bootstrapCache.ensure(scoped, runtime.adapter, runtime.log);
1204
- return actionResponse(await runChecksAction(body, runtime.log), cors);
1291
+ /*
1292
+ * `siteId` has to be the *effective* one, not whatever the caller sent.
1293
+ *
1294
+ * The action scopes the session itself, and it was being handed a body
1295
+ * with no `siteId` — so a library-mode site bootstrapped the right
1296
+ * session here and then scanned `"<session>"` instead of
1297
+ * `"<siteId>::<session>"`. That key holds the demo seed, so the checker
1298
+ * reported on Avocado's own sample pages: a real tri-lingual site with
1299
+ * sixty pages got findings for `/olives` and `/blueberries`, and none of
1300
+ * its own content was ever examined.
1301
+ */
1302
+ return actionResponse(await runChecksAction(withSiteId(body), runtime.log), cors);
1205
1303
  }
1206
1304
  if (request.method === "GET" && path === "/checks/findings") {
1207
1305
  const runtime = await getRuntime();
1208
1306
  await runtime.ready;
1209
1307
  const query = Object.fromEntries(url.searchParams);
1210
- return actionResponse(await listFindingsAction(query, runtime.log), cors);
1308
+ return actionResponse(await listFindingsAction(withSiteId(query), runtime.log), cors);
1211
1309
  }
1212
1310
  if (request.method === "GET" && path === "/checks/runs") {
1213
1311
  const runtime = await getRuntime();
1214
1312
  await runtime.ready;
1215
1313
  const query = Object.fromEntries(url.searchParams);
1216
- return actionResponse(await listCheckRunsAction(query), cors);
1314
+ return actionResponse(await listCheckRunsAction(withSiteId(query)), cors);
1217
1315
  }
1218
1316
  if (request.method === "POST" && path === "/checks/findings/status") {
1219
1317
  const runtime = await getRuntime();
@@ -1225,7 +1323,7 @@ export function createOrchestrator(config = {}) {
1225
1323
  catch {
1226
1324
  return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1227
1325
  }
1228
- return actionResponse(await updateFindingAction((raw ?? {})), cors);
1326
+ return actionResponse(await updateFindingAction(withSiteId((raw ?? {}))), cors);
1229
1327
  }
1230
1328
  if (request.method === "GET" && path === "/history/status") {
1231
1329
  const runtime = await getRuntime();
@@ -1239,7 +1337,7 @@ export function createOrchestrator(config = {}) {
1239
1337
  const query = Object.fromEntries(url.searchParams);
1240
1338
  return actionResponse(historyLog(query), cors);
1241
1339
  }
1242
- if (request.method === "POST" && (path === "/history/undo" || path === "/history/redo" || path === "/history/restore")) {
1340
+ if (request.method === "POST" && (path === "/history/undo" || path === "/history/redo" || path === "/history/restore" || path === "/history/discard")) {
1243
1341
  const runtime = await getRuntime();
1244
1342
  await runtime.ready;
1245
1343
  let raw;
@@ -1259,6 +1357,8 @@ export function createOrchestrator(config = {}) {
1259
1357
  : null;
1260
1358
  if (action)
1261
1359
  return actionResponse(action(body, runtime.log), cors);
1360
+ if (path === "/history/discard")
1361
+ return actionResponse(historyDiscardAction(body, runtime.log), cors);
1262
1362
  return actionResponse(historyRestoreAction(body, runtime.log), cors);
1263
1363
  }
1264
1364
  /*
@@ -1553,6 +1653,7 @@ export function createOrchestrator(config = {}) {
1553
1653
  const query = typeof body.query === "string" ? body.query.trim() : "";
1554
1654
  const page = Math.max(1, Math.trunc(Number(body.page) || 1));
1555
1655
  const limit = Math.min(50, Math.max(1, Math.trunc(Number(body.limit) || 20)));
1656
+ const kind = body.kind === "file" ? "file" : "image";
1556
1657
  const source = typeof runtime.adapter?.getMedia === "function"
1557
1658
  ? runtime.adapter.getMedia.bind(runtime.adapter)
1558
1659
  : mediaSourceFromUnknown(body.config);
@@ -1562,14 +1663,101 @@ export function createOrchestrator(config = {}) {
1562
1663
  if (!source)
1563
1664
  return jsonResponse({ error: "CMS media not configured" }, { status: 404, cors });
1564
1665
  try {
1565
- const result = await source({ query: query || undefined, page, limit });
1566
- return jsonResponse(result, { cors });
1666
+ const result = await source({ query: query || undefined, page, limit, kind });
1667
+ /*
1668
+ * Filter by kind on the way out as well as asking for it on the way in.
1669
+ *
1670
+ * An adapter written before documents existed ignores `kind` and
1671
+ * returns its images to a request for files. Trusting the response
1672
+ * would offer those images as PDFs. Filtering here means such an
1673
+ * adapter degrades to an empty document tab — which is the truth about
1674
+ * it — instead of to a wrong one, and costs a correct adapter nothing.
1675
+ */
1676
+ const items = (result.items ?? []).filter((item) => (item.kind ?? "image") === kind);
1677
+ /*
1678
+ * Whether this site takes uploads rides back on the read.
1679
+ *
1680
+ * The editor asks for the file list before it can offer a link picker,
1681
+ * so answering "and you may add one" here costs no round trip — and it
1682
+ * keeps the control's existence tied to a method that exists, rather
1683
+ * than to an assumption the UI made.
1684
+ */
1685
+ const canUpload = typeof runtime.adapter?.uploadMedia === "function";
1686
+ return jsonResponse({ ...result, items, canUpload }, { cors });
1567
1687
  }
1568
1688
  catch (error) {
1569
1689
  runtime.log.warn?.({ err: error }, "[avocado] CMS media read failed");
1570
1690
  return jsonResponse({ items: [], totalPages: 0 }, { cors });
1571
1691
  }
1572
1692
  }
1693
+ /*
1694
+ * Adding a document (or an image) to the site's own media library.
1695
+ *
1696
+ * The counterpart of `/media/cms`. The site's adapter decides where the
1697
+ * bytes go, because only it knows: a static site writes into `public/`, a
1698
+ * Sanity site uploads an asset and gets back a CDN URL. Deliberately NOT
1699
+ * routed through `/image/upload`, which writes to the orchestrator's local
1700
+ * disk — that storage loses every file on an ephemeral redeploy, and
1701
+ * putting documents on it would spread a known defect instead of leaving it
1702
+ * contained to the one POC route that has it.
1703
+ *
1704
+ * No `uploadMedia` is 404, not 500: the editor asks once and hides the
1705
+ * control, exactly as it does for the read side.
1706
+ */
1707
+ if (request.method === "POST" && path === "/media/upload") {
1708
+ const runtime = await getRuntime();
1709
+ await runtime.ready;
1710
+ const upload = runtime.adapter?.uploadMedia;
1711
+ if (typeof upload !== "function") {
1712
+ return jsonResponse({ error: "this site does not accept uploads" }, { status: 404, cors });
1713
+ }
1714
+ let form;
1715
+ try {
1716
+ form = await request.formData();
1717
+ }
1718
+ catch {
1719
+ return jsonResponse({ error: "expected multipart/form-data" }, { status: 400, cors });
1720
+ }
1721
+ const file = form.get("file");
1722
+ if (!(file instanceof File)) {
1723
+ return jsonResponse({ error: "no file in the request" }, { status: 400, cors });
1724
+ }
1725
+ if (file.size > MEDIA_UPLOAD_MAX_BYTES) {
1726
+ return jsonResponse({ error: `file is larger than ${Math.floor(MEDIA_UPLOAD_MAX_BYTES / 1_000_000)}MB` }, { status: 413, cors });
1727
+ }
1728
+ const rawKind = form.get("kind");
1729
+ const kind = rawKind === "file" ? "file" : "image";
1730
+ try {
1731
+ const item = await upload.call(runtime.adapter, {
1732
+ // Untrusted, and passed on as such: the adapter is the only thing that
1733
+ // knows what a safe name is in its own store, and sanitising here
1734
+ // would be guessing on its behalf.
1735
+ filename: file.name,
1736
+ contentType: file.type || "",
1737
+ data: new Uint8Array(await file.arrayBuffer()),
1738
+ kind
1739
+ });
1740
+ /*
1741
+ * Drop the cached asset list, so the file somebody just added is
1742
+ * linkable in the same breath — by the picker, by the checker, and by
1743
+ * the planner's site context. Waiting out the 30-second cache would
1744
+ * make a fresh upload look like a broken link.
1745
+ */
1746
+ invalidateSiteAssets();
1747
+ return jsonResponse({ item }, { cors });
1748
+ }
1749
+ catch (error) {
1750
+ /*
1751
+ * The adapter's message reaches the editor. A refusal is usually a
1752
+ * sentence a person needs to read — "we only accept PDFs", "a file of
1753
+ * that name already exists" — and swallowing it would leave the upload
1754
+ * control saying only that something went wrong.
1755
+ */
1756
+ const reason = error instanceof Error ? error.message : String(error);
1757
+ runtime.log.warn?.({ err: reason }, "[avocado] media upload refused");
1758
+ return jsonResponse({ error: reason }, { status: 400, cors });
1759
+ }
1760
+ }
1573
1761
  /* The Unsplash tab of the image picker. Unconfigured answers 404, not an
1574
1762
  * empty result set — "no key" and "no matches" are different answers. */
1575
1763
  if (request.method === "GET" && path === "/unsplash/search") {
@@ -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
+ }
@@ -35,6 +35,16 @@ export type PublishedContent = {
35
35
  pages: PageDoc[];
36
36
  siteConfig?: SiteConfig | null;
37
37
  };
38
+ /**
39
+ * Where the published side came from, in descending order of trust.
40
+ *
41
+ * A diff can be computed from any of them — a wrong diff only misreports. A
42
+ * *partial* publish cannot: it merges the unselected pages back out of this
43
+ * baseline and ships them, so a baseline that is really the startup demo seed
44
+ * would overwrite the live site with demo content. `"memory"` therefore
45
+ * disqualifies a subset publish; see `publish-selection.ts`.
46
+ */
47
+ export type PublishedPagesSource = "site" | "file" | "memory";
38
48
  /**
39
49
  * Where the published side comes from. The monorepo answers it from the site
40
50
  * app; a library-mode consumer answers it from its CMS adapter, whose
@@ -65,6 +75,7 @@ export declare function loadPublishedForDiff(opts: {
65
75
  }): Promise<{
66
76
  pages: PageDoc[];
67
77
  siteConfig: SiteConfig | null;
78
+ source: PublishedPagesSource;
68
79
  }>;
69
80
  /**
70
81
  * The default source: the four-step monorepo chain above, unchanged, so the
@@ -91,7 +91,7 @@ export async function loadPublishedForDiff(opts) {
91
91
  }
92
92
  // Hot path: remote returned both pages and siteConfig.
93
93
  if (remotePages && remoteSiteConfig) {
94
- return { pages: remotePages, siteConfig: remoteSiteConfig };
94
+ return { pages: remotePages, siteConfig: remoteSiteConfig, source: "site" };
95
95
  }
96
96
  // Either the remote didn't run, didn't include siteConfig (older SDK or
97
97
  // site dev not yet restarted), or didn't include pages. Read the JSON file
@@ -101,9 +101,9 @@ export async function loadPublishedForDiff(opts) {
101
101
  const pages = remotePages ?? fromFile.pages;
102
102
  const siteConfig = remoteSiteConfig ?? fromFile.siteConfig;
103
103
  if (pages)
104
- return { pages, siteConfig };
104
+ return { pages, siteConfig, source: remotePages ? "site" : "file" };
105
105
  logger.warn("publish/diff: falling back to in-memory publishedPages — diff may be inaccurate");
106
- return { pages: Array.from(publishedPages.values()), siteConfig };
106
+ return { pages: Array.from(publishedPages.values()), siteConfig, source: "memory" };
107
107
  }
108
108
  /**
109
109
  * The default source: the four-step monorepo chain above, unchanged, so the
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHandler } from "./handler/create-orchestrator.ts";
2
2
  export type { OrchestratorAuth, AuthContext } from "./handler/auth.ts";
3
- export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, ResolvedCapabilities } from "./cms/adapter.ts";
4
- export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaSourceConfig } from "./cms/index.ts";
3
+ export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsMediaUpload, ResolvedCapabilities } from "./cms/adapter.ts";
4
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./cms/index.ts";
5
5
  export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.ts";
6
6
  export type { PublishTarget, PublishContext, PublishOutcome, PublishStatus, PublishResult } from "./publish/publish-target.ts";
7
7
  export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, type SqliteDurableStoreOptions, type InMemoryDurableStoreOptions, type DurableStore, type FindingInput, type FindingRecord, type FindingQuery, type FindingSeverity, type FindingStatus, type FindingEvidence, type CheckRunInput, type CheckRunRecord, type CheckRunPatch, type CheckRunTrigger, type MemoryInput, type MemoryRecord, type MemoryQuery, type MemoryScope, type MemoryKind, type MemorySource, type MemoryStatus, type CorrectionInput, type CorrectionRecord, type CorrectionQuery, type CorrectionOutcome, type ProposalInput, type ProposalRecord, type ProposalQuery, type ProposalStatus } from "./durable/index.ts";
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  //
19
19
  // The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
20
20
  export { createOrchestrator } from "./handler/create-orchestrator.js";
21
- export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "./cms/index.js";
21
+ export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel } from "./cms/index.js";
22
22
  // The publish-target plugin point. `docs-site/integration/publishing.mdx` has
23
23
  // documented this as the way to publish somewhere we do not ship a target for
24
24
  // since before the package had an export map, and told the reader to import it
@@ -282,12 +282,28 @@ export declare function adviceResponse(args: {
282
282
  };
283
283
  export declare function plannerMessageWithPendingContext(session: string, message: string): string;
284
284
  /** Build the site context lines without wrapping in a message. Returns null if empty. */
285
+ /** How many documents ride along in a planner request. */
286
+ export declare const SITE_CONTEXT_DOCUMENT_CAP = 60;
285
287
  export declare function buildSiteContextBlock(args?: {
286
288
  sitePurpose?: string;
287
289
  siteHosting?: string;
288
290
  businessContext?: ChatRequestBody["businessContext"];
289
291
  siteContext?: ChatRequestBody["siteContext"];
290
292
  pageDirectory?: string;
293
+ /**
294
+ * The site's downloadable documents, so the planner can link one.
295
+ *
296
+ * Without it, "link the winter menu" is unanswerable: the model has the
297
+ * page list and the block schemas and no idea the site holds fifteen PDFs,
298
+ * so it invents a plausible path — which is exactly how a link to a
299
+ * misspelled filename gets written in the first place. Listed by path and
300
+ * name, because the path is what goes in the link and the name is what the
301
+ * user said.
302
+ */
303
+ documents?: Array<{
304
+ path: string;
305
+ name?: string;
306
+ }>;
291
307
  }): string | null;
292
308
  export declare function withSiteContext(message: string, args?: {
293
309
  sitePurpose?: string;