@geoly-ai/skills-hub 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin/skills-hub.mjs +26 -0
  4. package/package.json +44 -0
  5. package/src/adapters/index.mjs +832 -0
  6. package/src/artifact.mjs +376 -0
  7. package/src/atomic-fs.mjs +166 -0
  8. package/src/attestation.mjs +136 -0
  9. package/src/canonical-json.mjs +147 -0
  10. package/src/cli.mjs +208 -0
  11. package/src/commands/check.mjs +295 -0
  12. package/src/commands/context.mjs +235 -0
  13. package/src/commands/install.mjs +430 -0
  14. package/src/commands/locks.mjs +197 -0
  15. package/src/commands/output.mjs +127 -0
  16. package/src/commands/query.mjs +266 -0
  17. package/src/commands/recover.mjs +438 -0
  18. package/src/commands/registry.mjs +123 -0
  19. package/src/commands/resolve.mjs +171 -0
  20. package/src/commands/snapshot-access.mjs +91 -0
  21. package/src/commands/sync-lock.mjs +189 -0
  22. package/src/crc32c.mjs +27 -0
  23. package/src/exit-codes.mjs +265 -0
  24. package/src/fault-inject.mjs +379 -0
  25. package/src/install.mjs +732 -0
  26. package/src/journal.mjs +435 -0
  27. package/src/ledger.mjs +671 -0
  28. package/src/lock.mjs +98 -0
  29. package/src/lockfile.mjs +0 -0
  30. package/src/pack.mjs +792 -0
  31. package/src/packer.mjs +351 -0
  32. package/src/plan.mjs +519 -0
  33. package/src/recover.mjs +1345 -0
  34. package/src/safe-fs.mjs +252 -0
  35. package/src/sigstore.mjs +480 -0
  36. package/src/snapshot.mjs +528 -0
  37. package/src/stats.mjs +59 -0
  38. package/src/target.mjs +738 -0
  39. package/src/telemetry.mjs +393 -0
  40. package/src/tree-digest.mjs +103 -0
  41. package/src/trust-roots/README.md +31 -0
  42. package/src/trust-roots/sigstore-public-good.json +126 -0
  43. package/src/trust.mjs +563 -0
  44. package/src/untar.mjs +570 -0
  45. package/src/upload.mjs +268 -0
  46. package/src/vendor.mjs +465 -0
