@open-mercato/core 0.6.7-develop.6677.1.beabb7ca12 → 0.6.7-develop.6685.1.77c0a5591b

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.
Files changed (55) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/gateway_payment_operation/index.js +2 -0
  3. package/dist/generated/entities/gateway_payment_operation/index.js.map +2 -2
  4. package/dist/generated/entities/gateway_transaction/index.js +2 -0
  5. package/dist/generated/entities/gateway_transaction/index.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +2 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/payment_gateways/api/capture/route.js +1 -1
  9. package/dist/modules/payment_gateways/api/capture/route.js.map +2 -2
  10. package/dist/modules/payment_gateways/data/entities.js +7 -0
  11. package/dist/modules/payment_gateways/data/entities.js.map +2 -2
  12. package/dist/modules/payment_gateways/lib/capture-ledger.js +183 -0
  13. package/dist/modules/payment_gateways/lib/capture-ledger.js.map +7 -0
  14. package/dist/modules/payment_gateways/lib/gateway-service.js +70 -8
  15. package/dist/modules/payment_gateways/lib/gateway-service.js.map +2 -2
  16. package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js +1 -0
  17. package/dist/modules/payment_gateways/lib/payment-operation-idempotency.js.map +2 -2
  18. package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js +16 -0
  19. package/dist/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.js.map +7 -0
  20. package/dist/modules/query_index/lib/indexer.js +12 -2
  21. package/dist/modules/query_index/lib/indexer.js.map +2 -2
  22. package/dist/modules/workflows/backend/definitions/visual-editor/page.js +5 -2
  23. package/dist/modules/workflows/backend/definitions/visual-editor/page.js.map +2 -2
  24. package/dist/modules/workflows/components/ActivitiesEditor.js +67 -11
  25. package/dist/modules/workflows/components/ActivitiesEditor.js.map +2 -2
  26. package/dist/modules/workflows/components/TransitionsEditor.js +205 -163
  27. package/dist/modules/workflows/components/TransitionsEditor.js.map +2 -2
  28. package/dist/modules/workflows/data/validators.js +34 -1
  29. package/dist/modules/workflows/data/validators.js.map +3 -3
  30. package/dist/modules/workflows/lib/activity-executor.js +18 -2
  31. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  32. package/dist/modules/workflows/lib/format-validation-error.js +46 -1
  33. package/dist/modules/workflows/lib/format-validation-error.js.map +2 -2
  34. package/generated/entities/gateway_payment_operation/index.ts +1 -0
  35. package/generated/entities/gateway_transaction/index.ts +1 -0
  36. package/generated/entity-fields-registry.ts +2 -0
  37. package/package.json +7 -7
  38. package/src/modules/payment_gateways/api/capture/route.ts +1 -1
  39. package/src/modules/payment_gateways/data/entities.ts +8 -2
  40. package/src/modules/payment_gateways/lib/capture-ledger.ts +260 -0
  41. package/src/modules/payment_gateways/lib/gateway-service.ts +83 -10
  42. package/src/modules/payment_gateways/lib/payment-operation-idempotency.ts +1 -0
  43. package/src/modules/payment_gateways/migrations/.snapshot-open-mercato.json +34 -0
  44. package/src/modules/payment_gateways/migrations/Migration20260725101500_payment_gateways.ts +21 -0
  45. package/src/modules/query_index/lib/indexer.ts +25 -2
  46. package/src/modules/workflows/backend/definitions/visual-editor/page.tsx +12 -3
  47. package/src/modules/workflows/components/ActivitiesEditor.tsx +96 -12
  48. package/src/modules/workflows/components/TransitionsEditor.tsx +75 -17
  49. package/src/modules/workflows/data/validators.ts +52 -1
  50. package/src/modules/workflows/i18n/de.json +2 -0
  51. package/src/modules/workflows/i18n/en.json +2 -0
  52. package/src/modules/workflows/i18n/es.json +2 -0
  53. package/src/modules/workflows/i18n/pl.json +2 -0
  54. package/src/modules/workflows/lib/activity-executor.ts +38 -3
  55. package/src/modules/workflows/lib/format-validation-error.ts +60 -0
