@avocadostudio-ai/orchestrator-core 0.1.0 → 0.2.1

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,145 @@
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 { normalizeSession, scopedSessionKey, getSessionDraft, ensureHeroImageProps, bumpVersion, markRecentlyRestored, schedulePersistState } from "../state/session-state.js";
18
+ import { listRestoreSnapshots, loadPublishedSnapshotFromCommit, deletePublishSnapshot } from "../publish/publish-helpers.js";
19
+ import { toErrorDetail } from "../errors.js";
20
+ const realDeps = {
21
+ listSnapshots: listRestoreSnapshots,
22
+ loadSnapshot: loadPublishedSnapshotFromCommit,
23
+ deleteSnapshot: deletePublishSnapshot
24
+ };
25
+ const badRequest = (error) => ({ code: 400, body: { error } });
26
+ /**
27
+ * Snapshots are addressed by git hash and nothing else, so anything that is not
28
+ * a hash is rejected before it can reach `git show`/`git revert` as an argument.
29
+ */
30
+ const COMMIT_PATTERN = /^[0-9a-f]{7,40}$/i;
31
+ const readCommit = (value) => (typeof value === "string" ? value.trim() : "");
32
+ /**
33
+ * Whether the failure is "there is no git history to read here" rather than a
34
+ * fault worth a 500.
35
+ *
36
+ * `listRestoreSnapshots` runs `git log` in the repository root. A library-mode
37
+ * consumer's site is not the Avocado monorepo, and a container image often
38
+ * copies the build without its `.git` directory — both are ordinary situations
39
+ * where the honest answer is "no snapshots", and a restore panel showing an
40
+ * empty list is correct where one showing a red server error is not.
41
+ *
42
+ * The patterns are the three exact things git says in that situation. A bare
43
+ * `ENOENT` is deliberately not one of them: it appears in any failed file read
44
+ * anywhere below this call, and matching it would turn a genuine fault into a
45
+ * silent empty list — the failure mode this predicate exists to avoid causing.
46
+ */
47
+ function isMissingGitHistory(error) {
48
+ const message = error instanceof Error ? error.message : String(error);
49
+ return /not a git repository|does not have any commits yet|spawn git ENOENT/i.test(message);
50
+ }
51
+ /**
52
+ * The publish snapshots available to restore, newest first.
53
+ *
54
+ * One deliberate departure from the Fastify handler this replaces: that one
55
+ * answered 500 for *any* `git log` failure, including "this is not a
56
+ * repository". The editor prints `error` from the body straight into the
57
+ * restore panel, so an embedded site — which is never a checkout of this
58
+ * monorepo — got a red server error where the truthful answer is the panel's
59
+ * own "No snapshots available yet." Rewiring the standalone server through
60
+ * here changes that one case from 500 to 200 with an empty list.
61
+ */
62
+ export async function restoreSnapshotsList(query, deps = realDeps) {
63
+ // An empty or blank `limit` is the absent case, not a limit of zero:
64
+ // `?limit=` reaches here as "", and `Number("")` is 0, which the git helper
65
+ // floors to 1 — one snapshot in a panel that asked for thirty.
66
+ const raw = typeof query.limit === "string" ? (query.limit.trim() || undefined) : query.limit;
67
+ const requested = typeof raw === "string" ? Number(raw) : raw;
68
+ const limit = typeof requested === "number" && Number.isFinite(requested) ? requested : 30;
69
+ const siteId = typeof query.siteId === "string" ? query.siteId.trim() : "";
70
+ try {
71
+ const snapshots = await deps.listSnapshots(limit, siteId || undefined);
72
+ return { code: 200, body: { snapshots } };
73
+ }
74
+ catch (error) {
75
+ if (isMissingGitHistory(error))
76
+ return { code: 200, body: { snapshots: [] } };
77
+ return { code: 500, body: { error: toErrorDetail(error) } };
78
+ }
79
+ }
80
+ /**
81
+ * Replace the session draft with the pages published in one commit.
82
+ *
83
+ * The draft is cleared and refilled rather than merged: a snapshot restore is
84
+ * meant to reproduce that commit exactly, and leaving pages behind that the
85
+ * snapshot does not contain would silently resurrect deleted ones.
86
+ *
87
+ * `markRecentlyRestored` is what stops the restore being undone a moment later
88
+ * — bootstrap seeding treats a session it has not seen as empty and would
89
+ * overwrite the freshly restored pages with demo or CMS content.
90
+ */
91
+ export async function restoreSnapshotApply(body, log, deps = realDeps) {
92
+ const commit = readCommit(body.commit);
93
+ if (!COMMIT_PATTERN.test(commit))
94
+ return badRequest("commit is required (7-40 hex chars)");
95
+ const session = normalizeSession(body.session);
96
+ const scopedSession = scopedSessionKey(session, body.siteId);
97
+ try {
98
+ const pages = await deps.loadSnapshot(commit);
99
+ const draft = getSessionDraft(scopedSession);
100
+ draft.clear();
101
+ for (const page of pages) {
102
+ const clone = structuredClone(page);
103
+ ensureHeroImageProps(clone);
104
+ draft.set(clone.slug, clone);
105
+ }
106
+ const previewVersion = bumpVersion(scopedSession);
107
+ markRecentlyRestored(scopedSession);
108
+ schedulePersistState(log);
109
+ return {
110
+ code: 200,
111
+ body: {
112
+ status: "restored",
113
+ commit: commit.slice(0, 7),
114
+ session,
115
+ scopedSession,
116
+ slugs: pages.map((page) => page.slug),
117
+ previewVersion
118
+ }
119
+ };
120
+ }
121
+ catch (error) {
122
+ // A commit that does not exist, or whose published-content.json fails the
123
+ // page schema, is a bad request about the caller's commit — not a fault of
124
+ // this server.
125
+ return { code: 400, body: { error: toErrorDetail(error) } };
126
+ }
127
+ }
128
+ /** Drop a snapshot by reverting the commit that published it. */
129
+ export async function restoreSnapshotDelete(body, deps = realDeps) {
130
+ const commit = readCommit(body.commit);
131
+ if (!COMMIT_PATTERN.test(commit))
132
+ return badRequest("commit is required (7-40 hex chars)");
133
+ try {
134
+ const ok = await deps.deleteSnapshot(commit);
135
+ if (!ok)
136
+ return badRequest("Failed to delete snapshot.");
137
+ return { code: 200, body: { status: "deleted", commit } };
138
+ }
139
+ catch (error) {
140
+ // `deletePublishSnapshot` catches every git failure itself and reports it
141
+ // as `false`, so a throw that gets this far is not the caller's commit
142
+ // being wrong — it is this server misbehaving, and 500 says so.
143
+ return { code: 500, body: { error: toErrorDetail(error) } };
144
+ }
145
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `POST /preview/screenshot`, as a transport-agnostic action.
3
+ *
4
+ * It lived only in `apps/orchestrator/src/routes/preview.ts`, wired straight
5
+ * into Fastify. Library mode — `createOrchestrator()` in the site SDK — serves
6
+ * the same editor from a plain `Request`/`Response` handler and reimplements
7
+ * whichever routes somebody remembered to add. It never had this one, so every
8
+ * caller that screenshots a page — the MCP `screenshot-page` tool, the sites
9
+ * agent's own eyes on its work — answered "not handled by createOrchestrator()"
10
+ * against a library-mode host.
11
+ *
12
+ * A scaffolded site exposes `/preview-draft/[[...slug]]?session=X&siteId=Y`,
13
+ * which renders the orchestrator's draft content directly (no cookie dance —
14
+ * the route reads session + siteId from query params). We target that route by
15
+ * default so screenshots reflect in-progress edits, not just the last published
16
+ * snapshot. Callers can pass `published: true` to screenshot the public route
17
+ * instead (useful for before/after comparisons).
18
+ *
19
+ * `/preview-draft` is only the *default*, though, and it was hardcoded — which
20
+ * meant this route worked on sites `create-ai-site-editor` scaffolded and on no
21
+ * others. An existing site that wires Avocado into its own app has its own
22
+ * draft route (Paintball Arena Bern's is `/avocado/<lang>/<slug>`), and the
23
+ * screenshot an agent took to check its own work photographed that site's 404
24
+ * page. So the path is declarable: per request, per registered site, or via
25
+ * `createOrchestrator({ draftPath })`.
26
+ */
27
+ import type { Logger } from "../logger.js";
28
+ import type { ActionResult } from "./history-actions.js";
29
+ export type { ActionResult };
30
+ export type ScreenshotParams = {
31
+ session?: string;
32
+ siteId?: string;
33
+ /** Page slug to screenshot — defaults to the home page ("/"). */
34
+ slug?: string;
35
+ /** Override the preview URL from the registered site config. */
36
+ previewUrl?: string;
37
+ /**
38
+ * Override the site's draft-preview route. Either a prefix the slug is
39
+ * appended to (`/avocado`) or a template naming where the slug goes
40
+ * (`/preview/{slug}/draft`). Defaults to the registered site's `draftPath`,
41
+ * then to `/preview-draft`.
42
+ */
43
+ draftPath?: string;
44
+ /** Screenshot the published route instead of the draft preview. Defaults to false. */
45
+ published?: boolean;
46
+ };
47
+ /** What `takeScreenshot` from the migration SDK returns, narrowed to what we read. */
48
+ export type ScreenshotCapture = (url: string) => Promise<{
49
+ base64: string;
50
+ viewport: {
51
+ width: number;
52
+ height: number;
53
+ };
54
+ }>;
55
+ export type ScreenshotDeps = {
56
+ /**
57
+ * Overrides the capture implementation. Tests inject a stub; nothing else
58
+ * needs to pass it.
59
+ */
60
+ takeScreenshot?: ScreenshotCapture;
61
+ /**
62
+ * What the host declared about itself, used when neither the request nor a
63
+ * registered site config says. A library-mode site knows its own URL and its
64
+ * own draft route at `createOrchestrator()` time, and making it call
65
+ * `/sites/register` against itself to say so is ceremony — but a registration
66
+ * that *did* happen is more specific than a build-time default, so these lose
67
+ * to it.
68
+ */
69
+ fallbackPreviewUrl?: string;
70
+ fallbackDraftPath?: string;
71
+ /**
72
+ * The state key to read the site config and the page under, when the caller
73
+ * has already computed one.
74
+ *
75
+ * `scopedSessionKey(session, siteId)` is the right answer for the standalone
76
+ * server, where the caller's `siteId` *is* the site's identity. Library mode
77
+ * owns its identity in `createOrchestrator({ siteId })` and overrides what
78
+ * the caller sent, so recomputing here reads a key nothing was written to —
79
+ * the same double-scoping that made `/publish/status` answer 404 forever.
80
+ * The raw `session` and `siteId` still go into the URL: they are what the
81
+ * site itself will hand back to `/draft/pages`, which scopes them the same
82
+ * way this caller did.
83
+ */
84
+ scopedSession?: string;
85
+ };
86
+ export type ScreenshotSuccessBody = {
87
+ url: string;
88
+ slug: string;
89
+ mode: "published" | "draft";
90
+ mimeType: "image/jpeg";
91
+ base64: string;
92
+ width: number;
93
+ height: number;
94
+ };
95
+ /** The route a site renders its orchestrator drafts on, when it declares none. */
96
+ export declare const DEFAULT_DRAFT_PATH = "/preview-draft";
97
+ /**
98
+ * Resolve a site's draft route for one slug.
99
+ *
100
+ * Two spellings, because two things are natural to write. A template naming
101
+ * `{slug}` puts the slug wherever the site's route wants it; anything else is a
102
+ * prefix and the slug is appended — which is what `/preview-draft` always was.
103
+ * The home page contributes nothing in either spelling, so `/` does not become
104
+ * a trailing slash the site has to be tolerant of.
105
+ */
106
+ export declare function resolveDraftPath(template: string, normalizedSlug: string): string;
107
+ /** Capture a full-page screenshot of one draft or published page. */
108
+ export declare function screenshotAction(params: ScreenshotParams, log: Logger, deps?: ScreenshotDeps): Promise<ActionResult>;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `POST /preview/screenshot`, as a transport-agnostic action.
3
+ *
4
+ * It lived only in `apps/orchestrator/src/routes/preview.ts`, wired straight
5
+ * into Fastify. Library mode — `createOrchestrator()` in the site SDK — serves
6
+ * the same editor from a plain `Request`/`Response` handler and reimplements
7
+ * whichever routes somebody remembered to add. It never had this one, so every
8
+ * caller that screenshots a page — the MCP `screenshot-page` tool, the sites
9
+ * agent's own eyes on its work — answered "not handled by createOrchestrator()"
10
+ * against a library-mode host.
11
+ *
12
+ * A scaffolded site exposes `/preview-draft/[[...slug]]?session=X&siteId=Y`,
13
+ * which renders the orchestrator's draft content directly (no cookie dance —
14
+ * the route reads session + siteId from query params). We target that route by
15
+ * default so screenshots reflect in-progress edits, not just the last published
16
+ * snapshot. Callers can pass `published: true` to screenshot the public route
17
+ * instead (useful for before/after comparisons).
18
+ *
19
+ * `/preview-draft` is only the *default*, though, and it was hardcoded — which
20
+ * meant this route worked on sites `create-ai-site-editor` scaffolded and on no
21
+ * others. An existing site that wires Avocado into its own app has its own
22
+ * draft route (Paintball Arena Bern's is `/avocado/<lang>/<slug>`), and the
23
+ * screenshot an agent took to check its own work photographed that site's 404
24
+ * page. So the path is declarable: per request, per registered site, or via
25
+ * `createOrchestrator({ draftPath })`.
26
+ */
27
+ import { getPage, getSiteConfig, scopedSessionKey } from "../state/session-state.js";
28
+ const badRequest = (error) => ({ code: 400, body: { error } });
29
+ /** The route a site renders its orchestrator drafts on, when it declares none. */
30
+ export const DEFAULT_DRAFT_PATH = "/preview-draft";
31
+ /**
32
+ * Resolve a site's draft route for one slug.
33
+ *
34
+ * Two spellings, because two things are natural to write. A template naming
35
+ * `{slug}` puts the slug wherever the site's route wants it; anything else is a
36
+ * prefix and the slug is appended — which is what `/preview-draft` always was.
37
+ * The home page contributes nothing in either spelling, so `/` does not become
38
+ * a trailing slash the site has to be tolerant of.
39
+ */
40
+ export function resolveDraftPath(template, normalizedSlug) {
41
+ const slugPart = normalizedSlug === "/" ? "" : normalizedSlug;
42
+ if (template.includes("{slug}"))
43
+ return template.replaceAll("{slug}", slugPart);
44
+ return template.replace(/\/+$/, "") + slugPart;
45
+ }
46
+ /*
47
+ * Injected dep with a lazy default, resolved through an import a bundler
48
+ * cannot see.
49
+ *
50
+ * `@avocadostudio-ai/migration-sdk` pulls in Playwright, which ships a browser
51
+ * driver and native binaries. A static import would drag all of it into every
52
+ * consumer of orchestrator-core — including a Next.js site that only wants
53
+ * `createOrchestrator()` and will never take a screenshot. A plain `await
54
+ * import(...)` is not enough either: webpack traces dynamic specifiers too, and
55
+ * wiring this route into library mode made a real Next 16 host answer 500 with
56
+ * "Module parse failed: Unexpected character '\u0000'" — it had tried to bundle
57
+ * a binary.
58
+ *
59
+ * Hiding the specifier behind `new Function` defeats that analysis, which is
60
+ * the point: the module is resolved by Node at call time, and only when
61
+ * somebody actually asks for a screenshot. The alternative — making every
62
+ * integrator add the package to `serverExternalPackages` — pushes our
63
+ * dependency's shape into their config.
64
+ *
65
+ * The injected dep on top of that buys the test seam: stubbing the capture is
66
+ * what lets the URL-building rules below be asserted without a browser.
67
+ */
68
+ const runtimeImport = new Function("specifier", "return import(specifier)");
69
+ async function resolveCapture() {
70
+ const { takeScreenshot } = await runtimeImport("@avocadostudio-ai/migration-sdk");
71
+ if (typeof takeScreenshot !== "function") {
72
+ throw new Error("@avocadostudio-ai/migration-sdk exported no takeScreenshot");
73
+ }
74
+ return takeScreenshot;
75
+ }
76
+ /** Capture a full-page screenshot of one draft or published page. */
77
+ export async function screenshotAction(params, log, deps = {}) {
78
+ if (!params.session)
79
+ return badRequest("session is required");
80
+ if (!params.siteId)
81
+ return badRequest("siteId is required");
82
+ const scopedSession = deps.scopedSession ?? scopedSessionKey(params.session, params.siteId);
83
+ const config = getSiteConfig(scopedSession);
84
+ // `previewUrl` is not in `siteConfigSchema` — `/sites/register` merges it in
85
+ // as an unvalidated extra, so it can only be read off the config as unknown.
86
+ const previewUrl = params.previewUrl ?? config.previewUrl ?? deps.fallbackPreviewUrl;
87
+ if (typeof previewUrl !== "string" || previewUrl.length === 0) {
88
+ return badRequest("no previewUrl configured for this site. Register it via POST /sites/register, pass `previewUrl` in the body, " +
89
+ "or (in library mode) set `previewUrl` on createOrchestrator().");
90
+ }
91
+ const slug = params.slug ?? "/";
92
+ // Guard against absolute URLs in `slug` — only allow path-like values.
93
+ if (/^https?:\/\//i.test(slug)) {
94
+ return badRequest("slug must be a path, not a full URL");
95
+ }
96
+ const normalizedSlug = slug.startsWith("/") ? slug : `/${slug}`;
97
+ const base = previewUrl.replace(/\/+$/, "");
98
+ /*
99
+ * The published route is the page's *path*, which is not always its slug.
100
+ * A site with locale prefixes or its own routing declares the difference
101
+ * in `meta.path`; screenshotting the slug there photographs a 404. Draft
102
+ * mode is unaffected — `/preview-draft` is keyed by slug, because that is
103
+ * the draft's own identifier rather than a route the site owns.
104
+ */
105
+ const publicPath = getPage(scopedSession, normalizedSlug)?.meta?.path ?? normalizedSlug;
106
+ /*
107
+ * Draft mode: target the site's draft route so the orchestrator's draft
108
+ * content is rendered directly, without needing a signed cookie. Published
109
+ * mode: hit the plain public route.
110
+ *
111
+ * `session` and `siteId` are appended rather than assigned, so a declared
112
+ * path may carry query params of its own (a locale, a perspective) without
113
+ * losing them.
114
+ */
115
+ const declaredDraftPath = params.draftPath ?? config.draftPath ?? deps.fallbackDraftPath;
116
+ const draftTemplate = typeof declaredDraftPath === "string" && declaredDraftPath.length > 0
117
+ ? declaredDraftPath
118
+ : DEFAULT_DRAFT_PATH;
119
+ const draftPath = resolveDraftPath(draftTemplate, normalizedSlug);
120
+ const draftQuery = `session=${encodeURIComponent(params.session)}&siteId=${encodeURIComponent(params.siteId)}`;
121
+ const fullUrl = params.published === true
122
+ ? base + publicPath
123
+ : base + draftPath + (draftPath.includes("?") ? "&" : "?") + draftQuery;
124
+ /*
125
+ * Resolving the capture is its own failure, reported separately from taking
126
+ * one. A host that has not installed the migration SDK gets "Cannot find
127
+ * package '@avocadostudio-ai/migration-sdk'" out of Node, which reads as a
128
+ * broken orchestrator rather than as the configuration choice it is — the
129
+ * package is heavy and deliberately not a dependency of the site. Say what
130
+ * to do about it, and use 503: the route is unavailable here, not failing.
131
+ */
132
+ let capture = deps.takeScreenshot;
133
+ if (!capture) {
134
+ try {
135
+ capture = await resolveCapture();
136
+ }
137
+ catch (err) {
138
+ const message = err instanceof Error ? err.message : String(err);
139
+ log.warn({ err: message }, "preview screenshot unavailable: no capture backend");
140
+ return {
141
+ code: 503,
142
+ body: {
143
+ error: "screenshots are not available in this process. Install @avocadostudio-ai/migration-sdk " +
144
+ "alongside the host app, or pass `screenshot` to createOrchestrator() to supply your own " +
145
+ "capture function.",
146
+ detail: message
147
+ }
148
+ };
149
+ }
150
+ }
151
+ try {
152
+ const result = await capture(fullUrl);
153
+ return {
154
+ code: 200,
155
+ body: {
156
+ url: fullUrl,
157
+ slug: normalizedSlug,
158
+ mode: params.published === true ? "published" : "draft",
159
+ mimeType: "image/jpeg",
160
+ base64: result.base64,
161
+ width: result.viewport.width,
162
+ height: result.viewport.height
163
+ }
164
+ };
165
+ }
166
+ catch (err) {
167
+ const message = err instanceof Error ? err.message : String(err);
168
+ log.warn({ err: message, fullUrl }, "preview screenshot failed");
169
+ // The URL and mode ride along on the failure because they are the only way
170
+ // a caller can tell "the site is down" from "we photographed the wrong
171
+ // route" — the two failures read identically from the message alone.
172
+ return {
173
+ code: 502,
174
+ body: {
175
+ error: message,
176
+ url: fullUrl,
177
+ mode: params.published === true ? "published" : "draft"
178
+ }
179
+ };
180
+ }
181
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * "Which draft am I editing?" — as a transport-agnostic action.
3
+ *
4
+ * `/whoami` existed only on the Fastify app, which was defensible while the
5
+ * editor was the only client: the editor already knows its own session, so it
6
+ * never asks. The MCP server does ask, and it asks first — every install is
7
+ * bound to exactly one (session, siteId) pair and `avocado-whoami` is how you
8
+ * confirm you are about to mutate the draft you think you are.
9
+ *
10
+ * So an MCP install pointed at a site running library mode answered "not
11
+ * handled by createOrchestrator()" to the one question worth asking before a
12
+ * destructive edit. That is the same class of gap as Undo: a second client
13
+ * grew needs the second copy of the route table never heard about.
14
+ *
15
+ * `/sessions` — the directory of every session on the orchestrator — stays
16
+ * behind. It is a property of a host running many sites, not of one site
17
+ * embedding its own editor, and it would answer a question no embedded caller
18
+ * should be asking.
19
+ */
20
+ import type { ActionResult } from "./history-actions.js";
21
+ export type { ActionResult };
22
+ /**
23
+ * The caller's bound session and a summary of its state.
24
+ *
25
+ * `origin` is passed in rather than derived: the Fastify app knows it from the
26
+ * request's protocol and hostname, a `Request` handler from its own URL, and
27
+ * neither shape belongs in here. It is echoed so a mismatch between what the
28
+ * client thinks it is talking to and what answered is visible in the reply.
29
+ */
30
+ export declare function whoamiAction(query: {
31
+ session?: string;
32
+ siteId?: string;
33
+ }, origin: string, options?: {
34
+ hasAdapter?: boolean;
35
+ }): ActionResult;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * "Which draft am I editing?" — as a transport-agnostic action.
3
+ *
4
+ * `/whoami` existed only on the Fastify app, which was defensible while the
5
+ * editor was the only client: the editor already knows its own session, so it
6
+ * never asks. The MCP server does ask, and it asks first — every install is
7
+ * bound to exactly one (session, siteId) pair and `avocado-whoami` is how you
8
+ * confirm you are about to mutate the draft you think you are.
9
+ *
10
+ * So an MCP install pointed at a site running library mode answered "not
11
+ * handled by createOrchestrator()" to the one question worth asking before a
12
+ * destructive edit. That is the same class of gap as Undo: a second client
13
+ * grew needs the second copy of the route table never heard about.
14
+ *
15
+ * `/sessions` — the directory of every session on the orchestrator — stays
16
+ * behind. It is a property of a host running many sites, not of one site
17
+ * embedding its own editor, and it would answer a question no embedded caller
18
+ * should be asking.
19
+ */
20
+ import { DEFAULT_SESSION, getSessionSummary, getSessionCapabilities, capabilitiesBySession, publishedPageCountGlobal, scopedSessionKey } from "../state/session-state.js";
21
+ import { describeDraft } from "./draft-provenance.js";
22
+ /**
23
+ * The caller's bound session and a summary of its state.
24
+ *
25
+ * `origin` is passed in rather than derived: the Fastify app knows it from the
26
+ * request's protocol and hostname, a `Request` handler from its own URL, and
27
+ * neither shape belongs in here. It is echoed so a mismatch between what the
28
+ * client thinks it is talking to and what answered is visible in the reply.
29
+ */
30
+ export function whoamiAction(query, origin, options) {
31
+ const sessionKey = scopedSessionKey(query.session ?? DEFAULT_SESSION, query.siteId);
32
+ const summary = getSessionSummary(sessionKey);
33
+ /*
34
+ * Whose content this is. `whoami` is the first call an agent makes to
35
+ * confirm it is about to mutate the draft it thinks it is — and until now
36
+ * it could confirm the session key while saying nothing about whether the
37
+ * pages behind it belong to the site that was asked for. A mistyped
38
+ * `siteId` produced a confident answer either way.
39
+ */
40
+ const provenance = describeDraft({
41
+ requestedSiteId: query.siteId,
42
+ scopedSession: sessionKey,
43
+ pageCount: summary.draftPageCount,
44
+ hasAdapter: options?.hasAdapter
45
+ });
46
+ return {
47
+ code: 200,
48
+ body: {
49
+ ...summary,
50
+ ...provenance,
51
+ ...publishedPageCountFor(provenance.source),
52
+ orchestratorUrl: origin,
53
+ /*
54
+ * What this site can honour, so an agent finds out before it plans a
55
+ * page the CMS can never store. `declared` is false when nobody has
56
+ * said anything, which is the common case and reads as "everything is
57
+ * permitted, but nothing has been promised" — a caller that needs to
58
+ * tell that apart from an explicit yes has it here.
59
+ */
60
+ capabilities: {
61
+ ...getSessionCapabilities(sessionKey),
62
+ declared: capabilitiesBySession.has(sessionKey)
63
+ }
64
+ }
65
+ };
66
+ }
67
+ /**
68
+ * How many pages are live — but only where this process can actually know.
69
+ *
70
+ * `publishedPageCount` used to be `publishedPages.size`, unconditionally. That
71
+ * Map is seeded from the bundled demo pages at import time and *nothing writes
72
+ * it on publish*, so the field was the constant 8 on every deployment: an
73
+ * agent editing a 45-page Sanity site was told "45 draft, 8 published" and
74
+ * reasonably read it as 37 pages waiting to go out. It sat inside a
75
+ * session-scoped report, next to `draftPageCount`, which is what made it read
76
+ * as this site's number rather than a process-wide leftover.
77
+ *
78
+ * This is the same defect `describeDraft` was written for — a plausible count
79
+ * that belongs to nobody's site — one field over, and it survived that fix
80
+ * because it is computed from a different map. So it now answers from the same
81
+ * provenance: on the demo site the Map genuinely is what is published, and
82
+ * everywhere else the honest answer is that this process does not track it.
83
+ *
84
+ * `null` rather than an omitted key, because a caller reading a missing field
85
+ * learns nothing; one reading `null` beside the note learns where the real
86
+ * answer is. `GET /publish/diff` is that answer — in library mode it reads the
87
+ * live side through the site's own adapter.
88
+ */
89
+ function publishedPageCountFor(source) {
90
+ if (source === "demo")
91
+ return { publishedPageCount: publishedPageCountGlobal() };
92
+ return {
93
+ publishedPageCount: null,
94
+ publishedPageCountNote: "This process does not track what is live for this site. Its only published-page store holds " +
95
+ "Avocado's bundled demo pages, seeded at startup and never written on publish, so its size is " +
96
+ "a constant rather than this site's page count. Call GET /publish/diff for the live side."
97
+ };
98
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Thumbs-up / thumbs-down on a chat trace, as transport-agnostic actions.
3
+ *
4
+ * `POST /telemetry/chat/feedback` was written inline in
5
+ * `apps/orchestrator/src/index.ts` rather than in a route plugin, so library
6
+ * mode — `createOrchestrator()` in the site SDK, which reimplements by hand
7
+ * whichever routes somebody remembered to add — never got it. The editor sends
8
+ * the rating best-effort and ignores the response, so on a library-mode site
9
+ * every thumb the user clicked was answered "not handled by
10
+ * createOrchestrator()" and silently thrown away: the UI kept showing the
11
+ * button as accepted and no signal ever reached the store.
12
+ *
13
+ * A second hand-kept copy would drift the way the first one did, so the
14
+ * validation lives here and both transports call it. Each function returns the
15
+ * status code and body to send; neither Fastify nor `Response` appears in this
16
+ * file.
17
+ */
18
+ import type { FeedbackStore } from "../telemetry/feedback-store.js";
19
+ import type { ActionResult } from "./history-actions.js";
20
+ export type { ActionResult };
21
+ /**
22
+ * The store is a parameter, not a module singleton.
23
+ *
24
+ * Unlike the session state the other action files reach for, a feedback store
25
+ * is constructed per process from `FEEDBACK_FILE` / `FEEDBACK_LIMIT` and a
26
+ * logger — the standalone server and a library-mode host each own their own.
27
+ * Only the two methods a request path touches are required, which also keeps a
28
+ * test double from having to fake disk I/O.
29
+ */
30
+ export type FeedbackSink = Pick<FeedbackStore, "push" | "list">;
31
+ /**
32
+ * Record one rating against a chat trace.
33
+ *
34
+ * The rating is compared against the two literals rather than merely checked
35
+ * for being a string: the store's readers filter and count by rating, and a
36
+ * third value would quietly split the totals. An unusable payload is rejected
37
+ * rather than stored partially, because the editor fires this and forgets it —
38
+ * a row nobody can attribute to a trace is worse than no row.
39
+ */
40
+ export declare function telemetryFeedbackSubmitAction(raw: unknown, store: FeedbackSink): ActionResult;
41
+ /**
42
+ * Read the ratings back, filtered.
43
+ *
44
+ * `limit` arrives as a query string over HTTP and as a number from anything
45
+ * calling this directly, so it is coerced here; the store clamps the range.
46
+ * Left absent rather than defaulted so the store's own default still applies.
47
+ */
48
+ export declare function telemetryFeedbackListAction(query: {
49
+ session?: string;
50
+ rating?: string;
51
+ traceId?: string;
52
+ limit?: string | number;
53
+ }, store: FeedbackSink): ActionResult;