@cynodia/axiom-runtime 0.3.1-alpha.1 → 0.4.0-alpha.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.
@@ -5,6 +5,10 @@
5
5
  * throws in strict mode, which is what keeps "no implicit object mutation" an enforced
6
6
  * invariant rather than a convention.
7
7
  */
8
+ /**
9
+ * A structured clone, not a JSON round trip: a JSON round trip turns values like NaN into
10
+ * null, which would silently disguise a failed computation as an absent one.
11
+ */
8
12
  export declare function cloneValue<T>(value: T): T;
9
13
  export declare function deepFreeze<T>(value: T): T;
10
14
  export declare function isRecord(value: unknown): value is Record<string, unknown>;
@@ -5,8 +5,12 @@
5
5
  * throws in strict mode, which is what keeps "no implicit object mutation" an enforced
6
6
  * invariant rather than a convention.
7
7
  */
8
+ /**
9
+ * A structured clone, not a JSON round trip: a JSON round trip turns values like NaN into
10
+ * null, which would silently disguise a failed computation as an absent one.
11
+ */
8
12
  export function cloneValue(value) {
9
- return value === undefined ? value : JSON.parse(JSON.stringify(value));
13
+ return value === undefined ? value : structuredClone(value);
10
14
  }
11
15
  export function deepFreeze(value) {
12
16
  if (value === null || typeof value !== 'object' || Object.isFrozen(value)) {
package/dist/runtime.d.ts CHANGED
@@ -1,12 +1,48 @@
1
- import type { ApplicationIR, CompiledRoute, FieldId, NodeId } from '@cynodia/axiom-core';
1
+ import type { ApplicationIR, CompiledRoute, FieldId, Location, NodeId } from '@cynodia/axiom-core';
2
2
  import type { DomElement, HostEnvironment } from './dom.js';
3
3
  import type { MutationLogEntry } from './mutation/mutation-engine.js';
4
+ /**
5
+ * The diagnostics a runtime can report. Agents match on the code rather than parsing the
6
+ * message, so this vocabulary is part of the public contract.
7
+ */
8
+ export declare const RUNTIME_DIAGNOSTIC_CODES: {
9
+ readonly ACTION_NOT_FOUND: "ACTION_NOT_FOUND";
10
+ readonly PARAMETER_MISSING: "PARAMETER_MISSING";
11
+ readonly PRECONDITION_FAILED: "PRECONDITION_FAILED";
12
+ readonly POSTCONDITION_FAILED: "POSTCONDITION_FAILED";
13
+ readonly CONSTRAINT_VIOLATION: "CONSTRAINT_VIOLATION";
14
+ readonly REQUIRED_FIELD_MISSING: "REQUIRED_FIELD_MISSING";
15
+ readonly ENUM_VALUE_INVALID: "ENUM_VALUE_INVALID";
16
+ readonly TYPE_MISMATCH: "TYPE_MISMATCH";
17
+ readonly EXPRESSION_EVALUATION_FAILED: "EXPRESSION_EVALUATION_FAILED";
18
+ readonly UNRESOLVED_REFERENCE: "UNRESOLVED_REFERENCE";
19
+ readonly LOCATION_RESOLUTION_FAILED: "LOCATION_RESOLUTION_FAILED";
20
+ readonly MUTATION_FAILED: "MUTATION_FAILED";
21
+ readonly DERIVED_STATE_WRITE: "DERIVED_STATE_WRITE";
22
+ readonly UNKNOWN_STATE: "UNKNOWN_STATE";
23
+ readonly UNSUPPORTED_EXPRESSION: "UNSUPPORTED_EXPRESSION";
24
+ readonly UNSUPPORTED_OPERATION: "UNSUPPORTED_OPERATION";
25
+ readonly ROUTE_NOT_FOUND: "ROUTE_NOT_FOUND";
26
+ readonly NATIVE_OPERATION_MISSING: "NATIVE_OPERATION_MISSING";
27
+ readonly UI_NODE_MISSING: "UI_NODE_MISSING";
28
+ readonly UNSUPPORTED_UI_NODE: "UNSUPPORTED_UI_NODE";
29
+ readonly INPUT_REJECTED: "INPUT_REJECTED";
30
+ readonly PERSISTED_STATE_UNREADABLE: "PERSISTED_STATE_UNREADABLE";
31
+ };
32
+ export type RuntimeDiagnosticCode = (typeof RUNTIME_DIAGNOSTIC_CODES)[keyof typeof RUNTIME_DIAGNOSTIC_CODES];
4
33
  export interface RuntimeDiagnostic {
5
- code: string;
34
+ code: RuntimeDiagnosticCode;
6
35
  message: string;
7
36
  severity: 'error' | 'warning';
8
37
  nodeId?: NodeId;
9
38
  fieldId?: FieldId;
39
+ actionId?: NodeId;
40
+ constraintId?: NodeId;
41
+ stateId?: NodeId;
42
+ location?: Location;
43
+ transactionId?: string;
44
+ /** Structured context, so an agent never has to read the message. */
45
+ details?: Record<string, unknown>;
10
46
  }
11
47
  export interface ActionResult {
12
48
  ok: boolean;
@@ -37,7 +73,9 @@ export interface AxiomRuntime {
37
73
  invokeAction(id: NodeId, args?: Record<string, unknown>): ActionResult;
38
74
  navigate(path: string): void;
39
75
  currentRoute(): RouteMatch | null;
76
+ /** Every diagnostic reported so far. Per-invocation results carry their own. */
40
77
  diagnostics(): RuntimeDiagnostic[];
78
+ clearDiagnostics(): void;
41
79
  /** Every mutation this runtime has applied, in order, with its semantic location. */
42
80
  getMutationLog(): MutationLogEntry[];
43
81
  registerNativeOperation(implementationId: string, implementation: NativeImplementation): void;
package/dist/runtime.js CHANGED
@@ -3,6 +3,34 @@ import { LocationResolutionError, resolveLocation } from './mutation/resolve-loc
3
3
  import { createStateStore } from './mutation/store.js';
4
4
  import { createTransactionManager } from './mutation/transaction.js';
5
5
  import { cloneValue, compareValues, deepFreeze, isPresent, isRecord, toBoolean, toText, valuesEqual, } from './mutation/values.js';
6
+ /**
7
+ * The diagnostics a runtime can report. Agents match on the code rather than parsing the
8
+ * message, so this vocabulary is part of the public contract.
9
+ */
10
+ export const RUNTIME_DIAGNOSTIC_CODES = {
11
+ ACTION_NOT_FOUND: 'ACTION_NOT_FOUND',
12
+ PARAMETER_MISSING: 'PARAMETER_MISSING',
13
+ PRECONDITION_FAILED: 'PRECONDITION_FAILED',
14
+ POSTCONDITION_FAILED: 'POSTCONDITION_FAILED',
15
+ CONSTRAINT_VIOLATION: 'CONSTRAINT_VIOLATION',
16
+ REQUIRED_FIELD_MISSING: 'REQUIRED_FIELD_MISSING',
17
+ ENUM_VALUE_INVALID: 'ENUM_VALUE_INVALID',
18
+ TYPE_MISMATCH: 'TYPE_MISMATCH',
19
+ EXPRESSION_EVALUATION_FAILED: 'EXPRESSION_EVALUATION_FAILED',
20
+ UNRESOLVED_REFERENCE: 'UNRESOLVED_REFERENCE',
21
+ LOCATION_RESOLUTION_FAILED: 'LOCATION_RESOLUTION_FAILED',
22
+ MUTATION_FAILED: 'MUTATION_FAILED',
23
+ DERIVED_STATE_WRITE: 'DERIVED_STATE_WRITE',
24
+ UNKNOWN_STATE: 'UNKNOWN_STATE',
25
+ UNSUPPORTED_EXPRESSION: 'UNSUPPORTED_EXPRESSION',
26
+ UNSUPPORTED_OPERATION: 'UNSUPPORTED_OPERATION',
27
+ ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND',
28
+ NATIVE_OPERATION_MISSING: 'NATIVE_OPERATION_MISSING',
29
+ UI_NODE_MISSING: 'UI_NODE_MISSING',
30
+ UNSUPPORTED_UI_NODE: 'UNSUPPORTED_UI_NODE',
31
+ INPUT_REJECTED: 'INPUT_REJECTED',
32
+ PERSISTED_STATE_UNREADABLE: 'PERSISTED_STATE_UNREADABLE',
33
+ };
6
34
  const MISSING = Symbol('missing');
7
35
  function unwrapType(type) {
8
36
  return type.kind === 'optional' ? unwrapType(type.valueType) : type;
@@ -30,6 +58,19 @@ function defaultForType(type) {
30
58
  return null;
31
59
  }
32
60
  }
61
+ /** A short, structured description of a runtime value, for diagnostics. */
62
+ function describeValue(value) {
63
+ if (value === null || value === undefined) {
64
+ return 'nothing';
65
+ }
66
+ if (Array.isArray(value)) {
67
+ return `a collection of ${value.length}`;
68
+ }
69
+ if (typeof value === 'object') {
70
+ return 'a record';
71
+ }
72
+ return `${typeof value} ${JSON.stringify(value)}`;
73
+ }
33
74
  export function createAxiomRuntime(options) {
34
75
  const { ir, rootElement, host } = options;
35
76
  const store = createStateStore();
@@ -56,12 +97,27 @@ export function createAxiomRuntime(options) {
56
97
  parameterTypes.set(parameter.id, parameter.valueType);
57
98
  }
58
99
  }
100
+ /** Diagnostics reported while the current invocation runs, if one is collecting. */
101
+ let collector = null;
59
102
  function report(diagnostic) {
60
103
  diagnostics.push(diagnostic);
104
+ collector?.push(diagnostic);
61
105
  if (diagnostic.severity === 'error') {
62
106
  host.report?.(`${diagnostic.code}: ${diagnostic.message}`);
63
107
  }
64
108
  }
109
+ /** Runs `body` while gathering every diagnostic it reports. */
110
+ function collecting(body) {
111
+ const previous = collector;
112
+ const collected = [];
113
+ collector = collected;
114
+ try {
115
+ return body(collected);
116
+ }
117
+ finally {
118
+ collector = previous;
119
+ }
120
+ }
65
121
  // ---------------------------------------------------------------- state store
66
122
  function storageKey(state) {
67
123
  if (state.persistence?.kind !== 'local-storage') {
@@ -84,7 +140,7 @@ export function createAxiomRuntime(options) {
84
140
  }
85
141
  catch {
86
142
  report({
87
- code: 'PERSISTED_STATE_UNREADABLE',
143
+ code: RUNTIME_DIAGNOSTIC_CODES.PERSISTED_STATE_UNREADABLE,
88
144
  message: `Stored value for ${state.id} could not be parsed; falling back to the initial value`,
89
145
  severity: 'warning',
90
146
  nodeId: state.id,
@@ -126,7 +182,7 @@ export function createAxiomRuntime(options) {
126
182
  function writeState(stateId, value) {
127
183
  if (!statesById.has(stateId)) {
128
184
  report({
129
- code: 'UNKNOWN_STATE',
185
+ code: RUNTIME_DIAGNOSTIC_CODES.UNKNOWN_STATE,
130
186
  message: `Cannot write to unknown state ${stateId}`,
131
187
  severity: 'error',
132
188
  nodeId: stateId,
@@ -135,7 +191,7 @@ export function createAxiomRuntime(options) {
135
191
  }
136
192
  if (statesById.get(stateId)?.derivation) {
137
193
  report({
138
- code: 'DERIVED_STATE_WRITE',
194
+ code: RUNTIME_DIAGNOSTIC_CODES.DERIVED_STATE_WRITE,
139
195
  message: `${stateId} is derived state and cannot be written to`,
140
196
  severity: 'error',
141
197
  nodeId: stateId,
@@ -196,7 +252,7 @@ export function createAxiomRuntime(options) {
196
252
  }
197
253
  catch (error) {
198
254
  const failure = {
199
- code: error instanceof LocationResolutionError ? 'LOCATION_UNRESOLVED' : 'MUTATION_FAILED',
255
+ code: error instanceof LocationResolutionError ? RUNTIME_DIAGNOSTIC_CODES.LOCATION_RESOLUTION_FAILED : 'MUTATION_FAILED',
200
256
  message: error instanceof Error ? error.message : String(error),
201
257
  severity: 'error',
202
258
  ...(context.sourceNodeId ? { nodeId: context.sourceNodeId } : {}),
@@ -243,7 +299,7 @@ export function createAxiomRuntime(options) {
243
299
  return readState(expression.targetId);
244
300
  }
245
301
  report({
246
- code: 'UNRESOLVED_REFERENCE',
302
+ code: RUNTIME_DIAGNOSTIC_CODES.UNRESOLVED_REFERENCE,
247
303
  message: `Reference ${expression.targetId} could not be resolved`,
248
304
  severity: 'error',
249
305
  nodeId: expression.targetId,
@@ -280,6 +336,32 @@ export function createAxiomRuntime(options) {
280
336
  }
281
337
  return source.filter((item) => toBoolean(evaluate(expression.predicate, childScope(scope, expression.scopeId, item))));
282
338
  }
339
+ case 'map': {
340
+ const source = evaluate(expression.source, scope);
341
+ if (!Array.isArray(source)) {
342
+ report({
343
+ code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
344
+ message: `map expects a collection but received ${describeValue(source)}`,
345
+ severity: 'error',
346
+ });
347
+ return [];
348
+ }
349
+ return source.map((item) => evaluate(expression.projection, childScope(scope, expression.scopeId, item)));
350
+ }
351
+ case 'sort': {
352
+ const source = evaluate(expression.source, scope);
353
+ if (!Array.isArray(source)) {
354
+ report({
355
+ code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
356
+ message: `sort expects a collection but received ${describeValue(source)}`,
357
+ severity: 'error',
358
+ });
359
+ return [];
360
+ }
361
+ const direction = expression.direction === 'desc' ? -1 : 1;
362
+ const key = (item) => evaluate(expression.by, childScope(scope, expression.scopeId, item));
363
+ return [...source].sort((left, right) => direction * compareValues(key(left), key(right)));
364
+ }
283
365
  case 'find': {
284
366
  const source = evaluate(expression.source, scope);
285
367
  if (!Array.isArray(source)) {
@@ -290,7 +372,7 @@ export function createAxiomRuntime(options) {
290
372
  }
291
373
  default:
292
374
  report({
293
- code: 'UNKNOWN_EXPRESSION',
375
+ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION,
294
376
  message: `Unknown expression kind "${expression.kind}"`,
295
377
  severity: 'error',
296
378
  });
@@ -330,7 +412,7 @@ export function createAxiomRuntime(options) {
330
412
  return divisor === 0 ? null : Number(left ?? 0) / divisor;
331
413
  }
332
414
  default:
333
- report({ code: 'UNKNOWN_OPERATOR', message: `Unknown operator "${operator}"`, severity: 'error' });
415
+ report({ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION, message: `Unknown operator "${operator}"`, severity: 'error' });
334
416
  return null;
335
417
  }
336
418
  }
@@ -345,6 +427,34 @@ export function createAxiomRuntime(options) {
345
427
  return Array.isArray(values[0]) ? values[0].length : toText(values[0]).length;
346
428
  case 'count':
347
429
  return Array.isArray(values[0]) ? values[0].length : 0;
430
+ case 'sum': {
431
+ // An aggregation that cannot be computed must not quietly produce a value that
432
+ // makes a guard pass. It reports, and yields a number no comparison accepts.
433
+ const source = values[0];
434
+ if (!Array.isArray(source)) {
435
+ report({
436
+ code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
437
+ message: `sum expects a collection of numbers but received ${describeValue(source)}`,
438
+ severity: 'error',
439
+ details: { received: source },
440
+ });
441
+ return Number.NaN;
442
+ }
443
+ let total = 0;
444
+ for (const member of source) {
445
+ if (typeof member !== 'number' || !Number.isFinite(member)) {
446
+ report({
447
+ code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
448
+ message: `sum encountered ${describeValue(member)} where a number was required`,
449
+ severity: 'error',
450
+ details: { member },
451
+ });
452
+ return Number.NaN;
453
+ }
454
+ total += member;
455
+ }
456
+ return total;
457
+ }
348
458
  case 'contains': {
349
459
  const [haystack, needle] = values;
350
460
  if (Array.isArray(haystack)) {
@@ -367,7 +477,7 @@ export function createAxiomRuntime(options) {
367
477
  case 'uuid':
368
478
  return host.uuid();
369
479
  default:
370
- report({ code: 'UNKNOWN_FUNCTION', message: `Unknown function "${fn}"`, severity: 'error' });
480
+ report({ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_EXPRESSION, message: `Unknown function "${fn}"`, severity: 'error' });
371
481
  return null;
372
482
  }
373
483
  }
@@ -380,32 +490,51 @@ export function createAxiomRuntime(options) {
380
490
  }
381
491
  return resolved.kind === 'entity' ? resolved.entityId : null;
382
492
  }
383
- function instancesOf(entityId) {
384
- const instances = [];
493
+ /**
494
+ * Every canonical occurrence of every entity, found by walking state values against
495
+ * their declared types. Entities nested inside collections and inside other entities
496
+ * are reached recursively, so their constraints apply wherever they actually live.
497
+ */
498
+ function collectInstances() {
499
+ const found = new Map();
500
+ const visit = (value, type) => {
501
+ const resolved = unwrapType(type);
502
+ if (resolved.kind === 'collection') {
503
+ if (Array.isArray(value)) {
504
+ for (const item of value) {
505
+ visit(item, resolved.itemType);
506
+ }
507
+ }
508
+ return;
509
+ }
510
+ if (resolved.kind !== 'entity' || !isRecord(value)) {
511
+ return;
512
+ }
513
+ const instances = found.get(resolved.entityId) ?? [];
514
+ instances.push(value);
515
+ found.set(resolved.entityId, instances);
516
+ for (const field of entitiesById.get(resolved.entityId)?.fields ?? []) {
517
+ visit(value[field.id], field.valueType);
518
+ }
519
+ };
385
520
  for (const state of ir.states) {
386
521
  // Drafts are incomplete by definition, and derived states are views of data that
387
522
  // is already validated where it is stored.
388
523
  if (state.draft || state.derivation) {
389
524
  continue;
390
525
  }
391
- if (collectionEntityId(state) !== entityId) {
392
- continue;
393
- }
394
- const value = readState(state.id);
395
- if (Array.isArray(value)) {
396
- instances.push(...value);
397
- }
398
- else if (isRecord(value)) {
399
- instances.push(value);
400
- }
526
+ visit(readState(state.id), state.valueType);
401
527
  }
402
- return instances;
528
+ return found;
529
+ }
530
+ function instancesOf(entityId) {
531
+ return collectInstances().get(entityId) ?? [];
403
532
  }
404
533
  function checkFieldValue(field, value, entityId) {
405
534
  if (!isPresent(value)) {
406
535
  if (field.required) {
407
536
  return {
408
- code: 'REQUIRED_FIELD_MISSING',
537
+ code: RUNTIME_DIAGNOSTIC_CODES.REQUIRED_FIELD_MISSING,
409
538
  message: `${field.name ?? field.id} is required`,
410
539
  severity: 'error',
411
540
  nodeId: entityId,
@@ -417,7 +546,7 @@ export function createAxiomRuntime(options) {
417
546
  const resolved = unwrapType(field.valueType);
418
547
  if (resolved.kind === 'enum' && !resolved.values.includes(toText(value))) {
419
548
  return {
420
- code: 'ENUM_VALUE_INVALID',
549
+ code: RUNTIME_DIAGNOSTIC_CODES.ENUM_VALUE_INVALID,
421
550
  message: `${field.name ?? field.id} must be one of: ${resolved.values.join(', ')}`,
422
551
  severity: 'error',
423
552
  nodeId: entityId,
@@ -426,7 +555,7 @@ export function createAxiomRuntime(options) {
426
555
  }
427
556
  if (resolved.kind === 'primitive' && resolved.primitive === 'number' && typeof value !== 'number') {
428
557
  return {
429
- code: 'TYPE_MISMATCH',
558
+ code: RUNTIME_DIAGNOSTIC_CODES.TYPE_MISMATCH,
430
559
  message: `${field.name ?? field.id} must be a number`,
431
560
  severity: 'error',
432
561
  nodeId: entityId,
@@ -435,7 +564,7 @@ export function createAxiomRuntime(options) {
435
564
  }
436
565
  if (resolved.kind === 'primitive' && resolved.primitive === 'boolean' && typeof value !== 'boolean') {
437
566
  return {
438
- code: 'TYPE_MISMATCH',
567
+ code: RUNTIME_DIAGNOSTIC_CODES.TYPE_MISMATCH,
439
568
  message: `${field.name ?? field.id} must be a boolean`,
440
569
  severity: 'error',
441
570
  nodeId: entityId,
@@ -444,11 +573,15 @@ export function createAxiomRuntime(options) {
444
573
  }
445
574
  return null;
446
575
  }
447
- /** Schema conformance plus declared constraints, evaluated over live instances. */
576
+ /**
577
+ * Schema conformance plus declared constraints, evaluated over every canonical instance
578
+ * — including instances nested inside collections and inside other entities.
579
+ */
448
580
  function evaluateInvariants() {
449
581
  const failures = [];
582
+ const instances = collectInstances();
450
583
  for (const entity of ir.entities) {
451
- for (const instance of instancesOf(entity.id)) {
584
+ for (const instance of instances.get(entity.id) ?? []) {
452
585
  if (!isRecord(instance)) {
453
586
  continue;
454
587
  }
@@ -461,7 +594,7 @@ export function createAxiomRuntime(options) {
461
594
  }
462
595
  }
463
596
  for (const constraint of ir.constraints) {
464
- failures.push(...evaluateConstraint(constraint));
597
+ failures.push(...evaluateConstraint(constraint, instances));
465
598
  }
466
599
  return failures;
467
600
  }
@@ -503,15 +636,21 @@ export function createAxiomRuntime(options) {
503
636
  }
504
637
  return introduced;
505
638
  }
506
- function evaluateConstraint(constraint) {
639
+ /**
640
+ * An entity-scoped constraint is evaluated once per instance, with `ref(entityId)`
641
+ * bound to the instance under validation.
642
+ */
643
+ function evaluateConstraint(constraint, instances) {
507
644
  const severity = constraint.severity ?? 'error';
508
645
  const failures = [];
509
- const record = () => {
646
+ const record = (instance) => {
510
647
  failures.push({
511
- code: 'CONSTRAINT_VIOLATION',
648
+ code: RUNTIME_DIAGNOSTIC_CODES.CONSTRAINT_VIOLATION,
512
649
  message: constraint.message ?? `Constraint ${constraint.name ?? constraint.id} failed`,
513
650
  severity,
514
651
  nodeId: constraint.id,
652
+ constraintId: constraint.id,
653
+ ...(constraint.entityId ? { details: { entityId: constraint.entityId, instance } } : {}),
515
654
  });
516
655
  };
517
656
  if (!constraint.entityId) {
@@ -520,10 +659,10 @@ export function createAxiomRuntime(options) {
520
659
  }
521
660
  return failures;
522
661
  }
523
- for (const instance of instancesOf(constraint.entityId)) {
662
+ for (const instance of instances.get(constraint.entityId) ?? []) {
524
663
  const scope = childScope(rootScope(), constraint.entityId, instance);
525
664
  if (!toBoolean(evaluate(constraint.expression, scope))) {
526
- record();
665
+ record(instance);
527
666
  }
528
667
  }
529
668
  return failures;
@@ -536,6 +675,29 @@ export function createAxiomRuntime(options) {
536
675
  case 'remove':
537
676
  mutate(() => mutations.apply(operation, scope, context), context, result);
538
677
  return;
678
+ case 'for-each': {
679
+ // The members are read once, before any of them are mutated, so the iteration
680
+ // walks the collection as it stood when the operation began. Nothing here opens
681
+ // a transaction: these mutations belong to the action's own transaction, and a
682
+ // failure in any iteration rolls back every iteration with it.
683
+ const members = evaluate(operation.collection, scope);
684
+ if (!Array.isArray(members)) {
685
+ result.push({
686
+ code: RUNTIME_DIAGNOSTIC_CODES.EXPRESSION_EVALUATION_FAILED,
687
+ message: `for-each expects a collection but received ${describeValue(members)}`,
688
+ severity: 'error',
689
+ ...(context.sourceNodeId ? { actionId: context.sourceNodeId } : {}),
690
+ });
691
+ return;
692
+ }
693
+ for (const member of members) {
694
+ const iteration = childScope(scope, operation.scopeId, member);
695
+ for (const nested of operation.operations ?? []) {
696
+ executeOperation(nested, iteration, context, result);
697
+ }
698
+ }
699
+ return;
700
+ }
539
701
  case 'invoke': {
540
702
  const args = {};
541
703
  for (const [parameterId, argument] of Object.entries(operation.arguments ?? {})) {
@@ -553,7 +715,7 @@ export function createAxiomRuntime(options) {
553
715
  const route = ir.routes.find((candidate) => candidate.id === operation.routeId);
554
716
  if (!route) {
555
717
  result.push({
556
- code: 'ROUTE_NOT_FOUND',
718
+ code: RUNTIME_DIAGNOSTIC_CODES.ROUTE_NOT_FOUND,
557
719
  message: `Navigate operation could not resolve route ${String(operation.routeId)}`,
558
720
  severity: 'error',
559
721
  });
@@ -570,7 +732,7 @@ export function createAxiomRuntime(options) {
570
732
  const implementation = natives.get(operation.implementationId);
571
733
  if (!implementation) {
572
734
  result.push({
573
- code: 'NATIVE_OPERATION_MISSING',
735
+ code: RUNTIME_DIAGNOSTIC_CODES.NATIVE_OPERATION_MISSING,
574
736
  message: `No implementation registered for "${operation.implementationId}"`,
575
737
  severity: 'error',
576
738
  });
@@ -590,22 +752,25 @@ export function createAxiomRuntime(options) {
590
752
  }
591
753
  default:
592
754
  result.push({
593
- code: 'UNKNOWN_OPERATION',
755
+ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_OPERATION,
594
756
  message: `Unknown operation kind "${operation.kind}"`,
595
757
  severity: 'error',
596
758
  });
597
759
  }
598
760
  }
599
761
  function runAction(actionId, args = {}) {
762
+ return collecting((collected) => runActionCollecting(actionId, args, collected));
763
+ }
764
+ function runActionCollecting(actionId, args, collected) {
600
765
  const action = ir.actions[actionId];
601
766
  if (!action) {
602
767
  const failure = {
603
- code: 'ACTION_NOT_FOUND',
768
+ code: RUNTIME_DIAGNOSTIC_CODES.ACTION_NOT_FOUND,
604
769
  message: `Action ${actionId} is not defined`,
605
770
  severity: 'error',
606
771
  };
607
772
  report(failure);
608
- return { ok: false, diagnostics: [failure] };
773
+ return { ok: false, diagnostics: [...collected] };
609
774
  }
610
775
  const scope = rootScope();
611
776
  for (const parameter of action.parameters ?? []) {
@@ -615,7 +780,7 @@ export function createAxiomRuntime(options) {
615
780
  for (const parameter of action.parameters ?? []) {
616
781
  if (parameter.required && !isPresent(scope.values.get(parameter.id))) {
617
782
  failures.push({
618
- code: 'PARAMETER_MISSING',
783
+ code: RUNTIME_DIAGNOSTIC_CODES.PARAMETER_MISSING,
619
784
  message: `Action ${action.name ?? action.id} requires ${parameter.name ?? parameter.id}`,
620
785
  severity: 'error',
621
786
  nodeId: action.id,
@@ -624,25 +789,29 @@ export function createAxiomRuntime(options) {
624
789
  }
625
790
  if (failures.length > 0) {
626
791
  failures.forEach(report);
627
- return { ok: false, diagnostics: failures };
628
- }
629
- for (const precondition of action.preconditions ?? []) {
630
- if (!toBoolean(evaluate(precondition, scope))) {
631
- const failure = {
632
- code: 'PRECONDITION_FAILED',
633
- message: action.failureModes?.[0]?.message ??
634
- `A precondition of ${action.name ?? action.id} was not satisfied`,
635
- severity: 'error',
636
- nodeId: action.id,
637
- };
638
- report(failure);
639
- return { ok: false, diagnostics: [failure] };
792
+ return { ok: false, diagnostics: [...collected] };
793
+ }
794
+ // Failure modes line up with preconditions by position, so a refusal says which
795
+ // condition was not met rather than always naming the first one.
796
+ for (const [index, precondition] of (action.preconditions ?? []).entries()) {
797
+ if (toBoolean(evaluate(precondition, scope))) {
798
+ continue;
640
799
  }
800
+ const mode = action.failureModes?.[index];
801
+ report({
802
+ code: RUNTIME_DIAGNOSTIC_CODES.PRECONDITION_FAILED,
803
+ message: mode?.message ?? `A precondition of ${action.name ?? action.id} was not satisfied`,
804
+ severity: 'error',
805
+ nodeId: action.id,
806
+ actionId: action.id,
807
+ details: { preconditionIndex: index, ...(mode?.code ? { failureMode: mode.code } : {}) },
808
+ });
809
+ return { ok: false, diagnostics: [...collected] };
641
810
  }
642
811
  if (action.requiresConfirmation) {
643
812
  const message = action.confirmationMessage ?? `Confirm ${action.name ?? action.id}. This cannot be undone.`;
644
813
  if (!host.confirm(message)) {
645
- return { ok: false, diagnostics: [] };
814
+ return { ok: false, diagnostics: [...collected] };
646
815
  }
647
816
  }
648
817
  const transaction = transactions.begin();
@@ -652,32 +821,41 @@ export function createAxiomRuntime(options) {
652
821
  transactionId: transaction.id,
653
822
  };
654
823
  const operationDiagnostics = [];
824
+ const reportedBefore = collected.length;
655
825
  for (const operation of action.operations ?? []) {
656
826
  executeOperation(operation, scope, context, operationDiagnostics);
657
827
  }
828
+ // Anything reported while the operations ran — an expression that could not be
829
+ // evaluated, for instance — is a failure of this action, not a passing curiosity.
658
830
  const violations = [
659
831
  ...operationDiagnostics.filter((diagnostic) => diagnostic.severity === 'error'),
832
+ ...collected.slice(reportedBefore).filter((diagnostic) => diagnostic.severity === 'error'),
660
833
  ...evaluateInvariants().filter((diagnostic) => diagnostic.severity === 'error'),
661
834
  ];
662
835
  for (const postcondition of action.postconditions ?? []) {
663
836
  if (!toBoolean(evaluate(postcondition, scope))) {
664
837
  violations.push({
665
- code: 'POSTCONDITION_FAILED',
838
+ code: RUNTIME_DIAGNOSTIC_CODES.POSTCONDITION_FAILED,
666
839
  message: `A postcondition of ${action.name ?? action.id} was not satisfied`,
667
840
  severity: 'error',
668
841
  nodeId: action.id,
842
+ actionId: action.id,
669
843
  });
670
844
  }
671
845
  }
672
846
  if (violations.length > 0) {
673
847
  settle(transaction, 'rolled-back');
674
- violations.forEach(report);
848
+ for (const violation of violations) {
849
+ if (!collected.includes(violation)) {
850
+ report({ ...violation, actionId: action.id, transactionId: transaction.id });
851
+ }
852
+ }
675
853
  renderApplication();
676
- return { ok: false, diagnostics: violations };
854
+ return { ok: false, diagnostics: [...collected] };
677
855
  }
678
856
  settle(transaction, 'committed');
679
857
  renderApplication();
680
- return { ok: true, diagnostics: operationDiagnostics };
858
+ return { ok: true, diagnostics: [...collected] };
681
859
  }
682
860
  // ------------------------------------------------------------------ routing
683
861
  function buildPath(route, values) {
@@ -841,7 +1019,7 @@ export function createAxiomRuntime(options) {
841
1019
  const node = ir.uiNodes[id];
842
1020
  if (!node) {
843
1021
  report({
844
- code: 'UI_NODE_MISSING',
1022
+ code: RUNTIME_DIAGNOSTIC_CODES.UI_NODE_MISSING,
845
1023
  message: `UI node ${id} is not defined`,
846
1024
  severity: 'error',
847
1025
  nodeId: id,
@@ -988,7 +1166,7 @@ export function createAxiomRuntime(options) {
988
1166
  settle(transaction, 'rolled-back');
989
1167
  introduced.forEach(report);
990
1168
  report({
991
- code: 'INPUT_REJECTED',
1169
+ code: RUNTIME_DIAGNOSTIC_CODES.INPUT_REJECTED,
992
1170
  message: `${node.label ?? node.id} kept its previous value: ${introduced[0].message}`,
993
1171
  severity: 'warning',
994
1172
  nodeId: node.id,
@@ -1036,7 +1214,7 @@ export function createAxiomRuntime(options) {
1036
1214
  }
1037
1215
  default:
1038
1216
  report({
1039
- code: 'UNKNOWN_UI_NODE',
1217
+ code: RUNTIME_DIAGNOSTIC_CODES.UNSUPPORTED_UI_NODE,
1040
1218
  message: `Unknown UI node kind "${node.kind}"`,
1041
1219
  severity: 'error',
1042
1220
  });
@@ -1117,6 +1295,9 @@ export function createAxiomRuntime(options) {
1117
1295
  diagnostics() {
1118
1296
  return diagnostics.map((diagnostic) => ({ ...diagnostic }));
1119
1297
  },
1298
+ clearDiagnostics() {
1299
+ diagnostics.length = 0;
1300
+ },
1120
1301
  getMutationLog() {
1121
1302
  return mutationLog.map((entry) => ({ ...entry }));
1122
1303
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cynodia/axiom-runtime",
3
- "version": "0.3.1-alpha.1",
3
+ "version": "0.4.0-alpha.1",
4
4
  "description": "Domain-independent runtime that executes an Axiom application graph.",
5
5
  "license": "MIT",
6
6
  "author": "AskTech AS",
@@ -31,7 +31,7 @@
31
31
  }
32
32
  },
33
33
  "dependencies": {
34
- "@cynodia/axiom-core": "0.3.1-alpha.1"
34
+ "@cynodia/axiom-core": "0.4.0-alpha.1"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc -b tsconfig.json tsconfig.test.json",