@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent/sites-agent-context.js +3 -2
  2. package/dist/agent/sites-agent-shared.js +1 -0
  3. package/dist/chat/anthropic-planner.js +3 -3
  4. package/dist/chat/chat-pipeline.js +122 -20
  5. package/dist/chat/gemini-planner.js +3 -3
  6. package/dist/chat/planner.js +7 -5
  7. package/dist/chat/prompts.js +6 -1
  8. package/dist/cms/adapter.d.ts +159 -1
  9. package/dist/cms/adapter.js +19 -1
  10. package/dist/cms/bootstrap.d.ts +46 -1
  11. package/dist/cms/bootstrap.js +126 -2
  12. package/dist/cms/index.d.ts +3 -2
  13. package/dist/cms/index.js +2 -1
  14. package/dist/errors.d.ts +9 -1
  15. package/dist/handler/auth.d.ts +79 -0
  16. package/dist/handler/auth.js +113 -0
  17. package/dist/handler/create-orchestrator.d.ts +205 -0
  18. package/dist/handler/create-orchestrator.js +1599 -0
  19. package/dist/http/access-tokens.d.ts +58 -0
  20. package/dist/http/access-tokens.js +161 -0
  21. package/dist/http/audio-actions.d.ts +121 -0
  22. package/dist/http/audio-actions.js +248 -0
  23. package/dist/http/blocks-actions.d.ts +31 -0
  24. package/dist/http/blocks-actions.js +31 -0
  25. package/dist/http/draft-provenance.d.ts +68 -0
  26. package/dist/http/draft-provenance.js +101 -0
  27. package/dist/http/history-actions.d.ts +58 -0
  28. package/dist/http/history-actions.js +169 -0
  29. package/dist/http/image-generate-actions.d.ts +268 -0
  30. package/dist/http/image-generate-actions.js +546 -0
  31. package/dist/http/ops-actions.d.ts +51 -0
  32. package/dist/http/ops-actions.js +79 -0
  33. package/dist/http/publish-actions.d.ts +153 -0
  34. package/dist/http/publish-actions.js +323 -0
  35. package/dist/http/restore-actions.d.ts +67 -0
  36. package/dist/http/restore-actions.js +145 -0
  37. package/dist/http/screenshot-actions.d.ts +108 -0
  38. package/dist/http/screenshot-actions.js +181 -0
  39. package/dist/http/session-actions.d.ts +35 -0
  40. package/dist/http/session-actions.js +98 -0
  41. package/dist/http/telemetry-feedback-actions.d.ts +53 -0
  42. package/dist/http/telemetry-feedback-actions.js +68 -0
  43. package/dist/http/unsplash-actions.d.ts +64 -0
  44. package/dist/http/unsplash-actions.js +81 -0
  45. package/dist/http/variations-actions.d.ts +102 -0
  46. package/dist/http/variations-actions.js +104 -0
  47. package/dist/index.d.ts +4 -1
  48. package/dist/index.js +21 -1
  49. package/dist/nlp/deterministic-planner-refs.d.ts +1 -1
  50. package/dist/nlp/deterministic-planner-suggestions.d.ts +10 -0
  51. package/dist/nlp/deterministic-planner-suggestions.js +37 -11
  52. package/dist/nlp/plan-normalizer.js +18 -2
  53. package/dist/ops/ops-engine.js +219 -14
  54. package/dist/state/session-state.d.ts +56 -1
  55. package/dist/state/session-state.js +92 -6
  56. package/dist/state/sqlite-store-singleton.d.ts +22 -0
  57. package/dist/state/sqlite-store-singleton.js +49 -1
  58. package/dist/state/sqlite-store.d.ts +5 -0
  59. package/dist/state/sqlite-store.js +125 -2
  60. package/dist/telemetry/chat-telemetry.js +6 -1
  61. package/package.json +12 -16
@@ -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>;