@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/dist/zip.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * store-only ZIP 打包(P5 导出出口)—— 无压缩、无依赖,全浏览器可用。
3
+ * 项目导出走"另一介质"才真正提升持久等级;zip 是最低门槛的那条出口。
4
+ */
5
+ import { crc32 } from './worker-lib/wal-codec.js';
6
+ const enc = new TextEncoder();
7
+ /** files: {relPath: string content} → Uint8Array(ZIP,UTF-8 文件名)。 */
8
+ export function makeZip(files) {
9
+ const parts = [];
10
+ const central = [];
11
+ let offset = 0;
12
+ for (const [path, content] of Object.entries(files)) {
13
+ const name = enc.encode(path);
14
+ const data = enc.encode(content);
15
+ const crc = crc32(data);
16
+ const local = new Uint8Array(30 + name.length);
17
+ const lv = new DataView(local.buffer);
18
+ lv.setUint32(0, 0x04034b50, true); // local file header
19
+ lv.setUint16(4, 20, true); // version needed
20
+ lv.setUint16(6, 0x0800, true); // flags: UTF-8 names
21
+ lv.setUint16(8, 0, true); // method: store
22
+ lv.setUint32(14, crc, true);
23
+ lv.setUint32(18, data.length, true);
24
+ lv.setUint32(22, data.length, true);
25
+ lv.setUint16(26, name.length, true);
26
+ local.set(name, 30);
27
+ parts.push(local, data);
28
+ const cd = new Uint8Array(46 + name.length);
29
+ const cv = new DataView(cd.buffer);
30
+ cv.setUint32(0, 0x02014b50, true); // central directory header
31
+ cv.setUint16(4, 20, true);
32
+ cv.setUint16(6, 20, true);
33
+ cv.setUint16(8, 0x0800, true);
34
+ cv.setUint16(10, 0, true);
35
+ cv.setUint32(16, crc, true);
36
+ cv.setUint32(20, data.length, true);
37
+ cv.setUint32(24, data.length, true);
38
+ cv.setUint16(28, name.length, true);
39
+ cv.setUint32(42, offset, true); // local header offset
40
+ cd.set(name, 46);
41
+ central.push(cd);
42
+ offset += local.length + data.length;
43
+ }
44
+ const cdSize = central.reduce((s, c) => s + c.length, 0);
45
+ const eocd = new Uint8Array(22);
46
+ const ev = new DataView(eocd.buffer);
47
+ ev.setUint32(0, 0x06054b50, true);
48
+ ev.setUint16(8, central.length, true);
49
+ ev.setUint16(10, central.length, true);
50
+ ev.setUint32(12, cdSize, true);
51
+ ev.setUint32(16, offset, true);
52
+ const total = offset + cdSize + 22;
53
+ const out = new Uint8Array(total);
54
+ let at = 0;
55
+ for (const p of [...parts, ...central, eocd]) {
56
+ out.set(p, at);
57
+ at += p.length;
58
+ }
59
+ return out;
60
+ }
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@dimina-kit/fs-core",
3
+ "version": "0.2.0-dev.20260710085051",
4
+ "description": "Zero-dependency OPFS WAL filesystem core for the web, with an agent capability gate.",
5
+ "keywords": [
6
+ "dimina",
7
+ "opfs",
8
+ "filesystem",
9
+ "wal",
10
+ "agent"
11
+ ],
12
+ "type": "module",
13
+ "sideEffects": false,
14
+ "engines": {
15
+ "node": ">=20"
16
+ },
17
+ "license": "MIT",
18
+ "author": "EchoTechFE",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/EchoTechFE/dimina-kit.git",
22
+ "directory": "packages/fs-core"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/EchoTechFE/dimina-kit/issues"
26
+ },
27
+ "homepage": "https://github.com/EchoTechFE/dimina-kit/tree/main/packages/fs-core#readme",
28
+ "exports": {
29
+ "./client": {
30
+ "types": "./src/client.ts",
31
+ "default": "./dist/client.js"
32
+ },
33
+ "./agent-tools": {
34
+ "types": "./src/agent-tools.ts",
35
+ "default": "./dist/agent-tools.js"
36
+ },
37
+ "./disk-mirror": {
38
+ "types": "./src/disk-mirror.ts",
39
+ "default": "./dist/disk-mirror.js"
40
+ },
41
+ "./zip": {
42
+ "types": "./src/zip.ts",
43
+ "default": "./dist/zip.js"
44
+ },
45
+ "./sync": {
46
+ "types": "./sync/sync-engine.ts",
47
+ "default": "./dist/sync/sync-engine.js"
48
+ }
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "src",
53
+ "sync"
54
+ ],
55
+ "devDependencies": {
56
+ "@vitest/coverage-v8": "^4.1.4",
57
+ "esbuild": "^0.28.1",
58
+ "eslint": "^10.2.1",
59
+ "typescript": "5.9.2",
60
+ "vitest": "^4.1.4",
61
+ "@dimina-kit/eslint-config": "0.1.0",
62
+ "@dimina-kit/typescript-config": "0.1.0"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ },
67
+ "scripts": {
68
+ "build": "tsc -p tsconfig.build.json && tsc -p tsconfig.sync.build.json && node build-workers.js",
69
+ "check-types": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.worker.json",
70
+ "lint": "eslint . --max-warnings 0",
71
+ "test": "vitest run --reporter=default --reporter=json --outputFile.json=test-report.json --coverage.enabled --coverage.reporter=json-summary --coverage.reportsDirectory=coverage",
72
+ "test:dev": "vitest",
73
+ "test:coverage": "vitest run --coverage"
74
+ }
75
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Type-consumption smoke check: guards that the package.json `exports` map
3
+ * resolves `types` for all four public subpaths (not just `default`).
4
+ * Not a runtime test — only needs to satisfy `tsc --noEmit`.
5
+ */
6
+ import type { ProjectFsClient } from '@dimina-kit/fs-core/client'
7
+ import type { AgentTool, AgentToolsFs } from '@dimina-kit/fs-core/agent-tools'
8
+ import { createAgentTools } from '@dimina-kit/fs-core/agent-tools'
9
+ import type { DiskMirrorFs } from '@dimina-kit/fs-core/disk-mirror'
10
+ import { createDiskMirror } from '@dimina-kit/fs-core/disk-mirror'
11
+ import { makeZip } from '@dimina-kit/fs-core/zip'
12
+
13
+ export function typesSmoke(client: ProjectFsClient, fs: AgentToolsFs & DiskMirrorFs): {
14
+ tool: AgentTool | undefined
15
+ zip: Uint8Array
16
+ mirrorActive: boolean
17
+ } {
18
+ const agent = createAgentTools(fs)
19
+ const mirror = createDiskMirror(fs)
20
+ const zip = makeZip({ 'a.txt': 'hello' })
21
+ void client.projectId
22
+ return { tool: agent.byName.fs_read, zip, mirrorActive: mirror.active }
23
+ }
@@ -0,0 +1,151 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createAgentTools } from './agent-tools.js'
3
+
4
+ /** Hand-written mock shaped like ProjectFsClient's public surface. Every
5
+ * method is a vi.fn() so tests can assert on the exact args it receives,
6
+ * and returns a distinguishable sentinel value so tests can assert the
7
+ * tool's execute() forwards the fs call's result untouched. */
8
+ function createMockFs() {
9
+ return {
10
+ read: vi.fn(async (path: string) => ({ content: `content:${path}`, rev: 1 })),
11
+ ls: vi.fn(async () => ({ paths: ['a.txt'] })),
12
+ glob: vi.fn(async (pattern: string) => ({ paths: [pattern] })),
13
+ grep: vi.fn(async () => ({ hits: [] })),
14
+ write: vi.fn(async () => ({ rev: 2 })),
15
+ edit: vi.fn(async () => ({ rev: 3 })),
16
+ mv: vi.fn(async () => ({ ok: true })),
17
+ rm: vi.fn(async () => ({ ok: true })),
18
+ checkpoint: vi.fn(async () => ({ cpId: 'cp-1' })),
19
+ restore: vi.fn(async () => ({ ok: true })),
20
+ diff: vi.fn(async (turnId?: string) => ({ changes: [], turnId })),
21
+ turnBegin: vi.fn(async (_turnId: string, _opts?: Record<string, unknown>) => ({ cpId: 'cp-0', expiresAt: 1234 })),
22
+ turnEnd: vi.fn(async () => ({ closed: true })),
23
+ }
24
+ }
25
+
26
+ describe('createAgentTools', () => {
27
+ it('exposes exactly the ten tools by their documented names, each with a description, inputSchema, and execute', () => {
28
+ const { tools, byName } = createAgentTools(createMockFs())
29
+ const names = tools.map((t) => t.name).sort()
30
+ expect(names).toEqual(
31
+ ['fs_checkpoint', 'fs_diff', 'fs_edit', 'fs_glob', 'fs_grep', 'fs_ls', 'fs_mv', 'fs_read', 'fs_restore', 'fs_rm', 'fs_write'].sort(),
32
+ )
33
+ for (const t of tools) {
34
+ expect(byName[t.name]).toBe(t)
35
+ expect(typeof t.description).toBe('string')
36
+ expect(t.description.length).toBeGreaterThan(0)
37
+ expect(t.inputSchema).toBeTruthy()
38
+ expect(typeof t.execute).toBe('function')
39
+ }
40
+ })
41
+
42
+ it('marks only fs_restore as dangerous', () => {
43
+ const { tools } = createAgentTools(createMockFs())
44
+ for (const t of tools) {
45
+ if (t.name === 'fs_restore') expect(t.dangerous).toBe(true)
46
+ else expect(t.dangerous).toBeUndefined()
47
+ }
48
+ })
49
+
50
+ it('mints a fresh t-<base36>-<seq> turnId on beginTurn, forwards opts to fs.turnBegin, and merges its result', async () => {
51
+ const fs = createMockFs()
52
+ const agent = createAgentTools(fs)
53
+ const result = await agent.beginTurn({ reason: 'test' })
54
+ expect(fs.turnBegin).toHaveBeenCalledTimes(1)
55
+ const [turnIdArg, optsArg] = fs.turnBegin.mock.calls[0]!
56
+ expect(turnIdArg).toMatch(/^t-[0-9a-z]+-\d+$/)
57
+ expect(optsArg).toEqual({ reason: 'test' })
58
+ expect(result.turnId).toBe(turnIdArg)
59
+ expect(result.cpId).toBe('cp-0')
60
+ expect(result.expiresAt).toBe(1234)
61
+ expect(agent.activeTurn).toBe(turnIdArg)
62
+ })
63
+
64
+ it('returns {closed:false} without calling fs.turnEnd when there is no active turn', async () => {
65
+ const fs = createMockFs()
66
+ const { endTurn } = createAgentTools(fs)
67
+ const result = await endTurn()
68
+ expect(result).toEqual({ closed: false })
69
+ expect(fs.turnEnd).not.toHaveBeenCalled()
70
+ })
71
+
72
+ it('calls fs.turnEnd with the active turnId, clears activeTurn, and returns its result when a turn is active', async () => {
73
+ const fs = createMockFs()
74
+ const agent = createAgentTools(fs)
75
+ const { turnId } = await agent.beginTurn()
76
+ const result = await agent.endTurn()
77
+ expect(fs.turnEnd).toHaveBeenCalledExactlyOnceWith(turnId)
78
+ expect(result).toEqual({ closed: true })
79
+ expect(agent.activeTurn).toBeNull()
80
+ })
81
+
82
+ it('injects {actor:"agent", turnId:activeTurn} into fs.write, overriding any turnId the caller tries to pass', async () => {
83
+ const fs = createMockFs()
84
+ const agent = createAgentTools(fs)
85
+ const { turnId } = await agent.beginTurn()
86
+ await agent.byName.fs_write!.execute({ path: 'a.txt', content: 'hi', turnId: 'forged-turn' })
87
+ expect(fs.write).toHaveBeenCalledExactlyOnceWith('a.txt', 'hi', { actor: 'agent', turnId, ifMatch: undefined })
88
+ })
89
+
90
+ it('injects actor/turnId into every other write tool (edit, mv, rm, checkpoint, restore)', async () => {
91
+ const fs = createMockFs()
92
+ const agent = createAgentTools(fs)
93
+ const { turnId } = await agent.beginTurn()
94
+
95
+ await agent.byName.fs_edit!.execute({ path: 'a.txt', old: 'x', next: 'y' })
96
+ expect(fs.edit).toHaveBeenCalledExactlyOnceWith('a.txt', 'x', 'y', { actor: 'agent', turnId })
97
+
98
+ await agent.byName.fs_mv!.execute({ from: 'a.txt', to: 'b.txt' })
99
+ expect(fs.mv).toHaveBeenCalledExactlyOnceWith('a.txt', 'b.txt', { actor: 'agent', turnId })
100
+
101
+ await agent.byName.fs_rm!.execute({ path: 'a.txt' })
102
+ expect(fs.rm).toHaveBeenCalledExactlyOnceWith('a.txt', { actor: 'agent', turnId })
103
+
104
+ await agent.byName.fs_checkpoint!.execute({})
105
+ expect(fs.checkpoint).toHaveBeenCalledExactlyOnceWith({ actor: 'agent', turnId })
106
+
107
+ await agent.byName.fs_restore!.execute({ cpId: 'cp-1', force: true })
108
+ expect(fs.restore).toHaveBeenCalledExactlyOnceWith('cp-1', { actor: 'agent', turnId, force: true })
109
+ })
110
+
111
+ it('injects turnId:null (no actor forgery possible) when a write tool is invoked with no active turn', async () => {
112
+ const fs = createMockFs()
113
+ const { byName } = createAgentTools(fs)
114
+ await byName.fs_write!.execute({ path: 'a.txt', content: 'hi' })
115
+ expect(fs.write).toHaveBeenCalledExactlyOnceWith('a.txt', 'hi', { actor: 'agent', turnId: null, ifMatch: undefined })
116
+ })
117
+
118
+ it('forwards read tool args to fs without injecting actor or turnId', async () => {
119
+ const fs = createMockFs()
120
+ const agent = createAgentTools(fs)
121
+ await agent.beginTurn()
122
+
123
+ await agent.byName.fs_read!.execute({ path: 'a.txt' })
124
+ expect(fs.read).toHaveBeenCalledExactlyOnceWith('a.txt')
125
+
126
+ await agent.byName.fs_ls!.execute({})
127
+ expect(fs.ls).toHaveBeenCalledExactlyOnceWith()
128
+
129
+ await agent.byName.fs_glob!.execute({ pattern: '*.ts' })
130
+ expect(fs.glob).toHaveBeenCalledExactlyOnceWith('*.ts')
131
+
132
+ await agent.byName.fs_grep!.execute({ pattern: 'foo', glob: '*.ts', limit: 5 })
133
+ expect(fs.grep).toHaveBeenCalledExactlyOnceWith('foo', { glob: '*.ts', limit: 5 })
134
+ })
135
+
136
+ it('defaults fs_diff turnId to activeTurn when no turnId argument is passed', async () => {
137
+ const fs = createMockFs()
138
+ const agent = createAgentTools(fs)
139
+ const { turnId } = await agent.beginTurn()
140
+ await agent.byName.fs_diff!.execute()
141
+ expect(fs.diff).toHaveBeenCalledExactlyOnceWith(turnId)
142
+ })
143
+
144
+ it('lets fs_diff use an explicitly passed turnId instead of activeTurn', async () => {
145
+ const fs = createMockFs()
146
+ const agent = createAgentTools(fs)
147
+ await agent.beginTurn()
148
+ await agent.byName.fs_diff!.execute({ turnId: 'other-turn' })
149
+ expect(fs.diff).toHaveBeenCalledExactlyOnceWith('other-turn')
150
+ })
151
+ })
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Agent 工具面(P4)—— MCP 形状的工具表包装 ProjectFsClient。
3
+ *
4
+ * 设计对应架构文档 §13:Agent 拿到的不是裸 FS,而是这张工具表 +(宿主铸造的)
5
+ * turn 能力。要点:
6
+ * - execute 自动注入 {actor:'agent', turnId}:turnId 由 beginTurn 铸造并闭包持有,
7
+ * 不出现在工具参数里 —— 模型伪造不了别的 turn。
8
+ * - 真正的执法在 fs-core(checkTurn 与 WAL append 同一同步块);这里只是便利面。
9
+ * - inputSchema 用简化记法(K2 交付 Agent 组时再换成正式 JSON Schema + MCP server 包装)。
10
+ * - fs_read 返回完整内容,绝不截断(上游 8000 字符腰斩教训)。
11
+ *
12
+ * `AgentToolsFs` only declares the methods this file actually calls on the
13
+ * ProjectFsClient handle it's given (structurally, so any object shaped like
14
+ * it — including test mocks — works without importing the concrete class).
15
+ */
16
+ export interface AgentToolsFs {
17
+ read(path: string): unknown
18
+ ls(): unknown
19
+ glob(pattern: string): unknown
20
+ grep(pattern: string, opts?: Record<string, unknown>): unknown
21
+ write(path: string, content: string, opts?: Record<string, unknown>): unknown
22
+ edit(path: string, old: string, next: string, opts?: Record<string, unknown>): unknown
23
+ mv(from: string, to: string, opts?: Record<string, unknown>): unknown
24
+ rm(path: string, opts?: Record<string, unknown>): unknown
25
+ checkpoint(opts?: Record<string, unknown>): unknown
26
+ restore(cpId: string, opts?: Record<string, unknown>): unknown
27
+ diff(turnId?: string): unknown
28
+ turnBegin(turnId: string, opts?: Record<string, unknown>): Promise<Record<string, unknown>>
29
+ turnEnd(turnId: string): unknown
30
+ }
31
+
32
+ export interface AgentTool {
33
+ name: string
34
+ description: string
35
+ inputSchema: unknown
36
+ // Method shorthand (not an arrow-typed property): TS checks method members
37
+ // bivariantly, so each tool below can implement `execute` with its own
38
+ // narrower, concrete parameter shape (e.g. `({ path }: { path: string })`)
39
+ // instead of every implementation having to accept `unknown[]` directly.
40
+ execute(...args: unknown[]): unknown
41
+ dangerous?: boolean
42
+ }
43
+
44
+ export function createAgentTools(fs: AgentToolsFs): {
45
+ tools: AgentTool[]
46
+ byName: Record<string, AgentTool>
47
+ beginTurn(opts?: Record<string, unknown>): Promise<{ turnId: string } & Record<string, unknown>>
48
+ endTurn(): Promise<unknown>
49
+ readonly activeTurn: string | null
50
+ } {
51
+ let turnSeq = 0
52
+ let activeTurn: string | null = null
53
+
54
+ /** 铸造一轮写能力:fs-core 侧自动打 checkpoint 锚(返回 {cpId, expiresAt})。 */
55
+ async function beginTurn(opts: Record<string, unknown> = {}) {
56
+ const turnId = 't-' + Date.now().toString(36) + '-' + ++turnSeq
57
+ const r = await fs.turnBegin(turnId, opts)
58
+ activeTurn = turnId
59
+ return { turnId, ...r }
60
+ }
61
+
62
+ /** 结束(撤销)当前 turn;之后任何 agent 写都会被 fs-core 拒绝(turn-closed)。 */
63
+ async function endTurn() {
64
+ if (!activeTurn) return { closed: false }
65
+ const turnId = activeTurn
66
+ activeTurn = null
67
+ return fs.turnEnd(turnId)
68
+ }
69
+
70
+ const agentOpts = () => ({ actor: 'agent', turnId: activeTurn })
71
+ // E is inferred fresh per call from whatever concrete `execute` is passed
72
+ // (e.g. `({ path }: { path: string }) => …`) rather than checked against one
73
+ // fixed function type, so each tool below keeps its own narrow parameter
74
+ // shape; assigning it into the returned object's `execute` member is safe
75
+ // because AgentTool.execute is a method (bivariant), not an arrow-typed
76
+ // property.
77
+ function T<E extends (...args: never[]) => unknown>(name: string, description: string, inputSchema: unknown, execute: E, extra?: Record<string, unknown>): AgentTool {
78
+ return { name, description, inputSchema, execute, ...extra }
79
+ }
80
+
81
+ const tools: AgentTool[] = [
82
+ // 读/查询(路由 fs-query,不占写路径)
83
+ T('fs_read', '读文件,返回完整内容与版本 rev(绝不截断)', { path: 'string' },
84
+ ({ path }: { path: string }) => fs.read(path)),
85
+ T('fs_ls', '列出项目全部文件路径', {},
86
+ () => fs.ls()),
87
+ T('fs_glob', '按 glob 模式匹配文件路径', { pattern: 'string' },
88
+ ({ pattern }: { pattern: string }) => fs.glob(pattern)),
89
+ T('fs_grep', '按正则搜索文件内容,返回 {path, lineNo, line} 命中行', { pattern: 'string', glob: 'string?', limit: 'number?' },
90
+ ({ pattern, ...o }: { pattern: string; [k: string]: unknown }) => fs.grep(pattern, o)),
91
+ // 写(fs-core 执法:必须在有效 turn 内)
92
+ T('fs_write', '写整文件;可带 ifMatch(rev) 做 CAS 前置校验', { path: 'string', content: 'string', ifMatch: 'number?' },
93
+ ({ path, content, ifMatch }: { path: string; content: string; ifMatch?: number }) => fs.write(path, content, { ...agentOpts(), ifMatch })),
94
+ T('fs_edit', '精确替换:old 在文件中必须唯一匹配', { path: 'string', old: 'string', next: 'string' },
95
+ ({ path, old, next }: { path: string; old: string; next: string }) => fs.edit(path, old, next, agentOpts())),
96
+ T('fs_mv', '移动/重命名文件', { from: 'string', to: 'string' },
97
+ ({ from, to }: { from: string; to: string }) => fs.mv(from, to, agentOpts())),
98
+ T('fs_rm', '删除文件', { path: 'string' },
99
+ ({ path }: { path: string }) => fs.rm(path, agentOpts())),
100
+ // 回滚面
101
+ T('fs_checkpoint', '手动创建快照锚点(turn 开始时已自动有一个)', {},
102
+ () => fs.checkpoint(agentOpts())),
103
+ T('fs_restore', '回滚到指定 checkpoint(本身是一条可再撤销的写);若 checkpoint 之后有人类编辑,默认拒绝并返回 restore-conflict{humanPaths},需 force:true 确认覆盖', { cpId: 'string', force: 'boolean?' },
104
+ ({ cpId, force }: { cpId: string; force?: boolean }) => fs.restore(cpId, { ...agentOpts(), force }),
105
+ { dangerous: true }),
106
+ T('fs_diff', '列出某轮 turn 的全部改动(默认当前 turn)', { turnId: 'string?' },
107
+ ({ turnId }: { turnId?: string } = {}) => fs.diff(turnId ?? activeTurn ?? undefined)),
108
+ ]
109
+
110
+ return {
111
+ tools,
112
+ byName: Object.fromEntries(tools.map((t) => [t.name, t])),
113
+ beginTurn,
114
+ endTurn,
115
+ get activeTurn() { return activeTurn },
116
+ }
117
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Unit tests for ProjectFsClient's writer-mode bookkeeping (`mode` / `onModeChange`).
3
+ * `connect()` itself needs a real Worker + OPFS + navigator.locks environment (only
4
+ * exercised by real-browser e2e probes), so these construct a bare `ProjectFsClient`
5
+ * instance and drive its internal `_onCoreMessage` dispatcher directly with the exact
6
+ * message shapes fs-core.worker.js sends (WELCOME / writer-granted / writer-lost / FATAL),
7
+ * matching what a real core→client MessagePort would deliver.
8
+ */
9
+ import { describe, expect, it, vi } from 'vitest'
10
+ import { ProjectFsClient } from './client.js'
11
+
12
+ type Mode = 'starting' | 'writer' | 'readonly' | 'draining' | 'dead'
13
+ type CoreEvent = { type?: string; evt?: string; error?: string; mode?: Mode; readonly?: boolean; gen?: number }
14
+
15
+ /** Structural view of the internals `_onCoreMessage`/`_setMode` touch — not exposed
16
+ * on the public `ProjectFsClient` d.ts surface, so tests reach in via this narrow cast
17
+ * instead of `any`/`@ts-expect-error` sprinkled at every call site. */
18
+ type ClientInternals = ProjectFsClient & {
19
+ changeCbs: Set<(evt: CoreEvent) => void>
20
+ modeCbs: Set<(mode: Mode) => void>
21
+ pending: Map<number, unknown>
22
+ _mode: Mode
23
+ _onCoreMessage(msg: CoreEvent, resolveWelcome?: (w: CoreEvent) => void, rejectWelcome?: (e: Error) => void): void
24
+ _setMode(mode: Mode): void
25
+ }
26
+
27
+ /** Builds a client instance with just the fields `_onCoreMessage`/`_setMode` touch,
28
+ * skipping the real `connect()` (Worker/OPFS/locks) entirely. */
29
+ function makeBareClient(initialMode: Mode = 'starting'): ClientInternals {
30
+ const c = new ProjectFsClient() as unknown as ClientInternals
31
+ c.changeCbs = new Set()
32
+ c.modeCbs = new Set()
33
+ c.pending = new Map()
34
+ c._mode = initialMode
35
+ return c
36
+ }
37
+
38
+ describe('ProjectFsClient writer mode', () => {
39
+ it('adopts mode from WELCOME, mirroring what connect() does post-await', () => {
40
+ const c = makeBareClient()
41
+ let welcome: CoreEvent | undefined
42
+ c._onCoreMessage({ type: 'WELCOME', mode: 'writer', readonly: false }, (w) => { welcome = w })
43
+ expect(welcome?.mode).toBe('writer')
44
+ // connect() does: c._setMode(c.welcome.mode || (c.welcome.readonly ? 'readonly' : 'writer'))
45
+ c._setMode(welcome!.mode || (welcome!.readonly ? 'readonly' : 'writer'))
46
+ expect(c.mode).toBe('writer')
47
+ })
48
+
49
+ it('writer-granted flips mode to writer and notifies onModeChange subscribers', () => {
50
+ const c = makeBareClient('readonly')
51
+ const cb = vi.fn()
52
+ const unsubscribe = c.onModeChange(cb)
53
+
54
+ c._onCoreMessage({ evt: 'writer-granted', gen: 5 })
55
+
56
+ expect(c.mode).toBe('writer')
57
+ expect(cb).toHaveBeenCalledWith('writer')
58
+ expect(cb).toHaveBeenCalledTimes(1)
59
+
60
+ unsubscribe()
61
+ c._onCoreMessage({ evt: 'writer-lost', gen: 6 })
62
+ expect(cb).toHaveBeenCalledTimes(1) // unsubscribed — no further calls
63
+ })
64
+
65
+ it('writer-lost flips mode to readonly', () => {
66
+ const c = makeBareClient('writer')
67
+ const cb = vi.fn()
68
+ c.onModeChange(cb)
69
+
70
+ c._onCoreMessage({ evt: 'writer-lost', gen: 6 })
71
+
72
+ expect(c.mode).toBe('readonly')
73
+ expect(cb).toHaveBeenCalledWith('readonly')
74
+ })
75
+
76
+ it('does not notify subscribers when the mode is unchanged', () => {
77
+ const c = makeBareClient('writer')
78
+ const cb = vi.fn()
79
+ c.onModeChange(cb)
80
+
81
+ c._onCoreMessage({ evt: 'writer-granted', gen: 7 }) // already writer — no-op transition
82
+
83
+ expect(c.mode).toBe('writer')
84
+ expect(cb).not.toHaveBeenCalled()
85
+ })
86
+
87
+ it('FATAL flips mode to dead', () => {
88
+ const c = makeBareClient('writer')
89
+ const cb = vi.fn()
90
+ c.onModeChange(cb)
91
+
92
+ // mirrors self.postMessage({type:'FATAL', error}) in the worker
93
+ c._onCoreMessage({ type: 'FATAL', error: 'boom' })
94
+
95
+ expect(c.mode).toBe('dead')
96
+ expect(cb).toHaveBeenCalledWith('dead')
97
+ })
98
+
99
+ it('still delivers evt messages to onChange subscribers alongside mode updates', () => {
100
+ const c = makeBareClient('readonly')
101
+ const changeCb = vi.fn()
102
+ c.onChange(changeCb)
103
+
104
+ const msg: CoreEvent = { evt: 'writer-granted', gen: 9 }
105
+ c._onCoreMessage(msg)
106
+
107
+ expect(changeCb).toHaveBeenCalledWith(msg)
108
+ expect(c.mode).toBe('writer')
109
+ })
110
+ })