@hotmeshio/hotmesh 0.10.1 → 0.10.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Permanent-Memory Workflows & AI Agents",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -46,6 +46,8 @@
46
46
  "test:durable:sleep": "vitest run tests/durable/sleep/postgres.test.ts",
47
47
  "test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
48
48
  "test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
49
+ "test:durable:exporter": "vitest run tests/durable/exporter/exporter.test.ts",
50
+ "test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
49
51
  "test:dba": "vitest run tests/dba",
50
52
  "test:cycle": "vitest run tests/functional/cycle",
51
53
  "test:functional": "vitest run tests/functional",
@@ -11,7 +11,7 @@ import { PostgresClientType } from '../../types/postgres';
11
11
  * | Table | What accumulates |
12
12
  * |---|---|
13
13
  * | `{appId}.jobs` | Completed/expired jobs with `expired_at` set |
14
- * | `{appId}.jobs_attributes` | Execution artifacts (`adata`, `hmark`, `jmark`, `status`, `other`) that are only needed during workflow execution |
14
+ * | `{appId}.jobs_attributes` | Execution artifacts (`adata`, `hmark`, `status`, `other`) that are only needed during workflow execution |
15
15
  * | `{appId}.streams` | Processed stream messages with `expired_at` set |
16
16
  *
17
17
  * The `DBA` service addresses this with two methods:
@@ -22,10 +22,29 @@ import { PostgresClientType } from '../../types/postgres';
22
22
  * - {@link DBA.deploy | deploy()} — Pre-deploys the Postgres function
23
23
  * (e.g., during CI/CD migrations) without running a prune.
24
24
  *
25
- * ## Independent cron schedules (TypeScript)
25
+ * ## Attribute stripping preserves export history
26
+ *
27
+ * Stripping removes `adata`, `hmark`, `status`, and `other` attributes
28
+ * from completed jobs while preserving:
29
+ * - `jdata` — workflow return data
30
+ * - `udata` — user-searchable data
31
+ * - `jmark` — timeline markers needed for Temporal-compatible export
32
+ *
33
+ * Set `keepHmark: true` to also preserve `hmark` (activity state markers).
34
+ *
35
+ * ## Entity-scoped pruning
26
36
  *
27
- * Each table can be targeted independently, allowing different retention
28
- * windows and schedules:
37
+ * Use `entities` to restrict pruning/stripping to specific entity types
38
+ * (e.g., `['book', 'author']`). Use `pruneTransient` to delete expired
39
+ * jobs with no entity (`entity IS NULL`).
40
+ *
41
+ * ## Idempotent stripping with `pruned_at`
42
+ *
43
+ * After stripping, jobs are marked with `pruned_at = NOW()`. Subsequent
44
+ * prune calls skip already-pruned jobs, making the operation idempotent
45
+ * and efficient for repeated scheduling.
46
+ *
47
+ * ## Independent cron schedules (TypeScript)
29
48
  *
30
49
  * @example
31
50
  * ```typescript
@@ -38,7 +57,6 @@ import { PostgresClientType } from '../../types/postgres';
38
57
  * };
39
58
  *
40
59
  * // Cron 1 — Nightly: strip execution artifacts from completed jobs
41
- * // Keeps all jobs and their jdata/udata; keeps all streams.
42
60
  * await DBA.prune({
43
61
  * appId: 'myapp', connection,
44
62
  * jobs: false, streams: false, attributes: true,
@@ -51,29 +69,34 @@ import { PostgresClientType } from '../../types/postgres';
51
69
  * jobs: false, streams: true,
52
70
  * });
53
71
  *
54
- * // Cron 3 — Weekly: remove expired jobs older than 30 days
72
+ * // Cron 3 — Weekly: remove expired 'book' jobs older than 30 days
55
73
  * await DBA.prune({
56
74
  * appId: 'myapp', connection,
57
75
  * expire: '30 days',
58
76
  * jobs: true, streams: false,
77
+ * entities: ['book'],
78
+ * });
79
+ *
80
+ * // Cron 4 — Weekly: remove transient (no entity) expired jobs
81
+ * await DBA.prune({
82
+ * appId: 'myapp', connection,
83
+ * expire: '7 days',
84
+ * jobs: false, streams: false,
85
+ * pruneTransient: true,
59
86
  * });
60
87
  * ```
61
88
  *
62
89
  * ## Direct SQL (schedulable via pg_cron)
63
90
  *
64
91
  * The underlying Postgres function can be called directly, without
65
- * the TypeScript SDK. Schedule it via `pg_cron`, `crontab`, or any
66
- * SQL client:
92
+ * the TypeScript SDK. The first 4 parameters are backwards-compatible:
67
93
  *
68
94
  * ```sql
69
95
  * -- Strip attributes only (keep all jobs and streams)
70
96
  * SELECT * FROM myapp.prune('0 seconds', false, false, true);
71
97
  *
72
- * -- Prune streams older than 24 hours (keep jobs)
73
- * SELECT * FROM myapp.prune('24 hours', false, true, false);
74
- *
75
- * -- Prune expired jobs older than 30 days (keep streams)
76
- * SELECT * FROM myapp.prune('30 days', true, false, false);
98
+ * -- Prune only 'book' entity jobs older than 30 days
99
+ * SELECT * FROM myapp.prune('30 days', true, false, false, ARRAY['book']);
77
100
  *
78
101
  * -- Prune everything older than 7 days and strip attributes
79
102
  * SELECT * FROM myapp.prune('7 days', true, true, true);
@@ -98,6 +121,12 @@ declare class DBA {
98
121
  client: PostgresClientType;
99
122
  release: () => Promise<void>;
100
123
  }>;
124
+ /**
125
+ * Returns migration SQL for the `pruned_at` column.
126
+ * Handles existing deployments that lack the column.
127
+ * @private
128
+ */
129
+ static getMigrationSQL(schema: string): string;
101
130
  /**
102
131
  * Returns the SQL for the server-side `prune()` function.
103
132
  * @private
@@ -105,7 +134,8 @@ declare class DBA {
105
134
  static getPruneFunctionSQL(schema: string): string;
106
135
  /**
107
136
  * Deploys the `prune()` Postgres function into the target schema.
108
- * Idempotent uses `CREATE OR REPLACE` and can be called repeatedly.
137
+ * Also runs schema migrations (e.g., adding `pruned_at` column).
138
+ * Idempotent — uses `CREATE OR REPLACE` and `IF NOT EXISTS`.
109
139
  *
110
140
  * The function is automatically deployed when {@link DBA.prune} is called,
111
141
  * but this method is exposed for explicit control (e.g., CI/CD
@@ -138,12 +168,15 @@ declare class DBA {
138
168
  *
139
169
  * Operations (each enabled individually):
140
170
  * 1. **jobs** — Hard-deletes expired jobs older than the retention
141
- * window (FK CASCADE removes their attributes automatically)
171
+ * window (FK CASCADE removes their attributes automatically).
172
+ * Scoped by `entities` when set.
142
173
  * 2. **streams** — Hard-deletes expired stream messages older than
143
174
  * the retention window
144
175
  * 3. **attributes** — Strips non-essential attributes (`adata`,
145
- * `hmark`, `jmark`, `status`, `other`) from completed jobs,
146
- * retaining only `jdata` and `udata`
176
+ * `hmark`, `status`, `other`) from completed, un-pruned jobs.
177
+ * Preserves `jdata`, `udata`, and `jmark`. Marks stripped
178
+ * jobs with `pruned_at` for idempotency.
179
+ * 4. **pruneTransient** — Deletes expired jobs with `entity IS NULL`
147
180
  *
148
181
  * @param options - Prune configuration
149
182
  * @returns Counts of deleted/stripped rows
@@ -153,7 +186,7 @@ declare class DBA {
153
186
  * import { Client as Postgres } from 'pg';
154
187
  * import { DBA } from '@hotmeshio/hotmesh';
155
188
  *
156
- * // Strip attributes only keep all jobs and streams
189
+ * // Strip attributes from 'book' entities only
157
190
  * await DBA.prune({
158
191
  * appId: 'myapp',
159
192
  * connection: {
@@ -163,6 +196,7 @@ declare class DBA {
163
196
  * jobs: false,
164
197
  * streams: false,
165
198
  * attributes: true,
199
+ * entities: ['book'],
166
200
  * });
167
201
  * ```
