@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.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 (88) hide show
  1. package/dist/chat/anthropic-planner.d.ts +8 -0
  2. package/dist/chat/anthropic-planner.js +166 -12
  3. package/dist/chat/chat-pipeline-translation.d.ts +13 -0
  4. package/dist/chat/chat-pipeline-translation.js +109 -45
  5. package/dist/chat/chat-pipeline.d.ts +1 -1
  6. package/dist/chat/chat-pipeline.js +312 -54
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/planner-types.d.ts +15 -0
  10. package/dist/chat/planner-types.js +2 -2
  11. package/dist/chat/planner.d.ts +12 -0
  12. package/dist/chat/planner.js +16 -2
  13. package/dist/chat/prompts.d.ts +5 -0
  14. package/dist/chat/prompts.js +92 -9
  15. package/dist/chat/translation-chunking.d.ts +124 -0
  16. package/dist/chat/translation-chunking.js +371 -0
  17. package/dist/checks/field-walk.d.ts +42 -0
  18. package/dist/checks/field-walk.js +198 -0
  19. package/dist/checks/index.d.ts +5 -0
  20. package/dist/checks/index.js +4 -0
  21. package/dist/checks/page-weight.d.ts +22 -0
  22. package/dist/checks/page-weight.js +200 -0
  23. package/dist/checks/rules-draft.d.ts +2 -0
  24. package/dist/checks/rules-draft.js +439 -0
  25. package/dist/checks/run-checks.d.ts +42 -0
  26. package/dist/checks/run-checks.js +159 -0
  27. package/dist/checks/session-runner.d.ts +19 -0
  28. package/dist/checks/session-runner.js +99 -0
  29. package/dist/checks/types.d.ts +109 -0
  30. package/dist/checks/types.js +1 -0
  31. package/dist/cms/adapter.d.ts +74 -1
  32. package/dist/cms/adapter.js +1 -0
  33. package/dist/cms/index.d.ts +1 -1
  34. package/dist/cms/index.js +1 -1
  35. package/dist/cms/media-sources.d.ts +29 -1
  36. package/dist/cms/media-sources.js +188 -7
  37. package/dist/durable/durable-store-singleton.d.ts +37 -0
  38. package/dist/durable/durable-store-singleton.js +179 -0
  39. package/dist/durable/finding-impact.d.ts +30 -0
  40. package/dist/durable/finding-impact.js +53 -0
  41. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  42. package/dist/durable/in-memory-durable-store.js +363 -0
  43. package/dist/durable/index.d.ts +5 -0
  44. package/dist/durable/index.js +4 -0
  45. package/dist/durable/pending-plan-store.d.ts +28 -0
  46. package/dist/durable/pending-plan-store.js +156 -0
  47. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  48. package/dist/durable/sqlite-durable-store.js +631 -0
  49. package/dist/durable/types.d.ts +265 -0
  50. package/dist/durable/types.js +1 -0
  51. package/dist/handler/create-orchestrator.d.ts +4 -0
  52. package/dist/handler/create-orchestrator.js +283 -32
  53. package/dist/http/audio-actions.d.ts +1 -1
  54. package/dist/http/checks-actions.d.ts +39 -0
  55. package/dist/http/checks-actions.js +122 -0
  56. package/dist/http/history-actions.d.ts +44 -1
  57. package/dist/http/history-actions.js +122 -0
  58. package/dist/http/image-generate-actions.d.ts +2 -2
  59. package/dist/http/ops-actions.d.ts +2 -2
  60. package/dist/http/publish-actions.d.ts +15 -4
  61. package/dist/http/publish-actions.js +3 -3
  62. package/dist/http/restore-actions.d.ts +3 -3
  63. package/dist/http/screenshot-actions.d.ts +2 -2
  64. package/dist/http/session-actions.d.ts +1 -1
  65. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  66. package/dist/http/unsplash-actions.d.ts +2 -2
  67. package/dist/http/variations-actions.d.ts +2 -2
  68. package/dist/index.d.ts +9 -2
  69. package/dist/index.js +28 -1
  70. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  71. package/dist/nlp/deterministic-planner-context.js +33 -7
  72. package/dist/nlp/intent-detection.d.ts +16 -0
  73. package/dist/nlp/intent-detection.js +15 -1
  74. package/dist/nlp/plan-normalizer.js +66 -32
  75. package/dist/ops/destructive-action-gate.js +7 -2
  76. package/dist/ops/ops-engine.d.ts +12 -1
  77. package/dist/ops/ops-engine.js +41 -14
  78. package/dist/publish/publish-helpers.d.ts +12 -2
  79. package/dist/publish/publish-helpers.js +10 -3
  80. package/dist/publish/publish-selection.d.ts +84 -0
  81. package/dist/publish/publish-selection.js +113 -0
  82. package/dist/publish/publish-target-registry.js +1 -1
  83. package/dist/publish/publish-target.d.ts +1 -1
  84. package/dist/publish/targets/git.js +2 -2
  85. package/dist/state/session-state.js +8 -1
  86. package/dist/state/site-assets.d.ts +41 -0
  87. package/dist/state/site-assets.js +40 -0
  88. package/package.json +3 -3
