@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/src/client.ts CHANGED
@@ -1,40 +1,94 @@
1
1
  /**
2
- * ProjectFsClient — 主线程侧 ProjectFS 封装(P0,同 origin)。
2
+ * ProjectFsClient — 主线程侧 ProjectFS 封装(同 origin)。
3
3
  * 起 fs-core / fs-query 两个 worker,牵好 core→query 的 diff 端口,
4
4
  * 暴露 Promise API;写请求自动带 opId,超时重试幂等(同 opId 重发)。
5
5
  */
6
- const WRITE_TIMEOUT_MS = 8000
6
+ import type { CoreMessage, FsCoreMode } from './worker-lib/protocol.js'
7
7
 
8
- type Mode = 'starting' | 'writer' | 'readonly' | 'draining' | 'dead'
8
+ // The wire contract (error codes, event names, message shapes) lives in
9
+ // worker-lib/protocol.ts — shared verbatim with the worker's own emit sites —
10
+ // and is re-exported here so consumers can match on symbols instead of
11
+ // quoting string literals from worker source.
12
+ export { FS_CORE_ERROR_CODES, getFsCoreErrorCode, isFsCoreErrorCode } from './worker-lib/protocol.js'
13
+ export type {
14
+ CoreMessage, CoreWireMessage, FsCoreErrorCode, FsCoreErrorExtras, FsCoreEventName, FsCoreMode,
15
+ } from './worker-lib/protocol.js'
9
16
 
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
17
+ // ── Write-API opts/results — the client-side mirror of the worker RPC args
18
+ // in worker-lib/rpc-types.ts, narrowed to what a caller actually supplies
19
+ // (the worker also accepts `opId`, but callers never set it directly —
20
+ // `_writeOp` stamps one via `crypto.randomUUID()` for the idempotent-retry
21
+ // contract). `actor` is a literal union here (rpc-types.ts's `WriteArgs` etc.
22
+ // use a plain `string` because the worker only validates it at runtime) so
23
+ // that a typo'd actor is a compile error at the call site.
24
+ export interface FsWriteCallOpts {
25
+ actor?: 'human' | 'agent'
26
+ turnId?: string
27
+ agentToken?: string
28
+ ifMatch?: number | null
29
+ }
30
+
31
+ export interface FsCheckpointOpts {
32
+ actor?: 'human' | 'agent'
33
+ turnId?: string
34
+ agentToken?: string
35
+ }
36
+
37
+ export interface FsRestoreOpts extends FsCheckpointOpts {
38
+ baseGen?: number
39
+ force?: boolean
36
40
  }
37
41
 
