@pikku/core 0.12.9 → 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 (61) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/function/function-runner.d.ts +3 -1
  3. package/dist/function/function-runner.js +5 -1
  4. package/dist/services/credential-service.d.ts +40 -0
  5. package/dist/services/credential-service.js +1 -0
  6. package/dist/services/credential-wire-service.d.ts +13 -0
  7. package/dist/services/credential-wire-service.js +31 -0
  8. package/dist/services/index.d.ts +5 -0
  9. package/dist/services/index.js +3 -0
  10. package/dist/services/local-credential-service.d.ts +10 -0
  11. package/dist/services/local-credential-service.js +33 -0
  12. package/dist/services/typed-credential-service.d.ts +27 -0
  13. package/dist/services/typed-credential-service.js +40 -0
  14. package/dist/testing/service-tests.d.ts +8 -0
  15. package/dist/testing/service-tests.js +106 -0
  16. package/dist/types/core.types.d.ts +7 -0
  17. package/dist/wirings/ai-agent/agent-dynamic-workflow.js +173 -53
  18. package/dist/wirings/credential/credential.types.d.ts +24 -0
  19. package/dist/wirings/credential/credential.types.js +1 -0
  20. package/dist/wirings/credential/index.d.ts +3 -0
  21. package/dist/wirings/credential/index.js +2 -0
  22. package/dist/wirings/credential/validate-credential-definitions.d.ts +6 -0
  23. package/dist/wirings/credential/validate-credential-definitions.js +38 -0
  24. package/dist/wirings/credential/wire-credential.d.ts +48 -0
  25. package/dist/wirings/credential/wire-credential.js +47 -0
  26. package/dist/wirings/http/http-runner.js +4 -0
  27. package/dist/wirings/oauth2/index.d.ts +2 -1
  28. package/dist/wirings/oauth2/index.js +1 -1
  29. package/dist/wirings/oauth2/oauth2-client.js +4 -8
  30. package/dist/wirings/oauth2/oauth2-routes.d.ts +35 -0
  31. package/dist/wirings/oauth2/oauth2-routes.js +146 -0
  32. package/dist/wirings/oauth2/oauth2.types.d.ts +0 -26
  33. package/dist/wirings/workflow/graph/graph-runner.d.ts +1 -1
  34. package/dist/wirings/workflow/graph/graph-runner.js +15 -0
  35. package/dist/wirings/workflow/pikku-workflow-service.d.ts +2 -2
  36. package/dist/wirings/workflow/pikku-workflow-service.js +40 -8
  37. package/package.json +2 -1
  38. package/src/function/function-runner.ts +11 -0
  39. package/src/services/credential-service.ts +44 -0
  40. package/src/services/credential-wire-service.ts +44 -0
  41. package/src/services/index.ts +12 -0
  42. package/src/services/local-credential-service.ts +41 -0
  43. package/src/services/typed-credential-service.ts +75 -0
  44. package/src/testing/service-tests.ts +139 -0
  45. package/src/types/core.types.ts +7 -0
  46. package/src/wirings/ai-agent/agent-dynamic-workflow.ts +378 -237
  47. package/src/wirings/credential/credential.types.ts +28 -0
  48. package/src/wirings/credential/index.ts +8 -0
  49. package/src/wirings/credential/validate-credential-definitions.ts +64 -0
  50. package/src/wirings/credential/wire-credential.ts +49 -0
  51. package/src/wirings/http/http-runner.ts +7 -0
  52. package/src/wirings/oauth2/index.ts +2 -1
  53. package/src/wirings/oauth2/oauth2-client.ts +4 -8
  54. package/src/wirings/oauth2/oauth2-routes.ts +234 -0
  55. package/src/wirings/oauth2/oauth2.types.ts +0 -27
  56. package/src/wirings/workflow/graph/graph-runner.ts +33 -1
  57. package/src/wirings/workflow/pikku-workflow-service.ts +55 -9
  58. package/tsconfig.tsbuildinfo +1 -1
  59. package/dist/wirings/oauth2/wire-oauth2-credential.d.ts +0 -20
  60. package/dist/wirings/oauth2/wire-oauth2-credential.js +0 -21
  61. package/src/wirings/oauth2/wire-oauth2-credential.ts +0 -23
