@avocadostudio-ai/orchestrator-core 0.3.2 → 0.3.3

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 (69) hide show
  1. package/dist/chat/anthropic-planner.d.ts +8 -0
  2. package/dist/chat/anthropic-planner.js +166 -12
  3. package/dist/chat/chat-pipeline-translation.d.ts +13 -0
  4. package/dist/chat/chat-pipeline-translation.js +109 -45
  5. package/dist/chat/chat-pipeline.d.ts +1 -1
  6. package/dist/chat/chat-pipeline.js +296 -53
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/planner-types.d.ts +15 -0
  10. package/dist/chat/planner-types.js +2 -2
  11. package/dist/chat/planner.d.ts +12 -0
  12. package/dist/chat/planner.js +16 -2
  13. package/dist/chat/translation-chunking.d.ts +124 -0
  14. package/dist/chat/translation-chunking.js +371 -0
  15. package/dist/checks/field-walk.d.ts +25 -0
  16. package/dist/checks/field-walk.js +152 -0
  17. package/dist/checks/index.d.ts +5 -0
  18. package/dist/checks/index.js +4 -0
  19. package/dist/checks/page-weight.d.ts +22 -0
  20. package/dist/checks/page-weight.js +200 -0
  21. package/dist/checks/rules-draft.d.ts +2 -0
  22. package/dist/checks/rules-draft.js +375 -0
  23. package/dist/checks/run-checks.d.ts +32 -0
  24. package/dist/checks/run-checks.js +152 -0
  25. package/dist/checks/session-runner.d.ts +19 -0
  26. package/dist/checks/session-runner.js +95 -0
  27. package/dist/checks/types.d.ts +65 -0
  28. package/dist/checks/types.js +1 -0
  29. package/dist/durable/durable-store-singleton.d.ts +37 -0
  30. package/dist/durable/durable-store-singleton.js +179 -0
  31. package/dist/durable/finding-impact.d.ts +30 -0
  32. package/dist/durable/finding-impact.js +53 -0
  33. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  34. package/dist/durable/in-memory-durable-store.js +363 -0
  35. package/dist/durable/index.d.ts +5 -0
  36. package/dist/durable/index.js +4 -0
  37. package/dist/durable/pending-plan-store.d.ts +28 -0
  38. package/dist/durable/pending-plan-store.js +156 -0
  39. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  40. package/dist/durable/sqlite-durable-store.js +631 -0
  41. package/dist/durable/types.d.ts +265 -0
  42. package/dist/durable/types.js +1 -0
  43. package/dist/handler/create-orchestrator.d.ts +4 -0
  44. package/dist/handler/create-orchestrator.js +67 -4
  45. package/dist/http/audio-actions.d.ts +1 -1
  46. package/dist/http/checks-actions.d.ts +39 -0
  47. package/dist/http/checks-actions.js +122 -0
  48. package/dist/http/history-actions.d.ts +1 -1
  49. package/dist/http/image-generate-actions.d.ts +2 -2
  50. package/dist/http/ops-actions.d.ts +2 -2
  51. package/dist/http/publish-actions.d.ts +4 -4
  52. package/dist/http/restore-actions.d.ts +3 -3
  53. package/dist/http/screenshot-actions.d.ts +2 -2
  54. package/dist/http/session-actions.d.ts +1 -1
  55. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  56. package/dist/http/unsplash-actions.d.ts +2 -2
  57. package/dist/http/variations-actions.d.ts +2 -2
  58. package/dist/index.d.ts +7 -0
  59. package/dist/index.js +27 -0
  60. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  61. package/dist/nlp/deterministic-planner-context.js +33 -7
  62. package/dist/nlp/plan-normalizer.js +54 -6
  63. package/dist/ops/destructive-action-gate.js +7 -2
  64. package/dist/ops/ops-engine.d.ts +12 -1
  65. package/dist/ops/ops-engine.js +41 -14
  66. package/dist/publish/publish-target-registry.js +1 -1
  67. package/dist/publish/publish-target.d.ts +1 -1
  68. package/dist/state/session-state.js +8 -1
  69. package/package.json +3 -3
@@ -17,8 +17,8 @@
17
17
  * wire bytes for callers that don't already have an SSE writer. Neither Fastify
