@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.
@@ -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
- * 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) => {
@@ -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
- * Wraps `Durable.workflow.condition()` with escalation lifecycle:
5
- * when the signal arrives (from the dashboard resolve endpoint),
6
- * the payload includes an injected `$escalation_id` field. This
7
- * helper strips it, calls `ltResolveEscalation` as a durable
8
- * activity, and returns the clean resolver payload.
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
- * import { conditionLT } from '@hotmeshio/long-tail';
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
- * export async function myWorkflow(envelope: LTEnvelope) {
15
- * // Create an escalation with signal_id in metadata
16
- * const signalId = `approval-${Durable.workflow.workflowId}`;
17
- * await activities.ltCreateEscalation({
18
- * type: 'approval',
19
- * role: 'reviewer',
20
- * metadata: { signal_id: signalId },
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
- * // Wait — the dashboard signals on resolve
25
- * const decision = await conditionLT<{ approved: boolean }>(signalId);
26
- * // decision.approved is clean no $escalation_id
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
- * Wraps `Durable.workflow.condition()` with escalation lifecycle:
44
- * when the signal arrives (from the dashboard resolve endpoint),
45
- * the payload includes an injected `$escalation_id` field. This
46
- * helper strips it, calls `ltResolveEscalation` as a durable
47
- * activity, and returns the clean resolver payload.
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
- * import { conditionLT } from '@hotmeshio/long-tail';
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
- * export async function myWorkflow(envelope: LTEnvelope) {
54
- * // Create an escalation with signal_id in metadata
55
- * const signalId = `approval-${Durable.workflow.workflowId}`;
56
- * await activities.ltCreateEscalation({
57
- * type: 'approval',
58
- * role: 'reviewer',
59
- * metadata: { signal_id: signalId },
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
- * // Wait — the dashboard signals on resolve
64
- * const decision = await conditionLT<{ approved: boolean }>(signalId);
65
- * // decision.approved is clean no $escalation_id
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)
@@ -13,6 +13,7 @@ export interface LTEscalationRecord {
13
13
  workflow_id: string | null;
14
14
  task_queue: string | null;
15
15
  workflow_type: string | null;
16
+ signal_key: string | null;
16
17
  role: string;
17
18
  assigned_to: string | null;
18
19
  assigned_until: Date | null;
@@ -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
 
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/long-tail",
3
- "version": "0.5.3",
3
+ "version": "0.5.4",
4
4
  "description": "Long Tail Workflows — Durable AI workflows with human-in-the-loop escalation. Powered by PostgreSQL.",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",