@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.
- package/build/api/escalations/index.d.ts +1 -1
- package/build/api/escalations/index.js +2 -1
- package/build/api/escalations/resolve.d.ts +10 -0
- package/build/api/escalations/resolve.js +52 -0
- 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/routes/escalations/resolve.js +15 -3
- 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 +47 -21
- package/build/services/escalation/crud.js +204 -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 +64 -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/orchestrator/condition.d.ts +30 -25
- package/build/services/orchestrator/condition.js +30 -26
- 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/build/types/escalation.d.ts +1 -0
- package/docs/api/http/escalations.md +19 -0
- package/docs/api/sdk/escalations.md +33 -0
- package/docs/hitl-guide.md +44 -1
- 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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
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
|
|
8
|
-
const
|
|
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
|
|
17
|
-
|
|
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
|
|
29
|
-
const {
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
41
|
-
|
|
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
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
6
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
|
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
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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>;
|