@evomap/evolver-core 2.0.0-beta.2 → 2.0.0-beta.4
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/dist/algo/candidateAssembly.js +9 -6
- package/dist/algo/cycleEngine.d.ts +11 -0
- package/dist/algo/cycleEngine.js +12 -6
- package/dist/algo/cycleFailureClassifier.d.ts +1 -1
- package/dist/algo/geneSelection.d.ts +12 -1
- package/dist/algo/geneSelection.js +24 -8
- package/dist/algo/index.d.ts +1 -0
- package/dist/algo/index.js +1 -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 +5 -1
- package/dist/assetstore/assetSyncLedger.js +44 -64
- package/dist/assetstore/index.d.ts +2 -0
- package/dist/assetstore/index.js +2 -0
- package/dist/assetstore/localJsonl.d.ts +1 -0
- package/dist/assetstore/localJsonl.js +36 -32
- package/dist/assetstore/provenance.d.ts +13 -0
- package/dist/assetstore/provenance.js +60 -84
- package/dist/assetstore/provider.d.ts +2 -0
- package/dist/assetstore/reviewFilter.js +3 -1
- package/dist/assetstore/reviewLedger.d.ts +8 -2
- package/dist/assetstore/reviewLedger.js +71 -45
- 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/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/exec/autoExec.d.ts +6 -1
- package/dist/exec/autoExec.js +31 -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 +92 -14
- package/dist/exec/prompt.js +9 -0
- package/dist/exec/runnerRegistry.d.ts +56 -12
- package/dist/exec/runnerRegistry.js +272 -22
- package/dist/hub/bindings.js +12 -2
- package/dist/ops/savingsCore.js +1 -2
- package/dist/ops/selfUpdate.d.ts +10 -1
- package/dist/ops/selfUpdate.js +64 -15
- package/dist/util/fileLock.d.ts +19 -2
- package/dist/util/fileLock.js +166 -31
- package/package.json +5 -1
|
@@ -1,86 +1,66 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
2
1
|
import { dirname, join } from 'node:path';
|
|
2
|
+
import { appendUtf8Durable, assertAssetStoreDirectory, ensureAssetStoreDirectory, readUtf8Regular, regularFileFingerprint, withAssetStoreLock, } from './assetStoreStorage.js';
|
|
3
|
+
import { parseAssetSyncRecord, parseSidecarJsonl } from './assetSidecarRecords.js';
|
|
3
4
|
export class AssetSyncLedger {
|
|
4
5
|
now;
|
|
5
6
|
path;
|
|
7
|
+
lockPath;
|
|
6
8
|
index = new Map();
|
|
9
|
+
fileState = null;
|
|
7
10
|
loaded = false;
|
|
8
11
|
constructor(baseDir, now = Date.now) {
|
|
9
12
|
this.now = now;
|
|
13
|
+
ensureAssetStoreDirectory(baseDir);
|
|
10
14
|
this.path = join(baseDir, 'asset-sync.jsonl');
|
|
15
|
+
this.lockPath = join(baseDir, '.assetstore.lock');
|
|
11
16
|
}
|
|
12
17
|
append(rec) {
|
|
13
|
-
this.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
19
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
20
|
+
this.refreshUnderLock();
|
|
21
|
+
const full = immutableRecord({
|
|
22
|
+
...rec,
|
|
23
|
+
syncedAt: rec.syncedAt ?? new Date(this.now()).toISOString(),
|
|
24
|
+
});
|
|
25
|
+
appendUtf8Durable(this.path, `${JSON.stringify(full)}\n`);
|
|
26
|
+
this.index.set(full.assetId, full);
|
|
27
|
+
this.fileState = regularFileFingerprint(this.path);
|
|
28
|
+
return full;
|
|
29
|
+
});
|
|
22
30
|
}
|
|
23
31
|
get(assetId) {
|
|
24
|
-
this.
|
|
25
|
-
return this.index.get(assetId) ?? null;
|
|
32
|
+
return this.withFreshRead((index) => index.get(assetId) ?? null);
|
|
26
33
|
}
|
|
27
34
|
list() {
|
|
28
|
-
this.
|
|
29
|
-
return [...this.index.values()];
|
|
35
|
+
return this.withFreshRead((index) => [...index.values()]);
|
|
30
36
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
const rec = parseRecord(JSON.parse(line));
|
|
40
|
-
if (rec)
|
|
41
|
-
this.index.set(rec.assetId, rec);
|
|
42
|
-
}
|
|
43
|
-
catch {
|
|
44
|
-
// Skip corrupt audit lines; the asset store remains the source of content truth.
|
|
45
|
-
}
|
|
46
|
-
}
|
|
37
|
+
rebuildIndex(state) {
|
|
38
|
+
const next = new Map();
|
|
39
|
+
const raw = state === 'missing' ? null : readUtf8Regular(this.path);
|
|
40
|
+
if (raw !== null) {
|
|
41
|
+
const parsed = parseSidecarJsonl(raw, parseAssetSyncRecord);
|
|
42
|
+
for (const record of parsed.records)
|
|
43
|
+
next.set(record.assetId, immutableRecord(record));
|
|
47
44
|
}
|
|
45
|
+
this.index.clear();
|
|
46
|
+
for (const [assetId, record] of next)
|
|
47
|
+
this.index.set(assetId, record);
|
|
48
|
+
this.fileState = state;
|
|
48
49
|
this.loaded = true;
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
(scope !== 'purchased' && scope !== 'published') ||
|
|
62
|
-
!syncedAt ||
|
|
63
|
-
!remoteAssetId) {
|
|
64
|
-
return null;
|
|
51
|
+
refreshUnderLock() {
|
|
52
|
+
const state = regularFileFingerprint(this.path);
|
|
53
|
+
if (!this.loaded || state !== this.fileState)
|
|
54
|
+
this.rebuildIndex(state);
|
|
55
|
+
}
|
|
56
|
+
withFreshRead(read) {
|
|
57
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
58
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
59
|
+
this.refreshUnderLock();
|
|
60
|
+
return read(this.index);
|
|
61
|
+
});
|
|
65
62
|
}
|
|
66
|
-
const logicalId = stringField(value, 'logicalId');
|
|
67
|
-
const status = stringField(value, 'status');
|
|
68
|
-
const forced = value['forced'] === true;
|
|
69
|
-
const collisionWithAssetId = stringField(value, 'collisionWithAssetId');
|
|
70
|
-
return {
|
|
71
|
-
assetId,
|
|
72
|
-
type,
|
|
73
|
-
source,
|
|
74
|
-
scope,
|
|
75
|
-
syncedAt,
|
|
76
|
-
remoteAssetId,
|
|
77
|
-
...(logicalId ? { logicalId } : {}),
|
|
78
|
-
...(status ? { status } : {}),
|
|
79
|
-
...(forced ? { forced: true } : {}),
|
|
80
|
-
...(collisionWithAssetId ? { collisionWithAssetId } : {}),
|
|
81
|
-
};
|
|
82
63
|
}
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
|
|
64
|
+
function immutableRecord(record) {
|
|
65
|
+
return Object.freeze({ ...record });
|
|
86
66
|
}
|
package/dist/assetstore/index.js
CHANGED
|
@@ -25,6 +25,7 @@ export declare class LocalJsonlProvider implements AssetStoreProvider {
|
|
|
25
25
|
*/
|
|
26
26
|
putFrozen(record: AssetRecord): Promise<PutResult>;
|
|
27
27
|
get(assetId: string): Promise<AssetRecord | null>;
|
|
28
|
+
findByLogicalId(id: string, limit?: number): Promise<AssetRecord[]>;
|
|
28
29
|
list(kind?: AssetKind, limit?: number): Promise<AssetRecord[]>;
|
|
29
30
|
search(q: SearchQuery): Promise<AssetRecord[]>;
|
|
30
31
|
/**
|
|
@@ -1,22 +1,8 @@
|
|
|
1
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, statSync } 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
|
-
function isErrno(error, code) {
|
|
7
|
-
return typeof error === 'object' && error !== null && error.code === code;
|
|
8
|
-
}
|
|
9
|
-
function fileFingerprint(path) {
|
|
10
|
-
try {
|
|
11
|
-
const stat = statSync(path, { bigint: true });
|
|
12
|
-
return `${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
13
|
-
}
|
|
14
|
-
catch (error) {
|
|
15
|
-
if (isErrno(error, 'ENOENT'))
|
|
16
|
-
return 'missing';
|
|
17
|
-
throw error;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
6
|
function signalsOf(a) {
|
|
21
7
|
const out = [];
|
|
22
8
|
for (const key of ['signals_match', 'signals', 'trigger', 'trigger_signals']) {
|
|
@@ -44,13 +30,14 @@ export class LocalJsonlProvider {
|
|
|
44
30
|
// — the ReviewLedger/ProvenanceStore — in the SAME directory, instead of defaulting to the real ~/.evomap.
|
|
45
31
|
constructor(baseDir) {
|
|
46
32
|
this.baseDir = baseDir;
|
|
47
|
-
|
|
33
|
+
ensureAssetStoreDirectory(baseDir);
|
|
48
34
|
this.lockPath = join(baseDir, '.assetstore.lock');
|
|
49
35
|
}
|
|
50
36
|
captureFileState() {
|
|
37
|
+
assertAssetStoreDirectory(this.baseDir);
|
|
51
38
|
const state = new Map();
|
|
52
|
-
for (const [kind, file] of Object.entries(
|
|
53
|
-
state.set(kind,
|
|
39
|
+
for (const [kind, file] of Object.entries(LOCAL_ASSET_FILES)) {
|
|
40
|
+
state.set(kind, regularFileFingerprint(join(this.baseDir, file)));
|
|
54
41
|
}
|
|
55
42
|
return state;
|
|
56
43
|
}
|
|
@@ -65,11 +52,12 @@ export class LocalJsonlProvider {
|
|
|
65
52
|
}
|
|
66
53
|
rebuildIndex(state) {
|
|
67
54
|
const next = new Map();
|
|
68
|
-
for (const file of Object.values(
|
|
55
|
+
for (const file of Object.values(LOCAL_ASSET_FILES)) {
|
|
69
56
|
const p = join(this.baseDir, file);
|
|
70
|
-
|
|
57
|
+
const raw = readUtf8Regular(p);
|
|
58
|
+
if (raw === null)
|
|
71
59
|
continue;
|
|
72
|
-
for (const line of
|
|
60
|
+
for (const line of raw.split('\n')) {
|
|
73
61
|
if (!line.trim())
|
|
74
62
|
continue;
|
|
75
63
|
try {
|
|
@@ -95,6 +83,7 @@ export class LocalJsonlProvider {
|
|
|
95
83
|
const state = this.captureFileState();
|
|
96
84
|
if (!this.stateChanged(state))
|
|
97
85
|
return;
|
|
86
|
+
assertOptionalRegularFile(this.lockPath, 'lock_file');
|
|
98
87
|
acquireLock(this.lockPath);
|
|
99
88
|
try {
|
|
100
89
|
this.refreshUnderLock();
|
|
@@ -109,14 +98,15 @@ export class LocalJsonlProvider {
|
|
|
109
98
|
}
|
|
110
99
|
async put(asset) {
|
|
111
100
|
const { record, verified } = normalizeForPut(asset);
|
|
112
|
-
const file = join(this.baseDir,
|
|
101
|
+
const file = join(this.baseDir, LOCAL_ASSET_FILES[record.type]);
|
|
102
|
+
assertOptionalRegularFile(this.lockPath, 'lock_file');
|
|
113
103
|
acquireLock(this.lockPath);
|
|
114
104
|
try {
|
|
115
105
|
// Refresh under the shared lock so another process cannot append between reload and dedupe.
|
|
116
106
|
this.refreshUnderLock();
|
|
117
107
|
if (this.index.has(record.asset_id))
|
|
118
108
|
return { asset_id: record.asset_id, stored: false, verified };
|
|
119
|
-
|
|
109
|
+
appendUtf8Durable(file, `${JSON.stringify(record)}\n`);
|
|
120
110
|
this.index.set(record.asset_id, record);
|
|
121
111
|
this.updateFileStateAfterWrite();
|
|
122
112
|
}
|
|
@@ -132,13 +122,14 @@ export class LocalJsonlProvider {
|
|
|
132
122
|
async putFrozen(record) {
|
|
133
123
|
if (!record.asset_id)
|
|
134
124
|
throw new Error('putFrozen 需 record 自带冻结 asset_id');
|
|
135
|
-
const file = join(this.baseDir,
|
|
125
|
+
const file = join(this.baseDir, LOCAL_ASSET_FILES[record.type]);
|
|
126
|
+
assertOptionalRegularFile(this.lockPath, 'lock_file');
|
|
136
127
|
acquireLock(this.lockPath);
|
|
137
128
|
try {
|
|
138
129
|
this.refreshUnderLock();
|
|
139
130
|
if (this.index.has(record.asset_id))
|
|
140
131
|
return { asset_id: record.asset_id, stored: false, verified: false };
|
|
141
|
-
|
|
132
|
+
appendUtf8Durable(file, `${JSON.stringify(record)}\n`);
|
|
142
133
|
this.index.set(record.asset_id, record);
|
|
143
134
|
this.updateFileStateAfterWrite();
|
|
144
135
|
}
|
|
@@ -151,6 +142,19 @@ export class LocalJsonlProvider {
|
|
|
151
142
|
this.ensureFresh();
|
|
152
143
|
return this.index.get(assetId) ?? null;
|
|
153
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
|
+
}
|
|
154
158
|
async list(kind, limit = 1000) {
|
|
155
159
|
this.ensureFresh();
|
|
156
160
|
const out = [];
|
|
@@ -199,15 +203,17 @@ export class LocalJsonlProvider {
|
|
|
199
203
|
* the write lock. Returns kept/removed line counts.
|
|
200
204
|
*/
|
|
201
205
|
async compact() {
|
|
206
|
+
assertOptionalRegularFile(this.lockPath, 'lock_file');
|
|
202
207
|
acquireLock(this.lockPath);
|
|
203
208
|
try {
|
|
204
209
|
this.refreshUnderLock();
|
|
205
210
|
let kept = 0, removed = 0;
|
|
206
|
-
for (const [kind, file] of Object.entries(
|
|
211
|
+
for (const [kind, file] of Object.entries(LOCAL_ASSET_FILES)) {
|
|
207
212
|
const p = join(this.baseDir, file);
|
|
208
|
-
|
|
213
|
+
const raw = readUtf8Regular(p);
|
|
214
|
+
if (raw === null)
|
|
209
215
|
continue;
|
|
210
|
-
const lines =
|
|
216
|
+
const lines = raw.split('\n').filter((l) => l.trim());
|
|
211
217
|
const byId = new Map(); // asset_id → raw line (last wins, matching load semantics)
|
|
212
218
|
for (const line of lines) {
|
|
213
219
|
try {
|
|
@@ -218,9 +224,7 @@ export class LocalJsonlProvider {
|
|
|
218
224
|
catch { /* drop corrupt line */ }
|
|
219
225
|
}
|
|
220
226
|
const out = byId.size ? `${[...byId.values()].join('\n')}\n` : '';
|
|
221
|
-
|
|
222
|
-
writeFileSync(tmp, out);
|
|
223
|
-
renameSync(tmp, p); // atomic replace; a crash mid-compact leaves the original intact
|
|
227
|
+
replaceUtf8Durable(p, out);
|
|
224
228
|
kept += byId.size;
|
|
225
229
|
removed += lines.length - byId.size;
|
|
226
230
|
}
|
|
@@ -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
|
|
@@ -23,6 +31,7 @@ export declare class ProvenanceStore {
|
|
|
23
31
|
private rebuildIndex;
|
|
24
32
|
private refreshUnderLock;
|
|
25
33
|
private withFreshRead;
|
|
34
|
+
private appendUnderLock;
|
|
26
35
|
/** Record provenance for an asset_id (append-only; the JSONL history is the audit trail). */
|
|
27
36
|
mark(rec: Omit<ProvenanceRecord, 'at'> & {
|
|
28
37
|
at?: string;
|
|
@@ -33,8 +42,12 @@ export declare class ProvenanceStore {
|
|
|
33
42
|
isTrusted(assetId: string): boolean;
|
|
34
43
|
/** One linearizable trust snapshot for bounded batch readers. */
|
|
35
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;
|
|
36
47
|
/** Explicit, audited untrusted→trusted promotion. Appends a new trusted record carrying who/why. */
|
|
37
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;
|
|
38
51
|
}
|
|
39
52
|
/**
|
|
40
53
|
* The sanctioned hub→local-pool landing: store the asset (store.put recomputes/normalizes the asset_id, so a
|
|
@@ -3,23 +3,12 @@
|
|
|
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 { acquireLock, releaseLock } from '../util/fileLock.js';
|
|
9
7
|
import { normalizeForPut } from './provider.js';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
const stat = statSync(path, { bigint: true });
|
|
16
|
-
return `${stat.dev}:${stat.ino}:${stat.mode}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
|
|
17
|
-
}
|
|
18
|
-
catch (error) {
|
|
19
|
-
if (isErrno(error, 'ENOENT'))
|
|
20
|
-
return 'missing';
|
|
21
|
-
throw error;
|
|
22
|
-
}
|
|
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 });
|
|
23
12
|
}
|
|
24
13
|
/**
|
|
25
14
|
* Append-only JSONL sidecar (last-write-wins) at <baseDir>/provenance.jsonl. Default for an asset with NO
|
|
@@ -34,22 +23,18 @@ export class ProvenanceStore {
|
|
|
34
23
|
fileState = null;
|
|
35
24
|
constructor(baseDir, now = Date.now) {
|
|
36
25
|
this.now = now;
|
|
26
|
+
ensureAssetStoreDirectory(baseDir);
|
|
37
27
|
this.path = join(baseDir, 'provenance.jsonl');
|
|
38
28
|
this.lockPath = join(baseDir, '.assetstore.lock');
|
|
39
29
|
}
|
|
40
30
|
rebuildIndex(state) {
|
|
41
31
|
const next = new Map();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (r.assetId)
|
|
49
|
-
next.set(r.assetId, r);
|
|
50
|
-
}
|
|
51
|
-
catch { /* skip corrupt line */ }
|
|
52
|
-
}
|
|
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));
|
|
53
38
|
}
|
|
54
39
|
this.index.clear();
|
|
55
40
|
for (const [assetId, record] of next)
|
|
@@ -57,82 +42,49 @@ export class ProvenanceStore {
|
|
|
57
42
|
this.fileState = state;
|
|
58
43
|
}
|
|
59
44
|
refreshUnderLock() {
|
|
60
|
-
const state =
|
|
45
|
+
const state = regularFileFingerprint(this.path);
|
|
61
46
|
if (state !== this.fileState)
|
|
62
47
|
this.rebuildIndex(state);
|
|
63
48
|
}
|
|
64
49
|
withFreshRead(read) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
try {
|
|
50
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
51
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
68
52
|
this.refreshUnderLock();
|
|
69
53
|
return read(this.index);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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;
|
|
74
62
|
}
|
|
75
63
|
/** Record provenance for an asset_id (append-only; the JSONL history is the audit trail). */
|
|
76
64
|
mark(rec) {
|
|
77
65
|
const full = { ...rec, at: rec.at ?? new Date(this.now()).toISOString() };
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
try {
|
|
66
|
+
assertAssetStoreDirectory(dirname(this.path));
|
|
67
|
+
return withAssetStoreLock(this.lockPath, () => {
|
|
81
68
|
this.refreshUnderLock();
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
this.fileState = fileFingerprint(this.path);
|
|
85
|
-
}
|
|
86
|
-
finally {
|
|
87
|
-
releaseLock(this.lockPath);
|
|
88
|
-
}
|
|
89
|
-
return full;
|
|
69
|
+
return this.appendUnderLock(full);
|
|
70
|
+
});
|
|
90
71
|
}
|
|
91
72
|
rollbackLast(rec) {
|
|
92
73
|
const line = `${JSON.stringify(rec)}\n`;
|
|
93
|
-
const lineBytes = Buffer.byteLength(line, 'utf8');
|
|
94
|
-
let locked = false;
|
|
95
74
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
const offset = stat.size - lineBytes;
|
|
105
|
-
const buf = Buffer.alloc(lineBytes);
|
|
106
|
-
const fd = openSync(this.path, 'r');
|
|
107
|
-
try {
|
|
108
|
-
readSync(fd, buf, 0, lineBytes, offset);
|
|
109
|
-
}
|
|
110
|
-
finally {
|
|
111
|
-
closeSync(fd);
|
|
112
|
-
}
|
|
113
|
-
if (buf.toString('utf8') !== line) {
|
|
114
|
-
this.refreshUnderLock();
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
truncateSync(this.path, offset);
|
|
118
|
-
this.rebuildIndex(fileFingerprint(this.path));
|
|
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
|
+
});
|
|
119
83
|
}
|
|
120
84
|
catch {
|
|
121
85
|
this.index.clear();
|
|
122
86
|
this.fileState = null;
|
|
123
87
|
}
|
|
124
|
-
finally {
|
|
125
|
-
if (locked) {
|
|
126
|
-
try {
|
|
127
|
-
releaseLock(this.lockPath);
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
// Rollback is best-effort and must not mask the store failure that triggered it.
|
|
131
|
-
this.index.clear();
|
|
132
|
-
this.fileState = null;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
88
|
}
|
|
137
89
|
get(assetId) {
|
|
138
90
|
return this.withFreshRead((index) => index.get(assetId) ?? null);
|
|
@@ -145,10 +97,34 @@ export class ProvenanceStore {
|
|
|
145
97
|
snapshot() {
|
|
146
98
|
return this.withFreshRead((index) => new Map(index));
|
|
147
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
|
+
});
|
|
120
|
+
}
|
|
148
121
|
/** Explicit, audited untrusted→trusted promotion. Appends a new trusted record carrying who/why. */
|
|
149
122
|
promote(assetId, by, reason) {
|
|
150
|
-
|
|
151
|
-
|
|
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;
|
|
152
128
|
}
|
|
153
129
|
}
|
|
154
130
|
/**
|
|
@@ -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
|
}
|
|
@@ -31,11 +31,13 @@ export function provenanceStoreForStore(store) {
|
|
|
31
31
|
export async function listApprovedGenes(store, review, maxGenes, provenance = provenanceStoreForStore(store)) {
|
|
32
32
|
const all = await store.list('Gene', GENE_SCAN_LIMIT);
|
|
33
33
|
const trust = provenance.snapshot();
|
|
34
|
+
const reviewed = review.snapshot();
|
|
34
35
|
const approved = [];
|
|
35
36
|
for (const g of all) {
|
|
36
37
|
if (trust.get(String(g.asset_id))?.trusted === false)
|
|
37
38
|
continue; // hub-untrusted → withhold until promoted
|
|
38
|
-
|
|
39
|
+
const reviewRecord = reviewed.get(String(g.asset_id));
|
|
40
|
+
if (reviewRecord !== undefined && reviewRecord.state !== 'approved')
|
|
39
41
|
continue; // quarantined/rejected draft → withhold
|
|
40
42
|
approved.push(g);
|
|
41
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,8 @@ 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;
|
|
55
61
|
/**
|