@dsh-enhanced/assistant-evaluation 0.1.65 → 0.1.66
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/README.md +54 -1
- package/lib/attention-owner-questions.d.ts +85 -0
- package/lib/attention-owner-questions.d.ts.map +1 -0
- package/lib/attention-owner-questions.js +142 -0
- package/lib/attention-owner-questions.js.map +1 -0
- package/lib/attention-tuning.d.ts +431 -0
- package/lib/attention-tuning.d.ts.map +1 -0
- package/lib/attention-tuning.js +892 -0
- package/lib/attention-tuning.js.map +1 -0
- package/lib/benchmark/cli.d.ts.map +1 -1
- package/lib/benchmark/cli.js +11 -6
- package/lib/benchmark/cli.js.map +1 -1
- package/lib/benchmark/corpus.d.ts +13 -2
- package/lib/benchmark/corpus.d.ts.map +1 -1
- package/lib/benchmark/corpus.js +19 -9
- package/lib/benchmark/corpus.js.map +1 -1
- package/lib/benchmark/families-corpus.d.ts +11 -0
- package/lib/benchmark/families-corpus.d.ts.map +1 -0
- package/lib/benchmark/families-corpus.js +133 -0
- package/lib/benchmark/families-corpus.js.map +1 -0
- package/lib/benchmark/holdout-isolation.d.ts +27 -0
- package/lib/benchmark/holdout-isolation.d.ts.map +1 -0
- package/lib/benchmark/holdout-isolation.js +66 -0
- package/lib/benchmark/holdout-isolation.js.map +1 -0
- package/lib/benchmark/holdout.d.ts.map +1 -1
- package/lib/benchmark/holdout.js +3 -0
- package/lib/benchmark/holdout.js.map +1 -1
- package/lib/benchmark/index.d.ts +3 -0
- package/lib/benchmark/index.d.ts.map +1 -1
- package/lib/benchmark/index.js +2 -0
- package/lib/benchmark/index.js.map +1 -1
- package/lib/benchmark/native-skills.d.ts +12 -14
- package/lib/benchmark/native-skills.d.ts.map +1 -1
- package/lib/benchmark/native.d.ts +15 -2
- package/lib/benchmark/native.d.ts.map +1 -1
- package/lib/benchmark/native.js +67 -14
- package/lib/benchmark/native.js.map +1 -1
- package/lib/benchmark/report.d.ts.map +1 -1
- package/lib/benchmark/report.js +98 -8
- package/lib/benchmark/report.js.map +1 -1
- package/lib/benchmark/runner.d.ts.map +1 -1
- package/lib/benchmark/runner.js +5 -0
- package/lib/benchmark/runner.js.map +1 -1
- package/lib/benchmark/types.d.ts +64 -7
- package/lib/benchmark/types.d.ts.map +1 -1
- package/lib/index.d.ts +7 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +4 -0
- package/lib/index.js.map +1 -1
- package/lib/intake-attention-agreement.d.ts +63 -0
- package/lib/intake-attention-agreement.d.ts.map +1 -0
- package/lib/intake-attention-agreement.js +146 -0
- package/lib/intake-attention-agreement.js.map +1 -0
- package/lib/notification-outcomes.d.ts +56 -0
- package/lib/notification-outcomes.d.ts.map +1 -0
- package/lib/notification-outcomes.js +348 -0
- package/lib/notification-outcomes.js.map +1 -0
- package/lib/service.d.ts +244 -2
- package/lib/service.d.ts.map +1 -1
- package/lib/service.js +830 -5
- package/lib/service.js.map +1 -1
- package/lib/sqlite.d.ts +8 -2
- package/lib/sqlite.d.ts.map +1 -1
- package/lib/sqlite.js +406 -17
- package/lib/sqlite.js.map +1 -1
- package/lib/store.d.ts +13 -2
- package/lib/store.d.ts.map +1 -1
- package/lib/store.js +120 -24
- package/lib/store.js.map +1 -1
- package/lib/types.d.ts +247 -5
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +11 -0
- package/lib/types.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/lib/work-item-owner-proof.d.ts +4 -0
- package/lib/work-item-owner-proof.d.ts.map +1 -0
- package/lib/work-item-owner-proof.js +53 -0
- package/lib/work-item-owner-proof.js.map +1 -0
- package/package.json +30 -30
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { NotificationOutcome, NotificationOutcomeRecord } from './types.js';
|
|
2
|
+
export declare const INTAKE_ATTENTION_AGREEMENT_PROTOCOL: "assistant-evaluation/intake-attention-agreement/v1";
|
|
3
|
+
/** Notifications with a work item scanned per call; more is reported as `truncated`. */
|
|
4
|
+
export declare const INTAKE_ATTENTION_AGREEMENT_SCAN_LIMIT = 1000;
|
|
5
|
+
/** Structural mirror of WorkItems' `inspectIntakeProvenance`; Evaluation keeps no runtime dependency on WorkItems. */
|
|
6
|
+
export interface IntakeProvenanceSource {
|
|
7
|
+
inspectIntakeProvenance(itemId: string): unknown;
|
|
8
|
+
}
|
|
9
|
+
export declare function isIntakeProvenanceSource(value: unknown): value is IntakeProvenanceSource;
|
|
10
|
+
declare const LEVELS: readonly ["ignore", "digest", "notify", "accept"];
|
|
11
|
+
type IntakeLevel = typeof LEVELS[number];
|
|
12
|
+
export interface IntakeAttentionAgreementCell {
|
|
13
|
+
readonly notifications: number;
|
|
14
|
+
readonly items: number;
|
|
15
|
+
readonly outcomes: Readonly<Record<NotificationOutcome, number>>;
|
|
16
|
+
/** accepted + reacted */
|
|
17
|
+
readonly agreed: number;
|
|
18
|
+
/** rejected + expired-unhandled */
|
|
19
|
+
readonly disagreed: number;
|
|
20
|
+
/** agreed / (agreed + disagreed) in ppm; null without any decided notification. */
|
|
21
|
+
readonly agreementPpm: number | null;
|
|
22
|
+
}
|
|
23
|
+
export interface IntakeAttentionAgreement {
|
|
24
|
+
readonly protocol: typeof INTAKE_ATTENTION_AGREEMENT_PROTOCOL;
|
|
25
|
+
readonly causal: false;
|
|
26
|
+
readonly scope: Readonly<{
|
|
27
|
+
workspace: string;
|
|
28
|
+
preset: string;
|
|
29
|
+
}>;
|
|
30
|
+
readonly fromNotifiedAt: number;
|
|
31
|
+
readonly toNotifiedAt: number;
|
|
32
|
+
/** Current notification heads in the window that name a work item, at most the scan limit. */
|
|
33
|
+
readonly scanned: number;
|
|
34
|
+
readonly truncated: boolean;
|
|
35
|
+
/** No WorkItems peer exposes `inspectIntakeProvenance`: every item is unresolved. */
|
|
36
|
+
readonly provenanceAvailable: boolean;
|
|
37
|
+
/** Notifications whose item was not admitted from intake (explicit decisions, continuations). */
|
|
38
|
+
readonly notIntake: number;
|
|
39
|
+
/** Notifications whose provenance read threw (WorkItems unavailable or intake ledger off) or was malformed. */
|
|
40
|
+
readonly unresolved: number;
|
|
41
|
+
/** Notifications naming a work item of another owner scope; never attributed to this one. */
|
|
42
|
+
readonly foreign: number;
|
|
43
|
+
readonly intake: IntakeAttentionAgreementCell;
|
|
44
|
+
readonly byLevel: Readonly<Partial<Record<IntakeLevel, IntakeAttentionAgreementCell>>>;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Aggregate `records` (current heads, each naming a work item) by the intake
|
|
48
|
+
* judgment behind its item. Provenance is read once per item; a throwing or
|
|
49
|
+
* malformed answer leaves that item unresolved.
|
|
50
|
+
*/
|
|
51
|
+
export declare function computeIntakeAttentionAgreement(input: Readonly<{
|
|
52
|
+
scope: Readonly<{
|
|
53
|
+
workspace: string;
|
|
54
|
+
preset: string;
|
|
55
|
+
}>;
|
|
56
|
+
fromNotifiedAt: number;
|
|
57
|
+
toNotifiedAt: number;
|
|
58
|
+
records: readonly NotificationOutcomeRecord[];
|
|
59
|
+
truncated: boolean;
|
|
60
|
+
source: IntakeProvenanceSource | undefined;
|
|
61
|
+
}>): IntakeAttentionAgreement;
|
|
62
|
+
export {};
|
|
63
|
+
//# sourceMappingURL=intake-attention-agreement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"intake-attention-agreement.d.ts","sourceRoot":"","sources":["../src/intake-attention-agreement.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,mBAAmB,EAAE,yBAAyB,EAAE,MAAM,YAAY,CAAA;AAGhF,eAAO,MAAM,mCAAmC,EAAG,oDAA6D,CAAA;AAChH,wFAAwF;AACxF,eAAO,MAAM,qCAAqC,OAAQ,CAAA;AAE1D,sHAAsH;AACtH,MAAM,WAAW,sBAAsB;IACrC,uBAAuB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAA;CACjD;AAED,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,sBAAsB,CAGxF;AAED,QAAA,MAAM,MAAM,mDAAoD,CAAA;AAChE,KAAK,WAAW,GAAG,OAAO,MAAM,CAAC,MAAM,CAAC,CAAA;AA4BxC,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;IAC9B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAC,MAAM,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC,CAAA;IAChE,yBAAyB;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,mCAAmC;IACnC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI,CAAA;CACrC;AAED,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,QAAQ,EAAE,OAAO,mCAAmC,CAAA;IAC7D,QAAQ,CAAC,MAAM,EAAE,KAAK,CAAA;IACtB,QAAQ,CAAC,KAAK,EAAE,QAAQ,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAC/D,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAA;IAC7B,8FAA8F;IAC9F,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,SAAS,EAAE,OAAO,CAAA;IAC3B,qFAAqF;IACrF,QAAQ,CAAC,mBAAmB,EAAE,OAAO,CAAA;IACrC,iGAAiG;IACjG,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;IAC1B,+GAA+G;IAC/G,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAA;IAC3B,6FAA6F;IAC7F,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,4BAA4B,CAAA;IAC7C,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,MAAM,CAAC,WAAW,EAAE,4BAA4B,CAAC,CAAC,CAAC,CAAA;CACvF;AA6BD;;;;GAIG;AACH,wBAAgB,+BAA+B,CAAC,KAAK,EAAE,QAAQ,CAAC;IAC9D,KAAK,EAAE,QAAQ,CAAC;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACtD,cAAc,EAAE,MAAM,CAAA;IACtB,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,EAAE,SAAS,yBAAyB,EAAE,CAAA;IAC7C,SAAS,EAAE,OAAO,CAAA;IAClB,MAAM,EAAE,sBAAsB,GAAG,SAAS,CAAA;CAC3C,CAAC,GAAG,wBAAwB,CA8C5B"}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WP22: read-only agreement between WP20 intake judgments and the attention
|
|
3
|
+
* outcome ledger.
|
|
4
|
+
*
|
|
5
|
+
* The only join that exists is intake admission → work item → result
|
|
6
|
+
* notification (`workItemId` on the outcome record) → owner outcome. An
|
|
7
|
+
* observe-mode `would-admit` never creates an item, so it never reaches a
|
|
8
|
+
* notification and cannot be measured here; only auto-admitted items are.
|
|
9
|
+
* Nothing is written, adopted or sent; the result is observational.
|
|
10
|
+
*/
|
|
11
|
+
import { canonicalEvaluationScope } from './store.js';
|
|
12
|
+
import { notificationOutcomes } from './types.js';
|
|
13
|
+
export const INTAKE_ATTENTION_AGREEMENT_PROTOCOL = 'assistant-evaluation/intake-attention-agreement/v1';
|
|
14
|
+
/** Notifications with a work item scanned per call; more is reported as `truncated`. */
|
|
15
|
+
export const INTAKE_ATTENTION_AGREEMENT_SCAN_LIMIT = 1_000;
|
|
16
|
+
export function isIntakeProvenanceSource(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null
|
|
18
|
+
&& typeof value.inspectIntakeProvenance === 'function';
|
|
19
|
+
}
|
|
20
|
+
const LEVELS = ['ignore', 'digest', 'notify', 'accept'];
|
|
21
|
+
/**
|
|
22
|
+
* Validate one peer answer; anything malformed, or an item of another owner
|
|
23
|
+
* scope, counts as unresolved, never as a match. A notification sender may
|
|
24
|
+
* name any work item id, so the item's own scope must equal the metric's.
|
|
25
|
+
*/
|
|
26
|
+
function provenance(value, itemId, scopeKey) {
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
return null;
|
|
29
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
30
|
+
return 'invalid';
|
|
31
|
+
const item = value;
|
|
32
|
+
if (item.protocol !== 'work-items/intake-provenance/v1' || item.itemId !== itemId || item.wouldAccept !== true
|
|
33
|
+
|| !LEVELS.includes(item.level) || (item.mode !== 'observe' && item.mode !== 'auto'))
|
|
34
|
+
return 'invalid';
|
|
35
|
+
const scope = item.scope;
|
|
36
|
+
let key;
|
|
37
|
+
try {
|
|
38
|
+
key = canonicalEvaluationScope({ workspace: scope?.workspace, preset: scope?.preset }).scopeKey;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return 'invalid';
|
|
42
|
+
}
|
|
43
|
+
if (key !== scopeKey)
|
|
44
|
+
return 'foreign';
|
|
45
|
+
return { itemId, level: item.level, mode: item.mode };
|
|
46
|
+
}
|
|
47
|
+
/** Owner outcomes that confirm the intake judgment, that contradict it, and that decide nothing yet. */
|
|
48
|
+
const POSITIVE = new Set(['accepted', 'reacted']);
|
|
49
|
+
const NEGATIVE = new Set(['rejected', 'expired-unhandled']);
|
|
50
|
+
class Cell {
|
|
51
|
+
notifications = 0;
|
|
52
|
+
items = new Set();
|
|
53
|
+
outcomes = Object.fromEntries(notificationOutcomes.map(outcome => [outcome, 0]));
|
|
54
|
+
add(itemId, outcome) {
|
|
55
|
+
this.notifications += 1;
|
|
56
|
+
this.items.add(itemId);
|
|
57
|
+
this.outcomes[outcome] += 1;
|
|
58
|
+
}
|
|
59
|
+
freeze() {
|
|
60
|
+
let agreed = 0, disagreed = 0;
|
|
61
|
+
for (const outcome of notificationOutcomes) {
|
|
62
|
+
if (POSITIVE.has(outcome))
|
|
63
|
+
agreed += this.outcomes[outcome];
|
|
64
|
+
if (NEGATIVE.has(outcome))
|
|
65
|
+
disagreed += this.outcomes[outcome];
|
|
66
|
+
}
|
|
67
|
+
const decided = agreed + disagreed;
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
notifications: this.notifications,
|
|
70
|
+
items: this.items.size,
|
|
71
|
+
outcomes: Object.freeze({ ...this.outcomes }),
|
|
72
|
+
agreed,
|
|
73
|
+
disagreed,
|
|
74
|
+
agreementPpm: decided === 0 ? null : Math.round(agreed * 1_000_000 / decided),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Aggregate `records` (current heads, each naming a work item) by the intake
|
|
80
|
+
* judgment behind its item. Provenance is read once per item; a throwing or
|
|
81
|
+
* malformed answer leaves that item unresolved.
|
|
82
|
+
*/
|
|
83
|
+
export function computeIntakeAttentionAgreement(input) {
|
|
84
|
+
const { scopeKey } = canonicalEvaluationScope(input.scope);
|
|
85
|
+
const resolved = new Map();
|
|
86
|
+
const resolve = (itemId) => {
|
|
87
|
+
if (!resolved.has(itemId)) {
|
|
88
|
+
let value = 'invalid';
|
|
89
|
+
if (input.source !== undefined) {
|
|
90
|
+
try {
|
|
91
|
+
value = provenance(input.source.inspectIntakeProvenance(itemId), itemId, scopeKey);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
value = 'invalid';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
resolved.set(itemId, value);
|
|
98
|
+
}
|
|
99
|
+
return resolved.get(itemId);
|
|
100
|
+
};
|
|
101
|
+
const intake = new Cell();
|
|
102
|
+
const byLevel = new Map();
|
|
103
|
+
let notIntake = 0, unresolved = 0, foreign = 0;
|
|
104
|
+
for (const record of input.records) {
|
|
105
|
+
const itemId = record.workItemId;
|
|
106
|
+
if (itemId === undefined)
|
|
107
|
+
continue;
|
|
108
|
+
const found = resolve(itemId);
|
|
109
|
+
if (found === 'invalid') {
|
|
110
|
+
unresolved += 1;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (found === 'foreign') {
|
|
114
|
+
foreign += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (found === null) {
|
|
118
|
+
notIntake += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
intake.add(itemId, record.outcome);
|
|
122
|
+
let cell = byLevel.get(found.level);
|
|
123
|
+
if (cell === undefined)
|
|
124
|
+
byLevel.set(found.level, cell = new Cell());
|
|
125
|
+
cell.add(itemId, record.outcome);
|
|
126
|
+
}
|
|
127
|
+
return Object.freeze({
|
|
128
|
+
protocol: INTAKE_ATTENTION_AGREEMENT_PROTOCOL,
|
|
129
|
+
causal: false,
|
|
130
|
+
scope: Object.freeze({ workspace: input.scope.workspace, preset: input.scope.preset }),
|
|
131
|
+
fromNotifiedAt: input.fromNotifiedAt,
|
|
132
|
+
toNotifiedAt: input.toNotifiedAt,
|
|
133
|
+
scanned: input.records.length,
|
|
134
|
+
truncated: input.truncated,
|
|
135
|
+
provenanceAvailable: input.source !== undefined,
|
|
136
|
+
notIntake,
|
|
137
|
+
unresolved,
|
|
138
|
+
foreign,
|
|
139
|
+
intake: intake.freeze(),
|
|
140
|
+
byLevel: Object.freeze(Object.fromEntries(LEVELS.flatMap(level => {
|
|
141
|
+
const cell = byLevel.get(level);
|
|
142
|
+
return cell === undefined ? [] : [[level, cell.freeze()]];
|
|
143
|
+
}))),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=intake-attention-agreement.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"intake-attention-agreement.js","sourceRoot":"","sources":["../src/intake-attention-agreement.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { type EvaluationScope, type NotificationOutcomeFactInput, type NotificationOutcomeFeedPage, type NotificationOutcomeHistory, type NotificationOutcomeRecord, type NotificationOutcomeSummary, type StoredNotificationOutcome } from './types.js';
|
|
3
|
+
/** Strict shape check: Delivery facts are trusted Host data, but never trusted to be well-formed. */
|
|
4
|
+
export declare function normalizeNotificationOutcomeFact(input: unknown): NotificationOutcomeFactInput;
|
|
5
|
+
/**
|
|
6
|
+
* Deterministic `notification-outcome/v1` classifier. Precedence: an owner
|
|
7
|
+
* judgement (rejected, accepted) beats a reply (reacted), which beats transport
|
|
8
|
+
* state. A withdrawn judgement (`unknown`) contributes nothing. Expiry depends
|
|
9
|
+
* only on `now`, the earliest sent timestamp and the configured window. An
|
|
10
|
+
* optional linked route is carried verbatim and never affects the outcome, so
|
|
11
|
+
* the classifier stays at version 1 and route-free records keep their digests.
|
|
12
|
+
*/
|
|
13
|
+
export declare function classifyNotificationOutcome(fact: NotificationOutcomeFactInput, now: number, responseWindowMs: number): NotificationOutcomeRecord;
|
|
14
|
+
export declare function notificationOutcomeDigest(record: NotificationOutcomeRecord): string;
|
|
15
|
+
/**
|
|
16
|
+
* Evaluation-owned revisable ledger keyed by notification (Delivery Outbox id).
|
|
17
|
+
* Each changed classification appends an immutable revision and advances the
|
|
18
|
+
* scope's notification watermark in one writer transaction; an unchanged
|
|
19
|
+
* digest only refreshes the check time. Nothing here touches task projections,
|
|
20
|
+
* the Evolution outbox or any parameter.
|
|
21
|
+
*/
|
|
22
|
+
export declare class NotificationOutcomeLedger {
|
|
23
|
+
#private;
|
|
24
|
+
constructor(database: DatabaseSync, now: () => number);
|
|
25
|
+
/** Apply one fact; returns whether a new revision was appended. */
|
|
26
|
+
apply(factInput: unknown, responseWindowMs: number): Readonly<{
|
|
27
|
+
revised: boolean;
|
|
28
|
+
head: StoredNotificationOutcome;
|
|
29
|
+
}>;
|
|
30
|
+
/** Mark a head as checked without a fact (e.g. Delivery no longer reports it). */
|
|
31
|
+
touch(notificationId: string): void;
|
|
32
|
+
syncCursor(): number;
|
|
33
|
+
advanceSyncCursor(afterSequence: number): void;
|
|
34
|
+
/** Heads notified at or after `notifiedSince`, least recently checked first. */
|
|
35
|
+
dueForRefresh(notifiedSince: number, limit: number): readonly string[];
|
|
36
|
+
history(scopeInput: EvaluationScope, notificationId: string): NotificationOutcomeHistory | undefined;
|
|
37
|
+
feed(scopeInput: EvaluationScope, afterInput: Readonly<{
|
|
38
|
+
scopeKey: string;
|
|
39
|
+
watermark: number;
|
|
40
|
+
}> | undefined, limit: number): NotificationOutcomeFeedPage;
|
|
41
|
+
summary(scopeInput: EvaluationScope, fromNotifiedAt: number, toNotifiedAt: number): NotificationOutcomeSummary;
|
|
42
|
+
/**
|
|
43
|
+
* Current heads in `[from, to)` notification time whose record names a work
|
|
44
|
+
* item, oldest first, at most `limit` (plus whether more exist). Read-only;
|
|
45
|
+
* a scan of the scope's heads in the window (no index on the work item).
|
|
46
|
+
*/
|
|
47
|
+
workItemHeads(scopeInput: EvaluationScope, fromNotifiedAt: number, toNotifiedAt: number, limit: number): Readonly<{
|
|
48
|
+
records: readonly NotificationOutcomeRecord[];
|
|
49
|
+
truncated: boolean;
|
|
50
|
+
}>;
|
|
51
|
+
counts(): Readonly<{
|
|
52
|
+
notifications: number;
|
|
53
|
+
revisions: number;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=notification-outcomes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"notification-outcomes.d.ts","sourceRoot":"","sources":["../src/notification-outcomes.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,OAAO,EAIL,KAAK,eAAe,EAEpB,KAAK,4BAA4B,EACjC,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,EAC/B,KAAK,yBAAyB,EAC9B,KAAK,0BAA0B,EAC/B,KAAK,yBAAyB,EAC/B,MAAM,YAAY,CAAA;AA0CnB,qGAAqG;AACrG,wBAAgB,gCAAgC,CAAC,KAAK,EAAE,OAAO,GAAG,4BAA4B,CAkD7F;AAED;;;;;;;GAOG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,4BAA4B,EAAE,GAAG,EAAE,MAAM,EAAE,gBAAgB,EAAE,MAAM,GAAG,yBAAyB,CA0ChJ;AAUD,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,yBAAyB,GAAG,MAAM,CAEnF;AAyBD;;;;;;GAMG;AACH,qBAAa,yBAAyB;;gBAIxB,QAAQ,EAAE,YAAY,EAAE,GAAG,EAAE,MAAM,MAAM;IAiBrD,mEAAmE;IACnE,KAAK,CAAC,SAAS,EAAE,OAAO,EAAE,gBAAgB,EAAE,MAAM,GAAG,QAAQ,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,IAAI,EAAE,yBAAyB,CAAA;KAAE,CAAC;IAqCpH,kFAAkF;IAClF,KAAK,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI;IAKnC,UAAU,IAAI,MAAM;IAKpB,iBAAiB,CAAC,aAAa,EAAE,MAAM,GAAG,IAAI;IAM9C,gFAAgF;IAChF,aAAa,CAAC,aAAa,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE;IAYtE,OAAO,CAAC,UAAU,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,GAAG,0BAA0B,GAAG,SAAS;IASpG,IAAI,CAAC,UAAU,EAAE,eAAe,EAAE,UAAU,EAAE,QAAQ,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC,GAAG,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,2BAA2B;IA2BxJ,OAAO,CAAC,UAAU,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,0BAA0B;IAkB9G;;;;OAIG;IACH,aAAa,CAAC,UAAU,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GACpG,QAAQ,CAAC;QAAE,OAAO,EAAE,SAAS,yBAAyB,EAAE,CAAC;QAAC,SAAS,EAAE,OAAO,CAAA;KAAE,CAAC;IAWjF,MAAM,IAAI,QAAQ,CAAC;QAAE,aAAa,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CAKjE"}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { canonicalEvaluationScope, EvaluationStoreError } from './store.js';
|
|
3
|
+
import { NOTIFICATION_OUTCOME_RECORD_PROTOCOL, notificationOutcomes, objectiveStatuses, } from './types.js';
|
|
4
|
+
const purposes = ['result', 'attention', 'answer'];
|
|
5
|
+
const purposeSources = ['declared', 'lane', 'default'];
|
|
6
|
+
const verdicts = ['would-accept', 'would-attach', 'would-reject'];
|
|
7
|
+
const transportStatuses = ['pending', 'attempting', 'accepted', 'delivered', 'read', 'retry_wait', 'dead', 'unknown_after_send'];
|
|
8
|
+
const judgementSources = ['owner-objective', 'work-item-feedback'];
|
|
9
|
+
function invalid(message) {
|
|
10
|
+
throw new EvaluationStoreError('invalid-input', `notification outcome fact: ${message}`);
|
|
11
|
+
}
|
|
12
|
+
function plain(value, label) {
|
|
13
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
14
|
+
invalid(`${label} must be an object`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function text(value, label, max = 256) {
|
|
18
|
+
if (typeof value !== 'string' || value.length < 1 || value.length > max || value.trim() !== value || /\p{Cc}/u.test(value)) {
|
|
19
|
+
invalid(`${label} must be a bounded identifier`);
|
|
20
|
+
}
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function optionalText(value, label) {
|
|
24
|
+
return value === undefined ? undefined : text(value, label);
|
|
25
|
+
}
|
|
26
|
+
function time(value, label) {
|
|
27
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
28
|
+
invalid(`${label} must be a non-negative safe integer`);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
function optionalTime(value, label) {
|
|
32
|
+
return value === undefined ? undefined : time(value, label);
|
|
33
|
+
}
|
|
34
|
+
function member(value, allowed, label) {
|
|
35
|
+
if (typeof value !== 'string' || !allowed.includes(value))
|
|
36
|
+
invalid(`${label} is not recognised`);
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
/** Strict shape check: Delivery facts are trusted Host data, but never trusted to be well-formed. */
|
|
40
|
+
export function normalizeNotificationOutcomeFact(input) {
|
|
41
|
+
const fact = plain(input, 'fact');
|
|
42
|
+
const transport = plain(fact.transport, 'transport');
|
|
43
|
+
const reasons = fact.reasons;
|
|
44
|
+
if (!Array.isArray(reasons) || reasons.length > 32)
|
|
45
|
+
invalid('reasons must be a bounded array');
|
|
46
|
+
const scope = plain(fact.scope, 'scope');
|
|
47
|
+
const reaction = fact.reaction === undefined ? undefined : plain(fact.reaction, 'reaction');
|
|
48
|
+
const judgement = fact.judgement === undefined ? undefined : plain(fact.judgement, 'judgement');
|
|
49
|
+
if (judgement !== undefined && (!Number.isSafeInteger(judgement.version) || judgement.version < 1)) {
|
|
50
|
+
invalid('judgement.version must be a positive integer');
|
|
51
|
+
}
|
|
52
|
+
const route = fact.route === undefined ? undefined : plain(fact.route, 'route');
|
|
53
|
+
if (route !== undefined && (Object.keys(route).length !== 2 || route.kind !== 'linked'))
|
|
54
|
+
invalid('route must be a linked route');
|
|
55
|
+
const originBindingId = route === undefined ? undefined : text(route.originBindingId, 'route.originBindingId');
|
|
56
|
+
if (originBindingId !== undefined && originBindingId === fact.bindingId)
|
|
57
|
+
invalid('route.originBindingId must differ from bindingId');
|
|
58
|
+
const workItemId = optionalText(fact.workItemId, 'workItemId');
|
|
59
|
+
const questionId = optionalText(fact.questionId, 'questionId');
|
|
60
|
+
const answersQuestionId = optionalText(fact.answersQuestionId, 'answersQuestionId');
|
|
61
|
+
const acceptedAt = optionalTime(transport.acceptedAt, 'transport.acceptedAt');
|
|
62
|
+
const deliveredAt = optionalTime(transport.deliveredAt, 'transport.deliveredAt');
|
|
63
|
+
const readAt = optionalTime(transport.readAt, 'transport.readAt');
|
|
64
|
+
return {
|
|
65
|
+
auditSequence: time(fact.auditSequence, 'auditSequence'),
|
|
66
|
+
outboxId: text(fact.outboxId, 'outboxId'),
|
|
67
|
+
bindingId: text(fact.bindingId, 'bindingId'),
|
|
68
|
+
scope: canonicalEvaluationScope({ workspace: scope.workspace, preset: scope.preset }).scope,
|
|
69
|
+
lane: text(fact.lane, 'lane', 64),
|
|
70
|
+
sourceId: text(fact.sourceId, 'sourceId'),
|
|
71
|
+
purpose: member(fact.purpose, purposes, 'purpose'),
|
|
72
|
+
purposeSource: member(fact.purposeSource, purposeSources, 'purposeSource'),
|
|
73
|
+
...(workItemId === undefined ? {} : { workItemId }),
|
|
74
|
+
...(questionId === undefined ? {} : { questionId }),
|
|
75
|
+
...(answersQuestionId === undefined ? {} : { answersQuestionId }),
|
|
76
|
+
verdict: member(fact.verdict, verdicts, 'verdict'),
|
|
77
|
+
reasons: reasons.map((reason, index) => text(reason, `reasons[${index}]`, 64)),
|
|
78
|
+
auditedAt: time(fact.auditedAt, 'auditedAt'),
|
|
79
|
+
transport: {
|
|
80
|
+
status: member(transport.status, transportStatuses, 'transport.status'),
|
|
81
|
+
...(acceptedAt === undefined ? {} : { acceptedAt }),
|
|
82
|
+
...(deliveredAt === undefined ? {} : { deliveredAt }),
|
|
83
|
+
...(readAt === undefined ? {} : { readAt }),
|
|
84
|
+
},
|
|
85
|
+
...(reaction === undefined ? {} : { reaction: { firstAt: time(reaction.firstAt, 'reaction.firstAt') } }),
|
|
86
|
+
...(judgement === undefined ? {} : { judgement: {
|
|
87
|
+
source: member(judgement.source, judgementSources, 'judgement.source'),
|
|
88
|
+
version: judgement.version,
|
|
89
|
+
objectiveStatus: member(judgement.objectiveStatus, objectiveStatuses, 'judgement.objectiveStatus'),
|
|
90
|
+
} }),
|
|
91
|
+
...(originBindingId === undefined ? {} : { route: { kind: 'linked', originBindingId } }),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Deterministic `notification-outcome/v1` classifier. Precedence: an owner
|
|
96
|
+
* judgement (rejected, accepted) beats a reply (reacted), which beats transport
|
|
97
|
+
* state. A withdrawn judgement (`unknown`) contributes nothing. Expiry depends
|
|
98
|
+
* only on `now`, the earliest sent timestamp and the configured window. An
|
|
99
|
+
* optional linked route is carried verbatim and never affects the outcome, so
|
|
100
|
+
* the classifier stays at version 1 and route-free records keep their digests.
|
|
101
|
+
*/
|
|
102
|
+
export function classifyNotificationOutcome(fact, now, responseWindowMs) {
|
|
103
|
+
const judgement = fact.judgement !== undefined && fact.judgement.objectiveStatus !== 'unknown'
|
|
104
|
+
? { source: fact.judgement.source, version: fact.judgement.version, objectiveStatus: fact.judgement.objectiveStatus }
|
|
105
|
+
: undefined;
|
|
106
|
+
const sentTimes = [fact.transport.acceptedAt, fact.transport.deliveredAt, fact.transport.readAt].filter(value => value !== undefined);
|
|
107
|
+
const sentAt = sentTimes.length === 0 ? undefined : Math.min(...sentTimes);
|
|
108
|
+
let outcome;
|
|
109
|
+
let expiry;
|
|
110
|
+
if (judgement?.objectiveStatus === 'not-achieved')
|
|
111
|
+
outcome = 'rejected';
|
|
112
|
+
else if (judgement?.objectiveStatus === 'achieved')
|
|
113
|
+
outcome = 'accepted';
|
|
114
|
+
else if (fact.reaction !== undefined || judgement?.objectiveStatus === 'partial')
|
|
115
|
+
outcome = 'reacted';
|
|
116
|
+
else if (fact.transport.status === 'dead')
|
|
117
|
+
outcome = 'failed';
|
|
118
|
+
else if (sentAt !== undefined) {
|
|
119
|
+
if (now >= sentAt + responseWindowMs) {
|
|
120
|
+
outcome = 'expired-unhandled';
|
|
121
|
+
expiry = { at: sentAt + responseWindowMs, responseWindowMs };
|
|
122
|
+
}
|
|
123
|
+
else
|
|
124
|
+
outcome = 'delivered';
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
outcome = 'pending';
|
|
128
|
+
return {
|
|
129
|
+
protocol: NOTIFICATION_OUTCOME_RECORD_PROTOCOL,
|
|
130
|
+
classifier: { id: 'notification-outcome', version: 1 },
|
|
131
|
+
notificationId: fact.outboxId,
|
|
132
|
+
scope: { workspace: fact.scope.workspace, preset: fact.scope.preset },
|
|
133
|
+
bindingId: fact.bindingId,
|
|
134
|
+
lane: fact.lane,
|
|
135
|
+
sourceId: fact.sourceId,
|
|
136
|
+
purpose: fact.purpose,
|
|
137
|
+
purposeSource: fact.purposeSource,
|
|
138
|
+
...(fact.workItemId === undefined ? {} : { workItemId: fact.workItemId }),
|
|
139
|
+
...(fact.questionId === undefined ? {} : { questionId: fact.questionId }),
|
|
140
|
+
...(fact.answersQuestionId === undefined ? {} : { answersQuestionId: fact.answersQuestionId }),
|
|
141
|
+
verdict: fact.verdict,
|
|
142
|
+
reasons: [...fact.reasons],
|
|
143
|
+
notifiedAt: fact.auditedAt,
|
|
144
|
+
outcome,
|
|
145
|
+
transport: { ...fact.transport },
|
|
146
|
+
...(fact.reaction === undefined ? {} : { reaction: { firstAt: fact.reaction.firstAt } }),
|
|
147
|
+
...(judgement === undefined ? {} : { judgement }),
|
|
148
|
+
...(fact.route === undefined ? {} : { route: { kind: fact.route.kind, originBindingId: fact.route.originBindingId } }),
|
|
149
|
+
...(expiry === undefined ? {} : { expiry }),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function stable(value) {
|
|
153
|
+
if (value === null || typeof value !== 'object')
|
|
154
|
+
return JSON.stringify(value);
|
|
155
|
+
if (Array.isArray(value))
|
|
156
|
+
return `[${value.map(stable).join(',')}]`;
|
|
157
|
+
const record = value;
|
|
158
|
+
return `{${Object.keys(record).filter(key => record[key] !== undefined).sort()
|
|
159
|
+
.map(key => `${JSON.stringify(key)}:${stable(record[key])}`).join(',')}}`;
|
|
160
|
+
}
|
|
161
|
+
export function notificationOutcomeDigest(record) {
|
|
162
|
+
return createHash('sha256').update(stable(record)).digest('hex');
|
|
163
|
+
}
|
|
164
|
+
function deepFreeze(value) {
|
|
165
|
+
if (value !== null && typeof value === 'object') {
|
|
166
|
+
for (const entry of Object.values(value))
|
|
167
|
+
deepFreeze(entry);
|
|
168
|
+
Object.freeze(value);
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
}
|
|
172
|
+
function stored(row) {
|
|
173
|
+
return deepFreeze({
|
|
174
|
+
notificationId: row.notification_id, scopeKey: row.scope_key, version: row.version, digest: row.digest,
|
|
175
|
+
watermark: row.watermark, recordedAt: row.recorded_at, record: JSON.parse(row.record_json),
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function emptyCounts() {
|
|
179
|
+
return Object.fromEntries(notificationOutcomes.map(outcome => [outcome, 0]));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Evaluation-owned revisable ledger keyed by notification (Delivery Outbox id).
|
|
183
|
+
* Each changed classification appends an immutable revision and advances the
|
|
184
|
+
* scope's notification watermark in one writer transaction; an unchanged
|
|
185
|
+
* digest only refreshes the check time. Nothing here touches task projections,
|
|
186
|
+
* the Evolution outbox or any parameter.
|
|
187
|
+
*/
|
|
188
|
+
export class NotificationOutcomeLedger {
|
|
189
|
+
#database;
|
|
190
|
+
#now;
|
|
191
|
+
constructor(database, now) {
|
|
192
|
+
this.#database = database;
|
|
193
|
+
this.#now = now;
|
|
194
|
+
}
|
|
195
|
+
#transaction(fn) {
|
|
196
|
+
this.#database.exec('BEGIN IMMEDIATE');
|
|
197
|
+
try {
|
|
198
|
+
const value = fn();
|
|
199
|
+
this.#database.exec('COMMIT');
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
this.#database.exec('ROLLBACK');
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/** Apply one fact; returns whether a new revision was appended. */
|
|
208
|
+
apply(factInput, responseWindowMs) {
|
|
209
|
+
const fact = normalizeNotificationOutcomeFact(factInput);
|
|
210
|
+
const { scopeKey } = canonicalEvaluationScope(fact.scope);
|
|
211
|
+
return this.#transaction(() => {
|
|
212
|
+
const now = this.#now();
|
|
213
|
+
const record = classifyNotificationOutcome(fact, now, responseWindowMs);
|
|
214
|
+
const digest = notificationOutcomeDigest(record);
|
|
215
|
+
const head = this.#database.prepare('SELECT scope_key, version, digest FROM evaluation_notification_outcome_heads WHERE notification_id=?')
|
|
216
|
+
.get(fact.outboxId);
|
|
217
|
+
if (head !== undefined && head.scope_key !== scopeKey) {
|
|
218
|
+
throw new EvaluationStoreError('version-conflict', 'notification outcome scope changed');
|
|
219
|
+
}
|
|
220
|
+
if (head?.digest === digest) {
|
|
221
|
+
this.#database.prepare('UPDATE evaluation_notification_outcome_heads SET last_checked_at=? WHERE notification_id=?').run(now, fact.outboxId);
|
|
222
|
+
return { revised: false, head: this.#get(fact.outboxId, head.version) };
|
|
223
|
+
}
|
|
224
|
+
const version = (head?.version ?? 0) + 1;
|
|
225
|
+
this.#database.prepare(`INSERT INTO evaluation_notification_watermarks(scope_key, watermark) VALUES (?, 1)
|
|
226
|
+
ON CONFLICT(scope_key) DO UPDATE SET watermark = watermark + 1`).run(scopeKey);
|
|
227
|
+
const watermark = this.#database.prepare('SELECT watermark FROM evaluation_notification_watermarks WHERE scope_key=?')
|
|
228
|
+
.get(scopeKey).watermark;
|
|
229
|
+
this.#database.prepare(`INSERT INTO evaluation_notification_outcome_revisions
|
|
230
|
+
(notification_id, version, scope_key, outcome, record_json, digest, watermark, recorded_at) VALUES (?,?,?,?,?,?,?,?)`)
|
|
231
|
+
.run(fact.outboxId, version, scopeKey, record.outcome, stable(record), digest, watermark, now);
|
|
232
|
+
if (head === undefined) {
|
|
233
|
+
this.#database.prepare(`INSERT INTO evaluation_notification_outcome_heads
|
|
234
|
+
(notification_id, scope_key, version, digest, outcome, occurred_at, watermark, last_checked_at) VALUES (?,?,?,?,?,?,?,?)`)
|
|
235
|
+
.run(fact.outboxId, scopeKey, version, digest, record.outcome, record.notifiedAt, watermark, now);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
this.#database.prepare(`UPDATE evaluation_notification_outcome_heads
|
|
239
|
+
SET version=?, digest=?, outcome=?, watermark=?, last_checked_at=? WHERE notification_id=?`)
|
|
240
|
+
.run(version, digest, record.outcome, watermark, now, fact.outboxId);
|
|
241
|
+
}
|
|
242
|
+
return { revised: true, head: this.#get(fact.outboxId, version) };
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/** Mark a head as checked without a fact (e.g. Delivery no longer reports it). */
|
|
246
|
+
touch(notificationId) {
|
|
247
|
+
this.#database.prepare('UPDATE evaluation_notification_outcome_heads SET last_checked_at=? WHERE notification_id=?')
|
|
248
|
+
.run(this.#now(), notificationId);
|
|
249
|
+
}
|
|
250
|
+
syncCursor() {
|
|
251
|
+
return this.#database.prepare('SELECT after_sequence FROM evaluation_notification_sync_cursor WHERE id=1')
|
|
252
|
+
.get()?.after_sequence ?? 0;
|
|
253
|
+
}
|
|
254
|
+
advanceSyncCursor(afterSequence) {
|
|
255
|
+
this.#database.prepare(`INSERT INTO evaluation_notification_sync_cursor(id, after_sequence, updated_at) VALUES (1, ?, ?)
|
|
256
|
+
ON CONFLICT(id) DO UPDATE SET after_sequence=max(after_sequence, excluded.after_sequence), updated_at=excluded.updated_at`)
|
|
257
|
+
.run(afterSequence, this.#now());
|
|
258
|
+
}
|
|
259
|
+
/** Heads notified at or after `notifiedSince`, least recently checked first. */
|
|
260
|
+
dueForRefresh(notifiedSince, limit) {
|
|
261
|
+
return this.#database.prepare(`SELECT notification_id FROM evaluation_notification_outcome_heads
|
|
262
|
+
WHERE occurred_at >= ? ORDER BY last_checked_at, notification_id LIMIT ?`).all(notifiedSince, limit)
|
|
263
|
+
.map(row => row.notification_id);
|
|
264
|
+
}
|
|
265
|
+
#get(notificationId, version) {
|
|
266
|
+
const row = this.#database.prepare('SELECT * FROM evaluation_notification_outcome_revisions WHERE notification_id=? AND version=?')
|
|
267
|
+
.get(notificationId, version);
|
|
268
|
+
return row === undefined ? undefined : stored(row);
|
|
269
|
+
}
|
|
270
|
+
history(scopeInput, notificationId) {
|
|
271
|
+
const { scopeKey } = canonicalEvaluationScope(scopeInput);
|
|
272
|
+
const rows = this.#database.prepare(`SELECT r.* FROM evaluation_notification_outcome_revisions r
|
|
273
|
+
WHERE r.notification_id=? AND r.scope_key=? ORDER BY r.version`).all(notificationId, scopeKey);
|
|
274
|
+
if (rows.length === 0)
|
|
275
|
+
return undefined;
|
|
276
|
+
const revisions = rows.map(stored);
|
|
277
|
+
return Object.freeze({ head: revisions.at(-1), revisions: Object.freeze(revisions) });
|
|
278
|
+
}
|
|
279
|
+
feed(scopeInput, afterInput, limit) {
|
|
280
|
+
const { scopeKey } = canonicalEvaluationScope(scopeInput);
|
|
281
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
|
|
282
|
+
invalid('feed limit must be between 1 and 100');
|
|
283
|
+
if (afterInput !== undefined && (afterInput === null || typeof afterInput !== 'object' || Array.isArray(afterInput)
|
|
284
|
+
|| Object.keys(afterInput).length !== 2 || afterInput.scopeKey !== scopeKey
|
|
285
|
+
|| !Number.isSafeInteger(afterInput.watermark) || afterInput.watermark < 0))
|
|
286
|
+
invalid('feed cursor is invalid');
|
|
287
|
+
const after = afterInput?.watermark ?? 0;
|
|
288
|
+
this.#database.exec('BEGIN');
|
|
289
|
+
try {
|
|
290
|
+
const scopeWatermark = this.#database.prepare('SELECT watermark FROM evaluation_notification_watermarks WHERE scope_key=?')
|
|
291
|
+
.get(scopeKey)?.watermark ?? 0;
|
|
292
|
+
if (after > scopeWatermark)
|
|
293
|
+
invalid('feed cursor is ahead of scope');
|
|
294
|
+
const rows = this.#database.prepare(`SELECT r.* FROM evaluation_notification_outcome_heads h
|
|
295
|
+
JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
|
|
296
|
+
WHERE h.scope_key=? AND h.watermark>? ORDER BY h.watermark LIMIT ?`).all(scopeKey, after, limit + 1);
|
|
297
|
+
const items = rows.slice(0, limit).map(stored);
|
|
298
|
+
return Object.freeze({
|
|
299
|
+
items: Object.freeze(items),
|
|
300
|
+
nextCursor: Object.freeze({ scopeKey, watermark: items.at(-1)?.watermark ?? after }),
|
|
301
|
+
scopeWatermark,
|
|
302
|
+
hasMore: rows.length > limit,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
this.#database.exec('COMMIT');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
summary(scopeInput, fromNotifiedAt, toNotifiedAt) {
|
|
310
|
+
const { scope, scopeKey } = canonicalEvaluationScope(scopeInput);
|
|
311
|
+
const rows = this.#database.prepare(`SELECT h.outcome AS outcome, json_extract(r.record_json, '$.verdict') AS verdict, COUNT(*) AS count
|
|
312
|
+
FROM evaluation_notification_outcome_heads h
|
|
313
|
+
JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
|
|
314
|
+
WHERE h.scope_key=? AND h.occurred_at>=? AND h.occurred_at<? GROUP BY h.outcome, verdict`)
|
|
315
|
+
.all(scopeKey, fromNotifiedAt, toNotifiedAt);
|
|
316
|
+
const outcomes = emptyCounts();
|
|
317
|
+
const byVerdict = Object.fromEntries(verdicts.map(verdict => [verdict, emptyCounts()]));
|
|
318
|
+
let total = 0;
|
|
319
|
+
for (const row of rows) {
|
|
320
|
+
outcomes[row.outcome] += row.count;
|
|
321
|
+
byVerdict[row.verdict][row.outcome] += row.count;
|
|
322
|
+
total += row.count;
|
|
323
|
+
}
|
|
324
|
+
return deepFreeze({ scope, scopeKey, fromNotifiedAt, toNotifiedAt, total, outcomes, byVerdict });
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Current heads in `[from, to)` notification time whose record names a work
|
|
328
|
+
* item, oldest first, at most `limit` (plus whether more exist). Read-only;
|
|
329
|
+
* a scan of the scope's heads in the window (no index on the work item).
|
|
330
|
+
*/
|
|
331
|
+
workItemHeads(scopeInput, fromNotifiedAt, toNotifiedAt, limit) {
|
|
332
|
+
const { scopeKey } = canonicalEvaluationScope(scopeInput);
|
|
333
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10_000)
|
|
334
|
+
invalid('work item head limit is invalid');
|
|
335
|
+
const rows = this.#database.prepare(`SELECT r.* FROM evaluation_notification_outcome_heads h
|
|
336
|
+
JOIN evaluation_notification_outcome_revisions r ON r.notification_id=h.notification_id AND r.version=h.version
|
|
337
|
+
WHERE h.scope_key=? AND h.occurred_at>=? AND h.occurred_at<?
|
|
338
|
+
AND json_extract(r.record_json, '$.workItemId') IS NOT NULL
|
|
339
|
+
ORDER BY h.occurred_at, h.notification_id LIMIT ?`).all(scopeKey, fromNotifiedAt, toNotifiedAt, limit + 1);
|
|
340
|
+
return Object.freeze({ records: Object.freeze(rows.slice(0, limit).map(row => stored(row).record)), truncated: rows.length > limit });
|
|
341
|
+
}
|
|
342
|
+
counts() {
|
|
343
|
+
const heads = this.#database.prepare('SELECT COUNT(*) AS count FROM evaluation_notification_outcome_heads').get();
|
|
344
|
+
const revisions = this.#database.prepare('SELECT COUNT(*) AS count FROM evaluation_notification_outcome_revisions').get();
|
|
345
|
+
return { notifications: heads.count, revisions: revisions.count };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
//# sourceMappingURL=notification-outcomes.js.map
|