@evomap/evolver-core 2.0.0-beta.2 → 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.
Files changed (58) hide show
  1. package/dist/algo/candidateAssembly.js +9 -6
  2. package/dist/algo/cycleEngine.d.ts +11 -0
  3. package/dist/algo/cycleEngine.js +12 -6
  4. package/dist/algo/cycleFailureClassifier.d.ts +1 -1
  5. package/dist/algo/geneSelection.d.ts +12 -1
  6. package/dist/algo/geneSelection.js +24 -8
  7. package/dist/algo/index.d.ts +1 -0
  8. package/dist/algo/index.js +1 -0
  9. package/dist/algo/memoryGraph.d.ts +62 -0
  10. package/dist/algo/memoryGraph.js +86 -0
  11. package/dist/algo/orchestrator.d.ts +3 -0
  12. package/dist/algo/orchestrator.js +14 -2
  13. package/dist/assetstore/assetSidecarRecords.d.ts +23 -0
  14. package/dist/assetstore/assetSidecarRecords.js +142 -0
  15. package/dist/assetstore/assetSidecarRecovery.d.ts +48 -0
  16. package/dist/assetstore/assetSidecarRecovery.js +288 -0
  17. package/dist/assetstore/assetStoreHealth.d.ts +75 -0
  18. package/dist/assetstore/assetStoreHealth.js +277 -0
  19. package/dist/assetstore/assetStoreLayout.d.ts +2 -0
  20. package/dist/assetstore/assetStoreLayout.js +6 -0
  21. package/dist/assetstore/assetStoreStorage.d.ts +42 -0
  22. package/dist/assetstore/assetStoreStorage.js +318 -0
  23. package/dist/assetstore/assetSyncLedger.d.ts +5 -1
  24. package/dist/assetstore/assetSyncLedger.js +44 -64
  25. package/dist/assetstore/index.d.ts +2 -0
  26. package/dist/assetstore/index.js +2 -0
  27. package/dist/assetstore/localJsonl.d.ts +1 -0
  28. package/dist/assetstore/localJsonl.js +36 -32
  29. package/dist/assetstore/provenance.d.ts +13 -0
  30. package/dist/assetstore/provenance.js +60 -84
  31. package/dist/assetstore/provider.d.ts +2 -0
  32. package/dist/assetstore/reviewFilter.js +3 -1
  33. package/dist/assetstore/reviewLedger.d.ts +8 -2
  34. package/dist/assetstore/reviewLedger.js +71 -45
  35. package/dist/benchmark/index.d.ts +2 -1
  36. package/dist/benchmark/index.js +2 -1
  37. package/dist/benchmark/triggerShift.d.ts +62 -0
  38. package/dist/benchmark/triggerShift.js +106 -0
  39. package/dist/events/ingest.d.ts +1 -1
  40. package/dist/events/ingest.js +2 -0
  41. package/dist/events/paths.d.ts +1 -1
  42. package/dist/events/paths.js +2 -2
  43. package/dist/exec/autoExec.d.ts +6 -1
  44. package/dist/exec/autoExec.js +31 -0
  45. package/dist/exec/autonomousCycle.d.ts +2 -0
  46. package/dist/exec/autonomousCycle.js +5 -0
  47. package/dist/exec/claudeBridge.d.ts +7 -2
  48. package/dist/exec/claudeBridge.js +92 -14
  49. package/dist/exec/prompt.js +9 -0
  50. package/dist/exec/runnerRegistry.d.ts +56 -12
  51. package/dist/exec/runnerRegistry.js +272 -22
  52. package/dist/hub/bindings.js +12 -2
  53. package/dist/ops/savingsCore.js +1 -2
  54. package/dist/ops/selfUpdate.d.ts +10 -1
  55. package/dist/ops/selfUpdate.js +64 -15
  56. package/dist/util/fileLock.d.ts +19 -2
  57. package/dist/util/fileLock.js +166 -31
  58. 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.load();
14
- const full = {
15
- ...rec,
16
- syncedAt: rec.syncedAt ?? new Date(this.now()).toISOString(),
17
- };
18
- mkdirSync(dirname(this.path), { recursive: true });
19
- appendFileSync(this.path, `${JSON.stringify(full)}\n`);
20
- this.index.set(full.assetId, full);
21
- return full;
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.load();
25
- return this.index.get(assetId) ?? null;
32
+ return this.withFreshRead((index) => index.get(assetId) ?? null);
26
33
  }
27
34
  list() {
28
- this.load();
29
- return [...this.index.values()];
35
+ return this.withFreshRead((index) => [...index.values()]);
30
36
  }
