@avocadostudio-ai/orchestrator-core 0.3.2 → 0.4.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/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 +312 -54
- 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/prompts.d.ts +5 -0
- package/dist/chat/prompts.js +92 -9
- package/dist/chat/translation-chunking.d.ts +124 -0
- package/dist/chat/translation-chunking.js +371 -0
- package/dist/checks/field-walk.d.ts +42 -0
- package/dist/checks/field-walk.js +198 -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 +439 -0
- package/dist/checks/run-checks.d.ts +42 -0
- package/dist/checks/run-checks.js +159 -0
- package/dist/checks/session-runner.d.ts +19 -0
- package/dist/checks/session-runner.js +99 -0
- package/dist/checks/types.d.ts +109 -0
- package/dist/checks/types.js +1 -0
- package/dist/cms/adapter.d.ts +74 -1
- package/dist/cms/adapter.js +1 -0
- package/dist/cms/index.d.ts +1 -1
- package/dist/cms/index.js +1 -1
- package/dist/cms/media-sources.d.ts +29 -1
- package/dist/cms/media-sources.js +188 -7
- 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 +283 -32
- 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 +44 -1
- package/dist/http/history-actions.js +122 -0
- 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 +15 -4
- package/dist/http/publish-actions.js +3 -3
- 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 +9 -2
- package/dist/index.js +28 -1
- package/dist/nlp/deterministic-planner-context.d.ts +16 -0
- package/dist/nlp/deterministic-planner-context.js +33 -7
- package/dist/nlp/intent-detection.d.ts +16 -0
- package/dist/nlp/intent-detection.js +15 -1
- package/dist/nlp/plan-normalizer.js +66 -32
- 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-helpers.d.ts +12 -2
- package/dist/publish/publish-helpers.js +10 -3
- package/dist/publish/publish-selection.d.ts +84 -0
- package/dist/publish/publish-selection.js +113 -0
- package/dist/publish/publish-target-registry.js +1 -1
- package/dist/publish/publish-target.d.ts +1 -1
- package/dist/publish/targets/git.js +2 -2
- package/dist/state/session-state.js +8 -1
- package/dist/state/site-assets.d.ts +41 -0
- package/dist/state/site-assets.js +40 -0
- package/package.json +3 -3
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Sanity's dated API: one version for both the query and the asset endpoints. */
|
|
2
|
+
const SANITY_API_VERSION = "v2024-01-01";
|
|
1
3
|
const LABELS = {
|
|
2
4
|
contentful: "Contentful",
|
|
3
5
|
sanity: "Sanity",
|
|
@@ -69,20 +71,33 @@ async function contentfulMedia(config, { query, page, limit }) {
|
|
|
69
71
|
// ---------------------------------------------------------------------------
|
|
70
72
|
// Sanity — GROQ, slice paging, a second query for the count.
|
|
71
73
|
// ---------------------------------------------------------------------------
|
|
72
|
-
async function sanityMedia(config, { query, page, limit }) {
|
|
74
|
+
async function sanityMedia(config, { query, page, limit, kind }) {
|
|
73
75
|
const label = LABELS.sanity;
|
|
74
76
|
const dataset = config.dataset ?? "production";
|
|
75
77
|
const offset = (page - 1) * limit;
|
|
76
78
|
const end = offset + limit - 1;
|
|
79
|
+
const wantsFiles = kind === "file";
|
|
80
|
+
/*
|
|
81
|
+
* Sanity keeps documents in a second asset type. A PDF uploaded through the
|
|
82
|
+
* Studio — or through `cmsMediaUploader` below — is a `sanity.fileAsset`,
|
|
83
|
+
* and asking for `sanity.imageAsset` finds none of them however many the
|
|
84
|
+
* project holds. Before this branch existed, a request for documents was
|
|
85
|
+
* answered with the image list, and the route's own kind filter then dropped
|
|
86
|
+
* every item: an empty Documents tab on a project full of menus.
|
|
87
|
+
*/
|
|
88
|
+
const assetType = wantsFiles ? "sanity.fileAsset" : "sanity.imageAsset";
|
|
77
89
|
// GROQ is interpolated, so the filter term is stripped of the two characters
|
|
78
90
|
// that could close the string literal it lands inside.
|
|
79
91
|
const safeQuery = (query ?? "").replace(/["\\]/g, "");
|
|
80
92
|
const filter = safeQuery
|
|
81
|
-
? `_type == "
|
|
82
|
-
: `_type == "
|
|
83
|
-
const
|
|
93
|
+
? `_type == "${assetType}" && originalFilename match "*${safeQuery}*"`
|
|
94
|
+
: `_type == "${assetType}"`;
|
|
95
|
+
const projection = wantsFiles
|
|
96
|
+
? `{ _id, url, originalFilename, mimeType, size }`
|
|
97
|
+
: `{ _id, url, originalFilename, metadata { dimensions } }`;
|
|
98
|
+
const groq = `*[${filter}] | order(_createdAt desc) [${offset}..${end}] ${projection}`;
|
|
84
99
|
const countGroq = `count(*[${filter}])`;
|
|
85
|
-
const base = `https://${config.projectId}.api.sanity.io/
|
|
100
|
+
const base = `https://${config.projectId}.api.sanity.io/${SANITY_API_VERSION}/data/query/${dataset}`;
|
|
86
101
|
const headers = {};
|
|
87
102
|
if (config.token)
|
|
88
103
|
headers.authorization = `Bearer ${config.token}`;
|
|
@@ -95,17 +110,37 @@ async function sanityMedia(config, { query, page, limit }) {
|
|
|
95
110
|
const assets = (await assetsRes.json());
|
|
96
111
|
const count = countRes.ok ? (await countRes.json()).result ?? 0 : 0;
|
|
97
112
|
return {
|
|
98
|
-
items: (assets.result ?? []).map((asset) => ({
|
|
113
|
+
items: (assets.result ?? []).map((asset) => wantsFiles ? sanityFileItem(asset) : {
|
|
99
114
|
id: asset._id,
|
|
100
115
|
name: asset.originalFilename,
|
|
101
116
|
alt: asset.originalFilename,
|
|
102
117
|
imageUrl: asset.url,
|
|
103
118
|
thumbUrl: `${asset.url}?w=200&h=200&fit=crop`
|
|
104
|
-
})
|
|
119
|
+
}),
|
|
105
120
|
totalPages: Math.ceil(count / limit),
|
|
106
121
|
label
|
|
107
122
|
};
|
|
108
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* One `sanity.fileAsset` as the picker's document tab renders it.
|
|
126
|
+
*
|
|
127
|
+
* `url` rather than `imageUrl`, because this is a link target and not something
|
|
128
|
+
* to draw, and `thumbUrl: ""` for the same reason — the picker draws an icon
|
|
129
|
+
* from `contentType` when there is nothing to show. The URL is Sanity's own
|
|
130
|
+
* `cdn.sanity.io/files/…` address: durable, CDN-served, and unchanged by any
|
|
131
|
+
* redeploy of the site that links to it.
|
|
132
|
+
*/
|
|
133
|
+
function sanityFileItem(asset) {
|
|
134
|
+
return {
|
|
135
|
+
id: asset._id,
|
|
136
|
+
kind: "file",
|
|
137
|
+
name: asset.originalFilename,
|
|
138
|
+
url: asset.url,
|
|
139
|
+
thumbUrl: "",
|
|
140
|
+
contentType: asset.mimeType ?? "application/octet-stream",
|
|
141
|
+
...(typeof asset.size === "number" ? { size: asset.size } : {})
|
|
142
|
+
};
|
|
143
|
+
}
|
|
109
144
|
// ---------------------------------------------------------------------------
|
|
110
145
|
// Strapi — upload plugin, page/pageSize paging, count in a response header.
|
|
111
146
|
// ---------------------------------------------------------------------------
|
|
@@ -142,6 +177,152 @@ async function strapiMedia(config, { query, page, limit }) {
|
|
|
142
177
|
label
|
|
143
178
|
};
|
|
144
179
|
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// Adding one — the write half, for the CMSes whose asset API is a single call.
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
/**
|
|
184
|
+
* Build a media writer from the same connection details as the reader, or
|
|
185
|
+
* `null` when this provider (or this configuration) cannot take an upload.
|
|
186
|
+
*
|
|
187
|
+
* ```ts
|
|
188
|
+
* const uploadMedia = cmsMediaUploader({ provider: "sanity", projectId, dataset, token })
|
|
189
|
+
* return { id: "sanity", getPages, getMedia, ...(uploadMedia ? { uploadMedia } : {}) }
|
|
190
|
+
* ```
|
|
191
|
+
*
|
|
192
|
+
* Null rather than a stub that throws, and spread rather than assigned,
|
|
193
|
+
* because `writesMedia` is derived from whether the method *exists*
|
|
194
|
+
* (`resolveCapabilities`) and the editor shows its upload control on the
|
|
195
|
+
* strength of that. A method that is always present and always fails is a
|
|
196
|
+
* button that is always there and never works.
|
|
197
|
+
*
|
|
198
|
+
* So a project with a read-only token gets no upload control at all, which is
|
|
199
|
+
* the truth about it: Sanity's query API answers a viewer token and its asset
|
|
200
|
+
* API does not, and that difference is invisible until somebody picks a file.
|
|
201
|
+
*
|
|
202
|
+
* **Sanity only, for now.** Uploading to Contentful is a three-step
|
|
203
|
+
* asynchronous dance — create the asset, ask for processing, poll, publish —
|
|
204
|
+
* and Strapi's is a multipart POST to its upload plugin; neither is written
|
|
205
|
+
* yet, and both answer `null` so that an adapter which spreads the result
|
|
206
|
+
* simply offers no upload rather than offering a broken one.
|
|
207
|
+
*/
|
|
208
|
+
export function cmsMediaUploader(config) {
|
|
209
|
+
if (config.provider !== "sanity")
|
|
210
|
+
return null;
|
|
211
|
+
const token = config.token?.trim();
|
|
212
|
+
if (!token)
|
|
213
|
+
return null;
|
|
214
|
+
return (input) => sanityUpload({ ...config, token }, input);
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* MIME types for the document extensions a site is likely to be handed, used
|
|
218
|
+
* only when the browser reported none.
|
|
219
|
+
*
|
|
220
|
+
* Sanity sniffs the bytes and would usually get there on its own; sending
|
|
221
|
+
* `application/octet-stream` for a PDF, though, is how an asset ends up in the
|
|
222
|
+
* library with a type the picker cannot draw an icon for.
|
|
223
|
+
*/
|
|
224
|
+
const UPLOAD_MIME_BY_EXT = {
|
|
225
|
+
pdf: "application/pdf",
|
|
226
|
+
doc: "application/msword",
|
|
227
|
+
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
228
|
+
xls: "application/vnd.ms-excel",
|
|
229
|
+
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
230
|
+
csv: "text/csv",
|
|
231
|
+
txt: "text/plain",
|
|
232
|
+
png: "image/png",
|
|
233
|
+
jpg: "image/jpeg",
|
|
234
|
+
jpeg: "image/jpeg",
|
|
235
|
+
webp: "image/webp",
|
|
236
|
+
gif: "image/gif",
|
|
237
|
+
svg: "image/svg+xml"
|
|
238
|
+
};
|
|
239
|
+
function uploadContentType(input) {
|
|
240
|
+
const declared = input.contentType.trim();
|
|
241
|
+
if (declared)
|
|
242
|
+
return declared;
|
|
243
|
+
const ext = input.filename.toLowerCase().split(".").pop() ?? "";
|
|
244
|
+
return UPLOAD_MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The filename Sanity records as `originalFilename`.
|
|
248
|
+
*
|
|
249
|
+
* It is not a path — the asset's own URL is derived from a content hash — so
|
|
250
|
+
* this strips directory segments and control characters and otherwise leaves
|
|
251
|
+
* the name a person chose intact, accents and spaces included. The picker
|
|
252
|
+
* shows this string, and "Menükarte Winter 2026.pdf" is what makes a document
|
|
253
|
+
* recognisable in a list of twenty.
|
|
254
|
+
*/
|
|
255
|
+
function uploadFilename(raw) {
|
|
256
|
+
const base = raw.split(/[\\/]/).pop() ?? "";
|
|
257
|
+
// Control characters, written as escapes: a literal one in the source is
|
|
258
|
+
// invisible to every reader and turns the file binary to `grep`.
|
|
259
|
+
return base.replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 120);
|
|
260
|
+
}
|
|
261
|
+
async function sanityUpload(config, input) {
|
|
262
|
+
const dataset = config.dataset ?? "production";
|
|
263
|
+
/*
|
|
264
|
+
* Two buckets, and picking the wrong one is not a detail: `images` runs the
|
|
265
|
+
* bytes through image processing and refuses anything that is not a picture,
|
|
266
|
+
* which is exactly the refusal a PDF sent there produces. `kind` is what the
|
|
267
|
+
* editor said it was uploading, so it decides.
|
|
268
|
+
*/
|
|
269
|
+
const bucket = input.kind === "file" ? "files" : "images";
|
|
270
|
+
const filename = uploadFilename(input.filename);
|
|
271
|
+
const params = new URLSearchParams();
|
|
272
|
+
if (filename)
|
|
273
|
+
params.set("filename", filename);
|
|
274
|
+
const res = await fetch(`https://${config.projectId}.api.sanity.io/${SANITY_API_VERSION}/assets/${bucket}/${dataset}?${params}`, {
|
|
275
|
+
method: "POST",
|
|
276
|
+
headers: {
|
|
277
|
+
authorization: `Bearer ${config.token}`,
|
|
278
|
+
"content-type": uploadContentType(input)
|
|
279
|
+
},
|
|
280
|
+
body: new Uint8Array(input.data)
|
|
281
|
+
});
|
|
282
|
+
/*
|
|
283
|
+
* Throwing is the contract's way of refusing an upload, and the message
|
|
284
|
+
* reaches the person who picked the file — so it has to say what Sanity
|
|
285
|
+
* said, not that something went wrong.
|
|
286
|
+
*/
|
|
287
|
+
if (!res.ok)
|
|
288
|
+
throw new Error(await sanityUploadError(res));
|
|
289
|
+
const body = (await res.json());
|
|
290
|
+
const doc = body.document;
|
|
291
|
+
if (!doc?._id || !doc.url) {
|
|
292
|
+
throw new Error("Sanity accepted the upload but returned no asset.");
|
|
293
|
+
}
|
|
294
|
+
if (input.kind === "file") {
|
|
295
|
+
return sanityFileItem({ _id: doc._id, url: doc.url, originalFilename: doc.originalFilename, mimeType: doc.mimeType, size: doc.size });
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
id: doc._id,
|
|
299
|
+
name: doc.originalFilename,
|
|
300
|
+
alt: doc.originalFilename,
|
|
301
|
+
imageUrl: doc.url,
|
|
302
|
+
thumbUrl: `${doc.url}?w=200&h=200&fit=crop`
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
async function sanityUploadError(res) {
|
|
306
|
+
/*
|
|
307
|
+
* 401/403 has one overwhelmingly likely cause and it is not obvious from
|
|
308
|
+
* Sanity's own wording: the token reads fine — the picker's grid is full of
|
|
309
|
+
* images, which is what makes it confusing — and simply has no write grant.
|
|
310
|
+
*/
|
|
311
|
+
const suffix = res.status === 401 || res.status === 403
|
|
312
|
+
? " The API token needs write access to this dataset; a read token fills the picker but cannot upload."
|
|
313
|
+
: "";
|
|
314
|
+
const fallback = `Sanity refused the upload (${res.status}).${suffix}`;
|
|
315
|
+
try {
|
|
316
|
+
const body = (await res.json());
|
|
317
|
+
const described = typeof body.error === "string"
|
|
318
|
+
? body.error
|
|
319
|
+
: body.error?.description ?? body.error?.message ?? body.message;
|
|
320
|
+
return described ? `Sanity refused the upload: ${described}${suffix}` : fallback;
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return fallback;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
145
326
|
/**
|
|
146
327
|
* Build a reader from an untrusted object, or `null` if it is not one of the
|
|
147
328
|
* three shapes.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DurableStore } from "./types.ts";
|
|
2
|
+
export declare function getDurableStore(): DurableStore;
|
|
3
|
+
/**
|
|
4
|
+
* Whether findings, memory and proposals are reaching disk, and why not.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately separate from `persistenceHealth()`, which answers the same
|
|
7
|
+
* question about page state and whose warning text is about losing edits.
|
|
8
|
+
*/
|
|
9
|
+
export declare function durableHealth(): {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
reason: string | null;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Record a durable-store write failure. Separate from the page-state channel:
|
|
15
|
+
* a proposal row that did not save must not tell the user their edits are gone.
|
|
16
|
+
*/
|
|
17
|
+
export declare function noteDurableFailure(reason: string): void;
|
|
18
|
+
/**
|
|
19
|
+
* True when findings, memory and proposals are living in memory only. Callers
|
|
20
|
+
* that are about to promise durability — a scheduled run leaving a plan for the
|
|
21
|
+
* morning — should say so rather than assume.
|
|
22
|
+
*/
|
|
23
|
+
export declare function durableStoreIsEphemeral(): boolean;
|
|
24
|
+
/** Drop the singleton. Tests and graceful shutdown; `getStore()` owns closing. */
|
|
25
|
+
export declare function resetDurableStore(): void;
|
|
26
|
+
/** Swap in a store — for tests, and for a host that brings its own backend. */
|
|
27
|
+
export declare function setDurableStore(next: DurableStore | null, ephemeral?: boolean): void;
|
|
28
|
+
export declare function isDiscardInFlight(scopeKey: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Discard any pending proposal for a session being deleted.
|
|
31
|
+
*
|
|
32
|
+
* Without this, deleting a session leaves a `pending` row behind, and the next
|
|
33
|
+
* session with that id would rehydrate a plan the user had already thrown away.
|
|
34
|
+
*/
|
|
35
|
+
export declare function discardPendingProposalsForSession(scopeKey: string): void;
|
|
36
|
+
/** Sweep proposals past their TTL, alongside the ephemeral-map eviction pass. */
|
|
37
|
+
export declare function sweepExpiredProposals(): void;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { getStore } from "../state/sqlite-store-singleton.js";
|
|
2
|
+
import { SqliteDurableStore } from "./sqlite-durable-store.js";
|
|
3
|
+
import { InMemoryDurableStore } from "./in-memory-durable-store.js";
|
|
4
|
+
/*
|
|
5
|
+
* Singleton access to the durable store, mirroring `getStore()` next door.
|
|
6
|
+
*
|
|
7
|
+
* The one interesting decision here is what to do when SQLite is unavailable.
|
|
8
|
+
* `getStore()` throws: the driver could not load, and there is no page state
|
|
9
|
+
* without it. This one falls back to `InMemoryDurableStore` instead, for the
|
|
10
|
+
* same reason `createOrchestrator()` already keeps serving edits in that
|
|
11
|
+
* situation — a findings panel that 500s because a native module is missing is
|
|
12
|
+
* worse than one that works and says it is not saving anything.
|
|
13
|
+
*
|
|
14
|
+
* The failure is recorded here rather than through `notePersistenceFailure`.
|
|
15
|
+
* That channel is about *page state*: its warning tells the user "this edit was
|
|
16
|
+
* applied in memory but could not be saved", and every mutation response starts
|
|
17
|
+
* carrying `persisted: false`. Reporting a failed *proposal* row through it
|
|
18
|
+
* would tell somebody their content edits are being lost when they are not —
|
|
19
|
+
* a worse lie than the one the channel exists to prevent.
|
|
20
|
+
*
|
|
21
|
+
* Findings and proposals are capable of exactly the same lie on their own
|
|
22
|
+
* account, and an overnight run is precisely when nobody is watching the log,
|
|
23
|
+
* so they get their own flag with its own wording.
|
|
24
|
+
*/
|
|
25
|
+
let store = null;
|
|
26
|
+
let usingFallback = false;
|
|
27
|
+
let overridden = false;
|
|
28
|
+
let failureReason = null;
|
|
29
|
+
let lastAttemptAt = 0;
|
|
30
|
+
/** How long to stay on the fallback before re-trying SQLite. */
|
|
31
|
+
const RETRY_COOLDOWN_MS = 30_000;
|
|
32
|
+
/*
|
|
33
|
+
* The connection `store` was built against.
|
|
34
|
+
*
|
|
35
|
+
* `resetStore()` next door closes the database and drops its singleton, and the
|
|
36
|
+
* next `getStore()` opens a fresh one — which used to leave this module holding
|
|
37
|
+
* prepared statements on a closed handle. Rather than have `resetStore()` call
|
|
38
|
+
* in here (a cycle: this module imports that one), we notice: a different `db`
|
|
39
|
+
* object means a different database, and the store is rebuilt against it.
|
|
40
|
+
*/
|
|
41
|
+
let boundDb = null;
|
|
42
|
+
export function getDurableStore() {
|
|
43
|
+
// An explicitly-set store is sticky; the fallback is not.
|
|
44
|
+
if (store && overridden)
|
|
45
|
+
return store;
|
|
46
|
+
/*
|
|
47
|
+
* The fallback must not be permanent. `getStore()` next door leaves its own
|
|
48
|
+
* singleton null on a constructor throw and retries on the next call, so page
|
|
49
|
+
* state recovers from a transient failure — a `.data` directory not yet
|
|
50
|
+
* writable at boot, say — while this store, if it latched, would stay
|
|
51
|
+
* memory-only for the life of the process with no way back.
|
|
52
|
+
*
|
|
53
|
+
* Retrying on a cooldown rather than every call keeps a genuinely broken
|
|
54
|
+
* install from paying for a constructor on every finding. Whatever the
|
|
55
|
+
* fallback accumulated is lost on a successful retry; it was never durable,
|
|
56
|
+
* and saying so is the honest trade.
|
|
57
|
+
*/
|
|
58
|
+
if (store && usingFallback && Date.now() - lastAttemptAt < RETRY_COOLDOWN_MS)
|
|
59
|
+
return store;
|
|
60
|
+
try {
|
|
61
|
+
lastAttemptAt = Date.now();
|
|
62
|
+
// Share `SqliteStore`'s connection: one WAL, one backup, one VACUUM INTO.
|
|
63
|
+
const db = getStore().db;
|
|
64
|
+
if (!store || usingFallback || boundDb !== db) {
|
|
65
|
+
store = new SqliteDurableStore(db);
|
|
66
|
+
boundDb = db;
|
|
67
|
+
usingFallback = false;
|
|
68
|
+
failureReason = null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
failureReason = err instanceof Error ? err.message : String(err);
|
|
73
|
+
if (!store || !usingFallback) {
|
|
74
|
+
store = new InMemoryDurableStore();
|
|
75
|
+
boundDb = null;
|
|
76
|
+
usingFallback = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return store;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Whether findings, memory and proposals are reaching disk, and why not.
|
|
83
|
+
*
|
|
84
|
+
* Deliberately separate from `persistenceHealth()`, which answers the same
|
|
85
|
+
* question about page state and whose warning text is about losing edits.
|
|
86
|
+
*/
|
|
87
|
+
export function durableHealth() {
|
|
88
|
+
return { ok: !usingFallback, reason: usingFallback ? failureReason : null };
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Record a durable-store write failure. Separate from the page-state channel:
|
|
92
|
+
* a proposal row that did not save must not tell the user their edits are gone.
|
|
93
|
+
*/
|
|
94
|
+
export function noteDurableFailure(reason) {
|
|
95
|
+
failureReason ??= reason;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* True when findings, memory and proposals are living in memory only. Callers
|
|
99
|
+
* that are about to promise durability — a scheduled run leaving a plan for the
|
|
100
|
+
* morning — should say so rather than assume.
|
|
101
|
+
*/
|
|
102
|
+
export function durableStoreIsEphemeral() {
|
|
103
|
+
if (!store)
|
|
104
|
+
getDurableStore();
|
|
105
|
+
return usingFallback;
|
|
106
|
+
}
|
|
107
|
+
/** Drop the singleton. Tests and graceful shutdown; `getStore()` owns closing. */
|
|
108
|
+
export function resetDurableStore() {
|
|
109
|
+
store = null;
|
|
110
|
+
usingFallback = false;
|
|
111
|
+
overridden = false;
|
|
112
|
+
boundDb = null;
|
|
113
|
+
failureReason = null;
|
|
114
|
+
lastAttemptAt = 0;
|
|
115
|
+
}
|
|
116
|
+
/** Swap in a store — for tests, and for a host that brings its own backend. */
|
|
117
|
+
export function setDurableStore(next, ephemeral = false) {
|
|
118
|
+
store = next;
|
|
119
|
+
usingFallback = next ? ephemeral : false;
|
|
120
|
+
overridden = next !== null;
|
|
121
|
+
boundDb = null;
|
|
122
|
+
}
|
|
123
|
+
/*
|
|
124
|
+
* The two lifecycle hooks `session-state.ts` needs, living here rather than in
|
|
125
|
+
* `pending-plan-store.ts` so that session-state can call them without importing
|
|
126
|
+
* a module that imports session-state back. The cycle would probably work —
|
|
127
|
+
* neither side touches the other at module scope — but this repo has already
|
|
128
|
+
* been bitten by ESM evaluation order once, and a cleanup call is not worth
|
|
129
|
+
* finding out a second time.
|
|
130
|
+
*
|
|
131
|
+
* Both are fire-and-forget: they are called from synchronous state mutators
|
|
132
|
+
* whose signatures a durability concern has no business changing, and neither
|
|
133
|
+
* result affects what the caller returns.
|
|
134
|
+
*/
|
|
135
|
+
/**
|
|
136
|
+
* Sessions whose pending rows are being discarded right now.
|
|
137
|
+
*
|
|
138
|
+
* The set is added to *synchronously*, before the async discard starts, and the
|
|
139
|
+
* rehydration path consults it. `forgetSession` is a synchronous mutator, so
|
|
140
|
+
* without this a caller that forgets a session and immediately opens a new chat
|
|
141
|
+
* on the same id — ids are caller-supplied and routinely reused — races the
|
|
142
|
+
* discard and reads back the plan the delete was meant to remove.
|
|
143
|
+
*
|
|
144
|
+
* It lives here rather than in `pending-plan-store.ts` for the same reason the
|
|
145
|
+
* hooks below do: session-state must be able to reach it without importing a
|
|
146
|
+
* module that imports session-state back.
|
|
147
|
+
*/
|
|
148
|
+
const discardInFlight = new Set();
|
|
149
|
+
export function isDiscardInFlight(scopeKey) {
|
|
150
|
+
return discardInFlight.has(scopeKey);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Discard any pending proposal for a session being deleted.
|
|
154
|
+
*
|
|
155
|
+
* Without this, deleting a session leaves a `pending` row behind, and the next
|
|
156
|
+
* session with that id would rehydrate a plan the user had already thrown away.
|
|
157
|
+
*/
|
|
158
|
+
export function discardPendingProposalsForSession(scopeKey) {
|
|
159
|
+
discardInFlight.add(scopeKey);
|
|
160
|
+
void (async () => {
|
|
161
|
+
const active = getDurableStore();
|
|
162
|
+
const rows = await active.listProposals({ scopeKey, status: "pending" });
|
|
163
|
+
for (const row of rows)
|
|
164
|
+
await active.setProposalStatus(row.id, "discarded");
|
|
165
|
+
})()
|
|
166
|
+
.catch(() => {
|
|
167
|
+
/* best-effort: an orphan row is inert until something reads it, and the
|
|
168
|
+
read path re-checks expiry and payload validity anyway. */
|
|
169
|
+
})
|
|
170
|
+
.finally(() => discardInFlight.delete(scopeKey));
|
|
171
|
+
}
|
|
172
|
+
/** Sweep proposals past their TTL, alongside the ephemeral-map eviction pass. */
|
|
173
|
+
export function sweepExpiredProposals() {
|
|
174
|
+
void getDurableStore()
|
|
175
|
+
.expireProposals()
|
|
176
|
+
.catch(() => {
|
|
177
|
+
/* best-effort */
|
|
178
|
+
});
|
|
179
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FindingSeverity } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* What a severity is worth before the page is taken into account.
|
|
4
|
+
*
|
|
5
|
+
* Not linear, and deliberately: `info` is defined in `rules-draft.ts` as "worth
|
|
6
|
+
* a look, and safe to ignore forever", so an info finding on the home page
|
|
7
|
+
* should sit below a warning on a secondary page, and 0.3 × 1.0 < 0.6 × 0.55
|
|
8
|
+
* is what makes that true.
|
|
9
|
+
*/
|
|
10
|
+
export declare const SEVERITY_WEIGHT: Record<FindingSeverity, number>;
|
|
11
|
+
/**
|
|
12
|
+
* The weight given to a page nothing has scored — a row written by a build
|
|
13
|
+
* before impact existed, or by a caller that supplied no weights.
|
|
14
|
+
*
|
|
15
|
+
* Mid-scale rather than 1, so old rows interleave with scored ones instead of
|
|
16
|
+
* sorting above every finding on the site until the next run corrects them.
|
|
17
|
+
*/
|
|
18
|
+
export declare const NEUTRAL_PAGE_WEIGHT = 0.5;
|
|
19
|
+
/** Severity × page weight, rounded to something a human can read in a log. */
|
|
20
|
+
export declare function impactFor(severity: FindingSeverity, pageWeight: number): number;
|
|
21
|
+
/** What an unscored finding is worth. Must match `IMPACT_FALLBACK_SQL`. */
|
|
22
|
+
export declare function fallbackImpact(severity: FindingSeverity): number;
|
|
23
|
+
/**
|
|
24
|
+
* The same fallback, for SQLite's ORDER BY.
|
|
25
|
+
*
|
|
26
|
+
* Generated from the constants above rather than written out, because the one
|
|
27
|
+
* failure mode here is the two drifting apart — the list would then be sorted
|
|
28
|
+
* by one rule and explained by another, and nothing would look wrong.
|
|
29
|
+
*/
|
|
30
|
+
export declare const IMPACT_FALLBACK_SQL: string;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Impact — severity times how much the page matters.
|
|
3
|
+
*
|
|
4
|
+
* It lives beside the types rather than in `checks/` because both stores need
|
|
5
|
+
* it: a finding written before this shipped has no `impact` column, and the
|
|
6
|
+
* ORDER BY has to put it somewhere defensible rather than at one end.
|
|
7
|
+
*
|
|
8
|
+
* The two axes stay separate on the record. Severity is a claim about the
|
|
9
|
+
* problem and belongs to the rule; impact is a claim about the page and is
|
|
10
|
+
* recomputed by every run, because the link graph moves. Multiplying them into
|
|
11
|
+
* one stored number instead would make a finding's severity unrecoverable, and
|
|
12
|
+
* severity is what the icon in the panel means.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* What a severity is worth before the page is taken into account.
|
|
16
|
+
*
|
|
17
|
+
* Not linear, and deliberately: `info` is defined in `rules-draft.ts` as "worth
|
|
18
|
+
* a look, and safe to ignore forever", so an info finding on the home page
|
|
19
|
+
* should sit below a warning on a secondary page, and 0.3 × 1.0 < 0.6 × 0.55
|
|
20
|
+
* is what makes that true.
|
|
21
|
+
*/
|
|
22
|
+
export const SEVERITY_WEIGHT = {
|
|
23
|
+
error: 1,
|
|
24
|
+
warning: 0.6,
|
|
25
|
+
info: 0.3
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The weight given to a page nothing has scored — a row written by a build
|
|
29
|
+
* before impact existed, or by a caller that supplied no weights.
|
|
30
|
+
*
|
|
31
|
+
* Mid-scale rather than 1, so old rows interleave with scored ones instead of
|
|
32
|
+
* sorting above every finding on the site until the next run corrects them.
|
|
33
|
+
*/
|
|
34
|
+
export const NEUTRAL_PAGE_WEIGHT = 0.5;
|
|
35
|
+
/** Severity × page weight, rounded to something a human can read in a log. */
|
|
36
|
+
export function impactFor(severity, pageWeight) {
|
|
37
|
+
const clamped = Math.max(0, Math.min(pageWeight, 1));
|
|
38
|
+
return Math.round(SEVERITY_WEIGHT[severity] * clamped * 1000) / 1000;
|
|
39
|
+
}
|
|
40
|
+
/** What an unscored finding is worth. Must match `IMPACT_FALLBACK_SQL`. */
|
|
41
|
+
export function fallbackImpact(severity) {
|
|
42
|
+
return impactFor(severity, NEUTRAL_PAGE_WEIGHT);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The same fallback, for SQLite's ORDER BY.
|
|
46
|
+
*
|
|
47
|
+
* Generated from the constants above rather than written out, because the one
|
|
48
|
+
* failure mode here is the two drifting apart — the list would then be sorted
|
|
49
|
+
* by one rule and explained by another, and nothing would look wrong.
|
|
50
|
+
*/
|
|
51
|
+
export const IMPACT_FALLBACK_SQL = `COALESCE(impact, CASE severity ${Object.keys(SEVERITY_WEIGHT)
|
|
52
|
+
.map((severity) => `WHEN '${severity}' THEN ${fallbackImpact(severity)}`)
|
|
53
|
+
.join(" ")} END)`;
|