@condition-sh/core 2026.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +5 -0
- package/index.js +444 -0
- package/local.js +529 -0
- package/package.json +27 -0
- package/types/extract.d.ts +6 -0
- package/types/index.d.ts +114 -0
- package/types/local.d.ts +3 -0
- package/types/remote-inbox.d.ts +46 -0
- package/types/schema.d.ts +15 -0
- package/types/store.d.ts +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Rad Soft, Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
// packages/core/src/index.ts
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
// packages/core/src/extract.ts
|
|
6
|
+
var linkPattern = /https?:\/\/[^\s"'<>)\]]+/g;
|
|
7
|
+
var codePattern = /(?<![\d-])\d{4,8}(?![\d-])/g;
|
|
8
|
+
var unique = (values, limit) => [...new Set(values)].slice(0, limit);
|
|
9
|
+
function trimPunctuation(link) {
|
|
10
|
+
let end = link.length;
|
|
11
|
+
while (end > 0 && ".,;:".includes(link[end - 1]))
|
|
12
|
+
end--;
|
|
13
|
+
return link.slice(0, end);
|
|
14
|
+
}
|
|
15
|
+
function stripTags(html) {
|
|
16
|
+
let out = "";
|
|
17
|
+
let inTag = false;
|
|
18
|
+
for (const character of html) {
|
|
19
|
+
if (character === "<")
|
|
20
|
+
inTag = true;
|
|
21
|
+
else if (character === ">") {
|
|
22
|
+
inTag = false;
|
|
23
|
+
out += " ";
|
|
24
|
+
} else if (!inTag)
|
|
25
|
+
out += character;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function decodeEntities(value) {
|
|
30
|
+
return value.replaceAll("&", "&").replaceAll(""", '"').replaceAll("'", "'");
|
|
31
|
+
}
|
|
32
|
+
function extract(message) {
|
|
33
|
+
const text = `${message.bodyText}
|
|
34
|
+
${decodeEntities(message.bodyHtml)}`;
|
|
35
|
+
const links = unique((text.match(linkPattern) ?? []).map(trimPunctuation), 20);
|
|
36
|
+
const visible = `${message.subject}
|
|
37
|
+
${message.bodyText || stripTags(message.bodyHtml)}`;
|
|
38
|
+
const codes = unique((visible.replace(linkPattern, " ").match(codePattern) ?? []).filter((code) => !/^(19|20)\d{2}$/.test(code)), 10);
|
|
39
|
+
return { ...message, links, codes };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// packages/core/src/schema.ts
|
|
43
|
+
function typeOf(value) {
|
|
44
|
+
if (value === null)
|
|
45
|
+
return "null";
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return "array";
|
|
48
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
49
|
+
return "integer";
|
|
50
|
+
return typeof value;
|
|
51
|
+
}
|
|
52
|
+
function matchesType(expected, value) {
|
|
53
|
+
const actual = typeOf(value);
|
|
54
|
+
return expected === actual || expected === "number" && actual === "integer";
|
|
55
|
+
}
|
|
56
|
+
function checkBounds(schema, value, path, errors) {
|
|
57
|
+
if (typeof value === "number") {
|
|
58
|
+
if (schema.minimum !== undefined && value < schema.minimum)
|
|
59
|
+
errors.push(`${path} must be at least ${schema.minimum}`);
|
|
60
|
+
if (schema.maximum !== undefined && value > schema.maximum)
|
|
61
|
+
errors.push(`${path} must be at most ${schema.maximum}`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
if (schema.minLength !== undefined && value.length < schema.minLength)
|
|
65
|
+
errors.push(`${path} is shorter than ${schema.minLength}`);
|
|
66
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength)
|
|
67
|
+
errors.push(`${path} is longer than ${schema.maxLength}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function checkObject(schema, value, path, errors) {
|
|
71
|
+
for (const key of schema.required ?? [])
|
|
72
|
+
if (!(key in value))
|
|
73
|
+
errors.push(`${path}.${key} is required`);
|
|
74
|
+
for (const [key, child] of Object.entries(value)) {
|
|
75
|
+
const property = schema.properties?.[key];
|
|
76
|
+
if (property)
|
|
77
|
+
check(property, child, `${path}.${key}`, errors);
|
|
78
|
+
else if (schema.additionalProperties === false)
|
|
79
|
+
errors.push(`${path}.${key} is not allowed`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function check(schema, value, path, errors) {
|
|
83
|
+
if (schema.type && !matchesType(schema.type, value)) {
|
|
84
|
+
errors.push(`${path} must be ${schema.type}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (schema.enum && !schema.enum.some((option) => JSON.stringify(option) === JSON.stringify(value))) {
|
|
88
|
+
errors.push(`${path} must be one of ${schema.enum.map((option) => JSON.stringify(option)).join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
checkBounds(schema, value, path, errors);
|
|
91
|
+
if (typeOf(value) === "object")
|
|
92
|
+
checkObject(schema, value, path, errors);
|
|
93
|
+
if (Array.isArray(value) && schema.items)
|
|
94
|
+
value.forEach((item, index) => check(schema.items, item, `${path}[${index}]`, errors));
|
|
95
|
+
}
|
|
96
|
+
function validateInput(schema, value) {
|
|
97
|
+
const errors = [];
|
|
98
|
+
check(schema, value, "input", errors);
|
|
99
|
+
return errors;
|
|
100
|
+
}
|
|
101
|
+
// packages/core/src/remote-inbox.ts
|
|
102
|
+
import { createHash } from "node:crypto";
|
|
103
|
+
var actorPattern = /^[a-z][a-z0-9-]{0,15}$/;
|
|
104
|
+
var legacyInboxDomain = "inbox.condition.sh";
|
|
105
|
+
function inboxAlias(runId) {
|
|
106
|
+
const hex = createHash("sha256").update(runId).digest("hex").slice(0, 12);
|
|
107
|
+
return (parseInt(hex, 16) % 36 ** 6).toString(36).padStart(6, "0");
|
|
108
|
+
}
|
|
109
|
+
function inboxAddress(actor, runId, domain) {
|
|
110
|
+
if (!actorPattern.test(actor))
|
|
111
|
+
throw new Error(`invalid inbox actor ${actor}`);
|
|
112
|
+
return `${actor}.${domain === legacyInboxDomain ? runId : inboxAlias(runId)}@${domain}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class RemoteInbox {
|
|
116
|
+
token;
|
|
117
|
+
domain;
|
|
118
|
+
tenant;
|
|
119
|
+
base;
|
|
120
|
+
constructor(url, token, domain = "cndx.dev", tenant) {
|
|
121
|
+
this.token = token;
|
|
122
|
+
this.domain = domain;
|
|
123
|
+
this.tenant = tenant;
|
|
124
|
+
this.base = new URL(url);
|
|
125
|
+
if (this.base.protocol !== "https:" && this.base.hostname !== "localhost" && this.base.hostname !== "127.0.0.1") {
|
|
126
|
+
throw new Error("inbox API requires HTTPS");
|
|
127
|
+
}
|
|
128
|
+
if (!token)
|
|
129
|
+
throw new Error("inbox control token is required");
|
|
130
|
+
}
|
|
131
|
+
get identity() {
|
|
132
|
+
return `${this.base.origin}${this.base.pathname}|${this.domain}`;
|
|
133
|
+
}
|
|
134
|
+
address(runId, actor) {
|
|
135
|
+
return inboxAddress(actor, runId, this.domain);
|
|
136
|
+
}
|
|
137
|
+
async request(method, path, body) {
|
|
138
|
+
const response = await fetch(new URL(path, this.base), {
|
|
139
|
+
method,
|
|
140
|
+
redirect: "error",
|
|
141
|
+
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
|
|
142
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
143
|
+
});
|
|
144
|
+
const result = await response.json();
|
|
145
|
+
if (!response.ok)
|
|
146
|
+
throw new Error(`inbox ${method} ${path}: ${response.status} ${result.error ?? "request failed"}`);
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
async reserve(details) {
|
|
150
|
+
const body = { ...details, domain: this.domain, ...this.tenant ? { tenant: this.tenant } : {} };
|
|
151
|
+
const reserved = await this.request("POST", "/v1/leases", body);
|
|
152
|
+
if (reserved.domain !== this.domain)
|
|
153
|
+
throw new Error(`inbox domain mismatch: ${reserved.domain}`);
|
|
154
|
+
}
|
|
155
|
+
list(runId, actor) {
|
|
156
|
+
this.address(runId, actor);
|
|
157
|
+
return this.request("GET", `/v1/runs/${runId}/inbox/${actor}`);
|
|
158
|
+
}
|
|
159
|
+
async release(runId) {
|
|
160
|
+
await this.request("DELETE", `/v1/leases/${runId}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// packages/core/src/index.ts
|
|
165
|
+
var control = (value) => [...value].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
|
166
|
+
function defineProject(project) {
|
|
167
|
+
if (typeof project.name !== "string" || !project.name.trim() || project.name.length > 200 || control(project.name) || !Object.keys(project.scenarios).length) {
|
|
168
|
+
throw new Error("project needs a valid name and at least one scenario");
|
|
169
|
+
}
|
|
170
|
+
if (project.inbox && (typeof project.inbox.identity !== "string" || !project.inbox.identity.trim())) {
|
|
171
|
+
throw new Error("inbox needs a stable identity");
|
|
172
|
+
}
|
|
173
|
+
return project;
|
|
174
|
+
}
|
|
175
|
+
async function loadProject(path = process.env.CONDITION_PROJECT ?? "condition.config.ts") {
|
|
176
|
+
const module = await import(pathToFileURL(resolve(path)).href);
|
|
177
|
+
if (!module.default)
|
|
178
|
+
throw new Error(`${path} must export a default project`);
|
|
179
|
+
return defineProject(module.default);
|
|
180
|
+
}
|
|
181
|
+
function errorCode(error, step) {
|
|
182
|
+
if (/runner unreachable/.test(message(error)))
|
|
183
|
+
return "runner_unreachable";
|
|
184
|
+
if (step === "inbox")
|
|
185
|
+
return "inbox_unavailable";
|
|
186
|
+
return `${step}_failed`;
|
|
187
|
+
}
|
|
188
|
+
function message(error) {
|
|
189
|
+
return error instanceof Error ? error.message : String(error);
|
|
190
|
+
}
|
|
191
|
+
function parse(json) {
|
|
192
|
+
return json === null ? null : JSON.parse(json);
|
|
193
|
+
}
|
|
194
|
+
async function presentation(hook, value) {
|
|
195
|
+
if (!hook)
|
|
196
|
+
return null;
|
|
197
|
+
return JSON.stringify(await hook(value)) ?? null;
|
|
198
|
+
}
|
|
199
|
+
function describeProject(project) {
|
|
200
|
+
return {
|
|
201
|
+
project: project.name,
|
|
202
|
+
scenarios: Object.entries(project.scenarios).map(([name, scenario]) => ({
|
|
203
|
+
name,
|
|
204
|
+
description: scenario.description,
|
|
205
|
+
...scenario.input ? { input: scenario.input } : {},
|
|
206
|
+
...scenario.actors ? { actors: scenario.actors } : {}
|
|
207
|
+
}))
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function publicRun(run, project) {
|
|
211
|
+
return {
|
|
212
|
+
id: run.id,
|
|
213
|
+
project,
|
|
214
|
+
scenario: run.scenario,
|
|
215
|
+
input: parse(run.presentedInput),
|
|
216
|
+
output: parse(run.presentedOutput),
|
|
217
|
+
status: run.status,
|
|
218
|
+
leaseUntil: run.leaseUntil,
|
|
219
|
+
createdAt: run.createdAt,
|
|
220
|
+
error: run.error,
|
|
221
|
+
errorCode: run.errorCode,
|
|
222
|
+
hasInbox: run.inboxRef !== null
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
class Condition {
|
|
227
|
+
project;
|
|
228
|
+
store;
|
|
229
|
+
now;
|
|
230
|
+
constructor(project, store, options = {}) {
|
|
231
|
+
this.project = project;
|
|
232
|
+
this.store = store;
|
|
233
|
+
this.now = options.now ?? (() => new Date);
|
|
234
|
+
}
|
|
235
|
+
describe() {
|
|
236
|
+
return describeProject(this.project);
|
|
237
|
+
}
|
|
238
|
+
async get(id) {
|
|
239
|
+
return this.publicRun(await this.read(id));
|
|
240
|
+
}
|
|
241
|
+
async list() {
|
|
242
|
+
return (await this.store.list()).map((run) => this.publicRun(run));
|
|
243
|
+
}
|
|
244
|
+
async read(id) {
|
|
245
|
+
const run = await this.store.find(id);
|
|
246
|
+
if (!run)
|
|
247
|
+
throw new Error(`run ${id} not found`);
|
|
248
|
+
return run;
|
|
249
|
+
}
|
|
250
|
+
publicRun(run) {
|
|
251
|
+
return publicRun(run, this.project.name);
|
|
252
|
+
}
|
|
253
|
+
scenario(name) {
|
|
254
|
+
const scenario = this.project.scenarios[name];
|
|
255
|
+
if (!scenario)
|
|
256
|
+
throw new Error(`unknown scenario ${name}`);
|
|
257
|
+
return scenario;
|
|
258
|
+
}
|
|
259
|
+
inboxFor(run) {
|
|
260
|
+
const inbox = this.project.inbox;
|
|
261
|
+
if (!run.inboxRef || !inbox || inbox.identity !== run.inboxRef)
|
|
262
|
+
return;
|
|
263
|
+
return { address: (actor) => inbox.address(run.id, actor) };
|
|
264
|
+
}
|
|
265
|
+
checkActor(run, actor, capability) {
|
|
266
|
+
const actors = this.scenario(run.scenario).actors;
|
|
267
|
+
if (actors && !actors.some((spec) => spec.name === actor && spec[capability])) {
|
|
268
|
+
throw new Error(`actor ${actor} has no ${capability} in ${run.scenario}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
active(run) {
|
|
272
|
+
return run.status === "ready" && run.leaseUntil > this.now().toISOString();
|
|
273
|
+
}
|
|
274
|
+
async start(name, input = {}, options = {}) {
|
|
275
|
+
const scenario = this.scenario(name);
|
|
276
|
+
const problems = scenario.input ? validateInput(scenario.input, input) : [];
|
|
277
|
+
if (problems.length)
|
|
278
|
+
throw new Error(`invalid input: ${problems.join("; ")}`);
|
|
279
|
+
const wantsInbox = !scenario.actors || scenario.actors.some((actor) => actor.inbox);
|
|
280
|
+
const minutes = options.leaseMinutes ?? 20;
|
|
281
|
+
if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440)
|
|
282
|
+
throw new Error("leaseMinutes must be 1–1440");
|
|
283
|
+
if (options.requestKey && options.requestKey.length > 200)
|
|
284
|
+
throw new Error("requestKey is too long");
|
|
285
|
+
const inputJson = JSON.stringify(input);
|
|
286
|
+
if (inputJson === undefined)
|
|
287
|
+
throw new Error("input must be JSON serializable");
|
|
288
|
+
const now = this.now();
|
|
289
|
+
const run = {
|
|
290
|
+
id: crypto.randomUUID(),
|
|
291
|
+
scenario: name,
|
|
292
|
+
input: inputJson,
|
|
293
|
+
output: null,
|
|
294
|
+
status: "preparing",
|
|
295
|
+
leaseUntil: new Date(now.getTime() + minutes * 60000).toISOString(),
|
|
296
|
+
createdAt: now.toISOString(),
|
|
297
|
+
error: null,
|
|
298
|
+
errorCode: null,
|
|
299
|
+
requestKey: options.requestKey ?? null,
|
|
300
|
+
inboxRef: wantsInbox ? this.project.inbox?.identity ?? null : null,
|
|
301
|
+
presentedInput: null,
|
|
302
|
+
presentedOutput: null
|
|
303
|
+
};
|
|
304
|
+
if (await this.store.insert(run))
|
|
305
|
+
return { run: this.publicRun(run), created: true };
|
|
306
|
+
const existing = run.requestKey ? await this.store.findByRequestKey(run.requestKey) : null;
|
|
307
|
+
if (!existing)
|
|
308
|
+
throw new Error("run conflict");
|
|
309
|
+
if (existing.scenario !== name || existing.input !== inputJson)
|
|
310
|
+
throw new Error("requestKey was used with different input");
|
|
311
|
+
return { run: this.publicRun(existing), created: false };
|
|
312
|
+
}
|
|
313
|
+
async prepare(id) {
|
|
314
|
+
const run = await this.read(id);
|
|
315
|
+
if (run.status !== "preparing")
|
|
316
|
+
throw new Error(`run ${id} is ${run.status}`);
|
|
317
|
+
const scenario = this.scenario(run.scenario);
|
|
318
|
+
const input = JSON.parse(run.input);
|
|
319
|
+
const inbox = this.project.inbox;
|
|
320
|
+
let step = "prepare";
|
|
321
|
+
try {
|
|
322
|
+
if (!await this.store.update(id, "preparing", { presentedInput: await presentation(scenario.presentInput, input) })) {
|
|
323
|
+
throw new Error(`run ${id} changed during preparation`);
|
|
324
|
+
}
|
|
325
|
+
if (run.inboxRef) {
|
|
326
|
+
step = "inbox";
|
|
327
|
+
if (!inbox || inbox.identity !== run.inboxRef)
|
|
328
|
+
throw new Error(`inbox configuration for run ${id} is unavailable or changed`);
|
|
329
|
+
await inbox.reserve({ runId: id, project: this.project.name, leaseUntil: run.leaseUntil });
|
|
330
|
+
step = "prepare";
|
|
331
|
+
}
|
|
332
|
+
const output = await scenario.prepare({ runId: id, input, inbox: this.inboxFor(run) });
|
|
333
|
+
const saved = await this.store.update(id, "preparing", {
|
|
334
|
+
output: JSON.stringify(output ?? null),
|
|
335
|
+
presentedOutput: await presentation(scenario.present, output ?? null)
|
|
336
|
+
});
|
|
337
|
+
if (!saved)
|
|
338
|
+
throw new Error(`run ${id} changed during preparation`);
|
|
339
|
+
step = "verify";
|
|
340
|
+
await scenario.verify({ runId: id, input, output, inbox: this.inboxFor(run) });
|
|
341
|
+
if (!await this.store.update(id, "preparing", { status: "ready" }))
|
|
342
|
+
throw new Error(`run ${id} changed during verification`);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
await this.store.update(id, "preparing", { status: "failed", error: message(error), errorCode: errorCode(error, step) });
|
|
345
|
+
throw new Error(`run ${id} failed: ${message(error)}`, { cause: error });
|
|
346
|
+
}
|
|
347
|
+
return this.get(id);
|
|
348
|
+
}
|
|
349
|
+
async condition(name, input = {}, options = {}) {
|
|
350
|
+
const { run, created } = await this.start(name, input, options);
|
|
351
|
+
return created ? this.prepare(run.id) : run;
|
|
352
|
+
}
|
|
353
|
+
async login(id, actor) {
|
|
354
|
+
const run = await this.read(id);
|
|
355
|
+
if (run.status !== "ready")
|
|
356
|
+
throw new Error(`run ${id} is ${run.status}`);
|
|
357
|
+
if (!this.active(run))
|
|
358
|
+
throw new Error(`run ${id} lease expired`);
|
|
359
|
+
const scenario = this.scenario(run.scenario);
|
|
360
|
+
if (!scenario.login)
|
|
361
|
+
throw new Error(`scenario ${run.scenario} does not provide login`);
|
|
362
|
+
this.checkActor(run, actor, "login");
|
|
363
|
+
return scenario.login({ runId: id, input: JSON.parse(run.input), output: parse(run.output), actor, inbox: this.inboxFor(run) });
|
|
364
|
+
}
|
|
365
|
+
async inbox(id, actor) {
|
|
366
|
+
const run = await this.read(id);
|
|
367
|
+
if (!this.active(run))
|
|
368
|
+
throw new Error(`run ${id} is not active`);
|
|
369
|
+
if (!run.inboxRef)
|
|
370
|
+
throw new Error(`run ${id} has no inbox`);
|
|
371
|
+
if (!this.project.inbox || this.project.inbox.identity !== run.inboxRef) {
|
|
372
|
+
throw new Error(`inbox configuration for run ${id} is unavailable or changed`);
|
|
373
|
+
}
|
|
374
|
+
this.checkActor(run, actor, "inbox");
|
|
375
|
+
const inbox = await this.project.inbox.list(id, actor);
|
|
376
|
+
return { ...inbox, messages: inbox.messages.map(extract) };
|
|
377
|
+
}
|
|
378
|
+
async claimReset(id) {
|
|
379
|
+
const run = await this.read(id);
|
|
380
|
+
if (run.status === "released")
|
|
381
|
+
return false;
|
|
382
|
+
if (run.status === "preparing")
|
|
383
|
+
throw new Error(`run ${id} is still preparing`);
|
|
384
|
+
if (run.status === "cleaning")
|
|
385
|
+
throw new Error(`run ${id} is already cleaning`);
|
|
386
|
+
this.scenario(run.scenario);
|
|
387
|
+
if (!await this.store.update(id, run.status, { status: "cleaning" }))
|
|
388
|
+
throw new Error(`run ${id} changed while cleanup started`);
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
async finishReset(id) {
|
|
392
|
+
const run = await this.read(id);
|
|
393
|
+
if (run.status !== "cleaning")
|
|
394
|
+
throw new Error(`run ${id} is ${run.status}`);
|
|
395
|
+
const scenario = this.scenario(run.scenario);
|
|
396
|
+
try {
|
|
397
|
+
await scenario.cleanup({ runId: id, input: JSON.parse(run.input), output: parse(run.output), inbox: this.inboxFor(run) });
|
|
398
|
+
if (run.inboxRef) {
|
|
399
|
+
if (!this.project.inbox || this.project.inbox.identity !== run.inboxRef) {
|
|
400
|
+
throw new Error(`inbox configuration for run ${id} is unavailable or changed`);
|
|
401
|
+
}
|
|
402
|
+
await this.project.inbox.release(id);
|
|
403
|
+
}
|
|
404
|
+
await this.store.update(id, "cleaning", { status: "released", error: null, errorCode: null });
|
|
405
|
+
} catch (error) {
|
|
406
|
+
await this.store.update(id, "cleaning", { status: "cleanup_failed", error: message(error), errorCode: errorCode(error, "cleanup") });
|
|
407
|
+
throw new Error(`cleanup for ${id} failed: ${message(error)}`, { cause: error });
|
|
408
|
+
}
|
|
409
|
+
return this.get(id);
|
|
410
|
+
}
|
|
411
|
+
async reset(id) {
|
|
412
|
+
return await this.claimReset(id) ? this.finishReset(id) : this.get(id);
|
|
413
|
+
}
|
|
414
|
+
async cleanupExpired() {
|
|
415
|
+
const released = [];
|
|
416
|
+
const failures = [];
|
|
417
|
+
for (const id of await this.store.expired(this.now().toISOString())) {
|
|
418
|
+
try {
|
|
419
|
+
released.push(await this.reset(id));
|
|
420
|
+
} catch (error) {
|
|
421
|
+
failures.push({ id, error });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (failures.length) {
|
|
425
|
+
throw new AggregateError(failures.map(({ error }) => error), `cleanup failed for ${failures.map(({ id }) => id).join(", ")}`);
|
|
426
|
+
}
|
|
427
|
+
return released;
|
|
428
|
+
}
|
|
429
|
+
close() {
|
|
430
|
+
return this.store.close();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
export {
|
|
434
|
+
validateInput,
|
|
435
|
+
publicRun,
|
|
436
|
+
loadProject,
|
|
437
|
+
legacyInboxDomain,
|
|
438
|
+
inboxAlias,
|
|
439
|
+
inboxAddress,
|
|
440
|
+
describeProject,
|
|
441
|
+
defineProject,
|
|
442
|
+
RemoteInbox,
|
|
443
|
+
Condition
|
|
444
|
+
};
|
package/local.js
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
// packages/core/src/index.ts
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
|
|
5
|
+
// packages/core/src/extract.ts
|
|
6
|
+
var linkPattern = /https?:\/\/[^\s"'<>)\]]+/g;
|
|
7
|
+
var codePattern = /(?<![\d-])\d{4,8}(?![\d-])/g;
|
|
8
|
+
var unique = (values, limit) => [...new Set(values)].slice(0, limit);
|
|
9
|
+
function trimPunctuation(link) {
|
|
10
|
+
let end = link.length;
|
|
11
|
+
while (end > 0 && ".,;:".includes(link[end - 1]))
|
|
12
|
+
end--;
|
|
13
|
+
return link.slice(0, end);
|
|
14
|
+
}
|
|
15
|
+
function stripTags(html) {
|
|
16
|
+
let out = "";
|
|
17
|
+
let inTag = false;
|
|
18
|
+
for (const character of html) {
|
|
19
|
+
if (character === "<")
|
|
20
|
+
inTag = true;
|
|
21
|
+
else if (character === ">") {
|
|
22
|
+
inTag = false;
|
|
23
|
+
out += " ";
|
|
24
|
+
} else if (!inTag)
|
|
25
|
+
out += character;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function decodeEntities(value) {
|
|
30
|
+
return value.replaceAll("&", "&").replaceAll(""", '"').replaceAll("'", "'");
|
|
31
|
+
}
|
|
32
|
+
function extract(message) {
|
|
33
|
+
const text = `${message.bodyText}
|
|
34
|
+
${decodeEntities(message.bodyHtml)}`;
|
|
35
|
+
const links = unique((text.match(linkPattern) ?? []).map(trimPunctuation), 20);
|
|
36
|
+
const visible = `${message.subject}
|
|
37
|
+
${message.bodyText || stripTags(message.bodyHtml)}`;
|
|
38
|
+
const codes = unique((visible.replace(linkPattern, " ").match(codePattern) ?? []).filter((code) => !/^(19|20)\d{2}$/.test(code)), 10);
|
|
39
|
+
return { ...message, links, codes };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// packages/core/src/schema.ts
|
|
43
|
+
function typeOf(value) {
|
|
44
|
+
if (value === null)
|
|
45
|
+
return "null";
|
|
46
|
+
if (Array.isArray(value))
|
|
47
|
+
return "array";
|
|
48
|
+
if (typeof value === "number" && Number.isInteger(value))
|
|
49
|
+
return "integer";
|
|
50
|
+
return typeof value;
|
|
51
|
+
}
|
|
52
|
+
function matchesType(expected, value) {
|
|
53
|
+
const actual = typeOf(value);
|
|
54
|
+
return expected === actual || expected === "number" && actual === "integer";
|
|
55
|
+
}
|
|
56
|
+
function checkBounds(schema, value, path, errors) {
|
|
57
|
+
if (typeof value === "number") {
|
|
58
|
+
if (schema.minimum !== undefined && value < schema.minimum)
|
|
59
|
+
errors.push(`${path} must be at least ${schema.minimum}`);
|
|
60
|
+
if (schema.maximum !== undefined && value > schema.maximum)
|
|
61
|
+
errors.push(`${path} must be at most ${schema.maximum}`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
if (schema.minLength !== undefined && value.length < schema.minLength)
|
|
65
|
+
errors.push(`${path} is shorter than ${schema.minLength}`);
|
|
66
|
+
if (schema.maxLength !== undefined && value.length > schema.maxLength)
|
|
67
|
+
errors.push(`${path} is longer than ${schema.maxLength}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function checkObject(schema, value, path, errors) {
|
|
71
|
+
for (const key of schema.required ?? [])
|
|
72
|
+
if (!(key in value))
|
|
73
|
+
errors.push(`${path}.${key} is required`);
|
|
74
|
+
for (const [key, child] of Object.entries(value)) {
|
|
75
|
+
const property = schema.properties?.[key];
|
|
76
|
+
if (property)
|
|
77
|
+
check(property, child, `${path}.${key}`, errors);
|
|
78
|
+
else if (schema.additionalProperties === false)
|
|
79
|
+
errors.push(`${path}.${key} is not allowed`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function check(schema, value, path, errors) {
|
|
83
|
+
if (schema.type && !matchesType(schema.type, value)) {
|
|
84
|
+
errors.push(`${path} must be ${schema.type}`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (schema.enum && !schema.enum.some((option) => JSON.stringify(option) === JSON.stringify(value))) {
|
|
88
|
+
errors.push(`${path} must be one of ${schema.enum.map((option) => JSON.stringify(option)).join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
checkBounds(schema, value, path, errors);
|
|
91
|
+
if (typeOf(value) === "object")
|
|
92
|
+
checkObject(schema, value, path, errors);
|
|
93
|
+
if (Array.isArray(value) && schema.items)
|
|
94
|
+
value.forEach((item, index) => check(schema.items, item, `${path}[${index}]`, errors));
|
|
95
|
+
}
|
|
96
|
+
function validateInput(schema, value) {
|
|
97
|
+
const errors = [];
|
|
98
|
+
check(schema, value, "input", errors);
|
|
99
|
+
return errors;
|
|
100
|
+
}
|
|
101
|
+
// packages/core/src/remote-inbox.ts
|
|
102
|
+
import { createHash } from "node:crypto";
|
|
103
|
+
var actorPattern = /^[a-z][a-z0-9-]{0,15}$/;
|
|
104
|
+
var legacyInboxDomain = "inbox.condition.sh";
|
|
105
|
+
function inboxAlias(runId) {
|
|
106
|
+
const hex = createHash("sha256").update(runId).digest("hex").slice(0, 12);
|
|
107
|
+
return (parseInt(hex, 16) % 36 ** 6).toString(36).padStart(6, "0");
|
|
108
|
+
}
|
|
109
|
+
function inboxAddress(actor, runId, domain) {
|
|
110
|
+
if (!actorPattern.test(actor))
|
|
111
|
+
throw new Error(`invalid inbox actor ${actor}`);
|
|
112
|
+
return `${actor}.${domain === legacyInboxDomain ? runId : inboxAlias(runId)}@${domain}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class RemoteInbox {
|
|
116
|
+
token;
|
|
117
|
+
domain;
|
|
118
|
+
tenant;
|
|
119
|
+
base;
|
|
120
|
+
constructor(url, token, domain = "cndx.dev", tenant) {
|
|
121
|
+
this.token = token;
|
|
122
|
+
this.domain = domain;
|
|
123
|
+
this.tenant = tenant;
|
|
124
|
+
this.base = new URL(url);
|
|
125
|
+
if (this.base.protocol !== "https:" && this.base.hostname !== "localhost" && this.base.hostname !== "127.0.0.1") {
|
|
126
|
+
throw new Error("inbox API requires HTTPS");
|
|
127
|
+
}
|
|
128
|
+
if (!token)
|
|
129
|
+
throw new Error("inbox control token is required");
|
|
130
|
+
}
|
|
131
|
+
get identity() {
|
|
132
|
+
return `${this.base.origin}${this.base.pathname}|${this.domain}`;
|
|
133
|
+
}
|
|
134
|
+
address(runId, actor) {
|
|
135
|
+
return inboxAddress(actor, runId, this.domain);
|
|
136
|
+
}
|
|
137
|
+
async request(method, path, body) {
|
|
138
|
+
const response = await fetch(new URL(path, this.base), {
|
|
139
|
+
method,
|
|
140
|
+
redirect: "error",
|
|
141
|
+
headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json" },
|
|
142
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
143
|
+
});
|
|
144
|
+
const result = await response.json();
|
|
145
|
+
if (!response.ok)
|
|
146
|
+
throw new Error(`inbox ${method} ${path}: ${response.status} ${result.error ?? "request failed"}`);
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
async reserve(details) {
|
|
150
|
+
const body = { ...details, domain: this.domain, ...this.tenant ? { tenant: this.tenant } : {} };
|
|
151
|
+
const reserved = await this.request("POST", "/v1/leases", body);
|
|
152
|
+
if (reserved.domain !== this.domain)
|
|
153
|
+
throw new Error(`inbox domain mismatch: ${reserved.domain}`);
|
|
154
|
+
}
|
|
155
|
+
list(runId, actor) {
|
|
156
|
+
this.address(runId, actor);
|
|
157
|
+
return this.request("GET", `/v1/runs/${runId}/inbox/${actor}`);
|
|
158
|
+
}
|
|
159
|
+
async release(runId) {
|
|
160
|
+
await this.request("DELETE", `/v1/leases/${runId}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// packages/core/src/index.ts
|
|
165
|
+
var control = (value) => [...value].some((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127);
|
|
166
|
+
function defineProject(project) {
|
|
167
|
+
if (typeof project.name !== "string" || !project.name.trim() || project.name.length > 200 || control(project.name) || !Object.keys(project.scenarios).length) {
|
|
168
|
+
throw new Error("project needs a valid name and at least one scenario");
|
|
169
|
+
}
|
|
170
|
+
if (project.inbox && (typeof project.inbox.identity !== "string" || !project.inbox.identity.trim())) {
|
|
171
|
+
throw new Error("inbox needs a stable identity");
|
|
172
|
+
}
|
|
173
|
+
return project;
|
|
174
|
+
}
|
|
175
|
+
async function loadProject(path = process.env.CONDITION_PROJECT ?? "condition.config.ts") {
|
|
176
|
+
const module = await import(pathToFileURL(resolve(path)).href);
|
|
177
|
+
if (!module.default)
|
|
178
|
+
throw new Error(`${path} must export a default project`);
|
|
179
|
+
return defineProject(module.default);
|
|
180
|
+
}
|
|
181
|
+
function errorCode(error, step) {
|
|
182
|
+
if (/runner unreachable/.test(message(error)))
|
|
183
|
+
return "runner_unreachable";
|
|
184
|
+
if (step === "inbox")
|
|
185
|
+
return "inbox_unavailable";
|
|
186
|
+
return `${step}_failed`;
|
|
187
|
+
}
|
|
188
|
+
function message(error) {
|
|
189
|
+
return error instanceof Error ? error.message : String(error);
|
|
190
|
+
}
|
|
191
|
+
function parse(json) {
|
|
192
|
+
return json === null ? null : JSON.parse(json);
|
|
193
|
+
}
|
|
194
|
+
async function presentation(hook, value) {
|
|
195
|
+
if (!hook)
|
|
196
|
+
return null;
|
|
197
|
+
return JSON.stringify(await hook(value)) ?? null;
|
|
198
|
+
}
|
|
199
|
+
function describeProject(project) {
|
|
200
|
+
return {
|
|
201
|
+
project: project.name,
|
|
202
|
+
scenarios: Object.entries(project.scenarios).map(([name, scenario]) => ({
|
|
203
|
+
name,
|
|
204
|
+
description: scenario.description,
|
|
205
|
+
...scenario.input ? { input: scenario.input } : {},
|
|
206
|
+
...scenario.actors ? { actors: scenario.actors } : {}
|
|
207
|
+
}))
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
function publicRun(run, project) {
|
|
211
|
+
return {
|
|
212
|
+
id: run.id,
|
|
213
|
+
project,
|
|
214
|
+
scenario: run.scenario,
|
|
215
|
+
input: parse(run.presentedInput),
|
|
216
|
+
output: parse(run.presentedOutput),
|
|
217
|
+
status: run.status,
|
|
218
|
+
leaseUntil: run.leaseUntil,
|
|
219
|
+
createdAt: run.createdAt,
|
|
220
|
+
error: run.error,
|
|
221
|
+
errorCode: run.errorCode,
|
|
222
|
+
hasInbox: run.inboxRef !== null
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
class Condition {
|
|
227
|
+
project;
|
|
228
|
+
store;
|
|
229
|
+
now;
|
|
230
|
+
constructor(project, store, options = {}) {
|
|
231
|
+
this.project = project;
|
|
232
|
+
this.store = store;
|
|
233
|
+
this.now = options.now ?? (() => new Date);
|
|
234
|
+
}
|
|
235
|
+
describe() {
|
|
236
|
+
return describeProject(this.project);
|
|
237
|
+
}
|
|
238
|
+
async get(id) {
|
|
239
|
+
return this.publicRun(await this.read(id));
|
|
240
|
+
}
|
|
241
|
+
async list() {
|
|
242
|
+
return (await this.store.list()).map((run) => this.publicRun(run));
|
|
243
|
+
}
|
|
244
|
+
async read(id) {
|
|
245
|
+
const run = await this.store.find(id);
|
|
246
|
+
if (!run)
|
|
247
|
+
throw new Error(`run ${id} not found`);
|
|
248
|
+
return run;
|
|
249
|
+
}
|
|
250
|
+
publicRun(run) {
|
|
251
|
+
return publicRun(run, this.project.name);
|
|
252
|
+
}
|
|
253
|
+
scenario(name) {
|
|
254
|
+
const scenario = this.project.scenarios[name];
|
|
255
|
+
if (!scenario)
|
|
256
|
+
throw new Error(`unknown scenario ${name}`);
|
|
257
|
+
return scenario;
|
|
258
|
+
}
|
|
259
|
+
inboxFor(run) {
|
|
260
|
+
const inbox = this.project.inbox;
|
|
261
|
+
if (!run.inboxRef || !inbox || inbox.identity !== run.inboxRef)
|
|
262
|
+
return;
|
|
263
|
+
return { address: (actor) => inbox.address(run.id, actor) };
|
|
264
|
+
}
|
|
265
|
+
checkActor(run, actor, capability) {
|
|
266
|
+
const actors = this.scenario(run.scenario).actors;
|
|
267
|
+
if (actors && !actors.some((spec) => spec.name === actor && spec[capability])) {
|
|
268
|
+
throw new Error(`actor ${actor} has no ${capability} in ${run.scenario}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
active(run) {
|
|
272
|
+
return run.status === "ready" && run.leaseUntil > this.now().toISOString();
|
|
273
|
+
}
|
|
274
|
+
async start(name, input = {}, options = {}) {
|
|
275
|
+
const scenario = this.scenario(name);
|
|
276
|
+
const problems = scenario.input ? validateInput(scenario.input, input) : [];
|
|
277
|
+
if (problems.length)
|
|
278
|
+
throw new Error(`invalid input: ${problems.join("; ")}`);
|
|
279
|
+
const wantsInbox = !scenario.actors || scenario.actors.some((actor) => actor.inbox);
|
|
280
|
+
const minutes = options.leaseMinutes ?? 20;
|
|
281
|
+
if (!Number.isInteger(minutes) || minutes < 1 || minutes > 1440)
|
|
282
|
+
throw new Error("leaseMinutes must be 1–1440");
|
|
283
|
+
if (options.requestKey && options.requestKey.length > 200)
|
|
284
|
+
throw new Error("requestKey is too long");
|
|
285
|
+
const inputJson = JSON.stringify(input);
|
|
286
|
+
if (inputJson === undefined)
|
|
287
|
+
throw new Error("input must be JSON serializable");
|
|
288
|
+
const now = this.now();
|
|
289
|
+
const run = {
|
|
290
|
+
id: crypto.randomUUID(),
|
|
291
|
+
scenario: name,
|
|
292
|
+
input: inputJson,
|
|
293
|
+
output: null,
|
|
294
|
+
status: "preparing",
|
|
295
|
+
leaseUntil: new Date(now.getTime() + minutes * 60000).toISOString(),
|
|
296
|
+
createdAt: now.toISOString(),
|
|
297
|
+
error: null,
|
|
298
|
+
errorCode: null,
|
|
299
|
+
requestKey: options.requestKey ?? null,
|
|
300
|
+
inboxRef: wantsInbox ? this.project.inbox?.identity ?? null : null,
|
|
301
|
+
presentedInput: null,
|
|
302
|
+
presentedOutput: null
|
|
303
|
+
};
|
|
304
|
+
if (await this.store.insert(run))
|
|
305
|
+
return { run: this.publicRun(run), created: true };
|
|
306
|
+
const existing = run.requestKey ? await this.store.findByRequestKey(run.requestKey) : null;
|
|
307
|
+
if (!existing)
|
|
308
|
+
throw new Error("run conflict");
|
|
309
|
+
if (existing.scenario !== name || existing.input !== inputJson)
|
|
310
|
+
throw new Error("requestKey was used with different input");
|
|
311
|
+
return { run: this.publicRun(existing), created: false };
|
|
312
|
+
}
|
|
313
|
+
async prepare(id) {
|
|
314
|
+
const run = await this.read(id);
|
|
315
|
+
if (run.status !== "preparing")
|
|
316
|
+
throw new Error(`run ${id} is ${run.status}`);
|
|
317
|
+
const scenario = this.scenario(run.scenario);
|
|
318
|
+
const input = JSON.parse(run.input);
|
|
319
|
+
const inbox = this.project.inbox;
|
|
320
|
+
let step = "prepare";
|
|
321
|
+
try {
|
|
322
|
+
if (!await this.store.update(id, "preparing", { presentedInput: await presentation(scenario.presentInput, input) })) {
|
|
323
|
+
throw new Error(`run ${id} changed during preparation`);
|
|
324
|
+
}
|
|
325
|
+
if (run.inboxRef) {
|
|
326
|
+
step = "inbox";
|
|
327
|
+
if (!inbox || inbox.identity !== run.inboxRef)
|
|
328
|
+
throw new Error(`inbox configuration for run ${id} is unavailable or changed`);
|
|
329
|
+
await inbox.reserve({ runId: id, project: this.project.name, leaseUntil: run.leaseUntil });
|
|
330
|
+
step = "prepare";
|
|
331
|
+
}
|
|
332
|
+
const output = await scenario.prepare({ runId: id, input, inbox: this.inboxFor(run) });
|
|
333
|
+
const saved = await this.store.update(id, "preparing", {
|
|
334
|
+
output: JSON.stringify(output ?? null),
|
|
335
|
+
presentedOutput: await presentation(scenario.present, output ?? null)
|
|
336
|
+
});
|
|
337
|
+
if (!saved)
|
|
338
|
+
throw new Error(`run ${id} changed during preparation`);
|
|
339
|
+
step = "verify";
|
|
340
|
+
await scenario.verify({ runId: id, input, output, inbox: this.inboxFor(run) });
|
|
341
|
+
if (!await this.store.update(id, "preparing", { status: "ready" }))
|
|
342
|
+
throw new Error(`run ${id} changed during verification`);
|
|
343
|
+
} catch (error) {
|
|
344
|
+
await this.store.update(id, "preparing", { status: "failed", error: message(error), errorCode: errorCode(error, step) });
|
|
345
|
+
throw new Error(`run ${id} failed: ${message(error)}`, { cause: error });
|
|
346
|
+
}
|
|
347
|
+
return this.get(id);
|
|
348
|
+
}
|
|
349
|
+
async condition(name, input = {}, options = {}) {
|
|
350
|
+
const { run, created } = await this.start(name, input, options);
|
|
351
|
+
return created ? this.prepare(run.id) : run;
|
|
352
|
+
}
|
|
353
|
+
async login(id, actor) {
|
|
354
|
+
const run = await this.read(id);
|
|
355
|
+
if (run.status !== "ready")
|
|
356
|
+
throw new Error(`run ${id} is ${run.status}`);
|
|
357
|
+
if (!this.active(run))
|
|
358
|
+
throw new Error(`run ${id} lease expired`);
|
|
359
|
+
const scenario = this.scenario(run.scenario);
|
|
360
|
+
if (!scenario.login)
|
|
361
|
+
throw new Error(`scenario ${run.scenario} does not provide login`);
|
|
362
|
+
this.checkActor(run, actor, "login");
|
|
363
|
+
return scenario.login({ runId: id, input: JSON.parse(run.input), output: parse(run.output), actor, inbox: this.inboxFor(run) });
|
|
364
|
+
}
|
|
365
|
+
async inbox(id, actor) {
|
|
366
|
+
const run = await this.read(id);
|
|
367
|
+
if (!this.active(run))
|
|
368
|
+
throw new Error(`run ${id} is not active`);
|
|
369
|
+
if (!run.inboxRef)
|
|
370
|
+
throw new Error(`run ${id} has no inbox`);
|
|
371
|
+
if (!this.project.inbox || this.project.inbox.identity !== run.inboxRef) {
|
|
372
|
+
throw new Error(`inbox configuration for run ${id} is unavailable or changed`);
|
|
373
|
+
}
|
|
374
|
+
this.checkActor(run, actor, "inbox");
|
|
375
|
+
const inbox = await this.project.inbox.list(id, actor);
|
|
376
|
+
return { ...inbox, messages: inbox.messages.map(extract) };
|
|
377
|
+
}
|
|
378
|
+
async claimReset(id) {
|
|
379
|
+
const run = await this.read(id);
|
|
380
|
+
if (run.status === "released")
|
|
381
|
+
return false;
|
|
382
|
+
if (run.status === "preparing")
|
|
383
|
+
throw new Error(`run ${id} is still preparing`);
|
|
384
|
+
if (run.status === "cleaning")
|
|
385
|
+
throw new Error(`run ${id} is already cleaning`);
|
|
386
|
+
this.scenario(run.scenario);
|
|
387
|
+
if (!await this.store.update(id, run.status, { status: "cleaning" }))
|
|
388
|
+
throw new Error(`run ${id} changed while cleanup started`);
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
async finishReset(id) {
|
|
392
|
+
const run = await this.read(id);
|
|
393
|
+
if (run.status !== "cleaning")
|
|
394
|
+
throw new Error(`run ${id} is ${run.status}`);
|
|
395
|
+
const scenario = this.scenario(run.scenario);
|
|
396
|
+
try {
|
|
397
|
+
await scenario.cleanup({ runId: id, input: JSON.parse(run.input), output: parse(run.output), inbox: this.inboxFor(run) });
|
|
398
|
+
if (run.inboxRef) {
|
|
399
|
+
if (!this.project.inbox || this.project.inbox.identity !== run.inboxRef) {
|
|
400
|
+
throw new Error(`inbox configuration for run ${id} is unavailable or changed`);
|
|
401
|
+
}
|
|
402
|
+
await this.project.inbox.release(id);
|
|
403
|
+
}
|
|
404
|
+
await this.store.update(id, "cleaning", { status: "released", error: null, errorCode: null });
|
|
405
|
+
} catch (error) {
|
|
406
|
+
await this.store.update(id, "cleaning", { status: "cleanup_failed", error: message(error), errorCode: errorCode(error, "cleanup") });
|
|
407
|
+
throw new Error(`cleanup for ${id} failed: ${message(error)}`, { cause: error });
|
|
408
|
+
}
|
|
409
|
+
return this.get(id);
|
|
410
|
+
}
|
|
411
|
+
async reset(id) {
|
|
412
|
+
return await this.claimReset(id) ? this.finishReset(id) : this.get(id);
|
|
413
|
+
}
|
|
414
|
+
async cleanupExpired() {
|
|
415
|
+
const released = [];
|
|
416
|
+
const failures = [];
|
|
417
|
+
for (const id of await this.store.expired(this.now().toISOString())) {
|
|
418
|
+
try {
|
|
419
|
+
released.push(await this.reset(id));
|
|
420
|
+
} catch (error) {
|
|
421
|
+
failures.push({ id, error });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
if (failures.length) {
|
|
425
|
+
throw new AggregateError(failures.map(({ error }) => error), `cleanup failed for ${failures.map(({ id }) => id).join(", ")}`);
|
|
426
|
+
}
|
|
427
|
+
return released;
|
|
428
|
+
}
|
|
429
|
+
close() {
|
|
430
|
+
return this.store.close();
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// packages/core/src/store.ts
|
|
435
|
+
import { Database } from "bun:sqlite";
|
|
436
|
+
import { mkdirSync, chmodSync } from "node:fs";
|
|
437
|
+
import { dirname, resolve as resolve2 } from "node:path";
|
|
438
|
+
var columns = {
|
|
439
|
+
status: "status",
|
|
440
|
+
output: "output",
|
|
441
|
+
error: "error",
|
|
442
|
+
errorCode: "error_code",
|
|
443
|
+
presentedInput: "presented_input",
|
|
444
|
+
presentedOutput: "presented_output"
|
|
445
|
+
};
|
|
446
|
+
function fromRow(row) {
|
|
447
|
+
return {
|
|
448
|
+
id: row.id,
|
|
449
|
+
scenario: row.scenario,
|
|
450
|
+
input: row.input,
|
|
451
|
+
output: row.output,
|
|
452
|
+
status: row.status,
|
|
453
|
+
leaseUntil: row.lease_until,
|
|
454
|
+
createdAt: row.created_at,
|
|
455
|
+
error: row.error,
|
|
456
|
+
errorCode: row.error_code,
|
|
457
|
+
requestKey: row.request_key,
|
|
458
|
+
inboxRef: row.inbox_ref,
|
|
459
|
+
presentedInput: row.presented_input,
|
|
460
|
+
presentedOutput: row.presented_output
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
class SqliteRunStore {
|
|
465
|
+
project;
|
|
466
|
+
db;
|
|
467
|
+
constructor(databasePath, project) {
|
|
468
|
+
this.project = project;
|
|
469
|
+
const path = resolve2(databasePath);
|
|
470
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
471
|
+
this.db = new Database(path, { create: true });
|
|
472
|
+
chmodSync(path, 384);
|
|
473
|
+
this.db.exec("PRAGMA journal_mode = WAL");
|
|
474
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS runs (
|
|
475
|
+
id TEXT PRIMARY KEY, project TEXT NOT NULL, scenario TEXT NOT NULL,
|
|
476
|
+
input TEXT NOT NULL, output TEXT, status TEXT NOT NULL,
|
|
477
|
+
lease_until TEXT NOT NULL, created_at TEXT NOT NULL,
|
|
478
|
+
error TEXT, request_key TEXT, inbox_ref TEXT,
|
|
479
|
+
presented_input TEXT, presented_output TEXT, error_code TEXT,
|
|
480
|
+
UNIQUE(project, request_key)
|
|
481
|
+
)`);
|
|
482
|
+
const existing = new Set(this.db.query("PRAGMA table_info(runs)").all().map((column) => column.name));
|
|
483
|
+
for (const column of ["inbox_ref", "presented_input", "presented_output", "error_code"]) {
|
|
484
|
+
if (!existing.has(column))
|
|
485
|
+
this.db.exec(`ALTER TABLE runs ADD COLUMN ${column} TEXT`);
|
|
486
|
+
}
|
|
487
|
+
this.db.exec("CREATE INDEX IF NOT EXISTS runs_lease_idx ON runs(status, lease_until)");
|
|
488
|
+
}
|
|
489
|
+
async insert(run) {
|
|
490
|
+
const inserted = this.db.query(`INSERT INTO runs (id, project, scenario, input, output, status, lease_until, created_at, error, error_code, request_key, inbox_ref, presented_input, presented_output)
|
|
491
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
492
|
+
ON CONFLICT(project, request_key) DO NOTHING RETURNING id`).get(run.id, this.project, run.scenario, run.input, run.output, run.status, run.leaseUntil, run.createdAt, run.error, run.errorCode, run.requestKey, run.inboxRef, run.presentedInput, run.presentedOutput);
|
|
493
|
+
return inserted !== null;
|
|
494
|
+
}
|
|
495
|
+
async find(id) {
|
|
496
|
+
const row = this.db.query("SELECT * FROM runs WHERE id = ? AND project = ?").get(id, this.project);
|
|
497
|
+
return row ? fromRow(row) : null;
|
|
498
|
+
}
|
|
499
|
+
async findByRequestKey(requestKey) {
|
|
500
|
+
const row = this.db.query("SELECT * FROM runs WHERE project = ? AND request_key = ?").get(this.project, requestKey);
|
|
501
|
+
return row ? fromRow(row) : null;
|
|
502
|
+
}
|
|
503
|
+
async list() {
|
|
504
|
+
return this.db.query("SELECT * FROM runs WHERE project = ? ORDER BY created_at DESC").all(this.project).map(fromRow);
|
|
505
|
+
}
|
|
506
|
+
async update(id, from, patch) {
|
|
507
|
+
const entries = Object.entries(patch);
|
|
508
|
+
if (!entries.length)
|
|
509
|
+
return true;
|
|
510
|
+
const assignments = entries.map(([key]) => `${columns[key]} = ?`).join(", ");
|
|
511
|
+
const result = this.db.query(`UPDATE runs SET ${assignments} WHERE id = ? AND project = ? AND status = ?`).run(...entries.map(([, value]) => value), id, this.project, from);
|
|
512
|
+
return result.changes === 1;
|
|
513
|
+
}
|
|
514
|
+
async expired(now) {
|
|
515
|
+
return this.db.query("SELECT id FROM runs WHERE project = ? AND lease_until <= ? AND status IN ('ready', 'failed', 'cleanup_failed')").all(this.project, now).map((row) => row.id);
|
|
516
|
+
}
|
|
517
|
+
close() {
|
|
518
|
+
this.db.close();
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// packages/core/src/local.ts
|
|
523
|
+
function localCondition(project, path = ".condition/runs.sqlite", options = {}) {
|
|
524
|
+
return new Condition(project, new SqliteRunStore(path, project.name), options);
|
|
525
|
+
}
|
|
526
|
+
export {
|
|
527
|
+
localCondition,
|
|
528
|
+
SqliteRunStore
|
|
529
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@condition-sh/core",
|
|
3
|
+
"version": "2026.9.1",
|
|
4
|
+
"description": "The Condition scenario contract and run engine.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Rad-Soft/condition.git",
|
|
10
|
+
"directory": "packages/core"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://condition.sh",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./types/index.d.ts",
|
|
16
|
+
"default": "./index.js"
|
|
17
|
+
},
|
|
18
|
+
"./local": {
|
|
19
|
+
"types": "./types/local.d.ts",
|
|
20
|
+
"default": "./local.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { RunStatus, RunStore, StoredRun } from "./store.js";
|
|
2
|
+
import type { InboxAdapter } from "./remote-inbox.js";
|
|
3
|
+
import { type JsonSchema } from "./schema.js";
|
|
4
|
+
export type { ReadMessage } from "./extract.js";
|
|
5
|
+
export { validateInput } from "./schema.js";
|
|
6
|
+
export type { JsonSchema } from "./schema.js";
|
|
7
|
+
export { RemoteInbox, inboxAddress, inboxAlias, legacyInboxDomain } from "./remote-inbox.js";
|
|
8
|
+
export type { InboxAdapter, InboxMessage } from "./remote-inbox.js";
|
|
9
|
+
export type { RunPatch, RunStatus, RunStore, StoredRun } from "./store.js";
|
|
10
|
+
export type ScenarioContext<Input> = {
|
|
11
|
+
runId: string;
|
|
12
|
+
input: Input;
|
|
13
|
+
inbox?: {
|
|
14
|
+
address: (actor: string) => string;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
export type ActorSpec = {
|
|
18
|
+
name: string;
|
|
19
|
+
login?: boolean;
|
|
20
|
+
inbox?: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type Scenario<Input = unknown, Output = unknown> = {
|
|
23
|
+
description: string;
|
|
24
|
+
input?: JsonSchema;
|
|
25
|
+
actors?: ActorSpec[];
|
|
26
|
+
prepare: (context: ScenarioContext<Input>) => Promise<Output>;
|
|
27
|
+
verify: (context: ScenarioContext<Input> & {
|
|
28
|
+
output: Output;
|
|
29
|
+
}) => Promise<void>;
|
|
30
|
+
cleanup: (context: ScenarioContext<Input> & {
|
|
31
|
+
output: Output | null;
|
|
32
|
+
}) => Promise<void>;
|
|
33
|
+
login?: (context: ScenarioContext<Input> & {
|
|
34
|
+
output: Output;
|
|
35
|
+
actor: string;
|
|
36
|
+
}) => Promise<unknown>;
|
|
37
|
+
present?: (output: Output) => unknown;
|
|
38
|
+
presentInput?: (input: Input) => unknown;
|
|
39
|
+
};
|
|
40
|
+
export type Project = {
|
|
41
|
+
name: string;
|
|
42
|
+
scenarios: Record<string, Scenario<any, any>>;
|
|
43
|
+
inbox?: InboxAdapter;
|
|
44
|
+
};
|
|
45
|
+
export declare function defineProject<T extends Project>(project: T): T;
|
|
46
|
+
export declare function loadProject(path?: string): Promise<Project>;
|
|
47
|
+
export type Run = {
|
|
48
|
+
id: string;
|
|
49
|
+
project: string;
|
|
50
|
+
scenario: string;
|
|
51
|
+
input: unknown;
|
|
52
|
+
output: unknown | null;
|
|
53
|
+
status: RunStatus;
|
|
54
|
+
leaseUntil: string;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
error: string | null;
|
|
57
|
+
errorCode: string | null;
|
|
58
|
+
hasInbox: boolean;
|
|
59
|
+
};
|
|
60
|
+
export type ErrorCode = "inbox_unavailable" | "prepare_failed" | "verify_failed" | "runner_unreachable" | "cleanup_failed" | "worker_lost";
|
|
61
|
+
export type ConditionOptions = {
|
|
62
|
+
now?: () => Date;
|
|
63
|
+
};
|
|
64
|
+
export type ScenarioSummary = {
|
|
65
|
+
name: string;
|
|
66
|
+
description: string;
|
|
67
|
+
input?: JsonSchema;
|
|
68
|
+
actors?: ActorSpec[];
|
|
69
|
+
};
|
|
70
|
+
export declare function describeProject(project: Project): {
|
|
71
|
+
project: string;
|
|
72
|
+
scenarios: ScenarioSummary[];
|
|
73
|
+
};
|
|
74
|
+
export declare function publicRun(run: StoredRun, project: string): Run;
|
|
75
|
+
export declare class Condition {
|
|
76
|
+
readonly project: Project;
|
|
77
|
+
private readonly store;
|
|
78
|
+
private readonly now;
|
|
79
|
+
constructor(project: Project, store: RunStore, options?: ConditionOptions);
|
|
80
|
+
describe(): {
|
|
81
|
+
project: string;
|
|
82
|
+
scenarios: ScenarioSummary[];
|
|
83
|
+
};
|
|
84
|
+
get(id: string): Promise<Run>;
|
|
85
|
+
list(): Promise<Run[]>;
|
|
86
|
+
private read;
|
|
87
|
+
private publicRun;
|
|
88
|
+
private scenario;
|
|
89
|
+
private inboxFor;
|
|
90
|
+
private checkActor;
|
|
91
|
+
private active;
|
|
92
|
+
start(name: string, input?: unknown, options?: {
|
|
93
|
+
leaseMinutes?: number;
|
|
94
|
+
requestKey?: string;
|
|
95
|
+
}): Promise<{
|
|
96
|
+
run: Run;
|
|
97
|
+
created: boolean;
|
|
98
|
+
}>;
|
|
99
|
+
prepare(id: string): Promise<Run>;
|
|
100
|
+
condition(name: string, input?: unknown, options?: {
|
|
101
|
+
leaseMinutes?: number;
|
|
102
|
+
requestKey?: string;
|
|
103
|
+
}): Promise<Run>;
|
|
104
|
+
login(id: string, actor: string): Promise<unknown>;
|
|
105
|
+
inbox(id: string, actor: string): Promise<{
|
|
106
|
+
messages: import("./extract").ReadMessage[];
|
|
107
|
+
address: string;
|
|
108
|
+
}>;
|
|
109
|
+
claimReset(id: string): Promise<boolean>;
|
|
110
|
+
finishReset(id: string): Promise<Run>;
|
|
111
|
+
reset(id: string): Promise<Run>;
|
|
112
|
+
cleanupExpired(): Promise<Run[]>;
|
|
113
|
+
close(): void | Promise<void>;
|
|
114
|
+
}
|
package/types/local.d.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export type InboxMessage = {
|
|
2
|
+
id: string;
|
|
3
|
+
sender: string;
|
|
4
|
+
recipient: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
bodyText: string;
|
|
7
|
+
bodyHtml: string;
|
|
8
|
+
receivedAt: number;
|
|
9
|
+
};
|
|
10
|
+
export type InboxAdapter = {
|
|
11
|
+
readonly identity: string;
|
|
12
|
+
address(runId: string, actor: string): string;
|
|
13
|
+
reserve(details: {
|
|
14
|
+
runId: string;
|
|
15
|
+
project: string;
|
|
16
|
+
leaseUntil: string;
|
|
17
|
+
}): Promise<void>;
|
|
18
|
+
list(runId: string, actor: string): Promise<{
|
|
19
|
+
address: string;
|
|
20
|
+
messages: InboxMessage[];
|
|
21
|
+
}>;
|
|
22
|
+
release(runId: string): Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
export declare const legacyInboxDomain = "inbox.condition.sh";
|
|
25
|
+
export declare function inboxAlias(runId: string): string;
|
|
26
|
+
export declare function inboxAddress(actor: string, runId: string, domain: string): string;
|
|
27
|
+
export declare class RemoteInbox implements InboxAdapter {
|
|
28
|
+
private readonly token;
|
|
29
|
+
readonly domain: string;
|
|
30
|
+
private readonly tenant?;
|
|
31
|
+
private readonly base;
|
|
32
|
+
constructor(url: string, token: string, domain?: string, tenant?: string | undefined);
|
|
33
|
+
get identity(): string;
|
|
34
|
+
address(runId: string, actor: string): string;
|
|
35
|
+
private request;
|
|
36
|
+
reserve(details: {
|
|
37
|
+
runId: string;
|
|
38
|
+
project: string;
|
|
39
|
+
leaseUntil: string;
|
|
40
|
+
}): Promise<void>;
|
|
41
|
+
list(runId: string, actor: string): Promise<{
|
|
42
|
+
address: string;
|
|
43
|
+
messages: InboxMessage[];
|
|
44
|
+
}>;
|
|
45
|
+
release(runId: string): Promise<void>;
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type JsonSchema = {
|
|
2
|
+
type?: "object" | "string" | "number" | "integer" | "boolean" | "array" | "null";
|
|
3
|
+
description?: string;
|
|
4
|
+
properties?: Record<string, JsonSchema>;
|
|
5
|
+
required?: string[];
|
|
6
|
+
additionalProperties?: boolean;
|
|
7
|
+
enum?: unknown[];
|
|
8
|
+
minimum?: number;
|
|
9
|
+
maximum?: number;
|
|
10
|
+
minLength?: number;
|
|
11
|
+
maxLength?: number;
|
|
12
|
+
items?: JsonSchema;
|
|
13
|
+
default?: unknown;
|
|
14
|
+
};
|
|
15
|
+
export declare function validateInput(schema: JsonSchema, value: unknown): string[];
|
package/types/store.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export type RunStatus = "preparing" | "ready" | "failed" | "cleaning" | "cleanup_failed" | "released";
|
|
2
|
+
export type StoredRun = {
|
|
3
|
+
id: string;
|
|
4
|
+
scenario: string;
|
|
5
|
+
input: string;
|
|
6
|
+
output: string | null;
|
|
7
|
+
status: RunStatus;
|
|
8
|
+
leaseUntil: string;
|
|
9
|
+
createdAt: string;
|
|
10
|
+
error: string | null;
|
|
11
|
+
errorCode: string | null;
|
|
12
|
+
requestKey: string | null;
|
|
13
|
+
inboxRef: string | null;
|
|
14
|
+
presentedInput: string | null;
|
|
15
|
+
presentedOutput: string | null;
|
|
16
|
+
};
|
|
17
|
+
export type RunPatch = Partial<Pick<StoredRun, "status" | "output" | "error" | "errorCode" | "presentedInput" | "presentedOutput">>;
|
|
18
|
+
export interface RunStore {
|
|
19
|
+
insert(run: StoredRun): Promise<boolean>;
|
|
20
|
+
find(id: string): Promise<StoredRun | null>;
|
|
21
|
+
findByRequestKey(requestKey: string): Promise<StoredRun | null>;
|
|
22
|
+
list(): Promise<StoredRun[]>;
|
|
23
|
+
update(id: string, from: RunStatus, patch: RunPatch): Promise<boolean>;
|
|
24
|
+
expired(now: string): Promise<string[]>;
|
|
25
|
+
close(): void | Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
export declare class SqliteRunStore implements RunStore {
|
|
28
|
+
private readonly project;
|
|
29
|
+
private readonly db;
|
|
30
|
+
constructor(databasePath: string, project: string);
|
|
31
|
+
insert(run: StoredRun): Promise<boolean>;
|
|
32
|
+
find(id: string): Promise<StoredRun | null>;
|
|
33
|
+
findByRequestKey(requestKey: string): Promise<StoredRun | null>;
|
|
34
|
+
list(): Promise<StoredRun[]>;
|
|
35
|
+
update(id: string, from: RunStatus, patch: RunPatch): Promise<boolean>;
|
|
36
|
+
expired(now: string): Promise<string[]>;
|
|
37
|
+
close(): void;
|
|
38
|
+
}
|