@pikku/core 0.12.20 → 0.12.21

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 (138) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/dev/hot-reload.js +20 -6
  3. package/dist/errors/error-handler.d.ts +1 -1
  4. package/dist/errors/error-handler.js +2 -2
  5. package/dist/function/functions.types.d.ts +3 -2
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +2 -2
  8. package/dist/middleware/index.d.ts +2 -2
  9. package/dist/middleware/index.js +2 -2
  10. package/dist/middleware/telemetry.d.ts +2 -2
  11. package/dist/middleware/telemetry.js +2 -2
  12. package/dist/middleware-runner.d.ts +15 -27
  13. package/dist/middleware-runner.js +25 -30
  14. package/dist/permissions.d.ts +15 -22
  15. package/dist/permissions.js +30 -25
  16. package/dist/pikku-request.js +1 -1
  17. package/dist/pikku-state.js +2 -3
  18. package/dist/services/ai-agent-runner-service.d.ts +1 -0
  19. package/dist/services/in-memory-queue-service.js +1 -1
  20. package/dist/services/in-memory-workflow-service.d.ts +15 -13
  21. package/dist/services/in-memory-workflow-service.js +31 -13
  22. package/dist/services/local-content.js +8 -1
  23. package/dist/services/user-session-service.d.ts +1 -1
  24. package/dist/testing/service-tests.js +24 -0
  25. package/dist/types/core.types.d.ts +4 -0
  26. package/dist/types/state.types.d.ts +2 -18
  27. package/dist/wirings/ai-agent/ai-agent-memory.d.ts +24 -5
  28. package/dist/wirings/ai-agent/ai-agent-memory.js +128 -23
  29. package/dist/wirings/ai-agent/ai-agent-model-config.d.ts +10 -1
  30. package/dist/wirings/ai-agent/ai-agent-model-config.js +15 -35
  31. package/dist/wirings/ai-agent/ai-agent-prepare.js +4 -1
  32. package/dist/wirings/ai-agent/ai-agent-runner.js +45 -31
  33. package/dist/wirings/ai-agent/ai-agent-stream.js +63 -34
  34. package/dist/wirings/ai-agent/ai-agent.types.d.ts +20 -0
  35. package/dist/wirings/channel/channel-handler.js +1 -1
  36. package/dist/wirings/channel/channel-store.d.ts +13 -0
  37. package/dist/wirings/channel/channel.types.d.ts +3 -0
  38. package/dist/wirings/channel/local/local-channel-runner.js +23 -5
  39. package/dist/wirings/channel/pikku-abstract-channel-handler.js +8 -0
  40. package/dist/wirings/channel/serverless/serverless-channel-runner.js +9 -0
  41. package/dist/wirings/cli/cli-runner.js +24 -5
  42. package/dist/wirings/cli/command-parser.js +19 -4
  43. package/dist/wirings/http/http-runner.js +72 -36
  44. package/dist/wirings/http/http.types.d.ts +1 -0
  45. package/dist/wirings/http/pikku-fetch-http-request.js +3 -1
  46. package/dist/wirings/http/web-request.js +32 -0
  47. package/dist/wirings/rpc/rpc-runner.js +13 -3
  48. package/dist/wirings/workflow/graph/graph-node.d.ts +8 -0
  49. package/dist/wirings/workflow/graph/graph-node.js +4 -0
  50. package/dist/wirings/workflow/graph/graph-runner.js +12 -10
  51. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +4 -0
  52. package/dist/wirings/workflow/index.d.ts +1 -1
  53. package/dist/wirings/workflow/pikku-workflow-service.d.ts +81 -16
  54. package/dist/wirings/workflow/pikku-workflow-service.js +266 -45
  55. package/dist/wirings/workflow/workflow.types.d.ts +34 -0
  56. package/package.json +1 -1
  57. package/run-tests.sh +4 -1
  58. package/src/dev/hot-reload.test.ts +62 -11
  59. package/src/dev/hot-reload.ts +28 -7
  60. package/src/errors/error-handler.ts +8 -2
  61. package/src/errors/error.test.ts +2 -0
  62. package/src/function/function-runner.test.ts +500 -10
  63. package/src/function/functions.types.ts +3 -2
  64. package/src/index.ts +12 -2
  65. package/src/middleware/index.ts +11 -2
  66. package/src/middleware/telemetry.ts +2 -2
  67. package/src/middleware-runner.test.ts +16 -16
  68. package/src/middleware-runner.ts +42 -30
  69. package/src/permissions.test.ts +27 -24
  70. package/src/permissions.ts +41 -25
  71. package/src/pikku-request.test.ts +35 -0
  72. package/src/pikku-request.ts +1 -1
  73. package/src/pikku-state.ts +2 -3
  74. package/src/run-tests-script.test.ts +18 -0
  75. package/src/services/ai-agent-runner-service.ts +1 -0
  76. package/src/services/in-memory-queue-service.ts +1 -1
  77. package/src/services/in-memory-session-store.ts +1 -2
  78. package/src/services/in-memory-workflow-service.test.ts +33 -11
  79. package/src/services/in-memory-workflow-service.ts +49 -13
  80. package/src/services/local-content.test.ts +54 -0
  81. package/src/services/local-content.ts +12 -1
  82. package/src/services/typed-credential-service.ts +3 -3
  83. package/src/services/typed-secret-service.ts +3 -3
  84. package/src/services/typed-variables-service.ts +3 -3
  85. package/src/services/user-session-service.ts +4 -4
  86. package/src/testing/service-tests.ts +30 -0
  87. package/src/types/core.types.ts +6 -2
  88. package/src/types/state.types.ts +2 -13
  89. package/src/wirings/ai-agent/ai-agent-memory.test.ts +324 -0
  90. package/src/wirings/ai-agent/ai-agent-memory.ts +187 -36
  91. package/src/wirings/ai-agent/ai-agent-model-config.test.ts +12 -90
  92. package/src/wirings/ai-agent/ai-agent-model-config.ts +14 -38
  93. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +292 -0
  94. package/src/wirings/ai-agent/ai-agent-prepare.ts +4 -1
  95. package/src/wirings/ai-agent/ai-agent-registry.test.ts +230 -3
  96. package/src/wirings/ai-agent/ai-agent-runner.test.ts +625 -6
  97. package/src/wirings/ai-agent/ai-agent-runner.ts +65 -50
  98. package/src/wirings/ai-agent/ai-agent-stream.test.ts +544 -5
  99. package/src/wirings/ai-agent/ai-agent-stream.ts +71 -69
  100. package/src/wirings/ai-agent/ai-agent.types.ts +24 -0
  101. package/src/wirings/channel/channel-handler.test.ts +272 -0
  102. package/src/wirings/channel/channel-handler.ts +1 -1
  103. package/src/wirings/channel/channel-middleware-runner.test.ts +163 -0
  104. package/src/wirings/channel/channel-store.ts +19 -0
  105. package/src/wirings/channel/channel.types.ts +4 -0
  106. package/src/wirings/channel/local/local-channel-runner.test.ts +63 -0
  107. package/src/wirings/channel/local/local-channel-runner.ts +41 -5
  108. package/src/wirings/channel/local/local-eventhub-service.ts +3 -3
  109. package/src/wirings/channel/pikku-abstract-channel-handler.ts +9 -2
  110. package/src/wirings/channel/serverless/serverless-channel-runner.ts +23 -5
  111. package/src/wirings/cli/channel/cli-raw-channel-runner.ts +7 -2
  112. package/src/wirings/cli/cli-runner.test.ts +255 -2
  113. package/src/wirings/cli/cli-runner.ts +31 -10
  114. package/src/wirings/cli/cli.types.ts +4 -8
  115. package/src/wirings/cli/command-parser.test.ts +83 -0
  116. package/src/wirings/cli/command-parser.ts +23 -4
  117. package/src/wirings/http/http-runner.test.ts +296 -1
  118. package/src/wirings/http/http-runner.ts +87 -57
  119. package/src/wirings/http/http.types.ts +11 -26
  120. package/src/wirings/http/pikku-fetch-http-request.ts +8 -4
  121. package/src/wirings/http/pikku-fetch-http-response.test.ts +41 -0
  122. package/src/wirings/http/web-request.test.ts +115 -0
  123. package/src/wirings/http/web-request.ts +43 -5
  124. package/src/wirings/mcp/mcp-runner.test.ts +367 -0
  125. package/src/wirings/queue/queue.types.ts +2 -5
  126. package/src/wirings/rpc/rpc-runner.test.ts +511 -0
  127. package/src/wirings/rpc/rpc-runner.ts +15 -3
  128. package/src/wirings/rpc/wire-addon.test.ts +57 -0
  129. package/src/wirings/workflow/graph/graph-node.ts +12 -0
  130. package/src/wirings/workflow/graph/graph-runner.test.ts +98 -0
  131. package/src/wirings/workflow/graph/graph-runner.ts +28 -10
  132. package/src/wirings/workflow/graph/workflow-graph.types.ts +4 -0
  133. package/src/wirings/workflow/index.ts +1 -0
  134. package/src/wirings/workflow/pikku-workflow-service.test.ts +19 -2
  135. package/src/wirings/workflow/pikku-workflow-service.ts +370 -71
  136. package/src/wirings/workflow/workflow-queue-workers.test.ts +131 -0
  137. package/src/wirings/workflow/workflow.types.ts +68 -5
  138. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,511 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { addFunction } from '../../function/function-runner.js'
