@pikku/inspector 0.10.2 → 0.11.0

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.
@@ -0,0 +1,231 @@
1
+ import * as ts from 'typescript'
2
+ import {
3
+ getPropertyValue,
4
+ getPropertyTags,
5
+ } from '../utils/get-property-value.js'
6
+ import { PikkuDocs } from '@pikku/core'
7
+ import { AddWiring, InspectorState } from '../types.js'
8
+ import { extractFunctionName } from '../utils/extract-function-name.js'
9
+ import {
10
+ getPropertyAssignmentInitializer,
11
+ resolveFunctionDeclaration,
12
+ } from '../utils/type-utils.js'
13
+ import { resolveMiddleware } from '../utils/middleware.js'
14
+ import { extractWireNames } from '../utils/post-process.js'
15
+ import { ErrorCode } from '../error-codes.js'
16
+ import { WorkflowStepMeta } from '@pikku/core/workflow'
17
+ import {
18
+ extractStringLiteral,
19
+ extractNumberLiteral,
20
+ extractPropertyString,
21
+ isStringLike,
22
+ isFunctionLike,
23
+ } from '../utils/extract-node-value.js'
24
+
25
+ /**
26
+ * Scan for workflow.do() and workflow.sleep() calls to extract workflow steps
27
+ */
28
+ function getWorkflowInvocations(
29
+ node: ts.Node,
30
+ checker: ts.TypeChecker,
31
+ state: InspectorState,
32
+ workflowName: string,
33
+ steps: WorkflowStepMeta[]
34
+ ) {
35
+ // Look for property access expressions: workflow.do or workflow.sleep
36
+ if (ts.isPropertyAccessExpression(node)) {
37
+ const { name } = node
38
+
39
+ // Check if this is accessing 'do' or 'sleep' property
40
+ if (name.text === 'do' || name.text === 'sleep') {
41
+ // Check if the parent is a call expression
42
+ const parent = node.parent
43
+ if (ts.isCallExpression(parent) && parent.expression === node) {
44
+ const args = parent.arguments
45
+
46
+ if (name.text === 'do' && args.length >= 2) {
47
+ // workflow.do(stepName, rpcName|fn, data?, options?)
48
+ const stepNameArg = args[0]
49
+ const secondArg = args[1]
50
+ const optionsArg =
51
+ args.length >= 3 ? args[args.length - 1] : undefined
52
+
53
+ const stepName = extractStringLiteral(stepNameArg, checker)
54
+ const description =
55
+ extractDescription(optionsArg, checker) ?? undefined
56
+
57
+ // Determine form by checking 2nd argument type
58
+ if (isStringLike(secondArg, checker)) {
59
+ // RPC form: workflow.do(stepName, rpcName, data, options?)
60
+ const rpcName = extractStringLiteral(secondArg, checker)
61
+ steps.push({
62
+ type: 'rpc',
63
+ stepName,
64
+ rpcName,
65
+ description,
66
+ })
67
+ state.rpc.invokedFunctions.add(rpcName)
68
+ } else if (isFunctionLike(secondArg)) {
69
+ // Inline form: workflow.do(stepName, fn, options?)
70
+ steps.push({
71
+ type: 'inline',
72
+ stepName: stepName || '<dynamic>',
73
+ description: description || '<dynamic>',
74
+ })
75
+ }
76
+ } else if (name.text === 'sleep' && args.length >= 2) {
77
+ // workflow.sleep(stepName, duration)
78
+ const stepNameArg = args[0]
79
+ const durationArg = args[1]
80
+
81
+ const stepName = extractStringLiteral(stepNameArg, checker)
82
+ const duration = extractDuration(durationArg, checker)
83
+
84
+ steps.push({
85
+ type: 'sleep',
86
+ stepName: stepName || '<dynamic>',
87
+ duration: duration || '<dynamic>',
88
+ })
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ // Don't recurse into nested functions - only look at top-level workflow calls
95
+ ts.forEachChild(node, (child) => {
96
+ if (
97
+ ts.isFunctionDeclaration(child) ||
98
+ ts.isFunctionExpression(child) ||
99
+ ts.isArrowFunction(child)
100
+ ) {
101
+ return
102
+ }
103
+ getWorkflowInvocations(child, checker, state, workflowName, steps)
104
+ })
105
+ }
106
+
107
+ /**
108
+ * Extract description from options object
109
+ */
110
+ function extractDescription(
111
+ optionsNode: ts.Node | undefined,
112
+ checker: ts.TypeChecker
113
+ ): string | null {
114
+ if (!optionsNode || !ts.isObjectLiteralExpression(optionsNode)) {
115
+ return null
116
+ }
117
+ return extractPropertyString(optionsNode, 'description', checker)
118
+ }
119
+
120
+ /**
121
+ * Extract duration value (number or string)
122
+ */
123
+ function extractDuration(
124
+ node: ts.Node,
125
+ checker: ts.TypeChecker
126
+ ): string | number | null {
127
+ const numValue = extractNumberLiteral(node)
128
+ if (numValue !== null) {
129
+ return numValue
130
+ }
131
+ return extractStringLiteral(node, checker)
132
+ }
133
+
134
+ /**
135
+ * Inspector for wireWorkflow() calls
136
+ * Detects workflow registration and extracts metadata
137
+ */
138
+ export const addWorkflow: AddWiring = (
139
+ logger,
140
+ node,
141
+ checker,
142
+ state,
143
+ options
144
+ ) => {
145
+ if (!ts.isCallExpression(node)) {
146
+ return
147
+ }
148
+
149
+ const args = node.arguments
150
+ const firstArg = args[0]
151
+ const expression = node.expression
152
+
153
+ // Check if the call is to wireWorkflow
154
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireWorkflow') {
155
+ return
156
+ }
157
+
158
+ if (!firstArg) {
159
+ return
160
+ }
161
+
162
+ if (ts.isObjectLiteralExpression(firstArg)) {
163
+ const obj = firstArg
164
+
165
+ const workflowName = getPropertyValue(obj, 'name') as string | null
166
+ const description = getPropertyValue(obj, 'description') as
167
+ | string
168
+ | undefined
169
+ const docs = (getPropertyValue(obj, 'docs') as PikkuDocs) || undefined
170
+ const tags = getPropertyTags(obj, 'Workflow', workflowName, logger)
171
+
172
+ // --- find the referenced function ---
173
+ const funcInitializer = getPropertyAssignmentInitializer(
174
+ obj,
175
+ 'func',
176
+ true,
177
+ checker
178
+ )
179
+
180
+ if (!workflowName) {
181
+ logger.critical(
182
+ ErrorCode.MISSING_NAME,
183
+ `Wasn't able to determine 'name' property for workflow wiring.`
184
+ )
185
+ return
186
+ }
187
+
188
+ if (!funcInitializer) {
189
+ logger.critical(
190
+ ErrorCode.MISSING_FUNC,
191
+ `No valid 'func' property for workflow '${workflowName}'.`
192
+ )
193
+ return
194
+ }
195
+
196
+ const pikkuFuncName = extractFunctionName(
197
+ funcInitializer,
198
+ checker,
199
+ state.rootDir
200
+ ).pikkuFuncName
201
+
202
+ // --- resolve middleware ---
203
+ const middleware = resolveMiddleware(state, obj, tags, checker)
204
+
205
+ // --- track used functions/middleware for service aggregation ---
206
+ state.serviceAggregation.usedFunctions.add(pikkuFuncName)
207
+ extractWireNames(middleware).forEach((name) =>
208
+ state.serviceAggregation.usedMiddleware.add(name)
209
+ )
210
+
211
+ state.workflows.files.add(node.getSourceFile().fileName)
212
+
213
+ // Extract workflow steps from function body
214
+ // Resolve the identifier to the actual function declaration
215
+ const resolvedFunc = resolveFunctionDeclaration(funcInitializer, checker)
216
+ const steps: WorkflowStepMeta[] = []
217
+ if (resolvedFunc) {
218
+ getWorkflowInvocations(resolvedFunc, checker, state, workflowName, steps)
219
+ }
220
+
221
+ state.workflows.meta[workflowName] = {
222
+ pikkuFuncName,
223
+ workflowName,
224
+ description,
225
+ docs,
226
+ tags,
227
+ middleware,
228
+ steps,
229
+ }
230
+ }
231
+ }
@@ -19,6 +19,8 @@ export enum ErrorCode {
19
19
  MISSING_QUEUE_NAME = 'PKU384',
20
20
  MISSING_CHANNEL_NAME = 'PKU400',
21
21
  CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = 'PKU672',
22
+ DYNAMIC_STEP_NAME = 'PKU529',
23
+ WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
22
24
 
23
25
  // Configuration errors
24
26
  CONFIG_TYPE_NOT_FOUND = 'PKU426',
@@ -40,4 +42,7 @@ export enum ErrorCode {
40
42
  PERMISSION_TAG_INVALID = 'PKU836',
41
43
  PERMISSION_EMPTY_ARRAY = 'PKU937',
42
44
  PERMISSION_PATTERN_INVALID = 'PKU975',
45
+
46
+ // Feature Flag
47
+ WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = 'PKU901',
43
48
  }
package/src/inspector.ts CHANGED
@@ -58,6 +58,10 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
58
58
  meta: {},
59
59
  files: new Set(),
60
60
  },
61
+ workflows: {
62
+ meta: {},
63
+ files: new Set(),
64
+ },
61
65
  rpc: {
62
66
  internalMeta: {},
63
67
  internalFiles: new Map(),
package/src/types.ts CHANGED
@@ -2,7 +2,8 @@ import * as ts from 'typescript'
2
2
  import { ChannelsMeta } from '@pikku/core/channel'
3
3
  import { HTTPWiringsMeta } from '@pikku/core/http'
4
4
  import { ScheduledTasksMeta } from '@pikku/core/scheduler'
5
- import { queueWorkersMeta } from '@pikku/core/queue'
5
+ import { QueueWorkersMeta } from '@pikku/core/queue'
6
+ import { WorkflowsMeta } from '@pikku/core/workflow'
6
7
  import { MCPResourceMeta, MCPToolMeta, MCPPromptMeta } from '@pikku/core/mcp'
7
8
  import { CLIMeta } from '@pikku/core/cli'
8
9
  import { TypesMap } from './types-map.js'
@@ -205,7 +206,11 @@ export interface InspectorState {
205
206
  files: Set<string>
206
207
  }
207
208
  queueWorkers: {
208
- meta: queueWorkersMeta
209
+ meta: QueueWorkersMeta
210
+ files: Set<string>
211
+ }
212
+ workflows: {
213
+ meta: WorkflowsMeta
209
214
  files: Set<string>
210
215
  }
211
216
  rpc: {
@@ -0,0 +1,101 @@
1
+ import * as ts from 'typescript'
2
+
3
+ /**
4
+ * Extract string literal value from a TypeScript node.
5
+ * Handles string literals, template literals (including placeholders),
6
+ * and constant variable references.
7
+ */
8
+ export function extractStringLiteral(
9
+ node: ts.Node,
10
+ checker: ts.TypeChecker
11
+ ): string {
12
+ if (ts.isStringLiteral(node)) {
13
+ return node.text
14
+ }
15
+
16
+ if (ts.isNoSubstitutionTemplateLiteral(node)) {
17
+ return node.text
18
+ }
19
+
20
+ if (ts.isTemplateExpression(node)) {
21
+ // reconstruct: `head + ${expr} + middle + ${expr} + tail`
22
+ let result = node.head.text
23
+ for (const span of node.templateSpans) {
24
+ const exprText = span.expression.getText()
25
+ result += '${' + exprText + '}' + span.literal.text
26
+ }
27
+ return result
28
+ }
29
+
30
+ // Try to evaluate constant identifiers
31
+ if (ts.isIdentifier(node)) {
32
+ const symbol = checker.getSymbolAtLocation(node)
33
+ if (
34
+ symbol?.valueDeclaration &&
35
+ ts.isVariableDeclaration(symbol.valueDeclaration)
36
+ ) {
37
+ const init = symbol.valueDeclaration.initializer
38
+ if (init) {
39
+ return extractStringLiteral(init, checker)
40
+ }
41
+ }
42
+ }
43
+
44
+ throw new Error('Unable to extract string literal from node')
45
+ }
46
+
47
+ /**
48
+ * Check if node is string-like (string literal or template expression)
49
+ */
50
+ export function isStringLike(node: ts.Node, _checker: ts.TypeChecker): boolean {
51
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
52
+ return true
53
+ }
54
+ // Check if it's a template string with substitutions
55
+ if (ts.isTemplateExpression(node)) {
56
+ return true
57
+ }
58
+ return false
59
+ }
60
+
61
+ /**
62
+ * Check if node is function-like (arrow, function expression, or function declaration)
63
+ */
64
+ export function isFunctionLike(node: ts.Node): boolean {
65
+ return (
66
+ ts.isArrowFunction(node) ||
67
+ ts.isFunctionExpression(node) ||
68
+ ts.isFunctionDeclaration(node)
69
+ )
70
+ }
71
+
72
+ /**
73
+ * Extract number literal value from a node
74
+ */
75
+ export function extractNumberLiteral(node: ts.Node): number | null {
76
+ if (ts.isNumericLiteral(node)) {
77
+ return Number(node.text)
78
+ }
79
+ return null
80
+ }
81
+
82
+ /**
83
+ * Extract a property value from an object literal expression
84
+ * Returns the extracted value or null if not found/cannot extract
85
+ */
86
+ export function extractPropertyString(
87
+ objNode: ts.ObjectLiteralExpression,
88
+ propertyName: string,
89
+ checker: ts.TypeChecker
90
+ ): string | null {
91
+ for (const prop of objNode.properties) {
92
+ if (
93
+ ts.isPropertyAssignment(prop) &&
94
+ ts.isIdentifier(prop.name) &&
95
+ prop.name.text === propertyName
96
+ ) {
97
+ return extractStringLiteral(prop.initializer, checker)
98
+ }
99
+ }
100
+ return null
101
+ }
@@ -112,6 +112,10 @@ export interface SerializableInspectorState {
112
112
  meta: InspectorState['queueWorkers']['meta']
113
113
  files: string[]
114
114
  }
115
+ workflows: {
116
+ meta: InspectorState['workflows']['meta']
117
+ files: string[]
118
+ }
115
119
  rpc: {
116
120
  internalMeta: InspectorState['rpc']['internalMeta']
117
121
  internalFiles: Array<[string, { path: string; exportedName: string }]>
@@ -243,6 +247,10 @@ export function serializeInspectorState(
243
247
  meta: state.queueWorkers.meta,
244
248
  files: Array.from(state.queueWorkers.files),
245
249
  },
250
+ workflows: {
251
+ meta: state.workflows.meta,
252
+ files: Array.from(state.workflows.files),
253
+ },
246
254
  rpc: {
247
255
  internalMeta: state.rpc.internalMeta,
248
256
  internalFiles: Array.from(state.rpc.internalFiles.entries()),
@@ -344,6 +352,10 @@ export function deserializeInspectorState(
344
352
  meta: data.queueWorkers.meta,
345
353
  files: new Set(data.queueWorkers.files),
346
354
  },
355
+ workflows: {
356
+ meta: data.workflows.meta,
357
+ files: new Set(data.workflows.files),
358
+ },
347
359
  rpc: {
348
360
  internalMeta: data.rpc.internalMeta,
349
361
  internalFiles: new Map(data.rpc.internalFiles),
@@ -1260,6 +1260,10 @@
1260
1260
  },
1261
1261
  "files": ["src/queue-worker.wiring.ts"]
1262
1262
  },
1263
+ "workflows": {
1264
+ "meta": {},
1265
+ "files": []
1266
+ },
1263
1267
  "rpc": {
1264
1268
  "internalMeta": {
1265
1269
  "onConnect": "onConnect",
@@ -4,6 +4,75 @@ export const extractTypeKeys = (type: ts.Type): string[] => {
4
4
  return type.getProperties().map((symbol) => symbol.getName())
5
5
  }
6
6
 
7
+ /**
8
+ * Resolve an identifier or call expression to the actual function declaration
9
+ */
10
+ export function resolveFunctionDeclaration(
11
+ node: ts.Node,
12
+ checker: ts.TypeChecker
13
+ ): ts.Node | null {
14
+ // If it's already a function-like node, return it
15
+ if (
16
+ ts.isFunctionDeclaration(node) ||
17
+ ts.isFunctionExpression(node) ||
18
+ ts.isArrowFunction(node)
19
+ ) {
20
+ return node
21
+ }
22
+
23
+ // If it's a call expression (e.g., pikkuWorkflowFunc(...)), get its first argument
24
+ if (ts.isCallExpression(node) && node.arguments.length > 0) {
25
+ const firstArg = node.arguments[0]
26
+ if (ts.isFunctionExpression(firstArg) || ts.isArrowFunction(firstArg)) {
27
+ return firstArg
28
+ }
29
+ }
30
+
31
+ // If it's an identifier, resolve to declaration
32
+ if (ts.isIdentifier(node)) {
33
+ const symbol = checker.getSymbolAtLocation(node)
34
+ if (!symbol) return null
35
+
36
+ // Try valueDeclaration first, then fallback to declarations[0]
37
+ const decl = symbol.valueDeclaration || symbol.declarations?.[0]
38
+ if (!decl) return null
39
+
40
+ // If it's an import specifier, resolve the aliased symbol
41
+ if (ts.isImportSpecifier(decl)) {
42
+ const aliasedSymbol = checker.getAliasedSymbol(symbol)
43
+ if (aliasedSymbol) {
44
+ const aliasedDecl =
45
+ aliasedSymbol.valueDeclaration || aliasedSymbol.declarations?.[0]
46
+ if (aliasedDecl) {
47
+ // For variable declarations, get the initializer
48
+ if (
49
+ ts.isVariableDeclaration(aliasedDecl) &&
50
+ aliasedDecl.initializer
51
+ ) {
52
+ return resolveFunctionDeclaration(aliasedDecl.initializer, checker)
53
+ }
54
+ // For function declarations, return directly
55
+ if (ts.isFunctionDeclaration(aliasedDecl)) {
56
+ return aliasedDecl
57
+ }
58
+ }
59
+ }
60
+ }
61
+
62
+ // If it's a variable declaration, get the initializer
63
+ if (ts.isVariableDeclaration(decl) && decl.initializer) {
64
+ return resolveFunctionDeclaration(decl.initializer, checker)
65
+ }
66
+
67
+ // If it's a function declaration
68
+ if (ts.isFunctionDeclaration(decl)) {
69
+ return decl
70
+ }
71
+ }
72
+
73
+ return null
74
+ }
75
+
7
76
  export function getPropertyAssignmentInitializer(
8
77
  obj: ts.ObjectLiteralExpression,
9
78
  propName: string,
package/src/visit.ts CHANGED
@@ -4,6 +4,7 @@ import { addFileExtendsCoreType } from './add/add-file-extends-core-type.js'
4
4
  import { addHTTPRoute } from './add/add-http-route.js'
5
5
  import { addSchedule } from './add/add-schedule.js'
6
6
  import { addQueueWorker } from './add/add-queue-worker.js'
7
+ import { addWorkflow } from './add/add-workflow.js'
7
8
  import { addMCPResource } from './add/add-mcp-resource.js'
8
9
  import { addMCPTool } from './add/add-mcp-tool.js'
9
10
  import { addMCPPrompt } from './add/add-mcp-prompt.js'
@@ -74,6 +75,7 @@ export const visitSetup = (
74
75
  addRPCInvocations(node, state, logger)
75
76
  addMiddleware(logger, node, checker, state, options)
76
77
  addPermission(logger, node, checker, state, options)
78
+ addWorkflow(logger, node, checker, state, options)
77
79
 
78
80
  ts.forEachChild(node, (child) =>
79
81
  visitSetup(logger, checker, child, state, options)