@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
@@ -28,7 +28,7 @@ function makeLogger() {
28
28
 
29
29
  /**
30
30
  * Inline Private<T>/Pii<T>/Secret<T> definitions that the test source files use.
31
- * Mirrors what schema.d.ts emits so the TypeScript program sees the correct
31
+ * Mirrors what schema.gen.ts emits so the TypeScript program sees the correct
32
32
  * structural brand type even without @pikku/core being importable from /tmp.
33
33
  */
34
34
  // Optional `__classification__?` mirrors what @pikku/core and `pikku db migrate`
@@ -50,7 +50,10 @@ async function runInspect(sourceCode: string) {
50
50
  try {
51
51
  // The data-classification leak scan is opt-in (off by default to keep it
52
52
  // off the `pikku all` hot path); these tests exercise it, so enable it.
53
- await inspect(logger, [file], { rootDir: tmpDir, classificationCheck: true })
53
+ await inspect(logger, [file], {
54
+ rootDir: tmpDir,
55
+ classificationCheck: true,
56
+ })
54
57
  } finally {
55
58
  await rm(tmpDir, { recursive: true, force: true })
56
59
  }
@@ -0,0 +1,123 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { inspect } from '../inspector.js'
7
+ import { ErrorCode } from '../error-codes.js'
8
+ import type { InspectorLogger } from '../types.js'
9
+
10
+ function makeLogger() {
11
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
12
+ const logger: InspectorLogger = {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ diagnostic: ({ code, message }) => criticals.push({ code, message }),
18
+ critical: (code, message) => criticals.push({ code, message }),
19
+ hasCriticalErrors: () => criticals.length > 0,
20
+ }
21
+ return { logger, criticals }
22
+ }
23
+
24
+ async function runInspect(sourceCode: string) {
25
+ const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-rpc-cast-test-'))
26
+ const file = join(tmpDir, 'funcs.ts')
27
+ await writeFile(file, sourceCode)
28
+ const { logger, criticals } = makeLogger()
29
+ try {
30
+ await inspect(logger, [file], { rootDir: tmpDir })
31
+ } finally {
32
+ await rm(tmpDir, { recursive: true, force: true })
33
+ }
34
+ return criticals
35
+ }
36
+
37
+ describe('RPC type-cast check — PKU940', () => {
38
+ test('flags rpc.invoke() with an as-cast on an argument', async () => {
39
+ const criticals = await runInspect(`
40
+ declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
41
+ export async function doWork() {
42
+ return rpc.invoke('someFunction', { id: 1 } as any)
43
+ }
44
+ `)
45
+ const hit = criticals.find(
46
+ (c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
47
+ )
48
+ assert.ok(hit, `Expected PKU940 but got: ${JSON.stringify(criticals)}`)
49
+ })
50
+
51
+ test('flags rpc.invoke() with an angle-bracket cast on an argument', async () => {
52
+ const criticals = await runInspect(`
53
+ declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
54
+ export async function doWork() {
55
+ return rpc.invoke('someFunction', <any>{ id: 1 })
56
+ }
57
+ `)
58
+ const hit = criticals.find(
59
+ (c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
60
+ )
61
+ assert.ok(hit, `Expected PKU940 but got: ${JSON.stringify(criticals)}`)
62
+ })
63
+
64
+ test('flags rpc.invoke() result cast with as any', async () => {
65
+ const criticals = await runInspect(`
66
+ declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
67
+ export async function doWork() {
68
+ return (rpc.invoke('someFunction', { id: 1 }) as any)
69
+ }
70
+ `)
71
+ const hit = criticals.find(
72
+ (c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
73
+ )
74
+ assert.ok(hit, `Expected PKU940 but got: ${JSON.stringify(criticals)}`)
75
+ })
76
+
77
+ test('flags rpc.invoke() result cast with as never', async () => {
78
+ const criticals = await runInspect(`
79
+ declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
80
+ export async function doWork() {
81
+ return (rpc.invoke('someFunction', { id: 1 }) as never)
82
+ }
83
+ `)
84
+ const hit = criticals.find(
85
+ (c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
86
+ )
87
+ assert.ok(hit, `Expected PKU940 but got: ${JSON.stringify(criticals)}`)
88
+ })
89
+
90
+ test('does not flag a clean rpc.invoke() call', async () => {
91
+ const criticals = await runInspect(`
92
+ declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
93
+ export async function doWork() {
94
+ return rpc.invoke('someFunction', { id: 1 })
95
+ }
96
+ `)
97
+ const hit = criticals.find(
98
+ (c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
99
+ )
100
+ assert.equal(
101
+ hit,
102
+ undefined,
103
+ `Expected no PKU940 but got: ${JSON.stringify(hit)}`
104
+ )
105
+ })
106
+
107
+ test('does not flag as-casts on unrelated calls', async () => {
108
+ const criticals = await runInspect(`
109
+ declare function otherFn(data: unknown): Promise<unknown>
110
+ export async function doWork() {
111
+ return otherFn({ id: 1 } as any)
112
+ }
113
+ `)
114
+ const hit = criticals.find(
115
+ (c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
116
+ )
117
+ assert.equal(
118
+ hit,
119
+ undefined,
120
+ `Expected no PKU940 but got: ${JSON.stringify(hit)}`
121
+ )
122
+ })
123
+ })
@@ -88,6 +88,8 @@ export enum ErrorCode {
88
88
  // Addon authoring errors
89
89
  ADDON_WIRING_NOT_ALLOWED = 'PKU920',
90
90
  ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = 'PKU921',
91
+
92
+ RPC_INVOCATION_TYPE_CAST = 'PKU940',
91
93
  }
92
94
 
93
95
  /**
package/src/inspector.ts CHANGED
@@ -61,7 +61,9 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
61
61
  wireServicesFactories: new Map(),
62
62
  wireServicesMeta: new Map(),
63
63
  addonRequiredParentServices: [],
64
+ addonServerlessIncompatible: new Map(),
64
65
  configFactories: new Map(),
66
+ serverLifecycleFactories: new Map(),
65
67
  filesAndMethods: {},
66
68
  filesAndMethodsErrors: new Map(),
67
69
  typesLookup: new Map(),
package/src/types.ts CHANGED
@@ -263,6 +263,10 @@ export type InspectorFilters = {
263
263
  // to 'server'. Sourced from `pikku.config.json` →
264
264
  // `deploy.serverlessIncompatible`. Used only when deploy filters are set.
265
265
  serverlessIncompatible?: string[]
266
+ // Default deploy target for functions without an explicit `deploy` flag.
267
+ // Sourced from `pikku.config.json` → `deploy.defaultTarget`. Used only
268
+ // when deploy filters are set. Defaults to 'serverless'.
269
+ defaultTarget?: 'serverless' | 'server'
266
270
  }
267
271
 
268
272
  export type AddonConfig = {
@@ -377,6 +381,12 @@ export interface InspectorFilesAndMethods {
377
381
  type: string
378
382
  typePath: string
379
383
  }
384
+ serverLifecycleFactory?: {
385
+ file: string
386
+ variable: string
387
+ type: string
388
+ typePath: string
389
+ }
380
390
  }
381
391
 
382
392
  export interface InspectorDiagnostic {
@@ -431,7 +441,9 @@ export interface InspectorState {
431
441
  wireServicesFactories: PathToNameAndType
432
442
  wireServicesMeta: Map<string, string[]> // variable name -> singleton services consumed
433
443
  addonRequiredParentServices: string[] // services an addon needs from the parent (extracted from pikkuAddonServices 2nd param)
444
+ addonServerlessIncompatible: Map<string, string[]> // namespace → service names that are serverless-incompatible (scoped per addon)
434
445
  configFactories: PathToNameAndType
446
+ serverLifecycleFactories: PathToNameAndType
435
447
  filesAndMethods: InspectorFilesAndMethods
436
448
  filesAndMethodsErrors: Map<string, PathToNameAndType>
437
449
  typesLookup: Map<string, ts.Type[]> // Lookup for types by name (e.g., function input types, Config type)
@@ -3,7 +3,11 @@ import { strict as assert } from 'node:assert'
3
3
  import { filterInspectorState } from './filter-inspector-state.js'
4
4
  import type { InspectorState, InspectorFilters } from '../types.js'
5
5
  import type { SerializableInspectorState } from './serialize-inspector-state.js'
6
- import { deserializeInspectorState } from './serialize-inspector-state.js'
6
+ import {
7
+ deserializeInspectorState,
8
+ serializeInspectorState,
9
+ } from './serialize-inspector-state.js'
10
+ import { getInitialInspectorState } from '../inspector.js'
7
11
  import { readFileSync } from 'node:fs'
8
12
  import { fileURLToPath } from 'node:url'
9
13
  import { dirname, join } from 'node:path'
@@ -1548,3 +1552,108 @@ describe('filterInspectorState exclude filters', () => {
1548
1552
  assert.ok(!result.http.meta.get['/admin/settings'])
1549
1553
  })
1550
1554
  })
1555
+
1556
+ describe('addonServerlessIncompatible scoping', () => {
1557
+ // Service names inside addons are scoped to that addon's namespace.
1558
+ // 'FfmpegService' in addon A is unrelated to 'FfmpegService' in the app or
1559
+ // another addon. The state.addonServerlessIncompatible map must NOT be
1560
+ // merged into the global filter — it is intentionally kept separate.
1561
+
1562
+ test('addon serverlessIncompatible does not bleed into app-level function resolution', () => {
1563
+ const state = createMockInspectorState()
1564
+
1565
+ // App function uses a service named 'FfmpegService' (e.g. its own service,
1566
+ // unrelated to any addon's FfmpegService).
1567
+ ;(state.functions.meta as any).getUsers.services = {
1568
+ services: ['FfmpegService'],
1569
+ }
1570
+
1571
+ // An addon declares FfmpegService as serverless-incompatible for ITS namespace.
1572
+ ;(state as any).addonServerlessIncompatible = new Map([
1573
+ ['ffmpeg', ['FfmpegService']],
1574
+ ])
1575
+
1576
+ // Filter: only keep serverless functions. The filters object does NOT
1577
+ // include serverlessIncompatible — that's app-level config only.
1578
+ const filters: InspectorFilters = {
1579
+ target: ['serverless'],
1580
+ }
1581
+
1582
+ const result = filterInspectorState(state, filters, mockLogger)
1583
+
1584
+ // The app's getUsers function must survive — the addon's scoped
1585
+ // serverlessIncompatible should have no effect on app-level resolution.
1586
+ assert.ok(
1587
+ result.http.meta.get['/api/users'],
1588
+ 'app function using same-named service as addon should not be pruned by addon scoping'
1589
+ )
1590
+ })
1591
+
1592
+ test('app-level serverlessIncompatible in filters still prunes matching app functions', () => {
1593
+ const state = createMockInspectorState()
1594
+
1595
+ ;(state.functions.meta as any).getUsers.services = {
1596
+ services: ['HeavyService'],
1597
+ }
1598
+ ;(state as any).addonServerlessIncompatible = new Map([
1599
+ ['some-addon', ['HeavyService']],
1600
+ ])
1601
+
1602
+ // The APP explicitly declares HeavyService incompatible via filters.
1603
+ const filters: InspectorFilters = {
1604
+ target: ['serverless'],
1605
+ serverlessIncompatible: ['HeavyService'],
1606
+ }
1607
+
1608
+ const result = filterInspectorState(state, filters, mockLogger)
1609
+
1610
+ // App explicitly opted in via filters.serverlessIncompatible → pruned.
1611
+ assert.ok(
1612
+ !result.http.meta.get['/api/users'],
1613
+ 'app function should be pruned when app explicitly lists service in filters.serverlessIncompatible'
1614
+ )
1615
+ })
1616
+ })
1617
+
1618
+ describe('addonServerlessIncompatible serialization roundtrip', () => {
1619
+ // Uses getInitialInspectorState to guarantee all Map fields are initialized —
1620
+ // the lightweight mock in createMockInspectorState() omits fields like
1621
+ // schemaLookup that serializeInspectorState needs.
1622
+
1623
+ test('Map serializes to Array<[string, string[]]> and back', () => {
1624
+ const state = getInitialInspectorState('/test')
1625
+ state.addonServerlessIncompatible = new Map([
1626
+ ['ffmpeg', ['FfmpegService', 'FfprobeService']],
1627
+ ['humandesign', ['HumanDesignService']],
1628
+ ])
1629
+
1630
+ const serialized = serializeInspectorState(state)
1631
+ assert.deepStrictEqual(serialized.addonServerlessIncompatible, [
1632
+ ['ffmpeg', ['FfmpegService', 'FfprobeService']],
1633
+ ['humandesign', ['HumanDesignService']],
1634
+ ])
1635
+
1636
+ const restored = deserializeInspectorState(serialized)
1637
+ assert.ok(restored.addonServerlessIncompatible instanceof Map)
1638
+ assert.deepStrictEqual(restored.addonServerlessIncompatible.get('ffmpeg'), [
1639
+ 'FfmpegService',
1640
+ 'FfprobeService',
1641
+ ])
1642
+ assert.deepStrictEqual(
1643
+ restored.addonServerlessIncompatible.get('humandesign'),
1644
+ ['HumanDesignService']
1645
+ )
1646
+ })
1647
+
1648
+ test('empty Map serializes and restores correctly', () => {
1649
+ const state = getInitialInspectorState('/test')
1650
+ // addonServerlessIncompatible is already an empty Map from getInitialInspectorState
1651
+
1652
+ const serialized = serializeInspectorState(state)
1653
+ assert.deepStrictEqual(serialized.addonServerlessIncompatible, [])
1654
+
1655
+ const restored = deserializeInspectorState(serialized)
1656
+ assert.ok(restored.addonServerlessIncompatible instanceof Map)
1657
+ assert.strictEqual(restored.addonServerlessIncompatible.size, 0)
1658
+ })
1659
+ })
@@ -340,9 +340,15 @@ export function filterInspectorState(
340
340
  ? new Set(filters.excludeTarget)
341
341
  : null
342
342
  const incompatible = new Set(filters.serverlessIncompatible ?? [])
343
+ const defaultTarget = filters.defaultTarget ?? 'serverless'
343
344
  keptByDeploy = new Set<string>()
344
345
  for (const [funcId, funcMeta] of Object.entries(state.functions.meta)) {
345
- const target = resolveDeployTarget(funcMeta as any, incompatible, funcId)
346
+ const target = resolveDeployTarget(
347
+ funcMeta as any,
348
+ incompatible,
349
+ funcId,
350
+ defaultTarget
351
+ )
346
352
  if (allowed && !allowed.has(target)) continue
347
353
  if (excluded && excluded.has(target)) continue
348
354
  keptByDeploy.add(funcId)
@@ -1062,10 +1068,10 @@ export function filterInspectorState(
1062
1068
  }
1063
1069
 
1064
1070
  // Step dispatch is decided purely per-function: a workflow step runs via the
1065
- // queue only when its function opts out of inline execution (inline: false).
1066
- // Such a unit needs workflowService + queueService injected even though the
1067
- // function itself doesn't reference them. Check the ORIGINAL graph meta
1068
- // (before filtering pruned it).
1071
+ // queue only when its function is marked `workflowQueued: true`. Such a unit
1072
+ // needs workflowService + queueService injected even though the function
1073
+ // itself doesn't reference them. Check the ORIGINAL graph meta (before
1074
+ // filtering pruned it).
1069
1075
  const survivingFuncIds = new Set(Object.keys(filteredState.functions.meta))
1070
1076
  const resolveFuncId = (rpcName: string): string =>
1071
1077
  filteredState.rpc.internalMeta[rpcName] ??
@@ -1081,8 +1087,8 @@ export function filterInspectorState(
1081
1087
  if (!survivingFuncIds.has(funcId) && !survivingFuncIds.has(rpcName))
1082
1088
  continue
1083
1089
  const funcMeta = (filteredState.functions.meta[funcId] ??
1084
- filteredState.functions.meta[rpcName]) as { inline?: boolean }
1085
- if (funcMeta?.inline === false) {
1090
+ filteredState.functions.meta[rpcName]) as { workflowQueued?: boolean }
1091
+ if (funcMeta?.workflowQueued === true) {
1086
1092
  filteredState.serviceAggregation.requiredServices.add('workflowService')
1087
1093
  filteredState.serviceAggregation.requiredServices.add('queueService')
1088
1094
  }
@@ -19,6 +19,7 @@ export type FilesAndMethods = {
19
19
  pikkuConfigFactory: Meta
20
20
  singletonServicesFactory: Meta
21
21
  wireServicesFactory: Meta
22
+ serverLifecycleFactory: Meta
22
23
  }
23
24
 
24
25
  export type FilesAndMethodsErrors = Map<string, PathToNameAndType>
@@ -92,6 +93,7 @@ export const getFilesAndMethods = (
92
93
  wireServicesFactories,
93
94
  singletonServicesFactories,
94
95
  configFactories,
96
+ serverLifecycleFactories,
95
97
  }: InspectorState,
96
98
  {
97
99
  configFileType,
@@ -145,6 +147,10 @@ export const getFilesAndMethods = (
145
147
  wireServicesFactoryType,
146
148
  errors
147
149
  ),
150
+ serverLifecycleFactory: getMetaTypes(
151
+ 'ServerLifecycle',
152
+ serverLifecycleFactories
153
+ ),
148
154
  }
149
155
 
150
156
  return { result, errors }
@@ -199,6 +199,29 @@ export async function loadAddonFunctionsMeta(
199
199
  } catch {
200
200
  // No variables meta — that's fine
201
201
  }
202
+ // Load addon serverlessIncompatible service names from pikku-addon-meta.gen.json
203
+ try {
204
+ const addonMetaPath = require.resolve(
205
+ `${decl.package}/.pikku/console/pikku-addon-meta.gen.json`
206
+ )
207
+ const addonMetaRaw = await readFile(addonMetaPath, 'utf-8')
208
+ const addonMeta = JSON.parse(addonMetaRaw)
209
+ if (
210
+ Array.isArray(addonMeta.serverlessIncompatible) &&
211
+ addonMeta.serverlessIncompatible.length > 0
212
+ ) {
213
+ state.addonServerlessIncompatible.set(
214
+ namespace,
215
+ addonMeta.serverlessIncompatible
216
+ )
217
+ logger.debug(
218
+ `Addon '${namespace}' marks [${addonMeta.serverlessIncompatible.join(', ')}] as serverless-incompatible`
219
+ )
220
+ }
221
+ } catch {
222
+ // No addon meta or no serverlessIncompatible declared — that's fine
223
+ }
224
+
202
225
  // Load addon required parent services from pikku-services.gen
203
226
  try {
204
227
  const servicesGenPath = require.resolve(
@@ -290,10 +290,17 @@ export function aggregateRequiredServices(
290
290
  }
291
291
  }
292
292
 
293
- // Per-step queues
293
+ // Per-step queues — only for steps explicitly marked workflowQueued: true
294
294
  for (const node of Object.values(graph.nodes)) {
295
295
  if (!('rpcName' in node) || !node.rpcName) continue
296
296
  const rpcName = node.rpcName as string
297
+ const funcId =
298
+ state.rpc?.internalMeta?.[rpcName] ??
299
+ state.rpc?.exposedMeta?.[rpcName] ??
300
+ rpcName
301
+ const funcMeta = (state.functions.meta[funcId] ??
302
+ state.functions.meta[rpcName]) as { workflowQueued?: boolean }
303
+ if (funcMeta?.workflowQueued !== true) continue
297
304
  const stepQueueName = `wf-step-${toKebab(rpcName)}`
298
305
  if (!state.queueWorkers.meta[stepQueueName]) {
299
306
  state.queueWorkers.meta[stepQueueName] = {
@@ -102,4 +102,34 @@ describe('resolveDeployTarget', () => {
102
102
  'server'
103
103
  )
104
104
  })
105
+
106
+ test('defaultTarget: server overrides the serverless default', () => {
107
+ assert.strictEqual(
108
+ resolveDeployTarget({}, new Set(), '<unknown>', 'server'),
109
+ 'server'
110
+ )
111
+ })
112
+
113
+ test('explicit deploy flag wins over defaultTarget', () => {
114
+ assert.strictEqual(
115
+ resolveDeployTarget({ deploy: 'serverless' }, new Set(), 'fn', 'server'),
116
+ 'serverless'
117
+ )
118
+ assert.strictEqual(
119
+ resolveDeployTarget({ deploy: 'server' }, new Set(), 'fn', 'serverless'),
120
+ 'server'
121
+ )
122
+ })
123
+
124
+ test('serverlessIncompatible still forces server regardless of defaultTarget', () => {
125
+ assert.strictEqual(
126
+ resolveDeployTarget(
127
+ { services: { services: ['metaService'] } as any },
128
+ new Set(['metaService']),
129
+ 'fn',
130
+ 'serverless'
131
+ ),
132
+ 'server'
133
+ )
134
+ })
105
135
  })
@@ -29,7 +29,8 @@ export class IncompatibleDeployTargetError extends Error {
29
29
  * - throw if the function explicitly declares `deploy: 'serverless'`
30
30
  * - otherwise target is 'server'
31
31
  * 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
32
- * 3. Default 'serverless'
32
+ * 3. `defaultTarget` (sourced from `pikku.config.json` →
33
+ * `deploy.defaultTarget`, falling back to 'serverless')
33
34
  *
34
35
  * Used both by the per-unit deploy analyzer (when bucketing functions
35
36
  * into deployment units) and by `filterInspectorState` (when
@@ -39,7 +40,8 @@ export class IncompatibleDeployTargetError extends Error {
39
40
  export function resolveDeployTarget(
40
41
  funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>,
41
42
  serverlessIncompatible: Set<string>,
42
- functionName = '<unknown>'
43
+ functionName = '<unknown>',
44
+ defaultTarget: 'serverless' | 'server' = 'serverless'
43
45
  ): 'serverless' | 'server' {
44
46
  // Service compatibility wins over the explicit flag — a serverless
45
47
  // bundle of a function that needs (e.g.) node:fs would crash at runtime.
@@ -59,5 +61,5 @@ export function resolveDeployTarget(
59
61
 
60
62
  if (funcMeta.deploy === 'server') return 'server'
61
63
  if (funcMeta.deploy === 'serverless') return 'serverless'
62
- return 'serverless'
64
+ return defaultTarget
63
65
  }
@@ -46,12 +46,19 @@ export interface SerializableInspectorState {
46
46
  >
47
47
  wireServicesMeta: Array<[string, string[]]>
48
48
  addonRequiredParentServices: string[]
49
+ addonServerlessIncompatible: Array<[string, string[]]>
49
50
  configFactories: Array<
50
51
  [
51
52
  string,
52
53
  { variable: string; type: string | null; typePath: string | null }[],
53
54
  ]
54
55
  >
56
+ serverLifecycleFactories: Array<
57
+ [
58
+ string,
59
+ { variable: string; type: string | null; typePath: string | null }[],
60
+ ]
61
+ >
55
62
  filesAndMethods: InspectorState['filesAndMethods']
56
63
  filesAndMethodsErrors: Array<
57
64
  [
@@ -312,7 +319,13 @@ export function serializeInspectorState(
312
319
  wireServicesFactories: Array.from(state.wireServicesFactories.entries()),
313
320
  wireServicesMeta: Array.from(state.wireServicesMeta.entries()),
314
321
  addonRequiredParentServices: state.addonRequiredParentServices,
322
+ addonServerlessIncompatible: Array.from(
323
+ state.addonServerlessIncompatible.entries()
324
+ ),
315
325
  configFactories: Array.from(state.configFactories.entries()),
326
+ serverLifecycleFactories: Array.from(
327
+ state.serverLifecycleFactories.entries()
328
+ ),
316
329
  filesAndMethods: state.filesAndMethods,
317
330
  filesAndMethodsErrors: Array.from(
318
331
  state.filesAndMethodsErrors.entries()
@@ -491,7 +504,11 @@ export function deserializeInspectorState(
491
504
  wireServicesFactories: new Map(data.wireServicesFactories),
492
505
  wireServicesMeta: new Map(data.wireServicesMeta),
493
506
  addonRequiredParentServices: data.addonRequiredParentServices || [],
507
+ addonServerlessIncompatible: new Map(
508
+ data.addonServerlessIncompatible || []
509
+ ),
494
510
  configFactories: new Map(data.configFactories),
511
+ serverLifecycleFactories: new Map(data.serverLifecycleFactories),
495
512
  filesAndMethods: data.filesAndMethods,
496
513
  filesAndMethodsErrors: new Map(
497
514
  data.filesAndMethodsErrors.map(([key, entries]) => [
@@ -119,4 +119,22 @@ describe('deriveWorkflowPlan', () => {
119
119
  assert.equal(plan.deterministic, true)
120
120
  assert.deepEqual(plan.plannedSteps, [{ stepName: 'a' }])
121
121
  })
122
+
123
+ test('suspend steps appear in the plan with __workflow_suspend: key and sentence-case displayName', () => {
124
+ const plan = deriveWorkflowPlan([
125
+ rpc('build'),
126
+ { type: 'suspend', reason: 'building' } as WorkflowStepMeta,
127
+ rpc('publish'),
128
+ { type: 'suspend', reason: 'awaiting_approval' } as WorkflowStepMeta,
129
+ { type: 'suspend', reason: 'building-image' } as WorkflowStepMeta,
130
+ ])
131
+ assert.equal(plan.deterministic, true)
132
+ assert.deepEqual(plan.plannedSteps, [
133
+ { stepName: 'build' },
134
+ { stepName: '__workflow_suspend:building', displayName: 'Building' },
135
+ { stepName: 'publish' },
136
+ { stepName: '__workflow_suspend:awaiting_approval', displayName: 'Awaiting approval' },
137
+ { stepName: '__workflow_suspend:building-image', displayName: 'Building image' },
138
+ ])
139
+ })
122
140
  })
@@ -58,6 +58,16 @@ function containsConditional(steps: WorkflowStepMeta[]): boolean {
58
58
  return steps.some((step) => step.type === 'branch' || step.type === 'switch')
59
59
  }
60
60
 
61
+ /**
62
+ * Turn an internal snake/kebab reason key into a sentence-case display label.
63
+ * e.g. "awaiting_approval" → "Awaiting approval", "building-image" → "Building image"
64
+ */
65
+ function reasonToLabel(reason: string): string {
66
+ return reason
67
+ .replace(/[-_]/g, ' ')
68
+ .replace(/^(.)/, (c) => c.toUpperCase())
69
+ }
70
+
61
71
  /** Flatten named steps (rpc/inline/sleep/parallel children) in source order. */
62
72
  function collectNamedSteps(steps: WorkflowStepMeta[]): WorkflowPlannedStep[] {
63
73
  const planned: WorkflowPlannedStep[] = []
@@ -68,6 +78,16 @@ function collectNamedSteps(steps: WorkflowStepMeta[]): WorkflowPlannedStep[] {
68
78
  case 'sleep':
69
79
  planned.push({ stepName: step.stepName })
70
80
  break
81
+ case 'suspend':
82
+ // Runtime stores the suspend step as `__workflow_suspend:${reason}`.
83
+ // We surface it in the planned ladder with a readable displayName so
84
+ // the UI shows it in the right position instead of appending it at the
85
+ // bottom as an unrecognised orphan.
86
+ planned.push({
87
+ stepName: `__workflow_suspend:${step.reason}`,
88
+ displayName: reasonToLabel(step.reason),
89
+ })
90
+ break
71
91
  case 'parallel':
72
92
  for (const child of step.children) {
73
93
  planned.push({ stepName: child.stepName })
@@ -7,6 +7,7 @@ import type {
7
7
  ParallelGroupStepMeta,
8
8
  FanoutStepMeta,
9
9
  CancelStepMeta,
10
+ SuspendStepMeta,
10
11
  SetStepMeta,
11
12
  SwitchStepMeta,
12
13
  SwitchCaseMeta,
@@ -21,6 +22,7 @@ import type {
21
22
  import {
22
23
  isWorkflowDoCall,
23
24
  isWorkflowSleepCall,
25
+ isWorkflowSuspendCall,
24
26
  isThrowCancelException,
25
27
  extractCancelReason,
26
28
  isParallelFanout,
@@ -512,6 +514,10 @@ function extractExpressionStatement(
512
514
  return extractSleepStep(call, context)
513
515
  }
514
516
 
517
+ if (isWorkflowSuspendCall(call, context.checker)) {
518
+ return extractSuspendStep(call, context)
519
+ }
520
+
515
521
  // Check for parallel group or fanout
516
522
  if (isParallelFanout(call)) {
517
523
  return extractParallelFanout(call, context)
@@ -698,6 +704,31 @@ function extractSleepStep(
698
704
  }
699
705
  }
700
706
 
707
+ /**
708
+ * Extract suspend step from workflow.suspend() call
709
+ */
710
+ function extractSuspendStep(
711
+ call: ts.CallExpression,
712
+ context: ExtractionContext
713
+ ): SuspendStepMeta | null {
714
+ const args = call.arguments
715
+ if (args.length < 1) return null
716
+
717
+ try {
718
+ const reason = extractStringLiteral(args[0], context.checker)
719
+ return {
720
+ type: 'suspend',
721
+ reason,
722
+ }
723
+ } catch (error) {
724
+ context.errors.push({
725
+ message: `Failed to extract suspend step: ${error instanceof Error ? error.message : String(error)}`,
726
+ node: call,
727
+ })
728
+ return null
729
+ }
730
+ }
731
+
701
732
  /**
702
733
  * Extract cancel step from throw WorkflowCancelledException statement
703
734
  */