@hotmeshio/long-tail 0.4.14 → 0.4.15

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.
@@ -71,16 +71,7 @@ async function claimEscalation(input, auth) {
71
71
  if (!result) {
72
72
  return { status: 409, error: 'Escalation not available for claim' };
73
73
  }
74
- (0, publish_1.publishEscalationEvent)({
75
- type: 'escalation.claimed',
76
- source: 'api',
77
- workflowId: escalation.workflow_id || '',
78
- workflowName: escalation.workflow_type || '',
79
- taskQueue: escalation.task_queue || '',
80
- escalationId: id,
81
- status: 'claimed',
82
- data: { assigned_to: auth.userId },
83
- });
74
+ // Event published by service layer (services/escalation/crud.ts)
84
75
  return { status: 200, data: result };
85
76
  }
86
77
  catch (err) {
@@ -36,7 +36,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.createEscalation = createEscalation;
37
37
  const escalationService = __importStar(require("../../services/escalation"));
38
38
  const userService = __importStar(require("../../services/user"));
39
- const publish_1 = require("../../lib/events/publish");
40
39
  // ── Create ────────────────────────────────────────────────────────────────
41
40
  /**
42
41
  * Create a standalone escalation (not tied to a workflow).
@@ -83,16 +82,7 @@ async function createEscalation(input, auth) {
83
82
  metadata: input.metadata,
84
83
  escalation_payload: input.escalation_payload,
85
84
  });
86
- (0, publish_1.publishEscalationEvent)({
87
- type: 'escalation.created',
88
- source: 'api',
89
- workflowId: '',
90
- workflowName: '',
91
- taskQueue: '',
92
- escalationId: escalation.id,
93
- status: 'pending',
94
- data: { type: input.type, role },
95
- });
85
+ // Event published by service layer (services/escalation/crud.ts)
96
86
  return { status: 201, data: escalation };
97
87
  }
98
88
  catch (err) {
@@ -35,6 +35,7 @@ export declare function claimByMetadata(input: {
35
35
  value: string;
36
36
  durationMinutes?: number;
37
37
  assignee?: string;
38
+ metadata?: Record<string, any>;
38
39
  }, auth: LTApiAuth): Promise<LTApiResult>;
39
40
  /**
40
41
  * Resolve an escalation by metadata key-value pair.
@@ -53,4 +54,5 @@ export declare function resolveByMetadata(input: {
53
54
  value: string;
54
55
  resolverPayload: Record<string, any>;
55
56
  assignee?: string;
57
+ metadata?: Record<string, any>;
56
58
  }, auth: LTApiAuth): Promise<LTApiResult>;
@@ -38,7 +38,6 @@ exports.claimByMetadata = claimByMetadata;
38
38
  exports.resolveByMetadata = resolveByMetadata;
39
39
  const escalationService = __importStar(require("../../services/escalation"));
40
40
  const userService = __importStar(require("../../services/user"));
41
- const publish_1 = require("../../lib/events/publish");
42
41
  const helpers_1 = require("./helpers");
43
42
  /**
44
43
  * Find escalations by a metadata key-value pair.
@@ -105,20 +104,11 @@ async function claimByMetadata(input, auth) {
105
104
  return { status: 403, error: `User must have the "${candidate.role}" role to claim this escalation` };
106
105
  }
107
106
  }
108
- const result = await escalationService.claimByMetadata(input.key, input.value, claimUserId, input.durationMinutes);
107
+ const result = await escalationService.claimByMetadata(input.key, input.value, claimUserId, input.durationMinutes, input.metadata);
109
108
  if (!result) {
110
109
  return { status: 409, error: 'Escalation not available for claim' };
111
110
  }
112
- (0, publish_1.publishEscalationEvent)({
113
- type: 'escalation.claimed',
114
- source: 'api',
115
- workflowId: result.escalation.workflow_id || '',
116
- workflowName: result.escalation.workflow_type || '',
117
- taskQueue: result.escalation.task_queue || '',
118
- escalationId: result.escalation.id,
119
- status: 'claimed',
120
- data: { assigned_to: claimUserId },
121
- });
111
+ // Event published by service layer (services/escalation/crud.ts)
122
112
  return { status: 200, data: result };
123
113
  }
124
114
  catch (err) {
@@ -161,6 +151,10 @@ async function resolveByMetadata(input, auth) {
161
151
  return { status: 403, error: `User must have the "${escalation.role}" role` };
162
152
  }
163
153
  }
154
+ // Merge additional metadata if provided
155
+ if (input.metadata && Object.keys(input.metadata).length > 0) {
156
+ await escalationService.updateEscalationMetadata(escalation.id, input.metadata);
157
+ }
164
158
  // Auto-claim if unclaimed or expired
165
159
  const isClaimed = escalation.assigned_to &&
166
160
  escalation.assigned_until &&
@@ -36,7 +36,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.resolveEscalation = resolveEscalation;
37
37
  const escalationService = __importStar(require("../../services/escalation"));
38
38
  const taskService = __importStar(require("../../services/task"));
39
- const publish_1 = require("../../lib/events/publish");
40
39
  const escalation_strategy_1 = require("../../services/escalation-strategy");
41
40
  const ephemeral_1 = require("../../services/iam/ephemeral");
42
41
  const deployer_1 = require("../../services/yaml-workflow/deployer");
@@ -105,7 +104,7 @@ async function resolveViaConditionSignal(escalation, resolverPayload) {
105
104
  const client = (0, workers_1.createClient)();
106
105
  const handle = await client.workflow.getHandle(escalation.task_queue, escalation.workflow_type, escalation.workflow_id);
107
106
  await handle.signal(signalId, { ...resolverPayload, $escalation_id: escalation.id });
108
- publishResolvedEvent(escalation);
107
+ // Event published by service layer (services/escalation/crud.ts)
109
108
  return signaledResult(escalation, escalation.workflow_id);
110
109
  }
111
110
  /** Path B: waitFor signal escalation — signal via YAML engine or Durable handle. */
@@ -129,12 +128,7 @@ async function resolveViaSignalRouting(escalation, resolverPayload) {
129
128
  if (signalRouting.engine !== 'yaml') {
130
129
  await escalationService.resolveEscalation(escalation.id, resolverPayload);
131
130
  }
132
- publishResolvedEvent(escalation, {
133
- workflowId: escalation.workflow_id || signalRouting.workflowId,
134
- workflowName: escalation.workflow_type || signalRouting.workflowType,
135
- taskQueue: escalation.task_queue || signalRouting.taskQueue || signalRouting.appId,
136
- status: signalRouting.engine === 'yaml' ? 'signaled' : 'resolved',
137
- });
131
+ // Event published by service layer (services/escalation/crud.ts)
138
132
  return signaledResult(escalation, signalRouting.workflowId || signalRouting.appId);
139
133
  }
140
134
  /** Path C: escalation strategy directed triage — start a triage workflow. */
@@ -165,7 +159,7 @@ async function resolveViaTriage(escalation, resolverPayload, triageEnvelope) {
165
159
  ...resolverPayload,
166
160
  _lt: { ...resolverPayload._lt, triaged: true, triageWorkflowId },
167
161
  });
