@dimina-kit/fs-core 0.2.0-dev.20260710085051
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/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/agent-tools.js +52 -0
- package/dist/client.js +234 -0
- package/dist/disk-mirror.js +97 -0
- package/dist/fs-core.worker.js +1001 -0
- package/dist/fs-query.worker.js +115 -0
- package/dist/sync/sync-engine.js +381 -0
- package/dist/sync/truth-port.js +1 -0
- package/dist/worker-lib/engine-shared.js +25 -0
- package/dist/worker-lib/paths.js +17 -0
- package/dist/worker-lib/rpc-types.js +1 -0
- package/dist/worker-lib/wal-codec.js +111 -0
- package/dist/zip.js +60 -0
- package/package.json +75 -0
- package/src/__checks__/types-smoke.ts +23 -0
- package/src/agent-tools.test.ts +151 -0
- package/src/agent-tools.ts +117 -0
- package/src/client.test.ts +110 -0
- package/src/client.ts +284 -0
- package/src/disk-mirror.test.ts +190 -0
- package/src/disk-mirror.ts +119 -0
- package/src/fs-core-recovery.ts +280 -0
- package/src/fs-core-write-ops.ts +402 -0
- package/src/fs-core.worker.ts +182 -0
- package/src/fs-query.worker.ts +152 -0
- package/src/import-smoke.test.ts +40 -0
- package/src/worker-lib/engine-shared.test.ts +30 -0
- package/src/worker-lib/engine-shared.ts +74 -0
- package/src/worker-lib/paths.test.ts +39 -0
- package/src/worker-lib/paths.ts +14 -0
- package/src/worker-lib/rpc-types.ts +67 -0
- package/src/worker-lib/wal-codec.test.ts +83 -0
- package/src/worker-lib/wal-codec.ts +130 -0
- package/src/zip.test.ts +124 -0
- package/src/zip.ts +60 -0
- package/sync/sync-engine.test.ts +695 -0
- package/sync/sync-engine.ts +459 -0
- package/sync/truth-port.ts +72 -0
|
@@ -0,0 +1,1001 @@
|
|
|
1
|
+
// src/worker-lib/wal-codec.ts
|
|
2
|
+
var enc = new TextEncoder();
|
|
3
|
+
var dec = new TextDecoder();
|
|
4
|
+
var CRC_TABLE = (() => {
|
|
5
|
+
const t = new Uint32Array(256);
|
|
6
|
+
for (let n = 0; n < 256; n++) {
|
|
7
|
+
let c = n;
|
|
8
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
9
|
+
t[n] = c >>> 0;
|
|
10
|
+
}
|
|
11
|
+
return t;
|
|
12
|
+
})();
|
|
13
|
+
function crc32(bytes, start2 = 0, end = bytes.length) {
|
|
14
|
+
let c = 4294967295;
|
|
15
|
+
for (let i = start2; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]) & 255] ^ c >>> 8;
|
|
16
|
+
return (c ^ 4294967295) >>> 0;
|
|
17
|
+
}
|
|
18
|
+
async function sha256hex(bytes) {
|
|
19
|
+
const d = await crypto.subtle.digest("SHA-256", bytes);
|
|
20
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
21
|
+
}
|
|
22
|
+
var SB_MAGIC = 1146569521;
|
|
23
|
+
var SLOT_SIZE = 64;
|
|
24
|
+
function encodeSlot(s) {
|
|
25
|
+
const buf = new ArrayBuffer(SLOT_SIZE);
|
|
26
|
+
const dv = new DataView(buf);
|
|
27
|
+
dv.setUint32(0, SB_MAGIC);
|
|
28
|
+
dv.setUint32(4, s.epoch);
|
|
29
|
+
dv.setFloat64(8, s.compactGen);
|
|
30
|
+
dv.setFloat64(16, s.walStartGen);
|
|
31
|
+
dv.setUint32(24, s.manifestCrc);
|
|
32
|
+
dv.setUint32(60, crc32(new Uint8Array(buf, 0, 60)));
|
|
33
|
+
return new Uint8Array(buf);
|
|
34
|
+
}
|
|
35
|
+
function decodeSlot(bytes) {
|
|
36
|
+
if (bytes.length < SLOT_SIZE) return null;
|
|
37
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, SLOT_SIZE);
|
|
38
|
+
if (dv.getUint32(60) !== crc32(bytes, 0, 60)) return null;
|
|
39
|
+
if (dv.getUint32(0) !== SB_MAGIC) return null;
|
|
40
|
+
return {
|
|
41
|
+
epoch: dv.getUint32(4),
|
|
42
|
+
compactGen: dv.getFloat64(8),
|
|
43
|
+
walStartGen: dv.getFloat64(16),
|
|
44
|
+
manifestCrc: dv.getUint32(24)
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function frameRecord(gen, epoch, opcode, meta) {
|
|
48
|
+
const metaBytes = enc.encode(JSON.stringify(meta));
|
|
49
|
+
const len = 8 + 4 + 1 + 2 + metaBytes.length + 4 + 1;
|
|
50
|
+
const buf = new ArrayBuffer(4 + len);
|
|
51
|
+
const dv = new DataView(buf);
|
|
52
|
+
const u8 = new Uint8Array(buf);
|
|
53
|
+
dv.setUint32(0, len);
|
|
54
|
+
dv.setBigUint64(4, BigInt(gen));
|
|
55
|
+
dv.setUint32(12, epoch);
|
|
56
|
+
dv.setUint8(16, opcode);
|
|
57
|
+
dv.setUint16(17, metaBytes.length);
|
|
58
|
+
u8.set(metaBytes, 19);
|
|
59
|
+
const crcEnd = 19 + metaBytes.length;
|
|
60
|
+
dv.setUint32(crcEnd, crc32(u8, 4, crcEnd));
|
|
61
|
+
dv.setUint8(crcEnd + 4, 193);
|
|
62
|
+
return u8;
|
|
63
|
+
}
|
|
64
|
+
function parseRecord(u8, off) {
|
|
65
|
+
if (off + 4 > u8.length) return null;
|
|
66
|
+
const dv = new DataView(u8.buffer, u8.byteOffset);
|
|
67
|
+
const len = dv.getUint32(off);
|
|
68
|
+
if (len < 20 || off + 4 + len > u8.length) return null;
|
|
69
|
+
const gen = Number(dv.getBigUint64(off + 4));
|
|
70
|
+
const epoch = dv.getUint32(off + 12);
|
|
71
|
+
const opcode = dv.getUint8(off + 16);
|
|
72
|
+
const metaLen = dv.getUint16(off + 17);
|
|
73
|
+
const metaStart = off + 19;
|
|
74
|
+
const crcAt = metaStart + metaLen;
|
|
75
|
+
if (crcAt + 5 !== off + 4 + len) return null;
|
|
76
|
+
if (dv.getUint32(crcAt) !== crc32(u8, off + 4, crcAt)) return null;
|
|
77
|
+
if (dv.getUint8(crcAt + 4) !== 193) return null;
|
|
78
|
+
let meta;
|
|
79
|
+
try {
|
|
80
|
+
meta = JSON.parse(dec.decode(u8.subarray(metaStart, crcAt)));
|
|
81
|
+
} catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
return { rec: { gen, epoch, opcode, meta }, next: off + 4 + len };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/worker-lib/paths.ts
|
|
88
|
+
var DERIVED_PREFIXES = ["node_modules/", ".checkpoints/"];
|
|
89
|
+
function normalizePath(p) {
|
|
90
|
+
if (typeof p !== "string" || !p || p.includes("\0") || p.includes("\\")) return null;
|
|
91
|
+
if (p.startsWith("/")) return null;
|
|
92
|
+
const parts = [];
|
|
93
|
+
for (const seg of p.split("/")) {
|
|
94
|
+
if (seg === "" || seg === ".") continue;
|
|
95
|
+
if (seg === "..") return null;
|
|
96
|
+
parts.push(seg);
|
|
97
|
+
}
|
|
98
|
+
return parts.length ? parts.join("/") : null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/worker-lib/engine-shared.ts
|
|
102
|
+
var OP = { WRITE: 1, RM: 2, MV: 3, MKDIR: 4, CHECKPOINT: 5, RESTORE: 6 };
|
|
103
|
+
var OP_NAME = { 1: "write", 2: "rm", 3: "mv", 4: "mkdir", 5: "checkpoint", 6: "restore" };
|
|
104
|
+
var WRITE_OPCODES = /* @__PURE__ */ new Set([OP.WRITE, OP.RM, OP.MV, OP.RESTORE]);
|
|
105
|
+
var GROUP_WINDOW_MS = 50;
|
|
106
|
+
var SEGMENT_ROTATE_BYTES = 4 * 1024 * 1024;
|
|
107
|
+
var OPID_WINDOW = 1024;
|
|
108
|
+
var TURN_DEFAULT_TTL_MS = 12e4;
|
|
109
|
+
var TURN_MAX_OPS = 1e3;
|
|
110
|
+
var AUDIT_CAP = 4096;
|
|
111
|
+
var CHECKPOINT_KEEP = 20;
|
|
112
|
+
function rpcErr(code, message, extra) {
|
|
113
|
+
const e = new Error(message);
|
|
114
|
+
e.code = code;
|
|
115
|
+
if (extra) e.extra = extra;
|
|
116
|
+
return e;
|
|
117
|
+
}
|
|
118
|
+
function epochFloor(replayed) {
|
|
119
|
+
return replayed.length ? replayed[replayed.length - 1].epoch : 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/fs-core-recovery.ts
|
|
123
|
+
async function start(core2, projectId) {
|
|
124
|
+
core2.projectId = projectId;
|
|
125
|
+
core2.root = await (await navigator.storage.getDirectory()).getDirectoryHandle(projectId, { create: true });
|
|
126
|
+
core2.bc = new BroadcastChannel("dwc:" + projectId);
|
|
127
|
+
core2.bc.onmessage = (e) => core2.onBroadcast(e.data);
|
|
128
|
+
const granted = new Promise((resolve) => {
|
|
129
|
+
navigator.locks.request("dwc:writer:" + projectId, { mode: "exclusive" }, (lock) => {
|
|
130
|
+
resolve(lock);
|
|
131
|
+
return new Promise((release) => {
|
|
132
|
+
core2.releaseLock = release;
|
|
133
|
+
});
|
|
134
|
+
}).catch(() => resolve(null));
|
|
135
|
+
});
|
|
136
|
+
const winner = await Promise.race([granted, new Promise((r) => setTimeout(() => r("timeout"), 3e3))]);
|
|
137
|
+
if (winner === "timeout") {
|
|
138
|
+
await core2.recover();
|
|
139
|
+
core2.mode = "readonly";
|
|
140
|
+
core2.pushFullToQuery();
|
|
141
|
+
core2.welcome();
|
|
142
|
+
granted.then(async (lock) => {
|
|
143
|
+
if (!lock || core2.mode === "dead") return;
|
|
144
|
+
await core2.enqueue(async () => {
|
|
145
|
+
await core2.becomeWriter();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await core2.becomeWriter();
|
|
151
|
+
core2.welcome();
|
|
152
|
+
}
|
|
153
|
+
async function becomeWriter(core2) {
|
|
154
|
+
await core2.recover();
|
|
155
|
+
core2.sbHandle = await (await core2.root.getFileHandle("superblock", { create: true })).createSyncAccessHandle();
|
|
156
|
+
core2.epoch += 1;
|
|
157
|
+
core2.writeSuperblock();
|
|
158
|
+
const segName = "wal." + core2.lastSegStart;
|
|
159
|
+
core2.walHandle = await (await core2.root.getFileHandle(segName, { create: true })).createSyncAccessHandle();
|
|
160
|
+
core2.walOffset = core2.lastSegValidEnd;
|
|
161
|
+
core2.mode = "writer";
|
|
162
|
+
core2.pushFullToQuery();
|
|
163
|
+
core2.event({ evt: "writer-granted", gen: core2.memGen });
|
|
164
|
+
if (core2.walOffset > SEGMENT_ROTATE_BYTES) core2.enqueue(() => core2.compactNow());
|
|
165
|
+
}
|
|
166
|
+
function writeSuperblock(core2) {
|
|
167
|
+
const cand = 1 - core2.currentSlot;
|
|
168
|
+
const bytes = encodeSlot({ epoch: core2.epoch, compactGen: core2.compactGen, walStartGen: core2.walStartGen, manifestCrc: core2.manifestCrc || 0 });
|
|
169
|
+
const n = core2.sbHandle.write(bytes, { at: cand * SLOT_SIZE });
|
|
170
|
+
if (n !== SLOT_SIZE) throw new Error("superblock partial write: " + n);
|
|
171
|
+
core2.sbHandle.flush();
|
|
172
|
+
core2.currentSlot = cand;
|
|
173
|
+
}
|
|
174
|
+
async function loadSuperblockSlot(core2) {
|
|
175
|
+
let sb = null;
|
|
176
|
+
try {
|
|
177
|
+
const f = await (await core2.root.getFileHandle("superblock")).getFile();
|
|
178
|
+
const u8 = new Uint8Array(await f.arrayBuffer());
|
|
179
|
+
const s0 = decodeSlot(u8.subarray(0, SLOT_SIZE));
|
|
180
|
+
const s1 = u8.length >= 128 ? decodeSlot(u8.subarray(SLOT_SIZE, 2 * SLOT_SIZE)) : null;
|
|
181
|
+
if (s0 && s1) {
|
|
182
|
+
sb = s1.compactGen > s0.compactGen || s1.compactGen === s0.compactGen && s1.epoch >= s0.epoch ? s1 : s0;
|
|
183
|
+
core2.currentSlot = sb === s1 ? 1 : 0;
|
|
184
|
+
} else if (s0) {
|
|
185
|
+
sb = s0;
|
|
186
|
+
core2.currentSlot = 0;
|
|
187
|
+
} else if (s1) {
|
|
188
|
+
sb = s1;
|
|
189
|
+
core2.currentSlot = 1;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
}
|
|
193
|
+
if (!sb) {
|
|
194
|
+
sb = { epoch: 0, compactGen: 0, walStartGen: 1, manifestCrc: 0 };
|
|
195
|
+
core2.currentSlot = 1;
|
|
196
|
+
}
|
|
197
|
+
return sb;
|
|
198
|
+
}
|
|
199
|
+
async function loadManifestState(core2, compactGen) {
|
|
200
|
+
if (compactGen <= 0) return 0;
|
|
201
|
+
const f = await (await (await core2.root.getDirectoryHandle("manifests")).getFileHandle(compactGen + ".json")).getFile();
|
|
202
|
+
const bytes = new Uint8Array(await f.arrayBuffer());
|
|
203
|
+
if (crc32(bytes) !== core2.manifestCrc) throw new Error("manifest CRC mismatch (gen " + compactGen + ")");
|
|
204
|
+
const m = JSON.parse(dec.decode(bytes));
|
|
205
|
+
for (const [path, ent] of Object.entries(m.files)) {
|
|
206
|
+
core2.mirror.set(path, { content: await core2.readBlob(ent.h), rev: ent.rev });
|
|
207
|
+
}
|
|
208
|
+
for (const [cpId, entry] of Object.entries(m.checkpoints || {})) core2.checkpoints.set(cpId, entry);
|
|
209
|
+
return m.gen;
|
|
210
|
+
}
|
|
211
|
+
async function listWalSegmentNumbers(core2) {
|
|
212
|
+
const segs = [];
|
|
213
|
+
for await (const [name] of core2.root.entries()) {
|
|
214
|
+
const m = /^wal\.(\d+)$/.exec(name);
|
|
215
|
+
if (m && +m[1] >= core2.walStartGen) segs.push(+m[1]);
|
|
216
|
+
}
|
|
217
|
+
segs.sort((a, b) => a - b);
|
|
218
|
+
return segs;
|
|
219
|
+
}
|
|
220
|
+
function shouldStopReplay(p, gen, stopBefore, replayed) {
|
|
221
|
+
if (!p) return true;
|
|
222
|
+
if (p.rec.gen !== gen + 1) return true;
|
|
223
|
+
if (p.rec.gen >= stopBefore) return true;
|
|
224
|
+
if (p.rec.epoch < epochFloor(replayed)) return true;
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
async function replaySegment(core2, segNum, stopBefore, gen, replayed, isLastSeg) {
|
|
228
|
+
const f = await (await core2.root.getFileHandle("wal." + segNum)).getFile();
|
|
229
|
+
const u8 = new Uint8Array(await f.arrayBuffer());
|
|
230
|
+
let off = 0;
|
|
231
|
+
while (off < u8.length) {
|
|
232
|
+
const p = parseRecord(u8, off);
|
|
233
|
+
if (shouldStopReplay(p, gen, stopBefore, replayed)) break;
|
|
234
|
+
await core2.applyRecord(p.rec);
|
|
235
|
+
gen = p.rec.gen;
|
|
236
|
+
replayed.push(p.rec);
|
|
237
|
+
const m = p.rec.meta;
|
|
238
|
+
core2.audit({ gen, opcode: p.rec.opcode, actor: m.actor, turnId: m.turnId, path: m.path, from: m.from, to: m.to, cpId: m.cpId });
|
|
239
|
+
off = p.next;
|
|
240
|
+
if (isLastSeg) core2.lastSegValidEnd = off;
|
|
241
|
+
}
|
|
242
|
+
return gen;
|
|
243
|
+
}
|
|
244
|
+
async function replayWalSegments(core2, segs, gen0) {
|
|
245
|
+
let gen = gen0;
|
|
246
|
+
const replayed = [];
|
|
247
|
+
for (let i = 0; i < segs.length; i++) {
|
|
248
|
+
const stopBefore = i + 1 < segs.length ? segs[i + 1] : Infinity;
|
|
249
|
+
gen = await replaySegment(core2, segs[i], stopBefore, gen, replayed, segs[i] === core2.lastSegStart);
|
|
250
|
+
}
|
|
251
|
+
return { finalGen: gen, replayed };
|
|
252
|
+
}
|
|
253
|
+
async function recover(core2) {
|
|
254
|
+
core2.mirror.clear();
|
|
255
|
+
core2.checkpoints.clear();
|
|
256
|
+
core2.opIds.clear();
|
|
257
|
+
core2.auditLog = [];
|
|
258
|
+
core2.turn = null;
|
|
259
|
+
const sb = await loadSuperblockSlot(core2);
|
|
260
|
+
core2.epoch = sb.epoch;
|
|
261
|
+
core2.compactGen = sb.compactGen;
|
|
262
|
+
core2.walStartGen = sb.walStartGen;
|
|
263
|
+
core2.manifestCrc = sb.manifestCrc;
|
|
264
|
+
let gen = await loadManifestState(core2, core2.compactGen);
|
|
265
|
+
const segs = await listWalSegmentNumbers(core2);
|
|
266
|
+
core2.lastSegStart = segs.length ? segs[segs.length - 1] : core2.walStartGen;
|
|
267
|
+
core2.lastSegValidEnd = 0;
|
|
268
|
+
const { finalGen, replayed } = await replayWalSegments(core2, segs, gen);
|
|
269
|
+
gen = finalGen;
|
|
270
|
+
core2.appendedGen = core2.walGen = core2.memGen = core2.ackGen = gen;
|
|
271
|
+
core2.trimCheckpoints();
|
|
272
|
+
for (const r of replayed.slice(-OPID_WINDOW)) {
|
|
273
|
+
if (r.meta.opId) core2.rememberOpId(r.meta.opId, { gen: r.gen });
|
|
274
|
+
}
|
|
275
|
+
if (!segs.length) {
|
|
276
|
+
await core2.root.getFileHandle("wal." + core2.walStartGen, { create: true });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
async function applyRecord(core2, r) {
|
|
280
|
+
const m = r.meta;
|
|
281
|
+
switch (r.opcode) {
|
|
282
|
+
case OP.WRITE: {
|
|
283
|
+
const content = m.payload?.inline !== void 0 ? m.payload.inline : await core2.readBlob(m.payload.h);
|
|
284
|
+
core2.mirror.set(m.path, { content, rev: r.gen });
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case OP.RM:
|
|
288
|
+
core2.mirror.delete(m.path);
|
|
289
|
+
break;
|
|
290
|
+
case OP.MV: {
|
|
291
|
+
const e = core2.mirror.get(m.from);
|
|
292
|
+
if (e) {
|
|
293
|
+
core2.mirror.delete(m.from);
|
|
294
|
+
core2.mirror.set(m.to, { content: e.content, rev: r.gen });
|
|
295
|
+
}
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
case OP.MKDIR:
|
|
299
|
+
break;
|
|
300
|
+
// 目录隐式;记录仅为审计
|
|
301
|
+
case OP.CHECKPOINT:
|
|
302
|
+
core2.checkpoints.set(m.cpId, { h: m.h, gen: r.gen });
|
|
303
|
+
break;
|
|
304
|
+
case OP.RESTORE: {
|
|
305
|
+
const map = JSON.parse(await core2.readBlob(m.h));
|
|
306
|
+
const next = /* @__PURE__ */ new Map();
|
|
307
|
+
for (const [path, h] of Object.entries(map)) next.set(path, { content: await core2.readBlob(h), rev: r.gen });
|
|
308
|
+
core2.mirror = next;
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function ensureBlob(core2, content) {
|
|
314
|
+
const bytes = enc.encode(content);
|
|
315
|
+
const h = await sha256hex(bytes);
|
|
316
|
+
const d2 = await (await core2.root.getDirectoryHandle("blobs", { create: true })).getDirectoryHandle(h.slice(0, 2), { create: true });
|
|
317
|
+
try {
|
|
318
|
+
await d2.getFileHandle(h);
|
|
319
|
+
return h;
|
|
320
|
+
} catch {
|
|
321
|
+
}
|
|
322
|
+
const fh = await d2.getFileHandle(h, { create: true });
|
|
323
|
+
const sh = await fh.createSyncAccessHandle();
|
|
324
|
+
sh.write(bytes);
|
|
325
|
+
sh.flush();
|
|
326
|
+
sh.close();
|
|
327
|
+
return h;
|
|
328
|
+
}
|
|
329
|
+
async function readBlob(core2, h) {
|
|
330
|
+
const f = await (await (await (await core2.root.getDirectoryHandle("blobs")).getDirectoryHandle(h.slice(0, 2))).getFileHandle(h)).getFile();
|
|
331
|
+
return f.text();
|
|
332
|
+
}
|
|
333
|
+
function opRead(core2, { path }) {
|
|
334
|
+
const norm = normalizePath(path);
|
|
335
|
+
const e = norm && core2.mirror.get(norm);
|
|
336
|
+
if (!e) throw rpcErr("not-found", String(path));
|
|
337
|
+
return { content: e.content, rev: e.rev, gen: core2.memGen };
|
|
338
|
+
}
|
|
339
|
+
function opLs(core2) {
|
|
340
|
+
return { paths: [...core2.mirror.keys()].sort(), gen: core2.memGen };
|
|
341
|
+
}
|
|
342
|
+
function opStatus(core2) {
|
|
343
|
+
const { mode, appendedGen, walGen, memGen, ackGen, compactGen, epoch, walStartGen } = core2;
|
|
344
|
+
return {
|
|
345
|
+
mode,
|
|
346
|
+
appendedGen,
|
|
347
|
+
walGen,
|
|
348
|
+
memGen,
|
|
349
|
+
ackGen,
|
|
350
|
+
compactGen,
|
|
351
|
+
epoch,
|
|
352
|
+
walStartGen,
|
|
353
|
+
checkpoints: [...core2.checkpoints.keys()],
|
|
354
|
+
turn: core2.turn ? { turnId: core2.turn.turnId, cpId: core2.turn.cpId, expiresAt: core2.turn.expiresAt, ops: core2.turn.ops } : null
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function pushDiff(core2, diff, gen) {
|
|
358
|
+
if (!core2.queryPort) return;
|
|
359
|
+
const wire = {};
|
|
360
|
+
for (const [p, ent] of Object.entries(diff)) wire[p] = ent ? { content: ent.content, rev: ent.rev } : null;
|
|
361
|
+
core2.queryPort.postMessage({ gen, diff: wire });
|
|
362
|
+
}
|
|
363
|
+
function pushFullToQuery(core2) {
|
|
364
|
+
if (!core2.queryPort) return;
|
|
365
|
+
const files = {};
|
|
366
|
+
for (const [p, ent] of core2.mirror) files[p] = { content: ent.content, rev: ent.rev };
|
|
367
|
+
core2.queryPort.postMessage({ gen: core2.memGen, full: true, files });
|
|
368
|
+
}
|
|
369
|
+
function event(core2, e) {
|
|
370
|
+
if (core2.clientPort) core2.clientPort.postMessage(e);
|
|
371
|
+
}
|
|
372
|
+
function welcome(core2) {
|
|
373
|
+
core2.event({ type: "WELCOME", epoch: core2.epoch, memGen: core2.memGen, readonly: core2.mode !== "writer", mode: core2.mode });
|
|
374
|
+
}
|
|
375
|
+
async function onBroadcast(core2, msg) {
|
|
376
|
+
if (msg.type === "handover-request" && core2.mode === "writer") {
|
|
377
|
+
core2.mode = "draining";
|
|
378
|
+
await core2.enqueue(async () => {
|
|
379
|
+
core2.flushWindow();
|
|
380
|
+
core2.walHandle.close();
|
|
381
|
+
core2.walHandle = null;
|
|
382
|
+
core2.sbHandle.close();
|
|
383
|
+
core2.sbHandle = null;
|
|
384
|
+
core2.mode = "readonly";
|
|
385
|
+
if (core2.releaseLock) {
|
|
386
|
+
core2.releaseLock();
|
|
387
|
+
core2.releaseLock = null;
|
|
388
|
+
}
|
|
389
|
+
core2.bc.postMessage({ type: "handover-done", gen: core2.memGen });
|
|
390
|
+
core2.event({ evt: "writer-lost", gen: core2.memGen });
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/fs-core-write-ops.ts
|
|
396
|
+
function enqueue(core2, fn) {
|
|
397
|
+
const run = core2.chain.then(fn);
|
|
398
|
+
core2.chain = run.catch(() => {
|
|
399
|
+
});
|
|
400
|
+
return run;
|
|
401
|
+
}
|
|
402
|
+
function appendSync(core2, opcode, meta, checks) {
|
|
403
|
+
if (core2.mode !== "writer") throw rpcErr("readonly", "fs-core is " + core2.mode);
|
|
404
|
+
if (checks) checks();
|
|
405
|
+
if (core2.walOffset > SEGMENT_ROTATE_BYTES) throw rpcErr("rotate-needed", "internal");
|
|
406
|
+
const gen = core2.appendedGen + 1;
|
|
407
|
+
const frame = frameRecord(gen, core2.epoch, opcode, meta);
|
|
408
|
+
const n = core2.walHandle.write(frame, { at: core2.walOffset });
|
|
409
|
+
if (n !== frame.length) throw new Error("WAL partial write");
|
|
410
|
+
core2.walOffset += frame.length;
|
|
411
|
+
core2.appendedGen = gen;
|
|
412
|
+
core2.audit({ gen, opcode, actor: meta.actor, turnId: meta.turnId, path: meta.path, from: meta.from, to: meta.to, cpId: meta.cpId });
|
|
413
|
+
return gen;
|
|
414
|
+
}
|
|
415
|
+
function audit(core2, entry) {
|
|
416
|
+
core2.auditLog.push(entry);
|
|
417
|
+
if (core2.auditLog.length > AUDIT_CAP) core2.auditLog.splice(0, core2.auditLog.length - AUDIT_CAP);
|
|
418
|
+
}
|
|
419
|
+
function checkTurn(core2, actor, turnId, agentToken) {
|
|
420
|
+
if (actor !== "agent") return;
|
|
421
|
+
const t = core2.turn;
|
|
422
|
+
if (!t || t.turnId !== turnId) throw rpcErr("turn-closed", "agent write requires an active turn (got " + turnId + ")");
|
|
423
|
+
if (Date.now() > t.expiresAt) {
|
|
424
|
+
core2.turn = null;
|
|
425
|
+
throw rpcErr("turn-closed", "turn expired: " + turnId);
|
|
426
|
+
}
|
|
427
|
+
if (core2.agentToken !== null && agentToken !== core2.agentToken) {
|
|
428
|
+
throw rpcErr("agent-token-required", "agent write requires a valid agent token");
|
|
429
|
+
}
|
|
430
|
+
if (++t.ops > TURN_MAX_OPS) throw rpcErr("turn-quota", "per-turn op quota exceeded (" + TURN_MAX_OPS + ")");
|
|
431
|
+
}
|
|
432
|
+
function armAgentToken(core2, token) {
|
|
433
|
+
if (typeof token !== "string" || !token) throw rpcErr("bad-args", "agent token must be a non-empty string");
|
|
434
|
+
if (core2.agentToken === null) {
|
|
435
|
+
core2.agentToken = token;
|
|
436
|
+
return { armed: true };
|
|
437
|
+
}
|
|
438
|
+
if (core2.agentToken === token) return { armed: true, idempotent: true };
|
|
439
|
+
throw rpcErr("agent-token-gate-armed", "agent token gate already armed with a different token");
|
|
440
|
+
}
|
|
441
|
+
function checkRestoreConflict(core2, baseGen) {
|
|
442
|
+
const oldestGen = core2.auditLog.length ? core2.auditLog[0].gen : core2.appendedGen + 1;
|
|
443
|
+
if (oldestGen > baseGen + 1) {
|
|
444
|
+
throw rpcErr("restore-conflict", "audit log does not cover baseGen " + baseGen, { humanPaths: [], auditGap: true });
|
|
445
|
+
}
|
|
446
|
+
const humanPaths = [];
|
|
447
|
+
const seen = /* @__PURE__ */ new Set();
|
|
448
|
+
for (const e of core2.auditLog) {
|
|
449
|
+
if (e.gen <= baseGen || e.actor !== "human" || !WRITE_OPCODES.has(e.opcode)) continue;
|
|
450
|
+
const p = e.opcode === OP.MV ? e.to || e.from : e.opcode === OP.RESTORE ? "(restore:" + e.cpId + ")" : e.path;
|
|
451
|
+
if (p && !seen.has(p)) {
|
|
452
|
+
seen.add(p);
|
|
453
|
+
humanPaths.push(p);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (humanPaths.length) throw rpcErr("restore-conflict", "human edits since baseGen " + baseGen, { humanPaths });
|
|
457
|
+
}
|
|
458
|
+
function curOf(core2, path) {
|
|
459
|
+
return core2.staged.has(path) ? core2.staged.get(path) ?? null : core2.mirror.get(path) || null;
|
|
460
|
+
}
|
|
461
|
+
function checkWrite(core2, path, ifMatch, _actor) {
|
|
462
|
+
const norm = normalizePath(path);
|
|
463
|
+
if (!norm) throw rpcErr("bad-path", "invalid path: " + path);
|
|
464
|
+
for (const p of DERIVED_PREFIXES) if (norm.startsWith(p)) throw rpcErr("derived-readonly", norm + " is derived area");
|
|
465
|
+
if (core2.mode === "draining") throw rpcErr("draining", "writer is handing over");
|
|
466
|
+
const cur = core2.curOf(norm);
|
|
467
|
+
if (ifMatch === null && cur) throw rpcErr("cas-mismatch", norm + " already exists");
|
|
468
|
+
if (typeof ifMatch === "number" && (!cur || cur.rev !== ifMatch)) {
|
|
469
|
+
throw rpcErr("cas-mismatch", norm + " rev=" + (cur ? cur.rev : "none") + " ifMatch=" + ifMatch);
|
|
470
|
+
}
|
|
471
|
+
return norm;
|
|
472
|
+
}
|
|
473
|
+
async function rotateIfNeeded(core2) {
|
|
474
|
+
if (core2.walOffset <= SEGMENT_ROTATE_BYTES) return;
|
|
475
|
+
await core2.compactNow();
|
|
476
|
+
}
|
|
477
|
+
async function newSegment(core2, startGen) {
|
|
478
|
+
const name = "wal." + startGen;
|
|
479
|
+
const fh = await core2.root.getFileHandle(name + ".tmp", { create: true });
|
|
480
|
+
const sh = await fh.createSyncAccessHandle();
|
|
481
|
+
sh.flush();
|
|
482
|
+
sh.close();
|
|
483
|
+
if (fh.move) await fh.move(name);
|
|
484
|
+
else {
|
|
485
|
+
await core2.root.getFileHandle(name, { create: true });
|
|
486
|
+
try {
|
|
487
|
+
await core2.root.removeEntry(name + ".tmp");
|
|
488
|
+
} catch {
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
core2.walHandle = await (await core2.root.getFileHandle(name)).createSyncAccessHandle();
|
|
492
|
+
core2.walOffset = 0;
|
|
493
|
+
}
|
|
494
|
+
function flushWindow(core2) {
|
|
495
|
+
if (core2.flushTimer) {
|
|
496
|
+
clearTimeout(core2.flushTimer);
|
|
497
|
+
core2.flushTimer = null;
|
|
498
|
+
}
|
|
499
|
+
if (!core2.windowOps.length) return;
|
|
500
|
+
core2.walHandle.flush();
|
|
501
|
+
core2.walGen = core2.appendedGen;
|
|
502
|
+
const diff = {};
|
|
503
|
+
for (const [path, ent] of core2.staged) {
|
|
504
|
+
if (ent === null) core2.mirror.delete(path);
|
|
505
|
+
else core2.mirror.set(path, ent);
|
|
506
|
+
diff[path] = ent;
|
|
507
|
+
}
|
|
508
|
+
core2.staged.clear();
|
|
509
|
+
core2.memGen = core2.walGen;
|
|
510
|
+
core2.pushDiff(diff, core2.memGen);
|
|
511
|
+
const paths = [];
|
|
512
|
+
let actor = "human";
|
|
513
|
+
for (const w of core2.windowOps) {
|
|
514
|
+
core2.ackGen = Math.max(core2.ackGen, w.gen);
|
|
515
|
+
if (w.opId) core2.rememberOpId(w.opId, { gen: w.gen });
|
|
516
|
+
w.respond({ ok: true, result: { gen: w.gen, rev: w.gen, ...w.extra } });
|
|
517
|
+
if (w.path) paths.push(w.path);
|
|
518
|
+
if (w.actor === "agent") actor = "agent";
|
|
519
|
+
}
|
|
520
|
+
core2.windowOps = [];
|
|
521
|
+
core2.event({ evt: "fs-change", gen: core2.memGen, actor, ...paths.length <= 32 ? { paths } : { count: paths.length } });
|
|
522
|
+
core2.bc.postMessage({ type: "fs-change", gen: core2.memGen });
|
|
523
|
+
}
|
|
524
|
+
function rememberOpId(core2, opId, v) {
|
|
525
|
+
core2.opIds.set(opId, v);
|
|
526
|
+
if (core2.opIds.size > OPID_WINDOW) core2.opIds.delete(core2.opIds.keys().next().value);
|
|
527
|
+
}
|
|
528
|
+
function scheduleFlush(core2, immediate) {
|
|
529
|
+
if (immediate) {
|
|
530
|
+
core2.flushWindow();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (!core2.flushTimer) core2.flushTimer = setTimeout(() => core2.enqueue(() => core2.flushWindow()), GROUP_WINDOW_MS);
|
|
534
|
+
}
|
|
535
|
+
async function opWrite(core2, { path, content, ifMatch, actor = "human", turnId, agentToken, opId }, respond) {
|
|
536
|
+
if (typeof content !== "string") throw rpcErr("bad-args", "content must be string");
|
|
537
|
+
await core2.rotateIfNeeded();
|
|
538
|
+
const payload = enc.encode(content).length <= 4096 ? { inline: content } : { h: await core2.ensureBlob(content) };
|
|
539
|
+
const norm = normalizePath(path);
|
|
540
|
+
const meta = { opId, path: norm, actor, turnId, ifMatch, payload };
|
|
541
|
+
const gen = core2.appendSync(OP.WRITE, meta, () => {
|
|
542
|
+
core2.checkTurn(actor, turnId, agentToken);
|
|
543
|
+
core2.checkWrite(path, ifMatch, actor);
|
|
544
|
+
});
|
|
545
|
+
core2.staged.set(norm, { content, rev: gen });
|
|
546
|
+
core2.windowOps.push({ respond, gen, path: norm, actor, opId });
|
|
547
|
+
core2.scheduleFlush(actor === "agent");
|
|
548
|
+
}
|
|
549
|
+
async function opEdit(core2, { path, old, next, ifMatch, actor = "human", turnId, agentToken, opId }, respond) {
|
|
550
|
+
const norm = normalizePath(path);
|
|
551
|
+
const cur = norm && core2.curOf(norm);
|
|
552
|
+
if (!cur) throw rpcErr("not-found", String(path));
|
|
553
|
+
const idx = cur.content.indexOf(old);
|
|
554
|
+
if (idx === -1) throw rpcErr("edit-no-match", "old string not found in " + norm);
|
|
555
|
+
if (cur.content.indexOf(old, idx + 1) !== -1) throw rpcErr("edit-ambiguous", "old string not unique in " + norm);
|
|
556
|
+
const content = cur.content.slice(0, idx) + next + cur.content.slice(idx + old.length);
|
|
557
|
+
return core2.opWrite({ path, content, ifMatch: ifMatch !== void 0 ? ifMatch : cur.rev, actor, turnId, agentToken, opId }, respond);
|
|
558
|
+
}
|
|
559
|
+
async function opRm(core2, { path, actor = "human", turnId, agentToken, opId }, respond) {
|
|
560
|
+
await core2.rotateIfNeeded();
|
|
561
|
+
const gen = core2.appendSync(OP.RM, { opId, path: normalizePath(path), actor, turnId }, () => {
|
|
562
|
+
core2.checkTurn(actor, turnId, agentToken);
|
|
563
|
+
const norm = core2.checkWrite(path, void 0, actor);
|
|
564
|
+
if (!core2.curOf(norm)) throw rpcErr("not-found", norm);
|
|
565
|
+
});
|
|
566
|
+
core2.staged.set(normalizePath(path), null);
|
|
567
|
+
core2.windowOps.push({ respond, gen, path: normalizePath(path), actor, opId });
|
|
568
|
+
core2.scheduleFlush(true);
|
|
569
|
+
}
|
|
570
|
+
async function opMv(core2, { from, to, actor = "human", turnId, agentToken, opId }, respond) {
|
|
571
|
+
await core2.rotateIfNeeded();
|
|
572
|
+
const nf = normalizePath(from);
|
|
573
|
+
const nt = normalizePath(to);
|
|
574
|
+
const gen = core2.appendSync(OP.MV, { opId, from: nf, to: nt, actor, turnId }, () => {
|
|
575
|
+
core2.checkTurn(actor, turnId, agentToken);
|
|
576
|
+
core2.checkWrite(from, void 0, actor);
|
|
577
|
+
core2.checkWrite(to, void 0, actor);
|
|
578
|
+
if (!core2.curOf(nf)) throw rpcErr("not-found", nf);
|
|
579
|
+
if (core2.curOf(nt)) throw rpcErr("cas-mismatch", nt + " exists");
|
|
580
|
+
});
|
|
581
|
+
const e = core2.curOf(nf);
|
|
582
|
+
core2.staged.set(nf, null);
|
|
583
|
+
core2.staged.set(nt, { content: e.content, rev: gen });
|
|
584
|
+
core2.windowOps.push({ respond, gen, path: nt, actor, opId });
|
|
585
|
+
core2.scheduleFlush(true);
|
|
586
|
+
}
|
|
587
|
+
async function makeCheckpoint(core2, { opId, actor, turnId }) {
|
|
588
|
+
core2.flushWindow();
|
|
589
|
+
await core2.rotateIfNeeded();
|
|
590
|
+
const map = {};
|
|
591
|
+
for (const [path, ent] of core2.mirror) map[path] = await core2.ensureBlob(ent.content);
|
|
592
|
+
const h = await core2.ensureBlob(JSON.stringify(map));
|
|
593
|
+
const cpId = "cp-" + (core2.appendedGen + 1);
|
|
594
|
+
const gen = core2.appendSync(OP.CHECKPOINT, { opId, cpId, h, actor, turnId }, () => {
|
|
595
|
+
if (core2.mode === "draining") throw rpcErr("draining", "handing over");
|
|
596
|
+
});
|
|
597
|
+
core2.checkpoints.set(cpId, { h, gen });
|
|
598
|
+
core2.trimCheckpoints();
|
|
599
|
+
return { cpId, gen };
|
|
600
|
+
}
|
|
601
|
+
function trimCheckpoints(core2) {
|
|
602
|
+
while (core2.checkpoints.size > CHECKPOINT_KEEP) {
|
|
603
|
+
core2.checkpoints.delete(core2.checkpoints.keys().next().value);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async function opCheckpoint(core2, { actor = "human", turnId, agentToken, opId }, respond) {
|
|
607
|
+
core2.checkTurn(actor, turnId, agentToken);
|
|
608
|
+
const { cpId, gen } = await core2.makeCheckpoint({ opId, actor, turnId });
|
|
609
|
+
core2.windowOps.push({ respond, gen, actor, opId, extra: { cpId } });
|
|
610
|
+
core2.scheduleFlush(true);
|
|
611
|
+
}
|
|
612
|
+
async function opTurnBegin(core2, { turnId, ttlMs, opId }, respond) {
|
|
613
|
+
if (!turnId || typeof turnId !== "string") throw rpcErr("bad-args", "turnId required");
|
|
614
|
+
if (core2.turn && Date.now() <= core2.turn.expiresAt) {
|
|
615
|
+
throw rpcErr("turn-active", "a turn is already active: " + core2.turn.turnId);
|
|
616
|
+
}
|
|
617
|
+
const { cpId, gen } = await core2.makeCheckpoint({ opId, actor: "agent", turnId });
|
|
618
|
+
const expiresAt = Date.now() + (ttlMs || TURN_DEFAULT_TTL_MS);
|
|
619
|
+
core2.turn = { turnId, cpId, expiresAt, ops: 0 };
|
|
620
|
+
core2.windowOps.push({ respond, gen, actor: "agent", opId, extra: { turnId, cpId, expiresAt } });
|
|
621
|
+
core2.scheduleFlush(true);
|
|
622
|
+
}
|
|
623
|
+
function opTurnEnd(core2, { turnId }, respond) {
|
|
624
|
+
const active = !!(core2.turn && core2.turn.turnId === turnId);
|
|
625
|
+
const ops = active ? core2.turn.ops : 0;
|
|
626
|
+
if (active) core2.turn = null;
|
|
627
|
+
respond({ ok: true, result: { turnId, closed: active, ops } });
|
|
628
|
+
}
|
|
629
|
+
function opDiff(core2, { turnId }) {
|
|
630
|
+
const changes = core2.auditLog.filter((e) => e.turnId === turnId && e.opcode !== OP.CHECKPOINT).map((e) => ({ gen: e.gen, op: OP_NAME[e.opcode], path: e.path, from: e.from, to: e.to }));
|
|
631
|
+
const cpId = core2.turn && core2.turn.turnId === turnId ? core2.turn.cpId : (core2.auditLog.find((e) => e.turnId === turnId && e.opcode === OP.CHECKPOINT) || {}).cpId;
|
|
632
|
+
return { turnId, changes, cpId, auditWindow: { cap: AUDIT_CAP, sinceGen: core2.auditLog.length ? core2.auditLog[0].gen : core2.memGen } };
|
|
633
|
+
}
|
|
634
|
+
async function opRestore(core2, { cpId, baseGen, force = false, actor = "human", turnId, agentToken, opId }, respond) {
|
|
635
|
+
core2.flushWindow();
|
|
636
|
+
await core2.rotateIfNeeded();
|
|
637
|
+
const entry = core2.checkpoints.get(cpId);
|
|
638
|
+
if (!entry) throw rpcErr("not-found", "checkpoint " + cpId);
|
|
639
|
+
const effectiveBaseGen = typeof baseGen === "number" ? baseGen : entry.gen;
|
|
640
|
+
const map = JSON.parse(await core2.readBlob(entry.h));
|
|
641
|
+
const files = {};
|
|
642
|
+
for (const [path, bh] of Object.entries(map)) files[path] = await core2.readBlob(bh);
|
|
643
|
+
const gen = core2.appendSync(OP.RESTORE, { opId, cpId, h: entry.h, actor, turnId }, () => {
|
|
644
|
+
core2.checkTurn(actor, turnId, agentToken);
|
|
645
|
+
if (core2.mode === "draining") throw rpcErr("draining", "handing over");
|
|
646
|
+
if (!force) core2.checkRestoreConflict(effectiveBaseGen);
|
|
647
|
+
});
|
|
648
|
+
core2.walHandle.flush();
|
|
649
|
+
core2.walGen = gen;
|
|
650
|
+
const next = /* @__PURE__ */ new Map();
|
|
651
|
+
for (const [path, content] of Object.entries(files)) next.set(path, { content, rev: gen });
|
|
652
|
+
core2.mirror = next;
|
|
653
|
+
core2.memGen = core2.walGen;
|
|
654
|
+
core2.ackGen = gen;
|
|
655
|
+
if (opId) core2.rememberOpId(opId, { gen });
|
|
656
|
+
core2.pushFullToQuery();
|
|
657
|
+
respond({ ok: true, result: { gen, restored: Object.keys(files).length } });
|
|
658
|
+
core2.event({ evt: "fs-change", gen, actor, count: Object.keys(files).length, restore: cpId });
|
|
659
|
+
core2.bc.postMessage({ type: "fs-change", gen });
|
|
660
|
+
}
|
|
661
|
+
async function opCompact(core2, _args, respond) {
|
|
662
|
+
const r = await core2.compactNow();
|
|
663
|
+
respond({ ok: true, result: r });
|
|
664
|
+
}
|
|
665
|
+
async function buildManifestFiles(core2) {
|
|
666
|
+
const files = {};
|
|
667
|
+
for (const [path, ent] of core2.mirror) files[path] = { h: await core2.ensureBlob(ent.content), rev: ent.rev };
|
|
668
|
+
return files;
|
|
669
|
+
}
|
|
670
|
+
async function writeManifestFile(core2, G, manifestBytes) {
|
|
671
|
+
const mDir = await core2.root.getDirectoryHandle("manifests", { create: true });
|
|
672
|
+
const mh = await (await mDir.getFileHandle(G + ".json", { create: true })).createSyncAccessHandle();
|
|
673
|
+
mh.write(manifestBytes);
|
|
674
|
+
mh.flush();
|
|
675
|
+
mh.close();
|
|
676
|
+
return mDir;
|
|
677
|
+
}
|
|
678
|
+
async function listAllSegmentNumbers(core2) {
|
|
679
|
+
const oldSegs = [];
|
|
680
|
+
for await (const [name] of core2.root.entries()) {
|
|
681
|
+
const m = /^wal\.(\d+)$/.exec(name);
|
|
682
|
+
if (m) oldSegs.push(+m[1]);
|
|
683
|
+
}
|
|
684
|
+
return oldSegs;
|
|
685
|
+
}
|
|
686
|
+
async function removeOldSegments(core2, oldSegs) {
|
|
687
|
+
for (const s of oldSegs) {
|
|
688
|
+
try {
|
|
689
|
+
await core2.root.removeEntry("wal." + s);
|
|
690
|
+
} catch {
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
async function computeReferencedBlobs(core2, files) {
|
|
695
|
+
const referenced = new Set(Object.values(files).map((f) => f.h));
|
|
696
|
+
for (const entry of core2.checkpoints.values()) {
|
|
697
|
+
referenced.add(entry.h);
|
|
698
|
+
try {
|
|
699
|
+
for (const bh of Object.values(JSON.parse(await core2.readBlob(entry.h)))) referenced.add(bh);
|
|
700
|
+
} catch {
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return referenced;
|
|
704
|
+
}
|
|
705
|
+
async function gcUnreferencedBlobs(core2, referenced) {
|
|
706
|
+
try {
|
|
707
|
+
const blobs = await core2.root.getDirectoryHandle("blobs");
|
|
708
|
+
for await (const [d2name, d2] of blobs.entries()) {
|
|
709
|
+
void d2name;
|
|
710
|
+
if (d2.kind !== "directory") continue;
|
|
711
|
+
for await (const [name] of d2.entries()) {
|
|
712
|
+
if (!referenced.has(name)) {
|
|
713
|
+
try {
|
|
714
|
+
await d2.removeEntry(name);
|
|
715
|
+
} catch {
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
} catch {
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
async function gcOldManifests(core2, mDir, keepName) {
|
|
724
|
+
try {
|
|
725
|
+
for await (const [name] of mDir.entries()) {
|
|
726
|
+
if (name !== keepName) {
|
|
727
|
+
try {
|
|
728
|
+
await mDir.removeEntry(name);
|
|
729
|
+
} catch {
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
} catch {
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
async function compactNow(core2) {
|
|
737
|
+
core2.flushWindow();
|
|
738
|
+
const G = core2.memGen;
|
|
739
|
+
if (G === core2.compactGen) return { gen: G, skipped: true };
|
|
740
|
+
const files = await buildManifestFiles(core2);
|
|
741
|
+
const checkpoints = Object.fromEntries(core2.checkpoints);
|
|
742
|
+
const manifestBytes = enc.encode(JSON.stringify({ gen: G, epoch: core2.epoch, files, checkpoints }));
|
|
743
|
+
const mDir = await writeManifestFile(core2, G, manifestBytes);
|
|
744
|
+
const oldSegs = await listAllSegmentNumbers(core2);
|
|
745
|
+
core2.walHandle.close();
|
|
746
|
+
await core2.newSegment(G + 1);
|
|
747
|
+
core2.lastSegStart = G + 1;
|
|
748
|
+
core2.manifestCrc = crc32(manifestBytes);
|
|
749
|
+
core2.compactGen = G;
|
|
750
|
+
core2.walStartGen = G + 1;
|
|
751
|
+
core2.writeSuperblock();
|
|
752
|
+
await removeOldSegments(core2, oldSegs);
|
|
753
|
+
const referenced = await computeReferencedBlobs(core2, files);
|
|
754
|
+
await gcUnreferencedBlobs(core2, referenced);
|
|
755
|
+
await gcOldManifests(core2, mDir, G + ".json");
|
|
756
|
+
return { gen: G };
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// src/fs-core.worker.ts
|
|
760
|
+
var FsCore = class {
|
|
761
|
+
mode = "starting";
|
|
762
|
+
mirror = /* @__PURE__ */ new Map();
|
|
763
|
+
checkpoints = /* @__PURE__ */ new Map();
|
|
764
|
+
opIds = /* @__PURE__ */ new Map();
|
|
765
|
+
appendedGen = 0;
|
|
766
|
+
walGen = 0;
|
|
767
|
+
memGen = 0;
|
|
768
|
+
ackGen = 0;
|
|
769
|
+
compactGen = 0;
|
|
770
|
+
epoch = 0;
|
|
771
|
+
walStartGen = 1;
|
|
772
|
+
staged = /* @__PURE__ */ new Map();
|
|
773
|
+
windowOps = [];
|
|
774
|
+
flushTimer = null;
|
|
775
|
+
chain = Promise.resolve();
|
|
776
|
+
clientPort = null;
|
|
777
|
+
queryPort = null;
|
|
778
|
+
releaseLock = null;
|
|
779
|
+
walHandle = null;
|
|
780
|
+
walOffset = 0;
|
|
781
|
+
sbHandle = null;
|
|
782
|
+
currentSlot = 0;
|
|
783
|
+
turn = null;
|
|
784
|
+
// {turnId, cpId, expiresAt, ops} —— 内存态:worker 重启即失效(安全默认)
|
|
785
|
+
auditLog = [];
|
|
786
|
+
// 环形
|
|
787
|
+
// W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):只有内核持有的
|
|
788
|
+
// 随机令牌,一次性置位(armAgentToken)。null = 未 arm(门不生效,checkTurn 不额外校验)——
|
|
789
|
+
// 保证不起内核的裸 fs 场景(fs 域单测/工具,如 test:fs-smoke/test:fs-wal 直连 client)零回归。
|
|
790
|
+
// 同 worker 重启即失效(内存态,不落 WAL/持久层),与 this.turn 同一安全默认。
|
|
791
|
+
agentToken = null;
|
|
792
|
+
projectId;
|
|
793
|
+
root;
|
|
794
|
+
bc;
|
|
795
|
+
lastSegStart;
|
|
796
|
+
lastSegValidEnd;
|
|
797
|
+
manifestCrc;
|
|
798
|
+
// ── 启动/恢复/只读查询(fs-core-recovery.ts) ──
|
|
799
|
+
start(projectId) {
|
|
800
|
+
return start(this, projectId);
|
|
801
|
+
}
|
|
802
|
+
becomeWriter() {
|
|
803
|
+
return becomeWriter(this);
|
|
804
|
+
}
|
|
805
|
+
writeSuperblock() {
|
|
806
|
+
writeSuperblock(this);
|
|
807
|
+
}
|
|
808
|
+
recover() {
|
|
809
|
+
return recover(this);
|
|
810
|
+
}
|
|
811
|
+
applyRecord(r) {
|
|
812
|
+
return applyRecord(this, r);
|
|
813
|
+
}
|
|
814
|
+
epochFloor(replayed) {
|
|
815
|
+
return epochFloor(replayed);
|
|
816
|
+
}
|
|
817
|
+
ensureBlob(content) {
|
|
818
|
+
return ensureBlob(this, content);
|
|
819
|
+
}
|
|
820
|
+
readBlob(h) {
|
|
821
|
+
return readBlob(this, h);
|
|
822
|
+
}
|
|
823
|
+
opRead(args) {
|
|
824
|
+
return opRead(this, args);
|
|
825
|
+
}
|
|
826
|
+
opLs() {
|
|
827
|
+
return opLs(this);
|
|
828
|
+
}
|
|
829
|
+
opStatus() {
|
|
830
|
+
return opStatus(this);
|
|
831
|
+
}
|
|
832
|
+
pushDiff(diff, gen) {
|
|
833
|
+
pushDiff(this, diff, gen);
|
|
834
|
+
}
|
|
835
|
+
pushFullToQuery() {
|
|
836
|
+
pushFullToQuery(this);
|
|
837
|
+
}
|
|
838
|
+
event(e) {
|
|
839
|
+
event(this, e);
|
|
840
|
+
}
|
|
841
|
+
welcome() {
|
|
842
|
+
welcome(this);
|
|
843
|
+
}
|
|
844
|
+
onBroadcast(msg) {
|
|
845
|
+
return onBroadcast(this, msg);
|
|
846
|
+
}
|
|
847
|
+
// ── 写路径/compaction(fs-core-write-ops.ts) ──
|
|
848
|
+
enqueue(fn) {
|
|
849
|
+
return enqueue(this, fn);
|
|
850
|
+
}
|
|
851
|
+
appendSync(opcode, meta, checks) {
|
|
852
|
+
return appendSync(this, opcode, meta, checks);
|
|
853
|
+
}
|
|
854
|
+
audit(entry) {
|
|
855
|
+
audit(this, entry);
|
|
856
|
+
}
|
|
857
|
+
checkTurn(actor, turnId, agentToken) {
|
|
858
|
+
checkTurn(this, actor, turnId, agentToken);
|
|
859
|
+
}
|
|
860
|
+
armAgentToken(token) {
|
|
861
|
+
return armAgentToken(this, token);
|
|
862
|
+
}
|
|
863
|
+
checkRestoreConflict(baseGen) {
|
|
864
|
+
checkRestoreConflict(this, baseGen);
|
|
865
|
+
}
|
|
866
|
+
curOf(path) {
|
|
867
|
+
return curOf(this, path);
|
|
868
|
+
}
|
|
869
|
+
checkWrite(path, ifMatch, actor) {
|
|
870
|
+
return checkWrite(this, path, ifMatch, actor);
|
|
871
|
+
}
|
|
872
|
+
rotateIfNeeded() {
|
|
873
|
+
return rotateIfNeeded(this);
|
|
874
|
+
}
|
|
875
|
+
newSegment(startGen) {
|
|
876
|
+
return newSegment(this, startGen);
|
|
877
|
+
}
|
|
878
|
+
flushWindow() {
|
|
879
|
+
flushWindow(this);
|
|
880
|
+
}
|
|
881
|
+
rememberOpId(opId, v) {
|
|
882
|
+
rememberOpId(this, opId, v);
|
|
883
|
+
}
|
|
884
|
+
scheduleFlush(immediate) {
|
|
885
|
+
scheduleFlush(this, immediate);
|
|
886
|
+
}
|
|
887
|
+
opWrite(args, respond) {
|
|
888
|
+
return opWrite(this, args, respond);
|
|
889
|
+
}
|
|
890
|
+
opEdit(args, respond) {
|
|
891
|
+
return opEdit(this, args, respond);
|
|
892
|
+
}
|
|
893
|
+
opRm(args, respond) {
|
|
894
|
+
return opRm(this, args, respond);
|
|
895
|
+
}
|
|
896
|
+
opMv(args, respond) {
|
|
897
|
+
return opMv(this, args, respond);
|
|
898
|
+
}
|
|
899
|
+
makeCheckpoint(args) {
|
|
900
|
+
return makeCheckpoint(this, args);
|
|
901
|
+
}
|
|
902
|
+
trimCheckpoints() {
|
|
903
|
+
trimCheckpoints(this);
|
|
904
|
+
}
|
|
905
|
+
opCheckpoint(args, respond) {
|
|
906
|
+
return opCheckpoint(this, args, respond);
|
|
907
|
+
}
|
|
908
|
+
opTurnBegin(args, respond) {
|
|
909
|
+
return opTurnBegin(this, args, respond);
|
|
910
|
+
}
|
|
911
|
+
opTurnEnd(args, respond) {
|
|
912
|
+
opTurnEnd(this, args, respond);
|
|
913
|
+
}
|
|
914
|
+
opDiff(args) {
|
|
915
|
+
return opDiff(this, args);
|
|
916
|
+
}
|
|
917
|
+
opRestore(args, respond) {
|
|
918
|
+
return opRestore(this, args, respond);
|
|
919
|
+
}
|
|
920
|
+
opCompact(args, respond) {
|
|
921
|
+
return opCompact(this, args, respond);
|
|
922
|
+
}
|
|
923
|
+
compactNow() {
|
|
924
|
+
return compactNow(this);
|
|
925
|
+
}
|
|
926
|
+
handleRpc(msg) {
|
|
927
|
+
const respond = (r) => this.clientPort.postMessage({ id: msg.id, ...r });
|
|
928
|
+
const fail = (e) => respond({ ok: false, code: e.code || "internal", error: e.message || String(e), ...e.extra || {} });
|
|
929
|
+
if (msg.opId && this.opIds.has(msg.opId)) {
|
|
930
|
+
const known = this.opIds.get(msg.opId);
|
|
931
|
+
respond({ ok: true, result: { ...known, rev: known.gen, idempotent: true } });
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const a = { ...msg.args, opId: msg.opId };
|
|
935
|
+
const table = {
|
|
936
|
+
write: () => this.opWrite(a, respond),
|
|
937
|
+
edit: () => this.opEdit(a, respond),
|
|
938
|
+
rm: () => this.opRm(a, respond),
|
|
939
|
+
mv: () => this.opMv(a, respond),
|
|
940
|
+
mkdir: () => {
|
|
941
|
+
respond({ ok: true, result: { gen: this.memGen } });
|
|
942
|
+
},
|
|
943
|
+
// 目录隐式,保留 API
|
|
944
|
+
checkpoint: () => this.opCheckpoint(a, respond),
|
|
945
|
+
restore: () => this.opRestore(a, respond),
|
|
946
|
+
compact: () => this.opCompact(a, respond),
|
|
947
|
+
turnBegin: () => this.opTurnBegin(a, respond),
|
|
948
|
+
turnEnd: () => this.opTurnEnd(a, respond),
|
|
949
|
+
diff: () => respond({ ok: true, result: this.opDiff(a) }),
|
|
950
|
+
read: () => respond({ ok: true, result: this.opRead(a) }),
|
|
951
|
+
ls: () => respond({ ok: true, result: this.opLs() }),
|
|
952
|
+
status: () => respond({ ok: true, result: this.opStatus() }),
|
|
953
|
+
// W4 令牌门铸造(§6 替代方案 A / §8.3):纯内存状态置位,无 I/O、不让出,
|
|
954
|
+
// 不入 WAL 序列化链——同步分支即可(同 read/ls/status),不打扰组提交/恢复逻辑。
|
|
955
|
+
armAgentTokenGate: () => respond({ ok: true, result: this.armAgentToken(a.token) })
|
|
956
|
+
};
|
|
957
|
+
const fn = table[msg.op];
|
|
958
|
+
if (!fn) {
|
|
959
|
+
fail(rpcErr("bad-op", String(msg.op)));
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
if (["write", "edit", "rm", "mv", "checkpoint", "restore", "compact", "turnBegin", "turnEnd"].includes(msg.op)) {
|
|
963
|
+
this.enqueue(async () => {
|
|
964
|
+
try {
|
|
965
|
+
await fn();
|
|
966
|
+
} catch (e) {
|
|
967
|
+
fail(e);
|
|
968
|
+
}
|
|
969
|
+
});
|
|
970
|
+
} else {
|
|
971
|
+
try {
|
|
972
|
+
fn();
|
|
973
|
+
} catch (e) {
|
|
974
|
+
fail(e);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
var core = new FsCore();
|
|
980
|
+
self.onmessage = async (e) => {
|
|
981
|
+
const msg = e.data;
|
|
982
|
+
if (msg.type === "HELLO") {
|
|
983
|
+
core.clientPort = self;
|
|
984
|
+
core.queryPort = msg.queryPort || null;
|
|
985
|
+
try {
|
|
986
|
+
await core.start(msg.projectId);
|
|
987
|
+
} catch (err) {
|
|
988
|
+
const stack = err instanceof Error ? err.stack : void 0;
|
|
989
|
+
self.postMessage({ type: "FATAL", error: String(stack || err) });
|
|
990
|
+
}
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
if (msg.type === "PING") {
|
|
994
|
+
self.postMessage({ type: "PONG", t: msg.t });
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (msg.id !== void 0) core.handleRpc(msg);
|
|
998
|
+
};
|
|
999
|
+
export {
|
|
1000
|
+
FsCore
|
|
1001
|
+
};
|