168
202
  */
@@ -14,7 +14,7 @@ const postgres_1 = require("../connector/providers/postgres");
14
14
  * | Table | What accumulates |
15
15
  * |---|---|
16
16
  * | `{appId}.jobs` | Completed/expired jobs with `expired_at` set |
17
- * | `{appId}.jobs_attributes` | Execution artifacts (`adata`, `hmark`, `jmark`, `status`, `other`) that are only needed during workflow execution |
17
+ * | `{appId}.jobs_attributes` | Execution artifacts (`adata`, `hmark`, `status`, `other`) that are only needed during workflow execution |
18
18
  * | `{appId}.streams` | Processed stream messages with `expired_at` set |
19
19
  *
20
20
  * The `DBA` service addresses this with two methods:
@@ -25,10 +25,29 @@ const postgres_1 = require("../connector/providers/postgres");
25
25
  * - {@link DBA.deploy | deploy()} — Pre-deploys the Postgres function
26
26
  * (e.g., during CI/CD migrations) without running a prune.
27
27
  *
28
- * ## Independent cron schedules (TypeScript)
28
+ * ## Attribute stripping preserves export history
29
+ *
30
+ * Stripping removes `adata`, `hmark`, `status`, and `other` attributes
31
+ * from completed jobs while preserving:
32
+ * - `jdata` — workflow return data
33
+ * - `udata` — user-searchable data
34
+ * - `jmark` — timeline markers needed for Temporal-compatible export
35
+ *
36
+ * Set `keepHmark: true` to also preserve `hmark` (activity state markers).
37
+ *
38
+ * ## Entity-scoped pruning
29
39
  *
30
- * Each table can be targeted independently, allowing different retention
31
- * windows and schedules:
40
+ * Use `entities` to restrict pruning/stripping to specific entity types
41
+ * (e.g., `['book', 'author']`). Use `pruneTransient` to delete expired
42
+ * jobs with no entity (`entity IS NULL`).
43
+ *
44
+ * ## Idempotent stripping with `pruned_at`
45
+ *
46
+ * After stripping, jobs are marked with `pruned_at = NOW()`. Subsequent
47
+ * prune calls skip already-pruned jobs, making the operation idempotent
48
+ * and efficient for repeated scheduling.
49
+ *
50
+ * ## Independent cron schedules (TypeScript)
32
51
  *
33
52
  * @example
34
53
  * ```typescript
@@ -41,7 +60,6 @@ const postgres_1 = require("../connector/providers/postgres");
41
60
  * };
42
61
  *
43
62
  * // Cron 1 — Nightly: strip execution artifacts from completed jobs
44
- * // Keeps all jobs and their jdata/udata; keeps all streams.
45
63
  * await DBA.prune({
46
64
  * appId: 'myapp', connection,
47
65
  * jobs: false, streams: false, attributes: true,
@@ -54,29 +72,34 @@ const postgres_1 = require("../connector/providers/postgres");
54
72
  * jobs: false, streams: true,
55
73
  * });
56
74
  *
57
- * // Cron 3 — Weekly: remove expired jobs older than 30 days
75
+ * // Cron 3 — Weekly: remove expired 'book' jobs older than 30 days
58
76
  * await DBA.prune({
59
77
  * appId: 'myapp', connection,
60
78
  * expire: '30 days',
61
79
  * jobs: true, streams: false,
80
+ * entities: ['book'],
81
+ * });
82
+ *
83
+ * // Cron 4 — Weekly: remove transient (no entity) expired jobs
84
+ * await DBA.prune({
85
+ * appId: 'myapp', connection,
86
+ * expire: '7 days',
87
+ * jobs: false, streams: false,
88
+ * pruneTransient: true,
62
89
  * });
63
90
  * ```
64
91
  *
65
92
  * ## Direct SQL (schedulable via pg_cron)
66
93
  *
67
94
  * The underlying Postgres function can be called directly, without
68
- * the TypeScript SDK. Schedule it via `pg_cron`, `crontab`, or any
69
- * SQL client:
95
+ * the TypeScript SDK. The first 4 parameters are backwards-compatible:
70
96
  *
