@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.
Files changed (61) hide show
  1. package/dist/agent/sites-agent-context.js +3 -2
  2. package/dist/agent/sites-agent-shared.js +1 -0
  3. package/dist/chat/anthropic-planner.js +3 -3
  4. package/dist/chat/chat-pipeline.js +122 -20
  5. package/dist/chat/gemini-planner.js +3 -3
  6. package/dist/chat/planner.js +7 -5
  7. package/dist/chat/prompts.js +6 -1
  8. package/dist/cms/adapter.d.ts +159 -1
  9. package/dist/cms/adapter.js +19 -1
  10. package/dist/cms/bootstrap.d.ts +46 -1
  11. package/dist/cms/bootstrap.js +126 -2
  12. package/dist/cms/index.d.ts +3 -2
  13. package/dist/cms/index.js +2 -1
  14. package/dist/errors.d.ts +9 -1
  15. package/dist/handler/auth.d.ts +79 -0
  16. package/dist/handler/auth.js +113 -0
  17. package/dist/handler/create-orchestrator.d.ts +205 -0
  18. package/dist/handler/create-orchestrator.js +1599 -0
  19. package/dist/http/access-tokens.d.ts +58 -0
  20. package/dist/http/access-tokens.js +161 -0
  21. package/dist/http/audio-actions.d.ts +121 -0
  22. package/dist/http/audio-actions.js +248 -0
  23. package/dist/http/blocks-actions.d.ts +31 -0
  24. package/dist/http/blocks-actions.js +31 -0
  25. package/dist/http/draft-provenance.d.ts +68 -0
  26. package/dist/http/draft-provenance.js +101 -0
  27. package/dist/http/history-actions.d.ts +58 -0
  28. package/dist/http/history-actions.js +169 -0
  29. package/dist/http/image-generate-actions.d.ts +268 -0
  30. package/dist/http/image-generate-actions.js +546 -0
  31. package/dist/http/ops-actions.d.ts +51 -0
  32. package/dist/http/ops-actions.js +79 -0
  33. package/dist/http/publish-actions.d.ts +153 -0
  34. package/dist/http/publish-actions.js +323 -0
  35. package/dist/http/restore-actions.d.ts +67 -0
  36. package/dist/http/restore-actions.js +145 -0
  37. package/dist/http/screenshot-actions.d.ts +108 -0
  38. package/dist/http/screenshot-actions.js +181 -0
  39. package/dist/http/session-actions.d.ts +35 -0
  40. package/dist/http/session-actions.js +98 -0
  41. package/dist/http/telemetry-feedback-actions.d.ts +53 -0
  42. package/dist/http/telemetry-feedback-actions.js +68 -0
  43. package/dist/http/unsplash-actions.d.ts +64 -0
  44. package/dist/http/unsplash-actions.js +81 -0
  45. package/dist/http/variations-actions.d.ts +102 -0
  46. package/dist/http/variations-actions.js +104 -0
  47. package/dist/index.d.ts +4 -1
  48. package/dist/index.js +21 -1
  49. package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
  50. package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
  51. package/dist/nlp/deterministic-planner-suggestions.js +37 -11
  52. package/dist/nlp/plan-normalizer.js +18 -2
  53. package/dist/ops/ops-engine.js +219 -14
  54. package/dist/state/session-state.d.ts +56 -1
  55. package/dist/state/session-state.js +92 -6
  56. package/dist/state/sqlite-store-singleton.d.ts +22 -0
  57. package/dist/state/sqlite-store-singleton.js +49 -1
  58. package/dist/state/sqlite-store.d.ts +5 -0
  59. package/dist/state/sqlite-store.js +125 -2
  60. package/dist/telemetry/chat-telemetry.js +6 -1
  61. package/package.json +12 -16
@@ -1,12 +1,12 @@
1
1
  import { z } from "zod";
2
- import { blockSchemas, operationSchema, validateBlockProps, validateByJsonSchemaLike, isChrome, mapSemanticThemeTokens, generateItemId } from "@avocadostudio-ai/shared";
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
- if (!validateByJsonSchemaLike(manifestComponent.propsSchema, coerced)) {
448
- throw new OperationError(`Invalid props for ${blockType}: does not match block manifest schema`, { category: "schema_violation" });
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
- // When caller passes newTitle, keep meta.title in sync so SEO doesn't show
691
- // the source page's English title on a translated copy. Other meta fields
692
- // (description, ogImage) stay — caller can patch them with update_page_meta.
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
- return { ...entry, ...itemPatch };
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
- /** Total published pages across all sites (currently just the JSON-file legacy site). */
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
- import { CHAT_HISTORY_CAP, HISTORY_DEPTH_CAP, RECENT_EDITS_CAP, VERSION_LOG_CAP, PUBLISH_LOG_CAP, } from "./sqlite-store.js";
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
- /** Total published pages across all sites (currently just the JSON-file legacy site). */
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
- logger.error({ err: toErrorDetail(error) }, "Failed to persist orchestrator state to SQLite");
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
- logger.error({ err: toErrorDetail(error) }, "Failed to persist orchestrator state to SQLite");
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
- logger.error({ err: toErrorDetail(error) }, "Failed to open orchestrator SQLite store");
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
- return resolve(process.cwd(), "../../.data/orchestrator.db");
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;