@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,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs-core 写路径 —— WAL append、turn/checkpoint/restore 执法、compaction。
|
|
3
|
+
* 从 fs-core.worker.ts 抽出(file-length + cognitive-complexity 双重考量)。
|
|
4
|
+
* 每个导出函数的第一个参数 `core` 是宿主 FsCore 实例;`this.` 机械替换为
|
|
5
|
+
* `core.`,行为逐字保留。依赖 OPFS/Worker 专属 API,见 fs-core-recovery.ts
|
|
6
|
+
* 头部同样的说明——不追求 lib 中立,从主 tsconfig.json 里排除。
|
|
7
|
+
*/
|
|
8
|
+
import type { FsCore } from './fs-core.worker.js'
|
|
9
|
+
import { crc32, enc, frameRecord } from './worker-lib/wal-codec.js'
|
|
10
|
+
import { DERIVED_PREFIXES, normalizePath } from './worker-lib/paths.js'
|
|
11
|
+
import {
|
|
12
|
+
AUDIT_CAP, CHECKPOINT_KEEP, GROUP_WINDOW_MS, OP, OP_NAME, OPID_WINDOW, rpcErr, SEGMENT_ROTATE_BYTES,
|
|
13
|
+
TURN_DEFAULT_TTL_MS, TURN_MAX_OPS, WRITE_OPCODES,
|
|
14
|
+
type AuditEntry, type MirrorEntry, type Respond,
|
|
15
|
+
} from './worker-lib/engine-shared.js'
|
|
16
|
+
import type {
|
|
17
|
+
CheckpointArgs, EditArgs, MvArgs, RestoreArgs, RmArgs, TurnBeginArgs, TurnEndArgs, WriteArgs,
|
|
18
|
+
} from './worker-lib/rpc-types.js'
|
|
19
|
+
|
|
20
|
+
// ── 写路径基础设施 ──
|
|
21
|
+
export function enqueue<T>(core: FsCore, fn: () => T | Promise<T>): Promise<T> {
|
|
22
|
+
const run = core.chain.then(fn) as Promise<T>
|
|
23
|
+
core.chain = run.catch(() => {}) // 链条不断;错误在 fn 内部转为 RPC error
|
|
24
|
+
return run
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 校验+成帧+append —— 同一同步块,无让出点(能力/CAS/turn 二次校验就在这里)。 */
|
|
28
|
+
export function appendSync(core: FsCore, opcode: number, meta: Record<string, unknown>, checks?: () => void): number {
|
|
29
|
+
if (core.mode !== 'writer') throw rpcErr('readonly', 'fs-core is ' + core.mode)
|
|
30
|
+
if (checks) checks()
|
|
31
|
+
if (core.walOffset > SEGMENT_ROTATE_BYTES) throw rpcErr('rotate-needed', 'internal') // 上游先 rotate
|
|
32
|
+
const gen = core.appendedGen + 1
|
|
33
|
+
const frame = frameRecord(gen, core.epoch, opcode, meta)
|
|
34
|
+
const n = core.walHandle!.write(frame, { at: core.walOffset })
|
|
35
|
+
if (n !== frame.length) throw new Error('WAL partial write')
|
|
36
|
+
core.walOffset += frame.length
|
|
37
|
+
core.appendedGen = gen
|
|
38
|
+
core.audit({ gen, opcode, actor: meta.actor as string | undefined, turnId: meta.turnId as string | undefined, path: meta.path as string | undefined, from: meta.from as string | undefined, to: meta.to as string | undefined, cpId: meta.cpId as string | undefined })
|
|
39
|
+
return gen
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function audit(core: FsCore, entry: AuditEntry): void {
|
|
43
|
+
core.auditLog.push(entry)
|
|
44
|
+
if (core.auditLog.length > AUDIT_CAP) core.auditLog.splice(0, core.auditLog.length - AUDIT_CAP)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** turn 执法(agent 专属):在 append 前的同一同步块内调用,无让出点,
|
|
48
|
+
* 撤销(turnEnd/过期)与写入之间不存在竞态窗口。human 写不受限。
|
|
49
|
+
* W4 纵深加固(第二道锁,docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):门已
|
|
50
|
+
* arm(core.agentToken !== null)时,即使 turnId 猜对/偷到、turn 仍活跃,也必须携带匹配
|
|
51
|
+
* 的 agentToken,否则拒绝 —— 威胁模型是"B realm 内代码拿到裸 window.__FS_CLIENT 后伪造
|
|
52
|
+
* {actor:'agent', turnId} 直写",令牌只有内核持有(kernel.js 闭包,从不落 window)。
|
|
53
|
+
* 校验顺序刻意放在 turn 有效性判定之后:turnId 完全不匹配/已过期的旧行为('turn-closed')
|
|
54
|
+
* 保持不变(fs 域既有 e2e 的等价断言不回归),令牌门只在"turn 确实活跃且 turnId 匹配"这一步
|
|
55
|
+
* 追加第二道拒绝,精确对应 blocker #6 的伪造场景。 */
|
|
56
|
+
export function checkTurn(core: FsCore, actor: string | undefined, turnId: string | undefined, agentToken: string | undefined): void {
|
|
57
|
+
if (actor !== 'agent') return
|
|
58
|
+
const t = core.turn
|
|
59
|
+
if (!t || t.turnId !== turnId) throw rpcErr('turn-closed', 'agent write requires an active turn (got ' + turnId + ')')
|
|
60
|
+
if (Date.now() > t.expiresAt) { core.turn = null; throw rpcErr('turn-closed', 'turn expired: ' + turnId) }
|
|
61
|
+
if (core.agentToken !== null && agentToken !== core.agentToken) {
|
|
62
|
+
throw rpcErr('agent-token-required', 'agent write requires a valid agent token')
|
|
63
|
+
}
|
|
64
|
+
if (++t.ops > TURN_MAX_OPS) throw rpcErr('turn-quota', 'per-turn op quota exceeded (' + TURN_MAX_OPS + ')')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 令牌门铸造:一次性置位,已置位后收到不同令牌一律拒绝(防篡改,攻击者二次 arm 顶不掉内核
|
|
68
|
+
* 铸造的原令牌);同令牌重放幂等 ok(kernel 重连/重试安全)。错误信息不回显任何令牌值。 */
|
|
69
|
+
export function armAgentToken(core: FsCore, token: unknown): { armed: boolean; idempotent?: boolean } {
|
|
70
|
+
if (typeof token !== 'string' || !token) throw rpcErr('bad-args', 'agent token must be a non-empty string')
|
|
71
|
+
if (core.agentToken === null) { core.agentToken = token; return { armed: true } }
|
|
72
|
+
if (core.agentToken === token) return { armed: true, idempotent: true }
|
|
73
|
+
throw rpcErr('agent-token-gate-armed', 'agent token gate already armed with a different token')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** §4.7 restore 冲突执法:非 force 时在 appendSync 同步块内调用,无让出点。
|
|
77
|
+
* auditLog 是容量 AUDIT_CAP 的环——若其最老条目已晚于 baseGen+1,说明 (baseGen, 最老审计]
|
|
78
|
+
* 区间的历史已被丢弃(compaction 或环覆盖),无法证明期间没有人类写,一律保守拒绝。 */
|
|
79
|
+
export function checkRestoreConflict(core: FsCore, baseGen: number): void {
|
|
80
|
+
const oldestGen = core.auditLog.length ? core.auditLog[0]!.gen : core.appendedGen + 1
|
|
81
|
+
if (oldestGen > baseGen + 1) {
|
|
82
|
+
throw rpcErr('restore-conflict', 'audit log does not cover baseGen ' + baseGen, { humanPaths: [], auditGap: true })
|
|
83
|
+
}
|
|
84
|
+
const humanPaths: string[] = []
|
|
85
|
+
const seen = new Set<string>()
|
|
86
|
+
for (const e of core.auditLog) {
|
|
87
|
+
if (e.gen <= baseGen || e.actor !== 'human' || !WRITE_OPCODES.has(e.opcode)) continue
|
|
88
|
+
const p = e.opcode === OP.MV ? (e.to || e.from) : e.opcode === OP.RESTORE ? '(restore:' + e.cpId + ')' : e.path
|
|
89
|
+
if (p && !seen.has(p)) { seen.add(p); humanPaths.push(p) }
|
|
90
|
+
}
|
|
91
|
+
if (humanPaths.length) throw rpcErr('restore-conflict', 'human edits since baseGen ' + baseGen, { humanPaths })
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function curOf(core: FsCore, path: string): MirrorEntry | null { return core.staged.has(path) ? core.staged.get(path) ?? null : core.mirror.get(path) || null }
|
|
95
|
+
|
|
96
|
+
export function checkWrite(core: FsCore, path: unknown, ifMatch: unknown, _actor: string | undefined): string {
|
|
97
|
+
const norm = normalizePath(path)
|
|
98
|
+
if (!norm) throw rpcErr('bad-path', 'invalid path: ' + path)
|
|
99
|
+
for (const p of DERIVED_PREFIXES) if (norm.startsWith(p)) throw rpcErr('derived-readonly', norm + ' is derived area')
|
|
100
|
+
if (core.mode === 'draining') throw rpcErr('draining', 'writer is handing over')
|
|
101
|
+
const cur = core.curOf(norm)
|
|
102
|
+
if (ifMatch === null && cur) throw rpcErr('cas-mismatch', norm + ' already exists')
|
|
103
|
+
if (typeof ifMatch === 'number' && (!cur || cur.rev !== ifMatch)) {
|
|
104
|
+
throw rpcErr('cas-mismatch', norm + ' rev=' + (cur ? cur.rev : 'none') + ' ifMatch=' + ifMatch)
|
|
105
|
+
}
|
|
106
|
+
return norm
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 段超阈值 → 影子 compaction(物化 manifest + 新段 + superblock 翻转),
|
|
110
|
+
* 而不是裸换段:WAL 长度被真正回收,重放成本有上界(P1 compaction 上线)。 */
|
|
111
|
+
export async function rotateIfNeeded(core: FsCore): Promise<void> {
|
|
112
|
+
if (core.walOffset <= SEGMENT_ROTATE_BYTES) return
|
|
113
|
+
await core.compactNow()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function newSegment(core: FsCore, startGen: number): Promise<void> {
|
|
117
|
+
const name = 'wal.' + startGen
|
|
118
|
+
const fh = await core.root.getFileHandle(name + '.tmp', { create: true })
|
|
119
|
+
const sh = await fh.createSyncAccessHandle()
|
|
120
|
+
sh.flush(); sh.close()
|
|
121
|
+
if (fh.move) await fh.move(name)
|
|
122
|
+
else { await core.root.getFileHandle(name, { create: true }); try { await core.root.removeEntry(name + '.tmp') } catch {} }
|
|
123
|
+
core.walHandle = await (await core.root.getFileHandle(name)).createSyncAccessHandle()
|
|
124
|
+
core.walOffset = 0
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 组提交边界:flush WAL → 应用 staged → 推 diff → ack → 事件。 */
|
|
128
|
+
export function flushWindow(core: FsCore): void {
|
|
129
|
+
if (core.flushTimer) { clearTimeout(core.flushTimer); core.flushTimer = null }
|
|
130
|
+
if (!core.windowOps.length) return
|
|
131
|
+
core.walHandle!.flush()
|
|
132
|
+
core.walGen = core.appendedGen
|
|
133
|
+
const diff: Record<string, MirrorEntry | null> = {}
|
|
134
|
+
for (const [path, ent] of core.staged) {
|
|
135
|
+
if (ent === null) core.mirror.delete(path)
|
|
136
|
+
else core.mirror.set(path, ent)
|
|
137
|
+
diff[path] = ent
|
|
138
|
+
}
|
|
139
|
+
core.staged.clear()
|
|
140
|
+
core.memGen = core.walGen
|
|
141
|
+
core.pushDiff(diff, core.memGen)
|
|
142
|
+
const paths: string[] = []
|
|
143
|
+
let actor = 'human'
|
|
144
|
+
for (const w of core.windowOps) {
|
|
145
|
+
core.ackGen = Math.max(core.ackGen, w.gen)
|
|
146
|
+
if (w.opId) core.rememberOpId(w.opId, { gen: w.gen })
|
|
147
|
+
w.respond({ ok: true, result: { gen: w.gen, rev: w.gen, ...w.extra } })
|
|
148
|
+
if (w.path) paths.push(w.path)
|
|
149
|
+
if (w.actor === 'agent') actor = 'agent'
|
|
150
|
+
}
|
|
151
|
+
core.windowOps = []
|
|
152
|
+
core.event({ evt: 'fs-change', gen: core.memGen, actor, ...(paths.length <= 32 ? { paths } : { count: paths.length }) })
|
|
153
|
+
core.bc.postMessage({ type: 'fs-change', gen: core.memGen })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function rememberOpId(core: FsCore, opId: string, v: { gen: number }): void {
|
|
157
|
+
core.opIds.set(opId, v)
|
|
158
|
+
if (core.opIds.size > OPID_WINDOW) core.opIds.delete(core.opIds.keys().next().value!)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function scheduleFlush(core: FsCore, immediate?: boolean): void {
|
|
162
|
+
if (immediate) { core.flushWindow(); return }
|
|
163
|
+
if (!core.flushTimer) core.flushTimer = setTimeout(() => core.enqueue(() => core.flushWindow()), GROUP_WINDOW_MS)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── RPC 实现 ──
|
|
167
|
+
export async function opWrite(core: FsCore, { path, content, ifMatch, actor = 'human', turnId, agentToken, opId }: WriteArgs, respond: Respond): Promise<void> {
|
|
168
|
+
if (typeof content !== 'string') throw rpcErr('bad-args', 'content must be string')
|
|
169
|
+
await core.rotateIfNeeded()
|
|
170
|
+
const payload = enc.encode(content).length <= 4096 ? { inline: content } : { h: await core.ensureBlob(content) }
|
|
171
|
+
const norm = normalizePath(path) // 提前算好;真正校验在 appendSync 同步块内
|
|
172
|
+
// agentToken 只用于 checkTurn 校验,绝不进 meta(meta 落 WAL,持久且经 fs_diff/审计环可读——
|
|
173
|
+
// 令牌绝不可持久化或经任何读路径回显)。
|
|
174
|
+
const meta = { opId, path: norm, actor, turnId, ifMatch, payload }
|
|
175
|
+
const gen = core.appendSync(OP.WRITE, meta, () => { core.checkTurn(actor, turnId, agentToken); core.checkWrite(path, ifMatch, actor) })
|
|
176
|
+
core.staged.set(norm!, { content, rev: gen })
|
|
177
|
+
core.windowOps.push({ respond, gen, path: norm!, actor, opId })
|
|
178
|
+
core.scheduleFlush(actor === 'agent')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export async function opEdit(core: FsCore, { path, old, next, ifMatch, actor = 'human', turnId, agentToken, opId }: EditArgs, respond: Respond): Promise<void> {
|
|
182
|
+
const norm = normalizePath(path)
|
|
183
|
+
const cur = norm && core.curOf(norm)
|
|
184
|
+
if (!cur) throw rpcErr('not-found', String(path))
|
|
185
|
+
const idx = cur.content.indexOf(old)
|
|
186
|
+
if (idx === -1) throw rpcErr('edit-no-match', 'old string not found in ' + norm)
|
|
187
|
+
if (cur.content.indexOf(old, idx + 1) !== -1) throw rpcErr('edit-ambiguous', 'old string not unique in ' + norm)
|
|
188
|
+
const content = cur.content.slice(0, idx) + next + cur.content.slice(idx + old.length)
|
|
189
|
+
return core.opWrite({ path, content, ifMatch: ifMatch !== undefined ? ifMatch : cur.rev, actor, turnId, agentToken, opId }, respond)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function opRm(core: FsCore, { path, actor = 'human', turnId, agentToken, opId }: RmArgs, respond: Respond): Promise<void> {
|
|
193
|
+
await core.rotateIfNeeded()
|
|
194
|
+
const gen = core.appendSync(OP.RM, { opId, path: normalizePath(path), actor, turnId }, () => {
|
|
195
|
+
core.checkTurn(actor, turnId, agentToken)
|
|
196
|
+
const norm = core.checkWrite(path, undefined, actor)
|
|
197
|
+
if (!core.curOf(norm)) throw rpcErr('not-found', norm)
|
|
198
|
+
})
|
|
199
|
+
core.staged.set(normalizePath(path)!, null)
|
|
200
|
+
core.windowOps.push({ respond, gen, path: normalizePath(path)!, actor, opId })
|
|
201
|
+
core.scheduleFlush(true)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function opMv(core: FsCore, { from, to, actor = 'human', turnId, agentToken, opId }: MvArgs, respond: Respond): Promise<void> {
|
|
205
|
+
await core.rotateIfNeeded()
|
|
206
|
+
const nf = normalizePath(from)!; const nt = normalizePath(to)!
|
|
207
|
+
const gen = core.appendSync(OP.MV, { opId, from: nf, to: nt, actor, turnId }, () => {
|
|
208
|
+
core.checkTurn(actor, turnId, agentToken)
|
|
209
|
+
core.checkWrite(from, undefined, actor); core.checkWrite(to, undefined, actor)
|
|
210
|
+
if (!core.curOf(nf)) throw rpcErr('not-found', nf)
|
|
211
|
+
if (core.curOf(nt)) throw rpcErr('cas-mismatch', nt + ' exists')
|
|
212
|
+
})
|
|
213
|
+
const e = core.curOf(nf)!
|
|
214
|
+
core.staged.set(nf, null)
|
|
215
|
+
core.staged.set(nt, { content: e.content, rev: gen })
|
|
216
|
+
core.windowOps.push({ respond, gen, path: nt, actor, opId })
|
|
217
|
+
core.scheduleFlush(true)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** checkpoint 物化(干净边界 + blob 去重近乎免费)。调用方须在 enqueue 串行链内。 */
|
|
221
|
+
export async function makeCheckpoint(core: FsCore, { opId, actor, turnId }: { opId?: string; actor?: string; turnId?: string }): Promise<{ cpId: string; gen: number }> {
|
|
222
|
+
core.flushWindow() // checkpoint 基于干净边界
|
|
223
|
+
await core.rotateIfNeeded()
|
|
224
|
+
const map: Record<string, string> = {}
|
|
225
|
+
for (const [path, ent] of core.mirror) map[path] = await core.ensureBlob(ent.content)
|
|
226
|
+
const h = await core.ensureBlob(JSON.stringify(map))
|
|
227
|
+
const cpId = 'cp-' + (core.appendedGen + 1)
|
|
228
|
+
const gen = core.appendSync(OP.CHECKPOINT, { opId, cpId, h, actor, turnId }, () => {
|
|
229
|
+
if (core.mode === 'draining') throw rpcErr('draining', 'handing over')
|
|
230
|
+
})
|
|
231
|
+
core.checkpoints.set(cpId, { h, gen })
|
|
232
|
+
core.trimCheckpoints()
|
|
233
|
+
return { cpId, gen }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** P5 LRU:Map 按插入序,裁掉最老的(活跃 turn 的锚点必然在最近 N 个内)。 */
|
|
237
|
+
export function trimCheckpoints(core: FsCore): void {
|
|
238
|
+
while (core.checkpoints.size > CHECKPOINT_KEEP) {
|
|
239
|
+
core.checkpoints.delete(core.checkpoints.keys().next().value!)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function opCheckpoint(core: FsCore, { actor = 'human', turnId, agentToken, opId }: CheckpointArgs, respond: Respond): Promise<void> {
|
|
244
|
+
// actor:'agent' 的 checkpoint 同其余写类 op 一样受 turn 执法(否则 turn 外可伪造 agent
|
|
245
|
+
// checkpoint 审计记录)。opTurnBegin 铸造自己的锚点时直调 makeCheckpoint,不经这里。
|
|
246
|
+
core.checkTurn(actor, turnId, agentToken)
|
|
247
|
+
const { cpId, gen } = await core.makeCheckpoint({ opId, actor, turnId })
|
|
248
|
+
core.windowOps.push({ respond, gen, actor, opId, extra: { cpId } })
|
|
249
|
+
core.scheduleFlush(true)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── P4 turn 能力:铸造(checkpoint 锚 + 激活)→ 执法(checkTurn)→ 撤销 ──
|
|
253
|
+
export async function opTurnBegin(core: FsCore, { turnId, ttlMs, opId }: TurnBeginArgs, respond: Respond): Promise<void> {
|
|
254
|
+
if (!turnId || typeof turnId !== 'string') throw rpcErr('bad-args', 'turnId required')
|
|
255
|
+
if (core.turn && Date.now() <= core.turn.expiresAt) {
|
|
256
|
+
throw rpcErr('turn-active', 'a turn is already active: ' + core.turn.turnId)
|
|
257
|
+
}
|
|
258
|
+
// 铸造即锚定回滚点;铸造与激活在同一 enqueue 串行任务内,无并发写插入
|
|
259
|
+
const { cpId, gen } = await core.makeCheckpoint({ opId, actor: 'agent', turnId })
|
|
260
|
+
const expiresAt = Date.now() + (ttlMs || TURN_DEFAULT_TTL_MS)
|
|
261
|
+
core.turn = { turnId, cpId, expiresAt, ops: 0 }
|
|
262
|
+
core.windowOps.push({ respond, gen, actor: 'agent', opId, extra: { turnId, cpId, expiresAt } })
|
|
263
|
+
core.scheduleFlush(true)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function opTurnEnd(core: FsCore, { turnId }: TurnEndArgs, respond: Respond): void {
|
|
267
|
+
const active = !!(core.turn && core.turn.turnId === turnId)
|
|
268
|
+
const ops = active ? core.turn!.ops : 0
|
|
269
|
+
if (active) core.turn = null // 单线程同步置位:撤销即刻生效,无竞态窗口
|
|
270
|
+
respond({ ok: true, result: { turnId, closed: active, ops } })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** fs_diff:内存审计环里该 turn 的全部改动(WAL actor/turnId 标注免费提供)。 */
|
|
274
|
+
export function opDiff(core: FsCore, { turnId }: { turnId?: string }): { turnId: string | undefined; changes: Array<{ gen: number; op: string; path?: string; from?: string; to?: string }>; cpId: string | undefined; auditWindow: { cap: number; sinceGen: number } } {
|
|
275
|
+
const changes = core.auditLog
|
|
276
|
+
.filter((e) => e.turnId === turnId && e.opcode !== OP.CHECKPOINT)
|
|
277
|
+
.map((e) => ({ gen: e.gen, op: OP_NAME[e.opcode]!, path: e.path, from: e.from, to: e.to }))
|
|
278
|
+
const cpId = core.turn && core.turn.turnId === turnId ? core.turn.cpId
|
|
279
|
+
: (core.auditLog.find((e) => e.turnId === turnId && e.opcode === OP.CHECKPOINT) || {}).cpId
|
|
280
|
+
return { turnId, changes, cpId, auditWindow: { cap: AUDIT_CAP, sinceGen: core.auditLog.length ? core.auditLog[0]!.gen : core.memGen } }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** §4.7 restore 冲突策略:payload 支持 {cpId, baseGen?, force?}。baseGen 缺省时
|
|
284
|
+
* 从 checkpoint 自身记录的 gen 推导(checkpoint 创建时已存 {h, gen})。非 force 时在
|
|
285
|
+
* appendSync 同步块内做冲突检查(见 checkRestoreConflict);force:true 全部跳过。
|
|
286
|
+
* turn 执法(checkTurn)不受 force 影响——agent 场景仍必须在活跃 turn 内。 */
|
|
287
|
+
export async function opRestore(core: FsCore, { cpId, baseGen, force = false, actor = 'human', turnId, agentToken, opId }: RestoreArgs, respond: Respond): Promise<void> {
|
|
288
|
+
core.flushWindow()
|
|
289
|
+
await core.rotateIfNeeded()
|
|
290
|
+
const entry = core.checkpoints.get(cpId)
|
|
291
|
+
if (!entry) throw rpcErr('not-found', 'checkpoint ' + cpId)
|
|
292
|
+
const effectiveBaseGen = typeof baseGen === 'number' ? baseGen : entry.gen
|
|
293
|
+
const map = JSON.parse(await core.readBlob(entry.h)) as Record<string, string>
|
|
294
|
+
const files: Record<string, string> = {}
|
|
295
|
+
for (const [path, bh] of Object.entries(map)) files[path] = await core.readBlob(bh)
|
|
296
|
+
const gen = core.appendSync(OP.RESTORE, { opId, cpId, h: entry.h, actor, turnId }, () => {
|
|
297
|
+
core.checkTurn(actor, turnId, agentToken)
|
|
298
|
+
if (core.mode === 'draining') throw rpcErr('draining', 'handing over')
|
|
299
|
+
if (!force) core.checkRestoreConflict(effectiveBaseGen)
|
|
300
|
+
})
|
|
301
|
+
core.walHandle!.flush()
|
|
302
|
+
core.walGen = gen
|
|
303
|
+
const next = new Map<string, MirrorEntry>()
|
|
304
|
+
for (const [path, content] of Object.entries(files)) next.set(path, { content, rev: gen })
|
|
305
|
+
core.mirror = next
|
|
306
|
+
core.memGen = core.walGen
|
|
307
|
+
core.ackGen = gen
|
|
308
|
+
if (opId) core.rememberOpId(opId, { gen })
|
|
309
|
+
core.pushFullToQuery()
|
|
310
|
+
respond({ ok: true, result: { gen, restored: Object.keys(files).length } })
|
|
311
|
+
core.event({ evt: 'fs-change', gen, actor, count: Object.keys(files).length, restore: cpId })
|
|
312
|
+
core.bc.postMessage({ type: 'fs-change', gen })
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// 影子 compaction:新 blob → manifest → 新段(.tmp→rename) → superblock 翻转 → GC
|
|
316
|
+
export async function opCompact(core: FsCore, _args: unknown, respond: Respond): Promise<void> {
|
|
317
|
+
const r = await core.compactNow()
|
|
318
|
+
respond({ ok: true, result: r })
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function buildManifestFiles(core: FsCore): Promise<Record<string, { h: string; rev: number }>> {
|
|
322
|
+
const files: Record<string, { h: string; rev: number }> = {}
|
|
323
|
+
for (const [path, ent] of core.mirror) files[path] = { h: await core.ensureBlob(ent.content), rev: ent.rev }
|
|
324
|
+
return files
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
async function writeManifestFile(core: FsCore, G: number, manifestBytes: Uint8Array): Promise<FileSystemDirectoryHandle> {
|
|
328
|
+
const mDir = await core.root.getDirectoryHandle('manifests', { create: true })
|
|
329
|
+
const mh = await (await mDir.getFileHandle(G + '.json', { create: true })).createSyncAccessHandle()
|
|
330
|
+
mh.write(manifestBytes); mh.flush(); mh.close()
|
|
331
|
+
return mDir
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function listAllSegmentNumbers(core: FsCore): Promise<number[]> {
|
|
335
|
+
const oldSegs: number[] = []
|
|
336
|
+
for await (const [name] of core.root.entries()) {
|
|
337
|
+
const m = /^wal\.(\d+)$/.exec(name)
|
|
338
|
+
if (m) oldSegs.push(+m[1]!)
|
|
339
|
+
}
|
|
340
|
+
return oldSegs
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function removeOldSegments(core: FsCore, oldSegs: number[]): Promise<void> {
|
|
344
|
+
for (const s of oldSegs) { try { await core.root.removeEntry('wal.' + s) } catch {} }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** 引用集合 = manifest files ∪ checkpoint map 及其内部 hash(GC 前置计算)。 */
|
|
348
|
+
async function computeReferencedBlobs(core: FsCore, files: Record<string, { h: string; rev: number }>): Promise<Set<string>> {
|
|
349
|
+
const referenced = new Set<string>(Object.values(files).map((f) => f.h))
|
|
350
|
+
for (const entry of core.checkpoints.values()) {
|
|
351
|
+
referenced.add(entry.h)
|
|
352
|
+
try { for (const bh of Object.values(JSON.parse(await core.readBlob(entry.h)) as Record<string, string>)) referenced.add(bh) } catch {}
|
|
353
|
+
}
|
|
354
|
+
return referenced
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function gcUnreferencedBlobs(core: FsCore, referenced: Set<string>): Promise<void> {
|
|
358
|
+
try {
|
|
359
|
+
const blobs = await core.root.getDirectoryHandle('blobs')
|
|
360
|
+
for await (const [d2name, d2] of blobs.entries()) {
|
|
361
|
+
void d2name
|
|
362
|
+
if (d2.kind !== 'directory') continue
|
|
363
|
+
for await (const [name] of (d2 as FileSystemDirectoryHandle).entries()) {
|
|
364
|
+
if (!referenced.has(name)) { try { await (d2 as FileSystemDirectoryHandle).removeEntry(name) } catch {} }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} catch {}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function gcOldManifests(core: FsCore, mDir: FileSystemDirectoryHandle, keepName: string): Promise<void> {
|
|
371
|
+
try {
|
|
372
|
+
for await (const [name] of mDir.entries()) {
|
|
373
|
+
if (name !== keepName) { try { await mDir.removeEntry(name) } catch {} }
|
|
374
|
+
}
|
|
375
|
+
} catch {}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 必须在 enqueue 串行链内调用(写路径 rotateIfNeeded / 手动 compact RPC / 启动补偿)。 */
|
|
379
|
+
export async function compactNow(core: FsCore): Promise<{ gen: number; skipped?: boolean }> {
|
|
380
|
+
core.flushWindow()
|
|
381
|
+
const G = core.memGen
|
|
382
|
+
if (G === core.compactGen) return { gen: G, skipped: true }
|
|
383
|
+
const files = await buildManifestFiles(core)
|
|
384
|
+
const checkpoints = Object.fromEntries(core.checkpoints)
|
|
385
|
+
const manifestBytes = enc.encode(JSON.stringify({ gen: G, epoch: core.epoch, files, checkpoints }))
|
|
386
|
+
const mDir = await writeManifestFile(core, G, manifestBytes)
|
|
387
|
+
const oldSegs = await listAllSegmentNumbers(core)
|
|
388
|
+
core.walHandle!.close()
|
|
389
|
+
await core.newSegment(G + 1)
|
|
390
|
+
core.lastSegStart = G + 1
|
|
391
|
+
core.manifestCrc = crc32(manifestBytes)
|
|
392
|
+
core.compactGen = G
|
|
393
|
+
core.walStartGen = G + 1
|
|
394
|
+
core.writeSuperblock() // 原子翻转点:此后恢复走新 manifest+新段
|
|
395
|
+
// GC:旧段 + 无引用 blob(引用 = manifest files ∪ checkpoint map 及其内部 hash)
|
|
396
|
+
await removeOldSegments(core, oldSegs)
|
|
397
|
+
const referenced = await computeReferencedBlobs(core, files)
|
|
398
|
+
await gcUnreferencedBlobs(core, referenced)
|
|
399
|
+
// 旧 manifest 清理(保留当前)
|
|
400
|
+
await gcOldManifests(core, mDir, G + '.json')
|
|
401
|
+
return { gen: G }
|
|
402
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs-core worker — ProjectFS 单写者权威(P0,同 origin 形态)。
|
|
3
|
+
*
|
|
4
|
+
* 持久层(OPFS,无物化文件树):
|
|
5
|
+
* <projectId>/blobs/<h2>/<sha256> 内容寻址、不可变、写后 flush
|
|
6
|
+
* <projectId>/manifests/<gen>.json compaction 产物(CRC 记录在 superblock)
|
|
7
|
+
* <projectId>/wal.<startGen> 分段 append-only 日志,禁止原地重写/truncate
|
|
8
|
+
* <projectId>/superblock 双槽定长 64B×2;只写非当前槽,flush 后翻转
|
|
9
|
+
*
|
|
10
|
+
* WAL 记录成帧见 worker-lib/wal-codec.ts。
|
|
11
|
+
* 写序(WAL-first):blob flush → append(+组提交 flush) → 应用镜像 → ack(opId) → 广播。
|
|
12
|
+
* ack 语义:已 ack 必恢复;未 ack 可能恢复 —— opId 幂等消歧。
|
|
13
|
+
*
|
|
14
|
+
* 该类本身只保留字段声明 + 派发(handleRpc)+ 入口(self.onmessage);每个方法体
|
|
15
|
+
* 都委托给 fs-core-recovery.ts(启动/恢复/只读查询)或 fs-core-write-ops.ts
|
|
16
|
+
* (写路径/compaction)里的同名自由函数(第一个参数是本实例),这是纯粹的物理
|
|
17
|
+
* 拆分(file-length),方法名/调用方式对外不变。
|
|
18
|
+
*/
|
|
19
|
+
import * as recovery from './fs-core-recovery.js'
|
|
20
|
+
import * as writeOps from './fs-core-write-ops.js'
|
|
21
|
+
import type { WalRecord } from './worker-lib/wal-codec.js'
|
|
22
|
+
import { rpcErr, type MirrorEntry, type Respond, type TurnState, type WindowOp, type WorkerError } from './worker-lib/engine-shared.js'
|
|
23
|
+
import type {
|
|
24
|
+
CheckpointArgs, DiffArgs, EditArgs, MvArgs, ReadArgs, RestoreArgs, RmArgs, TurnBeginArgs, TurnEndArgs, WriteArgs,
|
|
25
|
+
} from './worker-lib/rpc-types.js'
|
|
26
|
+
|
|
27
|
+
/** FileSystemFileHandle.move() postdates this TS lib version's ambient types
|
|
28
|
+
* (feature-detected at the call site via `if (fh.move) …`, matching the
|
|
29
|
+
* runtime's own defensive check). */
|
|
30
|
+
declare global {
|
|
31
|
+
interface FileSystemFileHandle {
|
|
32
|
+
move?(name: string): Promise<void>
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ───────────────────────── core 主体 ─────────────────────────
|
|
37
|
+
export class FsCore {
|
|
38
|
+
mode: 'starting' | 'writer' | 'readonly' | 'draining' | 'dead' = 'starting'
|
|
39
|
+
mirror = new Map<string, MirrorEntry>()
|
|
40
|
+
checkpoints = new Map<string, { h: string; gen: number }>()
|
|
41
|
+
opIds = new Map<string, { gen: number }>()
|
|
42
|
+
appendedGen = 0
|
|
43
|
+
walGen = 0
|
|
44
|
+
memGen = 0
|
|
45
|
+
ackGen = 0
|
|
46
|
+
compactGen = 0
|
|
47
|
+
epoch = 0
|
|
48
|
+
walStartGen = 1
|
|
49
|
+
staged = new Map<string, MirrorEntry | null>()
|
|
50
|
+
windowOps: WindowOp[] = []
|
|
51
|
+
flushTimer: ReturnType<typeof setTimeout> | null = null
|
|
52
|
+
chain: Promise<unknown> = Promise.resolve()
|
|
53
|
+
clientPort: { postMessage: (msg: unknown) => void } | null = null
|
|
54
|
+
queryPort: MessagePort | null = null
|
|
55
|
+
releaseLock: (() => void) | null = null
|
|
56
|
+
walHandle: FileSystemSyncAccessHandle | null = null
|
|
57
|
+
walOffset = 0
|
|
58
|
+
sbHandle: FileSystemSyncAccessHandle | null = null
|
|
59
|
+
currentSlot = 0
|
|
60
|
+
turn: TurnState | null = null // {turnId, cpId, expiresAt, ops} —— 内存态:worker 重启即失效(安全默认)
|
|
61
|
+
auditLog: Array<{ gen: number; opcode: number; actor?: string; turnId?: string; path?: string; from?: string; to?: string; cpId?: string }> = [] // 环形
|
|
62
|
+
// W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):只有内核持有的
|
|
63
|
+
// 随机令牌,一次性置位(armAgentToken)。null = 未 arm(门不生效,checkTurn 不额外校验)——
|
|
64
|
+
// 保证不起内核的裸 fs 场景(fs 域单测/工具,如 test:fs-smoke/test:fs-wal 直连 client)零回归。
|
|
65
|
+
// 同 worker 重启即失效(内存态,不落 WAL/持久层),与 this.turn 同一安全默认。
|
|
66
|
+
agentToken: string | null = null
|
|
67
|
+
|
|
68
|
+
projectId!: string
|
|
69
|
+
root!: FileSystemDirectoryHandle
|
|
70
|
+
bc!: BroadcastChannel
|
|
71
|
+
lastSegStart!: number
|
|
72
|
+
lastSegValidEnd!: number
|
|
73
|
+
manifestCrc!: number
|
|
74
|
+
|
|
75
|
+
// ── 启动/恢复/只读查询(fs-core-recovery.ts) ──
|
|
76
|
+
start(projectId: string): Promise<void> { return recovery.start(this, projectId) }
|
|
77
|
+
becomeWriter(): Promise<void> { return recovery.becomeWriter(this) }
|
|
78
|
+
writeSuperblock(): void { recovery.writeSuperblock(this) }
|
|
79
|
+
recover(): Promise<void> { return recovery.recover(this) }
|
|
80
|
+
applyRecord(r: WalRecord): Promise<void> { return recovery.applyRecord(this, r) }
|
|
81
|
+
epochFloor(replayed: WalRecord[]): number { return recovery.epochFloor(replayed) }
|
|
82
|
+
ensureBlob(content: string): Promise<string> { return recovery.ensureBlob(this, content) }
|
|
83
|
+
readBlob(h: string): Promise<string> { return recovery.readBlob(this, h) }
|
|
84
|
+
opRead(args: ReadArgs): { content: string; rev: number; gen: number } { return recovery.opRead(this, args) }
|
|
85
|
+
opLs(): { paths: string[]; gen: number } { return recovery.opLs(this) }
|
|
86
|
+
opStatus() { return recovery.opStatus(this) }
|
|
87
|
+
pushDiff(diff: Record<string, MirrorEntry | null>, gen: number): void { recovery.pushDiff(this, diff, gen) }
|
|
88
|
+
pushFullToQuery(): void { recovery.pushFullToQuery(this) }
|
|
89
|
+
event(e: Record<string, unknown>): void { recovery.event(this, e) }
|
|
90
|
+
welcome(): void { recovery.welcome(this) }
|
|
91
|
+
onBroadcast(msg: { type?: string }): Promise<void> { return recovery.onBroadcast(this, msg) }
|
|
92
|
+
|
|
93
|
+
// ── 写路径/compaction(fs-core-write-ops.ts) ──
|
|
94
|
+
enqueue<T>(fn: () => T | Promise<T>): Promise<T> { return writeOps.enqueue(this, fn) }
|
|
95
|
+
appendSync(opcode: number, meta: Record<string, unknown>, checks?: () => void): number { return writeOps.appendSync(this, opcode, meta, checks) }
|
|
96
|
+
audit(entry: { gen: number; opcode: number; actor?: string; turnId?: string; path?: string; from?: string; to?: string; cpId?: string }): void { writeOps.audit(this, entry) }
|
|
97
|
+
checkTurn(actor: string | undefined, turnId: string | undefined, agentToken: string | undefined): void { writeOps.checkTurn(this, actor, turnId, agentToken) }
|
|
98
|
+
armAgentToken(token: unknown): { armed: boolean; idempotent?: boolean } { return writeOps.armAgentToken(this, token) }
|
|
99
|
+
checkRestoreConflict(baseGen: number): void { writeOps.checkRestoreConflict(this, baseGen) }
|
|
100
|
+
curOf(path: string): MirrorEntry | null { return writeOps.curOf(this, path) }
|
|
101
|
+
checkWrite(path: unknown, ifMatch: unknown, actor: string | undefined): string { return writeOps.checkWrite(this, path, ifMatch, actor) }
|
|
102
|
+
rotateIfNeeded(): Promise<void> { return writeOps.rotateIfNeeded(this) }
|
|
103
|
+
newSegment(startGen: number): Promise<void> { return writeOps.newSegment(this, startGen) }
|
|
104
|
+
flushWindow(): void { writeOps.flushWindow(this) }
|
|
105
|
+
rememberOpId(opId: string, v: { gen: number }): void { writeOps.rememberOpId(this, opId, v) }
|
|
106
|
+
scheduleFlush(immediate?: boolean): void { writeOps.scheduleFlush(this, immediate) }
|
|
107
|
+
opWrite(args: WriteArgs, respond: Respond): Promise<void> { return writeOps.opWrite(this, args, respond) }
|
|
108
|
+
opEdit(args: EditArgs, respond: Respond): Promise<void> { return writeOps.opEdit(this, args, respond) }
|
|
109
|
+
opRm(args: RmArgs, respond: Respond): Promise<void> { return writeOps.opRm(this, args, respond) }
|
|
110
|
+
opMv(args: MvArgs, respond: Respond): Promise<void> { return writeOps.opMv(this, args, respond) }
|
|
111
|
+
makeCheckpoint(args: { opId?: string; actor?: string; turnId?: string }): Promise<{ cpId: string; gen: number }> { return writeOps.makeCheckpoint(this, args) }
|
|
112
|
+
trimCheckpoints(): void { writeOps.trimCheckpoints(this) }
|
|
113
|
+
opCheckpoint(args: CheckpointArgs, respond: Respond): Promise<void> { return writeOps.opCheckpoint(this, args, respond) }
|
|
114
|
+
opTurnBegin(args: TurnBeginArgs, respond: Respond): Promise<void> { return writeOps.opTurnBegin(this, args, respond) }
|
|
115
|
+
opTurnEnd(args: TurnEndArgs, respond: Respond): void { writeOps.opTurnEnd(this, args, respond) }
|
|
116
|
+
opDiff(args: DiffArgs): { turnId: string | undefined; changes: Array<{ gen: number; op: string; path?: string; from?: string; to?: string }>; cpId: string | undefined; auditWindow: { cap: number; sinceGen: number } } { return writeOps.opDiff(this, args) }
|
|
117
|
+
opRestore(args: RestoreArgs, respond: Respond): Promise<void> { return writeOps.opRestore(this, args, respond) }
|
|
118
|
+
opCompact(args: unknown, respond: Respond): Promise<void> { return writeOps.opCompact(this, args, respond) }
|
|
119
|
+
compactNow(): Promise<{ gen: number; skipped?: boolean }> { return writeOps.compactNow(this) }
|
|
120
|
+
|
|
121
|
+
handleRpc(msg: { id: number; opId?: string; args?: Record<string, unknown>; op: string }): void {
|
|
122
|
+
const respond: Respond = (r) => this.clientPort!.postMessage({ id: msg.id, ...r })
|
|
123
|
+
const fail = (e: WorkerError) => respond({ ok: false, code: e.code || 'internal', error: e.message || String(e), ...(e.extra || {}) })
|
|
124
|
+
// opId 幂等:已知 opId 直接返回既有结果
|
|
125
|
+
if (msg.opId && this.opIds.has(msg.opId)) {
|
|
126
|
+
const known = this.opIds.get(msg.opId)!
|
|
127
|
+
respond({ ok: true, result: { ...known, rev: known.gen, idempotent: true } })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const a: Record<string, unknown> = { ...msg.args, opId: msg.opId }
|
|
131
|
+
// Each op's real shape is narrower than the dynamic bag above (required fields
|
|
132
|
+
// that msg.args may or may not actually carry) — the RPC layer trusts the wire
|
|
133
|
+
// contract, same as the pre-typed-args code this replaces did via `any`; the
|
|
134
|
+
// double cast through `unknown` documents that this is an intentional widening,
|
|
135
|
+
// not an accidental one.
|
|
136
|
+
const table: Record<string, () => void | Promise<void>> = {
|
|
137
|
+
write: () => this.opWrite(a as unknown as WriteArgs, respond),
|
|
138
|
+
edit: () => this.opEdit(a as unknown as EditArgs, respond),
|
|
139
|
+
rm: () => this.opRm(a as unknown as RmArgs, respond),
|
|
140
|
+
mv: () => this.opMv(a as unknown as MvArgs, respond),
|
|
141
|
+
mkdir: () => { respond({ ok: true, result: { gen: this.memGen } }) }, // 目录隐式,保留 API
|
|
142
|
+
checkpoint: () => this.opCheckpoint(a as unknown as CheckpointArgs, respond),
|
|
143
|
+
restore: () => this.opRestore(a as unknown as RestoreArgs, respond),
|
|
144
|
+
compact: () => this.opCompact(a, respond),
|
|
145
|
+
turnBegin: () => this.opTurnBegin(a as unknown as TurnBeginArgs, respond),
|
|
146
|
+
turnEnd: () => this.opTurnEnd(a as unknown as TurnEndArgs, respond),
|
|
147
|
+
diff: () => respond({ ok: true, result: this.opDiff(a as DiffArgs) }),
|
|
148
|
+
read: () => respond({ ok: true, result: this.opRead(a as unknown as ReadArgs) }),
|
|
149
|
+
ls: () => respond({ ok: true, result: this.opLs() }),
|
|
150
|
+
status: () => respond({ ok: true, result: this.opStatus() }),
|
|
151
|
+
// W4 令牌门铸造(§6 替代方案 A / §8.3):纯内存状态置位,无 I/O、不让出,
|
|
152
|
+
// 不入 WAL 序列化链——同步分支即可(同 read/ls/status),不打扰组提交/恢复逻辑。
|
|
153
|
+
armAgentTokenGate: () => respond({ ok: true, result: this.armAgentToken(a.token) }),
|
|
154
|
+
}
|
|
155
|
+
const fn = table[msg.op]
|
|
156
|
+
if (!fn) { fail(rpcErr('bad-op', String(msg.op))); return }
|
|
157
|
+
if (['write', 'edit', 'rm', 'mv', 'checkpoint', 'restore', 'compact', 'turnBegin', 'turnEnd'].includes(msg.op)) {
|
|
158
|
+
this.enqueue(async () => { try { await fn() } catch (e) { fail(e as WorkerError) } })
|
|
159
|
+
} else {
|
|
160
|
+
try { fn() } catch (e) { fail(e as WorkerError) }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ───────────────────────── 入口 ─────────────────────────
|
|
166
|
+
const core = new FsCore()
|
|
167
|
+
self.onmessage = async (e: MessageEvent) => {
|
|
168
|
+
const msg = e.data
|
|
169
|
+
if (msg.type === 'HELLO') {
|
|
170
|
+
core.clientPort = self // 单客户端 P0:直接用 worker 主端口
|
|
171
|
+
core.queryPort = msg.queryPort || null
|
|
172
|
+
try {
|
|
173
|
+
await core.start(msg.projectId)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
const stack = err instanceof Error ? err.stack : undefined
|
|
176
|
+
self.postMessage({ type: 'FATAL', error: String(stack || err) })
|
|
177
|
+
}
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
if (msg.type === 'PING') { self.postMessage({ type: 'PONG', t: msg.t }); return }
|
|
181
|
+
if (msg.id !== undefined) core.handleRpc(msg)
|
|
182
|
+
}
|