@pikku/core 0.12.7 → 0.12.9

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 (52) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/env.d.ts +1 -0
  3. package/dist/env.js +1 -0
  4. package/dist/errors/errors.d.ts +14 -0
  5. package/dist/errors/errors.js +27 -0
  6. package/dist/handle-error.js +2 -1
  7. package/dist/middleware/auth-bearer.js +10 -1
  8. package/dist/middleware/auth-cookie.js +34 -25
  9. package/dist/middleware/timeout.js +11 -5
  10. package/dist/pikku-state.js +1 -1
  11. package/dist/services/local-content.d.ts +7 -4
  12. package/dist/services/local-content.js +42 -12
  13. package/dist/services/local-secrets.js +2 -2
  14. package/dist/testing/service-tests.js +1 -1
  15. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
  16. package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
  17. package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
  18. package/dist/wirings/channel/local/local-channel-runner.js +11 -6
  19. package/dist/wirings/http/http-runner.js +4 -2
  20. package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
  21. package/dist/wirings/http/web-request.js +3 -1
  22. package/dist/wirings/workflow/graph/graph-runner.d.ts +7 -0
  23. package/dist/wirings/workflow/graph/graph-runner.js +72 -7
  24. package/dist/wirings/workflow/pikku-workflow-service.d.ts +3 -0
  25. package/dist/wirings/workflow/pikku-workflow-service.js +59 -16
  26. package/dist/wirings/workflow/workflow.types.d.ts +4 -0
  27. package/package.json +1 -1
  28. package/src/env.ts +1 -0
  29. package/src/errors/errors.ts +35 -0
  30. package/src/handle-error.ts +2 -1
  31. package/src/middleware/auth-bearer.ts +10 -1
  32. package/src/middleware/auth-cookie.ts +11 -4
  33. package/src/middleware/timeout.ts +14 -7
  34. package/src/pikku-state.ts +1 -1
  35. package/src/services/local-content.ts +61 -13
  36. package/src/services/local-secrets.test.ts +2 -2
  37. package/src/services/local-secrets.ts +2 -2
  38. package/src/testing/service-tests.ts +1 -1
  39. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
  40. package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
  41. package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
  42. package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
  43. package/src/wirings/channel/local/local-channel-runner.ts +11 -6
  44. package/src/wirings/http/http-runner.ts +4 -2
  45. package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
  46. package/src/wirings/http/web-request.ts +3 -1
  47. package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
  48. package/src/wirings/workflow/graph/graph-runner.ts +84 -7
  49. package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
  50. package/src/wirings/workflow/pikku-workflow-service.ts +95 -25
  51. package/src/wirings/workflow/workflow.types.ts +4 -0
  52. package/tsconfig.tsbuildinfo +1 -1
@@ -11,6 +11,7 @@ import type {
11
11
  } from './ai-agent.types.js'
12
12
  import type { AIAgentRunnerParams } from '../../services/ai-agent-runner-service.js'
13
13
  import { PikkuError } from '../../errors/error-handler.js'
14
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
14
15
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
15
16
  import { createMiddlewareSessionWireProps } from '../../services/user-session-service.js'
16
17
  import type { SessionService } from '../../services/user-session-service.js'
