@hotmeshio/hotmesh 0.25.0 → 0.25.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.
- package/build/package.json +1 -1
- package/build/services/activities/hook.d.ts +16 -0
- package/build/services/activities/hook.js +64 -6
- package/build/services/durable/workflow/condition.d.ts +10 -4
- package/build/services/durable/workflow/condition.js +14 -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
|
|
@@ -629,8 +636,59 @@ class Hook extends activity_1.Activity {
|
|
|
629
636
|
error: e.message,
|
|
630
637
|
});
|
|
631
638
|
}
|
|
639
|
+
await this.expireEscalationOnTimeout();
|
|
632
640
|
}
|
|
633
641
|
await this.processEvent(stream_1.StreamStatus.SUCCESS, 200, 'hook');
|
|
634
642
|
}
|
|
643
|
+
/**
|
|
644
|
+
* The timeout won the race: transition the wait's escalation row
|
|
645
|
+
* `pending → expired` so the worklist stops offering work whose workflow
|
|
646
|
+
* has already resumed with `false`, and a late resolve fails as
|
|
647
|
+
* already-expired instead of delivering a payload into the void. The
|
|
648
|
+
* UPDATE is guarded by status='pending' — when the signal won (row
|
|
649
|
+
* already resolved) or the wait carried no escalation, it is a no-op.
|
|
650
|
+
*/
|
|
651
|
+
async expireEscalationOnTimeout() {
|
|
652
|
+
if (!this.config.escalation)
|
|
653
|
+
return;
|
|
654
|
+
const store = this.store;
|
|
655
|
+
if (typeof store.expireEscalationBySignalKey !== 'function')
|
|
656
|
+
return;
|
|
657
|
+
try {
|
|
658
|
+
// The TIMEHOOK dispatch context carries metadata only — hydrate job
|
|
659
|
+
// state so the signal-key pipe expression resolves to the same value
|
|
660
|
+
// Leg1 stored on the row.
|
|
661
|
+
this.setLeg(2);
|
|
662
|
+
await this.getState();
|
|
663
|
+
const appId = this.engine.appId;
|
|
664
|
+
const namespace = this.engine.namespace ?? appId;
|
|
665
|
+
const signalKey = await this.deriveEscalationSignalKey();
|
|
666
|
+
const row = await store.expireEscalationBySignalKey(signalKey, namespace, appId);
|
|
667
|
+
if (row?.id && store.eventsPublish) {
|
|
668
|
+
const ts = new Date().toISOString();
|
|
669
|
+
const updatedAt = row.updated_at ? new Date(row.updated_at).toISOString() : ts;
|
|
670
|
+
void Promise.resolve(store.eventsPublish({
|
|
671
|
+
event_id: `${row.id}:expired:${updatedAt}`,
|
|
672
|
+
type: `system.escalation.${row.id}.expired`,
|
|
673
|
+
ts,
|
|
674
|
+
namespace: row.namespace ?? namespace,
|
|
675
|
+
app_id: row.app_id ?? appId,
|
|
676
|
+
workflow_id: row.workflow_id ?? undefined,
|
|
677
|
+
topic: row.topic ?? undefined,
|
|
678
|
+
origin_id: row.origin_id ?? undefined,
|
|
679
|
+
parent_id: row.parent_id ?? undefined,
|
|
680
|
+
trace_id: row.trace_id ?? undefined,
|
|
681
|
+
span_id: row.span_id ?? undefined,
|
|
682
|
+
data: row,
|
|
683
|
+
})).catch(() => { });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
catch (e) {
|
|
687
|
+
this.logger.debug('hook-timeout-escalation-expire', {
|
|
688
|
+
topic: this.config.hook?.topic,
|
|
689
|
+
error: e.message,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
635
693
|
}
|
|
636
694
|
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' });
|
|
@@ -95,9 +97,13 @@ import { ConditionQueueConfig } from '../../../types/hmsh_escalations';
|
|
|
95
97
|
* @param signalId - A unique signal identifier shared by the sender and receiver.
|
|
96
98
|
* @param timeoutOrConfig - Optional timeout string (e.g. `'30s'`, `'24h'`) OR a
|
|
97
99
|
* {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
|
|
98
|
-
* atomically at suspension time.
|
|
99
|
-
* `
|
|
100
|
-
*
|
|
101
|
-
*
|
|
100
|
+
* atomically at suspension time. For an escalation-bearing wait with an SLA,
|
|
101
|
+
* set the config's `timeout` field — the wait arms the same resume timer as
|
|
102
|
+
* the string form, and when the timer wins the escalation row transitions
|
|
103
|
+
* `pending → expired` so a late resolve fails as already-expired. (`expiresAt`
|
|
104
|
+
* is display metadata on the row only; it arms nothing.)
|
|
105
|
+
* @returns The signal payload, `false` when a timeout (string form or
|
|
106
|
+
* `config.timeout`) expired first, or `null` if the escalation was cancelled
|
|
107
|
+
* via `client.escalations.cancel()`.
|
|
102
108
|
*/
|
|
103
109
|
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' });
|
|
@@ -100,14 +102,21 @@ const didRun_1 = require("./didRun");
|
|
|
100
102
|
* @param signalId - A unique signal identifier shared by the sender and receiver.
|
|
101
103
|
* @param timeoutOrConfig - Optional timeout string (e.g. `'30s'`, `'24h'`) OR a
|
|
102
104
|
* {@link ConditionQueueConfig} that writes one row to `public.hmsh_escalations`
|
|
103
|
-
* atomically at suspension time.
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
105
|
+
* atomically at suspension time. For an escalation-bearing wait with an SLA,
|
|
106
|
+
* set the config's `timeout` field — the wait arms the same resume timer as
|
|
107
|
+
* the string form, and when the timer wins the escalation row transitions
|
|
108
|
+
* `pending → expired` so a late resolve fails as already-expired. (`expiresAt`
|
|
109
|
+
* is display metadata on the row only; it arms nothing.)
|
|
110
|
+
* @returns The signal payload, `false` when a timeout (string form or
|
|
111
|
+
* `config.timeout`) expired first, or `null` if the escalation was cancelled
|
|
112
|
+
* via `client.escalations.cancel()`.
|
|
107
113
|
*/
|
|
108
114
|
async function condition(signalId, timeoutOrConfig) {
|
|
109
|
-
|
|
115
|
+
// A string arg is a bare timeout; a config object may carry its own
|
|
116
|
+
// `timeout` field — the engine's waiter block accepts duration and
|
|
117
|
+
// queueConfig together (one wait: escalation row + resume timer).
|
|
110
118
|
const queueConfig = timeoutOrConfig && typeof timeoutOrConfig === 'object' ? timeoutOrConfig : undefined;
|
|
119
|
+
const timeout = typeof timeoutOrConfig === 'string' ? timeoutOrConfig : queueConfig?.timeout;
|
|
111
120
|
const [didRunAlready, execIndex, result] = await (0, didRun_1.didRun)('wait');
|
|
112
121
|
(0, cancellationScope_1.checkCancellation)();
|
|
113
122
|
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;
|