@evomap/evolver-core 2.0.0-beta.1 → 2.0.0-beta.3
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 +132 -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 +23 -0
- package/dist/assetstore/assetSidecarRecords.js +142 -0
- package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
- package/dist/assetstore/assetSidecarRecovery.js +288 -0
- package/dist/assetstore/assetStoreHealth.d.ts +75 -0
- package/dist/assetstore/assetStoreHealth.js +277 -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 +318 -0
- package/dist/assetstore/assetSyncLedger.d.ts +32 -0
- package/dist/assetstore/assetSyncLedger.js +66 -0
- package/dist/assetstore/index.d.ts +5 -1
- package/dist/assetstore/index.js +5 -1
- package/dist/assetstore/localJsonl.d.ts +10 -2
- package/dist/assetstore/localJsonl.js +93 -37
- package/dist/assetstore/pendingSignals.js +3 -1
- package/dist/assetstore/provenance.d.ts +20 -2
- package/dist/assetstore/provenance.js +92 -56
- package/dist/assetstore/provider.d.ts +2 -0
- 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 +2 -0
- package/dist/events/paths.d.ts +1 -1
- package/dist/events/paths.js +2 -2
- 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 +7 -2
- package/dist/exec/claudeBridge.js +115 -14
- package/dist/exec/prompt.js +9 -0
- package/dist/exec/runnerRegistry.d.ts +64 -18
- package/dist/exec/runnerRegistry.js +341 -38
- 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/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/schema/common.d.ts +1 -1
- package/dist/schema/common.js +1 -1
- package/dist/schema/material.d.ts +5 -5
- package/dist/trace/trajectoryExport.js +2 -2
- package/dist/util/fileLock.d.ts +19 -2
- package/dist/util/fileLock.js +166 -31
- 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/package.json +6 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
|
|
2
1
|
import { join } from 'node:path';
|
|
3
2
|
import { acquireLock, releaseLock } from '../util/fileLock.js';
|
|
3
|
+
import { appendUtf8Durable, assertAssetStoreDirectory, assertOptionalRegularFile, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, replaceUtf8Durable, } from './assetStoreStorage.js';
|
|
4
|
+
import { LOCAL_ASSET_FILES } from './assetStoreLayout.js';
|
|
4
5
|
import { normalizeForPut, } from './provider.js';
|
|
5
|
-
const FILES = { Gene: 'genes.jsonl', Capsule: 'capsules.jsonl', EvolutionEvent: 'events.jsonl', AntiGene: 'anti-genes.jsonl' };
|
|
6
6
|
function signalsOf(a) {
|
|
7
7
|
const out = [];
|
|
8
8
|
for (const key of ['signals_match', 'signals', 'trigger', 'trigger_signals']) {
|
|
@@ -17,56 +17,98 @@ function signalsOf(a) {
|
|
|
17
17
|
/**
|
|
18
18
|
* 本地 jsonl 资产库(M3-2, 移植 v1 src/gep/assetStore.js 单写锁).
|
|
19
19
|
* 每 kind 一文件(genes/capsules/events.jsonl); append-only; O_EXCL 文件锁防并发写撕裂;
|
|
20
|
-
* 内存索引(asset_id→record)供 get/search;
|
|
20
|
+
* 内存索引(asset_id→record)供 get/search; 文件指纹变化时在共享锁内重建索引,保证多个
|
|
21
|
+
* CLI/daemon 进程之间可见; 写入也在锁内刷新后再按 asset_id 去重(内容寻址天然幂等).
|
|
21
22
|
*/
|
|
22
23
|
export class LocalJsonlProvider {
|
|
23
24
|
baseDir;
|
|
24
25
|
index = new Map();
|
|
25
26
|
lockPath;
|
|
27
|
+
fileState = new Map();
|
|
26
28
|
loaded = false;
|
|
27
29
|
// `baseDir` is public-readonly so callers that inject a store (e.g. the CLI under test) can co-locate sidecars
|
|
28
30
|
// — the ReviewLedger/ProvenanceStore — in the SAME directory, instead of defaulting to the real ~/.evomap.
|
|
29
31
|
constructor(baseDir) {
|
|
30
32
|
this.baseDir = baseDir;
|
|
31
|
-
|
|
33
|
+
ensureAssetStoreDirectory(baseDir);
|
|
32
34
|
this.lockPath = join(baseDir, '.assetstore.lock');
|
|
33
35
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const file of Object.
|
|
36
|
+
captureFileState() {
|
|
37
|
+
assertAssetStoreDirectory(this.baseDir);
|
|
38
|
+
const state = new Map();
|
|
39
|
+
for (const [kind, file] of Object.entries(LOCAL_ASSET_FILES)) {
|
|
40
|
+
state.set(kind, regularFileFingerprint(join(this.baseDir, file)));
|
|
41
|
+
}
|
|
42
|
+
return state;
|
|
43
|
+
}
|
|
44
|
+
stateChanged(next) {
|
|
45
|
+
if (!this.loaded || next.size !== this.fileState.size)
|
|
46
|
+
return true;
|
|
47
|
+
for (const [kind, fingerprint] of next) {
|
|
48
|
+
if (this.fileState.get(kind) !== fingerprint)
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
rebuildIndex(state) {
|
|
54
|
+
const next = new Map();
|
|
55
|
+
for (const file of Object.values(LOCAL_ASSET_FILES)) {
|
|
38
56
|
const p = join(this.baseDir, file);
|
|
39
|
-
|
|
57
|
+
const raw = readUtf8Regular(p);
|
|
58
|
+
if (raw === null)
|
|
40
59
|
continue;
|
|
41
|
-
for (const line of
|
|
60
|
+
for (const line of raw.split('\n')) {
|
|
42
61
|
if (!line.trim())
|
|
43
62
|
continue;
|
|
44
63
|
try {
|
|
45
64
|
const r = JSON.parse(line);
|
|
46
65
|
if (r.asset_id)
|
|
47
|
-
|
|
66
|
+
next.set(r.asset_id, r);
|
|
48
67
|
}
|
|
49
68
|
catch { /* skip 坏行 */ }
|
|
50
69
|
}
|
|
51
70
|
}
|
|
71
|
+
this.index.clear();
|
|
72
|
+
for (const [assetId, record] of next)
|
|
73
|
+
this.index.set(assetId, record);
|
|
74
|
+
this.fileState = state;
|
|
75
|
+
this.loaded = true;
|
|
76
|
+
}
|
|
77
|
+
refreshUnderLock() {
|
|
78
|
+
const state = this.captureFileState();
|
|
79
|
+
if (this.stateChanged(state))
|
|
80
|
+
this.rebuildIndex(state);
|
|
81
|
+
}
|
|
82
|
+
ensureFresh() {
|
|
83
|
+
const state = this.captureFileState();
|
|
84
|
+
if (!this.stateChanged(state))
|
|
85
|
+
return;
|
|
86
|
+
assertOptionalRegularFile(this.lockPath, 'lock_file');
|
|
87
|
+
acquireLock(this.lockPath);
|
|
88
|
+
try {
|
|
89
|
+
this.refreshUnderLock();
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
releaseLock(this.lockPath);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
updateFileStateAfterWrite() {
|
|
96
|
+
this.fileState = this.captureFileState();
|
|
52
97
|
this.loaded = true;
|
|
53
98
|
}
|
|
54
99
|
async put(asset) {
|
|
55
|
-
this.ensureLoaded();
|
|
56
100
|
const { record, verified } = normalizeForPut(asset);
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const file = join(this.baseDir, FILES[record.type]);
|
|
101
|
+
const file = join(this.baseDir, LOCAL_ASSET_FILES[record.type]);
|
|
102
|
+
assertOptionalRegularFile(this.lockPath, 'lock_file');
|
|
60
103
|
acquireLock(this.lockPath);
|
|
61
104
|
try {
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
this.index.set(record.asset_id, record);
|
|
66
|
-
}
|
|
67
|
-
else {
|
|
105
|
+
// Refresh under the shared lock so another process cannot append between reload and dedupe.
|
|
106
|
+
this.refreshUnderLock();
|
|
107
|
+
if (this.index.has(record.asset_id))
|
|
68
108
|
return { asset_id: record.asset_id, stored: false, verified };
|
|
69
|
-
}
|
|
109
|
+
appendUtf8Durable(file, `${JSON.stringify(record)}\n`);
|
|
110
|
+
this.index.set(record.asset_id, record);
|
|
111
|
+
this.updateFileStateAfterWrite();
|
|
70
112
|
}
|
|
71
113
|
finally {
|
|
72
114
|
releaseLock(this.lockPath);
|
|
@@ -78,18 +120,18 @@ export class LocalJsonlProvider {
|
|
|
78
120
|
* 仅 v1→v2 导入用(硬化 A6 存量冻结); 普通写一律走 put(). record 必须自带 asset_id.
|
|
79
121
|
*/
|
|
80
122
|
async putFrozen(record) {
|
|
81
|
-
this.ensureLoaded();
|
|
82
123
|
if (!record.asset_id)
|
|
83
124
|
throw new Error('putFrozen 需 record 自带冻结 asset_id');
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const file = join(this.baseDir, FILES[record.type]);
|
|
125
|
+
const file = join(this.baseDir, LOCAL_ASSET_FILES[record.type]);
|
|
126
|
+
assertOptionalRegularFile(this.lockPath, 'lock_file');
|
|
87
127
|
acquireLock(this.lockPath);
|
|
88
128
|
try {
|
|
129
|
+
this.refreshUnderLock();
|
|
89
130
|
if (this.index.has(record.asset_id))
|
|
90
131
|
return { asset_id: record.asset_id, stored: false, verified: false };
|
|
91
|
-
|
|
132
|
+
appendUtf8Durable(file, `${JSON.stringify(record)}\n`);
|
|
92
133
|
this.index.set(record.asset_id, record);
|
|
134
|
+
this.updateFileStateAfterWrite();
|
|
93
135
|
}
|
|
94
136
|
finally {
|
|
95
137
|
releaseLock(this.lockPath);
|
|
@@ -97,11 +139,24 @@ export class LocalJsonlProvider {
|
|
|
97
139
|
return { asset_id: record.asset_id, stored: true, verified: false };
|
|
98
140
|
}
|
|
99
141
|
async get(assetId) {
|
|
100
|
-
this.
|
|
142
|
+
this.ensureFresh();
|
|
101
143
|
return this.index.get(assetId) ?? null;
|
|
102
144
|
}
|
|
145
|
+
async findByLogicalId(id, limit = 2) {
|
|
146
|
+
this.ensureFresh();
|
|
147
|
+
const boundedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(1_000, Math.floor(limit))) : 2;
|
|
148
|
+
const out = [];
|
|
149
|
+
for (const record of this.index.values()) {
|
|
150
|
+
if (record['id'] !== id)
|
|
151
|
+
continue;
|
|
152
|
+
out.push(record);
|
|
153
|
+
if (out.length >= boundedLimit)
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
103
158
|
async list(kind, limit = 1000) {
|
|
104
|
-
this.
|
|
159
|
+
this.ensureFresh();
|
|
105
160
|
const out = [];
|
|
106
161
|
for (const r of this.index.values()) {
|
|
107
162
|
if (!kind || r.type === kind)
|
|
@@ -112,7 +167,7 @@ export class LocalJsonlProvider {
|
|
|
112
167
|
return out;
|
|
113
168
|
}
|
|
114
169
|
async search(q) {
|
|
115
|
-
this.
|
|
170
|
+
this.ensureFresh();
|
|
116
171
|
const out = [];
|
|
117
172
|
for (const r of this.index.values()) {
|
|
118
173
|
if (q.kind && r.type !== q.kind)
|
|
@@ -148,15 +203,17 @@ export class LocalJsonlProvider {
|
|
|
148
203
|
* the write lock. Returns kept/removed line counts.
|
|
149
204
|
*/
|
|
150
205
|
async compact() {
|
|
151
|
-
this.
|
|
206
|
+
assertOptionalRegularFile(this.lockPath, 'lock_file');
|
|
152
207
|
acquireLock(this.lockPath);
|
|
153
208
|
try {
|
|
209
|
+
this.refreshUnderLock();
|
|
154
210
|
let kept = 0, removed = 0;
|
|
155
|
-
for (const [kind, file] of Object.entries(
|
|
211
|
+
for (const [kind, file] of Object.entries(LOCAL_ASSET_FILES)) {
|
|
156
212
|
const p = join(this.baseDir, file);
|
|
157
|
-
|
|
213
|
+
const raw = readUtf8Regular(p);
|
|
214
|
+
if (raw === null)
|
|
158
215
|
continue;
|
|
159
|
-
const lines =
|
|
216
|
+
const lines = raw.split('\n').filter((l) => l.trim());
|
|
160
217
|
const byId = new Map(); // asset_id → raw line (last wins, matching load semantics)
|
|
161
218
|
for (const line of lines) {
|
|
162
219
|
try {
|
|
@@ -167,12 +224,11 @@ export class LocalJsonlProvider {
|
|
|
167
224
|
catch { /* drop corrupt line */ }
|
|
168
225
|
}
|
|
169
226
|
const out = byId.size ? `${[...byId.values()].join('\n')}\n` : '';
|
|
170
|
-
|
|
171
|
-
writeFileSync(tmp, out);
|
|
172
|
-
renameSync(tmp, p); // atomic replace; a crash mid-compact leaves the original intact
|
|
227
|
+
replaceUtf8Durable(p, out);
|
|
173
228
|
kept += byId.size;
|
|
174
229
|
removed += lines.length - byId.size;
|
|
175
230
|
}
|
|
231
|
+
this.rebuildIndex(this.captureFileState());
|
|
176
232
|
return { kept, removed };
|
|
177
233
|
}
|
|
178
234
|
finally {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, join, resolve } from 'node:path';
|
|
2
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
3
3
|
import { acquireLock, releaseLock } from '../util/fileLock.js';
|
|
4
4
|
import { LocalJsonlProvider } from './localJsonl.js';
|
|
5
5
|
const MAX_SIGNAL_LENGTH = 200;
|
|
@@ -33,6 +33,8 @@ function findRepoRoot(start) {
|
|
|
33
33
|
while (current) {
|
|
34
34
|
if (existsSync(join(current, '.git')))
|
|
35
35
|
return current;
|
|
36
|
+
if (basename(current) === 'node_modules')
|
|
37
|
+
return null;
|
|
36
38
|
const parent = dirname(current);
|
|
37
39
|
if (parent === current)
|
|
38
40
|
return null;
|
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import { type AssetStoreProvider, type AssetRecord, type PutResult } from './provider.js';
|
|
2
2
|
export type ProvenanceSource = 'local' | 'migrated' | 'hub';
|
|
3
|
+
export type ProvenanceDecision = 'promoted' | 'revoked';
|
|
3
4
|
export interface ProvenanceRecord {
|
|
4
5
|
assetId: string;
|
|
5
6
|
source: ProvenanceSource;
|
|
6
7
|
trusted: boolean;
|
|
7
8
|
at: string;
|
|
9
|
+
decision?: ProvenanceDecision;
|
|
10
|
+
decidedBy?: string;
|
|
11
|
+
/** Legacy promotion actor field kept for existing sidecar readers. */
|
|
8
12
|
promotedBy?: string;
|
|
9
13
|
reason?: string;
|
|
10
14
|
}
|
|
15
|
+
export interface ProvenanceTrustChange {
|
|
16
|
+
changed: boolean;
|
|
17
|
+
record: ProvenanceRecord;
|
|
18
|
+
}
|
|
11
19
|
/**
|
|
12
20
|
* Append-only JSONL sidecar (last-write-wins) at <baseDir>/provenance.jsonl. Default for an asset with NO
|
|
13
21
|
* record = trusted: the only local writers (cycleEngine self-produce, v1 migration) are trusted and never
|
|
@@ -16,10 +24,14 @@ export interface ProvenanceRecord {
|
|
|
16
24
|
export declare class ProvenanceStore {
|
|
17
25
|
private readonly now;
|
|
18
26
|
private readonly path;
|
|
27
|
+
private readonly lockPath;
|
|
19
28
|
private readonly index;
|
|
20
|
-
private
|
|
29
|
+
private fileState;
|
|
21
30
|
constructor(baseDir: string, now?: () => number);
|
|
22
|
-
private
|
|
31
|
+
private rebuildIndex;
|
|
32
|
+
private refreshUnderLock;
|
|
33
|
+
private withFreshRead;
|
|
34
|
+
private appendUnderLock;
|
|
23
35
|
/** Record provenance for an asset_id (append-only; the JSONL history is the audit trail). */
|
|
24
36
|
mark(rec: Omit<ProvenanceRecord, 'at'> & {
|
|
25
37
|
at?: string;
|
|
@@ -28,8 +40,14 @@ export declare class ProvenanceStore {
|
|
|
28
40
|
get(assetId: string): ProvenanceRecord | null;
|
|
29
41
|
/** No record → trusted (local default); a record → its trusted flag. */
|
|
30
42
|
isTrusted(assetId: string): boolean;
|
|
43
|
+
/** One linearizable trust snapshot for bounded batch readers. */
|
|
44
|
+
snapshot(): ReadonlyMap<string, ProvenanceRecord>;
|
|
45
|
+
/** Compare and append one trust decision under the same cross-process lock. */
|
|
46
|
+
changeTrust(assetId: string, trusted: boolean, by: string, reason: string): ProvenanceTrustChange;
|
|
31
47
|
/** Explicit, audited untrusted→trusted promotion. Appends a new trusted record carrying who/why. */
|
|
32
48
|
promote(assetId: string, by: string, reason: string): ProvenanceRecord;
|
|
49
|
+
/** Explicit, audited trusted-to-untrusted revocation. A record-less asset is local by default. */
|
|
50
|
+
revoke(assetId: string, by: string, reason: string): ProvenanceRecord;
|
|
33
51
|
}
|
|
34
52
|
/**
|
|
35
53
|
* The sanctioned hub→local-pool landing: store the asset (store.put recomputes/normalizes the asset_id, so a
|
|
@@ -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
7
|
import { normalizeForPut } 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,11 @@ 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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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)
|
|
106
143
|
prov.rollbackLast(mark);
|
|
107
|
-
|
|
108
|
-
}
|
|
144
|
+
return result;
|
|
109
145
|
}
|
|
@@ -25,6 +25,8 @@ export interface SearchQuery {
|
|
|
25
25
|
export interface AssetStoreProvider {
|
|
26
26
|
put(asset: AssetRecord): Promise<PutResult>;
|
|
27
27
|
get(assetId: string): Promise<AssetRecord | null>;
|
|
28
|
+
/** Optional direct lookup for non-content-addressed logical ids. Callers must handle 0, 1, or multiple matches. */
|
|
29
|
+
findByLogicalId?(id: string, limit?: number): Promise<AssetRecord[]>;
|
|
28
30
|
search(query: SearchQuery): Promise<AssetRecord[]>;
|
|
29
31
|
list(kind?: AssetKind, limit?: number): Promise<AssetRecord[]>;
|
|
30
32
|
}
|
|
@@ -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
|
}
|