@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/dist/agent-tools.js +52 -0
  4. package/dist/client.js +234 -0
  5. package/dist/disk-mirror.js +97 -0
  6. package/dist/fs-core.worker.js +1001 -0
  7. package/dist/fs-query.worker.js +115 -0
  8. package/dist/sync/sync-engine.js +381 -0
  9. package/dist/sync/truth-port.js +1 -0
  10. package/dist/worker-lib/engine-shared.js +25 -0
  11. package/dist/worker-lib/paths.js +17 -0
  12. package/dist/worker-lib/rpc-types.js +1 -0
  13. package/dist/worker-lib/wal-codec.js +111 -0
  14. package/dist/zip.js +60 -0
  15. package/package.json +75 -0
  16. package/src/__checks__/types-smoke.ts +23 -0
  17. package/src/agent-tools.test.ts +151 -0
  18. package/src/agent-tools.ts +117 -0
  19. package/src/client.test.ts +110 -0
  20. package/src/client.ts +284 -0
  21. package/src/disk-mirror.test.ts +190 -0
  22. package/src/disk-mirror.ts +119 -0
  23. package/src/fs-core-recovery.ts +280 -0
  24. package/src/fs-core-write-ops.ts +402 -0
  25. package/src/fs-core.worker.ts +182 -0
  26. package/src/fs-query.worker.ts +152 -0
  27. package/src/import-smoke.test.ts +40 -0
  28. package/src/worker-lib/engine-shared.test.ts +30 -0
  29. package/src/worker-lib/engine-shared.ts +74 -0
  30. package/src/worker-lib/paths.test.ts +39 -0
  31. package/src/worker-lib/paths.ts +14 -0
  32. package/src/worker-lib/rpc-types.ts +67 -0
  33. package/src/worker-lib/wal-codec.test.ts +83 -0
  34. package/src/worker-lib/wal-codec.ts +130 -0
  35. package/src/zip.test.ts +124 -0
  36. package/src/zip.ts +60 -0
  37. package/sync/sync-engine.test.ts +695 -0
  38. package/sync/sync-engine.ts +459 -0
  39. package/sync/truth-port.ts +72 -0