71
97
  * ```sql
72
98
  * -- Strip attributes only (keep all jobs and streams)
73
99
  * SELECT * FROM myapp.prune('0 seconds', false, false, true);
74
100
  *
75
- * -- Prune streams older than 24 hours (keep jobs)
76
- * SELECT * FROM myapp.prune('24 hours', false, true, false);
77
- *
78
- * -- Prune expired jobs older than 30 days (keep streams)
79
- * SELECT * FROM myapp.prune('30 days', true, false, false);
101
+ * -- Prune only 'book' entity jobs older than 30 days
102
+ * SELECT * FROM myapp.prune('30 days', true, false, false, ARRAY['book']);
80
103
  *
81
104
  * -- Prune everything older than 7 days and strip attributes
82
105
  * SELECT * FROM myapp.prune('7 days', true, true, true);
@@ -121,6 +144,20 @@ class DBA {
121
144
  release: async () => { },
122
145
  };
123
146
  }
147
+ /**
148
+ * Returns migration SQL for the `pruned_at` column.
149
+ * Handles existing deployments that lack the column.
150
+ * @private
151
+ */
152
+ static getMigrationSQL(schema) {
153
+ return `
154
+ ALTER TABLE ${schema}.jobs
155
+ ADD COLUMN IF NOT EXISTS pruned_at TIMESTAMP WITH TIME ZONE;
156
+
157
+ CREATE INDEX IF NOT EXISTS idx_jobs_pruned_at
158
+ ON ${schema}.jobs (pruned_at) WHERE pruned_at IS NULL;
159
+ `;
160
+ }
124
161
  /**
125
162
  * Returns the SQL for the server-side `prune()` function.
126
163
  * @private
@@ -131,12 +168,17 @@ class DBA {
131
168
  retention INTERVAL DEFAULT INTERVAL '7 days',
132
169
  prune_jobs BOOLEAN DEFAULT TRUE,
133
170
  prune_streams BOOLEAN DEFAULT TRUE,
134
- strip_attributes BOOLEAN DEFAULT FALSE
171
+ strip_attributes BOOLEAN DEFAULT FALSE,
172
+ entity_list TEXT[] DEFAULT NULL,
173
+ prune_transient BOOLEAN DEFAULT FALSE,
174
+ keep_hmark BOOLEAN DEFAULT FALSE
135
175
  )
136
176
  RETURNS TABLE(
137
177
  deleted_jobs BIGINT,
138
178
  deleted_streams BIGINT,
139
- stripped_attributes BIGINT
179
+ stripped_attributes BIGINT,
180
+ deleted_transient BIGINT,
181
+ marked_pruned BIGINT
140
182
  )
141
183
  LANGUAGE plpgsql
142
184
  AS $$
@@ -144,17 +186,30 @@ class DBA {
144
186
  v_deleted_jobs BIGINT := 0;
145
187
  v_deleted_streams BIGINT := 0;
146
188
  v_stripped_attributes BIGINT := 0;
189
+ v_deleted_transient BIGINT := 0;
190
+ v_marked_pruned BIGINT := 0;
147
191
  BEGIN
148
192
  -- 1. Hard-delete expired jobs older than the retention window.
149
193
  -- FK CASCADE on jobs_attributes handles attribute cleanup.
194
+ -- Optionally scoped to an entity allowlist.
150
195
  IF prune_jobs THEN
151
196
  DELETE FROM ${schema}.jobs
152
197
  WHERE expired_at IS NOT NULL
153
- AND expired_at < NOW() - retention;
198
+ AND expired_at < NOW() - retention
199
+ AND (entity_list IS NULL OR entity = ANY(entity_list));
154
200
  GET DIAGNOSTICS v_deleted_jobs = ROW_COUNT;
155
201
  END IF;
156
202
 
157
- -- 2. Hard-delete expired stream messages older than the retention window.
203
+ -- 2. Hard-delete transient (entity IS NULL) expired jobs.
204
+ IF prune_transient THEN
205
+ DELETE FROM ${schema}.jobs
206
+ WHERE entity IS NULL
207
+ AND expired_at IS NOT NULL
208
+ AND expired_at < NOW() - retention;
209
+ GET DIAGNOSTICS v_deleted_transient = ROW_COUNT;
210
+ END IF;
211
+
212
+ -- 3. Hard-delete expired stream messages older than the retention window.
158
213
  IF prune_streams THEN
159
214
  DELETE FROM ${schema}.streams
160
215
  WHERE expired_at IS NOT NULL
@@ -162,22 +217,45 @@ class DBA {
162
217
  GET DIAGNOSTICS v_deleted_streams = ROW_COUNT;
163
218
  END IF;
164
219
 
165
- -- 3. Optionally strip execution artifacts from completed, live jobs.
166
- -- Retains jdata (workflow return data) and udata (searchable data).
220
+ -- 4. Strip execution artifacts from completed, live, un-pruned jobs.
221
+ -- Always preserves: jdata, udata, jmark (timeline/export history).
222
+ -- Optionally preserves: hmark (when keep_hmark is true).
167
223
  IF strip_attributes THEN
168
- DELETE FROM ${schema}.jobs_attributes
169
- WHERE job_id IN (
224
+ WITH target_jobs AS (
225
+ SELECT id FROM ${schema}.jobs
226
+ WHERE status = 0
227
+ AND is_live = TRUE
228
+ AND pruned_at IS NULL
229
+ AND (entity_list IS NULL OR entity = ANY(entity_list))
230
+ ),
231
+ deleted AS (
232
+ DELETE FROM ${schema}.jobs_attributes
233
+ WHERE job_id IN (SELECT id FROM target_jobs)
234
+ AND type NOT IN ('jdata', 'udata', 'jmark')
235
+ AND (keep_hmark = FALSE OR type <> 'hmark')
236
+ RETURNING job_id
237
+ )
238
+ SELECT COUNT(*) INTO v_stripped_attributes FROM deleted;
239
+
240
+ -- Mark pruned jobs so they are skipped on future runs.
241
+ WITH target_jobs AS (
170
242
  SELECT id FROM ${schema}.jobs
171
243
  WHERE status = 0
172
244
  AND is_live = TRUE
245
+ AND pruned_at IS NULL
246
+ AND (entity_list IS NULL OR entity = ANY(entity_list))
173
247
  )
174
- AND type NOT IN ('jdata', 'udata');
175
- GET DIAGNOSTICS v_stripped_attributes = ROW_COUNT;
248
+ UPDATE ${schema}.jobs
249
+ SET pruned_at = NOW()
250
+ WHERE id IN (SELECT id FROM target_jobs);
251
+ GET DIAGNOSTICS v_marked_pruned = ROW_COUNT;
176
252
  END IF;
177
253
 
178
254
  deleted_jobs := v_deleted_jobs;
179
255
  deleted_streams := v_deleted_streams;
180
256
  stripped_attributes := v_stripped_attributes;
257
+ deleted_transient := v_deleted_transient;
258
+ marked_pruned := v_marked_pruned;
181
259
  RETURN NEXT;
182
260
  END;
183
261
  $$;
@@ -185,7 +263,8 @@ class DBA {
185
263
  }
186
264
  /**
187
265
  * Deploys the `prune()` Postgres function into the target schema.
188
- * Idempotent uses `CREATE OR REPLACE` and can be called repeatedly.
266
+ * Also runs schema migrations (e.g., adding `pruned_at` column).
267
+ * Idempotent — uses `CREATE OR REPLACE` and `IF NOT EXISTS`.
189
268
  *
190
269
  * The function is automatically deployed when {@link DBA.prune} is called,
191
270
  * but this method is exposed for explicit control (e.g., CI/CD
@@ -214,6 +293,7 @@ class DBA {
214
293
  const schema = DBA.safeName(appId);
215
294
  const { client, release } = await DBA.getClient(connection);
216
295
  try {
296
+ await client.query(DBA.getMigrationSQL(schema));
217
297
  await client.query(DBA.getPruneFunctionSQL(schema));
218
298
  }
219
299
  finally {
@@ -227,12 +307,15 @@ class DBA {
227
307
  *
228
308
  * Operations (each enabled individually):
229
309
  * 1. **jobs** — Hard-deletes expired jobs older than the retention
230
- * window (FK CASCADE removes their attributes automatically)
310
+ * window (FK CASCADE removes their attributes automatically).
311
+ * Scoped by `entities` when set.
231
312
  * 2. **streams** — Hard-deletes expired stream messages older than
232
313
  * the retention window
233
314
  * 3. **attributes** — Strips non-essential attributes (`adata`,
234
- * `hmark`, `jmark`, `status`, `other`) from completed jobs,
235
- * retaining only `jdata` and `udata`
315
+ * `hmark`, `status`, `other`) from completed, un-pruned jobs.
316
+ * Preserves `jdata`, `udata`, and `jmark`. Marks stripped
317
+ * jobs with `pruned_at` for idempotency.
318
+ * 4. **pruneTransient** — Deletes expired jobs with `entity IS NULL`
236
319
  *
237
320
  * @param options - Prune configuration
238
321
  * @returns Counts of deleted/stripped rows
@@ -242,7 +325,7 @@ class DBA {
242
325
  * import { Client as Postgres } from 'pg';
243
326
  * import { DBA } from '@hotmeshio/hotmesh';
244
327
  *
245
- * // Strip attributes only keep all jobs and streams
328
+ * // Strip attributes from 'book' entities only
246
329
  * await DBA.prune({
247
330
  * appId: 'myapp',
248
331
  * connection: {
@@ -252,6 +335,7 @@ class DBA {
252
335
  * jobs: false,
253
336
  * streams: false,
254
337
  * attributes: true,
338
+ * entities: ['book'],
255
339
  * });
256
340
  * ```
257
341
  */