@@ -30,16 +30,18 @@ import { runChatPipeline, collectMentionedSlugsFromOps } from "../chat/chat-pipe
30
30
  import { createChatTelemetryStore } from "../telemetry/chat-telemetry.js";
31
31
  import { createToolRuntime } from "../tools/runtime.js";
32
32
  import { loadStateFromDisk, scopedSessionKey, getSessionPages, getPage, getSiteConfig, setSiteConfig, pushUndo, bumpVersion, pushRecentEdit, pushVersionEntry, schedulePersistState, normalizeSiteId, publishStatusBySession, pushPublishLogEntry, persistenceHealth, persistenceWarning } from "../state/session-state.js";
33
- import { historyStatus, historyLog, historyUndoAction, historyRedoAction, historyRestoreAction } from "../http/history-actions.js";
33
+ import { historyStatus, historyLog, historyUndoAction, historyRedoAction, historyRestoreAction, historyDiscardAction } from "../http/history-actions.js";
34
34
  import { whoamiAction } from "../http/session-actions.js";
35
35
  import { blocksManifestAction } from "../http/blocks-actions.js";
36
36
  import { screenshotAction } from "../http/screenshot-actions.js";
37
+ import { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction } from "../http/checks-actions.js";
37
38
  import { fileImageStore, formatImageChatFrame, generateImageAction, imageChatAction, imageChatStreamAction, interpretImageAction, validateImageChatRequest } from "../http/image-generate-actions.js";
38
39
  import { transcribeAudioAction, transcriptionUnavailable, validateAudioInput } from "../http/audio-actions.js";
39
40
  import { formatVariationFrame, parseVariationRequest, scopeVariationSession, variationsAction, variationsStreamAction } from "../http/variations-actions.js";
40
41
  import { opsDryRunAction, describeAppliedOps } from "../http/ops-actions.js";
41
42
  import { describeDraft } from "../http/draft-provenance.js";
42
43
  import { buildPublishSummary, publishDiffAction, publishLogAction, publishStatusAction } from "../http/publish-actions.js";
44
+ import { isSelectionFailure, parseSelectionSlugs, selectPagesForPublish } from "../publish/publish-selection.js";
43
45
  import { restoreSnapshotApply, restoreSnapshotDelete, restoreSnapshotsList } from "../http/restore-actions.js";
44
46
  import { unsplashSearchAction } from "../http/unsplash-actions.js";
45
47
  import { telemetryFeedbackListAction, telemetryFeedbackSubmitAction } from "../http/telemetry-feedback-actions.js";
@@ -50,6 +52,7 @@ import { resolveCapabilities } from "../cms/adapter.js";
50
52
  import { mediaSourceFromUnknown } from "../cms/media-sources.js";
51
53
  import { isAccessGateEnabled, mintAccessToken, verifyAccessPassword } from "../http/access-tokens.js";
52
54
  import { checkAuth, resolveAuth } from "./auth.js";
