@hotmeshio/long-tail 0.5.2 → 0.5.4

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.
Files changed (41) hide show
  1. package/build/api/escalations/index.d.ts +1 -1
  2. package/build/api/escalations/index.js +2 -1
  3. package/build/api/escalations/resolve.d.ts +10 -0
  4. package/build/api/escalations/resolve.js +52 -0
  5. package/build/lib/events/system-events.d.ts +19 -0
  6. package/build/lib/events/system-events.js +62 -0
  7. package/build/modules/ltconfig.d.ts +8 -0
  8. package/build/modules/ltconfig.js +10 -0
  9. package/build/routes/escalations/resolve.js +15 -3
  10. package/build/services/escalation/bulk.d.ts +2 -1
  11. package/build/services/escalation/bulk.js +20 -19
  12. package/build/services/escalation/client.d.ts +22 -0
  13. package/build/services/escalation/client.js +141 -0
  14. package/build/services/escalation/crud.d.ts +47 -21
  15. package/build/services/escalation/crud.js +204 -140
  16. package/build/services/escalation/index.d.ts +1 -0
  17. package/build/services/escalation/index.js +3 -0
  18. package/build/services/escalation/map.d.ts +15 -0
  19. package/build/services/escalation/map.js +64 -0
  20. package/build/services/escalation/queries.js +64 -149
  21. package/build/services/escalation/sql.d.ts +13 -32
  22. package/build/services/escalation/sql.js +36 -176
  23. package/build/services/export/post-process.js +23 -4
  24. package/build/services/interceptor/activities/config.js +5 -1
  25. package/build/services/interceptor/index.d.ts +3 -0
  26. package/build/services/interceptor/index.js +7 -21
  27. package/build/services/mcp/db-server/schemas.d.ts +1 -1
  28. package/build/services/orchestrator/condition.d.ts +30 -25
  29. package/build/services/orchestrator/condition.js +30 -26
  30. package/build/services/yaml-workflow/deployer.js +4 -0
  31. package/build/services/yaml-workflow/workers/register.js +3 -0
  32. package/build/start/index.js +2 -1
  33. package/build/start/workers.js +12 -0
  34. package/build/system/mcp-servers/admin/schemas.d.ts +1 -1
  35. package/build/system/mcp-servers/db-query/schemas.d.ts +1 -1
  36. package/build/tsconfig.tsbuildinfo +1 -1
  37. package/build/types/escalation.d.ts +1 -0
  38. package/docs/api/http/escalations.md +19 -0
  39. package/docs/api/sdk/escalations.md +33 -0
  40. package/docs/hitl-guide.md +44 -1
  41. 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 db_1 = require("../../lib/db");
7
+ const client_1 = require("./client");
8
+ const map_1 = require("./map");
8
9
  const types_1 = require("./types");
