@hotmeshio/hotmesh 0.21.1 → 0.22.1
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/README.md +12 -129
- package/build/modules/utils.d.ts +2 -0
- package/build/modules/utils.js +9 -1
- package/build/package.json +8 -2
- package/build/services/activities/hook.d.ts +178 -58
- package/build/services/activities/hook.js +244 -58
- package/build/services/activities/trigger.js +5 -1
- package/build/services/durable/client.d.ts +273 -67
- package/build/services/durable/client.js +351 -126
- package/build/services/durable/index.d.ts +7 -3
- package/build/services/durable/index.js +6 -0
- package/build/services/durable/schemas/factory.js +40 -0
- package/build/services/durable/worker.js +5 -28
- package/build/services/durable/workflow/condition.d.ts +69 -37
- package/build/services/durable/workflow/condition.js +70 -39
- package/build/services/hotmesh/index.d.ts +31 -4
- package/build/services/hotmesh/index.js +31 -4
- package/build/services/store/index.d.ts +1 -1
- package/build/services/store/providers/postgres/kvsql.d.ts +1 -1
- package/build/services/store/providers/postgres/kvtables.js +83 -122
- package/build/services/store/providers/postgres/kvtypes/hash/basic.d.ts +1 -1
- package/build/services/store/providers/postgres/kvtypes/hash/basic.js +8 -8
- package/build/services/store/providers/postgres/kvtypes/hash/index.d.ts +1 -1
- package/build/services/store/providers/postgres/postgres.d.ts +51 -188
- package/build/services/store/providers/postgres/postgres.js +542 -285
- package/build/types/activity.d.ts +2 -0
- package/build/types/hmsh_escalations.d.ts +240 -0
- package/build/types/index.d.ts +1 -1
- package/build/types/provider.d.ts +2 -0
- package/package.json +9 -2
- package/build/types/signal.d.ts +0 -147
- /package/build/types/{signal.js → hmsh_escalations.js} +0 -0
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ npm install @hotmeshio/hotmesh
|
|
|
14
14
|
- **Crash-safe execution** — Every step is a committed row. If the process dies, it picks up where it left off.
|
|
15
15
|
- **Distributed state machines** — Build stateful applications where every component can [fail and recover](https://github.com/hotmeshio/sdk-typescript/blob/main/services/collator/README.md).
|
|
16
16
|
- **AI and training pipelines** — Multi-step AI workloads where each stage is expensive and must not be repeated on failure. A crashed pipeline resumes from the last committed step, not from the beginning.
|
|
17
|
-
- **Human-in-the-loop
|
|
17
|
+
- **Human-in-the-loop workflows** — Workflow pauses that require external input are searchable, claimable rows in your database. Build approval queues, review flows, and AI handoffs without a separate task service.
|
|
18
18
|
|
|
19
19
|
## How it works in 30 seconds
|
|
20
20
|
|
|
@@ -167,35 +167,24 @@ const result = await Durable.workflow.executeChild({
|
|
|
167
167
|
**Signals** — pause a workflow until an external event arrives.
|
|
168
168
|
|
|
169
169
|
```typescript
|
|
170
|
-
// Basic: pause until any caller sends the matching signal
|
|
171
170
|
const approval = await Durable.workflow.condition<{ approved: boolean }>('manager-approval');
|
|
172
171
|
if (!approval.approved) return 'rejected';
|
|
173
172
|
```
|
|
174
173
|
|
|
175
|
-
|
|
174
|
+
**Human-in-the-loop** — When a workflow pauses waiting for input, it can write a searchable, claimable row to the database. External systems — dashboards, AI agents, APIs — query by role and metadata, claim ownership, and resolve to resume the workflow. The pause lives in `public.hmsh_escalations` alongside the rest of your workflow state.
|
|
176
175
|
|
|
177
176
|
```typescript
|
|
178
|
-
//
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
metadata: { orderId, region: 'us-west' }, // GIN-indexed; used for claimByMetadata queries
|
|
183
|
-
envelope: { formSchema: [...] }, // unindexed display context; safe for large blobs
|
|
184
|
-
});
|
|
185
|
-
// approval is false if a timeout was set and no signal arrived
|
|
186
|
-
if (approval === false) return 'timed-out';
|
|
187
|
-
if (!approval.approved) return 'rejected';
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
The optional first string argument is a timeout; the queue config can appear as the second or third argument:
|
|
191
|
-
|
|
192
|
-
```typescript
|
|
193
|
-
// With timeout AND queue config
|
|
194
|
-
const approval = await Durable.workflow.condition<{ approved: boolean }>(
|
|
195
|
-
'manager-approval',
|
|
196
|
-
'72 hours', // workflow times out if nobody acts
|
|
197
|
-
{ role: 'manager', metadata: { orderId } },
|
|
177
|
+
// workflow: pause and write a claimable row
|
|
178
|
+
const decision = await Durable.workflow.condition<{ approved: boolean }>(
|
|
179
|
+
'approval',
|
|
180
|
+
{ role: 'manager', type: 'order-review', metadata: { orderId } },
|
|
198
181
|
);
|
|
182
|
+
|
|
183
|
+
// elsewhere: find pending approvals, claim one, resolve it
|
|
184
|
+
const [pending] = await client.escalations.list({ role: 'manager', status: 'pending' });
|
|
185
|
+
await client.escalations.claim({ id: pending.id, assignee: 'alice@company.com' });
|
|
186
|
+
await client.escalations.resolve({ id: pending.id, resolverPayload: { approved: true } });
|
|
187
|
+
// the workflow resumes with { approved: true }
|
|
199
188
|
```
|
|
200
189
|
|
|
201
190
|
## Retries and error handling
|
|
@@ -271,112 +260,6 @@ const exported = await handle.export({ // selective export
|
|
|
271
260
|
});
|
|
272
261
|
```
|
|
273
262
|
|
|
274
|
-
## Signal queue (Postgres only)
|
|
275
|
-
|
|
276
|
-
When `condition()` is called with a `ConditionQueueConfig`, HotMesh atomically writes a row to `hotmesh_signals` inside the same Postgres transaction that suspends the workflow. The result is a durable, queryable task queue backed by the same database — no separate service, no double-write, no gap between "task created" and "workflow suspended".
|
|
277
|
-
|
|
278
|
-
**Workflow side** — declare what kind of task this suspension represents:
|
|
279
|
-
|
|
280
|
-
```typescript
|
|
281
|
-
// workflows/approval.ts
|
|
282
|
-
export async function orderApproval(orderId: string) {
|
|
283
|
-
const signalId = `approve-${orderId}`;
|
|
284
|
-
|
|
285
|
-
const result = await Durable.workflow.condition<{ approved: boolean; notes: string }>(
|
|
286
|
-
signalId,
|
|
287
|
-
{
|
|
288
|
-
role: 'pharmacist',
|
|
289
|
-
type: 'approval',
|
|
290
|
-
priority: 3,
|
|
291
|
-
description: `Review order ${orderId}`,
|
|
292
|
-
taskQueue: 'rx-approvals',
|
|
293
|
-
workflowType: 'orderApproval',
|
|
294
|
-
metadata: { orderId }, // GIN-indexed; query with claimByMetadata
|
|
295
|
-
envelope: { // not indexed; pass form schemas, display context
|
|
296
|
-
formSchema: [{ name: 'notes', type: 'textarea', required: true }],
|
|
297
|
-
},
|
|
298
|
-
},
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
if (result === false) return { status: 'timed-out' };
|
|
302
|
-
return result;
|
|
303
|
-
}
|
|
304
|
-
```
|
|
305
|
-
|
|
306
|
-
**Resolver side** — claim and resolve from any process (API handler, worker, dashboard):
|
|
307
|
-
|
|
308
|
-
```typescript
|
|
309
|
-
// resolver.ts
|
|
310
|
-
import { Durable } from '@hotmeshio/hotmesh';
|
|
311
|
-
|
|
312
|
-
const client = new Durable.Client({ connection });
|
|
313
|
-
|
|
314
|
-
// List all pending tasks for a role
|
|
315
|
-
const pending = await client.signalQueue.list({ role: 'pharmacist', status: 'pending' });
|
|
316
|
-
|
|
317
|
-
// Claim by metadata — atomic; concurrent workers get 'conflict', not a silent null
|
|
318
|
-
const claim = await client.signalQueue.claimByMetadata({
|
|
319
|
-
key: 'orderId',
|
|
320
|
-
value: orderId,
|
|
321
|
-
assignee: 'jane@example.com',
|
|
322
|
-
durationMinutes: 30,
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
if (!claim.ok) {
|
|
326
|
-
// claim.reason: 'not-found' (nothing queued) | 'conflict' (another worker beat you)
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Resolve — marks the DB record resolved AND delivers the signal to the paused workflow
|
|
331
|
-
const resolution = await client.signalQueue.resolve({
|
|
332
|
-
id: claim.entry.id,
|
|
333
|
-
resolverPayload: { approved: true, notes: 'LGTM' },
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
if (!resolution.ok) {
|
|
337
|
-
if (resolution.reason === 'signal-failed') {
|
|
338
|
-
// DB updated but workflow signal not delivered — retry with resolution.signalKey
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
```
|
|
342
|
-
|
|
343
|
-
**All `signalQueue` methods:**
|
|
344
|
-
|
|
345
|
-
| Method | Description |
|
|
346
|
-
|---|---|
|
|
347
|
-
| `list(params)` | List signals filtered by `role`, `status`, `taskQueue` |
|
|
348
|
-
| `get(id)` | Fetch a single signal record by UUID |
|
|
349
|
-
| `claim({ id, assignee?, durationMinutes? })` | Claim by record ID |
|
|
350
|
-
| `claimByMetadata({ key, value, assignee?, durationMinutes? })` | Claim by GIN-indexed metadata field |
|
|
351
|
-
| `release({ id })` | Return a claimed signal to `pending` |
|
|
352
|
-
| `releaseExpired()` | Sweep all past-expiry claims back to `pending` |
|
|
353
|
-
| `resolve({ id, resolverPayload? })` | Resolve by ID and deliver signal to the workflow |
|
|
354
|
-
| `resolveByMetadata({ key, value, resolverPayload? })` | Resolve by metadata field and deliver signal |
|
|
355
|
-
|
|
356
|
-
**Result types** — all mutating methods return a discriminated union so callers can handle every outcome without guessing what `null` meant:
|
|
357
|
-
|
|
358
|
-
```typescript
|
|
359
|
-
// Claim
|
|
360
|
-
type ClaimSignalResult =
|
|
361
|
-
| { ok: true; entry: SignalQueueEntry }
|
|
362
|
-
| { ok: false; reason: 'not-found' | 'conflict' };
|
|
363
|
-
|
|
364
|
-
// Release
|
|
365
|
-
type ReleaseSignalResult =
|
|
366
|
-
| { ok: true }
|
|
367
|
-
| { ok: false; reason: 'not-found' | 'wrong-status' };
|
|
368
|
-
|
|
369
|
-
// Resolve
|
|
370
|
-
type ResolveSignalResult =
|
|
371
|
-
| { ok: true }
|
|
372
|
-
| { ok: false; reason: 'not-found' | 'already-resolved' }
|
|
373
|
-
| { ok: false; reason: 'signal-failed'; signalKey: string };
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
`signal-failed` means the database record was updated but `workflow.signal()` threw (network blip, workflow already complete). The `signalKey` is returned so callers can retry signal delivery independently without re-resolving the record.
|
|
377
|
-
|
|
378
|
-
> **Postgres only.** The `signalQueue.*` methods require the Postgres provider. Workflows using `condition()` without a queue config are unaffected on any provider.
|
|
379
|
-
|
|
380
263
|
## Observability
|
|
381
264
|
|
|
382
265
|
There is no proprietary dashboard. Workflow state lives in Postgres, so use whatever tools you already have:
|
package/build/modules/utils.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export declare function getSystemHealth(): Promise<SystemHealth>;
|
|
|
13
13
|
export declare function deepCopy<T>(obj: T): T;
|
|
14
14
|
export declare function deterministicRandom(seed: number): number;
|
|
15
15
|
export declare function guid(size?: number): string;
|
|
16
|
+
/** Returns a standard RFC 4122 v4 UUID (e.g. for use as a DB primary key). */
|
|
17
|
+
export declare function uuid(): string;
|
|
16
18
|
export declare function sleepFor(ms: number): Promise<void>;
|
|
17
19
|
export declare function sleepImmediate(): Promise<void>;
|
|
18
20
|
export declare function XSleepFor(ms: number): {
|
package/build/modules/utils.js
CHANGED
|
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.arrayToHash = exports.isStreamMessage = exports.parseStreamMessage = exports.normalizeRetryPolicy = exports.s = exports.isValidCron = exports.restoreHierarchy = exports.getValueByPath = exports.getIndexedHash = exports.getSymVal = exports.getSymKey = exports.formatISODate = exports.getTimeSeries = exports.getSubscriptionTopic = exports.findSubscriptionForTrigger = exports.findTopKey = exports.matchesStatus = exports.matchesStatusCode = exports.polyfill = exports.identifyProvider = exports.XSleepFor = exports.sleepImmediate = exports.sleepFor = exports.guid = exports.deterministicRandom = exports.deepCopy = exports.getSystemHealth = exports.hashOptions = void 0;
|
|
6
|
+
exports.arrayToHash = exports.isStreamMessage = exports.parseStreamMessage = exports.normalizeRetryPolicy = exports.s = exports.isValidCron = exports.restoreHierarchy = exports.getValueByPath = exports.getIndexedHash = exports.getSymVal = exports.getSymKey = exports.formatISODate = exports.getTimeSeries = exports.getSubscriptionTopic = exports.findSubscriptionForTrigger = exports.findTopKey = exports.matchesStatus = exports.matchesStatusCode = exports.polyfill = exports.identifyProvider = exports.XSleepFor = exports.sleepImmediate = exports.sleepFor = exports.uuid = exports.guid = exports.deterministicRandom = exports.deepCopy = exports.getSystemHealth = exports.hashOptions = void 0;
|
|
7
7
|
const os_1 = __importDefault(require("os"));
|
|
8
8
|
const crypto_1 = require("crypto");
|
|
9
9
|
const cron_parser_1 = require("cron-parser");
|
|
@@ -47,6 +47,11 @@ function guid(size = enums_1.HMSH_GUID_SIZE) {
|
|
|
47
47
|
return `H` + (0, nanoid_1.nanoid)(size);
|
|
48
48
|
}
|
|
49
49
|
exports.guid = guid;
|
|
50
|
+
/** Returns a standard RFC 4122 v4 UUID (e.g. for use as a DB primary key). */
|
|
51
|
+
function uuid() {
|
|
52
|
+
return (0, crypto_1.randomUUID)();
|
|
53
|
+
}
|
|
54
|
+
exports.uuid = uuid;
|
|
50
55
|
async function sleepFor(ms) {
|
|
51
56
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
52
57
|
}
|
|
@@ -240,6 +245,9 @@ function getValueByPath(obj, path) {
|
|
|
240
245
|
const pathParts = path.split('/');
|
|
241
246
|
let currentValue = obj;
|
|
242
247
|
for (const part of pathParts) {
|
|
248
|
+
if (currentValue == null || typeof currentValue !== 'object') {
|
|
249
|
+
return undefined;
|
|
250
|
+
}
|
|
243
251
|
if (currentValue[part] !== undefined) {
|
|
244
252
|
currentValue = currentValue[part];
|
|
245
253
|
}
|
package/build/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hotmeshio/hotmesh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.1",
|
|
4
4
|
"description": "Durable Workflow",
|
|
5
5
|
"main": "./build/index.js",
|
|
6
6
|
"types": "./build/index.d.ts",
|
|
@@ -50,6 +50,7 @@
|
|
|
50
50
|
"test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
|
|
51
51
|
"test:durable:readonly": "docker compose --profile readonly up -d --build && docker compose exec hotmesh-readonly npx vitest run --config tests/durable/readonly/vitest.config.mts",
|
|
52
52
|
"test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
|
|
53
|
+
"test:durable:escalations": "HMSH_LOGLEVEL=info vitest run tests/durable/escalations",
|
|
53
54
|
"test:durable:exporter": "HMSH_LOGLEVEL=info vitest run tests/durable/exporter",
|
|
54
55
|
"test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
|
|
55
56
|
"test:durable:codec": "vitest run tests/durable/codec/postgres.test.ts",
|
|
@@ -84,7 +85,12 @@
|
|
|
84
85
|
"test:sub:nats": "vitest run tests/functional/sub/providers/nats/nats.test.ts",
|
|
85
86
|
"test:trigger": "vitest run tests/unit/services/activities/trigger.test.ts",
|
|
86
87
|
"test:virtual": "vitest run tests/virtual",
|
|
87
|
-
"test:unit": "vitest run tests/unit"
|
|
88
|
+
"test:unit": "vitest run tests/unit",
|
|
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
|
+
"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
|
+
"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",
|
|
92
|
+
"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",
|
|
93
|
+
"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"
|
|
88
94
|
},
|
|
89
95
|
"keywords": [
|
|
90
96
|
"Invisible Infrastructure",
|
|
@@ -6,54 +6,79 @@ import { ProviderTransaction } from '../../types/provider';
|
|
|
6
6
|
import { StreamCode, StreamStatus } from '../../types/stream';
|
|
7
7
|
import { Activity } from './activity';
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* (immediate transition with optional data mapping).
|
|
9
|
+
* The most flexible activity type in the HotMesh YAML DAG. Depending on
|
|
10
|
+
* its configuration it operates as one of four distinct flavors:
|
|
12
11
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* | Flavor | Key field | Behavior |
|
|
13
|
+
* |---|---|---|
|
|
14
|
+
* | **Time** | `sleep` | Pauses for a duration, then resumes |
|
|
15
|
+
* | **Signal** | `hook.topic` | Pauses until an external signal arrives |
|
|
16
|
+
* | **Cycle** | `cycle: true` | Passthrough that also accepts re-entry from a `cycle` activity |
|
|
17
|
+
* | **Passthrough** | _(none)_ | Maps data and transitions immediately |
|
|
15
18
|
*
|
|
16
|
-
*
|
|
19
|
+
* ---
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
+
* ## Flavor 1 — Time Hook (sleep)
|
|
22
|
+
*
|
|
23
|
+
* Pauses the flow for `sleep` seconds. The value can be a literal number
|
|
24
|
+
* or a `@pipe` expression (e.g., for exponential backoff).
|
|
21
25
|
*
|
|
22
26
|
* ```yaml
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* graphs:
|
|
27
|
-
* - subscribes: job.start
|
|
28
|
-
* expire: 300
|
|
27
|
+
* activities:
|
|
28
|
+
* t1:
|
|
29
|
+
* type: trigger
|
|
29
30
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
31
|
+
* wait_30s:
|
|
32
|
+
* type: hook
|
|
33
|
+
* sleep: 30 # pause for 30 seconds
|
|
34
|
+
* job:
|
|
35
|
+
* maps:
|
|
36
|
+
* paused_at: '{$self.output.metadata.ac}'
|
|
33
37
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* sleep: 60 # pause for 60 seconds
|
|
37
|
-
* job:
|
|
38
|
-
* maps:
|
|
39
|
-
* paused_at: '{$self.output.metadata.ac}'
|
|
38
|
+
* next_step:
|
|
39
|
+
* type: hook
|
|
40
40
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
41
|
+
* transitions:
|
|
42
|
+
* t1:
|
|
43
|
+
* - to: wait_30s
|
|
44
|
+
* wait_30s:
|
|
45
|
+
* - to: next_step
|
|
46
|
+
* ```
|
|
43
47
|
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
48
|
+
* Dynamic delay with `@pipe` (exponential backoff on retry):
|
|
49
|
+
*
|
|
50
|
+
* ```yaml
|
|
51
|
+
* wait_retry:
|
|
52
|
+
* type: hook
|
|
53
|
+
* sleep:
|
|
54
|
+
* '@pipe':
|
|
55
|
+
* - ['{$self.output.data.attempt}', 2]
|
|
56
|
+
* - ['{@math.pow}'] # 1, 2, 4, 8, 16 …
|
|
49
57
|
* ```
|
|
50
58
|
*
|
|
51
|
-
*
|
|
59
|
+
* ---
|
|
52
60
|
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
61
|
+
* ## Flavor 2 — Signal Hook (webhook)
|
|
62
|
+
*
|
|
63
|
+
* Registers a listener on a named topic. The flow pauses until an
|
|
64
|
+
* external caller delivers a signal. Signal data is available as
|
|
65
|
+
* `$self.hook.data`. The graph-level `hooks` section routes incoming
|
|
66
|
+
* signals to the waiting activity via a `conditions.match` rule.
|
|
67
|
+
*
|
|
68
|
+
* **Send the signal** from any process:
|
|
69
|
+
*
|
|
70
|
+
* ```typescript
|
|
71
|
+
* await hotMesh.signal('order.approval', { id: jobId, approved: true });
|
|
72
|
+
* ```
|
|
73
|
+
*
|
|
74
|
+
* **Claim and delete** a pending signal via the collator key:
|
|
75
|
+
*
|
|
76
|
+
* ```typescript
|
|
77
|
+
* // Ack/delete — deliver a signal and clear the hook registration
|
|
78
|
+
* await hotMesh.signal('order.approval', { id: jobId, approved: false });
|
|
79
|
+
* ```
|
|
80
|
+
*
|
|
81
|
+
* **YAML configuration:**
|
|
57
82
|
*
|
|
58
83
|
* ```yaml
|
|
59
84
|
* app:
|
|
@@ -87,20 +112,78 @@ import { Activity } from './activity';
|
|
|
87
112
|
* - to: done
|
|
88
113
|
*
|
|
89
114
|
* hooks:
|
|
90
|
-
* order.approval:
|
|
115
|
+
* order.approval: # topic delivering the signal
|
|
91
116
|
* - to: wait_for_approval
|
|
92
117
|
* conditions:
|
|
93
118
|
* match:
|
|
94
|
-
* - expected: '{t1.output.data.id}'
|
|
95
|
-
* actual: '{$self.hook.data.id}'
|
|
119
|
+
* - expected: '{t1.output.data.id}' # job ID
|
|
120
|
+
* actual: '{$self.hook.data.id}' # signal payload ID
|
|
121
|
+
* ```
|
|
122
|
+
*
|
|
123
|
+
* ### Signal Hook with Escalation
|
|
124
|
+
*
|
|
125
|
+
* Adding an `escalation:` block causes the hook activity to write one row
|
|
126
|
+
* to `public.hmsh_escalations` atomically inside its Leg 1 transaction —
|
|
127
|
+
* the same database commit that checkpoints job state. The row is
|
|
128
|
+
* immediately queryable and claimable by any external system.
|
|
129
|
+
*
|
|
130
|
+
* All field values support `@pipe` expressions so they can reference job
|
|
131
|
+
* data computed by earlier activities (e.g., `'{t1.output.data.region}'`).
|
|
132
|
+
*
|
|
133
|
+
* ```yaml
|
|
134
|
+
* wait_for_approval:
|
|
135
|
+
* type: hook
|
|
136
|
+
* hook:
|
|
137
|
+
* type: object
|
|
138
|
+
* properties:
|
|
139
|
+
* approved: { type: boolean }
|
|
140
|
+
* escalation:
|
|
141
|
+
* role: manager # RBAC role that should act
|
|
142
|
+
* type: order-approval
|
|
143
|
+
* subtype: regional
|
|
144
|
+
* priority: 2 # lower = higher priority
|
|
145
|
+
* description: Approve or reject the order
|
|
146
|
+
* entity: '{t1.output.data.entityType}'
|
|
147
|
+
* metadata:
|
|
148
|
+
* orderId: '{t1.output.data.orderId}'
|
|
149
|
+
* region: '{t1.output.data.region}'
|
|
150
|
+
* envelope:
|
|
151
|
+
* instructions: Review the attached order and approve or reject
|
|
152
|
+
* expiresAt: '{t1.output.data.dueDate}'
|
|
153
|
+
* job:
|
|
154
|
+
* maps:
|
|
155
|
+
* approved: '{$self.hook.data.approved}'
|
|
156
|
+
* ```
|
|
157
|
+
*
|
|
158
|
+
* **Claim and resolve the escalation** (resumes the waiting workflow):
|
|
159
|
+
*
|
|
160
|
+
* ```typescript
|
|
161
|
+
* // Find pending approvals for the manager role
|
|
162
|
+
* const [item] = await client.escalations.list({ role: 'manager', status: 'pending' });
|
|
163
|
+
*
|
|
164
|
+
* // Claim it (sets assigned_to + claim_expires_at)
|
|
165
|
+
* const claim = await client.escalations.claim({
|
|
166
|
+
* id: item.id,
|
|
167
|
+
* assignee: 'alice@company.com',
|
|
168
|
+
* durationMinutes: 30,
|
|
169
|
+
* });
|
|
170
|
+
*
|
|
171
|
+
* // Resolve atomically delivers the signal and resumes the workflow
|
|
172
|
+
* await client.escalations.resolve({
|
|
173
|
+
* id: item.id,
|
|
174
|
+
* resolverPayload: { approved: true },
|
|
175
|
+
* });
|
|
96
176
|
* ```
|
|
97
177
|
*
|
|
98
|
-
*
|
|
178
|
+
* ---
|
|
179
|
+
*
|
|
180
|
+
* ## Flavor 3 — Cycle Pivot
|
|
99
181
|
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
182
|
+
* A passthrough hook with `cycle: true` acts as the named re-entry point
|
|
183
|
+
* for a `cycle` activity. On first entry it behaves identically to a
|
|
184
|
+
* passthrough (maps data, transitions forward). When a `cycle` activity
|
|
185
|
+
* downstream names it as its `ancestor`, the engine routes execution back
|
|
186
|
+
* to it, allowing a controlled loop without spawning a new job.
|
|
104
187
|
*
|
|
105
188
|
* ```yaml
|
|
106
189
|
* app:
|
|
@@ -108,6 +191,7 @@ import { Activity } from './activity';
|
|
|
108
191
|
* version: '1'
|
|
109
192
|
* graphs:
|
|
110
193
|
* - subscribes: job.start
|
|
194
|
+
* expire: 120
|
|
111
195
|
*
|
|
112
196
|
* activities:
|
|
113
197
|
* t1:
|
|
@@ -115,34 +199,69 @@ import { Activity } from './activity';
|
|
|
115
199
|
*
|
|
116
200
|
* pivot:
|
|
117
201
|
* type: hook
|
|
118
|
-
* cycle: true
|
|
202
|
+
* cycle: true # marks this as a loop re-entry point
|
|
203
|
+
*
|
|
204
|
+
* do_work:
|
|
205
|
+
* type: worker
|
|
206
|
+
* topic: work.process
|
|
119
207
|
* output:
|
|
120
|
-
*
|
|
121
|
-
*
|
|
208
|
+
* schema:
|
|
209
|
+
* type: object
|
|
210
|
+
* properties:
|
|
211
|
+
* counter: { type: number }
|
|
122
212
|
* job:
|
|
123
213
|
* maps:
|
|
124
|
-
* counter: '{$self.output.data.
|
|
214
|
+
* counter: '{$self.output.data.counter}'
|
|
125
215
|
*
|
|
126
|
-
*
|
|
127
|
-
* type:
|
|
128
|
-
*
|
|
216
|
+
* loop_back:
|
|
217
|
+
* type: cycle
|
|
218
|
+
* ancestor: pivot # jumps back to `pivot` when condition holds
|
|
129
219
|
*
|
|
130
220
|
* transitions:
|
|
131
221
|
* t1:
|
|
132
222
|
* - to: pivot
|
|
133
223
|
* pivot:
|
|
134
224
|
* - to: do_work
|
|
225
|
+
* do_work:
|
|
226
|
+
* - to: loop_back
|
|
227
|
+
* conditions:
|
|
228
|
+
* match:
|
|
229
|
+
* - expected: true
|
|
230
|
+
* actual:
|
|
231
|
+
* '@pipe':
|
|
232
|
+
* - ['{do_work.output.data.counter}', 5]
|
|
233
|
+
* - ['{@conditional.less_than}']
|
|
135
234
|
* ```
|
|
136
235
|
*
|
|
236
|
+
* ---
|
|
237
|
+
*
|
|
238
|
+
* ## Flavor 4 — Passthrough
|
|
239
|
+
*
|
|
240
|
+
* When none of `sleep`, `hook`, or `cycle` is set, the hook activity
|
|
241
|
+
* immediately maps data and transitions to its children. Useful as a
|
|
242
|
+
* data transformation node or fan-in convergence point.
|
|
243
|
+
*
|
|
244
|
+
* ```yaml
|
|
245
|
+
* merge:
|
|
246
|
+
* type: hook
|
|
247
|
+
* output:
|
|
248
|
+
* maps:
|
|
249
|
+
* total: '{a1.output.data.subtotal}' # copy field into activity output
|
|
250
|
+
* job:
|
|
251
|
+
* maps:
|
|
252
|
+
* total: '{$self.output.data.total}' # promote to job-level data
|
|
253
|
+
* ```
|
|
254
|
+
*
|
|
255
|
+
* ---
|
|
256
|
+
*
|
|
137
257
|
* ## Execution Model
|
|
138
258
|
*
|
|
139
|
-
* - **
|
|
140
|
-
* hook
|
|
141
|
-
* external signal arrives
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
* to adjacent activities.
|
|
259
|
+
* - **Time and Signal flavors** — Category A (duplex). Leg 1 registers the
|
|
260
|
+
* hook (timer or webhook), saves state, and commits. Leg 2 fires when the
|
|
261
|
+
* timer fires or the external signal arrives.
|
|
262
|
+
* - **Cycle and Passthrough flavors** — Category B. Uses the crash-safe
|
|
263
|
+
* `executeLeg1StepProtocol` (GUID ledger backed) to map data and
|
|
264
|
+
* immediately transition to adjacent activities.
|
|
146
265
|
*
|
|
147
266
|
* @see {@link HookActivity} for the TypeScript interface
|
|
148
267
|
*/
|
|
@@ -152,6 +271,7 @@ declare class Hook extends Activity {
|
|
|
152
271
|
isConfiguredAsHook(): boolean;
|
|
153
272
|
doesHook(): boolean;
|
|
154
273
|
doHook(telemetry: TelemetryService): Promise<void>;
|
|
274
|
+
private addEscalationToTransaction;
|
|
155
275
|
/**
|
|
156
276
|
* Re-publishes a pending signal as a WEBHOOK stream message so the
|
|
157
277
|
* normal leg2 dispatch path processes it. Called when leg1's
|