@akterjs/conformance 0.0.0-stage → 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/LICENSE +201 -0
- package/README.md +52 -2
- package/dist/client-contract.suite.d.mts +23 -0
- package/dist/client-contract.suite.mjs +279 -0
- package/dist/client-target.errors.d.mts +8 -0
- package/dist/client-target.errors.mjs +4 -0
- package/dist/engine-conformance.suite.d.mts +19 -0
- package/dist/engine-conformance.suite.mjs +357 -0
- package/dist/fork/fork-conformance.suite.d.mts +20 -0
- package/dist/fork/fork-conformance.suite.mjs +167 -0
- package/dist/guarantee-titles.pure.d.mts +31 -0
- package/dist/guarantee-titles.pure.mjs +32 -0
- package/dist/ledger/ledger-calls.pure.d.mts +79 -0
- package/dist/ledger/ledger-calls.pure.mjs +111 -0
- package/dist/ledger/ledger-checks.pure.d.mts +21 -0
- package/dist/ledger/ledger-checks.pure.mjs +37 -0
- package/dist/ledger/ledger.actor.d.mts +13 -0
- package/dist/ledger/ledger.actor.mjs +115 -0
- package/dist/ledger/ledger.contract.d.mts +310 -0
- package/dist/ledger/ledger.contract.mjs +112 -0
- package/dist/ledger/plain-ledger.actor.d.mts +4 -0
- package/dist/ledger/plain-ledger.actor.mjs +214 -0
- package/dist/rejected.errors.d.mts +8 -0
- package/dist/rejected.errors.mjs +4 -0
- package/dist/tally-clients.layer.d.mts +39 -0
- package/dist/tally-clients.layer.mjs +97 -0
- package/dist/tally.actor.d.mts +49 -0
- package/dist/tally.actor.mjs +26 -0
- package/dist/tally.contract.d.mts +51 -0
- package/dist/tally.contract.mjs +28 -0
- package/dist/workbench/computer-conformance.suite.d.mts +15 -0
- package/dist/workbench/computer-conformance.suite.mjs +330 -0
- package/dist/workbench/workbench-calls.pure.d.mts +45 -0
- package/dist/workbench/workbench-calls.pure.mjs +75 -0
- package/dist/workbench/workbench.actor.d.mts +4 -0
- package/dist/workbench/workbench.actor.mjs +107 -0
- package/dist/workbench/workbench.contract.d.mts +220 -0
- package/dist/workbench/workbench.contract.mjs +54 -0
- package/package.json +86 -3
- package/src/client-contract.suite.ts +413 -0
- package/src/client-target.errors.ts +7 -0
- package/src/engine-conformance.suite.ts +484 -0
- package/src/fork/fork-conformance.suite.ts +204 -0
- package/src/guarantee-titles.pure.ts +50 -0
- package/src/ledger/ledger-calls.pure.ts +179 -0
- package/src/ledger/ledger-checks.pure.ts +84 -0
- package/src/ledger/ledger.actor.ts +196 -0
- package/src/ledger/ledger.contract.ts +76 -0
- package/src/ledger/plain-ledger.actor.ts +246 -0
- package/src/rejected.errors.ts +6 -0
- package/src/tally-clients.layer.ts +221 -0
- package/src/tally.actor.ts +28 -0
- package/src/tally.contract.ts +22 -0
- package/src/workbench/computer-conformance.suite.ts +380 -0
- package/src/workbench/workbench-calls.pure.ts +92 -0
- package/src/workbench/workbench.actor.ts +175 -0
- package/src/workbench/workbench.contract.ts +47 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
import { ActorHost } from "@akterjs/effect";
|
|
2
|
+
import { expect } from "@effect/vitest";
|
|
3
|
+
import type { Vitest } from "@effect/vitest";
|
|
4
|
+
import {
|
|
5
|
+
Array as Arr,
|
|
6
|
+
Clock,
|
|
7
|
+
Context,
|
|
8
|
+
Effect,
|
|
9
|
+
Layer,
|
|
10
|
+
Option,
|
|
11
|
+
Predicate,
|
|
12
|
+
Record as Rec,
|
|
13
|
+
Schedule,
|
|
14
|
+
String as Str,
|
|
15
|
+
} from "effect";
|
|
16
|
+
|
|
17
|
+
import type { Engine, Storage } from "#src/engine-conformance.suite.js";
|
|
18
|
+
import { computerGuaranteeTitles } from "#src/guarantee-titles.pure.js";
|
|
19
|
+
import type { Host, Unexpected } from "#src/ledger/ledger-calls.pure.js";
|
|
20
|
+
import { WorkbenchLive } from "./workbench.actor.js";
|
|
21
|
+
import { ComputerOp } from "./workbench.contract.js";
|
|
22
|
+
import { bench, ensure, get, list, remove, stop, unfinished, workbenchHost } from "./workbench-calls.pure.js";
|
|
23
|
+
|
|
24
|
+
type Checked<Failure> = Effect.Effect<void, Failure | Unexpected>;
|
|
25
|
+
|
|
26
|
+
const { Pause, Resume, Step } = ComputerOp.cases;
|
|
27
|
+
|
|
28
|
+
const { exec, outcomesOf, ran, startedRun } = workbenchHost;
|
|
29
|
+
|
|
30
|
+
const holding = (hold?: string): { readonly hold?: string } => Rec.getSomes({ hold: Option.fromUndefinedOr(hold) });
|
|
31
|
+
|
|
32
|
+
const pause = (computer: string, hold?: string): ComputerOp => Pause.make({ computer, ...holding(hold) });
|
|
33
|
+
|
|
34
|
+
const resume = (computer: string, hold?: string): ComputerOp => Resume.make({ computer, ...holding(hold) });
|
|
35
|
+
|
|
36
|
+
/** A durable step on `box`, named `name`, with an optional limit in seconds. */
|
|
37
|
+
const step = (name: string, command: string, timeoutSeconds?: number): ComputerOp => {
|
|
38
|
+
const limit = Rec.getSomes({ timeoutSeconds: Option.fromUndefinedOr(timeoutSeconds) });
|
|
39
|
+
|
|
40
|
+
return Step.make({ command, computer: "box", name, ...limit });
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Runs `use` on the Workbench on the real clock: a computer's calls wait on real processes and machines. */
|
|
44
|
+
const onBench = <Failure, Success>(
|
|
45
|
+
storage: Storage<Failure>,
|
|
46
|
+
use: (host: Host) => Effect.Effect<Success, Unexpected>,
|
|
47
|
+
): Effect.Effect<Success, Failure | Unexpected> => {
|
|
48
|
+
const engine = storage.pipe(Layer.provide(WorkbenchLive));
|
|
49
|
+
const run = Effect.flatMap(Layer.build(engine), (services) => use(Context.get(services, ActorHost)));
|
|
50
|
+
|
|
51
|
+
return Effect.provideService(Effect.scoped(run), Clock.Clock, Clock.Clock.defaultValue());
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A step that counts how many times it ran, in a file on the computer. */
|
|
55
|
+
const counting = "echo ran >> runs; grep -c ran runs";
|
|
56
|
+
|
|
57
|
+
const stepRunsOnce = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
58
|
+
Effect.gen(function* resendAcrossRestart() {
|
|
59
|
+
const live = yield* onBench(storage, (host) =>
|
|
60
|
+
ran(host, "r-1", [ensure("box"), exec("box", "count", counting), exec("box", "count", counting)]),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const restarted = yield* onBench(storage, (host) =>
|
|
64
|
+
ran(host, "r-2", [exec("box", "count", counting), exec("box", "read", "grep -c ran runs")]),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect([live, restarted]).toStrictEqual([
|
|
68
|
+
["up", "exit 0: 1\n", "exit 0: 1\n"],
|
|
69
|
+
["exit 0: 1\n", "exit 0: 1\n"],
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const keepsFiles = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
74
|
+
Effect.gen(function* writePauseStopRestart() {
|
|
75
|
+
const live = yield* onBench(storage, (host) =>
|
|
76
|
+
ran(host, "k-1", [
|
|
77
|
+
ensure("box"),
|
|
78
|
+
exec("box", "write", "echo kept > notes"),
|
|
79
|
+
pause("box"),
|
|
80
|
+
resume("box"),
|
|
81
|
+
stop("box"),
|
|
82
|
+
]),
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const restarted = yield* onBench(storage, (host) =>
|
|
86
|
+
ran(host, "k-2", [get("box"), exec("box", "read", "cat notes"), get("box")]),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect([live, restarted]).toStrictEqual([
|
|
90
|
+
["up", "exit 0: ", "paused", "up", "stopped"],
|
|
91
|
+
["stopped", "exit 0: kept\n", "up"],
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Neither a plain pause nor a held pause starts a stopped computer; a plain pause cannot interfere with its hold. */
|
|
96
|
+
const stoppedStaysStopped = (host: Host): Effect.Effect<void, Unexpected> =>
|
|
97
|
+
Effect.gen(function* pauseStopped() {
|
|
98
|
+
const asked = yield* ran(host, "h-2", [
|
|
99
|
+
ensure("idle"),
|
|
100
|
+
stop("idle"),
|
|
101
|
+
pause("idle"),
|
|
102
|
+
pause("idle", "fork-3"),
|
|
103
|
+
pause("idle"),
|
|
104
|
+
exec("idle", "step", "true"),
|
|
105
|
+
get("idle"),
|
|
106
|
+
resume("idle", "fork-3"),
|
|
107
|
+
exec("idle", "step", "echo again"),
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
expect(asked).toStrictEqual([
|
|
111
|
+
"up",
|
|
112
|
+
"stopped",
|
|
113
|
+
"stopped",
|
|
114
|
+
"stopped",
|
|
115
|
+
"ComputerHeldError",
|
|
116
|
+
"ComputerHeldError",
|
|
117
|
+
"stopped",
|
|
118
|
+
"stopped",
|
|
119
|
+
"exit 0: again\n",
|
|
120
|
+
]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
const heldStaysPaused = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
124
|
+
onBench(storage, (host) =>
|
|
125
|
+
Effect.gen(function* holdThenRelease() {
|
|
126
|
+
const outcomes = yield* ran(host, "h-1", [
|
|
127
|
+
ensure("box"),
|
|
128
|
+
pause("box", "fork-1"),
|
|
129
|
+
exec("box", "step", "true"),
|
|
130
|
+
ensure("box"),
|
|
131
|
+
resume("box"),
|
|
132
|
+
resume("box", "fork-2"),
|
|
133
|
+
pause("box"),
|
|
134
|
+
pause("box", "fork-2"),
|
|
135
|
+
pause("box", "fork-1"),
|
|
136
|
+
get("box"),
|
|
137
|
+
resume("box", "fork-1"),
|
|
138
|
+
resume("box", "fork-1"),
|
|
139
|
+
exec("box", "step", "echo once"),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const held = "ComputerHeldError";
|
|
143
|
+
|
|
144
|
+
expect(outcomes).toStrictEqual([
|
|
145
|
+
"up",
|
|
146
|
+
"paused",
|
|
147
|
+
held,
|
|
148
|
+
held,
|
|
149
|
+
held,
|
|
150
|
+
held,
|
|
151
|
+
held,
|
|
152
|
+
held,
|
|
153
|
+
"paused",
|
|
154
|
+
"paused",
|
|
155
|
+
"up",
|
|
156
|
+
"up",
|
|
157
|
+
"exit 0: once\n",
|
|
158
|
+
]);
|
|
159
|
+
|
|
160
|
+
yield* stoppedStaysStopped(host);
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const tried = 8;
|
|
165
|
+
|
|
166
|
+
const none = 0;
|
|
167
|
+
|
|
168
|
+
const first = 1;
|
|
169
|
+
|
|
170
|
+
/** `value` `count` times, for a count from none to `tried`. */
|
|
171
|
+
const times = (value: string, count: number): readonly string[] => Arr.take(Arr.replicate(value, tried), count);
|
|
172
|
+
|
|
173
|
+
const listed = (names: readonly string[]): string => `[${Arr.join(Arr.sort(names, Str.Order), ",")}]`;
|
|
174
|
+
|
|
175
|
+
const limitHolds = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
176
|
+
onBench(storage, (host) =>
|
|
177
|
+
Effect.gen(function* fillThenFree() {
|
|
178
|
+
const names = Arr.makeBy(tried, (index) => `c${String(index)}`);
|
|
179
|
+
const filled = yield* ran(host, "l-1", [...Arr.map(names, ensure), list]);
|
|
180
|
+
const made = Arr.takeWhile(filled, (outcome) => outcome === "up").length;
|
|
181
|
+
const refused = `ComputerLimitError store ${String(made)}`;
|
|
182
|
+
const kept = Arr.take(names, made);
|
|
183
|
+
const freed = yield* ran(host, "l-2", [remove("c0"), ensure("again"), ensure("more"), list]);
|
|
184
|
+
|
|
185
|
+
expect({ filled, freed, made: made > none && made < tried }).toStrictEqual({
|
|
186
|
+
filled: [...times("up", made), ...times(refused, tried - made), listed(kept)],
|
|
187
|
+
freed: ["removed", "up", refused, listed([...Arr.drop(kept, first), "again"])],
|
|
188
|
+
made: true,
|
|
189
|
+
});
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const removedIsGone = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
194
|
+
onBench(storage, (host) =>
|
|
195
|
+
Effect.gen(function* removeThenEnsure() {
|
|
196
|
+
const outcomes = yield* ran(host, "g-1", [
|
|
197
|
+
ensure("box"),
|
|
198
|
+
exec("box", "write", "echo x > left"),
|
|
199
|
+
remove("box"),
|
|
200
|
+
get("box"),
|
|
201
|
+
exec("box", "after", "true"),
|
|
202
|
+
remove("box"),
|
|
203
|
+
list,
|
|
204
|
+
ensure("box"),
|
|
205
|
+
exec("box", "look", "ls"),
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const missing = "ComputerNotFoundError";
|
|
209
|
+
|
|
210
|
+
expect(outcomes).toStrictEqual([
|
|
211
|
+
"up",
|
|
212
|
+
"exit 0: ",
|
|
213
|
+
"removed",
|
|
214
|
+
missing,
|
|
215
|
+
missing,
|
|
216
|
+
"removed",
|
|
217
|
+
"[]",
|
|
218
|
+
"up",
|
|
219
|
+
"exit 0: ",
|
|
220
|
+
]);
|
|
221
|
+
}),
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
/** Each journaled step as `akter steps list` reads it, newest first: its name, its state and its exit code once it ran. */
|
|
225
|
+
const journaled = (host: Host): Effect.Effect<readonly string[], Unexpected> =>
|
|
226
|
+
Effect.map(host.steps({ actor: "Workbench", key: bench }), (steps) =>
|
|
227
|
+
Arr.map(steps, ({ exitCode, name, state }) => {
|
|
228
|
+
const exit = Option.map(Option.fromUndefinedOr(exitCode), String);
|
|
229
|
+
|
|
230
|
+
return Arr.join([name, state, ...Option.toArray(exit)], " ");
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
const stepJournaled = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
235
|
+
onBench(storage, (host) =>
|
|
236
|
+
Effect.gen(function* stepThenRead() {
|
|
237
|
+
const outcomes = yield* ran(host, "j-1", [ensure("box"), step("hello", "echo hi"), step("no", "exit 3")]);
|
|
238
|
+
|
|
239
|
+
const steps = yield* host.steps({ actor: "Workbench", key: bench });
|
|
240
|
+
const hello = Arr.filter(steps, ({ name }) => name === "hello");
|
|
241
|
+
|
|
242
|
+
const read = yield* Effect.forEach(hello, ({ step: id }) =>
|
|
243
|
+
host.step({ actor: "Workbench", key: bench, step: id }),
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
const missing = yield* host.step({ actor: "Workbench", key: bench, step: "no-such-step" });
|
|
247
|
+
const journal = yield* journaled(host);
|
|
248
|
+
|
|
249
|
+
expect({
|
|
250
|
+
journal,
|
|
251
|
+
missing: Option.isNone(missing),
|
|
252
|
+
outcomes,
|
|
253
|
+
output: Arr.map(Arr.getSomes(read), ({ computer, stdout }) => [computer, stdout]),
|
|
254
|
+
quiet: Arr.every(steps, ({ stdout }) => Predicate.isUndefined(stdout)),
|
|
255
|
+
}).toStrictEqual({
|
|
256
|
+
journal: ["no ran 3", "hello ran 0"],
|
|
257
|
+
missing: true,
|
|
258
|
+
outcomes: ["up", "exit 0: hi\n", "exit 3: "],
|
|
259
|
+
output: [["box", "hi\n"]],
|
|
260
|
+
quiet: true,
|
|
261
|
+
});
|
|
262
|
+
}),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
/** How long a cut-off guarantee waits for the step to be journaled before the engine under it stops. */
|
|
266
|
+
const journalPolls = 200;
|
|
267
|
+
|
|
268
|
+
const pollEvery = "100 millis";
|
|
269
|
+
|
|
270
|
+
/** How long it waits for the replayed job to settle the step: past the step's limit and grace, with room under load. */
|
|
271
|
+
const settlePolls = 400;
|
|
272
|
+
|
|
273
|
+
/** A step that counts its runs, then outlives the moment the engine stops under it, within its 3 s limit. */
|
|
274
|
+
const slow = "echo ran >> runs; sleep 2";
|
|
275
|
+
|
|
276
|
+
const slowLimit = 3;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Starts a job whose durable step is cut off by the engine stopping once the step is journaled, then restarts it.
|
|
280
|
+
* Wherever the stop fell, the step ran at most once and its job settles it: with the outcome the computer recorded
|
|
281
|
+
* when the command finished before the stop reached it, or `unknown` once its limit has passed without one.
|
|
282
|
+
*/
|
|
283
|
+
const stepSurvivesRestart = <Failure>(storage: Storage<Failure>): Checked<Failure> =>
|
|
284
|
+
Effect.gen(function* cutThenReplay() {
|
|
285
|
+
const job = yield* onBench(storage, (host) =>
|
|
286
|
+
Effect.gen(function* startThenStop() {
|
|
287
|
+
const started = yield* startedRun(host, {
|
|
288
|
+
commandId: "c-1",
|
|
289
|
+
key: bench,
|
|
290
|
+
ops: [ensure("box"), step("slow", slow, slowLimit)],
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
yield* Effect.repeat(journaled(host), {
|
|
294
|
+
schedule: Schedule.spaced(pollEvery),
|
|
295
|
+
times: journalPolls,
|
|
296
|
+
until: Arr.isReadonlyArrayNonEmpty,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
return started;
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const settled = yield* onBench(storage, (host) =>
|
|
304
|
+
Effect.gen(function* replayThenCount() {
|
|
305
|
+
// The engine reopens its actors and resumes their jobs in the background, so a drain can run before the job is back.
|
|
306
|
+
const outcomes = yield* Effect.repeat(outcomesOf(host, bench, job), {
|
|
307
|
+
schedule: Schedule.spaced(pollEvery),
|
|
308
|
+
times: settlePolls,
|
|
309
|
+
until: (found) => !Arr.contains(found, unfinished),
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const counted = yield* ran(host, "c-2", [exec("box", "count", "grep -c ran runs")]);
|
|
313
|
+
const journal = yield* journaled(host);
|
|
314
|
+
|
|
315
|
+
return { counted, journal, outcomes };
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const recorded = { journal: ["slow ran 0"], outcomes: ["up", "exit 0: "] };
|
|
320
|
+
const unknown = { journal: ["slow unknown"], outcomes: ["up", "unknown"] };
|
|
321
|
+
const { counted, ...settledAs } = settled;
|
|
322
|
+
|
|
323
|
+
expect(counted).toStrictEqual(["exit 0: 1\n"]);
|
|
324
|
+
expect([recorded, unknown]).toContainEqual(settledAs);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
type ComputerGuaranteeName = keyof typeof computerGuaranteeTitles;
|
|
328
|
+
|
|
329
|
+
type Guarantee = <Failure>(storage: Storage<Failure>) => Checked<Failure>;
|
|
330
|
+
|
|
331
|
+
/** The computer guarantees, by name; each runs the Workbench on a fresh store. */
|
|
332
|
+
const computerGuarantees = {
|
|
333
|
+
computerHeldStaysPaused: heldStaysPaused,
|
|
334
|
+
computerKeepsFiles: keepsFiles,
|
|
335
|
+
computerLimitHolds: limitHolds,
|
|
336
|
+
computerRemovedIsGone: removedIsGone,
|
|
337
|
+
computerStepJournaled: stepJournaled,
|
|
338
|
+
computerStepRunsOnce: stepRunsOnce,
|
|
339
|
+
computerStepSurvivesRestart: stepSurvivesRestart,
|
|
340
|
+
} as const satisfies Record<ComputerGuaranteeName, Guarantee>;
|
|
341
|
+
|
|
342
|
+
const everyComputerGuarantee: readonly ComputerGuaranteeName[] = Rec.keys(computerGuarantees);
|
|
343
|
+
|
|
344
|
+
interface ComputerSkipping<Failure, Services> {
|
|
345
|
+
/** An engine that serves `Workbench/bench-1`, with its computers bound to the job runtime's `Actor.computer(key)`. */
|
|
346
|
+
readonly engine: Engine<Failure, Services>;
|
|
347
|
+
readonly skip: Readonly<Partial<Record<ComputerGuaranteeName, string>>>;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Registers every computer guarantee against `engine`, each on fresh storage, skipping only those `skip` names with a
|
|
352
|
+
* reason. A guarantee added later runs on every engine that registers the suite.
|
|
353
|
+
*/
|
|
354
|
+
const computerConformanceOf =
|
|
355
|
+
<Failure, Services>({
|
|
356
|
+
engine,
|
|
357
|
+
skip,
|
|
358
|
+
}: ComputerSkipping<Failure, Services>): ((test: Vitest.MethodsNonLive<Services>) => void) =>
|
|
359
|
+
(test) => {
|
|
360
|
+
for (const name of everyComputerGuarantee) {
|
|
361
|
+
const title = computerGuaranteeTitles[name];
|
|
362
|
+
const guarantee: Guarantee = computerGuarantees[name];
|
|
363
|
+
|
|
364
|
+
const run = (): Effect.Effect<void, Failure | Unexpected, Services> =>
|
|
365
|
+
Effect.scoped(Effect.flatMap(engine, (storage) => guarantee(storage)));
|
|
366
|
+
|
|
367
|
+
Option.match(Option.fromNullishOr(skip[name]), {
|
|
368
|
+
onNone: () => {
|
|
369
|
+
test.effect(title, run);
|
|
370
|
+
},
|
|
371
|
+
onSome: (reason) => {
|
|
372
|
+
test.effect.skip(`${title} (skipped: ${reason})`, run);
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
export { computerConformanceOf };
|
|
379
|
+
|
|
380
|
+
export type { ComputerGuaranteeName };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { Option, Record as Rec, Schema, Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import { ada, nothing } from "#src/ledger/ledger-calls.pure.js";
|
|
4
|
+
import type { Host, Unexpected } from "#src/ledger/ledger-calls.pure.js";
|
|
5
|
+
import { decodeReply } from "#src/ledger/ledger-checks.pure.js";
|
|
6
|
+
import { ComputerOp, Workbench } from "./workbench.contract.js";
|
|
7
|
+
|
|
8
|
+
/** The Workbench the computer guarantees run on, whose key every engine under test serves. */
|
|
9
|
+
const bench = "bench-1";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The key a fork of `bench` goes into. Akter Cloud forks only into a key on the parent's own shard, and `bench-250`
|
|
13
|
+
* hashes to `bench-1`'s shard there.
|
|
14
|
+
*/
|
|
15
|
+
const forkedBench = "bench-250";
|
|
16
|
+
|
|
17
|
+
const encodeOps = Schema.encodeEffect(Schema.fromJsonString(Workbench.commands.run.payload));
|
|
18
|
+
|
|
19
|
+
const { Ensure, Exec, Get, List, Remove, Stop } = ComputerOp.cases;
|
|
20
|
+
|
|
21
|
+
const ensure = (computer: string): ComputerOp => Ensure.make({ computer });
|
|
22
|
+
|
|
23
|
+
const get = (computer: string): ComputerOp => Get.make({ computer });
|
|
24
|
+
|
|
25
|
+
const remove = (computer: string): ComputerOp => Remove.make({ computer });
|
|
26
|
+
|
|
27
|
+
const stop = (computer: string): ComputerOp => Stop.make({ computer });
|
|
28
|
+
|
|
29
|
+
const list: ComputerOp = List.make({});
|
|
30
|
+
|
|
31
|
+
/** Starts the calls in one `run` job on the Workbench at `key` and answers the job's id. */
|
|
32
|
+
const startedRun = (
|
|
33
|
+
host: Host,
|
|
34
|
+
{ commandId, key, ops }: { readonly commandId: string; readonly key: string; readonly ops: readonly ComputerOp[] },
|
|
35
|
+
): Effect.Effect<string, Unexpected> =>
|
|
36
|
+
Effect.gen(function* startRun() {
|
|
37
|
+
const payload = yield* encodeOps({ ops });
|
|
38
|
+
|
|
39
|
+
const sent = yield* host.command({
|
|
40
|
+
actor: "Workbench",
|
|
41
|
+
commandId,
|
|
42
|
+
key,
|
|
43
|
+
member: "run",
|
|
44
|
+
payload,
|
|
45
|
+
principal: ada,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
return yield* Effect.flatten(decodeReply(Workbench.commands.run, sent));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const unfinished = "the job had not finished";
|
|
52
|
+
|
|
53
|
+
/** What each call of a job answered, once its result has reached the actor. */
|
|
54
|
+
const outcomesOf = (host: Host, key: string, job: string): Effect.Effect<readonly string[], Unexpected> =>
|
|
55
|
+
Effect.gen(function* readOutcomes() {
|
|
56
|
+
yield* host.drain;
|
|
57
|
+
|
|
58
|
+
const reply = yield* host.query({
|
|
59
|
+
actor: "Workbench",
|
|
60
|
+
key,
|
|
61
|
+
member: "runs",
|
|
62
|
+
payload: nothing,
|
|
63
|
+
principal: ada,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const runs = yield* Effect.flatten(decodeReply(Workbench.queries.runs, reply));
|
|
67
|
+
|
|
68
|
+
return Option.getOrElse(Rec.get(runs, job), () => [unfinished]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Runs the calls in one `run` job on the Workbench at `key` and answers what each returned, once the job's result has
|
|
73
|
+
* reached the actor. A call is one activity, so `run` with the same command id after a restart answers the same job.
|
|
74
|
+
*/
|
|
75
|
+
const ranAt = (
|
|
76
|
+
host: Host,
|
|
77
|
+
key: string,
|
|
78
|
+
{ commandId, ops }: { readonly commandId: string; readonly ops: readonly ComputerOp[] },
|
|
79
|
+
): Effect.Effect<readonly string[], Unexpected> =>
|
|
80
|
+
Effect.flatMap(startedRun(host, { commandId, key, ops }), (job) => outcomesOf(host, key, job));
|
|
81
|
+
|
|
82
|
+
/** Runs the calls in one `run` job on the suite's Workbench, `bench`, as `ranAt` does. */
|
|
83
|
+
const ran = (host: Host, commandId: string, ops: readonly ComputerOp[]): Effect.Effect<readonly string[], Unexpected> =>
|
|
84
|
+
ranAt(host, bench, { commandId, ops });
|
|
85
|
+
|
|
86
|
+
/** A step on `computer` under `key`, outside the job's journal. */
|
|
87
|
+
const exec = (computer: string, key: string, command: string): ComputerOp => Exec.make({ command, computer, key });
|
|
88
|
+
|
|
89
|
+
/** The calls, as one namespace: each takes the host first, and `exec`, which builds a step to run. */
|
|
90
|
+
const workbenchHost = { exec, outcomesOf, ran, ranAt, startedRun };
|
|
91
|
+
|
|
92
|
+
export { bench, ensure, forkedBench, get, list, remove, stop, unfinished, workbenchHost };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { Akter as Engine } from "@akterjs/effect";
|
|
2
|
+
import { Akter } from "@akterjs/effect/contract";
|
|
3
|
+
import { ComputerName, ExecRan } from "@akterjs/effect/contract/computer";
|
|
4
|
+
import type { Computer, ComputerSpec, ExecResult } from "@akterjs/effect/contract/computer";
|
|
5
|
+
import type { ActorComputerClients } from "@akterjs/effect/contract/computer-clients";
|
|
6
|
+
import { ComputerLimitError } from "@akterjs/effect/contract/errors";
|
|
7
|
+
import {
|
|
8
|
+
Array as Arr,
|
|
9
|
+
Cause,
|
|
10
|
+
Effect,
|
|
11
|
+
Exit,
|
|
12
|
+
Layer,
|
|
13
|
+
Option,
|
|
14
|
+
Predicate,
|
|
15
|
+
Record as Rec,
|
|
16
|
+
Schema,
|
|
17
|
+
String as Str,
|
|
18
|
+
} from "effect";
|
|
19
|
+
import { Activity } from "effect/workflow";
|
|
20
|
+
import type { WorkflowEngine } from "effect/workflow";
|
|
21
|
+
|
|
22
|
+
import { ComputerOp, Workbench } from "./workbench.contract.js";
|
|
23
|
+
|
|
24
|
+
type ComputerClient = ReturnType<ActorComputerClients["Service"]["connect"]>;
|
|
25
|
+
|
|
26
|
+
/** Every failure a computer call can answer. */
|
|
27
|
+
type CallFailure = Effect.Error<ReturnType<ComputerClient["ensure"]>>;
|
|
28
|
+
|
|
29
|
+
/** Every failure reaching the computers or calling them can answer. */
|
|
30
|
+
type Failed = CallFailure | Effect.Error<ReturnType<typeof Workbench.computer>>;
|
|
31
|
+
|
|
32
|
+
const memoryMib = 1024;
|
|
33
|
+
|
|
34
|
+
/** Every failure a computer call can answer, by tag. */
|
|
35
|
+
const failures = [
|
|
36
|
+
"ActorUnavailableError",
|
|
37
|
+
"CallerNotAllowedError",
|
|
38
|
+
"ComputerHeldError",
|
|
39
|
+
"ComputerLostError",
|
|
40
|
+
"ComputerNotFoundError",
|
|
41
|
+
"ComputersUnavailableError",
|
|
42
|
+
"InvalidRequestError",
|
|
43
|
+
"SecretUnavailableError",
|
|
44
|
+
"UnauthenticatedError",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
const isLimit = Schema.is(ComputerLimitError);
|
|
48
|
+
|
|
49
|
+
const isRan = Schema.is(ExecRan);
|
|
50
|
+
|
|
51
|
+
const specOf = (computer: string): ComputerSpec => ({
|
|
52
|
+
egress: [],
|
|
53
|
+
image: "node:24-bookworm",
|
|
54
|
+
name: ComputerName.make(computer),
|
|
55
|
+
size: { memoryMib, vcpu: 1 },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const stepOf = (result: ExecResult): string => {
|
|
59
|
+
if (!isRan(result)) {
|
|
60
|
+
return "unknown";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const exit = Option.getOrElse(Option.map(Option.fromUndefinedOr(result.exitCode), String), () => "none");
|
|
64
|
+
|
|
65
|
+
return `exit ${exit}: ${result.stdout}`;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** An engine may answer before the machine is up, so `starting` and `running` read the same: `up`. */
|
|
69
|
+
const upStates: readonly Computer["state"][] = ["running", "starting"];
|
|
70
|
+
|
|
71
|
+
const stateOf = ({ state }: Computer): string => {
|
|
72
|
+
if (Arr.contains(upStates, state)) {
|
|
73
|
+
return "up";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return state;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const holdOf = (hold: string | undefined): { readonly hold?: string } =>
|
|
80
|
+
Rec.getSomes({ hold: Option.fromUndefinedOr(hold) });
|
|
81
|
+
|
|
82
|
+
const namesOf = (computers: readonly Computer[]): string =>
|
|
83
|
+
`[${Arr.join(
|
|
84
|
+
Arr.sort(
|
|
85
|
+
Arr.map(computers, ({ name }) => name),
|
|
86
|
+
Str.Order,
|
|
87
|
+
),
|
|
88
|
+
",",
|
|
89
|
+
)}]`;
|
|
90
|
+
|
|
91
|
+
/** What one call answered, as the suite compares it: a state, a step's exit and output, names, or `removed`. */
|
|
92
|
+
const called = (client: ComputerClient, op: ComputerOp): Effect.Effect<string, CallFailure> =>
|
|
93
|
+
ComputerOp.match(op, {
|
|
94
|
+
Ensure: ({ computer }) => Effect.map(client.ensure(specOf(computer)), stateOf),
|
|
95
|
+
Exec: ({ command, computer, key }) => Effect.map(client.exec(computer, { command, key }), stepOf),
|
|
96
|
+
Get: ({ computer }) => Effect.map(client.get(computer), stateOf),
|
|
97
|
+
List: () => Effect.map(client.list, namesOf),
|
|
98
|
+
Pause: ({ computer, hold }) => Effect.map(client.pause(computer, holdOf(hold)), stateOf),
|
|
99
|
+
Remove: ({ computer }) => Effect.as(client.remove(computer), "removed"),
|
|
100
|
+
Resume: ({ computer, hold }) => Effect.map(client.resume(computer, holdOf(hold)), stateOf),
|
|
101
|
+
Step: () => Effect.die("a durable step runs in the job, not in an activity"),
|
|
102
|
+
Stop: ({ computer }) => Effect.map(client.stop(computer), stateOf),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** A failure by its tag; a limit with the limit and its maximum, which the suite checks against what was made. */
|
|
106
|
+
const failureOf = (failure: Failed): string => {
|
|
107
|
+
if (isLimit(failure)) {
|
|
108
|
+
return `ComputerLimitError ${failure.limit} ${String(failure.maximum)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Option.getOrElse(
|
|
112
|
+
Arr.findFirst(failures, (tag) => Predicate.isTagged(failure, tag)),
|
|
113
|
+
() => "unexpected failure",
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const attempted = (key: string, op: ComputerOp): Effect.Effect<string, Failed> =>
|
|
118
|
+
Effect.flatMap(Workbench.computer(key), (client) => called(client, op));
|
|
119
|
+
|
|
120
|
+
const answered = (key: string, op: ComputerOp): Effect.Effect<string> =>
|
|
121
|
+
Effect.match(attempted(key, op), { onFailure: failureOf, onSuccess: (outcome) => outcome });
|
|
122
|
+
|
|
123
|
+
type StepOp = typeof ComputerOp.cases.Step.Type;
|
|
124
|
+
|
|
125
|
+
const isStep = Schema.is(ComputerOp.cases.Step);
|
|
126
|
+
|
|
127
|
+
const timeoutOf = (timeoutSeconds: number | undefined): { readonly timeoutSeconds?: number } =>
|
|
128
|
+
Rec.getSomes({ timeoutSeconds: Option.fromUndefinedOr(timeoutSeconds) });
|
|
129
|
+
|
|
130
|
+
/** A durable step, made by the job itself: it journals its own start and outcome. */
|
|
131
|
+
const stepped = (
|
|
132
|
+
key: string,
|
|
133
|
+
{ command, computer, name, timeoutSeconds }: StepOp,
|
|
134
|
+
): Effect.Effect<string, never, WorkflowEngine.WorkflowEngine | WorkflowEngine.WorkflowInstance> =>
|
|
135
|
+
Effect.match(
|
|
136
|
+
Effect.flatMap(Workbench.computer(key), (computers) =>
|
|
137
|
+
computers.step(computer, { command, name, ...timeoutOf(timeoutSeconds) }),
|
|
138
|
+
),
|
|
139
|
+
{ onFailure: failureOf, onSuccess: stepOf },
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Makes each call in its own activity, so a job resumed after a restart reads back the calls it already made, and each
|
|
144
|
+
* `Step` as a durable step of the job.
|
|
145
|
+
*/
|
|
146
|
+
const RunLive = Workbench.jobs.run.toLayer(({ key, ops }) =>
|
|
147
|
+
Effect.forEach(ops, (op, index) => {
|
|
148
|
+
if (isStep(op)) {
|
|
149
|
+
return stepped(key, op);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return Activity.make({ execute: answered(key, op), name: `call-${String(index)}`, success: Schema.String });
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const outcomesOf = (result: Exit.Exit<readonly string[]>): readonly string[] =>
|
|
157
|
+
Exit.match(result, {
|
|
158
|
+
onFailure: (cause) => [`job failed: ${Cause.pretty(cause)}`],
|
|
159
|
+
onSuccess: (outcomes) => outcomes,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const WorkbenchHandlers = Workbench.toLayer({
|
|
163
|
+
commands: {
|
|
164
|
+
run: (_bench, { ops }) => Effect.flatMap(Akter.actorKey, (key) => Akter.start(Workbench.jobs.run, { key, ops })),
|
|
165
|
+
},
|
|
166
|
+
queries: { runs: (bench) => Effect.succeed(bench.runs) },
|
|
167
|
+
results: {
|
|
168
|
+
run: (bench, result, job) => Workbench.update({ runs: Rec.set(bench.runs, job.id, outcomesOf(result)) }),
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** The Workbench and its job on the engine's job runtime, whose activities reach the computers the engine bound. */
|
|
173
|
+
const WorkbenchLive = Layer.merge(WorkbenchHandlers, RunLive).pipe(Layer.provide(Engine.jobs));
|
|
174
|
+
|
|
175
|
+
export { WorkbenchLive };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Akter } from "@akterjs/effect/contract";
|
|
2
|
+
import { Schema } from "effect";
|
|
3
|
+
|
|
4
|
+
const Named = { computer: Schema.String };
|
|
5
|
+
|
|
6
|
+
const Held = { ...Named, hold: Schema.optionalKey(Schema.String) };
|
|
7
|
+
|
|
8
|
+
/** One call a `run` job makes to the actor's computers, in order. */
|
|
9
|
+
const ComputerOp = Schema.TaggedUnion({
|
|
10
|
+
Ensure: Named,
|
|
11
|
+
Exec: { ...Named, command: Schema.String, key: Schema.String },
|
|
12
|
+
Get: Named,
|
|
13
|
+
List: {},
|
|
14
|
+
Pause: Held,
|
|
15
|
+
Remove: Named,
|
|
16
|
+
Resume: Held,
|
|
17
|
+
Step: { ...Named, command: Schema.String, name: Schema.String, timeoutSeconds: Schema.optionalKey(Schema.Int) },
|
|
18
|
+
Stop: Named,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type ComputerOp = typeof ComputerOp.Type;
|
|
22
|
+
|
|
23
|
+
/** Each finished `run` job's outcomes, one per call, by the job's id. */
|
|
24
|
+
const Runs = Schema.Record(Schema.String, Schema.Array(Schema.String));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The suite's actor for computer guarantees. `run` starts a job that makes its calls to the actor's computers, each
|
|
28
|
+
* in an activity or, for a `Step`, as a durable step of the job, and records what each answered; `runs` reads those
|
|
29
|
+
* records back.
|
|
30
|
+
*/
|
|
31
|
+
const Workbench = Akter.make("Workbench", {
|
|
32
|
+
commands: {
|
|
33
|
+
run: Akter.command({ payload: { ops: Schema.Array(ComputerOp) }, success: Schema.String }),
|
|
34
|
+
},
|
|
35
|
+
initial: () => ({ runs: {} }),
|
|
36
|
+
jobs: {
|
|
37
|
+
run: Akter.job({
|
|
38
|
+
payload: { key: Schema.String, ops: Schema.Array(ComputerOp) },
|
|
39
|
+
success: Schema.Array(Schema.String),
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
key: Schema.String,
|
|
43
|
+
queries: { runs: Akter.query({ success: Runs }) },
|
|
44
|
+
state: Schema.Struct({ runs: Runs }),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export { ComputerOp, Workbench };
|