@@ -26,7 +26,11 @@ interface Activity {
26
26
  retryDelay?: number
27
27
  backoffMultiplier?: number
28
28
  }
29
- timeout?: number
29
+ // Milliseconds, matching the executor and the definition schema. This field
30
+ // used to be written as `timeout` (a number) while the schema typed `timeout`
31
+ // as an ISO 8601 string, so saving a timeout from this editor failed
32
+ // validation outright (#4424).
33
+ timeoutMs?: number
30
34
  compensation?: Record<string, any>
31
35
  }
32
36
 
@@ -46,8 +50,59 @@ const ACTIVITY_TYPES = [
46
50
  { value: 'WAIT', label: 'Wait' },
47
51
  ]
48
52
 
53
+ /**
54
+ * Per-activity draft of the raw JSON config text (#4234).
55
+ *
56
+ * The config textarea used to be controlled directly by
57
+ * `JSON.stringify(activity.config)`, and its onChange dropped anything that
58
+ * did not parse. Every intermediate keystroke of a hand edit is invalid JSON,
59
+ * so the state never advanced and React re-rendered the previous serialized
60
+ * value — the field read as frozen/non-editable right after a config was
61
+ * pasted. Keeping the raw text locally lets the user type freely; the parsed
62
+ * object is propagated whenever the text is valid, and an inline error is shown
63
+ * while it is not.
64
+ */
65
+ type ConfigDraft = { text: string; error: string | null }
66
+
67
+ function serializeConfig(config: Record<string, unknown> | undefined): string {
68
+ return JSON.stringify(config ?? {}, null, 2)
69
+ }
70
+
49
71
  export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEditorProps) {
50
72
  const t = useT()
73
+ const [configDrafts, setConfigDrafts] = React.useState<Record<number, ConfigDraft>>({})
74
+
75
+ const configTextFor = (index: number, activity: Activity): string =>
76
+ configDrafts[index]?.text ?? serializeConfig(activity.config)
77
+
78
+ const handleConfigTextChange = (index: number, text: string) => {
79
+ let parsed: Record<string, unknown> | null = null
80
+ let parseError: string | null = null
81
+ try {
82
+ const candidate = JSON.parse(text) as unknown
83
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
84
+ parsed = candidate as Record<string, unknown>
85
+ } else {
86
+ parseError = t('workflows.activities.configMustBeObject', 'Config must be a JSON object')
87
+ }
88
+ } catch (err) {
89
+ parseError = err instanceof Error ? err.message : t('workflows.activities.configInvalidJson', 'Invalid JSON')
90
+ }
91
+
92
+ setConfigDrafts((drafts) => ({ ...drafts, [index]: { text, error: parseError } }))
93
+ if (parsed) updateActivity(index, 'config', parsed)
94
+ }
95
+
96
+ // Drop the raw draft once the field loses focus and its content is valid, so
97
+ // the textarea goes back to mirroring the canonical (re-formatted) config.
98
+ const handleConfigBlur = (index: number) => {
99
+ setConfigDrafts((drafts) => {
100
+ if (!drafts[index] || drafts[index].error) return drafts
101
+ const next = { ...drafts }
102
+ delete next[index]
103
+ return next
104
+ })
105
+ }
51
106
 
