@avocadostudio-ai/orchestrator-core 0.3.2 → 0.3.3
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/chat/anthropic-planner.d.ts +8 -0
- package/dist/chat/anthropic-planner.js +166 -12
- package/dist/chat/chat-pipeline-translation.d.ts +13 -0
- package/dist/chat/chat-pipeline-translation.js +109 -45
- package/dist/chat/chat-pipeline.d.ts +1 -1
- package/dist/chat/chat-pipeline.js +296 -53
- package/dist/chat/gemini-planner.d.ts +2 -0
- package/dist/chat/gemini-planner.js +2 -1
- package/dist/chat/planner-types.d.ts +15 -0
- package/dist/chat/planner-types.js +2 -2
- package/dist/chat/planner.d.ts +12 -0
- package/dist/chat/planner.js +16 -2
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +25 -0
- package/dist/checks/field-walk.js +152 -0
- package/dist/checks/index.d.ts +5 -0
- package/dist/checks/index.js +4 -0
- package/dist/checks/page-weight.d.ts +22 -0
- package/dist/checks/page-weight.js +200 -0
- package/dist/checks/rules-draft.d.ts +2 -0
- package/dist/checks/rules-draft.js +375 -0
- package/dist/checks/run-checks.d.ts +32 -0
- package/dist/checks/run-checks.js +152 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +95 -0
- package/dist/checks/types.d.ts +65 -0
- package/dist/checks/types.js +1 -0
- package/dist/durable/durable-store-singleton.d.ts +37 -0
- package/dist/durable/durable-store-singleton.js +179 -0
- package/dist/durable/finding-impact.d.ts +30 -0
- package/dist/durable/finding-impact.js +53 -0
- package/dist/durable/in-memory-durable-store.d.ts +203 -0
- package/dist/durable/in-memory-durable-store.js +363 -0
- package/dist/durable/index.d.ts +5 -0
- package/dist/durable/index.js +4 -0
- package/dist/durable/pending-plan-store.d.ts +28 -0
- package/dist/durable/pending-plan-store.js +156 -0
- package/dist/durable/sqlite-durable-store.d.ts +71 -0
- package/dist/durable/sqlite-durable-store.js +631 -0
- package/dist/durable/types.d.ts +265 -0
- package/dist/durable/types.js +1 -0
- package/dist/handler/create-orchestrator.d.ts +4 -0
- package/dist/handler/create-orchestrator.js +67 -4
- package/dist/http/audio-actions.d.ts +1 -1
- package/dist/http/checks-actions.d.ts +39 -0
- package/dist/http/checks-actions.js +122 -0
- package/dist/http/history-actions.d.ts +1 -1
- package/dist/http/image-generate-actions.d.ts +2 -2
- package/dist/http/ops-actions.d.ts +2 -2
- package/dist/http/publish-actions.d.ts +4 -4
- package/dist/http/restore-actions.d.ts +3 -3
- package/dist/http/screenshot-actions.d.ts +2 -2
- package/dist/http/session-actions.d.ts +1 -1
- package/dist/http/telemetry-feedback-actions.d.ts +2 -2
- package/dist/http/unsplash-actions.d.ts +2 -2
- package/dist/http/variations-actions.d.ts +2 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +27 -0
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/plan-normalizer.js +54 -6
- package/dist/ops/destructive-action-gate.js +7 -2
- package/dist/ops/ops-engine.d.ts +12 -1
- package/dist/ops/ops-engine.js +41 -14
- package/dist/publish/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/state/session-state.js +8 -1
- package/package.json +3 -3
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { IMPACT_FALLBACK_SQL, fallbackImpact } from "./finding-impact.js";
|
|
3
|
+
/*
|
|
4
|
+
* SQLite implementation of `DurableStore`.
|
|
5
|
+
*
|
|
6
|
+
* It takes an already-open database rather than a file path, so it shares the
|
|
7
|
+
* single `.data/orchestrator.db` connection `SqliteStore` owns. Two writers on
|
|
8
|
+
* one file is a WAL problem nobody needs, and these tables want to be in the
|
|
9
|
+
* same backup and the same `VACUUM INTO` snapshot as everything else.
|
|
10
|
+
*
|
|
11
|
+
* Every statement here is row-addressed. Nothing truncates a table to write one
|
|
12
|
+
* record — which is the entire difference between this store and the snapshot
|
|
13
|
+
* path it sits beside, and the reason a network-backed implementation of the
|
|
14
|
+
* same interface is viable later.
|
|
15
|
+
*
|
|
16
|
+
* The schema is `CREATE TABLE IF NOT EXISTS` only. There is no migration runner
|
|
17
|
+
* in this repo (`ensureSchemaVersion` writes a version and does nothing else),
|
|
18
|
+
* so a new table is safe and a changed one is not. The single exception is
|
|
19
|
+
* `ensureColumns()` below, which adds a *nullable* column to a table that
|
|
20
|
+
* already exists — the one migration shape that needs no runner. A column added
|
|
21
|
+
* to the CREATE TABLE text alone would never reach an existing database, so any
|
|
22
|
+
* addition has to appear in both places.
|
|
23
|
+
*/
|
|
24
|
+
const SCHEMA = `
|
|
25
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
26
|
+
id TEXT PRIMARY KEY,
|
|
27
|
+
fingerprint TEXT NOT NULL,
|
|
28
|
+
scope_key TEXT NOT NULL,
|
|
29
|
+
slug TEXT NOT NULL,
|
|
30
|
+
rule_id TEXT NOT NULL,
|
|
31
|
+
agent TEXT NOT NULL,
|
|
32
|
+
severity TEXT NOT NULL CHECK (severity IN ('error','warning','info')),
|
|
33
|
+
impact REAL,
|
|
34
|
+
status TEXT NOT NULL CHECK (status IN ('open','snoozed','dismissed','fixed')),
|
|
35
|
+
title TEXT NOT NULL,
|
|
36
|
+
detail TEXT,
|
|
37
|
+
evidence TEXT,
|
|
38
|
+
proposed_ops TEXT,
|
|
39
|
+
last_run_id TEXT NOT NULL,
|
|
40
|
+
first_seen_at INTEGER NOT NULL,
|
|
41
|
+
last_seen_at INTEGER NOT NULL,
|
|
42
|
+
resolved_at INTEGER,
|
|
43
|
+
snoozed_until INTEGER
|
|
44
|
+
);
|
|
45
|
+
-- Per scope, not global. A natural fingerprint such as seo.description-missing::/
|
|
46
|
+
-- is identical across sites; a global unique index makes one tenant's finding
|
|
47
|
+
-- overwrite another's, and the second site then sees nothing at all.
|
|
48
|
+
CREATE UNIQUE INDEX IF NOT EXISTS findings_by_scope_fingerprint ON findings(scope_key, fingerprint);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS findings_by_scope ON findings(scope_key, status);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS findings_by_slug ON findings(scope_key, slug);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS check_runs (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
scope_key TEXT NOT NULL,
|
|
55
|
+
agent TEXT NOT NULL,
|
|
56
|
+
trigger_kind TEXT NOT NULL,
|
|
57
|
+
started_at INTEGER NOT NULL,
|
|
58
|
+
finished_at INTEGER,
|
|
59
|
+
pages_scanned INTEGER NOT NULL DEFAULT 0,
|
|
60
|
+
findings_opened INTEGER NOT NULL DEFAULT 0,
|
|
61
|
+
findings_closed INTEGER NOT NULL DEFAULT 0,
|
|
62
|
+
cost_usd REAL NOT NULL DEFAULT 0,
|
|
63
|
+
error TEXT
|
|
64
|
+
);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS check_runs_by_scope ON check_runs(scope_key, started_at DESC);
|
|
66
|
+
|
|
67
|
+
CREATE TABLE IF NOT EXISTS memory_records (
|
|
68
|
+
id TEXT PRIMARY KEY,
|
|
69
|
+
scope TEXT NOT NULL CHECK (scope IN ('site','session','page')),
|
|
70
|
+
scope_key TEXT NOT NULL,
|
|
71
|
+
kind TEXT NOT NULL CHECK (kind IN ('fact','preference','decision','glossary')),
|
|
72
|
+
key TEXT NOT NULL,
|
|
73
|
+
value TEXT NOT NULL,
|
|
74
|
+
source TEXT NOT NULL CHECK (source IN ('user','inferred','agent','import')),
|
|
75
|
+
source_ref TEXT,
|
|
76
|
+
confidence REAL NOT NULL DEFAULT 1,
|
|
77
|
+
status TEXT NOT NULL CHECK (status IN ('active','superseded','rejected')),
|
|
78
|
+
supersedes_id TEXT,
|
|
79
|
+
created_at INTEGER NOT NULL,
|
|
80
|
+
last_used_at INTEGER,
|
|
81
|
+
use_count INTEGER NOT NULL DEFAULT 0
|
|
82
|
+
);
|
|
83
|
+
CREATE INDEX IF NOT EXISTS memory_by_scope ON memory_records(scope_key, status, kind);
|
|
84
|
+
-- One active record per key. A new value supersedes rather than appends;
|
|
85
|
+
-- memory that only grows is memory that contradicts itself.
|
|
86
|
+
CREATE UNIQUE INDEX IF NOT EXISTS memory_active_key
|
|
87
|
+
ON memory_records(scope, scope_key, kind, key) WHERE status = 'active';
|
|
88
|
+
|
|
89
|
+
CREATE TABLE IF NOT EXISTS corrections (
|
|
90
|
+
id TEXT PRIMARY KEY,
|
|
91
|
+
trace_id TEXT NOT NULL,
|
|
92
|
+
scope_key TEXT NOT NULL,
|
|
93
|
+
slug TEXT,
|
|
94
|
+
request TEXT NOT NULL,
|
|
95
|
+
proposed TEXT NOT NULL,
|
|
96
|
+
outcome TEXT NOT NULL CHECK (outcome IN ('accepted','edited','rejected','undone')),
|
|
97
|
+
applied TEXT,
|
|
98
|
+
at INTEGER NOT NULL
|
|
99
|
+
);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS corrections_by_scope ON corrections(scope_key, at DESC);
|
|
101
|
+
|
|
102
|
+
CREATE TABLE IF NOT EXISTS proposals (
|
|
103
|
+
id TEXT PRIMARY KEY,
|
|
104
|
+
scope_key TEXT NOT NULL,
|
|
105
|
+
origin TEXT NOT NULL,
|
|
106
|
+
summary TEXT NOT NULL,
|
|
107
|
+
ops TEXT NOT NULL,
|
|
108
|
+
slugs TEXT NOT NULL,
|
|
109
|
+
finding_id TEXT,
|
|
110
|
+
payload TEXT,
|
|
111
|
+
status TEXT NOT NULL CHECK (status IN ('pending','approved','discarded','expired')),
|
|
112
|
+
created_at INTEGER NOT NULL,
|
|
113
|
+
expires_at INTEGER,
|
|
114
|
+
resolved_at INTEGER
|
|
115
|
+
);
|
|
116
|
+
CREATE INDEX IF NOT EXISTS proposals_by_scope ON proposals(scope_key, status, created_at DESC);
|
|
117
|
+
`;
|
|
118
|
+
/*
|
|
119
|
+
* Retention. Every other collection in this repo is capped — `HISTORY_DEPTH_CAP`,
|
|
120
|
+
* `VERSION_LOG_CAP`, `CHAT_HISTORY_CAP` — and these were not. A `plan_ready`
|
|
121
|
+
* turn writes a whole `PendingApprovalPlan` as a `payload` blob, and the file it
|
|
122
|
+
* lands in is copied entire into up to 14 rolling `VACUUM INTO` backups, so an
|
|
123
|
+
* unbounded proposals table is paid for fifteen times over.
|
|
124
|
+
*
|
|
125
|
+
* Findings are deliberately not capped: they are reconciled, so the table's size
|
|
126
|
+
* tracks the number of real problems rather than the number of runs.
|
|
127
|
+
*/
|
|
128
|
+
export const PROPOSAL_CAP = 200;
|
|
129
|
+
export const CORRECTION_CAP = 500;
|
|
130
|
+
export const CHECK_RUN_CAP = 200;
|
|
131
|
+
/**
|
|
132
|
+
* How long a snooze lasts.
|
|
133
|
+
*
|
|
134
|
+
* Seven days, because the button competes with Dismiss and has to mean
|
|
135
|
+
* something different from it: long enough to clear a finding out of the way
|
|
136
|
+
* for the week somebody is shipping a campaign, short enough that "I'll get to
|
|
137
|
+
* it" does not quietly become "never". Exported so both implementations, and
|
|
138
|
+
* the tests that hold them to the same behaviour, derive it from one number.
|
|
139
|
+
*/
|
|
140
|
+
export const SNOOZE_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
141
|
+
function parseJson(raw, fallback) {
|
|
142
|
+
if (!raw)
|
|
143
|
+
return fallback;
|
|
144
|
+
try {
|
|
145
|
+
return JSON.parse(raw);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return fallback;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function toFinding(row) {
|
|
152
|
+
const evidence = parseJson(row.evidence, null);
|
|
153
|
+
const proposedOps = parseJson(row.proposed_ops, null);
|
|
154
|
+
return {
|
|
155
|
+
id: row.id,
|
|
156
|
+
fingerprint: row.fingerprint,
|
|
157
|
+
scopeKey: row.scope_key,
|
|
158
|
+
slug: row.slug,
|
|
159
|
+
ruleId: row.rule_id,
|
|
160
|
+
agent: row.agent,
|
|
161
|
+
severity: row.severity,
|
|
162
|
+
impact: row.impact ?? fallbackImpact(row.severity),
|
|
163
|
+
status: row.status,
|
|
164
|
+
title: row.title,
|
|
165
|
+
...(row.detail ? { detail: row.detail } : {}),
|
|
166
|
+
...(evidence ? { evidence } : {}),
|
|
167
|
+
...(proposedOps ? { proposedOps } : {}),
|
|
168
|
+
lastRunId: row.last_run_id,
|
|
169
|
+
firstSeenAt: row.first_seen_at,
|
|
170
|
+
lastSeenAt: row.last_seen_at,
|
|
171
|
+
...(row.resolved_at != null ? { resolvedAt: row.resolved_at } : {}),
|
|
172
|
+
...(row.snoozed_until != null ? { snoozedUntil: row.snoozed_until } : {})
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function toCheckRun(row) {
|
|
176
|
+
return {
|
|
177
|
+
id: row.id,
|
|
178
|
+
scopeKey: row.scope_key,
|
|
179
|
+
agent: row.agent,
|
|
180
|
+
trigger: row.trigger_kind,
|
|
181
|
+
startedAt: row.started_at,
|
|
182
|
+
...(row.finished_at != null ? { finishedAt: row.finished_at } : {}),
|
|
183
|
+
pagesScanned: row.pages_scanned,
|
|
184
|
+
findingsOpened: row.findings_opened,
|
|
185
|
+
findingsClosed: row.findings_closed,
|
|
186
|
+
costUsd: row.cost_usd,
|
|
187
|
+
...(row.error ? { error: row.error } : {})
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function toMemory(row) {
|
|
191
|
+
return {
|
|
192
|
+
id: row.id,
|
|
193
|
+
scope: row.scope,
|
|
194
|
+
scopeKey: row.scope_key,
|
|
195
|
+
kind: row.kind,
|
|
196
|
+
key: row.key,
|
|
197
|
+
value: row.value,
|
|
198
|
+
source: row.source,
|
|
199
|
+
...(row.source_ref ? { sourceRef: row.source_ref } : {}),
|
|
200
|
+
confidence: row.confidence,
|
|
201
|
+
status: row.status,
|
|
202
|
+
...(row.supersedes_id ? { supersedesId: row.supersedes_id } : {}),
|
|
203
|
+
createdAt: row.created_at,
|
|
204
|
+
...(row.last_used_at != null ? { lastUsedAt: row.last_used_at } : {}),
|
|
205
|
+
useCount: row.use_count
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function toCorrection(row) {
|
|
209
|
+
const applied = parseJson(row.applied, null);
|
|
210
|
+
return {
|
|
211
|
+
id: row.id,
|
|
212
|
+
traceId: row.trace_id,
|
|
213
|
+
scopeKey: row.scope_key,
|
|
214
|
+
...(row.slug ? { slug: row.slug } : {}),
|
|
215
|
+
request: row.request,
|
|
216
|
+
proposed: parseJson(row.proposed, []),
|
|
217
|
+
outcome: row.outcome,
|
|
218
|
+
...(applied ? { applied } : {}),
|
|
219
|
+
at: row.at
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function toProposal(row) {
|
|
223
|
+
return {
|
|
224
|
+
id: row.id,
|
|
225
|
+
scopeKey: row.scope_key,
|
|
226
|
+
origin: row.origin,
|
|
227
|
+
summary: row.summary,
|
|
228
|
+
ops: parseJson(row.ops, []),
|
|
229
|
+
slugs: parseJson(row.slugs, []),
|
|
230
|
+
...(row.finding_id ? { findingId: row.finding_id } : {}),
|
|
231
|
+
...(row.payload ? { payload: parseJson(row.payload, {}) } : {}),
|
|
232
|
+
status: row.status,
|
|
233
|
+
createdAt: row.created_at,
|
|
234
|
+
...(row.expires_at != null ? { expiresAt: row.expires_at } : {}),
|
|
235
|
+
...(row.resolved_at != null ? { resolvedAt: row.resolved_at } : {})
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
export class SqliteDurableStore {
|
|
239
|
+
db;
|
|
240
|
+
now;
|
|
241
|
+
constructor(db, options = {}) {
|
|
242
|
+
this.db = db;
|
|
243
|
+
this.now = options.now ?? Date.now;
|
|
244
|
+
this.db.exec(SCHEMA);
|
|
245
|
+
this.ensureColumns();
|
|
246
|
+
}
|
|
247
|
+
/*
|
|
248
|
+
* The one thing `CREATE TABLE IF NOT EXISTS` cannot do: reach a table that
|
|
249
|
+
* already exists without a column this build needs. There is no migration
|
|
250
|
+
* runner in the repo, and a `payload` column added to `proposals` after the
|
|
251
|
+
* table shipped would make every insert fail with "no such column" — swallowed
|
|
252
|
+
* by a best-effort caller and surfaced as something unrelated.
|
|
253
|
+
*
|
|
254
|
+
* `ALTER TABLE ... ADD COLUMN` is cheap, idempotent once guarded, and the only
|
|
255
|
+
* migration shape this store needs. Anything beyond adding a nullable column
|
|
256
|
+
* still requires a real runner.
|
|
257
|
+
*/
|
|
258
|
+
ensureColumns() {
|
|
259
|
+
const expected = [
|
|
260
|
+
{ table: "proposals", column: "payload", type: "TEXT" },
|
|
261
|
+
{ table: "findings", column: "snoozed_until", type: "INTEGER" },
|
|
262
|
+
// Nullable on purpose: rows written before impact existed keep NULL and
|
|
263
|
+
// are read through `IMPACT_FALLBACK_SQL` until the next run scores them.
|
|
264
|
+
{ table: "findings", column: "impact", type: "REAL" }
|
|
265
|
+
];
|
|
266
|
+
for (const { table, column, type } of expected) {
|
|
267
|
+
const columns = this.db
|
|
268
|
+
.prepare(`PRAGMA table_info(${table})`)
|
|
269
|
+
.all()
|
|
270
|
+
.map((c) => c.name);
|
|
271
|
+
if (columns.length === 0 || columns.includes(column))
|
|
272
|
+
continue;
|
|
273
|
+
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
trim(table, scopeKey, cap) {
|
|
277
|
+
const orderColumn = table === "corrections" ? "at" : table === "proposals" ? "created_at" : "started_at";
|
|
278
|
+
this.db
|
|
279
|
+
.prepare(`DELETE FROM ${table} WHERE id IN (
|
|
280
|
+
SELECT id FROM ${table} WHERE scope_key = ?
|
|
281
|
+
ORDER BY ${orderColumn} DESC LIMIT -1 OFFSET ?
|
|
282
|
+
)`)
|
|
283
|
+
.run(scopeKey, cap);
|
|
284
|
+
}
|
|
285
|
+
// Findings ---------------------------------------------------------------
|
|
286
|
+
async recordFindings(runId, findings) {
|
|
287
|
+
if (findings.length === 0)
|
|
288
|
+
return { opened: 0, updated: 0 };
|
|
289
|
+
const at = this.now();
|
|
290
|
+
const selectByFingerprint = this.db.prepare("SELECT * FROM findings WHERE scope_key = ? AND fingerprint = ?");
|
|
291
|
+
const insert = this.db.prepare(`INSERT INTO findings
|
|
292
|
+
(id, fingerprint, scope_key, slug, rule_id, agent, severity, impact, status, title, detail,
|
|
293
|
+
evidence, proposed_ops, last_run_id, first_seen_at, last_seen_at, resolved_at)
|
|
294
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, NULL)`);
|
|
295
|
+
// Note what this UPDATE does not touch: `status` and `first_seen_at`. A
|
|
296
|
+
// dismissal has to survive every subsequent run, and "how long has this been
|
|
297
|
+
// wrong" is the only interesting thing about a finding that keeps recurring.
|
|
298
|
+
//
|
|
299
|
+
// `impact` *is* touched, and has to be: it derives from the site's link
|
|
300
|
+
// graph and nav, so a page that joins the nav today must lift the findings
|
|
301
|
+
// that have been sitting on it since Tuesday.
|
|
302
|
+
const update = this.db.prepare(`UPDATE findings
|
|
303
|
+
SET slug = ?, rule_id = ?, agent = ?, severity = ?, impact = ?, title = ?, detail = ?,
|
|
304
|
+
evidence = ?, proposed_ops = ?, last_run_id = ?, last_seen_at = ?
|
|
305
|
+
WHERE id = ?`);
|
|
306
|
+
const reopen = this.db.prepare("UPDATE findings SET status = 'open', resolved_at = NULL WHERE id = ?");
|
|
307
|
+
let opened = 0;
|
|
308
|
+
let updated = 0;
|
|
309
|
+
this.db.transaction(() => {
|
|
310
|
+
for (const f of findings) {
|
|
311
|
+
const existing = selectByFingerprint.get(f.scopeKey, f.fingerprint);
|
|
312
|
+
const evidence = f.evidence ? JSON.stringify(f.evidence) : null;
|
|
313
|
+
const ops = f.proposedOps ? JSON.stringify(f.proposedOps) : null;
|
|
314
|
+
if (!existing) {
|
|
315
|
+
insert.run(randomUUID(), f.fingerprint, f.scopeKey, f.slug, f.ruleId, f.agent, f.severity, f.impact ?? null, f.title, f.detail ?? null, evidence, ops, runId, at, at);
|
|
316
|
+
opened += 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
update.run(f.slug, f.ruleId, f.agent, f.severity, f.impact ?? null, f.title, f.detail ?? null, evidence, ops, runId, at, existing.id);
|
|
320
|
+
if (existing.status === "fixed") {
|
|
321
|
+
reopen.run(existing.id);
|
|
322
|
+
opened += 1;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
updated += 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
})();
|
|
329
|
+
return { opened, updated };
|
|
330
|
+
}
|
|
331
|
+
async reconcileFindings(args) {
|
|
332
|
+
// A run over two pages must not close findings on the other forty-three.
|
|
333
|
+
if (args.slugs.length === 0)
|
|
334
|
+
return { closed: 0 };
|
|
335
|
+
const at = args.at ?? this.now();
|
|
336
|
+
const placeholders = args.slugs.map(() => "?").join(", ");
|
|
337
|
+
const agentClause = args.agent ? " AND agent = ?" : "";
|
|
338
|
+
// `snoozed` closes too: a snooze defers the report, not the problem, so a
|
|
339
|
+
// finding deferred on Monday and fixed on Tuesday must not resurface on
|
|
340
|
+
// Friday as an open finding about a page that is fine.
|
|
341
|
+
const stmt = this.db.prepare(`UPDATE findings
|
|
342
|
+
SET status = 'fixed', resolved_at = ?, snoozed_until = NULL
|
|
343
|
+
WHERE scope_key = ?
|
|
344
|
+
AND status IN ('open', 'snoozed')
|
|
345
|
+
AND last_run_id != ?
|
|
346
|
+
AND slug IN (${placeholders})${agentClause}`);
|
|
347
|
+
const params = [at, args.scopeKey, args.runId, ...args.slugs];
|
|
348
|
+
if (args.agent)
|
|
349
|
+
params.push(args.agent);
|
|
350
|
+
const result = stmt.run(...params);
|
|
351
|
+
return { closed: result.changes };
|
|
352
|
+
}
|
|
353
|
+
async listFindings(query) {
|
|
354
|
+
const clauses = ["scope_key = ?"];
|
|
355
|
+
const params = [query.scopeKey];
|
|
356
|
+
if (query.slug) {
|
|
357
|
+
clauses.push("slug = ?");
|
|
358
|
+
params.push(query.slug);
|
|
359
|
+
}
|
|
360
|
+
if (query.agent) {
|
|
361
|
+
clauses.push("agent = ?");
|
|
362
|
+
params.push(query.agent);
|
|
363
|
+
}
|
|
364
|
+
if (query.severity) {
|
|
365
|
+
clauses.push("severity = ?");
|
|
366
|
+
params.push(query.severity);
|
|
367
|
+
}
|
|
368
|
+
if (query.status) {
|
|
369
|
+
const statuses = Array.isArray(query.status) ? query.status : [query.status];
|
|
370
|
+
if (statuses.length === 0)
|
|
371
|
+
return [];
|
|
372
|
+
clauses.push(`status IN (${statuses.map(() => "?").join(", ")})`);
|
|
373
|
+
params.push(...statuses);
|
|
374
|
+
}
|
|
375
|
+
const limit = Math.max(1, Math.min(query.limit ?? 500, 5000));
|
|
376
|
+
const rows = this.db
|
|
377
|
+
.prepare(
|
|
378
|
+
// Impact leads, severity breaks its ties. Ordering by severity first
|
|
379
|
+
// put every info finding below every warning wherever it sat, which is
|
|
380
|
+
// how a list whose largest single source was a test page came to be
|
|
381
|
+
// sorted the way it was.
|
|
382
|
+
`SELECT * FROM findings WHERE ${clauses.join(" AND ")}
|
|
383
|
+
ORDER BY
|
|
384
|
+
${IMPACT_FALLBACK_SQL} DESC,
|
|
385
|
+
CASE severity WHEN 'error' THEN 0 WHEN 'warning' THEN 1 ELSE 2 END,
|
|
386
|
+
last_seen_at DESC
|
|
387
|
+
LIMIT ?`)
|
|
388
|
+
.all(...params, limit);
|
|
389
|
+
return rows.map(toFinding);
|
|
390
|
+
}
|
|
391
|
+
async getFinding(id) {
|
|
392
|
+
const row = this.db.prepare("SELECT * FROM findings WHERE id = ?").get(id);
|
|
393
|
+
return row ? toFinding(row) : null;
|
|
394
|
+
}
|
|
395
|
+
async setFindingStatus(id, status, at) {
|
|
396
|
+
const when = at ?? this.now();
|
|
397
|
+
// A snoozed finding is deferred, not resolved — `resolvedAt` on one would
|
|
398
|
+
// read as "this was dealt with" in every ledger that looks at the column.
|
|
399
|
+
const resolved = status === "open" || status === "snoozed" ? null : when;
|
|
400
|
+
const snoozedUntil = status === "snoozed" ? when + SNOOZE_WINDOW_MS : null;
|
|
401
|
+
this.db
|
|
402
|
+
.prepare("UPDATE findings SET status = ?, resolved_at = ?, snoozed_until = ? WHERE id = ?")
|
|
403
|
+
.run(status, resolved, snoozedUntil, id);
|
|
404
|
+
}
|
|
405
|
+
async wakeSnoozedFindings(scopeKey, now) {
|
|
406
|
+
const at = now ?? this.now();
|
|
407
|
+
const result = this.db
|
|
408
|
+
.prepare(`UPDATE findings SET status = 'open', snoozed_until = NULL, resolved_at = NULL
|
|
409
|
+
WHERE scope_key = ? AND status = 'snoozed'
|
|
410
|
+
AND snoozed_until IS NOT NULL AND snoozed_until <= ?`)
|
|
411
|
+
.run(scopeKey, at);
|
|
412
|
+
return { woken: result.changes };
|
|
413
|
+
}
|
|
414
|
+
// Check runs -------------------------------------------------------------
|
|
415
|
+
async startCheckRun(run) {
|
|
416
|
+
this.db
|
|
417
|
+
.prepare(`INSERT INTO check_runs (id, scope_key, agent, trigger_kind, started_at)
|
|
418
|
+
VALUES (?, ?, ?, ?, ?)`)
|
|
419
|
+
.run(run.id, run.scopeKey, run.agent, run.trigger, run.startedAt);
|
|
420
|
+
this.trim("check_runs", run.scopeKey, CHECK_RUN_CAP);
|
|
421
|
+
return {
|
|
422
|
+
...run,
|
|
423
|
+
pagesScanned: 0,
|
|
424
|
+
findingsOpened: 0,
|
|
425
|
+
findingsClosed: 0,
|
|
426
|
+
costUsd: 0
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
async finishCheckRun(id, patch) {
|
|
430
|
+
const sets = [];
|
|
431
|
+
const params = [];
|
|
432
|
+
const map = [
|
|
433
|
+
["finishedAt", "finished_at"],
|
|
434
|
+
["pagesScanned", "pages_scanned"],
|
|
435
|
+
["findingsOpened", "findings_opened"],
|
|
436
|
+
["findingsClosed", "findings_closed"],
|
|
437
|
+
["costUsd", "cost_usd"],
|
|
438
|
+
["error", "error"]
|
|
439
|
+
];
|
|
440
|
+
for (const [key, column] of map) {
|
|
441
|
+
const value = patch[key];
|
|
442
|
+
if (value === undefined)
|
|
443
|
+
continue;
|
|
444
|
+
sets.push(`${column} = ?`);
|
|
445
|
+
params.push(value);
|
|
446
|
+
}
|
|
447
|
+
if (sets.length === 0)
|
|
448
|
+
return;
|
|
449
|
+
params.push(id);
|
|
450
|
+
this.db.prepare(`UPDATE check_runs SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
451
|
+
}
|
|
452
|
+
async listCheckRuns(scopeKey, limit = 50) {
|
|
453
|
+
const rows = this.db
|
|
454
|
+
.prepare("SELECT * FROM check_runs WHERE scope_key = ? ORDER BY started_at DESC LIMIT ?")
|
|
455
|
+
.all(scopeKey, Math.max(1, Math.min(limit, 1000)));
|
|
456
|
+
return rows.map(toCheckRun);
|
|
457
|
+
}
|
|
458
|
+
// Memory -----------------------------------------------------------------
|
|
459
|
+
async putMemory(input, at) {
|
|
460
|
+
const createdAt = at ?? this.now();
|
|
461
|
+
const id = randomUUID();
|
|
462
|
+
const confidence = input.confidence ?? 1;
|
|
463
|
+
const record = this.db.transaction(() => {
|
|
464
|
+
const previous = this.db
|
|
465
|
+
.prepare(`SELECT * FROM memory_records
|
|
466
|
+
WHERE scope = ? AND scope_key = ? AND kind = ? AND key = ? AND status = 'active'`)
|
|
467
|
+
.get(input.scope, input.scopeKey, input.kind, input.key);
|
|
468
|
+
if (previous) {
|
|
469
|
+
this.db.prepare("UPDATE memory_records SET status = 'superseded' WHERE id = ?").run(previous.id);
|
|
470
|
+
}
|
|
471
|
+
this.db
|
|
472
|
+
.prepare(`INSERT INTO memory_records
|
|
473
|
+
(id, scope, scope_key, kind, key, value, source, source_ref, confidence,
|
|
474
|
+
status, supersedes_id, created_at, last_used_at, use_count)
|
|
475
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, NULL, 0)`)
|
|
476
|
+
.run(id, input.scope, input.scopeKey, input.kind, input.key, input.value, input.source, input.sourceRef ?? null, confidence, previous?.id ?? null, createdAt);
|
|
477
|
+
return this.db
|
|
478
|
+
.prepare("SELECT * FROM memory_records WHERE id = ?")
|
|
479
|
+
.get(id);
|
|
480
|
+
})();
|
|
481
|
+
if (!record)
|
|
482
|
+
throw new Error("putMemory: insert did not produce a row");
|
|
483
|
+
return toMemory(record);
|
|
484
|
+
}
|
|
485
|
+
async listMemory(query = {}) {
|
|
486
|
+
const clauses = [];
|
|
487
|
+
const params = [];
|
|
488
|
+
if (query.scopeKey) {
|
|
489
|
+
clauses.push("scope_key = ?");
|
|
490
|
+
params.push(query.scopeKey);
|
|
491
|
+
}
|
|
492
|
+
if (query.scope) {
|
|
493
|
+
clauses.push("scope = ?");
|
|
494
|
+
params.push(query.scope);
|
|
495
|
+
}
|
|
496
|
+
if (query.kind) {
|
|
497
|
+
const kinds = Array.isArray(query.kind) ? query.kind : [query.kind];
|
|
498
|
+
if (kinds.length === 0)
|
|
499
|
+
return [];
|
|
500
|
+
clauses.push(`kind IN (${kinds.map(() => "?").join(", ")})`);
|
|
501
|
+
params.push(...kinds);
|
|
502
|
+
}
|
|
503
|
+
// Default to active only: a superseded record exists for the audit trail,
|
|
504
|
+
// not to be handed back to a planner as though it were still true.
|
|
505
|
+
clauses.push("status = ?");
|
|
506
|
+
params.push(query.status ?? "active");
|
|
507
|
+
const limit = Math.max(1, Math.min(query.limit ?? 500, 5000));
|
|
508
|
+
const rows = this.db
|
|
509
|
+
.prepare(`SELECT * FROM memory_records
|
|
510
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
511
|
+
ORDER BY created_at DESC LIMIT ?`)
|
|
512
|
+
.all(...params, limit);
|
|
513
|
+
return rows.map(toMemory);
|
|
514
|
+
}
|
|
515
|
+
async touchMemory(ids, at) {
|
|
516
|
+
if (ids.length === 0)
|
|
517
|
+
return;
|
|
518
|
+
const when = at ?? this.now();
|
|
519
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
520
|
+
this.db
|
|
521
|
+
.prepare(`UPDATE memory_records
|
|
522
|
+
SET last_used_at = ?, use_count = use_count + 1
|
|
523
|
+
WHERE id IN (${placeholders})`)
|
|
524
|
+
.run(when, ...ids);
|
|
525
|
+
}
|
|
526
|
+
async setMemoryStatus(id, status) {
|
|
527
|
+
// Reactivating has to demote whatever is active for that key. Writing
|
|
528
|
+
// `status` blind hits the `memory_active_key` partial unique index and
|
|
529
|
+
// throws from a setter no caller expects to throw — and the in-memory
|
|
530
|
+
// store, with no index to stop it, would instead end up handing a planner
|
|
531
|
+
// two contradicting facts for one key.
|
|
532
|
+
this.db.transaction(() => {
|
|
533
|
+
if (status === "active") {
|
|
534
|
+
const target = this.db
|
|
535
|
+
.prepare("SELECT * FROM memory_records WHERE id = ?")
|
|
536
|
+
.get(id);
|
|
537
|
+
if (!target)
|
|
538
|
+
return;
|
|
539
|
+
this.db
|
|
540
|
+
.prepare(`UPDATE memory_records SET status = 'superseded'
|
|
541
|
+
WHERE scope = ? AND scope_key = ? AND kind = ? AND key = ?
|
|
542
|
+
AND status = 'active' AND id != ?`)
|
|
543
|
+
.run(target.scope, target.scope_key, target.kind, target.key, id);
|
|
544
|
+
}
|
|
545
|
+
this.db.prepare("UPDATE memory_records SET status = ? WHERE id = ?").run(status, id);
|
|
546
|
+
})();
|
|
547
|
+
}
|
|
548
|
+
// Corrections ------------------------------------------------------------
|
|
549
|
+
async recordCorrection(input, at) {
|
|
550
|
+
const record = { ...input, id: randomUUID(), at: at ?? this.now() };
|
|
551
|
+
this.db
|
|
552
|
+
.prepare(`INSERT INTO corrections (id, trace_id, scope_key, slug, request, proposed, outcome, applied, at)
|
|
553
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
554
|
+
.run(record.id, record.traceId, record.scopeKey, record.slug ?? null, record.request, JSON.stringify(record.proposed), record.outcome, record.applied ? JSON.stringify(record.applied) : null, record.at);
|
|
555
|
+
this.trim("corrections", record.scopeKey, CORRECTION_CAP);
|
|
556
|
+
return record;
|
|
557
|
+
}
|
|
558
|
+
async listCorrections(query = {}) {
|
|
559
|
+
const clauses = [];
|
|
560
|
+
const params = [];
|
|
561
|
+
if (query.scopeKey) {
|
|
562
|
+
clauses.push("scope_key = ?");
|
|
563
|
+
params.push(query.scopeKey);
|
|
564
|
+
}
|
|
565
|
+
if (query.outcome) {
|
|
566
|
+
clauses.push("outcome = ?");
|
|
567
|
+
params.push(query.outcome);
|
|
568
|
+
}
|
|
569
|
+
if (query.since != null) {
|
|
570
|
+
clauses.push("at >= ?");
|
|
571
|
+
params.push(query.since);
|
|
572
|
+
}
|
|
573
|
+
const limit = Math.max(1, Math.min(query.limit ?? 200, 5000));
|
|
574
|
+
const rows = this.db
|
|
575
|
+
.prepare(`SELECT * FROM corrections
|
|
576
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
577
|
+
ORDER BY at DESC LIMIT ?`)
|
|
578
|
+
.all(...params, limit);
|
|
579
|
+
return rows.map(toCorrection);
|
|
580
|
+
}
|
|
581
|
+
// Proposals --------------------------------------------------------------
|
|
582
|
+
async putProposal(input) {
|
|
583
|
+
this.db
|
|
584
|
+
.prepare(`INSERT INTO proposals
|
|
585
|
+
(id, scope_key, origin, summary, ops, slugs, finding_id, payload, status, created_at, expires_at, resolved_at)
|
|
586
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, NULL)
|
|
587
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
588
|
+
origin = excluded.origin,
|
|
589
|
+
summary = excluded.summary,
|
|
590
|
+
ops = excluded.ops,
|
|
591
|
+
slugs = excluded.slugs,
|
|
592
|
+
finding_id = excluded.finding_id,
|
|
593
|
+
payload = excluded.payload,
|
|
594
|
+
expires_at = excluded.expires_at`)
|
|
595
|
+
.run(input.id, input.scopeKey, input.origin, input.summary, JSON.stringify(input.ops), JSON.stringify(input.slugs), input.findingId ?? null, input.payload ? JSON.stringify(input.payload) : null, input.createdAt, input.expiresAt ?? null);
|
|
596
|
+
this.trim("proposals", input.scopeKey, PROPOSAL_CAP);
|
|
597
|
+
const record = await this.getProposal(input.id);
|
|
598
|
+
if (!record)
|
|
599
|
+
throw new Error("putProposal: insert did not produce a row");
|
|
600
|
+
return record;
|
|
601
|
+
}
|
|
602
|
+
async getProposal(id) {
|
|
603
|
+
const row = this.db.prepare("SELECT * FROM proposals WHERE id = ?").get(id);
|
|
604
|
+
return row ? toProposal(row) : null;
|
|
605
|
+
}
|
|
606
|
+
async listProposals(query) {
|
|
607
|
+
const clauses = ["scope_key = ?"];
|
|
608
|
+
const params = [query.scopeKey];
|
|
609
|
+
if (query.status) {
|
|
610
|
+
clauses.push("status = ?");
|
|
611
|
+
params.push(query.status);
|
|
612
|
+
}
|
|
613
|
+
const limit = Math.max(1, Math.min(query.limit ?? 100, 1000));
|
|
614
|
+
const rows = this.db
|
|
615
|
+
.prepare(`SELECT * FROM proposals WHERE ${clauses.join(" AND ")} ORDER BY created_at DESC LIMIT ?`)
|
|
616
|
+
.all(...params, limit);
|
|
617
|
+
return rows.map(toProposal);
|
|
618
|
+
}
|
|
619
|
+
async setProposalStatus(id, status, at) {
|
|
620
|
+
const resolved = status === "pending" ? null : (at ?? this.now());
|
|
621
|
+
this.db.prepare("UPDATE proposals SET status = ?, resolved_at = ? WHERE id = ?").run(status, resolved, id);
|
|
622
|
+
}
|
|
623
|
+
async expireProposals(now) {
|
|
624
|
+
const at = now ?? this.now();
|
|
625
|
+
const result = this.db
|
|
626
|
+
.prepare(`UPDATE proposals SET status = 'expired', resolved_at = ?
|
|
627
|
+
WHERE status = 'pending' AND expires_at IS NOT NULL AND expires_at <= ?`)
|
|
628
|
+
.run(at, at);
|
|
629
|
+
return { expired: result.changes };
|
|
630
|
+
}
|
|
631
|
+
}
|