@@ -0,0 +1,28 @@
1
+ import type { OAuth2CredentialConfig } from '../secret/secret.types.js'
2
+
3
+ export type CoreCredential<T = unknown> = {
4
+ name: string
5
+ displayName: string
6
+ description?: string
7
+ type: 'singleton' | 'wire'
8
+ schema: T
9
+ oauth2?: OAuth2CredentialConfig & {
10
+ appCredentialSecretId: string
11
+ }
12
+ }
13
+
14
+ export type CredentialDefinitionMeta = {
15
+ name: string
16
+ displayName: string
17
+ description?: string
18
+ type: 'singleton' | 'wire'
19
+ schema?: Record<string, unknown> | string
20
+ oauth2?: OAuth2CredentialConfig & {
21
+ appCredentialSecretId: string
22
+ }
23
+ sourceFile?: string
24
+ }
25
+
26
+ export type CredentialDefinitionsMeta = Record<string, CredentialDefinitionMeta>
27
+
28
+ export type CredentialDefinitions = CredentialDefinitionMeta[]
@@ -0,0 +1,8 @@
1
+ export { wireCredential } from './wire-credential.js'
2
+ export { validateAndBuildCredentialDefinitionsMeta } from './validate-credential-definitions.js'
3
+ export type {
4
+ CoreCredential,
5
+ CredentialDefinitionMeta,
6
+ CredentialDefinitionsMeta,
7
+ CredentialDefinitions,
8
+ } from './credential.types.js'
@@ -0,0 +1,64 @@
1
+ import type {
2
+ CredentialDefinitions,
3
+ CredentialDefinitionsMeta,
4
+ } from './credential.types.js'
5
+
6
+ export interface SchemaRefLike {
7
+ variableName: string
8
+ sourceFile: string
9
+ }
10
+
11
+ export function validateAndBuildCredentialDefinitionsMeta(
12
+ definitions: CredentialDefinitions,
13
+ schemaLookup: Map<string, SchemaRefLike>
14
+ ): CredentialDefinitionsMeta {
15
+ const meta: CredentialDefinitionsMeta = {}
16
+
17
+ for (const def of definitions) {
18
+ const existing = meta[def.name]
19
+
20
+ if (existing) {
21
+ if (def.schema && existing.schema) {
22
+ const defSchemaRef = schemaLookup.get(def.schema as string)
23
+ const existingSchemaRef = schemaLookup.get(existing.schema as string)
24
+
25
+ if (defSchemaRef && existingSchemaRef) {
26
+ if (
27
+ defSchemaRef.variableName !== existingSchemaRef.variableName ||
28
+ defSchemaRef.sourceFile !== existingSchemaRef.sourceFile
29
+ ) {
30
+ throw new Error(
31
+ `Credential '${def.name}' is defined with different schemas.\n` +
32
+ ` First definition: ${existing.sourceFile} (schema: ${existingSchemaRef.variableName})\n` +
33
+ ` Second definition: ${def.sourceFile} (schema: ${defSchemaRef.variableName})\n` +
34
+ `Credentials sharing a name must use the same schema.`
35
+ )
36
+ }
37
+ }
38
+ }
39
+
40
+ if (def.type !== existing.type) {
41
+ throw new Error(
42
+ `Credential '${def.name}' is defined with different types.\n` +
43
+ ` First definition: ${existing.sourceFile} (type: ${existing.type})\n` +
44
+ ` Second definition: ${def.sourceFile} (type: ${def.type})\n` +
45
+ `Credentials sharing a name must use the same type.`
46
+ )
47
+ }
48
+
49
+ continue
50
+ }
51
+
52
+ meta[def.name] = {
53
+ name: def.name,
54
+ displayName: def.displayName,
55
+ description: def.description,
56
+ type: def.type,
57
+ schema: def.schema,
58
+ oauth2: def.oauth2,
59
+ sourceFile: def.sourceFile,
60
+ }
61
+ }
62
+
63
+ return meta
64
+ }
@@ -0,0 +1,49 @@
1
+ import type { CoreCredential } from './credential.types.js'
2
+
3
+ /**
4
+ * No-op function for declaring credentials.
5
+ * This exists purely for TypeScript type checking and will be tree-shaken.
6
+ * The CLI extracts metadata via AST parsing.
7
+ *
8
+ * @example
9
+ * ```typescript
10
+ * // Per-user API key
11
+ * wireCredential({
12
+ * name: 'stripe',
13
+ * displayName: 'Stripe API Key',
14
+ * type: 'wire',
15
+ * schema: z.object({ apiKey: z.string() }),
16
+ * })
17
+ *
18
+ * // Per-user OAuth
19
+ * wireCredential({
20
+ * name: 'google-sheets',
21
+ * displayName: 'Google Sheets',
22
+ * type: 'wire',
23
+ * schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
24
+ * oauth2: {
25
+ * appCredentialSecretId: 'GOOGLE_OAUTH_APP',
26
+ * authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
27
+ * tokenUrl: 'https://oauth2.googleapis.com/token',
28
+ * scopes: ['https://www.googleapis.com/auth/spreadsheets'],
29
+ * tokenSecretId: 'GOOGLE_OAUTH_TOKENS',
30
+ * }
31
+ * })
32
+ *
33
+ * // Platform-level OAuth (singleton)
34
+ * wireCredential({
35
+ * name: 'slack',
36
+ * displayName: 'Slack',
37
+ * type: 'singleton',
38
+ * schema: z.object({ accessToken: z.string(), refreshToken: z.string() }),
39
+ * oauth2: {
40
+ * appCredentialSecretId: 'SLACK_OAUTH_APP',
41
+ * authorizationUrl: 'https://slack.com/oauth/v2/authorize',
42
+ * tokenUrl: 'https://slack.com/api/oauth.v2.access',
43
+ * scopes: ['chat:write', 'channels:read'],
44
+ * tokenSecretId: 'SLACK_OAUTH_TOKENS',
45
+ * }
46
+ * })
47
+ * ```
48
+ */
49
+ export const wireCredential = <T>(_config: CoreCredential<T>): void => {}
@@ -33,6 +33,10 @@ import {
33
33
  } from '../../pikku-state.js'