55
+ import { setSiteAssetLister, invalidateSiteAssets } from "../state/site-assets.js";
53
56
  const defaultModelLookup = () => ({
54
57
  openai: {
55
58
  fast: process.env.OPENAI_MODEL_FAST ?? "gpt-4o-mini",
@@ -130,16 +133,70 @@ async function buildRuntime(config) {
130
133
  * forgotten, and `ensure()` still does its own fetch.
131
134
  */
132
135
  bootstrapCache.warm(config.adapter ?? null, log);
136
+ /*
137
+ * Tell the checker which documents this site has, if it can say.
138
+ *
139
+ * `content.file-link-unknown` is the rule that turns a hand-typed PDF path
140
+ * into something verifiable, and it needs the asset list. Registered here
141
+ * because this is the one place that holds the adapter and knows whether it
142
+ * implements the seam; a site with no `getMedia` registers nothing, and the
143
+ * rule stays silent rather than calling every document on the site missing.
144
+ */
145
+ const adapter = config.adapter ?? null;
146
+ if (typeof adapter?.getMedia === "function") {
147
+ const getMedia = adapter.getMedia.bind(adapter);
148
+ setSiteAssetLister(async () => {
149
+ const out = [];
150
+ /*
151
+ * Paged, and bounded. A media library can hold thousands of files, and a
152
+ * linter is not entitled to walk all of them on every publish — past the
153
+ * cap the rule sees a partial list, which is why it reports only links
154
+ * that match nothing *and* stops being useful rather than wrong: a file
155
+ * beyond the cap is simply not checked.
156
+ */
157
+ for (let page = 1; page <= ASSET_LIST_MAX_PAGES; page++) {
158
+ const result = await getMedia({ page, limit: ASSET_LIST_PAGE_SIZE, kind: "file" });
159
+ for (const item of result.items ?? []) {
160
+ if ((item.kind ?? "image") !== "file")
161
+ continue;
162
+ const path = item.url ?? item.imageUrl;
163
+ if (!path)
164
+ continue;
165
+ out.push({
166
+ path,
167
+ ...(item.name ? { name: item.name } : {}),
168
+ ...(item.contentType ? { contentType: item.contentType } : {}),
169
+ ...(item.size != null ? { size: item.size } : {})
170
+ });
171
+ }
172
+ if (page >= (result.totalPages ?? 1))
173
+ break;
174
+ }
175
+ return out;
176
+ });
177
+ }
178
+ else {
179
+ setSiteAssetLister(null);
180
+ }
133
181
  return {
134
182
  pipelineCtx,
135
183
  ready,
136
184
  resumableStore,
137
185
  log,
138
- adapter: config.adapter ?? null,
186
+ adapter,
139
187
  capabilities,
140
188
  bootstrapCache
141
189
  };
142
190
  }
191
+ /*
192
+ * A cap on one upload. Generous for a document — the largest PDF on the site
193
+ * that prompted this is 183KB — and small enough that a misdirected video does
194
+ * not sit in memory as a `Uint8Array` while the adapter decides what to do
195
+ * with it.
196
+ */
197
+ const MEDIA_UPLOAD_MAX_BYTES = 25_000_000;
198
+ const ASSET_LIST_PAGE_SIZE = 100;
199
+ const ASSET_LIST_MAX_PAGES = 10;
143
200
  /*
144
201
  * Takes the *resolved* CORS map rather than the raw Origin header. It used to
145
202
  * echo the origin directly, which meant every SSE response granted a
@@ -214,11 +271,16 @@ const SUPPORTED_ROUTES = [
214
271
  "POST /draft/bootstrap",
215
272
  "GET+PUT /draft/site-config",
216
273
  "POST /ops",
274
+ "POST /checks/run",
275
+ "GET /checks/findings",
276
+ "GET /checks/runs",
277
+ "POST /checks/findings/status",
217
278
  "GET /history/status",
218
279
  "GET /history/log",
219
280
  "POST /history/undo",
220
281
  "POST /history/redo",
221
282
  "POST /history/restore",
283
+ "POST /history/discard",
222
284
  "GET /whoami",
223
285
  "GET /blocks/manifest",
224
286
  "GET /sites",
@@ -231,6 +293,7 @@ const SUPPORTED_ROUTES = [
231
293
  "DELETE /restore/snapshot",
232
294
  "GET /unsplash/search",
233
295
  "POST /media/cms",
296
+ "POST /media/upload",
234
297
  "GET+POST /telemetry/chat/feedback",
235
298
  "POST /preview/screenshot",
236
299
  "POST /audio/transcribe",
@@ -298,14 +361,31 @@ export function createOrchestrator(config = {}) {
298
361
  // up editing the bundled demo pages instead of the site's real content.
299
362
  const effectiveSiteId = config.adapter ? (config.siteId ?? "library") : config.siteId;
300
363
  /*
301
- * Declared at mount, before any request can build a manifest. The registry
302
- * holds this on `globalThis`, so it survives Next duplicating these modules
303
- * across the RSC / SSR / route-handler layers — the declaration made here has
304
- * to be visible to `/api/editor/blocks` in another copy.
364
+ * The registry holds this on `globalThis`, so it survives Next duplicating
365
+ * these modules across the RSC / SSR / route-handler layers — the declaration
366
+ * made here is visible to `/api/editor/blocks` in another copy.
367
+ *
368
+ * It is *not*, however, made "at mount" in any sense that covers the other
369
+ * route. This runs when this route's module is first evaluated, which Next
370
+ * does on the first request to this route — so an editor that asks
371
+ * `/api/editor/blocks` before anything has touched `/api/avocado/*` sees an
372
+ * undeclared catalogue and is offered Avocado's built-ins alongside the
373
+ * site's own. Pass `blockTypes` to `createEditorApiHandler` as well; it
374
+ * declares the same catalogue while building the manifest, which is the point
375
+ * that actually needs it.
305
376
  */
306
377
  if (config.blockTypes)
307
378
  declareBlockCatalogue(config.blockTypes);
308
379
  const scope = (session, bodySiteId) => scopedSessionKey(session, effectiveSiteId ?? bodySiteId);
380
+ /*
381
+ * The same resolution, for an action that scopes the session itself.
382
+ *
383
+ * `scope()` answers with a key, which is what a route needs when it holds the
384
+ * key. An action given `{ session, siteId }` computes its own, and every
385
+ * caller that forgot to fill `siteId` in silently addressed a different
386
+ * session than the route beside it — see `/checks/run`.
387
+ */
388
+ const withSiteId = (params) => effectiveSiteId ? { ...params, siteId: effectiveSiteId } : params;
309
389
  const imageDir = config.imageDir ?? resolve(process.cwd(), ".data/generated-images");
310
390
  /*
311
391
  * Built on first use rather than at mount: the store opens an append-only
@@ -699,7 +779,48 @@ export function createOrchestrator(config = {}) {
699
779
  * publish (LM-04).
700
780
  */
701
781
  await runtime.bootstrapCache.ensure(scopedSession, runtime.adapter, runtime.log);
702
- const pages = getSessionPages(scopedSession);
782
+ /*
783
+ * Hand the adapter the baseline it needs to diff.
784
+ *
785
+ * `onPublish(pages)` alone is a snapshot contract, and a snapshot is not
786
+ * invertible: every CMS read is a projection, so writing the projection
787
+ * back replaces an asset reference with a URL and a document reference
788
+ * with a dead href. A publisher has to compare against what it read, and
789
+ * it cannot compare against nothing.
790
+ *
791
+ * This is the copy the bootstrap already took, not a fresh read — a
792
+ * second `getPages()` here is 45 sequential Sanity calls on the
793
+ * integration that motivated it. It used to be absent for the rest of a
794
+ * process's life after a restart that reloaded the draft from SQLite,
795
+ * which quietly disabled publishing for any adapter that refuses without
796
+ * a baseline; `ensure` now recovers it on the first request instead.
797
+ *
798
+ * It can still be undefined — the adapter read can fail, and baselines
799
+ * are evicted FIFO — so the contract is unchanged: treat undefined as
800
+ * "no baseline available" and never as "the site was empty".
801
+ */
802
+ const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
803
+ /*
804
+ * A publish of some pages is the live site with those pages replaced,
805
+ * never the ticked pages on their own — see `publish-selection.ts`. The
806
+ * baseline is doing double duty here: the adapter diffs against it, and
807
+ * the merge fills the unselected slots from it, so an unticked page is
808
+ * byte-identical to what the adapter read and produces no writes.
809
+ */
810
+ const selection = selectPagesForPublish({
811
+ draft: getSessionPages(scopedSession),
812
+ published,
813
+ draftSiteConfig: getSiteConfig(scopedSession),
814
+ publishedSiteConfig: undefined,
815
+ selection: {
816
+ slugs: parseSelectionSlugs(body.slugs),
817
+ includeSiteConfig: body.includeSiteConfig
818
+ }
819
+ });
820
+ if (isSelectionFailure(selection)) {
821
+ return jsonResponse({ ok: false, error: selection.error }, { status: 400, cors });
822
+ }
823
+ const pages = selection.pages;
703
824
  const slugs = pages.map((p) => p.slug);
704
825
  /*
705
826
  * Every exit below records what happened, because the alternative was a
@@ -759,28 +880,7 @@ export function createOrchestrator(config = {}) {
759
880
  record(true, "Nothing written — the adapter has no onPublish");
760
881
  return jsonResponse({ ok: true, written: false, count: pages.length, reason: "adapter has no onPublish; publish is a no-op" }, { status: 200, cors });
761
882
  }
762
- const config = getSiteConfig(scopedSession);
763
- /*
764
- * Hand the adapter the baseline it needs to diff.
765
- *
766
- * `onPublish(pages)` alone is a snapshot contract, and a snapshot is not
767
- * invertible: every CMS read is a projection, so writing the projection
768
- * back replaces an asset reference with a URL and a document reference
769
- * with a dead href. A publisher has to compare against what it read, and
770
- * it cannot compare against nothing.
771
- *
772
- * This is the copy the bootstrap already took, not a fresh read — a
773
- * second `getPages()` here is 45 sequential Sanity calls on the
774
- * integration that motivated it. It used to be absent for the rest of a
775
- * process's life after a restart that reloaded the draft from SQLite,
776
- * which quietly disabled publishing for any adapter that refuses without
777
- * a baseline; `ensure` now recovers it on the first request instead.
778
- *
779
- * It can still be undefined — the adapter read can fail, and baselines
780
- * are evicted FIFO — so the contract is unchanged: treat undefined as
781
- * "no baseline available" and never as "the site was empty".
782
- */
783
- const published = runtime.bootstrapCache.baselineFor(scopedSession) ?? undefined;
883
+ const config = selection.siteConfig;
784
884
  const context = body.assets || published
785
885
  ? { ...(body.assets ? { assets: body.assets } : {}), ...(published ? { published } : {}) }
786
886
  : undefined;
@@ -1164,6 +1264,67 @@ export function createOrchestrator(config = {}) {
1164
1264
  * The bodies are the same functions the Fastify app calls, so undo means
1165
1265
  * the same thing in both. See orchestrator-core/http/history-actions.ts.
1166
1266
  */
1267
+ /*
1268
+ * ---- Checks: findings, runs, and their status --------------------------
1269
+ *
1270
+ * Mounted here in the same commit that adds them to Fastify, rather than
1271
+ * after somebody notices. Every route in `history-actions.ts` exists
1272
+ * because that lesson was learned the expensive way: a UI shipped a button
1273
+ * whose endpoint answered "not handled by createOrchestrator()".
1274
+ */
1275
+ if (request.method === "POST" && path === "/checks/run") {
1276
+ const runtime = await getRuntime();
1277
+ await runtime.ready;
1278
+ let raw;
1279
+ try {
1280
+ raw = await request.json();
1281
+ }
1282
+ catch {
1283
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1284
+ }
1285
+ const body = (raw ?? {});
1286
+ // The draft has to exist before it can be checked — a cold library-mode
1287
+ // process has no pages until this runs, and an unchecked empty session
1288
+ // would report every page as missing because there are none.
1289
+ const scoped = scope(body.session, body.siteId);
1290
+ await runtime.bootstrapCache.ensure(scoped, runtime.adapter, runtime.log);
1291
+ /*
1292
+ * `siteId` has to be the *effective* one, not whatever the caller sent.
1293
+ *
1294
+ * The action scopes the session itself, and it was being handed a body
1295
+ * with no `siteId` — so a library-mode site bootstrapped the right
1296
+ * session here and then scanned `"<session>"` instead of
1297
+ * `"<siteId>::<session>"`. That key holds the demo seed, so the checker
1298
+ * reported on Avocado's own sample pages: a real tri-lingual site with
1299
+ * sixty pages got findings for `/olives` and `/blueberries`, and none of
1300
+ * its own content was ever examined.
1301
+ */
1302
+ return actionResponse(await runChecksAction(withSiteId(body), runtime.log), cors);
1303
+ }
1304
+ if (request.method === "GET" && path === "/checks/findings") {
1305
+ const runtime = await getRuntime();
1306
+ await runtime.ready;
1307
+ const query = Object.fromEntries(url.searchParams);
1308
+ return actionResponse(await listFindingsAction(withSiteId(query), runtime.log), cors);
1309
+ }
1310
+ if (request.method === "GET" && path === "/checks/runs") {
1311
+ const runtime = await getRuntime();
1312
+ await runtime.ready;
1313
+ const query = Object.fromEntries(url.searchParams);
1314
+ return actionResponse(await listCheckRunsAction(withSiteId(query)), cors);
1315
+ }
1316
+ if (request.method === "POST" && path === "/checks/findings/status") {
1317
+ const runtime = await getRuntime();
1318
+ await runtime.ready;
1319
+ let raw;
1320
+ try {
1321
+ raw = await request.json();
1322
+ }
1323
+ catch {
1324
+ return jsonResponse({ error: "invalid JSON body" }, { status: 400, cors });
1325
+ }
1326
+ return actionResponse(await updateFindingAction(withSiteId((raw ?? {}))), cors);
1327
+ }
1167
1328
  if (request.method === "GET" && path === "/history/status") {
1168
1329
  const runtime = await getRuntime();
1169
1330
  await runtime.ready;
@@ -1176,7 +1337,7 @@ export function createOrchestrator(config = {}) {
1176
1337
  const query = Object.fromEntries(url.searchParams);
1177
1338
  return actionResponse(historyLog(query), cors);
1178
1339
  }
1179
- if (request.method === "POST" && (path === "/history/undo" || path === "/history/redo" || path === "/history/restore")) {
1340
+ if (request.method === "POST" && (path === "/history/undo" || path === "/history/redo" || path === "/history/restore" || path === "/history/discard")) {
1180
1341
  const runtime = await getRuntime();
1181
1342
  await runtime.ready;
1182
1343
  let raw;
@@ -1196,6 +1357,8 @@ export function createOrchestrator(config = {}) {
1196
1357
  : null;
1197
1358
  if (action)
1198
1359
  return actionResponse(action(body, runtime.log), cors);
1360
+ if (path === "/history/discard")
1361
+ return actionResponse(historyDiscardAction(body, runtime.log), cors);
1199
1362
  return actionResponse(historyRestoreAction(body, runtime.log), cors);
1200
1363
  }
1201
1364
  /*
@@ -1490,6 +1653,7 @@ export function createOrchestrator(config = {}) {
1490
1653
  const query = typeof body.query === "string" ? body.query.trim() : "";
1491
1654
  const page = Math.max(1, Math.trunc(Number(body.page) || 1));
1492
1655
  const limit = Math.min(50, Math.max(1, Math.trunc(Number(body.limit) || 20)));
1656
+ const kind = body.kind === "file" ? "file" : "image";
1493
1657
  const source = typeof runtime.adapter?.getMedia === "function"
1494
1658
  ? runtime.adapter.getMedia.bind(runtime.adapter)
1495
1659
  : mediaSourceFromUnknown(body.config);
@@ -1499,14 +1663,101 @@ export function createOrchestrator(config = {}) {
1499
1663
  if (!source)
1500
1664
  return jsonResponse({ error: "CMS media not configured" }, { status: 404, cors });
1501
1665
  try {
1502
- const result = await source({ query: query || undefined, page, limit });
1503
- return jsonResponse(result, { cors });
1666
+ const result = await source({ query: query || undefined, page, limit, kind });
1667
+ /*
1668
+ * Filter by kind on the way out as well as asking for it on the way in.
1669
+ *
1670
+ * An adapter written before documents existed ignores `kind` and
1671
+ * returns its images to a request for files. Trusting the response
1672
+ * would offer those images as PDFs. Filtering here means such an
1673
+ * adapter degrades to an empty document tab — which is the truth about
1674
+ * it — instead of to a wrong one, and costs a correct adapter nothing.
1675
+ */
1676
+ const items = (result.items ?? []).filter((item) => (item.kind ?? "image") === kind);
1677
+ /*
1678
+ * Whether this site takes uploads rides back on the read.
1679
+ *
1680
+ * The editor asks for the file list before it can offer a link picker,
1681
+ * so answering "and you may add one" here costs no round trip — and it
1682
+ * keeps the control's existence tied to a method that exists, rather
1683
+ * than to an assumption the UI made.
1684
+ */
1685
+ const canUpload = typeof runtime.adapter?.uploadMedia === "function";
1686
+ return jsonResponse({ ...result, items, canUpload }, { cors });
1504
1687
  }
1505
1688
  catch (error) {
1506
1689
  runtime.log.warn?.({ err: error }, "[avocado] CMS media read failed");
1507
1690
  return jsonResponse({ items: [], totalPages: 0 }, { cors });
1508
1691
  }
1509
1692
  }
1693
+ /*
1694
+ * Adding a document (or an image) to the site's own media library.
1695
+ *
1696
+ * The counterpart of `/media/cms`. The site's adapter decides where the
1697
+ * bytes go, because only it knows: a static site writes into `public/`, a
1698
+ * Sanity site uploads an asset and gets back a CDN URL. Deliberately NOT
1699
+ * routed through `/image/upload`, which writes to the orchestrator's local
1700
+ * disk — that storage loses every file on an ephemeral redeploy, and
1701
+ * putting documents on it would spread a known defect instead of leaving it
1702
+ * contained to the one POC route that has it.
1703
+ *
1704
+ * No `uploadMedia` is 404, not 500: the editor asks once and hides the
1705
+ * control, exactly as it does for the read side.
1706
+ */
1707
+ if (request.method === "POST" && path === "/media/upload") {
1708
+ const runtime = await getRuntime();
1709
+ await runtime.ready;
1710
+ const upload = runtime.adapter?.uploadMedia;
1711
+ if (typeof upload !== "function") {
1712
+ return jsonResponse({ error: "this site does not accept uploads" }, { status: 404, cors });
1713
+ }
1714
+ let form;
1715
+ try {
1716
+ form = await request.formData();
1717
+ }
1718
+ catch {
1719
+ return jsonResponse({ error: "expected multipart/form-data" }, { status: 400, cors });
1720
+ }
1721
+ const file = form.get("file");
1722
+ if (!(file instanceof File)) {
1723
+ return jsonResponse({ error: "no file in the request" }, { status: 400, cors });
1724
+ }
1725
+ if (file.size > MEDIA_UPLOAD_MAX_BYTES) {
1726
+ return jsonResponse({ error: `file is larger than ${Math.floor(MEDIA_UPLOAD_MAX_BYTES / 1_000_000)}MB` }, { status: 413, cors });
1727
+ }
1728
+ const rawKind = form.get("kind");
1729
+ const kind = rawKind === "file" ? "file" : "image";
1730
+ try {
1731
+ const item = await upload.call(runtime.adapter, {
1732
+ // Untrusted, and passed on as such: the adapter is the only thing that
1733
+ // knows what a safe name is in its own store, and sanitising here
1734
+ // would be guessing on its behalf.
1735
+ filename: file.name,
1736
+ contentType: file.type || "",
1737
+ data: new Uint8Array(await file.arrayBuffer()),
1738
+ kind
1739
+ });
1740
+ /*
1741
+ * Drop the cached asset list, so the file somebody just added is
1742
+ * linkable in the same breath — by the picker, by the checker, and by
1743
+ * the planner's site context. Waiting out the 30-second cache would
1744
+ * make a fresh upload look like a broken link.
1745
+ */
1746
+ invalidateSiteAssets();
1747
+ return jsonResponse({ item }, { cors });
1748
+ }
1749
+ catch (error) {
1750
+ /*
1751
+ * The adapter's message reaches the editor. A refusal is usually a
1752
+ * sentence a person needs to read — "we only accept PDFs", "a file of
1753
+ * that name already exists" — and swallowing it would leave the upload
1754
+ * control saying only that something went wrong.
1755
+ */
1756
+ const reason = error instanceof Error ? error.message : String(error);
1757
+ runtime.log.warn?.({ err: reason }, "[avocado] media upload refused");
1758
+ return jsonResponse({ error: reason }, { status: 400, cors });
1759
+ }
1760
+ }
1510
1761
  /* The Unsplash tab of the image picker. Unconfigured answers 404, not an
1511
1762
  * empty result set — "no key" and "no matches" are different answers. */
1512
1763
  if (request.method === "GET" && path === "/unsplash/search") {
@@ -15,7 +15,7 @@
15
15
  * keep their own reader — Fastify streams the part (aborting mid-upload once it
16
16
  * crosses the cap), library mode reads a `File` off `request.formData()`.
17
17
  */
18
- import type { ActionResult } from "./history-actions.js";
18
+ import type { ActionResult } from "./history-actions.ts";
19
19
  export type { ActionResult };
20
20
  /**
21
21
  * Formats both providers read natively.
@@ -0,0 +1,39 @@
1
+ import type { CheckRunTrigger } from "../durable/types.ts";
2
+ import type { Logger } from "../logger.ts";
3
+ import type { ActionResult } from "./history-actions.ts";
4
+ export type { ActionResult };
5
+ export type ChecksScope = {
6
+ session?: string;
7
+ siteId?: string;
8
+ };
9
+ export type RunChecksParams = ChecksScope & {
10
+ /** Restrict the scan. Cross-page rules still see the whole site. */
11
+ slugs?: string[];
12
+ trigger?: CheckRunTrigger;
13
+ };
14
+ /** POST /checks/run — scan the session's draft and reconcile its findings. */
15
+ export declare function runChecksAction(params: RunChecksParams, log?: Logger): Promise<ActionResult>;
16
+ export type ListFindingsParams = ChecksScope & {
17
+ slug?: string;
18
+ agent?: string;
19
+ status?: string;
20
+ limit?: number;
21
+ };
22
+ /** GET /checks/findings — what is currently wrong, newest and most severe first. */
23
+ export declare function listFindingsAction(params: ListFindingsParams, log?: Logger): Promise<ActionResult>;
24
+ /** GET /checks/runs — the ledger, including what each run cost. */
25
+ export declare function listCheckRunsAction(params: ChecksScope & {
26
+ limit?: number;
27
+ }): Promise<ActionResult>;
28
+ export type UpdateFindingParams = ChecksScope & {
29
+ id?: string;
30
+ status?: string;
31
+ };
32
+ /**
33
+ * POST /checks/findings/status — dismiss, snooze, or reopen one finding.
34
+ *
35
+ * `fixed` is not accepted: it is reconciliation's word, meaning "a run looked
36
+ * and the problem was gone". Letting a client assert it would put a finding
37
+ * into a state the next run immediately contradicts.
38
+ */
39
+ export declare function updateFindingAction(params: UpdateFindingParams): Promise<ActionResult>;
@@ -0,0 +1,122 @@
1
+ import { scopedSessionKey } from "../state/session-state.js";
2
+ import { getDurableStore, durableHealth } from "../durable/durable-store-singleton.js";
3
+ import { runChecksForSession } from "../checks/session-runner.js";
4
+ /*
5
+ * The checks HTTP surface, as transport-agnostic actions.
6
+ *
7
+ * Written this way from the first commit rather than as Fastify handlers,
8
+ * because the alternative is already documented next door: `history-actions.ts`
9
+ * exists because `/history/undo` was wired into Fastify only, and answered "not
10
+ * handled by createOrchestrator()" in a library-mode UI that shows an Undo
11
+ * button unconditionally. A findings panel added to one transport would be a
12
+ * panel that is permanently empty in the other.
13
+ */
14
+ const badRequest = (error) => ({ code: 400, body: { error } });
15
+ /**
16
+ * What the panel asks for when it does not say. Snoozed rows are excluded:
17
+ * including them is what made Snooze a button that removed a row until the next
18
+ * refresh put it straight back.
19
+ */
20
+ const DEFAULT_STATUSES = ["open"];
21
+ /**
22
+ * End any snooze whose week is up, on the way in.
23
+ *
24
+ * There is no scheduler in this process, so the wake has to hang off something
25
+ * that already happens; the two read paths are the only moments a snooze
26
+ * expiring is observable. Best-effort — a failed sweep leaves a finding hidden a
27
+ * little longer, which is not worth failing a request over.
28
+ */
29
+ async function wakeSnoozed(scopeKey, log) {
30
+ try {
31
+ await getDurableStore().wakeSnoozedFindings(scopeKey);
32
+ }
33
+ catch (err) {
34
+ log?.warn({ err: String(err), scopeKey }, "waking snoozed findings failed");
35
+ }
36
+ }
37
+ /** POST /checks/run — scan the session's draft and reconcile its findings. */
38
+ export async function runChecksAction(params, log) {
39
+ if (!params.session)
40
+ return badRequest("session is required");
41
+ const scopeKey = scopedSessionKey(params.session, params.siteId);
42
+ try {
43
+ await wakeSnoozed(scopeKey, log);
44
+ const run = await runChecksForSession({
45
+ scopeKey,
46
+ trigger: params.trigger ?? "manual",
47
+ ...(params.slugs?.length ? { slugs: params.slugs } : {})
48
+ });
49
+ const findings = await getDurableStore().listFindings({ scopeKey, status: DEFAULT_STATUSES });
50
+ // `durable` rides along on every response: findings written to the
51
+ // in-memory fallback look identical to durable ones until the process
52
+ // restarts, and a scheduled run is exactly when nobody is watching a log.
53
+ return { code: 200, body: { run, findings, durable: durableHealth() } };
54
+ }
55
+ catch (err) {
56
+ const reason = err instanceof Error ? err.message : String(err);
57
+ log?.error({ err: reason, scopeKey }, "checks run failed");
58
+ return { code: 500, body: { error: reason } };
59
+ }
60
+ }
61
+ const FINDING_STATUSES = ["open", "snoozed", "dismissed", "fixed"];
62
+ function parseStatuses(raw) {
63
+ if (!raw)
64
+ return undefined;
65
+ const wanted = raw
66
+ .split(",")
67
+ .map((s) => s.trim())
68
+ .filter((s) => FINDING_STATUSES.includes(s));
69
+ return wanted.length > 0 ? wanted : undefined;
70
+ }
71
+ /** GET /checks/findings — what is currently wrong, newest and most severe first. */
72
+ export async function listFindingsAction(params, log) {
73
+ if (!params.session)
74
+ return badRequest("session is required");
75
+ const scopeKey = scopedSessionKey(params.session, params.siteId);
76
+ await wakeSnoozed(scopeKey, log);
77
+ const status = parseStatuses(params.status) ?? DEFAULT_STATUSES;
78
+ const limitRaw = Number(params.limit);
79
+ const findings = await getDurableStore().listFindings({
80
+ scopeKey,
81
+ status,
82
+ ...(params.slug ? { slug: params.slug } : {}),
83
+ ...(params.agent ? { agent: params.agent } : {}),
84
+ ...(Number.isFinite(limitRaw) && limitRaw > 0 ? { limit: Math.floor(limitRaw) } : {})
85
+ });
86
+ return { code: 200, body: { findings, durable: durableHealth() } };
87
+ }
88
+ /** GET /checks/runs — the ledger, including what each run cost. */
89
+ export async function listCheckRunsAction(params) {
90
+ if (!params.session)
91
+ return badRequest("session is required");
92
+ const scopeKey = scopedSessionKey(params.session, params.siteId);
93
+ const limitRaw = Number(params.limit);
94
+ const runs = await getDurableStore().listCheckRuns(scopeKey, Number.isFinite(limitRaw) && limitRaw > 0 ? Math.floor(limitRaw) : 50);
95
+ return { code: 200, body: { runs } };
96
+ }
97
+ /**
98
+ * POST /checks/findings/status — dismiss, snooze, or reopen one finding.
99
+ *
100
+ * `fixed` is not accepted: it is reconciliation's word, meaning "a run looked
101
+ * and the problem was gone". Letting a client assert it would put a finding
102
+ * into a state the next run immediately contradicts.
103
+ */
104
+ export async function updateFindingAction(params) {
105
+ if (!params.session)
106
+ return badRequest("session is required");
107
+ if (!params.id)
108
+ return badRequest("id is required");
109
+ const status = params.status;
110
+ if (status !== "dismissed" && status !== "snoozed" && status !== "open") {
111
+ return badRequest("status must be dismissed, snoozed or open");
112
+ }
113
+ const scopeKey = scopedSessionKey(params.session, params.siteId);
114
+ const store = getDurableStore();
115
+ const finding = await store.getFinding(params.id);
116
+ // Scope check, not politeness: findings are per-site and the id is opaque, so
117
+ // without this any session could dismiss any other site's finding.
118
+ if (!finding || finding.scopeKey !== scopeKey)
119
+ return { code: 404, body: { error: "finding not found" } };
120
+ await store.setFindingStatus(params.id, status);
121
+ return { code: 200, body: { finding: await store.getFinding(params.id) } };
122
+ }
@@ -15,7 +15,7 @@
15
15
  * status code and body to send; neither Fastify nor `Response` appears in this
16
16
  * file.
17
17
  */
18
- import type { Logger } from "../logger.js";
18
+ import type { Logger } from "../logger.ts";
19
19
  /** What a caller sends. `siteId` scopes the session; both transports pass it through. */
20
20
  export type HistoryScope = {
21
21
  session?: string;
@@ -56,3 +56,46 @@ export declare function historyRestoreAction(body: {
56
56
  siteId?: string;
57
57
  targetVersion?: number;
58
58
  }, log: Logger): ActionResult;
59
+ /** One page rolled back, and how far. */
60
+ export type DiscardedPage = {
61
+ slug: string;
62
+ /** The earliest selected version on this page — the change being undone. */
63
+ fromVersion: number;
64
+ /** The version whose snapshot the page now holds, or null if it went away. */
65
+ toVersion: number | null;
66
+ /**
67
+ * Entries on this page at or after `fromVersion` that the rollback also
68
+ * takes out. A page has one timeline, so discarding a change in the middle
69
+ * of it necessarily discards what was built on top.
70
+ */
71
+ alsoDiscarded: number[];
72
+ };
73
+ /** A selected entry that could not be rolled back, and why. */
74
+ export type SkippedDiscard = {
75
+ version: number;
76
+ reason: string;
77
+ };
78
+ /**
79
+ * Throw away selected changes by rolling each affected page back to the state
80
+ * it held immediately before the earliest change selected on it.
81
+ *
82
+ * The version log is a per-page timeline, not a stack of independent patches:
83
+ * an entry records the page *after* the change, so "undo this one change and
84
+ * keep the later ones" is not a question the stored data can answer. What it
85
+ * can answer is "put this page back the way it was before change N", which is
86
+ * what discarding means here — and it is why the response names every later
87
+ * entry that goes with it. The caller shows that list before asking the user
88
+ * to confirm; this function reports it again for whatever actually happened.
89
+ *
90
+ * Selecting several entries on one page collapses to its earliest. Selecting
91
+ * entries across pages rolls each page back independently — untouched pages
92
+ * keep their edits.
93
+ *
94
+ * Like a restore, a discard is itself an edit: the current page goes onto the
95
+ * undo stack first, so a discard can be undone.
96
+ */
97
+ export declare function historyDiscardAction(body: {
98
+ session?: string;
99
+ siteId?: string;
100
+ versions?: unknown;
101
+ }, log: Logger): ActionResult;