@pikku/inspector 0.12.27 → 0.12.29

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 (47) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/add/add-file-with-factory.js +1 -0
  3. package/dist/add/add-functions.js +9 -3
  4. package/dist/add/add-rpc-invocations.js +27 -0
  5. package/dist/error-codes.d.ts +2 -1
  6. package/dist/error-codes.js +1 -0
  7. package/dist/inspector.js +2 -0
  8. package/dist/types.d.ts +9 -0
  9. package/dist/utils/filter-inspector-state.js +7 -6
  10. package/dist/utils/get-files-and-methods.d.ts +2 -1
  11. package/dist/utils/get-files-and-methods.js +2 -1
  12. package/dist/utils/load-addon-functions-meta.js +14 -0
  13. package/dist/utils/post-process.js +8 -1
  14. package/dist/utils/resolve-deploy-target.d.ts +3 -2
  15. package/dist/utils/resolve-deploy-target.js +4 -3
  16. package/dist/utils/serialize-inspector-state.d.ts +9 -0
  17. package/dist/utils/serialize-inspector-state.js +4 -0
  18. package/dist/utils/workflow/derive-workflow-plan.js +19 -0
  19. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +26 -1
  20. package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
  21. package/dist/utils/workflow/dsl/patterns.js +12 -0
  22. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +0 -2
  23. package/dist/visit.js +1 -0
  24. package/package.json +2 -2
  25. package/src/add/add-file-with-factory.ts +1 -0
  26. package/src/add/add-functions.ts +9 -3
  27. package/src/add/add-rpc-invocations.ts +41 -0
  28. package/src/add/pii-check.test.ts +5 -2
  29. package/src/add/rpc-type-cast.test.ts +123 -0
  30. package/src/error-codes.ts +2 -0
  31. package/src/inspector.ts +2 -0
  32. package/src/types.ts +12 -0
  33. package/src/utils/filter-inspector-state.test.ts +110 -1
  34. package/src/utils/filter-inspector-state.ts +13 -7
  35. package/src/utils/get-files-and-methods.ts +6 -0
  36. package/src/utils/load-addon-functions-meta.ts +23 -0
  37. package/src/utils/post-process.ts +8 -1
  38. package/src/utils/resolve-deploy-target.test.ts +30 -0
  39. package/src/utils/resolve-deploy-target.ts +5 -3
  40. package/src/utils/serialize-inspector-state.ts +17 -0
  41. package/src/utils/workflow/derive-workflow-plan.test.ts +18 -0
  42. package/src/utils/workflow/derive-workflow-plan.ts +20 -0
  43. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +31 -0
  44. package/src/utils/workflow/dsl/patterns.ts +19 -0
  45. package/src/utils/workflow/graph/workflow-graph.types.ts +0 -2
  46. package/src/visit.ts +6 -0
  47. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,63 @@
1
+ ## 0.12.29
2
+
3
+ ### Patch Changes
4
+
5
+ - 7b5b10a: fix(workflow): include suspend steps in plannedSteps with readable displayName
6
+
7
+ `workflow.suspend(reason)` calls now appear in the static `plannedSteps` ladder
8
+ produced by `deriveWorkflowPlan`. Previously the inspector ignored them, so the
9
+ runtime's `__workflow_suspend:<reason>` steps had no planned counterpart and
10
+ the UI appended them as orphans at the bottom of the step list instead of
11
+ showing them at the correct position.
12
+
13
+ Changes:
14
+ - `WorkflowPlannedStep` gains an optional `displayName` field — the human-
15
+ readable label to show in the UI (falls back to `stepName` when absent).
16
+ - New `SuspendStepMeta` type added to `WorkflowStepMeta`.
17
+ - Inspector extracts `workflow.suspend('reason')` calls and emits a
18
+ `SuspendStepMeta` step with `type: 'suspend'` and `reason`.
19
+ - `collectNamedSteps` maps a suspend step to
20
+ `{ stepName: '__workflow_suspend:<reason>', displayName: '<reason>' }`,
21
+ matching the key the runtime stores so the UI can overlay live status
22
+ onto the planned position.
23
+
24
+ - Updated dependencies [7b5b10a]
25
+ - @pikku/core@0.12.42
26
+
27
+ ## 0.12.28
28
+
29
+ ### Patch Changes
30
+
31
+ - 66d43d1: Add `deploy.defaultTarget` to `pikku.config.json` to override the default deploy target ('serverless') for functions without an explicit `deploy` flag.
32
+ - a8c9e6d: feat(inspector): add PKU940 — block type casts on rpc.invoke() calls
33
+
34
+ The inspector now emits a critical PKU940 error when `rpc.invoke()` is called
35
+ with an `as` cast on an argument (`rpc.invoke('fn', data as any)`) or when its
36
+ result is cast (`rpc.invoke('fn', data) as any`). Both patterns defeat Pikku's
37
+ generated type safety and are rejected at build time.
38
+
39
+ - ba1ab08: refactor(workflow): replace `inline: false` with `workflowQueued: true` on function meta
40
+
41
+ The per-function workflow dispatch flag has been renamed from the confusing
42
+ negative `inline: false` to the explicit positive `workflowQueued: true`.
43
+ Two companion fields are also added: `workflowRetries` and `workflowTimeout`
44
+ as function-level equivalents of the per-call-site `NodeOptions` fields.
45
+
46
+ **Breaking change (patch — flag was undocumented):** rename `inline: false`
47
+ to `workflowQueued: true` on any `pikkuSessionlessFunc` / `pikkuFunc` that
48
+ dispatches its workflow steps via the queue.
49
+
50
+ **Behaviour change:** a step marked `workflowQueued: true` now throws if no
51
+ queue service is configured, instead of silently falling back to inline
52
+ execution.
53
+
54
+ **Bug fix:** `post-process.ts` was registering `wf-step-*` queues for every
55
+ workflow step node; it now only registers them for steps that are actually
56
+ `workflowQueued: true`, avoiding spurious queue resource allocation.
57
+
58
+ - Updated dependencies [ba1ab08]
59
+ - @pikku/core@0.12.40
60
+
1
61
  ## 0.12.27
