@pikku/core 0.12.8 → 0.12.10

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 (98) hide show
  1. package/CHANGELOG.md +31 -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/function/function-runner.d.ts +3 -1
  7. package/dist/function/function-runner.js +5 -1
  8. package/dist/handle-error.js +2 -1
  9. package/dist/middleware/auth-bearer.js +10 -1
  10. package/dist/middleware/auth-cookie.js +34 -25
  11. package/dist/middleware/timeout.js +11 -5
  12. package/dist/pikku-state.js +1 -1
  13. package/dist/services/credential-service.d.ts +40 -0
  14. package/dist/services/credential-service.js +1 -0
  15. package/dist/services/credential-wire-service.d.ts +13 -0
  16. package/dist/services/credential-wire-service.js +31 -0
  17. package/dist/services/index.d.ts +5 -0
  18. package/dist/services/index.js +3 -0
  19. package/dist/services/local-content.d.ts +7 -4
  20. package/dist/services/local-content.js +42 -12
  21. package/dist/services/local-credential-service.d.ts +10 -0
  22. package/dist/services/local-credential-service.js +33 -0
  23. package/dist/services/local-secrets.js +2 -2
  24. package/dist/services/typed-credential-service.d.ts +27 -0
  25. package/dist/services/typed-credential-service.js +40 -0
  26. package/dist/testing/service-tests.d.ts +8 -0
  27. package/dist/testing/service-tests.js +107 -1
  28. package/dist/types/core.types.d.ts +7 -0
  29. package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
  30. package/dist/wirings/ai-agent/ai-agent-prepare.js +2 -1
  31. package/dist/wirings/ai-agent/ai-agent-runner.js +2 -1
  32. package/dist/wirings/ai-agent/ai-agent-stream.js +2 -1
  33. package/dist/wirings/channel/local/local-channel-runner.js +11 -6
  34. package/dist/wirings/credential/credential.types.d.ts +24 -0
  35. package/dist/wirings/credential/credential.types.js +1 -0
  36. package/dist/wirings/credential/index.d.ts +3 -0
  37. package/dist/wirings/credential/index.js +2 -0
  38. package/dist/wirings/credential/validate-credential-definitions.d.ts +6 -0
  39. package/dist/wirings/credential/validate-credential-definitions.js +38 -0
  40. package/dist/wirings/credential/wire-credential.d.ts +48 -0
  41. package/dist/wirings/credential/wire-credential.js +47 -0
  42. package/dist/wirings/http/http-runner.js +8 -2
  43. package/dist/wirings/http/pikku-fetch-http-request.js +11 -1
  44. package/dist/wirings/oauth2/index.d.ts +2 -1
  45. package/dist/wirings/oauth2/index.js +1 -1
  46. package/dist/wirings/oauth2/oauth2-client.js +4 -8
  47. package/dist/wirings/oauth2/oauth2-routes.d.ts +35 -0
  48. package/dist/wirings/oauth2/oauth2-routes.js +146 -0
  49. package/dist/wirings/oauth2/oauth2.types.d.ts +0 -26
  50. package/dist/wirings/workflow/graph/graph-runner.d.ts +8 -1
  51. package/dist/wirings/workflow/graph/graph-runner.js +87 -7
  52. package/dist/wirings/workflow/pikku-workflow-service.d.ts +5 -2
  53. package/dist/wirings/workflow/pikku-workflow-service.js +99 -24
  54. package/dist/wirings/workflow/workflow.types.d.ts +4 -0
  55. package/package.json +2 -1
  56. package/src/env.ts +1 -0
  57. package/src/errors/errors.ts +35 -0
  58. package/src/function/function-runner.ts +11 -0
  59. package/src/handle-error.ts +2 -1
  60. package/src/middleware/auth-bearer.ts +10 -1
  61. package/src/middleware/auth-cookie.ts +11 -4
  62. package/src/middleware/timeout.ts +14 -7
  63. package/src/pikku-state.ts +1 -1
  64. package/src/services/credential-service.ts +44 -0
  65. package/src/services/credential-wire-service.ts +44 -0
  66. package/src/services/index.ts +12 -0
  67. package/src/services/local-content.ts +61 -13
  68. package/src/services/local-credential-service.ts +41 -0
  69. package/src/services/local-secrets.test.ts +2 -2
  70. package/src/services/local-secrets.ts +2 -2
  71. package/src/services/typed-credential-service.ts +75 -0
  72. package/src/testing/service-tests.ts +140 -1
  73. package/src/types/core.types.ts +7 -0
  74. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
  75. package/src/wirings/ai-agent/ai-agent-prepare.ts +2 -1
  76. package/src/wirings/ai-agent/ai-agent-runner.test.ts +34 -0
  77. package/src/wirings/ai-agent/ai-agent-runner.ts +2 -1
  78. package/src/wirings/ai-agent/ai-agent-stream.ts +2 -1
  79. package/src/wirings/channel/local/local-channel-runner.ts +11 -6
  80. package/src/wirings/credential/credential.types.ts +28 -0
  81. package/src/wirings/credential/index.ts +8 -0
  82. package/src/wirings/credential/validate-credential-definitions.ts +64 -0
  83. package/src/wirings/credential/wire-credential.ts +49 -0
  84. package/src/wirings/http/http-runner.ts +11 -2
  85. package/src/wirings/http/pikku-fetch-http-request.ts +11 -1
  86. package/src/wirings/oauth2/index.ts +2 -1
  87. package/src/wirings/oauth2/oauth2-client.ts +4 -8
  88. package/src/wirings/oauth2/oauth2-routes.ts +234 -0
  89. package/src/wirings/oauth2/oauth2.types.ts +0 -27
  90. package/src/wirings/workflow/graph/graph-runner.test.ts +42 -0
  91. package/src/wirings/workflow/graph/graph-runner.ts +117 -8
  92. package/src/wirings/workflow/pikku-workflow-service.test.ts +109 -0
  93. package/src/wirings/workflow/pikku-workflow-service.ts +150 -34
  94. package/src/wirings/workflow/workflow.types.ts +4 -0
  95. package/tsconfig.tsbuildinfo +1 -1
  96. package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +0 -20
  97. package/dist/wirings/oauth2/wire-oauth2-credential.js +0 -21
  98. package/src/wirings/oauth2/wire-oauth2-credential.ts +0 -23