package/src/ledger.mjs ADDED
@@ -0,0 +1,671 @@
1
+ // ledger —— 账本 `geoly.skills.ledger/2`、generation 单调水位、audit plane。
2
+ //
3
+ // 规格:04-install.md §3.2(布局)、§4(schema 与 audit plane)、§4.1(水位)、
4
+ // §5.4.2(ledger_image 的 patch 语义与 bootstrap 协议)、§5.9(reset-generation)、
5
+ // 11-wire-contract.md §2/§3/§5。
6
+ //
7
+ // 🔴 本模块**不 import recover.mjs**。audit intent 的崩溃恢复以 `resumeAuditArchive()`
8
+ // 的形式暴露,由 recover 的 2a 调用 —— 反过来会形成循环依赖。
9
+
10
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
11
+ import { createHash } from 'node:crypto';
12
+ import { join } from 'node:path';
13
+ import { parseStrict, stringify } from './canonical-json.mjs';
14
+ import { writeAtomic, mkdirChainFsync, rmtreeFsync, fsyncDir } from './atomic-fs.mjs';
15
+ import { fp } from './fault-inject.mjs';
16
+ import {
17
+ Corrupt, bad, assertKeys, assertSafeName, isUint, isTreeDigest, readJsonStrict, validateAuditEvent,
18
+ } from './journal.mjs';
19
+
20
+ export const LEDGER_SCHEMA = 'geoly.skills.ledger/2';
21
+ export const AUDIT_ARCHIVE_SCHEMA = 'geoly.skills.audit-archive/1';
22
+ export const AUDIT_INTENT_SCHEMA = 'geoly.skills.audit-archive-intent/1';
23
+ export const ATTIC_MANIFEST_SCHEMA = 'geoly.skills.attic-manifest/1';
24
+ export const REPAIR_INTENT_SCHEMA = 'geoly.skills.repair-intent/1';
25
+
26
+ /** §4 的默认阈值 */
27
+ export const DEFAULT_AUDIT_MAX_ENTRIES = 1000;
28
+
29
+ const sha256 = (buf) => 'sha256:' + createHash('sha256').update(buf).digest('hex');
30
+ export { sha256 };
31
+
32
+ // ── 布局(§3.2)─────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * 全部 per-target 状态**跟着物理目录走**(§3.1)。
36
+ * `gen === undefined` 时与代相关的字段为 null —— 调用方必须显式给代,
37
+ * 而不是让实现去「猜当前是哪一代」。
38
+ */
39
+ export function layout(target, gen) {
40
+ const state = join(target, '.geoly');
41
+ const g = gen === undefined ? null : String(gen);
42
+ return {
43
+ target,
44
+ state,
45
+ lock: join(state, 'lock.db'),
46
+ generationFile: join(state, 'generation'),
47
+ auditSeqFile: join(state, 'audit-seq'),
48
+ ledger: join(state, 'ledger.json'),
49
+ journalDir: join(state, 'journal'),
50
+ journal: g === null ? null : join(state, 'journal', `${g}.json`),
51
+ tx: g === null ? null : join(state, `tx-${g}`),
52
+ stage: g === null ? null : join(state, `tx-${g}`, 'stage'),
53
+ retired: g === null ? null : join(state, `tx-${g}`, 'retired'),
54
+ undo: g === null ? null : join(state, `tx-${g}`, 'undo'),
55
+ unpack: g === null ? null : join(state, `tx-${g}`, 'unpack'),
56
+ atticDir: join(state, 'attic'),
57
+ attic: g === null ? null : join(state, 'attic', g),
58
+ quarantineDir: join(state, 'quarantine'),
59
+ quarantine: g === null ? null : join(state, 'quarantine', g),
60
+ repairIntent: join(state, 'repair-intent.json'),
61
+ auditIntent: join(state, 'audit-archive-intent.json'),
62
+ auditArchiveDir: join(state, 'audit-archive'),
63
+ };
64
+ }
65
+
66
+ // ── 校验 ─────────────────────────────────────────────────────────────────────
67
+
68
+ function validateTargetMeta(t) {
69
+ assertKeys(t, ['client', 'scope', 'path', 'realpath', 'fstype'], [], 'ledger.target');
70
+ for (const k of ['client', 'scope', 'path', 'realpath', 'fstype']) {
71
+ if (typeof t[k] !== 'string' || t[k] === '') bad(`ledger.target.${k} 必须是非空字符串`);
72
+ }
73
+ if (!['global', 'project'].includes(t.scope)) bad(`ledger.target.scope 未知取值 ${t.scope}`);
74
+ }
75
+
76
+ function validateIntent(i, where, { allowYanked }) {
77
+ const opt = allowYanked ? ['allow_yanked'] : [];
78
+ assertKeys(i, ['no_bundled', 'pre'], opt, where);
79
+ for (const k of ['no_bundled', 'pre', ...opt]) {
80
+ if (k in i && typeof i[k] !== 'boolean') bad(`${where}.${k} 必须是布尔`);
81
+ }
82
+ }
83
+
84
+ function validateRoot(key, r) {
85
+ const where = `ledger.roots[${key}]`;
86
+ assertKeys(r, ['kind', 'snapshot', 'intent', 'requested_at'],
87
+ ['artifact', 'tree_digest'], where);
88
+ if (!['pack', 'direct', 'all'].includes(r.kind)) bad(`${where}.kind 未知取值 ${r.kind}`);
89
+ if (!isUint(r.snapshot)) bad(`${where}.snapshot 必须是非负整数`);
90
+ // ⚠️ 账本的 intent **保留** allow_yanked(它如实记录本机历史);
91
+ // lockfile 的 intent 里没有它 —— 未签名文件不得授予该例外(§8.1)。
92
+ validateIntent(r.intent, `${where}.intent`, { allowYanked: true });
93
+ if (r.kind === 'all') {
94
+ if ('artifact' in r || 'tree_digest' in r) bad(`${where}:all root 不带 artifact / tree_digest`);
95
+ } else {
96
+ if (typeof r.artifact !== 'string' || r.artifact === '') bad(`${where}.artifact 必填`);
97
+ if (!isTreeDigest(r.tree_digest)) bad(`${where}.tree_digest 必须是树摘要`);
98
+ }
99
+ }
100
+
101
+ function validateEntry(name, e) {
102
+ const where = `ledger.entries[${name}]`;
103
+ // 🔴 entry 的键 == 磁盘目录名,是所有权判定单位,也会进 join()
104
+ assertSafeName(name, 'ledger.entries 的键');
105
+ assertKeys(e,
106
+ ['artifact', 'tree_digest', 'snapshot', 'installed_at', 'generation', 'state', 'requested_by'],
107
+ [], where);
108
+ if (typeof e.artifact !== 'string' || e.artifact === '') bad(`${where}.artifact 必填`);
109
+ if (!isTreeDigest(e.tree_digest)) bad(`${where}.tree_digest 必须是树摘要`);
110
+ if (!isUint(e.snapshot)) bad(`${where}.snapshot 必须是非负整数`);
111
+ if (!isUint(e.generation)) bad(`${where}.generation 必须是非负整数`);
112
+ if (!['ok', 'corrupt'].includes(e.state)) bad(`${where}.state 只能是 ok / corrupt`);
113
+ if (!Array.isArray(e.requested_by)) bad(`${where}.requested_by 必须是数组`);
114
+ let prev = null;
115
+ for (const k of e.requested_by) {
116
+ if (typeof k !== 'string' || k === '') bad(`${where}.requested_by 元素必须是非空字符串`);
117
+ // 参与确定性:去重 + 字节序(§8.1 root key grammar 与唯一性)
118
+ if (prev !== null && Buffer.compare(Buffer.from(prev), Buffer.from(k)) >= 0) {
119
+ bad(`${where}.requested_by 必须按字节序严格升序且去重`);
120
+ }
121
+ prev = k;
122
+ }
123
+ }
124
+
125
+ export function validateLedger(L) {
126
+ assertKeys(L,
127
+ ['schema', 'target', 'last_applied_generation', 'roots', 'entries',
128
+ 'audit', 'audit_archived_until', 'transaction'],
129
+ ['cli_version', 'frozen_attic', 'history_unproven'],
130
+ 'ledger');
131
+ if (L.schema !== LEDGER_SCHEMA) bad(`ledger.schema 必须是 ${LEDGER_SCHEMA},得到 ${L.schema}`);
132
+ validateTargetMeta(L.target);
133
+ if (!isUint(L.last_applied_generation)) bad('ledger.last_applied_generation 必须是非负整数');
134
+ for (const k of ['roots', 'entries']) {
135
+ if (L[k] === null || typeof L[k] !== 'object' || Array.isArray(L[k])) bad(`ledger.${k} 必须是对象`);
136
+ }
137
+ for (const [k, v] of Object.entries(L.roots)) validateRoot(k, v);
138
+ for (const [k, v] of Object.entries(L.entries)) validateEntry(k, v);
139
+ if (!Array.isArray(L.audit)) bad('ledger.audit 必须是数组');
140
+ let prevId = -1;
141
+ for (const e of L.audit) {
142
+ validateAuditEvent(e, 'ledger.audit[]');
143
+ // live 流按 event_id 升序且唯一 —— 归档取的是「按 event_id 升序后的一个前缀」
144
+ if (e.event_id <= prevId) bad(`ledger.audit 必须按 event_id 严格升序且唯一(${e.event_id})`);
145
+ prevId = e.event_id;
146
+ }
147
+ if (!isUint(L.audit_archived_until)) bad('ledger.audit_archived_until 必须是非负整数');
148
+ // 🔴 live 流必须整体位于 cursor 之后 —— 否则 live 与 archive 会重叠(Codex 第二轮 #15)
149
+ for (const e of L.audit) {
150
+ if (e.event_id <= L.audit_archived_until) {
151
+ bad(`ledger.audit:event_id ${e.event_id} 不大于 audit_archived_until ${L.audit_archived_until}`);
152
+ }
153
+ }
154
+ if ('frozen_attic' in L) {
155
+ const fa = L.frozen_attic;
156
+ if (fa === null || typeof fa !== 'object' || Array.isArray(fa)) bad('ledger.frozen_attic 必须是对象');
157
+ for (const [label, gens] of Object.entries(fa)) {
158
+ if (!Array.isArray(gens) || !gens.every(isUint)) {
159
+ bad(`ledger.frozen_attic[${label}] 必须是非负整数数组`);
160
+ }
161
+ }
162
+ }
163
+ if ('history_unproven' in L && L.history_unproven !== true) {
164
+ bad('ledger.history_unproven 只允许 true(只增不撤)');
165
+ }
166
+ if (L.transaction !== null) {
167
+ assertKeys(L.transaction, ['generation', 'tx_dir'], [], 'ledger.transaction');
168
+ if (!isUint(L.transaction.generation)) bad('ledger.transaction.generation 必须是非负整数');
169
+ if (L.transaction.tx_dir !== `tx-${L.transaction.generation}`) {
170
+ bad('ledger.transaction.tx_dir 与 generation 不一致');
171
+ }
172
+ }
173
+ return L;
174
+ }
175
+
176
+ export function readLedger(path) {
177
+ return validateLedger(readJsonStrict(path, 'ledger'));
178
+ }
179
+
180
+ export function writeLedger(path, L) {
181
+ validateLedger(L);
182
+ writeAtomic(path, stringify(L));
183
+ return L;
184
+ }
185
+
186
+ // ── generation 单调水位(§4.1)──────────────────────────────────────────────
187
+
188
+ /** 🔴 纯十进制整数,不是 JSON。前导零、负号、空白、非数字一律拒绝。 */
189
+ export function readGenerationWatermark(P) {
190
+ if (!existsSync(P.generationFile)) return null;
191
+ const raw = readFileSync(P.generationFile, 'utf8');
192
+ if (!/^\d+$/.test(raw)) bad(`generation 水位文件内容非法:${JSON.stringify(raw)}`);
193
+ if (raw.length > 1 && raw[0] === '0') bad(`generation 水位文件有前导零:${raw}`);
194
+ const n = Number(raw);
195
+ if (!isUint(n)) bad(`generation 水位越界:${raw}`);
196
+ return n;
197
+ }
198
+
199
+ function writeWatermark(P, n) {
200
+ if (!isUint(n)) bad(`generation 水位越界:${n}`);
201
+ writeAtomic(P.generationFile, String(n));
202
+ }
203
+
204
+ /**
205
+ * 🔴 §4.1:取新 generation = 读该文件 → `+1` → **先原子写回并 fsync,再使用**。
206
+ * 绝不「先用后写」—— 崩在中间就会复用。
207
+ */
208
+ export function nextGeneration(P) {
209
+ const cur = readGenerationWatermark(P);
210
+ if (cur === null) bad('generation 水位文件缺失,不得取号(见 §4.1 的降级语义与 --reset-generation)');
211
+ const next = cur + 1;
212
+ if (!Number.isSafeInteger(next)) bad('generation 水位已达 2^53-1,无法取号');
213
+ writeWatermark(P, next);
214
+ return next;
215
+ }
216
+
217
+ /**
218
+ * §4.1:target 内是否已有 hub 管理的内容。
219
+ * 🔴 **必须含 `quarantine/` 与 `repair-intent.json`** —— v14 只数了 attic 与账本。
220
+ */
221
+ export function hasHubContent(P) {
222
+ if (!existsSync(P.state)) return false;
223
+ if (existsSync(P.ledger)) return true;
224
+ if (existsSync(P.atticDir)) return true;
225
+ if (existsSync(P.journalDir)) return true;
226
+ if (existsSync(P.auditArchiveDir)) return true;
227
+ if (existsSync(P.quarantineDir)) return true;
228
+ if (existsSync(P.repairIntent)) return true;
229
+ if (readdirSync(P.state).some((n) => /^tx-\d+$/.test(n))) return true;
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * §4.1 的降级语义:水位缺失时**不静默扫描猜一个**。
235
+ * - target 内没有任何 hub 管理的内容 → 从 0 开始,正常;
236
+ * - 已有内容而水位没了 → 拒绝初始化,要求 `--reset-generation <N>`。
237
+ */
238
+ export function ensureGenerationWatermark(P) {
239
+ const cur = readGenerationWatermark(P);
240
+ if (cur !== null) return cur;
241
+ if (hasHubContent(P)) {
242
+ bad('本地历史被重置:`.geoly/generation` 缺失而 target 内已有 hub 管理的内容。'
243
+ + '不静默扫描猜一个 —— 请用 `recover --reset-generation <N>`(§5.9)');
244
+ }
245
+ mkdirChainFsync(P.state);
246
+ writeWatermark(P, 0);
247
+ return 0;
248
+ }
249
+
250
+ /**
251
+ * §5.9 `--reset-generation <N>`。契约里的每一条都是拒绝条件,缺一不可。
252
+ * @param {object} P layout
253
+ * @param {number} N 新水位
254
+ */
255
+ export function resetGeneration(P, N) {
256
+ if (!isUint(N)) bad('--reset-generation 的 <N> 必须是非负整数');
257
+ if (!Number.isSafeInteger(N + 1)) bad('N + 1 超过 2^53-1');
258
+ // 前置:仅当水位缺失时可用
259
+ if (existsSync(P.generationFile)) bad('--reset-generation 仅当 `.geoly/generation` 缺失时可用');
260
+ // 🔴 前置:ledger 必须存在且可解析,且**不得自动重建**
261
+ if (!existsSync(P.ledger)) {
262
+ bad('--reset-generation 拒绝:ledger.json 缺失。出路只有两条 ——'
263
+ + '① 人工恢复**同一份一致的 `.geoly` 状态集**(ledger + journal + attic + audit 全套),'
264
+ + '不是凭 archive 手工拼一个 ledger;② 移走**整个 target** 后重装(🔴 放弃本地 audit)');
265
+ }
266
+ const L = readLedger(P.ledger);
267
+ // 拦截:未完成的事务 / 归档 / repair
268
+ const blockers = [];
269
+ if (existsSync(P.journalDir) && readdirSync(P.journalDir).some((n) => /^\d+\.json$/.test(n))) {
270
+ blockers.push('journal/');
271
+ }
272
+ if (readdirSync(P.state).some((n) => /^tx-\d+$/.test(n))) blockers.push('tx-*');
273
+ if (existsSync(P.auditIntent)) blockers.push('audit-archive-intent.json');
274
+ if (existsSync(P.repairIntent)) blockers.push('repair-intent.json');
275
+ if (blockers.length) bad(`--reset-generation 拒绝:存在未完成状态 ${blockers.join(' / ')},请先 recover`);
276
+
277
+ // <N> 必须高于**全部**可观察到的 generation
278
+ const observed = observedGenerations(P, L);
279
+ const maxObserved = observed.length ? Math.max(...observed) : 0;
280
+ if (N <= maxObserved) {
281
+ bad(`--reset-generation 拒绝:<N>=${N} 不高于可观察到的最大 generation ${maxObserved}`
282
+ + `(观察到 ${observed.sort((a, b) => a - b).join(',')})`);
283
+ }
284
+
285
+ // 🔴 顺序不可颠倒:先原子写并 fsync 标记,再写 generation 水位。
286
+ // 先写水位、崩在标记之前,后续运行会看到水位却不知道历史不可证明。
287
+ if (L.history_unproven !== true) writeLedger(P.ledger, { ...L, history_unproven: true });
288
+ writeWatermark(P, N);
289
+ return N;
290
+ }
291
+
292
+ /** §5.9:可观察的 generation 集合 —— 六个来源,一个都不能少 */
293
+ export function observedGenerations(P, L) {
294
+ const out = [];
295
+ const dirGens = (dir, re) => {
296
+ if (!existsSync(dir)) return;
297
+ for (const n of readdirSync(dir)) { const m = re.exec(n); if (m) out.push(Number(m[1])); }
298
+ };
299
+ dirGens(P.atticDir, /^(\d+)$/);
300
+ dirGens(P.quarantineDir, /^(\d+)$/);
301
+ dirGens(P.state, /^tx-(\d+)$/);
302
+ if (existsSync(P.journalDir)) {
303
+ for (const n of readdirSync(P.journalDir)) {
304
+ const m = /^(\d+)\.json$/.exec(n);
305
+ if (!m) continue;
306
+ out.push(Number(m[1]));
307
+ // 🔴 即使是 completed 的,也要参与下界计算;并且内部的 generation 也算
308
+ try {
309
+ const j = parseStrict(readFileSync(join(P.journalDir, n), 'utf8'));
310
+ if (isUint(j.generation)) out.push(j.generation);
311
+ } catch { /* 读不出来就只用文件名 —— 但绝不因此放低下界 */ }
312
+ }
313
+ }
314
+ if (L) {
315
+ out.push(L.last_applied_generation);
316
+ for (const e of Object.values(L.entries)) out.push(e.generation);
317
+ for (const gens of Object.values(L.frozen_attic ?? {})) out.push(...gens);
318
+ }
319
+ return out;
320
+ }
321
+
322
+ // ── audit-seq(§4 audit plane)───────────────────────────────────────────────
323
+
324
+ export function readAuditSeq(P) {
325
+ if (!existsSync(P.auditSeqFile)) return null;
326
+ const raw = readFileSync(P.auditSeqFile, 'utf8');
327
+ if (!/^\d+$/.test(raw)) bad(`audit-seq 内容非法:${JSON.stringify(raw)}`);
328
+ if (raw.length > 1 && raw[0] === '0') bad(`audit-seq 有前导零:${raw}`);
329
+ return Number(raw);
330
+ }
331
+
332
+ /**
333
+ * 🔴 分配顺序(§4):在 target 锁下 ① 读 seq → ② `+1` 原子写回并 fsync → ③ 才把该 id
334
+ * 用在事件上。②成功而后续失败 → **允许烧号**(号可以有洞,不可以重复)。
335
+ */
336
+ export function allocEventId(P) {
337
+ const cur = readAuditSeq(P);
338
+ if (cur === null) bad('audit-seq 缺失:已有 ledger 时一律拒绝(§4 audit-seq 生命周期)');
339
+ const next = cur + 1;
340
+ if (next > Number.MAX_SAFE_INTEGER) bad('event_id 达到 2^53-1,拒绝追加;需要归档并人工处置');
341
+ writeAtomic(P.auditSeqFile, String(next));
342
+ return next;
343
+ }
344
+
345
+ // ── bootstrap(§5.4.2)──────────────────────────────────────────────────────
346
+
347
+ export function ledgerSkeleton(targetMeta) {
348
+ return {
349
+ schema: LEDGER_SCHEMA,
350
+ audit: [],
351
+ audit_archived_until: 0,
352
+ entries: {},
353
+ // 🔴 是 `last_applied_generation: 0`,**不是** `generation` —— 与水位同名会被
354
+ // 重新诱导成「从账本取号」。
355
+ last_applied_generation: 0,
356
+ roots: {},
357
+ target: targetMeta,
358
+ transaction: null,
359
+ };
360
+ }
361
+
362
+ /**
363
+ * §5.4.2 bootstrap 协议:**先成功写出 ledger 骨架,再写 journal**;
364
+ * 骨架与 `audit-seq` 是**两份各自原子的文件**,不是一次跨文件原子操作。
365
+ *
366
+ * ① 先写 `audit-seq = 0`(若尚不存在)→ fsync。
367
+ * 🔴 已存在**合法** seq 时**沿用、绝不重置**(「无 ledger 但有 seq」是合法状态)。
368
+ * ② 再写 ledger 骨架 → fsync。
369
+ */
370
+ export function bootstrapLedger(P, targetMeta) {
371
+ mkdirChainFsync(P.state);
372
+ const seq = readAuditSeq(P); // 非法会在这里 fail-closed
373
+ if (seq === null) writeAtomic(P.auditSeqFile, '0');
374
+ const L = ledgerSkeleton(targetMeta);
375
+ writeLedger(P.ledger, L);
376
+ return L;
377
+ }
378
+
379
+ /**
380
+ * §4「bootstrap 不得删掉承载 audit 的账本」。
381
+ * rollback 在 `ledger_existed = false` 时要删掉整个 ledger.json,
382
+ * **例外**:存在任何 live audit / `audit-archive/` / audit intent / `audit_archived_until > 0`
383
+ * → 不删,改写一份**例外账本**:🔴 只清空 `entries` / `roots` / `transaction`,
384
+ * audit plane(live `audit` 与 `audit_archived_until`)**一律原样保留**。
385
+ */
386
+ export function hasAuditEvidence(P, L) {
387
+ if (L && Array.isArray(L.audit) && L.audit.length > 0) return true;
388
+ if (L && (L.audit_archived_until ?? 0) > 0) return true;
389
+ if (existsSync(P.auditIntent)) return true;
390
+ if (existsSync(P.auditArchiveDir)
391
+ && readdirSync(P.auditArchiveDir).some((n) => /^\d+\.json$/.test(n))) return true;
392
+ return false;
393
+ }
394
+
395
+ export function dropOrExceptionLedger(P) {
396
+ if (!existsSync(P.ledger)) return { action: 'already-absent' };
397
+ const L = readLedger(P.ledger);
398
+ if (!hasAuditEvidence(P, L)) {
399
+ rmtreeFsync(P.ledger);
400
+ return { action: 'removed' };
401
+ }
402
+ writeLedger(P.ledger, {
403
+ ...L,
404
+ entries: {},
405
+ roots: {},
406
+ transaction: null,
407
+ // 🔴 audit plane 原样保留 —— **不是**照骨架的 `audit: [] / 0` 来写
408
+ });
409
+ return { action: 'exception-ledger' };
410
+ }
411
+
412
+ // ── ledger_image 的 patch 语义(§5.4.2)─────────────────────────────────────
413
+
414
+ /**
415
+ * 🔴 「按 `post` 写」= 对这些键做**原子 patch**,**未列出的键一律保持不变**。
416
+ * 不是整文件替换 —— 否则 journal 权威时重建不出完整账本。
417
+ *
418
+ * - `null` 哨兵 = 删除该键;
419
+ * - `frozen_attic` **按整张 map 存取**(不是逐 label patch);
420
+ * - `transaction` **不进镜像**,由调用方按结果置;
421
+ * - 取号水位永不进镜像。
422
+ */
423
+ export function applyImageSide(L, side, { archiveDir } = {}) {
424
+ const out = { ...L, entries: { ...L.entries }, roots: { ...L.roots } };
425
+ for (const [k, v] of Object.entries(side.entries)) {
426
+ if (v === null) delete out.entries[k]; else out.entries[k] = v;
427
+ }
428
+ for (const [k, v] of Object.entries(side.roots)) {
429
+ if (v === null) delete out.roots[k]; else out.roots[k] = v;
430
+ }
431
+ out.last_applied_generation = side.last_applied_generation;
432
+ if ('frozen_attic' in side) {
433
+ if (side.frozen_attic === null) delete out.frozen_attic;
434
+ else out.frozen_attic = side.frozen_attic;
435
+ }
436
+ if (side.audit_append) {
437
+ out.audit = mergeAuditAppend(out.audit, side.audit_append,
438
+ { archiveDir, archivedUntil: L.audit_archived_until ?? 0 });
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /**
444
+ * §4「audit 永不回退」的 `audit_append` 合并。**去重必须 fail-closed。**
445
+ *
446
+ * 🔴 校验顺序(v20 定死,避免实现歧义):
447
+ * ① 先校验**已持久化的 live 流**自身唯一、且与 `audit-archive/` 不相交;
448
+ * ② 再校验 `audit_append` **批内**唯一;
449
+ * ③ 最后逐条合并。
450
+ * **不得把 replay 的候选先算成「live 内重复」。**
451
+ */
452
+ export function mergeAuditAppend(live, append, { archiveDir, archivedUntil = 0 } = {}) {
453
+ // ①
454
+ const liveById = new Map();
455
+ for (const e of live) {
456
+ if (liveById.has(e.event_id)) bad(`audit:live 流内 event_id ${e.event_id} 重复`);
457
+ liveById.set(e.event_id, stringify(e));
458
+ }
459
+ const archived = archiveDir ? readArchivedIds(archiveDir) : new Map();
460
+ for (const id of liveById.keys()) {
461
+ if (archived.has(id)) bad(`audit:event_id ${id} 同时在 live 与 audit-archive/`);
462
+ }
463
+ // ②
464
+ const batch = new Map();
465
+ for (const e of append) {
466
+ validateAuditEvent(e, 'audit_append[]');
467
+ // 🔴 新事件的 id 必须严格大于 cursor —— 否则 live 会与 archive 重叠、序列回退
468
+ // (Codex 第二轮 #15)
469
+ if (e.event_id <= archivedUntil) {
470
+ bad(`audit_append:event_id ${e.event_id} 不大于 audit_archived_until ${archivedUntil}`);
471
+ }
472
+ const s = stringify(e);
473
+ if (batch.has(e.event_id)) {
474
+ bad(`audit_append 批内 event_id ${e.event_id} 重复`);
475
+ }
476
+ batch.set(e.event_id, s);
477
+ }
478
+ // ③
479
+ const out = [...live];
480
+ for (const [id, s] of batch) {
481
+ if (liveById.has(id)) {
482
+ // 同 id 且 canonical 字节完全相同 → no-op(journal 重放的正常情形)
483
+ if (liveById.get(id) !== s) bad(`audit:event_id ${id} 已存在但内容不同(fail-closed)`);
484
+ continue;
485
+ }
486
+ if (archived.has(id)) {
487
+ // 归档只发生在 journal 完成之后,replay 正常撞不到 archive
488
+ if (archived.get(id) !== s) bad(`audit:event_id ${id} 与已归档事件冲突`);
489
+ continue;
490
+ }
491
+ out.push(parseStrict(s));
492
+ }
493
+ out.sort((a, b) => a.event_id - b.event_id);
494
+ return out;
495
+ }
496
+
497
+ /**
498
+ * 🔴 读归档时**做完整 schema 校验**,不是「JSON 能 parse 就当证据」(Codex 第二轮 #14)。
499
+ * 这些 id 会参与 audit_append 的去重判定 —— 一份损坏或伪造的归档不能进那道判定。
500
+ */
501
+ function readArchivedIds(archiveDir) {
502
+ const m = new Map();
503
+ if (!existsSync(archiveDir)) return m;
504
+ for (const n of readdirSync(archiveDir)) {
505
+ if (!/^\d+\.json$/.test(n)) continue;
506
+ const seq = Number(n.slice(0, -5));
507
+ const arc = readJsonStrict(join(archiveDir, n), 'audit-archive');
508
+ // 🔴 文件名与 seq 必须一致 —— 否则可以拿一份合法归档改个文件名来顶替另一批
509
+ verifyArchiveFile(join(archiveDir, n), {
510
+ batch_digest: arc.batch_digest, from_event: arc.from_event, seq, to_event: arc.to_event,
511
+ });
512
+ for (const e of arc.events) {
513
+ if (m.has(e.event_id)) bad(`audit-archive:event_id ${e.event_id} 在归档间重复`);
514
+ m.set(e.event_id, stringify(e));
515
+ }
516
+ }
517
+ return m;
518
+ }
519
+
520
+ // ── audit 归档小事务(§4 的四步)─────────────────────────────────────────────
521
+
522
+ /** 🔴 `batch_digest` = 对 `events` 数组按 §11 canonical 序列化后的**原始字节**求 sha256 */
523
+ export const batchDigest = (events) => sha256(Buffer.from(stringify(events), 'utf8'));
524
+
525
+ function validateArchiveIntent(o) {
526
+ assertKeys(o, ['schema', 'seq', 'from_event', 'to_event', 'batch_digest'], [], 'audit-archive-intent');
527
+ if (o.schema !== AUDIT_INTENT_SCHEMA) bad(`audit-archive-intent.schema 必须是 ${AUDIT_INTENT_SCHEMA}`);
528
+ for (const k of ['seq', 'from_event', 'to_event']) if (!isUint(o[k])) bad(`audit-archive-intent.${k} 必须是非负整数`);
529
+ // 🔴 `seq = to_event`(不再单独分配,天然唯一且单调)
530
+ if (o.seq !== o.to_event) bad('audit-archive-intent:seq 必须等于 to_event');
531
+ if (o.from_event > o.to_event) bad('audit-archive-intent:from_event > to_event');
532
+ if (typeof o.batch_digest !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(o.batch_digest)) {
533
+ bad('audit-archive-intent.batch_digest 形式非法');
534
+ }
535
+ return o;
536
+ }
537
+
538
+ /**
539
+ * §4「阈值归档」:live 事件数 `== audit_max_entries` **不归档**;`>` 才归档,归档到 `== max`。
540
+ * 归档前缀必须非空 —— 空前缀不写任何文件、不动 cursor(空批次 no-op)。
541
+ *
542
+ * 🔴 调用点由 §5.2 的步骤 **2c** 决定(「已确认无未完成安装事务之后」);
543
+ * 本函数不判断那件事,判断它的是 recover.mjs。
544
+ */
545
+ export function maybeArchiveAudit(P, { maxEntries = DEFAULT_AUDIT_MAX_ENTRIES } = {}) {
546
+ if (!existsSync(P.ledger)) return { outcome: 'noop' };
547
+ const L = readLedger(P.ledger);
548
+ const live = L.audit;
549
+ if (live.length <= maxEntries) return { outcome: 'noop' };
550
+ const batch = live.slice(0, live.length - maxEntries);
551
+ if (batch.length === 0) return { outcome: 'noop' };
552
+ const meta = {
553
+ schema: AUDIT_INTENT_SCHEMA,
554
+ batch_digest: batchDigest(batch),
555
+ from_event: batch[0].event_id,
556
+ seq: batch[batch.length - 1].event_id,
557
+ to_event: batch[batch.length - 1].event_id,
558
+ };
559
+ // ① 写 intent → fsync
560
+ writeAtomic(P.auditIntent, stringify(meta));
561
+ fp('audit-archive:step1:post-intent', { seq: meta.seq });
562
+ finishAuditArchive(P, meta, batch);
563
+ return { outcome: 'archived', seq: meta.seq };
564
+ }
565
+
566
+ /**
567
+ * ②②′③④。**幂等**,正常路径与崩溃恢复走**同一段**重验(§4 的 ②′)。
568
+ */
569
+ function finishAuditArchive(P, meta, batchMaybe) {
570
+ const file = join(P.auditArchiveDir, `${meta.seq}.json`);
571
+ mkdirChainFsync(P.auditArchiveDir);
572
+
573
+ // ② 写 archive。🔴 已存在时**绝不覆盖** —— 只重验。
574
+ if (!existsSync(file)) {
575
+ const batch = batchMaybe ?? sliceLiveForMeta(P, meta);
576
+ writeAtomic(file, stringify({
577
+ schema: AUDIT_ARCHIVE_SCHEMA,
578
+ batch_digest: meta.batch_digest,
579
+ events: batch,
580
+ from_event: meta.from_event,
581
+ seq: meta.seq,
582
+ to_event: meta.to_event,
583
+ }));
584
+ }
585
+ fp('audit-archive:step2:post-archive', { seq: meta.seq });
586
+
587
+ // ②′ 🔴 **正常路径也必须重验**:重新打开,严格校验 schema、seq、范围、完整性与 batch_digest
588
+ verifyArchiveFile(file, meta);
589
+ fp('audit-archive:step2:post-reverify', { seq: meta.seq });
590
+
591
+ // ③ 账本 patch:移除该前缀、置 audit_archived_until = to_event(幂等)
592
+ const L = readLedger(P.ledger);
593
+ // 🔴 归档的批次必须**与当前 live 前缀逐字节对应**(Codex 第三轮 #4):
594
+ // 只验归档自身、然后按 event_id 把 live 里的删掉,会在「live 在中间被改写」时
595
+ // 把改写过的那一版**丢掉**而毫无察觉。audit 只增不减,这里必须 fail-closed。
596
+ {
597
+ const arc = readJsonStrict(file, 'audit-archive');
598
+ const byId = new Map(arc.events.map((e) => [e.event_id, stringify(e)]));
599
+ for (const e of L.audit) {
600
+ if (e.event_id > meta.to_event) continue;
601
+ const want = byId.get(e.event_id);
602
+ if (want === undefined) bad(`audit 归档:live 里的 event_id ${e.event_id} 不在归档批次内`);
603
+ if (want !== stringify(e)) bad(`audit 归档:live 里的 event_id ${e.event_id} 与归档内容不同,停机`);
604
+ }
605
+ }
606
+ // 🔴 cursor **只前进**:当前值已经大于目标值时把它写小就是回退(Codex 第二轮 #13)
607
+ if (L.audit_archived_until > meta.to_event) {
608
+ bad(`audit cursor 只前进:当前 ${L.audit_archived_until} > 本批 to_event ${meta.to_event},停机`);
609
+ }
610
+ const remaining = L.audit.filter((e) => e.event_id > meta.to_event);
611
+ if (remaining.length !== L.audit.length || L.audit_archived_until !== meta.to_event) {
612
+ writeLedger(P.ledger, { ...L, audit: remaining, audit_archived_until: meta.to_event });
613
+ }
614
+ fp('audit-archive:step3:post-ledger-patch', { seq: meta.seq });
615
+
616
+ // ④ 删 intent → fsync 父目录
617
+ if (existsSync(P.auditIntent)) rmtreeFsync(P.auditIntent);
618
+ fp('audit-archive:step4:post-intent-removed', { seq: meta.seq });
619
+ }
620
+
621
+ function verifyArchiveFile(file, meta) {
622
+ const got = readJsonStrict(file, 'audit-archive');
623
+ assertKeys(got, ['schema', 'seq', 'from_event', 'to_event', 'events', 'batch_digest'], [], 'audit-archive');
624
+ if (got.schema !== AUDIT_ARCHIVE_SCHEMA) bad(`audit-archive.schema 不符:${got.schema}`);
625
+ if (got.seq !== meta.seq || got.from_event !== meta.from_event || got.to_event !== meta.to_event) {
626
+ bad('audit-archive 的 seq / from_event / to_event 与 intent 不符');
627
+ }
628
+ if (!Array.isArray(got.events) || got.events.length === 0) bad('audit-archive.events 必须是非空数组');
629
+ let prev = -1;
630
+ for (const e of got.events) {
631
+ validateAuditEvent(e, 'audit-archive.events[]');
632
+ if (e.event_id <= prev) bad('audit-archive.events 必须按 event_id 严格升序');
633
+ prev = e.event_id;
634
+ }
635
+ if (got.events[0].event_id !== got.from_event) bad('audit-archive:首个 event_id 与 from_event 不符');
636
+ if (prev !== got.to_event) bad('audit-archive:末个 event_id 与 to_event 不符');
637
+ // 🔴 §4:`seq = to_event`(不单独分配)—— 读历史归档时也要断言,不只在写的时候
638
+ if (got.seq !== got.to_event) bad('audit-archive:seq 必须等于 to_event');
639
+ if (batchDigest(got.events) !== meta.batch_digest) bad('audit-archive.batch_digest 与内容不符');
640
+ return got;
641
+ }
642
+
643
+ function sliceLiveForMeta(P, meta) {
644
+ const L = readLedger(P.ledger);
645
+ const batch = L.audit.filter((e) => e.event_id >= meta.from_event && e.event_id <= meta.to_event);
646
+ if (batch.length === 0) bad('audit intent 指向的批次在 live 流里已不存在,且 archive 文件也不在');
647
+ return batch;
648
+ }
649
+
650
+ /**
651
+ * 🔴 §5.2 步骤 **2a**:先清 audit intent。存在 → 按归档协议**完成它,或 fail-closed 停机**。
652
+ * **绝不跳过、绝不删除。**
653
+ */
654
+ export function resumeAuditArchive(P) {
655
+ if (!existsSync(P.auditIntent)) return { outcome: 'no-intent' };
656
+ const meta = validateArchiveIntent(readJsonStrict(P.auditIntent, 'audit-archive-intent'));
657
+ finishAuditArchive(P, meta);
658
+ return { outcome: 'audit-finished', seq: meta.seq };
659
+ }
660
+
661
+ /** 供上层在动 attic 之前判断某代是否被冻结(§5.8 `--freeze-attic`)*/
662
+ export function isGenerationFrozen(L, gen) {
663
+ for (const gens of Object.values(L.frozen_attic ?? {})) if (gens.includes(gen)) return true;
664
+ return false;
665
+ }
666
+
667
+ export function fsyncState(P) {
668
+ if (existsSync(P.state)) fsyncDir(P.state);
669
+ }
670
+
671
+ export { Corrupt };