@avocadostudio-ai/orchestrator-core 0.3.2 → 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/anthropic-planner.d.ts +8 -0
- package/dist/chat/anthropic-planner.js +166 -12
- package/dist/chat/chat-pipeline-translation.d.ts +13 -0
- package/dist/chat/chat-pipeline-translation.js +109 -45
- package/dist/chat/chat-pipeline.d.ts +1 -1
- package/dist/chat/chat-pipeline.js +312 -54
- package/dist/chat/gemini-planner.d.ts +2 -0
- package/dist/chat/gemini-planner.js +2 -1
- package/dist/chat/planner-types.d.ts +15 -0
- package/dist/chat/planner-types.js +2 -2
- package/dist/chat/planner.d.ts +12 -0
- package/dist/chat/planner.js +16 -2
- package/dist/chat/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +42 -0
- package/dist/checks/field-walk.js +198 -0
- package/dist/checks/index.d.ts +5 -0
- package/dist/checks/index.js +4 -0
- package/dist/checks/page-weight.d.ts +22 -0
- package/dist/checks/page-weight.js +200 -0
- package/dist/checks/rules-draft.d.ts +2 -0
- package/dist/checks/rules-draft.js +439 -0
- package/dist/checks/run-checks.d.ts +42 -0
- package/dist/checks/run-checks.js +159 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +99 -0
- package/dist/checks/types.d.ts +109 -0
- package/dist/checks/types.js +1 -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/durable/durable-store-singleton.d.ts +37 -0
- package/dist/durable/durable-store-singleton.js +179 -0
- package/dist/durable/finding-impact.d.ts +30 -0
- package/dist/durable/finding-impact.js +53 -0
- package/dist/durable/in-memory-durable-store.d.ts +203 -0
- package/dist/durable/in-memory-durable-store.js +363 -0
- package/dist/durable/index.d.ts +5 -0
- package/dist/durable/index.js +4 -0
- package/dist/durable/pending-plan-store.d.ts +28 -0
- package/dist/durable/pending-plan-store.js +156 -0
- package/dist/durable/sqlite-durable-store.d.ts +71 -0
- package/dist/durable/sqlite-durable-store.js +631 -0
- package/dist/durable/types.d.ts +265 -0
- package/dist/durable/types.js +1 -0
- package/dist/handler/create-orchestrator.d.ts +4 -0
- package/dist/handler/create-orchestrator.js +283 -32
- package/dist/http/audio-actions.d.ts +1 -1
- package/dist/http/checks-actions.d.ts +39 -0
- package/dist/http/checks-actions.js +122 -0
- package/dist/http/history-actions.d.ts +44 -1
- package/dist/http/history-actions.js +122 -0
- package/dist/http/image-generate-actions.d.ts +2 -2
- package/dist/http/ops-actions.d.ts +2 -2
- package/dist/http/publish-actions.d.ts +15 -4
- package/dist/http/publish-actions.js +3 -3
- package/dist/http/restore-actions.d.ts +3 -3
- package/dist/http/screenshot-actions.d.ts +2 -2
- package/dist/http/session-actions.d.ts +1 -1
- package/dist/http/telemetry-feedback-actions.d.ts +2 -2
- package/dist/http/unsplash-actions.d.ts +2 -2
- package/dist/http/variations-actions.d.ts +2 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +28 -1
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +66 -32
- package/dist/ops/destructive-action-gate.js +7 -2
- package/dist/ops/ops-engine.d.ts +12 -1
- package/dist/ops/ops-engine.js +41 -14
- 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/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/session-state.js +8 -1
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/package.json +3 -3
|
@@ -30,16 +30,18 @@ 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";
|
|
37
|
+
import { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction } from "../http/checks-actions.js";
|
|
37
38
|
import { fileImageStore, formatImageChatFrame, generateImageAction, imageChatAction, imageChatStreamAction, interpretImageAction, validateImageChatRequest } from "../http/image-generate-actions.js";
|
|
38
39
|
import { transcribeAudioAction, transcriptionUnavailable, validateAudioInput } from "../http/audio-actions.js";
|
|
39
40
|
import { formatVariationFrame, parseVariationRequest, scopeVariationSession, variationsAction, variationsStreamAction } from "../http/variations-actions.js";
|
|
40
41
|
import { opsDryRunAction, describeAppliedOps } from "../http/ops-actions.js";
|
|
41
42
|
import { describeDraft } from "../http/draft-provenance.js";
|
|
42
43
|
import { buildPublishSummary, publishDiffAction, publishLogAction, publishStatusAction } from "../http/publish-actions.js";
|
|
44
|
+
import { isSelectionFailure, parseSelectionSlugs, selectPagesForPublish } from "../publish/publish-selection.js";
|
|
43
45
|
import { restoreSnapshotApply, restoreSnapshotDelete, restoreSnapshotsList } from "../http/restore-actions.js";
|
|
44
46
|
import { unsplashSearchAction } from "../http/unsplash-actions.js";
|
|
45
47
|
import { telemetryFeedbackListAction, telemetryFeedbackSubmitAction } from "../http/telemetry-feedback-actions.js";
|
|
@@ -50,6 +52,7 @@ import { resolveCapabilities } from "../cms/adapter.js";
|
|
|
50
52
|
import { mediaSourceFromUnknown } from "../cms/media-sources.js";
|
|
51
53
|
import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
|
|
52
54
|
import { checkAuth, resolveAuth } from "./auth.js";
|
|
55
|
+
import { setSiteAssetLister, invalidateSiteAssets } from "../state/site-assets.js";
|
|
53
56
|
const defaultModelLookup = () => ({
|
|
54
57
|
openai: {
|
|
55
58
|
fast: process.env.OPENAI_MODEL_FAST ?? "gpt-4o-mini",
|
|
@@ -130,16 +133,70 @@ async function buildRuntime(config) {
|
|
|
130
133
|
* forgotten, and `ensure()` still does its own fetch.
|
|
131
134
|
*/
|
|
132
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
|
+
}
|
|
133
181
|
return {
|
|
134
182
|
pipelineCtx,
|
|
135
183
|
ready,
|
|
136
184
|
resumableStore,
|
|
137
185
|
log,
|
|
138
|
-
adapter
|
|
186
|
+
adapter,
|
|
139
187
|
capabilities,
|
|
140
188
|
bootstrapCache
|
|
141
189
|
};
|
|
142
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;
|
|
143
200
|
/*
|
|
144
201
|
* Takes the *resolved* CORS map rather than the raw Origin header. It used to
|
|
145
202
|
* echo the origin directly, which meant every SSE response granted a
|
|
@@ -214,11 +271,16 @@ const SUPPORTED_ROUTES = [
|
|
|
214
271
|
"POST /draft/bootstrap",
|
|
215
272
|
"GET+PUT /draft/site-config",
|
|
216
273
|
"POST /ops",
|
|
274
|
+
"POST /checks/run",
|
|
275
|
+
"GET /checks/findings",
|
|
276
|
+
"GET /checks/runs",
|
|
277
|
+
"POST /checks/findings/status",
|
|
217
278
|
"GET /history/status",
|
|
218
279
|
"GET /history/log",
|
|
219
280
|
"POST /history/undo",
|
|
220
281
|
"POST /history/redo",
|
|
221
282
|
"POST /history/restore",
|
|
283
|
+
"POST /history/discard",
|
|
222
284
|
"GET /whoami",
|
|
223
285
|
"GET /blocks/manifest",
|
|
224
286
|
"GET /sites",
|
|
@@ -231,6 +293,7 @@ const SUPPORTED_ROUTES = [
|
|
|
231
293
|
"DELETE /restore/snapshot",
|
|
232
294
|
"GET /unsplash/search",
|
|
233
295
|
"POST /media/cms",
|
|
296
|
+
"POST /media/upload",
|
|
234
297
|
"GET+POST /telemetry/chat/feedback",
|
|
235
298
|
"POST /preview/screenshot",
|
|
236
299
|
"POST /audio/transcribe",
|
|
@@ -298,14 +361,31 @@ export function createOrchestrator(config = {}) {
|
|
|
298
361
|
// up editing the bundled demo pages instead of the site's real content.
|
|
299
362
|
const effectiveSiteId = config.adapter ? (config.siteId ?? "library") : config.siteId;
|
|
300
363
|
/*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
364
|
+
* The registry holds this on `globalThis`, so it survives Next duplicating
|
|
365
|
+
* these modules across the RSC / SSR / route-handler layers — the declaration
|
|
366
|
+
* made here is visible to `/api/editor/blocks` in another copy.
|
|
367
|
+
*
|
|
368
|
+
* It is *not*, however, made "at mount" in any sense that covers the other
|
|
369
|
+
* route. This runs when this route's module is first evaluated, which Next
|
|
370
|
+
* does on the first request to this route — so an editor that asks
|
|
371
|
+
* `/api/editor/blocks` before anything has touched `/api/avocado/*` sees an
|
|
372
|
+
* undeclared catalogue and is offered Avocado's built-ins alongside the
|
|
373
|
+
* site's own. Pass `blockTypes` to `createEditorApiHandler` as well; it
|
|
374
|
+
* declares the same catalogue while building the manifest, which is the point
|
|
375
|
+
* that actually needs it.
|
|
305
376
|
*/
|
|
306
377
|
if (config.blockTypes)
|
|
307
378
|
declareBlockCatalogue(config.blockTypes);
|
|
308
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;
|
|
309
389
|
const imageDir = config.imageDir ?? resolve(process.cwd(), ".data/generated-images");
|
|
310
390
|
/*
|
|
311
391
|
* Built on first use rather than at mount: the store opens an append-only
|
|
@@ -699,7 +779,48 @@ export function createOrchestrator(config = {}) {
|
|
|
699
779
|
* publish (LM-04).
|
|
700
780
|
*/
|
|
701
781
|
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
702
|
-
|
|
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;
|
|
703
824
|
const slugs = pages.map((p) => p.slug);
|
|
704
825
|
/*
|
|
705
826
|
* Every exit below records what happened, because the alternative was a
|
|
@@ -759,28 +880,7 @@ export function createOrchestrator(config = {}) {
|
|
|
759
880
|
record(true, "Nothing written — the adapter has no onPublish");
|
|
760
881
|
return jsonResponse({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
|
|
761
882
|
}
|
|
762
|
-
const config =
|
|
763
|
-
/*
|
|
764
|
-
* Hand the adapter the baseline it needs to diff.
|
|
765
|
-
*
|
|
766
|
-
* `onPublish(pages)` alone is a snapshot contract, and a snapshot is not
|
|
767
|
-
* invertible: every CMS read is a projection, so writing the projection
|
|
768
|
-
* back replaces an asset reference with a URL and a document reference
|
|
769
|
-
* with a dead href. A publisher has to compare against what it read, and
|
|
770
|
-
* it cannot compare against nothing.
|
|
771
|
-
*
|
|
772
|
-
* This is the copy the bootstrap already took, not a fresh read — a
|
|
773
|
-
* second `getPages()` here is 45 sequential Sanity calls on the
|
|
774
|
-
* integration that motivated it. It used to be absent for the rest of a
|
|
775
|
-
* process's life after a restart that reloaded the draft from SQLite,
|
|
776
|
-
* which quietly disabled publishing for any adapter that refuses without
|
|
777
|
-
* a baseline; `ensure` now recovers it on the first request instead.
|
|
778
|
-
*
|
|
779
|
-
* It can still be undefined — the adapter read can fail, and baselines
|
|
780
|
-
* are evicted FIFO — so the contract is unchanged: treat undefined as
|
|
781
|
-
* "no baseline available" and never as "the site was empty".
|
|
782
|
-
*/
|
|
783
|
-
const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
|
|
883
|
+
const config = selection.siteConfig;
|
|
784
884
|
const context = body.assets || published
|
|
785
885
|
? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
|
|
786
886
|
: undefined;
|
|
@@ -1164,6 +1264,67 @@ export function createOrchestrator(config = {}) {
|
|
|
1164
1264
|
* The bodies are the same functions the Fastify app calls, so undo means
|
|
1165
1265
|
* the same thing in both. See orchestrator-core/http/history-actions.ts.
|
|
1166
1266
|
*/
|
|
1267
|
+
/*
|
|
1268
|
+
* ---- Checks: findings, runs, and their status --------------------------
|
|
1269
|
+
*
|
|
1270
|
+
* Mounted here in the same commit that adds them to Fastify, rather than
|
|
1271
|
+
* after somebody notices. Every route in `history-actions.ts` exists
|
|
1272
|
+
* because that lesson was learned the expensive way: a UI shipped a button
|
|
1273
|
+
* whose endpoint answered "not handled by createOrchestrator()".
|
|
1274
|
+
*/
|
|
1275
|
+
if (request.method === "POST" && path === "/checks/run") {
|
|
1276
|
+
const runtime = await getRuntime();
|
|
1277
|
+
await runtime.ready;
|
|
1278
|
+
let raw;
|
|
1279
|
+
try {
|
|
1280
|
+
raw = await request.json();
|
|
1281
|
+
}
|
|
1282
|
+
catch {
|
|
1283
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
1284
|
+
}
|
|
1285
|
+
const body = (raw ?? {});
|
|
1286
|
+
// The draft has to exist before it can be checked — a cold library-mode
|
|
1287
|
+
// process has no pages until this runs, and an unchecked empty session
|
|
1288
|
+
// would report every page as missing because there are none.
|
|
1289
|
+
const scoped = scope(body.session, body.siteId);
|
|
1290
|
+
await runtime.bootstrapCache.ensure(scoped, runtime.adapter, runtime.log);
|
|
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);
|
|
1303
|
+
}
|
|
1304
|
+
if (request.method === "GET" && path === "/checks/findings") {
|
|
1305
|
+
const runtime = await getRuntime();
|
|
1306
|
+
await runtime.ready;
|
|
1307
|
+
const query = Object.fromEntries(url.searchParams);
|
|
1308
|
+
return actionResponse(await listFindingsAction(withSiteId(query), runtime.log), cors);
|
|
1309
|
+
}
|
|
1310
|
+
if (request.method === "GET" && path === "/checks/runs") {
|
|
1311
|
+
const runtime = await getRuntime();
|
|
1312
|
+
await runtime.ready;
|
|
1313
|
+
const query = Object.fromEntries(url.searchParams);
|
|
1314
|
+
return actionResponse(await listCheckRunsAction(withSiteId(query)), cors);
|
|
1315
|
+
}
|
|
1316
|
+
if (request.method === "POST" && path === "/checks/findings/status") {
|
|
1317
|
+
const runtime = await getRuntime();
|
|
1318
|
+
await runtime.ready;
|
|
1319
|
+
let raw;
|
|
1320
|
+
try {
|
|
1321
|
+
raw = await request.json();
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
1325
|
+
}
|
|
1326
|
+
return actionResponse(await updateFindingAction(withSiteId((raw ?? {}))), cors);
|
|
1327
|
+
}
|
|
1167
1328
|
if (request.method === "GET" && path === "/history/status") {
|
|
1168
1329
|
const runtime = await getRuntime();
|
|
1169
1330
|
await runtime.ready;
|
|
@@ -1176,7 +1337,7 @@ export function createOrchestrator(config = {}) {
|
|
|
1176
1337
|
const query = Object.fromEntries(url.searchParams);
|
|
1177
1338
|
return actionResponse(historyLog(query), cors);
|
|
1178
1339
|
}
|
|
1179
|
-
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")) {
|
|
1180
1341
|
const runtime = await getRuntime();
|
|
1181
1342
|
await runtime.ready;
|
|
1182
1343
|
let raw;
|
|
@@ -1196,6 +1357,8 @@ export function createOrchestrator(config = {}) {
|
|
|
1196
1357
|
: null;
|
|
1197
1358
|
if (action)
|
|
1198
1359
|
return actionResponse(action(body, runtime.log), cors);
|
|
1360
|
+
if (path === "/history/discard")
|
|
1361
|
+
return actionResponse(historyDiscardAction(body, runtime.log), cors);
|
|
1199
1362
|
return actionResponse(historyRestoreAction(body, runtime.log), cors);
|
|
1200
1363
|
}
|
|
1201
1364
|
/*
|
|
@@ -1490,6 +1653,7 @@ export function createOrchestrator(config = {}) {
|
|
|
1490
1653
|
const query = typeof body.query === "string" ? body.query.trim() : "";
|
|
1491
1654
|
const page = Math.max(1, Math.trunc(Number(body.page) || 1));
|
|
1492
1655
|
const limit = Math.min(50, Math.max(1, Math.trunc(Number(body.limit) || 20)));
|
|
1656
|
+
const kind = body.kind === "file" ? "file" : "image";
|
|
1493
1657
|
const source = typeof runtime.adapter?.getMedia === "function"
|
|
1494
1658
|
? runtime.adapter.getMedia.bind(runtime.adapter)
|
|
1495
1659
|
: mediaSourceFromUnknown(body.config);
|
|
@@ -1499,14 +1663,101 @@ export function createOrchestrator(config = {}) {
|
|
|
1499
1663
|
if (!source)
|
|
1500
1664
|
return jsonResponse({ error: "CMS media not configured" }, { status: 404, cors });
|
|
1501
1665
|
try {
|
|
1502
|
-
const result = await source({ query: query || undefined, page, limit });
|
|
1503
|
-
|
|
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 });
|
|
1504
1687
|
}
|
|
1505
1688
|
catch (error) {
|
|
1506
1689
|
runtime.log.warn?.({ err: error }, "[avocado] CMS media read failed");
|
|
1507
1690
|
return jsonResponse({ items: [], totalPages: 0 }, { cors });
|
|
1508
1691
|
}
|
|
1509
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
|
+
}
|
|
1510
1761
|
/* The Unsplash tab of the image picker. Unconfigured answers 404, not an
|
|
1511
1762
|
* empty result set — "no key" and "no matches" are different answers. */
|
|
1512
1763
|
if (request.method === "GET" && path === "/unsplash/search") {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* keep their own reader — Fastify streams the part (aborting mid-upload once it
|
|
16
16
|
* crosses the cap), library mode reads a `File` off `request.formData()`.
|
|
17
17
|
*/
|
|
18
|
-
import type { ActionResult } from "./history-actions.
|
|
18
|
+
import type { ActionResult } from "./history-actions.ts";
|
|
19
19
|
export type { ActionResult };
|
|
20
20
|
/**
|
|
21
21
|
* Formats both providers read natively.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { CheckRunTrigger } from "../durable/types.ts";
|
|
2
|
+
import type { Logger } from "../logger.ts";
|
|
3
|
+
import type { ActionResult } from "./history-actions.ts";
|
|
4
|
+
export type { ActionResult };
|
|
5
|
+
export type ChecksScope = {
|
|
6
|
+
session?: string;
|
|
7
|
+
siteId?: string;
|
|
8
|
+
};
|
|
9
|
+
export type RunChecksParams = ChecksScope & {
|
|
10
|
+
/** Restrict the scan. Cross-page rules still see the whole site. */
|
|
11
|
+
slugs?: string[];
|
|
12
|
+
trigger?: CheckRunTrigger;
|
|
13
|
+
};
|
|
14
|
+
/** POST /checks/run — scan the session's draft and reconcile its findings. */
|
|
15
|
+
export declare function runChecksAction(params: RunChecksParams, log?: Logger): Promise<ActionResult>;
|
|
16
|
+
export type ListFindingsParams = ChecksScope & {
|
|
17
|
+
slug?: string;
|
|
18
|
+
agent?: string;
|
|
19
|
+
status?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
};
|
|
22
|
+
/** GET /checks/findings — what is currently wrong, newest and most severe first. */
|
|
23
|
+
export declare function listFindingsAction(params: ListFindingsParams, log?: Logger): Promise<ActionResult>;
|
|
24
|
+
/** GET /checks/runs — the ledger, including what each run cost. */
|
|
25
|
+
export declare function listCheckRunsAction(params: ChecksScope & {
|
|
26
|
+
limit?: number;
|
|
27
|
+
}): Promise<ActionResult>;
|
|
28
|
+
export type UpdateFindingParams = ChecksScope & {
|
|
29
|
+
id?: string;
|
|
30
|
+
status?: string;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* POST /checks/findings/status — dismiss, snooze, or reopen one finding.
|
|
34
|
+
*
|
|
35
|
+
* `fixed` is not accepted: it is reconciliation's word, meaning "a run looked
|
|
36
|
+
* and the problem was gone". Letting a client assert it would put a finding
|
|
37
|
+
* into a state the next run immediately contradicts.
|
|
38
|
+
*/
|
|
39
|
+
export declare function updateFindingAction(params: UpdateFindingParams): Promise<ActionResult>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { scopedSessionKey } from "../state/session-state.js";
|
|
2
|
+
import { getDurableStore, durableHealth } from "../durable/durable-store-singleton.js";
|
|
3
|
+
import { runChecksForSession } from "../checks/session-runner.js";
|
|
4
|
+
/*
|
|
5
|
+
* The checks HTTP surface, as transport-agnostic actions.
|
|
6
|
+
*
|
|
7
|
+
* Written this way from the first commit rather than as Fastify handlers,
|
|
8
|
+
* because the alternative is already documented next door: `history-actions.ts`
|
|
9
|
+
* exists because `/history/undo` was wired into Fastify only, and answered "not
|
|
10
|
+
* handled by createOrchestrator()" in a library-mode UI that shows an Undo
|
|
11
|
+
* button unconditionally. A findings panel added to one transport would be a
|
|
12
|
+
* panel that is permanently empty in the other.
|
|
13
|
+
*/
|
|
14
|
+
const badRequest = (error) => ({ code: 400, body: { error } });
|
|
15
|
+
/**
|
|
16
|
+
* What the panel asks for when it does not say. Snoozed rows are excluded:
|
|
17
|
+
* including them is what made Snooze a button that removed a row until the next
|
|
18
|
+
* refresh put it straight back.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_STATUSES = ["open"];
|
|
21
|
+
/**
|
|
22
|
+
* End any snooze whose week is up, on the way in.
|
|
23
|
+
*
|
|
24
|
+
* There is no scheduler in this process, so the wake has to hang off something
|
|
25
|
+
* that already happens; the two read paths are the only moments a snooze
|
|
26
|
+
* expiring is observable. Best-effort — a failed sweep leaves a finding hidden a
|
|
27
|
+
* little longer, which is not worth failing a request over.
|
|
28
|
+
*/
|
|
29
|
+
async function wakeSnoozed(scopeKey, log) {
|
|
30
|
+
try {
|
|
31
|
+
await getDurableStore().wakeSnoozedFindings(scopeKey);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
log?.warn({ err: String(err), scopeKey }, "waking snoozed findings failed");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** POST /checks/run — scan the session's draft and reconcile its findings. */
|
|
38
|
+
export async function runChecksAction(params, log) {
|
|
39
|
+
if (!params.session)
|
|
40
|
+
return badRequest("session is required");
|
|
41
|
+
const scopeKey = scopedSessionKey(params.session, params.siteId);
|
|
42
|
+
try {
|
|
43
|
+
await wakeSnoozed(scopeKey, log);
|
|
44
|
+
const run = await runChecksForSession({
|
|
45
|
+
scopeKey,
|
|
46
|
+
trigger: params.trigger ?? "manual",
|
|
47
|
+
...(params.slugs?.length ? { slugs: params.slugs } : {})
|
|
48
|
+
});
|
|
49
|
+
const findings = await getDurableStore().listFindings({ scopeKey, status: DEFAULT_STATUSES });
|
|
50
|
+
// `durable` rides along on every response: findings written to the
|
|
51
|
+
// in-memory fallback look identical to durable ones until the process
|
|
52
|
+
// restarts, and a scheduled run is exactly when nobody is watching a log.
|
|
53
|
+
return { code: 200, body: { run, findings, durable: durableHealth() } };
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
57
|
+
log?.error({ err: reason, scopeKey }, "checks run failed");
|
|
58
|
+
return { code: 500, body: { error: reason } };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const FINDING_STATUSES = ["open", "snoozed", "dismissed", "fixed"];
|
|
62
|
+
function parseStatuses(raw) {
|
|
63
|
+
if (!raw)
|
|
64
|
+
return undefined;
|
|
65
|
+
const wanted = raw
|
|
66
|
+
.split(",")
|
|
67
|
+
.map((s) => s.trim())
|
|
68
|
+
.filter((s) => FINDING_STATUSES.includes(s));
|
|
69
|
+
return wanted.length > 0 ? wanted : undefined;
|
|
70
|
+
}
|
|
71
|
+
/** GET /checks/findings — what is currently wrong, newest and most severe first. */
|
|
72
|
+
export async function listFindingsAction(params, log) {
|
|
73
|
+
if (!params.session)
|
|
74
|
+
return badRequest("session is required");
|
|
75
|
+
const scopeKey = scopedSessionKey(params.session, params.siteId);
|
|
76
|
+
await wakeSnoozed(scopeKey, log);
|
|
77
|
+
const status = parseStatuses(params.status) ?? DEFAULT_STATUSES;
|
|
78
|
+
const limitRaw = Number(params.limit);
|
|
79
|
+
const findings = await getDurableStore().listFindings({
|
|
80
|
+
scopeKey,
|
|
81
|
+
status,
|
|
82
|
+
...(params.slug ? { slug: params.slug } : {}),
|
|
83
|
+
...(params.agent ? { agent: params.agent } : {}),
|
|
84
|
+
...(Number.isFinite(limitRaw) && limitRaw > 0 ? { limit: Math.floor(limitRaw) } : {})
|
|
85
|
+
});
|
|
86
|
+
return { code: 200, body: { findings, durable: durableHealth() } };
|
|
87
|
+
}
|
|
88
|
+
/** GET /checks/runs — the ledger, including what each run cost. */
|
|
89
|
+
export async function listCheckRunsAction(params) {
|
|
90
|
+
if (!params.session)
|
|
91
|
+
return badRequest("session is required");
|
|
92
|
+
const scopeKey = scopedSessionKey(params.session, params.siteId);
|
|
93
|
+
const limitRaw = Number(params.limit);
|
|
94
|
+
const runs = await getDurableStore().listCheckRuns(scopeKey, Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 50);
|
|
95
|
+
return { code: 200, body: { runs } };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* POST /checks/findings/status — dismiss, snooze, or reopen one finding.
|
|
99
|
+
*
|
|
100
|
+
* `fixed` is not accepted: it is reconciliation's word, meaning "a run looked
|
|
101
|
+
* and the problem was gone". Letting a client assert it would put a finding
|
|
102
|
+
* into a state the next run immediately contradicts.
|
|
103
|
+
*/
|
|
104
|
+
export async function updateFindingAction(params) {
|
|
105
|
+
if (!params.session)
|
|
106
|
+
return badRequest("session is required");
|
|
107
|
+
if (!params.id)
|
|
108
|
+
return badRequest("id is required");
|
|
109
|
+
const status = params.status;
|
|
110
|
+
if (status !== "dismissed" && status !== "snoozed" && status !== "open") {
|
|
111
|
+
return badRequest("status must be dismissed, snoozed or open");
|
|
112
|
+
}
|
|
113
|
+
const scopeKey = scopedSessionKey(params.session, params.siteId);
|
|
114
|
+
const store = getDurableStore();
|
|
115
|
+
const finding = await store.getFinding(params.id);
|
|
116
|
+
// Scope check, not politeness: findings are per-site and the id is opaque, so
|
|
117
|
+
// without this any session could dismiss any other site's finding.
|
|
118
|
+
if (!finding || finding.scopeKey !== scopeKey)
|
|
119
|
+
return { code: 404, body: { error: "finding not found" } };
|
|
120
|
+
await store.setFindingStatus(params.id, status);
|
|
121
|
+
return { code: 200, body: { finding: await store.getFinding(params.id) } };
|
|
122
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* status code and body to send; neither Fastify nor `Response` appears in this
|
|
16
16
|
* file.
|
|
17
17
|
*/
|
|
18
|
-
import type { Logger } from "../logger.
|
|
18
|
+
import type { Logger } from "../logger.ts";
|
|
19
19
|
/** What a caller sends. `siteId` scopes the session; both transports pass it through. */
|
|
20
20
|
export type HistoryScope = {
|
|
21
21
|
session?: string;
|
|
@@ -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;
|