@dimina-kit/fs-core 0.2.0-dev.20260710085051 → 0.3.0-dev.6-dev.20260711133419

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 (51) hide show
  1. package/README.md +205 -18
  2. package/dist/client.js +23 -11
  3. package/dist/disk-mirror.js +2 -2
  4. package/dist/fs-core.worker.js +101 -21
  5. package/dist/sync/binary-sidecar.js +147 -0
  6. package/dist/sync/sync-engine.js +119 -169
  7. package/dist/sync/watch-expander.js +202 -0
  8. package/dist/worker-files.cjs +32 -0
  9. package/dist/worker-files.js +35 -0
  10. package/dist/worker-lib/.tsbuildinfo +1 -0
  11. package/dist/worker-lib/engine-shared.d.ts +79 -0
  12. package/dist/worker-lib/engine-shared.d.ts.map +1 -0
  13. package/dist/worker-lib/engine-shared.js +6 -3
  14. package/dist/worker-lib/paths.d.ts +4 -0
  15. package/dist/worker-lib/paths.d.ts.map +1 -0
  16. package/dist/worker-lib/protocol.d.ts +119 -0
  17. package/dist/worker-lib/protocol.d.ts.map +1 -0
  18. package/dist/worker-lib/protocol.js +73 -0
  19. package/dist/worker-lib/rpc-types.d.ts +68 -0
  20. package/dist/worker-lib/rpc-types.d.ts.map +1 -0
  21. package/dist/worker-lib/wal-codec.d.ts +58 -0
  22. package/dist/worker-lib/wal-codec.d.ts.map +1 -0
  23. package/dist/worker-lib/wal-codec.js +8 -5
  24. package/dist/zip.js +1 -1
  25. package/package.json +25 -2
  26. package/src/__checks__/types-smoke.ts +61 -0
  27. package/src/agent-tools.ts +3 -3
  28. package/src/client-retry.test.ts +76 -0
  29. package/src/client.ts +112 -45
  30. package/src/disk-mirror.ts +2 -2
  31. package/src/fs-core-handover.test.ts +215 -0
  32. package/src/fs-core-opid-replay.test.ts +158 -0
  33. package/src/fs-core-recovery-lock.test.ts +225 -0
  34. package/src/fs-core-recovery.ts +115 -13
  35. package/src/fs-core-write-ops.ts +14 -11
  36. package/src/fs-core.worker.ts +26 -11
  37. package/src/worker-files.test.ts +39 -0
  38. package/src/worker-files.ts +43 -0
  39. package/src/worker-lib/engine-shared.ts +19 -5
  40. package/src/worker-lib/protocol.test.ts +37 -0
  41. package/src/worker-lib/protocol.ts +184 -0
  42. package/src/worker-lib/wal-codec.ts +8 -5
  43. package/src/zip.ts +1 -1
  44. package/sync/binary-sidecar.test.ts +150 -0
  45. package/sync/binary-sidecar.ts +187 -0
  46. package/sync/sync-engine-degraded.test.ts +309 -0
  47. package/sync/sync-engine.test.ts +20 -91
  48. package/sync/sync-engine.ts +134 -181
  49. package/sync/truth-port.ts +3 -3
  50. package/sync/watch-expander.test.ts +96 -0
  51. package/sync/watch-expander.ts +236 -0
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
1
  # @dimina-kit/fs-core
2
2
 
3
- 浏览器端 OPFS WAL 文件系统内核:单写者权威(Web Locks 选主)、WAL-first 写序、checkpoint/restore 回滚、agent 写权 turn 门控,零运行时依赖。
3
+ 浏览器端 OPFS WAL 文件系统内核:单写者权威(Web Locks 选主 + 协作交接)、WAL-first 写序、checkpoint/restore 回滚、agent 写权 turn 门控,零运行时依赖。
4
+
5
+ 给两类宿主用:
6
+
7
+ - **fs-core 即真相源**的宿主(如 qdmp-web-workbench):项目文件直接活在 OPFS 账本里,导出/落盘是下游动作(`/disk-mirror`、`/zip`)。
8
+ - **磁盘才是真相源**的宿主(如 dimina-kit workbench/devtools):fs-core 只做记账与 agent 写权执法,`/sync` 引擎负责磁盘↔账本双向对账。
4
9
 
5
10
  ## 安装
6
11
 
@@ -8,38 +13,220 @@
8
13
  npm i @dimina-kit/fs-core
9
14
  ```
10
15
 
11
- ## 入口
12
-
13
- | 子路径 | 用途 |
14
- | --- | --- |
15
- | `@dimina-kit/fs-core/client` | `ProjectFsClient.connect({ projectId })`:读写/快照/grep/glob、`mode`/`onModeChange`(多标签页单写者可见性)、turn API |
16
- | `@dimina-kit/fs-core/agent-tools` | fs_read/fs_write/fs_restore 等 agent 工具面(fs-core 侧 turn 执法) |
17
- | `@dimina-kit/fs-core/disk-mirror` | File System Access 目录镜像(防抖增量写盘,`pick(handle)` 可注入已授权句柄) |
18
- | `@dimina-kit/fs-core/sync` | 磁盘↔账本同步引擎(TruthPort 适配外部真相源) |
19
- | `@dimina-kit/fs-core/zip` | 快照打包为 ZIP |
20
-
21
- ## 使用
16
+ ## 快速上手
22
17
 
23
18
  ```js
