@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.
- package/README.md +205 -18
- package/dist/client.js +23 -11
- package/dist/disk-mirror.js +2 -2
- package/dist/fs-core.worker.js +101 -21
- package/dist/sync/binary-sidecar.js +147 -0
- package/dist/sync/sync-engine.js +119 -169
- package/dist/sync/watch-expander.js +202 -0
- package/dist/worker-files.cjs +32 -0
- package/dist/worker-files.js +35 -0
- package/dist/worker-lib/.tsbuildinfo +1 -0
- package/dist/worker-lib/engine-shared.d.ts +79 -0
- package/dist/worker-lib/engine-shared.d.ts.map +1 -0
- package/dist/worker-lib/engine-shared.js +6 -3
- package/dist/worker-lib/paths.d.ts +4 -0
- package/dist/worker-lib/paths.d.ts.map +1 -0
- package/dist/worker-lib/protocol.d.ts +119 -0
- package/dist/worker-lib/protocol.d.ts.map +1 -0
- package/dist/worker-lib/protocol.js +73 -0
- package/dist/worker-lib/rpc-types.d.ts +68 -0
- package/dist/worker-lib/rpc-types.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.d.ts +58 -0
- package/dist/worker-lib/wal-codec.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.js +8 -5
- package/dist/zip.js +1 -1
- package/package.json +25 -2
- package/src/__checks__/types-smoke.ts +61 -0
- package/src/agent-tools.ts +3 -3
- package/src/client-retry.test.ts +76 -0
- package/src/client.ts +112 -45
- package/src/disk-mirror.ts +2 -2
- package/src/fs-core-handover.test.ts +215 -0
- package/src/fs-core-opid-replay.test.ts +158 -0
- package/src/fs-core-recovery-lock.test.ts +225 -0
- package/src/fs-core-recovery.ts +115 -13
- package/src/fs-core-write-ops.ts +14 -11
- package/src/fs-core.worker.ts +26 -11
- package/src/worker-files.test.ts +39 -0
- package/src/worker-files.ts +43 -0
- package/src/worker-lib/engine-shared.ts +19 -5
- package/src/worker-lib/protocol.test.ts +37 -0
- package/src/worker-lib/protocol.ts +184 -0
- package/src/worker-lib/wal-codec.ts +8 -5
- package/src/zip.ts +1 -1
- package/sync/binary-sidecar.test.ts +150 -0
- package/sync/binary-sidecar.ts +187 -0
- package/sync/sync-engine-degraded.test.ts +309 -0
- package/sync/sync-engine.test.ts +20 -91
- package/sync/sync-engine.ts +134 -181
- package/sync/truth-port.ts +3 -3
- package/sync/watch-expander.test.ts +96 -0
- package/sync/watch-expander.ts +236 -0
package/src/client.ts
CHANGED
|
@@ -1,40 +1,94 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ProjectFsClient — 主线程侧 ProjectFS
|
|
2
|
+
* ProjectFsClient — 主线程侧 ProjectFS 封装(同 origin)。
|
|
3
3
|
* 起 fs-core / fs-query 两个 worker,牵好 core→query 的 diff 端口,
|
|
4
4
|
* 暴露 Promise API;写请求自动带 opId,超时重试幂等(同 opId 重发)。
|
|
5
5
|
*/
|
|
6
|
-
|
|
6
|
+
import type { CoreMessage, FsCoreMode } from './worker-lib/protocol.js'
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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:
|
|
212
|
-
edit(path: string, old: string, next: string, opts:
|
|
213
|
-
rm(path: string, opts:
|
|
214
|
-
mv(from: string, to: string, opts:
|
|
215
|
-
mkdir(path: string, opts:
|
|
216
|
-
checkpoint(opts:
|
|
217
|
-
/** opts
|
|
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-core(restore 冲突策略);baseGen 缺省时
|
|
218
278
|
* fs-core 从 checkpoint 自身记录的 gen 推导。 */
|
|
219
|
-
restore(cpId: string, opts:
|
|
220
|
-
compact(): Promise<
|
|
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
|
-
// ──
|
|
223
|
-
turnBegin(turnId: string, opts:
|
|
224
|
-
turnEnd(turnId: string): Promise<
|
|
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
|
-
/**
|
|
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', {}) }
|
package/src/disk-mirror.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 本地磁盘镜像(
|
|
2
|
+
* 本地磁盘镜像(Chromium only)—— showDirectoryPicker() 授权一个真实磁盘目录,
|
|
3
3
|
* 把项目树 write-through 镜像过去:用户在访达/资源管理器里能看到自己的项目。
|
|
4
|
-
* 这是"持久化到磁盘"
|
|
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
|
+
})
|