@donkeylabs/server 2.0.20 → 2.0.22

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.
@@ -12,9 +12,10 @@ import type { Events } from "./events";
12
12
  import type { Jobs } from "./jobs";
13
13
  import type { SSE } from "./sse";
14
14
  import type { z } from "zod";
15
+ import { sql } from "kysely";
15
16
  import type { CoreServices } from "../core";
16
- import { dirname, join } from "node:path";
17
- import { fileURLToPath } from "node:url";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
19
  import {
19
20
  createWorkflowSocketServer,
20
21
  type WorkflowSocketServer,
@@ -22,6 +23,32 @@ import {
22
23
  type ProxyRequest,
23
24
  } from "./workflow-socket";
24
25
  import { isProcessAlive } from "./external-jobs";
26
+ import { WorkflowStateMachine, type StateMachineEvents } from "./workflow-state-machine";
27
+
28
+ // ============================================
29
+ // Auto-detect caller module for isolated workflows
30
+ // ============================================
31
+
32
+ const WORKFLOWS_FILE = resolve(fileURLToPath(import.meta.url));
33
+
34
+ /**
35
+ * Walk the call stack to find the file that invoked build().
36
+ * Returns a file:// URL string or undefined if detection fails.
37
+ */
38
+ function captureCallerUrl(): string | undefined {
39
+ const stack = new Error().stack ?? "";
40
+ for (const line of stack.split("\n").slice(1)) {
41
+ const match = line.match(/at\s+(?:.*?\s+\(?)?([^\s():]+):\d+:\d+/);
42
+ if (match) {
43
+ let filePath = match[1];
44
+ if (filePath.startsWith("file://")) filePath = fileURLToPath(filePath);
45
+ if (filePath.startsWith("native")) continue;
46
+ filePath = resolve(filePath);
47
+ if (filePath !== WORKFLOWS_FILE) return pathToFileURL(filePath).href;
48
+ }
49
+ }
50
+ return undefined;
51
+ }
25
52
 
26
53
  // Type helper for Zod schema inference
27
54
  type ZodSchema = z.ZodTypeAny;
@@ -143,6 +170,8 @@ export interface WorkflowDefinition {
143
170
  * Set to false for lightweight workflows that benefit from inline execution.
144
171
  */
145
172
  isolated?: boolean;
173
+ /** Auto-detected module URL where this workflow was built. Used as fallback for isolated execution. */
174
+ sourceModule?: string;
146
175
  }
147
176
 
148
177
  // ============================================
@@ -575,6 +604,7 @@ export class WorkflowBuilder {
575
604
  timeout: this._timeout,
576
605
  defaultRetry: this._defaultRetry,
577
606
  isolated: this._isolated,
607
+ sourceModule: captureCallerUrl(),
578
608
  };
579
609
  }
580
610
  }