34
34
  import { PikkuSessionService } from '../../services/user-session-service.js'
35
35
  import { getErrorResponse } from '../../errors/error-handler.js'
36
+ import {
37
+ PikkuCredentialWireService,
38
+ createMiddlewareCredentialWireProps,
39
+ } from '../../services/credential-wire-service.js'
36
40
  import { handleHTTPError } from '../../handle-error.js'
37
41
  import { pikkuState } from '../../pikku-state.js'
38
42
  import { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js'
@@ -299,6 +303,7 @@ const executeRoute = async (
299
303
  }
300
304
  ) => {
301
305
  const userSession = new PikkuSessionService<CoreUserSession>()
306
+ const credentialWire = new PikkuCredentialWireService()
302
307
  const { params, route, meta } = matchedRoute
303
308
  const { singletonServices, createWireServices, skipUserSession, requestId } =
304
309
  services
@@ -377,6 +382,7 @@ const executeRoute = async (
377
382
  setSession: (s: any) => userSession.setInitial(s),
378
383
  getSession: () => userSession.get(),
379
384
  hasSessionChanged: () => userSession.sessionChanged,
385
+ ...createMiddlewareCredentialWireProps(credentialWire),
380
386
  }
381
387
 
382
388
  result = await runPikkuFunc(
@@ -396,6 +402,7 @@ const executeRoute = async (
396
402
  tags: route.tags,
397
403
  wire,
398
404
  sessionService: userSession,
405
+ credentialWireService: credentialWire,
399
406
  packageName: meta.packageName,
400
407
  }
401
408
  )
@@ -1,3 +1,4 @@
1
1
  export { OAuth2Client } from './oauth2-client.js'
2
- export { wireOAuth2Credential } from './wire-oauth2-credential.js'
2
+ export { createOAuth2Handler } from './oauth2-routes.js'
3
+ export type { CreateOAuth2HandlerOptions } from './oauth2-routes.js'
3
4
  export type { OAuth2AppCredential, OAuth2Token } from './oauth2.types.js'
@@ -30,7 +30,8 @@ import type { SecretService } from '../../services/secret-service.js'
30
30
  * })