9
- const sql_1 = require("./sql");
10
- function buildOrderBy(sortBy, order, fallback = 'priority ASC, created_at ASC') {
11
- if (!sortBy || !types_1.SORTABLE_COLUMNS.has(sortBy))
12
- return fallback;
13
- const dir = order === 'asc' ? 'ASC' : 'DESC';
14
- return `${sortBy} ${dir}`;
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 pool = (0, db_1.getPool)();
18
- const interval = types_1.VALID_PERIODS[period ?? '24h'] ?? '24 hours';
19
- // Build optional RBAC filter
20
- const roleFilter = visibleRoles ? `WHERE role = ANY($1::text[])` : '';
21
- const roleFilterAnd = visibleRoles ? `AND role = ANY($1::text[])` : '';
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 pool = (0, db_1.getPool)();
67
- const { rows } = await pool.query(sql_1.LIST_DISTINCT_TYPES);
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 pool = (0, db_1.getPool)();
72
- const conditions = [];
73
- const values = [];
74
- let idx = 1;
75
- // RBAC: scope to roles the user is a member of
76
- if (filters.visibleRoles) {
77
- conditions.push(`role = ANY($${idx++}::text[])`);
78
- values.push(filters.visibleRoles);
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 pool = (0, db_1.getPool)();
129
- const conditions = [
130
- "status = 'pending'",
131
- '(assigned_to IS NULL OR assigned_until <= NOW())',
132
- ];
133
- const values = [];
134
- let idx = 1;
135
- // RBAC: scope to roles the user is a member of
136
- if (filters.visibleRoles) {
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
- * Atomic claim by metadata with inline RBAC.
26
- * $1 = metadata filter (jsonb), $2 = userId, $3 = durationMinutes,
27
- * $4 = metadata patch (jsonb, nullable), $5 = allowed roles (text[], null = no filter)
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 CLAIM_BY_METADATA_GUARDED = "WITH target AS MATERIALIZED (\n SELECT id, assigned_to\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND (assigned_to IS NULL OR assigned_until <= NOW() OR assigned_to = $2)\n AND ($5::text[] IS NULL OR role = ANY($5))\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n),\nupdated AS (\n UPDATE lt_escalations e\n SET assigned_to = $2,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '1 minute' * $3,\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 t\n WHERE e.id = t.id\n RETURNING e.*, t.assigned_to AS prev_assigned_to\n)\nSELECT *,\n (SELECT COUNT(*) FROM lt_escalations WHERE metadata @> $1::jsonb AND status = 'pending') AS candidates_exist\nFROM updated";
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` is null,
36
- * but `target_id`, `signal_id`, `workflow_id`, `task_queue`, `workflow_type`
37
- * are returned so the caller can signal the workflow directly.
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 lt_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 lt_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 metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END\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 lt_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";
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 – externalized from crud.ts, bulk.ts, queries.ts
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.CLAIM_BY_METADATA_GUARDED = exports.FIND_BY_METADATA = exports.LIST_DISTINCT_TYPES = exports.ENRICH_ESCALATION_ROUTING = exports.UPDATE_ESCALATION_METADATA = exports.BULK_RESOLVE_FOR_TRIAGE = exports.BULK_ESCALATE_TO_ROLE = exports.BULK_ASSIGN = exports.BULK_CLAIM = exports.GET_ESCALATIONS_BY_ORIGIN_ID = exports.GET_ESCALATIONS_BY_WORKFLOW_ID = exports.GET_ESCALATIONS_BY_TASK_ID = exports.GET_ESCALATION = exports.ESCALATE_TO_ROLE = exports.RELEASE_EXPIRED_CLAIMS = exports.RELEASE_ESCALATION = exports.GET_ESCALATION_ROLES = exports.UPDATE_ESCALATIONS_PRIORITY = exports.RESOLVE_ESCALATION = exports.CLAIM_ESCALATION = exports.CREATE_ESCALATION = exports.ENSURE_ROLE_EXISTS = void 0;
7
- // --- Role management -------------------------------------------------------
8
- exports.ENSURE_ROLE_EXISTS = 'INSERT INTO lt_roles (role) VALUES ($1) ON CONFLICT DO NOTHING';
9
- // --- Single-record CRUD ---------------------------------------------------
10
- exports.CREATE_ESCALATION = `\
11
- INSERT INTO lt_escalations
12
- (type, subtype, description, priority, task_id,
13
- origin_id, parent_id, role, envelope, metadata, escalation_payload,
14
- workflow_id, task_queue, workflow_type, trace_id, span_id)
15
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
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 lt_escalations
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 id = ANY($2::uuid[])
109
- AND status = 'pending'`;
110
- exports.BULK_RESOLVE_FOR_TRIAGE = `\
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` is null,
184
- * but `target_id`, `signal_id`, `workflow_id`, `task_queue`, `workflow_type`
185
- * are returned so the caller can signal the workflow directly.
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 lt_escalations
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 lt_escalations e
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 lt_escalations e
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
- // Sort by execution_index first it reflects the true workflow sequence.
115
- // Timestamps are unreliable for ordering (e.g. signal_wait_started gets
116
- // the workflow start time). Fall back to timestamp only when index is absent.
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; // indexed after non-indexed (workflow_started)
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
- return ltconfig_1.ltConfig.getResolvedConfig(workflowName);
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.