@hotmeshio/long-tail 0.5.3 → 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/routes/escalations/resolve.js +15 -3
- package/build/services/escalation/crud.d.ts +18 -0
- package/build/services/escalation/crud.js +29 -0
- package/build/services/escalation/map.js +1 -0
- package/build/services/orchestrator/condition.d.ts +30 -25
- package/build/services/orchestrator/condition.js +30 -26
- 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 +1 -1
|
@@ -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;
|
|
@@ -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) => {
|
|
@@ -14,6 +14,24 @@ export declare function claimEscalation(id: string, userId: string, durationMinu
|
|
|
14
14
|
* row is missing or already terminal.
|
|
15
15
|
*/
|
|
16
16
|
export declare function resolveEscalation(id: string, resolverPayload: Record<string, any>): Promise<LTEscalationRecord | null>;
|
|
17
|
+
/**
|
|
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>;
|
|
17
35
|
/**
|
|
18
36
|
* Bulk update priority for a set of escalations. Only pending escalations are
|
|
19
37
|
* updated.
|
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.createEscalation = createEscalation;
|
|
4
4
|
exports.claimEscalation = claimEscalation;
|
|
5
5
|
exports.resolveEscalation = resolveEscalation;
|
|
6
|
+
exports.getEscalationBySignalKey = getEscalationBySignalKey;
|
|
7
|
+
exports.resolveEscalationBySignalKey = resolveEscalationBySignalKey;
|
|
6
8
|
exports.updateEscalationsPriority = updateEscalationsPriority;
|
|
7
9
|
exports.getEscalationRoles = getEscalationRoles;
|
|
8
10
|
exports.releaseEscalation = releaseEscalation;
|
|
@@ -109,6 +111,33 @@ async function resolveEscalation(id, resolverPayload) {
|
|
|
109
111
|
});
|
|
110
112
|
return escalation;
|
|
111
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Look up an efficient (atomic) escalation by its `signal_key` — the signal id
|
|
116
|
+
* passed to `conditionLT(signalId, config)` / `condition(signalId, config)`.
|
|
117
|
+
* Returns null when no row carries that key.
|
|
118
|
+
*/
|
|
119
|
+
async function getEscalationBySignalKey(signalKey) {
|
|
120
|
+
const client = await (0, client_1.escalations)();
|
|
121
|
+
const entry = await client.getBySignalKey(signalKey);
|
|
122
|
+
return entry ? (0, map_1.toEscalationRecord)(entry) : null;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Resolve an efficient (atomic) escalation by its `signal_key` and resume the
|
|
126
|
+
* waiting workflow in place. Convenience for webhook callers that know the
|
|
127
|
+
* deterministic signal id (e.g. `signal-scan-ar-${orderId}`) and want to skip
|
|
128
|
+
* the id lookup. Returns null when the key is unknown or already terminal.
|
|
129
|
+
*
|
|
130
|
+
* Race-free: `signal_key → id` is an immutable mapping, and the state mutation
|
|
131
|
+
* is delegated to `resolveEscalation`, whose `client.resolve` uses FOR UPDATE +
|
|
132
|
+
* `WHERE status = 'pending'` so exactly one concurrent caller commits. No status
|
|
133
|
+
* pre-check (that would be a TOCTOU window) — the atomic resolve is the arbiter.
|
|
134
|
+
*/
|
|
135
|
+
async function resolveEscalationBySignalKey(signalKey, resolverPayload) {
|
|
136
|
+
const escalation = await getEscalationBySignalKey(signalKey);
|
|
137
|
+
if (!escalation)
|
|
138
|
+
return null;
|
|
139
|
+
return resolveEscalation(escalation.id, resolverPayload);
|
|
140
|
+
}
|
|
112
141
|
/**
|
|
113
142
|
* Bulk update priority for a set of escalations. Only pending escalations are
|
|
114
143
|
* updated.
|
|
@@ -23,6 +23,7 @@ function toEscalationRecord(entry) {
|
|
|
23
23
|
workflow_id: entry.workflow_id,
|
|
24
24
|
task_queue: entry.task_queue,
|
|
25
25
|
workflow_type: entry.workflow_type,
|
|
26
|
+
signal_key: entry.signal_key,
|
|
26
27
|
role: entry.role ?? '',
|
|
27
28
|
assigned_to: entry.assigned_to,
|
|
28
29
|
assigned_until: entry.assigned_until,
|
|
@@ -1,34 +1,39 @@
|
|
|
1
|
+
import type { Types } from '@hotmeshio/hotmesh';
|
|
1
2
|
/**
|
|
2
3
|
* Wait for a signal and resolve the associated escalation automatically.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* activity,
|
|
5
|
+
* Two ways to call it:
|
|
6
|
+
*
|
|
7
|
+
* **Efficient (atomic) — pass an escalation config.** The escalation row is
|
|
8
|
+
* written inside this workflow's Leg1 checkpoint (one commit, crash-safe — no
|
|
9
|
+
* separate create activity, no enrich). `signal_key` is the signal id, so the
|
|
10
|
+
* dashboard resolve endpoint (Path 0), `resolveEscalationBySignalKey`, and any
|
|
11
|
+
* webhook resume the SAME job in place. `system.escalation.{id}.created` fires
|
|
12
|
+
* from the engine automatically.
|
|
9
13
|
*
|
|
10
|
-
* Usage (from within a workflow):
|
|
11
14
|
* ```typescript
|
|
12
|
-
*
|
|
15
|
+
* const decision = await conditionLT<{ approved: boolean }>(signalId, {
|
|
16
|
+
* role: 'reviewer',
|
|
17
|
+
* type: 'orderPipeline',
|
|
18
|
+
* subtype: stationName,
|
|
19
|
+
* priority: 2,
|
|
20
|
+
* description: instructions,
|
|
21
|
+
* metadata: { orderId, station: stationName },
|
|
22
|
+
* envelope: { instructions },
|
|
23
|
+
* });
|
|
24
|
+
* ```
|
|
13
25
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* // ...
|
|
22
|
-
* });
|
|
26
|
+
* **Legacy (two-step) — no config.** Create the escalation first (e.g. via
|
|
27
|
+
* `ltCreateEscalation`) with `signal_id`/`signal_routing` metadata, then wait.
|
|
28
|
+
* On resume the signal payload carries an injected `$escalation_id`; this helper
|
|
29
|
+
* strips it, resolves the escalation as a durable activity, and returns the
|
|
30
|
+
* clean resolver payload. If no `$escalation_id` is present (efficient path, or
|
|
31
|
+
* a manual signal), the payload is returned as-is — the escalation was already
|
|
32
|
+
* resolved server-side.
|
|
23
33
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* }
|
|
34
|
+
* ```typescript
|
|
35
|
+
* await activities.ltCreateEscalation({ type: 'approval', role: 'reviewer', metadata: { signal_id: signalId } });
|
|
36
|
+
* const decision = await conditionLT<{ approved: boolean }>(signalId);
|
|
28
37
|
* ```
|
|
29
|
-
*
|
|
30
|
-
* If the signal payload does not contain `$escalation_id` (e.g., signaled
|
|
31
|
-
* manually), the function returns the payload as-is without calling
|
|
32
|
-
* the resolve activity.
|
|
33
38
|
*/
|
|
34
|
-
export declare function conditionLT<T = Record<string, any>>(signalId: string): Promise<T>;
|
|
39
|
+
export declare function conditionLT<T = Record<string, any>>(signalId: string, escalation?: Types.ConditionQueueConfig): Promise<T>;
|
|
@@ -40,38 +40,42 @@ const LT_ACTIVITY_QUEUE = 'lt-interceptor';
|
|
|
40
40
|
/**
|
|
41
41
|
* Wait for a signal and resolve the associated escalation automatically.
|
|
42
42
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
* activity,
|
|
43
|
+
* Two ways to call it:
|
|
44
|
+
*
|
|
45
|
+
* **Efficient (atomic) — pass an escalation config.** The escalation row is
|
|
46
|
+
* written inside this workflow's Leg1 checkpoint (one commit, crash-safe — no
|
|
47
|
+
* separate create activity, no enrich). `signal_key` is the signal id, so the
|
|
48
|
+
* dashboard resolve endpoint (Path 0), `resolveEscalationBySignalKey`, and any
|
|
49
|
+
* webhook resume the SAME job in place. `system.escalation.{id}.created` fires
|
|
50
|
+
* from the engine automatically.
|
|
48
51
|
*
|
|
49
|
-
* Usage (from within a workflow):
|
|
50
52
|
* ```typescript
|
|
51
|
-
*
|
|
53
|
+
* const decision = await conditionLT<{ approved: boolean }>(signalId, {
|
|
54
|
+
* role: 'reviewer',
|
|
55
|
+
* type: 'orderPipeline',
|
|
56
|
+
* subtype: stationName,
|
|
57
|
+
* priority: 2,
|
|
58
|
+
* description: instructions,
|
|
59
|
+
* metadata: { orderId, station: stationName },
|
|
60
|
+
* envelope: { instructions },
|
|
61
|
+
* });
|
|
62
|
+
* ```
|
|
52
63
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
* // ...
|
|
61
|
-
* });
|
|
64
|
+
* **Legacy (two-step) — no config.** Create the escalation first (e.g. via
|
|
65
|
+
* `ltCreateEscalation`) with `signal_id`/`signal_routing` metadata, then wait.
|
|
66
|
+
* On resume the signal payload carries an injected `$escalation_id`; this helper
|
|
67
|
+
* strips it, resolves the escalation as a durable activity, and returns the
|
|
68
|
+
* clean resolver payload. If no `$escalation_id` is present (efficient path, or
|
|
69
|
+
* a manual signal), the payload is returned as-is — the escalation was already
|
|
70
|
+
* resolved server-side.
|
|
62
71
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* }
|
|
72
|
+
* ```typescript
|
|
73
|
+
* await activities.ltCreateEscalation({ type: 'approval', role: 'reviewer', metadata: { signal_id: signalId } });
|
|
74
|
+
* const decision = await conditionLT<{ approved: boolean }>(signalId);
|
|
67
75
|
* ```
|
|
68
|
-
*
|
|
69
|
-
* If the signal payload does not contain `$escalation_id` (e.g., signaled
|
|
70
|
-
* manually), the function returns the payload as-is without calling
|
|
71
|
-
* the resolve activity.
|
|
72
76
|
*/
|
|
73
|
-
async function conditionLT(signalId) {
|
|
74
|
-
const raw = await hotmesh_1.Durable.workflow.condition(signalId);
|
|
77
|
+
async function conditionLT(signalId, escalation) {
|
|
78
|
+
const raw = await hotmesh_1.Durable.workflow.condition(signalId, escalation);
|
|
75
79
|
const escalationId = raw.$escalation_id;
|
|
76
80
|
if (escalationId) {
|
|
77
81
|
// Resolve the escalation as a durable activity (crash-safe)
|
|
@@ -215,8 +215,27 @@ The workflow is responsible for resolving the escalation. The `conditionLT()` he
|
|
|
215
215
|
|
|
216
216
|
If you use raw `Durable.workflow.condition()` instead, you must resolve the escalation yourself using the `$escalation_id` from the signal data.
|
|
217
217
|
|
|
218
|
+
### Signal-key resolution (efficient/atomic — `signal_key`)
|
|
219
|
+
|
|
220
|
+
When an escalation was written atomically by `conditionLT(signalId, config)` (or `Durable.workflow.condition(signalId, config)`), the row carries a `signal_key` and no `signal_id`/`signal_routing` metadata. The resolve endpoint detects `signal_key` and resolves it through the SDK: the resolve marks the row resolved **and** delivers the signal to the waiting `condition()` in one transaction, so the original job resumes in place — no re-run, no separate resolve activity. `system.escalation.{id}.resolved` fires.
|
|
221
|
+
|
|
222
|
+
```
|
|
223
|
+
POST /api/escalations/resolve-by-signal-key
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
For callers that know the deterministic signal id (webhooks — e.g. `signal-scan-ar-${orderId}`) and want to skip the id lookup.
|
|
227
|
+
|
|
228
|
+
| Field | Type | Required | Description |
|
|
229
|
+
|-------|------|----------|-------------|
|
|
230
|
+
| `signalKey` | `string` | yes | The signal id passed to `conditionLT(signalId, config)` |
|
|
231
|
+
| `resolverPayload` | `object` | yes | The decision payload delivered to the waiting workflow |
|
|
232
|
+
|
|
233
|
+
Returns `404` when the key is unknown, `409` when the escalation is already terminal, and `200 { signaled: true }` on success. RBAC-scoped to the caller's visible roles.
|
|
234
|
+
|
|
218
235
|
### What happens during resolution
|
|
219
236
|
|
|
237
|
+
> Applies to the **re-run** path (an escalation with no `signal_id`, `signal_routing`, or `signal_key`). Signal-based and signal-key escalations resume the live workflow in place, as described above.
|
|
238
|
+
|
|
220
239
|
1. The route reads the escalation record and verifies it is still `pending`.
|
|
221
240
|
2. It reconstructs the original workflow envelope from the escalation's `envelope` field (or from the parent task if the escalation envelope is missing).
|
|
222
241
|
3. It injects `resolver` (the reviewer's payload) and `lt.escalationId` into the envelope.
|
|
@@ -279,6 +279,39 @@ const result = await lt.escalations.resolve({
|
|
|
279
279
|
|
|
280
280
|
Wait for a signal and automatically resolve the associated escalation. This is the counterpart to `executeLT` — where `executeLT` wraps `startChild` + `condition`, `conditionLT` wraps `condition` + escalation resolution.
|
|
281
281
|
|
|
282
|
+
```typescript
|
|
283
|
+
conditionLT<T>(signalId: string, escalation?: ConditionQueueConfig): Promise<T>
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### Atomic form (recommended)
|
|
287
|
+
|
|
288
|
+
Pass an escalation config as the second argument. The escalation row is written inside the workflow's Leg1 checkpoint — one commit, crash-safe: no separate `ltCreateEscalation` activity, no enrich step. `signal_key` is set to `signalId`, so the dashboard resolve endpoint (resolve-by-id → Path 0) and `POST /escalations/resolve-by-signal-key` resume *this* job in place, and `system.escalation.{id}.created` fires automatically.
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
import { conditionLT } from '@hotmeshio/long-tail';
|
|
292
|
+
|
|
293
|
+
export async function stationWorker(envelope: LTEnvelope) {
|
|
294
|
+
const ctx = Durable.workflow.workflowInfo();
|
|
295
|
+
const signalId = `station-done-${ctx.workflowId}`;
|
|
296
|
+
|
|
297
|
+
const decision = await conditionLT<{ approved: boolean }>(signalId, {
|
|
298
|
+
role: 'qc-inspector',
|
|
299
|
+
type: 'orderPipeline',
|
|
300
|
+
subtype: 'qc',
|
|
301
|
+
priority: 2,
|
|
302
|
+
description: 'Inspect the order and approve',
|
|
303
|
+
workflowType: 'stationWorker',
|
|
304
|
+
metadata: { orderId: envelope.data.orderId, station: 'qc' },
|
|
305
|
+
envelope: { instructions: 'Review and approve or reject' },
|
|
306
|
+
});
|
|
307
|
+
// decision is clean — the escalation was resolved by the resolve endpoint
|
|
308
|
+
}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Two-step form
|
|
312
|
+
|
|
313
|
+
Create the escalation first (e.g. to enrich routing metadata), then wait:
|
|
314
|
+
|
|
282
315
|
```typescript
|
|
283
316
|
import { conditionLT } from '@hotmeshio/long-tail';
|
|
284
317
|
|
package/docs/hitl-guide.md
CHANGED
|
@@ -31,7 +31,50 @@ Durable Workflow Long-tail Platform Dashboard
|
|
|
31
31
|
|
|
32
32
|
### Pattern 1: `conditionLT` Signal (Recommended)
|
|
33
33
|
|
|
34
|
-
The workflow stays running and waits for a signal. Lightweight, no re-run needed.
|
|
34
|
+
The workflow stays running and waits for a signal. Lightweight, no re-run needed. Two forms — prefer the atomic one.
|
|
35
|
+
|
|
36
|
+
#### Atomic form (recommended)
|
|
37
|
+
|
|
38
|
+
Pass an escalation config to `conditionLT`. The escalation row is written inside the workflow's Leg1 checkpoint — one commit, crash-safe: no separate create activity, no enrich step. `signal_key` is the resume key, so the dashboard resolve endpoint and `POST /escalations/resolve-by-signal-key` both resume *this* job in place, and `system.escalation.{id}.created` fires automatically.
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { conditionLT } from '@hotmeshio/long-tail';
|
|
42
|
+
|
|
43
|
+
export async function approvalWorkflow(envelope: LTEnvelope) {
|
|
44
|
+
const ctx = Durable.workflow.workflowInfo();
|
|
45
|
+
const signalId = `approval-${ctx.workflowId}`;
|
|
46
|
+
|
|
47
|
+
// One atomic expression: write the escalation in Leg1, then pause.
|
|
48
|
+
const decision = await conditionLT<{ approved: boolean; notes?: string }>(signalId, {
|
|
49
|
+
role: 'finance-reviewer',
|
|
50
|
+
type: 'approval',
|
|
51
|
+
subtype: 'budget-request',
|
|
52
|
+
priority: 2,
|
|
53
|
+
description: `Budget approval needed: $${envelope.data.amount}`,
|
|
54
|
+
metadata: {
|
|
55
|
+
form_schema: {
|
|
56
|
+
title: 'Budget Approval',
|
|
57
|
+
properties: {
|
|
58
|
+
approved: { type: 'boolean', description: 'Approve this request?' },
|
|
59
|
+
notes: { type: 'string', format: 'textarea' },
|
|
60
|
+
},
|
|
61
|
+
required: ['approved'],
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
envelope: { data: envelope.data },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (decision.approved) {
|
|
68
|
+
// ... proceed with approved flow ...
|
|
69
|
+
} else {
|
|
70
|
+
// ... handle rejection ...
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
#### Two-step form
|
|
76
|
+
|
|
77
|
+
When you need to create the escalation separately — for example to enrich routing metadata before pausing — create it first, then wait:
|
|
35
78
|
|
|
36
79
|
```typescript
|
|
37
80
|
import { conditionLT } from 'long-tail/orchestrator';
|
package/package.json
CHANGED