@pikku/core 0.12.26 → 0.12.28

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 (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/data-classification.d.ts +7 -3
  3. package/dist/errors/error-handler.js +1 -0
  4. package/dist/function/function-runner.d.ts +1 -2
  5. package/dist/function/function-runner.js +2 -22
  6. package/dist/function/functions.types.d.ts +2 -0
  7. package/dist/handle-error.js +1 -0
  8. package/dist/index.d.ts +3 -2
  9. package/dist/index.js +2 -1
  10. package/dist/pikku-state.d.ts +1 -0
  11. package/dist/pikku-state.js +3 -0
  12. package/dist/services/gopass-secrets.d.ts +1 -0
  13. package/dist/services/gopass-secrets.js +9 -0
  14. package/dist/services/local-secrets.d.ts +1 -0
  15. package/dist/services/local-secrets.js +9 -0
  16. package/dist/services/meta-service.js +18 -7
  17. package/dist/services/scoped-secret-service.d.ts +1 -0
  18. package/dist/services/scoped-secret-service.js +4 -0
  19. package/dist/services/secret-service.d.ts +7 -0
  20. package/dist/services/typed-secret-service.d.ts +1 -0
  21. package/dist/services/typed-secret-service.js +3 -0
  22. package/dist/test-utils.d.ts +18 -0
  23. package/dist/test-utils.js +33 -0
  24. package/dist/types/core.types.d.ts +2 -0
  25. package/dist/wirings/channel/channel-common.js +9 -2
  26. package/dist/wirings/channel/local/local-channel-handler.d.ts +7 -4
  27. package/dist/wirings/channel/local/local-channel-handler.js +13 -5
  28. package/dist/wirings/channel/local/local-channel-runner.js +1 -1
  29. package/dist/wirings/workflow/pikku-workflow-service.js +10 -1
  30. package/package.json +1 -1
  31. package/src/data-classification.ts +11 -4
  32. package/src/dev/hot-reload.test.ts +2 -0
  33. package/src/errors/error-handler.ts +1 -0
  34. package/src/function/function-runner.test.ts +2 -28
  35. package/src/function/function-runner.ts +2 -35
  36. package/src/function/functions.types.ts +2 -0
  37. package/src/handle-error.test.ts +1 -0
  38. package/src/handle-error.ts +1 -0
  39. package/src/index.ts +7 -1
  40. package/src/pikku-state.ts +4 -0
  41. package/src/services/gopass-secrets.ts +10 -0
  42. package/src/services/local-secrets.ts +10 -0
  43. package/src/services/meta-service.ts +17 -7
  44. package/src/services/scoped-secret-service.ts +5 -0
  45. package/src/services/secret-service.ts +7 -0
  46. package/src/services/typed-secret-service.ts +4 -0
  47. package/src/test-utils.ts +38 -0
  48. package/src/types/core.types.ts +2 -0
  49. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +2 -0
  50. package/src/wirings/ai-agent/ai-agent-runner.test.ts +28 -4
  51. package/src/wirings/ai-agent/ai-agent-stream.test.ts +3 -0
  52. package/src/wirings/channel/channel-common.ts +9 -2
  53. package/src/wirings/channel/local/local-channel-handler.ts +24 -9
  54. package/src/wirings/channel/local/local-channel-runner.ts +2 -1
  55. package/src/wirings/cli/cli-runner.test.ts +4 -0
  56. package/src/wirings/http/http-runner.test.ts +1 -0
  57. package/src/wirings/queue/queue-runner.test.ts +1 -0
  58. package/src/wirings/scheduler/scheduler-runner.test.ts +10 -0
  59. package/src/wirings/workflow/pikku-workflow-service.ts +10 -1
  60. package/tsconfig.tsbuildinfo +1 -1
@@ -7,7 +7,6 @@ import { runPermissions } from '../permissions.js'
7
7
  import { pikkuState } from '../pikku-state.js'
8
8
  import { coerceTopLevelDataFromSchema, validateSchema } from '../schema.js'
9
9
  import type {
10
- CoreServices,
11
10
  CoreUserSession,
12
11
  CorePikkuMiddleware,
13
12
  PikkuWiringTypes,
@@ -26,10 +25,7 @@ import type {
26
25
  } from './functions.types.js'
27
26
  import { parseVersionedId } from '../version.js'
28
27
  import type { SessionService } from '../services/user-session-service.js'
29
- import {
30
- PikkuSessionService,
31
- createFunctionSessionWireProps,
32
- } from '../services/user-session-service.js'
28
+ import { PikkuSessionService } from '../services/user-session-service.js'
33
29
  import { ForbiddenError, ReadonlySessionError } from '../errors/errors.js'
34
30
  import {
35
31
  PikkuCredentialWireService,
@@ -200,27 +196,6 @@ export const getAllFunctionNames = (): string[] => {
200
196
  return functions
201
197
  }
202
198
 
203
- export const runPikkuFuncDirectly = async <In, Out>(
204
- funcName: string,
205
- allServices: CoreServices,
206
- wire: PikkuWire,
207
- data: In,
208
- userSession?: SessionService<CoreUserSession>,
209
- packageName: string | null = null
210
- ) => {
211
- const funcConfig = pikkuState(packageName, 'function', 'functions').get(
212
- funcName
213
- )
214
- if (!funcConfig) {
215
- throw new Error(`Function not found: ${funcName}`)
216
- }
217
- const wireWithSession = {
218
- ...wire,
219
- ...(userSession && createFunctionSessionWireProps(userSession)),
220
- }
221
- return (await funcConfig.func(allServices, data, wireWithSession)) as Out
222
- }
223
-
224
199
  export const runPikkuFunc = async <In = any, Out = any>(
225
200
  wireType: PikkuWiringTypes,
226
201
  wireId: string,
@@ -381,7 +356,7 @@ export const runPikkuFunc = async <In = any, Out = any>(
381
356
  throw new ForbiddenError('Authentication required')
382
357
  }
383
358
  }
384
- } else if (funcMeta.sessionless === false) {
359
+ } else {
385
360
  if (wiringAuth === false || funcConfig.auth === false) {
386
361
  resolvedSingletonServices.logger.warn(
387
362
  `Function '${funcName}' requires a session but auth was explicitly disabled — use pikkuSessionlessFunc instead.`
@@ -390,14 +365,6 @@ export const runPikkuFunc = async <In = any, Out = any>(
390
365
  if (!session) {
391
366
  throw new ForbiddenError('Authentication required')
392
367
  }
393
- } else {
394
- // TODO: Remove after a couple of releases — backward compat for
395
- // generated metadata that doesn't include the `sessionless` field yet.
396
- if (wiringAuth === true || funcConfig.auth === true) {
397
- if (!session) {
398
- throw new ForbiddenError('Authentication required')
399
- }
400
- }
401
368
  }
402
369
 
403
370
  if ((session as any)?.readonly && !funcMeta.readonly) {
@@ -295,6 +295,8 @@ export type CorePikkuFunctionConfig<
295
295
  readonly?: boolean
296
296
  deploy?: 'serverless' | 'server' | 'auto'
297
297
  approvalRequired?: boolean
298
+ /** When false, workflow steps calling this function are dispatched via the queue instead of running inline. Defaults to true (inline). */
299
+ inline?: boolean
298
300
  audit?:
299
301
  | boolean
300
302
  | {
@@ -89,6 +89,7 @@ describe('handleHTTPError', () => {
89
89
 
90
90
  assert.strictEqual(http._state.statusCode, 404)
91
91
  assert.deepStrictEqual(http._state.jsonBody, {
92
+ name: 'NotFoundError',
92
93
  message: 'The server cannot find the requested resource.',
93
94
  payload: undefined,
94
95
  errorId: 'tracker-1',
@@ -36,6 +36,7 @@ export const handleHTTPError = (
36
36
  // Set status and response body
37
37
  http?.response?.status(errorResponse.status)
38
38
  http?.response?.json({
39
+ name: e instanceof Error ? e.name : undefined,
39
40
  message:
40
41
  e instanceof Error && e.message && e.message !== 'An error occurred'
41
42
  ? e.message
package/src/index.ts CHANGED
@@ -171,7 +171,12 @@ export {
171
171
  checkAuthPermissions,
172
172
  } from './permissions.js'
173
173
  export { isSerializable, stopSingletonServices } from './utils.js'
174
- export { getSingletonServices, getCreateWireServices } from './pikku-state.js'
174
+ export {
175
+ getSingletonServices,
176
+ getCreateWireServices,
177
+ setSingletonServices,
178
+ } from './pikku-state.js'
179
+ export { clearPikkuRuntimeState } from './test-utils.js'
175
180
  export {
176
181
  type ScheduledTaskInfo,
177
182
  type ScheduledTaskSummary,
@@ -180,6 +185,7 @@ export { SchedulerService } from './services/scheduler-service.js'
180
185
 
181
186
  export type {
182
187
  Private,
188
+ Pii,
183
189
  Secret,
184
190
  Classification,
185
191
  AnonymizeStrategy,
@@ -193,6 +193,10 @@ export const getSingletonServices = (): CoreSingletonServices => {
193
193
  return services
194
194
  }
195
195
 
196
+ export const setSingletonServices = (services: CoreSingletonServices): void => {
197
+ pikkuState(null, 'package', 'singletonServices', services)
198
+ }
199
+
196
200
  export const getCreateWireServices = (): CreateWireServices | undefined => {
197
201
  return pikkuState(null, 'package', 'factories')?.createWireServices
198
202
  }
@@ -63,4 +63,14 @@ export class GopassSecretService implements SecretService {
63
63
  // Ignore errors if secret doesn't exist
64
64
  }
65
65
  }
66
+
67
+ public async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
68
+ const results = await Promise.allSettled(keys.map((k) => this.getSecret(k)))
69
+ const out: Record<string, unknown> = {}
70
+ keys.forEach((key, i) => {
71
+ if (results[i].status === 'fulfilled')
72
+ out[key] = (results[i] as PromiseFulfilledResult<unknown>).value
73
+ })
74
+ return out
75
+ }
66
76
  }
@@ -52,4 +52,14 @@ export class LocalSecretService implements SecretService {
52
52
  public async deleteSecret(key: string): Promise<void> {
53
53
  this.localSecrets.delete(key)
54
54
  }
55
+
56
+ public async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
57
+ const results = await Promise.allSettled(keys.map((k) => this.getSecret(k)))
58
+ const out: Record<string, unknown> = {}
59
+ keys.forEach((key, i) => {
60
+ if (results[i].status === 'fulfilled')
61
+ out[key] = (results[i] as PromiseFulfilledResult<unknown>).value
62
+ })
63
+ return out
64
+ }
55
65
  }
@@ -533,7 +533,17 @@ export class LocalMetaService implements MetaService {
533
533
  )
534
534
  }
535
535
 
536
+ // emailsMeta.src is an absolute path resolved by the CLI at generation time.
537
+ // Read directly rather than through readProjectFile (which prepends the project
538
+ // root and produces a wrong compound path when baseDir is absolute).
536
539
  const baseDir = emailsMeta.src
540
+ const readEmailFile = async (rel: string): Promise<string | null> => {
541
+ try {
542
+ return await readFile(join(baseDir, rel), 'utf-8')
543
+ } catch {
544
+ return null
545
+ }
546
+ }
537
547
  const [
538
548
  themeRaw,
539
549
  localeRaw,
@@ -543,13 +553,13 @@ export class LocalMetaService implements MetaService {
543
553
  templateSubject,
544
554
  templateText,
545
555
  ] = await Promise.all([
546
- this.readProjectFile(`${baseDir}/theme.json`),
547
- this.readProjectFile(`${baseDir}/locales/${locale}.json`),
548
- this.readProjectFile(`${baseDir}/partials/layout.html`),
549
- this.readProjectFile(`${baseDir}/partials/footer.html`),
550
- this.readProjectFile(`${baseDir}/templates/${templateName}.html`),
551
- this.readProjectFile(`${baseDir}/templates/${templateName}.subject.txt`),
552
- this.readProjectFile(`${baseDir}/templates/${templateName}.text.txt`),
556
+ readEmailFile('theme.json'),
557
+ readEmailFile(`locales/${locale}.json`),
558
+ readEmailFile('partials/layout.html'),
559
+ readEmailFile('partials/footer.html'),
560
+ readEmailFile(`templates/${templateName}.html`),
561
+ readEmailFile(`templates/${templateName}.subject.txt`),
562
+ readEmailFile(`templates/${templateName}.text.txt`),
553
563
  ])
554
564
 
555
565
  return {
@@ -33,4 +33,9 @@ export class ScopedSecretService implements SecretService {
33
33
  async deleteSecret(_key: string): Promise<void> {
34
34
  throw new Error('deleteSecret is not allowed in scoped secret service')
35
35
  }
36
+
37
+ async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
38
+ const allowed = keys.filter((k) => this.allowedKeys.has(k))
39
+ return this.secrets.getSecrets(allowed)
40
+ }
36
41
  }
@@ -28,4 +28,11 @@ export interface SecretService {
28
28
  * @returns A promise that resolves when the secret is deleted.
29
29
  */
30
30
  deleteSecret(key: string): Promise<void>
31
+ /**
32
+ * Retrieves multiple secrets in a single batch operation.
33
+ * Returns a map of key → value for successfully fetched secrets; missing
34
+ * keys are omitted rather than throwing.
35
+ * @param keys - The keys of the secrets to retrieve.
36
+ */
37
+ getSecrets(keys: string[]): Promise<Record<string, unknown>>
31
38
  }
@@ -43,6 +43,10 @@ export class TypedSecretService<
43
43
  return this.secrets.deleteSecret(key)
44
44
  }
45
45
 
46
+ async getSecrets(keys: string[]): Promise<Record<string, unknown>> {
47
+ return this.secrets.getSecrets(keys)
48
+ }
49
+
46
50
  async getAllStatus(): Promise<CredentialStatus[]> {
47
51
  const results: CredentialStatus[] = []
48
52
 
@@ -0,0 +1,38 @@
1
+ import { getAllPackageStates } from './pikku-state.js'
2
+ import { clearMiddlewareCache } from './middleware-runner.js'
3
+ import { clearPermissionsCache } from './permissions.js'
4
+ import { clearChannelMiddlewareCache } from './wirings/channel/channel-middleware-runner.js'
5
+
6
+ /**
7
+ * Clears all runtime caches between test scenarios without touching registered
8
+ * routes, functions, or middleware groups (which are set at module load time
9
+ * and must persist for the whole test run).
10
+ *
11
+ * Call this alongside your DB snapshot restore in your Before hook:
12
+ *
13
+ * ```typescript
14
+ * Before(async function() {
15
+ * await restoreSnapshot()
16
+ * clearPikkuRuntimeState()
17
+ * setSingletonServices(bundle.services)
18
+ * })
19
+ * ```
20
+ *
21
+ * Only works when NODE_ENV === 'test'. Logs an error and returns otherwise.
22
+ */
23
+ export const clearPikkuRuntimeState = (): void => {
24
+ if (process.env.NODE_ENV !== 'test') {
25
+ console.error(
26
+ '[pikku] clearPikkuRuntimeState() must only be called in test environments. Ignoring.'
27
+ )
28
+ return
29
+ }
30
+
31
+ clearMiddlewareCache()
32
+ clearPermissionsCache()
33
+ clearChannelMiddlewareCache()
34
+
35
+ for (const packageState of getAllPackageStates().values()) {
36
+ packageState.package.singletonServices = null
37
+ }
38
+ }
@@ -118,6 +118,8 @@ export type FunctionRuntimeMeta = {
118
118
  readonly?: boolean
119
119
  deploy?: 'serverless' | 'server' | 'auto'
120
120
  sessionless?: boolean
121
+ /** When false, workflow steps calling this function are dispatched via the queue. Defaults to true (inline). */
122
+ inline?: boolean
121
123
  version?: number
122
124
  approvalRequired?: boolean
123
125
  approvalDescription?: string
@@ -178,6 +178,7 @@ describe('ai-agent-prepare', () => {
178
178
  description: 'Deploy the service',
179
179
  approvalRequired: true,
180
180
  inputSchemaName: 'DeployInput',
181
+ sessionless: true,
181
182
  }
182
183
  pikkuState(null, 'misc', 'schemas').set('DeployInput', {
183
184
  type: 'object',
@@ -254,6 +255,7 @@ describe('ai-agent-prepare', () => {
254
255
  description: 'Secret tool',
255
256
  permissions: ['admin'],
256
257
  inputSchemaName: 'SecretInput',
258
+ sessionless: true,
257
259
  }
258
260
  pikkuState(null, 'misc', 'schemas').set('SecretInput', {
259
261
  type: 'object',
@@ -560,6 +560,7 @@ describe('runAIAgent', () => {
560
560
  description: 'Deploy',
561
561
  approvalRequired: true,
562
562
  inputSchemaName: 'DeployInput',
563
+ sessionless: true,
563
564
  }
564
565
  pikkuState(null, 'misc', 'schemas').set('DeployInput', {
565
566
  type: 'object',
@@ -716,6 +717,7 @@ describe('resumeAIAgentSync', () => {
716
717
  pikkuState(null, 'function', 'meta').deploy = {
717
718
  description: 'Deploy',
718
719
  inputSchemaName: 'DeployInput',
720
+ sessionless: true,
719
721
  }
720
722
  pikkuState(null, 'misc', 'schemas').set('DeployInput', {
721
723
  type: 'object',
@@ -901,6 +903,7 @@ describe('resumeAIAgentSync', () => {
901
903
  pikkuState(null, 'function', 'meta').deploy = {
902
904
  description: 'Deploy',
903
905
  inputSchemaName: 'DeployInput',
906
+ sessionless: true,
904
907
  }
905
908
  pikkuState(null, 'misc', 'schemas').set('DeployInput', {
906
909
  type: 'object',
@@ -986,7 +989,12 @@ describe('getCredential API key override', () => {
986
989
  }
987
990
 
988
991
  const mockServices = {
989
- logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
992
+ logger: {
993
+ info: () => {},
994
+ warn: () => {},
995
+ error: () => {},
996
+ debug: () => {},
997
+ },
990
998
  aiAgentRunner: {
991
999
  run: async (): Promise<AIAgentStepResult> => {
992
1000
  originalRunCalls.push('original')
@@ -1019,13 +1027,24 @@ describe('getCredential API key override', () => {
1019
1027
  const originalRunCalls: string[] = []
1020
1028
 
1021
1029
  const mockServices = {
1022
- logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
1030
+ logger: {
1031
+ info: () => {},
1032
+ warn: () => {},
1033
+ error: () => {},
1034
+ debug: () => {},
1035
+ },
1023
1036
  aiAgentRunner: {
1024
1037
  run: async (): Promise<AIAgentStepResult> => {
1025
1038
  originalRunCalls.push('original')
1026
1039
  return makeStepResult({ text: 'from-original', finishReason: 'stop' })
1027
1040
  },
1028
- withApiKey: (_key: string) => ({ run: async () => makeStepResult({ text: 'should-not-be-called', finishReason: 'stop' }) }),
1041
+ withApiKey: (_key: string) => ({
1042
+ run: async () =>
1043
+ makeStepResult({
1044
+ text: 'should-not-be-called',
1045
+ finishReason: 'stop',
1046
+ }),
1047
+ }),
1029
1048
  },
1030
1049
  aiRunState: {
1031
1050
  createRun: async () => 'run-no-cred',
@@ -1051,7 +1070,12 @@ describe('getCredential API key override', () => {
1051
1070
  const originalRunCalls: string[] = []
1052
1071
 
1053
1072
  const mockServices = {
1054
- logger: { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} },
1073
+ logger: {
1074
+ info: () => {},
1075
+ warn: () => {},
1076
+ error: () => {},
1077
+ debug: () => {},
1078
+ },
1055
1079
  aiAgentRunner: {
1056
1080
  run: async (): Promise<AIAgentStepResult> => {
1057
1081
  originalRunCalls.push('original')
@@ -327,6 +327,7 @@ describe('streamAIAgent', () => {
327
327
  description: 'Add a todo',
328
328
  approvalRequired: true,
329
329
  inputSchemaName: 'AddTodoInput',
330
+ sessionless: true,
330
331
  }
331
332
  pikkuState(null, 'misc', 'schemas').set('AddTodoInput', {
332
333
  type: 'object',
@@ -428,6 +429,7 @@ describe('streamAIAgent', () => {
428
429
  description: 'Add a todo',
429
430
  approvalRequired: true,
430
431
  inputSchemaName: 'AddTodoInput',
432
+ sessionless: true,
431
433
  }
432
434
  pikkuState('@test/addon-todos', 'misc', 'schemas').set('AddTodoInput', {
433
435
  type: 'object',
@@ -1287,6 +1289,7 @@ describe('resumeAIAgent', () => {
1287
1289
  pikkuState(null, 'function', 'meta').deploy = {
1288
1290
  description: 'Deploy',
1289
1291
  inputSchemaName: 'DeployInput',
1292
+ sessionless: true,
1290
1293
  }
1291
1294
  pikkuState(null, 'misc', 'schemas').set('DeployInput', {
1292
1295
  type: 'object',
@@ -6,7 +6,7 @@ import type {
6
6
  } from '../../types/core.types.js'
7
7
  import type { CoreChannel, ChannelMessageMeta } from './channel.types.js'
8
8
  import { combineMiddleware, runMiddleware } from '../../middleware-runner.js'
9
- import { runPikkuFuncDirectly } from '../../function/function-runner.js'
9
+ import { runPikkuFunc } from '../../function/function-runner.js'
10
10
  import {
11
11
  combineChannelMiddleware,
12
12
  wrapChannelWithMiddleware,
@@ -79,7 +79,14 @@ export const runChannelLifecycleWithMiddleware = async ({
79
79
  }
80
80
 
81
81
  const runLifecycle = async () => {
82
- return await runPikkuFuncDirectly(meta.pikkuFuncId, services, wire, data)
82
+ return await runPikkuFunc('channel', channelConfig.name, meta.pikkuFuncId, {
83
+ singletonServices: services,
84
+ data: () => data as any,
85
+ wire,
86
+ tags: meta.tags ?? [],
87
+ inheritedPermissions: meta.permissions,
88
+ packageName: meta.packageName ?? null,
89
+ })
83
90
  }
84
91
 
85
92
  if (allMiddleware.length > 0) {
@@ -1,3 +1,4 @@
1
+ import type { Logger } from '../../../services/logger.js'
1
2
  import type { BinaryData } from '../channel.types.js'
2
3
  import { PikkuAbstractChannelHandler } from '../pikku-abstract-channel-handler.js'
3
4
 
@@ -7,19 +8,30 @@ export class PikkuLocalChannelHandler<
7
8
  > extends PikkuAbstractChannelHandler<OpeningData, Out> {
8
9
  private onMessageCallback?: (message: unknown) => void
9
10
  private onBinaryMessageCallback?: (data: BinaryData) => void
10
- private openCallBack?: () => void
11
- private closeCallbacks: (() => void)[] = []
11
+ private openCallBack?: () => Promise<void> | void
12
+ private closeCallbacks: (() => Promise<void> | void)[] = []
12
13
  private sendCallback?: (message: Out, isBinary?: boolean) => void
13
14
  private sendBinaryCallback?: (data: BinaryData) => void
15
+ private logger?: Logger
14
16
 
15
- public registerOnOpen(callback: () => void): void {
17
+ constructor(
18
+ channelId: string,
19
+ channelName: string,
20
+ openingData: OpeningData,
21
+ logger?: Logger
22
+ ) {
23
+ super(channelId, channelName, openingData)
24
+ this.logger = logger
25
+ }
26
+
27
+ public registerOnOpen(callback: () => Promise<void> | void): void {
16
28
  this.openCallBack = callback
17
29
  }
18
30
 
19
- public open() {
31
+ public async open(): Promise<void> {
20
32
  this.getChannel().state = 'open'
21
33
  if (this.openCallBack) {
22
- this.openCallBack()
34
+ await this.openCallBack()
23
35
  }
24
36
  }
25
37
 
@@ -43,17 +55,20 @@ export class PikkuLocalChannelHandler<
43
55
  return this.onBinaryMessageCallback?.(data)
44
56
  }
45
57
 
46
- public registerOnClose(callback: () => void): void {
58
+ public registerOnClose(callback: () => Promise<void> | void): void {
47
59
  this.closeCallbacks.push(callback)
48
60
  }
49
61
 
50
- public close() {
62
+ public async close(): Promise<void> {
51
63
  if (this.getChannel().state === 'closed') {
52
64
  return
53
65
  }
54
66
  super.close()
55
- for (const cb of this.closeCallbacks) {
56
- cb()
67
+ const results = await Promise.allSettled(this.closeCallbacks.map((cb) => cb()))
68
+ for (const result of results) {
69
+ if (result.status === 'rejected') {
70
+ this.logger?.error('Error in channel close callback:', result.reason)
71
+ }
57
72
  }
58
73
  }
59
74
 
@@ -119,7 +119,8 @@ export const runLocalChannel = async ({
119
119
  channelHandler = new PikkuLocalChannelHandler(
120
120
  channelId,
121
121
  channelConfig.name,
122
- openingData
122
+ openingData,
123
+ singletonServices.logger
123
124
  )
124
125
  const channel = channelHandler.getChannel()
125
126
  const wire: PikkuWire = {
@@ -128,6 +128,7 @@ describe('CLI Runner', () => {
128
128
  pikkuFuncId: 'greetFunc',
129
129
  inputSchemaName: null,
130
130
  outputSchemaName: null,
131
+ sessionless: true,
131
132
  },
132
133
  })
133
134
 
@@ -192,6 +193,7 @@ describe('CLI Runner', () => {
192
193
  pikkuFuncId: 'testFunc',
193
194
  inputSchemaName: null,
194
195
  outputSchemaName: null,
196
+ sessionless: true,
195
197
  },
196
198
  })
197
199
 
@@ -252,6 +254,7 @@ describe('CLI Runner', () => {
252
254
  pikkuFuncId: 'greetFunc',
253
255
  inputSchemaName: null,
254
256
  outputSchemaName: null,
257
+ sessionless: true,
255
258
  },
256
259
  })
257
260
 
@@ -296,6 +299,7 @@ describe('CLI Runner', () => {
296
299
  pikkuFuncId: 'secureFunc',
297
300
  inputSchemaName: null,
298
301
  outputSchemaName: null,
302
+ sessionless: true,
299
303
  },
300
304
  })
301
305
 
@@ -129,6 +129,7 @@ const setRouteMeta = (
129
129
  inputSchemaName: null,
130
130
  outputSchemaName: null,
131
131
  sessionless: true,
132
+ sessionless: true,
132
133
  permissions: undefined,
133
134
  middleware: undefined,
134
135
  } as any
@@ -43,6 +43,7 @@ const addTestQueueFunction = (pikkuFuncId: string) => {
43
43
  pikkuFuncId,
44
44
  inputSchemaName: null,
45
45
  outputSchemaName: null,
46
+ sessionless: true,
46
47
  middleware: undefined,
47
48
  permissions: undefined,
48
49
  }
@@ -157,6 +157,7 @@ describe('runScheduledTask', () => {
157
157
  pikkuFuncId: 'scheduler_simple-task',
158
158
  inputSchemaName: null,
159
159
  outputSchemaName: null,
160
+ sessionless: true,
160
161
  }
161
162
  wireScheduler(mockTask)
162
163
 
@@ -199,6 +200,7 @@ describe('runScheduledTask', () => {
199
200
  pikkuFuncId: 'scheduler_task-with-session',
200
201
  inputSchemaName: null,
201
202
  outputSchemaName: null,
203
+ sessionless: true,
202
204
  }
203
205
  wireScheduler(mockTask)
204
206
 
@@ -287,6 +289,7 @@ describe('runScheduledTask', () => {
287
289
  pikkuFuncId: 'scheduler_skipped-task',
288
290
  inputSchemaName: null,
289
291
  outputSchemaName: null,
292
+ sessionless: true,
290
293
  }
291
294
  wireScheduler(mockTask)
292
295
 
@@ -332,6 +335,7 @@ describe('runScheduledTask', () => {
332
335
  pikkuFuncId: 'scheduler_skipped-task-no-reason',
333
336
  inputSchemaName: null,
334
337
  outputSchemaName: null,
338
+ sessionless: true,
335
339
  }
336
340
  wireScheduler(mockTask)
337
341
 
@@ -380,6 +384,7 @@ describe('runScheduledTask', () => {
380
384
  pikkuFuncId: 'scheduler_wire-task',
381
385
  inputSchemaName: null,
382
386
  outputSchemaName: null,
387
+ sessionless: true,
383
388
  }
384
389
  wireScheduler(mockTask)
385
390
 
@@ -420,6 +425,7 @@ describe('runScheduledTask', () => {
420
425
  pikkuFuncId: 'scheduler_session-services-task',
421
426
  inputSchemaName: null,
422
427
  outputSchemaName: null,
428
+ sessionless: true,
423
429
  }
424
430
  wireScheduler(mockTask)
425
431
 
@@ -469,6 +475,7 @@ describe('runScheduledTask', () => {
469
475
  pikkuFuncId: 'scheduler_cleanup-task',
470
476
  inputSchemaName: null,
471
477
  outputSchemaName: null,
478
+ sessionless: true,
472
479
  }
473
480
  wireScheduler(mockTask)
474
481
 
@@ -517,6 +524,7 @@ describe('runScheduledTask', () => {
517
524
  pikkuFuncId: 'scheduler_error-cleanup-task',
518
525
  inputSchemaName: null,
519
526
  outputSchemaName: null,
527
+ sessionless: true,
520
528
  }
521
529
  wireScheduler(mockTask)
522
530
 
@@ -558,6 +566,7 @@ describe('runScheduledTask', () => {
558
566
  pikkuFuncId: 'scheduler_error-task',
559
567
  inputSchemaName: null,
560
568
  outputSchemaName: null,
569
+ sessionless: true,
561
570
  }
562
571
  wireScheduler(mockTask)
563
572
 
@@ -607,6 +616,7 @@ describe('runScheduledTask', () => {
607
616
  pikkuFuncId: 'scheduler_middleware-task',
608
617
  inputSchemaName: null,
609
618
  outputSchemaName: null,
619
+ sessionless: true,
610
620
  }
611
621
  wireScheduler(mockTask)
612
622
 
@@ -879,7 +879,16 @@ export abstract class PikkuWorkflowService implements WorkflowService {
879
879
  data: unknown,
880
880
  stepOptions?: WorkflowStepOptions
881
881
  ): Promise<boolean> {
882
- if (this.isInline(runId) || !getSingletonServices()?.queueService) {
882
+ if (!getSingletonServices()?.queueService) {
883
+ return false
884
+ }
885
+ // Functions default to inline execution. Only dispatch via queue when the
886
+ // function explicitly sets inline: false.
887
+ const functionsMeta = pikkuState(null, 'function', 'meta')
888
+ const rpcFuncId = pikkuState(null, 'rpc', 'meta')[rpcName]
889
+ const rpcMeta = typeof rpcFuncId === 'string' ? functionsMeta[rpcFuncId] : undefined
890
+ const forceQueue = rpcMeta?.inline === false
891
+ if (!forceQueue && this.isInline(runId)) {
883
892
  return false
884
893
  }
885
894
  const retries = stepOptions?.retries ?? 0