@@ -520,7 +521,7 @@ export async function prepareAgentRun(
520
521
 
521
522
  const agentRunner = singletonServices.aiAgentRunner
522
523
  if (!agentRunner) {
523
- throw new Error('AIAgentRunnerService not available in singletonServices')
524
+ throw new AIProviderNotConfiguredError()
524
525
  }
525
526
 
526
527
  if (agent.dynamicWorkflows && singletonServices.workflowService) {
@@ -5,6 +5,7 @@ import { resetPikkuState, pikkuState } from '../../pikku-state.js'
5
5
  import { runAIAgent } from './ai-agent-runner.js'
6
6
  import type { CoreAIAgent, PikkuAIMiddlewareHooks } from './ai-agent.types.js'
7
7
  import type { AIAgentStepResult } from '../../services/ai-agent-runner-service.js'
8
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
8
9
 
9
10
  beforeEach(() => {
10
11
  resetPikkuState()
@@ -42,6 +43,39 @@ const makeStepResult = (
42
43
  })
43
44
 
44
45
  describe('runAIAgent', () => {
46
+ test('throws AIProviderNotConfiguredError when aiAgentRunner is missing', async () => {
47
+ addTestAgent('no-provider-agent')
48
+
49
+ const mockServices = {
50
+ logger: {
51
+ info: () => {},
52
+ warn: () => {},
53
+ error: () => {},
54
+ debug: () => {},
55
+ },
56
+ aiRunState: {
57
+ createRun: async () => 'run-no-provider',
58
+ updateRun: async () => {},
59
+ },
60
+ } as any
61
+
62
+ pikkuState(null, 'package', 'singletonServices', mockServices)
63
+
64
+ await assert.rejects(
65
+ () =>
66
+ runAIAgent(
67
+ 'no-provider-agent',
68
+ { message: 'hello', threadId: 't', resourceId: 'r' },
69
+ {}
70
+ ),
71
+ (error: unknown) => {
72
+ assert.ok(error instanceof AIProviderNotConfiguredError)
73
+ assert.match((error as Error).message, /AI provider/)
74
+ return true
75
+ }
76
+ )
77
+ })
78
+
45
79
  test('marks run as failed when runner throws', async () => {
46
80
  addTestAgent('failing-agent')
47
81
 
@@ -31,6 +31,7 @@ import {
31
31
  import { checkForApprovals, appendStepMessages } from './ai-agent-stream.js'
32
32
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
33
33
  import { resolveModelConfig } from './ai-agent-model-config.js'
34
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
34
35
  import { randomUUID } from 'crypto'
35
36
 
36
37
  export async function runAIAgent(
@@ -367,7 +368,7 @@ export async function resumeAIAgentSync(
367
368
  const memoryConfig = agent.memory
368
369
  const agentRunner = singletonServices.aiAgentRunner
369
370
  if (!agentRunner) {
370
- throw new Error('AIAgentRunnerService not available')
371
+ throw new AIProviderNotConfiguredError()
371
372
  }
372
373
 
373
374
  const approvedIds = new Set(
@@ -10,6 +10,7 @@ import type {
10
10
  AIAgentMemoryConfig,
11
11
  } from './ai-agent.types.js'
12
12
  import { pikkuState, getSingletonServices } from '../../pikku-state.js'
13
+ import { AIProviderNotConfiguredError } from '../../errors/errors.js'
13
14
  import {
14
15
  combineChannelMiddleware,
15
16
  wrapChannelWithMiddleware,
@@ -748,7 +749,7 @@ export async function resumeAIAgent(
748
749
  const memoryConfig = agent.memory
749
750
  const agentRunner = singletonServices.aiAgentRunner
750
751
  if (!agentRunner) {
751
- throw new Error('AIAgentRunnerService not available in singletonServices')
752
+ throw new AIProviderNotConfiguredError()
752
753
  }
753
754
 
754
755
  if (!input.approved) {
@@ -5,6 +5,8 @@ import { processMessageHandlers } from '../channel-handler.js'
5
5
  import type { CoreChannel, RunChannelOptions } from '../channel.types.js'
6
6
  import { PikkuLocalChannelHandler } from './local-channel-handler.js'
7
7
  import type { PikkuWire, WireServices } from '../../../types/core.types.js'
8
+ import { isProduction } from '../../../env.js'
9
+ import { getErrorResponse } from '../../../errors/error-handler.js'
8
10
  import { handleHTTPError } from '../../../handle-error.js'
9
11
  import {
10
12
  PikkuSessionService,
@@ -121,9 +123,10 @@ export const runLocalChannel = async ({
121
123
  }
122
124
  } catch (e: any) {
123
125
  singletonServices.logger.error(`Error handling onConnect: ${e}`)
126
+ const errorResponse = getErrorResponse(e)
124
127
  channel.send({
125
- error: e.message || 'Unknown error',
126
- errorName: e.constructor?.name,
128
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
129
+ ...(!isProduction() && { errorName: e.constructor?.name }),
127
130
  })
128
131
  }
129
132
  }
@@ -143,9 +146,10 @@ export const runLocalChannel = async ({
143
146
  })
144
147
  } catch (e: any) {
145
148
  singletonServices.logger.error(`Error handling onDisconnect: ${e}`)
149
+ const errorResponse = getErrorResponse(e)
146
150
  channel.send({
147
- error: e.message || 'Unknown error',
148
- errorName: e.constructor?.name,
151
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
152
+ ...(!isProduction() && { errorName: e.constructor?.name }),
149
153
  })
150
154
  }
151
155
  }
@@ -165,9 +169,10 @@ export const runLocalChannel = async ({
165
169
  await channel.send(result)
166
170
  } catch (e: any) {
167
171
  singletonServices.logger.error(e)
172
+ const errorResponse = getErrorResponse(e)
168
173
  channel.send({
169
- error: e.message || 'Unknown error',
170
- errorName: e.constructor?.name,
174
+ error: errorResponse?.message ?? (isProduction() ? 'Internal server error' : e.message),
175
+ ...(!isProduction() && { errorName: e.constructor?.name }),
171
176
  })
172
177
  setTimeout(() => channel.close(), 200)
173
178
  }
@@ -32,6 +32,7 @@ import {
32
32
  getCreateWireServices,
33
33
  } from '../../pikku-state.js'
34
34
  import { PikkuSessionService } from '../../services/user-session-service.js'
35
+ import { getErrorResponse } from '../../errors/error-handler.js'
35
36
  import { handleHTTPError } from '../../handle-error.js'
36
37
  import { pikkuState } from '../../pikku-state.js'
37
38
  import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js'
@@ -530,7 +531,7 @@ export const fetchData = async <In, Out>(
530
531
  apiRoute,
531
532
  apiType,
532
533
  })
533
- throw new NotFoundError(`Route not found: ${apiRoute}`)
534
+ throw new NotFoundError()
534
535
  }
535
536
 
536
537
  // Execute the matched route along with its middleware and session management
@@ -552,10 +553,11 @@ export const fetchData = async <In, Out>(
552
553
  // For SSE routes, send error through the stream since the response is already in stream mode
553
554
  singletonServices.logger.error(e instanceof Error ? e.message : e)
554
555
  try {
556
+ const errorResponse = getErrorResponse(e)
555
557
  response.arrayBuffer(
556
558
  JSON.stringify({
557
559
  type: 'error',
558
- errorText: e instanceof Error ? e.message : String(e),
560
+ errorText: errorResponse?.message ?? 'Internal server error',
559
561
  })
560
562
  )
561
563
  response.arrayBuffer(JSON.stringify({ type: 'done' }))
@@ -105,6 +105,9 @@ export class PikkuFetchHTTPRequest<In = unknown>
105
105
  const merged: Record<string, unknown> = {}
106
106
  for (const part of parts) {
107
107
  for (const [key, value] of Object.entries(part)) {
108
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
109
+ continue
110
+ }
108
111
  if (key in merged && !valuesAreEquivalent(merged[key], value)) {
109
112
  throw new UnprocessableContentError(
110
113
  `Conflicting values for key "${key}": "${merged[key]}" vs "${value}"`
@@ -141,7 +144,14 @@ export class PikkuFetchHTTPRequest<In = unknown>
141
144
  body = { data: buffer }
142
145
  } else if (contentType === 'application/x-www-form-urlencoded') {
143
146
  const text = await this.request.text()
144
- body = Object.fromEntries(new URLSearchParams(text))
147
+ const params = new URLSearchParams(text)
148
+ let count = 0
149
+ for (const _ of params) {
150
+ if (++count > 256) {
151
+ throw new UnprocessableContentError('Too many form parameters')
152
+ }
153
+ }
154
+ body = Object.fromEntries(params)
145
155
  } else {
146
156
  throw new UnprocessableContentError(
147
157
  `Unsupported content type ${contentType}`
@@ -6,9 +6,11 @@ import type { PikkuHTTPResponse } from './http.types.js'
6
6
  * Useful for bridging Pikku routes to libraries that expect standard Web Request objects.
7
7
  */
8
8
  export function toWebRequest(req: PikkuHTTPRequest, baseUrl?: string): Request {
9
+ const proto = req.header('x-forwarded-proto') ?? 'http'
10
+ const host = req.header('x-forwarded-host') ?? req.header('host') ?? 'localhost'
9
11
  const url = new URL(
10
12
  req.path(),
11
- baseUrl ?? `http://${req.header('host') ?? 'localhost'}`
13
+ baseUrl ?? `${proto}://${host}`
12
14
  )
13
15
 
14
16
  const query = req.query()
@@ -484,4 +484,46 @@ describe('graph-runner bugs', () => {
484
484
 
485
485
  delete metaState['testInlineRpcMissing']
486
486
  })
487
+
488
+ test('graph workflow with inline: true in meta should run inline', async () => {
489
+ const ws = new InMemoryWorkflowService()
490
+ let executed = false
491
+
492
+ const mockRpcService = {
493
+ rpcWithWire: async () => {
494
+ executed = true
495
+ return { done: true }
496
+ },
497
+ }
498
+
499
+ // Set up queue service to verify nothing is queued
500
+ let queued = false
501
+ pikkuState(null, 'package', 'singletonServices', {
502
+ queueService: {
503
+ add: async () => {
504
+ queued = true
505
+ },
506
+ },
507
+ } as any)
508
+
509
+ const metaState = pikkuState(null, 'workflows', 'meta')
510
+ metaState['testInlineMetaGraph'] = {
511
+ name: 'testInlineMetaGraph',
512
+ pikkuFuncId: 'testInlineMetaGraph',
513
+ source: 'graph',
514
+ inline: true,
515
+ entryNodeIds: ['a'],
516
+ graphHash: 'inline-meta-graph-hash',
517
+ nodes: {
518
+ a: { nodeId: 'a', rpcName: 'doA' },
519
+ },
520
+ }
521
+
522
+ await runWorkflowGraph(ws, 'testInlineMetaGraph', {}, mockRpcService, true)
523
+
524
+ assert.equal(executed, true, 'RPC should have been executed inline')
525
+ assert.equal(queued, false, 'nothing should have been queued')
526
+
527
+ delete metaState['testInlineMetaGraph']
528
+ })
487
529
  })
@@ -1,9 +1,20 @@
1
1
  import type { PikkuWorkflowService } from '../pikku-workflow-service.js'
2
2
  import type { GraphWireState, PikkuGraphWire } from './workflow-graph.types.js'
3
- import { pikkuState } from '../../../pikku-state.js'
3
+ import { pikkuState, getSingletonServices } from '../../../pikku-state.js'
4
4
  import type { WorkflowRuntimeMeta, WorkflowRunWire } from '../workflow.types.js'
5
5
  import { RPCNotFoundError } from '../../rpc/rpc-runner.js'
6
6
 
7
+ export class ChildWorkflowStartedException extends Error {
8
+ name = 'ChildWorkflowStartedException'
9
+ constructor(
10
+ public parentRunId: string,
11
+ public stepId: string,
12
+ public childRunId: string
13
+ ) {
14
+ super(`Child workflow started: ${childRunId}`)
15
+ }
16
+ }
17
+
7
18
  function buildTemplateRegex(nodeId: string): RegExp | null {
8
19
  if (!nodeId.includes('${')) return null
9
20
  const escaped = nodeId
@@ -442,9 +453,44 @@ export async function executeGraphStep(
442
453
  }
443
454
 
444
455
  try {
445
- const result = await rpcService.rpcWithWire(rpcName, data, {
446
- graph: graphWire,
447
- })
456
+ let result: any
457
+
458
+ const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
459
+ if (subWorkflowMeta) {
460
+ const childWire: WorkflowRunWire = {
461
+ type: 'workflow',
462
+ id: rpcName,
463
+ parentRunId: runId,
464
+ parentStepId: stepId,
465
+ }
466
+ const shouldInline =
467
+ subWorkflowMeta.inline || !getSingletonServices()?.queueService
468
+ const { runId: childRunId } = await workflowService.startWorkflow(
469
+ rpcName,
470
+ data,
471
+ childWire,
472
+ rpcService,
473
+ { inline: shouldInline }
474
+ )
475
+ await workflowService.setStepChildRunId(stepId, childRunId)
476
+
477
+ if (shouldInline) {
478
+ const childRun = await workflowService.getRun(childRunId)
479
+ if (childRun?.status === 'failed') {
480
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
481
+ }
482
+ if (childRun?.status === 'cancelled') {
483
+ throw new Error('Sub-workflow was cancelled')
484
+ }
485
+ result = childRun?.output
486
+ } else {
487
+ throw new ChildWorkflowStartedException(runId, stepId, childRunId)
488
+ }
489
+ } else {
490
+ result = await rpcService.rpcWithWire(rpcName, data, {
491
+ graph: graphWire,
492
+ })
493
+ }
448
494
 
449
495
  if (wireState.branchKey) {
450
496
  await workflowService.setBranchTaken(stepId, wireState.branchKey)
@@ -452,6 +498,9 @@ export async function executeGraphStep(
452
498
 
453
499
  return result
454
500
  } catch (error) {
501
+ if (error instanceof ChildWorkflowStartedException) {
502
+ throw error
503
+ }
455
504
  if (error instanceof RPCNotFoundError) {
456
505
  await workflowService.updateRunStatus(runId, 'suspended', undefined, {
457
506
  message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
@@ -541,9 +590,37 @@ async function executeGraphNodeInline(
541
590
  }
542
591
 
543
592
  try {
544
- const result = await rpcService.rpcWithWire(rpcName, input, {
545
- graph: graphWire,
546
- })
593
+ let result: any
594
+
595
+ const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
596
+ if (subWorkflowMeta) {
597
+ const childWire: WorkflowRunWire = {
598
+ type: 'workflow',
599
+ id: rpcName,
600
+ parentRunId: runId,
601
+ parentStepId: stepState.stepId,
602
+ }
603
+ const { runId: childRunId } = await workflowService.startWorkflow(
604
+ rpcName,
605
+ input,
606
+ childWire,
607
+ rpcService,
608
+ { inline: true }
609
+ )
610
+ await workflowService.setStepChildRunId(stepState.stepId, childRunId)
611
+ const childRun = await workflowService.getRun(childRunId)
612
+ if (childRun?.status === 'failed') {
613
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
614
+ }
615
+ if (childRun?.status === 'cancelled') {
616
+ throw new Error('Sub-workflow was cancelled')
617
+ }
618
+ result = childRun?.output
619
+ } else {
620
+ result = await rpcService.rpcWithWire(rpcName, input, {
621
+ graph: graphWire,
622
+ })
623
+ }
547
624
 
548
625
  if (wireState.branchKey) {
549
626
  await workflowService.setBranchTaken(
@@ -9,6 +9,115 @@ import {
9
9
  type PikkuWorkflowWire,
10
10
  } from './pikku-workflow-service.js'
11
11
 
12
+ describe('pikku-workflow-service inline meta flag', () => {
13
+ test('workflow with inline: true in meta should create inline run and not queue', async () => {
14
+ const ws = new InMemoryWorkflowService()
15
+ const workflowName = 'testInlineMetaFlag'
16
+ const graphHash = 'inline-meta-hash'
17
+
18
+ // Set up a queue service that tracks if anything was queued
19
+ let queued = false
20
+ pikkuState(null, 'package', 'singletonServices', {
21
+ logger: {
22
+ error: () => {},
23
+ info: () => {},
24
+ warn: () => {},
25
+ debug: () => {},
26
+ },
27
+ queueService: {
28
+ add: async () => {
29
+ queued = true
30
+ },
31
+ },
32
+ } as any)
33
+
34
+ const metaState = pikkuState(null, 'workflows', 'meta')
35
+ metaState[workflowName] = {
36
+ name: workflowName,
37
+ pikkuFuncId: workflowName,
38
+ source: 'dsl',
39
+ graphHash,
40
+ inline: true,
41
+ }
42
+
43
+ const functionMetaState = pikkuState(null, 'function', 'meta')
44
+ functionMetaState[workflowName] = {
45
+ name: workflowName,
46
+ sessionless: true,
47
+ permissions: [],
48
+ } as any
49
+
50
+ addWorkflow(workflowName, {
51
+ func: async () => {
52
+ return { ok: true }
53
+ },
54
+ })
55
+
56
+ const { runId } = await ws.startWorkflow(workflowName, {}, {})
57
+
58
+ // The run should be created as inline
59
+ const run = await ws.getRun(runId)
60
+ assert.equal(run?.inline, true, 'run should be marked as inline')
61
+ assert.equal(
62
+ queued,
63
+ false,
64
+ 'nothing should have been queued to the queue service'
65
+ )
66
+
67
+ delete metaState[workflowName]
68
+ delete functionMetaState[workflowName]
69
+ pikkuState(null, 'workflows', 'registrations').delete(workflowName)
70
+ })
71
+
72
+ test('workflow without inline flag should use queue when queueService exists', async () => {
73
+ const ws = new InMemoryWorkflowService()
74
+ const workflowName = 'testNonInlineMetaFlag'
75
+ const graphHash = 'non-inline-meta-hash'
76
+
77
+ let queued = false
78
+ pikkuState(null, 'package', 'singletonServices', {
79
+ queueService: {
80
+ add: async () => {
81
+ queued = true
82
+ },
83
+ },
84
+ } as any)
85
+
86
+ const metaState = pikkuState(null, 'workflows', 'meta')
87
+ metaState[workflowName] = {
88
+ name: workflowName,
89
+ pikkuFuncId: workflowName,
90
+ source: 'dsl',
91
+ graphHash,
92
+ // no inline flag
93
+ }
94
+
95
+ const functionMetaState = pikkuState(null, 'function', 'meta')
96
+ functionMetaState[workflowName] = {
97
+ name: workflowName,
98
+ sessionless: true,
99
+ permissions: [],
100
+ } as any
101
+
102
+ addWorkflow(workflowName, {
103
+ func: async () => {
104
+ return { ok: true }
105
+ },
106
+ })
107
+
108
+ await ws.startWorkflow(workflowName, {}, {})
109
+
110
+ // Wait a tick
111
+ await new Promise((r) => setTimeout(r, 50))
112
+
113
+ assert.equal(queued, true, 'workflow should have been queued')
114
+
115
+ delete metaState[workflowName]
116
+ delete functionMetaState[workflowName]
117
+ pikkuState(null, 'workflows', 'registrations').delete(workflowName)
118
+ })
119
+ })
120
+
12
121
  describe('pikku-workflow-service version mismatch fallback', () => {
13
122
  test('should fall back to stored graph for dsl workflow version mismatch', async () => {
14
123
  const ws = new InMemoryWorkflowService()