@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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
assertResourceOwner,
|
|
8
8
|
canAccessThread,
|
|
9
9
|
buildInstructions,
|
|
10
|
+
buildSubAgentRunInput,
|
|
10
11
|
buildToolDefs,
|
|
11
12
|
createScopedChannel,
|
|
12
13
|
getAddonCredentialRequirements,
|
|
@@ -775,3 +776,31 @@ describe('C2 sessionScope + resume ownership', () => {
|
|
|
775
776
|
)
|
|
776
777
|
})
|
|
777
778
|
})
|
|
779
|
+
|
|
780
|
+
describe('buildSubAgentRunInput (parent context forwarding)', () => {
|
|
781
|
+
// A delegated sub-agent's tool-call schema only carries { message, session }.
|
|
782
|
+
// If its run input does not inherit the parent's `context` (the identifier
|
|
783
|
+
// block with organizationId / project ids), the sub-agent never sees the
|
|
784
|
+
// authoritative ids and depends on the model re-typing them into `message` —
|
|
785
|
+
// which weak models botch, causing schema/permission rejections and retry
|
|
786
|
+
// loops. These pin that the parent context is always forwarded.
|
|
787
|
+
test('forwards the parent context onto the sub-agent run input', () => {
|
|
788
|
+
const input = buildSubAgentRunInput(
|
|
789
|
+
'find failing functions',
|
|
790
|
+
'thread-1',
|
|
791
|
+
'org-uuid',
|
|
792
|
+
'organizationId: 11111111-1111-1111-1111-111111111111'
|
|
793
|
+
)
|
|
794
|
+
assert.deepEqual(input, {
|
|
795
|
+
message: 'find failing functions',
|
|
796
|
+
threadId: 'thread-1',
|
|
797
|
+
resourceId: 'org-uuid',
|
|
798
|
+
context: 'organizationId: 11111111-1111-1111-1111-111111111111',
|
|
799
|
+
})
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
test('context is undefined when the parent run had none (root agent)', () => {
|
|
803
|
+
const input = buildSubAgentRunInput('hi', 'thread-1', 'res-1')
|
|
804
|
+
assert.equal(input.context, undefined)
|
|
805
|
+
})
|
|
806
|
+
})
|
|
@@ -476,6 +476,26 @@ export function createScopedChannel(
|
|
|
476
476
|
}
|
|
477
477
|
}
|
|
478
478
|
|
|
479
|
+
/**
|
|
480
|
+
* Build the run input for a delegated sub-agent.
|
|
481
|
+
*
|
|
482
|
+
* `context` is the PARENT run's identifier block (the "Current context" text
|
|
483
|
+
* with organizationId, project/stage ids). A sub-agent's tool-call schema only
|
|
484
|
+
* carries { message, session }, so unless the sub-agent inherits the parent's
|
|
485
|
+
* context it never sees the authoritative ids — it depends on the model
|
|
486
|
+
* re-typing them into `message`, which weak models botch, causing
|
|
487
|
+
* schema/permission rejections and retry loops. Forwarding it here is the
|
|
488
|
+
* regression this seam guards.
|
|
489
|
+
*/
|
|
490
|
+
export function buildSubAgentRunInput(
|
|
491
|
+
message: string,
|
|
492
|
+
threadId: string,
|
|
493
|
+
resourceId: string,
|
|
494
|
+
parentContext?: string
|
|
495
|
+
): { message: string; threadId: string; resourceId: string; context?: string } {
|
|
496
|
+
return { message, threadId, resourceId, context: parentContext }
|
|
497
|
+
}
|
|
498
|
+
|
|
479
499
|
export async function buildToolDefs(
|
|
480
500
|
params: RunAIAgentParams,
|
|
481
501
|
agentSessionMap: Map<string, string>,
|
|
@@ -484,7 +504,15 @@ export async function buildToolDefs(
|
|
|
484
504
|
packageName: string | null,
|
|
485
505
|
streamContext?: StreamContext,
|
|
486
506
|
aiMiddlewares?: PikkuAIMiddlewareHooks[],
|
|
487
|
-
agentMode?: 'delegate' | 'supervise'
|
|
507
|
+
agentMode?: 'delegate' | 'supervise',
|
|
508
|
+
// The parent run's `context` (the "Current context" identifier block). A
|
|
509
|
+
// delegated sub-agent's tool-call input schema only carries { message,
|
|
510
|
+
// session }, so without inheriting this the sub-agent never sees the
|
|
511
|
+
// authoritative ids (organizationId, project/stage ids) — it depends on the
|
|
512
|
+
// model re-typing them into `message`, which weak models botch, causing
|
|
513
|
+
// schema/permission rejections and retry loops. Forward it so the sub-agent
|
|
514
|
+
// gets the same context block in its instructions.
|
|
515
|
+
parentContext?: string
|
|
488
516
|
): Promise<{ tools: AIAgentToolDef[]; missingRpcs: string[] }> {
|
|
489
517
|
const singletonServices = getSingletonServices()
|
|
490
518
|
const tools: AIAgentToolDef[] = []
|
|
@@ -716,7 +744,12 @@ export async function buildToolDefs(
|
|
|
716
744
|
}
|
|
717
745
|
const resultText = await streamAIAgent(
|
|
718
746
|
subAgentName,
|
|
719
|
-
|
|
747
|
+
buildSubAgentRunInput(
|
|
748
|
+
message,
|
|
749
|
+
threadId,
|
|
750
|
+
resourceId,
|
|
751
|
+
parentContext
|
|
752
|
+
),
|
|
720
753
|
effectiveChannel,
|
|
721
754
|
params,
|
|
722
755
|
agentSessionMap,
|
|
@@ -744,7 +777,7 @@ export async function buildToolDefs(
|
|
|
744
777
|
// No stream context: sub-agent runs non-streaming
|
|
745
778
|
const result = await runAIAgent(
|
|
746
779
|
subAgentName,
|
|
747
|
-
|
|
780
|
+
buildSubAgentRunInput(message, threadId, resourceId, parentContext),
|
|
748
781
|
params,
|
|
749
782
|
agentSessionMap
|
|
750
783
|
)
|
|
@@ -1017,7 +1050,8 @@ export async function prepareAgentRun(
|
|
|
1017
1050
|
packageName,
|
|
1018
1051
|
streamContext,
|
|
1019
1052
|
aiMiddlewares,
|
|
1020
|
-
agent.agentMode
|
|
1053
|
+
agent.agentMode,
|
|
1054
|
+
input.context
|
|
1021
1055
|
)
|
|
1022
1056
|
|
|
1023
1057
|
let instructions = await buildInstructions(resolvedName, packageName)
|
|
@@ -31,6 +31,36 @@ export interface PikkuWorkerConfig {
|
|
|
31
31
|
maxStalledCount?: number
|
|
32
32
|
/** Condition to start processor at instance creation */
|
|
33
33
|
autorun?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* Cap how many jobs of any one group ({@link JobOptions.group}) may run at
|
|
36
|
+
* once, so a single group can't occupy the whole worker. Lets one shared
|
|
37
|
+
* queue stay fair across producers instead of splitting it into one queue
|
|
38
|
+
* per producer — which multiplies polling cost on pull-based backends.
|
|
39
|
+
* Must not exceed {@link batchSize}.
|
|
40
|
+
*/
|
|
41
|
+
groupConcurrency?: number | GroupConcurrencyConfig
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Per-group concurrency limits, optionally varied by tier so slow groups can
|
|
46
|
+
* be allowed more (or fewer) slots than the default.
|
|
47
|
+
*/
|
|
48
|
+
export interface GroupConcurrencyConfig {
|
|
49
|
+
/** Limit applied to any group without a matching tier */
|
|
50
|
+
default: number
|
|
51
|
+
/** Per-tier overrides, keyed by {@link JobGroup.tier} */
|
|
52
|
+
tiers?: Record<string, number>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Fairness key for a job. Jobs sharing an `id` count against the same
|
|
57
|
+
* {@link PikkuWorkerConfig.groupConcurrency} limit.
|
|
58
|
+
*/
|
|
59
|
+
export interface JobGroup {
|
|
60
|
+
/** Group this job belongs to (e.g. a workflow name) */
|
|
61
|
+
id: string
|
|
62
|
+
/** Optional tier selecting a per-tier limit */
|
|
63
|
+
tier?: string
|
|
34
64
|
}
|
|
35
65
|
|
|
36
66
|
/**
|
|
@@ -113,6 +143,8 @@ export interface JobOptions {
|
|
|
113
143
|
jobId?: string
|
|
114
144
|
/** Pikku user ID to propagate to the queue worker for credential resolution */
|
|
115
145
|
pikkuUserId?: string
|
|
146
|
+
/** Fairness key — counts against the worker's {@link PikkuWorkerConfig.groupConcurrency} */
|
|
147
|
+
group?: JobGroup
|
|
116
148
|
}
|
|
117
149
|
|
|
118
150
|
/**
|
|
@@ -29,6 +29,77 @@ describe('pikku-workflow-service worker registration', () => {
|
|
|
29
29
|
})
|
|
30
30
|
})
|
|
31
31
|
|
|
32
|
+
describe('pikku-workflow-service per-workflow queue warning', () => {
|
|
33
|
+
// Losing the per-workflow orchestrator queues is silent — dispatch just falls
|
|
34
|
+
// back to the single shared queue and one slow step starves every workflow
|
|
35
|
+
// behind it. These assert the misconfiguration is announced at wiring time.
|
|
36
|
+
const setup = (workflowNames: string[], perWorkflowQueues: string[] = []) => {
|
|
37
|
+
resetPikkuState()
|
|
38
|
+
const warnings: string[] = []
|
|
39
|
+
pikkuState(null, 'package', 'singletonServices', {
|
|
40
|
+
logger: {
|
|
41
|
+
error: () => {},
|
|
42
|
+
info: () => {},
|
|
43
|
+
debug: () => {},
|
|
44
|
+
warn: (message: string) => warnings.push(message),
|
|
45
|
+
},
|
|
46
|
+
} as any)
|
|
47
|
+
|
|
48
|
+
const metaState = pikkuState(null, 'workflows', 'meta')
|
|
49
|
+
for (const name of workflowNames) {
|
|
50
|
+
metaState[name] = {
|
|
51
|
+
name,
|
|
52
|
+
pikkuFuncId: name,
|
|
53
|
+
source: 'dsl',
|
|
54
|
+
graphHash: `${name}-hash`,
|
|
55
|
+
} as any
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const queueMeta = pikkuState(null, 'queue', 'meta')
|
|
59
|
+
for (const queueName of perWorkflowQueues) {
|
|
60
|
+
queueMeta[queueName] = {
|
|
61
|
+
pikkuFuncId: queueName,
|
|
62
|
+
name: queueName,
|
|
63
|
+
} as any
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return warnings
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
test('warns when workflows exist but no per-workflow orchestrator queue is registered', () => {
|
|
70
|
+
const warnings = setup(['someWorkflow'])
|
|
71
|
+
|
|
72
|
+
new InMemoryWorkflowService().wireQueueWorkers()
|
|
73
|
+
|
|
74
|
+
assert.ok(
|
|
75
|
+
warnings.some((w) => /no per-workflow orchestrator queues/.test(w)),
|
|
76
|
+
`expected a per-workflow queue warning, got: ${JSON.stringify(warnings)}`
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('does not warn when per-workflow orchestrator queues are present', () => {
|
|
81
|
+
const warnings = setup(['someWorkflow'], ['wf-orchestrator-some-workflow'])
|
|
82
|
+
|
|
83
|
+
new InMemoryWorkflowService().wireQueueWorkers()
|
|
84
|
+
|
|
85
|
+
assert.ok(
|
|
86
|
+
!warnings.some((w) => /no per-workflow orchestrator queues/.test(w)),
|
|
87
|
+
`expected no warning, got: ${JSON.stringify(warnings)}`
|
|
88
|
+
)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
test('does not warn when the app registers no workflows at all', () => {
|
|
92
|
+
const warnings = setup([])
|
|
93
|
+
|
|
94
|
+
new InMemoryWorkflowService().wireQueueWorkers()
|
|
95
|
+
|
|
96
|
+
assert.ok(
|
|
97
|
+
!warnings.some((w) => /no per-workflow orchestrator queues/.test(w)),
|
|
98
|
+
`expected no warning, got: ${JSON.stringify(warnings)}`
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
32
103
|
describe('pikku-workflow-service run-level inline', () => {
|
|
33
104
|
test('workflow runs inline (and queues nothing) when no queue service is configured', async () => {
|
|
34
105
|
const ws = new InMemoryWorkflowService()
|
|
@@ -60,6 +60,7 @@ import type {
|
|
|
60
60
|
WorkflowRunWire,
|
|
61
61
|
WorkflowStatus,
|
|
62
62
|
WorkflowVersionStatus,
|
|
63
|
+
WorkflowQueueOptions,
|
|
63
64
|
WorkflowServiceConfig,
|
|
64
65
|
WorkflowStepOptions,
|
|
65
66
|
WorkflowExpectEventuallyOptions,
|
|
@@ -88,7 +89,12 @@ import {
|
|
|
88
89
|
type RunTimeline,
|
|
89
90
|
type ReconstructedRunState,
|
|
90
91
|
} from './run-timeline.js'
|
|
91
|
-
import type {
|
|
92
|
+
import type {
|
|
93
|
+
GroupConcurrencyConfig,
|
|
94
|
+
JobGroup,
|
|
95
|
+
JobOptions,
|
|
96
|
+
PikkuWorkerConfig,
|
|
97
|
+
} from '../queue/queue.types.js'
|
|
92
98
|
|
|
93
99
|
/**
|
|
94
100
|
* Default number of retries for a workflow step when none is specified. The
|
|
@@ -257,11 +263,21 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
257
263
|
|
|
258
264
|
protected mirror?: WorkflowRunMirror
|
|
259
265
|
|
|
266
|
+
protected readonly queueStrategy: 'per-workflow' | 'shared-groups'
|
|
267
|
+
protected readonly queueConcurrency: number
|
|
268
|
+
protected readonly queueGroupConcurrency: number | GroupConcurrencyConfig
|
|
269
|
+
|
|
260
270
|
constructor(
|
|
261
|
-
options: {
|
|
271
|
+
options: {
|
|
272
|
+
wireQueues?: boolean
|
|
273
|
+
mirror?: WorkflowRunMirror
|
|
274
|
+
} & WorkflowQueueOptions = {}
|
|
262
275
|
) {
|
|
263
276
|
const wireQueues = options.wireQueues ?? true
|
|
264
277
|
this.mirror = options.mirror
|
|
278
|
+
this.queueStrategy = options.queueStrategy ?? 'per-workflow'
|
|
279
|
+
this.queueConcurrency = options.queueConcurrency ?? 20
|
|
280
|
+
this.queueGroupConcurrency = options.queueGroupConcurrency ?? 2
|
|
265
281
|
if (wireQueues) {
|
|
266
282
|
this.wireQueueWorkers()
|
|
267
283
|
}
|
|
@@ -306,29 +322,44 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
306
322
|
const registerWorkflowFunc = (
|
|
307
323
|
funcId: string,
|
|
308
324
|
func: { func: unknown },
|
|
309
|
-
queueName: string
|
|
325
|
+
queueName: string,
|
|
326
|
+
config?: PikkuWorkerConfig
|
|
310
327
|
) => {
|
|
311
328
|
if (functions.has(funcId)) return
|
|
312
329
|
addFunction(funcId, func as never)
|
|
313
330
|
if (!queueMeta[queueName]) {
|
|
314
331
|
queueMeta[queueName] = { pikkuFuncId: funcId, name: queueName }
|
|
315
332
|
}
|
|
316
|
-
wireQueueWorker({ name: queueName, func } as never)
|
|
333
|
+
wireQueueWorker({ name: queueName, func, config } as never)
|
|
317
334
|
if (!functionsMeta[funcId]) {
|
|
318
335
|
functionsMeta[funcId] = mkMeta(funcId)
|
|
319
336
|
}
|
|
320
337
|
}
|
|
321
338
|
|
|
339
|
+
// Under 'shared-groups' every workflow runs through these two queues and is
|
|
340
|
+
// kept from hogging them by the per-group cap, so the per-workflow queues
|
|
341
|
+
// below are left unconsumed — one set of pollers for the whole system
|
|
342
|
+
// instead of one per workflow.
|
|
343
|
+
const sharedGroups = this.queueStrategy === 'shared-groups'
|
|
344
|
+
const sharedQueueConfig: PikkuWorkerConfig | undefined = sharedGroups
|
|
345
|
+
? {
|
|
346
|
+
batchSize: this.queueConcurrency,
|
|
347
|
+
groupConcurrency: this.queueGroupConcurrency,
|
|
348
|
+
}
|
|
349
|
+
: undefined
|
|
350
|
+
|
|
322
351
|
// Register shared queue workers for monolith deployments
|
|
323
352
|
registerWorkflowFunc(
|
|
324
353
|
'pikkuWorkflowOrchestrator',
|
|
325
354
|
{ func: pikkuWorkflowOrchestratorFunc },
|
|
326
|
-
'pikku-workflow-orchestrator'
|
|
355
|
+
'pikku-workflow-orchestrator',
|
|
356
|
+
sharedQueueConfig
|
|
327
357
|
)
|
|
328
358
|
registerWorkflowFunc(
|
|
329
359
|
'pikkuWorkflowStepWorker',
|
|
330
360
|
{ func: pikkuWorkflowWorkerFunc },
|
|
331
|
-
'pikku-workflow-step-worker'
|
|
361
|
+
'pikku-workflow-step-worker',
|
|
362
|
+
sharedQueueConfig
|
|
332
363
|
)
|
|
333
364
|
|
|
334
365
|
// Register per-workflow queue workers (root + addon packages)
|
|
@@ -351,18 +382,41 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
351
382
|
}
|
|
352
383
|
}
|
|
353
384
|
|
|
354
|
-
|
|
385
|
+
if (!sharedGroups) {
|
|
386
|
+
registerQueueWorkers(pikkuState(null, 'queue', 'meta'))
|
|
355
387
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
388
|
+
const addons = pikkuState(null, 'addons', 'packages')
|
|
389
|
+
if (addons) {
|
|
390
|
+
for (const [, addon] of addons) {
|
|
391
|
+
const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta')
|
|
392
|
+
if (addonQueueMeta) {
|
|
393
|
+
registerQueueWorkers(addonQueueMeta)
|
|
394
|
+
}
|
|
362
395
|
}
|
|
363
396
|
}
|
|
364
397
|
}
|
|
365
398
|
|
|
399
|
+
// Workflows exist but no per-workflow orchestrator queue was registered:
|
|
400
|
+
// the generated queue meta never reached the runtime (most often the
|
|
401
|
+
// bootstrap doesn't import the queue-workers meta, so `queue.meta` is
|
|
402
|
+
// empty). Everything still "works" — dispatch silently falls back to the
|
|
403
|
+
// single shared orchestrator queue — but the isolation is gone: one slow
|
|
404
|
+
// workflow step head-of-line-blocks every other workflow behind it. That
|
|
405
|
+
// is invisible until a queue starves, so say so loudly at wiring time.
|
|
406
|
+
const workflowCount = Object.keys(
|
|
407
|
+
pikkuState(null, 'workflows', 'meta') ?? {}
|
|
408
|
+
).length
|
|
409
|
+
const perWorkflowQueues = Object.keys(queueMeta).filter((name) =>
|
|
410
|
+
name.startsWith('wf-orchestrator-')
|
|
411
|
+
).length
|
|
412
|
+
if (workflowCount > 0 && perWorkflowQueues === 0) {
|
|
413
|
+
this.logger?.warn?.(
|
|
414
|
+
`[pikku] ${workflowCount} workflow(s) registered but no per-workflow orchestrator queues were found in queue meta. ` +
|
|
415
|
+
`All workflows will share a single orchestrator queue, where one slow step blocks every other workflow behind it. ` +
|
|
416
|
+
`Check that the generated bootstrap imports the queue-workers meta (pikku-queue-workers-wirings-meta.gen.js).`
|
|
417
|
+
)
|
|
418
|
+
}
|
|
419
|
+
|
|
366
420
|
if (!functions.has('pikkuWorkflowSleeper')) {
|
|
367
421
|
addFunction('pikkuWorkflowSleeper', {
|
|
368
422
|
func: pikkuWorkflowSleeperFunc,
|
|
@@ -927,7 +981,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
927
981
|
await queueService.add(
|
|
928
982
|
this.getOrchestratorQueueName(workflowName),
|
|
929
983
|
{ runId },
|
|
930
|
-
|
|
984
|
+
{
|
|
985
|
+
...this.resolveStepJobOptions(),
|
|
986
|
+
group: this.getJobGroup(workflowName),
|
|
987
|
+
}
|
|
931
988
|
)
|
|
932
989
|
}
|
|
933
990
|
|
|
@@ -970,7 +1027,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
970
1027
|
JSON.parse(
|
|
971
1028
|
JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
|
|
972
1029
|
),
|
|
973
|
-
|
|
1030
|
+
{
|
|
1031
|
+
...this.resolveStepJobOptions(stepOptions),
|
|
1032
|
+
// Group by step function, mirroring how per-step queues split them —
|
|
1033
|
+
// one slow step function can't monopolise the shared step worker.
|
|
1034
|
+
group: this.getJobGroup(rpcName),
|
|
1035
|
+
}
|
|
974
1036
|
)
|
|
975
1037
|
}
|
|
976
1038
|
|
|
@@ -1005,7 +1067,12 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1005
1067
|
await queueService.add(
|
|
1006
1068
|
this.getOrchestratorQueueName(workflowName),
|
|
1007
1069
|
{ runId },
|
|
1008
|
-
|
|
1070
|
+
{
|
|
1071
|
+
...(retryDelay
|
|
1072
|
+
? { delay: getDurationInMilliseconds(retryDelay) }
|
|
1073
|
+
: undefined),
|
|
1074
|
+
group: this.getJobGroup(workflowName),
|
|
1075
|
+
}
|
|
1009
1076
|
)
|
|
1010
1077
|
}
|
|
1011
1078
|
|
|
@@ -1054,7 +1121,10 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
1054
1121
|
JSON.parse(
|
|
1055
1122
|
JSON.stringify({ runId, stepName, rpcName, data, fromStepName })
|
|
1056
1123
|
),
|
|
1057
|
-
|
|
1124
|
+
{
|
|
1125
|
+
...this.resolveStepJobOptions(stepOptions),
|
|
1126
|
+
group: this.getJobGroup(rpcName),
|
|
1127
|
+
}
|
|
1058
1128
|
)
|
|
1059
1129
|
} catch (cause) {
|
|
1060
1130
|
// The queue is down/unreachable — NOT a step failure. Surface it as a
|
|
@@ -2225,7 +2295,11 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
2225
2295
|
await queueService.add(
|
|
2226
2296
|
this.getOrchestratorQueueName(run.workflow),
|
|
2227
2297
|
{ runId },
|
|
2228
|
-
{
|
|
2298
|
+
{
|
|
2299
|
+
...this.resolveStepJobOptions(),
|
|
2300
|
+
delay,
|
|
2301
|
+
group: this.getJobGroup(run.workflow),
|
|
2302
|
+
}
|
|
2229
2303
|
)
|
|
2230
2304
|
} catch (error) {
|
|
2231
2305
|
this.logger?.warn(
|
|
@@ -2655,7 +2729,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
2655
2729
|
* queues — but it produces to them — so registrations would miss them.
|
|
2656
2730
|
*/
|
|
2657
2731
|
protected getOrchestratorQueueName(workflowName?: string): string {
|
|
2658
|
-
if (workflowName) {
|
|
2732
|
+
if (workflowName && this.queueStrategy !== 'shared-groups') {
|
|
2659
2733
|
const perWorkflow = `wf-orchestrator-${toKebab(workflowName)}`
|
|
2660
2734
|
const meta = pikkuState(null, 'queue', 'meta')
|
|
2661
2735
|
if (meta[perWorkflow]) {
|
|
@@ -2666,7 +2740,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
2666
2740
|
}
|
|
2667
2741
|
|
|
2668
2742
|
protected getStepWorkerQueueName(rpcName?: string): string {
|
|
2669
|
-
if (rpcName) {
|
|
2743
|
+
if (rpcName && this.queueStrategy !== 'shared-groups') {
|
|
2670
2744
|
const perStep = `wf-step-${toKebab(rpcName)}`
|
|
2671
2745
|
const meta = pikkuState(null, 'queue', 'meta')
|
|
2672
2746
|
if (meta[perStep]) {
|
|
@@ -2675,4 +2749,20 @@ export abstract class PikkuWorkflowService implements WorkflowService {
|
|
|
2675
2749
|
}
|
|
2676
2750
|
return this.getConfig().stepWorkerQueueName
|
|
2677
2751
|
}
|
|
2752
|
+
|
|
2753
|
+
/**
|
|
2754
|
+
* Fairness key for a job on a shared queue. Under `'per-workflow'` the queue
|
|
2755
|
+
* name already isolates workflows, so no group is needed — returning one
|
|
2756
|
+
* anyway would cap a workflow inside its own dedicated queue.
|
|
2757
|
+
*
|
|
2758
|
+
* The tier repeats the id so a workflow can be given its own limit purely
|
|
2759
|
+
* from config, with no per-workflow wiring; an unmatched tier falls back to
|
|
2760
|
+
* the default limit.
|
|
2761
|
+
*/
|
|
2762
|
+
protected getJobGroup(id?: string): JobGroup | undefined {
|
|
2763
|
+
if (!id || this.queueStrategy !== 'shared-groups') {
|
|
2764
|
+
return undefined
|
|
2765
|
+
}
|
|
2766
|
+
return { id, tier: id }
|
|
2767
|
+
}
|
|
2678
2768
|
}
|
|
@@ -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
|
|
|
4
5
|
// Re-export WorkflowService from services module
|
|
5
6
|
export type { WorkflowService } from '../../services/workflow-service.js'
|
|
@@ -62,6 +63,44 @@ export interface WorkflowServiceConfig {
|
|
|
62
63
|
sleeperRPCName: string
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
/**
|
|
67
|
+
* How a workflow service spreads its jobs across queues.
|
|
68
|
+
*
|
|
69
|
+
* Passed to the service constructor rather than read from `config.workflow`:
|
|
70
|
+
* the queues are wired during construction, before singleton services (and so
|
|
71
|
+
* before `config`) exist.
|
|
72
|
+
*/
|
|
73
|
+
export interface WorkflowQueueOptions {
|
|
74
|
+
/**
|
|
75
|
+
* - `'per-workflow'` (default) — each workflow gets its own
|
|
76
|
+
* `wf-orchestrator-*` / `wf-step-*` queue. Complete isolation, and it's
|
|
77
|
+
* what lets serverless providers deploy one unit per workflow. Costs one
|
|
78
|
+
* set of pollers per queue, which adds up on pull-based backends.
|
|
79
|
+
* - `'shared-groups'` — every workflow shares the orchestrator/step-worker
|
|
80
|
+
* queues and stays isolated via {@link queueGroupConcurrency}, so no
|
|
81
|
+
* workflow can occupy more than its share. One set of pollers total.
|
|
82
|
+
* Only for single-process (monolith) runtimes; a per-unit serverless
|
|
83
|
+
* deploy needs the per-workflow queues to route to its units.
|
|
84
|
+
*/
|
|
85
|
+
queueStrategy?: 'per-workflow' | 'shared-groups'
|
|
86
|
+
/**
|
|
87
|
+
* Total concurrent workflow jobs per node under `'shared-groups'`.
|
|
88
|
+
* Defaults to 20.
|
|
89
|
+
*/
|
|
90
|
+
queueConcurrency?: number
|
|
91
|
+
/**
|
|
92
|
+
* How many jobs of one workflow may run at once under `'shared-groups'`.
|
|
93
|
+
* Defaults to 2. Tiers are keyed by workflow name, so a specific workflow
|
|
94
|
+
* can be given its own limit without any extra wiring.
|
|
95
|
+
*
|
|
96
|
+
* Note tiers can only lower a workflow's limit, not raise it above
|
|
97
|
+
* `default`: the backend's pre-fetch exclusion is applied per group using
|
|
98
|
+
* `default` alone. To give one workflow more room, raise `default` and
|
|
99
|
+
* restrict the others by tier.
|
|
100
|
+
*/
|
|
101
|
+
queueGroupConcurrency?: number | GroupConcurrencyConfig
|
|
102
|
+
}
|
|
103
|
+
|
|
65
104
|
export interface WorkflowPlannedStep {
|
|
66
105
|
/** Durable step key — matches the runtime step name stored in the DB */
|
|
67
106
|
stepName: string
|