@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/agent-tools.js +52 -0
- package/dist/client.js +234 -0
- package/dist/disk-mirror.js +97 -0
- package/dist/fs-core.worker.js +1001 -0
- package/dist/fs-query.worker.js +115 -0
- package/dist/sync/sync-engine.js +381 -0
- package/dist/sync/truth-port.js +1 -0
- package/dist/worker-lib/engine-shared.js +25 -0
- package/dist/worker-lib/paths.js +17 -0
- package/dist/worker-lib/rpc-types.js +1 -0
- package/dist/worker-lib/wal-codec.js +111 -0
- package/dist/zip.js +60 -0
- package/package.json +75 -0
- package/src/__checks__/types-smoke.ts +23 -0
- package/src/agent-tools.test.ts +151 -0
- package/src/agent-tools.ts +117 -0
- package/src/client.test.ts +110 -0
- package/src/client.ts +284 -0
- package/src/disk-mirror.test.ts +190 -0
- package/src/disk-mirror.ts +119 -0
- package/src/fs-core-recovery.ts +280 -0
- package/src/fs-core-write-ops.ts +402 -0
- package/src/fs-core.worker.ts +182 -0
- package/src/fs-query.worker.ts +152 -0
- package/src/import-smoke.test.ts +40 -0
- package/src/worker-lib/engine-shared.test.ts +30 -0
- package/src/worker-lib/engine-shared.ts +74 -0
- package/src/worker-lib/paths.test.ts +39 -0
- package/src/worker-lib/paths.ts +14 -0
- package/src/worker-lib/rpc-types.ts +67 -0
- package/src/worker-lib/wal-codec.test.ts +83 -0
- package/src/worker-lib/wal-codec.ts +130 -0
- package/src/zip.test.ts +124 -0
- package/src/zip.ts +60 -0
- package/sync/sync-engine.test.ts +695 -0
- package/sync/sync-engine.ts +459 -0
- 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
|
+
})
|