@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 +26 -0
- package/dist/add/add-file-with-factory.js +1 -0
- package/dist/inspector.js +2 -0
- package/dist/types.d.ts +8 -0
- package/dist/utils/get-files-and-methods.d.ts +2 -1
- package/dist/utils/get-files-and-methods.js +2 -1
- package/dist/utils/load-addon-functions-meta.js +14 -0
- package/dist/utils/serialize-inspector-state.d.ts +9 -0
- package/dist/utils/serialize-inspector-state.js +4 -0
- package/dist/utils/workflow/derive-workflow-plan.js +19 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +26 -1
- package/dist/utils/workflow/dsl/patterns.d.ts +4 -0
- package/dist/utils/workflow/dsl/patterns.js +12 -0
- package/dist/visit.js +1 -0
- package/package.json +2 -2
- package/src/add/add-file-with-factory.ts +1 -0
- package/src/add/pii-check.test.ts +5 -2
- package/src/inspector.ts +2 -0
- package/src/types.ts +8 -0
- package/src/utils/filter-inspector-state.test.ts +110 -1
- package/src/utils/get-files-and-methods.ts +6 -0
- package/src/utils/load-addon-functions-meta.ts +23 -0
- package/src/utils/serialize-inspector-state.ts +17 -0
- package/src/utils/workflow/derive-workflow-plan.test.ts +18 -0
- package/src/utils/workflow/derive-workflow-plan.ts +20 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +31 -0
- package/src/utils/workflow/dsl/patterns.ts +19 -0
- package/src/visit.ts +6 -0
- package/tsconfig.tsbuildinfo +1 -1
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.
|
|
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.
|
|
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.
|
|
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], {
|
|
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 {
|
|
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 })
|