5
+ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
6
+ import {
7
+ ContextAwareRPCService,
8
+ RPCNotFoundError,
9
+ resolveNamespace,
10
+ rpcService,
11
+ } from './rpc-runner.js'
12
+
13
+ const createLogger = () => ({
14
+ debug: () => {},
15
+ info: () => {},
16
+ warn: () => {},
17
+ error: () => {},
18
+ })
19
+
20
+ const registerFunction = (
21
+ funcName: string,
22
+ func: (services: any, data: any, wire: any) => Promise<unknown> | unknown,
23
+ {
24
+ packageName = null,
25
+ expose,
26
+ pikkuFuncId,
27
+ tags,
28
+ }: {
29
+ packageName?: string | null
30
+ expose?: boolean
31
+ pikkuFuncId?: string
32
+ tags?: string[]
33
+ } = {}
34
+ ) => {
35
+ addFunction(funcName, { func } as never, packageName)
36
+ pikkuState(packageName, 'function', 'meta')[funcName] = {
37
+ name: funcName,
38
+ pikkuFuncId,
39
+ sessionless: true,
40
+ permissions: [],
41
+ expose,
42
+ tags,
43
+ } as never
44
+ }
45
+
46
+ const createServices = (overrides: Record<string, unknown> = {}) =>
47
+ ({
48
+ logger: createLogger(),
49
+ ...overrides,
50
+ }) as never
51
+
52
+ beforeEach(() => {
53
+ resetPikkuState()
54
+ pikkuState(null, 'package', 'singletonServices', createServices())
55
+ })
56
+
57
+ describe('resolveNamespace', () => {
58
+ test('returns null for non-namespaced functions', () => {
59
+ assert.equal(resolveNamespace('plainFunction'), null)
60
+ })
61
+
62
+ test('returns null when namespace is not registered', () => {
63
+ assert.equal(resolveNamespace('missing:func'), null)
64
+ })
65
+
66
+ test('resolves addon namespace to package and function', () => {
67
+ pikkuState(null, 'addons', 'packages').set('stripe', {
68
+ package: '@addon/stripe',
69
+ auth: true,
70
+ tags: ['addon-tag'],
71
+ } as never)
72
+
73
+ assert.deepEqual(resolveNamespace('stripe:createCharge'), {
74
+ package: '@addon/stripe',
75
+ function: 'createCharge',
76
+ addonConfig: {
77
+ package: '@addon/stripe',
78
+ auth: true,
79
+ tags: ['addon-tag'],
80
+ },
81
+ })
82
+ })
83
+ })
84
+
85
+ describe('ContextAwareRPCService.rpc', () => {
86
+ test('runs bare rpc functions through root rpc metadata', async () => {
87
+ pikkuState(null, 'rpc', 'meta').echo = 'echoFunc'
88
+ registerFunction('echoFunc', async (_services, data, wire) => ({
89
+ data,
90
+ traceId: wire.traceId,
91
+ }))
92
+
93
+ const service = new ContextAwareRPCService(
94
+ createServices(),
95
+ { traceId: 'trace-1' } as never,
96
+ { requiresAuth: false }
97
+ )
98
+
99
+ const result = await service.rpc('echo', { hello: 'world' })
100
+
101
+ assert.deepEqual(result, {
102
+ data: { hello: 'world' },
103
+ traceId: 'trace-1',
104
+ })
105
+ })
106
+
107
+ test('uses package-scoped function meta before root rpc metadata', async () => {
108
+ pikkuState(null, 'rpc', 'meta').echo = 'rootEchoFunc'
109
+ registerFunction('rootEchoFunc', async () => ({ source: 'root' }))
110
+ registerFunction(
111
+ 'packageEchoFunc',
112
+ async () => ({ source: '@addon/pkg' }),
113
+ {
114
+ packageName: '@addon/pkg',
115
+ }
116
+ )
117
+ pikkuState('@addon/pkg', 'function', 'meta').echo = {
118
+ name: 'echo',
119
+ pikkuFuncId: 'packageEchoFunc',
120
+ sessionless: true,
121
+ permissions: [],
122
+ } as never
123
+
124
+ const service = new ContextAwareRPCService(
125
+ createServices(),
126
+ {} as never,
127
+ {},
128
+ '@addon/pkg'
129
+ )
130
+
131
+ const result = await service.rpc('echo', {})
132
+
133
+ assert.deepEqual(result, { source: '@addon/pkg' })
134
+ })
135
+
136
+ test('falls back from versioned rpc names to the base metadata entry', async () => {
137
+ pikkuState(null, 'rpc', 'meta').echo = 'echoFunc'
138
+ registerFunction('echoFunc', async (_services, data) => data)
139
+
140
+ const service = new ContextAwareRPCService(
141
+ createServices(),
142
+ {} as never,
143
+ {}
144
+ )
145
+
146
+ const result = await service.rpc('echo@v2', { ok: true })
147
+
148
+ assert.deepEqual(result, { ok: true })
149
+ })
150
+
151
+ test('resolves internal root functions that are registered outside rpc metadata', async () => {
152
+ registerFunction('pikkuWorkflowSleeper', async (_services, data) => ({
153
+ sleeper: true,
154
+ data,
155
+ }))
156
+
157
+ const service = new ContextAwareRPCService(
158
+ createServices(),
159
+ {} as never,
160
+ {}
161
+ )
162
+
163
+ const result = await service.rpc('pikkuWorkflowSleeper', {
164
+ runId: 'run-1',
165
+ stepId: 'step-1',
166
+ })
167
+
168
+ assert.deepEqual(result, {
169
+ sleeper: true,
170
+ data: {
171
+ runId: 'run-1',
172
+ stepId: 'step-1',
173
+ },
174
+ })
175
+ })
176
+
177
+ test('invokes addon functions with addon auth and tags', async () => {
178
+ pikkuState(null, 'addons', 'packages').set('stripe', {
179
+ package: '@addon/stripe',
180
+ auth: false,
181
+ tags: ['addon-tag'],
182
+ } as never)
183
+ registerFunction(
184
+ 'createCharge',
185
+ async (_services, data, wire) => ({
186
+ data,
187
+ auth: wire.wireType,
188
+ }),
189
+ {
190
+ packageName: '@addon/stripe',
191
+ tags: ['function-tag'],
192
+ }
193
+ )
194
+
195
+ pikkuState('@addon/stripe', 'function', 'meta').createCharge = {
196
+ name: 'createCharge',
197
+ sessionless: true,
198
+ permissions: [],
199
+ tags: ['function-tag'],
200
+ } as never
201
+
202
+ const service = new ContextAwareRPCService(
203
+ createServices({
204
+ logger: createLogger(),
205
+ }),
206
+ { wireType: 'http', wireId: 'wire-1' } as never,
207
+ { requiresAuth: true }
208
+ )
209
+
210
+ const seenTags: string[][] = []
211
+ const addonTagGroup = pikkuState('@addon/stripe', 'permissions', 'tagGroup')
212
+ addonTagGroup['addon-tag'] = [
213
+ async (_services: any, _data: any, wire: any) => {
214
+ seenTags.push(['addon-tag', wire.wireType])
215
+ return true
216
+ },
217
+ ] as never
218
+ addonTagGroup['function-tag'] = [
219
+ async (_services: any, _data: any, wire: any) => {
220
+ seenTags.push(['function-tag', wire.wireType])
221
+ return true
222
+ },
223
+ ] as never
224
+
225
+ const result = await service.rpc('stripe:createCharge', { amount: 10 })
226
+
227
+ assert.deepEqual(result, {
228
+ data: { amount: 10 },
229
+ auth: 'http',
230
+ })
231
+ assert.deepEqual(seenTags, [['addon-tag', 'http']])
232
+ })
233
+
234
+ test('falls back to deploymentService when rpc meta is missing', async () => {
235
+ const calls: unknown[][] = []
236
+ const service = new ContextAwareRPCService(
237
+ createServices({
238
+ deploymentService: {
239
+ invoke: async (...args: unknown[]) => {
240
+ calls.push(args)
241
+ return { remote: true }
242
+ },
243
+ },
244
+ }),
245
+ {
246
+ traceId: 'trace-2',
247
+ getSession: async () => ({ userId: 'user-1' }),
248
+ } as never,
249
+ {}
250
+ )
251
+
252
+ const result = await service.rpc('missingRpc', { value: 1 })
253
+
254
+ assert.deepEqual(result, { remote: true })
255
+ assert.deepEqual(calls, [
256
+ ['missingRpc', { value: 1 }, { userId: 'user-1' }, 'trace-2'],
257
+ ])
258
+ })
259
+
260
+ test('throws RPCNotFoundError when rpc is missing and no deploymentService exists', async () => {
261
+ const service = new ContextAwareRPCService(
262
+ createServices(),
263
+ {} as never,
264
+ {}
265
+ )
266
+
267
+ await assert.rejects(() => service.rpc('missingRpc', {}), RPCNotFoundError)
268
+ })
269
+ })
270
+
271
+ describe('ContextAwareRPCService.rpcExposed', () => {
272
+ test('resolves unversioned exposed rpc names through latest rpc metadata', async () => {
273
+ pikkuState(null, 'rpc', 'meta').listCards = 'listCards@v2'
274
+ registerFunction(
275
+ 'listCards@v2',
276
+ async (_services, data) => ({ data, version: 2 }),
277
+ {
278
+ expose: true,
279
+ }
280
+ )
281
+
282
+ const service = new ContextAwareRPCService(
283
+ createServices(),
284
+ {} as never,
285
+ {}
286
+ )
287
+
288
+ const result = await service.rpcExposed('listCards', { ok: true })
289
+
290
+ assert.deepEqual(result, {
291
+ data: { ok: true },
292
+ version: 2,
293
+ })
294
+ })
295
+
296
+ test('throws when the function does not exist', async () => {
297
+ const service = new ContextAwareRPCService(
298
+ createServices(),
299
+ {} as never,
300
+ {}
301
+ )
302
+
303
+ await assert.rejects(
304
+ () => service.rpcExposed('missingRpc', {}),
305
+ RPCNotFoundError
306
+ )
307
+ })
308
+
309
+ test('throws RPCNotFoundError when the function is not exposed', async () => {
310
+ pikkuState(null, 'rpc', 'meta').hiddenFunc = 'hiddenFunc'
311
+ registerFunction('hiddenFunc', async () => ({ ok: true }), {
312
+ expose: false,
313
+ })
314
+
315
+ const service = new ContextAwareRPCService(
316
+ createServices(),
317
+ {} as never,
318
+ {}
319
+ )
320
+
321
+ await assert.rejects(
322
+ () => service.rpcExposed('hiddenFunc', {}),
323
+ RPCNotFoundError
324
+ )
325
+ })
326
+
327
+ test('runs exposed addon functions', async () => {
328
+ pikkuState(null, 'addons', 'packages').set('addon', {
329
+ package: '@addon/pkg',
330
+ } as never)
331
+ registerFunction(
332
+ 'visibleFunc',
333
+ async (_services, data) => ({ data, source: 'addon' }),
334
+ {
335
+ packageName: '@addon/pkg',
336
+ expose: true,
337
+ }
338
+ )
339
+
340
+ const service = new ContextAwareRPCService(
341
+ createServices(),
342
+ {} as never,
343
+ {}
344
+ )
345
+
346
+ const result = await service.rpcExposed('addon:visibleFunc', {
347
+ ok: true,
348
+ })
349
+
350
+ assert.deepEqual(result, { data: { ok: true }, source: 'addon' })
351
+ })
352
+ })
353
+
354
+ describe('ContextAwareRPCService.rpcWithWire', () => {
355
+ test('merges wire data for local rpc calls', async () => {
356
+ pikkuState(null, 'rpc', 'meta').echo = 'echoFunc'
357
+ registerFunction('echoFunc', async (_services, _data, wire) => ({
358
+ traceId: wire.traceId,
359
+ custom: wire.custom,
360
+ }))
361
+
362
+ const service = new ContextAwareRPCService(
363
+ createServices(),
364
+ { traceId: 'base-trace' } as never,
365
+ {}
366
+ )
367
+
368
+ const result = await service.rpcWithWire('echo', {}, {
369
+ custom: 'merged',
370
+ } as never)
371
+
372
+ assert.deepEqual(result, {
373
+ traceId: 'base-trace',
374
+ custom: 'merged',
375
+ })
376
+ })
377
+
378
+ test('falls back to deploymentService for rpcWithWire when local rpc is missing', async () => {
379
+ const calls: unknown[][] = []
380
+ const service = new ContextAwareRPCService(
381
+ createServices({
382
+ deploymentService: {
383
+ invoke: async (...args: unknown[]) => {
384
+ calls.push(args)
385
+ return { remote: true }
386
+ },
387
+ },
388
+ }),
389
+ { traceId: 'trace-3', session: { userId: 'user-2' } } as never,
390
+ {}
391
+ )
392
+
393
+ const result = await service.rpcWithWire('missingRpc', { value: 2 }, {
394
+ custom: 'wire',
395
+ } as never)
396
+
397
+ assert.deepEqual(result, { remote: true })
398
+ assert.deepEqual(calls, [
399
+ ['missingRpc', { value: 2 }, { userId: 'user-2' }, 'trace-3'],
400
+ ])
401
+ })
402
+ })
403
+
404
+ describe('ContextAwareRPCService.startWorkflow', () => {
405
+ test('throws when workflowService is unavailable', async () => {
406
+ const service = new ContextAwareRPCService(
407
+ createServices(),
408
+ {} as never,
409
+ {}
410
+ )
411
+
412
+ await assert.rejects(
413
+ () => service.startWorkflow('workflowA', {}),
414
+ /WorkflowService service not available/
415
+ )
416
+ })
417
+
418
+ test('passes derived wire metadata into workflowService.startWorkflow', async () => {
419
+ const calls: unknown[][] = []
420
+ const service = new ContextAwareRPCService(
421
+ createServices({
422
+ workflowService: {
423
+ startWorkflow: async (...args: unknown[]) => {
424
+ calls.push(args)
425
+ return { runId: 'run-1' }
426
+ },
427
+ },
428
+ }),
429
+ {
430
+ wireType: 'rpc',
431
+ wireId: 'rpc-1',
432
+ pikkuUserId: 'user-3',
433
+ workflowStep: { runId: 'parent-run-1' },
434
+ } as never,
435
+ {}
436
+ )
437
+
438
+ const result = await service.startWorkflow('workflowA', { ok: true })
439
+
440
+ assert.deepEqual(result, { runId: 'run-1' })
441
+ assert.deepEqual(calls[0]?.slice(0, 3), [
442
+ 'workflowA',
443
+ { ok: true },
444
+ {
445
+ type: 'rpc',
446
+ id: 'rpc-1',
447
+ parentRunId: 'parent-run-1',
448
+ pikkuUserId: 'user-3',
449
+ },
450
+ ])
451
+ })
452
+ })
453
+
454
+ describe('ContextAwareRPCService.remote', () => {
455
+ test('throws when deploymentService is unavailable', async () => {
456
+ const service = new ContextAwareRPCService(
457
+ createServices(),
458
+ {} as never,
459
+ {}
460
+ )
461
+
462
+ await assert.rejects(
463
+ () => service.remote('remoteFunc', {}),
464
+ /No DeploymentService configured for remote RPC: remoteFunc/
465
+ )
466
+ })
467
+
468
+ test('uses deploymentService with session and trace id', async () => {
469
+ const calls: unknown[][] = []
470
+ const service = new ContextAwareRPCService(
471
+ createServices({
472
+ deploymentService: {
473
+ invoke: async (...args: unknown[]) => {
474
+ calls.push(args)
475
+ return { ok: true }
476
+ },
477
+ },
478
+ }),
479
+ {
480
+ traceId: 'trace-4',
481
+ getSession: async () => ({ userId: 'user-4' }),
482
+ } as never,
483
+ {}
484
+ )
485
+
486
+ const result = await service.remote('remoteFunc', { hi: true })
487
+
488
+ assert.deepEqual(result, { ok: true })
489
+ assert.deepEqual(calls, [
490
+ ['remoteFunc', { hi: true }, { userId: 'user-4' }, 'trace-4'],
491
+ ])
492
+ })
493
+ })
494
+
495
+ describe('rpcService.getContextRPCService', () => {
496
+ test('builds a typed rpc facade with depth and bound methods', async () => {
497
+ pikkuState(null, 'rpc', 'meta').echo = 'echoFunc'
498
+ registerFunction('echoFunc', async (_services, data) => data)
499
+
500
+ const rpc = rpcService.getContextRPCService(
501
+ createServices(),
502
+ {} as never,
503
+ false,
504
+ 7
505
+ )
506
+
507
+ assert.equal(rpc.depth, 7)
508
+ assert.equal(rpc.global, false)
509
+ assert.deepEqual(await rpc.invoke('echo', { ok: true }), { ok: true })
510
+ })
511
+ })
@@ -3,7 +3,6 @@ import type { SessionService } from '../../services/user-session-service.js'
3
3
  import type { CoreUserSession } from '../../types/core.types.js'