@@ -40,30 +40,3 @@ export type OAuth2Config = {
40
40
  /** Additional query parameters for authorization URL */
41
41
  additionalParams?: Record<string, string>
42
42
  }
43
-
44
- /**
45
- * Configuration for wireOAuth2Credential.
46
- * Combines standard credential fields with OAuth2-specific config.
47
- */
48
- export type CoreOAuth2Credential = {
49
- /** Unique identifier for this credential */
50
- name: string
51
- /** Human-readable name for UI display */
52
- displayName: string
53
- /** Optional description for UI */
54
- description?: string
55
- /** Key used with SecretService to retrieve app credentials (clientId/clientSecret) */
56
- secretId: string
57
- /** Where access/refresh tokens are stored */
58
- tokenSecretId: string
59
- /** OAuth2 authorization URL */
60
- authorizationUrl: string
61
- /** OAuth2 token exchange URL */
62
- tokenUrl: string
63
- /** Required scopes */
64
- scopes: string[]
65
- /** Use PKCE flow */
66
- pkce?: boolean
67
- /** Additional query parameters for authorization URL */
68
- additionalParams?: Record<string, string>
69
- }
@@ -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,24 @@
1
- import type { PikkuWorkflowService } from '../pikku-workflow-service.js'
1
+ import {
2
+ type PikkuWorkflowService,
3
+ WorkflowAsyncException,
4
+ WorkflowSuspendedException,
5
+ } from '../pikku-workflow-service.js'
2
6
  import type { GraphWireState, PikkuGraphWire } from './workflow-graph.types.js'
3
- import { pikkuState } from '../../../pikku-state.js'
7
+ import { pikkuState, getSingletonServices } from '../../../pikku-state.js'
4
8
  import type { WorkflowRuntimeMeta, WorkflowRunWire } from '../workflow.types.js'
5
9
  import { RPCNotFoundError } from '../../rpc/rpc-runner.js'
6
10
 
