@forgeax/engine-plugin 0.1.21 → 0.1.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -10
- package/dist/__tests__/composition-contract.test-d.d.ts +2 -0
- package/dist/__tests__/composition-contract.test-d.d.ts.map +1 -0
- package/dist/__tests__/composition.integration.test.d.ts +2 -0
- package/dist/__tests__/composition.integration.test.d.ts.map +1 -0
- package/dist/__tests__/public-api.test-d.d.ts +2 -0
- package/dist/__tests__/public-api.test-d.d.ts.map +1 -0
- package/dist/__tests__/realm-loader.integration.test.d.ts +2 -0
- package/dist/__tests__/realm-loader.integration.test.d.ts.map +1 -0
- package/dist/browser.d.ts +2 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.mjs +548 -1
- package/dist/browser.mjs.map +1 -1
- package/dist/composition.d.ts +73 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +560 -8
- package/dist/index.mjs.map +1 -1
- package/dist/inspection.d.ts +26 -0
- package/dist/inspection.d.ts.map +1 -0
- package/dist/loader.d.ts +51 -3
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.mjs +12 -7
- package/dist/loader.mjs.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/browser-entry.test.ts +1 -0
- package/src/__tests__/composition-contract.test-d.ts +74 -0
- package/src/__tests__/composition.integration.test.ts +1347 -0
- package/src/__tests__/public-api.test-d.ts +70 -0
- package/src/__tests__/realm-loader.integration.test.ts +56 -0
- package/src/browser.ts +18 -0
- package/src/composition.ts +642 -0
- package/src/index.ts +18 -0
- package/src/inspection.ts +160 -0
- package/src/loader.ts +82 -13
- package/dist/.tsbuildinfo +0 -1
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis';
|
|
2
|
+
|
|
3
|
+
export type PluginCompositionErrorCode =
|
|
4
|
+
| 'plugin-config-invalid'
|
|
5
|
+
| 'plugin-group-child-failed'
|
|
6
|
+
| 'plugin-group-dependency-cycle'
|
|
7
|
+
| 'plugin-group-key-duplicate'
|
|
8
|
+
| 'plugin-group-key-required'
|
|
9
|
+
| 'plugin-group-provider-missing';
|
|
10
|
+
|
|
11
|
+
export interface PluginConfigInvalidDetail {
|
|
12
|
+
readonly plugin: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PluginGroupChildFailedDetail {
|
|
16
|
+
readonly child: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PluginGroupDependencyCycleDetail {
|
|
20
|
+
readonly path: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PluginGroupKeyDuplicateDetail {
|
|
24
|
+
readonly key: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PluginGroupKeyRequiredDetail {
|
|
28
|
+
readonly plugin: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PluginGroupProviderMissingDetail {
|
|
32
|
+
readonly service: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type PluginCompositionErrorDetailByCode = {
|
|
36
|
+
'plugin-config-invalid': PluginConfigInvalidDetail;
|
|
37
|
+
'plugin-group-child-failed': PluginGroupChildFailedDetail;
|
|
38
|
+
'plugin-group-dependency-cycle': PluginGroupDependencyCycleDetail;
|
|
39
|
+
'plugin-group-key-duplicate': PluginGroupKeyDuplicateDetail;
|
|
40
|
+
'plugin-group-key-required': PluginGroupKeyRequiredDetail;
|
|
41
|
+
'plugin-group-provider-missing': PluginGroupProviderMissingDetail;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type PluginCompositionErrorArgs<
|
|
45
|
+
C extends PluginCompositionErrorCode = PluginCompositionErrorCode,
|
|
46
|
+
> = {
|
|
47
|
+
readonly code: C;
|
|
48
|
+
readonly expected: string;
|
|
49
|
+
readonly hint: string;
|
|
50
|
+
readonly detail: PluginCompositionErrorDetailByCode[C];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
class PluginCompositionErrorClass extends Error {
|
|
54
|
+
readonly code: PluginCompositionErrorCode;
|
|
55
|
+
readonly expected: string;
|
|
56
|
+
readonly hint: string;
|
|
57
|
+
readonly detail: PluginCompositionErrorDetailByCode[PluginCompositionErrorCode];
|
|
58
|
+
|
|
59
|
+
constructor(args: PluginCompositionErrorArgs) {
|
|
60
|
+
super(`${args.code}: ${args.expected}`);
|
|
61
|
+
this.name = 'PluginCompositionError';
|
|
62
|
+
this.code = args.code;
|
|
63
|
+
this.expected = args.expected;
|
|
64
|
+
this.hint = args.hint;
|
|
65
|
+
this.detail = args.detail;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type PluginCompositionErrorVariant<C extends PluginCompositionErrorCode> =
|
|
70
|
+
PluginCompositionErrorClass & {
|
|
71
|
+
readonly code: C;
|
|
72
|
+
readonly detail: PluginCompositionErrorDetailByCode[C];
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type PluginCompositionError = {
|
|
76
|
+
[C in PluginCompositionErrorCode]: PluginCompositionErrorVariant<C>;
|
|
77
|
+
}[PluginCompositionErrorCode];
|
|
78
|
+
|
|
79
|
+
interface PluginCompositionErrorConstructor {
|
|
80
|
+
new <C extends PluginCompositionErrorCode>(
|
|
81
|
+
args: PluginCompositionErrorArgs<C>,
|
|
82
|
+
): PluginCompositionErrorVariant<C>;
|
|
83
|
+
readonly prototype: PluginCompositionError;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const PluginCompositionError: PluginCompositionErrorConstructor =
|
|
87
|
+
PluginCompositionErrorClass as unknown as PluginCompositionErrorConstructor;
|
|
88
|
+
|
|
89
|
+
export interface PluginUseOptions {
|
|
90
|
+
readonly key?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type PluginConfig<P> = P extends (ctx: Context, config: infer C) => unknown
|
|
94
|
+
? C
|
|
95
|
+
: P extends new (
|
|
96
|
+
ctx: Context,
|
|
97
|
+
config: infer C,
|
|
98
|
+
) => unknown
|
|
99
|
+
? C
|
|
100
|
+
: P extends { apply(ctx: Context, config: infer C): unknown }
|
|
101
|
+
? C
|
|
102
|
+
: unknown;
|
|
103
|
+
|
|
104
|
+
type ConfigArgs<P> =
|
|
105
|
+
undefined extends PluginConfig<P> ? [config?: PluginConfig<P>] : [config: PluginConfig<P>];
|
|
106
|
+
|
|
107
|
+
export interface PluginUse<P extends Plugin = Plugin> {
|
|
108
|
+
readonly plugin: P;
|
|
109
|
+
readonly config: PluginConfig<P>;
|
|
110
|
+
readonly key?: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function usePlugin<P extends Plugin>(
|
|
114
|
+
plugin: P,
|
|
115
|
+
...args: [...ConfigArgs<P>, options?: PluginUseOptions]
|
|
116
|
+
): PluginUse<P> {
|
|
117
|
+
const config = args[0] as PluginConfig<P>;
|
|
118
|
+
const options = args[1] as PluginUseOptions | undefined;
|
|
119
|
+
return {
|
|
120
|
+
plugin,
|
|
121
|
+
config,
|
|
122
|
+
...(options?.key === undefined ? {} : { key: options.key }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface PluginGroupOptions<C = unknown> {
|
|
127
|
+
readonly name: string;
|
|
128
|
+
readonly children: (config: C) => readonly PluginUse[];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
interface ChildRecord {
|
|
132
|
+
readonly key: string | object;
|
|
133
|
+
readonly plugin: Plugin;
|
|
134
|
+
config: unknown;
|
|
135
|
+
fiber: Fiber;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface GroupState {
|
|
139
|
+
readonly records: Map<string | object, ChildRecord>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
class ChildOperationFailure extends Error {
|
|
143
|
+
constructor(
|
|
144
|
+
readonly child: PluginUse,
|
|
145
|
+
readonly reason: unknown,
|
|
146
|
+
readonly cleanupFailures: readonly unknown[] = [],
|
|
147
|
+
) {
|
|
148
|
+
super(`child operation failed: ${pluginName(child.plugin)}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const groupStateKey = Symbol('forgeax.plugin.group.state');
|
|
153
|
+
|
|
154
|
+
function pluginName(plugin: Plugin): string {
|
|
155
|
+
return plugin.name ?? 'anonymous';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function dependencyNames(plugin: Plugin): readonly string[] {
|
|
159
|
+
const inject = plugin.inject;
|
|
160
|
+
if (inject === undefined) return [];
|
|
161
|
+
return Array.isArray(inject) ? inject : Object.keys(inject);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function providedNames(plugin: Plugin): readonly string[] {
|
|
165
|
+
const provide = plugin.provide;
|
|
166
|
+
if (provide === undefined) return [];
|
|
167
|
+
return Array.isArray(provide) ? provide : [provide];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function keyFor(child: PluginUse): string | object {
|
|
171
|
+
if (child.key !== undefined) return child.key;
|
|
172
|
+
return child.plugin;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function validateKeys(children: readonly PluginUse[]): void {
|
|
176
|
+
const keys = new Map<string | object, PluginUse>();
|
|
177
|
+
for (const child of children) {
|
|
178
|
+
const key = keyFor(child);
|
|
179
|
+
const previous = keys.get(key);
|
|
180
|
+
if (previous !== undefined) {
|
|
181
|
+
if (child.key === undefined && previous.key === undefined) {
|
|
182
|
+
throw new PluginCompositionError({
|
|
183
|
+
code: 'plugin-group-key-required',
|
|
184
|
+
expected: 'repeated Plugin references to declare a stable child key',
|
|
185
|
+
hint: 'assign a unique key to each repeated child before activation.',
|
|
186
|
+
detail: { plugin: pluginName(child.plugin) },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
throw new PluginCompositionError({
|
|
190
|
+
code: 'plugin-group-key-duplicate',
|
|
191
|
+
expected: 'stable child key to be unique within its Group',
|
|
192
|
+
hint: 'assign a unique key to each child declaration.',
|
|
193
|
+
detail: { key: String(child.key) },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
keys.set(key, child);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function orderedChildren(
|
|
201
|
+
ctx: Context,
|
|
202
|
+
children: readonly PluginUse[],
|
|
203
|
+
groupOwnedServices: ReadonlySet<string>,
|
|
204
|
+
): PluginUse[] {
|
|
205
|
+
const providers = new Map<string, number>();
|
|
206
|
+
children.forEach((child, index) => {
|
|
207
|
+
for (const service of providedNames(child.plugin)) {
|
|
208
|
+
if (!providers.has(service)) providers.set(service, index);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const graph = children.map(() => [] as number[]);
|
|
213
|
+
for (let index = 0; index < children.length; index += 1) {
|
|
214
|
+
const child = children[index];
|
|
215
|
+
if (child === undefined) continue;
|
|
216
|
+
for (const service of dependencyNames(child.plugin)) {
|
|
217
|
+
const provider = providers.get(service);
|
|
218
|
+
const externalServiceAvailable =
|
|
219
|
+
!groupOwnedServices.has(service) && ctx.reflect.get(service, false) !== undefined;
|
|
220
|
+
if (provider === undefined && !externalServiceAvailable) {
|
|
221
|
+
throw new PluginCompositionError({
|
|
222
|
+
code: 'plugin-group-provider-missing',
|
|
223
|
+
expected: 'an inject/provide dependency to be available before the child starts',
|
|
224
|
+
hint: 'provide the service in the Group or install the owning plugin first.',
|
|
225
|
+
detail: { service },
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
if (provider !== undefined && provider !== index) graph[index]?.push(provider);
|
|
229
|
+
if (provider === index) graph[index]?.push(index);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const state = children.map(() => 0);
|
|
234
|
+
const sorted: number[] = [];
|
|
235
|
+
const visit = (index: number, path: number[]): void => {
|
|
236
|
+
if (state[index] === 1) {
|
|
237
|
+
const cycleStart = path.indexOf(index);
|
|
238
|
+
const cycle = [...path.slice(cycleStart), index].map((item) => {
|
|
239
|
+
const child = children[item];
|
|
240
|
+
return child === undefined ? 'unknown' : pluginName(child.plugin);
|
|
241
|
+
});
|
|
242
|
+
throw new PluginCompositionError({
|
|
243
|
+
code: 'plugin-group-dependency-cycle',
|
|
244
|
+
expected: 'an acyclic inject/provide dependency graph',
|
|
245
|
+
hint: 'break the dependency cycle before activating the Group.',
|
|
246
|
+
detail: { path: cycle },
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (state[index] === 2) return;
|
|
250
|
+
state[index] = 1;
|
|
251
|
+
for (const dependency of graph[index] ?? []) visit(dependency, [...path, index]);
|
|
252
|
+
state[index] = 2;
|
|
253
|
+
sorted.push(index);
|
|
254
|
+
};
|
|
255
|
+
for (let index = 0; index < children.length; index += 1) visit(index, []);
|
|
256
|
+
return sorted
|
|
257
|
+
.map((index) => children[index])
|
|
258
|
+
.filter((child): child is PluginUse => child !== undefined);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function validateConfig(child: PluginUse): Promise<void> {
|
|
262
|
+
const schema = child.plugin.Config;
|
|
263
|
+
if (schema === undefined) return;
|
|
264
|
+
const result = await schema['~standard'].validate(child.config);
|
|
265
|
+
if ('issues' in result && result.issues) {
|
|
266
|
+
throw new PluginCompositionError({
|
|
267
|
+
code: 'plugin-config-invalid',
|
|
268
|
+
expected: `${pluginName(child.plugin)} Plugin.Config to validate the child config`,
|
|
269
|
+
hint: 'provide a valid config before the child side effect runs.',
|
|
270
|
+
detail: { plugin: pluginName(child.plugin) },
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function validateChildren(children: readonly PluginUse[]): Promise<void> {
|
|
276
|
+
const results = await Promise.allSettled(children.map((child) => validateConfig(child)));
|
|
277
|
+
const failure = results.find(
|
|
278
|
+
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
|
279
|
+
);
|
|
280
|
+
if (failure !== undefined) throw failure.reason;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function childFailure(child: PluginUse, _reason: unknown): PluginCompositionError {
|
|
284
|
+
return new PluginCompositionError({
|
|
285
|
+
code: 'plugin-group-child-failed',
|
|
286
|
+
expected: `${pluginName(child.plugin)} to activate under its owning Group`,
|
|
287
|
+
hint: 'repair the child and retry the Group update.',
|
|
288
|
+
detail: { child: pluginName(child.plugin) },
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function sameConfig(left: unknown, right: unknown): boolean {
|
|
293
|
+
return sameDataValue(left, right, new Set());
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function sameDataValue(left: unknown, right: unknown, active: Set<object>): boolean {
|
|
297
|
+
if (Object.is(left, right)) return true;
|
|
298
|
+
if (left === null || right === null || typeof left !== 'object' || typeof right !== 'object') {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
const leftArray = Array.isArray(left);
|
|
302
|
+
if (leftArray !== Array.isArray(right)) return false;
|
|
303
|
+
const leftPrototype = Object.getPrototypeOf(left);
|
|
304
|
+
const rightPrototype = Object.getPrototypeOf(right);
|
|
305
|
+
if (leftArray) {
|
|
306
|
+
if (leftPrototype !== Array.prototype || rightPrototype !== Array.prototype) return false;
|
|
307
|
+
} else if (
|
|
308
|
+
!(
|
|
309
|
+
(leftPrototype === Object.prototype || leftPrototype === null) &&
|
|
310
|
+
(rightPrototype === Object.prototype || rightPrototype === null) &&
|
|
311
|
+
leftPrototype === rightPrototype
|
|
312
|
+
)
|
|
313
|
+
) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
if (active.has(left)) return false;
|
|
317
|
+
active.add(left);
|
|
318
|
+
try {
|
|
319
|
+
const leftKeys = Reflect.ownKeys(left).filter((key) => !leftArray || key !== 'length');
|
|
320
|
+
const rightKeys = Reflect.ownKeys(right).filter((key) => !leftArray || key !== 'length');
|
|
321
|
+
if (leftKeys.length !== rightKeys.length) return false;
|
|
322
|
+
for (const key of leftKeys) {
|
|
323
|
+
if (!Object.hasOwn(right, key)) return false;
|
|
324
|
+
const leftDescriptor = Object.getOwnPropertyDescriptor(left, key);
|
|
325
|
+
const rightDescriptor = Object.getOwnPropertyDescriptor(right, key);
|
|
326
|
+
if (
|
|
327
|
+
leftDescriptor === undefined ||
|
|
328
|
+
rightDescriptor === undefined ||
|
|
329
|
+
!('value' in leftDescriptor) ||
|
|
330
|
+
!('value' in rightDescriptor) ||
|
|
331
|
+
!sameDataValue(leftDescriptor.value, rightDescriptor.value, active)
|
|
332
|
+
) {
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return true;
|
|
337
|
+
} finally {
|
|
338
|
+
active.delete(left);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function startChild(ctx: Context, child: PluginUse): ChildRecord {
|
|
343
|
+
let fiber: Fiber | undefined;
|
|
344
|
+
try {
|
|
345
|
+
fiber = ctx.plugin(child.plugin, child.config);
|
|
346
|
+
return { key: keyFor(child), plugin: child.plugin, config: child.config, fiber };
|
|
347
|
+
} catch (reason) {
|
|
348
|
+
if (fiber !== undefined) void fiber.dispose();
|
|
349
|
+
if (reason instanceof PluginCompositionError) throw reason;
|
|
350
|
+
throw childFailure(child, reason);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function activateChild(ctx: Context, child: PluginUse): Promise<ChildRecord> {
|
|
355
|
+
const record = startChild(ctx, child);
|
|
356
|
+
await record.fiber.await();
|
|
357
|
+
return record;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function recordUse(record: ChildRecord): PluginUse {
|
|
361
|
+
return {
|
|
362
|
+
plugin: record.plugin,
|
|
363
|
+
config: record.config,
|
|
364
|
+
...(typeof record.key === 'string' ? { key: record.key } : {}),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function sharesProvidedService(left: Plugin, right: Plugin): boolean {
|
|
369
|
+
const rightServices = new Set(providedNames(right));
|
|
370
|
+
return providedNames(left).some((service) => rightServices.has(service));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function restoreRecord(
|
|
374
|
+
ctx: Context,
|
|
375
|
+
record: ChildRecord,
|
|
376
|
+
config: unknown,
|
|
377
|
+
): Promise<ChildRecord> {
|
|
378
|
+
try {
|
|
379
|
+
await record.fiber.update(config);
|
|
380
|
+
await record.fiber.await();
|
|
381
|
+
return { ...record, config };
|
|
382
|
+
} catch (updateReason) {
|
|
383
|
+
if (record.fiber.uid !== null) await record.fiber.dispose();
|
|
384
|
+
try {
|
|
385
|
+
return await activateChild(ctx, { ...recordUse(record), config });
|
|
386
|
+
} catch (restoreReason) {
|
|
387
|
+
throw restoreReason ?? updateReason;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function settleRecords(
|
|
393
|
+
records: readonly ChildRecord[],
|
|
394
|
+
abort: Promise<unknown> | undefined,
|
|
395
|
+
): Promise<void> {
|
|
396
|
+
if (records.length === 0) return;
|
|
397
|
+
const outcomes = records.map((record, index) =>
|
|
398
|
+
record.fiber.await().then(
|
|
399
|
+
() => ({ index, reason: undefined }),
|
|
400
|
+
(reason: unknown) => ({ index, reason }),
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
const pending = new Set(outcomes);
|
|
404
|
+
const abortSignal =
|
|
405
|
+
abort === undefined
|
|
406
|
+
? undefined
|
|
407
|
+
: abort.then((reason) => ({ index: -1, reason, aborted: true as const }));
|
|
408
|
+
while (pending.size > 0) {
|
|
409
|
+
const outcome = await Promise.race([
|
|
410
|
+
...pending,
|
|
411
|
+
...(abortSignal === undefined ? [] : [abortSignal]),
|
|
412
|
+
]);
|
|
413
|
+
if ('aborted' in outcome) {
|
|
414
|
+
const cleanupFailures = await disposeRecordsInReverse(records);
|
|
415
|
+
await Promise.allSettled(outcomes);
|
|
416
|
+
if (outcome.reason instanceof ChildOperationFailure) {
|
|
417
|
+
throw new ChildOperationFailure(
|
|
418
|
+
outcome.reason.child,
|
|
419
|
+
outcome.reason.reason,
|
|
420
|
+
cleanupFailures,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
throw outcome.reason;
|
|
424
|
+
}
|
|
425
|
+
const completed = outcomes[outcome.index];
|
|
426
|
+
if (completed !== undefined) pending.delete(completed);
|
|
427
|
+
if (outcome.reason !== undefined) {
|
|
428
|
+
const record = records[outcome.index];
|
|
429
|
+
if (record !== undefined) {
|
|
430
|
+
const cleanupFailures = await disposeRecordsInReverse(records);
|
|
431
|
+
await Promise.allSettled(outcomes);
|
|
432
|
+
throw new ChildOperationFailure(recordUse(record), outcome.reason, cleanupFailures);
|
|
433
|
+
}
|
|
434
|
+
throw outcome.reason;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function disposeRecordsInReverse(records: readonly ChildRecord[]): Promise<unknown[]> {
|
|
440
|
+
const failures: unknown[] = [];
|
|
441
|
+
for (const record of [...records].reverse()) {
|
|
442
|
+
try {
|
|
443
|
+
await record.fiber.dispose();
|
|
444
|
+
} catch (reason) {
|
|
445
|
+
failures.push(reason);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return failures;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function reconcile(
|
|
452
|
+
ctx: Context,
|
|
453
|
+
state: GroupState,
|
|
454
|
+
children: readonly PluginUse[],
|
|
455
|
+
): Promise<void> {
|
|
456
|
+
validateKeys(children);
|
|
457
|
+
const groupOwnedServices = new Set<string>();
|
|
458
|
+
for (const record of state.records.values()) {
|
|
459
|
+
for (const service of providedNames(record.plugin)) groupOwnedServices.add(service);
|
|
460
|
+
}
|
|
461
|
+
const ordered = orderedChildren(ctx, children, groupOwnedServices);
|
|
462
|
+
await validateChildren(ordered);
|
|
463
|
+
const desired = new Map(ordered.map((child) => [keyFor(child), child] as const));
|
|
464
|
+
const removed = [...state.records.values()].filter((record) => !desired.has(record.key));
|
|
465
|
+
const changed: Array<{ record: ChildRecord; child: PluginUse; config: unknown }> = [];
|
|
466
|
+
const candidateChildren: PluginUse[] = [];
|
|
467
|
+
const replacements: Array<{ old: ChildRecord; child: PluginUse }> = [];
|
|
468
|
+
const released: ChildRecord[] = [];
|
|
469
|
+
const retired: ChildRecord[] = [];
|
|
470
|
+
const candidates: ChildRecord[] = [];
|
|
471
|
+
|
|
472
|
+
for (const child of ordered) {
|
|
473
|
+
const current = state.records.get(keyFor(child));
|
|
474
|
+
if (current === undefined) {
|
|
475
|
+
candidateChildren.push(child);
|
|
476
|
+
} else if (current.plugin !== child.plugin) {
|
|
477
|
+
candidateChildren.push(child);
|
|
478
|
+
replacements.push({ old: current, child });
|
|
479
|
+
} else if (!sameConfig(current.config, child.config)) {
|
|
480
|
+
changed.push({ record: current, child, config: current.config });
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const desiredServiceProviders = candidateChildren.filter(
|
|
485
|
+
(child) => providedNames(child.plugin).length > 0,
|
|
486
|
+
);
|
|
487
|
+
for (const record of removed) {
|
|
488
|
+
if (
|
|
489
|
+
desiredServiceProviders.some((child) => sharesProvidedService(record.plugin, child.plugin))
|
|
490
|
+
) {
|
|
491
|
+
released.push(record);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
for (const { old, child } of replacements) {
|
|
495
|
+
if (sharesProvidedService(old.plugin, child.plugin) && !released.includes(old)) {
|
|
496
|
+
released.push(old);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
try {
|
|
501
|
+
const releaseResults = await Promise.allSettled(
|
|
502
|
+
released.map((record) => record.fiber.dispose()),
|
|
503
|
+
);
|
|
504
|
+
const releaseFailureIndex = releaseResults.findIndex(
|
|
505
|
+
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
|
506
|
+
);
|
|
507
|
+
if (releaseFailureIndex !== -1) {
|
|
508
|
+
const releaseFailure = releaseResults[releaseFailureIndex];
|
|
509
|
+
const releasedRecord = released[releaseFailureIndex];
|
|
510
|
+
if (releaseFailure?.status === 'rejected' && releasedRecord !== undefined) {
|
|
511
|
+
throw new ChildOperationFailure(recordUse(releasedRecord), releaseFailure.reason);
|
|
512
|
+
}
|
|
513
|
+
throw releaseFailure;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const updateTasks = changed.map(({ record, child }) => {
|
|
517
|
+
return Promise.resolve()
|
|
518
|
+
.then(() => record.fiber.update(child.config))
|
|
519
|
+
.then(() => record.fiber.await())
|
|
520
|
+
.catch((reason: unknown) => {
|
|
521
|
+
throw new ChildOperationFailure(child, reason);
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
for (const child of candidateChildren) candidates.push(startChild(ctx, child));
|
|
525
|
+
const updateFailure =
|
|
526
|
+
updateTasks.length === 0
|
|
527
|
+
? undefined
|
|
528
|
+
: Promise.race(
|
|
529
|
+
updateTasks.map((task) =>
|
|
530
|
+
task.then(
|
|
531
|
+
() => new Promise<never>(() => {}),
|
|
532
|
+
(reason: unknown) => reason,
|
|
533
|
+
),
|
|
534
|
+
),
|
|
535
|
+
);
|
|
536
|
+
const results = await Promise.allSettled([
|
|
537
|
+
settleRecords(candidates, updateFailure),
|
|
538
|
+
...updateTasks,
|
|
539
|
+
]);
|
|
540
|
+
const failure = results.find(
|
|
541
|
+
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
|
542
|
+
);
|
|
543
|
+
if (failure !== undefined) throw failure.reason;
|
|
544
|
+
for (const record of removed) {
|
|
545
|
+
if (released.includes(record)) continue;
|
|
546
|
+
retired.push(record);
|
|
547
|
+
try {
|
|
548
|
+
await record.fiber.dispose();
|
|
549
|
+
} catch (retirementReason) {
|
|
550
|
+
throw new ChildOperationFailure(recordUse(record), retirementReason);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const { old } of replacements) {
|
|
554
|
+
if (released.includes(old)) continue;
|
|
555
|
+
retired.push(old);
|
|
556
|
+
try {
|
|
557
|
+
await old.fiber.dispose();
|
|
558
|
+
} catch (retirementReason) {
|
|
559
|
+
throw new ChildOperationFailure(recordUse(old), retirementReason);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
} catch (reason) {
|
|
563
|
+
const operation = reason instanceof ChildOperationFailure ? reason : undefined;
|
|
564
|
+
const primary = operation?.reason ?? reason;
|
|
565
|
+
const rollbackFailures = operation?.cleanupFailures
|
|
566
|
+
? [...operation.cleanupFailures]
|
|
567
|
+
: await disposeRecordsInReverse(candidates);
|
|
568
|
+
for (const { record, config } of changed.reverse()) {
|
|
569
|
+
try {
|
|
570
|
+
const restored = await restoreRecord(ctx, record, config);
|
|
571
|
+
state.records.set(record.key, restored);
|
|
572
|
+
} catch (restoreReason) {
|
|
573
|
+
rollbackFailures.push(restoreReason);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const record of released.reverse()) {
|
|
577
|
+
try {
|
|
578
|
+
const restored = await activateChild(ctx, recordUse(record));
|
|
579
|
+
state.records.set(record.key, restored);
|
|
580
|
+
} catch (restoreReason) {
|
|
581
|
+
rollbackFailures.push(restoreReason);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
for (const record of retired.reverse()) {
|
|
585
|
+
try {
|
|
586
|
+
const restored = await activateChild(ctx, recordUse(record));
|
|
587
|
+
state.records.set(record.key, restored);
|
|
588
|
+
} catch (restoreReason) {
|
|
589
|
+
rollbackFailures.push(restoreReason);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
const fallback = operation?.child ?? ordered[0];
|
|
593
|
+
const primaryError =
|
|
594
|
+
primary instanceof PluginCompositionError
|
|
595
|
+
? primary
|
|
596
|
+
: fallback === undefined
|
|
597
|
+
? primary
|
|
598
|
+
: childFailure(fallback, primary);
|
|
599
|
+
if (rollbackFailures.length > 0) {
|
|
600
|
+
throw new AggregateError(
|
|
601
|
+
[primaryError, ...rollbackFailures],
|
|
602
|
+
'plugin Group reconciliation and rollback both failed',
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
throw primaryError;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
for (const record of removed) state.records.delete(record.key);
|
|
609
|
+
for (const { old, child } of replacements) {
|
|
610
|
+
state.records.delete(old.key);
|
|
611
|
+
const next = candidates.find((record) => record.key === keyFor(child));
|
|
612
|
+
if (next !== undefined) state.records.set(next.key, next);
|
|
613
|
+
}
|
|
614
|
+
for (const record of candidates) state.records.set(record.key, record);
|
|
615
|
+
for (const { record, child } of changed) record.config = child.config;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function definePluginGroup<C = unknown>(options: PluginGroupOptions<C>): Plugin {
|
|
619
|
+
const group: Plugin.Object = {
|
|
620
|
+
name: options.name,
|
|
621
|
+
async apply(ctx, config) {
|
|
622
|
+
const owner = ctx.fiber as Fiber & { [groupStateKey]?: GroupState };
|
|
623
|
+
let state = owner[groupStateKey];
|
|
624
|
+
if (state === undefined) {
|
|
625
|
+
state = { records: new Map() };
|
|
626
|
+
owner[groupStateKey] = state;
|
|
627
|
+
}
|
|
628
|
+
ctx.effect(
|
|
629
|
+
() => async () => {
|
|
630
|
+
state.records.clear();
|
|
631
|
+
delete owner[groupStateKey];
|
|
632
|
+
},
|
|
633
|
+
'plugin-group-state',
|
|
634
|
+
);
|
|
635
|
+
ctx.on('internal/update', (nextConfig, _noSave, _next) =>
|
|
636
|
+
reconcile(ctx, state, options.children(nextConfig as C)),
|
|
637
|
+
);
|
|
638
|
+
await reconcile(ctx, state, options.children(config as C));
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
return group;
|
|
642
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,24 @@ declare module '@deepseek-ai/cordis' {
|
|
|
8
8
|
|
|
9
9
|
export * from '@deepseek-ai/cordis';
|
|
10
10
|
export { createContextCapabilityResolver } from './capability.js';
|
|
11
|
+
export {
|
|
12
|
+
definePluginGroup,
|
|
13
|
+
PluginCompositionError,
|
|
14
|
+
type PluginCompositionErrorArgs,
|
|
15
|
+
type PluginCompositionErrorCode,
|
|
16
|
+
type PluginCompositionErrorDetailByCode,
|
|
17
|
+
type PluginGroupOptions,
|
|
18
|
+
type PluginUse,
|
|
19
|
+
type PluginUseOptions,
|
|
20
|
+
usePlugin,
|
|
21
|
+
} from './composition.js';
|
|
22
|
+
export {
|
|
23
|
+
type CatalogPluginFiberState,
|
|
24
|
+
type CatalogPluginInspection,
|
|
25
|
+
type CatalogPluginInspectionEntry,
|
|
26
|
+
type CatalogPluginInspectionFailure,
|
|
27
|
+
inspectCatalogPlugins,
|
|
28
|
+
} from './inspection.js';
|
|
11
29
|
export {
|
|
12
30
|
bootstrapCatalogLoader,
|
|
13
31
|
CatalogLoader,
|