@avocadostudio-ai/orchestrator-core 0.24.0 → 0.25.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.
@@ -25,6 +25,12 @@ export type WriteFixResult = {
25
25
  export declare function currentValue(finding: Pick<FindingRecord, "ruleId" | "evidence">, page: PageDoc): string | undefined;
26
26
  /** The ops that put `text` in place — the same ops whether the text was written or edited. */
27
27
  export declare function fixOps(finding: Pick<FindingRecord, "ruleId" | "slug" | "evidence">, text: string): Operation[];
28
+ /**
29
+ * Whether a fix can be written *and* applied for this finding: a fixable rule,
30
+ * and — for a field on a block — a path `fieldWriteOp` can express. Counting a
31
+ * finding as writable that approve would then fail on is the bug this exists for.
32
+ */
33
+ export declare function canWriteFix(finding: Pick<FindingRecord, "ruleId" | "evidence">): boolean;
28
34
  /** What the page says, compactly: headings and the first sentences of its text, for the model to summarise. */
29
35
  export declare function pageDigest(page: PageDoc, maxChars?: number): string;
30
36
  /**
@@ -1,5 +1,6 @@
1
1
  import { buildBlockManifest } from "@avocadostudio-ai/shared";
2
2
  import { fieldText, walkPageFields } from "../checks/field-walk.js";
3
+ import { fieldWriteOp, isWritablePath } from "../checks/field-ops.js";
3
4
  import { DESCRIPTION_MAX, DESCRIPTION_MIN, effectiveTitle, TITLE_MAX, TITLE_MIN } from "../checks/rules-draft.js";
4
5
  import { getAnthropicClient } from "../chat/anthropic-planner.js";
5
6
  import { defaultModelLookup } from "../chat/model-defaults.js";
@@ -43,12 +44,27 @@ export function fixOps(finding, text) {
43
44
  return [{ op: "update_page_meta", pageSlug: finding.slug, patch: { title: text } }];
44
45
  if (field === "description")
45
46
  return [{ op: "update_page_meta", pageSlug: finding.slug, patch: { description: text } }];
46
- const { blockId, path } = finding.evidence ?? {};
47
+ const { blockId, path, itemId } = finding.evidence ?? {};
47
48
  if ((field === "alt" || field === "translation") && blockId && path) {
48
- return [{ op: "update_props", pageSlug: finding.slug, blockId, patch: { [path]: text } }];
49
+ const op = fieldWriteOp({ pageSlug: finding.slug, blockId, path, value: text, ...(itemId ? { itemId } : {}) });
50
+ return op ? [op] : [];
49
51
  }
50
52
  return [];
51
53
  }
54
+ /**
55
+ * Whether a fix can be written *and* applied for this finding: a fixable rule,
56
+ * and — for a field on a block — a path `fieldWriteOp` can express. Counting a
57
+ * finding as writable that approve would then fail on is the bug this exists for.
58
+ */
59
+ export function canWriteFix(finding) {
60
+ const field = fixFieldFor(finding.ruleId);
61
+ if (!field)
62
+ return false;
63
+ if (field === "title" || field === "description")
64
+ return true;
65
+ const path = finding.evidence?.path;
66
+ return typeof path === "string" && isWritablePath(path);
67
+ }
52
68
  /** What the page says, compactly: headings and the first sentences of its text, for the model to summarise. */
53
69
  export function pageDigest(page, maxChars = 2500) {
54
70
  const lines = [];
@@ -117,6 +133,8 @@ export async function writeFix(finding, page, deps = {}) {
117
133
  const field = fixFieldFor(finding.ruleId);
118
134
  if (!field)
119
135
  return { ok: false, error: "no fix can be written for this kind of finding" };
136
+ if (!canWriteFix(finding))
137
+ return { ok: false, error: "this field sits too deep in its block for a fix to be applied" };
120
138
  const base = currentValue(finding, page);
121
139
  if (base === undefined)
122
140
  return { ok: false, error: "the field this finding is about is no longer on the page" };
@@ -8,7 +8,7 @@ import { getSiteAssets } from "../state/site-assets.js";
8
8
  import { agentForRule } from "./builtins.js";
9
9
  import { isCustomAgentId } from "./spec.js";
10
10
  import { withoutPages } from "./edit-safety-resolve.js";
11
- import { currentValue, fixFieldFor, fixOps, writeFix } from "./fix-writer.js";
11
+ import { canWriteFix, currentValue, fixOps, writeFix } from "./fix-writer.js";
12
12
  import { listSiteFacts, recordOutcome } from "./learning.js";
13
13
  import { resolveSiteAgents } from "./settings.js";
14
14
  import { siteOpsAgentsEnabled } from "./enabled.js";
@@ -68,7 +68,7 @@ export function groupFindings(findings, enabled) {
68
68
  const first = list[0];
69
69
  const slugs = [...new Set(list.map((f) => f.slug))];
70
70
  const fixable = list.filter((f) => (f.proposedOps?.length ?? 0) > 0 || (f.fix?.ops.length ?? 0) > 0).length;
71
- const writable = list.filter((f) => fixFieldFor(f.ruleId) && !f.fix).length;
71
+ const writable = list.filter((f) => canWriteFix(f) && !f.fix).length;
72
72
  const agent = agentOfFinding(first);
73
73
  out.push({
74
74
  id: `findings:${ruleId}`,
@@ -64,6 +64,7 @@ import { validateAndStripHallucinatedProps } from "./hallucination-validator.js"
64
64
  import { validateChangelogCoverage } from "./changelog-coverage-validator.js";
65
65
  import { generateAltTextFromVision, parseAltPathForOp } from "./vision-alt-generator.js";
66
66
  import { checkEditSafety, pagesBefore, summarizeReport } from "../agents/edit-safety-runner.js";
67
+ import { scheduleChecksAfterApply } from "../checks/session-runner.js";
67
68
  import { recordOutcome, siteFactsForPlanner } from "../agents/learning.js";
68
69
  /**
69
70
  * Whether the CURRENT message plausibly depends on prior conversation turns — an
@@ -3020,6 +3021,10 @@ export async function runChatPipeline(ctx, body, options) {
3020
3021
  log: ctx.log
3021
3022
  })
3022
3023
  : null;
3024
+ // A chat turn is the commonest way content changes, so it schedules
3025
+ // checks the same way /ops does: debounced, in the background, and only
3026
+ // when apply checks are on (CHECKS_ON_APPLY, or Site ops agents).
3027
+ scheduleChecksAfterApply(body.session, ctx.log);
3023
3028
  const focusBlockId = pickFocusBlockId(resolvedPlan.ops);
3024
3029
  const aiInsightChanges = buildAiInsightChanges({ plan: resolvedPlan, message: plannerMessage });
3025
3030
  const metaChangeLogEntries = buildMetaChangeLogEntries(resolvedPlan.ops);
@@ -0,0 +1,10 @@
1
+ import type { Operation } from "@avocadostudio-ai/shared";
2
+ export declare function fieldWriteOp(args: {
3
+ pageSlug: string;
4
+ blockId: string;
5
+ path: string;
6
+ value: unknown;
7
+ itemId?: string;
8
+ }): Operation | null;
9
+ /** Whether a path is one `fieldWriteOp` can write. */
10
+ export declare function isWritablePath(path: string): boolean;
@@ -0,0 +1,37 @@
1
+ /*
2
+ * The op that writes one field, from the editable path the checks report.
3
+ *
4
+ * Field-walk reports a top-level prop as `title` and a list item's field as
5
+ * `cards[0].imageAlt`. `update_props` accepts only top-level prop names, so a
6
+ * fix that sent `{ "cards[0].imageAlt": "…" }` as a patch was refused as an
7
+ * unknown prop — every alt-text fix for a card grid or a gallery, which is where
8
+ * most images live, failed on approve. A list item's field is written with
9
+ * `update_item`, addressed by the item's id when it has one (stable across a
10
+ * reorder) and by its index otherwise.
11
+ *
12
+ * Field-walk goes one list deep, so those are the only two shapes. Anything
13
+ * else returns null: no fix is offered rather than one that fails.
14
+ */
15
+ const PROP = /^[A-Za-z_$][\w$]*$/;
16
+ const LIST_FIELD = /^([A-Za-z_$][\w$]*)\[(\d+)\]\.([A-Za-z_$][\w$]*)$/;
17
+ export function fieldWriteOp(args) {
18
+ if (PROP.test(args.path)) {
19
+ return { op: "update_props", pageSlug: args.pageSlug, blockId: args.blockId, patch: { [args.path]: args.value } };
20
+ }
21
+ const match = LIST_FIELD.exec(args.path);
22
+ if (!match)
23
+ return null;
24
+ const [, listKey, index, field] = match;
25
+ return {
26
+ op: "update_item",
27
+ pageSlug: args.pageSlug,
28
+ blockId: args.blockId,
29
+ listKey: listKey,
30
+ ...(args.itemId ? { itemId: args.itemId } : { index: Number(index) }),
31
+ patch: { [field]: args.value }
32
+ };
33
+ }
34
+ /** Whether a path is one `fieldWriteOp` can write. */
35
+ export function isWritablePath(path) {
36
+ return PROP.test(path) || LIST_FIELD.test(path);
37
+ }
@@ -95,7 +95,8 @@ function entriesForBlock(block, definition) {
95
95
  kind: meta.kind,
96
96
  ...(meta.label ? { label: meta.label } : {}),
97
97
  value: isRecord(item) ? item[itemKey] : undefined,
98
- container
98
+ container,
99
+ ...(isRecord(item) && typeof item.id === "string" && item.id ? { itemId: item.id } : {})
99
100
  });
100
101
  }
101
102
  });
@@ -1,6 +1,7 @@
1
1
  import { IMAGE_PLACEHOLDER, isKnownRoute, normalizeLinkPath, parseLink, toAltPath } from "@avocadostudio-ai/shared";
2
2
  import { fieldText, groupByBlock } from "./field-walk.js";
3
3
  import { I18N_RULES } from "./rules-i18n.js";
4
+ import { fieldWriteOp } from "./field-ops.js";
4
5
  /*
5
6
  * The eleven-ish draft-tier rules. Each is a pure function; none does IO.
6
7
  *
@@ -31,6 +32,7 @@ function evidenceFor(field, excerpt) {
31
32
  blockType: field.blockType,
32
33
  ...(field.blockLabel ? { blockLabel: field.blockLabel } : {}),
33
34
  path: field.path,
35
+ ...(field.itemId ? { itemId: field.itemId } : {}),
34
36
  ...(excerpt ? { excerpt } : {})
35
37
  };
36
38
  }
@@ -200,13 +202,8 @@ const h1Count = {
200
202
  detail: `${h1s.length} blocks are set to heading level 1.`,
201
203
  evidence: evidenceFor(field),
202
204
  proposedOps: [
203
- {
204
- op: "update_props",
205
- pageSlug: ctx.page.slug,
206
- blockId: field.blockId,
207
- patch: { [field.path]: 2 }
208
- }
209
- ]
205
+ fieldWriteOp({ pageSlug: ctx.page.slug, blockId: field.blockId, path: field.path, value: 2, ...(field.itemId ? { itemId: field.itemId } : {}) })
206
+ ].filter((op) => op !== null)
210
207
  }));
211
208
  }
212
209
  };
@@ -5,6 +5,8 @@ export declare function runChecksForSession(args: {
5
5
  trigger: CheckRunTrigger;
6
6
  slugs?: string[];
7
7
  }): Promise<CheckRunRecord>;
8
+ /** Set the host's `waitUntil`. Called by `createOrchestrator`; last caller wins. */
9
+ export declare function setChecksKeepAlive(fn: ((work: Promise<unknown>) => void) | null): void;
8
10
  /**
9
11
  * Queue a draft-tier run after an apply, coalescing a burst of edits into one.
10
12
  *
@@ -2,6 +2,7 @@ import { buildBlockManifest } from "@avocadostudio-ai/shared";
2
2
  import { getSessionDraft, getSiteConfig } from "../state/session-state.js";
3
3
  import { runDraftChecks } from "./run-checks.js";
4
4
  import { getSiteAssets } from "../state/site-assets.js";
5
+ import { siteOpsAgentsEnabled } from "../agents/enabled.js";
5
6
  /*
6
7
  * Binds the pure rules engine to session state.
7
8
  *
@@ -39,7 +40,10 @@ export async function runChecksForSession(args) {
39
40
  * `on_apply` is off by default, behind `CHECKS_ON_APPLY=1`. It is the one that
40
41
  * fires on every edit, and this repo has already paid once for a fan-out
41
42
  * nobody intended — an ambient linter should be something an operator turns on
42
- * having decided to, not something they discover in a CPU graph.
43
+ * having decided to, not something they discover in a CPU graph. Switching on
44
+ * Site ops agents (`SITE_OPS_AGENTS=1`) is that decision: a finding fixed by
45
+ * hand should close without anyone pressing "Run checks", so apply checks
46
+ * follow the agents flag unless `CHECKS_ON_APPLY=0` says otherwise.
43
47
  *
44
48
  * Both are inert under NODE_ENV=test: the hermetic suite must not have a
45
49
  * background task writing findings into a store its assertions are reading.
@@ -49,19 +53,52 @@ const pending = new Map();
49
53
  function enabled(flag) {
50
54
  if (process.env.NODE_ENV === "test")
51
55
  return false;
52
- if (flag === "apply")
53
- return process.env.CHECKS_ON_APPLY === "1";
56
+ if (flag === "apply") {
57
+ if (process.env.CHECKS_ON_APPLY === "1")
58
+ return true;
59
+ if (process.env.CHECKS_ON_APPLY === "0")
60
+ return false;
61
+ return siteOpsAgentsEnabled();
62
+ }
54
63
  return process.env.CHECKS_ON_PUBLISH !== "0";
55
64
  }
65
+ /*
66
+ * Background work and serverless hosts.
67
+ *
68
+ * A check run starts after the response that triggered it has been sent. On a
69
+ * long-lived server that is free; on a serverless function the platform may
70
+ * freeze or kill the instance the moment the response is out, and the run —
71
+ * or the debounce timer in front of it — simply never happens. A host that can
72
+ * keep an invocation alive (Next's `after()`, Vercel's `waitUntil`) passes it
73
+ * as `createOrchestrator({ waitUntil })`, and every scheduled run is handed to
74
+ * it. Without one, runs stay fire-and-forget, which is exactly right on a
75
+ * long-lived host.
76
+ */
77
+ let keepAlive = null;
78
+ /** Set the host's `waitUntil`. Called by `createOrchestrator`; last caller wins. */
79
+ export function setChecksKeepAlive(fn) {
80
+ keepAlive = fn;
81
+ }
82
+ function track(work) {
83
+ if (!keepAlive)
84
+ return;
85
+ try {
86
+ keepAlive(work.catch(() => { }));
87
+ }
88
+ catch {
89
+ // A host whose waitUntil refuses (called outside a request scope) loses
90
+ // nothing but the guarantee: the work is already running.
91
+ }
92
+ }
56
93
  function runInBackground(scopeKey, trigger, log) {
57
94
  // Custom agents switched on for this event run after the draft checker,
58
95
  // on the same engine (agents/run-spec.ts). Imported lazily: the agents
59
96
  // module reads settings from the durable store, which the checker alone
60
97
  // never needs.
61
- void import("../agents/run-spec.js")
98
+ const agents = import("../agents/run-spec.js")
62
99
  .then(({ runCustomAgentsOn }) => runCustomAgentsOn({ scopeKey, on: trigger === "on_apply" ? "apply" : "publish", ...(log ? { log } : {}) }))
63
100
  .catch((err) => log?.error({ err: String(err), scopeKey, trigger }, "agent runs failed"));
64
- void runChecksForSession({ scopeKey, trigger })
101
+ const checks = runChecksForSession({ scopeKey, trigger })
65
102
  .then((run) => {
66
103
  log?.info({ scopeKey, trigger, opened: run.findingsOpened, closed: run.findingsClosed }, "checks run complete");
67
104
  })
@@ -70,6 +107,7 @@ function runInBackground(scopeKey, trigger, log) {
70
107
  // triggered it. It reports and stops.
71
108
  log?.error({ err: String(err), scopeKey, trigger }, "checks run failed");
72
109
  });
110
+ return Promise.all([agents, checks]).then(() => undefined);
73
111
  }
74
112
  /**
75
113
  * Queue a draft-tier run after an apply, coalescing a burst of edits into one.
@@ -82,25 +120,37 @@ export function scheduleChecksAfterApply(scopeKey, log) {
82
120
  if (!enabled("apply"))
83
121
  return;
84
122
  const existing = pending.get(scopeKey);
85
- if (existing)
86
- clearTimeout(existing);
123
+ if (existing) {
124
+ clearTimeout(existing.timer);
125
+ // The superseded wait resolves now: the run it promised moves to the new timer.
126
+ existing.settle();
127
+ }
128
+ let settle = () => { };
129
+ const done = new Promise((resolve) => {
130
+ settle = resolve;
131
+ });
87
132
  const timer = setTimeout(() => {
88
133
  pending.delete(scopeKey);
89
- runInBackground(scopeKey, "on_apply", log);
134
+ void runInBackground(scopeKey, "on_apply", log).finally(settle);
90
135
  }, ON_APPLY_DEBOUNCE_MS);
91
- // Do not hold the process open for a linter.
92
- timer.unref?.();
93
- pending.set(scopeKey, timer);
136
+ // Do not hold the process open for a linter — unless the host asked for
137
+ // background work to finish (waitUntil), which is exactly that request.
138
+ if (!keepAlive)
139
+ timer.unref?.();
140
+ pending.set(scopeKey, { timer, settle });
141
+ track(done);
94
142
  }
95
143
  /** Run the draft tier after a successful publish. */
96
144
  export function scheduleChecksAfterPublish(scopeKey, log) {
97
145
  if (!enabled("publish"))
98
146
  return;
99
- runInBackground(scopeKey, "on_publish", log);
147
+ track(runInBackground(scopeKey, "on_publish", log));
100
148
  }
101
149
  /** Cancel any queued run. Tests, and graceful shutdown. */
102
150
  export function cancelScheduledChecks() {
103
- for (const timer of pending.values())
104
- clearTimeout(timer);
151
+ for (const entry of pending.values()) {
152
+ clearTimeout(entry.timer);
153
+ entry.settle();
154
+ }
105
155
  pending.clear();
106
156
  }
@@ -21,6 +21,8 @@ export type FieldEntry = {
21
21
  * siblings, and the container is what makes "sibling" meaningful.
22
22
  */
23
23
  container: string;
24
+ /** A list item's own id, when it has one — addresses the item across a reorder. */
25
+ itemId?: string;
24
26
  };
25
27
  /**
26
28
  * One link on the page, wherever it was written.
@@ -15,6 +15,8 @@ export type FindingStatus = "open" | "snoozed" | "dismissed" | "fixed";
15
15
  export type FindingEvidence = {
16
16
  source: "draft" | "rendered" | "model";
17
17
  blockId?: string;
18
+ /** For a field inside a list: the item's own id, so a fix still lands after a reorder. */
19
+ itemId?: string;
18
20
  /** For an image finding (alt text): the image, so a fix can be written from it. */
19
21
  imageUrl?: string;
20
22
  /** For a translation finding: the page's language, and the source text it should translate. */
@@ -214,6 +214,17 @@ export interface CreateOrchestratorConfig {
214
214
  * offering the matching tools.
215
215
  */
216
216
  capabilities?: CmsCapabilities;
217
+ /**
218
+ * Keep background work alive after the response is sent: Next's `after()`,
219
+ * or Vercel's `waitUntil` from `@vercel/functions`.
220
+ *
221
+ * Checks run in the background after a publish and after edits, so they can
222
+ * never fail the request that triggered them. A long-lived server needs
223
+ * nothing here. On a serverless host the platform may stop the instance as
224
+ * soon as the response is out, and without this hook those runs are
225
+ * silently lost.
226
+ */
227
+ waitUntil?: (work: Promise<unknown>) => void;
217
228
  }
218
229
  /**
219
230
  * A handler returned by {@link createOrchestrator}. Callable like the bare
@@ -43,6 +43,7 @@ import { screenshotAction } from "../http/screenshot-actions.js";
43
43
  import { runChecksAction, listFindingsAction, listCheckRunsAction, updateFindingAction } from "../http/checks-actions.js";
44
44
  import { listInboxAction, resolveAlertAction, undoAlertAction, markReportReadAction, approveFindingsAction, writeFixesAction, rejectFindingsAction, listAgentsAction, agentStatsAction, updateAgentsAction, backtestAgentsAction, listTemplatesAction, saveAgentSpecAction, removeAgentSpecAction, runAgentAction, tickAgentsAction, draftAgentAction, mentionAction, listFactsAction, addFactAction, removeFactAction } from "../http/inbox-actions.js";
45
45
  import { publishHoldResponse } from "../agents/inbox.js";
46
+ import { scheduleChecksAfterApply, scheduleChecksAfterPublish, setChecksKeepAlive } from "../checks/session-runner.js";
46
47
  import { siteOpsAgentsEnabled } from "../agents/enabled.js";
47
48
  import { fileImageStore, formatImageChatFrame, generateImageAction, imageChatAction, imageChatStreamAction, interpretImageAction, validateImageChatRequest } from "../http/image-generate-actions.js";
48
49
  import { transcribeAudioAction, transcriptionUnavailable, validateAudioInput } from "../http/audio-actions.js";
@@ -440,6 +441,8 @@ function publishEnvelope(session, slugs, message) {
440
441
  * export const OPTIONS = handler
441
442
  */
442
443
  export function createOrchestrator(config = {}) {
444
+ if (config.waitUntil)
445
+ setChecksKeepAlive(config.waitUntil);
443
446
  const basePath = config.basePath ?? "/api/avocado";
444
447
  // When an adapter is configured, force-scope sessions so the orchestrator's
445
448
  // built-in demo-content seed path (triggered when a session key has no `::`)
@@ -1135,6 +1138,10 @@ export function createOrchestrator(config = {}) {
1135
1138
  recordSiteBaseline(scopedSession, pages);
1136
1139
  schedulePersistState(runtime.log);
1137
1140
  }
1141
+ // The moment content ships is when anyone cares whether it is broken —
1142
+ // and a dry run is a rehearsal of exactly that moment. Background work: a
1143
+ // checker must never be able to fail the publish that triggered it.
1144
+ scheduleChecksAfterPublish(scopedSession, runtime.log);
1138
1145
  return jsonResponse({
1139
1146
  ...publishEnvelope(body.session, slugs, logged),
1140
1147
  ok: true,
@@ -1633,6 +1640,9 @@ export function createOrchestrator(config = {}) {
1633
1640
  ops: parsedOps.data,
1634
1641
  log: runtime.log
1635
1642
  });
1643
+ // Same as the standalone server's /ops: debounced, so a streamed
1644
+ // multi-op plan is checked once it has finished landing.
1645
+ scheduleChecksAfterApply(scopedSession, runtime.log);
1636
1646
  return jsonResponse({
1637
1647
  status: "applied",
1638
1648
  summary: "Applied operations.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./package.json": "./package.json",
@@ -22,8 +22,8 @@
22
22
  "openai": "^4.87.1",
23
23
  "sharp": "^0.35.4",
24
24
  "zod": "^4.3.6",
25
- "@avocadostudio-ai/migration-sdk": "^0.24.0",
26
- "@avocadostudio-ai/shared": "^0.24.0"
25
+ "@avocadostudio-ai/migration-sdk": "^0.25.0",
26
+ "@avocadostudio-ai/shared": "^0.25.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@anthropic-ai/claude-agent-sdk": "^0.3.220",