@kontourai/lookout 0.5.1 → 0.6.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 +52 -4
- package/dist/src/canonical-json.d.ts +8 -0
- package/dist/src/canonical-json.js +36 -0
- package/dist/src/check-runner.js +23 -0
- package/dist/src/drift-emission.d.ts +2 -2
- package/dist/src/drift-emission.js +3 -2
- package/dist/src/index.d.ts +3 -3
- package/dist/src/index.js +1 -1
- package/dist/src/observation-admission.d.ts +2 -2
- package/dist/src/observation-store.d.ts +52 -3
- package/dist/src/observation-store.js +265 -12
- package/dist/src/observe-extract-diff.d.ts +17 -0
- package/dist/src/observe-extract-diff.js +41 -3
- package/dist/src/proposal-diff.js +2 -1
- package/dist/src/snapshot-store.d.ts +9 -1
- package/dist/src/snapshot-store.js +5 -2
- package/package.json +6 -2
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,6 +179,14 @@ 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, redirects, render state, and `etag` /
|
|
184
|
+
`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.
|
|
189
|
+
|
|
182
190
|
`error` results preserve provenance: `origin: "forage"` carries Forage's
|
|
183
191
|
discriminated `FetchError` verbatim (its `kind`, and `status` when present);
|
|
184
192
|
`origin: "lookout"` carries a typed `kind` — `prior-read`, `persistence`,
|
|
@@ -189,7 +197,8 @@ are portable logical refs from Forage's `buildSnapshotSourceRef` — never
|
|
|
189
197
|
filesystem paths. Snapshots are stored via Forage's filesystem store, rooted by
|
|
190
198
|
default at `<cwd>/.kontourai/lookout/snapshots` (override with the CLI
|
|
191
199
|
`--snapshot-root` flag or by injecting a store in library use). Lookout adds no
|
|
192
|
-
custom filenames or retention
|
|
200
|
+
custom filenames or retention; `createLookoutSnapshotStore(root, {
|
|
201
|
+
maxHistoryFiles })` passes Forage's per-source record ceiling through. `resolveLookoutSnapshot()` replays one exact
|
|
193
202
|
reference through an injected store or Lookout snapshot root, authenticates its
|
|
194
203
|
body and replay metadata, and never fetches. References emitted before Forage's
|
|
195
204
|
replay-envelope digest remain resolvable with `integrity: "body-and-identity"`;
|
|
@@ -256,6 +265,32 @@ latest two valid observations. The emitted `events` are already
|
|
|
256
265
|
Hachure-evidence-shaped; a consumer or product lifts them into a Hachure
|
|
257
266
|
`TrustBundle` with `@kontourai/surface`'s `TrustBundleBuilder` when it wants
|
|
258
267
|
Surface projection — lookout itself authors nothing in the trust layer.
|
|
268
|
+
|
|
269
|
+
An observation id is the SHA-256 of the record's canonical JSON: object keys
|
|
270
|
+
and proposals in UTF-16 code-unit order, never the host locale's collation.
|
|
271
|
+
New records are `version: 2`. Records written before this change are
|
|
272
|
+
`version: 1`, whose digest used the writing host's locale collation. They
|
|
273
|
+
still load on a host whose locale collates their keys the same way, and the
|
|
274
|
+
next commit replaces a `version: 1` prior with a `version: 2` record. Under a
|
|
275
|
+
different locale, a `version: 1` record with non-ASCII or mixed-case keys or
|
|
276
|
+
values can read as `corrupt-state`.
|
|
277
|
+
|
|
278
|
+
### Verified proposal head witnesses
|
|
279
|
+
|
|
280
|
+
The concrete filesystem observation store additionally exposes
|
|
281
|
+
`readVerifiedHead(sourceId, limits)` and `compareHeadWitness(witness, limits)`.
|
|
282
|
+
The first returns an authenticated observation id and snapshot reference with an
|
|
283
|
+
opaque, versioned witness; the second reads only bounded head metadata and
|
|
284
|
+
returns `matches`, `changed`, `missing`, `unavailable`, `corrupt`, or
|
|
285
|
+
`unsupported`. It never reads proposal bytes during comparison.
|
|
286
|
+
|
|
287
|
+
A witness is an as-of fence, not a freshness guarantee: it binds the source,
|
|
288
|
+
current head, store scope, pointer, and filesystem metadata observed at capture.
|
|
289
|
+
It detects ordinary older writers without requiring them to write a sidecar.
|
|
290
|
+
Restarting the same store preserves a witness; copying or restoring the store
|
|
291
|
+
can invalidate it. It does not prove proposal-body bit-rot or provide an atomic
|
|
292
|
+
transaction across stores. Limits are finite hard caps on enumeration, pointer,
|
|
293
|
+
and authenticated-record reads; callers may only narrow the defaults.
|
|
259
294
|
Store paths refuse symbolic links. An existing source lock is not automatically
|
|
260
295
|
broken: after confirming no writer is active, an operator may remove an
|
|
261
296
|
abandoned `.lock` file and retry.
|
|
@@ -343,6 +378,10 @@ const composition = createObserveExtractDiff({
|
|
|
343
378
|
// Caller-owned continuity and durable storage.
|
|
344
379
|
return saveObservation(observation);
|
|
345
380
|
},
|
|
381
|
+
async lastExtractedSnapshotRef(source) {
|
|
382
|
+
// The newest non-null extractedSnapshotRef(observation) you recorded.
|
|
383
|
+
return loadLastExtractedSnapshotRef(source.id);
|
|
384
|
+
},
|
|
346
385
|
},
|
|
347
386
|
});
|
|
348
387
|
|
|
@@ -350,7 +389,14 @@ const result = await composition.observe(source);
|
|
|
350
389
|
```
|
|
351
390
|
|
|
352
391
|
`unchanged-304` and `unchanged-hash` are recorded as `unchanged` and never call
|
|
353
|
-
the extraction capability, so they make zero preparation and provider calls
|
|
392
|
+
the extraction capability, so they make zero preparation and provider calls,
|
|
393
|
+
when the capture has the same URL and body hash as the recorder's
|
|
394
|
+
`lastExtractedSnapshotRef`. That is the last snapshot an observation fully
|
|
395
|
+
handled: the current snapshot of a `completed`, `partial`, or `unchanged`
|
|
396
|
+
observation, as `extractedSnapshotRef(observation)` returns. Otherwise the
|
|
397
|
+
capture was persisted but never extracted (for example, a provider failed on the
|
|
398
|
+
check that first saw it), so it is extracted now against that baseline instead
|
|
399
|
+
of being reported as unchanged forever.
|
|
354
400
|
Changed observations retain source and snapshot references, Traverse's prepared
|
|
355
401
|
artifact identity, the proposal set, and the current/prior observation
|
|
356
402
|
identities returned by the recorder. `partial`, `provider-failure`, mixed
|
|
@@ -425,10 +471,12 @@ behavior.
|
|
|
425
471
|
## Development
|
|
426
472
|
|
|
427
473
|
```sh
|
|
428
|
-
|
|
474
|
+
pnpm install
|
|
429
475
|
npm run verify # content-boundary + decisions + typecheck + test + pack sanity
|
|
430
476
|
```
|
|
431
477
|
|
|
478
|
+
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`.
|
|
479
|
+
|
|
432
480
|
Individual gates: `npm test`, `npm run typecheck`, `npm run check:pack`,
|
|
433
481
|
`npm run check:decisions`, `npm run check:content-boundary`.
|
|
434
482
|
|
|
@@ -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
|
+
}
|
package/dist/src/check-runner.js
CHANGED
|
@@ -66,6 +66,13 @@ export function createCheckRunner(options) {
|
|
|
66
66
|
}
|
|
67
67
|
return { ...base, kind: "unchanged-304", snapshotRef: buildSnapshotSourceRef(prior) };
|
|
68
68
|
}
|
|
69
|
+
// A byte-identical repeat of the stored capture is not appended: its only
|
|
70
|
+
// new information is the check time, which the result carries. Both
|
|
71
|
+
// refs then name the stored capture, which stays replayable.
|
|
72
|
+
if (prior !== undefined && isRepeatCapture(prior, snapshot)) {
|
|
73
|
+
const priorSnapshotRef = buildSnapshotSourceRef(prior);
|
|
74
|
+
return { ...base, kind: "unchanged-hash", priorSnapshotRef, currentSnapshotRef: priorSnapshotRef };
|
|
75
|
+
}
|
|
69
76
|
try {
|
|
70
77
|
await options.store.put(snapshot);
|
|
71
78
|
}
|
|
@@ -104,6 +111,22 @@ export function createCheckRunner(options) {
|
|
|
104
111
|
}
|
|
105
112
|
return { check, checkAll };
|
|
106
113
|
}
|
|
114
|
+
// Response headers other than the validators a later conditional request
|
|
115
|
+
// reuses (e.g. Date) are not compared: they differ on nearly every response.
|
|
116
|
+
const REVALIDATION_HEADERS = ["etag", "last-modified"];
|
|
117
|
+
/** Same resource, same body, and nothing a later check reads differs. */
|
|
118
|
+
function isRepeatCapture(prior, current) {
|
|
119
|
+
const priorEncoding = bodyEncoding(prior);
|
|
120
|
+
return priorEncoding !== undefined &&
|
|
121
|
+
priorEncoding === bodyEncoding(current) &&
|
|
122
|
+
prior.sourceId === current.sourceId &&
|
|
123
|
+
prior.url === current.url &&
|
|
124
|
+
prior.status === current.status &&
|
|
125
|
+
prior.bodyHash === current.bodyHash &&
|
|
126
|
+
prior.rendered === current.rendered &&
|
|
127
|
+
isDeepStrictEqual(prior.redirects, current.redirects) &&
|
|
128
|
+
REVALIDATION_HEADERS.every((name) => prior.headers?.[name] === current.headers?.[name]);
|
|
129
|
+
}
|
|
107
130
|
function bodyEncoding(snapshot) {
|
|
108
131
|
const descriptor = Object.getOwnPropertyDescriptor(snapshot, "body");
|
|
109
132
|
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
|
@@ -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, ObservationCheckAnchor, ObservationStore, ObservationStoreError, ObservationStoreErrorKind, ObservationStoreResult, ProposalObservationRecordInput, StoredProposalObservationV1 } 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,44 @@ 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>>;
|
|
50
|
+
}
|
|
51
|
+
/** Finite caller-controlled ceilings for a verified proposal-head read. */
|
|
52
|
+
export interface VerifiedHeadLimits {
|
|
53
|
+
readonly maxEntries?: number;
|
|
54
|
+
readonly maxIndexBytes?: number;
|
|
55
|
+
readonly maxPointerBytes?: number;
|
|
56
|
+
readonly maxRecordBytes?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface ProposalHeadWitnessV1 {
|
|
59
|
+
readonly kind: "lookout.proposal-head-witness/v1";
|
|
60
|
+
readonly version: 1;
|
|
61
|
+
readonly sourceId: string;
|
|
62
|
+
readonly observationId: string;
|
|
63
|
+
/** Opaque binding of this store scope and its bounded metadata-as-of state. */
|
|
64
|
+
readonly token: string;
|
|
65
|
+
}
|
|
66
|
+
export type VerifiedHeadRead = {
|
|
67
|
+
readonly kind: "verified";
|
|
68
|
+
readonly sourceId: string;
|
|
69
|
+
readonly observationId: string;
|
|
70
|
+
readonly snapshotRef: string;
|
|
71
|
+
readonly witness: ProposalHeadWitnessV1;
|
|
72
|
+
} | {
|
|
73
|
+
readonly kind: "missing" | "unavailable" | "corrupt" | "unsupported";
|
|
74
|
+
};
|
|
75
|
+
export type HeadWitnessComparison = {
|
|
76
|
+
readonly kind: "matches";
|
|
77
|
+
readonly sourceId: string;
|
|
78
|
+
readonly observationId: string;
|
|
79
|
+
} | {
|
|
80
|
+
readonly kind: "changed" | "missing" | "unavailable" | "corrupt" | "unsupported";
|
|
81
|
+
};
|
|
82
|
+
/** Additive concrete-store capability. Generic ObservationStore implementations need not provide it. */
|
|
83
|
+
export interface VerifiedHeadObservationStore {
|
|
84
|
+
readVerifiedHead(sourceId: string, limits?: VerifiedHeadLimits): Promise<VerifiedHeadRead>;
|
|
85
|
+
compareHeadWitness(witness: ProposalHeadWitnessV1, limits?: VerifiedHeadLimits): Promise<HeadWitnessComparison>;
|
|
41
86
|
}
|
|
42
87
|
export interface ObservationStoreFaults {
|
|
43
88
|
readonly beforeSerialize?: () => void;
|
|
@@ -47,9 +92,13 @@ export interface ObservationStoreFaults {
|
|
|
47
92
|
readonly beforePointerRename?: () => void;
|
|
48
93
|
readonly beforeDirectorySync?: (kind: "record" | "pointer") => void;
|
|
49
94
|
readonly beforePrune?: () => void;
|
|
95
|
+
/** Test-only fence hooks; production callers do not supply them. */
|
|
96
|
+
readonly beforeHeadStrongLoad?: () => void;
|
|
97
|
+
readonly beforeHeadRecordRead?: () => void;
|
|
98
|
+
readonly beforeHeadAfterFence?: () => void;
|
|
50
99
|
}
|
|
51
100
|
export interface CreateObservationStoreOptions {
|
|
52
101
|
readonly root?: string;
|
|
53
102
|
readonly faults?: ObservationStoreFaults;
|
|
54
103
|
}
|
|
55
|
-
export declare function createObservationStore(options?: CreateObservationStoreOptions): ObservationStore;
|
|
104
|
+
export declare function createObservationStore(options?: CreateObservationStoreOptions): ObservationStore & VerifiedHeadObservationStore;
|
|
@@ -1,18 +1,223 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, unlink } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
5
|
+
import { types } from "node:util";
|
|
6
|
+
import { canonicalJson, compareCodeUnits } from "./canonical-json.js";
|
|
7
|
+
const VERIFIED_HEAD_MAX = Object.freeze({ maxEntries: 8, maxIndexBytes: 8 * 1024, maxPointerBytes: 8 * 1024, maxRecordBytes: 1024 * 1024 });
|
|
4
8
|
function sourceKey(sourceId) {
|
|
5
9
|
return `${encodeURIComponent(sourceId).replaceAll("%", "_").slice(0, 48)}-${createHash("sha256").update(sourceId).digest("hex").slice(0, 16)}`;
|
|
6
10
|
}
|
|
7
|
-
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) {
|
|
8
20
|
if (Array.isArray(value))
|
|
9
|
-
return value.map(
|
|
21
|
+
return value.map(legacyStable);
|
|
10
22
|
if (value && typeof value === "object")
|
|
11
|
-
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)]));
|
|
12
24
|
return value;
|
|
13
25
|
}
|
|
14
|
-
function
|
|
15
|
-
function
|
|
26
|
+
function legacyCanonical(value) { return `${JSON.stringify(legacyStable(value))}\n`; }
|
|
27
|
+
function resolveHeadLimits(limits) {
|
|
28
|
+
try {
|
|
29
|
+
if (limits !== undefined && (!limits || typeof limits !== "object" || types.isProxy(limits) || Array.isArray(limits)))
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
let supplied;
|
|
36
|
+
try {
|
|
37
|
+
const descriptors = limits === undefined ? {} : Object.getOwnPropertyDescriptors(limits);
|
|
38
|
+
if (Object.keys(descriptors).some((key) => !Object.hasOwn(VERIFIED_HEAD_MAX, key) || descriptors[key]?.get || descriptors[key]?.set))
|
|
39
|
+
return null;
|
|
40
|
+
supplied = Object.fromEntries(Object.entries(descriptors).map(([key, descriptor]) => [key, descriptor.value]));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
const resolved = { ...VERIFIED_HEAD_MAX };
|
|
46
|
+
for (const key of Object.keys(VERIFIED_HEAD_MAX)) {
|
|
47
|
+
const value = supplied[key];
|
|
48
|
+
if (value === undefined)
|
|
49
|
+
continue;
|
|
50
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0 || value > VERIFIED_HEAD_MAX[key])
|
|
51
|
+
return null;
|
|
52
|
+
resolved[key] = value;
|
|
53
|
+
}
|
|
54
|
+
return resolved;
|
|
55
|
+
}
|
|
56
|
+
async function lstatHead(file) { return lstat(file, { bigint: true }); }
|
|
57
|
+
function identity(stats) {
|
|
58
|
+
return { dev: String(stats.dev), ino: String(stats.ino), mode: String(stats.mode), size: String(stats.size), ctimeNs: String(stats.ctimeNs), mtimeNs: String(stats.mtimeNs) };
|
|
59
|
+
}
|
|
60
|
+
function sameIdentity(left, right) { return canonical(left) === canonical(right); }
|
|
61
|
+
function headToken(rootRealpath, sourceId, metadata) {
|
|
62
|
+
return createHash("sha256").update(canonical({ kind: "lookout.proposal-head-witness/v1", version: 1, rootRealpath, sourceId, metadata })).digest("hex");
|
|
63
|
+
}
|
|
64
|
+
function errno(cause) { return cause?.code; }
|
|
65
|
+
function boundedSourceId(sourceId) {
|
|
66
|
+
return typeof sourceId === "string" && sourceId.length > 0 && sourceId.length <= 256 && Buffer.byteLength(sourceId) <= 512;
|
|
67
|
+
}
|
|
68
|
+
async function boundedText(file, maximum) {
|
|
69
|
+
let handle;
|
|
70
|
+
try {
|
|
71
|
+
const before = await lstatHead(file);
|
|
72
|
+
if (before.isSymbolicLink() || !before.isFile())
|
|
73
|
+
return { kind: "corrupt" };
|
|
74
|
+
if (before.size > BigInt(maximum))
|
|
75
|
+
return { kind: "unavailable" };
|
|
76
|
+
handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
77
|
+
const opened = await handle.stat({ bigint: true });
|
|
78
|
+
if (!opened.isFile() || !sameIdentity(identity(before), identity(opened)) || opened.size > BigInt(maximum))
|
|
79
|
+
return { kind: "unavailable" };
|
|
80
|
+
const buffer = Buffer.alloc(Number(opened.size) + 1);
|
|
81
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
82
|
+
const after = await handle.stat({ bigint: true });
|
|
83
|
+
if (!sameIdentity(identity(opened), identity(after)) || bytesRead !== Number(opened.size) || bytesRead > maximum)
|
|
84
|
+
return { kind: "unavailable" };
|
|
85
|
+
return { kind: "ok", text: buffer.subarray(0, bytesRead).toString("utf8"), identity: identity(after) };
|
|
86
|
+
}
|
|
87
|
+
catch (cause) {
|
|
88
|
+
return errno(cause) === "ENOENT" ? { kind: "unavailable" } : { kind: "unavailable" };
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await handle?.close().catch(() => undefined);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function inspectHead(root, sourceId, limits) {
|
|
95
|
+
if (!boundedSourceId(sourceId))
|
|
96
|
+
return { kind: "unsupported" };
|
|
97
|
+
let key;
|
|
98
|
+
try {
|
|
99
|
+
key = sourceKey(sourceId);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return { kind: "unsupported" };
|
|
103
|
+
}
|
|
104
|
+
let rootStats;
|
|
105
|
+
try {
|
|
106
|
+
rootStats = await lstatHead(root);
|
|
107
|
+
}
|
|
108
|
+
catch (cause) {
|
|
109
|
+
return errno(cause) === "ENOENT" ? { kind: "missing" } : { kind: "unavailable" };
|
|
110
|
+
}
|
|
111
|
+
if (rootStats.isSymbolicLink() || !rootStats.isDirectory())
|
|
112
|
+
return { kind: "corrupt" };
|
|
113
|
+
let rootRealpath;
|
|
114
|
+
try {
|
|
115
|
+
rootRealpath = await realpath(root);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return { kind: "unavailable" };
|
|
119
|
+
}
|
|
120
|
+
const dir = path.join(root, key);
|
|
121
|
+
let sourceStats;
|
|
122
|
+
try {
|
|
123
|
+
sourceStats = await lstatHead(dir);
|
|
124
|
+
}
|
|
125
|
+
catch (cause) {
|
|
126
|
+
return errno(cause) === "ENOENT" ? { kind: "missing" } : { kind: "unavailable" };
|
|
127
|
+
}
|
|
128
|
+
if (sourceStats.isSymbolicLink() || !sourceStats.isDirectory())
|
|
129
|
+
return { kind: "corrupt" };
|
|
130
|
+
const names = [];
|
|
131
|
+
let indexBytes = 0;
|
|
132
|
+
try {
|
|
133
|
+
const directory = await opendir(dir, { bufferSize: 1 });
|
|
134
|
+
try {
|
|
135
|
+
for await (const entry of directory) {
|
|
136
|
+
if (names.length >= limits.maxEntries)
|
|
137
|
+
return { kind: "unavailable" };
|
|
138
|
+
indexBytes += Buffer.byteLength(entry.name);
|
|
139
|
+
if (indexBytes > limits.maxIndexBytes)
|
|
140
|
+
return { kind: "unavailable" };
|
|
141
|
+
if (entry.name === ".lock" || entry.name.includes(".tmp-"))
|
|
142
|
+
return { kind: "unavailable" };
|
|
143
|
+
if (entry.name !== "latest.json" && !/^[a-f0-9]{64}\.json$/.test(entry.name))
|
|
144
|
+
return { kind: "corrupt" };
|
|
145
|
+
const entryStats = await lstatHead(path.join(dir, entry.name));
|
|
146
|
+
if (entryStats.isSymbolicLink() || !entryStats.isFile())
|
|
147
|
+
return { kind: "corrupt" };
|
|
148
|
+
names.push(entry.name);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await directory.close().catch(() => undefined);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return { kind: "unavailable" };
|
|
157
|
+
}
|
|
158
|
+
names.sort();
|
|
159
|
+
if (!names.includes("latest.json"))
|
|
160
|
+
return names.length === 0 ? { kind: "missing" } : { kind: "corrupt" };
|
|
161
|
+
const pointerRead = await boundedText(path.join(dir, "latest.json"), limits.maxPointerBytes);
|
|
162
|
+
if (pointerRead.kind !== "ok")
|
|
163
|
+
return pointerRead;
|
|
164
|
+
let pointer;
|
|
165
|
+
try {
|
|
166
|
+
pointer = JSON.parse(pointerRead.text);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
return { kind: "corrupt" };
|
|
170
|
+
}
|
|
171
|
+
if (!pointer || typeof pointer !== "object" || Array.isArray(pointer) || pointer.version !== 1 || pointer.sourceId !== sourceId || typeof pointer.observationId !== "string" || !/^[a-f0-9]{64}$/.test(pointer.observationId))
|
|
172
|
+
return { kind: "corrupt" };
|
|
173
|
+
const recordName = `${pointer.observationId}.json`;
|
|
174
|
+
if (!names.includes(recordName))
|
|
175
|
+
return { kind: "corrupt" };
|
|
176
|
+
let recordStats;
|
|
177
|
+
try {
|
|
178
|
+
recordStats = await lstatHead(path.join(dir, recordName));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return { kind: "unavailable" };
|
|
182
|
+
}
|
|
183
|
+
if (recordStats.isSymbolicLink() || !recordStats.isFile())
|
|
184
|
+
return { kind: "corrupt" };
|
|
185
|
+
if (recordStats.size > BigInt(limits.maxRecordBytes))
|
|
186
|
+
return { kind: "unavailable" };
|
|
187
|
+
let finalRoot;
|
|
188
|
+
let finalSource;
|
|
189
|
+
try {
|
|
190
|
+
finalRoot = await lstatHead(root);
|
|
191
|
+
finalSource = await lstatHead(dir);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return { kind: "unavailable" };
|
|
195
|
+
}
|
|
196
|
+
if (!sameIdentity(identity(rootStats), identity(finalRoot)) || !sameIdentity(identity(sourceStats), identity(finalSource)))
|
|
197
|
+
return { kind: "unavailable" };
|
|
198
|
+
return { kind: "ready", metadata: { root: identity(rootStats), rootRealpath, source: identity(sourceStats), names, pointer: pointerRead.text, pointerIdentity: pointerRead.identity, observationId: pointer.observationId, record: identity(recordStats) } };
|
|
199
|
+
}
|
|
200
|
+
function snapshotWitness(value) {
|
|
201
|
+
try {
|
|
202
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || types.isProxy(value))
|
|
203
|
+
return { kind: "corrupt" };
|
|
204
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
205
|
+
const keys = ["kind", "version", "sourceId", "observationId", "token"];
|
|
206
|
+
if (Object.keys(descriptors).length !== keys.length || keys.some((key) => !Object.hasOwn(descriptors, key) || descriptors[key]?.get || descriptors[key]?.set))
|
|
207
|
+
return { kind: "corrupt" };
|
|
208
|
+
const witness = Object.fromEntries(keys.map((key) => [key, descriptors[key].value]));
|
|
209
|
+
if (witness.kind !== "lookout.proposal-head-witness/v1" || typeof witness.version !== "number")
|
|
210
|
+
return { kind: "unsupported" };
|
|
211
|
+
if (witness.version !== 1)
|
|
212
|
+
return { kind: "unsupported" };
|
|
213
|
+
if (!boundedSourceId(witness.sourceId) || typeof witness.observationId !== "string" || !/^[a-f0-9]{64}$/.test(witness.observationId) || typeof witness.token !== "string" || !/^[a-f0-9]{64}$/.test(witness.token))
|
|
214
|
+
return { kind: "corrupt" };
|
|
215
|
+
return { kind: "ok", witness: { kind: witness.kind, version: witness.version, sourceId: witness.sourceId, observationId: witness.observationId, token: witness.token } };
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return { kind: "corrupt" };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
16
221
|
function buildRecord(input) {
|
|
17
222
|
try {
|
|
18
223
|
const { observation, check } = input;
|
|
@@ -22,8 +227,8 @@ function buildRecord(input) {
|
|
|
22
227
|
if (!check || check.currentSnapshotRef !== observation.snapshotRef || typeof check.checkedAt !== "string" || (check.resultKind !== "changed" && check.resultKind !== "unchanged-hash") || typeof input.recordedAt !== "string") {
|
|
23
228
|
return { ok: false, error: { kind: "invalid-input", message: "Check anchor must match the current observation snapshot" } };
|
|
24
229
|
}
|
|
25
|
-
const proposals = [...observation.proposals].sort((a, b) => canonical(a)
|
|
26
|
-
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 };
|
|
27
232
|
try {
|
|
28
233
|
return { ok: true, value: { ...body, observationId: digest(body) } };
|
|
29
234
|
}
|
|
@@ -52,7 +257,7 @@ function validate(value, expectedSourceId) {
|
|
|
52
257
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
53
258
|
return { ok: false, error: { kind: "corrupt-state", message: "Stored observation is not an object" } };
|
|
54
259
|
const item = value;
|
|
55
|
-
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) {
|
|
56
261
|
return { ok: false, error: { kind: "corrupt-state", message: "Stored observation schema or continuity is invalid" } };
|
|
57
262
|
}
|
|
58
263
|
const { observationId, ...body } = item;
|
|
@@ -109,7 +314,7 @@ async function atomicWrite(file, bytes, kind, faults) {
|
|
|
109
314
|
}
|
|
110
315
|
}
|
|
111
316
|
export function createObservationStore(options = {}) {
|
|
112
|
-
const root = options.root ?? path.join(process.cwd(), ".kontourai", "lookout", "observations");
|
|
317
|
+
const root = path.resolve(options.root ?? path.join(process.cwd(), ".kontourai", "lookout", "observations"));
|
|
113
318
|
async function loadLatest(sourceId) {
|
|
114
319
|
try {
|
|
115
320
|
const dir = path.join(root, sourceKey(sourceId));
|
|
@@ -219,7 +424,7 @@ export function createObservationStore(options = {}) {
|
|
|
219
424
|
return null;
|
|
220
425
|
} }).filter((item) => item !== null);
|
|
221
426
|
const preserve = new Set([record.observationId, expectedPriorId].filter((item) => item !== null));
|
|
222
|
-
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));
|
|
223
428
|
await Promise.all(extras.map((item) => rm(path.join(dir, item.name), { force: true })));
|
|
224
429
|
}
|
|
225
430
|
catch (cause) {
|
|
@@ -237,5 +442,53 @@ export function createObservationStore(options = {}) {
|
|
|
237
442
|
await rm(lockPath, { force: true }).catch(() => undefined);
|
|
238
443
|
}
|
|
239
444
|
}
|
|
240
|
-
|
|
445
|
+
async function readVerifiedHead(sourceId, suppliedLimits) {
|
|
446
|
+
const limits = resolveHeadLimits(suppliedLimits);
|
|
447
|
+
if (limits === null)
|
|
448
|
+
return { kind: "unsupported" };
|
|
449
|
+
const before = await inspectHead(root, sourceId, limits);
|
|
450
|
+
if (before.kind !== "ready")
|
|
451
|
+
return before;
|
|
452
|
+
// This is the only body read in the witness API. It reuses the concrete store's
|
|
453
|
+
// authenticated record validator and is bracketed by metadata fences below.
|
|
454
|
+
options.faults?.beforeHeadStrongLoad?.();
|
|
455
|
+
options.faults?.beforeHeadRecordRead?.();
|
|
456
|
+
const record = await boundedText(path.join(root, sourceKey(sourceId), `${before.metadata.observationId}.json`), limits.maxRecordBytes);
|
|
457
|
+
if (record.kind !== "ok")
|
|
458
|
+
return record;
|
|
459
|
+
let loaded;
|
|
460
|
+
try {
|
|
461
|
+
loaded = validate(JSON.parse(record.text), sourceId);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
return { kind: "corrupt" };
|
|
465
|
+
}
|
|
466
|
+
if (!loaded.ok || loaded.value.observationId !== before.metadata.observationId)
|
|
467
|
+
return { kind: "corrupt" };
|
|
468
|
+
options.faults?.beforeHeadAfterFence?.();
|
|
469
|
+
const after = await inspectHead(root, sourceId, limits);
|
|
470
|
+
if (after.kind !== "ready")
|
|
471
|
+
return after;
|
|
472
|
+
if (canonical(before.metadata) !== canonical(after.metadata))
|
|
473
|
+
return { kind: "unavailable" };
|
|
474
|
+
const token = headToken(before.metadata.rootRealpath, sourceId, before.metadata);
|
|
475
|
+
return { kind: "verified", sourceId, observationId: loaded.value.observationId, snapshotRef: loaded.value.snapshotRef, witness: { kind: "lookout.proposal-head-witness/v1", version: 1, sourceId, observationId: loaded.value.observationId, token } };
|
|
476
|
+
}
|
|
477
|
+
async function compareHeadWitness(witness, suppliedLimits) {
|
|
478
|
+
const captured = snapshotWitness(witness);
|
|
479
|
+
if (captured.kind !== "ok")
|
|
480
|
+
return captured;
|
|
481
|
+
const limits = resolveHeadLimits(suppliedLimits);
|
|
482
|
+
if (limits === null)
|
|
483
|
+
return { kind: "unsupported" };
|
|
484
|
+
const inspected = await inspectHead(root, captured.witness.sourceId, limits);
|
|
485
|
+
if (inspected.kind !== "ready")
|
|
486
|
+
return inspected;
|
|
487
|
+
if (inspected.metadata.observationId !== captured.witness.observationId)
|
|
488
|
+
return { kind: "changed" };
|
|
489
|
+
return headToken(inspected.metadata.rootRealpath, captured.witness.sourceId, inspected.metadata) === captured.witness.token
|
|
490
|
+
? { kind: "matches", sourceId: captured.witness.sourceId, observationId: captured.witness.observationId }
|
|
491
|
+
: { kind: "changed" };
|
|
492
|
+
}
|
|
493
|
+
return { loadLatest, commit, readVerifiedHead, compareHeadWitness };
|
|
241
494
|
}
|
|
@@ -64,7 +64,24 @@ export interface ObserveExtractObservationIdentity {
|
|
|
64
64
|
*/
|
|
65
65
|
export interface ObserveExtractRecorder {
|
|
66
66
|
record(observation: ObserveExtractObservation): Promise<ObserveExtractObservationIdentity>;
|
|
67
|
+
/**
|
|
68
|
+
* The snapshot reference of this source's most recent recorded observation
|
|
69
|
+
* for which `extractedSnapshotRef(observation)` is not null, or `null` when
|
|
70
|
+
* there is none.
|
|
71
|
+
*
|
|
72
|
+
* An unchanged check is only skipped when its capture has the same URL and
|
|
73
|
+
* body hash as this snapshot. Otherwise a change that acquisition already
|
|
74
|
+
* persisted, but that was never extracted (for example because a provider
|
|
75
|
+
* failed), would be reported as unchanged forever.
|
|
76
|
+
*/
|
|
77
|
+
lastExtractedSnapshotRef(source: ObserveExtractSource): Promise<string | null>;
|
|
67
78
|
}
|
|
79
|
+
/**
|
|
80
|
+
* The snapshot an observation's extraction fully handled: the current snapshot
|
|
81
|
+
* of a `completed`, `partial`, or `unchanged` observation, else `null`.
|
|
82
|
+
* Recorders use this to answer `lastExtractedSnapshotRef`.
|
|
83
|
+
*/
|
|
84
|
+
export declare function extractedSnapshotRef(observation: ObserveExtractObservation): string | null;
|
|
68
85
|
export interface ObserveExtractDiffOptions {
|
|
69
86
|
readonly acquisition: ObserveExtractAcquisition;
|
|
70
87
|
readonly extraction: ObserveExtractExtraction;
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { validatePreparedArtifact } from "@kontourai/traverse";
|
|
2
|
+
import { parseSnapshotSourceRef } from "@kontourai/forage/fetch";
|
|
3
|
+
/**
|
|
4
|
+
* The snapshot an observation's extraction fully handled: the current snapshot
|
|
5
|
+
* of a `completed`, `partial`, or `unchanged` observation, else `null`.
|
|
6
|
+
* Recorders use this to answer `lastExtractedSnapshotRef`.
|
|
7
|
+
*/
|
|
8
|
+
export function extractedSnapshotRef(observation) {
|
|
9
|
+
const handled = observation.outcome === "completed" || observation.outcome === "partial" || observation.outcome === "unchanged";
|
|
10
|
+
return handled && observation.sourceSnapshot !== null ? observation.sourceSnapshot.currentSnapshotRef : null;
|
|
11
|
+
}
|
|
2
12
|
export function createObserveExtractDiff(options) {
|
|
3
13
|
return {
|
|
4
14
|
async observe(source) {
|
|
@@ -19,10 +29,26 @@ export function createObserveExtractDiff(options) {
|
|
|
19
29
|
if (check.kind === "error") {
|
|
20
30
|
return record(options.recorder, baseObservation(source, check, "acquisition-error", null, null, null, null));
|
|
21
31
|
}
|
|
32
|
+
let sourceSnapshot = snapshotFor(check);
|
|
22
33
|
if (check.kind === "unchanged-304" || check.kind === "unchanged-hash") {
|
|
23
|
-
|
|
34
|
+
// "Unchanged" compares with the latest stored capture, which may never
|
|
35
|
+
// have been extracted. Skip extraction only when the capture matches
|
|
36
|
+
// the last one that was; otherwise extract it against that baseline.
|
|
37
|
+
let extracted;
|
|
38
|
+
try {
|
|
39
|
+
extracted = await options.recorder.lastExtractedSnapshotRef(observationSource(source));
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
return { ok: false, error: error("recording-failed", "Observation recorder could not report the last extracted snapshot", cause) };
|
|
43
|
+
}
|
|
44
|
+
if (extracted !== null && (typeof extracted !== "string" || extracted === "")) {
|
|
45
|
+
return { ok: false, error: error("dependency-contract", "Observation recorder returned an invalid last extracted snapshot reference") };
|
|
46
|
+
}
|
|
47
|
+
if (extracted !== null && sameCapture(extracted, sourceSnapshot.currentSnapshotRef)) {
|
|
48
|
+
return record(options.recorder, baseObservation(source, check, "unchanged", sourceSnapshot, null, null, null));
|
|
49
|
+
}
|
|
50
|
+
sourceSnapshot = { priorSnapshotRef: extracted, currentSnapshotRef: sourceSnapshot.currentSnapshotRef };
|
|
24
51
|
}
|
|
25
|
-
const sourceSnapshot = snapshotFor(check);
|
|
26
52
|
let extraction;
|
|
27
53
|
try {
|
|
28
54
|
extraction = await options.extraction.extract({ source, snapshotRef: sourceSnapshot.currentSnapshotRef });
|
|
@@ -65,7 +91,7 @@ export function createObserveExtractDiff(options) {
|
|
|
65
91
|
}
|
|
66
92
|
function baseObservation(source, check, outcome, sourceSnapshot, preparedArtifact, proposalSet, attempt) {
|
|
67
93
|
return {
|
|
68
|
-
source:
|
|
94
|
+
source: observationSource(source),
|
|
69
95
|
check,
|
|
70
96
|
outcome,
|
|
71
97
|
sourceSnapshot,
|
|
@@ -93,6 +119,18 @@ async function record(recorder, observation) {
|
|
|
93
119
|
return { ok: false, error: error("recording-failed", "Observation recorder failed", cause), observation };
|
|
94
120
|
}
|
|
95
121
|
}
|
|
122
|
+
function observationSource(source) {
|
|
123
|
+
return { id: source.id, url: source.url, kind: source.kind };
|
|
124
|
+
}
|
|
125
|
+
/** Two references name the same capture content: same source, resource URL, and body hash. */
|
|
126
|
+
function sameCapture(left, right) {
|
|
127
|
+
if (left === right)
|
|
128
|
+
return true;
|
|
129
|
+
const a = parseSnapshotSourceRef(left);
|
|
130
|
+
const b = parseSnapshotSourceRef(right);
|
|
131
|
+
return a !== undefined && b !== undefined &&
|
|
132
|
+
a.sourceId === b.sourceId && a.url === b.url && a.bodyHash === b.bodyHash;
|
|
133
|
+
}
|
|
96
134
|
function snapshotFor(check) {
|
|
97
135
|
if (check.kind === "unchanged-304")
|
|
98
136
|
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
1
|
import type { 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): SnapshotStore;
|
|
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.6.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",
|
|
@@ -54,9 +54,13 @@
|
|
|
54
54
|
"@kontourai/flow-agents": "5.9.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
|
}
|