@pieceful/ravel-core 0.1.0 → 0.2.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 +15 -3
- package/README.md +29 -0
- package/package.json +27 -8
- package/src/directive-syntax.js +354 -0
- package/src/index.d.ts +112 -4
- package/src/index.js +294 -25
- package/src/live.js +406 -0
package/src/live.js
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
const zeroPosition = () => ({ line: 0, column: 0, offset: 0 });
|
|
2
|
+
|
|
3
|
+
const fallbackSource = (uri = "<live>") => ({
|
|
4
|
+
uri,
|
|
5
|
+
range: { start: zeroPosition(), end: zeroPosition() }
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const diagnostic = (code, message, source, related) => ({
|
|
9
|
+
code,
|
|
10
|
+
severity: "error",
|
|
11
|
+
message,
|
|
12
|
+
source: source ?? fallbackSource(),
|
|
13
|
+
...(related?.length ? { related } : {})
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const isRecord = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return the first reason a value cannot cross a Ravel execution boundary.
|
|
20
|
+
* `null` means the value is valid.
|
|
21
|
+
*/
|
|
22
|
+
export const ravelValueIssue = (value, path = "$", seen = new Set()) => {
|
|
23
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return null;
|
|
24
|
+
if (typeof value === "number") return Number.isFinite(value) ? null : path + " must be a finite number.";
|
|
25
|
+
if (typeof value !== "object") return path + " has unsupported type " + typeof value + ".";
|
|
26
|
+
if (seen.has(value)) return path + " contains a cycle.";
|
|
27
|
+
seen.add(value);
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
30
|
+
if (!Object.hasOwn(value, index)) return path + " must not contain array holes.";
|
|
31
|
+
const issue = ravelValueIssue(value[index], path + "[" + index + "]", seen);
|
|
32
|
+
if (issue) return issue;
|
|
33
|
+
}
|
|
34
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
35
|
+
if (key === "length") continue;
|
|
36
|
+
const index = typeof key === "string" && /^(?:0|[1-9][0-9]*)$/.test(key) ? Number(key) : -1;
|
|
37
|
+
if (!Number.isSafeInteger(index) || index < 0 || index >= value.length) {
|
|
38
|
+
return path + " has a non-index array property.";
|
|
39
|
+
}
|
|
40
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
41
|
+
if (!descriptor?.enumerable || descriptor.get || descriptor.set) {
|
|
42
|
+
return path + "[" + key + "] must be an enumerable data property.";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
seen.delete(value);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const prototype = Object.getPrototypeOf(value);
|
|
49
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
50
|
+
seen.delete(value);
|
|
51
|
+
return path + " must be a plain record.";
|
|
52
|
+
}
|
|
53
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
54
|
+
if (typeof key !== "string") {
|
|
55
|
+
seen.delete(value);
|
|
56
|
+
return path + " must not contain symbol keys.";
|
|
57
|
+
}
|
|
58
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
59
|
+
if (!descriptor?.enumerable || descriptor.get || descriptor.set) {
|
|
60
|
+
seen.delete(value);
|
|
61
|
+
return path + "." + key + " must be an enumerable data property.";
|
|
62
|
+
}
|
|
63
|
+
const issue = ravelValueIssue(value[key], path + "." + key, seen);
|
|
64
|
+
if (issue) return issue;
|
|
65
|
+
}
|
|
66
|
+
seen.delete(value);
|
|
67
|
+
return null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const serializeRavelValue = (value) => {
|
|
71
|
+
const issue = ravelValueIssue(value);
|
|
72
|
+
if (issue) throw new TypeError(issue);
|
|
73
|
+
return JSON.stringify(value);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const cloneRavelValue = (value) => JSON.parse(serializeRavelValue(value));
|
|
77
|
+
|
|
78
|
+
const providerValues = (providers) => {
|
|
79
|
+
if (providers instanceof Map) return [...providers.values()];
|
|
80
|
+
if (Array.isArray(providers)) return providers;
|
|
81
|
+
if (isRecord(providers)) return Object.values(providers);
|
|
82
|
+
return [];
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const providerLanguages = (provider) =>
|
|
86
|
+
provider?.languages instanceof Set ? [...provider.languages] : provider?.languages ?? [];
|
|
87
|
+
|
|
88
|
+
const selectProvider = (providers, language, requestedId, source, diagnostics) => {
|
|
89
|
+
const candidates = providers.filter((provider) =>
|
|
90
|
+
(!requestedId || provider?.id === requestedId) &&
|
|
91
|
+
providerLanguages(provider).includes(language)
|
|
92
|
+
);
|
|
93
|
+
if (candidates.length === 1) return candidates[0];
|
|
94
|
+
if (candidates.length === 0) {
|
|
95
|
+
diagnostics.push(diagnostic(
|
|
96
|
+
"RL100",
|
|
97
|
+
requestedId
|
|
98
|
+
? "Execution provider " + requestedId + " does not support language " + language + "."
|
|
99
|
+
: "No execution provider is registered for language " + language + ".",
|
|
100
|
+
source
|
|
101
|
+
));
|
|
102
|
+
} else {
|
|
103
|
+
diagnostics.push(diagnostic(
|
|
104
|
+
"RL101",
|
|
105
|
+
"Multiple execution providers are registered for language " + language + ": " +
|
|
106
|
+
candidates.map((provider) => provider.id).join(", ") + ".",
|
|
107
|
+
source
|
|
108
|
+
));
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const dependencyReference = (entry) => typeof entry === "string" ? entry : entry?.reference;
|
|
114
|
+
const dependencySource = (entry, fallback) => typeof entry === "string" ? fallback : entry?.source ?? fallback;
|
|
115
|
+
|
|
116
|
+
const resolveReference = (reference, owner, chunks) => {
|
|
117
|
+
if (typeof reference !== "string" || !reference) return null;
|
|
118
|
+
if (reference.includes("::")) {
|
|
119
|
+
return Object.hasOwn(chunks, reference) ? { id: reference } : null;
|
|
120
|
+
}
|
|
121
|
+
const document = owner.identity?.document;
|
|
122
|
+
if (document !== null && document !== undefined) {
|
|
123
|
+
const local = document + "::" + reference;
|
|
124
|
+
if (Object.hasOwn(chunks, local)) return { id: local };
|
|
125
|
+
if (!reference.includes(".")) {
|
|
126
|
+
const candidates = Object.entries(chunks)
|
|
127
|
+
.filter(([, chunk]) =>
|
|
128
|
+
chunk.identity?.document === document &&
|
|
129
|
+
chunk.identity?.chunk === reference &&
|
|
130
|
+
chunk.identity?.minor === null
|
|
131
|
+
)
|
|
132
|
+
.map(([id]) => id);
|
|
133
|
+
if (candidates.length === 1) return { id: candidates[0] };
|
|
134
|
+
if (candidates.length > 1) return { ambiguous: candidates };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return Object.hasOwn(chunks, reference) ? { id: reference } : null;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const normalizeAnalysis = (analysis) => ({
|
|
141
|
+
version: 1,
|
|
142
|
+
dependencies: Array.isArray(analysis?.dependencies) ? analysis.dependencies : [],
|
|
143
|
+
resources: Array.isArray(analysis?.resources) ? analysis.resources : [],
|
|
144
|
+
modules: Array.isArray(analysis?.modules) ? analysis.modules : [],
|
|
145
|
+
diagnostics: Array.isArray(analysis?.diagnostics) ? analysis.diagnostics : []
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Analyze executable chunks after ordinary Ravel composition has completed.
|
|
150
|
+
* Providers own their language syntax; core owns provider selection and graph
|
|
151
|
+
* resolution.
|
|
152
|
+
*/
|
|
153
|
+
export const planLiveExecutions = (program, options = {}) => {
|
|
154
|
+
const diagnostics = [];
|
|
155
|
+
const providers = providerValues(options.providers);
|
|
156
|
+
const chunks = program?.chunks ?? {};
|
|
157
|
+
const nodes = {};
|
|
158
|
+
|
|
159
|
+
for (const [id, chunk] of Object.entries(chunks)) {
|
|
160
|
+
const execution = chunk.metadata?.data?.ravel;
|
|
161
|
+
if (execution?.run !== true) continue;
|
|
162
|
+
const language = chunk.metadata?.language;
|
|
163
|
+
if (typeof language !== "string" || !language) {
|
|
164
|
+
diagnostics.push(diagnostic("RL102", "Executable chunk " + id + " has no language.", chunk.source));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const provider = selectProvider(providers, language, execution.provider, chunk.source, diagnostics);
|
|
168
|
+
if (!provider) continue;
|
|
169
|
+
let analysis;
|
|
170
|
+
try {
|
|
171
|
+
analysis = normalizeAnalysis(provider.analyze?.({
|
|
172
|
+
id,
|
|
173
|
+
language,
|
|
174
|
+
source: chunk.value,
|
|
175
|
+
sourceLocation: chunk.source
|
|
176
|
+
}));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
diagnostics.push(diagnostic(
|
|
179
|
+
"RL103",
|
|
180
|
+
"Provider " + provider.id + " could not analyze " + id + ": " + (error?.message ?? String(error)),
|
|
181
|
+
chunk.source
|
|
182
|
+
));
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
diagnostics.push(...analysis.diagnostics);
|
|
186
|
+
const dependencies = [];
|
|
187
|
+
for (const entry of analysis.dependencies) {
|
|
188
|
+
const reference = dependencyReference(entry);
|
|
189
|
+
const source = dependencySource(entry, chunk.source);
|
|
190
|
+
const resolved = resolveReference(reference, chunk, chunks);
|
|
191
|
+
if (!resolved) {
|
|
192
|
+
diagnostics.push(diagnostic("RL110", "Unknown live dependency: " + String(reference), source));
|
|
193
|
+
} else if (resolved.ambiguous) {
|
|
194
|
+
diagnostics.push(diagnostic(
|
|
195
|
+
"RL109",
|
|
196
|
+
"Ambiguous live dependency " + String(reference) + ": " + resolved.ambiguous.join(", ") + ".",
|
|
197
|
+
source
|
|
198
|
+
));
|
|
199
|
+
} else {
|
|
200
|
+
dependencies.push({ reference, id: resolved.id, source });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
nodes[id] = {
|
|
204
|
+
id,
|
|
205
|
+
language,
|
|
206
|
+
provider: { id: provider.id, version: provider.version ?? "unknown" },
|
|
207
|
+
source: chunk.source,
|
|
208
|
+
dependencies,
|
|
209
|
+
resources: analysis.resources,
|
|
210
|
+
modules: analysis.modules,
|
|
211
|
+
analysis
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const visiting = [];
|
|
216
|
+
const visited = new Set();
|
|
217
|
+
const reportedCycles = new Set();
|
|
218
|
+
const visit = (id) => {
|
|
219
|
+
const cycleIndex = visiting.indexOf(id);
|
|
220
|
+
if (cycleIndex !== -1) {
|
|
221
|
+
const cycle = [...visiting.slice(cycleIndex), id];
|
|
222
|
+
const key = cycle.join("\u0000");
|
|
223
|
+
if (!reportedCycles.has(key)) {
|
|
224
|
+
reportedCycles.add(key);
|
|
225
|
+
diagnostics.push(diagnostic(
|
|
226
|
+
"RL111",
|
|
227
|
+
"Live dependency cycle: " + cycle.join(" → "),
|
|
228
|
+
nodes[id]?.source,
|
|
229
|
+
cycle.slice(0, -1).map((entry) => nodes[entry]?.source).filter(Boolean)
|
|
230
|
+
));
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (visited.has(id)) return;
|
|
235
|
+
visiting.push(id);
|
|
236
|
+
for (const dependency of nodes[id]?.dependencies ?? []) {
|
|
237
|
+
if (nodes[dependency.id]) visit(dependency.id);
|
|
238
|
+
}
|
|
239
|
+
visiting.pop();
|
|
240
|
+
visited.add(id);
|
|
241
|
+
};
|
|
242
|
+
for (const id of Object.keys(nodes)) visit(id);
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
version: 1,
|
|
246
|
+
nodes,
|
|
247
|
+
diagnostics,
|
|
248
|
+
ok: diagnostics.every((entry) => entry.severity !== "error")
|
|
249
|
+
};
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const resourceValue = (resources, name) => {
|
|
253
|
+
if (resources instanceof Map) return resources.get(name);
|
|
254
|
+
return resources?.[name];
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const providerById = (providers, id) => providers.find((provider) => provider?.id === id);
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Execute a planned live graph. This initial scheduler is deliberately
|
|
261
|
+
* sequential and deterministic; the provider contract is asynchronous so
|
|
262
|
+
* bounded parallel scheduling can be added without changing executors.
|
|
263
|
+
*/
|
|
264
|
+
export const executeLiveProgram = async (program, options = {}) => {
|
|
265
|
+
const providers = providerValues(options.providers);
|
|
266
|
+
const plan = planLiveExecutions(program, { providers });
|
|
267
|
+
const diagnostics = [...plan.diagnostics];
|
|
268
|
+
const executions = {};
|
|
269
|
+
const active = [];
|
|
270
|
+
|
|
271
|
+
const execute = async (id) => {
|
|
272
|
+
if (executions[id]) return executions[id];
|
|
273
|
+
const node = plan.nodes[id];
|
|
274
|
+
if (!node) return null;
|
|
275
|
+
if (active.includes(id)) {
|
|
276
|
+
const failed = { id, status: "failed", value: undefined, serialized: undefined };
|
|
277
|
+
executions[id] = failed;
|
|
278
|
+
return failed;
|
|
279
|
+
}
|
|
280
|
+
active.push(id);
|
|
281
|
+
if (node.analysis.diagnostics.some((entry) => entry.severity === "error")) {
|
|
282
|
+
active.pop();
|
|
283
|
+
const failed = { id, status: "failed", value: undefined, serialized: undefined };
|
|
284
|
+
executions[id] = failed;
|
|
285
|
+
return failed;
|
|
286
|
+
}
|
|
287
|
+
const inputs = {};
|
|
288
|
+
let blocked = false;
|
|
289
|
+
for (const dependency of node.dependencies) {
|
|
290
|
+
const liveDependency = plan.nodes[dependency.id];
|
|
291
|
+
if (liveDependency) {
|
|
292
|
+
const completed = await execute(dependency.id);
|
|
293
|
+
if (completed?.status !== "succeeded") {
|
|
294
|
+
diagnostics.push(diagnostic(
|
|
295
|
+
"RL112",
|
|
296
|
+
"Execution " + id + " is blocked by failed dependency " + dependency.id + ".",
|
|
297
|
+
dependency.source
|
|
298
|
+
));
|
|
299
|
+
blocked = true;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
inputs[dependency.reference] = cloneRavelValue(completed.value);
|
|
303
|
+
} else {
|
|
304
|
+
inputs[dependency.reference] = program.chunks[dependency.id].value;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const resources = {};
|
|
309
|
+
for (const entry of node.resources) {
|
|
310
|
+
const name = typeof entry === "string" ? entry : entry?.name ?? entry?.path;
|
|
311
|
+
const value = resourceValue(options.resources, name);
|
|
312
|
+
if (value === undefined) {
|
|
313
|
+
diagnostics.push(diagnostic("RL113", "Missing live resource: " + String(name), node.source));
|
|
314
|
+
blocked = true;
|
|
315
|
+
} else {
|
|
316
|
+
resources[name] = cloneRavelValue(value);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
if (blocked) {
|
|
321
|
+
active.pop();
|
|
322
|
+
const failed = { id, status: "failed", value: undefined, serialized: undefined };
|
|
323
|
+
executions[id] = failed;
|
|
324
|
+
return failed;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const provider = providerById(providers, node.provider.id);
|
|
328
|
+
let outcome;
|
|
329
|
+
try {
|
|
330
|
+
outcome = await provider.execute({
|
|
331
|
+
version: 1,
|
|
332
|
+
id,
|
|
333
|
+
runId: options.runId ?? "live",
|
|
334
|
+
language: node.language,
|
|
335
|
+
source: program.chunks[id].value,
|
|
336
|
+
sourceLocation: node.source,
|
|
337
|
+
inputs,
|
|
338
|
+
resources,
|
|
339
|
+
analysis: node.analysis,
|
|
340
|
+
limits: options.limits ?? {},
|
|
341
|
+
signal: options.signal
|
|
342
|
+
});
|
|
343
|
+
} catch (error) {
|
|
344
|
+
outcome = {
|
|
345
|
+
ok: false,
|
|
346
|
+
diagnostics: [diagnostic(
|
|
347
|
+
"RL120",
|
|
348
|
+
"Provider " + node.provider.id + " failed while executing " + id + ": " +
|
|
349
|
+
(error?.message ?? String(error)),
|
|
350
|
+
node.source
|
|
351
|
+
)]
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const normalizedOutcome = {
|
|
355
|
+
version: outcome?.version ?? 1,
|
|
356
|
+
status: outcome?.status ?? (outcome?.ok && outcome?.hasExport !== false ? "succeeded" : "failed"),
|
|
357
|
+
...outcome,
|
|
358
|
+
diagnostics: Array.isArray(outcome?.diagnostics) ? outcome.diagnostics : []
|
|
359
|
+
};
|
|
360
|
+
diagnostics.push(...normalizedOutcome.diagnostics);
|
|
361
|
+
active.pop();
|
|
362
|
+
|
|
363
|
+
if (normalizedOutcome.status !== "succeeded" || !normalizedOutcome.ok || normalizedOutcome.hasExport === false) {
|
|
364
|
+
const failed = { id, status: "failed", value: undefined, serialized: undefined };
|
|
365
|
+
executions[id] = failed;
|
|
366
|
+
return failed;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
let value;
|
|
370
|
+
try {
|
|
371
|
+
value = typeof normalizedOutcome.serialized === "string"
|
|
372
|
+
? JSON.parse(normalizedOutcome.serialized)
|
|
373
|
+
: cloneRavelValue(normalizedOutcome.value);
|
|
374
|
+
const serialized = serializeRavelValue(value);
|
|
375
|
+
const succeeded = {
|
|
376
|
+
id,
|
|
377
|
+
status: "succeeded",
|
|
378
|
+
value,
|
|
379
|
+
serialized,
|
|
380
|
+
provider: node.provider,
|
|
381
|
+
durationMs: normalizedOutcome.durationMs
|
|
382
|
+
};
|
|
383
|
+
executions[id] = succeeded;
|
|
384
|
+
return succeeded;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
diagnostics.push(diagnostic(
|
|
387
|
+
"RL121",
|
|
388
|
+
"Execution " + id + " returned an invalid Ravel value: " + (error?.message ?? String(error)),
|
|
389
|
+
node.source
|
|
390
|
+
));
|
|
391
|
+
const failed = { id, status: "failed", value: undefined, serialized: undefined };
|
|
392
|
+
executions[id] = failed;
|
|
393
|
+
return failed;
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
for (const id of Object.keys(plan.nodes)) await execute(id);
|
|
398
|
+
return {
|
|
399
|
+
version: 1,
|
|
400
|
+
program,
|
|
401
|
+
plan,
|
|
402
|
+
executions,
|
|
403
|
+
diagnostics,
|
|
404
|
+
ok: diagnostics.every((entry) => entry.severity !== "error")
|
|
405
|
+
};
|
|
406
|
+
};
|