@avocadostudio-ai/orchestrator-core 0.3.1 → 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.
Files changed (73) hide show
  1. package/dist/chat/anthropic-planner.d.ts +8 -0
  2. package/dist/chat/anthropic-planner.js +166 -12
  3. package/dist/chat/chat-pipeline-translation.d.ts +13 -0
  4. package/dist/chat/chat-pipeline-translation.js +109 -45
  5. package/dist/chat/chat-pipeline.d.ts +1 -1
  6. package/dist/chat/chat-pipeline.js +297 -53
  7. package/dist/chat/gemini-planner.d.ts +2 -0
  8. package/dist/chat/gemini-planner.js +2 -1
  9. package/dist/chat/hallucination-validator.d.ts +6 -0
  10. package/dist/chat/hallucination-validator.js +49 -8
  11. package/dist/chat/planner-types.d.ts +15 -0
  12. package/dist/chat/planner-types.js +2 -2
  13. package/dist/chat/planner.d.ts +12 -0
  14. package/dist/chat/planner.js +16 -2
  15. package/dist/chat/translation-chunking.d.ts +124 -0
  16. package/dist/chat/translation-chunking.js +371 -0
  17. package/dist/checks/field-walk.d.ts +25 -0
  18. package/dist/checks/field-walk.js +152 -0
  19. package/dist/checks/index.d.ts +5 -0
  20. package/dist/checks/index.js +4 -0
  21. package/dist/checks/page-weight.d.ts +22 -0
  22. package/dist/checks/page-weight.js +200 -0
  23. package/dist/checks/rules-draft.d.ts +2 -0
  24. package/dist/checks/rules-draft.js +375 -0
  25. package/dist/checks/run-checks.d.ts +32 -0
  26. package/dist/checks/run-checks.js +152 -0
  27. package/dist/checks/session-runner.d.ts +19 -0
  28. package/dist/checks/session-runner.js +95 -0
  29. package/dist/checks/types.d.ts +65 -0
  30. package/dist/checks/types.js +1 -0
  31. package/dist/cms/adapter.d.ts +1 -0
  32. package/dist/durable/durable-store-singleton.d.ts +37 -0
  33. package/dist/durable/durable-store-singleton.js +179 -0
  34. package/dist/durable/finding-impact.d.ts +30 -0
  35. package/dist/durable/finding-impact.js +53 -0
  36. package/dist/durable/in-memory-durable-store.d.ts +203 -0
  37. package/dist/durable/in-memory-durable-store.js +363 -0
  38. package/dist/durable/index.d.ts +5 -0
  39. package/dist/durable/index.js +4 -0
  40. package/dist/durable/pending-plan-store.d.ts +28 -0
  41. package/dist/durable/pending-plan-store.js +156 -0
  42. package/dist/durable/sqlite-durable-store.d.ts +71 -0
  43. package/dist/durable/sqlite-durable-store.js +631 -0
  44. package/dist/durable/types.d.ts +265 -0
  45. package/dist/durable/types.js +1 -0
  46. package/dist/handler/create-orchestrator.d.ts +4 -0
  47. package/dist/handler/create-orchestrator.js +85 -9
  48. package/dist/http/audio-actions.d.ts +1 -1
  49. package/dist/http/checks-actions.d.ts +39 -0
  50. package/dist/http/checks-actions.js +122 -0
  51. package/dist/http/history-actions.d.ts +1 -1
  52. package/dist/http/image-generate-actions.d.ts +2 -2
  53. package/dist/http/ops-actions.d.ts +2 -2
  54. package/dist/http/publish-actions.d.ts +4 -4
  55. package/dist/http/restore-actions.d.ts +3 -3
  56. package/dist/http/screenshot-actions.d.ts +2 -2
  57. package/dist/http/session-actions.d.ts +1 -1
  58. package/dist/http/telemetry-feedback-actions.d.ts +2 -2
  59. package/dist/http/unsplash-actions.d.ts +2 -2
  60. package/dist/http/variations-actions.d.ts +2 -2
  61. package/dist/index.d.ts +7 -0
  62. package/dist/index.js +27 -0
  63. package/dist/nlp/deterministic-planner-context.d.ts +16 -0
  64. package/dist/nlp/deterministic-planner-context.js +33 -7
  65. package/dist/nlp/deterministic-planner-suggestions.js +1 -1
  66. package/dist/nlp/plan-normalizer.js +193 -56
  67. package/dist/ops/destructive-action-gate.js +7 -2
  68. package/dist/ops/ops-engine.d.ts +12 -1
  69. package/dist/ops/ops-engine.js +41 -14
  70. package/dist/publish/publish-target-registry.js +1 -1
  71. package/dist/publish/publish-target.d.ts +1 -1
  72. package/dist/state/session-state.js +8 -1
  73. package/package.json +3 -3
