@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
@@ -3,5 +3,5 @@ export { listEscalations, listAvailableEscalations, listDistinctTypes, getEscala
3
3
  export { getEscalation, getEscalationsByWorkflowId, escalateToRole } from './single';
4
4
  export { claimEscalation, releaseEscalation } from './claim';
5
5
  export { releaseExpiredClaims, updatePriority, bulkClaim, bulkAssign, bulkEscalate, bulkTriage } from './bulk';
6
- export { resolveEscalation } from './resolve';
6
+ export { resolveEscalation, resolveBySignalKey } from './resolve';
7
7
  export { findByMetadata, claimByMetadata, resolveByMetadata } from './metadata';
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.resolveByMetadata = exports.claimByMetadata = exports.findByMetadata = exports.resolveEscalation = exports.bulkTriage = exports.bulkEscalate = exports.bulkAssign = exports.bulkClaim = exports.updatePriority = exports.releaseExpiredClaims = exports.releaseEscalation = exports.claimEscalation = exports.escalateToRole = exports.getEscalationsByWorkflowId = exports.getEscalation = exports.getEscalationStats = exports.listDistinctTypes = exports.listAvailableEscalations = exports.listEscalations = exports.createEscalation = void 0;
3
+ exports.resolveByMetadata = exports.claimByMetadata = exports.findByMetadata = exports.resolveBySignalKey = exports.resolveEscalation = exports.bulkTriage = exports.bulkEscalate = exports.bulkAssign = exports.bulkClaim = exports.updatePriority = exports.releaseExpiredClaims = exports.releaseEscalation = exports.claimEscalation = exports.escalateToRole = exports.getEscalationsByWorkflowId = exports.getEscalation = exports.getEscalationStats = exports.listDistinctTypes = exports.listAvailableEscalations = exports.listEscalations = exports.createEscalation = void 0;
4
4
  var create_1 = require("./create");
5
5
  Object.defineProperty(exports, "createEscalation", { enumerable: true, get: function () { return create_1.createEscalation; } });
6
6
  var list_1 = require("./list");
@@ -24,6 +24,7 @@ Object.defineProperty(exports, "bulkEscalate", { enumerable: true, get: function
24
24
  Object.defineProperty(exports, "bulkTriage", { enumerable: true, get: function () { return bulk_1.bulkTriage; } });
25
25
  var resolve_1 = require("./resolve");
26
26
  Object.defineProperty(exports, "resolveEscalation", { enumerable: true, get: function () { return resolve_1.resolveEscalation; } });
27
+ Object.defineProperty(exports, "resolveBySignalKey", { enumerable: true, get: function () { return resolve_1.resolveBySignalKey; } });
27
28
  var metadata_1 = require("./metadata");
28
29
  Object.defineProperty(exports, "findByMetadata", { enumerable: true, get: function () { return metadata_1.findByMetadata; } });
29
30
  Object.defineProperty(exports, "claimByMetadata", { enumerable: true, get: function () { return metadata_1.claimByMetadata; } });
@@ -16,3 +16,13 @@ export declare function resolveEscalation(input: {
16
16
  id: string;
17
17
  resolverPayload: Record<string, any>;
18
18
  }, _auth: LTApiAuth): Promise<LTApiResult>;
19
+ /**
20
+ * Resolve an efficient (atomic) escalation directly by its `signal_key` and
21
+ * resume the waiting workflow in place. For webhook callers that know the
22
+ * deterministic signal id (e.g. `signal-scan-ar-${orderId}`) and want to skip
23
+ * the id lookup. RBAC-scoped to the caller's visible roles.
24
+ */
25
+ export declare function resolveBySignalKey(input: {
26
+ signalKey: string;
27
+ resolverPayload: Record<string, any>;
28
+ }, auth: LTApiAuth): Promise<LTApiResult>;
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveEscalation = resolveEscalation;
37
+ exports.resolveBySignalKey = resolveBySignalKey;
37
38
  const escalationService = __importStar(require("../../services/escalation"));
38
39
  const taskService = __importStar(require("../../services/task"));
39
40
  const escalation_strategy_1 = require("../../services/escalation-strategy");
@@ -41,6 +42,7 @@ const ephemeral_1 = require("../../services/iam/ephemeral");
41
42
  const deployer_1 = require("../../services/yaml-workflow/deployer");
42
43
  const workers_1 = require("../../workers");
43
44
  const defaults_1 = require("../../modules/defaults");
45
+ const helpers_1 = require("./helpers");
44
46
  // ── Orchestrator ─────────────────────────────────────────────────────────
45
47
  /**
46
48
  * Resolve a pending escalation with a human-provided payload.
@@ -76,6 +78,13 @@ async function resolveEscalation(input, _auth) {
76
78
  if (signalRouting?.signalId) {
77
79
  return resolveViaSignalRouting(escalation, resolverPayload);
78
80
  }
81
+ // Path 0: efficient (atomic) escalation — signal_key resumes in place.
82
+ // The row was written inside the workflow's Leg1 checkpoint via
83
+ // `condition(signalId, config)`. The SDK's resolve marks it resolved AND
84
+ // delivers the signal to `signal_key`, resuming THIS job — no re-run.
85
+ if (escalation.signal_key) {
86
+ return resolveViaSignalKey(escalation, resolverPayload);
87
+ }
79
88
  // Path C: escalation strategy may redirect to triage
80
89
  const envelope = await reconstructEnvelope(escalation);
81
90
  const strategy = escalation_strategy_1.escalationStrategyRegistry.current;
@@ -97,6 +106,34 @@ async function resolveEscalation(input, _auth) {
97
106
  return { status: 500, error: err.message };
98
107
  }
99
108
  }
109
+ /**
110
+ * Resolve an efficient (atomic) escalation directly by its `signal_key` and
111
+ * resume the waiting workflow in place. For webhook callers that know the
112
+ * deterministic signal id (e.g. `signal-scan-ar-${orderId}`) and want to skip
113
+ * the id lookup. RBAC-scoped to the caller's visible roles.
114
+ */
115
+ async function resolveBySignalKey(input, auth) {
116
+ try {
117
+ const { signalKey, resolverPayload } = input;
118
+ if (!signalKey)
119
+ return { status: 400, error: 'signalKey is required' };
120
+ if (!resolverPayload)
121
+ return { status: 400, error: 'resolverPayload is required' };
122
+ const escalation = await escalationService.getEscalationBySignalKey(signalKey);
123
+ if (!escalation)
124
+ return { status: 404, error: 'Escalation not found' };
125
+ if (escalation.status !== 'pending')
126
+ return { status: 409, error: 'Escalation not available for resolution' };
127
+ const visibleRoles = await (0, helpers_1.getVisibleRoles)(auth.userId);
128
+ if (visibleRoles && !visibleRoles.includes(escalation.role)) {
129
+ return { status: 404, error: 'Escalation not found' };
130
+ }
131
+ return resolveViaSignalKey(escalation, resolverPayload);
132
+ }
133
+ catch (err) {
134
+ return { status: 500, error: err.message };
135
+ }
136
+ }
100
137
  // ── Resolution paths ─────────────────────────────────────────────────────
101
138
  /** Path A: lightweight conditionLT signal — inject $escalation_id and signal the running workflow. */
102
139
  async function resolveViaConditionSignal(escalation, resolverPayload) {
@@ -107,6 +144,21 @@ async function resolveViaConditionSignal(escalation, resolverPayload) {
107
144
  // Event published by service layer (services/escalation/crud.ts)
108
145
  return signaledResult(escalation, escalation.workflow_id);
109
146
  }
147
+ /**
148
+ * Path 0: efficient escalation — resolve by `signal_key`. The SDK delivers the
149
+ * signal to the waiting `condition()` AND marks the row resolved in one
150
+ * transaction, so the original job resumes in place (no re-run, no separate
151
+ * resolve activity). Password fields are redacted before they enter the signal.
152
+ */
153
+ async function resolveViaSignalKey(escalation, resolverPayload) {
154
+ const signalPayload = await redactPasswords(resolverPayload, escalation.metadata?.form_schema);
155
+ const resolved = await escalationService.resolveEscalation(escalation.id, signalPayload);
156
+ if (!resolved) {
157
+ return { status: 409, error: 'Escalation not available for resolution' };
158
+ }
159
+ // Event published by service layer (services/escalation/crud.ts)
160
+ return signaledResult(escalation, escalation.workflow_id || '');
161
+ }
110
162
  /** Path B: waitFor signal escalation — signal via YAML engine or Durable handle. */
111
163
  async function resolveViaSignalRouting(escalation, resolverPayload) {
112
164
  const signalRouting = escalation.metadata.signal_routing;
@@ -0,0 +1,19 @@
1
+ import type { Types } from '@hotmeshio/hotmesh';
2
+ import type { LTEvent } from '../../types';
3
+ type SystemEvent = Types.SystemEvent;
4
+ /**
5
+ * Translate a HotMesh `SystemEvent` into long-tail's `LTEvent`. Escalation
6
+ * events carry the full committed row in `data`, from which we lift the routing
7
+ * fields; engine/worker lifecycle events pass through with their canonical type.
8
+ */
9
+ export declare function mapSystemEvent(event: SystemEvent): LTEvent;
10
+ /**
11
+ * The `EventsConfig.publish` hook long-tail wires into every worker/engine it
12
+ * constructs. Fire-and-forget — never throws back into the SDK's committed call.
13
+ */
14
+ export declare function onSystemEvent(event: SystemEvent): void;
15
+ /** The EventsConfig long-tail passes to Durable.Client / Worker.create / HotMesh.init. */
16
+ export declare const systemEventsConfig: {
17
+ publish: typeof onSystemEvent;
18
+ };
19
+ export {};
@@ -0,0 +1,62 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.systemEventsConfig = void 0;
4
+ exports.mapSystemEvent = mapSystemEvent;
5
+ exports.onSystemEvent = onSystemEvent;
6
+ const index_1 = require("./index");
7
+ /** Verb → long-tail event status, matching the manual publishEscalationEvent convention. */
8
+ const ESCALATION_STATUS_BY_VERB = {
9
+ created: 'pending',
10
+ claimed: 'claimed',
11
+ released: 'released',
12
+ reassigned: 'pending',
13
+ resolved: 'resolved',
14
+ cancelled: 'cancelled',
15
+ };
16
+ /**
17
+ * Translate a HotMesh `SystemEvent` into long-tail's `LTEvent`. Escalation
18
+ * events carry the full committed row in `data`, from which we lift the routing
19
+ * fields; engine/worker lifecycle events pass through with their canonical type.
20
+ */
21
+ function mapSystemEvent(event) {
22
+ const segments = event.type.split('.');
23
+ const domain = segments[1];
24
+ if (domain === 'escalation') {
25
+ const row = (event.data ?? {});
26
+ const verb = segments[3] ?? '';
27
+ return {
28
+ type: event.type,
29
+ source: 'sdk',
30
+ workflowId: row.workflow_id || event.workflow_id || '',
31
+ workflowName: row.workflow_type || '',
32
+ taskQueue: row.task_queue || '',
33
+ escalationId: row.id || segments[2],
34
+ originId: row.origin_id || event.origin_id || undefined,
35
+ status: ESCALATION_STATUS_BY_VERB[verb] ?? verb,
36
+ data: row,
37
+ timestamp: event.ts,
38
+ };
39
+ }
40
+ // Engine / worker lifecycle (system.engine.*, system.worker.*) — additive
41
+ // observability; pass through with the canonical type and metadata payload.
42
+ return {
43
+ type: event.type,
44
+ source: 'sdk',
45
+ workflowId: event.workflow_id || '',
46
+ workflowName: '',
47
+ taskQueue: event.data?.taskQueue || '',
48
+ data: event.data,
49
+ timestamp: event.ts,
50
+ };
51
+ }
52
+ /**
53
+ * The `EventsConfig.publish` hook long-tail wires into every worker/engine it
54
+ * constructs. Fire-and-forget — never throws back into the SDK's committed call.
55
+ */
56
+ function onSystemEvent(event) {
57
+ if (!index_1.eventRegistry.hasAdapters)
58
+ return;
59
+ void index_1.eventRegistry.publish(mapSystemEvent(event)).catch(() => { });
60
+ }
61
+ /** The EventsConfig long-tail passes to Durable.Client / Worker.create / HotMesh.init. */
62
+ exports.systemEventsConfig = { publish: onSystemEvent };
@@ -23,6 +23,14 @@ declare class LTConfigCache {
23
23
  * return null so the interceptor skips them.
24
24
  */
25
25
  getResolvedConfig(name: string): Promise<LTResolvedConfig | null>;
26
+ /**
27
+ * Get the config for any workflow REGISTERED in lt_config_workflows (a row
28
+ * exists), regardless of certification (roles/consumes). The interceptor
29
+ * uses this to decide whether to apply task tracking, escalation handling,
30
+ * and orchestrator context — every registered workflow gets the full
31
+ * treatment; only unregistered ad-hoc durable workflows are skipped.
32
+ */
33
+ getRegisteredConfig(name: string): Promise<LTResolvedConfig | null>;
26
34
  }
27
35
  export declare const ltConfig: LTConfigCache;
28
36
  export {};
@@ -116,5 +116,15 @@ class LTConfigCache {
116
116
  const isCertified = (config.roles?.length ?? 0) > 0 || (config.consumes?.length ?? 0) > 0;
117
117
  return isCertified ? config : null;
118
118
  }
119
+ /**
120
+ * Get the config for any workflow REGISTERED in lt_config_workflows (a row
121
+ * exists), regardless of certification (roles/consumes). The interceptor
122
+ * uses this to decide whether to apply task tracking, escalation handling,
123
+ * and orchestrator context — every registered workflow gets the full
124
+ * treatment; only unregistered ad-hoc durable workflows are skipped.
125
+ */
126
+ async getRegisteredConfig(name) {
127
+ return (await this.get(name)) ?? null;
128
+ }
119
129
  }
120
130
  exports.ltConfig = new LTConfigCache();
@@ -36,11 +36,23 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.registerResolveRoutes = registerResolveRoutes;
37
37
  const api = __importStar(require("../../api/escalations"));
38
38
  function registerResolveRoutes(router) {
39
+ /**
40
+ * POST /api/escalations/resolve-by-signal-key
41
+ * Resolve an efficient (atomic) escalation by its signal_key and resume the
42
+ * waiting workflow in place. For webhook callers that know the deterministic
43
+ * signal id. Literal single-segment path — registered before /:id/resolve so
44
+ * it is never shadowed by the parameterized route.
45
+ * Body: { signalKey: string, resolverPayload: Record<string, any> }
46
+ */
47
+ router.post('/resolve-by-signal-key', async (req, res) => {
48
+ const result = await api.resolveBySignalKey({ signalKey: req.body?.signalKey, resolverPayload: req.body?.resolverPayload }, req.auth);
49
+ res.status(result.status).json(result.data ?? { error: result.error });
50
+ });
39
51
  /**
40
52
  * POST /api/escalations/:id/resolve
41
- * Start a new workflow with resolver data to re-run the failed step.
42
- * The interceptor in the new workflow resolves the escalation record
43
- * and signals back to the orchestrator (if any) on success.
53
+ * Resolve a pending escalation with a human-provided payload. Routes by
54
+ * escalation shape: efficient (signal_key) resumes the job in place; legacy
55
+ * paths signal via routing metadata or re-run the original workflow.
44
56
  * Body: { resolverPayload: Record<string, any> }
45
57
  */
46
58
  router.post('/:id/resolve', async (req, res) => {
@@ -22,6 +22,7 @@ export declare function bulkAssignEscalations(ids: string[], targetUserId: strin
22
22
  export declare function bulkEscalateToRole(ids: string[], targetRole: string): Promise<number>;
23
23
  /**
24
24
  * Bulk resolve escalations for AI triage.
25
- * Returns full records so the caller can start triage workflows.
25
+ * Returns full records so the caller can start triage workflows. No signal is
26
+ * delivered — the triage workflow takes over handling.
26
27
  */
27
28
  export declare function bulkResolveForTriage(ids: string[], hint?: string): Promise<LTEscalationRecord[]>;
@@ -4,8 +4,8 @@ exports.bulkClaimEscalations = bulkClaimEscalations;
4
4
  exports.bulkAssignEscalations = bulkAssignEscalations;
5
5
  exports.bulkEscalateToRole = bulkEscalateToRole;
6
6
  exports.bulkResolveForTriage = bulkResolveForTriage;
7
- const db_1 = require("../../lib/db");
8
- const sql_1 = require("./sql");
7
+ const client_1 = require("./client");
8
+ const map_1 = require("./map");
9
9
  /**
10
10
  * Bulk claim escalations for a user.
11
11
  * Items already claimed by another active user are skipped.
@@ -13,10 +13,8 @@ const sql_1 = require("./sql");
13
13
  async function bulkClaimEscalations(ids, userId, durationMinutes = 30) {
14
14
  if (ids.length === 0)
15
15
  return { claimed: 0, skipped: 0 };
16
- const pool = (0, db_1.getPool)();
17
- const { rowCount } = await pool.query(sql_1.BULK_CLAIM, [userId, durationMinutes, ids]);
18
- const claimed = rowCount ?? 0;
19
- return { claimed, skipped: ids.length - claimed };
16
+ const client = await (0, client_1.escalations)();
17
+ return client.claimMany({ ids, assignee: userId, durationMinutes });
20
18
  }
21
19
  /**
22
20
  * Bulk assign escalations to a specific user (admin action).
@@ -25,10 +23,13 @@ async function bulkClaimEscalations(ids, userId, durationMinutes = 30) {
25
23
  async function bulkAssignEscalations(ids, targetUserId, durationMinutes = 30) {
26
24
  if (ids.length === 0)
27
25
  return { assigned: 0, skipped: 0 };
28
- const pool = (0, db_1.getPool)();
29
- const { rowCount } = await pool.query(sql_1.BULK_ASSIGN, [targetUserId, durationMinutes, ids]);
30
- const assigned = rowCount ?? 0;
31
- return { assigned, skipped: ids.length - assigned };
26
+ const client = await (0, client_1.escalations)();
27
+ const { claimed, skipped } = await client.claimMany({
28
+ ids,
29
+ assignee: targetUserId,
30
+ durationMinutes,
31
+ });
32
+ return { assigned: claimed, skipped };
32
33
  }
33
34
  /**
34
35
  * Bulk reassign escalations to a different role.
@@ -37,21 +38,21 @@ async function bulkAssignEscalations(ids, targetUserId, durationMinutes = 30) {
37
38
  async function bulkEscalateToRole(ids, targetRole) {
38
39
  if (ids.length === 0)
39
40
  return 0;
40
- const pool = (0, db_1.getPool)();
41
- const { rowCount } = await pool.query(sql_1.BULK_ESCALATE_TO_ROLE, [targetRole, ids]);
42
- return rowCount ?? 0;
41
+ const client = await (0, client_1.escalations)();
42
+ return client.escalateManyToRole({ ids, targetRole });
43
43
  }
44
44
  /**
45
45
  * Bulk resolve escalations for AI triage.
46
- * Returns full records so the caller can start triage workflows.
46
+ * Returns full records so the caller can start triage workflows. No signal is
47
+ * delivered — the triage workflow takes over handling.
47
48
  */
48
49
  async function bulkResolveForTriage(ids, hint) {
49
50
  if (ids.length === 0)
50
51
  return [];
51
- const pool = (0, db_1.getPool)();
52
- const resolverPayload = JSON.stringify({
53
- _lt: { needsTriage: true, ...(hint ? { hint } : {}) },
52
+ const client = await (0, client_1.escalations)();
53
+ const resolved = await client.resolveMany({
54
+ ids,
55
+ resolverPayload: { _lt: { needsTriage: true, ...(hint ? { hint } : {}) } },
54
56
  });
55
- const { rows } = await pool.query(sql_1.BULK_RESOLVE_FOR_TRIAGE, [resolverPayload, ids]);
56
- return rows;
57
+ return (0, map_1.toEscalationRecords)(resolved);
57
58
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The escalation client, with the `lt_escalations` compatibility view ensured
3
+ * exactly once per process. Every service function awaits this so the view is
4
+ * present before the first read/write in any context (app, route test, or the
5
+ * service-only test that runs `migrate()` without starting workers).
6
+ */
7
+ export declare function escalations(): Promise<import("@hotmeshio/hotmesh/build/services/escalations").EscalationClientService>;
8
+ /**
9
+ * Replace the legacy `lt_escalations` table with a view over
10
+ * `public.hmsh_escalations`. Idempotent and memoized per process.
11
+ *
12
+ * - Migrates any legacy rows into `hmsh_escalations` (no-op on a fresh DB), then
13
+ * RENAMES the legacy table to `lt_escalations_legacy` (a recoverable backup —
14
+ * never dropped here) so the view can take the `lt_escalations` name.
15
+ * - Read-path consumers (role, agent, mcp, overview) and frozen test cleanup
16
+ * (`DELETE FROM lt_escalations`) continue to work unchanged against the view.
17
+ * - The one-time conversion is serialized across concurrent containers with a
18
+ * dedicated Postgres advisory lock, so a multi-container deploy is safe.
19
+ *
20
+ * Safe to call eagerly at startup and lazily on first escalation use.
21
+ */
22
+ export declare function ensureEscalationCompatView(): Promise<void>;
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.escalations = escalations;
4
+ exports.ensureEscalationCompatView = ensureEscalationCompatView;
5
+ const hotmesh_1 = require("@hotmeshio/hotmesh");
6
+ const db_1 = require("../../lib/db");
7
+ const logger_1 = require("../../lib/logger");
8
+ // ---------------------------------------------------------------------------
9
+ // Escalation client — long-tail's service layer talks to the shared
10
+ // `public.hmsh_escalations` table exclusively through `client.escalations.*`
11
+ // (HotMesh 0.22.3). The escalation client is created off a `Durable.Client`,
12
+ // which injects `getHotMeshClient` so the escalation engine pool is shared
13
+ // with the rest of the app and torn down by `Durable.shutdown()`.
14
+ // ---------------------------------------------------------------------------
15
+ let durableClient = null;
16
+ /** The raw `client.escalations` surface over `public.hmsh_escalations`. */
17
+ function rawEscalations() {
18
+ if (!durableClient) {
19
+ durableClient = new hotmesh_1.Durable.Client({ connection: (0, db_1.getConnection)() });
20
+ }
21
+ return durableClient.escalations;
22
+ }
23
+ /**
24
+ * The escalation client, with the `lt_escalations` compatibility view ensured
25
+ * exactly once per process. Every service function awaits this so the view is
26
+ * present before the first read/write in any context (app, route test, or the
27
+ * service-only test that runs `migrate()` without starting workers).
28
+ */
29
+ async function escalations() {
30
+ await ensureEscalationCompatView();
31
+ return rawEscalations();
32
+ }
33
+ let viewReady = null;
34
+ /**
35
+ * Replace the legacy `lt_escalations` table with a view over
36
+ * `public.hmsh_escalations`. Idempotent and memoized per process.
37
+ *
38
+ * - Migrates any legacy rows into `hmsh_escalations` (no-op on a fresh DB), then
39
+ * RENAMES the legacy table to `lt_escalations_legacy` (a recoverable backup —
40
+ * never dropped here) so the view can take the `lt_escalations` name.
41
+ * - Read-path consumers (role, agent, mcp, overview) and frozen test cleanup
42
+ * (`DELETE FROM lt_escalations`) continue to work unchanged against the view.
43
+ * - The one-time conversion is serialized across concurrent containers with a
44
+ * dedicated Postgres advisory lock, so a multi-container deploy is safe.
45
+ *
46
+ * Safe to call eagerly at startup and lazily on first escalation use.
47
+ */
48
+ function ensureEscalationCompatView() {
49
+ if (!viewReady)
50
+ viewReady = installEscalationCompatView();
51
+ return viewReady;
52
+ }
53
+ // Dedicated advisory-lock id for the compat-view conversion. Distinct from
54
+ // migrate()'s lock (8675309) because this step runs after HotMesh engine init,
55
+ // outside the migrate() sequence.
56
+ const COMPAT_VIEW_LOCK_ID = 8675310;
57
+ async function installEscalationCompatView() {
58
+ // Force HotMesh engine init so `public.hmsh_escalations` exists before the
59
+ // view binds to it (kvtables are deployed on first engine use).
60
+ await rawEscalations().get('00000000-0000-0000-0000-000000000000');
61
+ // Serialize the conversion across concurrent containers on a dedicated
62
+ // connection. Only one process performs the migrate+rename; the rest acquire
63
+ // the lock afterward, see the view already in place, and no-op (the DO block
64
+ // is guarded and CREATE OR REPLACE VIEW is idempotent).
65
+ const pool = (0, db_1.getPool)();
66
+ const client = await pool.connect();
67
+ try {
68
+ await client.query('SELECT pg_advisory_lock($1)', [COMPAT_VIEW_LOCK_ID]);
69
+ await client.query(MIGRATE_AND_RENAME_LEGACY_TABLE);
70
+ await client.query(CREATE_COMPAT_VIEW);
71
+ logger_1.loggerRegistry.info('[escalation] lt_escalations compatibility view ensured');
72
+ }
73
+ finally {
74
+ await client.query('SELECT pg_advisory_unlock($1)', [COMPAT_VIEW_LOCK_ID]).catch(() => { });
75
+ client.release();
76
+ }
77
+ }
78
+ // Migrate legacy `lt_escalations` rows into `hmsh_escalations` (idempotent),
79
+ // then preserve the original table as `lt_escalations_legacy` rather than
80
+ // dropping it — the rows survive untouched for verification and rollback; a
81
+ // later explicit migration can drop the backup once the cut is confirmed. Runs
82
+ // only while `lt_escalations` is still a real table; once it is a view this
83
+ // block is skipped. Payload/envelope TEXT columns are cast to JSONB defensively
84
+ // so a malformed value can never abort the upgrade.
85
+ const MIGRATE_AND_RENAME_LEGACY_TABLE = `
86
+ DO $$
87
+ BEGIN
88
+ IF EXISTS (
89
+ SELECT 1 FROM pg_class c
90
+ JOIN pg_namespace n ON n.oid = c.relnamespace
91
+ WHERE c.relname = 'lt_escalations' AND c.relkind = 'r' AND n.nspname = 'public'
92
+ ) THEN
93
+ CREATE OR REPLACE FUNCTION pg_temp.lt_try_jsonb(t text) RETURNS jsonb AS $fn$
94
+ BEGIN
95
+ IF t IS NULL OR t = '' THEN RETURN NULL; END IF;
96
+ RETURN t::jsonb;
97
+ EXCEPTION WHEN others THEN
98
+ RETURN to_jsonb(t);
99
+ END;
100
+ $fn$ LANGUAGE plpgsql IMMUTABLE;
101
+
102
+ INSERT INTO public.hmsh_escalations
103
+ (id, namespace, app_id, type, subtype, description, status, priority,
104
+ task_id, origin_id, parent_id, workflow_id, task_queue, workflow_type,
105
+ role, assigned_to, assigned_until, claim_expires_at, resolved_at, claimed_at,
106
+ created_by, envelope, metadata, escalation_payload, resolver_payload,
107
+ trace_id, span_id, created_at, updated_at)
108
+ SELECT
109
+ id, 'hmsh', 'hmsh', type, subtype, description, status, priority,
110
+ task_id::text, origin_id, parent_id, workflow_id, task_queue, workflow_type,
111
+ role, assigned_to, assigned_until, assigned_until, resolved_at, claimed_at,
112
+ created_by::text,
113
+ pg_temp.lt_try_jsonb(envelope),
114
+ metadata,
115
+ pg_temp.lt_try_jsonb(escalation_payload),
116
+ pg_temp.lt_try_jsonb(resolver_payload),
117
+ trace_id, span_id, created_at, updated_at
118
+ FROM public.lt_escalations
119
+ ON CONFLICT (id) DO NOTHING;
120
+
121
+ -- Preserve the originals as a recoverable backup (rows already migrated).
122
+ -- If a backup already exists from a prior conversion, the current table is
123
+ -- redundant and is dropped instead of clobbering the backup.
124
+ IF EXISTS (
125
+ SELECT 1 FROM pg_class c
126
+ JOIN pg_namespace n ON n.oid = c.relnamespace
127
+ WHERE c.relname = 'lt_escalations_legacy' AND n.nspname = 'public'
128
+ ) THEN
129
+ DROP TABLE public.lt_escalations CASCADE;
130
+ ELSE
131
+ ALTER TABLE public.lt_escalations RENAME TO lt_escalations_legacy;
132
+ END IF;
133
+ END IF;
134
+ END $$;`;
135
+ // `available` mirrors the legacy isEffectivelyClaimed/isAvailable heuristic so
136
+ // existing `SELECT *` consumers are unaffected; the column is additive.
137
+ const CREATE_COMPAT_VIEW = `
138
+ CREATE OR REPLACE VIEW public.lt_escalations AS
139
+ SELECT *,
140
+ (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW()) AS available
141
+ FROM public.hmsh_escalations;`;
@@ -2,20 +2,39 @@ import type { LTEscalationRecord } from '../../types';
2
2
  import type { CreateEscalationInput, ClaimResult } from './types';
3
3
  export declare function createEscalation(input: CreateEscalationInput): Promise<LTEscalationRecord>;
4
4
  /**
5
- * Atomic claim operation. Does NOT change status "claimed" is implicit
6
- * via assigned_to + assigned_until > NOW().
7
- *
8
- * Conditions:
9
- * - status = 'pending' (not resolved/cancelled)
10
- * - Either: unassigned, expired claim, or same user (extension)
11
- *
12
- * Uses a CTE to capture the previous state so callers can detect extensions.
5
+ * Atomic claim. Implicit model status stays 'pending'; "claimed" is
6
+ * assigned_to + assigned_until > NOW(). `isExtension` is true when the same
7
+ * user re-claims (extends expiry). Returns null when the row is not claimable.
13
8
  */
14
9
  export declare function claimEscalation(id: string, userId: string, durationMinutes?: number): Promise<ClaimResult | null>;
10
+ /**
11
+ * Mark an escalation resolved. Signal delivery is owned by the resolution
12
+ * orchestrator (api/escalations/resolve.ts); service-created rows have no
13
+ * signal_key, so this never delivers a signal itself. Returns null when the
14
+ * row is missing or already terminal.
15
+ */
15
16
  export declare function resolveEscalation(id: string, resolverPayload: Record<string, any>): Promise<LTEscalationRecord | null>;
16
17
  /**
17
- * Bulk update priority for a set of escalations.
18
- * Only updates pending escalations.
18
+ * Look up an efficient (atomic) escalation by its `signal_key` — the signal id
19
+ * passed to `conditionLT(signalId, config)` / `condition(signalId, config)`.
20
+ * Returns null when no row carries that key.
21
+ */
22
+ export declare function getEscalationBySignalKey(signalKey: string): Promise<LTEscalationRecord | null>;
23
+ /**
24
+ * Resolve an efficient (atomic) escalation by its `signal_key` and resume the
25
+ * waiting workflow in place. Convenience for webhook callers that know the
26
+ * deterministic signal id (e.g. `signal-scan-ar-${orderId}`) and want to skip
27
+ * the id lookup. Returns null when the key is unknown or already terminal.
28
+ *
29
+ * Race-free: `signal_key → id` is an immutable mapping, and the state mutation
30
+ * is delegated to `resolveEscalation`, whose `client.resolve` uses FOR UPDATE +
31
+ * `WHERE status = 'pending'` so exactly one concurrent caller commits. No status
32
+ * pre-check (that would be a TOCTOU window) — the atomic resolve is the arbiter.
33
+ */
34
+ export declare function resolveEscalationBySignalKey(signalKey: string, resolverPayload: Record<string, any>): Promise<LTEscalationRecord | null>;
35
+ /**
36
+ * Bulk update priority for a set of escalations. Only pending escalations are
37
+ * updated.
19
38
  */
20
39
  export declare function updateEscalationsPriority(ids: string[], priority: 1 | 2 | 3 | 4): Promise<number>;
21
40
  /**
@@ -28,6 +47,12 @@ export declare function getEscalationRoles(ids: string[]): Promise<string[]>;
28
47
  * Only the assigned user (or superadmin via route) may release.
29
48
  */
30
49
  export declare function releaseEscalation(id: string, userId: string): Promise<LTEscalationRecord | null>;
50
+ /**
51
+ * Sweep expired claims back to the available pool, returning the count cleared.
52
+ * Availability is already query-time in the implicit model, but long-tail's
53
+ * public contract clears `assigned_to` and returns a count, so this runs as a
54
+ * single direct UPDATE on the shared table (the SDK's releaseExpired is a no-op).
55
+ */
31
56
  export declare function releaseExpiredClaims(): Promise<number>;
32
57
  /**
33
58
  * Reassign an escalation to a different role.
@@ -50,13 +75,12 @@ export declare function findByMetadata(key: string, value: string, status?: stri
50
75
  total: number;
51
76
  }>;
52
77
  /**
53
- * Atomic claim by metadata with inline RBAC.
54
- * The SQL WHERE clause enforces role membershipif the caller
55
- * doesn't have an allowed role, zero rows match and the claim
56
- * never happens. No pre-flight find, no TOCTOU.
78
+ * Atomic claim by metadata with inline RBAC and optional metadata merge.
79
+ * The SDK enforces the role filter in SQL callers without an allowed role
80
+ * match zero rows. Returns `{ escalation, isExtension, candidatesExist }` or
81
+ * null when nothing was claimed.
57
82
  *
58
- * @param allowedRoles — roles the caller can claim (null = no filter / global access)
59
- * @returns `{ escalation, isExtension, candidatesExist }` or null
83
+ * @param allowedRoles — roles the caller can claim (null = no filter / global)
60
84
  */
61
85
  export declare function claimByMetadata(key: string, value: string, userId: string, durationMinutes?: number, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<(ClaimResult & {
62
86
  candidatesExist: number;
@@ -74,11 +98,13 @@ export interface ResolveByMetadataResult {
74
98
  taskQueue?: string;
75
99
  }
76
100
  /**
77
- * Atomic resolve by metadata with signal guard.
101
+ * Atomic resolve by metadata with signal guard, in a single CTE.
78
102
  *
79
- * Single query, two outcomes:
80
- * 1. No signal_id claim + resolve atomically. Returns { outcome: 'resolved', escalation }.
81
- * 2. signal_id present resolve skipped. Returns { outcome: 'signal_required', signalId, escalationId, ... }
82
- * so the caller can signal the workflow. conditionLT handles the rest.
103
+ * Signal-backed rows (those carrying `metadata.signal_id`) are NOT resolved
104
+ * here long-tail signals the paused workflow and the workflow interceptor
105
+ * resolves durably. If the workflow is gone the signal fails and the row stays
106
+ * pending, which is the contract the route suite pins. This guard is long-tail
107
+ * business logic over the shared table, so it runs as one atomic statement on
108
+ * `hmsh_escalations` rather than through the generic SDK resolve.
83
109
  */
84
110
  export declare function resolveByMetadataAtomic(key: string, value: string, userId: string, resolverPayload: Record<string, any>, metadata?: Record<string, any>, allowedRoles?: string[] | null): Promise<ResolveByMetadataResult>;