@hotmeshio/hotmesh 0.22.3 → 0.22.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/index.d.ts CHANGED
@@ -22,3 +22,4 @@ import { Escalations } from './services/escalations';
22
22
  export { Connector, //factory
23
23
  ConnectorNATS, ConnectorPostgres, HotMesh, HotMeshConfig, Virtual, Durable, Escalations, DBA, Client, Connection, proxyActivities, Search, Entity, Worker, workflow, WorkflowHandle, Enums, Errors, Utils, KeyStore, };
24
24
  export * as Types from './types';
25
+ export type { EventsConfig, SystemEvent } from './types/system_events';
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.3",
3
+ "version": "0.22.4",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -89,6 +89,7 @@
89
89
  "prove": "docker compose exec hotmesh npx vitest run tests/durable 2>&1 | tee /tmp/hmsh-durable.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-durable.txt | tail -5",
90
90
  "prove:escalations": "docker compose exec hotmesh npx vitest run tests/durable/escalations/postgres.test.ts 2>&1 | tee /tmp/hmsh-escalations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-escalations.txt | tail -5",
91
91
  "prove:migrations": "docker compose exec hotmesh npx vitest run tests/durable/migrations/postgres.test.ts 2>&1 | tee /tmp/hmsh-migrations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-migrations.txt | tail -5",
92
+ "prove:events": "docker compose exec hotmesh npx vitest run tests/durable/events/postgres.test.ts 2>&1 | tee /tmp/hmsh-events.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-events.txt | tail -5",
92
93
  "prove:functional": "docker compose exec hotmesh npx vitest run tests/functional 2>&1 | tee /tmp/hmsh-functional.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-functional.txt | tail -5",
93
94
  "prove:all": "docker compose exec hotmesh npx vitest run tests/ 2>&1 | tee /tmp/hmsh-all.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-all.txt | tail -5",
94
95
  "prove:file": "f() { docker compose exec hotmesh npx vitest run \"$@\" 2>&1 | tee /tmp/hmsh-file.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-file.txt | tail -5; }; f"
