@effect-agent/testing 0.0.1-beta.5 → 0.1.0-beta.6
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/dist/index.d.mts +120 -73
- package/dist/index.mjs +563 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -8
- package/src/code-executor-conformance.d.ts +30 -0
- package/src/code-executor-conformance.ts +500 -0
- package/src/code-executor-substitute.ts +488 -0
- package/src/fixtures/warehouse/index.ts +412 -0
- package/src/index.ts +6 -0
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CodeExecutionHost,
|
|
3
|
+
CodeExecutionLimits,
|
|
4
|
+
CodeExecutionNamespace,
|
|
5
|
+
CodeExecutionProtocolError,
|
|
6
|
+
CodeExecutionRequest,
|
|
7
|
+
CodeExecutionResourceUse,
|
|
8
|
+
CodeExecutionResult,
|
|
9
|
+
CodeExecutionTimeoutError,
|
|
10
|
+
CodeExecutor,
|
|
11
|
+
type CodeExecutorExecute,
|
|
12
|
+
CodeExecutorUnsupportedError,
|
|
13
|
+
CodeHostCall,
|
|
14
|
+
CodeHostCallLimitError,
|
|
15
|
+
CodeHostCallResult,
|
|
16
|
+
CodeOutputLimitError,
|
|
17
|
+
CodeProgramFailedError,
|
|
18
|
+
CodeSourceError,
|
|
19
|
+
SandboxImplementation,
|
|
20
|
+
} from "@effect-agent/sandbox";
|
|
21
|
+
import { Clock, Duration, Effect, Fiber, Layer, Option, Queue, Schema } from "effect";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The deterministic in-process executor substitute (C1 of ADR-0017). It runs
|
|
25
|
+
* the generated program on the host JavaScript engine with best-effort global
|
|
26
|
+
* shadowing only, so it self-identifies as `unisolated` and is never a
|
|
27
|
+
* security boundary (CAP-010, CAP-015). It exists to prove the public
|
|
28
|
+
* `CodeExecutor` contract and to drive deterministic capability tests.
|
|
29
|
+
*/
|
|
30
|
+
export const inProcessCodeExecutorImplementation = SandboxImplementation.make({
|
|
31
|
+
isolation: "unisolated",
|
|
32
|
+
identity: "in-process-javascript",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// These two caps mirror the wire schema bounds (`BoundedLogs` is at most
|
|
36
|
+
// 4096 lines of at most 16 KiB each): capture must stay inside what
|
|
37
|
+
// `CodeExecutionResult` can carry. A line over the per-line cap is truncated
|
|
38
|
+
// with an explicit `…` marker; exceeding either the byte budget or the line
|
|
39
|
+
// cap fails the pass typed.
|
|
40
|
+
const MAX_LOG_LINES = 4_096;
|
|
41
|
+
const MAX_LOG_LINE_CHARACTERS = 16_000;
|
|
42
|
+
const MAX_THROWN_CHARACTERS = 4_000;
|
|
43
|
+
|
|
44
|
+
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Ambient globals shadowed inside the harness. Shadowing blocks the obvious
|
|
48
|
+
* identifier paths only; a determined program can still escape, which is
|
|
49
|
+
* exactly why this executor reports `unisolated` and the isolated network and
|
|
50
|
+
* CPU enforcement conformance cases run only against isolated adapters.
|
|
51
|
+
*/
|
|
52
|
+
const shadowedGlobals = [
|
|
53
|
+
"fetch",
|
|
54
|
+
"process",
|
|
55
|
+
"require",
|
|
56
|
+
"module",
|
|
57
|
+
"exports",
|
|
58
|
+
"global",
|
|
59
|
+
"globalThis",
|
|
60
|
+
"XMLHttpRequest",
|
|
61
|
+
"WebSocket",
|
|
62
|
+
"Deno",
|
|
63
|
+
"Bun",
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
class LogLimitSignal {
|
|
67
|
+
constructor(readonly observed: number) {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class EvaluationThrew {
|
|
71
|
+
constructor(readonly inner: unknown) {}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class NotAFunction {
|
|
75
|
+
constructor(readonly actual: string) {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface LogCapture {
|
|
79
|
+
readonly lines: Array<string>;
|
|
80
|
+
bytes: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Total, defect-free rendering of untrusted values: a hostile Proxy can throw
|
|
85
|
+
* from property access, `toString`, and `Symbol.toPrimitive`, and an expected
|
|
86
|
+
* program failure must never escape the typed channel as a defect while its
|
|
87
|
+
* diagnostics are being serialized.
|
|
88
|
+
*/
|
|
89
|
+
const formatLogValue = (value: unknown): string => {
|
|
90
|
+
try {
|
|
91
|
+
if (typeof value === "string") {
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
return JSON.stringify(value) ?? String(value);
|
|
95
|
+
} catch {
|
|
96
|
+
try {
|
|
97
|
+
return String(value);
|
|
98
|
+
} catch {
|
|
99
|
+
return "[unprintable value]";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const makeConsole = (capture: LogCapture, limits: CodeExecutionLimits) => {
|
|
105
|
+
const write = (...values: ReadonlyArray<unknown>): void => {
|
|
106
|
+
const joined = values.map(formatLogValue).join(" ");
|
|
107
|
+
const line =
|
|
108
|
+
joined.length > MAX_LOG_LINE_CHARACTERS
|
|
109
|
+
? `${joined.slice(0, MAX_LOG_LINE_CHARACTERS - 1)}…`
|
|
110
|
+
: joined;
|
|
111
|
+
const bytes = utf8ByteLength(line);
|
|
112
|
+
if (capture.lines.length >= MAX_LOG_LINES || capture.bytes + bytes > limits.maxLogBytes) {
|
|
113
|
+
throw new LogLimitSignal(capture.bytes + bytes);
|
|
114
|
+
}
|
|
115
|
+
capture.lines.push(line);
|
|
116
|
+
capture.bytes += bytes;
|
|
117
|
+
};
|
|
118
|
+
return { debug: write, error: write, info: write, log: write, warn: write };
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
interface PendingHostCall {
|
|
122
|
+
readonly namespace: string;
|
|
123
|
+
readonly method: string;
|
|
124
|
+
readonly argument: unknown;
|
|
125
|
+
readonly resolve: (value: unknown) => void;
|
|
126
|
+
readonly reject: (reason: unknown) => void;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const buildNamespaceObject = (
|
|
130
|
+
namespace: CodeExecutionNamespace,
|
|
131
|
+
offer: (pending: PendingHostCall) => void,
|
|
132
|
+
): Record<string, unknown> => {
|
|
133
|
+
const methods: Record<string, unknown> = {};
|
|
134
|
+
for (const method of namespace.methods) {
|
|
135
|
+
methods[method] = (argument: unknown) =>
|
|
136
|
+
new Promise((resolve, reject) => {
|
|
137
|
+
offer({ namespace: namespace.name, method, argument, resolve, reject });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return methods;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const boundedText = (value: unknown): string => {
|
|
144
|
+
try {
|
|
145
|
+
const text = value instanceof Error ? `${value.name}: ${value.message}` : formatLogValue(value);
|
|
146
|
+
return text.slice(0, MAX_THROWN_CHARACTERS);
|
|
147
|
+
} catch {
|
|
148
|
+
return "[unserializable thrown value]";
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/** Schema decoding of hostile values may itself throw through trap getters. */
|
|
153
|
+
const safeDecodeJson = (value: unknown): Option.Option<Schema.Json> => {
|
|
154
|
+
try {
|
|
155
|
+
return Schema.decodeUnknownOption(Schema.Json)(value);
|
|
156
|
+
} catch {
|
|
157
|
+
return Option.none();
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const boundedThrown = (value: unknown): Schema.Json => {
|
|
162
|
+
const decoded = safeDecodeJson(value);
|
|
163
|
+
if (Option.isSome(decoded)) {
|
|
164
|
+
try {
|
|
165
|
+
const encoded = JSON.stringify(decoded.value);
|
|
166
|
+
if (encoded !== undefined && encoded.length <= MAX_THROWN_CHARACTERS) {
|
|
167
|
+
return decoded.value;
|
|
168
|
+
}
|
|
169
|
+
} catch {
|
|
170
|
+
// fall through to the bounded string form
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return boundedText(value);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const encodedJsonByteLength = (value: Schema.Json): number | undefined => {
|
|
177
|
+
try {
|
|
178
|
+
const encoded = JSON.stringify(value);
|
|
179
|
+
return encoded === undefined ? undefined : utf8ByteLength(encoded);
|
|
180
|
+
} catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/** Host outcomes are protocol input; a hostile value must not defect mid-decode. */
|
|
186
|
+
const decodeHostOutcome = (value: unknown): Option.Option<CodeHostCallResult> => {
|
|
187
|
+
try {
|
|
188
|
+
return Schema.decodeUnknownOption(CodeHostCallResult)(value);
|
|
189
|
+
} catch {
|
|
190
|
+
return Option.none();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const validateRequest = (
|
|
195
|
+
request: CodeExecutionRequest,
|
|
196
|
+
): Effect.Effect<void, CodeExecutorUnsupportedError | CodeSourceError> =>
|
|
197
|
+
Effect.gen(function* () {
|
|
198
|
+
if (request.network._tag !== "NetworkDisabled") {
|
|
199
|
+
return yield* CodeExecutorUnsupportedError.make({
|
|
200
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
201
|
+
feature: "network",
|
|
202
|
+
message:
|
|
203
|
+
"The unisolated in-process executor cannot enforce an egress allowlist; only NetworkDisabled is accepted, and even that is shadowed rather than enforced",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (request.limits.cpuMillis !== undefined) {
|
|
207
|
+
return yield* CodeExecutorUnsupportedError.make({
|
|
208
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
209
|
+
feature: "cpu-limit",
|
|
210
|
+
message:
|
|
211
|
+
"The unisolated in-process executor shares the host engine and cannot enforce a CPU limit",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const reservedNames = new Set<string>([...shadowedGlobals, "console"]);
|
|
215
|
+
const seen = new Set<string>();
|
|
216
|
+
for (const namespace of request.namespaces) {
|
|
217
|
+
if (reservedNames.has(namespace.name) || seen.has(namespace.name)) {
|
|
218
|
+
return yield* CodeExecutorUnsupportedError.make({
|
|
219
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
220
|
+
feature: "namespaces",
|
|
221
|
+
message: `Namespace ${namespace.name} collides with a harness binding or another namespace`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
seen.add(namespace.name);
|
|
225
|
+
}
|
|
226
|
+
const sourceBytes = utf8ByteLength(request.source);
|
|
227
|
+
if (sourceBytes > request.limits.maxSourceBytes) {
|
|
228
|
+
return yield* CodeSourceError.make({
|
|
229
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
230
|
+
reason: "oversized",
|
|
231
|
+
message: `Source is ${sourceBytes} bytes; the request allows ${request.limits.maxSourceBytes}`,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const serveHostCalls = (
|
|
237
|
+
host: CodeExecutionHost["Service"],
|
|
238
|
+
queue: Queue.Queue<PendingHostCall>,
|
|
239
|
+
limits: CodeExecutionLimits,
|
|
240
|
+
capture: LogCapture,
|
|
241
|
+
counter: { calls: number },
|
|
242
|
+
): Effect.Effect<
|
|
243
|
+
never,
|
|
244
|
+
CodeHostCallLimitError | CodeOutputLimitError | CodeExecutionProtocolError
|
|
245
|
+
> =>
|
|
246
|
+
Effect.gen(function* () {
|
|
247
|
+
while (true) {
|
|
248
|
+
const pending = yield* Queue.take(queue);
|
|
249
|
+
counter.calls += 1;
|
|
250
|
+
if (counter.calls > limits.maxHostCalls) {
|
|
251
|
+
return yield* CodeHostCallLimitError.make({
|
|
252
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
253
|
+
limit: limits.maxHostCalls,
|
|
254
|
+
logs: [...capture.lines],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
const argument = safeDecodeJson(pending.argument);
|
|
258
|
+
if (Option.isNone(argument)) {
|
|
259
|
+
pending.reject(new TypeError("host call arguments must be JSON values"));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const argumentBytes = encodedJsonByteLength(argument.value);
|
|
263
|
+
if (argumentBytes === undefined || argumentBytes > limits.maxHostCallArgumentBytes) {
|
|
264
|
+
return yield* CodeOutputLimitError.make({
|
|
265
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
266
|
+
surface: "host-call-argument",
|
|
267
|
+
limit: limits.maxHostCallArgumentBytes,
|
|
268
|
+
observed: argumentBytes ?? 0,
|
|
269
|
+
logs: [...capture.lines],
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
const rawOutcome = yield* host.call(
|
|
273
|
+
CodeHostCall.make({
|
|
274
|
+
namespace: pending.namespace,
|
|
275
|
+
method: pending.method,
|
|
276
|
+
argument: argument.value,
|
|
277
|
+
}),
|
|
278
|
+
);
|
|
279
|
+
const outcome = decodeHostOutcome(rawOutcome);
|
|
280
|
+
if (Option.isNone(outcome)) {
|
|
281
|
+
return yield* CodeExecutionProtocolError.make({
|
|
282
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
283
|
+
message: "The execution host returned a value outside the CodeHostCallResult schema",
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (outcome.value._tag === "CodeHostCallFailure") {
|
|
287
|
+
pending.reject(outcome.value.error);
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const resultBytes = encodedJsonByteLength(outcome.value.value);
|
|
291
|
+
if (resultBytes === undefined || resultBytes > limits.maxHostCallResultBytes) {
|
|
292
|
+
return yield* CodeOutputLimitError.make({
|
|
293
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
294
|
+
surface: "host-call-result",
|
|
295
|
+
limit: limits.maxHostCallResultBytes,
|
|
296
|
+
observed: resultBytes ?? 0,
|
|
297
|
+
logs: [...capture.lines],
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
pending.resolve(outcome.value.value);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const classifyProgramFailure = (
|
|
305
|
+
thrown: unknown,
|
|
306
|
+
limits: CodeExecutionLimits,
|
|
307
|
+
capture: LogCapture,
|
|
308
|
+
): CodeOutputLimitError | CodeSourceError | CodeProgramFailedError => {
|
|
309
|
+
const inner = thrown instanceof EvaluationThrew ? thrown.inner : thrown;
|
|
310
|
+
if (inner instanceof LogLimitSignal) {
|
|
311
|
+
return CodeOutputLimitError.make({
|
|
312
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
313
|
+
surface: "logs",
|
|
314
|
+
limit: limits.maxLogBytes,
|
|
315
|
+
observed: inner.observed,
|
|
316
|
+
logs: [...capture.lines],
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
if (inner instanceof NotAFunction) {
|
|
320
|
+
return CodeSourceError.make({
|
|
321
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
322
|
+
reason: "not-a-function",
|
|
323
|
+
message: `The source expression evaluated to ${inner.actual}; it must evaluate to one async function`,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
// An async function converts a body-level `throw` into a rejection, so the
|
|
327
|
+
// split is by value shape: exception-like values read as `threw`, plain
|
|
328
|
+
// rejection values (an uncaught host failure envelope) read as `rejected`.
|
|
329
|
+
const reason = thrown instanceof EvaluationThrew || inner instanceof Error ? "threw" : "rejected";
|
|
330
|
+
return CodeProgramFailedError.make({
|
|
331
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
332
|
+
reason,
|
|
333
|
+
thrown: boundedThrown(inner),
|
|
334
|
+
message: boundedText(inner),
|
|
335
|
+
logs: [...capture.lines],
|
|
336
|
+
});
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
const executeInProcess: CodeExecutorExecute = Effect.fn("InProcessCodeExecutor.execute")(
|
|
340
|
+
function* (request) {
|
|
341
|
+
yield* validateRequest(request);
|
|
342
|
+
const host = yield* CodeExecutionHost;
|
|
343
|
+
const capture: LogCapture = { lines: [], bytes: 0 };
|
|
344
|
+
const counter = { calls: 0 };
|
|
345
|
+
const queue = yield* Queue.unbounded<PendingHostCall>();
|
|
346
|
+
|
|
347
|
+
const factory = yield* Effect.try({
|
|
348
|
+
try: () =>
|
|
349
|
+
new Function(
|
|
350
|
+
...shadowedGlobals,
|
|
351
|
+
"console",
|
|
352
|
+
...request.namespaces.map((namespace) => namespace.name),
|
|
353
|
+
`"use strict";\nreturn (\n${request.source}\n);`,
|
|
354
|
+
),
|
|
355
|
+
catch: (cause) =>
|
|
356
|
+
CodeSourceError.make({
|
|
357
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
358
|
+
reason: "invalid",
|
|
359
|
+
message: boundedText(cause),
|
|
360
|
+
}),
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const harnessConsole = makeConsole(capture, request.limits);
|
|
364
|
+
// Admission is enforced at call creation, not only at the single-consumer
|
|
365
|
+
// dequeue: a burst of unawaited calls can enqueue at most one entry past
|
|
366
|
+
// the cap (the entry the server fails the pass on); everything beyond is
|
|
367
|
+
// rejected synchronously, so the queue stays bounded against hostile
|
|
368
|
+
// programs.
|
|
369
|
+
let issuedHostCalls = 0;
|
|
370
|
+
const namespaceObjects = request.namespaces.map((namespace) =>
|
|
371
|
+
buildNamespaceObject(namespace, (pending) => {
|
|
372
|
+
issuedHostCalls += 1;
|
|
373
|
+
if (issuedHostCalls > request.limits.maxHostCalls + 1) {
|
|
374
|
+
pending.reject(new Error(`host-call limit of ${request.limits.maxHostCalls} exceeded`));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
Queue.offerUnsafe(queue, pending);
|
|
378
|
+
}),
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const server = yield* serveHostCalls(host, queue, request.limits, capture, counter).pipe(
|
|
382
|
+
Effect.forkScoped,
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
const program = Effect.tryPromise({
|
|
386
|
+
try: async () => {
|
|
387
|
+
let candidate: unknown;
|
|
388
|
+
try {
|
|
389
|
+
candidate = factory(
|
|
390
|
+
...shadowedGlobals.map(() => undefined),
|
|
391
|
+
harnessConsole,
|
|
392
|
+
...namespaceObjects,
|
|
393
|
+
);
|
|
394
|
+
} catch (cause) {
|
|
395
|
+
throw new EvaluationThrew(cause);
|
|
396
|
+
}
|
|
397
|
+
if (typeof candidate !== "function") {
|
|
398
|
+
throw new EvaluationThrew(new NotAFunction(typeof candidate));
|
|
399
|
+
}
|
|
400
|
+
let outcome: unknown;
|
|
401
|
+
try {
|
|
402
|
+
outcome = (candidate as () => unknown)();
|
|
403
|
+
} catch (cause) {
|
|
404
|
+
throw new EvaluationThrew(cause);
|
|
405
|
+
}
|
|
406
|
+
return await Promise.resolve(outcome);
|
|
407
|
+
},
|
|
408
|
+
catch: (thrown) => classifyProgramFailure(thrown, request.limits, capture),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const startedAt = yield* Clock.currentTimeMillis;
|
|
412
|
+
// The wall-clock deadline interrupts only at asynchronous suspension
|
|
413
|
+
// points: a synchronous runaway shares the host thread and cannot be
|
|
414
|
+
// stopped in-process — exactly why the platform CPU enforcement cases
|
|
415
|
+
// belong to isolated adapters only (testing spec §8.1). The server fiber
|
|
416
|
+
// is interrupted when the pass settles so no host call outlives the
|
|
417
|
+
// program that issued it.
|
|
418
|
+
const returned = yield* Effect.raceFirst(program, Fiber.join(server)).pipe(
|
|
419
|
+
Effect.timeoutOrElse({
|
|
420
|
+
duration: request.limits.maxWallTime,
|
|
421
|
+
orElse: () =>
|
|
422
|
+
CodeExecutionTimeoutError.make({
|
|
423
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
424
|
+
kind: "wall-clock",
|
|
425
|
+
maxWallTime: request.limits.maxWallTime,
|
|
426
|
+
logs: [...capture.lines],
|
|
427
|
+
}),
|
|
428
|
+
}),
|
|
429
|
+
Effect.ensuring(Fiber.interrupt(server)),
|
|
430
|
+
);
|
|
431
|
+
const finishedAt = yield* Clock.currentTimeMillis;
|
|
432
|
+
|
|
433
|
+
// An unawaited burst can outrun the server: the program may return before
|
|
434
|
+
// the over-limit entry is dequeued, so the admission counter is the
|
|
435
|
+
// authority — a pass that ISSUED more calls than the cap fails even when
|
|
436
|
+
// its promise settled first.
|
|
437
|
+
if (issuedHostCalls > request.limits.maxHostCalls) {
|
|
438
|
+
return yield* CodeHostCallLimitError.make({
|
|
439
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
440
|
+
limit: request.limits.maxHostCalls,
|
|
441
|
+
logs: [...capture.lines],
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const value = yield* Schema.decodeUnknownEffect(Schema.Json)(returned).pipe(
|
|
446
|
+
Effect.mapError(() =>
|
|
447
|
+
CodeProgramFailedError.make({
|
|
448
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
449
|
+
reason: "non-json-result",
|
|
450
|
+
thrown: null,
|
|
451
|
+
message: "The program must return a JSON value",
|
|
452
|
+
logs: [...capture.lines],
|
|
453
|
+
}),
|
|
454
|
+
),
|
|
455
|
+
);
|
|
456
|
+
const resultBytes = encodedJsonByteLength(value);
|
|
457
|
+
if (resultBytes === undefined || resultBytes > request.limits.maxResultBytes) {
|
|
458
|
+
return yield* CodeOutputLimitError.make({
|
|
459
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
460
|
+
surface: "result",
|
|
461
|
+
limit: request.limits.maxResultBytes,
|
|
462
|
+
observed: resultBytes ?? 0,
|
|
463
|
+
logs: [...capture.lines],
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return CodeExecutionResult.make({
|
|
468
|
+
implementation: inProcessCodeExecutorImplementation,
|
|
469
|
+
value,
|
|
470
|
+
logs: [...capture.lines],
|
|
471
|
+
resourceUse: CodeExecutionResourceUse.make({
|
|
472
|
+
wallTime: Duration.millis(Math.max(0, finishedAt - startedAt)),
|
|
473
|
+
hostCalls: counter.calls,
|
|
474
|
+
logBytes: capture.bytes,
|
|
475
|
+
resultBytes,
|
|
476
|
+
}),
|
|
477
|
+
});
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Layer providing the unisolated in-process `CodeExecutor` substitute. The
|
|
483
|
+
* per-pass `CodeExecutionHost` stays in the caller's requirement channel, the
|
|
484
|
+
* same as every real adapter.
|
|
485
|
+
*/
|
|
486
|
+
export const inProcessCodeExecutorLayer: Layer.Layer<CodeExecutor> = Layer.succeed(CodeExecutor)(
|
|
487
|
+
CodeExecutor.of({ execute: executeInProcess }),
|
|
488
|
+
);
|