@avocadostudio-ai/orchestrator-core 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-logger.js +2 -1
- package/dist/chat/chat-pipeline.js +16 -1
- package/dist/chat/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/checks/field-walk.d.ts +18 -1
- package/dist/checks/field-walk.js +46 -0
- package/dist/checks/rules-draft.js +79 -15
- package/dist/checks/run-checks.d.ts +11 -1
- package/dist/checks/run-checks.js +11 -4
- package/dist/checks/session-runner.js +4 -0
- package/dist/checks/types.d.ts +44 -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/handler/create-orchestrator.js +247 -34
- package/dist/handler/library-mount.d.ts +6 -0
- package/dist/handler/library-mount.js +45 -0
- package/dist/http/history-actions.d.ts +43 -0
- package/dist/http/history-actions.js +122 -0
- package/dist/http/publish-actions.d.ts +11 -0
- package/dist/http/publish-actions.js +3 -3
- package/dist/image/image-helpers.js +3 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +12 -26
- package/dist/publish/publish-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +12 -4
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/data-dir.d.ts +6 -0
- package/dist/state/data-dir.js +35 -0
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/dist/state/sqlite-store-singleton.js +3 -17
- package/package.json +3 -3
|
@@ -35,6 +35,16 @@ export type PublishedContent = {
|
|
|
35
35
|
pages: PageDoc[];
|
|
36
36
|
siteConfig?: SiteConfig | null;
|
|
37
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* Where the published side came from, in descending order of trust.
|
|
40
|
+
*
|
|
41
|
+
* A diff can be computed from any of them — a wrong diff only misreports. A
|
|
42
|
+
* *partial* publish cannot: it merges the unselected pages back out of this
|
|
43
|
+
* baseline and ships them, so a baseline that is really the startup demo seed
|
|
44
|
+
* would overwrite the live site with demo content. `"memory"` therefore
|
|
45
|
+
* disqualifies a subset publish; see `publish-selection.ts`.
|
|
46
|
+
*/
|
|
47
|
+
export type PublishedPagesSource = "site" | "file" | "memory";
|
|
38
48
|
/**
|
|
39
49
|
* Where the published side comes from. The monorepo answers it from the site
|
|
40
50
|
* app; a library-mode consumer answers it from its CMS adapter, whose
|
|
@@ -65,6 +75,7 @@ export declare function loadPublishedForDiff(opts: {
|
|
|
65
75
|
}): Promise<{
|
|
66
76
|
pages: PageDoc[];
|
|
67
77
|
siteConfig: SiteConfig | null;
|
|
78
|
+
source: PublishedPagesSource;
|
|
68
79
|
}>;
|
|
69
80
|
/**
|
|
70
81
|
* The default source: the four-step monorepo chain above, unchanged, so the
|
|
@@ -91,7 +91,7 @@ export async function loadPublishedForDiff(opts) {
|
|
|
91
91
|
}
|
|
92
92
|
// Hot path: remote returned both pages and siteConfig.
|
|
93
93
|
if (remotePages && remoteSiteConfig) {
|
|
94
|
-
return { pages: remotePages, siteConfig: remoteSiteConfig };
|
|
94
|
+
return { pages: remotePages, siteConfig: remoteSiteConfig, source: "site" };
|
|
95
95
|
}
|
|
96
96
|
// Either the remote didn't run, didn't include siteConfig (older SDK or
|
|
97
97
|
// site dev not yet restarted), or didn't include pages. Read the JSON file
|
|
@@ -101,9 +101,9 @@ export async function loadPublishedForDiff(opts) {
|
|
|
101
101
|
const pages = remotePages ?? fromFile.pages;
|
|
102
102
|
const siteConfig = remoteSiteConfig ?? fromFile.siteConfig;
|
|
103
103
|
if (pages)
|
|
104
|
-
return { pages, siteConfig };
|
|
104
|
+
return { pages, siteConfig, source: remotePages ? "site" : "file" };
|
|
105
105
|
logger.warn("publish/diff: falling back to in-memory publishedPages — diff may be inaccurate");
|
|
106
|
-
return { pages: Array.from(publishedPages.values()), siteConfig };
|
|
106
|
+
return { pages: Array.from(publishedPages.values()), siteConfig, source: "memory" };
|
|
107
107
|
}
|
|
108
108
|
/**
|
|
109
109
|
* The default source: the four-step monorepo chain above, unchanged, so the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import OpenAI from "openai";
|
|
4
|
+
import { resolveDataDir } from "../state/data-dir.js";
|
|
4
5
|
import { listImages, fileNameToAlt, resolveGdriveFolderId } from "./gdrive-client.js";
|
|
5
6
|
// ---------------------------------------------------------------------------
|
|
6
7
|
// Image generation timing — rolling average for progress estimation
|
|
@@ -218,7 +219,7 @@ export async function generateVariationImageWithOpenAI(args) {
|
|
|
218
219
|
const background = args.background ?? "auto";
|
|
219
220
|
const outputFormat = args.outputFormat ?? "png";
|
|
220
221
|
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
221
|
-
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(
|
|
222
|
+
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(resolveDataDir(), "generated-images");
|
|
222
223
|
const orchestratorPublicOrigin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
|
|
223
224
|
args.log?.info({ event: "openai_image_start", model, size, background, outputFormat, promptLength: args.prompt.length }, "Starting OpenAI image generation");
|
|
224
225
|
const genStartMs = Date.now();
|
|
@@ -270,7 +271,7 @@ export async function generateVariationImageWithOpenAI(args) {
|
|
|
270
271
|
// Shared image save utility
|
|
271
272
|
// ---------------------------------------------------------------------------
|
|
272
273
|
export async function saveGeneratedImage(bytes, prefix = "gen", ext = "png") {
|
|
273
|
-
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(
|
|
274
|
+
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ?? resolve(resolveDataDir(), "generated-images");
|
|
274
275
|
const orchestratorPublicOrigin = (process.env.ORCHESTRATOR_PUBLIC_ORIGIN ?? "http://localhost:4200").replace(/\/+$/, "");
|
|
275
276
|
const fileName = `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.${ext}`;
|
|
276
277
|
await mkdir(generatedImageDir, { recursive: true });
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { createOrchestrator, type CreateOrchestratorConfig, type OrchestratorHandler } from "./handler/create-orchestrator.ts";
|
|
2
2
|
export type { OrchestratorAuth, AuthContext } from "./handler/auth.ts";
|
|
3
|
-
export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, ResolvedCapabilities } from "./cms/adapter.ts";
|
|
4
|
-
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaSourceConfig } from "./cms/index.ts";
|
|
3
|
+
export type { CmsAdapter, CmsCapabilities, CmsInlineAsset, CmsPublishContext, CmsPublishResult, CmsPerspective, CmsReadOptions, CmsMediaItem, CmsMediaPage, CmsMediaQuery, CmsMediaUpload, ResolvedCapabilities } from "./cms/adapter.ts";
|
|
4
|
+
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel, type JsonFileAdapterOptions, type EditorApiAdapterOptions, type CmsMediaSource, type CmsMediaUploader, type CmsMediaSourceConfig } from "./cms/index.ts";
|
|
5
5
|
export { registerPublishTarget, selectPublishTarget, getPublishTarget, listPublishTargets } from "./publish/publish-target-registry.ts";
|
|
6
6
|
export type { PublishTarget, PublishContext, PublishOutcome, PublishStatus, PublishResult } from "./publish/publish-target.ts";
|
|
7
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";
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// The Fastify HTTP wrapper lives in apps/orchestrator and imports from here.
|
|
20
20
|
export { createOrchestrator } from "./handler/create-orchestrator.js";
|
|
21
|
-
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "./cms/index.js";
|
|
21
|
+
export { jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, cmsMediaLabel } from "./cms/index.js";
|
|
22
22
|
// The publish-target plugin point. `docs-site/integration/publishing.mdx` has
|
|
23
23
|
// documented this as the way to publish somewhere we do not ship a target for
|
|
24
24
|
// since before the package had an export map, and told the reader to import it
|
|
@@ -282,12 +282,28 @@ export declare function adviceResponse(args: {
|
|
|
282
282
|
};
|
|
283
283
|
export declare function plannerMessageWithPendingContext(session: string, message: string): string;
|
|
284
284
|
/** Build the site context lines without wrapping in a message. Returns null if empty. */
|
|
285
|
+
/** How many documents ride along in a planner request. */
|
|
286
|
+
export declare const SITE_CONTEXT_DOCUMENT_CAP = 60;
|
|
285
287
|
export declare function buildSiteContextBlock(args?: {
|
|
286
288
|
sitePurpose?: string;
|
|
287
289
|
siteHosting?: string;
|
|
288
290
|
businessContext?: ChatRequestBody["businessContext"];
|
|
289
291
|
siteContext?: ChatRequestBody["siteContext"];
|
|
290
292
|
pageDirectory?: string;
|
|
293
|
+
/**
|
|
294
|
+
* The site's downloadable documents, so the planner can link one.
|
|
295
|
+
*
|
|
296
|
+
* Without it, "link the winter menu" is unanswerable: the model has the
|
|
297
|
+
* page list and the block schemas and no idea the site holds fifteen PDFs,
|
|
298
|
+
* so it invents a plausible path — which is exactly how a link to a
|
|
299
|
+
* misspelled filename gets written in the first place. Listed by path and
|
|
300
|
+
* name, because the path is what goes in the link and the name is what the
|
|
301
|
+
* user said.
|
|
302
|
+
*/
|
|
303
|
+
documents?: Array<{
|
|
304
|
+
path: string;
|
|
305
|
+
name?: string;
|
|
306
|
+
}>;
|
|
291
307
|
}): string | null;
|
|
292
308
|
export declare function withSiteContext(message: string, args?: {
|
|
293
309
|
sitePurpose?: string;
|
|
@@ -492,6 +492,8 @@ function normalizeConstraintList(value) {
|
|
|
492
492
|
return [];
|
|
493
493
|
}
|
|
494
494
|
/** Build the site context lines without wrapping in a message. Returns null if empty. */
|
|
495
|
+
/** How many documents ride along in a planner request. */
|
|
496
|
+
export const SITE_CONTEXT_DOCUMENT_CAP = 60;
|
|
495
497
|
export function buildSiteContextBlock(args) {
|
|
496
498
|
const businessContext = parseJsonObjectMaybe(args?.businessContext);
|
|
497
499
|
const siteContext = parseJsonObjectMaybe(args?.siteContext);
|
|
@@ -516,7 +518,19 @@ export function buildSiteContextBlock(args) {
|
|
|
516
518
|
constraints.length > 0 ? `Constraints: ${constraints.join("; ")}` : null,
|
|
517
519
|
siteName ? `Site name: ${siteName}` : null,
|
|
518
520
|
pageTemplates.length > 0 ? `Page templates:\n${pageTemplates.join("\n")}` : null,
|
|
519
|
-
args?.pageDirectory ? `Pages:\n${args.pageDirectory}` : null
|
|
521
|
+
args?.pageDirectory ? `Pages:\n${args.pageDirectory}` : null,
|
|
522
|
+
/*
|
|
523
|
+
* Capped. A media library can hold hundreds of files and this rides in
|
|
524
|
+
* every planner request; past the cap the model sees a partial list, which
|
|
525
|
+
* makes it worse at finding an obscure document but never wrong about the
|
|
526
|
+
* ones it does see.
|
|
527
|
+
*/
|
|
528
|
+
args?.documents && args.documents.length > 0
|
|
529
|
+
? `Documents the site hosts (link by path, never invent one):\n${args.documents
|
|
530
|
+
.slice(0, SITE_CONTEXT_DOCUMENT_CAP)
|
|
531
|
+
.map((d) => (d.name ? `- ${d.name} — ${d.path}` : `- ${d.path}`))
|
|
532
|
+
.join("\n")}`
|
|
533
|
+
: null
|
|
520
534
|
].filter((line) => Boolean(line));
|
|
521
535
|
if (lines.length === 0)
|
|
522
536
|
return null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { allowedBlockTypes, blockSchemas, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType
|
|
1
|
+
import { allowedBlockTypes, blockAcceptsProp, blockListItemAcceptsKey, blockSchemas, declaredDefaultPropsForType, defaultPropsForType as sharedDefaultPropsForType } from "@avocadostudio-ai/shared";
|
|
2
2
|
import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parseCreatePageRequest } from "./intent-helpers.js";
|
|
3
3
|
// ---------------------------------------------------------------------------
|
|
4
4
|
// Prop-name aliasing, asked of the registry rather than of a literal
|
|
@@ -18,23 +18,14 @@ import { extractRouteMentions, firstRouteMention, normalizeRouteCandidate, parse
|
|
|
18
18
|
* wrong about its own job; the first link was answering a question about a name
|
|
19
19
|
* instead of about a schema.
|
|
20
20
|
*
|
|
21
|
-
* So ask the registry
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* So ask the registry — `blockAcceptsProp`, which lives in `shared` because the
|
|
22
|
+
* planner prompt needs the same answer before it tells the model a prop name is
|
|
23
|
+
* wrong. A rename requires positive evidence in both directions: the block
|
|
24
|
+
* cannot take the key the planner used, and can take the one we would rewrite
|
|
25
|
+
* it to. Every other case — unknown block type, a block that accepts both, a
|
|
26
|
+
* block that accepts neither — leaves the value alone, which is the answer that
|
|
27
|
+
* loses no data.
|
|
26
28
|
*/
|
|
27
|
-
function blockAcceptsProp(blockType, prop) {
|
|
28
|
-
if (!blockType)
|
|
29
|
-
return false;
|
|
30
|
-
const meta = getBlockMeta(blockType);
|
|
31
|
-
if (meta?.fields && prop in meta.fields)
|
|
32
|
-
return true;
|
|
33
|
-
// Manifest-registered blocks may carry a schema richer than their derived
|
|
34
|
-
// meta, so the schema gets the second look rather than the first refusal.
|
|
35
|
-
const shape = blockSchemas[blockType]?.shape;
|
|
36
|
-
return Boolean(shape && prop in shape);
|
|
37
|
-
}
|
|
38
29
|
/*
|
|
39
30
|
* Avocado's own `autoplay` / `loop` / `striped` are string enums ("true" /
|
|
40
31
|
* "false"), not booleans, so a model that emits a real boolean has to be
|
|
@@ -86,16 +77,11 @@ function shouldAliasProp(blockType, from, to) {
|
|
|
86
77
|
return !blockAcceptsProp(blockType, from) && blockAcceptsProp(blockType, to);
|
|
87
78
|
}
|
|
88
79
|
/*
|
|
89
|
-
* The same question for a key inside a list item
|
|
90
|
-
*
|
|
91
|
-
*
|
|
80
|
+
* The same question for a key inside a list item — also shared, for the same
|
|
81
|
+
* reason. A block with no declared list metadata answers "no" to both halves
|
|
82
|
+
* and is therefore left alone.
|
|
92
83
|
*/
|
|
93
|
-
|
|
94
|
-
if (!blockType)
|
|
95
|
-
return false;
|
|
96
|
-
const itemFields = getBlockMeta(blockType)?.listFields?.[listKey]?.itemFields;
|
|
97
|
-
return Boolean(itemFields && itemKey in itemFields);
|
|
98
|
-
}
|
|
84
|
+
const listItemAcceptsKey = blockListItemAcceptsKey;
|
|
99
85
|
function shouldAliasItemKey(blockType, listKey, from, to) {
|
|
100
86
|
return (!listItemAcceptsKey(blockType, listKey, from) && listItemAcceptsKey(blockType, listKey, to));
|
|
101
87
|
}
|
|
@@ -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[];
|
|
@@ -4,6 +4,7 @@ import { existsSync } from "node:fs";
|
|
|
4
4
|
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
6
|
import { resolve } from "node:path";
|
|
7
|
+
import { resolveDataDir } from "../state/data-dir.js";
|
|
7
8
|
import { pageDocSchema } from "@avocadostudio-ai/shared";
|
|
8
9
|
import { draftPages, versions, ensureHeroImageProps, persistStateNow, getSessionPages, getSiteConfig, isLegacySiteId } from "../state/session-state.js";
|
|
9
10
|
import { toErrorDetail } from "../ops/ops-engine.js";
|
|
@@ -392,13 +393,20 @@ function sanitizeBranch(input) {
|
|
|
392
393
|
const trimmed = input.trim();
|
|
393
394
|
return trimmed.length > 0 ? trimmed : "main";
|
|
394
395
|
}
|
|
395
|
-
|
|
396
|
+
/**
|
|
397
|
+
* `content` is what the route decided to publish. It defaults to the whole
|
|
398
|
+
* session draft, which is what this read for itself before the argument
|
|
399
|
+
* existed and what a full publish still computes. A partial publish passes the
|
|
400
|
+
* merged set instead — live site plus the selected pages — and re-reading the
|
|
401
|
+
* draft here would quietly publish everything anyway.
|
|
402
|
+
*/
|
|
403
|
+
export async function publishViaGit(session, content) {
|
|
396
404
|
const repoRoot = resolve(process.cwd(), "../..");
|
|
397
405
|
const targetPath = "apps/site/lib/published-content.json";
|
|
398
406
|
const absoluteTargetPath = resolve(repoRoot, targetPath);
|
|
399
407
|
const branch = sanitizeBranch(process.env.PUBLISH_GIT_BRANCH ?? "main");
|
|
400
408
|
const strict = process.env.PUBLISH_GIT_STRICT === "1";
|
|
401
|
-
let pages = getSessionPages(session);
|
|
409
|
+
let pages = content?.pages ?? getSessionPages(session);
|
|
402
410
|
const slugs = pages.map((page) => page.slug);
|
|
403
411
|
// Rewrite localhost image URLs → relative paths and copy files into public/
|
|
404
412
|
const imageUrlMap = findLocalhostImageUrls(pages);
|
|
@@ -406,7 +414,7 @@ export async function publishViaGit(session) {
|
|
|
406
414
|
let copiedImages = false;
|
|
407
415
|
if (imageUrlMap.size > 0) {
|
|
408
416
|
const generatedImageDir = process.env.ORCHESTRATOR_GENERATED_IMAGE_DIR ??
|
|
409
|
-
resolve(
|
|
417
|
+
resolve(resolveDataDir(), "generated-images");
|
|
410
418
|
await mkdir(imageDestDir, { recursive: true });
|
|
411
419
|
for (const [, fileName] of imageUrlMap) {
|
|
412
420
|
const src = resolve(generatedImageDir, fileName);
|
|
@@ -421,7 +429,7 @@ export async function publishViaGit(session) {
|
|
|
421
429
|
}
|
|
422
430
|
pages = rewriteImageUrlsInPages(pages, imageUrlMap);
|
|
423
431
|
}
|
|
424
|
-
const siteConfig = getSiteConfig(session);
|
|
432
|
+
const siteConfig = content?.siteConfig ?? getSiteConfig(session);
|
|
425
433
|
const payload = `${JSON.stringify({ pages, siteConfig }, null, 2)}\n`;
|
|
426
434
|
await writeFile(absoluteTargetPath, payload, "utf8");
|
|
427
435
|
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,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,
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare function isLegacyMonorepoLayout(cwd?: string): boolean;
|
|
2
|
+
/**
|
|
3
|
+
* The `.data` directory for this process, or the legacy monorepo one when this
|
|
4
|
+
* really is a package inside the monorepo and that directory already exists.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveDataDir(cwd?: string): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
/*
|
|
4
|
+
* Where this process keeps its own files: the database, generated images, the
|
|
5
|
+
* agent log.
|
|
6
|
+
*
|
|
7
|
+
* The answer is `<cwd>/.data`. It used to be `<cwd>/../../.data`, which is
|
|
8
|
+
* `apps/orchestrator`'s position in *this* repo and nobody else's — a
|
|
9
|
+
* library-mode host runs from its own project root, so it wrote two directories
|
|
10
|
+
* above itself, outside the project and outside version control, into a
|
|
11
|
+
* directory shared with every sibling checkout that made the same mistake.
|
|
12
|
+
*
|
|
13
|
+
* The legacy location is still honoured for an existing monorepo checkout, but
|
|
14
|
+
* only on proof of workspace membership. The first version of that back-compat
|
|
15
|
+
* check asked `existsSync(legacy)` alone — "has anyone ever created that file",
|
|
16
|
+
* not "am I a package in the workspace that owns it" — so one mistaken write
|
|
17
|
+
* captured every unrelated project under that parent, permanently, because the
|
|
18
|
+
* bug is what created the file that re-triggered it.
|
|
19
|
+
*/
|
|
20
|
+
export function isLegacyMonorepoLayout(cwd = process.cwd()) {
|
|
21
|
+
return (existsSync(resolve(cwd, "../../pnpm-workspace.yaml")) &&
|
|
22
|
+
existsSync(resolve(cwd, "package.json")));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The `.data` directory for this process, or the legacy monorepo one when this
|
|
26
|
+
* really is a package inside the monorepo and that directory already exists.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveDataDir(cwd = process.cwd()) {
|
|
29
|
+
if (isLegacyMonorepoLayout(cwd)) {
|
|
30
|
+
const legacy = resolve(cwd, "../../.data");
|
|
31
|
+
if (existsSync(legacy))
|
|
32
|
+
return legacy;
|
|
33
|
+
}
|
|
34
|
+
return resolve(cwd, ".data");
|
|
35
|
+
}
|
|
@@ -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
|
+
}
|