@hotmeshio/hotmesh 0.25.0 → 0.25.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -272,6 +272,13 @@ declare class Hook extends Activity {
272
272
  doesHook(): boolean;
273
273
  doHook(telemetry: TelemetryService): Promise<void>;
274
274
  private addEscalationToTransaction;
275
+ /**
276
+ * Derive signal_key from the hook rule's expected condition — the same
277
+ * value registerWebHook stores as the signal lookup key. Used by the Leg1
278
+ * escalation INSERT and by the timeout path's expiry UPDATE, so both sides
279
+ * address the identical row.
280
+ */
281
+ private deriveEscalationSignalKey;
275
282
  /**
276
283
  * Re-publishes a pending signal as a WEBHOOK stream message so the
277
284
  * normal leg2 dispatch path processes it. Called when leg1's
@@ -308,5 +315,14 @@ declare class Hook extends Activity {
308
315
  } | void>;
309
316
  processWebHookEvent(status?: StreamStatus, code?: StreamCode): Promise<JobStatus | void>;
310
317
  processTimeHookEvent(jobId: string): Promise<JobStatus | void>;
318
+ /**
319
+ * The timeout won the race: transition the wait's escalation row
320
+ * `pending → expired` so the worklist stops offering work whose workflow
321
+ * has already resumed with `false`, and a late resolve fails as
322
+ * already-expired instead of delivering a payload into the void. The
323
+ * UPDATE is guarded by status='pending' — when the signal won (row
324
+ * already resolved) or the wait carried no escalation, it is a no-op.
325
+ */
326
+ private expireEscalationOnTimeout;
311
327
  }
312
328
  export { Hook };
