@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,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs-query worker — ProjectFS 只读镜像。
|
|
3
|
+
* fs-core 经专用 MessagePort 推 {gen, diff|full};本 worker 承接 snapshot/grep/glob/大读,
|
|
4
|
+
* 查询永不进入写路径(fs-core 的事件循环不被大 grep 阻塞)。镜像无持久状态,可随时重建。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
interface MirrorEntry {
|
|
8
|
+
content: string
|
|
9
|
+
rev?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface QueryOpError extends Error {
|
|
13
|
+
code?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface SyncMsg {
|
|
17
|
+
gen: number
|
|
18
|
+
full?: boolean
|
|
19
|
+
files?: Record<string, MirrorEntry>
|
|
20
|
+
diff?: Record<string, MirrorEntry | null>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Waiter {
|
|
24
|
+
gen: number
|
|
25
|
+
resolve: () => void
|
|
26
|
+
timer: ReturnType<typeof setTimeout>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const state: {
|
|
30
|
+
mirror: Map<string, MirrorEntry>
|
|
31
|
+
gen: number
|
|
32
|
+
waiters: Waiter[]
|
|
33
|
+
} = {
|
|
34
|
+
mirror: new Map(), // path -> {content, rev}
|
|
35
|
+
gen: -1, // -1 = 尚未收到 core 的首次同步
|
|
36
|
+
waiters: [], // [{gen, resolve, timer}]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function applySync(msg: SyncMsg): void {
|
|
40
|
+
if (msg.full) {
|
|
41
|
+
state.mirror.clear()
|
|
42
|
+
for (const [p, ent] of Object.entries(msg.files || {})) state.mirror.set(p, ent)
|
|
43
|
+
} else {
|
|
44
|
+
for (const [p, ent] of Object.entries(msg.diff || {})) {
|
|
45
|
+
if (ent === null) state.mirror.delete(p)
|
|
46
|
+
else state.mirror.set(p, ent)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
state.gen = msg.gen
|
|
50
|
+
state.waiters = state.waiters.filter((w) => {
|
|
51
|
+
if (state.gen >= w.gen) { clearTimeout(w.timer); w.resolve(); return false }
|
|
52
|
+
return true
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 等镜像追平 gen;超时则按当前 gen 返回(结果带 stale 标记)。 */
|
|
57
|
+
function whenGen(gen: number | undefined): Promise<void> {
|
|
58
|
+
if (gen === undefined || state.gen >= gen) return Promise.resolve()
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
const w: Waiter = { gen, resolve, timer: setTimeout(() => { state.waiters = state.waiters.filter((x) => x !== w); resolve() }, 5000) }
|
|
61
|
+
state.waiters.push(w)
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const GLOB_SPECIAL_RE = /[.+^${}()|[\]\\]/
|
|
66
|
+
|
|
67
|
+
/** Translates the glob token starting at `pattern[i]`; returns the regex
|
|
68
|
+
* fragment plus how many EXTRA characters (beyond the one at `i`) it consumed,
|
|
69
|
+
* so the caller's loop index can skip over a consumed double-star (with or
|
|
70
|
+
* without a trailing slash). */
|
|
71
|
+
function translateGlobToken(pattern: string, i: number): { token: string; skip: number } {
|
|
72
|
+
const c = pattern[i]
|
|
73
|
+
if (c === '*') {
|
|
74
|
+
if (pattern[i + 1] === '*') {
|
|
75
|
+
const slash = pattern[i + 2] === '/'
|
|
76
|
+
return { token: slash ? '(?:.*/)?' : '.*', skip: slash ? 2 : 1 }
|
|
77
|
+
}
|
|
78
|
+
return { token: '[^/]*', skip: 0 }
|
|
79
|
+
}
|
|
80
|
+
if (c === '?') return { token: '[^/]', skip: 0 }
|
|
81
|
+
const escaped = GLOB_SPECIAL_RE.test(c!) ? '\\' + c : c!
|
|
82
|
+
return { token: escaped, skip: 0 }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function globToRegExp(pattern: string): RegExp {
|
|
86
|
+
let re = ''
|
|
87
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
88
|
+
const { token, skip } = translateGlobToken(pattern, i)
|
|
89
|
+
re += token
|
|
90
|
+
i += skip
|
|
91
|
+
}
|
|
92
|
+
return new RegExp('^' + re + '$')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ops = {
|
|
96
|
+
async snapshot({ gen }: { gen?: number }) {
|
|
97
|
+
await whenGen(gen)
|
|
98
|
+
const files: Record<string, string> = {}
|
|
99
|
+
for (const [p, ent] of state.mirror) files[p] = ent.content
|
|
100
|
+
return { files, gen: state.gen, stale: gen !== undefined && state.gen < gen }
|
|
101
|
+
},
|
|
102
|
+
async read({ path, gen }: { path: string; gen?: number }) {
|
|
103
|
+
await whenGen(gen)
|
|
104
|
+
const e = state.mirror.get(path)
|
|
105
|
+
if (!e) throw Object.assign(new Error(path), { code: 'not-found' })
|
|
106
|
+
return { content: e.content, rev: e.rev, gen: state.gen }
|
|
107
|
+
},
|
|
108
|
+
async glob({ pattern, gen }: { pattern: string; gen?: number }) {
|
|
109
|
+
await whenGen(gen)
|
|
110
|
+
const re = globToRegExp(pattern)
|
|
111
|
+
return { paths: [...state.mirror.keys()].filter((p) => re.test(p)).sort(), gen: state.gen }
|
|
112
|
+
},
|
|
113
|
+
async grep({ pattern, flags = '', glob, gen, limit = 200 }: { pattern: string; flags?: string; glob?: string; gen?: number; limit?: number }) {
|
|
114
|
+
await whenGen(gen)
|
|
115
|
+
const re = new RegExp(pattern, flags.replace('g', ''))
|
|
116
|
+
const scope = glob ? globToRegExp(glob) : null
|
|
117
|
+
const hits: Array<{ path: string; lineNo: number; line: string }> = []
|
|
118
|
+
for (const [path, ent] of state.mirror) {
|
|
119
|
+
if (scope && !scope.test(path)) continue
|
|
120
|
+
const lines = ent.content.split('\n')
|
|
121
|
+
for (let i = 0; i < lines.length; i++) {
|
|
122
|
+
if (re.test(lines[i]!)) {
|
|
123
|
+
hits.push({ path, lineNo: i + 1, line: lines[i]!.slice(0, 500) })
|
|
124
|
+
if (hits.length >= limit) return { hits, gen: state.gen, truncated: true }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { hits, gen: state.gen, truncated: false }
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
self.onmessage = async (e: MessageEvent) => {
|
|
133
|
+
const msg = e.data
|
|
134
|
+
if (msg.type === 'init') {
|
|
135
|
+
msg.corePort.onmessage = (ev: MessageEvent) => applySync(ev.data)
|
|
136
|
+
self.postMessage({ type: 'ready' })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (msg.id === undefined) return
|
|
140
|
+
try {
|
|
141
|
+
// ops's members are declared via method shorthand with their own distinct
|
|
142
|
+
// arg shapes; dispatching by dynamic string key needs one widening cast —
|
|
143
|
+
// through `unknown` since the concrete shapes don't structurally overlap
|
|
144
|
+
// with a single dynamic-args signature.
|
|
145
|
+
const dispatch = ops as unknown as Record<string, (args: Record<string, unknown>) => Promise<unknown>>
|
|
146
|
+
const result = await dispatch[msg.op]!(msg.args || {})
|
|
147
|
+
self.postMessage({ id: msg.id, ok: true, result })
|
|
148
|
+
} catch (err) {
|
|
149
|
+
const e = err as QueryOpError
|
|
150
|
+
self.postMessage({ id: msg.id, ok: false, code: e.code || 'internal', error: e.message || String(err) })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
/** Guards that none of the four public entry points touch a browser-only
|
|
4
|
+
* global (navigator.storage, Worker, showDirectoryPicker, ...) merely by
|
|
5
|
+
* being imported — those APIs are only allowed to be touched when their
|
|
6
|
+
* exported functions/classes are actually invoked. This suite runs under
|
|
7
|
+
* plain Node with no jsdom globals, so any import-time access would throw. */
|
|
8
|
+
describe('public entry points import cleanly under plain Node', () => {
|
|
9
|
+
it('imports client.js and exposes the ProjectFsClient class with its static/instance API', async () => {
|
|
10
|
+
const mod = await import('./client.js')
|
|
11
|
+
expect(typeof mod.ProjectFsClient).toBe('function')
|
|
12
|
+
expect(typeof mod.ProjectFsClient.connect).toBe('function')
|
|
13
|
+
expect(typeof mod.ProjectFsClient.wipe).toBe('function')
|
|
14
|
+
expect(typeof mod.ProjectFsClient.prototype.write).toBe('function')
|
|
15
|
+
expect(typeof mod.ProjectFsClient.prototype.read).toBe('function')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('imports agent-tools.js and exposes createAgentTools as a function', async () => {
|
|
19
|
+
const mod = await import('./agent-tools.js')
|
|
20
|
+
expect(typeof mod.createAgentTools).toBe('function')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('imports disk-mirror.js and exposes createDiskMirror as a function', async () => {
|
|
24
|
+
const mod = await import('./disk-mirror.js')
|
|
25
|
+
expect(typeof mod.createDiskMirror).toBe('function')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('imports zip.js and exposes makeZip as a function', async () => {
|
|
29
|
+
const mod = await import('./zip.js')
|
|
30
|
+
expect(typeof mod.makeZip).toBe('function')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('does not touch navigator.storage, Worker, or showDirectoryPicker in this Node environment', () => {
|
|
34
|
+
// Node itself defines a minimal `navigator` global (userAgent info only);
|
|
35
|
+
// it has no `storage` property, unlike a real browser's OPFS-capable one.
|
|
36
|
+
expect((globalThis.navigator as { storage?: unknown } | undefined)?.storage).toBeUndefined()
|
|
37
|
+
expect(typeof (globalThis as { Worker?: unknown }).Worker).toBe('undefined')
|
|
38
|
+
expect(typeof (globalThis as { showDirectoryPicker?: unknown }).showDirectoryPicker).toBe('undefined')
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { epochFloor, rpcErr } from './engine-shared.js'
|
|
3
|
+
import type { WalRecord } from './wal-codec.js'
|
|
4
|
+
|
|
5
|
+
describe('rpcErr', () => {
|
|
6
|
+
it('builds an Error carrying a code', () => {
|
|
7
|
+
const e = rpcErr('not-found', 'missing thing')
|
|
8
|
+
expect(e).toBeInstanceOf(Error)
|
|
9
|
+
expect(e.message).toBe('missing thing')
|
|
10
|
+
expect(e.code).toBe('not-found')
|
|
11
|
+
expect(e.extra).toBeUndefined()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('attaches optional extra fields', () => {
|
|
15
|
+
const e = rpcErr('restore-conflict', 'boom', { humanPaths: ['a.txt'] })
|
|
16
|
+
expect(e.extra).toEqual({ humanPaths: ['a.txt'] })
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('epochFloor', () => {
|
|
21
|
+
const rec = (epoch: number): WalRecord => ({ gen: 1, epoch, opcode: 1, meta: {} })
|
|
22
|
+
|
|
23
|
+
it('returns 0 for an empty replay list (epoch floor before anything replayed)', () => {
|
|
24
|
+
expect(epochFloor([])).toBe(0)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('returns the last replayed record\'s epoch', () => {
|
|
28
|
+
expect(epochFloor([rec(0), rec(2), rec(2)])).toBe(2)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fs-core 引擎共享定义(lib 中立)—— opcode 语义、调优常量、内存态记录形状、
|
|
3
|
+
* RPC 错误构造。被 fs-core.worker.ts 及其拆出的 fs-core-recovery.ts /
|
|
4
|
+
* fs-core-write-ops.ts 三方共用;纯类型/常量声明,不含任何 DOM/WebWorker
|
|
5
|
+
* 专属全局引用,可被主 tsconfig(DOM lib)与 tsconfig.worker.json
|
|
6
|
+
* (WebWorker lib)两个 program 同时编译。
|
|
7
|
+
*/
|
|
8
|
+
import type { WalRecord } from './wal-codec.js'
|
|
9
|
+
|
|
10
|
+
export const OP = { WRITE: 1, RM: 2, MV: 3, MKDIR: 4, CHECKPOINT: 5, RESTORE: 6 } as const
|
|
11
|
+
export const OP_NAME: Record<number, string> = { 1: 'write', 2: 'rm', 3: 'mv', 4: 'mkdir', 5: 'checkpoint', 6: 'restore' }
|
|
12
|
+
// §4.7 restore 冲突检查只关心"写类"操作(改变文件内容/存在性),checkpoint 本身不算
|
|
13
|
+
export const WRITE_OPCODES = new Set<number>([OP.WRITE, OP.RM, OP.MV, OP.RESTORE])
|
|
14
|
+
export const INLINE_MAX = 4096 // payload ≤4KB 内联进 WAL 记录
|
|
15
|
+
export const GROUP_WINDOW_MS = 50 // 人类写组提交窗口
|
|
16
|
+
export const SEGMENT_ROTATE_BYTES = 4 * 1024 * 1024
|
|
17
|
+
export const OPID_WINDOW = 1024
|
|
18
|
+
// P4 turn 能力:agent 写必须在有效 turn 内(fs-core 侧执法,不信任调用方透传)
|
|
19
|
+
export const TURN_DEFAULT_TTL_MS = 120000
|
|
20
|
+
export const TURN_MAX_OPS = 1000 // per-turn 限额(跑飞的 agent 刹车)
|
|
21
|
+
export const AUDIT_CAP = 4096 // 内存审计环(fs_diff 的数据源;重启由 WAL 回放重建)
|
|
22
|
+
// P5 checkpoint LRU:保留最近 N 个;被淘汰者的 blob 在下次 compaction GC 回收
|
|
23
|
+
export const CHECKPOINT_KEEP = 20
|
|
24
|
+
|
|
25
|
+
export interface MirrorEntry {
|
|
26
|
+
content: string
|
|
27
|
+
rev: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface AuditEntry {
|
|
31
|
+
gen: number
|
|
32
|
+
opcode: number
|
|
33
|
+
actor?: string
|
|
34
|
+
turnId?: string
|
|
35
|
+
path?: string
|
|
36
|
+
from?: string
|
|
37
|
+
to?: string
|
|
38
|
+
cpId?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface TurnState {
|
|
42
|
+
turnId: string
|
|
43
|
+
cpId: string
|
|
44
|
+
expiresAt: number
|
|
45
|
+
ops: number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface WindowOp {
|
|
49
|
+
respond: (r: Record<string, unknown>) => void
|
|
50
|
+
gen: number
|
|
51
|
+
path?: string
|
|
52
|
+
actor?: string
|
|
53
|
+
opId?: string
|
|
54
|
+
extra?: Record<string, unknown>
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Respond = (r: Record<string, unknown>) => void
|
|
58
|
+
|
|
59
|
+
export interface WorkerError extends Error {
|
|
60
|
+
code?: string
|
|
61
|
+
extra?: Record<string, unknown>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function rpcErr(code: string, message: string, extra?: Record<string, unknown>): WorkerError {
|
|
65
|
+
const e = new Error(message) as WorkerError
|
|
66
|
+
e.code = code
|
|
67
|
+
if (extra) e.extra = extra
|
|
68
|
+
return e
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 供 fs-core-recovery.ts 的回放循环使用;纯函数(无副作用)。 */
|
|
72
|
+
export function epochFloor(replayed: WalRecord[]): number {
|
|
73
|
+
return replayed.length ? replayed[replayed.length - 1]!.epoch : 0 // epoch 单调不减
|
|
74
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { DERIVED_PREFIXES, normalizePath } from './paths.js'
|
|
3
|
+
|
|
4
|
+
describe('normalizePath', () => {
|
|
5
|
+
it('strips a leading "./" and collapses internal "."/empty segments', () => {
|
|
6
|
+
expect(normalizePath('./a/./b')).toBe('a/b')
|
|
7
|
+
expect(normalizePath('a//b')).toBe('a/b')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('rejects absolute paths', () => {
|
|
11
|
+
expect(normalizePath('/a/b')).toBeNull()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('rejects paths that escape via ".."', () => {
|
|
15
|
+
expect(normalizePath('a/../../b')).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('rejects non-string, empty, NUL, and backslash input', () => {
|
|
19
|
+
expect(normalizePath(undefined)).toBeNull()
|
|
20
|
+
expect(normalizePath(42)).toBeNull()
|
|
21
|
+
expect(normalizePath('')).toBeNull()
|
|
22
|
+
expect(normalizePath('a\0b')).toBeNull()
|
|
23
|
+
expect(normalizePath('a\\b')).toBeNull()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns null for a path that normalizes to nothing (all "." segments)', () => {
|
|
27
|
+
expect(normalizePath('./.')).toBeNull()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('leaves an already-clean relative path untouched', () => {
|
|
31
|
+
expect(normalizePath('a/b/c.txt')).toBe('a/b/c.txt')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('DERIVED_PREFIXES', () => {
|
|
36
|
+
it('lists the derived-area prefixes checkWrite rejects', () => {
|
|
37
|
+
expect(DERIVED_PREFIXES).toEqual(['node_modules/', '.checkpoints/'])
|
|
38
|
+
})
|
|
39
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** 路径监狱(纯函数,lib 中立)—— fs-core.worker.ts 的写路径统一走这里做归一化/校验。 */
|
|
2
|
+
export const DERIVED_PREFIXES = ['node_modules/', '.checkpoints/']
|
|
3
|
+
|
|
4
|
+
export function normalizePath(p: unknown): string | null {
|
|
5
|
+
if (typeof p !== 'string' || !p || p.includes('\0') || p.includes('\\')) return null
|
|
6
|
+
if (p.startsWith('/')) return null
|
|
7
|
+
const parts: string[] = []
|
|
8
|
+
for (const seg of p.split('/')) {
|
|
9
|
+
if (seg === '' || seg === '.') continue
|
|
10
|
+
if (seg === '..') return null
|
|
11
|
+
parts.push(seg)
|
|
12
|
+
}
|
|
13
|
+
return parts.length ? parts.join('/') : null
|
|
14
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Per-op RPC argument shapes (lib-neutral type aliases only) —— handleRpc's
|
|
2
|
+
* dispatch table narrows the incoming `Record<string, unknown>` bag to one of
|
|
3
|
+
* these via a single `as` per op, matching each op method's own destructured
|
|
4
|
+
* parameter type below. */
|
|
5
|
+
export interface WriteArgs {
|
|
6
|
+
path: string
|
|
7
|
+
content: unknown
|
|
8
|
+
ifMatch?: number | null
|
|
9
|
+
actor?: string
|
|
10
|
+
turnId?: string
|
|
11
|
+
agentToken?: string
|
|
12
|
+
opId?: string
|
|
13
|
+
}
|
|
14
|
+
export interface EditArgs {
|
|
15
|
+
path: string
|
|
16
|
+
old: string
|
|
17
|
+
next: string
|
|
18
|
+
ifMatch?: number
|
|
19
|
+
actor?: string
|
|
20
|
+
turnId?: string
|
|
21
|
+
agentToken?: string
|
|
22
|
+
opId?: string
|
|
23
|
+
}
|
|
24
|
+
export interface RmArgs {
|
|
25
|
+
path: string
|
|
26
|
+
actor?: string
|
|
27
|
+
turnId?: string
|
|
28
|
+
agentToken?: string
|
|
29
|
+
opId?: string
|
|
30
|
+
}
|
|
31
|
+
export interface MvArgs {
|
|
32
|
+
from: string
|
|
33
|
+
to: string
|
|
34
|
+
actor?: string
|
|
35
|
+
turnId?: string
|
|
36
|
+
agentToken?: string
|
|
37
|
+
opId?: string
|
|
38
|
+
}
|
|
39
|
+
export interface CheckpointArgs {
|
|
40
|
+
actor?: string
|
|
41
|
+
turnId?: string
|
|
42
|
+
agentToken?: string
|
|
43
|
+
opId?: string
|
|
44
|
+
}
|
|
45
|
+
export interface RestoreArgs {
|
|
46
|
+
cpId: string
|
|
47
|
+
baseGen?: number
|
|
48
|
+
force?: boolean
|
|
49
|
+
actor?: string
|
|
50
|
+
turnId?: string
|
|
51
|
+
agentToken?: string
|
|
52
|
+
opId?: string
|
|
53
|
+
}
|
|
54
|
+
export interface TurnBeginArgs {
|
|
55
|
+
turnId: string
|
|
56
|
+
ttlMs?: number
|
|
57
|
+
opId?: string
|
|
58
|
+
}
|
|
59
|
+
export interface TurnEndArgs {
|
|
60
|
+
turnId: string
|
|
61
|
+
}
|
|
62
|
+
export interface ReadArgs {
|
|
63
|
+
path: string
|
|
64
|
+
}
|
|
65
|
+
export interface DiffArgs {
|
|
66
|
+
turnId?: string
|
|
67
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { crc32, decodeSlot, encodeSlot, frameRecord, parseRecord, sha256hex } from './wal-codec.js'
|
|
3
|
+
|
|
4
|
+
describe('crc32', () => {
|
|
5
|
+
it('matches the well-known CRC-32 of "123456789" (0xCBF43926)', () => {
|
|
6
|
+
const bytes = new TextEncoder().encode('123456789')
|
|
7
|
+
expect(crc32(bytes)).toBe(0xcbf43926)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns 0 for an empty input', () => {
|
|
11
|
+
expect(crc32(new Uint8Array(0))).toBe(0)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('honors the start/end window', () => {
|
|
15
|
+
const bytes = new TextEncoder().encode('xx123456789yy')
|
|
16
|
+
expect(crc32(bytes, 2, 11)).toBe(0xcbf43926)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('sha256hex', () => {
|
|
21
|
+
it('matches the well-known SHA-256 of the empty string', async () => {
|
|
22
|
+
const hex = await sha256hex(new Uint8Array(0))
|
|
23
|
+
expect(hex).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('superblock slot encode/decode', () => {
|
|
28
|
+
it('round-trips a slot', () => {
|
|
29
|
+
const slot = { epoch: 7, compactGen: 42, walStartGen: 43, manifestCrc: 0xdeadbeef }
|
|
30
|
+
const decoded = decodeSlot(encodeSlot(slot))
|
|
31
|
+
expect(decoded).toEqual(slot)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('rejects a corrupted slot (bad CRC)', () => {
|
|
35
|
+
const bytes = encodeSlot({ epoch: 1, compactGen: 2, walStartGen: 3, manifestCrc: 4 })
|
|
36
|
+
bytes[0] = bytes[0]! ^ 0xff // flip a byte inside the magic — CRC no longer matches
|
|
37
|
+
expect(decodeSlot(bytes)).toBeNull()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('rejects a too-short buffer', () => {
|
|
41
|
+
expect(decodeSlot(new Uint8Array(10))).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('WAL record frame/parse', () => {
|
|
46
|
+
it('round-trips a record through frameRecord → parseRecord', () => {
|
|
47
|
+
const meta = { path: 'a.txt', actor: 'human', payload: { inline: 'hi' } }
|
|
48
|
+
const frame = frameRecord(5, 1, 1, meta)
|
|
49
|
+
const parsed = parseRecord(frame, 0)
|
|
50
|
+
expect(parsed).not.toBeNull()
|
|
51
|
+
expect(parsed!.rec).toEqual({ gen: 5, epoch: 1, opcode: 1, meta })
|
|
52
|
+
expect(parsed!.next).toBe(frame.length)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('parses back-to-back records at sequential offsets', () => {
|
|
56
|
+
const a = frameRecord(1, 0, 1, { path: 'a' })
|
|
57
|
+
const b = frameRecord(2, 0, 2, { path: 'a' })
|
|
58
|
+
const buf = new Uint8Array(a.length + b.length)
|
|
59
|
+
buf.set(a, 0); buf.set(b, a.length)
|
|
60
|
+
const first = parseRecord(buf, 0)!
|
|
61
|
+
expect(first.rec.gen).toBe(1)
|
|
62
|
+
const second = parseRecord(buf, first.next)!
|
|
63
|
+
expect(second.rec.gen).toBe(2)
|
|
64
|
+
expect(second.next).toBe(buf.length)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns null on a truncated buffer', () => {
|
|
68
|
+
const frame = frameRecord(1, 0, 1, { path: 'a' })
|
|
69
|
+
expect(parseRecord(frame.subarray(0, frame.length - 1), 0)).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns null when the trailing CRC is corrupted', () => {
|
|
73
|
+
const frame = frameRecord(1, 0, 1, { path: 'a' })
|
|
74
|
+
frame[frame.length - 2] = frame[frame.length - 2]! ^ 0xff
|
|
75
|
+
expect(parseRecord(frame, 0)).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('returns null when the commit byte is wrong', () => {
|
|
79
|
+
const frame = frameRecord(1, 0, 1, { path: 'a' })
|
|
80
|
+
frame[frame.length - 1] = 0x00
|
|
81
|
+
expect(parseRecord(frame, 0)).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAL 编解码(纯函数,lib 中立)—— crc32、superblock 槽、WAL 记录成帧/解析。
|
|
3
|
+
* 从 fs-core.worker.ts 抽出:不含任何 OPFS/Worker 专属 API,可被主 tsconfig
|
|
4
|
+
* (DOM lib)与 tsconfig.worker.json(WebWorker lib)两个 program 同时编译,
|
|
5
|
+
* 因此 zip.ts(主 program 侧)与 fs-core.worker.ts(worker program 侧)
|
|
6
|
+
* 都能 import 同一份 crc32/CRC_TABLE 实现(消除重复代码)。
|
|
7
|
+
*
|
|
8
|
+
* WAL 记录成帧:
|
|
9
|
+
* [u32 len][u64 gen][u32 epoch][u8 opcode][u16 metaLen][meta JSON][u32 crc32][u8 0xC1]
|
|
10
|
+
* len = len 字段之后的字节数;crc 覆盖 gen..meta 末尾。
|
|
11
|
+
*/
|
|
12
|
+
export const enc = new TextEncoder()
|
|
13
|
+
export const dec = new TextDecoder()
|
|
14
|
+
|
|
15
|
+
// ───────────────────────── crc32(查表法) ─────────────────────────
|
|
16
|
+
export const CRC_TABLE = (() => {
|
|
17
|
+
const t = new Uint32Array(256)
|
|
18
|
+
for (let n = 0; n < 256; n++) {
|
|
19
|
+
let c = n
|
|
20
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
|
|
21
|
+
t[n] = c >>> 0
|
|
22
|
+
}
|
|
23
|
+
return t
|
|
24
|
+
})()
|
|
25
|
+
export function crc32(bytes: Uint8Array, start = 0, end: number = bytes.length): number {
|
|
26
|
+
let c = 0xffffffff
|
|
27
|
+
for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8)
|
|
28
|
+
return (c ^ 0xffffffff) >>> 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function sha256hex(bytes: Uint8Array): Promise<string> {
|
|
32
|
+
const d = await crypto.subtle.digest('SHA-256', bytes as BufferSource)
|
|
33
|
+
return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SlotInfo {
|
|
37
|
+
epoch: number
|
|
38
|
+
compactGen: number
|
|
39
|
+
walStartGen: number
|
|
40
|
+
manifestCrc: number
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SB_MAGIC = 0x44574331 // 'DWC1'
|
|
44
|
+
export const SLOT_SIZE = 64
|
|
45
|
+
|
|
46
|
+
// ───────────────────────── superblock 槽编解码 ─────────────────────────
|
|
47
|
+
// 布局:magic u32 | epoch u32 | compactGen f64 | walStartGen f64 | manifestCrc u32 | pad→60 | crc32 u32
|
|
48
|
+
export function encodeSlot(s: SlotInfo): Uint8Array {
|
|
49
|
+
const buf = new ArrayBuffer(SLOT_SIZE)
|
|
50
|
+
const dv = new DataView(buf)
|
|
51
|
+
dv.setUint32(0, SB_MAGIC)
|
|
52
|
+
dv.setUint32(4, s.epoch)
|
|
53
|
+
dv.setFloat64(8, s.compactGen)
|
|
54
|
+
dv.setFloat64(16, s.walStartGen)
|
|
55
|
+
dv.setUint32(24, s.manifestCrc)
|
|
56
|
+
dv.setUint32(60, crc32(new Uint8Array(buf, 0, 60)))
|
|
57
|
+
return new Uint8Array(buf)
|
|
58
|
+
}
|
|
59
|
+
export function decodeSlot(bytes: Uint8Array): SlotInfo | null {
|
|
60
|
+
if (bytes.length < SLOT_SIZE) return null
|
|
61
|
+
const dv = new DataView(bytes.buffer, bytes.byteOffset, SLOT_SIZE)
|
|
62
|
+
if (dv.getUint32(60) !== crc32(bytes, 0, 60)) return null
|
|
63
|
+
if (dv.getUint32(0) !== SB_MAGIC) return null
|
|
64
|
+
return {
|
|
65
|
+
epoch: dv.getUint32(4),
|
|
66
|
+
compactGen: dv.getFloat64(8),
|
|
67
|
+
walStartGen: dv.getFloat64(16),
|
|
68
|
+
manifestCrc: dv.getUint32(24),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** WAL 记录的 meta 载荷形状 —— 各 opcode 只填自己用到的字段(落 WAL 的持久
|
|
73
|
+
* 线格式,字段名/结构不可随意变更,见 fs-core.worker.ts 里对应 op* 方法)。 */
|
|
74
|
+
export interface WalMeta {
|
|
75
|
+
opId?: string
|
|
76
|
+
path?: string
|
|
77
|
+
from?: string
|
|
78
|
+
to?: string
|
|
79
|
+
actor?: string
|
|
80
|
+
turnId?: string
|
|
81
|
+
ifMatch?: number | null
|
|
82
|
+
payload?: { inline?: string; h?: string }
|
|
83
|
+
cpId?: string
|
|
84
|
+
h?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WalRecord {
|
|
88
|
+
gen: number
|
|
89
|
+
epoch: number
|
|
90
|
+
opcode: number
|
|
91
|
+
meta: WalMeta
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ───────────────────────── WAL 记录编解码 ─────────────────────────
|
|
95
|
+
export function frameRecord(gen: number, epoch: number, opcode: number, meta: unknown): Uint8Array {
|
|
96
|
+
const metaBytes = enc.encode(JSON.stringify(meta))
|
|
97
|
+
const len = 8 + 4 + 1 + 2 + metaBytes.length + 4 + 1
|
|
98
|
+
const buf = new ArrayBuffer(4 + len)
|
|
99
|
+
const dv = new DataView(buf)
|
|
100
|
+
const u8 = new Uint8Array(buf)
|
|
101
|
+
dv.setUint32(0, len)
|
|
102
|
+
dv.setBigUint64(4, BigInt(gen))
|
|
103
|
+
dv.setUint32(12, epoch)
|
|
104
|
+
dv.setUint8(16, opcode)
|
|
105
|
+
dv.setUint16(17, metaBytes.length)
|
|
106
|
+
u8.set(metaBytes, 19)
|
|
107
|
+
const crcEnd = 19 + metaBytes.length
|
|
108
|
+
dv.setUint32(crcEnd, crc32(u8, 4, crcEnd))
|
|
109
|
+
dv.setUint8(crcEnd + 4, 0xc1)
|
|
110
|
+
return u8
|
|
111
|
+
}
|
|
112
|
+
/** 解析一条记录;返回 {rec, next} 或 null(framing/CRC/commit 任一失败)。 */
|
|
113
|
+
export function parseRecord(u8: Uint8Array, off: number): { rec: WalRecord; next: number } | null {
|
|
114
|
+
if (off + 4 > u8.length) return null
|
|
115
|
+
const dv = new DataView(u8.buffer, u8.byteOffset)
|
|
116
|
+
const len = dv.getUint32(off)
|
|
117
|
+
if (len < 20 || off + 4 + len > u8.length) return null
|
|
118
|
+
const gen = Number(dv.getBigUint64(off + 4))
|
|
119
|
+
const epoch = dv.getUint32(off + 12)
|
|
120
|
+
const opcode = dv.getUint8(off + 16)
|
|
121
|
+
const metaLen = dv.getUint16(off + 17)
|
|
122
|
+
const metaStart = off + 19
|
|
123
|
+
const crcAt = metaStart + metaLen
|
|
124
|
+
if (crcAt + 5 !== off + 4 + len) return null
|
|
125
|
+
if (dv.getUint32(crcAt) !== crc32(u8, off + 4, crcAt)) return null
|
|
126
|
+
if (dv.getUint8(crcAt + 4) !== 0xc1) return null
|
|
127
|
+
let meta: WalMeta
|
|
128
|
+
try { meta = JSON.parse(dec.decode(u8.subarray(metaStart, crcAt))) as WalMeta } catch { return null }
|
|
129
|
+
return { rec: { gen, epoch, opcode, meta }, next: off + 4 + len }
|
|
130
|
+
}
|