@pikku/inspector 0.12.28 → 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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,29 @@
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
+
1
27
  ## 0.12.28
2
28
 
3
29
  ### 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)) {
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
@@ -303,6 +303,12 @@ export interface InspectorFilesAndMethods {
303
303
  type: string;
304
304
  typePath: string;
305
305
  };
306
+ serverLifecycleFactory?: {
307
+ file: string;
308
+ variable: string;
309
+ type: string;
310
+ typePath: string;
311
+ };
306
312
  }
307
313
  export interface InspectorDiagnostic {
308
314
  code: string;
@@ -354,7 +360,9 @@ export interface InspectorState {
354
360
  wireServicesFactories: PathToNameAndType;
355
361
  wireServicesMeta: Map<string, string[]>;
356
362
  addonRequiredParentServices: string[];
363
+ addonServerlessIncompatible: Map<string, string[]>;
357
364
  configFactories: PathToNameAndType;
365
+ serverLifecycleFactories: PathToNameAndType;
358
366
  filesAndMethods: InspectorFilesAndMethods;
359
367
  filesAndMethodsErrors: Map<string, PathToNameAndType>;
360
368
  typesLookup: Map<string, ts.Type[]>;
@@ -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`);
@@ -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(...)
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.28",
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.40",
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 = (
@@ -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
  }
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
@@ -381,6 +381,12 @@ export interface InspectorFilesAndMethods {
381
381
  type: string
382
382
  typePath: string
383
383
  }
384
+ serverLifecycleFactory?: {
385
+ file: string
386
+ variable: string
387
+ type: string
388
+ typePath: string
389
+ }
384
390
  }
385
391
 
386
392
  export interface InspectorDiagnostic {
@@ -435,7 +441,9 @@ export interface InspectorState {
435
441
  wireServicesFactories: PathToNameAndType
436
442
  wireServicesMeta: Map<string, string[]> // variable name -> singleton services consumed
437
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)
438
445
  configFactories: PathToNameAndType
446
+ serverLifecycleFactories: PathToNameAndType
439
447
  filesAndMethods: InspectorFilesAndMethods
440
448
  filesAndMethodsErrors: Map<string, PathToNameAndType>
441
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
+ })
@@ -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(
@@ -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 })