package/src/client.ts ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * ProjectFsClient — 主线程侧 ProjectFS 封装(P0,同 origin)。
3
+ * 起 fs-core / fs-query 两个 worker,牵好 core→query 的 diff 端口,
4
+ * 暴露 Promise API;写请求自动带 opId,超时重试幂等(同 opId 重发)。
5
+ */
6
+ const WRITE_TIMEOUT_MS = 8000
7
+
8
+ type Mode = 'starting' | 'writer' | 'readonly' | 'draining' | 'dead'
9
+
10
+ /**
11
+ * Loosely-shaped dynamic message bag flowing over the fs-core worker's main
12
+ * port: both the WELCOME/PONG/event messages (`event()`/`welcome()` in
13
+ * fs-core.worker.ts) and the RPC reply messages (`respond()`/`fail()` there)
14
+ * land here, distinguished at runtime by which optional fields are present.
15
+ * One flat interface (rather than a narrowed union) matches how the code
16
+ * actually reads it — every field is optional and independently checked.
17
+ */
18
+ export interface CoreMessage {
19
+ type?: string
20
+ evt?: string
21
+ error?: string
22
+ mode?: Mode
23
+ readonly?: boolean
24
+ gen?: number
25
+ id?: number
26
+ ok?: boolean
27
+ result?: unknown
28
+ code?: string
29
+ humanPaths?: string[]
30
+ auditGap?: boolean
31
+ actor?: string
32
+ paths?: string[]
33
+ count?: number
34
+ restore?: string
35
+ t?: number
36
+ }
37
+
38
+ interface PendingEntry {
39
+ resolve: (v: unknown) => void
40
+ reject: (e: unknown) => void
41
+ timer: ReturnType<typeof setTimeout> | null
42
+ }
43
+
44
+ interface QPendingEntry {
45
+ resolve: (v: unknown) => void
46
+ reject: (e: unknown) => void
47
+ }
48
+
49
+ interface DiffResult {
50
+ turnId: string
51
+ changes: Array<{ gen: number; op: string; path?: string; from?: string; to?: string }>
52
+ cpId: string | undefined
53
+ auditWindow: { cap: number; sinceGen: number }
54
+ }
55
+
56
+ interface StatusResult {
57
+ mode: string
58
+ appendedGen: number
59
+ walGen: number
60
+ memGen: number
61
+ ackGen: number
62
+ compactGen: number
63
+ epoch: number
64
+ walStartGen: number
65
+ checkpoints: string[]
66
+ turn: { turnId: string; cpId: string; expiresAt: number; ops: number } | null
67
+ }
68
+
69
+ export class ProjectFsClient {
70
+ projectId!: string
71
+ clientId!: string
72
+ core!: Worker
73
+ query!: Worker
74
+ pending!: Map<number, PendingEntry>
75
+ qPending!: Map<number, QPendingEntry>
76
+ changeCbs!: Set<(evt: CoreMessage) => void>
77
+ modeCbs!: Set<(mode: Mode) => void>
78
+ _mode!: Mode
79
+ seq!: number
80
+ welcome: CoreMessage | null = null
81
+ pingTimer!: ReturnType<typeof setInterval>
82
+ lastPong!: number
83
+ private _retried?: boolean
84
+
85
+ /** 测试用:抹掉一个项目的全部持久层(只能在无 core 运行时调用)。 */
86
+ static async wipe(projectId: string): Promise<void> {
87
+ const root = await navigator.storage.getDirectory()
88
+ try { await root.removeEntry(projectId, { recursive: true }) } catch {}
89
+ }
90
+
91
+ static async connect({
92
+ projectId,
93
+ coreUrl = '/ide/fs/fs-core.worker.js',
94
+ queryUrl = '/ide/fs/fs-query.worker.js',
95
+ clientId = 'c-' + Math.random().toString(36).slice(2, 10),
96
+ }: {
97
+ projectId: string
98
+ coreUrl?: string
99
+ queryUrl?: string
100
+ clientId?: string
101
+ }): Promise<ProjectFsClient> {
102
+ const c = new ProjectFsClient()
103
+ c.projectId = projectId
104
+ c.clientId = clientId
105
+ c.core = new Worker(coreUrl, { type: 'module' })
106
+ c.query = new Worker(queryUrl, { type: 'module' })
107
+ c.pending = new Map() // id -> {resolve, reject, timer}
108
+ c.qPending = new Map()
109
+ c.changeCbs = new Set()
110
+ c.modeCbs = new Set()
111
+ c._mode = 'starting'
112
+ c.seq = 0
113
+ c.welcome = null
114
+
115
+ const chan = new MessageChannel()
116
+ const welcomed = new Promise<CoreMessage>((resolve, reject) => {
117
+ c.core.onmessage = (e) => c._onCoreMessage(e.data, resolve, reject)
118
+ c.core.onerror = (e) => reject(new Error('fs-core worker error: ' + e.message))
119
+ })
120
+ c.query.onmessage = (e) => c._onQueryMessage(e.data)
121
+ c.query.postMessage({ type: 'init', corePort: chan.port2 }, [chan.port2])
122
+ c.core.postMessage({ type: 'HELLO', projectId, clientId, capabilityRequest: { mode: 'rw' }, queryPort: chan.port1 }, [chan.port1])
123
+ c.welcome = await welcomed
124
+ c._setMode(c.welcome.mode || (c.welcome.readonly ? 'readonly' : 'writer'))
125
+
126
+ c.pingTimer = setInterval(() => { try { c.core.postMessage({ type: 'PING', t: Date.now() }) } catch {} }, 10000)
127
+ c.lastPong = Date.now()
128
+ return c
129
+ }
130
+
131
+ _onCoreMessage(msg: CoreMessage, resolveWelcome: ((v: CoreMessage) => void) | null, rejectWelcome: ((e: Error) => void) | null): void {
132
+ if (msg.type === 'WELCOME') { this._handleWelcome(msg, resolveWelcome); return }
133
+ if (msg.type === 'FATAL') { this._handleFatal(msg, rejectWelcome); return }
134
+ if (msg.type === 'PONG') { this.lastPong = Date.now(); return }
135
+ if (msg.evt) { this._handleEvt(msg); return }
136
+ if (msg.id !== undefined) this._handleRpcReply(msg)
137
+ }
138
+
139
+ _handleWelcome(msg: CoreMessage, resolveWelcome: ((v: CoreMessage) => void) | null): void {
140
+ if (resolveWelcome) resolveWelcome(msg)
141
+ }
142
+
143
+ _handleFatal(msg: CoreMessage, rejectWelcome: ((e: Error) => void) | null): void {
144
+ this._setMode('dead')
145
+ const err = new Error('fs-core fatal: ' + msg.error)
146
+ if (rejectWelcome) rejectWelcome(err)
147
+ for (const [, p] of this.pending) p.reject(err)
148
+ this.pending.clear()
149
+ }
150
+
151
+ _handleEvt(msg: CoreMessage): void {
152
+ if (msg.evt === 'writer-granted') this._setMode('writer')
153
+ else if (msg.evt === 'writer-lost') this._setMode('readonly')
154
+ for (const cb of this.changeCbs) { try { cb(msg) } catch {} }
155
+ }
156
+
157
+ _handleRpcReply(msg: CoreMessage): void {
158
+ const p = this.pending.get(msg.id!)
159
+ if (!p) return
160
+ this.pending.delete(msg.id!)
161
+ if (p.timer) clearTimeout(p.timer)
162
+ if (msg.ok) p.resolve(msg.result)
163
+ else p.reject(Object.assign(new Error(msg.error), { code: msg.code, ...(msg.humanPaths !== undefined ? { humanPaths: msg.humanPaths } : {}), ...(msg.auditGap !== undefined ? { auditGap: msg.auditGap } : {}) }))
164
+ }
165
+
166
+ _onQueryMessage(msg: CoreMessage): void {
167
+ if (msg.id === undefined) return
168
+ const p = this.qPending.get(msg.id)
169
+ if (!p) return
170
+ this.qPending.delete(msg.id)
171
+ if (msg.ok) p.resolve(msg.result)
172
+ else p.reject(Object.assign(new Error(msg.error), { code: msg.code }))
173
+ }
174
+
175
+ _rpc<T = unknown>(op: string, args: Record<string, unknown>, { opId, timeout }: { opId?: string; timeout?: number } = {}): Promise<T> {
176
+ const id = ++this.seq
177
+ return new Promise<T>((resolve, reject) => {
178
+ const entryResolve = resolve as (v: unknown) => void
179
+ const timer = timeout
180
+ ? setTimeout(() => {
181
+ // 超时重试一次:同 opId 幂等(fs-core 对已知 opId 返回既有结果)
182
+ if (opId && !this._retried) {
183
+ const retryId = ++this.seq
184
+ this.pending.set(retryId, { resolve: entryResolve, reject, timer: setTimeout(() => { this.pending.delete(retryId); reject(new Error(op + ' timeout (after retry)')) }, timeout) })
185
+ this.pending.delete(id)
186
+ this.core.postMessage({ id: retryId, op, args, opId })
187
+ } else {
188
+ this.pending.delete(id)
189
+ reject(new Error(op + ' timeout'))
190
+ }
191
+ }, timeout)
192
+ : null
193
+ this.pending.set(id, { resolve: entryResolve, reject, timer })
194
+ this.core.postMessage({ id, op, args, opId })
195
+ })
196
+ }
197
+
198
+ _qrpc<T = unknown>(op: string, args: Record<string, unknown>): Promise<T> {
199
+ const id = ++this.seq
200
+ return new Promise<T>((resolve, reject) => {
201
+ this.qPending.set(id, { resolve: resolve as (v: unknown) => void, reject })
202
+ this.query.postMessage({ id, op, args })
203
+ })
204
+ }
205
+
206
+ _writeOp<T = unknown>(op: string, args: Record<string, unknown>): Promise<T> {
207
+ return this._rpc<T>(op, args, { opId: crypto.randomUUID(), timeout: WRITE_TIMEOUT_MS })
208
+ }
209
+
210
+ // ── 写 API({actor:'human'|'agent', turnId, ifMatch} 透传)──
211
+ write(path: string, content: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('write', { path, content, ...opts }) }
212
+ edit(path: string, old: string, next: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('edit', { path, old, next, ...opts }) }
213
+ rm(path: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('rm', { path, ...opts }) }
214
+ mv(from: string, to: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('mv', { from, to, ...opts }) }
215
+ mkdir(path: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('mkdir', { path, ...opts }) }
216
+ checkpoint(opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('checkpoint', { ...opts }) }
217
+ /** opts: {baseGen?, force?} 透传给 fs-core(§4.7 restore 冲突策略);baseGen 缺省时
218
+ * fs-core 从 checkpoint 自身记录的 gen 推导。 */
219
+ restore(cpId: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('restore', { cpId, ...opts }) }
220
+ compact(): Promise<unknown> { return this._rpc('compact', {}, { timeout: 30000 }) }
221
+
222
+ // ── P4 turn 能力:铸造(附带 checkpoint 锚)→ agent 写执法 → 撤销 ──
223
+ turnBegin(turnId: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._writeOp('turnBegin', { turnId, ...opts }) }
224
+ turnEnd(turnId: string): Promise<unknown> { return this._writeOp('turnEnd', { turnId }) }
225
+ /** 该 turn 的改动清单(WAL actor/turnId 审计标注免费提供)。 */
226
+ diff(turnId?: string): Promise<DiffResult> { return this._rpc<DiffResult>('diff', { turnId }) }
227
+
228
+ /** W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):特权方法,只应
229
+ * 由内核(kernel.js createKernel)在 boot 早期调用一次,把只有内核持有的随机令牌交给 fs-core;
230
+ * 此后 actor:'agent' 的写类 op 必须在 opts 里携带匹配的 agentToken(fs-core 侧强制,见
231
+ * fs-core.worker.js checkTurn/armAgentToken)。无 opId/超时重试——一次性 admin 调用,非写路径
232
+ * 幂等本就由 fs-core 的 armAgentToken 自身保证(同令牌重放 ok,不同令牌拒绝)。 */
233
+ armAgentTokenGate(token: string): Promise<{ armed: boolean; idempotent?: boolean }> { return this._rpc('armAgentTokenGate', { token }) }
234
+
235
+ // ── 读 API:小读走 core(权威),查询/快照走 query(不占写路径)──
236
+ read(path: string): Promise<{ content: string; rev?: number; gen: number }> { return this._rpc('read', { path }) }
237
+ ls(): Promise<{ paths: string[]; gen: number }> { return this._rpc('ls', {}) }
238
+ status(): Promise<StatusResult> { return this._rpc<StatusResult>('status', {}) }
239
+ snapshot(opts: { gen?: number } = {}): Promise<{ files: Record<string, string>; gen: number; stale: boolean }> { return this._qrpc('snapshot', opts) }
240
+ grep(pattern: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._qrpc('grep', { pattern, ...opts }) }
241
+ glob(pattern: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._qrpc('glob', { pattern, ...opts }) }
242
+ queryRead(path: string, opts: Record<string, unknown> = {}): Promise<unknown> { return this._qrpc('read', { path, ...opts }) }
243
+
244
+ /** 空项目时批量写入种子文件;非空则跳过。返回 {seeded, count}。 */
245
+ async seed(files: Record<string, string>): Promise<{ seeded: boolean; count: number }> {
246
+ const { paths } = await this.ls()
247
+ if (paths.length) return { seeded: false, count: paths.length }
248
+ const entries = Object.entries(files)
249
+ for (let i = 0; i < entries.length; i += 50) {
250
+ await Promise.all(entries.slice(i, i + 50).map(([p, c]) => this.write(p, c, { actor: 'human' })))
251
+ }
252
+ return { seeded: true, count: entries.length }
253
+ }
254
+
255
+ onChange(cb: (evt: CoreMessage) => void): () => void {
256
+ this.changeCbs.add(cb)
257
+ return () => this.changeCbs.delete(cb)
258
+ }
259
+
260
+ /** 当前单写者状态:starting(HELLO 未回)|writer(持写者租约)|readonly(排队中或已交出)|
261
+ * draining(worker 侧过渡态,client 不会长时间观察到——见 fs-core.worker.js onBroadcast,
262
+ * 收敛为 readonly 再广播事件)|dead(FATAL,worker 已不可用)。由 WELCOME 与
263
+ * writer-granted/writer-lost 事件驱动,供宿主向用户呈现"另一个标签页持有写权"之类提示。 */
264
+ get mode(): Mode { return this._mode }
265
+
266
+ _setMode(mode: Mode): void {
267
+ if (this._mode === mode) return
268
+ this._mode = mode
269
+ for (const cb of this.modeCbs) { try { cb(mode) } catch {} }
270
+ }
271
+
272
+ /** 订阅 mode 变化(writer ⇄ readonly ⇄ dead);返回退订函数。 */
273
+ onModeChange(cb: (mode: Mode) => void): () => void {
274
+ this.modeCbs.add(cb)
275
+ return () => this.modeCbs.delete(cb)
276
+ }
277
+
278
+ destroy(): void {
279
+ clearInterval(this.pingTimer)
280
+ this._setMode('dead')
281
+ this.core.terminate()
282
+ this.query.terminate()
283
+ }
284
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Contract tests for `createDiskMirror`'s lifecycle bookkeeping — the actual
3
+ * disk I/O only runs in a real Chromium window, so these exercise the pure
4
+ * scheduling/guard logic (pick(handle), dispose(), overlapping-sync pending
5
+ * re-arm) against fake FileSystemDirectoryHandle/FileHandle doubles and a
6
+ * fake `fs.snapshot()` source, using vitest's fake timers to control the 2s
7
+ * debounce deterministically.
8
+ */
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { createDiskMirror } from './disk-mirror.js'
11
+
12
+ /** Minimal in-memory stand-in for a FileSystemFileHandle: buffers writes
13
+ * until `close()`, matching the real API's write-then-close contract. */
14
+ class FakeFileHandle {
15
+ content = ''
16
+ private buf = ''
17
+ async createWritable() {
18
+ return {
19
+ write: async (data: string) => {
20
+ this.buf = data
21
+ },
22
+ close: async () => {
23
+ this.content = this.buf
24
+ },
25
+ }
26
+ }
27
+ }
28
+
29
+ /** Minimal in-memory stand-in for a FileSystemDirectoryHandle: supports the
30
+ * three calls disk-mirror.js makes (getDirectoryHandle/getFileHandle/removeEntry)
31
+ * over a nested tree, plus a `read(path)` test helper to inspect mirrored content. */
32
+ class FakeDirHandle {
33
+ name: string
34
+ private dirs = new Map<string, FakeDirHandle>()
35
+ private fileHandles = new Map<string, FakeFileHandle>()
36
+ constructor(name = '') {
37
+ this.name = name
38
+ }
39
+ async getDirectoryHandle(name: string, opts?: { create?: boolean }): Promise<FakeDirHandle> {
40
+ let d = this.dirs.get(name)
41
+ if (!d) {
42
+ if (!opts?.create) throw new Error(`not found: ${name}`)
43
+ d = new FakeDirHandle(name)
44
+ this.dirs.set(name, d)
45
+ }
46
+ return d
47
+ }
48
+ async getFileHandle(name: string, opts?: { create?: boolean }): Promise<FakeFileHandle> {
49
+ let f = this.fileHandles.get(name)
50
+ if (!f) {
51
+ if (!opts?.create) throw new Error(`not found: ${name}`)
52
+ f = new FakeFileHandle()
53
+ this.fileHandles.set(name, f)
54
+ }
55
+ return f
56
+ }
57
+ async removeEntry(name: string): Promise<void> {
58
+ if (this.fileHandles.delete(name)) return
59
+ if (this.dirs.delete(name)) return
60
+ throw new Error(`not found: ${name}`)
61
+ }
62
+ /** Test helper: reads mirrored content at a '/'-joined path, or undefined if absent. */
63
+ read(path: string): string | undefined {
64
+ const parts = path.split('/')
65
+ const dir = parts.slice(0, -1).reduce<FakeDirHandle | undefined>(
66
+ (d, seg) => d?.dirs.get(seg),
67
+ this,
68
+ )
69
+ return dir?.fileHandles.get(parts[parts.length - 1] as string)?.content
70
+ }
71
+ }
72
+
73
+ /** Fake `fs` source whose `snapshot()` call count and blocking can be
74
+ * controlled from the test, to force a real overlap between an in-flight
75
+ * `syncAll()` and a subsequent `schedule()`. */
76
+ function makeFs(initialFiles: Record<string, string>) {
77
+ let files = { ...initialFiles }
78
+ let gen = 0
79
+ let calls = 0
80
+ let blockOnCall = -1
81
+ let releaseBlock: (() => void) | null = null
82
+ const snapshot = vi.fn(async () => {
83
+ calls++
84
+ // Capture synchronously (before any `await`) so a mutation the test makes
85
+ // *after* this call returns control is never smeared into this round's
86
+ // snapshot — exactly mirroring how a real snapshot is a point-in-time read.
87
+ const captured = { files: { ...files }, gen: ++gen }
88
+ if (calls === blockOnCall) {
89
+ await new Promise<void>((resolve) => {
90
+ releaseBlock = resolve
91
+ })
92
+ }
93
+ return captured
94
+ })
95
+ return {
96
+ fs: { snapshot },
97
+ setFiles(next: Record<string, string>) {
98
+ files = { ...next }
99
+ },
100
+ callCount: () => calls,
101
+ /** Makes the Nth `snapshot()` call block (after capturing) until `release()` is called. */
102
+ blockCall(n: number) {
103
+ blockOnCall = n
104
+ },
105
+ release() {
106
+ const r = releaseBlock
107
+ releaseBlock = null
108
+ r?.()
109
+ },
110
+ }
111
+ }
112
+
113
+ describe('createDiskMirror', () => {
114
+ beforeEach(() => {
115
+ vi.useFakeTimers()
116
+ })
117
+ afterEach(() => {
118
+ vi.useRealTimers()
119
+ })
120
+
121
+ it('pick(handle) uses the injected handle directly, skipping showDirectoryPicker', async () => {
122
+ const { fs } = makeFs({ 'a.txt': '1' })
123
+ const mirror = createDiskMirror(fs)
124
+ const dir = new FakeDirHandle('root')
125
+
126
+ const r = await mirror.pick(dir as unknown as FileSystemDirectoryHandle)
127
+
128
+ expect(r.name).toBe('root')
129
+ expect(dir.read('a.txt')).toBe('1')
130
+ expect(mirror.active).toBe(true)
131
+ })
132
+
133
+ it('dispose() cancels the pending debounce timer and short-circuits later syncAll calls', async () => {
134
+ const { fs, callCount } = makeFs({ 'a.txt': '1' })
135
+ const mirror = createDiskMirror(fs)
136
+ const dir = new FakeDirHandle('root')
137
+ await mirror.pick(dir as unknown as FileSystemDirectoryHandle)
138
+ const callsAfterPick = callCount()
139
+
140
+ mirror.schedule() // arms the 2s debounce timer
141
+ mirror.dispose()
142
+ expect(mirror.active).toBe(false)
143
+
144
+ await vi.advanceTimersByTimeAsync(5000)
145
+ // The cleared timer never fires syncAll again.
146
+ expect(callCount()).toBe(callsAfterPick)
147
+
148
+ // Defense in depth: even a direct syncAll() call after dispose is a no-op.
149
+ const r = await mirror.syncAll()
150
+ expect(r).toBeNull()
151
+ expect(callCount()).toBe(callsAfterPick)
152
+ })
153
+
154
+ it('re-arms and mirrors a change that happened while a sync was already in flight', async () => {
155
+ const { fs, setFiles, callCount, blockCall, release } = makeFs({ 'a.txt': '1' })
156
+ const mirror = createDiskMirror(fs)
157
+ const dir = new FakeDirHandle('root')
158
+
159
+ await mirror.pick(dir as unknown as FileSystemDirectoryHandle) // snapshot call #1
160
+ expect(dir.read('a.txt')).toBe('1')
161
+
162
+ // Round 2: block after the snapshot for call #2 is captured, simulating a
163
+ // sync still "in flight" (syncing === true) while further changes land.
164
+ blockCall(2)
165
+ mirror.schedule()
166
+ await vi.advanceTimersByTimeAsync(2000) // fires the timer -> syncAll() call #2 starts and blocks
167
+
168
+ // While round 2 is in flight, a new change arrives and schedule() is called
169
+ // again. Assert schedule() does NOT arm a fresh timer while syncing is
170
+ // true (it must set `pending` instead): advancing time here, while call #2
171
+ // is still blocked/in-flight, must NOT produce any further snapshot() call.
172
+ // The old implementation unconditionally re-armed a timer here, which
173
+ // would fire into `syncAll()`'s own `syncing` guard and silently drop the
174
+ // update forever (no retry is ever scheduled after a guard-drop).
175
+ setFiles({ 'a.txt': '2' })
176
+ mirror.schedule()
177
+ await vi.advanceTimersByTimeAsync(2000)
178
+ expect(callCount()).toBe(2) // still just call #2, blocked — no drop, no premature retry
179
+
180
+ release() // let call #2 finish; it writes the *old* ('1') snapshot it had captured
181
+ await vi.waitFor(() => expect(dir.read('a.txt')).toBe('1'))
182
+
183
+ // The finally-block pending re-arm should have scheduled a fresh 2s timer
184
+ // only once call #2 actually finished.
185
+ await vi.advanceTimersByTimeAsync(2000) // fires -> syncAll() call #3, captures the new value
186
+
187
+ expect(dir.read('a.txt')).toBe('2')
188
+ expect(callCount()).toBe(3)
189
+ })
190
+ })
@@ -0,0 +1,119 @@
1
+ /**
2
+ * 本地磁盘镜像(P5,Chromium only)—— showDirectoryPicker() 授权一个真实磁盘目录,
3
+ * 把项目树 write-through 镜像过去:用户在访达/资源管理器里能看到自己的项目。
4
+ * 这是"持久化到磁盘"的字面回答(架构文档 §4 出口 1)。
5
+ *
6
+ * 策略:fs-change 防抖 2s 全量比对同步(内容不变的文件跳过 —— 内容比对在内存,
7
+ * 磁盘只写变更);删除按上次镜像记录清理。授权需要用户手势,无授权时静默不动。
8
+ */
9
+
10
+ /** Structural shape disk-mirror.js actually calls on a directory handle —
11
+ * deliberately looser than the DOM lib's `FileSystemDirectoryHandle` (which
12
+ * also requires `resolve`/`isSameEntry`) so any handle-like object a consumer
13
+ * already holds — a real FSA handle, or a project's own narrower wrapper type
14
+ * — can be injected via `pick(handle)` without a structural mismatch. */
15
+ export interface DiskMirrorDirectoryHandle {
16
+ name: string
17
+ getDirectoryHandle(name: string, options?: { create?: boolean }): Promise<DiskMirrorDirectoryHandle>
18
+ getFileHandle(name: string, options?: { create?: boolean }): Promise<{
19
+ createWritable(): Promise<{ write(data: unknown): Promise<void>; close(): Promise<void> }>
20
+ }>
21
+ removeEntry?(name: string, options?: { recursive?: boolean }): Promise<void>
22
+ }
23
+
24
+ // showDirectoryPicker isn't in TS's shipped DOM lib yet (real Chromium-only API).
25
+ declare global {
26
+ interface Window {
27
+ showDirectoryPicker?(options?: { mode?: 'read' | 'readwrite' }): Promise<DiskMirrorDirectoryHandle>
28
+ }
29
+ }
30
+
31
+ /** Only the one ProjectFsClient method disk-mirror actually calls. */
32
+ export interface DiskMirrorFs {
33
+ snapshot(): Promise<{ files: Record<string, string>; gen: number }>
34
+ }
35
+
36
+ export function createDiskMirror(fs: DiskMirrorFs) {
37
+ let dir: DiskMirrorDirectoryHandle | null = null
38
+ let last = new Map<string, string>() // path -> content(上次已镜像的内容)
39
+ let timer: ReturnType<typeof setTimeout> | undefined
40
+ let syncing = false
41
+ let pending = false // syncAll 运行期间又发生了变更,结束后需要再镜像一轮
42
+
43
+ /** @param handle 已获取授权的目录句柄;传了就直接用(跳过
44
+ * showDirectoryPicker 弹窗),调用方自行负责获取授权(例如复用宿主已有的句柄)。 */
45
+ async function pick(handle?: DiskMirrorDirectoryHandle) {
46
+ if (handle) {
47
+ dir = handle
48
+ } else {
49
+ if (!window.showDirectoryPicker) throw new Error('showDirectoryPicker 不可用(需要 Chromium)')
50
+ dir = await window.showDirectoryPicker({ mode: 'readwrite' })
51
+ }
52
+ last = new Map()
53
+ const r = await syncAll()
54
+ return { name: dir.name, ...r }
55
+ }
56
+
57
+ /** 停用镜像:取消挂起的防抖定时器,清空目录句柄与镜像记账,使后续任何 syncAll
58
+ * 调用(包括仍在飞行中的定时器回调)在入口处天然短路。 */
59
+ function dispose() {
60
+ clearTimeout(timer)
61
+ dir = null
62
+ last = new Map()
63
+ }
64
+
65
+ async function syncAll(): Promise<{ written: number; removed: number; gen: number } | null> {
66
+ if (!dir || syncing) return null
67
+ syncing = true
68
+ try {
69
+ const snap: { files: Record<string, string>; gen: number } = await fs.snapshot()
70
+ let written = 0
71
+ for (const [p, content] of Object.entries(snap.files)) {
72
+ if (last.get(p) === content) continue
73
+ const parts = p.split('/')
74
+ let d = dir
75
+ for (const seg of parts.slice(0, -1)) d = await d.getDirectoryHandle(seg, { create: true })
76
+ const fh = await d.getFileHandle(parts[parts.length - 1] as string, { create: true })
77
+ const w = await fh.createWritable()
78
+ await w.write(content)
79
+ await w.close()
80
+ last.set(p, content)
81
+ written++
82
+ }
83
+ let removed = 0
84
+ for (const p of [...last.keys()]) {
85
+ if (p in snap.files) continue
86
+ try {
87
+ const parts = p.split('/')
88
+ let d = dir
89
+ for (const seg of parts.slice(0, -1)) d = await d.getDirectoryHandle(seg)
90
+ await d.removeEntry?.(parts[parts.length - 1] as string)
91
+ } catch { /* 目录已不在等:忽略 */ }
92
+ last.delete(p)
93
+ removed++
94
+ }
95
+ return { written, removed, gen: snap.gen }
96
+ } finally {
97
+ syncing = false
98
+ if (pending && dir) {
99
+ pending = false
100
+ schedule()
101
+ }
102
+ }
103
+ }
104
+
105
+ function schedule() {
106
+ if (!dir) return
107
+ if (syncing) {
108
+ // 一轮 syncAll 正在进行中:这次变更不能被这轮同步捕获(快照已在读取途中),
109
+ // 也不能立刻并发再跑一轮(syncAll 自身会在入口处丢弃)。记一个 pending,
110
+ // 交给上面 syncAll 的 finally 在这轮结束后重新排期,保证不丢更新。
111
+ pending = true
112
+ return
113
+ }
114
+ clearTimeout(timer)
115
+ timer = setTimeout(() => { syncAll().catch(() => {}) }, 2000)
116
+ }
117
+
118
+ return { pick, syncAll, schedule, dispose, get active() { return !!dir } }
119
+ }