@kontourai/survey 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/example-data/public-directory-review-resource.d.ts +3 -3
- package/dist/examples/calibrated-auto-accept.d.ts +22 -15
- package/dist/examples/calibrated-auto-accept.js +40 -36
- package/dist/src/calibration.d.ts +48 -21
- package/dist/src/calibration.js +72 -33
- package/dist/src/console/review-console-server.d.ts +3 -1
- package/dist/src/console/review-console-server.js +203 -50
- package/dist/src/extraction-envelope.d.ts +22 -0
- package/dist/src/extraction-envelope.js +25 -4
- package/dist/src/index.d.ts +8 -7
- package/dist/src/index.js +2 -2
- package/dist/src/inquiry-mapping.d.ts +15 -1
- package/dist/src/inquiry-mapping.js +10 -2
- package/dist/src/mcp/review-mcp.js +70 -65
- package/dist/src/producer-profile.d.ts +41 -2
- package/dist/src/producer-profile.js +29 -2
- package/dist/src/review-session-file.d.ts +64 -0
- package/dist/src/review-session-file.js +320 -0
- package/dist/src/review-workbench/edited-value.d.ts +70 -0
- package/dist/src/review-workbench/edited-value.js +147 -0
- package/dist/src/review-workbench/review-presentation.d.ts +44 -0
- package/dist/src/review-workbench/review-presentation.js +49 -0
- package/dist/src/review-workbench/review-queue-session.js +8 -1
- package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
- package/dist/src/review-workbench/review-session-replay.js +77 -0
- package/dist/src/review-workbench/review-workbench.d.ts +8 -4
- package/dist/src/review-workbench/review-workbench.js +19 -7
- package/dist/src/review-workbench/server-review-session.d.ts +3 -1
- package/dist/src/review-workbench/server-review-session.js +1 -0
- package/dist/src/reviewed-candidate-resolution.js +13 -7
- package/dist/src/schema-mapping.d.ts +23 -0
- package/dist/src/schema-mapping.js +30 -20
- package/dist/src/to-surface.d.ts +30 -6
- package/dist/src/to-surface.js +196 -18
- package/dist/src/types.d.ts +39 -0
- package/package.json +5 -4
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared session-file persistence for the local review writers
|
|
3
|
+
* (`survey-review-console` and `survey-review-mcp`).
|
|
4
|
+
*
|
|
5
|
+
* Every write goes through {@link updateReviewSessionFile}, which takes one
|
|
6
|
+
* exclusive lock file next to the session, re-reads the session inside the
|
|
7
|
+
* lock, applies the caller's change, writes a uniquely named temp file and
|
|
8
|
+
* renames it into place. Both writers must use this helper: a lock that only
|
|
9
|
+
* one of them takes still races with the other (kontourai/survey#281).
|
|
10
|
+
*/
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
12
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
13
|
+
import { open, readdir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { defaultReviewSessionName } from "./review-workbench/review-queue-session.js";
|
|
16
|
+
export class ReviewSessionFileLockTimeoutError extends Error {
|
|
17
|
+
constructor(lockPath, timeoutMs) {
|
|
18
|
+
super(`Timed out after ${timeoutMs}ms waiting for session lock ${lockPath}`);
|
|
19
|
+
this.name = "ReviewSessionFileLockTimeoutError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
23
|
+
const DEFAULT_STALE_MS = 30_000;
|
|
24
|
+
/** A lock file that is still empty this long after creation was left by an acquirer that died between create and write. */
|
|
25
|
+
const EMPTY_LOCK_STALE_MS = 1_000;
|
|
26
|
+
/** The break mutex is held for a few milliseconds; older means its holder died mid-break. */
|
|
27
|
+
const BREAK_MUTEX_STALE_MS = 5_000;
|
|
28
|
+
const RETRY_MIN_MS = 10;
|
|
29
|
+
const RETRY_MAX_MS = 50;
|
|
30
|
+
export function reviewSessionLockPath(sessionPath) {
|
|
31
|
+
return `${sessionPath}.lock`;
|
|
32
|
+
}
|
|
33
|
+
export async function readReviewSessionFile(sessionPath) {
|
|
34
|
+
return JSON.parse(await readFile(sessionPath, "utf8"));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Opaque revision token for an event log: a digest of its serialized form.
|
|
38
|
+
* An event count is not enough, because the log is regenerated from session
|
|
39
|
+
* state and a changed decision keeps the count while changing the content.
|
|
40
|
+
*/
|
|
41
|
+
export function reviewSessionRevision(events) {
|
|
42
|
+
return createHash("sha256").update(JSON.stringify(events)).digest("hex").slice(0, 32);
|
|
43
|
+
}
|
|
44
|
+
function isPidAlive(pid) {
|
|
45
|
+
try {
|
|
46
|
+
process.kill(pid, 0);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return error.code === "EPERM";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* An identity for the process currently running as `pid`: its start time, as
|
|
55
|
+
* the OS reports it. A pid can be reused after its holder dies, so a live pid
|
|
56
|
+
* only proves the holder is alive when the start time also matches the one
|
|
57
|
+
* the holder recorded (kontourai/survey#298). Linux reads `/proc/<pid>/stat`
|
|
58
|
+
* field 22 (start time in clock ticks since boot); elsewhere `ps -o lstart=`
|
|
59
|
+
* (one-second resolution), rendered in UTC with the C locale so every reader
|
|
60
|
+
* spells the same instant the same way whatever its own TZ or locale. Resolves `undefined` when the platform offers
|
|
61
|
+
* neither or the process is gone, which leaves the pid-only rule in force.
|
|
62
|
+
*/
|
|
63
|
+
async function processStartIdentity(pid) {
|
|
64
|
+
if (process.platform === "linux") {
|
|
65
|
+
try {
|
|
66
|
+
const procStat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
67
|
+
// Fields after the parenthesized command name, which may contain spaces.
|
|
68
|
+
const fields = procStat.slice(procStat.lastIndexOf(")") + 2).split(" ");
|
|
69
|
+
const startTicks = fields[19];
|
|
70
|
+
return startTicks ? `linux-starttime:${startTicks}` : undefined;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (process.platform === "win32")
|
|
77
|
+
return undefined;
|
|
78
|
+
return new Promise((resolveIdentity) => {
|
|
79
|
+
execFile("ps", ["-o", "lstart=", "-p", String(pid)], { env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, timeout: 2_000 }, (error, stdout) => {
|
|
80
|
+
const started = error ? "" : stdout.trim().replace(/\s+/g, " ");
|
|
81
|
+
resolveIdentity(started ? `ps-lstart:${started}` : undefined);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
let ownStartIdentity;
|
|
86
|
+
/**
|
|
87
|
+
* A lock is stale only when we can show its holder is gone, never merely
|
|
88
|
+
* because it is old. Liveness is authoritative whenever the lock carries a
|
|
89
|
+
* usable pid: a holder that is alive but slow (age past `staleMs`) is NOT
|
|
90
|
+
* stale — age alone used to be enough to break it, which let a live-but-slow
|
|
91
|
+
* holder's lock be removed out from under it (kontourai/survey#281 review:
|
|
92
|
+
* 135 double-hold events in 60 rounds of the reviewer's stress test). We
|
|
93
|
+
* deliberately do not add a hard age ceiling that overrides a live pid: with
|
|
94
|
+
* the default timeoutMs (10s) well under the default staleMs (30s), a waiter
|
|
95
|
+
* simply times out with {@link ReviewSessionFileLockTimeoutError} against a
|
|
96
|
+
* live holder that never releases in time, which is a loud, safe failure
|
|
97
|
+
* mode rather than a silent double-hold. A genuinely wedged holder (hung
|
|
98
|
+
* forever) requires an operator to remove the lock file by hand; that is the
|
|
99
|
+
* accepted cost of "never remove a live lock" actually holding.
|
|
100
|
+
*
|
|
101
|
+
* The age rule is kept only as the fallback for a lock we cannot judge by
|
|
102
|
+
* liveness: no pid field (unknown-PID); a live pid whose current start time
|
|
103
|
+
* differs from the one the holder recorded, i.e. the holder died and its pid
|
|
104
|
+
* was reused by an unrelated process (kontourai/survey#298); or, since the lock carries no host
|
|
105
|
+
* identity, a pid from another host/container sharing this volume (`isPidAlive`
|
|
106
|
+
* is meaningless there, in either direction — the residual this repo has
|
|
107
|
+
* always accepted for that case).
|
|
108
|
+
*/
|
|
109
|
+
async function lockIsStale(lockPath, staleMs) {
|
|
110
|
+
try {
|
|
111
|
+
const info = await stat(lockPath);
|
|
112
|
+
const age = Date.now() - info.mtimeMs;
|
|
113
|
+
const raw = await readFile(lockPath, "utf8");
|
|
114
|
+
if (raw.trim() === "")
|
|
115
|
+
return age > EMPTY_LOCK_STALE_MS;
|
|
116
|
+
const holder = JSON.parse(raw);
|
|
117
|
+
if (typeof holder.pid === "number") {
|
|
118
|
+
if (!isPidAlive(holder.pid))
|
|
119
|
+
return true;
|
|
120
|
+
if (typeof holder.startIdentity !== "string")
|
|
121
|
+
return false;
|
|
122
|
+
const current = await processStartIdentity(holder.pid);
|
|
123
|
+
// Unknown now (or never recorded): trust liveness, as before.
|
|
124
|
+
if (current === undefined || current === holder.startIdentity)
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
return age > staleMs;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Vanished (released) or half-written by a live acquirer: not stale.
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Remove a stale lock without ever removing a live one.
|
|
136
|
+
*
|
|
137
|
+
* Breakers serialize on a separate `<lock>.break` mutex and re-judge the lock
|
|
138
|
+
* inside it. While a breaker holds the mutex, the lock file can only change if
|
|
139
|
+
* its owner releases it, and {@link lockIsStale} only calls a lock stale once
|
|
140
|
+
* its holder is provably dead (or, lacking a usable pid, sufficiently old), so
|
|
141
|
+
* the file the breaker removes is the one it judged stale. The earlier
|
|
142
|
+
* rename-aside scheme let two waiters that both judged the same lock stale
|
|
143
|
+
* each remove it, the second removing the first's fresh lock
|
|
144
|
+
* (kontourai/survey#281 review). A holder that is merely alive-but-slow is
|
|
145
|
+
* never broken, by construction, no matter its age (see {@link lockIsStale}).
|
|
146
|
+
* Residual (accepted): a breaker that dies inside the few-millisecond break
|
|
147
|
+
* section leaves a mutex that is removed after BREAK_MUTEX_STALE_MS, and pid
|
|
148
|
+
* liveness means nothing across hosts or containers that share the session
|
|
149
|
+
* volume (the age rule is the only signal available there, and it is a
|
|
150
|
+
* pre-existing limitation, not one this fix introduces).
|
|
151
|
+
*/
|
|
152
|
+
async function breakStaleLock(lockPath, staleMs) {
|
|
153
|
+
const breakPath = `${lockPath}.break`;
|
|
154
|
+
try {
|
|
155
|
+
await (await open(breakPath, "wx")).close();
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
if (error.code !== "EEXIST")
|
|
159
|
+
throw error;
|
|
160
|
+
try {
|
|
161
|
+
if (Date.now() - (await stat(breakPath)).mtimeMs > BREAK_MUTEX_STALE_MS) {
|
|
162
|
+
await rm(breakPath, { force: true });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
// Released meanwhile.
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
if (await lockIsStale(lockPath, staleMs)) {
|
|
172
|
+
await rm(lockPath, { force: true });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
await rm(breakPath, { force: true });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Acquire the exclusive session lock. Resolves with a release function.
|
|
181
|
+
* Exported so tests can hold the lock and observe that writers wait for it.
|
|
182
|
+
*/
|
|
183
|
+
export async function acquireReviewSessionFileLock(sessionPath, options = {}) {
|
|
184
|
+
const lockPath = reviewSessionLockPath(sessionPath);
|
|
185
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
186
|
+
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
187
|
+
const deadline = Date.now() + timeoutMs;
|
|
188
|
+
const token = randomUUID();
|
|
189
|
+
ownStartIdentity ??= processStartIdentity(process.pid);
|
|
190
|
+
const startIdentity = await ownStartIdentity;
|
|
191
|
+
for (;;) {
|
|
192
|
+
try {
|
|
193
|
+
const handle = await open(lockPath, "wx");
|
|
194
|
+
try {
|
|
195
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, ...(startIdentity ? { startIdentity } : {}), token, acquiredAt: new Date().toISOString() }));
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
await handle.close();
|
|
199
|
+
}
|
|
200
|
+
return async () => {
|
|
201
|
+
// Only remove the lock if it is still ours (a stale-lock breaker may
|
|
202
|
+
// have replaced it after we were presumed dead).
|
|
203
|
+
try {
|
|
204
|
+
const holder = JSON.parse(await readFile(lockPath, "utf8"));
|
|
205
|
+
if (holder.token === token)
|
|
206
|
+
await rm(lockPath, { force: true });
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// Already gone.
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
if (error.code !== "EEXIST")
|
|
215
|
+
throw error;
|
|
216
|
+
}
|
|
217
|
+
if (await lockIsStale(lockPath, staleMs)) {
|
|
218
|
+
await breakStaleLock(lockPath, staleMs);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (Date.now() >= deadline) {
|
|
222
|
+
throw new ReviewSessionFileLockTimeoutError(lockPath, timeoutMs);
|
|
223
|
+
}
|
|
224
|
+
const delay = RETRY_MIN_MS + Math.floor(Math.random() * (RETRY_MAX_MS - RETRY_MIN_MS));
|
|
225
|
+
await new Promise((resolveDelay) => setTimeout(resolveDelay, delay));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function tempFilePattern(sessionPath) {
|
|
229
|
+
const escaped = basename(sessionPath).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
230
|
+
return new RegExp(`^${escaped}\\.\\d+\\.[0-9a-f-]{36}\\.tmp$`);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Remove temp files a crashed writer left between write and rename. Called
|
|
234
|
+
* only while holding the lock, when no other writer can have a temp file in
|
|
235
|
+
* flight, so every match is an orphan.
|
|
236
|
+
*/
|
|
237
|
+
async function removeOrphanTempFiles(sessionPath) {
|
|
238
|
+
const pattern = tempFilePattern(sessionPath);
|
|
239
|
+
let entries;
|
|
240
|
+
try {
|
|
241
|
+
entries = await readdir(dirname(sessionPath));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
await Promise.all(entries.filter((entry) => pattern.test(entry)).map((entry) => rm(join(dirname(sessionPath), entry), { force: true })));
|
|
247
|
+
}
|
|
248
|
+
async function writeReviewSessionFileAtomic(sessionPath, content) {
|
|
249
|
+
const tmp = `${sessionPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
250
|
+
try {
|
|
251
|
+
const handle = await open(tmp, "wx");
|
|
252
|
+
try {
|
|
253
|
+
await handle.writeFile(JSON.stringify(content, null, 2), "utf8");
|
|
254
|
+
// Durable before it becomes visible: a crash after the rename must not
|
|
255
|
+
// leave a renamed but empty or partial session file.
|
|
256
|
+
await handle.sync();
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
await handle.close();
|
|
260
|
+
}
|
|
261
|
+
await rename(tmp, sessionPath);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
await rm(tmp, { force: true });
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Locked read-modify-write of a session file. `mutate` receives the content
|
|
270
|
+
* read inside the lock and returns the content to write, or `undefined` to
|
|
271
|
+
* leave the file untouched. Whatever `mutate` throws propagates after the lock
|
|
272
|
+
* is released.
|
|
273
|
+
*/
|
|
274
|
+
export async function updateReviewSessionFile(sessionPath, mutate, options = {}) {
|
|
275
|
+
const release = await acquireReviewSessionFileLock(sessionPath, options);
|
|
276
|
+
try {
|
|
277
|
+
await removeOrphanTempFiles(sessionPath);
|
|
278
|
+
const current = await readReviewSessionFile(sessionPath);
|
|
279
|
+
const { next, result } = await mutate(current);
|
|
280
|
+
if (next !== undefined) {
|
|
281
|
+
await writeReviewSessionFileAtomic(sessionPath, next);
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
await release();
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* The session name the stored log is recorded under: the name its events
|
|
291
|
+
* already carry, else the stored ReviewSession's name, else `fallback` (the
|
|
292
|
+
* writer's own default). Appended events are renamed into it so one log never mixes names.
|
|
293
|
+
*/
|
|
294
|
+
export function storedReviewSessionName(content, fallback = defaultReviewSessionName) {
|
|
295
|
+
const fromEvents = content.events[0]?.spec.sessionName;
|
|
296
|
+
if (fromEvents)
|
|
297
|
+
return fromEvents;
|
|
298
|
+
const session = content.session;
|
|
299
|
+
return typeof session?.metadata?.name === "string" ? session.metadata.name : fallback;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Append events to the stored log, renumbering them after the stored events
|
|
303
|
+
* and renaming them into the stored session. The stored log is never
|
|
304
|
+
* rewritten, so decision reversals and note changes stay on record.
|
|
305
|
+
*/
|
|
306
|
+
export function appendReviewSessionEvents(content, appended) {
|
|
307
|
+
const sessionName = storedReviewSessionName(content);
|
|
308
|
+
const start = content.events.length;
|
|
309
|
+
const renumbered = [...appended]
|
|
310
|
+
.sort((left, right) => left.spec.sequence - right.spec.sequence)
|
|
311
|
+
.map((event, index) => {
|
|
312
|
+
const sequence = start + index + 1;
|
|
313
|
+
return {
|
|
314
|
+
...event,
|
|
315
|
+
metadata: { ...event.metadata, name: `${sessionName}-${String(sequence).padStart(4, "0")}-${event.spec.eventType}` },
|
|
316
|
+
spec: { ...event.spec, sessionName, sequence },
|
|
317
|
+
};
|
|
318
|
+
});
|
|
319
|
+
return [...content.events, ...renumbered];
|
|
320
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ReviewItem, ReviewValueDescriptor } from "../review-resource.js";
|
|
2
|
+
/**
|
|
3
|
+
* Result of checking an inline edit (`data.workbenchEditedValue` on an
|
|
4
|
+
* `accept-proposed` decision event) against the ReviewItem it edits.
|
|
5
|
+
*
|
|
6
|
+
* - `ok: true` — the edit is allowed; `value` is the value to store. When
|
|
7
|
+
* `convertedFromText` is true, the stored edit was descriptor-typed text
|
|
8
|
+
* written by an older workbench (for example `"42"` on a number field) and
|
|
9
|
+
* `value` is its typed form (`42`).
|
|
10
|
+
* - `ok: false` — the edit must be refused: the item is not editable, or the
|
|
11
|
+
* value does not satisfy the item's `valueDescriptor`.
|
|
12
|
+
*/
|
|
13
|
+
export type EditedValueCheck = {
|
|
14
|
+
readonly ok: true;
|
|
15
|
+
readonly value: unknown;
|
|
16
|
+
readonly convertedFromText: boolean;
|
|
17
|
+
} | {
|
|
18
|
+
readonly ok: false;
|
|
19
|
+
readonly code: "edited-value-not-editable" | "edited-value-type-mismatch";
|
|
20
|
+
readonly message: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* True for a `YYYY-MM-DD` string naming a real calendar day. The Y/M/D must
|
|
24
|
+
* round-trip through a UTC date, so an impossible day such as `2026-02-31`
|
|
25
|
+
* (which `Date.parse` silently rolls over to March) is refused.
|
|
26
|
+
*/
|
|
27
|
+
export declare function isIsoCalendarDate(value: string): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Parses number-field editor text, or returns `undefined` when it is not a
|
|
30
|
+
* plain decimal or would not survive storage as a JSON number exactly.
|
|
31
|
+
*
|
|
32
|
+
* Integer-only text (no `.` and no exponent) is checked by
|
|
33
|
+
* `Number.isSafeInteger` instead of the digit count: every safe integer
|
|
34
|
+
* (|value| ≤ 2^53−1, i.e. up to 9007199254740991, 16 digits) round-trips
|
|
35
|
+
* through a JSON number exactly, so the 15-significant-digit rule would
|
|
36
|
+
* wrongly refuse `9007199254740991` while accepting some 15-digit values
|
|
37
|
+
* that are actually less precise. `9007199254740993` is refused: it is
|
|
38
|
+
* outside the safe range and `Number(...)` rounds it to `9007199254740992`,
|
|
39
|
+
* a different integer than the text named.
|
|
40
|
+
*
|
|
41
|
+
* A fractional value (has `.` or an exponent) keeps the 15-significant-digit
|
|
42
|
+
* rule: `Number.isSafeInteger` does not apply to it, and IEEE 754 doubles
|
|
43
|
+
* only guarantee exactness up to 15 significant decimal digits, so anything
|
|
44
|
+
* longer is refused rather than silently rounded. A value that overflows to
|
|
45
|
+
* Infinity is refused. `-0` is stored as `0` (JSON has no negative zero).
|
|
46
|
+
*/
|
|
47
|
+
export declare function parsePlainDecimal(text: string): number | undefined;
|
|
48
|
+
/**
|
|
49
|
+
* Converts a reviewer's editor text to the JSON type the item's descriptor
|
|
50
|
+
* declares, using the same parsing rules the workbench's `validateProposedValue`
|
|
51
|
+
* checks. Returns `undefined` when the text does not parse for that type. With
|
|
52
|
+
* no descriptor, or a type that has no single-line form (string, array, object),
|
|
53
|
+
* the text is returned unchanged.
|
|
54
|
+
*/
|
|
55
|
+
export declare function editedValueFromEditorText(descriptor: ReviewValueDescriptor | undefined, text: string): unknown;
|
|
56
|
+
/**
|
|
57
|
+
* Checks an inline edit carried by an `accept-proposed` decision against the
|
|
58
|
+
* item's `editable` flag and `valueDescriptor`. This is the server-side
|
|
59
|
+
* counterpart of the browser editor's validation (kontourai/survey#278): the
|
|
60
|
+
* apply boundary must not trust an edit the workbench would never have let a
|
|
61
|
+
* reviewer make.
|
|
62
|
+
*
|
|
63
|
+
* Legacy sessions: workbenches before this check stored every edit as the
|
|
64
|
+
* editor's text, so a number or boolean edit arrives as `"42"` / `"true"`. Such
|
|
65
|
+
* text (and date/enum text with surrounding whitespace, which the old editor
|
|
66
|
+
* accepted and stored untrimmed) is converted to its typed value when it parses
|
|
67
|
+
* cleanly under the workbench's own rules (`convertedFromText: true`, reported as a warning by the
|
|
68
|
+
* apply derivation); text that does not parse is refused.
|
|
69
|
+
*/
|
|
70
|
+
export declare function checkEditedValueForItem(item: ReviewItem, value: unknown): EditedValueCheck;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
const isoDatePattern = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2
|
+
/**
|
|
3
|
+
* True for a `YYYY-MM-DD` string naming a real calendar day. The Y/M/D must
|
|
4
|
+
* round-trip through a UTC date, so an impossible day such as `2026-02-31`
|
|
5
|
+
* (which `Date.parse` silently rolls over to March) is refused.
|
|
6
|
+
*/
|
|
7
|
+
export function isIsoCalendarDate(value) {
|
|
8
|
+
const match = isoDatePattern.exec(value);
|
|
9
|
+
if (!match)
|
|
10
|
+
return false;
|
|
11
|
+
const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
12
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
13
|
+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
|
14
|
+
}
|
|
15
|
+
// Plain decimal only: optional minus, no leading zeros, optional fraction and
|
|
16
|
+
// exponent. Refuses hex/binary/octal forms, "Infinity", "+1", ".5" and "007".
|
|
17
|
+
const plainDecimalPattern = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?$/;
|
|
18
|
+
/** A JSON number keeps at most 15 significant decimal digits exactly (IEEE 754 double). */
|
|
19
|
+
const maxSignificantDigits = 15;
|
|
20
|
+
/**
|
|
21
|
+
* Parses number-field editor text, or returns `undefined` when it is not a
|
|
22
|
+
* plain decimal or would not survive storage as a JSON number exactly.
|
|
23
|
+
*
|
|
24
|
+
* Integer-only text (no `.` and no exponent) is checked by
|
|
25
|
+
* `Number.isSafeInteger` instead of the digit count: every safe integer
|
|
26
|
+
* (|value| ≤ 2^53−1, i.e. up to 9007199254740991, 16 digits) round-trips
|
|
27
|
+
* through a JSON number exactly, so the 15-significant-digit rule would
|
|
28
|
+
* wrongly refuse `9007199254740991` while accepting some 15-digit values
|
|
29
|
+
* that are actually less precise. `9007199254740993` is refused: it is
|
|
30
|
+
* outside the safe range and `Number(...)` rounds it to `9007199254740992`,
|
|
31
|
+
* a different integer than the text named.
|
|
32
|
+
*
|
|
33
|
+
* A fractional value (has `.` or an exponent) keeps the 15-significant-digit
|
|
34
|
+
* rule: `Number.isSafeInteger` does not apply to it, and IEEE 754 doubles
|
|
35
|
+
* only guarantee exactness up to 15 significant decimal digits, so anything
|
|
36
|
+
* longer is refused rather than silently rounded. A value that overflows to
|
|
37
|
+
* Infinity is refused. `-0` is stored as `0` (JSON has no negative zero).
|
|
38
|
+
*/
|
|
39
|
+
export function parsePlainDecimal(text) {
|
|
40
|
+
const match = plainDecimalPattern.exec(text);
|
|
41
|
+
if (!match)
|
|
42
|
+
return undefined;
|
|
43
|
+
const isIntegerOnly = match[2] === undefined && match[3] === undefined;
|
|
44
|
+
if (isIntegerOnly) {
|
|
45
|
+
const parsed = Number(text);
|
|
46
|
+
if (!Number.isSafeInteger(parsed))
|
|
47
|
+
return undefined;
|
|
48
|
+
return parsed === 0 ? 0 : parsed;
|
|
49
|
+
}
|
|
50
|
+
const digits = `${match[1]}${(match[2] ?? "").slice(1)}`.replace(/^0+/, "").replace(/0+$/, "");
|
|
51
|
+
if (digits.length > maxSignificantDigits)
|
|
52
|
+
return undefined;
|
|
53
|
+
const parsed = Number(text);
|
|
54
|
+
if (!Number.isFinite(parsed))
|
|
55
|
+
return undefined;
|
|
56
|
+
return parsed === 0 ? 0 : parsed;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Converts a reviewer's editor text to the JSON type the item's descriptor
|
|
60
|
+
* declares, using the same parsing rules the workbench's `validateProposedValue`
|
|
61
|
+
* checks. Returns `undefined` when the text does not parse for that type. With
|
|
62
|
+
* no descriptor, or a type that has no single-line form (string, array, object),
|
|
63
|
+
* the text is returned unchanged.
|
|
64
|
+
*/
|
|
65
|
+
export function editedValueFromEditorText(descriptor, text) {
|
|
66
|
+
if (!descriptor)
|
|
67
|
+
return text;
|
|
68
|
+
const trimmed = text.trim();
|
|
69
|
+
switch (descriptor.type) {
|
|
70
|
+
case "number":
|
|
71
|
+
return parsePlainDecimal(trimmed);
|
|
72
|
+
case "boolean":
|
|
73
|
+
return trimmed === "true" ? true : trimmed === "false" ? false : undefined;
|
|
74
|
+
case "date":
|
|
75
|
+
return isIsoCalendarDate(trimmed) ? trimmed : undefined;
|
|
76
|
+
case "enum": {
|
|
77
|
+
const allowed = descriptor.enumValues ?? [];
|
|
78
|
+
return allowed.length === 0 || allowed.includes(trimmed) ? trimmed : undefined;
|
|
79
|
+
}
|
|
80
|
+
default:
|
|
81
|
+
return text;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function valueMatchesDescriptor(descriptor, value) {
|
|
85
|
+
switch (descriptor.type) {
|
|
86
|
+
case "number":
|
|
87
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
88
|
+
case "boolean":
|
|
89
|
+
return typeof value === "boolean";
|
|
90
|
+
case "date":
|
|
91
|
+
return typeof value === "string" && isIsoCalendarDate(value);
|
|
92
|
+
case "enum": {
|
|
93
|
+
if (typeof value !== "string")
|
|
94
|
+
return false;
|
|
95
|
+
const allowed = descriptor.enumValues ?? [];
|
|
96
|
+
return allowed.length === 0 || allowed.includes(value);
|
|
97
|
+
}
|
|
98
|
+
case "string":
|
|
99
|
+
return typeof value === "string";
|
|
100
|
+
default:
|
|
101
|
+
// array/object: the workbench has no typed editor for these, so Survey
|
|
102
|
+
// declares no constraint on the edit's shape.
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Checks an inline edit carried by an `accept-proposed` decision against the
|
|
108
|
+
* item's `editable` flag and `valueDescriptor`. This is the server-side
|
|
109
|
+
* counterpart of the browser editor's validation (kontourai/survey#278): the
|
|
110
|
+
* apply boundary must not trust an edit the workbench would never have let a
|
|
111
|
+
* reviewer make.
|
|
112
|
+
*
|
|
113
|
+
* Legacy sessions: workbenches before this check stored every edit as the
|
|
114
|
+
* editor's text, so a number or boolean edit arrives as `"42"` / `"true"`. Such
|
|
115
|
+
* text (and date/enum text with surrounding whitespace, which the old editor
|
|
116
|
+
* accepted and stored untrimmed) is converted to its typed value when it parses
|
|
117
|
+
* cleanly under the workbench's own rules (`convertedFromText: true`, reported as a warning by the
|
|
118
|
+
* apply derivation); text that does not parse is refused.
|
|
119
|
+
*/
|
|
120
|
+
export function checkEditedValueForItem(item, value) {
|
|
121
|
+
const itemName = item.metadata.name;
|
|
122
|
+
if (item.spec.editable === false) {
|
|
123
|
+
return {
|
|
124
|
+
ok: false,
|
|
125
|
+
code: "edited-value-not-editable",
|
|
126
|
+
message: `ReviewItem ${itemName} is not editable (spec.editable: false), but the decision carries an edited value.`,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const descriptor = item.spec.valueDescriptor;
|
|
130
|
+
if (!descriptor || valueMatchesDescriptor(descriptor, value)) {
|
|
131
|
+
return { ok: true, value, convertedFromText: false };
|
|
132
|
+
}
|
|
133
|
+
if (typeof value === "string") {
|
|
134
|
+
const converted = editedValueFromEditorText(descriptor, value);
|
|
135
|
+
if (converted !== undefined) {
|
|
136
|
+
return { ok: true, value: converted, convertedFromText: true };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const allowed = descriptor.type === "enum" && descriptor.enumValues?.length
|
|
140
|
+
? ` (one of: ${descriptor.enumValues.join(", ")})`
|
|
141
|
+
: "";
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
code: "edited-value-type-mismatch",
|
|
145
|
+
message: `ReviewItem ${itemName} declares value type ${descriptor.type}${allowed}, but the decision carries edited value ${JSON.stringify(value)}.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReviewCandidate, type ReviewItem } from "../review-resource.js";
|
|
2
|
+
import type { InterpretationAnswerImpact, InterpretationReadingKind } from "../types.js";
|
|
2
3
|
import { type ReviewWorkbenchResult } from "./review-workbench.js";
|
|
3
4
|
export interface ReviewPresentationAdapter {
|
|
4
5
|
readonly labelForTarget?: (target: string, context: ReviewItemPresentationContext) => string | undefined;
|
|
@@ -61,6 +62,49 @@ export interface ReviewResultPresentation {
|
|
|
61
62
|
readonly reviewItemLink?: ReviewPresentationLink;
|
|
62
63
|
readonly traceRefs: readonly ReviewTraceRef[];
|
|
63
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Structural input for {@link buildInterpretationReadingPresentation}: either
|
|
67
|
+
* a Survey `Interpretation` record (`id`) or the entry Survey projects onto a
|
|
68
|
+
* claim at `metadata.survey.interpretations[]` (`interpretationId`). Kind and
|
|
69
|
+
* impact arrive as plain strings when read back from projected metadata.
|
|
70
|
+
*/
|
|
71
|
+
export interface InterpretationReadingSource {
|
|
72
|
+
readonly id?: string;
|
|
73
|
+
readonly interpretationId?: string;
|
|
74
|
+
readonly readingKind?: string;
|
|
75
|
+
readonly answerImpact?: string;
|
|
76
|
+
readonly ruleLocator: string;
|
|
77
|
+
readonly reading: string;
|
|
78
|
+
readonly actor: string;
|
|
79
|
+
readonly recordedAt: string;
|
|
80
|
+
}
|
|
81
|
+
export interface InterpretationReadingPresentation {
|
|
82
|
+
readonly interpretationId: string;
|
|
83
|
+
readonly readingKind: InterpretationReadingKind;
|
|
84
|
+
readonly kindLabel: string;
|
|
85
|
+
readonly answerImpact?: InterpretationAnswerImpact;
|
|
86
|
+
readonly answerImpactLabel?: string;
|
|
87
|
+
readonly reading: string;
|
|
88
|
+
readonly actor: string;
|
|
89
|
+
readonly recordedAt: string;
|
|
90
|
+
readonly ruleLocator: string;
|
|
91
|
+
/**
|
|
92
|
+
* Always `"authored-judgment"`. This is DERIVED from the record type, not a
|
|
93
|
+
* stored flag: every Interpretation reading is a producer-authored reading
|
|
94
|
+
* by contract (CONTEXT.md "Interpretation Record"), never a machine-observed
|
|
95
|
+
* fact. Renderers must present readings under this marking, visually
|
|
96
|
+
* distinct from machine-observed values — the StatementBadge / ADR 0003 §4
|
|
97
|
+
* discipline (blending the two is the defect class of #247).
|
|
98
|
+
*/
|
|
99
|
+
readonly provenance: "authored-judgment";
|
|
100
|
+
readonly provenanceLabel: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Presents one interpretation reading as authored judgment. Fails closed on
|
|
104
|
+
* unknown reading-kind / answer-impact vocabulary rather than rendering an
|
|
105
|
+
* authored record under a label nothing derived.
|
|
106
|
+
*/
|
|
107
|
+
export declare function buildInterpretationReadingPresentation(source: InterpretationReadingSource): InterpretationReadingPresentation;
|
|
64
108
|
export declare function buildReviewItemPresentation(item: ReviewItem, adapter?: ReviewPresentationAdapter): ReviewItemPresentation;
|
|
65
109
|
export declare function buildReviewCandidatePresentation(item: ReviewItem, candidate: ReviewCandidate, adapter?: ReviewPresentationAdapter, targetLabel?: string): ReviewCandidatePresentation;
|
|
66
110
|
export declare function buildReviewResultPresentation(result: ReviewWorkbenchResult, item: ReviewItem | undefined, adapter?: ReviewPresentationAdapter): ReviewResultPresentation;
|
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
import { findSoleCandidateById } from "../review-resource.js";
|
|
2
2
|
import { formatValue } from "./review-surface-preview.js";
|
|
3
|
+
const INTERPRETATION_KIND_LABELS = {
|
|
4
|
+
"policy-standard": "Policy-standard reading",
|
|
5
|
+
gleaned: "Gleaned from results",
|
|
6
|
+
answerImpact: "Answer impact",
|
|
7
|
+
};
|
|
8
|
+
const ANSWER_IMPACT_LABELS = {
|
|
9
|
+
supported: "Supported the answer",
|
|
10
|
+
narrowed: "Narrowed the answer",
|
|
11
|
+
"accepted-risk": "Accepted as a risk",
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Presents one interpretation reading as authored judgment. Fails closed on
|
|
15
|
+
* unknown reading-kind / answer-impact vocabulary rather than rendering an
|
|
16
|
+
* authored record under a label nothing derived.
|
|
17
|
+
*/
|
|
18
|
+
export function buildInterpretationReadingPresentation(source) {
|
|
19
|
+
const interpretationId = source.interpretationId ?? source.id;
|
|
20
|
+
if (!interpretationId) {
|
|
21
|
+
throw new Error("Interpretation reading presentation requires an id or interpretationId.");
|
|
22
|
+
}
|
|
23
|
+
const readingKind = (source.readingKind ?? "policy-standard");
|
|
24
|
+
const kindLabel = INTERPRETATION_KIND_LABELS[readingKind];
|
|
25
|
+
if (!kindLabel) {
|
|
26
|
+
throw new Error(`Interpretation ${interpretationId} has unknown readingKind ${String(source.readingKind)}`);
|
|
27
|
+
}
|
|
28
|
+
const answerImpact = source.answerImpact;
|
|
29
|
+
const answerImpactLabel = answerImpact === undefined ? undefined : ANSWER_IMPACT_LABELS[answerImpact];
|
|
30
|
+
if (answerImpact !== undefined && !answerImpactLabel) {
|
|
31
|
+
throw new Error(`Interpretation ${interpretationId} has unknown answerImpact ${String(source.answerImpact)}`);
|
|
32
|
+
}
|
|
33
|
+
if (readingKind === "answerImpact" && answerImpact === undefined) {
|
|
34
|
+
throw new Error(`Interpretation ${interpretationId} readingKind answerImpact requires an answerImpact value`);
|
|
35
|
+
}
|
|
36
|
+
if (readingKind !== "answerImpact" && answerImpact !== undefined) {
|
|
37
|
+
throw new Error(`Interpretation ${interpretationId} sets answerImpact but readingKind is ${readingKind}`);
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
interpretationId,
|
|
41
|
+
readingKind,
|
|
42
|
+
kindLabel,
|
|
43
|
+
...(answerImpact !== undefined ? { answerImpact, answerImpactLabel } : {}),
|
|
44
|
+
reading: source.reading,
|
|
45
|
+
actor: source.actor,
|
|
46
|
+
recordedAt: source.recordedAt,
|
|
47
|
+
ruleLocator: source.ruleLocator,
|
|
48
|
+
provenance: "authored-judgment",
|
|
49
|
+
provenanceLabel: "Authored judgment",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
3
52
|
export function buildReviewItemPresentation(item, adapter = {}) {
|
|
4
53
|
const context = { item };
|
|
5
54
|
const targetLabel = adapter.labelForTarget?.(item.spec.target, context) ?? humanizeIdentifier(item.spec.target);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { publicDirectoryReviewItemExample, reviewWorkbenchQueueExamples } from "./review-workbench-data.js";
|
|
2
2
|
import { assertReviewResolutionConsistency } from "../producer-discipline.js";
|
|
3
|
+
import { checkEditedValueForItem } from "./edited-value.js";
|
|
3
4
|
import { assertSoleCandidateId, reviewResourceApiVersion, } from "../../src/review-resource.js";
|
|
4
5
|
export const reviewWorkbenchSessionStorageKey = "kontourai.survey.review-workbench.session-events.v1";
|
|
5
6
|
export const defaultReviewSessionName = "review-workbench-session";
|
|
@@ -368,7 +369,13 @@ export function replayReviewSessionEvents(startState, events) {
|
|
|
368
369
|
const editedValuesByItemName = { ...session.editedValuesByItemName };
|
|
369
370
|
const attemptEvidenceIdsByItemName = { ...session.attemptEvidenceIdsByItemName };
|
|
370
371
|
if (decision === "accept-proposed" && editedValue !== undefined) {
|
|
371
|
-
|
|
372
|
+
// Legacy sessions stored typed edits as editor text ("42"); store the
|
|
373
|
+
// descriptor-typed value so effectiveValue is 42. An edit the item does
|
|
374
|
+
// not allow is left as carried: validated replay refuses it before this
|
|
375
|
+
// point (kontourai/survey#278).
|
|
376
|
+
const item = session.items.find((entry) => entry.metadata.name === itemName);
|
|
377
|
+
const check = item ? checkEditedValueForItem(item, editedValue) : undefined;
|
|
378
|
+
editedValuesByItemName[itemName] = check?.ok ? check.value : editedValue;
|
|
372
379
|
}
|
|
373
380
|
else {
|
|
374
381
|
delete editedValuesByItemName[itemName];
|