@kortyx/hooks 0.23.1 → 0.24.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.24.0](https://github.com/kortyx-io/kortyx/compare/hooks-v0.23.1...hooks-v0.24.0) (2026-09-11)
4
+
5
+
6
+ ### Features
7
+
8
+ * **hooks:** support durable parallel child workflows ([#171](https://github.com/kortyx-io/kortyx/issues/171)) ([fdf2d56](https://github.com/kortyx-io/kortyx/commit/fdf2d564d88e921988e8f3e20da9fe0c30bbcdf9))
9
+
3
10
  ## [0.23.1](https://github.com/kortyx-io/kortyx/compare/hooks-v0.23.0...hooks-v0.23.1) (2026-09-11)
4
11
 
5
12
 
package/README.md CHANGED
@@ -78,6 +78,19 @@ string ids. Register the same definitions with the agent.
78
78
  Calls need stable ids. A child interrupt pauses the chain; resuming replays
79
79
  the enclosing node and reuses saved child results. Keep effects before calls
80
80
  idempotent. Fork/rollback retain nested checkpoints; use Redis for persistence
81
- across server restarts. Concurrent calls are unsupported.
81
+ across server restarts. Join concurrent children with `parallel`:
82
+
83
+ ```ts
84
+ const [first, second] = await parallel([
85
+ useWorkflow({ id: "first", workflow: researchWorkflow, input: { topic: "AI" } }),
86
+ useWorkflow({ id: "second", workflow: researchWorkflow, input: { topic: "Robotics" } }),
87
+ ]);
88
+ ```
89
+
90
+ The tuple follows input order. The join preserves all siblings before pausing and
91
+ presents waiting questions individually through the parent handle. Terminal group
92
+ failures throw `ParallelError`, whose `results` exposes each settled outcome.
93
+ Await groups sequentially within one node; children may contain nested groups.
94
+ Native `Promise.all` calls and parallel graph edges remain unsupported.
82
95
 
83
96
  See [the execution and replay contract](../../docs/design-specs/child-workflows.md).
package/dist/index.d.ts CHANGED
@@ -13,6 +13,16 @@ type CompleteResponseOptions = {
13
13
  /** Finish client delivery, leaving the node and workflow execution running. */
14
14
  declare function completeResponse(options?: CompleteResponseOptions): Promise<void>;
15
15
 
16
+ /** All siblings have settled; results retain the order passed to parallel(). */
17
+ declare class ParallelError extends AggregateError {
18
+ readonly results: readonly PromiseSettledResult<unknown>[];
19
+ constructor(results: readonly PromiseSettledResult<unknown>[]);
20
+ }
21
+ /** Join concurrent child calls, preserving their snapshots before suspending. */
22
+ declare function parallel<const T extends readonly unknown[]>(values: T): Promise<{
23
+ -readonly [K in keyof T]: Awaited<T[K]>;
24
+ }>;
25
+
16
26
  type WorkflowCallOutcome = {
17
27
  status: "completed";
18
28
  data: Record<string, unknown>;
@@ -236,4 +246,4 @@ declare function useWorkflowState<T>(key: string, ...rest: [] | [T]): [T, StateS
236
246
  /** Best effort lifecycle facts. Engine snapshots and interrupt tokens never leave the runtime. */
237
247
  declare function emitWorkflowCall(telemetry: KortyxTelemetryConfig | undefined, type: KortyxTelemetryEventType, payload: Record<string, unknown>): void;
238
248
 
239
- export { type CompleteResponseOptions, type HookRuntimeContext, KortyxTelemetryConfig, KortyxTelemetryEventType, KortyxTraceMetadata, ReasonTraceAdapter, type UseInterruptArgs, type UseReasonArgs, type UseReasonResult, type UseReasonStep, type UseReasonToolExecution, type UseStructuredDataArgs, WorkflowCallError, type WorkflowCallOutcome, type WorkflowCallService, completeResponse, createWorkflowHooks, emitWorkflowCall, runWithHookContext, useAbortSignal, useInterrupt, useNodeState, useReason, useRuntimeContext, useStructuredData, useWorkflow, useWorkflowState, workflowCallFingerprint };
249
+ export { type CompleteResponseOptions, type HookRuntimeContext, KortyxTelemetryConfig, KortyxTelemetryEventType, KortyxTraceMetadata, ParallelError, ReasonTraceAdapter, type UseInterruptArgs, type UseReasonArgs, type UseReasonResult, type UseReasonStep, type UseReasonToolExecution, type UseStructuredDataArgs, WorkflowCallError, type WorkflowCallOutcome, type WorkflowCallService, completeResponse, createWorkflowHooks, emitWorkflowCall, parallel, runWithHookContext, useAbortSignal, useInterrupt, useNodeState, useReason, useRuntimeContext, useStructuredData, useWorkflow, useWorkflowState, workflowCallFingerprint };
package/dist/index.js CHANGED
@@ -19,10 +19,12 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
19
19
  // src/index.ts
20
20
  var index_exports = {};
21
21
  __export(index_exports, {
22
+ ParallelError: () => ParallelError,
22
23
  WorkflowCallError: () => WorkflowCallError,
23
24
  completeResponse: () => completeResponse,
24
25
  createWorkflowHooks: () => createWorkflowHooks,
25
26
  emitWorkflowCall: () => emitWorkflowCall,
27
+ parallel: () => parallel,
26
28
  runWithHookContext: () => runWithHookContext,
27
29
  useAbortSignal: () => useAbortSignal,
28
30
  useInterrupt: () => useInterrupt,
@@ -90,6 +92,11 @@ var createInternalContext = (ctx) => {
90
92
  ...ctx,
91
93
  workflowCallActive: false,
92
94
  workflowCallIds: /* @__PURE__ */ new Set(),
95
+ workflowTasks: [],
96
+ parallelJoins: [],
97
+ parallelGroup: void 0,
98
+ parallelIndex: 0,
99
+ workflowContextClosed: false,
93
100
  nodeStateIndex: 0,
94
101
  reasonCallIndex: 0,
95
102
  currentNodeState,
@@ -161,10 +168,29 @@ function getReasonTraceAdapter() {
161
168
  async function runWithHookContext(ctx, fn) {
162
169
  const internal = createInternalContext(ctx);
163
170
  try {
164
- const result = await storage.run(internal, fn);
171
+ const result = await storage.run(internal, async () => {
172
+ const drain = async () => {
173
+ let drained = -1;
174
+ while (drained !== internal.parallelJoins.length + internal.workflowTasks.length) {
175
+ drained = internal.parallelJoins.length + internal.workflowTasks.length;
176
+ await Promise.allSettled(internal.parallelJoins);
177
+ await Promise.all(internal.workflowTasks.map((task) => task.done));
178
+ }
179
+ internal.workflowContextClosed = true;
180
+ };
181
+ try {
182
+ const result2 = await fn();
183
+ await drain();
184
+ if (internal.workflowControlError) throw internal.workflowControlError;
185
+ return result2;
186
+ } catch (error) {
187
+ await drain();
188
+ throw internal.workflowControlError ?? error;
189
+ }
190
+ });
165
191
  cleanupCompletedReasonCheckpoints(internal);
166
192
  for (const key of Object.keys(internal.currentNodeState.byKey)) {
167
- if (key.startsWith("__useWorkflow:") || key === "__useWorkflowActivation") {
193
+ if (key.startsWith("__useWorkflow:") || key.startsWith("__parallel:") || key === "__useWorkflowActivation") {
168
194
  internal.currentNodeState.byKey[key] = null;
169
195
  internal.stateDirty = true;
170
196
  }
@@ -2662,9 +2688,161 @@ function useWorkflowState(key, ...rest) {
2662
2688
  return [getValue(), setValue];
2663
2689
  }
2664
2690
 
2691
+ // src/parallel.ts
2692
+ var import_core6 = require("@kortyx/core");
2693
+ var ParallelChildWaiting = class extends Error {
2694
+ };
2695
+ var ParallelError = class extends AggregateError {
2696
+ constructor(results) {
2697
+ super(
2698
+ results.flatMap(
2699
+ (result) => result.status === "rejected" ? [result.reason] : []
2700
+ ),
2701
+ "One or more parallel operations failed."
2702
+ );
2703
+ this.results = results;
2704
+ this.name = "ParallelError";
2705
+ }
2706
+ results;
2707
+ };
2708
+ function registerWorkflowTask(id) {
2709
+ const ctx = getHookContext();
2710
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2711
+ if (ctx.workflowContextClosed)
2712
+ throw new Error("The workflow node activation has finished.");
2713
+ const group = ctx.parallelGroup;
2714
+ if (group?.restored && !group.record.calls.includes(id)) {
2715
+ throw new Error(`Parallel call membership changed during replay: '${id}'.`);
2716
+ }
2717
+ let finish;
2718
+ const task = {
2719
+ id,
2720
+ ...group ? { group } : {},
2721
+ completed: false,
2722
+ done: new Promise((resolve) => {
2723
+ finish = resolve;
2724
+ }),
2725
+ finish: () => {
2726
+ task.completed = true;
2727
+ finish();
2728
+ }
2729
+ };
2730
+ ctx.workflowTasks.push(task);
2731
+ if (group && !group.record.calls.includes(id)) group.record.calls.push(id);
2732
+ return task;
2733
+ }
2734
+ function parallel(values) {
2735
+ const settled = Promise.allSettled(values);
2736
+ let ctx;
2737
+ try {
2738
+ ctx = getHookContext();
2739
+ } catch (error) {
2740
+ return Promise.reject(error);
2741
+ }
2742
+ const join = async () => {
2743
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2744
+ if (ctx.parallelGroup)
2745
+ throw new Error(
2746
+ "Await each parallel group before starting another in the same node."
2747
+ );
2748
+ const key = `__parallel:${ctx.parallelIndex++}`;
2749
+ const saved = ctx.currentNodeState.byKey[key];
2750
+ const tasks = ctx.workflowTasks.filter(
2751
+ (task) => !task.completed && !task.group
2752
+ );
2753
+ const ids = tasks.map((task) => task.id);
2754
+ if (saved && (saved.version !== 1 || ids.some((id) => !saved.calls.includes(id)))) {
2755
+ throw new Error("Parallel call membership changed during replay.");
2756
+ }
2757
+ const group = {
2758
+ key,
2759
+ record: saved ?? { version: 1, calls: ids, interrupts: [] },
2760
+ restored: Boolean(saved),
2761
+ ready: Promise.resolve()
2762
+ };
2763
+ ctx.currentNodeState.byKey[key] = group.record;
2764
+ ctx.stateDirty = true;
2765
+ ctx.parallelGroup = group;
2766
+ for (const task of tasks) task.group = group;
2767
+ const callRecord = (id) => ctx.currentNodeState.byKey[`__useWorkflow:${id}`];
2768
+ const bridge = async (callId, index) => {
2769
+ const interruption = callRecord(callId)?.interrupts[index];
2770
+ if (!interruption)
2771
+ throw new Error(
2772
+ "Parallel interrupt history does not match the saved child."
2773
+ );
2774
+ try {
2775
+ interruption.response = await awaitInterruptInternal({
2776
+ request: interruption.request,
2777
+ ...interruption.request.meta ? { meta: interruption.request.meta } : {}
2778
+ });
2779
+ } catch (error) {
2780
+ ctx.workflowControlError = error;
2781
+ throw error;
2782
+ }
2783
+ };
2784
+ group.ready = (async () => {
2785
+ for (const entry of group.record.interrupts)
2786
+ await bridge(entry.callId, entry.index);
2787
+ })();
2788
+ try {
2789
+ const [ready, outcomes] = await Promise.all([
2790
+ Promise.allSettled([group.ready]),
2791
+ settled
2792
+ ]);
2793
+ let drained = -1;
2794
+ while (drained !== ctx.workflowTasks.length) {
2795
+ drained = ctx.workflowTasks.length;
2796
+ await Promise.all(
2797
+ ctx.workflowTasks.filter((task) => task.group === group).map((task) => task.done)
2798
+ );
2799
+ }
2800
+ if (ready[0]?.status === "rejected") throw ready[0].reason;
2801
+ (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
2802
+ if (ctx.workflowControlError) throw ctx.workflowControlError;
2803
+ const rejected = outcomes.filter(
2804
+ (outcome) => outcome.status === "rejected"
2805
+ );
2806
+ const control = rejected.find(
2807
+ (outcome) => (0, import_core6.isExecutionCancelled)(outcome.reason) || (0, import_core6.isExecutionLimitReached)(outcome.reason)
2808
+ );
2809
+ if (control) {
2810
+ ctx.workflowControlError = control.reason;
2811
+ throw control.reason;
2812
+ }
2813
+ const actual = ctx.workflowTasks.filter((task) => task.group === group).map((task) => task.id);
2814
+ if (group.record.calls.length !== actual.length || group.record.calls.some((id) => !actual.includes(id))) {
2815
+ throw new Error("Parallel call membership changed during replay.");
2816
+ }
2817
+ for (const callId of group.record.calls) {
2818
+ const record = callRecord(callId);
2819
+ if (record?.status !== "interrupted") continue;
2820
+ const index = record.interrupts.length - 1;
2821
+ if (record.interrupts[index]?.response !== void 0) continue;
2822
+ group.record.interrupts.push({ callId, index });
2823
+ await bridge(callId, index);
2824
+ throw new Error(
2825
+ "Parallel interrupt unexpectedly resumed without replay."
2826
+ );
2827
+ }
2828
+ if (rejected.length) throw new ParallelError(outcomes);
2829
+ return outcomes.map(
2830
+ (outcome) => outcome.value
2831
+ );
2832
+ } finally {
2833
+ ctx.parallelGroup = void 0;
2834
+ }
2835
+ };
2836
+ const promise = join();
2837
+ ctx.parallelJoins.push(promise);
2838
+ void promise.catch(() => {
2839
+ });
2840
+ return promise;
2841
+ }
2842
+
2665
2843
  // src/workflow.ts
2666
2844
  var import_node_crypto5 = require("crypto");
2667
- var import_core6 = require("@kortyx/core");
2845
+ var import_core7 = require("@kortyx/core");
2668
2846
 
2669
2847
  // src/workflow-telemetry.ts
2670
2848
  var import_node_crypto4 = require("crypto");
@@ -2750,16 +2928,30 @@ function createWorkflowHooks(workflows) {
2750
2928
  };
2751
2929
  }
2752
2930
  async function useWorkflow(args) {
2931
+ const task = registerWorkflowTask(args.id);
2932
+ try {
2933
+ await Promise.resolve();
2934
+ if (task.group) await task.group.ready;
2935
+ return await runWorkflowCall(args, task);
2936
+ } catch (error) {
2937
+ if ((0, import_core7.isExecutionCancelled)(error) || (0, import_core7.isExecutionLimitReached)(error))
2938
+ getHookContext().workflowControlError = error;
2939
+ throw error;
2940
+ } finally {
2941
+ task.finish();
2942
+ }
2943
+ }
2944
+ async function runWorkflowCall(args, task) {
2753
2945
  const ctx = getHookContext();
2754
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
2946
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
2755
2947
  const service = ctx.node.callWorkflow;
2756
2948
  if (!service)
2757
2949
  throw new Error("useWorkflow requires an agent workflow registry.");
2758
2950
  if (!args.id.trim())
2759
2951
  throw new Error("useWorkflow requires a stable, nonempty id.");
2760
- if (ctx.workflowCallActive)
2952
+ if (ctx.workflowCallActive && !task.group)
2761
2953
  throw new Error(
2762
- "Concurrent workflow calls are not supported; await each call."
2954
+ "Concurrent workflow calls require parallel([...]); otherwise await each call."
2763
2955
  );
2764
2956
  if (ctx.workflowCallIds.has(args.id))
2765
2957
  throw new Error(
@@ -2780,6 +2972,11 @@ async function useWorkflow(args) {
2780
2972
  });
2781
2973
  const key = `__useWorkflow:${args.id}`;
2782
2974
  let record = ctx.currentNodeState.byKey[key];
2975
+ if (record && record.parallelKey !== task.group?.key)
2976
+ throw new WorkflowCallError(
2977
+ workflow,
2978
+ "parallel call membership changed during replay"
2979
+ );
2783
2980
  if (record && record.fingerprint !== fingerprint)
2784
2981
  throw new WorkflowCallError(
2785
2982
  workflow,
@@ -2790,11 +2987,12 @@ async function useWorkflow(args) {
2790
2987
  fingerprint,
2791
2988
  invocationId: (0, import_node_crypto5.randomUUID)(),
2792
2989
  status: "running",
2793
- interrupts: []
2990
+ interrupts: [],
2991
+ ...task.group ? { parallelKey: task.group.key } : {}
2794
2992
  };
2795
2993
  ctx.currentNodeState.byKey[key] = record;
2796
2994
  ctx.stateDirty = true;
2797
- ctx.workflowCallActive = true;
2995
+ if (!task.group) ctx.workflowCallActive = true;
2798
2996
  const telemetry = ctx.node.workflowCallTelemetry;
2799
2997
  const activationKey = "__useWorkflowActivation";
2800
2998
  ctx.currentNodeState.byKey[activationKey] ??= (0, import_node_crypto5.randomUUID)();
@@ -2830,7 +3028,7 @@ async function useWorkflow(args) {
2830
3028
  };
2831
3029
  const cached = record.status === "completed";
2832
3030
  try {
2833
- for (const interruption of record.interrupts) {
3031
+ for (const interruption of task.group ? [] : record.interrupts) {
2834
3032
  interruption.response = await awaitInterruptInternal({
2835
3033
  request: interruption.request,
2836
3034
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
@@ -2838,6 +3036,8 @@ async function useWorkflow(args) {
2838
3036
  }
2839
3037
  if (record.status === "failed")
2840
3038
  throw new WorkflowCallError(workflow, record.error ?? "child failed");
3039
+ if (task.group && record.status === "interrupted" && record.interrupts.at(-1)?.response === void 0)
3040
+ throw new ParallelChildWaiting();
2841
3041
  while (record.status !== "completed") {
2842
3042
  let outcome;
2843
3043
  try {
@@ -2856,13 +3056,13 @@ async function useWorkflow(args) {
2856
3056
  ...record.interrupts.length ? { response: record.interrupts.at(-1)?.response } : {}
2857
3057
  });
2858
3058
  } catch (error) {
2859
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
2860
- if ((0, import_core6.isExecutionCancelled)(error)) throw error;
2861
- if ((0, import_core6.isExecutionLimitReached)(error)) {
2862
- const child = error;
2863
- if (child.__kortyxChildSnapshot)
3059
+ const child = error;
3060
+ recordUsage(child?.__kortyxChildUsage);
3061
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
3062
+ if ((0, import_core7.isExecutionCancelled)(error)) throw error;
3063
+ if ((0, import_core7.isExecutionLimitReached)(error)) {
3064
+ if (child?.__kortyxChildSnapshot)
2864
3065
  record.snapshot = child.__kortyxChildSnapshot;
2865
- recordUsage(child.__kortyxChildUsage);
2866
3066
  report("suspended", {
2867
3067
  reason: "limit_reached",
2868
3068
  limit: error.limitReached
@@ -2874,7 +3074,7 @@ async function useWorkflow(args) {
2874
3074
  report("failed", { error: "Child workflow failed" });
2875
3075
  throw new WorkflowCallError(workflow, record.error);
2876
3076
  }
2877
- (0, import_core6.throwIfExecutionAborted)(ctx.node.abortSignal);
3077
+ (0, import_core7.throwIfExecutionAborted)(ctx.node.abortSignal);
2878
3078
  recordUsage(outcome.usage);
2879
3079
  if (outcome.status === "completed") {
2880
3080
  record.data = outcome.data;
@@ -2895,6 +3095,7 @@ async function useWorkflow(args) {
2895
3095
  request: outcome.request
2896
3096
  };
2897
3097
  record.interrupts.push(interruption);
3098
+ if (task.group) throw new ParallelChildWaiting();
2898
3099
  interruption.response = await awaitInterruptInternal({
2899
3100
  request: interruption.request,
2900
3101
  ...interruption.request.meta ? { meta: interruption.request.meta } : {}
@@ -2908,15 +3109,17 @@ async function useWorkflow(args) {
2908
3109
  data: JSON.parse(JSON.stringify(data))
2909
3110
  };
2910
3111
  } finally {
2911
- ctx.workflowCallActive = false;
3112
+ if (!task.group) ctx.workflowCallActive = false;
2912
3113
  }
2913
3114
  }
2914
3115
  // Annotate the CommonJS export names for ESM import in node:
2915
3116
  0 && (module.exports = {
3117
+ ParallelError,
2916
3118
  WorkflowCallError,
2917
3119
  completeResponse,
2918
3120
  createWorkflowHooks,
2919
3121
  emitWorkflowCall,
3122
+ parallel,
2920
3123
  runWithHookContext,
2921
3124
  useAbortSignal,
2922
3125
  useInterrupt,