@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
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
// The Bot isolate contribution host: mounts one Composition member's
|
|
2
|
+
// content-addressed artifact as a Dynamic Worker and registers the tools its
|
|
3
|
+
// wrapper reports.
|
|
4
|
+
//
|
|
5
|
+
// It sits beside `LocalCordisContributionHost` because it is the *other*
|
|
6
|
+
// execution host the constitution names — first-party Packages run in the
|
|
7
|
+
// kernel isolate, everything else runs in a loaded Worker with
|
|
8
|
+
// `globalOutbound` disabled and only Assignment-derived bindings.
|
|
9
|
+
//
|
|
10
|
+
// Two behaviours come straight from `docs/research/spike-worker-loader-from-do.md`:
|
|
11
|
+
// `.get()` never throws, so mount and `health()` are a single guarded phase;
|
|
12
|
+
// and a reused loader id silently serves the first code, so the id is nothing
|
|
13
|
+
// but the content address of the module set actually mounted.
|
|
14
|
+
import {
|
|
15
|
+
decodeIsolateHealthV1,
|
|
16
|
+
decodeIsolateToolResultV1,
|
|
17
|
+
isolateToolSchemaV1,
|
|
18
|
+
ISOLATE_MAX_DEADLINE_MS,
|
|
19
|
+
isolateLoaderIdV1,
|
|
20
|
+
type BotCapabilitiesStub,
|
|
21
|
+
type BotIsolateEntrypoint,
|
|
22
|
+
type IsolateHealthV1,
|
|
23
|
+
type IsolateToolDescriptorV1,
|
|
24
|
+
type IsolateToolInvocationV1,
|
|
25
|
+
type ToolDefinition,
|
|
26
|
+
type ToolExecutionContext,
|
|
27
|
+
type ToolExecutionResult,
|
|
28
|
+
type ToolRegistration,
|
|
29
|
+
type TurnTypeV1,
|
|
30
|
+
} from "@frockbot/kernel-contracts";
|
|
31
|
+
import { CompositionMountFailureError } from "./activation.ts";
|
|
32
|
+
import { canonicalJson, sha256 } from "./compiler.ts";
|
|
33
|
+
import type { CompositionMemberV1 } from "./generation.ts";
|
|
34
|
+
import type {
|
|
35
|
+
ActiveContribution,
|
|
36
|
+
ContributionHost,
|
|
37
|
+
PackageDescriptor,
|
|
38
|
+
PreparedContribution,
|
|
39
|
+
} from "./index.ts";
|
|
40
|
+
import { decodeFrockBotManifest, type FrockBotManifest } from "./manifest.ts";
|
|
41
|
+
import {
|
|
42
|
+
BOT_ISOLATE_MAIN_MODULE,
|
|
43
|
+
BOT_ISOLATE_WRAPPER_SOURCE,
|
|
44
|
+
BOT_ISOLATE_WRAPPER_VERSION,
|
|
45
|
+
botIsolateModuleMap,
|
|
46
|
+
} from "./isolate-wrapper.ts";
|
|
47
|
+
|
|
48
|
+
/** The `WorkerCode` a Bot isolate is loaded from. Structurally the platform's. */
|
|
49
|
+
export interface BotIsolateWorkerCode {
|
|
50
|
+
compatibilityDate: string;
|
|
51
|
+
mainModule: string;
|
|
52
|
+
modules: Record<string, { js: string }>;
|
|
53
|
+
globalOutbound: null;
|
|
54
|
+
env: { IDENTITY: unknown; CAPABILITIES: unknown };
|
|
55
|
+
limits: { cpuMs: number; subRequests: number };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BotIsolateLoadedWorker {
|
|
59
|
+
getEntrypoint(name?: string | null): BotIsolateEntrypoint;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The `worker_loaders` binding, declared structurally so the kernel stays platform-free. */
|
|
63
|
+
export interface BotIsolateLoader {
|
|
64
|
+
get(
|
|
65
|
+
id: string,
|
|
66
|
+
callback: () => Promise<BotIsolateWorkerCode>,
|
|
67
|
+
): BotIsolateLoadedWorker;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Reads an immutable, content-addressed Package artifact and verifies its hash. */
|
|
71
|
+
export interface BotIsolateArtifactStore {
|
|
72
|
+
loadPackageArtifact(contentHash: string): Promise<string>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BotIsolateLimits {
|
|
76
|
+
cpuMs: number;
|
|
77
|
+
subRequests: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface BotIsolateHostOptions {
|
|
81
|
+
loader: BotIsolateLoader;
|
|
82
|
+
artifacts: BotIsolateArtifactStore;
|
|
83
|
+
/** Where the isolate's tools are registered — the kernel's tool surface. */
|
|
84
|
+
tools: ToolRegistration;
|
|
85
|
+
userId: string;
|
|
86
|
+
botId: string;
|
|
87
|
+
sessionId: string;
|
|
88
|
+
runId: string;
|
|
89
|
+
turnId: string;
|
|
90
|
+
generationId: string;
|
|
91
|
+
/**
|
|
92
|
+
* The loopback service binding minted with
|
|
93
|
+
* `ctx.exports.BotCapabilities({ props })`. Opaque to the kernel: it only
|
|
94
|
+
* places it in the isolate's `env`.
|
|
95
|
+
*/
|
|
96
|
+
capabilities: BotCapabilitiesStub;
|
|
97
|
+
/**
|
|
98
|
+
* A content address of the Assignment-derived bindings this isolate is
|
|
99
|
+
* loaded with. Required, and part of the loader id, because a loader id is
|
|
100
|
+
* served from cache: the `env` a Bot isolate was first loaded with is the
|
|
101
|
+
* `env` it keeps, so a change in the Bot's Assignments must produce a new
|
|
102
|
+
* isolate or the isolate would keep answering from a revoked authority.
|
|
103
|
+
*/
|
|
104
|
+
bindingDigest: string;
|
|
105
|
+
compatibilityDate: string;
|
|
106
|
+
limits?: BotIsolateLimits;
|
|
107
|
+
/** Per-invocation deadline; `AbortSignal` cannot cross the RPC boundary. */
|
|
108
|
+
deadlineMs?: number;
|
|
109
|
+
/** Verification deadline: an isolate that never answers `health()` fails closed. */
|
|
110
|
+
healthDeadlineMs?: number;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const BOT_ISOLATE_DEFAULT_LIMITS: BotIsolateLimits = {
|
|
114
|
+
cpuMs: 5_000,
|
|
115
|
+
subRequests: 5,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const BOT_ISOLATE_DEFAULT_DEADLINE_MS = 15_000;
|
|
119
|
+
export const BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS = 10_000;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The content address of what a Bot isolate mounts: the kernel wrapper text,
|
|
123
|
+
* the Package artifact, and the digest of the Assignment-derived bindings it
|
|
124
|
+
* is loaded with. A change to any of the three is a new isolate.
|
|
125
|
+
*/
|
|
126
|
+
export async function botIsolateModuleSetHashV1(
|
|
127
|
+
artifactContentHash: string,
|
|
128
|
+
bindingDigest: string,
|
|
129
|
+
): Promise<string> {
|
|
130
|
+
return sha256(
|
|
131
|
+
canonicalJson({
|
|
132
|
+
wrapperVersion: BOT_ISOLATE_WRAPPER_VERSION,
|
|
133
|
+
wrapperHash: await sha256(BOT_ISOLATE_WRAPPER_SOURCE),
|
|
134
|
+
packageHash: artifactContentHash,
|
|
135
|
+
bindingDigest,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The durable ceiling a manifest puts on the turn types a Package's tools may
|
|
142
|
+
* be admitted onto (manifest v4, `CapabilityDefinition.admission`). It is the
|
|
143
|
+
* union over the Package's tool Capabilities, because a tool descriptor names
|
|
144
|
+
* no Capability: a Package bounds its tools only when every tool Capability it
|
|
145
|
+
* declares bounds them. Absent means the manifest set no bound.
|
|
146
|
+
*/
|
|
147
|
+
export function botIsolateAdmissionCeilingV1(
|
|
148
|
+
manifest: FrockBotManifest,
|
|
149
|
+
): readonly TurnTypeV1[] | undefined {
|
|
150
|
+
const capabilities = (manifest.configuration?.capabilities ?? []).filter(
|
|
151
|
+
(capability) => capability.kind === "tool",
|
|
152
|
+
);
|
|
153
|
+
if (
|
|
154
|
+
capabilities.length === 0 ||
|
|
155
|
+
capabilities.some((capability) => capability.admission === undefined)
|
|
156
|
+
) {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const turnTypes = new Set<TurnTypeV1>();
|
|
160
|
+
for (const capability of capabilities) {
|
|
161
|
+
for (const turnType of capability.admission?.turnTypes ?? []) {
|
|
162
|
+
turnTypes.add(turnType);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return [...turnTypes];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The same durable ceiling on the second dimension: the subagent roles a
|
|
170
|
+
* Package's tools may be offered to. Union over the tool Capabilities, and
|
|
171
|
+
* absent unless *every* one of them names roles — a Package bounds its tools
|
|
172
|
+
* only when it has bounded all of them.
|
|
173
|
+
*/
|
|
174
|
+
export function botIsolateSubagentRoleCeilingV1(
|
|
175
|
+
manifest: FrockBotManifest,
|
|
176
|
+
): readonly string[] | undefined {
|
|
177
|
+
const capabilities = (manifest.configuration?.capabilities ?? []).filter(
|
|
178
|
+
(capability) => capability.kind === "tool",
|
|
179
|
+
);
|
|
180
|
+
if (
|
|
181
|
+
capabilities.length === 0 ||
|
|
182
|
+
capabilities.some(
|
|
183
|
+
(capability) => capability.admission?.subagentRoles === undefined,
|
|
184
|
+
)
|
|
185
|
+
) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
const roles = new Set<string>();
|
|
189
|
+
for (const capability of capabilities) {
|
|
190
|
+
for (const role of capability.admission?.subagentRoles ?? []) {
|
|
191
|
+
roles.add(role);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return [...roles];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A Composition member projected onto the descriptor a contribution host
|
|
199
|
+
* consumes. The durable record keeps the member's `manifestHash`, not its
|
|
200
|
+
* manifest, so the projection carries only what the isolate host reads: the
|
|
201
|
+
* Package identity, its specifier, and its immutable artifact.
|
|
202
|
+
*/
|
|
203
|
+
export function botIsolatePackageDescriptorV1(
|
|
204
|
+
member: CompositionMemberV1,
|
|
205
|
+
): PackageDescriptor {
|
|
206
|
+
return {
|
|
207
|
+
specifier: member.specifier,
|
|
208
|
+
manifest: decodeFrockBotManifest({
|
|
209
|
+
schemaVersion: 3,
|
|
210
|
+
id: member.packageId,
|
|
211
|
+
displayName: member.packageId,
|
|
212
|
+
version: member.version,
|
|
213
|
+
compatibility: { frockbot: `^${member.version}` },
|
|
214
|
+
dependencies: {},
|
|
215
|
+
contributions: { runtime: { entry: "./package.js" } },
|
|
216
|
+
permissions: [],
|
|
217
|
+
}),
|
|
218
|
+
...(member.artifact ? { artifact: member.artifact } : {}),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function errorMessage(error: unknown): string {
|
|
223
|
+
return error instanceof Error ? error.message : String(error);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Mounts an isolate Composition member and registers the tools it reports. */
|
|
227
|
+
export class BotIsolateContributionHost implements ContributionHost {
|
|
228
|
+
readonly kind = "bot-isolate" as const;
|
|
229
|
+
private readonly options: BotIsolateHostOptions;
|
|
230
|
+
|
|
231
|
+
constructor(options: BotIsolateHostOptions) {
|
|
232
|
+
this.options = options;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async prepare(
|
|
236
|
+
pkg: PackageDescriptor,
|
|
237
|
+
): Promise<PreparedContribution | undefined> {
|
|
238
|
+
const artifact = pkg.artifact;
|
|
239
|
+
if (!artifact) return undefined;
|
|
240
|
+
const packageId = pkg.manifest.id;
|
|
241
|
+
const source = await this.loadSource(packageId, artifact.contentHash);
|
|
242
|
+
const loaderId = isolateLoaderIdV1({
|
|
243
|
+
userId: this.options.userId,
|
|
244
|
+
botId: this.options.botId,
|
|
245
|
+
artifactSetHash: await botIsolateModuleSetHashV1(
|
|
246
|
+
artifact.contentHash,
|
|
247
|
+
this.options.bindingDigest,
|
|
248
|
+
),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// Mount and health-check are one guarded phase: `.get()` is lazy and never
|
|
252
|
+
// throws, so a broken `package.js` only surfaces on the first RPC.
|
|
253
|
+
let health: IsolateHealthV1;
|
|
254
|
+
let entrypoint: BotIsolateEntrypoint;
|
|
255
|
+
try {
|
|
256
|
+
entrypoint = this.load(loaderId, packageId, source).getEntrypoint();
|
|
257
|
+
health = decodeIsolateHealthV1(
|
|
258
|
+
await raceDeadline(
|
|
259
|
+
() => entrypoint.health(),
|
|
260
|
+
Math.min(
|
|
261
|
+
this.options.healthDeadlineMs ??
|
|
262
|
+
BOT_ISOLATE_DEFAULT_HEALTH_DEADLINE_MS,
|
|
263
|
+
ISOLATE_MAX_DEADLINE_MS,
|
|
264
|
+
),
|
|
265
|
+
),
|
|
266
|
+
`package "${packageId}" isolate health`,
|
|
267
|
+
);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
// Site two: `LOADER.get` plus the first RPC. `.get()` is lazy, so a
|
|
270
|
+
// broken `package.js` surfaces here and nowhere earlier.
|
|
271
|
+
throw new CompositionMountFailureError(
|
|
272
|
+
"mount",
|
|
273
|
+
`package "${packageId}" failed to mount in its isolate: ${errorMessage(error)}`,
|
|
274
|
+
[`loader:${loaderId}`],
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
// Site three: the isolate answered, but failed its declared check.
|
|
278
|
+
if (!health.ok || health.tools.length === 0) {
|
|
279
|
+
throw new CompositionMountFailureError(
|
|
280
|
+
"health",
|
|
281
|
+
`package "${packageId}" reported an unhealthy isolate`,
|
|
282
|
+
[`ok:${health.ok}`, `tools:${health.tools.length}`],
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
if (health.packageId !== packageId) {
|
|
286
|
+
throw new CompositionMountFailureError(
|
|
287
|
+
"health",
|
|
288
|
+
`package "${packageId}" isolate reported a different package id`,
|
|
289
|
+
[`reported:${health.packageId}`],
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let disposed = false;
|
|
294
|
+
const registered: (() => void)[] = [];
|
|
295
|
+
return {
|
|
296
|
+
kind: this.kind,
|
|
297
|
+
commit: (): Promise<ActiveContribution> => {
|
|
298
|
+
// The manifest ceiling is applied at registration, so the catalog the
|
|
299
|
+
// model is offered and the call the loop admits cannot disagree.
|
|
300
|
+
const admissionCeiling = botIsolateAdmissionCeilingV1(pkg.manifest);
|
|
301
|
+
const subagentRoleCeiling = botIsolateSubagentRoleCeilingV1(
|
|
302
|
+
pkg.manifest,
|
|
303
|
+
);
|
|
304
|
+
const options =
|
|
305
|
+
admissionCeiling || subagentRoleCeiling
|
|
306
|
+
? {
|
|
307
|
+
...(admissionCeiling ? { admissionCeiling } : {}),
|
|
308
|
+
...(subagentRoleCeiling ? { subagentRoleCeiling } : {}),
|
|
309
|
+
}
|
|
310
|
+
: undefined;
|
|
311
|
+
for (const descriptor of health.tools) {
|
|
312
|
+
registered.push(
|
|
313
|
+
this.options.tools.register(
|
|
314
|
+
this.definition(packageId, entrypoint, descriptor),
|
|
315
|
+
options,
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
return Promise.resolve({
|
|
320
|
+
dispose: () => {
|
|
321
|
+
if (disposed) return Promise.resolve();
|
|
322
|
+
disposed = true;
|
|
323
|
+
for (const unregister of registered.toReversed()) unregister();
|
|
324
|
+
return Promise.resolve();
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
},
|
|
328
|
+
rollback: () => Promise.resolve(),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private async loadSource(
|
|
333
|
+
packageId: string,
|
|
334
|
+
contentHash: string,
|
|
335
|
+
): Promise<string> {
|
|
336
|
+
try {
|
|
337
|
+
return await this.options.artifacts.loadPackageArtifact(contentHash);
|
|
338
|
+
} catch (error) {
|
|
339
|
+
// Site one: the immutable artifact read. A generation whose artifact is
|
|
340
|
+
// gone never resolves, and that is a different repair from a broken one.
|
|
341
|
+
throw new CompositionMountFailureError(
|
|
342
|
+
"resolve",
|
|
343
|
+
`package "${packageId}" artifact "${contentHash}" is unavailable: ${errorMessage(error)}`,
|
|
344
|
+
[`contentHash:${contentHash}`],
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
private load(
|
|
350
|
+
loaderId: string,
|
|
351
|
+
packageId: string,
|
|
352
|
+
source: string,
|
|
353
|
+
): BotIsolateLoadedWorker {
|
|
354
|
+
const limits = this.options.limits ?? BOT_ISOLATE_DEFAULT_LIMITS;
|
|
355
|
+
const identity = {
|
|
356
|
+
botId: this.options.botId,
|
|
357
|
+
generationId: this.options.generationId,
|
|
358
|
+
packageId,
|
|
359
|
+
};
|
|
360
|
+
return this.options.loader.get(loaderId, () =>
|
|
361
|
+
Promise.resolve({
|
|
362
|
+
compatibilityDate: this.options.compatibilityDate,
|
|
363
|
+
mainModule: BOT_ISOLATE_MAIN_MODULE,
|
|
364
|
+
modules: botIsolateModuleMap(source),
|
|
365
|
+
// The constitution's rule, made mechanical: no network except bindings.
|
|
366
|
+
globalOutbound: null,
|
|
367
|
+
env: { IDENTITY: identity, CAPABILITIES: this.options.capabilities },
|
|
368
|
+
limits,
|
|
369
|
+
}),
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private definition(
|
|
374
|
+
packageId: string,
|
|
375
|
+
entrypoint: BotIsolateEntrypoint,
|
|
376
|
+
descriptor: IsolateToolDescriptorV1,
|
|
377
|
+
): ToolDefinition {
|
|
378
|
+
const deadlineMs = Math.min(
|
|
379
|
+
this.options.deadlineMs ?? BOT_ISOLATE_DEFAULT_DEADLINE_MS,
|
|
380
|
+
ISOLATE_MAX_DEADLINE_MS,
|
|
381
|
+
);
|
|
382
|
+
const options = this.options;
|
|
383
|
+
return {
|
|
384
|
+
...isolateToolSchemaV1(descriptor),
|
|
385
|
+
idempotent: descriptor.idempotent,
|
|
386
|
+
// A contract v1 isolate declares none, and its tools stay on every turn.
|
|
387
|
+
...(descriptor.admission ? { admission: descriptor.admission } : {}),
|
|
388
|
+
execute: async (
|
|
389
|
+
input: unknown,
|
|
390
|
+
context: ToolExecutionContext,
|
|
391
|
+
): Promise<ToolExecutionResult> => {
|
|
392
|
+
const invocation: IsolateToolInvocationV1 = {
|
|
393
|
+
schemaVersion: 1,
|
|
394
|
+
tool: descriptor.name,
|
|
395
|
+
input: input ?? null,
|
|
396
|
+
botId: options.botId,
|
|
397
|
+
sessionId: context.sessionId,
|
|
398
|
+
runId: options.runId,
|
|
399
|
+
turnId: options.turnId,
|
|
400
|
+
generationId: context.compositionGenerationId,
|
|
401
|
+
deadlineMs,
|
|
402
|
+
};
|
|
403
|
+
try {
|
|
404
|
+
// `AbortSignal` cannot cross the RPC boundary, so the deadline is
|
|
405
|
+
// carried in the invocation and raced again on this side.
|
|
406
|
+
const raw = await raceDeadline(
|
|
407
|
+
() => entrypoint.execute(invocation),
|
|
408
|
+
deadlineMs,
|
|
409
|
+
context.signal,
|
|
410
|
+
);
|
|
411
|
+
const result = decodeIsolateToolResultV1(
|
|
412
|
+
raw,
|
|
413
|
+
`package "${packageId}" isolate result`,
|
|
414
|
+
);
|
|
415
|
+
return { content: result.content, isError: result.isError };
|
|
416
|
+
} catch (error) {
|
|
417
|
+
return {
|
|
418
|
+
content: `Tool "${descriptor.name}" failed in its isolate: ${errorMessage(error)}`,
|
|
419
|
+
isError: true,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** The Durable Object half of the deadline: a race the isolate cannot escape. */
|
|
428
|
+
export function raceDeadline<T>(
|
|
429
|
+
work: () => Promise<T>,
|
|
430
|
+
deadlineMs: number,
|
|
431
|
+
signal?: AbortSignal,
|
|
432
|
+
): Promise<T> {
|
|
433
|
+
if (
|
|
434
|
+
!Number.isSafeInteger(deadlineMs) ||
|
|
435
|
+
deadlineMs <= 0 ||
|
|
436
|
+
deadlineMs > ISOLATE_MAX_DEADLINE_MS
|
|
437
|
+
) {
|
|
438
|
+
return Promise.reject(
|
|
439
|
+
new Error("isolate invocation deadline is out of range"),
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
443
|
+
let onAbort: (() => void) | undefined;
|
|
444
|
+
const expiry = new Promise<never>((_resolve, reject) => {
|
|
445
|
+
timer = setTimeout(
|
|
446
|
+
() =>
|
|
447
|
+
reject(
|
|
448
|
+
new Error(
|
|
449
|
+
`isolate invocation exceeded its deadline of ${deadlineMs}ms`,
|
|
450
|
+
),
|
|
451
|
+
),
|
|
452
|
+
deadlineMs,
|
|
453
|
+
);
|
|
454
|
+
if (signal) {
|
|
455
|
+
if (signal.aborted) {
|
|
456
|
+
reject(new Error("isolate invocation was cancelled"));
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
onAbort = () => reject(new Error("isolate invocation was cancelled"));
|
|
460
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
return Promise.race([Promise.resolve().then(work), expiry]).finally(() => {
|
|
464
|
+
clearTimeout(timer);
|
|
465
|
+
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
BOT_ISOLATE_DEADLINE_SOURCE,
|
|
4
|
+
BOT_ISOLATE_INVOCATION_SOURCE,
|
|
5
|
+
BOT_ISOLATE_MAIN_MODULE,
|
|
6
|
+
BOT_ISOLATE_PACKAGE_MODULE,
|
|
7
|
+
BOT_ISOLATE_WRAPPER_SOURCE,
|
|
8
|
+
botIsolateModuleMap,
|
|
9
|
+
} from "./isolate-wrapper.ts";
|
|
10
|
+
|
|
11
|
+
type Deadline = (work: () => unknown, deadlineMs: number) => Promise<unknown>;
|
|
12
|
+
|
|
13
|
+
// The wrapper ships as generated text, so the tested function is compiled from
|
|
14
|
+
// exactly the source the wrapper embeds rather than from a TypeScript twin.
|
|
15
|
+
const withIsolateDeadline = new Function(
|
|
16
|
+
`${BOT_ISOLATE_DEADLINE_SOURCE}\nreturn withIsolateDeadline;`,
|
|
17
|
+
)() as Deadline;
|
|
18
|
+
|
|
19
|
+
type DecodeInvocation = (value: unknown) => unknown;
|
|
20
|
+
|
|
21
|
+
const decodeInvocation = new Function(
|
|
22
|
+
`${BOT_ISOLATE_INVOCATION_SOURCE}\nreturn decodeInvocation;`,
|
|
23
|
+
)() as DecodeInvocation;
|
|
24
|
+
|
|
25
|
+
function invocation(overrides: Record<string, unknown> = {}) {
|
|
26
|
+
return {
|
|
27
|
+
schemaVersion: 1,
|
|
28
|
+
tool: "reverse_text",
|
|
29
|
+
input: { text: "a" },
|
|
30
|
+
botId: "bot-1",
|
|
31
|
+
sessionId: "user-1:bot-1",
|
|
32
|
+
runId: "run-1",
|
|
33
|
+
turnId: "turn-1",
|
|
34
|
+
generationId: "gen-1",
|
|
35
|
+
deadlineMs: 1_000,
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function never(): Promise<never> {
|
|
41
|
+
return new Promise(() => {});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("the generated wrapper's deadline", () => {
|
|
45
|
+
test("resolves work that finishes inside the deadline", async () => {
|
|
46
|
+
await expect(withIsolateDeadline(() => "done", 1_000)).resolves.toBe(
|
|
47
|
+
"done",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects work that outlives the deadline", async () => {
|
|
52
|
+
await expect(withIsolateDeadline(never, 10)).rejects.toThrow(
|
|
53
|
+
"isolate invocation exceeded its deadline of 10ms",
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("turns a synchronous throw into a rejection", async () => {
|
|
58
|
+
await expect(
|
|
59
|
+
withIsolateDeadline(() => {
|
|
60
|
+
throw new Error("boom");
|
|
61
|
+
}, 1_000),
|
|
62
|
+
).rejects.toThrow("boom");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("refuses a deadline outside the contract bound", async () => {
|
|
66
|
+
for (const deadline of [0, -1, 60_001, 1.5, Number.NaN]) {
|
|
67
|
+
await expect(withIsolateDeadline(() => "done", deadline)).rejects.toThrow(
|
|
68
|
+
"isolate invocation deadline is out of range",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("does not hold the isolate open after the work settles", async () => {
|
|
74
|
+
const started = Date.now();
|
|
75
|
+
await withIsolateDeadline(() => "done", 50_000);
|
|
76
|
+
expect(Date.now() - started).toBeLessThan(1_000);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("the generated wrapper's invocation decoder", () => {
|
|
81
|
+
test("accepts exactly the declared invocation", () => {
|
|
82
|
+
expect(decodeInvocation(invocation())).toMatchObject({
|
|
83
|
+
tool: "reverse_text",
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("refuses an invocation carrying an undeclared field", () => {
|
|
88
|
+
expect(() =>
|
|
89
|
+
decodeInvocation(invocation({ capabilities: ["models:invoke"] })),
|
|
90
|
+
).toThrow("isolate tool invocation has invalid fields");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("refuses an invocation missing a declared field", () => {
|
|
94
|
+
const { turnId: _turnId, ...missing } = invocation();
|
|
95
|
+
expect(() => decodeInvocation(missing)).toThrow(
|
|
96
|
+
"isolate tool invocation has invalid fields",
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("the generated wrapper module map", () => {
|
|
102
|
+
test("is exactly two entries", () => {
|
|
103
|
+
const modules = botIsolateModuleMap("export const tools = [];");
|
|
104
|
+
expect(Object.keys(modules).sort()).toEqual([
|
|
105
|
+
BOT_ISOLATE_MAIN_MODULE,
|
|
106
|
+
BOT_ISOLATE_PACKAGE_MODULE,
|
|
107
|
+
]);
|
|
108
|
+
expect(modules[BOT_ISOLATE_MAIN_MODULE]?.js).toBe(
|
|
109
|
+
BOT_ISOLATE_WRAPPER_SOURCE,
|
|
110
|
+
);
|
|
111
|
+
expect(modules[BOT_ISOLATE_PACKAGE_MODULE]?.js).toBe(
|
|
112
|
+
"export const tools = [];",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("exposes only the wrapper entrypoint to the loader", () => {
|
|
117
|
+
expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain(
|
|
118
|
+
'import { WorkerEntrypoint } from "cloudflare:workers";',
|
|
119
|
+
);
|
|
120
|
+
expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain("async health()");
|
|
121
|
+
expect(BOT_ISOLATE_WRAPPER_SOURCE).toContain(
|
|
122
|
+
"async execute(rawInvocation)",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|