18
18
  * nor `Response` appears in this file.
19
19
  */
20
- import { type VariationRequestBody, type VariationPipelineContext, type VariationResult, type VariationImageUpdate } from "../chat/variation-pipeline.js";
21
- import type { ActionResult } from "./history-actions.js";
20
+ import { type VariationRequestBody, type VariationPipelineContext, type VariationResult, type VariationImageUpdate } from "../chat/variation-pipeline.ts";
21
+ import type { ActionResult } from "./history-actions.ts";
22
22
  export type { ActionResult };
23
23
  export type { VariationRequestBody, VariationPipelineContext };
24
24
  export type ParsedVariationRequest = {
package/dist/index.d.ts CHANGED
@@ -2,3 +2,10 @@ export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHan
2
2
  export type { OrchestratorAuth, AuthContext } from "./handler/auth.ts";
3
3
  export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, ResolvedCapabilities } from "./cms/adapter.ts";
4
4
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaSourceConfig } from "./cms/index.ts";
5
+ export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.ts";
6
+ export type { PublishTarget, PublishContext, PublishOutcome, PublishStatus, PublishResult } from "./publish/publish-target.ts";
7
+ export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, type SqliteDurableStoreOptions, type InMemoryDurableStoreOptions, type DurableStore, type FindingInput, type FindingRecord, type FindingQuery, type FindingSeverity, type FindingStatus, type FindingEvidence, type CheckRunInput, type CheckRunRecord, type CheckRunPatch, type CheckRunTrigger, type MemoryInput, type MemoryRecord, type MemoryQuery, type MemoryScope, type MemoryKind, type MemorySource, type MemoryStatus, type CorrectionInput, type CorrectionRecord, type CorrectionQuery, type CorrectionOutcome, type ProposalInput, type ProposalRecord, type ProposalQuery, type ProposalStatus } from "./durable/index.ts";
8
+ export { runDraftChecks, runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks, fingerprintFor, DRAFT_RULES, walkPageFields, fieldText, type RunChecksArgs, type CheckRule, type CheckContext, type RuleFinding, type FieldEntry, type SiteView } from "./checks/index.ts";
9
+ export { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction, type RunChecksParams, type ListFindingsParams, type UpdateFindingParams, type ChecksScope } from "./http/checks-actions.ts";
10
+ export { durableHealth, noteDurableFailure, isDiscardInFlight } from "./durable/durable-store-singleton.ts";
11
+ export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./durable/pending-plan-store.ts";
package/dist/index.js CHANGED
@@ -19,3 +19,30 @@
19
19
  // The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
20
20
  export { createOrchestrator } from "./handler/create-orchestrator.js";
21
21
  export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "./cms/index.js";
