@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.
@@ -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
- { message, threadId, resourceId },
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
- { message, threadId, resourceId },
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)
@@ -1,6 +1,8 @@
1
1
  export type {
2
2
  ConfigValidationResult,
3
3
  CoreQueueWorker,
4
+ GroupConcurrencyConfig,
5
+ JobGroup,
4
6
  JobOptions,
5
7
  PikkuJobConfig,
6
8
  PikkuWorkerConfig,
@@ -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
  /**
@@ -59,6 +59,7 @@ export type {
59
59
  // Re-export all types from workflow.types
60
60
  export type {
61
61
  WorkflowService,
62
+ WorkflowQueueOptions,
62
63
  WorkflowServiceConfig,
63
64
  WorkflowPlannedStep,
64
65
  WorkflowRunWire,
@@ -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 { JobOptions } from '../queue/queue.types.js'
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: { wireQueues?: boolean; mirror?: WorkflowRunMirror } = {}
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
- registerQueueWorkers(pikkuState(null, 'queue', 'meta'))
385
+ if (!sharedGroups) {
386
+ registerQueueWorkers(pikkuState(null, 'queue', 'meta'))
355
387
 
356
- const addons = pikkuState(null, 'addons', 'packages')
357
- if (addons) {
358
- for (const [, addon] of addons) {
359
- const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta')
360
- if (addonQueueMeta) {
361
- registerQueueWorkers(addonQueueMeta)
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
- this.resolveStepJobOptions()
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
- this.resolveStepJobOptions(stepOptions)
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
- retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined
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
- this.resolveStepJobOptions(stepOptions)
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
- { ...this.resolveStepJobOptions(), delay }
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