@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,363 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { CHECK_RUN_CAP, CORRECTION_CAP, PROPOSAL_CAP, SNOOZE_WINDOW_MS } from "./sqlite-durable-store.js";
|
|
3
|
+
import { fallbackImpact } from "./finding-impact.js";
|
|
4
|
+
/*
|
|
5
|
+
* In-memory `DurableStore`. Two jobs, both real.
|
|
6
|
+
*
|
|
7
|
+
* 1. It is the reference semantics. `durable-store.test.ts` runs one conformance
|
|
8
|
+
* suite against this and the SQLite store, so a behaviour that drifts between
|
|
9
|
+
* them fails rather than being discovered by whoever swaps implementations.
|
|
10
|
+
*
|
|
11
|
+
* 2. It is the honest fallback. `createOrchestrator()` already degrades to
|
|
12
|
+
* memory-only when `better-sqlite3` cannot be loaded, and reports it through
|
|
13
|
+
* `persistence: { ok: false, reason }`. Findings and memory should degrade
|
|
14
|
+
* the same way and report the same thing — not throw, and not pretend.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here is durable. The name describes the interface, not this class.
|
|
17
|
+
*/
|
|
18
|
+
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
|
19
|
+
/** A fingerprint is unique per scope, never globally. See the SQLite index. */
|
|
20
|
+
function fingerprintKey(scopeKey, fingerprint) {
|
|
21
|
+
return `${scopeKey}\u0000${fingerprint}`;
|
|
22
|
+
}
|
|
23
|
+
export class InMemoryDurableStore {
|
|
24
|
+
findings = new Map();
|
|
25
|
+
/** (scopeKey, fingerprint) → finding id. Mirrors the SQLite unique index. */
|
|
26
|
+
byFingerprint = new Map();
|
|
27
|
+
runs = new Map();
|
|
28
|
+
memory = new Map();
|
|
29
|
+
corrections = [];
|
|
30
|
+
proposals = new Map();
|
|
31
|
+
now;
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.now = options.now ?? Date.now;
|
|
34
|
+
}
|
|
35
|
+
/** Keep the newest `cap` rows per scope, matching the SQLite store's trim. */
|
|
36
|
+
trim(map, scopeKey, cap, order) {
|
|
37
|
+
const forScope = [...map.values()].filter((row) => row.scopeKey === scopeKey);
|
|
38
|
+
if (forScope.length <= cap)
|
|
39
|
+
return;
|
|
40
|
+
forScope
|
|
41
|
+
.sort((a, b) => order(a) - order(b))
|
|
42
|
+
.slice(0, forScope.length - cap)
|
|
43
|
+
.forEach((row) => map.delete(row.id));
|
|
44
|
+
}
|
|
45
|
+
// Findings ---------------------------------------------------------------
|
|
46
|
+
async recordFindings(runId, findings) {
|
|
47
|
+
const at = this.now();
|
|
48
|
+
let opened = 0;
|
|
49
|
+
let updated = 0;
|
|
50
|
+
for (const f of findings) {
|
|
51
|
+
const fpKey = fingerprintKey(f.scopeKey, f.fingerprint);
|
|
52
|
+
const existingId = this.byFingerprint.get(fpKey);
|
|
53
|
+
const existing = existingId ? this.findings.get(existingId) : undefined;
|
|
54
|
+
if (!existing) {
|
|
55
|
+
const id = randomUUID();
|
|
56
|
+
this.findings.set(id, {
|
|
57
|
+
...f,
|
|
58
|
+
id,
|
|
59
|
+
impact: f.impact ?? fallbackImpact(f.severity),
|
|
60
|
+
status: "open",
|
|
61
|
+
lastRunId: runId,
|
|
62
|
+
firstSeenAt: at,
|
|
63
|
+
lastSeenAt: at
|
|
64
|
+
});
|
|
65
|
+
this.byFingerprint.set(fpKey, id);
|
|
66
|
+
opened += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
// `status` and `firstSeenAt` deliberately survive — see the SQLite store.
|
|
70
|
+
const next = {
|
|
71
|
+
...existing,
|
|
72
|
+
slug: f.slug,
|
|
73
|
+
ruleId: f.ruleId,
|
|
74
|
+
agent: f.agent,
|
|
75
|
+
severity: f.severity,
|
|
76
|
+
// Recomputed every run — the page's weight moves even when the finding
|
|
77
|
+
// does not. See the SQLite store.
|
|
78
|
+
impact: f.impact ?? fallbackImpact(f.severity),
|
|
79
|
+
title: f.title,
|
|
80
|
+
lastRunId: runId,
|
|
81
|
+
lastSeenAt: at
|
|
82
|
+
};
|
|
83
|
+
if (f.detail === undefined)
|
|
84
|
+
delete next.detail;
|
|
85
|
+
else
|
|
86
|
+
next.detail = f.detail;
|
|
87
|
+
if (f.evidence === undefined)
|
|
88
|
+
delete next.evidence;
|
|
89
|
+
else
|
|
90
|
+
next.evidence = f.evidence;
|
|
91
|
+
if (f.proposedOps === undefined)
|
|
92
|
+
delete next.proposedOps;
|
|
93
|
+
else
|
|
94
|
+
next.proposedOps = f.proposedOps;
|
|
95
|
+
if (existing.status === "fixed") {
|
|
96
|
+
next.status = "open";
|
|
97
|
+
delete next.resolvedAt;
|
|
98
|
+
opened += 1;
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
updated += 1;
|
|
102
|
+
}
|
|
103
|
+
this.findings.set(existing.id, next);
|
|
104
|
+
}
|
|
105
|
+
return { opened, updated };
|
|
106
|
+
}
|
|
107
|
+
async reconcileFindings(args) {
|
|
108
|
+
if (args.slugs.length === 0)
|
|
109
|
+
return { closed: 0 };
|
|
110
|
+
const at = args.at ?? this.now();
|
|
111
|
+
const inScope = new Set(args.slugs);
|
|
112
|
+
let closed = 0;
|
|
113
|
+
for (const record of this.findings.values()) {
|
|
114
|
+
if (record.scopeKey !== args.scopeKey)
|
|
115
|
+
continue;
|
|
116
|
+
// `snoozed` closes too — see the SQLite store. A snooze defers the
|
|
117
|
+
// report, not the problem.
|
|
118
|
+
if (record.status !== "open" && record.status !== "snoozed")
|
|
119
|
+
continue;
|
|
120
|
+
if (record.lastRunId === args.runId)
|
|
121
|
+
continue;
|
|
122
|
+
if (!inScope.has(record.slug))
|
|
123
|
+
continue;
|
|
124
|
+
if (args.agent && record.agent !== args.agent)
|
|
125
|
+
continue;
|
|
126
|
+
const next = { ...record, status: "fixed", resolvedAt: at };
|
|
127
|
+
delete next.snoozedUntil;
|
|
128
|
+
this.findings.set(record.id, next);
|
|
129
|
+
closed += 1;
|
|
130
|
+
}
|
|
131
|
+
return { closed };
|
|
132
|
+
}
|
|
133
|
+
async listFindings(query) {
|
|
134
|
+
const statuses = query.status
|
|
135
|
+
? new Set(Array.isArray(query.status) ? query.status : [query.status])
|
|
136
|
+
: null;
|
|
137
|
+
if (statuses && statuses.size === 0)
|
|
138
|
+
return [];
|
|
139
|
+
const limit = Math.max(1, Math.min(query.limit ?? 500, 5000));
|
|
140
|
+
return [...this.findings.values()]
|
|
141
|
+
.filter((f) => f.scopeKey === query.scopeKey)
|
|
142
|
+
.filter((f) => (query.slug ? f.slug === query.slug : true))
|
|
143
|
+
.filter((f) => (query.agent ? f.agent === query.agent : true))
|
|
144
|
+
.filter((f) => (query.severity ? f.severity === query.severity : true))
|
|
145
|
+
.filter((f) => (statuses ? statuses.has(f.status) : true))
|
|
146
|
+
// Impact leads, severity breaks its ties — mirroring the SQLite ORDER BY.
|
|
147
|
+
.sort((a, b) => b.impact - a.impact ||
|
|
148
|
+
SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] ||
|
|
149
|
+
b.lastSeenAt - a.lastSeenAt)
|
|
150
|
+
.slice(0, limit)
|
|
151
|
+
.map((f) => ({ ...f }));
|
|
152
|
+
}
|
|
153
|
+
async getFinding(id) {
|
|
154
|
+
const record = this.findings.get(id);
|
|
155
|
+
return record ? { ...record } : null;
|
|
156
|
+
}
|
|
157
|
+
async setFindingStatus(id, status, at) {
|
|
158
|
+
const record = this.findings.get(id);
|
|
159
|
+
if (!record)
|
|
160
|
+
return;
|
|
161
|
+
const when = at ?? this.now();
|
|
162
|
+
const next = { ...record, status };
|
|
163
|
+
// Deferred is not resolved, so `snoozed` clears `resolvedAt` like `open`.
|
|
164
|
+
if (status === "open" || status === "snoozed")
|
|
165
|
+
delete next.resolvedAt;
|
|
166
|
+
else
|
|
167
|
+
next.resolvedAt = when;
|
|
168
|
+
if (status === "snoozed")
|
|
169
|
+
next.snoozedUntil = when + SNOOZE_WINDOW_MS;
|
|
170
|
+
else
|
|
171
|
+
delete next.snoozedUntil;
|
|
172
|
+
this.findings.set(id, next);
|
|
173
|
+
}
|
|
174
|
+
async wakeSnoozedFindings(scopeKey, now) {
|
|
175
|
+
const at = now ?? this.now();
|
|
176
|
+
let woken = 0;
|
|
177
|
+
for (const record of this.findings.values()) {
|
|
178
|
+
if (record.scopeKey !== scopeKey || record.status !== "snoozed")
|
|
179
|
+
continue;
|
|
180
|
+
if (record.snoozedUntil == null || record.snoozedUntil > at)
|
|
181
|
+
continue;
|
|
182
|
+
const next = { ...record, status: "open" };
|
|
183
|
+
delete next.snoozedUntil;
|
|
184
|
+
delete next.resolvedAt;
|
|
185
|
+
this.findings.set(record.id, next);
|
|
186
|
+
woken += 1;
|
|
187
|
+
}
|
|
188
|
+
return { woken };
|
|
189
|
+
}
|
|
190
|
+
// Check runs -------------------------------------------------------------
|
|
191
|
+
async startCheckRun(run) {
|
|
192
|
+
const record = {
|
|
193
|
+
...run,
|
|
194
|
+
pagesScanned: 0,
|
|
195
|
+
findingsOpened: 0,
|
|
196
|
+
findingsClosed: 0,
|
|
197
|
+
costUsd: 0
|
|
198
|
+
};
|
|
199
|
+
this.runs.set(run.id, record);
|
|
200
|
+
this.trim(this.runs, run.scopeKey, CHECK_RUN_CAP, (r) => r.startedAt);
|
|
201
|
+
return { ...record };
|
|
202
|
+
}
|
|
203
|
+
async finishCheckRun(id, patch) {
|
|
204
|
+
const record = this.runs.get(id);
|
|
205
|
+
if (!record)
|
|
206
|
+
return;
|
|
207
|
+
this.runs.set(id, { ...record, ...patch });
|
|
208
|
+
}
|
|
209
|
+
async listCheckRuns(scopeKey, limit = 50) {
|
|
210
|
+
return [...this.runs.values()]
|
|
211
|
+
.filter((r) => r.scopeKey === scopeKey)
|
|
212
|
+
.sort((a, b) => b.startedAt - a.startedAt)
|
|
213
|
+
.slice(0, Math.max(1, Math.min(limit, 1000)))
|
|
214
|
+
.map((r) => ({ ...r }));
|
|
215
|
+
}
|
|
216
|
+
// Memory -----------------------------------------------------------------
|
|
217
|
+
async putMemory(input, at) {
|
|
218
|
+
const createdAt = at ?? this.now();
|
|
219
|
+
const previous = [...this.memory.values()].find((m) => m.status === "active" &&
|
|
220
|
+
m.scope === input.scope &&
|
|
221
|
+
m.scopeKey === input.scopeKey &&
|
|
222
|
+
m.kind === input.kind &&
|
|
223
|
+
m.key === input.key);
|
|
224
|
+
if (previous)
|
|
225
|
+
this.memory.set(previous.id, { ...previous, status: "superseded" });
|
|
226
|
+
const record = {
|
|
227
|
+
...input,
|
|
228
|
+
id: randomUUID(),
|
|
229
|
+
confidence: input.confidence ?? 1,
|
|
230
|
+
status: "active",
|
|
231
|
+
...(previous ? { supersedesId: previous.id } : {}),
|
|
232
|
+
createdAt,
|
|
233
|
+
useCount: 0
|
|
234
|
+
};
|
|
235
|
+
this.memory.set(record.id, record);
|
|
236
|
+
return { ...record };
|
|
237
|
+
}
|
|
238
|
+
async listMemory(query = {}) {
|
|
239
|
+
const kinds = query.kind
|
|
240
|
+
? new Set(Array.isArray(query.kind) ? query.kind : [query.kind])
|
|
241
|
+
: null;
|
|
242
|
+
if (kinds && kinds.size === 0)
|
|
243
|
+
return [];
|
|
244
|
+
const status = query.status ?? "active";
|
|
245
|
+
const limit = Math.max(1, Math.min(query.limit ?? 500, 5000));
|
|
246
|
+
return [...this.memory.values()]
|
|
247
|
+
.filter((m) => (query.scopeKey ? m.scopeKey === query.scopeKey : true))
|
|
248
|
+
.filter((m) => (query.scope ? m.scope === query.scope : true))
|
|
249
|
+
.filter((m) => (kinds ? kinds.has(m.kind) : true))
|
|
250
|
+
.filter((m) => m.status === status)
|
|
251
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
252
|
+
.slice(0, limit)
|
|
253
|
+
.map((m) => ({ ...m }));
|
|
254
|
+
}
|
|
255
|
+
async touchMemory(ids, at) {
|
|
256
|
+
const when = at ?? this.now();
|
|
257
|
+
for (const id of ids) {
|
|
258
|
+
const record = this.memory.get(id);
|
|
259
|
+
if (!record)
|
|
260
|
+
continue;
|
|
261
|
+
this.memory.set(id, { ...record, lastUsedAt: when, useCount: record.useCount + 1 });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async setMemoryStatus(id, status) {
|
|
265
|
+
const record = this.memory.get(id);
|
|
266
|
+
if (!record)
|
|
267
|
+
return;
|
|
268
|
+
// Reactivating demotes whatever is active for that key — otherwise this
|
|
269
|
+
// store would hold two active records for one key and hand a planner two
|
|
270
|
+
// contradicting facts, while SQLite refused the same write outright.
|
|
271
|
+
if (status === "active") {
|
|
272
|
+
for (const other of this.memory.values()) {
|
|
273
|
+
if (other.id === id)
|
|
274
|
+
continue;
|
|
275
|
+
if (other.status !== "active")
|
|
276
|
+
continue;
|
|
277
|
+
if (other.scope !== record.scope ||
|
|
278
|
+
other.scopeKey !== record.scopeKey ||
|
|
279
|
+
other.kind !== record.kind ||
|
|
280
|
+
other.key !== record.key) {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
this.memory.set(other.id, { ...other, status: "superseded" });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
this.memory.set(id, { ...record, status });
|
|
287
|
+
}
|
|
288
|
+
// Corrections ------------------------------------------------------------
|
|
289
|
+
async recordCorrection(input, at) {
|
|
290
|
+
const record = { ...input, id: randomUUID(), at: at ?? this.now() };
|
|
291
|
+
this.corrections.push(record);
|
|
292
|
+
const forScope = this.corrections.filter((c) => c.scopeKey === record.scopeKey);
|
|
293
|
+
if (forScope.length > CORRECTION_CAP) {
|
|
294
|
+
const drop = new Set(forScope.sort((a, b) => a.at - b.at).slice(0, forScope.length - CORRECTION_CAP).map((c) => c.id));
|
|
295
|
+
for (let i = this.corrections.length - 1; i >= 0; i -= 1) {
|
|
296
|
+
if (drop.has(this.corrections[i].id))
|
|
297
|
+
this.corrections.splice(i, 1);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return { ...record };
|
|
301
|
+
}
|
|
302
|
+
async listCorrections(query = {}) {
|
|
303
|
+
const limit = Math.max(1, Math.min(query.limit ?? 200, 5000));
|
|
304
|
+
return this.corrections
|
|
305
|
+
.filter((c) => (query.scopeKey ? c.scopeKey === query.scopeKey : true))
|
|
306
|
+
.filter((c) => (query.outcome ? c.outcome === query.outcome : true))
|
|
307
|
+
.filter((c) => (query.since != null ? c.at >= query.since : true))
|
|
308
|
+
.slice()
|
|
309
|
+
.sort((a, b) => b.at - a.at)
|
|
310
|
+
.slice(0, limit)
|
|
311
|
+
.map((c) => ({ ...c }));
|
|
312
|
+
}
|
|
313
|
+
// Proposals --------------------------------------------------------------
|
|
314
|
+
async putProposal(input) {
|
|
315
|
+
const existing = this.proposals.get(input.id);
|
|
316
|
+
const record = {
|
|
317
|
+
...input,
|
|
318
|
+
status: existing?.status ?? "pending",
|
|
319
|
+
...(existing?.resolvedAt != null ? { resolvedAt: existing.resolvedAt } : {}),
|
|
320
|
+
createdAt: existing?.createdAt ?? input.createdAt
|
|
321
|
+
};
|
|
322
|
+
this.proposals.set(record.id, record);
|
|
323
|
+
this.trim(this.proposals, record.scopeKey, PROPOSAL_CAP, (p) => p.createdAt);
|
|
324
|
+
return { ...record };
|
|
325
|
+
}
|
|
326
|
+
async getProposal(id) {
|
|
327
|
+
const record = this.proposals.get(id);
|
|
328
|
+
return record ? { ...record } : null;
|
|
329
|
+
}
|
|
330
|
+
async listProposals(query) {
|
|
331
|
+
const limit = Math.max(1, Math.min(query.limit ?? 100, 1000));
|
|
332
|
+
return [...this.proposals.values()]
|
|
333
|
+
.filter((p) => p.scopeKey === query.scopeKey)
|
|
334
|
+
.filter((p) => (query.status ? p.status === query.status : true))
|
|
335
|
+
.sort((a, b) => b.createdAt - a.createdAt)
|
|
336
|
+
.slice(0, limit)
|
|
337
|
+
.map((p) => ({ ...p }));
|
|
338
|
+
}
|
|
339
|
+
async setProposalStatus(id, status, at) {
|
|
340
|
+
const record = this.proposals.get(id);
|
|
341
|
+
if (!record)
|
|
342
|
+
return;
|
|
343
|
+
const next = { ...record, status };
|
|
344
|
+
if (status === "pending")
|
|
345
|
+
delete next.resolvedAt;
|
|
346
|
+
else
|
|
347
|
+
next.resolvedAt = at ?? this.now();
|
|
348
|
+
this.proposals.set(id, next);
|
|
349
|
+
}
|
|
350
|
+
async expireProposals(now) {
|
|
351
|
+
const at = now ?? this.now();
|
|
352
|
+
let expired = 0;
|
|
353
|
+
for (const record of this.proposals.values()) {
|
|
354
|
+
if (record.status !== "pending")
|
|
355
|
+
continue;
|
|
356
|
+
if (record.expiresAt == null || record.expiresAt > at)
|
|
357
|
+
continue;
|
|
358
|
+
this.proposals.set(record.id, { ...record, status: "expired", resolvedAt: at });
|
|
359
|
+
expired += 1;
|
|
360
|
+
}
|
|
361
|
+
return { expired };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, discardPendingProposalsForSession, sweepExpiredProposals } from "./durable-store-singleton.ts";
|
|
2
|
+
export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./pending-plan-store.ts";
|
|
3
|
+
export { SqliteDurableStore, type SqliteDurableStoreOptions } from "./sqlite-durable-store.ts";
|
|
4
|
+
export { InMemoryDurableStore, type InMemoryDurableStoreOptions } from "./in-memory-durable-store.ts";
|
|
5
|
+
export type { DurableStore, FindingInput, FindingRecord, FindingQuery, FindingSeverity, FindingStatus, FindingEvidence, CheckRunInput, CheckRunRecord, CheckRunPatch, CheckRunTrigger, MemoryInput, MemoryRecord, MemoryQuery, MemoryScope, MemoryKind, MemorySource, MemoryStatus, CorrectionInput, CorrectionRecord, CorrectionQuery, CorrectionOutcome, ProposalInput, ProposalRecord, ProposalQuery, ProposalStatus } from "./types.ts";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { getDurableStore, resetDurableStore, setDurableStore, durableStoreIsEphemeral, discardPendingProposalsForSession, sweepExpiredProposals } from "./durable-store-singleton.js";
|
|
2
|
+
export { loadPendingPlan, savePendingPlan, clearPendingPlan, peekPendingPlan } from "./pending-plan-store.js";
|
|
3
|
+
export { SqliteDurableStore } from "./sqlite-durable-store.js";
|
|
4
|
+
export { InMemoryDurableStore } from "./in-memory-durable-store.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type PendingApprovalPlan } from "../state/session-state.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Map-only read. For call sites that only need to know whether a plan is live
|
|
4
|
+
* in this process and must not pay for a query.
|
|
5
|
+
*/
|
|
6
|
+
export declare function peekPendingPlan(session: string): PendingApprovalPlan | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* Read the pending plan, rehydrating from the durable store on a Map miss.
|
|
9
|
+
*
|
|
10
|
+
* An expired row is treated as absent rather than deleted: eviction is the
|
|
11
|
+
* sweeper's job, and a read is not the place to discover that a plan the user
|
|
12
|
+
* is looking at right now has just aged out mid-request.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadPendingPlan(session: string): Promise<PendingApprovalPlan | undefined>;
|
|
15
|
+
/** Set the plan in memory and mirror it to the durable store. */
|
|
16
|
+
export declare function savePendingPlan(session: string, plan: PendingApprovalPlan): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Drop the plan from memory and resolve its durable row.
|
|
19
|
+
*
|
|
20
|
+
* `outcome` is recorded rather than deleted, so "how often does a human discard
|
|
21
|
+
* what we proposed" stays answerable — which is the same signal the corrections
|
|
22
|
+
* table is for, and the reason `expectedId` matters. A turn that applies
|
|
23
|
+
* something unrelated must not record a held plan as `approved`: it was never
|
|
24
|
+
* reviewed, and writing that down poisons the one number this is here to make
|
|
25
|
+
* available. Pass the id of the plan actually acted on; omit it only where the
|
|
26
|
+
* intent really is "whatever chat plan is pending, it is over".
|
|
27
|
+
*/
|
|
28
|
+
export declare function clearPendingPlan(session: string, outcome: "approved" | "discarded", expectedId?: string): Promise<void>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { pendingApprovalPlanBySession } from "../state/session-state.js";
|
|
2
|
+
import { getDurableStore, isDiscardInFlight, noteDurableFailure } from "./durable-store-singleton.js";
|
|
3
|
+
/*
|
|
4
|
+
* Write-through durability for the pending approval plan.
|
|
5
|
+
*
|
|
6
|
+
* `pendingApprovalPlanBySession` stays the hot path — a Map read is what the
|
|
7
|
+
* chat pipeline wants and there is no reason to make it a query. What changes is
|
|
8
|
+
* that the Map is no longer the only copy: every plan is mirrored into the
|
|
9
|
+
* `proposals` table, and a read that misses rehydrates from there.
|
|
10
|
+
*
|
|
11
|
+
* The case this exists for is not a crash. It is the ordinary one: an agent
|
|
12
|
+
* runs at 03:00, produces a plan, and the process that produced it is gone long
|
|
13
|
+
* before anyone looks. Today that plan is in a Map that was never persisted and
|
|
14
|
+
* is evicted by age, so the morning's "approve" finds nothing and silently
|
|
15
|
+
* re-plans from the original message — at best spending tokens to reproduce
|
|
16
|
+
* work, at worst producing a different plan than the one that was reviewed.
|
|
17
|
+
*
|
|
18
|
+
* Every durable write here is best-effort. If the store is unavailable the chat
|
|
19
|
+
* path must behave exactly as it did before this module existed: in-memory, and
|
|
20
|
+
* honest about it through `durableHealth()`. A findings feature is not permitted
|
|
21
|
+
* to take chat down with it.
|
|
22
|
+
*/
|
|
23
|
+
/*
|
|
24
|
+
* Two different TTLs, because they answer two different questions.
|
|
25
|
+
*
|
|
26
|
+
* The Map's hour (`APPROVAL_PLAN_TTL_MS` in session-state) is "how long do we
|
|
27
|
+
* keep an abandoned approval warm in this process" — a memory-pressure answer.
|
|
28
|
+
* Reusing it for the durable row would defeat the entire point of the row: the
|
|
29
|
+
* agent that ran at 03:00 and the person who approves at 09:00 are six hours
|
|
30
|
+
* apart, so an hour-old row is expired before anybody could possibly have seen
|
|
31
|
+
* it, and `loadPendingPlan` falls straight back to the silent re-plan this
|
|
32
|
+
* module exists to prevent.
|
|
33
|
+
*
|
|
34
|
+
* A durable proposal is kept for a working week. The Map still forgets it after
|
|
35
|
+
* an hour; the next read rehydrates.
|
|
36
|
+
*/
|
|
37
|
+
const DURABLE_PLAN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
38
|
+
/**
|
|
39
|
+
* Marks the proposals this module owns.
|
|
40
|
+
*
|
|
41
|
+
* `proposals` is shared with agent-authored plans by design. "The newest
|
|
42
|
+
* pending row for this session" is therefore the wrong question — once a
|
|
43
|
+
* checker writes one, it would shadow a genuinely pending chat plan behind it,
|
|
44
|
+
* and `clearPendingPlan` would resolve somebody else's proposal as approved.
|
|
45
|
+
*/
|
|
46
|
+
const CHAT_ORIGIN_PREFIX = "chat:";
|
|
47
|
+
function createdAtMs(plan) {
|
|
48
|
+
const parsed = Date.parse(plan.createdAt);
|
|
49
|
+
return Number.isFinite(parsed) ? parsed : Date.now();
|
|
50
|
+
}
|
|
51
|
+
function noteFailure(err, what) {
|
|
52
|
+
noteDurableFailure(`${what}: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
+
}
|
|
54
|
+
function isChatProposal(record) {
|
|
55
|
+
return record.origin.startsWith(CHAT_ORIGIN_PREFIX);
|
|
56
|
+
}
|
|
57
|
+
async function findChatProposal(session) {
|
|
58
|
+
const rows = await getDurableStore().listProposals({ scopeKey: session, status: "pending" });
|
|
59
|
+
return rows.find(isChatProposal);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Map-only read. For call sites that only need to know whether a plan is live
|
|
63
|
+
* in this process and must not pay for a query.
|
|
64
|
+
*/
|
|
65
|
+
export function peekPendingPlan(session) {
|
|
66
|
+
return pendingApprovalPlanBySession.get(session);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Read the pending plan, rehydrating from the durable store on a Map miss.
|
|
70
|
+
*
|
|
71
|
+
* An expired row is treated as absent rather than deleted: eviction is the
|
|
72
|
+
* sweeper's job, and a read is not the place to discover that a plan the user
|
|
73
|
+
* is looking at right now has just aged out mid-request.
|
|
74
|
+
*/
|
|
75
|
+
export async function loadPendingPlan(session) {
|
|
76
|
+
const live = pendingApprovalPlanBySession.get(session);
|
|
77
|
+
if (live)
|
|
78
|
+
return live;
|
|
79
|
+
if (isDiscardInFlight(session))
|
|
80
|
+
return undefined;
|
|
81
|
+
try {
|
|
82
|
+
const record = await findChatProposal(session);
|
|
83
|
+
if (!record?.payload)
|
|
84
|
+
return undefined;
|
|
85
|
+
if (record.expiresAt != null && record.expiresAt <= Date.now())
|
|
86
|
+
return undefined;
|
|
87
|
+
const plan = record.payload;
|
|
88
|
+
// A payload that cannot be executed is worse than none: the approval path
|
|
89
|
+
// would hand a malformed plan to the ops engine on the user's click.
|
|
90
|
+
if (!plan?.id || !plan.plan || !Array.isArray(plan.plan.ops))
|
|
91
|
+
return undefined;
|
|
92
|
+
pendingApprovalPlanBySession.set(session, plan);
|
|
93
|
+
return plan;
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
noteFailure(err, "read pending plan");
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Set the plan in memory and mirror it to the durable store. */
|
|
101
|
+
export async function savePendingPlan(session, plan) {
|
|
102
|
+
pendingApprovalPlanBySession.set(session, plan);
|
|
103
|
+
const createdAt = createdAtMs(plan);
|
|
104
|
+
try {
|
|
105
|
+
await getDurableStore().putProposal({
|
|
106
|
+
id: plan.id,
|
|
107
|
+
scopeKey: session,
|
|
108
|
+
origin: `${CHAT_ORIGIN_PREFIX}${plan.source}`,
|
|
109
|
+
summary: plan.summary,
|
|
110
|
+
ops: plan.plan.ops,
|
|
111
|
+
slugs: plan.effectiveSlug ? [plan.effectiveSlug] : [],
|
|
112
|
+
payload: plan,
|
|
113
|
+
createdAt,
|
|
114
|
+
expiresAt: createdAt + DURABLE_PLAN_TTL_MS
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
noteFailure(err, "save pending plan");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Drop the plan from memory and resolve its durable row.
|
|
123
|
+
*
|
|
124
|
+
* `outcome` is recorded rather than deleted, so "how often does a human discard
|
|
125
|
+
* what we proposed" stays answerable — which is the same signal the corrections
|
|
126
|
+
* table is for, and the reason `expectedId` matters. A turn that applies
|
|
127
|
+
* something unrelated must not record a held plan as `approved`: it was never
|
|
128
|
+
* reviewed, and writing that down poisons the one number this is here to make
|
|
129
|
+
* available. Pass the id of the plan actually acted on; omit it only where the
|
|
130
|
+
* intent really is "whatever chat plan is pending, it is over".
|
|
131
|
+
*/
|
|
132
|
+
export async function clearPendingPlan(session, outcome, expectedId) {
|
|
133
|
+
const plan = pendingApprovalPlanBySession.get(session);
|
|
134
|
+
if (expectedId && plan && plan.id !== expectedId) {
|
|
135
|
+
// The live plan is not the one being resolved — leave both alone.
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
pendingApprovalPlanBySession.delete(session);
|
|
139
|
+
try {
|
|
140
|
+
const store = getDurableStore();
|
|
141
|
+
const id = expectedId ?? plan?.id;
|
|
142
|
+
if (id) {
|
|
143
|
+
const record = await store.getProposal(id);
|
|
144
|
+
if (record && isChatProposal(record))
|
|
145
|
+
await store.setProposalStatus(id, outcome);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// No live plan: a durable row may still be pending from a previous process.
|
|
149
|
+
const record = await findChatProposal(session);
|
|
150
|
+
if (record)
|
|
151
|
+
await store.setProposalStatus(record.id, outcome);
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
noteFailure(err, "clear pending plan");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { Database as BetterSqliteDatabase } from "better-sqlite3";
|
|
2
|
+
import type { CheckRunInput, CheckRunPatch, CheckRunRecord, CorrectionInput, CorrectionQuery, CorrectionRecord, DurableStore, FindingInput, FindingQuery, FindingRecord, FindingStatus, MemoryInput, MemoryQuery, MemoryRecord, MemoryStatus, ProposalInput, ProposalQuery, ProposalRecord, ProposalStatus } from "./types.ts";
|
|
3
|
+
export declare const PROPOSAL_CAP = 200;
|
|
4
|
+
export declare const CORRECTION_CAP = 500;
|
|
5
|
+
export declare const CHECK_RUN_CAP = 200;
|
|
6
|
+
/**
|
|
7
|
+
* How long a snooze lasts.
|
|
8
|
+
*
|
|
9
|
+
* Seven days, because the button competes with Dismiss and has to mean
|
|
10
|
+
* something different from it: long enough to clear a finding out of the way
|
|
11
|
+
* for the week somebody is shipping a campaign, short enough that "I'll get to
|
|
12
|
+
* it" does not quietly become "never". Exported so both implementations, and
|
|
13
|
+
* the tests that hold them to the same behaviour, derive it from one number.
|
|
14
|
+
*/
|
|
15
|
+
export declare const SNOOZE_WINDOW_MS: number;
|
|
16
|
+
export type SqliteDurableStoreOptions = {
|
|
17
|
+
/** Injectable clock, so tests can assert on ordering without sleeping. */
|
|
18
|
+
now?: () => number;
|
|
19
|
+
};
|
|
20
|
+
export declare class SqliteDurableStore implements DurableStore {
|
|
21
|
+
private readonly db;
|
|
22
|
+
private readonly now;
|
|
23
|
+
constructor(db: BetterSqliteDatabase, options?: SqliteDurableStoreOptions);
|
|
24
|
+
private ensureColumns;
|
|
25
|
+
private trim;
|
|
26
|
+
recordFindings(runId: string, findings: FindingInput[]): Promise<{
|
|
27
|
+
opened: number;
|
|
28
|
+
updated: number;
|
|
29
|
+
}>;
|
|
30
|
+
reconcileFindings(args: {
|
|
31
|
+
runId: string;
|
|
32
|
+
scopeKey: string;
|
|
33
|
+
slugs: string[];
|
|
34
|
+
agent?: string;
|
|
35
|
+
at?: number;
|
|
36
|
+
}): Promise<{
|
|
37
|
+
closed: number;
|
|
38
|
+
}>;
|
|
39
|
+
listFindings(query: FindingQuery): Promise<FindingRecord[]>;
|
|
40
|
+
getFinding(id: string): Promise<FindingRecord | null>;
|
|
41
|
+
setFindingStatus(id: string, status: FindingStatus, at?: number): Promise<void>;
|
|
42
|
+
wakeSnoozedFindings(scopeKey: string, now?: number): Promise<{
|
|
43
|
+
woken: number;
|
|
44
|
+
}>;
|
|
45
|
+
startCheckRun(run: CheckRunInput): Promise<{
|
|
46
|
+
pagesScanned: number;
|
|
47
|
+
findingsOpened: number;
|
|
48
|
+
findingsClosed: number;
|
|
49
|
+
costUsd: number;
|
|
50
|
+
id: string;
|
|
51
|
+
scopeKey: string;
|
|
52
|
+
agent: string;
|
|
53
|
+
trigger: import("./types.ts").CheckRunTrigger;
|
|
54
|
+
startedAt: number;
|
|
55
|
+
}>;
|
|
56
|
+
finishCheckRun(id: string, patch: CheckRunPatch): Promise<void>;
|
|
57
|
+
listCheckRuns(scopeKey: string, limit?: number): Promise<CheckRunRecord[]>;
|
|
58
|
+
putMemory(input: MemoryInput, at?: number): Promise<MemoryRecord>;
|
|
59
|
+
listMemory(query?: MemoryQuery): Promise<MemoryRecord[]>;
|
|
60
|
+
touchMemory(ids: string[], at?: number): Promise<void>;
|
|
61
|
+
setMemoryStatus(id: string, status: MemoryStatus): Promise<void>;
|
|
62
|
+
recordCorrection(input: CorrectionInput, at?: number): Promise<CorrectionRecord>;
|
|
63
|
+
listCorrections(query?: CorrectionQuery): Promise<CorrectionRecord[]>;
|
|
64
|
+
putProposal(input: ProposalInput): Promise<ProposalRecord>;
|
|
65
|
+
getProposal(id: string): Promise<ProposalRecord | null>;
|
|
66
|
+
listProposals(query: ProposalQuery): Promise<ProposalRecord[]>;
|
|
67
|
+
setProposalStatus(id: string, status: ProposalStatus, at?: number): Promise<void>;
|
|
68
|
+
expireProposals(now?: number): Promise<{
|
|
69
|
+
expired: number;
|
|
70
|
+
}>;
|
|
71
|
+
}
|