4
4
  import { runPikkuFunc } from '../../function/function-runner.js'
5
5
  import { pikkuState } from '../../pikku-state.js'
6
- import { ForbiddenError } from '../../errors/errors.js'
7
6
  import { PikkuError, addError } from '../../errors/error-handler.js'
8
7
  import type { PikkuRPC, ResolvedFunction } from './rpc-types.js'
9
8
  import { parseVersionedId } from '../../version.js'
@@ -81,6 +80,16 @@ const resolvePikkuFunction = (
81
80
  rpcMeta = rpc[baseName]
82
81
  }
83
82
  }
83
+ if (!rpcMeta) {
84
+ const rootFunctions = pikkuState(null, 'function', 'meta')
85
+ const rootFunctionMeta = rootFunctions?.[rpcName]
86
+ if (rootFunctionMeta) {
87
+ return {
88
+ pikkuFuncId: rootFunctionMeta.pikkuFuncId || rpcName,
89
+ packageName: null,
90
+ }
91
+ }
92
+ }
84
93
  if (!rpcMeta) {
85
94
  throw new RPCNotFoundError(rpcName)
86
95
  }
@@ -109,13 +118,16 @@ export class ContextAwareRPCService {
109
118
  ]
110
119
  }
111
120
  } else {
112
- functionMeta = pikkuState(null, 'function', 'meta')[funcName]
121
+ const resolved = resolvePikkuFunction(funcName, this.packageName)
122
+ functionMeta = pikkuState(resolved.packageName, 'function', 'meta')[
123
+ resolved.pikkuFuncId
124
+ ]
113
125
  }
