@kontourai/survey 0.4.19 → 0.4.20
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 +12 -3
- package/dist/examples/review-workbench/facility-credential-consumer.d.ts +1 -0
- package/dist/examples/review-workbench/facility-credential-consumer.js +6 -2
- package/dist/examples/review-workbench/server-apply-consumer.d.ts +30 -0
- package/dist/examples/review-workbench/server-apply-consumer.js +47 -0
- package/dist/src/review-workbench/review-session-replay.d.ts +12 -0
- package/dist/src/review-workbench/review-session-replay.js +136 -0
- package/dist/src/review-workbench/review-workbench.d.ts +2 -10
- package/dist/src/review-workbench/review-workbench.js +2 -94
- package/dist/src/review-workbench/server-review-session.d.ts +56 -0
- package/dist/src/review-workbench/server-review-session.js +120 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -101,15 +101,24 @@ For the full consumer path from `ReviewItem` construction through persisted
|
|
|
101
101
|
review events, exported results, and optional Surface projection, see
|
|
102
102
|
[`docs/consumer-integration-guide.md`](docs/consumer-integration-guide.md).
|
|
103
103
|
That guide also covers the server-side apply boundary: producers should derive
|
|
104
|
-
write results from
|
|
104
|
+
write results from pre-decision review snapshots plus persisted events, not from
|
|
105
105
|
browser-computed decisions or exported result payloads.
|
|
106
106
|
Use `persistReviewSessionEvents` when server code needs to save review events,
|
|
107
107
|
then pass the persisted event set to `deriveReviewSessionApplyResultForSnapshot`
|
|
108
108
|
before applying product policy. Survey derives selected review results and
|
|
109
109
|
structured replay/completion issues; the producer still owns current-record
|
|
110
110
|
validation and writes.
|
|
111
|
-
For
|
|
112
|
-
|
|
111
|
+
For browser-backed queues, server code can import
|
|
112
|
+
`@kontourai/survey/review-workbench/server-review-session` and use
|
|
113
|
+
`createServerReviewSessionRecord`, `hashReviewSessionSnapshot`,
|
|
114
|
+
`assertServerReviewSessionFreshness`, and `assertServerReviewSessionEvents` to
|
|
115
|
+
keep the review snapshot server-owned while accepting browser-submitted
|
|
116
|
+
`ReviewSessionEvent` resources.
|
|
117
|
+
For generic, test-covered consumer examples, see
|
|
118
|
+
[`examples/review-workbench/facility-credential-consumer.ts`](examples/review-workbench/facility-credential-consumer.ts)
|
|
119
|
+
for presentation and event persistence, and
|
|
120
|
+
[`examples/review-workbench/server-apply-consumer.ts`](examples/review-workbench/server-apply-consumer.ts)
|
|
121
|
+
for a compact server-side apply boundary.
|
|
113
122
|
For the current decision on why Survey is not adding a generic review adapter
|
|
114
123
|
builder yet, see
|
|
115
124
|
[`docs/consumer-adapter-abstraction-assessment.md`](docs/consumer-adapter-abstraction-assessment.md).
|
|
@@ -6,6 +6,7 @@ export declare function buildFacilityCredentialConsumerExample(): Promise<Facili
|
|
|
6
6
|
export declare const facilityCredentialConsumerExample: FacilityCredentialConsumerExample;
|
|
7
7
|
export interface FacilityCredentialConsumerExample {
|
|
8
8
|
readonly reviewItem: typeof facilityCredentialReviewItemFixture;
|
|
9
|
+
readonly reviewSessionSnapshot: ReturnType<typeof initialReviewQueueSessionState>;
|
|
9
10
|
readonly reviewedSnapshot: ReturnType<typeof initialReviewQueueSessionState>;
|
|
10
11
|
readonly eventsToPersist: readonly ReviewSessionEvent[];
|
|
11
12
|
readonly persistedEvents: readonly ReviewSessionEvent[];
|
|
@@ -45,10 +45,13 @@ export const facilityCredentialPresentationAdapter = {
|
|
|
45
45
|
},
|
|
46
46
|
};
|
|
47
47
|
export async function buildFacilityCredentialConsumerExample() {
|
|
48
|
-
const
|
|
48
|
+
const reviewSessionSnapshot = {
|
|
49
49
|
...initialReviewQueueSessionState([facilityCredentialReviewItemFixture]),
|
|
50
50
|
actorId: "review-operator@example.test",
|
|
51
51
|
reviewedAt: "2026-01-17T16:15:00.000Z",
|
|
52
|
+
};
|
|
53
|
+
const reviewedSnapshot = {
|
|
54
|
+
...reviewSessionSnapshot,
|
|
52
55
|
decisionsByItemName: {
|
|
53
56
|
[facilityCredentialReviewItemFixture.metadata.name]: "accept-proposed",
|
|
54
57
|
},
|
|
@@ -71,7 +74,7 @@ export async function buildFacilityCredentialConsumerExample() {
|
|
|
71
74
|
},
|
|
72
75
|
});
|
|
73
76
|
const applyResult = deriveReviewSessionApplyResultForSnapshot({
|
|
74
|
-
snapshot:
|
|
77
|
+
snapshot: reviewSessionSnapshot,
|
|
75
78
|
events: persisted.events,
|
|
76
79
|
requiredResolvedItems: "all",
|
|
77
80
|
});
|
|
@@ -90,6 +93,7 @@ export async function buildFacilityCredentialConsumerExample() {
|
|
|
90
93
|
}
|
|
91
94
|
return {
|
|
92
95
|
reviewItem: facilityCredentialReviewItemFixture,
|
|
96
|
+
reviewSessionSnapshot,
|
|
93
97
|
reviewedSnapshot,
|
|
94
98
|
eventsToPersist,
|
|
95
99
|
persistedEvents: persisted.events,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ReviewQueueSessionState, type ReviewWorkbenchResult } from "../../src/review-workbench/review-workbench.js";
|
|
2
|
+
import type { ReviewSessionEvent } from "../../src/review-resource.js";
|
|
3
|
+
export interface FacilityCredentialRecord {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly credential: unknown;
|
|
6
|
+
readonly appliedReviewItemNames: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export type FacilityCredentialApplyPreparation = {
|
|
9
|
+
readonly ok: true;
|
|
10
|
+
readonly mutation: {
|
|
11
|
+
readonly recordId: string;
|
|
12
|
+
readonly credential: unknown;
|
|
13
|
+
readonly reviewItemName: string;
|
|
14
|
+
readonly selectedCandidateId: string;
|
|
15
|
+
readonly actorId: string;
|
|
16
|
+
readonly appliedAt: string;
|
|
17
|
+
};
|
|
18
|
+
readonly result: ReviewWorkbenchResult;
|
|
19
|
+
} | {
|
|
20
|
+
readonly ok: false;
|
|
21
|
+
readonly message: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function prepareFacilityCredentialServerApply(input: {
|
|
24
|
+
readonly currentRecord: FacilityCredentialRecord;
|
|
25
|
+
readonly reviewSessionSnapshot: ReviewQueueSessionState;
|
|
26
|
+
readonly events: readonly ReviewSessionEvent[];
|
|
27
|
+
readonly actorId: string;
|
|
28
|
+
readonly appliedAt: string;
|
|
29
|
+
}): FacilityCredentialApplyPreparation;
|
|
30
|
+
export declare const facilityCredentialCurrentRecordFixture: FacilityCredentialRecord;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { facilityCredentialReviewItemFixture } from "../../src/review-workbench/review-workbench-data.js";
|
|
2
|
+
import { deriveReviewSessionApplyResultForSnapshot, } from "../../src/review-workbench/review-workbench.js";
|
|
3
|
+
export function prepareFacilityCredentialServerApply(input) {
|
|
4
|
+
const applyResult = deriveReviewSessionApplyResultForSnapshot({
|
|
5
|
+
snapshot: input.reviewSessionSnapshot,
|
|
6
|
+
events: input.events,
|
|
7
|
+
requiredResolvedItems: "all",
|
|
8
|
+
});
|
|
9
|
+
if (!applyResult.ok) {
|
|
10
|
+
return {
|
|
11
|
+
ok: false,
|
|
12
|
+
message: applyResult.issues.map((issue) => issue.message).join(" "),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
const [result] = applyResult.results;
|
|
16
|
+
if (!result || applyResult.results.length !== 1) {
|
|
17
|
+
return { ok: false, message: "Expected exactly one reviewed credential result." };
|
|
18
|
+
}
|
|
19
|
+
const item = input.reviewSessionSnapshot.items.find((candidate) => candidate.metadata.name === result.reviewItemName);
|
|
20
|
+
if (!item || item.spec.target !== "operatingLicenseCredential") {
|
|
21
|
+
return { ok: false, message: "Review result does not target the credential field." };
|
|
22
|
+
}
|
|
23
|
+
const currentCandidate = item.spec.candidates.find((candidate) => candidate.role === "current");
|
|
24
|
+
if (!currentCandidate || JSON.stringify(currentCandidate.value) !== JSON.stringify(input.currentRecord.credential)) {
|
|
25
|
+
return { ok: false, message: "Current credential no longer matches the review session snapshot." };
|
|
26
|
+
}
|
|
27
|
+
if (input.currentRecord.appliedReviewItemNames.includes(result.reviewItemName)) {
|
|
28
|
+
return { ok: false, message: "Review result was already applied." };
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
ok: true,
|
|
32
|
+
result,
|
|
33
|
+
mutation: {
|
|
34
|
+
recordId: input.currentRecord.id,
|
|
35
|
+
credential: result.selectedValue,
|
|
36
|
+
reviewItemName: result.reviewItemName,
|
|
37
|
+
selectedCandidateId: result.selectedCandidateId,
|
|
38
|
+
actorId: input.actorId,
|
|
39
|
+
appliedAt: input.appliedAt,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export const facilityCredentialCurrentRecordFixture = {
|
|
44
|
+
id: "facility-credential-record-1",
|
|
45
|
+
credential: facilityCredentialReviewItemFixture.spec.candidates.find((candidate) => candidate.role === "current")?.value,
|
|
46
|
+
appliedReviewItemNames: [],
|
|
47
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ReviewSessionEvent } from "../review-resource.js";
|
|
2
|
+
import { type ReviewQueueSessionState } from "./review-queue-session.js";
|
|
3
|
+
export type ReviewSessionReplayIssueCode = "invalid-sequence" | "duplicate-sequence" | "non-contiguous-sequence" | "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch";
|
|
4
|
+
export interface ReviewSessionReplayIssue {
|
|
5
|
+
readonly code: ReviewSessionReplayIssueCode;
|
|
6
|
+
readonly eventName: string;
|
|
7
|
+
readonly sequence: number;
|
|
8
|
+
readonly reviewItemName?: string;
|
|
9
|
+
readonly candidateId?: string;
|
|
10
|
+
readonly message: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { candidateForDecision, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
2
|
+
export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
3
|
+
const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
|
|
4
|
+
const sequenceIssues = validateEventSequence(events);
|
|
5
|
+
const replayIssues = events.flatMap((event) => {
|
|
6
|
+
const issues = [];
|
|
7
|
+
const activeItemName = event.spec.activeItemName;
|
|
8
|
+
const reviewItemName = event.spec.reviewItemName;
|
|
9
|
+
const itemName = reviewItemName ?? activeItemName;
|
|
10
|
+
const eventRef = {
|
|
11
|
+
eventName: event.metadata.name,
|
|
12
|
+
sequence: event.spec.sequence,
|
|
13
|
+
};
|
|
14
|
+
if (activeItemName && !itemsByName.has(activeItemName)) {
|
|
15
|
+
issues.push({
|
|
16
|
+
...eventRef,
|
|
17
|
+
code: "unknown-active-item",
|
|
18
|
+
reviewItemName: activeItemName,
|
|
19
|
+
message: `ReviewSessionEvent ${event.metadata.name} references active item ${activeItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
if (reviewItemName && !itemsByName.has(reviewItemName)) {
|
|
23
|
+
issues.push({
|
|
24
|
+
...eventRef,
|
|
25
|
+
code: "unknown-review-item",
|
|
26
|
+
reviewItemName,
|
|
27
|
+
message: `ReviewSessionEvent ${event.metadata.name} references review item ${reviewItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
|
|
31
|
+
&& !reviewItemName) {
|
|
32
|
+
issues.push({
|
|
33
|
+
...eventRef,
|
|
34
|
+
code: "missing-review-item",
|
|
35
|
+
message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not reference a ReviewItem.`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted") {
|
|
39
|
+
const decision = replayableWorkbenchDecision(event.spec.data?.workbenchDecision);
|
|
40
|
+
if (!decision) {
|
|
41
|
+
issues.push({
|
|
42
|
+
...eventRef,
|
|
43
|
+
code: "invalid-workbench-decision",
|
|
44
|
+
reviewItemName,
|
|
45
|
+
candidateId: event.spec.candidateId,
|
|
46
|
+
message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not include a replayable workbench decision.`,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
else if (itemName && itemsByName.has(itemName)) {
|
|
50
|
+
const item = itemsByName.get(itemName);
|
|
51
|
+
const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
|
|
52
|
+
const expectedStatus = workbenchDecisionDefinitions[decision].status;
|
|
53
|
+
const referencedCandidateExists = event.spec.candidateId
|
|
54
|
+
? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
|
|
55
|
+
: false;
|
|
56
|
+
if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidate.id) {
|
|
57
|
+
issues.push({
|
|
58
|
+
...eventRef,
|
|
59
|
+
code: "decision-candidate-mismatch",
|
|
60
|
+
reviewItemName: itemName,
|
|
61
|
+
candidateId: event.spec.candidateId,
|
|
62
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects candidate ${expectedCandidate.id}, but references ${event.spec.candidateId ?? "no candidate"}.`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
if (event.spec.status !== expectedStatus) {
|
|
66
|
+
issues.push({
|
|
67
|
+
...eventRef,
|
|
68
|
+
code: "decision-status-mismatch",
|
|
69
|
+
reviewItemName: itemName,
|
|
70
|
+
candidateId: event.spec.candidateId,
|
|
71
|
+
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
|
|
77
|
+
const item = itemsByName.get(itemName);
|
|
78
|
+
const hasCandidate = item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId);
|
|
79
|
+
if (!hasCandidate) {
|
|
80
|
+
issues.push({
|
|
81
|
+
...eventRef,
|
|
82
|
+
code: "unknown-candidate",
|
|
83
|
+
reviewItemName: itemName,
|
|
84
|
+
candidateId: event.spec.candidateId,
|
|
85
|
+
message: `ReviewSessionEvent ${event.metadata.name} references candidate ${event.spec.candidateId}, but ReviewItem ${itemName} in the supplied session snapshot does not contain that candidate.`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return issues;
|
|
90
|
+
});
|
|
91
|
+
return [...sequenceIssues, ...replayIssues];
|
|
92
|
+
}
|
|
93
|
+
function replayableWorkbenchDecision(value) {
|
|
94
|
+
return typeof value === "string" && value in workbenchDecisionDefinitions
|
|
95
|
+
? value
|
|
96
|
+
: undefined;
|
|
97
|
+
}
|
|
98
|
+
function validateEventSequence(events) {
|
|
99
|
+
const issues = [];
|
|
100
|
+
const seen = new Map();
|
|
101
|
+
events.forEach((event, index) => {
|
|
102
|
+
const sequence = event.spec.sequence;
|
|
103
|
+
const eventRef = {
|
|
104
|
+
eventName: event.metadata.name,
|
|
105
|
+
sequence,
|
|
106
|
+
};
|
|
107
|
+
if (!Number.isSafeInteger(sequence) || sequence < 1) {
|
|
108
|
+
issues.push({
|
|
109
|
+
...eventRef,
|
|
110
|
+
code: "invalid-sequence",
|
|
111
|
+
message: `ReviewSessionEvent ${event.metadata.name} has invalid sequence ${String(sequence)}. Sequences must be positive safe integers.`,
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const duplicateOf = seen.get(sequence);
|
|
116
|
+
if (duplicateOf) {
|
|
117
|
+
issues.push({
|
|
118
|
+
...eventRef,
|
|
119
|
+
code: "duplicate-sequence",
|
|
120
|
+
message: `ReviewSessionEvent ${event.metadata.name} reuses sequence ${sequence} from ${duplicateOf}.`,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
seen.set(sequence, event.metadata.name);
|
|
125
|
+
}
|
|
126
|
+
const expected = index + 1;
|
|
127
|
+
if (sequence !== expected) {
|
|
128
|
+
issues.push({
|
|
129
|
+
...eventRef,
|
|
130
|
+
code: "non-contiguous-sequence",
|
|
131
|
+
message: `ReviewSessionEvent ${event.metadata.name} has sequence ${sequence}, but canonical event streams must be ordered and contiguous from 1; expected ${expected}.`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
return issues;
|
|
136
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { type ReviewQueueSessionState, type ReviewWorkbenchDecision, type ReviewWorkbenchState } from "./review-queue-session.js";
|
|
2
|
+
import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
|
|
2
3
|
import { type ReviewPresentationAdapter } from "./review-presentation.js";
|
|
3
4
|
import { type ReviewCandidate, type ReviewDecision, type ReviewSession, type ReviewSessionEvent } from "../review-resource.js";
|
|
4
5
|
export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, type ReviewQueueRowStatus, type ReviewQueueSessionState, type ReviewSessionSummary, type ReviewWorkbenchDecision, type ReviewWorkbenchState, } from "./review-queue-session.js";
|
|
5
6
|
export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, type ReviewCandidatePresentation, type ReviewCandidatePresentationContext, type ReviewItemPresentation, type ReviewItemPresentationContext, type ReviewPresentationAdapter, type ReviewPresentationLink, type ReviewResultPresentation, type ReviewTracePresentationContext, type ReviewTraceRef, type ReviewValuePresentationContext, } from "./review-presentation.js";
|
|
6
7
|
export { buildSurfaceProjectionPreview, type PreviewAuthorityTrace, type PreviewCandidateHistory, type PreviewClaim, type PreviewIntegrityPosture, type PreviewReviewEvent, type PreviewSourceAuthority, type PreviewSourceEvidence, type SurfaceProjectionPreview, } from "./review-surface-preview.js";
|
|
8
|
+
export { validateReviewSessionEventsForSnapshot, type ReviewSessionReplayIssue, type ReviewSessionReplayIssueCode, } from "./review-session-replay.js";
|
|
7
9
|
export declare function buildReviewDecision(state: ReviewWorkbenchState): ReviewDecision | undefined;
|
|
8
10
|
export interface ReviewWorkbenchSessionExport {
|
|
9
11
|
readonly session: ReviewSession;
|
|
@@ -11,15 +13,6 @@ export interface ReviewWorkbenchSessionExport {
|
|
|
11
13
|
readonly decisions: readonly ReviewDecision[];
|
|
12
14
|
readonly results: readonly ReviewWorkbenchResult[];
|
|
13
15
|
}
|
|
14
|
-
export type ReviewSessionReplayIssueCode = "unknown-active-item" | "unknown-review-item" | "unknown-candidate" | "missing-review-item" | "invalid-workbench-decision" | "decision-candidate-mismatch" | "decision-status-mismatch";
|
|
15
|
-
export interface ReviewSessionReplayIssue {
|
|
16
|
-
readonly code: ReviewSessionReplayIssueCode;
|
|
17
|
-
readonly eventName: string;
|
|
18
|
-
readonly sequence: number;
|
|
19
|
-
readonly reviewItemName?: string;
|
|
20
|
-
readonly candidateId?: string;
|
|
21
|
-
readonly message: string;
|
|
22
|
-
}
|
|
23
16
|
export type ReviewSessionApplyResolutionRequirement = "all" | "any" | "none";
|
|
24
17
|
export type ReviewSessionApplyIssue = ReviewSessionReplayIssue | {
|
|
25
18
|
readonly code: "unresolved-review-item";
|
|
@@ -108,7 +101,6 @@ export interface ReviewWorkbenchResult {
|
|
|
108
101
|
export declare function buildReviewDecisionsFromSession(session: ReviewQueueSessionState): ReviewDecision[];
|
|
109
102
|
export declare function buildReviewWorkbenchResultsFromSession(session: ReviewQueueSessionState): ReviewWorkbenchResult[];
|
|
110
103
|
export declare function buildReviewWorkbenchSessionExport(session: ReviewQueueSessionState, events?: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
|
|
111
|
-
export declare function validateReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewSessionReplayIssue[];
|
|
112
104
|
export declare function replayReviewSessionEventsForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewQueueSessionState;
|
|
113
105
|
export declare function buildReviewWorkbenchSessionExportForSnapshot(snapshot: ReviewQueueSessionState, events: readonly ReviewSessionEvent[]): ReviewWorkbenchSessionExport;
|
|
114
106
|
export declare function deriveReviewSessionApplyResultForSnapshot(options: DeriveReviewSessionApplyResultForSnapshotOptions): DeriveReviewSessionApplyResultForSnapshotResult;
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { candidateForDecision, buildReviewSessionEvent, buildReviewSessionEvents, buildReviewSessionResource, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
2
|
+
import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
|
|
2
3
|
import { buildSurfaceProjectionPreview, formatValue, } from "./review-surface-preview.js";
|
|
3
4
|
import { buildReviewCandidatePresentation, buildReviewItemPresentation, } from "./review-presentation.js";
|
|
4
5
|
import { reviewResourceApiVersion, } from "../review-resource.js";
|
|
5
6
|
export { buildReviewSessionEvents, buildReviewSessionEvent, buildReviewSessionResource, candidateForDecision, currentReviewItem, currentReviewWorkbenchState, defaultReviewSessionName, deriveQueueRowStatus, initialReviewQueueSessionState, initialReviewWorkbenchState, nextUnresolvedItemName, replayReviewSessionEvents, reviewSessionSummary, reviewWorkbenchSessionStorageKey, selectedCandidateRole, workbenchDecisionDefinitions, } from "./review-queue-session.js";
|
|
6
7
|
export { buildReviewCandidatePresentation, buildReviewItemPresentation, buildReviewResultPresentation, humanizeIdentifier, } from "./review-presentation.js";
|
|
7
8
|
export { buildSurfaceProjectionPreview, } from "./review-surface-preview.js";
|
|
9
|
+
export { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
|
|
8
10
|
export function buildReviewDecision(state) {
|
|
9
11
|
if (!state.decision) {
|
|
10
12
|
return undefined;
|
|
@@ -91,100 +93,6 @@ export function buildReviewWorkbenchSessionExport(session, events = buildReviewS
|
|
|
91
93
|
results: buildReviewWorkbenchResultsFromSession(session),
|
|
92
94
|
};
|
|
93
95
|
}
|
|
94
|
-
export function validateReviewSessionEventsForSnapshot(snapshot, events) {
|
|
95
|
-
const itemsByName = new Map(snapshot.items.map((item) => [item.metadata.name, item]));
|
|
96
|
-
return events.flatMap((event) => {
|
|
97
|
-
const issues = [];
|
|
98
|
-
const activeItemName = event.spec.activeItemName;
|
|
99
|
-
const reviewItemName = event.spec.reviewItemName;
|
|
100
|
-
const itemName = reviewItemName ?? activeItemName;
|
|
101
|
-
const eventRef = {
|
|
102
|
-
eventName: event.metadata.name,
|
|
103
|
-
sequence: event.spec.sequence,
|
|
104
|
-
};
|
|
105
|
-
if (activeItemName && !itemsByName.has(activeItemName)) {
|
|
106
|
-
issues.push({
|
|
107
|
-
...eventRef,
|
|
108
|
-
code: "unknown-active-item",
|
|
109
|
-
reviewItemName: activeItemName,
|
|
110
|
-
message: `ReviewSessionEvent ${event.metadata.name} references active item ${activeItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
if (reviewItemName && !itemsByName.has(reviewItemName)) {
|
|
114
|
-
issues.push({
|
|
115
|
-
...eventRef,
|
|
116
|
-
code: "unknown-review-item",
|
|
117
|
-
reviewItemName,
|
|
118
|
-
message: `ReviewSessionEvent ${event.metadata.name} references review item ${reviewItemName}, but the supplied session snapshot does not contain that ReviewItem.`,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
if ((event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted")
|
|
122
|
-
&& !reviewItemName) {
|
|
123
|
-
issues.push({
|
|
124
|
-
...eventRef,
|
|
125
|
-
code: "missing-review-item",
|
|
126
|
-
message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not reference a ReviewItem.`,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
if (event.spec.eventType === "decision-changed" || event.spec.eventType === "decision-submitted") {
|
|
130
|
-
const decision = replayableWorkbenchDecision(event.spec.data?.workbenchDecision);
|
|
131
|
-
if (!decision) {
|
|
132
|
-
issues.push({
|
|
133
|
-
...eventRef,
|
|
134
|
-
code: "invalid-workbench-decision",
|
|
135
|
-
reviewItemName,
|
|
136
|
-
candidateId: event.spec.candidateId,
|
|
137
|
-
message: `ReviewSessionEvent ${event.metadata.name} is a decision event but does not include a replayable workbench decision.`,
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
else if (itemName && itemsByName.has(itemName)) {
|
|
141
|
-
const item = itemsByName.get(itemName);
|
|
142
|
-
const expectedCandidate = item ? candidateForDecision(item, decision) : undefined;
|
|
143
|
-
const expectedStatus = workbenchDecisionDefinitions[decision].status;
|
|
144
|
-
const referencedCandidateExists = event.spec.candidateId
|
|
145
|
-
? item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId)
|
|
146
|
-
: false;
|
|
147
|
-
if (expectedCandidate && (!event.spec.candidateId || referencedCandidateExists) && event.spec.candidateId !== expectedCandidate.id) {
|
|
148
|
-
issues.push({
|
|
149
|
-
...eventRef,
|
|
150
|
-
code: "decision-candidate-mismatch",
|
|
151
|
-
reviewItemName: itemName,
|
|
152
|
-
candidateId: event.spec.candidateId,
|
|
153
|
-
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects candidate ${expectedCandidate.id}, but references ${event.spec.candidateId ?? "no candidate"}.`,
|
|
154
|
-
});
|
|
155
|
-
}
|
|
156
|
-
if (event.spec.status !== expectedStatus) {
|
|
157
|
-
issues.push({
|
|
158
|
-
...eventRef,
|
|
159
|
-
code: "decision-status-mismatch",
|
|
160
|
-
reviewItemName: itemName,
|
|
161
|
-
candidateId: event.spec.candidateId,
|
|
162
|
-
message: `ReviewSessionEvent ${event.metadata.name} decision ${decision} expects status ${expectedStatus}, but references ${event.spec.status ?? "no status"}.`,
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
if (event.spec.candidateId && itemName && itemsByName.has(itemName)) {
|
|
168
|
-
const item = itemsByName.get(itemName);
|
|
169
|
-
const hasCandidate = item?.spec.candidates.some((candidate) => candidate.id === event.spec.candidateId);
|
|
170
|
-
if (!hasCandidate) {
|
|
171
|
-
issues.push({
|
|
172
|
-
...eventRef,
|
|
173
|
-
code: "unknown-candidate",
|
|
174
|
-
reviewItemName: itemName,
|
|
175
|
-
candidateId: event.spec.candidateId,
|
|
176
|
-
message: `ReviewSessionEvent ${event.metadata.name} references candidate ${event.spec.candidateId}, but ReviewItem ${itemName} in the supplied session snapshot does not contain that candidate.`,
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return issues;
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
function replayableWorkbenchDecision(value) {
|
|
184
|
-
return typeof value === "string" && value in workbenchDecisionDefinitions
|
|
185
|
-
? value
|
|
186
|
-
: undefined;
|
|
187
|
-
}
|
|
188
96
|
export function replayReviewSessionEventsForSnapshot(snapshot, events) {
|
|
189
97
|
const issues = validateReviewSessionEventsForSnapshot(snapshot, events);
|
|
190
98
|
if (issues.length > 0) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ReviewSessionReplayIssue } from "./review-session-replay.js";
|
|
2
|
+
import type { ReviewSessionEvent } from "../review-resource.js";
|
|
3
|
+
import type { ReviewQueueSessionState } from "./review-queue-session.js";
|
|
4
|
+
export interface ServerReviewSessionRecord {
|
|
5
|
+
readonly sessionName: string;
|
|
6
|
+
readonly snapshot: ReviewQueueSessionState;
|
|
7
|
+
readonly snapshotHash: string;
|
|
8
|
+
readonly eventCount?: number;
|
|
9
|
+
readonly updatedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export type ServerReviewSessionFreshnessStatus = "current" | "stale";
|
|
12
|
+
export interface ServerReviewSessionFreshnessComparison {
|
|
13
|
+
readonly status: ServerReviewSessionFreshnessStatus;
|
|
14
|
+
readonly expectedSnapshotHash: string;
|
|
15
|
+
readonly actualSnapshotHash: string;
|
|
16
|
+
readonly expectedEventCount?: number;
|
|
17
|
+
readonly actualEventCount?: number;
|
|
18
|
+
}
|
|
19
|
+
export type ServerReviewSessionStaleIssueCode = "snapshot-hash-mismatch" | "event-count-mismatch";
|
|
20
|
+
export interface ServerReviewSessionStaleIssue {
|
|
21
|
+
readonly code: ServerReviewSessionStaleIssueCode;
|
|
22
|
+
readonly message: string;
|
|
23
|
+
readonly expected: string | number;
|
|
24
|
+
readonly actual: string | number;
|
|
25
|
+
}
|
|
26
|
+
export declare class StaleServerReviewSessionError extends Error {
|
|
27
|
+
readonly name = "StaleServerReviewSessionError";
|
|
28
|
+
readonly issues: readonly ServerReviewSessionStaleIssue[];
|
|
29
|
+
readonly comparison: ServerReviewSessionFreshnessComparison;
|
|
30
|
+
constructor(comparison: ServerReviewSessionFreshnessComparison);
|
|
31
|
+
}
|
|
32
|
+
export type ServerReviewSessionEventValidationIssue = ReviewSessionReplayIssue | {
|
|
33
|
+
readonly code: "session-name-mismatch";
|
|
34
|
+
readonly eventName: string;
|
|
35
|
+
readonly sequence: number;
|
|
36
|
+
readonly expectedSessionName: string;
|
|
37
|
+
readonly actualSessionName: string;
|
|
38
|
+
readonly message: string;
|
|
39
|
+
};
|
|
40
|
+
export declare class ServerReviewSessionEventValidationError extends Error {
|
|
41
|
+
readonly name = "ServerReviewSessionEventValidationError";
|
|
42
|
+
readonly issues: readonly ServerReviewSessionEventValidationIssue[];
|
|
43
|
+
constructor(issues: readonly ServerReviewSessionEventValidationIssue[]);
|
|
44
|
+
}
|
|
45
|
+
export interface CreateServerReviewSessionRecordOptions {
|
|
46
|
+
readonly sessionName: string;
|
|
47
|
+
readonly snapshot: ReviewQueueSessionState;
|
|
48
|
+
readonly eventCount?: number;
|
|
49
|
+
readonly updatedAt?: string | Date;
|
|
50
|
+
}
|
|
51
|
+
export declare function createServerReviewSessionRecord(options: CreateServerReviewSessionRecordOptions): ServerReviewSessionRecord;
|
|
52
|
+
export declare function hashReviewSessionSnapshot(snapshot: ReviewQueueSessionState): string;
|
|
53
|
+
export declare function compareServerReviewSessionFreshness(record: ServerReviewSessionRecord, snapshot: ReviewQueueSessionState, eventCount?: number): ServerReviewSessionFreshnessComparison;
|
|
54
|
+
export declare function assertServerReviewSessionFreshness(record: ServerReviewSessionRecord, snapshot: ReviewQueueSessionState, eventCount?: number): void;
|
|
55
|
+
export declare function validateServerReviewSessionEvents(record: ServerReviewSessionRecord, events: readonly ReviewSessionEvent[]): ServerReviewSessionEventValidationIssue[];
|
|
56
|
+
export declare function assertServerReviewSessionEvents(record: ServerReviewSessionRecord, events: readonly ReviewSessionEvent[]): void;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { validateReviewSessionEventsForSnapshot, } from "./review-session-replay.js";
|
|
3
|
+
export class StaleServerReviewSessionError extends Error {
|
|
4
|
+
name = "StaleServerReviewSessionError";
|
|
5
|
+
issues;
|
|
6
|
+
comparison;
|
|
7
|
+
constructor(comparison) {
|
|
8
|
+
const issues = staleIssuesForComparison(comparison);
|
|
9
|
+
super(`Review session is stale: ${issues.map((issue) => issue.message).join(" ")}`);
|
|
10
|
+
this.issues = issues;
|
|
11
|
+
this.comparison = comparison;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class ServerReviewSessionEventValidationError extends Error {
|
|
15
|
+
name = "ServerReviewSessionEventValidationError";
|
|
16
|
+
issues;
|
|
17
|
+
constructor(issues) {
|
|
18
|
+
super(`Review session events are invalid: ${issues.map((issue) => issue.message).join(" ")}`);
|
|
19
|
+
this.issues = issues;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function createServerReviewSessionRecord(options) {
|
|
23
|
+
return {
|
|
24
|
+
sessionName: options.sessionName,
|
|
25
|
+
snapshot: options.snapshot,
|
|
26
|
+
snapshotHash: hashReviewSessionSnapshot(options.snapshot),
|
|
27
|
+
eventCount: options.eventCount,
|
|
28
|
+
updatedAt: isoTimestamp(options.updatedAt ?? new Date()),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function hashReviewSessionSnapshot(snapshot) {
|
|
32
|
+
return sha256(canonicalJson(snapshot));
|
|
33
|
+
}
|
|
34
|
+
export function compareServerReviewSessionFreshness(record, snapshot, eventCount) {
|
|
35
|
+
const actualSnapshotHash = hashReviewSessionSnapshot(snapshot);
|
|
36
|
+
const eventCountMatches = record.eventCount === undefined || eventCount === undefined || record.eventCount === eventCount;
|
|
37
|
+
const current = record.snapshotHash === actualSnapshotHash && eventCountMatches;
|
|
38
|
+
return {
|
|
39
|
+
status: current ? "current" : "stale",
|
|
40
|
+
expectedSnapshotHash: record.snapshotHash,
|
|
41
|
+
actualSnapshotHash,
|
|
42
|
+
expectedEventCount: record.eventCount,
|
|
43
|
+
actualEventCount: eventCount,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export function assertServerReviewSessionFreshness(record, snapshot, eventCount) {
|
|
47
|
+
const comparison = compareServerReviewSessionFreshness(record, snapshot, eventCount);
|
|
48
|
+
if (comparison.status === "stale") {
|
|
49
|
+
throw new StaleServerReviewSessionError(comparison);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function validateServerReviewSessionEvents(record, events) {
|
|
53
|
+
const replayIssues = validateReviewSessionEventsForSnapshot(record.snapshot, events);
|
|
54
|
+
const sessionIssues = events.flatMap((event) => {
|
|
55
|
+
if (event.spec.sessionName === record.sessionName) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
return [{
|
|
59
|
+
code: "session-name-mismatch",
|
|
60
|
+
eventName: event.metadata.name,
|
|
61
|
+
sequence: event.spec.sequence,
|
|
62
|
+
expectedSessionName: record.sessionName,
|
|
63
|
+
actualSessionName: event.spec.sessionName,
|
|
64
|
+
message: `ReviewSessionEvent ${event.metadata.name} references session ${event.spec.sessionName}, but the server session is ${record.sessionName}.`,
|
|
65
|
+
}];
|
|
66
|
+
});
|
|
67
|
+
return [...sessionIssues, ...replayIssues];
|
|
68
|
+
}
|
|
69
|
+
export function assertServerReviewSessionEvents(record, events) {
|
|
70
|
+
const issues = validateServerReviewSessionEvents(record, events);
|
|
71
|
+
if (issues.length > 0) {
|
|
72
|
+
throw new ServerReviewSessionEventValidationError(issues);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function staleIssuesForComparison(comparison) {
|
|
76
|
+
const issues = [];
|
|
77
|
+
if (comparison.expectedSnapshotHash !== comparison.actualSnapshotHash) {
|
|
78
|
+
issues.push({
|
|
79
|
+
code: "snapshot-hash-mismatch",
|
|
80
|
+
expected: comparison.expectedSnapshotHash,
|
|
81
|
+
actual: comparison.actualSnapshotHash,
|
|
82
|
+
message: `Expected snapshot hash ${comparison.expectedSnapshotHash}, received ${comparison.actualSnapshotHash}.`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (comparison.expectedEventCount !== undefined
|
|
86
|
+
&& comparison.actualEventCount !== undefined
|
|
87
|
+
&& comparison.expectedEventCount !== comparison.actualEventCount) {
|
|
88
|
+
issues.push({
|
|
89
|
+
code: "event-count-mismatch",
|
|
90
|
+
expected: comparison.expectedEventCount,
|
|
91
|
+
actual: comparison.actualEventCount,
|
|
92
|
+
message: `Expected ${comparison.expectedEventCount} events, received ${comparison.actualEventCount}.`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return issues;
|
|
96
|
+
}
|
|
97
|
+
function canonicalJson(value) {
|
|
98
|
+
return JSON.stringify(canonicalize(value));
|
|
99
|
+
}
|
|
100
|
+
function canonicalize(value) {
|
|
101
|
+
if (value instanceof Date) {
|
|
102
|
+
return value.toISOString();
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(value)) {
|
|
105
|
+
return value.map((entry) => canonicalize(entry));
|
|
106
|
+
}
|
|
107
|
+
if (value && typeof value === "object") {
|
|
108
|
+
const entries = Object.entries(value)
|
|
109
|
+
.filter(([, entryValue]) => entryValue !== undefined)
|
|
110
|
+
.sort(([left], [right]) => left.localeCompare(right));
|
|
111
|
+
return Object.fromEntries(entries.map(([key, entryValue]) => [key, canonicalize(entryValue)]));
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
function sha256(value) {
|
|
116
|
+
return createHash("sha256").update(value).digest("hex");
|
|
117
|
+
}
|
|
118
|
+
function isoTimestamp(value) {
|
|
119
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/survey",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.20",
|
|
4
4
|
"description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
"types": "./dist/src/review-workbench/review-workbench.d.ts",
|
|
23
23
|
"default": "./dist/src/review-workbench/review-workbench.js"
|
|
24
24
|
},
|
|
25
|
+
"./review-workbench/server-review-session": {
|
|
26
|
+
"types": "./dist/src/review-workbench/server-review-session.d.ts",
|
|
27
|
+
"default": "./dist/src/review-workbench/server-review-session.js"
|
|
28
|
+
},
|
|
25
29
|
"./review-workbench.css": "./dist/src/review-workbench/review-workbench.css",
|
|
26
30
|
"./review-workbench/standalone.css": "./dist/src/review-workbench/review-workbench.standalone.css",
|
|
27
31
|
"./fixtures/public-field-review": "./dist/fixtures/public-field-review.js",
|