@evomap/evolver-core 2.0.0-beta.1 → 2.0.0-beta.11
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/assets/gep/genes.jsonl +5 -0
- package/dist/algo/candidateAssembly.js +23 -14
- package/dist/algo/capabilityCandidates.d.ts +2 -0
- package/dist/algo/capabilityCandidates.js +5 -0
- package/dist/algo/conversationSniffer.d.ts +18 -0
- package/dist/algo/conversationSniffer.js +149 -0
- package/dist/algo/cycleEngine.d.ts +11 -0
- package/dist/algo/cycleEngine.js +14 -6
- package/dist/algo/cycleFailureClassifier.d.ts +1 -1
- package/dist/algo/cycleFailureClassifier.js +13 -5
- package/dist/algo/geneIntake.d.ts +14 -4
- package/dist/algo/geneIntake.js +37 -9
- package/dist/algo/geneSelection.d.ts +18 -1
- package/dist/algo/geneSelection.js +42 -18
- package/dist/algo/index.d.ts +2 -0
- package/dist/algo/index.js +2 -0
- package/dist/algo/memoryGraph.d.ts +62 -0
- package/dist/algo/memoryGraph.js +86 -0
- package/dist/algo/orchestrator.d.ts +3 -0
- package/dist/algo/orchestrator.js +14 -2
- package/dist/assetstore/assetSidecarRecords.d.ts +28 -0
- package/dist/assetstore/assetSidecarRecords.js +376 -0
- package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
- package/dist/assetstore/assetSidecarRecovery.js +288 -0
- package/dist/assetstore/assetStoreHealth.d.ts +82 -0
- package/dist/assetstore/assetStoreHealth.js +308 -0
- package/dist/assetstore/assetStoreLayout.d.ts +2 -0
- package/dist/assetstore/assetStoreLayout.js +6 -0
- package/dist/assetstore/assetStoreStorage.d.ts +42 -0
- package/dist/assetstore/assetStoreStorage.js +327 -0
- package/dist/assetstore/assetSyncLedger.d.ts +118 -0
- package/dist/assetstore/assetSyncLedger.js +745 -0
- package/dist/assetstore/index.d.ts +5 -1
- package/dist/assetstore/index.js +5 -1
- package/dist/assetstore/localJsonl.d.ts +12 -3
- package/dist/assetstore/localJsonl.js +131 -39
- package/dist/assetstore/pendingSignals.js +3 -1
- package/dist/assetstore/provenance.d.ts +39 -4
- package/dist/assetstore/provenance.js +134 -55
- package/dist/assetstore/provider.d.ts +40 -0
- package/dist/assetstore/provider.js +63 -5
- package/dist/assetstore/reviewFilter.js +5 -2
- package/dist/assetstore/reviewLedger.d.ts +14 -2
- package/dist/assetstore/reviewLedger.js +78 -43
- package/dist/assetstore/seedGenes.d.ts +3 -0
- package/dist/assetstore/seedGenes.js +134 -0
- package/dist/benchmark/antiGeneBenchmark.d.ts +2 -0
- package/dist/benchmark/antiGeneBenchmark.js +11 -1
- package/dist/benchmark/antiGeneImpact.d.ts +16 -0
- package/dist/benchmark/antiGeneImpact.js +34 -0
- package/dist/benchmark/antiGeneRollout.d.ts +2 -0
- package/dist/benchmark/antiGeneRollout.js +13 -1
- package/dist/benchmark/index.d.ts +2 -1
- package/dist/benchmark/index.js +2 -1
- package/dist/benchmark/triggerShift.d.ts +62 -0
- package/dist/benchmark/triggerShift.js +106 -0
- package/dist/events/eventArchive.d.ts +65 -0
- package/dist/events/eventArchive.js +343 -0
- package/dist/events/eventStore.js +2 -12
- package/dist/events/ingest.d.ts +1 -1
- package/dist/events/ingest.js +9 -0
- package/dist/events/paths.d.ts +10 -10
- package/dist/events/paths.js +20 -20
- package/dist/events/public.d.ts +3 -1
- package/dist/events/public.js +2 -1
- package/dist/events/retention.d.ts +24 -1
- package/dist/events/retention.js +133 -37
- package/dist/exec/autoExec.d.ts +6 -1
- package/dist/exec/autoExec.js +34 -0
- package/dist/exec/autonomousCycle.d.ts +2 -0
- package/dist/exec/autonomousCycle.js +5 -0
- package/dist/exec/claudeBridge.d.ts +21 -3
- package/dist/exec/claudeBridge.js +413 -26
- package/dist/exec/openPrRegistry.d.ts +8 -2
- package/dist/exec/openPrRegistry.js +32 -22
- package/dist/exec/prompt.js +9 -0
- package/dist/exec/runnerRegistry.d.ts +90 -18
- package/dist/exec/runnerRegistry.js +603 -42
- package/dist/exec/selfPrObfuscation.d.ts +2 -1
- package/dist/exec/selfPrObfuscation.js +19 -6
- package/dist/hub/agentDirectory.d.ts +90 -0
- package/dist/hub/agentDirectory.js +104 -0
- package/dist/hub/bindings.js +23 -3
- package/dist/hub/capability.d.ts +14 -2
- package/dist/hub/fake.d.ts +4 -2
- package/dist/hub/fake.js +3 -2
- package/dist/hub/index.d.ts +1 -0
- package/dist/hub/index.js +1 -0
- package/dist/hub/sanitize.js +3 -2
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/issueReporter/index.d.ts +156 -0
- package/dist/issueReporter/index.js +1688 -0
- package/dist/mailbox/catalog.js +3 -0
- package/dist/mailbox/ipcServer.js +2 -2
- package/dist/mailbox/store.d.ts +14 -0
- package/dist/mailbox/store.js +44 -2
- package/dist/material/consumer.js +9 -6
- package/dist/material/index.d.ts +1 -0
- package/dist/material/index.js +1 -0
- package/dist/material/materialArchive.d.ts +81 -0
- package/dist/material/materialArchive.js +466 -0
- package/dist/material/materialStore.d.ts +1 -0
- package/dist/material/materialStore.js +6 -13
- package/dist/ops/savingsCore.js +1 -2
- package/dist/ops/selfUpdate.d.ts +10 -1
- package/dist/ops/selfUpdate.js +64 -15
- package/dist/personality/schema.d.ts +12 -12
- package/dist/schema/common.d.ts +1 -1
- package/dist/schema/common.js +1 -1
- package/dist/schema/material.d.ts +5 -5
- package/dist/strategy/constraintAblation.d.ts +64 -0
- package/dist/strategy/constraintAblation.js +3074 -0
- package/dist/strategy/index.d.ts +2 -1
- package/dist/strategy/index.js +2 -1
- package/dist/trace/trajectoryExport.js +2 -2
- package/dist/util/fetchPort.d.ts +1 -0
- package/dist/util/fetchPort.js +11 -0
- package/dist/util/fileLock.d.ts +24 -5
- package/dist/util/fileLock.js +288 -72
- package/dist/util/index.d.ts +1 -0
- package/dist/util/index.js +1 -0
- package/dist/wire/geneHints.d.ts +42 -1
- package/dist/wire/geneHints.js +52 -1
- package/dist/wire/index.d.ts +14 -2
- package/dist/wire/index.js +1 -1
- package/dist/workflow/dsl.d.ts +24 -3
- package/dist/workflow/dsl.js +4 -0
- package/dist/workflow/engine.d.ts +5 -1
- package/dist/workflow/engine.js +3 -0
- package/dist/workflow/index.d.ts +3 -1
- package/dist/workflow/index.js +3 -1
- package/dist/workflow/runtime.d.ts +110 -0
- package/dist/workflow/runtime.js +1298 -0
- package/dist/workflow/stateStore.d.ts +172 -0
- package/dist/workflow/stateStore.js +1044 -0
- package/package.json +6 -1
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
// by asset_id, NOT a field on the asset: asset_id = sha256(canonicalize(asset)), and "how we got it" metadata
|
|
4
4
|
// must not enter the content hash (#30.2), or it would break content-addressing. Trust-first by construction:
|
|
5
5
|
// selection defaults to trusted-only; an untrusted asset is promoted to trusted only by an explicit, logged act.
|
|
6
|
-
import { appendFileSync, closeSync, existsSync, openSync, readFileSync, readSync, mkdirSync, statSync, truncateSync } from 'node:fs';
|
|
7
6
|
import { join, dirname } from 'node:path';
|
|
8
|
-
import { normalizeForPut } from './provider.js';
|
|
7
|
+
import { assertCapsuleGeneBinding, normalizeForPut, supportsAtomicConditionalPut, validateConditionalPutResult, } from './provider.js';
|
|
8
|
+
import { appendUtf8Durable, assertAssetStoreDirectory, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, truncateUtf8SuffixDurable, withAssetStoreLock, } from './assetStoreStorage.js';
|
|
9
|
+
import { assertTrustSidecarHealthy, parseProvenanceRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
|
|
10
|
+
function immutableRecord(record) {
|
|
11
|
+
return Object.freeze({ ...record });
|
|
12
|
+
}
|
|
9
13
|
/**
|
|
10
14
|
* Append-only JSONL sidecar (last-write-wins) at <baseDir>/provenance.jsonl. Default for an asset with NO
|
|
11
15
|
* record = trusted: the only local writers (cycleEngine self-produce, v1 migration) are trusted and never
|
|
@@ -14,81 +18,113 @@ import { normalizeForPut } from './provider.js';
|
|
|
14
18
|
export class ProvenanceStore {
|
|
15
19
|
now;
|
|
16
20
|
path;
|
|
21
|
+
lockPath;
|
|
17
22
|
index = new Map();
|
|
18
|
-
|
|
23
|
+
fileState = null;
|
|
19
24
|
constructor(baseDir, now = Date.now) {
|
|
20
25
|
this.now = now;
|
|
26
|
+
ensureAssetStoreDirectory(baseDir);
|
|
21
27
|
this.path = join(baseDir, 'provenance.jsonl');
|
|
28
|
+
this.lockPath = join(baseDir, '.assetstore.lock');
|
|
22
29
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const r = JSON.parse(line);
|
|
32
|
-
if (r.assetId)
|
|
33
|
-
this.index.set(r.assetId, r);
|
|
34
|
-
}
|
|
35
|
-
catch { /* skip corrupt line */ }
|
|
36
|
-
}
|
|
30
|
+
rebuildIndex(state) {
|
|
31
|
+
const next = new Map();
|
|
32
|
+
const raw = state === 'missing' ? null : readUtf8Regular(this.path);
|
|
33
|
+
if (raw !== null) {
|
|
34
|
+
const parsed = parseSidecarJsonl(raw, parseProvenanceRecord);
|
|
35
|
+
assertTrustSidecarHealthy('provenance', parsed);
|
|
36
|
+
for (const record of parsed.records)
|
|
37
|
+
next.set(record.assetId, immutableRecord(record));
|
|
37
38
|
}
|
|
38
|
-
this.
|
|
39
|
+
this.index.clear();
|
|
40
|
+
for (const [assetId, record] of next)
|
|
41
|
+
this.index.set(assetId, record);
|
|
42
|
+
this.fileState = state;
|
|
43
|
+
}
|
|
44
|
+
refreshUnderLock() {
|
|
45
|
+
const state = regularFileFingerprint(this.path);
|
|
46
|
+
if (state !== this.fileState)
|
|
47
|
+
this.rebuildIndex(state);
|
|
48
|
+
}
|
|
49
|
+
withFreshRead(read) {
|
|
50
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
51
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
52
|
+
this.refreshUnderLock();
|
|
53
|
+
return read(this.index);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
appendUnderLock(full) {
|
|
57
|
+
const stored = immutableRecord(full);
|
|
58
|
+
appendUtf8Durable(this.path, `${JSON.stringify(stored)}\n`);
|
|
59
|
+
this.index.set(stored.assetId, stored);
|
|
60
|
+
this.fileState = regularFileFingerprint(this.path);
|
|
61
|
+
return stored;
|
|
39
62
|
}
|
|
40
63
|
/** Record provenance for an asset_id (append-only; the JSONL history is the audit trail). */
|
|
41
64
|
mark(rec) {
|
|
42
|
-
this.load();
|
|
43
65
|
const full = { ...rec, at: rec.at ?? new Date(this.now()).toISOString() };
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
66
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
67
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
68
|
+
this.refreshUnderLock();
|
|
69
|
+
return this.appendUnderLock(full);
|
|
70
|
+
});
|
|
48
71
|
}
|
|
49
72
|
rollbackLast(rec) {
|
|
50
73
|
const line = `${JSON.stringify(rec)}\n`;
|
|
51
|
-
const lineBytes = Buffer.byteLength(line, 'utf8');
|
|
52
74
|
try {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
try {
|
|
62
|
-
readSync(fd, buf, 0, lineBytes, offset);
|
|
63
|
-
}
|
|
64
|
-
finally {
|
|
65
|
-
closeSync(fd);
|
|
66
|
-
}
|
|
67
|
-
if (buf.toString('utf8') !== line)
|
|
68
|
-
return;
|
|
69
|
-
truncateSync(this.path, offset);
|
|
70
|
-
this.index.clear();
|
|
71
|
-
this.loaded = false;
|
|
75
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
76
|
+
withAssetStoreLock(this.lockPath, () => {
|
|
77
|
+
if (!truncateUtf8SuffixDurable(this.path, line)) {
|
|
78
|
+
this.refreshUnderLock();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
this.rebuildIndex(regularFileFingerprint(this.path));
|
|
82
|
+
});
|
|
72
83
|
}
|
|
73
84
|
catch {
|
|
74
85
|
this.index.clear();
|
|
75
|
-
this.
|
|
86
|
+
this.fileState = null;
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
get(assetId) {
|
|
79
|
-
this.
|
|
80
|
-
return this.index.get(assetId) ?? null;
|
|
90
|
+
return this.withFreshRead((index) => index.get(assetId) ?? null);
|
|
81
91
|
}
|
|
82
92
|
/** No record → trusted (local default); a record → its trusted flag. */
|
|
83
93
|
isTrusted(assetId) {
|
|
84
|
-
this.
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
return this.withFreshRead((index) => index.get(assetId)?.trusted ?? true);
|
|
95
|
+
}
|
|
96
|
+
/** One linearizable trust snapshot for bounded batch readers. */
|
|
97
|
+
snapshot() {
|
|
98
|
+
return this.withFreshRead((index) => new Map(index));
|
|
99
|
+
}
|
|
100
|
+
/** Compare and append one trust decision under the same cross-process lock. */
|
|
101
|
+
changeTrust(assetId, trusted, by, reason) {
|
|
102
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
103
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
104
|
+
this.refreshUnderLock();
|
|
105
|
+
const current = this.index.get(assetId) ?? null;
|
|
106
|
+
if (current?.trusted === trusted)
|
|
107
|
+
return { changed: false, record: current };
|
|
108
|
+
const full = {
|
|
109
|
+
assetId,
|
|
110
|
+
source: current?.source ?? 'local',
|
|
111
|
+
trusted,
|
|
112
|
+
at: new Date(this.now()).toISOString(),
|
|
113
|
+
decision: trusted ? 'promoted' : 'revoked',
|
|
114
|
+
decidedBy: by,
|
|
115
|
+
...(trusted ? { promotedBy: by } : {}),
|
|
116
|
+
reason,
|
|
117
|
+
};
|
|
118
|
+
return { changed: true, record: this.appendUnderLock(full) };
|
|
119
|
+
});
|
|
87
120
|
}
|
|
88
121
|
/** Explicit, audited untrusted→trusted promotion. Appends a new trusted record carrying who/why. */
|
|
89
122
|
promote(assetId, by, reason) {
|
|
90
|
-
|
|
91
|
-
|
|
123
|
+
return this.changeTrust(assetId, true, by, reason).record;
|
|
124
|
+
}
|
|
125
|
+
/** Explicit, audited trusted-to-untrusted revocation. A record-less asset is local by default. */
|
|
126
|
+
revoke(assetId, by, reason) {
|
|
127
|
+
return this.changeTrust(assetId, false, by, reason).record;
|
|
92
128
|
}
|
|
93
129
|
}
|
|
94
130
|
/**
|
|
@@ -99,11 +135,54 @@ export class ProvenanceStore {
|
|
|
99
135
|
export async function ingestUntrusted(store, prov, record, source = 'hub') {
|
|
100
136
|
const normalized = normalizeForPut(record);
|
|
101
137
|
const mark = prov.mark({ assetId: normalized.record.asset_id, source, trusted: false });
|
|
102
|
-
|
|
103
|
-
|
|
138
|
+
const result = await store.put(record);
|
|
139
|
+
// A thrown write has an ambiguous outcome: the asset may have reached disk before the acknowledgement was
|
|
140
|
+
// lost. Keep the untrusted marker in that case so a persisted Hub asset can never fall through the default
|
|
141
|
+
// no-record => trusted policy. Only an explicit no-write result is safe to roll back.
|
|
142
|
+
if (!result.stored)
|
|
143
|
+
prov.rollbackLast(mark);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Hub → local-pool landing for an asset whose content does NOT hash to its declared asset_id. The hub
|
|
148
|
+
* demonstrably rewrites delivered payloads (injected `validation`, wholesale `payload_backfill_reason`
|
|
149
|
+
* synthesis — evolver-v2#570), which breaks {@link ingestUntrusted}'s normalizeForPut self-consistency check
|
|
150
|
+
* even though the loop's own in-run reuse already consumes hub content without re-verifying it (adapter
|
|
151
|
+
* `hubReuse.ts`). Rather than hard-reject a save the operator explicitly asked for, freeze the asset under its
|
|
152
|
+
* declared (network) asset_id via `putFrozen` and mark it untrusted in the sidecar with an explicit reason.
|
|
153
|
+
* Trust-first still holds (#30.1): selection defaults to trusted-only, so an unverified asset never silently
|
|
154
|
+
* enters the reasoning pool — it lands where the operator put it and stays flagged until an explicit,
|
|
155
|
+
* audited promotion. `reason` records WHY verification was waived (e.g. hub rewrite vs synthesized payload).
|
|
156
|
+
*/
|
|
157
|
+
export async function ingestUnverified(store, prov, record, reason, source = 'hub') {
|
|
158
|
+
if (typeof store.putFrozen !== 'function') {
|
|
159
|
+
// Only a content-addressed local pool receives hub reuse writes; a provider that cannot freeze a
|
|
160
|
+
// hash-inconsistent record cannot preserve the network id, so fail loudly rather than silently restamp it.
|
|
161
|
+
throw new Error('ingestUnverified requires a store that implements putFrozen');
|
|
104
162
|
}
|
|
105
|
-
|
|
163
|
+
// putFrozen bypasses normalizeForPut, so re-assert the M3-4 Capsule↔gene binding here — a hash-mismatched
|
|
164
|
+
// Capsule with an empty gene must still fail closed on the frozen path, exactly as it does on the verified one.
|
|
165
|
+
assertCapsuleGeneBinding(record);
|
|
166
|
+
const mark = prov.mark({ assetId: record.asset_id, source, trusted: false, reason });
|
|
167
|
+
const result = await store.putFrozen(record);
|
|
168
|
+
// Mirror ingestUntrusted: only an explicit no-write result (dedup) is safe to roll the marker back; a
|
|
169
|
+
// thrown write has an ambiguous on-disk outcome and must keep the untrusted marker.
|
|
170
|
+
if (!result.stored)
|
|
106
171
|
prov.rollbackLast(mark);
|
|
107
|
-
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Conditional variant used by Hub sync to reject a logical-id collision without ever allowing a Hub record
|
|
176
|
+
* to become implicitly trusted. Providers that cannot make the condition atomically are rejected here.
|
|
177
|
+
*/
|
|
178
|
+
export async function ingestUntrustedConditional(store, prov, record, options, source = 'hub') {
|
|
179
|
+
if (!supportsAtomicConditionalPut(store)) {
|
|
180
|
+
throw new Error('asset store does not support conditional writes');
|
|
108
181
|
}
|
|
182
|
+
const normalized = normalizeForPut(record);
|
|
183
|
+
const mark = prov.mark({ assetId: normalized.record.asset_id, source, trusted: false });
|
|
184
|
+
const result = validateConditionalPutResult(await store.putConditional(record, options), normalized.record.asset_id, options);
|
|
185
|
+
if (!result.stored)
|
|
186
|
+
prov.rollbackLast(mark);
|
|
187
|
+
return result;
|
|
109
188
|
}
|
|
@@ -10,6 +10,24 @@ export interface PutResult {
|
|
|
10
10
|
stored: boolean;
|
|
11
11
|
verified: boolean;
|
|
12
12
|
}
|
|
13
|
+
export type ConditionalPutStatus = 'stored' | 'already_exists' | 'logical_collision';
|
|
14
|
+
export interface ConditionalPutOptions {
|
|
15
|
+
/** Only explicit force-like callers may keep multiple content versions for the same type + logical id. */
|
|
16
|
+
allowLogicalCollision?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ConditionalPutResult extends PutResult {
|
|
19
|
+
status: ConditionalPutStatus;
|
|
20
|
+
logicalId?: string;
|
|
21
|
+
collisionWithAssetId?: string;
|
|
22
|
+
}
|
|
23
|
+
export type InvalidConditionalPutResultReason = 'malformed_result' | 'asset_id_mismatch' | 'inconsistent_status' | 'invalid_collision' | 'collision_bypass';
|
|
24
|
+
export declare class InvalidConditionalPutResultError extends Error {
|
|
25
|
+
readonly reason: InvalidConditionalPutResultReason;
|
|
26
|
+
readonly code = "INVALID_CONDITIONAL_PUT_RESULT";
|
|
27
|
+
constructor(reason: InvalidConditionalPutResultReason);
|
|
28
|
+
}
|
|
29
|
+
/** Validate an injected provider response before callers treat it as an explicit write/no-write decision. */
|
|
30
|
+
export declare function validateConditionalPutResult(value: unknown, expectedAssetId: string, options?: ConditionalPutOptions): ConditionalPutResult;
|
|
13
31
|
export interface SearchQuery {
|
|
14
32
|
kind?: AssetKind;
|
|
15
33
|
signalsAny?: string[];
|
|
@@ -24,10 +42,26 @@ export interface SearchQuery {
|
|
|
24
42
|
*/
|
|
25
43
|
export interface AssetStoreProvider {
|
|
26
44
|
put(asset: AssetRecord): Promise<PutResult>;
|
|
45
|
+
/** Optional atomic capability; use supportsAtomicConditionalPut() before calling through this interface. */
|
|
46
|
+
putConditional?(asset: AssetRecord, options?: ConditionalPutOptions): Promise<ConditionalPutResult>;
|
|
27
47
|
get(assetId: string): Promise<AssetRecord | null>;
|
|
48
|
+
/** Optional direct lookup for non-content-addressed logical ids. Callers must handle 0, 1, or multiple matches. */
|
|
49
|
+
findByLogicalId?(id: string, limit?: number): Promise<AssetRecord[]>;
|
|
50
|
+
/**
|
|
51
|
+
* Optional frozen write: store the record under its OWN declared asset_id without recomputing or
|
|
52
|
+
* normalizing it (bypasses {@link normalizeForPut}'s self-consistency check). Only providers backing a
|
|
53
|
+
* content-addressed local pool implement it. Callers that must preserve a hash-inconsistent asset —
|
|
54
|
+
* v1 migration import, and unverified hub reuse of a hub-rewritten payload (see `ingestUnverified`) —
|
|
55
|
+
* feature-detect it rather than assuming it exists.
|
|
56
|
+
*/
|
|
57
|
+
putFrozen?(record: AssetRecord): Promise<PutResult>;
|
|
28
58
|
search(query: SearchQuery): Promise<AssetRecord[]>;
|
|
29
59
|
list(kind?: AssetKind, limit?: number): Promise<AssetRecord[]>;
|
|
30
60
|
}
|
|
61
|
+
export type AtomicConditionalPutProvider = AssetStoreProvider & {
|
|
62
|
+
putConditional(asset: AssetRecord, options?: ConditionalPutOptions): Promise<ConditionalPutResult>;
|
|
63
|
+
};
|
|
64
|
+
export declare function supportsAtomicConditionalPut(provider: AssetStoreProvider): provider is AtomicConditionalPutProvider;
|
|
31
65
|
export declare class AssetIdMismatchError extends Error {
|
|
32
66
|
readonly claimed: string;
|
|
33
67
|
readonly actual: string;
|
|
@@ -36,6 +70,12 @@ export declare class AssetIdMismatchError extends Error {
|
|
|
36
70
|
export declare class CapsuleGeneBindingError extends Error {
|
|
37
71
|
constructor();
|
|
38
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* M3-4 强绑定校验: Capsule.gene 必须非空(否则一条无来源基因的经验会污染选择池).
|
|
75
|
+
* 抽成独立 helper 以便 normalizeForPut(校验落库路径)与 ingestUnverified(冻结落库路径,
|
|
76
|
+
* 绕过 normalizeForPut)共用同一条不变量,不让降级路径把绑定校验漏掉.
|
|
77
|
+
*/
|
|
78
|
+
export declare function assertCapsuleGeneBinding(asset: AssetRecord): void;
|
|
39
79
|
/**
|
|
40
80
|
* 落库前规范化(共享给各 provider): 计算/校验 asset_id + 强绑定校验.
|
|
41
81
|
* - 缺 asset_id → 计算填入(verified=false 表示非入参自带).
|
|
@@ -1,4 +1,54 @@
|
|
|
1
1
|
import { computeAssetId, verifyAssetId } from '../wire/index.js';
|
|
2
|
+
export class InvalidConditionalPutResultError extends Error {
|
|
3
|
+
reason;
|
|
4
|
+
code = 'INVALID_CONDITIONAL_PUT_RESULT';
|
|
5
|
+
constructor(reason) {
|
|
6
|
+
super(`invalid conditional put result: ${reason}`);
|
|
7
|
+
this.reason = reason;
|
|
8
|
+
this.name = 'InvalidConditionalPutResultError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** Validate an injected provider response before callers treat it as an explicit write/no-write decision. */
|
|
12
|
+
export function validateConditionalPutResult(value, expectedAssetId, options) {
|
|
13
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
14
|
+
throw new InvalidConditionalPutResultError('malformed_result');
|
|
15
|
+
}
|
|
16
|
+
const result = value;
|
|
17
|
+
const status = result['status'];
|
|
18
|
+
const collisionWithAssetId = result['collisionWithAssetId'];
|
|
19
|
+
if (typeof result['asset_id'] !== 'string'
|
|
20
|
+
|| typeof result['stored'] !== 'boolean'
|
|
21
|
+
|| typeof result['verified'] !== 'boolean'
|
|
22
|
+
|| (status !== 'stored' && status !== 'already_exists' && status !== 'logical_collision')
|
|
23
|
+
|| (result['logicalId'] !== undefined
|
|
24
|
+
&& (typeof result['logicalId'] !== 'string'
|
|
25
|
+
|| !result['logicalId'].trim()
|
|
26
|
+
|| result['logicalId'] !== result['logicalId'].trim()))
|
|
27
|
+
|| (collisionWithAssetId !== undefined
|
|
28
|
+
&& (typeof collisionWithAssetId !== 'string'
|
|
29
|
+
|| !collisionWithAssetId.trim()
|
|
30
|
+
|| collisionWithAssetId !== collisionWithAssetId.trim()))) {
|
|
31
|
+
throw new InvalidConditionalPutResultError('malformed_result');
|
|
32
|
+
}
|
|
33
|
+
if (result['asset_id'] !== expectedAssetId) {
|
|
34
|
+
throw new InvalidConditionalPutResultError('asset_id_mismatch');
|
|
35
|
+
}
|
|
36
|
+
if (result['stored'] !== (status === 'stored')) {
|
|
37
|
+
throw new InvalidConditionalPutResultError('inconsistent_status');
|
|
38
|
+
}
|
|
39
|
+
const allowLogicalCollision = options?.allowLogicalCollision === true;
|
|
40
|
+
if (status === 'logical_collision'
|
|
41
|
+
&& (!collisionWithAssetId || collisionWithAssetId === expectedAssetId || allowLogicalCollision)) {
|
|
42
|
+
throw new InvalidConditionalPutResultError('invalid_collision');
|
|
43
|
+
}
|
|
44
|
+
if (status === 'stored' && collisionWithAssetId && !allowLogicalCollision) {
|
|
45
|
+
throw new InvalidConditionalPutResultError('collision_bypass');
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
export function supportsAtomicConditionalPut(provider) {
|
|
50
|
+
return typeof provider.putConditional === 'function';
|
|
51
|
+
}
|
|
2
52
|
export class AssetIdMismatchError extends Error {
|
|
3
53
|
claimed;
|
|
4
54
|
actual;
|
|
@@ -12,6 +62,18 @@ export class AssetIdMismatchError extends Error {
|
|
|
12
62
|
export class CapsuleGeneBindingError extends Error {
|
|
13
63
|
constructor() { super('Capsule.gene 必须非空或显式 "ad-hoc" 哨兵 (批注#28/M3-4)'); this.name = 'CapsuleGeneBindingError'; }
|
|
14
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* M3-4 强绑定校验: Capsule.gene 必须非空(否则一条无来源基因的经验会污染选择池).
|
|
67
|
+
* 抽成独立 helper 以便 normalizeForPut(校验落库路径)与 ingestUnverified(冻结落库路径,
|
|
68
|
+
* 绕过 normalizeForPut)共用同一条不变量,不让降级路径把绑定校验漏掉.
|
|
69
|
+
*/
|
|
70
|
+
export function assertCapsuleGeneBinding(asset) {
|
|
71
|
+
if (asset.type !== 'Capsule')
|
|
72
|
+
return;
|
|
73
|
+
const gene = asset.gene;
|
|
74
|
+
if (typeof gene !== 'string' || gene.length === 0)
|
|
75
|
+
throw new CapsuleGeneBindingError();
|
|
76
|
+
}
|
|
15
77
|
/**
|
|
16
78
|
* 落库前规范化(共享给各 provider): 计算/校验 asset_id + 强绑定校验.
|
|
17
79
|
* - 缺 asset_id → 计算填入(verified=false 表示非入参自带).
|
|
@@ -19,11 +81,7 @@ export class CapsuleGeneBindingError extends Error {
|
|
|
19
81
|
* - Capsule.gene 必须非空(M3-4 强绑定).
|
|
20
82
|
*/
|
|
21
83
|
export function normalizeForPut(asset) {
|
|
22
|
-
|
|
23
|
-
const gene = asset.gene;
|
|
24
|
-
if (typeof gene !== 'string' || gene.length === 0)
|
|
25
|
-
throw new CapsuleGeneBindingError();
|
|
26
|
-
}
|
|
84
|
+
assertCapsuleGeneBinding(asset);
|
|
27
85
|
const actual = computeAssetId(asset);
|
|
28
86
|
if (actual === null)
|
|
29
87
|
throw new Error('computeAssetId 失败: 资产非对象');
|
|
@@ -30,11 +30,14 @@ export function provenanceStoreForStore(store) {
|
|
|
30
30
|
*/
|
|
31
31
|
export async function listApprovedGenes(store, review, maxGenes, provenance = provenanceStoreForStore(store)) {
|
|
32
32
|
const all = await store.list('Gene', GENE_SCAN_LIMIT);
|
|
33
|
+
const trust = provenance.snapshot();
|
|
34
|
+
const reviewed = review.snapshot();
|
|
33
35
|
const approved = [];
|
|
34
36
|
for (const g of all) {
|
|
35
|
-
if (
|
|
37
|
+
if (trust.get(String(g.asset_id))?.trusted === false)
|
|
36
38
|
continue; // hub-untrusted → withhold until promoted
|
|
37
|
-
|
|
39
|
+
const reviewRecord = reviewed.get(String(g.asset_id));
|
|
40
|
+
if (reviewRecord !== undefined && reviewRecord.state !== 'approved')
|
|
38
41
|
continue; // quarantined/rejected draft → withhold
|
|
39
42
|
approved.push(g);
|
|
40
43
|
if (approved.length >= maxGenes)
|
|
@@ -18,13 +18,17 @@ export interface ReviewRecord {
|
|
|
18
18
|
export declare class ReviewLedger {
|
|
19
19
|
private readonly now;
|
|
20
20
|
private readonly path;
|
|
21
|
+
private readonly lockPath;
|
|
21
22
|
private readonly index;
|
|
22
|
-
private
|
|
23
|
+
private fileState;
|
|
23
24
|
constructor(baseDir: string, now?: () => number);
|
|
24
25
|
private static isHuman;
|
|
25
26
|
/** Which record wins for an asset_id: a human decision beats a quarantine; otherwise the later one wins. */
|
|
26
27
|
private static keep;
|
|
27
|
-
private
|
|
28
|
+
private rebuildIndex;
|
|
29
|
+
private refreshUnderLock;
|
|
30
|
+
private withFreshRead;
|
|
31
|
+
private appendUnderLock;
|
|
28
32
|
/** Record a review-state for an asset_id (append-only; the JSONL history is the audit trail). */
|
|
29
33
|
mark(rec: Omit<ReviewRecord, 'at'> & {
|
|
30
34
|
at?: string;
|
|
@@ -50,6 +54,14 @@ export declare class ReviewLedger {
|
|
|
50
54
|
* asset list, so a draft awaiting approval is never missed behind a store-list cutoff.
|
|
51
55
|
*/
|
|
52
56
|
records(): ReviewRecord[];
|
|
57
|
+
/** One linearizable review snapshot for bounded batch readers. */
|
|
58
|
+
snapshot(): ReadonlyMap<string, ReviewRecord>;
|
|
53
59
|
/** No record → approved (default eligible); a record → approved only when its state is 'approved'. */
|
|
54
60
|
isApproved(assetId: string): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* True only when a human approval record exists. Safety-sensitive consumers such as AntiGene warning
|
|
63
|
+
* injection must fail closed when review state is absent; unlike cycle/migrate-authored Genes, an unreviewed
|
|
64
|
+
* negative-memory asset must never inherit the ledger's backward-compatible "no record = eligible" default.
|
|
65
|
+
*/
|
|
66
|
+
isExplicitlyApproved(assetId: string): boolean;
|
|
55
67
|
}
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Default (no record) = approved/eligible: the only writer of quarantine records is `--distill`; cycle-self-
|
|
10
10
|
// produced and v1-migrated genes are never quarantined, so the explore→prove loop is untouched for them.
|
|
11
|
-
import { appendFileSync, existsSync, readFileSync, mkdirSync, statSync } from 'node:fs';
|
|
12
11
|
import { join, dirname } from 'node:path';
|
|
12
|
+
import { appendUtf8Durable, assertAssetStoreDirectory, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, withAssetStoreLock, } from './assetStoreStorage.js';
|
|
13
|
+
import { assertTrustSidecarHealthy, parseReviewRecord, parseSidecarJsonl, } from './assetSidecarRecords.js';
|
|
14
|
+
function immutableRecord(record) {
|
|
15
|
+
return Object.freeze({ ...record });
|
|
16
|
+
}
|
|
13
17
|
/**
|
|
14
18
|
* Append-only JSONL sidecar at <baseDir>/review.jsonl. Default for an asset with NO record = approved (eligible):
|
|
15
19
|
* only auto-distilled drafts are quarantined here; everything else is eligible by default.
|
|
@@ -22,11 +26,14 @@ import { join, dirname } from 'node:path';
|
|
|
22
26
|
export class ReviewLedger {
|
|
23
27
|
now;
|
|
24
28
|
path;
|
|
29
|
+
lockPath;
|
|
25
30
|
index = new Map();
|
|
26
|
-
|
|
31
|
+
fileState = null;
|
|
27
32
|
constructor(baseDir, now = Date.now) {
|
|
28
33
|
this.now = now;
|
|
34
|
+
ensureAssetStoreDirectory(baseDir);
|
|
29
35
|
this.path = join(baseDir, 'review.jsonl');
|
|
36
|
+
this.lockPath = join(baseDir, '.assetstore.lock');
|
|
30
37
|
}
|
|
31
38
|
static isHuman(s) { return s === 'approved' || s === 'rejected'; }
|
|
32
39
|
/** Which record wins for an asset_id: a human decision beats a quarantine; otherwise the later one wins. */
|
|
@@ -37,43 +44,50 @@ export class ReviewLedger {
|
|
|
37
44
|
return r; // human decision always wins (and later human beats earlier)
|
|
38
45
|
return ReviewLedger.isHuman(existing.state) ? existing : r; // a quarantine replaces only a prior quarantine
|
|
39
46
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
const st = statSync(this.path);
|
|
51
|
-
const sig = `${st.mtimeMs}:${st.size}`;
|
|
52
|
-
if (sig === this.sig)
|
|
53
|
-
return; // unchanged since last read → cached index is current
|
|
54
|
-
this.index.clear();
|
|
55
|
-
for (const line of readFileSync(this.path, 'utf8').split('\n')) {
|
|
56
|
-
if (!line.trim())
|
|
57
|
-
continue;
|
|
58
|
-
try {
|
|
59
|
-
const r = JSON.parse(line);
|
|
60
|
-
if (r.assetId)
|
|
61
|
-
this.index.set(r.assetId, ReviewLedger.keep(this.index.get(r.assetId), r));
|
|
47
|
+
rebuildIndex(state) {
|
|
48
|
+
const next = new Map();
|
|
49
|
+
const raw = state === 'missing' ? null : readUtf8Regular(this.path);
|
|
50
|
+
if (raw !== null) {
|
|
51
|
+
const parsed = parseSidecarJsonl(raw, parseReviewRecord);
|
|
52
|
+
assertTrustSidecarHealthy('review', parsed);
|
|
53
|
+
for (const record of parsed.records) {
|
|
54
|
+
const frozen = immutableRecord(record);
|
|
55
|
+
next.set(record.assetId, ReviewLedger.keep(next.get(record.assetId), frozen));
|
|
62
56
|
}
|
|
63
|
-
catch { /* skip corrupt line */ }
|
|
64
57
|
}
|
|
65
|
-
this.
|
|
58
|
+
this.index.clear();
|
|
59
|
+
for (const [assetId, record] of next)
|
|
60
|
+
this.index.set(assetId, record);
|
|
61
|
+
this.fileState = state;
|
|
62
|
+
}
|
|
63
|
+
refreshUnderLock() {
|
|
64
|
+
const state = regularFileFingerprint(this.path);
|
|
65
|
+
if (state !== this.fileState)
|
|
66
|
+
this.rebuildIndex(state);
|
|
67
|
+
}
|
|
68
|
+
withFreshRead(read) {
|
|
69
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
70
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
71
|
+
this.refreshUnderLock();
|
|
72
|
+
return read(this.index);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
appendUnderLock(full) {
|
|
76
|
+
appendUtf8Durable(this.path, `${JSON.stringify(full)}\n`);
|
|
77
|
+
const frozen = immutableRecord(full);
|
|
78
|
+
this.index.set(full.assetId, ReviewLedger.keep(this.index.get(full.assetId), frozen));
|
|
79
|
+
this.fileState = regularFileFingerprint(this.path);
|
|
80
|
+
return this.index.get(full.assetId);
|
|
66
81
|
}
|
|
67
82
|
/** Record a review-state for an asset_id (append-only; the JSONL history is the audit trail). */
|
|
68
83
|
mark(rec) {
|
|
69
|
-
this.load();
|
|
70
84
|
const full = { ...rec, at: rec.at ?? new Date(this.now()).toISOString() };
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
85
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
86
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
87
|
+
this.refreshUnderLock();
|
|
88
|
+
this.appendUnderLock(full);
|
|
89
|
+
return immutableRecord(full);
|
|
90
|
+
});
|
|
77
91
|
}
|
|
78
92
|
/** Quarantine an auto-distilled draft: its strategy must not enter a real run until reviewed. */
|
|
79
93
|
quarantine(assetId, reason = 'auto-distilled — review before use') {
|
|
@@ -87,9 +101,19 @@ export class ReviewLedger {
|
|
|
87
101
|
* approval is inert. Returns the surviving record (the existing decision, or the new quarantine).
|
|
88
102
|
*/
|
|
89
103
|
quarantineIfAbsent(assetId, reason = 'auto-distilled — review before use') {
|
|
90
|
-
this.
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
105
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
106
|
+
this.refreshUnderLock();
|
|
107
|
+
const existing = this.index.get(assetId);
|
|
108
|
+
if (existing)
|
|
109
|
+
return existing;
|
|
110
|
+
return this.appendUnderLock({
|
|
111
|
+
assetId,
|
|
112
|
+
state: 'quarantined',
|
|
113
|
+
reason,
|
|
114
|
+
at: new Date(this.now()).toISOString(),
|
|
115
|
+
});
|
|
116
|
+
});
|
|
93
117
|
}
|
|
94
118
|
/** Explicit, audited approval (who/why) — flips a draft to eligible. */
|
|
95
119
|
approve(assetId, by, reason) {
|
|
@@ -100,8 +124,7 @@ export class ReviewLedger {
|
|
|
100
124
|
return this.mark({ assetId, state: 'rejected', by, reason });
|
|
101
125
|
}
|
|
102
126
|
get(assetId) {
|
|
103
|
-
this.
|
|
104
|
-
return this.index.get(assetId) ?? null;
|
|
127
|
+
return this.withFreshRead((index) => index.get(assetId) ?? null);
|
|
105
128
|
}
|
|
106
129
|
/**
|
|
107
130
|
* Every recorded review decision (resolved, reload-aware). The authoritative source of which assets are
|
|
@@ -109,13 +132,25 @@ export class ReviewLedger {
|
|
|
109
132
|
* asset list, so a draft awaiting approval is never missed behind a store-list cutoff.
|
|
110
133
|
*/
|
|
111
134
|
records() {
|
|
112
|
-
this.
|
|
113
|
-
|
|
135
|
+
return [...this.snapshot().values()];
|
|
136
|
+
}
|
|
137
|
+
/** One linearizable review snapshot for bounded batch readers. */
|
|
138
|
+
snapshot() {
|
|
139
|
+
return this.withFreshRead((index) => new Map(index));
|
|
114
140
|
}
|
|
115
141
|
/** No record → approved (default eligible); a record → approved only when its state is 'approved'. */
|
|
116
142
|
isApproved(assetId) {
|
|
117
|
-
this.
|
|
118
|
-
|
|
119
|
-
|
|
143
|
+
return this.withFreshRead((index) => {
|
|
144
|
+
const r = index.get(assetId);
|
|
145
|
+
return r ? r.state === 'approved' : true;
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* True only when a human approval record exists. Safety-sensitive consumers such as AntiGene warning
|
|
150
|
+
* injection must fail closed when review state is absent; unlike cycle/migrate-authored Genes, an unreviewed
|
|
151
|
+
* negative-memory asset must never inherit the ledger's backward-compatible "no record = eligible" default.
|
|
152
|
+
*/
|
|
153
|
+
isExplicitlyApproved(assetId) {
|
|
154
|
+
return this.withFreshRead((index) => index.get(assetId)?.state === 'approved');
|
|
120
155
|
}
|
|
121
156
|
}
|