@pikku/core 0.10.2 → 0.11.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 +9 -0
- package/dist/function/functions.types.d.ts +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/middleware-runner.js +1 -0
- package/dist/permissions.js +1 -0
- package/dist/pikku-state.d.ts +7 -2
- package/dist/pikku-state.js +4 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.js +1 -0
- package/dist/services/scheduler-service.d.ts +63 -0
- package/dist/services/scheduler-service.js +6 -0
- package/dist/time-utils.d.ts +14 -0
- package/dist/time-utils.js +62 -0
- package/dist/types/core.types.d.ts +24 -2
- package/dist/types/core.types.js +1 -0
- package/dist/wirings/cli/channel/cli-channel-runner.js +1 -1
- package/dist/wirings/cli/cli-runner.js +1 -1
- package/dist/wirings/queue/queue-runner.js +6 -3
- package/dist/wirings/queue/queue.types.d.ts +1 -3
- package/dist/wirings/rpc/index.d.ts +1 -1
- package/dist/wirings/rpc/index.js +1 -1
- package/dist/wirings/rpc/rpc-runner.d.ts +4 -0
- package/dist/wirings/rpc/rpc-runner.js +37 -2
- package/dist/wirings/rpc/rpc-types.d.ts +1 -0
- package/dist/wirings/workflow/index.d.ts +6 -0
- package/dist/wirings/workflow/index.js +6 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +152 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +448 -0
- package/dist/wirings/workflow/workflow-runner.d.ts +35 -0
- package/dist/wirings/workflow/workflow-runner.js +68 -0
- package/dist/wirings/workflow/workflow.types.d.ts +247 -0
- package/dist/wirings/workflow/workflow.types.js +1 -0
- package/package.json +2 -3
- package/src/function/functions.types.ts +1 -0
- package/src/index.ts +2 -0
- package/src/middleware-runner.ts +1 -0
- package/src/permissions.ts +1 -0
- package/src/pikku-state.ts +14 -2
- package/src/services/index.ts +1 -0
- package/src/services/scheduler-service.ts +76 -0
- package/src/time-utils.ts +75 -0
- package/src/types/core.types.ts +30 -1
- package/src/wirings/cli/channel/cli-channel-runner.ts +1 -1
- package/src/wirings/cli/cli-runner.ts +1 -1
- package/src/wirings/queue/queue-runner.ts +14 -6
- package/src/wirings/queue/queue.types.ts +1 -4
- package/src/wirings/rpc/index.ts +1 -1
- package/src/wirings/rpc/rpc-runner.ts +56 -3
- package/src/wirings/rpc/rpc-types.ts +1 -0
- package/src/wirings/workflow/index.ts +22 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +756 -0
- package/src/wirings/workflow/workflow-runner.ts +85 -0
- package/src/wirings/workflow/workflow.types.ts +332 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/services/file-channel-store.d.ts +0 -19
- package/dist/services/file-channel-store.js +0 -69
- package/dist/services/file-eventhub-store.d.ts +0 -17
- package/dist/services/file-eventhub-store.js +0 -71
- package/src/services/file-channel-store.ts +0 -86
- package/src/services/file-eventhub-store.ts +0 -90
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import { runPikkuFunc } from '../../function/function-runner.js'
|
|
2
|
+
import { pikkuState } from '../../pikku-state.js'
|
|
3
|
+
import { getDurationInMilliseconds } from '../../time-utils.js'
|
|
4
|
+
import {
|
|
5
|
+
CoreConfig,
|
|
6
|
+
CoreServices,
|
|
7
|
+
CoreSingletonServices,
|
|
8
|
+
CreateSessionServices,
|
|
9
|
+
PikkuWiringTypes,
|
|
10
|
+
SerializedError,
|
|
11
|
+
} from '../../types/core.types.js'
|
|
12
|
+
import { QueueService } from '../queue/queue.types.js'
|
|
13
|
+
import {
|
|
14
|
+
WorkflowAsyncException,
|
|
15
|
+
WorkflowCancelledException,
|
|
16
|
+
WorkflowNotFoundError,
|
|
17
|
+
WorkflowRunNotFound,
|
|
18
|
+
} from './workflow-runner.js'
|
|
19
|
+
import type {
|
|
20
|
+
PikkuWorkflowInteraction,
|
|
21
|
+
StepState,
|
|
22
|
+
WorkflowRun,
|
|
23
|
+
WorkflowStatus,
|
|
24
|
+
WorkflowService,
|
|
25
|
+
WorkflowServiceConfig,
|
|
26
|
+
WorkflowStepOptions,
|
|
27
|
+
} from './workflow.types.js'
|
|
28
|
+
|
|
29
|
+
export class WorkflowServiceNotInitialized extends Error {}
|
|
30
|
+
export class WorkflowStepNameNotString extends Error {
|
|
31
|
+
constructor(stepName: any) {
|
|
32
|
+
super(`Workflow step name must be a string. Received: ${typeof stepName}`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Abstract workflow state service
|
|
38
|
+
* Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
|
|
39
|
+
* Combines orchestration and step execution
|
|
40
|
+
*/
|
|
41
|
+
export abstract class PikkuWorkflowService implements WorkflowService {
|
|
42
|
+
private config: WorkflowServiceConfig | undefined
|
|
43
|
+
private singletonServices: CoreSingletonServices | undefined
|
|
44
|
+
private createSessionServices: CreateSessionServices | undefined
|
|
45
|
+
|
|
46
|
+
constructor() {}
|
|
47
|
+
|
|
48
|
+
public setServices(
|
|
49
|
+
singletonServices: CoreSingletonServices,
|
|
50
|
+
createSessionServices: CreateSessionServices,
|
|
51
|
+
{ workflow }: CoreConfig
|
|
52
|
+
) {
|
|
53
|
+
this.singletonServices = singletonServices
|
|
54
|
+
this.createSessionServices = createSessionServices
|
|
55
|
+
this.config = {
|
|
56
|
+
retries: workflow?.retries ?? 0,
|
|
57
|
+
retryDelay: workflow?.retryDelay ?? 0,
|
|
58
|
+
orchestratorQueueName:
|
|
59
|
+
workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
|
|
60
|
+
stepWorkerQueueName:
|
|
61
|
+
workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
|
|
62
|
+
sleeperRPCName: workflow?.sleeperRPCName ?? 'pikkuWorkflowStepSleeper',
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a new workflow run
|
|
68
|
+
* @param name - Workflow name
|
|
69
|
+
* @param input - Input data for the workflow
|
|
70
|
+
* @returns Run ID
|
|
71
|
+
*/
|
|
72
|
+
abstract createRun(workflowName: string, input: any): Promise<string>
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get a workflow run by ID
|
|
76
|
+
* @param id - Run ID
|
|
77
|
+
* @returns Workflow run or null if not found
|
|
78
|
+
*/
|
|
79
|
+
abstract getRun(id: string): Promise<WorkflowRun | null>
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Get workflow run history (all step attempts in chronological order)
|
|
83
|
+
* @param runId - Run ID
|
|
84
|
+
* @returns Array of step states with step names, ordered oldest to newest
|
|
85
|
+
*/
|
|
86
|
+
abstract getRunHistory(
|
|
87
|
+
runId: string
|
|
88
|
+
): Promise<Array<StepState & { stepName: string }>>
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Update workflow run status
|
|
92
|
+
* @param id - Run ID
|
|
93
|
+
* @param status - New status
|
|
94
|
+
*/
|
|
95
|
+
abstract updateRunStatus(
|
|
96
|
+
id: string,
|
|
97
|
+
status: WorkflowStatus,
|
|
98
|
+
output?: any,
|
|
99
|
+
error?: SerializedError
|
|
100
|
+
): Promise<void>
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Insert initial step state (called by orchestrator)
|
|
104
|
+
* Creates pending step in both workflow_step and workflow_step_history
|
|
105
|
+
* @param runId - Run ID
|
|
106
|
+
* @param stepName - Step cache key
|
|
107
|
+
* @param rpcName - RPC function name
|
|
108
|
+
* @param data - Step input data
|
|
109
|
+
* @param stepOptions - Step options (retries, retryDelay)
|
|
110
|
+
* @returns Step state with generated stepId
|
|
111
|
+
*/
|
|
112
|
+
abstract insertStepState(
|
|
113
|
+
runId: string,
|
|
114
|
+
stepName: string,
|
|
115
|
+
rpcName: string | null,
|
|
116
|
+
data: any,
|
|
117
|
+
stepOptions?: WorkflowStepOptions
|
|
118
|
+
): Promise<StepState>
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get step state by cache key (read-only)
|
|
122
|
+
* @param runId - Run ID
|
|
123
|
+
* @param stepName - Step cache key (from workflow.do)
|
|
124
|
+
* @returns Step state with attemptCount calculated from history
|
|
125
|
+
*/
|
|
126
|
+
abstract getStepState(runId: string, stepName: string): Promise<StepState>
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Mark step as running
|
|
130
|
+
* Updates both workflow_step and workflow_step_history
|
|
131
|
+
* @param stepId - Step ID
|
|
132
|
+
*/
|
|
133
|
+
abstract setStepRunning(stepId: string): Promise<void>
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Mark step as scheduled (queued for execution)
|
|
137
|
+
* Updates both workflow_step and workflow_step_history
|
|
138
|
+
* @param stepId - Step ID
|
|
139
|
+
*/
|
|
140
|
+
abstract setStepScheduled(stepId: string): Promise<void>
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Store step result and mark as succeeded
|
|
144
|
+
* Updates both workflow_step and workflow_step_history
|
|
145
|
+
* @param stepId - Step ID
|
|
146
|
+
* @param result - Step result
|
|
147
|
+
*/
|
|
148
|
+
abstract setStepResult(stepId: string, result: any): Promise<void>
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Store step error and mark as failed
|
|
152
|
+
* Updates both workflow_step and workflow_step_history
|
|
153
|
+
* @param stepId - Step ID
|
|
154
|
+
* @param error - Error object
|
|
155
|
+
*/
|
|
156
|
+
abstract setStepError(stepId: string, error: Error): Promise<void>
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Create a new retry attempt for a failed step
|
|
160
|
+
* Inserts new pending step in both workflow_step and workflow_step_history
|
|
161
|
+
* Resets status to 'pending' with new stepId
|
|
162
|
+
* Copies metadata (rpcName, data, retries, retryDelay) from failed attempt
|
|
163
|
+
* @param failedStepId - Failed step ID to copy from
|
|
164
|
+
* @returns New step state for the retry attempt
|
|
165
|
+
*/
|
|
166
|
+
abstract createRetryAttempt(
|
|
167
|
+
failedStepId: string,
|
|
168
|
+
status: 'pending' | 'running'
|
|
169
|
+
): Promise<StepState>
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Execute function within a run lock to prevent concurrent modifications
|
|
173
|
+
* @param id - Run ID
|
|
174
|
+
* @param fn - Function to execute
|
|
175
|
+
* @returns Function result
|
|
176
|
+
*/
|
|
177
|
+
abstract withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Close any open connections
|
|
181
|
+
*/
|
|
182
|
+
abstract close(): Promise<void>
|
|
183
|
+
|
|
184
|
+
// ============================================================================
|
|
185
|
+
// Workflow Lifecycle Methods
|
|
186
|
+
// ============================================================================
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Resume a paused workflow by triggering the orchestrator
|
|
190
|
+
* @param runId - Run ID
|
|
191
|
+
*/
|
|
192
|
+
public async resumeWorkflow(runId: string): Promise<void> {
|
|
193
|
+
const queueService = this.verifyQueueService()
|
|
194
|
+
await queueService.add(this.getConfig().orchestratorQueueName, { runId })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Execute a workflow sleep step completion
|
|
199
|
+
* Sets the step result to null and resumes the workflow
|
|
200
|
+
* @param data - Sleeper input data
|
|
201
|
+
*/
|
|
202
|
+
public async executeWorkflowSleep(
|
|
203
|
+
runId: string,
|
|
204
|
+
stepId: string
|
|
205
|
+
): Promise<void> {
|
|
206
|
+
await this.setStepResult(stepId, null)
|
|
207
|
+
await this.resumeWorkflow(runId)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Schedule orchestrator retry with delay
|
|
212
|
+
* @param runId - Run ID
|
|
213
|
+
* @param retryDelay - Delay in milliseconds or duration string (optional)
|
|
214
|
+
*/
|
|
215
|
+
protected async scheduleOrchestratorRetry(
|
|
216
|
+
runId: string,
|
|
217
|
+
retryDelay?: number | string
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
const queueService = this.verifyQueueService()
|
|
220
|
+
await queueService.add(
|
|
221
|
+
this.getConfig().orchestratorQueueName,
|
|
222
|
+
{ runId },
|
|
223
|
+
retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Start a new workflow run
|
|
229
|
+
*/
|
|
230
|
+
public async startWorkflow<I>(
|
|
231
|
+
name: string,
|
|
232
|
+
input: I,
|
|
233
|
+
rpcService: any
|
|
234
|
+
): Promise<{ runId: string }> {
|
|
235
|
+
const registrations = pikkuState('workflows', 'registrations')
|
|
236
|
+
const workflow = registrations.get(name)
|
|
237
|
+
|
|
238
|
+
if (!workflow) {
|
|
239
|
+
throw new WorkflowNotFoundError(name)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Create workflow run in state
|
|
243
|
+
const runId = await this.createRun(name, input)
|
|
244
|
+
|
|
245
|
+
// If queue service is available, use remote execution (queue-based)
|
|
246
|
+
// Otherwise, execute directly (inline/synchronous)
|
|
247
|
+
if (this.singletonServices?.queueService) {
|
|
248
|
+
// Queue orchestrator to start the workflow
|
|
249
|
+
await this.resumeWorkflow(runId)
|
|
250
|
+
} else {
|
|
251
|
+
// No queue service - execute directly via runWorkflowJob
|
|
252
|
+
await this.runWorkflowJob(runId, rpcService)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return { runId }
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
public async runWorkflowJob(runId: string, rpcService: any): Promise<void> {
|
|
259
|
+
// Get the run
|
|
260
|
+
const run = await this.getRun(runId)
|
|
261
|
+
if (!run) {
|
|
262
|
+
throw new WorkflowRunNotFound(runId)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Get workflow registration
|
|
266
|
+
const registrations = pikkuState('workflows', 'registrations')
|
|
267
|
+
const workflow = registrations.get(run.workflow)
|
|
268
|
+
if (!workflow) {
|
|
269
|
+
throw new WorkflowNotFoundError(run.workflow)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Use lock to prevent concurrent execution
|
|
273
|
+
await this.withRunLock(runId, async () => {
|
|
274
|
+
// Create workflow interaction object
|
|
275
|
+
const workflowInteraction = this.createWorkflowInteraction(
|
|
276
|
+
run.workflow,
|
|
277
|
+
runId,
|
|
278
|
+
rpcService
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
// Get function metadata
|
|
282
|
+
const meta = pikkuState('workflows', 'meta')
|
|
283
|
+
const workflowMeta = meta[run.workflow]
|
|
284
|
+
|
|
285
|
+
// Execute workflow function with workflow interaction
|
|
286
|
+
try {
|
|
287
|
+
const getAllServices = () => {
|
|
288
|
+
let sessionServices = {}
|
|
289
|
+
const interaction = { workflow: workflowInteraction }
|
|
290
|
+
if (this.createSessionServices) {
|
|
291
|
+
sessionServices = this.createSessionServices(
|
|
292
|
+
this.singletonServices!,
|
|
293
|
+
interaction,
|
|
294
|
+
undefined
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
...this.singletonServices,
|
|
299
|
+
...sessionServices,
|
|
300
|
+
...interaction,
|
|
301
|
+
} as CoreServices
|
|
302
|
+
}
|
|
303
|
+
const result = await runPikkuFunc(
|
|
304
|
+
PikkuWiringTypes.workflow,
|
|
305
|
+
workflowMeta.workflowName,
|
|
306
|
+
workflowMeta.pikkuFuncName,
|
|
307
|
+
{
|
|
308
|
+
singletonServices: this.singletonServices!,
|
|
309
|
+
interaction: { workflow: workflowInteraction },
|
|
310
|
+
getAllServices,
|
|
311
|
+
data: () => run.input,
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
// Workflow completed successfully
|
|
316
|
+
await this.updateRunStatus(runId, 'completed', result)
|
|
317
|
+
} catch (error: any) {
|
|
318
|
+
// Check if it's a WorkflowAsyncException
|
|
319
|
+
if (error instanceof WorkflowAsyncException) {
|
|
320
|
+
// Normal - workflow paused for step execution
|
|
321
|
+
throw error
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Check if it's a WorkflowCancelledException
|
|
325
|
+
if (error instanceof WorkflowCancelledException) {
|
|
326
|
+
// Workflow was cancelled - status already updated, just rethrow
|
|
327
|
+
throw error
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Real error - mark as failed
|
|
331
|
+
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
332
|
+
message: error.message,
|
|
333
|
+
stack: error.stack,
|
|
334
|
+
code: error.code,
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
throw error
|
|
338
|
+
}
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Execute a single workflow step (called by worker)
|
|
344
|
+
* Handles idempotency, RPC execution, result storage, retry logic, and orchestrator triggering
|
|
345
|
+
*/
|
|
346
|
+
public async executeWorkflowStep(
|
|
347
|
+
runId: string,
|
|
348
|
+
stepName: string,
|
|
349
|
+
rpcName: string,
|
|
350
|
+
data: any,
|
|
351
|
+
rpcService: any
|
|
352
|
+
): Promise<void> {
|
|
353
|
+
// Get step state
|
|
354
|
+
let stepState = await this.getStepState(runId, stepName)
|
|
355
|
+
|
|
356
|
+
// Idempotency - if already succeeded, resume orchestrator and return
|
|
357
|
+
if (stepState.status === 'succeeded') {
|
|
358
|
+
await this.resumeWorkflow(runId)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Log warning if already running (race condition)
|
|
363
|
+
if (stepState.status === 'running') {
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// If status is 'failed', this is a retry - create new attempt history
|
|
368
|
+
if (stepState.status === 'failed') {
|
|
369
|
+
stepState = await this.createRetryAttempt(stepState.stepId, 'running')
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (stepState.status !== 'pending') {
|
|
373
|
+
// Mark step as running (pending or failed status)
|
|
374
|
+
await this.setStepRunning(stepState.stepId)
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
try {
|
|
378
|
+
// Execute RPC with workflow step context
|
|
379
|
+
const result = await rpcService.rpcWithInteraction(rpcName, data, {
|
|
380
|
+
workflowStep: {
|
|
381
|
+
runId,
|
|
382
|
+
stepId: stepState.stepId,
|
|
383
|
+
attemptCount: stepState.attemptCount,
|
|
384
|
+
},
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
// Store result and mark succeeded
|
|
388
|
+
await this.setStepResult(stepState.stepId, result)
|
|
389
|
+
|
|
390
|
+
// Resume orchestrator to continue workflow
|
|
391
|
+
try {
|
|
392
|
+
await this.resumeWorkflow(runId)
|
|
393
|
+
} catch (resumeError: any) {
|
|
394
|
+
throw resumeError
|
|
395
|
+
}
|
|
396
|
+
} catch (error: any) {
|
|
397
|
+
// Store error and mark failed
|
|
398
|
+
await this.setStepError(stepState.stepId, error)
|
|
399
|
+
|
|
400
|
+
const maxAttempts = (stepState.retries ?? 0) + 1
|
|
401
|
+
const retriesExhausted = stepState.attemptCount >= maxAttempts
|
|
402
|
+
|
|
403
|
+
if (retriesExhausted) {
|
|
404
|
+
// No more retries - resume orchestrator to mark workflow as failed
|
|
405
|
+
await this.resumeWorkflow(runId)
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Always throw so queue knows the job failed and can retry if needed
|
|
409
|
+
throw error
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Orchestrate workflow execution (called by orchestrator)
|
|
415
|
+
* Runs workflow job and handles async exceptions
|
|
416
|
+
*/
|
|
417
|
+
public async orchestrateWorkflow(
|
|
418
|
+
runId: string,
|
|
419
|
+
rpcService: any
|
|
420
|
+
): Promise<void> {
|
|
421
|
+
try {
|
|
422
|
+
// Run workflow job (replays with caching)
|
|
423
|
+
await this.runWorkflowJob(runId, rpcService)
|
|
424
|
+
} catch (error: any) {
|
|
425
|
+
if (
|
|
426
|
+
error.name === 'WorkflowAsyncException' ||
|
|
427
|
+
error.name === 'WorkflowCancelledException'
|
|
428
|
+
) {
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
433
|
+
message: error.message,
|
|
434
|
+
stack: error.stack,
|
|
435
|
+
code: error.code,
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
throw error
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private verifyQueueService(): QueueService {
|
|
443
|
+
if (!this.singletonServices?.queueService) {
|
|
444
|
+
throw new Error(
|
|
445
|
+
'QueueService not configured. Remote workflows require a queue service.'
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
return this.singletonServices!.queueService
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private async rpcStep(
|
|
453
|
+
runId: string,
|
|
454
|
+
stepName: string,
|
|
455
|
+
rpcName: string,
|
|
456
|
+
data: any,
|
|
457
|
+
rpcService: any,
|
|
458
|
+
stepOptions?: WorkflowStepOptions
|
|
459
|
+
): Promise<any> {
|
|
460
|
+
// Check if step already exists
|
|
461
|
+
let stepState: StepState
|
|
462
|
+
try {
|
|
463
|
+
stepState = await this.getStepState(runId, stepName)
|
|
464
|
+
} catch (error: any) {
|
|
465
|
+
// Step doesn't exist - create it
|
|
466
|
+
stepState = await this.insertStepState(
|
|
467
|
+
runId,
|
|
468
|
+
stepName,
|
|
469
|
+
rpcName,
|
|
470
|
+
data,
|
|
471
|
+
stepOptions
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (stepState.status === 'succeeded') {
|
|
476
|
+
// Return cached result
|
|
477
|
+
return stepState.result
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (stepState.status === 'failed') {
|
|
481
|
+
// Step failed with retries exhausted - throw error to fail the workflow
|
|
482
|
+
const error = new Error(
|
|
483
|
+
stepState.error?.message ||
|
|
484
|
+
`Step '${stepName}' failed after exhausting all retries`
|
|
485
|
+
)
|
|
486
|
+
// Preserve original error properties if available
|
|
487
|
+
if (stepState.error) {
|
|
488
|
+
Object.assign(error, stepState.error)
|
|
489
|
+
}
|
|
490
|
+
throw error
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (stepState.status === 'scheduled') {
|
|
494
|
+
// Step is already scheduled, pause workflow
|
|
495
|
+
throw new WorkflowAsyncException(runId, stepName)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Step is pending - schedule it
|
|
499
|
+
await this.setStepScheduled(stepState.stepId)
|
|
500
|
+
|
|
501
|
+
// Enqueue step worker
|
|
502
|
+
if (this.singletonServices!.queueService) {
|
|
503
|
+
// Map step retry options to queue job options
|
|
504
|
+
const retries = stepOptions?.retries ?? 0
|
|
505
|
+
const retryDelay = stepOptions?.retryDelay
|
|
506
|
+
|
|
507
|
+
await this.singletonServices!.queueService.add(
|
|
508
|
+
this.getConfig().stepWorkerQueueName,
|
|
509
|
+
{
|
|
510
|
+
runId,
|
|
511
|
+
stepName,
|
|
512
|
+
rpcName,
|
|
513
|
+
data,
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
// attempts includes initial attempt, retries doesn't
|
|
517
|
+
attempts: retries + 1,
|
|
518
|
+
// Map retry delay to backoff
|
|
519
|
+
backoff:
|
|
520
|
+
typeof retryDelay === 'number'
|
|
521
|
+
? { type: 'fixed', delay: retryDelay }
|
|
522
|
+
: retryDelay === 'exponential'
|
|
523
|
+
? 'exponential'
|
|
524
|
+
: undefined,
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
} else {
|
|
528
|
+
// No queue service - execute locally
|
|
529
|
+
const retries = stepOptions?.retries ?? 0
|
|
530
|
+
const retryDelay = stepOptions?.retryDelay
|
|
531
|
+
const attemptCount = stepState.attemptCount
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
const result = await rpcService.rpcWithInteraction(rpcName, data, {
|
|
535
|
+
workflowStep: {
|
|
536
|
+
runId,
|
|
537
|
+
stepId: stepState.stepId,
|
|
538
|
+
attemptCount: stepState.attemptCount,
|
|
539
|
+
},
|
|
540
|
+
})
|
|
541
|
+
await this.setStepResult(stepState.stepId, result)
|
|
542
|
+
return result
|
|
543
|
+
} catch (error: any) {
|
|
544
|
+
// Record the error (marks step as failed)
|
|
545
|
+
await this.setStepError(stepState.stepId, error)
|
|
546
|
+
|
|
547
|
+
// Check if we should retry
|
|
548
|
+
if (attemptCount < retries) {
|
|
549
|
+
// Create a new pending retry attempt (copies metadata from failed step)
|
|
550
|
+
await this.createRetryAttempt(stepState.stepId, 'pending')
|
|
551
|
+
|
|
552
|
+
// Schedule orchestrator to retry after delay
|
|
553
|
+
await this.scheduleOrchestratorRetry(runId, retryDelay)
|
|
554
|
+
|
|
555
|
+
// Pause workflow - orchestrator will replay and pick up new attempt
|
|
556
|
+
throw new WorkflowAsyncException(runId, stepName)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// No more retries, fail the workflow
|
|
560
|
+
throw error
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Pause workflow - step will callback when done
|
|
565
|
+
throw new WorkflowAsyncException(runId, stepName)
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private async inlineStep(
|
|
569
|
+
runId: string,
|
|
570
|
+
stepName: string,
|
|
571
|
+
fn: Function,
|
|
572
|
+
stepOptions?: WorkflowStepOptions
|
|
573
|
+
): Promise<any> {
|
|
574
|
+
// Check if step already exists
|
|
575
|
+
let stepState: StepState
|
|
576
|
+
try {
|
|
577
|
+
stepState = await this.getStepState(runId, stepName)
|
|
578
|
+
} catch (error: any) {
|
|
579
|
+
// Step doesn't exist - create it (inline, no RPC)
|
|
580
|
+
stepState = await this.insertStepState(
|
|
581
|
+
runId,
|
|
582
|
+
stepName,
|
|
583
|
+
null, // No RPC for inline steps
|
|
584
|
+
null, // No data for inline steps
|
|
585
|
+
stepOptions
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (stepState.status === 'succeeded') {
|
|
590
|
+
// Return cached result
|
|
591
|
+
return stepState.result
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Execute inline function
|
|
595
|
+
const retries = stepOptions?.retries ?? this.getConfig().retries
|
|
596
|
+
const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay
|
|
597
|
+
const attemptCount = stepState.attemptCount
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
const result = await fn()
|
|
601
|
+
await this.setStepResult(stepState.stepId, result)
|
|
602
|
+
return result
|
|
603
|
+
} catch (error: any) {
|
|
604
|
+
// Record the error (marks step as failed)
|
|
605
|
+
await this.setStepError(stepState.stepId, error)
|
|
606
|
+
|
|
607
|
+
// Check if we should retry
|
|
608
|
+
if (attemptCount < retries) {
|
|
609
|
+
// Create a new pending retry attempt (copies metadata from failed step)
|
|
610
|
+
await this.createRetryAttempt(stepState.stepId, 'pending')
|
|
611
|
+
|
|
612
|
+
// Schedule orchestrator to retry after delay
|
|
613
|
+
await this.scheduleOrchestratorRetry(runId, retryDelay)
|
|
614
|
+
|
|
615
|
+
// Pause workflow - orchestrator will replay and pick up new attempt
|
|
616
|
+
throw new WorkflowAsyncException(runId, stepName)
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// No more retries, fail the workflow
|
|
620
|
+
throw error
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private async sleepStep(runId: string, stepName: string, duration: number) {
|
|
625
|
+
// Check if step already exists
|
|
626
|
+
let stepState: StepState
|
|
627
|
+
try {
|
|
628
|
+
stepState = await this.getStepState(runId, stepName)
|
|
629
|
+
} catch (error: any) {
|
|
630
|
+
// Step doesn't exist - create it (sleep step, no RPC)
|
|
631
|
+
stepState = await this.insertStepState(
|
|
632
|
+
runId,
|
|
633
|
+
stepName,
|
|
634
|
+
null, // No RPC for sleep steps
|
|
635
|
+
{ duration }, // Store duration as data
|
|
636
|
+
undefined // No retry options for sleep
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (stepState.status === 'succeeded') {
|
|
641
|
+
// Sleep already completed, return immediately
|
|
642
|
+
return
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (stepState.status === 'scheduled') {
|
|
646
|
+
// Sleep is already scheduled, pause workflow
|
|
647
|
+
throw new WorkflowAsyncException(runId, stepName)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// Step is pending - schedule it
|
|
651
|
+
await this.setStepScheduled(stepState.stepId)
|
|
652
|
+
|
|
653
|
+
// Check if we have queue service (remote mode) or inline mode
|
|
654
|
+
if (this.singletonServices!.schedulerService) {
|
|
655
|
+
// Remote mode - enqueue sleep worker with delay
|
|
656
|
+
await this.singletonServices!.schedulerService.scheduleRPC(
|
|
657
|
+
duration,
|
|
658
|
+
this.getConfig().sleeperRPCName,
|
|
659
|
+
{
|
|
660
|
+
runId,
|
|
661
|
+
stepId: stepState.stepId,
|
|
662
|
+
}
|
|
663
|
+
)
|
|
664
|
+
} else {
|
|
665
|
+
// Inline mode - use setTimeout with actual duration
|
|
666
|
+
await new Promise((resolve) =>
|
|
667
|
+
setTimeout(resolve, getDurationInMilliseconds(duration))
|
|
668
|
+
)
|
|
669
|
+
await this.setStepResult(stepState.stepId, null)
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Pause workflow - sleep will callback when done
|
|
674
|
+
throw new WorkflowAsyncException(runId, stepName)
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
private async cancelWorkflow(runId: string, reason?: string) {
|
|
678
|
+
// Update workflow run status to cancelled
|
|
679
|
+
await this.updateRunStatus(runId, 'cancelled', undefined, {
|
|
680
|
+
message: reason || 'Workflow cancelled by user',
|
|
681
|
+
stack: '',
|
|
682
|
+
code: 'WORKFLOW_CANCELLED',
|
|
683
|
+
})
|
|
684
|
+
|
|
685
|
+
// Throw cancellation exception to stop workflow execution
|
|
686
|
+
throw new WorkflowCancelledException(runId, reason)
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
private createWorkflowInteraction(
|
|
690
|
+
workflowName: string,
|
|
691
|
+
runId: string,
|
|
692
|
+
rpcService: any
|
|
693
|
+
): PikkuWorkflowInteraction {
|
|
694
|
+
const workflowInteraction: PikkuWorkflowInteraction = {
|
|
695
|
+
workflowName,
|
|
696
|
+
runId,
|
|
697
|
+
getRun: async () => (await this.getRun(runId)) as WorkflowRun,
|
|
698
|
+
|
|
699
|
+
// Implement workflow.do() - RPC form
|
|
700
|
+
do: async (
|
|
701
|
+
stepName: string,
|
|
702
|
+
rpcNameOrFn: any,
|
|
703
|
+
dataOrOptions?: any,
|
|
704
|
+
options?: any
|
|
705
|
+
) => {
|
|
706
|
+
this.verifyStepName(stepName)
|
|
707
|
+
if (typeof rpcNameOrFn === 'string') {
|
|
708
|
+
return await this.rpcStep(
|
|
709
|
+
runId,
|
|
710
|
+
stepName,
|
|
711
|
+
rpcNameOrFn,
|
|
712
|
+
dataOrOptions,
|
|
713
|
+
rpcService,
|
|
714
|
+
options
|
|
715
|
+
)
|
|
716
|
+
} else {
|
|
717
|
+
return await this.inlineStep(
|
|
718
|
+
runId,
|
|
719
|
+
stepName,
|
|
720
|
+
rpcNameOrFn,
|
|
721
|
+
dataOrOptions
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
|
|
726
|
+
// Implement workflow.sleep()
|
|
727
|
+
sleep: async (stepName: string, duration: string | number) => {
|
|
728
|
+
this.verifyStepName(stepName)
|
|
729
|
+
await this.sleepStep(
|
|
730
|
+
runId,
|
|
731
|
+
stepName,
|
|
732
|
+
getDurationInMilliseconds(duration)
|
|
733
|
+
)
|
|
734
|
+
},
|
|
735
|
+
|
|
736
|
+
// Implement workflow.cancel()
|
|
737
|
+
cancel: async (reason?: string): Promise<void> => {
|
|
738
|
+
await this.cancelWorkflow(runId, reason)
|
|
739
|
+
},
|
|
740
|
+
}
|
|
741
|
+
return workflowInteraction
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
private verifyStepName(stepName: string) {
|
|
745
|
+
if (typeof stepName !== 'string') {
|
|
746
|
+
throw new WorkflowStepNameNotString(stepName)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
private getConfig() {
|
|
751
|
+
if (!this.singletonServices || !this.config) {
|
|
752
|
+
throw new WorkflowServiceNotInitialized()
|
|
753
|
+
}
|
|
754
|
+
return this.config
|
|
755
|
+
}
|
|
756
|
+
}
|