@cotal-ai/lang 0.0.0 → 0.15.0
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 +202 -0
- package/dist/dryrun.d.ts +106 -0
- package/dist/dryrun.d.ts.map +1 -0
- package/dist/dryrun.js +172 -0
- package/dist/dryrun.js.map +1 -0
- package/dist/duration.d.ts +12 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +34 -0
- package/dist/duration.js.map +1 -0
- package/dist/effects.d.ts +231 -0
- package/dist/effects.d.ts.map +1 -0
- package/dist/effects.js +64 -0
- package/dist/effects.js.map +1 -0
- package/dist/errors.d.ts +141 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +163 -0
- package/dist/errors.js.map +1 -0
- package/dist/grammar.d.ts +28 -0
- package/dist/grammar.d.ts.map +1 -0
- package/dist/grammar.js +793 -0
- package/dist/grammar.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +23 -0
- package/dist/index.js.map +1 -0
- package/dist/interpret.d.ts +89 -0
- package/dist/interpret.d.ts.map +1 -0
- package/dist/interpret.js +1117 -0
- package/dist/interpret.js.map +1 -0
- package/dist/journal.d.ts +177 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +198 -0
- package/dist/journal.js.map +1 -0
- package/dist/keys.d.ts +87 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +105 -0
- package/dist/keys.js.map +1 -0
- package/dist/primitives.d.ts +84 -0
- package/dist/primitives.d.ts.map +1 -0
- package/dist/primitives.js +265 -0
- package/dist/primitives.js.map +1 -0
- package/dist/sim.d.ts +101 -0
- package/dist/sim.d.ts.map +1 -0
- package/dist/sim.js +192 -0
- package/dist/sim.js.map +1 -0
- package/dist/values.d.ts +35 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +0 -0
- package/dist/values.js.map +1 -0
- package/package.json +27 -7
- package/README.md +0 -10
|
@@ -0,0 +1,1117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interpreter: an AST walk over a validated program.
|
|
3
|
+
*
|
|
4
|
+
* Two properties make this worth reading closely, because everything else in the durability story
|
|
5
|
+
* rests on them:
|
|
6
|
+
*
|
|
7
|
+
* 1. **The interpreter owns the journal; the handler never touches it.** Every effect goes through
|
|
8
|
+
* {@link Interpreter.performEffect}, which allocates the key, consults the journal, and either
|
|
9
|
+
* replays a recorded result or performs the effect live and records it. That is why simulation
|
|
10
|
+
* and production cannot drift apart on durability: neither handler is in a position to.
|
|
11
|
+
* 2. **Resume is re-running from the top.** There is no cursor and no fast-forward. Journalled
|
|
12
|
+
* effects return their recorded results, so the deterministic prefix reproduces itself and
|
|
13
|
+
* out-of-order concurrency replays correctly. This is the same thing as an effect handler's
|
|
14
|
+
* resume(), implemented by re-running the pure prefix, which is why no continuation is ever
|
|
15
|
+
* serialized.
|
|
16
|
+
*/
|
|
17
|
+
import { validate } from "./grammar.js";
|
|
18
|
+
import { LangError, LangErrors } from "./errors.js";
|
|
19
|
+
import { KeyScope, digest, requestId, scopePathString, stepKeyString } from "./keys.js";
|
|
20
|
+
import { Journal, RunClock } from "./journal.js";
|
|
21
|
+
import { Prng, assertCrossable, deepFreeze } from "./values.js";
|
|
22
|
+
import { parseDuration } from "./duration.js";
|
|
23
|
+
import { PRIMITIVES } from "./primitives.js";
|
|
24
|
+
import { Cancelled, EffectError, applyCheckpointPolicy, } from "./effects.js";
|
|
25
|
+
// ---- environments ------------------------------------------------------------------------------
|
|
26
|
+
class Binding {
|
|
27
|
+
value;
|
|
28
|
+
mutable;
|
|
29
|
+
constructor(value, mutable) {
|
|
30
|
+
this.value = value;
|
|
31
|
+
this.mutable = mutable;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
class Env {
|
|
35
|
+
parent;
|
|
36
|
+
names = new Map();
|
|
37
|
+
constructor(parent) {
|
|
38
|
+
this.parent = parent;
|
|
39
|
+
}
|
|
40
|
+
declare(name, value, mutable) {
|
|
41
|
+
this.names.set(name, new Binding(value, mutable));
|
|
42
|
+
}
|
|
43
|
+
find(name) {
|
|
44
|
+
for (let e = this; e !== null; e = e.parent) {
|
|
45
|
+
const b = e.names.get(name);
|
|
46
|
+
if (b !== undefined)
|
|
47
|
+
return b;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
get(name) {
|
|
52
|
+
const b = this.find(name);
|
|
53
|
+
if (b === undefined)
|
|
54
|
+
throw new RuntimeFault("L2001", `${name} is not defined`);
|
|
55
|
+
return b.value;
|
|
56
|
+
}
|
|
57
|
+
has(name) {
|
|
58
|
+
return this.find(name) !== undefined;
|
|
59
|
+
}
|
|
60
|
+
set(name, value) {
|
|
61
|
+
const b = this.find(name);
|
|
62
|
+
if (b === undefined)
|
|
63
|
+
throw new RuntimeFault("L2001", `${name} is not defined`);
|
|
64
|
+
if (!b.mutable)
|
|
65
|
+
throw new RuntimeFault("L2003", `${name} is declared const`);
|
|
66
|
+
b.value = value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** A fault the interpreter itself raises, as opposed to one an effect handler reported. */
|
|
70
|
+
export class RuntimeFault extends Error {
|
|
71
|
+
code;
|
|
72
|
+
constructor(code, message) {
|
|
73
|
+
super(`${code} ${message}`);
|
|
74
|
+
this.code = code;
|
|
75
|
+
this.name = "RuntimeFault";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const NORMAL = { type: "normal" };
|
|
79
|
+
// ---- per-branch execution state ---------------------------------------------------------------------
|
|
80
|
+
class Signal {
|
|
81
|
+
cancelled = false;
|
|
82
|
+
reason;
|
|
83
|
+
listeners = [];
|
|
84
|
+
onCancel(fn) {
|
|
85
|
+
this.listeners.push(fn);
|
|
86
|
+
}
|
|
87
|
+
cancel(reason) {
|
|
88
|
+
if (this.cancelled)
|
|
89
|
+
return;
|
|
90
|
+
this.cancelled = true;
|
|
91
|
+
this.reason = reason;
|
|
92
|
+
for (const l of this.listeners)
|
|
93
|
+
l(reason);
|
|
94
|
+
}
|
|
95
|
+
child() {
|
|
96
|
+
const s = new Signal();
|
|
97
|
+
if (this.cancelled)
|
|
98
|
+
s.cancel(this.reason ?? "parent cancelled");
|
|
99
|
+
else
|
|
100
|
+
this.onCancel((r) => s.cancel(r));
|
|
101
|
+
return s;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* One branch of execution: its own key namespace, its own clock, its own cancellation signal.
|
|
106
|
+
*
|
|
107
|
+
* The key namespace is the reason concurrency is safe here. Two branches calling the same named
|
|
108
|
+
* effect cannot race for an occurrence counter, because they do not share one.
|
|
109
|
+
*/
|
|
110
|
+
class Frame {
|
|
111
|
+
keys;
|
|
112
|
+
clock;
|
|
113
|
+
signal;
|
|
114
|
+
constructor(keys, clock, signal) {
|
|
115
|
+
this.keys = keys;
|
|
116
|
+
this.clock = clock;
|
|
117
|
+
this.signal = signal;
|
|
118
|
+
}
|
|
119
|
+
branch(kind, name, occurrence, branchKey) {
|
|
120
|
+
return new Frame(this.keys.branch(kind, name, occurrence, branchKey), this.clock.fork(), this.signal.child());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** A recorded step's inputs changed, so its recorded result may no longer be the truth. */
|
|
124
|
+
export class RunDivergence extends Error {
|
|
125
|
+
stepKey;
|
|
126
|
+
recordedHash;
|
|
127
|
+
programHash;
|
|
128
|
+
constructor(stepKey, recordedHash, programHash) {
|
|
129
|
+
super(`L5001 Run divergence\n\n step ${stepKey} INPUT CHANGED\n recorded ${recordedHash}\n program ${programHash}\n\nThe recorded result was produced from different inputs, so replaying it would hand the program an answer to a question it is no longer asking.\n\nOptions\n fork(run, "${stepKey}") re-run from this step, keeping everything before it\n revert the inputs keep the recorded result`);
|
|
130
|
+
this.stepKey = stepKey;
|
|
131
|
+
this.recordedHash = recordedHash;
|
|
132
|
+
this.programHash = programHash;
|
|
133
|
+
this.name = "RunDivergence";
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
class Interpreter {
|
|
137
|
+
ast;
|
|
138
|
+
options;
|
|
139
|
+
programHash;
|
|
140
|
+
journal;
|
|
141
|
+
prng;
|
|
142
|
+
effectCount = 0;
|
|
143
|
+
ceiling;
|
|
144
|
+
steps = 0;
|
|
145
|
+
nextYield;
|
|
146
|
+
stepBudget;
|
|
147
|
+
yieldEvery;
|
|
148
|
+
constructor(ast, options, programHash) {
|
|
149
|
+
this.ast = ast;
|
|
150
|
+
this.options = options;
|
|
151
|
+
this.programHash = programHash;
|
|
152
|
+
this.journal = options.journal ?? new Journal({ run: options.runId });
|
|
153
|
+
this.prng = new Prng(options.seed ?? options.runId);
|
|
154
|
+
this.ceiling = options.effectCeiling ?? 10_000;
|
|
155
|
+
this.stepBudget = options.stepBudget ?? 1_000_000;
|
|
156
|
+
this.yieldEvery = options.yieldEvery ?? 1_024;
|
|
157
|
+
this.nextYield = this.yieldEvery;
|
|
158
|
+
}
|
|
159
|
+
// ---- the fuel ceiling -----------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* Charge one walker dispatch.
|
|
162
|
+
*
|
|
163
|
+
* Returns null on the common path and a promise only when it is time to breathe, so a dispatch
|
|
164
|
+
* normally costs an increment and a compare rather than an allocated promise. Callers await the
|
|
165
|
+
* result only when it is non-null, which is why this is not simply an async method.
|
|
166
|
+
*/
|
|
167
|
+
tick(frame) {
|
|
168
|
+
this.steps += 1;
|
|
169
|
+
if (this.steps > this.stepBudget) {
|
|
170
|
+
throw new RuntimeFault("L4013", `this run has taken more than ${this.stepBudget} interpreter steps without finishing, which means a loop that performs no effect is not terminating. The effect ceiling cannot see such a loop, because it performs nothing to count. Add an exit condition, or raise stepBudget if the program legitimately does this much work.`);
|
|
171
|
+
}
|
|
172
|
+
if (this.steps < this.nextYield)
|
|
173
|
+
return null;
|
|
174
|
+
this.nextYield = this.steps + this.yieldEvery;
|
|
175
|
+
return this.breathe(frame);
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Hand the macrotask queue back, then notice if this branch was cancelled while we were away.
|
|
179
|
+
*
|
|
180
|
+
* The cancellation check is deliberately HERE and not on every dispatch. Cancellation is
|
|
181
|
+
* otherwise observed only at effect boundaries (see {@link Interpreter.performEffect}), so a race
|
|
182
|
+
* loser that spins without performing an effect never learns it lost and spins forever. Checking
|
|
183
|
+
* at the yield boundary reaches exactly that case and no other: a branch that runs fewer than
|
|
184
|
+
* `yieldEvery` dispatches between two effects never crosses this line, so the cancellation law
|
|
185
|
+
* for ordinary programs is unchanged.
|
|
186
|
+
*/
|
|
187
|
+
get stepCount() {
|
|
188
|
+
return this.steps;
|
|
189
|
+
}
|
|
190
|
+
async breathe(frame) {
|
|
191
|
+
await new Promise((resolve) => {
|
|
192
|
+
setTimeout(resolve, 0);
|
|
193
|
+
});
|
|
194
|
+
if (frame.signal.cancelled)
|
|
195
|
+
throw new Cancelled(frame.signal.reason ?? "cancelled");
|
|
196
|
+
}
|
|
197
|
+
// ---- the effect seam ------------------------------------------------------------------------
|
|
198
|
+
/**
|
|
199
|
+
* Perform one effect, or replay it.
|
|
200
|
+
*
|
|
201
|
+
* Everything durable happens here. A handler is called only in the `miss` and `pending` cases,
|
|
202
|
+
* and in `pending` it is told to re-bind rather than re-issue.
|
|
203
|
+
*/
|
|
204
|
+
async performEffect(kind, name, hashedInput, perform, frame) {
|
|
205
|
+
const key = frame.keys.nextEffect(kind, name);
|
|
206
|
+
const inputHash = digest(hashedInput ?? null);
|
|
207
|
+
const verdict = this.journal.lookup(key, inputHash);
|
|
208
|
+
switch (verdict.verdict) {
|
|
209
|
+
case "replay":
|
|
210
|
+
if (verdict.entry.endedAt !== undefined)
|
|
211
|
+
frame.clock.advance(verdict.entry.endedAt);
|
|
212
|
+
return verdict.entry.result;
|
|
213
|
+
case "replay-failed": {
|
|
214
|
+
if (verdict.entry.endedAt !== undefined)
|
|
215
|
+
frame.clock.advance(verdict.entry.endedAt);
|
|
216
|
+
const e = verdict.entry.error;
|
|
217
|
+
throw new EffectError(e.code, e.kind, e.message, e.detail);
|
|
218
|
+
}
|
|
219
|
+
case "replay-cancelled":
|
|
220
|
+
throw new Cancelled("this branch was cancelled on the recorded run");
|
|
221
|
+
case "diverged":
|
|
222
|
+
throw new RunDivergence(stepKeyString(key), verdict.recordedHash, verdict.programHash);
|
|
223
|
+
case "pending":
|
|
224
|
+
case "miss":
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
// A cancelled branch performs no NEW effects. That is the whole of the cancellation law on
|
|
228
|
+
// this side: work already in flight is another matter, and the handler owns it.
|
|
229
|
+
if (frame.signal.cancelled) {
|
|
230
|
+
throw new Cancelled(frame.signal.reason ?? "cancelled");
|
|
231
|
+
}
|
|
232
|
+
this.effectCount += 1;
|
|
233
|
+
if (this.effectCount > this.ceiling) {
|
|
234
|
+
throw new RuntimeFault("L4009", `this run has performed more than ${this.ceiling} effects, which means a loop is not terminating. Add an exit condition or a permit.`);
|
|
235
|
+
}
|
|
236
|
+
const resume = verdict.verdict === "pending" ? verdict.entry.external : undefined;
|
|
237
|
+
// RECOVERY SUBMITS UNDER THE RECORDED IDENTITY. Re-deriving happens to agree whenever nothing
|
|
238
|
+
// moved, which is exactly why it read as correct: the whole point of writing the id down is
|
|
239
|
+
// the case where it does NOT agree, and a resumed run that re-derives is reissuing under an
|
|
240
|
+
// identity the far side may never have seen. An entry with no recorded id predates this rule.
|
|
241
|
+
const recorded = verdict.verdict === "pending" && verdict.entry.requestId !== undefined ? verdict.entry : undefined;
|
|
242
|
+
const reqId = recorded?.requestId ?? requestId(this.options.runId, key, inputHash);
|
|
243
|
+
// WHICH attempt is open, not merely which id. An id alone cannot say how much of an escalation
|
|
244
|
+
// chain is already spent, and a recovery that cannot tell replays the hop: it mints again under
|
|
245
|
+
// the id the far side already holds and reads that mint's cached expiry back as a fresh
|
|
246
|
+
// observation. An entry written before the index existed reads as attempt 0, which is what it
|
|
247
|
+
// is for every effect that never hops.
|
|
248
|
+
const attempt = recorded?.attempt ?? 0;
|
|
249
|
+
if (verdict.verdict === "miss") {
|
|
250
|
+
this.journal.begin(key, inputHash, this.options.handler.now(), reqId);
|
|
251
|
+
}
|
|
252
|
+
const ctx = {
|
|
253
|
+
key,
|
|
254
|
+
signal: frame.signal,
|
|
255
|
+
// Derived from the run, the step, the inputs and the attempt, and written on the pending
|
|
256
|
+
// entry by `begin` above BEFORE the handler runs. A handler submits under it idempotently,
|
|
257
|
+
// so a resumed run reissues the same id rather than creating a second goal.
|
|
258
|
+
requestId: reqId,
|
|
259
|
+
attempt,
|
|
260
|
+
...(resume !== undefined ? { resume } : {}),
|
|
261
|
+
bind: async (external) => {
|
|
262
|
+
this.journal.bind(key, external);
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
try {
|
|
266
|
+
const result = await perform(ctx, inputHash);
|
|
267
|
+
assertCrossable(result, `the result of ${stepKeyString(key)}`);
|
|
268
|
+
const endedAt = this.options.handler.now();
|
|
269
|
+
this.journal.settle(key, { status: "ok", result: deepFreeze(result) }, endedAt);
|
|
270
|
+
frame.clock.advance(endedAt);
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
catch (e) {
|
|
274
|
+
const endedAt = this.options.handler.now();
|
|
275
|
+
if (e instanceof Cancelled) {
|
|
276
|
+
this.journal.settle(key, { status: "cancelled" }, endedAt);
|
|
277
|
+
throw e;
|
|
278
|
+
}
|
|
279
|
+
// A handler may raise a language code directly, and it survives. The simulator's "unscripted
|
|
280
|
+
// effect" is L6001, and flattening that to a generic handler fault would tell a caller acting
|
|
281
|
+
// on `code` that the handler broke, when what actually happened is that their script is
|
|
282
|
+
// incomplete. Only the L-code shape is honoured: anything else a thrown object happens to
|
|
283
|
+
// call `code` (an errno, an HTTP status) is a handler fault and is recorded as one.
|
|
284
|
+
const raised = e.code;
|
|
285
|
+
const carried = typeof raised === "string" && /^L\d{4}$/.test(raised) ? raised : null;
|
|
286
|
+
const error = e instanceof EffectError
|
|
287
|
+
? { code: e.code, kind: e.kind, message: e.message, ...(e.detail !== undefined ? { detail: e.detail } : {}) }
|
|
288
|
+
: carried !== null
|
|
289
|
+
? { code: carried, kind: "handler-fault", message: e.message }
|
|
290
|
+
: { code: "L4000", kind: "handler-fault", message: e.message };
|
|
291
|
+
this.journal.settle(key, { status: "failed", error }, endedAt);
|
|
292
|
+
frame.clock.advance(endedAt);
|
|
293
|
+
throw e instanceof EffectError ? e : new EffectError(error.code, error.kind, error.message);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ---- expressions ------------------------------------------------------------------------------
|
|
297
|
+
async evaluate(node, env, frame) {
|
|
298
|
+
const pause = this.tick(frame);
|
|
299
|
+
if (pause !== null)
|
|
300
|
+
await pause;
|
|
301
|
+
switch (node.type) {
|
|
302
|
+
case "Literal":
|
|
303
|
+
return node.value;
|
|
304
|
+
case "Identifier":
|
|
305
|
+
return env.get(node.name);
|
|
306
|
+
case "TemplateLiteral": {
|
|
307
|
+
const quasis = node.quasis;
|
|
308
|
+
const exprs = node.expressions;
|
|
309
|
+
let out = "";
|
|
310
|
+
for (let i = 0; i < quasis.length; i += 1) {
|
|
311
|
+
out += quasis[i].value.cooked;
|
|
312
|
+
if (i < exprs.length)
|
|
313
|
+
out += String(await this.evaluate(exprs[i], env, frame));
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
case "ArrayExpression": {
|
|
318
|
+
const out = [];
|
|
319
|
+
for (const el of node.elements ?? []) {
|
|
320
|
+
if (el.type === "SpreadElement") {
|
|
321
|
+
const spread = await this.evaluate(el.argument, env, frame);
|
|
322
|
+
out.push(...spread);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
out.push(await this.evaluate(el, env, frame));
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
case "ObjectExpression": {
|
|
331
|
+
const out = {};
|
|
332
|
+
for (const p of node.properties ?? []) {
|
|
333
|
+
if (p.type === "SpreadElement") {
|
|
334
|
+
Object.assign(out, await this.evaluate(p.argument, env, frame));
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
const key = p.key;
|
|
338
|
+
const name = key.type === "Identifier" ? key.name : String(key.value);
|
|
339
|
+
out[name] = await this.evaluate(p.value, env, frame);
|
|
340
|
+
}
|
|
341
|
+
return out;
|
|
342
|
+
}
|
|
343
|
+
case "MemberExpression": {
|
|
344
|
+
const obj = await this.evaluate(node.object, env, frame);
|
|
345
|
+
if (obj === null || obj === undefined) {
|
|
346
|
+
if (node.optional === true)
|
|
347
|
+
return undefined;
|
|
348
|
+
throw new RuntimeFault("L4010", `cannot read a field of ${String(obj)}`);
|
|
349
|
+
}
|
|
350
|
+
const prop = node.computed === true
|
|
351
|
+
? String(await this.evaluate(node.property, env, frame))
|
|
352
|
+
: node.property.name;
|
|
353
|
+
return obj[prop];
|
|
354
|
+
}
|
|
355
|
+
case "UnaryExpression": {
|
|
356
|
+
const v = await this.evaluate(node.argument, env, frame);
|
|
357
|
+
switch (node.operator) {
|
|
358
|
+
case "!":
|
|
359
|
+
return !v;
|
|
360
|
+
case "-":
|
|
361
|
+
return -v;
|
|
362
|
+
case "+":
|
|
363
|
+
return +v;
|
|
364
|
+
case "typeof":
|
|
365
|
+
return typeof v;
|
|
366
|
+
default:
|
|
367
|
+
throw new RuntimeFault("L1000", `unsupported unary operator ${String(node.operator)}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
case "BinaryExpression": {
|
|
371
|
+
const l = await this.evaluate(node.left, env, frame);
|
|
372
|
+
const r = await this.evaluate(node.right, env, frame);
|
|
373
|
+
return applyBinary(node.operator, l, r);
|
|
374
|
+
}
|
|
375
|
+
case "LogicalExpression": {
|
|
376
|
+
const l = await this.evaluate(node.left, env, frame);
|
|
377
|
+
switch (node.operator) {
|
|
378
|
+
case "&&":
|
|
379
|
+
return l ? await this.evaluate(node.right, env, frame) : l;
|
|
380
|
+
case "||":
|
|
381
|
+
return l ? l : await this.evaluate(node.right, env, frame);
|
|
382
|
+
case "??":
|
|
383
|
+
// Orc's `otherwise`, spelled the way JavaScript already spells it: an event that
|
|
384
|
+
// halted without a result resolves null, and this is the recovery path.
|
|
385
|
+
return l === null || l === undefined
|
|
386
|
+
? await this.evaluate(node.right, env, frame)
|
|
387
|
+
: l;
|
|
388
|
+
default:
|
|
389
|
+
throw new RuntimeFault("L1000", `unsupported logical operator ${String(node.operator)}`);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
case "ConditionalExpression":
|
|
393
|
+
return (await this.evaluate(node.test, env, frame))
|
|
394
|
+
? await this.evaluate(node.consequent, env, frame)
|
|
395
|
+
: await this.evaluate(node.alternate, env, frame);
|
|
396
|
+
case "AssignmentExpression": {
|
|
397
|
+
const value = await this.evaluate(node.right, env, frame);
|
|
398
|
+
const left = node.left;
|
|
399
|
+
if (left.type !== "Identifier") {
|
|
400
|
+
throw new RuntimeFault("L2031", "only a plain binding can be assigned to");
|
|
401
|
+
}
|
|
402
|
+
env.set(left.name, value);
|
|
403
|
+
return value;
|
|
404
|
+
}
|
|
405
|
+
case "AwaitExpression":
|
|
406
|
+
return await this.evaluate(node.argument, env, frame);
|
|
407
|
+
case "ArrowFunctionExpression":
|
|
408
|
+
case "FunctionExpression":
|
|
409
|
+
return this.makeFunction(node, env);
|
|
410
|
+
case "CallExpression":
|
|
411
|
+
return await this.call(node, env, frame);
|
|
412
|
+
default:
|
|
413
|
+
throw new RuntimeFault("L1000", `unsupported expression ${node.type}`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
makeFunction(node, closure) {
|
|
417
|
+
const params = node.params ?? [];
|
|
418
|
+
const body = node.body;
|
|
419
|
+
const isExpressionBody = body.type !== "BlockStatement";
|
|
420
|
+
return async (frame, args) => {
|
|
421
|
+
const env = new Env(closure);
|
|
422
|
+
for (let i = 0; i < params.length; i += 1) {
|
|
423
|
+
await this.bindPattern(params[i], args[i], env, frame, true);
|
|
424
|
+
}
|
|
425
|
+
if (isExpressionBody)
|
|
426
|
+
return await this.evaluate(body, env, frame);
|
|
427
|
+
const c = await this.executeBlock(body, env, frame);
|
|
428
|
+
return c.type === "return" ? c.value : undefined;
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
async bindPattern(pattern, value, env, frame, mutable) {
|
|
432
|
+
switch (pattern.type) {
|
|
433
|
+
case "Identifier":
|
|
434
|
+
env.declare(pattern.name, value, mutable);
|
|
435
|
+
return;
|
|
436
|
+
case "AssignmentPattern":
|
|
437
|
+
await this.bindPattern(pattern.left, value === undefined ? await this.evaluate(pattern.right, env, frame) : value, env, frame, mutable);
|
|
438
|
+
return;
|
|
439
|
+
case "ObjectPattern": {
|
|
440
|
+
const src = (value ?? {});
|
|
441
|
+
const taken = [];
|
|
442
|
+
for (const p of pattern.properties) {
|
|
443
|
+
if (p.type === "RestElement") {
|
|
444
|
+
const rest = {};
|
|
445
|
+
for (const [k, v] of Object.entries(src))
|
|
446
|
+
if (!taken.includes(k))
|
|
447
|
+
rest[k] = v;
|
|
448
|
+
await this.bindPattern(p.argument, rest, env, frame, mutable);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const key = p.key;
|
|
452
|
+
const name = key.type === "Identifier" ? key.name : String(key.value);
|
|
453
|
+
taken.push(name);
|
|
454
|
+
await this.bindPattern(p.value, src[name], env, frame, mutable);
|
|
455
|
+
}
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
case "ArrayPattern": {
|
|
459
|
+
const src = (value ?? []);
|
|
460
|
+
const els = pattern.elements;
|
|
461
|
+
for (let i = 0; i < els.length; i += 1) {
|
|
462
|
+
const el = els[i];
|
|
463
|
+
if (el === null || el === undefined)
|
|
464
|
+
continue;
|
|
465
|
+
if (el.type === "RestElement") {
|
|
466
|
+
await this.bindPattern(el.argument, src.slice(i), env, frame, mutable);
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
await this.bindPattern(el, src[i], env, frame, mutable);
|
|
470
|
+
}
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
default:
|
|
474
|
+
throw new RuntimeFault("L1000", `unsupported binding pattern ${pattern.type}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// ---- calls ----------------------------------------------------------------------------------
|
|
478
|
+
async call(node, env, frame) {
|
|
479
|
+
const callee = node.callee;
|
|
480
|
+
const argNodes = node.arguments ?? [];
|
|
481
|
+
// A primitive is dispatched by NAME, not by value. The validator forbids shadowing one, so a
|
|
482
|
+
// call spelled `turn` is always the effect, which is what keeps the flowchart projection and
|
|
483
|
+
// the linter sound.
|
|
484
|
+
if (callee.type === "Identifier" && PRIMITIVES[callee.name] !== undefined && !env.has(callee.name)) {
|
|
485
|
+
return await this.callPrimitive(callee.name, argNodes, env, frame);
|
|
486
|
+
}
|
|
487
|
+
const fn = await this.evaluate(callee, env, frame);
|
|
488
|
+
const args = [];
|
|
489
|
+
for (const a of argNodes) {
|
|
490
|
+
if (a.type === "SpreadElement")
|
|
491
|
+
args.push(...(await this.evaluate(a.argument, env, frame)));
|
|
492
|
+
else
|
|
493
|
+
args.push(await this.evaluate(a, env, frame));
|
|
494
|
+
}
|
|
495
|
+
if (typeof fn !== "function") {
|
|
496
|
+
throw new RuntimeFault("L4011", `this value is not a function, so it cannot be called`);
|
|
497
|
+
}
|
|
498
|
+
return await fn(frame, args);
|
|
499
|
+
}
|
|
500
|
+
option(bag, key) {
|
|
501
|
+
return bag === null || typeof bag !== "object" ? undefined : bag[key];
|
|
502
|
+
}
|
|
503
|
+
async callPrimitive(name, argNodes, env, frame) {
|
|
504
|
+
const spec = PRIMITIVES[name];
|
|
505
|
+
if (spec === undefined)
|
|
506
|
+
throw new RuntimeFault("L2001", `${name} is not a primitive`);
|
|
507
|
+
// Concurrency combinators take their branches unevaluated: the thunks must run inside their
|
|
508
|
+
// own frames, so evaluating them here would defeat the whole point.
|
|
509
|
+
if (spec.opensScope)
|
|
510
|
+
return await this.callScope(name, argNodes, env, frame);
|
|
511
|
+
const args = [];
|
|
512
|
+
for (const a of argNodes)
|
|
513
|
+
args.push(await this.evaluate(a, env, frame));
|
|
514
|
+
const bag = args[spec.optionsAt];
|
|
515
|
+
const stepName = (name === "checkpoint" ? args[0] : this.option(bag, "name"));
|
|
516
|
+
const handler = this.options.handler;
|
|
517
|
+
switch (name) {
|
|
518
|
+
case "spawn": {
|
|
519
|
+
// The first argument is a persona name, or a record carrying the persona WITH its model
|
|
520
|
+
// and variant. Only the persona was ever read, so the object form silently dropped model
|
|
521
|
+
// and variant from both the request and the hash: editing a model did not diverge, and the
|
|
522
|
+
// handler was never told which model to run. This was missed by an audit that exercised
|
|
523
|
+
// only the string form, which is the same defect one level up.
|
|
524
|
+
const spawnSubject = args[0];
|
|
525
|
+
const persona = typeof spawnSubject === "string" ? spawnSubject : String(this.option(spawnSubject, "persona"));
|
|
526
|
+
const model = typeof spawnSubject === "string" ? undefined : this.option(spawnSubject, "model");
|
|
527
|
+
const variant = typeof spawnSubject === "string" ? undefined : this.option(spawnSubject, "variant");
|
|
528
|
+
// Every accepted option is forwarded, including the three that are policy rather than
|
|
529
|
+
// identity. Dropping them here would be silent: the validator accepts `permits`, so an
|
|
530
|
+
// author who writes a budget gets no error and no budget. They are deliberately absent
|
|
531
|
+
// from `hashedOptions` (§5.12) because they decide the INTERPRETATION of a result, not the
|
|
532
|
+
// recorded fact, so they are reapplied from current source on resume rather than hashed.
|
|
533
|
+
const req = {
|
|
534
|
+
persona,
|
|
535
|
+
...(model !== undefined ? { model } : {}),
|
|
536
|
+
...(variant !== undefined ? { variant } : {}),
|
|
537
|
+
...(this.option(bag, "worktree") !== undefined ? { worktree: this.option(bag, "worktree") } : {}),
|
|
538
|
+
...(this.option(bag, "role") !== undefined ? { role: this.option(bag, "role") } : {}),
|
|
539
|
+
...(this.option(bag, "join") !== undefined ? { join: this.option(bag, "join") } : {}),
|
|
540
|
+
...(this.option(bag, "permits") !== undefined
|
|
541
|
+
? { permits: this.option(bag, "permits") }
|
|
542
|
+
: {}),
|
|
543
|
+
...(this.option(bag, "supervise") !== undefined
|
|
544
|
+
? { supervise: this.option(bag, "supervise") }
|
|
545
|
+
: {}),
|
|
546
|
+
...(this.option(bag, "onFork") !== undefined ? { onFork: this.option(bag, "onFork") } : {}),
|
|
547
|
+
};
|
|
548
|
+
return await this.performEffect("spawn", stepName ?? persona,
|
|
549
|
+
// Model and variant are part of the IDENTITY being spawned, so they are hashed with the
|
|
550
|
+
// persona (design 5.12). A run that swapped the model under a recorded agent would be
|
|
551
|
+
// replaying a fact about a different agent.
|
|
552
|
+
{
|
|
553
|
+
persona,
|
|
554
|
+
model: model ?? null,
|
|
555
|
+
variant: variant ?? null,
|
|
556
|
+
worktree: req.worktree ?? null,
|
|
557
|
+
role: req.role ?? null,
|
|
558
|
+
join: (req.join ?? []).map((c) => c.channel),
|
|
559
|
+
}, (ctx) => handler.spawn(req, ctx), frame);
|
|
560
|
+
}
|
|
561
|
+
case "turn": {
|
|
562
|
+
const agent = deepFreeze(args[0]);
|
|
563
|
+
// The deadline STOPS OBSERVATION (design 5.12), so it belongs in the projection: a turn
|
|
564
|
+
// recorded under a 1m deadline cannot answer what a 10m turn would have produced, and a
|
|
565
|
+
// resumed run under the edited deadline replaying the old result is the silent-wrong-path
|
|
566
|
+
// class. Closing that for `checkpoint` and leaving it open on the siblings closed nothing.
|
|
567
|
+
const deadline = this.option(bag, "deadline");
|
|
568
|
+
return await this.performEffect("turn", stepName, { agent: agent.agent, deadline: deadline ?? null }, (ctx) => handler.turn({ agent, ...(deadline !== undefined ? { deadline } : {}) }, ctx), frame);
|
|
569
|
+
}
|
|
570
|
+
case "ask": {
|
|
571
|
+
const agent = deepFreeze(args[0]);
|
|
572
|
+
const schema = this.option(bag, "schema");
|
|
573
|
+
// Both of these END THE ASKING: `deadline` is the cutoff and `attempts` is how many
|
|
574
|
+
// schema-failed replies are tolerated before it gives up. A record made under one attempt
|
|
575
|
+
// is not an answer to what five attempts would have produced.
|
|
576
|
+
const deadline = this.option(bag, "deadline");
|
|
577
|
+
const attempts = this.option(bag, "attempts");
|
|
578
|
+
return await this.performEffect("ask", stepName, { agent: agent.agent, schema: schema ?? null, deadline: deadline ?? null, attempts: attempts ?? null }, (ctx) => handler.ask({
|
|
579
|
+
agent,
|
|
580
|
+
schema,
|
|
581
|
+
...(deadline !== undefined ? { deadline } : {}),
|
|
582
|
+
...(attempts !== undefined ? { attempts } : {}),
|
|
583
|
+
}, ctx), frame);
|
|
584
|
+
}
|
|
585
|
+
case "checkpoint": {
|
|
586
|
+
const prompt = args[1];
|
|
587
|
+
// The disposition is computed from TODAY's source, after the journal is consulted, on the
|
|
588
|
+
// live path and the replay path alike. performEffect returns the RAW outcome, which is
|
|
589
|
+
// what the journal holds; the policy sandwich closes here so a resumed run under an edited
|
|
590
|
+
// onExpiry throws even though nothing about the recorded expiry changed.
|
|
591
|
+
const onExpiry = this.option(bag, "onExpiry");
|
|
592
|
+
const schema = this.option(bag, "schema");
|
|
593
|
+
// The SAME projection the entry is keyed by, so an attempt's identity is a function of the
|
|
594
|
+
// step it belongs to rather than of anything the escalation invents.
|
|
595
|
+
// Design 5.12, and every field here earns its place. `timeout` STOPS OBSERVATION, so a
|
|
596
|
+
// record made under 1m cannot answer what a 3m wait would have seen. `escalate` and its
|
|
597
|
+
// `to` CREATE AN EFFECT rather than choosing a disposition, so editing them must diverge
|
|
598
|
+
// rather than be reapplied. `fail` versus `proceed` is the one genuine reapply and stays
|
|
599
|
+
// out. Hashing only prompt and schema left a timeout edit replaying clean, which is the
|
|
600
|
+
// silent-wrong-path class this projection exists to close.
|
|
601
|
+
const cpTimeout = this.option(bag, "timeout");
|
|
602
|
+
const cpTo = this.option(bag, "to");
|
|
603
|
+
const cpInput = {
|
|
604
|
+
prompt,
|
|
605
|
+
schema: schema ?? null,
|
|
606
|
+
timeout: cpTimeout ?? null,
|
|
607
|
+
...(onExpiry === "escalate" ? { onExpiry, to: cpTo ?? null } : {}),
|
|
608
|
+
};
|
|
609
|
+
return applyCheckpointPolicy((await this.performEffect("checkpoint", stepName, cpInput, async (ctx, inputHash) => {
|
|
610
|
+
// ONE hash value, threaded from what the entry is actually keyed by rather than
|
|
611
|
+
// re-digested from the projection here. The two agreed, which is exactly the problem:
|
|
612
|
+
// a second derivation that happens to match is a coincidence maintained by hand, and
|
|
613
|
+
// the first edit to the projection would desync attempt 1's identity from its own
|
|
614
|
+
// step with no type error and no failing test.
|
|
615
|
+
const attemptId = (n) => requestId(this.options.runId, ctx.key, inputHash, n);
|
|
616
|
+
const req = {
|
|
617
|
+
prompt,
|
|
618
|
+
...(schema !== undefined ? { schema } : {}),
|
|
619
|
+
...(cpTimeout !== undefined ? { timeout: cpTimeout } : {}),
|
|
620
|
+
...(onExpiry !== undefined ? { onExpiry } : {}),
|
|
621
|
+
...(cpTo !== undefined ? { to: cpTo } : {}),
|
|
622
|
+
};
|
|
623
|
+
// THE FINAL MINT DOES NOT ASK FOR AN ESCALATION. The interpreter owns the one-hop stop
|
|
624
|
+
// rule, and it can only own it if the far side is not simultaneously told to hop: a
|
|
625
|
+
// handler that honours `onExpiry` on the wire would mint a third attempt under an
|
|
626
|
+
// identity this journal never allocated, and nothing here would ever learn of it.
|
|
627
|
+
const finalReq = onExpiry === "escalate" ? { ...req, onExpiry: "proceed" } : req;
|
|
628
|
+
// RECOVERY COMPLETES THE OPEN ATTEMPT. IT DOES NOT REPLAY THE CHAIN.
|
|
629
|
+
//
|
|
630
|
+
// Arriving here with a non-zero attempt means the hop was issued before the crash, so
|
|
631
|
+
// the far side is already holding work under this very id. Re-running the live body
|
|
632
|
+
// from the top would call the handler again under it and take that call's cached
|
|
633
|
+
// expiry for a second observation: the stop rule would be satisfied on paper while the
|
|
634
|
+
// run had in fact observed one attempt twice. The chain's shape is recoverable without
|
|
635
|
+
// re-running it, because attempt 0's identity is derivable and its outcome is implied:
|
|
636
|
+
// the only path that opens attempt 1 is attempt 0 expiring.
|
|
637
|
+
if (ctx.attempt > 0) {
|
|
638
|
+
const raw = await handler.checkpoint(finalReq, ctx);
|
|
639
|
+
return {
|
|
640
|
+
...raw,
|
|
641
|
+
attempts: [
|
|
642
|
+
{ attempt: 0, requestId: attemptId(0), settled: "expired" },
|
|
643
|
+
{ attempt: ctx.attempt, requestId: ctx.requestId, to: cpTo ?? null, settled: raw.outcome },
|
|
644
|
+
],
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
const first = await handler.checkpoint(req, ctx);
|
|
648
|
+
if (first.outcome !== "expired" || onExpiry !== "escalate") {
|
|
649
|
+
// `ctx.attempt`, not a literal 0. Writing the literal made every recovery relabel the
|
|
650
|
+
// open attempt as the first one, which erased the hop from the journal and left the
|
|
651
|
+
// record claiming the escalated mint was the original.
|
|
652
|
+
return { ...first, attempts: [{ attempt: ctx.attempt, requestId: ctx.requestId, settled: first.outcome }] };
|
|
653
|
+
}
|
|
654
|
+
// ESCALATION STAYS INSIDE THIS ENTRY. The program made one call, and the interpreter
|
|
655
|
+
// owns key allocation, so a second mint must not become a second occurrence. What it
|
|
656
|
+
// does need is a second IDENTITY, derived from attempt 1 before the mint happens, or a
|
|
657
|
+
// crash between minting and recording leaves live work nothing in the journal names.
|
|
658
|
+
//
|
|
659
|
+
// Name the open attempt on the pending row BEFORE issuing it, index and all.
|
|
660
|
+
const nextId = attemptId(1);
|
|
661
|
+
this.journal.reissueAs(ctx.key, nextId, 1);
|
|
662
|
+
const second = await handler.checkpoint(finalReq, { ...ctx, requestId: nextId, attempt: 1 });
|
|
663
|
+
// ONE HOP. An escalation that can escalate again never terminates, so a second expiry
|
|
664
|
+
// settles as expired and the program decides, exactly as `proceed` would.
|
|
665
|
+
return {
|
|
666
|
+
...second,
|
|
667
|
+
attempts: [
|
|
668
|
+
{ attempt: 0, requestId: ctx.requestId, settled: "expired" },
|
|
669
|
+
{ attempt: 1, requestId: nextId, to: cpTo ?? null, settled: second.outcome },
|
|
670
|
+
],
|
|
671
|
+
};
|
|
672
|
+
}, frame)), onExpiry);
|
|
673
|
+
}
|
|
674
|
+
case "sleep": {
|
|
675
|
+
const duration = args[0];
|
|
676
|
+
parseDuration(duration); // fail at the call, not inside the handler
|
|
677
|
+
// The duration IS hashed (design 5.12). It determines the recorded fact: a resumed run
|
|
678
|
+
// reads the elapsed time back through the run clock, so editing 1h to 1m must diverge
|
|
679
|
+
// rather than silently keep the path the old duration chose. This hashed `null` until
|
|
680
|
+
// critic2 executed it, and the rule it violates is one this lane wrote and then only
|
|
681
|
+
// ever applied to the document.
|
|
682
|
+
return await this.performEffect("sleep", stepName ?? "", { duration }, (ctx) => handler.sleep({ duration }, ctx), frame);
|
|
683
|
+
}
|
|
684
|
+
case "wait": {
|
|
685
|
+
const event = deepFreeze(args[0]);
|
|
686
|
+
const timeout = this.option(bag, "timeout");
|
|
687
|
+
// A `wait` that resolved null did not observe "the event never happens": it observed "the
|
|
688
|
+
// event did not happen WITHIN THIS TIMEOUT". Editing the timeout therefore asks a different
|
|
689
|
+
// question, and replaying the recorded null answers the old one. This is the same hole the
|
|
690
|
+
// checkpoint projection closed, and leaving it open here left `?? recovery` steering off a
|
|
691
|
+
// stale cutoff.
|
|
692
|
+
return await this.performEffect("wait", stepName ?? "", { event, timeout: timeout ?? null }, (ctx) => handler.wait({ event, ...(timeout !== undefined ? { timeout } : {}) }, ctx), frame);
|
|
693
|
+
}
|
|
694
|
+
case "notify": {
|
|
695
|
+
const agents = deepFreeze(args[0]);
|
|
696
|
+
const fact = deepFreeze(args[1]);
|
|
697
|
+
return await this.performEffect("notify", stepName ?? "", { agents: agents.map((a) => a.agent), fact }, (ctx) => handler.notify({ agents, fact }, ctx), frame);
|
|
698
|
+
}
|
|
699
|
+
case "monitor": {
|
|
700
|
+
const agent = deepFreeze(args[0]);
|
|
701
|
+
return await this.performEffect("monitor", stepName ?? "", { agent: agent.agent }, (ctx) => handler.monitor({ agent }, ctx), frame);
|
|
702
|
+
}
|
|
703
|
+
default:
|
|
704
|
+
throw new RuntimeFault("L1000", `${name} is not implemented in this interpreter`);
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* The concurrency combinators.
|
|
709
|
+
*
|
|
710
|
+
* Each pushes a scope frame whose occurrence is allocated HERE, synchronously, in code that is
|
|
711
|
+
* already deterministic. Each branch then gets its own key namespace, so two branches running
|
|
712
|
+
* the same named effect cannot race for a counter, and replay reproduces both regardless of
|
|
713
|
+
* which one finished first.
|
|
714
|
+
*/
|
|
715
|
+
async callScope(name, argNodes, env, frame) {
|
|
716
|
+
const spec = PRIMITIVES[name];
|
|
717
|
+
if (spec === undefined)
|
|
718
|
+
throw new RuntimeFault("L2001", `${name} is not a primitive`);
|
|
719
|
+
const scopeKind = name;
|
|
720
|
+
const first = await this.evaluate(argNodes[0], env, frame);
|
|
721
|
+
const bagNode = argNodes[spec.optionsAt];
|
|
722
|
+
const bag = bagNode === undefined ? undefined : await this.evaluate(bagNode, env, frame);
|
|
723
|
+
const scopeName = this.option(bag, "name") ?? null;
|
|
724
|
+
const occurrence = frame.keys.nextScope(scopeKind, scopeName);
|
|
725
|
+
if (name === "parallel" || name === "race") {
|
|
726
|
+
const entries = Array.isArray(first)
|
|
727
|
+
? first.map((fn, i) => [String(i), fn])
|
|
728
|
+
: Object.entries(first);
|
|
729
|
+
const frames = entries.map(([k]) => frame.branch(scopeKind, scopeName, occurrence, k));
|
|
730
|
+
const running = entries.map(([, fn], i) => fn(frames[i], []));
|
|
731
|
+
if (name === "parallel") {
|
|
732
|
+
try {
|
|
733
|
+
const results = await Promise.all(running);
|
|
734
|
+
frame.clock.join(frames.map((f) => f.clock));
|
|
735
|
+
return Array.isArray(first)
|
|
736
|
+
? results
|
|
737
|
+
: Object.fromEntries(entries.map(([k], i) => [k, results[i]]));
|
|
738
|
+
}
|
|
739
|
+
catch (e) {
|
|
740
|
+
// The first rejection cancels the rest, then rethrows.
|
|
741
|
+
for (const f of frames)
|
|
742
|
+
f.signal.cancel("a sibling branch failed");
|
|
743
|
+
await Promise.allSettled(running);
|
|
744
|
+
frame.clock.join(frames.map((f) => f.clock));
|
|
745
|
+
throw e;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
// race: first to settle wins, and the losers are cancelled BY SEMANTICS, not by an API the
|
|
749
|
+
// program calls. A cancelled branch performs no new effects; an agent reply already in
|
|
750
|
+
// flight completes and is ignored, which is the documented answer rather than an accident.
|
|
751
|
+
const winner = await Promise.race(running.map((p, i) => p.then((value) => ({ index: entries[i]?.[0], value }))));
|
|
752
|
+
for (const f of frames)
|
|
753
|
+
f.signal.cancel("a sibling branch won the race");
|
|
754
|
+
await Promise.allSettled(running);
|
|
755
|
+
frame.clock.join(frames.map((f) => f.clock));
|
|
756
|
+
return winner;
|
|
757
|
+
}
|
|
758
|
+
if (name === "fanOut") {
|
|
759
|
+
const items = first;
|
|
760
|
+
const fn = (await this.evaluate(argNodes[1], env, frame));
|
|
761
|
+
const keyFn = this.option(bag, "key");
|
|
762
|
+
const branchKeys = [];
|
|
763
|
+
for (let i = 0; i < items.length; i += 1) {
|
|
764
|
+
const item = items[i];
|
|
765
|
+
let k;
|
|
766
|
+
if (keyFn !== undefined)
|
|
767
|
+
k = await keyFn(frame, [item]);
|
|
768
|
+
else if (item !== null && typeof item === "object" && typeof item.id === "string") {
|
|
769
|
+
k = item.id;
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
throw new RuntimeFault("L3021", `fanOut needs a stable key: without one, a reordered or filtered list silently reshuffles every journal key underneath it. Pass { key: (item) => ... }, or give items a string id.`);
|
|
773
|
+
}
|
|
774
|
+
branchKeys.push(String(k));
|
|
775
|
+
}
|
|
776
|
+
if (new Set(branchKeys).size !== branchKeys.length) {
|
|
777
|
+
throw new RuntimeFault("L3024", `fanOut produced duplicate branch keys (${branchKeys.join(", ")}), so two branches would share one journal namespace and allocate the same step key with different inputs. Nothing has run yet: the keys are all evaluated before any branch launches, because rejecting after launch would be too late by exactly the side effects the check exists to prevent.`);
|
|
778
|
+
}
|
|
779
|
+
const frames = branchKeys.map((k) => frame.branch(scopeKind, scopeName, occurrence, k));
|
|
780
|
+
const results = await Promise.all(items.map((item, i) => fn(frames[i], [item, i])));
|
|
781
|
+
frame.clock.join(frames.map((f) => f.clock));
|
|
782
|
+
return results;
|
|
783
|
+
}
|
|
784
|
+
throw new RuntimeFault("L1000", `${name} is not implemented in this interpreter`);
|
|
785
|
+
}
|
|
786
|
+
// ---- statements --------------------------------------------------------------------------------
|
|
787
|
+
async executeBlock(block, env, frame) {
|
|
788
|
+
const inner = new Env(env);
|
|
789
|
+
const body = block.body ?? [];
|
|
790
|
+
for (const s of body) {
|
|
791
|
+
if (s.type === "FunctionDeclaration") {
|
|
792
|
+
inner.declare(s.id.name, this.makeFunction(s, inner), false);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
for (const s of body) {
|
|
796
|
+
const c = await this.execute(s, inner, frame);
|
|
797
|
+
if (c.type !== "normal")
|
|
798
|
+
return c;
|
|
799
|
+
}
|
|
800
|
+
return NORMAL;
|
|
801
|
+
}
|
|
802
|
+
async execute(node, env, frame) {
|
|
803
|
+
const pause = this.tick(frame);
|
|
804
|
+
if (pause !== null)
|
|
805
|
+
await pause;
|
|
806
|
+
switch (node.type) {
|
|
807
|
+
case "ExpressionStatement":
|
|
808
|
+
await this.evaluate(node.expression, env, frame);
|
|
809
|
+
return NORMAL;
|
|
810
|
+
case "VariableDeclaration": {
|
|
811
|
+
const mutable = node.kind === "let";
|
|
812
|
+
for (const d of node.declarations) {
|
|
813
|
+
const init = d.init === null || d.init === undefined ? undefined : await this.evaluate(d.init, env, frame);
|
|
814
|
+
await this.bindPattern(d.id, init, env, frame, mutable);
|
|
815
|
+
}
|
|
816
|
+
return NORMAL;
|
|
817
|
+
}
|
|
818
|
+
case "FunctionDeclaration":
|
|
819
|
+
return NORMAL; // hoisted by executeBlock
|
|
820
|
+
case "BlockStatement":
|
|
821
|
+
return await this.executeBlock(node, env, frame);
|
|
822
|
+
case "IfStatement":
|
|
823
|
+
if (await this.evaluate(node.test, env, frame)) {
|
|
824
|
+
return await this.execute(node.consequent, env, frame);
|
|
825
|
+
}
|
|
826
|
+
return node.alternate === null || node.alternate === undefined
|
|
827
|
+
? NORMAL
|
|
828
|
+
: await this.execute(node.alternate, env, frame);
|
|
829
|
+
case "WhileStatement":
|
|
830
|
+
for (;;) {
|
|
831
|
+
if (!(await this.evaluate(node.test, env, frame)))
|
|
832
|
+
return NORMAL;
|
|
833
|
+
const c = await this.execute(node.body, env, frame);
|
|
834
|
+
if (c.type === "break")
|
|
835
|
+
return NORMAL;
|
|
836
|
+
if (c.type === "return")
|
|
837
|
+
return c;
|
|
838
|
+
}
|
|
839
|
+
case "ForStatement": {
|
|
840
|
+
const loopEnv = new Env(env);
|
|
841
|
+
if (node.init !== null && node.init !== undefined)
|
|
842
|
+
await this.execute(node.init, loopEnv, frame);
|
|
843
|
+
for (;;) {
|
|
844
|
+
if (node.test !== null && node.test !== undefined && !(await this.evaluate(node.test, loopEnv, frame))) {
|
|
845
|
+
return NORMAL;
|
|
846
|
+
}
|
|
847
|
+
const c = await this.execute(node.body, loopEnv, frame);
|
|
848
|
+
if (c.type === "break")
|
|
849
|
+
return NORMAL;
|
|
850
|
+
if (c.type === "return")
|
|
851
|
+
return c;
|
|
852
|
+
if (node.update !== null && node.update !== undefined)
|
|
853
|
+
await this.evaluate(node.update, loopEnv, frame);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
case "ForOfStatement": {
|
|
857
|
+
const iterable = (await this.evaluate(node.right, env, frame));
|
|
858
|
+
for (const item of iterable) {
|
|
859
|
+
const loopEnv = new Env(env);
|
|
860
|
+
const decl = node.left;
|
|
861
|
+
const target = decl.type === "VariableDeclaration" ? decl.declarations[0].id : decl;
|
|
862
|
+
await this.bindPattern(target, item, loopEnv, frame, decl.kind === "let");
|
|
863
|
+
const c = await this.execute(node.body, loopEnv, frame);
|
|
864
|
+
if (c.type === "break")
|
|
865
|
+
return NORMAL;
|
|
866
|
+
if (c.type === "return")
|
|
867
|
+
return c;
|
|
868
|
+
}
|
|
869
|
+
return NORMAL;
|
|
870
|
+
}
|
|
871
|
+
case "ReturnStatement":
|
|
872
|
+
return {
|
|
873
|
+
type: "return",
|
|
874
|
+
value: node.argument === null || node.argument === undefined ? undefined : await this.evaluate(node.argument, env, frame),
|
|
875
|
+
};
|
|
876
|
+
case "BreakStatement":
|
|
877
|
+
return { type: "break" };
|
|
878
|
+
case "ContinueStatement":
|
|
879
|
+
return { type: "continue" };
|
|
880
|
+
case "ThrowStatement":
|
|
881
|
+
throw await this.evaluate(node.argument, env, frame);
|
|
882
|
+
case "TryStatement": {
|
|
883
|
+
try {
|
|
884
|
+
const c = await this.execute(node.block, env, frame);
|
|
885
|
+
if (c.type !== "normal")
|
|
886
|
+
return c;
|
|
887
|
+
}
|
|
888
|
+
catch (e) {
|
|
889
|
+
// A cancellation is not a program error: it is the scope being unwound, and a catch
|
|
890
|
+
// block must not be able to swallow it and keep working in a branch that lost a race.
|
|
891
|
+
if (e instanceof Cancelled)
|
|
892
|
+
throw e;
|
|
893
|
+
const handlerNode = node.handler;
|
|
894
|
+
if (handlerNode === null || handlerNode === undefined)
|
|
895
|
+
throw e;
|
|
896
|
+
const catchEnv = new Env(env);
|
|
897
|
+
if (handlerNode.param !== null && handlerNode.param !== undefined) {
|
|
898
|
+
await this.bindPattern(handlerNode.param, toProgramError(e), catchEnv, frame, false);
|
|
899
|
+
}
|
|
900
|
+
const c = await this.executeBlock(handlerNode.body, catchEnv, frame);
|
|
901
|
+
if (c.type !== "normal")
|
|
902
|
+
return c;
|
|
903
|
+
}
|
|
904
|
+
finally {
|
|
905
|
+
if (node.finalizer !== null && node.finalizer !== undefined) {
|
|
906
|
+
await this.execute(node.finalizer, env, frame);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return NORMAL;
|
|
910
|
+
}
|
|
911
|
+
case "SwitchStatement": {
|
|
912
|
+
const disc = await this.evaluate(node.discriminant, env, frame);
|
|
913
|
+
const cases = node.cases;
|
|
914
|
+
const switchEnv = new Env(env);
|
|
915
|
+
let matched = false;
|
|
916
|
+
for (const c of cases) {
|
|
917
|
+
if (!matched) {
|
|
918
|
+
if (c.test === null || c.test === undefined)
|
|
919
|
+
matched = true;
|
|
920
|
+
else if (Object.is(await this.evaluate(c.test, switchEnv, frame), disc))
|
|
921
|
+
matched = true;
|
|
922
|
+
}
|
|
923
|
+
if (!matched)
|
|
924
|
+
continue;
|
|
925
|
+
for (const s of c.consequent ?? []) {
|
|
926
|
+
const comp = await this.execute(s, switchEnv, frame);
|
|
927
|
+
if (comp.type === "break")
|
|
928
|
+
return NORMAL;
|
|
929
|
+
if (comp.type !== "normal")
|
|
930
|
+
return comp;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return NORMAL;
|
|
934
|
+
}
|
|
935
|
+
case "EmptyStatement":
|
|
936
|
+
return NORMAL;
|
|
937
|
+
default:
|
|
938
|
+
throw new RuntimeFault("L1000", `unsupported statement ${node.type}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
// ---- helpers -------------------------------------------------------------------------------------
|
|
943
|
+
function applyBinary(op, l, r) {
|
|
944
|
+
switch (op) {
|
|
945
|
+
case "===":
|
|
946
|
+
return Object.is(l, r) || l === r;
|
|
947
|
+
case "!==":
|
|
948
|
+
return !(l === r);
|
|
949
|
+
case "<":
|
|
950
|
+
return l < r;
|
|
951
|
+
case "<=":
|
|
952
|
+
return l <= r;
|
|
953
|
+
case ">":
|
|
954
|
+
return l > r;
|
|
955
|
+
case ">=":
|
|
956
|
+
return l >= r;
|
|
957
|
+
case "+":
|
|
958
|
+
return typeof l === "string" || typeof r === "string"
|
|
959
|
+
? String(l) + String(r)
|
|
960
|
+
: l + r;
|
|
961
|
+
case "-":
|
|
962
|
+
return l - r;
|
|
963
|
+
case "*":
|
|
964
|
+
return l * r;
|
|
965
|
+
case "/":
|
|
966
|
+
return l / r;
|
|
967
|
+
case "%":
|
|
968
|
+
return l % r;
|
|
969
|
+
default:
|
|
970
|
+
throw new RuntimeFault("L1000", `unsupported operator ${op}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
/** What a `catch` block sees: a plain record, because programs branch on data, not on classes. */
|
|
974
|
+
function toProgramError(e) {
|
|
975
|
+
if (e instanceof EffectError) {
|
|
976
|
+
return deepFreeze({ code: e.code, kind: e.kind, message: e.message, ...(e.detail !== undefined ? { detail: e.detail } : {}) });
|
|
977
|
+
}
|
|
978
|
+
if (e instanceof RuntimeFault)
|
|
979
|
+
return deepFreeze({ code: e.code, kind: "runtime", message: e.message });
|
|
980
|
+
if (e !== null && typeof e === "object")
|
|
981
|
+
return e;
|
|
982
|
+
return deepFreeze({ code: "L4000", kind: "thrown", message: String(e) });
|
|
983
|
+
}
|
|
984
|
+
// ---- the public entry point -------------------------------------------------------------------------
|
|
985
|
+
/**
|
|
986
|
+
* Run a program.
|
|
987
|
+
*
|
|
988
|
+
* A run pins to the content hash of its SOURCE, which is why prompts, schemas, and model config
|
|
989
|
+
* are covered: they are in the source. Passing an existing journal resumes rather than starts.
|
|
990
|
+
*/
|
|
991
|
+
export async function run(source, options) {
|
|
992
|
+
const { ast } = validate(source, options.file);
|
|
993
|
+
const programHash = digest({ source });
|
|
994
|
+
const interp = new Interpreter(ast, options, programHash);
|
|
995
|
+
const frame = new Frame(new KeyScope(), new RunClock(options.handler.now()), new Signal());
|
|
996
|
+
const env = new Env(null);
|
|
997
|
+
installGlobals(env, interp, frame);
|
|
998
|
+
const completion = await interp.executeBlock(ast, env, frame);
|
|
999
|
+
return {
|
|
1000
|
+
value: completion.type === "return" ? completion.value : undefined,
|
|
1001
|
+
journal: interp.journal,
|
|
1002
|
+
programHash,
|
|
1003
|
+
steps: interp.stepCount,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
/** Re-run a program against an existing journal. Journalled effects return recorded results. */
|
|
1007
|
+
export async function resume(source, journal, options) {
|
|
1008
|
+
journal.resetConsumed();
|
|
1009
|
+
return await run(source, { ...options, journal });
|
|
1010
|
+
}
|
|
1011
|
+
function installGlobals(env, interp, rootFrame) {
|
|
1012
|
+
const fn = (impl) => async (frame, args) => impl(frame, args);
|
|
1013
|
+
// Pure primitives: a channel name is a name, so naming one costs nothing and journals nothing.
|
|
1014
|
+
env.declare("channel", fn((_f, a) => ({ channel: a[0] })), false);
|
|
1015
|
+
env.declare("run", fn(() => ({ id: interp.options.runId, programHash: interp.programHash })), false);
|
|
1016
|
+
// Event constructors are pure descriptors; awaiting them is `wait`.
|
|
1017
|
+
env.declare("replied", fn((_f, a) => ({ event: "replied", agent: a[0].agent })), false);
|
|
1018
|
+
env.declare("message", fn((_f, a) => {
|
|
1019
|
+
const ch = a[0].channel;
|
|
1020
|
+
const opts = (a[1] ?? {});
|
|
1021
|
+
return {
|
|
1022
|
+
event: "message",
|
|
1023
|
+
channel: ch,
|
|
1024
|
+
...(opts.from !== undefined ? { from: opts.from.agent } : {}),
|
|
1025
|
+
...(opts.matches !== undefined ? { matches: opts.matches } : {}),
|
|
1026
|
+
};
|
|
1027
|
+
}), false);
|
|
1028
|
+
env.declare("idle", fn((_f, a) => ({ event: "idle", channel: a[0].channel, duration: a[1] })), false);
|
|
1029
|
+
env.declare("down", fn((_f, a) => ({ event: "down", agent: a[0].agent })), false);
|
|
1030
|
+
// Time and randomness, tamed. `now()` reads the branch's own run clock, which is the maximum
|
|
1031
|
+
// endedAt over the effects this point actually awaited: time advances at effect boundaries as a
|
|
1032
|
+
// property of the design rather than a rule anyone has to follow.
|
|
1033
|
+
env.declare("now", fn((frame) => frame.clock.now()), false);
|
|
1034
|
+
env.declare("random", fn((frame) => interp.prng.next(frame.keys.path)), false);
|
|
1035
|
+
env.declare("randomInt", fn((frame, a) => Math.floor(interp.prng.next(frame.keys.path) * a[0])), false);
|
|
1036
|
+
env.declare("pick", fn((frame, a) => {
|
|
1037
|
+
const list = a[0];
|
|
1038
|
+
return list[Math.floor(interp.prng.next(frame.keys.path) * list.length)];
|
|
1039
|
+
}), false);
|
|
1040
|
+
env.declare("duration", fn((_f, a) => parseDuration(a[0])), false);
|
|
1041
|
+
// Records and arrays. Iteration order is insertion order, which is deterministic; sorting for
|
|
1042
|
+
// a hash is a separate concern and happens in canonicalization, not here.
|
|
1043
|
+
env.declare("keys", fn((_f, a) => Object.keys(a[0])), false);
|
|
1044
|
+
env.declare("values", fn((_f, a) => Object.values(a[0])), false);
|
|
1045
|
+
env.declare("entries", fn((_f, a) => Object.entries(a[0])), false);
|
|
1046
|
+
env.declare("has", fn((_f, a) => Object.prototype.hasOwnProperty.call(a[0], a[1])), false);
|
|
1047
|
+
env.declare("merge", fn((_f, a) => ({ ...a[0], ...a[1] })), false);
|
|
1048
|
+
env.declare("len", fn((_f, a) => a[0].length), false);
|
|
1049
|
+
env.declare("range", fn((_f, a) => Array.from({ length: a[0] }, (_, i) => i)), false);
|
|
1050
|
+
env.declare("sum", fn((_f, a) => a[0].reduce((x, y) => x + y, 0)), false);
|
|
1051
|
+
env.declare("concat", fn((_f, a) => a[0].concat(a[1])), false);
|
|
1052
|
+
env.declare("slice", fn((_f, a) => a[0].slice(a[1], a[2])), false);
|
|
1053
|
+
env.declare("reverse", fn((_f, a) => [...a[0]].reverse()), false);
|
|
1054
|
+
env.declare("unique", fn((_f, a) => [...new Set(a[0])]), false);
|
|
1055
|
+
env.declare("join", fn((_f, a) => a[0].join(a[1])), false);
|
|
1056
|
+
// Higher-order builtins take an interpreter function, so they have to await it.
|
|
1057
|
+
const higher = (impl) => async (frame, args) => await impl(frame, args[0], args[1]);
|
|
1058
|
+
env.declare("map", higher(async (frame, list, f) => {
|
|
1059
|
+
const out = [];
|
|
1060
|
+
for (let i = 0; i < list.length; i += 1)
|
|
1061
|
+
out.push(await f(frame, [list[i], i]));
|
|
1062
|
+
return out;
|
|
1063
|
+
}), false);
|
|
1064
|
+
env.declare("filter", higher(async (frame, list, f) => {
|
|
1065
|
+
const out = [];
|
|
1066
|
+
for (let i = 0; i < list.length; i += 1)
|
|
1067
|
+
if (await f(frame, [list[i], i]))
|
|
1068
|
+
out.push(list[i]);
|
|
1069
|
+
return out;
|
|
1070
|
+
}), false);
|
|
1071
|
+
env.declare("find", higher(async (frame, list, f) => {
|
|
1072
|
+
for (let i = 0; i < list.length; i += 1)
|
|
1073
|
+
if (await f(frame, [list[i], i]))
|
|
1074
|
+
return list[i];
|
|
1075
|
+
return null;
|
|
1076
|
+
}), false);
|
|
1077
|
+
env.declare("some", higher(async (frame, list, f) => {
|
|
1078
|
+
for (let i = 0; i < list.length; i += 1)
|
|
1079
|
+
if (await f(frame, [list[i], i]))
|
|
1080
|
+
return true;
|
|
1081
|
+
return false;
|
|
1082
|
+
}), false);
|
|
1083
|
+
env.declare("every", higher(async (frame, list, f) => {
|
|
1084
|
+
for (let i = 0; i < list.length; i += 1)
|
|
1085
|
+
if (!(await f(frame, [list[i], i])))
|
|
1086
|
+
return false;
|
|
1087
|
+
return true;
|
|
1088
|
+
}), false);
|
|
1089
|
+
// Strings and numbers.
|
|
1090
|
+
env.declare("split", fn((_f, a) => a[0].split(a[1])), false);
|
|
1091
|
+
env.declare("trim", fn((_f, a) => a[0].trim()), false);
|
|
1092
|
+
env.declare("lower", fn((_f, a) => a[0].toLowerCase()), false);
|
|
1093
|
+
env.declare("upper", fn((_f, a) => a[0].toUpperCase()), false);
|
|
1094
|
+
env.declare("startsWith", fn((_f, a) => a[0].startsWith(a[1])), false);
|
|
1095
|
+
env.declare("endsWith", fn((_f, a) => a[0].endsWith(a[1])), false);
|
|
1096
|
+
env.declare("contains", fn((_f, a) => a[0].includes(a[1])), false);
|
|
1097
|
+
env.declare("replace", fn((_f, a) => a[0].split(a[1]).join(a[2])), false);
|
|
1098
|
+
env.declare("min", fn((_f, a) => Math.min(...a)), false);
|
|
1099
|
+
env.declare("max", fn((_f, a) => Math.max(...a)), false);
|
|
1100
|
+
env.declare("abs", fn((_f, a) => Math.abs(a[0])), false);
|
|
1101
|
+
env.declare("floor", fn((_f, a) => Math.floor(a[0])), false);
|
|
1102
|
+
env.declare("ceil", fn((_f, a) => Math.ceil(a[0])), false);
|
|
1103
|
+
env.declare("round", fn((_f, a) => Math.round(a[0])), false);
|
|
1104
|
+
env.declare("parseNumber", fn((_f, a) => Number(a[0])), false);
|
|
1105
|
+
env.declare("assert", fn((_f, a) => {
|
|
1106
|
+
if (!a[0])
|
|
1107
|
+
throw new RuntimeFault("L4012", String(a[1] ?? "assertion failed"));
|
|
1108
|
+
return null;
|
|
1109
|
+
}), false);
|
|
1110
|
+
env.declare("log", fn((frame, a) => {
|
|
1111
|
+
interp.options.onLog?.({ scope: scopePathString(frame.keys.path), values: a });
|
|
1112
|
+
return null;
|
|
1113
|
+
}), false);
|
|
1114
|
+
void rootFrame;
|
|
1115
|
+
}
|
|
1116
|
+
export { LangError, LangErrors };
|
|
1117
|
+
//# sourceMappingURL=interpret.js.map
|