@grimoirelabs/core 0.19.0 → 0.21.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.
@@ -27,18 +27,104 @@ import { evaluatePreviewValueFlow, inferDriftClass, } from "./value-flow.js";
27
27
  // Keep preview-issued receipts in-process so commit only accepts known receipts.
28
28
  const issuedReceipts = new Map();
29
29
  const committedReceipts = new Set();
30
- /** Check if a trigger matches a filter string (e.g., "manual", "hourly", "daily") */
31
- function matchesTriggerFilter(trigger, filter) {
32
- if (trigger.type === filter)
33
- return true;
34
- if (filter === "hourly" && trigger.type === "schedule" && trigger.cron === "0 * * * *")
35
- return true;
36
- if (filter === "daily" && trigger.type === "schedule" && trigger.cron === "0 0 * * *")
37
- return true;
38
- return false;
30
+ function normalizeSelectedTrigger(selectedTrigger, triggerFilter) {
31
+ if (selectedTrigger && triggerFilter) {
32
+ throw new Error("selectedTrigger cannot be combined with triggerFilter");
33
+ }
34
+ const normalized = selectedTrigger ?? (triggerFilter ? { label: triggerFilter } : undefined);
35
+ if (!normalized) {
36
+ return undefined;
37
+ }
38
+ const definedFields = [normalized.id, normalized.index, normalized.label].filter((value) => value !== undefined);
39
+ if (definedFields.length > 1) {
40
+ throw new Error("selectedTrigger must define exactly one of id, index, or label");
41
+ }
42
+ return normalized;
43
+ }
44
+ function getSpellTriggerHandlers(spell) {
45
+ if (spell.triggerHandlers && spell.triggerHandlers.length > 0) {
46
+ return spell.triggerHandlers;
47
+ }
48
+ const primaryTrigger = spell.triggers[0];
49
+ if (primaryTrigger?.type === "any") {
50
+ return primaryTrigger.triggers.map((trigger, index) => {
51
+ const handlerTrigger = assertConcreteTrigger(trigger);
52
+ const stepIds = spell.triggerStepMap?.[index] ?? [];
53
+ return {
54
+ selector: {
55
+ id: `trigger_${index}`,
56
+ index,
57
+ label: describeTriggerLabel(handlerTrigger),
58
+ source: resolveTriggerSourceFromSpell(stepIds, spell),
59
+ },
60
+ trigger: handlerTrigger,
61
+ stepIds,
62
+ };
63
+ });
64
+ }
65
+ const trigger = (primaryTrigger ?? { type: "manual" });
66
+ return [
67
+ {
68
+ selector: {
69
+ id: "trigger_0",
70
+ index: 0,
71
+ label: describeTriggerLabel(trigger),
72
+ source: resolveTriggerSourceFromSpell(spell.steps.map((step) => step.id), spell),
73
+ },
74
+ trigger,
75
+ stepIds: spell.steps.map((step) => step.id),
76
+ },
77
+ ];
78
+ }
79
+ function resolveSelectedTriggerHandler(spell, selectedTrigger) {
80
+ if (!selectedTrigger) {
81
+ return undefined;
82
+ }
83
+ const handlers = getSpellTriggerHandlers(spell);
84
+ if (selectedTrigger.id !== undefined) {
85
+ const match = handlers.find((handler) => handler.selector.id === selectedTrigger.id);
86
+ if (!match) {
87
+ throw new Error(`Unknown trigger id "${selectedTrigger.id}". Available triggers: ${formatTriggerCandidates(handlers)}`);
88
+ }
89
+ return match;
90
+ }
91
+ if (selectedTrigger.index !== undefined) {
92
+ const match = handlers.find((handler) => handler.selector.index === selectedTrigger.index);
93
+ if (!match) {
94
+ throw new Error(`Unknown trigger index "${selectedTrigger.index}". Available triggers: ${formatTriggerCandidates(handlers)}`);
95
+ }
96
+ return match;
97
+ }
98
+ if (selectedTrigger.label !== undefined) {
99
+ const matches = handlers.filter((handler) => getTriggerMatchLabels(handler).has(selectedTrigger.label));
100
+ if (matches.length === 0) {
101
+ throw new Error(`Unknown trigger label "${selectedTrigger.label}". Available triggers: ${formatTriggerCandidates(handlers)}`);
102
+ }
103
+ if (matches.length > 1) {
104
+ throw new Error(`Ambiguous trigger label "${selectedTrigger.label}". Matching triggers: ${formatTriggerCandidates(matches)}`);
105
+ }
106
+ return matches[0];
107
+ }
108
+ return undefined;
109
+ }
110
+ function getTriggerMatchLabels(handler) {
111
+ const labels = new Set([handler.selector.label, handler.trigger.type]);
112
+ if (handler.trigger.type === "schedule") {
113
+ if (handler.trigger.cron === "0 * * * *") {
114
+ labels.add("hourly");
115
+ }
116
+ if (handler.trigger.cron === "0 0 * * *") {
117
+ labels.add("daily");
118
+ }
119
+ }
120
+ return labels;
121
+ }
122
+ function formatTriggerCandidates(handlers) {
123
+ return handlers
124
+ .map((handler) => `{id=${handler.selector.id}, index=${handler.selector.index}, label=${handler.selector.label}}`)
125
+ .join(", ");
39
126
  }
40
- /** Describe a trigger for user-facing messages */
41
- function describeTrigger(trigger) {
127
+ function describeTriggerLabel(trigger) {
42
128
  if (trigger.type === "schedule") {
43
129
  if (trigger.cron === "0 * * * *")
44
130
  return "hourly";
@@ -46,8 +132,26 @@ function describeTrigger(trigger) {
46
132
  return "daily";
47
133
  return `schedule(${trigger.cron})`;
48
134
  }
135
+ if (trigger.type === "event") {
136
+ return `event(${trigger.event})`;
137
+ }
49
138
  return trigger.type;
50
139
  }
140
+ function resolveTriggerSourceFromSpell(stepIds, spell) {
141
+ for (const stepId of stepIds) {
142
+ const loc = spell.sourceMap?.[stepId];
143
+ if (loc) {
144
+ return loc;
145
+ }
146
+ }
147
+ return { line: 1, column: 1 };
148
+ }
149
+ function assertConcreteTrigger(trigger) {
150
+ if (trigger.type === "any") {
151
+ throw new Error("Nested trigger.any handlers are not supported");
152
+ }
153
+ return trigger;
154
+ }
51
155
  /**
52
156
  * Preview a spell — runs the full step loop in simulation mode,
53
157
  * collects PlannedActions and ValueDeltas, and assembles a Receipt.
@@ -57,24 +161,25 @@ export async function preview(options) {
57
161
  // Apply trigger filter: narrow steps to only those from the matched trigger handler
58
162
  let spell = originalSpell;
59
163
  let triggerOverride = options.trigger;
60
- if (options.triggerFilter && originalSpell.triggerStepMap) {
61
- const anyTrigger = originalSpell.triggers.find((t) => t.type === "any");
62
- if (anyTrigger && anyTrigger.type === "any") {
63
- const filter = options.triggerFilter;
64
- const matchedIndex = anyTrigger.triggers.findIndex((t) => matchesTriggerFilter(t, filter));
65
- if (matchedIndex === -1) {
66
- const available = anyTrigger.triggers.map(describeTrigger).join(", ");
67
- throw new Error(`Unknown trigger "${options.triggerFilter}". Available triggers: ${available}`);
68
- }
69
- const allowedStepIds = new Set(originalSpell.triggerStepMap[matchedIndex] ?? []);
70
- spell = {
71
- ...originalSpell,
72
- steps: originalSpell.steps.filter((s) => allowedStepIds.has(s.id)),
73
- };
74
- // Set trigger context to the matched sub-trigger instead of "any"
75
- const matchedTrigger = anyTrigger.triggers[matchedIndex];
76
- triggerOverride = { type: matchedTrigger.type, source: matchedTrigger.type };
77
- }
164
+ let selectedTrigger;
165
+ const triggerSelection = normalizeSelectedTrigger(options.selectedTrigger, options.triggerFilter);
166
+ const matchedTriggerHandler = resolveSelectedTriggerHandler(originalSpell, triggerSelection);
167
+ if (matchedTriggerHandler) {
168
+ const allowedStepIds = new Set(matchedTriggerHandler.stepIds);
169
+ spell = {
170
+ ...originalSpell,
171
+ steps: originalSpell.steps.filter((step) => allowedStepIds.has(step.id)),
172
+ triggers: [matchedTriggerHandler.trigger],
173
+ triggerHandlers: [matchedTriggerHandler],
174
+ triggerStepMap: { 0: [...matchedTriggerHandler.stepIds] },
175
+ };
176
+ triggerOverride = {
177
+ ...matchedTriggerHandler.trigger,
178
+ id: matchedTriggerHandler.selector.id,
179
+ index: matchedTriggerHandler.selector.index,
180
+ label: matchedTriggerHandler.selector.label,
181
+ };
182
+ selectedTrigger = matchedTriggerHandler.selector;
78
183
  }
79
184
  // Always simulate during preview
80
185
  const actionExecution = { mode: "simulate" };
@@ -143,6 +248,7 @@ export async function preview(options) {
143
248
  receipt,
144
249
  error: structuredError,
145
250
  ledgerEvents: ledger.getEntries(),
251
+ selectedTrigger,
146
252
  };
147
253
  }
148
254
  // Run step loop in preview mode — action steps produce PlannedActions instead of executing
@@ -169,6 +275,7 @@ export async function preview(options) {
169
275
  receipt,
170
276
  error: structuredError,
171
277
  ledgerEvents: ledger.getEntries(),
278
+ selectedTrigger,
172
279
  };
173
280
  }
174
281
  // Post-execution guards
@@ -194,6 +301,7 @@ export async function preview(options) {
194
301
  receipt,
195
302
  error: structuredError,
196
303
  ledgerEvents: ledger.getEntries(),
304
+ selectedTrigger,
197
305
  };
198
306
  }
199
307
  const valueFlow = evaluatePreviewValueFlow(ctx, plannedActions, valueDeltas);
@@ -222,6 +330,7 @@ export async function preview(options) {
222
330
  receipt,
223
331
  error: structuredError,
224
332
  ledgerEvents: ledger.getEntries(),
333
+ selectedTrigger,
225
334
  };
226
335
  }
227
336
  const requiresApproval = valueFlow.requiresApproval;
@@ -255,16 +364,33 @@ export async function preview(options) {
255
364
  metrics: ctx.metrics,
256
365
  });
257
366
  ledger.emit({ type: "preview_completed", runId: ctx.runId, receiptId, status: "ready" });
258
- return { success: true, receipt, ledgerEvents: ledger.getEntries() };
367
+ return { success: true, receipt, ledgerEvents: ledger.getEntries(), selectedTrigger };
259
368
  }
260
369
  catch (error) {
261
370
  const message = error instanceof Error ? error.message : String(error);
262
371
  const structuredError = createStructuredError("preview", "PREVIEW_INTERNAL_ERROR", message);
263
372
  ledger.emit({ type: "run_failed", runId: ctx.runId, error: message });
264
373
  ledger.emit({ type: "preview_completed", runId: ctx.runId, receiptId, status: "rejected" });
265
- return { success: false, error: structuredError, ledgerEvents: ledger.getEntries() };
374
+ return {
375
+ success: false,
376
+ error: structuredError,
377
+ ledgerEvents: ledger.getEntries(),
378
+ selectedTrigger,
379
+ };
266
380
  }
267
381
  }
382
+ function commitFailureResult(receiptId, runId, ledger, driftChecks, finalState, error) {
383
+ ledger.emit({ type: "commit_completed", runId, receiptId, success: false });
384
+ return {
385
+ success: false,
386
+ receiptId,
387
+ transactions: [],
388
+ driftChecks,
389
+ finalState,
390
+ ledgerEvents: ledger.getEntries(),
391
+ error,
392
+ };
393
+ }
268
394
  /**
269
395
  * Commit a receipt — executes planned actions from the preview.
270
396
  */
@@ -309,16 +435,7 @@ export async function commit(options) {
309
435
  limit: options.driftPolicy.maxAge,
310
436
  suggestion: "Run preview again to generate a fresh receipt.",
311
437
  });
312
- ledger.emit({ type: "commit_completed", runId, receiptId: receipt.id, success: false });
313
- return {
314
- success: false,
315
- receiptId: receipt.id,
316
- transactions: [],
317
- driftChecks: [],
318
- finalState: receipt.finalState,
319
- ledgerEvents: ledger.getEntries(),
320
- error: structuredError,
321
- };
438
+ return commitFailureResult(receipt.id, runId, ledger, [], receipt.finalState, structuredError);
322
439
  }
323
440
  }
