@frockbot/kernel-composition 0.0.0 → 0.1.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/package.json +31 -6
- package/src/activation.ts +417 -0
- package/src/compiler.test.ts +230 -0
- package/src/compiler.ts +288 -0
- package/src/generation.test.ts +203 -0
- package/src/generation.ts +423 -0
- package/src/index.test.ts +1072 -0
- package/src/index.ts +328 -0
- package/src/isolate-host.test.ts +423 -0
- package/src/isolate-host.ts +467 -0
- package/src/isolate-wrapper.test.ts +125 -0
- package/src/isolate-wrapper.ts +247 -0
- package/src/manifest.ts +1233 -0
- package/src/runtime.ts +18 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
package/src/index.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { type Context, FiberState, type Plugin, Service } from "cordis";
|
|
2
|
+
import type { ArtifactRefV1 } from "./generation.ts";
|
|
3
|
+
import {
|
|
4
|
+
type ContributionKind,
|
|
5
|
+
type ManifestContributionKind,
|
|
6
|
+
decodeFrockBotManifest,
|
|
7
|
+
declaredContributionKinds,
|
|
8
|
+
type FrockBotManifest,
|
|
9
|
+
} from "./manifest.ts";
|
|
10
|
+
|
|
11
|
+
export * from "./manifest.ts";
|
|
12
|
+
export type { ArtifactRefV1 } from "./generation.ts";
|
|
13
|
+
|
|
14
|
+
// Runtime source imports stay explicit because Electron executes workspace TypeScript.
|
|
15
|
+
export type PackageStatus =
|
|
16
|
+
"installed" | "activating" | "active" | "disabling" | "failed";
|
|
17
|
+
|
|
18
|
+
export interface PackageSource {
|
|
19
|
+
specifier: string;
|
|
20
|
+
manifest: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PackageDescriptor {
|
|
24
|
+
specifier: string;
|
|
25
|
+
manifest: FrockBotManifest;
|
|
26
|
+
/**
|
|
27
|
+
* Present only for a Composition member that carries an immutable,
|
|
28
|
+
* content-addressed artifact — that is, a Package whose provenance is not
|
|
29
|
+
* first-party and which therefore runs in a Bot isolate.
|
|
30
|
+
*/
|
|
31
|
+
artifact?: ArtifactRefV1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ActiveContribution {
|
|
35
|
+
dispose(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PreparedContribution {
|
|
39
|
+
kind: ContributionKind;
|
|
40
|
+
commit(): Promise<ActiveContribution>;
|
|
41
|
+
rollback(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ContributionHost {
|
|
45
|
+
kind: ContributionKind;
|
|
46
|
+
prepare(pkg: PackageDescriptor): Promise<PreparedContribution | undefined>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PackageSummary extends PackageDescriptor {
|
|
50
|
+
status: PackageStatus;
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PackageRecord extends PackageSummary {
|
|
55
|
+
active: ActiveContribution[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
declare module "cordis" {
|
|
59
|
+
interface Context {
|
|
60
|
+
packages: PackageCatalog;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Events {
|
|
64
|
+
"package/status": (pkg: PackageSummary) => void;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function errorMessage(error: unknown): string {
|
|
69
|
+
return error instanceof Error ? error.message : String(error);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function summary(record: PackageRecord): PackageSummary {
|
|
73
|
+
const { active: _active, ...value } = record;
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface PackageCatalogConfig {
|
|
78
|
+
kinds?: ContributionKind[];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class PackageCatalog extends Service {
|
|
82
|
+
private hosts = new Map<ContributionKind, ContributionHost>();
|
|
83
|
+
private records = new Map<string, PackageRecord>();
|
|
84
|
+
private kinds: Set<ContributionKind> | undefined;
|
|
85
|
+
|
|
86
|
+
constructor(ctx: Context, config: PackageCatalogConfig = {}) {
|
|
87
|
+
super(ctx, "packages");
|
|
88
|
+
this.kinds = config.kinds ? new Set(config.kinds) : undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
registerHost(host: ContributionHost): () => void {
|
|
92
|
+
if (this.hosts.has(host.kind)) {
|
|
93
|
+
throw new Error(`contribution host "${host.kind}" is already registered`);
|
|
94
|
+
}
|
|
95
|
+
this.hosts.set(host.kind, host);
|
|
96
|
+
return () => {
|
|
97
|
+
if (this.hosts.get(host.kind) === host) this.hosts.delete(host.kind);
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
install(source: PackageSource): PackageSummary {
|
|
102
|
+
const manifest = decodeFrockBotManifest(source.manifest);
|
|
103
|
+
if (this.records.has(manifest.id)) {
|
|
104
|
+
throw new Error(`package "${manifest.id}" is already installed`);
|
|
105
|
+
}
|
|
106
|
+
const record: PackageRecord = {
|
|
107
|
+
specifier: source.specifier,
|
|
108
|
+
manifest,
|
|
109
|
+
status: "installed",
|
|
110
|
+
active: [],
|
|
111
|
+
};
|
|
112
|
+
this.records.set(manifest.id, record);
|
|
113
|
+
this.publish(record);
|
|
114
|
+
return summary(record);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get(packageId: string): PackageSummary | undefined {
|
|
118
|
+
const record = this.records.get(packageId);
|
|
119
|
+
return record ? summary(record) : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
list(): PackageSummary[] {
|
|
123
|
+
return [...this.records.values()].map(summary);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async enable(packageId: string): Promise<void> {
|
|
127
|
+
const record = this.requireRecord(packageId);
|
|
128
|
+
if (record.status === "active") return;
|
|
129
|
+
if (record.status === "activating" || record.status === "disabling") {
|
|
130
|
+
throw new Error(`package "${packageId}" is busy`);
|
|
131
|
+
}
|
|
132
|
+
record.status = "activating";
|
|
133
|
+
record.error = undefined;
|
|
134
|
+
this.publish(record);
|
|
135
|
+
|
|
136
|
+
const descriptor = summary(record);
|
|
137
|
+
const prepared: PreparedContribution[] = [];
|
|
138
|
+
const active: ActiveContribution[] = [];
|
|
139
|
+
try {
|
|
140
|
+
for (const kind of declaredContributionKinds(record.manifest)) {
|
|
141
|
+
if (this.kinds && !this.kinds.has(kind)) continue;
|
|
142
|
+
const host = this.hosts.get(kind);
|
|
143
|
+
if (!host)
|
|
144
|
+
throw new Error(`no contribution host is registered for "${kind}"`);
|
|
145
|
+
const contribution = await host.prepare(descriptor);
|
|
146
|
+
if (!contribution) {
|
|
147
|
+
throw new Error(`host "${kind}" refused package "${packageId}"`);
|
|
148
|
+
}
|
|
149
|
+
prepared.push(contribution);
|
|
150
|
+
}
|
|
151
|
+
for (const contribution of prepared) {
|
|
152
|
+
active.push(await contribution.commit());
|
|
153
|
+
}
|
|
154
|
+
record.active = active;
|
|
155
|
+
record.status = "active";
|
|
156
|
+
this.publish(record);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const failures: unknown[] = [error];
|
|
159
|
+
for (const contribution of active.toReversed()) {
|
|
160
|
+
try {
|
|
161
|
+
await contribution.dispose();
|
|
162
|
+
} catch (disposeError) {
|
|
163
|
+
failures.push(disposeError);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
for (const contribution of prepared.slice(active.length).toReversed()) {
|
|
167
|
+
try {
|
|
168
|
+
await contribution.rollback();
|
|
169
|
+
} catch (rollbackError) {
|
|
170
|
+
failures.push(rollbackError);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
record.active = [];
|
|
174
|
+
record.status = "failed";
|
|
175
|
+
record.error = errorMessage(error);
|
|
176
|
+
this.publish(record);
|
|
177
|
+
if (failures.length === 1) throw error;
|
|
178
|
+
throw new AggregateError(
|
|
179
|
+
failures,
|
|
180
|
+
`package "${packageId}" activation rollback failed`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async disable(packageId: string): Promise<void> {
|
|
186
|
+
const record = this.requireRecord(packageId);
|
|
187
|
+
if (record.status === "installed") return;
|
|
188
|
+
if (record.status !== "active" && record.status !== "failed") {
|
|
189
|
+
throw new Error(`package "${packageId}" is busy`);
|
|
190
|
+
}
|
|
191
|
+
record.status = "disabling";
|
|
192
|
+
this.publish(record);
|
|
193
|
+
const failures: unknown[] = [];
|
|
194
|
+
for (const contribution of record.active.toReversed()) {
|
|
195
|
+
try {
|
|
196
|
+
await contribution.dispose();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
failures.push(error);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
record.active = [];
|
|
202
|
+
record.status = failures.length > 0 ? "failed" : "installed";
|
|
203
|
+
record.error = failures.length > 0 ? errorMessage(failures[0]) : undefined;
|
|
204
|
+
this.publish(record);
|
|
205
|
+
if (failures.length > 0) {
|
|
206
|
+
throw new AggregateError(
|
|
207
|
+
failures,
|
|
208
|
+
`package "${packageId}" did not disable cleanly`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
uninstall(packageId: string): void {
|
|
214
|
+
const record = this.requireRecord(packageId);
|
|
215
|
+
if (record.status !== "installed") {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`package "${packageId}" must be disabled before uninstall`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
this.records.delete(packageId);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
[Service.init](): () => Promise<void> {
|
|
224
|
+
return async () => {
|
|
225
|
+
const active = [...this.records.values()].filter(
|
|
226
|
+
(record) => record.active.length > 0,
|
|
227
|
+
);
|
|
228
|
+
await Promise.all(
|
|
229
|
+
active.map((record) => this.disable(record.manifest.id)),
|
|
230
|
+
);
|
|
231
|
+
this.records.clear();
|
|
232
|
+
this.hosts.clear();
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private requireRecord(packageId: string): PackageRecord {
|
|
237
|
+
const record = this.records.get(packageId);
|
|
238
|
+
if (!record) throw new Error(`package "${packageId}" is not installed`);
|
|
239
|
+
return record;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private publish(record: PackageRecord): void {
|
|
243
|
+
this.ctx.emit("package/status", summary(record));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export type ContributionResolver = (specifier: string) => Promise<unknown>;
|
|
248
|
+
|
|
249
|
+
export class PassiveContributionHost implements ContributionHost {
|
|
250
|
+
readonly kind: ManifestContributionKind;
|
|
251
|
+
|
|
252
|
+
constructor(kind: ManifestContributionKind) {
|
|
253
|
+
this.kind = kind;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
prepare(pkg: PackageDescriptor): Promise<PreparedContribution | undefined> {
|
|
257
|
+
const contribution = pkg.manifest.contributions[this.kind];
|
|
258
|
+
if (!contribution) return Promise.resolve(undefined);
|
|
259
|
+
return Promise.resolve({
|
|
260
|
+
kind: this.kind,
|
|
261
|
+
commit: () => Promise.resolve({ dispose: () => Promise.resolve() }),
|
|
262
|
+
rollback: () => Promise.resolve(),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function unwrapPlugin(module: unknown): Plugin | undefined {
|
|
268
|
+
if (typeof module === "function") return module as Plugin;
|
|
269
|
+
if (!module || typeof module !== "object") return undefined;
|
|
270
|
+
const candidate = (module as { default?: unknown }).default;
|
|
271
|
+
if (typeof candidate === "function") return candidate as Plugin;
|
|
272
|
+
if (candidate && typeof candidate === "object" && "apply" in candidate) {
|
|
273
|
+
return candidate as Plugin;
|
|
274
|
+
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export class LocalCordisContributionHost implements ContributionHost {
|
|
279
|
+
readonly kind: "runtime" | "desktop" | "mobile";
|
|
280
|
+
private readonly ctx: Context;
|
|
281
|
+
private readonly resolve: ContributionResolver;
|
|
282
|
+
|
|
283
|
+
constructor(
|
|
284
|
+
kind: "runtime" | "desktop" | "mobile",
|
|
285
|
+
ctx: Context,
|
|
286
|
+
resolve: ContributionResolver,
|
|
287
|
+
) {
|
|
288
|
+
this.kind = kind;
|
|
289
|
+
this.ctx = ctx;
|
|
290
|
+
this.resolve = resolve;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async prepare(
|
|
294
|
+
pkg: PackageDescriptor,
|
|
295
|
+
): Promise<PreparedContribution | undefined> {
|
|
296
|
+
const contribution = pkg.manifest.contributions[this.kind];
|
|
297
|
+
if (!contribution) return undefined;
|
|
298
|
+
const module = await this.resolve(
|
|
299
|
+
`${pkg.specifier}${contribution.entry.slice(1)}`,
|
|
300
|
+
);
|
|
301
|
+
const plugin = unwrapPlugin(module);
|
|
302
|
+
if (!plugin || !this.ctx.registry.resolve(plugin)) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`package "${pkg.manifest.id}" has an invalid ${this.kind} plugin`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
let fiber: ReturnType<Context["plugin"]> | undefined;
|
|
308
|
+
return {
|
|
309
|
+
kind: this.kind,
|
|
310
|
+
commit: async () => {
|
|
311
|
+
try {
|
|
312
|
+
fiber = this.ctx.plugin(plugin);
|
|
313
|
+
await fiber;
|
|
314
|
+
if (fiber.state !== FiberState.ACTIVE) {
|
|
315
|
+
throw new Error(`${this.kind} contribution did not become active`);
|
|
316
|
+
}
|
|
317
|
+
} catch (error) {
|
|
318
|
+
await fiber?.dispose();
|
|
319
|
+
throw error;
|
|
320
|
+
}
|
|
321
|
+
return { dispose: () => fiber?.dispose() ?? Promise.resolve() };
|
|
322
|
+
},
|
|
323
|
+
rollback: async () => {
|
|
324
|
+
await fiber?.dispose();
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|