@avocadostudio-ai/orchestrator-core 0.2.3 → 0.3.1
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/sites-agent-tools.js +1 -1
- package/dist/chat/changelog-coverage-validator.d.ts +12 -0
- package/dist/chat/changelog-coverage-validator.js +25 -2
- package/dist/chat/chat-pipeline.js +49 -4
- package/dist/chat/hallucination-validator.d.ts +5 -0
- package/dist/chat/hallucination-validator.js +13 -0
- package/dist/cms/adapter.d.ts +62 -0
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/bootstrap.d.ts +28 -4
- package/dist/cms/bootstrap.js +70 -3
- package/dist/cms/index.d.ts +2 -1
- package/dist/cms/index.js +1 -0
- package/dist/cms/json-file-adapter.js +1 -1
- package/dist/cms/media-sources.d.ts +49 -0
- package/dist/cms/media-sources.js +182 -0
- package/dist/handler/create-orchestrator.js +113 -8
- package/dist/http/screenshot-actions.d.ts +1 -1
- package/dist/http/screenshot-actions.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/ops/ops-engine.d.ts +17 -0
- package/dist/ops/ops-engine.js +20 -1
- package/package.json +4 -4
|
@@ -19,7 +19,7 @@ import { scopedSessionKey, setPage, bumpVersion, getSiteConfig, setSiteConfig }
|
|
|
19
19
|
import { listImages as listGdriveImages, downloadImage as downloadGdriveImage, isGdriveConfigured, resolveGdriveFolderId, fileNameToAlt, } from "../image/gdrive-client.js";
|
|
20
20
|
import sharp from "sharp";
|
|
21
21
|
import { sanitizeSiteId, monorepoRoot, findAvailablePort, patchGlobalsCssVars, validateAndCorrectProps, fixFooterLinks, analyzeCodebase, cloneRepo, detectSitePort, startAndWaitForDevServer, getDraftModeSecret, packageJson, nextConfigTs, tsconfigJson, postcssConfig, layoutTsx, globalsCss, defaultsTs, editorApiRoute, pageTsx, hybridPageTsx, blocksRegisterTsx, samplePagesJson, defaultLogoSvg, faviconSvg, } from "./sites-agent-shared.js";
|
|
22
|
-
/** Convert a package name like "villa-
|
|
22
|
+
/** Convert a package name like "coastal-villa-web" → "Coastal Villa Web" */
|
|
23
23
|
function humanizePkgName(name) {
|
|
24
24
|
return name
|
|
25
25
|
.replace(/^@[^/]+\//, "") // strip scope
|
|
@@ -34,4 +34,16 @@ export type ChangelogCoverageResult = {
|
|
|
34
34
|
export declare function validateChangelogCoverage(args: {
|
|
35
35
|
plan: EditPlan;
|
|
36
36
|
draft: Map<string, PageDoc>;
|
|
37
|
+
/**
|
|
38
|
+
* Trailing `change_log` entries that describe the turn rather than an op —
|
|
39
|
+
* currently just the note the hallucination validator appends when it strips
|
|
40
|
+
* a prop the block does not have.
|
|
41
|
+
*
|
|
42
|
+
* They are neither counted against `ops.length` nor trimmed. Without this the
|
|
43
|
+
* arithmetic below sees N ops and N+1 entries, calls the note the planner
|
|
44
|
+
* over-describing itself, and deletes it — and that note is the only place
|
|
45
|
+
* the user is told a field they asked for does not exist. Two validators
|
|
46
|
+
* twenty lines apart, one erasing the other's output.
|
|
47
|
+
*/
|
|
48
|
+
notes?: string[];
|
|
37
49
|
}): ChangelogCoverageResult;
|
|
@@ -156,6 +156,22 @@ function describeOp(op, draft) {
|
|
|
156
156
|
*/
|
|
157
157
|
export function validateChangelogCoverage(args) {
|
|
158
158
|
const { plan, draft } = args;
|
|
159
|
+
/*
|
|
160
|
+
* Split the notes off the tail, run the op-vs-narration arithmetic on what is
|
|
161
|
+
* left, and put them back. `notes` are appended last by construction, so this
|
|
162
|
+
* is a suffix check rather than a search.
|
|
163
|
+
*/
|
|
164
|
+
const notes = [];
|
|
165
|
+
for (const note of [...(args.notes ?? [])].reverse()) {
|
|
166
|
+
if (plan.change_log[plan.change_log.length - 1] !== note)
|
|
167
|
+
continue;
|
|
168
|
+
plan.change_log = plan.change_log.slice(0, -1);
|
|
169
|
+
notes.unshift(note);
|
|
170
|
+
}
|
|
171
|
+
const restoreNotes = () => {
|
|
172
|
+
if (notes.length > 0)
|
|
173
|
+
plan.change_log = [...plan.change_log, ...notes];
|
|
174
|
+
};
|
|
159
175
|
const empty = {
|
|
160
176
|
plan,
|
|
161
177
|
missingCount: 0,
|
|
@@ -165,8 +181,10 @@ export function validateChangelogCoverage(args) {
|
|
|
165
181
|
fieldMislabelCount: 0,
|
|
166
182
|
relabeledEntries: []
|
|
167
183
|
};
|
|
168
|
-
if (plan.intent !== "edit_plan")
|
|
184
|
+
if (plan.intent !== "edit_plan") {
|
|
185
|
+
restoreNotes();
|
|
169
186
|
return empty;
|
|
187
|
+
}
|
|
170
188
|
const opCount = plan.ops.length;
|
|
171
189
|
// Field-level pass (#13): an update_props entry can be the right COUNT yet name
|
|
172
190
|
// the wrong field. Relabel each mislabeled update_props entry from the op's
|
|
@@ -190,13 +208,16 @@ export function validateChangelogCoverage(args) {
|
|
|
190
208
|
}
|
|
191
209
|
const changeCount = plan.change_log.length;
|
|
192
210
|
if (changeCount === opCount) {
|
|
211
|
+
restoreNotes();
|
|
193
212
|
return { ...empty, fieldMislabelCount: relabeledEntries.length, relabeledEntries };
|
|
194
213
|
}
|
|
195
214
|
// Empty ops list with non-empty change_log is almost always a planner
|
|
196
215
|
// confusion (intent should have been content_answer). Leave it for
|
|
197
216
|
// higher-level handling rather than wiping the user-facing copy here.
|
|
198
|
-
if (opCount === 0)
|
|
217
|
+
if (opCount === 0) {
|
|
218
|
+
restoreNotes();
|
|
199
219
|
return empty;
|
|
220
|
+
}
|
|
200
221
|
if (changeCount < opCount) {
|
|
201
222
|
const missingCount = opCount - changeCount;
|
|
202
223
|
const synthesizedEntries = [];
|
|
@@ -204,6 +225,7 @@ export function validateChangelogCoverage(args) {
|
|
|
204
225
|
synthesizedEntries.push(describeOp(plan.ops[i], draft));
|
|
205
226
|
}
|
|
206
227
|
plan.change_log = [...plan.change_log, ...synthesizedEntries];
|
|
228
|
+
restoreNotes();
|
|
207
229
|
return { plan, missingCount, synthesizedEntries, extraCount: 0, droppedEntries: [], fieldMislabelCount: relabeledEntries.length, relabeledEntries };
|
|
208
230
|
}
|
|
209
231
|
// changeCount > opCount: planner described more changes than it emitted ops
|
|
@@ -211,5 +233,6 @@ export function validateChangelogCoverage(args) {
|
|
|
211
233
|
const extraCount = changeCount - opCount;
|
|
212
234
|
const droppedEntries = plan.change_log.slice(opCount);
|
|
213
235
|
plan.change_log = plan.change_log.slice(0, opCount);
|
|
236
|
+
restoreNotes();
|
|
214
237
|
return { plan, missingCount: 0, synthesizedEntries: [], extraCount, droppedEntries, fieldMislabelCount: relabeledEntries.length, relabeledEntries };
|
|
215
238
|
}
|
|
@@ -4,7 +4,7 @@ import { GENERATING_IMAGE_PLACEHOLDER, SEARCHING_IMAGE_PLACEHOLDER, isGenerating
|
|
|
4
4
|
import { siteCapabilitiesSchema, isBatchAddRequest, isDuplicateBlockRequest, isBlockCatalogQuery, isInfoQuery, isAdviceQuery, adviceResponse, isContentQuery, isPageListQuery, requestsPlanFirst, plannerMessageWithPendingContext, buildSiteContextBlock, infoResponse } from "../nlp/intent-detection.js";
|
|
5
5
|
import { isLikelyClarificationFollowUp } from "../nlp/intent-helpers.js";
|
|
6
6
|
import { versions, pendingClarificationBySession, chatHistoryBySession, pendingApprovalPlanBySession, continuationChainBySession, imageSourcePreferenceBySession, getSessionDraft, getPage, setPage, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, pushChatHistory, schedulePersistState, removePage } from "../state/session-state.js";
|
|
7
|
-
import { toErrorDetail, isNoEffectiveChangeError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
7
|
+
import { toErrorDetail, isNoEffectiveChangeError, isAlreadyCurrentError, classifyGuardrailError, formatValidationError, isDeterministicRepairEligible, buildDeterministicRepairFeedback, validateOperations, applyOpsAtomically, isStructuralOperation, pickFocusBlockId, pickUpdatedSlug } from "../ops/ops-engine.js";
|
|
8
8
|
import { evaluateDestructiveActions } from "../ops/destructive-action-gate.js";
|
|
9
9
|
import { clarificationSuggestions, postEditSuggestions, demoPlanFromMessage, plannerContextPack, compileDeterministicPlan, inferDeterministicIntent, isHighConfidenceDeterministicCase, tryCompoundDeterministicPlan, resolveImageUrlForAltField } from "../nlp/deterministic-planner.js";
|
|
10
10
|
import { generatePlanWithOpenAI, isPlannerOutputError, isStrictJsonResponseEnabled, parseIntentWithOpenAI } from "./planner.js";
|
|
@@ -1101,6 +1101,14 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1101
1101
|
const respondFromPlan = async (plan, source, applyMode = "apply_now", optionsOverride, plannerTier) => {
|
|
1102
1102
|
if (plannerTier)
|
|
1103
1103
|
activePlannerTier = plannerTier;
|
|
1104
|
+
/*
|
|
1105
|
+
* The note the hallucination validator writes when it strips a prop the
|
|
1106
|
+
* block does not have, hoisted so the no-effective-change handler far below
|
|
1107
|
+
* can reach it. That handler replaces `summary_for_user` wholesale, and a
|
|
1108
|
+
* plan whose every prop was stripped lands there — so without this the one
|
|
1109
|
+
* true sentence written about the turn is the one the user never sees.
|
|
1110
|
+
*/
|
|
1111
|
+
let strippedPropsNote;
|
|
1104
1112
|
const usageFields = planUsage ? {
|
|
1105
1113
|
inputTokens: planUsage.inputTokens,
|
|
1106
1114
|
outputTokens: planUsage.outputTokens,
|
|
@@ -1152,6 +1160,7 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1152
1160
|
plan: resolvedPlan,
|
|
1153
1161
|
draft: getSessionDraft(body.session)
|
|
1154
1162
|
});
|
|
1163
|
+
strippedPropsNote = hallucinationResult.note;
|
|
1155
1164
|
if (hallucinationResult.hallucinatedProps.length > 0) {
|
|
1156
1165
|
for (const entry of hallucinationResult.hallucinatedProps) {
|
|
1157
1166
|
ctx.log.warn({
|
|
@@ -1173,7 +1182,9 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
1173
1182
|
// sees the correct count instead of silently approving uncovered ops.
|
|
1174
1183
|
const changelogResult = validateChangelogCoverage({
|
|
1175
1184
|
plan: resolvedPlan,
|
|
1176
|
-
draft: getSessionDraft(body.session)
|
|
1185
|
+
draft: getSessionDraft(body.session),
|
|
1186
|
+
// Not an op narration, so not something to trim for over-describing.
|
|
1187
|
+
notes: hallucinationResult.note ? [hallucinationResult.note] : undefined
|
|
1177
1188
|
});
|
|
1178
1189
|
if (changelogResult.missingCount > 0) {
|
|
1179
1190
|
ctx.log.warn({
|
|
@@ -2540,13 +2551,47 @@ export async function runChatPipeline(ctx, body, options) {
|
|
|
2540
2551
|
...plannerContextTelemetryFields,
|
|
2541
2552
|
...timingFields()
|
|
2542
2553
|
});
|
|
2554
|
+
/*
|
|
2555
|
+
* Two facts arrive here and only one of them is "your content is
|
|
2556
|
+
* already correct".
|
|
2557
|
+
*
|
|
2558
|
+
* The engine throws "All update patches matched existing values" when
|
|
2559
|
+
* the values genuinely matched, and "Edit plan produced no changes"
|
|
2560
|
+
* when the plan had nothing left to apply — which is what a plan looks
|
|
2561
|
+
* like after every prop in it was stripped as unsupported by the block.
|
|
2562
|
+
* Answering both with `status: "applied"` and "already up to date"
|
|
2563
|
+
* tells an author their content is fine when their request was in fact
|
|
2564
|
+
* dropped, and it is the shape of failure nobody reports, because the
|
|
2565
|
+
* product said it worked.
|
|
2566
|
+
*
|
|
2567
|
+
* The two sibling branches in this file already draw this line — the
|
|
2568
|
+
* approval-gate no-op at `planPreview.appliedCount === 0` and the
|
|
2569
|
+
* empty-plan branch that refuses to claim up-to-date-ness for an
|
|
2570
|
+
* `edit_plan` intent. This was the third one, and it was the one an
|
|
2571
|
+
* adopter hit.
|
|
2572
|
+
*/
|
|
2573
|
+
/*
|
|
2574
|
+
* A note from the hallucination validator outranks both engine
|
|
2575
|
+
* messages. It means this turn asked for a field the block does not
|
|
2576
|
+
* have, which is a better answer than either of them and the only one
|
|
2577
|
+
* that names the block — and it is true whichever message the engine
|
|
2578
|
+
* happened to throw, since an op stripped to an empty patch can land in
|
|
2579
|
+
* `skippedOps` and read as "matched existing values".
|
|
2580
|
+
*/
|
|
2581
|
+
const noopSummary = strippedPropsNote ??
|
|
2582
|
+
(isAlreadyCurrentError(reason)
|
|
2583
|
+
? "No changes needed — that content is already set."
|
|
2584
|
+
: "I couldn't make that change — it isn't supported on this block. Try a different field or wording.");
|
|
2543
2585
|
return {
|
|
2544
2586
|
done: true,
|
|
2545
2587
|
response: {
|
|
2546
2588
|
code: 200,
|
|
2547
2589
|
payload: withDebugPayload({
|
|
2548
|
-
|
|
2549
|
-
|
|
2590
|
+
// `info`, not `applied`: nothing was applied either way, and the
|
|
2591
|
+
// approval-gate branch above has answered `info` for the same
|
|
2592
|
+
// fact since it was written.
|
|
2593
|
+
status: "info",
|
|
2594
|
+
summary: noopSummary,
|
|
2550
2595
|
changes: [],
|
|
2551
2596
|
mentionedSlugs: [effectiveSlug],
|
|
2552
2597
|
previewVersion: versions.get(body.session) ?? 0,
|
|
@@ -19,6 +19,11 @@ export type HallucinatedProp = {
|
|
|
19
19
|
export type HallucinationValidationResult = {
|
|
20
20
|
plan: EditPlan;
|
|
21
21
|
hallucinatedProps: HallucinatedProp[];
|
|
22
|
+
/**
|
|
23
|
+
* The user-facing note appended to `summary_for_user` and `change_log`,
|
|
24
|
+
* when props were stripped. Absent when nothing was.
|
|
25
|
+
*/
|
|
26
|
+
note?: string;
|
|
22
27
|
};
|
|
23
28
|
/**
|
|
24
29
|
* Scan every `update_props` op in the plan, strip patch keys that are not
|
|
@@ -105,6 +105,19 @@ export function validateAndStripHallucinatedProps(args) {
|
|
|
105
105
|
const summary = plan.summary_for_user?.trimEnd() ?? "";
|
|
106
106
|
plan.summary_for_user = summary.length > 0 ? `${summary}\n\n${note}` : note;
|
|
107
107
|
plan.change_log = [...plan.change_log, note];
|
|
108
|
+
/*
|
|
109
|
+
* Returned as well as appended, for two callers that both need it back.
|
|
110
|
+
*
|
|
111
|
+
* The changelog-coverage validator runs next and trims trailing entries
|
|
112
|
+
* when there are more of them than ops — and this note is always the last
|
|
113
|
+
* entry, so it was always the one trimmed. It has to be told which entry is
|
|
114
|
+
* a note rather than an op narration.
|
|
115
|
+
*
|
|
116
|
+
* And the no-effective-change handler replaces `summary_for_user` wholesale
|
|
117
|
+
* with a canned line, which is exactly the case where this note is the only
|
|
118
|
+
* true thing anyone wrote about the turn.
|
|
119
|
+
*/
|
|
120
|
+
return { plan, hallucinatedProps, note };
|
|
108
121
|
}
|
|
109
122
|
return { plan, hallucinatedProps };
|
|
110
123
|
}
|
package/dist/cms/adapter.d.ts
CHANGED
|
@@ -57,6 +57,41 @@ export type CmsPublishResult = void | {
|
|
|
57
57
|
error?: string;
|
|
58
58
|
unsupported?: string[];
|
|
59
59
|
};
|
|
60
|
+
/**
|
|
61
|
+
* One asset from a CMS's media library, in the shape the editor's image picker
|
|
62
|
+
* renders. Deliberately the smallest projection that a picker needs: an
|
|
63
|
+
* identity, something to show, something to insert, and something to put in an
|
|
64
|
+
* `alt`. Anything richer would be a per-CMS shape again.
|
|
65
|
+
*/
|
|
66
|
+
export interface CmsMediaItem {
|
|
67
|
+
/** Stable id in the source store. Used as the picker's selection key. */
|
|
68
|
+
id: string;
|
|
69
|
+
/** Human-facing filename or title, if the store has one. */
|
|
70
|
+
name?: string;
|
|
71
|
+
/** Full-size URL to insert into the page. Omitted only if the store has none. */
|
|
72
|
+
imageUrl?: string;
|
|
73
|
+
/** Grid thumbnail. May be the same URL as `imageUrl` when no derivative exists. */
|
|
74
|
+
thumbUrl: string;
|
|
75
|
+
/** Alt text the store already holds, so the editor does not invent one. */
|
|
76
|
+
alt?: string;
|
|
77
|
+
}
|
|
78
|
+
/** One page of a media-library search. */
|
|
79
|
+
export interface CmsMediaQuery {
|
|
80
|
+
/** Free-text filter. Empty or absent means "everything, newest first". */
|
|
81
|
+
query?: string;
|
|
82
|
+
/** 1-based page number. */
|
|
83
|
+
page: number;
|
|
84
|
+
/** Items per page. */
|
|
85
|
+
limit: number;
|
|
86
|
+
}
|
|
87
|
+
/** What `getMedia` returns: the page, and how many there are in total. */
|
|
88
|
+
export interface CmsMediaPage {
|
|
89
|
+
items: CmsMediaItem[];
|
|
90
|
+
/** Total pages for this query, so the picker knows whether to offer "load more". */
|
|
91
|
+
totalPages: number;
|
|
92
|
+
/** Display name for the tab, e.g. "Sanity". Defaults to the adapter's `id`. */
|
|
93
|
+
label?: string;
|
|
94
|
+
}
|
|
60
95
|
/**
|
|
61
96
|
* Which side of a CMS's draft/published split a read wants.
|
|
62
97
|
*
|
|
@@ -146,6 +181,24 @@ export interface CmsAdapter {
|
|
|
146
181
|
* unreachable, so it cannot be a method that reads upstream.
|
|
147
182
|
*/
|
|
148
183
|
readonly capabilities?: CmsCapabilities;
|
|
184
|
+
/**
|
|
185
|
+
* Optional media-library read, for the editor's image picker.
|
|
186
|
+
*
|
|
187
|
+
* The picker used to reach the CMS from the browser, over a closed union of
|
|
188
|
+
* three providers compiled into the editor app. That made the image picker
|
|
189
|
+
* the one part of an integration that could not be integrated: a site could
|
|
190
|
+
* bring its own adapter, its own blocks and its own publish handler, and
|
|
191
|
+
* still had no way to bring its own images short of a change to Avocado.
|
|
192
|
+
*
|
|
193
|
+
* Implement it and the tab appears. Leave it off and it does not — silence
|
|
194
|
+
* means no here, the same way it does for `perspectives`, because a picker
|
|
195
|
+
* that offers a tab it cannot fill is worse than no tab.
|
|
196
|
+
*
|
|
197
|
+
* `cmsMediaSource()` in `./media-sources.ts` implements this for Contentful,
|
|
198
|
+
* Sanity and Strapi if one of those is what you have; it is a helper, not a
|
|
199
|
+
* requirement, and nothing in the route path knows it exists.
|
|
200
|
+
*/
|
|
201
|
+
getMedia?(query: CmsMediaQuery): Promise<CmsMediaPage>;
|
|
149
202
|
}
|
|
150
203
|
/**
|
|
151
204
|
* Per-operation capability declaration. Every field is tri-state, and the
|
|
@@ -203,6 +256,15 @@ export interface ResolvedCapabilities {
|
|
|
203
256
|
* knows not to conclude "nothing is pending" from an empty diff.
|
|
204
257
|
*/
|
|
205
258
|
readsDraftPerspective: boolean;
|
|
259
|
+
/**
|
|
260
|
+
* True when the adapter can list the CMS's media library; derived from the
|
|
261
|
+
* presence of `getMedia`, never declared as a capability.
|
|
262
|
+
*
|
|
263
|
+
* The editor reads it to decide whether to offer a CMS tab in the image
|
|
264
|
+
* picker at all, which is why it is derived rather than declared: a tab that
|
|
265
|
+
* opens onto a method nobody implemented is a worse answer than no tab.
|
|
266
|
+
*/
|
|
267
|
+
readsMedia: boolean;
|
|
206
268
|
/**
|
|
207
269
|
* The subset the adapter actually declared. A caller that needs to
|
|
208
270
|
* distinguish "this site says no" from "nobody said" reads this; everything
|
package/dist/cms/adapter.js
CHANGED
|
@@ -14,6 +14,7 @@ export function resolveCapabilities(adapter, override) {
|
|
|
14
14
|
publishesUpstream: typeof adapter?.onPublish === "function",
|
|
15
15
|
// `=== true`, not truthiness: an adapter that says nothing has not said yes.
|
|
16
16
|
readsDraftPerspective: adapter?.perspectives === true,
|
|
17
|
+
readsMedia: typeof adapter?.getMedia === "function",
|
|
17
18
|
declared
|
|
18
19
|
};
|
|
19
20
|
}
|
package/dist/cms/bootstrap.d.ts
CHANGED
|
@@ -43,12 +43,36 @@ export interface CmsBootstrapCache {
|
|
|
43
43
|
* clone and no extra adapter read. Re-reading upstream at publish time would
|
|
44
44
|
* cost 45 sequential CMS calls on the integration that motivated this.
|
|
45
45
|
*
|
|
46
|
-
* Bounded and best-effort by construction.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* Bounded and best-effort by construction. A restart that reloads the draft
|
|
47
|
+
* from SQLite skips the seed but not the baseline — `ensure` re-reads the
|
|
48
|
+
* adapter for it, which is sound because nothing was published in between —
|
|
49
|
+
* but it is still evicted FIFO past `maxBaselines`, and still null when the
|
|
50
|
+
* adapter read failed. A caller must treat null as "no baseline available"
|
|
51
|
+
* and never as "the site was empty".
|
|
50
52
|
*/
|
|
51
53
|
baselineFor(session: string): PageDoc[] | null;
|
|
54
|
+
/**
|
|
55
|
+
* Discard this session's draft and seed it again from the adapter.
|
|
56
|
+
*
|
|
57
|
+
* `ensure()` is deliberately once-per-session: it exists to not clobber a
|
|
58
|
+
* draft that SQLite already holds. That is right for every ordinary request
|
|
59
|
+
* and wrong for the one case an integrator hits constantly while building an
|
|
60
|
+
* adapter — they change how their projection shapes pages, and the
|
|
61
|
+
* orchestrator keeps serving the shapes it read before the change. The only
|
|
62
|
+
* way out used to be deleting `.data/` and restarting the process, which is
|
|
63
|
+
* a much larger hammer than the problem.
|
|
64
|
+
*
|
|
65
|
+
* **This destroys unpublished edits in the session draft**, which is what
|
|
66
|
+
* makes it a separate, explicitly-requested call rather than something
|
|
67
|
+
* `ensure()` could decide to do on its own.
|
|
68
|
+
*
|
|
69
|
+
* It discards nothing until the adapter has answered: a failed read rejects
|
|
70
|
+
* and leaves the draft, the baseline and the attempt marker untouched, so a
|
|
71
|
+
* reseed against a CMS that happens to be down cannot cost anyone their
|
|
72
|
+
* work. A read that succeeds and returns no pages *is* an answer, and empties
|
|
73
|
+
* the draft accordingly.
|
|
74
|
+
*/
|
|
75
|
+
reseed(session: string, adapter: CmsAdapter | null | undefined, log: Logger): Promise<void>;
|
|
52
76
|
/** Test helper — drop all remembered state. */
|
|
53
77
|
reset(): void;
|
|
54
78
|
/** Inspection — number of remembered attempts (post-eviction). */
|
package/dist/cms/bootstrap.js
CHANGED
|
@@ -129,7 +129,31 @@ export function createCmsBootstrapCache(opts = {}) {
|
|
|
129
129
|
return existing;
|
|
130
130
|
const promise = (async () => {
|
|
131
131
|
const draft = getSessionDraft(session);
|
|
132
|
-
|
|
132
|
+
/*
|
|
133
|
+
* A draft that is already populated came from SQLite, not from us, and
|
|
134
|
+
* for a long time that was the end of the matter: seeding was the only
|
|
135
|
+
* thing this function did, so a loaded draft meant there was nothing to
|
|
136
|
+
* do. But the seed also produces the publish baseline, and that made the
|
|
137
|
+
* early return silently disable publishing. The draft survives a
|
|
138
|
+
* restart, so `ensure` returned here, so `rememberBaseline` never ran, so
|
|
139
|
+
* `context.published` was `undefined` for the rest of the process's life
|
|
140
|
+
* — and a publisher written the documented way (diff against the
|
|
141
|
+
* baseline, refuse when there is none) can then never publish again.
|
|
142
|
+
* Restarting to fix it is the one thing guaranteed not to.
|
|
143
|
+
*
|
|
144
|
+
* So the two jobs are now separate. A loaded draft still skips the seed;
|
|
145
|
+
* it no longer skips the baseline. Recovering it costs one
|
|
146
|
+
* `getPages()` — the same call the seed would have made, usually served
|
|
147
|
+
* from the warm cache — and it is correct to read upstream for it,
|
|
148
|
+
* because nothing has been published: the CMS still holds exactly the
|
|
149
|
+
* pre-edit content the lost baseline was a copy of.
|
|
150
|
+
*
|
|
151
|
+
* This is the same shape as the `setSessionCapabilities` hoist above.
|
|
152
|
+
* Anything the seed produces besides the draft has to survive a restart
|
|
153
|
+
* that reloads the draft.
|
|
154
|
+
*/
|
|
155
|
+
const seeded = draft.size > 0;
|
|
156
|
+
if (seeded && baselines.has(session))
|
|
133
157
|
return; // finally-block records the attempt
|
|
134
158
|
try {
|
|
135
159
|
const warmed = warmedPages(adapter);
|
|
@@ -139,13 +163,17 @@ export function createCmsBootstrapCache(opts = {}) {
|
|
|
139
163
|
return;
|
|
140
164
|
}
|
|
141
165
|
/*
|
|
142
|
-
* Keep the pre-edit copy.
|
|
143
|
-
*
|
|
166
|
+
* Keep the pre-edit copy. When we are also seeding, the pages are
|
|
167
|
+
* already being cloned into the draft, so the baseline costs one more
|
|
144
168
|
* clone and — the point — no second adapter read. Re-reading upstream
|
|
145
169
|
* at publish time is 45 sequential CMS calls on the integration that
|
|
146
170
|
* motivated this.
|
|
147
171
|
*/
|
|
148
172
|
rememberBaseline(session, pages.map((page) => structuredClone(page)));
|
|
173
|
+
if (seeded) {
|
|
174
|
+
log.info({ session, adapter: adapter.id, count: pages.length }, "cms-bootstrap: recovered publish baseline for a draft reloaded from storage");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
149
177
|
for (const page of pages) {
|
|
150
178
|
const copy = structuredClone(page);
|
|
151
179
|
ensureHeroImageProps(copy);
|
|
@@ -174,9 +202,48 @@ export function createCmsBootstrapCache(opts = {}) {
|
|
|
174
202
|
inFlight.delete(session);
|
|
175
203
|
}
|
|
176
204
|
}
|
|
205
|
+
async function reseed(session, adapter, log) {
|
|
206
|
+
if (!adapter)
|
|
207
|
+
return;
|
|
208
|
+
setSessionCapabilities(session, resolveCapabilities(adapter, capabilityOverride));
|
|
209
|
+
// Wait out a seed already in flight, so the discard below cannot land
|
|
210
|
+
// between that seed's adapter read and its writes and leave half a draft.
|
|
211
|
+
const existing = inFlight.get(session);
|
|
212
|
+
if (existing)
|
|
213
|
+
await existing.catch(() => { });
|
|
214
|
+
/*
|
|
215
|
+
* Read before discarding, not after. Clearing the draft and then calling
|
|
216
|
+
* `ensure` reads well and fails badly: `ensure` swallows an adapter error
|
|
217
|
+
* and marks the session attempted on the way out, so an adapter that was
|
|
218
|
+
* merely down turned a stale draft into an empty one that nothing would
|
|
219
|
+
* ever retry. The caller asked to replace the draft with what the adapter
|
|
220
|
+
* says; if the adapter says nothing, there is no replacement to make and
|
|
221
|
+
* the right answer is to leave the session exactly as we found it.
|
|
222
|
+
*
|
|
223
|
+
* This deliberately does not consult the warmed list. That list is a
|
|
224
|
+
* cold-start bridge and may predate whatever change prompted the reseed —
|
|
225
|
+
* serving it here would return the exact staleness we were asked to clear.
|
|
226
|
+
*/
|
|
227
|
+
const pages = await adapter.getPages({ perspective: "draft" });
|
|
228
|
+
const draft = getSessionDraft(session);
|
|
229
|
+
const discarded = draft.size;
|
|
230
|
+
draft.clear();
|
|
231
|
+
for (const page of pages) {
|
|
232
|
+
const copy = structuredClone(page);
|
|
233
|
+
ensureHeroImageProps(copy);
|
|
234
|
+
draft.set(copy.slug, copy);
|
|
235
|
+
}
|
|
236
|
+
rememberBaseline(session, pages.map((page) => structuredClone(page)));
|
|
237
|
+
markAttempted(session);
|
|
238
|
+
warmEntry = null;
|
|
239
|
+
bumpVersion(session);
|
|
240
|
+
schedulePersistState(log);
|
|
241
|
+
log.info({ session, adapter: adapter.id, discarded, count: pages.length }, "cms-bootstrap: reseeded session draft from the adapter");
|
|
242
|
+
}
|
|
177
243
|
return {
|
|
178
244
|
ensure,
|
|
179
245
|
warm,
|
|
246
|
+
reseed,
|
|
180
247
|
baselineFor(session) {
|
|
181
248
|
return baselines.get(session) ?? null;
|
|
182
249
|
},
|
package/dist/cms/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./adapter.ts";
|
|
1
|
+
export type { CmsAdapter, CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, ResolvedCapabilities } from "./adapter.ts";
|
|
2
2
|
export { resolveCapabilities } from "./adapter.ts";
|
|
3
3
|
export { jsonFileAdapter, type JsonFileAdapterOptions } from "./json-file-adapter.ts";
|
|
4
4
|
export { editorApiAdapter, type EditorApiAdapterOptions } from "./editor-api-adapter.ts";
|
|
5
5
|
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, type CmsBootstrapCache, type CmsBootstrapCacheOptions, warmSessionBootstrap } from "./bootstrap.ts";
|
|
6
|
+
export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown, type CmsMediaSource, type CmsMediaSourceConfig } from "./media-sources.ts";
|
package/dist/cms/index.js
CHANGED
|
@@ -2,3 +2,4 @@ export { resolveCapabilities } from "./adapter.js";
|
|
|
2
2
|
export { jsonFileAdapter } from "./json-file-adapter.js";
|
|
3
3
|
export { editorApiAdapter } from "./editor-api-adapter.js";
|
|
4
4
|
export { ensureSessionBootstrapped, createCmsBootstrapCache, _resetCmsBootstrapCache, warmSessionBootstrap } from "./bootstrap.js";
|
|
5
|
+
export { cmsMediaSource, cmsMediaLabel, mediaSourceFromUnknown } from "./media-sources.js";
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Smallest possible adapter. Suitable for sites that keep their content as
|
|
4
4
|
// a checked-in JSON array (the shape the orchestrator + blocks already
|
|
5
|
-
// understand).
|
|
5
|
+
// understand). A consumer site's lib/published-content.json is the
|
|
6
6
|
// canonical example.
|
|
7
7
|
//
|
|
8
8
|
// If `writeOnPublish` is true, onPublish() will overwrite the file with the
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { CmsMediaPage, CmsMediaQuery } from "./adapter.ts";
|
|
2
|
+
/** Connection details for one of the built-in media readers. */
|
|
3
|
+
export type CmsMediaSourceConfig = {
|
|
4
|
+
provider: "contentful";
|
|
5
|
+
spaceId: string;
|
|
6
|
+
deliveryToken: string;
|
|
7
|
+
environment?: string;
|
|
8
|
+
} | {
|
|
9
|
+
provider: "sanity";
|
|
10
|
+
projectId: string;
|
|
11
|
+
dataset?: string;
|
|
12
|
+
token?: string;
|
|
13
|
+
} | {
|
|
14
|
+
provider: "strapi";
|
|
15
|
+
url: string;
|
|
16
|
+
token?: string;
|
|
17
|
+
};
|
|
18
|
+
/** A media reader: the exact shape of `CmsAdapter.getMedia`. */
|
|
19
|
+
export type CmsMediaSource = (query: CmsMediaQuery) => Promise<CmsMediaPage>;
|
|
20
|
+
/** Display name for a provider id, for the picker's tab. */
|
|
21
|
+
export declare function cmsMediaLabel(provider: CmsMediaSourceConfig["provider"]): string;
|
|
22
|
+
/**
|
|
23
|
+
* Build a media reader from connection details.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* export function sanityAdapter(): CmsAdapter {
|
|
27
|
+
* return {
|
|
28
|
+
* id: "sanity",
|
|
29
|
+
* getPages: …,
|
|
30
|
+
* getMedia: cmsMediaSource({ provider: "sanity", projectId, dataset })
|
|
31
|
+
* }
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Every reader answers an empty page rather than throwing when the upstream
|
|
36
|
+
* call fails: a picker tab that renders nothing is recoverable, and a rejected
|
|
37
|
+
* promise inside a modal is not.
|
|
38
|
+
*/
|
|
39
|
+
export declare function cmsMediaSource(config: CmsMediaSourceConfig): CmsMediaSource;
|
|
40
|
+
/**
|
|
41
|
+
* Build a reader from an untrusted object, or `null` if it is not one of the
|
|
42
|
+
* three shapes.
|
|
43
|
+
*
|
|
44
|
+
* The editor sends this over the wire, so every field is checked rather than
|
|
45
|
+
* asserted. A partially-filled form — the drawer creates `{ provider:
|
|
46
|
+
* "sanity", projectId: "" }` the moment the provider is picked — must read as
|
|
47
|
+
* "not configured" and not as a reader that will 404 on every request.
|
|
48
|
+
*/
|
|
49
|
+
export declare function mediaSourceFromUnknown(raw: unknown): CmsMediaSource | null;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
const LABELS = {
|
|
2
|
+
contentful: "Contentful",
|
|
3
|
+
sanity: "Sanity",
|
|
4
|
+
strapi: "Strapi"
|
|
5
|
+
};
|
|
6
|
+
/** Display name for a provider id, for the picker's tab. */
|
|
7
|
+
export function cmsMediaLabel(provider) {
|
|
8
|
+
return LABELS[provider];
|
|
9
|
+
}
|
|
10
|
+
const EMPTY = (label) => ({ items: [], totalPages: 0, label });
|
|
11
|
+
/**
|
|
12
|
+
* Build a media reader from connection details.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* export function sanityAdapter(): CmsAdapter {
|
|
16
|
+
* return {
|
|
17
|
+
* id: "sanity",
|
|
18
|
+
* getPages: …,
|
|
19
|
+
* getMedia: cmsMediaSource({ provider: "sanity", projectId, dataset })
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Every reader answers an empty page rather than throwing when the upstream
|
|
25
|
+
* call fails: a picker tab that renders nothing is recoverable, and a rejected
|
|
26
|
+
* promise inside a modal is not.
|
|
27
|
+
*/
|
|
28
|
+
export function cmsMediaSource(config) {
|
|
29
|
+
switch (config.provider) {
|
|
30
|
+
case "contentful": return (q) => contentfulMedia(config, q);
|
|
31
|
+
case "sanity": return (q) => sanityMedia(config, q);
|
|
32
|
+
case "strapi": return (q) => strapiMedia(config, q);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Contentful — Delivery API, `skip`/`limit` paging, `total` in the envelope.
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
async function contentfulMedia(config, { query, page, limit }) {
|
|
39
|
+
const label = LABELS.contentful;
|
|
40
|
+
const env = config.environment ?? "master";
|
|
41
|
+
const params = new URLSearchParams({
|
|
42
|
+
access_token: config.deliveryToken,
|
|
43
|
+
limit: String(limit),
|
|
44
|
+
skip: String((page - 1) * limit),
|
|
45
|
+
mimetype_group: "image"
|
|
46
|
+
});
|
|
47
|
+
if (query)
|
|
48
|
+
params.set("query", query);
|
|
49
|
+
const res = await fetch(`https://cdn.contentful.com/spaces/${config.spaceId}/environments/${env}/assets?${params}`);
|
|
50
|
+
if (!res.ok)
|
|
51
|
+
return EMPTY(label);
|
|
52
|
+
const data = (await res.json());
|
|
53
|
+
const items = (data.items ?? [])
|
|
54
|
+
.filter((item) => item.sys?.id && item.fields?.file?.url)
|
|
55
|
+
.map((item) => {
|
|
56
|
+
const url = item.fields.file.url;
|
|
57
|
+
// Contentful returns protocol-relative asset URLs.
|
|
58
|
+
const fullUrl = url.startsWith("//") ? `https:${url}` : url;
|
|
59
|
+
return {
|
|
60
|
+
id: item.sys.id,
|
|
61
|
+
name: item.fields?.title,
|
|
62
|
+
alt: item.fields?.description ?? item.fields?.title,
|
|
63
|
+
imageUrl: fullUrl,
|
|
64
|
+
thumbUrl: `${fullUrl}?w=200&h=200&fit=thumb`
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
return { items, totalPages: Math.ceil((data.total ?? 0) / limit), label };
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Sanity — GROQ, slice paging, a second query for the count.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
async function sanityMedia(config, { query, page, limit }) {
|
|
73
|
+
const label = LABELS.sanity;
|
|
74
|
+
const dataset = config.dataset ?? "production";
|
|
75
|
+
const offset = (page - 1) * limit;
|
|
76
|
+
const end = offset + limit - 1;
|
|
77
|
+
// GROQ is interpolated, so the filter term is stripped of the two characters
|
|
78
|
+
// that could close the string literal it lands inside.
|
|
79
|
+
const safeQuery = (query ?? "").replace(/["\\]/g, "");
|
|
80
|
+
const filter = safeQuery
|
|
81
|
+
? `_type == "sanity.imageAsset" && originalFilename match "*${safeQuery}*"`
|
|
82
|
+
: `_type == "sanity.imageAsset"`;
|
|
83
|
+
const groq = `*[${filter}] | order(_createdAt desc) [${offset}..${end}] { _id, url, originalFilename, metadata { dimensions } }`;
|
|
84
|
+
const countGroq = `count(*[${filter}])`;
|
|
85
|
+
const base = `https://${config.projectId}.api.sanity.io/v2024-01-01/data/query/${dataset}`;
|
|
86
|
+
const headers = {};
|
|
87
|
+
if (config.token)
|
|
88
|
+
headers.authorization = `Bearer ${config.token}`;
|
|
89
|
+
const [assetsRes, countRes] = await Promise.all([
|
|
90
|
+
fetch(`${base}?query=${encodeURIComponent(groq)}`, { headers }),
|
|
91
|
+
fetch(`${base}?query=${encodeURIComponent(countGroq)}`, { headers })
|
|
92
|
+
]);
|
|
93
|
+
if (!assetsRes.ok)
|
|
94
|
+
return EMPTY(label);
|
|
95
|
+
const assets = (await assetsRes.json());
|
|
96
|
+
const count = countRes.ok ? (await countRes.json()).result ?? 0 : 0;
|
|
97
|
+
return {
|
|
98
|
+
items: (assets.result ?? []).map((asset) => ({
|
|
99
|
+
id: asset._id,
|
|
100
|
+
name: asset.originalFilename,
|
|
101
|
+
alt: asset.originalFilename,
|
|
102
|
+
imageUrl: asset.url,
|
|
103
|
+
thumbUrl: `${asset.url}?w=200&h=200&fit=crop`
|
|
104
|
+
})),
|
|
105
|
+
totalPages: Math.ceil(count / limit),
|
|
106
|
+
label
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Strapi — upload plugin, page/pageSize paging, count in a response header.
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
async function strapiMedia(config, { query, page, limit }) {
|
|
113
|
+
const label = LABELS.strapi;
|
|
114
|
+
const baseUrl = config.url.replace(/\/+$/, "");
|
|
115
|
+
const params = new URLSearchParams({
|
|
116
|
+
"filters[mime][$startsWith]": "image",
|
|
117
|
+
"pagination[page]": String(page),
|
|
118
|
+
"pagination[pageSize]": String(limit),
|
|
119
|
+
sort: "createdAt:desc"
|
|
120
|
+
});
|
|
121
|
+
if (query)
|
|
122
|
+
params.set("filters[name][$containsi]", query);
|
|
123
|
+
const headers = {};
|
|
124
|
+
if (config.token)
|
|
125
|
+
headers.authorization = `Bearer ${config.token}`;
|
|
126
|
+
const res = await fetch(`${baseUrl}/api/upload/files?${params}`, { headers });
|
|
127
|
+
if (!res.ok)
|
|
128
|
+
return EMPTY(label);
|
|
129
|
+
const data = (await res.json());
|
|
130
|
+
const absolute = (u) => (u.startsWith("http") ? u : `${baseUrl}${u}`);
|
|
131
|
+
// The upload endpoint returns a bare array; the count is a header.
|
|
132
|
+
const totalCount = Number(res.headers.get("x-total-count") ?? data.length);
|
|
133
|
+
return {
|
|
134
|
+
items: data.map((file) => ({
|
|
135
|
+
id: file.documentId ?? String(file.id),
|
|
136
|
+
name: file.name,
|
|
137
|
+
alt: file.name,
|
|
138
|
+
imageUrl: absolute(file.url),
|
|
139
|
+
thumbUrl: file.formats?.thumbnail?.url ? absolute(file.formats.thumbnail.url) : absolute(file.url)
|
|
140
|
+
})),
|
|
141
|
+
totalPages: Math.ceil(totalCount / limit),
|
|
142
|
+
label
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Build a reader from an untrusted object, or `null` if it is not one of the
|
|
147
|
+
* three shapes.
|
|
148
|
+
*
|
|
149
|
+
* The editor sends this over the wire, so every field is checked rather than
|
|
150
|
+
* asserted. A partially-filled form — the drawer creates `{ provider:
|
|
151
|
+
* "sanity", projectId: "" }` the moment the provider is picked — must read as
|
|
152
|
+
* "not configured" and not as a reader that will 404 on every request.
|
|
153
|
+
*/
|
|
154
|
+
export function mediaSourceFromUnknown(raw) {
|
|
155
|
+
if (!raw || typeof raw !== "object")
|
|
156
|
+
return null;
|
|
157
|
+
const obj = raw;
|
|
158
|
+
const str = (v) => typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
159
|
+
switch (obj.provider) {
|
|
160
|
+
case "contentful": {
|
|
161
|
+
const spaceId = str(obj.spaceId);
|
|
162
|
+
const deliveryToken = str(obj.deliveryToken);
|
|
163
|
+
if (!spaceId || !deliveryToken)
|
|
164
|
+
return null;
|
|
165
|
+
return cmsMediaSource({ provider: "contentful", spaceId, deliveryToken, environment: str(obj.environment) });
|
|
166
|
+
}
|
|
167
|
+
case "sanity": {
|
|
168
|
+
const projectId = str(obj.projectId);
|
|
169
|
+
if (!projectId)
|
|
170
|
+
return null;
|
|
171
|
+
return cmsMediaSource({ provider: "sanity", projectId, dataset: str(obj.dataset), token: str(obj.token) });
|
|
172
|
+
}
|
|
173
|
+
case "strapi": {
|
|
174
|
+
const url = str(obj.url);
|
|
175
|
+
if (!url)
|
|
176
|
+
return null;
|
|
177
|
+
return cmsMediaSource({ provider: "strapi", url, token: str(obj.token) });
|
|
178
|
+
}
|
|
179
|
+
default:
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -47,6 +47,7 @@ import { createFeedbackStore } from "../telemetry/feedback-store.js";
|
|
|
47
47
|
import { consoleLogger } from "../logger.js";
|
|
48
48
|
import { createCmsBootstrapCache } from "../cms/bootstrap.js";
|
|
49
49
|
import { resolveCapabilities } from "../cms/adapter.js";
|
|
50
|
+
import { mediaSourceFromUnknown } from "../cms/media-sources.js";
|
|
50
51
|
import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
|
|
51
52
|
import { checkAuth, resolveAuth } from "./auth.js";
|
|
52
53
|
const defaultModelLookup = () => ({
|
|
@@ -122,7 +123,7 @@ async function buildRuntime(config) {
|
|
|
122
123
|
* Start the adapter's page read now rather than on the first request.
|
|
123
124
|
*
|
|
124
125
|
* `ensure()` is lazy, so without this the first caller pays for the whole
|
|
125
|
-
* CMS read:
|
|
126
|
+
* CMS read: a tri-lingual site's adapter is 45 sequential Sanity reads
|
|
126
127
|
* behind a dev-server compile, and the `whoami` an MCP agent opens with took
|
|
127
128
|
* four minutes — long enough that the host times out and the agent reports
|
|
128
129
|
* the site as down. Fire-and-forget by contract: a failed warm logs and is
|
|
@@ -229,6 +230,7 @@ const SUPPORTED_ROUTES = [
|
|
|
229
230
|
"POST /restore/snapshot",
|
|
230
231
|
"DELETE /restore/snapshot",
|
|
231
232
|
"GET /unsplash/search",
|
|
233
|
+
"POST /media/cms",
|
|
232
234
|
"GET+POST /telemetry/chat/feedback",
|
|
233
235
|
"POST /preview/screenshot",
|
|
234
236
|
"POST /audio/transcribe",
|
|
@@ -835,6 +837,10 @@ export function createOrchestrator(config = {}) {
|
|
|
835
837
|
unsplash: Boolean(process.env.UNSPLASH_ACCESS_KEY),
|
|
836
838
|
imageGenerate: hasImageBackend,
|
|
837
839
|
imageGenerateChat: hasImageBackend,
|
|
840
|
+
/* Whether this mount's adapter can list a media library. The
|
|
841
|
+
* editor offers the picker's CMS tab on it, so silence hides the
|
|
842
|
+
* tab rather than opening one onto a method nobody implemented. */
|
|
843
|
+
cmsMedia: runtime.capabilities.readsMedia,
|
|
838
844
|
agentMode: false
|
|
839
845
|
},
|
|
840
846
|
/*
|
|
@@ -866,10 +872,24 @@ export function createOrchestrator(config = {}) {
|
|
|
866
872
|
await runtime.ready;
|
|
867
873
|
const session = url.searchParams.get("session") ?? undefined;
|
|
868
874
|
const siteId = url.searchParams.get("siteId") ?? undefined;
|
|
869
|
-
const slug = url.searchParams.get("slug")
|
|
870
|
-
if (!session ||
|
|
875
|
+
const slug = url.searchParams.get("slug");
|
|
876
|
+
if (!session || slug === null) {
|
|
871
877
|
return jsonResponse({ error: "session and slug are required" }, { status: 400, cors });
|
|
872
878
|
}
|
|
879
|
+
/*
|
|
880
|
+
* `?slug=` is a parameter that is present and empty, and answering
|
|
881
|
+
* "required" sends the integrator to look for a bug in their query
|
|
882
|
+
* string. What it actually means is that their projection emitted bare
|
|
883
|
+
* paths: a slug is a leading-slash path and the home page's is `"/"`.
|
|
884
|
+
* Every other bare slug resolves by string equality, so the home page is
|
|
885
|
+
* the only one that fails, and it fails as the site's own 404 rendered
|
|
886
|
+
* inside the editor iframe — which reads as "my site is broken".
|
|
887
|
+
*/
|
|
888
|
+
if (slug === "") {
|
|
889
|
+
return jsonResponse({
|
|
890
|
+
error: 'slug must not be empty: a page slug is a path beginning with "/", and the home page is "/" rather than ""'
|
|
891
|
+
}, { status: 400, cors });
|
|
892
|
+
}
|
|
873
893
|
const scopedSession = scope(session, siteId);
|
|
874
894
|
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
875
895
|
const page = getPage(scopedSession, slug);
|
|
@@ -909,9 +929,25 @@ export function createOrchestrator(config = {}) {
|
|
|
909
929
|
})
|
|
910
930
|
}, { status: 200, cors });
|
|
911
931
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
932
|
+
/*
|
|
933
|
+
* Editor bootstrap probe. In library mode the adapter is the source of
|
|
934
|
+
* truth (already seeded by ensure), so this is effectively a confirm — we
|
|
935
|
+
* don't clobber the draft with the posted pages.
|
|
936
|
+
*
|
|
937
|
+
* `force` was accepted by the standalone orchestrator and silently dropped
|
|
938
|
+
* here: the body was read as `{session, siteId}` and nothing else, so a
|
|
939
|
+
* caller asking to re-read the adapter got the cached page list back with
|
|
940
|
+
* a 200 and the word "bootstrapped" on it. That is worse than refusing,
|
|
941
|
+
* because the shape an integrator is in when they send it is "I changed my
|
|
942
|
+
* projection and the orchestrator is still serving the old shapes" — and
|
|
943
|
+
* the answer looks like confirmation that it re-read. The documented
|
|
944
|
+
* escape was deleting `.data/` and restarting.
|
|
945
|
+
*
|
|
946
|
+
* It now does what it says, including the destructive half: the session
|
|
947
|
+
* draft is discarded and rebuilt from the adapter, so unpublished edits in
|
|
948
|
+
* it are lost. `forced` comes back in the response so a caller can tell
|
|
949
|
+
* which of the two things happened.
|
|
950
|
+
*/
|
|
915
951
|
if (request.method === "POST" && path === "/draft/bootstrap") {
|
|
916
952
|
const runtime = await getRuntime();
|
|
917
953
|
await runtime.ready;
|
|
@@ -924,9 +960,31 @@ export function createOrchestrator(config = {}) {
|
|
|
924
960
|
}
|
|
925
961
|
const body = (raw ?? {});
|
|
926
962
|
const scopedSession = scope(body.session, body.siteId);
|
|
927
|
-
|
|
963
|
+
const force = body.force === true;
|
|
964
|
+
if (force) {
|
|
965
|
+
try {
|
|
966
|
+
await runtime.bootstrapCache.reseed(scopedSession, runtime.adapter, runtime.log);
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
/*
|
|
970
|
+
* A forced reseed reaches the adapter and can fail there, and the
|
|
971
|
+
* one thing this route must not do is answer "bootstrapped" when it
|
|
972
|
+
* did not bootstrap — that is the failure being fixed, in a new
|
|
973
|
+
* costume. The reseed discards nothing before the read succeeds, so
|
|
974
|
+
* the draft is intact and saying so is the useful half of the reply.
|
|
975
|
+
*/
|
|
976
|
+
return jsonResponse({
|
|
977
|
+
error: "reseed failed: the adapter could not be read",
|
|
978
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
979
|
+
draft: "unchanged"
|
|
980
|
+
}, { status: 502, cors });
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
|
|
985
|
+
}
|
|
928
986
|
const pages = getSessionPages(scopedSession);
|
|
929
|
-
return jsonResponse({ status: "bootstrapped", count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
|
|
987
|
+
return jsonResponse({ status: "bootstrapped", forced: force, count: pages.length, slugs: pages.map((p) => p.slug) }, { status: 200, cors });
|
|
930
988
|
}
|
|
931
989
|
// Site config — drives the page-level nav-label + SEO fields shown in the
|
|
932
990
|
// property panel when no block is selected.
|
|
@@ -1389,6 +1447,53 @@ export function createOrchestrator(config = {}) {
|
|
|
1389
1447
|
}
|
|
1390
1448
|
return actionResponse(await restoreSnapshotDelete((raw ?? {})), cors);
|
|
1391
1449
|
}
|
|
1450
|
+
/*
|
|
1451
|
+
* The CMS tab of the image picker.
|
|
1452
|
+
*
|
|
1453
|
+
* Two callers, one route. A library-mode site whose adapter implements
|
|
1454
|
+
* `getMedia` needs to send nothing: the credentials are already in the
|
|
1455
|
+
* process serving this request. The standalone multi-site orchestrator
|
|
1456
|
+
* wires no adapter at all, so the editor sends the per-site connection
|
|
1457
|
+
* details it holds and the route builds a reader from them.
|
|
1458
|
+
*
|
|
1459
|
+
* The adapter wins when both are available — a site that implemented the
|
|
1460
|
+
* seam meant it, and its own reader can see things a generic one cannot.
|
|
1461
|
+
*
|
|
1462
|
+
* POST rather than GET because the second form carries a token, and a
|
|
1463
|
+
* token in a query string is a token in every access log between here and
|
|
1464
|
+
* the browser.
|
|
1465
|
+
*/
|
|
1466
|
+
if (request.method === "POST" && path === "/media/cms") {
|
|
1467
|
+
const runtime = await getRuntime();
|
|
1468
|
+
await runtime.ready;
|
|
1469
|
+
let raw;
|
|
1470
|
+
try {
|
|
1471
|
+
raw = await request.json();
|
|
1472
|
+
}
|
|
1473
|
+
catch {
|
|
1474
|
+
return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
|
|
1475
|
+
}
|
|
1476
|
+
const body = (raw ?? {});
|
|
1477
|
+
const query = typeof body.query === "string" ? body.query.trim() : "";
|
|
1478
|
+
const page = Math.max(1, Math.trunc(Number(body.page) || 1));
|
|
1479
|
+
const limit = Math.min(50, Math.max(1, Math.trunc(Number(body.limit) || 20)));
|
|
1480
|
+
const source = typeof runtime.adapter?.getMedia === "function"
|
|
1481
|
+
? runtime.adapter.getMedia.bind(runtime.adapter)
|
|
1482
|
+
: mediaSourceFromUnknown(body.config);
|
|
1483
|
+
/* No adapter method and no usable config is "this site has no media
|
|
1484
|
+
* library", which is a 404 and not an empty page — the editor hides the
|
|
1485
|
+
* tab on the former and renders an empty grid on the latter. */
|
|
1486
|
+
if (!source)
|
|
1487
|
+
return jsonResponse({ error: "CMS media not configured" }, { status: 404, cors });
|
|
1488
|
+
try {
|
|
1489
|
+
const result = await source({ query: query || undefined, page, limit });
|
|
1490
|
+
return jsonResponse(result, { cors });
|
|
1491
|
+
}
|
|
1492
|
+
catch (error) {
|
|
1493
|
+
runtime.log.warn?.({ err: error }, "[avocado] CMS media read failed");
|
|
1494
|
+
return jsonResponse({ items: [], totalPages: 0 }, { cors });
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1392
1497
|
/* The Unsplash tab of the image picker. Unconfigured answers 404, not an
|
|
1393
1498
|
* empty result set — "no key" and "no matches" are different answers. */
|
|
1394
1499
|
if (request.method === "GET" && path === "/unsplash/search") {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* `/preview-draft` is only the *default*, though, and it was hardcoded — which
|
|
20
20
|
* meant this route worked on sites `create-ai-site-editor` scaffolded and on no
|
|
21
21
|
* others. An existing site that wires Avocado into its own app has its own
|
|
22
|
-
* draft route (
|
|
22
|
+
* draft route (one such site's is `/avocado/<lang>/<slug>`), and the
|
|
23
23
|
* screenshot an agent took to check its own work photographed that site's 404
|
|
24
24
|
* page. So the path is declarable: per request, per registered site, or via
|
|
25
25
|
* `createOrchestrator({ draftPath })`.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* `/preview-draft` is only the *default*, though, and it was hardcoded — which
|
|
20
20
|
* meant this route worked on sites `create-ai-site-editor` scaffolded and on no
|
|
21
21
|
* others. An existing site that wires Avocado into its own app has its own
|
|
22
|
-
* draft route (
|
|
22
|
+
* draft route (one such site's is `/avocado/<lang>/<slug>`), and the
|
|
23
23
|
* screenshot an agent took to check its own work photographed that site's 404
|
|
24
24
|
* page. So the path is declarable: per request, per registered site, or via
|
|
25
25
|
* `createOrchestrator({ draftPath })`.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
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, ResolvedCapabilities } from "./cms/adapter.ts";
|
|
4
|
-
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, type JsonFileAdapterOptions, type EditorApiAdapterOptions } from "./cms/index.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";
|
package/dist/index.js
CHANGED
|
@@ -18,4 +18,4 @@
|
|
|
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 } from "./cms/index.js";
|
|
21
|
+
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "./cms/index.js";
|
package/dist/ops/ops-engine.d.ts
CHANGED
|
@@ -4,6 +4,23 @@ import type { ContentSource } from "../state/content-source.ts";
|
|
|
4
4
|
import type { PublishDiff } from "@avocadostudio-ai/shared";
|
|
5
5
|
export declare const toErrorDetail: typeof _unifiedToErrorDetail;
|
|
6
6
|
export declare function isNoEffectiveChangeError(reason: string): boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Within a no-effective-change, did the stored values already match?
|
|
9
|
+
*
|
|
10
|
+
* The engine throws two different things and they are not the same fact:
|
|
11
|
+
*
|
|
12
|
+
* "No effective prop change across plan. All update patches matched
|
|
13
|
+
* existing values." → the content really was already what was asked for
|
|
14
|
+
* "Edit plan produced no changes"
|
|
15
|
+
* → the plan had nothing left to apply, which is what
|
|
16
|
+
* happens when every prop in it was stripped as
|
|
17
|
+
* unsupported by the block
|
|
18
|
+
*
|
|
19
|
+
* Reporting the second as the first tells the author their content is up to
|
|
20
|
+
* date when their request was in fact dropped. Both messages are pinned by
|
|
21
|
+
* `isNoEffectiveChangeError` above, so they are already load-bearing strings.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isAlreadyCurrentError(reason: string): boolean;
|
|
7
24
|
export declare function classifyGuardrailError(reason: string): GuardrailErrorCategory;
|
|
8
25
|
export declare function formatValidationError(reason: string): string;
|
|
9
26
|
export declare function isDeterministicRepairEligible(reason: string): boolean;
|
package/dist/ops/ops-engine.js
CHANGED
|
@@ -159,6 +159,25 @@ export function isNoEffectiveChangeError(reason) {
|
|
|
159
159
|
// "No effective meta change for…" / "Edit plan produced no changes"
|
|
160
160
|
return /no effective \w+ change/i.test(reason) || /produced no changes/i.test(reason);
|
|
161
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Within a no-effective-change, did the stored values already match?
|
|
164
|
+
*
|
|
165
|
+
* The engine throws two different things and they are not the same fact:
|
|
166
|
+
*
|
|
167
|
+
* "No effective prop change across plan. All update patches matched
|
|
168
|
+
* existing values." → the content really was already what was asked for
|
|
169
|
+
* "Edit plan produced no changes"
|
|
170
|
+
* → the plan had nothing left to apply, which is what
|
|
171
|
+
* happens when every prop in it was stripped as
|
|
172
|
+
* unsupported by the block
|
|
173
|
+
*
|
|
174
|
+
* Reporting the second as the first tells the author their content is up to
|
|
175
|
+
* date when their request was in fact dropped. Both messages are pinned by
|
|
176
|
+
* `isNoEffectiveChangeError` above, so they are already load-bearing strings.
|
|
177
|
+
*/
|
|
178
|
+
export function isAlreadyCurrentError(reason) {
|
|
179
|
+
return /no effective \w+ change/i.test(reason);
|
|
180
|
+
}
|
|
162
181
|
export function classifyGuardrailError(reason) {
|
|
163
182
|
const lower = reason.toLowerCase();
|
|
164
183
|
if (isNoEffectiveChangeError(reason))
|
|
@@ -404,7 +423,7 @@ function _resolveBlockIndex(blocks, blockId, fuzzyMatches) {
|
|
|
404
423
|
* Drop an adapter's provenance keys from a block that is being copied.
|
|
405
424
|
*
|
|
406
425
|
* A projected block carries the adapter's answer to "which upstream thing is
|
|
407
|
-
* this", and the adapter routes the write by it:
|
|
426
|
+
* this", and the adapter routes the write by it: a field-level-i18n CMS marks a
|
|
408
427
|
* block with `props._sectionId` and publishes anything carrying one into the
|
|
409
428
|
* *shared section document* rather than into the page. Copied verbatim onto a
|
|
410
429
|
* duplicate, the first edit to that duplicate's hero is written back into the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/orchestrator-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"openai": "^4.87.1",
|
|
24
24
|
"sharp": "^0.34.5",
|
|
25
25
|
"zod": "^4.3.6",
|
|
26
|
-
"@avocadostudio-ai/migration-sdk": "0.
|
|
27
|
-
"@avocadostudio-ai/shared": "0.
|
|
26
|
+
"@avocadostudio-ai/migration-sdk": "^0.3.1",
|
|
27
|
+
"@avocadostudio-ai/shared": "^0.3.1"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@google/genai": "^1.46.0",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"visual-editing"
|
|
57
57
|
],
|
|
58
58
|
"license": "Apache-2.0",
|
|
59
|
-
"homepage": "https://
|
|
59
|
+
"homepage": "https://docs.avocadostudio.dev",
|
|
60
60
|
"bugs": {
|
|
61
61
|
"url": "https://github.com/avocadostudio-ai/avocado/issues"
|
|
62
62
|
},
|