114
126
  if (!functionMeta) {
115
127
  throw new RPCNotFoundError(funcName)
116
128
  }
117
129
  if (!functionMeta.expose) {
118
- throw new ForbiddenError()
130
+ throw new RPCNotFoundError(funcName)
119
131
  }
120
132
  return await this.rpc(funcName, data)
121
133
  }
@@ -0,0 +1,57 @@
1
+ import { beforeEach, describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
+ import { wireAddon } from './wire-addon.js'
6
+
7
+ beforeEach(() => {
8
+ resetPikkuState()
9
+ })
10
+
11
+ describe('wireAddon', () => {
12
+ test('registers addon package metadata for namespace resolution', () => {
13
+ wireAddon({
14
+ name: 'stripe',
15
+ package: '@addon/stripe',
16
+ rpcEndpoint: 'https://rpc.example.com',
17
+ auth: true,
18
+ mcp: true,
19
+ tags: ['payments', 'billing'],
20
+ secretOverrides: { apiKey: 'secretName' },
21
+ variableOverrides: { region: 'eu-west-1' },
22
+ credentialOverrides: { oauth: 'credentialName' },
23
+ })
24
+
25
+ assert.deepEqual(pikkuState(null, 'addons', 'packages').get('stripe'), {
26
+ package: '@addon/stripe',
27
+ rpcEndpoint: 'https://rpc.example.com',
28
+ auth: true,
29
+ tags: ['payments', 'billing'],
30
+ })
31
+ })
32
+
33
+ test('overwrites existing addon config for the same namespace', () => {
34
+ wireAddon({
35
+ name: 'stripe',
36
+ package: '@addon/stripe-v1',
37
+ rpcEndpoint: 'https://rpc-v1.example.com',
38
+ auth: false,
39
+ tags: ['old'],
40
+ })
41
+
42
+ wireAddon({
43
+ name: 'stripe',
44
+ package: '@addon/stripe-v2',
45
+ rpcEndpoint: 'https://rpc-v2.example.com',
46
+ auth: true,
47
+ tags: ['new'],
48
+ })
49
+
50
+ assert.deepEqual(pikkuState(null, 'addons', 'packages').get('stripe'), {
51
+ package: '@addon/stripe-v2',
52
+ rpcEndpoint: 'https://rpc-v2.example.com',
53
+ auth: true,
54
+ tags: ['new'],
55
+ })
56
+ })
57
+ })
@@ -45,6 +45,10 @@ export interface GraphNodeDef<
45
45
  next?: NextConfig<NodeIds>
46
46
  /** Error handling - node(s) to execute on error */
47
47
  onError?: NodeIds | NodeIds[]
48
+ /** Maximum retry attempts allowed (excludes the initial attempt) */
49
+ retries?: number
50
+ /** Delay between retries — milliseconds, duration string, or 'exponential' */
51
+ retryDelay?: string | number
48
52
  }
