@hotmeshio/long-tail 0.5.9 → 0.5.10
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/api/escalations/metadata.js +17 -1
- package/build/services/escalation/crud.d.ts +10 -3
- package/build/services/escalation/crud.js +6 -1
- package/build/services/escalation/sql.d.ts +16 -6
- package/build/services/escalation/sql.js +21 -6
- package/docs/hitl-guide.md +48 -0
- package/package.json +1 -1
|
@@ -125,10 +125,26 @@ async function resolveByMetadata(input, auth) {
|
|
|
125
125
|
if (result.outcome === 'not_found') {
|
|
126
126
|
return { status: 404, error: 'No pending escalation found for this metadata, or insufficient role permissions' };
|
|
127
127
|
}
|
|
128
|
+
if (result.outcome === 'conflict') {
|
|
129
|
+
return { status: 409, error: 'A concurrent resolution is already in progress for this escalation' };
|
|
130
|
+
}
|
|
128
131
|
if (result.outcome === 'resolved') {
|
|
129
132
|
return { status: 200, data: { escalation: result.escalation } };
|
|
130
133
|
}
|
|
131
|
-
//
|
|
134
|
+
// Atomic conditionLT escalation (signal_key set) — SDK resolve atomically marks
|
|
135
|
+
// resolved AND delivers the signal to the waiting condition(), resuming the workflow.
|
|
136
|
+
if (result.signalKey) {
|
|
137
|
+
const resolved = await escalationService.resolveEscalation(result.escalationId, input.resolverPayload);
|
|
138
|
+
if (!resolved) {
|
|
139
|
+
return { status: 409, error: 'Escalation not available for resolution' };
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
status: 200,
|
|
143
|
+
data: { signaled: true, escalationId: result.escalationId, workflowId: result.workflowId },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Legacy conditionLT escalation (metadata.signal_id set) — signal the workflow,
|
|
147
|
+
// conditionLT interceptor resolves durably via ltResolveEscalation.
|
|
132
148
|
const { createClient } = await Promise.resolve().then(() => __importStar(require('../../workers')));
|
|
133
149
|
const client = createClient();
|
|
134
150
|
const handle = await client.workflow.getHandle(result.taskQueue, result.workflowType, result.workflowId);
|
|
@@ -103,12 +103,19 @@ export declare function claimByMetadata(key: string, value: string, userId: stri
|
|
|
103
103
|
candidatesExist: number;
|
|
104
104
|
}) | null>;
|
|
105
105
|
export interface ResolveByMetadataResult {
|
|
106
|
-
/**
|
|
107
|
-
|
|
106
|
+
/**
|
|
107
|
+
* 'resolved' = done atomically in SQL (no signal backing).
|
|
108
|
+
* 'signal_required' = signal backing present, caller must deliver the signal.
|
|
109
|
+
* 'conflict' = signal_id row already claimed by a concurrent caller; skip re-signal.
|
|
110
|
+
* 'not_found' = no pending escalation matched the metadata filter.
|
|
111
|
+
*/
|
|
112
|
+
outcome: 'resolved' | 'signal_required' | 'conflict' | 'not_found';
|
|
108
113
|
/** The resolved escalation (when outcome = 'resolved') */
|
|
109
114
|
escalation?: LTEscalationRecord;
|
|
110
|
-
/**
|
|
115
|
+
/** Legacy conditionLT signal info (when signalId is set, caller uses handle.signal) */
|
|
111
116
|
signalId?: string;
|
|
117
|
+
/** Atomic conditionLT signal key (when signalKey is set, caller uses SDK resolve to atomically mark+signal) */
|
|
118
|
+
signalKey?: string;
|
|
112
119
|
escalationId?: string;
|
|
113
120
|
workflowId?: string;
|
|
114
121
|
workflowType?: string;
|
|
@@ -402,10 +402,15 @@ async function resolveByMetadataAtomic(key, value, userId, resolverPayload, meta
|
|
|
402
402
|
});
|
|
403
403
|
return { outcome: 'resolved', escalation };
|
|
404
404
|
}
|
|
405
|
+
// Signal_id row already claimed by a concurrent caller — skip re-signal to prevent duplicate delivery.
|
|
406
|
+
if (row.signal_id && row.signal_already_claimed) {
|
|
407
|
+
return { outcome: 'conflict', escalationId: row.target_id };
|
|
408
|
+
}
|
|
405
409
|
// Signal-backed escalation — return the signal info for the caller to deliver.
|
|
406
410
|
return {
|
|
407
411
|
outcome: 'signal_required',
|
|
408
|
-
signalId: row.signal_id,
|
|
412
|
+
signalId: row.signal_id ?? undefined,
|
|
413
|
+
signalKey: row.signal_key ?? undefined,
|
|
409
414
|
escalationId: row.target_id,
|
|
410
415
|
workflowId: row.target_workflow_id,
|
|
411
416
|
workflowType: row.target_workflow_type,
|
|
@@ -11,13 +11,23 @@ export declare const RELEASE_EXPIRED_CLAIMS = "UPDATE public.hmsh_escalations\nS
|
|
|
11
11
|
/**
|
|
12
12
|
* Atomic resolve by metadata with signal guard.
|
|
13
13
|
*
|
|
14
|
-
* Single query,
|
|
15
|
-
* 1. No
|
|
16
|
-
* 2. `metadata.signal_id` present →
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* Single query, four outcomes:
|
|
15
|
+
* 1. No signal backing → claim + resolve atomically. `resolved` is populated.
|
|
16
|
+
* 2. `metadata.signal_id` present, row unclaimed → claim the row (preventing concurrent
|
|
17
|
+
* duplicate signals via FOR UPDATE serialization), then return signal info so the
|
|
18
|
+
* caller can signal the workflow. `signal_already_claimed = false`.
|
|
19
|
+
* 3. `metadata.signal_id` present, row already claimed and claim not expired →
|
|
20
|
+
* a concurrent caller is handling the signal. `signal_already_claimed = true`;
|
|
21
|
+
* caller returns 409 without re-signaling.
|
|
22
|
+
* 4. `signal_key` present (atomic conditionLT) → `claimed` and `resolved` both skip.
|
|
23
|
+
* Caller invokes SDK resolve to atomically mark resolved + deliver the signal.
|
|
24
|
+
*
|
|
25
|
+
* Signal_id hardening: the `claimed` CTE runs for signal_id rows (previously excluded).
|
|
26
|
+
* This stamps `assigned_to` on the row inside the FOR UPDATE transaction, so a
|
|
27
|
+
* concurrent second caller sees `signal_already_claimed = true` and aborts.
|
|
28
|
+
* The `resolved` CTE still skips signal_id rows — the workflow resolves durably.
|
|
19
29
|
*
|
|
20
30
|
* $1 = metadata filter (jsonb), $2 = userId, $3 = resolver_payload (jsonb),
|
|
21
31
|
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
22
32
|
*/
|
|
23
|
-
export declare const RESOLVE_BY_METADATA_ATOMIC = "WITH target AS MATERIALIZED (\n SELECT *\n FROM public.hmsh_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND ($5::text[] IS NULL OR role = ANY($5))\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE\n),\nclaimed AS (\n UPDATE public.hmsh_escalations e\n SET assigned_to = COALESCE(e.assigned_to, $2),\n claimed_at = COALESCE(e.claimed_at, NOW()),\n assigned_until = CASE\n WHEN e.assigned_to IS NOT NULL AND e.assigned_until > NOW() THEN e.assigned_until\n ELSE NOW() + INTERVAL '5 minutes' END,\n claim_expires_at = CASE\n WHEN e.assigned_to IS NOT NULL AND e.assigned_until > NOW() THEN e.claim_expires_at\n ELSE NOW() + INTERVAL '5 minutes' END,\n metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END,\n updated_at = NOW()\n FROM target\n WHERE e.id = target.id\n AND
|
|
33
|
+
export declare const RESOLVE_BY_METADATA_ATOMIC = "WITH target AS MATERIALIZED (\n SELECT *\n FROM public.hmsh_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND ($5::text[] IS NULL OR role = ANY($5))\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE\n),\nclaimed AS (\n UPDATE public.hmsh_escalations e\n SET assigned_to = COALESCE(e.assigned_to, $2),\n claimed_at = COALESCE(e.claimed_at, NOW()),\n assigned_until = CASE\n WHEN e.assigned_to IS NOT NULL AND e.assigned_until > NOW() THEN e.assigned_until\n ELSE NOW() + INTERVAL '5 minutes' END,\n claim_expires_at = CASE\n WHEN e.assigned_to IS NOT NULL AND e.assigned_until > NOW() THEN e.claim_expires_at\n ELSE NOW() + INTERVAL '5 minutes' END,\n metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END,\n updated_at = NOW()\n FROM target\n WHERE e.id = target.id\n AND target.signal_key IS NULL\n RETURNING e.*\n),\nresolved AS (\n UPDATE public.hmsh_escalations e\n SET status = 'resolved',\n resolved_at = NOW(),\n resolver_payload = $3,\n updated_at = NOW()\n FROM claimed\n WHERE e.id = claimed.id\n AND (claimed.metadata->>'signal_id') IS NULL\n RETURNING e.*\n)\nSELECT\n resolved.*,\n target.id AS target_id,\n target.metadata->>'signal_id' AS signal_id,\n target.signal_key AS signal_key,\n target.workflow_id AS target_workflow_id,\n target.workflow_type AS target_workflow_type,\n target.task_queue AS target_task_queue,\n (target.assigned_to IS NOT NULL\n AND target.assigned_until IS NOT NULL\n AND target.assigned_until > NOW()) AS signal_already_claimed,\n CASE WHEN resolved.id IS NOT NULL THEN 'resolved' ELSE 'signal_required' END AS outcome\nFROM target\nLEFT JOIN resolved ON resolved.id = target.id";
|
|
@@ -34,11 +34,21 @@ WHERE status = 'pending'
|
|
|
34
34
|
/**
|
|
35
35
|
* Atomic resolve by metadata with signal guard.
|
|
36
36
|
*
|
|
37
|
-
* Single query,
|
|
38
|
-
* 1. No
|
|
39
|
-
* 2. `metadata.signal_id` present →
|
|
40
|
-
*
|
|
41
|
-
*
|
|
37
|
+
* Single query, four outcomes:
|
|
38
|
+
* 1. No signal backing → claim + resolve atomically. `resolved` is populated.
|
|
39
|
+
* 2. `metadata.signal_id` present, row unclaimed → claim the row (preventing concurrent
|
|
40
|
+
* duplicate signals via FOR UPDATE serialization), then return signal info so the
|
|
41
|
+
* caller can signal the workflow. `signal_already_claimed = false`.
|
|
42
|
+
* 3. `metadata.signal_id` present, row already claimed and claim not expired →
|
|
43
|
+
* a concurrent caller is handling the signal. `signal_already_claimed = true`;
|
|
44
|
+
* caller returns 409 without re-signaling.
|
|
45
|
+
* 4. `signal_key` present (atomic conditionLT) → `claimed` and `resolved` both skip.
|
|
46
|
+
* Caller invokes SDK resolve to atomically mark resolved + deliver the signal.
|
|
47
|
+
*
|
|
48
|
+
* Signal_id hardening: the `claimed` CTE runs for signal_id rows (previously excluded).
|
|
49
|
+
* This stamps `assigned_to` on the row inside the FOR UPDATE transaction, so a
|
|
50
|
+
* concurrent second caller sees `signal_already_claimed = true` and aborts.
|
|
51
|
+
* The `resolved` CTE still skips signal_id rows — the workflow resolves durably.
|
|
42
52
|
*
|
|
43
53
|
* $1 = metadata filter (jsonb), $2 = userId, $3 = resolver_payload (jsonb),
|
|
44
54
|
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
@@ -70,7 +80,7 @@ claimed AS (
|
|
|
70
80
|
updated_at = NOW()
|
|
71
81
|
FROM target
|
|
72
82
|
WHERE e.id = target.id
|
|
73
|
-
AND
|
|
83
|
+
AND target.signal_key IS NULL
|
|
74
84
|
RETURNING e.*
|
|
75
85
|
),
|
|
76
86
|
resolved AS (
|
|
@@ -81,15 +91,20 @@ resolved AS (
|
|
|
81
91
|
updated_at = NOW()
|
|
82
92
|
FROM claimed
|
|
83
93
|
WHERE e.id = claimed.id
|
|
94
|
+
AND (claimed.metadata->>'signal_id') IS NULL
|
|
84
95
|
RETURNING e.*
|
|
85
96
|
)
|
|
86
97
|
SELECT
|
|
87
98
|
resolved.*,
|
|
88
99
|
target.id AS target_id,
|
|
89
100
|
target.metadata->>'signal_id' AS signal_id,
|
|
101
|
+
target.signal_key AS signal_key,
|
|
90
102
|
target.workflow_id AS target_workflow_id,
|
|
91
103
|
target.workflow_type AS target_workflow_type,
|
|
92
104
|
target.task_queue AS target_task_queue,
|
|
105
|
+
(target.assigned_to IS NOT NULL
|
|
106
|
+
AND target.assigned_until IS NOT NULL
|
|
107
|
+
AND target.assigned_until > NOW()) AS signal_already_claimed,
|
|
93
108
|
CASE WHEN resolved.id IS NOT NULL THEN 'resolved' ELSE 'signal_required' END AS outcome
|
|
94
109
|
FROM target
|
|
95
110
|
LEFT JOIN resolved ON resolved.id = target.id`;
|
package/docs/hitl-guide.md
CHANGED
|
@@ -594,6 +594,54 @@ metadata: {
|
|
|
594
594
|
|
|
595
595
|
---
|
|
596
596
|
|
|
597
|
+
## Resolving from System Code
|
|
598
|
+
|
|
599
|
+
When a backend service (not the dashboard UI) needs to resolve an escalation — for example, an ingress handler that receives a webhook or processes a domain event — use the escalation SDK methods directly.
|
|
600
|
+
|
|
601
|
+
### By escalation ID
|
|
602
|
+
|
|
603
|
+
Use when you already have the escalation UUID (e.g. stored in your own DB alongside the order):
|
|
604
|
+
|
|
605
|
+
```typescript
|
|
606
|
+
const result = await lt.escalations.resolve({
|
|
607
|
+
id: escalationId,
|
|
608
|
+
resolverPayload: { approved: true, targetStatus: 'ready' },
|
|
609
|
+
});
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
This routes through the full resolution path and works for all escalation types — atomic `conditionLT` (signal_key), legacy `conditionLT` (signal_id), and re-run-style escalations.
|
|
613
|
+
|
|
614
|
+
### By metadata key-value pair
|
|
615
|
+
|
|
616
|
+
Use when you know a domain identifier (e.g. `orderId`) but not the escalation UUID. `resolveByMetadata` finds the highest-priority pending escalation matching the key-value pair and resolves it atomically — no pre-flight lookup, no TOCTOU:
|
|
617
|
+
|
|
618
|
+
```typescript
|
|
619
|
+
const result = await lt.escalations.resolveByMetadata({
|
|
620
|
+
key: 'orderId',
|
|
621
|
+
value: orderId,
|
|
622
|
+
resolverPayload: { approved: true, targetStatus: 'ready' },
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
if (result.status === 404) {
|
|
626
|
+
// No pending escalation for this orderId
|
|
627
|
+
}
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
This works for all escalation types including atomic `conditionLT` rows (those with `signal_key` set). The routing is transparent — the caller does not need to know which pattern the workflow used.
|
|
631
|
+
|
|
632
|
+
### By signal key
|
|
633
|
+
|
|
634
|
+
When the signal key is deterministic and known to the caller (e.g. `station-done-${workflowId}`), use the direct signal-key path to skip the metadata lookup:
|
|
635
|
+
|
|
636
|
+
```typescript
|
|
637
|
+
await lt.escalations.resolveBySignalKey({
|
|
638
|
+
signalKey: `station-done-${workflowId}`,
|
|
639
|
+
resolverPayload: { approved: true },
|
|
640
|
+
});
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
---
|
|
644
|
+
|
|
597
645
|
## Cancelling Escalations
|
|
598
646
|
|
|
599
647
|
Escalations can be cancelled at any point before they are resolved. Cancellation is terminal — a cancelled escalation cannot be re-opened.
|
package/package.json
CHANGED