@ref0/sdk 0.0.0-stage → 0.5.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 +201 -0
- package/README.md +375 -2
- package/dist/browse-DBqoeS-u.d.mts +674 -0
- package/dist/browser.d.mts +29 -0
- package/dist/browser.mjs +28 -0
- package/dist/client.d.mts +94 -0
- package/dist/client.mjs +18136 -0
- package/dist/index-Dns8tCkT.d.mts +28913 -0
- package/dist/index.d.mts +341 -0
- package/dist/index.mjs +1552 -0
- package/package.json +58 -3
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1552 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/ref0-steps.ts
|
|
4
|
+
/**
|
|
5
|
+
* What ref0's own steps in a run share, whichever handle or leaf takes them: how each retries,
|
|
6
|
+
* and the digest a step's name carries as its identity.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* How ref0's own steps retry: what fails in one is ref0 unavailable, so five tries a second
|
|
10
|
+
* apart and doubling, about half a minute, then the run ends `unavailable`, never as a failure.
|
|
11
|
+
* ref0 gives up on an attempt after 40 seconds, inside its minute, so a stall fails as ref0's
|
|
12
|
+
* outage and never as Workflows' own timeout.
|
|
13
|
+
*/
|
|
14
|
+
const ref0Steps = {
|
|
15
|
+
retries: {
|
|
16
|
+
limit: 5,
|
|
17
|
+
delay: "1 second",
|
|
18
|
+
backoff: "exponential"
|
|
19
|
+
},
|
|
20
|
+
timeout: "1 minute"
|
|
21
|
+
};
|
|
22
|
+
/** The first 12 hex of the SHA-256 of some text: a step's identity in its name. */
|
|
23
|
+
const digestOf = async (text) => {
|
|
24
|
+
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))).slice(0, 6)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
25
|
+
};
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/rpc.ts
|
|
28
|
+
/**
|
|
29
|
+
* What the SDK does with what reaches a run's code over RPC: Cloudflare's `step`, and ref0's own
|
|
30
|
+
* calls. Each result is copied and released, so nothing holds the call open and the invocation
|
|
31
|
+
* can end.
|
|
32
|
+
*/
|
|
33
|
+
/** A result that came over RPC, copied and released. */
|
|
34
|
+
const settle = async (call) => {
|
|
35
|
+
const result = await call;
|
|
36
|
+
try {
|
|
37
|
+
return structuredClone(result);
|
|
38
|
+
} finally {
|
|
39
|
+
result?.[Symbol.dispose]?.();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Cloudflare's `step`, with every result settled, and each `do` callback given to `scoped`
|
|
44
|
+
* first, so a run can know which step its code is in.
|
|
45
|
+
*/
|
|
46
|
+
const settledStep = (step, scoped = (callback) => callback) => ({
|
|
47
|
+
do: (name, ...args) => settle(args.length === 1 ? step.do(name, scoped(args[0])) : step.do(name, args[0], scoped(args[1]))),
|
|
48
|
+
sleep: (name, duration) => settle(step.sleep(name, duration)),
|
|
49
|
+
sleepUntil: (name, timestamp) => settle(step.sleepUntil(name, timestamp)),
|
|
50
|
+
waitForEvent: (name, options) => settle(step.waitForEvent(name, options))
|
|
51
|
+
});
|
|
52
|
+
//#endregion
|
|
53
|
+
//#region src/run-scope.ts
|
|
54
|
+
const inStep = new AsyncLocalStorage();
|
|
55
|
+
const inRun = new AsyncLocalStorage();
|
|
56
|
+
/**
|
|
57
|
+
* Where a sandbox opened now goes, for `sandbox.run`: the run's sink, its place counted among the
|
|
58
|
+
* step's actions, and the step's attempt. Undefined outside a workflow's step.
|
|
59
|
+
*/
|
|
60
|
+
const sandboxPlace = () => {
|
|
61
|
+
const at = inStep.getStore();
|
|
62
|
+
if (at === void 0) return void 0;
|
|
63
|
+
const place = {
|
|
64
|
+
step: at.step,
|
|
65
|
+
count: at.count,
|
|
66
|
+
call: at.calls
|
|
67
|
+
};
|
|
68
|
+
at.calls += 1;
|
|
69
|
+
return {
|
|
70
|
+
sink: at.sink,
|
|
71
|
+
place,
|
|
72
|
+
attempt: at.attempt
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/ask.ts
|
|
77
|
+
/**
|
|
78
|
+
* ref0 refused an ask, as it refuses an action, and never opened it; `duplicate` when the same
|
|
79
|
+
* words went to the same decider within the hour; or `withdrawn`, opened, then closed with no
|
|
80
|
+
* answer when its thread closed. A refusal is kept as its step's result, so a replay throws it
|
|
81
|
+
* again and asks nothing.
|
|
82
|
+
*/
|
|
83
|
+
var AskRefused = class extends Error {
|
|
84
|
+
why;
|
|
85
|
+
constructor(key, why) {
|
|
86
|
+
super(why === "withdrawn" ? `ref0 withdrew the ask ${key}: its thread closed before anyone answered` : `ref0 refused the ask ${key}: ${why}`);
|
|
87
|
+
this.name = "AskRefused";
|
|
88
|
+
this.why = why;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/** What an ask's key may be, as `AnswerKey` in `@ref0/contracts` says. */
|
|
92
|
+
const askKey = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
|
93
|
+
/**
|
|
94
|
+
* How long the run waits for an ask's close: its deadline is a day at most, and ref0 sends its
|
|
95
|
+
* lean at the deadline, so past a day and an hour ref0 has failed to.
|
|
96
|
+
*/
|
|
97
|
+
const answerTimeout = "25 hours";
|
|
98
|
+
/**
|
|
99
|
+
* The run's sink: refused, in words, inside a step's callback, since one step can't hold another,
|
|
100
|
+
* and outside a workflow run.
|
|
101
|
+
*/
|
|
102
|
+
const sinkFor$1 = () => {
|
|
103
|
+
if (inStep.getStore() !== void 0) throw new Error("ask.run is a step of its own: call it outside step.do's callback");
|
|
104
|
+
const run = inRun.getStore();
|
|
105
|
+
if (run === void 0) throw new Error("ask.run asks from a workflow's run only");
|
|
106
|
+
return run.sink;
|
|
107
|
+
};
|
|
108
|
+
/**
|
|
109
|
+
* Asks once, in a step of its own, then waits in the run for the ask's close, `ask-<key>`. The
|
|
110
|
+
* ask's step name carries its key and a digest of what it asks, since Workflows tells steps of
|
|
111
|
+
* one name apart only by the order they're reached in: a replay finds the same ask, and two asks
|
|
112
|
+
* with one key never take each other's answers, as ref0 opens only the first.
|
|
113
|
+
*/
|
|
114
|
+
const run$1 = async (step, options) => {
|
|
115
|
+
const sink = sinkFor$1();
|
|
116
|
+
const key = options.key;
|
|
117
|
+
if (!askKey.test(key)) throw new AskRefused(JSON.stringify(key).slice(0, 80), "invalid");
|
|
118
|
+
const request = {
|
|
119
|
+
verb: "ask",
|
|
120
|
+
to: options.to,
|
|
121
|
+
key,
|
|
122
|
+
question: options.question,
|
|
123
|
+
answers: options.answers.map((answer) => ({
|
|
124
|
+
key: answer.key,
|
|
125
|
+
title: answer.title,
|
|
126
|
+
detail: answer.detail
|
|
127
|
+
})),
|
|
128
|
+
lean: options.lean,
|
|
129
|
+
deadline: options.deadline
|
|
130
|
+
};
|
|
131
|
+
const asked = await step.do(`ref0: ask ${key} ${await digestOf(JSON.stringify(request))}`, ref0Steps, async (context) => await settle(sink.act({
|
|
132
|
+
place: {
|
|
133
|
+
step: context.step.name,
|
|
134
|
+
count: context.step.count,
|
|
135
|
+
call: 0
|
|
136
|
+
},
|
|
137
|
+
request
|
|
138
|
+
})));
|
|
139
|
+
if (asked.kind === "duplicate") throw new AskRefused(key, "duplicate");
|
|
140
|
+
if (asked.kind === "refused") throw new AskRefused(key, asked.why);
|
|
141
|
+
const got = (await step.waitForEvent(`ref0: answer ${key}`, {
|
|
142
|
+
type: `ask-${key}`,
|
|
143
|
+
timeout: answerTimeout
|
|
144
|
+
}).catch(() => {
|
|
145
|
+
throw new Error(`ref0 sent no answer to the ask ${key} within a day and an hour`);
|
|
146
|
+
})).payload;
|
|
147
|
+
if (got.kind === "withdrawn") throw new AskRefused(key, "withdrawn");
|
|
148
|
+
const answered = got.key;
|
|
149
|
+
return got.via === "decider" ? {
|
|
150
|
+
key: answered,
|
|
151
|
+
by: got.by,
|
|
152
|
+
via: got.via,
|
|
153
|
+
at: got.at
|
|
154
|
+
} : {
|
|
155
|
+
key: answered,
|
|
156
|
+
via: got.via,
|
|
157
|
+
at: got.at
|
|
158
|
+
};
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* ref0's ask: `ask.run(step, { to, key, question, answers, lean, deadline })` asks, then waits
|
|
162
|
+
* in the run for `{ key, by, via, at }`, the answer, or the lean at the deadline.
|
|
163
|
+
*/
|
|
164
|
+
const ask = { run: run$1 };
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/check-params.ts
|
|
167
|
+
/**
|
|
168
|
+
* How deep an object may sit in params: an expansion's answer holds them two deeper, and the run's
|
|
169
|
+
* main module takes an answer whose objects sit at most 64 deep.
|
|
170
|
+
*/
|
|
171
|
+
const maxDepth$1 = 62;
|
|
172
|
+
/** JSON with each object's keys in order, so one value always reads as the same text. */
|
|
173
|
+
const sortedJson = (value) => JSON.stringify(value, (_key, each) => each instanceof Object && !Array.isArray(each) ? Object.fromEntries(Object.entries(each).toSorted(([a], [b]) => a < b ? -1 : 1)) : each);
|
|
174
|
+
/**
|
|
175
|
+
* Params as `CheckRunParams` holds them, in a copy of their own, or the path to the first value
|
|
176
|
+
* in them that JSON can't hold as it is, `''` for the params themselves: a function, a number
|
|
177
|
+
* that isn't finite, `undefined` in a list, or an object that isn't plain, such as a `Date` or a
|
|
178
|
+
* `Map`. An object's `undefined` field is left out, and `-0` is `0`, as JSON writes them, so
|
|
179
|
+
* what's sent is what's digested.
|
|
180
|
+
*/
|
|
181
|
+
const paramsOf = (value) => {
|
|
182
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return { notJson: "" };
|
|
183
|
+
const copy = objectOf(value, "", 0);
|
|
184
|
+
return "notJson" in copy ? copy : { params: copy.json };
|
|
185
|
+
};
|
|
186
|
+
/** A value as JSON holds it, at `at`, `depth` deep: see `paramsOf`. */
|
|
187
|
+
const copyOf = (value, at, depth) => {
|
|
188
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return { json: value };
|
|
189
|
+
if (typeof value === "number") return Number.isFinite(value) ? { json: value === 0 ? 0 : value } : { notJson: at };
|
|
190
|
+
if (typeof value !== "object" || depth > maxDepth$1) return { notJson: at };
|
|
191
|
+
return Array.isArray(value) ? listOf(value, at, depth) : objectOf(value, at, depth);
|
|
192
|
+
};
|
|
193
|
+
/** A list as JSON holds it, each item read by its index, as JSON reads one. */
|
|
194
|
+
const listOf = (value, at, depth) => {
|
|
195
|
+
const items = [];
|
|
196
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
197
|
+
const copy = copyOf(value[index], `${at}[${index}]`, depth + 1);
|
|
198
|
+
if ("notJson" in copy) return copy;
|
|
199
|
+
items.push(copy.json);
|
|
200
|
+
}
|
|
201
|
+
return { json: items };
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* A called workflow's output as JSON holds it, in a copy of its own, or the path to the first value
|
|
205
|
+
* in it that JSON can't hold as it is, `''` for the output itself, as `paramsOf` reads params.
|
|
206
|
+
*/
|
|
207
|
+
const outputOf = (value) => copyOf(value, "", 0);
|
|
208
|
+
/** A plain object as JSON holds it, each `undefined` field left out. */
|
|
209
|
+
const objectOf = (value, at, depth) => {
|
|
210
|
+
const prototype = Object.getPrototypeOf(value);
|
|
211
|
+
if (prototype !== Object.prototype && prototype !== null) return { notJson: at };
|
|
212
|
+
const fields = [];
|
|
213
|
+
for (const [key, field] of Object.entries(value)) {
|
|
214
|
+
if (field === void 0) continue;
|
|
215
|
+
const copy = copyOf(field, at === "" ? key : `${at}.${key}`, depth + 1);
|
|
216
|
+
if ("notJson" in copy) return copy;
|
|
217
|
+
fields.push([key, copy.json]);
|
|
218
|
+
}
|
|
219
|
+
return { json: Object.fromEntries(fields) };
|
|
220
|
+
};
|
|
221
|
+
/** The most a refusal's why may say, as `CheckExpansion` and `CalledWorkflowAnswer` keep it. */
|
|
222
|
+
const whyLength$1 = 1e3;
|
|
223
|
+
/**
|
|
224
|
+
* The first of a zod error's issues, as one line, at its path, for a check's params or a called
|
|
225
|
+
* workflow's input: `none` when it has no issue to say.
|
|
226
|
+
*/
|
|
227
|
+
const issueOf = (error, none) => {
|
|
228
|
+
const [issue] = error.issues;
|
|
229
|
+
if (issue === void 0) return none;
|
|
230
|
+
const at = issue.path.map(String).join(".");
|
|
231
|
+
return (at === "" ? issue.message : `${at}: ${issue.message}`).slice(0, whyLength$1);
|
|
232
|
+
};
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/called.ts
|
|
235
|
+
/**
|
|
236
|
+
* Why a call of a called workflow failed in its caller: ref0 refused it, by `why`, with what to do
|
|
237
|
+
* next, such as a chain of calls past four workflows; or the child run ended without returning,
|
|
238
|
+
* `failed`, in words that say how, such as its code throwing or its time running out. A refusal
|
|
239
|
+
* is kept as its step's result, so a replay throws it again and calls nothing.
|
|
240
|
+
*/
|
|
241
|
+
var CalledRunFailed = class extends Error {
|
|
242
|
+
workflow;
|
|
243
|
+
why;
|
|
244
|
+
/** The child run, once there is one. */
|
|
245
|
+
run;
|
|
246
|
+
constructor(workflow, failed, run) {
|
|
247
|
+
super(failed.kind === "refused" ? `ref0 refused to call ${workflow}: ${failed.nextStep}` : `${workflow} failed: ${failed.why}`);
|
|
248
|
+
this.name = "CalledRunFailed";
|
|
249
|
+
this.workflow = workflow;
|
|
250
|
+
this.why = failed.kind === "refused" ? failed.why : "failed";
|
|
251
|
+
this.run = run;
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
/** The most a call's input may be, as JSON in UTF-8, as `@ref0/contracts` says. */
|
|
255
|
+
const maxInputBytes = 16384;
|
|
256
|
+
/** How long one wait for a child's end lasts before ref0's record is read again. */
|
|
257
|
+
const waitTimeout$1 = "1 hour";
|
|
258
|
+
const encoder = new TextEncoder();
|
|
259
|
+
/**
|
|
260
|
+
* A call's input as JSON holds it, as a check's params are, an `undefined` field left out, so
|
|
261
|
+
* what's sent is what's digested: refused, before anything is sent, when JSON can't hold it as it
|
|
262
|
+
* is, or past its bound.
|
|
263
|
+
*/
|
|
264
|
+
const inputOf = (workflow, given) => {
|
|
265
|
+
const copy = paramsOf(given);
|
|
266
|
+
if ("notJson" in copy) throw new CalledRunFailed(workflow, {
|
|
267
|
+
kind: "refused",
|
|
268
|
+
why: "invalid input",
|
|
269
|
+
nextStep: copy.notJson === "" ? "Give a called workflow's input as a plain object of JSON values" : `Give ${copy.notJson} as JSON: a string, a finite number, true, false, null, or a list or plain object of them`
|
|
270
|
+
});
|
|
271
|
+
if (encoder.encode(JSON.stringify(copy.params)).byteLength > maxInputBytes) throw new CalledRunFailed(workflow, {
|
|
272
|
+
kind: "refused",
|
|
273
|
+
why: "invalid input",
|
|
274
|
+
nextStep: `Give it at most ${maxInputBytes} bytes of input, as JSON: pass a name or a key, not the data`
|
|
275
|
+
});
|
|
276
|
+
return copy.params;
|
|
277
|
+
};
|
|
278
|
+
/**
|
|
279
|
+
* The run's sink: refused, in words, inside a step's callback, since one step can't hold another,
|
|
280
|
+
* and outside a workflow run.
|
|
281
|
+
*/
|
|
282
|
+
const sinkFor = (workflow) => {
|
|
283
|
+
if (inStep.getStore() !== void 0) throw new Error(`${workflow}.run is a step of its own: call it outside step.do's callback`);
|
|
284
|
+
const run = inRun.getStore();
|
|
285
|
+
if (run === void 0) throw new Error(`ref0 runs a called workflow from a workflow's run only: ${workflow}`);
|
|
286
|
+
return run.sink;
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Calls a called workflow in a step of its own, `ref0: call <name> <digest>`. Its name carries a
|
|
290
|
+
* digest of the input, since Workflows tells steps of one name apart only by the order they're
|
|
291
|
+
* reached in, and concurrent calls reach theirs in another order on a replay. ref0 keys the child
|
|
292
|
+
* by this run, the step's name and count, and the workflow, so a replay finds the same child, a
|
|
293
|
+
* replay with other input calls another, and two calls with the same input are a child each.
|
|
294
|
+
* Calls alike in input may swap their children on a replay, as check starts alike do. Then each
|
|
295
|
+
* round reads how the child is going, in a step, and waits for its end, an hour at a time: ref0
|
|
296
|
+
* sends the end, and a wait that times out reads again, for as long as the child is going.
|
|
297
|
+
*/
|
|
298
|
+
const callOf = async (step, workflow, given) => {
|
|
299
|
+
const sink = sinkFor(workflow);
|
|
300
|
+
const input = inputOf(workflow, given);
|
|
301
|
+
const answer = await step.do(`ref0: call ${workflow} ${await digestOf(JSON.stringify(input))}`, ref0Steps, async (context) => await settle(sink.callWorkflow({
|
|
302
|
+
place: {
|
|
303
|
+
step: context.step.name,
|
|
304
|
+
count: context.step.count
|
|
305
|
+
},
|
|
306
|
+
workflow,
|
|
307
|
+
input
|
|
308
|
+
})));
|
|
309
|
+
if (answer.kind === "refused") throw new CalledRunFailed(workflow, answer);
|
|
310
|
+
const { run } = answer;
|
|
311
|
+
for (let round = 1;; round += 1) {
|
|
312
|
+
const state = await step.do(`ref0: called ${run} ${round}`, ref0Steps, async () => await settle(sink.calledState({ run })));
|
|
313
|
+
if (state.kind === "refused") throw new CalledRunFailed(workflow, state, run);
|
|
314
|
+
if (state.kind === "failed") throw new CalledRunFailed(workflow, state, run);
|
|
315
|
+
if (state.kind === "done") return state.output;
|
|
316
|
+
await step.waitForEvent(`ref0: wait ${run} ${round}`, {
|
|
317
|
+
type: `workflow-${run}`,
|
|
318
|
+
timeout: waitTimeout$1
|
|
319
|
+
}).catch(() => void 0);
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
/**
|
|
323
|
+
* What an import of `.ref0/workflows/<name>.ts` is, as ref0's bundler writes it: a called
|
|
324
|
+
* workflow's handle. Its bare call takes input JSON holds, as a call does.
|
|
325
|
+
*/
|
|
326
|
+
const workflowHandle = (workflow) => Object.assign((input) => ({
|
|
327
|
+
kind: "workflow",
|
|
328
|
+
workflow,
|
|
329
|
+
input: inputOf(workflow, input)
|
|
330
|
+
}), { run: async (step, input) => await callOf(step, workflow, input) });
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/attempts.ts
|
|
333
|
+
const inAttempt = new AsyncLocalStorage();
|
|
334
|
+
/** A check's step callback, run with its attempt's opener in scope, and given the context alone. */
|
|
335
|
+
const attemptScoped = (callback) => (context, opener) => opener === void 0 ? callback(context) : inAttempt.run(opener, () => callback(context));
|
|
336
|
+
/** The opener of the attempt this code runs in, or `undefined` outside a step's callback. */
|
|
337
|
+
const attemptOpener = () => inAttempt.getStore();
|
|
338
|
+
//#endregion
|
|
339
|
+
//#region src/needs.ts
|
|
340
|
+
const made = /* @__PURE__ */ new WeakMap();
|
|
341
|
+
/** The most of a need's stack kept, as `maxNeedStackLength` in `@ref0/contracts` says. */
|
|
342
|
+
const maxStack = 320;
|
|
343
|
+
/** The frames kept: the SDK's own few, then the line of `gate.ts` that made the need. */
|
|
344
|
+
const maxFrames = 6;
|
|
345
|
+
/**
|
|
346
|
+
* A need, made where it's called from, or on `stack` when a helper of the SDK's took its caller's
|
|
347
|
+
* stack itself, so the line of `gate.ts` stays in the frames kept.
|
|
348
|
+
*/
|
|
349
|
+
const needFrom = (need, stack = (/* @__PURE__ */ new Error("a need")).stack ?? "") => {
|
|
350
|
+
const value = Object.freeze({});
|
|
351
|
+
made.set(value, {
|
|
352
|
+
need,
|
|
353
|
+
stack: stack.split("\n").slice(0, maxFrames).join("\n").slice(0, maxStack)
|
|
354
|
+
});
|
|
355
|
+
return value;
|
|
356
|
+
};
|
|
357
|
+
/**
|
|
358
|
+
* What the SDK made a need as, or `undefined` for anything else the gate returned in its place:
|
|
359
|
+
* what isn't an object is never a key of `made`.
|
|
360
|
+
*/
|
|
361
|
+
const madeOf = (value) => made.get(value);
|
|
362
|
+
/**
|
|
363
|
+
* Whether what the gate or a check's run returned in place of a list of needs is one: a list the
|
|
364
|
+
* value's own type says holds needs keeps that type, where `Array.isArray` would make it `any[]`.
|
|
365
|
+
*/
|
|
366
|
+
const isList = (value) => Array.isArray(value);
|
|
367
|
+
//#endregion
|
|
368
|
+
//#region src/start-answers.ts
|
|
369
|
+
/**
|
|
370
|
+
* A start rule's answer, as `check()` keeps it for ref0 to call in the check's isolate: the rule
|
|
371
|
+
* run for each new run a start would make, and what its lists say, checked, since a rule may
|
|
372
|
+
* return anything.
|
|
373
|
+
*/
|
|
374
|
+
/** The most refusals an answer keeps, and called runs it waits on, as `@ref0/contracts` says. */
|
|
375
|
+
const maxRefusals = 16;
|
|
376
|
+
const maxWaits = 4;
|
|
377
|
+
/** The starter, as a run their start would make shows them: their actor, without its chain. */
|
|
378
|
+
const actorOf = (actor) => {
|
|
379
|
+
switch (actor.kind) {
|
|
380
|
+
case "person": return {
|
|
381
|
+
kind: "person",
|
|
382
|
+
id: actor.id
|
|
383
|
+
};
|
|
384
|
+
case "agent": return {
|
|
385
|
+
kind: "agent",
|
|
386
|
+
id: actor.id,
|
|
387
|
+
owner: actor.owner
|
|
388
|
+
};
|
|
389
|
+
case "ref0": return {
|
|
390
|
+
kind: "ref0",
|
|
391
|
+
id: actor.id
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
const isStrings = (value) => isList(value) && value.every((item) => typeof item === "string");
|
|
396
|
+
const isAllow = (item) => typeof item === "object" && item !== null && "kind" in item && item.kind === "allow";
|
|
397
|
+
/** Whether a value is `text`'s, a start rule's refusal's words or a called workflow's title. */
|
|
398
|
+
const isText = (value) => typeof value === "object" && value !== null && "strings" in value && "values" in value && isStrings(value.strings) && isStrings(value.values);
|
|
399
|
+
const isRefusal = (item) => typeof item === "object" && item !== null && "kind" in item && item.kind === "refuse" && "text" in item && isText(item.text);
|
|
400
|
+
const isCalledRun = (item) => typeof item === "object" && item !== null && "kind" in item && item.kind === "workflow" && "workflow" in item && typeof item.workflow === "string" && "input" in item && typeof item.input === "object" && item.input !== null && !isList(item.input);
|
|
401
|
+
/**
|
|
402
|
+
* A start rule's answer for each new run a start would make, each judged with its own params, the
|
|
403
|
+
* check's runs with the start's new runs before it, newest first, as just started, so a grid
|
|
404
|
+
* can't pass a limit its rule counts, and when it's judged: refused if any list refuses, with
|
|
405
|
+
* each refusal once; otherwise waiting on each called workflow's run the lists name, each
|
|
406
|
+
* workflow and input once, at most `maxWaits`; otherwise allowed. Invalid, with why, when the
|
|
407
|
+
* rule returns anything else, or names more runs.
|
|
408
|
+
*/
|
|
409
|
+
const startAnswerOf = async (rule, context) => {
|
|
410
|
+
if (rule === void 0) return { kind: "allowed" };
|
|
411
|
+
const refusals = /* @__PURE__ */ new Map();
|
|
412
|
+
const waits = /* @__PURE__ */ new Map();
|
|
413
|
+
const starting = [];
|
|
414
|
+
for (const cell of context.cells) {
|
|
415
|
+
const listed = await rule({
|
|
416
|
+
actor: context.actor,
|
|
417
|
+
params: cell,
|
|
418
|
+
where: context.where,
|
|
419
|
+
runs: [...starting, ...context.runs],
|
|
420
|
+
now: context.now
|
|
421
|
+
});
|
|
422
|
+
if (!isList(listed)) return {
|
|
423
|
+
kind: "invalid",
|
|
424
|
+
why: "It didn't return a list"
|
|
425
|
+
};
|
|
426
|
+
for (const item of listed) {
|
|
427
|
+
if (!item) continue;
|
|
428
|
+
if (isRefusal(item)) {
|
|
429
|
+
const text = {
|
|
430
|
+
strings: [...item.text.strings],
|
|
431
|
+
values: [...item.text.values]
|
|
432
|
+
};
|
|
433
|
+
refusals.set(JSON.stringify(text), text);
|
|
434
|
+
} else if (isCalledRun(item)) {
|
|
435
|
+
const run = {
|
|
436
|
+
workflow: item.workflow,
|
|
437
|
+
input: item.input
|
|
438
|
+
};
|
|
439
|
+
waits.set(sortedJson(run), run);
|
|
440
|
+
} else if (!isAllow(item)) return {
|
|
441
|
+
kind: "invalid",
|
|
442
|
+
why: "Its list holds something that isn't allow(), refuse(text), a called workflow's run or a falsy value"
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
starting.unshift({
|
|
446
|
+
startedAt: context.now,
|
|
447
|
+
actor: actorOf(context.actor),
|
|
448
|
+
where: context.where,
|
|
449
|
+
params: cell
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
if (refusals.size > 0) return {
|
|
453
|
+
kind: "refused",
|
|
454
|
+
refusals: [...refusals.values()].slice(0, maxRefusals)
|
|
455
|
+
};
|
|
456
|
+
if (waits.size > maxWaits) return {
|
|
457
|
+
kind: "invalid",
|
|
458
|
+
why: `It waits on more than ${maxWaits} called workflows' runs: name at most ${maxWaits}, each workflow and input once`
|
|
459
|
+
};
|
|
460
|
+
return waits.size === 0 ? { kind: "allowed" } : {
|
|
461
|
+
kind: "waits",
|
|
462
|
+
runs: [...waits.values()]
|
|
463
|
+
};
|
|
464
|
+
};
|
|
465
|
+
//#endregion
|
|
466
|
+
//#region src/tree.ts
|
|
467
|
+
const decoder = new TextDecoder();
|
|
468
|
+
/** A tree's reads, each one of ref0's over RPC through `host`. */
|
|
469
|
+
const treeOf = (host) => {
|
|
470
|
+
const bytesAt = ({ path }) => settle(host.readFile(path));
|
|
471
|
+
const textAt = async (read) => {
|
|
472
|
+
const bytes = await bytesAt(read);
|
|
473
|
+
return bytes === null ? null : decoder.decode(bytes);
|
|
474
|
+
};
|
|
475
|
+
return {
|
|
476
|
+
readFile: async (read) => {
|
|
477
|
+
const bytes = await bytesAt(read);
|
|
478
|
+
return bytes === null ? null : new Blob([bytes]);
|
|
479
|
+
},
|
|
480
|
+
readText: textAt,
|
|
481
|
+
readJSON: async (read) => {
|
|
482
|
+
const text = await textAt(read);
|
|
483
|
+
return text === null ? null : JSON.parse(text);
|
|
484
|
+
},
|
|
485
|
+
readTree: async ({ path }) => await settle(host.readTree(path))
|
|
486
|
+
};
|
|
487
|
+
};
|
|
488
|
+
//#endregion
|
|
489
|
+
//#region src/check.ts
|
|
490
|
+
/**
|
|
491
|
+
* ref0 refused a workflow's start of a check, or its read of one's runs, as `ref0 run` refuses
|
|
492
|
+
* one: `why`, what to do next, and for `busy`, when it may go. A refusal is kept as its step's
|
|
493
|
+
* result, so a replay throws it again and starts nothing. The SDK refuses a start's key or
|
|
494
|
+
* params itself, before any step, and a gate's need's params when the need is made.
|
|
495
|
+
*/
|
|
496
|
+
var CheckRunRefused = class extends Error {
|
|
497
|
+
why;
|
|
498
|
+
nextStep;
|
|
499
|
+
/** When a `busy` start may go, in milliseconds since the epoch. */
|
|
500
|
+
retryAt;
|
|
501
|
+
constructor(check, refused) {
|
|
502
|
+
super(`ref0 refused ${check}: ${refused.nextStep}`);
|
|
503
|
+
this.name = "CheckRunRefused";
|
|
504
|
+
this.why = refused.why;
|
|
505
|
+
this.nextStep = refused.nextStep;
|
|
506
|
+
this.retryAt = refused.retryAt;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
/** How long one wait for a run's end lasts before ref0's record is read again. */
|
|
510
|
+
const waitTimeout = "1 hour";
|
|
511
|
+
/**
|
|
512
|
+
* The run whose code calls a handle, to start and read its runs through: refused, in words,
|
|
513
|
+
* inside a step's callback, since one step can't hold another, and outside a workflow run.
|
|
514
|
+
*/
|
|
515
|
+
const runFor = (name) => {
|
|
516
|
+
if (inStep.getStore() !== void 0) throw new Error(`${name}.run, start and wait are steps of their own: call them outside step.do's callback`);
|
|
517
|
+
const run = inRun.getStore();
|
|
518
|
+
if (run === void 0) throw new Error(`ref0 runs a check from a workflow's code only, not yet from a check: ${name}`);
|
|
519
|
+
return run;
|
|
520
|
+
};
|
|
521
|
+
/** A reference to a run a start got, as a step keeps it. */
|
|
522
|
+
const referenceOf = (check, run) => ({
|
|
523
|
+
check,
|
|
524
|
+
run
|
|
525
|
+
});
|
|
526
|
+
/** How a run ended, as a result, from what ref0's record says of it. */
|
|
527
|
+
const resultOf = (reference, read) => {
|
|
528
|
+
const { params } = read;
|
|
529
|
+
if (read.state !== "ended") throw new Error(`ref0 read ${reference.run} as ${read.state} once it had ended`);
|
|
530
|
+
if (read.error !== void 0) return read.why === void 0 ? {
|
|
531
|
+
run: reference,
|
|
532
|
+
params,
|
|
533
|
+
error: read.error
|
|
534
|
+
} : {
|
|
535
|
+
run: reference,
|
|
536
|
+
params,
|
|
537
|
+
error: read.error,
|
|
538
|
+
why: read.why
|
|
539
|
+
};
|
|
540
|
+
const output = read.output;
|
|
541
|
+
return output === void 0 ? {
|
|
542
|
+
run: reference,
|
|
543
|
+
params,
|
|
544
|
+
conclusion: read.conclusion
|
|
545
|
+
} : {
|
|
546
|
+
run: reference,
|
|
547
|
+
params,
|
|
548
|
+
conclusion: read.conclusion,
|
|
549
|
+
output
|
|
550
|
+
};
|
|
551
|
+
};
|
|
552
|
+
/** What a start asks at its step's place, with `where` and `params` only when given. */
|
|
553
|
+
const startAt = (place, check, where, params) => {
|
|
554
|
+
if (where === void 0) return params === void 0 ? {
|
|
555
|
+
place,
|
|
556
|
+
check
|
|
557
|
+
} : {
|
|
558
|
+
place,
|
|
559
|
+
check,
|
|
560
|
+
params
|
|
561
|
+
};
|
|
562
|
+
return params === void 0 ? {
|
|
563
|
+
place,
|
|
564
|
+
check,
|
|
565
|
+
where
|
|
566
|
+
} : {
|
|
567
|
+
place,
|
|
568
|
+
check,
|
|
569
|
+
where,
|
|
570
|
+
params
|
|
571
|
+
};
|
|
572
|
+
};
|
|
573
|
+
/** What a start's key may be: see `CheckRunOptions['key']`. */
|
|
574
|
+
const startKey = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/;
|
|
575
|
+
/** What to give in place of params JSON can't hold, by the path `paramsOf` answers. */
|
|
576
|
+
const jsonStep = (at) => (at === "" ? "Give a check's params as a plain object of JSON values" : `Give ${at} as JSON: a string, a finite number, true, false, null, or a list or plain object of them`).slice(0, whyLength);
|
|
577
|
+
/**
|
|
578
|
+
* A start's or a need's params as JSON holds them, an `undefined` field left out, so what's sent
|
|
579
|
+
* is what's digested, and a field left out runs each of its `oneOf` values. Params JSON can't hold
|
|
580
|
+
* as they are are refused before anything is digested or started.
|
|
581
|
+
*/
|
|
582
|
+
const jsonOf = (name, params) => {
|
|
583
|
+
if (params === void 0) return void 0;
|
|
584
|
+
const copy = paramsOf(params);
|
|
585
|
+
if ("notJson" in copy) throw new CheckRunRefused(name, {
|
|
586
|
+
kind: "refused",
|
|
587
|
+
why: "refused",
|
|
588
|
+
nextStep: jsonStep(copy.notJson)
|
|
589
|
+
});
|
|
590
|
+
return copy.params;
|
|
591
|
+
};
|
|
592
|
+
/**
|
|
593
|
+
* Waits for a start that waits on called workflows to go on or be refused: each round reads how
|
|
594
|
+
* it stands, in a step, then waits for its end, an hour at a time. ref0 sends its end, and a wait
|
|
595
|
+
* that times out reads again, for as long as ref0's record says it waits. Each read's name carries
|
|
596
|
+
* the waiting start's ID and its round, so a replay reads the same. Its runs, or its refusal,
|
|
597
|
+
* thrown.
|
|
598
|
+
*/
|
|
599
|
+
const waitedOf = async (step, name, start) => {
|
|
600
|
+
const { sink } = runFor(name);
|
|
601
|
+
for (let round = 1;; round += 1) {
|
|
602
|
+
const state = await step.do(`ref0: waited ${name} ${start} ${round}`, ref0Steps, async () => await settle(sink.startState({
|
|
603
|
+
check: name,
|
|
604
|
+
start
|
|
605
|
+
})));
|
|
606
|
+
if (state.kind === "refused") throw new CheckRunRefused(name, state);
|
|
607
|
+
if (state.kind === "started") return state.runs;
|
|
608
|
+
await step.waitForEvent(`ref0: wait ${start} ${round}`, {
|
|
609
|
+
type: `start-${start}`,
|
|
610
|
+
timeout: waitTimeout
|
|
611
|
+
}).catch(() => void 0);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
/**
|
|
615
|
+
* Starts a check's runs in one step of its own. Its name carries the start's identity: its key,
|
|
616
|
+
* if any, and a digest of its `where`, params and key, since Workflows tells steps of one name
|
|
617
|
+
* apart only by the order they're reached in, and concurrent starts reach theirs in another order
|
|
618
|
+
* on a replay. Starts that ask the same without a key are alike to Workflows, so on a replay each
|
|
619
|
+
* may get the other's runs, which differ if the head moved between them: see
|
|
620
|
+
* `CheckRunOptions['key']`. A start whose check's start rule makes it wait waits here, in the
|
|
621
|
+
* run, until it goes on or is refused.
|
|
622
|
+
*/
|
|
623
|
+
const startOf = async (step, name, options = {}) => {
|
|
624
|
+
const { sink } = runFor(name);
|
|
625
|
+
const { key } = options;
|
|
626
|
+
if (key !== void 0 && !startKey.test(key)) throw new CheckRunRefused(name, {
|
|
627
|
+
kind: "refused",
|
|
628
|
+
why: "refused",
|
|
629
|
+
nextStep: `A start's key is up to 64 letters, digits, ., _, : or -, from a letter or digit, not ${JSON.stringify(key).slice(0, 80)}.`
|
|
630
|
+
});
|
|
631
|
+
const params = jsonOf(name, options.params);
|
|
632
|
+
const asked = await digestOf(sortedJson({
|
|
633
|
+
where: options.where ?? null,
|
|
634
|
+
params: params ?? {},
|
|
635
|
+
key: key ?? null
|
|
636
|
+
}));
|
|
637
|
+
const answer = await step.do(key === void 0 ? `ref0: start ${name} ${asked}` : `ref0: start ${name} ${key} ${asked}`, ref0Steps, async (context) => await settle(sink.startChecks(startAt({
|
|
638
|
+
step: context.step.name,
|
|
639
|
+
count: context.step.count
|
|
640
|
+
}, name, options.where, params))));
|
|
641
|
+
if (answer.kind === "refused") throw new CheckRunRefused(name, answer);
|
|
642
|
+
return (answer.kind === "waiting" ? await waitedOf(step, name, answer.start) : answer.runs).map((run) => referenceOf(name, run));
|
|
643
|
+
};
|
|
644
|
+
/** A wait's identity: the digest of its runs, sorted, one to a line. */
|
|
645
|
+
const waitIdOf = async (runs) => await digestOf(runs.toSorted().join("\n"));
|
|
646
|
+
/**
|
|
647
|
+
* Waits until a run's end wakes this invocation, or an hour passes. Every wait on the run shares
|
|
648
|
+
* one `step.waitForEvent`, since ref0 sends the end once and it wakes one waiter: one that's
|
|
649
|
+
* going is joined, and one that woke stays woken. Only one that ran out its hour makes the next,
|
|
650
|
+
* its count one more, so each one's name is the same on a replay, whichever wait makes it.
|
|
651
|
+
*/
|
|
652
|
+
const wokenBy = (step, waiters, run) => {
|
|
653
|
+
const held = waiters.get(run);
|
|
654
|
+
if (held !== void 0 && !held.timedOut) return held.woken;
|
|
655
|
+
const waiter = {
|
|
656
|
+
count: (held?.count ?? 0) + 1,
|
|
657
|
+
woken: Promise.resolve(),
|
|
658
|
+
timedOut: false
|
|
659
|
+
};
|
|
660
|
+
waiter.woken = step.waitForEvent(`ref0: wait ${run} ${waiter.count}`, {
|
|
661
|
+
type: `check-${run}`,
|
|
662
|
+
timeout: waitTimeout
|
|
663
|
+
}).then(() => void 0, () => {
|
|
664
|
+
waiter.timedOut = true;
|
|
665
|
+
});
|
|
666
|
+
waiters.set(run, waiter);
|
|
667
|
+
return waiter.woken;
|
|
668
|
+
};
|
|
669
|
+
/**
|
|
670
|
+
* Waits for runs to end, then reads how each ended. Each round reads whether they've ended, in a
|
|
671
|
+
* step, then waits for each still going, an hour at a time, as `wokenBy` says; an end ref0 sends
|
|
672
|
+
* wakes the wait, and a wait that times out reads again, for as long as ref0's record says a run
|
|
673
|
+
* is going. Then the results are read a page at a time, each page a step well inside Workflows'
|
|
674
|
+
* 1 MiB. Each read's name carries the wait's own identity, a digest of its runs, and its round or
|
|
675
|
+
* page, since Workflows tells steps of one name apart only by the order they're reached in, and
|
|
676
|
+
* concurrent waits reach theirs in another order on a replay.
|
|
677
|
+
*/
|
|
678
|
+
const waitOf = async (step, name, pending) => {
|
|
679
|
+
const { sink, waiters } = runFor(name);
|
|
680
|
+
const foreign = pending.find((reference) => reference.check !== name);
|
|
681
|
+
if (foreign !== void 0) throw new Error(`${foreign.run} is a run of ${foreign.check}, not ${name}`);
|
|
682
|
+
const runs = [...new Set(pending.map((reference) => reference.run))].toSorted();
|
|
683
|
+
if (runs.length === 0) return [];
|
|
684
|
+
const wait = await waitIdOf(runs);
|
|
685
|
+
const missing = (found) => {
|
|
686
|
+
const unknown = runs.find((run) => !found.some((each) => each.run === run));
|
|
687
|
+
if (unknown !== void 0) throw new Error(`${unknown} isn't a run of ${name} that this workflow run started`);
|
|
688
|
+
};
|
|
689
|
+
for (let round = 1;; round += 1) {
|
|
690
|
+
const states = await step.do(`ref0: states ${name} ${wait} ${round}`, ref0Steps, async () => await settle(sink.checkStates({
|
|
691
|
+
check: name,
|
|
692
|
+
runs
|
|
693
|
+
})));
|
|
694
|
+
if (states.kind === "refused") throw new CheckRunRefused(name, states);
|
|
695
|
+
missing(states.runs);
|
|
696
|
+
const going = states.runs.filter((run) => !run.ended);
|
|
697
|
+
if (going.length === 0) break;
|
|
698
|
+
await Promise.all(going.map(async ({ run }) => await wokenBy(step, waiters, run)));
|
|
699
|
+
}
|
|
700
|
+
const read = [];
|
|
701
|
+
for (let from = 0, page = 1; from !== void 0; page += 1) {
|
|
702
|
+
const at = from;
|
|
703
|
+
const got = await step.do(`ref0: results ${name} ${wait} ${page}`, ref0Steps, async () => await settle(sink.checkResults({
|
|
704
|
+
check: name,
|
|
705
|
+
runs,
|
|
706
|
+
from: at
|
|
707
|
+
})));
|
|
708
|
+
if (got.kind === "refused") throw new CheckRunRefused(name, got);
|
|
709
|
+
read.push(...got.runs);
|
|
710
|
+
from = got.next;
|
|
711
|
+
}
|
|
712
|
+
missing(read);
|
|
713
|
+
return pending.map((reference) => {
|
|
714
|
+
const found = read.find((run) => run.run === reference.run);
|
|
715
|
+
if (found === void 0) throw new Error(`${reference.run} wasn't read`);
|
|
716
|
+
return resultOf(reference, found);
|
|
717
|
+
});
|
|
718
|
+
};
|
|
719
|
+
/**
|
|
720
|
+
* What an import of `.ref0/checks/<name>.ts` is, as ref0's bundler writes it; with `project`,
|
|
721
|
+
* an import of `ref0:project/checks/<name>`, one of the project repo's checks, which a gate needs
|
|
722
|
+
* and nothing runs from here yet.
|
|
723
|
+
*/
|
|
724
|
+
const checkHandle = (name, source) => {
|
|
725
|
+
const notYet = () => {
|
|
726
|
+
throw new Error(`ref0 can't run the project's check ${name} from here yet`);
|
|
727
|
+
};
|
|
728
|
+
const runs = source === "project" ? {
|
|
729
|
+
run: notYet,
|
|
730
|
+
start: notYet,
|
|
731
|
+
wait: notYet
|
|
732
|
+
} : {
|
|
733
|
+
run: async (step, options) => await waitOf(step, name, await startOf(step, name, options)),
|
|
734
|
+
start: async (step, options) => ({ pending: await startOf(step, name, options) }),
|
|
735
|
+
wait: async (step, pending) => await waitOf(step, name, pending)
|
|
736
|
+
};
|
|
737
|
+
const from = source === "project" ? { source } : {};
|
|
738
|
+
return Object.assign((given) => {
|
|
739
|
+
const params = jsonOf(name, given);
|
|
740
|
+
return needFrom(params === void 0 ? {
|
|
741
|
+
kind: "check",
|
|
742
|
+
name,
|
|
743
|
+
...from
|
|
744
|
+
} : {
|
|
745
|
+
kind: "check",
|
|
746
|
+
name,
|
|
747
|
+
params,
|
|
748
|
+
...from
|
|
749
|
+
});
|
|
750
|
+
}, runs);
|
|
751
|
+
};
|
|
752
|
+
/** Each `oneOf` field's values, by its schema: the SDK's own, so no zod registry is needed. */
|
|
753
|
+
const marks = /* @__PURE__ */ new WeakMap();
|
|
754
|
+
/**
|
|
755
|
+
* A field's `oneOf` mark: its own, or that of the schema it was cloned from, as `.min()` clones
|
|
756
|
+
* one, or of the schema it wraps, as `.optional()` and `.transform()` wrap one. It reads zod's
|
|
757
|
+
* own links, so a check reaches no zod it doesn't import.
|
|
758
|
+
*/
|
|
759
|
+
const markOf = (field) => {
|
|
760
|
+
const mark = marks.get(field);
|
|
761
|
+
if (mark !== void 0) return mark;
|
|
762
|
+
const { def, parent } = field._zod;
|
|
763
|
+
const inner = "innerType" in def ? def.innerType : "in" in def ? def.in : parent;
|
|
764
|
+
return inner === void 0 ? void 0 : markOf(inner);
|
|
765
|
+
};
|
|
766
|
+
/** The most runs one start may ask for, as `maxCheckRunsPerStart` in `@ref0/contracts` says. */
|
|
767
|
+
const maxRuns = 256;
|
|
768
|
+
/** What a refusal may say, as `CheckExpansion` in `@ref0/contracts` keeps it. */
|
|
769
|
+
const whyLength = 1e3;
|
|
770
|
+
/** A start's runs, by its params' `oneOf` fields: see `CheckOwn['expand']`. */
|
|
771
|
+
const expansionOf = async (params, asked) => {
|
|
772
|
+
const fields = params?.shape ?? {};
|
|
773
|
+
const unknown = Object.keys(asked).filter((key) => !Object.hasOwn(fields, key));
|
|
774
|
+
if (unknown.length > 0) return { refused: `It has no param ${unknown.join(", ")}`.slice(0, whyLength) };
|
|
775
|
+
let cells = [asked];
|
|
776
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
777
|
+
const mark = Object.hasOwn(asked, key) ? void 0 : markOf(field);
|
|
778
|
+
if (mark === void 0) continue;
|
|
779
|
+
if ("computed" in mark) return { refused: `${key}'s values are read from the tree, which ref0 doesn't do yet: give ${key}` };
|
|
780
|
+
const distinct = [...new Set(mark.listed)];
|
|
781
|
+
if (distinct.length === 0) return { refused: `${key} has no values` };
|
|
782
|
+
if (cells.length * distinct.length > maxRuns) return { refused: `It asks for more than ${maxRuns} runs: give ${key}, or another param` };
|
|
783
|
+
cells = cells.flatMap((cell) => distinct.map((value) => ({
|
|
784
|
+
...cell,
|
|
785
|
+
[key]: value
|
|
786
|
+
})));
|
|
787
|
+
}
|
|
788
|
+
if (params === void 0) return { runs: cells };
|
|
789
|
+
const runs = [];
|
|
790
|
+
for (const cell of cells) {
|
|
791
|
+
const parsed = await params.safeParseAsync(cell);
|
|
792
|
+
if (!parsed.success) return { refused: issueOf(parsed.error, "Its params are invalid") };
|
|
793
|
+
const copy = paramsOf(parsed.data);
|
|
794
|
+
if ("notJson" in copy) return { refused: (copy.notJson === "" ? "Its params aren't JSON once parsed" : `Its params aren't JSON once parsed: ${copy.notJson} isn't`).slice(0, whyLength) };
|
|
795
|
+
runs.push(copy.params);
|
|
796
|
+
}
|
|
797
|
+
return { runs };
|
|
798
|
+
};
|
|
799
|
+
/** The most needs one run's result may add, as `maxResultNeeds` in `@ref0/contracts` says. */
|
|
800
|
+
const maxResultNeeds = 16;
|
|
801
|
+
/**
|
|
802
|
+
* A run's result needs as ref0 keeps them, or why they can't be: each one the SDK made, and none a
|
|
803
|
+
* check's, since a result can't ask for runs mid-version.
|
|
804
|
+
*/
|
|
805
|
+
const resultNeedsOf = (needs) => {
|
|
806
|
+
if (needs === void 0) return { needs: [] };
|
|
807
|
+
if (!isList(needs)) return { why: "Its needs aren't a list" };
|
|
808
|
+
if (needs.length > maxResultNeeds) return { why: `Its result adds past ${maxResultNeeds} needs` };
|
|
809
|
+
const kept = [];
|
|
810
|
+
for (const need of needs) {
|
|
811
|
+
const made = madeOf(need)?.need;
|
|
812
|
+
if (made === void 0) return { why: "Its needs hold something that isn't one: return approval() and review()" };
|
|
813
|
+
if (made.kind === "check" || made.kind === "checks") return { why: `Its result can't need ${made.kind === "check" ? `the check ${made.name}` : "checks"}: return approval() and review()` };
|
|
814
|
+
kept.push(made);
|
|
815
|
+
}
|
|
816
|
+
return { needs: kept };
|
|
817
|
+
};
|
|
818
|
+
/** One run of a check: see `CheckOwn['run']`. */
|
|
819
|
+
const runOf$1 = (spec) => async (input, step, host) => {
|
|
820
|
+
const params = input.params;
|
|
821
|
+
const result = await spec.run({
|
|
822
|
+
params,
|
|
823
|
+
step: settledStep(step, attemptScoped),
|
|
824
|
+
repo: treeOf(host)
|
|
825
|
+
});
|
|
826
|
+
const checked = spec.output === void 0 ? {
|
|
827
|
+
value: result.output,
|
|
828
|
+
issues: void 0
|
|
829
|
+
} : await spec.output["~standard"].validate(result.output);
|
|
830
|
+
if (checked.issues !== void 0) return {
|
|
831
|
+
error: "invalid output",
|
|
832
|
+
why: checked.issues.map((issue) => issue.message).join("; ").slice(0, whyLength)
|
|
833
|
+
};
|
|
834
|
+
const read = resultNeedsOf(result.needs);
|
|
835
|
+
if ("why" in read) return {
|
|
836
|
+
error: "invalid output",
|
|
837
|
+
why: read.why
|
|
838
|
+
};
|
|
839
|
+
const { needs } = read;
|
|
840
|
+
const judged = needs.length === 0 ? { conclusion: result.conclusion } : {
|
|
841
|
+
conclusion: result.conclusion,
|
|
842
|
+
needs
|
|
843
|
+
};
|
|
844
|
+
if (checked.value === void 0) return judged;
|
|
845
|
+
return {
|
|
846
|
+
...judged,
|
|
847
|
+
output: checked.value
|
|
848
|
+
};
|
|
849
|
+
};
|
|
850
|
+
/**
|
|
851
|
+
* A check: what it is, its params, when it runs, and the work of one run. Export it as its
|
|
852
|
+
* file's default, `.ref0/checks/<name>.ts`, named by its file.
|
|
853
|
+
*/
|
|
854
|
+
const check = (spec) => Object.assign(checkHandle("this check"), { "~ref0": {
|
|
855
|
+
version: 1,
|
|
856
|
+
expand: async (asked) => await expansionOf(spec.params, asked),
|
|
857
|
+
start: async (context) => await startAnswerOf(spec.start, context),
|
|
858
|
+
run: runOf$1(spec)
|
|
859
|
+
} });
|
|
860
|
+
/**
|
|
861
|
+
* A param that's one check run for each of its values, and a grid with several. It's a zod 4
|
|
862
|
+
* string schema, so `.ref0/package.json` needs zod 4, the one its `z.object` comes from.
|
|
863
|
+
*/
|
|
864
|
+
const oneOf = (values) => {
|
|
865
|
+
const schema = z.string();
|
|
866
|
+
marks.set(schema, Array.isArray(values) ? { listed: values } : { computed: true });
|
|
867
|
+
return schema;
|
|
868
|
+
};
|
|
869
|
+
//#endregion
|
|
870
|
+
//#region src/path-patterns.ts
|
|
871
|
+
const segmentOf = (text) => {
|
|
872
|
+
if (text === "**") return { kind: "any" };
|
|
873
|
+
if (text.includes("?")) return {
|
|
874
|
+
kind: "wild",
|
|
875
|
+
chars: Array.from(text),
|
|
876
|
+
parts: void 0
|
|
877
|
+
};
|
|
878
|
+
return text.includes("*") ? {
|
|
879
|
+
kind: "wild",
|
|
880
|
+
chars: Array.from(text),
|
|
881
|
+
parts: text.split("*")
|
|
882
|
+
} : {
|
|
883
|
+
kind: "literal",
|
|
884
|
+
text
|
|
885
|
+
};
|
|
886
|
+
};
|
|
887
|
+
/**
|
|
888
|
+
* Whether a segment matches the literals between a pattern's stars: the first at its start, the
|
|
889
|
+
* last at its end, and each between them in order, the earliest place each fits leaving the
|
|
890
|
+
* most room for the rest.
|
|
891
|
+
*/
|
|
892
|
+
const partsMatch = (parts, segment) => {
|
|
893
|
+
const head = parts[0] ?? "";
|
|
894
|
+
const tail = parts.at(-1) ?? "";
|
|
895
|
+
const end = segment.length - tail.length;
|
|
896
|
+
if (end < head.length || !segment.startsWith(head) || !segment.endsWith(tail)) return false;
|
|
897
|
+
let at = head.length;
|
|
898
|
+
for (const part of parts.slice(1, -1)) {
|
|
899
|
+
const found = segment.indexOf(part, at);
|
|
900
|
+
if (found === -1 || found + part.length > end) return false;
|
|
901
|
+
at = found + part.length;
|
|
902
|
+
}
|
|
903
|
+
return true;
|
|
904
|
+
};
|
|
905
|
+
/**
|
|
906
|
+
* Whether one segment matches a wildcard segment's characters, `*` and `?` within it: in time
|
|
907
|
+
* linear in each, since a `*` reaches everything from the first place it can start.
|
|
908
|
+
*/
|
|
909
|
+
const wildMatches = (pattern, segment) => {
|
|
910
|
+
let reach = new Uint8Array(segment.length + 1);
|
|
911
|
+
let next = new Uint8Array(segment.length + 1);
|
|
912
|
+
reach[0] = 1;
|
|
913
|
+
for (const character of pattern) {
|
|
914
|
+
next.fill(0);
|
|
915
|
+
if (character === "*") {
|
|
916
|
+
const first = reach.indexOf(1);
|
|
917
|
+
if (first !== -1) next.fill(1, first);
|
|
918
|
+
} else for (let j = 0; j < segment.length; j++) if (reach[j] === 1 && (character === "?" || character === segment[j])) next[j + 1] = 1;
|
|
919
|
+
[reach, next] = [next, reach];
|
|
920
|
+
if (reach.indexOf(1) === -1) return false;
|
|
921
|
+
}
|
|
922
|
+
return reach[segment.length] === 1;
|
|
923
|
+
};
|
|
924
|
+
/** Whether one segment matches a wildcard segment. */
|
|
925
|
+
const wildSegmentMatches = (wild, segment) => wild.parts === void 0 ? wildMatches(wild.chars, Array.from(segment)) : partsMatch(wild.parts, segment);
|
|
926
|
+
/** Whether a path's segments match a compiled pattern's. */
|
|
927
|
+
const segmentsMatch = (pattern, path) => {
|
|
928
|
+
const width = path.length + 1;
|
|
929
|
+
const tried = new Uint8Array((pattern.length + 1) * width);
|
|
930
|
+
const from = (p, s) => {
|
|
931
|
+
const kept = tried[p * width + s];
|
|
932
|
+
if (kept !== 0) return kept === 1;
|
|
933
|
+
const segment = pattern[p];
|
|
934
|
+
const here = path[s];
|
|
935
|
+
let matched;
|
|
936
|
+
if (segment === void 0) matched = s === path.length;
|
|
937
|
+
else if (segment.kind === "any") matched = from(p + 1, s) || s < path.length && from(p, s + 1);
|
|
938
|
+
else if (here === void 0) matched = false;
|
|
939
|
+
else if (segment.kind === "literal") matched = segment.text === here && from(p + 1, s + 1);
|
|
940
|
+
else matched = wildSegmentMatches(segment, here) && from(p + 1, s + 1);
|
|
941
|
+
tried[p * width + s] = matched ? 1 : 2;
|
|
942
|
+
return matched;
|
|
943
|
+
};
|
|
944
|
+
return from(0, 0);
|
|
945
|
+
};
|
|
946
|
+
/**
|
|
947
|
+
* Whether a path matches any of the patterns, each compiled once. The shapes gates use most for
|
|
948
|
+
* a stack's files are read straight from the path's segments, as the general match would read
|
|
949
|
+
* them: `**\/name` is its last segment, `**\/name/**` any segment, and `**\/name/**\/*` any
|
|
950
|
+
* folder it's in.
|
|
951
|
+
*/
|
|
952
|
+
const pathMatcher = (patterns) => {
|
|
953
|
+
const names = /* @__PURE__ */ new Set();
|
|
954
|
+
const segments = /* @__PURE__ */ new Set();
|
|
955
|
+
const folders = /* @__PURE__ */ new Set();
|
|
956
|
+
const lastWild = [];
|
|
957
|
+
const general = [];
|
|
958
|
+
for (const pattern of patterns) {
|
|
959
|
+
const compiled = pattern.split("/").map(segmentOf);
|
|
960
|
+
const [first, second, third, fourth] = compiled;
|
|
961
|
+
const anyFirst = first?.kind === "any";
|
|
962
|
+
if (anyFirst && compiled.length === 2 && second?.kind === "literal") names.add(second.text);
|
|
963
|
+
else if (anyFirst && compiled.length === 2 && second?.kind === "wild") lastWild.push(second);
|
|
964
|
+
else if (anyFirst && compiled.length === 3 && second?.kind === "literal" && third?.kind === "any") segments.add(second.text);
|
|
965
|
+
else if (anyFirst && compiled.length === 4 && second?.kind === "literal" && third?.kind === "any" && fourth?.kind === "wild" && fourth.chars.length === 1 && fourth.chars[0] === "*") folders.add(second.text);
|
|
966
|
+
else general.push(compiled);
|
|
967
|
+
}
|
|
968
|
+
return (path) => {
|
|
969
|
+
const parts = path.split("/");
|
|
970
|
+
const last = parts.length - 1;
|
|
971
|
+
const name = parts[last] ?? "";
|
|
972
|
+
return names.has(name) || parts.some((part, index) => segments.has(part) || index < last && folders.has(part)) || lastWild.some((wild) => wildSegmentMatches(wild, name)) || general.some((pattern) => segmentsMatch(pattern, parts));
|
|
973
|
+
};
|
|
974
|
+
};
|
|
975
|
+
//#endregion
|
|
976
|
+
//#region src/gate.ts
|
|
977
|
+
/** What the gate is given, from the version's summary and the hosts of the change's trees. */
|
|
978
|
+
const contextOf = (summary, trees) => {
|
|
979
|
+
const paths = summary.files.flatMap((file) => file.from === null ? [file.path] : [file.path, file.from]);
|
|
980
|
+
return {
|
|
981
|
+
change: {
|
|
982
|
+
files: summary.files,
|
|
983
|
+
complete: summary.complete,
|
|
984
|
+
permissions: [...new Set(summary.files.flatMap((file) => file.permissions))].sort(),
|
|
985
|
+
lines: {
|
|
986
|
+
added: summary.size.added,
|
|
987
|
+
removed: summary.size.deleted
|
|
988
|
+
},
|
|
989
|
+
touches: (...patterns) => paths.some(pathMatcher(patterns)),
|
|
990
|
+
only: (...patterns) => paths.length > 0 && paths.every(pathMatcher(patterns)),
|
|
991
|
+
author: summary.author,
|
|
992
|
+
before: treeOf(trees.before),
|
|
993
|
+
after: treeOf(trees.after)
|
|
994
|
+
},
|
|
995
|
+
plan: summary.plan
|
|
996
|
+
};
|
|
997
|
+
};
|
|
998
|
+
/** The most needs the gate may return, as `maxGateNeeds` in `@ref0/contracts` says. */
|
|
999
|
+
const maxNeeds = 128;
|
|
1000
|
+
/** How deep the gate's lists may nest. */
|
|
1001
|
+
const maxDepth = 16;
|
|
1002
|
+
/** What the gate returned, flattened: each need the SDK made, or a throw for anything else. */
|
|
1003
|
+
const flatten = (returned) => {
|
|
1004
|
+
const needs = [];
|
|
1005
|
+
const walk = (list, depth) => {
|
|
1006
|
+
if (!isList(list)) throw new TypeError("The gate returns a list of needs");
|
|
1007
|
+
if (depth > maxDepth) throw new TypeError(`The gate's lists nest past ${maxDepth} deep`);
|
|
1008
|
+
for (const item of list) {
|
|
1009
|
+
if (item === false || item === null || item === void 0) continue;
|
|
1010
|
+
if (isList(item)) {
|
|
1011
|
+
walk(item, depth + 1);
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const made = madeOf(item);
|
|
1015
|
+
if (made === void 0) throw new TypeError("The gate returned something that isn't a need: return checks, approval() and review()");
|
|
1016
|
+
if (needs.length >= maxNeeds) throw new TypeError(`The gate returned past ${maxNeeds} needs`);
|
|
1017
|
+
needs.push({
|
|
1018
|
+
need: made.need,
|
|
1019
|
+
stack: made.stack
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
};
|
|
1023
|
+
walk(returned, 0);
|
|
1024
|
+
return needs;
|
|
1025
|
+
};
|
|
1026
|
+
/** The gate: given a change, what it needs. Export it as `.ref0/gate.ts`'s default. */
|
|
1027
|
+
const gate = (decide) => ({
|
|
1028
|
+
decide,
|
|
1029
|
+
"~ref0": {
|
|
1030
|
+
version: 1,
|
|
1031
|
+
needs: async (input, trees) => ({ needs: flatten(await decide(contextOf(input.summary, trees))) })
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
/**
|
|
1035
|
+
* An approval from someone holding that repo permission: `approval('billing')`. Admins hold every
|
|
1036
|
+
* permission.
|
|
1037
|
+
*/
|
|
1038
|
+
const approval = (permission) => needFrom({
|
|
1039
|
+
kind: "permission",
|
|
1040
|
+
name: permission
|
|
1041
|
+
});
|
|
1042
|
+
/** A whole review covering the change, with no blocking finding open. ref0's floor has one. */
|
|
1043
|
+
const review = () => needFrom({ kind: "review" });
|
|
1044
|
+
/**
|
|
1045
|
+
* A check's need that a local run meets too: `local(unit())`. A run of the same check-run
|
|
1046
|
+
* identity, the check's code, its params and the version's expected tree, run with `ref0 run
|
|
1047
|
+
* --local` on an attached machine of the thread's and reported, meets each of its parts, and ref0
|
|
1048
|
+
* runs none of them on the change, unless another need for the part doesn't take local. ref0's
|
|
1049
|
+
* failure of a part still stands, the merge queue's tip still runs the check as ref0's own, and
|
|
1050
|
+
* when ref0's runs of a local success's identity fail twice, its machine stops answering the
|
|
1051
|
+
* check until an admin restores it. It takes a check's need, never a check or another need.
|
|
1052
|
+
*/
|
|
1053
|
+
const local = (need) => {
|
|
1054
|
+
const made = madeOf(need)?.need;
|
|
1055
|
+
if (made?.kind !== "check") throw new TypeError("local() wraps a check's need, as local(unit()) does: never approval(), review() or a check");
|
|
1056
|
+
return needFrom({
|
|
1057
|
+
...made,
|
|
1058
|
+
local: true
|
|
1059
|
+
});
|
|
1060
|
+
};
|
|
1061
|
+
//#endregion
|
|
1062
|
+
//#region src/passkey.ts
|
|
1063
|
+
/**
|
|
1064
|
+
* ref0 asked nobody, and why, as `PasskeyRefusal` in `@ref0/contracts` says. A refusal is
|
|
1065
|
+
* kept as its step's result, so a replay throws it again and asks nobody; `agent` is refused
|
|
1066
|
+
* before any step.
|
|
1067
|
+
*/
|
|
1068
|
+
var PasskeyRequestRefused = class extends Error {
|
|
1069
|
+
why;
|
|
1070
|
+
constructor(why) {
|
|
1071
|
+
super(`ref0 asked nobody for a passkey: ${why}`);
|
|
1072
|
+
this.name = "PasskeyRequestRefused";
|
|
1073
|
+
this.why = why;
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
/** The longest one wait for a request's end lasts before ref0's record is read again. */
|
|
1077
|
+
const longestWait = 36e5;
|
|
1078
|
+
/** What a step's code asks, as a digest: who, the statement and the deadline. */
|
|
1079
|
+
const askedDigest = async (options) => await digestOf(JSON.stringify([
|
|
1080
|
+
options.from,
|
|
1081
|
+
options.statement.strings,
|
|
1082
|
+
options.statement.values,
|
|
1083
|
+
options.deadline
|
|
1084
|
+
]));
|
|
1085
|
+
/**
|
|
1086
|
+
* The run whose code asks, refused in words inside a step's callback, since one step can't hold
|
|
1087
|
+
* another, and outside a workflow run.
|
|
1088
|
+
*/
|
|
1089
|
+
const runOf = () => {
|
|
1090
|
+
if (inStep.getStore() !== void 0) throw new Error("passkey.run is a step of its own: call it outside step.do's callback");
|
|
1091
|
+
const run = inRun.getStore();
|
|
1092
|
+
if (run === void 0) throw new Error("ref0 asks for a passkey from a workflow's code only");
|
|
1093
|
+
return run;
|
|
1094
|
+
};
|
|
1095
|
+
/** What `passkey.run` answers, from ref0's read of a request that ended. */
|
|
1096
|
+
const answerOf$1 = (answered) => {
|
|
1097
|
+
const answer = {
|
|
1098
|
+
approved: answered.approved,
|
|
1099
|
+
at: answered.at
|
|
1100
|
+
};
|
|
1101
|
+
if (answered.by !== void 0) answer.by = answered.by;
|
|
1102
|
+
return answer;
|
|
1103
|
+
};
|
|
1104
|
+
/**
|
|
1105
|
+
* Asks for a passkey's yes to `statement`, then waits for the answer. The request is one step,
|
|
1106
|
+
* named `ref0: passkey` and told from the run's others by its count, so it's reached in the order
|
|
1107
|
+
* the code asks: a replay waits on the same request, and one that asks something else there
|
|
1108
|
+
* throws `changed`. Then each round reads the request in a step and waits for its end, until its
|
|
1109
|
+
* deadline or an hour, whichever is first, and a wait that times out reads again.
|
|
1110
|
+
*/
|
|
1111
|
+
const run = async (step, options) => {
|
|
1112
|
+
const { sink } = runOf();
|
|
1113
|
+
const { from } = options;
|
|
1114
|
+
if (from !== "accountable" && "agent" in from) throw new PasskeyRequestRefused("agent");
|
|
1115
|
+
const asking = step.do("ref0: passkey", ref0Steps, async (context) => ({
|
|
1116
|
+
...await settle(sink.askPasskey({
|
|
1117
|
+
place: {
|
|
1118
|
+
step: context.step.name,
|
|
1119
|
+
count: context.step.count
|
|
1120
|
+
},
|
|
1121
|
+
from,
|
|
1122
|
+
statement: options.statement,
|
|
1123
|
+
deadline: options.deadline
|
|
1124
|
+
})),
|
|
1125
|
+
asked: await askedDigest(options)
|
|
1126
|
+
}));
|
|
1127
|
+
const [answer, asked] = await Promise.all([asking, askedDigest(options)]);
|
|
1128
|
+
if (answer.asked !== asked) throw new PasskeyRequestRefused("changed");
|
|
1129
|
+
if (answer.kind === "refused") throw new PasskeyRequestRefused(answer.why);
|
|
1130
|
+
for (let round = 1;; round += 1) {
|
|
1131
|
+
const state = await step.do(`ref0: passkey ${answer.request} ${round}`, ref0Steps, async () => await settle(sink.passkeyState({ request: answer.request })));
|
|
1132
|
+
if (state.state === "refused") throw new Error(`ref0 read ${answer.request} as ${state.why}`);
|
|
1133
|
+
if (state.state === "answered") return answerOf$1(state);
|
|
1134
|
+
const timeout = Math.min(Math.max(state.expiresAt - state.readAt + 1e3, 1e3), longestWait);
|
|
1135
|
+
await step.waitForEvent(`ref0: passkey wait ${answer.request} ${round}`, {
|
|
1136
|
+
type: `passkey-${answer.request}`,
|
|
1137
|
+
timeout
|
|
1138
|
+
}).catch(() => {});
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
/**
|
|
1142
|
+
* A check's need whose result counts only once a person said yes to it with their passkey:
|
|
1143
|
+
* `passkey.check(local(e2e()))`, or `passkey.check(e2e(), { from: { permission: 'qa' } })` to ask
|
|
1144
|
+
* someone else. For each part, ref0 asks for a yes to a statement that names the run that meets it,
|
|
1145
|
+
* its check-run identity, its conclusion and its outputs' hash, and the part waits for that yes. A
|
|
1146
|
+
* newer run that counts asks again. A no or the request's day ending waits for a newer local run;
|
|
1147
|
+
* on a run of ref0's, or from a person who can't be asked, the check needs an admin's override.
|
|
1148
|
+
* When ref0's runs of an identity someone said yes over fail twice, their yes stops answering the
|
|
1149
|
+
* check until an admin restores them. It takes a check's need, never a check or another need, and wraps it
|
|
1150
|
+
* once, before or after `local`.
|
|
1151
|
+
*/
|
|
1152
|
+
const check$1 = (need, options = {}) => {
|
|
1153
|
+
const made = madeOf(need)?.need;
|
|
1154
|
+
if (made?.kind !== "check") throw new TypeError("passkey.check() wraps a check's need, as passkey.check(unit()) does: never approval(), review() or a check");
|
|
1155
|
+
if (made.passkey !== void 0) throw new TypeError("passkey.check() wraps a need once: return another need to ask someone else too");
|
|
1156
|
+
return needFrom({
|
|
1157
|
+
...made,
|
|
1158
|
+
passkey: { from: options.from ?? "accountable" }
|
|
1159
|
+
});
|
|
1160
|
+
};
|
|
1161
|
+
const passkey = {
|
|
1162
|
+
run,
|
|
1163
|
+
check: check$1
|
|
1164
|
+
};
|
|
1165
|
+
//#endregion
|
|
1166
|
+
//#region src/browse.ts
|
|
1167
|
+
/**
|
|
1168
|
+
* Where ref0 loads its browser module, beside the prelude, for a check that imports it: the path
|
|
1169
|
+
* contracts names, written here since the SDK's root takes only types from contracts.
|
|
1170
|
+
*/
|
|
1171
|
+
const browserModule = "/ref0-browser.js";
|
|
1172
|
+
const loaded = async () => {
|
|
1173
|
+
try {
|
|
1174
|
+
return await import(
|
|
1175
|
+
/* @vite-ignore */
|
|
1176
|
+
browserModule
|
|
1177
|
+
);
|
|
1178
|
+
} catch {
|
|
1179
|
+
throw new Error("This check doesn't import @ref0/sdk/browser: import `expect` from @ref0/sdk/browser");
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
/** The most the reports' flush and the disconnect may each take, which only help the close. */
|
|
1183
|
+
const browseCleanupMs = 5e3;
|
|
1184
|
+
/**
|
|
1185
|
+
* Runs a cleanup step, ignoring its failure, and, given `ms`, giving up on it after that long, so
|
|
1186
|
+
* a stalled step holds up none after it.
|
|
1187
|
+
*/
|
|
1188
|
+
const quietly = async (step, ms) => {
|
|
1189
|
+
let timer;
|
|
1190
|
+
try {
|
|
1191
|
+
const stalled = ms === void 0 ? [] : [new Promise((given) => {
|
|
1192
|
+
timer = setTimeout(given, ms);
|
|
1193
|
+
})];
|
|
1194
|
+
await Promise.race([step(), ...stalled]);
|
|
1195
|
+
} catch {} finally {
|
|
1196
|
+
clearTimeout(timer);
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
/**
|
|
1200
|
+
* Lends `fn` a browser for `session`, and closes it however `fn` ends: the reports flushed and the
|
|
1201
|
+
* browser disconnected, each within `cleanupMs`, then the lease's close, durable in ref0, always
|
|
1202
|
+
* asked, and the lease disposed after it whatever happens.
|
|
1203
|
+
*/
|
|
1204
|
+
const browseWith = async (opener, session, fn, load = loaded, cleanupMs = browseCleanupMs) => {
|
|
1205
|
+
const browser = await load();
|
|
1206
|
+
const lease = await opener.browse(session);
|
|
1207
|
+
try {
|
|
1208
|
+
const opened = await browser.__ref0Open(lease);
|
|
1209
|
+
try {
|
|
1210
|
+
return await fn(opened.browser);
|
|
1211
|
+
} finally {
|
|
1212
|
+
await quietly(opened.flush, cleanupMs);
|
|
1213
|
+
await quietly(opened.disconnect, cleanupMs);
|
|
1214
|
+
}
|
|
1215
|
+
} finally {
|
|
1216
|
+
try {
|
|
1217
|
+
await quietly(async () => await lease.close());
|
|
1218
|
+
} finally {
|
|
1219
|
+
lease[Symbol.dispose]?.();
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
//#endregion
|
|
1224
|
+
//#region src/sandbox.ts
|
|
1225
|
+
/** A sandbox step's timeout and retries unless its options say. */
|
|
1226
|
+
const defaultTimeout = "10 minutes";
|
|
1227
|
+
const defaultRetries = 2;
|
|
1228
|
+
const durationUnits = /* @__PURE__ */ new Map([
|
|
1229
|
+
["second", 1e3],
|
|
1230
|
+
["minute", 6e4],
|
|
1231
|
+
["hour", 36e5]
|
|
1232
|
+
]);
|
|
1233
|
+
/** A duration's milliseconds: `'10 minutes'` is 600,000. */
|
|
1234
|
+
const millisOf = (text) => {
|
|
1235
|
+
const match = /^(\d+(?:\.\d+)?) (second|minute|hour)s?$/.exec(text);
|
|
1236
|
+
const unit = match?.[2] === void 0 ? void 0 : durationUnits.get(match[2]);
|
|
1237
|
+
if (match === null || unit === void 0) throw new Error(`Not a duration: ${text}`);
|
|
1238
|
+
return Math.ceil(Number(match[1]) * unit);
|
|
1239
|
+
};
|
|
1240
|
+
/**
|
|
1241
|
+
* A command's answer in the sandbox, or the failure of one that gave none, and its push. It
|
|
1242
|
+
* serves nothing.
|
|
1243
|
+
*/
|
|
1244
|
+
const execIn = (at, sandbox) => ({
|
|
1245
|
+
push: async ({ message }) => {
|
|
1246
|
+
const place = sandboxPlace()?.place;
|
|
1247
|
+
if (place === void 0) throw new Error("A sandbox pushes only inside its step");
|
|
1248
|
+
return await settle(at.sink.pushSandbox({
|
|
1249
|
+
place,
|
|
1250
|
+
attempt: at.attempt,
|
|
1251
|
+
sandbox,
|
|
1252
|
+
message
|
|
1253
|
+
}));
|
|
1254
|
+
},
|
|
1255
|
+
exec: async (command, options = {}) => {
|
|
1256
|
+
const answer = await settle(at.sink.execSandbox({
|
|
1257
|
+
sandbox,
|
|
1258
|
+
command,
|
|
1259
|
+
...options.cwd !== void 0 && { cwd: options.cwd },
|
|
1260
|
+
...options.env !== void 0 && { env: { ...options.env } },
|
|
1261
|
+
...options.timeout !== void 0 && { timeoutMs: millisOf(options.timeout) }
|
|
1262
|
+
}));
|
|
1263
|
+
if (answer.kind === "failed") throw new Error(`The command gave no answer, ${answer.why}: ${answer.message}`);
|
|
1264
|
+
return {
|
|
1265
|
+
exitCode: answer.exitCode,
|
|
1266
|
+
stdout: answer.stdout,
|
|
1267
|
+
stderr: answer.stderr,
|
|
1268
|
+
startedAt: answer.startedAt,
|
|
1269
|
+
durationMs: answer.durationMs
|
|
1270
|
+
};
|
|
1271
|
+
},
|
|
1272
|
+
serve: async () => await Promise.reject(/* @__PURE__ */ new Error("A workflow's sandbox serves nothing: `serve` is for checks")),
|
|
1273
|
+
browse: async () => await Promise.reject(/* @__PURE__ */ new Error("A workflow's sandbox browses nothing: `browse` is for checks"))
|
|
1274
|
+
});
|
|
1275
|
+
/**
|
|
1276
|
+
* Opens a workflow's sandbox for this attempt of its step, gives it to `work`, and closes it when
|
|
1277
|
+
* `work` ends or throws. A step's retry opens a new one. A refusal throws, since `work` has
|
|
1278
|
+
* nothing to run in.
|
|
1279
|
+
*/
|
|
1280
|
+
const runInWorkflow = async (at, sandbox, options, work) => {
|
|
1281
|
+
if (options.paths !== void 0) throw new Error("A workflow's sandbox holds the whole tree: `paths` is for checks");
|
|
1282
|
+
if (options.tree === void 0) throw new Error("A workflow's sandbox needs a tree: `'main'` or a fork's answer");
|
|
1283
|
+
const opened = await settle(at.sink.openSandbox({
|
|
1284
|
+
place: at.place,
|
|
1285
|
+
attempt: at.attempt,
|
|
1286
|
+
sandbox,
|
|
1287
|
+
tree: options.tree,
|
|
1288
|
+
...options.size !== void 0 && { size: options.size },
|
|
1289
|
+
timeoutMs: millisOf(options.timeout ?? defaultTimeout)
|
|
1290
|
+
}));
|
|
1291
|
+
if (opened.kind === "refused") throw new Error(`ref0 refused the sandbox ${sandbox}: ${opened.why}`);
|
|
1292
|
+
try {
|
|
1293
|
+
return await work(execIn(at, opened.sandbox));
|
|
1294
|
+
} finally {
|
|
1295
|
+
await settle(at.sink.closeSandbox(opened.sandbox));
|
|
1296
|
+
}
|
|
1297
|
+
};
|
|
1298
|
+
/**
|
|
1299
|
+
* What an import of `.ref0/sandboxes/<name>.ts` is, as ref0's bundler writes it. Its `run` is
|
|
1300
|
+
* `step.do(name, { timeout, retries })` around opening the sandbox once for each attempt, `work`,
|
|
1301
|
+
* and closing it in `finally`: a replay returns the step's kept output and opens nothing. In a
|
|
1302
|
+
* workflow's step, ref0's Worker opens it, through the run's sink.
|
|
1303
|
+
*/
|
|
1304
|
+
const sandboxHandle = (name) => {
|
|
1305
|
+
const run = async (step, stepName, options, work) => await step.do(stepName, {
|
|
1306
|
+
timeout: options.timeout ?? defaultTimeout,
|
|
1307
|
+
retries: {
|
|
1308
|
+
limit: options.retries ?? defaultRetries,
|
|
1309
|
+
delay: "10 seconds",
|
|
1310
|
+
backoff: "exponential"
|
|
1311
|
+
}
|
|
1312
|
+
}, async () => {
|
|
1313
|
+
const at = sandboxPlace();
|
|
1314
|
+
if (at !== void 0) return await runInWorkflow(at, name, options, work);
|
|
1315
|
+
const opener = attemptOpener();
|
|
1316
|
+
if (opener === void 0) throw new Error(`${name} opens only in a step of a check run`);
|
|
1317
|
+
if (options.tree !== void 0) throw new Error(`${name} opens in its check run's own tree: \`tree\` is for workflows`);
|
|
1318
|
+
const asked = { sandbox: name };
|
|
1319
|
+
if (options.paths !== void 0) asked.paths = [...options.paths];
|
|
1320
|
+
if (options.size !== void 0) asked.size = options.size;
|
|
1321
|
+
const { session } = await settle(opener.open(asked));
|
|
1322
|
+
try {
|
|
1323
|
+
return await work({
|
|
1324
|
+
exec: async (command, given = {}) => await settle(opener.exec(session, command, given)),
|
|
1325
|
+
push: async () => {
|
|
1326
|
+
throw new Error("A check's sandbox pushes nothing: `push` is a workflow's");
|
|
1327
|
+
},
|
|
1328
|
+
serve: async (command, given) => await settle(opener.serve(session, command, given)),
|
|
1329
|
+
browse: async (fn) => await browseWith(opener, session, fn)
|
|
1330
|
+
});
|
|
1331
|
+
} finally {
|
|
1332
|
+
await settle(opener.close(session)).catch(() => void 0);
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
return { run };
|
|
1336
|
+
};
|
|
1337
|
+
/**
|
|
1338
|
+
* A sandbox: its image, size, cached setup, and the hosts setup may reach. Export it as its
|
|
1339
|
+
* file's default, `.ref0/sandboxes/<name>.ts`, named by its file.
|
|
1340
|
+
*/
|
|
1341
|
+
const sandbox = (_spec) => sandboxHandle("this sandbox");
|
|
1342
|
+
//#endregion
|
|
1343
|
+
//#region src/start-rules.ts
|
|
1344
|
+
/** Lets a start make its run, unless another item of the rule's list refuses it. */
|
|
1345
|
+
const allow = () => ({ kind: "allow" });
|
|
1346
|
+
/** Refuses a start, in words: `` refuse(text`Ten e2e runs a day each. Reuse a result instead`) ``. */
|
|
1347
|
+
const refuse = (text) => ({
|
|
1348
|
+
kind: "refuse",
|
|
1349
|
+
text
|
|
1350
|
+
});
|
|
1351
|
+
//#endregion
|
|
1352
|
+
//#region src/workflow.ts
|
|
1353
|
+
/**
|
|
1354
|
+
* Cloudflare's `step`, with each `do` callback run in a scope that knows its step and attempt,
|
|
1355
|
+
* so an action inside it gets its place.
|
|
1356
|
+
*/
|
|
1357
|
+
const durableStep = (step, sink) => settledStep(step, (callback) => (context) => inStep.run({
|
|
1358
|
+
step: context.step.name,
|
|
1359
|
+
count: context.step.count,
|
|
1360
|
+
attempt: context.attempt,
|
|
1361
|
+
sink,
|
|
1362
|
+
calls: 0,
|
|
1363
|
+
connectionCalls: 0
|
|
1364
|
+
}, () => callback(context)));
|
|
1365
|
+
/**
|
|
1366
|
+
* ref0's actions, each sent with its place, or refused at once outside a step. A fork's place is
|
|
1367
|
+
* counted among the step's actions, in the order they're asked.
|
|
1368
|
+
*/
|
|
1369
|
+
const actionsOf = (sink) => {
|
|
1370
|
+
const placeOf = () => {
|
|
1371
|
+
const at = inStep.getStore();
|
|
1372
|
+
if (at === void 0) throw new Error("ref0's actions run inside a step, in step.do's callback, since a run's code outside one runs again on each replay");
|
|
1373
|
+
const place = {
|
|
1374
|
+
step: at.step,
|
|
1375
|
+
count: at.count,
|
|
1376
|
+
call: at.calls
|
|
1377
|
+
};
|
|
1378
|
+
at.calls += 1;
|
|
1379
|
+
return place;
|
|
1380
|
+
};
|
|
1381
|
+
const send = (request) => settle(sink.act({
|
|
1382
|
+
place: placeOf(),
|
|
1383
|
+
request
|
|
1384
|
+
}));
|
|
1385
|
+
return {
|
|
1386
|
+
comments: { create: (comment) => send({
|
|
1387
|
+
verb: "comment",
|
|
1388
|
+
...comment
|
|
1389
|
+
}) },
|
|
1390
|
+
threads: { start: (start) => send({
|
|
1391
|
+
verb: "thread.start",
|
|
1392
|
+
...start
|
|
1393
|
+
}) },
|
|
1394
|
+
forks: { create: (options = {}) => settle(sink.createFork({
|
|
1395
|
+
place: placeOf(),
|
|
1396
|
+
...options
|
|
1397
|
+
})) },
|
|
1398
|
+
pr: {
|
|
1399
|
+
needs: { add: (pullRequest, need, options = {}) => {
|
|
1400
|
+
const made = madeOf(need);
|
|
1401
|
+
if (made === void 0) throw new TypeError("pr.needs.add takes a need: a check's handle called, approval() or review()");
|
|
1402
|
+
return send({
|
|
1403
|
+
verb: "pr.needs.add",
|
|
1404
|
+
pullRequest,
|
|
1405
|
+
need: made.need,
|
|
1406
|
+
...options
|
|
1407
|
+
});
|
|
1408
|
+
} },
|
|
1409
|
+
open: (options) => {
|
|
1410
|
+
const attempt = inStep.getStore()?.attempt ?? 1;
|
|
1411
|
+
return settle(sink.openPullRequest({
|
|
1412
|
+
place: placeOf(),
|
|
1413
|
+
attempt,
|
|
1414
|
+
...options
|
|
1415
|
+
}));
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
};
|
|
1419
|
+
};
|
|
1420
|
+
/** What a call came to, in a few words, for the run's trace. */
|
|
1421
|
+
const callWords = (answer) => answer.kind === "answered" ? "answered" : `refused, ${answer.why}`;
|
|
1422
|
+
/**
|
|
1423
|
+
* The connections the workflow declared, each operation sent with its place, inside a step, and
|
|
1424
|
+
* written to the run's trace once answered: which connection and operation, and what it came
|
|
1425
|
+
* to. Never what it answered.
|
|
1426
|
+
*/
|
|
1427
|
+
const connectionsOf = (sink, declared) => {
|
|
1428
|
+
const send = async (connection, input) => {
|
|
1429
|
+
const at = inStep.getStore();
|
|
1430
|
+
if (at === void 0) throw new Error("ref0's calls run inside a step, in step.do's callback, since a run's code outside one runs again on each replay");
|
|
1431
|
+
const place = {
|
|
1432
|
+
step: at.step,
|
|
1433
|
+
count: at.count,
|
|
1434
|
+
call: at.connectionCalls
|
|
1435
|
+
};
|
|
1436
|
+
at.connectionCalls += 1;
|
|
1437
|
+
const answer = await settle(sink.callConnection({
|
|
1438
|
+
place,
|
|
1439
|
+
connection,
|
|
1440
|
+
method: "getIssue",
|
|
1441
|
+
input
|
|
1442
|
+
}));
|
|
1443
|
+
console.log(`ref0: ${connection}.getIssue ${callWords(answer)}`);
|
|
1444
|
+
return answer;
|
|
1445
|
+
};
|
|
1446
|
+
const entries = Object.entries(declared ?? {}).map(([connection, methods]) => [connection, methods.includes("getIssue") ? { getIssue: async (input) => await send(connection, input) } : {}]);
|
|
1447
|
+
return Object.fromEntries(entries);
|
|
1448
|
+
};
|
|
1449
|
+
/**
|
|
1450
|
+
* A call's input, as a called workflow's `input` parses it, and its title from that: see
|
|
1451
|
+
* `WorkflowOwn['called']`.
|
|
1452
|
+
*/
|
|
1453
|
+
const calledOf = async (spec, given) => {
|
|
1454
|
+
const parsed = await spec.input.safeParseAsync(given);
|
|
1455
|
+
if (!parsed.success) return {
|
|
1456
|
+
kind: "invalid",
|
|
1457
|
+
why: issueOf(parsed.error, "Its input is invalid")
|
|
1458
|
+
};
|
|
1459
|
+
const copy = paramsOf(parsed.data);
|
|
1460
|
+
if ("notJson" in copy) return {
|
|
1461
|
+
kind: "invalid",
|
|
1462
|
+
why: (copy.notJson === "" ? "Its input isn't JSON once parsed" : `Its input isn't JSON once parsed: ${copy.notJson} isn't`).slice(0, 1e3)
|
|
1463
|
+
};
|
|
1464
|
+
const title = spec.title({ input: parsed.data });
|
|
1465
|
+
if (!isText(title)) return {
|
|
1466
|
+
kind: "invalid",
|
|
1467
|
+
why: "Its title isn't text: give title a function that returns text`…`"
|
|
1468
|
+
};
|
|
1469
|
+
return {
|
|
1470
|
+
kind: "called",
|
|
1471
|
+
input: copy.params,
|
|
1472
|
+
title: {
|
|
1473
|
+
strings: [...title.strings],
|
|
1474
|
+
values: [...title.values]
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
};
|
|
1478
|
+
/** The most a called run's output may be, as JSON in UTF-8, as `@ref0/contracts` says. */
|
|
1479
|
+
const maxOutputBytes = 65536;
|
|
1480
|
+
/**
|
|
1481
|
+
* What a called run's code came to: its output as JSON holds it, or why JSON can't hold it as it
|
|
1482
|
+
* is, or past its bound, which fails the run.
|
|
1483
|
+
*/
|
|
1484
|
+
const answerOf = (output) => {
|
|
1485
|
+
if (output === void 0) return { kind: "returned" };
|
|
1486
|
+
const copy = outputOf(output);
|
|
1487
|
+
if ("notJson" in copy) return {
|
|
1488
|
+
kind: "invalid output",
|
|
1489
|
+
why: copy.notJson === "" ? "Its output isn't JSON" : `Its output isn't JSON: ${copy.notJson} isn't`.slice(0, 1e3)
|
|
1490
|
+
};
|
|
1491
|
+
const bytes = new TextEncoder().encode(JSON.stringify(copy.json)).byteLength;
|
|
1492
|
+
if (bytes > maxOutputBytes) return {
|
|
1493
|
+
kind: "invalid output",
|
|
1494
|
+
why: `Its output is ${bytes} bytes of JSON, past ${maxOutputBytes}: return a name or a key, not the data`
|
|
1495
|
+
};
|
|
1496
|
+
return {
|
|
1497
|
+
kind: "returned",
|
|
1498
|
+
output: copy.json
|
|
1499
|
+
};
|
|
1500
|
+
};
|
|
1501
|
+
/** The run's scope, for any check or called workflow its code runs through a handle. */
|
|
1502
|
+
const inRunOf = (sink, run) => inRun.run({
|
|
1503
|
+
sink,
|
|
1504
|
+
waiters: /* @__PURE__ */ new Map()
|
|
1505
|
+
}, run);
|
|
1506
|
+
const eventWorkflowOf = (spec) => ({ "~ref0": {
|
|
1507
|
+
version: 1,
|
|
1508
|
+
run: async (input, step, sink) => {
|
|
1509
|
+
if ("input" in input) throw new Error("ref0 ran an event workflow on a call's input");
|
|
1510
|
+
const event = input.event;
|
|
1511
|
+
await inRunOf(sink, () => spec.run({
|
|
1512
|
+
event,
|
|
1513
|
+
sessions: input.sessions,
|
|
1514
|
+
step: durableStep(step, sink),
|
|
1515
|
+
ref0: actionsOf(sink),
|
|
1516
|
+
connections: connectionsOf(sink, spec.permissions.connections)
|
|
1517
|
+
}));
|
|
1518
|
+
return { kind: "returned" };
|
|
1519
|
+
}
|
|
1520
|
+
} });
|
|
1521
|
+
const calledWorkflowOf = (spec) => Object.assign(workflowHandle("this workflow"), { "~ref0": {
|
|
1522
|
+
version: 1,
|
|
1523
|
+
run: async (input, step, sink) => {
|
|
1524
|
+
if (!("input" in input)) throw new Error("ref0 ran a called workflow on an event");
|
|
1525
|
+
const parsed = input.input;
|
|
1526
|
+
const output = await inRunOf(sink, () => spec.run({
|
|
1527
|
+
input: parsed,
|
|
1528
|
+
step: durableStep(step, sink),
|
|
1529
|
+
ref0: actionsOf(sink),
|
|
1530
|
+
connections: connectionsOf(sink, spec.permissions.connections)
|
|
1531
|
+
}));
|
|
1532
|
+
return answerOf(output);
|
|
1533
|
+
},
|
|
1534
|
+
called: async (input) => await calledOf(spec, input)
|
|
1535
|
+
} });
|
|
1536
|
+
function workflow(spec) {
|
|
1537
|
+
return spec.input === void 0 ? eventWorkflowOf(spec) : calledWorkflowOf(spec);
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* Words to send, as a tagged template: `` text`Started a docs thread for ${event.pullRequest}` ``.
|
|
1541
|
+
* The template's own words are kept as written, and ref0 passes only templates written in the
|
|
1542
|
+
* workflow's code; each value is quoted when it's shown, so nothing from an event reads as an
|
|
1543
|
+
* instruction.
|
|
1544
|
+
*/
|
|
1545
|
+
const text = (strings, ...values) => ({
|
|
1546
|
+
strings: [...strings],
|
|
1547
|
+
values: values.map(String)
|
|
1548
|
+
});
|
|
1549
|
+
//#endregion
|
|
1550
|
+
export { AskRefused, CalledRunFailed, CheckRunRefused, PasskeyRequestRefused, allow, approval, ask, check, gate, local, oneOf, passkey, refuse, review, sandbox, text, workflow };
|
|
1551
|
+
|
|
1552
|
+
//# sourceMappingURL=index.mjs.map
|