@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.
- package/dist/agent/agent-logger.js +2 -1
- 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 +247 -34
- package/dist/handler/library-mount.d.ts +6 -0
- package/dist/handler/library-mount.js +45 -0
- 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/image/image-helpers.js +3 -2
- 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 +12 -4
- 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/data-dir.d.ts +6 -0
- package/dist/state/data-dir.js +35 -0
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/dist/state/sqlite-store-singleton.js +3 -17
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|