31
- load() {
32
- if (this.loaded)
33
- return;
34
- if (existsSync(this.path)) {
35
- for (const line of readFileSync(this.path, 'utf8').split('\n')) {
36
- if (!line.trim())
37
- continue;
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
- function parseRecord(value) {
52
- const assetId = stringField(value, 'assetId');
53
- const type = stringField(value, 'type');
54
- const source = stringField(value, 'source');
55
- const scope = stringField(value, 'scope');
56
- const syncedAt = stringField(value, 'syncedAt');
57
- const remoteAssetId = stringField(value, 'remoteAssetId');
58
- if (!assetId ||
59
- (type !== 'Gene' && type !== 'Capsule') ||
60
- source !== 'hub' ||
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 stringField(value, key) {
84
- const raw = value[key];
85
- return typeof raw === 'string' && raw.trim() ? raw.trim() : undefined;
64
+ function immutableRecord(record) {
65
+ return Object.freeze({ ...record });
86
66
  }
@@ -1,5 +1,7 @@
1
1
  export * from './provider.js';
2
2
  export * from './localJsonl.js';
3
+ export * from './assetStoreHealth.js';
4
+ export * from './assetSidecarRecovery.js';
3
5
  export * from './remoteStub.js';
4
6
  export * from './learningHistory.js';
5
7
  export * from './provenance.js';
@@ -1,5 +1,7 @@
1
1
  export * from './provider.js';
2
2
  export * from './localJsonl.js';
3
+ export * from './assetStoreHealth.js';
4
+ export * from './assetSidecarRecovery.js';
3
5
  export * from './remoteStub.js';
4
6
  export * from './learningHistory.js';
5
7
  export * from './provenance.js';
@@ -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
- mkdirSync(baseDir, { recursive: true });
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(FILES)) {
53
- state.set(kind, fileFingerprint(join(this.baseDir, file)));
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(FILES)) {
55
+ for (const file of Object.values(LOCAL_ASSET_FILES)) {
69
56
  const p = join(this.baseDir, file);
70
- if (!existsSync(p))
57
+ const raw = readUtf8Regular(p);
58
+ if (raw === null)
71
59
  continue;
72
- for (const line of readFileSync(p, 'utf8').split('\n')) {
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, FILES[record.type]);
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
- appendFileSync(file, `${JSON.stringify(record)}\n`);
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, FILES[record.type]);
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
- appendFileSync(file, `${JSON.stringify(record)}\n`);
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(FILES)) {
211
+ for (const [kind, file] of Object.entries(LOCAL_ASSET_FILES)) {
207
212
  const p = join(this.baseDir, file);
208
- if (!existsSync(p))
213
+ const raw = readUtf8Regular(p);
214
+ if (raw === null)
209
215
  continue;
210
- const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
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
- const tmp = `${p}.compact.tmp`;
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
- function isErrno(error, code) {
11
- return typeof error === 'object' && error !== null && error.code === code;
12
- }
13
- function fileFingerprint(path) {
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
- if (state !== 'missing') {
43
- for (const line of readFileSync(this.path, 'utf8').split('\n')) {
44
- if (!line.trim())
45
- continue;
46
- try {
47
- const r = JSON.parse(line);
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 = fileFingerprint(this.path);
45
+ const state = regularFileFingerprint(this.path);
61
46
  if (state !== this.fileState)
62
47
  this.rebuildIndex(state);
63
48
  }
64
49
  withFreshRead(read) {
65
- mkdirSync(dirname(this.path), { recursive: true });
66
- acquireLock(this.lockPath);
67
- try {
50
+ assertAssetStoreDirectory(dirname(this.path));
51
+ return withAssetStoreLock(this.lockPath, () => {
68
52
  this.refreshUnderLock();
69
53
  return read(this.index);
70
- }
71
- finally {
72
- releaseLock(this.lockPath);
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
- mkdirSync(dirname(this.path), { recursive: true });
79
- acquireLock(this.lockPath);
80
- try {
66
+ assertAssetStoreDirectory(dirname(this.path));
67
+ return withAssetStoreLock(this.lockPath, () => {
81
68
  this.refreshUnderLock();
82
- appendFileSync(this.path, `${JSON.stringify(full)}\n`);
83
- this.index.set(full.assetId, full);
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
- mkdirSync(dirname(this.path), { recursive: true });
97
- acquireLock(this.lockPath);
98
- locked = true;
99
- if (!existsSync(this.path))
100
- return;
101
- const stat = statSync(this.path);
102
- if (!stat.isFile() || stat.size < lineBytes)
103
- return;
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
- const cur = this.get(assetId);
151
- return this.mark({ assetId, source: cur?.source ?? 'hub', trusted: true, promotedBy: by, reason });
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
- if (!review.isApproved(String(g.asset_id)))
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 sig;
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 load;
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
  /**