168
- publishResolvedEvent(escalation);
162
+ // Event published by service layer (services/escalation/crud.ts)
169
163
  return {
170
164
  status: 200,
171
165
  data: { started: true, escalationId: escalation.id, workflowId: triageWorkflowId, triage: true },
@@ -184,26 +178,13 @@ async function resolveViaRerun(escalation, envelope, resolverPayload) {
184
178
  workflowId: newWorkflowId,
185
179
  expire: 180,
186
180
  });
187
- publishResolvedEvent(escalation);
181
+ // Event published by service layer (services/escalation/crud.ts)
188
182
  return {
189
183
  status: 200,
190
184
  data: { started: true, escalationId: escalation.id, workflowId: newWorkflowId },
191
185
  };
192
186
  }
193
187
  // ── Shared helpers ───────────────────────────────────────────────────────
194
- function publishResolvedEvent(escalation, overrides) {
195
- (0, publish_1.publishEscalationEvent)({
196
- type: 'escalation.resolved',
197
- source: 'api',
198
- workflowId: overrides?.workflowId || escalation.workflow_id || '',
199
- workflowName: overrides?.workflowName || escalation.workflow_type || '',
200
- taskQueue: overrides?.taskQueue || escalation.task_queue || '',
201
- taskId: escalation.task_id,
202
- escalationId: escalation.id,
203
- originId: escalation.origin_id ?? undefined,
204
- status: overrides?.status || 'resolved',
205
- });
206
- }
207
188
  function signaledResult(escalation, workflowId) {
208
189
  return {
209
190
  status: 200,
package/build/bin/ltc.js CHANGED
@@ -145,11 +145,13 @@ escCmd.command('claim-by-meta <key> <value>')
145
145
  .description('Claim an escalation by metadata key-value pair')
146
146
  .option('--duration <minutes>', 'Claim duration in minutes')
147
147
  .option('--assignee <external_id>', 'Claim on behalf of user (external_id)')
148
+ .option('--meta <json>', 'Merge metadata (JSON object, e.g. \'{"claimedBy":"jimbo"}\')')
148
149
  .action(wrap(esc.claimByMetadata));
149
150
  escCmd.command('resolve-by-meta <key> <value>')
150
151
  .description('Resolve an escalation by metadata key-value pair')
151
152
  .option('--data <json>', 'Resolver payload (JSON string)')
152
153
  .option('--assignee <external_id>', 'Resolve on behalf of user (external_id)')
154
+ .option('--meta <json>', 'Merge metadata (JSON object)')
153
155
  .action(wrap(esc.resolveByMetadata));
154
156
  // ── Workflows ────────────────────────────────────────────────────────────
155
157
  const wfCmd = commander_1.program.command('workflows').alias('wf').description('Manage durable workflows');
@@ -20,9 +20,11 @@ export declare function findByMetadata(key: string, value: string, opts: ListOpt
20
20
  export declare function claimByMetadata(key: string, value: string, opts: {
21
21
  duration?: string;
22
22
  assignee?: string;
23
+ meta?: string;
23
24
  }): Promise<void>;
24
25
  export declare function resolveByMetadata(key: string, value: string, opts: {
25
26
  data?: string;
26
27
  assignee?: string;
28
+ meta?: string;
27
29
  }): Promise<void>;
28
30
  export {};
@@ -86,6 +86,8 @@ async function claimByMetadata(key, value, opts) {
86
86
  body.durationMinutes = parseInt(opts.duration, 10);
87
87
  if (opts.assignee)
88
88
  body.assignee = opts.assignee;
89
+ if (opts.meta)
90
+ body.metadata = JSON.parse(opts.meta);
89
91
  const data = await (0, client_1.apiFetch)('/escalations/claim-by-metadata', {
90
92
  method: 'POST',
91
93
  body: JSON.stringify(body),
@@ -97,6 +99,8 @@ async function resolveByMetadata(key, value, opts) {
97
99
  const body = { key, value, resolverPayload };
98
100
  if (opts.assignee)
99
101
  body.assignee = opts.assignee;
102
+ if (opts.meta)
103
+ body.metadata = JSON.parse(opts.meta);
100
104
  await (0, client_1.apiFetch)('/escalations/resolve-by-metadata', {
101
105
  method: 'POST',
102
106
  body: JSON.stringify(body),
@@ -9,12 +9,14 @@ exports.publishFileEvent = publishFileEvent;
9
9
  exports.publishAgentEvent = publishAgentEvent;
10
10
  exports.publishWorkflowEvent = publishWorkflowEvent;
11
11
  const index_1 = require("./index");
12
+ const logger_1 = require("../logger");
12
13
  /**
13
14
  * Fire-and-forget publish helper. Swallows errors (best-effort).
14
15
  */
15
16
  function fireAndForget(event) {
16
17
  if (!index_1.eventRegistry.hasAdapters)
17
18
  return Promise.resolve();
19
+ logger_1.loggerRegistry.info(`[lt-pub] ${event.type} ${event.workflowId || ''} ${event.escalationId || event.taskId || ''}`);
18
20
  return index_1.eventRegistry.publish(event).catch(() => { });
19
21
  }
20
22
  /**
@@ -58,7 +58,7 @@ function registerMetadataRoutes(router) {
58
58
  /**
59
59
  * POST /api/escalations/claim-by-metadata
60
60
  * Find and claim an escalation by metadata key-value pair.
61
- * Body: { key, value, durationMinutes?, assignee? }
61
+ * Body: { key, value, durationMinutes?, assignee?, metadata? }
62
62
  */
63
63
  router.post('/claim-by-metadata', async (req, res) => {
64
64
  const result = await api.claimByMetadata({
@@ -66,13 +66,14 @@ function registerMetadataRoutes(router) {
66
66
  value: req.body?.value,
67
67
  durationMinutes: req.body?.durationMinutes,
68
68
  assignee: req.body?.assignee,
69
+ metadata: req.body?.metadata,
69
70
  }, req.auth);
70
71
  res.status(result.status).json(result.data ?? { error: result.error });
71
72
  });
72
73
  /**
73
74
  * POST /api/escalations/resolve-by-metadata
74
75
  * Find and resolve an escalation by metadata key-value pair.
75
- * Body: { key, value, resolverPayload, assignee? }
76
+ * Body: { key, value, resolverPayload, assignee?, metadata? }
76
77
  */
77
78
  router.post('/resolve-by-metadata', async (req, res) => {
78
79
  const result = await api.resolveByMetadata({
@@ -80,6 +81,7 @@ function registerMetadataRoutes(router) {
80
81
  value: req.body?.value,
81
82
  resolverPayload: req.body?.resolverPayload,
82
83
  assignee: req.body?.assignee,
84
+ metadata: req.body?.metadata,
83
85
  }, req.auth);
84
86
  res.status(result.status).json(result.data ?? { error: result.error });
85
87
  });
@@ -162,12 +162,14 @@ export declare function createClient(options?: LTClientOptions): {
162
162
  value: string;
163
163
  durationMinutes?: number;
164
164
  assignee?: string;
165
+ metadata?: Record<string, any>;
165
166
  }, auth?: LTApiAuth) => Promise<LTApiResult<any>>;
166
167
  resolveByMetadata: (input: {
167
168
  key: string;
168
169
  value: string;
169
170
  resolverPayload: Record<string, any>;
170
171
  assignee?: string;
172
+ metadata?: Record<string, any>;
171
173
  }, auth?: LTApiAuth) => Promise<LTApiResult<any>>;
172
174
  };
173
175
  workflows: {
@@ -49,4 +49,4 @@ export declare function findByMetadata(key: string, value: string, status?: stri
49
49
  escalations: LTEscalationRecord[];
50
50
  total: number;
51
51
  }>;
52
- export declare function claimByMetadata(key: string, value: string, userId: string, durationMinutes?: number): Promise<ClaimResult | null>;
52
+ export declare function claimByMetadata(key: string, value: string, userId: string, durationMinutes?: number, metadata?: Record<string, any>): Promise<ClaimResult | null>;
@@ -17,8 +17,11 @@ exports.getEscalationsByOriginId = getEscalationsByOriginId;
17
17
  exports.findByMetadata = findByMetadata;
18
18
  exports.claimByMetadata = claimByMetadata;
19
19
  const db_1 = require("../../lib/db");
20
+ const publish_1 = require("../../lib/events/publish");
21
+ const logger_1 = require("../../lib/logger");
20
22
  const sql_1 = require("./sql");
21
23
  async function createEscalation(input) {
24
+ logger_1.loggerRegistry.info(`[escalation-crud] createEscalation called for wf=${input.workflow_id} type=${input.type} caller=${new Error().stack?.split('\n')[2]?.trim()}`);
22
25
  const pool = (0, db_1.getPool)();
23
26
  // Ensure the role exists in lt_roles (FK constraint)
24
27
  await pool.query(sql_1.ENSURE_ROLE_EXISTS, [input.role]);
@@ -40,7 +43,18 @@ async function createEscalation(input) {
40
43
  input.trace_id || null,
41
44
  input.span_id || null,
42
45
  ]);
43
- return rows[0];
46
+ const escalation = rows[0];
47
+ (0, publish_1.publishEscalationEvent)({
48
+ type: 'escalation.created',
49
+ source: 'service',
50
+ workflowId: escalation.workflow_id || '',
51
+ workflowName: escalation.workflow_type || '',
52
+ taskQueue: escalation.task_queue || '',
53
+ escalationId: escalation.id,
54
+ status: 'pending',
55
+ data: { type: input.type, role: input.role },
56
+ });
57
+ return escalation;
44
58
  }
45
59
  /**
46
60
  * Atomic claim operation. Does NOT change status — "claimed" is implicit
@@ -58,15 +72,39 @@ async function claimEscalation(id, userId, durationMinutes = 30) {
58
72
  if (rows.length === 0)
59
73
  return null;
60
74
  const row = rows[0];
75
+ const escalation = row;
76
+ (0, publish_1.publishEscalationEvent)({
77
+ type: 'escalation.claimed',
78
+ source: 'service',
79
+ workflowId: escalation.workflow_id || '',
80
+ workflowName: escalation.workflow_type || '',
81
+ taskQueue: escalation.task_queue || '',
82
+ escalationId: escalation.id,
83
+ status: 'claimed',
84
+ data: { assigned_to: userId },
85
+ });
61
86
  return {
62
- escalation: row,
87
+ escalation,
63
88
  isExtension: row.prev_assigned_to === userId,
64
89
  };
65
90
  }
66
91
  async function resolveEscalation(id, resolverPayload) {
67
92
  const pool = (0, db_1.getPool)();
68
93
  const { rows } = await pool.query(sql_1.RESOLVE_ESCALATION, [id, JSON.stringify(resolverPayload)]);
69
- return rows[0] || null;
94
+ const escalation = rows[0] || null;
95
+ if (escalation) {
96
+ (0, publish_1.publishEscalationEvent)({
97
+ type: 'escalation.resolved',
98
+ source: 'service',
99
+ workflowId: escalation.workflow_id || '',
100
+ workflowName: escalation.workflow_type || '',
101
+ taskQueue: escalation.task_queue || '',
102
+ escalationId: escalation.id,
103
+ status: 'resolved',
104
+ data: {},
105
+ });
106
+ }
107
+ return escalation;
70
108
  }
71
109
  /**
72
110
  * Bulk update priority for a set of escalations.
@@ -163,15 +201,27 @@ async function findByMetadata(key, value, status, limit = 50, offset = 0) {
163
201
  total: parseInt(countResult.rows[0].count, 10),
164
202
  };
165
203
  }
166
- async function claimByMetadata(key, value, userId, durationMinutes = 30) {
204
+ async function claimByMetadata(key, value, userId, durationMinutes = 30, metadata) {
167
205
  const pool = (0, db_1.getPool)();
168
206
  const filter = JSON.stringify({ [key]: value });
169
- const { rows } = await pool.query(sql_1.CLAIM_BY_METADATA, [filter, userId, durationMinutes]);
207
+ const metaPatch = metadata ? JSON.stringify(metadata) : null;
208
+ const { rows } = await pool.query(sql_1.CLAIM_BY_METADATA, [filter, userId, durationMinutes, metaPatch]);
170
209
  if (rows.length === 0)
171
210
  return null;
172
211
  const row = rows[0];
212
+ const escalation = row;
213
+ (0, publish_1.publishEscalationEvent)({
214
+ type: 'escalation.claimed',
215
+ source: 'service',
216
+ workflowId: escalation.workflow_id || '',
217
+ workflowName: escalation.workflow_type || '',
218
+ taskQueue: escalation.task_queue || '',
219
+ escalationId: escalation.id,
220
+ status: 'claimed',
221
+ data: { assigned_to: userId },
222
+ });
173
223
  return {
174
- escalation: row,
224
+ escalation,
175
225
  isExtension: row.prev_assigned_to === userId,
176
226
  };
177
227
  }
@@ -22,5 +22,5 @@ export declare const LIST_DISTINCT_TYPES = "SELECT DISTINCT type FROM lt_escalat
22
22
  /** Find escalations by a single metadata key-value pair. */
23
23
  export declare const FIND_BY_METADATA = "SELECT * FROM lt_escalations\nWHERE metadata @> $1::jsonb\n AND ($2::text IS NULL OR status = $2)\nORDER BY priority ASC, created_at ASC\nLIMIT $3 OFFSET $4";
24
24
  export declare const COUNT_BY_METADATA = "SELECT COUNT(*) FROM lt_escalations\nWHERE metadata @> $1::jsonb\n AND ($2::text IS NULL OR status = $2)";
25
- /** Atomic claim by metadata: find one available escalation and claim it. */
26
- export declare const CLAIM_BY_METADATA = "WITH target AS (\n SELECT id, assigned_to\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND (\n assigned_to IS NULL\n OR assigned_until <= NOW()\n OR assigned_to = $2\n )\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n),\nupdated AS (\n UPDATE lt_escalations e\n SET assigned_to = $2,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '1 minute' * $3\n FROM target t\n WHERE e.id = t.id\n RETURNING e.*, t.assigned_to AS prev_assigned_to\n)\nSELECT * FROM updated";
25
+ /** Atomic claim by metadata: find one available escalation, claim it, and optionally merge metadata. */
26
+ export declare const CLAIM_BY_METADATA = "WITH target AS (\n SELECT id, assigned_to\n FROM lt_escalations\n WHERE metadata @> $1::jsonb\n AND status = 'pending'\n AND (\n assigned_to IS NULL\n OR assigned_until <= NOW()\n OR assigned_to = $2\n )\n ORDER BY priority ASC, created_at ASC\n LIMIT 1\n FOR UPDATE SKIP LOCKED\n),\nupdated AS (\n UPDATE lt_escalations e\n SET assigned_to = $2,\n claimed_at = NOW(),\n assigned_until = NOW() + INTERVAL '1 minute' * $3,\n metadata = CASE WHEN $4::jsonb IS NOT NULL\n THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb\n ELSE e.metadata END\n FROM target t\n WHERE e.id = t.id\n RETURNING e.*, t.assigned_to AS prev_assigned_to\n)\nSELECT * FROM updated";
@@ -145,7 +145,7 @@ exports.COUNT_BY_METADATA = `\
145
145
  SELECT COUNT(*) FROM lt_escalations
146
146
  WHERE metadata @> $1::jsonb
147
147
  AND ($2::text IS NULL OR status = $2)`;
148
- /** Atomic claim by metadata: find one available escalation and claim it. */
148
+ /** Atomic claim by metadata: find one available escalation, claim it, and optionally merge metadata. */
149
149
  exports.CLAIM_BY_METADATA = `\
150
150
  WITH target AS (
151
151
  SELECT id, assigned_to
@@ -165,7 +165,10 @@ updated AS (
165
165
  UPDATE lt_escalations e
166
166
  SET assigned_to = $2,
167
167
  claimed_at = NOW(),
168
- assigned_until = NOW() + INTERVAL '1 minute' * $3
168
+ assigned_until = NOW() + INTERVAL '1 minute' * $3,
169
+ metadata = CASE WHEN $4::jsonb IS NOT NULL
170
+ THEN COALESCE(e.metadata, '{}'::jsonb) || $4::jsonb
171
+ ELSE e.metadata END
169
172
  FROM target t
170
173
  WHERE e.id = t.id
171
174
  RETURNING e.*, t.assigned_to AS prev_assigned_to
@@ -39,7 +39,6 @@ exports.ltEnrichEscalationRouting = ltEnrichEscalationRouting;
39
39
  exports.ltCreateEscalation = ltCreateEscalation;
40
40
  const escalationService = __importStar(require("../../escalation"));
41
41
  const logger_1 = require("../../../lib/logger");
42
- const publish_1 = require("../../../lib/events/publish");
43
42
  /**
44
43
  * Resolve an escalation record. Called by the interceptor after
45
44
  * detecting a re-run (resolver data present in the envelope).
@@ -107,15 +106,6 @@ async function ltCreateEscalation(input) {
107
106
  trace_id: input.traceId,
108
107
  span_id: input.spanId,
109
108
  });
110
- (0, publish_1.publishEscalationEvent)({
111
- type: 'escalation.created',
112
- source: 'interceptor',
113
- workflowId: input.workflowId || '',
114
- workflowName: input.workflowType || '',
115
- taskQueue: input.taskQueue || '',
116
- escalationId: escalation.id,
117
- status: 'pending',
118
- data: { type: input.type, role: input.role },
119
- });
109
+ // Event published by service layer (services/escalation/crud.ts)
120
110
  return escalation.id;
121
111
  }
@@ -81,16 +81,7 @@ async function ltCreateTask(input) {
81
81
  executing_as: input.executingAs,
82
82
  status: input.status,
83
83
  });
84
- (0, publish_1.publishTaskEvent)({
85
- type: 'task.created',
86
- source: 'interceptor',
87
- workflowId: input.workflowId,
88
- workflowName: input.workflowType,
89
- taskQueue: input.taskQueue || 'unknown',
90
- taskId: task.id,
91
- originId: input.originId,
92
- status: input.status || 'pending',
93
- });
84
+ // Event published by service layer (services/task/crud.ts)
94
85
  return task.id;
95
86
  }
96
87
  /**
@@ -109,19 +100,7 @@ async function ltCompleteTask(input) {
109
100
  data: input.data,
110
101
  milestones: input.milestones,
111
102
  });
112
- // Publish task.completed event
113
- if (input.workflowId) {
114
- (0, publish_1.publishTaskEvent)({
115
- type: 'task.completed',
116
- source: 'orchestrator',
117
- workflowId: input.workflowId,
118
- workflowName: input.workflowName || 'unknown',
119
- taskQueue: input.taskQueue || 'unknown',
120
- taskId: input.taskId,
121
- status: 'completed',
122
- milestones: input.milestones,
123
- });
124
- }
103
+ // task.completed event published by service layer (services/task/crud.ts)
125
104
  // Publish milestone event from orchestrator context
126
105
  if (input.milestones?.length && input.workflowId) {
127
106
  (0, publish_1.publishMilestoneEvent)({
@@ -115,28 +115,8 @@ async function createAdvisoryEscalation(state, result) {
115
115
  traceId: state.traceId,
116
116
  spanId: state.spanId,
117
117
  });
118
- (0, publish_1.publishEscalationEvent)({
119
- type: 'escalation.created',
120
- source: 'interceptor',
121
- workflowId: state.workflowId,
122
- workflowName: state.workflowName,
123
- taskQueue: state.taskQueue,
124
- taskId: state.taskId,
125
- escalationId,
126
- originId: state.envelope?.lt?.originId,
127
- status: 'pending',
128
- data: result.data,
129
- });
130
- (0, publish_1.publishTaskEvent)({
131
- type: 'task.escalated',
132
- source: 'interceptor',
133
- workflowId: state.workflowId,
134
- workflowName: state.workflowName,
135
- taskQueue: state.taskQueue,
136
- taskId: state.taskId,
137
- originId: state.envelope?.lt?.originId,
138
- status: 'needs_intervention',
139
- });
118
+ // escalation.created event published by service layer (services/escalation/crud.ts)
119
+ // task.escalated event published by service layer (services/task/crud.ts)
140
120
  // Auto-claim to submitting user if known
141
121
  const userId = state.envelope?.lt?.userId;
142
122
  if (userId) {
@@ -41,28 +41,8 @@ async function handleEscalation(state, result) {
41
41
  traceId: state.traceId,
42
42
  spanId: state.spanId,
43
43
  });
44
- (0, publish_1.publishEscalationEvent)({
45
- type: 'escalation.created',
46
- source: 'interceptor',
47
- workflowId: state.workflowId,
48
- workflowName: state.workflowName,
49
- taskQueue: state.taskQueue,
50
- taskId: state.taskId,
51
- escalationId,
52
- originId: state.envelope?.lt?.originId,
53
- status: 'pending',
54
- data: result.data,
55
- });
56
- (0, publish_1.publishTaskEvent)({
57
- type: 'task.escalated',
58
- source: 'interceptor',
59
- workflowId: state.workflowId,
60
- workflowName: state.workflowName,
61
- taskQueue: state.taskQueue,
62
- taskId: state.taskId,
63
- originId: state.envelope?.lt?.originId,
64
- status: 'needs_intervention',
65
- });
44
+ // escalation.created event published by service layer (services/escalation/crud.ts)
45
+ // task.escalated event published by service layer (services/task/crud.ts)
66
46
  return result;
67
47
  }
68
48
  /**
@@ -109,28 +89,9 @@ async function handleErrorEscalation(state, err) {
109
89
  traceId: state.traceId,
110
90
  spanId: state.spanId,
111
91
  });
112
- (0, publish_1.publishEscalationEvent)({
113
- type: 'escalation.created',
114
- source: 'interceptor',
115
- workflowId: state.workflowId,
116
- workflowName: state.workflowName,
117
- taskQueue: state.taskQueue,
118
- taskId: state.taskId,
119
- escalationId: errorEscalationId,
120
- originId: state.envelope?.lt?.originId,
121
- status: 'pending',
122
- data: { error: err.message },
123
- });
124
- (0, publish_1.publishTaskEvent)({
125
- type: 'task.escalated',
126
- source: 'interceptor',
127
- workflowId: state.workflowId,
128
- workflowName: state.workflowName,
129
- taskQueue: state.taskQueue,
130
- taskId: state.taskId,
131
- originId: state.envelope?.lt?.originId,
132
- status: 'needs_intervention',
133
- });
92
+ // escalation.created event published by service layer (services/escalation/crud.ts)
93
+ // task.escalated event published by service layer (services/task/crud.ts)
94
+ // Publish workflow.failed event (error path only runs once — no replay guard needed)
134
95
  (0, publish_1.publishWorkflowEvent)({
135
96
  type: 'workflow.failed',
136
97
  source: 'interceptor',
@@ -44,6 +44,7 @@ const escalation_1 = require("./escalation");
44
44
  const completion_1 = require("./completion");
45
45
  const lifecycle_1 = require("./lifecycle");
46
46
  const context_2 = require("../iam/context");
47
+ const publish_1 = require("../../lib/events/publish");
47
48
  const envelope_1 = require("../iam/envelope");
48
49
  const DEFAULT_ACTIVITY_QUEUE = 'lt-interceptor';
49
50
  /**
@@ -104,13 +105,36 @@ function createLTInterceptor(options) {
104
105
  // task creation, and escalation wiring.
105
106
  if (envelope?.metadata?.certified !== true) {
106
107
  const toolCtx = (0, envelope_1.buildToolContextFromEnvelope)(envelope, wf.workflowId, wf.workflowTrace, wf.workflowSpan);
107
- return toolCtx ? (0, context_2.runWithToolContext)(toolCtx, next) : next();
108
+ // Publish workflow events even for uncertified workflows
109
+ const taskQueue = (0, lifecycle_1.deriveTaskQueue)(wf);
110
+ (0, lifecycle_1.publishStartedEvents)(wf, taskQueue, undefined, wf.workflowId);
111
+ const result = toolCtx ? await (0, context_2.runWithToolContext)(toolCtx, next) : await next();
112
+ (0, publish_1.publishWorkflowEvent)({
113
+ type: 'workflow.completed',
114
+ source: 'interceptor',
115
+ workflowId: wf.workflowId,
116
+ workflowName: wf.workflowName,
117
+ taskQueue,
118
+ status: 'completed',
119
+ });
120
+ return result;
108
121
  }
109
122
  // 3. Load config — unregistered/uncertified workflows get ToolContext only
110
123
  const wfConfig = await activities.ltGetWorkflowConfig(wf.workflowName);
111
124
  if (!wfConfig) {
112
125
  const toolCtx = (0, envelope_1.buildToolContextFromEnvelope)(envelope, wf.workflowId, wf.workflowTrace, wf.workflowSpan);
113
- return toolCtx ? (0, context_2.runWithToolContext)(toolCtx, next) : next();
126
+ const taskQueue2 = (0, lifecycle_1.deriveTaskQueue)(wf);
127
+ (0, lifecycle_1.publishStartedEvents)(wf, taskQueue2, undefined, wf.workflowId);
128
+ const result2 = toolCtx ? await (0, context_2.runWithToolContext)(toolCtx, next) : await next();
129
+ (0, publish_1.publishWorkflowEvent)({
130
+ type: 'workflow.completed',
131
+ source: 'interceptor',
132
+ workflowId: wf.workflowId,
133
+ workflowName: wf.workflowName,
134
+ taskQueue: taskQueue2,
135
+ status: 'completed',
136
+ });
137
+ return result2;
114
138
  }
115
139
  const taskQueue = (0, lifecycle_1.deriveTaskQueue)(wf);
116
140
  // 3. Find existing task and handle re-run escalation resolution
@@ -37,7 +37,7 @@ export declare function resolveReRun(activities: ProxiedActivities, envelope: LT
37
37
  * Also injects originId back into the envelope for downstream consistency.
38
38
  */
39
39
  export declare function ensureTaskWithRouting(activities: ProxiedActivities, wf: WorkflowIdentity, envelope: LTEnvelope | undefined, existingTask: any | null, taskQueue: string, reRun: ReRunContext): Promise<TaskContext>;
40
- /** Publish workflow.started + task.started events. */
40
+ /** Publish workflow.started event (guarded against replay). */
41
41
  export declare function publishStartedEvents(wf: WorkflowIdentity, taskQueue: string, taskId: string | undefined, originId: string): void;
42
42
  /** Complete a task and signal parent for plain (non-LTReturn) results. */
43
43
  export declare function completePlainResult(activities: ProxiedActivities, wf: WorkflowIdentity, taskQueue: string, taskId: string | undefined, routing: Record<string, any> | null, result: any): Promise<void>;
@@ -13,6 +13,7 @@ exports.resolveReRun = resolveReRun;
13
13
  exports.ensureTaskWithRouting = ensureTaskWithRouting;
14
14
  exports.publishStartedEvents = publishStartedEvents;
15
15
  exports.completePlainResult = completePlainResult;
16
+ const hotmesh_1 = require("@hotmeshio/hotmesh");
16
17
  const publish_1 = require("../../lib/events/publish");
17
18
  // ── Helpers ──────────────────────────────────────────────────────────────────
18
19
  /** Extract workflow identity fields from the HotMesh context map. */
@@ -56,17 +57,7 @@ async function resolveReRun(activities, envelope, existingTask, wf, taskQueue) {
56
57
  escalationId: escalationId,
57
58
  resolverPayload: envelope.resolver,
58
59
  });
59
- (0, publish_1.publishEscalationEvent)({
60
- type: 'escalation.resolved',
61
- source: 'interceptor',
62
- workflowId: wf.workflowId,
63
- workflowName: wf.workflowName,
64
- taskQueue,
65
- taskId: task?.id || existingTask?.id,
66
- escalationId: escalationId,
67
- originId: envelope?.lt?.originId,
68
- status: 'resolved',
69
- });
60
+ // escalation.resolved event published by service layer (services/escalation/crud.ts)
70
61
  }
71
62
  return { isReRun, task, metadata };
72
63
  }
@@ -128,28 +119,23 @@ async function ensureTaskWithRouting(activities, wf, envelope, existingTask, tas
128
119
  }
129
120
  return { taskId, routing, originId };
130
121
  }
131
- /** Publish workflow.started + task.started events. */
122
+ /** Publish workflow.started event (guarded against replay). */
132
123
  function publishStartedEvents(wf, taskQueue, taskId, originId) {
133
- (0, publish_1.publishWorkflowEvent)({
134
- type: 'workflow.started',
135
- source: 'interceptor',
136
- workflowId: wf.workflowId,
137
- workflowName: wf.workflowName,
138
- taskQueue,
139
- taskId,
140
- originId,
141
- status: 'running',
142
- });
143
- (0, publish_1.publishTaskEvent)({
144
- type: 'task.started',
145
- source: 'interceptor',
146
- workflowId: wf.workflowId,
147
- workflowName: wf.workflowName,
148
- taskQueue,
149
- taskId: taskId,
150
- originId,
151
- status: 'in_progress',
152
- });
124
+ const { replay } = hotmesh_1.Durable.workflow.workflowInfo();
125
+ const isFirstExecution = Object.keys(replay).length === 0;
126
+ if (isFirstExecution) {
127
+ (0, publish_1.publishWorkflowEvent)({
128
+ type: 'workflow.started',
129
+ source: 'interceptor',
130
+ workflowId: wf.workflowId,
131
+ workflowName: wf.workflowName,
132
+ taskQueue,
133
+ taskId,
134
+ originId,
135
+ status: 'running',
136
+ });
137
+ }
138
+ // task.started event published by service layer (services/task/crud.ts)
153
139
  }
154
140
  /** Complete a task and signal parent for plain (non-LTReturn) results. */
155
141
  async function completePlainResult(activities, wf, taskQueue, taskId, routing, result) {
@@ -8,6 +8,7 @@ exports.getTaskBySignalId = getTaskBySignalId;
8
8
  exports.getTaskByWorkflowId = getTaskByWorkflowId;
9
9
  exports.listTasks = listTasks;
10
10
  const db_1 = require("../../lib/db");
11
+ const publish_1 = require("../../lib/events/publish");
11
12
  const sql_1 = require("./sql");
12
13
  async function createTask(input) {
13
14
  const pool = (0, db_1.getPool)();
@@ -30,7 +31,18 @@ async function createTask(input) {
30
31
  input.executing_as || null,
31
32
  input.status || null,
32
33
  ]);
33
- return rows[0];
34
+ const task = rows[0];
35
+ (0, publish_1.publishTaskEvent)({
36
+ type: 'task.created',
37
+ source: 'service',
38
+ workflowId: task.workflow_id || '',
39
+ workflowName: task.workflow_type || '',
40
+ taskQueue: task.task_queue || '',
41
+ taskId: task.id,
42
+ originId: task.origin_id || undefined,
43
+ status: task.status || 'pending',
44
+ });
45
+ return task;
34
46
  }
35
47
  async function updateTask(id, input) {
36
48
  const pool = (0, db_1.getPool)();
@@ -59,7 +71,29 @@ async function updateTask(id, input) {
59
71
  }
60
72
  values.push(id);
61
73
  const { rows } = await pool.query(`UPDATE lt_tasks SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`, values);
62
- return rows[0];
74
+ const task = rows[0];
75
+ // Publish status-driven events from the single write path
76
+ if (input.status && task) {
77
+ const STATUS_EVENTS = {
78
+ in_progress: 'task.started',
79
+ completed: 'task.completed',
80
+ needs_intervention: 'task.escalated',
81
+ };
82
+ const eventType = STATUS_EVENTS[input.status];
83
+ if (eventType) {
84
+ (0, publish_1.publishTaskEvent)({
85
+ type: eventType,
86
+ source: 'service',
87
+ workflowId: task.workflow_id || '',
88
+ workflowName: task.workflow_type || '',
89
+ taskQueue: task.task_queue || '',
90
+ taskId: task.id,
91
+ originId: task.origin_id || undefined,
92
+ status: input.status,
93
+ });
94
+ }
95
+ }
96
+ return task;
63
97
  }
64
98
  async function appendMilestones(id, milestones) {
65
99
  const pool = (0, db_1.getPool)();
@@ -719,7 +719,7 @@ Finds one available (pending + unassigned/expired) escalation matching the metad
719
719
  "key": "orderId",
720
720
  "value": "order-123",
721
721
  "durationMinutes": 30,
722
- "assignee": "station-operator-42"
722
+ "metadata": { "claimedBy": "jimbo", "station": "scanning" }
723
723
  }
724
724
  ```
725
725
 
@@ -728,7 +728,8 @@ Finds one available (pending + unassigned/expired) escalation matching the metad
728
728
  | `key` | `string` | **Required.** Metadata field name |
729
729
  | `value` | `string` | **Required.** Metadata field value |
730
730
  | `durationMinutes` | `number` | Claim duration (default 30) |
731
- | `assignee` | `string` | External user ID to claim as (resolved via `getUserByExternalId`) |
731
+ | `assignee` | `string` | Claim as a Long Tail user (resolved via `getUserByExternalId`) |
732
+ | `metadata` | `object` | Additional metadata to merge (new keys added, existing overwritten) |
732
733
 
733
734
  **Response 200:**
734
735
 
@@ -756,7 +757,7 @@ Finds the pending escalation, auto-claims if unclaimed, then resolves it. Suppor
756
757
  "key": "orderId",
757
758
  "value": "order-123",
758
759
  "resolverPayload": { "approved": true, "targetStatus": "completed" },
759
- "assignee": "station-operator-42"
760
+ "metadata": { "completedBy": "jimbo" }
760
761
  }
761
762
  ```
762
763
 
@@ -765,6 +766,7 @@ Finds the pending escalation, auto-claims if unclaimed, then resolves it. Suppor
765
766
  | `key` | `string` | **Required.** Metadata field name |
766
767
  | `value` | `string` | **Required.** Metadata field value |
767
768
  | `resolverPayload` | `object` | **Required.** Resolution data passed to the workflow |
768
- | `assignee` | `string` | External user ID to resolve as |
769
+ | `assignee` | `string` | Resolve as a Long Tail user (resolved via `getUserByExternalId`) |
770
+ | `metadata` | `object` | Additional metadata to merge (new keys added, existing overwritten) |
769
771
 
770
772
  **Response 200:** Same as standard resolve endpoint.
@@ -549,7 +549,7 @@ const result = await lt.escalations.claimByMetadata({
549
549
  key: 'orderId',
550
550
  value: 'order-123',
551
551
  durationMinutes: 30,
552
- assignee: 'station-operator-42', // optional external_id
552
+ metadata: { claimedBy: 'jimbo', station: 'scanning' },
553
553
  });
554
554
  ```
555
555
 
@@ -560,7 +560,8 @@ const result = await lt.escalations.claimByMetadata({
560
560
  | `key` | `string` | Yes | Metadata field name |
561
561
  | `value` | `string` | Yes | Metadata field value |
562
562
  | `durationMinutes` | `number` | No | Claim duration (default: 30) |
563
- | `assignee` | `string` | No | External user ID to claim as (resolved via `getUserByExternalId`) |
563
+ | `assignee` | `string` | No | Claim as a Long Tail user (resolved via `getUserByExternalId`) |
564
+ | `metadata` | `object` | No | Merge into escalation metadata (single atomic SQL call with the claim) |
564
565
 
565
566
  **Returns:** `LTApiResult<{ escalation, isExtension }>` -- 404 if no match, 409 if already claimed.
566
567
 
@@ -577,7 +578,7 @@ const result = await lt.escalations.resolveByMetadata({
577
578
  key: 'orderId',
578
579
  value: 'order-123',
579
580
  resolverPayload: { approved: true, targetStatus: 'completed' },
580
- assignee: 'station-operator-42',
581
+ metadata: { completedBy: 'jimbo' },
581
582
  });
582
583
  ```
583
584
 
@@ -588,7 +589,8 @@ const result = await lt.escalations.resolveByMetadata({
588
589
  | `key` | `string` | Yes | Metadata field name |
589
590
  | `value` | `string` | Yes | Metadata field value |
590
591
  | `resolverPayload` | `object` | Yes | Resolution data passed to the workflow |
591
- | `assignee` | `string` | No | External user ID to resolve as |
592
+ | `assignee` | `string` | No | Resolve as a Long Tail user (resolved via `getUserByExternalId`) |
593
+ | `metadata` | `object` | No | Merge into escalation metadata before resolving |
592
594
 
593
595
  **Returns:** Same as `resolve` -- 404 if no match.
594
596
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/long-tail",
3
- "version": "0.4.14",
3
+ "version": "0.4.15",
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",