@hotmeshio/long-tail 0.5.2 → 0.5.3
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/lib/events/system-events.d.ts +19 -0
- package/build/lib/events/system-events.js +62 -0
- package/build/modules/ltconfig.d.ts +8 -0
- package/build/modules/ltconfig.js +10 -0
- package/build/services/escalation/bulk.d.ts +2 -1
- package/build/services/escalation/bulk.js +20 -19
- package/build/services/escalation/client.d.ts +22 -0
- package/build/services/escalation/client.js +141 -0
- package/build/services/escalation/crud.d.ts +29 -21
- package/build/services/escalation/crud.js +175 -140
- package/build/services/escalation/index.d.ts +1 -0
- package/build/services/escalation/index.js +3 -0
- package/build/services/escalation/map.d.ts +15 -0
- package/build/services/escalation/map.js +63 -0
- package/build/services/escalation/queries.js +64 -149
- package/build/services/escalation/sql.d.ts +13 -32
- package/build/services/escalation/sql.js +36 -176
- package/build/services/export/post-process.js +23 -4
- package/build/services/interceptor/activities/config.js +5 -1
- package/build/services/interceptor/index.d.ts +3 -0
- package/build/services/interceptor/index.js +7 -21
- package/build/services/mcp/db-server/schemas.d.ts +1 -1
- package/build/services/yaml-workflow/deployer.js +4 -0
- package/build/services/yaml-workflow/workers/register.js +3 -0
- package/build/start/index.js +2 -1
- package/build/start/workers.js +12 -0
- package/build/system/mcp-servers/admin/schemas.d.ts +1 -1
- package/build/system/mcp-servers/db-query/schemas.d.ts +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
|
@@ -4,167 +4,82 @@ exports.getEscalationStats = getEscalationStats;
|
|
|
4
4
|
exports.listDistinctTypes = listDistinctTypes;
|
|
5
5
|
exports.listEscalations = listEscalations;
|
|
6
6
|
exports.listAvailableEscalations = listAvailableEscalations;
|
|
7
|
-
const
|
|
7
|
+
const client_1 = require("./client");
|
|
8
|
+
const map_1 = require("./map");
|
|
8
9
|
const types_1 = require("./types");
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Default sort is priority ASC, created_at ASC. A user-chosen `sort_by` maps to
|
|
12
|
+
* a single column (DESC unless `order='asc'`), matching the legacy behavior.
|
|
13
|
+
*/
|
|
14
|
+
function buildOrderBy(sortBy, order) {
|
|
15
|
+
if (sortBy && types_1.SORTABLE_COLUMNS.has(sortBy)) {
|
|
16
|
+
return [{ column: sortBy, direction: order === 'asc' ? 'asc' : 'desc' }];
|
|
17
|
+
}
|
|
18
|
+
return [
|
|
19
|
+
{ column: 'priority', direction: 'asc' },
|
|
20
|
+
{ column: 'created_at', direction: 'asc' },
|
|
21
|
+
];
|
|
15
22
|
}
|
|
16
23
|
async function getEscalationStats(visibleRoles, period) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
const params = visibleRoles ? [visibleRoles] : [];
|
|
23
|
-
// Global counts
|
|
24
|
-
const { rows: [totals] } = await pool.query(`SELECT
|
|
25
|
-
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
|
|
26
|
-
COUNT(*) FILTER (WHERE status = 'pending' AND assigned_to IS NOT NULL AND assigned_until > NOW()) AS claimed,
|
|
27
|
-
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '${interval}') AS created,
|
|
28
|
-
COUNT(*) FILTER (WHERE resolved_at > NOW() - INTERVAL '${interval}') AS resolved
|
|
29
|
-
FROM lt_escalations ${roleFilter}`, params);
|
|
30
|
-
// By-role breakdown (pending only)
|
|
31
|
-
const { rows: byRole } = await pool.query(`SELECT role,
|
|
32
|
-
COUNT(*) AS pending,
|
|
33
|
-
COUNT(*) FILTER (WHERE assigned_to IS NOT NULL AND assigned_until > NOW()) AS claimed
|
|
34
|
-
FROM lt_escalations
|
|
35
|
-
WHERE status = 'pending' ${roleFilterAnd}
|
|
36
|
-
GROUP BY role
|
|
37
|
-
ORDER BY COUNT(*) DESC`, params);
|
|
38
|
-
// By-type breakdown (within the time period)
|
|
39
|
-
const { rows: byType } = await pool.query(`SELECT type,
|
|
40
|
-
COUNT(*) FILTER (WHERE status = 'pending') AS pending,
|
|
41
|
-
COUNT(*) FILTER (WHERE status = 'pending' AND assigned_to IS NOT NULL AND assigned_until > NOW()) AS claimed,
|
|
42
|
-
COUNT(*) FILTER (WHERE resolved_at IS NOT NULL) AS resolved
|
|
43
|
-
FROM lt_escalations
|
|
44
|
-
WHERE created_at > NOW() - INTERVAL '${interval}' ${roleFilterAnd}
|
|
45
|
-
GROUP BY type
|
|
46
|
-
ORDER BY COUNT(*) DESC`, params);
|
|
47
|
-
return {
|
|
48
|
-
pending: parseInt(totals.pending),
|
|
49
|
-
claimed: parseInt(totals.claimed),
|
|
50
|
-
created: parseInt(totals.created),
|
|
51
|
-
resolved: parseInt(totals.resolved),
|
|
52
|
-
by_role: byRole.map((r) => ({
|
|
53
|
-
role: r.role,
|
|
54
|
-
pending: parseInt(r.pending),
|
|
55
|
-
claimed: parseInt(r.claimed),
|
|
56
|
-
})),
|
|
57
|
-
by_type: byType.map((r) => ({
|
|
58
|
-
type: r.type,
|
|
59
|
-
pending: parseInt(r.pending),
|
|
60
|
-
claimed: parseInt(r.claimed),
|
|
61
|
-
resolved: parseInt(r.resolved),
|
|
62
|
-
})),
|
|
63
|
-
};
|
|
24
|
+
const client = await (0, client_1.escalations)();
|
|
25
|
+
return client.stats({
|
|
26
|
+
roles: visibleRoles,
|
|
27
|
+
period: period,
|
|
28
|
+
});
|
|
64
29
|
}
|
|
65
30
|
async function listDistinctTypes() {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
return rows.map((r) => r.type);
|
|
31
|
+
const client = await (0, client_1.escalations)();
|
|
32
|
+
return client.listDistinctTypes();
|
|
69
33
|
}
|
|
70
34
|
async function listEscalations(filters) {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (filters.status) {
|
|
81
|
-
conditions.push(`status = $${idx++}`);
|
|
82
|
-
values.push(filters.status);
|
|
83
|
-
}
|
|
84
|
-
if (filters.role) {
|
|
85
|
-
conditions.push(`role = $${idx++}`);
|
|
86
|
-
values.push(filters.role);
|
|
87
|
-
}
|
|
88
|
-
if (filters.type) {
|
|
89
|
-
conditions.push(`type = $${idx++}`);
|
|
90
|
-
values.push(filters.type);
|
|
91
|
-
}
|
|
92
|
-
if (filters.subtype) {
|
|
93
|
-
conditions.push(`subtype = $${idx++}`);
|
|
94
|
-
values.push(filters.subtype);
|
|
95
|
-
}
|
|
96
|
-
if (filters.assigned_to) {
|
|
97
|
-
conditions.push(`assigned_to = $${idx++}`);
|
|
98
|
-
values.push(filters.assigned_to);
|
|
99
|
-
// Only return active claims — expired claims are back in the available pool
|
|
100
|
-
conditions.push('assigned_until > NOW()');
|
|
101
|
-
}
|
|
102
|
-
if (filters.claimed) {
|
|
103
|
-
// All actively claimed escalations (by anyone)
|
|
104
|
-
conditions.push('assigned_to IS NOT NULL');
|
|
105
|
-
conditions.push('assigned_until > NOW()');
|
|
106
|
-
}
|
|
107
|
-
if (filters.priority) {
|
|
108
|
-
conditions.push(`priority = $${idx++}`);
|
|
109
|
-
values.push(filters.priority);
|
|
110
|
-
}
|
|
111
|
-
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
112
|
-
const limit = filters.limit || 50;
|
|
113
|
-
const offset = filters.offset || 0;
|
|
114
|
-
const orderBy = buildOrderBy(filters.sort_by, filters.order);
|
|
115
|
-
const [countResult, dataResult] = await Promise.all([
|
|
116
|
-
pool.query(`SELECT COUNT(*) FROM lt_escalations ${where}`, values),
|
|
117
|
-
pool.query(`SELECT * FROM lt_escalations ${where} ORDER BY ${orderBy} LIMIT $${idx++} OFFSET $${idx++}`, [...values, limit, offset]),
|
|
118
|
-
]);
|
|
119
|
-
return {
|
|
120
|
-
escalations: dataResult.rows,
|
|
121
|
-
total: parseInt(countResult.rows[0].count, 10),
|
|
35
|
+
const client = await (0, client_1.escalations)();
|
|
36
|
+
// Shared filter — passed to both list() and count() so totals stay in sync.
|
|
37
|
+
const where = {
|
|
38
|
+
status: filters.status,
|
|
39
|
+
role: filters.role,
|
|
40
|
+
type: filters.type,
|
|
41
|
+
subtype: filters.subtype,
|
|
42
|
+
priority: filters.priority,
|
|
43
|
+
roles: filters.visibleRoles,
|
|
122
44
|
};
|
|
45
|
+
if (filters.assigned_to)
|
|
46
|
+
where.assignedTo = filters.assigned_to;
|
|
47
|
+
// Active claim semantics: assigned_to-active and `claimed` both mean "held now".
|
|
48
|
+
if (filters.assigned_to || filters.claimed)
|
|
49
|
+
where.available = false;
|
|
50
|
+
const [rows, total] = await Promise.all([
|
|
51
|
+
client.list({
|
|
52
|
+
...where,
|
|
53
|
+
orderBy: buildOrderBy(filters.sort_by, filters.order),
|
|
54
|
+
limit: filters.limit ?? 50,
|
|
55
|
+
offset: filters.offset ?? 0,
|
|
56
|
+
}),
|
|
57
|
+
client.count(where),
|
|
58
|
+
]);
|
|
59
|
+
return { escalations: (0, map_1.toEscalationRecords)(rows), total };
|
|
123
60
|
}
|
|
124
61
|
/**
|
|
125
62
|
* List available escalations: pending AND (unassigned OR expired claim).
|
|
126
63
|
*/
|
|
127
64
|
async function listAvailableEscalations(filters) {
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
conditions.push(`role = ANY($${idx++}::text[])`);
|
|
138
|
-
values.push(filters.visibleRoles);
|
|
139
|
-
}
|
|
140
|
-
if (filters.role) {
|
|
141
|
-
conditions.push(`role = $${idx++}`);
|
|
142
|
-
values.push(filters.role);
|
|
143
|
-
}
|
|
144
|
-
if (filters.type) {
|
|
145
|
-
conditions.push(`type = $${idx++}`);
|
|
146
|
-
values.push(filters.type);
|
|
147
|
-
}
|
|
148
|
-
if (filters.subtype) {
|
|
149
|
-
conditions.push(`subtype = $${idx++}`);
|
|
150
|
-
values.push(filters.subtype);
|
|
151
|
-
}
|
|
152
|
-
if (filters.priority) {
|
|
153
|
-
conditions.push(`priority = $${idx++}`);
|
|
154
|
-
values.push(filters.priority);
|
|
155
|
-
}
|
|
156
|
-
const where = `WHERE ${conditions.join(' AND ')}`;
|
|
157
|
-
const limit = filters.limit || 50;
|
|
158
|
-
const offset = filters.offset || 0;
|
|
159
|
-
const orderBy = buildOrderBy(filters.sort_by, filters.order);
|
|
160
|
-
const [countResult, dataResult] = await Promise.all([
|
|
161
|
-
pool.query(`SELECT COUNT(*) FROM lt_escalations ${where}`, values),
|
|
162
|
-
pool.query(`SELECT * FROM lt_escalations ${where}
|
|
163
|
-
ORDER BY ${orderBy}
|
|
164
|
-
LIMIT $${idx++} OFFSET $${idx++}`, [...values, limit, offset]),
|
|
165
|
-
]);
|
|
166
|
-
return {
|
|
167
|
-
escalations: dataResult.rows,
|
|
168
|
-
total: parseInt(countResult.rows[0].count, 10),
|
|
65
|
+
const client = await (0, client_1.escalations)();
|
|
66
|
+
const where = {
|
|
67
|
+
status: 'pending',
|
|
68
|
+
available: true,
|
|
69
|
+
role: filters.role,
|
|
70
|
+
type: filters.type,
|
|
71
|
+
subtype: filters.subtype,
|
|
72
|
+
priority: filters.priority,
|
|
73
|
+
roles: filters.visibleRoles,
|
|
169
74
|
};
|
|
75
|
+
const [rows, total] = await Promise.all([
|
|
76
|
+
client.list({
|
|
77
|
+
...where,
|
|
78
|
+
orderBy: buildOrderBy(filters.sort_by, filters.order),
|
|
79
|
+
limit: filters.limit ?? 50,
|
|
80
|
+
offset: filters.offset ?? 0,
|
|
81
|
+
}),
|
|
82
|
+
client.count(where),
|
|
83
|
+
]);
|
|
84
|
+
return { escalations: (0, map_1.toEscalationRecords)(rows), total };
|
|
170
85
|
}
|
|
@@ -1,42 +1,23 @@
|
|
|
1
|
-
export declare const ENSURE_ROLE_EXISTS = "INSERT INTO lt_roles (role) VALUES ($1) ON CONFLICT DO NOTHING";
|
|
2
|
-
export declare const CREATE_ESCALATION = "INSERT INTO lt_escalations\n (type, subtype, description, priority, task_id,\n origin_id, parent_id, role, envelope, metadata, escalation_payload,\n workflow_id, task_queue, workflow_type, trace_id, span_id)\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)\nRETURNING *";
|
|
3
|
-
export declare const CLAIM_ESCALATION = "WITH prev AS (\n SELECT assigned_to, assigned_until\n FROM lt_escalations\n WHERE id = $1\n),\nupdated AS (\n UPDATE lt_escalations\n SET assigned_to = $2,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '1 minute' * $3\n WHERE id = $1\n AND status = 'pending'\n AND (\n assigned_to IS NULL\n OR assigned_until <= NOW()\n OR assigned_to = $2\n )\n RETURNING *\n)\nSELECT updated.*,\n prev.assigned_to AS prev_assigned_to\nFROM updated\nCROSS JOIN prev";
|
|
4
|
-
export declare const RESOLVE_ESCALATION = "UPDATE lt_escalations\nSET status = 'resolved',\n resolved_at = NOW(),\n resolver_payload = $2\nWHERE id = $1\n AND status = 'pending'\nRETURNING *";
|
|
5
|
-
export declare const UPDATE_ESCALATIONS_PRIORITY = "UPDATE lt_escalations\nSET priority = $1, updated_at = NOW()\nWHERE id = ANY($2::uuid[])\n AND status = 'pending'";
|
|
6
|
-
export declare const GET_ESCALATION_ROLES = "SELECT DISTINCT role FROM lt_escalations WHERE id = ANY($1::uuid[])";
|
|
7
|
-
export declare const RELEASE_ESCALATION = "UPDATE lt_escalations\nSET assigned_to = NULL,\n assigned_until = NULL,\n claimed_at = NULL\nWHERE id = $1\n AND status = 'pending'\n AND assigned_to = $2\nRETURNING *";
|
|
8
|
-
export declare const RELEASE_EXPIRED_CLAIMS = "UPDATE lt_escalations\nSET assigned_to = NULL,\n assigned_until = NULL,\n claimed_at = NULL\nWHERE status = 'pending'\n AND assigned_to IS NOT NULL\n AND assigned_until < NOW()";
|
|
9
|
-
export declare const ESCALATE_TO_ROLE = "UPDATE lt_escalations\nSET role = $2,\n assigned_to = NULL,\n assigned_until = NULL,\n claimed_at = NULL,\n updated_at = NOW()\nWHERE id = $1\n AND status = 'pending'\nRETURNING *";
|
|
10
|
-
export declare const GET_ESCALATION = "SELECT * FROM lt_escalations WHERE id = $1";
|
|
11
|
-
export declare const GET_ESCALATIONS_BY_TASK_ID = "SELECT * FROM lt_escalations WHERE task_id = $1 ORDER BY created_at DESC";
|
|
12
|
-
export declare const GET_ESCALATIONS_BY_WORKFLOW_ID = "SELECT * FROM lt_escalations WHERE workflow_id = $1 ORDER BY created_at DESC";
|
|
13
|
-
export declare const GET_ESCALATIONS_BY_ORIGIN_ID = "SELECT * FROM lt_escalations WHERE origin_id = $1 ORDER BY created_at DESC";
|
|
14
|
-
/** Used by both bulkClaimEscalations and bulkAssignEscalations (identical SQL). */
|
|
15
|
-
export declare const BULK_CLAIM = "UPDATE lt_escalations\nSET assigned_to = $1,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '1 minute' * $2\nWHERE id = ANY($3::uuid[])\n AND status = 'pending'\n AND (\n assigned_to IS NULL\n OR assigned_until <= NOW()\n OR assigned_to = $1\n )";
|
|
16
|
-
export declare const BULK_ASSIGN = "UPDATE lt_escalations\nSET assigned_to = $1,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '1 minute' * $2\nWHERE id = ANY($3::uuid[])\n AND status = 'pending'\n AND (\n assigned_to IS NULL\n OR assigned_until <= NOW()\n OR assigned_to = $1\n )";
|
|
17
|
-
export declare const BULK_ESCALATE_TO_ROLE = "UPDATE lt_escalations\nSET role = $1,\n assigned_to = NULL,\n assigned_until = NULL,\n claimed_at = NULL,\n updated_at = NOW()\nWHERE id = ANY($2::uuid[])\n AND status = 'pending'";
|
|
18
|
-
export declare const BULK_RESOLVE_FOR_TRIAGE = "UPDATE lt_escalations\nSET status = 'resolved',\n resolved_at = NOW(),\n resolver_payload = $1\nWHERE id = ANY($2::uuid[])\n AND status = 'pending'\nRETURNING *";
|
|
19
|
-
export declare const UPDATE_ESCALATION_METADATA = "UPDATE lt_escalations\nSET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,\n updated_at = NOW()\nWHERE id = $1\nRETURNING *";
|
|
20
|
-
export declare const ENRICH_ESCALATION_ROUTING = "UPDATE lt_escalations\nSET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,\n workflow_type = COALESCE(workflow_type, $3),\n workflow_id = COALESCE(workflow_id, $4),\n task_queue = COALESCE(task_queue, $5),\n task_id = COALESCE(task_id, $6),\n updated_at = NOW()\nWHERE id = $1\nRETURNING *";
|
|
21
|
-
export declare const LIST_DISTINCT_TYPES = "SELECT DISTINCT type FROM lt_escalations ORDER BY type";
|
|
22
|
-
/** Find escalations by a single metadata key-value pair. Window function for total count. */
|
|
23
|
-
export declare const FIND_BY_METADATA = "SELECT *, COUNT(*) OVER() AS _total\nFROM lt_escalations\nWHERE metadata @> $1::jsonb\n AND ($2::text IS NULL OR status = $2)\nORDER BY priority ASC, created_at ASC\nLIMIT $3 OFFSET $4";
|
|
24
1
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
2
|
+
* Sweep expired claims back to the available pool.
|
|
3
|
+
*
|
|
4
|
+
* In the implicit-claim model an expired claim is already available at query
|
|
5
|
+
* time (`assigned_until <= NOW()`), so this is a cosmetic cleanup — but it is
|
|
6
|
+
* long-tail's public contract (returns a count, clears `assigned_to`) and the
|
|
7
|
+
* SDK's `releaseExpired()` is a no-op, so it runs as direct SQL on the shared
|
|
8
|
+
* table. Clears both `assigned_until` and `claim_expires_at`.
|
|
28
9
|
*/
|
|
29
|
-
export declare const
|
|
10
|
+
export declare const RELEASE_EXPIRED_CLAIMS = "UPDATE public.hmsh_escalations\nSET assigned_to = NULL,\n assigned_until = NULL,\n claimed_at = NULL,\n claim_expires_at = NULL,\n updated_at = NOW()\nWHERE status = 'pending'\n AND assigned_to IS NOT NULL\n AND assigned_until <= NOW()";
|
|
30
11
|
/**
|
|
31
12
|
* Atomic resolve by metadata with signal guard.
|
|
32
13
|
*
|
|
33
14
|
* Single query, two outcomes:
|
|
34
|
-
* 1. No signal_id → claim + resolve atomically. `resolved` is populated.
|
|
35
|
-
* 2. signal_id present → resolve CTE skips (guard in WHERE). `resolved`
|
|
36
|
-
* but `target_id`, `signal_id`,
|
|
37
|
-
*
|
|
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.
|
|
38
19
|
*
|
|
39
20
|
* $1 = metadata filter (jsonb), $2 = userId, $3 = resolver_payload (jsonb),
|
|
40
21
|
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
41
22
|
*/
|
|
42
|
-
export declare const RESOLVE_BY_METADATA_ATOMIC = "WITH target AS MATERIALIZED (\n SELECT *\n FROM
|
|
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";
|
|
@@ -1,188 +1,44 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
// ---------------------------------------------------------------------------
|
|
3
|
-
// Escalation SQL
|
|
3
|
+
// Escalation SQL
|
|
4
|
+
//
|
|
5
|
+
// The escalation service operates through `client.escalations.*` over
|
|
6
|
+
// `public.hmsh_escalations`. The single exception is the resolve-by-metadata
|
|
7
|
+
// signal guard: long-tail must NOT resolve a signal-backed row in the DB (it
|
|
8
|
+
// signals the paused workflow instead, and the workflow interceptor resolves
|
|
9
|
+
// durably). That decision plus the find + claim + resolve must be one atomic
|
|
10
|
+
// statement to avoid a TOCTOU window, so it is expressed directly against
|
|
11
|
+
// `hmsh_escalations` here rather than composed from generic SDK calls.
|
|
4
12
|
// ---------------------------------------------------------------------------
|
|
5
13
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.RESOLVE_BY_METADATA_ATOMIC = exports.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
RETURNING *`;
|
|
17
|
-
exports.CLAIM_ESCALATION = `\
|
|
18
|
-
WITH prev AS (
|
|
19
|
-
SELECT assigned_to, assigned_until
|
|
20
|
-
FROM lt_escalations
|
|
21
|
-
WHERE id = $1
|
|
22
|
-
),
|
|
23
|
-
updated AS (
|
|
24
|
-
UPDATE lt_escalations
|
|
25
|
-
SET assigned_to = $2,
|
|
26
|
-
claimed_at = NOW(),
|
|
27
|
-
assigned_until = NOW() + INTERVAL '1 minute' * $3
|
|
28
|
-
WHERE id = $1
|
|
29
|
-
AND status = 'pending'
|
|
30
|
-
AND (
|
|
31
|
-
assigned_to IS NULL
|
|
32
|
-
OR assigned_until <= NOW()
|
|
33
|
-
OR assigned_to = $2
|
|
34
|
-
)
|
|
35
|
-
RETURNING *
|
|
36
|
-
)
|
|
37
|
-
SELECT updated.*,
|
|
38
|
-
prev.assigned_to AS prev_assigned_to
|
|
39
|
-
FROM updated
|
|
40
|
-
CROSS JOIN prev`;
|
|
41
|
-
exports.RESOLVE_ESCALATION = `\
|
|
42
|
-
UPDATE lt_escalations
|
|
43
|
-
SET status = 'resolved',
|
|
44
|
-
resolved_at = NOW(),
|
|
45
|
-
resolver_payload = $2
|
|
46
|
-
WHERE id = $1
|
|
47
|
-
AND status = 'pending'
|
|
48
|
-
RETURNING *`;
|
|
49
|
-
exports.UPDATE_ESCALATIONS_PRIORITY = `\
|
|
50
|
-
UPDATE lt_escalations
|
|
51
|
-
SET priority = $1, updated_at = NOW()
|
|
52
|
-
WHERE id = ANY($2::uuid[])
|
|
53
|
-
AND status = 'pending'`;
|
|
54
|
-
exports.GET_ESCALATION_ROLES = 'SELECT DISTINCT role FROM lt_escalations WHERE id = ANY($1::uuid[])';
|
|
55
|
-
exports.RELEASE_ESCALATION = `\
|
|
56
|
-
UPDATE lt_escalations
|
|
57
|
-
SET assigned_to = NULL,
|
|
58
|
-
assigned_until = NULL,
|
|
59
|
-
claimed_at = NULL
|
|
60
|
-
WHERE id = $1
|
|
61
|
-
AND status = 'pending'
|
|
62
|
-
AND assigned_to = $2
|
|
63
|
-
RETURNING *`;
|
|
14
|
+
exports.RESOLVE_BY_METADATA_ATOMIC = exports.RELEASE_EXPIRED_CLAIMS = void 0;
|
|
15
|
+
/**
|
|
16
|
+
* Sweep expired claims back to the available pool.
|
|
17
|
+
*
|
|
18
|
+
* In the implicit-claim model an expired claim is already available at query
|
|
19
|
+
* time (`assigned_until <= NOW()`), so this is a cosmetic cleanup — but it is
|
|
20
|
+
* long-tail's public contract (returns a count, clears `assigned_to`) and the
|
|
21
|
+
* SDK's `releaseExpired()` is a no-op, so it runs as direct SQL on the shared
|
|
22
|
+
* table. Clears both `assigned_until` and `claim_expires_at`.
|
|
23
|
+
*/
|
|
64
24
|
exports.RELEASE_EXPIRED_CLAIMS = `\
|
|
65
|
-
UPDATE
|
|
25
|
+
UPDATE public.hmsh_escalations
|
|
66
26
|
SET assigned_to = NULL,
|
|
67
|
-
assigned_until = NULL,
|
|
68
|
-
claimed_at = NULL
|
|
69
|
-
WHERE status = 'pending'
|
|
70
|
-
AND assigned_to IS NOT NULL
|
|
71
|
-
AND assigned_until < NOW()`;
|
|
72
|
-
exports.ESCALATE_TO_ROLE = `\
|
|
73
|
-
UPDATE lt_escalations
|
|
74
|
-
SET role = $2,
|
|
75
|
-
assigned_to = NULL,
|
|
76
|
-
assigned_until = NULL,
|
|
77
|
-
claimed_at = NULL,
|
|
78
|
-
updated_at = NOW()
|
|
79
|
-
WHERE id = $1
|
|
80
|
-
AND status = 'pending'
|
|
81
|
-
RETURNING *`;
|
|
82
|
-
exports.GET_ESCALATION = 'SELECT * FROM lt_escalations WHERE id = $1';
|
|
83
|
-
exports.GET_ESCALATIONS_BY_TASK_ID = 'SELECT * FROM lt_escalations WHERE task_id = $1 ORDER BY created_at DESC';
|
|
84
|
-
exports.GET_ESCALATIONS_BY_WORKFLOW_ID = 'SELECT * FROM lt_escalations WHERE workflow_id = $1 ORDER BY created_at DESC';
|
|
85
|
-
exports.GET_ESCALATIONS_BY_ORIGIN_ID = 'SELECT * FROM lt_escalations WHERE origin_id = $1 ORDER BY created_at DESC';
|
|
86
|
-
// --- Bulk operations -------------------------------------------------------
|
|
87
|
-
/** Used by both bulkClaimEscalations and bulkAssignEscalations (identical SQL). */
|
|
88
|
-
exports.BULK_CLAIM = `\
|
|
89
|
-
UPDATE lt_escalations
|
|
90
|
-
SET assigned_to = $1,
|
|
91
|
-
claimed_at = NOW(),
|
|
92
|
-
assigned_until = NOW() + INTERVAL '1 minute' * $2
|
|
93
|
-
WHERE id = ANY($3::uuid[])
|
|
94
|
-
AND status = 'pending'
|
|
95
|
-
AND (
|
|
96
|
-
assigned_to IS NULL
|
|
97
|
-
OR assigned_until <= NOW()
|
|
98
|
-
OR assigned_to = $1
|
|
99
|
-
)`;
|
|
100
|
-
exports.BULK_ASSIGN = exports.BULK_CLAIM;
|
|
101
|
-
exports.BULK_ESCALATE_TO_ROLE = `\
|
|
102
|
-
UPDATE lt_escalations
|
|
103
|
-
SET role = $1,
|
|
104
|
-
assigned_to = NULL,
|
|
105
27
|
assigned_until = NULL,
|
|
106
28
|
claimed_at = NULL,
|
|
29
|
+
claim_expires_at = NULL,
|
|
107
30
|
updated_at = NOW()
|
|
108
|
-
WHERE
|
|
109
|
-
AND
|
|
110
|
-
|
|
111
|
-
UPDATE lt_escalations
|
|
112
|
-
SET status = 'resolved',
|
|
113
|
-
resolved_at = NOW(),
|
|
114
|
-
resolver_payload = $1
|
|
115
|
-
WHERE id = ANY($2::uuid[])
|
|
116
|
-
AND status = 'pending'
|
|
117
|
-
RETURNING *`;
|
|
118
|
-
// --- Query helpers ---------------------------------------------------------
|
|
119
|
-
exports.UPDATE_ESCALATION_METADATA = `\
|
|
120
|
-
UPDATE lt_escalations
|
|
121
|
-
SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
|
|
122
|
-
updated_at = NOW()
|
|
123
|
-
WHERE id = $1
|
|
124
|
-
RETURNING *`;
|
|
125
|
-
exports.ENRICH_ESCALATION_ROUTING = `\
|
|
126
|
-
UPDATE lt_escalations
|
|
127
|
-
SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb,
|
|
128
|
-
workflow_type = COALESCE(workflow_type, $3),
|
|
129
|
-
workflow_id = COALESCE(workflow_id, $4),
|
|
130
|
-
task_queue = COALESCE(task_queue, $5),
|
|
131
|
-
task_id = COALESCE(task_id, $6),
|
|
132
|
-
updated_at = NOW()
|
|
133
|
-
WHERE id = $1
|
|
134
|
-
RETURNING *`;
|
|
135
|
-
exports.LIST_DISTINCT_TYPES = 'SELECT DISTINCT type FROM lt_escalations ORDER BY type';
|
|
136
|
-
// --- Metadata candidate key lookups -----------------------------------------
|
|
137
|
-
/** Find escalations by a single metadata key-value pair. Window function for total count. */
|
|
138
|
-
exports.FIND_BY_METADATA = `\
|
|
139
|
-
SELECT *, COUNT(*) OVER() AS _total
|
|
140
|
-
FROM lt_escalations
|
|
141
|
-
WHERE metadata @> $1::jsonb
|
|
142
|
-
AND ($2::text IS NULL OR status = $2)
|
|
143
|
-
ORDER BY priority ASC, created_at ASC
|
|
144
|
-
LIMIT $3 OFFSET $4`;
|
|
145
|
-
/**
|
|
146
|
-
* Atomic claim by metadata with inline RBAC.
|
|
147
|
-
* $1 = metadata filter (jsonb), $2 = userId, $3 = durationMinutes,
|
|
148
|
-
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
149
|
-
*/
|
|
150
|
-
exports.CLAIM_BY_METADATA_GUARDED = `\
|
|
151
|
-
WITH target AS MATERIALIZED (
|
|
152
|
-
SELECT id, assigned_to
|
|
153
|
-
FROM lt_escalations
|
|
154
|
-
WHERE metadata @> $1::jsonb
|
|
155
|
-
AND status = 'pending'
|
|
156
|
-
AND (assigned_to IS NULL OR assigned_until <= NOW() OR assigned_to = $2)
|
|
157
|
-
AND ($5::text[] IS NULL OR role = ANY($5))
|
|
158
|
-
ORDER BY priority ASC, created_at ASC
|
|
159
|
-
LIMIT 1
|
|
160
|
-
FOR UPDATE SKIP LOCKED
|
|
161
|
-
),
|
|
162
|
-
updated AS (
|
|
163
|
-
UPDATE lt_escalations e
|
|
164
|
-
SET assigned_to = $2,
|
|
165
|
-
claimed_at = NOW(),
|
|
166
|
-
assigned_until = NOW() + INTERVAL '1 minute' * $3,
|
|
167
|
-
metadata = CASE WHEN $4::jsonb IS NOT NULL
|
|
168
|
-
THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb
|
|
169
|
-
ELSE e.metadata END,
|
|
170
|
-
updated_at = NOW()
|
|
171
|
-
FROM target t
|
|
172
|
-
WHERE e.id = t.id
|
|
173
|
-
RETURNING e.*, t.assigned_to AS prev_assigned_to
|
|
174
|
-
)
|
|
175
|
-
SELECT *,
|
|
176
|
-
(SELECT COUNT(*) FROM lt_escalations WHERE metadata @> $1::jsonb AND status = 'pending') AS candidates_exist
|
|
177
|
-
FROM updated`;
|
|
31
|
+
WHERE status = 'pending'
|
|
32
|
+
AND assigned_to IS NOT NULL
|
|
33
|
+
AND assigned_until <= NOW()`;
|
|
178
34
|
/**
|
|
179
35
|
* Atomic resolve by metadata with signal guard.
|
|
180
36
|
*
|
|
181
37
|
* Single query, two outcomes:
|
|
182
|
-
* 1. No signal_id → claim + resolve atomically. `resolved` is populated.
|
|
183
|
-
* 2. signal_id present → resolve CTE skips (guard in WHERE). `resolved`
|
|
184
|
-
* but `target_id`, `signal_id`,
|
|
185
|
-
*
|
|
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.
|
|
186
42
|
*
|
|
187
43
|
* $1 = metadata filter (jsonb), $2 = userId, $3 = resolver_payload (jsonb),
|
|
188
44
|
* $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
|
|
@@ -190,7 +46,7 @@ FROM updated`;
|
|
|
190
46
|
exports.RESOLVE_BY_METADATA_ATOMIC = `\
|
|
191
47
|
WITH target AS MATERIALIZED (
|
|
192
48
|
SELECT *
|
|
193
|
-
FROM
|
|
49
|
+
FROM public.hmsh_escalations
|
|
194
50
|
WHERE metadata @> $1::jsonb
|
|
195
51
|
AND status = 'pending'
|
|
196
52
|
AND ($5::text[] IS NULL OR role = ANY($5))
|
|
@@ -199,22 +55,26 @@ WITH target AS MATERIALIZED (
|
|
|
199
55
|
FOR UPDATE
|
|
200
56
|
),
|
|
201
57
|
claimed AS (
|
|
202
|
-
UPDATE
|
|
58
|
+
UPDATE public.hmsh_escalations e
|
|
203
59
|
SET assigned_to = COALESCE(e.assigned_to, $2),
|
|
204
60
|
claimed_at = COALESCE(e.claimed_at, NOW()),
|
|
205
61
|
assigned_until = CASE
|
|
206
62
|
WHEN e.assigned_to IS NOT NULL AND e.assigned_until > NOW() THEN e.assigned_until
|
|
207
63
|
ELSE NOW() + INTERVAL '5 minutes' END,
|
|
64
|
+
claim_expires_at = CASE
|
|
65
|
+
WHEN e.assigned_to IS NOT NULL AND e.assigned_until > NOW() THEN e.claim_expires_at
|
|
66
|
+
ELSE NOW() + INTERVAL '5 minutes' END,
|
|
208
67
|
metadata = CASE WHEN $4::jsonb IS NOT NULL
|
|
209
68
|
THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb
|
|
210
|
-
ELSE e.metadata END
|
|
69
|
+
ELSE e.metadata END,
|
|
70
|
+
updated_at = NOW()
|
|
211
71
|
FROM target
|
|
212
72
|
WHERE e.id = target.id
|
|
213
73
|
AND (target.metadata->>'signal_id') IS NULL
|
|
214
74
|
RETURNING e.*
|
|
215
75
|
),
|
|
216
76
|
resolved AS (
|
|
217
|
-
UPDATE
|
|
77
|
+
UPDATE public.hmsh_escalations e
|
|
218
78
|
SET status = 'resolved',
|
|
219
79
|
resolved_at = NOW(),
|
|
220
80
|
resolver_payload = $3,
|
|
@@ -111,16 +111,35 @@ function postProcessExecution(execution) {
|
|
|
111
111
|
if (toAdd.length > 0) {
|
|
112
112
|
events.push(...toAdd);
|
|
113
113
|
}
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
114
|
+
// Order the stream in three bands: the workflow_execution_started bookend
|
|
115
|
+
// first, the workflow_execution_completed/failed bookend last, and the
|
|
116
|
+
// activity/signal events in between. Within the middle band, execution_index
|
|
117
|
+
// reflects the true workflow sequence (timestamps are unreliable — e.g.
|
|
118
|
+
// signal_wait_started gets the workflow start time), so sort by it and fall
|
|
119
|
+
// back to timestamp only when index is absent or tied.
|
|
120
|
+
//
|
|
121
|
+
// The bookends carry no execution_index; without explicit banding the
|
|
122
|
+
// completed event would sort to the front alongside started. (HotMesh's
|
|
123
|
+
// exporter does not index the terminal event.)
|
|
124
|
+
const band = (e) => {
|
|
125
|
+
if (e.event_type === 'workflow_execution_started')
|
|
126
|
+
return 0;
|
|
127
|
+
if (e.event_type === 'workflow_execution_completed' ||
|
|
128
|
+
e.event_type === 'workflow_execution_failed')
|
|
129
|
+
return 2;
|
|
130
|
+
return 1;
|
|
131
|
+
};
|
|
117
132
|
events.sort((a, b) => {
|
|
133
|
+
const bandA = band(a);
|
|
134
|
+
const bandB = band(b);
|
|
135
|
+
if (bandA !== bandB)
|
|
136
|
+
return bandA - bandB;
|
|
118
137
|
const idxA = a.attributes.execution_index ?? -1;
|
|
119
138
|
const idxB = b.attributes.execution_index ?? -1;
|
|
120
139
|
if (idxA !== -1 && idxB !== -1 && idxA !== idxB)
|
|
121
140
|
return idxA - idxB;
|
|
122
141
|
if (idxA !== -1 && idxB === -1)
|
|
123
|
-
return 1;
|
|
142
|
+
return 1;
|
|
124
143
|
if (idxA === -1 && idxB !== -1)
|
|
125
144
|
return -1;
|
|
126
145
|
// Same index (scheduled/completed pairs) — sort by timestamp
|
|
@@ -12,7 +12,11 @@ const ltconfig_1 = require("../../../modules/ltconfig");
|
|
|
12
12
|
* TTL cache — most calls resolve from memory without hitting the DB.
|
|
13
13
|
*/
|
|
14
14
|
async function ltGetWorkflowConfig(workflowName) {
|
|
15
|
-
|
|
15
|
+
// Gate on REGISTRATION (a row in lt_config_workflows), not certification:
|
|
16
|
+
// every registered workflow gets interceptor task/escalation/orchestrator
|
|
17
|
+
// treatment. Certification (roles/consumes) governs RBAC and provider
|
|
18
|
+
// injection, not whether the workflow is tracked.
|
|
19
|
+
return ltconfig_1.ltConfig.getRegisteredConfig(workflowName);
|
|
16
20
|
}
|
|
17
21
|
/**
|
|
18
22
|
* Get provider data for a workflow's consumers by looking up
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Types } from '@hotmeshio/hotmesh';
|
|
1
2
|
/**
|
|
2
3
|
* Register the Long Tail interceptors in a single call.
|
|
3
4
|
*
|
|
@@ -12,6 +13,8 @@ export declare function registerLT(connection: {
|
|
|
12
13
|
}, options?: {
|
|
13
14
|
taskQueue?: string;
|
|
14
15
|
defaultRole?: string;
|
|
16
|
+
/** SDK system-event sink — wired so the activity worker emits lifecycle events. */
|
|
17
|
+
events?: Types.EventsConfig;
|
|
15
18
|
}): Promise<void>;
|
|
16
19
|
/**
|
|
17
20
|
* The Long Tail interceptor wraps every registered workflow.
|