@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.
- 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,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;
|