@pieceful/ravel-core 0.1.1 → 0.3.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/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
+ };