@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.
- package/build/package.json +1 -1
- package/build/services/activities/hook.d.ts +16 -0
- package/build/services/activities/hook.js +89 -7
- package/build/services/durable/workflow/condition.d.ts +19 -4
- package/build/services/durable/workflow/condition.js +23 -5
- package/build/services/store/providers/postgres/postgres.d.ts +8 -0
- package/build/services/store/providers/postgres/postgres.js +32 -0
- package/build/types/hmsh_escalations.d.ts +9 -1
- package/package.json +1 -1
package/build/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
99
|
-
* `
|
|
100
|
-
*
|
|
101
|
-
*
|
|
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.
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
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;
|