@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.
- package/dist/chat/chat-pipeline.js +16 -1
- package/dist/chat/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/checks/field-walk.d.ts +18 -1
- package/dist/checks/field-walk.js +46 -0
- package/dist/checks/rules-draft.js +79 -15
- package/dist/checks/run-checks.d.ts +11 -1
- package/dist/checks/run-checks.js +11 -4
- package/dist/checks/session-runner.js +4 -0
- package/dist/checks/types.d.ts +44 -0
- package/dist/cms/adapter.d.ts +74 -1
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +1 -1
- package/dist/cms/index.js +1 -1
- package/dist/cms/media-sources.d.ts +29 -1
- package/dist/cms/media-sources.js +188 -7
- package/dist/handler/create-orchestrator.js +220 -32
- package/dist/http/history-actions.d.ts +43 -0
- package/dist/http/history-actions.js +122 -0
- package/dist/http/publish-actions.d.ts +11 -0
- package/dist/http/publish-actions.js +3 -3
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +12 -26
- package/dist/publish/publish-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +10 -3
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- 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";
|
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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;
|