@pikku/core 0.12.67 → 0.12.70
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 +397 -0
- package/README.md +34 -2
- package/dist/function/functions.types.d.ts +27 -0
- package/dist/index.d.ts +1 -1
- package/dist/internal.d.ts +1 -1
- package/dist/internal.js +1 -1
- package/dist/pikku-state.js +1 -0
- package/dist/services/http-scenario-actors.d.ts +12 -4
- package/dist/services/http-scenario-actors.js +47 -45
- package/dist/services/in-memory-workflow-service.d.ts +2 -2
- package/dist/services/in-memory-workflow-service.js +2 -2
- package/dist/services/index.d.ts +2 -1
- package/dist/services/index.js +1 -0
- package/dist/services/meta-service.d.ts +5 -1
- package/dist/services/meta-service.js +44 -18
- package/dist/services/scenario-actors-service.d.ts +108 -2
- package/dist/services/scenario-actors-service.js +40 -1
- package/dist/types/core.types.d.ts +21 -3
- package/dist/types/state.types.d.ts +3 -1
- package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
- package/dist/wirings/actor-flow/index.d.ts +1 -1
- package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
- package/dist/wirings/actor-flow/run-conversation.js +27 -27
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +18 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
- package/dist/wirings/cli/command-parser.js +11 -1
- package/dist/wirings/queue/index.d.ts +1 -1
- package/dist/wirings/queue/queue.types.d.ts +30 -0
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
- package/dist/wirings/workflow/feature.d.ts +28 -0
- package/dist/wirings/workflow/feature.js +57 -0
- package/dist/wirings/workflow/index.d.ts +13 -2
- package/dist/wirings/workflow/index.js +15 -0
- package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
- package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +118 -12
- package/dist/wirings/workflow/pikku-workflow-service.js +166 -153
- package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
- package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
- package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
- package/dist/wirings/workflow/scenario-poll.js +25 -0
- package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
- package/dist/wirings/workflow/scenario-prose.js +45 -0
- package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
- package/dist/wirings/workflow/scenario-step-guards.js +29 -0
- package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
- package/dist/wirings/workflow/scenario-step.types.js +1 -0
- package/dist/wirings/workflow/workflow.types.d.ts +119 -2
- package/package.json +3 -1
- package/src/function/functions.types.ts +32 -0
- package/src/index.ts +1 -0
- package/src/internal.ts +5 -1
- package/src/pikku-state.ts +1 -0
- package/src/services/http-scenario-actors.test.ts +85 -1
- package/src/services/http-scenario-actors.ts +65 -51
- package/src/services/in-memory-workflow-service.test.ts +50 -1
- package/src/services/in-memory-workflow-service.ts +3 -2
- package/src/services/index.ts +5 -0
- package/src/services/meta-service.test.ts +79 -0
- package/src/services/meta-service.ts +61 -26
- package/src/services/scenario-actors-service.ts +157 -2
- package/src/types/core.types.ts +27 -2
- package/src/types/state.types.ts +3 -0
- package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
- package/src/wirings/actor-flow/index.ts +1 -1
- package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
- package/src/wirings/actor-flow/run-conversation.ts +36 -41
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +29 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
- package/src/wirings/cli/command-parser.test.ts +60 -0
- package/src/wirings/cli/command-parser.ts +12 -1
- package/src/wirings/queue/index.ts +2 -0
- package/src/wirings/queue/queue.types.ts +32 -0
- package/src/wirings/rpc/rpc-runner.test.ts +28 -5
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
- package/src/wirings/workflow/feature.test.ts +131 -0
- package/src/wirings/workflow/feature.ts +78 -0
- package/src/wirings/workflow/index.ts +74 -0
- package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +126 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +306 -228
- package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
- package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
- package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
- package/src/wirings/workflow/scenario-poll.test.ts +66 -0
- package/src/wirings/workflow/scenario-poll.ts +36 -0
- package/src/wirings/workflow/scenario-prose.test.ts +152 -0
- package/src/wirings/workflow/scenario-prose.ts +79 -0
- package/src/wirings/workflow/scenario-service.test.ts +155 -0
- package/src/wirings/workflow/scenario-step-guards.ts +43 -0
- package/src/wirings/workflow/scenario-step.test.ts +441 -8
- package/src/wirings/workflow/scenario-step.types.ts +157 -0
- package/src/wirings/workflow/workflow.types.ts +137 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,7 +3,6 @@ import { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSl
|
|
|
3
3
|
import { wireQueueWorker } from '../queue/queue-runner.js';
|
|
4
4
|
import { getSingletonServices, getCreateWireServices, pikkuState, } from '../../pikku-state.js';
|
|
5
5
|
import { getDurationInMilliseconds } from '../../time-utils.js';
|
|
6
|
-
import { createHttpScenarioActors } from '../../services/http-scenario-actors.js';
|
|
7
6
|
const resolveWorkflowMeta = (name) => {
|
|
8
7
|
const rootMeta = pikkuState(null, 'workflows', 'meta');
|
|
9
8
|
if (rootMeta[name]) {
|
|
@@ -29,7 +28,6 @@ const resolveWorkflowMeta = (name) => {
|
|
|
29
28
|
return null;
|
|
30
29
|
};
|
|
31
30
|
const toKebab = (s) => s.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
32
|
-
import { runScheduledTask } from '../scheduler/scheduler-runner.js';
|
|
33
31
|
import { continueGraph, executeGraphStep, runWorkflowGraph, runFromMeta, } from './graph/graph-runner.js';
|
|
34
32
|
import { PikkuError, addError, isExpectedError, } from '../../errors/error-handler.js';
|
|
35
33
|
import { RPCNotFoundError } from '../rpc/rpc-runner.js';
|
|
@@ -179,16 +177,20 @@ const WORKFLOW_END_STATES = new Set([
|
|
|
179
177
|
*/
|
|
180
178
|
export class PikkuWorkflowService {
|
|
181
179
|
inlineRuns = new Set();
|
|
182
|
-
|
|
183
|
-
// process-local by nature, so they ride this map, never the persisted wire.
|
|
184
|
-
runActors = new Map();
|
|
180
|
+
runExtension;
|
|
185
181
|
get logger() {
|
|
186
182
|
return getSingletonServices()?.logger;
|
|
187
183
|
}
|
|
188
184
|
mirror;
|
|
185
|
+
queueStrategy;
|
|
186
|
+
queueConcurrency;
|
|
187
|
+
queueGroupConcurrency;
|
|
189
188
|
constructor(options = {}) {
|
|
190
189
|
const wireQueues = options.wireQueues ?? true;
|
|
191
190
|
this.mirror = options.mirror;
|
|
191
|
+
this.queueStrategy = options.queueStrategy ?? 'per-workflow';
|
|
192
|
+
this.queueConcurrency = options.queueConcurrency ?? 20;
|
|
193
|
+
this.queueGroupConcurrency = options.queueGroupConcurrency ?? 2;
|
|
192
194
|
if (wireQueues) {
|
|
193
195
|
this.wireQueueWorkers();
|
|
194
196
|
}
|
|
@@ -226,21 +228,32 @@ export class PikkuWorkflowService {
|
|
|
226
228
|
outputSchemaName: null,
|
|
227
229
|
});
|
|
228
230
|
const queueMeta = pikkuState(null, 'queue', 'meta');
|
|
229
|
-
const registerWorkflowFunc = (funcId, func, queueName) => {
|
|
231
|
+
const registerWorkflowFunc = (funcId, func, queueName, config) => {
|
|
230
232
|
if (functions.has(funcId))
|
|
231
233
|
return;
|
|
232
234
|
addFunction(funcId, func);
|
|
233
235
|
if (!queueMeta[queueName]) {
|
|
234
236
|
queueMeta[queueName] = { pikkuFuncId: funcId, name: queueName };
|
|
235
237
|
}
|
|
236
|
-
wireQueueWorker({ name: queueName, func });
|
|
238
|
+
wireQueueWorker({ name: queueName, func, config });
|
|
237
239
|
if (!functionsMeta[funcId]) {
|
|
238
240
|
functionsMeta[funcId] = mkMeta(funcId);
|
|
239
241
|
}
|
|
240
242
|
};
|
|
243
|
+
// Under 'shared-groups' every workflow runs through these two queues and is
|
|
244
|
+
// kept from hogging them by the per-group cap, so the per-workflow queues
|
|
245
|
+
// below are left unconsumed — one set of pollers for the whole system
|
|
246
|
+
// instead of one per workflow.
|
|
247
|
+
const sharedGroups = this.queueStrategy === 'shared-groups';
|
|
248
|
+
const sharedQueueConfig = sharedGroups
|
|
249
|
+
? {
|
|
250
|
+
batchSize: this.queueConcurrency,
|
|
251
|
+
groupConcurrency: this.queueGroupConcurrency,
|
|
252
|
+
}
|
|
253
|
+
: undefined;
|
|
241
254
|
// Register shared queue workers for monolith deployments
|
|
242
|
-
registerWorkflowFunc('pikkuWorkflowOrchestrator', { func: pikkuWorkflowOrchestratorFunc }, 'pikku-workflow-orchestrator');
|
|
243
|
-
registerWorkflowFunc('pikkuWorkflowStepWorker', { func: pikkuWorkflowWorkerFunc }, 'pikku-workflow-step-worker');
|
|
255
|
+
registerWorkflowFunc('pikkuWorkflowOrchestrator', { func: pikkuWorkflowOrchestratorFunc }, 'pikku-workflow-orchestrator', sharedQueueConfig);
|
|
256
|
+
registerWorkflowFunc('pikkuWorkflowStepWorker', { func: pikkuWorkflowWorkerFunc }, 'pikku-workflow-step-worker', sharedQueueConfig);
|
|
244
257
|
// Register per-workflow queue workers (root + addon packages)
|
|
245
258
|
const registerQueueWorkers = (queueMeta) => {
|
|
246
259
|
for (const [queueName, meta] of Object.entries(queueMeta)) {
|
|
@@ -254,16 +267,32 @@ export class PikkuWorkflowService {
|
|
|
254
267
|
}
|
|
255
268
|
}
|
|
256
269
|
};
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
if (!sharedGroups) {
|
|
271
|
+
registerQueueWorkers(pikkuState(null, 'queue', 'meta'));
|
|
272
|
+
const addons = pikkuState(null, 'addons', 'packages');
|
|
273
|
+
if (addons) {
|
|
274
|
+
for (const [, addon] of addons) {
|
|
275
|
+
const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta');
|
|
276
|
+
if (addonQueueMeta) {
|
|
277
|
+
registerQueueWorkers(addonQueueMeta);
|
|
278
|
+
}
|
|
264
279
|
}
|
|
265
280
|
}
|
|
266
281
|
}
|
|
282
|
+
// Workflows exist but no per-workflow orchestrator queue was registered:
|
|
283
|
+
// the generated queue meta never reached the runtime (most often the
|
|
284
|
+
// bootstrap doesn't import the queue-workers meta, so `queue.meta` is
|
|
285
|
+
// empty). Everything still "works" — dispatch silently falls back to the
|
|
286
|
+
// single shared orchestrator queue — but the isolation is gone: one slow
|
|
287
|
+
// workflow step head-of-line-blocks every other workflow behind it. That
|
|
288
|
+
// is invisible until a queue starves, so say so loudly at wiring time.
|
|
289
|
+
const workflowCount = Object.keys(pikkuState(null, 'workflows', 'meta') ?? {}).length;
|
|
290
|
+
const perWorkflowQueues = Object.keys(queueMeta).filter((name) => name.startsWith('wf-orchestrator-')).length;
|
|
291
|
+
if (workflowCount > 0 && perWorkflowQueues === 0) {
|
|
292
|
+
this.logger?.warn?.(`[pikku] ${workflowCount} workflow(s) registered but no per-workflow orchestrator queues were found in queue meta. ` +
|
|
293
|
+
`All workflows will share a single orchestrator queue, where one slow step blocks every other workflow behind it. ` +
|
|
294
|
+
`Check that the generated bootstrap imports the queue-workers meta (pikku-queue-workers-wirings-meta.gen.js).`);
|
|
295
|
+
}
|
|
267
296
|
if (!functions.has('pikkuWorkflowSleeper')) {
|
|
268
297
|
addFunction('pikkuWorkflowSleeper', {
|
|
269
298
|
func: pikkuWorkflowSleeperFunc,
|
|
@@ -387,7 +416,10 @@ export class PikkuWorkflowService {
|
|
|
387
416
|
* Creates pending step in both workflow_step and workflow_step_history
|
|
388
417
|
* @param runId - Run ID
|
|
389
418
|
* @param stepName - Step cache key
|
|
390
|
-
* @param rpcName - RPC
|
|
419
|
+
* @param rpcName - The name this step was dispatched by: an RPC for a
|
|
420
|
+
* `workflow.do` step, a step function for a scenario step, null for a
|
|
421
|
+
* closure. Nothing dispatches off this value — it is recorded so a reader
|
|
422
|
+
* can join a step back to the function that ran it.
|
|
391
423
|
* @param data - Step input data
|
|
392
424
|
* @param stepOptions - Step options (retries, retryDelay)
|
|
393
425
|
* @returns Step state with generated stepId
|
|
@@ -513,7 +545,10 @@ export class PikkuWorkflowService {
|
|
|
513
545
|
// dispatch/infra failure recover: the job is rethrown and retried instead of
|
|
514
546
|
// the run hanging. Passing `attempts` per-job overrides the queue default, so
|
|
515
547
|
// this holds even when the orchestrator queue is configured `retry_limit 0`.
|
|
516
|
-
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId },
|
|
548
|
+
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, {
|
|
549
|
+
...this.resolveStepJobOptions(),
|
|
550
|
+
group: this.getJobGroup(workflowName),
|
|
551
|
+
});
|
|
517
552
|
}
|
|
518
553
|
/**
|
|
519
554
|
* Resolve a step's retry policy into queue job options. The workflow is the
|
|
@@ -538,7 +573,12 @@ export class PikkuWorkflowService {
|
|
|
538
573
|
}
|
|
539
574
|
async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
540
575
|
const queueService = this.verifyQueueService();
|
|
541
|
-
await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })),
|
|
576
|
+
await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), {
|
|
577
|
+
...this.resolveStepJobOptions(stepOptions),
|
|
578
|
+
// Group by step function, mirroring how per-step queues split them —
|
|
579
|
+
// one slow step function can't monopolise the shared step worker.
|
|
580
|
+
group: this.getJobGroup(rpcName),
|
|
581
|
+
});
|
|
542
582
|
}
|
|
543
583
|
/**
|
|
544
584
|
* Execute a workflow sleep step completion
|
|
@@ -560,7 +600,12 @@ export class PikkuWorkflowService {
|
|
|
560
600
|
const run = await this.getRun(runId);
|
|
561
601
|
workflowName = run?.workflow;
|
|
562
602
|
}
|
|
563
|
-
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId },
|
|
603
|
+
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, {
|
|
604
|
+
...(retryDelay
|
|
605
|
+
? { delay: getDurationInMilliseconds(retryDelay) }
|
|
606
|
+
: undefined),
|
|
607
|
+
group: this.getJobGroup(workflowName),
|
|
608
|
+
});
|
|
564
609
|
}
|
|
565
610
|
/**
|
|
566
611
|
* Dispatch a workflow step to be executed asynchronously.
|
|
@@ -592,7 +637,10 @@ export class PikkuWorkflowService {
|
|
|
592
637
|
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
593
638
|
}
|
|
594
639
|
try {
|
|
595
|
-
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })),
|
|
640
|
+
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), {
|
|
641
|
+
...this.resolveStepJobOptions(stepOptions),
|
|
642
|
+
group: this.getJobGroup(rpcName),
|
|
643
|
+
});
|
|
596
644
|
}
|
|
597
645
|
catch (cause) {
|
|
598
646
|
// The queue is down/unreachable — NOT a step failure. Surface it as a
|
|
@@ -620,40 +668,34 @@ export class PikkuWorkflowService {
|
|
|
620
668
|
await getSingletonServices().schedulerService.scheduleRPC(duration, this.getConfig().sleeperRPCName, { runId, stepId });
|
|
621
669
|
return true;
|
|
622
670
|
}
|
|
623
|
-
/**
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
'/api/auth/sign-in/actor';
|
|
643
|
-
const rpcPath = (await variables.get('SCENARIO_RPC_PATH')) ?? '/rpc';
|
|
644
|
-
return createHttpScenarioActors({
|
|
645
|
-
apiUrl,
|
|
646
|
-
secret,
|
|
647
|
-
actors: actorsConfig,
|
|
648
|
-
signInPath,
|
|
649
|
-
rpcPath,
|
|
650
|
-
});
|
|
671
|
+
/**
|
|
672
|
+
* Install the one extension a run may have, built from a handle onto the run
|
|
673
|
+
* engine so that `inlineStep` and friends stay protected rather than becoming
|
|
674
|
+
* public API. Returns the extension, so the caller keeps a typed reference to
|
|
675
|
+
* whatever it just built.
|
|
676
|
+
*/
|
|
677
|
+
setRunExtension(create) {
|
|
678
|
+
const engine = {
|
|
679
|
+
inlineStep: this.inlineStep.bind(this),
|
|
680
|
+
updateRunStatus: this.updateRunStatus.bind(this),
|
|
681
|
+
onChildWorkflowFailed: this.onChildWorkflowFailed.bind(this),
|
|
682
|
+
verifyStepName: this.verifyStepName.bind(this),
|
|
683
|
+
};
|
|
684
|
+
const extension = create(engine);
|
|
685
|
+
this.runExtension = extension;
|
|
686
|
+
return extension;
|
|
687
|
+
}
|
|
688
|
+
getRunExtension() {
|
|
689
|
+
return this.runExtension;
|
|
651
690
|
}
|
|
652
691
|
/**
|
|
653
692
|
* Start a new workflow run
|
|
654
693
|
* Automatically detects workflow type (DSL or graph) from meta and executes accordingly
|
|
655
694
|
* @param options.inline - If true, execute workflow directly without queue service
|
|
656
695
|
* @param options.startNode - Starting node ID for graph workflows (from wire config)
|
|
696
|
+
* @param options.onRunCreated - Called with the run id the moment the run exists.
|
|
697
|
+
* An inline run that fails throws instead of returning, so this is the only
|
|
698
|
+
* way a caller can still read that run back — its steps, and which one failed.
|
|
657
699
|
*/
|
|
658
700
|
async startWorkflow(name, input, wire, rpcService, options) {
|
|
659
701
|
// Resolve workflow from static meta (root or addon namespace), then dynamic DB
|
|
@@ -689,13 +731,8 @@ export class PikkuWorkflowService {
|
|
|
689
731
|
deterministic: workflowMeta.deterministic,
|
|
690
732
|
plannedSteps: workflowMeta.plannedSteps,
|
|
691
733
|
});
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
? await this.resolveScenarioActors()
|
|
695
|
-
: undefined);
|
|
696
|
-
if (actors) {
|
|
697
|
-
this.runActors.set(runId, actors);
|
|
698
|
-
}
|
|
734
|
+
options?.onRunCreated?.(runId);
|
|
735
|
+
await this.runExtension?.attachRunContext(runId, workflowMeta, options);
|
|
699
736
|
if (shouldInline) {
|
|
700
737
|
this.inlineRuns.add(runId);
|
|
701
738
|
try {
|
|
@@ -728,7 +765,7 @@ export class PikkuWorkflowService {
|
|
|
728
765
|
}
|
|
729
766
|
finally {
|
|
730
767
|
this.inlineRuns.delete(runId);
|
|
731
|
-
this.
|
|
768
|
+
this.runExtension?.detachRunContext(runId);
|
|
732
769
|
}
|
|
733
770
|
}
|
|
734
771
|
else {
|
|
@@ -853,14 +890,30 @@ export class PikkuWorkflowService {
|
|
|
853
890
|
workflowWire.pikkuUserId = run.wire?.pikkuUserId;
|
|
854
891
|
const wire = {
|
|
855
892
|
workflow: workflowWire,
|
|
856
|
-
scenario: workflowMeta?.source === 'scenario' ? workflowWire : undefined,
|
|
857
893
|
pikkuUserId: run.wire?.pikkuUserId,
|
|
858
894
|
session: rpcService?.wire?.session,
|
|
859
895
|
rpc: rpcService?.wire?.rpc,
|
|
860
|
-
// User-flow actors registered for this run (see startWorkflow options)
|
|
861
|
-
actors: this.runActors.get(runId),
|
|
862
896
|
};
|
|
897
|
+
this.runExtension?.decorateRunWire(wire, {
|
|
898
|
+
runId,
|
|
899
|
+
workflowMeta,
|
|
900
|
+
workflowWire,
|
|
901
|
+
});
|
|
902
|
+
const lifecycle = {
|
|
903
|
+
runId,
|
|
904
|
+
run,
|
|
905
|
+
workflowMeta,
|
|
906
|
+
workflow,
|
|
907
|
+
wire,
|
|
908
|
+
packageName: pkgName,
|
|
909
|
+
};
|
|
910
|
+
// `interrupted` means the run has not reached a terminal state — it is
|
|
911
|
+
// suspended or waiting — so teardown would run while the run is still
|
|
912
|
+
// mid-flight.
|
|
913
|
+
let outcome = 'completed';
|
|
914
|
+
let failure;
|
|
863
915
|
try {
|
|
916
|
+
await this.runExtension?.onBeforeRunFunc(lifecycle);
|
|
864
917
|
const result = await runPikkuFunc('workflow', workflowMeta.name, workflowMeta.pikkuFuncId, {
|
|
865
918
|
singletonServices: getSingletonServices(),
|
|
866
919
|
wire,
|
|
@@ -872,10 +925,13 @@ export class PikkuWorkflowService {
|
|
|
872
925
|
await this.onChildWorkflowCompleted(run, result);
|
|
873
926
|
}
|
|
874
927
|
catch (error) {
|
|
928
|
+
failure = error;
|
|
875
929
|
if (error instanceof WorkflowAsyncException) {
|
|
930
|
+
outcome = 'interrupted';
|
|
876
931
|
throw error;
|
|
877
932
|
}
|
|
878
933
|
if (error instanceof WorkflowCancelledException) {
|
|
934
|
+
outcome = 'failed';
|
|
879
935
|
await this.updateRunStatus(runId, 'cancelled', undefined, {
|
|
880
936
|
message: error.message || 'Workflow cancelled',
|
|
881
937
|
stack: '',
|
|
@@ -885,6 +941,7 @@ export class PikkuWorkflowService {
|
|
|
885
941
|
throw error;
|
|
886
942
|
}
|
|
887
943
|
if (error instanceof WorkflowSuspendedException) {
|
|
944
|
+
outcome = 'interrupted';
|
|
888
945
|
await this.updateRunStatus(runId, 'suspended', undefined, {
|
|
889
946
|
message: error.message || 'Workflow suspended',
|
|
890
947
|
stack: '',
|
|
@@ -892,6 +949,7 @@ export class PikkuWorkflowService {
|
|
|
892
949
|
});
|
|
893
950
|
throw error;
|
|
894
951
|
}
|
|
952
|
+
outcome = 'failed';
|
|
895
953
|
await this.updateRunStatus(runId, 'failed', undefined, {
|
|
896
954
|
message: error.message,
|
|
897
955
|
stack: error.stack,
|
|
@@ -900,6 +958,9 @@ export class PikkuWorkflowService {
|
|
|
900
958
|
await this.onChildWorkflowFailed(run, error);
|
|
901
959
|
throw error;
|
|
902
960
|
}
|
|
961
|
+
finally {
|
|
962
|
+
await this.runExtension?.onAfterRunFunc(lifecycle, outcome, failure);
|
|
963
|
+
}
|
|
903
964
|
});
|
|
904
965
|
}
|
|
905
966
|
async onChildWorkflowCompleted(childRun, result) {
|
|
@@ -1279,7 +1340,21 @@ export class PikkuWorkflowService {
|
|
|
1279
1340
|
}
|
|
1280
1341
|
});
|
|
1281
1342
|
}
|
|
1282
|
-
async inlineStep(runId, logicalStepName, fn, stepOptions
|
|
1343
|
+
async inlineStep(runId, logicalStepName, fn, stepOptions,
|
|
1344
|
+
/**
|
|
1345
|
+
* The input this step was called with, recorded on the run so a reporter can
|
|
1346
|
+
* name the values under test. A closure step has none; a scenario step does.
|
|
1347
|
+
*/
|
|
1348
|
+
data = null,
|
|
1349
|
+
/**
|
|
1350
|
+
* The name this step was dispatched by, for the kinds of inline step that
|
|
1351
|
+
* have one. A closure step has no name; a scenario step is a step RPC, so
|
|
1352
|
+
* it records the step function that ran — which is the only way to join a
|
|
1353
|
+
* step back to its declaration when its durable name was built at runtime
|
|
1354
|
+
* (a step called in a loop reaches the run as `sees @pikku/addon-todos`,
|
|
1355
|
+
* declared as `sees ${packageName}`).
|
|
1356
|
+
*/
|
|
1357
|
+
rpcName = null) {
|
|
1283
1358
|
const fromStepName = this.lastStepName(runId);
|
|
1284
1359
|
const stepName = this.nextStepKey(runId, logicalStepName);
|
|
1285
1360
|
// Check if step already exists
|
|
@@ -1288,8 +1363,8 @@ export class PikkuWorkflowService {
|
|
|
1288
1363
|
stepState = await this.getStepState(runId, stepName);
|
|
1289
1364
|
}
|
|
1290
1365
|
catch {
|
|
1291
|
-
// Step doesn't exist - create it (inline,
|
|
1292
|
-
stepState = await this.insertStepState(runId, stepName,
|
|
1366
|
+
// Step doesn't exist - create it (inline, so never dispatched)
|
|
1367
|
+
stepState = await this.insertStepState(runId, stepName, rpcName, data, stepOptions, fromStepName);
|
|
1293
1368
|
}
|
|
1294
1369
|
if (stepState.status === 'succeeded') {
|
|
1295
1370
|
// Return cached result
|
|
@@ -1430,7 +1505,11 @@ export class PikkuWorkflowService {
|
|
|
1430
1505
|
const run = await this.getRun(runId);
|
|
1431
1506
|
if (!run?.workflow)
|
|
1432
1507
|
return;
|
|
1433
|
-
await queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, {
|
|
1508
|
+
await queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, {
|
|
1509
|
+
...this.resolveStepJobOptions(),
|
|
1510
|
+
delay,
|
|
1511
|
+
group: this.getJobGroup(run.workflow),
|
|
1512
|
+
});
|
|
1434
1513
|
}
|
|
1435
1514
|
catch (error) {
|
|
1436
1515
|
this.logger?.warn(`Failed to schedule approval expiry wake for run ${runId}; expiry will still resolve on the next replay`, error);
|
|
@@ -1582,90 +1661,6 @@ export class PikkuWorkflowService {
|
|
|
1582
1661
|
return await this.inlineStep(runId, stepName, rpcNameOrFn, dataOrOptions);
|
|
1583
1662
|
}
|
|
1584
1663
|
},
|
|
1585
|
-
// Durable polling step: invoke an RPC (as an actor when options.as is
|
|
1586
|
-
// set) until the predicate passes or `within` elapses. The whole poll is
|
|
1587
|
-
// ONE recorded step, so replay returns the cached outcome.
|
|
1588
|
-
expectEventually: async (stepName, rpcName, data, predicate, options) => {
|
|
1589
|
-
this.verifyStepName(stepName);
|
|
1590
|
-
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
1591
|
-
? `${addonNamespace}:${rpcName}`
|
|
1592
|
-
: rpcName;
|
|
1593
|
-
const within = getDurationInMilliseconds(options?.within ?? '30s');
|
|
1594
|
-
const interval = getDurationInMilliseconds(options?.interval ?? '1s');
|
|
1595
|
-
return await this.inlineStep(runId, stepName, async () => {
|
|
1596
|
-
const deadline = Date.now() + within;
|
|
1597
|
-
let last;
|
|
1598
|
-
while (true) {
|
|
1599
|
-
last = options?.actor
|
|
1600
|
-
? await options.actor.invoke(resolvedRpcName, data)
|
|
1601
|
-
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
1602
|
-
if (predicate(last))
|
|
1603
|
-
return last;
|
|
1604
|
-
if (Date.now() + interval > deadline) {
|
|
1605
|
-
throw new Error(`[workflow] expectEventually '${stepName}' ('${resolvedRpcName}'` +
|
|
1606
|
-
`${options?.actor ? ` as '${options.actor.name}'` : ''}) did not pass within ${within}ms; ` +
|
|
1607
|
-
`last result: ${JSON.stringify(last)?.slice(0, 300)}`);
|
|
1608
|
-
}
|
|
1609
|
-
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
1610
|
-
}
|
|
1611
|
-
}, options);
|
|
1612
|
-
},
|
|
1613
|
-
expectError: async (stepName, rpcName, data, options) => {
|
|
1614
|
-
this.verifyStepName(stepName);
|
|
1615
|
-
const resolvedRpcName = addonNamespace && !rpcName.includes(':')
|
|
1616
|
-
? `${addonNamespace}:${rpcName}`
|
|
1617
|
-
: rpcName;
|
|
1618
|
-
return await this.inlineStep(runId, stepName, async () => {
|
|
1619
|
-
let result;
|
|
1620
|
-
try {
|
|
1621
|
-
result = options?.actor
|
|
1622
|
-
? await options.actor.invoke(resolvedRpcName, data)
|
|
1623
|
-
: await rpcService.rpcWithWire(resolvedRpcName, data, {});
|
|
1624
|
-
}
|
|
1625
|
-
catch (e) {
|
|
1626
|
-
const message = e?.message ?? String(e);
|
|
1627
|
-
if (options?.matches) {
|
|
1628
|
-
const matched = typeof options.matches === 'string'
|
|
1629
|
-
? message.includes(options.matches)
|
|
1630
|
-
: options.matches.test(message);
|
|
1631
|
-
if (!matched) {
|
|
1632
|
-
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') threw, but the message did not match ${options.matches}: ${message}`);
|
|
1633
|
-
}
|
|
1634
|
-
}
|
|
1635
|
-
return message;
|
|
1636
|
-
}
|
|
1637
|
-
throw new Error(`[workflow] expectError '${stepName}' ('${resolvedRpcName}') expected an error but the call succeeded: ${JSON.stringify(result)?.slice(0, 300)}`);
|
|
1638
|
-
}, options);
|
|
1639
|
-
},
|
|
1640
|
-
expectService: async (stepName, serviceMethod, options) => {
|
|
1641
|
-
this.verifyStepName(stepName);
|
|
1642
|
-
const [service, method] = serviceMethod.split('.');
|
|
1643
|
-
if (!service || !method) {
|
|
1644
|
-
throw new Error(`[workflow] expectService '${stepName}' needs 'service.method', got '${serviceMethod}'`);
|
|
1645
|
-
}
|
|
1646
|
-
await this.inlineStep(runId, stepName, async () => {
|
|
1647
|
-
const rpcName = 'pikkuScenarioGetStubCalls';
|
|
1648
|
-
const calls = options?.actor
|
|
1649
|
-
? await options.actor.invoke(rpcName, { service })
|
|
1650
|
-
: await rpcService.rpcWithWire(rpcName, { service }, {});
|
|
1651
|
-
const matching = (calls ?? []).filter((c) => c.service === service &&
|
|
1652
|
-
c.method === method &&
|
|
1653
|
-
(options?.calledWith === undefined ||
|
|
1654
|
-
JSON.stringify(c.args?.[0]) ===
|
|
1655
|
-
JSON.stringify(options.calledWith)));
|
|
1656
|
-
const expected = options?.times;
|
|
1657
|
-
const ok = expected === undefined
|
|
1658
|
-
? matching.length > 0
|
|
1659
|
-
: matching.length === expected;
|
|
1660
|
-
if (!ok) {
|
|
1661
|
-
const seen = (calls ?? [])
|
|
1662
|
-
.map((c) => `${c.service}.${c.method}(${JSON.stringify(c.args?.[0])?.slice(0, 120) ?? ''})`)
|
|
1663
|
-
.join('\n ') || '(none)';
|
|
1664
|
-
throw new Error(`[workflow] expectService '${stepName}' expected ${expected ?? 'at least one'} call(s) to '${serviceMethod}'` +
|
|
1665
|
-
`${options?.calledWith !== undefined ? ` with ${JSON.stringify(options.calledWith)}` : ''}, found ${matching.length}. Recorded:\n ${seen}`);
|
|
1666
|
-
}
|
|
1667
|
-
}, options);
|
|
1668
|
-
},
|
|
1669
1664
|
// Implement workflow.sleep()
|
|
1670
1665
|
sleep: async (stepName, duration) => {
|
|
1671
1666
|
this.verifyStepName(stepName);
|
|
@@ -1679,10 +1674,13 @@ export class PikkuWorkflowService {
|
|
|
1679
1674
|
this.verifyStepName(reason);
|
|
1680
1675
|
return await this.approvalStep(runId, reason, options);
|
|
1681
1676
|
}),
|
|
1682
|
-
runScheduledTask: async (taskName) => {
|
|
1683
|
-
await runScheduledTask({ name: taskName });
|
|
1684
|
-
},
|
|
1685
1677
|
};
|
|
1678
|
+
this.runExtension?.decorateWorkflowWire(workflowWire, {
|
|
1679
|
+
name,
|
|
1680
|
+
runId,
|
|
1681
|
+
rpcService,
|
|
1682
|
+
addonNamespace,
|
|
1683
|
+
});
|
|
1686
1684
|
return workflowWire;
|
|
1687
1685
|
}
|
|
1688
1686
|
verifyStepName(stepName) {
|
|
@@ -1712,7 +1710,7 @@ export class PikkuWorkflowService {
|
|
|
1712
1710
|
* queues — but it produces to them — so registrations would miss them.
|
|
1713
1711
|
*/
|
|
1714
1712
|
getOrchestratorQueueName(workflowName) {
|
|
1715
|
-
if (workflowName) {
|
|
1713
|
+
if (workflowName && this.queueStrategy !== 'shared-groups') {
|
|
1716
1714
|
const perWorkflow = `wf-orchestrator-${toKebab(workflowName)}`;
|
|
1717
1715
|
const meta = pikkuState(null, 'queue', 'meta');
|
|
1718
1716
|
if (meta[perWorkflow]) {
|
|
@@ -1722,7 +1720,7 @@ export class PikkuWorkflowService {
|
|
|
1722
1720
|
return this.getConfig().orchestratorQueueName;
|
|
1723
1721
|
}
|
|
1724
1722
|
getStepWorkerQueueName(rpcName) {
|
|
1725
|
-
if (rpcName) {
|
|
1723
|
+
if (rpcName && this.queueStrategy !== 'shared-groups') {
|
|
1726
1724
|
const perStep = `wf-step-${toKebab(rpcName)}`;
|
|
1727
1725
|
const meta = pikkuState(null, 'queue', 'meta');
|
|
1728
1726
|
if (meta[perStep]) {
|
|
@@ -1731,4 +1729,19 @@ export class PikkuWorkflowService {
|
|
|
1731
1729
|
}
|
|
1732
1730
|
return this.getConfig().stepWorkerQueueName;
|
|
1733
1731
|
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Fairness key for a job on a shared queue. Under `'per-workflow'` the queue
|
|
1734
|
+
* name already isolates workflows, so no group is needed — returning one
|
|
1735
|
+
* anyway would cap a workflow inside its own dedicated queue.
|
|
1736
|
+
*
|
|
1737
|
+
* The tier repeats the id so a workflow can be given its own limit purely
|
|
1738
|
+
* from config, with no per-workflow wiring; an unmatched tier falls back to
|
|
1739
|
+
* the default limit.
|
|
1740
|
+
*/
|
|
1741
|
+
getJobGroup(id) {
|
|
1742
|
+
if (!id || this.queueStrategy !== 'shared-groups') {
|
|
1743
|
+
return undefined;
|
|
1744
|
+
}
|
|
1745
|
+
return { id, tier: id };
|
|
1746
|
+
}
|
|
1734
1747
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** A session held the way a browser holds one, for code driving a real target. */
|
|
2
|
+
export interface ScenarioCookieJar {
|
|
3
|
+
/** `fetch`, but it sends what the target has set and keeps what it sets. */
|
|
4
|
+
fetch: typeof fetch;
|
|
5
|
+
/** Forgets the session — what an actor does before signing in again. */
|
|
6
|
+
clear(): void;
|
|
7
|
+
/**
|
|
8
|
+
* Whether the target has set anything yet. This is a fact about the jar, not
|
|
9
|
+
* about the session: a target that sets a CSRF or locale cookie before anyone
|
|
10
|
+
* signs in fills the jar without establishing one. Whoever needs to know
|
|
11
|
+
* whether a sign-in happened has to track the sign-in.
|
|
12
|
+
*/
|
|
13
|
+
readonly empty: boolean;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A `fetch` that remembers cookies.
|
|
17
|
+
*
|
|
18
|
+
* A browser persists the session cookie on its own; anything driving a target
|
|
19
|
+
* from this process has to. Every response is read, not just the sign-in, so a
|
|
20
|
+
* cookie the target rotates mid-session is followed rather than dropped.
|
|
21
|
+
*
|
|
22
|
+
* The jar is a closure local, so two jars never share a session — which is what
|
|
23
|
+
* lets one scenario sign in as several people without one of them inheriting
|
|
24
|
+
* the other's session.
|
|
25
|
+
*
|
|
26
|
+
* It also stamps `Origin`, because Better Auth rejects a state-changing POST
|
|
27
|
+
* whose Origin does not match its baseURL.
|
|
28
|
+
*/
|
|
29
|
+
export declare const createCookieJar: (apiUrl: string) => ScenarioCookieJar;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A `fetch` that remembers cookies.
|
|
3
|
+
*
|
|
4
|
+
* A browser persists the session cookie on its own; anything driving a target
|
|
5
|
+
* from this process has to. Every response is read, not just the sign-in, so a
|
|
6
|
+
* cookie the target rotates mid-session is followed rather than dropped.
|
|
7
|
+
*
|
|
8
|
+
* The jar is a closure local, so two jars never share a session — which is what
|
|
9
|
+
* lets one scenario sign in as several people without one of them inheriting
|
|
10
|
+
* the other's session.
|
|
11
|
+
*
|
|
12
|
+
* It also stamps `Origin`, because Better Auth rejects a state-changing POST
|
|
13
|
+
* whose Origin does not match its baseURL.
|
|
14
|
+
*/
|
|
15
|
+
export const createCookieJar = (apiUrl) => {
|
|
16
|
+
const jar = new Map();
|
|
17
|
+
const origin = new URL(apiUrl).origin;
|
|
18
|
+
return {
|
|
19
|
+
fetch: async (input, init) => {
|
|
20
|
+
const headers = new Headers(init?.headers);
|
|
21
|
+
headers.set('origin', origin);
|
|
22
|
+
const held = [...jar].map(([name, value]) => `${name}=${value}`);
|
|
23
|
+
const caller = headers.get('cookie');
|
|
24
|
+
if (held.length > 0 || caller) {
|
|
25
|
+
headers.set('cookie', [caller, ...held].filter(Boolean).join('; '));
|
|
26
|
+
}
|
|
27
|
+
const response = await fetch(input, { ...init, headers });
|
|
28
|
+
for (const raw of response.headers.getSetCookie()) {
|
|
29
|
+
const [pair] = raw.split(';');
|
|
30
|
+
const separator = pair.indexOf('=');
|
|
31
|
+
if (separator > 0) {
|
|
32
|
+
const name = pair.slice(0, separator);
|
|
33
|
+
const value = pair.slice(separator + 1);
|
|
34
|
+
// An empty value is how a target deletes a cookie — drop the name
|
|
35
|
+
// rather than holding a cookie whose value says it is gone.
|
|
36
|
+
if (value) {
|
|
37
|
+
jar.set(name, value);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
jar.delete(name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return response;
|
|
45
|
+
},
|
|
46
|
+
clear: () => jar.clear(),
|
|
47
|
+
get empty() {
|
|
48
|
+
return jar.size === 0;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** How long to keep trying, and how long to wait between tries. */
|
|
2
|
+
export interface PollOptions {
|
|
3
|
+
/** Total time to keep attempting before giving up. Default 15s. */
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
/** Gap between attempts. Default 250ms. */
|
|
6
|
+
intervalMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Attempt something until it answers, or the deadline passes.
|
|
10
|
+
*
|
|
11
|
+
* `undefined` means "not yet" and nothing else — `false`, `0` and `''` are all
|
|
12
|
+
* answers, because a probe asking whether something happened reports `false`
|
|
13
|
+
* when it did not. Answering `undefined` at the deadline rather than throwing
|
|
14
|
+
* leaves the error to the caller, who is the only one who knows what was being
|
|
15
|
+
* waited for.
|
|
16
|
+
*
|
|
17
|
+
* A step that polls its target — a delivery reaching a terminal status, a run
|
|
18
|
+
* finishing — reaches for this rather than writing the deadline loop again.
|
|
19
|
+
*/
|
|
20
|
+
export declare const pollUntil: <T>(attempt: () => Promise<T | undefined> | T | undefined, { timeoutMs, intervalMs }?: PollOptions) => Promise<T | undefined>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attempt something until it answers, or the deadline passes.
|
|
3
|
+
*
|
|
4
|
+
* `undefined` means "not yet" and nothing else — `false`, `0` and `''` are all
|
|
5
|
+
* answers, because a probe asking whether something happened reports `false`
|
|
6
|
+
* when it did not. Answering `undefined` at the deadline rather than throwing
|
|
7
|
+
* leaves the error to the caller, who is the only one who knows what was being
|
|
8
|
+
* waited for.
|
|
9
|
+
*
|
|
10
|
+
* A step that polls its target — a delivery reaching a terminal status, a run
|
|
11
|
+
* finishing — reaches for this rather than writing the deadline loop again.
|
|
12
|
+
*/
|
|
13
|
+
export const pollUntil = async (attempt, { timeoutMs = 15_000, intervalMs = 250 } = {}) => {
|
|
14
|
+
const deadline = Date.now() + timeoutMs;
|
|
15
|
+
for (;;) {
|
|
16
|
+
const answer = await attempt();
|
|
17
|
+
if (answer !== undefined) {
|
|
18
|
+
return answer;
|
|
19
|
+
}
|
|
20
|
+
if (Date.now() >= deadline) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
await new Promise((done) => setTimeout(done, intervalMs));
|
|
24
|
+
}
|
|
25
|
+
};
|