31
31
  * ```
32
32
  */
33
- const DEFAULT_TIMEOUT_MS = 30000
33
+ const DEFAULT_TIMEOUT_MS = 30_000
34
+ const TOKEN_EXPIRY_BUFFER_MS = 60_000
34
35
 
35
36
  /**
36
37
  * Helper to fetch with a timeout using AbortController.
@@ -145,18 +146,15 @@ export class OAuth2Client {
145
146
  * Uses a promise lock to prevent concurrent refresh attempts.
146
147
  */
147
148
  private async refreshAndGetToken(): Promise<string> {
148
- // If already refreshing, wait for that to complete
149
149
  if (this.refreshPromise) {
150
150
  const token = await this.refreshPromise
151
151
  return token.accessToken
152
152
  }
153
153
 
154
- // Start refresh
155
154
  this.refreshPromise = this.doRefreshToken()
156
155
 
157
156
  try {
158
157
  const token = await this.refreshPromise
159
- this.cachedToken = token
160
158
  return token.accessToken
161
159
  } finally {
162
160
  this.refreshPromise = null
@@ -211,9 +209,8 @@ export class OAuth2Client {
211
209
  scope: data.scope,
212
210
  }
213
211
 
214
- // Cache and persist the refreshed token
215
- this.cachedToken = token
216
212
  await this.secrets.setSecretJSON(this.oauth2Config.tokenSecretId, token)
213
+ this.cachedToken = token
217
214
 
218
215
  return token
219
216
  }
@@ -225,8 +222,7 @@ export class OAuth2Client {
225
222
  if (!token.expiresAt) {
226
223
  return true // No expiry info, assume valid
227
224
  }
228
- // Add 60 second buffer to account for clock skew
229
- return token.expiresAt > Date.now() + 60000
225
+ return token.expiresAt > Date.now() + TOKEN_EXPIRY_BUFFER_MS
230
226
  }
231
227
 
232
228
  /**
@@ -0,0 +1,234 @@
1
+ import type { CredentialService } from '../../services/credential-service.js'
2
+ import type { SecretService } from '../../services/secret-service.js'
3
+ import type { JWTService } from '../../services/jwt-service.js'
4
+ import type { CredentialDefinitionsMeta } from '../credential/credential.types.js'
5
+ import type { OAuth2Token } from './oauth2.types.js'
6
+ import { OAuth2Client } from './oauth2-client.js'
7
+
8
+ const TOKEN_EXPIRY_BUFFER_MS = 60_000
9
+
10
+ export type CreateOAuth2HandlerOptions = {
11
+ credentialsMeta: CredentialDefinitionsMeta
12
+ basePath?: string
13
+ }
14
+
15
+ type OAuth2RouteContext = {
16
+ credentialsMeta: CredentialDefinitionsMeta
17
+ basePath: string
18
+ }
19
+
20
+ function getCredentialMeta(ctx: OAuth2RouteContext, name: string) {
21
+ const meta = ctx.credentialsMeta[name]
22
+ if (!meta) {
23
+ throw new Error(`Credential '${name}' not found`)
24
+ }
25
+ if (!meta.oauth2) {
26
+ throw new Error(`Credential '${name}' is not an OAuth2 credential`)
27
+ }
28
+ return meta
29
+ }
30
+
31
+ /**
32
+ * Creates OAuth2 route handlers for user credential management.
33
+ *
34
+ * Returns individual handler functions for connect/callback/disconnect/status
35
+ * that handle the OAuth2 authorization code flow and store tokens in CredentialService.
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * const oauth2 = createOAuth2Handler({ credentialsMeta })
40
+ *
41
+ * const oauth2Routes = defineHTTPRoutes({
42
+ * auth: true,
43
+ * basePath: '/credentials',
44
+ * routes: {
45
+ * connect: { method: 'get', route: '/:name/connect', func: oauth2.connect },
46
+ * callback: { method: 'get', route: '/:name/callback', func: oauth2.callback, auth: false },
47
+ * disconnect: { method: 'delete', route: '/:name', func: oauth2.disconnect },
48
+ * status: { method: 'get', route: '/:name/status', func: oauth2.status },
49
+ * },
50
+ * })
51
+ *
52
+ * wireHTTPRoutes({ routes: { credentials: oauth2Routes } })
53
+ * ```
54
+ */
55
+ export const createOAuth2Handler = (options: CreateOAuth2HandlerOptions) => {
56
+ const basePath = options.basePath ?? '/credentials'
57
+ const ctx: OAuth2RouteContext = {
58
+ credentialsMeta: options.credentialsMeta,
59
+ basePath,
60
+ }
61
+
62
+ const connectHandler = async (
63
+ services: {
64
+ secrets: SecretService
65
+ jwt: JWTService
66
+ credentialService: CredentialService
67
+ },
68
+ _data: any,
69
+ wire: any
70
+ ) => {
71
+ const { name } = wire.http.request.params()
72
+ const meta = getCredentialMeta(ctx, name)
73
+ const queryParams = wire.http.request.query()
74
+ const redirectUrl = queryParams.redirect_url || queryParams.redirect
75
+
76
+ const oauth2Client = new OAuth2Client(
77
+ meta.oauth2!,
78
+ meta.oauth2!.appCredentialSecretId,
79
+ services.secrets
80
+ )
81
+
82
+ const userId = wire.session?.userId
83
+ if (!userId) {
84
+ throw new Error('Authentication required for OAuth2 connect')
85
+ }
86
+
87
+ const state = await services.jwt.encode(
88
+ { value: 10, unit: 'minute' },
89
+ {
90
+ userId,
91
+ credentialName: name,
92
+ redirectUrl,
93
+ }
94
+ )
95
+
96
+ let origin = wire.http.request.header('origin')
97
+ if (!origin) {
98
+ const host = wire.http.request.header('host')
99
+ if (!host) {
100
+ throw new Error(
101
+ 'Unable to determine request origin for OAuth2 callback'
102
+ )
103
+ }
104
+ const protocol = wire.http.request.header('x-forwarded-proto') || 'http'
105
+ origin = `${protocol}://${host}`
106
+ }
107
+ const callbackUrl = `${origin}${basePath}/${name}/callback`
108
+ const authUrl = await oauth2Client.getAuthorizationUrl(state, callbackUrl)
109
+
110
+ return Response.redirect(authUrl, 302)
111
+ }
112
+
113
+ const callbackHandler = async (
114
+ services: {
115
+ secrets: SecretService
116
+ jwt: JWTService
117
+ credentialService: CredentialService
118
+ logger: any
119
+ },
120
+ _data: any,
121
+ wire: any
122
+ ) => {
123
+ const { name } = wire.http.request.params()
124
+ const meta = getCredentialMeta(ctx, name)
125
+ const queryParams = wire.http.request.query()
126
+ const code = queryParams.code as string | undefined
127
+ const state = queryParams.state as string | undefined
128
+
129
+ if (!code || !state) {
130
+ throw new Error('Missing code or state parameter')
131
+ }
132
+
133
+ let statePayload: {
134
+ userId: string
135
+ credentialName: string
136
+ redirectUrl?: string
137
+ }
138
+ try {
139
+ statePayload = await services.jwt.decode(state)
140
+ } catch {
141
+ throw new Error('Invalid or expired OAuth2 state')
142
+ }
143
+
144
+ if (statePayload.credentialName !== name) {
145
+ throw new Error('Credential name mismatch in state')
146
+ }
147
+
148
+ const oauth2Client = new OAuth2Client(
149
+ meta.oauth2!,
150
+ meta.oauth2!.appCredentialSecretId,
151
+ services.secrets
152
+ )
153
+
154
+ let origin = wire.http.request.header('origin')
155
+ if (!origin) {
156
+ const host = wire.http.request.header('host')
157
+ if (!host) {
158
+ throw new Error(
159
+ 'Unable to determine request origin for OAuth2 callback'
160
+ )
161
+ }
162
+ const protocol = wire.http.request.header('x-forwarded-proto') || 'http'
163
+ origin = `${protocol}://${host}`
164
+ }
165
+ const callbackUrl = `${origin}${basePath}/${name}/callback`
166
+ const tokens = await oauth2Client.exchangeCode(code, callbackUrl)
167
+
168
+ await services.credentialService.set(name, tokens, statePayload.userId)
169
+
170
+ services.logger.info(
171
+ `OAuth2 tokens stored for user '${statePayload.userId}', credential '${name}'`
172
+ )
173
+
174
+ if (statePayload.redirectUrl) {
175
+ return Response.redirect(statePayload.redirectUrl, 302)
176
+ } else {
177
+ return { success: true, credentialName: name }
178
+ }
179
+ }
180
+
181
+ const disconnectHandler = async (
182
+ services: { credentialService: CredentialService; logger: any },
183
+ _data: any,
184
+ wire: any
185
+ ) => {
186
+ const { name } = wire.http.request.params()
187
+ const userId = wire.session?.userId
188
+ if (!userId) {
189
+ throw new Error('Authentication required')
190
+ }
191
+ await services.credentialService.delete(name, userId)
192
+ services.logger.info(
193
+ `OAuth2 credential '${name}' disconnected for user '${userId}'`
194
+ )
195
+ return { success: true }
196
+ }
197
+
198
+ const statusHandler = async (
199
+ services: { credentialService: CredentialService },
200
+ _data: any,
201
+ wire: any
202
+ ) => {
203
+ const { name } = wire.http.request.params()
204
+ const userId = wire.session?.userId
205
+ if (!userId) {
206
+ throw new Error('Authentication required')
207
+ }
208
+
209
+ const credential = await services.credentialService.get<OAuth2Token>(
210
+ name,
211
+ userId
212
+ )
213
+
214
+ if (!credential) {
215
+ return { connected: false }
216
+ }
217
+
218
+ return {
219
+ connected: true,
220
+ hasRefreshToken: !!credential.refreshToken,
221
+ expiresAt: credential.expiresAt,
222
+ isExpired: credential.expiresAt
223
+ ? credential.expiresAt < Date.now() + TOKEN_EXPIRY_BUFFER_MS
224
+ : false,
225
+ }
226
+ }
227
+
228
+ return {
229
+ connect: { func: connectHandler } as any,
230
+ callback: { func: callbackHandler } as any,
231
+ disconnect: { func: disconnectHandler } as any,
232
+ status: { func: statusHandler } as any,
233
+ }
234
+ }
@@ -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
- }
@@ -1,4 +1,8 @@
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
7
  import { pikkuState, getSingletonServices } from '../../../pikku-state.js'