22
+ // The publish-target plugin point. `docs-site/integration/publishing.mdx` has
23
+ // documented this as the way to publish somewhere we do not ship a target for
24
+ // since before the package had an export map, and told the reader to import it
25
+ // from `"./publish/publish-target-registry.js"` — a relative path into *this*
26
+ // repository, pasted into their file. It cannot resolve from anywhere, and
27
+ // there was no specifier that could: the symbol was not exported here, so a
28
+ // registry consumer had no reachable way to register a target at all.
29
+ //
30
+ // The interface was always meant to be public — `architecture.mdx` calls it the
31
+ // integration point for "S3, GitLab Pages, Netlify, a CMS API, a custom CI/CD
32
+ // pipeline". Only the export was missing.
33
+ export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.js";
34
+ // The durable substrate for findings, memory, corrections and proposals. It is
35
+ // public because the implementation is meant to be replaceable: a library-mode
36
+ // host on a serverless platform has no persistent disk for a SQLite file, and
37
+ // every record here would otherwise die with the process. `DurableStore` is the
38
+ // seam a Neon- or Turso-backed store fills — which is only true if a consumer
39
+ // installing from the registry can reach the type.
40
+ export { SqliteDurableStore, InMemoryDurableStore, getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral } from "./durable/index.js";
41
+ // The draft-tier checks: pure functions of a PageDoc and the block manifest,
42
+ // producing findings into the durable store. Public so an integrator can run
43
+ // them (or add a rule of their own) against a site we do not host.
44
+ export { runDraftChecks, runChecksForSession, scheduleChecksAfterApply, scheduleChecksAfterPublish, cancelScheduledChecks, fingerprintFor, DRAFT_RULES, walkPageFields, fieldText } from "./checks/index.js";
45
+ // The checks HTTP surface, mounted by both transports.
46
+ export { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction } from "./http/checks-actions.js";
47
+ export { durableHealth, noteDurableFailure, isDiscardInFlight } from "./durable/durable-store-singleton.js";
48
+ export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./durable/pending-plan-store.js";
@@ -46,6 +46,22 @@ export declare function selectedBlockSnapshot(args: {
46
46
  selectedEditablePath: string | null;
47
47
  selectedEditableValue: {} | null;
48
48
  } | null;
49
+ /**
50
+ * How an item in a list prop gets named to the planner, in precedence order.
51
+ *
52
+ * A list whose items match none of these reaches the planner as a bare count,
53
+ * and the planner then cannot answer "update the FAQ entry about freezing" —
54
+ * it knows there are three entries and nothing about any of them. It says so,
55
+ * correctly, and asks which one. Three of the twelve list fields in the block
56
+ * registry were in that state: `FAQAccordion.items` names its question `q`,
57
+ * `Testimonials.items` has `author`/`quote`, and `Gallery.images` has
58
+ * `caption`/`alt`. None of those were listed here.
59
+ *
60
+ * `arrayPropLabelKeys` is asserted against the block registry by a test, so a
61
+ * new block with a differently-named item field fails there rather than
62
+ * silently costing the planner a clarification round-trip.
63
+ */
64
+ export declare const ARRAY_PROP_LABEL_KEYS: readonly ["label", "title", "heading", "question", "q", "name", "author", "caption", "alt", "quote", "value", "text"];
49
65
  export declare function arrayPropLengths(props: Record<string, unknown>): Record<string, {
50
66
  length: number;
51
67
  labels?: string[];
@@ -184,6 +184,35 @@ export function selectedBlockSnapshot(args) {
184
184
  // ---------------------------------------------------------------------------
185
185
  // Array prop metadata
186
186
  // ---------------------------------------------------------------------------
187
+ /**
188
+ * How an item in a list prop gets named to the planner, in precedence order.
189
+ *
190
+ * A list whose items match none of these reaches the planner as a bare count,
191
+ * and the planner then cannot answer "update the FAQ entry about freezing" —
192
+ * it knows there are three entries and nothing about any of them. It says so,
193
+ * correctly, and asks which one. Three of the twelve list fields in the block
194
+ * registry were in that state: `FAQAccordion.items` names its question `q`,
195
+ * `Testimonials.items` has `author`/`quote`, and `Gallery.images` has
196
+ * `caption`/`alt`. None of those were listed here.
197
+ *
198
+ * `arrayPropLabelKeys` is asserted against the block registry by a test, so a
199
+ * new block with a differently-named item field fails there rather than
200
+ * silently costing the planner a clarification round-trip.
201
+ */
202
+ export const ARRAY_PROP_LABEL_KEYS = [
203
+ "label",
204
+ "title",
205
+ "heading",
206
+ "question",
207
+ "q",
208
+ "name",
209
+ "author",
210
+ "caption",
211
+ "alt",
212
+ "quote",
213
+ "value",
214
+ "text"
215
+ ];
187
216
  export function arrayPropLengths(props) {
188
217
  const out = {};
189
218
  for (const [key, value] of Object.entries(props)) {
@@ -192,13 +221,10 @@ export function arrayPropLengths(props) {
192
221
  const labels = [];
193
222
  for (const item of value) {
194
223
  if (typeof item === "object" && item !== null) {
195
- const labelValue = item.label ??
196
- item.title ??
197
- item.heading ??
198
- item.question ??
199
- item.name;
200
- if (typeof labelValue === "string")
201
- labels.push(labelValue);
224
+ const record = item;
225
+ const labelKey = ARRAY_PROP_LABEL_KEYS.find((candidate) => typeof record[candidate] === "string");
226
+ if (labelKey)
227
+ labels.push(record[labelKey]);
202
228
  }
203
229
  }
204
230
  out[key] = labels.length > 0 ? { length: value.length, labels } : { length: value.length };
@@ -711,6 +711,19 @@ export function normalizePlanCandidate(input, args) {
711
711
  return args.currentPage.blocks[idx - 1]?.id;
712
712
  };
713
713
  const usedBlockIds = new Set((args?.currentPage?.blocks ?? []).map((b) => b.id));
714
+ /**
715
+ * Block ids this pass renamed, old → new.
716
+ *
717
+ * Renaming an `add_block` id is only safe if the rest of the plan follows it.
718
+ * A plan that adds `b_hero_x` and then updates `b_hero_x` means the block it
719
+ * just added; leave the reference pointing at the colliding name and the
720
+ * update lands on the *existing* block instead — content written over the
721
+ * wrong block, which is the failure mode this repo keeps re-shipping. Ops are
722
+ * normalized in order, so only ops after the rename are rewritten: a
723
+ * reference that came *before* the add_block can only have meant the block
724
+ * that was already there.
725
+ */
726
+ const renamedBlockIds = new Map();
714
727
  let createdPageSlug;
715
728
  let droppedPageLevelUpdate = false;
716
729
  // itemIds already claimed by a remove_item freeze in THIS plan — used to spot
@@ -972,6 +985,19 @@ export function normalizePlanCandidate(input, args) {
972
985
  raw.afterBlockId =
973
986
  raw.after_block_id ?? raw.after ?? raw.insertAfterId ?? beforeToAfter(raw.beforeId ?? raw.insertBeforeId);
974
987
  }
988
+ // Follow any add_block id an earlier op in this plan had to uniquify. Runs
989
+ // after the alias resolution above so `after_block_id` and friends are
990
+ // already folded into the canonical keys. See `renamedBlockIds`.
991
+ if (renamedBlockIds.size > 0) {
992
+ for (const key of ["blockId", "afterBlockId", "newBlockId"]) {
993
+ const value = raw[key];
994
+ if (typeof value === "string") {
995
+ const renamed = renamedBlockIds.get(value);
996
+ if (renamed)
997
+ raw[key] = renamed;
998
+ }
999
+ }
1000
+ }
975
1001
  if (!raw.afterPageSlug) {
976
1002
  raw.afterPageSlug =
977
1003
  raw.afterPageSlug ??
@@ -1246,15 +1272,37 @@ export function normalizePlanCandidate(input, args) {
1246
1272
  block.props = {};
1247
1273
  }
1248
1274
  }
1249
- if ((!block.id || typeof block.id !== "string") && typeof block.type === "string") {
1250
- let fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}`;
1275
+ /*
1276
+ * The model authors this id. Nothing in the `add_block` prompt says how
1277
+ * to build one, so it free-associates from the page and the block type —
1278
+ * `b_featuregrid_wellness` for a FeatureGrid on /avocado-wellness. That is
1279
+ * a near-deterministic function of the prompt, so the same request asked
1280
+ * twice produces the same id twice, and the second one dies in the ops
1281
+ * engine on "Block id … already exists", taking the whole plan down with
1282
+ * it: a committing apply is all-or-nothing.
1283
+ *
1284
+ * `usedBlockIds` is seeded from the page's existing blocks and knew this
1285
+ * all along. It was consulted only when the model *omitted* an id, which
1286
+ * is the one case that cannot collide; a supplied id went through
1287
+ * untouched and was never even registered, so two ops in one plan could
1288
+ * pick the same name. Uniquify instead — a fourth FeatureGrid is a
1289
+ * legitimate reading of "populate this page", and only the label was
1290
+ * wrong. The engine's own check stays as the backstop and must never be
1291
+ * relaxed into an overwrite.
1292
+ */
1293
+ const suppliedId = typeof block.id === "string" && block.id.length > 0 ? block.id : null;
1294
+ const idStem = suppliedId ?? (typeof block.type === "string" ? `b_${String(block.type).toLowerCase()}_${Date.now()}` : null);
1295
+ if (idStem) {
1296
+ let candidate = idStem;
1251
1297
  let sfx = 0;
1252
- while (usedBlockIds.has(fallbackId)) {
1298
+ while (usedBlockIds.has(candidate)) {
1253
1299
  sfx++;
1254
- fallbackId = `b_${String(block.type).toLowerCase()}_${Date.now()}_${sfx}`;
1300
+ candidate = `${idStem}_${sfx}`;
1255
1301
  }
1256
- usedBlockIds.add(fallbackId);
1257
- block.id = fallbackId;
1302
+ usedBlockIds.add(candidate);
1303
+ if (suppliedId && candidate !== suppliedId)
1304
+ renamedBlockIds.set(suppliedId, candidate);
1305
+ block.id = candidate;
1258
1306
  }
1259
1307
  raw.block = block;
1260
1308
  // Remap heading→title for non-Hero blocks (LLMs often confuse heading/title)
@@ -24,12 +24,17 @@ function slugsTouchedByOps(ops) {
24
24
  set.add(op.toPageSlug);
25
25
  continue;
26
26
  }
27
+ // rename_page: one page, under two names. Counting both slugs made every
28
+ // rename look like a cross-page plan and held it for approval — the same
29
+ // miscount duplicate_page and duplicate_block are special-cased for above.
30
+ if (op.op === "rename_page") {
31
+ set.add(typeof op.newPageSlug === "string" ? op.newPageSlug : op.pageSlug);
32
+ continue;
33
+ }
27
34
  if ("pageSlug" in op && typeof op.pageSlug === "string")
28
35
  set.add(op.pageSlug);
29
36
  if (op.op === "duplicate_block" && typeof op.toPageSlug === "string")
30
37
  set.add(op.toPageSlug);
31
- if (op.op === "rename_page" && typeof op.newPageSlug === "string")
32
- set.add(op.newPageSlug);
33
38
  }
34
39
  return Array.from(set);
35
40
  }
@@ -22,7 +22,18 @@ export declare function isNoEffectiveChangeError(reason: string): boolean;
22
22
  */
23
23
  export declare function isAlreadyCurrentError(reason: string): boolean;
24
24
  export declare function classifyGuardrailError(reason: string): GuardrailErrorCategory;
25
- export declare function formatValidationError(reason: string): string;
25
+ /**
26
+ * `category` is the one the failure was actually thrown with, for callers that
27
+ * still hold it. Without it this falls back to reading the category back out of
28
+ * the message, which is a guess — see `isRepairEligibleCategory`.
29
+ */
30
+ export declare function formatValidationError(reason: string, category?: GuardrailErrorCategory): string;
31
+ /**
32
+ * The deterministic repair pass exists for plans that are structurally wrong
33
+ * but fixable — a bad prop name, an id that clashes. Anything else is either
34
+ * hopeless or not the planner's fault, and re-prompting it wastes a model call.
35
+ */
36
+ export declare function isRepairEligibleCategory(category: GuardrailErrorCategory): category is "schema_violation";
26
37
  export declare function isDeterministicRepairEligible(reason: string): boolean;
27
38
  /**
28
39
  * Extracts structured fields from a planner schema_violation reason so the
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { blockSchemas, operationSchema, validateBlockProps, findManifestSchemaIssue, isChrome, isInBlockCatalogue, catalogueBlockTypes, mapSemanticThemeTokens, generateItemId, isRichTextDoc, fromMarkdown, mergeRichTextDoc } from "@avocadostudio-ai/shared";
2
+ import { blockSchemas, operationSchema, validateBlockProps, findManifestSchemaIssue, isChrome, getMediaFields, 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";
@@ -88,7 +88,17 @@ function remapRouteReference(value, fromSlug, toSlug) {
88
88
  }
89
89
  return value;
90
90
  }
91
- function rewriteRouteLinksInValue(input, fromSlug, toSlug) {
91
+ /**
92
+ * Rewrite every route reference under `input` from `fromSlug` to `toSlug`.
93
+ *
94
+ * The walk is permissive by design — it remaps any string that starts with the
95
+ * old slug, whatever prop holds it — because a custom block ships no field
96
+ * metadata and its links still have to survive a rename. `mediaKeys` is the one
97
+ * exception: props that address a *resource* rather than a page. Renaming
98
+ * `/pricing` to `/plans` does not move `/pricing/demo.mp4`, and before this the
99
+ * walker rewrote that path too, quietly breaking the asset.
100
+ */
101
+ function rewriteRouteLinksInValue(input, fromSlug, toSlug, mediaKeys) {
92
102
  if (typeof input === "string") {
93
103
  const mapped = remapRouteReference(input, fromSlug, toSlug);
94
104
  return { value: mapped, changed: mapped !== input };
@@ -96,7 +106,7 @@ function rewriteRouteLinksInValue(input, fromSlug, toSlug) {
96
106
  if (Array.isArray(input)) {
97
107
  let changed = false;
98
108
  const next = input.map((item) => {
99
- const mapped = rewriteRouteLinksInValue(item, fromSlug, toSlug);
109
+ const mapped = rewriteRouteLinksInValue(item, fromSlug, toSlug, mediaKeys);
100
110
  if (mapped.changed)
101
111
  changed = true;
102
112
  return mapped.value;
@@ -109,11 +119,8 @@ function rewriteRouteLinksInValue(input, fromSlug, toSlug) {
109
119
  let changed = false;
110
120
  const next = {};
111
121
  for (const [key, value] of Object.entries(source)) {
112
- if (typeof value === "string" && key.toLowerCase().includes("href")) {
113
- const mapped = remapRouteReference(value, fromSlug, toSlug);
114
- if (mapped !== value)
115
- changed = true;
116
- next[key] = mapped;
122
+ if (mediaKeys.has(key)) {
123
+ next[key] = value;
117
124
  continue;
118
125
  }
119
126
  if (typeof value === "string" && key === "body") {
@@ -128,7 +135,7 @@ function rewriteRouteLinksInValue(input, fromSlug, toSlug) {
128
135
  next[key] = rewritten;
129
136
  continue;
130
137
  }
131
- const mapped = rewriteRouteLinksInValue(value, fromSlug, toSlug);
138
+ const mapped = rewriteRouteLinksInValue(value, fromSlug, toSlug, mediaKeys);
132
139
  if (mapped.changed)
133
140
  changed = true;
134
141
  next[key] = mapped.value;
@@ -138,7 +145,7 @@ function rewriteRouteLinksInValue(input, fromSlug, toSlug) {
138
145
  function rewriteLinksToRenamedPage(page, fromSlug, toSlug) {
139
146
  let changed = false;
140
147
  const nextBlocks = page.blocks.map((block) => {
141
- const mapped = rewriteRouteLinksInValue(block.props, fromSlug, toSlug);
148
+ const mapped = rewriteRouteLinksInValue(block.props, fromSlug, toSlug, getMediaFields(block.type));
142
149
  if (!mapped.changed)
143
150
  return block;
144
151
  changed = true;
@@ -208,16 +215,36 @@ export function classifyGuardrailError(reason) {
208
215
  lower.includes("required") ||
209
216
  lower.includes("unknown props") ||
210
217
  lower.includes("out of range") ||
211
- lower.includes("must be")) {
218
+ lower.includes("must be") ||
219
+ // Every "already exists" this engine raises — a duplicate block id, a page
220
+ // slug that is taken — it raises *as* a schema_violation. This list did not
221
+ // know the word, so a plan one renamed id away from applying classified as
222
+ // `internal_error`, which the repair pass refuses to touch. Callers holding
223
+ // the thrown error should prefer its `category` over this function; the
224
+ // keyword is here for the paths that only ever had the string.
225
+ lower.includes("already exists")) {
212
226
  return "schema_violation";
213
227
  }
214
228
  return "internal_error";
215
229
  }
216
- export function formatValidationError(reason) {
217
- return `${classifyGuardrailError(reason)}: ${reason}`;
230
+ /**
231
+ * `category` is the one the failure was actually thrown with, for callers that
232
+ * still hold it. Without it this falls back to reading the category back out of
233
+ * the message, which is a guess — see `isRepairEligibleCategory`.
234
+ */
235
+ export function formatValidationError(reason, category) {
236
+ return `${category ?? classifyGuardrailError(reason)}: ${reason}`;
237
+ }
238
+ /**
239
+ * The deterministic repair pass exists for plans that are structurally wrong
240
+ * but fixable — a bad prop name, an id that clashes. Anything else is either
241
+ * hopeless or not the planner's fault, and re-prompting it wastes a model call.
242
+ */
243
+ export function isRepairEligibleCategory(category) {
244
+ return category === "schema_violation";
218
245
  }
219
246
  export function isDeterministicRepairEligible(reason) {
220
- return classifyGuardrailError(reason) === "schema_violation";
247
+ return isRepairEligibleCategory(classifyGuardrailError(reason));
221
248
  }
222
249
  /**
223
250
  * Extracts structured fields from a planner schema_violation reason so the
@@ -10,7 +10,7 @@ import { DeployHookPublishTarget } from "./targets/deploy-hook.js";
10
10
  * handling requests:
11
11
  *
12
12
  * ```ts
13
- * import { registerPublishTarget } from "./publish/publish-target-registry.js"
13
+ * import { registerPublishTarget } from "@avocadostudio-ai/orchestrator-core"
14
14
  * registerPublishTarget(new S3PublishTarget())
15
15
  * ```
16
16
  *
@@ -6,7 +6,7 @@ import type { PublishTracker } from "../state/session-state.ts";
6
6
  * up writing content. Built-ins cover git + Vercel, a site-contract POST, and
7
7
  * a raw Vercel deploy hook. Third parties (S3, GitLab Pages, custom CI, etc.)
8
8
  * implement {@link PublishTarget} and register via
9
- * {@link registerPublishTarget} from `./publish-target-registry.js`.
9
+ * {@link registerPublishTarget} from `@avocadostudio-ai/orchestrator-core`.
10
10
  *
11
11
  * The route handler's job shrinks to: build {@link PublishContext}, call
12
12
  * {@link selectPublishTarget}, invoke `target.publish(ctx)`, save the returned
@@ -3,6 +3,7 @@ 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
5
  import { archiveMigratedJson, getStore, notePersistenceFailure, notePersistenceSuccess, readLegacyJson, resolveDbFile, resolveJsonMigrationTtlDays, sweepStaleMigrations, } from "./sqlite-store-singleton.js";
6
+ import { discardPendingProposalsForSession, sweepExpiredProposals } from "../durable/durable-store-singleton.js";
6
7
  // Re-exported so callers that already depend on this module for state can ask
7
8
  // whether that state is actually being kept, without a second import path.
8
9
  export { persistenceHealth, persistenceWarning } from "./sqlite-store-singleton.js";
@@ -173,6 +174,9 @@ export function forgetSession(sessionKey) {
173
174
  pendingClarificationBySession.delete(sessionKey);
174
175
  chatHistoryBySession.delete(sessionKey);
175
176
  pendingApprovalPlanBySession.delete(sessionKey);
177
+ // Session ids are caller-supplied and routinely reused, so a pending proposal
178
+ // left behind here would rehydrate into the *next* session with this id.
179
+ discardPendingProposalsForSession(sessionKey);
176
180
  imageSourcePreferenceBySession.delete(sessionKey);
177
181
  publishStatusBySession.delete(sessionKey);
178
182
  capabilitiesBySession.delete(sessionKey);
@@ -1091,12 +1095,15 @@ const EPHEMERAL_MAP_CAP = 500; // max entries for maps without timestamps
1091
1095
  */
1092
1096
  export function evictStaleEphemeralMaps() {
1093
1097
  const now = Date.now();
1094
- // pendingApprovalPlanBySession: has createdAt — evict by age
1098
+ // pendingApprovalPlanBySession: has createdAt — evict by age. The durable
1099
+ // mirror carries the same TTL as `expiresAt`, so both halves age out together
1100
+ // rather than the Map forgetting a plan the store would hand straight back.
1095
1101
  for (const [key, plan] of pendingApprovalPlanBySession) {
1096
1102
  if (now - new Date(plan.createdAt).getTime() > APPROVAL_PLAN_TTL_MS) {
1097
1103
  pendingApprovalPlanBySession.delete(key);
1098
1104
  }
1099
1105
  }
1106
+ sweepExpiredProposals();
1100
1107
  // publishStatusBySession: has updatedAt — evict by age
1101
1108
  for (const [key, tracker] of publishStatusBySession) {
1102
1109
  if (now - new Date(tracker.updatedAt).getTime() > PUBLISH_STATUS_TTL_MS) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
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.3.2",
27
- "@avocadostudio-ai/shared": "^0.3.2"
26
+ "@avocadostudio-ai/shared": "^0.3.3",
27
+ "@avocadostudio-ai/migration-sdk": "^0.3.3"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@google/genai": "^1.46.0",