@capyclaw/ppt-workbench 0.0.0-stage → 2.4.1
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 +126 -3
- package/THIRD_PARTY_NOTICES.md +21 -0
- package/contract/ppt-contract.json +1529 -0
- package/cordis.patch.yml +25 -0
- package/core/audit.js +500 -0
- package/core/chart.js +199 -0
- package/core/color.js +80 -0
- package/core/deck-layout.js +195 -0
- package/core/formula.js +80 -0
- package/core/hfmath/hershey.js +1763 -0
- package/core/hfmath/index.js +1176 -0
- package/core/hfmath/stretch.js +41 -0
- package/core/hfmath/symbols.js +408 -0
- package/core/ops/browser.js +28 -0
- package/core/ops/chart.js +64 -0
- package/core/ops/deck.js +21 -0
- package/core/ops/dispatcher.js +2 -0
- package/core/ops/element.js +55 -0
- package/core/ops/history.js +11 -0
- package/core/ops/import.js +95 -0
- package/core/ops/index.js +39 -0
- package/core/ops/playback.js +9 -0
- package/core/ops/recolor.js +33 -0
- package/core/ops/slide.js +66 -0
- package/core/ops/table.js +85 -0
- package/core/ops/text.js +35 -0
- package/core/ops/view.js +13 -0
- package/core/ops.js +12 -0
- package/core/quick.js +52 -0
- package/core/shape-library.js +122 -0
- package/core/table.js +178 -0
- package/core/text.js +200 -0
- package/editor/app.js +5366 -0
- package/editor/heartbeat-worker.js +116 -0
- package/editor/index.html +89 -0
- package/editor/ppt-full-app.js +376 -0
- package/lib/host.mjs +98 -0
- package/package.json +53 -6
- package/server/audit.mjs +2 -0
- package/server/bridge-client.mjs +174 -0
- package/server/constants.mjs +20 -0
- package/server/contract-snapshots.mjs +62 -0
- package/server/contract.mjs +24 -0
- package/server/deck-layout.mjs +2 -0
- package/server/http-utils.mjs +2 -0
- package/server/infra/http.mjs +113 -0
- package/server/infra/runtime/context.mjs +128 -0
- package/server/infra/store/blob-store.mjs +109 -0
- package/server/infra/tuning.mjs +82 -0
- package/server/infra/util/fs-lock.mjs +132 -0
- package/server/infra/workspace/manager.mjs +657 -0
- package/server/mcp/dispatch.mjs +2 -0
- package/server/mcp/handler.mjs +2 -0
- package/server/mcp/instructions.mjs +40 -0
- package/server/mcp-http.mjs +3 -0
- package/server/ppt-shim.mjs +156 -0
- package/server/relay.mjs +245 -0
- package/server/routes/assets.mjs +2 -0
- package/server/routes/bridge.mjs +47 -0
- package/server/routes/editor.mjs +9 -0
- package/server/routes/import.mjs +258 -0
- package/server/routes/mcp.mjs +17 -0
- package/server/runtime/recovery.mjs +9 -0
- package/server/runtime/watch.mjs +17 -0
- package/server/shared/SHARED_MANIFEST.json +47 -0
- package/server/shared/asset-service.mjs +217 -0
- package/server/shared/editor-host.mjs +125 -0
- package/server/shared/kit/editor/lease.mjs +139 -0
- package/server/shared/kit/service/api.mjs +45 -0
- package/server/shared/kit/service/http.mjs +113 -0
- package/server/shared/kit/service/mcp-http.mjs +142 -0
- package/server/shared/kit/service/single-instance.mjs +72 -0
- package/server/shared/kit/tools/validate.mjs +115 -0
- package/server/shared/kit/tuning.mjs +82 -0
- package/server/shared/kit/util/fs-lock.mjs +132 -0
- package/server/shared/service-host.mjs +511 -0
- package/server/shared/state-service.mjs +249 -0
- package/server/shared/tool-host.mjs +341 -0
- package/server/shared/tuning.mjs +63 -0
- package/server/single-instance.mjs +2 -0
- package/server/store/backends/file-backend.mjs +13 -0
- package/server/store/checkpoints.mjs +28 -0
- package/server/store/doc.mjs +52 -0
- package/server/store/index.mjs +34 -0
- package/server/store/journal.mjs +9 -0
- package/server/store/lease.mjs +2 -0
- package/server/store/sanitize.mjs +32 -0
- package/server/store.mjs +2 -0
- package/server/tools/capabilities/apply.mjs +33 -0
- package/server/tools/capabilities/audit.mjs +24 -0
- package/server/tools/capabilities/chart.mjs +83 -0
- package/server/tools/capabilities/compose.mjs +97 -0
- package/server/tools/capabilities/contract.mjs +34 -0
- package/server/tools/capabilities/deck.mjs +27 -0
- package/server/tools/capabilities/element.mjs +67 -0
- package/server/tools/capabilities/export.mjs +95 -0
- package/server/tools/capabilities/formula.mjs +72 -0
- package/server/tools/capabilities/history.mjs +29 -0
- package/server/tools/capabilities/image.mjs +152 -0
- package/server/tools/capabilities/imagesearch.mjs +152 -0
- package/server/tools/capabilities/import.mjs +180 -0
- package/server/tools/capabilities/index.mjs +58 -0
- package/server/tools/capabilities/line.mjs +58 -0
- package/server/tools/capabilities/notes.mjs +33 -0
- package/server/tools/capabilities/present.mjs +34 -0
- package/server/tools/capabilities/recolor.mjs +60 -0
- package/server/tools/capabilities/screenshot.mjs +86 -0
- package/server/tools/capabilities/shape.mjs +64 -0
- package/server/tools/capabilities/slide.mjs +81 -0
- package/server/tools/capabilities/style.mjs +48 -0
- package/server/tools/capabilities/table.mjs +127 -0
- package/server/tools/capabilities/task.mjs +52 -0
- package/server/tools/capabilities/text.mjs +82 -0
- package/server/tools/domains/color.mjs +4 -0
- package/server/tools/domains/image.mjs +49 -0
- package/server/tools/domains/index.mjs +7 -0
- package/server/tools/domains/pptx-export.mjs +566 -0
- package/server/tools/domains/recolor.mjs +25 -0
- package/server/tools/domains/remote-image.mjs +291 -0
- package/server/tools/domains/slide.mjs +21 -0
- package/server/tools/domains/text-style.mjs +11 -0
- package/server/tools/domains/tooling.mjs +13 -0
- package/server/tools/index.mjs +14 -0
- package/server/tools/kernel/define.mjs +5 -0
- package/server/tools/kernel/ports.mjs +42 -0
- package/server/tools/kernel/registry.mjs +17 -0
- package/server/tools.mjs +18 -0
- package/server/tuning.mjs +86 -0
- package/server/validate.mjs +2 -0
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 文件关系中间层(Workspace / Catalog Manager)。
|
|
3
|
+
*
|
|
4
|
+
* 职责边界:
|
|
5
|
+
* BlobStore(store/blob-store.mjs):只存对象,不认识文件名、作品、归属。
|
|
6
|
+
* WorkspaceManager(本文件):解释“哪些对象属于同一个 work、谁能改、
|
|
7
|
+
* 版本是多少、什么时候过期”,并生成可给 UI/生命周期管理读取的 catalog。
|
|
8
|
+
*
|
|
9
|
+
* 同一个 runtime 内的多个会话共享同一个 work;不同 runtime 通过 namespace
|
|
10
|
+
* token 隔离。新建文件自动归入当前 work 的 manifest,不会串到别的 runtime。
|
|
11
|
+
*/
|
|
12
|
+
import crypto from 'node:crypto'
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { BlobStore } from '../store/blob-store.mjs'
|
|
16
|
+
import { readJson, withFileLock, writeJsonAtomic } from '../util/fs-lock.mjs'
|
|
17
|
+
import { tuning } from '../tuning.mjs'
|
|
18
|
+
|
|
19
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/
|
|
20
|
+
const KEY_RE = /^(?!\/)(?!.*(?:^|\/)\.\.(?:\/|$))[A-Za-z0-9._@/+:-]{1,512}$/
|
|
21
|
+
|
|
22
|
+
export class WorkspaceConflictError extends Error {
|
|
23
|
+
constructor(message = '内容版本冲突') {
|
|
24
|
+
super(message)
|
|
25
|
+
this.name = 'WorkspaceConflictError'
|
|
26
|
+
this.code = 'ESTALE'
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class WorkspaceAccessError extends Error {
|
|
31
|
+
constructor(message = '没有访问该 runtime namespace 的权限') {
|
|
32
|
+
super(message)
|
|
33
|
+
this.name = 'WorkspaceAccessError'
|
|
34
|
+
this.code = 'EACCES'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function assertId(value, label) {
|
|
39
|
+
if (typeof value !== 'string' || !ID_RE.test(value)) {
|
|
40
|
+
throw new Error(`${label} 必须是 1-64 位小写字母/数字/._-: ${value}`)
|
|
41
|
+
}
|
|
42
|
+
return value
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function assertKey(key) {
|
|
46
|
+
if (typeof key !== 'string' || !KEY_RE.test(key)) throw new Error(`对象 key 非法: ${key}`)
|
|
47
|
+
if (key === 'manifest.json') throw new Error('manifest.json 是中间层保留 key')
|
|
48
|
+
return key
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function hashToken(token) {
|
|
52
|
+
return crypto.createHash('sha256').update(String(token)).digest('hex')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function safeEqualHex(left, right) {
|
|
56
|
+
const a = Buffer.from(String(left), 'hex')
|
|
57
|
+
const b = Buffer.from(String(right), 'hex')
|
|
58
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class NamespaceRegistry {
|
|
62
|
+
constructor({ storeRoot }) {
|
|
63
|
+
this.storeRoot = path.resolve(storeRoot)
|
|
64
|
+
this.dir = path.join(this.storeRoot, 'namespaces')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
file(runtimeId) {
|
|
68
|
+
return path.join(this.dir, `${assertId(runtimeId, 'runtimeId')}.json`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
create(runtimeId, { token, ttlSec = 0, quotaBytes = 0, meta = {} } = {}) {
|
|
72
|
+
assertId(runtimeId, 'runtimeId')
|
|
73
|
+
fs.mkdirSync(this.dir, { recursive: true })
|
|
74
|
+
const secret = token || crypto.randomBytes(32).toString('hex')
|
|
75
|
+
const now = new Date().toISOString()
|
|
76
|
+
const record = {
|
|
77
|
+
schemaVersion: 1,
|
|
78
|
+
runtimeId,
|
|
79
|
+
tokenHash: hashToken(secret),
|
|
80
|
+
status: 'active',
|
|
81
|
+
createdAt: now,
|
|
82
|
+
lastAccessAt: now,
|
|
83
|
+
ttlSec: Number(ttlSec) || 0,
|
|
84
|
+
quotaBytes: Number(quotaBytes) || 0,
|
|
85
|
+
meta: meta && typeof meta === 'object' ? meta : {},
|
|
86
|
+
}
|
|
87
|
+
writeJsonAtomic(this.file(runtimeId), record)
|
|
88
|
+
return { runtimeId, token: secret, record }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
read(runtimeId) {
|
|
92
|
+
return readJson(this.file(runtimeId), null)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
verify(runtimeId, token) {
|
|
96
|
+
const record = this.read(runtimeId)
|
|
97
|
+
if (!record || record.status !== 'active') throw new WorkspaceAccessError()
|
|
98
|
+
if (!safeEqualHex(record.tokenHash, hashToken(token))) throw new WorkspaceAccessError()
|
|
99
|
+
if (record.ttlSec > 0) {
|
|
100
|
+
const last = Date.parse(record.lastAccessAt || record.createdAt)
|
|
101
|
+
if (Date.now() - last > record.ttlSec * 1000) throw new WorkspaceAccessError('运行时 namespace 已过期')
|
|
102
|
+
}
|
|
103
|
+
return record
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
touch(runtimeId) {
|
|
107
|
+
const record = this.read(runtimeId)
|
|
108
|
+
if (!record) return
|
|
109
|
+
record.lastAccessAt = new Date().toISOString()
|
|
110
|
+
writeJsonAtomic(this.file(runtimeId), record)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
remove(runtimeId) {
|
|
114
|
+
fs.rmSync(this.file(runtimeId), { force: true })
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class WorkspaceManager {
|
|
119
|
+
/* journalMaxBytes 默认 512KB:与 JournalStore / FileStateBackend 的进化固化值
|
|
120
|
+
* 保持一致(同构机制:原子 state + journal 轮转),尾读更快、磁盘更省。 */
|
|
121
|
+
constructor({ storeRoot, blobStore = null, journalMaxBytes = 512 * 1024 }) {
|
|
122
|
+
if (!storeRoot) throw new Error('WorkspaceManager 需要 storeRoot')
|
|
123
|
+
this.storeRoot = path.resolve(storeRoot)
|
|
124
|
+
this.blobs = blobStore || new BlobStore({ root: this.storeRoot }).init()
|
|
125
|
+
this.namespaces = new NamespaceRegistry({ storeRoot: this.storeRoot })
|
|
126
|
+
this.worksRoot = path.join(this.storeRoot, 'workspaces')
|
|
127
|
+
this.catalogFile = path.join(this.storeRoot, 'catalog', 'works.json')
|
|
128
|
+
this.locksRoot = path.join(this.storeRoot, 'locks')
|
|
129
|
+
this.journalMaxBytes = Math.max(64 * 1024, Number(journalMaxBytes) || 512 * 1024)
|
|
130
|
+
this._catalogTouched = new Map()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
init() {
|
|
134
|
+
fs.mkdirSync(this.worksRoot, { recursive: true })
|
|
135
|
+
fs.mkdirSync(path.join(this.storeRoot, 'catalog'), { recursive: true })
|
|
136
|
+
fs.mkdirSync(this.locksRoot, { recursive: true })
|
|
137
|
+
return this
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
workDir(runtimeId, workId) {
|
|
141
|
+
return path.join(this.worksRoot, assertId(runtimeId, 'runtimeId'), assertId(workId, 'workId'))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
manifestFile(runtimeId, workId) {
|
|
145
|
+
return path.join(this.workDir(runtimeId, workId), 'manifest.json')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
lockFile(kind, runtimeId, workId = '') {
|
|
149
|
+
const parts = [assertId(runtimeId, 'runtimeId'), workId ? assertId(workId, 'workId') : ''].filter(Boolean)
|
|
150
|
+
return path.join(this.locksRoot, kind, ...parts) + '.lock'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
emptyManifest(runtimeId, workId, title = '') {
|
|
154
|
+
const now = new Date().toISOString()
|
|
155
|
+
return {
|
|
156
|
+
schemaVersion: 1,
|
|
157
|
+
runtimeId,
|
|
158
|
+
workId,
|
|
159
|
+
title: title || workId,
|
|
160
|
+
createdAt: now,
|
|
161
|
+
updatedAt: now,
|
|
162
|
+
version: 0,
|
|
163
|
+
/* 中间层声明:这个 work 管哪些逻辑文件;allowNew 控制新 key 是否自动归属。 */
|
|
164
|
+
declaration: {
|
|
165
|
+
files: ['**'],
|
|
166
|
+
allowNew: true,
|
|
167
|
+
updatedAt: now,
|
|
168
|
+
},
|
|
169
|
+
files: {},
|
|
170
|
+
meta: {},
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 中间层声明一个 work 的文件范围。
|
|
176
|
+
* files 支持精确 key、目录前缀(以 / 结尾)和 /** 前缀通配。
|
|
177
|
+
* 声明本身不增加内容版本号;内容写入才增加 version。
|
|
178
|
+
*/
|
|
179
|
+
declareWork(runtimeId, workId, { files = ['**'], allowNew = true, title = '', meta = {} } = {}) {
|
|
180
|
+
assertId(runtimeId, 'runtimeId')
|
|
181
|
+
assertId(workId, 'workId')
|
|
182
|
+
if (!Array.isArray(files) || files.length === 0 || files.some((item) => typeof item !== 'string' || !item)) {
|
|
183
|
+
throw new Error('declaration.files 必须是非空字符串数组')
|
|
184
|
+
}
|
|
185
|
+
return withFileLock(this.lockFile('works', runtimeId, workId), () => {
|
|
186
|
+
const manifest = this.readManifest(runtimeId, workId) || this.emptyManifest(runtimeId, workId, title)
|
|
187
|
+
manifest.title = title || manifest.title || workId
|
|
188
|
+
manifest.declaration = {
|
|
189
|
+
files: [...new Set(files)],
|
|
190
|
+
allowNew: !!allowNew,
|
|
191
|
+
updatedAt: new Date().toISOString(),
|
|
192
|
+
}
|
|
193
|
+
if (meta && typeof meta === 'object') manifest.meta = { ...(manifest.meta || {}), ...meta }
|
|
194
|
+
manifest.updatedAt = new Date().toISOString()
|
|
195
|
+
this.writeManifest(manifest, { catalog: true })
|
|
196
|
+
return manifest
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
assertDeclared(manifest, key) {
|
|
201
|
+
const declaration = manifest?.declaration
|
|
202
|
+
if (!declaration || !Array.isArray(declaration.files) || declaration.files.length === 0) return true
|
|
203
|
+
const matched = declaration.files.some((pattern) => {
|
|
204
|
+
if (pattern === '*' || pattern === '**') return true
|
|
205
|
+
if (pattern.endsWith('/**')) return key.startsWith(pattern.slice(0, -2))
|
|
206
|
+
if (pattern.endsWith('/')) return key.startsWith(pattern)
|
|
207
|
+
return key === pattern
|
|
208
|
+
})
|
|
209
|
+
if (matched) return true
|
|
210
|
+
if (declaration.allowNew) return true
|
|
211
|
+
throw new WorkspaceAccessError(`文件 ${key} 不在 work ${manifest.workId} 的声明范围内`)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
readManifest(runtimeId, workId) {
|
|
215
|
+
return readJson(this.manifestFile(runtimeId, workId), null)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
writeManifest(manifest, { catalog = false } = {}) {
|
|
219
|
+
writeJsonAtomic(this.manifestFile(manifest.runtimeId, manifest.workId), manifest)
|
|
220
|
+
const key = `${manifest.runtimeId}/${manifest.workId}`
|
|
221
|
+
const last = this._catalogTouched.get(key) || 0
|
|
222
|
+
/* catalog 是展示/生命周期索引,不是热路径事务日志:按节流间隔写一次,
|
|
223
|
+
* 或在少数版本节点/显式操作时刷新,避免每次 state 保存都抢全局 catalog 锁。 */
|
|
224
|
+
if (catalog || manifest.version <= 5 || manifest.version % 50 === 0 || Date.now() - last > tuning().catalogThrottleMs) {
|
|
225
|
+
this.updateCatalog(manifest)
|
|
226
|
+
this._catalogTouched.set(key, Date.now())
|
|
227
|
+
}
|
|
228
|
+
return manifest
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** catalog 损坏时按磁盘 manifest 重建,绝不能把坏文件当成空库覆盖。 */
|
|
232
|
+
_readCatalogForUpdate() {
|
|
233
|
+
try {
|
|
234
|
+
const parsed = JSON.parse(fs.readFileSync(this.catalogFile, 'utf8'))
|
|
235
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (error.code !== 'ENOENT') {
|
|
238
|
+
const quarantine = `${this.catalogFile}.corrupt-${Date.now()}`
|
|
239
|
+
try { fs.renameSync(this.catalogFile, quarantine) } catch { /* 忽略 */ }
|
|
240
|
+
console.error(`[workspace] catalog 损坏,已隔离到 ${quarantine},按 manifest 重建`)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const works = {}
|
|
244
|
+
let runtimes = []
|
|
245
|
+
try { runtimes = fs.readdirSync(this.worksRoot, { withFileTypes: true }).filter((e) => e.isDirectory()) } catch { /* 空库 */ }
|
|
246
|
+
for (const runtime of runtimes) {
|
|
247
|
+
let entries = []
|
|
248
|
+
try { entries = fs.readdirSync(path.join(this.worksRoot, runtime.name), { withFileTypes: true }) } catch { continue }
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
if (!entry.isDirectory()) continue
|
|
251
|
+
const manifest = this.readManifest(runtime.name, entry.name)
|
|
252
|
+
if (!manifest) continue
|
|
253
|
+
const total = Object.values(manifest.files || {}).reduce((sum, item) => sum + (Number(item.size) || 0), 0)
|
|
254
|
+
works[`${manifest.runtimeId}/${manifest.workId}`] = {
|
|
255
|
+
runtimeId: manifest.runtimeId,
|
|
256
|
+
workId: manifest.workId,
|
|
257
|
+
title: manifest.title || '',
|
|
258
|
+
version: manifest.version || 0,
|
|
259
|
+
files: Object.keys(manifest.files || {}).length,
|
|
260
|
+
size: total,
|
|
261
|
+
createdAt: manifest.createdAt,
|
|
262
|
+
updatedAt: manifest.updatedAt,
|
|
263
|
+
declaration: manifest.declaration || null,
|
|
264
|
+
meta: manifest.meta || {},
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return { schemaVersion: 1, updatedAt: '', works }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
updateCatalog(manifest) {
|
|
272
|
+
withFileLock(path.join(this.locksRoot, 'catalog.lock'), () => {
|
|
273
|
+
const catalog = this._readCatalogForUpdate()
|
|
274
|
+
const total = Object.values(manifest.files || {}).reduce((sum, item) => sum + (Number(item.size) || 0), 0)
|
|
275
|
+
catalog.works ||= {}
|
|
276
|
+
catalog.works[`${manifest.runtimeId}/${manifest.workId}`] = {
|
|
277
|
+
runtimeId: manifest.runtimeId,
|
|
278
|
+
workId: manifest.workId,
|
|
279
|
+
title: manifest.title || '',
|
|
280
|
+
version: manifest.version || 0,
|
|
281
|
+
files: Object.keys(manifest.files || {}).length,
|
|
282
|
+
size: total,
|
|
283
|
+
createdAt: manifest.createdAt,
|
|
284
|
+
updatedAt: manifest.updatedAt,
|
|
285
|
+
declaration: manifest.declaration || null,
|
|
286
|
+
meta: manifest.meta || {},
|
|
287
|
+
}
|
|
288
|
+
catalog.updatedAt = new Date().toISOString()
|
|
289
|
+
writeJsonAtomic(this.catalogFile, catalog)
|
|
290
|
+
})
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
ensureWork(runtimeId, workId, { title = '', meta = {} } = {}) {
|
|
294
|
+
assertId(runtimeId, 'runtimeId')
|
|
295
|
+
assertId(workId, 'workId')
|
|
296
|
+
const file = this.manifestFile(runtimeId, workId)
|
|
297
|
+
if (fs.existsSync(file)) return this.readManifest(runtimeId, workId)
|
|
298
|
+
return withFileLock(this.lockFile('works', runtimeId, workId), () => {
|
|
299
|
+
/* 双重检查:两个实例同时初始化同一 work 时只允许一个写 v0。 */
|
|
300
|
+
if (fs.existsSync(file)) return this.readManifest(runtimeId, workId)
|
|
301
|
+
const manifest = this.emptyManifest(runtimeId, workId, title)
|
|
302
|
+
manifest.meta = meta && typeof meta === 'object' ? meta : {}
|
|
303
|
+
return this.writeManifest(manifest, { catalog: true })
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** 中间层解释逻辑 key → 对象 hash;仓库只收到字节。 */
|
|
308
|
+
put(runtimeId, workId, key, data, { expectedVersion = null, contentType = 'application/octet-stream', title = '' } = {}) {
|
|
309
|
+
assertKey(key)
|
|
310
|
+
this.ensureWork(runtimeId, workId, { title })
|
|
311
|
+
return withFileLock(this.lockFile('works', runtimeId, workId), () => {
|
|
312
|
+
const manifest = this.readManifest(runtimeId, workId) || this.emptyManifest(runtimeId, workId, title)
|
|
313
|
+
if (expectedVersion != null && Number(expectedVersion) !== manifest.version) {
|
|
314
|
+
throw new WorkspaceConflictError(`版本冲突: 当前 ${manifest.version},期望 ${expectedVersion}`)
|
|
315
|
+
}
|
|
316
|
+
this.assertDeclared(manifest, key)
|
|
317
|
+
const blob = this.blobs.put(data)
|
|
318
|
+
const now = new Date().toISOString()
|
|
319
|
+
manifest.files[key] = {
|
|
320
|
+
blob: blob.hash,
|
|
321
|
+
size: blob.size,
|
|
322
|
+
contentType,
|
|
323
|
+
updatedAt: now,
|
|
324
|
+
}
|
|
325
|
+
manifest.version = (manifest.version | 0) + 1
|
|
326
|
+
manifest.updatedAt = now
|
|
327
|
+
if (title) manifest.title = title
|
|
328
|
+
this.writeManifest(manifest)
|
|
329
|
+
return { key, blob: blob.hash, size: blob.size, version: manifest.version }
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
get(runtimeId, workId, key, fallback = undefined) {
|
|
334
|
+
assertKey(key)
|
|
335
|
+
const manifest = this.readManifest(runtimeId, workId)
|
|
336
|
+
if (!manifest || !manifest.files[key]) {
|
|
337
|
+
if (fallback !== undefined) return fallback
|
|
338
|
+
throw new Error(`对象不存在: ${runtimeId}/${workId}/${key}`)
|
|
339
|
+
}
|
|
340
|
+
return this.blobs.get(manifest.files[key].blob)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
list(runtimeId, workId) {
|
|
344
|
+
const manifest = this.readManifest(runtimeId, workId)
|
|
345
|
+
return manifest ? Object.entries(manifest.files).map(([key, item]) => ({ key, ...item })) : []
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
delete(runtimeId, workId, key, { expectedVersion = null } = {}) {
|
|
349
|
+
assertKey(key)
|
|
350
|
+
return withFileLock(this.lockFile('works', runtimeId, workId), () => {
|
|
351
|
+
const manifest = this.readManifest(runtimeId, workId)
|
|
352
|
+
if (!manifest) throw new Error(`work 不存在: ${runtimeId}/${workId}`)
|
|
353
|
+
if (expectedVersion != null && Number(expectedVersion) !== manifest.version) {
|
|
354
|
+
throw new WorkspaceConflictError(`版本冲突: 当前 ${manifest.version},期望 ${expectedVersion}`)
|
|
355
|
+
}
|
|
356
|
+
this.assertDeclared(manifest, key)
|
|
357
|
+
delete manifest.files[key]
|
|
358
|
+
manifest.version = (manifest.version | 0) + 1
|
|
359
|
+
manifest.updatedAt = new Date().toISOString()
|
|
360
|
+
this.writeManifest(manifest, { catalog: true })
|
|
361
|
+
return { key, version: manifest.version }
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
append(runtimeId, workId, key, text) {
|
|
366
|
+
assertKey(key)
|
|
367
|
+
return withFileLock(this.lockFile('works', runtimeId, workId), () => {
|
|
368
|
+
const manifest = this.readManifest(runtimeId, workId) || this.emptyManifest(runtimeId, workId)
|
|
369
|
+
this.assertDeclared(manifest, key)
|
|
370
|
+
const now = new Date().toISOString()
|
|
371
|
+
let current = Buffer.alloc(0)
|
|
372
|
+
if (manifest.files[key]) current = this.blobs.get(manifest.files[key].blob)
|
|
373
|
+
const extra = Buffer.from(text)
|
|
374
|
+
/* journal 单文件超过阈值时轮转到 .1,避免每次 append 重写无上限增长的历史。 */
|
|
375
|
+
if (key === 'journal.jsonl' && current.length && current.length + extra.length > this.journalMaxBytes) {
|
|
376
|
+
const rotated = this.blobs.put(current)
|
|
377
|
+
manifest.files['journal.jsonl.1'] = {
|
|
378
|
+
blob: rotated.hash,
|
|
379
|
+
size: rotated.size,
|
|
380
|
+
contentType: 'text/plain; charset=utf-8',
|
|
381
|
+
updatedAt: now,
|
|
382
|
+
}
|
|
383
|
+
current = Buffer.alloc(0)
|
|
384
|
+
}
|
|
385
|
+
const blob = this.blobs.put(Buffer.concat([current, extra]))
|
|
386
|
+
manifest.files[key] = {
|
|
387
|
+
blob: blob.hash,
|
|
388
|
+
size: blob.size,
|
|
389
|
+
contentType: 'text/plain; charset=utf-8',
|
|
390
|
+
updatedAt: now,
|
|
391
|
+
}
|
|
392
|
+
manifest.version = (manifest.version | 0) + 1
|
|
393
|
+
manifest.updatedAt = now
|
|
394
|
+
this.writeManifest(manifest)
|
|
395
|
+
return { key, blob: blob.hash, size: blob.size, version: manifest.version }
|
|
396
|
+
})
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
tail(runtimeId, workId, key, limit = 8) {
|
|
400
|
+
try {
|
|
401
|
+
const text = this.get(runtimeId, workId, key).toString('utf8').trim()
|
|
402
|
+
if (!text) return []
|
|
403
|
+
return text.split(/\r?\n/).filter(Boolean).slice(-limit).map((line) => {
|
|
404
|
+
try { return JSON.parse(line) } catch { return { type: 'invalid', raw: line.slice(0, 120) } }
|
|
405
|
+
})
|
|
406
|
+
} catch {
|
|
407
|
+
return []
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** 从磁盘 manifest 重建 catalog;用于 CLI/UI 读取前刷新最终一致性索引。 */
|
|
412
|
+
refreshCatalog({ runtimeId = '' } = {}) {
|
|
413
|
+
const works = []
|
|
414
|
+
let runtimes = []
|
|
415
|
+
try { runtimes = fs.readdirSync(this.worksRoot, { withFileTypes: true }).filter((e) => e.isDirectory()) } catch { return works }
|
|
416
|
+
for (const runtime of runtimes) {
|
|
417
|
+
if (runtimeId && runtime.name !== runtimeId) continue
|
|
418
|
+
let entries = []
|
|
419
|
+
try { entries = fs.readdirSync(path.join(this.worksRoot, runtime.name), { withFileTypes: true }) } catch { continue }
|
|
420
|
+
for (const entry of entries) {
|
|
421
|
+
if (!entry.isDirectory()) continue
|
|
422
|
+
const manifest = this.readManifest(runtime.name, entry.name)
|
|
423
|
+
if (!manifest) continue
|
|
424
|
+
this.updateCatalog(manifest)
|
|
425
|
+
works.push(manifest)
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return works
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
listWorks({ runtimeId = '' } = {}) {
|
|
432
|
+
const catalog = readJson(this.catalogFile, null)
|
|
433
|
+
if (catalog && catalog.works) {
|
|
434
|
+
return Object.values(catalog.works)
|
|
435
|
+
.filter((item) => !runtimeId || item.runtimeId === runtimeId)
|
|
436
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))
|
|
437
|
+
}
|
|
438
|
+
const works = []
|
|
439
|
+
let runtimes = []
|
|
440
|
+
try { runtimes = fs.readdirSync(this.worksRoot, { withFileTypes: true }).filter((e) => e.isDirectory()) } catch { /* 空库 */ }
|
|
441
|
+
for (const runtime of runtimes) {
|
|
442
|
+
if (runtimeId && runtime.name !== runtimeId) continue
|
|
443
|
+
let entries = []
|
|
444
|
+
try { entries = fs.readdirSync(path.join(this.worksRoot, runtime.name), { withFileTypes: true }) } catch { /* 忽略 */ }
|
|
445
|
+
for (const entry of entries) {
|
|
446
|
+
if (!entry.isDirectory()) continue
|
|
447
|
+
const manifest = this.readManifest(runtime.name, entry.name)
|
|
448
|
+
if (manifest) works.push(manifest)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return works
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
referencedHashes() {
|
|
455
|
+
const referenced = new Set()
|
|
456
|
+
for (const summary of this.listWorks()) {
|
|
457
|
+
const manifest = this.readManifest(summary.runtimeId, summary.workId)
|
|
458
|
+
for (const item of Object.values(manifest?.files || {})) if (item?.blob) referenced.add(item.blob)
|
|
459
|
+
}
|
|
460
|
+
return referenced
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
gc({ minAgeMs = 60000 } = {}) {
|
|
464
|
+
return withFileLock(path.join(this.locksRoot, 'gc.lock'), () => {
|
|
465
|
+
/* 刚写入的 blob 可能还没写进 manifest;留出宽限期防止 GC 与 put 竞态。 */
|
|
466
|
+
return this.blobs.gc(this.referencedHashes(), { minAgeMs })
|
|
467
|
+
})
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
removeWork(runtimeId, workId) {
|
|
471
|
+
fs.rmSync(this.workDir(runtimeId, workId), { recursive: true, force: true })
|
|
472
|
+
withFileLock(path.join(this.locksRoot, 'catalog.lock'), () => {
|
|
473
|
+
const catalog = this._readCatalogForUpdate()
|
|
474
|
+
delete catalog.works?.[`${runtimeId}/${workId}`]
|
|
475
|
+
catalog.updatedAt = new Date().toISOString()
|
|
476
|
+
writeJsonAtomic(this.catalogFile, catalog)
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
removeRuntime(runtimeId) {
|
|
481
|
+
assertId(runtimeId, 'runtimeId')
|
|
482
|
+
fs.rmSync(path.join(this.worksRoot, runtimeId), { recursive: true, force: true })
|
|
483
|
+
this.namespaces.remove(runtimeId)
|
|
484
|
+
withFileLock(path.join(this.locksRoot, 'catalog.lock'), () => {
|
|
485
|
+
const catalog = this._readCatalogForUpdate()
|
|
486
|
+
for (const key of Object.keys(catalog.works || {})) {
|
|
487
|
+
if (key.startsWith(`${runtimeId}/`)) delete catalog.works[key]
|
|
488
|
+
}
|
|
489
|
+
catalog.updatedAt = new Date().toISOString()
|
|
490
|
+
writeJsonAtomic(this.catalogFile, catalog)
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** 绑定到单个 runtime / work 的客户端;每次操作校验 namespace token。 */
|
|
496
|
+
export class WorkspaceClient {
|
|
497
|
+
constructor({ manager, runtimeId, token, workId = 'main' } = {}) {
|
|
498
|
+
/* 结构化契约:允许同版本 kit 快照跨包提供 manager,不依赖类身份。 */
|
|
499
|
+
if (!manager || typeof manager.put !== 'function' || typeof manager.readManifest !== 'function') {
|
|
500
|
+
throw new Error('WorkspaceClient 需要 WorkspaceManager 兼容实现')
|
|
501
|
+
}
|
|
502
|
+
this.manager = manager
|
|
503
|
+
this.runtimeId = assertId(runtimeId, 'runtimeId')
|
|
504
|
+
this.token = token
|
|
505
|
+
this.workId = assertId(workId, 'workId')
|
|
506
|
+
this._verifiedAt = 0
|
|
507
|
+
this._verify(true)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
_verify(force = false) {
|
|
511
|
+
const now = Date.now()
|
|
512
|
+
if (!force && this._verifiedAt && now - this._verifiedAt < tuning().tokenVerifyCacheMs) return
|
|
513
|
+
this.manager.namespaces.verify(this.runtimeId, this.token)
|
|
514
|
+
this.manager.namespaces.touch(this.runtimeId)
|
|
515
|
+
this._verifiedAt = now
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
ensureWork(workId = this.workId, options = {}) {
|
|
519
|
+
this._verify()
|
|
520
|
+
return this.manager.ensureWork(this.runtimeId, workId, options)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
put(workId, key, data, options = {}) {
|
|
524
|
+
this._verify()
|
|
525
|
+
return this.manager.put(this.runtimeId, workId, key, data, options)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
putJson(workId, key, value, options = {}) {
|
|
529
|
+
return this.put(workId, key, Buffer.from(JSON.stringify(value)), {
|
|
530
|
+
contentType: 'application/json; charset=utf-8',
|
|
531
|
+
...options,
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
get(workId, key, fallback = undefined) {
|
|
536
|
+
this._verify()
|
|
537
|
+
return this.manager.get(this.runtimeId, workId, key, fallback)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
getJson(workId, key, fallback = undefined) {
|
|
541
|
+
const data = this.get(workId, key, fallback === undefined ? undefined : Buffer.from(JSON.stringify(fallback)))
|
|
542
|
+
if (data === undefined) return undefined
|
|
543
|
+
try { return JSON.parse(data.toString('utf8')) } catch { return fallback }
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
list(workId) {
|
|
547
|
+
this._verify()
|
|
548
|
+
return this.manager.list(this.runtimeId, workId)
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
delete(workId, key, options = {}) {
|
|
552
|
+
this._verify()
|
|
553
|
+
return this.manager.delete(this.runtimeId, workId, key, options)
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
append(workId, key, text) {
|
|
557
|
+
this._verify()
|
|
558
|
+
return this.manager.append(this.runtimeId, workId, key, text)
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
tail(workId, key, limit = 8) {
|
|
562
|
+
this._verify()
|
|
563
|
+
return this.manager.tail(this.runtimeId, workId, key, limit)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
manifest(workId = this.workId) {
|
|
567
|
+
this._verify()
|
|
568
|
+
return this.manager.readManifest(this.runtimeId, workId)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
listWorks() {
|
|
572
|
+
this._verify()
|
|
573
|
+
return this.manager.listWorks({ runtimeId: this.runtimeId })
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** 插件的 JournalStore 持久化接口 → 中间层 workspace。 */
|
|
578
|
+
export class WorkspaceBackend {
|
|
579
|
+
constructor({ client, workId = 'main', journalPath = null }) {
|
|
580
|
+
/* 结构化契约:workspace-service 可能来自另一份 kit 快照。 */
|
|
581
|
+
if (!client || typeof client.put !== 'function' || typeof client.getJson !== 'function') {
|
|
582
|
+
throw new Error('WorkspaceBackend 需要 WorkspaceClient 兼容实现')
|
|
583
|
+
}
|
|
584
|
+
this.client = client
|
|
585
|
+
this.workId = workId
|
|
586
|
+
this.journalPath = journalPath || null
|
|
587
|
+
this.stateKey = 'state.json'
|
|
588
|
+
this.journalKey = 'journal.jsonl'
|
|
589
|
+
this.checkpointPrefix = 'checkpoints/'
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
readState() {
|
|
593
|
+
return this.client.getJson(this.workId, this.stateKey, null)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
writeState(doc) {
|
|
597
|
+
const result = this.client.putJson(this.workId, this.stateKey, doc)
|
|
598
|
+
return { ...result, stateKey: this.stateKey }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
appendJournal(event) {
|
|
602
|
+
try {
|
|
603
|
+
this.client.append(this.workId, this.journalKey, JSON.stringify({ ...event, ts: Date.now() / 1000 }) + '\n')
|
|
604
|
+
} catch { /* journal 失败不阻塞主状态 */ }
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
journalTail(limit = 8) {
|
|
608
|
+
const parse = (text) => String(text || '').trim().split(/\r?\n/).filter(Boolean)
|
|
609
|
+
const lines = []
|
|
610
|
+
for (const key of ['journal.jsonl.1', 'journal.jsonl']) {
|
|
611
|
+
try {
|
|
612
|
+
const data = this.client.get(this.workId, key, null)
|
|
613
|
+
if (data) lines.push(...parse(data.toString('utf8')))
|
|
614
|
+
} catch { /* 轮转文件可能不存在 */ }
|
|
615
|
+
}
|
|
616
|
+
return lines.slice(-limit).map((line) => {
|
|
617
|
+
try { return JSON.parse(line) } catch { return { type: 'invalid', raw: line.slice(0, 120) } }
|
|
618
|
+
})
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
writeCheckpoint(name, payload) {
|
|
622
|
+
const key = `${this.checkpointPrefix}${name}.json`
|
|
623
|
+
this.client.putJson(this.workId, key, payload)
|
|
624
|
+
this.appendJournal({ type: 'checkpoint', name })
|
|
625
|
+
return { name, createdAt: payload.createdAt, meta: payload.meta || {} }
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
listCheckpoints() {
|
|
629
|
+
return this.client.list(this.workId)
|
|
630
|
+
.filter((item) => item.key.startsWith(this.checkpointPrefix) && item.key.endsWith('.json'))
|
|
631
|
+
.map((item) => {
|
|
632
|
+
try {
|
|
633
|
+
const payload = this.client.getJson(this.workId, item.key)
|
|
634
|
+
if (!payload) return null
|
|
635
|
+
return {
|
|
636
|
+
name: payload.name,
|
|
637
|
+
seq: payload.seq,
|
|
638
|
+
appliedSeq: payload.appliedSeq,
|
|
639
|
+
pending: !!payload.pending,
|
|
640
|
+
title: payload.title,
|
|
641
|
+
slides: payload.slides,
|
|
642
|
+
createdAt: payload.createdAt,
|
|
643
|
+
meta: payload.meta || {},
|
|
644
|
+
}
|
|
645
|
+
} catch { return null }
|
|
646
|
+
})
|
|
647
|
+
.filter(Boolean)
|
|
648
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)))
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
readCheckpoint(name) {
|
|
652
|
+
const key = `${this.checkpointPrefix}${name}.json`
|
|
653
|
+
const payload = this.client.getJson(this.workId, key, null)
|
|
654
|
+
if (!payload || !payload.deck) throw new Error('checkpoint 内容损坏: ' + name)
|
|
655
|
+
return payload
|
|
656
|
+
}
|
|
657
|
+
}
|