@pikku/core 0.12.66 → 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.
Files changed (36) hide show
  1. package/CHANGELOG.md +153 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/scopes.d.ts +14 -0
  5. package/dist/scopes.js +39 -8
  6. package/dist/services/in-memory-workflow-service.d.ts +2 -2
  7. package/dist/services/in-memory-workflow-service.js +2 -2
  8. package/dist/types/core.types.d.ts +22 -0
  9. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +18 -1
  10. package/dist/wirings/ai-agent/ai-agent-prepare.js +26 -4
  11. package/dist/wirings/queue/index.d.ts +1 -1
  12. package/dist/wirings/queue/queue.types.d.ts +30 -0
  13. package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
  14. package/dist/wirings/scope/validate-scope-definitions.js +16 -1
  15. package/dist/wirings/workflow/index.d.ts +1 -1
  16. package/dist/wirings/workflow/pikku-workflow-service.d.ts +16 -3
  17. package/dist/wirings/workflow/pikku-workflow-service.js +86 -18
  18. package/dist/wirings/workflow/workflow.types.d.ts +38 -0
  19. package/package.json +1 -1
  20. package/src/index.ts +2 -1
  21. package/src/scopes.test.ts +37 -1
  22. package/src/scopes.ts +48 -9
  23. package/src/services/in-memory-workflow-service.test.ts +50 -1
  24. package/src/services/in-memory-workflow-service.ts +3 -2
  25. package/src/types/core.types.ts +23 -0
  26. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +29 -0
  27. package/src/wirings/ai-agent/ai-agent-prepare.ts +38 -4
  28. package/src/wirings/queue/index.ts +2 -0
  29. package/src/wirings/queue/queue.types.ts +32 -0
  30. package/src/wirings/scope/scope.test.ts +25 -0
  31. package/src/wirings/scope/validate-scope-definitions.ts +16 -1
  32. package/src/wirings/workflow/index.ts +1 -0
  33. package/src/wirings/workflow/pikku-workflow-service.test.ts +71 -0
  34. package/src/wirings/workflow/pikku-workflow-service.ts +110 -20
  35. package/src/wirings/workflow/workflow.types.ts +39 -0
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -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
- registerQueueWorkers(pikkuState(null, 'queue', 'meta'));
258
- const addons = pikkuState(null, 'addons', 'packages');
259
- if (addons) {
260
- for (const [, addon] of addons) {
261
- const addonQueueMeta = pikkuState(addon.package, 'queue', 'meta');
262
- if (addonQueueMeta) {
263
- registerQueueWorkers(addonQueueMeta);
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 }, this.resolveStepJobOptions());
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 })), this.resolveStepJobOptions(stepOptions));
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 }, retryDelay ? { delay: getDurationInMilliseconds(retryDelay) } : undefined);
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 })), this.resolveStepJobOptions(stepOptions));
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 }, { ...this.resolveStepJobOptions(), delay });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.66",
3
+ "version": "0.12.69",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * @module @pikku/core
3
3
  */
4
4
  export type {
5
+ AuthInstance,
5
6
  CommonWireMeta,
6
7
  CoreConfig,
7
8
  CorePikkuMiddleware,
@@ -197,7 +198,7 @@ export {
197
198
  addGlobalMiddleware,
198
199
  } from './middleware-runner.js'
199
200
  export { addGlobalPermission, checkAuthPermissions } from './permissions.js'
200
- export { verifyScopes } from './scopes.js'
201
+ export { hasScopes, verifyScopes } from './scopes.js'
201
202
  export {
202
203
  isSerializable,
203
204
  stopSingletonServices,
@@ -1,6 +1,6 @@
1
1
  import { describe, test } from 'node:test'
2
2
  import * as assert from 'node:assert'
3
- import { verifyScopes } from './scopes.js'
3
+ import { hasScopes, verifyScopes } from './scopes.js'
4
4
  import { MissingScopeError } from './errors/errors.js'
5
5
  import type { CoreUserSession } from './types/core.types.js'
6
6
 
@@ -165,3 +165,39 @@ describe('verifyScopes', () => {
165
165
  )
166
166
  })
167
167
  })