11
+ export class ChildWorkflowStartedException extends Error {
12
+ name = 'ChildWorkflowStartedException'
13
+ constructor(
14
+ public parentRunId: string,
15
+ public stepId: string,
16
+ public childRunId: string
17
+ ) {
18
+ super(`Child workflow started: ${childRunId}`)
19
+ }
20
+ }
21
+
7
22
  function buildTemplateRegex(nodeId: string): RegExp | null {
8
23
  if (!nodeId.includes('${')) return null
9
24
  const escaped = nodeId
@@ -442,9 +457,49 @@ export async function executeGraphStep(
442
457
  }
443
458
 
444
459
  try {
445
- const result = await rpcService.rpcWithWire(rpcName, data, {
446
- graph: graphWire,
447
- })
460
+ let result: any
461
+
462
+ const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
463
+ if (subWorkflowMeta) {
464
+ const childWire: WorkflowRunWire = {
465
+ type: 'workflow',
466
+ id: rpcName,
467
+ parentRunId: runId,
468
+ parentStepId: stepId,
469
+ }
470
+ const shouldInline =
471
+ subWorkflowMeta.inline || !getSingletonServices()?.queueService
472
+ const { runId: childRunId } = await workflowService.startWorkflow(
473
+ rpcName,
474
+ data,
475
+ childWire,
476
+ rpcService,
477
+ { inline: shouldInline }
478
+ )
479
+ await workflowService.setStepChildRunId(stepId, childRunId)
480
+
481
+ if (shouldInline) {
482
+ const childRun = await workflowService.getRun(childRunId)
483
+ if (childRun?.status === 'failed') {
484
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
485
+ }
486
+ if (childRun?.status === 'cancelled') {
487
+ throw new Error('Sub-workflow was cancelled')
488
+ }
489
+ result = childRun?.output
490
+ } else {
491
+ throw new ChildWorkflowStartedException(runId, stepId, childRunId)
492
+ }
493
+ } else {
494
+ result = await rpcService.rpcWithWire(rpcName, data, {
495
+ graph: graphWire,
496
+ workflow: workflowService.createWorkflowWire(
497
+ graphName,
498
+ runId,
499
+ rpcService
500
+ ),
501
+ })
502
+ }
448
503
 
449
504
  if (wireState.branchKey) {
450
505
  await workflowService.setBranchTaken(stepId, wireState.branchKey)
@@ -452,6 +507,15 @@ export async function executeGraphStep(
452
507
 
453
508
  return result
454
509
  } catch (error) {
510
+ if (
511
+ error instanceof WorkflowAsyncException ||
512
+ error instanceof WorkflowSuspendedException
513
+ ) {
514
+ throw error
515
+ }
516
+ if (error instanceof ChildWorkflowStartedException) {
517
+ throw error
518
+ }
455
519
  if (error instanceof RPCNotFoundError) {
456
520
  await workflowService.updateRunStatus(runId, 'suspended', undefined, {
457
521
  message: `RPC '${rpcName}' not found. Deploy the missing function and resume.`,
@@ -541,9 +605,42 @@ async function executeGraphNodeInline(
541
605
  }
542
606
 
543
607
  try {
544
- const result = await rpcService.rpcWithWire(rpcName, input, {
545
- graph: graphWire,
546
- })
608
+ let result: any
609
+
610
+ const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName]
611
+ if (subWorkflowMeta) {
612
+ const childWire: WorkflowRunWire = {
613
+ type: 'workflow',
614
+ id: rpcName,
615
+ parentRunId: runId,
616
+ parentStepId: stepState.stepId,
617
+ }
618
+ const { runId: childRunId } = await workflowService.startWorkflow(
619
+ rpcName,
620
+ input,
621
+ childWire,
622
+ rpcService,
623
+ { inline: true }
624
+ )
625
+ await workflowService.setStepChildRunId(stepState.stepId, childRunId)
626
+ const childRun = await workflowService.getRun(childRunId)
627
+ if (childRun?.status === 'failed') {
628
+ throw new Error(childRun.error?.message || 'Sub-workflow failed')
629
+ }
630
+ if (childRun?.status === 'cancelled') {
631
+ throw new Error('Sub-workflow was cancelled')
632
+ }
633
+ result = childRun?.output
634
+ } else {
635
+ result = await rpcService.rpcWithWire(rpcName, input, {
636
+ graph: graphWire,
637
+ workflow: workflowService.createWorkflowWire(
638
+ graphName,
639
+ runId,
640
+ rpcService
641
+ ),
642
+ })
643
+ }
547
644
 
548
645
  if (wireState.branchKey) {
549
646
  await workflowService.setBranchTaken(
@@ -554,6 +651,12 @@ async function executeGraphNodeInline(
554
651
 
555
652
  await workflowService.setStepResult(stepState.stepId, result)
556
653
  } catch (error) {
654
+ if (
655
+ error instanceof WorkflowAsyncException ||
656
+ error instanceof WorkflowSuspendedException
657
+ ) {
658
+ throw error
659
+ }
557
660
  if (error instanceof RPCNotFoundError) {
558
661
  await workflowService.setStepError(stepState.stepId, error as Error)
559
662
  await workflowService.updateRunStatus(runId, 'suspended', undefined, {
@@ -778,6 +881,12 @@ export async function runWorkflowGraph(
778
881
  entryNodes
779
882
  )
780
883
  } catch (error) {
884
+ if (
885
+ error instanceof WorkflowAsyncException ||
886
+ error instanceof WorkflowSuspendedException
887
+ ) {
888
+ return
889
+ }
781
890
  await workflowService.updateRunStatus(runId, 'failed', undefined, {
782
891
  message: (error as Error).message,
783
892
  stack: (error as Error).stack || '',
@@ -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()
@@ -26,6 +26,7 @@ import {
26
26
  import type { WorkflowService } from '../../services/workflow-service.js'
27
27
  import { PikkuError, addError } from '../../errors/error-handler.js'
28
28
  import { RPCNotFoundError } from '../rpc/rpc-runner.js'
29
+ import { ChildWorkflowStartedException } from './graph/graph-runner.js'
29
30
 
30
31
  /**
31
32
  * Exception thrown when workflow needs to pause for async step
@@ -111,6 +112,10 @@ const WORKFLOW_END_STATES: ReadonlySet<string> = new Set([
111
112
  export abstract class PikkuWorkflowService implements WorkflowService {
112
113
  private inlineRuns = new Set<string>()
113
114
 
115
+ protected get logger() {
116
+ return getSingletonServices()?.logger
117
+ }
118
+
114
119
  constructor() {}
115
120
 
116
121
  /**
@@ -386,12 +391,17 @@ export abstract class PikkuWorkflowService implements WorkflowService {
386
391
  data: any
387
392
  ): Promise<void> {
388
393
  const queueService = this.verifyQueueService()
389
- await queueService.add(this.getConfig().stepWorkerQueueName, {
390
- runId,
391
- stepName,
392
- rpcName,
393
- data,
394
- })
394
+ await queueService.add(
395
+ this.getConfig().stepWorkerQueueName,
396
+ JSON.parse(
397
+ JSON.stringify({
398
+ runId,
399
+ stepName,
400
+ rpcName,
401
+ data,
402
+ })
403
+ )
404
+ )
395
405
  }
396
406
 
397
407
  /**
@@ -447,7 +457,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
447
457
 
448
458
  if (workflowMeta.source === 'graph' || workflowMeta.source === 'ai-agent') {
449
459
  const shouldInline =
450
- options?.inline || !getSingletonServices()?.queueService
460
+ options?.inline ||
461
+ workflowMeta.inline ||
462
+ !getSingletonServices()?.queueService
451
463
  return runWorkflowGraph(
452
464
  this,
453
465
  name,
@@ -472,7 +484,9 @@ export abstract class PikkuWorkflowService implements WorkflowService {
472
484
  }
473
485
 
474
486
  const shouldInline =
475
- options?.inline || !getSingletonServices()?.queueService
487
+ options?.inline ||
488
+ workflowMeta.inline ||
489
+ !getSingletonServices()?.queueService
476
490
 
477
491
  const runId = await this.createRun(
478
492
  name,
@@ -484,16 +498,22 @@ export abstract class PikkuWorkflowService implements WorkflowService {
484
498
 
485
499
  if (shouldInline) {
486
500
  this.inlineRuns.add(runId)
487
- this.runWorkflowJob(runId, rpcService)
488
- .catch((err) => {
501
+ try {
502
+ await this.runWorkflowJob(runId, rpcService)
503
+ } catch (error: any) {
504
+ if (
505
+ error.name !== 'WorkflowAsyncException' &&
506
+ error.name !== 'WorkflowCancelledException' &&
507
+ error.name !== 'WorkflowSuspendedException'
508
+ ) {
489
509
  getSingletonServices()!.logger.error(
490
510
  `Workflow ${name} (run ${runId}) failed:`,
491
- err
511
+ error
492
512
  )
493
- })
494
- .finally(() => {
495
- this.inlineRuns.delete(runId)
496
- })
513
+ }
514
+ } finally {
515
+ this.inlineRuns.delete(runId)
516
+ }
497
517
  } else {
498
518
  await this.resumeWorkflow(runId)
499
519
  }
@@ -553,6 +573,18 @@ export abstract class PikkuWorkflowService implements WorkflowService {
553
573
 
554
574
  if (workflowMeta?.source === 'graph') {
555
575
  await continueGraph(this, runId, run.workflow)
576
+ const updatedRun = await this.getRun(runId)
577
+ if (updatedRun?.status === 'completed') {
578
+ await this.onChildWorkflowCompleted(updatedRun, updatedRun.output)
579
+ } else if (
580
+ updatedRun?.status === 'failed' ||
581
+ updatedRun?.status === 'cancelled'
582
+ ) {
583
+ await this.onChildWorkflowFailed(
584
+ updatedRun,
585
+ new Error(updatedRun.error?.message || 'Child workflow failed')
586
+ )
587
+ }
556
588
  return
557
589
  }
558
590
 
@@ -587,6 +619,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
587
619
  )
588
620
 
589
621
  await this.updateRunStatus(runId, 'completed', result)
622
+ await this.onChildWorkflowCompleted(run, result)
590
623
  } catch (error: any) {
591
624
  if (error instanceof WorkflowAsyncException) {
592
625
  throw error
@@ -598,6 +631,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
598
631
  stack: '',
599
632
  code: 'WORKFLOW_CANCELLED',
600
633
  })
634
+ await this.onChildWorkflowFailed(run, error)
601
635
  throw error
602
636
  }
603
637
 
@@ -615,12 +649,41 @@ export abstract class PikkuWorkflowService implements WorkflowService {
615
649
  stack: error.stack,
616
650
  code: error.code,
617
651
  })
652
+ await this.onChildWorkflowFailed(run, error)
618
653
 
619
654
  throw error
620
655
  }
621
656
  })
622
657
  }
623
658
 
659
+ private async onChildWorkflowCompleted(
660
+ childRun: WorkflowRun,
661
+ result: any
662
+ ): Promise<void> {
663
+ const { parentRunId, parentStepId } = childRun.wire ?? {}
664
+ if (!parentRunId || !parentStepId) return
665
+
666
+ this.logger?.debug(
667
+ `Child workflow ${childRun.id} completed, updating parent step ${parentStepId}`
668
+ )
669
+ await this.setStepResult(parentStepId, result)
670
+ await this.resumeWorkflow(parentRunId)
671
+ }
672
+
673
+ private async onChildWorkflowFailed(
674
+ childRun: WorkflowRun,
675
+ error: Error
676
+ ): Promise<void> {
677
+ const { parentRunId, parentStepId } = childRun.wire ?? {}
678
+ if (!parentRunId || !parentStepId) return
679
+
680
+ this.logger?.debug(
681
+ `Child workflow ${childRun.id} failed, updating parent step ${parentStepId}`
682
+ )
683
+ await this.setStepError(parentStepId, error)
684
+ await this.resumeWorkflow(parentRunId)
685
+ }
686
+
624
687
  private async runVersionMismatchFallback(
625
688
  run: WorkflowRun,
626
689
  currentMeta: { source: string },
@@ -666,9 +729,8 @@ export abstract class PikkuWorkflowService implements WorkflowService {
666
729
  // Get step state
667
730
  let stepState = await this.getStepState(runId, stepName)
668
731
 
669
- // Idempotency - if already succeeded, resume orchestrator and return
732
+ // Idempotency - if already succeeded, nothing to do
670
733
  if (stepState.status === 'succeeded') {
671
- await this.resumeWorkflow(runId)
672
734
  return
673
735
  }
674
736
 
@@ -682,8 +744,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
682
744
  stepState = await this.createRetryAttempt(stepState.stepId, 'running')
683
745
  }
684
746
 
685
- if (stepState.status === 'pending') {
686
- // Mark pending step as running before execution
747
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
687
748
  await this.setStepRunning(stepState.stepId)
688
749
  }
689
750
 
@@ -698,7 +759,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
698
759
  const meta = pikkuState(null, 'workflows', 'meta')
699
760
  const workflowMeta = meta[run.workflow]
700
761
 
701
- if (workflowMeta?.nodes && stepName in workflowMeta.nodes) {
762
+ const isGraphWorkflow =
763
+ workflowMeta?.source === 'graph' ||
764
+ workflowMeta?.source === 'ai-agent'
765
+ if (
766
+ isGraphWorkflow &&
767
+ workflowMeta?.nodes &&
768
+ stepName in workflowMeta.nodes
769
+ ) {
702
770
  result = await executeGraphStep(
703
771
  this,
704
772
  rpcService,
@@ -710,13 +778,52 @@ export abstract class PikkuWorkflowService implements WorkflowService {
710
778
  run.workflow
711
779
  )
712
780
  } else {
713
- result = await rpcService.rpcWithWire(rpcName, data, {
714
- workflowStep: {
715
- runId,
716
- stepId: stepState.stepId,
717
- attemptCount: stepState.attemptCount,
718
- },
719
- })
781
+ // Check if rpcName refers to a sub-workflow
782
+ const subWorkflowMeta = meta[rpcName]
783
+ if (subWorkflowMeta) {
784
+ const childWire: WorkflowRunWire = {
785
+ type: 'workflow',
786
+ id: rpcName,
787
+ parentRunId: runId,
788
+ parentStepId: stepState.stepId,
789
+ }
790
+ const shouldInline =
791
+ subWorkflowMeta.inline || !getSingletonServices()?.queueService
792
+ const { runId: childRunId } = await this.startWorkflow(
793
+ rpcName,
794
+ data,
795
+ childWire,
796
+ rpcService,
797
+ { inline: shouldInline }
798
+ )
799
+ await this.setStepChildRunId(stepState.stepId, childRunId)
800
+ if (shouldInline) {
801
+ const childRun = await this.getRun(childRunId)
802
+ if (childRun?.status === 'failed') {
803
+ throw new Error(
804
+ childRun.error?.message || 'Sub-workflow failed'
805
+ )
806
+ }
807
+ if (childRun?.status === 'cancelled') {
808
+ throw new Error('Sub-workflow was cancelled')
809
+ }
810
+ result = childRun?.output
811
+ } else {
812
+ throw new ChildWorkflowStartedException(
813
+ runId,
814
+ stepState.stepId,
815
+ childRunId
816
+ )
817
+ }
818
+ } else {
819
+ result = await rpcService.rpcWithWire(rpcName, data, {
820
+ workflowStep: {
821
+ runId,
822
+ stepId: stepState.stepId,
823
+ attemptCount: stepState.attemptCount,
824
+ },
825
+ })
826
+ }
720
827
  }
721
828
 
722
829
  // Store result and mark succeeded
@@ -725,6 +832,13 @@ export abstract class PikkuWorkflowService implements WorkflowService {
725
832
  // Resume orchestrator to continue workflow
726
833
  await this.resumeWorkflow(runId)
727
834
  } catch (error: any) {
835
+ if (error instanceof ChildWorkflowStartedException) {
836
+ this.logger?.debug(
837
+ `Workflow step '${stepName}': child workflow ${error.childRunId} started, waiting for completion`
838
+ )
839
+ return
840
+ }
841
+
728
842
  if (error instanceof RPCNotFoundError) {
729
843
  await this.setStepError(stepState.stepId, error)
730
844
  await this.updateRunStatus(runId, 'suspended', undefined, {
@@ -848,12 +962,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
848
962
 
849
963
  await getSingletonServices()!.queueService!.add(
850
964
  this.getConfig().stepWorkerQueueName,
851
- {
852
- runId,
853
- stepName,
854
- rpcName,
855
- data,
856
- },
965
+ JSON.parse(
966
+ JSON.stringify({
967
+ runId,
968
+ stepName,
969
+ rpcName,
970
+ data,
971
+ })
972
+ ),
857
973
  {
858
974
  // attempts includes initial attempt, retries doesn't
859
975
  attempts: retries + 1,
@@ -1153,7 +1269,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1153
1269
  })
1154
1270
  }
1155
1271
 
1156
- private createWorkflowWire(
1272
+ public createWorkflowWire(
1157
1273
  name: string,
1158
1274
  runId: string,
1159
1275
  rpcService: any
@@ -40,6 +40,7 @@ export interface WorkflowRunWire {
40
40
  type: string
41
41
  id?: string
42
42
  parentRunId?: string
43
+ parentStepId?: string
43
44
  }
44
45
 
45
46
  export interface WorkflowServiceConfig {
@@ -224,6 +225,7 @@ export type WorkflowsMeta = Record<
224
225
  steps: WorkflowStepMeta[]
225
226
  context?: WorkflowContext
226
227
  dsl?: boolean
228
+ inline?: boolean
227
229
  }
228
230
  >
229
231
 
@@ -243,6 +245,8 @@ export interface WorkflowRuntimeMeta {
243
245
  description?: string
244
246
  /** Tags for organization */
245
247
  tags?: string[]
248
+ /** If true, workflow always executes inline without queues */
249
+ inline?: boolean
246
250
  /** Serialized nodes */
247
251
  nodes?: Record<string, any>
248
252
  /** Entry node IDs for graph workflows (computed at build time) */