@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/sites-agent-context.js +3 -2
- package/dist/agent/sites-agent-shared.js +1 -0
- package/dist/chat/anthropic-planner.js +3 -3
- package/dist/chat/chat-pipeline.js +122 -20
- package/dist/chat/gemini-planner.js +3 -3
- package/dist/chat/planner.js +7 -5
- package/dist/chat/prompts.js +6 -1
- package/dist/cms/adapter.d.ts +159 -1
- package/dist/cms/adapter.js +19 -1
- package/dist/cms/bootstrap.d.ts +46 -1
- package/dist/cms/bootstrap.js +126 -2
- package/dist/cms/index.d.ts +3 -2
- package/dist/cms/index.js +2 -1
- package/dist/errors.d.ts +9 -1
- package/dist/handler/auth.d.ts +79 -0
- package/dist/handler/auth.js +113 -0
- package/dist/handler/create-orchestrator.d.ts +205 -0
- package/dist/handler/create-orchestrator.js +1599 -0
- package/dist/http/access-tokens.d.ts +58 -0
- package/dist/http/access-tokens.js +161 -0
- package/dist/http/audio-actions.d.ts +121 -0
- package/dist/http/audio-actions.js +248 -0
- package/dist/http/blocks-actions.d.ts +31 -0
- package/dist/http/blocks-actions.js +31 -0
- package/dist/http/draft-provenance.d.ts +68 -0
- package/dist/http/draft-provenance.js +101 -0
- package/dist/http/history-actions.d.ts +58 -0
- package/dist/http/history-actions.js +169 -0
- package/dist/http/image-generate-actions.d.ts +268 -0
- package/dist/http/image-generate-actions.js +546 -0
- package/dist/http/ops-actions.d.ts +51 -0
- package/dist/http/ops-actions.js +79 -0
- package/dist/http/publish-actions.d.ts +153 -0
- package/dist/http/publish-actions.js +323 -0
- package/dist/http/restore-actions.d.ts +67 -0
- package/dist/http/restore-actions.js +145 -0
- package/dist/http/screenshot-actions.d.ts +108 -0
- package/dist/http/screenshot-actions.js +181 -0
- package/dist/http/session-actions.d.ts +35 -0
- package/dist/http/session-actions.js +98 -0
- package/dist/http/telemetry-feedback-actions.d.ts +53 -0
- package/dist/http/telemetry-feedback-actions.js +68 -0
- package/dist/http/unsplash-actions.d.ts +64 -0
- package/dist/http/unsplash-actions.js +81 -0
- package/dist/http/variations-actions.d.ts +102 -0
- package/dist/http/variations-actions.js +104 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +21 -1
- package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
- package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
- package/dist/nlp/deterministic-planner-suggestions.js +37 -11
- package/dist/nlp/plan-normalizer.js +18 -2
- package/dist/ops/ops-engine.js +219 -14
- package/dist/state/session-state.d.ts +56 -1
- package/dist/state/session-state.js +92 -6
- package/dist/state/sqlite-store-singleton.d.ts +22 -0
- package/dist/state/sqlite-store-singleton.js +49 -1
- package/dist/state/sqlite-store.d.ts +5 -0
- package/dist/state/sqlite-store.js +125 -2
- package/dist/telemetry/chat-telemetry.js +6 -1
- package/package.json +12 -16
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish status, publish diff and the publish log, as transport-agnostic
|
|
3
|
+
* actions.
|
|
4
|
+
*
|
|
5
|
+
* These lived only in `apps/orchestrator/src/routes/publishing.ts`, wired
|
|
6
|
+
* directly into Fastify. Library mode — `createOrchestrator()` in the site SDK
|
|
7
|
+
* — serves the same editor from a plain `Request`/`Response` handler and
|
|
8
|
+
* reimplements by hand whichever routes somebody remembered to add. It never
|
|
9
|
+
* had these three, so a library-mode editor polls `GET /publish/status` after
|
|
10
|
+
* every publish and gets "not handled by createOrchestrator()" back, and its
|
|
11
|
+
* publish-diff panel has nothing to render.
|
|
12
|
+
*
|
|
13
|
+
* As with `history-actions.ts` and `restore-actions.ts`, a second hand-kept
|
|
14
|
+
* copy would drift the way the first one did, so the logic lives here and both
|
|
15
|
+
* transports call it. Each function returns the status code and body to send;
|
|
16
|
+
* neither Fastify nor `Response` appears in this file.
|
|
17
|
+
*/
|
|
18
|
+
import type { PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
|
|
19
|
+
import type { PublishLogStatus } from "../state/session-state.js";
|
|
20
|
+
import { refreshPublishStatusFromVercel } from "../publish/publish-helpers.js";
|
|
21
|
+
import type { Logger } from "../logger.js";
|
|
22
|
+
import type { ActionResult } from "./history-actions.js";
|
|
23
|
+
export type { ActionResult };
|
|
24
|
+
/**
|
|
25
|
+
* What "the site as it is currently live" resolves to.
|
|
26
|
+
*
|
|
27
|
+
* `siteConfig: null` asserts the published site genuinely has no header
|
|
28
|
+
* config; `siteConfig` left absent means the source cannot tell. The
|
|
29
|
+
* distinction matters because `computePublishDiff` reads a missing published
|
|
30
|
+
* config as "the entire site chrome was just added" and reports every nav
|
|
31
|
+
* label as a change. A source that does not know must say so rather than
|
|
32
|
+
* guess null — see `publishDiffAction`.
|
|
33
|
+
*/
|
|
34
|
+
export type PublishedContent = {
|
|
35
|
+
pages: PageDoc[];
|
|
36
|
+
siteConfig?: SiteConfig | null;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Where the published side comes from. The monorepo answers it from the site
|
|
40
|
+
* app; a library-mode consumer answers it from its CMS adapter, whose
|
|
41
|
+
* `getPages()` is the only thing that knows what is actually live there.
|
|
42
|
+
*/
|
|
43
|
+
export type PublishedSource = {
|
|
44
|
+
load(opts: {
|
|
45
|
+
siteOrigin?: string;
|
|
46
|
+
logger: Logger;
|
|
47
|
+
}): Promise<PublishedContent>;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Load the authoritative published pages for diff computation.
|
|
51
|
+
*
|
|
52
|
+
* Priority (first hit wins):
|
|
53
|
+
* 1. `${siteOrigin}/api/editor/pages` — accurate in both dev and prod.
|
|
54
|
+
* 2. `PUBLISHED_CONTENT_PATH` env var — direct file read (ops override).
|
|
55
|
+
* 3. `apps/site/lib/published-content.json` — monorepo default for local dev.
|
|
56
|
+
* 4. In-memory `publishedPages` Map — stale demo seed, last resort.
|
|
57
|
+
*
|
|
58
|
+
* The in-memory Map is NOT authoritative: it is seeded from demo data at
|
|
59
|
+
* startup and never updated on publish. Relying on it produces bogus diffs
|
|
60
|
+
* (e.g. "all pages added" when the site already has them published).
|
|
61
|
+
*/
|
|
62
|
+
export declare function loadPublishedForDiff(opts: {
|
|
63
|
+
siteOrigin?: string;
|
|
64
|
+
logger: Logger;
|
|
65
|
+
}): Promise<{
|
|
66
|
+
pages: PageDoc[];
|
|
67
|
+
siteConfig: SiteConfig | null;
|
|
68
|
+
}>;
|
|
69
|
+
/**
|
|
70
|
+
* The default source: the four-step monorepo chain above, unchanged, so the
|
|
71
|
+
* Fastify routes keep answering exactly what they answered before.
|
|
72
|
+
*/
|
|
73
|
+
export declare const monorepoPublishedSource: PublishedSource;
|
|
74
|
+
/**
|
|
75
|
+
* Reject origins that point at private/loopback IPs or non-http protocols.
|
|
76
|
+
*
|
|
77
|
+
* `siteOrigin` arrives from the caller and is fetched server-side, so an
|
|
78
|
+
* unchecked value turns the orchestrator into an SSRF proxy onto whatever the
|
|
79
|
+
* host can reach. Local addresses stay allowed outside production because that
|
|
80
|
+
* is what dev *is*; in production they are allowed only when the deployment
|
|
81
|
+
* has already declared the origin trusted via `ORCHESTRATOR_CORS_ORIGINS`.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isSafeOrigin(raw: string): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Map a refreshed Vercel `vercelState` to a publish-log status. READY counts
|
|
86
|
+
* as success; ERROR/CANCELED count as failed. Any other state (BUILDING,
|
|
87
|
+
* QUEUED, INITIALIZING, UNKNOWN, …) leaves the row at `triggered`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function publishStatusFromVercelState(state: string | undefined): PublishLogStatus | null;
|
|
90
|
+
/**
|
|
91
|
+
* Build a short human-readable summary of what was published, biased toward
|
|
92
|
+
* *changes* rather than the full site contents. Falls back to "republished N
|
|
93
|
+
* pages" when no diff is available.
|
|
94
|
+
*
|
|
95
|
+
* Exported because both transports write publish-log rows and a log whose
|
|
96
|
+
* wording depends on which server wrote it is a log nobody can read.
|
|
97
|
+
*/
|
|
98
|
+
export declare function buildPublishSummary(opts: {
|
|
99
|
+
changedSlugs: string[];
|
|
100
|
+
removedSlugs: string[];
|
|
101
|
+
totalPages: number;
|
|
102
|
+
hasDiff: boolean;
|
|
103
|
+
}): string;
|
|
104
|
+
/**
|
|
105
|
+
* The publish history for a session, newest rows last.
|
|
106
|
+
*
|
|
107
|
+
* The limit is clamped rather than trusted: the rows carry per-page slugs and
|
|
108
|
+
* diff summaries, and an unbounded `?limit=` would let a caller pull the whole
|
|
109
|
+
* table into one response.
|
|
110
|
+
*/
|
|
111
|
+
export declare function publishLogAction(query: {
|
|
112
|
+
session?: string;
|
|
113
|
+
siteId?: string;
|
|
114
|
+
limit?: string | number;
|
|
115
|
+
}): ActionResult;
|
|
116
|
+
/**
|
|
117
|
+
* The Vercel poll, injectable so the status action is testable.
|
|
118
|
+
*
|
|
119
|
+
* The real one talks to `api.vercel.com` (or waits out a grace period when no
|
|
120
|
+
* token is configured), which a unit test can neither reach nor hurry.
|
|
121
|
+
*/
|
|
122
|
+
export type PublishStatusDeps = {
|
|
123
|
+
refresh: typeof refreshPublishStatusFromVercel;
|
|
124
|
+
};
|
|
125
|
+
/**
|
|
126
|
+
* Poll the deployment a publish kicked off, and mature its log row when the
|
|
127
|
+
* deployment finally resolves.
|
|
128
|
+
*
|
|
129
|
+
* A publish-log row is born "triggered" because `POST /publish` returns while
|
|
130
|
+
* the build is still running on the host's side. This poll is the only place
|
|
131
|
+
* that ever learns the outcome, so it is also where the row becomes "success"
|
|
132
|
+
* or "failed" — without that, every publish would stay pending in the history
|
|
133
|
+
* panel forever. The row is matched by deployment id where there is one, and
|
|
134
|
+
* only a still-`triggered` row is touched, so a re-poll after resolution
|
|
135
|
+
* cannot overwrite a finished row.
|
|
136
|
+
*
|
|
137
|
+
* The tracker lives in a non-persisted in-memory Map, so a session that has
|
|
138
|
+
* not published since the process started genuinely has no status: that is a
|
|
139
|
+
* 404, not an empty success.
|
|
140
|
+
*/
|
|
141
|
+
export declare function publishStatusAction(query: {
|
|
142
|
+
session?: string;
|
|
143
|
+
siteId?: string;
|
|
144
|
+
}, deps?: PublishStatusDeps): Promise<ActionResult>;
|
|
145
|
+
/**
|
|
146
|
+
* What the editor's "review changes" panel shows: the session draft against
|
|
147
|
+
* whatever is currently live.
|
|
148
|
+
*/
|
|
149
|
+
export declare function publishDiffAction(query: {
|
|
150
|
+
session?: string;
|
|
151
|
+
siteId?: string;
|
|
152
|
+
siteOrigin?: string;
|
|
153
|
+
}, log: Logger, source?: PublishedSource): Promise<ActionResult>;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish status, publish diff and the publish log, as transport-agnostic
|
|
3
|
+
* actions.
|
|
4
|
+
*
|
|
5
|
+
* These lived only in `apps/orchestrator/src/routes/publishing.ts`, wired
|
|
6
|
+
* directly into Fastify. Library mode — `createOrchestrator()` in the site SDK
|
|
7
|
+
* — serves the same editor from a plain `Request`/`Response` handler and
|
|
8
|
+
* reimplements by hand whichever routes somebody remembered to add. It never
|
|
9
|
+
* had these three, so a library-mode editor polls `GET /publish/status` after
|
|
10
|
+
* every publish and gets "not handled by createOrchestrator()" back, and its
|
|
11
|
+
* publish-diff panel has nothing to render.
|
|
12
|
+
*
|
|
13
|
+
* As with `history-actions.ts` and `restore-actions.ts`, a second hand-kept
|
|
14
|
+
* copy would drift the way the first one did, so the logic lives here and both
|
|
15
|
+
* transports call it. Each function returns the status code and body to send;
|
|
16
|
+
* neither Fastify nor `Response` appears in this file.
|
|
17
|
+
*/
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
import { normalizeSession, scopedSessionKey, getSessionPages, getSiteConfig, publishedPages, publishStatusBySession, listPublishLog, findPublishLogForStatusUpdate, updatePublishLogStatusById } from "../state/session-state.js";
|
|
21
|
+
import { computePublishDiff } from "../publish/diff-engine.js";
|
|
22
|
+
import { refreshPublishStatusFromVercel } from "../publish/publish-helpers.js";
|
|
23
|
+
const badRequest = (error) => ({ code: 400, body: { error } });
|
|
24
|
+
/**
|
|
25
|
+
* Read published content off disk.
|
|
26
|
+
*
|
|
27
|
+
* Priority (first hit wins):
|
|
28
|
+
* 1. `PUBLISHED_CONTENT_PATH` env var — direct file read (ops override).
|
|
29
|
+
* 2. `apps/site/lib/published-content.json` — monorepo default for local dev.
|
|
30
|
+
*/
|
|
31
|
+
async function readPublishedJsonFile() {
|
|
32
|
+
const paths = [];
|
|
33
|
+
if (process.env.PUBLISHED_CONTENT_PATH?.trim())
|
|
34
|
+
paths.push(process.env.PUBLISHED_CONTENT_PATH.trim());
|
|
35
|
+
// Monorepo default — orchestrator cwd is apps/orchestrator in dev.
|
|
36
|
+
paths.push(resolve(process.cwd(), "../../apps/site/lib/published-content.json"));
|
|
37
|
+
paths.push(resolve(process.cwd(), "../site/lib/published-content.json"));
|
|
38
|
+
for (const path of paths) {
|
|
39
|
+
try {
|
|
40
|
+
const raw = await readFile(path, "utf8");
|
|
41
|
+
const parsed = JSON.parse(raw);
|
|
42
|
+
if (Array.isArray(parsed)) {
|
|
43
|
+
return { pages: parsed, siteConfig: null };
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(parsed.pages)) {
|
|
46
|
+
return { pages: parsed.pages, siteConfig: parsed.siteConfig ?? null };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Try the next candidate.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { pages: null, siteConfig: null };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Load the authoritative published pages for diff computation.
|
|
57
|
+
*
|
|
58
|
+
* Priority (first hit wins):
|
|
59
|
+
* 1. `${siteOrigin}/api/editor/pages` — accurate in both dev and prod.
|
|
60
|
+
* 2. `PUBLISHED_CONTENT_PATH` env var — direct file read (ops override).
|
|
61
|
+
* 3. `apps/site/lib/published-content.json` — monorepo default for local dev.
|
|
62
|
+
* 4. In-memory `publishedPages` Map — stale demo seed, last resort.
|
|
63
|
+
*
|
|
64
|
+
* The in-memory Map is NOT authoritative: it is seeded from demo data at
|
|
65
|
+
* startup and never updated on publish. Relying on it produces bogus diffs
|
|
66
|
+
* (e.g. "all pages added" when the site already has them published).
|
|
67
|
+
*/
|
|
68
|
+
export async function loadPublishedForDiff(opts) {
|
|
69
|
+
const { siteOrigin, logger } = opts;
|
|
70
|
+
let remotePages = null;
|
|
71
|
+
let remoteSiteConfig = null;
|
|
72
|
+
if (siteOrigin) {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`${siteOrigin.replace(/\/+$/, "")}/api/editor/pages`, {
|
|
75
|
+
headers: { accept: "application/json" },
|
|
76
|
+
});
|
|
77
|
+
if (res.ok) {
|
|
78
|
+
const data = (await res.json());
|
|
79
|
+
if (Array.isArray(data.pages)) {
|
|
80
|
+
remotePages = data.pages;
|
|
81
|
+
remoteSiteConfig = data.siteConfig ?? null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
logger.warn({ siteOrigin, status: res.status }, "publish/diff: site pages endpoint not ok, falling back");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
logger.warn({ siteOrigin, err: String(err) }, "publish/diff: site fetch failed, falling back");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Hot path: remote returned both pages and siteConfig.
|
|
93
|
+
if (remotePages && remoteSiteConfig) {
|
|
94
|
+
return { pages: remotePages, siteConfig: remoteSiteConfig };
|
|
95
|
+
}
|
|
96
|
+
// Either the remote didn't run, didn't include siteConfig (older SDK or
|
|
97
|
+
// site dev not yet restarted), or didn't include pages. Read the JSON file
|
|
98
|
+
// to backfill the missing pieces — without it, a missing siteConfig would
|
|
99
|
+
// be misread as "header added · all fields" on every publish-diff load.
|
|
100
|
+
const fromFile = await readPublishedJsonFile();
|
|
101
|
+
const pages = remotePages ?? fromFile.pages;
|
|
102
|
+
const siteConfig = remoteSiteConfig ?? fromFile.siteConfig;
|
|
103
|
+
if (pages)
|
|
104
|
+
return { pages, siteConfig };
|
|
105
|
+
logger.warn("publish/diff: falling back to in-memory publishedPages — diff may be inaccurate");
|
|
106
|
+
return { pages: Array.from(publishedPages.values()), siteConfig };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The default source: the four-step monorepo chain above, unchanged, so the
|
|
110
|
+
* Fastify routes keep answering exactly what they answered before.
|
|
111
|
+
*/
|
|
112
|
+
export const monorepoPublishedSource = {
|
|
113
|
+
load: (opts) => loadPublishedForDiff(opts)
|
|
114
|
+
};
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Shared publish helpers (also used by the POST /publish handlers)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
/**
|
|
119
|
+
* Reject origins that point at private/loopback IPs or non-http protocols.
|
|
120
|
+
*
|
|
121
|
+
* `siteOrigin` arrives from the caller and is fetched server-side, so an
|
|
122
|
+
* unchecked value turns the orchestrator into an SSRF proxy onto whatever the
|
|
123
|
+
* host can reach. Local addresses stay allowed outside production because that
|
|
124
|
+
* is what dev *is*; in production they are allowed only when the deployment
|
|
125
|
+
* has already declared the origin trusted via `ORCHESTRATOR_CORS_ORIGINS`.
|
|
126
|
+
*/
|
|
127
|
+
export function isSafeOrigin(raw) {
|
|
128
|
+
let parsed;
|
|
129
|
+
try {
|
|
130
|
+
parsed = new URL(raw);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
136
|
+
return false;
|
|
137
|
+
const host = parsed.hostname;
|
|
138
|
+
const isPrivate = host === "localhost" ||
|
|
139
|
+
host === "127.0.0.1" ||
|
|
140
|
+
host === "[::1]" ||
|
|
141
|
+
host === "0.0.0.0" ||
|
|
142
|
+
host.startsWith("10.") ||
|
|
143
|
+
host.startsWith("192.168.") ||
|
|
144
|
+
host.startsWith("169.254.") ||
|
|
145
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
|
146
|
+
if (isPrivate) {
|
|
147
|
+
if (process.env.NODE_ENV !== "production")
|
|
148
|
+
return true;
|
|
149
|
+
const corsOrigins = (process.env.ORCHESTRATOR_CORS_ORIGINS ?? "").split(",").map((s) => s.trim().replace(/\/+$/, ""));
|
|
150
|
+
const normalized = parsed.origin;
|
|
151
|
+
if (corsOrigins.some((o) => o === normalized))
|
|
152
|
+
return true;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Map a refreshed Vercel `vercelState` to a publish-log status. READY counts
|
|
159
|
+
* as success; ERROR/CANCELED count as failed. Any other state (BUILDING,
|
|
160
|
+
* QUEUED, INITIALIZING, UNKNOWN, …) leaves the row at `triggered`.
|
|
161
|
+
*/
|
|
162
|
+
export function publishStatusFromVercelState(state) {
|
|
163
|
+
if (!state)
|
|
164
|
+
return null;
|
|
165
|
+
const upper = state.toUpperCase();
|
|
166
|
+
if (upper === "READY")
|
|
167
|
+
return "success";
|
|
168
|
+
if (upper === "ERROR" || upper === "CANCELED")
|
|
169
|
+
return "failed";
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
function slugToTitle(slug) {
|
|
173
|
+
return slug === "/" ? "Home" : slug.replace(/^\//, "");
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Build a short human-readable summary of what was published, biased toward
|
|
177
|
+
* *changes* rather than the full site contents. Falls back to "republished N
|
|
178
|
+
* pages" when no diff is available.
|
|
179
|
+
*
|
|
180
|
+
* Exported because both transports write publish-log rows and a log whose
|
|
181
|
+
* wording depends on which server wrote it is a log nobody can read.
|
|
182
|
+
*/
|
|
183
|
+
export function buildPublishSummary(opts) {
|
|
184
|
+
const { changedSlugs, removedSlugs, totalPages, hasDiff } = opts;
|
|
185
|
+
if (totalPages === 0 && removedSlugs.length === 0)
|
|
186
|
+
return "Published (no pages)";
|
|
187
|
+
if (!hasDiff) {
|
|
188
|
+
return `Republished ${totalPages} ${totalPages === 1 ? "page" : "pages"}`;
|
|
189
|
+
}
|
|
190
|
+
if (changedSlugs.length === 0 && removedSlugs.length === 0) {
|
|
191
|
+
return "Republished — no content changes";
|
|
192
|
+
}
|
|
193
|
+
const titles = changedSlugs.map(slugToTitle);
|
|
194
|
+
const removed = removedSlugs.map(slugToTitle);
|
|
195
|
+
const parts = [];
|
|
196
|
+
if (titles.length === 1)
|
|
197
|
+
parts.push(`Published ${titles[0]}`);
|
|
198
|
+
else if (titles.length === 2)
|
|
199
|
+
parts.push(`Published ${titles[0]} and ${titles[1]}`);
|
|
200
|
+
else if (titles.length === 3)
|
|
201
|
+
parts.push(`Published ${titles.join(", ")}`);
|
|
202
|
+
else if (titles.length > 0)
|
|
203
|
+
parts.push(`Published ${titles.slice(0, 2).join(", ")} +${titles.length - 2} more`);
|
|
204
|
+
if (removed.length === 1)
|
|
205
|
+
parts.push(`removed ${removed[0]}`);
|
|
206
|
+
else if (removed.length > 1)
|
|
207
|
+
parts.push(`removed ${removed.length} pages`);
|
|
208
|
+
return parts.join(", ") || `Republished ${totalPages} pages`;
|
|
209
|
+
}
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
// GET /publish/log
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
/**
|
|
214
|
+
* The publish history for a session, newest rows last.
|
|
215
|
+
*
|
|
216
|
+
* The limit is clamped rather than trusted: the rows carry per-page slugs and
|
|
217
|
+
* diff summaries, and an unbounded `?limit=` would let a caller pull the whole
|
|
218
|
+
* table into one response.
|
|
219
|
+
*/
|
|
220
|
+
export function publishLogAction(query) {
|
|
221
|
+
if (!query.session)
|
|
222
|
+
return badRequest("session is required");
|
|
223
|
+
const scopedSession = scopedSessionKey(normalizeSession(query.session), query.siteId);
|
|
224
|
+
const requested = typeof query.limit === "string" ? Number(query.limit) : query.limit;
|
|
225
|
+
const limit = typeof requested === "number" && Number.isFinite(requested) ? Math.max(1, Math.min(100, requested)) : 50;
|
|
226
|
+
return { code: 200, body: { entries: listPublishLog(scopedSession, limit) } };
|
|
227
|
+
}
|
|
228
|
+
const realStatusDeps = { refresh: refreshPublishStatusFromVercel };
|
|
229
|
+
/**
|
|
230
|
+
* Poll the deployment a publish kicked off, and mature its log row when the
|
|
231
|
+
* deployment finally resolves.
|
|
232
|
+
*
|
|
233
|
+
* A publish-log row is born "triggered" because `POST /publish` returns while
|
|
234
|
+
* the build is still running on the host's side. This poll is the only place
|
|
235
|
+
* that ever learns the outcome, so it is also where the row becomes "success"
|
|
236
|
+
* or "failed" — without that, every publish would stay pending in the history
|
|
237
|
+
* panel forever. The row is matched by deployment id where there is one, and
|
|
238
|
+
* only a still-`triggered` row is touched, so a re-poll after resolution
|
|
239
|
+
* cannot overwrite a finished row.
|
|
240
|
+
*
|
|
241
|
+
* The tracker lives in a non-persisted in-memory Map, so a session that has
|
|
242
|
+
* not published since the process started genuinely has no status: that is a
|
|
243
|
+
* 404, not an empty success.
|
|
244
|
+
*/
|
|
245
|
+
export async function publishStatusAction(query, deps = realStatusDeps) {
|
|
246
|
+
const scopedSession = scopedSessionKey(normalizeSession(query.session), query.siteId);
|
|
247
|
+
const current = publishStatusBySession.get(scopedSession);
|
|
248
|
+
/*
|
|
249
|
+
* Never having published is a state, not an error.
|
|
250
|
+
*
|
|
251
|
+
* This answered 404 with "no publish status for session", and an agent
|
|
252
|
+
* checking whether it was safe to publish read that as publishing being
|
|
253
|
+
* broken and stopped. It is neither: the tracker is an in-memory row about
|
|
254
|
+
* *this process's* last attempt, so it is empty before the first publish and
|
|
255
|
+
* again after every restart, on a site that publishes perfectly well.
|
|
256
|
+
*
|
|
257
|
+
* The reply says which question it is answering and names the two endpoints
|
|
258
|
+
* that answer the other ones — `/publish/diff` for what a publish would
|
|
259
|
+
* change, `/publish/log` for attempts that outlived this process.
|
|
260
|
+
*/
|
|
261
|
+
if (!current) {
|
|
262
|
+
return {
|
|
263
|
+
code: 200,
|
|
264
|
+
body: {
|
|
265
|
+
session: scopedSession,
|
|
266
|
+
status: "idle",
|
|
267
|
+
message: "No publish has been attempted for this session in this process. Publish status is held in memory, " +
|
|
268
|
+
"so it is also empty after a restart — it is not a report about whether this site can publish. " +
|
|
269
|
+
"GET /publish/diff for what a publish would change; GET /publish/log for earlier attempts."
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
const refreshed = await deps.refresh(current);
|
|
274
|
+
publishStatusBySession.set(scopedSession, refreshed);
|
|
275
|
+
// Falls back to the most-recent "triggered" row when the deployment id is
|
|
276
|
+
// missing (deploy-hook targets that don't parse one out of their response).
|
|
277
|
+
const terminalStatus = publishStatusFromVercelState(refreshed.vercelState);
|
|
278
|
+
if (terminalStatus) {
|
|
279
|
+
const target = findPublishLogForStatusUpdate(scopedSession, refreshed.deploymentId);
|
|
280
|
+
if (target && target.status === "triggered") {
|
|
281
|
+
updatePublishLogStatusById(target.id, {
|
|
282
|
+
status: terminalStatus,
|
|
283
|
+
deploymentId: refreshed.deploymentId ?? target.deploymentId,
|
|
284
|
+
deploymentUrl: refreshed.deploymentUrl ?? target.deploymentUrl,
|
|
285
|
+
inspectUrl: refreshed.inspectUrl ?? target.inspectUrl,
|
|
286
|
+
error: terminalStatus === "failed"
|
|
287
|
+
? refreshed.lastCheckError ?? `vercel_state_${refreshed.vercelState}`
|
|
288
|
+
: undefined,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { code: 200, body: refreshed };
|
|
293
|
+
}
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// GET /publish/diff
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
/**
|
|
298
|
+
* What the editor's "review changes" panel shows: the session draft against
|
|
299
|
+
* whatever is currently live.
|
|
300
|
+
*/
|
|
301
|
+
export async function publishDiffAction(query, log, source = monorepoPublishedSource) {
|
|
302
|
+
if (!query.session)
|
|
303
|
+
return badRequest("session is required");
|
|
304
|
+
const siteOrigin = typeof query.siteOrigin === "string" ? query.siteOrigin.trim().replace(/\/+$/, "") : "";
|
|
305
|
+
if (siteOrigin && !isSafeOrigin(siteOrigin)) {
|
|
306
|
+
log.warn({ siteOrigin, session: query.session, siteId: query.siteId }, "publish/diff: rejected siteOrigin");
|
|
307
|
+
return badRequest("siteOrigin is not an allowed URL");
|
|
308
|
+
}
|
|
309
|
+
const scopedSession = scopedSessionKey(normalizeSession(query.session), query.siteId);
|
|
310
|
+
const draft = getSessionPages(scopedSession);
|
|
311
|
+
const draftSiteConfig = getSiteConfig(scopedSession);
|
|
312
|
+
const published = await source.load({ siteOrigin: siteOrigin || undefined, logger: log });
|
|
313
|
+
// A source that cannot report the published site config (a CMS adapter has
|
|
314
|
+
// pages and nothing else) gets the draft's own config compared against
|
|
315
|
+
// itself, which reads as "unchanged". The alternative — passing null —
|
|
316
|
+
// would announce the entire site header as newly added on every load, which
|
|
317
|
+
// is worse than admitting we don't know.
|
|
318
|
+
const publishedSiteConfig = published.siteConfig === undefined ? draftSiteConfig : published.siteConfig;
|
|
319
|
+
return {
|
|
320
|
+
code: 200,
|
|
321
|
+
body: computePublishDiff(draft, published.pages, { draftSiteConfig, publishedSiteConfig })
|
|
322
|
+
};
|
|
323
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publish-snapshot restore — list, apply, delete — as transport-agnostic actions.
|
|
3
|
+
*
|
|
4
|
+
* These lived only in `apps/orchestrator/src/routes/publishing.ts`, wired
|
|
5
|
+
* directly into Fastify, even though the three git helpers they wrap have been
|
|
6
|
+
* in orchestrator-core all along. Library mode — `createOrchestrator()` in the
|
|
7
|
+
* site SDK — serves the same editor from a plain `Request`/`Response` handler
|
|
8
|
+
* and reimplements by hand whichever routes somebody remembered to add. It
|
|
9
|
+
* never had these, so the editor's restore panel answered "not handled by
|
|
10
|
+
* createOrchestrator()" for a UI that offers Restore unconditionally.
|
|
11
|
+
*
|
|
12
|
+
* As with `history-actions.ts`, a second hand-kept copy would drift the way the
|
|
13
|
+
* first one did, so the logic lives here and both transports call it. Each
|
|
14
|
+
* function returns the status code and body to send; neither Fastify nor
|
|
15
|
+
* `Response` appears in this file.
|
|
16
|
+
*/
|
|
17
|
+
import { listRestoreSnapshots, loadPublishedSnapshotFromCommit, deletePublishSnapshot } from "../publish/publish-helpers.js";
|
|
18
|
+
import type { Logger } from "../logger.js";
|
|
19
|
+
import type { ActionResult } from "./history-actions.js";
|
|
20
|
+
export type { ActionResult };
|
|
21
|
+
/**
|
|
22
|
+
* The three git-backed helpers, injectable so the actions are testable.
|
|
23
|
+
*
|
|
24
|
+
* The real ones shell out to `git log`/`git show`/`git revert` against the
|
|
25
|
+
* repository root, which a unit test cannot stage without writing commits.
|
|
26
|
+
* Callers pass nothing and get the real thing.
|
|
27
|
+
*/
|
|
28
|
+
export type RestoreDeps = {
|
|
29
|
+
listSnapshots: typeof listRestoreSnapshots;
|
|
30
|
+
loadSnapshot: typeof loadPublishedSnapshotFromCommit;
|
|
31
|
+
deleteSnapshot: typeof deletePublishSnapshot;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The publish snapshots available to restore, newest first.
|
|
35
|
+
*
|
|
36
|
+
* One deliberate departure from the Fastify handler this replaces: that one
|
|
37
|
+
* answered 500 for *any* `git log` failure, including "this is not a
|
|
38
|
+
* repository". The editor prints `error` from the body straight into the
|
|
39
|
+
* restore panel, so an embedded site — which is never a checkout of this
|
|
40
|
+
* monorepo — got a red server error where the truthful answer is the panel's
|
|
41
|
+
* own "No snapshots available yet." Rewiring the standalone server through
|
|
42
|
+
* here changes that one case from 500 to 200 with an empty list.
|
|
43
|
+
*/
|
|
44
|
+
export declare function restoreSnapshotsList(query: {
|
|
45
|
+
limit?: string | number;
|
|
46
|
+
siteId?: string;
|
|
47
|
+
}, deps?: RestoreDeps): Promise<ActionResult>;
|
|
48
|
+
/**
|
|
49
|
+
* Replace the session draft with the pages published in one commit.
|
|
50
|
+
*
|
|
51
|
+
* The draft is cleared and refilled rather than merged: a snapshot restore is
|
|
52
|
+
* meant to reproduce that commit exactly, and leaving pages behind that the
|
|
53
|
+
* snapshot does not contain would silently resurrect deleted ones.
|
|
54
|
+
*
|
|
55
|
+
* `markRecentlyRestored` is what stops the restore being undone a moment later
|
|
56
|
+
* — bootstrap seeding treats a session it has not seen as empty and would
|
|
57
|
+
* overwrite the freshly restored pages with demo or CMS content.
|
|
58
|
+
*/
|
|
59
|
+
export declare function restoreSnapshotApply(body: {
|
|
60
|
+
commit?: string;
|
|
61
|
+
session?: string;
|
|
62
|
+
siteId?: string;
|
|
63
|
+
}, log: Logger, deps?: RestoreDeps): Promise<ActionResult>;
|
|
64
|
+
/** Drop a snapshot by reverting the commit that published it. */
|
|
65
|
+
export declare function restoreSnapshotDelete(body: {
|
|
66
|
+
commit?: string;
|
|
67
|
+
}, deps?: RestoreDeps): Promise<ActionResult>;
|