324
441
  const driftResult = await performDriftChecks(receipt.driftKeys, {
@@ -327,16 +444,7 @@ export async function commit(options) {
327
444
  resolveDriftValue: options.resolveDriftValue,
328
445
  });
329
446
  if (driftResult.error) {
330
- ledger.emit({ type: "commit_completed", runId, receiptId: receipt.id, success: false });
331
- return {
332
- success: false,
333
- receiptId: receipt.id,
334
- transactions: [],
335
- driftChecks: driftResult.driftChecks,
336
- finalState: receipt.finalState,
337
- ledgerEvents: ledger.getEntries(),
338
- error: driftResult.error,
339
- };
447
+ return commitFailureResult(receipt.id, runId, ledger, driftResult.driftChecks, receipt.finalState, driftResult.error);
340
448
  }
341
449
  const driftChecks = driftResult.driftChecks;
342
450
  for (const check of driftChecks) {
@@ -353,29 +461,11 @@ export async function commit(options) {
353
461
  if (options.provider && options.provider.chainId !== chainId) {
354
462
  const structuredError = createStructuredError("commit", "CHAIN_MISMATCH", `Provider chain ${options.provider.chainId} does not match receipt chain ${chainId}. ` +
355
463
  `Provide a provider for chain ${chainId} or omit it to auto-create one.`);
356
- ledger.emit({ type: "commit_completed", runId, receiptId: receipt.id, success: false });
357
- return {
358
- success: false,
359
- receiptId: receipt.id,
360
- transactions: [],
361
- driftChecks,
362
- finalState: receipt.finalState,
363
- ledgerEvents: ledger.getEntries(),
364
- error: structuredError,
365
- };
464
+ return commitFailureResult(receipt.id, runId, ledger, driftChecks, receipt.finalState, structuredError);
366
465
  }
367
466
  const resolvedAssets = resolveAuthoritativeAssets(receipt.assets, options.assets);
368
467
  if (resolvedAssets.error) {
369
- ledger.emit({ type: "commit_completed", runId, receiptId: receipt.id, success: false });
370
- return {
371
- success: false,
372
- receiptId: receipt.id,
373
- transactions: [],
374
- driftChecks,
375
- finalState: receipt.finalState,
376
- ledgerEvents: ledger.getEntries(),
377
- error: resolvedAssets.error,
378
- };
468
+ return commitFailureResult(receipt.id, runId, ledger, driftChecks, receipt.finalState, resolvedAssets.error);
379
469
  }
380
470
  const provider = options.provider ?? createProvider(chainId, options.rpcUrl);
381
471
  const executor = createExecutor({
@@ -570,7 +660,15 @@ export async function buildTransactions(options) {
570
660
  for (const planned of receipt.plannedActions) {
571
661
  try {
572
662
  options.progressCallback?.(`Building transactions for step '${planned.stepId}'...`);
573
- const builtTxs = await buildActionTransactions(planned.action, chainId, getProvider, options.walletAddress, receipt.chainContext.vault, registry, resolvedAssets.assets);
663
+ const builtTxs = await buildActionTransactions({
664
+ action: planned.action,
665
+ chainId,
666
+ getProvider,
667
+ walletAddress: options.walletAddress,
668
+ vault: receipt.chainContext.vault,
669
+ registry,
670
+ assets: resolvedAssets.assets,
671
+ });
574
672
  transactions.push({
575
673
  stepId: planned.stepId,
576
674
  builtTransactions: builtTxs,
@@ -628,6 +726,7 @@ export async function execute(options) {
628
726
  warningCallback: options.warningCallback,
629
727
  crossChain: options.crossChain,
630
728
  queryProvider: options.queryProvider,
729
+ selectedTrigger: options.selectedTrigger,
631
730
  triggerFilter: options.triggerFilter,
632
731
  });
633
732
  if (!previewResult.success || !previewResult.receipt) {
@@ -771,6 +870,7 @@ function convertPreviewToExecutionResult(previewResult, _spell) {
771
870
  },
772
871
  finalState: receipt?.finalState ?? {},
773
872
  ledgerEvents: previewResult.ledgerEvents,
873
+ selectedTrigger: previewResult.selectedTrigger,
774
874
  receipt,
775
875
  };
776
876
  }
@@ -801,6 +901,7 @@ function convertPreviewCommitToExecutionResult(previewResult, commitResult) {
801
901
  },
802
902
  finalState: commitResult.finalState,
803
903
  ledgerEvents: [...previewResult.ledgerEvents, ...commitResult.ledgerEvents],
904
+ selectedTrigger: previewResult.selectedTrigger,
804
905
  receipt,
805
906
  commit: commitResult,
806
907
  };
@@ -920,25 +1021,7 @@ function registerIssuedReceipt(receipt) {
920
1021
  * Deterministic serialization of the receipt fields that must not change
921
1022
  * between preview and buildTransactions. Used as the HMAC payload.
922
1023
  */
923
- function canonicalizeReceiptFields(receipt) {
924
- const critical = {
925
- id: receipt.id,
926
- spellId: receipt.spellId,
927
- chainId: receipt.chainContext.chainId,
928
- vault: receipt.chainContext.vault,
929
- timestamp: receipt.timestamp,
930
- status: receipt.status,
931
- plannedActions: receipt.plannedActions.map((pa) => ({
932
- stepId: pa.stepId,
933
- venue: pa.venue,
934
- action: pa.action,
935
- onFailure: pa.onFailure,
936
- })),
937
- assets: canonicalizeAssetDefs(receipt.assets),
938
- };
939
- return JSON.stringify(critical, (_key, value) => typeof value === "bigint" ? `__bigint__${value.toString()}` : value);
940
- }
941
- function canonicalizeReceiptFieldsLegacy(receipt) {
1024
+ function canonicalizeReceiptFields(receipt, includeAssets = true) {
942
1025
  const critical = {
943
1026
  id: receipt.id,
944
1027
  spellId: receipt.spellId,
@@ -953,6 +1036,9 @@ function canonicalizeReceiptFieldsLegacy(receipt) {
953
1036
  onFailure: pa.onFailure,
954
1037
  })),
955
1038
  };
1039
+ if (includeAssets) {
1040
+ critical.assets = canonicalizeAssetDefs(receipt.assets);
1041
+ }
956
1042
  return JSON.stringify(critical, (_key, value) => typeof value === "bigint" ? `__bigint__${value.toString()}` : value);
957
1043
  }
958
1044
  function canonicalizeAssetDefs(assets) {
@@ -998,7 +1084,7 @@ function verifyReceiptIntegrity(receipt, secret, integrity) {
998
1084
  return false;
999
1085
  }
1000
1086
  const legacyExpected = createHmac("sha256", secret)
1001
- .update(canonicalizeReceiptFieldsLegacy(receipt))
1087
+ .update(canonicalizeReceiptFields(receipt, false))
1002
1088
  .digest("hex");
1003
1089
  return integrityMatches(legacyExpected, integrity);
1004
1090
  }
@@ -1153,19 +1239,8 @@ async function resolveDriftValue(driftKey, options) {
1153
1239
  }
1154
1240
  return { found: false, value: undefined };
1155
1241
  }
1156
- /**
1157
- * Build unsigned transactions for a single action.
1158
- * Mirrors Executor.buildAction() for EVM actions without a wallet, and
1159
- * rejects offchain adapters because they do not produce signable calldata.
1160
- *
1161
- * The provider is lazy so we never instantiate it for receipts that
1162
- * contain only offchain or zero planned actions.
1163
- *
1164
- * Always uses the receipt's chainId for adapter context (not provider.chainId)
1165
- * so calldata matches the previewed chain even if a mismatched provider
1166
- * sneaks through.
1167
- */
1168
- async function buildActionTransactions(action, chainId, getProvider, walletAddress, vault, registry, assets) {
1242
+ async function buildActionTransactions(options) {
1243
+ const { action, chainId, getProvider, walletAddress, vault, registry, assets } = options;
1169
1244
  const normalizeBuildResult = (result) => Array.isArray(result) ? result : [result];
1170
1245
  if (action.type === "custom") {
1171
1246
  const adapter = registry.get(action.venue);