4
8
  import type { WorkflowRuntimeMeta, WorkflowRunWire } from '../workflow.types.js'
@@ -489,6 +493,11 @@ export async function executeGraphStep(
489
493
  } else {
490
494
  result = await rpcService.rpcWithWire(rpcName, data, {
491
495
  graph: graphWire,
496
+ workflow: workflowService.createWorkflowWire(
497
+ graphName,
498
+ runId,
499
+ rpcService
500
+ ),
492
501
  })
493
502
  }
494
503
 
@@ -498,6 +507,12 @@ export async function executeGraphStep(
498
507
 
499
508
  return result
500
509
  } catch (error) {
510
+ if (
511
+ error instanceof WorkflowAsyncException ||
512
+ error instanceof WorkflowSuspendedException
513
+ ) {
514
+ throw error
515
+ }
501
516
  if (error instanceof ChildWorkflowStartedException) {
502
517
  throw error
503
518
  }
@@ -619,6 +634,11 @@ async function executeGraphNodeInline(
619
634
  } else {
620
635
  result = await rpcService.rpcWithWire(rpcName, input, {
621
636
  graph: graphWire,
637
+ workflow: workflowService.createWorkflowWire(
638
+ graphName,
639
+ runId,
640
+ rpcService
641
+ ),
622
642
  })
623
643
  }
624
644
 
@@ -631,6 +651,12 @@ async function executeGraphNodeInline(
631
651
 
632
652
  await workflowService.setStepResult(stepState.stepId, result)
633
653
  } catch (error) {
654
+ if (
655
+ error instanceof WorkflowAsyncException ||
656
+ error instanceof WorkflowSuspendedException
657
+ ) {
658
+ throw error
659
+ }
634
660
  if (error instanceof RPCNotFoundError) {
635
661
  await workflowService.setStepError(stepState.stepId, error as Error)
636
662
  await workflowService.updateRunStatus(runId, 'suspended', undefined, {
@@ -855,6 +881,12 @@ export async function runWorkflowGraph(
855
881
  entryNodes
856
882
  )
857
883
  } catch (error) {
884
+ if (
885
+ error instanceof WorkflowAsyncException ||
886
+ error instanceof WorkflowSuspendedException
887
+ ) {
888
+ return
889
+ }
858
890
  await workflowService.updateRunStatus(runId, 'failed', undefined, {
859
891
  message: (error as Error).message,
860
892
  stack: (error as Error).stack || '',
@@ -759,7 +759,14 @@ export abstract class PikkuWorkflowService implements WorkflowService {
759
759
  const meta = pikkuState(null, 'workflows', 'meta')
760
760
  const workflowMeta = meta[run.workflow]
761
761
 
762
- 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
+ ) {
763
770
  result = await executeGraphStep(
764
771
  this,
765
772
  rpcService,
@@ -771,13 +778,52 @@ export abstract class PikkuWorkflowService implements WorkflowService {
771
778
  run.workflow
772
779
  )
773
780
  } else {
774
- result = await rpcService.rpcWithWire(rpcName, data, {
775
- workflowStep: {
776
- runId,
777
- stepId: stepState.stepId,
778
- attemptCount: stepState.attemptCount,
779
- },
780
- })
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
+ }
781
827
  }
782
828
 
783
829
  // Store result and mark succeeded
@@ -1223,7 +1269,7 @@ export abstract class PikkuWorkflowService implements WorkflowService {
1223
1269
  })
1224
1270
  }
1225
1271
 
1226
- private createWorkflowWire(
1272
+ public createWorkflowWire(
1227
1273
  name: string,
1228
1274
  runId: string,
1229
1275
  rpcService: any