@pikku/core 0.10.2 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/dist/errors/errors.d.ts +1 -1
- package/dist/errors/errors.js +3 -3
- package/dist/function/function-runner.d.ts +6 -7
- package/dist/function/function-runner.js +35 -16
- package/dist/function/functions.types.d.ts +23 -30
- package/dist/function/functions.types.js +9 -6
- package/dist/handle-error.d.ts +1 -1
- package/dist/handle-error.js +1 -1
- 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-apikey.js +3 -3
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-bearer.js +3 -3
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/auth-cookie.js +5 -5
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/middleware/timeout.js +3 -2
- package/dist/middleware-runner.d.ts +3 -6
- package/dist/middleware-runner.js +11 -11
- package/dist/permissions.d.ts +6 -6
- package/dist/permissions.js +15 -15
- 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/services/user-session-service.d.ts +7 -3
- package/dist/services/user-session-service.js +8 -1
- package/dist/time-utils.d.ts +14 -0
- package/dist/time-utils.js +62 -0
- package/dist/types/core.types.d.ts +48 -35
- package/dist/types/core.types.js +3 -13
- package/dist/utils.d.ts +1 -1
- package/dist/utils.js +3 -3
- package/dist/wirings/channel/channel-common.js +2 -3
- package/dist/wirings/channel/channel-handler.d.ts +3 -2
- package/dist/wirings/channel/channel-handler.js +6 -9
- package/dist/wirings/channel/channel-runner.d.ts +2 -2
- package/dist/wirings/channel/channel.types.d.ts +10 -5
- package/dist/wirings/channel/local/local-channel-runner.d.ts +1 -1
- package/dist/wirings/channel/local/local-channel-runner.js +19 -26
- package/dist/wirings/channel/serverless/serverless-channel-runner.d.ts +1 -1
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +41 -46
- package/dist/wirings/cli/channel/cli-channel-runner.js +1 -1
- package/dist/wirings/cli/cli-runner.d.ts +5 -5
- package/dist/wirings/cli/cli-runner.js +11 -29
- package/dist/wirings/cli/cli.types.d.ts +15 -11
- package/dist/wirings/http/http-runner.d.ts +6 -6
- package/dist/wirings/http/http-runner.js +25 -39
- package/dist/wirings/http/http.types.d.ts +5 -10
- package/dist/wirings/mcp/mcp-runner.d.ts +2 -2
- package/dist/wirings/mcp/mcp-runner.js +12 -19
- package/dist/wirings/mcp/mcp.types.d.ts +6 -0
- package/dist/wirings/queue/queue-runner.d.ts +3 -3
- package/dist/wirings/queue/queue-runner.js +7 -14
- package/dist/wirings/queue/queue.types.d.ts +4 -13
- 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 +13 -7
- package/dist/wirings/rpc/rpc-runner.js +50 -26
- package/dist/wirings/rpc/rpc-types.d.ts +1 -0
- package/dist/wirings/scheduler/scheduler-runner.d.ts +3 -3
- package/dist/wirings/scheduler/scheduler-runner.js +13 -24
- package/dist/wirings/scheduler/scheduler.types.d.ts +3 -8
- package/dist/wirings/workflow/index.d.ts +15 -0
- package/dist/wirings/workflow/index.js +14 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +160 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +430 -0
- package/dist/wirings/workflow/workflow-runner.d.ts +34 -0
- package/dist/wirings/workflow/workflow-runner.js +65 -0
- package/dist/wirings/workflow/workflow.types.d.ts +332 -0
- package/dist/wirings/workflow/workflow.types.js +1 -0
- package/package.json +3 -4
- package/src/errors/errors.ts +3 -3
- package/src/factory-functions.test.ts +9 -36
- package/src/function/function-runner.test.ts +57 -68
- package/src/function/function-runner.ts +51 -28
- package/src/function/function-runner.ts.bak +188 -0
- package/src/function/functions.types.ts +69 -51
- package/src/handle-error.ts +1 -1
- package/src/index.ts +2 -0
- package/src/middleware/auth-apikey.test.ts +360 -0
- package/src/middleware/auth-apikey.ts +3 -7
- package/src/middleware/auth-bearer.test.ts +447 -0
- package/src/middleware/auth-bearer.ts +27 -33
- package/src/middleware/auth-cookie.test.ts +525 -0
- package/src/middleware/auth-cookie.ts +6 -6
- package/src/middleware/timeout.ts +4 -2
- package/src/middleware-runner.test.ts +58 -127
- package/src/middleware-runner.ts +13 -15
- package/src/permissions.test.ts +41 -45
- package/src/permissions.ts +21 -20
- package/src/pikku-state.ts +14 -2
- package/src/services/index.ts +1 -0
- package/src/services/scheduler-service.ts +76 -0
- package/src/services/user-session-service.ts +14 -4
- package/src/time-utils.ts +75 -0
- package/src/types/core.types.ts +92 -51
- package/src/utils.ts +4 -4
- package/src/wirings/channel/channel-common.ts +4 -7
- package/src/wirings/channel/channel-handler.ts +16 -22
- package/src/wirings/channel/channel-runner.ts +2 -2
- package/src/wirings/channel/channel.types.ts +14 -11
- package/src/wirings/channel/local/local-channel-runner.test.ts +5 -3
- package/src/wirings/channel/local/local-channel-runner.ts +23 -38
- package/src/wirings/channel/serverless/serverless-channel-runner.ts +51 -77
- package/src/wirings/cli/channel/cli-channel-runner.ts +1 -1
- package/src/wirings/cli/cli-runner.test.ts +7 -7
- package/src/wirings/cli/cli-runner.ts +25 -63
- package/src/wirings/cli/cli.types.ts +22 -16
- package/src/wirings/http/http-runner.test.ts +8 -8
- package/src/wirings/http/http-runner.ts +29 -56
- package/src/wirings/http/http.types.ts +5 -14
- package/src/wirings/mcp/mcp-runner.ts +23 -48
- package/src/wirings/mcp/mcp.types.ts +6 -0
- package/src/wirings/queue/queue-runner.test.ts +641 -0
- package/src/wirings/queue/queue-runner.ts +10 -24
- package/src/wirings/queue/queue.types.ts +5 -15
- package/src/wirings/rpc/index.ts +1 -1
- package/src/wirings/rpc/rpc-runner.ts +71 -32
- package/src/wirings/rpc/rpc-types.ts +1 -0
- package/src/wirings/scheduler/scheduler-runner.test.ts +627 -0
- package/src/wirings/scheduler/scheduler-runner.ts +24 -50
- package/src/wirings/scheduler/scheduler.types.ts +3 -9
- package/src/wirings/workflow/index.ts +43 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +746 -0
- package/src/wirings/workflow/workflow-runner.ts +72 -0
- package/src/wirings/workflow/workflow.types.ts +422 -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,430 @@
|
|
|
1
|
+
import { runPikkuFunc } from '../../function/function-runner.js';
|
|
2
|
+
import { pikkuState } from '../../pikku-state.js';
|
|
3
|
+
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
4
|
+
import { WorkflowAsyncException, WorkflowCancelledException, WorkflowNotFoundError, WorkflowRunNotFound, } from './workflow-runner.js';
|
|
5
|
+
export class WorkflowServiceNotInitialized extends Error {
|
|
6
|
+
}
|
|
7
|
+
export class WorkflowStepNameNotString extends Error {
|
|
8
|
+
constructor(stepName) {
|
|
9
|
+
super(`Workflow step name must be a string. Received: ${typeof stepName}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Abstract workflow state service
|
|
14
|
+
* Implementations provide pluggable storage backends (SQLite, PostgreSQL, etc.)
|
|
15
|
+
* Combines orchestration and step execution
|
|
16
|
+
*/
|
|
17
|
+
export class PikkuWorkflowService {
|
|
18
|
+
config;
|
|
19
|
+
singletonServices;
|
|
20
|
+
createWireServices;
|
|
21
|
+
constructor() { }
|
|
22
|
+
setServices(singletonServices, createWireServices, { workflow }) {
|
|
23
|
+
this.singletonServices = singletonServices;
|
|
24
|
+
this.createWireServices = createWireServices;
|
|
25
|
+
this.config = {
|
|
26
|
+
retries: workflow?.retries ?? 0,
|
|
27
|
+
retryDelay: workflow?.retryDelay ?? 0,
|
|
28
|
+
orchestratorQueueName: workflow?.orchestratorQueueName ?? 'pikku-workflow-orchestrator',
|
|
29
|
+
stepWorkerQueueName: workflow?.stepWorkerQueueName ?? 'pikku-workflow-step-worker',
|
|
30
|
+
sleeperRPCName: workflow?.sleeperRPCName ?? 'pikkuWorkflowStepSleeper',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// ============================================================================
|
|
34
|
+
// Workflow Lifecycle Methods
|
|
35
|
+
// ============================================================================
|
|
36
|
+
/**
|
|
37
|
+
* Resume a paused workflow by triggering the orchestrator
|
|
38
|
+
* @param runId - Run ID
|
|
39
|
+
*/
|
|
40
|
+
async resumeWorkflow(runId) {
|
|
41
|
+
const queueService = this.verifyQueueService();
|
|
42
|
+
await queueService.add(this.getConfig().orchestratorQueueName, { runId });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Execute a workflow sleep step completion
|
|
46
|
+
* Sets the step result to null and resumes the workflow
|
|
47
|
+
* @param data - Sleeper input data
|
|
48
|
+
*/
|
|
49
|
+
async executeWorkflowSleep(runId, stepId) {
|
|
50
|
+
await this.setStepResult(stepId, null);
|
|
51
|
+
await this.resumeWorkflow(runId);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Schedule orchestrator retry with delay
|
|
55
|
+
* @param runId - Run ID
|
|
56
|
+
* @param retryDelay - Delay in milliseconds or duration string (optional)
|
|
57
|
+
*/
|
|
58
|
+
async scheduleOrchestratorRetry(runId, retryDelay) {
|
|
59
|
+
const queueService = this.verifyQueueService();
|
|
60
|
+
await queueService.add(this.getConfig().orchestratorQueueName, { runId }, retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Start a new workflow run
|
|
64
|
+
*/
|
|
65
|
+
async startWorkflow(name, input, rpcService) {
|
|
66
|
+
const registrations = pikkuState('workflows', 'registrations');
|
|
67
|
+
const workflow = registrations.get(name);
|
|
68
|
+
if (!workflow) {
|
|
69
|
+
throw new WorkflowNotFoundError(name);
|
|
70
|
+
}
|
|
71
|
+
// Create workflow run in state
|
|
72
|
+
const runId = await this.createRun(name, input);
|
|
73
|
+
// If queue service is available, use remote execution (queue-based)
|
|
74
|
+
// Otherwise, execute directly (inline/synchronous)
|
|
75
|
+
if (this.singletonServices?.queueService) {
|
|
76
|
+
// Queue orchestrator to start the workflow
|
|
77
|
+
await this.resumeWorkflow(runId);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// No queue service - execute directly via runWorkflowJob
|
|
81
|
+
await this.runWorkflowJob(runId, rpcService);
|
|
82
|
+
}
|
|
83
|
+
return { runId };
|
|
84
|
+
}
|
|
85
|
+
async runWorkflowJob(runId, rpcService) {
|
|
86
|
+
const run = await this.getRun(runId);
|
|
87
|
+
if (!run) {
|
|
88
|
+
throw new WorkflowRunNotFound(runId);
|
|
89
|
+
}
|
|
90
|
+
const registrations = pikkuState('workflows', 'registrations');
|
|
91
|
+
const workflow = registrations.get(run.workflow);
|
|
92
|
+
if (!workflow) {
|
|
93
|
+
throw new WorkflowNotFoundError(run.workflow);
|
|
94
|
+
}
|
|
95
|
+
await this.withRunLock(runId, async () => {
|
|
96
|
+
const meta = pikkuState('workflows', 'meta');
|
|
97
|
+
const workflowMeta = meta[run.workflow];
|
|
98
|
+
const workflowWire = this.createWorkflowWire(run.workflow, runId, rpcService);
|
|
99
|
+
const wire = { workflow: workflowWire };
|
|
100
|
+
try {
|
|
101
|
+
const result = await runPikkuFunc('workflow', workflowMeta.workflowName, workflowMeta.pikkuFuncName, {
|
|
102
|
+
singletonServices: this.singletonServices,
|
|
103
|
+
wire,
|
|
104
|
+
createWireServices: this.createWireServices,
|
|
105
|
+
data: () => run.input,
|
|
106
|
+
});
|
|
107
|
+
await this.updateRunStatus(runId, 'completed', result);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error instanceof WorkflowAsyncException) {
|
|
111
|
+
// Normal - workflow paused for step execution
|
|
112
|
+
throw error;
|
|
113
|
+
}
|
|
114
|
+
// Check if it's a WorkflowCancelledException
|
|
115
|
+
if (error instanceof WorkflowCancelledException) {
|
|
116
|
+
// Workflow was cancelled - status already updated, just rethrow
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
120
|
+
message: error.message,
|
|
121
|
+
stack: error.stack,
|
|
122
|
+
code: error.code,
|
|
123
|
+
});
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Execute a single workflow step (called by worker)
|
|
130
|
+
* Handles idempotency, RPC execution, result storage, retry logic, and orchestrator triggering
|
|
131
|
+
*/
|
|
132
|
+
async executeWorkflowStep(runId, stepName, rpcName, data, rpcService) {
|
|
133
|
+
// Use step-level lock to prevent concurrent execution of same step
|
|
134
|
+
await this.withStepLock(runId, stepName, async () => {
|
|
135
|
+
// Get step state
|
|
136
|
+
let stepState = await this.getStepState(runId, stepName);
|
|
137
|
+
// Idempotency - if already succeeded, resume orchestrator and return
|
|
138
|
+
if (stepState.status === 'succeeded') {
|
|
139
|
+
await this.resumeWorkflow(runId);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
// Log warning if already running (race condition)
|
|
143
|
+
if (stepState.status === 'running') {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
// If status is 'failed', this is a retry - create new attempt history
|
|
147
|
+
if (stepState.status === 'failed') {
|
|
148
|
+
stepState = await this.createRetryAttempt(stepState.stepId, 'running');
|
|
149
|
+
}
|
|
150
|
+
if (stepState.status !== 'pending') {
|
|
151
|
+
// Mark step as running (pending or failed status)
|
|
152
|
+
await this.setStepRunning(stepState.stepId);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
// Execute RPC with workflow step context
|
|
156
|
+
const result = await rpcService.rpcWithWire(rpcName, data, {
|
|
157
|
+
workflowStep: {
|
|
158
|
+
runId,
|
|
159
|
+
stepId: stepState.stepId,
|
|
160
|
+
attemptCount: stepState.attemptCount,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
// Store result and mark succeeded
|
|
164
|
+
await this.setStepResult(stepState.stepId, result);
|
|
165
|
+
// Resume orchestrator to continue workflow
|
|
166
|
+
try {
|
|
167
|
+
await this.resumeWorkflow(runId);
|
|
168
|
+
}
|
|
169
|
+
catch (resumeError) {
|
|
170
|
+
throw resumeError;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
// Store error and mark failed
|
|
175
|
+
await this.setStepError(stepState.stepId, error);
|
|
176
|
+
const maxAttempts = (stepState.retries ?? 0) + 1;
|
|
177
|
+
const retriesExhausted = stepState.attemptCount >= maxAttempts;
|
|
178
|
+
if (retriesExhausted) {
|
|
179
|
+
// No more retries - resume orchestrator to mark workflow as failed
|
|
180
|
+
await this.resumeWorkflow(runId);
|
|
181
|
+
}
|
|
182
|
+
// Always throw so queue knows the job failed and can retry if needed
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Orchestrate workflow execution (called by orchestrator)
|
|
189
|
+
* Runs workflow job and handles async exceptions
|
|
190
|
+
*/
|
|
191
|
+
async orchestrateWorkflow(runId, rpcService) {
|
|
192
|
+
try {
|
|
193
|
+
// Run workflow job (replays with caching)
|
|
194
|
+
await this.runWorkflowJob(runId, rpcService);
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error.name === 'WorkflowAsyncException' ||
|
|
198
|
+
error.name === 'WorkflowCancelledException') {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
202
|
+
message: error.message,
|
|
203
|
+
stack: error.stack,
|
|
204
|
+
code: error.code,
|
|
205
|
+
});
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
verifyQueueService() {
|
|
210
|
+
if (!this.singletonServices?.queueService) {
|
|
211
|
+
throw new Error('QueueService not configured. Remote workflows require a queue service.');
|
|
212
|
+
}
|
|
213
|
+
return this.singletonServices.queueService;
|
|
214
|
+
}
|
|
215
|
+
async rpcStep(runId, stepName, rpcName, data, rpcService, stepOptions) {
|
|
216
|
+
// Check if step already exists
|
|
217
|
+
let stepState;
|
|
218
|
+
try {
|
|
219
|
+
stepState = await this.getStepState(runId, stepName);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
// Step doesn't exist - create it
|
|
223
|
+
stepState = await this.insertStepState(runId, stepName, rpcName, data, stepOptions);
|
|
224
|
+
}
|
|
225
|
+
if (stepState.status === 'succeeded') {
|
|
226
|
+
// Return cached result
|
|
227
|
+
return stepState.result;
|
|
228
|
+
}
|
|
229
|
+
if (stepState.status === 'failed') {
|
|
230
|
+
// Step failed with retries exhausted - throw error to fail the workflow
|
|
231
|
+
const error = new Error(stepState.error?.message ||
|
|
232
|
+
`Step '${stepName}' failed after exhausting all retries`);
|
|
233
|
+
// Preserve original error properties if available
|
|
234
|
+
if (stepState.error) {
|
|
235
|
+
Object.assign(error, stepState.error);
|
|
236
|
+
}
|
|
237
|
+
throw error;
|
|
238
|
+
}
|
|
239
|
+
if (stepState.status === 'scheduled') {
|
|
240
|
+
// Step is already scheduled, pause workflow
|
|
241
|
+
throw new WorkflowAsyncException(runId, stepName);
|
|
242
|
+
}
|
|
243
|
+
// Step is pending - schedule it
|
|
244
|
+
await this.setStepScheduled(stepState.stepId);
|
|
245
|
+
// Enqueue step worker
|
|
246
|
+
if (this.singletonServices.queueService) {
|
|
247
|
+
// Map step retry options to queue job options
|
|
248
|
+
const retries = stepOptions?.retries ?? 0;
|
|
249
|
+
const retryDelay = stepOptions?.retryDelay;
|
|
250
|
+
await this.singletonServices.queueService.add(this.getConfig().stepWorkerQueueName, {
|
|
251
|
+
runId,
|
|
252
|
+
stepName,
|
|
253
|
+
rpcName,
|
|
254
|
+
data,
|
|
255
|
+
}, {
|
|
256
|
+
// attempts includes initial attempt, retries doesn't
|
|
257
|
+
attempts: retries + 1,
|
|
258
|
+
// Map retry delay to backoff
|
|
259
|
+
backoff: typeof retryDelay === 'number'
|
|
260
|
+
? { type: 'fixed', delay: retryDelay }
|
|
261
|
+
: retryDelay === 'exponential'
|
|
262
|
+
? 'exponential'
|
|
263
|
+
: undefined,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// No queue service - execute locally
|
|
268
|
+
const retries = stepOptions?.retries ?? 0;
|
|
269
|
+
const retryDelay = stepOptions?.retryDelay;
|
|
270
|
+
const attemptCount = stepState.attemptCount;
|
|
271
|
+
try {
|
|
272
|
+
const result = await rpcService.rpcWithWire(rpcName, data, {
|
|
273
|
+
workflowStep: {
|
|
274
|
+
runId,
|
|
275
|
+
stepId: stepState.stepId,
|
|
276
|
+
attemptCount: stepState.attemptCount,
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
await this.setStepResult(stepState.stepId, result);
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
catch (error) {
|
|
283
|
+
// Record the error (marks step as failed)
|
|
284
|
+
await this.setStepError(stepState.stepId, error);
|
|
285
|
+
// Check if we should retry
|
|
286
|
+
if (attemptCount < retries) {
|
|
287
|
+
// Create a new pending retry attempt (copies metadata from failed step)
|
|
288
|
+
await this.createRetryAttempt(stepState.stepId, 'pending');
|
|
289
|
+
// Schedule orchestrator to retry after delay
|
|
290
|
+
await this.scheduleOrchestratorRetry(runId, retryDelay);
|
|
291
|
+
// Pause workflow - orchestrator will replay and pick up new attempt
|
|
292
|
+
throw new WorkflowAsyncException(runId, stepName);
|
|
293
|
+
}
|
|
294
|
+
// No more retries, fail the workflow
|
|
295
|
+
throw error;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Pause workflow - step will callback when done
|
|
299
|
+
throw new WorkflowAsyncException(runId, stepName);
|
|
300
|
+
}
|
|
301
|
+
async inlineStep(runId, stepName, fn, stepOptions) {
|
|
302
|
+
// Check if step already exists
|
|
303
|
+
let stepState;
|
|
304
|
+
try {
|
|
305
|
+
stepState = await this.getStepState(runId, stepName);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
// Step doesn't exist - create it (inline, no RPC)
|
|
309
|
+
stepState = await this.insertStepState(runId, stepName, null, // No RPC for inline steps
|
|
310
|
+
null, // No data for inline steps
|
|
311
|
+
stepOptions);
|
|
312
|
+
}
|
|
313
|
+
if (stepState.status === 'succeeded') {
|
|
314
|
+
// Return cached result
|
|
315
|
+
return stepState.result;
|
|
316
|
+
}
|
|
317
|
+
// Execute inline function
|
|
318
|
+
const retries = stepOptions?.retries ?? this.getConfig().retries;
|
|
319
|
+
const retryDelay = stepOptions?.retryDelay ?? this.getConfig().retryDelay;
|
|
320
|
+
const attemptCount = stepState.attemptCount;
|
|
321
|
+
try {
|
|
322
|
+
const result = await fn();
|
|
323
|
+
await this.setStepResult(stepState.stepId, result);
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
// Record the error (marks step as failed)
|
|
328
|
+
await this.setStepError(stepState.stepId, error);
|
|
329
|
+
// Check if we should retry
|
|
330
|
+
if (attemptCount < retries) {
|
|
331
|
+
// Create a new pending retry attempt (copies metadata from failed step)
|
|
332
|
+
await this.createRetryAttempt(stepState.stepId, 'pending');
|
|
333
|
+
// Schedule orchestrator to retry after delay
|
|
334
|
+
await this.scheduleOrchestratorRetry(runId, retryDelay);
|
|
335
|
+
// Pause workflow - orchestrator will replay and pick up new attempt
|
|
336
|
+
throw new WorkflowAsyncException(runId, stepName);
|
|
337
|
+
}
|
|
338
|
+
// No more retries, fail the workflow
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
async sleepStep(runId, stepName, duration) {
|
|
343
|
+
// Check if step already exists
|
|
344
|
+
let stepState;
|
|
345
|
+
try {
|
|
346
|
+
stepState = await this.getStepState(runId, stepName);
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
// Step doesn't exist - create it (sleep step, no RPC)
|
|
350
|
+
stepState = await this.insertStepState(runId, stepName, null, // No RPC for sleep steps
|
|
351
|
+
{ duration }, // Store duration as data
|
|
352
|
+
undefined // No retry options for sleep
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
if (stepState.status === 'succeeded') {
|
|
356
|
+
// Sleep already completed, return immediately
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (stepState.status === 'scheduled') {
|
|
360
|
+
// Sleep is already scheduled, pause workflow
|
|
361
|
+
throw new WorkflowAsyncException(runId, stepName);
|
|
362
|
+
}
|
|
363
|
+
// Step is pending - schedule it
|
|
364
|
+
await this.setStepScheduled(stepState.stepId);
|
|
365
|
+
// Check if we have queue service (remote mode) or inline mode
|
|
366
|
+
if (this.singletonServices.schedulerService) {
|
|
367
|
+
// Remote mode - enqueue sleep worker with delay
|
|
368
|
+
await this.singletonServices.schedulerService.scheduleRPC(duration, this.getConfig().sleeperRPCName, {
|
|
369
|
+
runId,
|
|
370
|
+
stepId: stepState.stepId,
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
// Inline mode - use setTimeout with actual duration
|
|
375
|
+
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(duration)));
|
|
376
|
+
await this.setStepResult(stepState.stepId, null);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
// Pause workflow - sleep will callback when done
|
|
380
|
+
throw new WorkflowAsyncException(runId, stepName);
|
|
381
|
+
}
|
|
382
|
+
async cancelWorkflow(runId, reason) {
|
|
383
|
+
// Update workflow run status to cancelled
|
|
384
|
+
await this.updateRunStatus(runId, 'cancelled', undefined, {
|
|
385
|
+
message: reason || 'Workflow cancelled by user',
|
|
386
|
+
stack: '',
|
|
387
|
+
code: 'WORKFLOW_CANCELLED',
|
|
388
|
+
});
|
|
389
|
+
// Throw cancellation exception to stop workflow execution
|
|
390
|
+
throw new WorkflowCancelledException(runId, reason);
|
|
391
|
+
}
|
|
392
|
+
createWorkflowWire(workflowName, runId, rpcService) {
|
|
393
|
+
const workflowWire = {
|
|
394
|
+
workflowName,
|
|
395
|
+
runId,
|
|
396
|
+
getRun: async () => (await this.getRun(runId)),
|
|
397
|
+
// Implement workflow.do() - RPC form
|
|
398
|
+
do: async (stepName, rpcNameOrFn, dataOrOptions, options) => {
|
|
399
|
+
this.verifyStepName(stepName);
|
|
400
|
+
if (typeof rpcNameOrFn === 'string') {
|
|
401
|
+
return await this.rpcStep(runId, stepName, rpcNameOrFn, dataOrOptions, rpcService, options);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
return await this.inlineStep(runId, stepName, rpcNameOrFn, dataOrOptions);
|
|
405
|
+
}
|
|
406
|
+
},
|
|
407
|
+
// Implement workflow.sleep()
|
|
408
|
+
sleep: async (stepName, duration) => {
|
|
409
|
+
this.verifyStepName(stepName);
|
|
410
|
+
await this.sleepStep(runId, stepName, getDurationInMilliseconds(duration));
|
|
411
|
+
},
|
|
412
|
+
// Implement workflow.cancel()
|
|
413
|
+
cancel: async (reason) => {
|
|
414
|
+
await this.cancelWorkflow(runId, reason);
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
return workflowWire;
|
|
418
|
+
}
|
|
419
|
+
verifyStepName(stepName) {
|
|
420
|
+
if (typeof stepName !== 'string') {
|
|
421
|
+
throw new WorkflowStepNameNotString(stepName);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
getConfig() {
|
|
425
|
+
if (!this.singletonServices || !this.config) {
|
|
426
|
+
throw new WorkflowServiceNotInitialized();
|
|
427
|
+
}
|
|
428
|
+
return this.config;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PikkuError } from '../../errors/error-handler.js';
|
|
2
|
+
/**
|
|
3
|
+
* Exception thrown when workflow needs to pause for async step
|
|
4
|
+
*/
|
|
5
|
+
export declare class WorkflowAsyncException extends Error {
|
|
6
|
+
readonly runId: string;
|
|
7
|
+
readonly stepName: string;
|
|
8
|
+
constructor(runId: string, stepName: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Exception thrown when workflow is cancelled
|
|
12
|
+
*/
|
|
13
|
+
export declare class WorkflowCancelledException extends Error {
|
|
14
|
+
readonly runId: string;
|
|
15
|
+
readonly reason?: string | undefined;
|
|
16
|
+
constructor(runId: string, reason?: string | undefined);
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Error class for workflow not found
|
|
20
|
+
*/
|
|
21
|
+
export declare class WorkflowNotFoundError extends PikkuError {
|
|
22
|
+
constructor(name: string);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Error class for workflow not found
|
|
26
|
+
*/
|
|
27
|
+
export declare class WorkflowRunNotFound extends PikkuError {
|
|
28
|
+
constructor(runId: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Add a workflow to the system
|
|
32
|
+
* This is called by the generated workflow wirings
|
|
33
|
+
*/
|
|
34
|
+
export declare const addWorkflow: (workflowName: string, workflowFunc: any) => void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { pikkuState } from '../../pikku-state.js';
|
|
2
|
+
import { PikkuError } from '../../errors/error-handler.js';
|
|
3
|
+
import { addFunction } from '../../function/function-runner.js';
|
|
4
|
+
/**
|
|
5
|
+
* Exception thrown when workflow needs to pause for async step
|
|
6
|
+
*/
|
|
7
|
+
export class WorkflowAsyncException extends Error {
|
|
8
|
+
runId;
|
|
9
|
+
stepName;
|
|
10
|
+
constructor(runId, stepName) {
|
|
11
|
+
super(`Workflow paused at step: ${stepName}`);
|
|
12
|
+
this.runId = runId;
|
|
13
|
+
this.stepName = stepName;
|
|
14
|
+
this.name = 'WorkflowAsyncException';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Exception thrown when workflow is cancelled
|
|
19
|
+
*/
|
|
20
|
+
export class WorkflowCancelledException extends Error {
|
|
21
|
+
runId;
|
|
22
|
+
reason;
|
|
23
|
+
constructor(runId, reason) {
|
|
24
|
+
super(reason || 'Workflow cancelled');
|
|
25
|
+
this.runId = runId;
|
|
26
|
+
this.reason = reason;
|
|
27
|
+
this.name = 'WorkflowCancelledException';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Error class for workflow not found
|
|
32
|
+
*/
|
|
33
|
+
export class WorkflowNotFoundError extends PikkuError {
|
|
34
|
+
constructor(name) {
|
|
35
|
+
super(`Workflow not found: ${name}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Error class for workflow not found
|
|
40
|
+
*/
|
|
41
|
+
export class WorkflowRunNotFound extends PikkuError {
|
|
42
|
+
constructor(runId) {
|
|
43
|
+
super(`Workflow run not found: ${runId}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Add a workflow to the system
|
|
48
|
+
* This is called by the generated workflow wirings
|
|
49
|
+
*/
|
|
50
|
+
export const addWorkflow = (workflowName, workflowFunc) => {
|
|
51
|
+
// Get workflow metadata from inspector
|
|
52
|
+
const meta = pikkuState('workflows', 'meta');
|
|
53
|
+
const workflowMeta = meta[workflowName];
|
|
54
|
+
if (!workflowMeta) {
|
|
55
|
+
throw new Error(`Workflow metadata not found for '${workflowName}'. Make sure to run the CLI to generate metadata.`);
|
|
56
|
+
}
|
|
57
|
+
// Store workflow definition in state
|
|
58
|
+
const registrations = pikkuState('workflows', 'registrations');
|
|
59
|
+
registrations.set(workflowName, {
|
|
60
|
+
name: workflowName,
|
|
61
|
+
func: workflowFunc,
|
|
62
|
+
});
|
|
63
|
+
// Register the function with pikku
|
|
64
|
+
addFunction(workflowMeta.pikkuFuncName, workflowFunc);
|
|
65
|
+
};
|