@@ -616,10 +646,15 @@ export interface WorkflowsConfig {
616
646
  export interface WorkflowRegisterOptions {
617
647
  /**
618
648
  * Module path for isolated workflows.
619
- * Required when workflow.isolated !== false and running in isolated mode.
620
- * Use `import.meta.url` to get the current module's path.
649
+ * Auto-detected from the call site of `build()` in most cases.
650
+ * Only needed if the workflow definition is re-exported from a different
651
+ * module than the one that calls `build()`.
621
652
  *
622
653
  * @example
654
+ * // Usually not needed — auto-detected:
655
+ * workflows.register(myWorkflow);
656
+ *
657
+ * // Override when re-exporting from another module:
623
658
  * workflows.register(myWorkflow, { modulePath: import.meta.url });
624
659
  */
625
660
  modulePath?: string;
@@ -648,6 +683,8 @@ export interface Workflows {
648
683
  stop(): Promise<void>;
649
684
  /** Set core services (called after initialization to resolve circular dependency) */
650
685
  setCore(core: CoreServices): void;
686
+ /** Resolve dbPath from the database instance (call after setCore, before resume) */
687
+ resolveDbPath(): Promise<void>;
651
688
  /** Set plugin services (called after plugins are initialized) */
652
689
  setPlugins(plugins: Record<string, any>): void;
653
690
  /** Update metadata for a workflow instance (used by isolated workflows) */
@@ -655,7 +692,7 @@ export interface Workflows {
655
692
  }
656
693
 
657
694
  // ============================================
658
- // Workflow Service Implementation
695
+ // Workflow Service Implementation (Supervisor)
659
696
  // ============================================
660
697
 
661
698
  interface IsolatedProcessInfo {
@@ -667,13 +704,13 @@ interface IsolatedProcessInfo {
667
704
 
668
705
  class WorkflowsImpl implements Workflows {
669
706
  private adapter: WorkflowAdapter;
670
- private events?: Events;
707
+ private eventsService?: Events;
671
708
  private jobs?: Jobs;
672
709
  private sse?: SSE;
673
710
  private core?: CoreServices;
674
711
  private plugins: Record<string, any> = {};
675
712
  private definitions = new Map<string, WorkflowDefinition>();
676
- private running = new Map<string, { timeout?: ReturnType<typeof setTimeout> }>();
713
+ private running = new Map<string, { timeout?: ReturnType<typeof setTimeout>; sm?: WorkflowStateMachine }>();
677
714
  private pollInterval: number;
678
715
 
679
716
  // Isolated execution state
@@ -687,7 +724,7 @@ class WorkflowsImpl implements Workflows {
687
724
 
688
725
  constructor(config: WorkflowsConfig = {}) {
689
726
  this.adapter = config.adapter ?? new MemoryWorkflowAdapter();
690
- this.events = config.events;
727
+ this.eventsService = config.events;
691
728
  this.jobs = config.jobs;
692
729
  this.sse = config.sse;
693
730
  this.core = config.core;
@@ -727,19 +764,21 @@ class WorkflowsImpl implements Workflows {
727
764
 
728
765
  setCore(core: CoreServices): void {
729
766
  this.core = core;
730
- // Extract DB path if using Kysely adapter (for isolated workflows)
731
- if (!this.dbPath && (core.db as any)?.getExecutor) {
732
- // Try to get the database path from the Kysely instance
733
- // This is a bit hacky but necessary for isolated workflows
734
- try {
735
- const executor = (core.db as any).getExecutor();
736
- const adapter = executor?.adapter;
737
- if (adapter?.db?.filename) {
738
- this.dbPath = adapter.db.filename;
739
- }
740
- } catch {
741
- // Ignore - dbPath might be set manually
767
+ }
768
+
769
+ async resolveDbPath(): Promise<void> {
770
+ if (this.dbPath) return;
771
+ if (!this.core?.db) return;
772
+
773
+ // Use PRAGMA database_list to get the file path — works with any SQLite dialect
774
+ try {
775
+ const result = await sql<{ name: string; file: string }>`PRAGMA database_list`.execute(this.core.db);
776
+ const main = result.rows.find((r) => r.name === "main");
777
+ if (main?.file && main.file !== "" && main.file !== ":memory:") {
778
+ this.dbPath = main.file;
742
779
  }
780
+ } catch {
781
+ // Not a SQLite database or PRAGMA not supported — dbPath stays unset
743
782
  }
744
783
  }
745
784
 
@@ -760,26 +799,15 @@ class WorkflowsImpl implements Workflows {
760
799
  throw new Error(`Workflow "${definition.name}" is already registered`);
761
800
  }
762
801
 
763
- // Validate isolated workflows don't use unsupported step types
764
- if (definition.isolated !== false) {
765
- for (const [stepName, step] of definition.steps) {
766
- if (step.type === "choice" || step.type === "parallel") {
767
- throw new Error(
768
- `Workflow "${definition.name}" uses ${step.type} step "${stepName}" ` +
769
- `which is not supported in isolated mode. Use .isolated(false) to run inline.`
770
- );
771
- }
772
- }
773
- }
774
-
775
- // Store module path for isolated workflows
776
- if (options?.modulePath) {
777
- this.workflowModulePaths.set(definition.name, options.modulePath);
802
+ // Resolve module path: explicit option > auto-detected sourceModule
803
+ const modulePath = options?.modulePath ?? definition.sourceModule;
804
+ if (modulePath) {
805
+ this.workflowModulePaths.set(definition.name, modulePath);
778
806
  } else if (definition.isolated !== false) {
779
- // Warn if isolated workflow has no module path
807
+ // Warn only if neither explicit nor auto-detected path is available
780
808
  console.warn(
781
- `[Workflows] Workflow "${definition.name}" is isolated but no modulePath provided. ` +
782
- `Use: workflows.register(myWorkflow, { modulePath: import.meta.url })`
809
+ `[Workflows] Workflow "${definition.name}" is isolated but no modulePath could be detected. ` +
810
+ `Pass { modulePath: import.meta.url } to register().`
783
811
  );
784
812
  }
785
813
 
@@ -829,13 +857,18 @@ class WorkflowsImpl implements Workflows {
829
857
  // Execute in isolated subprocess
830
858
  this.executeIsolatedWorkflow(instance.id, definition, input, modulePath);
831
859
  } else {
832
- // Execute inline (existing behavior)
860
+ // Execute inline using state machine
833
861
  if (isIsolated && !modulePath) {
834
862
  console.warn(
835
863
  `[Workflows] Workflow "${workflowName}" falling back to inline execution (no modulePath)`
836
864
  );
865
+ } else if (isIsolated && modulePath && !this.dbPath) {
866
+ console.warn(
867
+ `[Workflows] Workflow "${workflowName}" falling back to inline execution (dbPath could not be auto-detected). ` +
868
+ `Set workflows.dbPath in your server config to enable isolated execution.`
869
+ );
837
870
  }
838
- this.executeWorkflow(instance.id, definition);
871
+ this.startInlineWorkflow(instance.id, definition);
839
872
  }
840
873
 
841
874
  return instance.id;
@@ -865,8 +898,11 @@ class WorkflowsImpl implements Workflows {
865
898
  await this.getSocketServer().closeSocket(instanceId);
866
899
  }
867
900
 
868
- // Clear inline timeout
901
+ // Cancel inline state machine if running
869
902
  const runInfo = this.running.get(instanceId);
903
+ if (runInfo?.sm) {
904
+ runInfo.sm.cancel(instanceId);
905
+ }
870
906
  if (runInfo?.timeout) {
871
907
  clearTimeout(runInfo.timeout);
872
908
  }
@@ -918,7 +954,7 @@ class WorkflowsImpl implements Workflows {
918
954
  if (isIsolated && modulePath && this.dbPath) {
919
955
  this.executeIsolatedWorkflow(instance.id, definition, instance.input, modulePath);
920
956
  } else {
921
- this.executeWorkflow(instance.id, definition);
957
+ this.startInlineWorkflow(instance.id, definition);
922
958
  }
923
959
  }
924
960
  }
@@ -942,8 +978,11 @@ class WorkflowsImpl implements Workflows {
942
978
  this.socketServer = undefined;
943
979
  }
944
980
 
945
- // Clear all inline timeouts
981
+ // Clear all inline timeouts and cancel state machines
946
982
  for (const [instanceId, runInfo] of this.running) {
983
+ if (runInfo.sm) {
984
+ runInfo.sm.cancel(instanceId);
985
+ }
947
986
  if (runInfo.timeout) {
948
987
  clearTimeout(runInfo.timeout);
949
988
  }
@@ -957,761 +996,177 @@ class WorkflowsImpl implements Workflows {
957
996
  }
958
997
 
959
998
  // ============================================
960
- // Execution Engine
999
+ // Inline Execution via State Machine
961
1000
  // ============================================
962
1001
 
963
- private async executeWorkflow(
1002
+ private startInlineWorkflow(
964
1003
  instanceId: string,
965
- definition: WorkflowDefinition
966
- ): Promise<void> {
967
- const instance = await this.adapter.getInstance(instanceId);
968
- if (!instance) return;
969
-
970
- // Mark as running
971
- if (instance.status === "pending") {
972
- await this.adapter.updateInstance(instanceId, {
973
- status: "running",
974
- startedAt: new Date(),
975
- });
976
- }
1004
+ definition: WorkflowDefinition,
1005
+ ): void {
1006
+ const sm = new WorkflowStateMachine({
1007
+ adapter: this.adapter,
1008
+ core: this.core,
1009
+ plugins: this.plugins,
1010
+ events: this.createInlineEventHandler(instanceId),
1011
+ jobs: this.jobs,
1012
+ pollInterval: this.pollInterval,
1013
+ });
977
1014
 
978
1015
  // Set up workflow timeout
1016
+ let timeout: ReturnType<typeof setTimeout> | undefined;
979
1017
  if (definition.timeout) {
980
- const timeout = setTimeout(async () => {
981
- await this.failWorkflow(instanceId, "Workflow timed out");
982
- }, definition.timeout);
983
- this.running.set(instanceId, { timeout });
984
- } else {
985
- this.running.set(instanceId, {});
986
- }
987
-
988
- // Execute current step
989
- await this.executeStep(instanceId, definition);
990
- }
991
-
992
- private async executeStep(
993
- instanceId: string,
994
- definition: WorkflowDefinition
995
- ): Promise<void> {
996
- const instance = await this.adapter.getInstance(instanceId);
997
- if (!instance || instance.status !== "running") return;
998
-
999
- const stepName = instance.currentStep;
1000
- if (!stepName) {
1001
- await this.completeWorkflow(instanceId);
1002
- return;
1003
- }
1004
-
1005
- const step = definition.steps.get(stepName);
1006
- if (!step) {
1007
- await this.failWorkflow(instanceId, `Step "${stepName}" not found`);
1008
- return;
1009
- }
1010
-
1011
- // Build context
1012
- const ctx = this.buildContext(instance, definition);
1013
-
1014
- // Emit step started event
1015
- await this.emitEvent("workflow.step.started", {
1016
- instanceId,
1017
- workflowName: instance.workflowName,
1018
- stepName,
1019
- stepType: step.type,
1020
- });
1021
-
1022
- // Broadcast via SSE
1023
- if (this.sse) {
1024
- this.sse.broadcast(`workflow:${instanceId}`, "step.started", { stepName });
1025
- this.sse.broadcast("workflows:all", "workflow.step.started", {
1026
- instanceId,
1027
- workflowName: instance.workflowName,
1028
- stepName,
1029
- });
1030
- }
1031
-
1032
- // Update step result as running
1033
- const stepResult: StepResult = {
1034
- stepName,
1035
- status: "running",
1036
- startedAt: new Date(),
1037
- attempts: (instance.stepResults[stepName]?.attempts ?? 0) + 1,
1038
- };
1039
- await this.adapter.updateInstance(instanceId, {
1040
- stepResults: { ...instance.stepResults, [stepName]: stepResult },
1041
- });
1042
-
1043
- try {
1044
- let output: any;
1045
-
1046
- switch (step.type) {
1047
- case "task":
1048
- output = await this.executeTaskStep(instanceId, step, ctx, definition);
1049
- break;
1050
- case "parallel":
1051
- output = await this.executeParallelStep(instanceId, step, ctx, definition);
1052
- break;
1053
- case "choice":
1054
- output = await this.executeChoiceStep(instanceId, step, ctx, definition);
1055
- break;
1056
- case "pass":
1057
- output = await this.executePassStep(instanceId, step, ctx);
1058
- break;
1059
- }
1060
-
1061
- // Step completed successfully
1062
- await this.completeStep(instanceId, stepName, output, step, definition);
1063
- } catch (error) {
1064
- const errorMsg = error instanceof Error ? error.message : String(error);
1065
- await this.handleStepError(instanceId, stepName, errorMsg, step, definition);
1066
- }
1067
- }
1068
-
1069
- private async executeTaskStep(
1070
- instanceId: string,
1071
- step: TaskStepDefinition,
1072
- ctx: WorkflowContext,
1073
- definition: WorkflowDefinition
1074
- ): Promise<any> {
1075
- // Determine which API is being used
1076
- const useInlineHandler = !!step.handler;
1077
-
1078
- if (useInlineHandler) {
1079
- // === NEW API: Inline handler with Zod schemas ===
1080
- let input: any;
1081
-
1082
- if (step.inputSchema) {
1083
- if (typeof step.inputSchema === "function") {
1084
- // inputSchema is a mapper function: (prev, workflowInput) => input
1085
- input = step.inputSchema(ctx.prev, ctx.input);
1086
- } else {
1087
- // inputSchema is a Zod schema - validate workflow input
1088
- const parseResult = step.inputSchema.safeParse(ctx.input);
1089
- if (!parseResult.success) {
1090
- throw new Error(`Input validation failed: ${parseResult.error.message}`);
1091
- }
1092
- input = parseResult.data;
1093
- }
1094
- } else {
1095
- // No input schema, use workflow input directly
1096
- input = ctx.input;
1097
- }
1098
-
1099
- // Update step with input
1100
- const instance = await this.adapter.getInstance(instanceId);
1101
- if (instance) {
1102
- const stepResult = instance.stepResults[step.name];
1103
- if (stepResult) {
1104
- stepResult.input = input;
1105
- await this.adapter.updateInstance(instanceId, {
1106
- stepResults: { ...instance.stepResults, [step.name]: stepResult },
1107
- });
1108
- }
1109
- }
1110
-
1111
- // Execute the inline handler
1112
- let result = await step.handler!(input, ctx);
1113
-
1114
- // Validate output if schema provided
1115
- if (step.outputSchema) {
1116
- const parseResult = step.outputSchema.safeParse(result);
1117
- if (!parseResult.success) {
1118
- throw new Error(`Output validation failed: ${parseResult.error.message}`);
1119
- }
1120
- result = parseResult.data;
1121
- }
1122
-
1123
- return result;
1124
- } else {
1125
- // === LEGACY API: Job-based execution ===
1126
- if (!this.jobs) {
1127
- throw new Error("Jobs service not configured");
1128
- }
1129
-
1130
- if (!step.job) {
1131
- throw new Error("Task step requires either 'handler' or 'job'");
1132
- }
1133
-
1134
- // Prepare job input
1135
- const jobInput = step.input ? step.input(ctx) : ctx.input;
1136
-
1137
- // Update step with input
1138
- const instance = await this.adapter.getInstance(instanceId);
1139
- if (instance) {
1140
- const stepResult = instance.stepResults[step.name];
1141
- if (stepResult) {
1142
- stepResult.input = jobInput;
1143
- await this.adapter.updateInstance(instanceId, {
1144
- stepResults: { ...instance.stepResults, [step.name]: stepResult },
1018
+ timeout = setTimeout(async () => {
1019
+ sm.cancel(instanceId);
1020
+ await this.adapter.updateInstance(instanceId, {
1021
+ status: "failed",
1022
+ error: "Workflow timed out",
1023
+ completedAt: new Date(),
1024
+ });
1025
+ await this.emitEvent("workflow.failed", {
1026
+ instanceId,
1027
+ workflowName: definition.name,
1028
+ error: "Workflow timed out",
1029
+ });
1030
+ if (this.sse) {
1031
+ this.sse.broadcast(`workflow:${instanceId}`, "failed", { error: "Workflow timed out" });
1032
+ this.sse.broadcast("workflows:all", "workflow.failed", {
1033
+ instanceId,
1034
+ workflowName: definition.name,
1035
+ error: "Workflow timed out",
1145
1036
  });
1146
1037
  }
1147
- }
1148
-
1149
- // Enqueue the job
1150
- const jobId = await this.jobs.enqueue(step.job, {
1151
- ...jobInput,
1152
- _workflowInstanceId: instanceId,
1153
- _workflowStepName: step.name,
1154
- });
1155
-
1156
- // Wait for job completion
1157
- const result = await this.waitForJob(jobId, step.timeout);
1158
-
1159
- // Transform output if needed
1160
- return step.output ? step.output(result, ctx) : result;
1161
- }
1162
- }
1163
-
1164
- private async waitForJob(jobId: string, timeout?: number): Promise<any> {
1165
- if (!this.jobs) {
1166
- throw new Error("Jobs service not configured");
1038
+ this.running.delete(instanceId);
1039
+ }, definition.timeout);
1167
1040
  }
1168
1041
 
1169
- const startTime = Date.now();
1170
-
1171
- while (true) {
1172
- const job = await this.jobs.get(jobId);
1173
-
1174
- if (!job) {
1175
- throw new Error(`Job ${jobId} not found`);
1176
- }
1177
-
1178
- if (job.status === "completed") {
1179
- return job.result;
1180
- }
1181
-
1182
- if (job.status === "failed") {
1183
- throw new Error(job.error ?? "Job failed");
1184
- }
1042
+ this.running.set(instanceId, { timeout, sm });
1185
1043
 
1186
- // Check timeout
1187
- if (timeout && Date.now() - startTime > timeout) {
1188
- throw new Error("Job timed out");
1044
+ // Run the state machine (fire and forget - events handle communication)
1045
+ sm.run(instanceId, definition).then(() => {
1046
+ // Clean up timeout on completion
1047
+ const runInfo = this.running.get(instanceId);
1048
+ if (runInfo?.timeout) {
1049
+ clearTimeout(runInfo.timeout);
1189
1050
  }
1190
-
1191
- // Wait before polling again
1192
- await new Promise((resolve) => setTimeout(resolve, this.pollInterval));
1193
- }
1194
- }
1195
-
1196
- private async executeParallelStep(
1197
- instanceId: string,
1198
- step: ParallelStepDefinition,
1199
- ctx: WorkflowContext,
1200
- definition: WorkflowDefinition
1201
- ): Promise<any> {
1202
- const branchPromises: Promise<{ name: string; result: any }>[] = [];
1203
- const branchInstanceIds: string[] = [];
1204
-
1205
- for (const branchDef of step.branches) {
1206
- // Register branch workflow if not already
1207
- if (!this.definitions.has(branchDef.name)) {
1208
- this.definitions.set(branchDef.name, branchDef);
1051
+ this.running.delete(instanceId);
1052
+ }).catch(() => {
1053
+ // State machine already persisted the failure - just clean up
1054
+ const runInfo = this.running.get(instanceId);
1055
+ if (runInfo?.timeout) {
1056
+ clearTimeout(runInfo.timeout);
1209
1057
  }
1210
-
1211
- // Start branch as sub-workflow
1212
- const branchInstanceId = await this.adapter.createInstance({
1213
- workflowName: branchDef.name,
1214
- status: "pending",
1215
- currentStep: branchDef.startAt,
1216
- input: ctx.input,
1217
- stepResults: {},
1218
- createdAt: new Date(),
1219
- parentId: instanceId,
1220
- branchName: branchDef.name,
1221
- });
1222
-
1223
- branchInstanceIds.push(branchInstanceId.id);
1224
-
1225
- // Execute branch
1226
- const branchPromise = (async () => {
1227
- await this.executeWorkflow(branchInstanceId.id, branchDef);
1228
-
1229
- // Wait for branch completion
1230
- while (true) {
1231
- const branchInstance = await this.adapter.getInstance(branchInstanceId.id);
1232
- if (!branchInstance) {
1233
- throw new Error(`Branch instance ${branchInstanceId.id} not found`);
1234
- }
1235
-
1236
- if (branchInstance.status === "completed") {
1237
- return { name: branchDef.name, result: branchInstance.output };
1238
- }
1239
-
1240
- if (branchInstance.status === "failed") {
1241
- throw new Error(branchInstance.error ?? `Branch ${branchDef.name} failed`);
1242
- }
1243
-
1244
- await new Promise((resolve) => setTimeout(resolve, this.pollInterval));
1245
- }
1246
- })();
1247
-
1248
- branchPromises.push(branchPromise);
1249
- }
1250
-
1251
- // Track branch instances
1252
- await this.adapter.updateInstance(instanceId, {
1253
- branchInstances: {
1254
- ...((await this.adapter.getInstance(instanceId))?.branchInstances ?? {}),
1255
- [step.name]: branchInstanceIds,
1256
- },
1058
+ this.running.delete(instanceId);
1257
1059
  });
1258
-
1259
- // Wait for all branches
1260
- if (step.onError === "wait-all") {
1261
- const results = await Promise.allSettled(branchPromises);
1262
- const output: Record<string, any> = {};
1263
- const errors: string[] = [];
1264
-
1265
- for (const result of results) {
1266
- if (result.status === "fulfilled") {
1267
- output[result.value.name] = result.value.result;
1268
- } else {
1269
- errors.push(result.reason?.message ?? "Branch failed");
1270
- }
1271
- }
1272
-
1273
- if (errors.length > 0) {
1274
- throw new Error(`Parallel branches failed: ${errors.join(", ")}`);
1275
- }
1276
-
1277
- return output;
1278
- } else {
1279
- // fail-fast (default)
1280
- const results = await Promise.all(branchPromises);
1281
- const output: Record<string, any> = {};
1282
- for (const result of results) {
1283
- output[result.name] = result.result;
1284
- }
1285
- return output;
1286
- }
1287
1060
  }
1288
1061
 
1289
- private async executeChoiceStep(
1290
- instanceId: string,
1291
- step: ChoiceStepDefinition,
1292
- ctx: WorkflowContext,
1293
- definition: WorkflowDefinition
1294
- ): Promise<string> {
1295
- // Evaluate conditions in order
1296
- for (const choice of step.choices) {
1297
- try {
1298
- if (choice.condition(ctx)) {
1299
- // Update current step and continue
1300
- await this.adapter.updateInstance(instanceId, {
1301
- currentStep: choice.next,
1302
- });
1303
-
1304
- // Mark choice step as complete
1305
- const instance = await this.adapter.getInstance(instanceId);
1306
- if (instance) {
1307
- const stepResult = instance.stepResults[step.name];
1308
- if (stepResult) {
1309
- stepResult.status = "completed";
1310
- stepResult.output = { chosen: choice.next };
1311
- stepResult.completedAt = new Date();
1312
- await this.adapter.updateInstance(instanceId, {
1313
- stepResults: { ...instance.stepResults, [step.name]: stepResult },
1314
- });
1315
- }
1316
- }
1317
-
1318
- // Emit progress
1319
- await this.emitEvent("workflow.step.completed", {
1320
- instanceId,
1321
- workflowName: (await this.adapter.getInstance(instanceId))?.workflowName,
1322
- stepName: step.name,
1323
- output: { chosen: choice.next },
1062
+ /**
1063
+ * Create an event handler that bridges state machine events to Events service + SSE
1064
+ */
1065
+ private createInlineEventHandler(instanceId: string): StateMachineEvents {
1066
+ return {
1067
+ onStepStarted: (id, stepName, stepType) => {
1068
+ this.emitEvent("workflow.step.started", {
1069
+ instanceId: id,
1070
+ stepName,
1071
+ stepType,
1072
+ });
1073
+ if (this.sse) {
1074
+ this.sse.broadcast(`workflow:${id}`, "step.started", { stepName });
1075
+ this.sse.broadcast("workflows:all", "workflow.step.started", {
1076
+ instanceId: id,
1077
+ stepName,
1324
1078
  });
1325
-
1326
- // Execute next step
1327
- await this.executeStep(instanceId, definition);
1328
- return choice.next;
1329
1079
  }
1330
- } catch {
1331
- // Condition threw, try next
1332
- }
1333
- }
1334
-
1335
- // No condition matched, use default
1336
- if (step.default) {
1337
- await this.adapter.updateInstance(instanceId, {
1338
- currentStep: step.default,
1339
- });
1340
-
1341
- // Mark choice step as complete
1342
- const instance = await this.adapter.getInstance(instanceId);
1343
- if (instance) {
1344
- const stepResult = instance.stepResults[step.name];
1345
- if (stepResult) {
1346
- stepResult.status = "completed";
1347
- stepResult.output = { chosen: step.default };
1348
- stepResult.completedAt = new Date();
1349
- await this.adapter.updateInstance(instanceId, {
1350
- stepResults: { ...instance.stepResults, [step.name]: stepResult },
1080
+ },
1081
+ onStepCompleted: (id, stepName, output, nextStep) => {
1082
+ this.emitEvent("workflow.step.completed", {
1083
+ instanceId: id,
1084
+ stepName,
1085
+ output,
1086
+ });
1087
+ if (this.sse) {
1088
+ this.sse.broadcast(`workflow:${id}`, "step.completed", { stepName, output });
1089
+ this.sse.broadcast("workflows:all", "workflow.step.completed", {
1090
+ instanceId: id,
1091
+ stepName,
1351
1092
  });
1352
1093
  }
1353
- }
1354
-
1355
- await this.emitEvent("workflow.step.completed", {
1356
- instanceId,
1357
- workflowName: instance?.workflowName,
1358
- stepName: step.name,
1359
- output: { chosen: step.default },
1360
- });
1361
-
1362
- await this.executeStep(instanceId, definition);
1363
- return step.default;
1364
- }
1365
-
1366
- throw new Error("No choice condition matched and no default specified");
1367
- }
1368
-
1369
- private async executePassStep(
1370
- instanceId: string,
1371
- step: PassStepDefinition,
1372
- ctx: WorkflowContext
1373
- ): Promise<any> {
1374
- if (step.result !== undefined) {
1375
- return step.result;
1376
- }
1377
-
1378
- if (step.transform) {
1379
- return step.transform(ctx);
1380
- }
1381
-
1382
- return ctx.input;
1383
- }
1384
-
1385
- private buildContext(instance: WorkflowInstance, definition: WorkflowDefinition): WorkflowContext {
1386
- // Build steps object with outputs
1387
- const steps: Record<string, any> = {};
1388
- for (const [name, result] of Object.entries(instance.stepResults)) {
1389
- if (result.status === "completed" && result.output !== undefined) {
1390
- steps[name] = result.output;
1391
- }
1392
- }
1393
-
1394
- // Find the previous step's output by tracing the workflow path
1395
- let prev: any = undefined;
1396
- if (instance.currentStep) {
1397
- // Find which step comes before current step
1398
- for (const [stepName, stepDef] of definition.steps) {
1399
- if (stepDef.next === instance.currentStep && steps[stepName] !== undefined) {
1400
- prev = steps[stepName];
1401
- break;
1094
+ },
1095
+ onStepFailed: (id, stepName, error, attempts) => {
1096
+ this.emitEvent("workflow.step.failed", {
1097
+ instanceId: id,
1098
+ stepName,
1099
+ error,
1100
+ attempts,
1101
+ });
1102
+ if (this.sse) {
1103
+ this.sse.broadcast(`workflow:${id}`, "step.failed", { stepName, error });
1104
+ this.sse.broadcast("workflows:all", "workflow.step.failed", {
1105
+ instanceId: id,
1106
+ stepName,
1107
+ error,
1108
+ });
1402
1109
  }
1403
- }
1404
- // If no explicit next found, use most recent completed step output
1405
- if (prev === undefined) {
1406
- const completedSteps = Object.entries(instance.stepResults)
1407
- .filter(([, r]) => r.status === "completed" && r.output !== undefined)
1408
- .sort((a, b) => {
1409
- const aTime = a[1].completedAt?.getTime() ?? 0;
1410
- const bTime = b[1].completedAt?.getTime() ?? 0;
1411
- return bTime - aTime;
1110
+ },
1111
+ onStepRetry: (id, stepName, attempt, max, delayMs) => {
1112
+ this.emitEvent("workflow.step.retry", {
1113
+ instanceId: id,
1114
+ stepName,
1115
+ attempt,
1116
+ maxAttempts: max,
1117
+ delay: delayMs,
1118
+ });
1119
+ },
1120
+ onProgress: (id, progress, currentStep, completed, total) => {
1121
+ this.emitEvent("workflow.progress", {
1122
+ instanceId: id,
1123
+ progress,
1124
+ currentStep,
1125
+ completedSteps: completed,
1126
+ totalSteps: total,
1127
+ });
1128
+ if (this.sse) {
1129
+ this.sse.broadcast(`workflow:${id}`, "progress", {
1130
+ progress,
1131
+ currentStep,
1132
+ completedSteps: completed,
1133
+ totalSteps: total,
1134
+ });
1135
+ this.sse.broadcast("workflows:all", "workflow.progress", {
1136
+ instanceId: id,
1137
+ progress,
1138
+ currentStep,
1412
1139
  });
1413
- if (completedSteps.length > 0) {
1414
- prev = completedSteps[0][1].output;
1415
1140
  }
1416
- }
1417
- }
1418
-
1419
- // Metadata snapshot (mutable reference for setMetadata updates)
1420
- const metadata = { ...(instance.metadata ?? {}) };
1421
-
1422
- return {
1423
- input: instance.input,
1424
- steps,
1425
- prev,
1426
- instance,
1427
- getStepResult: <T = any>(stepName: string): T | undefined => {
1428
- return steps[stepName] as T | undefined;
1429
1141
  },
1430
- core: this.core!,
1431
- plugins: this.plugins,
1432
- metadata,
1433
- setMetadata: async (key: string, value: any): Promise<void> => {
1434
- // Update local snapshot
1435
- metadata[key] = value;
1436
- // Persist to database
1437
- await this.adapter.updateInstance(instance.id, {
1438
- metadata: { ...metadata },
1142
+ onCompleted: (id, output) => {
1143
+ this.emitEvent("workflow.completed", {
1144
+ instanceId: id,
1145
+ output,
1439
1146
  });
1147
+ if (this.sse) {
1148
+ this.sse.broadcast(`workflow:${id}`, "completed", { output });
1149
+ this.sse.broadcast("workflows:all", "workflow.completed", {
1150
+ instanceId: id,
1151
+ });
1152
+ }
1440
1153
  },
1441
- getMetadata: <T = any>(key: string): T | undefined => {
1442
- return metadata[key] as T | undefined;
1154
+ onFailed: (id, error) => {
1155
+ this.emitEvent("workflow.failed", {
1156
+ instanceId: id,
1157
+ error,
1158
+ });
1159
+ if (this.sse) {
1160
+ this.sse.broadcast(`workflow:${id}`, "failed", { error });
1161
+ this.sse.broadcast("workflows:all", "workflow.failed", {
1162
+ instanceId: id,
1163
+ error,
1164
+ });
1165
+ }
1443
1166
  },
1444
1167
  };
1445
1168
  }
1446
1169
 
1447
- private async completeStep(
1448
- instanceId: string,
1449
- stepName: string,
1450
- output: any,
1451
- step: StepDefinition,
1452
- definition: WorkflowDefinition
1453
- ): Promise<void> {
1454
- const instance = await this.adapter.getInstance(instanceId);
1455
- if (!instance) return;
1456
-
1457
- // Check if workflow is still running (not cancelled/failed/timed out)
1458
- if (instance.status !== "running") {
1459
- console.log(`[Workflows] Ignoring step completion for ${instanceId}, status is ${instance.status}`);
1460
- return;
1461
- }
1462
-
1463
- // Update step result
1464
- const stepResult = instance.stepResults[stepName] ?? {
1465
- stepName,
1466
- status: "pending",
1467
- attempts: 0,
1468
- };
1469
- stepResult.status = "completed";
1470
- stepResult.output = output;
1471
- stepResult.completedAt = new Date();
1472
-
1473
- await this.adapter.updateInstance(instanceId, {
1474
- stepResults: { ...instance.stepResults, [stepName]: stepResult },
1475
- });
1476
-
1477
- // Emit step completed event
1478
- await this.emitEvent("workflow.step.completed", {
1479
- instanceId,
1480
- workflowName: instance.workflowName,
1481
- stepName,
1482
- output,
1483
- });
1484
-
1485
- // Broadcast step completed via SSE
1486
- if (this.sse) {
1487
- this.sse.broadcast(`workflow:${instanceId}`, "step.completed", {
1488
- stepName,
1489
- output,
1490
- });
1491
- this.sse.broadcast("workflows:all", "workflow.step.completed", {
1492
- instanceId,
1493
- workflowName: instance.workflowName,
1494
- stepName,
1495
- });
1496
- }
1497
-
1498
- // Calculate and emit progress
1499
- const totalSteps = definition.steps.size;
1500
- const completedSteps = Object.values(instance.stepResults).filter(
1501
- (r) => r.status === "completed"
1502
- ).length + 1; // +1 for current step
1503
- const progress = Math.round((completedSteps / totalSteps) * 100);
1504
-
1505
- await this.emitEvent("workflow.progress", {
1506
- instanceId,
1507
- workflowName: instance.workflowName,
1508
- progress,
1509
- currentStep: stepName,
1510
- completedSteps,
1511
- totalSteps,
1512
- });
1513
-
1514
- // Broadcast progress via SSE
1515
- if (this.sse) {
1516
- this.sse.broadcast(`workflow:${instanceId}`, "progress", {
1517
- progress,
1518
- currentStep: stepName,
1519
- completedSteps,
1520
- totalSteps,
1521
- });
1522
- this.sse.broadcast("workflows:all", "workflow.progress", {
1523
- instanceId,
1524
- workflowName: instance.workflowName,
1525
- progress,
1526
- currentStep: stepName,
1527
- });
1528
- }
1529
-
1530
- // Move to next step or complete
1531
- if (step.end) {
1532
- await this.completeWorkflow(instanceId, output);
1533
- } else if (step.next) {
1534
- await this.adapter.updateInstance(instanceId, {
1535
- currentStep: step.next,
1536
- });
1537
- await this.executeStep(instanceId, definition);
1538
- } else {
1539
- // No next step, complete
1540
- await this.completeWorkflow(instanceId, output);
1541
- }
1542
- }
1543
-
1544
- private async handleStepError(
1545
- instanceId: string,
1546
- stepName: string,
1547
- error: string,
1548
- step: StepDefinition,
1549
- definition: WorkflowDefinition
1550
- ): Promise<void> {
1551
- const instance = await this.adapter.getInstance(instanceId);
1552
- if (!instance) return;
1553
-
1554
- const stepResult = instance.stepResults[stepName] ?? {
1555
- stepName,
1556
- status: "pending",
1557
- attempts: 0,
1558
- };
1559
-
1560
- // Check retry config
1561
- const retry = step.retry ?? definition.defaultRetry;
1562
- if (retry && stepResult.attempts < retry.maxAttempts) {
1563
- // Retry with backoff
1564
- const backoffRate = retry.backoffRate ?? 2;
1565
- const intervalMs = retry.intervalMs ?? 1000;
1566
- const maxIntervalMs = retry.maxIntervalMs ?? 30000;
1567
- const delay = Math.min(
1568
- intervalMs * Math.pow(backoffRate, stepResult.attempts - 1),
1569
- maxIntervalMs
1570
- );
1571
-
1572
- console.log(
1573
- `[Workflows] Retrying step ${stepName} in ${delay}ms (attempt ${stepResult.attempts}/${retry.maxAttempts})`
1574
- );
1575
-
1576
- await this.emitEvent("workflow.step.retry", {
1577
- instanceId,
1578
- workflowName: instance.workflowName,
1579
- stepName,
1580
- attempt: stepResult.attempts,
1581
- maxAttempts: retry.maxAttempts,
1582
- delay,
1583
- error,
1584
- });
1585
-
1586
- // Update step result
1587
- stepResult.error = error;
1588
- await this.adapter.updateInstance(instanceId, {
1589
- stepResults: { ...instance.stepResults, [stepName]: stepResult },
1590
- });
1591
-
1592
- // Retry after delay
1593
- setTimeout(() => {
1594
- this.executeStep(instanceId, definition);
1595
- }, delay);
1596
-
1597
- return;
1598
- }
1599
-
1600
- // No more retries, fail the step
1601
- stepResult.status = "failed";
1602
- stepResult.error = error;
1603
- stepResult.completedAt = new Date();
1604
-
1605
- await this.adapter.updateInstance(instanceId, {
1606
- stepResults: { ...instance.stepResults, [stepName]: stepResult },
1607
- });
1608
-
1609
- await this.emitEvent("workflow.step.failed", {
1610
- instanceId,
1611
- workflowName: instance.workflowName,
1612
- stepName,
1613
- error,
1614
- attempts: stepResult.attempts,
1615
- });
1616
-
1617
- // Broadcast step failed via SSE
1618
- if (this.sse) {
1619
- this.sse.broadcast(`workflow:${instanceId}`, "step.failed", {
1620
- stepName,
1621
- error,
1622
- });
1623
- this.sse.broadcast("workflows:all", "workflow.step.failed", {
1624
- instanceId,
1625
- workflowName: instance.workflowName,
1626
- stepName,
1627
- error,
1628
- });
1629
- }
1630
-
1631
- // Fail the workflow
1632
- await this.failWorkflow(instanceId, `Step "${stepName}" failed: ${error}`);
1633
- }
1634
-
1635
- private async completeWorkflow(instanceId: string, output?: any): Promise<void> {
1636
- const instance = await this.adapter.getInstance(instanceId);
1637
- if (!instance) return;
1638
-
1639
- // Check if workflow is still running (not cancelled/failed/timed out)
1640
- if (instance.status !== "running") {
1641
- console.log(`[Workflows] Ignoring workflow completion for ${instanceId}, status is ${instance.status}`);
1642
- return;
1643
- }
1644
-
1645
- // Clear timeout
1646
- const runInfo = this.running.get(instanceId);
1647
- if (runInfo?.timeout) {
1648
- clearTimeout(runInfo.timeout);
1649
- }
1650
- this.running.delete(instanceId);
1651
-
1652
- await this.adapter.updateInstance(instanceId, {
1653
- status: "completed",
1654
- output,
1655
- completedAt: new Date(),
1656
- currentStep: undefined,
1657
- });
1658
-
1659
- await this.emitEvent("workflow.completed", {
1660
- instanceId,
1661
- workflowName: instance.workflowName,
1662
- output,
1663
- });
1664
-
1665
- // Broadcast via SSE
1666
- if (this.sse) {
1667
- this.sse.broadcast(`workflow:${instanceId}`, "completed", { output });
1668
- this.sse.broadcast("workflows:all", "workflow.completed", {
1669
- instanceId,
1670
- workflowName: instance.workflowName,
1671
- });
1672
- }
1673
- }
1674
-
1675
- private async failWorkflow(instanceId: string, error: string): Promise<void> {
1676
- const instance = await this.adapter.getInstance(instanceId);
1677
- if (!instance) return;
1678
-
1679
- // Clear timeout
1680
- const runInfo = this.running.get(instanceId);
1681
- if (runInfo?.timeout) {
1682
- clearTimeout(runInfo.timeout);
1683
- }
1684
- this.running.delete(instanceId);
1685
-
1686
- await this.adapter.updateInstance(instanceId, {
1687
- status: "failed",
1688
- error,
1689
- completedAt: new Date(),
1690
- });
1691
-
1692
- await this.emitEvent("workflow.failed", {
1693
- instanceId,
1694
- workflowName: instance.workflowName,
1695
- error,
1696
- });
1697
-
1698
- // Broadcast via SSE
1699
- if (this.sse) {
1700
- this.sse.broadcast(`workflow:${instanceId}`, "failed", { error });
1701
- this.sse.broadcast("workflows:all", "workflow.failed", {
1702
- instanceId,
1703
- workflowName: instance.workflowName,
1704
- error,
1705
- });
1706
- }
1707
- }
1708
-
1709
- private async emitEvent(event: string, data: any): Promise<void> {
1710
- if (this.events) {
1711
- await this.events.emit(event, data);
1712
- }
1713
- }
1714
-
1715
1170
  // ============================================
1716
1171
  // Isolated Execution Engine
1717
1172
  // ============================================
@@ -1799,13 +1254,33 @@ class WorkflowsImpl implements Workflows {
1799
1254
  const instance = await this.adapter.getInstance(instanceId);
1800
1255
  if (instance && instance.status === "running") {
1801
1256
  console.error(`[Workflows] Isolated workflow ${instanceId} crashed with exit code ${exitCode}`);
1802
- await this.failWorkflow(instanceId, `Subprocess crashed with exit code ${exitCode}`);
1257
+ await this.adapter.updateInstance(instanceId, {
1258
+ status: "failed",
1259
+ error: `Subprocess crashed with exit code ${exitCode}`,
1260
+ completedAt: new Date(),
1261
+ });
1262
+ await this.emitEvent("workflow.failed", {
1263
+ instanceId,
1264
+ workflowName: instance.workflowName,
1265
+ error: `Subprocess crashed with exit code ${exitCode}`,
1266
+ });
1267
+ if (this.sse) {
1268
+ this.sse.broadcast(`workflow:${instanceId}`, "failed", {
1269
+ error: `Subprocess crashed with exit code ${exitCode}`,
1270
+ });
1271
+ this.sse.broadcast("workflows:all", "workflow.failed", {
1272
+ instanceId,
1273
+ workflowName: instance.workflowName,
1274
+ error: `Subprocess crashed with exit code ${exitCode}`,
1275
+ });
1276
+ }
1803
1277
  }
1804
1278
  });
1805
1279
  }
1806
1280
 
1807
1281
  /**
1808
- * Handle events from isolated workflow subprocess
1282
+ * Handle events from isolated workflow subprocess.
1283
+ * The subprocess owns persistence via its own adapter - we only forward events to SSE/Events.
1809
1284
  */
1810
1285
  private async handleIsolatedEvent(event: WorkflowEvent): Promise<void> {
1811
1286
  const { instanceId, type } = event;
@@ -1819,42 +1294,22 @@ class WorkflowsImpl implements Workflows {
1819
1294
 
1820
1295
  switch (type) {
1821
1296
  case "started":
1822
- // Already marked as running in executeIsolatedWorkflow
1823
- break;
1824
-
1825
1297
  case "heartbeat":
1826
- // Heartbeat handled above
1298
+ // No-op: heartbeat handled above, started is handled by executeIsolatedWorkflow
1827
1299
  break;
1828
1300
 
1829
1301
  case "step.started": {
1830
- const instance = await this.adapter.getInstance(instanceId);
1831
- if (!instance) break;
1832
-
1833
- // Update current step and step results in DB
1834
- const stepResult = {
1835
- stepName: event.stepName!,
1836
- status: "running" as const,
1837
- startedAt: new Date(),
1838
- attempts: (instance.stepResults[event.stepName!]?.attempts ?? 0) + 1,
1839
- };
1840
- await this.adapter.updateInstance(instanceId, {
1841
- currentStep: event.stepName,
1842
- stepResults: { ...instance.stepResults, [event.stepName!]: stepResult },
1843
- });
1844
-
1845
1302
  await this.emitEvent("workflow.step.started", {
1846
1303
  instanceId,
1847
- workflowName: instance?.workflowName,
1848
1304
  stepName: event.stepName,
1305
+ stepType: event.stepType,
1849
1306
  });
1850
- // Broadcast via SSE
1851
1307
  if (this.sse) {
1852
1308
  this.sse.broadcast(`workflow:${instanceId}`, "step.started", {
1853
1309
  stepName: event.stepName,
1854
1310
  });
1855
1311
  this.sse.broadcast("workflows:all", "workflow.step.started", {
1856
1312
  instanceId,
1857
- workflowName: instance?.workflowName,
1858
1313
  stepName: event.stepName,
1859
1314
  });
1860
1315
  }
@@ -1862,32 +1317,11 @@ class WorkflowsImpl implements Workflows {
1862
1317
  }
1863
1318
 
1864
1319
  case "step.completed": {
1865
- const instance = await this.adapter.getInstance(instanceId);
1866
- if (!instance) break;
1867
-
1868
- // Update step results in DB
1869
- const stepResult = instance.stepResults[event.stepName!] ?? {
1870
- stepName: event.stepName!,
1871
- status: "pending" as const,
1872
- startedAt: new Date(),
1873
- attempts: 0,
1874
- };
1875
- stepResult.status = "completed";
1876
- stepResult.output = event.output;
1877
- stepResult.completedAt = new Date();
1878
-
1879
- await this.adapter.updateInstance(instanceId, {
1880
- stepResults: { ...instance.stepResults, [event.stepName!]: stepResult },
1881
- currentStep: event.nextStep,
1882
- });
1883
-
1884
1320
  await this.emitEvent("workflow.step.completed", {
1885
1321
  instanceId,
1886
- workflowName: instance?.workflowName,
1887
1322
  stepName: event.stepName,
1888
1323
  output: event.output,
1889
1324
  });
1890
- // Broadcast via SSE
1891
1325
  if (this.sse) {
1892
1326
  this.sse.broadcast(`workflow:${instanceId}`, "step.completed", {
1893
1327
  stepName: event.stepName,
@@ -1895,7 +1329,6 @@ class WorkflowsImpl implements Workflows {
1895
1329
  });
1896
1330
  this.sse.broadcast("workflows:all", "workflow.step.completed", {
1897
1331
  instanceId,
1898
- workflowName: instance?.workflowName,
1899
1332
  stepName: event.stepName,
1900
1333
  output: event.output,
1901
1334
  });
@@ -1904,31 +1337,11 @@ class WorkflowsImpl implements Workflows {
1904
1337
  }
1905
1338
 
1906
1339
  case "step.failed": {
1907
- const instance = await this.adapter.getInstance(instanceId);
1908
- if (!instance) break;
1909
-
1910
- // Update step results in DB
1911
- const stepResult = instance.stepResults[event.stepName!] ?? {
1912
- stepName: event.stepName!,
1913
- status: "pending" as const,
1914
- startedAt: new Date(),
1915
- attempts: 0,
1916
- };
1917
- stepResult.status = "failed";
1918
- stepResult.error = event.error;
1919
- stepResult.completedAt = new Date();
1920
-
1921
- await this.adapter.updateInstance(instanceId, {
1922
- stepResults: { ...instance.stepResults, [event.stepName!]: stepResult },
1923
- });
1924
-
1925
1340
  await this.emitEvent("workflow.step.failed", {
1926
1341
  instanceId,
1927
- workflowName: instance?.workflowName,
1928
1342
  stepName: event.stepName,
1929
1343
  error: event.error,
1930
1344
  });
1931
- // Broadcast via SSE
1932
1345
  if (this.sse) {
1933
1346
  this.sse.broadcast(`workflow:${instanceId}`, "step.failed", {
1934
1347
  stepName: event.stepName,
@@ -1936,7 +1349,6 @@ class WorkflowsImpl implements Workflows {
1936
1349
  });
1937
1350
  this.sse.broadcast("workflows:all", "workflow.step.failed", {
1938
1351
  instanceId,
1939
- workflowName: instance?.workflowName,
1940
1352
  stepName: event.stepName,
1941
1353
  error: event.error,
1942
1354
  });
@@ -1945,15 +1357,12 @@ class WorkflowsImpl implements Workflows {
1945
1357
  }
1946
1358
 
1947
1359
  case "progress": {
1948
- const instance = await this.adapter.getInstance(instanceId);
1949
1360
  await this.emitEvent("workflow.progress", {
1950
1361
  instanceId,
1951
- workflowName: instance?.workflowName,
1952
1362
  progress: event.progress,
1953
1363
  completedSteps: event.completedSteps,
1954
1364
  totalSteps: event.totalSteps,
1955
1365
  });
1956
- // Broadcast via SSE
1957
1366
  if (this.sse) {
1958
1367
  this.sse.broadcast(`workflow:${instanceId}`, "progress", {
1959
1368
  progress: event.progress,
@@ -1962,7 +1371,6 @@ class WorkflowsImpl implements Workflows {
1962
1371
  });
1963
1372
  this.sse.broadcast("workflows:all", "workflow.progress", {
1964
1373
  instanceId,
1965
- workflowName: instance?.workflowName,
1966
1374
  progress: event.progress,
1967
1375
  completedSteps: event.completedSteps,
1968
1376
  totalSteps: event.totalSteps,
@@ -1971,13 +1379,40 @@ class WorkflowsImpl implements Workflows {
1971
1379
  break;
1972
1380
  }
1973
1381
 
1974
- case "completed":
1975
- await this.completeWorkflowIsolated(instanceId, event.output);
1382
+ case "completed": {
1383
+ // Clean up isolated process tracking
1384
+ this.cleanupIsolatedProcess(instanceId);
1385
+
1386
+ // Subprocess already persisted state - just emit events
1387
+ await this.emitEvent("workflow.completed", {
1388
+ instanceId,
1389
+ output: event.output,
1390
+ });
1391
+ if (this.sse) {
1392
+ this.sse.broadcast(`workflow:${instanceId}`, "completed", { output: event.output });
1393
+ this.sse.broadcast("workflows:all", "workflow.completed", { instanceId });
1394
+ }
1976
1395
  break;
1396
+ }
1397
+
1398
+ case "failed": {
1399
+ // Clean up isolated process tracking
1400
+ this.cleanupIsolatedProcess(instanceId);
1977
1401
 
1978
- case "failed":
1979
- await this.failWorkflowIsolated(instanceId, event.error ?? "Unknown error");
1402
+ // Subprocess already persisted state - just emit events
1403
+ await this.emitEvent("workflow.failed", {
1404
+ instanceId,
1405
+ error: event.error,
1406
+ });
1407
+ if (this.sse) {
1408
+ this.sse.broadcast(`workflow:${instanceId}`, "failed", { error: event.error });
1409
+ this.sse.broadcast("workflows:all", "workflow.failed", {
1410
+ instanceId,
1411
+ error: event.error,
1412
+ });
1413
+ }
1980
1414
  break;
1415
+ }
1981
1416
  }
1982
1417
  }
1983
1418
 
@@ -2015,6 +1450,18 @@ class WorkflowsImpl implements Workflows {
2015
1450
  }
2016
1451
  }
2017
1452
 
1453
+ /**
1454
+ * Clean up isolated process tracking
1455
+ */
1456
+ private cleanupIsolatedProcess(instanceId: string): void {
1457
+ const info = this.isolatedProcesses.get(instanceId);
1458
+ if (info) {
1459
+ if (info.timeout) clearTimeout(info.timeout);
1460
+ if (info.heartbeatTimeout) clearTimeout(info.heartbeatTimeout);
1461
+ this.isolatedProcesses.delete(instanceId);
1462
+ }
1463
+ }
1464
+
2018
1465
  /**
2019
1466
  * Reset heartbeat timeout for an isolated workflow
2020
1467
  */
@@ -2060,85 +1507,29 @@ class WorkflowsImpl implements Workflows {
2060
1507
  await this.getSocketServer().closeSocket(instanceId);
2061
1508
 
2062
1509
  // Fail the workflow
2063
- await this.failWorkflow(instanceId, "Workflow timed out");
2064
- }
2065
-
2066
- /**
2067
- * Complete an isolated workflow (called from event handler)
2068
- */
2069
- private async completeWorkflowIsolated(instanceId: string, output?: any): Promise<void> {
2070
- const instance = await this.adapter.getInstance(instanceId);
2071
- if (!instance) return;
2072
-
2073
- // Clean up isolated process tracking (process should have exited)
2074
- const info = this.isolatedProcesses.get(instanceId);
2075
- if (info) {
2076
- if (info.timeout) clearTimeout(info.timeout);
2077
- if (info.heartbeatTimeout) clearTimeout(info.heartbeatTimeout);
2078
- this.isolatedProcesses.delete(instanceId);
2079
- }
2080
-
2081
- await this.adapter.updateInstance(instanceId, {
2082
- status: "completed",
2083
- output,
2084
- completedAt: new Date(),
2085
- currentStep: undefined,
2086
- });
2087
-
2088
- await this.emitEvent("workflow.completed", {
2089
- instanceId,
2090
- workflowName: instance.workflowName,
2091
- output,
2092
- });
2093
-
2094
- // Broadcast via SSE
2095
- if (this.sse) {
2096
- this.sse.broadcast(`workflow:${instanceId}`, "completed", { output });
2097
- this.sse.broadcast("workflows:all", "workflow.completed", {
2098
- instanceId,
2099
- workflowName: instance.workflowName,
2100
- output,
2101
- });
2102
- }
2103
- }
2104
-
2105
- /**
2106
- * Fail an isolated workflow (called from event handler)
2107
- */
2108
- private async failWorkflowIsolated(instanceId: string, error: string): Promise<void> {
2109
- const instance = await this.adapter.getInstance(instanceId);
2110
- if (!instance) return;
2111
-
2112
- // Clean up isolated process tracking
2113
- const info = this.isolatedProcesses.get(instanceId);
2114
- if (info) {
2115
- if (info.timeout) clearTimeout(info.timeout);
2116
- if (info.heartbeatTimeout) clearTimeout(info.heartbeatTimeout);
2117
- this.isolatedProcesses.delete(instanceId);
2118
- }
2119
-
2120
1510
  await this.adapter.updateInstance(instanceId, {
2121
1511
  status: "failed",
2122
- error,
1512
+ error: "Workflow timed out",
2123
1513
  completedAt: new Date(),
2124
1514
  });
2125
-
2126
1515
  await this.emitEvent("workflow.failed", {
2127
1516
  instanceId,
2128
- workflowName: instance.workflowName,
2129
- error,
1517
+ error: "Workflow timed out",
2130
1518
  });
2131
-
2132
- // Broadcast via SSE
2133
1519
  if (this.sse) {
2134
- this.sse.broadcast(`workflow:${instanceId}`, "failed", { error });
1520
+ this.sse.broadcast(`workflow:${instanceId}`, "failed", { error: "Workflow timed out" });
2135
1521
  this.sse.broadcast("workflows:all", "workflow.failed", {
2136
1522
  instanceId,
2137
- workflowName: instance.workflowName,
2138
- error,
1523
+ error: "Workflow timed out",
2139
1524
  });
2140
1525
  }
2141
1526
  }
1527
+
1528
+ private async emitEvent(event: string, data: any): Promise<void> {
1529
+ if (this.eventsService) {
1530
+ await this.eventsService.emit(event, data);
1531
+ }
1532
+ }
2142
1533
  }
2143
1534
 
2144
1535
  // ============================================