168
+
169
+ describe('hasScopes', () => {
170
+ test('true on an exact match', () => {
171
+ assert.equal(hasScopes(['invoices:create'], ['invoices:create']), true)
172
+ })
173
+
174
+ test('false when the grant is unrelated', () => {
175
+ assert.equal(hasScopes(['invoices:create'], ['billing:read']), false)
176
+ })
177
+
178
+ test('shares the hierarchy rules with verifyScopes', () => {
179
+ assert.equal(hasScopes(['admin:impersonate'], ['admin']), true)
180
+ assert.equal(hasScopes(['admin:impersonate'], ['admin:*']), true)
181
+ assert.equal(hasScopes(['admin:impersonate'], ['*']), true)
182
+ assert.equal(hasScopes(['admin'], ['admin:impersonate']), false)
183
+ })
184
+
185
+ test('AND semantics: every required scope must be satisfied', () => {
186
+ assert.equal(hasScopes(['a:read', 'a:write'], ['a:read']), false)
187
+ assert.equal(hasScopes(['a:read', 'a:write'], ['a']), true)
188
+ })
189
+
190
+ test('fails closed on absent or empty grants', () => {
191
+ assert.equal(hasScopes(['invoices:create'], undefined), false)
192
+ assert.equal(hasScopes(['invoices:create'], []), false)
193
+ })
194
+
195
+ test('an empty requirement is satisfied by anything', () => {
196
+ assert.equal(hasScopes([], undefined), true)
197
+ assert.equal(hasScopes(undefined, undefined), true)
198
+ })
199
+
200
+ test('accepts a Set of held grants', () => {
201
+ assert.equal(hasScopes(['admin:impersonate'], new Set(['admin'])), true)
202
+ })
203
+ })
package/src/scopes.ts CHANGED
@@ -37,6 +37,51 @@ const satisfyingGrants = (scope: string): string[] => {
37
37
  const holds = (held: ReadonlySet<string>, scope: string): boolean =>
38
38
  satisfyingGrants(scope).some((grant) => held.has(grant))
39
39
 
40
+ /**
41
+ * The first required scope a set of held grants does not satisfy, or `null`
42
+ * when every one is satisfied.
43
+ *
44
+ * Scopes are an AND gate: every entry in `required` must be satisfied. This is
45
+ * deliberately distinct from `permissions`, which OR together — a scope can
46
+ * only ever narrow access, so adding one to a function can never widen it.
47
+ *
48
+ * Fails closed: an absent `held` satisfies nothing.
49
+ */
50
+ const firstUnsatisfied = (
51
+ required: readonly string[] | undefined,
52
+ held: Iterable<string> | undefined
53
+ ): string | null => {
54
+ if (!required || required.length === 0) {
55
+ return null
56
+ }
57
+
58
+ const grants = new Set(held ?? [])
59
+ for (const scope of required) {
60
+ if (!holds(grants, scope)) {
61
+ return scope
62
+ }
63
+ }
64
+ return null
65
+ }
66
+
67
+ /**
68
+ * Whether a set of held grants satisfies every required scope.
69
+ *
70
+ * The non-throwing counterpart to {@link verifyScopes}, for deciding rather
71
+ * than enforcing — an authorization gate that falls back to another check when
72
+ * it is not satisfied, rather than rejecting the request outright.
73
+ *
74
+ * Fails closed: an absent or empty `held` satisfies nothing. An empty
75
+ * `required` is satisfied by anything.
76
+ *
77
+ * @param required - Scopes to check for. Empty means no gate.
78
+ * @param held - The grants held, e.g. `session.scopes`. May be undefined.
79
+ */
80
+ export const hasScopes = (
81
+ required: readonly string[] | undefined,
82
+ held: Iterable<string> | undefined
83
+ ): boolean => firstUnsatisfied(required, held) === null
84
+
40
85
  /**
41
86
  * Verifies that a session holds every required scope, throwing on the first
42
87
  * one it does not.
@@ -56,14 +101,8 @@ export const verifyScopes = (
56
101
  required: readonly string[] | undefined,
57
102
  session: CoreUserSession | undefined
58
103
  ): void => {
59
- if (!required || required.length === 0) {
60
- return
61
- }
62
-
63
- const held = new Set(session?.scopes ?? [])
64
- for (const scope of required) {
65
- if (!holds(held, scope)) {
66
- throw new MissingScopeError(scope)
67
- }
104
+ const missing = firstUnsatisfied(required, session?.scopes)
105
+ if (missing !== null) {
106
+ throw new MissingScopeError(missing)
68
107
  }
69
108
  }
@@ -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>>()
@@ -269,6 +269,22 @@ export interface CoreUserSession {
269
269
  scopes?: string[]
270
270
  }
271
271
 
272
+ /**
273
+ * The shape pikku needs from whatever auth library a project wires: something
274
+ * that can answer an HTTP request and expose its own endpoints as callable
275
+ * methods. Kept structural so core stays independent of any one auth package —
276
+ * `@pikku/better-auth`'s `BetterAuthInstance` is this type.
277
+ */
278
+ export interface AuthInstance {
279
+ handler: (request: Request) => Promise<Response>
280
+ api: Record<string, any>
281
+ /**
282
+ * The auth library's resolved context. Optional because a hand-built instance
283
+ * may omit it.
284
+ */
285
+ $context?: Promise<any>
286
+ }
287
+
272
288
  /**
273
289
  * Interface for core singleton services provided by Pikku.
274
290
  */
@@ -341,6 +357,13 @@ export interface CoreSingletonServices<Config extends CoreConfig = CoreConfig> {
341
357
  * better-auth's `mapSession`), never by the function runner.
342
358
  */
343
359
  scopeService?: ScopeService
360
+ /**
361
+ * The project's resolved auth instance, built once by the factory an auth
362
+ * package registers (e.g. `pikkuBetterAuth`) and injected by the generated
363
+ * `pikkuServices` wrapper — which is why service factories are forbidden from
364
+ * returning it themselves. Absent when the project wires no auth.
365
+ */
366
+ auth?: () => Promise<AuthInstance>
344
367
  }
345
368
 
346
369
  /**
@@ -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,