@@ -261,15 +345,20 @@ class DBA {
261
345
  const jobs = options.jobs ?? true;
262
346
  const streams = options.streams ?? true;
263
347
  const attributes = options.attributes ?? false;
348
+ const entities = options.entities ?? null;
349
+ const pruneTransient = options.pruneTransient ?? false;
350
+ const keepHmark = options.keepHmark ?? false;
264
351
  await DBA.deploy(options.connection, options.appId);
265
352
  const { client, release } = await DBA.getClient(options.connection);
266
353
  try {
267
- const result = await client.query(`SELECT * FROM ${schema}.prune($1::interval, $2::boolean, $3::boolean, $4::boolean)`, [expire, jobs, streams, attributes]);
354
+ const result = await client.query(`SELECT * FROM ${schema}.prune($1::interval, $2::boolean, $3::boolean, $4::boolean, $5::text[], $6::boolean, $7::boolean)`, [expire, jobs, streams, attributes, entities, pruneTransient, keepHmark]);
268
355
  const row = result.rows[0];
269
356
  return {
270
357
  jobs: Number(row.deleted_jobs),
271
358
  streams: Number(row.deleted_streams),
272
359
  attributes: Number(row.stripped_attributes),
360
+ transient: Number(row.deleted_transient),
361
+ marked: Number(row.marked_pruned),
273
362
  };
274
363
  }
275
364
  finally {
@@ -1,8 +1,46 @@
1
1
  import { ILogger } from '../logger';
2
2
  import { StoreService } from '../store';
3
- import { ExportOptions, DurableJobExport, TimelineType, TransitionType, ExportFields } from '../../types/exporter';
3
+ import { ExportOptions, DurableJobExport, TimelineType, TransitionType, ExportFields, ExecutionExportOptions, WorkflowExecution, WorkflowExecutionStatus } from '../../types/exporter';
4
4
  import { ProviderClient, ProviderTransaction } from '../../types/provider';
5
5
  import { StringStringType, Symbols } from '../../types/serializer';
6
+ /**
7
+ * Parse a HotMesh compact timestamp (YYYYMMDDHHmmss.mmm) into ISO 8601.
8
+ * Also accepts ISO 8601 strings directly.
9
+ */
10
+ declare function parseTimestamp(raw: string | undefined | null): string | null;
11
+ /**
12
+ * Compute duration in milliseconds between two HotMesh timestamps.
13
+ */
14
+ declare function computeDuration(ac: string | undefined, au: string | undefined): number | null;
15
+ /**
16
+ * Extract the operation type (proxy, child, start, wait, sleep, hook)
17
+ * from a timeline key like `-proxy,0,0-1-`.
18
+ */
19
+ declare function extractOperation(key: string): string;
20
+ /**
21
+ * Extract the activity name from a timeline entry value's job_id.
22
+ *
23
+ * Job ID format: `-{workflowId}-$${activityName}{dimension}-{execIndex}`
24
+ * Examples:
25
+ * `-wfId-$analyzeContent-5` → `'analyzeContent'`
26
+ * `-wfId-$processOrder,0,0-3` → `'processOrder'`
27
+ */
28
+ declare function extractActivityName(value: Record<string, any> | null): string;
29
+ /**
30
+ * Check if an activity name is a system (interceptor) operation.
31
+ */
32
+ declare function isSystemActivity(name: string): boolean;
33
+ /**
34
+ * Map HotMesh job state to a human-readable execution status.
35
+ *
36
+ * HotMesh semaphore: `0` = idle, `> 0` = pending activities,
37
+ * `< 0` = failed / interrupted.
38
+ *
39
+ * A workflow can be "done" (`state.data.done === true`) while the
40
+ * semaphore is still > 0 (cleanup activities pending). We check
41
+ * both the `done` flag and the semaphore to determine status.
42
+ */
43
+ declare function mapStatus(rawStatus: number | undefined, isDone?: boolean, hasError?: boolean): WorkflowExecutionStatus;
6
44
  declare class ExporterService {
7
45
  appId: string;
8
46
  logger: ILogger;
@@ -11,10 +49,29 @@ declare class ExporterService {
11
49
  private static symbols;
12
50
  constructor(appId: string, store: StoreService<ProviderClient, ProviderTransaction>, logger: ILogger);
13
51
  /**
14
- * Convert the job hash from its compiles format into a DurableJobExport object with
52
+ * Convert the job hash from its compiled format into a DurableJobExport object with
15
53
  * facets that describe the workflow in terms relevant to narrative storytelling.
16
54
  */
17
55
  export(jobId: string, options?: ExportOptions): Promise<DurableJobExport>;
56
+ /**
57
+ * Export a workflow execution as a Temporal-compatible event history.
58
+ *
59
+ * **Sparse mode** (default): transforms the main workflow's timeline
60
+ * into a flat event list. No additional I/O beyond the initial export.
61
+ *
62
+ * **Verbose mode**: recursively fetches child workflow jobs and attaches
63
+ * their executions as nested `children`.
64
+ */
65
+ exportExecution(jobId: string, workflowTopic: string, options?: ExecutionExportOptions): Promise<WorkflowExecution>;
66
+ /**
67
+ * Pure transformation: convert a raw DurableJobExport into a
68
+ * Temporal-compatible WorkflowExecution event history.
69
+ */
70
+ transformToExecution(raw: DurableJobExport, workflowId: string, workflowTopic: string, options: ExecutionExportOptions): WorkflowExecution;
71
+ /**
72
+ * Recursively fetch child workflow executions for verbose mode.
73
+ */
74
+ private fetchChildren;
18
75
  /**
19
76
  * Inflates the job data into a DurableJobExport object
20
77
  * @param jobHash - the job data
@@ -48,4 +105,4 @@ declare class ExporterService {
48
105
  */
49
106
  sortParts(parts: TimelineType[]): TimelineType[];
50
107
  }
51
- export { ExporterService };
108
+ export { ExporterService, parseTimestamp, computeDuration, extractOperation, extractActivityName, isSystemActivity, mapStatus, };
@@ -1,8 +1,158 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ExporterService = void 0;
3
+ exports.mapStatus = exports.isSystemActivity = exports.extractActivityName = exports.extractOperation = exports.computeDuration = exports.parseTimestamp = exports.ExporterService = void 0;
4
4
  const utils_1 = require("../../modules/utils");
5
5
  const serializer_1 = require("../serializer");
6
+ // ── Timestamp helpers ────────────────────────────────────────────────────────
7
+ /**
8
+ * Parse a HotMesh compact timestamp (YYYYMMDDHHmmss.mmm) into ISO 8601.
9
+ * Also accepts ISO 8601 strings directly.
10
+ */
11
+ function parseTimestamp(raw) {
12
+ if (!raw || typeof raw !== 'string')
13
+ return null;
14
+ const m = raw.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})(?:\.?(\d+))?$/);
15
+ if (m) {
16
+ const [, yr, mo, dy, hr, mi, sc, ms] = m;
17
+ const frac = ms ? `.${ms.padEnd(3, '0').slice(0, 3)}` : '.000';
18
+ return `${yr}-${mo}-${dy}T${hr}:${mi}:${sc}${frac}Z`;
19
+ }
20
+ // Try ISO 8601
21
+ if (raw.includes('T') || raw.includes('-')) {
22
+ const d = new Date(raw);
23
+ if (!isNaN(d.getTime()))
24
+ return d.toISOString();
25
+ }
26
+ return null;
27
+ }
28
+ exports.parseTimestamp = parseTimestamp;
29
+ /**
30
+ * Compute duration in milliseconds between two HotMesh timestamps.
31
+ */
32
+ function computeDuration(ac, au) {
33
+ const start = parseTimestamp(ac);
34
+ const end = parseTimestamp(au);
35
+ if (!start || !end)
36
+ return null;
37
+ return new Date(end).getTime() - new Date(start).getTime();
38
+ }
39
+ exports.computeDuration = computeDuration;
40
+ // ── Timeline key parsing ─────────────────────────────────────────────────────
41
+ /**
42
+ * Extract the operation type (proxy, child, start, wait, sleep, hook)
43
+ * from a timeline key like `-proxy,0,0-1-`.
44
+ */
45
+ function extractOperation(key) {
46
+ const parts = key.split('-').filter(Boolean);
47
+ return parts[0]?.split(',')[0] || 'unknown';
48
+ }
49
+ exports.extractOperation = extractOperation;
50
+ // ── Name extraction ──────────────────────────────────────────────────────────
51
+ /**
52
+ * Extract the activity name from a timeline entry value's job_id.
53
+ *
54
+ * Job ID format: `-{workflowId}-$${activityName}{dimension}-{execIndex}`
55
+ * Examples:
56
+ * `-wfId-$analyzeContent-5` → `'analyzeContent'`
57
+ * `-wfId-$processOrder,0,0-3` → `'processOrder'`
58
+ */
59
+ function extractActivityName(value) {
60
+ const jobId = value?.job_id;
61
+ if (!jobId || typeof jobId !== 'string')
62
+ return 'unknown';
63
+ const dollarIdx = jobId.lastIndexOf('$');
64
+ if (dollarIdx === -1)
65
+ return jobId;
66
+ const afterDollar = jobId.substring(dollarIdx + 1);
67
+ const dashIdx = afterDollar.lastIndexOf('-');
68
+ const nameWithDim = dashIdx > 0 ? afterDollar.substring(0, dashIdx) : afterDollar;
69
+ // Strip dimension suffix (,N,N...)
70
+ const commaIdx = nameWithDim.indexOf(',');
71
+ return (commaIdx > 0 ? nameWithDim.substring(0, commaIdx) : nameWithDim) || 'unknown';
72
+ }
73
+ exports.extractActivityName = extractActivityName;
74
+ /**
75
+ * Check if an activity name is a system (interceptor) operation.
76
+ */
77
+ function isSystemActivity(name) {
78
+ return name.startsWith('lt');
79
+ }
80
+ exports.isSystemActivity = isSystemActivity;
81
+ /**
82
+ * Extract a child workflow ID from a child/start timeline value.
83
+ */
84
+ function extractChildWorkflowId(value) {
85
+ return value?.job_id || 'unknown';
86
+ }
87
+ // ── Status mapping ───────────────────────────────────────────────────────────
88
+ /**
89
+ * Map HotMesh job state to a human-readable execution status.
90
+ *
91
+ * HotMesh semaphore: `0` = idle, `> 0` = pending activities,
92
+ * `< 0` = failed / interrupted.
93
+ *
94
+ * A workflow can be "done" (`state.data.done === true`) while the
95
+ * semaphore is still > 0 (cleanup activities pending). We check
96
+ * both the `done` flag and the semaphore to determine status.
97
+ */
98
+ function mapStatus(rawStatus, isDone, hasError) {
99
+ if (hasError || (rawStatus !== undefined && !isNaN(rawStatus) && rawStatus < 0)) {
100
+ return 'failed';
101
+ }
102
+ if (isDone || rawStatus === 0)
103
+ return 'completed';
104
+ if (rawStatus === undefined || isNaN(rawStatus))
105
+ return 'running';
106
+ return 'running';
107
+ }
108
+ exports.mapStatus = mapStatus;
109
+ // ── Event construction ───────────────────────────────────────────────────────
110
+ function makeEvent(event_id, event_type, category, event_time, duration_ms, is_system, attributes) {
111
+ return { event_id, event_type, category, event_time, duration_ms, is_system, attributes };
112
+ }
113
+ function computeSummary(events) {
114
+ const summary = {
115
+ total_events: events.length,
116
+ activities: { total: 0, completed: 0, failed: 0, system: 0, user: 0 },
117
+ child_workflows: { total: 0, completed: 0, failed: 0 },
118
+ timers: 0,
119
+ signals: 0,
120
+ };
121
+ for (const e of events) {
122
+ switch (e.event_type) {
123
+ case 'activity_task_scheduled':
124
+ summary.activities.total++;
125
+ if (e.is_system)
126
+ summary.activities.system++;
127
+ else
128
+ summary.activities.user++;
129
+ break;
130
+ case 'activity_task_completed':
131
+ summary.activities.completed++;
132
+ break;
133
+ case 'activity_task_failed':
134
+ summary.activities.failed++;
135
+ break;
136
+ case 'child_workflow_execution_started':
137
+ summary.child_workflows.total++;
138
+ break;
139
+ case 'child_workflow_execution_completed':
140
+ summary.child_workflows.completed++;
141
+ break;
142
+ case 'child_workflow_execution_failed':
143
+ summary.child_workflows.failed++;
144
+ break;
145
+ case 'timer_started':
146
+ summary.timers++;
147
+ break;
148
+ case 'workflow_execution_signaled':
149
+ summary.signals++;
150
+ break;
151
+ }
152
+ }
153
+ return summary;
154
+ }
155
+ // ── Exporter Service ─────────────────────────────────────────────────────────
6
156
  class ExporterService {
7
157
  constructor(appId, store, logger) {
8
158
  this.appId = appId;
@@ -10,7 +160,7 @@ class ExporterService {
10
160
  this.store = store;
11
161
  }
12
162
  /**
13
- * Convert the job hash from its compiles format into a DurableJobExport object with
163
+ * Convert the job hash from its compiled format into a DurableJobExport object with
14
164
  * facets that describe the workflow in terms relevant to narrative storytelling.
15
165
  */
16
166
  async export(jobId, options = {}) {
@@ -22,6 +172,284 @@ class ExporterService {
22
172
  const jobExport = this.inflate(jobData, options);
23
173
  return jobExport;
24
174
  }
175
+ /**
176
+ * Export a workflow execution as a Temporal-compatible event history.
177
+ *
178
+ * **Sparse mode** (default): transforms the main workflow's timeline
179
+ * into a flat event list. No additional I/O beyond the initial export.
180
+ *
181
+ * **Verbose mode**: recursively fetches child workflow jobs and attaches
182
+ * their executions as nested `children`.
183
+ */
184
+ async exportExecution(jobId, workflowTopic, options = {}) {
185
+ const raw = await this.export(jobId);
186
+ const execution = this.transformToExecution(raw, jobId, workflowTopic, options);
187
+ if (options.mode === 'verbose') {
188
+ const maxDepth = options.max_depth ?? 5;
189
+ execution.children = await this.fetchChildren(raw, workflowTopic, options, 1, maxDepth);
190
+ }
191
+ return execution;
192
+ }
193
+ /**
194
+ * Pure transformation: convert a raw DurableJobExport into a
195
+ * Temporal-compatible WorkflowExecution event history.
196
+ */
197
+ transformToExecution(raw, workflowId, workflowTopic, options) {
198
+ const events = [];
199
+ let nextId = 1;
200
+ // ── Extract timing from state metadata ─────────────────────────
201
+ const state = raw.state;
202
+ const metadata = state?.output?.metadata ?? state?.metadata;
203
+ const stateData = state?.output?.data ?? state?.data;
204
+ const jobCreated = metadata?.jc ?? stateData?.jc ?? metadata?.ac;
205
+ const jobUpdated = metadata?.ju ?? stateData?.ju ?? metadata?.au;
206
+ const startTime = parseTimestamp(jobCreated);
207
+ const closeTime = parseTimestamp(jobUpdated);
208
+ // ── Synthetic workflow_execution_started ─────────────────────────
209
+ if (startTime) {
210
+ events.push(makeEvent(nextId++, 'workflow_execution_started', 'workflow', startTime, null, false, {
211
+ kind: 'workflow_execution_started',
212
+ workflow_type: workflowTopic,
213
+ task_queue: workflowTopic,
214
+ input: options.omit_results ? undefined : raw.data,
215
+ }));
216
+ }
217
+ // ── Transform timeline entries ───────────────────────────────
218
+ const timeline = raw.timeline || [];
219
+ for (const entry of timeline) {
220
+ const operation = extractOperation(entry.key);
221
+ const val = (typeof entry.value === 'object' && entry.value !== null)
222
+ ? entry.value
223
+ : null;
224
+ const ac = val?.ac;
225
+ const au = val?.au;
226
+ const acIso = parseTimestamp(ac);
227
+ const auIso = parseTimestamp(au);
228
+ const dur = computeDuration(ac, au);
229
+ const hasError = val != null && '$error' in val;
230
+ switch (operation) {
231
+ case 'proxy': {
232
+ const name = extractActivityName(val);
233
+ const isSys = isSystemActivity(name);
234
+ if (options.exclude_system && isSys)
235
+ break;
236
+ if (acIso) {
237
+ events.push(makeEvent(nextId++, 'activity_task_scheduled', 'activity', acIso, null, isSys, {
238
+ kind: 'activity_task_scheduled',
239
+ activity_type: name,
240
+ timeline_key: entry.key,
241
+ execution_index: entry.index,
242
+ }));
243
+ }
244
+ if (auIso) {
245
+ if (hasError) {
246
+ events.push(makeEvent(nextId++, 'activity_task_failed', 'activity', auIso, dur, isSys, {
247
+ kind: 'activity_task_failed',
248
+ activity_type: name,
249
+ failure: val?.$error,
250
+ timeline_key: entry.key,
251
+ execution_index: entry.index,
252
+ }));
253
+ }
254
+ else {
255
+ events.push(makeEvent(nextId++, 'activity_task_completed', 'activity', auIso, dur, isSys, {
256
+ kind: 'activity_task_completed',
257
+ activity_type: name,
258
+ result: options.omit_results ? undefined : val?.data,
259
+ timeline_key: entry.key,
260
+ execution_index: entry.index,
261
+ }));
262
+ }
263
+ }
264
+ break;
265
+ }
266
+ case 'child': {
267
+ const childId = extractChildWorkflowId(val);
268
+ if (acIso) {
269
+ events.push(makeEvent(nextId++, 'child_workflow_execution_started', 'child_workflow', acIso, null, false, {
270
+ kind: 'child_workflow_execution_started',
271
+ child_workflow_id: childId,
272
+ awaited: true,
273
+ timeline_key: entry.key,
274
+ execution_index: entry.index,
275
+ }));
276
+ }
277
+ if (auIso) {
278
+ if (hasError) {
279
+ events.push(makeEvent(nextId++, 'child_workflow_execution_failed', 'child_workflow', auIso, dur, false, {
280
+ kind: 'child_workflow_execution_failed',
281
+ child_workflow_id: childId,
282
+ failure: val?.$error,
283
+ timeline_key: entry.key,
284
+ execution_index: entry.index,
285
+ }));
286
+ }
287
+ else {
288
+ events.push(makeEvent(nextId++, 'child_workflow_execution_completed', 'child_workflow', auIso, dur, false, {
289
+ kind: 'child_workflow_execution_completed',
290
+ child_workflow_id: childId,
291
+ result: options.omit_results ? undefined : val?.data,
292
+ timeline_key: entry.key,
293
+ execution_index: entry.index,
294
+ }));
295
+ }
296
+ }
297
+ break;
298
+ }
299
+ case 'start': {
300
+ const childId = extractChildWorkflowId(val);
301
+ const ts = acIso || auIso;
302
+ if (ts) {
303
+ events.push(makeEvent(nextId++, 'child_workflow_execution_started', 'child_workflow', ts, null, false, {
304
+ kind: 'child_workflow_execution_started',
305
+ child_workflow_id: childId,
306
+ awaited: false,
307
+ timeline_key: entry.key,
308
+ execution_index: entry.index,
309
+ }));
310
+ }
311
+ break;
312
+ }
313
+ case 'wait': {
314
+ const signalName = val?.id
315
+ || val?.data?.id
316
+ || val?.data?.data?.id
317
+ || `signal-${entry.index}`;
318
+ const ts = auIso || acIso;
319
+ if (ts) {
320
+ events.push(makeEvent(nextId++, 'workflow_execution_signaled', 'signal', ts, dur, false, {
321
+ kind: 'workflow_execution_signaled',
322
+ signal_name: signalName,
323
+ input: options.omit_results ? undefined : val?.data?.data,
324
+ timeline_key: entry.key,
325
+ execution_index: entry.index,
326
+ }));
327
+ }
328
+ break;
329
+ }
330
+ case 'sleep': {
331
+ if (acIso) {
332
+ events.push(makeEvent(nextId++, 'timer_started', 'timer', acIso, null, false, {
333
+ kind: 'timer_started',
334
+ duration_ms: dur ?? undefined,
335
+ timeline_key: entry.key,
336
+ execution_index: entry.index,
337
+ }));
338
+ }
339
+ if (auIso) {
340
+ events.push(makeEvent(nextId++, 'timer_fired', 'timer', auIso, dur, false, {
341
+ kind: 'timer_fired',
342
+ timeline_key: entry.key,
343
+ execution_index: entry.index,
344
+ }));
345
+ }
346
+ break;
347
+ }
348
+ // Unknown operation types are silently skipped (forward-compatible)
349
+ }
350
+ }
351
+ // ── Determine status ─────────────────────────────────────────
352
+ const isDone = stateData?.done === true;
353
+ const hasError = !!stateData?.$error;
354
+ const status = mapStatus(raw.status, isDone, hasError);
355
+ // ── Extract workflow result ──────────────────────────────────
356
+ const result = stateData?.response ?? (raw.data && Object.keys(raw.data).length > 0 ? raw.data : null);
357
+ // ── Synthetic workflow_execution_completed / failed ───────────
358
+ if (status === 'completed' && closeTime) {
359
+ const totalDur = startTime
360
+ ? new Date(closeTime).getTime() - new Date(startTime).getTime()
361
+ : null;
362
+ events.push(makeEvent(nextId++, 'workflow_execution_completed', 'workflow', closeTime, totalDur, false, {
363
+ kind: 'workflow_execution_completed',
364
+ result: options.omit_results ? undefined : result,
365
+ }));
366
+ }
367
+ else if (status === 'failed' && closeTime) {
368
+ const totalDur = startTime
369
+ ? new Date(closeTime).getTime() - new Date(startTime).getTime()
370
+ : null;
371
+ events.push(makeEvent(nextId++, 'workflow_execution_failed', 'workflow', closeTime, totalDur, false, {
372
+ kind: 'workflow_execution_failed',
373
+ failure: stateData?.err,
374
+ }));
375
+ }
376
+ // ── Sort chronologically ─────────────────────────────────────
377
+ events.sort((a, b) => {
378
+ const cmp = a.event_time.localeCompare(b.event_time);
379
+ return cmp !== 0 ? cmp : a.event_id - b.event_id;
380
+ });
381
+ // ── Re-number event IDs after sort ───────────────────────────
382
+ for (let i = 0; i < events.length; i++) {
383
+ events[i].event_id = i + 1;
384
+ }
385
+ // ── Back-references (Temporal-compatible) ────────────────────
386
+ const scheduledMap = new Map();
387
+ const initiatedMap = new Map();
388
+ for (const e of events) {
389
+ const attrs = e.attributes;
390
+ if (e.event_type === 'activity_task_scheduled' && attrs.timeline_key) {
391
+ scheduledMap.set(attrs.timeline_key, e.event_id);
392
+ }
393
+ if (e.event_type === 'child_workflow_execution_started' && attrs.timeline_key) {
394
+ initiatedMap.set(attrs.timeline_key, e.event_id);
395
+ }
396
+ if ((e.event_type === 'activity_task_completed' || e.event_type === 'activity_task_failed') && attrs.timeline_key) {
397
+ attrs.scheduled_event_id = scheduledMap.get(attrs.timeline_key) ?? null;
398
+ }
399
+ if ((e.event_type === 'child_workflow_execution_completed' || e.event_type === 'child_workflow_execution_failed') && attrs.timeline_key) {
400
+ attrs.initiated_event_id = initiatedMap.get(attrs.timeline_key) ?? null;
401
+ }
402
+ }
403
+ // ── Compute total duration ───────────────────────────────────
404
+ const totalDuration = (startTime && closeTime)
405
+ ? new Date(closeTime).getTime() - new Date(startTime).getTime()
406
+ : null;
407
+ return {
408
+ workflow_id: workflowId,
409
+ workflow_type: workflowTopic,
410
+ task_queue: workflowTopic,
411
+ status,
412
+ start_time: startTime,
413
+ close_time: (status !== 'running') ? closeTime : null,
414
+ duration_ms: totalDuration,
415
+ result,
416
+ events,
417
+ summary: computeSummary(events),
418
+ };
419
+ }
420
+ /**
421
+ * Recursively fetch child workflow executions for verbose mode.
422
+ */
423
+ async fetchChildren(raw, workflowTopic, options, depth, maxDepth) {
424
+ if (depth >= maxDepth)
425
+ return [];
426
+ const children = [];
427
+ const timeline = raw.timeline || [];
428
+ for (const entry of timeline) {
429
+ const operation = extractOperation(entry.key);
430
+ if (operation !== 'child' && operation !== 'start')
431
+ continue;
432
+ const val = (typeof entry.value === 'object' && entry.value !== null)
433
+ ? entry.value
434
+ : null;
435
+ const childJobId = val?.job_id;
436
+ if (!childJobId || typeof childJobId !== 'string')
437
+ continue;
438
+ try {
439
+ const childRaw = await this.export(childJobId);
440
+ const childTopic = childRaw.data?.workflowTopic ?? workflowTopic;
441
+ const childExecution = this.transformToExecution(childRaw, childJobId, childTopic, options);
442
+ if (options.mode === 'verbose') {
443
+ childExecution.children = await this.fetchChildren(childRaw, childTopic, options, depth + 1, maxDepth);
444
+ }
445
+ children.push(childExecution);
446
+ }
447
+ catch {
448
+ // Child job may have expired or been cleaned up
449
+ }
450
+ }
451
+ return children;
452
+ }
25
453
  /**
26
454
  * Inflates the job data into a DurableJobExport object
27
455
  * @param jobHash - the job data
@@ -1,5 +1,5 @@
1
1
  import { HotMesh } from '../hotmesh';
2
- import { DurableJobExport, ExportOptions } from '../../types/exporter';
2
+ import { DurableJobExport, ExportOptions, ExecutionExportOptions, WorkflowExecution } from '../../types/exporter';
3
3
  import { JobInterruptOptions } from '../../types/job';
4
4
  import { StreamError } from '../../types/stream';
5
5
  import { ExporterService } from './exporter';
@@ -44,6 +44,17 @@ export declare class WorkflowHandleService {
44
44
  * Exports the workflow state to a JSON object.
45
45
  */
46
46
  export(options?: ExportOptions): Promise<DurableJobExport>;
47
+ /**
48
+ * Exports the workflow as a Temporal-like execution event history.
49
+ *
50
+ * **Sparse mode** (default): transforms the main workflow's timeline
51
+ * into a flat event list with workflow lifecycle, activity, child workflow,
52
+ * timer, and signal events.
53
+ *
54
+ * **Verbose mode**: recursively fetches child workflow jobs and attaches
55
+ * their full execution histories as nested `children`.
56
+ */
57
+ exportExecution(options?: ExecutionExportOptions): Promise<WorkflowExecution>;
47
58
  /**
48
59
  * Sends a signal to the workflow. This is a way to send
49
60
  * a message to a workflow that is paused due to having
@@ -43,6 +43,19 @@ class WorkflowHandleService {
43
43
  async export(options) {
44
44
  return this.exporter.export(this.workflowId, options);
45
45
  }
46
+ /**
47
+ * Exports the workflow as a Temporal-like execution event history.
48
+ *
49
+ * **Sparse mode** (default): transforms the main workflow's timeline
50
+ * into a flat event list with workflow lifecycle, activity, child workflow,
51
+ * timer, and signal events.
52
+ *
53
+ * **Verbose mode**: recursively fetches child workflow jobs and attaches
54
+ * their full execution histories as nested `children`.
55
+ */
56
+ async exportExecution(options) {
57
+ return this.exporter.exportExecution(this.workflowId, this.workflowTopic, options);
58
+ }
46
59
  /**
47
60
  * Sends a signal to the workflow. This is a way to send
48
61
  * a message to a workflow that is paused due to having
@@ -182,6 +182,7 @@ const KVTables = (context) => ({
182
182
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
183
183
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
184
184
  expired_at TIMESTAMP WITH TIME ZONE,
185
+ pruned_at TIMESTAMP WITH TIME ZONE,
185
186
  is_live BOOLEAN DEFAULT TRUE,
186
187
  PRIMARY KEY (id)
187
188
  ) PARTITION BY HASH (id);
@@ -214,8 +215,12 @@ const KVTables = (context) => ({
214
215
  ON ${fullTableName} (entity, status);
215
216
  `);
216
217
  await client.query(`
217
- CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
218
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_expired_at
218
219
  ON ${fullTableName} (expired_at);
220
+ `);
221
+ await client.query(`
222
+ CREATE INDEX IF NOT EXISTS idx_${tableDef.name}_pruned_at
223
+ ON ${fullTableName} (pruned_at) WHERE pruned_at IS NULL;
219
224
  `);
220
225
  // Create function to update is_live flag in the schema
221
226
  await client.query(`
@@ -31,7 +31,7 @@ export interface PruneOptions {
31
31
  /**
32
32
  * If true, hard-deletes expired jobs older than the retention window.
33
33
  * FK CASCADE on `jobs_attributes` automatically removes associated
34
- * attribute rows.
34
+ * attribute rows. When `entities` is set, only matching jobs are deleted.
35
35
  * @default true
36
36
  */
37
37
  jobs?: boolean;
@@ -42,13 +42,35 @@ export interface PruneOptions {
42
42
  */
43
43
  streams?: boolean;
44
44
  /**
45
- * If true, strips execution-artifact attributes (`adata`, `hmark`,
46
- * `jmark`, `status`, `other`) from completed jobs (status = 0),
47
- * retaining only `jdata` (workflow return data) and `udata`
48
- * (user-searchable data).
45
+ * If true, strips execution-artifact attributes from completed,
46
+ * un-pruned jobs. Preserves `jdata` (return data), `udata`
47
+ * (searchable data), and `jmark` (timeline/event history for
48
+ * Temporal-compatible export). See `keepHmark` for `hmark`.
49
49
  * @default false
50
50
  */
51
51
  attributes?: boolean;
52
+ /**
53
+ * Entity allowlist. When provided, only jobs whose `entity` column
54
+ * matches one of these values are eligible for pruning/stripping.
55
+ * Jobs with `entity IS NULL` are excluded unless `pruneTransient`
56
+ * is also true.
57
+ * @default undefined (all entities)
58
+ */
59
+ entities?: string[];
60
+ /**
61
+ * If true, hard-deletes expired jobs where `entity IS NULL`
62
+ * (transient workflow runs). Must also satisfy the retention
63
+ * window (`expire`).
64
+ * @default false
65
+ */
66
+ pruneTransient?: boolean;
67
+ /**
68
+ * If true, `hmark` attributes are preserved during stripping
69
+ * (along with `jdata`, `udata`, and `jmark`). If false, `hmark`
70
+ * rows are stripped.
71
+ * @default false
72
+ */
73
+ keepHmark?: boolean;
52
74
  }
53
75
  /**
54
76
  * Result returned by `DBA.prune()`, providing deletion
@@ -61,4 +83,8 @@ export interface PruneResult {
61
83
  streams: number;
62
84
  /** Number of execution-artifact attribute rows stripped from completed jobs */
63
85
  attributes: number;
86
+ /** Number of transient (entity IS NULL) job rows hard-deleted */
87
+ transient: number;
88
+ /** Number of jobs marked as pruned (pruned_at set) */
89
+ marked: number;
64
90
  }
@@ -79,3 +79,130 @@ export interface JobExport {
79
79
  process: StringAnyType;
80
80
  status: string;
81
81
  }
82
+ export type ExportMode = 'sparse' | 'verbose';
83
+ export type WorkflowEventType = 'workflow_execution_started' | 'workflow_execution_completed' | 'workflow_execution_failed' | 'activity_task_scheduled' | 'activity_task_completed' | 'activity_task_failed' | 'child_workflow_execution_started' | 'child_workflow_execution_completed' | 'child_workflow_execution_failed' | 'timer_started' | 'timer_fired' | 'workflow_execution_signaled';
84
+ export type WorkflowEventCategory = 'workflow' | 'activity' | 'child_workflow' | 'timer' | 'signal';
85
+ export interface WorkflowExecutionStartedAttributes {
86
+ kind: 'workflow_execution_started';
87
+ workflow_type: string;
88
+ task_queue: string;
89
+ input?: any;
90
+ }
91
+ export interface WorkflowExecutionCompletedAttributes {
92
+ kind: 'workflow_execution_completed';
93
+ result?: any;
94
+ }
95
+ export interface WorkflowExecutionFailedAttributes {
96
+ kind: 'workflow_execution_failed';
97
+ failure?: string;
98
+ }
99
+ export interface ActivityTaskScheduledAttributes {
100
+ kind: 'activity_task_scheduled';
101
+ activity_type: string;
102
+ timeline_key: string;
103
+ execution_index: number;
104
+ }
105
+ export interface ActivityTaskCompletedAttributes {
106
+ kind: 'activity_task_completed';
107
+ activity_type: string;
108
+ result?: any;
109
+ scheduled_event_id?: number;
110
+ timeline_key: string;
111
+ execution_index: number;
112
+ }
113
+ export interface ActivityTaskFailedAttributes {
114
+ kind: 'activity_task_failed';
115
+ activity_type: string;
116
+ failure?: any;
117
+ scheduled_event_id?: number;
118
+ timeline_key: string;
119
+ execution_index: number;
120
+ }
121
+ export interface ChildWorkflowExecutionStartedAttributes {
122
+ kind: 'child_workflow_execution_started';
123
+ child_workflow_id: string;
124
+ awaited: boolean;
125
+ timeline_key: string;
126
+ execution_index: number;
127
+ }
128
+ export interface ChildWorkflowExecutionCompletedAttributes {
129
+ kind: 'child_workflow_execution_completed';
130
+ child_workflow_id: string;
131
+ result?: any;
132
+ initiated_event_id?: number;
133
+ timeline_key: string;
134
+ execution_index: number;
135
+ }
136
+ export interface ChildWorkflowExecutionFailedAttributes {
137
+ kind: 'child_workflow_execution_failed';
138
+ child_workflow_id: string;
139
+ failure?: any;
140
+ initiated_event_id?: number;
141
+ timeline_key: string;
142
+ execution_index: number;
143
+ }
144
+ export interface TimerStartedAttributes {
145
+ kind: 'timer_started';
146
+ duration_ms?: number;
147
+ timeline_key: string;
148
+ execution_index: number;
149
+ }
150
+ export interface TimerFiredAttributes {
151
+ kind: 'timer_fired';
152
+ timeline_key: string;
153
+ execution_index: number;
154
+ }
155
+ export interface WorkflowExecutionSignaledAttributes {
156
+ kind: 'workflow_execution_signaled';
157
+ signal_name: string;
158
+ input?: any;
159
+ timeline_key: string;
160
+ execution_index: number;
161
+ }
162
+ export type WorkflowEventAttributes = WorkflowExecutionStartedAttributes | WorkflowExecutionCompletedAttributes | WorkflowExecutionFailedAttributes | ActivityTaskScheduledAttributes | ActivityTaskCompletedAttributes | ActivityTaskFailedAttributes | ChildWorkflowExecutionStartedAttributes | ChildWorkflowExecutionCompletedAttributes | ChildWorkflowExecutionFailedAttributes | TimerStartedAttributes | TimerFiredAttributes | WorkflowExecutionSignaledAttributes;
163
+ export interface WorkflowExecutionEvent {
164
+ event_id: number;
165
+ event_type: WorkflowEventType;
166
+ category: WorkflowEventCategory;
167
+ event_time: string;
168
+ duration_ms: number | null;
169
+ is_system: boolean;
170
+ attributes: WorkflowEventAttributes;
171
+ }
172
+ export interface WorkflowExecutionSummary {
173
+ total_events: number;
174
+ activities: {
175
+ total: number;
176
+ completed: number;
177
+ failed: number;
178
+ system: number;
179
+ user: number;
180
+ };
181
+ child_workflows: {
182
+ total: number;
183
+ completed: number;
184
+ failed: number;
185
+ };
186
+ timers: number;
187
+ signals: number;
188
+ }
189
+ export type WorkflowExecutionStatus = 'running' | 'completed' | 'failed';
190
+ export interface WorkflowExecution {
191
+ workflow_id: string;
192
+ workflow_type: string;
193
+ task_queue: string;
194
+ status: WorkflowExecutionStatus;
195
+ start_time: string | null;
196
+ close_time: string | null;
197
+ duration_ms: number | null;
198
+ result: any;
199
+ events: WorkflowExecutionEvent[];
200
+ summary: WorkflowExecutionSummary;
201
+ children?: WorkflowExecution[];
202
+ }
203
+ export interface ExecutionExportOptions {
204
+ mode?: ExportMode;
205
+ exclude_system?: boolean;
206
+ omit_results?: boolean;
207
+ max_depth?: number;
208
+ }
@@ -6,7 +6,7 @@ export { CollationFaultType, CollationStage } from './collator';
6
6
  export { ActivityConfig, ActivityInterceptor, ActivityInterceptorContext, ActivityWorkflowDataType, ChildResponseType, ClientConfig, ClientWorkflow, ContextType, Connection, ProxyResponseType, ProxyType, Registry, SignalOptions, FindJobsOptions, FindOptions, FindWhereOptions, FindWhereQuery, HookOptions, SearchResults, WorkflowConfig, WorkerConfig, WorkerOptions, WorkflowContext, WorkflowSearchOptions, WorkflowSearchSchema, WorkflowDataType, WorkflowOptions, WorkflowInterceptor, InterceptorRegistry, } from './durable';
7
7
  export { PruneOptions, PruneResult, } from './dba';
8
8
  export { DurableChildErrorType, DurableProxyErrorType, DurableSleepErrorType, DurableWaitForAllErrorType, DurableWaitForErrorType, } from './error';
9
- export { ActivityAction, DependencyExport, DurableJobExport, ExportCycles, ExportItem, ExportOptions, ExportTransitions, JobAction, JobExport, JobActionExport, JobTimeline, } from './exporter';
9
+ export { ActivityAction, DependencyExport, DurableJobExport, ExecutionExportOptions, ExportCycles, ExportItem, ExportMode, ExportOptions, ExportTransitions, JobAction, JobExport, JobActionExport, JobTimeline, WorkflowEventAttributes, WorkflowEventCategory, WorkflowEventType, WorkflowExecution, WorkflowExecutionEvent, WorkflowExecutionStatus, WorkflowExecutionSummary, } from './exporter';
10
10
  export { HookCondition, HookConditions, HookGate, HookInterface, HookRule, HookRules, HookSignal, } from './hook';
11
11
  export { HotMesh, HotMeshEngine, HotMeshWorker, HotMeshSettings, HotMeshApp, HotMeshApps, HotMeshConfig, HotMeshManifest, HotMeshGraph, KeyType, KeyStoreParams, ScoutType, } from './hotmesh';
12
12
  export { ILogger, LogLevel } from './logger';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hotmeshio/hotmesh",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Permanent-Memory Workflows & AI Agents",
5
5
  "main": "./build/index.js",
6
6
  "types": "./build/index.d.ts",
@@ -46,6 +46,8 @@
46
46
  "test:durable:sleep": "vitest run tests/durable/sleep/postgres.test.ts",
47
47
  "test:durable:signal": "vitest run tests/durable/signal/postgres.test.ts",
48
48
  "test:durable:unknown": "vitest run tests/durable/unknown/postgres.test.ts",
49
+ "test:durable:exporter": "vitest run tests/durable/exporter/exporter.test.ts",
50
+ "test:durable:exporter:debug": "EXPORT_DEBUG=1 HMSH_LOGLEVEL=error vitest run tests/durable/basic/postgres.test.ts",
49
51
  "test:dba": "vitest run tests/dba",
50
52
  "test:cycle": "vitest run tests/functional/cycle",
51
53
  "test:functional": "vitest run tests/functional",