@effect-agent/testing 0.0.1-beta.5 → 0.1.0-beta.10
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 +161 -84
- package/dist/index.mjs +568 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +18 -17
- package/src/certification.ts +5 -0
- 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/travel-planner/phase6.ts +4 -0
- package/src/fixtures/warehouse/index.ts +412 -0
- package/src/index.ts +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@effect-agent/testing",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0-beta.10",
|
|
4
|
+
"exports": {
|
|
5
|
+
".": {
|
|
6
|
+
"types": "./dist/index.d.mts",
|
|
7
|
+
"default": "./dist/index.mjs"
|
|
8
|
+
}
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@effect-agent/capabilities": "0.1.0-beta.10",
|
|
12
|
+
"@effect-agent/core": "0.1.0-beta.10",
|
|
13
|
+
"@effect-agent/engine": "0.1.0-beta.10",
|
|
14
|
+
"@effect-agent/platform-node": "0.1.0-beta.10",
|
|
15
|
+
"@effect-agent/sandbox": "0.1.0-beta.10",
|
|
16
|
+
"@effect-agent/session": "0.1.0-beta.10",
|
|
17
|
+
"@effect-agent/storage-memory": "0.1.0-beta.10",
|
|
18
|
+
"@effect-agent/storage-sqlite": "0.1.0-beta.10",
|
|
19
|
+
"effect": "4.0.0-beta.107"
|
|
20
|
+
},
|
|
4
21
|
"description": "Scripted models, deterministic fixtures, and adapter conformance kits for testing Effect Agent applications.",
|
|
5
22
|
"license": "MIT",
|
|
6
23
|
"repository": {
|
|
@@ -13,12 +30,6 @@
|
|
|
13
30
|
"src"
|
|
14
31
|
],
|
|
15
32
|
"type": "module",
|
|
16
|
-
"exports": {
|
|
17
|
-
".": {
|
|
18
|
-
"types": "./dist/index.d.mts",
|
|
19
|
-
"default": "./dist/index.mjs"
|
|
20
|
-
}
|
|
21
|
-
},
|
|
22
33
|
"publishConfig": {
|
|
23
34
|
"access": "public"
|
|
24
35
|
},
|
|
@@ -27,16 +38,6 @@
|
|
|
27
38
|
"check": "tsc --noEmit -p tsconfig.json",
|
|
28
39
|
"test": "vp test --passWithNoTests"
|
|
29
40
|
},
|
|
30
|
-
"dependencies": {
|
|
31
|
-
"@effect-agent/capabilities": "0.0.1-beta.5",
|
|
32
|
-
"@effect-agent/core": "0.0.1-beta.5",
|
|
33
|
-
"@effect-agent/engine": "0.0.1-beta.5",
|
|
34
|
-
"@effect-agent/platform-node": "0.0.1-beta.5",
|
|
35
|
-
"@effect-agent/session": "0.0.1-beta.5",
|
|
36
|
-
"@effect-agent/storage-memory": "0.0.1-beta.5",
|
|
37
|
-
"@effect-agent/storage-sqlite": "0.0.1-beta.5",
|
|
38
|
-
"effect": "4.0.0-beta.107"
|
|
39
|
-
},
|
|
40
41
|
"devDependencies": {
|
|
41
42
|
"@effect/platform-node": "4.0.0-beta.107",
|
|
42
43
|
"@effect/sql-sqlite-node": "4.0.0-beta.107",
|
package/src/certification.ts
CHANGED
|
@@ -166,6 +166,11 @@ export const CERTIFICATION_SCENARIOS: ReadonlyArray<CertificationScenario> = [
|
|
|
166
166
|
*/
|
|
167
167
|
export const TIER2_UNREACHED_LOCATIONS: ReadonlyArray<DurableRuntimeFailpointLocation> = [
|
|
168
168
|
"abort:after-intent",
|
|
169
|
+
// Compaction requires a `contextTokenLimit` policy plus prior-Run history
|
|
170
|
+
// none of the six scenario shapes carries; pinned in-process by the
|
|
171
|
+
// RUN-026 rows in `packages/testing/test/durable-runtime.test.ts`
|
|
172
|
+
// (compaction failpoint idempotence across re-drive).
|
|
173
|
+
"compaction:after-canonical-append",
|
|
169
174
|
"resolve:after-intent",
|
|
170
175
|
"subagent:after-child-abort-intent",
|
|
171
176
|
];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CodeExecutor, SandboxImplementation } from "@effect-agent/sandbox";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
declare const CodeExecutorConformanceViolation_base: Schema.Class<CodeExecutorConformanceViolation, Schema.TaggedStruct<"CodeExecutorConformanceViolation", {
|
|
4
|
+
readonly caseName: Schema.String;
|
|
5
|
+
readonly message: Schema.String;
|
|
6
|
+
}>, import("effect/Cause").YieldableError>;
|
|
7
|
+
/**
|
|
8
|
+
* Shared `CodeExecutor` conformance (TEST-015). Every adapter — the
|
|
9
|
+
* deterministic `unisolated` substitute and each isolated adapter — runs
|
|
10
|
+
* `codeExecutorConformanceCases` verbatim. Enforcement cases that only genuine
|
|
11
|
+
* isolation can prove (ambient network denial, synchronous CPU runaway
|
|
12
|
+
* termination) are NOT here; they belong to isolated adapters only
|
|
13
|
+
* (testing spec §8.1).
|
|
14
|
+
*
|
|
15
|
+
* Cases assume the live `Clock` (the wall-clock case uses a short real
|
|
16
|
+
* deadline) and take one fresh executor pass per case, so a suite may share
|
|
17
|
+
* one executor Layer across cases.
|
|
18
|
+
*/
|
|
19
|
+
export declare class CodeExecutorConformanceViolation extends CodeExecutorConformanceViolation_base {
|
|
20
|
+
}
|
|
21
|
+
export interface CodeExecutorConformanceCase {
|
|
22
|
+
readonly name: string;
|
|
23
|
+
readonly run: Effect.Effect<void, CodeExecutorConformanceViolation, CodeExecutor>;
|
|
24
|
+
}
|
|
25
|
+
export interface CodeExecutorConformanceOptions {
|
|
26
|
+
/** The posture the adapter under test must stamp on results and errors. */
|
|
27
|
+
readonly implementation: SandboxImplementation;
|
|
28
|
+
}
|
|
29
|
+
export declare const codeExecutorConformanceCases: (options: CodeExecutorConformanceOptions) => ReadonlyArray<CodeExecutorConformanceCase>;
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CodeExecutionError,
|
|
3
|
+
CodeExecutionHost,
|
|
4
|
+
CodeExecutionLimits,
|
|
5
|
+
CodeExecutionNamespace,
|
|
6
|
+
CodeExecutionRequest,
|
|
7
|
+
CodeExecutionResult,
|
|
8
|
+
CodeExecutor,
|
|
9
|
+
CodeHostCall,
|
|
10
|
+
CodeHostCallFailure,
|
|
11
|
+
CodeHostCallResult,
|
|
12
|
+
CodeHostCallSuccess,
|
|
13
|
+
NetworkAllowlist,
|
|
14
|
+
NetworkDisabled,
|
|
15
|
+
SandboxImplementation,
|
|
16
|
+
} from "@effect-agent/sandbox";
|
|
17
|
+
import { Deferred, Duration, Effect, Fiber, Schema } from "effect";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Shared `CodeExecutor` conformance (TEST-015). Every adapter — the
|
|
21
|
+
* deterministic `unisolated` substitute and each isolated adapter — runs
|
|
22
|
+
* `codeExecutorConformanceCases` verbatim. Enforcement cases that only genuine
|
|
23
|
+
* isolation can prove (ambient network denial, synchronous CPU runaway
|
|
24
|
+
* termination) are NOT here; they belong to isolated adapters only
|
|
25
|
+
* (testing spec §8.1).
|
|
26
|
+
*
|
|
27
|
+
* Cases assume the live `Clock` (the wall-clock case uses a short real
|
|
28
|
+
* deadline) and take one fresh executor pass per case, so a suite may share
|
|
29
|
+
* one executor Layer across cases.
|
|
30
|
+
*/
|
|
31
|
+
export class CodeExecutorConformanceViolation extends Schema.TaggedError<CodeExecutorConformanceViolation>()(
|
|
32
|
+
"CodeExecutorConformanceViolation",
|
|
33
|
+
{
|
|
34
|
+
caseName: Schema.String,
|
|
35
|
+
message: Schema.String,
|
|
36
|
+
},
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
export interface CodeExecutorConformanceCase {
|
|
40
|
+
readonly name: string;
|
|
41
|
+
readonly run: Effect.Effect<void, CodeExecutorConformanceViolation, CodeExecutor>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface CodeExecutorConformanceOptions {
|
|
45
|
+
/** The posture the adapter under test must stamp on results and errors. */
|
|
46
|
+
readonly implementation: SandboxImplementation;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const baseLimits = CodeExecutionLimits.make({
|
|
50
|
+
maxSourceBytes: 64 * 1024,
|
|
51
|
+
maxWallTime: Duration.seconds(10),
|
|
52
|
+
maxLogBytes: 16 * 1024,
|
|
53
|
+
maxResultBytes: 64 * 1024,
|
|
54
|
+
maxHostCalls: 8,
|
|
55
|
+
maxHostCallArgumentBytes: 16 * 1024,
|
|
56
|
+
maxHostCallResultBytes: 32 * 1024,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const warehouseNamespace = CodeExecutionNamespace.make({
|
|
60
|
+
name: "warehouse",
|
|
61
|
+
methods: ["query", "count"],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const makeRequest = (
|
|
65
|
+
source: string,
|
|
66
|
+
overrides?: {
|
|
67
|
+
readonly limits?: CodeExecutionLimits;
|
|
68
|
+
readonly namespaces?: ReadonlyArray<CodeExecutionNamespace>;
|
|
69
|
+
readonly network?: CodeExecutionRequest["network"];
|
|
70
|
+
},
|
|
71
|
+
): CodeExecutionRequest =>
|
|
72
|
+
CodeExecutionRequest.make({
|
|
73
|
+
language: "javascript",
|
|
74
|
+
source,
|
|
75
|
+
namespaces: overrides?.namespaces ?? [],
|
|
76
|
+
network: overrides?.network ?? NetworkDisabled.make(),
|
|
77
|
+
limits: overrides?.limits ?? baseLimits,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const unusedHost: CodeExecutionHost["Service"] = {
|
|
81
|
+
call: () =>
|
|
82
|
+
Effect.die(
|
|
83
|
+
new Error("this conformance case expected no host call to reach the CodeExecutionHost"),
|
|
84
|
+
),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const respondingHost = (
|
|
88
|
+
respond: (call: CodeHostCall) => CodeHostCallResult,
|
|
89
|
+
): { readonly host: CodeExecutionHost["Service"]; readonly calls: Array<CodeHostCall> } => {
|
|
90
|
+
const calls: Array<CodeHostCall> = [];
|
|
91
|
+
return {
|
|
92
|
+
calls,
|
|
93
|
+
host: {
|
|
94
|
+
call: (call) =>
|
|
95
|
+
Effect.sync(() => {
|
|
96
|
+
calls.push(call);
|
|
97
|
+
return respond(call);
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const runPass = (
|
|
104
|
+
request: CodeExecutionRequest,
|
|
105
|
+
host: CodeExecutionHost["Service"],
|
|
106
|
+
): Effect.Effect<CodeExecutionResult, CodeExecutionError, CodeExecutor> =>
|
|
107
|
+
Effect.gen(function* () {
|
|
108
|
+
const executor = yield* CodeExecutor;
|
|
109
|
+
return yield* executor
|
|
110
|
+
.execute(request)
|
|
111
|
+
.pipe(Effect.provideService(CodeExecutionHost, CodeExecutionHost.of(host)));
|
|
112
|
+
}).pipe(Effect.scoped);
|
|
113
|
+
|
|
114
|
+
const violation = (caseName: string, message: string) =>
|
|
115
|
+
CodeExecutorConformanceViolation.make({ caseName, message });
|
|
116
|
+
|
|
117
|
+
const preview = (value: unknown): string => {
|
|
118
|
+
try {
|
|
119
|
+
return JSON.stringify(value)?.slice(0, 200) ?? String(value).slice(0, 200);
|
|
120
|
+
} catch {
|
|
121
|
+
return String(value).slice(0, 200);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const expectSuccess = (
|
|
126
|
+
caseName: string,
|
|
127
|
+
request: CodeExecutionRequest,
|
|
128
|
+
host: CodeExecutionHost["Service"],
|
|
129
|
+
check: (result: CodeExecutionResult) => string | undefined,
|
|
130
|
+
): Effect.Effect<void, CodeExecutorConformanceViolation, CodeExecutor> =>
|
|
131
|
+
runPass(request, host).pipe(
|
|
132
|
+
Effect.mapError((error) =>
|
|
133
|
+
violation(caseName, `expected success, got ${error._tag}: ${preview(error)}`),
|
|
134
|
+
),
|
|
135
|
+
Effect.flatMap((result) => {
|
|
136
|
+
const complaint = check(result);
|
|
137
|
+
return complaint === undefined ? Effect.void : Effect.fail(violation(caseName, complaint));
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const expectFailure = (
|
|
142
|
+
caseName: string,
|
|
143
|
+
request: CodeExecutionRequest,
|
|
144
|
+
host: CodeExecutionHost["Service"],
|
|
145
|
+
tag: CodeExecutionError["_tag"],
|
|
146
|
+
check?: (error: CodeExecutionError) => string | undefined,
|
|
147
|
+
): Effect.Effect<void, CodeExecutorConformanceViolation, CodeExecutor> =>
|
|
148
|
+
runPass(request, host).pipe(
|
|
149
|
+
Effect.flip,
|
|
150
|
+
Effect.mapError((result) =>
|
|
151
|
+
violation(caseName, `expected ${tag}, but the pass succeeded with ${preview(result.value)}`),
|
|
152
|
+
),
|
|
153
|
+
Effect.flatMap((error) => {
|
|
154
|
+
if (error._tag !== tag) {
|
|
155
|
+
return Effect.fail(
|
|
156
|
+
violation(caseName, `expected ${tag}, got ${error._tag}: ${preview(error)}`),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
const complaint = check?.(error);
|
|
160
|
+
return complaint === undefined ? Effect.void : Effect.fail(violation(caseName, complaint));
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
export const codeExecutorConformanceCases = (
|
|
165
|
+
options: CodeExecutorConformanceOptions,
|
|
166
|
+
): ReadonlyArray<CodeExecutorConformanceCase> => {
|
|
167
|
+
const posture = options.implementation;
|
|
168
|
+
return [
|
|
169
|
+
{
|
|
170
|
+
name: "TEST-015 executes bounded JSON computation and returns the program value",
|
|
171
|
+
run: expectSuccess(
|
|
172
|
+
"TEST-015 executes bounded JSON computation and returns the program value",
|
|
173
|
+
makeRequest(
|
|
174
|
+
"async () => { const xs = [1, 2, 3].map((n) => n * 2); return { xs, sum: xs.reduce((a, b) => a + b, 0) }; }",
|
|
175
|
+
),
|
|
176
|
+
unusedHost,
|
|
177
|
+
(result) =>
|
|
178
|
+
JSON.stringify(result.value) === JSON.stringify({ xs: [2, 4, 6], sum: 12 })
|
|
179
|
+
? undefined
|
|
180
|
+
: `unexpected program value ${preview(result.value)}`,
|
|
181
|
+
),
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
name: "CAP-015 reports its isolation posture honestly in results and errors",
|
|
185
|
+
run: Effect.gen(function* () {
|
|
186
|
+
const caseName = "CAP-015 reports its isolation posture honestly in results and errors";
|
|
187
|
+
const result = yield* runPass(makeRequest("async () => 1"), unusedHost).pipe(
|
|
188
|
+
Effect.mapError((error) => violation(caseName, `expected success, got ${error._tag}`)),
|
|
189
|
+
);
|
|
190
|
+
if (
|
|
191
|
+
result.implementation.isolation !== posture.isolation ||
|
|
192
|
+
result.implementation.identity !== posture.identity
|
|
193
|
+
) {
|
|
194
|
+
return yield* violation(
|
|
195
|
+
caseName,
|
|
196
|
+
`result posture ${preview(result.implementation)} does not match the declared ${preview(posture)}`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const error = yield* runPass(makeRequest("async () => {"), unusedHost).pipe(
|
|
200
|
+
Effect.flip,
|
|
201
|
+
Effect.mapError(() => violation(caseName, "expected the invalid-source pass to fail")),
|
|
202
|
+
);
|
|
203
|
+
// Every expected execution failure carries the posture; an adapter
|
|
204
|
+
// omitting the field must fail this case, not slip past a probe.
|
|
205
|
+
if (
|
|
206
|
+
error.implementation === undefined ||
|
|
207
|
+
error.implementation.isolation !== posture.isolation ||
|
|
208
|
+
error.implementation.identity !== posture.identity
|
|
209
|
+
) {
|
|
210
|
+
return yield* violation(
|
|
211
|
+
caseName,
|
|
212
|
+
`error posture ${preview(error.implementation)} does not match the declared ${preview(posture)}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}),
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
name: "TEST-015 routes host calls through the CodeExecutionHost in program order",
|
|
219
|
+
run: Effect.gen(function* () {
|
|
220
|
+
const caseName =
|
|
221
|
+
"TEST-015 routes host calls through the CodeExecutionHost in program order";
|
|
222
|
+
const { host, calls } = respondingHost((call) =>
|
|
223
|
+
call.method === "query"
|
|
224
|
+
? CodeHostCallSuccess.make({ value: { rows: [1, 2, 3] } })
|
|
225
|
+
: CodeHostCallSuccess.make({ value: 3 }),
|
|
226
|
+
);
|
|
227
|
+
const result = yield* runPass(
|
|
228
|
+
makeRequest(
|
|
229
|
+
"async () => { const q = await warehouse.query({ sql: 'select' }); const c = await warehouse.count({ table: 't' }); return { rows: q.rows, count: c }; }",
|
|
230
|
+
{ namespaces: [warehouseNamespace] },
|
|
231
|
+
),
|
|
232
|
+
host,
|
|
233
|
+
).pipe(
|
|
234
|
+
Effect.mapError((error) =>
|
|
235
|
+
violation(caseName, `expected success, got ${error._tag}: ${preview(error)}`),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
if (JSON.stringify(result.value) !== JSON.stringify({ rows: [1, 2, 3], count: 3 })) {
|
|
239
|
+
return yield* violation(caseName, `unexpected value ${preview(result.value)}`);
|
|
240
|
+
}
|
|
241
|
+
const observed = calls.map((call) => `${call.namespace}.${call.method}`);
|
|
242
|
+
if (JSON.stringify(observed) !== JSON.stringify(["warehouse.query", "warehouse.count"])) {
|
|
243
|
+
return yield* violation(caseName, `unexpected host call order ${preview(observed)}`);
|
|
244
|
+
}
|
|
245
|
+
if (result.resourceUse.hostCalls !== 2) {
|
|
246
|
+
return yield* violation(
|
|
247
|
+
caseName,
|
|
248
|
+
`expected 2 accounted host calls, got ${result.resourceUse.hostCalls}`,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
}),
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
name: "TEST-015 a caught failed host call lets the program branch on the envelope",
|
|
255
|
+
run: expectSuccess(
|
|
256
|
+
"TEST-015 a caught failed host call lets the program branch on the envelope",
|
|
257
|
+
makeRequest(
|
|
258
|
+
"async () => { try { await warehouse.query({ sql: 'x' }); return 'unreachable'; } catch (envelope) { return { caught: envelope }; } }",
|
|
259
|
+
{ namespaces: [warehouseNamespace] },
|
|
260
|
+
),
|
|
261
|
+
respondingHost(() =>
|
|
262
|
+
CodeHostCallFailure.make({ error: { _tag: "ToolInputError", message: "bad input" } }),
|
|
263
|
+
).host,
|
|
264
|
+
(result) =>
|
|
265
|
+
JSON.stringify(result.value) ===
|
|
266
|
+
JSON.stringify({ caught: { _tag: "ToolInputError", message: "bad input" } })
|
|
267
|
+
? undefined
|
|
268
|
+
: `the envelope did not round-trip: ${preview(result.value)}`,
|
|
269
|
+
),
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "TEST-015 an uncaught failed host call fails the program with the envelope",
|
|
273
|
+
run: expectFailure(
|
|
274
|
+
"TEST-015 an uncaught failed host call fails the program with the envelope",
|
|
275
|
+
makeRequest("async () => warehouse.query({ sql: 'x' })", {
|
|
276
|
+
namespaces: [warehouseNamespace],
|
|
277
|
+
}),
|
|
278
|
+
respondingHost(() =>
|
|
279
|
+
CodeHostCallFailure.make({ error: { _tag: "PolicyDenied", message: "denied" } }),
|
|
280
|
+
).host,
|
|
281
|
+
"CodeProgramFailedError",
|
|
282
|
+
(error) =>
|
|
283
|
+
error._tag === "CodeProgramFailedError" &&
|
|
284
|
+
error.reason === "rejected" &&
|
|
285
|
+
JSON.stringify(error.thrown) ===
|
|
286
|
+
JSON.stringify({ _tag: "PolicyDenied", message: "denied" })
|
|
287
|
+
? undefined
|
|
288
|
+
: `unexpected failure detail ${preview(error)}`,
|
|
289
|
+
),
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
name: "TEST-015 fails typed on syntactically invalid source",
|
|
293
|
+
run: expectFailure(
|
|
294
|
+
"TEST-015 fails typed on syntactically invalid source",
|
|
295
|
+
makeRequest("async () => {"),
|
|
296
|
+
unusedHost,
|
|
297
|
+
"CodeSourceError",
|
|
298
|
+
(error) =>
|
|
299
|
+
error._tag === "CodeSourceError" && error.reason === "invalid"
|
|
300
|
+
? undefined
|
|
301
|
+
: `expected reason invalid, got ${preview(error)}`,
|
|
302
|
+
),
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
name: "TEST-015 fails typed when the expression is not one async function",
|
|
306
|
+
run: expectFailure(
|
|
307
|
+
"TEST-015 fails typed when the expression is not one async function",
|
|
308
|
+
makeRequest("1 + 1"),
|
|
309
|
+
unusedHost,
|
|
310
|
+
"CodeSourceError",
|
|
311
|
+
(error) =>
|
|
312
|
+
error._tag === "CodeSourceError" && error.reason === "not-a-function"
|
|
313
|
+
? undefined
|
|
314
|
+
: `expected reason not-a-function, got ${preview(error)}`,
|
|
315
|
+
),
|
|
316
|
+
},
|
|
317
|
+
{
|
|
318
|
+
name: "TEST-015 fails typed on source larger than the declared byte limit",
|
|
319
|
+
run: expectFailure(
|
|
320
|
+
"TEST-015 fails typed on source larger than the declared byte limit",
|
|
321
|
+
makeRequest(`async () => "${"x".repeat(2_000)}"`, {
|
|
322
|
+
limits: CodeExecutionLimits.make({ ...baseLimits, maxSourceBytes: 256 }),
|
|
323
|
+
}),
|
|
324
|
+
unusedHost,
|
|
325
|
+
"CodeSourceError",
|
|
326
|
+
(error) =>
|
|
327
|
+
error._tag === "CodeSourceError" && error.reason === "oversized"
|
|
328
|
+
? undefined
|
|
329
|
+
: `expected reason oversized, got ${preview(error)}`,
|
|
330
|
+
),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
name: "TEST-015 terminates a never-settling program at the wall-clock deadline",
|
|
334
|
+
run: expectFailure(
|
|
335
|
+
"TEST-015 terminates a never-settling program at the wall-clock deadline",
|
|
336
|
+
makeRequest("async () => { await new Promise(() => {}); return 1; }", {
|
|
337
|
+
limits: CodeExecutionLimits.make({ ...baseLimits, maxWallTime: Duration.millis(250) }),
|
|
338
|
+
}),
|
|
339
|
+
unusedHost,
|
|
340
|
+
"CodeExecutionTimeoutError",
|
|
341
|
+
),
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "TEST-015 fails typed when console output exceeds its byte budget",
|
|
345
|
+
run: expectFailure(
|
|
346
|
+
"TEST-015 fails typed when console output exceeds its byte budget",
|
|
347
|
+
makeRequest(
|
|
348
|
+
"async () => { for (let i = 0; i < 64; i += 1) { console.log('x'.repeat(256)); } return 1; }",
|
|
349
|
+
{ limits: CodeExecutionLimits.make({ ...baseLimits, maxLogBytes: 2_048 }) },
|
|
350
|
+
),
|
|
351
|
+
unusedHost,
|
|
352
|
+
"CodeOutputLimitError",
|
|
353
|
+
(error) =>
|
|
354
|
+
error._tag === "CodeOutputLimitError" && error.surface === "logs"
|
|
355
|
+
? undefined
|
|
356
|
+
: `expected surface logs, got ${preview(error)}`,
|
|
357
|
+
),
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "TEST-015 fails typed when the final result exceeds its byte budget",
|
|
361
|
+
run: expectFailure(
|
|
362
|
+
"TEST-015 fails typed when the final result exceeds its byte budget",
|
|
363
|
+
makeRequest("async () => 'y'.repeat(4096)", {
|
|
364
|
+
limits: CodeExecutionLimits.make({ ...baseLimits, maxResultBytes: 1_024 }),
|
|
365
|
+
}),
|
|
366
|
+
unusedHost,
|
|
367
|
+
"CodeOutputLimitError",
|
|
368
|
+
(error) =>
|
|
369
|
+
error._tag === "CodeOutputLimitError" && error.surface === "result"
|
|
370
|
+
? undefined
|
|
371
|
+
: `expected surface result, got ${preview(error)}`,
|
|
372
|
+
),
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
name: "TEST-015 fails typed when host calls exceed the executor cap",
|
|
376
|
+
run: Effect.gen(function* () {
|
|
377
|
+
const caseName = "TEST-015 fails typed when host calls exceed the executor cap";
|
|
378
|
+
const { host, calls } = respondingHost(() => CodeHostCallSuccess.make({ value: null }));
|
|
379
|
+
yield* expectFailure(
|
|
380
|
+
caseName,
|
|
381
|
+
makeRequest(
|
|
382
|
+
"async () => { await warehouse.query({}); await warehouse.query({}); await warehouse.query({}); return 1; }",
|
|
383
|
+
{
|
|
384
|
+
namespaces: [warehouseNamespace],
|
|
385
|
+
limits: CodeExecutionLimits.make({ ...baseLimits, maxHostCalls: 2 }),
|
|
386
|
+
},
|
|
387
|
+
),
|
|
388
|
+
host,
|
|
389
|
+
"CodeHostCallLimitError",
|
|
390
|
+
);
|
|
391
|
+
// The cap is enforced before dispatch: the over-limit call must never
|
|
392
|
+
// have reached the host, or an unauthorized side effect already ran.
|
|
393
|
+
if (calls.length !== 2) {
|
|
394
|
+
return yield* violation(
|
|
395
|
+
caseName,
|
|
396
|
+
`expected exactly 2 dispatched host calls under a cap of 2, observed ${calls.length}`,
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
}),
|
|
400
|
+
},
|
|
401
|
+
{
|
|
402
|
+
name: "TEST-015 fails typed on a host outcome outside the protocol schema",
|
|
403
|
+
run: expectFailure(
|
|
404
|
+
"TEST-015 fails typed on a host outcome outside the protocol schema",
|
|
405
|
+
makeRequest("async () => warehouse.query({})", { namespaces: [warehouseNamespace] }),
|
|
406
|
+
{
|
|
407
|
+
call: () => Effect.succeed({ bogus: true } as unknown as CodeHostCallResult),
|
|
408
|
+
},
|
|
409
|
+
"CodeExecutionProtocolError",
|
|
410
|
+
),
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: "TEST-015 surfaces an uncaught program throw with its bounded log capture",
|
|
414
|
+
run: expectFailure(
|
|
415
|
+
"TEST-015 surfaces an uncaught program throw with its bounded log capture",
|
|
416
|
+
makeRequest(
|
|
417
|
+
"async () => { console.log('before the failure'); throw new Error('deliberate'); }",
|
|
418
|
+
),
|
|
419
|
+
unusedHost,
|
|
420
|
+
"CodeProgramFailedError",
|
|
421
|
+
(error) =>
|
|
422
|
+
error._tag === "CodeProgramFailedError" &&
|
|
423
|
+
error.reason === "threw" &&
|
|
424
|
+
error.logs.some((line) => line.includes("before the failure"))
|
|
425
|
+
? undefined
|
|
426
|
+
: `expected a threw failure carrying the log capture, got ${preview(error)}`,
|
|
427
|
+
),
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
name: "TEST-015 fails typed when the program returns a non-JSON value",
|
|
431
|
+
run: expectFailure(
|
|
432
|
+
"TEST-015 fails typed when the program returns a non-JSON value",
|
|
433
|
+
makeRequest("async () => (() => 1)"),
|
|
434
|
+
unusedHost,
|
|
435
|
+
"CodeProgramFailedError",
|
|
436
|
+
(error) =>
|
|
437
|
+
error._tag === "CodeProgramFailedError" && error.reason === "non-json-result"
|
|
438
|
+
? undefined
|
|
439
|
+
: `expected reason non-json-result, got ${preview(error)}`,
|
|
440
|
+
),
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
name: "CAP-015 rejects a network allowlist it cannot enforce with a typed unsupported error",
|
|
444
|
+
run: expectFailure(
|
|
445
|
+
"CAP-015 rejects a network allowlist it cannot enforce with a typed unsupported error",
|
|
446
|
+
makeRequest("async () => 1", {
|
|
447
|
+
network: NetworkAllowlist.make({ domains: ["example.com"], ports: [443] }),
|
|
448
|
+
}),
|
|
449
|
+
unusedHost,
|
|
450
|
+
"CodeExecutorUnsupportedError",
|
|
451
|
+
(error) =>
|
|
452
|
+
error._tag === "CodeExecutorUnsupportedError" && error.feature === "network"
|
|
453
|
+
? undefined
|
|
454
|
+
: `expected feature network, got ${preview(error)}`,
|
|
455
|
+
),
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: "TEST-015 interruption reaches in-flight host calls and pass teardown",
|
|
459
|
+
run: Effect.gen(function* () {
|
|
460
|
+
const caseName = "TEST-015 interruption reaches in-flight host calls and pass teardown";
|
|
461
|
+
const started = yield* Deferred.make<void>();
|
|
462
|
+
const witness = { hostCallInterrupted: false };
|
|
463
|
+
const host: CodeExecutionHost["Service"] = {
|
|
464
|
+
call: () =>
|
|
465
|
+
Deferred.succeed(started, undefined).pipe(
|
|
466
|
+
Effect.andThen(Effect.never),
|
|
467
|
+
Effect.ensuring(
|
|
468
|
+
Effect.sync(() => {
|
|
469
|
+
witness.hostCallInterrupted = true;
|
|
470
|
+
}),
|
|
471
|
+
),
|
|
472
|
+
),
|
|
473
|
+
};
|
|
474
|
+
const fiber = yield* runPass(
|
|
475
|
+
makeRequest("async () => warehouse.query({})", { namespaces: [warehouseNamespace] }),
|
|
476
|
+
host,
|
|
477
|
+
).pipe(Effect.forkChild);
|
|
478
|
+
// Guard against a broken adapter that settles the pass without ever
|
|
479
|
+
// reaching the host: the case must report a violation, not hang.
|
|
480
|
+
const winner = yield* Effect.raceFirst(
|
|
481
|
+
Deferred.await(started).pipe(Effect.as("started" as const)),
|
|
482
|
+
Fiber.join(fiber).pipe(Effect.exit, Effect.as("exited" as const)),
|
|
483
|
+
);
|
|
484
|
+
if (winner === "exited") {
|
|
485
|
+
return yield* violation(
|
|
486
|
+
caseName,
|
|
487
|
+
"the pass settled before any host call reached the CodeExecutionHost",
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
yield* Fiber.interrupt(fiber);
|
|
491
|
+
if (!witness.hostCallInterrupted) {
|
|
492
|
+
return yield* violation(
|
|
493
|
+
caseName,
|
|
494
|
+
"interrupting the pass did not interrupt the in-flight host call",
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
}),
|
|
498
|
+
},
|
|
499
|
+
];
|
|
500
|
+
};
|