2
62
 
3
63
  ### Patch Changes
@@ -8,6 +8,7 @@ const wrapperFunctionMap = {
8
8
  pikkuAddonServices: 'CreateSingletonServices',
9
9
  pikkuWireServices: 'CreateWireServices',
10
10
  pikkuAddonWireServices: 'CreateWireServices',
11
+ pikkuServerLifecycle: 'ServerLifecycle',
11
12
  };
12
13
  export const addFileWithFactory = (node, checker, methods = new Map(), expectedTypeName, state) => {
13
14
  if (ts.isVariableDeclaration(node)) {
@@ -277,7 +277,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
277
277
  let deploy;
278
278
  let approvalRequired;
279
279
  let approvalDescription;
280
- let inline;
280
+ let workflowQueued;
281
+ let workflowRetries;
282
+ let workflowTimeout;
281
283
  let version;
282
284
  let objectNode;
283
285
  let nodeDisplayName = null;
@@ -345,7 +347,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
345
347
  readonly_ = getPropertyValue(firstArg, 'readonly');
346
348
  deploy = getPropertyValue(firstArg, 'deploy');
347
349
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
348
- inline = getPropertyValue(firstArg, 'inline');
350
+ workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
351
+ workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
352
+ workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
349
353
  // Extract approvalDescription identifier reference
350
354
  for (const prop of firstArg.properties) {
351
355
  if (ts.isPropertyAssignment(prop) &&
@@ -740,7 +744,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
740
744
  deploy: deploy || undefined,
741
745
  approvalRequired: approvalRequired || undefined,
742
746
  approvalDescription: approvalDescription || undefined,
743
- inline: inline === false ? false : undefined,
747
+ workflowQueued: workflowQueued === true ? true : undefined,
748
+ workflowRetries: workflowRetries ?? undefined,
749
+ workflowTimeout: workflowTimeout ?? undefined,
744
750
  implementationHash,
745
751
  version,
746
752
  title,
@@ -1,4 +1,18 @@
1
1
  import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ function hasTypeCast(node) {
4
+ return ts.isAsExpression(node) || ts.isTypeAssertionExpression(node);
5
+ }
6
+ function outerParent(node) {
7
+ let p = node.parent;
8
+ while (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p))) {
9
+ p = p.parent;
10
+ }
11
+ return p;
12
+ }
13
+ function findCastArg(args) {
14
+ return args.find(hasTypeCast);
15
+ }
2
16
  /**
3
17
  * Helper to extract namespace from a namespaced function reference like 'ext:hello'
4
18
  */
@@ -52,6 +66,19 @@ export function addRPCInvocations(node, state, logger) {
52
66
  expression.name.text === 'invoke' &&
53
67
  ts.isIdentifier(expression.expression) &&
54
68
  expression.expression.text === 'rpc') {
69
+ // Skip PKU940 for generated files — they may contain intentional casts
70
+ // (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
71
+ const sourceFileName = node.getSourceFile().fileName;
72
+ const isGenerated = sourceFileName.endsWith('.gen.ts') || sourceFileName.endsWith('.gen.js');
73
+ if (!isGenerated) {
74
+ if (hasTypeCast(outerParent(node))) {
75
+ logger.critical(ErrorCode.RPC_INVOCATION_TYPE_CAST, `rpc.invoke() result is type-cast — remove the 'as' expression and rely on Pikku's generated types`);
76
+ }
77
+ const castArg = findCastArg(args);
78
+ if (castArg) {
79
+ logger.critical(ErrorCode.RPC_INVOCATION_TYPE_CAST, `rpc.invoke() has a type cast on an argument — remove the 'as' expression and rely on Pikku's generated types`);
80
+ }
81
+ }
55
82
  const [firstArg] = args;
56
83
  if (firstArg) {
57
84
  if (ts.isStringLiteral(firstArg)) {
@@ -59,7 +59,8 @@ export declare enum ErrorCode {
59
59
  WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901",
60
60
  PII_IN_OUTPUT = "PKU910",
61
61
  ADDON_WIRING_NOT_ALLOWED = "PKU920",
62
- ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921"
62
+ ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921",
63
+ RPC_INVOCATION_TYPE_CAST = "PKU940"
63
64
  }
64
65
  /**
65
66
  * Severity of a tracked, coded diagnostic. `critical` always blocks the build;
@@ -75,4 +75,5 @@ export var ErrorCode;
75
75
  // Addon authoring errors
76
76
  ErrorCode["ADDON_WIRING_NOT_ALLOWED"] = "PKU920";
77
77
  ErrorCode["ADDON_CONTRACT_HANDLERS_NOT_ALLOWED"] = "PKU921";
78
+ ErrorCode["RPC_INVOCATION_TYPE_CAST"] = "PKU940";
78
79
  })(ErrorCode || (ErrorCode = {}));
package/dist/inspector.js CHANGED
@@ -30,7 +30,9 @@ export function getInitialInspectorState(rootDir) {
30
30
  wireServicesFactories: new Map(),
31
31
  wireServicesMeta: new Map(),
32
32
  addonRequiredParentServices: [],
33
+ addonServerlessIncompatible: new Map(),
33
34
  configFactories: new Map(),
35
+ serverLifecycleFactories: new Map(),
34
36
  filesAndMethods: {},
35
37
  filesAndMethodsErrors: new Map(),
36
38
  typesLookup: new Map(),
package/dist/types.d.ts CHANGED
@@ -196,6 +196,7 @@ export type InspectorFilters = {
196
196
  target?: Array<'serverless' | 'server'>;
197
197
  excludeTarget?: Array<'serverless' | 'server'>;
198
198
  serverlessIncompatible?: string[];
199
+ defaultTarget?: 'serverless' | 'server';
199
200
  };
200
201
  export type AddonConfig = {
201
202
  package: string;
@@ -302,6 +303,12 @@ export interface InspectorFilesAndMethods {
302
303
  type: string;
303
304
  typePath: string;
304
305
  };
306
+ serverLifecycleFactory?: {
307
+ file: string;
308
+ variable: string;
309
+ type: string;
310
+ typePath: string;
311
+ };
305
312
  }
306
313
  export interface InspectorDiagnostic {
307
314
  code: string;
@@ -353,7 +360,9 @@ export interface InspectorState {
353
360
  wireServicesFactories: PathToNameAndType;
354
361
  wireServicesMeta: Map<string, string[]>;
355
362
  addonRequiredParentServices: string[];
363
+ addonServerlessIncompatible: Map<string, string[]>;
356
364
  configFactories: PathToNameAndType;
365
+ serverLifecycleFactories: PathToNameAndType;
357
366
  filesAndMethods: InspectorFilesAndMethods;
358
367
  filesAndMethodsErrors: Map<string, PathToNameAndType>;
359
368
  typesLookup: Map<string, ts.Type[]>;
@@ -256,9 +256,10 @@ export function filterInspectorState(state, filters, logger) {
256
256
  ? new Set(filters.excludeTarget)
257
257
  : null;
258
258
  const incompatible = new Set(filters.serverlessIncompatible ?? []);
259
+ const defaultTarget = filters.defaultTarget ?? 'serverless';
259
260
  keptByDeploy = new Set();
260
261
  for (const [funcId, funcMeta] of Object.entries(state.functions.meta)) {
261
- const target = resolveDeployTarget(funcMeta, incompatible, funcId);
262
+ const target = resolveDeployTarget(funcMeta, incompatible, funcId, defaultTarget);
262
263
  if (allowed && !allowed.has(target))
263
264
  continue;
264
265
  if (excluded && excluded.has(target))
@@ -761,10 +762,10 @@ export function filterInspectorState(state, filters, logger) {
761
762
  filteredState.requiredSchemas = prunedSchemas;
762
763
  }
763
764
  // Step dispatch is decided purely per-function: a workflow step runs via the
764
- // queue only when its function opts out of inline execution (inline: false).
765
- // Such a unit needs workflowService + queueService injected even though the
766
- // function itself doesn't reference them. Check the ORIGINAL graph meta
767
- // (before filtering pruned it).
765
+ // queue only when its function is marked `workflowQueued: true`. Such a unit
766
+ // needs workflowService + queueService injected even though the function
767
+ // itself doesn't reference them. Check the ORIGINAL graph meta (before
768
+ // filtering pruned it).
768
769
  const survivingFuncIds = new Set(Object.keys(filteredState.functions.meta));
769
770
  const resolveFuncId = (rpcName) => filteredState.rpc.internalMeta[rpcName] ??
770
771
  filteredState.rpc.exposedMeta[rpcName] ??
@@ -782,7 +783,7 @@ export function filterInspectorState(state, filters, logger) {
782
783
  continue;
783
784
  const funcMeta = (filteredState.functions.meta[funcId] ??
784
785
  filteredState.functions.meta[rpcName]);
785
- if (funcMeta?.inline === false) {
786
+ if (funcMeta?.workflowQueued === true) {
786
787
  filteredState.serviceAggregation.requiredServices.add('workflowService');
787
788
  filteredState.serviceAggregation.requiredServices.add('queueService');
788
789
  }
@@ -13,9 +13,10 @@ export type FilesAndMethods = {
13
13
  pikkuConfigFactory: Meta;
14
14
  singletonServicesFactory: Meta;
15
15
  wireServicesFactory: Meta;
16
+ serverLifecycleFactory: Meta;
16
17
  };
17
18
  export type FilesAndMethodsErrors = Map<string, PathToNameAndType>;
18
- export declare const getFilesAndMethods: ({ singletonServicesTypeImportMap, wireServicesTypeImportMap, userSessionTypeImportMap, configTypeImportMap, wireServicesFactories, singletonServicesFactories, configFactories, }: InspectorState, { configFileType, userSessionType, singletonServicesFactoryType, wireServicesFactoryType, }?: InspectorOptions["types"]) => {
19
+ export declare const getFilesAndMethods: ({ singletonServicesTypeImportMap, wireServicesTypeImportMap, userSessionTypeImportMap, configTypeImportMap, wireServicesFactories, singletonServicesFactories, configFactories, serverLifecycleFactories, }: InspectorState, { configFileType, userSessionType, singletonServicesFactoryType, wireServicesFactoryType, }?: InspectorOptions["types"]) => {
19
20
  result: Partial<FilesAndMethods>;
20
21
  errors: FilesAndMethodsErrors;
21
22
  };
@@ -46,7 +46,7 @@ const getMetaTypes = (type, map, desiredType, errors) => {
46
46
  }
47
47
  return;
48
48
  };
49
- export const getFilesAndMethods = ({ singletonServicesTypeImportMap, wireServicesTypeImportMap, userSessionTypeImportMap, configTypeImportMap, wireServicesFactories, singletonServicesFactories, configFactories, }, { configFileType, userSessionType, singletonServicesFactoryType, wireServicesFactoryType, } = {}) => {
49
+ export const getFilesAndMethods = ({ singletonServicesTypeImportMap, wireServicesTypeImportMap, userSessionTypeImportMap, configTypeImportMap, wireServicesFactories, singletonServicesFactories, configFactories, serverLifecycleFactories, }, { configFileType, userSessionType, singletonServicesFactoryType, wireServicesFactoryType, } = {}) => {
50
50
  const errors = new Map();
51
51
  const result = {
52
52
  userSessionType: getMetaTypes('CoreUserSession', userSessionTypeImportMap, userSessionType, errors),
@@ -56,6 +56,7 @@ export const getFilesAndMethods = ({ singletonServicesTypeImportMap, wireService
56
56
  pikkuConfigFactory: getMetaTypes('CoreConfig', configFactories, configFileType, errors),
57
57
  singletonServicesFactory: getMetaTypes('CreateSingletonServices', singletonServicesFactories, singletonServicesFactoryType, errors),
58
58
  wireServicesFactory: getMetaTypes('CreateWireServices', wireServicesFactories, wireServicesFactoryType, errors),
59
+ serverLifecycleFactory: getMetaTypes('ServerLifecycle', serverLifecycleFactories),
59
60
  };
60
61
  return { result, errors };
61
62
  };
@@ -134,6 +134,20 @@ export async function loadAddonFunctionsMeta(logger, state) {
134
134
  catch {
135
135
  // No variables meta — that's fine
136
136
  }
137
+ // Load addon serverlessIncompatible service names from pikku-addon-meta.gen.json
138
+ try {
139
+ const addonMetaPath = require.resolve(`${decl.package}/.pikku/console/pikku-addon-meta.gen.json`);
140
+ const addonMetaRaw = await readFile(addonMetaPath, 'utf-8');
141
+ const addonMeta = JSON.parse(addonMetaRaw);
142
+ if (Array.isArray(addonMeta.serverlessIncompatible) &&
143
+ addonMeta.serverlessIncompatible.length > 0) {
144
+ state.addonServerlessIncompatible.set(namespace, addonMeta.serverlessIncompatible);
145
+ logger.debug(`Addon '${namespace}' marks [${addonMeta.serverlessIncompatible.join(', ')}] as serverless-incompatible`);
146
+ }
147
+ }
148
+ catch {
149
+ // No addon meta or no serverlessIncompatible declared — that's fine
150
+ }
137
151
  // Load addon required parent services from pikku-services.gen
138
152
  try {
139
153
  const servicesGenPath = require.resolve(`${decl.package}/.pikku/pikku-services.gen.js`);
@@ -220,11 +220,18 @@ export function aggregateRequiredServices(state) {
220
220
  pikkuFuncId: `pikkuWorkflowOrchestrator:${graph.name}`,
221
221
  };
222
222
  }
223
- // Per-step queues
223
+ // Per-step queues — only for steps explicitly marked workflowQueued: true
224
224
  for (const node of Object.values(graph.nodes)) {
225
225
  if (!('rpcName' in node) || !node.rpcName)
226
226
  continue;
227
227
  const rpcName = node.rpcName;
228
+ const funcId = state.rpc?.internalMeta?.[rpcName] ??
229
+ state.rpc?.exposedMeta?.[rpcName] ??
230
+ rpcName;
231
+ const funcMeta = (state.functions.meta[funcId] ??
232
+ state.functions.meta[rpcName]);
233
+ if (funcMeta?.workflowQueued !== true)
234
+ continue;
228
235
  const stepQueueName = `wf-step-${toKebab(rpcName)}`;
229
236
  if (!state.queueWorkers.meta[stepQueueName]) {
230
237
  state.queueWorkers.meta[stepQueueName] = {
@@ -18,11 +18,12 @@ export declare class IncompatibleDeployTargetError extends Error {
18
18
  * - throw if the function explicitly declares `deploy: 'serverless'`
19
19
  * - otherwise target is 'server'
20
20
  * 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
21
- * 3. Default 'serverless'
21
+ * 3. `defaultTarget` (sourced from `pikku.config.json` →
22
+ * `deploy.defaultTarget`, falling back to 'serverless')
22
23
  *
23
24
  * Used both by the per-unit deploy analyzer (when bucketing functions
24
25
  * into deployment units) and by `filterInspectorState` (when
25
26
  * `pikku all --deploy <target>` is used to emit a target-scoped set
26
27
  * of gen files).
27
28
  */
28
- export declare function resolveDeployTarget(funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>, serverlessIncompatible: Set<string>, functionName?: string): 'serverless' | 'server';
29
+ export declare function resolveDeployTarget(funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>, serverlessIncompatible: Set<string>, functionName?: string, defaultTarget?: 'serverless' | 'server'): 'serverless' | 'server';
@@ -25,14 +25,15 @@ export class IncompatibleDeployTargetError extends Error {
25
25
  * - throw if the function explicitly declares `deploy: 'serverless'`
26
26
  * - otherwise target is 'server'
27
27
  * 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
28
- * 3. Default 'serverless'
28
+ * 3. `defaultTarget` (sourced from `pikku.config.json` →
29
+ * `deploy.defaultTarget`, falling back to 'serverless')
29
30
  *
30
31
  * Used both by the per-unit deploy analyzer (when bucketing functions
31
32
  * into deployment units) and by `filterInspectorState` (when
32
33
  * `pikku all --deploy <target>` is used to emit a target-scoped set
33
34
  * of gen files).
34
35
  */
35
- export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionName = '<unknown>') {
36
+ export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionName = '<unknown>', defaultTarget = 'serverless') {
36
37
  // Service compatibility wins over the explicit flag — a serverless
37
38
  // bundle of a function that needs (e.g.) node:fs would crash at runtime.
38
39
  const incompatibleHits = [];
@@ -52,5 +53,5 @@ export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionNa
52
53
  return 'server';
53
54
  if (funcMeta.deploy === 'serverless')
54
55
  return 'serverless';
55
- return 'serverless';
56
+ return defaultTarget;
56
57
  }
@@ -56,6 +56,7 @@ export interface SerializableInspectorState {
56
56
  ]>;
57
57
  wireServicesMeta: Array<[string, string[]]>;
58
58
  addonRequiredParentServices: string[];
59
+ addonServerlessIncompatible: Array<[string, string[]]>;
59
60
  configFactories: Array<[
60
61
  string,
61
62
  {
@@ -64,6 +65,14 @@ export interface SerializableInspectorState {
64
65
  typePath: string | null;
65
66
  }[]
66
67
  ]>;
68
+ serverLifecycleFactories: Array<[
69
+ string,
70
+ {
71
+ variable: string;
72
+ type: string | null;
73
+ typePath: string | null;
74
+ }[]
75
+ ]>;
67
76
  filesAndMethods: InspectorState['filesAndMethods'];
68
77
  filesAndMethodsErrors: Array<[
69
78
  string,
@@ -24,7 +24,9 @@ export function serializeInspectorState(state) {
24
24
  wireServicesFactories: Array.from(state.wireServicesFactories.entries()),
25
25
  wireServicesMeta: Array.from(state.wireServicesMeta.entries()),
26
26
  addonRequiredParentServices: state.addonRequiredParentServices,
27
+ addonServerlessIncompatible: Array.from(state.addonServerlessIncompatible.entries()),
27
28
  configFactories: Array.from(state.configFactories.entries()),
29
+ serverLifecycleFactories: Array.from(state.serverLifecycleFactories.entries()),
28
30
  filesAndMethods: state.filesAndMethods,
29
31
  filesAndMethodsErrors: Array.from(state.filesAndMethodsErrors.entries()).map(([key, mapValue]) => [key, Array.from(mapValue.entries())]),
30
32
  schemaLookup: Array.from(state.schemaLookup.entries()),
@@ -176,7 +178,9 @@ export function deserializeInspectorState(data) {
176
178
  wireServicesFactories: new Map(data.wireServicesFactories),
177
179
  wireServicesMeta: new Map(data.wireServicesMeta),
178
180
  addonRequiredParentServices: data.addonRequiredParentServices || [],
181
+ addonServerlessIncompatible: new Map(data.addonServerlessIncompatible || []),
179
182
  configFactories: new Map(data.configFactories),
183
+ serverLifecycleFactories: new Map(data.serverLifecycleFactories),
180
184
  filesAndMethods: data.filesAndMethods,
181
185
  filesAndMethodsErrors: new Map(data.filesAndMethodsErrors.map(([key, entries]) => [
182
186
  key,
@@ -43,6 +43,15 @@ function containsLoop(steps) {
43
43
  function containsConditional(steps) {
44
44
  return steps.some((step) => step.type === 'branch' || step.type === 'switch');
45
45
  }
46
+ /**
47
+ * Turn an internal snake/kebab reason key into a sentence-case display label.
48
+ * e.g. "awaiting_approval" → "Awaiting approval", "building-image" → "Building image"
49
+ */
50
+ function reasonToLabel(reason) {
51
+ return reason
52
+ .replace(/[-_]/g, ' ')
53
+ .replace(/^(.)/, (c) => c.toUpperCase());
54
+ }
46
55
  /** Flatten named steps (rpc/inline/sleep/parallel children) in source order. */
47
56
  function collectNamedSteps(steps) {
48
57
  const planned = [];
@@ -53,6 +62,16 @@ function collectNamedSteps(steps) {
53
62
  case 'sleep':
54
63
  planned.push({ stepName: step.stepName });
55
64
  break;
65
+ case 'suspend':
66
+ // Runtime stores the suspend step as `__workflow_suspend:${reason}`.
67
+ // We surface it in the planned ladder with a readable displayName so
68
+ // the UI shows it in the right position instead of appending it at the
69
+ // bottom as an unrecognised orphan.
70
+ planned.push({
71
+ stepName: `__workflow_suspend:${step.reason}`,
72
+ displayName: reasonToLabel(step.reason),
73
+ });
74
+ break;
56
75
  case 'parallel':
57
76
  for (const child of step.children) {
58
77
  planned.push({ stepName: child.stepName });
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import { isWorkflowDoCall, isWorkflowSleepCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
2
+ import { isWorkflowDoCall, isWorkflowSleepCall, isWorkflowSuspendCall, isThrowCancelException, extractCancelReason, isParallelFanout, isParallelGroup, isSequentialFanout, isArrayFilter, isArraySome, isArrayEvery, extractForOfVariable, isArrayType, getSourceText, extractSourcePath, } from './patterns.js';
3
3
  import { validateNoDisallowedPatterns, validateAwaitedCalls, formatValidationErrors, } from './validation.js';
4
4
  import { extractStringLiteral, extractNumberLiteral, } from '../../extract-node-value.js';
5
5
  /**
@@ -360,6 +360,9 @@ function extractExpressionStatement(statement, context) {
360
360
  if (isWorkflowSleepCall(call, context.checker)) {
361
361
  return extractSleepStep(call, context);
362
362
  }
363
+ if (isWorkflowSuspendCall(call, context.checker)) {
364
+ return extractSuspendStep(call, context);
365
+ }
363
366
  // Check for parallel group or fanout
364
367
  if (isParallelFanout(call)) {
365
368
  return extractParallelFanout(call, context);
@@ -513,6 +516,28 @@ function extractSleepStep(call, context) {
513
516
  return null;
514
517
  }
515
518
  }
519
+ /**
520
+ * Extract suspend step from workflow.suspend() call
521
+ */
522
+ function extractSuspendStep(call, context) {
523
+ const args = call.arguments;
524
+ if (args.length < 1)
525
+ return null;
526
+ try {
527
+ const reason = extractStringLiteral(args[0], context.checker);
528
+ return {
529
+ type: 'suspend',
530
+ reason,
531
+ };
532
+ }
533
+ catch (error) {
534
+ context.errors.push({
535
+ message: `Failed to extract suspend step: ${error instanceof Error ? error.message : String(error)}`,
536
+ node: call,
537
+ });
538
+ return null;
539
+ }
540
+ }
516
541
  /**
517
542
  * Extract cancel step from throw WorkflowCancelledException statement
518
543
  */
@@ -10,6 +10,10 @@ export declare function isWorkflowDoCall(node: ts.CallExpression, checker: ts.Ty
10
10
  * Check if a call expression is workflow.sleep()
11
11
  */
12
12
  export declare function isWorkflowSleepCall(node: ts.CallExpression, checker: ts.TypeChecker): boolean;
13
+ /**
14
+ * Check if a call expression is workflow.suspend()
15
+ */
16
+ export declare function isWorkflowSuspendCall(node: ts.CallExpression, _checker: ts.TypeChecker): boolean;
13
17
  /**
14
18
  * Check if a throw statement throws WorkflowCancelledException
15
19
  * Matches: throw new WorkflowCancelledException(...) or throw WorkflowCancelledException(...)
@@ -26,6 +26,18 @@ export function isWorkflowSleepCall(node, checker) {
26
26
  ts.isIdentifier(propAccess.expression) &&
27
27
  propAccess.expression.text === 'workflow');
28
28
  }
29
+ /**
30
+ * Check if a call expression is workflow.suspend()
31
+ */
32
+ export function isWorkflowSuspendCall(node, _checker) {
33
+ if (!ts.isPropertyAccessExpression(node.expression)) {
34
+ return false;
35
+ }
36
+ const propAccess = node.expression;
37
+ return (propAccess.name.text === 'suspend' &&
38
+ ts.isIdentifier(propAccess.expression) &&
39
+ propAccess.expression.text === 'workflow');
40
+ }
29
41
  /**
30
42
  * Check if a throw statement throws WorkflowCancelledException
31
43
  * Matches: throw new WorkflowCancelledException(...) or throw WorkflowCancelledException(...)
@@ -62,8 +62,6 @@ export interface NodeOptions {
62
62
  retryDelay?: string;
63
63
  /** Timeout for node execution (e.g., '30s', '5m') */
64
64
  timeout?: string;
65
- /** If true, execute via queue (async). Default: false (inline) */
66
- async?: boolean;
67
65
  }
68
66
  /**
69
67
  * Flow node types for control flow (no RPC call)
package/dist/visit.js CHANGED
@@ -33,6 +33,7 @@ export const visitSetup = (logger, checker, node, state, options) => {
33
33
  addFileWithFactory(node, checker, state.singletonServicesFactories, 'CreateSingletonServices', state);
34
34
  addFileWithFactory(node, checker, state.wireServicesFactories, 'CreateWireServices', state);
35
35
  addFileWithFactory(node, checker, state.configFactories, 'CreateConfig');
36
+ addFileWithFactory(node, checker, state.serverLifecycleFactories, 'ServerLifecycle');
36
37
  addRPCInvocations(node, state, logger);
37
38
  addWireAddon(node, state, logger);
38
39
  addMiddleware(logger, node, checker, state, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.27",
3
+ "version": "0.12.29",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
38
- "@pikku/core": "^0.12.39",
38
+ "@pikku/core": "^0.12.42",
39
39
  "openapi-types": "^12.1.3",
40
40
  "path-to-regexp": "^8.3.0",
41
41
  "ts-json-schema-generator": "^2.5.0",
@@ -10,6 +10,7 @@ const wrapperFunctionMap: Record<string, string> = {
10
10
  pikkuAddonServices: 'CreateSingletonServices',
11
11
  pikkuWireServices: 'CreateWireServices',
12
12
  pikkuAddonWireServices: 'CreateWireServices',
13
+ pikkuServerLifecycle: 'ServerLifecycle',
13
14
  }
14
15
 
15
16
  export const addFileWithFactory = (
@@ -392,7 +392,9 @@ export const addFunctions: AddWiring = (
392
392
  let deploy: 'serverless' | 'server' | 'auto' | undefined
393
393
  let approvalRequired: boolean | undefined
394
394
  let approvalDescription: string | undefined
395
- let inline: boolean | undefined
395
+ let workflowQueued: boolean | undefined
396
+ let workflowRetries: number | undefined
397
+ let workflowTimeout: string | undefined
396
398
  let version: number | undefined
397
399
  let objectNode: ts.ObjectLiteralExpression | undefined
398
400
  let nodeDisplayName: string | null = null
@@ -488,7 +490,9 @@ export const addFunctions: AddWiring = (
488
490
  approvalRequired = getPropertyValue(firstArg, 'approvalRequired') as
489
491
  | boolean
490
492
  | undefined
491
- inline = getPropertyValue(firstArg, 'inline') as boolean | undefined
493
+ workflowQueued = getPropertyValue(firstArg, 'workflowQueued') as boolean | undefined
494
+ workflowRetries = getPropertyValue(firstArg, 'workflowRetries') as number | undefined
495
+ workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as string | undefined
492
496
 
493
497
  // Extract approvalDescription identifier reference
494
498
  for (const prop of firstArg.properties) {
@@ -1027,7 +1031,9 @@ export const addFunctions: AddWiring = (
1027
1031
  deploy: deploy || undefined,
1028
1032
  approvalRequired: approvalRequired || undefined,
1029
1033
  approvalDescription: approvalDescription || undefined,
1030
- inline: inline === false ? false : undefined,
1034
+ workflowQueued: workflowQueued === true ? true : undefined,
1035
+ workflowRetries: workflowRetries ?? undefined,
1036
+ workflowTimeout: workflowTimeout ?? undefined,
1031
1037
  implementationHash,
1032
1038
  version,
1033
1039
  title,
@@ -1,5 +1,24 @@
1
1
  import * as ts from 'typescript'
2
2
  import type { InspectorState, InspectorLogger } from '../types.js'
3
+ import { ErrorCode } from '../error-codes.js'
4
+
5
+ function hasTypeCast(node: ts.Node): boolean {
6
+ return ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)
7
+ }
8
+
9
+ function outerParent(node: ts.Node): ts.Node {
10
+ let p = node.parent
11
+ while (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p))) {
12
+ p = p.parent
13
+ }
14
+ return p
15
+ }
16
+
17
+ function findCastArg(
18
+ args: ts.NodeArray<ts.Expression>
19
+ ): ts.Expression | undefined {
20
+ return args.find(hasTypeCast)
21
+ }
3
22
 
4
23
  /**
5
24
  * Helper to extract namespace from a namespaced function reference like 'ext:hello'
@@ -67,6 +86,28 @@ export function addRPCInvocations(
67
86
  ts.isIdentifier(expression.expression) &&
68
87
  expression.expression.text === 'rpc'
69
88
  ) {
89
+ // Skip PKU940 for generated files — they may contain intentional casts
90
+ // (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
91
+ const sourceFileName = node.getSourceFile().fileName
92
+ const isGenerated =
93
+ sourceFileName.endsWith('.gen.ts') || sourceFileName.endsWith('.gen.js')
94
+ if (!isGenerated) {
95
+ if (hasTypeCast(outerParent(node))) {
96
+ logger.critical(
97
+ ErrorCode.RPC_INVOCATION_TYPE_CAST,
98
+ `rpc.invoke() result is type-cast — remove the 'as' expression and rely on Pikku's generated types`
99
+ )
100
+ }
101
+
102
+ const castArg = findCastArg(args)
103
+ if (castArg) {
104
+ logger.critical(
105
+ ErrorCode.RPC_INVOCATION_TYPE_CAST,
106
+ `rpc.invoke() has a type cast on an argument — remove the 'as' expression and rely on Pikku's generated types`
107
+ )
108
+ }
109
+ }
110
+
70
111
  const [firstArg] = args
71
112
  if (firstArg) {
72
113
  if (ts.isStringLiteral(firstArg)) {