@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.
- package/CHANGELOG.md +60 -0
- package/dist/add/add-file-with-factory.js +1 -0
- package/dist/add/add-functions.js +9 -3
- package/dist/add/add-rpc-invocations.js +27 -0
- package/dist/error-codes.d.ts +2 -1
- package/dist/error-codes.js +1 -0
- package/dist/inspector.js +2 -0
- package/dist/types.d.ts +9 -0
- package/dist/utils/filter-inspector-state.js +7 -6
- 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/post-process.js +8 -1
- package/dist/utils/resolve-deploy-target.d.ts +3 -2
- package/dist/utils/resolve-deploy-target.js +4 -3
- 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/utils/workflow/graph/workflow-graph.types.d.ts +0 -2
- package/dist/visit.js +1 -0
- package/package.json +2 -2
- package/src/add/add-file-with-factory.ts +1 -0
- package/src/add/add-functions.ts +9 -3
- package/src/add/add-rpc-invocations.ts +41 -0
- package/src/add/pii-check.test.ts +5 -2
- package/src/add/rpc-type-cast.test.ts +123 -0
- package/src/error-codes.ts +2 -0
- package/src/inspector.ts +2 -0
- package/src/types.ts +12 -0
- package/src/utils/filter-inspector-state.test.ts +110 -1
- package/src/utils/filter-inspector-state.ts +13 -7
- package/src/utils/get-files-and-methods.ts +6 -0
- package/src/utils/load-addon-functions-meta.ts +23 -0
- package/src/utils/post-process.ts +8 -1
- package/src/utils/resolve-deploy-target.test.ts +30 -0
- package/src/utils/resolve-deploy-target.ts +5 -3
- 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/utils/workflow/graph/workflow-graph.types.ts +0 -2
- package/src/visit.ts +6 -0
- 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.
|
|
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
|
}
|
|
@@ -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
|
+
})
|
package/src/error-codes.ts
CHANGED
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 {
|
|
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(
|
|
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
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
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 {
|
|
1085
|
-
if (funcMeta?.
|
|
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.
|
|
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
|
|
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
|
*/
|