@lokvis/runtime 0.1.0 → 0.2.0
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/dist/asset-store.d.ts +5 -2
- package/dist/asset-store.d.ts.map +1 -1
- package/dist/asset-store.js +93 -3
- package/dist/asset-store.js.map +1 -1
- package/dist/batch-processor.d.ts +200 -0
- package/dist/batch-processor.d.ts.map +1 -0
- package/dist/batch-processor.js +505 -0
- package/dist/batch-processor.js.map +1 -0
- package/dist/capability-registry.d.ts +4 -0
- package/dist/capability-registry.d.ts.map +1 -1
- package/dist/capability-registry.js +19 -2
- package/dist/capability-registry.js.map +1 -1
- package/dist/degradation.d.ts +109 -0
- package/dist/degradation.d.ts.map +1 -0
- package/dist/degradation.js +184 -0
- package/dist/degradation.js.map +1 -0
- package/dist/event-bus.d.ts.map +1 -1
- package/dist/event-bus.js +7 -2
- package/dist/event-bus.js.map +1 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +37 -1
- package/dist/executor.js.map +1 -1
- package/dist/history-store.d.ts +76 -0
- package/dist/history-store.d.ts.map +1 -0
- package/dist/history-store.js +109 -0
- package/dist/history-store.js.map +1 -0
- package/dist/history.d.ts +1 -1
- package/dist/history.d.ts.map +1 -1
- package/dist/history.js +1 -1
- package/dist/history.js.map +1 -1
- package/dist/idb-asset-store.js +1 -1
- package/dist/idb-asset-store.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/memory-guard.d.ts +109 -0
- package/dist/memory-guard.d.ts.map +1 -0
- package/dist/memory-guard.js +161 -0
- package/dist/memory-guard.js.map +1 -0
- package/dist/opfs-asset-store.d.ts +35 -4
- package/dist/opfs-asset-store.d.ts.map +1 -1
- package/dist/opfs-asset-store.js +94 -10
- package/dist/opfs-asset-store.js.map +1 -1
- package/dist/runtime.d.ts +139 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +443 -25
- package/dist/runtime.js.map +1 -1
- package/dist/types.d.ts +101 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/worker-host.d.ts +13 -0
- package/dist/worker-host.d.ts.map +1 -1
- package/dist/worker-host.js +47 -15
- package/dist/worker-host.js.map +1 -1
- package/dist/worker-protocol.d.ts +10 -1
- package/dist/worker-protocol.d.ts.map +1 -1
- package/dist/worker-protocol.js.map +1 -1
- package/dist/workflow-builder.d.ts +124 -0
- package/dist/workflow-builder.d.ts.map +1 -0
- package/dist/workflow-builder.js +240 -0
- package/dist/workflow-builder.js.map +1 -0
- package/package.json +3 -2
- package/src/asset-store.ts +103 -3
- package/src/batch-processor.ts +699 -0
- package/src/capability-registry.ts +19 -2
- package/src/degradation.ts +241 -0
- package/src/event-bus.ts +6 -2
- package/src/executor.ts +46 -1
- package/src/history-store.ts +159 -0
- package/src/history.ts +2 -2
- package/src/idb-asset-store.ts +1 -1
- package/src/index.ts +5 -0
- package/src/memory-guard.ts +202 -0
- package/src/opfs-asset-store.ts +118 -10
- package/src/runtime.ts +482 -21
- package/src/types.ts +98 -1
- package/src/worker-host.ts +56 -16
- package/src/worker-protocol.ts +11 -1
- package/src/workflow-builder.ts +303 -0
package/src/runtime.ts
CHANGED
|
@@ -11,12 +11,15 @@ import type {
|
|
|
11
11
|
Capability,
|
|
12
12
|
CapabilityParam,
|
|
13
13
|
CapabilityParamType,
|
|
14
|
+
ExifData,
|
|
14
15
|
HistoryEntry,
|
|
15
16
|
LokvisEvent,
|
|
16
17
|
McpManifest,
|
|
17
18
|
McpToolManifest,
|
|
19
|
+
MetadataReader,
|
|
18
20
|
} from '@lokvis/schema';
|
|
19
21
|
import type { Workflow, WorkflowResult } from '@lokvis/schema';
|
|
22
|
+
import { validateWorkflow } from '@lokvis/schema';
|
|
20
23
|
import type {
|
|
21
24
|
LokvisRuntime,
|
|
22
25
|
RuntimeConfig,
|
|
@@ -33,13 +36,32 @@ import {
|
|
|
33
36
|
} from './asset-store.js';
|
|
34
37
|
import { CapabilityRegistry } from './capability-registry.js';
|
|
35
38
|
import { WorkflowExecutor } from './executor.js';
|
|
39
|
+
import { MAX_WORKFLOW_STEPS } from './workflow-builder.js';
|
|
36
40
|
import { HistoryStack, type HistoryStackConfig } from './history.js';
|
|
41
|
+
import {
|
|
42
|
+
createHistoryStore,
|
|
43
|
+
type HistoryStore,
|
|
44
|
+
} from './history-store.js';
|
|
45
|
+
import { BatchProcessor } from './batch-processor.js';
|
|
46
|
+
import { MemoryGuard, DEFAULT_MEMORY_BUDGET } from './memory-guard.js';
|
|
37
47
|
|
|
38
48
|
export const RUNTIME_VERSION = '0.1.0';
|
|
39
49
|
|
|
40
50
|
/** 默认历史记录上限 */
|
|
41
51
|
const DEFAULT_MAX_HISTORY = 10;
|
|
42
52
|
|
|
53
|
+
/**
|
|
54
|
+
* 同时持有的工作流历史栈上限(W2.8 内存治理)。
|
|
55
|
+
*
|
|
56
|
+
* 修复 review 报告:原实现 historyStacks 是无限增长 Map,每次 run() 都加入新
|
|
57
|
+
* workflow.id(ui-react buildLinearWorkflow 用 `wf_${Date.now()}` 每次唯一),
|
|
58
|
+
* 长会话累积导致 Map 引用的 AssetId 无法回收 → 内存泄漏。
|
|
59
|
+
*
|
|
60
|
+
* 32 是经验值:覆盖用户常见使用(多 tab 切换 + undo 范围),超限按 FIFO
|
|
61
|
+
* 清理最旧 stack(reset 触发 onEvict → assetStore.remove 回收资产)。
|
|
62
|
+
*/
|
|
63
|
+
const MAX_CONCURRENT_WORKFLOW_STACKS = 32;
|
|
64
|
+
|
|
43
65
|
/** 存储配额超限时抛出(W2.9) */
|
|
44
66
|
export class QuotaExceededError extends Error {
|
|
45
67
|
readonly usage: number;
|
|
@@ -74,7 +96,18 @@ function estimateSourceSize(source: AssetSource): number {
|
|
|
74
96
|
* 并发安全:import/create/remove 通过 promise 链串行化,避免 check 与 update
|
|
75
97
|
* 之间的 TOCTOU 窗口导致两个并发操作都基于旧 usage 通过校验或回退。
|
|
76
98
|
*/
|
|
77
|
-
|
|
99
|
+
/**
|
|
100
|
+
* 配额感知的 AssetStore:在 AssetStore 接口之上扩展 _getQuotaUsage,
|
|
101
|
+
* 供 runtime.getStorageUsage O(1) 读取内部 usage(下划线表"内部 API")。
|
|
102
|
+
*/
|
|
103
|
+
type QuotaAwareAssetStore = AssetStore & {
|
|
104
|
+
_getQuotaUsage: () => number;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function wrapAssetStoreWithQuota(
|
|
108
|
+
inner: AssetStore,
|
|
109
|
+
quota: number
|
|
110
|
+
): QuotaAwareAssetStore {
|
|
78
111
|
let usage = 0;
|
|
79
112
|
let initialized = false;
|
|
80
113
|
/** 串行化 import/create/remove 的 chain tail,确保 check-update 原子性 */
|
|
@@ -135,12 +168,23 @@ function wrapAssetStoreWithQuota(inner: AssetStore, quota: number): AssetStore {
|
|
|
135
168
|
async create(blob, metadata, type) {
|
|
136
169
|
return runExclusive(async () => {
|
|
137
170
|
await ensureInit();
|
|
138
|
-
|
|
171
|
+
// m8 修复:配额预检与累加使用同一口径(metadata.size),
|
|
172
|
+
// 避免 blob.size 与 metadata.size 漂移导致账面与预检不一致
|
|
173
|
+
assertQuota(metadata.size);
|
|
139
174
|
const asset = await inner.create(blob, metadata, type);
|
|
140
175
|
usage += asset.metadata.size;
|
|
141
176
|
return asset;
|
|
142
177
|
});
|
|
143
178
|
},
|
|
179
|
+
/**
|
|
180
|
+
* m6 优化:暴露配额包装器内部维护的 usage(O(1)),
|
|
181
|
+
* 供 runtime.getStorageUsage 优先使用,避免每次 O(n) 全量 listAssets。
|
|
182
|
+
* 下划线前缀表"内部 API",非 AssetStore 接口一部分。
|
|
183
|
+
* 若 ensureInit 未完成,返回 -1 表示"未就绪",调用方 fallback 到 listAssets。
|
|
184
|
+
*/
|
|
185
|
+
_getQuotaUsage(): number {
|
|
186
|
+
return initialized ? usage : -1;
|
|
187
|
+
},
|
|
144
188
|
};
|
|
145
189
|
}
|
|
146
190
|
|
|
@@ -149,21 +193,73 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
149
193
|
readonly version = RUNTIME_VERSION;
|
|
150
194
|
readonly eventBus: EventBus;
|
|
151
195
|
|
|
152
|
-
private config: Required<Omit<RuntimeConfig, 'assetStore'>>;
|
|
196
|
+
private config: Required<Omit<RuntimeConfig, 'assetStore' | 'historyStore' | 'historyStoreOptions'>>;
|
|
153
197
|
private _status: RuntimeStatus = 'idle';
|
|
154
|
-
|
|
198
|
+
/**
|
|
199
|
+
* AssetStore 实例。类型为 QuotaAwareAssetStore —— 由 wrapAssetStoreWithQuota
|
|
200
|
+
* 返回(在构造函数中无条件包裹,即使是注入的 assetStore 也会被包装以提供配额校验)。
|
|
201
|
+
* 保留 _getQuotaUsage 内部 API 供 getStorageUsage O(1) 读取 usage。
|
|
202
|
+
* 注:外部注入的 assetStore 在 wrapAssetStoreWithQuota 中同样被包装,
|
|
203
|
+
* 因此所有路径下 this.assetStore 都是 QuotaAwareAssetStore。
|
|
204
|
+
*/
|
|
205
|
+
private assetStore: QuotaAwareAssetStore;
|
|
155
206
|
private capabilityRegistry: CapabilityRegistry;
|
|
156
207
|
private executor: WorkflowExecutor;
|
|
208
|
+
private memoryGuard: MemoryGuard;
|
|
209
|
+
private batchProcessor: BatchProcessor;
|
|
157
210
|
/**
|
|
158
211
|
* 每个工作流独立的 HistoryStack。
|
|
159
|
-
*
|
|
160
|
-
*
|
|
212
|
+
* W7.2 起,栈快照(entries + cursor)连同 initialInputs / currentOutputs
|
|
213
|
+
* 通过 historyStore 持久化到 IndexedDB,刷新后可恢复。
|
|
161
214
|
*/
|
|
162
215
|
private historyStacks = new Map<string, HistoryStack>();
|
|
163
216
|
/** 记录每个工作流的初始输入 AssetId(undo 回到初始时使用) */
|
|
164
217
|
private initialInputsMap = new Map<string, AssetId[]>();
|
|
165
218
|
/** 记录每个工作流当前的输出 AssetId(undo/redo 后切换"当前") */
|
|
166
219
|
private currentOutputsMap = new Map<string, AssetId[]>();
|
|
220
|
+
/**
|
|
221
|
+
* 历史持久化存储(W7.2)。undefined 时退化为仅内存历史(刷新后丢失)。
|
|
222
|
+
* 由 createRuntime 在 enableIndexedDB 时自动创建,或通过 config 注入。
|
|
223
|
+
*/
|
|
224
|
+
private historyStore: HistoryStore | undefined;
|
|
225
|
+
/**
|
|
226
|
+
* 加载持久化历史快照期间的守卫标志。
|
|
227
|
+
* restore() 会触发 onChanged → persistHistory,此时跳过写回,
|
|
228
|
+
* 避免把刚读出的数据又重复写入(冗余 IO + 潜在覆盖竞态)。
|
|
229
|
+
*/
|
|
230
|
+
private isLoadingHistory = false;
|
|
231
|
+
/**
|
|
232
|
+
* 加载期间被 onChanged 标记为"dirty"的工作流 ID 集合(W7.2 review 修复)。
|
|
233
|
+
*
|
|
234
|
+
* 原实现:isLoadingHistory 期间所有 persistHistory 调用直接 return,
|
|
235
|
+
* 若加载期间有其他来源(run / undo / 外部事件)触发 onChanged,
|
|
236
|
+
* 这些变更会被永久丢弃(加载结束后不会重发 persist)。
|
|
237
|
+
*
|
|
238
|
+
* 现策略:加载期间被跳过的 persistHistory 把 workflowId 加入此 Set,
|
|
239
|
+
* loadPersistedHistory 结束后逐个补 persist,确保不丢变更。
|
|
240
|
+
* (restore() 自身触发的 onChanged 也加入,但其内容与刚读出的相同,
|
|
241
|
+
* 补 persist 仅多一次等价写回,幂等无害。)
|
|
242
|
+
*/
|
|
243
|
+
private dirtyDuringLoad = new Set<string>();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* 元数据读取器注册表(W7.3/7.4 长期方案:MetadataReader 依赖反转)。
|
|
247
|
+
*
|
|
248
|
+
* Plugin(plugin-image)通过 PluginContext.registerMetadataReader 注册
|
|
249
|
+
* 查询函数(如 'image.read-exif'),Runtime 持有引用并按名调用。
|
|
250
|
+
* 解决了 readExif(Blob→ExifData)不符合 Engine 层 Blob↔Blob 纯函数约束、
|
|
251
|
+
* 也不符合 CapabilityImplementation Asset[]→Asset[] 契约的问题。
|
|
252
|
+
* UI 经 runtime.readAssetExif 间接调用,不直接依赖 plugin/engine。
|
|
253
|
+
*/
|
|
254
|
+
private metadataReaders = new Map<string, MetadataReader>();
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 注册元数据读取器(由 PluginContext.registerMetadataReader 转发)。
|
|
258
|
+
* 下划线前缀表示内部 API,不暴露在 LokvisRuntime 公开接口。
|
|
259
|
+
*/
|
|
260
|
+
_registerMetadataReader(name: string, reader: MetadataReader): void {
|
|
261
|
+
this.metadataReaders.set(name, reader);
|
|
262
|
+
}
|
|
167
263
|
|
|
168
264
|
constructor(config: RuntimeConfig = {}) {
|
|
169
265
|
this.config = {
|
|
@@ -172,14 +268,32 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
172
268
|
storageQuota: config.storageQuota ?? 1024 * 1024 * 1024, // 1GB
|
|
173
269
|
enableLog: config.enableLog ?? true,
|
|
174
270
|
engineStrategy: config.engineStrategy ?? 'first',
|
|
271
|
+
isPro: config.isPro ?? false,
|
|
272
|
+
memoryBudget: config.memoryBudget ?? DEFAULT_MEMORY_BUDGET,
|
|
175
273
|
};
|
|
176
274
|
|
|
177
275
|
this.eventBus = createEventBus();
|
|
178
276
|
|
|
179
277
|
// 注入或降级到内存 store;随后用配额校验包裹(W2.9)
|
|
278
|
+
// M6 修复:enableOpfs/enableIndexedDB 仅在 createLokvis()/createRuntime
|
|
279
|
+
// 工厂中生效(工厂按 flag 调 createAssetStore 选择 OPFS→IDB→Memory)。
|
|
280
|
+
// 直接 new LokvisRuntimeImpl 且未传 assetStore 时,无法同步等待 createAssetStore,
|
|
281
|
+
// 兜底用 Memory store;若用户显式期望持久化,提示其用 createLokvis()。
|
|
282
|
+
if (!config.assetStore && (this.config.enableOpfs || this.config.enableIndexedDB)) {
|
|
283
|
+
console.warn(
|
|
284
|
+
'[lokvis] LokvisRuntimeImpl constructed without assetStore: ' +
|
|
285
|
+
'enableOpfs/enableIndexedDB flags are ignored. ' +
|
|
286
|
+
'Use createLokvis() to respect these flags, or pass an assetStore explicitly.'
|
|
287
|
+
);
|
|
288
|
+
}
|
|
180
289
|
const rawStore = config.assetStore ?? createMemoryAssetStore();
|
|
181
290
|
this.assetStore = wrapAssetStoreWithQuota(rawStore, this.config.storageQuota);
|
|
182
291
|
|
|
292
|
+
// W7.2 历史持久化:优先用注入的 historyStore;否则在 createRuntime 工厂中
|
|
293
|
+
// 由 createHistoryStore 自动创建并注入。直接 new Impl 时为 undefined,
|
|
294
|
+
// 历史退化为仅内存模式(与 W2 行为一致)。
|
|
295
|
+
this.historyStore = config.historyStore;
|
|
296
|
+
|
|
183
297
|
this.capabilityRegistry = new CapabilityRegistry(this.config.engineStrategy);
|
|
184
298
|
|
|
185
299
|
this.executor = new WorkflowExecutor({
|
|
@@ -189,6 +303,22 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
189
303
|
enableLog: this.config.enableLog,
|
|
190
304
|
});
|
|
191
305
|
|
|
306
|
+
// W3.3 MemoryGuard:追踪中间结果占用,达到 high 阈值时建议 OPFS 溢出。
|
|
307
|
+
// BatchProcessor 据此动态收缩并发槽位(W6.1)。
|
|
308
|
+
this.memoryGuard = new MemoryGuard({
|
|
309
|
+
budget: this.config.memoryBudget,
|
|
310
|
+
assetStore: this.assetStore,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// W6.1 BatchProcessor:把 W5 playground 的并发/进度/重试逻辑下沉到 runtime。
|
|
314
|
+
// 注入 this(实现 LokvisRuntime 接口),不直接依赖 LokvisRuntimeImpl 避免循环引用。
|
|
315
|
+
this.batchProcessor = new BatchProcessor({
|
|
316
|
+
runtime: this,
|
|
317
|
+
eventBus: this.eventBus,
|
|
318
|
+
isPro: this.config.isPro,
|
|
319
|
+
memoryGuard: this.memoryGuard,
|
|
320
|
+
});
|
|
321
|
+
|
|
192
322
|
// 监听 node:finished 事件,自动 append 到 HistoryStack
|
|
193
323
|
this.eventBus.on('node:finished', (event) => {
|
|
194
324
|
this.recordHistoryFromNodeEvent(event as Extract<LokvisEvent, { type: 'node:finished' }>);
|
|
@@ -199,27 +329,84 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
199
329
|
return this._status;
|
|
200
330
|
}
|
|
201
331
|
|
|
332
|
+
get isPro(): boolean {
|
|
333
|
+
return this.config.isPro;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
get batch(): BatchProcessor {
|
|
337
|
+
return this.batchProcessor;
|
|
338
|
+
}
|
|
339
|
+
|
|
202
340
|
// ─── 工作流执行 ──────────────────────────────────────
|
|
203
341
|
|
|
204
342
|
async run(
|
|
205
343
|
workflow: Workflow,
|
|
206
|
-
inputs: AssetId[] | Asset[]
|
|
344
|
+
inputs: AssetId[] | Asset[],
|
|
345
|
+
options?: import('./types.js').RunOptions
|
|
207
346
|
): Promise<WorkflowResult> {
|
|
208
347
|
this._status = 'running';
|
|
209
348
|
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
349
|
+
// Schema 层校验:在 executor.execute 之前调用 validateWorkflow,
|
|
350
|
+
// 让结构问题(保留字哨兵 __input__、悬挂 edge、自环、重复 id、真环)
|
|
351
|
+
// 在入口处暴露,错误信息精准(如 "Edge from __input__ references a
|
|
352
|
+
// reserved sentinel id"),而不是被 executor 拓扑排序误判为含糊的 "cycle"。
|
|
353
|
+
// 三层防御的第 3 层(前两层:executor 防御性校验 + 单元测试覆盖)。
|
|
354
|
+
//
|
|
355
|
+
// W10.2/W10.3 增强:
|
|
356
|
+
// - resolveCapability 回调注入 capability 兼容性校验(相邻节点
|
|
357
|
+
// outputTypes 与 inputTypes 必须有交集;输入/输出节点类型与
|
|
358
|
+
// workflow.inputs/outputs.type 兼容)
|
|
359
|
+
// - maxSteps: 5(由 MAX_WORKFLOW_STEPS 常量定义,M1 MVP 约束)
|
|
360
|
+
const validation = validateWorkflow(workflow, {
|
|
361
|
+
maxSteps: MAX_WORKFLOW_STEPS,
|
|
362
|
+
resolveCapability: (name) => {
|
|
363
|
+
const cap = this.capabilityRegistry.get(name);
|
|
364
|
+
if (!cap) return undefined;
|
|
365
|
+
return {
|
|
366
|
+
inputTypes: cap.inputTypes,
|
|
367
|
+
outputTypes: cap.outputTypes,
|
|
368
|
+
};
|
|
369
|
+
},
|
|
370
|
+
});
|
|
371
|
+
if (!validation.success) {
|
|
372
|
+
this._status = 'error';
|
|
373
|
+
const error = validation.error.issues
|
|
374
|
+
.map((i) => i.message)
|
|
375
|
+
.join('; ');
|
|
376
|
+
const result: WorkflowResult = {
|
|
377
|
+
workflowId: workflow.id,
|
|
378
|
+
outputs: [],
|
|
379
|
+
duration: 0,
|
|
380
|
+
status: 'failed',
|
|
381
|
+
error,
|
|
382
|
+
};
|
|
383
|
+
this.eventBus.emit({
|
|
384
|
+
type: 'workflow:completed',
|
|
385
|
+
workflowId: workflow.id,
|
|
386
|
+
result,
|
|
387
|
+
});
|
|
388
|
+
return result;
|
|
216
389
|
}
|
|
217
390
|
|
|
218
|
-
//
|
|
391
|
+
// 历史栈管理:
|
|
392
|
+
// - 默认:每次 run() 重置历史(重跑语义),并通过 onEvict 回收旧 outputs 资产
|
|
393
|
+
// - appendHistory:true:保留已有历史栈,支持跨次 undo/redo 链(如连续滤镜)
|
|
219
394
|
const inputIds = await this.collectInputAssetIds(inputs);
|
|
220
|
-
this.
|
|
221
|
-
|
|
222
|
-
|
|
395
|
+
if (options?.appendHistory && this.historyStacks.has(workflow.id)) {
|
|
396
|
+
// 追加模式:保留历史栈与 currentOutputs,仅确保初始输入已记录
|
|
397
|
+
if (!this.initialInputsMap.has(workflow.id)) {
|
|
398
|
+
this.initialInputsMap.set(workflow.id, inputIds);
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
// 重置模式(默认):丢弃旧历史,重新初始化
|
|
402
|
+
const existingStack = this.historyStacks.get(workflow.id);
|
|
403
|
+
if (existingStack) {
|
|
404
|
+
existingStack.reset();
|
|
405
|
+
}
|
|
406
|
+
this.initialInputsMap.set(workflow.id, inputIds);
|
|
407
|
+
this.currentOutputsMap.set(workflow.id, inputIds);
|
|
408
|
+
}
|
|
409
|
+
this.enforceHistoryStacksLimit();
|
|
223
410
|
|
|
224
411
|
try {
|
|
225
412
|
const result = await this.executor.execute(workflow, inputs);
|
|
@@ -247,6 +434,34 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
247
434
|
return this.executor.resume(workflowId);
|
|
248
435
|
}
|
|
249
436
|
|
|
437
|
+
/**
|
|
438
|
+
* 销毁指定工作流的运行时状态(W2.8 内存治理)。
|
|
439
|
+
*
|
|
440
|
+
* 调用时机:
|
|
441
|
+
* - ui-react 卸载 Workspace 组件时
|
|
442
|
+
* - 用户主动关闭工作流标签页时
|
|
443
|
+
*
|
|
444
|
+
* 行为:
|
|
445
|
+
* - 调用 stack.reset() 触发 onEvict → assetStore.remove 回收历史 outputs 资产
|
|
446
|
+
* - 从 historyStacks / initialInputsMap / currentOutputsMap 三 Map 中删除 entry
|
|
447
|
+
* - 调用 executor.cancel 取消运行中的执行(若有)
|
|
448
|
+
*
|
|
449
|
+
* 修复 review 报告:原实现无清理入口,Workflow 组件卸载后 Map 中残留 entry,
|
|
450
|
+
* 长会话累积导致内存与 OPFS 空间双泄漏。
|
|
451
|
+
*/
|
|
452
|
+
async disposeWorkflow(workflowId: string): Promise<void> {
|
|
453
|
+
await this.cancel(workflowId).catch(() => {
|
|
454
|
+
/* 工作流可能未在运行 */
|
|
455
|
+
});
|
|
456
|
+
const stack = this.historyStacks.get(workflowId);
|
|
457
|
+
if (stack) {
|
|
458
|
+
stack.reset();
|
|
459
|
+
this.historyStacks.delete(workflowId);
|
|
460
|
+
}
|
|
461
|
+
this.initialInputsMap.delete(workflowId);
|
|
462
|
+
this.currentOutputsMap.delete(workflowId);
|
|
463
|
+
}
|
|
464
|
+
|
|
250
465
|
// ─── 历史与撤销 ──────────────────────────────────────
|
|
251
466
|
|
|
252
467
|
async history(workflowId: string): Promise<HistoryEntry[]> {
|
|
@@ -254,6 +469,15 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
254
469
|
return this.historyStacks.get(workflowId)?.list() ?? [];
|
|
255
470
|
}
|
|
256
471
|
|
|
472
|
+
async getHistoryState(
|
|
473
|
+
workflowId: string
|
|
474
|
+
): Promise<{ entries: HistoryEntry[]; cursor: number }> {
|
|
475
|
+
const stack = this.historyStacks.get(workflowId);
|
|
476
|
+
if (!stack) return { entries: [], cursor: -1 };
|
|
477
|
+
const snap = stack.snapshot();
|
|
478
|
+
return { entries: snap.entries, cursor: snap.cursor };
|
|
479
|
+
}
|
|
480
|
+
|
|
257
481
|
async undo(workflowId: string): Promise<void> {
|
|
258
482
|
const stack = this.getOrCreateHistoryStack(workflowId);
|
|
259
483
|
const result = stack.undo();
|
|
@@ -278,10 +502,44 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
278
502
|
// history:changed 事件由 stack 的 onChanged 回调统一发射,避免双发
|
|
279
503
|
}
|
|
280
504
|
|
|
505
|
+
async jumpTo(workflowId: string, index: number): Promise<void> {
|
|
506
|
+
const stack = this.getOrCreateHistoryStack(workflowId);
|
|
507
|
+
const result = stack.jumpTo(index);
|
|
508
|
+
if (result === undefined) return; // 越界或游标未变,无操作
|
|
509
|
+
|
|
510
|
+
// 同 undo/redo:更新当前输出
|
|
511
|
+
const newCurrent = result === null
|
|
512
|
+
? (this.initialInputsMap.get(workflowId) ?? [])
|
|
513
|
+
: result.outputs;
|
|
514
|
+
this.currentOutputsMap.set(workflowId, newCurrent);
|
|
515
|
+
// history:changed 事件由 stack 的 onChanged 回调统一发射
|
|
516
|
+
}
|
|
517
|
+
|
|
281
518
|
// ─── Asset 管理 ──────────────────────────────────────
|
|
282
519
|
|
|
283
520
|
async importAsset(source: AssetSource): Promise<AssetId> {
|
|
284
|
-
|
|
521
|
+
// 修复 review 报告:原实现直接透传 source 给 assetStore.import,
|
|
522
|
+
// 但 MemoryAssetStore/OpfsAssetStore/IdbAssetStore 的 extractBlobFromSource
|
|
523
|
+
// 仅支持 file/blob 两种 kind,url/opfs 会抛 "not supported"。
|
|
524
|
+
// 这里在 runtime 层兜底处理 url(fetch → blob),opfs 暂不支持(OPFS
|
|
525
|
+
// 路径访问需要 filesystem access permission,未来单独实现)
|
|
526
|
+
let effectiveSource = source;
|
|
527
|
+
if (source.kind === 'url') {
|
|
528
|
+
const resp = await fetch(source.url);
|
|
529
|
+
if (!resp.ok) {
|
|
530
|
+
throw new Error(`Failed to fetch asset from ${source.url}: ${resp.status}`);
|
|
531
|
+
}
|
|
532
|
+
const blob = await resp.blob();
|
|
533
|
+
const name = source.url.split('/').pop()?.split('?')[0] ?? 'asset';
|
|
534
|
+
effectiveSource = { kind: 'blob', blob, name };
|
|
535
|
+
} else if (source.kind === 'opfs') {
|
|
536
|
+
throw new Error(
|
|
537
|
+
"AssetSource kind 'opfs' is not yet supported by importAsset; " +
|
|
538
|
+
'use the OPFS-aware AssetStore directly or convert to blob first'
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const asset = await this.assetStore.import(effectiveSource);
|
|
285
543
|
this.eventBus.emit({
|
|
286
544
|
type: 'asset:imported',
|
|
287
545
|
assetId: asset.id,
|
|
@@ -299,13 +557,49 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
299
557
|
async exportAsset(id: AssetId, format?: string): Promise<Blob> {
|
|
300
558
|
const asset = await this.getAsset(id);
|
|
301
559
|
const blob = await this.assetStore.getBlob(asset.blob);
|
|
560
|
+
// OPFS 存储后端用 .bin 扩展名存储,读取时 fileHandle.getFile() 返回的
|
|
561
|
+
// File.type 可能为空字符串或 'application/octet-stream'(浏览器对未知扩展名
|
|
562
|
+
// 的默认兜底 MIME)。这两种情况都会导致 Object URL 的 Content-Type 退化,
|
|
563
|
+
// 下载时文件扩展名变成 .octet-stream。
|
|
564
|
+
// 用 asset metadata 的 mimeType 补全 Blob type(IDB/Memory 后端不受影响)。
|
|
565
|
+
const OPFS_FALLBACK_MIME = 'application/octet-stream';
|
|
566
|
+
const mimeType =
|
|
567
|
+
!blob.type || blob.type === OPFS_FALLBACK_MIME
|
|
568
|
+
? asset.metadata.mimeType
|
|
569
|
+
: blob.type;
|
|
570
|
+
// 关键:用 arrayBuffer() 显式读取数据到内存,再构造新 Blob。
|
|
571
|
+
// 不能用 new Blob([blob]) —— 浏览器实现中它可能延迟引用底层 OPFS 文件,
|
|
572
|
+
// WatermarkBatchTool 在 export 后立即 removeAsset 删除 OPFS 文件,
|
|
573
|
+
// 导致后续 downloadBlob 读取悬空引用失败("check internet connection")。
|
|
574
|
+
// arrayBuffer() 立即拉取数据,确保返回的 Blob 完全独立于底层存储。
|
|
575
|
+
const buffer = await blob.arrayBuffer();
|
|
576
|
+
const exported = new Blob([buffer], { type: mimeType });
|
|
302
577
|
this.eventBus.emit({
|
|
303
578
|
type: 'export:completed',
|
|
304
579
|
assetId: id,
|
|
305
580
|
format: format ?? asset.metadata.format,
|
|
306
581
|
size: blob.size,
|
|
307
582
|
});
|
|
308
|
-
return
|
|
583
|
+
return exported;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 读取 image 资产的 EXIF 元数据(W7.3/7.4 长期方案:MetadataReader 依赖反转)。
|
|
588
|
+
*
|
|
589
|
+
* Runtime 持有 plugin-image 通过 ctx.registerMetadataReader('image.read-exif', fn)
|
|
590
|
+
* 注册的 reader 引用,按名调用。reader 内部调 readExifFromBlob(exifr)。
|
|
591
|
+
* Plugin 未安装时优雅降级返回 null(不抛错)。
|
|
592
|
+
*
|
|
593
|
+
* 架构决策:readExif 是 Blob→ExifData 查询,不符合 Engine 层 Blob↔Blob 纯函数
|
|
594
|
+
* 约束,也不符合 Capability Asset[]→Asset[] 契约,故走 MetadataReader 机制,
|
|
595
|
+
* 不进 engine-image、不走 Capability execute。
|
|
596
|
+
*/
|
|
597
|
+
async readAssetExif(id: AssetId): Promise<ExifData | null> {
|
|
598
|
+
const asset = await this.getAsset(id);
|
|
599
|
+
if (asset.type !== 'image') return null;
|
|
600
|
+
const reader = this.metadataReaders.get('image.read-exif');
|
|
601
|
+
if (!reader) return null; // Plugin 未安装,优雅降级
|
|
602
|
+
return reader(asset) as Promise<ExifData | null>;
|
|
309
603
|
}
|
|
310
604
|
|
|
311
605
|
async removeAsset(id: AssetId): Promise<void> {
|
|
@@ -317,6 +611,23 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
317
611
|
return this.assetStore.list();
|
|
318
612
|
}
|
|
319
613
|
|
|
614
|
+
async getStorageUsage(): Promise<{ usage: number; quota: number }> {
|
|
615
|
+
// m6 优化:优先用配额包装器内部维护的 usage(O(1),import/create/remove
|
|
616
|
+
// 时增量更新),避免每次 O(n) 全量 listAssets 影响 StatusBar 刷新。
|
|
617
|
+
// 包装器未就绪(ensureInit 未完成)返回 -1 时,fallback 到 listAssets
|
|
618
|
+
// 实时计算(source of truth,与 W6.4 富元数据一致)。
|
|
619
|
+
//
|
|
620
|
+
// 类型说明:assetStore 字段类型为 QuotaAwareAssetStore(含 _getQuotaUsage),
|
|
621
|
+
// 由 wrapAssetStoreWithQuota 返回。无需重新断言 —— 类型信息未丢失。
|
|
622
|
+
const cached = this.assetStore._getQuotaUsage();
|
|
623
|
+
if (cached >= 0) {
|
|
624
|
+
return { usage: cached, quota: this.config.storageQuota };
|
|
625
|
+
}
|
|
626
|
+
const all = await this.assetStore.list();
|
|
627
|
+
const usage = all.reduce((sum, a) => sum + a.metadata.size, 0);
|
|
628
|
+
return { usage, quota: this.config.storageQuota };
|
|
629
|
+
}
|
|
630
|
+
|
|
320
631
|
// ─── 能力查询 ────────────────────────────────────────
|
|
321
632
|
|
|
322
633
|
async capabilities(): Promise<Capability[]> {
|
|
@@ -391,13 +702,49 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
391
702
|
return this.capabilityRegistry;
|
|
392
703
|
}
|
|
393
704
|
|
|
705
|
+
/**
|
|
706
|
+
* 获取 MemoryGuard(内部用,供集成测试驱动内存压力验证 BatchProcessor 收缩)。
|
|
707
|
+
*/
|
|
708
|
+
_getMemoryGuard(): MemoryGuard {
|
|
709
|
+
return this.memoryGuard;
|
|
710
|
+
}
|
|
711
|
+
|
|
394
712
|
/** 获取工作流当前输出 AssetId(undo/redo 后的"当前"状态,内部用) */
|
|
395
713
|
_getCurrentOutputs(workflowId: string): AssetId[] {
|
|
396
714
|
return this.currentOutputsMap.get(workflowId) ?? [];
|
|
397
715
|
}
|
|
398
716
|
|
|
717
|
+
/** 获取工作流当前输出 AssetId(公开 API,供 UI / MCP 查询) */
|
|
718
|
+
async getCurrentOutputs(workflowId: string): Promise<AssetId[]> {
|
|
719
|
+
return this._getCurrentOutputs(workflowId);
|
|
720
|
+
}
|
|
721
|
+
|
|
399
722
|
// ─── 私有:历史栈管理 ──────────────────────────────
|
|
400
723
|
|
|
724
|
+
/**
|
|
725
|
+
* LRU 上限清理:historyStacks 超过 MAX_CONCURRENT_WORKFLOW_STACKS 时
|
|
726
|
+
* 按 FIFO 删除最旧 stack(reset 触发 onEvict → assetStore.remove 回收资产)。
|
|
727
|
+
*
|
|
728
|
+
* Map 的迭代顺序是插入顺序(ES2015+ 规范),所以第一个 entry 即最旧。
|
|
729
|
+
* 注意:当前 run() 的工作流尚未插入 Map(getOrCreateHistoryStack 才会插入),
|
|
730
|
+
* 所以这里清理不会误删当前工作流。
|
|
731
|
+
*/
|
|
732
|
+
private enforceHistoryStacksLimit(): void {
|
|
733
|
+
while (this.historyStacks.size >= MAX_CONCURRENT_WORKFLOW_STACKS) {
|
|
734
|
+
// 取最旧 workflowId(Map 第一个 key)
|
|
735
|
+
const oldestId = this.historyStacks.keys().next().value;
|
|
736
|
+
if (oldestId === undefined) break;
|
|
737
|
+
const stack = this.historyStacks.get(oldestId);
|
|
738
|
+
if (stack) {
|
|
739
|
+
// reset 触发 onEvict,回收历史 outputs 资产
|
|
740
|
+
stack.reset();
|
|
741
|
+
}
|
|
742
|
+
this.historyStacks.delete(oldestId);
|
|
743
|
+
this.initialInputsMap.delete(oldestId);
|
|
744
|
+
this.currentOutputsMap.delete(oldestId);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
|
|
401
748
|
/** 获取或创建工作流对应的 HistoryStack */
|
|
402
749
|
private getOrCreateHistoryStack(workflowId: string): HistoryStack {
|
|
403
750
|
let stack = this.historyStacks.get(workflowId);
|
|
@@ -412,12 +759,15 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
412
759
|
this.assetStore.remove(assetId).catch(() => {});
|
|
413
760
|
}
|
|
414
761
|
},
|
|
415
|
-
onChanged: (wfId, entries) => {
|
|
762
|
+
onChanged: (wfId, entries, currentIndex) => {
|
|
416
763
|
this.eventBus.emit({
|
|
417
764
|
type: 'history:changed',
|
|
418
765
|
workflowId: wfId,
|
|
419
766
|
entries,
|
|
767
|
+
currentIndex,
|
|
420
768
|
});
|
|
769
|
+
// W7.2:持久化快照到 IndexedDB(加载期间跳过,避免冗余写回)
|
|
770
|
+
void this.persistHistory(wfId);
|
|
421
771
|
},
|
|
422
772
|
};
|
|
423
773
|
stack = new HistoryStack(workflowId, stackConfig);
|
|
@@ -465,6 +815,107 @@ export class LokvisRuntimeImpl implements LokvisRuntime {
|
|
|
465
815
|
// 更新当前输出为该 node 的 outputs
|
|
466
816
|
this.currentOutputsMap.set(event.workflowId, outputs);
|
|
467
817
|
}
|
|
818
|
+
|
|
819
|
+
// ─── 私有:历史持久化(W7.2) ────────────────────────
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* 把指定工作流的当前历史状态快照写入 historyStore。
|
|
823
|
+
*
|
|
824
|
+
* 时序修复:currentOutputs 从 stack snapshot 派生(cursor === -1 用
|
|
825
|
+
* initialInputs,否则用 entries[cursor].outputs),而非读 currentOutputsMap。
|
|
826
|
+
* 原因:onChanged 在 undo/redo/jumpTo/run 内部同步触发时,map 尚未更新,
|
|
827
|
+
* 会读到旧值(例如 run 后 append 触发 onChanged,但 currentOutputsMap 在
|
|
828
|
+
* append 返回后才 set)。
|
|
829
|
+
*
|
|
830
|
+
* 竞态安全:多个 fire-and-forget save 的 IDB readwrite 事务由 IndexedDB
|
|
831
|
+
* 引擎按发起顺序串行化(同 object store 不重叠),无需应用层加链。
|
|
832
|
+
*
|
|
833
|
+
* - entries 为空时改为 delete,避免残留空记录(reset/clear 后自然清理)
|
|
834
|
+
* - 加载期间(isLoadingHistory=true)跳过写回,但把 workflowId 加入
|
|
835
|
+
* dirtyDuringLoad,loadPersistedHistory 结束后补 persist(避免丢变更)
|
|
836
|
+
*/
|
|
837
|
+
private async persistHistory(workflowId: string): Promise<void> {
|
|
838
|
+
if (!this.historyStore) return;
|
|
839
|
+
if (this.isLoadingHistory) {
|
|
840
|
+
this.dirtyDuringLoad.add(workflowId);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
// fire-and-forget 调用方用 `void this.persistHistory(...)`,故内部必须
|
|
844
|
+
// try/catch,否则 IDB 故障(数据库关闭 / quota exceeded)会变成 unhandled
|
|
845
|
+
// promise rejection。历史持久化是非关键路径,失败只 warn 不抛。
|
|
846
|
+
try {
|
|
847
|
+
const stack = this.historyStacks.get(workflowId);
|
|
848
|
+
// 栈已从内存移除(disposeWorkflow / enforceHistoryStacksLimit 的 reset+delete
|
|
849
|
+
// 后异步到达此处)→ 删除持久化记录,避免孤儿数据跨会话残留
|
|
850
|
+
if (!stack) {
|
|
851
|
+
await this.historyStore.delete(workflowId);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
const { entries, cursor } = stack.snapshot();
|
|
855
|
+
if (entries.length === 0) {
|
|
856
|
+
await this.historyStore.delete(workflowId);
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
// 从 snapshot 派生 currentOutputs,而非读 currentOutputsMap —— 后者在
|
|
860
|
+
// onChanged 触发时尚未更新(见方法文档注释)
|
|
861
|
+
const initialInputs = this.initialInputsMap.get(workflowId) ?? [];
|
|
862
|
+
const currentOutputs = cursor === -1
|
|
863
|
+
? initialInputs
|
|
864
|
+
: (entries[cursor]?.outputs ?? initialInputs);
|
|
865
|
+
await this.historyStore.save({
|
|
866
|
+
workflowId,
|
|
867
|
+
entries,
|
|
868
|
+
cursor,
|
|
869
|
+
initialInputs,
|
|
870
|
+
currentOutputs,
|
|
871
|
+
updatedAt: Date.now(),
|
|
872
|
+
});
|
|
873
|
+
} catch (err) {
|
|
874
|
+
console.warn(
|
|
875
|
+
`[lokvis] persistHistory failed for workflow ${workflowId}:`,
|
|
876
|
+
err
|
|
877
|
+
);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* 从 historyStore 预加载所有持久化的历史快照,恢复到内存。
|
|
883
|
+
*
|
|
884
|
+
* 由 createRuntime 工厂在构造完 impl 后调用一次。加载期间置 isLoadingHistory
|
|
885
|
+
* 守卫,使 restore() 触发的 onChanged → persistHistory 跳过冗余写回;
|
|
886
|
+
* 但被跳过的 workflowId 记入 dirtyDuringLoad,加载结束后补 persist,
|
|
887
|
+
* 避免加载期间其他来源(run / undo / 外部事件)的变更被永久丢弃。
|
|
888
|
+
*
|
|
889
|
+
* 注意:restore 会 emit history:changed 事件,但此时 UI 尚未订阅
|
|
890
|
+
* (runtime-slice.init 在 createRuntime resolve 后才订阅),故无副作用。
|
|
891
|
+
*
|
|
892
|
+
* 非 LokvisRuntime 接口的一部分,仅为 impl 的初始化钩子(工厂调用)。
|
|
893
|
+
*/
|
|
894
|
+
async loadPersistedHistory(): Promise<void> {
|
|
895
|
+
if (!this.historyStore) return;
|
|
896
|
+
let records: Awaited<ReturnType<HistoryStore['loadAll']>> = [];
|
|
897
|
+
this.isLoadingHistory = true;
|
|
898
|
+
try {
|
|
899
|
+
records = await this.historyStore.loadAll();
|
|
900
|
+
for (const record of records) {
|
|
901
|
+
// 跳过空记录(理论上 save 已删除,双重防御)
|
|
902
|
+
if (record.entries.length === 0) continue;
|
|
903
|
+
const stack = this.getOrCreateHistoryStack(record.workflowId);
|
|
904
|
+
stack.restore({ entries: record.entries, cursor: record.cursor });
|
|
905
|
+
this.initialInputsMap.set(record.workflowId, record.initialInputs);
|
|
906
|
+
this.currentOutputsMap.set(record.workflowId, record.currentOutputs);
|
|
907
|
+
}
|
|
908
|
+
} finally {
|
|
909
|
+
this.isLoadingHistory = false;
|
|
910
|
+
// 加载期间被跳过的 persist 补发:逐个 await 保证顺序
|
|
911
|
+
// 复制一份避免补 persist 过程中新触发 onChanged → dirtyDuringLoad 死循环
|
|
912
|
+
const pending = [...this.dirtyDuringLoad];
|
|
913
|
+
this.dirtyDuringLoad.clear();
|
|
914
|
+
for (const wfId of pending) {
|
|
915
|
+
await this.persistHistory(wfId);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
468
919
|
}
|
|
469
920
|
|
|
470
921
|
/**
|
|
@@ -481,7 +932,17 @@ export async function createRuntime(
|
|
|
481
932
|
const assetStore =
|
|
482
933
|
config?.assetStore ??
|
|
483
934
|
(await createAssetStore({ preferOpfs: config?.enableOpfs ?? true }));
|
|
484
|
-
|
|
935
|
+
// W7.2:历史持久化 —— 优先用注入的 historyStore;否则在 enableIndexedDB 时
|
|
936
|
+
// 通过 createHistoryStore 自动创建(IDB 不可用时返回 undefined,退化仅内存)
|
|
937
|
+
const historyStore =
|
|
938
|
+
config?.historyStore ??
|
|
939
|
+
((config?.enableIndexedDB ?? true)
|
|
940
|
+
? createHistoryStore(config?.historyStoreOptions)
|
|
941
|
+
: undefined);
|
|
942
|
+
const impl = new LokvisRuntimeImpl({ ...config, assetStore, historyStore });
|
|
943
|
+
// 预加载持久化的历史快照(跨会话恢复 undo/redo 链)
|
|
944
|
+
await impl.loadPersistedHistory();
|
|
945
|
+
return impl;
|
|
485
946
|
}
|
|
486
947
|
|
|
487
948
|
/**
|