@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.
- package/dist/agents/edit-safety-runner.js +2 -1
- package/dist/agents/enabled.d.ts +18 -0
- package/dist/agents/enabled.js +21 -0
- package/dist/agents/fix-writer.d.ts +6 -0
- package/dist/agents/fix-writer.js +20 -2
- package/dist/agents/inbox.js +6 -2
- package/dist/agents/learning.js +3 -0
- package/dist/agents/mention.js +4 -0
- package/dist/agents/tick.js +3 -0
- package/dist/chat/chat-pipeline.js +5 -0
- package/dist/checks/field-ops.d.ts +10 -0
- package/dist/checks/field-ops.js +37 -0
- package/dist/checks/field-walk.js +2 -1
- package/dist/checks/rules-draft.js +4 -7
- package/dist/checks/session-runner.d.ts +2 -0
- package/dist/checks/session-runner.js +64 -14
- package/dist/checks/types.d.ts +2 -0
- package/dist/durable/types.d.ts +2 -0
- package/dist/handler/create-orchestrator.d.ts +11 -0
- package/dist/handler/create-orchestrator.js +14 -1
- package/package.json +3 -3
|
@@ -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
|
-
|
|
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" };
|
package/dist/agents/inbox.js
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
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))));
|
package/dist/agents/learning.js
CHANGED
|
@@ -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)
|
package/dist/agents/mention.js
CHANGED
|
@@ -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 };
|
package/dist/agents/tick.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
104
|
-
clearTimeout(timer);
|
|
151
|
+
for (const entry of pending.values()) {
|
|
152
|
+
clearTimeout(entry.timer);
|
|
153
|
+
entry.settle();
|
|
154
|
+
}
|
|
105
155
|
pending.clear();
|
|
106
156
|
}
|
package/dist/checks/types.d.ts
CHANGED
|
@@ -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.
|
package/dist/durable/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
26
|
-
"@avocadostudio-ai/shared": "^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",
|