@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chat/anthropic-planner.d.ts +8 -0
- package/dist/chat/anthropic-planner.js +166 -12
- package/dist/chat/chat-pipeline-translation.d.ts +13 -0
- package/dist/chat/chat-pipeline-translation.js +109 -45
- package/dist/chat/chat-pipeline.d.ts +1 -1
- package/dist/chat/chat-pipeline.js +312 -54
- package/dist/chat/gemini-planner.d.ts +2 -0
- package/dist/chat/gemini-planner.js +2 -1
- package/dist/chat/planner-types.d.ts +15 -0
- package/dist/chat/planner-types.js +2 -2
- package/dist/chat/planner.d.ts +12 -0
- package/dist/chat/planner.js +16 -2
- package/dist/chat/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +42 -0
- package/dist/checks/field-walk.js +198 -0
- package/dist/checks/index.d.ts +5 -0
- package/dist/checks/index.js +4 -0
- package/dist/checks/page-weight.d.ts +22 -0
- package/dist/checks/page-weight.js +200 -0
- package/dist/checks/rules-draft.d.ts +2 -0
- package/dist/checks/rules-draft.js +439 -0
- package/dist/checks/run-checks.d.ts +42 -0
- package/dist/checks/run-checks.js +159 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +99 -0
- package/dist/checks/types.d.ts +109 -0
- package/dist/checks/types.js +1 -0
- package/dist/cms/adapter.d.ts +74 -1
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +1 -1
- package/dist/cms/index.js +1 -1
- package/dist/cms/media-sources.d.ts +29 -1
- package/dist/cms/media-sources.js +188 -7
- package/dist/durable/durable-store-singleton.d.ts +37 -0
- package/dist/durable/durable-store-singleton.js +179 -0
- package/dist/durable/finding-impact.d.ts +30 -0
- package/dist/durable/finding-impact.js +53 -0
- package/dist/durable/in-memory-durable-store.d.ts +203 -0
- package/dist/durable/in-memory-durable-store.js +363 -0
- package/dist/durable/index.d.ts +5 -0
- package/dist/durable/index.js +4 -0
- package/dist/durable/pending-plan-store.d.ts +28 -0
- package/dist/durable/pending-plan-store.js +156 -0
- package/dist/durable/sqlite-durable-store.d.ts +71 -0
- package/dist/durable/sqlite-durable-store.js +631 -0
- package/dist/durable/types.d.ts +265 -0
- package/dist/durable/types.js +1 -0
- package/dist/handler/create-orchestrator.d.ts +4 -0
- package/dist/handler/create-orchestrator.js +283 -32
- package/dist/http/audio-actions.d.ts +1 -1
- package/dist/http/checks-actions.d.ts +39 -0
- package/dist/http/checks-actions.js +122 -0
- package/dist/http/history-actions.d.ts +44 -1
- package/dist/http/history-actions.js +122 -0
- package/dist/http/image-generate-actions.d.ts +2 -2
- package/dist/http/ops-actions.d.ts +2 -2
- package/dist/http/publish-actions.d.ts +15 -4
- package/dist/http/publish-actions.js +3 -3
- package/dist/http/restore-actions.d.ts +3 -3
- package/dist/http/screenshot-actions.d.ts +2 -2
- package/dist/http/session-actions.d.ts +1 -1
- package/dist/http/telemetry-feedback-actions.d.ts +2 -2
- package/dist/http/unsplash-actions.d.ts +2 -2
- package/dist/http/variations-actions.d.ts +2 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +28 -1
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +66 -32
- package/dist/ops/destructive-action-gate.js +7 -2
- package/dist/ops/ops-engine.d.ts +12 -1
- package/dist/ops/ops-engine.js +41 -14
- package/dist/publish/publish-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +10 -3
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/session-state.js +8 -1
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/package.json +3 -3
package/dist/ops/ops-engine.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/dist/ops/ops-engine.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
113
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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)
|
|
247
|
+
return isRepairEligibleCategory(classifyGuardrailError(reason));
|
|
221
248
|
}
|
|
222
249
|
/**
|
|
223
250
|
* Extracts structured fields from a planner schema_violation reason so the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Logger } from "../logger.ts";
|
|
2
|
-
import { type PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { type PageDoc, type SiteConfig } from "@avocadostudio-ai/shared";
|
|
3
3
|
import { type PublishTracker } from "../state/session-state.ts";
|
|
4
4
|
export declare function deploymentIdFromAny(input: string): string | undefined;
|
|
5
5
|
export declare function refreshPublishStatusFromVercel(current: PublishTracker): Promise<PublishTracker>;
|
|
@@ -44,7 +44,17 @@ export declare function collectInlineAssets(pages: PageDoc[], generatedImageDir:
|
|
|
44
44
|
* Best-effort: failures are silently ignored.
|
|
45
45
|
*/
|
|
46
46
|
export declare function recordPublishSnapshot(session: string, pages: PageDoc[], log?: Logger, siteConfig?: Record<string, unknown>): Promise<string | undefined>;
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* `content` is what the route decided to publish. It defaults to the whole
|
|
49
|
+
* session draft, which is what this read for itself before the argument
|
|
50
|
+
* existed and what a full publish still computes. A partial publish passes the
|
|
51
|
+
* merged set instead — live site plus the selected pages — and re-reading the
|
|
52
|
+
* draft here would quietly publish everything anyway.
|
|
53
|
+
*/
|
|
54
|
+
export declare function publishViaGit(session: string, content?: {
|
|
55
|
+
pages: PageDoc[];
|
|
56
|
+
siteConfig: SiteConfig;
|
|
57
|
+
}): Promise<{
|
|
48
58
|
status: "failed";
|
|
49
59
|
session: string;
|
|
50
60
|
slugs: string[];
|
|
@@ -392,13 +392,20 @@ function sanitizeBranch(input) {
|
|
|
392
392
|
const trimmed = input.trim();
|
|
393
393
|
return trimmed.length > 0 ? trimmed : "main";
|
|
394
394
|
}
|
|
395
|
-
|
|
395
|
+
/**
|
|
396
|
+
* `content` is what the route decided to publish. It defaults to the whole
|
|
397
|
+
* session draft, which is what this read for itself before the argument
|
|
398
|
+
* existed and what a full publish still computes. A partial publish passes the
|
|
399
|
+
* merged set instead — live site plus the selected pages — and re-reading the
|
|
400
|
+
* draft here would quietly publish everything anyway.
|
|
401
|
+
*/
|
|
402
|
+
export async function publishViaGit(session, content) {
|
|
396
403
|
const repoRoot = resolve(process.cwd(), "../..");
|
|
397
404
|
const targetPath = "apps/site/lib/published-content.json";
|
|
398
405
|
const absoluteTargetPath = resolve(repoRoot, targetPath);
|
|
399
406
|
const branch = sanitizeBranch(process.env.PUBLISH_GIT_BRANCH ?? "main");
|
|
400
407
|
const strict = process.env.PUBLISH_GIT_STRICT === "1";
|
|
401
|
-
let pages = getSessionPages(session);
|
|
408
|
+
let pages = content?.pages ?? getSessionPages(session);
|
|
402
409
|
const slugs = pages.map((page) => page.slug);
|
|
403
410
|
// Rewrite localhost image URLs → relative paths and copy files into public/
|
|
404
411
|
const imageUrlMap = findLocalhostImageUrls(pages);
|
|
@@ -421,7 +428,7 @@ export async function publishViaGit(session) {
|
|
|
421
428
|
}
|
|
422
429
|
pages = rewriteImageUrlsInPages(pages, imageUrlMap);
|
|
423
430
|
}
|
|
424
|
-
const siteConfig = getSiteConfig(session);
|
|
431
|
+
const siteConfig = content?.siteConfig ?? getSiteConfig(session);
|
|
425
432
|
const payload = `${JSON.stringify({ pages, siteConfig }, null, 2)}\n`;
|
|
426
433
|
await writeFile(absoluteTargetPath, payload, "utf8");
|
|
427
434
|
const statusRaw = await runGit(["status", "--porcelain"], repoRoot);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing some of the draft rather than all of it.
|
|
3
|
+
*
|
|
4
|
+
* The editor's publish dialog lists every changed page and lets the user tick
|
|
5
|
+
* the ones to ship. Turning that tick list into something a publish target can
|
|
6
|
+
* receive is not filtering, and the difference is the whole point of this file.
|
|
7
|
+
*
|
|
8
|
+
* `PublishContext.pages` is a snapshot: "make the site be this". Every
|
|
9
|
+
* built-in target reads it that way — the git target serialises it to
|
|
10
|
+
* `published-content.json`, the site-contract target POSTs it to
|
|
11
|
+
* `/api/editor/publish`, a CMS adapter diffs it against what it read. Handing
|
|
12
|
+
* any of them the four pages the user ticked would not publish four pages; it
|
|
13
|
+
* would publish a four-page site and take the other fifty-six down. That is
|
|
14
|
+
* the same class of bug as the three in b4b58a5f — content destroyed by an
|
|
15
|
+
* edit that was only ever asked to change part of it.
|
|
16
|
+
*
|
|
17
|
+
* So a subset publish is a *merge*: start from what is live, overwrite the
|
|
18
|
+
* selected slugs with their draft versions, and leave every other page exactly
|
|
19
|
+
* as the live site already has it. A diffing adapter then finds changes only
|
|
20
|
+
* in the selected pages, because the rest are byte-identical to the baseline
|
|
21
|
+
* it read. A snapshot target writes a site that differs from the current one
|
|
22
|
+
* only in the selected pages. Both contracts come out right, and neither
|
|
23
|
+
* target needs to know a subset was requested.
|
|
24
|
+
*
|
|
25
|
+
* It follows that a subset publish is impossible without knowing what is live.
|
|
26
|
+
* `selectPagesForPublish` says so rather than guessing — the caller refuses
|
|
27
|
+
* the publish instead of shipping a site assembled from a baseline it does not
|
|
28
|
+
* have.
|
|
29
|
+
*/
|
|
30
|
+
import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
31
|
+
export type PublishSelection = {
|
|
32
|
+
/**
|
|
33
|
+
* Slugs to publish. `undefined` means "publish everything", which is the
|
|
34
|
+
* old behaviour and skips this module entirely.
|
|
35
|
+
*/
|
|
36
|
+
slugs?: readonly string[];
|
|
37
|
+
/**
|
|
38
|
+
* Whether the site-wide config (header, nav, footer chrome) ships too. It
|
|
39
|
+
* is one more tick box in the dialog and lives outside the page tree, so it
|
|
40
|
+
* is selected separately. Defaults to true, matching a full publish.
|
|
41
|
+
*/
|
|
42
|
+
includeSiteConfig?: boolean;
|
|
43
|
+
};
|
|
44
|
+
export type SelectedPublish = {
|
|
45
|
+
/** The merged page set to hand a publish target. */
|
|
46
|
+
pages: PageDoc[];
|
|
47
|
+
/** The config to hand it — draft or published, per `includeSiteConfig`. */
|
|
48
|
+
siteConfig: SiteConfig;
|
|
49
|
+
/** Slugs whose content this publish actually changes. */
|
|
50
|
+
selectedSlugs: string[];
|
|
51
|
+
/** Selected slugs that are live now and are being taken down. */
|
|
52
|
+
removedSlugs: string[];
|
|
53
|
+
};
|
|
54
|
+
export type SelectionFailure = {
|
|
55
|
+
error: string;
|
|
56
|
+
};
|
|
57
|
+
export type SelectionResult = SelectedPublish | SelectionFailure;
|
|
58
|
+
export declare function isSelectionFailure(result: SelectionResult): result is SelectionFailure;
|
|
59
|
+
/**
|
|
60
|
+
* Normalise what the client sent.
|
|
61
|
+
*
|
|
62
|
+
* Sending the field at all is the request for a subset; its length is not. An
|
|
63
|
+
* empty array means "no pages", which is a real thing to ask for — the publish
|
|
64
|
+
* dialog can have every page unticked and the site header ticked — and the two
|
|
65
|
+
* mistakes are not symmetrical: reading `[]` as "everything" ships pages the
|
|
66
|
+
* user just unticked, while reading it as "no pages" republishes what is
|
|
67
|
+
* already live and changes nothing. Only an absent or malformed field means
|
|
68
|
+
* the whole draft, which is what every caller meant before this existed.
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseSelectionSlugs(raw: unknown): string[] | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Build the page set and config for a publish that covers only `slugs`.
|
|
73
|
+
*
|
|
74
|
+
* `published` is what the live site currently holds. `null`/`undefined` means
|
|
75
|
+
* the caller could not find out, which is a refusal rather than an empty site
|
|
76
|
+
* — see the note at the top of this file.
|
|
77
|
+
*/
|
|
78
|
+
export declare function selectPagesForPublish(opts: {
|
|
79
|
+
draft: readonly PageDoc[];
|
|
80
|
+
published: readonly PageDoc[] | null | undefined;
|
|
81
|
+
draftSiteConfig: SiteConfig;
|
|
82
|
+
publishedSiteConfig?: SiteConfig | null;
|
|
83
|
+
selection: PublishSelection;
|
|
84
|
+
}): SelectionResult;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing some of the draft rather than all of it.
|
|
3
|
+
*
|
|
4
|
+
* The editor's publish dialog lists every changed page and lets the user tick
|
|
5
|
+
* the ones to ship. Turning that tick list into something a publish target can
|
|
6
|
+
* receive is not filtering, and the difference is the whole point of this file.
|
|
7
|
+
*
|
|
8
|
+
* `PublishContext.pages` is a snapshot: "make the site be this". Every
|
|
9
|
+
* built-in target reads it that way — the git target serialises it to
|
|
10
|
+
* `published-content.json`, the site-contract target POSTs it to
|
|
11
|
+
* `/api/editor/publish`, a CMS adapter diffs it against what it read. Handing
|
|
12
|
+
* any of them the four pages the user ticked would not publish four pages; it
|
|
13
|
+
* would publish a four-page site and take the other fifty-six down. That is
|
|
14
|
+
* the same class of bug as the three in b4b58a5f — content destroyed by an
|
|
15
|
+
* edit that was only ever asked to change part of it.
|
|
16
|
+
*
|
|
17
|
+
* So a subset publish is a *merge*: start from what is live, overwrite the
|
|
18
|
+
* selected slugs with their draft versions, and leave every other page exactly
|
|
19
|
+
* as the live site already has it. A diffing adapter then finds changes only
|
|
20
|
+
* in the selected pages, because the rest are byte-identical to the baseline
|
|
21
|
+
* it read. A snapshot target writes a site that differs from the current one
|
|
22
|
+
* only in the selected pages. Both contracts come out right, and neither
|
|
23
|
+
* target needs to know a subset was requested.
|
|
24
|
+
*
|
|
25
|
+
* It follows that a subset publish is impossible without knowing what is live.
|
|
26
|
+
* `selectPagesForPublish` says so rather than guessing — the caller refuses
|
|
27
|
+
* the publish instead of shipping a site assembled from a baseline it does not
|
|
28
|
+
* have.
|
|
29
|
+
*/
|
|
30
|
+
export function isSelectionFailure(result) {
|
|
31
|
+
return "error" in result;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Normalise what the client sent.
|
|
35
|
+
*
|
|
36
|
+
* Sending the field at all is the request for a subset; its length is not. An
|
|
37
|
+
* empty array means "no pages", which is a real thing to ask for — the publish
|
|
38
|
+
* dialog can have every page unticked and the site header ticked — and the two
|
|
39
|
+
* mistakes are not symmetrical: reading `[]` as "everything" ships pages the
|
|
40
|
+
* user just unticked, while reading it as "no pages" republishes what is
|
|
41
|
+
* already live and changes nothing. Only an absent or malformed field means
|
|
42
|
+
* the whole draft, which is what every caller meant before this existed.
|
|
43
|
+
*/
|
|
44
|
+
export function parseSelectionSlugs(raw) {
|
|
45
|
+
if (!Array.isArray(raw))
|
|
46
|
+
return undefined;
|
|
47
|
+
const slugs = raw.filter((s) => typeof s === "string" && s.trim().length > 0).map((s) => s.trim());
|
|
48
|
+
return Array.from(new Set(slugs));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the page set and config for a publish that covers only `slugs`.
|
|
52
|
+
*
|
|
53
|
+
* `published` is what the live site currently holds. `null`/`undefined` means
|
|
54
|
+
* the caller could not find out, which is a refusal rather than an empty site
|
|
55
|
+
* — see the note at the top of this file.
|
|
56
|
+
*/
|
|
57
|
+
export function selectPagesForPublish(opts) {
|
|
58
|
+
const { draft, published, draftSiteConfig, publishedSiteConfig, selection } = opts;
|
|
59
|
+
const slugs = selection.slugs;
|
|
60
|
+
// No subset asked for: the full draft, unchanged, exactly as before.
|
|
61
|
+
if (!slugs) {
|
|
62
|
+
return { pages: [...draft], siteConfig: draftSiteConfig, selectedSlugs: draft.map((p) => p.slug), removedSlugs: [] };
|
|
63
|
+
}
|
|
64
|
+
if (!published) {
|
|
65
|
+
return {
|
|
66
|
+
error: "Cannot publish selected pages without knowing what is currently live. " +
|
|
67
|
+
"Publish everything, or retry once the published site is reachable."
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const draftBySlug = new Map(draft.map((page) => [page.slug, page]));
|
|
71
|
+
const publishedBySlug = new Map(published.map((page) => [page.slug, page]));
|
|
72
|
+
const unknown = slugs.filter((slug) => !draftBySlug.has(slug) && !publishedBySlug.has(slug));
|
|
73
|
+
if (unknown.length > 0) {
|
|
74
|
+
return { error: `Unknown page${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}` };
|
|
75
|
+
}
|
|
76
|
+
const selected = new Set(slugs);
|
|
77
|
+
const pages = [];
|
|
78
|
+
const removedSlugs = [];
|
|
79
|
+
/*
|
|
80
|
+
* Live pages first, in the order the live site has them, so an unselected
|
|
81
|
+
* page keeps its position as well as its content. A selected page that the
|
|
82
|
+
* draft no longer has is a deletion the user ticked: drop it here and record
|
|
83
|
+
* it, so the summary can say a page came down rather than silently omitting
|
|
84
|
+
* it.
|
|
85
|
+
*/
|
|
86
|
+
for (const page of published) {
|
|
87
|
+
if (!selected.has(page.slug)) {
|
|
88
|
+
pages.push(page);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const draftPage = draftBySlug.get(page.slug);
|
|
92
|
+
if (draftPage)
|
|
93
|
+
pages.push(draftPage);
|
|
94
|
+
else
|
|
95
|
+
removedSlugs.push(page.slug);
|
|
96
|
+
}
|
|
97
|
+
// Selected pages the live site does not have yet, in draft order.
|
|
98
|
+
for (const page of draft) {
|
|
99
|
+
if (!selected.has(page.slug))
|
|
100
|
+
continue;
|
|
101
|
+
if (publishedBySlug.has(page.slug))
|
|
102
|
+
continue;
|
|
103
|
+
pages.push(page);
|
|
104
|
+
}
|
|
105
|
+
/*
|
|
106
|
+
* Leaving the header out has to mean shipping the header that is live, not
|
|
107
|
+
* shipping nothing: the targets take a config, and an absent one reads as
|
|
108
|
+
* "clear the site chrome". When the published config is unknown the draft's
|
|
109
|
+
* is the only one there is — the same value a full publish would send.
|
|
110
|
+
*/
|
|
111
|
+
const siteConfig = selection.includeSiteConfig === false ? publishedSiteConfig ?? draftSiteConfig : draftSiteConfig;
|
|
112
|
+
return { pages, siteConfig, selectedSlugs: [...slugs], removedSlugs };
|
|
113
|
+
}
|
|
@@ -10,7 +10,7 @@ import { DeployHookPublishTarget } from "./targets/deploy-hook.js";
|
|
|
10
10
|
* handling requests:
|
|
11
11
|
*
|
|
12
12
|
* ```ts
|
|
13
|
-
* import { registerPublishTarget } from "
|
|
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
|
|
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
|
|
@@ -10,8 +10,8 @@ import { publishViaGit } from "../publish-helpers.js";
|
|
|
10
10
|
export class GitPublishTarget {
|
|
11
11
|
name = "git";
|
|
12
12
|
async publish(ctx) {
|
|
13
|
-
const { session, scopedSession, slugs } = ctx;
|
|
14
|
-
const result = await publishViaGit(scopedSession);
|
|
13
|
+
const { session, scopedSession, slugs, pages, siteConfig } = ctx;
|
|
14
|
+
const result = await publishViaGit(scopedSession, { pages, siteConfig });
|
|
15
15
|
const now = new Date().toISOString();
|
|
16
16
|
const tracker = {
|
|
17
17
|
session,
|
|
@@ -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) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The documents this site holds, for the parts of the process that cannot ask.
|
|
3
|
+
*
|
|
4
|
+
* The list comes from `CmsAdapter.getMedia`, and getting it is IO. Two very
|
|
5
|
+
* different consumers need it and neither has an adapter in scope:
|
|
6
|
+
*
|
|
7
|
+
* - `content.file-link-unknown`, which turns a hand-typed PDF path into
|
|
8
|
+
* something verifiable, and which is a pure function by construction;
|
|
9
|
+
* - the planner's site context, so "link the winter menu" can resolve to a
|
|
10
|
+
* path that exists instead of a plausible-looking invention.
|
|
11
|
+
*
|
|
12
|
+
* So it is registered once per process, which is what an adapter already is:
|
|
13
|
+
* `createOrchestrator` sets it when the host implements `getMedia`, and the
|
|
14
|
+
* standalone multi-site orchestrator — which wires no adapter — never does.
|
|
15
|
+
*
|
|
16
|
+
* Unregistered answers `undefined`, and that is load-bearing everywhere it is
|
|
17
|
+
* read. `undefined` is "this site cannot list its documents", under which no
|
|
18
|
+
* rule may call a link broken; `[]` is "it listed them and has none", under
|
|
19
|
+
* which every document link really is broken. Collapsing the two would report
|
|
20
|
+
* every document on every site without a media library as missing.
|
|
21
|
+
*/
|
|
22
|
+
export type SiteAssetRecord = {
|
|
23
|
+
path: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
contentType?: string;
|
|
26
|
+
size?: number;
|
|
27
|
+
};
|
|
28
|
+
type AssetLister = () => Promise<SiteAssetRecord[]>;
|
|
29
|
+
/** Registered by `createOrchestrator` when the site's adapter can list files. */
|
|
30
|
+
export declare function setSiteAssetLister(lister: AssetLister | null): void;
|
|
31
|
+
/** Drop the cache — called after an upload, so a new file is visible at once. */
|
|
32
|
+
export declare function invalidateSiteAssets(): void;
|
|
33
|
+
/**
|
|
34
|
+
* The site's documents, or `undefined` if it cannot say.
|
|
35
|
+
*
|
|
36
|
+
* A lister that throws also answers `undefined`: a media library that is *down*
|
|
37
|
+
* must not read as a media library that is *empty*, or one outage becomes a
|
|
38
|
+
* finding on every document link on the site.
|
|
39
|
+
*/
|
|
40
|
+
export declare function getSiteAssets(now?: () => number): Promise<SiteAssetRecord[] | undefined>;
|
|
41
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
let listSiteAssets = null;
|
|
2
|
+
/** Registered by `createOrchestrator` when the site's adapter can list files. */
|
|
3
|
+
export function setSiteAssetLister(lister) {
|
|
4
|
+
listSiteAssets = lister;
|
|
5
|
+
cache = null;
|
|
6
|
+
}
|
|
7
|
+
/*
|
|
8
|
+
* A publish fans out one check run and an apply debounces into another, and a
|
|
9
|
+
* chat turn asks as well; going to the CMS three times in ten seconds is waste
|
|
10
|
+
* the site pays for. Short enough that a document uploaded mid-session is
|
|
11
|
+
* picked up almost immediately — and an upload clears it outright, so the file
|
|
12
|
+
* somebody just added is linkable in the same breath.
|
|
13
|
+
*/
|
|
14
|
+
const CACHE_MS = 30_000;
|
|
15
|
+
let cache = null;
|
|
16
|
+
/** Drop the cache — called after an upload, so a new file is visible at once. */
|
|
17
|
+
export function invalidateSiteAssets() {
|
|
18
|
+
cache = null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The site's documents, or `undefined` if it cannot say.
|
|
22
|
+
*
|
|
23
|
+
* A lister that throws also answers `undefined`: a media library that is *down*
|
|
24
|
+
* must not read as a media library that is *empty*, or one outage becomes a
|
|
25
|
+
* finding on every document link on the site.
|
|
26
|
+
*/
|
|
27
|
+
export async function getSiteAssets(now = Date.now) {
|
|
28
|
+
if (!listSiteAssets)
|
|
29
|
+
return undefined;
|
|
30
|
+
if (cache && now() - cache.at < CACHE_MS)
|
|
31
|
+
return cache.assets;
|
|
32
|
+
try {
|
|
33
|
+
const assets = await listSiteAssets();
|
|
34
|
+
cache = { at: now(), assets };
|
|
35
|
+
return assets;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avocadostudio-ai/orchestrator-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./package.json": "./package.json",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"openai": "^4.87.1",
|
|
24
24
|
"sharp": "^0.34.5",
|
|
25
25
|
"zod": "^4.3.6",
|
|
26
|
-
"@avocadostudio-ai/migration-sdk": "^0.
|
|
27
|
-
"@avocadostudio-ai/shared": "^0.
|
|
26
|
+
"@avocadostudio-ai/migration-sdk": "^0.4.0",
|
|
27
|
+
"@avocadostudio-ai/shared": "^0.4.0"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"@google/genai": "^1.46.0",
|