@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.
@@ -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
- // Signal-backed escalation signal the workflow, conditionLT resolves durably
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
- /** 'resolved' = done atomically. 'signal_required' = signal_id present, caller must signal. */
107
- outcome: 'resolved' | 'signal_required' | 'not_found';
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
- /** Signal info (when outcome = 'signal_required') */
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, two outcomes:
15
- * 1. No `metadata.signal_id` → claim + resolve atomically. `resolved` is populated.
16
- * 2. `metadata.signal_id` present → resolve CTE skips (guard in WHERE). `resolved`
17
- * is null, but `target_id`, `signal_id`, and workflow routing are returned so
18
- * the caller can signal the workflow directly.
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 (target.metadata->>'signal_id') 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 RETURNING e.*\n)\nSELECT\n resolved.*,\n target.id AS target_id,\n target.metadata->>'signal_id' AS signal_id,\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 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";
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, two outcomes:
38
- * 1. No `metadata.signal_id` → claim + resolve atomically. `resolved` is populated.
39
- * 2. `metadata.signal_id` present → resolve CTE skips (guard in WHERE). `resolved`
40
- * is null, but `target_id`, `signal_id`, and workflow routing are returned so
41
- * the caller can signal the workflow directly.
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 (target.metadata->>'signal_id') IS NULL
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`;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/long-tail",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "Long Tail Workflows — Durable AI workflows with human-in-the-loop escalation. Powered by PostgreSQL.",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",