@@ -437,12 +437,7 @@ class Hook extends activity_1.Activity {
437
437
  if (params.role == null && params.type == null &&
438
438
  params.priority == null && params.metadata == null)
439
439
  return null;
440
- // Derive signal_key from the hook rule's expected condition — the same
441
- // value registerWebHook stores as the signal lookup key.
442
- const hookRule = await this.getHookRule(this.config.hook.topic);
443
- const signalKey = hookRule?.conditions?.match?.[0]?.expected
444
- ? pipe_1.Pipe.resolve(hookRule.conditions.match[0].expected, this.context)
445
- : jid;
440
+ const signalKey = await this.deriveEscalationSignalKey();
446
441
  store.addEscalationToTransaction({
447
442
  namespace,
448
443
  appId,
@@ -453,6 +448,18 @@ class Hook extends activity_1.Activity {
453
448
  }, transaction);
454
449
  return signalKey;
455
450
  }
451
+ /**
452
+ * Derive signal_key from the hook rule's expected condition — the same
453
+ * value registerWebHook stores as the signal lookup key. Used by the Leg1
454
+ * escalation INSERT and by the timeout path's expiry UPDATE, so both sides
455
+ * address the identical row.
456
+ */
457
+ async deriveEscalationSignalKey() {
458
+ const hookRule = await this.getHookRule(this.config.hook.topic);
459
+ return hookRule?.conditions?.match?.[0]?.expected
460
+ ? pipe_1.Pipe.resolve(hookRule.conditions.match[0].expected, this.context)
461
+ : this.context.metadata.jid;
462
+ }
456
463
  /**
457
464
  * Re-publishes a pending signal as a WEBHOOK stream message so the
458
465
  * normal leg2 dispatch path processes it. Called when leg1's
@@ -489,7 +496,16 @@ class Hook extends activity_1.Activity {
489
496
  }
490
497
  async getHookRule(topic) {
491
498
  const rules = await this.store.getHookRules();
492
- return rules?.[topic]?.[0];
499
+ const forTopic = rules?.[topic];
500
+ if (!forTopic?.length)
501
+ return undefined;
502
+ // A topic may carry one rule per waiter activity — the durable factory
503
+ // registers `waiter` and `signaler_waiter` on the same wait topic. Select
504
+ // the rule that targets THIS activity: the positional first rule addresses
505
+ // a sibling branch for every activity but its own, so its expected-pipe
506
+ // resolves against output that does not exist in this flow (which is how
507
+ // execHook-context waits were writing signal_key-less escalation rows).
508
+ return (forTopic.find((rule) => rule.to === this.metadata.aid) ?? forTopic[0]);
493
509
  }
494
510
  /**
495
511
  * Register the time hook (sleep) inside the Leg1 transaction.
@@ -629,8 +645,74 @@ class Hook extends activity_1.Activity {
629
645
  error: e.message,
630
646
  });
631
647
  }
648
+ await this.expireEscalationOnTimeout();
632
649
  }
633
650
  await this.processEvent(stream_1.StreamStatus.SUCCESS, 200, 'hook');
634
651
  }
652
+ /**
653
+ * The timeout won the race: transition the wait's escalation row
654
+ * `pending → expired` so the worklist stops offering work whose workflow
655
+ * has already resumed with `false`, and a late resolve fails as
656
+ * already-expired instead of delivering a payload into the void. The
657
+ * UPDATE is guarded by status='pending' — when the signal won (row
658
+ * already resolved) or the wait carried no escalation, it is a no-op.
659
+ */
660
+ async expireEscalationOnTimeout() {
661
+ if (!this.config.escalation)
662
+ return;
663
+ const store = this.store;
664
+ if (typeof store.expireEscalationBySignalKey !== 'function')
665
+ return;
666
+ // Hydration here is THROWAWAY. getState() REPLACES this.context with
667
+ // restored job state, which does not carry the per-message dimensional
668
+ // address (dad) — the stream message is its only carrier for this leg.
669
+ // processEvent must receive the pristine dispatch state (verifyReentry
670
+ // hydrates for itself, reading dad from context.metadata), so snapshot
671
+ // everything getState mutates — the context reference, metadata.dad,
672
+ // and leg — and restore them on every exit path. Without this, a wait
673
+ // executing below the root (any dimension) crashes Leg2 notarization.
674
+ const dispatchContext = this.context;
675
+ const dispatchDad = this.metadata.dad;
676
+ const dispatchLeg = this.leg;
677
+ try {
678
+ // Hydrate job state so the signal-key pipe expression resolves to the
679
+ // same value Leg1 stored on the row.
680
+ this.setLeg(2);
681
+ await this.getState();
682
+ const appId = this.engine.appId;
683
+ const namespace = this.engine.namespace ?? appId;
684
+ const signalKey = await this.deriveEscalationSignalKey();
685
+ const row = await store.expireEscalationBySignalKey(signalKey, namespace, appId);
686
+ if (row?.id && store.eventsPublish) {
687
+ const ts = new Date().toISOString();
688
+ const updatedAt = row.updated_at ? new Date(row.updated_at).toISOString() : ts;
689
+ void Promise.resolve(store.eventsPublish({
690
+ event_id: `${row.id}:expired:${updatedAt}`,
691
+ type: `system.escalation.${row.id}.expired`,
692
+ ts,
693
+ namespace: row.namespace ?? namespace,
694
+ app_id: row.app_id ?? appId,
695
+ workflow_id: row.workflow_id ?? undefined,
696
+ topic: row.topic ?? undefined,
697
+ origin_id: row.origin_id ?? undefined,
698
+ parent_id: row.parent_id ?? undefined,
699
+ trace_id: row.trace_id ?? undefined,
700
+ span_id: row.span_id ?? undefined,
701
+ data: row,
702
+ })).catch(() => { });
703
+ }
704
+ }
705
+ catch (e) {
706
+ this.logger.debug('hook-timeout-escalation-expire', {
707
+ topic: this.config.hook?.topic,
708
+ error: e.message,
709
+ });
710
+ }
711
+ finally {
712
+ this.context = dispatchContext;
713
+ this.metadata.dad = dispatchDad;
714
+ this.setLeg(dispatchLeg);
715
+ }
716
+ }
635
717
  }
636
718
  exports.Hook = Hook;
@@ -61,8 +61,10 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
61
61
  * description: 'Approve or reject the regional order',
62
62
  * metadata: { orderId, region },
63
63
  * envelope: { instructions: 'Review the attached order' },
64
+ * timeout: '72h', // SLA: resume with false + expire the row if unresolved
64
65
  * },
65
66
  * );
67
+ * if (decision === false) return 'auto-rejected-sla'; // row is now status='expired'
66
68
  *
67
69
  * // Elsewhere: list, claim, then resolve (resumes the workflow)
68
70
  * const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
@@ -70,6 +72,15 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
70
72
  * await client.escalations.resolve({ id: item.id, resolverPayload: { approved: true } });
71
73
  * ```
72
74
  *
75
+ * ## Placement: call escalation-bearing waits from main workflow code
76
+ *
77
+ * The resolve/signal delivery pipeline routes to the main flow's waiter.
78
+ * Inside a hook function (`execHook`), an escalation-bearing wait writes its
79
+ * row and honors `timeout` (the row expires and the hook resumes with
80
+ * `false`), while resolution delivery targets the main flow — so structure
81
+ * SLA-gated human waits in the workflow body and let hook functions report
82
+ * back via `signal()`.
83
+ *
73
84
  * ## Fan-in: wait for multiple signals in parallel
74
85
  *
75
86
  * ```typescript
@@ -95,9 +106,13 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
95
106
  * @param signalId - A unique signal identifier shared by the sender and receiver.