24
19
  import { ProjectFsClient } from '@dimina-kit/fs-core/client'
25
20
 
26
- // 宿主需把 dist/fs-core.worker.js 与 dist/fs-query.worker.js 部署到可访问 URL
27
- // (默认约定 /ide/fs/ 下),client 以 module worker 加载它们。
21
+ // 宿主需把 dist/fs-core.worker.js 与 dist/fs-query.worker.js 部署到可访问 URL
22
+ // client 以 module worker 加载它们。coreUrl/queryUrl 的缺省值 /ide/fs/ 是最初
23
+ // dwc 宿主的部署形状;其它宿主都应显式传入自己的 URL。文件名/位置的单一权威
24
+ // 见 `@dimina-kit/fs-core/worker-files` 的 resolveWorkerFiles()。
28
25
  const fs = await ProjectFsClient.connect({
29
26
  projectId: 'my-project',
30
27
  coreUrl: '/ide/fs/fs-core.worker.js',
31
28
  queryUrl: '/ide/fs/fs-query.worker.js',
32
29
  })
33
- await fs.write('app.json', '{}')
34
- const { content } = await fs.read('app.json')
30
+ const { gen, rev } = await fs.write('app.json', '{}') // 写 API 的 opts 与返回值全部类型化
31
+ const { content } = await fs.read('app.json') //(FsWriteCallOpts / FsWriteResult 等随 /client 导出)
32
+
33
+ // 错误处理按符号匹配(错误码契约见 `/protocol`):
34
+ import { isFsCoreErrorCode } from '@dimina-kit/fs-core/protocol'
35
+ try {
36
+ await fs.write('app.json', '{}')
37
+ } catch (e) {
38
+ if (isFsCoreErrorCode(e, 'readonly')) {
39
+ /* 另一个标签页持有写权 —— 见下文「多标签页单写者」 */
40
+ }
41
+ }
35
42
  ```
36
43
 
37
- ## Worker 产物约束
44
+ ### Worker 产物部署
38
45
 
39
- `dist/fs-core.worker.js` 与 `dist/fs-query.worker.js` 是单文件自包含 ESM(无 import 语句、零依赖),宿主按字面文件名从 `dist/` 拷贝/托管即可;以 module worker(`{ type: 'module' }`)加载。
46
+ `dist/fs-core.worker.js` 与 `dist/fs-query.worker.js` 是单文件自包含 ESM(无 import 语句、零依赖),宿主按**字面文件名**从 `dist/` 拷贝/托管即可,以 module worker(`{ type: 'module' }`)加载。构建脚本请消费 `@dimina-kit/fs-core/worker-files` 的 `resolveWorkerFiles()`(ESM/CJS 双形态)拿文件名与路径,不要手拼。
40
47
 
41
48
  需要 OPFS(`navigator.storage.getDirectory`)与 `createSyncAccessHandle`(worker 内),即 Chromium ≥ 102 一类环境;不依赖 COI。
42
49
 
50
+ ## 架构总览
51
+
52
+ 三个执行体,一条权威链:
53
+
54
+ ```mermaid
55
+ flowchart LR
56
+ subgraph 主线程
57
+ C["ProjectFsClient<br/>(RPC 封装 + opId 幂等重试 + mode 状态机)"]
58
+ end
59
+ subgraph "fs-core worker(单写者权威)"
60
+ W["FsCore<br/>WAL append / 内存镜像 / turn 执法 / compaction"]
61
+ end
62
+ subgraph "fs-query worker(只读副本)"
63
+ Q["快照 / grep / glob<br/>(不占写路径)"]
64
+ end
65
+ C -- "写类 op(write/rm/mv/restore/turn*)" --> W
66
+ C -- "查询类 op(snapshot/grep/glob)" --> Q
67
+ W -- "diff 端口(增量推送镜像)" --> Q
68
+ W -- "事件(fs-change / writer-granted / writer-lost / FATAL)" --> C
69
+ ```
70
+
71
+ - **fs-core worker** 是唯一权威:持有 OPFS 写句柄,所有写序列化在一条 `enqueue` 链上。
72
+ - **fs-query worker** 是它的只读投影:core 经专用 MessagePort 推增量 diff,重查询(全量快照、grep、glob)在这里跑,不打扰写路径。
73
+ - **client** 只是主线程的便利面:Promise API、写超时后按同 `opId` 幂等重试恰一次、把 worker 事件翻译成 `mode`/`onChange`。
74
+
75
+ ### 持久层布局(OPFS,无物化文件树)
76
+
77
+ ```
78
+ <projectId>/blobs/<h2>/<sha256> 内容寻址、不可变、写后 flush
79
+ <projectId>/manifests/<gen>.json compaction 产物(CRC 记录在 superblock)
80
+ <projectId>/wal.<startGen> 分段 append-only 日志,禁止原地重写/truncate
81
+ <projectId>/superblock 双槽定长 64B×2;只写非当前槽,flush 后翻转
82
+ ```
83
+
84
+ ### WAL-first 写序
85
+
86
+ 每个写类 op 严格按 **blob flush → WAL append(组提交 flush)→ 应用内存镜像 → ack(opId) → 广播** 的顺序执行。ack 语义:**已 ack 必恢复;未 ack 可能恢复**——超时重试用同 `opId` 消歧(fs-core 对已知 opId 直接返回既有结果,不重放副作用)。
87
+
88
+ ```mermaid
89
+ sequenceDiagram
90
+ participant C as client
91
+ participant W as fs-core worker
92
+ participant O as OPFS
93
+ C->>W: write(path, content, opId)
94
+ W->>O: blob flush(内容寻址,>4KB 才落 blob,小 payload 内联进 WAL)
95
+ W->>O: WAL append + 组提交 flush(50ms 窗口)
96
+ W->>W: 应用内存镜像(gen++)
97
+ W-->>C: ack {gen, rev}(同 opId 重放 → 直接返回该结果)
98
+ W-->>C: 广播 fs-change 事件
99
+ W-->>W: 段超 4MB → 影子 compaction(manifest 物化 + 新段 + superblock 翻转)
100
+ ```
101
+
102
+ 崩溃恢复(worker 启动时):superblock 双槽选优 → 加载 manifest(CRC 校验)→ 按段升序回放 WAL 有效前缀(帧级 CRC,坏尾截断)→ 重建内存镜像与 opId 窗口。
103
+
104
+ ## 多标签页单写者与协作交接
105
+
106
+ 同一 `projectId` 只有一个写者:Web Locks `'dwc:writer:<projectId>'` 排队互斥,**禁 steal**。
107
+
108
+ - 后开的标签页 3s 拿不到锁即以 **readonly** 服务(`fs.mode === 'readonly'`,经 `onModeChange` 订阅变化),排队请求保留,锁 granted 后自动升级为写者。
109
+ - readonly 端可调 `fs.requestHandover()` **主动请求交接**:现任写者排干(flush + 关句柄)后释放锁,本端排队请求随之 granted。何时发起是宿主的 UI 策略(典型:在"另一个标签页持有写权"提示上放"在此接管"动作),fs-core 只提供机制、绝不自动重发;同一 pending 周期内重复调用被合并。只有 readonly 会真正行动:writer/dead/starting 上调用是 no-op(如实返回 `{mode}`),draining 以错误码 `'draining'` 拒绝。
110
+ - **锁仲裁本身失败**(Web Locks 层异常,非"被人占着")一律 FATAL——绝不无锁当写者,也绝不静默滞留 readonly。能跑 fs-core(OPFS SyncAccessHandle)的环境必有 Web Locks,仲裁失败只可能是异常环境,诚实死掉比装活好。
111
+ - **拿到锁但升级失败**(恢复/开句柄抛错)同样 FATAL,且**必定释放锁**——失败的 worker 绝不占着锁当死写者,后面排队的标签页正常接手。
112
+
113
+ ```mermaid
114
+ sequenceDiagram
115
+ participant A as Tab A(写者)
116
+ participant L as Web Locks
117
+ participant B as Tab B(readonly,排队中)
118
+ Note over B: 用户点"在此接管写权"
119
+ B->>B: requestHandover()(无排队请求则先重新排队)
120
+ B-->>A: BroadcastChannel: handover-request
121
+ A->>A: 排干:flushWindow → 关 WAL/superblock 句柄 → mode=readonly
122
+ A->>L: 释放写者锁
123
+ A-->>B: BroadcastChannel: handover-done(各 readonly 端复位合并标志)
124
+ A->>A: 发 writer-lost 事件(宿主提示"已交出写权")
125
+ L->>B: 锁 granted
126
+ B->>B: becomeWriter:recover(以盘上状态为准重建)→ epoch++ → mode=writer
127
+ B->>B: 发 writer-granted 事件(宿主撤 readonly 提示)
128
+ ```
129
+
130
+ 交出写权的一端**不会**自动重新排队(否则两个 tab 会互相抢);它再次调用 `requestHandover()` 时才重新排队并广播。
131
+
132
+ ## Agent 写权:turn 门控与回滚
133
+
134
+ agent(`actor: 'agent'`)的写必须发生在一个活跃 turn 内,执法在 fs-core worker 侧(`checkTurn` 与 WAL append 同一同步块,无竞态窗口)——调用方伪造不了:
135
+
136
+ 1. `turnBegin(turnId)`:铸造 turn,同时自动打一个 checkpoint 锚(回滚点);有 TTL 与 per-turn op 限额(跑飞的 agent 刹车)。
137
+ 2. turn 内的 `write/edit/rm/mv`:携带 `{actor:'agent', turnId}`;宿主可另经 `armAgentTokenGate(token)` 上第二道令牌门(令牌只有内核持有,防同 realm 内伪造)。
138
+ 3. `diff(turnId)`:该 turn 的全部改动清单(WAL 审计标注免费提供)。
139
+ 4. `restore(cpId)`:回滚到锚点;非 force 时做冲突执法——锚点之后存在人类写则拒绝(附 `humanPaths`),审计环覆盖不到的历史一律保守拒绝。
140
+ 5. `turnEnd(turnId)`:撤销 turn(同步置位,立即生效)。
141
+
142
+ `@dimina-kit/fs-core/agent-tools` 提供 MCP 形状的工具表包装(`fs_read`/`fs_write`/`fs_restore`/…,execute 自动注入 turnId,模型伪造不了别的 turn)。
143
+
144
+ ## 两套磁盘机制的划界(永不合流)
145
+
146
+ - **`/disk-mirror`**:OPFS 真相源 → 本地目录的**单向导出**(File System Access,防抖全量比对写盘)。适用于"fs-core 即真相源"的宿主(如 qdmp-web-workbench)想把内容落到本地磁盘。
147
+ - **`/sync`**:**外部真相源 ↔ fs-core 账本的双向对账引擎**(TruthPort 抽象外部真相源;echo 判据、FIFO 序列化、二进制侧车)。适用于"磁盘才是真相源、fs-core 只记账"的宿主(如 dimina-kit workbench 经 `/__fs` 桥 + SSE watch)。
148
+ - 一个宿主真需要双向时,正确姿势是给 `/sync` 写一个 poll 形态的 TruthPort 适配器,而不是把 `/disk-mirror` 养成第二个同步引擎。
149
+ - **未来 poll 适配器须自持的不变量**(引擎只服务 push 形态宿主,不内置 poll 侧机制):poll 宿主自己驱动出站扫描(读账本→写真相源),因此必须自己防"刚从真相源灌入的变更被自己的出站扫描原样回写"——抑制记录须**一次性消费**(命中即清除,不是常驻内容缓存)、匹配须区分文本/字节/删除三种形态、且**绝不能把真相源"读不到"(瞬时 I/O 失败)推断为删除**。
150
+
151
+ ## `/sync` 同步引擎
152
+
153
+ 宿主注入两件东西,拿回一个引擎:
154
+
155
+ ```ts
156
+ import { createSyncEngine } from '@dimina-kit/fs-core/sync'
157
+
158
+ const engine = createSyncEngine(client /* fs-core 账本 */, truthPort /* 外部真相源 */, {
159
+ applyToEditor: async (rel, bytes) => { /* 入站变更灌进编辑器 buffer;bytes===null 是删除 */ },
160
+ onDegraded: (d) => { /* 降级上抛:watcher-dead / path-sync-failed(见下) */ },
161
+ })
162
+ await engine.populateLedger() // 全量播种 + 对账残留
163
+ engine.start() // 订阅 truthPort.changes,开始入站对账
164
+ ```
165
+
166
+ 核心机制:
167
+
168
+ - **ledgerTurn FIFO**:入站批与 `onHumanSave` 记账走同一条 FIFO,二者永不交错——飞行中保存的同路径入站通知总能看到保存完成后的账本记录。
169
+ - **echo 判据**(双向都是内容比较):入站字节 == 账本记录 → 是自己上次写的回声,丢弃;出站保存 == 账本记录 → 冗余,跳过。
170
+ - **二进制分层**:NUL 嗅探分类,二进制永不进字符串账本,由 `binary-sidecar` 的 `{size, sha256}` 索引承接(echo 判据 = 尺寸+哈希相等)。二进制无 WAL 审计/回滚(审计面是字符串契约)。
171
+ - **降级必须 loud**(`onDegraded`):
172
+ - `{kind:'watcher-dead'}`——变更订阅死了,此后账本只反映打开时镜像 + 已处理的批;
173
+ - `{kind:'path-sync-failed', rel, stage:'truth-read'|'reconcile', error}`——单路径对账失败被跳过(真相源读失败 / 账本写失败)。
174
+ 静默跳过会让账本漂移而无人知晓,所以事件与 console 告警**同时**发。
175
+
176
+ ```mermaid
177
+ sequenceDiagram
178
+ participant T as TruthPort(磁盘/桥)
179
+ participant E as sync 引擎
180
+ participant F as fs-core 账本
181
+ participant Ed as 编辑器 buffer
182
+ T-->>E: changes 批 [a.js, b.png]
183
+ E->>T: read(a.js)
184
+ alt 内容 == 账本记录
185
+ E->>E: 自己写的回声 → 丢弃
186
+ else 新内容
187
+ E->>F: client.write(a.js, text, {actor:'human'})
188
+ E->>Ed: applyToEditor(a.js, bytes)
189
+ end
190
+ E->>T: read(b.png)(NUL 嗅探 → 二进制)
191
+ E->>E: binary-sidecar.put()(尺寸+哈希判 echo)
192
+ E->>Ed: applyToEditor(b.png, bytes)
193
+ Note over E: read 瞬时失败 → 跳过该路径 + onDegraded(path-sync-failed)
194
+ ```
195
+
196
+ watch 事件是 coalesced/有损的(macOS FSEvents 会合并突发、丢递归删除的子项)——TruthPort 适配器在喂给引擎**之前**先经 `watch-expander` 做 stat 级核对展开,引擎信任到手的批。
197
+
198
+ ## 宿主接入清单
199
+
200
+ **形态一:fs-core 即真相源**(qdmp 形状)
201
+
202
+ 1. `ProjectFsClient.connect()`,项目加载时播种(注意换项目要"rm 旧 + 覆盖写新"的重置语义,`seed()` 是"非空即跳过")。
203
+ 2. 订阅 `onModeChange` 呈现多标签页写权状态;readonly 提示上挂 `requestHandover()` 动作。
204
+ 3. 二进制资产自建 `binary-sidecar`(`retainBytes: true`),导出/编译经 `overlay()` 与账本文本合并。
205
+ 4. 需要落盘 → `/disk-mirror`;需要打包 → `/zip`。
206
+
207
+ **形态二:磁盘即真相源**(workbench 形状)
208
+
209
+ 1. 实现 `TruthPort`(read/write/delete/walk/changes),watch 批先过 `watch-expander`。
210
+ 2. `createSyncEngine()` + `populateLedger()` + `start()`;人类保存落盘**之后**调 `onHumanSave`(记账是 best-effort,绝不阻塞保存本身)。
211
+ 3. 接 `onDegraded` 到宿主可见的通道(状态条/诊断面板),不要只留 console。
212
+ 4. agent 写走 turn 门控面(`turnBegin`/`agentWrite`/`diff`/`restore`),磁盘与账本一致后再回灌编辑器。
213
+
214
+ 两种形态都请从 `/protocol` 导入错误码/事件名做符号匹配,不要抄 worker 源码里的字符串字面量。
215
+
216
+ ## 入口一览
217
+
218
+ | 子路径 | 用途 |
219
+ | --- | --- |
220
+ | `@dimina-kit/fs-core/client` | `ProjectFsClient.connect({ projectId })`:读写/快照/grep/glob、`mode`/`onModeChange`/`requestHandover`(多标签页单写者)、turn API |
221
+ | `@dimina-kit/fs-core/agent-tools` | fs_read/fs_write/fs_restore 等 agent 工具面(fs-core 侧 turn 执法) |
222
+ | `@dimina-kit/fs-core/disk-mirror` | File System Access 目录镜像(防抖增量写盘,`pick(handle)` 可注入已授权句柄) |
223
+ | `@dimina-kit/fs-core/sync` | 磁盘↔账本同步引擎(TruthPort 适配外部真相源;`onDegraded` 降级上抛) |
224
+ | `@dimina-kit/fs-core/sync/binary-sidecar` | 二进制侧车:分类(NUL 嗅探)、`{size, sha256}` 索引、echo 判据、可选 bytes 保留 + `overlay()` 合并 |
225
+ | `@dimina-kit/fs-core/sync/watch-expander` | watch 批扩展 helper(stat 级核对,供 TruthPort 适配器组装 `changes`) |
226
+ | `@dimina-kit/fs-core/protocol` | 线上契约:错误码/事件名/消息形状(消费方按符号匹配,不抄 worker 源码字符串;`/client` 亦有 re-export) |
227
+ | `@dimina-kit/fs-core/worker-files` | Node 侧 worker 产物清单:`FS_CORE_WORKER_FILES` + `resolveWorkerFiles()`(ESM/CJS 双形态) |
228
+ | `@dimina-kit/fs-core/zip` | 快照打包为 ZIP |
229
+
43
230
  ## License
44
231
 
45
232
  MIT
package/dist/client.js CHANGED
@@ -1,8 +1,8 @@
1
- /**
2
- * ProjectFsClient 主线程侧 ProjectFS 封装(P0,同 origin)。
3
- * fs-core / fs-query 两个 worker,牵好 core→query diff 端口,
4
- * 暴露 Promise API;写请求自动带 opId,超时重试幂等(同 opId 重发)。
5
- */
1
+ // The wire contract (error codes, event names, message shapes) lives in
2
+ // worker-lib/protocol.tsshared verbatim with the worker's own emit sites —
3
+ // and is re-exported here so consumers can match on symbols instead of
4
+ // quoting string literals from worker source.
5
+ export { FS_CORE_ERROR_CODES, getFsCoreErrorCode, isFsCoreErrorCode } from './worker-lib/protocol.js';
6
6
  const WRITE_TIMEOUT_MS = 8000;
7
7
  export class ProjectFsClient {
8
8
  projectId;
@@ -18,7 +18,6 @@ export class ProjectFsClient {
18
18
  welcome = null;
19
19
  pingTimer;
20
20
  lastPong;
21
- _retried;
22
21
  /** 测试用:抹掉一个项目的全部持久层(只能在无 core 运行时调用)。 */
23
22
  static async wipe(projectId) {
24
23
  const root = await navigator.storage.getDirectory();
@@ -27,6 +26,11 @@ export class ProjectFsClient {
27
26
  }
28
27
  catch { }
29
28
  }
29
+ /** `coreUrl`/`queryUrl` default to the `/ide/fs/` deployment convention of
30
+ * the original dwc host (documented in this package's README「使用」节);
31
+ * every other real host serves the worker files elsewhere and passes both
32
+ * URLs explicitly — see `resolveWorkerFiles` (`./worker-files`) for the
33
+ * authoritative file-name/sibling contract. */
30
34
  static async connect({ projectId, coreUrl = '/ide/fs/fs-core.worker.js', queryUrl = '/ide/fs/fs-query.worker.js', clientId = 'c-' + Math.random().toString(36).slice(2, 10), }) {
31
35
  const c = new ProjectFsClient();
32
36
  c.projectId = projectId;
@@ -132,8 +136,10 @@ export class ProjectFsClient {
132
136
  const entryResolve = resolve;
133
137
  const timer = timeout
134
138
  ? setTimeout(() => {
135
- // 超时重试一次:同 opId 幂等(fs-core 对已知 opId 返回既有结果)
136
- if (opId && !this._retried) {
139
+ // 超时重试一次:同 opId 幂等(fs-core 对已知 opId 返回既有结果);
140
+ // 重试自己的 timer 只会终止性 reject,不会再进这个分支,所以每次
141
+ // 调用恰好一次重试。
142
+ if (opId) {
137
143
  const retryId = ++this.seq;
138
144
  this.pending.set(retryId, { resolve: entryResolve, reject, timer: setTimeout(() => { this.pending.delete(retryId); reject(new Error(op + ' timeout (after retry)')); }, timeout) });
139
145
  this.pending.delete(id);
@@ -166,22 +172,28 @@ export class ProjectFsClient {
166
172
  mv(from, to, opts = {}) { return this._writeOp('mv', { from, to, ...opts }); }
167
173
  mkdir(path, opts = {}) { return this._writeOp('mkdir', { path, ...opts }); }
168
174
  checkpoint(opts = {}) { return this._writeOp('checkpoint', { ...opts }); }
169
- /** opts: {baseGen?, force?} 透传给 fs-core(§4.7 restore 冲突策略);baseGen 缺省时
175
+ /** opts.baseGen/force 透传给 fs-corerestore 冲突策略);baseGen 缺省时
170
176
  * fs-core 从 checkpoint 自身记录的 gen 推导。 */
171
177
  restore(cpId, opts = {}) { return this._writeOp('restore', { cpId, ...opts }); }
172
178
  compact() { return this._rpc('compact', {}, { timeout: 30000 }); }
173
- // ── P4 turn 能力:铸造(附带 checkpoint 锚)→ agent 写执法 → 撤销 ──
179
+ // ── turn 能力:铸造(附带 checkpoint 锚)→ agent 写执法 → 撤销 ──
174
180
  turnBegin(turnId, opts = {}) { return this._writeOp('turnBegin', { turnId, ...opts }); }
175
181
  turnEnd(turnId) { return this._writeOp('turnEnd', { turnId }); }
176
182
  /** 该 turn 的改动清单(WAL actor/turnId 审计标注免费提供)。 */
177
183
  diff(turnId) { return this._rpc('diff', { turnId }); }
178
- /** W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):特权方法,只应
184
+ /** 纵深加固令牌门:特权方法,只应
179
185
  * 由内核(kernel.js createKernel)在 boot 早期调用一次,把只有内核持有的随机令牌交给 fs-core;
180
186
  * 此后 actor:'agent' 的写类 op 必须在 opts 里携带匹配的 agentToken(fs-core 侧强制,见
181
187
  * fs-core.worker.js checkTurn/armAgentToken)。无 opId/超时重试——一次性 admin 调用,非写路径
182
188
  * 幂等本就由 fs-core 的 armAgentToken 自身保证(同令牌重放 ok,不同令牌拒绝)。 */
183
189
  armAgentTokenGate(token) { return this._rpc('armAgentTokenGate', { token }); }
184
190
  // ── 读 API:小读走 core(权威),查询/快照走 query(不占写路径)──
191
+ /** readonly 端主动请求写权交接(协作交接协议,禁 steal):现任写者排干释放后,
192
+ * 本 client 的排队锁请求 granted → mode 经 writer-granted 事件翻转(订阅
193
+ * onModeChange 观察结果)。只有 readonly 会真正行动:writer/dead/starting 上
194
+ * 调用是 no-op(如实返回 {mode}),draining 以错误码 'draining' 拒绝。
195
+ * 何时调用是宿主策略——典型是用户在"另一个标签页持有写权"提示上点"在此接管"。 */
196
+ requestHandover() { return this._rpc('requestHandover', {}); }
185
197
  read(path) { return this._rpc('read', { path }); }
186
198
  ls() { return this._rpc('ls', {}); }
187
199
  status() { return this._rpc('status', {}); }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * 本地磁盘镜像(P5,Chromium only)—— showDirectoryPicker() 授权一个真实磁盘目录,
2
+ * 本地磁盘镜像(Chromium only)—— showDirectoryPicker() 授权一个真实磁盘目录,
3
3
  * 把项目树 write-through 镜像过去:用户在访达/资源管理器里能看到自己的项目。
4
- * 这是"持久化到磁盘"的字面回答(架构文档 §4 出口 1)。
4
+ * 这是"持久化到磁盘"的字面回答。
5
5
  *
6
6
  * 策略:fs-change 防抖 2s 全量比对同步(内容不变的文件跳过 —— 内容比对在内存,
7
7
  * 磁盘只写变更);删除按上次镜像记录清理。授权需要用户手势,无授权时静默不动。
@@ -120,37 +120,97 @@ function epochFloor(replayed) {
120
120
  }
121
121
 
122
122
  // src/fs-core-recovery.ts
123
- async function start(core2, projectId) {
124
- core2.projectId = projectId;
125
- core2.root = await (await navigator.storage.getDirectory()).getDirectoryHandle(projectId, { create: true });
126
- core2.bc = new BroadcastChannel("dwc:" + projectId);
127
- core2.bc.onmessage = (e) => core2.onBroadcast(e.data);
128
- const granted = new Promise((resolve) => {
129
- navigator.locks.request("dwc:writer:" + projectId, { mode: "exclusive" }, (lock) => {
123
+ function queueWriterLock(core2) {
124
+ core2.writerLockQueued = true;
125
+ return new Promise((resolve, reject) => {
126
+ navigator.locks.request("dwc:writer:" + core2.projectId, { mode: "exclusive" }, (lock) => {
127
+ core2.writerLockQueued = false;
128
+ core2.writerLockHeld = true;
130
129
  resolve(lock);
131
130
  return new Promise((release) => {
132
131
  core2.releaseLock = release;
133
132
  });
134
- }).catch(() => resolve(null));
133
+ }).catch((err) => {
134
+ core2.writerLockQueued = false;
135
+ reject(err instanceof Error ? err : new Error(String(err)));
136
+ });
135
137
  });
138
+ }
139
+ function failWriterUpgrade(core2) {
140
+ try {
141
+ core2.sbHandle?.close();
142
+ } catch {
143
+ }
144
+ try {
145
+ core2.walHandle?.close();
146
+ } catch {
147
+ }
148
+ core2.sbHandle = null;
149
+ core2.walHandle = null;
150
+ if (core2.releaseLock) {
151
+ core2.releaseLock();
152
+ core2.releaseLock = null;
153
+ }
154
+ core2.writerLockHeld = false;
155
+ core2.mode = "dead";
156
+ }
157
+ function wireDeferredWriterUpgrade(core2, granted) {
158
+ granted.then(
159
+ async (lock) => {
160
+ if (!lock || core2.mode === "dead") return;
161
+ try {
162
+ await core2.enqueue(async () => {
163
+ await core2.becomeWriter();
164
+ });
165
+ } catch (err) {
166
+ failWriterUpgrade(core2);
167
+ core2.event({ type: "FATAL", error: "writer upgrade failed: " + String(err) });
168
+ }
169
+ },
170
+ (err) => {
171
+ core2.mode = "dead";
172
+ core2.event({ type: "FATAL", error: "writer lock arbitration failed: " + String(err) });
173
+ }
174
+ );
175
+ }
176
+ async function start(core2, projectId) {
177
+ core2.projectId = projectId;
178
+ core2.root = await (await navigator.storage.getDirectory()).getDirectoryHandle(projectId, { create: true });
179
+ core2.bc = new BroadcastChannel("dwc:" + projectId);
180
+ core2.bc.onmessage = (e) => core2.onBroadcast(e.data);
181
+ const granted = queueWriterLock(core2);
136
182
  const winner = await Promise.race([granted, new Promise((r) => setTimeout(() => r("timeout"), 3e3))]);
137
183
  if (winner === "timeout") {
138
184
  await core2.recover();
139
185
  core2.mode = "readonly";
140
186
  core2.pushFullToQuery();
141
187
  core2.welcome();
142
- granted.then(async (lock) => {
143
- if (!lock || core2.mode === "dead") return;
144
- await core2.enqueue(async () => {
145
- await core2.becomeWriter();
146
- });
147
- });
188
+ wireDeferredWriterUpgrade(core2, granted);
148
189
  return;
149
190
  }
150
- await core2.becomeWriter();
191
+ try {
192
+ await core2.becomeWriter();
193
+ } catch (err) {
194
+ failWriterUpgrade(core2);
195
+ throw err;
196
+ }
151
197
  core2.welcome();
152
198
  }
199
+ function requestHandover(core2) {
200
+ if (core2.mode === "writer") return { mode: "writer" };
201
+ if (core2.mode === "draining") throw rpcErr("draining", "writer is handing over");
202
+ if (core2.mode !== "readonly") return { mode: core2.mode };
203
+ if (core2.handoverRequested) return { requested: true };
204
+ if (core2.writerLockHeld) return { requested: true };
205
+ core2.handoverRequested = true;
206
+ if (!core2.writerLockQueued) {
207
+ wireDeferredWriterUpgrade(core2, queueWriterLock(core2));
208
+ }
209
+ core2.bc.postMessage({ type: "handover-request" });
210
+ return { requested: true };
211
+ }
153
212
  async function becomeWriter(core2) {
213
+ core2.handoverRequested = false;
154
214
  await core2.recover();
155
215
  core2.sbHandle = await (await core2.root.getFileHandle("superblock", { create: true })).createSyncAccessHandle();
156
216
  core2.epoch += 1;
@@ -373,6 +433,10 @@ function welcome(core2) {
373
433
  core2.event({ type: "WELCOME", epoch: core2.epoch, memGen: core2.memGen, readonly: core2.mode !== "writer", mode: core2.mode });
374
434
  }
375
435
  async function onBroadcast(core2, msg) {
436
+ if (msg.type === "handover-done" && core2.mode !== "writer") {
437
+ core2.handoverRequested = false;
438
+ return;
439
+ }
376
440
  if (msg.type === "handover-request" && core2.mode === "writer") {
377
441
  core2.mode = "draining";
378
442
  await core2.enqueue(async () => {
@@ -386,6 +450,7 @@ async function onBroadcast(core2, msg) {
386
450
  core2.releaseLock();
387
451
  core2.releaseLock = null;
388
452
  }
453
+ core2.writerLockHeld = false;
389
454
  core2.bc.postMessage({ type: "handover-done", gen: core2.memGen });
390
455
  core2.event({ evt: "writer-lost", gen: core2.memGen });
391
456
  });
@@ -512,7 +577,7 @@ function flushWindow(core2) {
512
577
  let actor = "human";
513
578
  for (const w of core2.windowOps) {
514
579
  core2.ackGen = Math.max(core2.ackGen, w.gen);
515
- if (w.opId) core2.rememberOpId(w.opId, { gen: w.gen });
580
+ if (w.opId) core2.rememberOpId(w.opId, { gen: w.gen, rev: w.gen, ...w.extra });
516
581
  w.respond({ ok: true, result: { gen: w.gen, rev: w.gen, ...w.extra } });
517
582
  if (w.path) paths.push(w.path);
518
583
  if (w.actor === "agent") actor = "agent";
@@ -652,7 +717,7 @@ async function opRestore(core2, { cpId, baseGen, force = false, actor = "human",
652
717
  core2.mirror = next;
653
718
  core2.memGen = core2.walGen;
654
719
  core2.ackGen = gen;
655
- if (opId) core2.rememberOpId(opId, { gen });
720
+ if (opId) core2.rememberOpId(opId, { gen, restored: Object.keys(files).length });
656
721
  core2.pushFullToQuery();
657
722
  respond({ ok: true, result: { gen, restored: Object.keys(files).length } });
658
723
  core2.event({ evt: "fs-change", gen, actor, count: Object.keys(files).length, restore: cpId });
@@ -784,7 +849,7 @@ var FsCore = class {
784
849
  // {turnId, cpId, expiresAt, ops} —— 内存态:worker 重启即失效(安全默认)
785
850
  auditLog = [];
786
851
  // 环形
787
- // W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):只有内核持有的
852
+ // 纵深加固令牌门:只有内核持有的
788
853
  // 随机令牌,一次性置位(armAgentToken)。null = 未 arm(门不生效,checkTurn 不额外校验)——
789
854
  // 保证不起内核的裸 fs 场景(fs 域单测/工具,如 test:fs-smoke/test:fs-wal 直连 client)零回归。
790
855
  // 同 worker 重启即失效(内存态,不落 WAL/持久层),与 this.turn 同一安全默认。
@@ -795,6 +860,15 @@ var FsCore = class {
795
860
  lastSegStart;
796
861
  lastSegValidEnd;
797
862
  manifestCrc;
863
+ // 写者锁排队中(granted/仲裁失败时复位)——requestHandover 据此判断是否需要重新排队
864
+ writerLockQueued = false;
865
+ // 写者锁已持有(granted 一刻置位;排干释放/升级失败清理时复位)。granted 与
866
+ // mode='writer' 之间有异步窗口(recover/开句柄)——requestHandover 据此避免
867
+ // 在升级在途时再排一个陈旧锁请求
868
+ writerLockHeld = false;
869
+ // 交接请求合并标志:一个 pending 周期内重复 requestHandover 不追加锁请求/不重复广播;
870
+ // becomeWriter(自己赢了)或 handover-done 广播(别人赢了)复位
871
+ handoverRequested = false;
798
872
  // ── 启动/恢复/只读查询(fs-core-recovery.ts) ──
799
873
  start(projectId) {
800
874
  return start(this, projectId);
@@ -844,6 +918,9 @@ var FsCore = class {
844
918
  onBroadcast(msg) {
845
919
  return onBroadcast(this, msg);
846
920
  }
921
+ requestHandover() {
922
+ return requestHandover(this);
923
+ }
847
924
  // ── 写路径/compaction(fs-core-write-ops.ts) ──
848
925
  enqueue(fn) {
849
926
  return enqueue(this, fn);
@@ -928,7 +1005,7 @@ var FsCore = class {
928
1005
  const fail = (e) => respond({ ok: false, code: e.code || "internal", error: e.message || String(e), ...e.extra || {} });
929
1006
  if (msg.opId && this.opIds.has(msg.opId)) {
930
1007
  const known = this.opIds.get(msg.opId);
931
- respond({ ok: true, result: { ...known, rev: known.gen, idempotent: true } });
1008
+ respond({ ok: true, result: { rev: known.gen, ...known, idempotent: true } });
932
1009
  return;
933
1010
  }
934
1011
  const a = { ...msg.args, opId: msg.opId };
@@ -950,9 +1027,12 @@ var FsCore = class {
950
1027
  read: () => respond({ ok: true, result: this.opRead(a) }),
951
1028
  ls: () => respond({ ok: true, result: this.opLs() }),
952
1029
  status: () => respond({ ok: true, result: this.opStatus() }),
953
- // W4 令牌门铸造(§6 替代方案 A / §8.3):纯内存状态置位,无 I/O、不让出,
1030
+ // 令牌门铸造:纯内存状态置位,无 I/O、不让出,
954
1031
  // 不入 WAL 序列化链——同步分支即可(同 read/ls/status),不打扰组提交/恢复逻辑。
955
- armAgentTokenGate: () => respond({ ok: true, result: this.armAgentToken(a.token) })
1032
+ armAgentTokenGate: () => respond({ ok: true, result: this.armAgentToken(a.token) }),
1033
+ // 协作交接发起(readonly 端):广播 + 必要时重新排队锁,纯内存/信道操作,
1034
+ // 不碰 WAL——同步分支(升级本身走 becomeWriter 的 enqueue 路径)。
1035
+ requestHandover: () => respond({ ok: true, result: this.requestHandover() })
956
1036
  };
957
1037
  const fn = table[msg.op];
958
1038
  if (!fn) {