@pikku/core 0.12.20 → 0.12.21
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 +11 -0
- package/dist/dev/hot-reload.js +20 -6
- package/dist/errors/error-handler.d.ts +1 -1
- package/dist/errors/error-handler.js +2 -2
- package/dist/function/functions.types.d.ts +3 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/middleware/index.d.ts +2 -2
- package/dist/middleware/index.js +2 -2
- package/dist/middleware/telemetry.d.ts +2 -2
- package/dist/middleware/telemetry.js +2 -2
- package/dist/middleware-runner.d.ts +15 -27
- package/dist/middleware-runner.js +25 -30
- package/dist/permissions.d.ts +15 -22
- package/dist/permissions.js +30 -25
- package/dist/pikku-request.js +1 -1
- package/dist/pikku-state.js +2 -3
- package/dist/services/ai-agent-runner-service.d.ts +1 -0
- package/dist/services/in-memory-queue-service.js +1 -1
- package/dist/services/in-memory-workflow-service.d.ts +15 -13
- package/dist/services/in-memory-workflow-service.js +31 -13
- package/dist/services/local-content.js +8 -1
- package/dist/services/user-session-service.d.ts +1 -1
- package/dist/testing/service-tests.js +24 -0
- package/dist/types/core.types.d.ts +4 -0
- package/dist/types/state.types.d.ts +2 -18
- package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
- package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
- package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
- package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
- package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
- package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
- package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
- package/dist/wirings/channel/channel-handler.js +1 -1
- package/dist/wirings/channel/channel-store.d.ts +13 -0
- package/dist/wirings/channel/channel.types.d.ts +3 -0
- package/dist/wirings/channel/local/local-channel-runner.js +23 -5
- package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
- package/dist/wirings/cli/cli-runner.js +24 -5
- package/dist/wirings/cli/command-parser.js +19 -4
- package/dist/wirings/http/http-runner.js +72 -36
- package/dist/wirings/http/http.types.d.ts +1 -0
- package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
- package/dist/wirings/http/web-request.js +32 -0
- package/dist/wirings/rpc/rpc-runner.js +13 -3
- package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
- package/dist/wirings/workflow/graph/graph-node.js +4 -0
- package/dist/wirings/workflow/graph/graph-runner.js +12 -10
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
- package/dist/wirings/workflow/index.d.ts +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
- package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
- package/dist/wirings/workflow/workflow.types.d.ts +34 -0
- package/package.json +1 -1
- package/run-tests.sh +4 -1
- package/src/dev/hot-reload.test.ts +62 -11
- package/src/dev/hot-reload.ts +28 -7
- package/src/errors/error-handler.ts +8 -2
- package/src/errors/error.test.ts +2 -0
- package/src/function/function-runner.test.ts +500 -10
- package/src/function/functions.types.ts +3 -2
- package/src/index.ts +12 -2
- package/src/middleware/index.ts +11 -2
- package/src/middleware/telemetry.ts +2 -2
- package/src/middleware-runner.test.ts +16 -16
- package/src/middleware-runner.ts +42 -30
- package/src/permissions.test.ts +27 -24
- package/src/permissions.ts +41 -25
- package/src/pikku-request.test.ts +35 -0
- package/src/pikku-request.ts +1 -1
- package/src/pikku-state.ts +2 -3
- package/src/run-tests-script.test.ts +18 -0
- package/src/services/ai-agent-runner-service.ts +1 -0
- package/src/services/in-memory-queue-service.ts +1 -1
- package/src/services/in-memory-session-store.ts +1 -2
- package/src/services/in-memory-workflow-service.test.ts +33 -11
- package/src/services/in-memory-workflow-service.ts +49 -13
- package/src/services/local-content.test.ts +54 -0
- package/src/services/local-content.ts +12 -1
- package/src/services/typed-credential-service.ts +3 -3
- package/src/services/typed-secret-service.ts +3 -3
- package/src/services/typed-variables-service.ts +3 -3
- package/src/services/user-session-service.ts +4 -4
- package/src/testing/service-tests.ts +30 -0
- package/src/types/core.types.ts +6 -2
- package/src/types/state.types.ts +2 -13
- package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
- package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
- package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
- package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
- package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
- package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
- package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
- package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
- package/src/wirings/channel/channel-handler.test.ts +272 -0
- package/src/wirings/channel/channel-handler.ts +1 -1
- package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
- package/src/wirings/channel/channel-store.ts +19 -0
- package/src/wirings/channel/channel.types.ts +4 -0
- package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
- package/src/wirings/channel/local/local-channel-runner.ts +41 -5
- package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
- package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
- package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
- package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
- package/src/wirings/cli/cli-runner.test.ts +255 -2
- package/src/wirings/cli/cli-runner.ts +31 -10
- package/src/wirings/cli/cli.types.ts +4 -8
- package/src/wirings/cli/command-parser.test.ts +83 -0
- package/src/wirings/cli/command-parser.ts +23 -4
- package/src/wirings/http/http-runner.test.ts +296 -1
- package/src/wirings/http/http-runner.ts +87 -57
- package/src/wirings/http/http.types.ts +11 -26
- package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
- package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
- package/src/wirings/http/web-request.test.ts +115 -0
- package/src/wirings/http/web-request.ts +43 -5
- package/src/wirings/mcp/mcp-runner.test.ts +367 -0
- package/src/wirings/queue/queue.types.ts +2 -5
- package/src/wirings/rpc/rpc-runner.test.ts +511 -0
- package/src/wirings/rpc/rpc-runner.ts +15 -3
- package/src/wirings/rpc/wire-addon.test.ts +57 -0
- package/src/wirings/workflow/graph/graph-node.ts +12 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
- package/src/wirings/workflow/graph/graph-runner.ts +28 -10
- package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
- package/src/wirings/workflow/index.ts +1 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
- package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
- package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
- package/src/wirings/workflow/workflow.types.ts +68 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -92,6 +92,17 @@ addError(WorkflowRunNotFoundError, {
|
|
|
92
92
|
status: 404,
|
|
93
93
|
message: 'Workflow run not found.',
|
|
94
94
|
});
|
|
95
|
+
export class WorkflowRunFailedError extends PikkuError {
|
|
96
|
+
payload;
|
|
97
|
+
constructor(message) {
|
|
98
|
+
super(`Workflow run failed: ${message ?? 'unknown'}`);
|
|
99
|
+
this.payload = { message };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
addError(WorkflowRunFailedError, {
|
|
103
|
+
status: 422,
|
|
104
|
+
message: 'Workflow run failed.',
|
|
105
|
+
});
|
|
95
106
|
export class WorkflowServiceNotInitialized extends Error {
|
|
96
107
|
}
|
|
97
108
|
export class WorkflowStepNameNotString extends Error {
|
|
@@ -115,7 +126,38 @@ export class PikkuWorkflowService {
|
|
|
115
126
|
get logger() {
|
|
116
127
|
return getSingletonServices()?.logger;
|
|
117
128
|
}
|
|
118
|
-
|
|
129
|
+
mirror;
|
|
130
|
+
constructor(options = {}) {
|
|
131
|
+
const wireQueues = options.wireQueues ?? true;
|
|
132
|
+
this.mirror = options.mirror;
|
|
133
|
+
if (wireQueues) {
|
|
134
|
+
this.wireQueueWorkers();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async safeMirror(fn) {
|
|
138
|
+
if (!this.mirror)
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
await fn();
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
try {
|
|
145
|
+
this.logger?.warn?.(`[pikku] WorkflowRunMirror write failed: ${err?.message ?? err}`);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// logger unavailable (e.g. singleton services not initialized) — swallow
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
rewireQueueWorkers() {
|
|
153
|
+
this.wireQueueWorkers();
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Wire the queue-based orchestrator/step/sleeper workers.
|
|
157
|
+
* Subclasses that orchestrate without queues (e.g. Durable Objects) should
|
|
158
|
+
* pass `wireQueues: false` to the base constructor and skip this entirely.
|
|
159
|
+
*/
|
|
160
|
+
wireQueueWorkers() {
|
|
119
161
|
const functions = pikkuState(null, 'function', 'functions');
|
|
120
162
|
const functionsMeta = pikkuState(null, 'function', 'meta');
|
|
121
163
|
// Minimal meta for internal workflow functions (satisfies FunctionMeta)
|
|
@@ -170,6 +212,9 @@ export class PikkuWorkflowService {
|
|
|
170
212
|
func: pikkuWorkflowSleeperFunc,
|
|
171
213
|
});
|
|
172
214
|
}
|
|
215
|
+
if (!functionsMeta.pikkuWorkflowSleeper) {
|
|
216
|
+
functionsMeta.pikkuWorkflowSleeper = mkMeta('pikkuWorkflowSleeper');
|
|
217
|
+
}
|
|
173
218
|
}
|
|
174
219
|
/**
|
|
175
220
|
* Check if a run is executing inline (without queues)
|
|
@@ -197,6 +242,11 @@ export class PikkuWorkflowService {
|
|
|
197
242
|
await this.upsertWorkflowVersion(name, meta.graphHash, meta, meta.source);
|
|
198
243
|
}
|
|
199
244
|
}
|
|
245
|
+
async createRun(workflowName, input, inline, graphHash, wire, options) {
|
|
246
|
+
const runId = await this.createRunImpl(workflowName, input, inline, graphHash, wire, options);
|
|
247
|
+
await this.safeMirror(() => this.mirror.createRun(runId, workflowName, input, inline, graphHash, wire, options));
|
|
248
|
+
return runId;
|
|
249
|
+
}
|
|
200
250
|
/**
|
|
201
251
|
* Get minimal workflow run status with step summaries.
|
|
202
252
|
* Used by the public API — the console addon provides the full verbose view.
|
|
@@ -240,6 +290,126 @@ export class PikkuWorkflowService {
|
|
|
240
290
|
: undefined,
|
|
241
291
|
};
|
|
242
292
|
}
|
|
293
|
+
/**
|
|
294
|
+
* Update workflow run status
|
|
295
|
+
* @param id - Run ID
|
|
296
|
+
* @param status - New status
|
|
297
|
+
*/
|
|
298
|
+
async updateRunStatus(id, status, output, error) {
|
|
299
|
+
await this.updateRunStatusImpl(id, status, output, error);
|
|
300
|
+
await this.safeMirror(() => this.mirror.updateRunStatus(id, status, output, error));
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Insert initial step state (called by orchestrator)
|
|
304
|
+
* Creates pending step in both workflow_step and workflow_step_history
|
|
305
|
+
* @param runId - Run ID
|
|
306
|
+
* @param stepName - Step cache key
|
|
307
|
+
* @param rpcName - RPC function name
|
|
308
|
+
* @param data - Step input data
|
|
309
|
+
* @param stepOptions - Step options (retries, retryDelay)
|
|
310
|
+
* @returns Step state with generated stepId
|
|
311
|
+
*/
|
|
312
|
+
async insertStepState(runId, stepName, rpcName, data, stepOptions) {
|
|
313
|
+
const step = await this.insertStepStateImpl(runId, stepName, rpcName, data, stepOptions);
|
|
314
|
+
await this.safeMirror(() => this.mirror.insertStepState(runId, { ...step, stepName, rpcName, data }));
|
|
315
|
+
return step;
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Mark step as running
|
|
319
|
+
* Updates both workflow_step and workflow_step_history
|
|
320
|
+
* @param stepId - Step ID
|
|
321
|
+
*/
|
|
322
|
+
async setStepRunning(stepId) {
|
|
323
|
+
await this.setStepRunningImpl(stepId);
|
|
324
|
+
await this.safeMirror(() => this.mirror.setStepRunning(stepId));
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Mark step as scheduled (queued for execution)
|
|
328
|
+
* Updates both workflow_step and workflow_step_history
|
|
329
|
+
* @param stepId - Step ID
|
|
330
|
+
*/
|
|
331
|
+
async setStepScheduled(stepId) {
|
|
332
|
+
await this.setStepScheduledImpl(stepId);
|
|
333
|
+
await this.safeMirror(() => this.mirror.setStepScheduled(stepId));
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Store step result and mark as succeeded
|
|
337
|
+
* Updates both workflow_step and workflow_step_history
|
|
338
|
+
* @param stepId - Step ID
|
|
339
|
+
* @param result - Step result
|
|
340
|
+
*/
|
|
341
|
+
async setStepResult(stepId, result) {
|
|
342
|
+
await this.setStepResultImpl(stepId, result);
|
|
343
|
+
await this.safeMirror(() => this.mirror.setStepResult(stepId, result));
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Set the child workflow run ID on a step
|
|
347
|
+
* @param stepId - Step ID
|
|
348
|
+
* @param childRunId - Child workflow run ID
|
|
349
|
+
*/
|
|
350
|
+
async setStepChildRunId(stepId, childRunId) {
|
|
351
|
+
await this.setStepChildRunIdImpl(stepId, childRunId);
|
|
352
|
+
await this.safeMirror(() => this.mirror.setStepChildRunId(stepId, childRunId));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Store step error and mark as failed
|
|
356
|
+
* Updates both workflow_step and workflow_step_history
|
|
357
|
+
* @param stepId - Step ID
|
|
358
|
+
* @param error - Error object
|
|
359
|
+
*/
|
|
360
|
+
async setStepError(stepId, error) {
|
|
361
|
+
await this.setStepErrorImpl(stepId, error);
|
|
362
|
+
const serialized = {
|
|
363
|
+
message: error.message,
|
|
364
|
+
stack: error.stack,
|
|
365
|
+
code: error.code,
|
|
366
|
+
};
|
|
367
|
+
await this.safeMirror(() => this.mirror.setStepError(stepId, serialized));
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Create a new retry attempt for a failed step
|
|
371
|
+
* Inserts new pending step in both workflow_step and workflow_step_history
|
|
372
|
+
* Resets status to 'pending' with new stepId
|
|
373
|
+
* Copies metadata (rpcName, data, retries, retryDelay) from failed attempt
|
|
374
|
+
* @param failedStepId - Failed step ID to copy from
|
|
375
|
+
* @returns New step state for the retry attempt
|
|
376
|
+
*/
|
|
377
|
+
async createRetryAttempt(failedStepId, status) {
|
|
378
|
+
const newStep = await this.createRetryAttemptImpl(failedStepId, status);
|
|
379
|
+
const stepName = newStep.stepName ?? '';
|
|
380
|
+
await this.safeMirror(() => this.mirror.createRetryAttempt(failedStepId, {
|
|
381
|
+
...newStep,
|
|
382
|
+
stepName,
|
|
383
|
+
}));
|
|
384
|
+
return newStep;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Set the branch key for a graph node step
|
|
388
|
+
* @param stepId - Step ID
|
|
389
|
+
* @param branchKey - Branch key selected by graph.branch()
|
|
390
|
+
*/
|
|
391
|
+
async setBranchTaken(stepId, branchKey) {
|
|
392
|
+
await this.setBranchTakenImpl(stepId, branchKey);
|
|
393
|
+
await this.safeMirror(() => this.mirror.setBranchTaken(stepId, branchKey));
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Update a state variable in the workflow run's state
|
|
397
|
+
* @param runId - Run ID
|
|
398
|
+
* @param name - Variable name
|
|
399
|
+
* @param value - Value to store
|
|
400
|
+
*/
|
|
401
|
+
async updateRunState(runId, name, value) {
|
|
402
|
+
await this.updateRunStateImpl(runId, name, value);
|
|
403
|
+
await this.safeMirror(() => this.mirror.updateRunState(runId, name, value));
|
|
404
|
+
}
|
|
405
|
+
async upsertWorkflowVersion(name, graphHash, graph, source, status) {
|
|
406
|
+
await this.upsertWorkflowVersionImpl(name, graphHash, graph, source, status);
|
|
407
|
+
await this.safeMirror(() => this.mirror.upsertWorkflowVersion(name, graphHash, graph, source, status));
|
|
408
|
+
}
|
|
409
|
+
async updateWorkflowVersionStatus(name, graphHash, status) {
|
|
410
|
+
await this.updateWorkflowVersionStatusImpl(name, graphHash, status);
|
|
411
|
+
await this.safeMirror(() => this.mirror.updateWorkflowVersionStatus(name, graphHash, status));
|
|
412
|
+
}
|
|
243
413
|
// ============================================================================
|
|
244
414
|
// Workflow Lifecycle Methods
|
|
245
415
|
// ============================================================================
|
|
@@ -257,14 +427,25 @@ export class PikkuWorkflowService {
|
|
|
257
427
|
runId,
|
|
258
428
|
});
|
|
259
429
|
}
|
|
260
|
-
async queueStepWorker(runId, stepName, rpcName, data) {
|
|
430
|
+
async queueStepWorker(runId, stepName, rpcName, data, stepOptions) {
|
|
261
431
|
const queueService = this.verifyQueueService();
|
|
432
|
+
const retries = stepOptions?.retries ?? 0;
|
|
433
|
+
const retryDelay = stepOptions?.retryDelay;
|
|
262
434
|
await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({
|
|
263
435
|
runId,
|
|
264
436
|
stepName,
|
|
265
437
|
rpcName,
|
|
266
438
|
data,
|
|
267
|
-
}))
|
|
439
|
+
})), retries > 0 || retryDelay
|
|
440
|
+
? {
|
|
441
|
+
attempts: retries + 1,
|
|
442
|
+
backoff: typeof retryDelay === 'number'
|
|
443
|
+
? { type: 'fixed', delay: retryDelay }
|
|
444
|
+
: retryDelay === 'exponential'
|
|
445
|
+
? 'exponential'
|
|
446
|
+
: undefined,
|
|
447
|
+
}
|
|
448
|
+
: undefined);
|
|
268
449
|
}
|
|
269
450
|
/**
|
|
270
451
|
* Execute a workflow sleep step completion
|
|
@@ -288,6 +469,58 @@ export class PikkuWorkflowService {
|
|
|
288
469
|
}
|
|
289
470
|
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined);
|
|
290
471
|
}
|
|
472
|
+
/**
|
|
473
|
+
* Dispatch a workflow step to be executed asynchronously.
|
|
474
|
+
*
|
|
475
|
+
* Default implementation enqueues a step worker job via the queue service.
|
|
476
|
+
* Subclasses with non-queue transports (e.g. Durable Objects) override this
|
|
477
|
+
* to dispatch via their own mechanism (RPC to a step worker, etc.).
|
|
478
|
+
*
|
|
479
|
+
* On return, the workflow is paused via `WorkflowAsyncException` thrown by
|
|
480
|
+
* the caller; the step transport is responsible for calling back into the
|
|
481
|
+
* orchestrator (via `resumeWorkflow` or equivalent) when the step completes.
|
|
482
|
+
*
|
|
483
|
+
* @returns true if dispatch was async (caller should pause), false to fall
|
|
484
|
+
* through to the inline execution path.
|
|
485
|
+
*/
|
|
486
|
+
async dispatchStep(runId, stepName, rpcName, data, stepOptions) {
|
|
487
|
+
if (this.isInline(runId) || !getSingletonServices()?.queueService) {
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
const retries = stepOptions?.retries ?? 0;
|
|
491
|
+
const retryDelay = stepOptions?.retryDelay;
|
|
492
|
+
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({
|
|
493
|
+
runId,
|
|
494
|
+
stepName,
|
|
495
|
+
rpcName,
|
|
496
|
+
data,
|
|
497
|
+
})), {
|
|
498
|
+
attempts: retries + 1,
|
|
499
|
+
backoff: typeof retryDelay === 'number'
|
|
500
|
+
? { type: 'fixed', delay: retryDelay }
|
|
501
|
+
: retryDelay === 'exponential'
|
|
502
|
+
? 'exponential'
|
|
503
|
+
: undefined,
|
|
504
|
+
});
|
|
505
|
+
return true;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Schedule a workflow sleep wakeup at the given duration.
|
|
509
|
+
*
|
|
510
|
+
* Default implementation uses the scheduler service to enqueue a delayed
|
|
511
|
+
* sleeper RPC. Subclasses with native timer primitives (e.g. Durable Object
|
|
512
|
+
* alarms) override this to schedule directly without going through queues.
|
|
513
|
+
*
|
|
514
|
+
* @returns true if the wakeup was scheduled remotely (caller should pause),
|
|
515
|
+
* false to fall through to inline `setTimeout` behavior.
|
|
516
|
+
*/
|
|
517
|
+
async scheduleSleep(runId, stepId, duration) {
|
|
518
|
+
if (this.isInline(runId) || !getSingletonServices()?.schedulerService) {
|
|
519
|
+
return false;
|
|
520
|
+
}
|
|
521
|
+
await getSingletonServices().schedulerService.scheduleRPC(duration, this.getConfig().sleeperRPCName, { runId, stepId });
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
291
524
|
/**
|
|
292
525
|
* Start a new workflow run
|
|
293
526
|
* Automatically detects workflow type (DSL or graph) from meta and executes accordingly
|
|
@@ -341,7 +574,13 @@ export class PikkuWorkflowService {
|
|
|
341
574
|
if (error.name !== 'WorkflowAsyncException' &&
|
|
342
575
|
error.name !== 'WorkflowCancelledException' &&
|
|
343
576
|
error.name !== 'WorkflowSuspendedException') {
|
|
577
|
+
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
578
|
+
name: error.name,
|
|
579
|
+
message: error.message,
|
|
580
|
+
stack: error.stack,
|
|
581
|
+
});
|
|
344
582
|
getSingletonServices().logger.error(`Workflow ${name} (run ${runId}) failed:`, error);
|
|
583
|
+
throw error;
|
|
345
584
|
}
|
|
346
585
|
}
|
|
347
586
|
finally {
|
|
@@ -363,7 +602,7 @@ export class PikkuWorkflowService {
|
|
|
363
602
|
}
|
|
364
603
|
if (WORKFLOW_END_STATES.has(run.status)) {
|
|
365
604
|
if (run.status === 'failed') {
|
|
366
|
-
throw new
|
|
605
|
+
throw new WorkflowRunFailedError(run.error?.message);
|
|
367
606
|
}
|
|
368
607
|
if (run.status === 'cancelled') {
|
|
369
608
|
throw new Error('Workflow was cancelled');
|
|
@@ -677,31 +916,14 @@ export class PikkuWorkflowService {
|
|
|
677
916
|
}
|
|
678
917
|
// Step is pending - schedule it
|
|
679
918
|
await this.setStepScheduled(stepState.stepId);
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
const retryDelay = stepOptions?.retryDelay;
|
|
685
|
-
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({
|
|
686
|
-
runId,
|
|
687
|
-
stepName,
|
|
688
|
-
rpcName,
|
|
689
|
-
data,
|
|
690
|
-
})), {
|
|
691
|
-
// attempts includes initial attempt, retries doesn't
|
|
692
|
-
attempts: retries + 1,
|
|
693
|
-
// Map retry delay to backoff
|
|
694
|
-
backoff: typeof retryDelay === 'number'
|
|
695
|
-
? { type: 'fixed', delay: retryDelay }
|
|
696
|
-
: retryDelay === 'exponential'
|
|
697
|
-
? 'exponential'
|
|
698
|
-
: undefined,
|
|
699
|
-
});
|
|
700
|
-
// Pause workflow - step will callback when done
|
|
919
|
+
// Hand off to subclass-overridable transport. Default behavior enqueues
|
|
920
|
+
// via the queue service; DO-style subclasses RPC to a step worker.
|
|
921
|
+
const dispatched = await this.dispatchStep(runId, stepName, rpcName, data, stepOptions);
|
|
922
|
+
if (dispatched) {
|
|
701
923
|
throw new WorkflowAsyncException(runId, stepName);
|
|
702
924
|
}
|
|
703
|
-
|
|
704
|
-
// Inline
|
|
925
|
+
{
|
|
926
|
+
// Inline (no transport available) - execute locally with retry loop
|
|
705
927
|
const retries = stepOptions?.retries ?? 0;
|
|
706
928
|
const retryDelay = stepOptions?.retryDelay;
|
|
707
929
|
let currentStepState = stepState;
|
|
@@ -873,22 +1095,16 @@ export class PikkuWorkflowService {
|
|
|
873
1095
|
}
|
|
874
1096
|
// Step is pending - schedule it
|
|
875
1097
|
await this.setStepScheduled(stepState.stepId);
|
|
876
|
-
//
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
stepId: stepState.stepId,
|
|
882
|
-
});
|
|
883
|
-
// Pause workflow - sleep will callback when done
|
|
1098
|
+
// Hand off to subclass-overridable transport. Default behavior schedules
|
|
1099
|
+
// a delayed sleeper RPC via the scheduler service; DO-style subclasses
|
|
1100
|
+
// override to use native timer primitives (e.g. setAlarm).
|
|
1101
|
+
const scheduled = await this.scheduleSleep(runId, stepState.stepId, duration);
|
|
1102
|
+
if (scheduled) {
|
|
884
1103
|
throw new WorkflowAsyncException(runId, stepName);
|
|
885
1104
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
await this.setStepResult(stepState.stepId, null);
|
|
890
|
-
return;
|
|
891
|
-
}
|
|
1105
|
+
// Inline mode - use setTimeout with actual duration
|
|
1106
|
+
await new Promise((resolve) => setTimeout(resolve, getDurationInMilliseconds(duration)));
|
|
1107
|
+
await this.setStepResult(stepState.stepId, null);
|
|
892
1108
|
}
|
|
893
1109
|
getSuspendStepName(reason) {
|
|
894
1110
|
if (!reason) {
|
|
@@ -975,12 +1191,17 @@ export class PikkuWorkflowService {
|
|
|
975
1191
|
* Get the orchestrator queue name for a specific workflow.
|
|
976
1192
|
* Checks queue meta for a per-workflow queue first (e.g. wf-orchestrator-{name}),
|
|
977
1193
|
* falls back to the shared orchestrator queue.
|
|
1194
|
+
*
|
|
1195
|
+
* Reads from `queue.meta` (always populated globally) rather than
|
|
1196
|
+
* `queue.registrations` (only populated for queues this unit consumes).
|
|
1197
|
+
* In a per-unit deploy the orchestrator unit doesn't consume per-step
|
|
1198
|
+
* queues — but it produces to them — so registrations would miss them.
|
|
978
1199
|
*/
|
|
979
1200
|
getOrchestratorQueueName(workflowName) {
|
|
980
1201
|
if (workflowName) {
|
|
981
1202
|
const perWorkflow = `wf-orchestrator-${toKebab(workflowName)}`;
|
|
982
|
-
const
|
|
983
|
-
if (
|
|
1203
|
+
const meta = pikkuState(null, 'queue', 'meta');
|
|
1204
|
+
if (meta[perWorkflow]) {
|
|
984
1205
|
return perWorkflow;
|
|
985
1206
|
}
|
|
986
1207
|
}
|
|
@@ -989,8 +1210,8 @@ export class PikkuWorkflowService {
|
|
|
989
1210
|
getStepWorkerQueueName(rpcName) {
|
|
990
1211
|
if (rpcName) {
|
|
991
1212
|
const perStep = `wf-step-${toKebab(rpcName)}`;
|
|
992
|
-
const
|
|
993
|
-
if (
|
|
1213
|
+
const meta = pikkuState(null, 'queue', 'meta');
|
|
1214
|
+
if (meta[perStep]) {
|
|
994
1215
|
return perStep;
|
|
995
1216
|
}
|
|
996
1217
|
}
|
|
@@ -143,6 +143,40 @@ export interface WorkflowRunService {
|
|
|
143
143
|
}>>;
|
|
144
144
|
deleteRun(id: string): Promise<boolean>;
|
|
145
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Write-only companion to `WorkflowRunService`. An executor (a
|
|
148
|
+
* `PikkuWorkflowService` subclass) can be given a mirror; every write
|
|
149
|
+
* the executor makes to its own canonical store is then forwarded here,
|
|
150
|
+
* so an external read store (e.g. a DB queried by the console UI) stays
|
|
151
|
+
* in sync with DO/Redis/etc-driven runs.
|
|
152
|
+
*
|
|
153
|
+
* Mirror failures are logged but never fail the workflow — the mirror is
|
|
154
|
+
* an index, not the source of truth.
|
|
155
|
+
*/
|
|
156
|
+
export interface WorkflowRunMirror {
|
|
157
|
+
createRun(runId: string, workflowName: string, input: any, inline: boolean, graphHash: string, wire: WorkflowRunWire, options?: {
|
|
158
|
+
deterministic?: boolean;
|
|
159
|
+
plannedSteps?: WorkflowPlannedStep[];
|
|
160
|
+
}): Promise<void>;
|
|
161
|
+
updateRunStatus(id: string, status: WorkflowStatus, output?: any, error?: SerializedError): Promise<void>;
|
|
162
|
+
insertStepState(runId: string, step: StepState & {
|
|
163
|
+
stepName: string;
|
|
164
|
+
rpcName: string | null;
|
|
165
|
+
data: any;
|
|
166
|
+
}): Promise<void>;
|
|
167
|
+
setStepRunning(stepId: string): Promise<void>;
|
|
168
|
+
setStepScheduled(stepId: string): Promise<void>;
|
|
169
|
+
setStepResult(stepId: string, result: any): Promise<void>;
|
|
170
|
+
setStepChildRunId(stepId: string, childRunId: string): Promise<void>;
|
|
171
|
+
setStepError(stepId: string, error: SerializedError): Promise<void>;
|
|
172
|
+
createRetryAttempt(failedStepId: string, newStep: StepState & {
|
|
173
|
+
stepName: string;
|
|
174
|
+
}): Promise<void>;
|
|
175
|
+
setBranchTaken(stepId: string, branchKey: string): Promise<void>;
|
|
176
|
+
updateRunState(runId: string, name: string, value: unknown): Promise<void>;
|
|
177
|
+
upsertWorkflowVersion(name: string, graphHash: string, graph: any, source: string, status?: WorkflowVersionStatus): Promise<void>;
|
|
178
|
+
updateWorkflowVersionStatus(name: string, graphHash: string, status: WorkflowVersionStatus): Promise<void>;
|
|
179
|
+
}
|
|
146
180
|
/**
|
|
147
181
|
* Core workflow definition
|
|
148
182
|
*/
|
package/package.json
CHANGED
package/run-tests.sh
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
|
|
3
|
+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
4
|
+
cd "$script_dir"
|
|
5
|
+
|
|
3
6
|
# Enable nullglob to handle cases where no files match the pattern
|
|
4
7
|
shopt -s nullglob
|
|
5
8
|
|
|
@@ -34,7 +37,7 @@ files=($(find src -type f -name "*.test.ts"))
|
|
|
34
37
|
# Check if any files matched the pattern
|
|
35
38
|
if [ ${#files[@]} -eq 0 ]; then
|
|
36
39
|
echo "No test files found matching pattern: $pattern"
|
|
37
|
-
if [ "${STRICT_TEST_DISCOVERY}" = "1" ]
|
|
40
|
+
if [ "${STRICT_TEST_DISCOVERY}" = "1" ]; then
|
|
38
41
|
exit 1
|
|
39
42
|
fi
|
|
40
43
|
exit 0
|
|
@@ -1,5 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
test,
|
|
4
|
+
beforeEach,
|
|
5
|
+
afterEach,
|
|
6
|
+
type TestContext,
|
|
7
|
+
} from 'node:test'
|
|
2
8
|
import assert from 'node:assert/strict'
|
|
9
|
+
import { watch } from 'node:fs'
|
|
3
10
|
import { mkdtemp, writeFile, rm, mkdir } from 'node:fs/promises'
|
|
4
11
|
import { join } from 'node:path'
|
|
5
12
|
import { tmpdir } from 'node:os'
|
|
@@ -21,6 +28,32 @@ import {
|
|
|
21
28
|
|
|
22
29
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
23
30
|
|
|
31
|
+
const ensureRecursiveWatchAvailable = async (
|
|
32
|
+
t: TestContext,
|
|
33
|
+
dir: string
|
|
34
|
+
): Promise<boolean> => {
|
|
35
|
+
if (process.platform === 'darwin') {
|
|
36
|
+
t.skip('recursive fs.watch is unreliable on darwin')
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const watcher = watch(dir, { recursive: true }, () => {})
|
|
42
|
+
watcher.close()
|
|
43
|
+
return true
|
|
44
|
+
} catch (error: any) {
|
|
45
|
+
if (
|
|
46
|
+
error?.code === 'EMFILE' ||
|
|
47
|
+
error?.code === 'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM' ||
|
|
48
|
+
error?.code === 'ERR_INVALID_ARG_VALUE'
|
|
49
|
+
) {
|
|
50
|
+
t.skip(`recursive fs.watch unavailable: ${error.code}`)
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
24
57
|
const createMockLogger = () => {
|
|
25
58
|
const logs: Array<{ level: string; message: string }> = []
|
|
26
59
|
return {
|
|
@@ -47,7 +80,7 @@ const writeFunctionModule = async (
|
|
|
47
80
|
await writeFile(join(dir, filename), `// ts trigger ${Date.now()}`)
|
|
48
81
|
}
|
|
49
82
|
|
|
50
|
-
describe('pikkuDevReloader', () => {
|
|
83
|
+
describe('pikkuDevReloader', { concurrency: false }, () => {
|
|
51
84
|
let tmpDir: string
|
|
52
85
|
let reloader: { close: () => void } | undefined
|
|
53
86
|
let mockLogger: ReturnType<typeof createMockLogger>
|
|
@@ -76,7 +109,9 @@ describe('pikkuDevReloader', () => {
|
|
|
76
109
|
await rm(tmpDir, { recursive: true, force: true })
|
|
77
110
|
})
|
|
78
111
|
|
|
79
|
-
test('should hot-reload a function and pick up new return value', async () => {
|
|
112
|
+
test('should hot-reload a function and pick up new return value', async (t) => {
|
|
113
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
114
|
+
|
|
80
115
|
addFunction('myFunc', {
|
|
81
116
|
func: async () => ({ version: 1 }),
|
|
82
117
|
})
|
|
@@ -112,7 +147,9 @@ describe('pikkuDevReloader', () => {
|
|
|
112
147
|
assert.ok(reloadLog, 'Should log hot-reload message')
|
|
113
148
|
})
|
|
114
149
|
|
|
115
|
-
test('should not replace a function that is not registered', async () => {
|
|
150
|
+
test('should not replace a function that is not registered', async (t) => {
|
|
151
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
152
|
+
|
|
116
153
|
addFunction('registeredFunc', {
|
|
117
154
|
func: async () => ({ name: 'registered' }),
|
|
118
155
|
})
|
|
@@ -135,7 +172,9 @@ describe('pikkuDevReloader', () => {
|
|
|
135
172
|
)
|
|
136
173
|
})
|
|
137
174
|
|
|
138
|
-
test('should keep old code when JS import fails', async () => {
|
|
175
|
+
test('should keep old code when JS import fails', async (t) => {
|
|
176
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
177
|
+
|
|
139
178
|
addFunction('badFunc', {
|
|
140
179
|
func: async () => ({ working: true }),
|
|
141
180
|
})
|
|
@@ -166,7 +205,9 @@ describe('pikkuDevReloader', () => {
|
|
|
166
205
|
})
|
|
167
206
|
})
|
|
168
207
|
|
|
169
|
-
test('should ignore non-ts files, test files, and gen files', async () => {
|
|
208
|
+
test('should ignore non-ts files, test files, and gen files', async (t) => {
|
|
209
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
210
|
+
|
|
170
211
|
addFunction('someFunc', {
|
|
171
212
|
func: async () => ({ original: true }),
|
|
172
213
|
})
|
|
@@ -190,7 +231,9 @@ describe('pikkuDevReloader', () => {
|
|
|
190
231
|
assert.equal(reloadLogs.length, 0)
|
|
191
232
|
})
|
|
192
233
|
|
|
193
|
-
test('should hot-reload function used via HTTP wire', async () => {
|
|
234
|
+
test('should hot-reload function used via HTTP wire', async (t) => {
|
|
235
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
236
|
+
|
|
194
237
|
const sessionMiddleware = async (_services: any, wire: any, next: any) => {
|
|
195
238
|
wire.setSession?.({ userId: 'test' } as any)
|
|
196
239
|
await next()
|
|
@@ -251,7 +294,9 @@ describe('pikkuDevReloader', () => {
|
|
|
251
294
|
})
|
|
252
295
|
})
|
|
253
296
|
|
|
254
|
-
test('should hot-reload function used via scheduler wire', async () => {
|
|
297
|
+
test('should hot-reload function used via scheduler wire', async (t) => {
|
|
298
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
299
|
+
|
|
255
300
|
const taskResult = { ref: 'initial' }
|
|
256
301
|
|
|
257
302
|
pikkuState(null, 'scheduler', 'meta')['hotTask'] = {
|
|
@@ -371,7 +416,9 @@ describe('pikkuDevReloader', () => {
|
|
|
371
416
|
assert.deepEqual(resultV2, { result: 'v2' })
|
|
372
417
|
})
|
|
373
418
|
|
|
374
|
-
test('should debounce rapid file changes', async () => {
|
|
419
|
+
test('should debounce rapid file changes', async (t) => {
|
|
420
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
421
|
+
|
|
375
422
|
addFunction('debounceFunc', {
|
|
376
423
|
func: async () => ({ count: 0 }),
|
|
377
424
|
})
|
|
@@ -396,7 +443,9 @@ describe('pikkuDevReloader', () => {
|
|
|
396
443
|
assert.equal(result.count, 5)
|
|
397
444
|
})
|
|
398
445
|
|
|
399
|
-
test('should watch subdirectories', async () => {
|
|
446
|
+
test('should watch subdirectories', async (t) => {
|
|
447
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
448
|
+
|
|
400
449
|
const subDir = join(tmpDir, 'functions')
|
|
401
450
|
await mkdir(subDir)
|
|
402
451
|
|
|
@@ -426,7 +475,9 @@ describe('pikkuDevReloader', () => {
|
|
|
426
475
|
})
|
|
427
476
|
})
|
|
428
477
|
|
|
429
|
-
test('should properly clean up on close', async () => {
|
|
478
|
+
test('should properly clean up on close', async (t) => {
|
|
479
|
+
if (!(await ensureRecursiveWatchAvailable(t, tmpDir))) return
|
|
480
|
+
|
|
430
481
|
addFunction('cleanupFunc', {
|
|
431
482
|
func: async () => ({ v: 1 }),
|
|
432
483
|
})
|