@@ -0,0 +1,152 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { getDurableStore } from "../durable/durable-store-singleton.js";
3
+ import { NEUTRAL_PAGE_WEIGHT, impactFor } from "../durable/finding-impact.js";
4
+ import { walkPageFields } from "./field-walk.js";
5
+ import { computePageWeights } from "./page-weight.js";
6
+ import { DRAFT_RULES } from "./rules-draft.js";
7
+ /**
8
+ * The fingerprint: identity of a problem, not of an occurrence of it.
9
+ *
10
+ * It is computed here, from `(scopeKey, slug, ruleId, key)`, and never by a
11
+ * rule — because the one thing that must not leak into it is the offending
12
+ * *value*. Include the value and half-fixing a title produces a second finding
13
+ * instead of an updated one, orphaning the first and silently voiding the
14
+ * dismissal somebody made last week.
15
+ */
16
+ export function fingerprintFor(scopeKey, slug, ruleId, key = "") {
17
+ return createHash("sha256").update([scopeKey, slug, ruleId, key].join("\u0000")).digest("hex").slice(0, 32);
18
+ }
19
+ export async function runDraftChecks(args) {
20
+ const store = args.store ?? getDurableStore();
21
+ const now = args.now ?? Date.now;
22
+ const rules = args.rules ?? DRAFT_RULES;
23
+ const runId = args.runId ?? randomUUID();
24
+ const startedAt = now();
25
+ const site = {
26
+ slugs: args.pages.map((p) => p.slug),
27
+ pages: args.pages.map((p) => ({ slug: p.slug, title: p.title, ...(p.meta ? { meta: p.meta } : {}) })),
28
+ config: args.siteConfig ?? {}
29
+ };
30
+ const wanted = args.slugs ? new Set(args.slugs) : null;
31
+ const scanned = args.pages.filter((p) => (wanted ? wanted.has(p.slug) : true));
32
+ /*
33
+ * Every page is walked, not only the scanned ones.
34
+ *
35
+ * Page weight is a property of the site's link graph, so scoring it from the
36
+ * scanned subset would make a finding's impact depend on which run last
37
+ * touched it — the same finding worth 0.6 after a full sweep and 0.1 after an
38
+ * incremental one, with the panel resorting itself for no visible reason.
39
+ *
40
+ * The walk is pure in-memory work over data already held, and the scanned
41
+ * pages reuse their entry rather than being walked a second time.
42
+ */
43
+ const fieldsBySlug = new Map();
44
+ for (const page of args.pages)
45
+ fieldsBySlug.set(page.slug, walkPageFields(page, args.manifest));
46
+ const weights = computePageWeights({ pages: site.pages, fieldsBySlug, config: site.config });
47
+ await store.startCheckRun({
48
+ id: runId,
49
+ scopeKey: args.scopeKey,
50
+ agent: rules.length === 1 ? rules[0].agent : "checks",
51
+ trigger: args.trigger ?? "manual",
52
+ startedAt
53
+ });
54
+ const findings = [];
55
+ const seen = new Set();
56
+ let error;
57
+ try {
58
+ for (const page of scanned) {
59
+ const pageWeight = weights.get(page.slug)?.weight ?? NEUTRAL_PAGE_WEIGHT;
60
+ const ctx = {
61
+ scopeKey: args.scopeKey,
62
+ page,
63
+ site,
64
+ manifest: args.manifest,
65
+ fields: fieldsBySlug.get(page.slug) ?? []
66
+ };
67
+ for (const rule of rules) {
68
+ // One rule throwing must not cost the run every other rule's findings —
69
+ // a checker that goes dark because a custom block had an unexpected
70
+ // prop shape is worse than one that reports twelve of thirteen rules.
71
+ let produced;
72
+ try {
73
+ produced = rule.run(ctx);
74
+ }
75
+ catch (err) {
76
+ error ??= `${rule.id}: ${err instanceof Error ? err.message : String(err)}`;
77
+ continue;
78
+ }
79
+ for (const finding of produced) {
80
+ const fingerprint = fingerprintFor(args.scopeKey, page.slug, rule.id, finding.key);
81
+ /*
82
+ * Two findings from one rule sharing a key are one finding as far as
83
+ * the store is concerned — the second upsert overwrites the first.
84
+ * Collapsing them here instead keeps the ledger honest (the second
85
+ * was being counted as an `updated` row) and makes which one survives
86
+ * a decision rather than an accident of iteration order.
87
+ */
88
+ if (seen.has(fingerprint))
89
+ continue;
90
+ seen.add(fingerprint);
91
+ const severity = finding.severity ?? rule.severity;
92
+ findings.push({
93
+ fingerprint,
94
+ scopeKey: args.scopeKey,
95
+ slug: page.slug,
96
+ ruleId: rule.id,
97
+ agent: rule.agent,
98
+ severity,
99
+ impact: impactFor(severity, pageWeight),
100
+ title: finding.title,
101
+ ...(finding.detail ? { detail: finding.detail } : {}),
102
+ ...(finding.evidence ? { evidence: finding.evidence } : {}),
103
+ ...(finding.proposedOps ? { proposedOps: finding.proposedOps } : {})
104
+ });
105
+ }
106
+ }
107
+ }
108
+ const { opened } = await store.recordFindings(runId, findings);
109
+ // Reconcile per agent, not once for the whole run. A run of only the SEO
110
+ // rules that closed everything in scope would mark this morning's
111
+ // accessibility findings fixed without having looked at them.
112
+ const scannedSlugs = scanned.map((p) => p.slug);
113
+ let closed = 0;
114
+ for (const agent of new Set(rules.map((r) => r.agent))) {
115
+ const result = await store.reconcileFindings({
116
+ runId,
117
+ scopeKey: args.scopeKey,
118
+ slugs: scannedSlugs,
119
+ agent,
120
+ at: now()
121
+ });
122
+ closed += result.closed;
123
+ }
124
+ const record = {
125
+ id: runId,
126
+ scopeKey: args.scopeKey,
127
+ agent: rules.length === 1 ? rules[0].agent : "checks",
128
+ trigger: args.trigger ?? "manual",
129
+ startedAt,
130
+ finishedAt: now(),
131
+ pagesScanned: scanned.length,
132
+ findingsOpened: opened,
133
+ findingsClosed: closed,
134
+ costUsd: 0,
135
+ ...(error ? { error } : {})
136
+ };
137
+ await store.finishCheckRun(runId, {
138
+ finishedAt: record.finishedAt,
139
+ pagesScanned: record.pagesScanned,
140
+ findingsOpened: record.findingsOpened,
141
+ findingsClosed: record.findingsClosed,
142
+ costUsd: 0,
143
+ ...(error ? { error } : {})
144
+ });
145
+ return record;
146
+ }
147
+ catch (err) {
148
+ const reason = err instanceof Error ? err.message : String(err);
149
+ await store.finishCheckRun(runId, { finishedAt: now(), pagesScanned: scanned.length, error: reason });
150
+ throw err;
151
+ }
152
+ }
@@ -0,0 +1,19 @@
1
+ import type { CheckRunRecord, CheckRunTrigger } from "../durable/types.ts";
2
+ import type { Logger } from "../logger.ts";
3
+ export declare function runChecksForSession(args: {
4
+ scopeKey: string;
5
+ trigger: CheckRunTrigger;
6
+ slugs?: string[];
7
+ }): Promise<CheckRunRecord>;
8
+ /**
9
+ * Queue a draft-tier run after an apply, coalescing a burst of edits into one.
10
+ *
11
+ * Debounced rather than throttled: the useful moment is after someone stops
12
+ * typing, not in the middle of a streamed multi-op plan where half the ops have
13
+ * landed and the page is transiently wrong.
14
+ */
15
+ export declare function scheduleChecksAfterApply(scopeKey: string, log?: Logger): void;
16
+ /** Run the draft tier after a successful publish. */
17
+ export declare function scheduleChecksAfterPublish(scopeKey: string, log?: Logger): void;
18
+ /** Cancel any queued run. Tests, and graceful shutdown. */
19
+ export declare function cancelScheduledChecks(): void;
@@ -0,0 +1,95 @@
1
+ import { buildBlockManifest } from "@avocadostudio-ai/shared";
2
+ import { getSessionDraft, getSiteConfig } from "../state/session-state.js";
3
+ import { runDraftChecks } from "./run-checks.js";
4
+ /*
5
+ * Binds the pure rules engine to session state.
6
+ *
7
+ * `run-checks.ts` deliberately takes pages and a manifest as arguments and
8
+ * touches no globals — it is testable against invented block types and an
9
+ * invented site. This is the one place that reaches for the real ones, so both
10
+ * the HTTP action and the triggers below run identical code.
11
+ */
12
+ export async function runChecksForSession(args) {
13
+ return runDraftChecks({
14
+ scopeKey: args.scopeKey,
15
+ pages: [...getSessionDraft(args.scopeKey).values()],
16
+ // The registry of *this* process — in library mode, the host's own
17
+ // `registerBlocks()`. Same reason `blocksManifestAction` uses it: a rule
18
+ // reading our built-ins would be blind to the site's real vocabulary.
19
+ manifest: buildBlockManifest(),
20
+ siteConfig: getSiteConfig(args.scopeKey),
21
+ trigger: args.trigger,
22
+ ...(args.slugs?.length ? { slugs: args.slugs } : {})
23
+ });
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Triggers
27
+ // ---------------------------------------------------------------------------
28
+ /*
29
+ * What wakes a check run, and why the defaults are what they are.
30
+ *
31
+ * `on_publish` is on by default: the draft tier is a few milliseconds of
32
+ * in-memory work, it costs nothing, and the moment content ships is when
33
+ * anybody cares whether it is broken.
34
+ *
35
+ * `on_apply` is off by default, behind `CHECKS_ON_APPLY=1`. It is the one that
36
+ * fires on every edit, and this repo has already paid once for a fan-out
37
+ * nobody intended — an ambient linter should be something an operator turns on
38
+ * having decided to, not something they discover in a CPU graph.
39
+ *
40
+ * Both are inert under NODE_ENV=test: the hermetic suite must not have a
41
+ * background task writing findings into a store its assertions are reading.
42
+ */
43
+ const ON_APPLY_DEBOUNCE_MS = 2_000;
44
+ const pending = new Map();
45
+ function enabled(flag) {
46
+ if (process.env.NODE_ENV === "test")
47
+ return false;
48
+ if (flag === "apply")
49
+ return process.env.CHECKS_ON_APPLY === "1";
50
+ return process.env.CHECKS_ON_PUBLISH !== "0";
51
+ }
52
+ function runInBackground(scopeKey, trigger, log) {
53
+ void runChecksForSession({ scopeKey, trigger })
54
+ .then((run) => {
55
+ log?.info({ scopeKey, trigger, opened: run.findingsOpened, closed: run.findingsClosed }, "checks run complete");
56
+ })
57
+ .catch((err) => {
58
+ // A checker must never be able to fail the edit or the publish that
59
+ // triggered it. It reports and stops.
60
+ log?.error({ err: String(err), scopeKey, trigger }, "checks run failed");
61
+ });
62
+ }
63
+ /**
64
+ * Queue a draft-tier run after an apply, coalescing a burst of edits into one.
65
+ *
66
+ * Debounced rather than throttled: the useful moment is after someone stops
67
+ * typing, not in the middle of a streamed multi-op plan where half the ops have
68
+ * landed and the page is transiently wrong.
69
+ */
70
+ export function scheduleChecksAfterApply(scopeKey, log) {
71
+ if (!enabled("apply"))
72
+ return;
73
+ const existing = pending.get(scopeKey);
74
+ if (existing)
75
+ clearTimeout(existing);
76
+ const timer = setTimeout(() => {
77
+ pending.delete(scopeKey);
78
+ runInBackground(scopeKey, "on_apply", log);
79
+ }, ON_APPLY_DEBOUNCE_MS);
80
+ // Do not hold the process open for a linter.
81
+ timer.unref?.();
82
+ pending.set(scopeKey, timer);
83
+ }
84
+ /** Run the draft tier after a successful publish. */
85
+ export function scheduleChecksAfterPublish(scopeKey, log) {
86
+ if (!enabled("publish"))
87
+ return;
88
+ runInBackground(scopeKey, "on_publish", log);
89
+ }
90
+ /** Cancel any queued run. Tests, and graceful shutdown. */
91
+ export function cancelScheduledChecks() {
92
+ for (const timer of pending.values())
93
+ clearTimeout(timer);
94
+ pending.clear();
95
+ }
@@ -0,0 +1,65 @@
1
+ import type { BlockManifest, FieldKind, Operation, PageDoc, SiteConfig } from "@avocadostudio-ai/shared";
2
+ import type { FindingEvidence, FindingSeverity } from "../durable/types.ts";
3
+ /** One field on one block, located by the manifest rather than by block type. */
4
+ export type FieldEntry = {
5
+ blockId: string;
6
+ blockType: string;
7
+ /** Editable-path form: `title`, `cards[0].imageAlt`. */
8
+ path: string;
9
+ kind: FieldKind;
10
+ label?: string;
11
+ /**
12
+ * What a reader would call the block this field belongs to — its own heading,
13
+ * not the block type. A page with three Card Grids produces three findings
14
+ * reading `cards[0].imageAlt`, and the type name distinguishes none of them.
15
+ */
16
+ blockLabel?: string;
17
+ value: unknown;
18
+ /**
19
+ * The container this field sits in — `""` for a top-level prop, `cards[0]`
20
+ * for a list item. Pairing an image with its alt text is a question about
21
+ * siblings, and the container is what makes "sibling" meaningful.
22
+ */
23
+ container: string;
24
+ };
25
+ /** The other pages, for the rules that cannot be answered from one page. */
26
+ export type SiteView = {
27
+ slugs: string[];
28
+ pages: Array<Pick<PageDoc, "slug" | "title"> & {
29
+ meta?: PageDoc["meta"];
30
+ }>;
31
+ config: SiteConfig;
32
+ };
33
+ export type CheckContext = {
34
+ scopeKey: string;
35
+ page: PageDoc;
36
+ site: SiteView;
37
+ manifest: BlockManifest;
38
+ /** Every field on the page, already flattened. Rules should not re-walk. */
39
+ fields: FieldEntry[];
40
+ };
41
+ /**
42
+ * What a rule returns.
43
+ *
44
+ * Deliberately *not* a `FindingInput`: the fingerprint is computed by the
45
+ * runner from `(scopeKey, slug, ruleId, key)`, so a rule cannot accidentally
46
+ * fold the offending value into it. That mistake is invisible until the day
47
+ * somebody half-fixes a title and gets a second finding instead of an updated
48
+ * one, and the dismissal they made last week stops applying.
49
+ */
50
+ export type RuleFinding = {
51
+ /** Distinguishes several findings from one rule on one page. Stable, not a value. */
52
+ key?: string;
53
+ severity?: FindingSeverity;
54
+ title: string;
55
+ detail?: string;
56
+ evidence?: FindingEvidence;
57
+ proposedOps?: Operation[];
58
+ };
59
+ export type CheckRule = {
60
+ id: string;
61
+ agent: string;
62
+ /** Used when a finding does not override it. */
63
+ severity: FindingSeverity;
64
+ run(ctx: CheckContext): RuleFinding[];
65
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -51,6 +51,7 @@ export interface CmsPublishContext {
51
51
  */
52
52
  export type CmsPublishResult = void | {
53
53
  ok: true;
54
+ written?: boolean;
54
55
  unsupported?: string[];
55
56
  } | {
56
57
  ok: false;
@@ -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)`;