96
107
  * @param timeoutOrConfig - Optional timeout string (e.g. `'30s'`, `'24h'`) OR a
97
108
  * {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
98
- * atomically at suspension time. Cannot specify both; use the config object's
99
- * `expiresAt` field for deadline enforcement when an escalation is involved.
100
- * @returns The signal payload, `false` if a timeout string was given and it expired,
101
- * or `null` if the escalation was cancelled via `client.escalations.cancel()`.
109
+ * atomically at suspension time. For an escalation-bearing wait with an SLA,
110
+ * set the config's `timeout` field the wait arms the same resume timer as
111
+ * the string form, and when the timer wins the escalation row transitions
112
+ * `pending → expired` so a late resolve fails as already-expired. (`expiresAt`
113
+ * is display metadata on the row only; it arms nothing.)
114
+ * @returns The signal payload, `false` when a timeout (string form or
115
+ * `config.timeout`) expired first, or `null` if the escalation was cancelled
116
+ * via `client.escalations.cancel()`.
102
117
  */
103
118
  export declare function condition<T>(signalId: string, timeoutOrConfig?: string | ConditionQueueConfig): Promise<T | false | null>;
@@ -66,8 +66,10 @@ const didRun_1 = require("./didRun");
66
66
  * description: 'Approve or reject the regional order',
67
67
  * metadata: { orderId, region },
68
68
  * envelope: { instructions: 'Review the attached order' },
69
+ * timeout: '72h', // SLA: resume with false + expire the row if unresolved
69
70
  * },
70
71
  * );
72
+ * if (decision === false) return 'auto-rejected-sla'; // row is now status='expired'
71
73
  *
72
74
  * // Elsewhere: list, claim, then resolve (resumes the workflow)
73
75
  * const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
@@ -75,6 +77,15 @@ const didRun_1 = require("./didRun");
75
77
  * await client.escalations.resolve({ id: item.id, resolverPayload: { approved: true } });
76
78
  * ```
77
79
  *
80
+ * ## Placement: call escalation-bearing waits from main workflow code
81
+ *
82
+ * The resolve/signal delivery pipeline routes to the main flow's waiter.
83
+ * Inside a hook function (`execHook`), an escalation-bearing wait writes its
84
+ * row and honors `timeout` (the row expires and the hook resumes with
85
+ * `false`), while resolution delivery targets the main flow — so structure
86
+ * SLA-gated human waits in the workflow body and let hook functions report
87
+ * back via `signal()`.
88
+ *
78
89
  * ## Fan-in: wait for multiple signals in parallel
79
90
  *
80
91
  * ```typescript
@@ -100,14 +111,21 @@ const didRun_1 = require("./didRun");
100
111
  * @param signalId - A unique signal identifier shared by the sender and receiver.
101
112
  * @param timeoutOrConfig - Optional timeout string (e.g. `'30s'`, `'24h'`) OR a
