@particle-academy/fancy-flow 0.15.0 → 0.16.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/dist/engine.cjs CHANGED
@@ -222,6 +222,8 @@ function pauseForHuman(ctx, awaiting, detail) {
222
222
  var NODE_MANIFEST_SCHEMA_VERSION = 1;
223
223
  var FIRST_PARTY_SCOPE = "@particle-academy/";
224
224
  var NAMESPACED_KIND = /^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i;
225
+ var SIDE_EFFECTS = ["none", "idempotent", "unsafe-to-replay"];
226
+ var REQUIREMENTS = ["required", "optional"];
225
227
  function err(field, message) {
226
228
  return { level: "error", field, message };
227
229
  }
@@ -252,72 +254,200 @@ function validateNodeManifest(input) {
252
254
  if (typeof m.name !== "string" || m.name.trim() === "") {
253
255
  problems.push(err("name", "Required \u2014 the package name as installed."));
254
256
  }
255
- if (typeof m.kind !== "string" || m.kind.trim() === "") {
257
+ validateKind(m.kind, problems);
258
+ validateAliases(m.aliases, problems);
259
+ if (m.configVersion !== void 0 && (typeof m.configVersion !== "number" || !Number.isInteger(m.configVersion))) {
260
+ problems.push(err("configVersion", "Must be an integer."));
261
+ }
262
+ if (m.fancyFlow !== void 0) {
263
+ problems.push(
264
+ err(
265
+ "fancyFlow",
266
+ 'A single engine range cannot express the split \u2014 it cannot say "needs ts >=0.15 AND php >=0.7". Move the range into each entry of `runtimes` as `engine`.'
267
+ )
268
+ );
269
+ }
270
+ validateRuntimes(m.runtimes, problems);
271
+ if (typeof m.fixtures !== "string" || m.fixtures.trim() === "") {
272
+ problems.push(
273
+ err("fixtures", "Required \u2014 path to the node's golden fixtures. Every claimed runtime runs them, which is what makes cross-runtime parity verified rather than claimed.")
274
+ );
275
+ }
276
+ validateCapabilities(m.capabilities, problems);
277
+ if (m.sideEffects !== void 0 && !SIDE_EFFECTS.includes(m.sideEffects)) {
278
+ problems.push(err("sideEffects", `Must be one of: ${SIDE_EFFECTS.join(", ")}.`));
279
+ }
280
+ if (m.verified !== void 0) {
281
+ problems.push(
282
+ err("verified", "Assigned by the registry, not the package. Remove it \u2014 a package cannot vouch for itself.")
283
+ );
284
+ }
285
+ const ok = !problems.some((p) => p.level === "error");
286
+ return ok ? { ok, manifest: m, problems } : { ok, problems };
287
+ }
288
+ function validateKind(kind, problems) {
289
+ if (typeof kind !== "string" || kind.trim() === "") {
256
290
  problems.push(err("kind", "Required \u2014 the canonical kind id this package provides."));
257
- } else if (!NAMESPACED_KIND.test(m.kind)) {
291
+ return;
292
+ }
293
+ if (!NAMESPACED_KIND.test(kind)) {
258
294
  problems.push(
259
- err("kind", `"${m.kind}" must be namespaced as @scope/name \u2014 a bare id makes stored graphs ambiguous, and that is unfixable once documents carry it.`)
295
+ err("kind", `"${kind}" must be namespaced as @scope/name \u2014 a bare id makes stored graphs ambiguous, and that is unfixable once documents carry it.`)
260
296
  );
261
- } else if (m.kind.startsWith(FIRST_PARTY_SCOPE)) {
297
+ return;
298
+ }
299
+ if (kind.startsWith(FIRST_PARTY_SCOPE)) {
262
300
  problems.push(
263
301
  warn("kind", `${FIRST_PARTY_SCOPE}* is reserved for first-party nodes; the registry will reject this unless the package is first-party.`)
264
302
  );
265
303
  }
266
- if (typeof m.fancyFlow !== "string" || m.fancyFlow.trim() === "") {
267
- problems.push(err("fancyFlow", "Required \u2014 the semver range of fancy-flow this node targets."));
304
+ }
305
+ function validateAliases(aliases2, problems) {
306
+ if (aliases2 === void 0) return;
307
+ if (!Array.isArray(aliases2) || aliases2.some((a) => typeof a !== "string" || a.trim() === "")) {
308
+ problems.push(err("aliases", "Must be an array of non-empty id strings."));
309
+ }
310
+ }
311
+ function validateRuntimes(runtimes, problems) {
312
+ if (typeof runtimes !== "object" || runtimes === null || Array.isArray(runtimes)) {
313
+ problems.push(err("runtimes", "Required \u2014 an object of runtime id to { entry | package, engine }."));
314
+ return;
268
315
  }
269
- if (typeof m.runtimes !== "object" || m.runtimes === null || Array.isArray(m.runtimes)) {
270
- problems.push(err("runtimes", "Required \u2014 an object of runtime id to entrypoint."));
271
- } else {
272
- const entries = Object.entries(m.runtimes);
273
- if (entries.length === 0) {
274
- problems.push(err("runtimes", "A node that implements no runtime cannot execute anywhere."));
316
+ const entries = Object.entries(runtimes);
317
+ if (entries.length === 0) {
318
+ problems.push(err("runtimes", "A node that implements no runtime cannot execute anywhere."));
319
+ return;
320
+ }
321
+ for (const [runtime, spec] of entries) {
322
+ if (typeof spec !== "object" || spec === null || Array.isArray(spec)) {
323
+ problems.push(err(`runtimes.${runtime}`, "Must be an object of { entry | package, engine }."));
324
+ continue;
275
325
  }
276
- for (const [runtime, entry] of entries) {
277
- if (typeof entry !== "string" || entry.trim() === "") {
278
- problems.push(err(`runtimes.${runtime}`, "Entrypoint must be a non-empty string."));
279
- }
326
+ const s = spec;
327
+ const hasEntry = typeof s.entry === "string" && s.entry.trim() !== "";
328
+ const hasPackage = typeof s.package === "string" && s.package.trim() !== "";
329
+ if (!hasEntry && !hasPackage) {
330
+ problems.push(err(`runtimes.${runtime}`, "Needs `entry` (a module path) or `package` (a dependency requirement)."));
331
+ }
332
+ if (hasEntry && hasPackage) {
333
+ problems.push(err(`runtimes.${runtime}`, "Declare `entry` or `package`, not both \u2014 which one is authoritative is otherwise ambiguous."));
334
+ }
335
+ if (typeof s.engine !== "string" || s.engine.trim() === "") {
336
+ problems.push(
337
+ err(`runtimes.${runtime}.engine`, `Required \u2014 the semver range of the ${runtime} engine. Without it, this node installs against a ${runtime} engine too old to run it.`)
338
+ );
280
339
  }
281
340
  }
282
- if (typeof m.fixtures !== "string" || m.fixtures.trim() === "") {
341
+ }
342
+ function validateCapabilities(capabilities, problems) {
343
+ if (capabilities === void 0) return;
344
+ if (typeof capabilities !== "object" || capabilities === null || Array.isArray(capabilities)) {
283
345
  problems.push(
284
- err("fixtures", "Required \u2014 path to the node's golden fixtures. Every claimed runtime runs them, which is what makes cross-runtime parity verified rather than claimed.")
346
+ err("capabilities", 'Must be an object of capability id to "required" | "optional" \u2014 a bare list cannot say whether the node works without one.')
285
347
  );
348
+ return;
286
349
  }
287
- if (m.capabilities !== void 0) {
288
- if (!Array.isArray(m.capabilities) || m.capabilities.some((c) => typeof c !== "string")) {
289
- problems.push(err("capabilities", "Must be an array of capability id strings."));
350
+ for (const [id, requirement] of Object.entries(capabilities)) {
351
+ if (!REQUIREMENTS.includes(requirement)) {
352
+ problems.push(err(`capabilities.${id}`, `Must be "required" or "optional".`));
290
353
  }
291
354
  }
292
- if (m.verified !== void 0) {
355
+ }
356
+ function checkRuntimeSupport(manifest, hostRuntimes, engineVersions) {
357
+ const runtimes = manifest.runtimes ?? {};
358
+ const provided = Object.keys(runtimes);
359
+ const problems = [];
360
+ const missing = hostRuntimes.filter((r) => !provided.includes(r));
361
+ if (missing.length > 0) {
293
362
  problems.push(
294
- err("verified", "Assigned by the registry, not the package. Remove it \u2014 a package cannot vouch for itself.")
363
+ err(
364
+ "runtimes",
365
+ `${manifest.kind} implements ${provided.join(", ") || "no runtime"} but this project executes on ${missing.join(", ")}. The node would install, appear in the palette, and then fail to run.`
366
+ )
295
367
  );
296
368
  }
297
- const ok = !problems.some((p) => p.level === "error");
298
- return ok ? { ok, manifest: m, problems } : { ok, problems };
369
+ for (const runtime of hostRuntimes) {
370
+ const spec = runtimes[runtime];
371
+ if (!spec) continue;
372
+ const hostVersion = engineVersions?.[runtime];
373
+ if (hostVersion === void 0) {
374
+ problems.push(
375
+ warn(`runtimes.${runtime}.engine`, `${manifest.kind} needs ${runtime} engine ${spec.engine}; this host did not report its ${runtime} version, so the range was not checked.`)
376
+ );
377
+ continue;
378
+ }
379
+ if (!satisfiesRange(hostVersion, spec.engine)) {
380
+ problems.push(
381
+ err(`runtimes.${runtime}.engine`, `${manifest.kind} needs ${runtime} engine ${spec.engine}, but this host runs ${hostVersion}.`)
382
+ );
383
+ }
384
+ }
385
+ return problems;
299
386
  }
300
- function checkRuntimeSupport(manifest, hostRuntimes) {
301
- const provided = Object.keys(manifest.runtimes ?? {});
302
- const missing = hostRuntimes.filter((r) => !provided.includes(r));
303
- if (missing.length === 0) return [];
304
- return [
305
- err(
306
- "runtimes",
307
- `${manifest.kind} implements ${provided.join(", ") || "no runtime"} but this project executes on ${missing.join(", ")}. The node would install, appear in the palette, and then fail to run.`
308
- )
309
- ];
387
+ function satisfiesRange(version, range) {
388
+ const trimmed = range.trim();
389
+ if (trimmed === "*" || trimmed === "") return true;
390
+ const v = parseVersion(version);
391
+ if (!v) return false;
392
+ for (const clause of trimmed.split("||").map((c) => c.trim())) {
393
+ if (satisfiesClause(v, clause)) return true;
394
+ }
395
+ return false;
396
+ }
397
+ function satisfiesClause(v, clause) {
398
+ const m = /^(\^|~|>=|>|<=|<|=)?\s*v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(clause);
399
+ if (!m) return false;
400
+ const op = m[1] ?? "=";
401
+ const target = [Number(m[2]), Number(m[3] ?? 0), Number(m[4] ?? 0)];
402
+ const cmp = compare(v, target);
403
+ switch (op) {
404
+ case ">=":
405
+ return cmp >= 0;
406
+ case ">":
407
+ return cmp > 0;
408
+ case "<=":
409
+ return cmp <= 0;
410
+ case "<":
411
+ return cmp < 0;
412
+ case "=":
413
+ return cmp === 0;
414
+ case "~":
415
+ return cmp >= 0 && v[0] === target[0] && v[1] === target[1];
416
+ case "^":
417
+ if (target[0] === 0) return cmp >= 0 && v[0] === 0 && v[1] === target[1];
418
+ return cmp >= 0 && v[0] === target[0];
419
+ default:
420
+ return false;
421
+ }
422
+ }
423
+ function parseVersion(version) {
424
+ const m = /^v?(\d+)\.(\d+)(?:\.(\d+))?/.exec(version.trim());
425
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3] ?? 0)] : null;
426
+ }
427
+ function compare(a, b) {
428
+ for (let i = 0; i < 3; i++) {
429
+ if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
430
+ }
431
+ return 0;
310
432
  }
311
433
  function checkCapabilities(manifest, available) {
312
- const needed = manifest.capabilities ?? [];
313
- const missing = needed.filter((c) => available[c] !== true);
314
- if (missing.length === 0) return [];
315
- return [
316
- warn(
317
- "capabilities",
318
- `${manifest.kind} needs ${missing.join(", ")} wired on the host. Until then the node will fail at run time rather than at install.`
319
- )
320
- ];
434
+ const needed = manifest.capabilities ?? {};
435
+ const problems = [];
436
+ for (const [id, requirement] of Object.entries(needed)) {
437
+ if (available[id] === true) continue;
438
+ problems.push(
439
+ requirement === "required" ? err("capabilities", `${manifest.kind} requires the ${id} capability, which this host has not registered. The node cannot run here.`) : warn("capabilities", `${manifest.kind} can use the ${id} capability, which this host has not registered. The node runs with reduced behaviour.`)
440
+ );
441
+ }
442
+ return problems;
443
+ }
444
+ function registerLlmClient(client) {
445
+ return () => {
446
+ };
447
+ }
448
+ function registerWorkflowResolver(resolver) {
449
+ return () => {
450
+ };
321
451
  }
322
452
 
323
453
  // src/marketplace/fixtures.ts
@@ -332,7 +462,7 @@ function buildGraph(kindId, testCase) {
332
462
  data: { kind: kindId, config: testCase.config ?? {} }
333
463
  };
334
464
  const kind = getNodeKind(kindId) ?? void 0;
335
- const ports = (resolveNodePorts(subject, kind).outputs ?? []).map((p) => p.id);
465
+ const ports = testCase.ports ?? (resolveNodePorts(subject, kind).outputs ?? []).map((p) => p.id);
336
466
  const effective = ports.length ? ports : ["out"];
337
467
  const nodes = [
338
468
  { id: TRIGGER, type: "manual_trigger", position: { x: 0, y: 0 }, data: {} },
@@ -360,17 +490,67 @@ function buildGraph(kindId, testCase) {
360
490
  function deepEqual(a, b) {
361
491
  return JSON.stringify(a) === JSON.stringify(b);
362
492
  }
493
+ function installStubs(stubs) {
494
+ if (!stubs) return () => {
495
+ };
496
+ const teardown = [];
497
+ if (stubs.llm_client) {
498
+ stubs.llm_client.chooseRoute;
499
+ teardown.push(registerLlmClient());
500
+ }
501
+ if (stubs.workflow_resolver) {
502
+ stubs.workflow_resolver;
503
+ teardown.push(registerWorkflowResolver());
504
+ }
505
+ return () => teardown.forEach((fn) => fn());
506
+ }
507
+ function matchesEvent(event, want) {
508
+ if (event.type !== want.type) return false;
509
+ const e = event;
510
+ if (want.nodeId !== void 0 && e.nodeId !== want.nodeId) return false;
511
+ if (want.level !== void 0 && e.level !== want.level) return false;
512
+ if (want.messageContains !== void 0 && !String(e.message ?? "").includes(want.messageContains)) return false;
513
+ return true;
514
+ }
515
+ async function resume(kindId, testCase, executor, pausedNodeId, submit) {
516
+ const { graph } = buildGraph(kindId, testCase);
517
+ const fired = [];
518
+ let carried;
519
+ const executors = {
520
+ manual_trigger: () => testCase.inputs ?? {},
521
+ "@particle-academy/manual_trigger": () => testCase.inputs ?? {},
522
+ [kindId]: executor
523
+ };
524
+ executors[pausedNodeId] = ((c) => executor({ ...c, inputs: { ...c.inputs, values: submit } }));
525
+ for (const node of graph.nodes) {
526
+ if (!node.id.startsWith("probe:")) continue;
527
+ const port = node.id.slice("probe:".length);
528
+ executors[node.id] = ((c) => {
529
+ fired.push(port);
530
+ carried = c.inputs?.in ?? c.inputs;
531
+ return void 0;
532
+ });
533
+ }
534
+ const releaseStubs = installStubs(testCase.stubs);
535
+ const result = await runFlow(graph, executors, () => {
536
+ });
537
+ releaseStubs();
538
+ return { ok: result.ok, error: result.error, fired, carried };
539
+ }
363
540
  async function runFixtures(file, executor) {
364
541
  const failures = [];
365
542
  let passed = 0;
366
543
  for (const testCase of file.cases) {
367
- const { graph } = buildGraph(file.kind, testCase);
544
+ const kindId = testCase.legacyKind ?? file.kind;
545
+ const { graph } = buildGraph(kindId, testCase);
368
546
  const fired = [];
547
+ const events = [];
369
548
  let carried;
549
+ const releaseStubs = installStubs(testCase.stubs);
370
550
  const executors = {
371
551
  manual_trigger: () => testCase.inputs ?? {},
372
552
  "@particle-academy/manual_trigger": () => testCase.inputs ?? {},
373
- [file.kind]: executor
553
+ [kindId]: executor
374
554
  };
375
555
  for (const node of graph.nodes) {
376
556
  if (!node.id.startsWith("probe:")) continue;
@@ -381,8 +561,8 @@ async function runFixtures(file, executor) {
381
561
  return void 0;
382
562
  });
383
563
  }
384
- const result = await runFlow(graph, executors, () => {
385
- });
564
+ const result = await runFlow(graph, executors, (e) => events.push(e));
565
+ releaseStubs();
386
566
  const fail = (message) => failures.push({ case: testCase.name, message });
387
567
  const expected = testCase.expect;
388
568
  let caseOk = true;
@@ -425,6 +605,40 @@ async function runFixtures(file, executor) {
425
605
  fail(`expected the value carried downstream to be ${JSON.stringify(expected.value)}, got ${JSON.stringify(carried)}`);
426
606
  }
427
607
  }
608
+ for (const want of expected.events ?? []) {
609
+ if (!events.some((e) => matchesEvent(e, want))) {
610
+ caseOk = false;
611
+ fail(`expected an emitted event matching ${JSON.stringify(want)}, but none of the ${events.length} emitted events did`);
612
+ }
613
+ }
614
+ if (expected.afterResume) {
615
+ const paused = decodePause(result.error);
616
+ if (!paused) {
617
+ caseOk = false;
618
+ fail("expected the run to pause before resuming, but it never paused");
619
+ } else {
620
+ const resumed = await resume(kindId, testCase, executor, paused.nodeId, expected.afterResume.submit);
621
+ const want = expected.afterResume;
622
+ if (want.error !== void 0) {
623
+ if (resumed.ok || !String(resumed.error ?? "").includes(want.error)) {
624
+ caseOk = false;
625
+ fail(`after resume: expected an error containing "${want.error}", got ${resumed.ok ? "success" : `"${resumed.error}"`}`);
626
+ }
627
+ }
628
+ if (want.ports !== void 0) {
629
+ const got = [...resumed.fired].sort();
630
+ const wanted = [...want.ports].sort();
631
+ if (!deepEqual(got, wanted)) {
632
+ caseOk = false;
633
+ fail(`after resume: expected ports [${wanted.join(", ")}] to reach a downstream node, but [${got.join(", ")}] did`);
634
+ }
635
+ }
636
+ if ("value" in want && !deepEqual(resumed.carried, want.value)) {
637
+ caseOk = false;
638
+ fail(`after resume: expected the value carried downstream to be ${JSON.stringify(want.value)}, got ${JSON.stringify(resumed.carried)}`);
639
+ }
640
+ }
641
+ }
428
642
  if (caseOk) passed += 1;
429
643
  }
430
644
  return { ok: failures.length === 0, passed, failures };
@@ -444,6 +658,15 @@ function validateFixtureFile(input, expectedKind) {
444
658
  problems.push("`cases` must contain at least one case \u2014 an empty fixture file proves nothing.");
445
659
  return problems;
446
660
  }
661
+ const coversFailure = f.cases.some((c) => {
662
+ const expect = c?.expect;
663
+ return Boolean(expect && ("error" in expect || "pause" in expect));
664
+ });
665
+ if (!coversFailure) {
666
+ problems.push(
667
+ "At least one case must assert a failure (`expect.error`) or a pause (`expect.pause`). Every case here covers a success path, and the divergence this format exists to catch reported success while doing nothing."
668
+ );
669
+ }
447
670
  f.cases.forEach((c, i) => {
448
671
  const label = `cases[${i}]`;
449
672
  if (typeof c !== "object" || c === null) {
@@ -480,6 +703,7 @@ exports.isPause = isPause;
480
703
  exports.pauseForHuman = pauseForHuman;
481
704
  exports.runFixtures = runFixtures;
482
705
  exports.runFlow = runFlow;
706
+ exports.satisfiesRange = satisfiesRange;
483
707
  exports.validateFixtureFile = validateFixtureFile;
484
708
  exports.validateNodeManifest = validateNodeManifest;
485
709
  //# sourceMappingURL=engine.cjs.map