@pikku/core 0.12.67 → 0.12.69
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 +63 -0
- package/dist/services/in-memory-workflow-service.d.ts +2 -2
- package/dist/services/in-memory-workflow-service.js +2 -2
- 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/queue/index.d.ts +1 -1
- package/dist/wirings/queue/queue.types.d.ts +30 -0
- package/dist/wirings/workflow/index.d.ts +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +16 -3
- package/dist/wirings/workflow/pikku-workflow-service.js +86 -18
- package/dist/wirings/workflow/workflow.types.d.ts +38 -0
- package/package.json +1 -1
- package/src/services/in-memory-workflow-service.test.ts +50 -1
- package/src/services/in-memory-workflow-service.ts +3 -2
- 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/queue/index.ts +2 -0
- package/src/wirings/queue/queue.types.ts +32 -0
- package/src/wirings/workflow/index.ts +1 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +110 -20
- package/src/wirings/workflow/workflow.types.ts +39 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
|
+
## 0.12.69
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 24252b8: Emit queue meta for workflow-only projects, so per-workflow orchestrator queues actually work.
|
|
6
|
+
|
|
7
|
+
Workflows synthesise their own `wf-orchestrator-*` / `wf-step-*` queue meta during
|
|
8
|
+
post-processing, and those entries have no declaring source file. The queue codegen
|
|
9
|
+
bailed early on `queueWorkers.files.size === 0`, so a project that uses workflows but
|
|
10
|
+
hand-declares no `wireQueueWorker` wrote no queue meta at all — and the generated
|
|
11
|
+
bootstrap therefore never imported it.
|
|
12
|
+
|
|
13
|
+
With `queue.meta` empty at runtime, `getOrchestratorQueueName()` never found a
|
|
14
|
+
per-workflow queue and every workflow silently fell back to the single shared
|
|
15
|
+
`pikku-workflow-orchestrator` queue. Nothing failed, but the isolation was gone: one
|
|
16
|
+
long-running workflow step head-of-line-blocked every other workflow queued behind it.
|
|
17
|
+
|
|
18
|
+
The codegen now gates on the meta alone. `@pikku/core` additionally warns at wiring
|
|
19
|
+
time when workflows are registered but no per-workflow orchestrator queue is present,
|
|
20
|
+
so this degradation can't recur silently.
|
|
21
|
+
|
|
22
|
+
- e3d4454: Add job groups, so one shared queue can stay fair without splitting into one
|
|
23
|
+
queue per producer.
|
|
24
|
+
|
|
25
|
+
A job may now carry `group: { id, tier }`, and a worker may cap how many jobs
|
|
26
|
+
of any one group run at once via `groupConcurrency`. On pg-boss this maps to
|
|
27
|
+
`localGroupConcurrency`, which excludes at-capacity groups from the fetch query
|
|
28
|
+
itself, so a capped group costs nothing rather than being fetched and restored.
|
|
29
|
+
BullMQ declares it unsupported (groups are a BullMQ Pro feature) — being
|
|
30
|
+
push-based, it can simply use a queue per group at no polling cost.
|
|
31
|
+
|
|
32
|
+
Workflow services accept a `queueStrategy`. The default `'per-workflow'` is
|
|
33
|
+
unchanged: every workflow gets its own `wf-orchestrator-*` / `wf-step-*` queue,
|
|
34
|
+
which is also what lets serverless providers deploy one unit per workflow. The
|
|
35
|
+
new `'shared-groups'` routes every workflow through the shared
|
|
36
|
+
orchestrator/step-worker queues and isolates them by group instead, so a
|
|
37
|
+
monolith runs one set of pollers rather than one per workflow — on a
|
|
38
|
+
pull-based backend with dozens of workflows that is the difference between
|
|
39
|
+
hundreds of poll loops and twenty. It is for single-process runtimes only; a
|
|
40
|
+
per-unit serverless deploy still needs the per-workflow queues to route to its
|
|
41
|
+
units.
|
|
42
|
+
|
|
43
|
+
## 0.12.68
|
|
44
|
+
|
|
45
|
+
### Patch Changes
|
|
46
|
+
|
|
47
|
+
- f11675f: Forward the parent run's `context` into delegated sub-agent invocations.
|
|
48
|
+
|
|
49
|
+
A supervisor agent's injected `context` (the "Current context" block holding the
|
|
50
|
+
authoritative identifiers — organizationId, project/stage ids) was appended only
|
|
51
|
+
to the supervisor's own instructions. When it delegated, the sub-agent tool's
|
|
52
|
+
input schema carries just `{ message, session }`, and `buildToolDefs` invoked the
|
|
53
|
+
sub-agent with `{ message, threadId, resourceId }` — dropping the context. The
|
|
54
|
+
sub-agent therefore never saw the real ids and depended on the model re-typing
|
|
55
|
+
them into the free-text `message`, which weaker models routinely botch, producing
|
|
56
|
+
schema-validation and permission rejections that the agent then retries — burning
|
|
57
|
+
steps and ballooning the transcript.
|
|
58
|
+
|
|
59
|
+
`buildToolDefs` now takes the parent `context` and forwards it (via the new
|
|
60
|
+
`buildSubAgentRunInput` helper) into both the streaming and non-streaming
|
|
61
|
+
sub-agent invocations, so a specialist inherits the same identifier block in its
|
|
62
|
+
instructions.
|
|
63
|
+
|
|
1
64
|
## 0.12.67
|
|
2
65
|
|
|
3
66
|
### Patch Changes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PikkuWorkflowService } from '../wirings/workflow/pikku-workflow-service.js';
|
|
2
2
|
import type { SerializedError } from '../types/core.types.js';
|
|
3
|
-
import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
|
|
3
|
+
import type { WorkflowPlannedStep, WorkflowQueueOptions, WorkflowRun, WorkflowRunService, WorkflowRunWire, StepState, StepStatus, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from '../wirings/workflow/workflow.types.js';
|
|
4
4
|
/**
|
|
5
5
|
* In-memory implementation of WorkflowService for inline-only execution
|
|
6
6
|
*
|
|
@@ -17,7 +17,7 @@ import type { WorkflowPlannedStep, WorkflowRun, WorkflowRunService, WorkflowRunW
|
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
19
|
export declare class InMemoryWorkflowService extends PikkuWorkflowService implements WorkflowRunService {
|
|
20
|
-
constructor();
|
|
20
|
+
constructor(options?: WorkflowQueueOptions);
|
|
21
21
|
private sleepTimers;
|
|
22
22
|
private runs;
|
|
23
23
|
private steps;
|
|
@@ -17,8 +17,8 @@ import { isExpectedError } from '../errors/error-handler.js';
|
|
|
17
17
|
* ```
|
|
18
18
|
*/
|
|
19
19
|
export class InMemoryWorkflowService extends PikkuWorkflowService {
|
|
20
|
-
constructor() {
|
|
21
|
-
super({ wireQueues: false });
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
super({ ...options, wireQueues: false });
|
|
22
22
|
}
|
|
23
23
|
sleepTimers = new Set();
|
|
24
24
|
runs = new Map();
|
|
@@ -168,7 +168,24 @@ export type ScopedChannel = AIStreamChannel & {
|
|
|
168
168
|
}>;
|
|
169
169
|
};
|
|
170
170
|
export declare function createScopedChannel(parent: AIStreamChannel, agentName: string, session: string): ScopedChannel;
|
|
171
|
-
|
|
171
|
+
/**
|
|
172
|
+
* Build the run input for a delegated sub-agent.
|
|
173
|
+
*
|
|
174
|
+
* `context` is the PARENT run's identifier block (the "Current context" text
|
|
175
|
+
* with organizationId, project/stage ids). A sub-agent's tool-call schema only
|
|
176
|
+
* carries { message, session }, so unless the sub-agent inherits the parent's
|
|
177
|
+
* context it never sees the authoritative ids — it depends on the model
|
|
178
|
+
* re-typing them into `message`, which weak models botch, causing
|
|
179
|
+
* schema/permission rejections and retry loops. Forwarding it here is the
|
|
180
|
+
* regression this seam guards.
|
|
181
|
+
*/
|
|
182
|
+
export declare function buildSubAgentRunInput(message: string, threadId: string, resourceId: string, parentContext?: string): {
|
|
183
|
+
message: string;
|
|
184
|
+
threadId: string;
|
|
185
|
+
resourceId: string;
|
|
186
|
+
context?: string;
|
|
187
|
+
};
|
|
188
|
+
export declare function buildToolDefs(params: RunAIAgentParams, agentSessionMap: Map<string, string>, resourceId: string, agentName: string, packageName: string | null, streamContext?: StreamContext, aiMiddlewares?: PikkuAIMiddlewareHooks[], agentMode?: 'delegate' | 'supervise', parentContext?: string): Promise<{
|
|
172
189
|
tools: AIAgentToolDef[];
|
|
173
190
|
missingRpcs: string[];
|
|
174
191
|
}>;
|
|
@@ -325,7 +325,29 @@ export function createScopedChannel(parent, agentName, session) {
|
|
|
325
325
|
clearState: () => parent.clearState(),
|
|
326
326
|
};
|
|
327
327
|
}
|
|
328
|
-
|
|
328
|
+
/**
|
|
329
|
+
* Build the run input for a delegated sub-agent.
|
|
330
|
+
*
|
|
331
|
+
* `context` is the PARENT run's identifier block (the "Current context" text
|
|
332
|
+
* with organizationId, project/stage ids). A sub-agent's tool-call schema only
|
|
333
|
+
* carries { message, session }, so unless the sub-agent inherits the parent's
|
|
334
|
+
* context it never sees the authoritative ids — it depends on the model
|
|
335
|
+
* re-typing them into `message`, which weak models botch, causing
|
|
336
|
+
* schema/permission rejections and retry loops. Forwarding it here is the
|
|
337
|
+
* regression this seam guards.
|
|
338
|
+
*/
|
|
339
|
+
export function buildSubAgentRunInput(message, threadId, resourceId, parentContext) {
|
|
340
|
+
return { message, threadId, resourceId, context: parentContext };
|
|
341
|
+
}
|
|
342
|
+
export async function buildToolDefs(params, agentSessionMap, resourceId, agentName, packageName, streamContext, aiMiddlewares, agentMode,
|
|
343
|
+
// The parent run's `context` (the "Current context" identifier block). A
|
|
344
|
+
// delegated sub-agent's tool-call input schema only carries { message,
|
|
345
|
+
// session }, so without inheriting this the sub-agent never sees the
|
|
346
|
+
// authoritative ids (organizationId, project/stage ids) — it depends on the
|
|
347
|
+
// model re-typing them into `message`, which weak models botch, causing
|
|
348
|
+
// schema/permission rejections and retry loops. Forward it so the sub-agent
|
|
349
|
+
// gets the same context block in its instructions.
|
|
350
|
+
parentContext) {
|
|
329
351
|
const singletonServices = getSingletonServices();
|
|
330
352
|
const tools = [];
|
|
331
353
|
const missingRpcs = [];
|
|
@@ -505,7 +527,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
|
|
|
505
527
|
subChannel.send(event);
|
|
506
528
|
},
|
|
507
529
|
};
|
|
508
|
-
const resultText = await streamAIAgent(subAgentName,
|
|
530
|
+
const resultText = await streamAIAgent(subAgentName, buildSubAgentRunInput(message, threadId, resourceId, parentContext), effectiveChannel, params, agentSessionMap, streamContext.options);
|
|
509
531
|
if (subChannel.approvals.length > 0) {
|
|
510
532
|
return {
|
|
511
533
|
[APPROVAL_REQUIRED]: true,
|
|
@@ -525,7 +547,7 @@ export async function buildToolDefs(params, agentSessionMap, resourceId, agentNa
|
|
|
525
547
|
return resultText;
|
|
526
548
|
}
|
|
527
549
|
// No stream context: sub-agent runs non-streaming
|
|
528
|
-
const result = await runAIAgent(subAgentName,
|
|
550
|
+
const result = await runAIAgent(subAgentName, buildSubAgentRunInput(message, threadId, resourceId, parentContext), params, agentSessionMap);
|
|
529
551
|
if (result.status === 'suspended' &&
|
|
530
552
|
result.pendingApprovals?.length) {
|
|
531
553
|
return {
|
|
@@ -731,7 +753,7 @@ export async function prepareAgentRun(agentName, input, params, agentSessionMap,
|
|
|
731
753
|
const allMessages = [...contextMessages, ...messages, userMessage];
|
|
732
754
|
const trimmedMessages = trimMessages(allMessages);
|
|
733
755
|
const aiMiddlewares = agent.aiMiddleware ?? [];
|
|
734
|
-
const { tools, missingRpcs } = await buildToolDefs(params, agentSessionMap, input.resourceId, resolvedName, packageName, streamContext, aiMiddlewares, agent.agentMode);
|
|
756
|
+
const { tools, missingRpcs } = await buildToolDefs(params, agentSessionMap, input.resourceId, resolvedName, packageName, streamContext, aiMiddlewares, agent.agentMode, input.context);
|
|
735
757
|
let instructions = await buildInstructions(resolvedName, packageName);
|
|
736
758
|
if (input.context) {
|
|
737
759
|
instructions = `${instructions}\n\nCurrent context (use these identifiers directly in tool calls — do not ask the user for them):\n${input.context}`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type { ConfigValidationResult, CoreQueueWorker, JobOptions, PikkuJobConfig, PikkuWorkerConfig, PikkuQueue, QueueJob, QueueJobStatus, QueueService, QueueWorkers, QueueWorkersMeta, } from './queue.types.js';
|
|
1
|
+
export type { ConfigValidationResult, CoreQueueWorker, GroupConcurrencyConfig, JobGroup, JobOptions, PikkuJobConfig, PikkuWorkerConfig, PikkuQueue, QueueJob, QueueJobStatus, QueueService, QueueWorkers, QueueWorkersMeta, } from './queue.types.js';
|
|
2
2
|
export { wireQueueWorker, runQueueJob, getQueueWorkers, removeQueueWorker, QueueJobDiscardedError, QueueJobFailedError, } from './queue-runner.js';
|
|
3
3
|
export { validateWorkerConfig } from './validate-worker-config.js';
|
|
4
4
|
export type { QueueConfigMapping } from './validate-worker-config.js';
|
|
@@ -30,6 +30,34 @@ export interface PikkuWorkerConfig {
|
|
|
30
30
|
maxStalledCount?: number;
|
|
31
31
|
/** Condition to start processor at instance creation */
|
|
32
32
|
autorun?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Cap how many jobs of any one group ({@link JobOptions.group}) may run at
|
|
35
|
+
* once, so a single group can't occupy the whole worker. Lets one shared
|
|
36
|
+
* queue stay fair across producers instead of splitting it into one queue
|
|
37
|
+
* per producer — which multiplies polling cost on pull-based backends.
|
|
38
|
+
* Must not exceed {@link batchSize}.
|
|
39
|
+
*/
|
|
40
|
+
groupConcurrency?: number | GroupConcurrencyConfig;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Per-group concurrency limits, optionally varied by tier so slow groups can
|
|
44
|
+
* be allowed more (or fewer) slots than the default.
|
|
45
|
+
*/
|
|
46
|
+
export interface GroupConcurrencyConfig {
|
|
47
|
+
/** Limit applied to any group without a matching tier */
|
|
48
|
+
default: number;
|
|
49
|
+
/** Per-tier overrides, keyed by {@link JobGroup.tier} */
|
|
50
|
+
tiers?: Record<string, number>;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Fairness key for a job. Jobs sharing an `id` count against the same
|
|
54
|
+
* {@link PikkuWorkerConfig.groupConcurrency} limit.
|
|
55
|
+
*/
|
|
56
|
+
export interface JobGroup {
|
|
57
|
+
/** Group this job belongs to (e.g. a workflow name) */
|
|
58
|
+
id: string;
|
|
59
|
+
/** Optional tier selecting a per-tier limit */
|
|
60
|
+
tier?: string;
|
|
33
61
|
}
|
|
34
62
|
/**
|
|
35
63
|
* Configuration for individual jobs - how jobs behave
|
|
@@ -106,6 +134,8 @@ export interface JobOptions {
|
|
|
106
134
|
jobId?: string;
|
|
107
135
|
/** Pikku user ID to propagate to the queue worker for credential resolution */
|
|
108
136
|
pikkuUserId?: string;
|
|
137
|
+
/** Fairness key — counts against the worker's {@link PikkuWorkerConfig.groupConcurrency} */
|
|
138
|
+
group?: JobGroup;
|
|
109
139
|
}
|
|
110
140
|
/**
|
|
111
141
|
* Queue provider interface for job publishing operations
|
|
@@ -11,5 +11,5 @@ export { pikkuWorkflowGraph, type PikkuWorkflowGraphConfig, type PikkuWorkflowGr
|
|
|
11
11
|
export { validateWorkflowWiring, computeEntryNodeIds, } from './graph/graph-validation.js';
|
|
12
12
|
export { pikkuWorkflowWorkerFunc, pikkuWorkflowOrchestratorFunc, pikkuWorkflowSleeperFunc, } from './workflow-queue-workers.js';
|
|
13
13
|
export type { WorkflowStepInput as WorkflowStepQueueInput, PikkuWorkflowOrchestratorInput, PikkuWorkflowSleeperInput, } from './workflow-queue-workers.js';
|
|
14
|
-
export type { WorkflowService, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowRuntimeMeta, WorkflowsRuntimeMeta, WorkflowStepInput, WorkflowOrchestratorInput, WorkflowSleeperInput, } from './workflow.types.js';
|
|
14
|
+
export type { WorkflowService, WorkflowQueueOptions, WorkflowServiceConfig, WorkflowPlannedStep, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, StepStatus, WorkflowRun, WorkflowRunStatus, StepState, WorkflowRunService, WorkflowRunMirror, CoreWorkflow, PikkuWorkflow, ContextVariable, WorkflowContext, WorkflowsMeta, WorkflowRuntimeMeta, WorkflowsRuntimeMeta, WorkflowStepInput, WorkflowOrchestratorInput, WorkflowSleeperInput, } from './workflow.types.js';
|
|
15
15
|
export type { WorkflowStepOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './workflow.types.js';
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { SerializedError } from '../../types/core.types.js';
|
|
2
|
-
import type { ApprovalOutcome, PikkuScenarioWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowStepOptions } from './workflow.types.js';
|
|
2
|
+
import type { ApprovalOutcome, PikkuScenarioWire, StepState, StepStatus, WorkflowPlannedStep, WorkflowRun, WorkflowRunMirror, WorkflowRunStatus, WorkflowRunWire, WorkflowStatus, WorkflowVersionStatus, WorkflowQueueOptions, WorkflowStepOptions } from './workflow.types.js';
|
|
3
3
|
import type { WorkflowService } from '../../services/workflow-service.js';
|
|
4
4
|
import type { ScenarioActors } from '../../services/scenario-actors-service.js';
|
|
5
5
|
import { PikkuError } from '../../errors/error-handler.js';
|
|
6
6
|
import { type RunTimeline, type ReconstructedRunState } from './run-timeline.js';
|
|
7
|
-
import type { JobOptions } from '../queue/queue.types.js';
|
|
7
|
+
import type { GroupConcurrencyConfig, JobGroup, JobOptions } from '../queue/queue.types.js';
|
|
8
8
|
/**
|
|
9
9
|
* Default number of retries for a workflow step when none is specified. The
|
|
10
10
|
* workflow — not the queue — owns retry policy; a step inherits this unless it
|
|
@@ -98,10 +98,13 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
98
98
|
private runActors;
|
|
99
99
|
protected get logger(): import("../../services/logger.js").Logger;
|
|
100
100
|
protected mirror?: WorkflowRunMirror;
|
|
101
|
+
protected readonly queueStrategy: 'per-workflow' | 'shared-groups';
|
|
102
|
+
protected readonly queueConcurrency: number;
|
|
103
|
+
protected readonly queueGroupConcurrency: number | GroupConcurrencyConfig;
|
|
101
104
|
constructor(options?: {
|
|
102
105
|
wireQueues?: boolean;
|
|
103
106
|
mirror?: WorkflowRunMirror;
|
|
104
|
-
});
|
|
107
|
+
} & WorkflowQueueOptions);
|
|
105
108
|
private safeMirror;
|
|
106
109
|
/**
|
|
107
110
|
* Wire the queue-based orchestrator/step/sleeper workers.
|
|
@@ -519,4 +522,14 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
519
522
|
*/
|
|
520
523
|
protected getOrchestratorQueueName(workflowName?: string): string;
|
|
521
524
|
protected getStepWorkerQueueName(rpcName?: string): string;
|
|
525
|
+
/**
|
|
526
|
+
* Fairness key for a job on a shared queue. Under `'per-workflow'` the queue
|
|
527
|
+
* name already isolates workflows, so no group is needed — returning one
|
|
528
|
+
* anyway would cap a workflow inside its own dedicated queue.
|
|
529
|
+
*
|
|
530
|
+
* The tier repeats the id so a workflow can be given its own limit purely
|
|
531
|
+
* from config, with no per-workflow wiring; an unmatched tier falls back to
|
|
532
|
+
* the default limit.
|
|
533
|
+
*/
|
|
534
|
+
protected getJobGroup(id?: string): JobGroup | undefined;
|
|
522
535
|
}
|
|
@@ -186,9 +186,15 @@ export class PikkuWorkflowService {
|
|
|
186
186
|
return getSingletonServices()?.logger;
|
|
187
187
|
}
|
|
188
188
|
mirror;
|
|
189
|
+
queueStrategy;
|
|
190
|
+
queueConcurrency;
|
|
191
|
+
queueGroupConcurrency;
|
|
189
192
|
constructor(options = {}) {
|
|
190
193
|
const wireQueues = options.wireQueues ?? true;
|
|
191
194
|
this.mirror = options.mirror;
|
|
195
|
+
this.queueStrategy = options.queueStrategy ?? 'per-workflow';
|
|
196
|
+
this.queueConcurrency = options.queueConcurrency ?? 20;
|
|
197
|
+
this.queueGroupConcurrency = options.queueGroupConcurrency ?? 2;
|
|
192
198
|
if (wireQueues) {
|
|
193
199
|
this.wireQueueWorkers();
|
|
194
200
|
}
|
|
@@ -226,21 +232,32 @@ export class PikkuWorkflowService {
|
|
|
226
232
|
outputSchemaName: null,
|
|
227
233
|
});
|
|
228
234
|
const queueMeta = pikkuState(null, 'queue', 'meta');
|
|
229
|
-
const registerWorkflowFunc = (funcId, func, queueName) => {
|
|
235
|
+
const registerWorkflowFunc = (funcId, func, queueName, config) => {
|
|
230
236
|
if (functions.has(funcId))
|
|
231
237
|
return;
|
|
232
238
|
addFunction(funcId, func);
|
|
233
239
|
if (!queueMeta[queueName]) {
|
|
234
240
|
queueMeta[queueName] = { pikkuFuncId: funcId, name: queueName };
|
|
235
241
|
}
|
|
236
|
-
wireQueueWorker({ name: queueName, func });
|
|
242
|
+
wireQueueWorker({ name: queueName, func, config });
|
|
237
243
|
if (!functionsMeta[funcId]) {
|
|
238
244
|
functionsMeta[funcId] = mkMeta(funcId);
|
|
239
245
|
}
|
|
240
246
|
};
|
|
247
|
+
// Under 'shared-groups' every workflow runs through these two queues and is
|
|
248
|
+
// kept from hogging them by the per-group cap, so the per-workflow queues
|
|
249
|
+
// below are left unconsumed — one set of pollers for the whole system
|
|
250
|
+
// instead of one per workflow.
|
|
251
|
+
const sharedGroups = this.queueStrategy === 'shared-groups';
|
|
252
|
+
const sharedQueueConfig = sharedGroups
|
|
253
|
+
? {
|
|
254
|
+
batchSize: this.queueConcurrency,
|
|
255
|
+
groupConcurrency: this.queueGroupConcurrency,
|
|
256
|
+
}
|
|
257
|
+
: undefined;
|
|
241
258
|
// Register shared queue workers for monolith deployments
|
|
242
|
-
registerWorkflowFunc('pikkuWorkflowOrchestrator', { func: pikkuWorkflowOrchestratorFunc }, 'pikku-workflow-orchestrator');
|
|
243
|
-
registerWorkflowFunc('pikkuWorkflowStepWorker', { func: pikkuWorkflowWorkerFunc }, 'pikku-workflow-step-worker');
|
|
259
|
+
registerWorkflowFunc('pikkuWorkflowOrchestrator', { func: pikkuWorkflowOrchestratorFunc }, 'pikku-workflow-orchestrator', sharedQueueConfig);
|
|
260
|
+
registerWorkflowFunc('pikkuWorkflowStepWorker', { func: pikkuWorkflowWorkerFunc }, 'pikku-workflow-step-worker', sharedQueueConfig);
|
|
244
261
|
// Register per-workflow queue workers (root + addon packages)
|
|
245
262
|
const registerQueueWorkers = (queueMeta) => {
|
|
246
263
|
for (const [queueName, meta] of Object.entries(queueMeta)) {
|
|
@@ -254,16 +271,32 @@ export class PikkuWorkflowService {
|
|
|
254
271
|
}
|
|
255
272
|
}
|
|
256
273
|
};
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
274
|
+
if (!sharedGroups) {
|
|
275
|
+
registerQueueWorkers(pikkuState(null, 'queue', 'meta'));
|
|
276
|
+
const addons = pikkuState(null, 'addons', 'packages');
|
|
277
|
+
if (addons) {
|
|
278
|
+
for (const [, addon] of addons) {
|
|
279
|
+
const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta');
|
|
280
|
+
if (addonQueueMeta) {
|
|
281
|
+
registerQueueWorkers(addonQueueMeta);
|
|
282
|
+
}
|
|
264
283
|
}
|
|
265
284
|
}
|
|
266
285
|
}
|
|
286
|
+
// Workflows exist but no per-workflow orchestrator queue was registered:
|
|
287
|
+
// the generated queue meta never reached the runtime (most often the
|
|
288
|
+
// bootstrap doesn't import the queue-workers meta, so `queue.meta` is
|
|
289
|
+
// empty). Everything still "works" — dispatch silently falls back to the
|
|
290
|
+
// single shared orchestrator queue — but the isolation is gone: one slow
|
|
291
|
+
// workflow step head-of-line-blocks every other workflow behind it. That
|
|
292
|
+
// is invisible until a queue starves, so say so loudly at wiring time.
|
|
293
|
+
const workflowCount = Object.keys(pikkuState(null, 'workflows', 'meta') ?? {}).length;
|
|
294
|
+
const perWorkflowQueues = Object.keys(queueMeta).filter((name) => name.startsWith('wf-orchestrator-')).length;
|
|
295
|
+
if (workflowCount > 0 && perWorkflowQueues === 0) {
|
|
296
|
+
this.logger?.warn?.(`[pikku] ${workflowCount} workflow(s) registered but no per-workflow orchestrator queues were found in queue meta. ` +
|
|
297
|
+
`All workflows will share a single orchestrator queue, where one slow step blocks every other workflow behind it. ` +
|
|
298
|
+
`Check that the generated bootstrap imports the queue-workers meta (pikku-queue-workers-wirings-meta.gen.js).`);
|
|
299
|
+
}
|
|
267
300
|
if (!functions.has('pikkuWorkflowSleeper')) {
|
|
268
301
|
addFunction('pikkuWorkflowSleeper', {
|
|
269
302
|
func: pikkuWorkflowSleeperFunc,
|
|
@@ -513,7 +546,10 @@ export class PikkuWorkflowService {
|
|
|
513
546
|
// dispatch/infra failure recover: the job is rethrown and retried instead of
|
|
514
547
|
// the run hanging. Passing `attempts` per-job overrides the queue default, so
|
|
515
548
|
// this holds even when the orchestrator queue is configured `retry_limit 0`.
|
|
516
|
-
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId },
|
|
549
|
+
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, {
|
|
550
|
+
...this.resolveStepJobOptions(),
|
|
551
|
+
group: this.getJobGroup(workflowName),
|
|
552
|
+
});
|
|
517
553
|
}
|
|
518
554
|
/**
|
|
519
555
|
* Resolve a step's retry policy into queue job options. The workflow is the
|
|
@@ -538,7 +574,12 @@ export class PikkuWorkflowService {
|
|
|
538
574
|
}
|
|
539
575
|
async queueStepWorker(runId, stepName, rpcName, data, stepOptions, fromStepName) {
|
|
540
576
|
const queueService = this.verifyQueueService();
|
|
541
|
-
await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })),
|
|
577
|
+
await queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), {
|
|
578
|
+
...this.resolveStepJobOptions(stepOptions),
|
|
579
|
+
// Group by step function, mirroring how per-step queues split them —
|
|
580
|
+
// one slow step function can't monopolise the shared step worker.
|
|
581
|
+
group: this.getJobGroup(rpcName),
|
|
582
|
+
});
|
|
542
583
|
}
|
|
543
584
|
/**
|
|
544
585
|
* Execute a workflow sleep step completion
|
|
@@ -560,7 +601,12 @@ export class PikkuWorkflowService {
|
|
|
560
601
|
const run = await this.getRun(runId);
|
|
561
602
|
workflowName = run?.workflow;
|
|
562
603
|
}
|
|
563
|
-
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId },
|
|
604
|
+
await queueService.add(this.getOrchestratorQueueName(workflowName), { runId }, {
|
|
605
|
+
...(retryDelay
|
|
606
|
+
? { delay: getDurationInMilliseconds(retryDelay) }
|
|
607
|
+
: undefined),
|
|
608
|
+
group: this.getJobGroup(workflowName),
|
|
609
|
+
});
|
|
564
610
|
}
|
|
565
611
|
/**
|
|
566
612
|
* Dispatch a workflow step to be executed asynchronously.
|
|
@@ -592,7 +638,10 @@ export class PikkuWorkflowService {
|
|
|
592
638
|
throw new Error(`Workflow step '${stepName}' (function '${rpcName}') is marked 'workflowQueued: true' but no queue service is configured.`);
|
|
593
639
|
}
|
|
594
640
|
try {
|
|
595
|
-
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })),
|
|
641
|
+
await getSingletonServices().queueService.add(this.getStepWorkerQueueName(rpcName), JSON.parse(JSON.stringify({ runId, stepName, rpcName, data, fromStepName })), {
|
|
642
|
+
...this.resolveStepJobOptions(stepOptions),
|
|
643
|
+
group: this.getJobGroup(rpcName),
|
|
644
|
+
});
|
|
596
645
|
}
|
|
597
646
|
catch (cause) {
|
|
598
647
|
// The queue is down/unreachable — NOT a step failure. Surface it as a
|
|
@@ -1430,7 +1479,11 @@ export class PikkuWorkflowService {
|
|
|
1430
1479
|
const run = await this.getRun(runId);
|
|
1431
1480
|
if (!run?.workflow)
|
|
1432
1481
|
return;
|
|
1433
|
-
await queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, {
|
|
1482
|
+
await queueService.add(this.getOrchestratorQueueName(run.workflow), { runId }, {
|
|
1483
|
+
...this.resolveStepJobOptions(),
|
|
1484
|
+
delay,
|
|
1485
|
+
group: this.getJobGroup(run.workflow),
|
|
1486
|
+
});
|
|
1434
1487
|
}
|
|
1435
1488
|
catch (error) {
|
|
1436
1489
|
this.logger?.warn(`Failed to schedule approval expiry wake for run ${runId}; expiry will still resolve on the next replay`, error);
|
|
@@ -1712,7 +1765,7 @@ export class PikkuWorkflowService {
|
|
|
1712
1765
|
* queues — but it produces to them — so registrations would miss them.
|
|
1713
1766
|
*/
|
|
1714
1767
|
getOrchestratorQueueName(workflowName) {
|
|
1715
|
-
if (workflowName) {
|
|
1768
|
+
if (workflowName && this.queueStrategy !== 'shared-groups') {
|
|
1716
1769
|
const perWorkflow = `wf-orchestrator-${toKebab(workflowName)}`;
|
|
1717
1770
|
const meta = pikkuState(null, 'queue', 'meta');
|
|
1718
1771
|
if (meta[perWorkflow]) {
|
|
@@ -1722,7 +1775,7 @@ export class PikkuWorkflowService {
|
|
|
1722
1775
|
return this.getConfig().orchestratorQueueName;
|
|
1723
1776
|
}
|
|
1724
1777
|
getStepWorkerQueueName(rpcName) {
|
|
1725
|
-
if (rpcName) {
|
|
1778
|
+
if (rpcName && this.queueStrategy !== 'shared-groups') {
|
|
1726
1779
|
const perStep = `wf-step-${toKebab(rpcName)}`;
|
|
1727
1780
|
const meta = pikkuState(null, 'queue', 'meta');
|
|
1728
1781
|
if (meta[perStep]) {
|
|
@@ -1731,4 +1784,19 @@ export class PikkuWorkflowService {
|
|
|
1731
1784
|
}
|
|
1732
1785
|
return this.getConfig().stepWorkerQueueName;
|
|
1733
1786
|
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Fairness key for a job on a shared queue. Under `'per-workflow'` the queue
|
|
1789
|
+
* name already isolates workflows, so no group is needed — returning one
|
|
1790
|
+
* anyway would cap a workflow inside its own dedicated queue.
|
|
1791
|
+
*
|
|
1792
|
+
* The tier repeats the id so a workflow can be given its own limit purely
|
|
1793
|
+
* from config, with no per-workflow wiring; an unmatched tier falls back to
|
|
1794
|
+
* the default limit.
|
|
1795
|
+
*/
|
|
1796
|
+
getJobGroup(id) {
|
|
1797
|
+
if (!id || this.queueStrategy !== 'shared-groups') {
|
|
1798
|
+
return undefined;
|
|
1799
|
+
}
|
|
1800
|
+
return { id, tier: id };
|
|
1801
|
+
}
|
|
1734
1802
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SerializedError, CommonWireMeta } from '../../types/core.types.js';
|
|
2
2
|
import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
|
|
3
|
+
import type { GroupConcurrencyConfig } from '../queue/queue.types.js';
|
|
3
4
|
export type { WorkflowService } from '../../services/workflow-service.js';
|
|
4
5
|
export type { WorkflowStepOptions, WorkflowExpectEventuallyOptions, WorkflowExpectErrorOptions, WorkflowExpectServiceOptions, WorkflowWireDoRPC, WorkflowWireDoInline, WorkflowWireSleep, WorkflowWireSuspend, WorkflowWireApproval, WorkflowApprovalOptions, ApprovalOutcome, InputSource, OutputBinding, RpcStepMeta, SimpleCondition, Condition, BranchCase, BranchStepMeta, ParallelGroupStepMeta, FanoutStepMeta, ReturnStepMeta, InlineStepMeta, SleepStepMeta, CancelStepMeta, SuspendStepMeta, ApprovalStepMeta, SetStepMeta, SwitchCaseMeta, SwitchStepMeta, FilterStepMeta, ArrayPredicateStepMeta, WorkflowStepMeta, WorkflowStepWire, PikkuWorkflowWire, PikkuScenarioWire, } from './dsl/workflow-dsl.types.js';
|
|
5
6
|
import type { WorkflowStepMeta } from './dsl/workflow-dsl.types.js';
|
|
@@ -18,6 +19,43 @@ export interface WorkflowServiceConfig {
|
|
|
18
19
|
stepWorkerQueueName: string;
|
|
19
20
|
sleeperRPCName: string;
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* How a workflow service spreads its jobs across queues.
|
|
24
|
+
*
|
|
25
|
+
* Passed to the service constructor rather than read from `config.workflow`:
|
|
26
|
+
* the queues are wired during construction, before singleton services (and so
|
|
27
|
+
* before `config`) exist.
|
|
28
|
+
*/
|
|
29
|
+
export interface WorkflowQueueOptions {
|
|
30
|
+
/**
|
|
31
|
+
* - `'per-workflow'` (default) — each workflow gets its own
|
|
32
|
+
* `wf-orchestrator-*` / `wf-step-*` queue. Complete isolation, and it's
|
|
33
|
+
* what lets serverless providers deploy one unit per workflow. Costs one
|
|
34
|
+
* set of pollers per queue, which adds up on pull-based backends.
|
|
35
|
+
* - `'shared-groups'` — every workflow shares the orchestrator/step-worker
|
|
36
|
+
* queues and stays isolated via {@link queueGroupConcurrency}, so no
|
|
37
|
+
* workflow can occupy more than its share. One set of pollers total.
|
|
38
|
+
* Only for single-process (monolith) runtimes; a per-unit serverless
|
|
39
|
+
* deploy needs the per-workflow queues to route to its units.
|
|
40
|
+
*/
|
|
41
|
+
queueStrategy?: 'per-workflow' | 'shared-groups';
|
|
42
|
+
/**
|
|
43
|
+
* Total concurrent workflow jobs per node under `'shared-groups'`.
|
|
44
|
+
* Defaults to 20.
|
|
45
|
+
*/
|
|
46
|
+
queueConcurrency?: number;
|
|
47
|
+
/**
|
|
48
|
+
* How many jobs of one workflow may run at once under `'shared-groups'`.
|
|
49
|
+
* Defaults to 2. Tiers are keyed by workflow name, so a specific workflow
|
|
50
|
+
* can be given its own limit without any extra wiring.
|
|
51
|
+
*
|
|
52
|
+
* Note tiers can only lower a workflow's limit, not raise it above
|
|
53
|
+
* `default`: the backend's pre-fetch exclusion is applied per group using
|
|
54
|
+
* `default` alone. To give one workflow more room, raise `default` and
|
|
55
|
+
* restrict the others by tier.
|
|
56
|
+
*/
|
|
57
|
+
queueGroupConcurrency?: number | GroupConcurrencyConfig;
|
|
58
|
+
}
|
|
21
59
|
export interface WorkflowPlannedStep {
|
|
22
60
|
/** Durable step key — matches the runtime step name stored in the DB */
|
|
23
61
|
stepName: string;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@ import { describe, test, beforeEach } from 'node:test'
|
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
import { InMemoryWorkflowService } from './in-memory-workflow-service.js'
|
|
4
4
|
import { getQueueWorkers } from '../wirings/queue/queue-runner.js'
|
|
5
|
-
import { pikkuState } from '../pikku-state.js'
|
|
5
|
+
import { pikkuState, resetPikkuState } from '../pikku-state.js'
|
|
6
6
|
|
|
7
7
|
let service: InMemoryWorkflowService
|
|
8
8
|
|
|
@@ -390,5 +390,54 @@ describe('InMemoryWorkflowService', () => {
|
|
|
390
390
|
assert.strictEqual(getQueueWorkers().has(stepQueue), true)
|
|
391
391
|
assert.strictEqual(getQueueWorkers().has(orchQueue), true)
|
|
392
392
|
})
|
|
393
|
+
|
|
394
|
+
test('shared-groups leaves per-workflow queues unconsumed', () => {
|
|
395
|
+
const orchQueue = 'wf-orchestrator-shared-strategy-check'
|
|
396
|
+
|
|
397
|
+
const queueMeta = pikkuState(null, 'queue', 'meta')
|
|
398
|
+
queueMeta[orchQueue] = {
|
|
399
|
+
name: orchQueue,
|
|
400
|
+
pikkuFuncId: 'pikkuWorkflowOrchestrator:sharedStrategyCheck',
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const ws = new InMemoryWorkflowService({
|
|
404
|
+
queueStrategy: 'shared-groups',
|
|
405
|
+
})
|
|
406
|
+
ws.wireQueueWorkers()
|
|
407
|
+
|
|
408
|
+
// The whole point: one set of pollers, not one per workflow.
|
|
409
|
+
assert.strictEqual(getQueueWorkers().has(orchQueue), false)
|
|
410
|
+
assert.strictEqual(
|
|
411
|
+
getQueueWorkers().has('pikku-workflow-orchestrator'),
|
|
412
|
+
true
|
|
413
|
+
)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('shared-groups caps how much of the shared queue one workflow takes', () => {
|
|
417
|
+
// registerWorkflowFunc is first-write-wins, so start from clean state
|
|
418
|
+
// rather than inheriting the shared queues an earlier test registered.
|
|
419
|
+
resetPikkuState()
|
|
420
|
+
const ws = new InMemoryWorkflowService({
|
|
421
|
+
queueStrategy: 'shared-groups',
|
|
422
|
+
queueConcurrency: 20,
|
|
423
|
+
queueGroupConcurrency: 3,
|
|
424
|
+
})
|
|
425
|
+
ws.wireQueueWorkers()
|
|
426
|
+
|
|
427
|
+
const worker = getQueueWorkers().get('pikku-workflow-orchestrator')
|
|
428
|
+
assert.deepStrictEqual(worker?.config, {
|
|
429
|
+
batchSize: 20,
|
|
430
|
+
groupConcurrency: 3,
|
|
431
|
+
})
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
test('per-workflow (the default) leaves the shared queues unconstrained', () => {
|
|
435
|
+
resetPikkuState()
|
|
436
|
+
const ws = new InMemoryWorkflowService()
|
|
437
|
+
ws.wireQueueWorkers()
|
|
438
|
+
|
|
439
|
+
const worker = getQueueWorkers().get('pikku-workflow-orchestrator')
|
|
440
|
+
assert.strictEqual(worker?.config, undefined)
|
|
441
|
+
})
|
|
393
442
|
})
|
|
394
443
|
})
|
|
@@ -4,6 +4,7 @@ import { isExpectedError } from '../errors/error-handler.js'
|
|
|
4
4
|
import type { SerializedError } from '../types/core.types.js'
|
|
5
5
|
import type {
|
|
6
6
|
WorkflowPlannedStep,
|
|
7
|
+
WorkflowQueueOptions,
|
|
7
8
|
WorkflowRun,
|
|
8
9
|
WorkflowRunService,
|
|
9
10
|
WorkflowRunWire,
|
|
@@ -39,8 +40,8 @@ export class InMemoryWorkflowService
|
|
|
39
40
|
extends PikkuWorkflowService
|
|
40
41
|
implements WorkflowRunService
|
|
41
42
|
{
|
|
42
|
-
constructor() {
|
|
43
|
-
super({ wireQueues: false })
|
|
43
|
+
constructor(options: WorkflowQueueOptions = {}) {
|
|
44
|
+
super({ ...options, wireQueues: false })
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
private sleepTimers = new Set<ReturnType<typeof setTimeout>>()
|