42
+ export interface FsTurnBeginOpts {
43
+ ttlMs?: number
44
+ }
45
+
46
+ export interface FsWriteResult {
47
+ gen: number
48
+ rev: number
49
+ idempotent?: boolean
50
+ }
51
+
52
+ export interface FsCheckpointResult extends FsWriteResult {
53
+ cpId: string
54
+ }
55
+
56
+ export interface FsTurnBeginResult extends FsWriteResult {
57
+ turnId: string
58
+ cpId: string
59
+ expiresAt: number
60
+ }
61
+
62
+ export interface FsTurnEndResult {
63
+ turnId: string
64
+ closed: boolean
65
+ ops: number
66
+ }
67
+
68
+ /** opRestore's respond replaces the mirror wholesale rather than appending to
69
+ * it, so it carries no `rev` — see fs-core-write-ops.ts's `opRestore`. */
70
+ export interface FsRestoreResult {
71
+ gen: number
72
+ restored: number
73
+ }
74
+
75
+ /** Directories are implicit (no tracked entry), so mkdir's respond carries
76
+ * only `gen`, no `rev` — see fs-core.worker.ts's `mkdir` dispatch. */
77
+ export interface FsMkdirResult {
78
+ gen: number
79
+ }
80
+
81
+ /** compactNow's return shape (fs-core.worker.ts) — `skipped` when the
82
+ * active segment was already below the rotation threshold. */
83
+ export interface FsCompactResult {
84
+ gen: number
85
+ skipped?: boolean
86
+ }
87
+
88
+ const WRITE_TIMEOUT_MS = 8000
89
+
90
+ type Mode = FsCoreMode
91
+
38
92
  interface PendingEntry {
39
93
  resolve: (v: unknown) => void
40
94
  reject: (e: unknown) => void
@@ -80,7 +134,6 @@ export class ProjectFsClient {
80
134
  welcome: CoreMessage | null = null
81
135
  pingTimer!: ReturnType<typeof setInterval>
82
136
  lastPong!: number
83
- private _retried?: boolean
84
137
 
85
138
  /** 测试用:抹掉一个项目的全部持久层(只能在无 core 运行时调用)。 */
86
139
  static async wipe(projectId: string): Promise<void> {
@@ -88,6 +141,11 @@ export class ProjectFsClient {
88
141
  try { await root.removeEntry(projectId, { recursive: true }) } catch {}
89
142
  }
90
143
 
144
+ /** `coreUrl`/`queryUrl` default to the `/ide/fs/` deployment convention of
145
+ * the original dwc host (documented in this package's README「使用」节);
146
+ * every other real host serves the worker files elsewhere and passes both
147
+ * URLs explicitly — see `resolveWorkerFiles` (`./worker-files`) for the
148
+ * authoritative file-name/sibling contract. */
91
149
  static async connect({
92
150
  projectId,
93
151
  coreUrl = '/ide/fs/fs-core.worker.js',
@@ -178,8 +236,10 @@ export class ProjectFsClient {
178
236
  const entryResolve = resolve as (v: unknown) => void
179
237
  const timer = timeout
180
238
  ? setTimeout(() => {
181
- // 超时重试一次:同 opId 幂等(fs-core 对已知 opId 返回既有结果)
182
- if (opId && !this._retried) {
239
+ // 超时重试一次:同 opId 幂等(fs-core 对已知 opId 返回既有结果);
240
+ // 重试自己的 timer 只会终止性 reject,不会再进这个分支,所以每次
241
+ // 调用恰好一次重试。
242
+ if (opId) {
183
243
  const retryId = ++this.seq
184
244
  this.pending.set(retryId, { resolve: entryResolve, reject, timer: setTimeout(() => { this.pending.delete(retryId); reject(new Error(op + ' timeout (after retry)')) }, timeout) })
185
245
  this.pending.delete(id)
@@ -208,24 +268,24 @@ export class ProjectFsClient {
208
268
  }
209
269
 
210
270
  // ── 写 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 缺省时
271
+ write(path: string, content: string, opts: FsWriteCallOpts = {}): Promise<FsWriteResult> { return this._writeOp<FsWriteResult>('write', { path, content, ...opts }) }
272
+ edit(path: string, old: string, next: string, opts: FsWriteCallOpts = {}): Promise<FsWriteResult> { return this._writeOp<FsWriteResult>('edit', { path, old, next, ...opts }) }
273
+ rm(path: string, opts: FsWriteCallOpts = {}): Promise<FsWriteResult> { return this._writeOp<FsWriteResult>('rm', { path, ...opts }) }
274
+ mv(from: string, to: string, opts: FsWriteCallOpts = {}): Promise<FsWriteResult> { return this._writeOp<FsWriteResult>('mv', { from, to, ...opts }) }
275
+ mkdir(path: string, opts: FsWriteCallOpts = {}): Promise<FsMkdirResult> { return this._writeOp<FsMkdirResult>('mkdir', { path, ...opts }) }
276
+ checkpoint(opts: FsCheckpointOpts = {}): Promise<FsCheckpointResult> { return this._writeOp<FsCheckpointResult>('checkpoint', { ...opts }) }
277
+ /** opts.baseGen/force 透传给 fs-corerestore 冲突策略);baseGen 缺省时
218
278
  * 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 }) }
279
+ restore(cpId: string, opts: FsRestoreOpts = {}): Promise<FsRestoreResult> { return this._writeOp<FsRestoreResult>('restore', { cpId, ...opts }) }
280
+ compact(): Promise<FsCompactResult> { return this._rpc<FsCompactResult>('compact', {}, { timeout: 30000 }) }
221
281
 
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 }) }
282
+ // ── turn 能力:铸造(附带 checkpoint 锚)→ agent 写执法 → 撤销 ──
283
+ turnBegin(turnId: string, opts: FsTurnBeginOpts = {}): Promise<FsTurnBeginResult> { return this._writeOp<FsTurnBeginResult>('turnBegin', { turnId, ...opts }) }
284
+ turnEnd(turnId: string): Promise<FsTurnEndResult> { return this._writeOp<FsTurnEndResult>('turnEnd', { turnId }) }
225
285
  /** 该 turn 的改动清单(WAL actor/turnId 审计标注免费提供)。 */
226
286
  diff(turnId?: string): Promise<DiffResult> { return this._rpc<DiffResult>('diff', { turnId }) }
227
287
 
228
- /** W4 纵深加固令牌门(docs/k3-terminal-split-plan.md §6 替代方案 A / §8.3):特权方法,只应
288
+ /** 纵深加固令牌门:特权方法,只应
229
289
  * 由内核(kernel.js createKernel)在 boot 早期调用一次,把只有内核持有的随机令牌交给 fs-core;
230
290
  * 此后 actor:'agent' 的写类 op 必须在 opts 里携带匹配的 agentToken(fs-core 侧强制,见
231
291
  * fs-core.worker.js checkTurn/armAgentToken)。无 opId/超时重试——一次性 admin 调用,非写路径
@@ -233,6 +293,13 @@ export class ProjectFsClient {
233
293
  armAgentTokenGate(token: string): Promise<{ armed: boolean; idempotent?: boolean }> { return this._rpc('armAgentTokenGate', { token }) }
234
294
 
235
295
  // ── 读 API:小读走 core(权威),查询/快照走 query(不占写路径)──
296
+ /** readonly 端主动请求写权交接(协作交接协议,禁 steal):现任写者排干释放后,
297
+ * 本 client 的排队锁请求 granted → mode 经 writer-granted 事件翻转(订阅
298
+ * onModeChange 观察结果)。只有 readonly 会真正行动:writer/dead/starting 上
299
+ * 调用是 no-op(如实返回 {mode}),draining 以错误码 'draining' 拒绝。
300
+ * 何时调用是宿主策略——典型是用户在"另一个标签页持有写权"提示上点"在此接管"。 */
301
+ requestHandover(): Promise<{ requested?: boolean; mode?: FsCoreMode }> { return this._rpc('requestHandover', {}) }
302
+
236
303
  read(path: string): Promise<{ content: string; rev?: number; gen: number }> { return this._rpc('read', { path }) }
237
304
  ls(): Promise<{ paths: string[]; gen: number }> { return this._rpc('ls', {}) }
238
305
  status(): Promise<StatusResult> { return this._rpc<StatusResult>('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
  * 磁盘只写变更);删除按上次镜像记录清理。授权需要用户手势,无授权时静默不动。
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Guards `requestHandover(core)` — the readonly-side counterpart to the
3
+ * writer-side handover in `onBroadcast` (fs-core-recovery.ts): a readonly
4
+ * client actively asking the current writer to hand off the lease, instead
5
+ * of only ever passively upgrading once a queued `navigator.locks` request
6
+ * happens to land. Also guards the client-facing `ProjectFsClient.requestHandover()`
7
+ * wrapper that posts the RPC.
8
+ *
9
+ * `core` is a structural fake — only the fields `requestHandover`/`onBroadcast`
10
+ * touch — narrowed through `as unknown as FsCore`, matching the pattern in
11
+ * client.test.ts/fs-core-recovery-lock.test.ts (no `as any`/`@ts-expect-error`).
12
+ */
13
+ import { afterEach, describe, expect, it, vi } from 'vitest'
14
+ import { onBroadcast, requestHandover } from './fs-core-recovery.js'
15
+ import type { FsCore } from './fs-core.worker.js'
16
+ import { isFsCoreErrorCode, type CoreMessage, type FsCoreMode } from './worker-lib/protocol.js'
17
+ import { ProjectFsClient } from './client.js'
18
+
19
+ type LockRequestFn = (name: string, opts: unknown, cb: (lock: unknown) => Promise<void>) => Promise<unknown>
20
+
21
+ interface FakeCore {
22
+ mode: FsCoreMode
23
+ bc: { postMessage: (msg: unknown) => void }
24
+ writerLockQueued: boolean
25
+ writerLockHeld: boolean
26
+ handoverRequested: boolean
27
+ becomeWriter: () => Promise<void>
28
+ enqueue: (fn: () => unknown) => Promise<unknown>
29
+ }
30
+
31
+ function makeCore(overrides: Partial<FakeCore> = {}): FakeCore {
32
+ return {
33
+ mode: 'readonly',
34
+ bc: { postMessage: vi.fn() },
35
+ writerLockQueued: false,
36
+ writerLockHeld: false,
37
+ handoverRequested: false,
38
+ becomeWriter: vi.fn(async () => {}),
39
+ enqueue: vi.fn((fn: () => unknown) => Promise.resolve(fn())),
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ describe('fs-core-recovery requestHandover() — readonly-initiated handover request', () => {
45
+ afterEach(() => {
46
+ vi.unstubAllGlobals()
47
+ })
48
+
49
+ it('mode writer returns {mode:\'writer\'} immediately — no broadcast, no lock request', async () => {
50
+ const request: LockRequestFn = vi.fn()
51
+ vi.stubGlobal('navigator', { locks: { request } })
52
+ const core = makeCore({ mode: 'writer' })
53
+
54
+ const result = await requestHandover(core as unknown as FsCore)
55
+
56
+ expect(result).toEqual({ mode: 'writer' })
57
+ expect(core.bc.postMessage).not.toHaveBeenCalled()
58
+ expect(request).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('mode draining rejects with the draining error code', async () => {
62
+ const core = makeCore({ mode: 'draining' })
63
+ let caught: unknown
64
+ try {
65
+ await requestHandover(core as unknown as FsCore)
66
+ } catch (e) {
67
+ caught = e
68
+ }
69
+ expect(isFsCoreErrorCode(caught, 'draining')).toBe(true)
70
+ })
71
+
72
+ it('mode dead returns {mode:\'dead\'} without broadcasting or requesting a lock', async () => {
73
+ // A FATAL'd worker cannot upgrade even if the lease later lands (the
74
+ // deferred writer upgrade bails on dead), so broadcasting
75
+ // handover-request from it would only drain a healthy writer for nothing.
76
+ const request: LockRequestFn = vi.fn()
77
+ vi.stubGlobal('navigator', { locks: { request } })
78
+ const core = makeCore({ mode: 'dead' })
79
+
80
+ const result = await requestHandover(core as unknown as FsCore)
81
+
82
+ expect(result).toEqual({ mode: 'dead' })
83
+ expect(core.bc.postMessage).not.toHaveBeenCalled()
84
+ expect(request).not.toHaveBeenCalled()
85
+ expect(core.handoverRequested).toBe(false)
86
+ })
87
+
88
+ it('mode starting returns {mode:\'starting\'} without broadcasting or requesting a lock', async () => {
89
+ // start()'s own lock arbitration is still in flight — acting here would
90
+ // race it with a second queued lock request and a premature broadcast.
91
+ const request: LockRequestFn = vi.fn()
92
+ vi.stubGlobal('navigator', { locks: { request } })
93
+ const core = makeCore({ mode: 'starting' })
94
+
95
+ const result = await requestHandover(core as unknown as FsCore)
96
+
97
+ expect(result).toEqual({ mode: 'starting' })
98
+ expect(core.bc.postMessage).not.toHaveBeenCalled()
99
+ expect(request).not.toHaveBeenCalled()
100
+ expect(core.handoverRequested).toBe(false)
101
+ })
102
+
103
+ it('mode readonly with a lock already queued rebroadcasts without requesting a second lock', async () => {
104
+ const request: LockRequestFn = vi.fn()
105
+ vi.stubGlobal('navigator', { locks: { request } })
106
+ const core = makeCore({ mode: 'readonly', writerLockQueued: true })
107
+
108
+ const result = await requestHandover(core as unknown as FsCore)
109
+
110
+ expect(result).toEqual({ requested: true })
111
+ expect(core.bc.postMessage).toHaveBeenCalledTimes(1)
112
+ expect(core.bc.postMessage).toHaveBeenCalledWith({ type: 'handover-request' })
113
+ expect(request).not.toHaveBeenCalled()
114
+ })
115
+
116
+ it('mode readonly with the lock already granted (upgrade in flight) neither re-queues nor broadcasts', async () => {
117
+ // Window between lock grant and becomeWriter completion: the lock callback
118
+ // clears writerLockQueued at grant time, but mode stays 'readonly' until
119
+ // the async recover finishes. Acting here would queue a stale second lock
120
+ // request (which could later grab the lease and corrupt the handover) and
121
+ // broadcast a pointless drain to the outgoing writer.
122
+ const request: LockRequestFn = vi.fn()
123
+ vi.stubGlobal('navigator', { locks: { request } })
124
+ const core = makeCore({ mode: 'readonly', writerLockQueued: false, writerLockHeld: true })
125
+
126
+ const result = await requestHandover(core as unknown as FsCore)
127
+
128
+ expect(result).toEqual({ requested: true })
129
+ expect(request).not.toHaveBeenCalled()
130
+ expect(core.bc.postMessage).not.toHaveBeenCalled()
131
+ expect(core.handoverRequested).toBe(false)
132
+ })
133
+
134
+ it('mode readonly with no lock queued issues a fresh writer-lock request and upgrades once granted', async () => {
135
+ let grantCb!: (lock: unknown) => Promise<void>
136
+ const request: LockRequestFn = vi.fn((_name, _opts, cb) => {
137
+ grantCb = cb
138
+ return new Promise(() => {})
139
+ })
140
+ vi.stubGlobal('navigator', { locks: { request } })
141
+ const core = makeCore({ mode: 'readonly', writerLockQueued: false })
142
+
143
+ const result = await requestHandover(core as unknown as FsCore)
144
+
145
+ expect(result).toEqual({ requested: true })
146
+ expect(request).toHaveBeenCalledTimes(1)
147
+ expect(core.bc.postMessage).toHaveBeenCalledWith({ type: 'handover-request' })
148
+ expect(core.becomeWriter).not.toHaveBeenCalled()
149
+
150
+ grantCb({})
151
+ await Promise.resolve()
152
+ await Promise.resolve()
153
+
154
+ expect(core.enqueue).toHaveBeenCalled()
155
+ expect(core.becomeWriter).toHaveBeenCalledTimes(1)
156
+ })
157
+
158
+ it('a repeated call within the same pending cycle merges: no extra lock request or broadcast', async () => {
159
+ const request: LockRequestFn = vi.fn(() => new Promise(() => {}))
160
+ vi.stubGlobal('navigator', { locks: { request } })
161
+ const core = makeCore({ mode: 'readonly', writerLockQueued: false })
162
+
163
+ const first = await requestHandover(core as unknown as FsCore)
164
+ const second = await requestHandover(core as unknown as FsCore)
165
+
166
+ expect(first).toEqual({ requested: true })
167
+ expect(second).toEqual({ requested: true })
168
+ expect(request).toHaveBeenCalledTimes(1)
169
+ expect(core.bc.postMessage).toHaveBeenCalledTimes(1)
170
+ })
171
+
172
+ it('a handover-done broadcast clears the merge flag so a later requestHandover broadcasts again', async () => {
173
+ const request: LockRequestFn = vi.fn(() => new Promise(() => {}))
174
+ vi.stubGlobal('navigator', { locks: { request } })
175
+ const core = makeCore({ mode: 'readonly', writerLockQueued: false })
176
+
177
+ await requestHandover(core as unknown as FsCore) // 1st: broadcasts, marks the pending cycle
178
+ await onBroadcast(core as unknown as FsCore, { type: 'handover-done' })
179
+ await requestHandover(core as unknown as FsCore) // merge flag reset — broadcasts again
180
+
181
+ expect(core.bc.postMessage).toHaveBeenCalledTimes(2)
182
+ })
183
+ })
184
+
185
+ type ClientInternals = Omit<ProjectFsClient, 'core'> & {
186
+ pending: Map<number, unknown>
187
+ seq: number
188
+ core: { postMessage: (msg: unknown) => void }
189
+ _onCoreMessage(msg: CoreMessage, resolveWelcome?: (w: CoreMessage) => void, rejectWelcome?: (e: Error) => void): void
190
+ requestHandover(): Promise<unknown>
191
+ }
192
+
193
+ function makeBareRpcClient(): { client: ClientInternals; posted: Array<{ id: number; op: string }> } {
194
+ const posted: Array<{ id: number; op: string }> = []
195
+ const c = new ProjectFsClient() as unknown as ClientInternals
196
+ c.pending = new Map()
197
+ c.seq = 0
198
+ c.core = { postMessage: (msg) => posted.push(msg as { id: number; op: string }) }
199
+ return { client: c, posted }
200
+ }
201
+
202
+ describe('ProjectFsClient.requestHandover', () => {
203
+ it('posts a requestHandover op to the core worker and resolves via the RPC reply', async () => {
204
+ const { client, posted } = makeBareRpcClient()
205
+
206
+ const p = client.requestHandover()
207
+
208
+ expect(posted).toHaveLength(1)
209
+ expect(posted[0]!.op).toBe('requestHandover')
210
+
211
+ client._onCoreMessage({ id: posted[0]!.id, ok: true, result: { requested: true } } as CoreMessage)
212
+
213
+ await expect(p).resolves.toEqual({ requested: true })
214
+ })
215
+ })
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Guards the opId idempotent-replay cache's payload completeness: when a
3
+ * timed-out client retries with the same opId, handleRpc (fs-core.worker.ts)
4
+ * answers from `core.opIds` instead of re-executing — so the cached entry
5
+ * must carry the FULL result the live path responded with. A cache that only
6
+ * stores `{gen}` makes the replay lose checkpoint's cpId, turnBegin's
7
+ * turnId/cpId/expiresAt, and restore's restored count, breaking the client's
8
+ * typed result contract (client.ts declares those fields as always present).
9
+ *
10
+ * Recovery-rebuilt entries legitimately hold only `{gen}` (WAL records do not
11
+ * persist in-memory extras) — that cross-session degradation is documented at
12
+ * the implementation, not tested here; these tests pin the LIVE path only.
13
+ *
14
+ * `core` fakes are structural (only the fields flushWindow/opRestore touch),
15
+ * narrowed via `as unknown as FsCore`; `rememberOpId` is the REAL
16
+ * implementation bound to the fake so assertions read the actual cache Map.
17
+ */
18
+ import { describe, expect, it, vi } from 'vitest'
19
+ import * as writeOps from './fs-core-write-ops.js'
20
+ import type { FsCore } from './fs-core.worker.js'
21
+ import type { Respond, WindowOp } from './worker-lib/engine-shared.js'
22
+
23
+ interface FakeFlushCore {
24
+ flushTimer: ReturnType<typeof setTimeout> | null
25
+ windowOps: WindowOp[]
26
+ walHandle: { flush: () => void }
27
+ walGen: number
28
+ appendedGen: number
29
+ memGen: number
30
+ ackGen: number
31
+ staged: Map<string, unknown>
32
+ mirror: Map<string, unknown>
33
+ opIds: Map<string, { gen: number }>
34
+ pushDiff: (diff: Record<string, unknown>, gen: number) => void
35
+ rememberOpId: (opId: string, v: { gen: number }) => void
36
+ event: (e: unknown) => void
37
+ bc: { postMessage: (msg: unknown) => void }
38
+ }
39
+
40
+ function makeFlushCore(windowOps: WindowOp[]): FakeFlushCore {
41
+ const core: FakeFlushCore = {
42
+ flushTimer: null,
43
+ windowOps,
44
+ walHandle: { flush: vi.fn() },
45
+ walGen: 0,
46
+ appendedGen: Math.max(0, ...windowOps.map((w) => w.gen)),
47
+ memGen: 0,
48
+ ackGen: 0,
49
+ staged: new Map(),
50
+ mirror: new Map(),
51
+ opIds: new Map(),
52
+ pushDiff: vi.fn(),
53
+ rememberOpId: (opId, v) => writeOps.rememberOpId(core as unknown as FsCore, opId, v),
54
+ event: vi.fn(),
55
+ bc: { postMessage: vi.fn() },
56
+ }
57
+ return core
58
+ }
59
+
60
+ describe('flushWindow — opId replay cache stores the full respond result', () => {
61
+ it('caches a checkpoint windowOp as {gen, rev, cpId}, matching what the live respond delivered', () => {
62
+ const respond = vi.fn()
63
+ const core = makeFlushCore([{ respond, gen: 3, actor: 'human', opId: 'op-c1', extra: { cpId: 'c1' } }])
64
+
65
+ writeOps.flushWindow(core as unknown as FsCore)
66
+
67
+ expect(respond).toHaveBeenCalledWith({ ok: true, result: { gen: 3, rev: 3, cpId: 'c1' } })
68
+ // A retry replay answers from this entry verbatim — a {gen}-only cache
69
+ // would hand the client a checkpoint result with no cpId.
70
+ expect(core.opIds.get('op-c1')).toEqual({ gen: 3, rev: 3, cpId: 'c1' })
71
+ })
72
+
73
+ it('caches a turnBegin windowOp as {gen, rev, turnId, cpId, expiresAt}', () => {
74
+ const respond = vi.fn()
75
+ const extra = { turnId: 't-1', cpId: 'cp-9', expiresAt: 4200 }
76
+ const core = makeFlushCore([{ respond, gen: 6, actor: 'agent', opId: 'op-t1', extra }])
77
+
78
+ writeOps.flushWindow(core as unknown as FsCore)
79
+
80
+ expect(respond).toHaveBeenCalledWith({ ok: true, result: { gen: 6, rev: 6, ...extra } })
81
+ expect(core.opIds.get('op-t1')).toEqual({ gen: 6, rev: 6, ...extra })
82
+ })
83
+
84
+ it('caches a plain write windowOp (no extra) as {gen, rev}', () => {
85
+ const respond = vi.fn()
86
+ const core = makeFlushCore([{ respond, gen: 4, path: 'a.txt', actor: 'human', opId: 'op-w1' }])
87
+
88
+ writeOps.flushWindow(core as unknown as FsCore)
89
+
90
+ expect(respond).toHaveBeenCalledWith({ ok: true, result: { gen: 4, rev: 4 } })
91
+ expect(core.opIds.get('op-w1')).toEqual({ gen: 4, rev: 4 })
92
+ })
93
+ })
94
+
95
+ interface FakeRestoreCore {
96
+ mode: string
97
+ flushWindow: () => void
98
+ rotateIfNeeded: () => Promise<void>
99
+ checkpoints: Map<string, { h: string; gen: number }>
100
+ readBlob: (h: string) => Promise<string>
101
+ appendSync: (opcode: number, meta: Record<string, unknown>, checks?: () => void) => number
102
+ checkTurn: (actor: string | undefined, turnId: string | undefined, agentToken: string | undefined) => void
103
+ checkRestoreConflict: (baseGen: number) => void
104
+ walHandle: { flush: () => void }
105
+ walGen: number
106
+ memGen: number
107
+ ackGen: number
108
+ mirror: Map<string, unknown>
109
+ opIds: Map<string, { gen: number }>
110
+ rememberOpId: (opId: string, v: { gen: number }) => void
111
+ pushFullToQuery: () => void
112
+ event: (e: unknown) => void
113
+ bc: { postMessage: (msg: unknown) => void }
114
+ }
115
+
116
+ /** Two files in the checkpoint manifest — opRestore's live respond carries
117
+ * restored: 2, and the replay cache must preserve it. */
118
+ function makeRestoreCore(): FakeRestoreCore {
119
+ const manifest: Record<string, string> = { 'a.txt': 'h-a', 'b.txt': 'h-b' }
120
+ const core: FakeRestoreCore = {
121
+ mode: 'writer',
122
+ flushWindow: vi.fn(),
123
+ rotateIfNeeded: vi.fn(async () => {}),
124
+ checkpoints: new Map([['cp-1', { h: 'h-manifest', gen: 5 }]]),
125
+ readBlob: vi.fn(async (h: string) => (h === 'h-manifest' ? JSON.stringify(manifest) : 'content-of-' + h)),
126
+ appendSync: vi.fn((_opcode: number, _meta: Record<string, unknown>, checks?: () => void) => {
127
+ checks?.()
128
+ return 7
129
+ }),
130
+ checkTurn: vi.fn(),
131
+ checkRestoreConflict: vi.fn(),
132
+ walHandle: { flush: vi.fn() },
133
+ walGen: 0,
134
+ memGen: 0,
135
+ ackGen: 0,
136
+ mirror: new Map(),
137
+ opIds: new Map(),
138
+ rememberOpId: (opId, v) => writeOps.rememberOpId(core as unknown as FsCore, opId, v),
139
+ pushFullToQuery: vi.fn(),
140
+ event: vi.fn(),
141
+ bc: { postMessage: vi.fn() },
142
+ }
143
+ return core
144
+ }
145
+
146
+ describe('opRestore — opId replay cache stores the full respond result', () => {
147
+ it('caches a restore as {gen, restored}, matching what the live respond delivered', async () => {
148
+ const core = makeRestoreCore()
149
+ const respond: Respond = vi.fn()
150
+
151
+ await writeOps.opRestore(core as unknown as FsCore, { cpId: 'cp-1', actor: 'human', opId: 'op-r1' }, respond)
152
+
153
+ expect(respond).toHaveBeenCalledWith({ ok: true, result: { gen: 7, restored: 2 } })
154
+ // A retry replay answers from this entry verbatim — a {gen}-only cache
155
+ // would hand the client a restore result with no restored count.
156
+ expect(core.opIds.get('op-r1')).toEqual({ gen: 7, restored: 2 })
157
+ })
158
+ })