@@ -347,8 +347,10 @@ class Hook extends activity_1.Activity {
347
347
  //enqueue escalation INSERT inside the Leg1 transaction so it is
348
348
  //written atomically with the job state checkpoint — one committed
349
349
  //unit, crash-safe, no separate recovery path needed.
350
- await this.addEscalationToTransaction(transaction);
351
- await transaction.exec();
350
+ const escalationSignalKey = await this.addEscalationToTransaction(transaction);
351
+ // exec() returns one result per queued command; the escalation INSERT
352
+ // (with RETURNING *) is the last command — its row is results[last].
353
+ const execResults = await transaction.exec();
352
354
  telemetry.mapActivityAttributes();
353
355
  //register the web hook signal AFTER the transaction commits.
354
356
  //this eliminates the FORBIDDEN window: the hook signal is never
@@ -360,13 +362,39 @@ class Hook extends activity_1.Activity {
360
362
  if (pending) {
361
363
  await this.redeliverPendingSignal(pending);
362
364
  }
365
+ // Post-commit: fire the escalation.created event using the row returned
366
+ // directly from the RETURNING * clause — no extra SELECT.
367
+ if (escalationSignalKey) {
368
+ const store = this.store;
369
+ if (store.eventsPublish) {
370
+ const row = execResults[execResults.length - 1];
371
+ if (row?.id) {
372
+ const ts = new Date().toISOString();
373
+ const updatedAt = row.updated_at ? new Date(row.updated_at).toISOString() : ts;
374
+ void Promise.resolve(store.eventsPublish({
375
+ event_id: `${row.id}:created:${updatedAt}`,
376
+ type: `system.escalation.${row.id}.created`,
377
+ ts,
378
+ namespace: row.namespace ?? this.engine.namespace ?? this.engine.appId,
379
+ app_id: row.app_id ?? this.engine.appId,
380
+ workflow_id: row.workflow_id ?? undefined,
381
+ topic: row.topic ?? undefined,
382
+ origin_id: row.origin_id ?? undefined,
383
+ parent_id: row.parent_id ?? undefined,
384
+ trace_id: row.trace_id ?? undefined,
385
+ span_id: row.span_id ?? undefined,
386
+ data: row,
387
+ })).catch(() => { });
388
+ }
389
+ }
390
+ }
363
391
  }
364
392
  async addEscalationToTransaction(transaction) {
365
393
  if (!this.config.escalation || !this.config.hook?.topic)
366
- return;
394
+ return null;
367
395
  const store = this.store;
368
396
  if (typeof store.addEscalationToTransaction !== 'function')
369
- return;
397
+ return null;
370
398
  const escalationConfig = this.config.escalation;
371
399
  const jid = this.context.metadata.jid;
372
400
  const appId = this.engine.appId;
@@ -408,7 +436,7 @@ class Hook extends activity_1.Activity {
408
436
  // the factory waiter runs for a condition() call that had no queueConfig.
409
437
  if (params.role == null && params.type == null &&
410
438
  params.priority == null && params.metadata == null)
411
- return;
439
+ return null;
412
440
  // Derive signal_key from the hook rule's expected condition — the same
413
441
  // value registerWebHook stores as the signal lookup key.
414
442
  const hookRule = await this.getHookRule(this.config.hook.topic);
@@ -423,6 +451,7 @@ class Hook extends activity_1.Activity {
423
451
  workflowId: jid,
424
452
  ...params,
425
453
  }, transaction);
454
+ return signalKey;
426
455
  }
427
456
  /**
428
457
  * Re-publishes a pending signal as a WEBHOOK stream message so the
@@ -4,6 +4,11 @@ import { EscalationClientService } from '../escalations/client';
4
4
  /**
5
5
  * Workflow client. Starts workflows, sends signals, and reads results.
6
6
  *
7
+ * Pass `config.events` to receive system-event notifications from all
8
+ * escalation operations performed through this client. Events fire
9
+ * post-commit, from this process only — no fanout to other containers.
10
+ * See `EventsConfig` and `SystemEvent` in `types/system_events` for the full ontology.
11
+ *
7
12
  * @example
8
13
  * ```typescript
9
14
  * import { Durable } from '@hotmeshio/hotmesh';
@@ -14,6 +19,13 @@ import { EscalationClientService } from '../escalations/client';
14
19
  * class: Postgres,
15
20
  * options: { connectionString: 'postgresql://usr:pwd@localhost:5432/db' },
16
21
  * },
22
+ * // optional — wire lifecycle events
23
+ * events: {
24
+ * publish: (event) => {
25
+ * // event.type follows system.escalation.{id}.{verb}
26
+ * myEventBus.emit(event.type, event.data);
27
+ * },
28
+ * },
17
29
  * });
18
30
  *
19
31
  * // Start a workflow and await its result
@@ -14,6 +14,11 @@ const factory_1 = require("./schemas/factory");
14
14
  /**
15
15
  * Workflow client. Starts workflows, sends signals, and reads results.
16
16
  *
17
+ * Pass `config.events` to receive system-event notifications from all
18
+ * escalation operations performed through this client. Events fire
19
+ * post-commit, from this process only — no fanout to other containers.
20
+ * See `EventsConfig` and `SystemEvent` in `types/system_events` for the full ontology.
21
+ *
17
22
  * @example
18
23
  * ```typescript
19
24
  * import { Durable } from '@hotmeshio/hotmesh';
@@ -24,6 +29,13 @@ const factory_1 = require("./schemas/factory");
24
29
  * class: Postgres,
25
30
  * options: { connectionString: 'postgresql://usr:pwd@localhost:5432/db' },
26
31
  * },
32
+ * // optional — wire lifecycle events
33
+ * events: {
34
+ * publish: (event) => {
35
+ * // event.type follows system.escalation.{id}.{verb}
36
+ * myEventBus.emit(event.type, event.data);
37
+ * },
38
+ * },
27
39
  * });
28
40
  *
29
41
  * // Start a workflow and await its result
@@ -286,9 +298,9 @@ class ClientService {
286
298
  },
287
299
  };
288
300
  this.connection = config.connection;
289
- // Inject our getHotMeshClient so the escalation client shares the same engine pool.
290
301
  this.escalations = new client_1.EscalationClientService({
291
302
  getHotMeshClient: this.getHotMeshClient.bind(this),
303
+ events: config.events,
292
304
  });
293
305
  }
294
306
  hashOptions() {
@@ -104,6 +104,12 @@ export declare class WorkerService {
104
104
  * @private
105
105
  */
106
106
  activityRunner: HotMesh;
107
+ /** @private — retained from create() config for stop() lifecycle event */
108
+ _eventsPublish?: (event: import('../../types/system_events').SystemEvent) => void | Promise<void>;
109
+ /** @private */
110
+ _eventsTaskQueue?: string;
111
+ /** @private */
112
+ _eventsAppId?: string;
107
113
  /**
108
114
  * @private
109
115
  */
@@ -268,6 +274,10 @@ export declare class WorkerService {
268
274
  * Run the connected worker; no-op (unnecessary to call)
269
275
  */
270
276
  run(): Promise<void>;
277
+ /**
278
+ * Stops the worker's HotMesh instances and fires `system.worker.{taskQueue}.stopped`.
279
+ */
280
+ stop(): void;
271
281
  /**
272
282
  * @private
273
283
  */
@@ -478,6 +478,21 @@ class WorkerService {
478
478
  worker.workflowRunner = await worker.initWorkflowWorker(config, taskQueue, workflowFunctionName, workflowTopic, workflowFunction);
479
479
  search_1.Search.configureSearchIndex(worker.workflowRunner, config.search);
480
480
  await WorkerService.activateWorkflow(worker.workflowRunner);
481
+ // Fire system.worker.{taskQueue}.started post-init (best-effort).
482
+ if (config.events?.publish) {
483
+ worker._eventsPublish = config.events.publish;
484
+ worker._eventsTaskQueue = taskQueue;
485
+ worker._eventsAppId = targetNamespace;
486
+ const ts = new Date().toISOString();
487
+ void Promise.resolve(config.events.publish({
488
+ event_id: `${taskQueue}:started:${ts}`,
489
+ type: `system.worker.${taskQueue}.started`,
490
+ ts,
491
+ namespace: targetNamespace,
492
+ app_id: targetNamespace,
493
+ data: { taskQueue, appId: targetNamespace },
494
+ })).catch(() => { });
495
+ }
481
496
  return worker;
482
497
  }
483
498
  /**
@@ -502,6 +517,24 @@ class WorkerService {
502
517
  async run() {
503
518
  this.workflowRunner.engine.logger.info('durable-worker-running');
504
519
  }
520
+ /**
521
+ * Stops the worker's HotMesh instances and fires `system.worker.{taskQueue}.stopped`.
522
+ */
523
+ stop() {
524
+ this.workflowRunner?.stop();
525
+ this.activityRunner?.stop();
526
+ if (this._eventsPublish && this._eventsTaskQueue) {
527
+ const ts = new Date().toISOString();
528
+ void Promise.resolve(this._eventsPublish({
529
+ event_id: `${this._eventsTaskQueue}:stopped:${ts}`,
530
+ type: `system.worker.${this._eventsTaskQueue}.stopped`,
531
+ ts,
532
+ namespace: this._eventsAppId ?? '',
533
+ app_id: this._eventsAppId ?? '',
534
+ data: { taskQueue: this._eventsTaskQueue, appId: this._eventsAppId },
535
+ })).catch(() => { });
536
+ }
537
+ }
505
538
  /**
506
539
  * @private
507
540
  */
@@ -1,4 +1,5 @@
1
1
  import { HotMesh } from '../hotmesh';
2
+ import { EventsConfig } from '../../types/system_events';
2
3
  import { Connection } from '../../types/durable';
3
4
  import { EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, StatsEscalationsParams, EscalationStats, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, MigrateEscalationParams, ClaimManyParams, EscalateManyToRoleParams, UpdateManyPriorityParams, ResolveManyParams } from '../../types/hmsh_escalations';
4
5
  export type GetHotMeshFn = (topic: string | null, namespace?: string) => Promise<HotMesh>;
@@ -10,6 +11,12 @@ export interface EscalationClientConfig {
10
11
  * When provided, the client reuses the caller's engine pool — no extra connections.
11
12
  */
12
13
  getHotMeshClient?: GetHotMeshFn;
14
+ /**
15
+ * Optional system-event sink. When set, this client calls `events.publish`
16
+ * post-commit for every escalation lifecycle transition it performs.
17
+ * Fire-and-forget; a publish error never fails the committed operation.
18
+ */
19
+ events?: EventsConfig;
13
20
  }
14
21
  /**
15
22
  * Standalone client for the `public.hmsh_escalations` signal-pause surface.
@@ -48,8 +55,20 @@ export interface EscalationClientConfig {
48
55
  */
49
56
  export declare class EscalationClientService {
50
57
  private readonly _engine;
58
+ private readonly _events?;
51
59
  static instances: Map<string, HotMesh | Promise<HotMesh>>;
52
60
  constructor(config?: EscalationClientConfig);
61
+ /**
62
+ * Fires the configured `events.publish` hook post-commit.
63
+ * Detached (not awaited); a publish error never fails the caller.
64
+ * @private
65
+ */
66
+ private _emit;
67
+ /**
68
+ * Fires per-row events for bulk operations. Skipped rows are not emitted.
69
+ * @private
70
+ */
71
+ private _emitMany;
53
72
  private _makeEngineFactory;
54
73
  private _hashConnection;
55
74
  private _deliverEscalationSignal;
@@ -44,7 +44,6 @@ const factory_1 = require("../durable/schemas/factory");
44
44
  class EscalationClientService {
45
45
  constructor(config = {}) {
46
46
  if (config.getHotMeshClient) {
47
- // Reuse a caller-supplied engine factory (e.g. Durable.Client) — no extra connections.
48
47
  this._engine = config.getHotMeshClient;
49
48
  }
50
49
  else if (config.connection) {
@@ -53,6 +52,43 @@ class EscalationClientService {
53
52
  else {
54
53
  throw new Error('EscalationClient requires either `connection` or `getHotMeshClient`');
55
54
  }
55
+ this._events = config.events;
56
+ }
57
+ /**
58
+ * Fires the configured `events.publish` hook post-commit.
59
+ * Detached (not awaited); a publish error never fails the caller.
60
+ * @private
61
+ */
62
+ _emit(verb, entry) {
63
+ if (!this._events?.publish)
64
+ return;
65
+ const ts = new Date().toISOString();
66
+ const updatedAt = entry.updated_at
67
+ ? (0, utils_1.formatISODate)(entry.updated_at)
68
+ : ts;
69
+ const event = {
70
+ event_id: `${entry.id}:${verb}:${updatedAt}`,
71
+ type: `system.escalation.${entry.id}.${verb}`,
72
+ ts,
73
+ namespace: entry.namespace ?? '',
74
+ app_id: entry.app_id ?? '',
75
+ workflow_id: entry.workflow_id ?? undefined,
76
+ topic: entry.topic ?? undefined,
77
+ origin_id: entry.origin_id ?? undefined,
78
+ parent_id: entry.parent_id ?? undefined,
79
+ trace_id: entry.trace_id ?? undefined,
80
+ span_id: entry.span_id ?? undefined,
81
+ data: entry,
82
+ };
83
+ void Promise.resolve(this._events.publish(event)).catch(() => { });
84
+ }
85
+ /**
86
+ * Fires per-row events for bulk operations. Skipped rows are not emitted.
87
+ * @private
88
+ */
89
+ _emitMany(verb, entries) {
90
+ for (const entry of entries)
91
+ this._emit(verb, entry);
56
92
  }
57
93
  _makeEngineFactory(connection) {
58
94
  return async (topic, namespace) => {
@@ -143,7 +179,10 @@ class EscalationClientService {
143
179
  */
144
180
  async create(params) {
145
181
  const hm = await this._engine(null, params.namespace);
146
- return hm.engine.store.createEscalation(params);
182
+ const entry = await hm.engine.store.createEscalation(params);
183
+ if (entry)
184
+ this._emit('created', entry);
185
+ return entry;
147
186
  }
148
187
  /**
149
188
  * Patches an existing escalation row. `metadata` is merged, not replaced.
@@ -165,7 +204,10 @@ class EscalationClientService {
165
204
  */
166
205
  async claim(params) {
167
206
  const hm = await this._engine(null, params.namespace);
168
- return hm.engine.store.claimEscalation(params);
207
+ const result = await hm.engine.store.claimEscalation(params);
208
+ if (result.ok === true)
209
+ this._emit('claimed', result.entry);
210
+ return result;
169
211
  }
170
212
  /**
171
213
  * Atomically claims the highest-priority pending escalation whose `metadata`
@@ -175,12 +217,18 @@ class EscalationClientService {
175
217
  */
176
218
  async claimByMetadata(params) {
177
219
  const hm = await this._engine(null, params.namespace);
178
- return hm.engine.store.claimEscalationByMetadata(params);
220
+ const result = await hm.engine.store.claimEscalationByMetadata(params);
221
+ if (result.ok === true)
222
+ this._emit('claimed', result.entry);
223
+ return result;
179
224
  }
180
225
  /** Releases a claimed escalation, returning it to available status. */
181
226
  async release(params) {
182
227
  const hm = await this._engine(null, params.namespace);
183
- return hm.engine.store.releaseEscalation(params);
228
+ const result = await hm.engine.store.releaseEscalation(params);
229
+ if (result.ok === true)
230
+ this._emit('released', result.entry);
231
+ return result;
184
232
  }
185
233
  /**
186
234
  * Reassigns the escalation to a different role, clearing any current claim
@@ -188,7 +236,10 @@ class EscalationClientService {
188
236
  */
189
237
  async escalateToRole(params) {
190
238
  const hm = await this._engine(null, params.namespace);
191
- return hm.engine.store.escalateEscalationToRole(params);
239
+ const entry = await hm.engine.store.escalateEscalationToRole(params);
240
+ if (entry)
241
+ this._emit('reassigned', entry);
242
+ return entry;
192
243
  }
193
244
  /**
194
245
  * Cancels a pending escalation without delivering a signal. Terminal rows
@@ -196,7 +247,10 @@ class EscalationClientService {
196
247
  */
197
248
  async cancel(id, namespace) {
198
249
  const hm = await this._engine(null, namespace);
199
- return hm.engine.store.cancelEscalation(id, namespace);
250
+ const result = await hm.engine.store.cancelEscalation(id, namespace);
251
+ if (result.ok === true)
252
+ this._emit('cancelled', result.entry);
253
+ return result;
200
254
  }
201
255
  /**
202
256
  * Resolves a pending escalation by UUID. Uses an explicit Postgres transaction
@@ -219,6 +273,7 @@ class EscalationClientService {
219
273
  data: params.resolverPayload ?? {},
220
274
  });
221
275
  }
276
+ this._emit('resolved', dbResult.entry);
222
277
  return { ok: true, entry: dbResult.entry };
223
278
  }
224
279
  /**
@@ -238,6 +293,7 @@ class EscalationClientService {
238
293
  data: params.resolverPayload ?? {},
239
294
  });
240
295
  }
296
+ this._emit('resolved', dbResult.entry);
241
297
  return { ok: true, entry: dbResult.entry };
242
298
  }
243
299
  /**
@@ -265,7 +321,9 @@ class EscalationClientService {
265
321
  */
266
322
  async claimMany(params) {
267
323
  const hm = await this._engine(null, params.namespace);
268
- return hm.engine.store.claimManyEscalations(params);
324
+ const { entries, skipped } = await hm.engine.store.claimManyEscalations(params);
325
+ this._emitMany('claimed', entries);
326
+ return { claimed: entries.length, skipped };
269
327
  }
270
328
  /**
271
329
  * Bulk-reassigns pending escalations to a new role, clearing any current claim.
@@ -289,7 +347,9 @@ class EscalationClientService {
289
347
  */
290
348
  async resolveMany(params) {
291
349
  const hm = await this._engine(null, params.namespace);
292
- return hm.engine.store.resolveManyEscalations(params);
350
+ const entries = await hm.engine.store.resolveManyEscalations(params);
351
+ this._emitMany('resolved', entries);
352
+ return entries;
293
353
  }
294
354
  // ─── Aggregates ─────────────────────────────────────────────────────────────
295
355
  /**
@@ -124,6 +124,8 @@ declare class HotMesh {
124
124
  */
125
125
  workers: WorkerService[];
126
126
  logger: ILogger;
127
+ /** @private — retained from init config for stop() lifecycle event */
128
+ private _eventsPublish?;
127
129
  static disconnecting: boolean;
128
130
  /**
129
131
  * @private
@@ -144,6 +146,13 @@ declare class HotMesh {
144
146
  * functions that consume messages from Postgres streams — they can
145
147
  * run on the same process or on entirely separate servers.
146
148
  *
149
+ * Pass `config.events` to receive lifecycle events from this engine:
150
+ * - `system.engine.{appId}.started` — fires when init completes.
151
+ * - `system.engine.{appId}.deployed` — fires after schema deploy.
152
+ * - `system.engine.{appId}.stopped` — fires when `stop()` is called.
153
+ * - `system.escalation.{id}.created` — fires from the hook Leg1 path
154
+ * (YAML `escalation:` block) when the escalation row commits.
155
+ *
147
156
  * @param config - Engine connection, worker definitions, app ID, and options.
148
157
  * @returns A running HotMesh instance joined to the quorum.
149
158
  */
@@ -170,6 +170,13 @@ class HotMesh {
170
170
  * functions that consume messages from Postgres streams — they can
171
171
  * run on the same process or on entirely separate servers.
172
172
  *
173
+ * Pass `config.events` to receive lifecycle events from this engine:
174
+ * - `system.engine.{appId}.started` — fires when init completes.
175
+ * - `system.engine.{appId}.deployed` — fires after schema deploy.
176
+ * - `system.engine.{appId}.stopped` — fires when `stop()` is called.
177
+ * - `system.escalation.{id}.created` — fires from the hook Leg1 path
178
+ * (YAML `escalation:` block) when the escalation row commits.
179
+ *
173
180
  * @param config - Engine connection, worker definitions, app ID, and options.
174
181
  * @returns A running HotMesh instance joined to the quorum.
175
182
  */
@@ -196,6 +203,24 @@ class HotMesh {
196
203
  });
197
204
  }
198
205
  await Init.doWork(instance, config, instance.logger);
206
+ // Thread events.publish to the store (used by the hook Leg1 path and deploy).
207
+ if (config.events?.publish && instance.engine?.store) {
208
+ instance.engine.store.eventsPublish = config.events.publish;
209
+ }
210
+ // Retain for stop() lifecycle event.
211
+ if (config.events?.publish) {
212
+ instance._eventsPublish = config.events.publish;
213
+ const ts = new Date().toISOString();
214
+ const event = {
215
+ event_id: `${config.appId}:started:${ts}`,
216
+ type: `system.engine.${config.appId}.started`,
217
+ ts,
218
+ namespace: instance.namespace,
219
+ app_id: config.appId,
220
+ data: { appId: config.appId, guid: instance.guid },
221
+ };
222
+ void Promise.resolve(config.events.publish(event)).catch(() => { });
223
+ }
199
224
  return instance;
200
225
  }
201
226
  /**
@@ -489,6 +514,18 @@ class HotMesh {
489
514
  this.workers?.forEach((worker) => {
490
515
  worker.stop();
491
516
  });
517
+ if (this._eventsPublish) {
518
+ const ts = new Date().toISOString();
519
+ const event = {
520
+ event_id: `${this.appId}:stopped:${ts}`,
521
+ type: `system.engine.${this.appId}.stopped`,
522
+ ts,
523
+ namespace: this.namespace,
524
+ app_id: this.appId,
525
+ data: { appId: this.appId, guid: this.guid },
526
+ };
527
+ void Promise.resolve(this._eventsPublish(event)).catch(() => { });
528
+ }
492
529
  }
493
530
  /**
494
531
  * @private
@@ -71,6 +71,18 @@ const KVTables = (context) => ({
71
71
  await client.release();
72
72
  }
73
73
  }
74
+ // Fire system.engine.{appId}.deployed post-deploy (best-effort).
75
+ if (context.eventsPublish) {
76
+ const ts = new Date().toISOString();
77
+ void Promise.resolve(context.eventsPublish({
78
+ event_id: `${appName}:deployed:${ts}`,
79
+ type: `system.engine.${appName}.deployed`,
80
+ ts,
81
+ namespace: appName,
82
+ app_id: appName,
83
+ data: { appId: appName },
84
+ })).catch(() => { });
85
+ }
74
86
  },
75
87
  getAdvisoryLockId(appName) {
76
88
  return this.hashStringToInt(appName);
@@ -19,6 +19,8 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
19
19
  pgClient: PostgresClientType;
20
20
  kvTables: ReturnType<typeof KVTables>;
21
21
  isScout: boolean;
22
+ /** Set by HotMesh.init() when `events.publish` is configured. Used by hook.ts Leg1 path. */
23
+ eventsPublish?: (event: import('../../../../types/system_events').SystemEvent) => void | Promise<void>;
22
24
  transact(): ProviderTransaction;
23
25
  constructor(storeClient: ProviderClient);
24
26
  init(namespace: string, appId: string, logger: ILogger, guid?: string, role?: string): Promise<HotMeshApps>;
@@ -265,7 +267,7 @@ declare class PostgresStoreService extends StoreService<ProviderClient, Provider
265
267
  updateEscalation(params: import('../../../../types/hmsh_escalations').UpdateEscalationParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
266
268
  appendEscalationMilestones(params: import('../../../../types/hmsh_escalations').AppendMilestonesParams): Promise<import('../../../../types/hmsh_escalations').EscalationEntry | null>;
267
269
  claimManyEscalations(params: import('../../../../types/hmsh_escalations').ClaimManyParams): Promise<{
268
- claimed: number;
270
+ entries: import('../../../../types/hmsh_escalations').EscalationEntry[];
269
271
  skipped: number;
270
272
  }>;
271
273
  escalateManyEscalationsToRole(params: import('../../../../types/hmsh_escalations').EscalateManyToRoleParams): Promise<number>;
@@ -1439,7 +1439,7 @@ class PostgresStoreService extends __1.StoreService {
1439
1439
  * idempotent re-runs after a crash.
1440
1440
  */
1441
1441
  addEscalationToTransaction(params, transaction) {
1442
- transaction.addCommand(this._escalationInsertSql, this._escalationInsertParams(params), 'void');
1442
+ transaction.addCommand(this._escalationInsertSql + ' RETURNING *', this._escalationInsertParams(params), 'object');
1443
1443
  }
1444
1444
  /**
1445
1445
  * Full-fidelity INSERT for data migration. Preserves the original `id` (UUID),
@@ -1827,22 +1827,24 @@ class PostgresStoreService extends __1.StoreService {
1827
1827
  FROM target
1828
1828
  WHERE public.hmsh_escalations.id = target.id
1829
1829
  AND target.status = 'pending'
1830
- RETURNING public.hmsh_escalations.id
1830
+ RETURNING public.hmsh_escalations.*
1831
1831
  )
1832
1832
  SELECT t.id, t.status AS prior_status,
1833
1833
  CASE
1834
1834
  WHEN c.id IS NOT NULL THEN 'cancelled'
1835
1835
  WHEN t.id IS NULL THEN 'not-found'
1836
1836
  ELSE 'already-terminal'
1837
- END AS outcome
1837
+ END AS outcome,
1838
+ row_to_json(c.*) AS entry_json
1838
1839
  FROM (SELECT * FROM target) t
1839
- FULL OUTER JOIN (SELECT id FROM cancelled) c ON c.id = t.id
1840
+ FULL OUTER JOIN (SELECT * FROM cancelled) c ON c.id = t.id
1840
1841
  `, namespace ? [id, namespace] : [id]);
1841
1842
  if (!result.rows[0] || result.rows[0].outcome === 'not-found')
1842
1843
  return { ok: false, reason: 'not-found' };
1843
1844
  if (result.rows[0].outcome === 'already-terminal')
1844
1845
  return { ok: false, reason: 'already-terminal' };
1845
- return { ok: true };
1846
+ const entry = result.rows[0].entry_json;
1847
+ return { ok: true, entry };
1846
1848
  }
1847
1849
  async escalateEscalationToRole(params) {
1848
1850
  const { id, targetRole, namespace } = params;
@@ -1947,9 +1949,10 @@ class PostgresStoreService extends __1.StoreService {
1947
1949
  WHERE id = ANY($3::uuid[])
1948
1950
  ${namespace ? 'AND namespace = $4' : ''}
1949
1951
  AND status = 'pending'
1950
- AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $1)`, namespace ? [assignee, durationMinutes, ids, namespace] : [assignee, durationMinutes, ids]);
1951
- const claimed = result.rowCount ?? 0;
1952
- return { claimed, skipped: ids.length - claimed };
1952
+ AND (assigned_to IS NULL OR assigned_until IS NULL OR assigned_until <= NOW() OR assigned_to = $1)
1953
+ RETURNING *`, namespace ? [assignee, durationMinutes, ids, namespace] : [assignee, durationMinutes, ids]);
1954
+ const entries = result.rows;
1955
+ return { entries, skipped: ids.length - entries.length };
1953
1956
  }
1954
1957
  async escalateManyEscalationsToRole(params) {
1955
1958
  const { ids, namespace, targetRole } = params;
@@ -395,6 +395,12 @@ type WorkflowDataType = {
395
395
  type Connection = ProviderConfig | ProvidersConfig;
396
396
  type ClientConfig = {
397
397
  connection: Connection;
398
+ /**
399
+ * Optional system-event sink. When set, `client.escalations.*` operations
400
+ * call `events.publish` post-commit from the invoking process. Wires the
401
+ * same hook as `HotMeshConfig.events` for direct-client callers.
402
+ */
403
+ events?: import('./system_events').EventsConfig;
398
404
  };
399
405
  type Registry = {
400
406
  [key: string]: Function;
@@ -442,6 +448,11 @@ type WorkerConfig = {
442
448
  user: string;
443
449
  password: string;
444
450
  };
451
+ /**
452
+ * Optional system-event sink. When set, the worker fires `events.publish`
453
+ * on `system.worker.{taskQueue}.started` and `system.worker.{taskQueue}.stopped`.
454
+ */
455
+ events?: import('./system_events').EventsConfig;
445
456
  };
446
457
  type FindWhereQuery = {
447
458
  field: string;
@@ -106,6 +106,7 @@ export type ReleaseEscalationResult = {
106
106
  };
107
107
  export type CancelEscalationResult = {
108
108
  ok: true;
109
+ entry: EscalationEntry;
109
110
  } | {
110
111
  ok: false;
111
112
  reason: 'not-found' | 'already-terminal';
@@ -309,6 +309,23 @@ type HotMeshConfig = {
309
309
  taskQueue?: string;
310
310
  engine?: HotMeshEngine;
311
311
  workers?: HotMeshWorker[];
312
+ /**
313
+ * Optional system-event sink. When provided, the engine calls
314
+ * `events.publish` post-commit for each durable transition it performs
315
+ * (escalation lifecycle, engine start/stop, deploy). Fire-and-forget.
316
+ *
317
+ * @example
318
+ * ```typescript
319
+ * const hotMesh = await HotMesh.init({
320
+ * appId: 'myapp',
321
+ * engine: { connection: { class: Postgres, options: { ... } } },
322
+ * events: {
323
+ * publish: (e) => nats.publish(e.type, JSON.stringify(e)),
324
+ * },
325
+ * });
326
+ * ```
327
+ */
328
+ events?: import('./system_events').EventsConfig;
312
329
  };
313
330
  type HotMeshGraph = {
314
331
  /**
@@ -26,3 +26,4 @@ export { context, Context, Counter, Meter, metrics, propagation, SpanContext, Sp
26
26
  export { WorkListTaskType } from './task';
27
27
  export { TransitionMatch, TransitionRule, Transitions } from './transition';
28
28
  export { ConditionQueueConfig, EscalationEntry, ClaimEscalationResult, ClaimByMetadataResult, ReleaseEscalationResult, ResolveEscalationResult, CancelEscalationResult, ListEscalationsParams, CreateEscalationParams, UpdateEscalationParams, AppendMilestonesParams, ClaimEscalationParams, ClaimByMetadataParams, ReleaseEscalationParams, ResolveEscalationParams, ResolveByMetadataParams, EscalateToRoleParams, MigrateEscalationParams, } from './hmsh_escalations';
29
+ export { EscalationVerb, EngineVerb, WorkerVerb, SystemEvent, EventsConfig } from './system_events';
@@ -0,0 +1,178 @@
1
+ /**
2
+ * System-event emission surface for HotMesh lifecycle transitions.
3
+ *
4
+ * The performing actor — the one engine/SDK call that commits a durable
5
+ * transition — fires `EventsConfig.publish` exactly once, inline,
6
+ * post-commit. The hook is fire-and-forget; the SDK never awaits it.
7
+ *
8
+ * ## Ontology
9
+ *
10
+ * Every event has a canonical `type` string and a `data` payload.
11
+ * Consumers pattern-match on `type` and cherry-pick fields from `data`.
12
+ *
13
+ * ### Escalation lifecycle (`system.escalation.{id}.{verb}`)
14
+ *
15
+ * | verb | trigger | `data` shape |
16
+ * |-------------|----------------------------------------------------------|---------------------|
17
+ * | `created` | `client.create()` or hook Leg1 INSERT | full `EscalationEntry` row |
18
+ * | `claimed` | `client.claim()` / `client.claimByMetadata()` | full `EscalationEntry` row |
19
+ * | `released` | `client.release()` | full `EscalationEntry` row |
20
+ * | `reassigned`| `client.escalateToRole()` / role change | full `EscalationEntry` row |
21
+ * | `resolved` | `client.resolve()` / `client.resolveByMetadata()` | full `EscalationEntry` row |
22
+ * | `cancelled` | `client.cancel()` | full `EscalationEntry` row |
23
+ *
24
+ * ### Engine lifecycle (`system.engine.{appId}.{verb}`)
25
+ *
26
+ * | verb | trigger | `data` shape |
27
+ * |------------|----------------------------------|----------------------------------|
28
+ * | `started` | `HotMesh.init()` completes | `{ appId: string, guid: string }` |
29
+ * | `stopped` | `hotMesh.stop()` called | `{ appId: string, guid: string }` |
30
+ * | `deployed` | `kvTables.deploy()` completes | `{ appId: string }` |
31
+ *
32
+ * ### Worker lifecycle (`system.worker.{taskQueue}.{verb}`)
33
+ *
34
+ * | verb | trigger | `data` shape |
35
+ * |------------|----------------------------------|----------------------------------------------|
36
+ * | `started` | `Durable.Worker.create()` ready | `{ taskQueue: string, appId: string }` |
37
+ * | `stopped` | `worker.stop()` called | `{ taskQueue: string, appId: string }` |
38
+ *
39
+ * ## Registration — three construction sites
40
+ *
41
+ * **Site 1 — YAML DAG / hook Leg1 (escalation `created` + engine events):**
42
+ * ```typescript
43
+ * import { HotMesh } from '@hotmeshio/hotmesh';
44
+ * const hm = await HotMesh.init({
45
+ * appId: 'myapp',
46
+ * engine: { connection },
47
+ * events: { publish: (e) => bus.emit(e.type, e) },
48
+ * });
49
+ * ```
50
+ *
51
+ * **Site 2 — Standalone `EscalationClientService` (all 6 verbs):**
52
+ * ```typescript
53
+ * import { Escalations } from '@hotmeshio/hotmesh';
54
+ * const client = new Escalations.Client({
55
+ * connection,
56
+ * events: { publish: (e) => bus.emit(e.type, e) },
57
+ * });
58
+ * ```
59
+ *
60
+ * **Site 3 — `Durable.Client` (all 6 verbs via `.escalations`):**
61
+ * ```typescript
62
+ * import { Durable } from '@hotmeshio/hotmesh';
63
+ * const client = new Durable.Client({
64
+ * connection,
65
+ * events: { publish: (e) => bus.emit(e.type, e) },
66
+ * });
67
+ * // client.escalations.* operations now emit lifecycle events
68
+ * ```
69
+ *
70
+ * ## Event ID format
71
+ *
72
+ * `event_id` is collision-proof across recurrences:
73
+ * - Escalation: `${id}:${verb}:${updated_at_iso}` — claim→release→reclaim
74
+ * produces distinct IDs because `updated_at` changes on each transition.
75
+ * - Engine / worker: `${app_id_or_queue}:${verb}:${ts}`.
76
+ *
77
+ * ## Fire-and-forget contract
78
+ *
79
+ * The SDK wraps every `publish` call in
80
+ * `void Promise.resolve(publish(event)).catch(() => {})`.
81
+ * A slow or throwing `publish` implementation never blocks or fails the
82
+ * committed operation. Use an in-process buffer or async queue if you need
83
+ * back-pressure.
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * import { Durable } from '@hotmeshio/hotmesh';
88
+ * import { Client as Postgres } from 'pg';
89
+ *
90
+ * const client = new Durable.Client({
91
+ * connection: { class: Postgres, options: { connectionString: process.env.DATABASE_URL } },
92
+ * events: {
93
+ * publish: (event) => {
94
+ * if (event.type.endsWith('.created')) {
95
+ * // new escalation — route to the right team
96
+ * dispatch(event.data as EscalationEntry);
97
+ * }
98
+ * },
99
+ * },
100
+ * });
101
+ * ```
102
+ */
103
+ /** Verbs for escalation lifecycle transitions. */
104
+ export type EscalationVerb = 'created' | 'claimed' | 'released' | 'reassigned' | 'resolved' | 'cancelled';
105
+ /** Verbs for engine lifecycle transitions. */
106
+ export type EngineVerb = 'started' | 'stopped' | 'deployed';
107
+ /** Verbs for worker lifecycle transitions. */
108
+ export type WorkerVerb = 'started' | 'stopped';
109
+ /**
110
+ * Canonical lifecycle event emitted by the SDK post-commit.
111
+ *
112
+ * `event_id` is stable across replays:
113
+ * - Escalation transitions: `${id}:${verb}:${updated_at_iso}` — unique
114
+ * per transition; a re-claim after release gets a new `updated_at`.
115
+ * - Engine / worker events: `${app_id}:${verb}:${ts}`.
116
+ *
117
+ * `data` carries the full committed row (escalation entry) or lifecycle
118
+ * metadata (engine/worker). Consumers cherry-pick what they need; nothing
119
+ * is pre-projected so the shape is future-proof.
120
+ */
121
+ export interface SystemEvent {
122
+ /** Stable, unique ID per durable transition. */
123
+ event_id: string;
124
+ /**
125
+ * Canonical topic string.
126
+ *
127
+ * | Class | Pattern |
128
+ * |-------------|---------------------------------------|
129
+ * | escalation | `system.escalation.{id}.{verb}` |
130
+ * | engine | `system.engine.{appId}.{verb}` |
131
+ * | worker | `system.worker.{taskQueue}.{verb}` |
132
+ */
133
+ type: string;
134
+ /** ISO timestamp at emit time (wall-clock, post-commit). */
135
+ ts: string;
136
+ namespace: string;
137
+ app_id: string;
138
+ workflow_id?: string;
139
+ topic?: string;
140
+ origin_id?: string;
141
+ parent_id?: string;
142
+ trace_id?: string;
143
+ span_id?: string;
144
+ /**
145
+ * Full committed row for escalation events; lifecycle metadata for
146
+ * engine/worker events. Long-tail and hike-mono each cherry-pick fields
147
+ * for their own event shape.
148
+ */
149
+ data: Record<string, unknown>;
150
+ }
151
+ /**
152
+ * System-event sink configuration. Attach to `HotMeshConfig.events`,
153
+ * `EscalationClientConfig.events`, or `ClientConfig.events` to receive
154
+ * lifecycle events from the SDK.
155
+ *
156
+ * The SDK calls `publish` after each durable transition commits, from the
157
+ * single actor that performed the commit. In a multi-container fleet every
158
+ * container's SDK calls its own `publish` hook — and only for the work it
159
+ * performed — so exactly one container's `publish` fires per real event.
160
+ *
161
+ * @example
162
+ * ```typescript
163
+ * const events: EventsConfig = {
164
+ * publish: (event) => {
165
+ * // map SystemEvent → your LTEvent shape and hand to NATS/Socket.IO
166
+ * eventRegistry.publish(mapToLTEvent(event));
167
+ * },
168
+ * };
169
+ * ```
170
+ */
171
+ export interface EventsConfig {
172
+ /**
173
+ * Called post-commit by the performing actor. Fire-and-forget — the SDK
174
+ * does not await the return value; a thrown/rejected promise is silently
175
+ * swallowed so the committed call is never failed by a publish error.
176
+ */
177
+ publish: (event: SystemEvent) => void | Promise<void>;
178
+ }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+ /**
3
+ * System-event emission surface for HotMesh lifecycle transitions.
4
+ *
5
+ * The performing actor — the one engine/SDK call that commits a durable
6
+ * transition — fires `EventsConfig.publish` exactly once, inline,
7
+ * post-commit. The hook is fire-and-forget; the SDK never awaits it.
8
+ *
9
+ * ## Ontology
10
+ *
11
+ * Every event has a canonical `type` string and a `data` payload.
12
+ * Consumers pattern-match on `type` and cherry-pick fields from `data`.
13
+ *
14
+ * ### Escalation lifecycle (`system.escalation.{id}.{verb}`)
15
+ *
16
+ * | verb | trigger | `data` shape |
17
+ * |-------------|----------------------------------------------------------|---------------------|
18
+ * | `created` | `client.create()` or hook Leg1 INSERT | full `EscalationEntry` row |
19
+ * | `claimed` | `client.claim()` / `client.claimByMetadata()` | full `EscalationEntry` row |
20
+ * | `released` | `client.release()` | full `EscalationEntry` row |
21
+ * | `reassigned`| `client.escalateToRole()` / role change | full `EscalationEntry` row |
22
+ * | `resolved` | `client.resolve()` / `client.resolveByMetadata()` | full `EscalationEntry` row |
23
+ * | `cancelled` | `client.cancel()` | full `EscalationEntry` row |
24
+ *
25
+ * ### Engine lifecycle (`system.engine.{appId}.{verb}`)
26
+ *
27
+ * | verb | trigger | `data` shape |
28
+ * |------------|----------------------------------|----------------------------------|
29
+ * | `started` | `HotMesh.init()` completes | `{ appId: string, guid: string }` |
30
+ * | `stopped` | `hotMesh.stop()` called | `{ appId: string, guid: string }` |
31
+ * | `deployed` | `kvTables.deploy()` completes | `{ appId: string }` |
32
+ *
33
+ * ### Worker lifecycle (`system.worker.{taskQueue}.{verb}`)
34
+ *
35
+ * | verb | trigger | `data` shape |
36
+ * |------------|----------------------------------|----------------------------------------------|
37
+ * | `started` | `Durable.Worker.create()` ready | `{ taskQueue: string, appId: string }` |
38
+ * | `stopped` | `worker.stop()` called | `{ taskQueue: string, appId: string }` |
39
+ *
40
+ * ## Registration — three construction sites
41
+ *
42
+ * **Site 1 — YAML DAG / hook Leg1 (escalation `created` + engine events):**
43
+ * ```typescript
44
+ * import { HotMesh } from '@hotmeshio/hotmesh';
45
+ * const hm = await HotMesh.init({
46
+ * appId: 'myapp',
47
+ * engine: { connection },
48
+ * events: { publish: (e) => bus.emit(e.type, e) },
49
+ * });
50
+ * ```
51
+ *
52
+ * **Site 2 — Standalone `EscalationClientService` (all 6 verbs):**
53
+ * ```typescript
54
+ * import { Escalations } from '@hotmeshio/hotmesh';
55
+ * const client = new Escalations.Client({
56
+ * connection,
57
+ * events: { publish: (e) => bus.emit(e.type, e) },
58
+ * });
59
+ * ```
60
+ *
61
+ * **Site 3 — `Durable.Client` (all 6 verbs via `.escalations`):**
62
+ * ```typescript
63
+ * import { Durable } from '@hotmeshio/hotmesh';
64
+ * const client = new Durable.Client({
65
+ * connection,
66
+ * events: { publish: (e) => bus.emit(e.type, e) },
67
+ * });
68
+ * // client.escalations.* operations now emit lifecycle events
69
+ * ```
70
+ *
71
+ * ## Event ID format
72
+ *
73
+ * `event_id` is collision-proof across recurrences:
74
+ * - Escalation: `${id}:${verb}:${updated_at_iso}` — claim→release→reclaim
75
+ * produces distinct IDs because `updated_at` changes on each transition.
76
+ * - Engine / worker: `${app_id_or_queue}:${verb}:${ts}`.
77
+ *
78
+ * ## Fire-and-forget contract
79
+ *
80
+ * The SDK wraps every `publish` call in
81
+ * `void Promise.resolve(publish(event)).catch(() => {})`.
82
+ * A slow or throwing `publish` implementation never blocks or fails the
83
+ * committed operation. Use an in-process buffer or async queue if you need
84
+ * back-pressure.
85
+ *
86
+ * @example
87
+ * ```typescript
88
+ * import { Durable } from '@hotmeshio/hotmesh';
89
+ * import { Client as Postgres } from 'pg';
90
+ *
91
+ * const client = new Durable.Client({
92
+ * connection: { class: Postgres, options: { connectionString: process.env.DATABASE_URL } },
93
+ * events: {
94
+ * publish: (event) => {
95
+ * if (event.type.endsWith('.created')) {
96
+ * // new escalation — route to the right team
97
+ * dispatch(event.data as EscalationEntry);
98
+ * }
99
+ * },
100
+ * },
101
+ * });
102
+ * ```
103
+ */
104
+ Object.defineProperty(exports, "__esModule", { value: true });
package/index.ts CHANGED
@@ -52,3 +52,4 @@ export {
52
52
  };
53
53
 
54
54
  export * as Types from './types';
55
+ export type { EventsConfig, SystemEvent } from './types/system_events';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.22.3",
3
+ "version": "0.22.4",
4
4
  "description": "Durable Workflow",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -89,6 +89,7 @@
89
89
  "prove": "docker compose exec hotmesh npx vitest run tests/durable 2>&1 | tee /tmp/hmsh-durable.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-durable.txt | tail -5",
90
90
  "prove:escalations": "docker compose exec hotmesh npx vitest run tests/durable/escalations/postgres.test.ts 2>&1 | tee /tmp/hmsh-escalations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-escalations.txt | tail -5",
91
91
  "prove:migrations": "docker compose exec hotmesh npx vitest run tests/durable/migrations/postgres.test.ts 2>&1 | tee /tmp/hmsh-migrations.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-migrations.txt | tail -5",
92
+ "prove:events": "docker compose exec hotmesh npx vitest run tests/durable/events/postgres.test.ts 2>&1 | tee /tmp/hmsh-events.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-events.txt | tail -5",
92
93
  "prove:functional": "docker compose exec hotmesh npx vitest run tests/functional 2>&1 | tee /tmp/hmsh-functional.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-functional.txt | tail -5",
93
94
  "prove:all": "docker compose exec hotmesh npx vitest run tests/ 2>&1 | tee /tmp/hmsh-all.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-all.txt | tail -5",
94
95
  "prove:file": "f() { docker compose exec hotmesh npx vitest run \"$@\" 2>&1 | tee /tmp/hmsh-file.txt && grep -E 'FAIL|Tests |Files ' /tmp/hmsh-file.txt | tail -5; }; f"