49
53
 
50
54
  /**
@@ -146,6 +150,8 @@ export function createGraph<RPCMap extends Record<string, RPCHandler>>() {
146
150
  input: def?.input as any,
147
151
  next: def?.next,
148
152
  onError: def?.onError,
153
+ retries: def?.retries,
154
+ retryDelay: def?.retryDelay,
149
155
  }
150
156
  }
151
157
 
@@ -172,6 +178,8 @@ type GraphNodeConfigMap<
172
178
  ) => TypedRef<ComputeNodeOutputs<FuncMap, RPCMap>[N][P]>
173
179
  ) => InputWithRefs<ComputeNodeInputs<FuncMap, RPCMap>[K]>
174
180
  onError?: Extract<keyof FuncMap, string> | Extract<keyof FuncMap, string>[]
181
+ retries?: number
182
+ retryDelay?: string | number
175
183
  }
176
184
  }
177
185
 
@@ -204,6 +212,8 @@ export function graph<NodeIds extends string = string>(
204
212
  ref: (nodeId: NodeIds, path: string) => RefValue
205
213
  ) => Record<string, unknown>
206
214
  onError?: NodeIds | NodeIds[]
215
+ retries?: number
216
+ retryDelay?: string | number
207
217
  }
208
218
  >
209
219
  ): Record<NodeIds, GraphNodeConfig<NodeIds>> {
@@ -215,6 +225,8 @@ export function graph<NodeIds extends string = string>(
215
225
  input: def.input as any,
216
226
  next: def.next,
217
227
  onError: def.onError,
228
+ retries: def.retries,
229
+ retryDelay: def.retryDelay,
218
230
  }
219
231
  }
220
232