@avocadostudio-ai/orchestrator-core 0.22.0 → 0.24.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/agent/agent-loop-openai.js +3 -1
- package/dist/agent/agent-provider.js +2 -2
- package/dist/agents/backtest.d.ts +35 -0
- package/dist/agents/backtest.js +92 -0
- package/dist/agents/builtins.d.ts +21 -0
- package/dist/agents/builtins.js +102 -0
- package/dist/agents/draft-spec.d.ts +44 -0
- package/dist/agents/draft-spec.js +195 -0
- package/dist/agents/edit-safety-resolve.d.ts +70 -0
- package/dist/agents/edit-safety-resolve.js +195 -0
- package/dist/agents/edit-safety-runner.d.ts +68 -0
- package/dist/agents/edit-safety-runner.js +157 -0
- package/dist/agents/edit-safety.d.ts +82 -0
- package/dist/agents/edit-safety.js +425 -0
- package/dist/agents/enabled.d.ts +18 -0
- package/dist/agents/enabled.js +21 -0
- package/dist/agents/fix-writer.d.ts +38 -0
- package/dist/agents/fix-writer.js +197 -0
- package/dist/agents/inbox.d.ts +141 -0
- package/dist/agents/inbox.js +364 -0
- package/dist/agents/learning.d.ts +32 -0
- package/dist/agents/learning.js +74 -0
- package/dist/agents/mention.d.ts +76 -0
- package/dist/agents/mention.js +165 -0
- package/dist/agents/run-spec.d.ts +27 -0
- package/dist/agents/run-spec.js +82 -0
- package/dist/agents/schedule.d.ts +22 -0
- package/dist/agents/schedule.js +53 -0
- package/dist/agents/settings.d.ts +30 -0
- package/dist/agents/settings.js +64 -0
- package/dist/agents/spec.d.ts +84 -0
- package/dist/agents/spec.js +70 -0
- package/dist/agents/stats.d.ts +15 -0
- package/dist/agents/stats.js +48 -0
- package/dist/agents/templates.d.ts +34 -0
- package/dist/agents/templates.js +268 -0
- package/dist/agents/tick.d.ts +59 -0
- package/dist/agents/tick.js +164 -0
- package/dist/chat/anthropic-model-caps.d.ts +19 -0
- package/dist/chat/anthropic-model-caps.js +56 -0
- package/dist/chat/anthropic-planner.d.ts +1 -0
- package/dist/chat/anthropic-planner.js +72 -15
- package/dist/chat/chat-pipeline.js +54 -1
- package/dist/chat/model-defaults.js +6 -3
- package/dist/checks/rules-draft.d.ts +6 -1
- package/dist/checks/rules-draft.js +10 -7
- package/dist/checks/rules-i18n.d.ts +25 -0
- package/dist/checks/rules-i18n.js +193 -0
- package/dist/checks/run-checks.d.ts +16 -1
- package/dist/checks/run-checks.js +80 -63
- package/dist/checks/session-runner.js +7 -0
- package/dist/checks/types.d.ts +6 -0
- package/dist/durable/in-memory-durable-store.d.ts +34 -1
- package/dist/durable/in-memory-durable-store.js +122 -1
- package/dist/durable/index.d.ts +1 -1
- package/dist/durable/sqlite-durable-store.d.ts +19 -1
- package/dist/durable/sqlite-durable-store.js +220 -1
- package/dist/durable/types.d.ts +146 -0
- package/dist/handler/create-orchestrator.js +195 -2
- package/dist/http/history-actions.d.ts +22 -0
- package/dist/http/history-actions.js +33 -5
- package/dist/http/inbox-actions.d.ts +156 -0
- package/dist/http/inbox-actions.js +498 -0
- package/dist/http/ops-actions.d.ts +17 -1
- package/dist/http/ops-actions.js +36 -0
- package/dist/http/variation-preview-actions.d.ts +30 -0
- package/dist/http/variation-preview-actions.js +75 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -0
- package/dist/nlp/deterministic-planner.d.ts +1 -1
- package/dist/nlp/intent-detection.d.ts +15 -0
- package/dist/nlp/intent-detection.js +5 -0
- package/dist/state/session-state.d.ts +11 -3
- package/dist/state/session-state.js +8 -0
- package/dist/state/sqlite-store.d.ts +12 -0
- package/dist/telemetry/usage.js +4 -0
- package/package.json +3 -3
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { getDurableStore } from "../durable/durable-store-singleton.js";
|
|
2
|
+
import { consoleLogger } from "../logger.js";
|
|
3
|
+
import { applyOpsAtomically, toErrorDetail } from "../ops/ops-engine.js";
|
|
4
|
+
import { restoreSnapshot } from "../http/history-actions.js";
|
|
5
|
+
import { bumpVersion, getPage, pushUndo, pushVersionEntry, schedulePersistState } from "../state/session-state.js";
|
|
6
|
+
import { isStillAsEditLeftIt, revertFor } from "./edit-safety.js";
|
|
7
|
+
import { EDIT_SAFETY_AGENT } from "./edit-safety-runner.js";
|
|
8
|
+
import { recordOutcome } from "./learning.js";
|
|
9
|
+
function alertPayload(item) {
|
|
10
|
+
const payload = item.payload;
|
|
11
|
+
if (!payload || !Array.isArray(payload.issues))
|
|
12
|
+
return null;
|
|
13
|
+
return payload;
|
|
14
|
+
}
|
|
15
|
+
/** A page's content, without the timestamp every write restamps. */
|
|
16
|
+
export function contentHash(page) {
|
|
17
|
+
if (!page)
|
|
18
|
+
return "null";
|
|
19
|
+
const { updatedAt: _updatedAt, ...content } = page;
|
|
20
|
+
return JSON.stringify(content);
|
|
21
|
+
}
|
|
22
|
+
async function applyReverts(scopeKey, issues, by) {
|
|
23
|
+
const outcome = { applied: 0, changedSince: 0, failed: [] };
|
|
24
|
+
const appliedOps = [];
|
|
25
|
+
const touched = new Map();
|
|
26
|
+
let lastSlug;
|
|
27
|
+
for (const issue of issues) {
|
|
28
|
+
if (!issue.revert)
|
|
29
|
+
continue;
|
|
30
|
+
const slug = "pageSlug" in issue.revert ? String(issue.revert.pageSlug) : issue.slug;
|
|
31
|
+
const page = getPage(scopeKey, slug);
|
|
32
|
+
if (!isStillAsEditLeftIt(issue, page)) {
|
|
33
|
+
outcome.changedSince += 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const before = page ? structuredClone(page) : null;
|
|
38
|
+
const op = revertFor(issue, page);
|
|
39
|
+
await applyOpsAtomically(scopeKey, [op]);
|
|
40
|
+
if (!touched.has(slug))
|
|
41
|
+
touched.set(slug, before);
|
|
42
|
+
appliedOps.push(op);
|
|
43
|
+
outcome.applied += 1;
|
|
44
|
+
lastSlug = slug;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
outcome.failed.push({
|
|
48
|
+
...(issue.path ? { path: issue.path } : {}),
|
|
49
|
+
...(issue.blockId ? { blockId: issue.blockId } : {}),
|
|
50
|
+
error: toErrorDetail(error)
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (appliedOps.length === 0 || !lastSlug)
|
|
55
|
+
return { outcome, pages: {} };
|
|
56
|
+
// One undo step per page for the whole revert, taken before its first op.
|
|
57
|
+
for (const [slug, snapshot] of touched)
|
|
58
|
+
pushUndo(scopeKey, slug, snapshot);
|
|
59
|
+
const previewVersion = bumpVersion(scopeKey);
|
|
60
|
+
const snapshot = getPage(scopeKey, lastSlug);
|
|
61
|
+
pushVersionEntry(scopeKey, {
|
|
62
|
+
version: previewVersion,
|
|
63
|
+
slug: lastSlug,
|
|
64
|
+
summary: `Edit safety: put back ${appliedOps.length} ${appliedOps.length === 1 ? "change" : "changes"}`,
|
|
65
|
+
opTypes: appliedOps.map((op) => op.op),
|
|
66
|
+
opCount: appliedOps.length,
|
|
67
|
+
source: "direct",
|
|
68
|
+
actor: { kind: "user", ...(by ? { id: by } : {}) },
|
|
69
|
+
snapshot: snapshot ? structuredClone(snapshot) : null
|
|
70
|
+
});
|
|
71
|
+
outcome.previewVersion = previewVersion;
|
|
72
|
+
const pages = {};
|
|
73
|
+
for (const [slug, before] of touched)
|
|
74
|
+
pages[slug] = { before, afterHash: contentHash(getPage(scopeKey, slug)) };
|
|
75
|
+
return { outcome, pages };
|
|
76
|
+
}
|
|
77
|
+
export async function resolveEditSafetyAlert(args) {
|
|
78
|
+
const store = getDurableStore();
|
|
79
|
+
const item = await store.getInboxItem(args.itemId);
|
|
80
|
+
// Another site's item is reported exactly like a missing one.
|
|
81
|
+
if (!item || item.scopeKey !== args.scopeKey || item.agent !== EDIT_SAFETY_AGENT) {
|
|
82
|
+
return { ok: false, code: 404, error: "alert not found" };
|
|
83
|
+
}
|
|
84
|
+
if (item.status !== "open")
|
|
85
|
+
return { ok: false, code: 409, error: "alert already resolved" };
|
|
86
|
+
const payload = alertPayload(item);
|
|
87
|
+
if (!payload)
|
|
88
|
+
return { ok: false, code: 422, error: "alert has no issues to act on" };
|
|
89
|
+
let revert;
|
|
90
|
+
let revertedPages;
|
|
91
|
+
if (args.decision === "revert") {
|
|
92
|
+
const result = await applyReverts(args.scopeKey, payload.issues, args.resolvedBy);
|
|
93
|
+
revert = result.outcome;
|
|
94
|
+
revertedPages = result.pages;
|
|
95
|
+
if (revert.applied > 0)
|
|
96
|
+
schedulePersistState(args.log ?? consoleLogger());
|
|
97
|
+
}
|
|
98
|
+
const resolved = await store.resolveInboxItem(item.id, {
|
|
99
|
+
resolution: args.decision === "revert" ? "reverted" : "intended",
|
|
100
|
+
...(args.resolvedBy ? { resolvedBy: args.resolvedBy } : {}),
|
|
101
|
+
...(revert ? { payload: { revert, revertedPages } } : {})
|
|
102
|
+
});
|
|
103
|
+
// Somebody else answered between our read and our write. Their answer stands.
|
|
104
|
+
if (!resolved)
|
|
105
|
+
return { ok: false, code: 409, error: "alert already resolved" };
|
|
106
|
+
await recordOutcome({
|
|
107
|
+
scopeKey: args.scopeKey,
|
|
108
|
+
traceId: payload.traceId ?? item.id,
|
|
109
|
+
slug: item.slugs[0],
|
|
110
|
+
request: payload.request ?? item.title,
|
|
111
|
+
proposed: payload.issues.flatMap((issue) => (issue.revert ? [issue.revert] : [])),
|
|
112
|
+
// The edit was the proposal: reverting its damage undoes it, "it was
|
|
113
|
+
// meant" accepts it as it stands.
|
|
114
|
+
outcome: args.decision === "revert" ? "undone" : "accepted",
|
|
115
|
+
log: args.log
|
|
116
|
+
});
|
|
117
|
+
return { ok: true, item: withoutPages(resolved), ...(revert ? { revert } : {}) };
|
|
118
|
+
}
|
|
119
|
+
/** The stored page snapshots are the server's business; the editor never needs them. */
|
|
120
|
+
export function withoutPages(item) {
|
|
121
|
+
if (!item.payload || !("revertedPages" in item.payload))
|
|
122
|
+
return item;
|
|
123
|
+
const { revertedPages: _pages, ...payload } = item.payload;
|
|
124
|
+
return { ...item, payload };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Undo "Put it back": the pages go back to what the edit left, and the alert
|
|
128
|
+
* reopens — the damage is back, so is the publish hold, and the person can
|
|
129
|
+
* decide again (site-ops-agents.md SO-08, "Undo everywhere").
|
|
130
|
+
*
|
|
131
|
+
* All or nothing, and only while every page the revert touched is exactly as
|
|
132
|
+
* the revert left it. A page somebody has edited since is not rolled back
|
|
133
|
+
* under them; the answer names it and points at History instead.
|
|
134
|
+
*/
|
|
135
|
+
export async function undoRevert(args) {
|
|
136
|
+
const store = getDurableStore();
|
|
137
|
+
const item = await store.getInboxItem(args.itemId);
|
|
138
|
+
if (!item || item.scopeKey !== args.scopeKey || item.agent !== EDIT_SAFETY_AGENT) {
|
|
139
|
+
return { ok: false, code: 404, error: "alert not found" };
|
|
140
|
+
}
|
|
141
|
+
const pages = item.payload?.revertedPages;
|
|
142
|
+
if (item.status !== "resolved" || item.resolution !== "reverted" || !pages || Object.keys(pages).length === 0) {
|
|
143
|
+
return { ok: false, code: 409, error: "only a revert that changed something can be undone" };
|
|
144
|
+
}
|
|
145
|
+
const changedSlugs = Object.entries(pages)
|
|
146
|
+
.filter(([slug, page]) => contentHash(getPage(args.scopeKey, slug)) !== page.afterHash)
|
|
147
|
+
.map(([slug]) => slug);
|
|
148
|
+
if (changedSlugs.length > 0) {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
code: 409,
|
|
152
|
+
error: `The page changed after the revert (${changedSlugs.join(", ")}); use History to go back instead.`,
|
|
153
|
+
changedSlugs
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const slugs = Object.keys(pages);
|
|
157
|
+
for (const [slug, { before }] of Object.entries(pages)) {
|
|
158
|
+
if (!before)
|
|
159
|
+
continue;
|
|
160
|
+
pushUndo(args.scopeKey, slug, getPage(args.scopeKey, slug));
|
|
161
|
+
// Restamped by restoreSnapshot, so the preview accepts it as newer.
|
|
162
|
+
restoreSnapshot(args.scopeKey, before, { types: new Set(), mode: "keep-current" });
|
|
163
|
+
}
|
|
164
|
+
const previewVersion = bumpVersion(args.scopeKey);
|
|
165
|
+
const last = slugs[slugs.length - 1];
|
|
166
|
+
const snapshot = getPage(args.scopeKey, last);
|
|
167
|
+
pushVersionEntry(args.scopeKey, {
|
|
168
|
+
version: previewVersion,
|
|
169
|
+
slug: last,
|
|
170
|
+
summary: "Undid an Edit safety revert",
|
|
171
|
+
opTypes: [],
|
|
172
|
+
opCount: 0,
|
|
173
|
+
source: "restore",
|
|
174
|
+
actor: { kind: "user", ...(args.by ? { id: args.by } : {}) },
|
|
175
|
+
snapshot: snapshot ? structuredClone(snapshot) : null
|
|
176
|
+
});
|
|
177
|
+
schedulePersistState(args.log ?? consoleLogger());
|
|
178
|
+
const reopened = await store.reopenInboxItem(item.id, {
|
|
179
|
+
payload: { revertedPages: undefined, undoneRevert: { at: Date.now(), ...(args.by ? { by: args.by } : {}) } }
|
|
180
|
+
});
|
|
181
|
+
if (!reopened)
|
|
182
|
+
return { ok: false, code: 409, error: "alert changed while undoing" };
|
|
183
|
+
// The revert was recorded as the edit being undone; taking it back accepts the edit.
|
|
184
|
+
const payload = alertPayload(item);
|
|
185
|
+
await recordOutcome({
|
|
186
|
+
scopeKey: args.scopeKey,
|
|
187
|
+
traceId: payload?.traceId ?? item.id,
|
|
188
|
+
slug: item.slugs[0],
|
|
189
|
+
request: `Undid the revert of: ${payload?.request ?? item.title}`,
|
|
190
|
+
proposed: [],
|
|
191
|
+
outcome: "accepted",
|
|
192
|
+
log: args.log
|
|
193
|
+
});
|
|
194
|
+
return { ok: true, item: withoutPages(reopened), previewVersion, slugs };
|
|
195
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { type Operation, type PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { Logger } from "../logger.ts";
|
|
3
|
+
import { type EditSafetyCounts, type EditSafetyIssue } from "./edit-safety.ts";
|
|
4
|
+
/** How long a chat-turn alert waits in the chat before it also lands in the Inbox. */
|
|
5
|
+
export declare const CHAT_ALERT_GRACE_MS: number;
|
|
6
|
+
export type EditSafetySource = "chat" | "mcp";
|
|
7
|
+
export type EditSafetyReport = {
|
|
8
|
+
itemId: string;
|
|
9
|
+
slugs: string[];
|
|
10
|
+
counts: EditSafetyCounts;
|
|
11
|
+
issues: EditSafetyIssue[];
|
|
12
|
+
};
|
|
13
|
+
/** What an alert's `payload` holds. Read back by the resolve path and the editor. */
|
|
14
|
+
export type EditSafetyAlertPayload = {
|
|
15
|
+
issues: EditSafetyIssue[];
|
|
16
|
+
counts: EditSafetyCounts;
|
|
17
|
+
source: EditSafetySource;
|
|
18
|
+
/** The sentence that produced the edit, when there was one. */
|
|
19
|
+
request?: string;
|
|
20
|
+
traceId?: string;
|
|
21
|
+
};
|
|
22
|
+
export declare const EDIT_SAFETY_AGENT = "edit-safety";
|
|
23
|
+
/**
|
|
24
|
+
* Every page a batch of ops names, as it stands before the batch — the
|
|
25
|
+
* `before` checkEditSafety diffs against.
|
|
26
|
+
*
|
|
27
|
+
* A chat turn used to keep only the page it was about, so a plan that also
|
|
28
|
+
* edited a second page (a shared nav label, a translated copy) was never
|
|
29
|
+
* checked there. `also` adds pages the caller knows about that an op may not
|
|
30
|
+
* name. A page an op creates is recorded as null; detectEditDamage skips it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function pagesBefore(ops: Operation[], getPage: (slug: string) => PageDoc | null | undefined, also?: string[]): Map<string, PageDoc | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Check one applied batch. Never throws and never blocks the edit: a failure
|
|
35
|
+
* here is logged and the edit stands as it would have without this agent.
|
|
36
|
+
*/
|
|
37
|
+
export declare function checkEditSafety(args: {
|
|
38
|
+
scopeKey: string;
|
|
39
|
+
/** Every page the batch touched, as it stood before. `null` for one it created. */
|
|
40
|
+
before: Map<string, PageDoc | null>;
|
|
41
|
+
/** The page as it stands now, by the slug it had before the batch. */
|
|
42
|
+
afterOf: (slug: string) => PageDoc | null;
|
|
43
|
+
ops: Operation[];
|
|
44
|
+
source: EditSafetySource;
|
|
45
|
+
request?: string;
|
|
46
|
+
traceId?: string;
|
|
47
|
+
log?: Logger;
|
|
48
|
+
now?: number;
|
|
49
|
+
}): Promise<EditSafetyReport | null>;
|
|
50
|
+
/** What travels to the editor with a chat turn: enough to name each issue, no whole blocks. */
|
|
51
|
+
export type EditSafetySummaryIssue = {
|
|
52
|
+
kind: EditSafetyIssue["kind"];
|
|
53
|
+
slug: string;
|
|
54
|
+
blockId?: string;
|
|
55
|
+
blockType?: string;
|
|
56
|
+
blockLabel?: string;
|
|
57
|
+
path?: string;
|
|
58
|
+
label?: string;
|
|
59
|
+
/** What was lost, as text: the old value, or a dropped entry's first text. */
|
|
60
|
+
before?: string;
|
|
61
|
+
};
|
|
62
|
+
export type EditSafetySummary = {
|
|
63
|
+
itemId: string;
|
|
64
|
+
slugs: string[];
|
|
65
|
+
counts: EditSafetyCounts;
|
|
66
|
+
issues: EditSafetySummaryIssue[];
|
|
67
|
+
};
|
|
68
|
+
export declare function summarizeReport(report: EditSafetyReport): EditSafetySummary;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { buildBlockManifest } from "@avocadostudio-ai/shared";
|
|
3
|
+
import { getDurableStore, noteDurableFailure } from "../durable/durable-store-singleton.js";
|
|
4
|
+
import { agentState } from "./settings.js";
|
|
5
|
+
import { countIssues, describeIssues, detectEditDamage } from "./edit-safety.js";
|
|
6
|
+
import { siteOpsAgentsEnabled } from "./enabled.js";
|
|
7
|
+
/*
|
|
8
|
+
* Binds the Edit safety diff to a real edit, and records what it found.
|
|
9
|
+
*
|
|
10
|
+
* Called after a batch has been applied, with the pages as they stood before
|
|
11
|
+
* it. Every path that writes a draft on somebody's behalf goes through here:
|
|
12
|
+
* the chat turn, and `/ops` when the caller is an MCP client. A person typing
|
|
13
|
+
* in the property panel also reaches `/ops`, and is not checked — emptying a
|
|
14
|
+
* field by hand is the edit, not damage done alongside one.
|
|
15
|
+
*
|
|
16
|
+
* What it produces is one Inbox alert per edit, holding publish for the pages
|
|
17
|
+
* it names until someone reverts it or says it was meant (SO-18). Where it
|
|
18
|
+
* shows up first depends on who is watching (SO-06 surface 2):
|
|
19
|
+
*
|
|
20
|
+
* - a chat turn: the person who asked is looking at the chat, so the alert
|
|
21
|
+
* rides back on the turn's result and stays out of the Inbox list for
|
|
22
|
+
* `CHAT_ALERT_GRACE_MS`. Publish is held from the start either way.
|
|
23
|
+
* - MCP, or anything else unattended: straight into the Inbox.
|
|
24
|
+
*/
|
|
25
|
+
/** How long a chat-turn alert waits in the chat before it also lands in the Inbox. */
|
|
26
|
+
export const CHAT_ALERT_GRACE_MS = 15 * 60 * 1000;
|
|
27
|
+
export const EDIT_SAFETY_AGENT = "edit-safety";
|
|
28
|
+
/**
|
|
29
|
+
* Every page a batch of ops names, as it stands before the batch — the
|
|
30
|
+
* `before` checkEditSafety diffs against.
|
|
31
|
+
*
|
|
32
|
+
* A chat turn used to keep only the page it was about, so a plan that also
|
|
33
|
+
* edited a second page (a shared nav label, a translated copy) was never
|
|
34
|
+
* checked there. `also` adds pages the caller knows about that an op may not
|
|
35
|
+
* name. A page an op creates is recorded as null; detectEditDamage skips it.
|
|
36
|
+
*/
|
|
37
|
+
export function pagesBefore(ops, getPage, also = []) {
|
|
38
|
+
const slugs = new Set(also);
|
|
39
|
+
for (const op of ops) {
|
|
40
|
+
const slug = op.pageSlug;
|
|
41
|
+
if (typeof slug === "string" && slug)
|
|
42
|
+
slugs.add(slug);
|
|
43
|
+
if (op.op === "create_page")
|
|
44
|
+
slugs.add(op.page.slug);
|
|
45
|
+
}
|
|
46
|
+
const before = new Map();
|
|
47
|
+
for (const slug of slugs) {
|
|
48
|
+
const page = getPage(slug);
|
|
49
|
+
before.set(slug, page ? structuredClone(page) : null);
|
|
50
|
+
}
|
|
51
|
+
return before;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Check one applied batch. Never throws and never blocks the edit: a failure
|
|
55
|
+
* here is logged and the edit stands as it would have without this agent.
|
|
56
|
+
*/
|
|
57
|
+
export async function checkEditSafety(args) {
|
|
58
|
+
try {
|
|
59
|
+
if (args.before.size === 0 || !siteOpsAgentsEnabled())
|
|
60
|
+
return null;
|
|
61
|
+
const agent = await agentState(args.scopeKey, "edit-safety");
|
|
62
|
+
if (!agent.enabled)
|
|
63
|
+
return null;
|
|
64
|
+
const manifest = buildBlockManifest();
|
|
65
|
+
const issues = [];
|
|
66
|
+
for (const [slug, before] of args.before) {
|
|
67
|
+
issues.push(...detectEditDamage({ slug, before, after: args.afterOf(slug), ops: args.ops, manifest }));
|
|
68
|
+
}
|
|
69
|
+
if (issues.length === 0)
|
|
70
|
+
return null;
|
|
71
|
+
const at = args.now ?? Date.now();
|
|
72
|
+
const slugs = [...new Set(issues.map((issue) => issue.slug))];
|
|
73
|
+
const counts = countIssues(issues);
|
|
74
|
+
const itemId = `edit-safety:${randomUUID()}`;
|
|
75
|
+
const what = describeIssues(issues);
|
|
76
|
+
const origin = args.source === "chat" ? "An AI edit" : "An MCP edit";
|
|
77
|
+
const payload = {
|
|
78
|
+
issues,
|
|
79
|
+
counts,
|
|
80
|
+
source: args.source,
|
|
81
|
+
...(args.request ? { request: args.request.slice(0, 500) } : {}),
|
|
82
|
+
...(args.traceId ? { traceId: args.traceId } : {})
|
|
83
|
+
};
|
|
84
|
+
await getDurableStore().putInboxItem({
|
|
85
|
+
id: itemId,
|
|
86
|
+
scopeKey: args.scopeKey,
|
|
87
|
+
kind: "alert",
|
|
88
|
+
agent: EDIT_SAFETY_AGENT,
|
|
89
|
+
object: slugs.join(", "),
|
|
90
|
+
verb: `caught ${args.source === "chat" ? "an AI" : "an MCP"} edit that ${what}`,
|
|
91
|
+
title: `${origin} ${what} on ${slugs.join(", ")}`,
|
|
92
|
+
summary: args.request
|
|
93
|
+
? `The request was “${args.request.slice(0, 200)}”. The edit also ${what}, which nobody asked for.`
|
|
94
|
+
: `The edit also ${what}, which nobody asked for.`,
|
|
95
|
+
slugs,
|
|
96
|
+
// Something broke: always at the top of the queue.
|
|
97
|
+
impact: 1,
|
|
98
|
+
...(agent.owner ? { assignee: agent.owner } : {}),
|
|
99
|
+
...(args.source === "chat" ? { visibleFrom: at + CHAT_ALERT_GRACE_MS } : {}),
|
|
100
|
+
blocksPublish: true,
|
|
101
|
+
payload: payload,
|
|
102
|
+
createdAt: at
|
|
103
|
+
});
|
|
104
|
+
args.log?.info({ scopeKey: args.scopeKey, itemId, slugs, counts }, "[edit-safety] edit damage held for review");
|
|
105
|
+
return { itemId, slugs, counts, issues };
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
noteDurableFailure(error instanceof Error ? error.message : String(error));
|
|
109
|
+
args.log?.warn({ err: error }, "[edit-safety] check failed; the edit stands unchecked");
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const SUMMARY_VALUE_MAX = 120;
|
|
114
|
+
/** A dropped entry or block, named by its first piece of text ("Day pass"). */
|
|
115
|
+
function nameOf(value, depth = 0) {
|
|
116
|
+
if (typeof value === "string")
|
|
117
|
+
return value.trim() || undefined;
|
|
118
|
+
if (typeof value === "number")
|
|
119
|
+
return String(value);
|
|
120
|
+
if (depth > 3 || value == null || typeof value !== "object")
|
|
121
|
+
return undefined;
|
|
122
|
+
const entries = Array.isArray(value) ? value.map((v) => ["", v]) : Object.entries(value);
|
|
123
|
+
for (const [key, entry] of entries) {
|
|
124
|
+
if (key === "id" || key === "type" || /url|href|src/i.test(key))
|
|
125
|
+
continue;
|
|
126
|
+
const found = nameOf(entry, depth + 1);
|
|
127
|
+
if (found)
|
|
128
|
+
return found;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
function beforeText(issue) {
|
|
133
|
+
const source = issue.kind === "block_dropped" && issue.before && typeof issue.before === "object"
|
|
134
|
+
? issue.before.props
|
|
135
|
+
: issue.before;
|
|
136
|
+
const text = typeof source === "string" ? source : issue.kind.endsWith("_dropped") ? nameOf(source) : undefined;
|
|
137
|
+
if (!text)
|
|
138
|
+
return undefined;
|
|
139
|
+
return text.length > SUMMARY_VALUE_MAX ? `${text.slice(0, SUMMARY_VALUE_MAX - 1)}…` : text;
|
|
140
|
+
}
|
|
141
|
+
export function summarizeReport(report) {
|
|
142
|
+
return {
|
|
143
|
+
itemId: report.itemId,
|
|
144
|
+
slugs: report.slugs,
|
|
145
|
+
counts: report.counts,
|
|
146
|
+
issues: report.issues.map((issue) => ({
|
|
147
|
+
kind: issue.kind,
|
|
148
|
+
slug: issue.slug,
|
|
149
|
+
...(issue.blockId ? { blockId: issue.blockId } : {}),
|
|
150
|
+
...(issue.blockType ? { blockType: issue.blockType } : {}),
|
|
151
|
+
...(issue.blockLabel ? { blockLabel: issue.blockLabel } : {}),
|
|
152
|
+
...(issue.path ? { path: issue.path } : {}),
|
|
153
|
+
...(issue.label ? { label: issue.label } : {}),
|
|
154
|
+
...(beforeText(issue) ? { before: beforeText(issue) } : {})
|
|
155
|
+
}))
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { BlockManifest, Operation, PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
export type EditSafetyIssueKind = "emptied" | "block_dropped" | "item_dropped" | "type_changed";
|
|
3
|
+
export type EditSafetyIssue = {
|
|
4
|
+
kind: EditSafetyIssueKind;
|
|
5
|
+
slug: string;
|
|
6
|
+
/** Absent for a page-level field (`meta.description`). */
|
|
7
|
+
blockId?: string;
|
|
8
|
+
blockType?: string;
|
|
9
|
+
blockLabel?: string;
|
|
10
|
+
/** Editable-target grammar against the page after the edit: `plans[1].price`. */
|
|
11
|
+
path?: string;
|
|
12
|
+
/** The field's label from the manifest, when it has one. */
|
|
13
|
+
label?: string;
|
|
14
|
+
/** For `item_dropped`: the list and the item's stable id. */
|
|
15
|
+
listKey?: string;
|
|
16
|
+
itemId?: string;
|
|
17
|
+
/** What was there: the field value, or the whole block or item for a drop. */
|
|
18
|
+
before: unknown;
|
|
19
|
+
/** What the edit left. `null` for a dropped block or item. */
|
|
20
|
+
after: unknown;
|
|
21
|
+
/** The op that restores `before`. Absent when there is no op that can. */
|
|
22
|
+
revert?: Operation;
|
|
23
|
+
/**
|
|
24
|
+
* For a drop: the ids that came before it on the page (or in the list),
|
|
25
|
+
* nearest first. The revert goes back after the first of them that exists
|
|
26
|
+
* when it runs — which may be one another revert of the same alert just
|
|
27
|
+
* put back, so several dropped entries return in their original order.
|
|
28
|
+
*/
|
|
29
|
+
anchors?: string[];
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Whether a value holds something a reader would miss.
|
|
33
|
+
*
|
|
34
|
+
* Booleans never count: `false` is a setting, not an absence. Objects count
|
|
35
|
+
* when any string or number inside them does, so an image `{ src: "" }` is
|
|
36
|
+
* empty and a rich-text document with one word is not.
|
|
37
|
+
*/
|
|
38
|
+
export declare function hasContent(value: unknown): boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Everything an edit did to one page that nobody asked for.
|
|
41
|
+
*
|
|
42
|
+
* `ops` is the batch that produced `after`: a block removed by a
|
|
43
|
+
* `remove_block` naming it was asked for and is not reported. A page created
|
|
44
|
+
* or deleted by the batch is not this agent's business — `before` or `after`
|
|
45
|
+
* is null and the answer is empty; deleting a page already goes through the
|
|
46
|
+
* destructive-action gate.
|
|
47
|
+
*/
|
|
48
|
+
export declare function detectEditDamage(args: {
|
|
49
|
+
slug: string;
|
|
50
|
+
before: PageDoc | null;
|
|
51
|
+
after: PageDoc | null;
|
|
52
|
+
ops: Operation[];
|
|
53
|
+
manifest: BlockManifest;
|
|
54
|
+
}): EditSafetyIssue[];
|
|
55
|
+
/** The value at an editable-target path inside `root`, or undefined. */
|
|
56
|
+
export declare function valueAtPath(root: unknown, path: string): unknown;
|
|
57
|
+
/**
|
|
58
|
+
* What the issue's location holds on `page` now, in the same form `after` was
|
|
59
|
+
* recorded in. For a dropped block, the block if it is back; otherwise null.
|
|
60
|
+
*/
|
|
61
|
+
export declare function currentValueFor(issue: EditSafetyIssue, page: PageDoc | null): unknown;
|
|
62
|
+
/**
|
|
63
|
+
* True while the location still holds exactly what the edit left there — the
|
|
64
|
+
* only state in which putting the old value back restores the edit's damage
|
|
65
|
+
* and nothing else.
|
|
66
|
+
*/
|
|
67
|
+
export declare function isStillAsEditLeftIt(issue: EditSafetyIssue, page: PageDoc | null): boolean;
|
|
68
|
+
export type EditSafetyCounts = {
|
|
69
|
+
emptied: number;
|
|
70
|
+
blocksDropped: number;
|
|
71
|
+
itemsDropped: number;
|
|
72
|
+
typeChanged: number;
|
|
73
|
+
};
|
|
74
|
+
export declare function countIssues(issues: EditSafetyIssue[]): EditSafetyCounts;
|
|
75
|
+
/** One English line for logs, the Inbox row and anything outside the editor. */
|
|
76
|
+
export declare function describeIssues(issues: EditSafetyIssue[]): string;
|
|
77
|
+
/**
|
|
78
|
+
* The revert op for `issue`, re-anchored against `page` as it is now. For a
|
|
79
|
+
* dropped block or entry, the anchor is the nearest earlier neighbour that is
|
|
80
|
+
* on the page at this moment; every other revert is returned unchanged.
|
|
81
|
+
*/
|
|
82
|
+
export declare function revertFor(issue: EditSafetyIssue, page: PageDoc | null): Operation | undefined;
|