@avocadostudio-ai/orchestrator-core 0.23.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.
@@ -3,6 +3,7 @@ import { buildBlockManifest } from "@avocadostudio-ai/shared";
3
3
  import { getDurableStore, noteDurableFailure } from "../durable/durable-store-singleton.js";
4
4
  import { agentState } from "./settings.js";
5
5
  import { countIssues, describeIssues, detectEditDamage } from "./edit-safety.js";
6
+ import { siteOpsAgentsEnabled } from "./enabled.js";
6
7
  /*
7
8
  * Binds the Edit safety diff to a real edit, and records what it found.
8
9
  *
@@ -55,7 +56,7 @@ export function pagesBefore(ops, getPage, also = []) {
55
56
  */
56
57
  export async function checkEditSafety(args) {
57
58
  try {
58
- if (args.before.size === 0)
59
+ if (args.before.size === 0 || !siteOpsAgentsEnabled())
59
60
  return null;
60
61
  const agent = await agentState(args.scopeKey, "edit-safety");
61
62
  if (!agent.enabled)
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Site ops agents — Edit safety, the Inbox, scheduled and custom agents, site
3
+ * facts, @mentions — are pre-alpha, and off unless `SITE_OPS_AGENTS=1`.
4
+ *
5
+ * Off has to mean off on the server, not just hidden tabs: Edit safety alerts
6
+ * hold publishing (409) until someone resolves them in the Inbox, so an editor
7
+ * that hides the Inbox while the server still raises alerts leaves publish
8
+ * stuck with no screen that can unstick it. Each entry point checks this:
9
+ * Edit safety raises nothing, nothing holds publish, the planner is given no
10
+ * site facts, a tick runs nothing, and an @handle goes to the planner. The
11
+ * editor reads it from `/status/planner` (`features.siteOpsAgents`) and hides
12
+ * Inbox, Agents and @-agent routing.
13
+ *
14
+ * Read per call, like every flag here, so a test or a library-mode host can set
15
+ * it after import. The hermetic test runner switches it on; the tests that pin
16
+ * the default delete it.
17
+ */
18
+ export declare function siteOpsAgentsEnabled(): boolean;
@@ -0,0 +1,21 @@
1
+ import { envFlag } from "../env-flags.js";
2
+ /**
3
+ * Site ops agents — Edit safety, the Inbox, scheduled and custom agents, site
4
+ * facts, @mentions — are pre-alpha, and off unless `SITE_OPS_AGENTS=1`.
5
+ *
6
+ * Off has to mean off on the server, not just hidden tabs: Edit safety alerts
7
+ * hold publishing (409) until someone resolves them in the Inbox, so an editor
8
+ * that hides the Inbox while the server still raises alerts leaves publish
9
+ * stuck with no screen that can unstick it. Each entry point checks this:
10
+ * Edit safety raises nothing, nothing holds publish, the planner is given no
11
+ * site facts, a tick runs nothing, and an @handle goes to the planner. The
12
+ * editor reads it from `/status/planner` (`features.siteOpsAgents`) and hides
13
+ * Inbox, Agents and @-agent routing.
14
+ *
15
+ * Read per call, like every flag here, so a test or a library-mode host can set
16
+ * it after import. The hermetic test runner switches it on; the tests that pin
17
+ * the default delete it.
18
+ */
19
+ export function siteOpsAgentsEnabled() {
20
+ return envFlag("SITE_OPS_AGENTS", false);
21
+ }
@@ -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,9 +8,10 @@ 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
+ import { siteOpsAgentsEnabled } from "./enabled.js";
14
15
  /** How many closed items the Done tab shows. */
15
16
  export const DONE_TAB_LIMIT = 50;
16
17
  function entryFromItem(stored) {
@@ -67,7 +68,7 @@ export function groupFindings(findings, enabled) {
67
68
  const first = list[0];
68
69
  const slugs = [...new Set(list.map((f) => f.slug))];
69
70
  const fixable = list.filter((f) => (f.proposedOps?.length ?? 0) > 0 || (f.fix?.ops.length ?? 0) > 0).length;
70
- const writable = list.filter((f) => fixFieldFor(f.ruleId) && !f.fix).length;
71
+ const writable = list.filter((f) => canWriteFix(f) && !f.fix).length;
71
72
  const agent = agentOfFinding(first);
72
73
  out.push({
73
74
  id: `findings:${ruleId}`,
@@ -137,6 +138,9 @@ export async function buildInboxView(scopeKey, now = Date.now()) {
137
138
  * hold for the site — a whole-site publish ships every page.
138
139
  */
139
140
  export async function publishHolds(scopeKey, slugs) {
141
+ // With agents off nobody can see or resolve an alert, so none may hold.
142
+ if (!siteOpsAgentsEnabled())
143
+ return [];
140
144
  const open = await getDurableStore().listInboxItems({ scopeKey, status: "open", limit: 500 });
141
145
  const wanted = slugs && slugs.length > 0 ? new Set(slugs) : null;
142
146
  return open.filter((item) => item.blocksPublish && (!wanted || item.slugs.some((slug) => wanted.has(slug))));
@@ -1,4 +1,5 @@
1
1
  import { getDurableStore, noteDurableFailure } from "../durable/durable-store-singleton.js";
2
+ import { siteOpsAgentsEnabled } from "./enabled.js";
2
3
  /*
3
4
  * Memory and corrections, wired in (site-ops-agents.md SO-10, SO-11).
4
5
  *
@@ -55,6 +56,8 @@ export async function listSiteFacts(scopeKey, limit = 200) {
55
56
  * because memory could not be read; it plans without it, as it did before.
56
57
  */
57
58
  export async function siteFactsForPlanner(scopeKey, log) {
59
+ if (!siteOpsAgentsEnabled())
60
+ return [];
58
61
  try {
59
62
  const facts = await listSiteFacts(scopeKey, SITE_FACTS_IN_PROMPT);
60
63
  if (facts.length === 0)
@@ -4,6 +4,7 @@ import { agentOfFinding } from "./inbox.js";
4
4
  import { runAgentSpec } from "./run-spec.js";
5
5
  import { resolveSiteAgents } from "./settings.js";
6
6
  import { CUSTOM_AGENT_PREFIX } from "./spec.js";
7
+ import { siteOpsAgentsEnabled } from "./enabled.js";
7
8
  const WEEK = 7 * 24 * 3_600_000;
8
9
  const TOP = 3;
9
10
  /** An agent's handle in chat: its id, without the `custom:` prefix. */
@@ -47,6 +48,9 @@ async function openFor(scopeKey, agentId, store) {
47
48
  return open.filter((f) => agentOfFinding(f) === agentId);
48
49
  }
49
50
  export async function handleMention(args) {
51
+ // Off, an @handle is just text: the planner gets it, as before agents existed.
52
+ if (!siteOpsAgentsEnabled())
53
+ return { handled: false };
50
54
  const head = /^\s*@([a-z0-9][a-z0-9:-]*)\b(.*)$/is.exec(args.message);
51
55
  if (!head)
52
56
  return { handled: false };
@@ -3,6 +3,7 @@ import { agentOfFinding } from "./inbox.js";
3
3
  import { runAgentSpec } from "./run-spec.js";
4
4
  import { isDue, latestSlot } from "./schedule.js";
5
5
  import { resolveSiteAgents } from "./settings.js";
6
+ import { siteOpsAgentsEnabled } from "./enabled.js";
6
7
  /*
7
8
  * POST /agents/tick (site-ops-agents.md §8 item 6): run every scheduled agent
8
9
  * whose slot has passed, on every site, and post each site's weekly digest.
@@ -22,6 +23,8 @@ export async function tickAgents(args = {}) {
22
23
  const now = args.now ?? Date.now();
23
24
  const store = args.store ?? getDurableStore();
24
25
  const outcome = { at: now, sites: 0, ran: [], failed: [], digests: [] };
26
+ if (!siteOpsAgentsEnabled())
27
+ return outcome;
25
28
  for (const scopeKey of await store.listAgentScopes()) {
26
29
  outcome.sites += 1;
27
30
  try {
@@ -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,8 @@ 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";
47
+ import { siteOpsAgentsEnabled } from "../agents/enabled.js";
46
48
  import { fileImageStore, formatImageChatFrame, generateImageAction, imageChatAction, imageChatStreamAction, interpretImageAction, validateImageChatRequest } from "../http/image-generate-actions.js";
47
49
  import { transcribeAudioAction, transcriptionUnavailable, validateAudioInput } from "../http/audio-actions.js";
48
50
  import { formatVariationFrame, parseVariationRequest, scopeVariationSession, variationsAction, variationsStreamAction } from "../http/variations-actions.js";
@@ -439,6 +441,8 @@ function publishEnvelope(session, slugs, message) {
439
441
  * export const OPTIONS = handler
440
442
  */
441
443
  export function createOrchestrator(config = {}) {
444
+ if (config.waitUntil)
445
+ setChecksKeepAlive(config.waitUntil);
442
446
  const basePath = config.basePath ?? "/api/avocado";
443
447
  // When an adapter is configured, force-scope sessions so the orchestrator's
444
448
  // built-in demo-content seed path (triggered when a session key has no `::`)
@@ -1134,6 +1138,10 @@ export function createOrchestrator(config = {}) {
1134
1138
  recordSiteBaseline(scopedSession, pages);
1135
1139
  schedulePersistState(runtime.log);
1136
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);
1137
1145
  return jsonResponse({
1138
1146
  ...publishEnvelope(body.session, slugs, logged),
1139
1147
  ok: true,
@@ -1204,7 +1212,9 @@ export function createOrchestrator(config = {}) {
1204
1212
  * offered.
1205
1213
  */
1206
1214
  audioTranscription: Boolean(process.env.OPENAI_API_KEY?.trim() || process.env.GOOGLE_GENAI_API_KEY?.trim()),
1207
- agentMode: false
1215
+ agentMode: false,
1216
+ // Pre-alpha; the editor hides Inbox, Agents and @-agents unless this is true.
1217
+ siteOpsAgents: siteOpsAgentsEnabled()
1208
1218
  },
1209
1219
  /*
1210
1220
  * The capability probe lives here rather than on `/whoami` because
@@ -1630,6 +1640,9 @@ export function createOrchestrator(config = {}) {
1630
1640
  ops: parsedOps.data,
1631
1641
  log: runtime.log
1632
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);
1633
1646
  return jsonResponse({
1634
1647
  status: "applied",
1635
1648
  summary: "Applied operations.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/orchestrator-core",
3
- "version": "0.23.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.23.0",
26
- "@avocadostudio-ai/shared": "^0.23.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",