52
107
  const addActivity = () => {
53
108
  const newActivity: Activity = {
@@ -85,6 +140,18 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
85
140
 
86
141
  const removeActivity = (index: number) => {
87
142
  onChange(value.filter((_, i) => i !== index))
143
+ // Keep the index-keyed drafts aligned with the re-indexed activities:
144
+ // drop the removed row's draft and shift every later draft down by one,
145
+ // otherwise a pending (invalid) draft would render over a different row.
146
+ setConfigDrafts((drafts) => {
147
+ const next: Record<number, ConfigDraft> = {}
148
+ for (const key of Object.keys(drafts)) {
149
+ const i = Number(key)
150
+ if (i === index) continue
151
+ next[i > index ? i - 1 : i] = drafts[i]
152
+ }
153
+ return next
154
+ })
88
155
  }
89
156
 
90
157
  const moveActivity = (index: number, direction: 'up' | 'down') => {
@@ -96,6 +163,18 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
96
163
  updated[index] = updated[newIndex]
97
164
  updated[newIndex] = temp
98
165
  onChange(updated)
166
+ // Swap the two rows' drafts too, so an in-progress edit follows its activity.
167
+ setConfigDrafts((drafts) => {
168
+ if (!(index in drafts) && !(newIndex in drafts)) return drafts
169
+ const next = { ...drafts }
170
+ const atIndex = drafts[index]
171
+ const atNew = drafts[newIndex]
172
+ if (atNew !== undefined) next[index] = atNew
173
+ else delete next[index]
174
+ if (atIndex !== undefined) next[newIndex] = atIndex
175
+ else delete next[newIndex]
176
+ return next
177
+ })
99
178
  }
100
179
 
101
180
  return (
@@ -211,8 +290,8 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
211
290
  <Input
212
291
  id={`activity-${index}-timeout`}
213
292
  type="number"
214
- value={activity.timeout || ''}
215
- onChange={(e) => updateActivity(index, 'timeout', e.target.value ? parseInt(e.target.value) : undefined)}
293
+ value={activity.timeoutMs || ''}
294
+ onChange={(e) => updateActivity(index, 'timeoutMs', e.target.value ? parseInt(e.target.value) : undefined)}
216
295
  placeholder="30000"
217
296
  className="mt-1"
218
297
  />
@@ -318,19 +397,24 @@ export function ActivitiesEditor({ value = [], onChange, error }: ActivitiesEdit
318
397
  </Label>
319
398
  <Textarea
320
399
  id={`activity-${index}-config`}
321
- value={JSON.stringify(activity.config || {}, null, 2)}
322
- onChange={(e) => {
323
- try {
324
- const parsed = JSON.parse(e.target.value)
325
- updateActivity(index, 'config', parsed)
326
- } catch {
327
- // Invalid JSON, don't update
328
- }
329
- }}
400
+ value={configTextFor(index, activity)}
401
+ onChange={(e) => handleConfigTextChange(index, e.target.value)}
402
+ onBlur={() => handleConfigBlur(index)}
403
+ aria-invalid={configDrafts[index]?.error ? true : undefined}
404
+ aria-describedby={configDrafts[index]?.error ? `activity-${index}-config-error` : undefined}
330
405
  placeholder='{"key": "value"}'
331
406
  rows={3}
332
407
  className="mt-1 font-mono text-xs"
333
408
  />
409
+ {configDrafts[index]?.error ? (
410
+ <p
411
+ id={`activity-${index}-config-error`}
412
+ className="mt-1 text-xs text-status-error-text"
413
+ role="alert"
414
+ >
415
+ {configDrafts[index]?.error}
416
+ </p>
417
+ ) : null}
334
418
  </div>
335
419
  )}
336
420
  </div>
@@ -26,7 +26,11 @@ interface Activity {
26
26
  retryDelay?: number
27
27
  backoffMultiplier?: number
28
28
  }
29
- timeout?: number
29
+ // Milliseconds, matching the executor and the definition schema. This field
30
+ // used to be written as `timeout` (a number) while the schema typed `timeout`
31
+ // as an ISO 8601 string, so saving a timeout from this editor failed
32
+ // validation outright (#4424).
33
+ timeoutMs?: number
30
34
  compensation?: Record<string, any>
31
35
  }
32
36
 
@@ -66,8 +70,53 @@ const ACTIVITY_TYPES = [
66
70
  { value: 'WAIT', label: 'Wait' },
67
71
  ]
68
72
 
73
+ type ConfigDraft = { text: string; error: string | null }
74
+
69
75
  export function TransitionsEditor({ value = [], onChange, steps = [], error }: TransitionsEditorProps) {
70
76
  const t = useT()
77
+ const [configDrafts, setConfigDrafts] = React.useState<Record<string, ConfigDraft>>({})
78
+
79
+ const configDraftKey = (transition: Transition, activity: Activity) =>
80
+ `${transition.transitionId}:${activity.activityId}`
81
+
82
+ const handleConfigTextChange = (
83
+ transitionIndex: number,
84
+ activityIndex: number,
85
+ text: string,
86
+ ) => {
87
+ const transition = value[transitionIndex]
88
+ const activity = transition.activities?.[activityIndex]
89
+ if (!activity) return
90
+
91
+ let parsed: Record<string, unknown> | null = null
92
+ let parseError: string | null = null
93
+ try {
94
+ const candidate = JSON.parse(text) as unknown
95
+ if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) {
96
+ parsed = candidate as Record<string, unknown>
97
+ } else {
98
+ parseError = t('workflows.activities.configMustBeObject', 'Config must be a JSON object')
99
+ }
100
+ } catch (err) {
101
+ parseError = err instanceof Error
102
+ ? err.message
103
+ : t('workflows.activities.configInvalidJson', 'Invalid JSON')
104
+ }
105
+
106
+ const key = configDraftKey(transition, activity)
107
+ setConfigDrafts((drafts) => ({ ...drafts, [key]: { text, error: parseError } }))
108
+ if (parsed) updateActivity(transitionIndex, activityIndex, 'config', parsed)
109
+ }
110
+
111
+ const handleConfigBlur = (transition: Transition, activity: Activity) => {
112
+ const key = configDraftKey(transition, activity)
113
+ setConfigDrafts((drafts) => {
114
+ if (!drafts[key] || drafts[key].error) return drafts
115
+ const next = { ...drafts }
116
+ delete next[key]
117
+ return next
118
+ })
119
+ }
71
120
 
72
121
  const addTransition = () => {
73
122
  const newTransition: Transition = {
@@ -370,9 +419,12 @@ export function TransitionsEditor({ value = [], onChange, steps = [], error }: T
370
419
  )}
371
420
 
372
421
  <div className="space-y-2">
373
- {(transition.activities || []).map((activity, activityIndex) => (
374
- <div key={activityIndex} className="p-3 border rounded-md bg-muted shadow-sm border-l-4 border-l-green-500">
375
- <div className="space-y-2">
422
+ {(transition.activities || []).map((activity, activityIndex) => {
423
+ const draftKey = configDraftKey(transition, activity)
424
+ const configDraft = configDrafts[draftKey]
425
+ return (
426
+ <div key={draftKey} className="p-3 border rounded-md bg-muted shadow-sm border-l-4 border-l-green-500">
427
+ <div className="space-y-2">
376
428
  <div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
377
429
  <div className="flex-1 grid grid-cols-1 sm:grid-cols-2 gap-2">
378
430
  <div>
@@ -461,8 +513,8 @@ export function TransitionsEditor({ value = [], onChange, steps = [], error }: T
461
513
  <Input
462
514
  id={`activity-${index}-${activityIndex}-timeout`}
463
515
  type="number"
464
- value={activity.timeout || ''}
465
- onChange={(e) => updateActivity(index, activityIndex, 'timeout', e.target.value ? parseInt(e.target.value) : undefined)}
516
+ value={activity.timeoutMs || ''}
517
+ onChange={(e) => updateActivity(index, activityIndex, 'timeoutMs', e.target.value ? parseInt(e.target.value) : undefined)}
466
518
  placeholder="30000"
467
519
  className="mt-1 text-xs h-8"
468
520
  />
@@ -529,23 +581,29 @@ export function TransitionsEditor({ value = [], onChange, steps = [], error }: T
529
581
  </Label>
530
582
  <Textarea
531
583
  id={`activity-${index}-${activityIndex}-config`}
532
- value={JSON.stringify(activity.config || {}, null, 2)}
533
- onChange={(e) => {
534
- try {
535
- const parsed = JSON.parse(e.target.value)
536
- updateActivity(index, activityIndex, 'config', parsed)
537
- } catch {
538
- // Invalid JSON, don't update
539
- }
540
- }}
584
+ value={configDraft?.text ?? JSON.stringify(activity.config || {}, null, 2)}
585
+ onChange={(e) => handleConfigTextChange(index, activityIndex, e.target.value)}
586
+ onBlur={() => handleConfigBlur(transition, activity)}
587
+ aria-invalid={configDraft?.error ? true : undefined}
588
+ aria-describedby={configDraft?.error ? `activity-${index}-${activityIndex}-config-error` : undefined}
541
589
  placeholder='{"key": "value"}'
542
590
  rows={2}
543
591
  className="mt-1 font-mono text-xs"
544
592
  />
593
+ {configDraft?.error ? (
594
+ <p
595
+ id={`activity-${index}-${activityIndex}-config-error`}
596
+ className="mt-1 text-xs text-status-error-text"
597
+ role="alert"
598
+ >
599
+ {configDraft.error}
600
+ </p>
601
+ ) : null}
602
+ </div>
545
603
  </div>
546
604
  </div>
547
- </div>
548
- ))}
605
+ )
606
+ })}
549
607
  </div>
550
608
  </div>
551
609
  </div>
@@ -202,6 +202,20 @@ export const activityRetryPolicySchema = z.object({
202
202
  maxIntervalMs: z.number().int().min(0),
203
203
  })
204
204
 
205
+ /**
206
+ * Config fields each activity executor requires to run (see
207
+ * `lib/activity-executor.ts`). WAIT is handled separately below because it
208
+ * takes "duration" OR "until" rather than a fixed key set.
209
+ */
210
+ const REQUIRED_ACTIVITY_CONFIG_KEYS: Partial<Record<ActivityType, readonly string[]>> = {
211
+ SEND_EMAIL: ['to', 'subject'],
212
+ CALL_API: ['endpoint'],
213
+ CALL_WEBHOOK: ['url'],
214
+ UPDATE_ENTITY: ['commandId', 'input'],
215
+ EMIT_EVENT: ['eventName'],
216
+ EXECUTE_FUNCTION: ['functionName'],
217
+ }
218
+
205
219
  // Activity definition (embedded in transitions)
206
220
  export const activityDefinitionSchema = z.object({
207
221
  activityId: z.string().min(1).max(100).regex(/^[a-z0-9_-]+$/, 'Activity ID must contain only lowercase letters, numbers, hyphens, and underscores'),
@@ -210,12 +224,49 @@ export const activityDefinitionSchema = z.object({
210
224
  config: z.record(z.string(), z.any()),
211
225
  async: z.boolean().default(false).optional(), // For Phase 8.3
212
226
  retryPolicy: activityRetryPolicySchema.optional(),
213
- timeout: z.string().optional(), // ISO 8601 duration
227
+ /**
228
+ * Per-activity timeout in milliseconds. This is what the editor writes and
229
+ * what `executeActivity` reads; it was missing from the schema, so
230
+ * `z.object()` stripped it on save and UI-configured timeouts silently did
231
+ * nothing (#4424).
232
+ */
233
+ timeoutMs: z.number().int().positive().optional(),
234
+ /**
235
+ * @deprecated Use `timeoutMs`. Accepted for definitions already stored with
236
+ * an ISO 8601 duration string; the executor normalizes it to milliseconds.
237
+ */
238
+ timeout: z.string().optional(),
214
239
  compensation: z.object({
215
240
  activityId: z.string().min(1), // ID of compensation activity
216
241
  automatic: z.boolean().default(true).optional() // Auto-trigger on failure
217
242
  }).optional(), // Compensation configuration (Phase 8.2)
218
243
  }).superRefine((activity, ctx) => {
244
+ // Config keys each activity executor requires at runtime. Without this, an
245
+ // activity missing e.g. CALL_API's `endpoint` saved cleanly from the visual
246
+ // editor and only failed once an instance ran it — the "edit silently
247
+ // disappeared / nothing told me why" report in #4232 (and the class of bug
248
+ // #4322 was an instance of). Validating here surfaces the exact missing field
249
+ // at edit time, since both the editor's save path and the API route parse
250
+ // through this schema.
251
+ const requiredConfigKeys = REQUIRED_ACTIVITY_CONFIG_KEYS[activity.activityType]
252
+ if (requiredConfigKeys) {
253
+ const config = activity.config || {}
254
+ for (const key of requiredConfigKeys) {
255
+ const value = config[key]
256
+ const isEmpty =
257
+ value == null ||
258
+ (typeof value === 'string' && value.trim() === '') ||
259
+ (key === 'input' && typeof value !== 'object')
260
+ if (isEmpty) {
261
+ ctx.addIssue({
262
+ code: 'custom',
263
+ path: ['config', key],
264
+ message: `${activity.activityType} activity requires "${key}"`,
265
+ })
266
+ }
267
+ }
268
+ }
269
+
219
270
  if (activity.activityType !== 'WAIT') return
220
271
  const config = activity.config || {}
221
272
  const hasDuration = config.duration != null && config.duration !== ''
@@ -80,6 +80,8 @@
80
80
  "workflows.activities.async": "Asynchron",
81
81
  "workflows.activities.compensation": "Kompensation",
82
82
  "workflows.activities.config": "Konfiguration",
83
+ "workflows.activities.configInvalidJson": "Ungültiges JSON",
84
+ "workflows.activities.configMustBeObject": "Konfiguration muss ein JSON-Objekt sein",
83
85
  "workflows.activities.deleteActivity": "Aktivität löschen",
84
86
  "workflows.activities.editActivity": "Aktivität bearbeiten",
85
87
  "workflows.activities.plural": "Aktivitäten",
@@ -80,6 +80,8 @@
80
80
  "workflows.activities.async": "Asynchronous",
81
81
  "workflows.activities.compensation": "Compensation",
82
82
  "workflows.activities.config": "Configuration",
83
+ "workflows.activities.configInvalidJson": "Invalid JSON",
84
+ "workflows.activities.configMustBeObject": "Config must be a JSON object",
83
85
  "workflows.activities.deleteActivity": "Delete Activity",
84
86
  "workflows.activities.editActivity": "Edit Activity",
85
87
  "workflows.activities.plural": "Activities",
@@ -80,6 +80,8 @@
80
80
  "workflows.activities.async": "Asincrona",
81
81
  "workflows.activities.compensation": "Compensacion",
82
82
  "workflows.activities.config": "Configuracion",
83
+ "workflows.activities.configInvalidJson": "JSON no válido",
84
+ "workflows.activities.configMustBeObject": "La configuración debe ser un objeto JSON",
83
85
  "workflows.activities.deleteActivity": "Eliminar actividad",
84
86
  "workflows.activities.editActivity": "Editar actividad",
85
87
  "workflows.activities.plural": "Actividades",
@@ -80,6 +80,8 @@
80
80
  "workflows.activities.async": "Asynchroniczne",
81
81
  "workflows.activities.compensation": "Kompensacja",
82
82
  "workflows.activities.config": "Konfiguracja",
83
+ "workflows.activities.configInvalidJson": "Nieprawidłowy JSON",
84
+ "workflows.activities.configMustBeObject": "Konfiguracja musi być obiektem JSON",
83
85
  "workflows.activities.deleteActivity": "Usuń Działanie",
84
86
  "workflows.activities.editActivity": "Edytuj Działanie",
85
87
  "workflows.activities.plural": "Działania",
@@ -107,9 +107,42 @@ export interface ActivityDefinition {
107
107
  async?: boolean // Flag to execute activity asynchronously via queue
108
108
  retryPolicy?: RetryPolicy
109
109
  timeoutMs?: number
110
+ /**
111
+ * @deprecated Use `timeoutMs`. Legacy ISO 8601 duration string accepted by
112
+ * the definition schema before #4424; normalized by `resolveActivityTimeoutMs`.
113
+ */
114
+ timeout?: string
110
115
  compensate?: boolean // Flag to execute compensation on failure
111
116
  }
112
117
 
118
+ /**
119
+ * Effective timeout for an activity, in milliseconds.
120
+ *
121
+ * The editor and this executor both speak `timeoutMs`, but the definition
122
+ * schema historically accepted only an ISO 8601 `timeout` string — so stored
123
+ * definitions can carry either. Prefer `timeoutMs`; fall back to parsing
124
+ * `timeout`, ignoring a malformed value rather than throwing mid-execution
125
+ * (an unparseable timeout must not fail an activity that would otherwise
126
+ * succeed). Returns undefined when no usable timeout is configured (#4424).
127
+ */
128
+ export function resolveActivityTimeoutMs(activity: {
129
+ timeoutMs?: number
130
+ timeout?: string
131
+ }): number | undefined {
132
+ if (typeof activity.timeoutMs === 'number' && activity.timeoutMs > 0) {
133
+ return activity.timeoutMs
134
+ }
135
+ if (typeof activity.timeout === 'string' && activity.timeout.trim().length > 0) {
136
+ try {
137
+ const parsed = parseDuration(activity.timeout.trim())
138
+ if (Number.isFinite(parsed) && parsed > 0) return parsed
139
+ } catch {
140
+ return undefined
141
+ }
142
+ }
143
+ return undefined
144
+ }
145
+
113
146
  export interface RetryPolicy {
114
147
  maxAttempts: number
115
148
  initialIntervalMs: number
@@ -332,11 +365,13 @@ export async function executeActivity(
332
365
  try {
333
366
  const startTime = Date.now()
334
367
 
335
- // Execute with timeout if specified
336
- const result = activity.timeoutMs
368
+ // Execute with timeout if specified (timeoutMs, or a legacy ISO 8601
369
+ // `timeout` string normalized to ms — see resolveActivityTimeoutMs).
370
+ const timeoutMs = resolveActivityTimeoutMs(activity)
371
+ const result = timeoutMs
337
372
  ? await executeWithTimeout(
338
373
  () => executeActivityByType(em, container, activity, context),
339
- activity.timeoutMs
374
+ timeoutMs
340
375
  )
341
376
  : await executeActivityByType(em, container, activity, context)
342
377
 
@@ -8,6 +8,66 @@ type ApiErrorBody = {
8
8
  details?: ZodIssueLite[]
9
9
  }
10
10
 
11
+ // Collection segments in a definition path, mapped to the label an operator
12
+ // recognizes from the editor. Indexes are rendered 1-based.
13
+ const COLLECTION_LABELS: Record<string, string> = {
14
+ steps: 'step',
15
+ transitions: 'transition',
16
+ activities: 'activity',
17
+ triggers: 'trigger',
18
+ preConditions: 'pre-condition',
19
+ }
20
+
21
+ /**
22
+ * Turn a raw Zod path into something an operator can act on:
23
+ * `steps.2.activities.0.config.endpoint` → `step 3 › activity 1 › config.endpoint`.
24
+ *
25
+ * The raw dotted path reads as internal JSON and gives no hint which node to
26
+ * open in the visual editor (#4232).
27
+ */
28
+ export function humanizeDefinitionIssuePath(path: ReadonlyArray<PropertyKey>): string {
29
+ if (!path.length) return 'definition'
30
+ const parts: string[] = []
31
+ let pending: string | null = null
32
+
33
+ for (const rawSegment of path) {
34
+ // Zod types issue paths as PropertyKey; symbols can't appear in JSON data
35
+ // but narrow defensively rather than casting.
36
+ const segment = typeof rawSegment === 'symbol' ? rawSegment.toString() : rawSegment
37
+ if (typeof segment === 'number') {
38
+ parts.push(pending ? `${pending} ${segment + 1}` : `#${segment + 1}`)
39
+ pending = null
40
+ continue
41
+ }
42
+ const label = COLLECTION_LABELS[segment]
43
+ if (label) {
44
+ if (pending) parts.push(pending)
45
+ pending = label
46
+ continue
47
+ }
48
+ if (pending) {
49
+ parts.push(pending)
50
+ pending = null
51
+ }
52
+ parts.push(segment)
53
+ }
54
+ if (pending) parts.push(pending)
55
+
56
+ const [head, ...rest] = parts
57
+ if (!rest.length) return head
58
+ // Keep trailing field names dotted (config.endpoint), collections chevroned.
59
+ const grouped: string[] = [head]
60
+ for (const part of rest) {
61
+ const isIndexed = /\s\d+$/.test(part) || part.startsWith('#')
62
+ if (!isIndexed && grouped.length > 0 && !/\s\d+$/.test(grouped[grouped.length - 1]) && !grouped[grouped.length - 1].startsWith('#')) {
63
+ grouped[grouped.length - 1] = `${grouped[grouped.length - 1]}.${part}`
64
+ continue
65
+ }
66
+ grouped.push(part)
67
+ }
68
+ return grouped.join(' › ')
69
+ }
70
+
11
71
  /**
12
72
  * Format an API validation error body into a user-readable message.
13
73
  *