@kontourai/lookout 0.5.2 → 0.7.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 +69 -5
- package/dist/src/canonical-json.d.ts +8 -0
- package/dist/src/canonical-json.js +36 -0
- package/dist/src/capture-decoding.d.ts +12 -0
- package/dist/src/capture-decoding.js +18 -0
- package/dist/src/check-result.d.ts +1 -1
- package/dist/src/check-runner.d.ts +20 -0
- package/dist/src/check-runner.js +98 -2
- package/dist/src/drift-emission.d.ts +2 -2
- package/dist/src/drift-emission.js +3 -2
- package/dist/src/index.d.ts +4 -4
- package/dist/src/index.js +1 -1
- package/dist/src/observation-admission.d.ts +2 -2
- package/dist/src/observation-store.d.ts +11 -2
- package/dist/src/observation-store.js +17 -9
- package/dist/src/observe-extract-diff.d.ts +26 -0
- package/dist/src/observe-extract-diff.js +52 -3
- package/dist/src/proposal-diff.js +2 -1
- package/dist/src/snapshot-store.d.ts +10 -2
- package/dist/src/snapshot-store.js +5 -2
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -167,7 +167,7 @@ warnings.
|
|
|
167
167
|
| `kind` | When | Extra fields |
|
|
168
168
|
| --- | --- | --- |
|
|
169
169
|
| `unchanged-304` | A validator-backed conditional request returned `304`; zero body transfer; the prior snapshot is not re-persisted. | `snapshotRef` |
|
|
170
|
-
| `unchanged-hash` | A full body was fetched
|
|
170
|
+
| `unchanged-hash` | A full body was fetched, but its sha256 `bodyHash` equals the prior, **and** it came from the same resource URL — established by Lookout's own comparison. A byte-identical repeat of the prior capture is not persisted again (see below). | `priorSnapshotRef`, `currentSnapshotRef` |
|
|
171
171
|
| `changed` | The fresh body differs from the prior (`changeBasis: "hash"`), **or** it is the first successful observation (`changeBasis: "initial"`, `priorSnapshotRef: null`). | `priorSnapshotRef` (nullable), `currentSnapshotRef`, `changeBasis` |
|
|
172
172
|
| `error` | Any operational failure — contained so the runner never rejects. | `origin` (`forage` \| `lookout`), `error` |
|
|
173
173
|
|
|
@@ -179,17 +179,55 @@ snapshot is persisted as the new baseline. (The `unchanged-304` path is already
|
|
|
179
179
|
resource-scoped by Forage's validators, so only the hash path needs this
|
|
180
180
|
guard.)
|
|
181
181
|
|
|
182
|
+
A fetch that repeats the prior capture exactly is not appended to snapshot
|
|
183
|
+
history: same URL, status, body bytes, text decoding, redirects, render state,
|
|
184
|
+
and `etag` / `last-modified` validators, with only the fetch time differing. Its
|
|
185
|
+
`unchanged-hash` result names the stored capture as both `priorSnapshotRef` and
|
|
186
|
+
`currentSnapshotRef`, and `checkedAt` records the check. Other response headers
|
|
187
|
+
of the repeat, such as `Date`, are not kept. A stable source therefore adds no
|
|
188
|
+
records, however often it is checked. The text decoding is the encoding the
|
|
189
|
+
declared `Content-Type` charset resolves to, so the same bytes under another
|
|
190
|
+
charset (other text for an extractor) are a new capture, while `utf8` and
|
|
191
|
+
`utf-8` are the same decoding.
|
|
192
|
+
|
|
193
|
+
Changed captures still grow history. Pass `retention` to bound it with Forage's
|
|
194
|
+
`prune`: after each stored capture, the runner keeps the newest `keepLast`
|
|
195
|
+
snapshots, the latest, the prior capture its result names, and every reference
|
|
196
|
+
`cited(sourceId)` returns. Cite what your observations, review rounds, and
|
|
197
|
+
exported receipts still reference. If `cited` throws or returns anything but
|
|
198
|
+
snapshot references, nothing is pruned and the result carries a warning.
|
|
199
|
+
`cited` is read just before each prune, outside the store's lock, so record a
|
|
200
|
+
citation before handing its reference out.
|
|
201
|
+
|
|
202
|
+
```ts
|
|
203
|
+
const runner = createCheckRunner({
|
|
204
|
+
store,
|
|
205
|
+
retention: { keepLast: 20, cited: (sourceId) => loadCitedSnapshotRefs(sourceId) },
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**Upgrading to 0.7 (Forage 1.0).** New text captures hash their exact bytes.
|
|
210
|
+
A text page whose bytes are not plain UTF-8 (a non-UTF-8 charset, a byte-order
|
|
211
|
+
mark, or invalid UTF-8) therefore reports `changed` once, on its first check
|
|
212
|
+
after the upgrade, and is extracted again. Drift comes from proposal diffs, so
|
|
213
|
+
a page whose extracted content is identical emits nothing. Plain UTF-8 pages
|
|
214
|
+
keep their stored capture.
|
|
215
|
+
|
|
182
216
|
`error` results preserve provenance: `origin: "forage"` carries Forage's
|
|
183
217
|
discriminated `FetchError` verbatim (its `kind`, and `status` when present);
|
|
184
218
|
`origin: "lookout"` carries a typed `kind` — `prior-read`, `persistence`,
|
|
185
|
-
`dependency-contract`, or `unexpected`.
|
|
219
|
+
`history-full`, `dependency-contract`, or `unexpected`. `history-full` means the
|
|
220
|
+
source holds Forage's `maxHistoryFiles` records; with `retention` the runner
|
|
221
|
+
prunes once and retries before reporting it.
|
|
186
222
|
|
|
187
223
|
Snapshot references (`snapshotRef` / `priorSnapshotRef` / `currentSnapshotRef`)
|
|
188
224
|
are portable logical refs from Forage's `buildSnapshotSourceRef` — never
|
|
189
225
|
filesystem paths. Snapshots are stored via Forage's filesystem store, rooted by
|
|
190
226
|
default at `<cwd>/.kontourai/lookout/snapshots` (override with the CLI
|
|
191
227
|
`--snapshot-root` flag or by injecting a store in library use). Lookout adds no
|
|
192
|
-
custom filenames or
|
|
228
|
+
custom filenames or storage format; `createLookoutSnapshotStore(root, {
|
|
229
|
+
maxHistoryFiles })` passes Forage's per-source record ceiling through and
|
|
230
|
+
returns a store with Forage's `prune`. `resolveLookoutSnapshot()` replays one exact
|
|
193
231
|
reference through an injected store or Lookout snapshot root, authenticates its
|
|
194
232
|
body and replay metadata, and never fetches. References emitted before Forage's
|
|
195
233
|
replay-envelope digest remain resolvable with `integrity: "body-and-identity"`;
|
|
@@ -257,6 +295,15 @@ Hachure-evidence-shaped; a consumer or product lifts them into a Hachure
|
|
|
257
295
|
`TrustBundle` with `@kontourai/surface`'s `TrustBundleBuilder` when it wants
|
|
258
296
|
Surface projection — lookout itself authors nothing in the trust layer.
|
|
259
297
|
|
|
298
|
+
An observation id is the SHA-256 of the record's canonical JSON: object keys
|
|
299
|
+
and proposals in UTF-16 code-unit order, never the host locale's collation.
|
|
300
|
+
New records are `version: 2`. Records written before this change are
|
|
301
|
+
`version: 1`, whose digest used the writing host's locale collation. They
|
|
302
|
+
still load on a host whose locale collates their keys the same way, and the
|
|
303
|
+
next commit replaces a `version: 1` prior with a `version: 2` record. Under a
|
|
304
|
+
different locale, a `version: 1` record with non-ASCII or mixed-case keys or
|
|
305
|
+
values can read as `corrupt-state`.
|
|
306
|
+
|
|
260
307
|
### Verified proposal head witnesses
|
|
261
308
|
|
|
262
309
|
The concrete filesystem observation store additionally exposes
|
|
@@ -348,6 +395,8 @@ import { createObserveExtractDiff } from "@kontourai/lookout";
|
|
|
348
395
|
|
|
349
396
|
const composition = createObserveExtractDiff({
|
|
350
397
|
acquisition: { check: runner.check },
|
|
398
|
+
// The store acquisition persists to; used to compare captures by reference.
|
|
399
|
+
snapshots: store,
|
|
351
400
|
extraction: {
|
|
352
401
|
async extract({ source, snapshotRef }) {
|
|
353
402
|
// Resolve `snapshotRef`, prepare content, and invoke a caller-selected
|
|
@@ -360,6 +409,10 @@ const composition = createObserveExtractDiff({
|
|
|
360
409
|
// Caller-owned continuity and durable storage.
|
|
361
410
|
return saveObservation(observation);
|
|
362
411
|
},
|
|
412
|
+
async lastExtractedSnapshotRef(source) {
|
|
413
|
+
// The newest non-null extractedSnapshotRef(observation) you recorded.
|
|
414
|
+
return loadLastExtractedSnapshotRef(source.id);
|
|
415
|
+
},
|
|
363
416
|
},
|
|
364
417
|
});
|
|
365
418
|
|
|
@@ -367,7 +420,16 @@ const result = await composition.observe(source);
|
|
|
367
420
|
```
|
|
368
421
|
|
|
369
422
|
`unchanged-304` and `unchanged-hash` are recorded as `unchanged` and never call
|
|
370
|
-
the extraction capability, so they make zero preparation and provider calls
|
|
423
|
+
the extraction capability, so they make zero preparation and provider calls,
|
|
424
|
+
when the capture has the same URL, body hash, and text decoding as the
|
|
425
|
+
recorder's `lastExtractedSnapshotRef`. Both references are resolved through
|
|
426
|
+
`snapshots`; one that does not resolve (for example, pruned) counts as a
|
|
427
|
+
different capture and is extracted. That is the last snapshot an observation fully
|
|
428
|
+
handled: the current snapshot of a `completed`, `partial`, or `unchanged`
|
|
429
|
+
observation, as `extractedSnapshotRef(observation)` returns. Otherwise the
|
|
430
|
+
capture was persisted but never extracted (for example, a provider failed on the
|
|
431
|
+
check that first saw it), so it is extracted now against that baseline instead
|
|
432
|
+
of being reported as unchanged forever.
|
|
371
433
|
Changed observations retain source and snapshot references, Traverse's prepared
|
|
372
434
|
artifact identity, the proposal set, and the current/prior observation
|
|
373
435
|
identities returned by the recorder. `partial`, `provider-failure`, mixed
|
|
@@ -442,10 +504,12 @@ behavior.
|
|
|
442
504
|
## Development
|
|
443
505
|
|
|
444
506
|
```sh
|
|
445
|
-
|
|
507
|
+
pnpm install
|
|
446
508
|
npm run verify # content-boundary + decisions + typecheck + test + pack sanity
|
|
447
509
|
```
|
|
448
510
|
|
|
511
|
+
The pnpm version is pinned in `package.json` (`packageManager`). Dependency install scripts are blocked by default; the only packages allowed to run one are listed under `allowBuilds` in `pnpm-workspace.yaml`, pinned by version. Scripts are still run with `npm run …` — that only invokes `package.json` scripts and does not depend on which tool installed `node_modules`.
|
|
512
|
+
|
|
449
513
|
Individual gates: `npm test`, `npm run typecheck`, `npm run check:pack`,
|
|
450
514
|
`npm run check:decisions`, `npm run check:content-boundary`.
|
|
451
515
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Orders strings by UTF-16 code unit. Unlike `localeCompare`, it ignores the host locale. */
|
|
2
|
+
export declare function compareCodeUnits(left: string, right: string): number;
|
|
3
|
+
/**
|
|
4
|
+
* JSON text with every object's keys in code-unit order, written directly.
|
|
5
|
+
* Rebuilding an object and calling JSON.stringify cannot give this order,
|
|
6
|
+
* because JavaScript always lists integer-like keys ("9", "10") first.
|
|
7
|
+
*/
|
|
8
|
+
export declare function canonicalJson(value: unknown): string;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Orders strings by UTF-16 code unit. Unlike `localeCompare`, it ignores the host locale. */
|
|
2
|
+
export function compareCodeUnits(left, right) {
|
|
3
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
4
|
+
}
|
|
5
|
+
function encode(input, key) {
|
|
6
|
+
// Mirrors JSON.stringify apart from key order: toJSON is honoured (a Date
|
|
7
|
+
// becomes its ISO string); undefined, functions and symbols have no encoding
|
|
8
|
+
// (omitted from objects, null in arrays); array holes and non-finite numbers
|
|
9
|
+
// become null; bigint throws.
|
|
10
|
+
const value = input !== null && typeof input === "object" && typeof input.toJSON === "function"
|
|
11
|
+
? input.toJSON(key)
|
|
12
|
+
: input;
|
|
13
|
+
if (value === null || typeof value !== "object")
|
|
14
|
+
return JSON.stringify(value);
|
|
15
|
+
// Array.from visits holes; Array.prototype.map would skip them and write `[,1]`.
|
|
16
|
+
if (Array.isArray(value))
|
|
17
|
+
return `[${Array.from(value, (item, index) => encode(item, String(index)) ?? "null").join(",")}]`;
|
|
18
|
+
const members = [];
|
|
19
|
+
for (const key of Object.keys(value).sort(compareCodeUnits)) {
|
|
20
|
+
const item = encode(value[key], key);
|
|
21
|
+
if (item !== undefined)
|
|
22
|
+
members.push(`${JSON.stringify(key)}:${item}`);
|
|
23
|
+
}
|
|
24
|
+
return `{${members.join(",")}}`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* JSON text with every object's keys in code-unit order, written directly.
|
|
28
|
+
* Rebuilding an object and calling JSON.stringify cannot give this order,
|
|
29
|
+
* because JavaScript always lists integer-like keys ("9", "10") first.
|
|
30
|
+
*/
|
|
31
|
+
export function canonicalJson(value) {
|
|
32
|
+
const text = encode(value, "");
|
|
33
|
+
if (text === undefined)
|
|
34
|
+
throw new TypeError("Value has no JSON encoding");
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Snapshot } from "@kontourai/forage/fetch";
|
|
2
|
+
/**
|
|
3
|
+
* The encoding a text capture's body was decoded with, or `null` for a binary
|
|
4
|
+
* capture. Two captures with the same bytes but a different decoding give the
|
|
5
|
+
* extractor different text, so they are different captures.
|
|
6
|
+
*
|
|
7
|
+
* Text captures that carry `bytes` were decoded with their declared charset;
|
|
8
|
+
* the label is resolved the way Forage decodes it, so `utf8` and `utf-8` are
|
|
9
|
+
* the same decoding. Text captures without `bytes` (the earlier Forage record
|
|
10
|
+
* format, and rendered pages) were decoded or serialized as UTF-8.
|
|
11
|
+
*/
|
|
12
|
+
export declare function captureDecoding(snapshot: Snapshot): string | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { decodeTextBody } from "@kontourai/forage/fetch";
|
|
2
|
+
/**
|
|
3
|
+
* The encoding a text capture's body was decoded with, or `null` for a binary
|
|
4
|
+
* capture. Two captures with the same bytes but a different decoding give the
|
|
5
|
+
* extractor different text, so they are different captures.
|
|
6
|
+
*
|
|
7
|
+
* Text captures that carry `bytes` were decoded with their declared charset;
|
|
8
|
+
* the label is resolved the way Forage decodes it, so `utf8` and `utf-8` are
|
|
9
|
+
* the same decoding. Text captures without `bytes` (the earlier Forage record
|
|
10
|
+
* format, and rendered pages) were decoded or serialized as UTF-8.
|
|
11
|
+
*/
|
|
12
|
+
export function captureDecoding(snapshot) {
|
|
13
|
+
if (typeof snapshot.body !== "string")
|
|
14
|
+
return null;
|
|
15
|
+
if (snapshot.bytes === undefined)
|
|
16
|
+
return "utf-8";
|
|
17
|
+
return decodeTextBody(new Uint8Array(0), snapshot.declaredCharset ?? null).encoding;
|
|
18
|
+
}
|
|
@@ -20,7 +20,7 @@ export interface ChangedResult extends CheckResultCommon {
|
|
|
20
20
|
currentSnapshotRef: string;
|
|
21
21
|
changeBasis: "initial" | "hash";
|
|
22
22
|
}
|
|
23
|
-
export type LookoutErrorKind = "prior-read" | "persistence" | "dependency-contract" | "unexpected";
|
|
23
|
+
export type LookoutErrorKind = "prior-read" | "persistence" | "history-full" | "dependency-contract" | "unexpected";
|
|
24
24
|
export interface ErrorResult extends CheckResultCommon {
|
|
25
25
|
kind: "error";
|
|
26
26
|
origin: "forage" | "lookout";
|
|
@@ -3,8 +3,28 @@ import type { CheckResult } from "./check-result.js";
|
|
|
3
3
|
import type { ProviderResolver } from "./provider-resolution.js";
|
|
4
4
|
import type { LookoutSource } from "./registry.js";
|
|
5
5
|
export type FetchSource = (config: SourceConfig, options?: FetchSourceOptions) => Promise<FetchResult>;
|
|
6
|
+
/**
|
|
7
|
+
* Bounds each source's snapshot history with the store's `prune` capability.
|
|
8
|
+
* After every stored capture, all but the newest `keepLast` snapshots are
|
|
9
|
+
* removed, except the latest, the captures the result names, and every
|
|
10
|
+
* snapshot `cited` returns. `cited` is read just before the prune, outside the
|
|
11
|
+
* store's lock: a citation recorded after that read is not protected by that
|
|
12
|
+
* prune, so record a citation before handing its reference out.
|
|
13
|
+
*/
|
|
14
|
+
export interface SnapshotRetention {
|
|
15
|
+
/** Newest snapshots kept per source (a non-negative integer; the latest is always kept). */
|
|
16
|
+
readonly keepLast: number;
|
|
17
|
+
/**
|
|
18
|
+
* Snapshot references the caller still cites for this source: recorded
|
|
19
|
+
* observations, review rounds, exported receipts. Never pruned. When this
|
|
20
|
+
* throws or returns anything but canonical references, nothing is pruned.
|
|
21
|
+
*/
|
|
22
|
+
readonly cited: (sourceId: string) => Promise<readonly string[]> | readonly string[];
|
|
23
|
+
}
|
|
6
24
|
export interface CreateCheckRunnerOptions {
|
|
7
25
|
store: SnapshotStore;
|
|
26
|
+
/** Prune history after each stored capture. Without it, history is kept in full. */
|
|
27
|
+
retention?: SnapshotRetention;
|
|
8
28
|
fetchSource?: FetchSource;
|
|
9
29
|
fetchOptions?: Omit<FetchSourceOptions, "store">;
|
|
10
30
|
clock?: () => string;
|
package/dist/src/check-runner.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
|
-
import { buildSnapshotSourceRef, fetchSource as forageFetchSource, } from "@kontourai/forage/fetch";
|
|
2
|
+
import { buildSnapshotSourceRef, fetchSource as forageFetchSource, isSnapshotHistoryFullError, parseSnapshotSourceRef, } from "@kontourai/forage/fetch";
|
|
3
|
+
import { captureDecoding } from "./capture-decoding.js";
|
|
3
4
|
/**
|
|
4
5
|
* The egress policy for lookout's registered-source fetches. Registered source
|
|
5
6
|
* URLs are operator- / aggregator-supplied and not fully trusted, so a source
|
|
@@ -15,6 +16,40 @@ export function createCheckRunner(options) {
|
|
|
15
16
|
const fetchImpl = options.fetchSource ?? forageFetchSource;
|
|
16
17
|
const clock = options.clock ?? (() => new Date().toISOString());
|
|
17
18
|
const fetchOptions = { ...options.fetchOptions };
|
|
19
|
+
const retention = options.retention;
|
|
20
|
+
if (retention !== undefined && (!Number.isSafeInteger(retention.keepLast) || retention.keepLast < 0 || typeof retention.cited !== "function")) {
|
|
21
|
+
throw new TypeError("retention requires a non-negative integer keepLast and a cited function");
|
|
22
|
+
}
|
|
23
|
+
/** Apply retention for one source. Returns a warning instead of throwing: the capture is already stored. */
|
|
24
|
+
async function prune(sourceId, keepAlso) {
|
|
25
|
+
if (retention === undefined)
|
|
26
|
+
return undefined;
|
|
27
|
+
if (typeof options.store.prune !== "function")
|
|
28
|
+
return "snapshot retention skipped: the store cannot prune";
|
|
29
|
+
const keep = keepAlso.map((snapshot) => lookupOf(buildSnapshotSourceRef(snapshot)));
|
|
30
|
+
try {
|
|
31
|
+
const cited = await retention.cited(sourceId);
|
|
32
|
+
if (!Array.isArray(cited))
|
|
33
|
+
return "snapshot retention skipped: cited references are not a list";
|
|
34
|
+
for (const reference of cited) {
|
|
35
|
+
const lookup = lookupOf(reference);
|
|
36
|
+
if (lookup === undefined)
|
|
37
|
+
return "snapshot retention skipped: a cited reference is not a snapshot reference";
|
|
38
|
+
if (lookup.sourceId === sourceId)
|
|
39
|
+
keep.push(lookup);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
return `snapshot retention skipped: cited references could not be read: ${error instanceof Error ? error.message : String(error)}`;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
await options.store.prune(sourceId, { keepLast: retention.keepLast, keep });
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
return `snapshot retention failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
18
53
|
async function check(source) {
|
|
19
54
|
const common = () => ({
|
|
20
55
|
sourceId: source.id,
|
|
@@ -66,12 +101,43 @@ export function createCheckRunner(options) {
|
|
|
66
101
|
}
|
|
67
102
|
return { ...base, kind: "unchanged-304", snapshotRef: buildSnapshotSourceRef(prior) };
|
|
68
103
|
}
|
|
104
|
+
// A byte-identical repeat of the stored capture is not appended: its only
|
|
105
|
+
// new information is the check time, which the result carries. Both
|
|
106
|
+
// refs then name the stored capture, which stays replayable.
|
|
107
|
+
if (prior !== undefined && isRepeatCapture(prior, snapshot)) {
|
|
108
|
+
const priorSnapshotRef = buildSnapshotSourceRef(prior);
|
|
109
|
+
return { ...base, kind: "unchanged-hash", priorSnapshotRef, currentSnapshotRef: priorSnapshotRef };
|
|
110
|
+
}
|
|
111
|
+
// The capture just stored is kept even when it is not the newest (clock
|
|
112
|
+
// skew, or another check storing a later capture first): its result names it.
|
|
113
|
+
const keepAlso = prior === undefined ? [snapshot] : [snapshot, prior];
|
|
69
114
|
try {
|
|
70
115
|
await options.store.put(snapshot);
|
|
71
116
|
}
|
|
72
117
|
catch (error) {
|
|
73
|
-
|
|
118
|
+
if (!isSnapshotHistoryFullError(error))
|
|
119
|
+
return lookoutError(base, "persistence", error);
|
|
120
|
+
// A full history refuses every later capture. Retention frees space
|
|
121
|
+
// (for example when it is enabled on a store that already reached the
|
|
122
|
+
// ceiling), so prune once and retry; otherwise say what to do.
|
|
123
|
+
const warning = await prune(source.id, keepAlso);
|
|
124
|
+
if (retention === undefined || warning !== undefined) {
|
|
125
|
+
if (warning !== undefined)
|
|
126
|
+
base.warnings.push(warning);
|
|
127
|
+
return historyFull(base, error.maxHistoryFiles);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
await options.store.put(snapshot);
|
|
131
|
+
}
|
|
132
|
+
catch (retryError) {
|
|
133
|
+
return isSnapshotHistoryFullError(retryError)
|
|
134
|
+
? historyFull(base, retryError.maxHistoryFiles)
|
|
135
|
+
: lookoutError(base, "persistence", retryError);
|
|
136
|
+
}
|
|
74
137
|
}
|
|
138
|
+
const retentionWarning = await prune(source.id, keepAlso);
|
|
139
|
+
if (retentionWarning !== undefined)
|
|
140
|
+
base.warnings.push(retentionWarning);
|
|
75
141
|
const currentSnapshotRef = buildSnapshotSourceRef(snapshot);
|
|
76
142
|
if (!prior) {
|
|
77
143
|
return {
|
|
@@ -104,6 +170,36 @@ export function createCheckRunner(options) {
|
|
|
104
170
|
}
|
|
105
171
|
return { check, checkAll };
|
|
106
172
|
}
|
|
173
|
+
// Response headers other than the validators a later conditional request
|
|
174
|
+
// reuses (e.g. Date) are not compared: they differ on nearly every response.
|
|
175
|
+
const REVALIDATION_HEADERS = ["etag", "last-modified"];
|
|
176
|
+
/** Same resource, same body, and nothing a later check reads differs. */
|
|
177
|
+
function isRepeatCapture(prior, current) {
|
|
178
|
+
const priorEncoding = bodyEncoding(prior);
|
|
179
|
+
return priorEncoding !== undefined &&
|
|
180
|
+
priorEncoding === bodyEncoding(current) &&
|
|
181
|
+
prior.sourceId === current.sourceId &&
|
|
182
|
+
prior.url === current.url &&
|
|
183
|
+
prior.status === current.status &&
|
|
184
|
+
prior.bodyHash === current.bodyHash &&
|
|
185
|
+
// Same bytes under another charset decode to other text: a new capture.
|
|
186
|
+
captureDecoding(prior) === captureDecoding(current) &&
|
|
187
|
+
prior.rendered === current.rendered &&
|
|
188
|
+
isDeepStrictEqual(prior.redirects, current.redirects) &&
|
|
189
|
+
REVALIDATION_HEADERS.every((name) => prior.headers?.[name] === current.headers?.[name]);
|
|
190
|
+
}
|
|
191
|
+
function lookupOf(reference) {
|
|
192
|
+
if (typeof reference !== "string")
|
|
193
|
+
return undefined;
|
|
194
|
+
const parsed = parseSnapshotSourceRef(reference);
|
|
195
|
+
if (parsed === undefined)
|
|
196
|
+
return undefined;
|
|
197
|
+
const { sourceId, url, bodyHash, fetchedAt, snapshotDigest } = parsed;
|
|
198
|
+
return { sourceId, url, bodyHash, fetchedAt, ...(snapshotDigest === undefined ? {} : { snapshotDigest }) };
|
|
199
|
+
}
|
|
200
|
+
function historyFull(common, maxHistoryFiles) {
|
|
201
|
+
return lookoutError(common, "history-full", `snapshot history for this source holds its maximum of ${maxHistoryFiles} records; configure snapshot retention or prune the store`);
|
|
202
|
+
}
|
|
107
203
|
function bodyEncoding(snapshot) {
|
|
108
204
|
const descriptor = Object.getOwnPropertyDescriptor(snapshot, "body");
|
|
109
205
|
if (descriptor === undefined || !("value" in descriptor))
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { LookoutSource } from "./registry.js";
|
|
2
2
|
import { type ProposalDiffEvent, type ProposalSetDiff, type ProposalSetDiffInput, type ProposalSetFacts, type ProposalSetObservation } from "./proposal-diff.js";
|
|
3
|
-
import type { ObservationCheckAnchor, ObservationStore,
|
|
3
|
+
import type { ObservationCheckAnchor, ObservationStore, StoredProposalObservation } from "./observation-store.js";
|
|
4
4
|
import type { SnapshotStore } from "@kontourai/forage";
|
|
5
5
|
export interface BaselineEstablishedFact {
|
|
6
6
|
readonly kind: "baseline-established";
|
|
@@ -25,7 +25,7 @@ export interface DriftSuccess {
|
|
|
25
25
|
readonly facts: readonly DriftFact[];
|
|
26
26
|
/** The prior observation this drift was diffed against, or null on a first-ever (baseline) observation. */
|
|
27
27
|
readonly priorObservationId: string | null;
|
|
28
|
-
readonly committedObservation:
|
|
28
|
+
readonly committedObservation: StoredProposalObservation;
|
|
29
29
|
readonly warnings: readonly string[];
|
|
30
30
|
}
|
|
31
31
|
export type DriftErrorKind = "invalid-input" | "prior-state-error" | "diff-error" | "persistence-error" | "serialization-error" | "unexpected";
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import { diffProposalSets } from "./proposal-diff.js";
|
|
2
|
+
import { compareCodeUnits } from "./canonical-json.js";
|
|
2
3
|
import { admitProposalObservation } from "./observation-admission.js";
|
|
3
4
|
function stableJson(value) {
|
|
4
5
|
if (Array.isArray(value))
|
|
5
6
|
return `[${value.map(stableJson).join(",")}]`;
|
|
6
7
|
if (value && typeof value === "object")
|
|
7
|
-
return `{${Object.entries(value).sort(([a], [b]) => a
|
|
8
|
+
return `{${Object.entries(value).sort(([a], [b]) => compareCodeUnits(a, b)).map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`).join(",")}}`;
|
|
8
9
|
return JSON.stringify(value) ?? "undefined";
|
|
9
10
|
}
|
|
10
11
|
function normalizeDiff(value) {
|
|
11
|
-
const sorted = (items) => [...items].sort((a, b) => stableJson(a)
|
|
12
|
+
const sorted = (items) => [...items].sort((a, b) => compareCodeUnits(stableJson(a), stableJson(b)));
|
|
12
13
|
return {
|
|
13
14
|
events: sorted(value.events),
|
|
14
15
|
facts: {
|
package/dist/src/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { runCli } from "./cli.js";
|
|
2
2
|
export type { RunCliOptions } from "./cli.js";
|
|
3
3
|
export { createCheckRunner } from "./check-runner.js";
|
|
4
|
-
export type { CheckRunner, CreateCheckRunnerOptions, FetchSource } from "./check-runner.js";
|
|
4
|
+
export type { CheckRunner, CreateCheckRunnerOptions, FetchSource, SnapshotRetention } from "./check-runner.js";
|
|
5
5
|
export type { ChangedResult, CheckResult, CheckResultCommon, ErrorResult, LookoutErrorKind, Unchanged304Result, UnchangedHashResult, } from "./check-result.js";
|
|
6
6
|
export { defaultProviderResolver } from "./provider-resolution.js";
|
|
7
7
|
export type { ProviderResolver } from "./provider-resolution.js";
|
|
@@ -10,7 +10,7 @@ export { inMemorySourceStore } from "./source-store.js";
|
|
|
10
10
|
export type { SourceStore } from "./source-store.js";
|
|
11
11
|
export type { ExtractableLookoutSource, LookoutRegistryDocument, LookoutSource, LookoutSourceKind, RenderPolicy, StructuredFileFormat, StructuredFileLookoutSource, } from "./registry.js";
|
|
12
12
|
export { createLookoutSnapshotStore, resolveLookoutSnapshot, } from "./snapshot-store.js";
|
|
13
|
-
export type { ResolveLookoutSnapshotOptions } from "./snapshot-store.js";
|
|
13
|
+
export type { LookoutSnapshotStoreOptions, ResolveLookoutSnapshotOptions } from "./snapshot-store.js";
|
|
14
14
|
export { admitProposalObservation } from "./observation-admission.js";
|
|
15
15
|
export type { AdmitProposalObservationInput, AdmittedProposalObservation, AdmittedSnapshotIdentity, ObservationAdmissionError, ObservationAdmissionErrorKind, ObservationAdmissionResult } from "./observation-admission.js";
|
|
16
16
|
export { admitSourceCapture, admitSourceCheck } from "./source-admission.js";
|
|
@@ -23,12 +23,12 @@ export type { KeyedMultisetFacts, KeyedMultisetOptions, StructuralComparison, St
|
|
|
23
23
|
export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js";
|
|
24
24
|
export type { FieldChangedEvent, FieldChangeKind, NewEntityAppearedEvent, ProposalDiffEvent, ProposalEvidence, ProposalIdentity, ProposalOccurrencePair, ProposalSetDiff, ProposalSetDiffInput, ProposalSetFacts, ProposalSetObservation, ProvenanceChangeFact, } from "./proposal-diff.js";
|
|
25
25
|
export { createObservationStore } from "./observation-store.js";
|
|
26
|
-
export type { CreateObservationStoreOptions, HeadWitnessComparison, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalHeadWitnessV1, ProposalObservationRecordInput, StoredProposalObservationV1, VerifiedHeadLimits, VerifiedHeadObservationStore, VerifiedHeadRead } from "./observation-store.js";
|
|
26
|
+
export type { CreateObservationStoreOptions, HeadWitnessComparison, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalHeadWitnessV1, ProposalObservationRecordInput, StoredProposalObservation, StoredProposalObservationV1, StoredProposalObservationV2, VerifiedHeadLimits, VerifiedHeadObservationStore, VerifiedHeadRead } from "./observation-store.js";
|
|
27
27
|
export { createDriftEmitter } from "./drift-emission.js";
|
|
28
28
|
export type { BaselineEstablishedFact, CreateDriftEmitterOptions, DriftEmitter, DriftError, DriftErrorKind, DriftFact, DriftResult, DriftSuccess, EmitDriftInput } from "./drift-emission.js";
|
|
29
29
|
export { checkSchemaCoverage } from "./coverage.js";
|
|
30
30
|
export type { SchemaCoverageGap, SchemaCoverageResult } from "./coverage.js";
|
|
31
|
-
export { createObserveExtractDiff } from "./observe-extract-diff.js";
|
|
31
|
+
export { createObserveExtractDiff, extractedSnapshotRef } from "./observe-extract-diff.js";
|
|
32
32
|
export type { ObserveExtractAcquisition, ObserveExtractAttempt, ObserveExtractDiff, ObserveExtractDiffOptions, ObserveExtractError, ObserveExtractErrorKind, ObserveExtractExtraction, ObserveExtractExtractionInput, ObserveExtractObservation, ObserveExtractObservationIdentity, ObserveExtractOutcome, ObserveExtractProviderFailure, ObserveExtractRecorder, ObserveExtractResult, ObserveExtractSource, ObserveExtractSourceSnapshot, } from "./observe-extract-diff.js";
|
|
33
33
|
export { buildSemanticReviewWork, semanticReviewApiVersion } from "./semantic-review-work.js";
|
|
34
34
|
export type { BuildSemanticReviewWorkInput, SemanticClaimTarget, SemanticObservationIdentity, SemanticReviewCandidate, SemanticReviewChange, SemanticReviewItem, SemanticReviewKind, SemanticReviewWork, } from "./semantic-review-work.js";
|
package/dist/src/index.js
CHANGED
|
@@ -12,5 +12,5 @@ export { diffProposalSets, extractionProposalIdentity } from "./proposal-diff.js
|
|
|
12
12
|
export { createObservationStore } from "./observation-store.js";
|
|
13
13
|
export { createDriftEmitter } from "./drift-emission.js";
|
|
14
14
|
export { checkSchemaCoverage } from "./coverage.js";
|
|
15
|
-
export { createObserveExtractDiff } from "./observe-extract-diff.js";
|
|
15
|
+
export { createObserveExtractDiff, extractedSnapshotRef } from "./observe-extract-diff.js";
|
|
16
16
|
export { buildSemanticReviewWork, semanticReviewApiVersion } from "./semantic-review-work.js";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { SnapshotStore } from "@kontourai/forage";
|
|
2
2
|
import type { LookoutSource } from "./registry.js";
|
|
3
|
-
import type {
|
|
3
|
+
import type { StoredProposalObservation, ObservationCheckAnchor } from "./observation-store.js";
|
|
4
4
|
import type { ProposalSetObservation } from "./proposal-diff.js";
|
|
5
5
|
/** A resolved snapshot identity suitable for durable observation metadata. */
|
|
6
6
|
export interface AdmittedSnapshotIdentity {
|
|
@@ -44,7 +44,7 @@ export interface AdmitProposalObservationInput {
|
|
|
44
44
|
readonly current: ProposalSetObservation;
|
|
45
45
|
readonly check: ObservationCheckAnchor;
|
|
46
46
|
/** The already-selected observation-store record; admission never selects or persists continuity. */
|
|
47
|
-
readonly prior:
|
|
47
|
+
readonly prior: StoredProposalObservation | null;
|
|
48
48
|
/** Explicit capability: admission never chooses a snapshot root or storage implementation. */
|
|
49
49
|
readonly snapshotStore: SnapshotStore;
|
|
50
50
|
}
|
|
@@ -21,6 +21,15 @@ export interface StoredProposalObservationV1 {
|
|
|
21
21
|
readonly check: ObservationCheckAnchor;
|
|
22
22
|
readonly proposals: readonly ExtractionProposal[];
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* Same fields as version 1. The observationId is computed over code-unit-ordered
|
|
26
|
+
* canonical JSON, so it does not depend on the host locale. New records are
|
|
27
|
+
* always version 2.
|
|
28
|
+
*/
|
|
29
|
+
export interface StoredProposalObservationV2 extends Omit<StoredProposalObservationV1, "version"> {
|
|
30
|
+
readonly version: 2;
|
|
31
|
+
}
|
|
32
|
+
export type StoredProposalObservation = StoredProposalObservationV1 | StoredProposalObservationV2;
|
|
24
33
|
export type ObservationStoreErrorKind = "invalid-input" | "corrupt-state" | "continuity-conflict" | "io-error";
|
|
25
34
|
export interface ObservationStoreError {
|
|
26
35
|
readonly kind: ObservationStoreErrorKind;
|
|
@@ -36,8 +45,8 @@ export type ObservationStoreResult<T> = {
|
|
|
36
45
|
readonly error: ObservationStoreError;
|
|
37
46
|
};
|
|
38
47
|
export interface ObservationStore {
|
|
39
|
-
loadLatest(sourceId: string): Promise<ObservationStoreResult<
|
|
40
|
-
commit(input: ProposalObservationRecordInput, expectedPriorId: string | null): Promise<ObservationStoreResult<
|
|
48
|
+
loadLatest(sourceId: string): Promise<ObservationStoreResult<StoredProposalObservation | null>>;
|
|
49
|
+
commit(input: ProposalObservationRecordInput, expectedPriorId: string | null): Promise<ObservationStoreResult<StoredProposalObservation>>;
|
|
41
50
|
}
|
|
42
51
|
/** Finite caller-controlled ceilings for a verified proposal-head read. */
|
|
43
52
|
export interface VerifiedHeadLimits {
|
|
@@ -3,19 +3,27 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, unlink } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { types } from "node:util";
|
|
6
|
+
import { canonicalJson, compareCodeUnits } from "./canonical-json.js";
|
|
6
7
|
const VERIFIED_HEAD_MAX = Object.freeze({ maxEntries: 8, maxIndexBytes: 8 * 1024, maxPointerBytes: 8 * 1024, maxRecordBytes: 1024 * 1024 });
|
|
7
8
|
function sourceKey(sourceId) {
|
|
8
9
|
return `${encodeURIComponent(sourceId).replaceAll("%", "_").slice(0, 48)}-${createHash("sha256").update(sourceId).digest("hex").slice(0, 16)}`;
|
|
9
10
|
}
|
|
10
|
-
function
|
|
11
|
+
function canonical(value) { return `${canonicalJson(value)}\n`; }
|
|
12
|
+
function digest(body) {
|
|
13
|
+
return createHash("sha256").update(body.version === 1 ? legacyCanonical(body) : canonical(body)).digest("hex");
|
|
14
|
+
}
|
|
15
|
+
// Version 1 digests only. Version 1 records were hashed with keys sorted by the
|
|
16
|
+
// host locale and then rebuilt with Object.fromEntries, which moves integer-like
|
|
17
|
+
// keys first. A version 1 record therefore verifies only under a locale that
|
|
18
|
+
// collates its keys the way the writing host did. Do not use this for new records.
|
|
19
|
+
function legacyStable(value) {
|
|
11
20
|
if (Array.isArray(value))
|
|
12
|
-
return value.map(
|
|
21
|
+
return value.map(legacyStable);
|
|
13
22
|
if (value && typeof value === "object")
|
|
14
|
-
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key,
|
|
23
|
+
return Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => [key, legacyStable(item)]));
|
|
15
24
|
return value;
|
|
16
25
|
}
|
|
17
|
-
function
|
|
18
|
-
function digest(body) { return createHash("sha256").update(canonical(body)).digest("hex"); }
|
|
26
|
+
function legacyCanonical(value) { return `${JSON.stringify(legacyStable(value))}\n`; }
|
|
19
27
|
function resolveHeadLimits(limits) {
|
|
20
28
|
try {
|
|
21
29
|
if (limits !== undefined && (!limits || typeof limits !== "object" || types.isProxy(limits) || Array.isArray(limits)))
|
|
@@ -219,8 +227,8 @@ function buildRecord(input) {
|
|
|
219
227
|
if (!check || check.currentSnapshotRef !== observation.snapshotRef || typeof check.checkedAt !== "string" || (check.resultKind !== "changed" && check.resultKind !== "unchanged-hash") || typeof input.recordedAt !== "string") {
|
|
220
228
|
return { ok: false, error: { kind: "invalid-input", message: "Check anchor must match the current observation snapshot" } };
|
|
221
229
|
}
|
|
222
|
-
const proposals = [...observation.proposals].sort((a, b) => canonical(a)
|
|
223
|
-
const body = { version:
|
|
230
|
+
const proposals = [...observation.proposals].sort((a, b) => compareCodeUnits(canonical(a), canonical(b)));
|
|
231
|
+
const body = { version: 2, sourceKey: sourceKey(observation.sourceId), sourceId: observation.sourceId, snapshotRef: observation.snapshotRef, observedAt: observation.observedAt, recordedAt: input.recordedAt, check, proposals };
|
|
224
232
|
try {
|
|
225
233
|
return { ok: true, value: { ...body, observationId: digest(body) } };
|
|
226
234
|
}
|
|
@@ -249,7 +257,7 @@ function validate(value, expectedSourceId) {
|
|
|
249
257
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
250
258
|
return { ok: false, error: { kind: "corrupt-state", message: "Stored observation is not an object" } };
|
|
251
259
|
const item = value;
|
|
252
|
-
if (item.version !== 1 || item.sourceId !== expectedSourceId || item.sourceKey !== sourceKey(expectedSourceId) || typeof item.observationId !== "string" || !/^[a-f0-9]{64}$/.test(item.observationId) || typeof item.snapshotRef !== "string" || item.snapshotRef === "" || typeof item.observedAt !== "string" || item.observedAt === "" || typeof item.recordedAt !== "string" || item.recordedAt === "" || !Array.isArray(item.proposals) || item.proposals.some((proposal) => !validProposal(proposal)) || !item.check || typeof item.check !== "object" || typeof item.check.checkedAt !== "string" || item.check.checkedAt === "" || (item.check.resultKind !== "changed" && item.check.resultKind !== "unchanged-hash") || item.check.currentSnapshotRef !== item.snapshotRef) {
|
|
260
|
+
if ((item.version !== 1 && item.version !== 2) || item.sourceId !== expectedSourceId || item.sourceKey !== sourceKey(expectedSourceId) || typeof item.observationId !== "string" || !/^[a-f0-9]{64}$/.test(item.observationId) || typeof item.snapshotRef !== "string" || item.snapshotRef === "" || typeof item.observedAt !== "string" || item.observedAt === "" || typeof item.recordedAt !== "string" || item.recordedAt === "" || !Array.isArray(item.proposals) || item.proposals.some((proposal) => !validProposal(proposal)) || !item.check || typeof item.check !== "object" || typeof item.check.checkedAt !== "string" || item.check.checkedAt === "" || (item.check.resultKind !== "changed" && item.check.resultKind !== "unchanged-hash") || item.check.currentSnapshotRef !== item.snapshotRef) {
|
|
253
261
|
return { ok: false, error: { kind: "corrupt-state", message: "Stored observation schema or continuity is invalid" } };
|
|
254
262
|
}
|
|
255
263
|
const { observationId, ...body } = item;
|
|
@@ -416,7 +424,7 @@ export function createObservationStore(options = {}) {
|
|
|
416
424
|
return null;
|
|
417
425
|
} }).filter((item) => item !== null);
|
|
418
426
|
const preserve = new Set([record.observationId, expectedPriorId].filter((item) => item !== null));
|
|
419
|
-
const extras = valid.filter((item) => !preserve.has(item.id)).sort((a, b) => b.recordedAt
|
|
427
|
+
const extras = valid.filter((item) => !preserve.has(item.id)).sort((a, b) => compareCodeUnits(b.recordedAt, a.recordedAt) || compareCodeUnits(b.id, a.id));
|
|
420
428
|
await Promise.all(extras.map((item) => rm(path.join(dir, item.name), { force: true })));
|
|
421
429
|
}
|
|
422
430
|
catch (cause) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtractionPartial, ExtractionProviderFailure, ExtractionResult, PreparedArtifact } from "@kontourai/traverse";
|
|
2
|
+
import type { SnapshotStore } from "@kontourai/forage/fetch";
|
|
2
3
|
import type { CheckResult } from "./check-result.js";
|
|
3
4
|
import type { ProposalSetObservation } from "./proposal-diff.js";
|
|
4
5
|
import type { LookoutSource } from "./registry.js";
|
|
@@ -64,11 +65,36 @@ export interface ObserveExtractObservationIdentity {
|
|
|
64
65
|
*/
|
|
65
66
|
export interface ObserveExtractRecorder {
|
|
66
67
|
record(observation: ObserveExtractObservation): Promise<ObserveExtractObservationIdentity>;
|
|
68
|
+
/**
|
|
69
|
+
* The snapshot reference of this source's most recent recorded observation
|
|
70
|
+
* for which `extractedSnapshotRef(observation)` is not null, or `null` when
|
|
71
|
+
* there is none.
|
|
72
|
+
*
|
|
73
|
+
* An unchanged check is only skipped when its capture has the same URL, body
|
|
74
|
+
* hash, and text decoding as this snapshot. Otherwise a change that acquisition already
|
|
75
|
+
* persisted, but that was never extracted (for example because a provider
|
|
76
|
+
* failed), would be reported as unchanged forever.
|
|
77
|
+
*/
|
|
78
|
+
lastExtractedSnapshotRef(source: ObserveExtractSource): Promise<string | null>;
|
|
67
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* The snapshot an observation's extraction fully handled: the current snapshot
|
|
82
|
+
* of a `completed`, `partial`, or `unchanged` observation, else `null`.
|
|
83
|
+
* Recorders use this to answer `lastExtractedSnapshotRef`.
|
|
84
|
+
*/
|
|
85
|
+
export declare function extractedSnapshotRef(observation: ObserveExtractObservation): string | null;
|
|
68
86
|
export interface ObserveExtractDiffOptions {
|
|
69
87
|
readonly acquisition: ObserveExtractAcquisition;
|
|
70
88
|
readonly extraction: ObserveExtractExtraction;
|
|
71
89
|
readonly recorder: ObserveExtractRecorder;
|
|
90
|
+
/**
|
|
91
|
+
* The snapshot store acquisition persists to. An unchanged check is compared
|
|
92
|
+
* with the last extracted capture by resolving both references here, since a
|
|
93
|
+
* reference does not name the charset its text was decoded with. A reference
|
|
94
|
+
* that does not resolve counts as a different capture, so it is extracted.
|
|
95
|
+
* With snapshot retention, cite the last extracted reference to keep it.
|
|
96
|
+
*/
|
|
97
|
+
readonly snapshots: SnapshotStore;
|
|
72
98
|
}
|
|
73
99
|
export type ObserveExtractErrorKind = "acquisition-threw" | "recording-failed" | "dependency-contract";
|
|
74
100
|
export interface ObserveExtractError {
|
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
import { validatePreparedArtifact } from "@kontourai/traverse";
|
|
2
|
+
import { resolveSnapshotSourceRef } from "@kontourai/forage/fetch";
|
|
3
|
+
import { captureDecoding } from "./capture-decoding.js";
|
|
4
|
+
/**
|
|
5
|
+
* The snapshot an observation's extraction fully handled: the current snapshot
|
|
6
|
+
* of a `completed`, `partial`, or `unchanged` observation, else `null`.
|
|
7
|
+
* Recorders use this to answer `lastExtractedSnapshotRef`.
|
|
8
|
+
*/
|
|
9
|
+
export function extractedSnapshotRef(observation) {
|
|
10
|
+
const handled = observation.outcome === "completed" || observation.outcome === "partial" || observation.outcome === "unchanged";
|
|
11
|
+
return handled && observation.sourceSnapshot !== null ? observation.sourceSnapshot.currentSnapshotRef : null;
|
|
12
|
+
}
|
|
2
13
|
export function createObserveExtractDiff(options) {
|
|
14
|
+
// Without a store every unchanged capture would silently count as new and be
|
|
15
|
+
// extracted again, so a missing one is refused up front.
|
|
16
|
+
if (typeof options?.snapshots?.findExact !== "function") {
|
|
17
|
+
throw new TypeError("createObserveExtractDiff requires snapshots: the snapshot store acquisition persists to, with exact lookup");
|
|
18
|
+
}
|
|
3
19
|
return {
|
|
4
20
|
async observe(source) {
|
|
5
21
|
try {
|
|
@@ -19,10 +35,26 @@ export function createObserveExtractDiff(options) {
|
|
|
19
35
|
if (check.kind === "error") {
|
|
20
36
|
return record(options.recorder, baseObservation(source, check, "acquisition-error", null, null, null, null));
|
|
21
37
|
}
|
|
38
|
+
let sourceSnapshot = snapshotFor(check);
|
|
22
39
|
if (check.kind === "unchanged-304" || check.kind === "unchanged-hash") {
|
|
23
|
-
|
|
40
|
+
// "Unchanged" compares with the latest stored capture, which may never
|
|
41
|
+
// have been extracted. Skip extraction only when the capture matches
|
|
42
|
+
// the last one that was; otherwise extract it against that baseline.
|
|
43
|
+
let extracted;
|
|
44
|
+
try {
|
|
45
|
+
extracted = await options.recorder.lastExtractedSnapshotRef(observationSource(source));
|
|
46
|
+
}
|
|
47
|
+
catch (cause) {
|
|
48
|
+
return { ok: false, error: error("recording-failed", "Observation recorder could not report the last extracted snapshot", cause) };
|
|
49
|
+
}
|
|
50
|
+
if (extracted !== null && (typeof extracted !== "string" || extracted === "")) {
|
|
51
|
+
return { ok: false, error: error("dependency-contract", "Observation recorder returned an invalid last extracted snapshot reference") };
|
|
52
|
+
}
|
|
53
|
+
if (extracted !== null && await sameCapture(options.snapshots, extracted, sourceSnapshot.currentSnapshotRef)) {
|
|
54
|
+
return record(options.recorder, baseObservation(source, check, "unchanged", sourceSnapshot, null, null, null));
|
|
55
|
+
}
|
|
56
|
+
sourceSnapshot = { priorSnapshotRef: extracted, currentSnapshotRef: sourceSnapshot.currentSnapshotRef };
|
|
24
57
|
}
|
|
25
|
-
const sourceSnapshot = snapshotFor(check);
|
|
26
58
|
let extraction;
|
|
27
59
|
try {
|
|
28
60
|
extraction = await options.extraction.extract({ source, snapshotRef: sourceSnapshot.currentSnapshotRef });
|
|
@@ -65,7 +97,7 @@ export function createObserveExtractDiff(options) {
|
|
|
65
97
|
}
|
|
66
98
|
function baseObservation(source, check, outcome, sourceSnapshot, preparedArtifact, proposalSet, attempt) {
|
|
67
99
|
return {
|
|
68
|
-
source:
|
|
100
|
+
source: observationSource(source),
|
|
69
101
|
check,
|
|
70
102
|
outcome,
|
|
71
103
|
sourceSnapshot,
|
|
@@ -93,6 +125,23 @@ async function record(recorder, observation) {
|
|
|
93
125
|
return { ok: false, error: error("recording-failed", "Observation recorder failed", cause), observation };
|
|
94
126
|
}
|
|
95
127
|
}
|
|
128
|
+
function observationSource(source) {
|
|
129
|
+
return { id: source.id, url: source.url, kind: source.kind };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Two references name the same capture content: same source, resource URL,
|
|
133
|
+
* body hash, and text decoding. Same bytes under another declared charset are
|
|
134
|
+
* other text, so they are a different capture.
|
|
135
|
+
*/
|
|
136
|
+
async function sameCapture(store, left, right) {
|
|
137
|
+
if (left === right)
|
|
138
|
+
return true;
|
|
139
|
+
const [a, b] = await Promise.all([resolveSnapshotSourceRef(store, left), resolveSnapshotSourceRef(store, right)]);
|
|
140
|
+
if (!a.ok || !b.ok)
|
|
141
|
+
return false;
|
|
142
|
+
return a.snapshot.sourceId === b.snapshot.sourceId && a.snapshot.url === b.snapshot.url &&
|
|
143
|
+
a.snapshot.bodyHash === b.snapshot.bodyHash && captureDecoding(a.snapshot) === captureDecoding(b.snapshot);
|
|
144
|
+
}
|
|
96
145
|
function snapshotFor(check) {
|
|
97
146
|
if (check.kind === "unchanged-304")
|
|
98
147
|
return { priorSnapshotRef: check.snapshotRef, currentSnapshotRef: check.snapshotRef };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { canonicalValueKey, } from "./canonical-value.js";
|
|
2
2
|
import { compareStructural, diffKeyedMultiset } from "./structural-diff.js";
|
|
3
|
+
import { compareCodeUnits } from "./canonical-json.js";
|
|
3
4
|
function callbackError(label, cause) {
|
|
4
5
|
return { kind: "callback-threw", message: `${label} callback threw`, cause };
|
|
5
6
|
}
|
|
@@ -90,7 +91,7 @@ function semanticFieldOrder(items, fieldIdentity) {
|
|
|
90
91
|
return encoded;
|
|
91
92
|
groups.set(fieldKey.value, [...(groups.get(fieldKey.value) ?? []), { item, key: encoded.key, index }]);
|
|
92
93
|
}
|
|
93
|
-
const ordered = [...groups.values()].flatMap((group) => group.sort((left, right) => left.key
|
|
94
|
+
const ordered = [...groups.values()].flatMap((group) => group.sort((left, right) => compareCodeUnits(left.key, right.key) || left.index - right.index));
|
|
94
95
|
return { ok: true, value: ordered.map(({ item }) => item) };
|
|
95
96
|
}
|
|
96
97
|
export function diffProposalSets(input) {
|
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
import type { SnapshotStore } from "@kontourai/forage";
|
|
1
|
+
import type { ExactSnapshotStore, PrunableSnapshotStore, SnapshotStore } from "@kontourai/forage";
|
|
2
2
|
import { type SnapshotSourceRefResolution } from "@kontourai/forage/fetch";
|
|
3
|
-
export
|
|
3
|
+
export interface LookoutSnapshotStoreOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Per-source record ceiling, passed to Forage's filesystem store (1 to
|
|
6
|
+
* 10,000; Forage's default is 10,000). It cannot change after a source's
|
|
7
|
+
* store directory is initialized.
|
|
8
|
+
*/
|
|
9
|
+
readonly maxHistoryFiles?: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function createLookoutSnapshotStore(root?: string, options?: LookoutSnapshotStoreOptions): ExactSnapshotStore & PrunableSnapshotStore;
|
|
4
12
|
export type ResolveLookoutSnapshotOptions = {
|
|
5
13
|
store: SnapshotStore;
|
|
6
14
|
root?: never;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { createFilesystemSnapshotStore } from "@kontourai/forage";
|
|
3
3
|
import { resolveSnapshotSourceRef, } from "@kontourai/forage/fetch";
|
|
4
|
-
export function createLookoutSnapshotStore(root = path.join(process.cwd(), ".kontourai", "lookout", "snapshots")) {
|
|
5
|
-
return createFilesystemSnapshotStore({
|
|
4
|
+
export function createLookoutSnapshotStore(root = path.join(process.cwd(), ".kontourai", "lookout", "snapshots"), options = {}) {
|
|
5
|
+
return createFilesystemSnapshotStore({
|
|
6
|
+
root,
|
|
7
|
+
...(options.maxHistoryFiles === undefined ? {} : { maxHistoryFiles: options.maxHistoryFiles }),
|
|
8
|
+
});
|
|
6
9
|
}
|
|
7
10
|
/** Replay one Lookout-emitted durable reference without any network access. */
|
|
8
11
|
export async function resolveLookoutSnapshot(reference, options = {}) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kontourai/lookout",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "A small source registry and drift-check runner built on Forage snapshots.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -46,17 +46,21 @@
|
|
|
46
46
|
"workflow:validate-artifacts": "flow-agents-validate-artifacts"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@kontourai/datum": "0.
|
|
50
|
-
"@kontourai/forage": "0.
|
|
49
|
+
"@kontourai/datum": "0.8.0",
|
|
50
|
+
"@kontourai/forage": "1.0.0",
|
|
51
51
|
"@kontourai/traverse": "0.25.1"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@kontourai/flow-agents": "
|
|
54
|
+
"@kontourai/flow-agents": "6.4.0",
|
|
55
55
|
"@kontourai/survey": "3.0.0",
|
|
56
56
|
"@types/node": "^26.1.2",
|
|
57
|
+
"ajv": "8.20.0",
|
|
58
|
+
"ajv-formats": "3.0.1",
|
|
59
|
+
"hachure": "0.15.0",
|
|
57
60
|
"typescript": "^7.0.2"
|
|
58
61
|
},
|
|
59
62
|
"engines": {
|
|
60
63
|
"node": ">=22"
|
|
61
|
-
}
|
|
64
|
+
},
|
|
65
|
+
"packageManager": "pnpm@11.25.0"
|
|
62
66
|
}
|