102
113
  * {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
103
- * atomically at suspension time. Cannot specify both; use the config object's
104
- * `expiresAt` field for deadline enforcement when an escalation is involved.
105
- * @returns The signal payload, `false` if a timeout string was given and it expired,
106
- * or `null` if the escalation was cancelled via `client.escalations.cancel()`.
114
+ * atomically at suspension time. For an escalation-bearing wait with an SLA,
115
+ * set the config's `timeout` field the wait arms the same resume timer as
116
+ * the string form, and when the timer wins the escalation row transitions
117
+ * `pending → expired` so a late resolve fails as already-expired. (`expiresAt`
118
+ * is display metadata on the row only; it arms nothing.)
119
+ * @returns The signal payload, `false` when a timeout (string form or
120
+ * `config.timeout`) expired first, or `null` if the escalation was cancelled
121
+ * via `client.escalations.cancel()`.
107
122
  */
108
123
  async function condition(signalId, timeoutOrConfig) {
109
- const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : undefined;
124
+ // A string arg is a bare timeout; a config object may carry its own
125
+ // `timeout` field — the engine's waiter block accepts duration and
126
+ // queueConfig together (one wait: escalation row + resume timer).
110
127
  const queueConfig = timeoutOrConfig && typeof timeoutOrConfig === 'object' ? timeoutOrConfig : undefined;
128
+ const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : queueConfig?.timeout;
111
129
  const [didRunAlready, execIndex, result] = await (0, didRun_1.didRun)('wait');
112
130
  (0, cancellationScope_1.checkCancellation)();
113
131
  if (didRunAlready) {
@@ -256,6 +256,14 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
256
256
  createEscalationForMigration(params: import('../../../../types/hmsh_escalations').MigrateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
257
257
  getEscalation(id: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
258
258
  getEscalationBySignalKey(signalKey: string, namespace?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
259
+ /**
260
+ * Transition a wait's escalation row to `expired` when its resume timer
261
+ * fires first (`condition(signalId, { ..., timeout })`). Guarded by
262
+ * `status = 'pending'`: a signal that won the race already resolved the
263
+ * row and is never touched. Returns the expired row, or null when no
264
+ * pending row carried the key (signal won, or the wait had no escalation).
265
+ */
266
+ expireEscalationBySignalKey(signalKey: string, namespace?: string, appId?: string): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
259
267
  private _escalationFilterConditions;
260
268
  listEscalations(params?: import('../../../../types/hmsh_escalations').ListEscalationsParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry[]>;
261
269
  countEscalations(params?: import('../../../../types/hmsh_escalations').ListEscalationsParams): Promise<number>;
@@ -1540,6 +1540,30 @@ class PostgresStoreService extends __1.StoreService {
1540
1540
  const result = await this.pgClient.query(`SELECT * FROM public.hmsh_escalations WHERE signal_key = $1${namespace ? ' AND namespace = $2' : ''}`, namespace ? [signalKey, namespace] : [signalKey]);
1541
1541
  return result.rows[0] ?? null;
1542
1542
  }
1543
+ /**
1544
+ * Transition a wait's escalation row to `expired` when its resume timer
1545
+ * fires first (`condition(signalId, { ..., timeout })`). Guarded by
1546
+ * `status = 'pending'`: a signal that won the race already resolved the
1547
+ * row and is never touched. Returns the expired row, or null when no
1548
+ * pending row carried the key (signal won, or the wait had no escalation).
1549
+ */
1550
+ async expireEscalationBySignalKey(signalKey, namespace, appId) {
1551
+ const values = [signalKey];
1552
+ let clause = '';
1553
+ if (namespace) {
1554
+ values.push(namespace);
1555
+ clause += ` AND namespace = $${values.length}`;
1556
+ }
1557
+ if (appId) {
1558
+ values.push(appId);
1559
+ clause += ` AND app_id = $${values.length}`;
1560
+ }
1561
+ const result = await this.pgClient.query(`UPDATE public.hmsh_escalations
1562
+ SET status = 'expired', updated_at = NOW()
1563
+ WHERE signal_key = $1${clause} AND status = 'pending'
1564
+ RETURNING *`, values);
1565
+ return result.rows[0] ?? null;
1566
+ }
1543
1567
  _escalationFilterConditions(params, startIdx = 1) {
1544
1568
  const { namespace, role, roles, type, subtype, entity, status, assignedTo, workflowId, originId, available, priority, metadata, ids, taskId } = params;
1545
1569
  const conditions = [];
@@ -1791,6 +1815,14 @@ class PostgresStoreService extends __1.StoreService {
1791
1815
  await this.pgClient.query('ROLLBACK');
1792
1816
  return { ok: false, reason: 'already-cancelled' };
1793
1817
  }
1818
+ if (status === 'expired') {
1819
+ // The wait's SLA timer fired first and the workflow resumed with
1820
+ // false — the resolver payload has nowhere to go. Name it, so the
1821
+ // operator learns the deadline passed rather than believing the
1822
+ // resolution landed.
1823
+ await this.pgClient.query('ROLLBACK');
1824
+ return { ok: false, reason: 'already-expired' };
1825
+ }
1794
1826
  const updateResult = await this.pgClient.query(`UPDATE public.hmsh_escalations
1795
1827
  SET status = 'resolved', resolved_at = NOW(), resolver_payload = $2,
1796
1828
  metadata = CASE WHEN $3::jsonb IS NOT NULL
@@ -17,6 +17,14 @@ export interface ConditionQueueConfig {
17
17
  /** Unindexed display/form context for resolver UIs */
18
18
  envelope?: Record<string, unknown>;
19
19
  expiresAt?: Date;
20
+ /**
21
+ * SLA timer for the wait itself (e.g. `'30m'`, `'24h'`). Arms the same
22
+ * resume timer as `condition(signalId, '30m')`: when it fires first, the
23
+ * workflow resumes with `false` and the escalation row transitions
24
+ * `pending → expired` (a later resolve fails as already-expired). A signal
25
+ * that arrives first resolves normally and the timer is inert.
26
+ */
27
+ timeout?: string;
20
28
  }
21
29
  export interface EscalationEntry {
22
30
  id: string;
@@ -95,7 +103,7 @@ export type ResolveEscalationResult = {
95
103
  entry: EscalationEntry;
96
104
  } | {
97
105
  ok: false;
98
- reason: 'not-found' | 'already-resolved' | 'already-cancelled';
106
+ reason: 'not-found' | 'already-resolved' | 'already-cancelled' | 'already-expired';
99
107
  };
100
108
  export type ReleaseEscalationResult = {
101
109
  ok: true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",