@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/sites-agent-context.js +3 -2
- package/dist/agent/sites-agent-shared.js +1 -0
- package/dist/chat/anthropic-planner.js +3 -3
- package/dist/chat/chat-pipeline.js +122 -20
- package/dist/chat/gemini-planner.js +3 -3
- package/dist/chat/planner.js +7 -5
- package/dist/chat/prompts.js +6 -1
- package/dist/cms/adapter.d.ts +159 -1
- package/dist/cms/adapter.js +19 -1
- package/dist/cms/bootstrap.d.ts +46 -1
- package/dist/cms/bootstrap.js +126 -2
- package/dist/cms/index.d.ts +3 -2
- package/dist/cms/index.js +2 -1
- package/dist/errors.d.ts +9 -1
- package/dist/handler/auth.d.ts +79 -0
- package/dist/handler/auth.js +113 -0
- package/dist/handler/create-orchestrator.d.ts +205 -0
- package/dist/handler/create-orchestrator.js +1599 -0
- package/dist/http/access-tokens.d.ts +58 -0
- package/dist/http/access-tokens.js +161 -0
- package/dist/http/audio-actions.d.ts +121 -0
- package/dist/http/audio-actions.js +248 -0
- package/dist/http/blocks-actions.d.ts +31 -0
- package/dist/http/blocks-actions.js +31 -0
- package/dist/http/draft-provenance.d.ts +68 -0
- package/dist/http/draft-provenance.js +101 -0
- package/dist/http/history-actions.d.ts +58 -0
- package/dist/http/history-actions.js +169 -0
- package/dist/http/image-generate-actions.d.ts +268 -0
- package/dist/http/image-generate-actions.js +546 -0
- package/dist/http/ops-actions.d.ts +51 -0
- package/dist/http/ops-actions.js +79 -0
- package/dist/http/publish-actions.d.ts +153 -0
- package/dist/http/publish-actions.js +323 -0
- package/dist/http/restore-actions.d.ts +67 -0
- package/dist/http/restore-actions.js +145 -0
- package/dist/http/screenshot-actions.d.ts +108 -0
- package/dist/http/screenshot-actions.js +181 -0
- package/dist/http/session-actions.d.ts +35 -0
- package/dist/http/session-actions.js +98 -0
- package/dist/http/telemetry-feedback-actions.d.ts +53 -0
- package/dist/http/telemetry-feedback-actions.js +68 -0
- package/dist/http/unsplash-actions.d.ts +64 -0
- package/dist/http/unsplash-actions.js +81 -0
- package/dist/http/variations-actions.d.ts +102 -0
- package/dist/http/variations-actions.js +104 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +21 -1
- package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
- package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
- package/dist/nlp/deterministic-planner-suggestions.js +37 -11
- package/dist/nlp/plan-normalizer.js +18 -2
- package/dist/ops/ops-engine.js +219 -14
- package/dist/state/session-state.d.ts +56 -1
- package/dist/state/session-state.js +92 -6
- package/dist/state/sqlite-store-singleton.d.ts +22 -0
- package/dist/state/sqlite-store-singleton.js +49 -1
- package/dist/state/sqlite-store.d.ts +5 -0
- package/dist/state/sqlite-store.js +125 -2
- package/dist/telemetry/chat-telemetry.js +6 -1
- package/package.json +12 -16
package/dist/ops/ops-engine.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { blockSchemas, operationSchema, validateBlockProps,
|
|
2
|
+
import { blockSchemas, operationSchema, validateBlockProps, findManifestSchemaIssue, isChrome, isInBlockCatalogue, catalogueBlockTypes, mapSemanticThemeTokens, generateItemId, isRichTextDoc, fromMarkdown, mergeRichTextDoc } from "@avocadostudio-ai/shared";
|
|
3
3
|
import { normalizeRouteCandidate } from "../nlp/intent-helpers.js";
|
|
4
4
|
import { pageIdFromSlug, pageTitleFromSlug } from "../nlp/plan-normalizer.js";
|
|
5
5
|
import { OperationError, toErrorDetail as _unifiedToErrorDetail } from "../errors.js";
|
|
6
6
|
import { isDemoModeEnabled, enforceDemoOps } from "../demo-mode.js";
|
|
7
7
|
import { computePublishDiff } from "../publish/diff-engine.js";
|
|
8
8
|
import { acquireSessionLock } from "../state/session-lock.js";
|
|
9
|
-
import { getSessionDraft, orderSlugsHomeFirst, setPage, getPage, getSiteConfig, setSiteConfig } from "../state/session-state.js";
|
|
9
|
+
import { getSessionDraft, orderSlugsHomeFirst, setPage, getPage, getSiteConfig, setSiteConfig, getSessionCapabilities } from "../state/session-state.js";
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
// Passthrough Zod schema cache — avoids allocating a new ZodObject per call
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
@@ -29,6 +29,34 @@ function _sanitizeListItemImageAlt(item) {
|
|
|
29
29
|
}
|
|
30
30
|
return obj;
|
|
31
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Bring a patch value up to the shape the prop is actually stored in.
|
|
34
|
+
*
|
|
35
|
+
* A rich-text prop can hold either a markdown string or a ProseMirror document
|
|
36
|
+
* — an integration whose CMS has real rich text (Portable Text, Contentful) maps
|
|
37
|
+
* it to the document form so nothing is flattened. The planner does not know
|
|
38
|
+
* which: it was asked to rewrite some prose, and prose to a planner is a string.
|
|
39
|
+
* Writing that string straight into a document-valued prop replaces the document
|
|
40
|
+
* with a `string`, which the renderer cannot draw and the CMS adapter cannot
|
|
41
|
+
* publish.
|
|
42
|
+
*
|
|
43
|
+
* So a string landing on a document-valued prop is parsed into a document and
|
|
44
|
+
* merged with what was there: stored `_key`s carry over, so the CMS sees an
|
|
45
|
+
* update rather than a wholesale replacement, and any block markdown cannot
|
|
46
|
+
* express — an embedded entry, a product card — is put back rather than deleted
|
|
47
|
+
* by a text edit that never mentioned it.
|
|
48
|
+
*
|
|
49
|
+
* Returns `undefined` when nothing needs coercing, including when the patch is
|
|
50
|
+
* itself a document: that came from the editor, which owns the whole value, and
|
|
51
|
+
* restoring blocks the user deliberately deleted would fight them.
|
|
52
|
+
*/
|
|
53
|
+
function _coerceRichTextValue(oldVal, newVal) {
|
|
54
|
+
if (!isRichTextDoc(oldVal))
|
|
55
|
+
return undefined;
|
|
56
|
+
if (typeof newVal !== "string")
|
|
57
|
+
return undefined;
|
|
58
|
+
return mergeRichTextDoc(fromMarkdown(newVal), oldVal);
|
|
59
|
+
}
|
|
32
60
|
function _getPassthroughSchema(blockType) {
|
|
33
61
|
const cached = _passthroughSchemaCache.get(blockType);
|
|
34
62
|
if (cached)
|
|
@@ -243,6 +271,58 @@ export function buildDeterministicRepairFeedback(reason) {
|
|
|
243
271
|
.filter((line) => line.length > 0)
|
|
244
272
|
.join(" ");
|
|
245
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Refuse operations the site behind this session has said it cannot honour.
|
|
276
|
+
*
|
|
277
|
+
* Checked once for the whole batch, before anything is staged, because ops are
|
|
278
|
+
* atomic: a batch containing one refused op must not half-apply, and telling
|
|
279
|
+
* the caller after four of five ops have been written is telling them too
|
|
280
|
+
* late.
|
|
281
|
+
*
|
|
282
|
+
* This is the half of the capability gate that matters. Omitting a tool from
|
|
283
|
+
* the MCP list makes one client honest and leaves /chat, /ops, the editor and
|
|
284
|
+
* Puck producing the same unpublishable pages — they never pass through the
|
|
285
|
+
* MCP server. Every write path reaches here.
|
|
286
|
+
*
|
|
287
|
+
* Silence is permission, so this is inert for every integration that has not
|
|
288
|
+
* declared a capability: nothing changes until an adapter says `false`.
|
|
289
|
+
*/
|
|
290
|
+
function _assertOperationsPermitted(session, ops) {
|
|
291
|
+
const caps = getSessionCapabilities(session);
|
|
292
|
+
if (caps.createPages && caps.deletePages && caps.structuralEdits)
|
|
293
|
+
return;
|
|
294
|
+
const refuse = (opName, why) => {
|
|
295
|
+
throw new OperationError(`This site cannot ${why} (operation "${opName}"). Its content adapter has declared the ` +
|
|
296
|
+
`capability unavailable, so the change would apply to the draft and then fail to publish.`, { category: "unsupported_by_site" });
|
|
297
|
+
};
|
|
298
|
+
for (const op of ops) {
|
|
299
|
+
if (!caps.createPages && (op.op === "create_page" || op.op === "duplicate_page")) {
|
|
300
|
+
// duplicate_page mints an identity just as surely as create_page does;
|
|
301
|
+
// the CMS has no document behind the new slug either way.
|
|
302
|
+
refuse(op.op, "create pages");
|
|
303
|
+
}
|
|
304
|
+
if (!caps.deletePages && op.op === "remove_page")
|
|
305
|
+
refuse(op.op, "delete pages");
|
|
306
|
+
if (!caps.structuralEdits && _changesBlockStructure(op)) {
|
|
307
|
+
refuse(op.op, "change which blocks a page has");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Narrower than `isStructuralOperation` on purpose: that predicate also counts
|
|
313
|
+
* `add_item`/`remove_item`/`move_item`, and adding a row to a list is an
|
|
314
|
+
* ordinary array write inside one block's props. Any adapter that projects the
|
|
315
|
+
* CMS's own keys can publish it. What a `structuralEdits: false` site cannot
|
|
316
|
+
* take is a change to the *set and order of blocks* — the case that motivates
|
|
317
|
+
* the flag is language variants sharing one block structure, where every text
|
|
318
|
+
* edit is fine and no insertion is.
|
|
319
|
+
*/
|
|
320
|
+
function _changesBlockStructure(op) {
|
|
321
|
+
return (op.op === "add_block" ||
|
|
322
|
+
op.op === "remove_block" ||
|
|
323
|
+
op.op === "move_block" ||
|
|
324
|
+
op.op === "duplicate_block");
|
|
325
|
+
}
|
|
246
326
|
export function isStructuralOperation(op) {
|
|
247
327
|
return (op.op === "add_block" ||
|
|
248
328
|
op.op === "remove_block" ||
|
|
@@ -320,6 +400,93 @@ function _resolveBlockIndex(blocks, blockId, fuzzyMatches) {
|
|
|
320
400
|
}
|
|
321
401
|
return -1;
|
|
322
402
|
}
|
|
403
|
+
/**
|
|
404
|
+
* Drop an adapter's provenance keys from a block that is being copied.
|
|
405
|
+
*
|
|
406
|
+
* A projected block carries the adapter's answer to "which upstream thing is
|
|
407
|
+
* this", and the adapter routes the write by it: Paintball Arena Bern marks a
|
|
408
|
+
* block with `props._sectionId` and publishes anything carrying one into the
|
|
409
|
+
* *shared section document* rather than into the page. Copied verbatim onto a
|
|
410
|
+
* duplicate, the first edit to that duplicate's hero is written back into the
|
|
411
|
+
* section every other page renders. A copy has no upstream document yet, and
|
|
412
|
+
* saying nothing is the only honest answer available.
|
|
413
|
+
*
|
|
414
|
+
* Shallow on purpose, and the nesting is not an oversight. A rich-text prop
|
|
415
|
+
* holds a document whose nodes carry the stored `attrs._key`s that let a CMS
|
|
416
|
+
* patch a paragraph instead of replacing the field, plus the raw `_type`/`_key`
|
|
417
|
+
* blocks markdown cannot express, kept so a text edit does not delete an
|
|
418
|
+
* embedded entry. Those are content, and a recursive strip would delete the
|
|
419
|
+
* identity that makes a CMS patch rather than rewrite.
|
|
420
|
+
*
|
|
421
|
+
* But shallow is a limit, not a guarantee: an adapter may mark list rows too —
|
|
422
|
+
* PBA tags an individual button and card with its source key — and those
|
|
423
|
+
* survive a duplicate. The key that routes a write to another document
|
|
424
|
+
* (`_sectionId`) is top-level, so the damage this was written to stop is
|
|
425
|
+
* stopped. The rest waits for an adapter that can say which of its own keys
|
|
426
|
+
* are identity, rather than us guessing by depth.
|
|
427
|
+
*
|
|
428
|
+
* Deliberately the opposite of `update_props`, which keeps undeclared `_` props
|
|
429
|
+
* because site-owned bookkeeping has to survive an edit. An edit is the same
|
|
430
|
+
* upstream thing; a copy is a different one.
|
|
431
|
+
*/
|
|
432
|
+
function _stripProvenanceProps(props) {
|
|
433
|
+
const provenanceKeys = Object.keys(props).filter((key) => key.startsWith("_"));
|
|
434
|
+
if (provenanceKeys.length === 0)
|
|
435
|
+
return props;
|
|
436
|
+
const next = { ...props };
|
|
437
|
+
for (const key of provenanceKeys)
|
|
438
|
+
delete next[key];
|
|
439
|
+
return next;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Canonical form of a `meta.path` for collision comparison. `/events/` and
|
|
443
|
+
* `/events` are one URL, so a page must not be able to claim a path another
|
|
444
|
+
* page already serves by differing in a trailing slash.
|
|
445
|
+
*/
|
|
446
|
+
function _canonicalPagePath(path) {
|
|
447
|
+
const trimmed = path.trim();
|
|
448
|
+
if (trimmed.length <= 1)
|
|
449
|
+
return trimmed;
|
|
450
|
+
return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Refuse a `meta.path` another page already serves.
|
|
454
|
+
*
|
|
455
|
+
* `path` is where the site actually answers for a page, and until now only the
|
|
456
|
+
* slug — the page's identity in the draft — was checked for uniqueness. Two
|
|
457
|
+
* pages could name one live URL and the first thing to notice was the browser.
|
|
458
|
+
*
|
|
459
|
+
* An empty or absent path collides with nothing: it means "the slug is the
|
|
460
|
+
* path", which is true for every page on a site whose routing Avocado owns.
|
|
461
|
+
*/
|
|
462
|
+
/**
|
|
463
|
+
* No two pages may serve one URL, checked across the finished plan.
|
|
464
|
+
*
|
|
465
|
+
* Two things make this an invariant of the *result* rather than a guard on
|
|
466
|
+
* `create_page`. A page that declares no `path` still serves one — its slug —
|
|
467
|
+
* so comparing declared paths to declared paths misses a page whose declared
|
|
468
|
+
* path is another page's slug, which is precisely the locale case `path`
|
|
469
|
+
* exists for. And a plan is atomic: checking as each op lands refuses
|
|
470
|
+
* `[create_page(path X), remove_page(the page holding X)]`, where the finished
|
|
471
|
+
* state is perfectly consistent and only the order was inconvenient.
|
|
472
|
+
*
|
|
473
|
+
* So it runs once, over what the plan actually produced.
|
|
474
|
+
*/
|
|
475
|
+
function _assertNoPagePathCollisions(finalPages) {
|
|
476
|
+
const byPath = new Map();
|
|
477
|
+
for (const [slug, page] of finalPages) {
|
|
478
|
+
// Absent means "the slug is the path" — the page still occupies a URL.
|
|
479
|
+
const effective = _canonicalPagePath(page.meta?.path ?? slug);
|
|
480
|
+
if (effective.length === 0)
|
|
481
|
+
continue;
|
|
482
|
+
const holder = byPath.get(effective);
|
|
483
|
+
if (holder !== undefined) {
|
|
484
|
+
throw new OperationError(`Two pages would serve ${JSON.stringify(effective)}: ${holder} and ${slug}. ` +
|
|
485
|
+
`A page's URL is its meta.path, or its slug when it declares none.`, { category: "schema_violation" });
|
|
486
|
+
}
|
|
487
|
+
byPath.set(effective, slug);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
323
490
|
function _nextDuplicateSlug(candidateMap, sourceSlug) {
|
|
324
491
|
const base = sourceSlug === "/" ? "/home-copy" : `${sourceSlug.replace(/\/+$/, "")}-copy`;
|
|
325
492
|
if (!candidateMap.has(base))
|
|
@@ -444,8 +611,13 @@ function _validateWithManifestIfPresent(manifestByType, blockType, nextProps) {
|
|
|
444
611
|
if (result.success)
|
|
445
612
|
coerced = result.data;
|
|
446
613
|
}
|
|
447
|
-
|
|
448
|
-
|
|
614
|
+
const issue = findManifestSchemaIssue(manifestComponent.propsSchema, coerced);
|
|
615
|
+
if (issue) {
|
|
616
|
+
// Name the prop and the mismatch. "does not match block manifest schema"
|
|
617
|
+
// on a block with a dozen props tells the person reading it nothing, and
|
|
618
|
+
// it is the message custom blocks get — the ones whose shape the reader
|
|
619
|
+
// is least likely to have memorised.
|
|
620
|
+
throw new OperationError(`Invalid props for ${blockType}: ${issue.path}: ${issue.message}`, { category: "schema_violation" });
|
|
449
621
|
}
|
|
450
622
|
return coerced;
|
|
451
623
|
}
|
|
@@ -531,6 +703,7 @@ export async function applyOpsAtomically(session, ops, options) {
|
|
|
531
703
|
}
|
|
532
704
|
}
|
|
533
705
|
async function _applyOpsAtomicallyUnsafe(session, ops, options) {
|
|
706
|
+
_assertOperationsPermitted(session, ops);
|
|
534
707
|
const manifestByType = new Map();
|
|
535
708
|
if (options?.componentsManifest) {
|
|
536
709
|
for (const component of options.componentsManifest.blocks) {
|
|
@@ -676,8 +849,18 @@ async function _applyOpsAtomicallyUnsafe(session, ops, options) {
|
|
|
676
849
|
const nextBlocks = source.blocks.map((block) => {
|
|
677
850
|
const newId = _nextUniqueBlockId(source.blocks, `${block.id}_copy`);
|
|
678
851
|
blockIdMap[block.id] = newId;
|
|
679
|
-
return { ...block, id: newId };
|
|
852
|
+
return { ...block, id: newId, props: _stripProvenanceProps(block.props) };
|
|
680
853
|
});
|
|
854
|
+
// A copy's URL is nobody's to assign yet. `meta.path` is the adapter's
|
|
855
|
+
// report of where it serves the *source*; carried over, two draft pages
|
|
856
|
+
// answer for one live URL and nothing notices until a browser does.
|
|
857
|
+
// Absent means "the slug is the path", the honest answer for a page whose
|
|
858
|
+
// URL no one has assigned.
|
|
859
|
+
const { path: _sourcePath, ...copiedMeta } = source.meta ?? {};
|
|
860
|
+
// When caller passes newTitle, keep meta.title in sync so SEO doesn't show
|
|
861
|
+
// the source page's English title on a translated copy. Other meta fields
|
|
862
|
+
// (description, ogImage) stay — caller can patch them with update_page_meta.
|
|
863
|
+
const nextMeta = explicitNewTitle ? { ...copiedMeta, title: explicitNewTitle } : copiedMeta;
|
|
681
864
|
// source was already deep-cloned at entry into `staged`; spread is sufficient
|
|
682
865
|
// since all later mutations replace blocks/props wholesale rather than mutating in-place.
|
|
683
866
|
const copy = {
|
|
@@ -687,14 +870,9 @@ async function _applyOpsAtomicallyUnsafe(session, ops, options) {
|
|
|
687
870
|
title: explicitNewTitle ?? `${source.title} Copy`,
|
|
688
871
|
updatedAt: new Date().toISOString(),
|
|
689
872
|
blocks: nextBlocks,
|
|
690
|
-
//
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
meta: explicitNewTitle && source.meta
|
|
694
|
-
? { ...source.meta, title: explicitNewTitle }
|
|
695
|
-
: explicitNewTitle
|
|
696
|
-
? { title: explicitNewTitle }
|
|
697
|
-
: source.meta
|
|
873
|
+
// A source whose only meta was `path` must not leave an empty `meta: {}`
|
|
874
|
+
// behind for the adapter to read as "this page has SEO fields".
|
|
875
|
+
meta: Object.keys(nextMeta).length > 0 ? nextMeta : undefined
|
|
698
876
|
};
|
|
699
877
|
staged.set(nextSlug, copy);
|
|
700
878
|
touchedSlugs.add(nextSlug);
|
|
@@ -885,6 +1063,20 @@ async function _applyOpsAtomicallyUnsafe(session, ops, options) {
|
|
|
885
1063
|
if (op.op === "add_block") {
|
|
886
1064
|
if (isChrome(op.block.type))
|
|
887
1065
|
throw new OperationError(`Cannot add chrome block type "${op.block.type}"`, { category: "schema_violation" });
|
|
1066
|
+
/*
|
|
1067
|
+
* Registered is not the same as renderable. A site that declared its own
|
|
1068
|
+
* catalogue is telling us the built-ins it inherited by importing
|
|
1069
|
+
* `@avocadostudio-ai/shared` are types it has no renderer for — adding one
|
|
1070
|
+
* applies cleanly and draws nothing, which is worse than a refusal.
|
|
1071
|
+
*
|
|
1072
|
+
* Only `add_block` is guarded. A `duplicate_block` copies a type the page
|
|
1073
|
+
* already holds, so the site plainly renders it whatever the declaration
|
|
1074
|
+
* says, and refusing would strand existing content.
|
|
1075
|
+
*/
|
|
1076
|
+
if (!isInBlockCatalogue(op.block.type)) {
|
|
1077
|
+
throw new OperationError(`Cannot add block "${op.block.type}" — this site does not render that type. ` +
|
|
1078
|
+
`Its catalogue is: ${catalogueBlockTypes().join(", ")}`, { category: "not_found" });
|
|
1079
|
+
}
|
|
888
1080
|
_requireManifestComponent(manifestByType, op.block.type, "add block");
|
|
889
1081
|
const validatedProps = _validateWithManifestIfPresent(manifestByType, op.block.type, op.block.props);
|
|
890
1082
|
const alreadyExists = page.blocks.some((b) => b.id === op.block.id);
|
|
@@ -999,7 +1191,14 @@ async function _applyOpsAtomicallyUnsafe(session, ops, options) {
|
|
|
999
1191
|
const nextList = list.map((entry, idx) => {
|
|
1000
1192
|
if (idx !== targetIndex)
|
|
1001
1193
|
return entry;
|
|
1002
|
-
|
|
1194
|
+
const previous = entry;
|
|
1195
|
+
const merged = { ...previous };
|
|
1196
|
+
for (const [key, value] of Object.entries(itemPatch)) {
|
|
1197
|
+
// Same coercion as update_props: a list item can hold a document-valued
|
|
1198
|
+
// rich-text field too (an FAQ answer, a tab body).
|
|
1199
|
+
merged[key] = _coerceRichTextValue(previous[key], value) ?? value;
|
|
1200
|
+
}
|
|
1201
|
+
return merged;
|
|
1003
1202
|
});
|
|
1004
1203
|
const nextProps = { ...block.props, [op.listKey]: nextList };
|
|
1005
1204
|
page.blocks[blockIdx] = { ...block, props: _withValidatedBlockProps(manifestByType, block, nextProps) };
|
|
@@ -1176,6 +1375,11 @@ async function _applyOpsAtomicallyUnsafe(session, ops, options) {
|
|
|
1176
1375
|
if (key === "imageAlt" && typeof newVal === "string" && _looksLikeUserInstruction(newVal)) {
|
|
1177
1376
|
continue;
|
|
1178
1377
|
}
|
|
1378
|
+
const coercedRichText = _coerceRichTextValue(oldVal, newVal);
|
|
1379
|
+
if (coercedRichText !== undefined) {
|
|
1380
|
+
nextProps[key] = coercedRichText;
|
|
1381
|
+
continue;
|
|
1382
|
+
}
|
|
1179
1383
|
// Deep-merge arrays of objects by index so partial items inherit existing fields
|
|
1180
1384
|
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
|
|
1181
1385
|
nextProps[key] = newVal.map((item, i) => {
|
|
@@ -1294,6 +1498,7 @@ async function _applyOpsAtomicallyUnsafe(session, ops, options) {
|
|
|
1294
1498
|
}
|
|
1295
1499
|
}
|
|
1296
1500
|
}
|
|
1501
|
+
_assertNoPagePathCollisions(staged);
|
|
1297
1502
|
// A dry-run never aborts on "nothing changed" — an empty preview is a valid,
|
|
1298
1503
|
// useful answer ("this plan would do nothing"). Only the committing path
|
|
1299
1504
|
// treats a wholly-ineffective plan as an error.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Logger } from "../logger.ts";
|
|
2
2
|
import { type EditPlan, type Operation, type PageDoc, type SiteConfig } from "@avocadostudio-ai/shared";
|
|
3
3
|
import { toErrorDetail as _unifiedToErrorDetail } from "../errors.ts";
|
|
4
|
+
export { persistenceHealth, persistenceWarning, type PersistenceHealth } from "./sqlite-store-singleton.ts";
|
|
4
5
|
import type { PublishLogEntry } from "./sqlite-store.ts";
|
|
5
6
|
export type { PublishLogEntry, PublishLogStatus } from "./sqlite-store.ts";
|
|
6
7
|
export declare const HISTORY_DEPTH_MAX = 50;
|
|
@@ -164,6 +165,29 @@ export declare const pendingApprovalPlanBySession: Map<string, PendingApprovalPl
|
|
|
164
165
|
export type ImageSourcePreference = "unsplash" | "genai" | "either";
|
|
165
166
|
export declare const imageSourcePreferenceBySession: Map<string, ImageSourcePreference>;
|
|
166
167
|
export declare const publishStatusBySession: Map<string, PublishTracker>;
|
|
168
|
+
/**
|
|
169
|
+
* What the adapter behind a session says it can honour, recorded when the
|
|
170
|
+
* session is first touched so the ops engine can refuse an operation the site
|
|
171
|
+
* cannot publish.
|
|
172
|
+
*
|
|
173
|
+
* Session-keyed rather than a module-level singleton, because one process can
|
|
174
|
+
* host several mounts: the standalone server serves many sites, and nothing
|
|
175
|
+
* stops a host from constructing two runtimes. A global "current capabilities"
|
|
176
|
+
* would let one site's declaration decide another site's edits.
|
|
177
|
+
*
|
|
178
|
+
* Ephemeral by design, and deliberately NOT persisted to SQLite: this is a
|
|
179
|
+
* property of the running mount, not of the draft. A restart with a different
|
|
180
|
+
* adapter must not inherit the old one's answer.
|
|
181
|
+
*/
|
|
182
|
+
export declare const capabilitiesBySession: Map<string, SessionCapabilities>;
|
|
183
|
+
export type SessionCapabilities = {
|
|
184
|
+
createPages: boolean;
|
|
185
|
+
deletePages: boolean;
|
|
186
|
+
structuralEdits: boolean;
|
|
187
|
+
};
|
|
188
|
+
export declare function setSessionCapabilities(session: string, caps: SessionCapabilities): void;
|
|
189
|
+
/** Silence means permission — see `resolveCapabilities` for why the default is not restrictive. */
|
|
190
|
+
export declare function getSessionCapabilities(session: string): SessionCapabilities;
|
|
167
191
|
export declare const siteConfigs: Map<string, {
|
|
168
192
|
name?: string | undefined;
|
|
169
193
|
logo?: string | undefined;
|
|
@@ -191,8 +215,39 @@ export type SessionSummary = {
|
|
|
191
215
|
lastMutatedAt: string | null;
|
|
192
216
|
};
|
|
193
217
|
export declare function getSessionSummary(sessionKey: string): SessionSummary;
|
|
218
|
+
/**
|
|
219
|
+
* Erase every trace of one session.
|
|
220
|
+
*
|
|
221
|
+
* Sessions are created by being named: a GET with a `siteId` nobody has ever
|
|
222
|
+
* used seeds a draft from the demo pages and the session exists from then on,
|
|
223
|
+
* indistinguishable in `/sessions` from one holding real work. That is fine
|
|
224
|
+
* for the demo it was built for and actively misleading everywhere else — an
|
|
225
|
+
* agent asked to find a site's pages sees "8 draft pages" under the right
|
|
226
|
+
* siteId and reasonably believes it has found them.
|
|
227
|
+
*
|
|
228
|
+
* Sites already had `DELETE /sites` for exactly this; sessions had nothing, so
|
|
229
|
+
* a session created by a typo could only be removed by stopping the process
|
|
230
|
+
* and editing SQLite. Every map keyed by the session key is listed here rather
|
|
231
|
+
* than a subset, because a half-forgotten session is worse than a kept one:
|
|
232
|
+
* the summary disappears while its undo stack and chat history stay behind.
|
|
233
|
+
*/
|
|
234
|
+
export declare function forgetSession(sessionKey: string): boolean;
|
|
194
235
|
export declare function listSessionSummaries(): SessionSummary[];
|
|
195
|
-
/**
|
|
236
|
+
/**
|
|
237
|
+
* Size of the in-memory published Map — which is the bundled demo content, and
|
|
238
|
+
* only that.
|
|
239
|
+
*
|
|
240
|
+
* The old comment here read "total published pages across all sites", and every
|
|
241
|
+
* caller believed it. `publishedPages` is seeded from `demoPublishedPages()` at
|
|
242
|
+
* import time and no publish path writes it: `POST /publish` goes to the site
|
|
243
|
+
* origin or a CmsAdapter, and `loadPublishedForDiff` treats this Map as its
|
|
244
|
+
* last-resort fallback precisely because it is stale. So the number is the
|
|
245
|
+
* constant 8 on every deployment that has not migrated a legacy JSON state file.
|
|
246
|
+
*
|
|
247
|
+
* Kept, because on the demo site it is the truth — but callers must say which
|
|
248
|
+
* site they are answering for before using it. `whoamiAction` is the worked
|
|
249
|
+
* example.
|
|
250
|
+
*/
|
|
196
251
|
export declare function publishedPageCountGlobal(): number;
|
|
197
252
|
export declare function markRecentlyRestored(session: string): void;
|
|
198
253
|
export declare function consumeRecentlyRestored(session: string): boolean;
|
|
@@ -2,8 +2,11 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, resolve } from "node:path";
|
|
3
3
|
import { demoPublishedPages, demoSiteConfig, ensureItemIds, IMAGE_PLACEHOLDER } from "@avocadostudio-ai/shared";
|
|
4
4
|
import { toErrorDetail as _unifiedToErrorDetail } from "../errors.js";
|
|
5
|
-
import { archiveMigratedJson, getStore, readLegacyJson, resolveJsonMigrationTtlDays, sweepStaleMigrations, } from "./sqlite-store-singleton.js";
|
|
6
|
-
|
|
5
|
+
import { archiveMigratedJson, getStore, notePersistenceFailure, notePersistenceSuccess, readLegacyJson, resolveDbFile, resolveJsonMigrationTtlDays, sweepStaleMigrations, } from "./sqlite-store-singleton.js";
|
|
6
|
+
// Re-exported so callers that already depend on this module for state can ask
|
|
7
|
+
// whether that state is actually being kept, without a second import path.
|
|
8
|
+
export { persistenceHealth, persistenceWarning } from "./sqlite-store-singleton.js";
|
|
9
|
+
import { CHAT_HISTORY_CAP, HISTORY_DEPTH_CAP, RECENT_EDITS_CAP, VERSION_LOG_CAP, PUBLISH_LOG_CAP, preloadSqliteDriver, } from "./sqlite-store.js";
|
|
7
10
|
// Single source of truth for these caps is `sqlite-store.ts`. Re-exported
|
|
8
11
|
// under the legacy names so existing imports keep working.
|
|
9
12
|
export const HISTORY_DEPTH_MAX = HISTORY_DEPTH_CAP;
|
|
@@ -96,6 +99,28 @@ export const chatHistoryBySession = new Map();
|
|
|
96
99
|
export const pendingApprovalPlanBySession = new Map();
|
|
97
100
|
export const imageSourcePreferenceBySession = new Map();
|
|
98
101
|
export const publishStatusBySession = new Map();
|
|
102
|
+
/**
|
|
103
|
+
* What the adapter behind a session says it can honour, recorded when the
|
|
104
|
+
* session is first touched so the ops engine can refuse an operation the site
|
|
105
|
+
* cannot publish.
|
|
106
|
+
*
|
|
107
|
+
* Session-keyed rather than a module-level singleton, because one process can
|
|
108
|
+
* host several mounts: the standalone server serves many sites, and nothing
|
|
109
|
+
* stops a host from constructing two runtimes. A global "current capabilities"
|
|
110
|
+
* would let one site's declaration decide another site's edits.
|
|
111
|
+
*
|
|
112
|
+
* Ephemeral by design, and deliberately NOT persisted to SQLite: this is a
|
|
113
|
+
* property of the running mount, not of the draft. A restart with a different
|
|
114
|
+
* adapter must not inherit the old one's answer.
|
|
115
|
+
*/
|
|
116
|
+
export const capabilitiesBySession = new Map();
|
|
117
|
+
export function setSessionCapabilities(session, caps) {
|
|
118
|
+
capabilitiesBySession.set(session, caps);
|
|
119
|
+
}
|
|
120
|
+
/** Silence means permission — see `resolveCapabilities` for why the default is not restrictive. */
|
|
121
|
+
export function getSessionCapabilities(session) {
|
|
122
|
+
return capabilitiesBySession.get(session) ?? { createPages: true, deletePages: true, structuralEdits: true };
|
|
123
|
+
}
|
|
99
124
|
export const siteConfigs = new Map();
|
|
100
125
|
// No hardcoded seed: drafts now hydrate from the live published JSON via
|
|
101
126
|
// `hydrateSiteConfigFromPublished` on first session touch. A hardcoded seed
|
|
@@ -119,6 +144,40 @@ export function getSessionSummary(sessionKey) {
|
|
|
119
144
|
const lastMutatedAt = log && log.length > 0 ? log[log.length - 1].at : null;
|
|
120
145
|
return { sessionKey, session, siteId, version, draftPageCount, lastMutatedAt };
|
|
121
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Erase every trace of one session.
|
|
149
|
+
*
|
|
150
|
+
* Sessions are created by being named: a GET with a `siteId` nobody has ever
|
|
151
|
+
* used seeds a draft from the demo pages and the session exists from then on,
|
|
152
|
+
* indistinguishable in `/sessions` from one holding real work. That is fine
|
|
153
|
+
* for the demo it was built for and actively misleading everywhere else — an
|
|
154
|
+
* agent asked to find a site's pages sees "8 draft pages" under the right
|
|
155
|
+
* siteId and reasonably believes it has found them.
|
|
156
|
+
*
|
|
157
|
+
* Sites already had `DELETE /sites` for exactly this; sessions had nothing, so
|
|
158
|
+
* a session created by a typo could only be removed by stopping the process
|
|
159
|
+
* and editing SQLite. Every map keyed by the session key is listed here rather
|
|
160
|
+
* than a subset, because a half-forgotten session is worse than a kept one:
|
|
161
|
+
* the summary disappears while its undo stack and chat history stay behind.
|
|
162
|
+
*/
|
|
163
|
+
export function forgetSession(sessionKey) {
|
|
164
|
+
const existed = draftPages.has(sessionKey) || versions.has(sessionKey) || versionLog.has(sessionKey);
|
|
165
|
+
draftPages.delete(sessionKey);
|
|
166
|
+
historyUndo.delete(sessionKey);
|
|
167
|
+
historyRedo.delete(sessionKey);
|
|
168
|
+
versions.delete(sessionKey);
|
|
169
|
+
recentEdits.delete(sessionKey);
|
|
170
|
+
versionLog.delete(sessionKey);
|
|
171
|
+
publishLogBySession.delete(sessionKey);
|
|
172
|
+
continuationChainBySession.delete(sessionKey);
|
|
173
|
+
pendingClarificationBySession.delete(sessionKey);
|
|
174
|
+
chatHistoryBySession.delete(sessionKey);
|
|
175
|
+
pendingApprovalPlanBySession.delete(sessionKey);
|
|
176
|
+
imageSourcePreferenceBySession.delete(sessionKey);
|
|
177
|
+
publishStatusBySession.delete(sessionKey);
|
|
178
|
+
capabilitiesBySession.delete(sessionKey);
|
|
179
|
+
return existed;
|
|
180
|
+
}
|
|
122
181
|
export function listSessionSummaries() {
|
|
123
182
|
// Union of every key seen across draft / version / version-log maps. A
|
|
124
183
|
// session may appear in `versions` (bumped) without `draftPages` (e.g.
|
|
@@ -143,7 +202,21 @@ export function listSessionSummaries() {
|
|
|
143
202
|
});
|
|
144
203
|
return summaries;
|
|
145
204
|
}
|
|
146
|
-
/**
|
|
205
|
+
/**
|
|
206
|
+
* Size of the in-memory published Map — which is the bundled demo content, and
|
|
207
|
+
* only that.
|
|
208
|
+
*
|
|
209
|
+
* The old comment here read "total published pages across all sites", and every
|
|
210
|
+
* caller believed it. `publishedPages` is seeded from `demoPublishedPages()` at
|
|
211
|
+
* import time and no publish path writes it: `POST /publish` goes to the site
|
|
212
|
+
* origin or a CmsAdapter, and `loadPublishedForDiff` treats this Map as its
|
|
213
|
+
* last-resort fallback precisely because it is stale. So the number is the
|
|
214
|
+
* constant 8 on every deployment that has not migrated a legacy JSON state file.
|
|
215
|
+
*
|
|
216
|
+
* Kept, because on the demo site it is the truth — but callers must say which
|
|
217
|
+
* site they are answering for before using it. `whoamiAction` is the worked
|
|
218
|
+
* example.
|
|
219
|
+
*/
|
|
147
220
|
export function publishedPageCountGlobal() {
|
|
148
221
|
return publishedPages.size;
|
|
149
222
|
}
|
|
@@ -966,9 +1039,12 @@ export async function persistStateNow(logger) {
|
|
|
966
1039
|
}
|
|
967
1040
|
try {
|
|
968
1041
|
writeAllStateToStore(getStore());
|
|
1042
|
+
notePersistenceSuccess();
|
|
969
1043
|
}
|
|
970
1044
|
catch (error) {
|
|
971
|
-
|
|
1045
|
+
const reason = toErrorDetail(error);
|
|
1046
|
+
notePersistenceFailure(reason);
|
|
1047
|
+
logger.error({ err: reason }, "Failed to persist orchestrator state to SQLite");
|
|
972
1048
|
throw error;
|
|
973
1049
|
}
|
|
974
1050
|
}
|
|
@@ -991,9 +1067,12 @@ export function schedulePersistState(logger) {
|
|
|
991
1067
|
persistTimer = null;
|
|
992
1068
|
try {
|
|
993
1069
|
writeAllStateToStore(getStore());
|
|
1070
|
+
notePersistenceSuccess();
|
|
994
1071
|
}
|
|
995
1072
|
catch (error) {
|
|
996
|
-
|
|
1073
|
+
const reason = toErrorDetail(error);
|
|
1074
|
+
notePersistenceFailure(reason);
|
|
1075
|
+
logger.error({ err: reason }, "Failed to persist orchestrator state to SQLite");
|
|
997
1076
|
}
|
|
998
1077
|
}, PERSIST_DEBOUNCE_MS);
|
|
999
1078
|
// Don't let the debounce timer keep the process alive on its own.
|
|
@@ -1041,10 +1120,17 @@ export function evictStaleEphemeralMaps() {
|
|
|
1041
1120
|
export async function loadStateFromDisk(logger) {
|
|
1042
1121
|
let store;
|
|
1043
1122
|
try {
|
|
1123
|
+
// Resolve the native driver before the first synchronous `getStore()`.
|
|
1124
|
+
// A bundled host can only reach it through an async import; see
|
|
1125
|
+
// `preloadSqliteDriver`.
|
|
1126
|
+
await preloadSqliteDriver();
|
|
1044
1127
|
store = getStore();
|
|
1045
1128
|
}
|
|
1046
1129
|
catch (error) {
|
|
1047
|
-
|
|
1130
|
+
const reason = toErrorDetail(error);
|
|
1131
|
+
notePersistenceFailure(reason);
|
|
1132
|
+
logger.error({ err: reason, dbFile: resolveDbFile() }, "Failed to open orchestrator SQLite store — drafts will live in memory only " +
|
|
1133
|
+
"and are lost on restart");
|
|
1048
1134
|
return;
|
|
1049
1135
|
}
|
|
1050
1136
|
const jsonPath = resolveStateFilePath();
|
|
@@ -4,6 +4,28 @@ export declare function resolveDbFile(): string;
|
|
|
4
4
|
export declare function resolveJsonMigrationTtlDays(): number;
|
|
5
5
|
export declare function resolveBackupLimit(): number;
|
|
6
6
|
export declare function resolveBackupIntervalHours(): number;
|
|
7
|
+
export type PersistenceHealth = {
|
|
8
|
+
ok: boolean;
|
|
9
|
+
reason: string | null;
|
|
10
|
+
};
|
|
11
|
+
/** Record a persistence failure. The first reason wins; later ones are noise. */
|
|
12
|
+
export declare function notePersistenceFailure(reason: string): void;
|
|
13
|
+
/** Clear the recorded failure — a successful write means we are healthy again. */
|
|
14
|
+
export declare function notePersistenceSuccess(): void;
|
|
15
|
+
export declare function persistenceHealth(): PersistenceHealth;
|
|
16
|
+
/**
|
|
17
|
+
* Spread into a successful mutation response. Empty while healthy, so the happy
|
|
18
|
+
* path is unchanged and no client has to learn a new field to keep working.
|
|
19
|
+
*
|
|
20
|
+
* When it is not empty, `status: "applied"` is still true — the operation did
|
|
21
|
+
* apply, to state that will not outlive the process. Saying so in the response
|
|
22
|
+
* is the whole point: an agent gets one chance to notice, at the moment it
|
|
23
|
+
* would otherwise record the edit as done.
|
|
24
|
+
*/
|
|
25
|
+
export declare function persistenceWarning(): Record<string, never> | {
|
|
26
|
+
persisted: false;
|
|
27
|
+
persistenceWarning: string;
|
|
28
|
+
};
|
|
7
29
|
export declare function getStore(): SqliteStore;
|
|
8
30
|
/** Close & clear the singleton. Intended for tests + graceful shutdown. */
|
|
9
31
|
export declare function resetStore(): void;
|
|
@@ -17,7 +17,23 @@ export function resolveDbFile() {
|
|
|
17
17
|
return ":memory:";
|
|
18
18
|
if (process.env.NODE_ENV === "test")
|
|
19
19
|
return ":memory:";
|
|
20
|
-
|
|
20
|
+
/*
|
|
21
|
+
* `../../.data` is this monorepo's shape, not a general default.
|
|
22
|
+
*
|
|
23
|
+
* It is right for `apps/orchestrator`, whose cwd is two levels under the repo
|
|
24
|
+
* root, and wrong for everyone else: a library-mode host running from its own
|
|
25
|
+
* project root writes two directories *above* itself. PBA's landed in
|
|
26
|
+
* `~/Projects/.data` — outside the project, outside version control, and
|
|
27
|
+
* shared with any sibling checkout that made the same mistake.
|
|
28
|
+
*
|
|
29
|
+
* The default is now the host's own `.data/`. The legacy path still wins when
|
|
30
|
+
* a database is already sitting there, so an existing monorepo checkout keeps
|
|
31
|
+
* its state without an env var and without a migration step.
|
|
32
|
+
*/
|
|
33
|
+
const legacy = resolve(process.cwd(), "../../.data/orchestrator.db");
|
|
34
|
+
if (existsSync(legacy))
|
|
35
|
+
return legacy;
|
|
36
|
+
return resolve(process.cwd(), ".data/orchestrator.db");
|
|
21
37
|
}
|
|
22
38
|
export function resolveJsonMigrationTtlDays() {
|
|
23
39
|
const raw = Number(process.env.ORCHESTRATOR_JSON_MIGRATION_TTL_DAYS ?? 14);
|
|
@@ -35,6 +51,37 @@ export function resolveBackupIntervalHours() {
|
|
|
35
51
|
// Singleton access
|
|
36
52
|
// ---------------------------------------------------------------------------
|
|
37
53
|
let store = null;
|
|
54
|
+
let persistenceFailure = null;
|
|
55
|
+
/** Record a persistence failure. The first reason wins; later ones are noise. */
|
|
56
|
+
export function notePersistenceFailure(reason) {
|
|
57
|
+
if (persistenceFailure === null)
|
|
58
|
+
persistenceFailure = reason;
|
|
59
|
+
}
|
|
60
|
+
/** Clear the recorded failure — a successful write means we are healthy again. */
|
|
61
|
+
export function notePersistenceSuccess() {
|
|
62
|
+
persistenceFailure = null;
|
|
63
|
+
}
|
|
64
|
+
export function persistenceHealth() {
|
|
65
|
+
return { ok: persistenceFailure === null, reason: persistenceFailure };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Spread into a successful mutation response. Empty while healthy, so the happy
|
|
69
|
+
* path is unchanged and no client has to learn a new field to keep working.
|
|
70
|
+
*
|
|
71
|
+
* When it is not empty, `status: "applied"` is still true — the operation did
|
|
72
|
+
* apply, to state that will not outlive the process. Saying so in the response
|
|
73
|
+
* is the whole point: an agent gets one chance to notice, at the moment it
|
|
74
|
+
* would otherwise record the edit as done.
|
|
75
|
+
*/
|
|
76
|
+
export function persistenceWarning() {
|
|
77
|
+
if (persistenceFailure === null)
|
|
78
|
+
return {};
|
|
79
|
+
return {
|
|
80
|
+
persisted: false,
|
|
81
|
+
persistenceWarning: "This edit was applied in memory but could not be saved — it will be lost when the " +
|
|
82
|
+
`server restarts, and the session may silently revert to CMS content. Cause: ${persistenceFailure}`
|
|
83
|
+
};
|
|
84
|
+
}
|
|
38
85
|
export function getStore() {
|
|
39
86
|
if (!store) {
|
|
40
87
|
store = new SqliteStore({ file: resolveDbFile() });
|
|
@@ -50,6 +97,7 @@ export function resetStore() {
|
|
|
50
97
|
catch { /* ignore */ }
|
|
51
98
|
}
|
|
52
99
|
store = null;
|
|
100
|
+
persistenceFailure = null;
|
|
53
101
|
}
|
|
54
102
|
// ---------------------------------------------------------------------------
|
|
55
103
|
// JSON migration helpers
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { Database as BetterSqliteDatabase } from "better-sqlite3";
|
|
2
|
+
/**
|
|
3
|
+
* Resolve and cache the driver. Awaited once at startup; safe to call again.
|
|
4
|
+
* Throws with every attempt's failure when the module cannot be reached at all.
|
|
5
|
+
*/
|
|
6
|
+
export declare function preloadSqliteDriver(): Promise<void>;
|
|
2
7
|
import type { PageDoc, Operation, SiteConfig } from "@avocadostudio-ai/shared";
|
|
3
8
|
export declare const HISTORY_DEPTH_CAP = 50;
|
|
4
9
|
export declare const VERSION_LOG_CAP = 100;
|