@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.
- package/CHANGELOG.md +153 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/scopes.d.ts +14 -0
- package/dist/scopes.js +39 -8
- package/dist/services/in-memory-workflow-service.d.ts +2 -2
- package/dist/services/in-memory-workflow-service.js +2 -2
- package/dist/types/core.types.d.ts +22 -0
- 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/scope/validate-scope-definitions.d.ts +8 -0
- package/dist/wirings/scope/validate-scope-definitions.js +16 -1
- 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/index.ts +2 -1
- package/src/scopes.test.ts +37 -1
- package/src/scopes.ts +48 -9
- package/src/services/in-memory-workflow-service.test.ts +50 -1
- package/src/services/in-memory-workflow-service.ts +3 -2
- package/src/types/core.types.ts +23 -0
- 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/scope/scope.test.ts +25 -0
- package/src/wirings/scope/validate-scope-definitions.ts +16 -1
- 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
|
@@ -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
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,
|
package/src/scopes.test.ts
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
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>>()
|
package/src/types/core.types.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|